Charge for usage
A plan can include so many units of something each month, such as AI messages, and charge for each unit beyond. The assistant is the example: Pro includes 500 messages a month, then 2¢ each. It works the same with Stripe and Polar. Putting a whole feature on some plans is Charge for a feature.
Declare a meter
Section titled “Declare a meter”In packages/contracts/src/billing.ts, name the meter and put it on the plans:
export const METERS = ['aiMessages', 'exports'] as const;
export const METER_LABELS: Record<MeterId, { name: string; unit: string; units: string }> = { // ... exports: { name: 'Exports', unit: 'export', units: 'exports' },};
pro: { // ... usage: { exports: { included: 100, pricePerUnit: 5 } }, // 5¢ each beyond 100},includedis per month, on yearly plans too.pricePerUnitis in cents, and can be a fraction:0.5is half a cent.nullstops the workspace atincludeduntil the next month instead.- On the free plan there is nobody to charge, so it always stops at
included. - A plan without the meter does not limit it.
Then run bun run billing:sync. It creates the meter at the provider and a metered price beside
each plan’s price. Checkout adds that price to new subscriptions.
Record it
Section titled “Record it”Call UsageService where the work happens, before doing it. Any module can inject it:
await this.usage.record(workspace.id, 'exports');- Past an allowance that cannot be exceeded it throws a
402, with a sentence saying when more are available, and records nothing. Calling it first means a refusal costs you nothing. quantityrecords more than one unit:{ quantity: rows.length }.keymakes it safe to repeat, for work that may run twice, such as a job: passexport:<its id>. Never take the key from the client, or people can choose not to be counted.- Inside a transaction, the units count only if it commits.
API calls
Section titled “API calls”The kit declares one more meter, apiCalls, on no plan. Put it on a plan’s usage and every
request made with an API key counts as one, recorded by the
workspace guard once the request is let through. Requests from people signed in are not counted.
Past an allowance that cannot be exceeded, keys get 402 until the next month.
pro: { // ... usage: { aiMessages: { included: 500, pricePerUnit: 2 }, apiCalls: { included: 100_000, pricePerUnit: 0.01 } },},Each request is a row in usage_events, and each one beyond the allowance a job, so price
them in bulk rather than one by one if your customers make millions.
What reaches the provider
Section titled “What reaches the provider”Usage is counted in the usage_events table, so the allowance and the 402 are decided by the
API and are the same with either provider. Only the units beyond the allowance are sent to the
provider. Each is sent by a job with the row’s id, which the provider uses
to drop a repeat. If the provider cannot be reached, the job tries again for about a day, so no
usage is lost.
The provider adds them to the next invoice. On a yearly plan that is at the end of the year.
Show it
Section titled “Show it”GET /api/workspaces/:workspaceId/usage answers with this month’s usage of each meter on the
plan. Any member can read it. The web app shows it on the overview, the billing page and the
assistant (UsageMeters in features/billing/usage.tsx), and the mobile app on its overview and
assistant. usageAllowances(plan) in the contracts puts “100 exports a month, then $0.05 each”
on the pricing page and the plan picker.
Test it
Section titled “Test it”Give the test its own allowances, then record and read:
const t = await createTestApp({ teams: false, plans: { free: { usage: { exports: { included: 2, pricePerUnit: null } } } },});const usage = t.app.get(UsageService);await usage.record(workspaceId, 'exports', { quantity: 2 });expect(usage.record(workspaceId, 'exports')).rejects.toMatchObject({ status: 402 });With stripe: new FakeStripe() and a paid plan, run the queue with t.app.get(JobsService).drain()
and read what was charged from stripe.meterEvents; with Polar, polar.ingested. See
apps/api/test/usage.e2e.test.ts.
When you change a price
Section titled “When you change a price”billing:sync replaces a metered price as it does a plan’s. People already subscribed keep the
prices they bought. A subscription from before you added a meter has no metered price: the
units beyond its allowance are counted and sent, but not charged. Move it to the new prices in
Stripe or Polar to charge for them.