10 — Payments Architecture
Companion: 02 Enterprise Architecture §4.6 · 07 Security & Tenancy · 09 Lock & Key Integration · ADR-0002 Multi-Tenancy Model · 12 Desktop Spec · Error Codes — PAYMENT / BILLING
This document is the canonical specification for how Ghasi Melmastoon takes money. It defines the payment thesis for our target markets, the payment-gateway-service shape, the PaymentPort interface, the booking-flow money model, cash-on-arrival as a first-class option, multi-currency and FX handling, the optional Sharia-compliant pricing path, the tax engine, the refund-policy engine, daily reconciliation, chargebacks and disputes, PCI scope, mobile-financial-services (MFS) regional adapters, failure modes, testing, the workflow for onboarding a new method, and the compliance posture.
The implementation lives in services/payment-gateway-service/. billing-service owns the folio and invoices; payment-gateway-service owns the payment intents and processor adapters. The two services communicate exclusively via events.
1. Payment Thesis
Small and medium hotels in Melmastoon's target markets — Afghanistan, Tajikistan, Iran, then GCC and South Asia — operate in a payment landscape that international hospitality platforms misunderstand. Card penetration is limited, MFS (mobile-financial-services) adoption is rapid, and cash-on-arrival is the dominant payment path, not a footnote.
A platform built for these markets must:
- Treat cash-on-arrival as a first-class payment method, with the same invariants, audit, and reconciliation rigor as a card capture. Not as an afterthought ("we accept cash too"), but as a real method with guarantee policies, no-show automation, daily-close workflows, and tax-correct invoices.
- Be pluggable at the processor layer. Each market has different card processors, different MFS rails, and different regulatory regimes. The platform code does not change when a tenant adds HesabPay, M-Pesa, or Razorpay.
- Minimize PCI scope so that a single tenant onboarding a Visa/Debit method does not pull every other tenant into PCI scope. We do this with schema-per-tenant in
payment-gateway-serviceand processor-hosted card capture (Stripe Elements, PayPal hosted fields). - Support Sharia-compliant pricing explicitly as a tenant toggle. For some tenants in our markets, charging interest-style late fees or compound charges is non-negotiable.
- Be cheap to operate. Per-transaction processor fees in our markets are high (3–5% common); the platform's own per-transaction overhead must be near-zero.
- Be transparent in multi-currency. Guests pay in the currency they understand; tenants reconcile in the currency they bank in; FX is snapshotted at quote time and explicitly disclosed.
Every architectural decision in this document follows from these six commitments.
2. payment-gateway-service Overview
2.1 Responsibilities
payment-gateway-service owns:
- The
PaymentPortinterface (declared inapplication/ports/payment.port.ts). - All processor adapters (PayPal, Stripe, MFS adapters, Cash) as infrastructure-layer adapters.
- Payment intents: server-side records of each authorize / capture / refund attempt.
- Webhook intake from each processor; signature verification; idempotent state transitions.
- Reconciliation jobs: daily reconciliation per processor, settlement report ingestion, mismatch detection.
- Cash drawer state for properties operating cash-on-arrival flows.
- FX rate caching via the
ExchangeRatePort(separate but co-located).
payment-gateway-service does not own:
- The folio (charges, taxes, invoices). That is
billing-service. - The booking state. That is
reservation-service. - The notifications. That is
notification-service(it consumes payment events).
2.2 Why a separate service from billing-service
| Concern | billing-service | payment-gateway-service |
|---|---|---|
| Owns | Folio, charges, taxes, refunds, invoices | Payment intents, processor adapters, cash drawers |
| PCI scope | Out (no card data) | In (handles processor tokens; never PAN) |
| Schema model | Schema-per-tenant for financial isolation | Schema-per-tenant for PCI scope isolation |
| Failure isolation | A processor outage does not stop posting charges to folios | An error posting a charge does not corrupt a payment record |
| Vendor lock-in surface | None | All vendor SDK imports live here only |
Splitting them keeps PCI scope contained and lets the folio keep working when payments are degraded (e.g., still post a cash_on_arrival_pending charge to the folio while the card processor is down).
2.3 Schema-per-tenant rationale
Per ADR-0002, payment-gateway-service uses schema-per-tenant rather than the platform default of shared schema + RLS:
- PCI scope minimization — a vulnerability that defeats RLS in one schema cannot reach another tenant's payment records.
- Vendor token isolation — processor tokens (
pi_xxx,tr_xxx, etc.) are bound to a single processor account per tenant; cross-tenant token exposure is structurally impossible. - Per-tenant audit — regulators can be shown a single tenant's complete payment record without query filters that could be bypassed.
- Clean offboarding —
DROP SCHEMA tenant_<id>on offboarding produces a defensible "right to be forgotten" at the storage layer.
3. Supported Methods at Launch (Phase 1)
The MVP ships with four payment methods. Each is a real PaymentPort adapter with full sandbox tests, reconciliation, refund support (where applicable), and tax integration.
| Method | Adapter | Use case | Currencies | Refund support | Notes |
|---|---|---|---|---|---|
| Cash on arrival | CashAdapter | Dominant in target markets; pre-authorized at booking with optional card guarantee | All | Full (cash-out at desk) | First-class — full guarantee policies, no-show automation, daily close |
| PayPal | PayPalAdapter | Consumer-friendly globally; works for guests booking from abroad | 25 currencies | Full | Hosted checkout (no PCI scope expansion) |
| Stripe | StripeAdapter | Visa / Mastercard / Amex; 130+ currencies | 130+ | Full | Stripe Elements (SAQ A); 3DS where required |
| HesabPay (Afghanistan MFS) | HesabPayAdapter (slot ready in Phase 1, lit up in Phase 2 once we have the merchant agreement) | Afghanistan MFS rails | AFN | Partial — per HesabPay capability | Webhook-driven async confirmation; reconciliation against MFS daily report |
The HesabPay adapter slot is built into Phase 1 so that lighting it up is a configuration change, not a new release.
4. Roadmap
| Phase | Methods added | Why |
|---|---|---|
| Phase 2 | M-Pesa-style MFS adapter (East Africa expansion); regional cards via UnionPay; Sharia-compliant non-interest option (no chargeback fees, no riba); HesabPay live | Regional payment rails; regulatory option |
| Phase 3 | Adyen (alternative card processor for redundancy and EU coverage); Razorpay (India market entry); Apple Pay / Google Pay surfaces (mobile booking) | Geographic expansion + mobile UX parity |
| Phase 4 | Crypto stablecoin pilot (USDC) for cross-border bookings — opt-in tenants only; off-ramp via partner | Cross-border friction reduction; experimental |
Every roadmap method goes through the §18 onboarding checklist before going live.
5. The PaymentPort Interface
PaymentPort is the only interface the rest of the platform knows about for payment operations. The shape below is the canonical TypeScript declaration; the real file lives at services/payment-gateway-service/src/application/ports/payment.port.ts.
import type {
TenantId,
PropertyId,
ReservationId,
GuestId,
UserId,
ISODate,
Brand,
} from '@ghasi/shared/types';
export type PaymentId = Brand<string, 'PaymentId'>;
export type AuthorizationId = Brand<string, 'AuthorizationId'>;
export type RefundId = Brand<string, 'RefundId'>;
export type ISO4217 =
| 'AFN' | 'IRR' | 'TJS'
| 'USD' | 'EUR' | 'AED' | 'INR' | 'PKR' | 'SAR'
| 'GBP' | 'KES' | 'CNY';
export interface Money {
amountMinor: bigint; // smallest currency unit (fils, cents, qirsh, rial, ...)
currency: ISO4217;
}
export type PaymentMethodKind =
| 'cash_on_arrival'
| 'paypal'
| 'card' // generic card (Stripe, Adyen, ...)
| 'mfs' // mobile-financial-services (HesabPay, M-Pesa, ...)
| 'apple_pay'
| 'google_pay'
| 'crypto_stablecoin';
export type RefundReason =
| 'cancellation_within_policy'
| 'cancellation_goodwill'
| 'overcharge_correction'
| 'service_failure'
| 'duplicate_charge'
| 'fraud_chargeback';
export interface AuthorizeInput {
tenantId: TenantId;
propertyId: PropertyId;
reservationId: ReservationId;
guestId: GuestId;
amount: Money;
method: {
kind: PaymentMethodKind;
processorRef?: string; // e.g. Stripe payment_method id; cash has none
metadata?: Record<string, string>;
};
fxSnapshot?: {
quoteCurrency: ISO4217;
settleCurrency: ISO4217;
rate: number; // settle per quote
quotedAt: ISODate;
provider: string;
snapshotId: string;
};
capture?: 'manual' | 'automatic';
description?: string;
idempotencyKey: string;
initiatedBy?: UserId;
}
export interface AuthorizeResult {
authorizationId: AuthorizationId;
status: 'authorized' | 'pending' | 'requires_action' | 'failed';
requiresAction?: { type: '3ds_redirect' | 'mfs_otp' | 'webhook_async'; url?: string; ref?: string };
expiresAt?: ISODate;
processor: string;
warnings?: { code: string; detail: string }[];
}
export interface CaptureResult {
paymentId: PaymentId;
authorizationId: AuthorizationId;
status: 'captured' | 'pending' | 'failed';
capturedAt?: ISODate;
amount: Money;
}
export interface RefundResult {
refundId: RefundId;
paymentId: PaymentId;
status: 'refunded' | 'pending' | 'failed';
amount: Money;
reason: RefundReason;
refundedAt?: ISODate;
}
export interface Transaction {
paymentId: PaymentId;
reservationId: ReservationId;
amount: Money;
status: 'authorized' | 'captured' | 'partially_refunded' | 'refunded' | 'voided' | 'failed';
method: PaymentMethodKind;
processor: string;
createdAt: ISODate;
events: { at: ISODate; type: string; processorRef?: string }[];
}
export interface ReconciliationReport {
date: ISODate;
processor: string;
matched: { count: number; total: Money };
unmatched: { count: number; total: Money; entries: ReconciliationDelta[] };
refundsMatched: { count: number; total: Money };
fees: Money;
net: Money;
source: { reportId: string; ingestedAt: ISODate };
}
export interface ReconciliationDelta {
side: 'platform_only' | 'processor_only';
paymentId?: PaymentId;
processorRef?: string;
amount: Money;
reason: string;
}
export type PaymentError =
| { code: 'MELMASTOON.PAYMENT.GATEWAY_TIMEOUT'; retriable: true; processor: string }
| { code: 'MELMASTOON.PAYMENT.DECLINED'; retriable: false; processor: string; declineCode?: string }
| { code: 'MELMASTOON.PAYMENT.INSUFFICIENT_FUNDS'; retriable: false; processor: string }
| { code: 'MELMASTOON.PAYMENT.INTENT_NOT_FOUND'; retriable: false }
| { code: 'MELMASTOON.PAYMENT.WEBHOOK_SIGNATURE_INVALID'; retriable: false; processor: string }
| { code: 'MELMASTOON.PAYMENT.CASH_RECONCILIATION_PENDING'; retriable: true };
export interface PaymentPort {
authorize(input: AuthorizeInput): Promise<AuthorizeResult>;
capture(authorizationId: AuthorizationId, amount?: Money, idempotencyKey?: string): Promise<CaptureResult>;
refund(transactionId: PaymentId, amount: Money, reason: RefundReason, idempotencyKey: string): Promise<RefundResult>;
void(authorizationId: AuthorizationId, idempotencyKey: string): Promise<void>;
getTransaction(id: PaymentId): Promise<Transaction>;
reconcileBatch(date: ISODate): Promise<ReconciliationReport>;
describeAdapter(): {
processor: string;
methods: PaymentMethodKind[];
capabilities: {
partialCapture: boolean;
partialRefund: boolean;
voidWindow: boolean;
threeDSecure: boolean;
asyncConfirm: boolean;
};
};
}
5.1 Invariants
Moneyis always{ amountMinor: bigint, currency: ISO4217 }. Never floats. Never combined-string"$100.50".- No method takes raw card data. Card data flows directly from the guest's browser/app to the processor (Stripe Elements, PayPal hosted fields). The platform receives only processor tokens.
- Every method takes
idempotencyKey. Replays return the same result; they do not double-charge. - Cash adapter implements the same port. It returns synthetic
processor: 'cash'records and never calls a network. - Adapter errors are normalized to
PaymentError. No vendor SDK exception types leak past the adapter boundary.
6. Booking Flow Money Model
The money model is consistent across processors and across cash. The differences are in how authorization happens (network vs. promise-to-pay), not what the platform records.
6.1 Lifecycle
[guest selects rate, adds to booking]
│
▼
QUOTE ──────► pricing-service computes line items + tax + total
│ Money snapshot persisted on the reservation hold
│
▼
[guest hits "Pay"]
│
▼
AUTHORIZE
├─ card → Stripe/PayPal authorize for full amount (or deposit per policy)
├─ MFS → MFS push notification to guest's phone; async confirmation
└─ cash → record `authorized` synthetically; optionally hold a card as guarantee
│
▼
reservation.confirmed.v1 (if AUTHORIZE succeeded or cash with valid guarantee)
│
▼
[at check-in]
│
▼
CAPTURE deposit ──► card: capture pre-auth; cash: record cash receipt
│
▼
[during stay — incremental folio charges]
│
▼
For each charge: post to folio (billing-service) + (card path) capture incremental
│ (cash path) accumulate balance
│
▼
[at checkout]
│
▼
CAPTURE remainder ──► card: capture remaining auth or new charge
cash: collect at desk; cash drawer entry; receipt
│
▼
reservation.checkout.v1 + folio.closed.v1 + invoice.issued.v1
│
▼
[on cancellation]
│
▼
REFUND per refund-policy engine (§11)
6.2 Authorize vs. capture timing
| Method | At booking | At check-in | During stay | At checkout |
|---|---|---|---|---|
| Card (default) | Authorize full booking total (or deposit) | Capture deposit | Either: top-up auth + post charges, or pay-on-checkout | Capture remainder |
| Card (pay-on-checkout) | Authorize $1 to validate the card | Re-authorize correct deposit | Post charges to folio (no card movement) | Single capture for full amount |
| PayPal | Authorize full | Capture deposit | Same as card | Capture remainder |
| MFS | Authorize triggers OTP push | Capture on guest confirm (often same as authorize for MFS) | Top-up via MFS push | Final MFS push at checkout |
| Cash + card guarantee | Card authorize for guarantee; record cash promise | Cash deposit at desk; release card auth | Accumulate cash balance | Cash settle at desk; release card auth |
| Cash only | Record authorized (synthetic); no card | Cash deposit at desk | Accumulate cash | Cash settle at desk |
6.3 Where the Money types live
- The folio (
billing-service) holds the canonical line-item Money values, taxes, and totals. - The payment intents (
payment-gateway-service) hold the Money values that actually moved through a processor (or were collected in cash). - The two are reconciled per stay; mismatches trip
MELMASTOON.BILLING.RECONCILIATION_MISMATCHand page finance on-call.
7. Cash-on-Arrival as First-Class
Cash is not a degraded card path. It is its own method with its own lifecycle, its own guarantee policies, its own reconciliation, and its own UX in the desktop. The treatment below is the contract.
7.1 Guarantee policy options (per rate plan)
| Policy | Behavior at booking | No-show window | Auto-release |
|---|---|---|---|
none | No card collected; no platform-side guarantee | Per tenant (e.g., 18:00 local on arrival day) | No-show automatically marks reservation; no refund processing |
card_guarantee | Card details captured (Stripe Elements / PayPal); $1 auth to validate; no funds taken | No-show window per policy; auto-charge card for first-night fee + tax | At check-in, $1 auth voided when cash deposit collected |
partial_card_deposit | Capture configurable % (e.g., 30%) of total via card at booking | Card portion non-refundable past cancellation cutoff per policy | At check-in, the captured portion is applied to folio; remainder collected in cash at desk |
bank_transfer_promise | Guest indicates wire by date X; reservation held conditionally | If wire not received by validUntil, reservation auto-cancels | Manual confirm by finance staff |
The default for a tenant is configurable per rate plan. The booking flow shows the guest the policy in plain language in their locale before they confirm.
7.2 No-show automation
The no-show saga is owned by reservation-service, with payment-gateway-service as a participant:
[arrival date passes the no-show window]
│
▼
reservation-service emits reservation.no_show.v1
│
├──► payment-gateway-service
│ if guarantee = card_guarantee:
│ capture first-night charge from the held card
│ emit payment.captured.v1
│ if guarantee = partial_card_deposit:
│ no action (deposit already captured at booking)
│ if guarantee = none:
│ no action
│
├──► lock-integration-service
│ suspend any issued credentials (reason='no_show')
│
└──► notification-service
notify guest + property
7.3 Cash drawer & daily close
The desktop owns the cash drawer state for properties operating cash-on-arrival.
- Each shift has an opening cash count (entered by the cashier).
- Every cash receipt creates a
cash.receiptentry withamount,currency,paymentId,reservationId,operatorId,timestamp. - Every cash refund creates a
cash.refundentry. - At end of shift, the cashier counts cash and enters the closing count.
- The desktop computes expected vs counted and flags variance > tenant-configured threshold (e.g., 0.5% or AFN 100, whichever is greater).
- A two-staff sign-off is required to close the day: cashier + manager (or two managers if cashier is also manager). Both authenticate; both signatures persisted.
- The close emits
cash.drawer.closed.v1withexpected,counted,variance,signedBy(two operators), and a settlement-report PDF stored infile-storage-service. - Reconciliation in
billing-servicejoins the cash drawer close to folios that posted cash payments that day; mismatches are flagged.
7.4 Cash-on-arrival audit
Every cash event is in the audit-service immutable log. The daily Merkle anchor covers cash events. Disputes ("the staff stole my cash") have a tamper-evident trail.
7.5 Cash UX in the desktop
- Cash receipt and cash refund flows are available offline (cash does not need a network).
- All cash entries persist to local SQLite outbox and sync on reconnect.
- The cash drawer close requires the desktop to be online (to validate the second operator's identity against
iam-serviceand to emitcash.drawer.closed.v1synchronously). - An offline cash close is held as
pending_close; the desktop refuses to start a new shift while a previous shift's close ispending.
8. Multi-Currency
8.1 Currency model
- Tenants have a default billing currency (the currency they reconcile in, typically what they bank in).
- Properties have an optional display currency for guest-facing pricing (defaults to tenant default).
- Guests in the consumer meta layer see prices in their preferred currency (geo-IP guess, user toggle), with FX disclosed.
- Every monetary value is
Money = { amountMinor: bigint, currency: ISO4217 }.
8.2 Supported currencies at MVP
| Currency | Code | Minor unit | Notes |
|---|---|---|---|
| Afghan Afghani | AFN | pul (1/100 AFN) | Primary target market |
| Iranian Rial | IRR | dinar (1/100 IRR) | High denomination — bigint mandatory |
| Tajikistani Somoni | TJS | diram (1/100 TJS) | |
| US Dollar | USD | cent | Cross-border standard |
| Euro | EUR | cent | EU market |
| UAE Dirham | AED | fils (1/100 AED) | GCC market |
| Indian Rupee | INR | paise (1/100 INR) | South Asia |
| Pakistani Rupee | PKR | paisa (1/100 PKR) | South Asia |
| Saudi Riyal | SAR | halala (1/100 SAR) | GCC |
Additional currencies are added per market entry; each addition includes a tax-engine review and processor-support verification.
8.3 FX rate snapshotting
FX rates are snapshotted at quote time and persisted on the reservation hold. The snapshot includes:
interface FxSnapshot {
snapshotId: string;
quoteCurrency: ISO4217; // currency the guest sees
settleCurrency: ISO4217; // currency the tenant settles in
rate: number; // settle per quote
quotedAt: ISODate;
provider: string; // e.g. 'ecb', 'openexchangerates', 'manual'
ttlSeconds: number; // typically 900 (15 min) for the quote
}
The snapshot travels with the quote to confirm. If the guest takes longer than ttlSeconds to confirm, the funnel re-quotes and shows a "price updated" notice.
8.4 Exchange-rate provider adapter
ExchangeRatePort is a separate port co-located in payment-gateway-service:
interface ExchangeRatePort {
getRate(from: ISO4217, to: ISO4217, at?: ISODate): Promise<{ rate: number; provider: string; quotedAt: ISODate }>;
getBatch(from: ISO4217, to: ISO4217[], at?: ISODate): Promise<Record<ISO4217, { rate: number; provider: string; quotedAt: ISODate }>>;
}
- Default adapter: a multi-source aggregator (ECB + OpenExchangeRates + a manual-override store).
- Cached in Memorystore with TTL 15 minutes.
- On provider failure: serve last cached rate up to 4 hours; beyond that, fail the quote with
MELMASTOON.PRICING.QUOTE_EXPIREDand degrade the consumer meta layer to "price on request". - Manual overrides supported per tenant for currencies the providers do not cover well (e.g., black-market AFN/USD rates differ from official rates; tenants may configure a manual rate per their actual settlement experience).
8.5 Display & rounding
- Display formatting respects locale (RTL Arabic invoice templates show currency symbol on the right; LTR English on the left).
- Rounding rules per currency are explicit (e.g., IRR is typically rounded to thousands for display; minor units kept internally).
- Half-up banker's rounding for tax computations.
9. Sharia-Compliant Option
For tenants who require Sharia-compliant pricing, the platform exposes an explicit tenant-level toggle (tenant.settings.payments.shariaCompliant: boolean).
When enabled:
- No interest charges. Late payment does not accrue interest. Late fees are not configurable as a rate; only as a fixed, transparent service fee, disclosed at booking.
- No riba (usurious increases). Compounding is disabled; surcharges are flat.
- No chargeback fees passed to the guest. Chargeback losses are absorbed by the tenant (or covered by their processor agreement).
- Transparent service fees. All fees disclosed at quote time, never added later.
- Sharia-compliant invoice template. A separate invoice format that:
- Omits any "interest" or "finance charge" line items.
- Labels service fees explicitly with their nature.
- Includes a tenant-configurable Sharia-compliance attestation footer.
- Available in RTL Arabic and Pashto/Dari layouts.
- Refund handling. Refunds are full or scheduled-installment per policy; no penalty discount that resembles a financing charge.
The toggle gates:
- The refund-policy engine's selectable policies (interest-bearing late-cancel fees are not selectable when the toggle is on).
- The invoice template emitted by
billing-service. - The booking flow's fee disclosure copy.
- The chargeback workflow's cost allocation.
Tenants can opt in or out at any time; existing reservations retain the policy in effect at booking time.
10. Tax Engine
10.1 Per-tenant configuration
Each tenant configures:
- Tax authority registration per jurisdiction they operate in (Afghanistan, Tajikistan, Iran, GCC member states, EU member states, etc.).
- Tax rates per tax code per jurisdiction (e.g., Afghanistan BRT, UAE VAT 5%, EU VAT 19/21/22%).
- Tax-inclusive vs. tax-exclusive display preference.
- Tax holiday windows per jurisdiction.
- Tax-exempt customer classes (diplomats, certain corporate accounts).
10.2 Per-line tax codes
Every folio line carries a taxCode. The tax engine resolves the applicable rate from (jurisdiction, taxCode, date, customerClass).
folio line:
category: 'room_revenue'
taxCode: 'AF.HOTEL_BRT' ← Afghan Business Receipts Tax for hotels
amount: { amountMinor: 5_000_00n, currency: 'AFN' }
│
▼
tax engine resolves:
AF.HOTEL_BRT @ 4% on 2026-04-22 → tax = 5_000_00n × 0.04 = 200_00n
│
▼
folio entry:
net: 5_000_00 AFN
tax: 200_00 AFN
gross: 5_200_00 AFN
10.3 Jurisdictional support at MVP
| Jurisdiction | Tax codes supported at MVP |
|---|---|
| Afghanistan | BRT (hotel), service fee, tourism fee where applicable per province |
| Tajikistan | VAT, hotel-specific where applicable |
| Iran | VAT, municipal tourism tax |
| GCC (UAE, Saudi, Bahrain, Oman, Kuwait, Qatar) | VAT (5% standard; 0% for some categories) |
| EU (selected) | VAT per member state; reduced rate for accommodation per member state where applicable |
Adding a new jurisdiction is a configuration task (not a code release) once the tax engine has the relevant calculation types (percentage, fixed-per-night, percentage-on-percentage compounding).
10.4 Tax invoice format per locale
- RTL Arabic invoice template for tenants in Arabic-speaking jurisdictions (UAE, Saudi, etc.) — VAT compliant, sequential numbering per tenant per fiscal year.
- Pashto / Dari invoice template for Afghan tenants — BRT compliant.
- EN / FR / RTL templates as needed per market entry.
Invoice numbering rules are per-jurisdiction (some require strict gapless sequences; some tolerate gaps with audit explanation). The invoice service tracks per-tenant per-fiscal-year sequences.
10.5 Tax holiday support
A tax holiday is a (jurisdiction, taxCode, fromDate, toDate, reason) record. During the window, the tax engine returns 0 for the affected taxCode. Existing reservations with stay dates spanning the boundary use the rate effective on each night.
11. Refund Policy Engine
Refund rules are attached to rate plans, not to individual reservations. This makes them auditable and consistent.
11.1 Per-rate-plan rules
Each rate plan declares one of the following refund policies (or a custom rule expressed in the policy DSL):
| Policy | Description |
|---|---|
non_refundable | No refund at any point after booking (typical for promotional rates). |
flexible_24h | Full refund if cancelled ≥ 24 h before arrival; non-refundable thereafter. |
flexible_72h | Full refund if cancelled ≥ 72 h before arrival; non-refundable thereafter. |
flexible_7d | Full refund if cancelled ≥ 7 d before arrival; non-refundable thereafter. |
partial_per_window | Tiered: e.g., 100% if > 14 d, 50% if 3–14 d, 0% if < 3 d. |
custom | Expressed in the policy DSL: a sequence of { daysBefore, refundPct, fixedFee? } entries plus optional noShowFee. |
11.2 Auto-refund within window
When a guest cancels:
reservation-serviceevaluates the policy againstnowandarrivalDate.- If
nowis within the refundable window,reservation-serviceemitsreservation.cancelled.v1withrefund: { amount, reason }. billing-serviceposts a refund line to the folio.payment-gateway-serviceprocesses the refund viaPaymentPort.refund(...)against the original payment.notification-servicenotifies the guest.
This flow is fully automated; no human approval required when the policy is satisfied.
11.3 Manual approval for exceptions
Outside the refundable window, the guest's request becomes an exception that requires staff approval:
- Front desk / GM sees the request in the desktop with policy context ("this rate is non-refundable; requesting full refund").
- Approval requires the role permission
reservation.refund.exception_approve(typically GM or owner). - Approved exceptions are tagged with
reason: cancellation_goodwilland the operator's identity for audit.
11.4 Partial refunds
- A folio refund line can be less than the full payment.
- Multiple refunds per payment are allowed up to the original captured amount.
- Refunds beyond captured amount are rejected with
MELMASTOON.BILLING.REFUND_EXCEEDS_BALANCE.
11.5 Refund timing per processor
| Processor | Funds back on guest's instrument | Notes |
|---|---|---|
| Stripe (cards) | 5–10 business days | Immediate API success; funds settle later |
| PayPal | 1–3 business days | Immediate API success |
| HesabPay / MFS | Per MFS rail (often immediate or next-day) | Some MFS support partial refund only via reverse-transfer flow |
| Cash | Immediate at desk | Subject to cash drawer balance and dual sign-off > tenant-configured threshold |
12. Reconciliation
Reconciliation is a daily, per-processor job. Mismatches are alerts, not silent.
12.1 Per-processor daily job
Every processor adapter implements reconcileBatch(date). The job:
- Fetches the processor's settlement report for
date(Stripe Sigma / API; PayPal CSV; MFS provider report; cash drawer close events). - Loads the platform's payment records for
date. - Joins by
processorRef(or bycashReceiptIdfor cash). - Produces a
ReconciliationReportwithmatched,unmatched,fees,net. - Persists the report; emits
payment.reconciliation.completed.v1.
12.2 Mismatch alerts
Any unmatched entry triggers an alert:
| Side | Meaning | Action |
|---|---|---|
platform_only | Platform recorded a payment the processor did not | Investigate webhook drop; possible retry; finance escalation if persists |
processor_only | Processor settled a payment the platform did not record | Investigate possible chargeback or out-of-band charge; finance escalation |
Alerts route to the finance on-call rotation via PagerDuty. An unresolved mismatch > 24 h escalates to GM at the affected tenant (read-only summary; no PII beyond reservationId).
12.3 Cash close-of-day on Electron
Cash reconciliation is the desktop-side counterpart. See §7.3 for the workflow. The settlement-report PDF is stored in file-storage-service and linked from the cash drawer close record.
12.4 Settlement reports
Every reconciliation produces a settlement-report artifact:
- PDF stored in
file-storage-service(per-tenant bucket; signed URL access). - CSV export available via the reporting surface for tenant accountants.
- Retention: 7 years (matches financial-record retention).
13. Chargebacks & Disputes
13.1 Webhook ingestion
Each processor's chargeback webhook (charge.dispute.created for Stripe, CUSTOMER.DISPUTE.CREATED for PayPal, etc.) is received by payment-gateway-service. After signature verification:
- The dispute is recorded in the
disputestable withprocessor,processorRef,paymentId,reservationId,amount,reason,evidenceDeadline,status. payment.dispute.opened.v1is published.notification-servicealerts the tenant's finance + GM roles immediately.- A 48-hour SLA timer starts for evidence submission.
13.2 Evidence collection
The desktop surfaces an "Evidence pack" workflow:
- Auto-fills booking record, guest communications, ID document if collected, lock access logs, folio history.
- Staff adds narrative + supporting attachments.
- Submitted via the appropriate processor's evidence API.
13.3 Central dispute tracker
A platform-wide dispute tracker dashboard (per tenant; aggregated for tenant chains) shows open / submitted / won / lost disputes with response-time and win-rate metrics. AI assistance (via ai-orchestrator-service) drafts initial evidence narratives from the booking record; HITL gate before submission.
13.4 Cost allocation
Chargeback fees and lost amounts post as folio adjustments labeled clearly. Sharia-compliant tenants (§9) see chargeback losses as flat, not interest-bearing.
14. PCI Scope
14.1 Target: SAQ A
We target SAQ A (the lightest PCI scope tier) for the platform.
This is achievable because:
- Card data flows from the guest's browser/app directly to the processor (Stripe Elements / PayPal hosted fields / Adyen hosted page in Phase 3).
- The platform receives only processor tokens (
pi_xxx, etc.) — never PAN, never CVV, never raw card data. - Card capture surfaces are iframed from the processor on the booking flow; we do not render input fields for PAN/CVV.
14.2 What we store
- Processor tokens (
paymentMethod,customer,paymentIntent, etc.) keyed per tenant in the per-tenant schema. - Last-4 digits and brand for display (returned by the processor; non-PCI).
- Expiry month/year for display (returned by the processor; non-PCI).
14.3 What we never store
- PAN (Primary Account Number).
- CVV / CVC.
- Magnetic stripe data.
- Raw cardholder data of any kind.
14.4 Audit per access
- Every read of a
paymentMethodtoken (e.g., to attempt a recurring capture) is logged withuserId,paymentId,purpose,timestamp. - Service-account access is identical; logged with the service identity and
traceId. - Logs are retained 7 years.
14.5 Annual review
- PCI scope is reviewed annually with an external QSA (or an internal assessment for SAQ A).
- Schema-per-tenant boundaries are re-validated; any cross-tenant query path through
payment-gateway-servicetriggers a finding. - Processor agreements and data-processing agreements are reviewed and renewed.
14.6 Network posture
payment-gateway-serviceruns on Cloud Run with a dedicated service account.- Outbound traffic is restricted via VPC egress rules to the documented processor endpoints only.
- Inbound webhook receivers are on a dedicated subdomain (e.g.,
webhooks.payments.melmastoon.ghasi.io) behind Cloud Armor + signature verification. - All traffic is TLS 1.3.
15. MFS Regional Adapters
Mobile-financial-services (MFS) adapters share a pattern. The pattern below is canonical; specific adapters (HesabPay, M-Pesa, etc.) implement it with vendor specifics behind the port.
15.1 Pattern
1. Booking funnel calls PaymentPort.authorize({ method: { kind: 'mfs', processorRef: msisdn }, ... }).
2. MFS adapter:
- Validates msisdn against the rail's regex.
- Issues a push to the rail (HesabPay API, M-Pesa STK push, etc.).
- Returns AuthorizeResult { status: 'pending', requiresAction: { type: 'mfs_otp', ref: '<txn>' } }.
3. Booking funnel shows guest "Confirm on your phone" with a countdown (typically 60–120 s).
4. Guest confirms on phone; rail sends async webhook to payment-gateway-service.
5. Webhook receiver:
- Verifies signature (HMAC, mTLS, or rail-specific).
- Idempotently updates the payment intent to 'captured' (or 'failed').
- Publishes payment.captured.v1 (or payment.failed.v1).
6. reservation-service receives the event and proceeds with booking confirmation.
15.2 Idempotency
- MFS rails return an
externalTransactionId. We persist it and key idempotency by it. - A duplicate webhook for the same
externalTransactionIdis recorded but does not re-publish events. - A missing webhook (rail dropped it) is detected by the daily reconciliation against the rail's report.
15.3 Reconciliation
- MFS rails produce daily reports (CSV, API, or batch). The adapter ingests them at end-of-day.
- Mismatches are alerts, same as §12.
15.4 Currency
- MFS rails are typically single-currency (HesabPay → AFN; M-Pesa → KES, TZS, UGX per market). The adapter rejects authorizations in other currencies with a clear error.
16. Failure Modes
| Failure | Detection | Graceful degradation | Audit |
|---|---|---|---|
| Processor down (Stripe / PayPal 5xx) | Adapter timeout / 5xx | Booking funnel offers next configured adapter; if cash is enabled for this rate plan, offers cash-on-arrival with manager override | MELMASTOON.PAYMENT.GATEWAY_TIMEOUT |
| Webhook lost | Daily reconciliation detects | Reconciliation job synthesizes the missing event from the processor's report; replays platform-side state | MELMASTOON.BILLING.RECONCILIATION_MISMATCH if not auto-recovered |
| FX provider down | Exchange-rate adapter timeout | Use cached rate up to 4 h TTL; beyond that, fail quote with QUOTE_EXPIRED; consumer meta layer falls back to "price on request" | Logged at adapter |
| Guest payment method declined | Processor returns decline | Booking funnel shows decline with safe message (no decline-code leak); offers retry with different method or cash-on-arrival fallback | MELMASTOON.PAYMENT.DECLINED |
| Webhook signature invalid | Receiver | 401; alert; do not enqueue any state change | MELMASTOON.PAYMENT.WEBHOOK_SIGNATURE_INVALID |
| Double-charge attempt (guest hits Pay twice) | Idempotency key on AUTHORIZE + on the booking funnel | Second click is idempotent; same authorization returned | No alert (expected behavior) |
| Refund failure at processor | Adapter | Recorded as pending; retry with backoff; after exhaustion, finance on-call alerted; the folio refund line still posts (the platform's view is correct; the processor side is the discrepancy) | MELMASTOON.PAYMENT.GATEWAY_TIMEOUT or specific decline |
| Cash drawer mismatch at close | Desktop close workflow | Two-staff sign-off required to acknowledge variance; variance event persisted; finance dashboard surfaces the trend per cashier | Variance recorded in close event |
| MFS push not delivered to guest phone | Guest does not confirm within timeout | Authorization expires; booking funnel shows retry option | Logged with requiresAction.ref |
| Currency exposure (rate moved between quote and capture) | Comparison at capture | Tenant absorbs (default) or surcharge per tenant policy; never silent to guest if surcharge | Logged on payment record |
In every failure mode, the invariant is: the booking either succeeds with full money trail or fails clearly with no money movement. No silent partial states.
17. Testing
17.1 Levels
| Level | What it tests | Where it runs |
|---|---|---|
| Unit | Domain (Money, FX snapshot, refund policy DSL, tax computation) | Pure TS, every PR |
| Adapter contract tests | Each adapter's contract against an in-memory mock of the processor's documented behavior | Per PR |
| Sandbox per processor | Real adapter against the processor's sandbox (Stripe test mode, PayPal sandbox, MFS sandbox where available) | Nightly; on adapter PRs |
| CI fixtures | Recorded sandbox fixtures replayed deterministically | Every PR |
| Chaos | Simulated webhook delays, double-charge attempts, refund failures | Weekly |
| Reconciliation | Synthetic settlement reports vs. platform records | Per PR for the reconciliation job |
| Production canary | New adapter builds roll to one pilot tenant per processor for 7 days before fleet | Continuous |
17.2 Sandbox per processor
- Stripe: full test-mode integration; per-test Stripe customer + payment-method tokens; 3DS required scenarios via Stripe's test cards.
- PayPal: sandbox accounts; auto-approve + decline scenarios.
- HesabPay / MFS: vendor sandbox where offered; otherwise vendor simulator under
services/payment-gateway-service/test/simulators/<rail>/. - Cash: in-process simulator with deterministic close-of-day flows.
17.3 CI fixtures
We record sandbox traffic once and replay deterministically in CI to keep the test suite fast and offline. Fixtures are versioned with the adapter and refreshed on a quarterly cadence.
17.4 Chaos scenarios
- Webhook delay: hold a webhook 30 minutes; verify reconciliation catches up cleanly.
- Webhook duplicate: send the same webhook 100 times; verify exactly-one state change.
- Double charge: replay the AUTHORIZE call 100 times with the same idempotency key; verify exactly one authorization at the processor.
- Refund mid-flight: cancel a reservation while AUTHORIZE is pending; verify void instead of refund; verify no orphan capture.
- FX provider failure: drop the provider for 30 minutes; verify cached-rate path activates and falls through to "price on request" beyond 4 h.
17.5 Tax engine tests
- A property-based suite over
(jurisdiction, taxCode, date, amount)permutations. - Reference tables per jurisdiction validated against the official tax authority's published examples.
18. Onboarding a New Payment Method
Adding a payment method is a contained, repeatable workflow.
18.1 Checklist
- Regulatory review — local payment regulations, KYC/AML requirements (see §19), tax implications, FX licensing if applicable.
- Security review — auth model, token storage, webhook signature scheme, secret rotation surface, audit log surface.
- Currency support — list supported currencies; any currency conversion the processor handles vs. we handle.
- Capability mapping — fill out
describeAdapter().capabilities: partial capture, partial refund, void window, 3DS, async confirm. - Implement adapter — under
infrastructure/adapters/<processor>/. Domain may not change. - Implement webhook receiver — signature verification mandatory; idempotency mandatory.
- Sandbox tests — full happy path + failure injection (decline, timeout, 3DS, partial refund).
- Contract tests — every
PaymentPortmethod, including idempotency and capability-conditional branches. - Reconciliation job — implement
reconcileBatch(date)against the processor's settlement report. - Certification (where required) — some processors require certification before going live in production (Adyen, certain MFS rails).
- Tax-engine alignment — verify the processor returns enough metadata to feed the tax engine (jurisdiction inferred from issuer country where relevant).
- Refund-policy alignment — verify the processor supports the refund timing the platform's policies declare.
- Runbook —
runbooks/payment/<processor>/: onboarding a tenant, rotating credentials, common errors, escalation contacts at the processor, dispute response procedure. - Cost model — per-transaction fee schedule; integrated into
analytics-servicefor tenant cost reporting. - Production canary — one pilot tenant for 7 days before fleet rollout.
- Documentation — update §3 / §4 (methods + roadmap), §17.1 if a new adapter type, ERROR_CODES.md if any new normalized codes, and the per-service
SERVICE_README.mdforpayment-gateway-service.
18.2 Definition of done
A method is "shipped" when:
- All contract tests pass against the processor sandbox.
- The reconciliation job has run cleanly for 7 days against synthetic + sandbox data.
- The runbook is reviewed by the on-call rotation and the finance lead.
- The pilot tenant has run for 7 days with no S1/S2 incidents and
authorize.success.rate >= 99%(excluding decline outcomes, which are user-driven). - The compliance lead has signed off on regulatory and PCI posture.
19. Compliance
19.1 PCI-DSS
- Target SAQ A (see §14).
- Annual review with QSA (or internal SAQ A self-assessment).
- Network segmentation enforced via VPC egress rules.
- Schema-per-tenant for token isolation.
19.2 AML / KYC
- Phase 1 — No KYC on guests. Bookings under universal threshold (typically USD equivalent 10,000 single transaction; varies by jurisdiction) do not require KYC for hospitality booking under our target markets' rules.
- Phase 3 — Hotel-payment KYC introduced for transactions above the per-jurisdiction threshold. Guest provides ID document; document is verified by a KYC provider (Onfido, Persona, or a regional alternative); decision recorded with the reservation; ID document is encrypted at rest with a dedicated KMS key in
file-storage-serviceand access-audited. - AML monitoring —
ai-orchestrator-serviceprovides anomaly detection over payment patterns (multiple cards, unusual refund frequency, structuring attempts). Anomalies route to the platform's compliance dashboard, not to the tenant. Suspicious activity reporting (SAR) is a platform-side compliance responsibility under the relevant regulator.
19.3 Per-region tax compliance
- Tax authority registration per tenant per jurisdiction (see §10).
- Invoice numbering rules per jurisdiction (sequential vs. tolerant of gaps with audit).
- Tax holiday support per jurisdiction (see §10.5).
- Periodic reviews of jurisdictional rates as authorities publish updates; the tax engine is configuration, not code, so updates are deploys-free where possible.
19.4 Data subject requests
When a guest exercises a DSAR (export or erasure):
- Payment records associated with their
guestIdare included in the export withpaymentId,amount,currency,processor,status,createdAt. Processor tokens are redacted (they are the processor's reference, not platform data we are legally compelled to surface). - Payment records are not erased — they fall under the financial-records retention carve-out (7-year minimum). After the retention window, identifying fields are pseudonymized.
19.5 Audit retention
- All payment events (
payment.*.v1) are in theaudit-serviceimmutable log. - The daily Merkle anchor covers payment events.
- Retention: 7 years by default, configurable longer per tenant.
19.6 Sharia compliance attestation
Tenants who enable the Sharia-compliant option (§9) can configure an attestation footer on invoices. The platform does not issue Sharia certifications itself; the tenant is responsible for any external Sharia board attestation. The platform provides the operational substrate (no riba, no compounding interest, transparent fees) that supports such attestation.
Cross-references: per-service deep doc lives at
services/payment-gateway-service/. Folio mechanics:services/billing-service/. Booking saga: 02 Enterprise Architecture §7.3. Multi-tenancy rationale for schema-per-tenant: ADR-0002. Desktop cash-drawer flow: 12 Desktop Spec. Error codes: PAYMENT / BILLING sections.