ADR-0003: Gateway-Agnostic Payment Architecture¶
Status: Accepted
Date: 2026-05-03
Deciders: Architecture team, Payments team
Impacted Repos: reservation-api-server, admin-web-app, venuplus-app,
customer-web-app
Context¶
The platform supports multiple payment gateways for legitimate reasons:
- Magensa MPPG — card-not-present, Apple Pay, Google Pay (legacy primary).
- Datacap NETePay — card-present / POS at the front desk (EMV chip, contactless, swipe).
- Stripe Connect — strategic platform direction, multi-region.
- CenterEdge — for FiveStar/Malibu Jack operations using CenterEdge POS.
- Manual — cash, check, comp.
- Square — planned for adjacent products.
Historically, payment logic was scattered across transactionUtils.ts
(Magensa-specific) and various ad-hoc integration points. Adding a new
gateway required edits to every booking code path.
Decision¶
All payment operations route through a gateway-agnostic abstraction:
# bookingzone.services.payment_gateways
class PaymentGatewayBase(ABC):
def process_payment(self, amount, ...) -> PaymentResult: ...
def process_refund(self, txn_id, amount) -> RefundResult: ...
def validate_config(self) -> bool: ...
def get_transaction_status(self, txn_id) -> Status: ...
# registry.py
def get_gateway(name: str, outlet_id: str = None) -> PaymentGatewayBase: ...
def get_gateway_for_outlet(outlet_id: str) -> PaymentGatewayBase: ...
Each concrete gateway (MagensaGateway, DatacapGateway, StripeGateway,
ManualGateway) implements the interface. Booking and refund code
never hardcodes gateway logic; it routes via get_gateway_for_outlet().
Outlet-level configuration (BZOutlet.pay_config) stores per-outlet gateway
selection and credentials.
Mandatory cross-cutting properties for every gateway:
- Idempotency: every payment carries an
idempotency_key; replays return the original result. - PCI-safe logging: card data, full tokens, and CVV are redacted from all logs at the framework level.
- Audit trail: every Payment Transaction logs request, redacted
response, latency, and outcome to
BZPaymentTransaction. - Refund atomicity: booking amounts update after gateway success, never before.
Consequences¶
Positive¶
- Adding a new gateway is a single-file change (new gateway class + registry entry); no booking or refund code is touched.
- Per-outlet gateway selection enables mixed deployments (some outlets Magensa, some Datacap, some both).
- Test doubles are trivial via
ManualGatewayor a mock.
Negative / Trade-offs¶
- The abstraction layer adds indirection compared to direct gateway calls.
- Gateway-specific features (e.g., Datacap's same-day-void) require thoughtful exposure via the base interface or a typed extension hatch.
Risks / Mitigations¶
- Risk: a gateway returns success for a payment that the bank ultimately declines (asynchronous reversal). Mitigation: webhooks
- reconciliation jobs reconcile asynchronous gateway state.
- Risk: PCI scope creep into frontend repos. Mitigation: payment pages use gateway-hosted iframes / tokenized inputs; raw PAN never reaches our servers.
Alternatives Considered¶
- Single global gateway: rejected; operator deployments require multiple processors due to existing merchant relationships.
- Frontend chooses gateway: rejected; security and consistency demand backend-driven gateway selection.
Implementation Notes¶
- Live in
bookingzone/services/payment_gateways/. - Stuck-transaction recovery:
bookingzone.api.payment.get_stuck_transactions resolve_stuck_transaction.
References¶
system-constitution.mdPrinciples VI, VIIglossary/domain-terms.md§ Payments