Prorated Charges for Mid-Cycle Upgrades
When a customer upgrades mid-cycle, you have to charge them for the better plan only for the days that remain, after crediting the unused days they already paid for on the old plan. The naive version β charge the full new price now β overbills and generates support tickets; the black-box version hands the math to a billing vendor you cannot audit. This page implements the calculation yourself, deterministically, as a concrete instance of Proration Logic & Calculations. Understanding the cycle-boundary handling in Subscription Billing Architecture & Pricing Models is the prerequisite: the accuracy of every figure below depends on where you place the period boundary.
Trade-offs
How you pick the divisor and where you round determines both accuracy and compliance exposure.
| Decision | Actual/Actual days | Fixed 30-day divisor | Seconds-based |
|---|---|---|---|
| GAAP/IFRS alignment | Compliant | Drifts in 28/29/31-day months | Compliant |
| Max error per cycle | 0 cents | up to ~3% on a 31-day month | sub-cent |
| Implementation cost | Calendar library | Trivial | Calendar library + epoch math |
| Leap-year safety | Native | Manual patch needed | Native |
| Best for | Monthly invoicing audited under ASC 606 | Internal estimates only | High-value or sub-daily cycles |
Use Actual/Actual (or seconds-based for high-value plans); reserve the fixed 30-day divisor for non-billing estimates where a few percent of drift does not matter.
Step-by-Step Implementation
1. Extract and normalize cycle boundaries
Pull period_start, period_end, and upgrade_timestamp, normalize to UTC, and reject upgrades outside the active window. Validate the subscription is strictly active to gate out trials and past-due records.
import { Temporal } from '@js-temporal/polyfill';
interface CycleBoundaries {
remainingSeconds: number;
totalSeconds: number;
}
function extractCycleBoundaries(startISO: string, endISO: string, upgradeISO: string): CycleBoundaries {
const periodStart = Temporal.Instant.from(startISO);
const periodEnd = Temporal.Instant.from(endISO);
const upgradeTimestamp = Temporal.Instant.from(upgradeISO);
const afterStart = Temporal.Instant.compare(upgradeTimestamp, periodStart) > 0;
const beforeEnd = Temporal.Instant.compare(upgradeTimestamp, periodEnd) < 0;
if (!afterStart || !beforeEnd) {
throw new Error('Upgrade timestamp falls outside active billing cycle.'); // β reject
}
const remainingSeconds = (periodEnd.epochMilliseconds - upgradeTimestamp.epochMilliseconds) / 1000;
const totalSeconds = (periodEnd.epochMilliseconds - periodStart.epochMilliseconds) / 1000;
return { remainingSeconds, totalSeconds };
}
2. Compute the daily effective rate
The Daily Effective Rate is plan_price / days_in_cycle, computed in decimal with extra internal scale. Use actual calendar days as the divisor β a fixed 30 violates revenue-recognition standards.
from decimal import Decimal
def compute_daily_effective_rate(plan_price_cents: int, days_in_cycle: int) -> Decimal:
if days_in_cycle <= 0:
raise ValueError("Cycle divisor must be a positive integer.")
# β
keep 6 internal decimal places; round later
return (Decimal(plan_price_cents) / Decimal(days_in_cycle)).quantize(Decimal('0.000001'))
3. Calculate credit, debit, and net amount
The net charge is (DER_new - DER_old) * remaining_days. Clamp so it never exceeds a full new cycle, and route negative results (a downgrade) to a credit balance instead of a charge β see Handling downgrade credits and proration refunds.
import Decimal from 'decimal.js';
function calculateNetProration(oldPriceCents: number, newPriceCents: number,
totalDays: number, remainingDays: number): Decimal {
const oldDER = new Decimal(oldPriceCents).div(totalDays);
const newDER = new Decimal(newPriceCents).div(totalDays);
const credit = oldDER.mul(remainingDays); // unused old-plan days
const debit = newDER.mul(remainingDays); // remaining new-plan days
const netAmount = debit.sub(credit);
if (netAmount.gt(new Decimal(newPriceCents))) {
throw new Error('Proration exceeds maximum allowable cycle charge.'); // β safety clamp
}
return netAmount;
}
4. Round and generate idempotent line items
Round half-up to the nearest cent, apply tax after rounding the base, and persist with an idempotency_key so webhook retries cannot double-charge.
{
"invoice_id": "inv_8f3a9c2d",
"idempotency_key": "sub_12345:2026-06-15T14:30:00Z:price_pro_annual",
"line_items": [
{ "type": "proration_credit", "amount_cents": -450, "description": "Unused days on Starter" },
{ "type": "proration_debit", "amount_cents": 1200, "description": "Remaining days on Pro" }
],
"net_proration_cents": 750,
"tax_rate": 0.08,
"tax_applied_cents": 60,
"final_charge_cents": 810,
"currency": "USD",
"status": "pending_capture"
}
Verification & Testing
Assert that credit + net == debit exactly in integer cents after the single rounding step, and that the net never exceeds the new planβs full price. Drive a mock clock to an upgrade at 23:59:59 on the final day and assert remaining_days resolves to a fraction near zero rather than negative. Replay the same idempotency_key and assert exactly one credit/debit pair is persisted. A reconciliation query to run after each upgrade batch:
SELECT subscription_id, SUM(amount_cents) AS net
FROM invoice_line_items
WHERE line_type IN ('proration_credit','proration_debit')
AND invoice_id = $1
GROUP BY subscription_id
HAVING SUM(amount_cents) <> $2; -- expected net; any row here is a drift bug
Gotchas & Production Pitfalls
Negative proration on a downgrade hitting a charge path. A downgrade yields a negative net; sending it to the capture flow attempts a negative charge. Branch on sign and route credits to the balance ledger.
Hardcoded 365 or 30 divisors. These silently mis-bill across leap years and uneven months. Resolve days_in_cycle dynamically from the actual period boundaries at runtime.
Concurrent upgrade requests double-charging. Two near-simultaneous requests both compute and post. Take a row lock (SELECT ... FOR UPDATE) on the subscription and key the line items on subscription_id + upgrade_timestamp + target_price_id.
Recomputing historical proration with live FX. Re-running a past upgrade against todayβs exchange rate changes settled history. Snapshot the FX rate at the exact upgrade_timestamp and store it alongside the ledger entry.
Applying tax before rounding the base. Taxing the unrounded base then rounding the total introduces sub-cent discrepancies between the invoice and the tax engine. Round the base first, then apply tax.