From 9d8145e24575de6b98993011bdcce93ff14dd77a Mon Sep 17 00:00:00 2001 From: Isha Dewangan Date: Mon, 12 Oct 2026 21:10:15 +0530 Subject: [PATCH 1/8] feat(api): describe every installed gateway for settings screens A consumer building a gateway settings screen had to keep its own list of gateways, with each one's description, docs link and settings DocType, so every new gateway meant a change in each consumer. - api.get_gateways() returns a GatewayInfo (name, settings_doctype, description, docs_url, capabilities, logos) for every gateway Settings Single on the site, enabled or not, including another app's gateways. - The contract gains description, docs_url and capabilities class attributes. REUSABLE_CHECKOUT says the checkout link stays payable after the shopper leaves it. - Razorpay, Stripe, Telr and Tabby describe themselves. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 5 +++- bwh_payments/api.py | 24 +++++++++++++++- bwh_payments/base_class.py | 19 +++++++++++++ .../razorpay_gateway_settings.py | 2 ++ .../stripe_gateway_settings.py | 3 ++ .../tabby_gateway_settings.py | 3 ++ .../telr_gateway_settings.py | 3 ++ bwh_payments/bwh_payments/utils.py | 11 ++++++++ bwh_payments/tests/test_api.py | 28 +++++++++++++++++++ 9 files changed, 96 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3c7fdcc..067a44f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,8 @@ PR titles must be conventional commits (`fix: ...`). Label a merged PR `backport `refund_payment`, `handle_webhook`. Optional methods have defaults, so a third-party gateway that skips them keeps working: `cancel_session` (False), `get_transaction_ref` (the settled payment's id; None), `get_refund_status` (None = cannot report), `supported_currencies` (None = any), and the `logo` / -`logo_dark` class attributes. `create_session` also takes an optional `return_url`, passed only when set. +`logo_dark`, `description`, `docs_url` and `capabilities` class attributes. `REUSABLE_CHECKOUT` in +`capabilities` means the checkout link stays payable after the shopper leaves it. `create_session` also takes an optional `return_url`, passed only when set. Results are pydantic models: `CheckoutSession`, `RefundResult`, and `WebhookEvent | None` (a payment event carries `session_id`, a refund event `refund_id`, `refund_amount` and `transaction_ref`). Callers read them through `.model_validate`, so a third-party gateway that still returns dicts keeps working. Each gateway is a @@ -89,6 +90,8 @@ as it stands. `company`, `redirect_to` (a path on this site) and `customer_*`. - `get_payment_gateways` (names), `get_gateway_options(currency=None)` (`GatewayOption`: name, title, logos), `validate_currency(gateway, currency)`, `resolve_payment_mode`. +- `get_gateways()` (`GatewayInfo`): every installed gateway, enabled or not, with its settings DocType, + description, docs link, capabilities and logos, so a consumer's settings screen needs no gateway list. - `refund(payment_request, amount=None, reference=None)` returns the Gateway Refund; `sync_refunds(payment_request)`. - `record_external_payment` / `record_external_refund`: import history settled elsewhere, idempotent, diff --git a/bwh_payments/api.py b/bwh_payments/api.py index 19ac15a..ee762b1 100644 --- a/bwh_payments/api.py +++ b/bwh_payments/api.py @@ -3,7 +3,7 @@ import frappe from frappe import _ -from bwh_payments.base_class import GatewayOption +from bwh_payments.base_class import GatewayInfo, GatewayOption from bwh_payments.bwh_payments.doctype.gateway_payment_request.gateway_payment_request import ( GatewayPaymentRequest, ) @@ -11,16 +11,19 @@ from bwh_payments.bwh_payments.utils import ( get_available_payment_modes, get_gateway_settings, + get_gateway_settings_doctypes, resolve_payment_mode, supports_currency, ) from bwh_payments.currency import from_minor_units, to_minor_units, validate_transaction_currency __all__ = [ + "GatewayInfo", "GatewayOption", "create_payment", "from_minor_units", "get_gateway_options", + "get_gateways", "get_payment_gateways", "record_external_payment", "record_external_refund", @@ -76,6 +79,25 @@ def get_gateway_options(currency: str | None = None) -> list[GatewayOption]: return options +def get_gateways() -> list[GatewayInfo]: + """Every gateway installed on the site, enabled or not, described for a settings screen.""" + gateways = [] + for doctype in get_gateway_settings_doctypes(): + settings = frappe.get_cached_doc(doctype) + gateways.append( + GatewayInfo( + name=settings.get_gateway_name(), + settings_doctype=doctype, + description=settings.description, + docs_url=settings.docs_url, + capabilities=list(settings.capabilities), + logo=settings.logo, + logo_dark=settings.logo_dark or settings.logo, + ) + ) + return sorted(gateways, key=lambda gateway: gateway.name.casefold()) + + def validate_currency(gateway: str, currency: str): """Throw unless `gateway` can charge in `currency`, before any checkout is opened.""" validate_transaction_currency(currency) diff --git a/bwh_payments/base_class.py b/bwh_payments/base_class.py index ed68701..1c65102 100644 --- a/bwh_payments/base_class.py +++ b/bwh_payments/base_class.py @@ -2,6 +2,9 @@ from pydantic import BaseModel, ConfigDict, model_validator +# The checkout link stays payable after the shopper leaves it, so a caller can send them back to it. +REUSABLE_CHECKOUT = "reusable_checkout" + class GatewayResult(BaseModel): # Rejecting a numeric refund id after money moved would leave the refund unrecorded. @@ -51,11 +54,27 @@ class GatewayOption(BaseModel): logo_dark: str | None = None +class GatewayInfo(BaseModel): + """A gateway this app can take payments through, as a settings screen lists it, set up or not.""" + + name: str + settings_doctype: str + description: str | None = None + docs_url: str | None = None + capabilities: list[str] = [] + logo: str | None = None + logo_dark: str | None = None + + class PaymentGatewayBase(ABC): """Contract for every ` Gateway Settings` Single. Amounts are in major units (12.34).""" logo: str | None = None logo_dark: str | None = None + # One line for a merchant choosing a gateway, and where they find its credentials. + description: str | None = None + docs_url: str | None = None + capabilities: tuple[str, ...] = () @abstractmethod def create_session( diff --git a/bwh_payments/bwh_payments/doctype/razorpay_gateway_settings/razorpay_gateway_settings.py b/bwh_payments/bwh_payments/doctype/razorpay_gateway_settings/razorpay_gateway_settings.py index 8ae48ef..acee2e4 100644 --- a/bwh_payments/bwh_payments/doctype/razorpay_gateway_settings/razorpay_gateway_settings.py +++ b/bwh_payments/bwh_payments/doctype/razorpay_gateway_settings/razorpay_gateway_settings.py @@ -44,6 +44,8 @@ class RazorpayGatewaySettings(Document, PaymentGatewayBase): logo = "/assets/bwh_payments/images/gateways/razorpay.svg" logo_dark = "/assets/bwh_payments/images/gateways/razorpay-dark.svg" + description = "Cards, UPI, netbanking and wallets. India." + docs_url = "https://dashboard.razorpay.com/app/website-app-settings/webhooks" # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. diff --git a/bwh_payments/bwh_payments/doctype/stripe_gateway_settings/stripe_gateway_settings.py b/bwh_payments/bwh_payments/doctype/stripe_gateway_settings/stripe_gateway_settings.py index c38a9f2..5e1711c 100644 --- a/bwh_payments/bwh_payments/doctype/stripe_gateway_settings/stripe_gateway_settings.py +++ b/bwh_payments/bwh_payments/doctype/stripe_gateway_settings/stripe_gateway_settings.py @@ -23,6 +23,9 @@ class StripeGatewaySettings(Document, PaymentGatewayBase): + description = "Cards and wallets, worldwide." + docs_url = "https://dashboard.stripe.com/apikeys" + # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. diff --git a/bwh_payments/bwh_payments/doctype/tabby_gateway_settings/tabby_gateway_settings.py b/bwh_payments/bwh_payments/doctype/tabby_gateway_settings/tabby_gateway_settings.py index e318054..8c66b33 100644 --- a/bwh_payments/bwh_payments/doctype/tabby_gateway_settings/tabby_gateway_settings.py +++ b/bwh_payments/bwh_payments/doctype/tabby_gateway_settings/tabby_gateway_settings.py @@ -44,6 +44,9 @@ class TabbyGatewaySettings(Document, PaymentGatewayBase): + description = "Buy now, pay later in four instalments. MENA." + docs_url = "https://docs.tabby.ai/" + # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. diff --git a/bwh_payments/bwh_payments/doctype/telr_gateway_settings/telr_gateway_settings.py b/bwh_payments/bwh_payments/doctype/telr_gateway_settings/telr_gateway_settings.py index c110069..a05a5db 100644 --- a/bwh_payments/bwh_payments/doctype/telr_gateway_settings/telr_gateway_settings.py +++ b/bwh_payments/bwh_payments/doctype/telr_gateway_settings/telr_gateway_settings.py @@ -31,6 +31,9 @@ class TelrGatewaySettings(Document, PaymentGatewayBase): + description = "Cards and local methods across the GCC." + docs_url = "https://telr.com/support/knowledge-base/hosted-payment-page-integration-guide/" + # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. diff --git a/bwh_payments/bwh_payments/utils.py b/bwh_payments/bwh_payments/utils.py index 97ecdac..7cfbed9 100644 --- a/bwh_payments/bwh_payments/utils.py +++ b/bwh_payments/bwh_payments/utils.py @@ -3,8 +3,11 @@ from urllib.parse import urlsplit, urlunsplit import frappe +from frappe.model.base_document import get_controller from frappe.utils.caching import redis_cache +from bwh_payments.base_class import PaymentGatewayBase + LANGUAGE_SEGMENT = re.compile(r"^[a-z]{2}(-[A-Za-z]{2})?$") @@ -44,6 +47,14 @@ def get_gateway_settings(gateway: str): ) +def get_gateway_settings_doctypes() -> list[str]: + """Every ` Gateway Settings` Single on the site, including another app's gateways.""" + doctypes = frappe.get_all( + "DocType", filters={"issingle": 1, "name": ("like", "% Gateway Settings")}, pluck="name" + ) + return [doctype for doctype in doctypes if issubclass(get_controller(doctype), PaymentGatewayBase)] + + def supports_currency(settings, currency: str) -> bool: supported = settings.supported_currencies() return supported is None or currency.strip().upper() in supported diff --git a/bwh_payments/tests/test_api.py b/bwh_payments/tests/test_api.py index 23bdd38..4de804f 100644 --- a/bwh_payments/tests/test_api.py +++ b/bwh_payments/tests/test_api.py @@ -10,12 +10,14 @@ from bwh_payments.api import ( get_gateway_options, + get_gateways, record_external_payment, record_external_refund, refund, sync_refunds, validate_currency, ) +from bwh_payments.base_class import PaymentGatewayBase from bwh_payments.bwh_payments.doctype.razorpay_gateway_settings.test_razorpay_gateway_settings import ( RAZORPAY_GATEWAY, RazorpayTestCase, @@ -82,6 +84,32 @@ def test_validate_currency_refuses_a_currency_the_site_does_not_have(self): validate_currency(RAZORPAY_GATEWAY, "XYZ") +class TestGateways(IntegrationTestCase): + def get_gateway(self, name: str): + return next(gateway for gateway in get_gateways() if gateway.name == name) + + def test_every_installed_gateway_is_listed_whether_or_not_it_is_set_up(self): + names = [gateway.name for gateway in get_gateways()] + + self.assertTrue({"Razorpay", "Stripe", "Tabby", "Telr"} <= set(names)) + self.assertEqual(names, sorted(names, key=str.casefold)) + + def test_each_gateway_names_the_settings_behind_it(self): + for gateway in get_gateways(): + with self.subTest(gateway=gateway.name): + settings = frappe.get_single(gateway.settings_doctype) + self.assertIsInstance(settings, PaymentGatewayBase) + self.assertEqual(gateway.name, settings.get_gateway_name()) + + def test_a_gateway_carries_what_a_merchant_needs_to_choose_and_set_it_up(self): + gateway = self.get_gateway("Razorpay") + + self.assertTrue(gateway.description) + self.assertTrue(gateway.docs_url.startswith("https://")) + self.assertTrue(gateway.logo.startswith("/assets/bwh_payments/images/gateways/razorpay")) + self.assertEqual(gateway.capabilities, []) + + class TestRefundsAndImports(RazorpayTestCase): def import_paid_payment(self, order_ref: str = "pay_legacy_1"): return record_external_payment( From a9c441941fc51254da190fda3bf56790056b04f7 Mon Sep 17 00:00:00 2001 From: Isha Dewangan Date: Wed, 14 Oct 2026 14:42:22 +0530 Subject: [PATCH 2/8] refactor(api): mark a reusable checkout with a flag A tuple of capability strings and a constant carried one flag that nothing in this app reads. reusable_checkout: bool says the same on the contract and on GatewayInfo. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 6 +++--- bwh_payments/api.py | 2 +- bwh_payments/base_class.py | 8 +++----- bwh_payments/tests/test_api.py | 2 +- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 067a44f..448f21d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,8 +42,8 @@ PR titles must be conventional commits (`fix: ...`). Label a merged PR `backport `refund_payment`, `handle_webhook`. Optional methods have defaults, so a third-party gateway that skips them keeps working: `cancel_session` (False), `get_transaction_ref` (the settled payment's id; None), `get_refund_status` (None = cannot report), `supported_currencies` (None = any), and the `logo` / -`logo_dark`, `description`, `docs_url` and `capabilities` class attributes. `REUSABLE_CHECKOUT` in -`capabilities` means the checkout link stays payable after the shopper leaves it. `create_session` also takes an optional `return_url`, passed only when set. +`logo_dark`, `description`, `docs_url` and `reusable_checkout` class attributes. `reusable_checkout` means +the checkout link stays payable after the shopper leaves it. `create_session` also takes an optional `return_url`, passed only when set. Results are pydantic models: `CheckoutSession`, `RefundResult`, and `WebhookEvent | None` (a payment event carries `session_id`, a refund event `refund_id`, `refund_amount` and `transaction_ref`). Callers read them through `.model_validate`, so a third-party gateway that still returns dicts keeps working. Each gateway is a @@ -91,7 +91,7 @@ as it stands. - `get_payment_gateways` (names), `get_gateway_options(currency=None)` (`GatewayOption`: name, title, logos), `validate_currency(gateway, currency)`, `resolve_payment_mode`. - `get_gateways()` (`GatewayInfo`): every installed gateway, enabled or not, with its settings DocType, - description, docs link, capabilities and logos, so a consumer's settings screen needs no gateway list. + description, docs link, `reusable_checkout` and logos, so a consumer's settings screen needs no gateway list. - `refund(payment_request, amount=None, reference=None)` returns the Gateway Refund; `sync_refunds(payment_request)`. - `record_external_payment` / `record_external_refund`: import history settled elsewhere, idempotent, diff --git a/bwh_payments/api.py b/bwh_payments/api.py index ee762b1..ec9dfe1 100644 --- a/bwh_payments/api.py +++ b/bwh_payments/api.py @@ -90,7 +90,7 @@ def get_gateways() -> list[GatewayInfo]: settings_doctype=doctype, description=settings.description, docs_url=settings.docs_url, - capabilities=list(settings.capabilities), + reusable_checkout=settings.reusable_checkout, logo=settings.logo, logo_dark=settings.logo_dark or settings.logo, ) diff --git a/bwh_payments/base_class.py b/bwh_payments/base_class.py index 1c65102..bcfd30a 100644 --- a/bwh_payments/base_class.py +++ b/bwh_payments/base_class.py @@ -2,9 +2,6 @@ from pydantic import BaseModel, ConfigDict, model_validator -# The checkout link stays payable after the shopper leaves it, so a caller can send them back to it. -REUSABLE_CHECKOUT = "reusable_checkout" - class GatewayResult(BaseModel): # Rejecting a numeric refund id after money moved would leave the refund unrecorded. @@ -61,7 +58,7 @@ class GatewayInfo(BaseModel): settings_doctype: str description: str | None = None docs_url: str | None = None - capabilities: list[str] = [] + reusable_checkout: bool = False logo: str | None = None logo_dark: str | None = None @@ -74,7 +71,8 @@ class PaymentGatewayBase(ABC): # One line for a merchant choosing a gateway, and where they find its credentials. description: str | None = None docs_url: str | None = None - capabilities: tuple[str, ...] = () + # The checkout link stays payable after the shopper leaves it, so a caller can send them back to it. + reusable_checkout: bool = False @abstractmethod def create_session( diff --git a/bwh_payments/tests/test_api.py b/bwh_payments/tests/test_api.py index 4de804f..ab09bdd 100644 --- a/bwh_payments/tests/test_api.py +++ b/bwh_payments/tests/test_api.py @@ -107,7 +107,7 @@ def test_a_gateway_carries_what_a_merchant_needs_to_choose_and_set_it_up(self): self.assertTrue(gateway.description) self.assertTrue(gateway.docs_url.startswith("https://")) self.assertTrue(gateway.logo.startswith("/assets/bwh_payments/images/gateways/razorpay")) - self.assertEqual(gateway.capabilities, []) + self.assertFalse(gateway.reusable_checkout) class TestRefundsAndImports(RazorpayTestCase): From feadb1c05043ceea2f37bc4fd97ac068fbca3627 Mon Sep 17 00:00:00 2001 From: Isha Dewangan Date: Wed, 14 Oct 2026 15:30:51 +0530 Subject: [PATCH 3/8] fix(api): skip a gateway whose code cannot be imported get_gateways imported every `% Gateway Settings` controller, so one DocType left behind by a removed app, or by code on another branch, raised ImportError and broke every screen that lists gateways, such as a storefront checkout. It is now skipped and logged to the file log. Co-Authored-By: Claude Opus 5.5 --- bwh_payments/bwh_payments/utils.py | 13 ++++++++++++- bwh_payments/tests/test_api.py | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/bwh_payments/bwh_payments/utils.py b/bwh_payments/bwh_payments/utils.py index 7cfbed9..d3c6dca 100644 --- a/bwh_payments/bwh_payments/utils.py +++ b/bwh_payments/bwh_payments/utils.py @@ -52,7 +52,18 @@ def get_gateway_settings_doctypes() -> list[str]: doctypes = frappe.get_all( "DocType", filters={"issingle": 1, "name": ("like", "% Gateway Settings")}, pluck="name" ) - return [doctype for doctype in doctypes if issubclass(get_controller(doctype), PaymentGatewayBase)] + return [doctype for doctype in doctypes if is_gateway_settings(doctype)] + + +def is_gateway_settings(doctype: str) -> bool: + try: + controller = get_controller(doctype) + except ImportError: + # A DocType whose app was removed, or whose code is on another branch, must not break every + # screen that lists gateways. Logged to the file log: this runs on every checkout. + frappe.logger("bwh_payments").warning(f"Skipped {doctype}: its controller could not be imported") + return False + return issubclass(controller, PaymentGatewayBase) def supports_currency(settings, currency: str) -> bool: diff --git a/bwh_payments/tests/test_api.py b/bwh_payments/tests/test_api.py index ab09bdd..4614e65 100644 --- a/bwh_payments/tests/test_api.py +++ b/bwh_payments/tests/test_api.py @@ -18,6 +18,7 @@ validate_currency, ) from bwh_payments.base_class import PaymentGatewayBase +from bwh_payments.bwh_payments import utils from bwh_payments.bwh_payments.doctype.razorpay_gateway_settings.test_razorpay_gateway_settings import ( RAZORPAY_GATEWAY, RazorpayTestCase, @@ -94,6 +95,20 @@ def test_every_installed_gateway_is_listed_whether_or_not_it_is_set_up(self): self.assertTrue({"Razorpay", "Stripe", "Tabby", "Telr"} <= set(names)) self.assertEqual(names, sorted(names, key=str.casefold)) + def test_a_gateway_whose_code_is_missing_is_skipped(self): + get_controller = utils.get_controller + + def get_controller_without_stripe(doctype: str): + if doctype == "Stripe Gateway Settings": + raise ImportError("No module named stripe_gateway_settings") + return get_controller(doctype) + + with patch.object(utils, "get_controller", side_effect=get_controller_without_stripe): + names = [gateway.name for gateway in get_gateways()] + + self.assertNotIn("Stripe", names) + self.assertIn("Razorpay", names) + def test_each_gateway_names_the_settings_behind_it(self): for gateway in get_gateways(): with self.subTest(gateway=gateway.name): From 1e14c618b093a3fa7386f0c6642fd12c259740df Mon Sep 17 00:00:00 2001 From: Isha Dewangan Date: Mon, 12 Oct 2026 19:31:23 +0530 Subject: [PATCH 4/8] feat: add PayPal gateway PayPal Orders v2 behind the gateway contract, on the base/live/stub service-client layout: - PayPal Gateway Settings (Sandbox/Live client credentials, webhook ID, return URLs) and services/paypal/{base,live,stub}.py. - Checkout creates an order and sends the shopper to PayPal's approval link; the return page and the CHECKOUT.ORDER.APPROVED webhook both capture, and losing that race (ORDER_ALREADY_CAPTURED) counts as paid. - Webhooks are verified by calling PayPal back with the webhook ID. - supported_currencies() leaves out what PayPal cannot settle (SAR, AED, KWD, BHD, QAR, INR); HUF, JPY and TWD are charged in whole units. - Refunds go against the completed capture and report processed or pending; get_refund_status settles a pending one, get_transaction_ref returns the capture, and create_session honours return_url. - get_gateways() lists PayPal with REUSABLE_CHECKOUT: an approval link stays payable until the order is captured or expires. Co-Authored-By: Claude Opus 5.5 --- .github/logos/paypal.svg | 1 + AGENTS.md | 10 +- README.md | 6 +- .../paypal_gateway_settings/__init__.py | 0 .../paypal_gateway_settings.json | 159 ++++++++ .../paypal_gateway_settings.py | 355 ++++++++++++++++++ .../test_paypal_gateway_settings.py | 349 +++++++++++++++++ bwh_payments/services/paypal/__init__.py | 0 bwh_payments/services/paypal/base.py | 73 ++++ bwh_payments/services/paypal/live.py | 169 +++++++++ bwh_payments/services/paypal/stub.py | 176 +++++++++ bwh_payments/tests/test_currency_filtering.py | 27 ++ bwh_payments/tests/test_paypal_webhook.py | 164 ++++++++ 13 files changed, 1486 insertions(+), 3 deletions(-) create mode 100644 .github/logos/paypal.svg create mode 100644 bwh_payments/bwh_payments/doctype/paypal_gateway_settings/__init__.py create mode 100644 bwh_payments/bwh_payments/doctype/paypal_gateway_settings/paypal_gateway_settings.json create mode 100644 bwh_payments/bwh_payments/doctype/paypal_gateway_settings/paypal_gateway_settings.py create mode 100644 bwh_payments/bwh_payments/doctype/paypal_gateway_settings/test_paypal_gateway_settings.py create mode 100644 bwh_payments/services/paypal/__init__.py create mode 100644 bwh_payments/services/paypal/base.py create mode 100644 bwh_payments/services/paypal/live.py create mode 100644 bwh_payments/services/paypal/stub.py create mode 100644 bwh_payments/tests/test_currency_filtering.py create mode 100644 bwh_payments/tests/test_paypal_webhook.py diff --git a/.github/logos/paypal.svg b/.github/logos/paypal.svg new file mode 100644 index 0000000..e232d1c --- /dev/null +++ b/.github/logos/paypal.svg @@ -0,0 +1 @@ +PayPal diff --git a/AGENTS.md b/AGENTS.md index 448f21d..97e4b6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ This file provides guidance to AI coding agents (Claude Code, Codex and others) ## What this is `bwh_payments` is a Frappe app (Frappe 16+, Python 3.14) that takes payments through hosted checkout pages -on Stripe, Razorpay, Telr and Tabby, behind one gateway-agnostic API. ERPNext is optional. Developer docs: +on Stripe, Razorpay, Telr, Tabby and PayPal, behind one gateway-agnostic API. ERPNext is optional. Developer docs: https://docs.bwh.tech/bwh-payments/get-started/overview The repo sits inside a bench at `/Users/harsh/bwh/bwh_payments` (`apps/bwh_payments`); the local site is @@ -153,6 +153,14 @@ An insert that is already settled is an import and fires neither. `get_payment_status`. - **Tabby**: unsigned webhooks, checked with a static header token plus a source-IP allowlist. `get_payment_status` also *captures* an authorised payment; that capture is where money is taken. +- **PayPal**: raw HTTP (Orders v2) with a cached client-credentials token. `get_payment_status` and the + `CHECKOUT.ORDER.APPROVED` webhook both capture an approved order; the loser of that race gets + `ORDER_ALREADY_CAPTURED` (`PayPalOrderAlreadyCapturedError`), which counts as paid. Webhooks are signed + with a certificate, so verification is a call back to PayPal (`verify_webhook_signature`), the one + verifier that goes through the client; the stub's `next_verification_status` stands in for a forgery. + `cancel_session` stays `False`: PayPal cannot cancel an unapproved order. Refunds report `processed` / + `pending` (PayPal's `COMPLETED` / `PENDING`); a pending one settles through `get_refund_status`. + `get_transaction_ref` is the capture's id. ### Money and currency rules diff --git a/README.md b/README.md index 5ab86a4..c0fffb7 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,13 @@ Razorpay Telr Tabby + PayPal

-BWH Payments lets a Frappe app take payments through Stripe, Razorpay, Telr or Tabby, using the same code -for every gateway. Read the **[developer docs](https://docs.bwh.tech/bwh-payments/get-started/overview)**. +BWH Payments lets a Frappe app take payments through Stripe, Razorpay, Telr, Tabby or PayPal, using the +same code for every gateway. Read the **[developer docs](https://docs.bwh.tech/bwh-payments/get-started/overview)**. ### Gateways @@ -25,6 +26,7 @@ for every gateway. Read the **[developer docs](https://docs.bwh.tech/bwh-payment - **Razorpay**: cards, UPI, netbanking and wallets in India - **Telr**: cards and local payment methods in the GCC - **Tabby**: buy now, pay later in Saudi Arabia, the UAE, Kuwait, Bahrain and Qatar +- **PayPal**: PayPal wallet payments in the currencies PayPal settles ### Features diff --git a/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/__init__.py b/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/paypal_gateway_settings.json b/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/paypal_gateway_settings.json new file mode 100644 index 0000000..1359b3f --- /dev/null +++ b/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/paypal_gateway_settings.json @@ -0,0 +1,159 @@ +{ + "actions": [], + "creation": "2026-09-03 00:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "enabled", + "column_break_mode", + "mode", + "section_break_keys", + "client_id", + "currency", + "column_break_keys", + "client_secret", + "section_break_webhook", + "webhook_id", + "section_break_branding", + "brand_name", + "section_break_urls", + "success_url", + "column_break_urls", + "cancelled_url", + "column_break_urls_two", + "failure_url" + ], + "fields": [ + { + "default": "0", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled" + }, + { + "fieldname": "column_break_mode", + "fieldtype": "Column Break" + }, + { + "default": "Sandbox", + "description": "Selects which PayPal API the credentials below are sent to. Sandbox and Live credentials are not interchangeable.", + "fieldname": "mode", + "fieldtype": "Select", + "label": "Mode", + "options": "Sandbox\nLive", + "reqd": 1 + }, + { + "fieldname": "section_break_keys", + "fieldtype": "Section Break", + "label": "API Credentials" + }, + { + "fieldname": "client_id", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Client ID", + "reqd": 1 + }, + { + "description": "Fallback currency when the payment request does not carry one. PayPal cannot settle in SAR, AED, KWD, BHD, QAR or INR.", + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency" + }, + { + "fieldname": "column_break_keys", + "fieldtype": "Column Break" + }, + { + "fieldname": "client_secret", + "fieldtype": "Password", + "in_list_view": 1, + "label": "Client Secret", + "reqd": 1 + }, + { + "fieldname": "section_break_webhook", + "fieldtype": "Section Break", + "label": "Webhook" + }, + { + "description": "The webhook's ID from the PayPal dashboard, not a secret. PayPal signs its deliveries with a certificate, so every one is verified by calling PayPal back with this ID. Without it a webhook cannot be verified and anyone could mark an order paid.", + "fieldname": "webhook_id", + "fieldtype": "Data", + "label": "Webhook ID", + "reqd": 1 + }, + { + "fieldname": "section_break_branding", + "fieldtype": "Section Break", + "label": "Branding" + }, + { + "description": "Shown to the shopper on PayPal's approval page. Defaults to the site name.", + "fieldname": "brand_name", + "fieldtype": "Data", + "label": "Brand Name" + }, + { + "fieldname": "section_break_urls", + "fieldtype": "Section Break", + "label": "Redirect URLs" + }, + { + "fieldname": "success_url", + "fieldtype": "Data", + "label": "Success URL", + "options": "URL" + }, + { + "fieldname": "column_break_urls", + "fieldtype": "Column Break" + }, + { + "description": "Where PayPal returns a shopper who backs out of the approval page.", + "fieldname": "cancelled_url", + "fieldtype": "Data", + "label": "Cancelled URL", + "options": "URL" + }, + { + "fieldname": "column_break_urls_two", + "fieldtype": "Column Break" + }, + { + "description": "PayPal has no separate failure redirect; it is recorded on the request so the storefront has somewhere to send a shopper whose payment did not complete.", + "fieldname": "failure_url", + "fieldtype": "Data", + "label": "Failure URL", + "options": "URL" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2026-09-03 12:32:14.972706", + "modified_by": "Administrator", + "module": "BWH Payments", + "name": "PayPal Gateway Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/paypal_gateway_settings.py b/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/paypal_gateway_settings.py new file mode 100644 index 0000000..db302c4 --- /dev/null +++ b/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/paypal_gateway_settings.py @@ -0,0 +1,355 @@ +# Copyright (c) 2026, Build With Hussain and contributors +# For license information, please see license.txt + +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils.data import flt + +from bwh_payments.base_class import ( + REUSABLE_CHECKOUT, + CheckoutSession, + PaymentGatewayBase, + RefundResult, + WebhookEvent, +) +from bwh_payments.bwh_payments.utils import get_localised_url +from bwh_payments.currency import get_minor_unit_exponent, validate_transaction_currency +from bwh_payments.services.paypal.base import BasePayPalClient, PayPalOrder, PayPalOrderAlreadyCapturedError +from bwh_payments.services.paypal.live import LivePayPalClient +from bwh_payments.services.paypal.stub import StubPayPalClient + +# PayPal settles in nothing else. SAR, AED, KWD, BHD, QAR and INR are notably absent. +PAYPAL_SUPPORTED_CURRENCIES = ( + "AUD", + "CAD", + "CHF", + "CZK", + "DKK", + "EUR", + "GBP", + "HKD", + "HUF", + "ILS", + "JPY", + "MXN", + "MYR", + "NOK", + "NZD", + "PHP", + "PLN", + "SEK", + "SGD", + "THB", + "TWD", + "USD", +) + +# Whole amounts only at PayPal, although HUF and TWD are two-decimal under ISO 4217. +PAYPAL_WHOLE_AMOUNT_CURRENCIES = ("HUF", "JPY", "TWD") + +# Unmapped states stay Pending. APPROVED is absent: no money has moved until the capture. +PAYPAL_STATUS_MAP = { + "COMPLETED": "Paid", + "VOIDED": "Cancelled", + "CREATED": "Pending", + "SAVED": "Pending", + "PAYER_ACTION_REQUIRED": "Pending", +} + +PAYPAL_APPROVED_STATUS = "APPROVED" +PAYPAL_ACCEPTED_REFUND_STATUSES = ("COMPLETED", "PENDING") +# PayPal's refund statuses in the words Gateway Refund understands; anything else stays Pending. +PAYPAL_REFUND_STATUS_MAP = { + "COMPLETED": "processed", + "PENDING": "pending", + "FAILED": "failed", + "CANCELLED": "cancelled", +} +# The balance of a partially refunded capture stays refundable. +PAYPAL_REFUNDABLE_CAPTURE_STATUSES = ("COMPLETED", "PARTIALLY_REFUNDED") +# A fully refunded capture is still the payment that settled. +PAYPAL_SETTLED_CAPTURE_STATUSES = (*PAYPAL_REFUNDABLE_CAPTURE_STATUSES, "REFUNDED") +PAYPAL_VERIFICATION_SUCCESS = "SUCCESS" + +PAYPAL_ORDER_APPROVED_EVENT = "CHECKOUT.ORDER.APPROVED" +PAYPAL_CAPTURE_COMPLETED_EVENT = "PAYMENT.CAPTURE.COMPLETED" + +# `payer-action` answers an order created with `payment_source`; `approve` is the older spelling. +PAYPAL_REDIRECT_LINK_RELS = ("payer-action", "approve") + +# Title case: `dict(frappe.request.headers)` drops werkzeug's case-insensitivity. +PAYPAL_SIGNATURE_HEADERS = { + "auth_algo": "Paypal-Auth-Algo", + "cert_url": "Paypal-Cert-Url", + "transmission_id": "Paypal-Transmission-Id", + "transmission_sig": "Paypal-Transmission-Sig", + "transmission_time": "Paypal-Transmission-Time", +} + + +class PayPalGatewaySettings(Document, PaymentGatewayBase): + description = "PayPal wallet payments. Not for INR or Gulf currencies." + docs_url = "https://developer.paypal.com/dashboard/applications" + # An approval link stays payable until the order is captured or expires. + capabilities = (REUSABLE_CHECKOUT,) + + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + brand_name: DF.Data | None + cancelled_url: DF.Data | None + client_id: DF.Data + client_secret: DF.Password + currency: DF.Link | None + enabled: DF.Check + failure_url: DF.Data | None + mode: DF.Literal["Sandbox", "Live"] + success_url: DF.Data | None + webhook_id: DF.Data + # end: auto-generated types + + def get_gateway_name(self) -> str: + return "PayPal" + + def supported_currencies(self) -> tuple[str, ...]: + return PAYPAL_SUPPORTED_CURRENCIES + + def get_client(self) -> BasePayPalClient: + if frappe.in_test: + return StubPayPalClient() + + return LivePayPalClient(self.mode, self.client_id, self.get_password("client_secret")) + + def create_session( + self, + amount: float, + currency: str, + reference: str | None = None, + customer: dict | None = None, + return_url: str | None = None, + ) -> CheckoutSession: + currency = currency or self.currency + validate_transaction_currency(currency) + validate_paypal_currency(currency) + + success_url = return_url or self.build_return_url(self.success_url, _("Success URL"), reference) + cancel_url = return_url or self.build_return_url(self.cancelled_url, _("Cancelled URL"), reference) + payload = { + "intent": "CAPTURE", + "purchase_units": [ + { + # Echoed on captures and refunds, so a PayPal statement reconciles to the request. + "custom_id": reference, + "amount": { + "currency_code": currency.upper(), + "value": format_paypal_amount(amount, currency), + }, + } + ], + "payment_source": { + "paypal": { + "experience_context": { + # No `locale`: PayPal wants BCP-47 and refuses a bare language tag with a 400. + "user_action": "PAY_NOW", + "return_url": success_url, + "cancel_url": cancel_url, + **({"brand_name": self.brand_name} if self.brand_name else {}), + } + } + }, + } + # No payer email: PayPal locks the wallet to it, and shoppers often use a different one there. + + order = self.get_client().create_order(payload, request_id=reference) + # The order id, not a capture id: webhooks resolve back to the same `order_ref`. + return CheckoutSession( + session_id=order.id, + redirect_url=get_redirect_url(order), + success_url=success_url, + cancel_url=cancel_url, + failure_url=return_url or get_localised_url(self.failure_url), + ) + + def build_return_url(self, url: str, label: str, reference: str | None) -> str: + """PayPal appends only `token` and `PayerID`; the storefront reads `reference_id`.""" + if not url: + frappe.throw(_("Please set the {0} in PayPal Gateway Settings").format(label)) + parts = urlsplit(get_localised_url(url)) + query = parse_qsl(parts.query) + query.append(("reference_id", reference or "")) + return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment)) + + def get_payment_status(self, session_id: str) -> str: + order = self.get_client().get_order(session_id) + if read_status(order) == PAYPAL_APPROVED_STATUS: + order = self.capture_order(session_id) + + return PAYPAL_STATUS_MAP.get(read_status(order), "Pending") + + def capture_order(self, session_id: str) -> PayPalOrder: + """Approval is not payment: without a capture the order sits approved and no money arrives.""" + client = self.get_client() + try: + client.capture_order(session_id) + except PayPalOrderAlreadyCapturedError: + # The return page and the APPROVED webhook race to capture; losing means the other one won. + pass + + return client.get_order(session_id) + + def handle_webhook(self, payload: bytes, headers: dict) -> WebhookEvent | None: + event = self.verify_webhook(payload, headers) + event_type = (event.get("event_type") or "").strip().upper() + resource = event.get("resource") or {} + + if event_type == PAYPAL_ORDER_APPROVED_EVENT: + session_id = resource.get("id") + if not session_id: + frappe.throw(_("PayPal sent a {0} event with no order id").format(event_type)) + # Charges a shopper who approved on PayPal and never came back to the storefront. + status = PAYPAL_STATUS_MAP.get(read_status(self.capture_order(session_id)), "Pending") + elif event_type == PAYPAL_CAPTURE_COMPLETED_EVENT: + # `resource.id` is the capture; the order id is only in supplementary data. + related_ids = (resource.get("supplementary_data") or {}).get("related_ids") or {} + session_id = related_ids.get("order_id") + if not session_id: + frappe.throw(_("PayPal sent a {0} event with no order id").format(event_type)) + status = "Paid" + else: + return None + + return WebhookEvent(session_id=session_id, status=status, event_id=event.get("id")) + + def verify_webhook(self, payload: bytes, headers: dict) -> dict: + """PayPal signs with a certificate, so verification is a call back to PayPal with our webhook id.""" + if not self.webhook_id: + frappe.throw(_("Webhook ID is not configured in PayPal Gateway Settings")) + + transmission = {} + for field, header in PAYPAL_SIGNATURE_HEADERS.items(): + value = headers.get(header) + if not value: + frappe.throw(_("Missing {0} header").format(header)) + transmission[field] = value + + # parse_json passes bytes through untouched; a bad body raises ValueError either way. + try: + event = frappe.parse_json(payload.decode()) + except ValueError: + frappe.throw(_("PayPal webhook body could not be read")) + if not isinstance(event, dict): + frappe.throw(_("PayPal webhook body was not an event")) + + verification = self.get_client().verify_webhook_signature( + {**transmission, "webhook_id": self.webhook_id, "webhook_event": event} + ) + if (verification.verification_status or "").strip().upper() != PAYPAL_VERIFICATION_SUCCESS: + frappe.throw(_("PayPal could not verify this webhook delivery")) + + return event + + def refund_payment(self, session_id: str, amount: float, currency: str | None = None) -> RefundResult: + currency = currency or self.currency + client = self.get_client() + capture_id = get_completed_capture_id(client.get_order(session_id)) + + refund = client.refund_capture( + capture_id, + { + "amount": { + "currency_code": currency.upper(), + "value": format_paypal_amount(amount, currency), + } + }, + ) + status = (refund.status or "").strip().upper() + if status not in PAYPAL_ACCEPTED_REFUND_STATUSES: + frappe.throw(_("Refund failed with status: {0}").format(refund.status)) + + echoed = refund.amount.value if refund.amount else None + return RefundResult( + refund_id=refund.id, + status=read_refund_status(refund), + amount=flt(echoed) if echoed else flt(amount), + ) + + def get_refund_status(self, session_id: str, refund_id: str) -> RefundResult: + refund = self.get_client().get_refund(refund_id) + return RefundResult(refund_id=refund.id, status=read_refund_status(refund)) + + def get_transaction_ref(self, session_id: str) -> str | None: + """The capture that collected the money: refunds go against it, not the order.""" + return find_capture_id(self.get_client().get_order(session_id), PAYPAL_SETTLED_CAPTURE_STATUSES) + + +def validate_paypal_currency(currency: str): + if (currency or "").strip().upper() not in PAYPAL_SUPPORTED_CURRENCIES: + frappe.throw( + _("PayPal cannot take payment in {0}. It supports {1}.").format( + frappe.bold(currency), ", ".join(PAYPAL_SUPPORTED_CURRENCIES) + ) + ) + + +def get_paypal_exponent(currency: str) -> int: + if (currency or "").strip().upper() in PAYPAL_WHOLE_AMOUNT_CURRENCIES: + return 0 + return get_minor_unit_exponent(currency) + + +def format_paypal_amount(amount, currency: str) -> str: + """Refused rather than rounded: sending 1000 for a 1000.50 HUF order bills a different figure.""" + exponent = get_paypal_exponent(currency) + if flt(amount, get_minor_unit_exponent(currency)) != flt(amount, exponent): + frappe.throw( + _("PayPal does not accept decimals in {0}, so {1} cannot be charged.").format( + frappe.bold(currency), amount + ) + ) + return f"{flt(amount, exponent):.{exponent}f}" + + +def read_status(entity) -> str: + return (entity.status or "").strip().upper() + + +def get_redirect_url(order: PayPalOrder) -> str: + links = {link.rel: link.href for link in order.links or []} + for rel in PAYPAL_REDIRECT_LINK_RELS: + if links.get(rel): + return links[rel] + + frappe.throw(_("PayPal created an order with no approval link")) + + +def get_completed_capture_id(order: PayPalOrder) -> str: + """An order is not a charge, so a refund goes against the capture that collected the money.""" + if capture_id := find_capture_id(order, PAYPAL_REFUNDABLE_CAPTURE_STATUSES): + return capture_id + + frappe.throw( + _( + "PayPal has no completed capture for this order. An approved but uncaptured payment has to be" + " captured or voided from the PayPal dashboard before it can be refunded." + ) + ) + + +def find_capture_id(order: PayPalOrder, statuses: tuple[str, ...]) -> str | None: + for purchase_unit in order.purchase_units or []: + for capture in (purchase_unit.payments.captures if purchase_unit.payments else None) or []: + if capture.id and read_status(capture) in statuses: + return capture.id + return None + + +def read_refund_status(refund) -> str: + return PAYPAL_REFUND_STATUS_MAP.get(read_status(refund), "pending") diff --git a/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/test_paypal_gateway_settings.py b/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/test_paypal_gateway_settings.py new file mode 100644 index 0000000..2a75f44 --- /dev/null +++ b/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/test_paypal_gateway_settings.py @@ -0,0 +1,349 @@ +# Copyright (c) 2026, Build With Hussain and contributors +# See license.txt + +import frappe +from frappe.tests import IntegrationTestCase + +from bwh_payments.api import get_gateways +from bwh_payments.base_class import REUSABLE_CHECKOUT +from bwh_payments.bwh_payments.doctype.paypal_gateway_settings import paypal_gateway_settings +from bwh_payments.services.paypal.stub import StubPayPalClient + +PAYPAL_GATEWAY = "PayPal" +PAYPAL_WEBHOOK_ID = "WH-TEST-WEBHOOK-ID" +SUCCESS_URL = "https://shop.test/en/account/orders/confirmation" + +IGNORE_TEST_RECORD_DEPENDENCIES = [ + "Address", + "Company", + "Customer", + "Payment Request", +] + + +def configure_paypal_gateway(): + settings = frappe.get_single("PayPal Gateway Settings") + settings.update( + { + "enabled": 1, + "mode": "Sandbox", + "client_id": "paypal_test_client_id", + "client_secret": "paypal_test_client_secret", + "webhook_id": PAYPAL_WEBHOOK_ID, + "brand_name": "Test Shop", + "currency": "USD", + "success_url": SUCCESS_URL, + "cancelled_url": "https://shop.test/en/cart", + "failure_url": "https://shop.test/en/cart/checkout", + } + ) + settings.save(ignore_permissions=True) + + if not frappe.db.exists("Payment Gateway Profile", PAYPAL_GATEWAY): + frappe.get_doc( + { + "doctype": "Payment Gateway Profile", + "name": PAYPAL_GATEWAY, + "gateway_settings": "PayPal Gateway Settings", + "enabled": 1, + } + ).insert(ignore_permissions=True) + + +def remove_paypal_gateway(): + """`create_request_log` commits, so these tests escape the rollback and need this cleanup.""" + frappe.db.delete( + "Gateway Refund", + { + "payment_request": [ + "in", + frappe.get_all("Gateway Payment Request", {"gateway": PAYPAL_GATEWAY}, pluck="name"), + ] + }, + ) + for request_name in frappe.get_all( + "Gateway Payment Request", filters={"gateway": PAYPAL_GATEWAY}, pluck="name" + ): + frappe.delete_doc( + "Gateway Payment Request", request_name, ignore_permissions=True, delete_permanently=True + ) + + for service in ("PayPal", f"{PAYPAL_GATEWAY} Webhook"): + for log_name in frappe.get_all( + "Integration Request", filters={"integration_request_service": service}, pluck="name" + ): + frappe.delete_doc( + "Integration Request", log_name, ignore_permissions=True, delete_permanently=True + ) + + frappe.delete_doc( + "Payment Gateway Profile", + PAYPAL_GATEWAY, + ignore_missing=True, + ignore_permissions=True, + force=True, + ) + # Not save(): the credentials are mandatory. + frappe.db.set_single_value( + "PayPal Gateway Settings", + { + "enabled": 0, + "client_id": "", + "client_secret": "", + "webhook_id": "", + "brand_name": "", + "currency": "", + "success_url": "", + "cancelled_url": "", + "failure_url": "", + }, + ) + frappe.clear_cache(doctype="PayPal Gateway Settings") + + +def make_paypal_payment_request(amount: float, currency: str = "USD"): + return frappe.get_doc( + { + "doctype": "Gateway Payment Request", + "gateway": PAYPAL_GATEWAY, + "amount": amount, + "currency_code": currency, + "ref_doctype": "Currency", + "ref_docname": currency, + } + ).insert(ignore_permissions=True) + + +class PayPalTestCase(IntegrationTestCase): + def setUp(self): + StubPayPalClient.reset() + configure_paypal_gateway() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + # Commit only the cleanup; frappe's class-level rollback would undo it otherwise. + frappe.db.rollback() + remove_paypal_gateway() + frappe.db.commit() # nosemgrep + + def get_settings(self): + return frappe.get_single("PayPal Gateway Settings") + + +class TestPayPalGatewaySettings(PayPalTestCase): + def test_charges_major_units_as_a_decimal_string(self): + make_paypal_payment_request(12.5, "USD") + + amount = StubPayPalClient.created_orders[0]["purchase_units"][0]["amount"] + self.assertEqual(amount, {"currency_code": "USD", "value": "12.50"}) + + def test_charges_a_zero_decimal_currency_without_decimals(self): + make_paypal_payment_request(1000, "JPY") + + self.assertEqual(StubPayPalClient.created_orders[0]["purchase_units"][0]["amount"]["value"], "1000") + + def test_refuses_a_fractional_amount_in_a_currency_paypal_bills_whole(self): + with self.assertRaises(frappe.ValidationError): + paypal_gateway_settings.format_paypal_amount(1000.50, "HUF") + + def test_refuses_a_currency_paypal_cannot_settle(self): + with self.assertRaises(frappe.ValidationError): + make_paypal_payment_request(100, "SAR") + self.assertEqual(StubPayPalClient.created_orders, []) + + def test_session_carries_the_order_id_and_approval_link(self): + payment_request = make_paypal_payment_request(20, "USD") + + self.assertIn(payment_request.order_ref, StubPayPalClient.orders) + self.assertIn(payment_request.order_ref, payment_request.order_url) + self.assertTrue(payment_request.order_url.startswith("https://paypal.test/checkoutnow")) + + def test_success_url_carries_our_reference_alongside_an_existing_query(self): + settings = self.get_settings() + settings.success_url = f"{SUCCESS_URL}?utm_source=paypal" + settings.save(ignore_permissions=True) + + url = settings.build_return_url(settings.success_url, "Success URL", "GPR-0001") + + self.assertIn("utm_source=paypal", url) + self.assertIn("reference_id=GPR-0001", url) + self.assertEqual(url.count("?"), 1) + + def test_missing_success_url_is_refused_rather_than_sent_blank(self): + settings = self.get_settings() + with self.assertRaises(frappe.ValidationError): + settings.build_return_url("", "Success URL", "GPR-0001") + + def test_status_map_covers_the_terminal_states(self): + settings = self.get_settings() + for paypal_status, expected in ( + ("COMPLETED", "Paid"), + ("VOIDED", "Cancelled"), + ("CREATED", "Pending"), + ("PAYER_ACTION_REQUIRED", "Pending"), + ): + with self.subTest(paypal_status=paypal_status): + order_id = StubPayPalClient.add_order(status=paypal_status) + self.assertEqual(settings.get_payment_status(order_id), expected) + + def test_an_unmapped_status_stays_pending(self): + settings = self.get_settings() + order_id = StubPayPalClient.add_order(status="SOMETHING_NEW") + + self.assertEqual(settings.get_payment_status(order_id), "Pending") + + def test_an_approved_order_is_captured_before_it_is_called_paid(self): + settings = self.get_settings() + order_id = StubPayPalClient.add_order() + StubPayPalClient.approve(order_id) + + status = settings.get_payment_status(order_id) + + self.assertEqual(StubPayPalClient.captured_orders, [order_id]) + self.assertEqual(status, "Paid") + + def test_losing_the_capture_race_still_reports_paid(self): + settings = self.get_settings() + order_id = StubPayPalClient.add_order() + StubPayPalClient.approve(order_id) + StubPayPalClient.lose_next_capture_race = True + + self.assertEqual(settings.get_payment_status(order_id), "Paid") + self.assertEqual(StubPayPalClient.captured_orders, []) + + def test_a_capture_that_really_failed_raises_instead_of_reporting_paid(self): + settings = self.get_settings() + order_id = StubPayPalClient.add_order() + StubPayPalClient.approve(order_id) + StubPayPalClient.next_capture_issue = "INSTRUMENT_DECLINED" + + with self.assertRaises(frappe.ValidationError): + settings.get_payment_status(order_id) + self.assertEqual(StubPayPalClient.captured_orders, []) + + def test_refund_goes_against_the_completed_capture(self): + settings = self.get_settings() + order_id = StubPayPalClient.add_order(status="COMPLETED") + StubPayPalClient.add_capture(order_id, status="DECLINED") + capture_id = StubPayPalClient.add_capture(order_id, status="COMPLETED") + + result = settings.refund_payment(order_id, 12.5, "USD") + + self.assertEqual(StubPayPalClient.refunds[0]["capture_id"], capture_id) + self.assertEqual(StubPayPalClient.refunds[0]["amount"]["value"], "12.50") + # In the words Gateway Refund understands, so a completed refund is recorded as Processed. + self.assertEqual(result.status, "processed") + self.assertEqual(result.amount, 12.5) + + def test_a_partially_refunded_capture_can_refund_its_balance(self): + settings = self.get_settings() + order_id = StubPayPalClient.add_order(status="COMPLETED") + capture_id = StubPayPalClient.add_capture(order_id, status="PARTIALLY_REFUNDED") + + settings.refund_payment(order_id, 5, "USD") + + self.assertEqual(StubPayPalClient.refunds[0]["capture_id"], capture_id) + + def test_refund_without_a_capture_is_refused_with_something_actionable(self): + settings = self.get_settings() + order_id = StubPayPalClient.add_order() + StubPayPalClient.approve(order_id) + + with self.assertRaises(frappe.ValidationError): + settings.refund_payment(order_id, 5, "USD") + self.assertEqual(StubPayPalClient.refunds, []) + + def test_a_rejected_refund_raises_rather_than_reporting_success(self): + settings = self.get_settings() + order_id = StubPayPalClient.add_order() + StubPayPalClient.pay(order_id) + StubPayPalClient.next_refund_status = "FAILED" + + with self.assertRaises(frappe.ValidationError): + settings.refund_payment(order_id, 5, "USD") + + def test_refund_of_an_unknown_order_is_refused(self): + with self.assertRaises(frappe.ValidationError): + self.get_settings().refund_payment("PAYPALNOTHING", 5, "USD") + self.assertEqual(StubPayPalClient.refunds, []) + + def test_release_if_unpaid_keeps_an_open_order_pending(self): + payment_request = make_paypal_payment_request(20, "USD") + + # PayPal has no cancel endpoint for an unapproved order, so the request stays open. + self.assertFalse(payment_request.release_if_unpaid()) + payment_request.reload() + self.assertEqual(payment_request.status, "Pending") + + def test_release_if_unpaid_refuses_a_paid_order(self): + payment_request = make_paypal_payment_request(20, "USD") + StubPayPalClient.pay(payment_request.order_ref) + + self.assertFalse(payment_request.release_if_unpaid()) + payment_request.reload() + self.assertEqual(payment_request.status, "Paid") + + def test_declares_the_currencies_it_can_settle(self): + supported = self.get_settings().supported_currencies() + + self.assertIn("USD", supported) + self.assertNotIn("SAR", supported) + + def test_a_pending_refund_settles_when_paypal_completes_it(self): + settings = self.get_settings() + order_id = StubPayPalClient.add_order() + StubPayPalClient.pay(order_id) + StubPayPalClient.next_refund_status = "PENDING" + + result = settings.refund_payment(order_id, 5, "USD") + self.assertEqual(result.status, "pending") + + StubPayPalClient.set_refund_status(result.refund_id, "COMPLETED") + self.assertEqual(settings.get_refund_status(order_id, result.refund_id).status, "processed") + + def test_a_refund_through_the_request_is_recorded_as_processed(self): + payment_request = make_paypal_payment_request(20, "USD") + StubPayPalClient.pay(payment_request.order_ref) + payment_request.sync_status() + + refund = payment_request.create_refund(5) + + self.assertEqual(refund.status, "Processed") + payment_request.reload() + self.assertEqual(payment_request.status, "Partially Refunded") + + def test_the_transaction_ref_is_the_completed_capture(self): + settings = self.get_settings() + order_id = StubPayPalClient.add_order() + capture_id = StubPayPalClient.pay(order_id) + + self.assertEqual(settings.get_transaction_ref(order_id), capture_id) + + def test_a_fully_refunded_capture_is_still_the_transaction_ref(self): + order_id = StubPayPalClient.add_order(status="COMPLETED") + capture_id = StubPayPalClient.add_capture(order_id, status="REFUNDED") + + self.assertEqual(self.get_settings().get_transaction_ref(order_id), capture_id) + + def test_an_unpaid_order_has_no_transaction_ref(self): + order_id = StubPayPalClient.add_order() + + self.assertIsNone(self.get_settings().get_transaction_ref(order_id)) + + def test_a_return_url_replaces_every_way_back(self): + session = self.get_settings().create_session( + 20, "USD", reference="GPR-0001", return_url="https://shop.test/return" + ) + + self.assertEqual(session.success_url, "https://shop.test/return") + self.assertEqual(session.cancel_url, "https://shop.test/return") + self.assertEqual(session.failure_url, "https://shop.test/return") + context = StubPayPalClient.created_orders[0]["payment_source"]["paypal"]["experience_context"] + self.assertEqual(context["return_url"], "https://shop.test/return") + + def test_paypal_is_listed_with_a_reusable_checkout(self): + gateway = next(gateway for gateway in get_gateways() if gateway.name == PAYPAL_GATEWAY) + + self.assertEqual(gateway.settings_doctype, "PayPal Gateway Settings") + self.assertEqual(gateway.capabilities, [REUSABLE_CHECKOUT]) diff --git a/bwh_payments/services/paypal/__init__.py b/bwh_payments/services/paypal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bwh_payments/services/paypal/base.py b/bwh_payments/services/paypal/base.py new file mode 100644 index 0000000..a6888bb --- /dev/null +++ b/bwh_payments/services/paypal/base.py @@ -0,0 +1,73 @@ +from abc import ABC, abstractmethod + +import frappe + +from bwh_payments.services import GatewayModel + + +class PayPalOrderAlreadyCapturedError(frappe.ValidationError): + """PayPal's `ORDER_ALREADY_CAPTURED`: another call took the money first.""" + + +class PayPalLink(GatewayModel): + rel: str | None = None + href: str | None = None + + +class PayPalCapture(GatewayModel): + id: str | None = None + status: str | None = None + + +class PayPalPayments(GatewayModel): + captures: list[PayPalCapture] | None = None + + +class PayPalPurchaseUnit(GatewayModel): + payments: PayPalPayments | None = None + + +class PayPalOrder(GatewayModel): + id: str + status: str | None = None + links: list[PayPalLink] | None = None + purchase_units: list[PayPalPurchaseUnit] | None = None + + +class PayPalAmount(GatewayModel): + currency_code: str | None = None + # A major-unit decimal string. + value: str | None = None + + +class PayPalRefund(GatewayModel): + id: str | None = None + status: str | None = None + amount: PayPalAmount | None = None + + +class PayPalWebhookVerification(GatewayModel): + verification_status: str | None = None + + +class BasePayPalClient(ABC): + """PayPal Orders v2, Payments v2 and webhook verification. A refusal raises `frappe.ValidationError`.""" + + @abstractmethod + def create_order(self, payload: dict, request_id: str | None = None) -> PayPalOrder: ... + + @abstractmethod + def get_order(self, order_id: str) -> PayPalOrder: ... + + @abstractmethod + def capture_order(self, order_id: str) -> PayPalOrder: + """Raises `PayPalOrderAlreadyCapturedError` when the order was already captured.""" + + @abstractmethod + def refund_capture(self, capture_id: str, payload: dict) -> PayPalRefund: ... + + @abstractmethod + def get_refund(self, refund_id: str) -> PayPalRefund: ... + + @abstractmethod + def verify_webhook_signature(self, payload: dict) -> PayPalWebhookVerification: ... diff --git a/bwh_payments/services/paypal/live.py b/bwh_payments/services/paypal/live.py new file mode 100644 index 0000000..8c0c783 --- /dev/null +++ b/bwh_payments/services/paypal/live.py @@ -0,0 +1,169 @@ +import base64 +from typing import NoReturn + +import frappe +from frappe import _ +from frappe.integrations.utils import create_request_log, make_get_request, make_post_request +from frappe.utils.data import cint + +from bwh_payments.services.paypal.base import ( + BasePayPalClient, + PayPalOrder, + PayPalOrderAlreadyCapturedError, + PayPalRefund, + PayPalWebhookVerification, +) + +PAYPAL_BASE_URLS = { + "Sandbox": "https://api-m.sandbox.paypal.com", + "Live": "https://api-m.paypal.com", +} +PAYPAL_ALREADY_CAPTURED_ISSUE = "ORDER_ALREADY_CAPTURED" +SETTINGS_DOCTYPE = "PayPal Gateway Settings" + + +class LivePayPalClient(BasePayPalClient): + def __init__(self, mode: str, client_id: str, client_secret: str): + self.mode = mode or "Sandbox" + self.base_url = PAYPAL_BASE_URLS[self.mode] + self.client_id = client_id + self.client_secret = client_secret + + def create_order(self, payload: dict, request_id: str | None = None) -> PayPalOrder: + return PayPalOrder.model_validate(self.post("/v2/checkout/orders", payload, request_id)) + + def get_order(self, order_id: str) -> PayPalOrder: + return PayPalOrder.model_validate(self.get(f"/v2/checkout/orders/{order_id}")) + + def capture_order(self, order_id: str) -> PayPalOrder: + # The order id as request id: PayPal replays the first outcome instead of capturing twice. + return PayPalOrder.model_validate( + self.post(f"/v2/checkout/orders/{order_id}/capture", {}, request_id=order_id) + ) + + def refund_capture(self, capture_id: str, payload: dict) -> PayPalRefund: + # No request id: a stable one would replay the first refund instead of taking a second partial. + return PayPalRefund.model_validate(self.post(f"/v2/payments/captures/{capture_id}/refund", payload)) + + def get_refund(self, refund_id: str) -> PayPalRefund: + return PayPalRefund.model_validate(self.get(f"/v2/payments/refunds/{refund_id}")) + + def verify_webhook_signature(self, payload: dict) -> PayPalWebhookVerification: + return PayPalWebhookVerification.model_validate( + self.post("/v1/notifications/verify-webhook-signature", payload) + ) + + def get_access_token(self) -> str: + # `frappe.cache` is per site; the mode keeps a sandbox token away from the live API. + cache_key = f"paypal_access_token::{self.mode}" + if token := frappe.cache.get_value(cache_key): + return token + + url = f"{self.base_url}/v1/oauth2/token" + credentials = base64.b64encode(f"{self.client_id}:{self.client_secret}".encode()).decode() + headers = { + "Authorization": f"Basic {credentials}", + "Content-Type": "application/x-www-form-urlencoded", + } + try: + response = make_post_request(url, headers=headers, data="grant_type=client_credentials") + except Exception as exception: + throw_request_error(url, exception) + + token = response.get("access_token") if isinstance(response, dict) else None + if not token: + log_request(url, error="PayPal returned no access token") + frappe.throw(_("PayPal did not return an access token")) + + # Expire early: a token lapsing mid-checkout surfaces as an unexplained 401. + ttl = max(cint(response.get("expires_in")) - 300, 60) + frappe.cache.set_value(cache_key, token, expires_in_sec=ttl) + return token + + def get_headers(self, request_id: str | None = None) -> dict: + headers = { + "Authorization": f"Bearer {self.get_access_token()}", + "Content-Type": "application/json", + } + if request_id: + headers["PayPal-Request-Id"] = request_id + return headers + + def post(self, endpoint: str, payload: dict, request_id: str | None = None) -> dict: + url = f"{self.base_url}{endpoint}" + try: + response = make_post_request(url, json=payload, headers=self.get_headers(request_id)) + except Exception as exception: + throw_request_error(url, exception) + return read_response(url, response) + + def get(self, endpoint: str) -> dict: + url = f"{self.base_url}{endpoint}" + try: + response = make_get_request(url, headers=self.get_headers()) + except Exception as exception: + throw_request_error(url, exception) + return read_response(url, response) + + +def throw_request_error(url: str, exception: Exception) -> NoReturn: + body = read_paypal_error_body(exception) or {} + description = read_paypal_error(body) + log_request(url, error=description or exception) + exc = ( + PayPalOrderAlreadyCapturedError + if read_paypal_issue(body) == PAYPAL_ALREADY_CAPTURED_ISSUE + else frappe.ValidationError + ) + frappe.throw(_("PayPal rejected the request: {0}").format(description or _("unknown error")), exc=exc) + + +def read_response(url: str, response) -> dict: + if not isinstance(response, dict): + log_request(url, error="PayPal returned a non-JSON body") + frappe.throw(_("PayPal returned an unreadable response")) + + log_request(url, output=summarise_paypal_response(response)) + return response + + +def log_request(url: str, output=None, error=None): + # Headers carry the bearer token and payloads the shopper's details, so only the endpoint is logged. + create_request_log( + {"endpoint": url}, + service_name="PayPal", + is_remote_request=True, + reference_doctype=SETTINGS_DOCTYPE, + reference_docname=SETTINGS_DOCTYPE, + output=output, + error=error, + status="Failed" if error else "Completed", + ) + + +def summarise_paypal_response(response: dict) -> dict: + return {"id": response.get("id"), "status": response.get("status")} + + +def read_paypal_error(body: dict) -> str | None: + details = body.get("details") or [] + description = (details[0] or {}).get("description") if details else None + return description or body.get("message") or body.get("name") + + +def read_paypal_issue(body: dict) -> str | None: + details = body.get("details") or [] + if not details: + return None + return ((details[0] or {}).get("issue") or "").strip().upper() or None + + +def read_paypal_error_body(exception: Exception) -> dict | None: + response = getattr(exception, "response", None) + if response is None: + return None + try: + body = response.json() + except ValueError: + return None + return body if isinstance(body, dict) else None diff --git a/bwh_payments/services/paypal/stub.py b/bwh_payments/services/paypal/stub.py new file mode 100644 index 0000000..caf5c6f --- /dev/null +++ b/bwh_payments/services/paypal/stub.py @@ -0,0 +1,176 @@ +import json +from typing import ClassVar + +import frappe +from frappe import _ + +from bwh_payments.services.paypal.base import ( + BasePayPalClient, + PayPalAmount, + PayPalCapture, + PayPalLink, + PayPalOrder, + PayPalOrderAlreadyCapturedError, + PayPalPayments, + PayPalPurchaseUnit, + PayPalRefund, + PayPalWebhookVerification, +) + +# The five headers PayPal signs a delivery with, in the title case werkzeug hands over. +SIGNATURE_HEADERS = { + "Paypal-Auth-Algo": "SHA256withRSA", + "Paypal-Cert-Url": "https://api.sandbox.paypal.com/cert.pem", + "Paypal-Transmission-Id": "transmission-test-0001", + "Paypal-Transmission-Sig": "signature-test-0001", + "Paypal-Transmission-Time": "2026-09-03T00:00:00Z", +} + + +class StubPayPalClient(BasePayPalClient): + """In-memory PayPal for tests. Call reset() in setUp.""" + + orders: ClassVar[dict[str, PayPalOrder]] = {} + created_orders: ClassVar[list[dict]] = [] + captured_orders: ClassVar[list[str]] = [] + refunds: ClassVar[list[dict]] = [] + issued_refunds: ClassVar[dict[str, PayPalRefund]] = {} + verified_events: ClassVar[list[dict]] = [] + next_refund_status: ClassVar[str] = "COMPLETED" + next_verification_status: ClassVar[str] = "SUCCESS" + # An issue code such as "INSTRUMENT_DECLINED" makes the next capture fail with it. + next_capture_issue: ClassVar[str | None] = None + # Another call (the return page or the webhook) captures between our read and our capture. + lose_next_capture_race: ClassVar[bool] = False + + @classmethod + def reset(cls): + cls.orders = {} + cls.created_orders = [] + cls.captured_orders = [] + cls.refunds = [] + cls.issued_refunds = {} + cls.verified_events = [] + cls.next_refund_status = "COMPLETED" + cls.next_verification_status = "SUCCESS" + cls.next_capture_issue = None + cls.lose_next_capture_race = False + + @classmethod + def add_order(cls, status: str = "CREATED") -> str: + # Unique per call: the doctype enforces a unique order_ref and rows outlive a single test. + order_id = f"PAYPAL{frappe.generate_hash(length=12).upper()}" + cls.orders[order_id] = PayPalOrder( + id=order_id, + status=status, + links=[ + PayPalLink(rel="self", href=f"https://api.paypal.test/v2/checkout/orders/{order_id}"), + PayPalLink(rel="payer-action", href=f"https://paypal.test/checkoutnow?token={order_id}"), + ], + purchase_units=[PayPalPurchaseUnit(payments=PayPalPayments(captures=[]))], + ) + return order_id + + @classmethod + def set_status(cls, order_id: str, status: str): + cls.orders[order_id].status = status + + @classmethod + def add_capture(cls, order_id: str, status: str = "COMPLETED") -> str: + capture_id = f"CAPTURE{frappe.generate_hash(length=12).upper()}" + cls.orders[order_id].purchase_units[0].payments.captures.append( + PayPalCapture(id=capture_id, status=status) + ) + return capture_id + + @classmethod + def set_refund_status(cls, refund_id: str, status: str): + """PayPal settles a PENDING refund later, as COMPLETED or FAILED.""" + cls.issued_refunds[refund_id].status = status + + @classmethod + def approve(cls, order_id: str): + """The shopper approves on PayPal; no money moves until a capture.""" + cls.set_status(order_id, "APPROVED") + + @classmethod + def pay(cls, order_id: str) -> str: + """Approve and capture, as when another call won the capture race.""" + cls.set_status(order_id, "COMPLETED") + return cls.add_capture(order_id) + + def create_order(self, payload: dict, request_id: str | None = None) -> PayPalOrder: + self.created_orders.append(payload) + return self.get_order(self.add_order()) + + def get_order(self, order_id: str) -> PayPalOrder: + if order_id not in self.orders: + frappe.throw( + _("PayPal rejected the request: {0}").format("The specified resource does not exist.") + ) + return self.orders[order_id].model_copy(deep=True) + + def capture_order(self, order_id: str) -> PayPalOrder: + if self.lose_next_capture_race: + StubPayPalClient.lose_next_capture_race = False + self.pay(order_id) + + order = self.get_order(order_id) + if order.status == "COMPLETED": + frappe.throw( + _("PayPal rejected the request: {0}").format("Order already captured."), + exc=PayPalOrderAlreadyCapturedError, + ) + if issue := self.next_capture_issue: + frappe.throw(_("PayPal rejected the request: {0}").format(issue)) + + self.captured_orders.append(order_id) + self.pay(order_id) + return self.get_order(order_id) + + def refund_capture(self, capture_id: str, payload: dict) -> PayPalRefund: + self.refunds.append({"capture_id": capture_id, **payload}) + amount = payload.get("amount") or {} + refund = PayPalRefund( + id=f"REFUND{frappe.generate_hash(length=12).upper()}", + status=self.next_refund_status, + amount=PayPalAmount(currency_code=amount.get("currency_code"), value=amount.get("value")), + ) + self.issued_refunds[refund.id] = refund + return refund.model_copy(deep=True) + + def get_refund(self, refund_id: str) -> PayPalRefund: + if refund_id not in self.issued_refunds: + frappe.throw( + _("PayPal rejected the request: {0}").format("The specified resource does not exist.") + ) + return self.issued_refunds[refund_id].model_copy(deep=True) + + def verify_webhook_signature(self, payload: dict) -> PayPalWebhookVerification: + self.verified_events.append(payload) + return PayPalWebhookVerification(verification_status=self.next_verification_status) + + +def build_order_approved_event(order_id: str, event_id: str = "WH-TEST-APPROVED-0001") -> bytes: + return json.dumps( + { + "id": event_id, + "event_type": "CHECKOUT.ORDER.APPROVED", + "resource": {"id": order_id, "status": "APPROVED"}, + } + ).encode() + + +def build_capture_completed_event(order_id: str, event_id: str = "WH-TEST-CAPTURE-0001") -> bytes: + """`resource.id` is the capture; the order id is only in supplementary data.""" + return json.dumps( + { + "id": event_id, + "event_type": "PAYMENT.CAPTURE.COMPLETED", + "resource": { + "id": f"CAPTURE{frappe.generate_hash(length=8).upper()}", + "status": "COMPLETED", + "supplementary_data": {"related_ids": {"order_id": order_id}}, + }, + } + ).encode() diff --git a/bwh_payments/tests/test_currency_filtering.py b/bwh_payments/tests/test_currency_filtering.py new file mode 100644 index 0000000..7864d1a --- /dev/null +++ b/bwh_payments/tests/test_currency_filtering.py @@ -0,0 +1,27 @@ +# Copyright (c) 2026, Build With Hussain and contributors +# See license.txt + +import frappe + +from bwh_payments.api import get_gateway_options, validate_currency +from bwh_payments.bwh_payments.doctype.paypal_gateway_settings.test_paypal_gateway_settings import ( + PAYPAL_GATEWAY, + PayPalTestCase, +) + + +class TestPayPalCurrencyFiltering(PayPalTestCase): + def get_offered(self, currency: str) -> list[str]: + return [option.name for option in get_gateway_options(currency)] + + def test_paypal_is_hidden_from_a_currency_it_cannot_settle(self): + self.assertNotIn(PAYPAL_GATEWAY, self.get_offered("SAR")) + self.assertIn(PAYPAL_GATEWAY, self.get_offered("USD")) + + def test_currency_matching_ignores_case_and_padding(self): + self.assertIn(PAYPAL_GATEWAY, self.get_offered(" usd ")) + + def test_validate_currency_refuses_what_paypal_cannot_take(self): + with self.assertRaises(frappe.ValidationError): + validate_currency(PAYPAL_GATEWAY, "SAR") + validate_currency(PAYPAL_GATEWAY, "USD") diff --git a/bwh_payments/tests/test_paypal_webhook.py b/bwh_payments/tests/test_paypal_webhook.py new file mode 100644 index 0000000..8c3d5eb --- /dev/null +++ b/bwh_payments/tests/test_paypal_webhook.py @@ -0,0 +1,164 @@ +# Copyright (c) 2026, Build With Hussain and contributors +# See license.txt + +import frappe +from werkzeug.test import EnvironBuilder +from werkzeug.wrappers import Request + +from bwh_payments.bwh_payments import webhook +from bwh_payments.bwh_payments.doctype.paypal_gateway_settings.test_paypal_gateway_settings import ( + PAYPAL_GATEWAY, + PayPalTestCase, + make_paypal_payment_request, +) +from bwh_payments.services.paypal.stub import ( + SIGNATURE_HEADERS, + StubPayPalClient, + build_capture_completed_event, + build_order_approved_event, +) + + +class TestPayPalWebhookSpine(PayPalTestCase): + def setUp(self): + super().setUp() + self.original_request = getattr(frappe.local, "request", None) + self.addCleanup(self.restore_request) + + def restore_request(self): + frappe.local.request = self.original_request + + def post_webhook(self, payload: bytes, headers: dict | None = None, gateway: str = PAYPAL_GATEWAY): + builder = EnvironBuilder( + method="POST", path="/api/method/bwh_payments.bwh_payments.webhook.handle", data=payload + ) + # `is None`, not `or`: an empty dict means "strip the signature". + signed = SIGNATURE_HEADERS if headers is None else headers + builder.headers.extend({"Content-Type": "application/json", **signed}) + environ = builder.get_environ() + environ["QUERY_STRING"] = f"gateway={gateway}" + frappe.local.request = Request(environ) + # Normally set by the WSGI handler; the rate limiter counts against it. + frappe.local.request_ip = "127.0.0.1" + frappe.local.response = frappe._dict() + return webhook.handle() + + def get_status_code(self): + return frappe.local.response.get("http_status_code") + + def test_a_capture_completed_event_marks_the_request_paid(self): + payment_request = make_paypal_payment_request(100, "USD") + payload = build_capture_completed_event(payment_request.order_ref) + + response = self.post_webhook(payload) + + self.assertEqual(response["status"], "ok") + payment_request.reload() + self.assertEqual(payment_request.status, "Paid") + + def test_an_approved_event_captures_the_money_the_shopper_never_came_back_for(self): + payment_request = make_paypal_payment_request(100, "USD") + StubPayPalClient.approve(payment_request.order_ref) + payload = build_order_approved_event(payment_request.order_ref) + + response = self.post_webhook(payload) + + self.assertEqual(response["status"], "ok") + self.assertEqual(StubPayPalClient.captured_orders, [payment_request.order_ref]) + payment_request.reload() + self.assertEqual(payment_request.status, "Paid") + + def test_a_delivery_paypal_will_not_verify_is_rejected(self): + payment_request = make_paypal_payment_request(100, "USD") + payload = build_capture_completed_event(payment_request.order_ref) + StubPayPalClient.next_verification_status = "FAILURE" + + response = self.post_webhook(payload) + + self.assertEqual(response["status"], "error") + self.assertEqual(self.get_status_code(), 400) + payment_request.reload() + self.assertEqual(payment_request.status, "Pending") + + def test_a_delivery_missing_its_signature_headers_is_rejected(self): + payment_request = make_paypal_payment_request(100, "USD") + payload = build_capture_completed_event(payment_request.order_ref) + + response = self.post_webhook(payload, headers={}) + + self.assertEqual(response["status"], "error") + self.assertEqual(self.get_status_code(), 400) + payment_request.reload() + self.assertEqual(payment_request.status, "Pending") + self.assertEqual(StubPayPalClient.verified_events, []) + + def test_the_body_is_handed_to_paypal_exactly_as_the_event_it_arrived_as(self): + payment_request = make_paypal_payment_request(100, "USD") + payload = build_capture_completed_event(payment_request.order_ref, event_id="WH-VERIFY-0001") + + self.post_webhook(payload) + + verified = StubPayPalClient.verified_events[0] + self.assertEqual(verified["webhook_event"]["id"], "WH-VERIFY-0001") + self.assertEqual(verified["transmission_id"], SIGNATURE_HEADERS["Paypal-Transmission-Id"]) + self.assertEqual(verified["webhook_id"], self.get_settings().webhook_id) + + def test_an_event_type_we_do_not_act_on_is_accepted_and_ignored(self): + payment_request = make_paypal_payment_request(100, "USD") + payload = build_capture_completed_event(payment_request.order_ref).replace( + b"PAYMENT.CAPTURE.COMPLETED", b"PAYMENT.CAPTURE.PENDING" + ) + + response = self.post_webhook(payload) + + self.assertEqual(response["status"], "ok") + payment_request.reload() + self.assertEqual(payment_request.status, "Pending") + + def test_a_replayed_delivery_is_accepted_but_applied_once(self): + payment_request = make_paypal_payment_request(100, "USD") + payload = build_capture_completed_event(payment_request.order_ref, event_id="WH-REPLAY-0001") + + self.post_webhook(payload) + second = self.post_webhook(payload) + + self.assertEqual(second["status"], "ok") + self.assertIsNone(self.get_status_code()) + payment_request.reload() + self.assertEqual(payment_request.status, "Paid") + self.assertEqual(payment_request.last_webhook_event_id, "WH-REPLAY-0001") + + def test_every_failure_looks_the_same_from_outside(self): + payment_request = make_paypal_payment_request(100, "USD") + payload = build_capture_completed_event(payment_request.order_ref) + + unknown_gateway = self.post_webhook(payload, gateway="NotAGateway") + unsigned = self.post_webhook(payload, headers={}) + StubPayPalClient.next_verification_status = "FAILURE" + unverified = self.post_webhook(payload) + + self.assertEqual(unknown_gateway, unsigned) + self.assertEqual(unsigned, unverified) + + def test_a_rejection_does_not_echo_the_gateway_error_back_to_the_caller(self): + payload = build_capture_completed_event("PAYPALNOTHING") + + self.post_webhook(payload, headers={}) + + self.assertEqual(frappe.local.message_log, []) + + def test_the_webhook_log_never_stores_the_raw_payload(self): + payment_request = make_paypal_payment_request(100, "USD") + payload = build_capture_completed_event(payment_request.order_ref) + + self.post_webhook(payload) + + logged = frappe.get_all( + "Integration Request", + filters={"integration_request_service": f"{PAYPAL_GATEWAY} Webhook"}, + fields=["data"], + ) + self.assertTrue(logged) + for row in logged: + self.assertNotIn("supplementary_data", row.data) + self.assertNotIn("event_type", row.data) From fad8e8317edb43b36f7ac18284d9b0f8edc33dd1 Mon Sep 17 00:00:00 2001 From: Isha Dewangan Date: Wed, 14 Oct 2026 14:47:14 +0530 Subject: [PATCH 5/8] fix(paypal): capture only through the request's status sync Review fixes for the PayPal gateway. - CHECKOUT.ORDER.APPROVED no longer captures inside the guest webhook. It queues sync_status, as Administrator, for our own Pending request with that order_ref and returns None. An order that is not ours is never captured, and a declined capture fails the job, not the delivery. - The cached access token is keyed on mode and client id, so new credentials never create orders in the old account. - Refund webhooks: PAYMENT.CAPTURE.REFUNDED, PAYMENT.CAPTURE.REVERSED, PAYMENT.REFUND.PENDING and PAYMENT.REFUND.FAILED. A dashboard refund is found through the capture id in the refund's `up` link. - Webhook verification stays a call back to PayPal, documented as the one deliberate exception to local signature checks. - Smaller: only COMPLETED is mapped for refunds, the `approve` link fallback is gone, supports_currency replaces validate_paypal_currency, and the live client reads the error detail once. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 22 +-- .../paypal_gateway_settings.py | 129 +++++++++++------- .../test_paypal_gateway_settings.py | 5 +- bwh_payments/services/paypal/base.py | 1 + bwh_payments/services/paypal/live.py | 32 ++--- bwh_payments/services/paypal/stub.py | 36 ++++- bwh_payments/tests/test_paypal_webhook.py | 79 ++++++++++- 7 files changed, 217 insertions(+), 87 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 97e4b6b..200bfb9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,8 @@ Every external service sits behind a client in `bwh_payments/services// The settings controller picks the client in `get_client()`: the stub when `frappe.in_test`, otherwise the live one. There is no other switch, so a real site can never run on a stub. The controller keeps every decision: currency conversion, URL building, status maps, capture and refund rules, and webhook signature -checks. Signature checks are local crypto and must never move into a client a stub could replace. +checks. Signature checks are local crypto and must never move into a client a stub could replace. PayPal is +the one deliberate exception (see below). Live HTTP clients pass no timeout: `make_request` accepts one only from Frappe 17. Add it when Frappe 16 support is dropped. @@ -153,13 +154,18 @@ An insert that is already settled is an import and fires neither. `get_payment_status`. - **Tabby**: unsigned webhooks, checked with a static header token plus a source-IP allowlist. `get_payment_status` also *captures* an authorised payment; that capture is where money is taken. -- **PayPal**: raw HTTP (Orders v2) with a cached client-credentials token. `get_payment_status` and the - `CHECKOUT.ORDER.APPROVED` webhook both capture an approved order; the loser of that race gets - `ORDER_ALREADY_CAPTURED` (`PayPalOrderAlreadyCapturedError`), which counts as paid. Webhooks are signed - with a certificate, so verification is a call back to PayPal (`verify_webhook_signature`), the one - verifier that goes through the client; the stub's `next_verification_status` stands in for a forgery. - `cancel_session` stays `False`: PayPal cannot cancel an unapproved order. Refunds report `processed` / - `pending` (PayPal's `COMPLETED` / `PENDING`); a pending one settles through `get_refund_status`. +- **PayPal**: raw HTTP (Orders v2) with a client-credentials token, cached per mode and client id. + Only `get_payment_status` captures an approved order. The `CHECKOUT.ORDER.APPROVED` webhook captures + nothing itself: it queues `sync_status` for our own Pending request with that `order_ref` and returns + `None`, so a guest request never moves money and an order that is not ours is never captured. When the + return page and that job race, the loser gets `ORDER_ALREADY_CAPTURED` + (`PayPalOrderAlreadyCapturedError`), which counts as paid. Webhooks are signed with a certificate, so + verification is a call back to PayPal (`verify_webhook_signature`). It is the one verifier that goes + through the client, by decision: checking PayPal's certificate chain locally is easy to get wrong. The + stub's `next_verification_status` stands in for a forgery. `cancel_session` stays `False`: PayPal + cannot cancel an unapproved order. Refunds report `processed` for PayPal's `COMPLETED` and PayPal's own + status otherwise. Refund events: `PAYMENT.CAPTURE.REFUNDED`, `PAYMENT.CAPTURE.REVERSED`, + `PAYMENT.REFUND.PENDING`, `PAYMENT.REFUND.FAILED`; the refund names its capture only in its `up` link. `get_transaction_ref` is the capture's id. ### Money and currency rules diff --git a/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/paypal_gateway_settings.py b/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/paypal_gateway_settings.py index db302c4..3d38490 100644 --- a/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/paypal_gateway_settings.py +++ b/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/paypal_gateway_settings.py @@ -8,16 +8,15 @@ from frappe.model.document import Document from frappe.utils.data import flt -from bwh_payments.base_class import ( - REUSABLE_CHECKOUT, - CheckoutSession, - PaymentGatewayBase, - RefundResult, - WebhookEvent, -) -from bwh_payments.bwh_payments.utils import get_localised_url +from bwh_payments.base_class import CheckoutSession, PaymentGatewayBase, RefundResult, WebhookEvent +from bwh_payments.bwh_payments.utils import get_localised_url, run_as_administrator, supports_currency from bwh_payments.currency import get_minor_unit_exponent, validate_transaction_currency -from bwh_payments.services.paypal.base import BasePayPalClient, PayPalOrder, PayPalOrderAlreadyCapturedError +from bwh_payments.services.paypal.base import ( + BasePayPalClient, + PayPalOrder, + PayPalOrderAlreadyCapturedError, + PayPalRefund, +) from bwh_payments.services.paypal.live import LivePayPalClient from bwh_payments.services.paypal.stub import StubPayPalClient @@ -61,13 +60,6 @@ PAYPAL_APPROVED_STATUS = "APPROVED" PAYPAL_ACCEPTED_REFUND_STATUSES = ("COMPLETED", "PENDING") -# PayPal's refund statuses in the words Gateway Refund understands; anything else stays Pending. -PAYPAL_REFUND_STATUS_MAP = { - "COMPLETED": "processed", - "PENDING": "pending", - "FAILED": "failed", - "CANCELLED": "cancelled", -} # The balance of a partially refunded capture stays refundable. PAYPAL_REFUNDABLE_CAPTURE_STATUSES = ("COMPLETED", "PARTIALLY_REFUNDED") # A fully refunded capture is still the payment that settled. @@ -76,9 +68,13 @@ PAYPAL_ORDER_APPROVED_EVENT = "CHECKOUT.ORDER.APPROVED" PAYPAL_CAPTURE_COMPLETED_EVENT = "PAYMENT.CAPTURE.COMPLETED" - -# `payer-action` answers an order created with `payment_source`; `approve` is the older spelling. -PAYPAL_REDIRECT_LINK_RELS = ("payer-action", "approve") +# Each carries the refund as its resource. REVERSED is PayPal taking the money back, e.g. a chargeback. +PAYPAL_REFUND_EVENTS = ( + "PAYMENT.CAPTURE.REFUNDED", + "PAYMENT.CAPTURE.REVERSED", + "PAYMENT.REFUND.PENDING", + "PAYMENT.REFUND.FAILED", +) # Title case: `dict(frappe.request.headers)` drops werkzeug's case-insensitivity. PAYPAL_SIGNATURE_HEADERS = { @@ -94,7 +90,7 @@ class PayPalGatewaySettings(Document, PaymentGatewayBase): description = "PayPal wallet payments. Not for INR or Gulf currencies." docs_url = "https://developer.paypal.com/dashboard/applications" # An approval link stays payable until the order is captured or expires. - capabilities = (REUSABLE_CHECKOUT,) + reusable_checkout = True # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. @@ -138,7 +134,12 @@ def create_session( ) -> CheckoutSession: currency = currency or self.currency validate_transaction_currency(currency) - validate_paypal_currency(currency) + if not supports_currency(self, currency): + frappe.throw( + _("PayPal cannot take payment in {0}. It supports {1}.").format( + frappe.bold(currency), ", ".join(PAYPAL_SUPPORTED_CURRENCIES) + ) + ) success_url = return_url or self.build_return_url(self.success_url, _("Success URL"), reference) cancel_url = return_url or self.build_return_url(self.cancelled_url, _("Cancelled URL"), reference) @@ -211,25 +212,27 @@ def handle_webhook(self, payload: bytes, headers: dict) -> WebhookEvent | None: resource = event.get("resource") or {} if event_type == PAYPAL_ORDER_APPROVED_EVENT: - session_id = resource.get("id") - if not session_id: - frappe.throw(_("PayPal sent a {0} event with no order id").format(event_type)) - # Charges a shopper who approved on PayPal and never came back to the storefront. - status = PAYPAL_STATUS_MAP.get(read_status(self.capture_order(session_id)), "Pending") - elif event_type == PAYPAL_CAPTURE_COMPLETED_EVENT: - # `resource.id` is the capture; the order id is only in supplementary data. - related_ids = (resource.get("supplementary_data") or {}).get("related_ids") or {} - session_id = related_ids.get("order_id") - if not session_id: + if not resource.get("id"): frappe.throw(_("PayPal sent a {0} event with no order id").format(event_type)) - status = "Paid" - else: + enqueue_capture(resource["id"]) + return None + if event_type in PAYPAL_REFUND_EVENTS: + return read_refund_event(event) + if event_type != PAYPAL_CAPTURE_COMPLETED_EVENT: return None - return WebhookEvent(session_id=session_id, status=status, event_id=event.get("id")) + # `resource.id` is the capture; the order id is only in supplementary data. + related_ids = (resource.get("supplementary_data") or {}).get("related_ids") or {} + if not related_ids.get("order_id"): + frappe.throw(_("PayPal sent a {0} event with no order id").format(event_type)) + return WebhookEvent(session_id=related_ids["order_id"], status="Paid", event_id=event.get("id")) def verify_webhook(self, payload: bytes, headers: dict) -> dict: - """PayPal signs with a certificate, so verification is a call back to PayPal with our webhook id.""" + """PayPal signs with a certificate, so we ask PayPal to verify the delivery with our webhook id. + + The one verifier that goes through the client, on purpose: checking PayPal's certificate chain + locally is easy to get wrong, and PayPal's own check is the documented way. + """ if not self.webhook_id: frappe.throw(_("Webhook ID is not configured in PayPal Gateway Settings")) @@ -290,15 +293,39 @@ def get_transaction_ref(self, session_id: str) -> str | None: return find_capture_id(self.get_client().get_order(session_id), PAYPAL_SETTLED_CAPTURE_STATUSES) -def validate_paypal_currency(currency: str): - if (currency or "").strip().upper() not in PAYPAL_SUPPORTED_CURRENCIES: - frappe.throw( - _("PayPal cannot take payment in {0}. It supports {1}.").format( - frappe.bold(currency), ", ".join(PAYPAL_SUPPORTED_CURRENCIES) - ) +def enqueue_capture(session_id: str): + """Capture moves money, so a guest webhook never does it. Our own request's status sync does.""" + if payment_request := frappe.db.get_value( + "Gateway Payment Request", {"order_ref": session_id, "status": "Pending"}, "name" + ): + frappe.enqueue( + "bwh_payments.bwh_payments.doctype.paypal_gateway_settings.paypal_gateway_settings.sync_payment_request", + queue="short", + payment_request=payment_request, + now=frappe.in_test, ) +def sync_payment_request(payment_request: str): + """As Administrator, like the return page: a paid request settles its referenced document.""" + with run_as_administrator(): + frappe.get_doc("Gateway Payment Request", payment_request).sync_status() + + +def read_refund_event(event: dict) -> WebhookEvent: + refund = PayPalRefund.model_validate(event.get("resource") or {}) + if not refund.id: + frappe.throw(_("PayPal sent a refund event with no refund id")) + + return WebhookEvent( + refund_id=refund.id, + status=read_refund_status(refund), + refund_amount=flt(refund.amount.value) if refund.amount and refund.amount.value else None, + transaction_ref=get_refunded_capture_id(refund), + event_id=event.get("id"), + ) + + def get_paypal_exponent(currency: str) -> int: if (currency or "").strip().upper() in PAYPAL_WHOLE_AMOUNT_CURRENCIES: return 0 @@ -322,10 +349,9 @@ def read_status(entity) -> str: def get_redirect_url(order: PayPalOrder) -> str: - links = {link.rel: link.href for link in order.links or []} - for rel in PAYPAL_REDIRECT_LINK_RELS: - if links.get(rel): - return links[rel] + for link in order.links or []: + if link.rel == "payer-action" and link.href: + return link.href frappe.throw(_("PayPal created an order with no approval link")) @@ -351,5 +377,14 @@ def find_capture_id(order: PayPalOrder, statuses: tuple[str, ...]) -> str | None return None -def read_refund_status(refund) -> str: - return PAYPAL_REFUND_STATUS_MAP.get(read_status(refund), "pending") +def get_refunded_capture_id(refund: PayPalRefund) -> str | None: + """A refund names its capture only in its `up` link: `.../v2/payments/captures/`.""" + for link in refund.links or []: + if link.rel == "up" and link.href: + return link.href.rstrip("/").rsplit("/", 1)[-1] + return None + + +def read_refund_status(refund: PayPalRefund) -> str | None: + # Gateway Refund reads pending, failed and cancelled as they are; only COMPLETED needs a word. + return "processed" if read_status(refund) == "COMPLETED" else refund.status diff --git a/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/test_paypal_gateway_settings.py b/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/test_paypal_gateway_settings.py index 2a75f44..d1bb2e4 100644 --- a/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/test_paypal_gateway_settings.py +++ b/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/test_paypal_gateway_settings.py @@ -5,7 +5,6 @@ from frappe.tests import IntegrationTestCase from bwh_payments.api import get_gateways -from bwh_payments.base_class import REUSABLE_CHECKOUT from bwh_payments.bwh_payments.doctype.paypal_gateway_settings import paypal_gateway_settings from bwh_payments.services.paypal.stub import StubPayPalClient @@ -297,7 +296,7 @@ def test_a_pending_refund_settles_when_paypal_completes_it(self): StubPayPalClient.next_refund_status = "PENDING" result = settings.refund_payment(order_id, 5, "USD") - self.assertEqual(result.status, "pending") + self.assertEqual(result.status, "PENDING") StubPayPalClient.set_refund_status(result.refund_id, "COMPLETED") self.assertEqual(settings.get_refund_status(order_id, result.refund_id).status, "processed") @@ -346,4 +345,4 @@ def test_paypal_is_listed_with_a_reusable_checkout(self): gateway = next(gateway for gateway in get_gateways() if gateway.name == PAYPAL_GATEWAY) self.assertEqual(gateway.settings_doctype, "PayPal Gateway Settings") - self.assertEqual(gateway.capabilities, [REUSABLE_CHECKOUT]) + self.assertTrue(gateway.reusable_checkout) diff --git a/bwh_payments/services/paypal/base.py b/bwh_payments/services/paypal/base.py index a6888bb..68fd1a6 100644 --- a/bwh_payments/services/paypal/base.py +++ b/bwh_payments/services/paypal/base.py @@ -44,6 +44,7 @@ class PayPalRefund(GatewayModel): id: str | None = None status: str | None = None amount: PayPalAmount | None = None + links: list[PayPalLink] | None = None class PayPalWebhookVerification(GatewayModel): diff --git a/bwh_payments/services/paypal/live.py b/bwh_payments/services/paypal/live.py index 8c0c783..8d2a078 100644 --- a/bwh_payments/services/paypal/live.py +++ b/bwh_payments/services/paypal/live.py @@ -54,8 +54,9 @@ def verify_webhook_signature(self, payload: dict) -> PayPalWebhookVerification: ) def get_access_token(self) -> str: - # `frappe.cache` is per site; the mode keeps a sandbox token away from the live API. - cache_key = f"paypal_access_token::{self.mode}" + # `frappe.cache` is per site. The mode and client id keep a token to the account that issued it, + # so new credentials never create orders in the old account. + cache_key = f"paypal_access_token::{self.mode}::{self.client_id}" if token := frappe.cache.get_value(cache_key): return token @@ -108,12 +109,12 @@ def get(self, endpoint: str) -> dict: def throw_request_error(url: str, exception: Exception) -> NoReturn: body = read_paypal_error_body(exception) or {} - description = read_paypal_error(body) + detail = (body.get("details") or [{}])[0] or {} + description = detail.get("description") or body.get("message") or body.get("name") log_request(url, error=description or exception) + issue = (detail.get("issue") or "").strip().upper() exc = ( - PayPalOrderAlreadyCapturedError - if read_paypal_issue(body) == PAYPAL_ALREADY_CAPTURED_ISSUE - else frappe.ValidationError + PayPalOrderAlreadyCapturedError if issue == PAYPAL_ALREADY_CAPTURED_ISSUE else frappe.ValidationError ) frappe.throw(_("PayPal rejected the request: {0}").format(description or _("unknown error")), exc=exc) @@ -123,7 +124,7 @@ def read_response(url: str, response) -> dict: log_request(url, error="PayPal returned a non-JSON body") frappe.throw(_("PayPal returned an unreadable response")) - log_request(url, output=summarise_paypal_response(response)) + log_request(url, output={"id": response.get("id"), "status": response.get("status")}) return response @@ -141,23 +142,6 @@ def log_request(url: str, output=None, error=None): ) -def summarise_paypal_response(response: dict) -> dict: - return {"id": response.get("id"), "status": response.get("status")} - - -def read_paypal_error(body: dict) -> str | None: - details = body.get("details") or [] - description = (details[0] or {}).get("description") if details else None - return description or body.get("message") or body.get("name") - - -def read_paypal_issue(body: dict) -> str | None: - details = body.get("details") or [] - if not details: - return None - return ((details[0] or {}).get("issue") or "").strip().upper() or None - - def read_paypal_error_body(exception: Exception) -> dict | None: response = getattr(exception, "response", None) if response is None: diff --git a/bwh_payments/services/paypal/stub.py b/bwh_payments/services/paypal/stub.py index caf5c6f..ae6b64b 100644 --- a/bwh_payments/services/paypal/stub.py +++ b/bwh_payments/services/paypal/stub.py @@ -131,10 +131,8 @@ def capture_order(self, order_id: str) -> PayPalOrder: def refund_capture(self, capture_id: str, payload: dict) -> PayPalRefund: self.refunds.append({"capture_id": capture_id, **payload}) amount = payload.get("amount") or {} - refund = PayPalRefund( - id=f"REFUND{frappe.generate_hash(length=12).upper()}", - status=self.next_refund_status, - amount=PayPalAmount(currency_code=amount.get("currency_code"), value=amount.get("value")), + refund = build_refund( + capture_id, self.next_refund_status, amount.get("value"), amount.get("currency_code") ) self.issued_refunds[refund.id] = refund return refund.model_copy(deep=True) @@ -174,3 +172,33 @@ def build_capture_completed_event(order_id: str, event_id: str = "WH-TEST-CAPTUR }, } ).encode() + + +def build_refund_event( + refund: PayPalRefund, + event_type: str = "PAYMENT.CAPTURE.REFUNDED", + event_id: str | None = None, +) -> bytes: + return json.dumps( + { + "id": event_id or f"WH-TEST-REFUND-{frappe.generate_hash(length=8).upper()}", + "event_type": event_type, + "resource": refund.model_dump(exclude_none=True), + } + ).encode() + + +def build_refund( + capture_id: str, status: str = "COMPLETED", value: str = "5.00", currency_code: str = "USD" +) -> PayPalRefund: + """A refund as PayPal sends it, naming its capture only in the `up` link.""" + refund_id = f"REFUND{frappe.generate_hash(length=12).upper()}" + return PayPalRefund( + id=refund_id, + status=status, + amount=PayPalAmount(currency_code=currency_code, value=value), + links=[ + PayPalLink(rel="self", href=f"https://api.paypal.test/v2/payments/refunds/{refund_id}"), + PayPalLink(rel="up", href=f"https://api.paypal.test/v2/payments/captures/{capture_id}"), + ], + ) diff --git a/bwh_payments/tests/test_paypal_webhook.py b/bwh_payments/tests/test_paypal_webhook.py index 8c3d5eb..d3c1321 100644 --- a/bwh_payments/tests/test_paypal_webhook.py +++ b/bwh_payments/tests/test_paypal_webhook.py @@ -16,10 +16,12 @@ StubPayPalClient, build_capture_completed_event, build_order_approved_event, + build_refund, + build_refund_event, ) -class TestPayPalWebhookSpine(PayPalTestCase): +class PayPalWebhookTestCase(PayPalTestCase): def setUp(self): super().setUp() self.original_request = getattr(frappe.local, "request", None) @@ -46,6 +48,8 @@ def post_webhook(self, payload: bytes, headers: dict | None = None, gateway: str def get_status_code(self): return frappe.local.response.get("http_status_code") + +class TestPayPalWebhookSpine(PayPalWebhookTestCase): def test_a_capture_completed_event_marks_the_request_paid(self): payment_request = make_paypal_payment_request(100, "USD") payload = build_capture_completed_event(payment_request.order_ref) @@ -68,6 +72,24 @@ def test_an_approved_event_captures_the_money_the_shopper_never_came_back_for(se payment_request.reload() self.assertEqual(payment_request.status, "Paid") + def test_an_approved_order_that_is_not_ours_is_never_captured(self): + order_id = StubPayPalClient.add_order() + StubPayPalClient.approve(order_id) + + response = self.post_webhook(build_order_approved_event(order_id)) + + self.assertEqual(response["status"], "ok") + self.assertEqual(StubPayPalClient.captured_orders, []) + + def test_an_approved_event_for_a_paid_request_captures_nothing(self): + payment_request = make_paypal_payment_request(100, "USD") + StubPayPalClient.pay(payment_request.order_ref) + payment_request.sync_status() + + self.post_webhook(build_order_approved_event(payment_request.order_ref, event_id="WH-LATE-0001")) + + self.assertEqual(StubPayPalClient.captured_orders, []) + def test_a_delivery_paypal_will_not_verify_is_rejected(self): payment_request = make_paypal_payment_request(100, "USD") payload = build_capture_completed_event(payment_request.order_ref) @@ -162,3 +184,58 @@ def test_the_webhook_log_never_stores_the_raw_payload(self): for row in logged: self.assertNotIn("supplementary_data", row.data) self.assertNotIn("event_type", row.data) + + +class TestPayPalRefundWebhook(PayPalWebhookTestCase): + def make_paid_request(self, amount: float = 20): + payment_request = make_paypal_payment_request(amount, "USD") + StubPayPalClient.pay(payment_request.order_ref) + payment_request.sync_status() + return payment_request + + def get_refund(self, refund_id: str): + return frappe.get_doc("Gateway Refund", {"refund_id": refund_id}) + + def test_a_refund_raised_on_the_paypal_dashboard_is_recorded(self): + payment_request = self.make_paid_request() + refund = build_refund(payment_request.gateway_transaction_ref, "COMPLETED", "5.00") + + response = self.post_webhook(build_refund_event(refund)) + + self.assertEqual(response["status"], "ok") + recorded = self.get_refund(refund.id) + self.assertEqual(recorded.status, "Processed") + self.assertEqual(recorded.amount, 5) + payment_request.reload() + self.assertEqual(payment_request.status, "Partially Refunded") + + def test_a_pending_refund_settles_when_paypal_completes_it(self): + payment_request = self.make_paid_request() + StubPayPalClient.next_refund_status = "PENDING" + refund_id = payment_request.create_refund(5).refund_id + + StubPayPalClient.set_refund_status(refund_id, "COMPLETED") + self.post_webhook(build_refund_event(StubPayPalClient.issued_refunds[refund_id])) + + self.assertEqual(self.get_refund(refund_id).status, "Processed") + + def test_a_failed_refund_is_marked_failed(self): + payment_request = self.make_paid_request() + StubPayPalClient.next_refund_status = "PENDING" + refund_id = payment_request.create_refund(5).refund_id + + StubPayPalClient.set_refund_status(refund_id, "FAILED") + self.post_webhook( + build_refund_event(StubPayPalClient.issued_refunds[refund_id], event_type="PAYMENT.REFUND.FAILED") + ) + + self.assertEqual(self.get_refund(refund_id).status, "Failed") + + def test_a_reversal_is_recorded_as_a_refund(self): + payment_request = self.make_paid_request(20) + refund = build_refund(payment_request.gateway_transaction_ref, "COMPLETED", "20.00") + + self.post_webhook(build_refund_event(refund, event_type="PAYMENT.CAPTURE.REVERSED")) + + payment_request.reload() + self.assertEqual(payment_request.status, "Refunded") From b4676c7ed5dd434263592938baa7c1dfb9b76ccb Mon Sep 17 00:00:00 2001 From: Isha Dewangan Date: Wed, 14 Oct 2026 15:03:52 +0530 Subject: [PATCH 6/8] fix(paypal): capture through the shared webhook pipeline Follow-up review fixes. - WebhookEvent gains `sync_status`: the request asks the gateway instead of applying `status`. CHECKOUT.ORDER.APPROVED returns one, so the capture runs in process_webhook with the gateway check, event id dedupe and Integration Request log. A failed capture is a Failed request that retry_webhook can replay, instead of a lost background job. - A request that is no longer Pending is not synced, so a closed request never captures. - A PayPal refund with no status stays pending instead of reading as Processed. - PAYMENT.CAPTURE.REVERSED is no longer recorded as a refund. PayPal can reinstate a disputed capture with no webhook, and a settled Gateway Refund never changes. - The PayPal-only sync_payment_request job is gone. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 22 +++++--- bwh_payments/base_class.py | 7 ++- .../paypal_gateway_settings.py | 54 ++++++++----------- .../test_paypal_gateway_settings.py | 7 +++ bwh_payments/bwh_payments/webhook.py | 4 ++ bwh_payments/tests/test_paypal_webhook.py | 38 ++++++++++--- 6 files changed, 83 insertions(+), 49 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 200bfb9..2fcfe65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,9 @@ keeps working: `cancel_session` (False), `get_transaction_ref` (the settled paym `logo_dark`, `description`, `docs_url` and `reusable_checkout` class attributes. `reusable_checkout` means the checkout link stays payable after the shopper leaves it. `create_session` also takes an optional `return_url`, passed only when set. Results are pydantic models: `CheckoutSession`, `RefundResult`, and `WebhookEvent | None` (a payment event -carries `session_id`, a refund event `refund_id`, `refund_amount` and `transaction_ref`). Callers read them through +carries `session_id`, a refund event `refund_id`, `refund_amount` and `transaction_ref`; a payment event +with `sync_status` makes the request ask the gateway instead of applying `status`, for a gateway that still +has to capture). Callers read them through `.model_validate`, so a third-party gateway that still returns dicts keeps working. Each gateway is a **Single DocType** ` Gateway Settings` whose controller inherits `(Document, PaymentGatewayBase)` and holds the credentials (as `Password` fields, read with `get_password`). @@ -121,7 +123,8 @@ An insert that is already settled is an import and fires neither. `Paid`, `gateway_transaction_ref` is read through `get_transaction_ref` (a failure never blocks it). - **Webhook**: `webhook.handle` (guest POST, `?gateway=`, rate-limited) verifies the delivery, logs the parsed event as a Queued Integration Request (deduped on the event id), answers 200 and - queues `process_webhook`. The job applies it as Administrator; a failure is rolled back to a + queues `process_webhook`. The job applies it as Administrator (a `sync_status` event runs + `sync_status` on a Pending request instead); a failure is rolled back to a savepoint, logged and marked Failed for `retry_webhook` (System Manager). Deadlocks and lock timeouts are raised as `RetryBackgroundJobError`, because Frappe's job runner does not retry the `QueryDeadlockError` / `QueryTimeoutError` it wraps them in. @@ -155,17 +158,20 @@ An insert that is already settled is an import and fires neither. - **Tabby**: unsigned webhooks, checked with a static header token plus a source-IP allowlist. `get_payment_status` also *captures* an authorised payment; that capture is where money is taken. - **PayPal**: raw HTTP (Orders v2) with a client-credentials token, cached per mode and client id. - Only `get_payment_status` captures an approved order. The `CHECKOUT.ORDER.APPROVED` webhook captures - nothing itself: it queues `sync_status` for our own Pending request with that `order_ref` and returns - `None`, so a guest request never moves money and an order that is not ours is never captured. When the - return page and that job race, the loser gets `ORDER_ALREADY_CAPTURED` + Only `get_payment_status` captures an approved order. The `CHECKOUT.ORDER.APPROVED` webhook returns a + `sync_status` event, so the capture runs in `process_webhook` for our own Pending request: a guest + request never moves money, an order that is not ours is never captured, and a failed capture is a Failed + Integration Request for `retry_webhook`. When the return page and that job race, the loser gets + `ORDER_ALREADY_CAPTURED` (`PayPalOrderAlreadyCapturedError`), which counts as paid. Webhooks are signed with a certificate, so verification is a call back to PayPal (`verify_webhook_signature`). It is the one verifier that goes through the client, by decision: checking PayPal's certificate chain locally is easy to get wrong. The stub's `next_verification_status` stands in for a forgery. `cancel_session` stays `False`: PayPal cannot cancel an unapproved order. Refunds report `processed` for PayPal's `COMPLETED` and PayPal's own - status otherwise. Refund events: `PAYMENT.CAPTURE.REFUNDED`, `PAYMENT.CAPTURE.REVERSED`, - `PAYMENT.REFUND.PENDING`, `PAYMENT.REFUND.FAILED`; the refund names its capture only in its `up` link. + status otherwise. Refund events: `PAYMENT.CAPTURE.REFUNDED`, `PAYMENT.REFUND.PENDING`, + `PAYMENT.REFUND.FAILED`; the refund names its capture only in its `up` link. `PAYMENT.CAPTURE.REVERSED` + (dispute or chargeback) is ignored on purpose: PayPal can reinstate the money with no webhook, and a + settled Gateway Refund never changes, so the merchant records a reversal once it stands. `get_transaction_ref` is the capture's id. ### Money and currency rules diff --git a/bwh_payments/base_class.py b/bwh_payments/base_class.py index bcfd30a..8e4f037 100644 --- a/bwh_payments/base_class.py +++ b/bwh_payments/base_class.py @@ -24,10 +24,15 @@ class RefundResult(GatewayResult): class WebhookEvent(GatewayResult): - """A payment event carries `session_id`; a refund event carries `refund_id` instead.""" + """A payment event carries `session_id`; a refund event carries `refund_id` instead. + + With `sync_status`, the request asks the gateway instead of applying `status`. A gateway whose + payment still needs a capture uses it, so money moves in our own status sync, never in a guest request. + """ session_id: str | None = None status: str + sync_status: bool = False event_id: str | None = None refund_id: str | None = None # Major units. Needed to record a refund raised outside this app. diff --git a/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/paypal_gateway_settings.py b/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/paypal_gateway_settings.py index 3d38490..bb96834 100644 --- a/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/paypal_gateway_settings.py +++ b/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/paypal_gateway_settings.py @@ -9,7 +9,7 @@ from frappe.utils.data import flt from bwh_payments.base_class import CheckoutSession, PaymentGatewayBase, RefundResult, WebhookEvent -from bwh_payments.bwh_payments.utils import get_localised_url, run_as_administrator, supports_currency +from bwh_payments.bwh_payments.utils import get_localised_url, supports_currency from bwh_payments.currency import get_minor_unit_exponent, validate_transaction_currency from bwh_payments.services.paypal.base import ( BasePayPalClient, @@ -68,10 +68,10 @@ PAYPAL_ORDER_APPROVED_EVENT = "CHECKOUT.ORDER.APPROVED" PAYPAL_CAPTURE_COMPLETED_EVENT = "PAYMENT.CAPTURE.COMPLETED" -# Each carries the refund as its resource. REVERSED is PayPal taking the money back, e.g. a chargeback. +# Each carries the refund as its resource. PAYMENT.CAPTURE.REVERSED (a dispute or chargeback) is left +# out: PayPal can reinstate the money later, sends no webhook for that, and a settled refund never changes. PAYPAL_REFUND_EVENTS = ( "PAYMENT.CAPTURE.REFUNDED", - "PAYMENT.CAPTURE.REVERSED", "PAYMENT.REFUND.PENDING", "PAYMENT.REFUND.FAILED", ) @@ -211,21 +211,27 @@ def handle_webhook(self, payload: bytes, headers: dict) -> WebhookEvent | None: event_type = (event.get("event_type") or "").strip().upper() resource = event.get("resource") or {} - if event_type == PAYPAL_ORDER_APPROVED_EVENT: - if not resource.get("id"): - frappe.throw(_("PayPal sent a {0} event with no order id").format(event_type)) - enqueue_capture(resource["id"]) - return None if event_type in PAYPAL_REFUND_EVENTS: return read_refund_event(event) - if event_type != PAYPAL_CAPTURE_COMPLETED_EVENT: + if event_type == PAYPAL_ORDER_APPROVED_EVENT: + session_id = resource.get("id") + elif event_type == PAYPAL_CAPTURE_COMPLETED_EVENT: + # `resource.id` is the capture; the order id is only in supplementary data. + related_ids = (resource.get("supplementary_data") or {}).get("related_ids") or {} + session_id = related_ids.get("order_id") + else: return None - # `resource.id` is the capture; the order id is only in supplementary data. - related_ids = (resource.get("supplementary_data") or {}).get("related_ids") or {} - if not related_ids.get("order_id"): + if not session_id: frappe.throw(_("PayPal sent a {0} event with no order id").format(event_type)) - return WebhookEvent(session_id=related_ids["order_id"], status="Paid", event_id=event.get("id")) + # An approved order is not paid until captured, and the capture belongs to the status sync. + approved = event_type == PAYPAL_ORDER_APPROVED_EVENT + return WebhookEvent( + session_id=session_id, + status="Pending" if approved else "Paid", + sync_status=approved, + event_id=event.get("id"), + ) def verify_webhook(self, payload: bytes, headers: dict) -> dict: """PayPal signs with a certificate, so we ask PayPal to verify the delivery with our webhook id. @@ -293,25 +299,6 @@ def get_transaction_ref(self, session_id: str) -> str | None: return find_capture_id(self.get_client().get_order(session_id), PAYPAL_SETTLED_CAPTURE_STATUSES) -def enqueue_capture(session_id: str): - """Capture moves money, so a guest webhook never does it. Our own request's status sync does.""" - if payment_request := frappe.db.get_value( - "Gateway Payment Request", {"order_ref": session_id, "status": "Pending"}, "name" - ): - frappe.enqueue( - "bwh_payments.bwh_payments.doctype.paypal_gateway_settings.paypal_gateway_settings.sync_payment_request", - queue="short", - payment_request=payment_request, - now=frappe.in_test, - ) - - -def sync_payment_request(payment_request: str): - """As Administrator, like the return page: a paid request settles its referenced document.""" - with run_as_administrator(): - frappe.get_doc("Gateway Payment Request", payment_request).sync_status() - - def read_refund_event(event: dict) -> WebhookEvent: refund = PayPalRefund.model_validate(event.get("resource") or {}) if not refund.id: @@ -387,4 +374,5 @@ def get_refunded_capture_id(refund: PayPalRefund) -> str | None: def read_refund_status(refund: PayPalRefund) -> str | None: # Gateway Refund reads pending, failed and cancelled as they are; only COMPLETED needs a word. - return "processed" if read_status(refund) == "COMPLETED" else refund.status + # No status is not a refund that went through, which is how Gateway Refund reads a blank. + return "processed" if read_status(refund) == "COMPLETED" else refund.status or "pending" diff --git a/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/test_paypal_gateway_settings.py b/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/test_paypal_gateway_settings.py index d1bb2e4..43774cd 100644 --- a/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/test_paypal_gateway_settings.py +++ b/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/test_paypal_gateway_settings.py @@ -5,7 +5,9 @@ from frappe.tests import IntegrationTestCase from bwh_payments.api import get_gateways +from bwh_payments.bwh_payments.doctype.gateway_refund.gateway_refund import normalise_refund_status from bwh_payments.bwh_payments.doctype.paypal_gateway_settings import paypal_gateway_settings +from bwh_payments.services.paypal.base import PayPalRefund from bwh_payments.services.paypal.stub import StubPayPalClient PAYPAL_GATEWAY = "PayPal" @@ -301,6 +303,11 @@ def test_a_pending_refund_settles_when_paypal_completes_it(self): StubPayPalClient.set_refund_status(result.refund_id, "COMPLETED") self.assertEqual(settings.get_refund_status(order_id, result.refund_id).status, "processed") + def test_a_refund_with_no_status_stays_pending(self): + status = paypal_gateway_settings.read_refund_status(PayPalRefund(id="REFUNDNOSTATUS")) + + self.assertEqual(normalise_refund_status(status), "Pending") + def test_a_refund_through_the_request_is_recorded_as_processed(self): payment_request = make_paypal_payment_request(20, "USD") StubPayPalClient.pay(payment_request.order_ref) diff --git a/bwh_payments/bwh_payments/webhook.py b/bwh_payments/bwh_payments/webhook.py index 78dda6c..c2656ed 100644 --- a/bwh_payments/bwh_payments/webhook.py +++ b/bwh_payments/bwh_payments/webhook.py @@ -135,6 +135,10 @@ def apply_event_as_administrator(payment_request, event: WebhookEvent): with run_as_administrator(): if event.refund_id: apply_refund_event(payment_request, event) + elif event.sync_status: + # Checked first: the sync asks the gateway, and that may capture. + if payment_request.status == "Pending": + payment_request.sync_status() else: payment_request.apply_webhook_status(event.status, event.event_id) diff --git a/bwh_payments/tests/test_paypal_webhook.py b/bwh_payments/tests/test_paypal_webhook.py index d3c1321..7a09fa1 100644 --- a/bwh_payments/tests/test_paypal_webhook.py +++ b/bwh_payments/tests/test_paypal_webhook.py @@ -81,15 +81,37 @@ def test_an_approved_order_that_is_not_ours_is_never_captured(self): self.assertEqual(response["status"], "ok") self.assertEqual(StubPayPalClient.captured_orders, []) - def test_an_approved_event_for_a_paid_request_captures_nothing(self): + def test_an_approved_order_is_not_captured_once_its_request_is_closed(self): payment_request = make_paypal_payment_request(100, "USD") - StubPayPalClient.pay(payment_request.order_ref) - payment_request.sync_status() + payment_request.db_set("status", "Cancelled") + StubPayPalClient.approve(payment_request.order_ref) - self.post_webhook(build_order_approved_event(payment_request.order_ref, event_id="WH-LATE-0001")) + self.post_webhook(build_order_approved_event(payment_request.order_ref, event_id="WH-CLOSED-0001")) self.assertEqual(StubPayPalClient.captured_orders, []) + def test_a_declined_capture_is_kept_for_a_retry_and_paypal_gets_a_200(self): + payment_request = make_paypal_payment_request(100, "USD") + StubPayPalClient.approve(payment_request.order_ref) + StubPayPalClient.next_capture_issue = "INSTRUMENT_DECLINED" + + response = self.post_webhook( + build_order_approved_event(payment_request.order_ref, event_id="WH-DECLINED-0001") + ) + + self.assertEqual(response["status"], "ok") + self.assertIsNone(self.get_status_code()) + log = frappe.get_doc("Integration Request", {"request_id": "WH-DECLINED-0001"}) + self.assertEqual(log.status, "Failed") + payment_request.reload() + self.assertEqual(payment_request.status, "Pending") + + StubPayPalClient.next_capture_issue = None + webhook.retry_webhook(log.name) + + payment_request.reload() + self.assertEqual(payment_request.status, "Paid") + def test_a_delivery_paypal_will_not_verify_is_rejected(self): payment_request = make_paypal_payment_request(100, "USD") payload = build_capture_completed_event(payment_request.order_ref) @@ -231,11 +253,13 @@ def test_a_failed_refund_is_marked_failed(self): self.assertEqual(self.get_refund(refund_id).status, "Failed") - def test_a_reversal_is_recorded_as_a_refund(self): + def test_a_reversal_is_not_recorded_as_a_refund(self): payment_request = self.make_paid_request(20) refund = build_refund(payment_request.gateway_transaction_ref, "COMPLETED", "20.00") - self.post_webhook(build_refund_event(refund, event_type="PAYMENT.CAPTURE.REVERSED")) + response = self.post_webhook(build_refund_event(refund, event_type="PAYMENT.CAPTURE.REVERSED")) + self.assertEqual(response["status"], "ok") + self.assertFalse(frappe.db.exists("Gateway Refund", {"refund_id": refund.id})) payment_request.reload() - self.assertEqual(payment_request.status, "Refunded") + self.assertEqual(payment_request.status, "Paid") From 23feae1732fbe12ceea653633ac3f7d27ce9cae3 Mon Sep 17 00:00:00 2001 From: Isha Dewangan Date: Wed, 14 Oct 2026 15:13:33 +0530 Subject: [PATCH 7/8] fix(paypal): call a request paid only once its capture settles Second review round. - PayPal completes an order once captured, even while the capture waits for an eCheck or review, or was declined. get_payment_status now reads the capture: settled is Paid, DECLINED or FAILED is Not Paid, anything else stays Pending. PAYMENT.CAPTURE.DENIED syncs the request, and PAYMENT.CAPTURE.COMPLETED still marks it paid. - sync_status asks the gateway only while the request is Pending. Asking may capture, so the Sync Status button or another app calling it can no longer take money for a closed request and drop the answer. - services.GatewayUnavailableError marks a 5xx, a 429 or a dropped connection. process_webhook logs it as Failed, commits and raises RetryBackgroundJobError, so Frappe retries a capture PayPal could not answer and only a real decline waits for retry_webhook. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 33 ++++++++------- .../gateway_payment_request.py | 5 ++- .../paypal_gateway_settings.py | 28 +++++++++---- .../test_paypal_gateway_settings.py | 34 +++++++++++++++- bwh_payments/bwh_payments/webhook.py | 11 +++-- bwh_payments/services/__init__.py | 5 +++ bwh_payments/services/paypal/live.py | 18 +++++++-- bwh_payments/services/paypal/stub.py | 13 +++++- bwh_payments/tests/test_paypal_webhook.py | 40 +++++++++++++++++++ 9 files changed, 155 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2fcfe65..8c4bf6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,10 +127,13 @@ An insert that is already settled is an import and fires neither. `sync_status` on a Pending request instead); a failure is rolled back to a savepoint, logged and marked Failed for `retry_webhook` (System Manager). Deadlocks and lock timeouts are raised as `RetryBackgroundJobError`, because Frappe's job runner does not retry the - `QueryDeadlockError` / `QueryTimeoutError` it wraps them in. + `QueryDeadlockError` / `QueryTimeoutError` it wraps them in. A live client raises + `services.GatewayUnavailableError` for a 5xx, a 429 or a dropped connection; the job marks the log + Failed, commits, and raises `RetryBackgroundJobError` so Frappe retries it. - **Return**: `checkout.return_from_gateway` (guest GET, rate-limited) runs `sync_status` and redirects to `redirect_to`. Never trust the browser redirect itself. - - **Pull**: whitelisted `sync_status` asks the gateway. + - **Pull**: whitelisted `sync_status` asks the gateway, and only while the request is Pending, because + asking may capture. 4. `create_refund(amount=None, reference=None, payment_entry=None)` checks the amount under the row lock, inserts a **Gateway Refund** as `Pending` and commits it before calling the gateway, so the reservation holds once the lock is gone (Pending refunds count against what is left). The gateway's answer is @@ -157,22 +160,22 @@ An insert that is already settled is an import and fires neither. `get_payment_status`. - **Tabby**: unsigned webhooks, checked with a static header token plus a source-IP allowlist. `get_payment_status` also *captures* an authorised payment; that capture is where money is taken. -- **PayPal**: raw HTTP (Orders v2) with a client-credentials token, cached per mode and client id. - Only `get_payment_status` captures an approved order. The `CHECKOUT.ORDER.APPROVED` webhook returns a - `sync_status` event, so the capture runs in `process_webhook` for our own Pending request: a guest - request never moves money, an order that is not ours is never captured, and a failed capture is a Failed - Integration Request for `retry_webhook`. When the return page and that job race, the loser gets - `ORDER_ALREADY_CAPTURED` +- **PayPal**: raw HTTP (Orders v2) with a client-credentials token, cached per mode and client id. Only + `get_payment_status` captures an approved order, and it reads Paid from the capture, not the order: PayPal + completes an order whose capture is still `PENDING` (eCheck, review) or `DECLINED`. The + `CHECKOUT.ORDER.APPROVED` webhook returns a `sync_status` event (so does `PAYMENT.CAPTURE.DENIED`), so the + capture runs in `process_webhook` for our own Pending request: a guest request never moves money, an order + that is not ours is never captured, and a failed capture is a Failed Integration Request for + `retry_webhook`. When the return page and that job race, the loser gets `ORDER_ALREADY_CAPTURED` (`PayPalOrderAlreadyCapturedError`), which counts as paid. Webhooks are signed with a certificate, so verification is a call back to PayPal (`verify_webhook_signature`). It is the one verifier that goes through the client, by decision: checking PayPal's certificate chain locally is easy to get wrong. The - stub's `next_verification_status` stands in for a forgery. `cancel_session` stays `False`: PayPal - cannot cancel an unapproved order. Refunds report `processed` for PayPal's `COMPLETED` and PayPal's own - status otherwise. Refund events: `PAYMENT.CAPTURE.REFUNDED`, `PAYMENT.REFUND.PENDING`, - `PAYMENT.REFUND.FAILED`; the refund names its capture only in its `up` link. `PAYMENT.CAPTURE.REVERSED` - (dispute or chargeback) is ignored on purpose: PayPal can reinstate the money with no webhook, and a - settled Gateway Refund never changes, so the merchant records a reversal once it stands. - `get_transaction_ref` is the capture's id. + stub's `next_verification_status` stands in for a forgery. `cancel_session` stays `False`: PayPal cannot + cancel an unapproved order. Refunds report `processed` for PayPal's `COMPLETED` and PayPal's own status + otherwise. Refund events: `PAYMENT.CAPTURE.REFUNDED`, `PAYMENT.REFUND.PENDING`, `PAYMENT.REFUND.FAILED`; + the refund names its capture only in its `up` link. `PAYMENT.CAPTURE.REVERSED` (dispute or chargeback) is + ignored on purpose: PayPal can reinstate the money with no webhook, and a settled Gateway Refund never + changes, so the merchant records a reversal once it stands. `get_transaction_ref` is the capture's id. ### Money and currency rules diff --git a/bwh_payments/bwh_payments/doctype/gateway_payment_request/gateway_payment_request.py b/bwh_payments/bwh_payments/doctype/gateway_payment_request/gateway_payment_request.py index 7913fa0..470be9a 100644 --- a/bwh_payments/bwh_payments/doctype/gateway_payment_request/gateway_payment_request.py +++ b/bwh_payments/bwh_payments/doctype/gateway_payment_request/gateway_payment_request.py @@ -153,7 +153,10 @@ def get_customer_details(self) -> dict: @frappe.whitelist() def sync_status(self): - status = self.get_gateway_settings().get_payment_status(self.order_ref) + # Asking the gateway may capture, so a request that is no longer Pending never asks. + status = None + if self.status == "Pending": + status = self.get_gateway_settings().get_payment_status(self.order_ref) # Locked after the slow gateway call, so a webhook committed meanwhile cannot cause a # TimestampMismatchError. diff --git a/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/paypal_gateway_settings.py b/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/paypal_gateway_settings.py index bb96834..92f6bfc 100644 --- a/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/paypal_gateway_settings.py +++ b/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/paypal_gateway_settings.py @@ -64,10 +64,13 @@ PAYPAL_REFUNDABLE_CAPTURE_STATUSES = ("COMPLETED", "PARTIALLY_REFUNDED") # A fully refunded capture is still the payment that settled. PAYPAL_SETTLED_CAPTURE_STATUSES = (*PAYPAL_REFUNDABLE_CAPTURE_STATUSES, "REFUNDED") +PAYPAL_REFUSED_CAPTURE_STATUSES = ("DECLINED", "FAILED") PAYPAL_VERIFICATION_SUCCESS = "SUCCESS" PAYPAL_ORDER_APPROVED_EVENT = "CHECKOUT.ORDER.APPROVED" PAYPAL_CAPTURE_COMPLETED_EVENT = "PAYMENT.CAPTURE.COMPLETED" +# A pending capture (eCheck, PayPal review) that PayPal later refused. +PAYPAL_CAPTURE_DENIED_EVENT = "PAYMENT.CAPTURE.DENIED" # Each carries the refund as its resource. PAYMENT.CAPTURE.REVERSED (a dispute or chargeback) is left # out: PayPal can reinstate the money later, sends no webhook for that, and a settled refund never changes. PAYPAL_REFUND_EVENTS = ( @@ -193,7 +196,8 @@ def get_payment_status(self, session_id: str) -> str: if read_status(order) == PAYPAL_APPROVED_STATUS: order = self.capture_order(session_id) - return PAYPAL_STATUS_MAP.get(read_status(order), "Pending") + status = PAYPAL_STATUS_MAP.get(read_status(order), "Pending") + return read_capture_status(order) if status == "Paid" else status def capture_order(self, session_id: str) -> PayPalOrder: """Approval is not payment: without a capture the order sits approved and no money arrives.""" @@ -215,7 +219,7 @@ def handle_webhook(self, payload: bytes, headers: dict) -> WebhookEvent | None: return read_refund_event(event) if event_type == PAYPAL_ORDER_APPROVED_EVENT: session_id = resource.get("id") - elif event_type == PAYPAL_CAPTURE_COMPLETED_EVENT: + elif event_type in (PAYPAL_CAPTURE_COMPLETED_EVENT, PAYPAL_CAPTURE_DENIED_EVENT): # `resource.id` is the capture; the order id is only in supplementary data. related_ids = (resource.get("supplementary_data") or {}).get("related_ids") or {} session_id = related_ids.get("order_id") @@ -224,13 +228,12 @@ def handle_webhook(self, payload: bytes, headers: dict) -> WebhookEvent | None: if not session_id: frappe.throw(_("PayPal sent a {0} event with no order id").format(event_type)) - # An approved order is not paid until captured, and the capture belongs to the status sync. - approved = event_type == PAYPAL_ORDER_APPROVED_EVENT + if event_type == PAYPAL_CAPTURE_COMPLETED_EVENT: + return WebhookEvent(session_id=session_id, status="Paid", event_id=event.get("id")) + # An approved order still needs its capture, and a denied capture is read back from the order: + # both belong to the status sync. return WebhookEvent( - session_id=session_id, - status="Pending" if approved else "Paid", - sync_status=approved, - event_id=event.get("id"), + session_id=session_id, status="Pending", sync_status=True, event_id=event.get("id") ) def verify_webhook(self, payload: bytes, headers: dict) -> dict: @@ -343,6 +346,15 @@ def get_redirect_url(order: PayPalOrder) -> str: frappe.throw(_("PayPal created an order with no approval link")) +def read_capture_status(order: PayPalOrder) -> str: + """PayPal completes an order once captured, even while the capture waits for an eCheck or review.""" + if find_capture_id(order, PAYPAL_SETTLED_CAPTURE_STATUSES): + return "Paid" + if find_capture_id(order, PAYPAL_REFUSED_CAPTURE_STATUSES): + return "Not Paid" + return "Pending" + + def get_completed_capture_id(order: PayPalOrder) -> str: """An order is not a charge, so a refund goes against the capture that collected the money.""" if capture_id := find_capture_id(order, PAYPAL_REFUNDABLE_CAPTURE_STATUSES): diff --git a/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/test_paypal_gateway_settings.py b/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/test_paypal_gateway_settings.py index 43774cd..f10d2a2 100644 --- a/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/test_paypal_gateway_settings.py +++ b/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/test_paypal_gateway_settings.py @@ -179,7 +179,6 @@ def test_missing_success_url_is_refused_rather_than_sent_blank(self): def test_status_map_covers_the_terminal_states(self): settings = self.get_settings() for paypal_status, expected in ( - ("COMPLETED", "Paid"), ("VOIDED", "Cancelled"), ("CREATED", "Pending"), ("PAYER_ACTION_REQUIRED", "Pending"), @@ -188,6 +187,39 @@ def test_status_map_covers_the_terminal_states(self): order_id = StubPayPalClient.add_order(status=paypal_status) self.assertEqual(settings.get_payment_status(order_id), expected) + def test_a_completed_order_is_paid_only_once_its_capture_settles(self): + settings = self.get_settings() + for capture_status, expected in ( + ("COMPLETED", "Paid"), + ("PENDING", "Pending"), + ("DECLINED", "Not Paid"), + ("FAILED", "Not Paid"), + ): + with self.subTest(capture_status=capture_status): + order_id = StubPayPalClient.add_order(status="COMPLETED") + StubPayPalClient.add_capture(order_id, status=capture_status) + self.assertEqual(settings.get_payment_status(order_id), expected) + + def test_a_capture_held_for_review_leaves_the_request_pending(self): + payment_request = make_paypal_payment_request(20, "USD") + StubPayPalClient.approve(payment_request.order_ref) + StubPayPalClient.next_capture_status = "PENDING" + + payment_request.sync_status() + + self.assertEqual(StubPayPalClient.captured_orders, [payment_request.order_ref]) + payment_request.reload() + self.assertEqual(payment_request.status, "Pending") + + def test_a_closed_request_never_asks_paypal_and_so_never_captures(self): + payment_request = make_paypal_payment_request(20, "USD") + payment_request.db_set("status", "Cancelled") + StubPayPalClient.approve(payment_request.order_ref) + + payment_request.sync_status() + + self.assertEqual(StubPayPalClient.captured_orders, []) + def test_an_unmapped_status_stays_pending(self): settings = self.get_settings() order_id = StubPayPalClient.add_order(status="SOMETHING_NEW") diff --git a/bwh_payments/bwh_payments/webhook.py b/bwh_payments/bwh_payments/webhook.py index c2656ed..f642b55 100644 --- a/bwh_payments/bwh_payments/webhook.py +++ b/bwh_payments/bwh_payments/webhook.py @@ -5,6 +5,7 @@ from bwh_payments.base_class import WebhookEvent from bwh_payments.bwh_payments.utils import run_as_administrator +from bwh_payments.services import GatewayUnavailableError WEBHOOK_ACCEPTED = {"status": "ok"} WEBHOOK_SAVEPOINT = "bwh_payments_webhook" @@ -110,6 +111,12 @@ def process_webhook(integration_request: str): frappe.db.rollback(save_point=WEBHOOK_SAVEPOINT) # Frappe's job runner retries only RetryBackgroundJobError, not the DB errors it wraps. raise frappe.RetryBackgroundJobError from error + except GatewayUnavailableError as error: + frappe.db.rollback(save_point=WEBHOOK_SAVEPOINT) + # Failed and committed first, so it stays retryable by hand once Frappe's own retries run out. + log.handle_failure({"error": repr(error)}) + frappe.db.commit() # nosemgrep + raise frappe.RetryBackgroundJobError from error except Exception as error: frappe.db.rollback(save_point=WEBHOOK_SAVEPOINT) frappe.log_error(title=f"{log.integration_request_service} could not be applied") @@ -136,9 +143,7 @@ def apply_event_as_administrator(payment_request, event: WebhookEvent): if event.refund_id: apply_refund_event(payment_request, event) elif event.sync_status: - # Checked first: the sync asks the gateway, and that may capture. - if payment_request.status == "Pending": - payment_request.sync_status() + payment_request.sync_status() else: payment_request.apply_webhook_status(event.status, event.event_id) diff --git a/bwh_payments/services/__init__.py b/bwh_payments/services/__init__.py index e39dfc6..423a398 100644 --- a/bwh_payments/services/__init__.py +++ b/bwh_payments/services/__init__.py @@ -1,6 +1,11 @@ +import frappe from pydantic import BaseModel, ConfigDict +class GatewayUnavailableError(frappe.ValidationError): + """The gateway could not answer (a 5xx, a rate limit or a dropped connection), so a retry may work.""" + + class GatewayModel(BaseModel): """Gateways sometimes send ids or amounts as bare numbers; read them as strings.""" diff --git a/bwh_payments/services/paypal/live.py b/bwh_payments/services/paypal/live.py index 8d2a078..6e9d4c5 100644 --- a/bwh_payments/services/paypal/live.py +++ b/bwh_payments/services/paypal/live.py @@ -5,7 +5,9 @@ from frappe import _ from frappe.integrations.utils import create_request_log, make_get_request, make_post_request from frappe.utils.data import cint +from requests import RequestException +from bwh_payments.services import GatewayUnavailableError from bwh_payments.services.paypal.base import ( BasePayPalClient, PayPalOrder, @@ -113,9 +115,12 @@ def throw_request_error(url: str, exception: Exception) -> NoReturn: description = detail.get("description") or body.get("message") or body.get("name") log_request(url, error=description or exception) issue = (detail.get("issue") or "").strip().upper() - exc = ( - PayPalOrderAlreadyCapturedError if issue == PAYPAL_ALREADY_CAPTURED_ISSUE else frappe.ValidationError - ) + if issue == PAYPAL_ALREADY_CAPTURED_ISSUE: + exc = PayPalOrderAlreadyCapturedError + elif is_transient_error(exception): + exc = GatewayUnavailableError + else: + exc = frappe.ValidationError frappe.throw(_("PayPal rejected the request: {0}").format(description or _("unknown error")), exc=exc) @@ -142,6 +147,13 @@ def log_request(url: str, output=None, error=None): ) +def is_transient_error(exception: Exception) -> bool: + response = getattr(exception, "response", None) + if response is None: + return isinstance(exception, RequestException) + return response.status_code >= 500 or response.status_code == 429 + + def read_paypal_error_body(exception: Exception) -> dict | None: response = getattr(exception, "response", None) if response is None: diff --git a/bwh_payments/services/paypal/stub.py b/bwh_payments/services/paypal/stub.py index ae6b64b..791ea8e 100644 --- a/bwh_payments/services/paypal/stub.py +++ b/bwh_payments/services/paypal/stub.py @@ -4,6 +4,7 @@ import frappe from frappe import _ +from bwh_payments.services import GatewayUnavailableError from bwh_payments.services.paypal.base import ( BasePayPalClient, PayPalAmount, @@ -40,6 +41,10 @@ class StubPayPalClient(BasePayPalClient): next_verification_status: ClassVar[str] = "SUCCESS" # An issue code such as "INSTRUMENT_DECLINED" makes the next capture fail with it. next_capture_issue: ClassVar[str | None] = None + # PENDING stands in for an eCheck or a payment PayPal holds for review. + next_capture_status: ClassVar[str] = "COMPLETED" + # The next capture gets a 5xx or a dropped connection. + capture_unavailable: ClassVar[bool] = False # Another call (the return page or the webhook) captures between our read and our capture. lose_next_capture_race: ClassVar[bool] = False @@ -54,6 +59,8 @@ def reset(cls): cls.next_refund_status = "COMPLETED" cls.next_verification_status = "SUCCESS" cls.next_capture_issue = None + cls.next_capture_status = "COMPLETED" + cls.capture_unavailable = False cls.lose_next_capture_race = False @classmethod @@ -121,11 +128,15 @@ def capture_order(self, order_id: str) -> PayPalOrder: _("PayPal rejected the request: {0}").format("Order already captured."), exc=PayPalOrderAlreadyCapturedError, ) + if self.capture_unavailable: + StubPayPalClient.capture_unavailable = False + frappe.throw(_("PayPal rejected the request: {0}").format("503"), exc=GatewayUnavailableError) if issue := self.next_capture_issue: frappe.throw(_("PayPal rejected the request: {0}").format(issue)) self.captured_orders.append(order_id) - self.pay(order_id) + self.set_status(order_id, "COMPLETED") + self.add_capture(order_id, self.next_capture_status) return self.get_order(order_id) def refund_capture(self, capture_id: str, payload: dict) -> PayPalRefund: diff --git a/bwh_payments/tests/test_paypal_webhook.py b/bwh_payments/tests/test_paypal_webhook.py index 7a09fa1..2552fa7 100644 --- a/bwh_payments/tests/test_paypal_webhook.py +++ b/bwh_payments/tests/test_paypal_webhook.py @@ -112,6 +112,46 @@ def test_a_declined_capture_is_kept_for_a_retry_and_paypal_gets_a_200(self): payment_request.reload() self.assertEqual(payment_request.status, "Paid") + def test_a_capture_paypal_could_not_answer_is_retried(self): + payment_request = make_paypal_payment_request(100, "USD") + StubPayPalClient.approve(payment_request.order_ref) + StubPayPalClient.capture_unavailable = True + + # Raised for Frappe's job runner, which retries it; in tests the job runs inline. + with self.assertRaises(frappe.RetryBackgroundJobError): + self.post_webhook(build_order_approved_event(payment_request.order_ref, event_id="WH-503-0001")) + + log = frappe.get_doc("Integration Request", {"request_id": "WH-503-0001"}) + self.assertEqual(log.status, "Failed") + webhook.process_webhook(log.name) + + payment_request.reload() + self.assertEqual(payment_request.status, "Paid") + + def test_a_pending_capture_is_paid_when_paypal_completes_it(self): + payment_request = make_paypal_payment_request(100, "USD") + StubPayPalClient.approve(payment_request.order_ref) + StubPayPalClient.next_capture_status = "PENDING" + payment_request.sync_status() + + self.post_webhook(build_capture_completed_event(payment_request.order_ref, event_id="WH-HELD-0001")) + + payment_request.reload() + self.assertEqual(payment_request.status, "Paid") + + def test_a_denied_capture_marks_the_request_not_paid(self): + payment_request = make_paypal_payment_request(100, "USD") + StubPayPalClient.set_status(payment_request.order_ref, "COMPLETED") + StubPayPalClient.add_capture(payment_request.order_ref, status="DECLINED") + payload = build_capture_completed_event(payment_request.order_ref, event_id="WH-DENIED-0001").replace( + b"PAYMENT.CAPTURE.COMPLETED", b"PAYMENT.CAPTURE.DENIED" + ) + + self.post_webhook(payload) + + payment_request.reload() + self.assertEqual(payment_request.status, "Not Paid") + def test_a_delivery_paypal_will_not_verify_is_rejected(self): payment_request = make_paypal_payment_request(100, "USD") payload = build_capture_completed_event(payment_request.order_ref) From 09c15b23a458227561bcea98c682d13de48fdd39 Mon Sep 17 00:00:00 2001 From: Isha Dewangan Date: Wed, 14 Oct 2026 16:20:09 +0530 Subject: [PATCH 8/8] feat(api): describe PayU for settings screens PayU arrived from v1-stable without the description and docs_url that get_gateways() shows for every other gateway. Co-Authored-By: Claude Opus 5.5 --- .../doctype/payu_gateway_settings/payu_gateway_settings.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bwh_payments/bwh_payments/doctype/payu_gateway_settings/payu_gateway_settings.py b/bwh_payments/bwh_payments/doctype/payu_gateway_settings/payu_gateway_settings.py index 5d48d78..d1f1942 100644 --- a/bwh_payments/bwh_payments/doctype/payu_gateway_settings/payu_gateway_settings.py +++ b/bwh_payments/bwh_payments/doctype/payu_gateway_settings/payu_gateway_settings.py @@ -46,6 +46,9 @@ class PayUGatewaySettings(Document, PaymentGatewayBase): + description = "Cards, UPI, netbanking and wallets. India, INR only." + docs_url = "https://docs.payu.in/" + # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block.