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.
Prerequisites
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.
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.
Implementation Walkthrough
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)
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
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;
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.
Edge Cases & Failure Modes
| 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 |
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.
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. 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.
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.