Skip to main content

Domain Model

:::info Source Sourced from services/billing-service/DOMAIN_MODEL.md in the documentation repo. :::

1. Aggregates

Subscription (root)

type SubscriptionId = Branded<string, 'SubscriptionId'>;
interface Subscription {
id: SubscriptionId;
tenantId: TenantId;
planId: string;
state: 'trialing' | 'active' | 'past_due' | 'canceled' | 'paused';
currentPeriod: { start: ISODate; end: ISODate };
trialEnd?: ISODate;
cancelAt?: ISODate;
itemQuantities: Record<string, number>;
}

Invoice (root)

type InvoiceId = Branded<string, 'InvoiceId'>;
interface Invoice {
id: InvoiceId;
tenantId: TenantId;
customerId: string;
lines: InvoiceLine[];
subtotals: Money;
taxes: TaxLine[];
total: Money;
currency: ISO4217;
status: 'draft' | 'open' | 'paid' | 'void' | 'uncollectible';
dueAt: ISODate;
pdfUrl?: string;
}

Payment (root)

type PaymentId = Branded<string, 'PaymentId'>;
interface Payment {
id: PaymentId;
invoiceId?: InvoiceId;
orderId?: OrderId;
tenantId: TenantId;
amount: Money;
currency: ISO4217;
processor: string; // 'stripe'
processorRef: string; // Stripe PaymentIntent ID
status: 'requires_action' | 'pending' | 'succeeded' | 'failed' | 'refunded';
}

DunningProcess

interface DunningProcess {
id: ULID;
subscriptionId: SubscriptionId;
tenantId: TenantId;
startedAt: ISODate;
stage: 'reminder_1' | 'reminder_2' | 'reminder_3' | 'suspension_pending' | 'suspended';
nextAttemptAt?: ISODate;
completedAt?: ISODate;
resolution?: 'paid' | 'suspended' | 'canceled' | 'manual';
}

Payout / PayoutBatch

interface Payout { id: ULID; providerTenantId: TenantId; amount: Money; periodStart: ISODate; periodEnd: ISODate; status: 'pending' | 'initiated' | 'completed' | 'failed'; processor: string; processorRef?: string; }
interface PayoutBatch { id: ULID; runAt: ISODate; payouts: ULID[]; status: string; }

2. State Machines

Subscription: trialing → active → past_due → canceled | paused
↑_______________________| (resume)
Payment: pending → requires_action → succeeded | failed → refunded
Invoice: draft → open → paid | void | uncollectible
Dunning: reminder_1 → reminder_2 → reminder_3 → suspension_pending → suspended
↓ (payment on any stage)
resolved (paid)

3. Invariants

  1. Refund amount ≤ remaining refundable on Payment.
  2. Invoice total = sum(lines) + sum(taxes) - sum(discounts).
  3. Subscription state transitions idempotent on Stripe webhook replay.
  4. Payout amount ≤ provider's outstanding earnings balance.

4. Domain Events

  • billing.subscription.created.v1, .changed.v1, .canceled.v1, .paused.v1, .resumed.v1
  • billing.invoice.created.v1, .finalized.v1, .paid.v1, .voided.v1
  • billing.payment.succeeded.v1, .failed.v1, .refunded.v1
  • billing.dunning.started.v1, .stage_advanced.v1, .resolved.v1
  • billing.payout.initiated.v1, .completed.v1, .failed.v1
  • billing.tax.calculated.v1

5. Diagram

marketplace.order.placed.v1 ──▶ billing


Create PaymentIntent (Stripe)


Stripe confirms → webhook


Payment.succeeded → emit


marketplace saga continues

Subscriptions: nightly billing job creates invoices → PaymentIntent → succeeded/failed → dunning if failed.
Payouts: weekly job computes provider earnings → Stripe Connect payout → reconciliation.