Every gateway in BWH Payments talks to its provider through a service client. This page explains what goes in a client, what stays in the controller, and why.
The layout
Each service has a folder with three files:
bwh_payments/services/razorpay/
├── base.py # the calls, and models of Razorpay's JSON
├── live.py # HTTP calls, logging, error reading
└── stub.py # in-memory Razorpay for tests
The gateway's settings controller picks one:
def get_client(self) -> BaseRazorpayClient:
return (
StubRazorpayClient()
if frappe.in_test
else LiveRazorpayClient(self.key_id, self.get_password("key_secret"))
)
There is no setting or flag for this. A real site always gets the live client.
What goes where
| Lives in | Owns | Examples |
|---|---|---|
| Controller | Every decision about money and state | Minor-unit conversion, return URLs, status map, capture and refund rules, webhook signature checks |
base.py |
The shape of the conversation | One method per provider call. Models with only the fields the controller reads. |
live.py |
Transport | The HTTP or SDK call, create_request_log with the endpoint and ids, turning a refusal into frappe.ValidationError |
stub.py |
A believable fake provider | State in ClassVars, controls such as pay(), recordings such as created_links |
The rule of thumb: if it can be wrong in a way that costs money, it goes in the controller. The live client isn't tested, so it must have nothing to get wrong except the call itself.
Why signature checks stay in the controller
Webhook verification is local cryptography. It doesn't call the provider, so it doesn't belong to the client. More importantly, tests replace the client with a stub. If the check lived in the client, a test, or a bug, could swap in something that accepts a forged event. In the controller, the real check runs in every test.
Models
Models in base.py extend bwh_payments.services.GatewayModel:
class RazorpayPaymentLink(GatewayModel):
id: str
status: str | None = None
short_url: str | None = None
# Razorpay sends null rather than [] for a link nobody has paid against.
payments: list[RazorpayPayment] | None = None
- Unknown fields are ignored, so a provider adding fields never breaks parsing.
- A bare number is read into a
strfield. Some providers send ids as numbers. - Keep only fields the controller reads, and make a field optional wherever the provider sends
null.
For an SDK that returns objects rather than dicts, parse with model_validate(obj, from_attributes=True), as the Stripe client does.
Stubs
A stub implements the same base class as the live client, in memory:
| Part | Purpose | Razorpay example |
|---|---|---|
| State | What the provider knows | links: ClassVar[dict[str, RazorpayPaymentLink]] |
reset() |
Clears state. Called in every test's setUp. |
StubRazorpayClient.reset() |
| Controls | Move the provider to a state, as a shopper or the provider would | pay(link_id), add_payment(link_id, "authorized"), set_status(link_id, "expired") |
| Recordings | What the controller sent, in the provider's own shape | created_links[-1]["amount"] |
| Test helpers | Build and sign webhook bodies | sign_payload(...), build_payment_link_event(...) |
A stub follows the provider's real state machine for the paths you support, including refusals. It raises for an unknown id the same way the live client does.
The four built-in clients
| Service | Transport | Stub controls |
|---|---|---|
| Stripe | Official SDK, one StripeClient per instance |
pay |
| Razorpay | Payment Links API over HTTP | add_link, add_payment, pay, set_status |
| Telr | order.json and remote.xml over HTTP |
add_order, pay, set_status |
| Tabby | Checkout and Payments API over HTTP | add_payment, authorise, set_status, reject_next_checkout |
The Stripe, Razorpay and Telr stubs also have next_refund_status. Set it to test a refused refund.
Next steps
- Build a payment gateway: write a controller and client from scratch
- Test with stubs: what to cover, and how