payment_gateways — overview
Provider-neutral payment gateway contract, registry, and the PaymentRequest/GatewayRefundRequest async lifecycle. Mirrors the channel-adapter architecture (ADR 0016) that notifications uses for email/WhatsApp. Official, installable/removable. Issue #263, PR 1 of #365. Full rationale: ADR 0029.
What it is
adapters/— the public contract (GatewayAdapterProtocol, typed request/result dataclasses:GatewayCheckoutResult,GatewayConfirmation,GatewayWebhookEvent,GatewayRefundInitResult, etc.) and the idempotentgateway_registry. A provider module (razorpay)depends=["payment_gateways"]and callsgateway_registry.register(...)from its ownon_activate().service.py—PaymentRequestService(create/initiate → confirm → succeeded, or fail/expire/cancel) andGatewayRefundService(request → complete/fail). The only code allowed to callpayments.workflow.record_payment/refund_paymentfrom a gateway flow.router.py—POST /requests,GET/POST .../refresh|cancel,GET /payments/{id}/gateway-info,POST /payments/gateway-info/batch(same lookup, batched for a page of the payments list — capped at 100 ids),POST /refunds,GET /refunds/{id},GET /payments/{id}/refunds. Reusespayments.record.*permissions (no permissions of its own).constants.py— thePaymentRequestState/GatewayRefundStateStrEnums and their validated transition tables.
No frontend layer — this module is backend-only infrastructure. UI (collection panel, transaction detail, refunds) is owned entirely by the provider module. The collection rails are chips the provider registers into the core "New payment"/"Cobrar" modal's method row via the payments.create.methods slot; on submit the modal hands its validated form to the provider's own panel (QR / checkout / link, wait, expiry), so the core modal is never forked. The payment-list badge uses the additive payments.list.row.meta slot, since it only needs to render alongside existing content.
Data model
payment_requests— one row per collection attempt:provider_key,requested_amount/currency/requested_method,state,allocation_input/context(JSONB, verbatim inputs torecord_payment),provider_reference/provider_payment_reference(unique per provider once assigned),checkout_payload_snapshot(non-secret — what the frontend was handed),payment_id(FK, set once at confirmation).gateway_refund_requests— one row per refund attempt:payment_id(the corePayment),payment_request_id,provider_payment_reference(denormalized from the request),state,provider_refund_reference,refund_id(FK, set once at completion).
Neither table duplicates anything on payments.Payment/Refund — no provider name, status, or fees live in the core ledger. See docs/adr/0029-payment-gateway-adapter-architecture.md.
Module boundary
manifest.depends = ["patients", "budget", "payments"]. Never add billing. The only cross-module imports are into those three (patient contact lookup, allocation validation, and the direct record_payment/refund_payment calls) — enforced by tests/test_module_isolation.py.