BWH Payments (Next)

BWH Payments (Next)

Open in ChatGPT
Ask ChatGPT about this page
Open in Claude
Ask Claude about this page

Service clients

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 settings controller calls a client. On a site the live client calls the gateway. In tests the stub answers from memory.

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 str field. 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

Last updated 2 days ago
Was this helpful?
Thanks!