Skip to content

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:

  1. Idempotency: every payment carries an idempotency_key; replays return the original result.
  2. PCI-safe logging: card data, full tokens, and CVV are redacted from all logs at the framework level.
  3. Audit trail: every Payment Transaction logs request, redacted response, latency, and outcome to BZPaymentTransaction.
  4. 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 ManualGateway or 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.md Principles VI, VII
  • glossary/domain-terms.md § Payments