Proration Logic & Calculations

Proration is how a billing engine reconciles money against partial time. The moment a customer changes plan on day 11 of a 30-day cycle, you owe them credit for 19 unused days on the old plan and you must charge for 19 days on the new one — and the arithmetic must be deterministic, replay-safe, and auditable to the cent. This is the reconciliation layer of a Subscription Billing Architecture & Pricing Models stack, sitting between plan modifications and invoice generation. Done well it is invisible; done wrong it produces negative balances, duplicate charges, or revenue that does not tie out under audit.

The difficulty is that three things conspire: day-count conventions disagree about how many days a month has, floating-point math drifts a fraction of a cent per operation, and asynchronous webhooks can deliver plan-change events out of order or twice. This page covers the algorithms, the ledger discipline, and the idempotency controls that keep proration correct under all three. It leans on the same primitives as the rest of the billing engine — decimal arithmetic, a subscription FSM state machine, and double-entry ledger posting — applied to the specific problem of money-against-partial-time.

Prerequisites

Proration sits downstream of several guarantees. It assumes the change event has already been deduplicated, that money is represented in a decimal type, and that the ledger it posts to is append-only. The stack below lists the non-negotiable foundations.

Proration prerequisites Decimal money type, idempotency store, immutable ledger, UTC boundaries, and a sequence cursor are the five foundations the proration calculator depends on. Proration calculator Decimal money type Idempotency store Immutable ledger UTC period boundaries Sequence cursor
Proration inherits correctness from the five foundations beneath it — weaken any one and the math drifts.

The ordering of these prerequisites is not arbitrary. If the decimal money type is missing, every downstream guarantee is theatre: you can serialize a float-derived adjustment behind the most rigorous idempotency key in the world and still emit 1799.9999999998 cents where you meant 1800. If the ledger is not append-only, a correction path that mutates the original credit row erases the audit trail that made proration defensible in the first place. Each foundation removes one class of silent corruption, and none of them substitute for another. Treat the checklist as a gate the calculator refuses to start without, not as a set of nice-to-haves you bolt on when a bug report arrives.

The sequence cursor deserves particular attention because it is the least obvious of the five. Most teams reach for an idempotency key first and assume it also solves ordering — it does not. An idempotency key stops the same event from applying twice; it says nothing about two different events arriving in the wrong order. A customer who upgrades to price_enterprise and then, four seconds later, corrects themselves back to price_pro generates two distinct subscription.updated events with two distinct keys. If your webhook consumer processes them out of order, the customer ends up prorated onto Enterprise despite settling on Pro, and no amount of idempotency deduplication catches it because both events are legitimately unique. The monotonic cursor — a per-subscription_id counter that only ever advances — is what turns “process each event once” into “process events in the order the source system emitted them.”

One more foundation is implicit in the money type: a policy for where fractional cents go. Every division of an integer price by a day count that is not a divisor of that price leaves a remainder. A 2999-cent plan over a 30-day cycle yields a daily rate of 99.9666… cents, and those trailing digits do not vanish — they either accumulate in a full-precision intermediate until the single final rounding, or they leak. Deciding up front that remainders live in the Decimal until quantize is called, and nowhere else, is what makes the arithmetic reproducible across two engineers running the same numbers on two machines.

Architecture & Data Flow

A proration event flows from a plan-change request, through boundary normalization and rate derivation, into a credit/debit calculation, and finally into balanced ledger postings guarded by an idempotency key. The credit (unused old plan) and the charge (remaining new plan) are computed separately and never netted into one opaque number, so audits can see both halves.

Proration calculation flow A plan change is normalized to UTC, daily rates are derived, credit and charge are computed separately, rounded once, and posted as balanced ledger entries behind an idempotency gate. Plan change (UTC normalized) Daily rates (decimal) Credit (old days) Charge (new days) Ledger: paired credit + debit Idempotency gate
Credit and charge are derived separately, rounded once, and posted as paired ledger entries behind an idempotency gate.

The inputs are the old and new prices, the cycle boundaries, and the change timestamp. The outputs are two ledger postings and an invoice line pair. The idempotency gate ensures a retried subscription.updated webhook produces no second adjustment. The separation of credit and charge is not cosmetic: netting them into one number destroys the information an auditor needs to verify that the credit matched the unused time and the charge matched the new plan. Keep them as two lines and the reconciliation query is a SUM with a GROUP BY line_type; collapse them and you are reverse-engineering intent from a single figure.

Immediate versus deferred settlement

The data flow above assumes the adjustment settles immediately — the customer is charged or credited the moment the plan changes. That is one of two defensible models, and the choice shapes every stage downstream. In immediate settlement, an upgrade from price_pro at 1999 to price_enterprise at 4999 on day 11 of 30 generates a charge of the prorated difference right now, and the customer’s card is captured for it. In deferred settlement, the same adjustment is accrued as a pending line item and folded into the next regular invoice at cycle close. Immediate settlement gives cleaner cash timing and instant feature entitlement but multiplies the number of payment attempts — and therefore the number of decline paths, retries, and dunning triggers your engine has to survive. Deferred settlement keeps the payment cadence to one attempt per cycle but requires the accrued proration line to survive intact from day 11 to day 30 without being recomputed if the customer changes plans again in between.

The subtle failure here is mixing the two. If your architecture settles upgrades immediately but defers downgrade credits, a customer who upgrades and then downgrades inside one cycle has one captured charge and one pending credit that do not net against each other in the same settlement event. The credit sits on the balance while the charge already hit the card. That is not wrong, but it must be a deliberate policy the ledger reflects, not an accident of two code paths that were written by different people at different times. Pick one settlement discipline per adjustment type and document which line types settle when.

What the change timestamp actually anchors

The change timestamp is the single most consequential input, because it defines the boundary between “days the customer had the old plan” and “days they will have the new one.” A common mistake is to use the timestamp the webhook was received rather than the timestamp the change was requested. Under retry, those can differ by minutes or, if a queue backed up, hours — and a change that crosses midnight UTC between request and receipt shifts by a whole day of proration. Always carry the source system’s effective_at through the event payload and prorate against that, treating the receipt time as metadata only. The day count is a function of intent, not of network latency.

Implementation Walkthrough

The walkthrough moves left to right: compute the net adjustment in decimal, order the triggering events, post separated lines, and resolve tax. The ordering guard sits before the calculation deliberately — there is no point computing a precise adjustment for an event you should have discarded as stale.

Proration implementation sequence Guard event order, compute the decimal adjustment, post separated credit and debit lines, then resolve tax and finalize. 1 Order guard sequence cursor 2 Compute decimal, round 1× 3 Post lines credit + debit 4 Tax + finalize snapshot rate
Order-guard first, then compute, post, and tax — discarding a stale event before doing precise math on it.

1. Compute the net proration in decimal

Derive a daily rate for each plan and apply it to the remaining days. Round only at the end. The detailed arithmetic and precision strategy live in How to calculate prorated charges for mid-cycle upgrades.

from decimal import Decimal, ROUND_HALF_UP

def calculate_proration(old_price_cents: int, new_price_cents: int,
                        days_remaining: int, total_days: int) -> Decimal:
    daily_old = Decimal(old_price_cents) / Decimal(total_days)
    daily_new = Decimal(new_price_cents) / Decimal(total_days)
    credit = daily_old * Decimal(days_remaining)       # unused old plan
    charge = daily_new * Decimal(days_remaining)       # remaining new plan
    # ✅ round once, at finalization
    return (charge - credit).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)

Notice that daily_old and daily_new are never rounded — they stay as full-precision Decimal values, and only the final difference is quantized. This matters more than it looks. If you rounded each daily rate to the nearest cent first, a 30-day cycle would multiply that rounding error by up to 30, and on a plan pair where both daily rates round in the same direction the errors compound rather than cancel. A worked example: old_price_cents = 2999 over 30 days is 99.9666… cents per day; rounded to 100 and multiplied by 19 remaining days gives 1900, but the exact 99.9666… × 19 is 1899.366…, quantizing to 1899. That single cent, multiplied across a tenant with fifty thousand mid-cycle changes a month, is a five-hundred-dollar reconciliation gap that no one can explain because it was manufactured one rounding call too early. Round last, always.

The upgrade clamp belongs in this function too, even though it is omitted above for clarity. After computing charge - credit, assert the result never exceeds new_price_cents — the customer cannot owe more for the remainder of the cycle than the new plan’s full-cycle price. If it does, an input is wrong: usually a days_remaining that went negative because the change timestamp fell after the cycle boundary, or a total_days of zero that should have raised before arithmetic began. The clamp is a cheap invariant that converts a class of silent overcharges into a loud exception at the point of computation.

2. Order events with a sequence cursor

Out-of-order webhooks must not apply a stale adjustment. Skip any event at or below the last processed sequence.

def process_webhook(event: dict) -> None:
    last = get_last_processed_sequence(event['subscription_id'])
    if event['sequence_id'] <= last:
        return                                          # ✗ idempotent skip
    with acquire_cycle_lock(event['subscription_id']):
        if not validate_cycle_state(event):
            raise ValueError("invalid state transition")
        apply_proration_adjustment(event)
        update_sequence_cursor(event['sequence_id'])    # ✅ advance cursor

The lock and the cursor update must live in the same transaction as the adjustment posting. If you advance the cursor in a separate commit, a crash between the posting and the cursor write leaves the system believing the event was never processed — the retry re-applies it, and because the idempotency key on the line items catches the duplicate insert, you end up in the confusing state of a cursor that never advanced but a ledger that already reflects the change. Two guards protecting the same operation must succeed or fail together. Wrap the cursor read, the state validation, the posting, and the cursor advance in one SELECT ... FOR UPDATE transaction and let the database’s atomicity do the coordination.

The validate_cycle_state check is what stops a well-ordered but nonsensical event. An event can pass the sequence guard — it is genuinely the newest — and still be invalid, for example a plan change arriving for a subscription that has already been canceled, or a proration event whose effective_at predates the current cycle’s start. Ordering tells you when an event happened relative to others; state validation tells you whether it is a legal transition at all. The two are orthogonal, and skipping the second is how a canceled subscription acquires a phantom mid-cycle charge.

3. Post separated credit and debit lines

Never collapse the two into one line — ASC 606 audits need the breakdown. Each posts to the ledger with a shared idempotency key.

INSERT INTO invoice_line_items (
  invoice_id, subscription_id, line_type, amount, idempotency_key
) VALUES
  ($1, $2, 'proration_credit', $3, $5),   -- unused old plan (cents)
  ($1, $2, 'proration_debit',  $4, $5)    -- remaining new plan (cents)
ON CONFLICT (idempotency_key, line_type) DO NOTHING;

The composite conflict target (idempotency_key, line_type) is deliberate and easy to get wrong. If you put the unique constraint on idempotency_key alone, the second row of the pair — the debit sharing the credit’s key — collides with the first and is silently dropped, leaving a credit with no matching charge and a ledger that will never reconcile to zero. The key identifies the adjustment; the line_type distinguishes the two halves within it. Both columns together are what make the insert idempotent at the granularity of a line rather than an event. Test this specifically: replay the exact statement and assert two rows exist, not one, and not three.

Storing both amounts as positive integers with the sign carried by line_type is cleaner than storing a signed amount, because it makes the reconciliation query trivial and removes an entire class of sign-flip bugs. A proration_credit of 633 cents and a proration_debit of 2500 cents read unambiguously; the net is debit - credit = 1867, and the invoice engine decides presentation. If instead you store the credit as -633, every consumer of the row has to remember the convention, and the first one that forgets produces a credit that adds to the customer’s bill.

4. Resolve tax on the base before posting

Send the exact prorated base to the tax engine, snapshot the rate, then post. When metered overages are involved, prorate them against the same effective date per Usage-Based Billing Implementation. Trial conversions must isolate the proration line from recurring charges so dunning does not cascade — see Trial Period Management.

Snapshotting the tax rate is the step teams forget, and it produces the most maddening audit discrepancies. Tax rates change, jurisdictions redraw boundaries, and a customer’s address of record can be updated after the fact. If your proration line stores only the pre-tax base and recomputes tax at read time, a rate change between the proration event and the invoice PDF being regenerated will make the historical adjustment appear to have used a rate that did not yet exist. Persist the rate that was in effect at the moment of the adjustment — the exact percentage, the jurisdiction code, and the tax engine’s decision identifier — alongside the line. The proration line becomes a self-contained record of what was charged and why, immune to later reference-data drift.

There is also an ordering subtlety between tax and the credit half. Tax on the credit must be computed at the rate that applied when the original charge was made, not the rate current at the moment of the downgrade. If the customer paid tax at 8.5% on the old plan and the rate has since risen to 9%, crediting the unused portion at 9% refunds more tax than was ever collected, and the tax remittance will not reconcile. This is why the credit line references the original invoice’s rate snapshot rather than pulling a fresh rate — the credit is a partial reversal of a past transaction and must speak that transaction’s terms.

Edge Cases & Failure Modes

The proration edge cases that bite in production cluster around three axes: the calendar, event ordering, and money that goes negative. The map below places each against the axis it lives on so you know which defense applies.

Proration edge cases by axis Calendar failures need Actual/Actual day counts; ordering failures need a sequence cursor; sign failures need credit-balance routing and an upgrade clamp. Calendar axis leap year / 31-day month → Actual/Actual counts DST boundary shift → anchor to UTC Ordering axis out-of-order toggles → monotonic cursor duplicate retry → idempotency key Sign axis credit > balance → route to balance / cap dunning on proration → isolate from schedule
Each edge case lives on one of three axes; the right defense is the one matched to that axis.
Scenario Failure Mitigation
Leap year / 31-day month 30/360 over- or under-charges Use Actual/Actual with a calendar-aware date library
Out-of-order plan toggles Stale event overwrites newer state Monotonic sequence cursor + cycle lock
Downgrade credit exceeds balance Negative invoice or refund risk Route excess to credit balance, cap, or refund per policy
Duplicate webhook retry Double proration line Idempotency key on subscription_id + ts + target_price_id
Dunning on failed proration invoice Retries full-cycle amount Isolate proration line from recurring schedule

The month-boundary anchor problem

Beyond the day-count convention lies a nastier calendar question: what happens to the next billing anchor after a mid-cycle change? A subscription anchored to the 31st that upgrades in a 30-day month has no 31st to renew on. Naively advancing “one month” from January 31 lands on an ambiguous date, and different date libraries resolve it differently — some clamp to February 28, some overflow to March 3. The proration itself may be correct while the renewal date silently drifts, so the following cycle’s day count is wrong even though nobody touched the proration code. Anchor the renewal to a stable rule — last-day-of-month semantics, or a fixed anchor day that clamps down never up — and store the resolved next-anchor date on the subscription rather than recomputing it. Proration correctness and anchor correctness are separate problems that share a calendar; fixing one does not fix the other.

When credit exceeds the remaining balance

The sign axis hides a policy decision most engines defer until it breaks. A downgrade late in a cycle, from an annual-billed price_enterprise to a monthly price_starter, can generate a credit larger than anything the customer will owe on the next invoice — sometimes larger than several future invoices combined. Routing that credit to the account balance is the safe default, but an unbounded balance credit is a liability that sits on the books indefinitely and, in some jurisdictions, becomes escheatable unclaimed property after a statutory period. Decide explicitly: cap the credit at the value of the current cycle, spread it across a fixed number of future invoices, or trigger a refund path above a threshold. Whatever the policy, the excess must never silently become a negative invoice total, which most payment processors reject outright and which, if it does post, reads as the company owing the customer money at checkout.

Rapid successive changes within one cycle

A customer who upgrades, downgrades, and upgrades again inside a single cycle stresses every guard at once. Each change is a legitimate event with its own key and its own cursor position, so idempotency and ordering both pass — yet the naive result is three proration pairs stacked on one invoice, each computed against a different days_remaining. The arithmetic is individually correct but collectively bewildering to a customer reading their bill. Two mitigations exist: collapse consecutive changes that resolve to the same net plan within a short window into a single net adjustment, or present the lines grouped with a running subtotal so the customer sees the sequence rather than a wall of near-duplicate figures. The engine must stay correct either way; this is a presentation and consolidation concern layered on top of correct math, not a substitute for it.

Performance & Scale

Proration is computed on the change event, not in a batch, so per-event latency matters more than throughput. Acquire the cycle lock with SELECT ... FOR UPDATE on the single subscription row to avoid table-wide contention. Keep daily-rate derivation in memory for the transaction scope so concurrent microservice calls see one consistent value. Index the idempotency store on the key for O(1) dedup. For tenants that toggle plans frequently, debounce rapid successive changes at the API layer so you do not generate a proration line per keystroke. The lock scope is the thing to get right: a row-level lock on one subscription lets thousands of unrelated prorations proceed in parallel, while a careless table lock serializes the entire tenant.

Proration lock scope A row-level FOR UPDATE lock on one subscription isolates concurrent prorations, whereas a table lock serializes every tenant. Table lock (avoid) one proration blocks all throughput ≈ serial renewal-day meltdown Row lock (use) FOR UPDATE on one sub concurrent across subs scales horizontally
Lock the single subscription row, never the table — the difference is linear scaling versus a renewal-day meltdown.

Testing Strategy

Use a mock clock so a change at any instant — including 23:59:59 on the last day, and February 29 — is deterministic. Replay the identical subscription.updated event and assert exactly one credit/debit pair exists. Forge a webhook with a tampered HMAC signature and assert rejection before any ledger write. Assert the net adjustment never exceeds the new plan’s full-cycle price (the upgrade clamp) and never produces a charge on a pure downgrade. The assertion set below is the floor for confidence before shipping.

Proration test assertions Determinism at boundaries, exactly-one pair on replay, signature rejection, and zero-cent reconciliation are the four assertions a proration engine must pass. Determinism mock clock Feb 29 stable Idempotency replay event exactly 1 pair Security tampered HMAC rejected pre-write Reconcile credits+debits net to 0¢
Reconcile every cycle: sum all proration credits and debits and assert they tie to the invoice total within zero cents.

Reconcile: sum all proration credits and debits for a cycle and assert they reconcile against the invoice total within zero cents after the single final rounding. Property-based tests that generate random change timestamps and plan pairs will surface the boundary bugs — a change at midnight UTC on a month with a leap second, say — that example-based tests never think to write.

Frequently Asked Questions

What day-count convention should I use? Use Actual/Actual — actual days elapsed over actual days in the cycle — for GAAP/IFRS alignment. The 30/360 convention systematically mis-charges in 28-, 29-, and 31-day months. Calendar-aware libraries like Python dateutil or the JavaScript Temporal API handle leap years and month-ends correctly.

How do I prevent duplicate proration charges during webhook retries? Derive an idempotency key from subscription_id, the plan-change timestamp, and the target price_id, and store it under a unique constraint. A retried event hits the conflict and returns the original result instead of posting a second adjustment.

Where should rounding happen? Carry full precision through every intermediate step and round half-up to the nearest cent once, at the line-item boundary, before tax. Maintain a fractional-cent ledger to sweep accumulated remainders so they reconcile against monthly statements.

Should a mid-cycle downgrade issue an immediate refund? Usually no. Route the credit to the customer’s balance and apply it to the next invoice; issue cash refunds only when policy or law requires. This avoids reversing captured payments and the tax complications that come with refunds.

How do you prorate a hybrid plan with a metered component? Prorate only the fixed base against the effective date; leave the metered component to accrue and rate at cycle close. Mixing prorated base math with in-progress usage produces a figure that cannot be reconciled, so keep the two on separate lines with the same effective date.

Where should the rounding happen in a proration calculation? Do the high-precision arithmetic — daily rates, fractional periods, tier boundaries — in a decimal type, and round exactly once, to integer minor units, at the line-item boundary before the amount is persisted. Rounding intermediate results compounds error across a multi-line proration and produces a total that does not equal the sum of its rounded parts. Because you round per line, also decide a documented policy for the sub-cent remainder that inevitably falls out of dividing a monthly price by an odd number of days; a fixed rule (round half to even, or bank the remainder to a rounding account) keeps the ledger balanced and the behavior reproducible under audit.