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/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/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 58b929f..afdee74 100644 --- a/components/payment/default.htm +++ b/components/payment/default.htm @@ -1,6 +1,6 @@ {% put scripts %} {% for method in paymentMethods %} - {{ method.renderPaymentScripts(invoice.currency ?invoice.currency.code : 'USD')|raw }} + {{ method.renderPaymentScripts()|raw }} {% endfor %} {% endput %} {% if invoice %} @@ -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/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/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/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/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/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. 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/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/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/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/models/Tax.php b/models/Tax.php index 83c164a..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. @@ -62,13 +57,13 @@ 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); + return round($result); } /** @@ -79,13 +74,13 @@ 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); + 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']; @@ -148,13 +143,13 @@ 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 - ? 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; } @@ -205,7 +200,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 +401,9 @@ public function getDataTableOptions($attribute, $field, $data) */ protected function getCountryList($term) { - $result = ['*' => __("* - Any Country")]; - - // 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; - } + $codes = Country::applyEnabled()->lists('code'); - return $result; + return array_merge(['*'], $codes); } /** @@ -426,29 +411,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']); - - if ($countryCode) { - $states = State::whereHas('country', function($query) use ($countryCode) { - $query->where('code', $countryCode); - }); - } + $codes = State::whereHas('country', function($query) use ($countryCode) { + $query->where('code', $countryCode); + })->limit(10)->lists('code'); - $states = $states->limit(10)->lists('name', 'code'); - - foreach ($states as $code => $name) { - $result[$code] = $code .' - ' . $name; - } - - return $result; + return array_merge(['*'], $codes); } /** 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/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 dc42aec..7dd79f8 100644 --- a/models/invoice/HasModelAttributes.php +++ b/models/invoice/HasModelAttributes.php @@ -1,16 +1,17 @@ subtotal - $this->discount; + return $this->subtotal + $this->discount; } /** @@ -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 */ @@ -83,13 +101,13 @@ public function getIsPastDueDateAttribute() } /** - * getCurrencyCodeAttribute returns `currency_code` + * getCurrencyObject returns the Currency model for this invoice's currency_code */ - public function getCurrencyCodeAttribute() + public function getCurrencyObject() { - return $this->currency - ? $this->currency->code - : Currency::getDefault()?->code; + return $this->currency_code + ? CurrencyModel::findByCode($this->currency_code) + : Currency::getActive(); } /** 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/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/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 93f65fa..430518a 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: discount + label: Discount + type: currency + currencyFrom: invoice.currency_code span: row spanClass: col-5 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; + } + } } diff --git a/paymenttypes/PayPalPayment.php b/paymenttypes/PayPalPayment.php index 44f343c..676c4a5 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; @@ -61,7 +62,8 @@ public function registerAccessPoints() { return [ 'paypal_rest_invoices' => 'processApiInvoices', - 'paypal_rest_invoice_capture' => 'processApiInvoiceCapture' + 'paypal_rest_invoice_capture' => 'processApiInvoiceCapture', + 'paypal_rest_webhook' => 'processWebhook' ]; } @@ -92,21 +94,13 @@ public function getInvoiceCaptureUrl() /** * getPayPalEndpoint */ - public function getPayPalEndpoint(): string + public function getPayPalEndpoint() { return $this->getHostObject()->test_mode ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; } - /** - * getPayPalClientId - */ - public function getPayPalClientId(): string - { - return $this->getHostObject()->test_mode ? 'test' : $this->getHostObject()->client_id; - } - /** * getPayPalNamespace */ @@ -118,14 +112,14 @@ public function getPayPalNamespace(): string /** * renderPaymentScripts */ - public function renderPaymentScripts($currency = 'USD') + public function renderPaymentScripts() { $queryParams = http_build_query([ - 'client-id' => $this->getPayPalClientId(), + 'client-id' => $this->getHostObject()->client_id, + 'currency' => Currency::getActiveCode(), 'components' => 'buttons', 'enable-funding' => 'venmo', 'disable-funding' => 'paylater,card', - 'currency' => $currency, ]); $scriptParams = [ @@ -226,32 +220,50 @@ 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(); + } + elseif ($captureStatus === 'PENDING') { + $invoice->updateInvoiceStatus(InvoiceStatus::STATUS_APPROVED); + } } return Response::json(['cms_redirect' => $invoice->getReceiptUrl()] + $response->json(), $response->status()); @@ -265,6 +277,209 @@ 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 + */ + public function getWebhookUrl() + { + return $this->makeAccessPointLink('paypal_rest_webhook'); + } + + /** + * processWebhook handles asynchronous PayPal webhook events, such as + * 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) + { + 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'] ?? ''; + $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; + + case 'PAYMENT.CAPTURE.REFUNDED': + if ($invoice = $this->findInvoiceFromWebhookEvent($event)) { + $invoice->logPaymentAttempt( + "Webhook PAYMENT.CAPTURE.REFUNDED: {$resourceId}", + true, [], $event, $body + ); + $invoice->updateInvoiceStatus(InvoiceStatus::STATUS_REFUNDED, 'Refunded via PayPal'); + } + break; + + case 'PAYMENT.CAPTURE.DENIED': + if ($invoice = $this->findInvoiceFromWebhookEvent($event)) { + $invoice->logPaymentAttempt( + "Webhook PAYMENT.CAPTURE.DENIED: {$resourceId}", + false, [], $event, $body + ); + $invoice->updateInvoiceStatus(InvoiceStatus::STATUS_VOID, 'Payment denied by PayPal'); + } + break; + } + + return Response::make('OK', 200); + } + catch (Exception $ex) { + Log::error('PayPal webhook error: ' . $ex->getMessage()); + return Response::make('Error', 500); + } + } + + /** + * 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. + * @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..fe6440b 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,25 @@
  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 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. +
  3. Click Add Webhook
  4. +
  5. + Enter your webhook URL: + getWebhookUrl()) ?> +
  6. +
  7. + Subscribe to the following events: + PAYMENT.CAPTURE.COMPLETED, + PAYMENT.CAPTURE.REFUNDED, + PAYMENT.CAPTURE.DENIED +
  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 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)); + } +} 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/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(); 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 new file mode 100644 index 0000000..3581381 --- /dev/null +++ b/updates/migrate_v3_0_0.php @@ -0,0 +1,29 @@ + true, + 'name' => 'Refunded', + 'code' => '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() + { + } +}; 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