Skip to content

Billing ​

How a team pays, and the one thing billing tells the rest of the product. Eight 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

The cabinet separates payment history from team settings (AGNT2-405). Tokens and quota remain customer-facing; vendor/model spend belongs to the admin panel (AGNT2-409). The image above is a historical early-access screenshot. AGNT2-347 added invoice, payment and method-reference records. Invoices are created on completed-payment intake; reconciliation reports differences without silently correcting either side. Automatic usage-based charging is not established.

The eight 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
billing/invoicesubscription period and charge purpose
billing/paymentprovider-reported payments and read-only reconciliation
billing/paymentMethodprovider reference, never card data

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 /v1/billing/subscriptions — our 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 /v1/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 /v1/usage/summary   what has been consumed, broken down
GET /v1/usage/total     the single number
GET /v1/usage/quota     what is left against the plan
GET /v1/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.

Partner identity and payer specification ​

The proposed v1 design supports both organization-paid and team-paid teams. Read identity and user journeys, provisioning and payer contracts, and migration and self-audit. Partner customer access uses organization-scoped actors; it is separate from the existing guest embed, and linking never grants a partner access to direct accounts.