updated prepaid volume billing tests
This commit is contained in:
@@ -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];
|
||||
|
||||
@@ -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 }],
|
||||
}),
|
||||
|
||||
@@ -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<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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
Reference in New Issue
Block a user