← Blog
Engineering8 min read

Per-Seat Billing Is a State Machine, Not a Number

Adding a seat mid-cycle does not charge, removing one does not refund. The money bugs in team billing live in the transitions.

Per-seat billing looks like arithmetic. Price times members, invoiced monthly. Teams get bigger, the number goes up.

Then you implement it and discover that Stripe's default behaviour, documented plainly, is not what your pricing page implies:

Negative prorations aren't automatically refunded and positive prorations aren't immediately billed.

Read that twice, because both halves are counterintuitive and both cost money. A team adds a seat and nothing is charged today. The amount sits as a proration item on the next invoice. A team removes a seat and nothing is refunded. They get a credit against what they owe next month.

Neither behaviour is wrong. Both are surprising if you implemented "update the quantity when the member list changes" and expected the money to follow. The rest of this is the transitions where teams actually lose revenue, and the design that prevents them.

What changing the quantity actually does

Changing an item's quantity triggers a proration by default, calculated to the second between the current period start and end, in the same way changing the price does. The behaviour is controlled by one parameter with three values, and picking it is a product decision rather than a technical one.

  • create_prorations, the default. Proration items are created and appear on the next invoice. Money moves later.
  • always_invoice. The proration is calculated and an invoice is generated immediately. Money moves now.
  • none. No proration at all: the customer is billed the full new amount at the next renewal, as if the change happened at the start of the period.

Most teams want always_invoice on seat increases, because a company that added twelve people this morning expects a charge and finds a surprise on the next invoice annoying. Many want create_prorations or even none on decreases, because immediate refunds on seat churn is a policy, not an accident. What you must not do is leave it unconsidered on both, which is what happens when the quantity update is buried in a "remove member" handler someone wrote in an afternoon.

One more mechanic worth knowing before you design around it: usage-based billing is not subject to proration at all. If your plan mixes seats with metered usage, only the seat half of the invoice behaves the way this article describes.

The four transitions where money goes missing

Seat billing has a small number of state changes, and essentially all of the bugs live in four of them.

A member is added mid-cycle. The quantity goes up, the proration is created, and by default the customer pays at the end of the period. If your product grants access immediately, you have given away days of service. Usually fine, occasionally not, and it should be a decision rather than a default you inherited.

A member is removed mid-cycle. The quantity goes down and a credit appears. The trap is what happens if that customer cancels before the next invoice: the credit was never money in their pocket, and they will ask about it. Decide whether removal produces a credit, a refund or nothing at all, and say so in the interface at the moment of removal.

A team downgrades to fewer seats than it has members. Stripe will accept this without complaint, because it is a billing system and not an access control system. The quantity is now five, the team still has nine people using the product, and nothing in the payment stack considers that a problem. This is the one that quietly gives away your product, and the fix is not in your billing code at all: either refuse the downgrade until the member list matches, or accept it and deactivate members by a rule you can defend. Both are acceptable; silence is not.

The paying owner leaves. If the subscription belongs to a person, it leaves with them. The remaining team keeps working until the card expires, and then the only account that could have fixed it no longer has access. Billing has to belong to the organization, with the payer role transferable by someone who is still there.

TransitionWhat the billing system doesWhat it costs you
Member addedproration created, charged on the next invoicedays of service given away, unless you set always_invoice
Member removedcredit, not a refundthe credit disappears if they cancel before it is used
Seats cut below the member countaccepts it without complaintthe product is free for the people over the limit
The paying owner leavesnothing, the subscription is theirsit leaves with them, and nobody remaining can fix it

The invariant that makes it tractable

The reason seat billing sprawls is that most implementations keep one number and treat it as two different things: how many people can use the product, and how many people you are charging for.

They are not the same number and they do not change at the same time. A member invited yesterday but not yet accepted, a member suspended for the weekend, a member removed at the end of the cycle: each answers "can they use it" and "are they billable" differently.

Keep them separate and the design collapses into something you can reason about:

// Two questions, two answers, one source each.
const seatsBilled = organization.subscription.quantity   // what Stripe knows
const seatsUsed = await countBillableMembers(organization.id) // what your app knows

// The only invariant worth enforcing, checked in one place:
// seatsUsed must never exceed seatsBilled without a deliberate decision.

Every transition above becomes a question about that one invariant: what happens when the two numbers diverge, who is allowed to make them diverge, and what the customer sees when they do. Answer it once, in one function, rather than in each of the four handlers where the divergence can start.

Show the number before you charge it

There is a practical detail here that saves an entire class of support ticket, and it is in the documentation rather than in most implementations.

Stripe can generate a preview invoice for a change without applying it, so you can put the exact amount in front of the customer before they confirm. The catch is that prorations are computed to the second, so the amount you previewed and the amount you charge a few seconds later are not identical.

The fix is one parameter: pass a proration_date when you create the preview, then pass the same proration_date when you apply the update. The number the customer agreed to becomes the number on the invoice. Without it, you are showing an estimate and calling it a price, and somebody will eventually screenshot the difference.

What we ship, and what we do not

Honesty first, since this article describes a problem we have not finished solving.

The kit ships single-subscription billing: one subscription per user, plans, checkout, the customer portal, one-time payments, usage-based metering, and a webhook handler that keeps the local state in sync. That plumbing is the same plumbing seat billing needs, and it is where the idempotency work matters most, because seat changes generate exactly the kind of duplicated and out-of-order events that produce wrong invoices.

What it does not ship is organizations. The subscription is keyed to a user, so there is no entity for a team to own, and everything above would have to hang off something that does not yet exist. Teams and seat billing are the paid tier we are building, and until it exists we would rather describe the problem accurately than pretend a free kit solves it.

If you are building this yourself in the meantime, the order that works: organizations first, membership with roles second, the billable-versus-active distinction third, and only then the Stripe quantity. Teams that do it in the other order end up with a quantity field nobody can explain.

Frequently asked questions

Does Stripe charge immediately when I add a seat?

Not by default. Changing the quantity creates proration invoice items, and Stripe's documentation states that positive prorations are not immediately billed: they sit on the next invoice. If you want the customer charged when they add someone, set proration_behavior to always_invoice on that update, which calculates the proration and generates an invoice right away.

What happens to the money when a seat is removed mid-cycle?

A credit, not a refund. Negative prorations are not automatically refunded, they reduce the next invoice. That is usually what you want, but it means a customer who removes five seats and then cancels the following week has credit they never see, so decide deliberately what happens to it rather than discovering the question from a support email.

What if a team downgrades to fewer seats than it has members?

Stripe will happily accept the quantity change, because it is billing and not access control. Deciding who loses access is your job, and doing nothing means you are giving away seats. The two defensible answers are refusing the downgrade until members are removed, or accepting it and deactivating the most recently added members, and both need to be visible in the interface before the customer clicks.

What happens when the owner who pays leaves the team?

Whatever you designed, and if you designed nothing the subscription leaves with them. Billing has to belong to the organization rather than to a person, with the ability to transfer the payer role. This is the case that turns into a support ticket at the worst possible time, because the person who could fix it has already gone.

Can I show the customer the exact amount before they confirm?

Yes, and you should. Stripe can create a preview invoice for a change without applying it. One catch: prorations are calculated to the second, so the previewed amount drifts from the applied one. Pass the same proration_date to both the preview and the update and the number the customer agreed to is the number they are charged.

The half you can have today

The billing plumbing underneath all of this is free and MIT licensed: checkout, the customer portal, one-time and recurring payments, metered usage, and the webhook handler that keeps your database honest when Stripe delivers the same event twice. The billing guide walks through the setup end to end.

Seats sit on top of that, not instead of it. Whichever way you build the team layer, the events, the idempotency and the reconciliation underneath are the same ones, and they are the part that is worth getting right first.