Skip to content

Billing

How a team pays, and the one thing billing tells the rest of the product. Five slices in the billing/ group, and between them and everything else there is a single field: Team.planId.

That narrowness is the design. Nothing outside billing/ asks who the provider is, what a subscription looks like on the wire, or whether an invoice was captured — it reads a plan off the team and gets on with it.

The billing screen in the cabinet: one team on the free early-access plan, and usage for the period

What the cabinet shows today is narrower than what the api can do. The screen above offers no plan to buy: it names the team, says Free (early access), and states that subscriptions, limits and usage billing arrive closer to v0.9. Everything below is built and reachable over the api; the buying flow is simply not surfaced yet.

The five slices

slicewhat it owns
billing/productthe catalogue: one sellable thing — a name, a price, the planId a subscriber gets
billing/subscriptionour record of a team's subscription, and the Team.planId write
billing/paymentProviderthe provider, behind a gateway — PayPal today
billing/webhookintake for the provider's events
billing/usagewhat a team has actually consumed, and against which quota

The provider is behind a gate

IPaymentProviderGateway lives in domain/; PayPal lives in data/. Consumers never see the SDK, the wire format, or the provider's vocabulary — the same shape infra/storage and infra/vector have, for the same reason.

1.x had the opposite: a slice called paypal, named by every consumer, whose spellings (APPROVAL_PENDING, BILLING.SUBSCRIPTION.ACTIVATED) reached into services that had no business knowing them.

The subscription flow

  1. POST /billing/subscriptionsour row is written first, because its id is the reference the provider carries and echoes back on every event. Then the provider is asked for a subscription, and the payer is handed an approval link.
  2. The payer approves at the provider. Nothing is charged before that.
  3. The provider delivers ACTIVATED. The webhook slice verifies it, claims it against the replay ledger, and applies it — which activates the subscription and writes Team.planId.
  4. POST /billing/subscriptions/:id/cancel stops future charges; CANCELLED arrives and the plan comes off.

One open subscription per team. pending, active and suspended all count as open, so a payer who reloads a half-finished checkout is handed the same approval link rather than a second subscription to pay for.

Two rules that exist because 1.x broke them

Nothing here computes a date. currentPeriodEnd is read from the provider. 1.x added a month on every capture event, which is why a redelivered event was worth a free month.

Every write is an assignment, never an increment, so handling an event twice is harmless — as a second line of defence behind the replay ledger, not instead of it.

Why the replay ledger is worth its own table

The tempting objection is that applying "activated" twice changes nothing. True — and that is not the case that costs money. The case that costs money is out of order: activated, then cancelled, then the activated event redelivered. Every write involved is individually idempotent, and the result is a team on a paid plan that has stopped paying.

The order is the design:

verify  →  claim  →  handle  →  mark processed
  • Verify first, parse second — an unverified body is attacker input.
  • Claim before handling — a claim written afterwards leaves a window where a second delivery arrives mid-handling, finds no claim, and handles the event again.
  • Release on failure — a handler that threw did not act on the event, so the claim must go, or the provider's retry (the thing that would have fixed it) is discarded as a duplicate.

The claim is an INSERT against a unique key rather than a read followed by a write: two concurrent deliveries both read nothing and both proceed, so the database decides, once.

Usage

GET /usage/summary   what has been consumed, broken down
GET /usage/total     the single number
GET /usage/quota     what is left against the plan
GET /usage/agents    the same, per agent

Known gaps, stated rather than hidden

An abandoned checkout can be approved later. The provider has no cancel operation for an unapproved subscription — measured live, /cancel answers 404 RESOURCE_NOT_FOUND — so cancelling a pending subscription closes our row and leaves the approval link to expire on the provider's own schedule. Someone who abandoned a checkout, subscribed to something else, then went back and approved the first link before it expired would end up with two live subscriptions.

A real spend cap does not exist. Usage is measured and shown; nothing stops a team at a ceiling.

Where the code is

api/src/slices/billing/ — one directory per slice above. See also Slice breakdown and Resources.