- Add deployment-name branding to titles, mastheads, and OG tags - Share one grant delivery-state query with lineage across grants surfaces - Show pool status/usage, org owners, and config readiness - Make billing views projection-aware with recency and sync vocabulary - Guard FedWiki creation without domains and render route-aware 404s
16 KiB
title, audience, summary
| title | audience | summary | |
|---|---|---|---|
| Payments and Billing |
|
How money moves: billing accounts, subscriptions, invoices and payments projected from Stripe, scheduled changes as future intent, and the reconciler that keeps it all convergent. |
Payments and Billing
Purpose
This model handles money, and its first rule is that the console does not process payments itself: Stripe, the payment processor, executes all charging, while the console keeps its own records. Traffic flows both ways — the console pushes its catalog and customers out to Stripe, and projects Stripe's billing activity (subscriptions, invoices, payments) back in. Everything money-related roots in a billing account, a container owned by one organization (the entity a member acts within; every member gets a personal one at signup); subscriptions, invoices, payments, and payment methods all hang from it.
The central object is the subscription: the standing agreement that an organization pays recurringly for a product. Three kinds of record sit around it and must never be confused: invoices and payments are one-shot facts, projected in from Stripe after they happen; scheduled changes (intent) are the future — a plan switch, a quantity change, or a cancellation that has not fired yet; subscription changes (history) are the past — an append-only log of status transitions. The reconciler is the single code path that converges the console's records, and the capabilities the organization actually holds, with what Stripe says is true.
One purchase, end to end: a member clicks buy; the console runs its guard checks (invariant 12) and starts a Stripe Checkout session — a Stripe-hosted payment page; after paying, the member returns to the console, which immediately reconciles the new subscription so the purchase is visible right away; Stripe's webhook event arrives shortly after, is stored, and triggers the same reconciler again — harmless, because reconciling is repeat-safe; the invoice and payment rows are then projected from the stored event.
This card reuses vocabulary introduced in the Product Catalog card: product, price, default price, plan ladder, conferral, and provision.
Where this lives
Database objects sit in the core schema (defined in internal/db/migrations/00001_init.sql) except the Stripe link tables, which get their own stripe schema.
| Piece | What it is | Where |
|---|---|---|
core.accounts |
Billing accounts, each owned by exactly one organization; a default account is created during signup provisioning. Its status column (default active) renders as a badge on the operator billing-accounts list; the org-detail billing summary also reads it into its view model but never renders it. No application code path changes the column after creation: the update query exists in the generated querier and nothing calls it, so every account reads active today regardless of what happens to its subscriptions |
00001_init.sql; internal/provisioning/provisioning.go |
core.subscriptions |
The standing agreement projected from Stripe: status (CHECK-pinned to Stripe's eight-value vocabulary), billing-period start/end, cancel_at_period_end, and the three commitment columns (see the commitment gloss under Invariants) |
00001_init.sql; 00010_schema_hardening.sql |
core.subscription_items |
The per-product, per-price lines of a subscription, with quantity | 00001_init.sql |
core.subscription_changes |
Append-only history of status transitions (previous_status → new_status), each carrying an attribution string saying what triggered it |
00001_init.sql |
core.subscription_scheduled_changes |
The intent ledger: future plan switches, quantity changes, and cancellations, with when they fire and their lifecycle status | 00001_init.sql |
core.invoices, core.invoice_line_items, core.payments, core.payment_methods |
The money projections: amounts in integer minor units (cents) with a per-row currency; payment methods store safe card descriptors (brand, last4, expiry), never card data | 00001_init.sql |
core.webhook_events |
The inbound event store all integrations share (Discourse uses it too): every verified Stripe event lands here before processing; partitioned by month, meaning each month's rows live in their own physical sub-table; partitions are created ahead of need at boot and by a recurring maintenance schedule | 00001_init.sql |
| Partition maintenance | The idempotent ensure pass that creates upcoming monthly partitions, run once at boot and on a 24h Temporal schedule (webhook-partition-ensure) |
internal/db/partitions.go, internal/workflows/maintenance/ |
core.outbox |
The transactional outbox for outbound work: rows enqueue in the same database transaction as the domain write that needs them, and a drainer executes them against Stripe | 00001_init.sql |
stripe schema |
Eight mapping tables (customer, product, price, subscription, subscription item, invoice, payment, payment method) — each links one console UUID to one Stripe id, both unique, with a sync_status that starts pending for objects the console pushes out and synced for objects projected in |
internal/integrations/stripe/store/migrations/00001_init.sql |
| Webhook receiver | Verifies the Stripe signature, scrubs personal data from the payload, and stores the event | internal/integrations/stripe/web/webhook.go |
| Webhook processor | The recurring worker that picks up stored events: subscription and checkout events trigger the reconciler; invoice and payment events project rows from the stored payload | internal/integrations/stripe/workflows/ |
| Outbox drainer | The recurring worker that executes outbox rows (create Stripe product / price / customer), retrying with backoff until a cap parks the row as dead_letter for operator-driven retry |
internal/integrations/stripe/workflows/outbox.go |
| Reconciler | ReconcileSubscription: refetches the subscription from the Stripe API and converges console records and provisions to match |
internal/fulfillment/reconcile.go |
| Plan-change mechanics | Member-initiated switch and cancel, a preview of the price difference before switching, the commitment gating, and the scheduled-change bookkeeping | internal/fulfillment/plan_change.go |
| Scheduled-change sweeper | The hourly backstop that fires due scheduled changes Stripe itself cannot fire | internal/fulfillment/sweep.go; internal/workflows/billing/ |
| Checkout | Runs the guard checks and starts the Stripe Checkout session; also creates the Stripe customer synchronously if the organization has none yet | internal/server/billing.go |
| Operator billing surfaces | Catalog sync to Stripe, price creation, and the invoice/payment/subscription listings; each of the four billing views states when the most recent webhook event finished processing (core.webhook_events.processed_at), or states that none ever has, so an operator can tell current data from stale before reading a row |
internal/server/operator_billing.go |
| Member invoice history | The member-facing billing page; because invoices and payments are projections from processed webhook events, an empty list states that records appear once payment processing completes rather than asserting the member has no billing history | internal/server/member_invoices.go |
| Operator guide | Configuration flags, the webhook event list, and what to do (and not do) in the Stripe dashboard | docs/stripe.md |
Invariants
An invariant is a rule the model guarantees everywhere; code that would break one is wrong even if it works locally. Tags mark the enforcement mechanism: [db] — a schema constraint rejects violations; [db-code] — database-side code upholds it; [app] — only Go code upholds it, so every new code path must too.
One gloss first, because four rules below depend on it. A commitment is a minimum-term agreement attached to a subscription, carried in three columns: commitment_end is when the term ends (the "commitment boundary"); early_termination_policy is what happens if the member tries to downgrade or cancel before then — this is the "commitment policy", with values allow, block, and fee; commitment_renewal says whether the commitment re-arms for another term at the boundary (auto_renew) or lapses (expire). A subscription with empty commitment columns is evergreen — no minimum term.
- [db] Every subscription, invoice, line item, payment, and payment method belongs to exactly one billing account, and every billing account to exactly one organization — a money row always resolves to an organization.
- [db] Stripe linkage is one-to-one per entity: each mapping table holds the console UUID and the Stripe id, both unique, so neither side can be linked twice.
- [app] Stripe ids never live on core tables; every resolution between a console record and a Stripe object goes through a
stripemapping row. One codified exception: the attribution column oncore.subscription_changesstores whatever string triggered the transition — sometimes a Stripe event id, sometimes an internal reason like "eager:checkout-return". - [app] Subscription fulfillment never trusts a webhook payload. A subscription event yields only an id; the reconciler refetches the authoritative state from the Stripe API, takes a per-subscription lock so concurrent runs serialize, and converges — running it twice is safe and changes nothing the second time. The rule is scoped to subscriptions because they drive entitlements; invoice and payment rows are display facts and are projected straight from the stored payload — with every amount conversion range-checked, so a value too large for its column fails the processing loudly instead of being stored as a wrapped negative number.
- [app] Personal data is scrubbed before any webhook payload is stored: names, emails, and addresses are replaced with a redaction marker; card descriptor fields (brand, last4) are deliberately kept.
- [app] Outbound catalog sync goes only through the outbox: the enqueue helper takes the caller's database transaction, so the domain write and the outbox row commit or roll back together. Customer creation is the exception — checkout creates the Stripe customer synchronously when no mapping exists, because the checkout session needs it immediately.
- [db] The scheduled-change vocabulary is closed by CHECK constraints: three change types (plan switch, quantity change, cancellation), three firing triggers (at a date, at period end, at the commitment boundary), four lifecycle states (scheduled, applied, superseded, canceled).
- [app] A subscription has at most one pending scheduled change: every writer supersedes existing scheduled rows before creating a new one. No unique index backs this — the rule lives entirely in the writers.
- [db-code] A scheduled change fires exactly once: firing claims the row by flipping scheduled → applied in a conditional update, so a concurrent sweeper and webhook cannot both fire it; the loser sees no row and stops.
- [app] An immediate plan switch disarms any pending cancellation: the switch supersedes the intent row and clears Stripe's cancel-at-period-end flag in the same Stripe call that changes the price.
- [app] Mid-term downgrades and cancellations dispatch on the commitment policy:
allowproceeds now;blockdefers the change to the commitment boundary as a scheduled change;feemeans a termination fee would be owed, and since fee collection is unbuilt, the console refuses the change rather than charging. Upgrades are never gated. - [app] Checkout requires a published, active, public product (the shared member gate — see the product-catalog card) and an active, Stripe-mapped price, and refuses when the organization already holds an active subscription on a ladder that the product is a tier of — so neither a stale tab nor a crafted request can buy an unpublished product or create a second concurrent subscription.
- [db] Every payment references an invoice — there are no invoice-less payments.
- [app] The webhook receiver acknowledges only what it recorded: it answers 2xx once the event row is inserted or identified as a duplicate of an already-recorded delivery, and answers 500 when the insert fails, so Stripe redelivers instead of the event being silently lost. The Discourse receiver follows the same contract.
Dimensions
These axes are independent; most billing bugs come from reading one as another:
- Standing agreement vs one-shot fact — a subscription is long-lived state the reconciler converges again and again; an invoice or payment is created once from one Stripe event and then only marked paid or voided.
- Intent vs history — scheduled changes (intent) are the future and can be superseded or canceled; subscription changes (history) are the past, append-only and never edited. Never read one as the other.
- Sync authority per entity — catalog and customer objects are console-authoritative and pushed out; subscriptions, invoices, payments, and payment methods are Stripe-authoritative and projected in. The direction is visible in each mapping table's
sync_statusdefault —pendingfor pushed objects (the mapping exists before Stripe confirms),syncedfor projected ones (the mapping is born linked) — but nothing machine-enforces it; a code path writing against the declared direction would compile fine. - Core vs provider boundary — core tables carry console UUIDs; Stripe ids live in the
stripeschema, with the single attribution-column exception of invariant 3. Replacing the payment processor would touch the provider schema plus that one column. - Evergreen vs committed — every subscription today is evergreen; a populated commitment changes the whole downgrade/cancel dispatch (invariant 11). The read side is complete; nothing writes the commitment columns yet.
- Webhook-primary vs sweeper-backstop — Stripe's own events fire most changes; the hourly sweeper exists for changes Stripe cannot fire (commitment-boundary deferrals) and as a catch-up for missed events. Both paths run the same two steps: claim the intent row (invariant 9), then reconcile.
- Eager vs asynchronous fulfillment — the checkout return page reconciles immediately so the member sees their purchase land; the webhook pipeline reconciles the same subscription again later. Safe only because the reconciler is serialized and convergent (invariant 4).
- Money representation — integer minor units plus a per-row ISO currency code, always; cross-currency totals stay per-currency buckets and are never converted.
- Status classification — Stripe's many subscription statuses collapse into four behavioral buckets: entitlement-bearing (active, trialing); suspended (past due, unpaid, paused — the provisions pause delivery without ending); ended (canceled, incomplete-expired — the conferrals end, and the organization's baseline default plan is restored if nothing else covers it); ignored (incomplete — recorded without provisioning).
- Projected data carries a recency, not a completeness guarantee — invoices, payments, and subscription state are projections from processed webhook events (see "Sync authority per entity" above), so an unchanged or empty view is never proof that nothing happened, only that nothing has been projected yet. The operator billing surfaces name the most recent processed-event time so stale data is visibly stale; the member invoice history states the same fact in plain language, without exposing a timestamp.