From 97b28a42ca6b1a52b9ddadfb38eb0bfc74cbccb3 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Wed, 25 Feb 2026 22:31:01 +0000 Subject: [PATCH] updated prepaid volume billing tests --- .../products/prices/priceInitUtils.ts | 96 +---------- .../update-paid-tier-behavior.test.ts | 4 +- .../volume-tiers-update-quantity.test.ts | 159 +++++++++++++++++- .../volume-tiers-to-line-amount.test.ts | 119 +++++++++++++ 4 files changed, 278 insertions(+), 100 deletions(-) diff --git a/server/src/internal/products/prices/priceInitUtils.ts b/server/src/internal/products/prices/priceInitUtils.ts index 9095f463b..14c5b64da 100644 --- a/server/src/internal/products/prices/priceInitUtils.ts +++ b/server/src/internal/products/prices/priceInitUtils.ts @@ -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]; diff --git a/server/tests/integration/billing/update-subscription/custom-plan/update-paid-tier-behavior.test.ts b/server/tests/integration/billing/update-subscription/custom-plan/update-paid-tier-behavior.test.ts index b3fad383f..66c274232 100644 --- a/server/tests/integration/billing/update-subscription/custom-plan/update-paid-tier-behavior.test.ts +++ b/server/tests/integration/billing/update-subscription/custom-plan/update-paid-tier-behavior.test.ts @@ -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 }], }), diff --git a/server/tests/integration/billing/update-subscription/update-quantity/volume-tiers-update-quantity.test.ts b/server/tests/integration/billing/update-subscription/update-quantity/volume-tiers-update-quantity.test.ts index c497e6231..bee9783fa 100644 --- a/server/tests/integration/billing/update-subscription/update-quantity/volume-tiers-update-quantity.test.ts +++ b/server/tests/integration/billing/update-subscription/update-quantity/volume-tiers-update-quantity.test.ts @@ -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: 0–500 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 6–7: 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(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(customerId); + + expect(customerAfter.features?.[TestFeature.Messages]?.balance).toBe( + newQuantity, + ); + + expectLatestInvoiceCorrect({ + customer: customerAfter, + productId: product.id, + amount: expectedDelta, + }); +}); diff --git a/server/tests/unit/billing/invoicing/line-item-utils/volume-tiers-to-line-amount.test.ts b/server/tests/unit/billing/invoicing/line-item-utils/volume-tiers-to-line-amount.test.ts index 4e7ff4bde..305962480 100644 --- a/server/tests/unit/billing/invoicing/line-item-utils/volume-tiers-to-line-amount.test.ts +++ b/server/tests/unit/billing/invoicing/line-item-utils/volume-tiers-to-line-amount.test.ts @@ -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(() =>