diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..599e3f5 --- /dev/null +++ b/.github/workflows/test.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21c736c --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +vendor/ +output/ +dist/ +*.log + +output-fixture.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..821e0b1 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..91d641b --- /dev/null +++ b/README.md @@ -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). diff --git a/app/PaymentDrivers/MakePay/Protocol.php b/app/PaymentDrivers/MakePay/Protocol.php new file mode 100644 index 0000000..7090bdd --- /dev/null +++ b/app/PaymentDrivers/MakePay/Protocol.php @@ -0,0 +1,32 @@ + 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; } + } +} diff --git a/app/PaymentDrivers/MakePayPaymentDriver.php b/app/PaymentDrivers/MakePayPaymentDriver.php new file mode 100644 index 0000000..fba5f83 --- /dev/null +++ b/app/PaymentDrivers/MakePayPaymentDriver.php @@ -0,0 +1,118 @@ + 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.'); } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..73f7b87 --- /dev/null +++ b/composer.json @@ -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"}} diff --git a/docs/PDF-AND-EMAIL.md b/docs/PDF-AND-EMAIL.md new file mode 100644 index 0000000..b095c7f --- /dev/null +++ b/docs/PDF-AND-EMAIL.md @@ -0,0 +1,46 @@ +# MakePay buttons in invoice PDFs and emails + +Invoice Ninja supports a clickable **Pay with MakePay** button in an invoice PDF, invoice email and reminder email. These examples use Invoice Ninja's invitation URL, so the customer opens the current invoice and selects **Crypto / MakePay**. They contain no API credentials and do not create a payment merely by opening an email or PDF. + +## Invoice PDF + +1. Open **Settings → Invoice Design → Customize & Preview** and copy your existing design to a custom design. +2. Insert [pdf-payment-button.html](../examples/pdf-payment-button.html) into the design's **Body**, after the invoice totals. If the design contains a closing ``, insert the fragment before it. +3. Preview the design, save it and select it as your invoice design. +4. Download an unpaid invoice PDF. Click its button and verify that it opens that invoice in your public client portal. Choose Crypto / MakePay to continue. + +The template also prints the complete payment URL below the button. If Invoice Ninja's PDF sanitizer removes the button's link, the template hides that button and leaves the URL available to copy. Use a public HTTPS domain for Invoice Ninja's application URL: the native sanitizer removes links containing localhost/IP addresses or ports. Our public-domain rendering used `https://billing.example.com` to verify clickable PDF annotations; the sample domain is illustrative. + +For fully local PDF testing, we used Invoice Ninja's supported Gotenberg renderer with `PDF_GENERATOR=gotenberg` and a loopback-only `GOTENBERG_API_URL`. The hosted renderer used earlier removed the localhost IP even from the visible URL. See [Gotenberg installation](https://gotenberg.dev/docs/getting-started/installation); keep its API private. + +![Native invoice PDF with a clickable MakePay button](screenshots/06-pdf-payment-button.png) + +## Invoice emails and reminders + +Open **Settings → Templates & Reminders**. Select the **Invoice** template and use [email-invoice.html](../examples/email-invoice.html) in its HTML/source editor, preserving any company-specific message you need. Use [email-reminder.html](../examples/email-reminder.html) in each reminder template you enable. Save and preview each template. + +Configure reminder schedules in the same settings area. Configure your normal mail provider/SMTP transport in Invoice Ninja, and enable **Attach PDF** if you want the customized invoice PDF included with the email. Send a test invoice to an address you control and check both the HTML button and PDF attachment in the receiving mail client before sending to customers. + +![Invoice email body rendered by Invoice Ninja](screenshots/07-invoice-email.png) + +## Choosing the right native link + +| Variable | Behavior | +| --- | --- | +| `$view_url` | Opens this invoice's client portal page. Used by our MakePay examples so customers can choose MakePay when several gateways exist. | +| `$payment_link` | Starts Invoice Ninja's instant-payment route using its first available gateway. Use it for a direct MakePay button only when MakePay is the first/only available gateway, and verify that ordering. | +| `$payment_button` | Invoice Ninja's styled payment button using the same instant-payment route. | + +Keep invitation links private to the intended customer. Use the native variables rather than hardcoding an invitation or hosted checkout URL, so each invoice receives its own link and current balance. + +## Payment receipts + +Enable Invoice Ninja's **Online Payment Notifications** for the company/client and customize its **Payment** email template in Templates & Reminders. A verified completed MakePay callback creates a native payment; Invoice Ninja then queues its usual payment receipt. Duplicate callbacks do not queue another receipt. Pending or failed sessions do not send a paid receipt. + +Payment email variables describe the payment, so `$view_url` in that template opens the payment receipt. Keep a receipt's action labeled **View payment** rather than presenting an already-paid invoice as a new MakePay checkout. + +## Validation + +The native test suite renders the invoice and reminder examples through `InvoiceEmailEngine`, processes the PDF fragment through `PdfService` and its sanitizer, and verifies one receipt queue job after payment completion and no second job after duplicate delivery. A real native PDF was rendered, visually inspected and checked for its clickable URI annotation. Local mail/queue fakes were used; delivery to an external mailbox has not been tested. + +See Invoice Ninja's [email customization](https://invoiceninja.github.io/docs/advanced-topics/email-customization) and [advanced settings](https://invoiceninja.github.io/docs/user-guide/advanced-settings) documentation for the host application's settings. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..a4ea6c5 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,33 @@ +# Validation record + +Recorded 2026-10-11. Candidate release: 1.0.0-rc.1. + +## Upstream and environment + +Invoice Ninja `v5-stable`, commit `f1ffa5f9989afbed4e7dc80b5c0cc18944335ffc`; PHP 8.5.5 locally, MySQL 8.4, Laravel's native client portal at localhost. The installer ran twice successfully without duplicate gateway registrations. CI uses PHP 8.4 and the same pinned upstream commit. + +## Automated checks + +`php tests/protocol.php`: 22 checks for HMAC verification, timestamp window, exact amounts and payment reference matching. + +`php -d memory_limit=512M tests/integration.php /path/to/disposable/invoiceninja`: 38 native checks, including driver/settings discovery, merchant API connection-field serialization, client payment-method discovery, credential validation, server totals, persistent link reuse, native invoice/reminder HTML rendering, sanitized PDF design links and visible URL fallbacks, one-time receipt queueing, status filtering, stale signatures, wrong UID/amount/currency, company gateway binding, native payment allocation, duplicate delivery, HTTP routing, native fee quote, fee-inclusive partial payment and one-time fee recording. Requests to MakePay are mocked; the upstream models, MySQL transactions, fee calculations and payment service are real. + +The HTTP endpoint was also exercised through the running Laravel server: an invalid signature returned 401 and a duplicate complete callback returned 200 `already_completed`. The native route is `/payment_webhook/{company_key}/{hashed_company_gateway_id}`; it is outside the `/api/v1` route group. + +## Browser and real API checks + +The real native portal offered Crypto, displayed the MakePay gateway screen, and redirected through the production MakePay API to hosted Demo Shop 2 checkouts displaying **10 USD** and **10 EUR**. A dedicated approved merchant QA key and signing secret were stored in encrypted gateway configuration. No secrets are committed or included in tutorial images. + +A native custom invoice PDF containing the MakePay button was rendered, visually inspected and checked for its clickable URI annotation. Invoice and reminder HTML was rendered through the native email engine. Receipt queue behavior passed with mail/queue fakes; external mailbox delivery has not been tested. See [PDF/email setup](PDF-AND-EMAIL.md). + +The native merchant application login succeeded, and **Configure Gateways → Create → MakePay** displayed all four credential fields. The installer stores the definition using the upstream JSON-string convention required by `GatewayTransformer`; this is covered by a regression check. + +The QA credentials were also entered and saved through the native form. The real API credential check returned HTTP 200. Local Gotenberg PDF rendering verified that a localhost PDF hides the stripped button and retains the full visible URL; public-domain rendering retains clickable button/link annotations. The native Download PDF action was checked against the resulting downloaded file. + +The Paid screenshot is from a local signed-callback simulation. The hosted-checkout screenshot is from the real API connection. No crypto was transferred. Localhost cannot receive Internet callbacks without a public HTTPS route; a funded payment and automatic provider delivery remain required before customer rollout. + +## Reproduce + +Use a disposable local/testing Invoice Ninja application with its dependencies installed and database migrated/seeded. See `tests/ci.env` and the GitHub Actions workflow. Run the installer, protocol and integration checks. To create an unpaid browser fixture, pass `--portal-fixture` to the integration script; it prints an invitation URL. `output-fixture.json` is ignored and contains test references only. + +The native automated suite now creates and completes an EUR invoice, preserving `fiatCurrency` independently of USDC settlement. It also rejects mismatched fiat amounts/currencies. Protocol checks cover zero and three decimal price precision. diff --git a/docs/TUTORIAL.md b/docs/TUTORIAL.md new file mode 100644 index 0000000..90a60b9 --- /dev/null +++ b/docs/TUTORIAL.md @@ -0,0 +1,51 @@ +# Invoice Ninja merchant and customer walkthrough + +Use self-hosted Invoice Ninja v5. The invoice currency is preserved in MakePay checkout. See the README for installation and the four encrypted gateway connection fields. Configure a stablecoin settlement route in MakeCrypto and set the company's callback to Invoice Ninja's native gateway webhook URL. + +[Watch the captioned walkthrough](makepay-invoice-ninja-tutorial.mp4). + +## Connect in native merchant settings + +Open **Settings → Payment Settings → Configure Gateways → Create**, search for MakePay and select it. Enter the four approved company credentials, save the gateway and enable Crypto. Invoice Ninja encrypts the saved configuration. + +![MakePay connection fields in Invoice Ninja's native gateway form](screenshots/00-native-gateway-settings.png) + +## Choose the native payment option + +Open an unpaid invoice and choose Crypto. Invoice Ninja performs its normal contact/address checks before displaying the gateway. + +![Crypto is offered in the native invoice portal](screenshots/01-native-payment-option.jpg) + +## Review the payable amount + +The MakePay screen shows Invoice Ninja's server-calculated total, including configured fees. Click **Continue to MakePay**. Browser-submitted amount and currency fields cannot override the server amount. + +![Native MakePay checkout screen](screenshots/02-makepay-checkout.jpg) + +## Continue on hosted checkout + +A real Demo Shop 2 API connection created this $10 USD payment link from the local Invoice Ninja portal. The customer chooses a supported asset/network on MakePay. A browser return alone does not confirm payment. + +![Real MakePay hosted checkout opened from the local native gateway](screenshots/04-hosted-checkout.jpg) + +## Confirm through a signed callback + +A complete callback must match the saved UID, payment hash, fiat amount/currency and company gateway. Invoice Ninja's native payment service allocates it to the invoice. The following **Paid** view comes from a local signed-callback simulation; no real funds were transferred. + +![Paid invoice after the local native callback simulation](screenshots/03-paid-invoice.jpg) + +For localhost, simulate signed callbacks against a disposable installation. A public HTTPS endpoint is needed for real provider delivery; `127.0.0.1` cannot receive Internet callbacks. Use separate merchant companies for Wix and Invoice Ninja when they need different default callbacks. + +See [testing evidence](TESTING.md) for the exact upstream version, test commands and limits. + +## Preserve the invoice currency + +An EUR invoice also redirects from the native portal to a checkout displaying **10 EUR**. The price currency stays separate from the customer's chosen crypto asset. + +![Native EUR invoice redirected to MakePay](screenshots/05-eur-hosted-checkout.png) + +## Add PDF and email buttons + +Follow the [PDF and email setup guide](PDF-AND-EMAIL.md) to customize the invoice design and Invoice/Reminder templates. The button opens the invoice portal; customers select Crypto / MakePay there. + +![Native invoice PDF with MakePay button](screenshots/06-pdf-payment-button.png) diff --git a/docs/UPSTREAM-REFERENCE.md b/docs/UPSTREAM-REFERENCE.md new file mode 100644 index 0000000..bba06c4 --- /dev/null +++ b/docs/UPSTREAM-REFERENCE.md @@ -0,0 +1,31 @@ +# Invoice Ninja gateway implementation references + +This integration follows the structure of Invoice Ninja's maintained gateways. +The MakePay implementation is original code; upstream classes remain in Invoice +Ninja and are not redistributed in this package. + +Reviewed upstream: `invoiceninja/invoiceninja`, `v5-stable`, commit +`f1ffa5f9989afbed4e7dc80b5c0cc18944335ffc` (11 October 2026). + +- `app/PaymentDrivers/BTCPayPaymentDriver.php`: gateway discovery, credential + validation, crypto method registration and webhook entry point. +- `app/PaymentDrivers/BTCPay/BTCPay.php`: hosted redirect, client currency and + invoice number, native portal data, regular and Livewire views. +- `app/PaymentDrivers/CustomPaymentDriver.php`: provider configuration and + portal layout conventions. +- `app/PaymentDrivers/BaseDriver.php`: `createPayment()` performs gateway fee + confirmation, attaches invoices, updates balances and creates native receipts. +- `app/Models/CompanyGateway.php`: encrypted configuration and provider-based + class discovery. +- `app/Http/Requests/Payments/PaymentWebhookRequest.php`: existing company-scoped + webhook route. + +Differences necessary for MakePay: do not trust browser amount/currency; reserve +creation in the database; verify timestamped raw-body HMAC before accessing a +payment hash; match UID, fiat amount/currency and merchant order reference; only a session +whose status is exactly `complete` can complete payment; serialize callbacks +with a row lock; prohibit cross-gateway/company callbacks. A redirect never +creates a payment. Refunds and automatic recurring debits are unsupported. + +Native integration tests exercise the installed driver against the upstream +Laravel app and MySQL, including the actual balance update through `BaseDriver`. diff --git a/docs/makepay-invoice-ninja-tutorial.mp4 b/docs/makepay-invoice-ninja-tutorial.mp4 new file mode 100644 index 0000000..9228804 Binary files /dev/null and b/docs/makepay-invoice-ninja-tutorial.mp4 differ diff --git a/docs/screenshots/00-native-gateway-settings.png b/docs/screenshots/00-native-gateway-settings.png new file mode 100644 index 0000000..0bf4be4 Binary files /dev/null and b/docs/screenshots/00-native-gateway-settings.png differ diff --git a/docs/screenshots/01-native-payment-option.jpg b/docs/screenshots/01-native-payment-option.jpg new file mode 100644 index 0000000..d703fbf Binary files /dev/null and b/docs/screenshots/01-native-payment-option.jpg differ diff --git a/docs/screenshots/02-makepay-checkout.jpg b/docs/screenshots/02-makepay-checkout.jpg new file mode 100644 index 0000000..60e1398 Binary files /dev/null and b/docs/screenshots/02-makepay-checkout.jpg differ diff --git a/docs/screenshots/03-paid-invoice.jpg b/docs/screenshots/03-paid-invoice.jpg new file mode 100644 index 0000000..536b86b Binary files /dev/null and b/docs/screenshots/03-paid-invoice.jpg differ diff --git a/docs/screenshots/04-hosted-checkout.jpg b/docs/screenshots/04-hosted-checkout.jpg new file mode 100644 index 0000000..a897496 Binary files /dev/null and b/docs/screenshots/04-hosted-checkout.jpg differ diff --git a/docs/screenshots/05-eur-hosted-checkout.png b/docs/screenshots/05-eur-hosted-checkout.png new file mode 100644 index 0000000..0e8bdba Binary files /dev/null and b/docs/screenshots/05-eur-hosted-checkout.png differ diff --git a/docs/screenshots/06-pdf-payment-button.png b/docs/screenshots/06-pdf-payment-button.png new file mode 100644 index 0000000..72a9383 Binary files /dev/null and b/docs/screenshots/06-pdf-payment-button.png differ diff --git a/docs/screenshots/07-invoice-email.png b/docs/screenshots/07-invoice-email.png new file mode 100644 index 0000000..a9a1b71 Binary files /dev/null and b/docs/screenshots/07-invoice-email.png differ diff --git a/examples/email-invoice.html b/examples/email-invoice.html new file mode 100644 index 0000000..b0320c1 --- /dev/null +++ b/examples/email-invoice.html @@ -0,0 +1,8 @@ +

Hello $contact.first_name,

+

Your invoice $number for $amount is ready.

+
+ Pay with MakePay +
+

Open invoice checkout, select Crypto / MakePay and choose your payment asset and network.

+

$view_link

+

$signature

diff --git a/examples/email-reminder.html b/examples/email-reminder.html new file mode 100644 index 0000000..50262fc --- /dev/null +++ b/examples/email-reminder.html @@ -0,0 +1,7 @@ +

Hello $contact.first_name,

+

Invoice $number has an outstanding balance of $amount.

+
+ Pay with MakePay +
+

Open invoice checkout and select Crypto / MakePay. If you have already paid, please check your invoice for the latest status.

+

$signature

diff --git a/examples/pdf-payment-button.html b/examples/pdf-payment-button.html new file mode 100644 index 0000000..a89a018 --- /dev/null +++ b/examples/pdf-payment-button.html @@ -0,0 +1,9 @@ + + +
+ Pay with MakePay +

Open secure invoice checkout and select Crypto / MakePay.

+

Payment link: $view_url

+
diff --git a/resources/views/portal/ninja2020/gateways/makepay/content.blade.php b/resources/views/portal/ninja2020/gateways/makepay/content.blade.php new file mode 100644 index 0000000..38b3ccd --- /dev/null +++ b/resources/views/portal/ninja2020/gateways/makepay/content.blade.php @@ -0,0 +1,14 @@ +
+

MakePay · Crypto checkout

+

Pay your invoice with crypto

+

Choose your currency and network on secure MakePay checkout. Your invoice updates after payment confirmation.

+ @include('portal.ninja2020.gateways.includes.payment_details') +
+ @csrf + + + + +
+

Returning to this invoice does not confirm payment. Keep your transaction reference until the invoice is paid.

+
diff --git a/resources/views/portal/ninja2020/gateways/makepay/pay.blade.php b/resources/views/portal/ninja2020/gateways/makepay/pay.blade.php new file mode 100644 index 0000000..c00693a --- /dev/null +++ b/resources/views/portal/ninja2020/gateways/makepay/pay.blade.php @@ -0,0 +1,4 @@ +@extends('portal.ninja2020.layout.payments', ['gateway_title' => 'MakePay', 'card_title' => 'Crypto payment']) +@section('gateway_content') + @include('portal.ninja2020.gateways.makepay.content') +@endsection diff --git a/resources/views/portal/ninja2020/gateways/makepay/pay_livewire.blade.php b/resources/views/portal/ninja2020/gateways/makepay/pay_livewire.blade.php new file mode 100644 index 0000000..47d1865 --- /dev/null +++ b/resources/views/portal/ninja2020/gateways/makepay/pay_livewire.blade.php @@ -0,0 +1,3 @@ +
+ @include('portal.ninja2020.gateways.makepay.content') +
diff --git a/scripts/install.php b/scripts/install.php new file mode 100644 index 0000000..7791216 --- /dev/null +++ b/scripts/install.php @@ -0,0 +1,43 @@ +provider === 'MakePay') return 'https://github.com/makepay-apps/makepay-invoice-ninja';\n" + : "\n // MakePay getMethods\n if (\$this->provider === 'MakePay') return [GatewayType::CRYPTO => ['refund' => false, 'token_billing' => false, 'webhooks' => ['makepay.payment.status_changed']]];\n"; + $model = preg_replace_callback($pattern, fn($m) => $m[1].$guard, $model, 1, $count); + if ($count !== 1) throw new RuntimeException('Unsupported Gateway model; no files changed.'); +} +if (!is_file($modelPath.'.makepay-backup')) copy($modelPath, $modelPath.'.makepay-backup'); +file_put_contents($modelPath, $model); +foreach (['app/PaymentDrivers', 'resources/views'] as $folder) { + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root.'/'.$folder, FilesystemIterator::SKIP_DOTS)); + foreach ($iterator as $file) { + if (!$file->isFile()) continue; + $relative = substr($file->getPathname(), strlen($root)+1); + if (!is_dir(dirname($target.'/'.$relative))) mkdir(dirname($target.'/'.$relative), 0755, true); + copy($file->getPathname(), $target.'/'.$relative); + } +} +require $target.'/vendor/autoload.php'; +$app = require $target.'/bootstrap/app.php'; +$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); +set_exception_handler(function (Throwable $error) { fwrite(STDERR, $error->getMessage().PHP_EOL); exit(1); }); +$gateway = App\Models\Gateway::where('provider', 'MakePay')->first() ?? new App\Models\Gateway(); +$gateway->provider = 'MakePay'; +$gateway->name = 'MakePay'; +$gateway->key = $gateway->key ?: substr(hash('sha256', 'makepay-invoice-ninja-v1'), 0, 32); +$gateway->is_offsite = true; $gateway->visible = true; +$gateway->site_url = 'https://www.makepay.io'; $gateway->default_gateway_type_id = App\Models\GatewayType::CRYPTO; +// Upstream gateways store a JSON string through the model's object cast. +// The merchant API transformer expects that string and the UI decodes it. +$gateway->fields = json_encode(['companyId' => '', 'apiKeyId' => '', 'apiKeySecret' => '', 'webhookSecret' => ''], JSON_THROW_ON_ERROR); +$gateway->save(); +Illuminate\Support\Facades\Artisan::call('optimize:clear'); +echo "Installed MakePay. Open Settings > Payment Settings > New Gateway > MakePay.\n"; diff --git a/tests/ci.env b/tests/ci.env new file mode 100644 index 0000000..019a547 --- /dev/null +++ b/tests/ci.env @@ -0,0 +1,23 @@ +APP_NAME="MakePay Invoice Ninja QA" +APP_ENV=testing +APP_DEBUG=false +APP_KEY= +APP_URL=http://127.0.0.1:8130 +REACT_URL=http://127.0.0.1:8130 +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=ninja +DB_USERNAME=root +DB_PASSWORD=makepay-local-only +CACHE_STORE=file +CACHE_DRIVER=file +QUEUE_CONNECTION=sync +SESSION_DRIVER=file +MAIL_MAILER=log +LOG_CHANNEL=stack +REQUIRE_HTTPS=false +NINJA_ENVIRONMENT=selfhost +PDF_GENERATOR=hosted_ninja +SCOUT_DRIVER=null +MULTI_DB_ENABLED=false diff --git a/tests/integration.php b/tests/integration.php new file mode 100644 index 0000000..218ef2f --- /dev/null +++ b/tests/integration.php @@ -0,0 +1,123 @@ +make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); +set_exception_handler(function (Throwable $error) { fwrite(STDERR, $error->getMessage().PHP_EOL); exit(1); }); +if (!app()->environment(['local', 'testing'])) throw new RuntimeException('Only disposable local/testing applications are allowed.'); +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Event; +use App\Models\CompanyGateway; +use App\Models\Gateway; +use App\Models\GatewayType; +use App\Models\PaymentHash; +use App\Models\Payment; +use App\PaymentDrivers\MakePayPaymentDriver; +Queue::fake(); Mail::fake(); Event::fake(); Http::preventStrayRequests(); +$fixture = new class { use Tests\MockAccountData; }; +$fixture->makeTestData(); +$eur=App\Models\Currency::where('code','EUR')->firstOrFail(); +$clientSettings=$fixture->client->settings;$clientSettings->currency_id=$eur->id;$clientSettings->client_online_payment_notification=true;$clientSettings->pdf_email_attachment=false;$clientSettings->document_email_attachment=false;$fixture->client->settings=$clientSettings;$fixture->client->save(); +$gateway = Gateway::where('provider','MakePay')->firstOrFail(); +$cg = new CompanyGateway(); $cg->company_id=$fixture->company->id; $cg->user_id=$fixture->user->id; $cg->gateway_key=$gateway->key; +$cg->setConfig(['companyId'=>'test-company','apiKeyId'=>'test-key','apiKeySecret'=>'test-api-secret','webhookSecret'=>'test-webhook-secret']); +$cg->fees_and_limits = (object) ['4'=>new App\DataMapper\FeesAndLimits()]; +$cg->save(); +$amount=number_format($fixture->invoice->balance,2,'.',''); +$hash=bin2hex(random_bytes(16)); +$ph=new PaymentHash();$ph->hash=$hash;$ph->fee_total=0;$ph->fee_invoice_id=$fixture->invoice->id; +$ph->data=(object)['amount_with_fee'=>$amount,'credits'=>0,'invoices'=>[(object)['invoice_id'=>$fixture->invoice->hashed_id,'invoice_number'=>$fixture->invoice->number,'amount'=>(float)$amount]]];$ph->save(); +$driver=new MakePayPaymentDriver($cg,$fixture->client);$driver->payment_hash=$ph;$driver->setPaymentMethod(GatewayType::CRYPTO); +if (in_array('--portal-fixture', $argv, true)) { + $settings = $fixture->company->settings; + $settings->name = 'MakePay Local QA'; + $fixture->company->settings = $settings; $fixture->company->save(); + $fixture->client->name = 'Local Test Customer'; $fixture->client->save(); + $invitation = $fixture->invoice->invitations->first(); + file_put_contents(__DIR__.'/../output-fixture.json', json_encode(['company_key'=>$fixture->company->company_key, + 'gateway_id'=>$cg->hashed_id, 'invoice_id'=>$fixture->invoice->hashed_id, 'invitation_key'=>$invitation->key, + 'payment_hash'=>$hash, 'amount'=>$amount], JSON_PRETTY_PRINT)); + echo 'Created unpaid local fixture. Open '.url('/client/invoice/'.$invitation->key).PHP_EOL; + exit(0); +} +$count=0; +function verify($bool,$message){global$count;if(!$bool)throw new RuntimeException($message);$count++;echo "PASS $message\n";} +verify($cg->driver() instanceof MakePayPaymentDriver,'Native driver discovery'); +verify(isset($gateway->getMethods()[GatewayType::CRYPTO]),'Native gateway settings metadata'); +$gatewayApi=(new App\Transformers\GatewayTransformer())->transform($gateway); +verify(array_keys(json_decode($gatewayApi['fields'],true,512,JSON_THROW_ON_ERROR))===['companyId','apiKeyId','apiKeySecret','webhookSecret'],'Merchant gateway API renders all four connection fields'); +verify(in_array(GatewayType::CRYPTO, array_column($fixture->client->service()->getPaymentMethods((float)$amount), 'gateway_type_id')), 'Native client portal offers crypto payment'); +Http::fake(['www.makecrypto.io/*'=>Http::response(['paymentLink'=>['uid'=>'mp_'.$hash,'publicUrl'=>'https://www.makepay.io/pay/demo']],201)]); +verify($driver->auth()==='ok','Credential validation'); +$r=$driver->processPaymentResponse(Illuminate\Http\Request::create('/','POST',['amount'=>'0.01','currency'=>'AED'])); +verify($r->getTargetUrl()==='https://www.makepay.io/pay/demo','Hosted checkout redirect'); +Http::assertSent(fn($r)=>$r->method()==='POST' && $r['payload']['amount']===$amount && $r['payload']['fiatCurrency']==='EUR' && !isset($r['payload']['currency']) && $r['source']==='invoice-ninja'); +verify(true,'EUR server price ignores tampered browser fields and preserves fiat currency'); +$driver->payment_hash=$ph->fresh();$driver->processPaymentResponse(Illuminate\Http\Request::create('/','POST'));Http::assertSentCount(2);verify(true,'Repeated checkout reuses the same payment link'); +$invitation=$fixture->invoice->invitations->first(); +$variables=(new App\Utils\HtmlEngine($invitation))->makeValues(); +verify(($variables['$view_url']??'')===$invitation->getLink(),'PDF and email use native invitation URL'); +foreach (['email-invoice'=>'invoice','email-reminder'=>'reminder1'] as $example=>$template) { + $engine=(new App\Mail\Engine\InvoiceEmailEngine($invitation,$template,['subject'=>'Invoice $number','body'=>file_get_contents(__DIR__.'/../examples/'.$example.'.html')]))->build(); + verify(str_contains($engine->getBody(),'Pay with MakePay') && str_contains($engine->getBody(),'href="'.$invitation->getLink().'"') && !str_contains($engine->getBody(),'$view_url'),'Native '.$template.' email renders the MakePay button'); +} +$oldAppUrl=config('ninja.app_url'); +config(['ninja.app_url'=>'https://billing.example.com']); +$baseDesign=App\Models\Design::findOrFail(2); +$designBody=file_get_contents(config('ninja.designs.base_path').strtolower($baseDesign->name).'.html'); +$pdfButton=file_get_contents(__DIR__.'/../examples/pdf-payment-button.html'); +$designBody=str_contains($designBody,'')?str_replace('',$pdfButton.'',$designBody):$designBody.$pdfButton; +$design=$baseDesign->replicate();$design->name='MakePay test PDF';$design->is_custom=true;$design->company_id=$fixture->company->id;$design->user_id=$fixture->user->id; +$design->design=(object)['includes'=>'','header'=>'','body'=>$designBody,'footer'=>''];$design->save(); +$fixture->invoice->design_id=$design->id;$fixture->invoice->save(); +$invitation->unsetRelation('invoice'); +$pdfHtml=(new App\Services\Pdf\PdfService($invitation))->boot()->getHtml(); +verify(str_contains($pdfHtml,'href="'.$invitation->getLink().'"') && str_contains($pdfHtml,'Pay with MakePay'),'Native PDF design and sanitizer retain the clickable MakePay button'); +verify(str_contains($pdfHtml,'Payment link:') && str_contains(strip_tags($pdfHtml),$invitation->getLink()),'PDF includes a visible payment URL fallback'); +config(['ninja.app_url'=>$oldAppUrl]); +$localPdfHtml=(new App\Services\Pdf\PdfService($invitation))->boot()->getHtml(); +verify(str_contains(strip_tags($localPdfHtml),$invitation->getLink()) && str_contains($localPdfHtml,'a.makepay-payment-button:not([href])'),'Local PDF retains the copyable URL and hides a button if its href is removed'); +$e=['type'=>'makepay.payment.status_changed','paymentLink'=>['uid'=>'mp_'.$hash,'merchantOrderId'=>'ninja_'.$hash,'amount'=>$amount,'currency'=>'USDC','fiatAmount'=>$amount,'fiatCurrency'=>'EUR'],'session'=>['id'=>'session_'.$hash,'status'=>'complete']]; +function signedRequest(array $e,int $offset=0){$raw=json_encode($e);$t=time()+$offset;$sig=hash_hmac('sha256',$t.'.'.$raw,'test-webhook-secret');return Illuminate\Http\Request::create('/','POST',[],[],[],['HTTP_X_MAKEPAY_SIGNATURE'=>'t='.$t.',v1='.$sig],$raw);} +$before=Payment::where('company_gateway_id',$cg->id)->count(); +foreach(['underpaid','pending','failed','expired','unpaid','incomplete']as$status){$v=$e;$v['session']['status']=$status;verify($driver->processWebhookRequest(signedRequest($v))->getStatusCode()===200,'Ignore '.$status);} +verify(Payment::where('company_gateway_id',$cg->id)->count()===$before,'No payment for intermediate or failed statuses'); +verify($driver->processWebhookRequest(signedRequest($e,-301))->getStatusCode()===401,'Reject expired signature'); +foreach(['uid'=>'wrong','fiatAmount'=>'0.01','fiatCurrency'=>'USD']as$key=>$value){$v=$e;$v['paymentLink'][$key]=$value;verify($driver->processWebhookRequest(signedRequest($v))->getStatusCode()===400,'Reject wrong '.$key);} +$r=$driver->processWebhookRequest(signedRequest($e));verify($r->getStatusCode()===200,'Completed webhook accepted: '.$r->getContent()); +verify($ph->fresh()->payment_id!==null,'Native payment record attached'); +Queue::assertPushed(App\Jobs\Payment\EmailPayment::class,1);verify(true,'Native online payment receipt queued'); +verify((float)$fixture->invoice->fresh()->balance===0.0,'Invoice balance cleared by native payment service'); +$driver->processWebhookRequest(signedRequest($e));verify(Payment::where('company_gateway_id',$cg->id)->count()===$before+1,'Duplicate webhook creates exactly one payment'); +Queue::assertPushed(App\Jobs\Payment\EmailPayment::class,1);verify(true,'Duplicate callback sends no second receipt'); +$wrong=new CompanyGateway();$wrong->company_id=$fixture->company->id;$wrong->user_id=$fixture->user->id;$wrong->gateway_key=$gateway->key;$wrong->setConfig(['webhookSecret'=>'test-webhook-secret']);$wrong->save(); +verify((new MakePayPaymentDriver($wrong))->processWebhookRequest(signedRequest($e))->getStatusCode()===400,'Reject cross-gateway callback'); +$callbackUrl=route('payment_webhook',['company_key'=>$fixture->company->company_key,'company_gateway_id'=>$cg->hashed_id]); +$httpKernel=$app->make(Illuminate\Contracts\Http\Kernel::class); +$httpRequest=signedRequest($e);$httpRequest->initialize([],[],[],[],[],array_merge($httpRequest->server->all(),['REQUEST_URI'=>parse_url($callbackUrl,PHP_URL_PATH)]),$httpRequest->getContent()); +verify($httpKernel->handle($httpRequest)->getStatusCode()===200,'Native HTTP webhook route accepts duplicate completion'); +$partial = new class { use Tests\MockAccountData; }; $partial->makeTestData(); +$feeCg=$cg->replicate();$feeCg->company_id=$partial->company->id;$feeCg->user_id=$partial->user->id; +$fees=$feeCg->fees_and_limits;$fees->{'4'}->fee_amount=1;$feeCg->fees_and_limits=$fees;$feeCg->save(); +$quote=(new App\Services\Invoice\CalculateGatewayFee($feeCg,GatewayType::CRYPTO,$partial->invoice,5))->run(); +verify((float)$quote['gross']===1.0,'Native gateway fee quote'); +$partialHash=bin2hex(random_bytes(16));$partialPh=new PaymentHash();$partialPh->hash=$partialHash; +$partialPh->fee_total=$quote['gross'];$partialPh->fee_invoice_id=$partial->invoice->id; +$partialPh->data=(object)['amount_with_fee'=>'6.00','credits'=>0,'invoices'=>[(object)['invoice_id'=>$partial->invoice->hashed_id,'invoice_number'=>$partial->invoice->number,'amount'=>5]]];$partialPh->save(); +$partialDriver=new MakePayPaymentDriver($feeCg,$partial->client);$partialDriver->payment_hash=$partialPh;$partialDriver->setPaymentMethod(GatewayType::CRYPTO); +Http::swap(new Illuminate\Http\Client\Factory());Http::preventStrayRequests(); +Http::fake(['www.makecrypto.io/*'=>Http::response(['paymentLink'=>['uid'=>'mp_'.$partialHash,'publicUrl'=>'https://www.makepay.io/pay/partial']],201)]); +$partialDriver->processPaymentResponse(Illuminate\Http\Request::create('/','POST')); +Http::assertSent(fn($r)=>$r->method()==='POST'&&$r['payload']['amount']==='6.00');verify(true,'Partial checkout includes the native gateway fee'); +$partialEvent=['paymentLink'=>['uid'=>'mp_'.$partialHash,'merchantOrderId'=>'ninja_'.$partialHash,'amount'=>'6.00','currency'=>'USDC','fiatAmount'=>'6.00','fiatCurrency'=>'USD'],'session'=>['id'=>'session_'.$partialHash,'status'=>'complete']]; +$partialResponse=$partialDriver->processWebhookRequest(signedRequest($partialEvent)); +verify($partialResponse->getStatusCode()===200,'Partial payment callback accepted: '.$partialResponse->getContent()); +verify((float)$partial->invoice->fresh()->balance===5.0,'Partial payment leaves the correct invoice balance'); +$partialDriver->processWebhookRequest(signedRequest($partialEvent)); +verify(count(array_filter($partial->invoice->fresh()->line_items,fn($line)=>($line->unit_code??'')===$partialHash))===1,'Gateway fee is recorded once on duplicate delivery'); +file_put_contents(__DIR__.'/../output-fixture.json',json_encode(['company_key'=>$fixture->company->company_key,'gateway_id'=>$cg->hashed_id,'invoice_id'=>$fixture->invoice->hashed_id,'invitation_key'=>$fixture->invoice->invitations->first()->key,'payment_hash'=>$hash,'amount'=>$amount],JSON_PRETTY_PRINT)); +echo "$count native integration checks passed.\n"; diff --git a/tests/protocol.php b/tests/protocol.php new file mode 100644 index 0000000..546e920 --- /dev/null +++ b/tests/protocol.php @@ -0,0 +1,25 @@ +'mp_1','amount'=>'129.99','currency'=>'EUR','precision'=>2]; +$event = ['paymentLink'=>['uid'=>'mp_1','merchantOrderId'=>'ninja_testhash','amount'=>'129.99','currency'=>'USDC','fiatCurrency'=>'EUR','fiatAmount'=>'129.99'],'session'=>['id'=>'s1']]; +check(Protocol::matches($event,$saved,'testhash'),'Matching reference'); +foreach (['uid'=>'other','merchantOrderId'=>'ninja_other','fiatAmount'=>'1.00','fiatCurrency'=>'AED'] as $key=>$value) { $changed=$event;$changed['paymentLink'][$key]=$value;check(!Protocol::matches($changed,$saved,'testhash'),'Mismatch rejected'); } +check(Protocol::amount('1.234',3)==='1.234','Three decimal currency preserved'); +check(Protocol::amount('1234',0)==='1234','Zero decimal currency preserved'); +check(Protocol::amount('00010.00')==='10.00','Decimal normalization'); +$missing=$event;unset($missing['paymentLink']['fiatCurrency']);check(!Protocol::matches($missing,$saved,'testhash'),'Settlement token cannot stand in for fiat currency'); +echo "$count protocol checks passed.\n";