Skip to content
1 change: 1 addition & 0 deletions .github/logos/paypal.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 33 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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>.model_validate`, so a third-party gateway that still returns dicts keeps working. Each gateway is a
**Single DocType** `<Name> Gateway Settings` whose controller inherits `(Document, PaymentGatewayBase)` and
holds the credentials (as `Password` fields, read with `get_password`).
Expand Down Expand Up @@ -74,7 +77,8 @@ Every external service sits behind a client in `bwh_payments/services/<service>/
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.
Expand All @@ -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,
Expand Down Expand Up @@ -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=<profile>`, 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
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,21 @@
<img src=".github/logos/razorpay.svg" alt="Razorpay" height="40" />
<img src=".github/logos/telr.svg" alt="Telr" height="40" />
<img src=".github/logos/tabby.svg" alt="Tabby" height="40" />
<img src=".github/logos/paypal.svg" alt="PayPal" height="40" />
</p>

</div>

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

- **Stripe**: cards and wallets, worldwide
- **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
Expand Down
24 changes: 23 additions & 1 deletion bwh_payments/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,27 @@
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,
)
from bwh_payments.bwh_payments.doctype.gateway_refund.gateway_refund import normalise_refund_status
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",
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 23 additions & 1 deletion bwh_payments/base_class.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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> 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Empty file.
Original file line number Diff line number Diff line change
@@ -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": []
}
Loading
Loading