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:
- 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.
- 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 throughcurrent → grace → past_due → suspendedif unpaid.
The service exists for three reasons that no other service can satisfy:
- 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.
- 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>_billingschemas. This buys defensible audit, clean offboarding, and per-tenant PCI-adjacent scope without the operational cost of full schema-per-tenant across the estate. - 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, plusre_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-serviceviaAIClient.
3. Aggregates owned
| Aggregate | Cardinality | Purpose | Identity prefix | Schema |
|---|---|---|---|---|
Folio | 1 per reservation | Ledger root for one stay | fol_ | per-tenant |
FolioCharge | 0..N per folio | One posted charge with computed tax | chg_ | per-tenant |
FolioPayment | 0..N per folio | One recorded payment (cash receipt or external link) | fpm_ | per-tenant |
FolioRefund | 0..N per folio | One processed refund | frd_ | per-tenant |
Invoice | 0..N per folio (typically 1; re-issue makes a new one with prior void) | Issued invoice document | inv_doc_ | per-tenant |
InvoiceLine | 1..N per invoice | Line items with tax and locale labels | (composite ULID) | per-tenant |
CreditNote | 0..N per invoice | Compensating doc for refund or correction | cnt_ | per-tenant |
Settlement | 1 per closed folio | Final settlement summary (per-currency totals, FX, residual) | set_ | per-tenant |
CashDrawer | 1 per property | Drawer registry | cdr_ | per-tenant |
CashDrawerSession | 0..N per drawer | One shift / session with float, receipts, close | cds_ | per-tenant |
Subscription | 1 per tenant | Platform subscription state machine | sub_ | central |
SubscriptionInvoice | 0..N per subscription | Monthly platform invoice + dunning state | sin_ | central |
UsageRecord | M per (tenantId, period, meter) | Aggregated metered usage | usg_ | central (partitioned) |
4. Responsibilities (numbered)
- Open folio on
melmastoon.reservation.confirmed.v1if the tenant policy iseager, or onmelmastoon.reservation.checked_in.v1ifdeferred. Idempotent — duplicate events do not create a second folio. - Post the rate-plan room-night charges at folio open (eager) or at first arrival night (deferred), seeded from the
pricing-servicesnapshot pinned on the reservation. - 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. - 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. - Record external payments by consuming
melmastoon.payment.transaction.captured.v1and creating aFolioPaymentlinked to thepaymentId. - Process refunds — partial or full; emit
FolioRefundand aCreditNote; callpayment-gateway-service.refund(...)for the wire (the gateway is the executor). - Close folio at checkout on
melmastoon.reservation.checked_out.v1: tally totals, generate theInvoice, render the PDF, persist viafile-storage-service, emitfolio.closed.v1andinvoice.generated.v1. - Re-open folio under supervisor override (
MELMASTOON.BILLING.FOLIO_LOCKEDcleared by an authorized actor); the original invoice is voided, a new one is issued on next close, and the audit chain records both. - 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. - 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.v1and blocks the next session start until reconciled. - Daily reconciliation — join cash session closes to folio cash payments per
(propertyId, businessDate); emitMELMASTOON.BILLING.RECONCILIATION_MISMATCHon any deviation > threshold. - Subscription billing cycle — monthly Cloud Run cron worker per tenant: aggregate
UsageRecords for the period, computeSubscriptionInvoice, attempt charge viapayment-gateway-service, drive dunning state machine on failure. - Dunning —
current → grace (7 d) → past_due (14 d) → suspendedwith notification triggers at each step; suspension emitsmelmastoon.billing.subscription.cancelled.v1(withreason='past_due') whichtenant-serviceconsumes to settenant.status='suspended'. - Usage metering aggregation — consume room-activation, AI-completion, and storage-measurement events; aggregate into the partitioned
usage_recordstable; exposeusage.recorded.v1snapshots 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
- No cross-tenant references. Every aggregate carries
TenantId; folio operations refuse a charge whosereservationId.tenantId != folio.tenantId. (MELMASTOON.GENERAL.CROSS_TENANT_REFERENCE) - 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) - A closed folio rejects charges, payments, refunds. Re-open required first; only roles with
billing.folio.reopenmay. (MELMASTOON.BILLING.FOLIO_LOCKED) - A refund cannot exceed the folio's net captured balance. (
MELMASTOON.BILLING.REFUND_EXCEEDS_BALANCE) - An invoice is immutable once issued. Corrections issue a
CreditNoteplus optionally a new invoice; never an in-place edit. (MELMASTOON.BILLING.INVOICE_ALREADY_ISSUED) - 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_rulefor which the tenant configuredallowUntaxed=true; otherwiseMELMASTOON.BILLING.TAX_RULE_MISSING. - A cash drawer session may not be closed offline. The close use case requires live
iam-servicestep-up auth for the second operator and synchronous outbox publish. (MELMASTOON.BILLING.CASH_DRAWER_OFFLINE_CLOSE_FORBIDDEN) - A new cash drawer session may not start while the previous session is in
pending_close. (MELMASTOON.BILLING.CASH_DRAWER_PRIOR_SESSION_OPEN) - 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.
- Subscription dunning state transitions are monotone forward except
suspended → currentvia explicit reactivation. No silent state retreat. - OCC version checked on every aggregate save. (
MELMASTOON.GENERAL.PRECONDITION_FAILED) - Sharia-compliant tenants (
tenant.settings.payments.shariaCompliant=true) cannot have late-fee charges withkind='interest'; the domain rejects them and the invoice template strips any "finance charge" wording. (See 10 Payments §9.)
8. Hot read paths
| Read | Frequency | Caching strategy |
|---|---|---|
| Active in-house folios for a property | every 60 s per active operator | Memorystore bil:in_house:<tenantId>:<propertyId>, TTL 60 s, invalidated on folio.opened.v1, folio.closed.v1, payment_recorded.v1 |
| Folio balance by id | high | Postgres primary lookup; no cache (must be authoritative) |
| Daily cash session for active drawer | every 30 s during shift | Memorystore TTL 30 s |
| Subscription state for tenant | per-request from BFF | Memorystore TTL 5 min, invalidated on cycle / reactivation |
| Invoice PDF | low | file-storage-service signed URL, valid 5 min |
9. Cost & scale envelope
| Dimension | Target |
|---|---|
| Folios per tenant per day | 1 (smallest guesthouse) → 1,500 (large chain property) |
| Charges per folio | typical 5–20; group-stay outliers up to 200 |
| Active concurrent open folios per tenant | 1× tenant in-house count (typically ≤ 1,000) |
| Subscription invoices per cycle | 1 per active tenant per month |
| Usage records per cycle per tenant | up 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 worker | Cloud Run cron monthly (1st of month, per-tenant fanout) |
| Per-tenant schema migration job | triggered on tenant.created.v1; runs templated DDL bundle |
| Cloud SQL Postgres | dedicated 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 aspending_closeuntil 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/pubsubfor outbox publishing.@react-pdf/renderer(or equivalent) for invoice PDF rendering.puppeteer-coreis 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,UsageRecordRepositoryEventPublisher(outbox-backed)Clock,IdGeneratorTaxEngine(per-tenant-rule resolver; callstenant-servicesettings + tax-rule cache)PaymentClient(callspayment-gateway-servicefor 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(publishesinvoice.sendrequests; 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
- Folio mechanics anchor: 10 Payments Architecture §6 Booking Flow Money Model
- Cash drawer & daily close: 10 Payments §7
- Tax engine semantics: 10 Payments §10
- Refund policy engine: 10 Payments §11
- Reconciliation (settlement reports): 10 Payments §12
- Schema-per-tenant rationale: ADR-0002 §2
- Booking saga interactions: 04 §7
- API conventions: 05 API Design
- Naming, error codes: standards/NAMING.md, standards/ERROR_CODES.md (BILLING)