DOMAIN_MODEL — billing-service
Companion to SERVICE_OVERVIEW. Pure-TypeScript domain layer. No framework, ORM, HTTP, Pub/Sub, or filesystem imports allowed in
src/domain/. CI gates this rule.
The model is split across two sub-domains that share value objects and DomainEvent plumbing but expose separate aggregate roots:
- Folio sub-domain (per-tenant schema):
Folio(root),FolioCharge,FolioPayment,FolioRefund,Invoice(root),InvoiceLine,CreditNote(root),Settlement,CashDrawer(root),CashDrawerSession. - Subscription sub-domain (central schema):
Subscription(root),SubscriptionInvoice,UsageRecord,Plan,Meter.
1. Shared value objects
// src/domain/shared/value-objects/money.ts
import { z } from 'zod';
export const ISO4217 = z.enum(['AFN','USD','EUR','PKR','SAR','AED','TJS','IRR','GBP','TRY']);
export type ISO4217 = z.infer<typeof ISO4217>;
export class Money {
private constructor(
readonly amountMicro: bigint,
readonly currency: ISO4217,
) {}
static of(amountMicro: bigint, currency: ISO4217): Money {
if (amountMicro < 0n) throw new DomainError('MELMASTOON.BILLING.NEGATIVE_AMOUNT');
return new Money(amountMicro, currency);
}
static zero(currency: ISO4217): Money { return new Money(0n, currency); }
add(other: Money): Money { this.assertSame(other); return Money.of(this.amountMicro + other.amountMicro, this.currency); }
sub(other: Money): Money { this.assertSame(other); return Money.of(this.amountMicro - other.amountMicro, this.currency); }
mulFraction(numerator: bigint, denominator: bigint): Money {
return Money.of((this.amountMicro * numerator) / denominator, this.currency);
}
isZero(): boolean { return this.amountMicro === 0n; }
isNegative(): boolean { return this.amountMicro < 0n; }
private assertSame(o: Money) {
if (o.currency !== this.currency) throw new DomainError('MELMASTOON.BILLING.CURRENCY_MISMATCH');
}
}
// src/domain/shared/value-objects/fx-snapshot.ts
export class FXSnapshot {
constructor(
readonly baseCurrency: ISO4217,
readonly rates: ReadonlyMap<ISO4217, bigint>, // micro-units per 1 base unit (1e6)
readonly takenAt: Date,
readonly source: string, // 'pricing-service:rate-plan-snapshot'
) {}
convert(amount: Money, target: ISO4217): Money {
if (amount.currency === target) return amount;
if (amount.currency !== this.baseCurrency) {
const inBase = this.toBase(amount);
return this.fromBase(inBase, target);
}
return this.fromBase(amount, target);
}
private toBase(amount: Money): Money {
const rate = this.rates.get(amount.currency);
if (!rate) throw new DomainError('MELMASTOON.BILLING.FX_RATE_MISSING');
return Money.of((amount.amountMicro * 1_000_000n) / rate, this.baseCurrency);
}
private fromBase(base: Money, target: ISO4217): Money {
if (target === this.baseCurrency) return base;
const rate = this.rates.get(target);
if (!rate) throw new DomainError('MELMASTOON.BILLING.FX_RATE_MISSING');
return Money.of((base.amountMicro * rate) / 1_000_000n, target);
}
}
// src/domain/shared/value-objects/tax.ts
export class TaxCode { constructor(readonly value: string) {} } // e.g., 'VAT_STANDARD', 'VAT_ZERO', 'CITY_TAX'
export class TaxRate { constructor(readonly numerator: bigint, readonly denominator: bigint) {} }
export class TaxLine {
constructor(readonly code: TaxCode, readonly rate: TaxRate, readonly amount: Money, readonly jurisdiction: string) {}
}
2. Folio aggregate
// src/domain/folio/folio.aggregate.ts
export type FolioStatus = 'pending'|'open'|'balance_due'|'settled'|'closed'|'re_opened';
export class Folio {
private _events: DomainEvent[] = [];
private constructor(
readonly id: FolioId,
readonly tenantId: TenantId,
readonly propertyId: PropertyId,
readonly reservationId: ReservationId,
readonly currency: ISO4217,
readonly fxSnapshot: FXSnapshot,
private _status: FolioStatus,
private _charges: FolioCharge[],
private _payments: FolioPayment[],
private _refunds: FolioRefund[],
readonly openedAt: Date,
private _closedAt: Date | null,
private _version: number,
) {}
static open(input: {
id: FolioId; tenantId: TenantId; propertyId: PropertyId; reservationId: ReservationId;
currency: ISO4217; fxSnapshot: FXSnapshot; clock: Clock;
}): Folio {
const f = new Folio(input.id, input.tenantId, input.propertyId, input.reservationId,
input.currency, input.fxSnapshot, 'open', [], [], [], input.clock.now(), null, 1);
f._events.push(new FolioOpenedV1({
folioId: f.id, tenantId: f.tenantId, propertyId: f.propertyId,
reservationId: f.reservationId, currency: f.currency, openedAt: f.openedAt,
}));
return f;
}
postCharge(charge: FolioCharge): void {
if (this.isClosed()) throw new DomainError('MELMASTOON.BILLING.FOLIO_LOCKED');
if (charge.tenantId.value !== this.tenantId.value)
throw new DomainError('MELMASTOON.GENERAL.CROSS_TENANT_REFERENCE');
this._charges.push(charge);
this._version++;
this._events.push(new FolioChargeAddedV1({
folioId: this.id, chargeId: charge.id, tenantId: this.tenantId,
kind: charge.kind, gross: charge.gross, tax: charge.tax, postedAt: charge.postedAt,
}));
}
recordPayment(payment: FolioPayment): void {
if (this.isClosed()) throw new DomainError('MELMASTOON.BILLING.FOLIO_LOCKED');
if (payment.amount.isZero()) throw new DomainError('MELMASTOON.BILLING.PAYMENT_ZERO_AMOUNT');
this._payments.push(payment);
this._version++;
this._events.push(new FolioPaymentRecordedV1({
folioId: this.id, paymentId: payment.id, method: payment.method,
amount: payment.amount, externalPaymentId: payment.externalPaymentId,
cashSessionId: payment.cashSessionId, recordedAt: payment.recordedAt,
}));
}
recordRefund(refund: FolioRefund): void {
if (this.isClosed()) throw new DomainError('MELMASTOON.BILLING.FOLIO_LOCKED');
const balance = this.netCaptured();
if (refund.amount.amountMicro > balance.amountMicro)
throw new DomainError('MELMASTOON.BILLING.REFUND_EXCEEDS_BALANCE');
this._refunds.push(refund);
this._version++;
this._events.push(new FolioRefundRecordedV1({
folioId: this.id, refundId: refund.id, amount: refund.amount,
reason: refund.reason, refundedAt: refund.refundedAt,
}));
}
close(clock: Clock): Settlement {
if (this._status === 'closed') throw new DomainError('MELMASTOON.BILLING.FOLIO_ALREADY_CLOSED');
const balance = this.balanceInFolioCurrency();
if (balance.amountMicro > 0n) {
this._status = 'balance_due';
this._events.push(new FolioBalanceDueV1({ folioId: this.id, balance, attemptedAt: clock.now() }));
throw new DomainError('MELMASTOON.BILLING.BALANCE_DUE');
}
this._status = 'closed';
this._closedAt = clock.now();
this._version++;
const settlement = Settlement.from(this);
this._events.push(new FolioClosedV1({
folioId: this.id, totals: settlement.perCurrencyTotals,
residual: settlement.residual, closedAt: this._closedAt,
}));
return settlement;
}
reopen(actor: ActorId, reason: string, clock: Clock): void {
if (this._status !== 'closed') throw new DomainError('MELMASTOON.BILLING.FOLIO_NOT_CLOSED');
this._status = 're_opened';
this._closedAt = null;
this._version++;
this._events.push(new FolioReopenedV1({ folioId: this.id, by: actor, reason, at: clock.now() }));
}
// ---- Computed views (no setters) ----
balanceInFolioCurrency(): Money {
const charges = this._charges.reduce((acc, c) => acc.add(this.fxSnapshot.convert(c.gross, this.currency)).add(this.fxSnapshot.convert(c.tax.amount, this.currency)), Money.zero(this.currency));
const payments = this._payments.reduce((acc, p) => acc.add(this.fxSnapshot.convert(p.amount, this.currency)), Money.zero(this.currency));
const refunds = this._refunds.reduce((acc, r) => acc.add(this.fxSnapshot.convert(r.amount, this.currency)), Money.zero(this.currency));
return charges.sub(payments).add(refunds);
}
netCaptured(): Money {
const payments = this._payments.reduce((acc, p) => acc.add(this.fxSnapshot.convert(p.amount, this.currency)), Money.zero(this.currency));
const refunds = this._refunds.reduce((acc, r) => acc.add(this.fxSnapshot.convert(r.amount, this.currency)), Money.zero(this.currency));
return payments.sub(refunds);
}
isClosed(): boolean { return this._status === 'closed'; }
pullEvents(): DomainEvent[] { const e = this._events; this._events = []; return e; }
}
3. Folio entities
// src/domain/folio/folio-charge.entity.ts
export type ChargeKind = 'room_night'|'tax'|'fee'|'mini_bar'|'restaurant'|'laundry'|'service'|'adjustment'|'late_fee';
export class FolioCharge {
constructor(
readonly id: ChargeId,
readonly tenantId: TenantId,
readonly kind: ChargeKind,
readonly description: { default: string; locales: Record<string,string> },
readonly quantity: number,
readonly unitPrice: Money,
readonly gross: Money, // quantity × unitPrice
readonly tax: TaxLine, // computed at post time
readonly taxCode: TaxCode,
readonly postedAt: Date,
readonly postedBy: ActorId,
readonly source: { kind: 'rate_plan'|'pos'|'manual'|'event'; ref?: string },
) {
if (quantity <= 0) throw new DomainError('MELMASTOON.BILLING.CHARGE_INVALID');
if (unitPrice.isNegative()) throw new DomainError('MELMASTOON.BILLING.CHARGE_INVALID');
}
}
// src/domain/folio/folio-payment.entity.ts
export type PaymentMethod = 'cash'|'card'|'paypal'|'mfs'|'bank_transfer'|'on_account';
export class FolioPayment {
constructor(
readonly id: PaymentId,
readonly method: PaymentMethod,
readonly amount: Money,
readonly externalPaymentId: string | null, // payment-gateway-service paymentId
readonly cashSessionId: CashSessionId | null,
readonly recordedAt: Date,
readonly recordedBy: ActorId,
readonly metadata: Readonly<Record<string,string>>,
) {
if (amount.isZero()) throw new DomainError('MELMASTOON.BILLING.PAYMENT_ZERO_AMOUNT');
if (method === 'cash' && !cashSessionId)
throw new DomainError('MELMASTOON.BILLING.CASH_SESSION_REQUIRED');
if (method !== 'cash' && method !== 'on_account' && !externalPaymentId)
throw new DomainError('MELMASTOON.BILLING.EXTERNAL_PAYMENT_REQUIRED');
}
}
// src/domain/folio/folio-refund.entity.ts
export class FolioRefund {
constructor(
readonly id: RefundId,
readonly amount: Money,
readonly reason: string,
readonly externalRefundId: string | null,
readonly cashSessionId: CashSessionId | null,
readonly refundedAt: Date,
readonly refundedBy: ActorId,
) {}
}
4. Invoice & credit-note aggregates
// src/domain/invoice/invoice.aggregate.ts
export class Invoice {
private _events: DomainEvent[] = [];
private constructor(
readonly id: InvoiceId,
readonly tenantId: TenantId,
readonly folioId: FolioId,
readonly number: InvoiceNumber, // tenant-jurisdiction-sequenced
readonly customer: InvoiceCustomer,
readonly lines: ReadonlyArray<InvoiceLine>,
readonly currency: ISO4217,
readonly subtotal: Money,
readonly taxTotal: Money,
readonly grandTotal: Money,
readonly locale: string,
readonly template: InvoiceTemplate, // 'standard'|'government'|'corporate'|'agent'|'sharia'
readonly issuedAt: Date,
private _voidedAt: Date | null,
private _voidReason: string | null,
readonly pdfUri: FileUri | null,
) {}
static issue(input: { /* ... */ }): Invoice { /* compute totals, freeze */ }
attachPdf(uri: FileUri): void { /* sets pdfUri, emits InvoiceGeneratedV1 */ }
void(reason: string, clock: Clock): void {
if (this._voidedAt) throw new DomainError('MELMASTOON.BILLING.INVOICE_ALREADY_VOIDED');
this._voidedAt = clock.now(); this._voidReason = reason;
this._events.push(new InvoiceVoidedV1({ invoiceId: this.id, reason, voidedAt: this._voidedAt! }));
}
isVoided(): boolean { return this._voidedAt !== null; }
}
// src/domain/invoice/credit-note.aggregate.ts
export class CreditNote {
static issue(input: {
id: CreditNoteId; tenantId: TenantId; invoiceId: InvoiceId;
lines: CreditNoteLine[]; reason: string; issuedAt: Date;
}): CreditNote { /* freezes, emits CreditNoteGeneratedV1 */ }
}
5. CashDrawer & CashDrawerSession aggregate
// src/domain/cash-drawer/cash-drawer.aggregate.ts
export class CashDrawer {
constructor(readonly id: CashDrawerId, readonly tenantId: TenantId, readonly propertyId: PropertyId, readonly currency: ISO4217) {}
}
export type SessionStatus = 'open'|'pending_close'|'closed'|'reconciliation_blocked';
export class CashDrawerSession {
private _events: DomainEvent[] = [];
private constructor(
readonly id: CashSessionId,
readonly drawerId: CashDrawerId,
readonly tenantId: TenantId,
readonly openedBy: ActorId,
readonly openingFloat: Money,
private _status: SessionStatus,
private _receipts: SessionReceipt[],
private _refunds: SessionRefund[],
private _closingActor: ActorId | null,
private _coSigner: ActorId | null,
private _closingFloat: Money | null,
private _variance: Money | null,
readonly openedAt: Date,
private _closedAt: Date | null,
private _version: number,
) {}
static open(input: { id: CashSessionId; drawerId: CashDrawerId; tenantId: TenantId; openedBy: ActorId; openingFloat: Money; clock: Clock }): CashDrawerSession {
const s = new CashDrawerSession(input.id, input.drawerId, input.tenantId, input.openedBy, input.openingFloat, 'open', [], [], null, null, null, null, input.clock.now(), null, 1);
s._events.push(new CashDrawerOpenedV1({ sessionId: s.id, drawerId: s.drawerId, openedBy: s.openedBy, openingFloat: s.openingFloat, openedAt: s.openedAt }));
return s;
}
postReceipt(folioId: FolioId, paymentId: PaymentId, amount: Money, by: ActorId, clock: Clock): void {
if (this._status !== 'open') throw new DomainError('MELMASTOON.BILLING.CASH_SESSION_NOT_OPEN');
this._receipts.push({ folioId, paymentId, amount, by, at: clock.now() });
this._version++;
}
initiateClose(by: ActorId, countedClosingFloat: Money, clock: Clock): void {
if (this._status !== 'open') throw new DomainError('MELMASTOON.BILLING.CASH_SESSION_NOT_OPEN');
this._closingActor = by;
this._closingFloat = countedClosingFloat;
this._status = 'pending_close';
this._version++;
}
finalizeClose(coSigner: ActorId, online: boolean, varianceThresholdMicro: bigint, clock: Clock): void {
if (this._status !== 'pending_close') throw new DomainError('MELMASTOON.BILLING.CASH_SESSION_NOT_PENDING_CLOSE');
if (!online) throw new DomainError('MELMASTOON.BILLING.CASH_DRAWER_OFFLINE_CLOSE_FORBIDDEN');
if (coSigner.value === this._closingActor!.value)
throw new DomainError('MELMASTOON.BILLING.CASH_DRAWER_COSIGNER_MUST_DIFFER');
const expected = this.expectedClosingFloat();
const variance = this._closingFloat!.sub(expected);
this._variance = variance;
this._coSigner = coSigner;
this._closedAt = clock.now();
const overThreshold = abs(variance.amountMicro) > varianceThresholdMicro;
this._status = overThreshold ? 'reconciliation_blocked' : 'closed';
this._version++;
this._events.push(new CashDrawerClosedV1({ sessionId: this.id, expected, counted: this._closingFloat!, variance, closedBy: this._closingActor!, coSigner, closedAt: this._closedAt! }));
if (overThreshold) {
this._events.push(new CashDrawerDiscrepancyFoundV1({ sessionId: this.id, variance, threshold: varianceThresholdMicro }));
}
}
expectedClosingFloat(): Money {
const recv = this._receipts.reduce((a,r) => a.add(r.amount), Money.zero(this.openingFloat.currency));
const refs = this._refunds.reduce((a,r) => a.add(r.amount), Money.zero(this.openingFloat.currency));
return this.openingFloat.add(recv).sub(refs);
}
}
6. Subscription aggregate
// src/domain/subscription/subscription.aggregate.ts
export type DunningState = 'current'|'grace'|'past_due'|'suspended'|'cancelled';
export class Subscription {
private _events: DomainEvent[] = [];
private constructor(
readonly id: SubscriptionId,
readonly tenantId: TenantId,
readonly plan: Plan,
private _state: DunningState,
readonly currency: ISO4217,
readonly cycleAnchor: number, // day of month, 1..28
private _gracePeriodDays: number,
private _hardCapMicro: bigint | null,
readonly createdAt: Date,
private _suspendedAt: Date | null,
private _version: number,
) {}
static create(input: { id: SubscriptionId; tenantId: TenantId; plan: Plan; currency: ISO4217; cycleAnchor: number; clock: Clock }): Subscription {
const s = new Subscription(input.id, input.tenantId, input.plan, 'current', input.currency, input.cycleAnchor, 7, null, input.clock.now(), null, 1);
s._events.push(new SubscriptionCreatedV1({ subscriptionId: s.id, tenantId: s.tenantId, plan: s.plan, createdAt: s.createdAt }));
return s;
}
generateInvoice(period: BillingPeriod, usage: ReadonlyArray<UsageRecord>, clock: Clock): SubscriptionInvoice {
const lines = this.plan.compute(period, usage);
const inv = SubscriptionInvoice.from({
tenantId: this.tenantId, subscriptionId: this.id, period, lines,
currency: this.currency, issuedAt: clock.now(),
});
this._events.push(new SubscriptionInvoiceGeneratedV1({ subscriptionId: this.id, invoice: inv.summary() }));
return inv;
}
recordPaymentFailure(invoiceId: SubscriptionInvoiceId, clock: Clock): void {
if (this._state === 'current') this._state = 'grace';
else if (this._state === 'grace' && this.daysSince(this._suspendedAt ?? clock.now()) >= this._gracePeriodDays) this._state = 'past_due';
this._version++;
this._events.push(new SubscriptionPaymentFailedV1({ subscriptionId: this.id, invoiceId, state: this._state, at: clock.now() }));
}
suspend(reason: 'past_due'|'hard_cap_exceeded', clock: Clock): void {
if (this._state === 'cancelled') throw new DomainError('MELMASTOON.BILLING.SUBSCRIPTION_ALREADY_CANCELLED');
this._state = 'suspended';
this._suspendedAt = clock.now();
this._version++;
this._events.push(new SubscriptionCancelledV1({ subscriptionId: this.id, reason, at: this._suspendedAt }));
}
reactivate(clock: Clock): void {
if (this._state !== 'suspended') throw new DomainError('MELMASTOON.BILLING.SUBSCRIPTION_NOT_SUSPENDED');
this._state = 'current';
this._suspendedAt = null;
this._version++;
this._events.push(new SubscriptionReactivatedV1({ subscriptionId: this.id, at: clock.now() }));
}
}
export class Plan {
constructor(
readonly code: string,
readonly base: { kind: 'flat'|'per_room_month'; amount: Money; perRoomAmount?: Money },
readonly meters: ReadonlyArray<{ meter: 'ai_tokens'|'storage_bytes'; included: bigint; overageUnit: Money; perUnits: bigint }>,
) {}
compute(period: BillingPeriod, usage: ReadonlyArray<UsageRecord>): SubscriptionInvoiceLine[] { /* ... */ return []; }
}
7. Domain events (signatures only — payloads in EVENT_SCHEMAS)
export class FolioOpenedV1 extends DomainEvent {}
export class FolioChargeAddedV1 extends DomainEvent {}
export class FolioPaymentRecordedV1 extends DomainEvent {}
export class FolioRefundRecordedV1 extends DomainEvent {}
export class FolioClosedV1 extends DomainEvent {}
export class FolioBalanceDueV1 extends DomainEvent {}
export class FolioReopenedV1 extends DomainEvent {}
export class InvoiceGeneratedV1 extends DomainEvent {}
export class InvoiceSentV1 extends DomainEvent {}
export class InvoiceVoidedV1 extends DomainEvent {}
export class CreditNoteGeneratedV1 extends DomainEvent {}
export class CashDrawerOpenedV1 extends DomainEvent {}
export class CashDrawerClosedV1 extends DomainEvent {}
export class CashDrawerDiscrepancyFoundV1 extends DomainEvent {}
export class SubscriptionCreatedV1 extends DomainEvent {}
export class SubscriptionInvoiceGeneratedV1 extends DomainEvent {}
export class SubscriptionPaymentFailedV1 extends DomainEvent {}
export class SubscriptionCancelledV1 extends DomainEvent {}
export class SubscriptionReactivatedV1 extends DomainEvent {}
export class UsageRecordedV1 extends DomainEvent {}
8. Invariant matrix
| # | Invariant | Where enforced | Error |
|---|---|---|---|
| 1 | All references same tenant | Folio.postCharge, Subscription.create | MELMASTOON.GENERAL.CROSS_TENANT_REFERENCE |
| 2 | Folio balance computed, never stored as denormalized field | Folio.balanceInFolioCurrency() | n/a (compile-time) |
| 3 | Closed folio rejects mutations | Folio.postCharge, recordPayment, recordRefund | MELMASTOON.BILLING.FOLIO_LOCKED |
| 4 | Refund ≤ net captured | Folio.recordRefund | MELMASTOON.BILLING.REFUND_EXCEEDS_BALANCE |
| 5 | Invoice immutable; void + reissue only | Invoice.void, CreditNote.issue | MELMASTOON.BILLING.INVOICE_ALREADY_ISSUED |
| 6 | Charge requires tax rule (or explicit allowUntaxed) | PostChargeUseCase (calls TaxEngine) | MELMASTOON.BILLING.TAX_RULE_MISSING |
| 7 | Cash session close online-only | CashDrawerSession.finalizeClose | MELMASTOON.BILLING.CASH_DRAWER_OFFLINE_CLOSE_FORBIDDEN |
| 8 | Co-signer differs from closing actor | CashDrawerSession.finalizeClose | MELMASTOON.BILLING.CASH_DRAWER_COSIGNER_MUST_DIFFER |
| 9 | New session blocked while previous in pending_close or reconciliation_blocked | OpenCashSessionUseCase | MELMASTOON.BILLING.CASH_DRAWER_PRIOR_SESSION_OPEN |
| 10 | Cash payment requires cashSessionId; non-cash requires externalPaymentId | FolioPayment ctor | MELMASTOON.BILLING.CASH_SESSION_REQUIRED / EXTERNAL_PAYMENT_REQUIRED |
| 11 | Subscription dunning monotone | Subscription.recordPaymentFailure, suspend | MELMASTOON.BILLING.SUBSCRIPTION_INVALID_TRANSITION |
| 12 | Sharia-compliant tenants reject interest-kind charges | PostChargeUseCase | MELMASTOON.BILLING.SHARIA_COMPLIANT_VIOLATION |
| 13 | OCC version on save | repositories | MELMASTOON.GENERAL.PRECONDITION_FAILED |
| 14 | All Money operations same currency | Money.assertSame | MELMASTOON.BILLING.CURRENCY_MISMATCH |
| 15 | FX conversion requires snapshot covering currency | FXSnapshot.toBase/fromBase | MELMASTOON.BILLING.FX_RATE_MISSING |
9. Why pure TypeScript
- Auditability. A regulator can read
Folio.close()end-to-end without a NestJS map. - Testability.
npm run test:domainboots in ~80ms across 600+ unit tests with zero infra. - Portability. Domain code runs unchanged in Cloud Run, in the Electron
billing.engineworker, and in the daily reconciliation Cloud Run Job. The sameMoneyandFXSnapshotmath is the source of truth in all three contexts. - Refactor safety. Compile errors flag any framework leak into the domain because TS imports become unsatisfiable.