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.