The Billing module gives fullstackhero a working subscription-billing skeleton: a global catalogue of plans, per-tenant subscriptions driven by the tenant lifecycle (create + renew), a Draft → Issued → Paid invoice state machine, usage metering with overage rates, and a monthly Hangfire job that generates usage invoices for every active tenant. It’s not a payment gateway (no Stripe SDK, no card webhooks), it’s the invoice + subscription primitives every SaaS needs before it integrates a gateway.
What ships in v10
- A global plan catalogue (
BillingPlan) —Key, name, currency, monthly base price, billing interval (MonthlyorYearlywith optional flatAnnualPrice), per-resource overage rates keyed byQuotaResource. Plans are platform-wide (IGlobalEntity), so the same catalogue serves every tenant. - Tenant-lifecycle wiring — creating a tenant publishes
TenantSubscribedIntegrationEventand renewing one publishesTenantRenewedIntegrationEvent(both from the Multitenancy module); Billing’s handlers react by starting/replacing the active subscription and issuing the term’s subscription invoice. Invoice creation is idempotent (guarded by invoice number), and expiry pastValidUptois grace-windowed viaBilling:GraceWindowDays. - Per-tenant subscriptions with a small state machine — Active → Suspended → Active or Active → Cancelled. At most one Active subscription per tenant; assigning a plan cancels the prior active subscription.
- An invoice aggregate with a clean state machine: Draft → Issued → (Paid | Void). Line items are owned; subtotals recompute on add; period (year, month) is immutable. Every invoice carries a
Purpose—Subscription(plan term, created on tenant create/renew) orUsage(metered overage, created by the monthly job) — so the two streams never collide on idempotency keys. - Usage metering —
UsageSnapshotrows record what each tenant consumed in a window. The monthly job reads these against the subscribed plan’s overage rates to add overage line items to the next invoice. - A monthly Hangfire recurring job (
billing-monthly-invoices) registered at module startup. Runs on cron5 0 1 * *UTC — 00:05 UTC on the 1st of each month — and generates a Draft usage invoice per active tenant for the previous period. - Email notifications — issuing an invoice raises
InvoiceIssuedIntegrationEvent, and a daily tenant-expiry scan raises nearing-expiry / entered-grace / expired events; the Notifications module emails the tenant admin. Expiry events are deduped so a tenant is notified once per state per validity window. - On-demand PDF invoices —
GET /invoices/{id}/pdfrenders the invoice to a PDF (QuestPDF, behind a swappableIInvoicePdfRenderer). The fetch is scoped to the caller’s tenant, so the same endpoint serves operators and tenant self-service; another tenant’s id returns404. - 16 endpoints at
/api/v1/billing/...covering plans, subscriptions, invoices, usage, and PDF download. Two coarse permissions:Billing.ViewandBilling.Manage.
Architecture at a glance
src/Modules/Billing/├── Modules.Billing/│ ├── BillingModule.cs IModule entry — order 500│ ├── Domain/│ │ ├── BillingPlan.cs Global plan (IGlobalEntity)│ │ ├── Subscription.cs Per-tenant subscription + state machine│ │ ├── Invoice.cs AggregateRoot — state machine│ │ ├── InvoiceLineItem.cs Owned by Invoice│ │ └── UsageSnapshot.cs Metering record│ ├── Data/│ │ ├── BillingDbContext.cs Schema: billing — NOT per-tenant│ │ ├── Configurations/ EF type configs│ │ └── BillingDbInitializer.cs Seeds default plans│ ├── Features/v1/│ │ ├── Plans/ 3 endpoints (+ GetPlanTerm internal query)│ │ ├── Subscriptions/ 3 endpoints│ │ ├── Invoices/ 8 endpoints (state machine + PDF)│ │ └── Usage/ 2 endpoints│ ├── IntegrationEventHandlers/│ │ ├── TenantSubscribedIntegrationEventHandler.cs│ │ └── TenantRenewedIntegrationEventHandler.cs│ └── Services/│ ├── BillingService.cs Invoice generation + transitions│ ├── UsageReporter.cs IUsageReporter implementation│ ├── InvoicePdfRenderer.cs IInvoicePdfRenderer (QuestPDF)│ └── MonthlyInvoiceJob.cs Hangfire recurring job└── Modules.Billing.Contracts/ Public commands/queries/eventsThe module loads at order 500 so it boots after Identity (100) and Multitenancy (200) but before consuming modules like Catalog (600).
The invoice state machine
Invoice is the load-bearing aggregate. Its lifecycle is small but strict:
public sealed class Invoice : AggregateRoot<Guid>{ public InvoiceStatus Status { get; private set; } // Draft | Issued | Paid | Void public InvoicePurpose Purpose { get; private set; } // Subscription | Usage
public static Invoice CreateDraft( string tenantId, string invoiceNumber, int periodYear, int periodMonth, string currency, InvoicePurpose purpose, DateTime? periodStartUtc, DateTime? periodEndUtc) { /* ... */ }
public InvoiceLineItem AddLineItem( InvoiceLineItemKind kind, string description, decimal quantity, decimal unitPrice) { RequireStatus(InvoiceStatus.Draft); // only Draft accepts line items // ... recalculates SubtotalAmount }
public void Issue(DateTime? dueAtUtc = null) { RequireStatus(InvoiceStatus.Draft); Status = InvoiceStatus.Issued; IssuedAtUtc = DateTime.UtcNow; DueAtUtc = dueAtUtc ?? IssuedAtUtc.Value.AddDays(14); }
public void MarkPaid() { if (Status is InvoiceStatus.Paid) return; // idempotent if (Status is not InvoiceStatus.Issued) throw new InvalidOperationException($"Cannot mark invoice as paid from status {Status}."); Status = InvoiceStatus.Paid; PaidAtUtc = DateTime.UtcNow; }
public void Void(string? reason = null) { if (Status is InvoiceStatus.Paid) throw new InvalidOperationException("Paid invoices cannot be voided."); if (Status is InvoiceStatus.Void) return; // idempotent Status = InvoiceStatus.Void; VoidedAtUtc = DateTime.UtcNow; }}Each transition enforces the source state — you can’t void a Paid invoice, can’t MarkPaid a Draft, can’t add line items to an Issued invoice. Re-paying a Paid invoice and re-voiding a Void one are deliberate no-ops (idempotent); genuinely invalid transitions throw InvalidOperationException. Enum values like Status serialize as strings in API responses ("Issued", not 1) — the host registers a global JsonStringEnumConverter.
Public API
The contracts assembly exposes every command, query, response, and enum that callers can use without referencing the runtime module.
Plans
| Type | Purpose |
|---|---|
CreatePlanCommand(key, name, currency, monthlyBasePrice, overageRates?, interval, annualPrice?) | Admin: define a new plan |
UpdatePlanCommand(planId, name, monthlyBasePrice, overageRates?, interval, annualPrice?) | Admin: re-price an existing plan |
GetPlansQuery(includeInactive?) | List plans (active only by default) |
GetPlanTermQuery(planKey) | Resolve a plan’s id + term length — used by Multitenancy’s renew flow |
Subscriptions
| Type | Purpose |
|---|---|
AssignSubscriptionCommand(tenantId, planKey) | Move a tenant onto a plan (cancels + replaces the active one) |
GetSubscriptionQuery(tenantId?) | Current subscription — root may pass any tenant id; others always get their own |
Invoices
| Type | Purpose |
|---|---|
GenerateInvoicesCommand(periodYear, periodMonth) | Batch: create Draft usage invoices for every active tenant |
IssueInvoiceCommand(invoiceId, dueAtUtc?) | Draft → Issued |
MarkInvoicePaidCommand(invoiceId) | Issued → Paid |
VoidInvoiceCommand(invoiceId, reason?) | Issued/Draft → Void |
GetInvoicesQuery(tenantId?, status?, periodYear?, periodMonth?, paging) | Admin: paginated cross-tenant invoice list (root-gated) |
GetMyInvoicesQuery(status?, periodYear?, periodMonth?, paging) | Tenant-scoped invoice list |
GetInvoiceByIdQuery(invoiceId) | Single invoice with line items |
Usage
| Type | Purpose |
|---|---|
CaptureUsageSnapshotsCommand(tenantId, periodYear, periodMonth) | Record one snapshot per QuotaResource for a tenant + period (idempotent) |
GetUsageSnapshotsQuery(tenantId?, periodYear?, periodMonth?) | Query history (root-gated for cross-tenant reads) |
Endpoints
All routes are mounted under /api/v1/billing and require the relevant Billing.* permission — except the two /me self-service reads, which are open to any authenticated tenant user so the dashboard can show the current plan and invoices.
| Verb | Route | Permission |
|---|---|---|
| POST | /plans | Billing.Manage |
| GET | /plans | Billing.View |
| PUT | /plans/{planId} | Billing.Manage |
| POST | /subscriptions | Billing.Manage |
| GET | /subscriptions?tenantId= | Billing.View |
| GET | /subscriptions/me | authenticated |
| POST | /invoices/generate | Billing.Manage |
| POST | /invoices/{invoiceId}/issue | Billing.Manage |
| POST | /invoices/{invoiceId}/pay | Billing.Manage |
| POST | /invoices/{invoiceId}/void | Billing.Manage |
| GET | /invoices | Billing.View |
| GET | /invoices/me | authenticated |
| GET | /invoices/{invoiceId} | Billing.View |
| GET | /invoices/{invoiceId}/pdf | Billing.View |
| POST | /usage/snapshots/capture | Billing.Manage |
| GET | /usage | Billing.View |
Configuration
The Billing appsettings section governs the tenant lifecycle side (it’s bound by the Multitenancy and Identity modules as TenantBillingOptions / TenantGraceOptions, but it’s billing behaviour, so it lives here):
{ "Billing": { "DefaultPlanKey": "free", // plan assigned when CreateTenant has no explicit plan "GraceWindowDays": 7 // days past ValidUpto before requests are hard-blocked }}Plan catalogue — seeded by BillingDbInitializer when the module first starts, or manage via the /plans endpoints at runtime. Plan Key should be canonical lowercase (e.g. free, pro, enterprise) and match the keys used by the Quota building block so quota and billing stay aligned.
Hangfire schedule — the recurring job is registered at module startup via the IRecurringJobManager:
// MonthlyInvoiceJob registration (BillingModule.cs)jobManager.AddOrUpdate( "billing-monthly-invoices", Job.FromExpression<MonthlyInvoiceJob>(j => j.RunAsync(CancellationToken.None)), "5 0 1 * *", // 00:05 UTC on the 1st of every month new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });To change the cadence, edit the cron expression. To run an ad-hoc generation (back-fill, testing), call POST /api/v1/billing/invoices/generate with the period.
How to extend
Wire a payment gateway
MarkInvoicePaidCommand is the seam. Replace the handler (or add a decorator) to call your gateway:
public sealed class MarkInvoicePaidCommandHandler(BillingDbContext db, IStripeClient stripe) : ICommandHandler<MarkInvoicePaidCommand, Guid>{ public async ValueTask<Guid> Handle(MarkInvoicePaidCommand cmd, CancellationToken ct) { var invoice = await db.Invoices.FirstAsync(i => i.Id == cmd.InvoiceId, ct).ConfigureAwait(false); // ... charge via gateway, capture payment-method-id on the invoice, then: invoice.MarkPaid(); await db.SaveChangesAsync(ct).ConfigureAwait(false); return invoice.Id; }}For inbound webhooks (Stripe → your app), expose a webhook endpoint, validate the signature, look up the invoice by external ID, and call the same domain method.
Add a new overage resource
BillingPlan.OverageRates is keyed by the QuotaResource enum (ApiCalls, StorageBytes, Users, ActiveFeatureFlags), so overage resources line up one-to-one with what the Quota building block meters:
- Add the new member to
QuotaResourceand give it a gauge/counter so Quota tracks it. - Update the plan(s) via
UpdatePlanCommandto set the per-unit overage rate. - The monthly job (which captures snapshots per period via
IUsageReporter) picks it up automatically.
Emit domain events on plan changes
Today the aggregates raise no domain events. If you need cross-module reactions (e.g. notify the tenant on plan upgrade), add an event to the relevant method:
public void Reactivate(){ Status = SubscriptionStatus.Active; RaiseDomainEvent(new SubscriptionReactivatedDomainEvent(Id, TenantId));}Then handle it in another module via IDomainEventHandler<SubscriptionReactivatedDomainEvent>.
Tests
Unit tests live at src/Tests/Billing.Tests/ (domain state machines, services, validators). Integration tests at src/Tests/Integration.Tests/Tests/Billing/ (8 files) cover the endpoint surface, tenant isolation/root-gating, the tenant billing lifecycle (create + renew → subscription + invoice), the monthly job, usage metering, and PDF rendering.
Related
- Quota building block — the plan keys here must line up with what Quota enforces.
- Multitenancy deep-dive — why Billing opts out of the default tenant isolation.
- Modules overview — the other nine modules that ship in v10.
- Cross-cutting concerns — Hangfire, multitenancy, OpenTelemetry.