Skip to main content

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

#InvariantWhere enforcedError
1All references same tenantFolio.postCharge, Subscription.createMELMASTOON.GENERAL.CROSS_TENANT_REFERENCE
2Folio balance computed, never stored as denormalized fieldFolio.balanceInFolioCurrency()n/a (compile-time)
3Closed folio rejects mutationsFolio.postCharge, recordPayment, recordRefundMELMASTOON.BILLING.FOLIO_LOCKED
4Refund ≤ net capturedFolio.recordRefundMELMASTOON.BILLING.REFUND_EXCEEDS_BALANCE
5Invoice immutable; void + reissue onlyInvoice.void, CreditNote.issueMELMASTOON.BILLING.INVOICE_ALREADY_ISSUED
6Charge requires tax rule (or explicit allowUntaxed)PostChargeUseCase (calls TaxEngine)MELMASTOON.BILLING.TAX_RULE_MISSING
7Cash session close online-onlyCashDrawerSession.finalizeCloseMELMASTOON.BILLING.CASH_DRAWER_OFFLINE_CLOSE_FORBIDDEN
8Co-signer differs from closing actorCashDrawerSession.finalizeCloseMELMASTOON.BILLING.CASH_DRAWER_COSIGNER_MUST_DIFFER
9New session blocked while previous in pending_close or reconciliation_blockedOpenCashSessionUseCaseMELMASTOON.BILLING.CASH_DRAWER_PRIOR_SESSION_OPEN
10Cash payment requires cashSessionId; non-cash requires externalPaymentIdFolioPayment ctorMELMASTOON.BILLING.CASH_SESSION_REQUIRED / EXTERNAL_PAYMENT_REQUIRED
11Subscription dunning monotoneSubscription.recordPaymentFailure, suspendMELMASTOON.BILLING.SUBSCRIPTION_INVALID_TRANSITION
12Sharia-compliant tenants reject interest-kind chargesPostChargeUseCaseMELMASTOON.BILLING.SHARIA_COMPLIANT_VIOLATION
13OCC version on saverepositoriesMELMASTOON.GENERAL.PRECONDITION_FAILED
14All Money operations same currencyMoney.assertSameMELMASTOON.BILLING.CURRENCY_MISMATCH
15FX conversion requires snapshot covering currencyFXSnapshot.toBase/fromBaseMELMASTOON.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:domain boots in ~80ms across 600+ unit tests with zero infra.
  • Portability. Domain code runs unchanged in Cloud Run, in the Electron billing.engine worker, and in the daily reconciliation Cloud Run Job. The same Money and FXSnapshot math 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.