Skip to main content

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:

  1. 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.
  2. 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.
  3. 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-service and processor-hosted card capture (Stripe Elements, PayPal hosted fields).
  4. 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.
  5. 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.
  6. 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 PaymentPort interface (declared in application/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

Concernbilling-servicepayment-gateway-service
OwnsFolio, charges, taxes, refunds, invoicesPayment intents, processor adapters, cash drawers
PCI scopeOut (no card data)In (handles processor tokens; never PAN)
Schema modelSchema-per-tenant for financial isolationSchema-per-tenant for PCI scope isolation
Failure isolationA processor outage does not stop posting charges to foliosAn error posting a charge does not corrupt a payment record
Vendor lock-in surfaceNoneAll 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 offboardingDROP 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.

MethodAdapterUse caseCurrenciesRefund supportNotes
Cash on arrivalCashAdapterDominant in target markets; pre-authorized at booking with optional card guaranteeAllFull (cash-out at desk)First-class — full guarantee policies, no-show automation, daily close
PayPalPayPalAdapterConsumer-friendly globally; works for guests booking from abroad25 currenciesFullHosted checkout (no PCI scope expansion)
StripeStripeAdapterVisa / Mastercard / Amex; 130+ currencies130+FullStripe 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 railsAFNPartial — per HesabPay capabilityWebhook-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

PhaseMethods addedWhy
Phase 2M-Pesa-style MFS adapter (East Africa expansion); regional cards via UnionPay; Sharia-compliant non-interest option (no chargeback fees, no riba); HesabPay liveRegional payment rails; regulatory option
Phase 3Adyen (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 4Crypto stablecoin pilot (USDC) for cross-border bookings — opt-in tenants only; off-ramp via partnerCross-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

  • Money is 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

MethodAt bookingAt check-inDuring stayAt checkout
Card (default)Authorize full booking total (or deposit)Capture depositEither: top-up auth + post charges, or pay-on-checkoutCapture remainder
Card (pay-on-checkout)Authorize $1 to validate the cardRe-authorize correct depositPost charges to folio (no card movement)Single capture for full amount
PayPalAuthorize fullCapture depositSame as cardCapture remainder
MFSAuthorize triggers OTP pushCapture on guest confirm (often same as authorize for MFS)Top-up via MFS pushFinal MFS push at checkout
Cash + card guaranteeCard authorize for guarantee; record cash promiseCash deposit at desk; release card authAccumulate cash balanceCash settle at desk; release card auth
Cash onlyRecord authorized (synthetic); no cardCash deposit at deskAccumulate cashCash 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_MISMATCH and 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)

PolicyBehavior at bookingNo-show windowAuto-release
noneNo card collected; no platform-side guaranteePer tenant (e.g., 18:00 local on arrival day)No-show automatically marks reservation; no refund processing
card_guaranteeCard details captured (Stripe Elements / PayPal); $1 auth to validate; no funds takenNo-show window per policy; auto-charge card for first-night fee + taxAt check-in, $1 auth voided when cash deposit collected
partial_card_depositCapture configurable % (e.g., 30%) of total via card at bookingCard portion non-refundable past cancellation cutoff per policyAt check-in, the captured portion is applied to folio; remainder collected in cash at desk
bank_transfer_promiseGuest indicates wire by date X; reservation held conditionallyIf wire not received by validUntil, reservation auto-cancelsManual 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.receipt entry with amount, currency, paymentId, reservationId, operatorId, timestamp.
  • Every cash refund creates a cash.refund entry.
  • 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.v1 with expected, counted, variance, signedBy (two operators), and a settlement-report PDF stored in file-storage-service.
  • Reconciliation in billing-service joins 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-service and to emit cash.drawer.closed.v1 synchronously).
  • An offline cash close is held as pending_close; the desktop refuses to start a new shift while a previous shift's close is pending.

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

CurrencyCodeMinor unitNotes
Afghan AfghaniAFNpul (1/100 AFN)Primary target market
Iranian RialIRRdinar (1/100 IRR)High denomination — bigint mandatory
Tajikistani SomoniTJSdiram (1/100 TJS)
US DollarUSDcentCross-border standard
EuroEURcentEU market
UAE DirhamAEDfils (1/100 AED)GCC market
Indian RupeeINRpaise (1/100 INR)South Asia
Pakistani RupeePKRpaisa (1/100 PKR)South Asia
Saudi RiyalSARhalala (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_EXPIRED and 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

JurisdictionTax codes supported at MVP
AfghanistanBRT (hotel), service fee, tourism fee where applicable per province
TajikistanVAT, hotel-specific where applicable
IranVAT, 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):

PolicyDescription
non_refundableNo refund at any point after booking (typical for promotional rates).
flexible_24hFull refund if cancelled ≥ 24 h before arrival; non-refundable thereafter.
flexible_72hFull refund if cancelled ≥ 72 h before arrival; non-refundable thereafter.
flexible_7dFull refund if cancelled ≥ 7 d before arrival; non-refundable thereafter.
partial_per_windowTiered: e.g., 100% if > 14 d, 50% if 3–14 d, 0% if < 3 d.
customExpressed in the policy DSL: a sequence of { daysBefore, refundPct, fixedFee? } entries plus optional noShowFee.

11.2 Auto-refund within window

When a guest cancels:

  1. reservation-service evaluates the policy against now and arrivalDate.
  2. If now is within the refundable window, reservation-service emits reservation.cancelled.v1 with refund: { amount, reason }.
  3. billing-service posts a refund line to the folio.
  4. payment-gateway-service processes the refund via PaymentPort.refund(...) against the original payment.
  5. notification-service notifies 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_goodwill and 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

ProcessorFunds back on guest's instrumentNotes
Stripe (cards)5–10 business daysImmediate API success; funds settle later
PayPal1–3 business daysImmediate API success
HesabPay / MFSPer MFS rail (often immediate or next-day)Some MFS support partial refund only via reverse-transfer flow
CashImmediate at deskSubject 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:

  1. Fetches the processor's settlement report for date (Stripe Sigma / API; PayPal CSV; MFS provider report; cash drawer close events).
  2. Loads the platform's payment records for date.
  3. Joins by processorRef (or by cashReceiptId for cash).
  4. Produces a ReconciliationReport with matched, unmatched, fees, net.
  5. Persists the report; emits payment.reconciliation.completed.v1.

12.2 Mismatch alerts

Any unmatched entry triggers an alert:

SideMeaningAction
platform_onlyPlatform recorded a payment the processor did notInvestigate webhook drop; possible retry; finance escalation if persists
processor_onlyProcessor settled a payment the platform did not recordInvestigate 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:

  1. The dispute is recorded in the disputes table with processor, processorRef, paymentId, reservationId, amount, reason, evidenceDeadline, status.
  2. payment.dispute.opened.v1 is published.
  3. notification-service alerts the tenant's finance + GM roles immediately.
  4. 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 paymentMethod token (e.g., to attempt a recurring capture) is logged with userId, 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-service triggers a finding.
  • Processor agreements and data-processing agreements are reviewed and renewed.

14.6 Network posture

  • payment-gateway-service runs 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 externalTransactionId is 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

FailureDetectionGraceful degradationAudit
Processor down (Stripe / PayPal 5xx)Adapter timeout / 5xxBooking funnel offers next configured adapter; if cash is enabled for this rate plan, offers cash-on-arrival with manager overrideMELMASTOON.PAYMENT.GATEWAY_TIMEOUT
Webhook lostDaily reconciliation detectsReconciliation job synthesizes the missing event from the processor's report; replays platform-side stateMELMASTOON.BILLING.RECONCILIATION_MISMATCH if not auto-recovered
FX provider downExchange-rate adapter timeoutUse 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 declinedProcessor returns declineBooking funnel shows decline with safe message (no decline-code leak); offers retry with different method or cash-on-arrival fallbackMELMASTOON.PAYMENT.DECLINED
Webhook signature invalidReceiver401; alert; do not enqueue any state changeMELMASTOON.PAYMENT.WEBHOOK_SIGNATURE_INVALID
Double-charge attempt (guest hits Pay twice)Idempotency key on AUTHORIZE + on the booking funnelSecond click is idempotent; same authorization returnedNo alert (expected behavior)
Refund failure at processorAdapterRecorded 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 closeDesktop close workflowTwo-staff sign-off required to acknowledge variance; variance event persisted; finance dashboard surfaces the trend per cashierVariance recorded in close event
MFS push not delivered to guest phoneGuest does not confirm within timeoutAuthorization expires; booking funnel shows retry optionLogged with requiresAction.ref
Currency exposure (rate moved between quote and capture)Comparison at captureTenant absorbs (default) or surcharge per tenant policy; never silent to guest if surchargeLogged 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

LevelWhat it testsWhere it runs
UnitDomain (Money, FX snapshot, refund policy DSL, tax computation)Pure TS, every PR
Adapter contract testsEach adapter's contract against an in-memory mock of the processor's documented behaviorPer PR
Sandbox per processorReal adapter against the processor's sandbox (Stripe test mode, PayPal sandbox, MFS sandbox where available)Nightly; on adapter PRs
CI fixturesRecorded sandbox fixtures replayed deterministicallyEvery PR
ChaosSimulated webhook delays, double-charge attempts, refund failuresWeekly
ReconciliationSynthetic settlement reports vs. platform recordsPer PR for the reconciliation job
Production canaryNew adapter builds roll to one pilot tenant per processor for 7 days before fleetContinuous

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 PaymentPort method, 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.
  • Runbookrunbooks/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-service for 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.md for payment-gateway-service.

18.2 Definition of done

A method is "shipped" when:

  1. All contract tests pass against the processor sandbox.
  2. The reconciliation job has run cleanly for 7 days against synthetic + sandbox data.
  3. The runbook is reviewed by the on-call rotation and the finance lead.
  4. 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).
  5. 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 1No 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 3Hotel-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-service and access-audited.
  • AML monitoringai-orchestrator-service provides 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 guestId are included in the export with paymentId, 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 the audit-service immutable 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.