Skip to content

refactor: move gateways onto base/live/stub service clients - #13

Merged
harshtandiya merged 12 commits into
v1-stablefrom
feat/razorpay-services
Oct 10, 2026
Merged

harshtandiya merged 12 commits into
v1-stablefrom
feat/razorpay-services

Conversation

@harshtandiya

@harshtandiya harshtandiya commented Oct 7, 2026 •

Copy link
Copy Markdown

What this changes

Each gateway now talks to its provider through a service client in bwh_payments/services/<gateway>/:

  • base.py holds an abstract client and pydantic models that mirror the provider's JSON.
  • live.py holds the HTTP or SDK calls, error reading and request logging.
  • stub.py holds an in-memory provider. Tests drive it with controls such as pay() and authorise().

The settings controller selects the stub only when frappe.in_test is set. A real site always uses the live client. The controller keeps every payment decision: currency conversion, status maps, capture and refund rules, and webhook signature checks.

Razorpay, Stripe, Telr and Tabby are migrated. Tests no longer patch transports, and tests/fake_stripe.py and tests/fake_razorpay.py are deleted.

Other changes

  • Gateway results are models: CheckoutSession, RefundResult and WebhookEvent | None. Callers read them with model_validate, so a gateway in another app that returns dicts still works.
  • When a request becomes Paid, on_update calls ref_doc.run_method("on_payment_authorized", "Completed"). This is the frappe/payments convention.
  • bwh_payments/api.py is the import surface for other apps: create_payment, get_payment_gateways, resolve_payment_mode and the minor-unit helpers.
  • Stripe calls use StripeClient.v1. The stripe floor moves to 12.5, the first release with v1.
  • AGENTS.md documents the pattern. Two project skills in .claude/skills/ describe how to migrate a gateway and how to add one.

Behavior changes

  • A verified webhook that does not produce a valid event returns 400. Before, it returned 200 and logged a failure. None of the four gateways can produce such a delivery.
  • A Telr order created without a ref fails at checkout. Before, the request saved an empty order_ref.
  • create_payment accepts only company and customer_* fields, because its insert skips permission checks.

Tests

  • bwh_payments: 104 pass. There were 83 before. New tests cover the callback, create_payment, Telr sessions and refunds, and Tabby, which had no tests (Tabby gateway has no tests #12).
  • commera: 79 unit and 687 integration tests pass, with 17 skipped. This includes a Razorpay checkout-to-refund test that was written and passing before the refactor. Only its driver helpers changed.

Merge order

Merge this before bwhtech/commera#228, because it removes the fakes that commera tests import.

fix/refund-lock-commit also edits gateway_payment_request.py. Whichever merges second needs a rebase.

Gateway results become pydantic models (CheckoutSession, RefundResult,
WebhookEvent). Callers read them through model_validate, so a gateway
that still returns plain dicts keeps working.

Razorpay transport moves to bwh_payments/services/razorpay: base.py
holds the client contract and response models, live.py the HTTP calls,
logging and error reading, stub.py an in-memory Razorpay that tests
drive through classmethod controls. The settings controller keeps every
decision (minor units, status map, capture rules, signature check) and
picks the stub when frappe.in_test.

Tests drop the transport patches and tests/fake_razorpay.py.
A request turning Paid now calls ref_doc.run_method("on_payment_authorized",
"Completed") from on_update, the frappe/payments convention, so apps built
for it settle orders without a doc_events hook.

bwh_payments.api is the supported import surface for other apps:
create_payment, get_payment_gateways, resolve_payment_mode and the
minor-unit helpers.
bwh_payments/services/stripe holds the client contract and models, the
live StripeClient wrapper (one client per instance, never the module
level api_key) and an in-memory stub. Webhook signature verification
stays in the controller: it is local crypto, and a stub must never be
able to accept a forged event.

tests/fake_stripe.py and the StripeClient patches are gone; tests drive
StubStripeClient.pay() instead.
bwh_payments/services/telr holds the order.json and remote.xml transport,
the refund XML and auth-response parsing, and an in-memory stub. The
controller keeps the status map, the authorised-as-paid switch, the
remote-key check and the refund acceptance rule.

The Telr tests drive the stub instead of patching get_order, and now
also cover create_session and the refund path.
…st tests

bwh_payments/services/tabby holds the checkout and payments transport,
error reading and logging, typed models for Tabby's nested checkout
configuration, and an in-memory stub. The controller keeps the currency
allowlist, rejection messages, capture-on-read, refund guard and the
webhook token and IP checks.

Tabby had no tests (#12). The stub makes them cheap: session keying,
currency refusal, rejection wording, capture of the authorised amount,
refund guards and the webhook token/allowlist are now covered.
Describes the gateway contract models, the services/<service>/{base,live,stub}
layout and its rules (stub only under frappe.in_test, decisions stay in the
controller, live clients are untested transport), bwh_payments.api and the
on_payment_authorized callback, and how tests drive stubs instead of
patching transports.
- RefundResult is read after the money has moved, so nothing on it is
  required and a None result no longer strands the refund ledger.
- create_payment only accepts company and customer_* fields: its insert
  skips permissions, so a forwarded status or order_ref must not land.
- Gateway models share GatewayModel, which coerces bare numbers into str
  fields (a numeric Telr ref no longer fails validation) and relies on
  pydantic's default of ignoring unknown fields.
- Stripe calls go through StripeClient.v1; the floor moves to 12.5, the
  first SDK that ships it.
- get_client() is a single expression; webhook.handle reads the event
  directly.
Project skills that capture the base/live/stub procedure: pinning
behaviour before a migration, sorting transport from decisions, stub
design, consumer parity, and the facts to gather before adding a new
provider.
harshtandiya and others added 4 commits October 7, 2026 17:35
get_client now returns the stub under frappe.in_test first and the live client after, in every gateway.
create_payment is annotated as returning GatewayPaymentRequest, which it already did at runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A refund result is read after the money has moved. If it fails to parse, the ledger is never updated and the
same amount stays refundable, so a retry refunds twice.

- StripeRefund and RazorpayRefund no longer require id or amount. Without an echoed amount, the requested
  amount is used.
- CheckoutSession, RefundResult and WebhookEvent share a GatewayResult base that turns numeric ids into
  strings, so a gateway from another app that returns {"refund_id": 12345} still books the refund.
- Telr's live client turns a non-JSON body into frappe.ValidationError, as Razorpay and Tabby already do.
- throw_request_error is annotated NoReturn in the Razorpay and Tabby live clients.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ON guards

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@harshtandiya
harshtandiya changed the base branch from develop to v1-stable October 10, 2026 19:17
@harshtandiya
harshtandiya marked this pull request as ready for review October 10, 2026 21:02
@harshtandiya
harshtandiya merged commit aebcfdd into v1-stable Oct 10, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant