From 94e25d2ede45e12c85fd42b5ea5ef37c7710d037 Mon Sep 17 00:00:00 2001 From: Samuel Georges Date: Thu, 5 Mar 2026 18:26:23 +1100 Subject: [PATCH 01/16] Replace discount field with simple currency --- Plugin.php | 9 -- formwidgets/Discount.php | 151 -------------------- formwidgets/discount/partials/_discount.php | 28 ---- models/invoiceitem/fields.yaml | 2 +- 4 files changed, 1 insertion(+), 189 deletions(-) delete mode 100644 formwidgets/Discount.php delete mode 100644 formwidgets/discount/partials/_discount.php diff --git a/Plugin.php b/Plugin.php index d1f1033..d83a721 100644 --- a/Plugin.php +++ b/Plugin.php @@ -183,13 +183,4 @@ public function registerSettings() ]; } - /** - * registerFormWidgets - */ - public function registerFormWidgets() - { - return [ - \Responsiv\Pay\FormWidgets\Discount::class => 'discount' - ]; - } } diff --git a/formwidgets/Discount.php b/formwidgets/Discount.php deleted file mode 100644 index d7f1202..0000000 --- a/formwidgets/Discount.php +++ /dev/null @@ -1,151 +0,0 @@ -fillFromConfig([ - 'fixedPrice' - ]); - } - - /** - * {@inheritDoc} - */ - public function render() - { - $this->prepareVars(); - - return $this->makePartial('discount'); - } - - /** - * Prepares the list data - */ - public function prepareVars() - { - [$amount, $symbol] = $this->getLoadAmount(); - $this->vars['amount'] = $amount; - $this->vars['symbol'] = $symbol; - - $this->vars['name'] = $this->formField->getName(); - $this->vars['field'] = $this->formField; - $this->vars['fixedPrice'] = $this->fixedPrice; - } - - /** - * getLoadAmount - */ - public function getLoadAmount() - { - $value = $this->getLoadValue(); - if ($value === null) { - return [null, null]; - } - - $amount = $value; - - // Percentage - $isPercentage = strpos($value, '%') !== false; - if ($isPercentage) { - $amount = str_replace('%', '', $amount); - return [$amount.'%', '%']; - } - - // Fixed price or discount - $symbol = ''; - $isNegativeFloat = strpos($amount, '-') !== false; - if ($isNegativeFloat) { - $symbol = '-'; - $amount = str_replace('-', '', $amount); - } - - $currencyObj = $this->getLoadCurrency(); - $amount = $currencyObj->fromBaseValue((int) $amount); - - return [$symbol.$amount, $symbol]; - } - - /** - * {@inheritDoc} - */ - public function getSaveValue($value) - { - if ($this->formField->disabled || $this->formField->hidden) { - return FormField::NO_SAVE_DATA; - } - - if (!is_array($value) || !array_key_exists('amount', $value)) { - return FormField::NO_SAVE_DATA; - } - - $amount = $value['amount']; - $symbol = trim($value['symbol'] ?? ''); - - if (!strlen($amount)) { - return null; - } - - // Percentage - if ($symbol === '%' || str_ends_with($amount, '%') !== false) { - $amount = str_replace('%', '', $amount); - return "{$amount}%"; - } - - // Subtract - if ($symbol === '-' || str_starts_with($amount, '-') !== false) { - $amount = str_replace('-', '', $amount); - } - - $currencyObj = $this->getLoadCurrency(); - $amount = floatval(str_replace($currencyObj->decimal_point, '.', $amount)); - $amount = $currencyObj->toBaseValue((float) $amount); - return $symbol . $amount; - } - - /** - * getLoadCurrency returns the currency object to used. If the model uses multisite, - * then extract the primary currency from the site definition, otherwise use the - * primary currency definition. - */ - public function getLoadCurrency() - { - if ( - $this->model && - $this->model->isClassInstanceOf(\October\Contracts\Database\MultisiteInterface::class) && - $this->model->isMultisiteEnabled() - ) { - return Currency::getPrimary(); - } - - return Currency::getDefault(); - } -} diff --git a/formwidgets/discount/partials/_discount.php b/formwidgets/discount/partials/_discount.php deleted file mode 100644 index bc4ca35..0000000 --- a/formwidgets/discount/partials/_discount.php +++ /dev/null @@ -1,28 +0,0 @@ -previewMode): ?> - value ? e($field->value) : ' ' ?> - -
- - getAttributes() ?> - /> -
- diff --git a/models/invoiceitem/fields.yaml b/models/invoiceitem/fields.yaml index 93f65fa..8a5ecfa 100644 --- a/models/invoiceitem/fields.yaml +++ b/models/invoiceitem/fields.yaml @@ -20,7 +20,7 @@ tabs: discount: label: Unit Discount - type: discount + type: currency span: row spanClass: col-5 From bbf29cd8632c0b909c43103e8c184f701dd07939 Mon Sep 17 00:00:00 2001 From: Samuel Georges Date: Thu, 5 Mar 2026 18:27:34 +1100 Subject: [PATCH 02/16] Bump --- updates/version.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/updates/version.yaml b/updates/version.yaml index db4af5f..65f0bf6 100644 --- a/updates/version.yaml +++ b/updates/version.yaml @@ -29,3 +29,4 @@ v2.0.0: - migrate_v2_0_0.php v2.0.2: Update Stripe and PayPal gateway instructions v2.0.3: Fixes bug in PayPal gateway amounts +v2.0.4: Fixes for PHP 8.4 From 09df8e6cf4319fb2dcc02754bce2428c9812d721 Mon Sep 17 00:00:00 2001 From: Samuel Georges Date: Wed, 11 Mar 2026 20:12:48 +1100 Subject: [PATCH 03/16] Fixes to PayPal gateway --- paymenttypes/PayPalPayment.php | 36 ++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/paymenttypes/PayPalPayment.php b/paymenttypes/PayPalPayment.php index 8d10681..1649f50 100644 --- a/paymenttypes/PayPalPayment.php +++ b/paymenttypes/PayPalPayment.php @@ -113,7 +113,8 @@ public function getPayPalNamespace(): string public function renderPaymentScripts() { $queryParams = http_build_query([ - 'client-id' => 'test', + 'client-id' => $this->getHostObject()->client_id, + 'currency' => Currency::getActiveCode(), 'components' => 'buttons', 'enable-funding' => 'venmo', 'disable-funding' => 'paylater,card', @@ -217,32 +218,47 @@ public function processApiInvoiceCapture($params) ->post("{$baseUrl}/v2/checkout/orders/{$orderId}/capture", $payload); if (!$response->successful()) { + // Order already captured (e.g. duplicate callback) - treat as success + if ($response->json('details.0.issue') === 'ORDER_ALREADY_CAPTURED') { + return Response::json(['cms_redirect' => $invoice->getReceiptUrl()] + ($response->json() ?: []), 200); + } + $errorIssue = $response->json('details.0.issue'); $errorDescription = $response->json('details.0.description'); $invoice->logPaymentAttempt("{$errorIssue} {$errorDescription}", false, $payload, $response->json(), ''); throw new Exception("{$errorIssue} {$errorDescription}"); } elseif (!$invoice->isPaymentProcessed(true)) { - if ($response->json('status') !== 'COMPLETED') { - throw new ApplicationException('Invalid response'); + $validStatuses = ['COMPLETED', 'PENDING']; + + if (!in_array($response->json('status'), $validStatuses)) { + throw new ApplicationException('Invalid response status: ' . $response->json('status')); } if ($response->json('purchase_units.0.reference_id') !== $invoice->getUniqueId()) { throw new ApplicationException('Invalid invoice number'); } - if ($response->json('purchase_units.0.payments.captures.0.status') !== 'COMPLETED') { - throw new ApplicationException('Invalid response'); + if (!in_array($response->json('purchase_units.0.payments.captures.0.status'), $validStatuses)) { + throw new ApplicationException('Invalid capture status: ' . $response->json('purchase_units.0.payments.captures.0.status')); } - if (($matchedValue = $response->json('purchase_units.0.payments.captures.0.amount.value')) !== Currency::fromBaseValue($totals['total'])) { - throw new ApplicationException('Invalid invoice total - order total received is: ' . e($matchedValue)); + $matchedValue = $response->json('purchase_units.0.payments.captures.0.amount.value'); + $expectedValue = Currency::fromBaseValue($totals['total']); + if (!empty($expectedValue) && strpos($expectedValue, ',') !== false) { + $expectedValue = str_replace(',', '.', $expectedValue); + } + if ($matchedValue !== $expectedValue) { + throw new ApplicationException('Invalid invoice total - order total received is: ' . e($matchedValue) . ', expected: ' . e($expectedValue)); } - $transactionStatus = $response->json('status'); + $captureStatus = $response->json('purchase_units.0.payments.captures.0.status'); $transactionId = $response->json('id'); - $invoice->logPaymentAttempt("Transaction {$transactionStatus}: {$transactionId}", true, $payload, $response->json(), ''); - $invoice->markAsPaymentProcessed(); + $invoice->logPaymentAttempt("Transaction {$captureStatus}: {$transactionId}", true, $payload, $response->json(), ''); + + if ($captureStatus === 'COMPLETED') { + $invoice->markAsPaymentProcessed(); + } } return Response::json(['cms_redirect' => $invoice->getReceiptUrl()] + $response->json(), $response->status()); From c3a844e09aeccf7f6cd51f8ae3164e6080925f37 Mon Sep 17 00:00:00 2001 From: Samuel Georges Date: Wed, 11 Mar 2026 20:40:42 +1100 Subject: [PATCH 04/16] Add webhook support --- paymenttypes/PayPalPayment.php | 129 ++++++++++++++++++++- paymenttypes/paypalpayment/_setup_help.php | 17 ++- paymenttypes/paypalpayment/fields.yaml | 5 + 3 files changed, 149 insertions(+), 2 deletions(-) diff --git a/paymenttypes/PayPalPayment.php b/paymenttypes/PayPalPayment.php index 1649f50..37cb4df 100644 --- a/paymenttypes/PayPalPayment.php +++ b/paymenttypes/PayPalPayment.php @@ -61,7 +61,8 @@ public function registerAccessPoints() { return [ 'paypal_rest_invoices' => 'processApiInvoices', - 'paypal_rest_invoice_capture' => 'processApiInvoiceCapture' + 'paypal_rest_invoice_capture' => 'processApiInvoiceCapture', + 'paypal_rest_webhook' => 'processWebhook' ]; } @@ -272,6 +273,132 @@ public function processApiInvoiceCapture($params) } } + /** + * getWebhookUrl + */ + public function getWebhookUrl() + { + return $this->makeAccessPointLink('paypal_rest_webhook'); + } + + /** + * processWebhook handles asynchronous PayPal webhook events, such as + * PAYMENT.CAPTURE.COMPLETED for pending transactions. + * @see https://developer.paypal.com/docs/api/webhooks/v1/#verify-webhook-signature_post + */ + public function processWebhook($params) + { + try { + $host = $this->getHostObject(); + $webhookId = $host->webhook_id; + if (!$webhookId) { + return Response::make('Webhook not configured', 200); + } + + $body = request()->getContent(); + $event = json_decode($body, true); + if (!$event) { + return Response::make('Invalid payload', 400); + } + + // Verify webhook signature + if (!$this->verifyWebhookSignature($webhookId, $body)) { + Log::warning('PayPal webhook signature verification failed'); + return Response::make('Invalid signature', 403); + } + + $eventType = $event['event_type'] ?? ''; + + if ($eventType === 'PAYMENT.CAPTURE.COMPLETED') { + $captureId = $event['resource']['id'] ?? null; + $referenceId = $event['resource']['custom_id'] + ?? $event['resource']['invoice_id'] + ?? null; + + if (!$referenceId) { + // Look up via the order linked to this capture + $orderId = $event['resource']['supplementary_data']['related_ids']['order_id'] ?? null; + if ($orderId) { + $referenceId = $this->lookupReferenceIdFromOrder($orderId); + } + } + + if ($referenceId) { + $invoice = $this->createInvoiceModel()->findByUniqueId($referenceId); + if ($invoice && !$invoice->isPaymentProcessed()) { + $invoice->logPaymentAttempt( + "Webhook PAYMENT.CAPTURE.COMPLETED: {$captureId}", + true, + [], + $event, + $body + ); + $invoice->markAsPaymentProcessed(); + } + } + } + + return Response::make('OK', 200); + } + catch (Exception $ex) { + Log::error('PayPal webhook error: ' . $ex->getMessage()); + return Response::make('Error', 500); + } + } + + /** + * verifyWebhookSignature verifies the PayPal webhook signature using the + * PayPal verification API endpoint. + * @see https://developer.paypal.com/docs/api/webhooks/v1/#verify-webhook-signature_post + */ + protected function verifyWebhookSignature(string $webhookId, string $body): bool + { + $headers = request()->headers; + + $payload = [ + 'auth_algo' => $headers->get('paypal-auth-algo'), + 'cert_url' => $headers->get('paypal-cert-url'), + 'transmission_id' => $headers->get('paypal-transmission-id'), + 'transmission_sig' => $headers->get('paypal-transmission-sig'), + 'transmission_time' => $headers->get('paypal-transmission-time'), + 'webhook_id' => $webhookId, + 'webhook_event' => json_decode($body, true), + ]; + + $token = $this->generatePayPalAccessToken(); + $baseUrl = $this->getPayPalEndpoint(); + + $response = Http::withToken($token) + ->post("{$baseUrl}/v1/notifications/verify-webhook-signature", $payload); + + return $response->successful() + && $response->json('verification_status') === 'SUCCESS'; + } + + /** + * lookupReferenceIdFromOrder fetches the reference_id from a PayPal order + * when the webhook event doesn't include it directly. + */ + protected function lookupReferenceIdFromOrder(string $orderId): ?string + { + try { + $token = $this->generatePayPalAccessToken(); + $baseUrl = $this->getPayPalEndpoint(); + + $response = Http::withToken($token) + ->get("{$baseUrl}/v2/checkout/orders/{$orderId}"); + + if ($response->successful()) { + return $response->json('purchase_units.0.reference_id'); + } + } + catch (Exception $ex) { + Log::warning('PayPal order lookup failed: ' . $ex->getMessage()); + } + + return null; + } + /** * generatePayPalAccessToken generate an OAuth 2.0 access token for authenticating with PayPal REST APIs. * @see https://developer.paypal.com/api/rest/authentication/ diff --git a/paymenttypes/paypalpayment/_setup_help.php b/paymenttypes/paypalpayment/_setup_help.php index 165e0fa..b490665 100644 --- a/paymenttypes/paypalpayment/_setup_help.php +++ b/paymenttypes/paypalpayment/_setup_help.php @@ -5,7 +5,7 @@
    -
  1. Log in to your PayPal Developer account
  2. +
  3. Log in to your PayPal Developer account
  4. Click the My Apps & Credentials link in the menu
  5. Toggle the Live / Sandbox option depending on your requirements
  6. Click Create App and fill in the required details
  7. @@ -13,5 +13,20 @@
  8. Copy the Client ID and paste it in the Configuration tab
  9. Copy the Secret Key and paste it in the Configuration tab
+ +

Setting up Webhooks (Optional)

+

+ Webhooks allow PayPal to notify your site when a pending payment is completed. This is recommended for production use. +

+
    +
  1. In your PayPal Developer Dashboard, navigate to your app and click Webhooks
  2. +
  3. Click Add Webhook
  4. +
  5. + Enter your webhook URL: + getWebhookUrl()) ?> +
  6. +
  7. Subscribe to the event: PAYMENT.CAPTURE.COMPLETED
  8. +
  9. Save, then copy the generated Webhook ID and paste it in the Configuration tab
  10. +
diff --git a/paymenttypes/paypalpayment/fields.yaml b/paymenttypes/paypalpayment/fields.yaml index 346d5eb..1e84d47 100644 --- a/paymenttypes/paypalpayment/fields.yaml +++ b/paymenttypes/paypalpayment/fields.yaml @@ -24,3 +24,8 @@ fields: comment: PayPal client secret to authenticate with the client ID. Keep this secret safe. tab: Configuration type: sensitive + + webhook_id: + label: Webhook ID + comment: Optional. Enter the Webhook ID from PayPal to enable automatic payment confirmation for pending transactions. + tab: Configuration From 3a97abf77a03005fdea8af709f1510a2a6a83142 Mon Sep 17 00:00:00 2001 From: Samuel Georges Date: Wed, 11 Mar 2026 20:46:35 +1100 Subject: [PATCH 05/16] Capture refunds --- paymenttypes/PayPalPayment.php | 81 ++++++++++++++++------ paymenttypes/paypalpayment/_setup_help.php | 11 ++- 2 files changed, 66 insertions(+), 26 deletions(-) diff --git a/paymenttypes/PayPalPayment.php b/paymenttypes/PayPalPayment.php index 37cb4df..62d6b4e 100644 --- a/paymenttypes/PayPalPayment.php +++ b/paymenttypes/PayPalPayment.php @@ -7,6 +7,7 @@ use Response; use Cms\Classes\Page; use Responsiv\Pay\Classes\GatewayBase; +use Responsiv\Pay\Models\InvoiceStatus; use Illuminate\Http\Exceptions\HttpResponseException; use ApplicationException; use Exception; @@ -283,7 +284,8 @@ public function getWebhookUrl() /** * processWebhook handles asynchronous PayPal webhook events, such as - * PAYMENT.CAPTURE.COMPLETED for pending transactions. + * PAYMENT.CAPTURE.COMPLETED for pending transactions and + * PAYMENT.CAPTURE.REFUNDED for refunds. * @see https://developer.paypal.com/docs/api/webhooks/v1/#verify-webhook-signature_post */ public function processWebhook($params) @@ -308,34 +310,40 @@ public function processWebhook($params) } $eventType = $event['event_type'] ?? ''; + $resourceId = $event['resource']['id'] ?? null; + + switch ($eventType) { + case 'PAYMENT.CAPTURE.COMPLETED': + if ($invoice = $this->findInvoiceFromWebhookEvent($event)) { + if (!$invoice->isPaymentProcessed()) { + $invoice->logPaymentAttempt( + "Webhook PAYMENT.CAPTURE.COMPLETED: {$resourceId}", + true, [], $event, $body + ); + $invoice->markAsPaymentProcessed(); + } + } + break; - if ($eventType === 'PAYMENT.CAPTURE.COMPLETED') { - $captureId = $event['resource']['id'] ?? null; - $referenceId = $event['resource']['custom_id'] - ?? $event['resource']['invoice_id'] - ?? null; - - if (!$referenceId) { - // Look up via the order linked to this capture - $orderId = $event['resource']['supplementary_data']['related_ids']['order_id'] ?? null; - if ($orderId) { - $referenceId = $this->lookupReferenceIdFromOrder($orderId); + case 'PAYMENT.CAPTURE.REFUNDED': + if ($invoice = $this->findInvoiceFromWebhookEvent($event)) { + $invoice->logPaymentAttempt( + "Webhook PAYMENT.CAPTURE.REFUNDED: {$resourceId}", + true, [], $event, $body + ); + $invoice->updateInvoiceStatus(InvoiceStatus::STATUS_VOID, 'Refunded via PayPal'); } - } + break; - if ($referenceId) { - $invoice = $this->createInvoiceModel()->findByUniqueId($referenceId); - if ($invoice && !$invoice->isPaymentProcessed()) { + case 'PAYMENT.CAPTURE.DENIED': + if ($invoice = $this->findInvoiceFromWebhookEvent($event)) { $invoice->logPaymentAttempt( - "Webhook PAYMENT.CAPTURE.COMPLETED: {$captureId}", - true, - [], - $event, - $body + "Webhook PAYMENT.CAPTURE.DENIED: {$resourceId}", + false, [], $event, $body ); - $invoice->markAsPaymentProcessed(); + $invoice->updateInvoiceStatus(InvoiceStatus::STATUS_VOID, 'Payment denied by PayPal'); } - } + break; } return Response::make('OK', 200); @@ -346,6 +354,33 @@ public function processWebhook($params) } } + /** + * findInvoiceFromWebhookEvent locates the invoice associated with a + * PayPal webhook event by checking resource fields and falling back + * to an order lookup. + */ + protected function findInvoiceFromWebhookEvent(array $event) + { + $resource = $event['resource'] ?? []; + + $referenceId = $resource['custom_id'] + ?? $resource['invoice_id'] + ?? null; + + if (!$referenceId) { + $orderId = $resource['supplementary_data']['related_ids']['order_id'] ?? null; + if ($orderId) { + $referenceId = $this->lookupReferenceIdFromOrder($orderId); + } + } + + if (!$referenceId) { + return null; + } + + return $this->createInvoiceModel()->findByUniqueId($referenceId); + } + /** * verifyWebhookSignature verifies the PayPal webhook signature using the * PayPal verification API endpoint. diff --git a/paymenttypes/paypalpayment/_setup_help.php b/paymenttypes/paypalpayment/_setup_help.php index b490665..fe6440b 100644 --- a/paymenttypes/paypalpayment/_setup_help.php +++ b/paymenttypes/paypalpayment/_setup_help.php @@ -14,9 +14,9 @@
  • Copy the Secret Key and paste it in the Configuration tab
  • -

    Setting up Webhooks (Optional)

    +

    Setting up Webhooks (Optional)

    - Webhooks allow PayPal to notify your site when a pending payment is completed. This is recommended for production use. + Webhooks allow PayPal to notify your site when payments are completed, refunded or denied. This is recommended for production use.

    1. In your PayPal Developer Dashboard, navigate to your app and click Webhooks
    2. @@ -25,7 +25,12 @@ Enter your webhook URL: getWebhookUrl()) ?> -
    3. Subscribe to the event: PAYMENT.CAPTURE.COMPLETED
    4. +
    5. + Subscribe to the following events: + PAYMENT.CAPTURE.COMPLETED, + PAYMENT.CAPTURE.REFUNDED, + PAYMENT.CAPTURE.DENIED +
    6. Save, then copy the generated Webhook ID and paste it in the Configuration tab
    From 881bbde3901f3547268b83220a5711fe5161831a Mon Sep 17 00:00:00 2001 From: Samuel Georges Date: Wed, 11 Mar 2026 21:08:18 +1100 Subject: [PATCH 06/16] Adds refunded status to invoices --- controllers/invoices/HasInvoiceStatus.php | 2 ++ controllers/invoices/_preview_toolbar.php | 2 +- models/InvoiceStatus.php | 4 +++- models/InvoiceStatusLog.php | 2 ++ paymenttypes/PayPalPayment.php | 2 +- updates/migrate_v3_0_0.php | 22 ++++++++++++++++++++++ updates/seed_tables.php | 1 + updates/version.yaml | 3 +++ 8 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 updates/migrate_v3_0_0.php diff --git a/controllers/invoices/HasInvoiceStatus.php b/controllers/invoices/HasInvoiceStatus.php index ef66b17..dc3cac6 100644 --- a/controllers/invoices/HasInvoiceStatus.php +++ b/controllers/invoices/HasInvoiceStatus.php @@ -123,6 +123,8 @@ protected function getInvoiceStatusPopupTitle() return "Add Payment"; case 'approved': return "Approve Invoice"; + case 'refunded': + return "Refund Invoice"; case 'void': return "Void Invoice"; default: diff --git a/controllers/invoices/_preview_toolbar.php b/controllers/invoices/_preview_toolbar.php index 61a0f6f..75264f2 100644 --- a/controllers/invoices/_preview_toolbar.php +++ b/controllers/invoices/_preview_toolbar.php @@ -22,7 +22,7 @@ ->outline() ?> ajaxData(['invoice_id' => $formModel->id, 'status_preset' => 'void']) + ->ajaxData(['invoice_id' => $formModel->id, 'status_preset' => 'refunded']) ->size(500) ->icon('icon-ban') ->outline() ?> diff --git a/models/InvoiceStatus.php b/models/InvoiceStatus.php index d4a84da..cbe6a37 100644 --- a/models/InvoiceStatus.php +++ b/models/InvoiceStatus.php @@ -26,6 +26,7 @@ class InvoiceStatus extends Model const STATUS_DRAFT = 'draft'; const STATUS_APPROVED = 'approved'; const STATUS_PAID = 'paid'; + const STATUS_REFUNDED = 'refunded'; const STATUS_VOID = 'void'; /** @@ -72,8 +73,9 @@ public function getStatusCodeOptions() return [ 'draft' => ['Draft', '#98a0a0'], 'approved' => ['Approved', 'var(--bs-info)'], - 'void' => ['Void', 'var(--bs-danger)'], 'paid' => ['Paid', 'var(--bs-success)'], + 'refunded' => ['Refunded', 'var(--bs-warning)'], + 'void' => ['Void', 'var(--bs-danger)'], ]; } diff --git a/models/InvoiceStatusLog.php b/models/InvoiceStatusLog.php index 9e1f831..077aa77 100644 --- a/models/InvoiceStatusLog.php +++ b/models/InvoiceStatusLog.php @@ -128,8 +128,10 @@ protected static function checkStatusTransition($fromStatusId, $toStatusId): boo InvoiceStatus::STATUS_VOID, ], InvoiceStatus::STATUS_PAID => [ + InvoiceStatus::STATUS_REFUNDED, InvoiceStatus::STATUS_VOID, ], + InvoiceStatus::STATUS_REFUNDED => [], InvoiceStatus::STATUS_VOID => [], ]; diff --git a/paymenttypes/PayPalPayment.php b/paymenttypes/PayPalPayment.php index 62d6b4e..f37b8f5 100644 --- a/paymenttypes/PayPalPayment.php +++ b/paymenttypes/PayPalPayment.php @@ -331,7 +331,7 @@ public function processWebhook($params) "Webhook PAYMENT.CAPTURE.REFUNDED: {$resourceId}", true, [], $event, $body ); - $invoice->updateInvoiceStatus(InvoiceStatus::STATUS_VOID, 'Refunded via PayPal'); + $invoice->updateInvoiceStatus(InvoiceStatus::STATUS_REFUNDED, 'Refunded via PayPal'); } break; diff --git a/updates/migrate_v3_0_0.php b/updates/migrate_v3_0_0.php new file mode 100644 index 0000000..4522ed9 --- /dev/null +++ b/updates/migrate_v3_0_0.php @@ -0,0 +1,22 @@ + true, + 'name' => 'Refunded', + 'code' => 'refunded' + ]); + } + } + + public function down() + { + } +}; diff --git a/updates/seed_tables.php b/updates/seed_tables.php index 02f6aa3..5e074e3 100644 --- a/updates/seed_tables.php +++ b/updates/seed_tables.php @@ -11,6 +11,7 @@ public function run() InvoiceStatus::create(['is_enabled' => true, 'name' => 'Draft', 'code' => 'draft']); InvoiceStatus::create(['is_enabled' => true, 'name' => 'Approved', 'code' => 'approved']); InvoiceStatus::create(['is_enabled' => true, 'name' => 'Paid', 'code' => 'paid']); + InvoiceStatus::create(['is_enabled' => true, 'name' => 'Refunded', 'code' => 'refunded']); InvoiceStatus::create(['is_enabled' => true, 'name' => 'Void', 'code' => 'void']); InvoiceTemplate::create([ diff --git a/updates/version.yaml b/updates/version.yaml index 65f0bf6..3c839b7 100644 --- a/updates/version.yaml +++ b/updates/version.yaml @@ -30,3 +30,6 @@ v2.0.0: v2.0.2: Update Stripe and PayPal gateway instructions v2.0.3: Fixes bug in PayPal gateway amounts v2.0.4: Fixes for PHP 8.4 +v3.0.0: + - Added refunded invoice status and PayPal webhook support + - migrate_v3_0_0.php From 299d8f977e6092033522d08fa94332dd22fa76e3 Mon Sep 17 00:00:00 2001 From: Samuel Georges Date: Wed, 11 Mar 2026 21:49:11 +1100 Subject: [PATCH 07/16] Add a payment submitted status --- components/invoice/default.htm | 5 ++++- components/payment/default.htm | 6 ++++++ models/invoice/HasModelAttributes.php | 18 ++++++++++++++++++ paymenttypes/PayPalPayment.php | 3 +++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/components/invoice/default.htm b/components/invoice/default.htm index cc2c5b5..20f76f7 100644 --- a/components/invoice/default.htm +++ b/components/invoice/default.htm @@ -32,9 +32,12 @@

    Invoice {{ invoice.invoice_number }}

    - {% if invoice.isPaid %} + {% if invoice.is_paid %}

    This invoice has been paid!

    Thank you for your business.

    + {% elseif invoice.is_payment_submitted %} +

    Your payment is being processed

    +

    Your payment has been submitted and is awaiting confirmation. No further action is needed.

    {% else %}

    This invoice has not been paid

    diff --git a/components/payment/default.htm b/components/payment/default.htm index 292575e..afdee74 100644 --- a/components/payment/default.htm +++ b/components/payment/default.htm @@ -11,6 +11,12 @@

    This invoice has been paid. Thank-you!

    + {% elseif invoice.is_payment_submitted %} +
    +

    + Your payment has been submitted and is being processed. Thank-you! +

    +
    {% else %} diff --git a/models/invoice/HasModelAttributes.php b/models/invoice/HasModelAttributes.php index dc42aec..fc263e0 100644 --- a/models/invoice/HasModelAttributes.php +++ b/models/invoice/HasModelAttributes.php @@ -6,6 +6,7 @@ * HasModelAttributes * * @property bool $is_paid + * @property bool $is_payment_submitted * @property bool $is_past_due_date * @property int $original_subtotal * @property int $final_subtotal @@ -70,6 +71,23 @@ public function getIsPaidAttribute() return $this->isPaymentProcessed(); } + /** + * getIsPaymentSubmittedAttribute returns true if the payment has been + * submitted by the customer, either fully processed or awaiting + * confirmation (e.g. PayPal PENDING capture). + */ + public function getIsPaymentSubmittedAttribute() + { + if ($this->isPaymentProcessed()) { + return true; + } + + return in_array($this->status_code, [ + \Responsiv\Pay\Models\InvoiceStatus::STATUS_APPROVED, + \Responsiv\Pay\Models\InvoiceStatus::STATUS_PAID, + ]); + } + /** * getIsPastDueDateAttribute */ diff --git a/paymenttypes/PayPalPayment.php b/paymenttypes/PayPalPayment.php index f37b8f5..aeb3b1e 100644 --- a/paymenttypes/PayPalPayment.php +++ b/paymenttypes/PayPalPayment.php @@ -261,6 +261,9 @@ public function processApiInvoiceCapture($params) if ($captureStatus === 'COMPLETED') { $invoice->markAsPaymentProcessed(); } + elseif ($captureStatus === 'PENDING') { + $invoice->updateInvoiceStatus(InvoiceStatus::STATUS_APPROVED); + } } return Response::json(['cms_redirect' => $invoice->getReceiptUrl()] + $response->json(), $response->status()); From c0ef889094d12cf46b9f13b629eabe8b5234f710 Mon Sep 17 00:00:00 2001 From: Samuel Georges Date: Wed, 11 Mar 2026 21:56:35 +1100 Subject: [PATCH 08/16] Adds interface for checkPaymentStatus when an payment screen loads --- classes/GatewayBase.php | 12 ++++++++++ components/Payment.php | 30 +++++++++++++++++++++++- paymenttypes/PayPalPayment.php | 43 ++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/classes/GatewayBase.php b/classes/GatewayBase.php index 734049d..f3734e6 100644 --- a/classes/GatewayBase.php +++ b/classes/GatewayBase.php @@ -172,6 +172,18 @@ public function payOfflineMessage() return ''; } + /** + * checkPaymentStatus checks the payment status with the gateway for an + * invoice that has been submitted but not yet confirmed. Gateways can + * implement this to poll for updated payment status (e.g. a pending + * capture that has since completed). Returns true if the payment has + * been confirmed as processed. + */ + public function checkPaymentStatus($invoice): bool + { + return false; + } + // // Payment Profiles // diff --git a/components/Payment.php b/components/Payment.php index b32d46c..32f992f 100644 --- a/components/Payment.php +++ b/components/Payment.php @@ -1,5 +1,6 @@ page['invoice'] = $invoice = $this->invoice(); - $this->page['paymentMethods'] = $this->listAvailablePaymentMethods(); if ($invoice) { + $this->checkPaymentStatus($invoice); + $this->page->meta_title = $this->page->meta_title ? str_replace('%s', $invoice->getUniqueId(), $this->page->meta_title) : 'Invoice #'.$invoice->getUniqueId(); } + + $this->page['paymentMethods'] = $this->listAvailablePaymentMethods(); + } + + /** + * checkPaymentStatus polls the payment gateway to check if a submitted + * payment has since been confirmed. Silently fails on any error. + */ + protected function checkPaymentStatus($invoice) + { + if ($invoice->is_paid || !$invoice->is_payment_submitted) { + return; + } + + try { + $paymentMethod = $invoice->payment_method; + if ($paymentMethod && ($driver = $paymentMethod->getDriverObject())) { + if ($driver->checkPaymentStatus($invoice)) { + $invoice->refresh(); + } + } + } + catch (Exception $ex) { + Log::debug('Payment status check failed: ' . $ex->getMessage()); + } } /** diff --git a/paymenttypes/PayPalPayment.php b/paymenttypes/PayPalPayment.php index aeb3b1e..676c4a5 100644 --- a/paymenttypes/PayPalPayment.php +++ b/paymenttypes/PayPalPayment.php @@ -277,6 +277,49 @@ public function processApiInvoiceCapture($params) } } + /** + * checkPaymentStatus polls PayPal to check if a pending capture has + * since completed. Returns true if the payment was confirmed. + * @see https://developer.paypal.com/docs/api/orders/v2/#orders_get + */ + public function checkPaymentStatus($invoice): bool + { + // Find the PayPal order ID from the last successful payment log + $paymentLog = $invoice->payment_log() + ->where('is_success', true) + ->latest() + ->first(); + + $orderId = $paymentLog?->response_data['id'] ?? null; + if (!$orderId) { + return false; + } + + $paymentMethod = $invoice->getPaymentMethod(); + $token = $paymentMethod->generatePayPalAccessToken(); + $baseUrl = $paymentMethod->getPayPalEndpoint(); + + $response = Http::withToken($token) + ->get("{$baseUrl}/v2/checkout/orders/{$orderId}"); + + if (!$response->successful()) { + return false; + } + + $captureStatus = $response->json('purchase_units.0.payments.captures.0.status'); + + if ($captureStatus === 'COMPLETED' && !$invoice->isPaymentProcessed()) { + $invoice->logPaymentAttempt( + "Status Check COMPLETED: {$orderId}", + true, [], $response->json(), '' + ); + $invoice->markAsPaymentProcessed(); + return true; + } + + return false; + } + /** * getWebhookUrl */ From 5e48aea7660bac1ff7ca552a4a336a054f447727 Mon Sep 17 00:00:00 2001 From: Samuel Georges Date: Wed, 11 Mar 2026 22:03:42 +1100 Subject: [PATCH 09/16] Docs checkPaymentStatus() --- docs/building-payment-types.md | 40 ++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/docs/building-payment-types.md b/docs/building-payment-types.md index 56671dd..8c0d5d8 100644 --- a/docs/building-payment-types.md +++ b/docs/building-payment-types.md @@ -137,6 +137,42 @@ Using the redirection method, when the user clicks the Pay button, they are redi ### Client-side Method -Using the client-side method, the payment form is handled using custom JavaScript. When the user clicks Pay, the request is submitted back to the server using a locally registered API endpoint, which communicates to the payment gateway via a REST API or equivalent. The response given with JSON And is processed by the JavaScript code. If everything is successful the `onPay` AJAX handler is called to redirect to the confirmation page. +Using the client-side method, the payment form is handled using custom JavaScript. When the user clicks Pay, the request is submitted back to the server using a locally registered API endpoint, which communicates to the payment gateway via a REST API or equivalent. The response given with JSON and is processed by the JavaScript code. If everything is successful the `onPay` AJAX handler is called to redirect to the confirmation page. -> **Note**: View the `Responsiv\Pay\PaymentTypes\PayPalPayment` class for an example of a redirection payment type. +> **Note**: View the `Responsiv\Pay\PaymentTypes\PayPalPayment` class for an example of a client-side payment type. + +## Handling Pending Payments + +Some payment gateways don't confirm transactions immediately. For example, PayPal may return a `PENDING` capture status when the seller's account requires manual review. In these cases, you should update the invoice status to `approved` to indicate the customer has submitted payment, while waiting for final confirmation via a webhook. + +```php +use Responsiv\Pay\Models\InvoiceStatus; + +if ($captureStatus === 'COMPLETED') { + $invoice->markAsPaymentProcessed(); +} +elseif ($captureStatus === 'PENDING') { + $invoice->updateInvoiceStatus(InvoiceStatus::STATUS_APPROVED); +} +``` + +When the invoice status is `approved`, the payment page will display a "payment is being processed" message instead of showing payment methods again, preventing the customer from paying twice. + +## Checking Payment Status + +Gateways can implement the `checkPaymentStatus` method to poll the payment provider when the customer revisits the payment page. This allows pending payments to be confirmed without waiting for a webhook. The method is called automatically by the `Payment` component when an invoice has been submitted but not yet confirmed. + +```php +public function checkPaymentStatus($invoice): bool +{ + // Query your payment gateway API to check the current status + // If the payment is now confirmed: + $invoice->markAsPaymentProcessed(); + return true; + + // If still pending or unable to check: + return false; +} +``` + +The method should return `true` if the payment was confirmed, or `false` otherwise. Any exceptions thrown will be caught silently by the component, so the payment page will still render normally if the check fails. From 8b2a5c79e3fbf0fe24ea123d137ddf5923162310 Mon Sep 17 00:00:00 2001 From: Samuel Georges Date: Thu, 12 Mar 2026 19:42:16 +1100 Subject: [PATCH 10/16] Store currency code instead of ID --- controllers/Invoices.php | 3 +-- controllers/invoices/_scoreboard_preview.php | 4 ++-- models/Invoice.php | 9 +++------ models/invoice/HasInvoiceContract.php | 2 +- models/invoice/HasModelAttributes.php | 12 ++++++------ updates/000001_create_invoices.php | 2 +- updates/migrate_v2_0_0.php | 3 +-- updates/migrate_v3_0_0.php | 7 +++++++ 8 files changed, 22 insertions(+), 20 deletions(-) diff --git a/controllers/Invoices.php b/controllers/Invoices.php index 09ac6ea..a174c8f 100644 --- a/controllers/Invoices.php +++ b/controllers/Invoices.php @@ -7,7 +7,6 @@ use Responsiv\Pay\Models\Tax; use Backend\Classes\Controller; use Responsiv\Pay\Models\Invoice; -use Responsiv\Currency\Models\Currency as CurrencyModel; /** * Invoices Back-end Controller @@ -75,7 +74,7 @@ public function preview($recordId = null, $context = null) { try { $invoice = Invoice::find($recordId); - $this->vars['currency'] = CurrencyModel::findByCode($invoice->currency); + $this->vars['currency'] = $invoice->getCurrencyObject(); } catch (Exception $ex) { $this->handleError($ex); diff --git a/controllers/invoices/_scoreboard_preview.php b/controllers/invoices/_scoreboard_preview.php index 4712a1d..ec2e724 100644 --- a/controllers/invoices/_scoreboard_preview.php +++ b/controllers/invoices/_scoreboard_preview.php @@ -29,8 +29,8 @@

    -

    currency?->formatCurrency($formModel->total) ?: Currency::format($formModel->total) ?>

    +

    getCurrencyObject()?->formatCurrency($formModel->total) ?: Currency::format($formModel->total) ?>

    - : currency?->formatCurrency($formModel->tax) ?: Currency::format($formModel->tax) ?> + : getCurrencyObject()?->formatCurrency($formModel->tax) ?: Currency::format($formModel->tax) ?>

    diff --git a/models/Invoice.php b/models/Invoice.php index 91340c4..871c209 100644 --- a/models/Invoice.php +++ b/models/Invoice.php @@ -6,7 +6,6 @@ use Currency; use Responsiv\Pay\Classes\TaxLocation; use Responsiv\Pay\Contracts\Invoice as InvoiceContract; -use Responsiv\Currency\Models\Currency as CurrencyModel; /** * Invoice Model @@ -37,7 +36,7 @@ * @property int $user_id * @property int $template_id * @property int $payment_method_id - * @property int $currency_id + * @property string $currency_code * @property int $related_id * @property string $related_type * @property int $status_id @@ -92,7 +91,6 @@ class Invoice extends Model implements InvoiceContract 'template' => InvoiceTemplate::class, 'payment_method' => PaymentMethod::class, 'user' => \RainLab\User\Models\User::class, - 'currency' => CurrencyModel::class, ]; /** @@ -170,9 +168,8 @@ public function beforeSave() $this->template_id = InvoiceTemplate::getDefault()?->id; } - if (!$this->currency_id) { - // Use default currency since multisite not used on invoices - $this->currency_id = Currency::getDefault()?->id; + if (!$this->currency_code) { + $this->currency_code = Currency::getActiveCode(); } } diff --git a/models/invoice/HasInvoiceContract.php b/models/invoice/HasInvoiceContract.php index 92c84e0..d5df3bb 100644 --- a/models/invoice/HasInvoiceContract.php +++ b/models/invoice/HasInvoiceContract.php @@ -138,7 +138,7 @@ public function getTotalDetails() 'total' => $this->total, 'subtotal' => $this->subtotal, 'tax' => $this->tax, - 'currency' => $this->currency?->code, + 'currency' => $this->currency_code, ]; return $details; diff --git a/models/invoice/HasModelAttributes.php b/models/invoice/HasModelAttributes.php index fc263e0..35f3559 100644 --- a/models/invoice/HasModelAttributes.php +++ b/models/invoice/HasModelAttributes.php @@ -1,6 +1,7 @@ currency - ? $this->currency->code - : Currency::getDefault()?->code; + return $this->currency_code + ? CurrencyModel::findByCode($this->currency_code) + : Currency::getActive(); } /** diff --git a/updates/000001_create_invoices.php b/updates/000001_create_invoices.php index a54cd5b..a628d57 100644 --- a/updates/000001_create_invoices.php +++ b/updates/000001_create_invoices.php @@ -36,7 +36,7 @@ public function up() $table->integer('payment_method_id')->unsigned()->nullable()->index(); $table->string('related_id')->index()->nullable(); $table->string('related_type')->index()->nullable(); - $table->integer('currency_id')->unsigned()->nullable()->index(); + $table->string('currency_code', 3)->nullable(); $table->integer('state_id')->unsigned()->nullable()->index(); $table->integer('country_id')->unsigned()->nullable()->index(); $table->integer('status_id')->unsigned()->nullable()->index(); diff --git a/updates/migrate_v2_0_0.php b/updates/migrate_v2_0_0.php index f597285..c350056 100644 --- a/updates/migrate_v2_0_0.php +++ b/updates/migrate_v2_0_0.php @@ -40,11 +40,10 @@ public function up() }); } - if (!Schema::hasColumn('responsiv_pay_invoices', 'currency_id')) { + if (!Schema::hasColumn('responsiv_pay_invoices', 'total_tax')) { Schema::table('responsiv_pay_invoices', function(Blueprint $table) { $table->bigInteger('total_tax')->nullable(); $table->boolean('prices_include_tax')->default(false)->nullable(); - $table->integer('currency_id')->unsigned()->nullable()->index(); }); } diff --git a/updates/migrate_v3_0_0.php b/updates/migrate_v3_0_0.php index 4522ed9..3581381 100644 --- a/updates/migrate_v3_0_0.php +++ b/updates/migrate_v3_0_0.php @@ -1,5 +1,6 @@ 'refunded' ]); } + + if (!Schema::hasColumn('responsiv_pay_invoices', 'currency_code')) { + Schema::table('responsiv_pay_invoices', function(Blueprint $table) { + $table->string('currency_code', 3)->nullable(); + }); + } } public function down() From dd7453d64dd826aa3b96d87b7306761c3cae5209 Mon Sep 17 00:00:00 2001 From: Samuel Georges Date: Fri, 13 Mar 2026 20:24:17 +1100 Subject: [PATCH 11/16] Currency locking on invoices --- controllers/invoices/_scoreboard_edit.php | 10 ++++++---- models/invoice/columns.yaml | 2 ++ models/invoiceitem/columns.yaml | 3 +++ models/invoiceitem/fields.yaml | 2 ++ 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/controllers/invoices/_scoreboard_edit.php b/controllers/invoices/_scoreboard_edit.php index c5ecb8a..1e54645 100644 --- a/controllers/invoices/_scoreboard_edit.php +++ b/controllers/invoices/_scoreboard_edit.php @@ -19,19 +19,21 @@ + getCurrencyObject(); ?> +

    -

    subtotal ?: 0) ?>

    +

    formatCurrency($formModel->subtotal ?: 0) ?: Currency::format($formModel->subtotal ?: 0) ?>

    - : discount ?: 0) ?> + : formatCurrency($formModel->discount ?: 0) ?: Currency::format($formModel->discount ?: 0) ?>

    -

    total) ?>

    +

    formatCurrency($formModel->total) ?: Currency::format($formModel->total) ?>

    - : tax) ?> + : formatCurrency($formModel->tax) ?: Currency::format($formModel->tax) ?>

    diff --git a/models/invoice/columns.yaml b/models/invoice/columns.yaml index c83f80f..c0dd598 100644 --- a/models/invoice/columns.yaml +++ b/models/invoice/columns.yaml @@ -54,6 +54,7 @@ columns: label: Total searchable: true type: currency + currencyFrom: currency_code sent_at: label: Sent @@ -63,6 +64,7 @@ columns: label: Subtotal invisible: true type: currency + currencyFrom: currency_code phone: label: Phone diff --git a/models/invoiceitem/columns.yaml b/models/invoiceitem/columns.yaml index 43f61ff..1a41fce 100644 --- a/models/invoiceitem/columns.yaml +++ b/models/invoiceitem/columns.yaml @@ -9,10 +9,12 @@ columns: price: label: Price type: currency + currencyFrom: invoice.currency_code discount: label: Discount type: currency + currencyFrom: invoice.currency_code quantity: label: Quantity @@ -22,3 +24,4 @@ columns: subtotal: label: Total type: currency + currencyFrom: invoice.currency_code diff --git a/models/invoiceitem/fields.yaml b/models/invoiceitem/fields.yaml index 8a5ecfa..3c5e0be 100644 --- a/models/invoiceitem/fields.yaml +++ b/models/invoiceitem/fields.yaml @@ -15,12 +15,14 @@ tabs: price: label: Unit Price type: currency + currencyFrom: invoice.currency_code span: row spanClass: col-5 discount: label: Unit Discount type: currency + currencyFrom: invoice.currency_code span: row spanClass: col-5 From b87dfa162f876d8b3218f7b93e61d95a30f0bdbe Mon Sep 17 00:00:00 2001 From: Samuel Georges Date: Sat, 14 Mar 2026 11:48:26 +1100 Subject: [PATCH 12/16] Switch to line item discount This simplifies the architecture significantly --- models/InvoiceItem.php | 18 ++++++++--------- models/invoice/HasCalculatedAttributes.php | 20 +++++++++++++++---- models/invoice/HasModelAttributes.php | 2 +- .../invoiceitem/HasCalculatedAttributes.php | 11 +++++----- models/invoiceitem/HasModelAttributes.php | 2 +- models/invoiceitem/fields.yaml | 2 +- updates/000003_create_invoice_items.php | 2 -- 7 files changed, 33 insertions(+), 24 deletions(-) diff --git a/models/InvoiceItem.php b/models/InvoiceItem.php index 76fd7eb..65179b8 100644 --- a/models/InvoiceItem.php +++ b/models/InvoiceItem.php @@ -8,15 +8,13 @@ * @property int $id * @property string $description * @property int $quantity quantity the total quantity of units - * @property int $price - * @property int $price_less_tax - * @property int $price_with_tax - * @property int $discount - * @property int $discount_less_tax - * @property int $discount_with_tax - * @property int $tax - * @property int $subtotal - * @property int $total + * @property int $price price the per-unit price + * @property int $price_less_tax price_less_tax the per-unit price excluding tax + * @property int $price_with_tax price_with_tax the per-unit price including tax + * @property int $discount discount the line discount amount (total for the row) + * @property int $tax tax the line tax amount + * @property int $subtotal subtotal the line subtotal (qty * price - discount) + * @property int $total total the line total (subtotal + tax) * @property int $sort_order * @property int $related_id * @property string $related_type @@ -72,7 +70,7 @@ public function beforeSave() $this->tax_class = Tax::getDefault(); } - $this->subtotal = $this->quantity * ($this->price - $this->discount); + $this->subtotal = $this->quantity * $this->price - $this->discount; $this->total = $this->subtotal + $this->tax; } diff --git a/models/invoice/HasCalculatedAttributes.php b/models/invoice/HasCalculatedAttributes.php index fa249c3..18fdc1d 100644 --- a/models/invoice/HasCalculatedAttributes.php +++ b/models/invoice/HasCalculatedAttributes.php @@ -26,7 +26,7 @@ public function evalInvoiceTotals(array $options = []) Tax::setLocationContext($address); Tax::setPricesIncludeTax((bool) $this->prices_include_tax); - // Calculate totals for order items + // Calculate totals for invoice items if ($items !== null) { $this->discount = 0; $this->discount_tax = 0; @@ -34,9 +34,21 @@ public function evalInvoiceTotals(array $options = []) foreach ($items as $item) { if ($item instanceof InvoiceItem) { - $this->discount += $item->quantity * $item->discount; - $this->discount_tax += $item->quantity * ($item->discount_with_tax - $item->discount); - $this->subtotal += $item->quantity * ($item->price - $item->discount); + $this->discount += $item->discount; + $this->subtotal += $item->quantity * $item->price - $item->discount; + + // Calculate the tax portion of the discount + if ($item->discount) { + $taxClass = Tax::findByKey($item->tax_class_id); + if ($taxClass) { + if ($this->prices_include_tax) { + $this->discount_tax += $taxClass->getTotalUntax($item->discount, $address); + } + else { + $this->discount_tax += $taxClass->getTotalTax($item->discount, $address); + } + } + } } } diff --git a/models/invoice/HasModelAttributes.php b/models/invoice/HasModelAttributes.php index 35f3559..7dd79f8 100644 --- a/models/invoice/HasModelAttributes.php +++ b/models/invoice/HasModelAttributes.php @@ -40,7 +40,7 @@ public function getInvoiceNumberAttribute() */ public function getOriginalSubtotalAttribute(): int { - return $this->subtotal - $this->discount; + return $this->subtotal + $this->discount; } /** diff --git a/models/invoiceitem/HasCalculatedAttributes.php b/models/invoiceitem/HasCalculatedAttributes.php index 49b0d63..e29e8b8 100644 --- a/models/invoiceitem/HasCalculatedAttributes.php +++ b/models/invoiceitem/HasCalculatedAttributes.php @@ -24,7 +24,6 @@ public function evalInvoiceItemTotals(array $options = []) // Defaults $this->price_with_tax = $this->price_less_tax = $this->price; - $this->discount_with_tax = $this->discount_less_tax = $this->discount; $this->tax = 0; // Recalculate taxes @@ -35,13 +34,15 @@ public function evalInvoiceItemTotals(array $options = []) if ($this->prices_include_tax) { $this->price_less_tax = $this->price - $taxClass->getTotalUntax($this->price, $address); - $this->discount_less_tax = $this->discount - $taxClass->getTotalUntax($this->discount, $address); - $this->tax = ($this->price - $this->price_less_tax) - ($this->discount - $this->discount_less_tax); + $priceTax = ($this->price - $this->price_less_tax) * $this->quantity; + $discountTax = $taxClass->getTotalUntax($this->discount, $address); } else { $this->price_with_tax = $this->price + $taxClass->getTotalTax($this->price, $address); - $this->discount_with_tax = $this->discount + $taxClass->getTotalTax($this->discount, $address); - $this->tax = ($this->price_with_tax - $this->price) - ($this->discount_with_tax - $this->discount); + $priceTax = ($this->price_with_tax - $this->price) * $this->quantity; + $discountTax = $taxClass->getTotalTax($this->discount, $address); } + + $this->tax = $priceTax - $discountTax; } } diff --git a/models/invoiceitem/HasModelAttributes.php b/models/invoiceitem/HasModelAttributes.php index ffc84a2..25a4b8a 100644 --- a/models/invoiceitem/HasModelAttributes.php +++ b/models/invoiceitem/HasModelAttributes.php @@ -16,7 +16,7 @@ public function setDiscountAttribute($amount) if (strpos($amount, '%') !== false) { $amount = str_replace('%', '', $amount); - $amount = $this->price * ($amount / 100); + $amount = $this->quantity * $this->price * ($amount / 100); } $this->attributes['discount'] = $amount; diff --git a/models/invoiceitem/fields.yaml b/models/invoiceitem/fields.yaml index 3c5e0be..430518a 100644 --- a/models/invoiceitem/fields.yaml +++ b/models/invoiceitem/fields.yaml @@ -20,7 +20,7 @@ tabs: spanClass: col-5 discount: - label: Unit Discount + label: Discount type: currency currencyFrom: invoice.currency_code span: row diff --git a/updates/000003_create_invoice_items.php b/updates/000003_create_invoice_items.php index 0a940d9..9376495 100644 --- a/updates/000003_create_invoice_items.php +++ b/updates/000003_create_invoice_items.php @@ -15,8 +15,6 @@ public function up() $table->bigInteger('price_less_tax')->nullable(); $table->bigInteger('price_with_tax')->nullable(); $table->bigInteger('discount')->nullable(); - $table->bigInteger('discount_less_tax')->nullable(); - $table->bigInteger('discount_with_tax')->nullable(); $table->bigInteger('subtotal')->nullable(); $table->bigInteger('tax')->nullable(); $table->bigInteger('total')->nullable(); From d4722c29e27b19d0dfeccfb8c39d040266e8228a Mon Sep 17 00:00:00 2001 From: Samuel Georges Date: Sat, 14 Mar 2026 12:17:59 +1100 Subject: [PATCH 13/16] Fixes compat with new datatable --- models/Tax.php | 44 ++++++++++---------------------------------- 1 file changed, 10 insertions(+), 34 deletions(-) diff --git a/models/Tax.php b/models/Tax.php index 83c164a..2a23f1e 100644 --- a/models/Tax.php +++ b/models/Tax.php @@ -148,8 +148,8 @@ public function getTaxRates($amount, array $options = []) foreach ($compoundTaxes as $compoundTax) { $taxInfo = []; - $taxInfo['name'] = $compoundTax->name; - $taxInfo['taxRate'] = $compoundTax->rate / 100; + $taxInfo['name'] = $compoundTax['name']; + $taxInfo['taxRate'] = $compoundTax['rate'] / 100; $taxInfo['compoundTax'] = true; $taxInfo['addedTax'] = false; $taxInfo['rate'] = $pricesIncludeTax @@ -205,7 +205,7 @@ protected function getRate(array $options = []) continue; } - $isCompound = isset($row['compound']) ? $row['compound'] : 0; + $isCompound = isset($row['is_compound']) ? $row['is_compound'] : 0; if (preg_match('/^[0-9]+$/', $isCompound)) { $isCompound = (int) $isCompound; } @@ -406,19 +406,9 @@ public function getDataTableOptions($attribute, $field, $data) */ protected function getCountryList($term) { - $result = ['*' => __("* - Any Country")]; + $codes = Country::applyEnabled()->lists('code'); - // The search term functionality is disabled as it's not supported - // by the Table widget's drop-down processor -ab 2015-01-03 - //$countries = Country::searchWhere($term, ['name', 'code']) - - $countries = Country::applyEnabled()->lists('name', 'code'); - - foreach ($countries as $code => $name) { - $result[$code] = $code .' - ' . $name; - } - - return $result; + return array_merge(['*'], $codes); } /** @@ -426,29 +416,15 @@ protected function getCountryList($term) */ protected function getStateList($countryCode, $term) { - $result = ['*' => __("* - Any State")]; - if (!$countryCode || $countryCode == '*') { - return $result; + return ['*']; } - // The search term functionality is disabled as it's not supported - // by the Table widget's drop-down processor -ab 2015-01-03 - // $states = State::searchWhere($term, ['name', 'code']); + $codes = State::whereHas('country', function($query) use ($countryCode) { + $query->where('code', $countryCode); + })->limit(10)->lists('code'); - if ($countryCode) { - $states = State::whereHas('country', function($query) use ($countryCode) { - $query->where('code', $countryCode); - }); - } - - $states = $states->limit(10)->lists('name', 'code'); - - foreach ($states as $code => $name) { - $result[$code] = $code .' - ' . $name; - } - - return $result; + return array_merge(['*'], $codes); } /** From 86de876958559266cb0510b5aad53d0d8e1f157c Mon Sep 17 00:00:00 2001 From: Samuel Georges Date: Sat, 14 Mar 2026 12:34:08 +1100 Subject: [PATCH 14/16] Fixes tax calculations --- models/Tax.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/models/Tax.php b/models/Tax.php index 2a23f1e..811ad18 100644 --- a/models/Tax.php +++ b/models/Tax.php @@ -62,10 +62,10 @@ public function getTotalTax($amount) { $result = 0; - $taxes = $this->getTaxRates($amount); + $taxes = $this->getTaxRates($amount, ['pricesIncludeTax' => false]); foreach ($taxes as $tax) { - $result += ($tax['taxRate'] * $amount) / (1 + $tax['taxRate']); + $result += $tax['rate']; } return round($result, $this->roundPrecision); @@ -79,10 +79,10 @@ public function getTotalUntax($amount) { $result = 0; - $taxes = $this->getTaxRates($amount); + $taxes = $this->getTaxRates($amount, ['pricesIncludeTax' => true]); foreach ($taxes as $tax) { - $result += ($tax['taxRate'] * $amount) / (1 + $tax['taxRate']); + $result += $tax['rate']; } return round($result, $this->roundPrecision); From 3b32d7fa69ee423b58a05a1958429c28a87cfe3c Mon Sep 17 00:00:00 2001 From: Samuel Georges Date: Sat, 14 Mar 2026 15:27:30 +1100 Subject: [PATCH 15/16] Adds tax context method --- models/tax/HasGlobalContext.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/models/tax/HasGlobalContext.php b/models/tax/HasGlobalContext.php index 35cde14..e696817 100644 --- a/models/tax/HasGlobalContext.php +++ b/models/tax/HasGlobalContext.php @@ -61,4 +61,28 @@ public static function setPricesIncludeTax($pricesIncludeTax) { static::$pricesIncludeTax = (bool) $pricesIncludeTax; } + + /** + * withContext executes a callback with a temporary tax context, + * restoring the previous context when done. + */ + public static function withContext(?TaxLocation $location, bool $pricesIncludeTax, callable $callback) + { + $prevLocation = static::$locationContext; + $prevPricesIncludeTax = static::$pricesIncludeTax; + $prevUserContext = static::$userContext; + $prevTaxExempt = static::$taxExempt; + + try { + static::$locationContext = $location; + static::$pricesIncludeTax = $pricesIncludeTax; + return $callback(); + } + finally { + static::$locationContext = $prevLocation; + static::$pricesIncludeTax = $prevPricesIncludeTax; + static::$userContext = $prevUserContext; + static::$taxExempt = $prevTaxExempt; + } + } } From 344abb7abc03457e5e731c19b7b7885076f8d171 Mon Sep 17 00:00:00 2001 From: Samuel Georges Date: Sat, 14 Mar 2026 20:30:04 +1100 Subject: [PATCH 16/16] Tests for tax rules --- models/Tax.php | 17 +- phpunit.xml | 31 ++ tests/TaxRuleTest.php | 868 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 905 insertions(+), 11 deletions(-) create mode 100644 phpunit.xml create mode 100644 tests/TaxRuleTest.php diff --git a/models/Tax.php b/models/Tax.php index 811ad18..ffdb56f 100644 --- a/models/Tax.php +++ b/models/Tax.php @@ -49,11 +49,6 @@ class Tax extends Model 'name' => 'required', ]; - /** - * @var int roundPrecision - */ - protected $roundPrecision = 2; - /** * getTotalTax adds tax to an untaxed amount. Return value is the tax amount * to add to the final price. @@ -68,7 +63,7 @@ public function getTotalTax($amount) $result += $tax['rate']; } - return round($result, $this->roundPrecision); + return round($result); } /** @@ -85,7 +80,7 @@ public function getTotalUntax($amount) $result += $tax['rate']; } - return round($result, $this->roundPrecision); + return round($result); } /** @@ -139,8 +134,8 @@ public function getTaxRates($amount, array $options = []) $taxInfo['addedTax'] = true; $taxInfo['compoundTax'] = false; $taxInfo['rate'] = $pricesIncludeTax - ? round(($amount * $taxInfo['taxRate']) / (1 + $taxInfo['taxRate']), $this->roundPrecision) - : round($amount * $taxInfo['taxRate'], $this->roundPrecision); + ? round(($amount * $taxInfo['taxRate']) / (1 + $taxInfo['taxRate'])) + : round($amount * $taxInfo['taxRate']); $taxInfo['total'] = $taxInfo['rate']; $result[] = $taxInfo; $addedResult += $taxInfo['rate']; @@ -153,8 +148,8 @@ public function getTaxRates($amount, array $options = []) $taxInfo['compoundTax'] = true; $taxInfo['addedTax'] = false; $taxInfo['rate'] = $pricesIncludeTax - ? round(($addedResult * $taxInfo['taxRate']) / (1 + $taxInfo['taxRate']), $this->roundPrecision) - : round($addedResult * $taxInfo['taxRate'], $this->roundPrecision); + ? round(($addedResult * $taxInfo['taxRate']) / (1 + $taxInfo['taxRate'])) + : round($addedResult * $taxInfo['taxRate']); $taxInfo['total'] = $taxInfo['rate']; $result[] = $taxInfo; } diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..0e72cb8 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,31 @@ + + + + + ./tests + ./tests/browser + + + + + + + + + + + + + + + diff --git a/tests/TaxRuleTest.php b/tests/TaxRuleTest.php new file mode 100644 index 0000000..1d10003 --- /dev/null +++ b/tests/TaxRuleTest.php @@ -0,0 +1,868 @@ + $name, + 'rates' => $rates, + ]); + Model::reguard(); + + return $tax; + } + + /** + * makeLocation creates a TaxLocation with country and optional state + */ + protected function makeLocation(string $countryCode, ?string $stateCode = null): TaxLocation + { + $location = new TaxLocation; + $location->countryCode($countryCode); + if ($stateCode !== null) { + $location->stateCode($stateCode); + } + return $location; + } + + // + // USA Tax Tests + // + + /** + * testCaliforniaStateSalesTax — California charges a flat 7.25% state + * sales tax on all purchases. This is the base rate before any local + * district taxes are added. + */ + public function testCaliforniaStateSalesTax() + { + $tax = $this->createTaxClass('California Sales Tax', [ + [ + 'tax_name' => 'CA State Tax', + 'rate' => 7.25, + 'country' => 'US', + 'state' => 'CA', + 'priority' => 1, + 'is_compound' => 0, + ], + ]); + + Tax::setLocationContext($this->makeLocation('US', 'CA')); + + // $100.00 item → $7.25 tax + $this->assertEquals(725, $tax->getTotalTax(10000)); + + // $49.99 item → 362.4275 cents → rounds to 362 cents + $this->assertEquals(362, $tax->getTotalTax(4999)); + } + + /** + * testNewYorkCitySalesTax — NYC has three additive tax layers: + * state (4%), city (4.5%), and MCTD surcharge (0.375%), totaling 8.875%. + * In the USA, all sales tax layers are additive (not compound) — each + * is calculated independently on the base price and then summed. + * + * The system models this as two separate rate rows with priority 1 and 2. + * Each priority level can match one rate, so we split the NYC tax into + * two rows: state tax at priority 1 and city+MCTD combined at priority 2. + */ + public function testNewYorkCitySalesTax() + { + $tax = $this->createTaxClass('New York Tax', [ + [ + 'tax_name' => 'NY State Tax', + 'rate' => 4, + 'country' => 'US', + 'state' => 'NY', + 'priority' => 1, + 'is_compound' => 0, + ], + [ + 'tax_name' => 'NYC Local Tax', + 'rate' => 4.875, + 'country' => 'US', + 'state' => 'NY', + 'priority' => 2, + 'is_compound' => 0, + ], + ]); + + Tax::setLocationContext($this->makeLocation('US', 'NY')); + + // $100.00 item + // NY State: 10000 * 0.04 = 400 + // NYC Local: 10000 * 0.04875 = 487.5 → rounds to 488 + // Total tax: 400 + 488 = 888 + $rates = $tax->getTaxRates(10000); + $this->assertCount(2, $rates); + + $this->assertEquals('NY State Tax', $rates[0]['name']); + $this->assertEquals(400, $rates[0]['rate']); + $this->assertFalse($rates[0]['compoundTax']); + + $this->assertEquals('NYC Local Tax', $rates[1]['name']); + $this->assertEquals(488, $rates[1]['rate']); + $this->assertFalse($rates[1]['compoundTax']); + + $this->assertEquals(888, $tax->getTotalTax(10000)); + } + + /** + * testTexasCombinedSalesTax — Texas has a state rate of 6.25% plus + * up to 2% local tax (city/county/transit), for a maximum combined + * rate of 8.25%. All layers are additive. + */ + public function testTexasCombinedSalesTax() + { + $tax = $this->createTaxClass('Texas Tax', [ + [ + 'tax_name' => 'TX State Tax', + 'rate' => 6.25, + 'country' => 'US', + 'state' => 'TX', + 'priority' => 1, + 'is_compound' => 0, + ], + [ + 'tax_name' => 'TX Local Tax', + 'rate' => 2, + 'country' => 'US', + 'state' => 'TX', + 'priority' => 2, + 'is_compound' => 0, + ], + ]); + + Tax::setLocationContext($this->makeLocation('US', 'TX')); + + // $200.00 item + // TX State: 20000 * 0.0625 = 1250 + // TX Local: 20000 * 0.02 = 400 + // Total tax: 1650 + $this->assertEquals(1250, $tax->getTaxRates(20000)[0]['rate']); + $this->assertEquals(400, $tax->getTaxRates(20000)[1]['rate']); + $this->assertEquals(1650, $tax->getTotalTax(20000)); + } + + /** + * testUsStateOnlyMatchesCorrectState — A California tax rate should + * not apply when the buyer is located in Texas. + */ + public function testUsStateOnlyMatchesCorrectState() + { + $tax = $this->createTaxClass('California Sales Tax', [ + [ + 'tax_name' => 'CA State Tax', + 'rate' => 7.25, + 'country' => 'US', + 'state' => 'CA', + 'priority' => 1, + 'is_compound' => 0, + ], + ]); + + // Buyer in Texas — should NOT match California rate + Tax::setLocationContext($this->makeLocation('US', 'TX')); + $this->assertEquals(0, $tax->getTotalTax(10000)); + + // Buyer in California — should match + Tax::setLocationContext($this->makeLocation('US', 'CA')); + $this->assertEquals(725, $tax->getTotalTax(10000)); + } + + // + // Germany Tax Tests + // + + /** + * testGermanyStandardVat — Germany charges 19% Mehrwertsteuer (MwSt) + * on most goods and services. + */ + public function testGermanyStandardVat() + { + $tax = $this->createTaxClass('German VAT', [ + [ + 'tax_name' => 'MwSt 19%', + 'rate' => 19, + 'country' => 'DE', + 'state' => '*', + 'priority' => 1, + 'is_compound' => 0, + ], + ]); + + Tax::setLocationContext($this->makeLocation('DE')); + + // €50.00 → €9.50 tax + $this->assertEquals(950, $tax->getTotalTax(5000)); + + // €119.00 → €22.61 tax + $this->assertEquals(2261, $tax->getTotalTax(11900)); + } + + /** + * testGermanyReducedVat — Germany charges a reduced 7% VAT on food, + * books, newspapers, and some other categories. + */ + public function testGermanyReducedVat() + { + $tax = $this->createTaxClass('German Reduced VAT', [ + [ + 'tax_name' => 'MwSt 7%', + 'rate' => 7, + 'country' => 'DE', + 'state' => '*', + 'priority' => 1, + 'is_compound' => 0, + ], + ]); + + Tax::setLocationContext($this->makeLocation('DE')); + + // €100.00 → €7.00 tax + $this->assertEquals(700, $tax->getTotalTax(10000)); + } + + /** + * testGermanyVatInclusivePricing — In Germany, consumer prices include + * VAT. Extracting 19% VAT from a €119.00 gross price should yield + * €19.00 tax and €100.00 net. + */ + public function testGermanyVatInclusivePricing() + { + $tax = $this->createTaxClass('German VAT', [ + [ + 'tax_name' => 'MwSt 19%', + 'rate' => 19, + 'country' => 'DE', + 'state' => '*', + 'priority' => 1, + 'is_compound' => 0, + ], + ]); + + Tax::setLocationContext($this->makeLocation('DE')); + + // €119.00 gross (inc. 19% VAT) + // Tax = (11900 * 0.19) / 1.19 = 1900 + $this->assertEquals(1900, $tax->getTotalUntax(11900)); + + // €100.00 gross (inc. 19% VAT) + // Tax = (10000 * 0.19) / 1.19 = 1596.64 → rounds to 1597 + $this->assertEquals(1597, $tax->getTotalUntax(10000)); + } + + /** + * testGermanyVatDoesNotApplyOutsideCountry — German VAT should not + * apply to a buyer located in the USA. + */ + public function testGermanyVatDoesNotApplyOutsideCountry() + { + $tax = $this->createTaxClass('German VAT', [ + [ + 'tax_name' => 'MwSt 19%', + 'rate' => 19, + 'country' => 'DE', + 'state' => '*', + 'priority' => 1, + 'is_compound' => 0, + ], + ]); + + Tax::setLocationContext($this->makeLocation('US', 'CA')); + + $this->assertEquals(0, $tax->getTotalTax(10000)); + } + + // + // Canada Tax Tests + // + + /** + * testOntarioHst — Ontario uses a harmonized sales tax (HST) of 13% + * that combines the 5% federal GST with the 8% provincial portion + * into a single tax. + */ + public function testOntarioHst() + { + $tax = $this->createTaxClass('Ontario HST', [ + [ + 'tax_name' => 'HST', + 'rate' => 13, + 'country' => 'CA', + 'state' => 'ON', + 'priority' => 1, + 'is_compound' => 0, + ], + ]); + + Tax::setLocationContext($this->makeLocation('CA', 'ON')); + + // $100.00 → $13.00 HST + $this->assertEquals(1300, $tax->getTotalTax(10000)); + + // $75.00 → $9.75 HST + $this->assertEquals(975, $tax->getTotalTax(7500)); + } + + /** + * testBritishColumbiaGstPlusPst — British Columbia charges GST (5%) + * and PST (7%) separately. Both are additive — each is calculated + * independently on the base price, for a combined 12%. + */ + public function testBritishColumbiaGstPlusPst() + { + $tax = $this->createTaxClass('BC Tax', [ + [ + 'tax_name' => 'GST', + 'rate' => 5, + 'country' => 'CA', + 'state' => 'BC', + 'priority' => 1, + 'is_compound' => 0, + ], + [ + 'tax_name' => 'BC PST', + 'rate' => 7, + 'country' => 'CA', + 'state' => 'BC', + 'priority' => 2, + 'is_compound' => 0, + ], + ]); + + Tax::setLocationContext($this->makeLocation('CA', 'BC')); + + // $100.00 item + // GST: 10000 * 0.05 = 500 + // PST: 10000 * 0.07 = 700 + // Total tax: 1200 + $rates = $tax->getTaxRates(10000); + $this->assertCount(2, $rates); + + $this->assertEquals('GST', $rates[0]['name']); + $this->assertEquals(500, $rates[0]['rate']); + + $this->assertEquals('BC PST', $rates[1]['name']); + $this->assertEquals(700, $rates[1]['rate']); + + $this->assertEquals(1200, $tax->getTotalTax(10000)); + } + + /** + * testSaskatchewanGstPlusPst — Saskatchewan charges GST (5%) and + * PST (6%) separately, both additive, for a combined 11%. + */ + public function testSaskatchewanGstPlusPst() + { + $tax = $this->createTaxClass('SK Tax', [ + [ + 'tax_name' => 'GST', + 'rate' => 5, + 'country' => 'CA', + 'state' => 'SK', + 'priority' => 1, + 'is_compound' => 0, + ], + [ + 'tax_name' => 'SK PST', + 'rate' => 6, + 'country' => 'CA', + 'state' => 'SK', + 'priority' => 2, + 'is_compound' => 0, + ], + ]); + + Tax::setLocationContext($this->makeLocation('CA', 'SK')); + + // $200.00 item + // GST: 20000 * 0.05 = 1000 + // PST: 20000 * 0.06 = 1200 + // Total: 2200 + $this->assertEquals(2200, $tax->getTotalTax(20000)); + } + + /** + * testAlbertaGstOnly — Alberta has no provincial sales tax. Only the + * federal 5% GST applies. + */ + public function testAlbertaGstOnly() + { + $tax = $this->createTaxClass('Alberta Tax', [ + [ + 'tax_name' => 'GST', + 'rate' => 5, + 'country' => 'CA', + 'state' => 'AB', + 'priority' => 1, + 'is_compound' => 0, + ], + ]); + + Tax::setLocationContext($this->makeLocation('CA', 'AB')); + + // $100.00 → $5.00 GST only + $this->assertEquals(500, $tax->getTotalTax(10000)); + } + + // + // Compound Tax Tests + // + + /** + * testQuebecPreHarmonizationCompoundTax — Before January 1, 2013, + * Quebec's QST (9.975%) was compound: it was calculated on the + * base price PLUS the GST, creating a "tax on tax" effect. + * + * This is the most well-known real-world example of compound taxation. + * + * $100.00 item: + * GST = $100.00 × 5% = $5.00 + * QST = ($100.00 + $5.00) × 9.975% = $10.47 (rounded) + * Total tax = $15.47 + */ + public function testQuebecPreHarmonizationCompoundTax() + { + $tax = $this->createTaxClass('Quebec Pre-2013 Tax', [ + [ + 'tax_name' => 'GST', + 'rate' => 5, + 'country' => 'CA', + 'state' => 'QC', + 'priority' => 1, + 'is_compound' => 0, + ], + [ + 'tax_name' => 'QST', + 'rate' => 9.975, + 'country' => 'CA', + 'state' => 'QC', + 'priority' => 2, + 'is_compound' => 1, + ], + ]); + + Tax::setLocationContext($this->makeLocation('CA', 'QC')); + + $rates = $tax->getTaxRates(10000); + $this->assertCount(2, $rates); + + // GST: 10000 * 0.05 = 500 + $this->assertEquals('GST', $rates[0]['name']); + $this->assertEquals(500, $rates[0]['rate']); + $this->assertTrue($rates[0]['addedTax']); + $this->assertFalse($rates[0]['compoundTax']); + + // QST (compound): (10000 + 500) * 0.09975 = 1047.375 → rounds to 1047 + $this->assertEquals('QST', $rates[1]['name']); + $this->assertEquals(1047, $rates[1]['rate']); + $this->assertTrue($rates[1]['compoundTax']); + $this->assertFalse($rates[1]['addedTax']); + + // Total tax: 500 + 1047 = 1547 + $this->assertEquals(1547, $tax->getTotalTax(10000)); + } + + /** + * testQuebecCurrentNonCompound — Since January 1, 2013, Quebec's QST + * is no longer compound. Both GST (5%) and QST (9.975%) are calculated + * independently on the base price, for a combined 14.975%. + */ + public function testQuebecCurrentNonCompound() + { + $tax = $this->createTaxClass('Quebec Current Tax', [ + [ + 'tax_name' => 'GST', + 'rate' => 5, + 'country' => 'CA', + 'state' => 'QC', + 'priority' => 1, + 'is_compound' => 0, + ], + [ + 'tax_name' => 'QST', + 'rate' => 9.975, + 'country' => 'CA', + 'state' => 'QC', + 'priority' => 2, + 'is_compound' => 0, + ], + ]); + + Tax::setLocationContext($this->makeLocation('CA', 'QC')); + + // $100.00 item + // GST: 10000 * 0.05 = 500 + // QST: 10000 * (9.975/100) = 997 (float precision: 9.975/100 = 0.09974999...) + // Total: 1497 + $rates = $tax->getTaxRates(10000); + $this->assertCount(2, $rates); + + $this->assertEquals(500, $rates[0]['rate']); + $this->assertFalse($rates[0]['compoundTax']); + + $this->assertEquals(997, $rates[1]['rate']); + $this->assertFalse($rates[1]['compoundTax']); + + $this->assertEquals(1497, $tax->getTotalTax(10000)); + } + + /** + * testCompoundVsAdditiveProducesDifferentResults — Demonstrates the + * mathematical difference between compound and additive tax. With the + * same rates, compound tax always produces a higher total because it + * creates a "tax on tax" effect. + */ + public function testCompoundVsAdditiveProducesDifferentResults() + { + // Two taxes: 5% and 10%. Additive version. + $additive = $this->createTaxClass('Additive', [ + [ + 'tax_name' => 'Tax A', + 'rate' => 5, + 'country' => 'US', + 'state' => '*', + 'priority' => 1, + 'is_compound' => 0, + ], + [ + 'tax_name' => 'Tax B', + 'rate' => 10, + 'country' => 'US', + 'state' => '*', + 'priority' => 2, + 'is_compound' => 0, + ], + ]); + + // Same two taxes but Tax B is compound. + $compound = $this->createTaxClass('Compound', [ + [ + 'tax_name' => 'Tax A', + 'rate' => 5, + 'country' => 'US', + 'state' => '*', + 'priority' => 1, + 'is_compound' => 0, + ], + [ + 'tax_name' => 'Tax B', + 'rate' => 10, + 'country' => 'US', + 'state' => '*', + 'priority' => 2, + 'is_compound' => 1, + ], + ]); + + Tax::setLocationContext($this->makeLocation('US')); + + // $100.00 item — additive + // Tax A: 10000 * 0.05 = 500 + // Tax B: 10000 * 0.10 = 1000 + // Total: 1500 + $this->assertEquals(1500, $additive->getTotalTax(10000)); + + // $100.00 item — compound + // Tax A: 10000 * 0.05 = 500 + // Tax B: (10000 + 500) * 0.10 = 1050 + // Total: 1550 + $this->assertEquals(1550, $compound->getTotalTax(10000)); + + // Compound total is higher + $this->assertGreaterThan( + $additive->getTotalTax(10000), + $compound->getTotalTax(10000) + ); + } + + /** + * testCompoundTaxInclusivePricing — When prices include compound tax, + * extracting the tax requires working backwards through the compound + * calculation. Uses Quebec pre-2013 rates as the real-world example. + */ + public function testCompoundTaxInclusivePricing() + { + $tax = $this->createTaxClass('Quebec Pre-2013', [ + [ + 'tax_name' => 'GST', + 'rate' => 5, + 'country' => 'CA', + 'state' => 'QC', + 'priority' => 1, + 'is_compound' => 0, + ], + [ + 'tax_name' => 'QST', + 'rate' => 9.975, + 'country' => 'CA', + 'state' => 'QC', + 'priority' => 2, + 'is_compound' => 1, + ], + ]); + + Tax::setLocationContext($this->makeLocation('CA', 'QC')); + + // Price inclusive of tax: 11547 cents + // This was calculated as: base=10000, GST=500, QST=1047 + // Extracting tax from 11547: + // GST untax: round((11547 * 0.05) / 1.05) = round(549.86) = 550 + // Compound base: 11547 + 550 = 12097 + // QST untax: round((12097 * 0.09975) / 1.09975) = round(1097.06) = 1097 + // Total untax: 550 + 1097 = 1647 + $totalUntax = $tax->getTotalUntax(11547); + $this->assertEquals(1647, $totalUntax); + } + + // + // Edge Cases and Cross-Cutting Tests + // + + /** + * testTaxExemptReturnsZero — When the tax-exempt flag is set, no tax + * should be calculated regardless of the tax class configuration. + */ + public function testTaxExemptReturnsZero() + { + $tax = $this->createTaxClass('German VAT', [ + [ + 'tax_name' => 'MwSt 19%', + 'rate' => 19, + 'country' => 'DE', + 'state' => '*', + 'priority' => 1, + 'is_compound' => 0, + ], + ]); + + Tax::setLocationContext($this->makeLocation('DE')); + Tax::setTaxExempt(true); + + $this->assertEquals(0, $tax->getTotalTax(10000)); + $this->assertEmpty($tax->getTaxRates(10000)); + } + + /** + * testNoLocationContextReturnsZero — When no location context is set, + * the system cannot determine which tax rates apply and returns zero. + */ + public function testNoLocationContextReturnsZero() + { + $tax = $this->createTaxClass('German VAT', [ + [ + 'tax_name' => 'MwSt 19%', + 'rate' => 19, + 'country' => 'DE', + 'state' => '*', + 'priority' => 1, + 'is_compound' => 0, + ], + ]); + + // No location context set + $this->assertEquals(0, $tax->getTotalTax(10000)); + } + + /** + * testWildcardStateMatchesAllStates — When a tax rate uses '*' for the + * state, it should apply to all states within that country. + */ + public function testWildcardStateMatchesAllStates() + { + $tax = $this->createTaxClass('Country-wide Tax', [ + [ + 'tax_name' => 'National Tax', + 'rate' => 10, + 'country' => 'AU', + 'state' => '*', + 'priority' => 1, + 'is_compound' => 0, + ], + ]); + + // Should match any Australian state + Tax::setLocationContext($this->makeLocation('AU', 'NSW')); + $this->assertEquals(1000, $tax->getTotalTax(10000)); + + Tax::setLocationContext($this->makeLocation('AU', 'VIC')); + $this->assertEquals(1000, $tax->getTotalTax(10000)); + + Tax::setLocationContext($this->makeLocation('AU', 'QLD')); + $this->assertEquals(1000, $tax->getTotalTax(10000)); + } + + /** + * testCalculateTaxesAcrossMultipleItems — Verifies the static + * calculateTaxes method correctly sums taxes across multiple cart + * items with different quantities. Uses Ontario HST as example. + */ + public function testCalculateTaxesAcrossMultipleItems() + { + $tax = $this->createTaxClass('Ontario HST', [ + [ + 'tax_name' => 'HST', + 'rate' => 13, + 'country' => 'CA', + 'state' => 'ON', + 'priority' => 1, + 'is_compound' => 0, + ], + ]); + + Tax::setLocationContext($this->makeLocation('CA', 'ON')); + + // Item 1: $50.00 × 2 = $100.00 + $item1 = new TaxItem; + $item1->taxClassId = $tax->id; + $item1->quantity = 2; + $item1->unitPrice = 5000; + + // Item 2: $25.00 × 4 = $100.00 + $item2 = new TaxItem; + $item2->taxClassId = $tax->id; + $item2->quantity = 4; + $item2->unitPrice = 2500; + + $result = Tax::calculateTaxes([$item1, $item2]); + + // Total base: $200.00 + // HST 13%: $200.00 × 0.13 = $26.00 = 2600 + $this->assertEquals(2600, $result['taxTotal']); + $this->assertArrayHasKey('HST', $result['taxes']); + } + + /** + * testWithContextRestoresPreviousState — The withContext method should + * temporarily change the tax context and restore it afterwards. + */ + public function testWithContextRestoresPreviousState() + { + $tax = $this->createTaxClass('German VAT', [ + [ + 'tax_name' => 'MwSt 19%', + 'rate' => 19, + 'country' => 'DE', + 'state' => '*', + 'priority' => 1, + 'is_compound' => 0, + ], + ]); + + $usLocation = $this->makeLocation('US', 'CA'); + $deLocation = $this->makeLocation('DE'); + + // Set initial context to US + Tax::setLocationContext($usLocation); + $this->assertEquals(0, $tax->getTotalTax(10000)); + + // Temporarily switch to DE context + $result = Tax::withContext($deLocation, false, function() use ($tax) { + return $tax->getTotalTax(10000); + }); + $this->assertEquals(1900, $result); + + // Context should be restored to US + $this->assertEquals(0, $tax->getTotalTax(10000)); + } + + /** + * testMultipleRatesSameCountryDifferentStates — A single tax class + * can have different rates for different states. The system should + * select the matching rate based on the buyer's location. + */ + public function testMultipleRatesSameCountryDifferentStates() + { + $tax = $this->createTaxClass('US State Tax', [ + [ + 'tax_name' => 'CA State Tax', + 'rate' => 7.25, + 'country' => 'US', + 'state' => 'CA', + 'priority' => 1, + 'is_compound' => 0, + ], + [ + 'tax_name' => 'TX State Tax', + 'rate' => 6.25, + 'country' => 'US', + 'state' => 'TX', + 'priority' => 1, + 'is_compound' => 0, + ], + [ + 'tax_name' => 'NY State Tax', + 'rate' => 4, + 'country' => 'US', + 'state' => 'NY', + 'priority' => 1, + 'is_compound' => 0, + ], + ]); + + // California buyer + Tax::setLocationContext($this->makeLocation('US', 'CA')); + $this->assertEquals(725, $tax->getTotalTax(10000)); + + // Texas buyer + Tax::setLocationContext($this->makeLocation('US', 'TX')); + $this->assertEquals(625, $tax->getTotalTax(10000)); + + // New York buyer + Tax::setLocationContext($this->makeLocation('US', 'NY')); + $this->assertEquals(400, $tax->getTotalTax(10000)); + + // Oregon buyer (no matching rate → no tax) + Tax::setLocationContext($this->makeLocation('US', 'OR')); + $this->assertEquals(0, $tax->getTotalTax(10000)); + } +}