0029 — Payment gateway adapter architecture (payment_gateways + razorpay)
- Status: accepted
- Date: 2026-09-03
- Deciders: martinezsalmeron
- Author: tresundios
- Tags: modules, payments, india, finance, security
Context
India clinics need to collect patient payments electronically — UPI, QR codes, cards, payment links — through a real gateway (Razorpay first; PhonePe and Stripe are on the roadmap). Two constraints shape the design:
paymentsis the financial ledger (ADR 0010): aPaymentis immutable and means money has actually been received. A gateway collection attempt is asynchronous — started, waiting on the customer, authorized, captured, failed, expired, or cancelled — and none of those intermediate states may leak into the ledger as aPayment.- Provider credentials and vendor wire logic must never live in core. This is the same shape problem ADR 0016 solved for notification channels (email vs. WhatsApp vs. future SMS): one contract, a registry, and a provider module per vendor.
Decision
Two modules, mirroring the notifications / whatsapp_kapso split:
payment_gatewaysowns the provider-neutral contract (adapters/base.py:GatewayAdapterProtocol + typed request/result dataclasses), the registry (adapters/registry.py,gateway_registry), and thePaymentRequest/GatewayRefundRequestlifecycle (service.py). It depends onpatients,budget,paymentsand is the only module allowed to callpayments.workflow.record_payment/refund_paymentfrom a gateway confirmation.razorpayis a provider module. It depends only onpayment_gateways, implementsGatewayAdapterasRazorpayAdapter, and registers it intogateway_registryfromon_activate()— never at import time (ADR 0020: an uninstalled provider is never registered). All Razorpay SDK/API calls, webhook signature verification, credentials (Fernet-encrypted, mirroringwhatsapp_kapso), and provider-specific ids live here exclusively. A futurephonepeorstripemodule implements the same contract and never touchespayment_gatewaysorpayments.
Module boundary — what never crosses it
payment_gatewaysmust not depend onbilling.- Neither module adds a provider name, credentials, provider transaction id, or a status column to core
payments.Payment/payments.Refund. The only place that knows a givenPaymentwas gateway-collected at all ispayment_gateways.PaymentRequest(payment_idFK, set once, at confirmation) —paymentsitself stays provider-agnostic, exactly as ADR 0010 requires of the ledger. - Invoice allocation is never gateway logic. A
PaymentRequestcarries the sameallocation_input(budget/on_account) andcontext(e.g.{"prefer_invoice_id": ...}) thatpayments.workflow.record_paymentalready accepts from any other caller (e.g. billing's own invoice-payment endpoint) — the existing billing event bridge (payment.allocated→payment_bridge) does the invoice-side work unchanged.
The PaymentRequest state machine
payment_gateways.constants.PaymentRequestState:
pending -> awaiting_customer_action -> authorised_awaiting_capture -> succeeded
\-> failed / expired / cancelled (from pending or awaiting_customer_action)succeeded / failed / expired / cancelled are terminal. validate_payment_request_transition rejects any move not in the table — including re-entering a terminal state — so an adapter bug can never resurrect a succeeded request or silently overwrite a failed one. Confirming an already-terminal request (a late webhook racing a timeout sweep) is a documented no-op, not an error: it is recorded on the row (raw_status_snapshot) for manual reconciliation, but never creates a Payment — a stale confirmation must not silently resurrect an attempt the clinic has already moved on from.
GatewayRefundRequest mirrors this with its own small machine (requested -> processing -> completed | failed), gating the creation of a core payments.Refund behind completed.
Confirmation is atomic and idempotent — never from a browser redirect
PaymentRequestService.confirm() is the single code path that creates a Payment from a gateway confirmation. It:
- No-ops if the request is already
succeeded(or any other terminal state) — the primary defense against duplicate webhook delivery. - Rejects a confirmation whose amount disagrees with
requested_amount— protects the allocation-sum invariantpayments.workflow.record_paymentitself enforces. - Calls
record_payment(...)and setspayment_id/state=succeededin the same DB transaction — both writes commit together or not at all (FastAPI'sget_db()commits once at the end of the request; nothing in this path commits early except the deliberate failure-persistence case below). - Is only ever called after the caller (a provider's webhook handler) has independently verified the provider's signature and holds a row lock on the
PaymentRequest(get_locked_by_provider_reference,SELECT ... FOR UPDATE) — so two concurrent deliveries of the same event serialize instead of racing on the idempotency check.
A hard, unique DB index on (provider_key, provider_payment_reference) is the backstop: even a bug that bypassed the row lock and the in-service check cannot produce two PaymentRequest rows claiming the same provider payment.
A browser-side "payment succeeded" callback (Razorpay Checkout.js's handler) is never treated as authoritative — the collection UI only reacts to it by polling harder; the only thing that ever flips a request to succeeded is a verified webhook (or, for a manual refresh, an equivalent server-side status pull through the same confirm() path).
Why a failed attempt still gets committed before the error is raised
create_and_initiate() / request_refund() mark the row failed and call db.commit() before raising GatewayError back to the router. This looks unusual, but it is deliberate: FastAPI's get_db() dependency rolls back the whole request session on any exception — without the explicit early commit, a provider-side rejection (bad credentials, network outage) would leave no PaymentRequest row at all, and the collection UI would have nothing to show a failed state for. The explicit commit trades a small amount of transactional purity for an honest audit trail on the one path where "nothing happened" is not actually true — a provider order/refund attempt was made.
Why Refund is created only from complete(), never inline
Some providers (Razorpay included) resolve a refund synchronously in the initiate API response. Even then, GatewayRefundService.request_refund routes the synchronous case through the same complete() method the asynchronous webhook path uses, rather than constructing a payments.Refund inline — there is exactly one code path that ever writes that row, so its idempotency and cap-enforcement logic cannot drift between the two cases.
Consequences
Good
- The provider boundary is enforced mechanically:
test_module_isolation.pyfails the build the momentrazorpayimports anything outsidepayment_gateways, orpayment_gatewaysimports anything outsidepatients/budget/payments. - Adding PhonePe or Stripe is a new module + one
register()call — zero changes topayment_gatewaysorpayments. payments.Payment/Refundstay exactly as provider-agnostic as ADR 0010 already required them to be; nothing about this feature widens that ledger's surface area beyond two new values inPAYMENT_METHODS(upi,netbanking).- Round-trip uninstall is clean:
razorpayuninstall unregisters its adapter;payment_gatewaysuninstall is blocked while any request or refund is still in flight (non-terminal state), so removing the module can never orphan a checkout or refund a clinic is actively waiting on.
Bad / accepted trade-offs
- The refundable-amount pre-check (
GatewayRefundService._refundable_amount) only counts completed refunds — two refund requests issued in fast concurrent succession could both pass the pre-check before either completes.payments.workflow.refund_payment's own row-locked cap check atcomplete()time is the final backstop (the second one fails there, converting to afailedGatewayRefundRequestinstead of an over-refund) — accepted for v1 rather than adding cross-request locking for an edge case admin UIs rarely trigger. - Per-clinic Razorpay webhook URLs carry the
clinic_idin the path (/api/v1/razorpay/webhook/{clinic_id}) rather than resolving tenancy from an account-level header, since a direct (non-Partner) Razorpay integration has no such header. Theclinic_idis only ever used to select which secret to verify against — a wrong or guessed id can never forge events without also knowing that clinic's actual webhook secret.
Alternatives considered
- Event bus for gateway confirmation (mirroring most other cross-module writes). Rejected: the confirmation path needs the same atomicity billing's own direct-call path to
payments.workflowalready relies on (see ADR 0010's "orchestrator" consequence) — a webhook has exactly one authoritative consumer, unlike notifications' genuinely fan-out channel sends. - Store provider/status fields directly on
payments.Payment. Rejected outright by the task's own boundary and by ADR 0010: the core ledger must stay meaning "money received," full stop, and provider-neutral. - Razorpay module calls
payments.workflowdirectly. Rejected — would requirerazorpay.dependsto includepatients/budget/payments, defeating the point of the adapter boundary and making a future PhonePe module duplicate the same wiring instead of only implementingGatewayAdapter.
How to verify the rule still holds
backend/tests/test_module_isolation.py— fails ifrazorpayimports anything outsidepayment_gateways, orpayment_gatewaysimports anything outside its declareddepends.backend/tests/modules/payment_gateways/— registry, state-machine transitions (including illegal/no-op cases), atomic confirm, duplicate-webhook idempotency, refund cap enforcement, permission checks — all against aFakeAdapter, no network.backend/tests/modules/razorpay/— webhook signature verification, payload parsing, settings secret-masking/isolation, and an end-to-end "verified webhook creates exactly onePaymentwithmethod=upi" test.
References
- Issue #263 (PR 1 of #365)
backend/app/modules/payment_gateways/(contract + registry + service)backend/app/modules/razorpay/(adapter + client + webhook)- ADR 0001 (modular plugin architecture)
- ADR 0010 (payments as a primitive module)
- ADR 0016 (channel-adapter architecture — the pattern this mirrors)
- ADR 0019 (transactional event handlers)
- ADR 0020 (install-state gates runtime —
on_activate()registration)