Skip to main content

SERVICE_OVERVIEW — payment-gateway-service

Bundle index: SERVICE_OVERVIEW · DOMAIN_MODEL · APPLICATION_LOGIC · API_CONTRACTS · EVENT_SCHEMAS · DATA_MODEL · SYNC_CONTRACT · AI_INTEGRATION · SECURITY_MODEL · OBSERVABILITY · TESTING_STRATEGY · DEPLOYMENT_TOPOLOGY · FAILURE_MODES · LOCAL_DEV_SETUP · SERVICE_READINESS · SERVICE_RISK_REGISTER · MIGRATION_PLAN

Strategic anchors: 02 Enterprise Architecture · 04 Event-Driven Architecture · 05 API Design · 07 Security & Tenancy · 10 Payments Architecture · ADR-0002 Multi-Tenancy

1. Purpose

payment-gateway-service is the single internal abstraction for all payment movement on Ghasi Melmastoon — the multi-tenant hotel SaaS whose backoffice is an Electron offline-first desktop app and whose cloud is GCP. Behind a stable port (PaymentPort) it hides every vendor SDK and every market-specific rail, so the rest of the platform never imports stripe, @paypal/checkout-server-sdk, or any MFS client. The service exists for four reasons no other service can satisfy:

  1. One vendor boundary, one place. Each adapter (StripeAdapter, PayPalAdapter, HesabPayAdapter, CashAdapter, future AdyenAdapter, RazorpayAdapter, MpesaAdapter, ApplePayAdapter, GooglePayAdapter, StablecoinAdapter) lives behind infrastructure/adapters/<vendor>/. Domain code never sees a vendor type. Onboarding a new method is a PaymentPort impl + a webhook receiver + a reconciliation job — not a release that touches reservation-service or billing-service.
  2. PCI scope minimization. Per ADR-0002, this service uses schema-per-tenant Postgres (tenant_<uuid>_payments) so a vulnerability that defeats RLS in one tenant cannot reach another. Card capture is processor-hosted (Stripe Elements, PayPal hosted fields); we receive only processor tokens. Target compliance tier is PCI-DSS SAQ A.
  3. Cash is a real method. In our target markets — Afghanistan, Tajikistan, Iran — cash-on-arrival is the dominant path, not a footnote (10 §1). The CashAdapter implements the same PaymentPort interface as Stripe; cash transactions get full audit, FX context, refund support, no-show automation, and reconciliation against billing-service's cash-drawer events.
  4. Idempotency is a property, not a hope. Every primitive — authorize, capture, refund, void, tokenize, webhook ingest — accepts and enforces an Idempotency-Key. Replays return the same result; they do not double-charge. The CI chaos suite proves this with a 100×-replay test.

2. Bounded context

Context name: Payments Domain class: Generic (we sell hotel rooms, not payment processing — the business value is in the abstraction, not in payment innovation) Ubiquitous language: PaymentMethod, Authorization, Capture, Refund, Void, Transaction, Webhook, ReconciliationReport, Chargeback, FxContext, AdapterHealth, IdempotencyKey, ProcessorRef.

What is in:

  • The PaymentPort and every vendor adapter.
  • Tokenized payment methods (per guest, per tenant) — encrypted at rest.
  • Transactions, authorizations, captures, refunds, voids — full lifecycle.
  • Webhook intake (signature verification, idempotency, dispatch).
  • Daily reconciliation jobs per processor.
  • Cash receipt + cash refund recording (no vendor call but full audit).
  • Chargebacks: intake → evidence pack → submission → outcome.
  • FX context capture at authorize time.
  • Per-vendor circuit-breaker health & adapter precedence routing.

What is out:

  • The folio (charges, taxes, invoices) → billing-service.
  • Refund policy decisions → reservation-service (caller decides amount + reason; we execute).
  • Booking saga state → reservation-service.
  • Notification delivery → notification-service (we publish events; they format and send).
  • Tax computation → billing-service (we receive Money already tax-tagged).
  • Currency exchange-rate sourcing → ExchangeRatePort (separate but co-located here).
  • Card UI surfaces (Stripe Elements, PayPal buttons) → tenant booking + mobile apps embed processor SDKs directly under our tokenization-session envelope.

3. Aggregates owned

AggregateCardinalityPurposeIdentity prefix
Transactionroot, 1 per attempted movementLifecycle state, totals, FX, processor ref, channel of originpay_
Authorization0..N per transactionPre-auth hold, expiry, processor referenceauth_
Capture0..N per authorizationEach capture (multi-capture supported per adapter capability)cap_
Refund0..N per transactionFull or partial refund; references original capturerfd_
Void0..1 per authorizationReleased unused authorizationvd_
PaymentMethod0..N per (tenant, guest)Tokenized, encrypted method metadata; never PANpm_
Webhook1 per processor eventSigned, deduped envelope; FK from Transaction.events[]whk_
Reconciliation1 per (processor, date)Settlement match reportrec_
Chargeback0..N per transactionDispute lifecyclecbk_
FxContext1 per cross-currency transactionRate, source, quotedAt — immutable(composite)
AdapterHealth1 per (vendor, env)Sliding-window error rate, latency, circuit state(composite)

4. Responsibilities (numbered)

  1. Implement PaymentPort.authorize() — route by tenant adapter precedence and method, snapshot FX, persist intent + outbox, call adapter.
  2. Implement PaymentPort.capture() — full or partial; idempotent on Idempotency-Key.
  3. Implement PaymentPort.refund() — full or partial; reject if exceeds captured-minus-already-refunded.
  4. Implement PaymentPort.void() — within adapter void window; releases authorization.
  5. Implement PaymentPort.getTransaction() — return canonical Transaction with full event timeline.
  6. Implement PaymentPort.reconcileBatch(date) — fetch processor settlement, join, emit reconciliation.completed.v1 (+ discrepancy_found.v1 per delta).
  7. Implement PaymentPort.tokenize() — create a hosted-fields session, return client_secret; on hosted-fields confirm, attach to (tenant, guest) and emit method.tokenized.v1.
  8. Implement PaymentPort.describeAdapter() — per-adapter capability matrix surfaced to the booking funnel for UX gating (3DS, async confirm, partial refund, void window).
  9. Cash adapter — record receipts and refunds with synthetic processor: 'cash', no network call, full audit, FX snapshot if guest paid in non-tenant currency.
  10. Webhook receivers under /webhooks/v1/<vendor> — verify signature, persist to webhook_inbox with externalEventId unique key, enqueue dispatcher; the receiver itself never mutates business state.
  11. Webhook dispatcher worker — drain webhook_inbox, route to per-vendor handler, apply state transitions idempotently, emit webhook.processed.v1 (or duplicate_dropped.v1).
  12. Reconciliation cron — daily per (tenant, processor); locks per-tenant schema row; persists report; emits events.
  13. Adapter circuit-breaker — per (vendor, env) sliding window of last 100 calls; trips open at >25% errors or p99 > 5 s for 60 s; half-open after 30 s; closed after 10 successive successes.
  14. Chargeback intake — webhook-driven; opens Chargeback with deadline; surfaces evidence-pack workflow to backoffice.
  15. AI fraud anomaly hooks — every authorize is scored async (does not block) by ai-orchestrator-service; high-confidence anomalies create a HITL Decision for manager review.
  16. PCI-safe logging — every log line passes through a redaction layer that masks PAN to last-4, drops CVV, drops card.* raw fields; CI scans logs for the canonical card-number regex and fails the build on a hit.

5. Upstream / downstream context map

┌─────────────────────┐
│ tenant-service │ adapter precedence,
│ │ vendor credential pointer,
│ │ Sharia toggle, currency defaults
└──────────┬──────────┘
│ tenant.config_updated.v1
│ tenant.finance_schemas.provisioned.v1

┌─────────────────────┐ ┌──────────────────────────┐
│ reservation-service │ ── saga │ payment-gateway-service │── refund/capture →┌──────────────┐
│ (caller / driver) │ ──────▶│ (PaymentPort impl) │ │ billing-svc │
│ │ ◀──────│ │── events ─────────▶│ (folio) │
└─────────────────────┘ events └──┬─────────┬─────────────┘ └──────────────┘
│ │
│ └──── webhooks ──── Stripe / PayPal / HesabPay


┌────────────────────────┐
│ ai-orchestrator-svc │ async fraud anomaly score
│ (HITL gate) │
└────────────────────────┘

┌─────────────────────┐ ┌──────────────────────────┐
│ billing-service │── cash_drawer.closed.v1 ───────────────▶│ payment-gateway-service │
│ (desktop close) │ │ (cash reconciliation) │
└─────────────────────┘ └──────────────────────────┘

6. Booking-saga participation — ASCII sequence

reservation-svc payment-gateway-svc processor (Stripe/PayPal/Cash/MFS)
│ │ │
│ reservation.held.v1 │ (consume; saga step) │
├──────────────────────▶│ │
│ │ AuthorizePayment │
│ │ ├ persist Transaction(pending)
│ │ ├ snapshot FX │
│ │ └ call adapter.authorize() │
│ │ ──────────────────────────▶ │
│ │ ◀── { authId, status } ───── │
│ │ persist Authorization │
│ │ outbox: transaction.authorized.v1
│ ◀─ transaction.authorized.v1 │
│ │ │
│ reservation.confirmed.v1 │
├──────────────────────▶│ │
│ │ CapturePayment (or stay pending_cash)
│ │ adapter.capture() │
│ │ ──────────────────────────▶ │
│ │ ◀── { paymentId, captured } │
│ │ outbox: transaction.captured.v1
│ ◀─ transaction.captured.v1 │
│ │ │
│ reservation.cancelled.v1 { refund: { amount, reason }}│
├──────────────────────▶│ │
│ │ RefundPayment │
│ │ adapter.refund() │
│ │ ──────────────────────────▶ │
│ │ ◀── { refundId, status } ─── │
│ │ outbox: transaction.refunded.v1
│ ◀─ transaction.refunded.v1 │

For the cash adapter the right column is replaced by an in-process synthetic receipt; the saga steps and event shapes are identical.

7. Adapter pattern in detail

src/
├── application/
│ └── ports/
│ ├── payment.port.ts # canonical interface
│ └── exchange-rate.port.ts # FX snapshot source
├── infrastructure/
│ └── adapters/
│ ├── stripe/
│ │ ├── stripe.adapter.ts implements PaymentPort
│ │ ├── stripe.webhook.ts signed receiver
│ │ ├── stripe.reconciler.ts settlement report fetch + join
│ │ └── stripe.error-mapper.ts Stripe.* errors → PaymentError
│ ├── paypal/ (same shape)
│ ├── hesabpay/ (HMAC-signed webhook; AFN only)
│ ├── cash/
│ │ ├── cash.adapter.ts in-process; no network
│ │ └── cash.reconciler.ts joins billing.cash_drawer.closed.v1
│ └── _shared/
│ ├── circuit-breaker.ts
│ ├── retry-with-jitter.ts
│ └── pan-redaction.ts

The four-layer separation is mandatory (standards/SERVICE_TEMPLATE). The CI ESLint pack fails any PR that imports a vendor SDK outside infrastructure/adapters/<vendor>/.

8. Cash-on-arrival as first-class

Cash is not a degraded card path. It is a real adapter that implements the same PaymentPort interface. The implementation is an in-process synthetic processor with these contractual properties:

  • Authorize — record Transaction with status: 'authorized', processor: 'cash', no network call. If the rate plan's guarantee policy is card_guarantee or partial_card_deposit, route a separate card authorization in parallel under originatingTransactionId.
  • Capture — only legal at front-desk via the POST /api/v1/payments/cash/receipts endpoint; persists a Capture row tied to operatorId, cashDrawerShiftId, and an optional photo of the receipt. Available offline on the desktop.
  • RefundPOST /api/v1/payments/cash/refunds; requires dual sign-off if > tenant.threshold (default 0.5% of daily revenue or AFN 1000, whichever is greater); persists with two operatorIds.
  • Reconciliation — daily job consumes melmastoon.billing.cash_drawer.closed.v1, joins by (propertyId, date, shiftId), computes expected − counted. Variances above threshold publish reconciliation.discrepancy_found.v1.
  • No-show — when reservation.no_show.v1 arrives and the policy is none, no action; if card_guarantee, capture first-night charge from the held card; if partial_card_deposit, no further action.

This is documented in detail at 10 §7.

9. Multi-tenancy posture (schema-per-tenant)

Per ADR-0002 §2, tenant data lives in tenant_<uuid>_payments schemas. PgBouncer connection-init runs SET search_path = 'tenant_<uuid>_payments','public'; on every checkout. RLS is also enabled inside each schema as defense in depth. A cross-tenant query path through payment-gateway-service is a CI-blocking finding.

A small number of cross-tenant control tables live in the payments_central schema:

  • vendor_credentials_pointer — Secret Manager resource names per (tenant, vendor); never the secrets themselves.
  • idempotency_keys — global idempotency across schemas (key = <tenantId>:<requestId>).
  • dlq_webhooks — failed webhook envelopes for ops triage.
  • adapter_health_log — per-vendor circuit history for fleet-wide observability.

10. Key decisions (and why)

DecisionWhy
Single PaymentPort, vendor adapters behind itAdding HesabPay or Adyen does not touch reservation-service. Vendor lock-in is contained in this service.
Schema-per-tenant for tokens & transactionsPCI scope minimization; structurally impossible cross-tenant payment-data leakage; clean offboarding via DROP SCHEMA.
Cash adapter implements PaymentPortCash is dominant in target markets; treating it as a first-class PaymentPort keeps invariants and audit identical to card.
Idempotency-Key mandatory on every mutating methodGuest double-clicks, retried sagas, and replayed webhooks must never double-charge.
FX snapshot at authorize timeTenants and guests must see the same number at confirm and at capture. Re-quoting silently would break trust.
Webhook ingest = signature verify + persist + dispatch (separate worker)Receiver path is < 50 ms; processing is async and idempotent; receiver crashes never lose a webhook.
AI fraud scoring async, never blockingA scoring outage must not stop bookings; high-risk scores create a HITL Decision (see AI_INTEGRATION).
No card data ever in our networkHosted-fields capture + tokens only. PAN/CVV in our logs or DB is a Sev-1.

11. Out of scope (to prevent scope creep)

  • Subscription billing, recurring plans, dunning — that's tenant-internal SaaS billing for Ghasi itself, owned by a separate (future) subscriptions-service.
  • Tenant-to-tenant payouts (chain treasury) — out of scope; a future payouts-service if and when chain treasury becomes a product.
  • Tax engine — owned by billing-service; we receive Money values that already carry tax classification.
  • Refund policy DSL evaluation — owned by reservation-service (per rate plan); we execute the refund with the amount and reason supplied.
  • Loyalty / points balances — owned by a future loyalty-service; payment events feed it but we do not store balances.