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
- Refund amount ≤ remaining refundable on Payment.
- Invoice total = sum(lines) + sum(taxes) - sum(discounts).
- Subscription state transitions idempotent on Stripe webhook replay.
- Payout amount ≤ provider's outstanding earnings balance.
4. Domain Events
billing.subscription.created.v1,.changed.v1,.canceled.v1,.paused.v1,.resumed.v1billing.invoice.created.v1,.finalized.v1,.paid.v1,.voided.v1billing.payment.succeeded.v1,.failed.v1,.refunded.v1billing.dunning.started.v1,.stage_advanced.v1,.resolved.v1billing.payout.initiated.v1,.completed.v1,.failed.v1billing.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.