Billing
A workspace’s plan comes from the payment provider: Stripe, or
Polar with BILLING_PROVIDER=polar. The API keeps a copy in the
subscriptions table so it can answer “which plan is this?” without calling the provider on
every request. The code is in apps/api/src/billing.
From checkout to plan
Section titled “From checkout to plan”Browser API Provider │ Upgrade │ │ ├──────────────►│ customer (once per workspace)│ │ │ checkout ───────────────────►│ │◄──────────────┤ address of the page │ ├──────────── pays on the provider's page ────►│ │ │◄── webhook: subscription ────┤ │ │ fetch the subscription ─────►│ │ │ save plan, audit, receipt │ │◄── back on the billing page, on the new plan │Returning from checkout does not change the plan. The webhook does, a moment later. Each workspace has one customer at the provider, created at its first checkout and kept, so invoices stay in one place. A workspace that is already on a paid plan cannot check out again: plan changes, card updates and cancelling happen in the provider’s customer portal, and come back through the same webhook.
One set of rules, two providers
Section titled “One set of rules, two providers”BillingService holds every rule on this page. What talks to a provider is behind one
interface, PaymentProvider in provider.ts, with StripeProvider and PolarProvider behind
it: create a customer, start a checkout, open the portal, set seats, cancel, check a webhook’s
signature, fetch a subscription, and report usage. A rule you add goes in BillingService, so it holds with
either provider.
Why only the webhook writes the plan
Section titled “Why only the webhook writes the plan”Anything that changes a subscription, whether in the app, in the portal or in the provider’s
dashboard, reaches the API as a webhook. If the API also wrote plans itself, the two could
disagree. So plan, status and the billing period are written in one place,
BillingService.handleEvent, and nowhere else. To change a subscription from your code, call
the provider and let the webhook record the result.
Why an event can arrive twice, late or out of order
Section titled “Why an event can arrive twice, late or out of order”Providers deliver each event at least once, and not always in order. Two rules make that harmless:
- The event id is saved in the same transaction as its effects (the
billing_eventstable). A repeat finds the id and does nothing. A failure saves neither, the provider sends the event again, and it is handled from scratch. - The event is a signal, not the data. The handler fetches the subscription from the provider as it is now instead of trusting the event, so a late event cannot put back an older state.
A handler you add for another event type should follow the same two rules.
Which statuses keep a plan
Section titled “Which statuses keep a plan”A workspace is on its paid plan while the subscription’s status is in ACTIVE_STATUSES:
active, trialing and past_due. Stripe and Polar use the same statuses. Anything else, or
no subscription, is the free plan. past_due is there on purpose: when a card fails, the
provider retries it for a while, and people should not lose access while it does. When it gives
up, the status changes and the workspace returns to free.
A cancelled subscription keeps its plan until the end of the period it was paid for.
With pricing: 'perSeat', the quantity at the provider follows the number of members. When
someone joins, accepts an invitation or is removed, BillingService.syncSeats updates the
provider, which prorates the difference. If it cannot be reached, the change of membership
still succeeds and the error is logged; the next change of membership corrects the quantity.
Flat plans are billed for one, whatever the size of the team.
maxSeats is checked before an invitation is sent, counting open invitations, so a plan cannot
be oversubscribed.
A plan can include so many units of a meter each month and price the rest
(Charge for usage). UsageService in usage.service.ts keeps
the rules, so they are the same with either provider:
- Usage is counted in
usage_events, never at the provider. A workspace’s use this month is the sum of its rows, so the usage screens and the allowance need no call to the provider. - Only units beyond the allowance reach the provider. Each row records how many of its
units were
billable. A job sends those with the row’s id; the provider drops a repeat, so a job that runs twice charges once. The provider adds them up on the next invoice. - One record at a time per workspace and meter.
recordtakes a lock in its transaction, so two requests at once cannot both use the last unit of an allowance. - Months follow the subscription. On a paid plan a month starts on the day of the month its period started, so allowances line up with invoices; on the free plan it is the calendar month in UTC. Until the renewal webhook arrives, the next month is worked out from the last period.
- Where nothing can be charged, the allowance is a limit. On the free plan, on a plan whose
pricePerUnitisnull, and with billing not set up,recordanswers402past it.
When a workspace or account goes away
Section titled “When a workspace or account goes away”Deleting a workspace ends its subscription first. Deleting an account ends the subscriptions of the workspaces that are deleted with it. See Auth and sessions.
What else a payment triggers
Section titled “What else a payment triggers”- With Stripe,
invoice.paidqueues a receipt email, in the webhook’s transaction, keyed by the invoice, so a repeated event cannot send two. Polar sends its own receipts. - A change of plan, a cancellation, a resumption and an end are each written to the audit log. Renewals are not.
Without a provider
Section titled “Without a provider”With no key for the chosen provider, nothing can start a subscription, so every workspace is on
the free plan. The billing page says billing is not set up, and checkout and the portal answer
503. Putting features on plans is on Charge for a feature.