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 @@
+
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 @@
+
-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.