Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Test MakePay Invoice Ninja
on: [push, pull_request]
jobs:
native-integration:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.4
env:
MYSQL_ROOT_PASSWORD: makepay-local-only
MYSQL_DATABASE: ninja
ports: ['3306:3306']
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -pmakepay-local-only"
--health-interval=10s --health-timeout=5s --health-retries=10
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: bcmath, curl, gd, intl, mbstring, pdo_mysql, zip
coverage: none
- run: php tests/protocol.php
- name: Fetch pinned upstream application
run: |
git init /tmp/invoiceninja
git -C /tmp/invoiceninja remote add origin https://github.com/invoiceninja/invoiceninja.git
git -C /tmp/invoiceninja fetch --depth=1 origin f1ffa5f9989afbed4e7dc80b5c0cc18944335ffc
git -C /tmp/invoiceninja checkout FETCH_HEAD
composer install --working-dir=/tmp/invoiceninja --no-interaction --prefer-dist --no-progress
- name: Prepare isolated local application
run: |
cp tests/ci.env /tmp/invoiceninja/.env
cd /tmp/invoiceninja
php artisan key:generate
php artisan migrate --force
php artisan db:seed --force
- name: Install twice and exercise native payment allocation
run: |
php scripts/install.php /tmp/invoiceninja
php scripts/install.php /tmp/invoiceninja
php -d memory_limit=512M tests/integration.php /tmp/invoiceninja
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.env
vendor/
output/
dist/
*.log

output-fixture.json
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 MakePay

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
104 changes: 104 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# MakePay for Invoice Ninja

**1.0.0-rc.1:** connection and local integration checks passed; funded settlement remains a rollout prerequisite. See [validation evidence](docs/TESTING.md).

A hosted crypto payment gateway for **self-hosted Invoice Ninja v5**, with native
merchant connection fields and a client portal checkout screen. Built against
Invoice Ninja's BTCPay, Custom and BaseDriver conventions. This is a source
extension for self-hosted installations; it cannot be installed into Invoice
Ninja's hosted SaaS without their cooperation.

## Supported behavior

- Invoice currencies such as EUR and USD, including Invoice Ninja's calculated gateway fees and partial
payment amounts. Customers choose supported crypto assets on MakePay checkout.
- MakePay company/API key connection fields in **Settings → Payment Settings → Configure Gateways**.
- Regular and Livewire client checkout pages.
- Signed asynchronous payment confirmation, native payment records, invoice
balance updates and receipts.
- Persistent link reuse, duplicate webhook protection and company/gateway binding.
- Optional MakePay buttons in invoice PDFs, invoice emails and reminder emails.

The invoice currency is sent as `fiatCurrency`; the company's configured crypto
settlement asset is separate. Currency precision comes from Invoice Ninja.
Callbacks verify the saved fiat amount and currency, rather than the token symbol.
Refunds are handled in MakePay and recorded manually after settlement. Saved
payment methods and automatic recurring debits are not supported.

## Install

Back up your application and database. Download this repository to a separate
folder on your Invoice Ninja host. PHP 8.2+ and an installed Invoice Ninja v5
application are required.

```bash
php scripts/install.php /absolute/path/to/invoiceninja
```

The installer copies the driver/views, adds provider-based guards to
`Gateway::getHelp()` and `Gateway::getMethods()`, saves the original model as
`Gateway.php.makepay-backup`, registers MakePay in the existing gateways table,
and clears Laravel caches. It never stores merchant secrets. It is idempotent.
Run the installer again after Invoice Ninja upgrades, then rerun integration
checks against that version. Review the small model patch before a major upgrade.

## Connect

1. In MakeCrypto create a company-specific API key with
`makepay:payment-links:read` and `makepay:payment-links:write`.
2. Configure a supported stablecoin settlement route for that company.
3. Copy the company ID, API key ID/secret and company webhook secret.
4. In Invoice Ninja open **Settings → Payment Settings → Configure Gateways → Create** and
choose **MakePay**.
5. Enter `companyId`, `apiKeyId`, `apiKeySecret`, and `webhookSecret`. Invoice
Ninja encrypts gateway configuration. Enable the Crypto payment method.
6. Use Invoice Ninja's native webhook URL for the company gateway:

```text
https://your-ninja-domain/payment_webhook/COMPANY_KEY/HASHED_COMPANY_GATEWAY_ID
```

Copy the saved gateway's **Webhook URL** from its Overview screen and set that HTTPS URL under **Integrations → API Integrations → Callback URL configuration** in MakeCrypto. The ID
is the hashed **company gateway** ID, not the global gateway ID. Invoice Ninja
exposes the company key and hashed company gateway ID in its API responses.
A MakeCrypto company has one default callback destination: use separate company
credentials for separate stores or route callbacks through a reviewed dispatcher.

## Checkout and verification

Open an unpaid invoice in the client portal, choose Crypto/MakePay, and click
**Continue to MakePay**. The server obtains the payable amount from Invoice
Ninja's payment hash, creates a hosted link, and redirects the customer. The
invoice becomes paid only after a verified `session.status = complete` webhook.
Pending, underpaid, failed, expired, and cancelled sessions remain unpaid.

If checkout creation has an ambiguous timeout, the saved `makepay.state` remains
`creating` to prevent a duplicate charge. Inspect MakeCrypto payment links for
`orderId = ninja_PAYMENT_HASH` and reconcile the saved UID/URL before allowing
another attempt. Never delete a reservation without checking provider history.

## PDF and email buttons

Use the included [PDF and email templates](docs/PDF-AND-EMAIL.md) to add **Pay with
MakePay** buttons. They open the invoice portal, where the customer selects
Crypto / MakePay. Invoice Ninja supports reminders, PDF email attachments and
native payment receipts. The guide explains setup and direct-link behavior when
multiple gateways are configured.

## Tests

```bash
php tests/protocol.php
php scripts/install.php /path/to/disposable/invoiceninja
php tests/integration.php /path/to/disposable/invoiceninja
```

The upstream app must have its dependencies installed, a local/testing `.env`,
and a migrated/seeded disposable MySQL database. Integration tests create fake
companies, clients and invoices, mock the MakePay API, then exercise the actual
native payment service and invoice balance updates. No real crypto is transferred.
See [upstream reference](docs/UPSTREAM-REFERENCE.md) for reviewed gateway code.

## Tutorial

See the [screenshot and video walkthrough](docs/TUTORIAL.md) and [validation record](docs/TESTING.md).
32 changes: 32 additions & 0 deletions app/PaymentDrivers/MakePay/Protocol.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php
namespace App\PaymentDrivers\MakePay;

final class Protocol
{
public static function verify(string $raw, string $header, string $secret, ?int $now = null): bool
{
if ($secret === '' || !preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/D', $header, $m)) return false;
if (abs(($now ?? time()) - (int) $m[1]) > 300) return false;
return hash_equals(hash_hmac('sha256', $m[1].'.'.$raw, $secret), $m[2]);
}
public static function amount($value, int $precision = 2): string
{
$s = (string) $value;
if ($precision < 0 || $precision > 8 || !preg_match('/^\d+(?:\.\d{1,8})?$/D', $s) || strlen($s) > 30 || !preg_match('/[1-9]/', $s)) throw new \InvalidArgumentException('Invalid checkout amount.');
[$whole, $fraction] = array_pad(explode('.', $s, 2), 2, '');
if (strlen(rtrim($fraction, '0')) > $precision) throw new \InvalidArgumentException('Amount exceeds the invoice currency precision.');
$whole = ltrim($whole, '0') ?: '0';
return $whole.($precision ? '.'.str_pad(substr($fraction, 0, $precision), $precision, '0') : '');
}
public static function matches(array $event, array $saved, string $hash): bool
{
$link = $event['paymentLink'] ?? [];
try {
return ($link['uid'] ?? '') === ($saved['uid'] ?? null)
&& ($link['merchantOrderId'] ?? '') === 'ninja_'.$hash
&& ($link['fiatCurrency'] ?? (($link['currency'] ?? '') === 'USD' ? 'USD' : null)) === ($saved['currency'] ?? null)
&& self::amount($link['fiatAmount'] ?? $link['amount'] ?? '', $saved['precision'] ?? 2) === ($saved['amount'] ?? null)
&& is_string($event['session']['id'] ?? null) && $event['session']['id'] !== '';
} catch (\Throwable) { return false; }
}
}
118 changes: 118 additions & 0 deletions app/PaymentDrivers/MakePayPaymentDriver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php
namespace App\PaymentDrivers;

use App\Exceptions\PaymentFailed;
use App\Models\Client;
use App\Models\GatewayType;
use App\Models\Payment;
use App\Models\PaymentHash;
use App\Models\PaymentType;
use App\PaymentDrivers\MakePay\Protocol;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;

class MakePayPaymentDriver extends BaseDriver
{
public $refundable = false;
public $token_billing = false;
public $can_authorise_credit_card = false;
public static $methods = [GatewayType::CRYPTO => self::class];

public function gatewayTypes(): array { return [GatewayType::CRYPTO]; }
public function setPaymentMethod($payment_method_id) { $this->payment_method = GatewayType::CRYPTO; return $this; }
public function init() { return $this; }
public function auth(): string
{
try {
if (!$this->company_gateway->getConfigField('webhookSecret')) return 'error';
$this->api('GET'); return 'ok';
} catch (\Throwable) { return 'error'; }
}
private function api(string $method, ?array $body = null): array
{
$id = (string) $this->company_gateway->getConfigField('companyId');
$key = (string) $this->company_gateway->getConfigField('apiKeyId');
$secret = (string) $this->company_gateway->getConfigField('apiKeySecret');
if ($id === '' || $key === '' || $secret === '') throw new PaymentFailed('Configure MakePay company ID and API credentials.');
// Production destination is fixed; credentials never follow redirects.
$request = Http::withOptions(['allow_redirects' => false])->acceptJson()->timeout(20)->connectTimeout(5)
->withHeaders(['X-MakeCrypto-Key-Id' => $key, 'X-MakeCrypto-Key-Secret' => $secret]);
$url = 'https://www.makecrypto.io/api/partner/v1/companies/'.rawurlencode($id).'/payment-links';
$response = $method === 'GET' ? $request->get($url) : $request->post($url, $body);
if (!$response->successful()) throw new PaymentFailed('MakePay could not create checkout. Check credentials, settlement and minimum payment amount.');
return $response->json();
}
public function processPaymentView(array $data) { return render('gateways.makepay.pay', $this->processPaymentViewData($data)); }
public function livewirePaymentView(array $data): string { return 'gateways.makepay.pay_livewire'; }
public function processPaymentViewData(array $data): array
{
return array_merge($data, ['gateway' => $this, 'payment_hash' => $this->payment_hash->hash,
'amount' => $this->payment_hash->amount_with_fee(), 'currency' => $this->client->getCurrencyCode(), 'payment_method_id' => GatewayType::CRYPTO]);
}
public function processPaymentResponse($request)
{
$currency = $this->client->getCurrencyCode();
if (!preg_match('/^[A-Z]{3}$/D', $currency)) throw new PaymentFailed('Invalid invoice currency.');
$precision = (int) \App\Models\Currency::where('code', $currency)->firstOrFail()->precision;
$hash = $this->payment_hash->hash;
$saved = DB::transaction(function () use ($hash, $currency, $precision) {
$row = PaymentHash::where('hash', $hash)->lockForUpdate()->firstOrFail();
$state = (array) ($row->data->makepay ?? []);
if ($state) {
if (($state['client_id'] ?? null) !== $this->client->id || ($state['gateway_id'] ?? null) !== $this->company_gateway->id || ($state['currency'] ?? null) !== $currency || ($state['amount'] ?? null) !== Protocol::amount($row->amount_with_fee(), $precision)) throw new PaymentFailed('Checkout account or price changed.');
if (isset($state['url'])) return $state;
throw new PaymentFailed('Checkout creation is pending. Contact the merchant before retrying.');
}
$state = ['client_id' => $this->client->id, 'gateway_id' => $this->company_gateway->id,
'amount' => Protocol::amount($row->amount_with_fee(), $precision), 'currency' => $currency, 'precision' => $precision, 'state' => 'creating'];
$row->data = array_merge((array) $row->data, ['makepay' => $state]); $row->save();
return $state;
});
if (isset($saved['url'])) return redirect()->away($saved['url']);
$invoice = $this->payment_hash->invoices()[0] ?? null;
if (!$invoice) throw new PaymentFailed('Invoice reference missing.');
$return = route('client.invoice.show', ['invoice' => $invoice->invoice_id]);
$result = $this->api('POST', ['source' => 'invoice-ninja', 'status' => 'active', 'sendPaymentRequestEmail' => false,
'payload' => ['title' => 'Invoice '.$invoice->invoice_number, 'amount' => $saved['amount'], 'fiatCurrency' => $saved['currency'],
'orderId' => 'ninja_'.$hash, 'clientEmail' => $this->client->present()->email(),
'returnUrl' => $return, 'successUrl' => $return, 'failureUrl' => $return, 'expirationTime' => '12h']]);
$link = $result['paymentLink'] ?? [];
$url = $link['publicUrl'] ?? '';
if (empty($link['uid']) || !filter_var($url, FILTER_VALIDATE_URL) || parse_url($url, PHP_URL_SCHEME) !== 'https' || parse_url($url, PHP_URL_USER) !== null) throw new PaymentFailed('MakePay returned an invalid hosted checkout.');
$saved = array_merge($saved, ['uid' => $link['uid'], 'url' => $url, 'state' => 'pending']);
DB::transaction(function () use ($hash, $saved) {
$row = PaymentHash::where('hash', $hash)->lockForUpdate()->firstOrFail();
$row->data = array_merge((array) $row->data, ['makepay' => $saved]); $row->save();
});
return redirect()->away($url);
}
public function processWebhookRequest($request = null)
{
$request = $request ?? request();
$raw = $request->getContent();
if (strlen($raw) > 262144) return response()->json(['error' => 'Payload too large'], 413);
if (!Protocol::verify($raw, (string) $request->header('X-MakePay-Signature'), (string) $this->company_gateway->getConfigField('webhookSecret'))) return response()->json(['error' => 'Invalid signature'], 401);
try { $event = json_decode($raw, true, 32, JSON_THROW_ON_ERROR); }
catch (\Throwable) { return response()->json(['error' => 'Invalid JSON'], 400); }
if (($event['session']['status'] ?? '') !== 'complete') return response()->json(['status' => 'ignored']);
$order = $event['paymentLink']['merchantOrderId'] ?? '';
if (!is_string($order) || !str_starts_with($order, 'ninja_')) return response()->json(['error' => 'Invalid reference'], 400);
$hash = substr($order, 6);
try {
return DB::transaction(function () use ($hash, $event) {
$row = PaymentHash::where('hash', $hash)->lockForUpdate()->first();
$saved = (array) ($row?->data->makepay ?? []);
if (!$row || ($saved['gateway_id'] ?? null) !== $this->company_gateway->id || !Protocol::matches($event, $saved, $hash)) return response()->json(['error' => 'Checkout mismatch'], 400);
$this->client = Client::where('id', $saved['client_id'])->where('company_id', $this->company_gateway->company_id)->firstOrFail();
$this->payment_hash = $row;
if ($row->payment_id) return response()->json(['status' => 'already_completed']);
$this->createPayment(['payment_method' => GatewayType::CRYPTO, 'payment_type' => PaymentType::CRYPTO,
'amount' => $saved['amount'], 'transaction_reference' => $saved['uid'], 'gateway_type_id' => GatewayType::CRYPTO], Payment::STATUS_COMPLETED);
return response()->json(['status' => 'completed']);
});
} catch (\Throwable $error) {
report($error); return response()->json(['error' => 'Temporary payment processing failure'], 503);
}
}
public function refund(Payment $payment, $refund_amount, $return_client_response = false) { throw new PaymentFailed('Issue the refund in MakePay and record it in Invoice Ninja after settlement.'); }
}
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"makepay/invoice-ninja","description":"MakePay hosted crypto gateway for self-hosted Invoice Ninja v5","type":"library","license":"MIT","require":{"php":"^8.2"},"scripts":{"test":"php tests/protocol.php"}}
Loading
Loading