Skip to main content

SERVICE_OVERVIEW — billing-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 · 06 Data Models · 07 Security & Tenancy · 10 Payments Architecture · ADR-0002 Multi-Tenancy

1. Purpose

billing-service owns the financial ledger truth for Ghasi Melmastoon — the multi-tenant hotel SaaS platform whose backoffice is an Electron offline-first desktop app and whose cloud is GCP. The service combines two distinct sub-domains under one bounded context because they share the same ledger machinery, the same tax engine, the same invoice renderer, and the same audit posture:

  1. Guest Folio (per-reservation ledger) — every charge a guest accrues during a stay (room nights, taxes, fees, mini-bar, restaurant, laundry, additional services), every payment they make (cash, card, PayPal, MFS), every refund they receive, and the resulting tax-correct invoice. The folio is the operational truth that hotel staff close out at the front desk.
  2. Tenant Subscription Billing (platform's bill to the tenant) — every tenant's monthly bill for using Ghasi Melmastoon, computed from a flat plan or per-room-month plus metered AI / storage overage, generated as a SubscriptionInvoice, and dunned through current → grace → past_due → suspended if unpaid.

The service exists for three reasons that no other service can satisfy:

  1. Audit-grade ledger. Money facts must be append-only, tamper-evident, and survive seven years of regulatory retention. Every other service operates on summaries; we keep the line items.
  2. Schema-per-tenant for finance. Per ADR-0002, the platform default is shared-schema + RLS, but financial state (folios, payments, refunds, invoices) lives in tenant_<uuid>_billing schemas. This buys defensible audit, clean offboarding, and per-tenant PCI-adjacent scope without the operational cost of full schema-per-tenant across the estate.
  3. Cash on arrival is first-class on Electron. In Afghanistan / Tajikistan / Iran, cash dominates. The cash-drawer workflow — open with counted float, post receipts and refunds, close with two-staff sign-off — must be operationally first-class on the desktop, must reconcile against the folio, and must enforce the close-online invariant cryptographically.

2. Bounded context

Context name: Billing & Folio Domain class: Core (revenue-critical, regulated, longest retention, finance on-call) Ubiquitous language: Folio, FolioCharge, FolioPayment, FolioRefund, Invoice, InvoiceLine, CreditNote, Settlement, CashDrawer, CashDrawerSession, Reconciliation, Subscription, SubscriptionInvoice, UsageRecord, Plan, Meter, Dunning, TaxCode, TaxRule, TaxJurisdiction, FXSnapshot (reference, locked at confirm by pricing-service), Reservation (reference, owned by reservation-service), PaymentIntent / Payment (reference, owned by payment-gateway-service).

What is in:

  • Folio lifecycle (pending → open → balance_due → settled → closed, plus re_opened).
  • Charge and payment posting with multi-currency support.
  • Refund processing and credit-note issuance.
  • Tax computation per line via per-tenant tax engine.
  • Invoice rendering (multi-locale, multi-template, RTL).
  • Cash drawer session management and reconciliation.
  • Subscription cycle and dunning.
  • Usage metering aggregation.

What is out:

  • Money capture / refund execution at the processor → payment-gateway-service.
  • Price calculation, rate-plan rules, FX rate sourcing → pricing-service.
  • Reservation state, check-in / check-out triggers → reservation-service.
  • Tax-rate sourcing per jurisdiction (we hold per-tenant overrides; canonical rate tables live in tenant-service.taxRules) → tenant-service.
  • Notification delivery (we publish; delivery happens in) → notification-service.
  • Storage of PDFs / signed URLs → file-storage-service.
  • AI-only logic (anomaly detection) → calls ai-orchestrator-service via AIClient.

3. Aggregates owned

AggregateCardinalityPurposeIdentity prefixSchema
Folio1 per reservationLedger root for one stayfol_per-tenant
FolioCharge0..N per folioOne posted charge with computed taxchg_per-tenant
FolioPayment0..N per folioOne recorded payment (cash receipt or external link)fpm_per-tenant
FolioRefund0..N per folioOne processed refundfrd_per-tenant
Invoice0..N per folio (typically 1; re-issue makes a new one with prior void)Issued invoice documentinv_doc_per-tenant
InvoiceLine1..N per invoiceLine items with tax and locale labels(composite ULID)per-tenant
CreditNote0..N per invoiceCompensating doc for refund or correctioncnt_per-tenant
Settlement1 per closed folioFinal settlement summary (per-currency totals, FX, residual)set_per-tenant
CashDrawer1 per propertyDrawer registrycdr_per-tenant
CashDrawerSession0..N per drawerOne shift / session with float, receipts, closecds_per-tenant
Subscription1 per tenantPlatform subscription state machinesub_central
SubscriptionInvoice0..N per subscriptionMonthly platform invoice + dunning statesin_central
UsageRecordM per (tenantId, period, meter)Aggregated metered usageusg_central (partitioned)

4. Responsibilities (numbered)

  1. Open folio on melmastoon.reservation.confirmed.v1 if the tenant policy is eager, or on melmastoon.reservation.checked_in.v1 if deferred. Idempotent — duplicate events do not create a second folio.
  2. Post the rate-plan room-night charges at folio open (eager) or at first arrival night (deferred), seeded from the pricing-service snapshot pinned on the reservation.
  3. Compute tax per charge through the tax engine using (jurisdiction, taxCode, charge.date, customerClass); tax is stored on the charge row and recomputed only on credit-note or correction.
  4. Record cash payments posted from the desktop cash-drawer flow with metadata.cashSessionId, metadata.receivedBy, metadata.location. Cash is a first-class payment, not a degraded card path.
  5. Record external payments by consuming melmastoon.payment.transaction.captured.v1 and creating a FolioPayment linked to the paymentId.
  6. Process refunds — partial or full; emit FolioRefund and a CreditNote; call payment-gateway-service.refund(...) for the wire (the gateway is the executor).
  7. Close folio at checkout on melmastoon.reservation.checked_out.v1: tally totals, generate the Invoice, render the PDF, persist via file-storage-service, emit folio.closed.v1 and invoice.generated.v1.
  8. Re-open folio under supervisor override (MELMASTOON.BILLING.FOLIO_LOCKED cleared by an authorized actor); the original invoice is voided, a new one is issued on next close, and the audit chain records both.
  9. Multi-currency settlement — folios may carry charges in tenant currency and payments in guest currency; the FX snapshot pinned on the reservation governs settlement; per-currency residual is captured on Settlement.
  10. Cash drawer session management — open with counted float, post every cash receipt / refund into the session, close with two-staff sign-off online only, variance > tenant threshold raises cash_drawer.discrepancy_found.v1 and blocks the next session start until reconciled.
  11. Daily reconciliation — join cash session closes to folio cash payments per (propertyId, businessDate); emit MELMASTOON.BILLING.RECONCILIATION_MISMATCH on any deviation > threshold.
  12. Subscription billing cycle — monthly Cloud Run cron worker per tenant: aggregate UsageRecords for the period, compute SubscriptionInvoice, attempt charge via payment-gateway-service, drive dunning state machine on failure.
  13. Dunningcurrent → grace (7 d) → past_due (14 d) → suspended with notification triggers at each step; suspension emits melmastoon.billing.subscription.cancelled.v1 (with reason='past_due') which tenant-service consumes to set tenant.status='suspended'.
  14. Usage metering aggregation — consume room-activation, AI-completion, and storage-measurement events; aggregate into the partitioned usage_records table; expose usage.recorded.v1 snapshots monthly for the cycle.

5. Upstream / downstream context map

┌─────────────────────┐
│ reservation-service │ reservation lifecycle events
└──────────┬───────────┘
│ (confirmed.v1, checked_in.v1, checked_out.v1, cancelled.v1)

┌──────────────────────────┐
payment-gateway-svc ─▶│ │── invoice.generated.v1 ──▶ notification-service
(tx.captured.v1, │ billing-service │── subscription.payment_failed.v1
tx.refunded.v1) │ (folio + subscription) │── usage.recorded.v1 ─────▶ analytics-service
│ │── cash_drawer.discrepancy_found.v1 ──▶ audit-service
pricing-service ─────▶│ │
(rate-plan snapshot │ │── refund.requested ──▶ payment-gateway-service
on reservation) └──┬─────────┬──────┬─────┬┘
│ │ │ │
tenant-service ──────────┘ │ │ └── PDF render ──▶ file-storage-service
(tenant.created.v1, │ │
tax rules) │ └── ai inference ──▶ ai-orchestrator-service
│ (folio anomaly)
property-service ──────────────────┘
(room.activated.v1)
ai-orchestrator (completion.recorded.v1)
file-storage (bytes.measured.v1)

6. Key sequence: open folio → post charges → record payment → close + invoice

reservation-svc billing-svc payment-gateway-svc file-storage-svc notification-svc
│ │ │ │ │
│ reservation.confirmed │ │ │ │
├──────────────────────▶│ OpenFolioUseCase │ │ │
│ │ (tenant policy: eager) │ │ │
│ │ state: pending → open │ │ │
│ │ outbox: folio.opened.v1 │ │
│ │ │ │ │
│ reservation.checked_in│ │ │ │
├──────────────────────▶│ PostRoomNightChargesUseCase │ │ │
│ │ (per night, taxed) │ │ │
│ │ outbox: folio.charge_added.v1 (×N nights) │ │
│ │ │ │ │
│ ── (during stay) ─── add charges, restaurant, mini-bar, … │ │
│ │ │ │ │
│ │ payment.tx.captured.v1 ◀─────┤ (card capture) │ │
│ │ RecordExternalPayment │ │ │
│ │ outbox: folio.payment_recorded.v1 │ │
│ │ │ │ │
│ │ desktop POST /folios/:id/payments (cash) │ │
│ │ RecordCashPaymentUseCase │ │ │
│ │ outbox: folio.payment_recorded.v1 │ │
│ │ │ │ │
│ reservation.checked_out │ │ │
├──────────────────────▶│ CloseFolioUseCase │ │ │
│ │ state: open → settled → closed │ │
│ │ GenerateInvoiceUseCase │ │ │
│ │ render PDF (RTL Arabic, multi-currency) │ │
│ ├─────────────────────────────────────────────────────▶ │ persist │
│ │ ◀─ signedUrl │ │
│ │ outbox: folio.closed.v1, invoice.generated.v1 │
│ ├───────────────────────────────────────────────────────────────────────────▶ │ deliver
│ │ │ │ │ (RTL/LTR)

7. Key invariants enforced in the domain layer

  1. No cross-tenant references. Every aggregate carries TenantId; folio operations refuse a charge whose reservationId.tenantId != folio.tenantId. (MELMASTOON.GENERAL.CROSS_TENANT_REFERENCE)
  2. Folio balance is the algebraic sum Σ charges + Σ taxes − Σ payments + Σ refunds, computed in tenant currency after FX. The total is never stored as a denormalized field that can diverge — it is computed on every save and asserted equal to the per-currency line aggregates. (MELMASTOON.BILLING.RECONCILIATION_MISMATCH)
  3. A closed folio rejects charges, payments, refunds. Re-open required first; only roles with billing.folio.reopen may. (MELMASTOON.BILLING.FOLIO_LOCKED)
  4. A refund cannot exceed the folio's net captured balance. (MELMASTOON.BILLING.REFUND_EXCEEDS_BALANCE)
  5. An invoice is immutable once issued. Corrections issue a CreditNote plus optionally a new invoice; never an in-place edit. (MELMASTOON.BILLING.INVOICE_ALREADY_ISSUED)
  6. A charge without a resolvable tax rule is rejected. The tax engine must return either a positive rule, a zero-rule (e.g., tax holiday or exempt customer class), or an explicit no_rule for which the tenant configured allowUntaxed=true; otherwise MELMASTOON.BILLING.TAX_RULE_MISSING.
  7. A cash drawer session may not be closed offline. The close use case requires live iam-service step-up auth for the second operator and synchronous outbox publish. (MELMASTOON.BILLING.CASH_DRAWER_OFFLINE_CLOSE_FORBIDDEN)
  8. A new cash drawer session may not start while the previous session is in pending_close. (MELMASTOON.BILLING.CASH_DRAWER_PRIOR_SESSION_OPEN)
  9. Cash variance over the tenant threshold blocks close until acknowledged by both signers with a written reason. The session still closes (we never lose money state) but the next session start is blocked until a reconciliation acknowledgement is filed.
  10. Subscription dunning state transitions are monotone forward except suspended → current via explicit reactivation. No silent state retreat.
  11. OCC version checked on every aggregate save. (MELMASTOON.GENERAL.PRECONDITION_FAILED)
  12. Sharia-compliant tenants (tenant.settings.payments.shariaCompliant=true) cannot have late-fee charges with kind='interest'; the domain rejects them and the invoice template strips any "finance charge" wording. (See 10 Payments §9.)

8. Hot read paths

ReadFrequencyCaching strategy
Active in-house folios for a propertyevery 60 s per active operatorMemorystore bil:in_house:<tenantId>:<propertyId>, TTL 60 s, invalidated on folio.opened.v1, folio.closed.v1, payment_recorded.v1
Folio balance by idhighPostgres primary lookup; no cache (must be authoritative)
Daily cash session for active drawerevery 30 s during shiftMemorystore TTL 30 s
Subscription state for tenantper-request from BFFMemorystore TTL 5 min, invalidated on cycle / reactivation
Invoice PDFlowfile-storage-service signed URL, valid 5 min

9. Cost & scale envelope

DimensionTarget
Folios per tenant per day1 (smallest guesthouse) → 1,500 (large chain property)
Charges per foliotypical 5–20; group-stay outliers up to 200
Active concurrent open folios per tenant1× tenant in-house count (typically ≤ 1,000)
Subscription invoices per cycle1 per active tenant per month
Usage records per cycle per tenantup to ~3,000 (rooms × days + AI events + storage snapshots)
Invoice generation p95< 2 s
Cash drawer close p95< 5 s
Cloud Run min replicas (hot path)3
Subscription cycle workerCloud Run cron monthly (1st of month, per-tenant fanout)
Per-tenant schema migration jobtriggered on tenant.created.v1; runs templated DDL bundle
Cloud SQL Postgresdedicated high-IO instance (financial workload) with HA + PITR

10. Decision log (anchors)

  • Why one service for both folio and subscription billing — they share the tax engine, the invoice renderer, the dunning state machine, the audit posture, and the on-call rotation. Splitting them would duplicate every utility for a thin separation; instead we segregate by schema (per-tenant for folio, central for subscription) and by RBAC scope.
  • Why schema-per-tenant for guest folio (not shared + RLS) — financial audit and DSAR offboarding are dramatically cleaner against a single schema; PCI-adjacent reasoning extends here even though we don't store cardholder data; per-tenant retention pins are bookkeeping-free. (See ADR-0002 §2.)
  • Why subscription billing uses a central schema — there are no per-tenant audit benefits to schema isolation for our own invoices to the tenant; cross-tenant queries are essential for platform finance ops; central is correct.
  • Why cash drawer close is online-only — the close requires step-up auth for the second operator (validated against iam-service) and immediate outbox publish for the audit chain; offering offline close would break both. Sessions stay open as pending_close until connectivity returns.
  • Why we recompute folio balance on every save — denormalized totals diverge under partial failures; a small CPU cost per save buys an invariant the audit can rely on.
  • Why cash-on-arrival is first-class — see 10 Payments §1 and §7. In our markets, requiring online capture would block the funnel.
  • Why we replicate folios to Electron for active stays — front-desk staff add charges (mini-bar, laundry, restaurant) and record cash payments through 5-hour blackouts. (See SYNC_CONTRACT.)
  • Why invoices are immutable once issued — tax authorities forbid in-place edits in most of our jurisdictions; the credit-note pattern is universal and preserves the audit chain.

11. What this service depends on (libraries, ports, infrastructure)

  • NestJS for presentation + DI composition root (out of the domain layer).
  • Drizzle ORM for Postgres access in the infrastructure layer (per-schema dynamic search_path).
  • @google-cloud/pubsub for outbox publishing.
  • @react-pdf/renderer (or equivalent) for invoice PDF rendering.
  • puppeteer-core is not used — PDFs are pure-JS rendered to keep Cloud Run images small and avoid Chromium fork.
  • Ports the application layer depends on (interfaces only):
    • FolioRepository, InvoiceRepository, CreditNoteRepository, CashDrawerRepository, SubscriptionRepository, UsageRecordRepository
    • EventPublisher (outbox-backed)
    • Clock, IdGenerator
    • TaxEngine (per-tenant-rule resolver; calls tenant-service settings + tax-rule cache)
    • PaymentClient (calls payment-gateway-service for refund execution + balance reconciliation)
    • ReservationClient (read-only; resolves reservation header data for invoice rendering)
    • PricingClient (reads the rate-plan snapshot pinned on the reservation)
    • FileStorageClient (uploads PDFs and gets signed URLs)
    • NotificationClient (publishes invoice.send requests; doesn't deliver)
    • IdentityClient (validates the second operator's step-up auth at cash drawer close)
    • AIClient (anomaly detection on folio charge patterns; suggestion-only)

The domain layer depends on nothing outside @ghasi/domain-primitives and the standard library. CI fails the build on any framework or I/O import inside src/domain/.

12. References