updated prepaid volume billing tests

This commit is contained in:
John Yeo
2026-02-25 22:31:01 +00:00
parent b8a7f8081c
commit 97b28a42ca
4 changed files with 278 additions and 100 deletions

View File

@@ -1,103 +1,13 @@
/** biome-ignore-all lint/suspicious/noDoubleEquals: need to use falsy check */
import {
AllowanceType,
BillingInterval,
BillingType,
type Entitlement,
ErrCode,
type FixedPriceConfig,
FixedPriceConfigSchema,
type Price,
PriceType,
type UsagePriceConfig,
UsagePriceConfigSchema,
type UsageTier,
} from "@autumn/shared";
import { getBillingType } from "@server/internal/products/prices/priceUtils";
import RecaseError from "@server/utils/errorUtils";
import { generateId } from "@server/utils/genUtils";
const constructPrice = ({
name,
config,
orgId,
internalProductId,
isCustom = false,
}: {
name: string;
config: UsagePriceConfig | FixedPriceConfig;
orgId: string;
internalProductId: string;
isCustom: boolean;
}) => {
return {
id: generateId("pr"),
org_id: orgId,
internal_product_id: internalProductId,
created_at: Date.now(),
billing_type: getBillingType(config),
is_custom: isCustom,
name,
config,
};
};
// GET PRICES
const validatePrice = (
price: Price,
relatedEnt?: Entitlement | undefined | null,
) => {
if (!price.config?.type) {
throw new RecaseError({
message: "Missing `type` field in price config",
code: ErrCode.InvalidPriceConfig,
statusCode: 400,
});
}
if (price.config?.type == PriceType.Fixed) {
FixedPriceConfigSchema.parse(price.config);
} else {
UsagePriceConfigSchema.parse(price.config);
const config = price.config! as UsagePriceConfig;
if (config.usage_tiers.length == 0) {
throw new RecaseError({
message: "Usage based prices should have at least one tier",
code: ErrCode.InvalidPriceConfig,
statusCode: 400,
});
}
if (relatedEnt?.allowance_type == AllowanceType.Unlimited) {
if (config.interval == BillingInterval.OneOff) {
throw new RecaseError({
message: `Usage-based price cannot have unlimited allowance (${relatedEnt.feature_id})`,
code: ErrCode.InvalidPriceConfig,
statusCode: 400,
});
}
}
const billingType = getBillingType(config);
if (billingType == BillingType.UsageInArrear) {
if (config.interval == BillingInterval.OneOff) {
throw new RecaseError({
message: "One off prices must be billed at start of period",
code: ErrCode.InvalidPriceConfig,
statusCode: 400,
});
}
}
}
return {
valid: true,
error: null,
};
};
export const tiersAreSame = (tiers1: any[], tiers2: any[]) => {
export const tiersAreSame = (tiers1: UsageTier[], tiers2: UsageTier[]) => {
if (tiers1.length !== tiers2.length) return false;
for (let i = 0; i < tiers1.length; i++) {
const tier1 = tiers1[i];

View File

@@ -49,7 +49,7 @@ test.concurrent(`${chalk.yellowBright("p2p: graduated prepaid to volume prepaid
s.products({ list: [pro] }),
],
actions: [
s.attach({
s.billing.attach({
productId: "pro",
options: [{ feature_id: TestFeature.Messages, quantity }],
}),
@@ -159,7 +159,7 @@ test.concurrent(`${chalk.yellowBright("p2p: volume prepaid to graduated tiered c
s.products({ list: [pro] }),
],
actions: [
s.attach({
s.billing.attach({
productId: "pro",
options: [{ feature_id: TestFeature.Messages, quantity }],
}),

View File

@@ -13,6 +13,9 @@ import chalk from "chalk";
* Verifies that `subscriptions.update` charges the correct delta when quantity changes
* under VOLUME pricing (entire quantity billed at the rate of its tier).
*
* Uses V2 billing.attach (quantity INCLUDES included usage / allowance).
* With includedUsage=0, the quantity is purely purchased units.
*
* Tier setup (billingUnits = 100):
* Tier 1: 0500 units → $10 / pack → volumeCost(n) = (n/100) × $10
* Tier 2: 501+ units → $5 / pack → volumeCost(n) = (n/100) × $5
@@ -69,7 +72,7 @@ test.concurrent(`${chalk.yellowBright("volume-tiers-update-quantity: increase sa
s.products({ list: [product] }),
],
actions: [
s.attach({
s.billing.attach({
productId: product.id,
options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }],
}),
@@ -132,7 +135,7 @@ test.concurrent(`${chalk.yellowBright("volume-tiers-update-quantity: increase cr
s.products({ list: [product] }),
],
actions: [
s.attach({
s.billing.attach({
productId: product.id,
options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }],
}),
@@ -196,7 +199,7 @@ test.concurrent(`${chalk.yellowBright("volume-tiers-update-quantity: increase sa
s.products({ list: [product] }),
],
actions: [
s.attach({
s.billing.attach({
productId: product.id,
options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }],
}),
@@ -259,7 +262,7 @@ test.concurrent(`${chalk.yellowBright("volume-tiers-update-quantity: decrease sa
s.products({ list: [product] }),
],
actions: [
s.attach({
s.billing.attach({
productId: product.id,
options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }],
}),
@@ -322,7 +325,7 @@ test.concurrent(`${chalk.yellowBright("volume-tiers-update-quantity: decrease cr
s.products({ list: [product] }),
],
actions: [
s.attach({
s.billing.attach({
productId: product.id,
options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }],
}),
@@ -355,3 +358,149 @@ test.concurrent(`${chalk.yellowBright("volume-tiers-update-quantity: decrease cr
amount: expectedDelta,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// Tests 67: With included usage (includedUsage=100)
//
// V2 billing.attach quantity INCLUDES included usage.
// V2 Stripe volume tiers have boundaries shifted by allowance (1 pack),
// and Stripe quantity = total packs (including included).
//
// Stripe tiers: [{up_to: 6, $10}, {up_to: inf, $5}] (500/100+1=6)
// 4 total packs (400 units) → tier 1 → 4 × $10 = $40
// 9 total packs (900 units) → tier 2 → 9 × $5 = $45
// ═══════════════════════════════════════════════════════════════════════════════
const INCLUDED_USAGE = 100;
// ─── Test 6: Increase with included — crosses tier (400 → 900 total) ─────────
test.concurrent(`${chalk.yellowBright("volume-tiers-update-quantity: increase with included 400→900")}`, async () => {
const customerId = "volume-update-qty-incl-increase";
const initQuantity = 400; // 4 total packs (1 included + 3 purchased)
const newQuantity = 900; // 9 total packs (1 included + 8 purchased)
// Stripe V2 volume tiers shifted by allowance (1 pack): [{up_to:6,$10}, {up_to:inf,$5}]
// Old: 4 total packs → tier 1 (≤6) → 4 × $10 = $40
// New: 9 total packs → tier 2 (>6) → 9 × $5 = $45
// Delta: +$5
const oldCost = (initQuantity / BILLING_UNITS) * 10; // $40
const newCost = (newQuantity / BILLING_UNITS) * 5; // $45
const expectedDelta = newCost - oldCost; // $5
const volumeItem = items.volumePrepaidMessages({
includedUsage: INCLUDED_USAGE,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const product = products.base({
id: "vol-upd-qty-incl-inc",
items: [volumeItem],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [product] }),
],
actions: [
s.billing.attach({
productId: product.id,
options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }],
}),
],
});
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: product.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
});
expect(preview.total).toBe(expectedDelta);
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: product.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
});
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customerAfter.features?.[TestFeature.Messages]?.balance).toBe(
newQuantity,
);
expectLatestInvoiceCorrect({
customer: customerAfter,
productId: product.id,
amount: expectedDelta,
});
});
// ─── Test 7: Decrease with included — crosses tier (900 → 400 total) ─────────
test.concurrent(`${chalk.yellowBright("volume-tiers-update-quantity: decrease with included 900→400")}`, async () => {
const customerId = "volume-update-qty-incl-decrease";
const initQuantity = 900; // 9 total packs (1 included + 8 purchased)
const newQuantity = 400; // 4 total packs (1 included + 3 purchased)
// Stripe V2 volume tiers shifted by allowance (1 pack): [{up_to:6,$10}, {up_to:inf,$5}]
// Old: 9 total packs → tier 2 (>6) → 9 × $5 = $45
// New: 4 total packs → tier 1 (≤6) → 4 × $10 = $40
// Delta: $5
const oldCost = (initQuantity / BILLING_UNITS) * 5; // $45
const newCost = (newQuantity / BILLING_UNITS) * 10; // $40
const expectedDelta = newCost - oldCost; // -$5
const volumeItem = items.volumePrepaidMessages({
includedUsage: INCLUDED_USAGE,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const product = products.base({
id: "vol-upd-qty-incl-dec",
items: [volumeItem],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [product] }),
],
actions: [
s.billing.attach({
productId: product.id,
options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }],
}),
],
});
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: product.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
});
expect(preview.total).toBe(expectedDelta);
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: product.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
});
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customerAfter.features?.[TestFeature.Messages]?.balance).toBe(
newQuantity,
);
expectLatestInvoiceCorrect({
customer: customerAfter,
productId: product.id,
amount: expectedDelta,
});
});

View File

@@ -232,6 +232,125 @@ describe("volumeTiersToLineAmount", () => {
});
});
describe("allowance (free tier prepended)", () => {
// With allowance=50, tiers become:
// [{to:50, amt:0}, {to:150, amt:0.10}, {to:550, amt:0.05}, {to:Inf, amt:0.02}]
// Volume: entire usage charged at whichever tier it falls into.
test("usage below allowance → $0 (falls in free tier)", () => {
expect(
volumeTiersToLineAmount({
tiers: THREE_TIERS,
usage: 30,
allowance: 50,
}),
).toBe(0);
});
test("usage exactly at allowance → $0 (boundary of free tier)", () => {
expect(
volumeTiersToLineAmount({
tiers: THREE_TIERS,
usage: 50,
allowance: 50,
}),
).toBe(0);
});
test("usage just above allowance → falls in shifted tier 1: 51 @ $0.10 = $5.10", () => {
// Tiers with allowance=50: [{to:50,$0}, {to:150,$0.10}, ...]
// 51 > 50, 51 <= 150 → entire 51 × $0.10
expect(
volumeTiersToLineAmount({
tiers: THREE_TIERS,
usage: 51,
allowance: 50,
}),
).toBe(5.1);
});
test("usage in shifted tier 1: 120 @ $0.10 = $12 (includes free portion)", () => {
// 120 > 50, 120 <= 150 → entire 120 × $0.10
expect(
volumeTiersToLineAmount({
tiers: THREE_TIERS,
usage: 120,
allowance: 50,
}),
).toBe(12);
});
test("usage in shifted tier 2: 200 @ $0.05 = $10", () => {
// 200 > 50, > 150, 200 <= 550 → entire 200 × $0.05
expect(
volumeTiersToLineAmount({
tiers: THREE_TIERS,
usage: 200,
allowance: 50,
}),
).toBe(10);
});
test("usage in shifted tier 3: 600 @ $0.02 = $12", () => {
// 600 > 50, > 150, > 550 → entire 600 × $0.02
expect(
volumeTiersToLineAmount({
tiers: THREE_TIERS,
usage: 600,
allowance: 50,
}),
).toBe(12);
});
test("allowance with billingUnits: usage below allowance rounds up but stays in free tier → $0", () => {
// Tiers: [{to:500, $10}, {to:Inf, $5}], allowance=100, billingUnits=100
// Tiers with allowance: [{to:100,$0}, {to:600,$10}, {to:Inf,$5}]
// Usage 50 rounds up to 100, 100 <= 100 → free tier → $0
expect(
volumeTiersToLineAmount({
tiers: [
{ to: 500, amount: 10 },
{ to: Infinite, amount: 5 },
],
usage: 50,
allowance: 100,
billingUnits: 100,
}),
).toBe(0);
});
test("allowance with billingUnits: usage above allowance → paid tier", () => {
// Tiers with allowance=100: [{to:100,$0}, {to:600,$10}, {to:Inf,$5}]
// Usage 300, billingUnits=100 → rounded=300, 300 > 100, 300 <= 600
// → 10/100 * 300 = $30
expect(
volumeTiersToLineAmount({
tiers: [
{ to: 500, amount: 10 },
{ to: Infinite, amount: 5 },
],
usage: 300,
allowance: 100,
billingUnits: 100,
}),
).toBe(30);
});
test("allowance=0 behaves identically to no allowance", () => {
const withZero = volumeTiersToLineAmount({
tiers: THREE_TIERS,
usage: 250,
allowance: 0,
});
const withoutAllowance = volumeTiersToLineAmount({
tiers: THREE_TIERS,
usage: 250,
});
expect(withZero).toBe(withoutAllowance);
expect(withZero).toBe(12.5);
});
});
describe("throws on bad input", () => {
test("throws when tiers is null/undefined", () => {
expect(() =>