6.4 KiB
6.4 KiB
Proration Utilities
When testing mid-cycle upgrades/downgrades, use the proration utilities to calculate exact expected amounts.
Location: @tests/integration/billing/utils/proration/
Import
import {
getBillingPeriod,
calculateProration,
calculateProratedDiff,
calculateCrossIntervalUpgrade,
} from "@tests/integration/billing/utils/proration";
calculateProratedDiff (Most Common)
Calculate net charge for upgrade/downgrade. Works for base prices, prepaid, and allocated features.
const customerBefore = await autumnV1.customers.get<ApiCustomerV3>(customerId);
const expectedCharge = calculateProratedDiff({
customer: customerBefore,
advancedTo, // From initScenario
oldAmount: 20, // Pro base price
newAmount: 50, // Premium base price
});
expect(preview.total).toBeCloseTo(expectedCharge, 0);
Parameters
| Param | Type | Required | Description |
|---|---|---|---|
customer |
ApiCustomerV3 |
Yes | Customer object from API |
advancedTo |
number |
Yes | Current time from initScenario |
oldAmount |
number |
Yes | Old/current price (credited) |
newAmount |
number |
Yes | New price (charged) |
productId |
string |
No | Filter by specific product ID |
interval |
"month" | "year" |
No | Filter by billing interval |
entityId |
string |
No | For entity-level products |
entityIndex |
number |
No | 0-based index → "ent-1", "ent-2" |
Multi-Product/Multi-Interval/Entity Examples
// Filter by product ID (when customer has multiple products)
calculateProratedDiff({
customer, advancedTo, oldAmount: 20, newAmount: 50, productId: "pro",
});
// Filter by billing interval (for dual subscriptions)
calculateProratedDiff({
customer, advancedTo, oldAmount: 20, newAmount: 50, interval: "month",
});
// Entity-level product
calculateProratedDiff({
customer, advancedTo, oldAmount: 20, newAmount: 50, entityId: "ent-1",
});
// Or using entityIndex (0-based → "ent-1")
calculateProratedDiff({
customer, advancedTo, oldAmount: 20, newAmount: 50, entityIndex: 0,
});
calculateCrossIntervalUpgrade (Monthly → Annual)
Calculate total charge for cross-interval upgrades (e.g., monthly → annual). This is async — it fetches the billing anchor from Stripe.
const expectedCharge = await calculateCrossIntervalUpgrade({
customerId,
advancedTo, // From initScenario
oldAmount: 20, // Current monthly price (credited for remaining period)
newAmount: 200, // New annual price (prorated from now to anchor + 1 year)
oldInterval: "month", // Default: "month"
});
expect(preview.total).toBeCloseTo(expectedCharge, 0);
Parameters
| Param | Type | Required | Description |
|---|---|---|---|
customerId |
string |
Yes | Customer ID |
advancedTo |
number |
Yes | Current time from initScenario |
oldAmount |
number |
No | Current price (default: 0 = no credit) |
newAmount |
number |
Yes | New annual price |
oldInterval |
"month" | "year" |
No | Default: "month" |
Logic: total = annualCharge - oldCredit (Decimal.js, 2 decimal places)
Example — Monthly to Annual Upgrade
test.concurrent(`${chalk.yellowBright("cross-interval: monthly to annual")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({ items: [messagesItem] }); // $20/mo
const proAnnual = products.proAnnual({ items: [messagesItem] }); // $200/yr
const { customerId, autumnV1, ctx, advancedTo } = await initScenario({
customerId: "cross-interval-test",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, proAnnual] }),
],
actions: [
s.billing.attach({ productId: pro.id }),
s.advanceTestClock({ days: 15 }),
],
});
const expectedCharge = await calculateCrossIntervalUpgrade({
customerId,
advancedTo,
oldAmount: 20,
newAmount: 200,
oldInterval: "month",
});
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: proAnnual.id,
});
expect(preview.total).toBeCloseTo(expectedCharge, 0);
});
Key Behaviors
| Feature Type | Prorated on Upgrade? | Use calculateProratedDiff? |
|---|---|---|
| Base price | ✅ Yes | ✅ Yes |
| Prepaid | ✅ Yes | ✅ Yes |
| Allocated | ✅ Yes | ✅ Yes |
| Consumable (arrear) | ❌ No - full amount | ❌ No - add separately |
Mixed Prorated + Non-Prorated (Consumable Arrear)
Consumable/arrear charges are NEVER prorated — add them separately:
// Base price is prorated
const proratedBase = calculateProratedDiff({
customer: customerBefore, advancedTo, oldAmount: 20, newAmount: 50,
});
// Consumable arrear is NOT prorated - full amount
const arrearOverage = 5; // 100 overage × $0.05
const expectedTotal = proratedBase + arrearOverage;
expect(preview.total).toBeCloseTo(expectedTotal, 0);
getBillingPeriod
Get the raw billing period from customer's subscription:
const period = getBillingPeriod({ customer });
// Returns: { start: number, end: number } in milliseconds
// With filters
const monthlyPeriod = getBillingPeriod({ customer, interval: "month" });
const entityPeriod = getBillingPeriod({ customer, entityIndex: 0 });
calculateProration
Calculate prorated amount for a single price (not the difference):
const proratedCharge = calculateProration({
customer, advancedTo, amount: 50, // Full price
});
// Returns prorated amount for remaining period
Why Use These Utilities?
- Correct billing period: Gets actual
current_period_start/endfrom Stripe (not estimated withms.days(30)) - Precision: Uses
Decimal.jsinternally — no floating point errors - Auto-flooring: Automatically floors
advancedToto match Stripe's frozen_time - Multi-subscription support: Handles monthly/annual dual subscriptions, entity products
Anti-Pattern (DON'T DO THIS)
// ❌ BAD — estimating billing period manually
const periodStart = advancedTo - ms.days(15);
const periodEnd = periodStart + ms.days(30); // Wrong! Months vary
// ✅ GOOD — use the utility
const expectedTotal = calculateProratedDiff({
customer: customerBefore, advancedTo, oldAmount: 20, newAmount: 50,
});