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 de6d96a..dad6f57 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
@@ -43,9 +43,12 @@ PR titles must be conventional commits (`fix: ...`). Label a merged PR `backport
keeps working: `cancel_session` (False), `get_transaction_ref` (the settled payment's id; None),
`get_refund_status` (None = cannot report), `list_refunds` (None = cannot list; `sync_refunds` records
the unknown ones), `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 `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`).
@@ -74,7 +77,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.
@@ -90,6 +94,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, `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,
@@ -118,13 +124,17 @@ 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.
+ `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
@@ -151,6 +161,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, 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.
- **PayU India**: raw HTTP form POSTs to `postservice.php` (`verify_payment`, `cancel_refund_transaction`,
`check_action_status`), each signed with `payu_hash` (sha512) and the salt. Checkout is a browser form
POST, so `create_session` makes no call: `order_url` is our own `start_checkout`, which renders the
diff --git a/README.md b/README.md
index 85523ff..35f664e 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
- **PayU**: cards, UPI, netbanking and wallets in India (INR)
### Features
diff --git a/bwh_payments/api.py b/bwh_payments/api.py
index 19ac15a..ec9dfe1 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,
+ reusable_checkout=settings.reusable_checkout,
+ 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 fb31de7..ecdfcc3 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.
@@ -51,11 +56,28 @@ 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
+ reusable_checkout: bool = False
+ 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
+ # 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/bwh_payments/doctype/gateway_payment_request/gateway_payment_request.py b/bwh_payments/bwh_payments/doctype/gateway_payment_request/gateway_payment_request.py
index 11ce161..a698d1b 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/__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..92f6bfc
--- /dev/null
+++ b/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/paypal_gateway_settings.py
@@ -0,0 +1,390 @@
+# 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 CheckoutSession, PaymentGatewayBase, RefundResult, WebhookEvent
+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,
+ PayPalOrder,
+ PayPalOrderAlreadyCapturedError,
+ PayPalRefund,
+)
+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")
+# 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_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 = (
+ "PAYMENT.CAPTURE.REFUNDED",
+ "PAYMENT.REFUND.PENDING",
+ "PAYMENT.REFUND.FAILED",
+)
+
+# 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.
+ reusable_checkout = True
+
+ # 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)
+ 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)
+ 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)
+
+ 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."""
+ 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 in PAYPAL_REFUND_EVENTS:
+ return read_refund_event(event)
+ if event_type == PAYPAL_ORDER_APPROVED_EVENT:
+ session_id = resource.get("id")
+ 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")
+ else:
+ return None
+
+ if not session_id:
+ frappe.throw(_("PayPal sent a {0} event with no order id").format(event_type))
+ 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", sync_status=True, 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.
+
+ 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"))
+
+ 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 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
+ 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:
+ 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"))
+
+
+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):
+ 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 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.
+ # 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
new file mode 100644
index 0000000..f10d2a2
--- /dev/null
+++ b/bwh_payments/bwh_payments/doctype/paypal_gateway_settings/test_paypal_gateway_settings.py
@@ -0,0 +1,387 @@
+# 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.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"
+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 (
+ ("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_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")
+
+ 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_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)
+ 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.assertTrue(gateway.reusable_checkout)
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.
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 7eb416a..ca2c3c2 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
@@ -43,6 +43,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 dbcd92e..5708280 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
@@ -43,6 +43,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 8b91a60..ba4ab74 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
@@ -29,6 +29,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 a5bab83..1eba6ea 100644
--- a/bwh_payments/bwh_payments/utils.py
+++ b/bwh_payments/bwh_payments/utils.py
@@ -3,8 +3,11 @@
from urllib.parse import parse_qsl, urlencode, 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})?$")
@@ -51,6 +54,25 @@ 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 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:
supported = settings.supported_currencies()
return supported is None or currency.strip().upper() in supported
diff --git a/bwh_payments/bwh_payments/webhook.py b/bwh_payments/bwh_payments/webhook.py
index 78dda6c..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")
@@ -135,6 +142,8 @@ 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:
+ 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/__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..68fd1a6
--- /dev/null
+++ b/bwh_payments/services/paypal/base.py
@@ -0,0 +1,74 @@
+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
+ links: list[PayPalLink] | 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..6e9d4c5
--- /dev/null
+++ b/bwh_payments/services/paypal/live.py
@@ -0,0 +1,165 @@
+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 requests import RequestException
+
+from bwh_payments.services import GatewayUnavailableError
+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 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
+
+ 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 {}
+ 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()
+ 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)
+
+
+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={"id": response.get("id"), "status": response.get("status")})
+ 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 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:
+ 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..791ea8e
--- /dev/null
+++ b/bwh_payments/services/paypal/stub.py
@@ -0,0 +1,215 @@
+import json
+from typing import ClassVar
+
+import frappe
+from frappe import _
+
+from bwh_payments.services import GatewayUnavailableError
+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
+ # 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
+
+ @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.next_capture_status = "COMPLETED"
+ cls.capture_unavailable = False
+ 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 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.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:
+ self.refunds.append({"capture_id": capture_id, **payload})
+ amount = payload.get("amount") or {}
+ 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)
+
+ 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()
+
+
+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_api.py b/bwh_payments/tests/test_api.py
index 23bdd38..4614e65 100644
--- a/bwh_payments/tests/test_api.py
+++ b/bwh_payments/tests/test_api.py
@@ -10,12 +10,15 @@
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 import utils
from bwh_payments.bwh_payments.doctype.razorpay_gateway_settings.test_razorpay_gateway_settings import (
RAZORPAY_GATEWAY,
RazorpayTestCase,
@@ -82,6 +85,46 @@ 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_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):
+ 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.assertFalse(gateway.reusable_checkout)
+
+
class TestRefundsAndImports(RazorpayTestCase):
def import_paid_payment(self, order_ref: str = "pay_legacy_1"):
return record_external_payment(
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..2552fa7
--- /dev/null
+++ b/bwh_payments/tests/test_paypal_webhook.py
@@ -0,0 +1,305 @@
+# 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,
+ build_refund,
+ build_refund_event,
+)
+
+
+class PayPalWebhookTestCase(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")
+
+
+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)
+
+ 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_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_order_is_not_captured_once_its_request_is_closed(self):
+ payment_request = make_paypal_payment_request(100, "USD")
+ 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-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_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)
+ 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)
+
+
+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_not_recorded_as_a_refund(self):
+ payment_request = self.make_paid_request(20)
+ refund = build_refund(payment_request.gateway_transaction_ref, "COMPLETED", "20.00")
+
+ 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, "Paid")