#DevOps#独立开发

SaaS Seat Subscription Proration: Interval Ledgers, Concurrent Changes, and Billing Acceptance Points

Mid-month seat changes are not just the current seat count times the days remaining. This article freezes billing intervals, price versions, and seat events to make proration reproducible from non-overlapping segments, stable operation IDs, and provider receipts while handling unknown outcomes and post-invoice corrections.

Key Points

  • Topic Focus: DevOps、独立开发
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

A customer increases a subscription from 10 seats to 18 in the middle of the month, and another administrator removes three users a few minutes later. If the system sends only the final quantity=15 to the payment provider, it loses how long 10, 18, and 15 were each in effect; if both requests preview the "current subscription" before submitting, the response that arrives later may also overwrite the earlier change. Stripe creates a proration invoice item for some subscription changes, but the preview amount and final invoice can still be affected by timing, discounts, taxes, and concurrent updates. A single API response cannot serve as the local ledger.

AI Pricing Migration handles price and cohort transitions, while AI SaaS Entitlement Snapshots projects commercial facts that have already taken effect into the runtime. This article owns only "how many seats are billed over which time segment" within a fixed billing period and the corresponding financial evidence. It does not redefine plans or mix seat counts into usage-based billing.

Freeze seatBillingPolicy.v1

Every subscription is bound to an immutable subscription.seatBillingPolicy.v1 containing customer/subscription, provider API version, price ID and digest, currency and minor unit, billing period [start,end), time basis, seat identity or quantity-only mode, minimum commitment, rules for when seat additions and removals take effect, proration behavior, discount/tax input generation, rounding point, and post-invoice correction rules. Intervals use offset-bearing RFC 3339 instants and are normalized to UTC; calculating by "days remaining" must not depend on local midnight or treat every month as a fixed 30 days.

yaml
schema: subscription.seatBillingPolicy.v1
subscription: sub_team_82
generation: 14
price: price_pro@7
currency: USD
period: {start: 2026-07-01T00:00:00Z, end: 2026-08-01T00:00:00Z}
quantityMode: NAMED_SEAT
changeEffectiveAt: ACCEPTED_AT
proration: CREATE_PRORATIONS
rounding: INVOICE_LINE_MINOR_UNIT

Seat facts are written as seatChange.v1, containing a stable operation ID, expected subscription generation, a set of seat IDs or quantity delta, requestedAt, effectiveAt, actor, policy digest, and payload digest. Named seats must ensure that the active intervals of the same seat in the same subscription do not overlap. Quantity-only mode must also retain every delta rather than storing only the overwritten total. The canonical ledger partitions the entire period into contiguous half-open segments, each bound to a quantity and source event root. Any gap, overlap, or negative quantity causes invoicing to be rejected.

Amount calculation uses the price's minor unit and interval ratio to produce an internal oracle, but does not claim to reproduce the payment provider's tax engine. Stripe documentation states that proration usually does not trigger an immediate charge, and scenarios involving discounts or unpaid invoices require an explicit policy. The internal preview records the policy, segments, calculation precision, and expected line root; the provider preview separately stores its request parameters, return timestamp, and digest. If the difference exceeds the threshold, the case enters PRORATION_REVIEW; the system cannot silently approve it by choosing the cheaper side.

Each Seat Change Has Exactly One Acceptance Point

A command follows PROPOSED -> VALIDATED -> READY -> ACCEPTED -> PROVIDER_PENDING -> INVOICED -> RECONCILED; additional states and paths are STALE_CHANGE, PROVIDER_UNKNOWN, PROVIDER_REJECTED -> COMPENSATING -> COMPENSATED, and INVOICE_EXCEPTION. At the local acceptance point, CAS matches the expected generation/period, appends the seat event, segments, and outbox, and advances the generation. It also updates the subscription's providerDesiredGeneration; a button click or preview does not constitute acceptance.

The provider dispatcher is guarded by a per-subscription fence: it may acquire only the lowest nonterminal generation via CAS and send that generation's explicit quantity/price under the stable operation ID; subsequent generations must wait. A timeout enters PROVIDER_UNKNOWN and blocks successors. Reconcile first against the subscription/invoice and idempotency result; a late receipt for an old generation closes only its lineage and must never overwrite a higher providerDesiredGeneration. Only an explicit permanent rejection enters PROVIDER_REJECTED; the acceptance function compensates entitlements/segments with a new generation while retaining the original event. If reconciliation is coalesced, it must still read the authoritative provider state and use CAS to match the desired root, sending only the latest desired generation. Entitlements reference the latest generation, and HTTP Idempotency-Key Correctness requires an idempotency key to remain bound to the same payload.

After an invoice is finalized, reconcile the provider invoice line, period, quantity, discount/tax inputs, and local segment root. An already-paid invoice cannot be corrected by rewriting an old seat event. Errors become a new generation through a credit note, supplemental invoice, or explicit adjustment in the next period. The AI Usage Metering Ledger handles usage such as tokens, while the Prepaid Credits Ledger handles balance reservations; neither can substitute for seat-time intervals.

Break Proration at Boundaries and Under Concurrency

The fault suite injects concurrent seat additions and removals, billing-period/DST boundaries, a price generation change after preview, generation A with quantity=18 timing out while generation B with quantity=15 is ready, B responding first and A arriving late, permanent rejection, duplicate Webhooks, an old invoice, seat ID reuse, and an outbox crash. Expected behavior is that local CAS serializes changes and B is not dispatched while A remains nonterminal. No old receipt may roll the provider quantity back from the latest desired generation, and a permanent rejection is compensated at a higher generation without deleting history.

Release gates require 100% interval coverage and 100% root recomputation consistency. Overlaps/gaps, concurrent double occupancy, mismatched-payload acceptance, duplicate proration lines, and provider generation regressions must all remain at 0. UNKNOWN must block successors 100% of the time and have an assigned owner, while permanent-rejection compensation must achieve a 100% closure rate. Run in shadow for two billing periods before rolling out to internal users and 5% of new subscriptions. If anomalies occur, stop new changes and retain the outbox for reconciliation. Finalized/paid amounts receive compensating entries only; history is never deleted.

Related

Related Content