How to test prorated billing on upgrades, downgrades, and cancellations
Testing prorated billing means recomputing each credit and charge line of a mid-cycle upgrade, downgrade, or cancellation from the period dates, the proration basis, the rounding mode, and the currency's minor units, then comparing every line with the billing engine. The check that finds most defects asserts each line separately, because a correct net can hide a wrong line.
Where proration defects come from
Prorated charges and credits are invoice lines for part of a billing period, and their defects sit in four inputs: the length of the period, the unused share of it, the rounding of each line, and the minor units of the currency. A test suite built on 30-day months, changes at midnight UTC, and USD prices exercises none of them. The defects then reach production through the cases nobody wrote down: an anchor on 31 January, a change in the evening in New York, a February in a leap year, a price in yen.
Each defect repeats on every subscriber who changes plan under the same configuration. A wrong divisor over-credits or under-charges every upgrade in the month it affects. A credit that references the wrong price or tax rate turns into a credit note the client's finance team has to reissue. The subscription billing segment page traces how these errors reach the client's revenue and the platform's percentage; this guide gives the test data and the checks.
Billing engines document different behaviour. Stripe calculates prorations "down to the second" by default. Chargebee offers a day-based and a millisecond-based proration mode. Recurly states that the denominator "always represents the plan's full billing period". Your specification has to pick one behaviour per plan, and the tests hold the engine to it. All vendor pages were checked on 2026-09-22.
Procedure
1. Write the proration specification per plan
Record these decisions before any expected value exists:
- Basis: calendar days or seconds. On a day basis, state whether the change day counts as used or unused. Chargebee's day-based mode treats a cancellation "at a timestamp greater than the creation timestamp" as a "next-day cancellation".
- Anchor rule: the day of month, the time, and the time zone the anchor is stored in. Stripe documents that the billing cycle anchor uses UTC.
- Rounding: the mode (half-even or half-up) and the rounding point, per line or per invoice. The banker's rounding guide covers tie vectors and runtime defaults.
- Settlement of the result: a net charge invoiced now or on the next invoice, and a net credit refunded, held as balance, or issued as a credit note.
- Tax: the rate and address used for the credit line and for the charge line.
2. Build the case set
Every row below is synthetic. Periods start at the anchor and end at the next anchor, timestamps are UTC, and prices are monthly in the stated currency.
case,change,currency,tz,period_start,period_end,change_at,old_price,new_price,basis,rounding,old_tax_rate,new_tax_rate
U1-D,upgrade,USD,UTC,2027-03-01T00:00:00Z,2027-04-01T00:00:00Z,2027-03-11T12:00:00Z,30.00,60.00,day,HALF_EVEN,0,0
U1-S,upgrade,USD,UTC,2027-03-01T00:00:00Z,2027-04-01T00:00:00Z,2027-03-11T12:00:00Z,30.00,60.00,second,HALF_EVEN,0,0
D1-D,downgrade,USD,UTC,2027-03-01T00:00:00Z,2027-04-01T00:00:00Z,2027-03-11T12:00:00Z,60.00,30.00,day,HALF_EVEN,0,0
X1-D,cancel,USD,UTC,2027-03-01T00:00:00Z,2027-04-01T00:00:00Z,2027-03-11T12:00:00Z,60.00,0.00,day,HALF_EVEN,0,0
L1-D,upgrade,USD,UTC,2028-02-01T00:00:00Z,2028-03-01T00:00:00Z,2028-02-15T00:00:00Z,30.00,60.00,day,HALF_EVEN,0,0
M1-D,upgrade,USD,UTC,2027-01-31T00:00:00Z,2027-02-28T00:00:00Z,2027-02-14T00:00:00Z,30.00,60.00,day,HALF_EVEN,0,0
J1-D,upgrade,JPY,UTC,2027-03-01T00:00:00Z,2027-04-01T00:00:00Z,2027-03-11T12:00:00Z,1000,3000,day,HALF_EVEN,0,0
K1-D,upgrade,KWD,UTC,2027-03-01T00:00:00Z,2027-04-01T00:00:00Z,2027-03-11T12:00:00Z,5.000,10.000,day,HALF_EVEN,0,0
T1-E,upgrade,USD,UTC,2027-04-01T00:00:00Z,2027-05-01T00:00:00Z,2027-04-16T00:00:00Z,10.01,20.01,day,HALF_EVEN,0,0
T1-U,upgrade,USD,UTC,2027-04-01T00:00:00Z,2027-05-01T00:00:00Z,2027-04-16T00:00:00Z,10.01,20.01,day,HALF_UP,0,0
Z1-D,upgrade,USD,America/New_York,2027-03-01T05:00:00Z,2027-04-01T04:00:00Z,2027-03-11T03:00:00Z,30.00,60.00,day,HALF_EVEN,0,0
Z1-S,upgrade,USD,America/New_York,2027-03-01T05:00:00Z,2027-04-01T04:00:00Z,2027-03-11T03:00:00Z,30.00,60.00,second,HALF_EVEN,0,0
V1-D,upgrade,EUR,UTC,2027-03-01T00:00:00Z,2027-04-01T00:00:00Z,2027-03-11T12:00:00Z,49.99,99.99,day,HALF_EVEN,0.20,0.22Z1 anchors at local midnight in New York, so its period spans the daylight saving change of 14 March 2027 and lasts 31 days minus one hour. The tax rates in V1 are synthetic and model a rate change between the original invoice and the plan change.
3. Compute expected lines with a reference model
The model calculates prorated charges and credits in integer minor units, rounds each line on its own, and records the net that rounding the price difference once would give. Minor units come from ISO 4217 list one, published 2026-09-17: 0 for JPY, 2 for USD and EUR, 3 for KWD.
# reference_model.py: expected proration lines for every row of proration_cases.csv
# Needs Python 3.9+; on Windows also: pip install tzdata
import csv
from datetime import datetime
from decimal import Decimal, InvalidOperation, ROUND_HALF_EVEN, ROUND_HALF_UP
from zoneinfo import ZoneInfo
MINOR_UNITS = {"USD": 2, "EUR": 2, "JPY": 0, "KWD": 3} # ISO 4217 list one, published 2026-09-17
MODES = {"HALF_EVEN": ROUND_HALF_EVEN, "HALF_UP": ROUND_HALF_UP}
def ts(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def fraction(row) -> tuple[int, int]:
"""Unused part of the period as (numerator, denominator) in the row's basis."""
start, end, change = ts(row["period_start"]), ts(row["period_end"]), ts(row["change_at"])
if row["basis"] == "second":
return int((end - change).total_seconds()), int((end - start).total_seconds())
tz = ZoneInfo(row["tz"]) # day basis: calendar dates in the anchor time zone, change day unused
d = lambda t: t.astimezone(tz).date()
return (d(end) - d(change)).days, (d(end) - d(start)).days
def to_minor(amount: Decimal, mode: str) -> int:
return int(amount.quantize(Decimal(1), rounding=MODES[mode]))
def lines(row) -> dict:
mode = row["rounding"]
scale = Decimal(10) ** MINOR_UNITS[row["currency"]]
num, den = fraction(row)
old, new = Decimal(row["old_price"]) * scale, Decimal(row["new_price"]) * scale
credit = -to_minor(old * num / den, mode)
charge = to_minor(new * num / den, mode)
credit_tax = -to_minor(-credit * Decimal(row["old_tax_rate"]), mode)
charge_tax = to_minor(charge * Decimal(row["new_tax_rate"]), mode)
return {"case": row["case"], "unused": f"{num}/{den}", "credit": credit, "charge": charge,
"net": credit + charge, "net_rounded_once": to_minor((new - old) * num / den, mode),
"credit_tax": credit_tax, "charge_tax": charge_tax}
if __name__ == "__main__":
with open("proration_cases.csv", newline="") as f:
for row in csv.DictReader(f):
try:
print(lines(row))
except (KeyError, ValueError, InvalidOperation, ZeroDivisionError) as exc:
print({"case": row.get("case"), "error": repr(exc)})Run against the CSV above on 2026-09-22, the model returns these lines. Every amount is in the row's currency, and the unused share is in the row's basis.
| Case | Period and basis | Unused share | Credit | Charge | Net per line | Net rounded once |
|---|---|---|---|---|---|---|
| U1-D | 1 Mar to 1 Apr 2027, day | 21 of 31 days | USD -20.32 | USD 40.65 | USD 20.33 | USD 20.32 |
| U1-S | 1 Mar to 1 Apr 2027, second | 1,771,200 of 2,678,400 s | USD -19.84 | USD 39.68 | USD 19.84 | USD 19.84 |
| D1-D | 1 Mar to 1 Apr 2027, day | 21 of 31 days | USD -40.65 | USD 20.32 | USD -20.33 | USD -20.32 |
| X1-D | 1 Mar to 1 Apr 2027, day | 21 of 31 days | USD -40.65 | none | USD -40.65 | USD -40.65 |
| L1-D | 1 Feb to 1 Mar 2028, day | 15 of 29 days | USD -15.52 | USD 31.03 | USD 15.51 | USD 15.52 |
| M1-D | 31 Jan to 28 Feb 2027, day | 14 of 28 days | USD -15.00 | USD 30.00 | USD 15.00 | USD 15.00 |
| J1-D | 1 Mar to 1 Apr 2027, day | 21 of 31 days | JPY -677 | JPY 2,032 | JPY 1,355 | JPY 1,355 |
| K1-D | 1 Mar to 1 Apr 2027, day | 21 of 31 days | KWD -3.387 | KWD 6.774 | KWD 3.387 | KWD 3.387 |
| T1-E | 1 Apr to 1 May 2027, day, half-even | 15 of 30 days | USD -5.00 | USD 10.00 | USD 5.00 | USD 5.00 |
| T1-U | 1 Apr to 1 May 2027, day, half-up | 15 of 30 days | USD -5.01 | USD 10.01 | USD 5.00 | USD 5.00 |
| Z1-D | 1 Mar to 1 Apr 2027 New York time, day | 22 of 31 days | USD -21.29 | USD 42.58 | USD 21.29 | USD 21.29 |
| Z1-S | 1 Mar to 1 Apr 2027 New York time, second | 1,818,000 of 2,674,800 s | USD -20.39 | USD 40.78 | USD 20.39 | USD 20.39 |
| V1-D | 1 Mar to 1 Apr 2027, day | 21 of 31 days | EUR -33.86 | EUR 67.74 | EUR 33.88 | EUR 33.87 |
Three rows carry the common defects. L1-D with a fixed 30-day divisor gives a credit of USD -15.00 and a charge of USD 30.00 for 1 February to 1 March 2028; with a 28-day February it gives USD -16.07 and USD 32.14. Z1-D counted on UTC dates gives 21 of 31 days instead of 22, because 22:00 on 10 March in New York is already 11 March in UTC. X1-D under a next-day cancellation rule leaves 20 of 31 days and a credit of USD -38.71. Day count conventions for interest follow the same logic and are covered in the interest accrual guide.
4. Derive every period from the anchor
Periods for anchors on the 29th to the 31st must come from the original anchor day. Stripe documents that an anchor of 31 January bills on 28 February, or 29 February in a leap year, then 31 March and 30 April. An engine that derives each period from the previous period end drifts to the 28th for good.
# periods.py: monthly period ends derived from the original anchor, clamped to month end
import calendar
from datetime import datetime
from zoneinfo import ZoneInfo
UTC, NY, BERLIN = ZoneInfo("UTC"), ZoneInfo("America/New_York"), ZoneInfo("Europe/Berlin")
def period_end(anchor: datetime, n: int) -> datetime:
"""End of the n-th monthly period; the day comes from the anchor, never from the previous end."""
m = anchor.month - 1 + n
year, month = anchor.year + m // 12, m % 12 + 1
day = min(anchor.day, calendar.monthrange(year, month)[1])
return anchor.replace(year=year, month=month, day=day)
def chained_end(anchor: datetime, n: int) -> datetime:
"""The defect: each period end derived from the previous one."""
end = anchor
for _ in range(n):
end = period_end(end, 1)
return end
ends = lambda a, count: [period_end(a, n).date().isoformat() for n in range(1, count + 1)]
# Anchors on the 29th, 30th, and 31st in 2027 (365 days) and 2028 (leap year)
assert ends(datetime(2027, 1, 31, 9, tzinfo=UTC), 3) == ["2027-02-28", "2027-03-31", "2027-04-30"]
assert ends(datetime(2028, 1, 31, 9, tzinfo=UTC), 2) == ["2028-02-29", "2028-03-31"]
assert ends(datetime(2027, 1, 30, 9, tzinfo=UTC), 2) == ["2027-02-28", "2027-03-30"]
assert ends(datetime(2027, 1, 29, 9, tzinfo=UTC), 2) == ["2027-02-28", "2027-03-29"]
assert ends(datetime(2028, 1, 29, 9, tzinfo=UTC), 2) == ["2028-02-29", "2028-03-29"]
assert chained_end(datetime(2027, 1, 31, 9, tzinfo=UTC), 2).date().isoformat() == "2027-03-28"
# 20:00 on 31 January 2027 in New York is 01:00 UTC on 1 February 2027
assert datetime(2027, 1, 31, 20, tzinfo=NY).astimezone(UTC).day == 1
# Anchor 23:30 Berlin time on 15 January 2027, stored as local time versus as UTC
local = datetime(2027, 1, 15, 23, 30, tzinfo=BERLIN)
assert period_end(local, 6).isoformat() == "2027-07-15T23:30:00+02:00"
assert period_end(local.astimezone(UTC), 6).astimezone(BERLIN).isoformat() == "2027-07-16T00:30:00+02:00"
print("periods: all assertions passed")The last two assertions show the time zone defect: an anchor stored in UTC moves the July invoice to the 16th in Berlin, one day after the customer's billing day.
5. Send each case through the billing engine
Expose the engine's preview in the test environment through a small adapter that takes one CSV row and returns the lines in minor units. Fix the change timestamp in the request: Stripe notes that because it prorates to the second, a preview can differ from the applied change unless the same proration_date is passed to both.
POST /v1/test/proration-quotes HTTP/1.1
Host: api.example.com
Authorization: Bearer sk_test_EXAMPLE_ONLY
Content-Type: application/json
{"currency": "USD", "tz": "UTC", "period_start": "2027-03-01T00:00:00Z", "period_end": "2027-04-01T00:00:00Z", "change_at": "2027-03-11T12:00:00Z", "old_price": "30.00", "new_price": "60.00", "basis": "second", "rounding": "HALF_EVEN", "old_tax_rate": "0", "new_tax_rate": "0"}{"currency": "USD", "credit": -1984, "charge": 3968, "net": 1984, "credit_tax": 0, "charge_tax": 0}# test_proration_quotes.py: run with pytest next to proration_cases.csv and reference_model.py
import csv
import os
import pytest
import requests
from reference_model import lines
BASE = os.environ.get("BILLING_TEST_URL", "https://api.example.com/v1/test")
FIELDS = ("currency", "tz", "period_start", "period_end", "change_at", "old_price",
"new_price", "basis", "rounding", "old_tax_rate", "new_tax_rate")
AMOUNTS = ("credit", "charge", "net", "credit_tax", "charge_tax")
with open("proration_cases.csv", newline="") as f:
CASES = list(csv.DictReader(f))
@pytest.mark.parametrize("row", CASES, ids=[r["case"] for r in CASES])
def test_quote_matches_reference(row):
try:
resp = requests.post(f"{BASE}/proration-quotes", json={k: row[k] for k in FIELDS}, timeout=10)
except requests.RequestException as exc:
pytest.fail(f"{row['case']}: no response: {exc!r}")
try:
body = resp.json()
actual = {k: body[k] for k in AMOUNTS}
except (ValueError, KeyError, TypeError):
pytest.fail(f"{row['case']}: HTTP {resp.status_code}, body {resp.text[:200]!r}")
expected = lines(row)
assert actual == {k: expected[k] for k in AMOUNTS}, row["case"]
assert actual["net"] == actual["credit"] + actual["charge"], "net is not the sum of the lines"6. Test where the credit and the charge go
The same net lands in different places depending on configuration. Stripe documents that under the default create_prorations, negative prorations are not refunded automatically and positive prorations are not billed immediately, while always_invoice invoices at once. Chargebee issues refundable credits when the current invoice is paid and adjustment credits when it is unpaid. Recurly applies change credits to the immediate charge by default.
For D1-D, a net credit of USD -20.33 on 11 March 2027 (day basis, 21 of 31 days), assert the balance or credit note the specification names, and assert that the next renewal on 1 April 2027 of the USD 30.00 plan collects USD 9.67 when the credit is held as balance. For U1-D, assert whether USD 20.33 is charged at the change or added to the renewal invoice. When the period's invoice is still unpaid, a credit for unused time pays back money that was never collected; Stripe documents this case and suggests proration_behavior=none for it. Failed payments and their retries belong to the dunning testing guide.
7. Test tax on each proration line
A credit line reverses tax that was charged on the original invoice, so it takes the original rate and address. Recurly documents that change credits "reverse the taxes on the referenced charge proportionally" and use "the original address on the referenced charge". Stripe's tax rates guide says to use proration_details to find the original debit and adjust tax for credit prorations.
For V1-D (1 March to 1 April 2027, day basis, 21 of 31 days, synthetic rates), the credit of EUR -33.86 carries tax of EUR -6.77 at 20% and the charge of EUR 67.74 carries EUR 14.90 at 22%, a tax total of EUR 8.13. An engine that taxes only the net line of EUR 33.88 at 22% bills EUR 7.45. The invoice and credit note rules for EU VAT are on the subscription billing testing service page.
The thresholds vendor docs leave out
Line rounding moves the net by one minor unit. U1-D rounds to USD 20.33 per line and USD 20.32 when the price difference is rounded once. Chargebee's own day-based example shows the same effect for a USD 300 to USD 600 monthly upgrade with 22 of 31 days left in a term starting 23 July 2025: a credit of USD 212.90 and a charge of USD 425.81 give a net of USD 212.91, while 300 / 31 × 22 rounds to USD 212.90. A test that computes the expected net from the price difference fails a correct engine, and a test that checks only the net passes an engine that rounds the wrong line. Assert each line and assert that the net equals their sum.
A second-basis preview goes stale after period seconds divided by price in minor units. For U1-S the USD 60.00 charge line moves by one cent every 2,678,400 / 6,000 = 446.4 seconds of the 31-day March 2027 period, so a quote shown to a customer is out by a cent after about 7.4 minutes. For a USD 6,000.00 annual plan over a 365-day year the window is 31,536,000 / 600,000 = 52.56 seconds. Test the confirm step after waiting longer than the window, with and without the fixed proration timestamp.
The anchor time zone changes the day count. Z1-D gains a day when counted in New York time, and a sign-up at 20:00 on 31 January 2027 in New York anchors on 1 February in UTC. Stripe records an anchor set at 5 PM EST as 10 PM UTC. Place changes between local midnight and UTC midnight for each customer zone: in New York that window runs from 19:00 to 24:00 local time in winter and from 20:00 to 24:00 in summer.
Test matrix
| Case | Input | Expected |
|---|---|---|
| Upgrade, day basis | U1-D | Credit USD -20.32, charge USD 40.65 |
| Upgrade, second basis | U1-S | Credit USD -19.84, charge USD 39.68 |
| Downgrade | D1-D | Net USD -20.33 held as specified |
| Cancellation mid-cycle | X1-D | Credit USD -40.65, or USD -38.71 under a next-day rule |
| Cancellation at period end | Cancel effective 1 Apr 2027 | No proration line |
| Leap-year February | L1-D | Divisor 29 days |
| Anchor on the 31st | 31 Jan 2027 anchor | Periods end 28 Feb, 31 Mar, 30 Apr 2027 |
| Anchor on the 29th in a leap year | 29 Jan 2028 anchor | Period ends 29 Feb 2028 |
| Zero-decimal currency | J1-D | Integer yen, no decimals |
| Three-decimal currency | K1-D | Three decimals in fils |
| Tie on a line | T1-E and T1-U | Half-up lines one cent larger, net USD 5.00 in both |
| Customer time zone | Z1-D | 22 of 31 days in New York time |
| Tax on credit | V1-D | Credit taxed at the original 20% |
| Unpaid period invoice | Downgrade with an open invoice | Credit follows the unpaid-invoice rule |
| Second change in one period | Upgrade without proration, then downgrade | Credit based on the price the specification names |
Stripe documents the last case for billing_mode=classic: a credit of USD -6.67 for a third of a month on a USD 20 price that was never billed, against a net of USD 0 under flexible. The financial calculation testing service turns this matrix into a reference-model suite per formula.
Related
Segments
Questions
How to calculate prorated charges?
What are prorated charges?
Scope your testing
Send FintechAssuranceLabs your product, segment, money flows, and the standards that need evidence, and get a proposed test scope with its deliverables.
Scope your testing