Skip to main content

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-Key dedupe (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:

  1. Verify folio belongs to tenant; verify refund ≤ net captured (Folio.recordRefund enforces).
  2. Create FolioRefund row.
  3. Call paymentClient.refund({ paymentId, amount, reason }) — the gateway returns acknowledged|executed synchronously and emits payment.transaction.refunded.v1 async; we tolerate either ordering via inbox dedupe.
  4. Issue a CreditNote referencing the original invoice line(s).
  5. 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

  1. Build InvoiceLines from the folio charges (group by taxCode + currency + description).
  2. Pick template from tenant.settings.billing.invoiceTemplates[customer.class].
  3. Pick locale from customer.preferredLocale ?? tenant.defaultLocale.
  4. Call the PdfRenderer (pure-JS) — output a buffer.
  5. fileStorage.upload({ bucket: 'billing-invoices-<tenantId>', key: '<yyyy>/<mm>/<invoiceNumber>.pdf', buffer }).
  6. invoice.attachPdf(uri).
  7. 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:

  1. Validate the source invoice is not voided.
  2. Build CreditNoteLines referencing original InvoiceLines with negative amounts.
  3. Allocate a credit-note number from the per-tenant sequence (CN-<jurisdiction>-<seq>).
  4. Render PDF, upload, attach URI.
  5. Outbox CreditNoteGeneratedV1.

11. Use case: OpenCashSession & FinalizeCashSessionClose

OpenCashSession:

  1. Tenant-scoped query for any session in open|pending_close|reconciliation_blocked status on the same drawer; reject if found (CASH_DRAWER_PRIOR_SESSION_OPEN).
  2. Create CashDrawerSession.open(...).
  3. Outbox CashDrawerOpenedV1.

InitiateCashSessionClose (called from desktop):

  1. Mutate session to pending_close with counted closing float and closing actor.
  2. Persist; no event emitted yet.

FinalizeCashSessionClose (online-only):

  1. Verify connectivity (SyncOrchestrator.online).
  2. Step-up auth check on co-signer via IdentityClient.verifyStepUp({ actor: coSigner, scope: 'billing.cash_drawer.close' }).
  3. Call session.finalizeClose(coSigner, online=true, varianceThreshold, clock).
  4. Persist; outbox CashDrawerClosedV1 and (conditionally) CashDrawerDiscrepancyFoundV1.
  5. If reconciliation_blocked, the next OpenCashSession will refuse until a supervisor calls POST /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):

  1. Sum cash payments on the folio per business date in the tenant schema.
  2. Sum cash session receipts in the tenant schema.
  3. Compare; on deviation > threshold, raise an audit event and a notification to the property manager.
  4. 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:

  1. Aggregate usage records for the closing period.
  2. Optional hard-cap check: if any meter.usage > meter.hardCap, call subscription.suspend('hard_cap_exceeded') first.
  3. subscription.generateInvoice(period, usage, clock).
  4. Persist invoice; render PDF; attach URI.
  5. Charge attempt: paymentClient.charge({ tenantId, subscriptionInvoiceId, amount, currency, paymentMethodToken }).
  6. On success → record payment via RecordSubscriptionPaymentResult('success').
  7. 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

SurfaceIdempotency mechanism
Public POST endpointsIdempotency-Key header → inbox row keyed req:<tenantId>:<route>:<key> (24 h TTL)
Event consumersInbox key inbox:billing:<event-type>:<eventId> (7 d TTL)
OCCWHERE id = $1 AND version = $2 on UPDATE; mismatch → PRECONDITION_FAILED
OutboxSingle 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 errorHTTPBody code
MELMASTOON.BILLING.FOLIO_LOCKED409BILLING_FOLIO_LOCKED
MELMASTOON.BILLING.CHARGE_INVALID422BILLING_CHARGE_INVALID
MELMASTOON.BILLING.REFUND_EXCEEDS_BALANCE422BILLING_REFUND_EXCEEDS_BALANCE
MELMASTOON.BILLING.BALANCE_DUE409BILLING_BALANCE_DUE
MELMASTOON.BILLING.TAX_RULE_MISSING422BILLING_TAX_RULE_MISSING
MELMASTOON.BILLING.CASH_DRAWER_OFFLINE_CLOSE_FORBIDDEN409BILLING_CASH_DRAWER_OFFLINE_CLOSE_FORBIDDEN
MELMASTOON.BILLING.CASH_DRAWER_PRIOR_SESSION_OPEN409BILLING_CASH_DRAWER_PRIOR_SESSION_OPEN
MELMASTOON.BILLING.SHARIA_COMPLIANT_VIOLATION422BILLING_SHARIA_COMPLIANT_VIOLATION
MELMASTOON.BILLING.SUBSCRIPTION_INVALID_TRANSITION409BILLING_SUBSCRIPTION_INVALID_TRANSITION
MELMASTOON.GENERAL.PRECONDITION_FAILED412PRECONDITION_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 eventType per 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.