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): ?>
- = $field->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 @@