Skip to content

feat(paypal): charge an invoice in its billing currency when the operator chooses to - #189

Open
ertugrulkeyvan wants to merge 3 commits into
Panelica:mainfrom
ertugrulkeyvan:feat/paypal-billing-currency
Open

ertugrulkeyvan wants to merge 3 commits into
Panelica:mainfrom
ertugrulkeyvan:feat/paypal-billing-currency

Conversation

@ertugrulkeyvan

Copy link
Copy Markdown
Contributor

What

PayPal gets the "Charge in the invoice's billing currency" option that Stripe (#172) and Mollie (#182) have. It is off by default.

When a customer is billed in another currency (Setup > Currencies), the invoice shows that currency at a rate frozen on the invoice. A shop pricing in euros that bills a customer in zloty shows that customer zł43.00 for a €10.00 invoice. PayPal charged €10.00 and left the conversion to the customer's bank. With the option on, PayPal charges zł43.00, the amount on the invoice.

What changes with the option on, for an invoice whose billing currency PayPal takes:

  • The buttons: the SDK is loaded in the billing currency, and the order is built for the amount due at the invoice's rate. As before, HUF, JPY and TWD have no decimals.
  • The capture: the capture endpoint and the webhook hold it to that currency. Its amount is recorded in the shop currency: a capture of exactly what was due records the amount due; any other amount is converted back at the invoice's rate. The currency check from fix(paypal): a capture is credited only in the currency the shop charges in #186 now expects the billing currency for such an invoice, so a capture in euros for a zloty invoice is refused, just as a zloty capture is refused with the option off.
  • Server-made orders: capture() and unattended renewals from a stored PayPal account (chargeStoredMethod) are charged the same way. A caller that names a currency keeps it.
  • Refunds: a refund goes back in the currency the capture was taken in, at the rate it was actually taken at: what PayPal took against what was recorded for it (the same approach as iyzico in fix(iyzico): an invoice billed in lira is charged the lira on the invoice #181). A full refund gives back exactly what was taken, a part stays in proportion, and never more than was taken. The capture is looked up only when the invoice was billed in another currency, so every other refund sends what it always sent.

What stays the same:

  • Currencies PayPal does not take: a billing currency outside PayPal's list (TRY, for one) is charged in the shop currency, as with the option off. BRL, CNY and MYR are left out as well: PayPal takes them "for in-country PayPal accounts only".
  • With the option off, nothing changes.

Unlike Stripe and Mollie, nothing about the conversion is written on the PayPal order. The browser builds the order, so nothing on it could be believed. What a capture is worth is worked out again from the invoice when it is captured, and from the recorded payment when it is refunded.

A note for operators, in the setting's description and in docs/guides/payment-gateways.md: the PayPal account must accept payments in those currencies (Payment receiving preferences). Otherwise PayPal holds them as pending, and the invoice stays unpaid until they are accepted.

How

  • PayPalModule:
    • billingCharge(): currency, value and rate, or null.
    • BILLING_CURRENCIES: PayPal's list, fetched 2026-10-12.
    • Public verifyInvoiceCapture($invoice, $captureId) and captureCurrency($invoice).
    • recordedAmount() and billedRefund().
    • The setting.
  • GatewayWebhookController::paypalCapture: uses verifyInvoiceCapture() when the module has it.
  • processWebhook(): looks the invoice up from the reference before verifying.

The work is split into three commits: the module and the controller, the docs, and the tests.

Tests

tests/Feature/PayPalBillingCurrencyTest.php, 14 tests, all against faked responses:

  • Option off: a zloty invoice is ordered in euros.
  • Option on: it is ordered in zloty at the invoice's rate. TRY and a euro invoice are ordered in euros.
  • Buttons: they load and order in zloty, and yen goes out without decimals.
  • Capture endpoint: zł43.00 pays a €10.00 invoice and records €10.00. A part (zł21.50) is recorded as €5.00.
  • Refused captures: a euro capture is refused for a zloty invoice with the option on, and a zloty capture with it off.
  • Webhook: it verifies against the invoice's currency and reports €10.00.
  • Unattended renewal: it orders zł43.00 and settles €10.00.
  • Zloty refunds: zł43.00 in full and zł10.75 for €2.50.
  • Euro refunds: they send euros, with no capture look-up.

Ten of them fail on main. PayPalCaptureCurrencyTest, PayPalTokenisationTest and PayPalWebhookSecurityTest pass unchanged (67).

Full suite: 4171 passed. The one failure is the CurrencyAndBackupTest db backup test, which fails on main too in my local setup.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant