A customer on the $99 Growth plan upgrades to the $299 Scale plan on day 14 of a 30-day cycle. What do you charge them? What can they do in the product in the next five seconds? What happens to the 4,200 API calls they already burned against a 5,000-call cap that is now 50,000? If your answer to any of those is "whatever the payment provider does," you have a bug waiting for a support ticket.
Billing looks like a solved problem because charging a card is solved. Charging a card is the easy 10%. The hard 90% is the state machine underneath: a plan, a set of entitlements derived from that plan, usage counters scoped to a billing period, and an invoice that has to agree with all three after the customer changes their mind halfway through the month. Those four things live in different systems with different clocks, and mid-cycle changes are exactly where the clocks drift apart.
We have built subscription billing, usage metering, entitlement gating and AI spend wallets with auto-recharge across our own products and client platforms. Every painful billing incident we have dealt with traces back to the same root cause: somebody treated the plan on the subscription object as the source of truth for what the user can do, and treated the payment provider as the source of truth for when that changed.
The four clocks that disagree
Before writing any proration code, get precise about what changes and when. There are four distinct timelines in a subscription and they do not move together.
- Money. The invoice period. Usually anchored to the original signup date and only moves when you deliberately reset the anchor.
- Access. What features and limits the tenant has right now. Should change the instant the customer pays for an upgrade, and typically at period end for a downgrade.
- Usage. Counters that reset on a period boundary. If the plan changes mid-period, you have to decide whether the counter resets, keeps counting, or splits into two segments.
- Provider truth. Whatever Stripe or your processor thinks the subscription is, which you learn about asynchronously via webhooks that arrive out of order and sometimes twice.
Most mid-cycle bugs are a mismatch between two of these four. The customer paid for Scale (money moved) but the app still gates them at Growth limits (access did not move) because the customer.subscription.updated webhook lost a race with invoice.paid. Or access moved immediately on downgrade and the customer lost their team members on day 3 of a cycle they already paid for.
Entitlements are their own table, not a plan lookup
The single highest-leverage decision here — and the one we make on every platform we ship — is that the application never reads the plan to decide what a tenant can do. It reads an entitlements record. The plan is an input that produces entitlements; it is not the answer itself.
create table tenant_entitlements (
tenant_id uuid primary key,
plan_code text not null,
effective_at timestamptz not null,
expires_at timestamptz, -- null = current
features jsonb not null, -- {"marketplace_sync": true}
limits jsonb not null, -- {"locations": 5, "api_calls": 50000}
source text not null, -- 'plan' | 'override' | 'trial' | 'comp'
source_ref text -- provider subscription id
);
Three things fall out of this shape for free. Sales can grant a one-off override without a fake plan in the price catalog. You can write a future-dated row for a scheduled downgrade and let it activate on its own. And every gate check is one indexed read, which matters when it is on the hot path of every request.
The rule we enforce: entitlements are written by exactly one code path. A single reconciler takes the current provider subscription state plus any overrides and computes the row. Webhooks do not write entitlements directly; they enqueue a reconcile job for that tenant. That makes duplicate and out-of-order webhooks harmless, because the reconciler always reads current provider state rather than trusting the event payload it happened to receive.
A decision rule for mid-cycle changes
Proration policy is a product decision that engineers usually get handed by accident. Make it explicitly, write it down, and encode it in one function. Here is the default we start from and only deviate from with a reason:
- Upgrade (higher price): apply immediately. Invoice the prorated difference immediately rather than at next cycle. The customer asked for more capability and expects it now; billing it now also avoids the collections risk of a large surprise on the next invoice.
- Downgrade (lower price): schedule at period end. No refund, no credit. They paid for the period; they keep it. Show the scheduled change clearly in the UI with the exact date it takes effect.
- Downgrade that violates current usage: block it at request time with the specific overage named. "You have 8 locations. The Growth plan allows 3. Remove 5 locations to downgrade." Never silently delete resources to make a plan fit.
- Same-price plan swap: apply immediately, no invoice line.
- Annual to monthly: treat as a downgrade, schedule at period end. Annual to annual upgrade: prorate against remaining term, not remaining month.
- Cancellation: access to period end, then a read-only grace window. Data deletion is a separate, later, explicitly-scheduled job.
That rule set covers the large majority of cases and it is boring on purpose. The one place we break it: seat-based products where a customer removes a seat because someone left the company. Holding them to the higher seat count for three more weeks reads as punitive. There we credit the removed seat prorated against the remaining period and apply it to the next invoice, never as a cash refund.
Usage counters need period segments, not resets
This is where teams usually get it wrong, and it is not obvious until a customer disputes an invoice. If a tenant used 4,200 of 5,000 API calls on Growth and upgrades to Scale with 50,000 on day 14, what does the counter say on day 15?
Do not reset it — that is free capacity and it gets abused by anyone who notices. Do not carry it forward against the new limit either, unless the new limit is genuinely for the whole period. The honest model is to record usage as immutable events with a timestamp and derive the counter per entitlement segment, which is the same thing your invoice already does with prorated line items.
- Store raw usage events, never a mutable counter.
(tenant_id, metric, quantity, occurred_at, idempotency_key). - Derive current-period consumption by summing events between the active entitlement row's
effective_atand now. - Cache the derived number aggressively — a per-tenant counter in Redis or D1 is fine — but treat the event table as truth and make the cache rebuildable from it.
- Emit a usage snapshot row at every plan change and every period close, so an invoice from four months ago can still be explained.
The rebuildable-cache point is not theoretical. When you find the metering bug — and you will, usually a double-counted retry — the difference between a one-line replay and a week of manual credit notes is whether you kept the events. This is the same discipline we apply to AI spend wallets, where per-request token cost is written as an event and the wallet balance is a derived, reconcilable figure rather than a number you decrement and hope.
Idempotency, ordering, and the reconciliation report
Payment provider webhooks are at-least-once and unordered. Design for that from the first commit rather than adding defenses after an incident.
On the inbound side
Persist every webhook event by its provider event ID in a table with a unique constraint, return 200 immediately, and process asynchronously. Duplicate delivery hits the constraint and drops out. Because processing is a reconcile-from-current-state job rather than an apply-this-delta job, ordering stops mattering: a stale event just triggers a recompute that arrives at the correct answer.
On the outbound side
Every write you send to the provider carries an idempotency key derived from your own intent, not from a random UUID. We use {tenant_id}:{action}:{target_plan}:{period_start}. If a user double-clicks Upgrade, or your job retries after a timeout that actually succeeded, the second call returns the first result instead of creating a second subscription change and a second invoice.
And run a nightly reconciliation
Once a day, walk every active tenant and compare three things: the provider's subscription and plan, your entitlements row, and your derived usage against the limits in that row. Write a report with a row per tenant and a status. Anything that is not match gets a human. We used exactly this pattern — a per-tenant reconciliation report gating every cutover — when we migrated over a hundred tenant databases off a platform we then decommissioned, and the same shape works for billing drift.
If you cannot produce a one-line-per-tenant report showing that provider state, entitlements and usage agree, you do not know whether your billing is correct — you only know that nobody has complained yet.
Where tenancy shape changes the answer
On a shared-schema platform, entitlements live in the same database as the tenant's data and a plan change is a single-row update. On a database-per-tenant fleet — we operate 700+ tenant databases — entitlements have to live in the control plane, not the tenant database, or you cannot answer "which tenants are over their limit" without fanning out hundreds of queries. If you have not settled that decision yet, our post on shared schema versus database per tenant walks through the tradeoffs; the billing consequence is that per-tenant isolation pushes all plan and entitlement state up into a shared control-plane database whether you wanted it there or not.
Provisioning also becomes asynchronous. An upgrade that unlocks a feature requiring a schema migration in each tenant database cannot complete synchronously in the checkout request. So entitlements get a third state beyond on and off: provisioning. The UI shows the feature as coming online, the gate check returns false, and a job flips it when the migration lands. Pretending that transition is instant is how you get a customer who paid for a feature and sees a 500.
The advice in this post has a floor. If you have fewer than about 50 paying customers on two plans and no usage-based component, the reconciler, the event table and the segment logic are over-engineering. Take the provider's default proration behavior, read the plan off the subscription object, and spend the time on the product. The threshold where this becomes urgent is the first of: usage-based pricing, a sales team that grants exceptions, annual contracts, or an enterprise customer whose finance department reads invoices line by line. Each one of those turns billing from a settings page into a system, and it is far cheaper to add the events table before there is revenue to reconstruct than after.
When we scope a new SaaS build, billing and entitlements are in the first milestone alongside auth and tenancy, not a later phase. It is the part clients most often expect to be a two-week integration, and it is the part that decides whether every feature you ship for the next three years can be sold separately. If you are working through it now, see how we approach platform builds or tell us what you are building.
Frequently asked
Should upgrades take effect immediately or at the next billing period?
Immediately, with the prorated difference invoiced right away. The customer asked for more capability and expects it in the product now. Billing the difference immediately also avoids a surprise on the next invoice, which is a common cause of failed payments. Downgrades are the opposite case: schedule those for period end and let the customer keep what they paid for.
How do you handle usage limits when a plan changes mid-cycle?
Store usage as immutable events with timestamps rather than a mutable counter, then derive consumption per entitlement segment. When the plan changes, close the old segment and open a new one, exactly as a prorated invoice does. Resetting the counter gives away free capacity; carrying it forward blindly overcharges. Segments are the only version that matches what the invoice says.
Why not just read the plan name to decide what a customer can access?
Because plans and access diverge almost immediately. Sales grants an exception, a trial adds a feature temporarily, a downgrade is scheduled for next month, or a feature needs provisioning before it works. A separate entitlements record with effective dates, feature flags and limits handles all of those with one indexed read, and lets one reconciler own every write.
How do you stop duplicate webhooks from double-charging or double-provisioning?
Persist every inbound event by its provider event ID under a unique constraint, acknowledge fast, and process asynchronously as a reconcile-from-current-state job rather than applying a delta. Duplicates hit the constraint and stop. For outbound calls, derive idempotency keys from your own intent, such as tenant, action, target plan and period start, so retries return the original result.
When is this level of billing engineering not worth it?
If you have a small number of customers, two flat plans and no usage component, take your payment provider's default proration and move on. The event table, reconciler and segment logic start earning their cost when you add usage-based pricing, annual contracts, sales-granted exceptions, or an enterprise customer whose finance team audits invoice lines.