APPLICATION_LOGIC — billing-service
Use cases that orchestrate the DOMAIN_MODEL aggregates against the ports declared in SERVICE_OVERVIEW §11. Every public application method runs inside an Inbox dedupe (for events) or
Idempotency-Keydedupe (for commands), within a single transaction over the per-tenant schema (folio sub-domain) or the central schema (subscription sub-domain), and writes to the outbox in the same transaction.
1. Module map
src/application/
├── use-cases/
│ folio/
│ open-folio.use-case.ts
│ post-charge.use-case.ts
│ record-cash-payment.use-case.ts
│ record-external-payment.use-case.ts
│ record-refund.use-case.ts
│ close-folio.use-case.ts
│ reopen-folio.use-case.ts
│ invoice/
│ generate-invoice.use-case.ts
│ send-invoice.use-case.ts
│ issue-credit-note.use-case.ts
│ cash-drawer/
│ open-cash-session.use-case.ts
│ record-cash-receipt.use-case.ts (internal — invoked by record-cash-payment)
│ initiate-cash-session-close.use-case.ts
│ finalize-cash-session-close.use-case.ts
│ reconcile-day.use-case.ts
│ subscription/
│ initialize-subscription.use-case.ts
│ record-usage.use-case.ts
│ run-subscription-cycle.use-case.ts
│ record-subscription-payment-result.use-case.ts
│ reactivate-subscription.use-case.ts
├── event-handlers/
│ on-reservation-confirmed.handler.ts
│ on-reservation-checked-in.handler.ts
│ on-reservation-checked-out.handler.ts
│ on-reservation-cancelled.handler.ts
│ on-payment-captured.handler.ts
│ on-payment-refunded.handler.ts
│ on-tenant-created.handler.ts
│ on-room-activated.handler.ts
│ on-ai-completion-recorded.handler.ts
│ on-storage-bytes-measured.handler.ts
└── ports/ (re-exports from src/domain/ports)
2. UoW & transactionality
All write use cases follow the same transactional shape:
// src/application/shared/transactional-use-case.ts
export abstract class TransactionalUseCase<I, O> {
constructor(
protected readonly uow: UnitOfWork, // wraps Drizzle transaction; sets search_path per tenant
protected readonly outbox: OutboxWriter,
protected readonly inbox: InboxStore,
protected readonly clock: Clock,
protected readonly logger: Logger,
) {}
protected async run(idem: IdempotencyToken, body: () => Promise<O>): Promise<O> {
return this.uow.transaction(async () => {
const cached = await this.inbox.lookup(idem);
if (cached) return cached as O;
const out = await body();
await this.inbox.commit(idem, out);
return out;
});
}
}
UnitOfWork.transaction opens a Drizzle tx and, for folio writes, sets SET LOCAL search_path = "tenant_<uuid>_billing", public; before the body. For subscription writes, it leaves the default public, billing_central path. The outbox row is written in the same transaction with published_at = NULL; a Cloud Run worker drains it to Pub/Sub.
3. Use case: OpenFolio
export class OpenFolioUseCase extends TransactionalUseCase<OpenFolioInput, OpenFolioOutput> {
constructor(
private readonly folios: FolioRepository,
private readonly reservations: ReservationClient,
private readonly pricing: PricingClient,
private readonly ids: IdGenerator,
/* base ctor */
) { super(/*…*/); }
async execute(input: OpenFolioInput, idem: IdempotencyToken): Promise<OpenFolioOutput> {
return this.run(idem, async () => {
const existing = await this.folios.findByReservationId(input.tenantId, input.reservationId);
if (existing) return { folioId: existing.id.value, alreadyExists: true };
const reservation = await this.reservations.get(input.tenantId, input.reservationId);
const snapshot = await this.pricing.getRatePlanSnapshot(input.tenantId, reservation.ratePlanSnapshotId);
const folio = Folio.open({
id: FolioId.from(this.ids.generate('fol_')),
tenantId: input.tenantId,
propertyId: reservation.propertyId,
reservationId: input.reservationId,
currency: reservation.currency,
fxSnapshot: snapshot.fxSnapshot,
clock: this.clock,
});
await this.folios.save(folio);
const events = folio.pullEvents();
await this.outbox.append(events);
return { folioId: folio.id.value, alreadyExists: false };
});
}
}
Triggered by: OnReservationConfirmedHandler (eager mode), OnReservationCheckedInHandler (deferred mode), or POST /folios from the BFF.
4. Use case: PostCharge
export class PostChargeUseCase extends TransactionalUseCase<PostChargeInput, PostChargeOutput> {
constructor(
private readonly folios: FolioRepository,
private readonly tax: TaxEngine,
private readonly tenantSettings: TenantSettingsClient, // resolves Sharia flag, allowUntaxed
private readonly ids: IdGenerator,
/* base */
) { super(/*…*/); }
async execute(input: PostChargeInput, idem: IdempotencyToken): Promise<PostChargeOutput> {
return this.run(idem, async () => {
const folio = await this.folios.getById(input.tenantId, FolioId.from(input.folioId));
if (!folio) throw new AppError('NOT_FOUND', 'Folio not found');
const settings = await this.tenantSettings.get(input.tenantId);
if (settings.shariaCompliant && input.kind === 'late_fee' && input.feeKind === 'interest')
throw new DomainError('MELMASTOON.BILLING.SHARIA_COMPLIANT_VIOLATION');
const gross = Money.of(input.unitPriceMicro * BigInt(input.quantity), input.currency);
const taxRule = await this.tax.resolve({
tenantId: input.tenantId, propertyId: folio.propertyId, taxCode: input.taxCode,
chargeDate: input.postedAt ?? this.clock.now(), customerClass: input.customerClass,
});
if (!taxRule && !settings.allowUntaxed)
throw new DomainError('MELMASTOON.BILLING.TAX_RULE_MISSING');
const taxLine = taxRule ? this.tax.computeLine(gross, taxRule) : TaxLine.zero(input.taxCode, input.currency);
const charge = new FolioCharge(
ChargeId.from(this.ids.generate('chg_')), input.tenantId, input.kind,
input.description, input.quantity, Money.of(input.unitPriceMicro, input.currency),
gross, taxLine, input.taxCode, input.postedAt ?? this.clock.now(),
input.actor, input.source,
);
folio.postCharge(charge);
await this.folios.save(folio);
await this.outbox.append(folio.pullEvents());
return { chargeId: charge.id.value, taxAmountMicro: taxLine.amount.amountMicro };
});
}
}
5. Use case: RecordCashPayment (desktop hot path)
export class RecordCashPaymentUseCase extends TransactionalUseCase<RecordCashPaymentInput, RecordCashPaymentOutput> {
constructor(
private readonly folios: FolioRepository,
private readonly sessions: CashDrawerSessionRepository,
private readonly ids: IdGenerator,
/* base */
) { super(/*…*/); }
async execute(input: RecordCashPaymentInput, idem: IdempotencyToken): Promise<RecordCashPaymentOutput> {
return this.run(idem, async () => {
const folio = await this.folios.getById(input.tenantId, FolioId.from(input.folioId));
const session = await this.sessions.getById(input.tenantId, CashSessionId.from(input.cashSessionId));
if (session.status !== 'open')
throw new DomainError('MELMASTOON.BILLING.CASH_SESSION_NOT_OPEN');
const payment = new FolioPayment(
PaymentId.from(this.ids.generate('fpm_')),
'cash',
Money.of(input.amountMicro, input.currency),
null,
session.id,
this.clock.now(),
input.actor,
{ propertyId: folio.propertyId.value, drawerId: session.drawerId.value },
);
folio.recordPayment(payment);
session.postReceipt(folio.id, payment.id, payment.amount, input.actor, this.clock);
await this.folios.save(folio);
await this.sessions.save(session);
await this.outbox.append([...folio.pullEvents(), ...session.pullEvents()]);
return { paymentId: payment.id.value };
});
}
}
6. Use case: RecordExternalPayment (consumes payment-gateway event)
Triggered by OnPaymentCapturedHandler. Resolves the folio via payment.metadata.folioId, creates a FolioPayment linked to paymentId, dedupes by inbox key inbox:billing:payment.captured.v1:<paymentId>. If the payment metadata refers to a subscriptionInvoiceId instead, the handler routes to RecordSubscriptionPaymentResult (success branch) — see §11.
7. Use case: RecordRefund
Performs the refund execution sequence:
- Verify folio belongs to tenant; verify refund ≤ net captured (
Folio.recordRefundenforces). - Create
FolioRefundrow. - Call
paymentClient.refund({ paymentId, amount, reason })— the gateway returnsacknowledged|executedsynchronously and emitspayment.transaction.refunded.v1async; we tolerate either ordering via inbox dedupe. - Issue a
CreditNotereferencing the original invoice line(s). - Append all events.
If the gateway returns executed=false after acknowledged, our OnPaymentRefundedHandler reconciles. If the gateway hard-fails (MELMASTOON.PAYMENT.PROCESSOR_DECLINED), the use case rolls back the folio refund and surfaces the error to the caller — no orphaned credit note.
8. Use case: CloseFolio
export class CloseFolioUseCase extends TransactionalUseCase<CloseFolioInput, CloseFolioOutput> {
constructor(
private readonly folios: FolioRepository,
private readonly invoiceUseCase: GenerateInvoiceUseCase,
/* base */
) { super(/*…*/); }
async execute(input: CloseFolioInput, idem: IdempotencyToken): Promise<CloseFolioOutput> {
return this.run(idem, async () => {
const folio = await this.folios.getById(input.tenantId, FolioId.from(input.folioId));
const settlement = folio.close(this.clock); // throws BALANCE_DUE if owed
await this.folios.save(folio);
const invoice = await this.invoiceUseCase.executeInline(folio, settlement, input.actor);
await this.outbox.append([...folio.pullEvents(), ...invoice.pullEvents()]);
return { folioId: folio.id.value, invoiceId: invoice.id.value, status: 'closed' };
});
}
}
GenerateInvoiceUseCase.executeInline is a pure-domain helper used in the same transaction; the public GenerateInvoiceUseCase.execute exists too, for re-issue scenarios.
9. Use case: GenerateInvoice
- Build
InvoiceLines from the folio charges (group bytaxCode + currency + description). - Pick template from
tenant.settings.billing.invoiceTemplates[customer.class]. - Pick locale from
customer.preferredLocale ?? tenant.defaultLocale. - Call the PdfRenderer (pure-JS) — output a buffer.
fileStorage.upload({ bucket: 'billing-invoices-<tenantId>', key: '<yyyy>/<mm>/<invoiceNumber>.pdf', buffer }).invoice.attachPdf(uri).- Outbox
InvoiceGeneratedV1.
PDF render is wrapped in a circuit breaker; failure raises MELMASTOON.BILLING.INVOICE_RENDER_FAILED which sends the close path to the deferred-invoice queue — the folio remains closed but the invoice generation is retried by a Cloud Run Job. The desktop UI surfaces "invoice pending" with a retry button.
10. Use case: IssueCreditNote
Required when:
- A refund is processed (auto-issued).
- A guest disputes a charge (manual, via supervisor).
- Tax is recalculated post-issue (rare; locale-specific).
Issuance:
- Validate the source invoice is not voided.
- Build
CreditNoteLines referencing originalInvoiceLines with negative amounts. - Allocate a credit-note number from the per-tenant sequence (
CN-<jurisdiction>-<seq>). - Render PDF, upload, attach URI.
- Outbox
CreditNoteGeneratedV1.
11. Use case: OpenCashSession & FinalizeCashSessionClose
OpenCashSession:
- Tenant-scoped query for any session in
open|pending_close|reconciliation_blockedstatus on the same drawer; reject if found (CASH_DRAWER_PRIOR_SESSION_OPEN). - Create
CashDrawerSession.open(...). - Outbox
CashDrawerOpenedV1.
InitiateCashSessionClose (called from desktop):
- Mutate session to
pending_closewith counted closing float and closing actor. - Persist; no event emitted yet.
FinalizeCashSessionClose (online-only):
- Verify connectivity (
SyncOrchestrator.online). - Step-up auth check on co-signer via
IdentityClient.verifyStepUp({ actor: coSigner, scope: 'billing.cash_drawer.close' }). - Call
session.finalizeClose(coSigner, online=true, varianceThreshold, clock). - Persist; outbox
CashDrawerClosedV1and (conditionally)CashDrawerDiscrepancyFoundV1. - If
reconciliation_blocked, the nextOpenCashSessionwill refuse until a supervisor callsPOST /cash-sessions/:id/acknowledge-discrepancy(use case omitted here for brevity; trivial state mutation + audit event).
12. Use case: ReconcileDay
A Cloud Run Job triggered by Cloud Scheduler nightly per (tenantId, propertyId, businessDate):
- Sum cash payments on the folio per business date in the tenant schema.
- Sum cash session receipts in the tenant schema.
- Compare; on deviation > threshold, raise an audit event and a notification to the property manager.
- Write the reconciliation summary into
daily_cash_reconciliations(per-tenant table).
13. Subscription use cases
InitializeSubscription — triggered by OnTenantCreatedHandler. Reads the chosen plan from the tenant onboarding payload (default STARTER_PER_ROOM). Calls Subscription.create. Outboxes SubscriptionCreatedV1.
RecordUsage — bulk write helper used by the three usage handlers (on-room-activated, on-ai-completion-recorded, on-storage-bytes-measured). Increments the partitioned usage_records row for (tenantId, period, meter). Optionally outboxes UsageRecordedV1 once per (tenantId, meter) per cycle for downstream analytics.
RunSubscriptionCycle — Cloud Run Job invoked monthly by Cloud Scheduler with a per-tenant fanout. For each tenant whose cycleAnchor == today.day and state != cancelled:
- Aggregate usage records for the closing period.
- Optional hard-cap check: if any
meter.usage > meter.hardCap, callsubscription.suspend('hard_cap_exceeded')first. subscription.generateInvoice(period, usage, clock).- Persist invoice; render PDF; attach URI.
- Charge attempt:
paymentClient.charge({ tenantId, subscriptionInvoiceId, amount, currency, paymentMethodToken }). - On success → record payment via
RecordSubscriptionPaymentResult('success'). - On failure →
subscription.recordPaymentFailure(...); on subsequent cycle if still failing →suspend('past_due').
RecordSubscriptionPaymentResult — invoked from the OnPaymentCaptured / OnPaymentRefunded handlers when the metadata indicates a subscription invoice. Updates invoice state, emits notification triggers, may transition dunning state.
ReactivateSubscription — platform-admin-only API. Verifies the suspended subscription has a valid payment method on file, then calls subscription.reactivate(clock).
14. Concurrency & idempotency model
| Surface | Idempotency mechanism |
|---|---|
Public POST endpoints | Idempotency-Key header → inbox row keyed req:<tenantId>:<route>:<key> (24 h TTL) |
| Event consumers | Inbox key inbox:billing:<event-type>:<eventId> (7 d TTL) |
| OCC | WHERE id = $1 AND version = $2 on UPDATE; mismatch → PRECONDITION_FAILED |
| Outbox | Single row per emit; drainer is at-least-once with messageId dedupe at consumer side |
Concurrent staff editing the same folio (front-desk + restaurant POS posting simultaneously): both reads see the same version; the second write fails with OCC; the application layer retries up to 3 times with a 10 ms exponential backoff before surfacing the conflict to the caller. The desktop renders "another change happened — refresh and re-apply".
15. Error → HTTP mapping (selected)
| Domain error | HTTP | Body code |
|---|---|---|
MELMASTOON.BILLING.FOLIO_LOCKED | 409 | BILLING_FOLIO_LOCKED |
MELMASTOON.BILLING.CHARGE_INVALID | 422 | BILLING_CHARGE_INVALID |
MELMASTOON.BILLING.REFUND_EXCEEDS_BALANCE | 422 | BILLING_REFUND_EXCEEDS_BALANCE |
MELMASTOON.BILLING.BALANCE_DUE | 409 | BILLING_BALANCE_DUE |
MELMASTOON.BILLING.TAX_RULE_MISSING | 422 | BILLING_TAX_RULE_MISSING |
MELMASTOON.BILLING.CASH_DRAWER_OFFLINE_CLOSE_FORBIDDEN | 409 | BILLING_CASH_DRAWER_OFFLINE_CLOSE_FORBIDDEN |
MELMASTOON.BILLING.CASH_DRAWER_PRIOR_SESSION_OPEN | 409 | BILLING_CASH_DRAWER_PRIOR_SESSION_OPEN |
MELMASTOON.BILLING.SHARIA_COMPLIANT_VIOLATION | 422 | BILLING_SHARIA_COMPLIANT_VIOLATION |
MELMASTOON.BILLING.SUBSCRIPTION_INVALID_TRANSITION | 409 | BILLING_SUBSCRIPTION_INVALID_TRANSITION |
MELMASTOON.GENERAL.PRECONDITION_FAILED | 412 | PRECONDITION_FAILED |
16. Outbox & Pub/Sub
The outbox writer table _outbox is per-schema (per-tenant for folio events, central for subscription events). The drainer is a Cloud Run service with min 1 replica that:
- claims rows with
SELECT … FOR UPDATE SKIP LOCKED LIMIT 100; - builds Pub/Sub messages with attributes
tenantId,eventType,traceId,aggregateId; - publishes to topics derived from
eventTypeper 04-event-driven-architecture §3; - on success, sets
published_at = now(); - on Pub/Sub failure, leaves the row; metrics surface drainer lag.
For hot path latency, the inline outbox writer also enqueues a "wake" hint that flips the drainer to immediate poll.
17. Observability hooks (see OBSERVABILITY)
Every use case opens a span billing.usecase.<name> with attributes tenantId, aggregateId, idempotencyKey, actorId. Domain errors are recorded with event=domain_error, code=<MELMASTOON…>. Latency histograms feed the SLOs.