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.

You face this decision the moment your product has more than one paid tier and customers who grow into the next one. The upgrade is a happy path for revenue but a sharp edge for correctness: it mixes a credit and a charge on the same invoice, crosses a plan boundary, and is usually triggered by an asynchronous webhook that can arrive twice. Get the arithmetic and the idempotency right once, here, and every future upgrade inherits it.

The mental model that keeps the arithmetic honest is that the customer has already prepaid the whole cycle at the old rate, so the upgrade is not a fresh charge — it is a swap. You are returning the value of the days they will not spend on the old plan and selling them the same days on the new plan. Because both legs use the same remaining-days count against the same cycle divisor, the credit and the debit share a denominator, and any rounding you apply must be applied once to their difference rather than twice to each leg independently. That single-rounding invariant is what lets a downstream reconciliation query prove the invoice balances to the cent, and it is the first thing to protect when you refactor the code. Everything else on this page — UTC normalization, decimal daily rates, the idempotency key — exists to keep that swap deterministic when it is replayed, retried, or audited months later.

Trade-offs

How you pick the divisor and where you round determines both accuracy and compliance exposure. The three approaches below differ mainly in how honestly they treat the calendar — a fixed 30-day divisor is trivial to implement and wrong every month that is not 30 days long.

Proration divisor trade-offs Actual/Actual is GAAP-compliant with zero error; fixed-30 drifts up to three percent; seconds-based is sub-cent and best for high-value cycles. Actual/Actual error: 0¢ GAAP/IFRS compliant use for audited invoicing Fixed 30-day error: up to ~3% drifts on 28/29/31 estimates only Seconds-based error: sub-cent epoch math high-value / sub-daily
Actual/Actual for audited billing, seconds-based for high-value cycles, fixed-30 only for throwaway estimates.
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.

Why the divisor choice is a revenue-recognition decision, not a rounding preference

The reason the divisor matters beyond a fraction of a cent is that it decides how much revenue you recognize in the current period versus how much you defer. Under ASC 606 the prorated debit is recognized ratably over the days it covers, so a divisor that overstates the daily rate pulls revenue forward into the wrong period, and your finance team discovers it during the audit rather than during code review. A concrete case: a 31-day month with a plan priced at 3100 cents yields a clean Actual/Actual daily rate of 100 cents, but a hardcoded 30-day divisor produces 103.33 cents per day — a 3.33 percent overcharge that compounds across every upgrade in that month and every March, May, July, August, October, and December after it. Over a book of ten thousand upgrades that is not a rounding artifact; it is a material misstatement. The seconds-based divisor removes even the day-boundary ambiguity by treating the cycle as epochEnd - epochStart seconds, which is why it is the right default when a single upgrade can move thousands of dollars and the customer might dispute the exact minute they clicked.

Anchor-date drift on monthly cycles

A subtler divisor trap appears when the billing anchor is a day-of-month that some months do not have. A subscription anchored on the 31st bills on the 30th in April and the 28th in February, which means consecutive cycles for the same subscription have different lengths. If you cache days_in_cycle on the plan record instead of deriving it from the live period_start and period_end, every February upgrade on that subscription mis-bills. Derive the divisor from the actual boundaries of the cycle the upgrade falls in, never from a stored constant, and the anchor-date problem disappears on its own.

Step-by-Step Implementation

The implementation is four steps: normalize the boundaries, derive the daily rate, compute the signed net, then round and persist idempotently. The diagram tracks the value as it flows from raw timestamps to a persisted, tax-applied invoice line.

Upgrade proration steps Normalize boundaries, compute daily effective rate, calculate signed net with a clamp, then round and persist with an idempotency key. 1 Normalize UTC boundaries 2 Daily rate decimal DER 3 Signed net + clamp 4 Round + persist idempotent
Value flows from raw timestamps to a persisted, tax-applied line — signed and clamped before it is ever rounded.

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 };
}

Normalizing to UTC before any comparison is not optional. If period_end is stored in the customer’s local zone and the upgrade_timestamp arrives from a webhook in UTC, a naive comparison can place a genuinely mid-cycle upgrade one second outside the window and reject a legitimate charge — or worse, accept one that belongs to the next cycle and bill it against the wrong invoice. Resolving both to Temporal.Instant collapses the zone question to a single epoch scalar, so the only remaining ambiguity is the boundary itself. Treat the window as half-open, [period_start, period_end): an upgrade exactly at period_end belongs to the next cycle, not this one, which is why the guard uses strict < 0 rather than <= 0. Rejecting rather than silently clamping an out-of-window timestamp is deliberate — a timestamp outside the active cycle almost always signals a stale webhook or a clock-skew bug upstream, and swallowing it hides the defect instead of surfacing it while the customer_id and subscription_id are still in scope to debug.

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'))

The six internal decimal places are a specific defense against a specific failure. If you quantize the daily rate to whole cents before multiplying by remaining days, the truncated fraction is multiplied too, and the error grows linearly with the number of days remaining — an upgrade with 27 days left can drift several cents from the mathematically correct figure. Keeping the rate at 0.000001 scale means the only rounding that ever touches the customer’s money happens once, at the end, on the net. Note also that the input is plan_price_cents as an integer of minor units, never a float: representing 1999 cents as 19.99 in a binary float and dividing invites the classic 0.1-cannot-be-represented error, and in billing that error is not academic — it is the difference between an invoice that reconciles and a support ticket. Decimal arithmetic on integer minor units sidesteps the entire class of floating-point drift, which is why both the Python and TypeScript examples reach for a decimal type rather than native numbers.

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;
}

The upper clamp deserves more than the one line it occupies. Its purpose is not to fix a rounding edge but to catch a category of upstream bug — a swapped old/new price, a remaining_days that exceeds total_days because of a boundary error, or a plan record whose price was updated mid-cycle without a corresponding schedule change. Any of these can produce a net that is larger than a full new-plan cycle, and a proration that bills the customer for more than they would pay by simply subscribing fresh is always wrong. Throwing here converts a silent overcharge into a loud, catchable exception that names the subscription_id before the line item is ever written. Keep both legs visible in the ledger rather than collapsing them into the net: storing the proration_credit and proration_debit as separate lines means a customer, an auditor, or a support engineer can read the invoice and see exactly what was returned and what was sold, which a single netted figure hides. The reconciliation query later in this page depends on those two lines existing independently.

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"
}

The shape of the idempotency_key is the whole idempotency strategy in one string. Composing it from subscription_id, the exact upgrade_timestamp, and the target price_id means a retry of the same webhook produces a byte-identical key and is deduplicated, while a genuinely distinct upgrade — a customer who jumps from Starter to Pro and, minutes later, from Pro to Enterprise — produces a different key and is correctly billed twice. A key built only from subscription_id would collapse those two legitimate upgrades into one; a key that included a random nonce or the current wall-clock would defeat deduplication entirely, because the retry would look new. Persist the key with a unique constraint at the database level, not just an application check: the constraint is what holds when two workers race, and the INSERT ... ON CONFLICT DO NOTHING it enables is cheaper and more correct than a read-then-write. Note too that tax is computed on the already-rounded net_proration_cents of 750, yielding 60 cents at eight percent, and only then summed into final_charge_cents — the rounding of the base precedes the tax, never follows it.

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. The boundary cases below are the ones worth encoding as explicit tests.

Upgrade proration boundary tests Last-second upgrade, replay of the same idempotency key, and a downgrade sign flip are the three boundary tests that catch the common bugs. Last-second upgrade 23:59:59 final day remaining ≈ 0, not < 0 Replay key same idempotency_key exactly one pair Downgrade sign net < 0 routes to balance
Three boundary tests catch the bugs that example inputs miss: the last second, the replay, and the sign flip.

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

Run that query as an assertion inside the same transaction that writes the line items, not as a nightly job. If the sum of the credit and debit lines does not equal the net you computed in step three, the transaction should roll back before the customer is ever charged, because a discrepancy at write time is a code bug, not a data-quality issue to be cleaned up later. The nightly variant is still worth keeping as a second line of defense, but its role is to catch drift introduced by out-of-band adjustments — a manual credit memo, a refund posted directly to the ledger — rather than the arithmetic this page controls.

Property tests beat example tests here

Example-based tests catch the inputs you thought of; proration bugs live in the inputs you did not. A property test that generates random plan_price_cents between 100 and 1000000, random cycle lengths between 28 and 366 days, and a random upgrade_timestamp inside the window, then asserts the single invariant credit + net == debit in integer cents, will find the off-by-one-day and double-rounding bugs far faster than a hand-written table. Add a second property — that the net is always less than or equal to the full new-plan price and greater than or equal to the negative of the full old-plan price — and you have bounded the output on both sides. Seed the generator deterministically so a failing case is reproducible, and capture the failing subscription_id, prices, and timestamp in the assertion message so the shrunk counterexample tells you exactly which boundary broke.

Gotchas & Production Pitfalls

The pitfalls below are the ones that reach production despite passing a naive test suite; the map groups them by where the bug hides — in the sign, the calendar, concurrency, or the tax/FX ordering.

Upgrade proration pitfalls Sign errors on downgrades, hardcoded divisors, concurrent double-charges, and tax-before-rounding are the four recurring production pitfalls. Sign negative net to charge path → branch on sign Calendar hardcoded 30/365 leap-year drift → dynamic divisor Concurrency two requests double charge → row lock + key Tax / FX tax before round live FX replay → snapshot both
Four categories of pitfall — each hides in a different layer, and each has a one-line defense.

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.

Crediting a plan the customer never actually paid. If the old plan was itself acquired mid-cycle through a previous proration, the customer did not prepay a full cycle at the old rate — they prepaid a partial one. Crediting the full old-plan daily rate against days they only partially paid for over-refunds them. Derive the credit from what was actually invoiced for the current cycle, not from the plan’s list price, and store a reference to the originating invoice_id so the chain is auditable.

Coupons and account credits distorting the daily rate. A percentage discount or a fixed account credit applied to the old plan lowers the effective price the customer paid, but the proration credit is often computed from the undiscounted list price. The result is a credit larger than the customer’s actual outlay. Compute the daily effective rate from the net-of-discount amount that landed on the original invoice line, and apply the new plan’s discount, if any, to the debit leg symmetrically.

Proration on a plan whose price changed mid-cycle. If pricing was updated after the cycle started — a grandfathered customer moved to new list pricing, or a currency reprice landed — the price stored on the plan record no longer matches what the customer was billed. Snapshot the price onto the subscription at the moment the cycle was invoiced and read the credit rate from that snapshot, never from the mutable plan record.

Silent failure when remaining_days rounds to zero. An upgrade in the final seconds of a cycle produces a near-zero remaining fraction, and a net that rounds to zero cents. Posting a zero-value invoice line is usually harmless, but silently applying the plan change without any ledger entry can leave the next full cycle billing the old price. Treat a zero-cent net as a valid outcome that still flips the plan, and assert the subscription’s price_id advances even when no money moves.

Frequently Asked Questions

Should proration be by day or by second? By second, computed from the actual instants. Day-granularity rounding produces amounts that disagree with any other prorated line on the same invoice and is impossible to reconcile at the boundary.

Is it better to charge the proration immediately or add it to the next invoice? Immediately for upgrades, because the customer is gaining access now and expects a charge. Deferring it produces a larger, more surprising invoice later.

How is the unused portion of the old plan handled? As a credit against the new charge, computed over the same remaining interval. Computing the two over different bases is where most proration arithmetic goes wrong.

Does the billing anchor move on an upgrade? It should not, unless you deliberately want the invoice day to change. Keeping the anchor fixed makes future invoices predictable and keeps monthly cohorts intact.