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:
- One vendor boundary, one place. Each adapter (
StripeAdapter,PayPalAdapter,HesabPayAdapter,CashAdapter, futureAdyenAdapter,RazorpayAdapter,MpesaAdapter,ApplePayAdapter,GooglePayAdapter,StablecoinAdapter) lives behindinfrastructure/adapters/<vendor>/. Domain code never sees a vendor type. Onboarding a new method is aPaymentPortimpl + a webhook receiver + a reconciliation job — not a release that touchesreservation-serviceorbilling-service. - 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. - 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
CashAdapterimplements the samePaymentPortinterface as Stripe; cash transactions get full audit, FX context, refund support, no-show automation, and reconciliation againstbilling-service's cash-drawer events. - 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
PaymentPortand 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 receiveMoneyalready 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-sessionenvelope.
3. Aggregates owned
| Aggregate | Cardinality | Purpose | Identity prefix |
|---|---|---|---|
Transaction | root, 1 per attempted movement | Lifecycle state, totals, FX, processor ref, channel of origin | pay_ |
Authorization | 0..N per transaction | Pre-auth hold, expiry, processor reference | auth_ |
Capture | 0..N per authorization | Each capture (multi-capture supported per adapter capability) | cap_ |
Refund | 0..N per transaction | Full or partial refund; references original capture | rfd_ |
Void | 0..1 per authorization | Released unused authorization | vd_ |
PaymentMethod | 0..N per (tenant, guest) | Tokenized, encrypted method metadata; never PAN | pm_ |
Webhook | 1 per processor event | Signed, deduped envelope; FK from Transaction.events[] | whk_ |
Reconciliation | 1 per (processor, date) | Settlement match report | rec_ |
Chargeback | 0..N per transaction | Dispute lifecycle | cbk_ |
FxContext | 1 per cross-currency transaction | Rate, source, quotedAt — immutable | (composite) |
AdapterHealth | 1 per (vendor, env) | Sliding-window error rate, latency, circuit state | (composite) |
4. Responsibilities (numbered)
- Implement
PaymentPort.authorize()— route by tenant adapter precedence and method, snapshot FX, persist intent + outbox, call adapter. - Implement
PaymentPort.capture()— full or partial; idempotent onIdempotency-Key. - Implement
PaymentPort.refund()— full or partial; reject if exceeds captured-minus-already-refunded. - Implement
PaymentPort.void()— within adapter void window; releases authorization. - Implement
PaymentPort.getTransaction()— return canonicalTransactionwith full event timeline. - Implement
PaymentPort.reconcileBatch(date)— fetch processor settlement, join, emitreconciliation.completed.v1(+discrepancy_found.v1per delta). - Implement
PaymentPort.tokenize()— create a hosted-fields session, returnclient_secret; on hosted-fields confirm, attach to (tenant, guest) and emitmethod.tokenized.v1. - Implement
PaymentPort.describeAdapter()— per-adapter capability matrix surfaced to the booking funnel for UX gating (3DS, async confirm, partial refund, void window). - Cash adapter — record receipts and refunds with synthetic
processor: 'cash', no network call, full audit, FX snapshot if guest paid in non-tenant currency. - Webhook receivers under
/webhooks/v1/<vendor>— verify signature, persist towebhook_inboxwithexternalEventIdunique key, enqueue dispatcher; the receiver itself never mutates business state. - Webhook dispatcher worker — drain
webhook_inbox, route to per-vendor handler, apply state transitions idempotently, emitwebhook.processed.v1(orduplicate_dropped.v1). - Reconciliation cron — daily per (tenant, processor); locks per-tenant schema row; persists report; emits events.
- 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.
- Chargeback intake — webhook-driven; opens
Chargebackwith deadline; surfaces evidence-pack workflow to backoffice. - AI fraud anomaly hooks — every
authorizeis scored async (does not block) byai-orchestrator-service; high-confidence anomalies create a HITLDecisionfor manager review. - 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
Transactionwithstatus: 'authorized',processor: 'cash', no network call. If the rate plan's guarantee policy iscard_guaranteeorpartial_card_deposit, route a separate card authorization in parallel underoriginatingTransactionId. - Capture — only legal at front-desk via the
POST /api/v1/payments/cash/receiptsendpoint; persists aCapturerow tied tooperatorId,cashDrawerShiftId, and an optional photo of the receipt. Available offline on the desktop. - Refund —
POST /api/v1/payments/cash/refunds; requires dual sign-off if> tenant.threshold(default 0.5% of daily revenue orAFN 1000, whichever is greater); persists with twooperatorIds. - Reconciliation — daily job consumes
melmastoon.billing.cash_drawer.closed.v1, joins by(propertyId, date, shiftId), computes expected − counted. Variances above threshold publishreconciliation.discrepancy_found.v1. - No-show — when
reservation.no_show.v1arrives and the policy isnone, no action; ifcard_guarantee, capture first-night charge from the held card; ifpartial_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)
| Decision | Why |
|---|---|
Single PaymentPort, vendor adapters behind it | Adding HesabPay or Adyen does not touch reservation-service. Vendor lock-in is contained in this service. |
| Schema-per-tenant for tokens & transactions | PCI scope minimization; structurally impossible cross-tenant payment-data leakage; clean offboarding via DROP SCHEMA. |
Cash adapter implements PaymentPort | Cash 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 method | Guest double-clicks, retried sagas, and replayed webhooks must never double-charge. |
| FX snapshot at authorize time | Tenants 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 blocking | A scoring outage must not stop bookings; high-risk scores create a HITL Decision (see AI_INTEGRATION). |
| No card data ever in our network | Hosted-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-serviceif and when chain treasury becomes a product. - Tax engine — owned by
billing-service; we receiveMoneyvalues 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.