Billing in the Colden kit
How the kit bills organizations through Stripe: the mirror and why the application reads only it, the plan catalog, the trial, cancellation and resumption endpoints, the webhook through the billing queue, the events billing publishes, local development with the Stripe CLI and testing.
Each organization has at most one current Stripe subscription. Stripe is the source of every billing fact; the kit keeps a mirror of customers, subscriptions and invoices in PostgreSQL and the application reads only the mirror, so a Stripe outage never blocks signing in, the dashboard or the billing page. The kit changes something in Stripe through one gateway and writes Stripe's answer into the mirror at once; Stripe's webhooks then confirm it and carry every change made elsewhere (the end of a trial, a failed payment, the Stripe dashboard).
Plans
A product sets its plans in configuration, Billing:Plans, one entry per plan key with the Stripe price id of each interval (Month, Year) in every currency of Billing:Currencies; the package README has the shape and the rules the startup check enforces. Create the products and prices in the Stripe dashboard (test mode first), then put their ids in the environment, for example Billing__Plans__starter__Prices__Month__usd=price_....
GET /v1/billing/plans serves the catalog to the pricing pages, signed in or not: every plan with its trial days, included seats, limits, feature keys, the recommended flag and its prices with Stripe's amounts in the currency's minor unit. The amounts are cached for Billing:PriceCacheMinutes; while Stripe cannot be read they are null and nothing is cached.
Starting a trial
POST /v1/organizations/{organizationId}/billing/trial with { "planKey": "starter", "interval": "month", "currency": "usd" } (interval and currency optional) starts the organization's trial without a card. Owners only: billing is the billing:manage permission, which only owners hold (see the authorization guide).
- The plan, interval and currency must exist in the catalog (400 with field errors otherwise).
- The plan must have a trial, and the organization must have had no subscription before: one trial per organization (409
trial-unavailable). A current subscription is 409subscription-exists. - The organization's Stripe customer is created on the first subscription, with the owner's email and the organization's name and language.
- The subscription is created with the plan's trial days and no payment method; Stripe cancels it at the end of the trial unless a card is added by then.
- Stripe's answer is mirrored at once and returned with
201;confirmedisfalseuntil the webhook confirms it.
Concurrent requests share an idempotency key, so a double click creates one subscription in Stripe and one row in the mirror.
Canceling and resuming
POST /v1/organizations/{organizationId}/billing/subscription/cancel with { "reason": "too_expensive", "comment": "..." } ends the current subscription at the end of its period; the reason is one of Stripe's feedback values (too_expensive, missing_features, switched_service, unused, too_complex, low_quality, customer_service, other) and the comment is up to 1000 characters. POST .../subscription/resume keeps it again before the period ends. Owners only; repeating either changes nothing. A subscription that has ended cannot be resumed: the organization subscribes again.
Reading the subscription
GET /v1/organizations/{organizationId}/billing/subscription returns the current subscription from the mirror to any member: plan, status, interval, currency, periods, trial end, whether and why it ends at the period end, the card on file (brand, last four digits, expiry), since when a payment has been failing, and confirmed. 404 when the organization has none.
The card
The organization's card is collected by Stripe.js in the browser and never reaches the kit:
POST /v1/organizations/{organizationId}/billing/payment-method/setup(owners) creates a setup intent on the organization's customer, creating the customer when there is none, and returnssetupIntentId,clientSecretandpublishableKey. The client secret goes to Stripe.js only; the kit never logs or stores it. A second click within the same minute returns the same intent.- The browser confirms the intent with Stripe.js, which collects the card and handles the bank's authentication.
POST .../billing/payment-methodwith{ "setupIntentId": "seti_..." }(owners) makes the confirmed card the customer's default for invoices and the current subscription's. It returns the card (brand, last four digits, expiry) and recordsbilling.card_changedin the audit log. An intent that is not confirmed yet is 409; another organization's intent is 404.
GET .../billing/payment-method returns the card from the mirror to any member, 404 when there is none. Card events from Stripe (a new expiry, a removed card) keep it in step.
Changing the plan
A plan change is previewed, then confirmed with the preview's proration date, so the owner is charged the amount they saw (owners only):
POST .../billing/subscription/previewwith{ "planKey": "growth", "interval": "month" }returns Stripe's invoice of the change: its lines (the credit for the unused time and the charge for the remaining time, each markedproration), thetotal, theamountDuenow, thenextRenewaland theprorationDate. Nothing changes.POST .../billing/subscription/changewith{ "planKey": "growth", "interval": "month", "prorationDate": "<the preview's>" }applies it. The prorations are invoiced and charged at once (always_invoice); when the payment fails nothing changes and the answer is 402payment-failed. A preview older than an hour is refused with a field error. The change is recorded assubscription.plan_changed.
The subscription must exist (404), the plan must differ (409), and a subscription keeps its currency (409). An upgrade is paid when it applies; a downgrade leaves a credit for the next invoices.
Subscribing without a trial
POST .../billing/subscription with { "planKey": "scale" } (owners) subscribes to a plan sold without a trial, or to any plan once the organization's subscription has ended. The first invoice is charged at once to the card on file, and the subscription exists only if it is paid: 402 payment-failed otherwise. Without a card it is 409 payment-method-required; with a current subscription, 409 subscription-exists. The answer is 201 with the subscription, recorded as subscription.created.
Invoices
GET .../billing/invoices?page=1&pageSize=25 (owners) lists the organization's invoices, newest first, from the mirror only: number, status, amounts due and paid, currency, period, issue and payment dates, and Stripe's page and PDF. Drafts are left out until Stripe finalizes them. The webhook keeps the mirror (invoice.finalized, invoice.paid, invoice.payment_failed).
The billing contact
GET .../billing/contact returns who invoices are made out to and sent to: name, email and postal address (empty until the organization has a billing account). PUT .../billing/contact with { "name": "Acme SRL", "email": "[email protected]", "address": { "line1": "...", "city": "...", "postalCode": "...", "country": "RO" } } changes it in Stripe, then in the mirror (owners); it is recorded as billing.contact_changed. The country is the two-letter ISO code. A tax id is added in the customer portal.
The customer portal
POST .../billing/portal (owners) returns the address of a Stripe customer portal session, valid for a few minutes. There the owner sees every invoice, changes the card, adds a tax id and confirms a payment the bank asks to authenticate. The portal's back link leads to the organization's billing page, {Email:WebUrl}/{slug}/billing; the API builds it and never takes it from the request. 404 until the organization has a billing account. Configure the portal's features in Stripe's dashboard (Settings, Billing, Customer portal).
Deleting an organization
Deleting an organization ends its current subscription in Stripe first, at once and without a final invoice or a refund, so a deleted organization is never charged again; the mirror records it as subscription.canceled. When Stripe cannot be reached the organization is not deleted (503) and the owner tries again.
Entitlements and usage
IEntitlements answers what an organization may do and how much it has used, from the plan of its current subscription:
- Seats. The plan's
IncludedSeatsagainst the members plus the pending invitations. Inviting beyond them is 402seat-limit-reached, withlimit,usedandrequestedin the problem; an address already invited takes no new seat. Without a plan, or with a plan withoutIncludedSeats, seats are unlimited. - Features. The plan's
Features. A plan feature that is also a feature flag is the organization's only while the flag allows it, so a feature rolls out within the plans that include it.HasFeatureAsyncchecks one; an endpoint declares.RequireEntitlement("key")and answers 403plan-upgrade-required(naming thefeature) to an organization without it. - Meters.
RecordUsageAsync(organization, meter, quantity)adds to the organization's use of the meter in the current period: the subscription's period, or the calendar month without one. It runs in the organization's tenant context, and the addition is atomic, so concurrent requests never lose a count. The product saves withColdenOutbox, which sendsUsageRecordedto the worker.
GET .../billing/usage returns the plan, the seats used, included and left, the features and each meter's use in the current period against the plan's limit (Billing:Plans:<plan>:Limits:<meter>), to any member.
A meter with Billing:Meters:<meter>:StripeEventName is billed through a Stripe billing meter of that event name, for metered prices. On UsageRecorded the worker locks the period's row and reports the part Stripe has not been told about as one meter event; the event's identifier is the row and its running total, so a retried report is not counted twice. A meter without an event name is counted by the kit only.
Billing emails and dunning
The billing emails go to the organization's billing contact (PUT .../billing/contact), or to its owners when it has none, in the organization's language and time zone, through the kit's email pipeline:
| Template | Sent when | |
|---|---|---|
| Receipt | receipt | an invoice is paid: its lines, subtotal, tax and total as Stripe has them, the card, who it is billed to, and the PDF |
| Payment failed | payment-failed | a payment fails: the card, the amount, the plan and when Stripe tries again, with the link to change the payment method |
| Trial ending | trial-ending | three days before a trial without a payment method ends: the days left, the members, the plan's prices and the link to choose a plan |
A failed payment marks the subscription past due (pastDueSince in GET .../billing/subscription), which raises the past-due banner in the web; a paid invoice clears it. Stripe's retry schedule does the retries. A receipt or a payment-failed email is sent once per Stripe event, even when its message is delivered again.
The daily trial-ending-notifications job (09:00 UTC) finds the trials that end within three days and asks for one notice each; the notice and the mark that it went out (subscriptions.trial_ending_notice_for) commit together, so a second run sends nothing, and a trial whose end moves gets a notice for its new end. A trial with a card turns into a paid subscription by itself and gets no notice.
Reconciliation
The nightly billing-reconciliation job (04:15 UTC) compares every current subscription of the mirror with Stripe, one message per subscription on colden_billing. Where the status, price, quantity, period end, trial end or the end at the period's end differ, Stripe's state is applied as a webhook would apply it, SubscriptionChanged or SubscriptionCanceled is published, the repair is recorded as billing.subscription_reconciled in the organization's audit log with the values before and after, and a warning names the drift. A subscription Stripe no longer has is logged and left as it is. Without Stripe:SecretKey the job does nothing.
The super-admin views
Operators (super-admins with a second factor) see billing across organizations, each read inside a logged cross-tenant scope:
GET /v1/admin/tenantsandGET /v1/admin/tenants/{tenantId}show each organization's plan, status and monthly revenue, and the detail its subscription.GET /v1/admin/billing/statsreturns the stat cards from the mirror:- the organizations in use, the paying subscriptions (active, past due, unpaid) and the trials;
- the monthly recurring revenue per currency, with a yearly price counted as a twelfth and a trial as nothing;
- the subscriptions that ended in the last thirty days, and their share of the paying and churned ones.
POST /v1/admin/tenants/{tenantId}/trial/extendwith{ "days": 7, "reason": "..." }adds days to a trial.POST /v1/admin/tenants/{tenantId}/planwith{ "planKey": "growth", "interval": "month", "reason": "..." }moves the subscription to another plan or interval as the owner's own change does: prorated from now and charged at once.
Each change is recorded in the organization's audit log with the operator as its actor.
Webhooks
Point a Stripe webhook endpoint at https://<api>/webhooks/stripe with the event types of StripeEventTypes and put its signing secret in Stripe:WebhookSecret.
sequenceDiagram
participant S as Stripe
participant A as api
participant D as PostgreSQL
participant W as worker
S->>A: POST /webhooks/stripe (signed)
A->>D: StripeEvent row + StripeEventReceived, one transaction
A-->>S: 200
D-->>W: StripeEventReceived on colden_billing
W->>D: apply to the mirror, mark processed, billing events, one transaction
- A request whose signature does not match, or is older than five minutes, is 400 and nothing is stored. Without a signing secret the endpoint answers 503, and Stripe retries for three days.
- A type the kit does not apply is acknowledged and dropped. An event Stripe delivers again is acknowledged and not stored twice.
- The worker finds the event's organization from the metadata the kit writes on customers and subscriptions, else from the mirrored customer or subscription; an event of no organization of the kit is marked processed and changes nothing.
- Subscription and invoice events carry the whole object, so the newest wins: an event older than the state the mirror holds changes nothing.
- A failure records the error on the event row and the messaging retry policy tries again; the event stays unprocessed until it succeeds, and lands in the error queue after the last attempt.
Billing events
The worker publishes, in the transaction that applies the event:
| Event | When |
|---|---|
SubscriptionStarted | the first webhook confirms a subscription |
SubscriptionChanged | the plan, the status or the end at the period end changes |
SubscriptionCanceled | the subscription ends |
TrialWillEnd | three days before a trial ends |
InvoicePaid | an invoice is paid; the past-due mark is cleared |
InvoicePaymentFailed | a payment fails; the subscription is marked past due |
Their correlation id is the Stripe event's id. The worker sends the receipt on InvoicePaid and the payment-failed email on InvoicePaymentFailed; the others have no consumer in the kit, for a product to subscribe to.
Without Stripe
With no Stripe:SecretKey the kit starts and runs: the plans are served without amounts, the trial, cancel and resume endpoints answer 503 unavailable, and the stripe check in /health/deps is Degraded. A Stripe outage behaves the same for the calls that need Stripe; everything that reads the mirror keeps working.
Local development
- In the Stripe dashboard (test mode), create the products and prices of the plans and copy the secret key.
- In
infra/compose/.env, setStripe__SecretKeyand the plan variables (Billing__Plans__starter__Name=Starter,Billing__Plans__starter__Prices__Month__usd=price_..., ...). - Set the webhook signing secret once. The
stripe-cliservice (profilestripe, on inCOMPOSE_PROFILES) logs in with the same key and forwards test-mode events to the api. Its signing secret is the same for every forwarder on the account:docker compose -f infra/compose/compose.yaml --env-file infra/compose/.env run --rm --no-deps stripe-cli listen --print-secretprints it. Put thatwhsec_...value inStripe__WebhookSecret, then start the stack (or recreate the api). - Start a trial from the API reference at
/scalar, then cancel it;stripe trigger customer.subscription.updatedsends sample events.
The web pages
The web's billing area has three tabs: the overview (BILL-02), the plans (BILL-01) and, for owners, the invoices (BILL-03).
- Overview. The plan with its status and dates, the seats and every meter of
GET .../billing/usage(warning from 80% of the limit, danger at it), and the card on file. An owner adds or changes the card in Stripe's own payment form (@stripe/react-stripe-js), loaded with the publishable key and the client secret ofPOST .../billing/payment-method/setup; Stripe confirms the setup intent, with the bank's check when it asks, and the web then saves the card withPOST .../billing/payment-method. The card number never reaches the kit. The form takes its colors, radius and font from the design tokens on the page. - Past due. While
pastDueSinceis set (BILL-06), the banner shows on every page, the plan card says since when the payment fails and links the invoices, and the card is marked and asks to be updated first. - Plans. For an owner with a subscription, each other plan or interval offers its change. The change dialog (BILL-04) shows Stripe's prorated lines from
POST .../subscription/preview, what the card is charged today and the price from the next renewal, and confirms with the preview's proration date, so the charge is the one shown. A change that needs a card says so. - Invoices. The mirror's invoices, newest first, with the status badge, Stripe's PDF and, for an open invoice, Stripe's payment page; the billing contact's address is named as where invoices are emailed.
Testing
FakeStripeGateway and StripeTestData.WithTestBilling give a host the test catalog and an in-memory Stripe; the sample events in tests/fixtures/stripe drive the webhook and worker tests on PostgreSQL. The gateway's contract tests against Stripe's test mode run when STRIPE_TEST_SECRET_KEY and STRIPE_TEST_PRICE_ID are set and are reported skipped otherwise. See the testing guide.