Merge branch 'dev' into feat/filter-preview-response

This commit is contained in:
John Yeo
2026-02-26 09:42:47 +00:00
committed by GitHub
87 changed files with 6968 additions and 607 deletions

View File

@@ -331,6 +331,7 @@
"@types/bun": "latest",
"@types/node": "^24.0.3",
"cross-env": "^7.0.3",
"tsx": "^4.21.0",
"typescript": "^5.7.2",
},
"peerDependencies": {

View File

@@ -64,6 +64,7 @@
"setup": "node scripts/setup/setup.js",
"setup:test": "infisical run --env=dev -- bun scripts/setup/setup-test.ts",
"migrate-functions": "infisical run --env=dev -- bun scripts/migrations/migrate-functions.ts",
"migrate-functions:test": "infisical run --env=test -- bun scripts/migrations/migrate-functions.ts",
"migrate-functions:prod": "infisical run --env=prod -- bun scripts/migrations/migrate-functions.ts",
"validate-schema": "infisical run --env=prod -- bun scripts/migrations/validate-schema.ts",
"setupci": "node scripts/setup/setupci.js",

22
prepaid-volume.md Normal file
View File

@@ -0,0 +1,22 @@
```bash
bun test:integration \
server/tests/unit/billing/invoicing/line-item-utils/volume-tiers-to-line-amount.test.ts \
server/tests/unit/billing/invoicing/line-item-utils/tiers-to-line-amount.test.ts \
server/tests/integration/billing/attach/new-plan/attach-prepaid-volume.test.ts \
server/tests/integration/billing/attach/new-plan/attach-prepaid-volume-entities.test.ts \
server/tests/integration/billing/attach/new-plan/new-prepaid.test.ts \
server/tests/integration/billing/attach/immediate-switch/immediate-switch-prepaid-volume.test.ts \
server/tests/integration/billing/attach/immediate-switch/immediate-switch-entities-prepaid-volume.test.ts \
server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-prepaid-volume.test.ts \
server/tests/integration/billing/attach/edge-cases/v1-v2-compatibility/prepaid/attach-prepaid-volume-edge-cases.test.ts \
server/tests/integration/billing/attach/checkout/stripe-checkout/stripe-checkout-prepaid.test.ts \
server/tests/integration/billing/update-subscription/update-quantity/volume-tiers-update-quantity.test.ts \
server/tests/integration/billing/update-subscription/custom-plan/update-paid-tier-behavior.test.ts \
server/tests/integration/billing/legacy/attach/new/legacy-new-volume.test.ts \
server/tests/integration/crud/plans/create-plan-advanced.test.ts \
server/tests/integration/crud/plans/get-plan-advanced.test.ts \
server/tests/integration/balances/check/check-prepaid.test.ts \
server/tests/integration/balances/check/check-balance-price.test.ts \
server/tests/integration/billing/attach/v2-params/v2-customize.test.ts \
--timeout 0
```

View File

@@ -138,10 +138,19 @@ export const priceToInArrearTiers = ({
currency: org.default_currency || undefined,
});
tiers.push({
const stripeTier: Record<string, unknown> = {
unit_amount_decimal: stripeUnitAmountDecimal,
up_to: tier.to === -1 ? "inf" : tier.to,
});
};
if (tier.flat_amount) {
stripeTier.flat_amount_decimal = atmnToStripeAmountDecimal({
amount: tier.flat_amount,
currency: org.default_currency || undefined,
});
}
tiers.push(stripeTier);
}
return tiers;
@@ -271,12 +280,14 @@ export const createStripeInArrearPrice = async ({
...productData,
...priceAmountData,
currency: org.default_currency || "usd",
recurring: {
interval: recurringData.interval,
interval_count: recurringData.interval_count,
meter: meter.id,
usage_type: "metered",
},
recurring: recurringData?.interval
? {
interval: recurringData.interval,
interval_count: recurringData.interval_count,
meter: meter.id,
usage_type: "metered",
}
: undefined,
nickname: `Autumn Price (${relatedEnt.feature.name})`,
});

View File

@@ -5,6 +5,7 @@ import {
type Organization,
type Price,
type Product,
priceToStripeTiersMode,
TierInfinite,
type UsagePriceConfig,
} from "@autumn/shared";
@@ -42,10 +43,19 @@ const prepaidToStripeTiers = ({
? "inf"
: Math.round(tier.to / billingUnits!);
tiers.push({
const stripeTier: Record<string, unknown> = {
unit_amount_decimal: amount,
up_to: upTo,
});
};
if (tier.flat_amount) {
stripeTier.flat_amount_decimal = atmnToStripeAmountDecimal({
amount: tier.flat_amount,
currency: org.default_currency || undefined,
});
}
tiers.push(stripeTier);
}
return tiers;
@@ -70,20 +80,19 @@ export const createStripePrepaid = async ({
}) => {
const relatedEnt = getPriceEntitlement(price, entitlements);
let recurringData;
let recurringData: Partial<Stripe.PriceCreateParams.Recurring> | undefined;
if (price.config!.interval !== BillingInterval.OneOff) {
recurringData = billingIntervalToStripe({
interval: price.config!.interval,
intervalCount: price.config!.interval_count,
});
recurringData = {
...billingIntervalToStripe({
interval: price.config!.interval,
intervalCount: price.config!.interval_count,
}),
};
}
const config = price.config as UsagePriceConfig;
// 1. Product name
const productName = `${product.name} - ${
config.billing_units === 1 ? "" : `${config.billing_units} `
}${relatedEnt.feature.name}`;
const productName = `${product.name} - ${relatedEnt.feature.name}`;
const productData = curStripeProd
? { product: curStripeProd.id }
@@ -113,6 +122,7 @@ export const createStripePrepaid = async ({
config.stripe_price_id = stripePrice.id;
} else {
const tiers = prepaidToStripeTiers({ price, org });
const tiersMode = priceToStripeTiersMode({ price });
let priceAmountData = {};
if (tiers.length === 1) {
@@ -122,7 +132,7 @@ export const createStripePrepaid = async ({
} else {
priceAmountData = {
billing_scheme: "tiered",
tiers_mode: "graduated",
tiers_mode: tiersMode,
tiers: tiers,
};
}

View File

@@ -25,12 +25,12 @@ export const createStripePrepaidPriceV2 = async ({
}) => {
const { org, db, env } = ctx;
// 1. If no entitlement, re-use current stripe price
const entitlement = priceToEnt({
price,
entitlements: product.entitlements,
});
// No allowance → V2 price is identical to V1. Reuse the same Stripe price.
if (!entitlement?.allowance) {
price.config = {
...(price.config as UsagePriceConfig),

View File

@@ -28,7 +28,7 @@ export const priceToOneOffAndTiered = ({
const quantity = options?.quantity ?? 0;
const overage = new Decimal(quantity).mul(config.billing_units!).toNumber();
const amount = getPriceForOverage(price, overage);
const amount = getPriceForOverage({ price, overage });
if (!config.stripe_product_id) {
console.log(
`WARNING: One off & tiered in advance price has no stripe product id: ${price.id}, ${relatedEnt.feature.name}`,

View File

@@ -25,7 +25,7 @@ export const billingIntervalToStripe = ({
}: {
interval: BillingInterval;
intervalCount?: number | null;
}): Stripe.PriceCreateParams.Recurring | Record<string, any> => {
}): Partial<Stripe.PriceCreateParams.Recurring> => {
const finalCount = intervalCount ?? 1;
switch (interval) {
case BillingInterval.Week:

View File

@@ -98,7 +98,7 @@ const getContUseNewItems = async ({
}
}
const amount = getPriceForOverage(price, overage);
const amount = getPriceForOverage({ price, overage });
const description = getFeatureInvoiceDescription({
feature: ent.feature,
usage: usage,

View File

@@ -7,6 +7,7 @@ import {
ErrCode,
ProcessorType,
RecaseError,
TierBehavior,
type UsagePriceConfig,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
@@ -173,6 +174,21 @@ export const handleExternalPSPErrors = ({
}
};
export const handlePrepaidVolumeErrors = ({
attachParams,
}: {
attachParams: AttachParams;
}) => {
for (const price of attachParams.prices) {
if (price.tier_behavior === TierBehavior.VolumeBased) {
throw new RecaseError({
message:
"Volume pricing is not supported on attach V1. Please upgrade to V2 of the Autumn API to use prepaid volume tiers",
});
}
}
};
export const handleAttachErrors = async ({
attachParams,
attachBody,
@@ -196,6 +212,10 @@ export const handleAttachErrors = async ({
attachParams,
});
handlePrepaidVolumeErrors({
attachParams,
});
if (branch === AttachBranch.MultiAttach) {
await handleMultiAttachErrors({
attachParams,

View File

@@ -72,7 +72,10 @@ export const getCusPriceUsage = ({
const roundedQuantity =
Math.ceil(new Decimal(usage).div(billingUnits).toNumber()) * billingUnits;
const amount = getPriceForOverage(cusPrice.price, -totalNegativeBalance);
const amount = getPriceForOverage({
price: cusPrice.price,
overage: -totalNegativeBalance,
});
let description = getFeatureInvoiceDescription({
feature: cusEnt.entitlement.feature,

View File

@@ -221,9 +221,9 @@ export const getItemsForNewProduct = async ({
periodEnd: finalProration.end,
periodStart: finalProration.start,
now,
amount: getPriceForOverage(price),
})
: getPriceForOverage(price, 0);
amount: getPriceForOverage({ price }),
})
: getPriceForOverage({ price, overage: 0 });
if (freeTrial) {
amount = 0;

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];
@@ -110,6 +20,7 @@ export const tiersAreSame = (tiers1: any[], tiers2: any[]) => {
}
if (tier1.amount !== tier2.amount) return false;
if ((tier1.flat_amount ?? 0) !== (tier2.flat_amount ?? 0)) return false;
}
return true;
};
@@ -198,8 +109,8 @@ export const pricesAreSame = (
usageConfig2.usage_tiers,
),
message: `Usage tiers different: ${usageConfig1.usage_tiers.map(
(t) => `${t.to} (${t.amount})`,
)} !== ${usageConfig2.usage_tiers.map((t) => `${t.to} (${t.amount})`)}`,
(t) => `${t.to} (${t.amount}, flat: ${t.flat_amount ?? 0})`,
)} !== ${usageConfig2.usage_tiers.map((t) => `${t.to} (${t.amount}, flat: ${t.flat_amount ?? 0})`)}`,
},
};
@@ -217,6 +128,10 @@ export const pricesAreSame = (
prorationConfig1?.on_decrease != prorationConfig2?.on_decrease,
message: `On decrease different: ${prorationConfig1?.on_decrease} != ${prorationConfig2?.on_decrease}`,
},
tier_behavior: {
condition: price1.tier_behavior != price2.tier_behavior,
message: `Tier behaviour different: ${price1.tier_behavior} != ${price2.tier_behavior}`,
},
};
const pricesAreDiff =

View File

@@ -22,6 +22,8 @@ import { Decimal } from "decimal.js";
import { StatusCodes } from "http-status-codes";
import { compareBillingIntervals } from "./priceUtils/priceIntervalUtils.js";
export { getPriceForOverage } from "@autumn/shared";
export const constructPrice = ({
internalProductId,
entitlementId,
@@ -228,52 +230,40 @@ export const getPriceOptions = (
return options;
};
export const getPriceForOverage = (price: Price, overage?: number) => {
const pricesAreSame = (price1: Price, price2: Price) => {
for (const key in price1.config) {
const originalValue = (price1.config as any)[key];
const newValue = (price2.config as any)[key];
if (key === "usage_tiers") {
for (let i = 0; i < originalValue.length; i++) {
const originalTier = originalValue[i];
const newTier = newValue[i];
if (!compareObjects(originalTier, newTier)) {
return false;
}
}
} else if (originalValue !== newValue) {
return false;
}
}
return true;
};
const getUsageTier = (price: Price, quantity: number) => {
const usageConfig = price.config as UsagePriceConfig;
const billingType = getBillingType(usageConfig);
if (
billingType === BillingType.FixedCycle ||
billingType === BillingType.OneOff
) {
const config = price.config as FixedPriceConfig;
return config.amount;
}
let amount = 0;
const billingUnits = usageConfig.billing_units || 1;
let remainingUsage = new Decimal(
Math.ceil(new Decimal(overage!).div(billingUnits).toNumber()),
)
.mul(billingUnits)
.toNumber();
let lastTo: number = 0;
for (let i = 0; i < usageConfig.usage_tiers.length; i++) {
const tier = usageConfig.usage_tiers[i];
let amountUsed = 0;
if (tier.to === TierInfinite || tier.to === -1) {
amountUsed = remainingUsage;
} else {
amountUsed = Math.min(remainingUsage, tier.to - lastTo);
lastTo = tier.to;
if (i === usageConfig.usage_tiers.length - 1) {
return usageConfig.usage_tiers[i];
}
// Divide amount by billing units
const amountPerUnit = new Decimal(tier.amount)
.div(usageConfig.billing_units!)
.toNumber();
amount += amountPerUnit * amountUsed;
remainingUsage -= amountUsed;
if (remainingUsage <= 0) {
break;
const tier = usageConfig.usage_tiers[i];
if (tier.to === TierInfinite || tier.to >= quantity) {
return tier;
}
}
return Number(amount.toFixed(10));
return usageConfig.usage_tiers[0];
};
const priceToEventName = (productName: string, featureName: string) => {

View File

@@ -277,6 +277,7 @@ const toFeatureAndPrice = ({
config,
entitlement_id: ent.id,
proration_config: prorationConfig,
tier_behavior: item.tier_behavior ?? null,
};
const billingType = getBillingType(price.config!);

View File

@@ -15,10 +15,10 @@ import {
RecaseError,
type RolloverConfig,
RolloverExpiryDurationType,
TierBehavior,
UsageModel,
} from "@autumn/shared";
import { createFeaturesFromItems } from "@server/internal/products/product-items/createFeaturesFromItems";
import { StatusCodes } from "http-status-codes";
import {
isBooleanFeatureItem,
@@ -147,9 +147,9 @@ const validateProductItem = ({
if (isFeaturePriceItem(item) && item.tiers) {
if (
item.tiers.some((x) => {
return x.amount <= 0;
})
item.tiers.some(
(x) => x.amount < 0 || (x.amount === 0 && (x.flat_amount ?? 0) <= 0),
)
) {
throw new RecaseError({
message: `Price must be a number and greater than 0 for feature ${item.feature_id}`,
@@ -173,6 +173,49 @@ const validateProductItem = ({
statusCode: StatusCodes.BAD_REQUEST,
});
}
if (
item.tier_behavior === TierBehavior.VolumeBased &&
item.tiers.length > 1 &&
item.usage_model !== UsageModel.Prepaid
) {
throw new RecaseError({
message: `Volume-based pricing is only supported for prepaid items`,
code: ErrCode.InvalidInputs,
statusCode: StatusCodes.BAD_REQUEST,
});
}
// flat_amount validations
const hasFlatAmount = item.tiers.some(
(t) => t.flat_amount != null && t.flat_amount > 0,
);
if (hasFlatAmount) {
if (item.tier_behavior !== TierBehavior.VolumeBased) {
throw new RecaseError({
message: `flat_amount on tiers is only supported for volume-based pricing`,
code: ErrCode.InvalidInputs,
statusCode: StatusCodes.BAD_REQUEST,
});
}
if (item.tiers.length <= 1) {
throw new RecaseError({
message: `flat_amount is not supported on single-tier pricing`,
code: ErrCode.InvalidInputs,
statusCode: StatusCodes.BAD_REQUEST,
});
}
}
if (item.tiers.some((t) => t.flat_amount != null && t.flat_amount < 0)) {
throw new RecaseError({
message: `flat_amount must be 0 or greater`,
code: ErrCode.InvalidInputs,
statusCode: StatusCodes.BAD_REQUEST,
});
}
}
if (

View File

@@ -7,6 +7,7 @@ import {
type ProductItemFeatureType,
ProductItemInterval,
type RolloverConfig,
type TierBehavior,
UsageModel,
} from "@autumn/shared";
@@ -69,6 +70,7 @@ export const constructPrepaidItem = ({
featureId,
price = 9,
tiers,
tierBehaviour,
billingUnits = 100,
includedUsage = 0,
isOneOff = false,
@@ -84,7 +86,8 @@ export const constructPrepaidItem = ({
}: {
featureId: string;
price?: number;
tiers?: { amount: number; to: number | "inf" }[];
tiers?: { amount: number; to: number | "inf"; flat_amount?: number | null }[];
tierBehaviour?: TierBehavior;
billingUnits?: number;
includedUsage?: number;
isOneOff?: boolean;
@@ -101,6 +104,7 @@ export const constructPrepaidItem = ({
price: tiers ? undefined : price,
tiers: tiers,
tier_behavior: tierBehaviour,
billing_units: billingUnits || 100,
interval: isOneOff ? null : ProductItemInterval.Month,
interval_count: intervalCount,
@@ -122,6 +126,7 @@ export const constructArrearItem = ({
featureId,
includedUsage = 10000,
price = 0.1,
tiers,
billingUnits = 1000,
config = {
on_increase: OnIncrease.ProrateImmediately,
@@ -136,6 +141,7 @@ export const constructArrearItem = ({
featureId: string;
includedUsage?: number;
price?: number;
tiers?: { amount: number; to: number | "inf" }[];
billingUnits?: number;
config?: ProductItemConfig;
rolloverConfig?: RolloverConfig;
@@ -148,7 +154,8 @@ export const constructArrearItem = ({
feature_id: featureId,
usage_model: UsageModel.PayPerUse,
included_usage: includedUsage,
price: price,
price: tiers ? undefined : price,
tiers: tiers,
billing_units: billingUnits,
interval: interval,
interval_count: intervalCount,

View File

@@ -0,0 +1,27 @@
import type { TestGroup } from "../../types";
export const prepaidVolume: TestGroup = {
name: "prepaid-volume",
description: "Prepaid volume-based tier pricing tests",
tier: "domain",
paths: [
"unit/billing/invoicing/line-item-utils/volume-tiers-to-line-amount.test.ts",
"unit/billing/invoicing/line-item-utils/tiers-to-line-amount.test.ts",
"integration/billing/attach/new-plan/attach-prepaid-volume.test.ts",
"integration/billing/attach/new-plan/attach-prepaid-volume-entities.test.ts",
"integration/billing/attach/new-plan/new-prepaid.test.ts",
"integration/billing/attach/immediate-switch/immediate-switch-prepaid-volume.test.ts",
"integration/billing/attach/immediate-switch/immediate-switch-entities-prepaid-volume.test.ts",
"integration/billing/attach/scheduled-switch/scheduled-switch-prepaid-volume.test.ts",
"integration/billing/attach/edge-cases/v1-v2-compatibility/prepaid/attach-prepaid-volume-edge-cases.test.ts",
"integration/billing/attach/checkout/stripe-checkout/stripe-checkout-prepaid.test.ts",
"integration/billing/update-subscription/update-quantity/volume-tiers-update-quantity.test.ts",
"integration/billing/update-subscription/custom-plan/update-paid-tier-behavior.test.ts",
"integration/billing/legacy/attach/new/legacy-new-volume.test.ts",
"integration/crud/plans/create-plan-advanced.test.ts",
"integration/crud/plans/get-plan-advanced.test.ts",
"integration/balances/check/check-prepaid.test.ts",
"integration/balances/check/check-balance-price.test.ts",
"integration/billing/attach/v2-params/v2-customize.test.ts",
],
};

View File

@@ -15,6 +15,7 @@ import { updateBalance } from "./domains/balances/updateBalance";
import { billing } from "./domains/billing/billing";
import { billingV1 } from "./domains/billing/billingV1";
import { billingV2 } from "./domains/billing/billingV2";
import { prepaidVolume } from "./domains/billing/prepaidVolume";
import { crud } from "./domains/crud";
import { misc } from "./domains/misc";
import { webhooks } from "./domains/webhooks";
@@ -38,6 +39,7 @@ const allGroups: TestGroup[] = [
billing,
billingV1,
billingV2,
prepaidVolume,
crud,
webhooks,
advanced,

View File

@@ -1,137 +0,0 @@
import { beforeAll, describe, expect, test } from "bun:test";
import {
ApiVersion,
type CheckResponseV1,
type CheckResponseV2,
type LimitedItem,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
const prepaidMessagesFeature = constructPrepaidItem({
featureId: TestFeature.Messages,
includedUsage: 100,
billingUnits: 100,
price: 8.5,
}) as LimitedItem;
const freeProd = constructProduct({
type: "free",
isDefault: false,
items: [prepaidMessagesFeature],
});
const testCase = "check-prepaid1";
describe(`${chalk.yellowBright("check-prepaid1: test /check when prepaid feature attached")}`, () => {
const customerId = testCase;
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 });
const prepaidQuantity = 500;
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
attachPm: "success",
});
await initProductsV0({
ctx,
products: [freeProd],
prefix: testCase,
});
await autumnV1.attach({
customer_id: customerId,
product_id: freeProd.id,
options: [
{
feature_id: TestFeature.Messages,
quantity: prepaidQuantity,
},
],
});
});
test("should have correct v2 response", async () => {
const res = (await autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV2;
expect(res).toMatchObject({
allowed: true,
customer_id: customerId,
required_balance: 1,
balance: {
feature_id: TestFeature.Messages,
unlimited: false,
granted_balance: prepaidMessagesFeature.included_usage,
purchased_balance: prepaidQuantity,
current_balance:
prepaidQuantity + prepaidMessagesFeature.included_usage,
usage: 0,
max_purchase: null,
overage_allowed: false,
reset: {
interval: "month",
},
},
});
expect(res.balance?.reset?.resets_at).toBeDefined();
});
test("should have allowed true if value is less than current balance", async () => {
const res = (await autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance:
prepaidQuantity + prepaidMessagesFeature.included_usage - 1,
})) as unknown as CheckResponseV2;
expect(res.allowed).toBe(true);
});
test("should have allowed false if value is greater than current balance", async () => {
const res = (await autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance:
prepaidQuantity + prepaidMessagesFeature.included_usage + 1,
})) as unknown as CheckResponseV2;
expect(res.allowed).toBe(false);
});
test("should have correct v1 response", async () => {
const res = (await autumnV1.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV1;
expect(res).toMatchObject({
allowed: true,
code: "feature_found",
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance: 1,
interval: "month",
interval_count: 1,
unlimited: false,
balance: prepaidQuantity + prepaidMessagesFeature.included_usage,
usage: 0,
included_usage: prepaidQuantity + prepaidMessagesFeature.included_usage,
overage_allowed: false,
});
});
});

View File

@@ -99,9 +99,12 @@ test.concurrent(`${chalk.yellowBright("check-balance-price: verify price field i
max_purchase: null,
});
expect(wordsBreakdown?.price?.amount).toBeUndefined();
// Tiers in user-facing response INCLUDE included_usage (50)
// Internal tiers: [{to:100}, {to:500}, {to:"inf"}]
// User-facing: [{to:150}, {to:550}, {to:"inf"}]
expect(wordsBreakdown?.price?.tiers).toEqual([
{ to: 100, amount: 0.1 },
{ to: 500, amount: 0.05 },
{ to: 150, amount: 0.1 },
{ to: 550, amount: 0.05 },
{ to: "inf", amount: 0.02 },
]);
});

View File

@@ -0,0 +1,240 @@
/**
* Check Prepaid Balance Tests
*
* Verifies that the /check endpoint returns correct balance structures
* for prepaid features:
* - Basic prepaid: V1 and V2 response shapes, allowed/disallowed by required_balance
* - Tiered prepaid: user-facing tiers INCLUDE included usage in boundaries
* - Balance totals (granted, usage, remaining) are correct after attach
*/
import { expect, test } from "bun:test";
import {
BillingMethod,
type CheckResponseV1,
type CheckResponseV2,
type CheckResponseV3,
TierBehavior,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════
// Tiered prepaid: included=100, graduated tiers (internal: 0-500 @ $10, 501+ @ $5)
// User-facing tiers should INCLUDE included (100): 0-600 @ $10, 601+ @ $5
// ═══════════════════════════════════════════════════════════════════
const INCLUDED_USAGE = 100;
const BILLING_UNITS = 100;
const PREPAID_QUANTITY = 300;
const TIERS = [
{ to: 500, amount: 10 },
{ to: "inf" as const, amount: 5 },
];
test.concurrent(`${chalk.yellowBright("check-prepaid: tiered balance.price tiers include included usage")}`, async () => {
const customerId = "check-prepaid-tiered-tiers";
const tieredPrepaidItem = items.tieredPrepaidMessages({
includedUsage: INCLUDED_USAGE,
billingUnits: BILLING_UNITS,
tiers: TIERS,
});
const freeProd = products.base({
id: "tiered-prepaid",
items: [tieredPrepaidItem],
});
const { autumnV2_1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success", testClock: false }),
s.products({ list: [freeProd] }),
],
actions: [
s.attach({
productId: freeProd.id,
options: [
{ feature_id: TestFeature.Messages, quantity: PREPAID_QUANTITY },
],
}),
],
});
const res = (await autumnV2_1.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV3;
expect(res.allowed).toBe(true);
expect(res.balance).toBeDefined();
expect(res.balance?.breakdown).toHaveLength(1);
const breakdown = res.balance?.breakdown?.[0];
expect(breakdown?.price).toBeDefined();
expect(breakdown?.price).toMatchObject({
billing_units: BILLING_UNITS,
billing_method: BillingMethod.Prepaid,
tier_behavior: TierBehavior.Graduated,
});
// Internal tiers: [{to:500, amount:10}, {to:"inf", amount:5}]
// User-facing tiers add included (100): [{to:600, amount:10}, {to:"inf", amount:5}]
expect(breakdown?.price?.tiers).toEqual([
{ to: 600, amount: 10 },
{ to: "inf", amount: 5 },
]);
expect(breakdown?.price?.amount).toBeUndefined();
});
test.concurrent(`${chalk.yellowBright("check-prepaid: tiered balance totals correct after attach")}`, async () => {
const customerId = "check-prepaid-tiered-totals";
const tieredPrepaidItem = items.tieredPrepaidMessages({
includedUsage: INCLUDED_USAGE,
billingUnits: BILLING_UNITS,
tiers: TIERS,
});
const freeProd = products.base({
id: "tiered-prepaid-totals",
items: [tieredPrepaidItem],
});
const { autumnV2_1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success", testClock: false }),
s.products({ list: [freeProd] }),
],
actions: [
s.billing.attach({
productId: freeProd.id,
options: [
{ feature_id: TestFeature.Messages, quantity: PREPAID_QUANTITY },
],
}),
],
});
const res = (await autumnV2_1.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV3;
expect(res.balance).toMatchObject({
feature_id: TestFeature.Messages,
unlimited: false,
granted: PREPAID_QUANTITY,
usage: 0,
});
// Remaining = purchased
expect(res.balance?.remaining).toBe(PREPAID_QUANTITY);
});
// ═══════════════════════════════════════════════════════════════════
// Basic prepaid: single price ($8.50/pack), included=100, quantity=500
// Verifies V1 and V2 response shapes, and allowed/disallowed thresholds
// ═══════════════════════════════════════════════════════════════════
const BASIC_INCLUDED = 100;
const BASIC_QUANTITY = 500;
const TOTAL_BALANCE = BASIC_QUANTITY + BASIC_INCLUDED;
test.concurrent(`${chalk.yellowBright("check-prepaid: basic V1/V2 response shape + allowed thresholds")}`, async () => {
const customerId = "check-prepaid-basic";
const prepaidItem = items.prepaidMessages({
includedUsage: BASIC_INCLUDED,
billingUnits: 100,
price: 8.5,
});
const freeProd = products.base({
id: "basic-prepaid",
items: [prepaidItem],
});
const { autumnV1, autumnV2 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success", testClock: false }),
s.products({ list: [freeProd] }),
],
actions: [
s.attach({
productId: freeProd.id,
options: [
{ feature_id: TestFeature.Messages, quantity: BASIC_QUANTITY },
],
}),
],
});
// ── V2 response shape ──────────────────────────────────────────
const v2Res = (await autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV2;
expect(v2Res).toMatchObject({
allowed: true,
customer_id: customerId,
required_balance: 1,
balance: {
feature_id: TestFeature.Messages,
unlimited: false,
granted_balance: BASIC_INCLUDED,
purchased_balance: BASIC_QUANTITY,
current_balance: TOTAL_BALANCE,
usage: 0,
max_purchase: null,
overage_allowed: false,
reset: { interval: "month" },
},
});
expect(v2Res.balance?.reset?.resets_at).toBeDefined();
// ── Allowed threshold ──────────────────────────────────────────
const allowedRes = (await autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance: TOTAL_BALANCE - 1,
})) as unknown as CheckResponseV2;
expect(allowedRes.allowed).toBe(true);
// ── Disallowed threshold ───────────────────────────────────────
const disallowedRes = (await autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance: TOTAL_BALANCE + 1,
})) as unknown as CheckResponseV2;
expect(disallowedRes.allowed).toBe(false);
// ── V1 response shape ──────────────────────────────────────────
const v1Res = (await autumnV1.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV1;
expect(v1Res).toMatchObject({
allowed: true,
code: "feature_found",
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance: 1,
interval: "month",
interval_count: 1,
unlimited: false,
balance: TOTAL_BALANCE,
usage: 0,
included_usage: TOTAL_BALANCE,
overage_allowed: false,
});
});

View File

@@ -0,0 +1,45 @@
import { test } from "bun:test";
import type { AttachParamsV1Input } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
test.concurrent(`${chalk.yellowBright("stripe-checkout: prepaid volume: flat_amount only")}`, async () => {
const customerId = "stripe-cko-prepaid-volume-flat";
const quantity = 50;
// Tier 1: 0-100 → $0/unit, $20 flat. Tier 2: 101+ → $0/unit, $50 flat.
// 50 units → falls in tier 1 → 50 × $0 + $20 = $20
const expectedTotal = 20;
const volumeItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: 1,
tiers: [
{ to: 100, amount: 0, flat_amount: 20 },
{ to: "inf", amount: 0, flat_amount: 50 },
],
});
const product = products.base({
id: "vol-flat-only",
items: [volumeItem],
});
const { autumnV2 } = await initScenario({
customerId,
setup: [s.customer({}), s.products({ list: [product] })],
actions: [],
});
const result = await autumnV2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: product.id,
redirect_mode: "if_required",
feature_quantities: [{ feature_id: TestFeature.Messages, quantity }],
});
console.log(result);
});

View File

@@ -554,3 +554,80 @@ test.concurrent(`${chalk.yellowBright("stripe-checkout: tiered prepaid with quan
env: ctx.env,
});
});
const VOLUME_TIERS: { to: number | "inf"; amount: number }[] = [
{ to: 500, amount: 30 },
{ to: 1500, amount: 50 },
{ to: "inf", amount: 70 },
];
const BASE_PRICE = 20;
test.concurrent(`${chalk.yellowBright("stripe-checkout: prepaid volume: 300 units, 100 included, tier 1 → $30")}`, async () => {
const customerId = "attach-prepaid-volume-included-tier1";
const quantity = 300;
const includedUsage = 100;
const expectedPrepaidCost = 90;
const volumeItem = items.volumePrepaidMessages({
includedUsage,
tiers: VOLUME_TIERS,
});
const pro = products.pro({
id: "pro-volume-included-tier1",
items: [volumeItem],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [s.customer({}), s.products({ list: [pro] })],
actions: [],
});
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
});
expect(preview.total).toBe(BASE_PRICE + expectedPrepaidCost);
const result = await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
redirect_mode: "if_required",
});
expect(result.payment_url).toBeDefined();
expect(result.payment_url).toContain("checkout.stripe.com");
await completeStripeCheckoutForm({ url: result.payment_url });
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer,
productId: pro.id,
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: quantity,
balance: quantity,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: BASE_PRICE + expectedPrepaidCost,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});

View File

@@ -0,0 +1,528 @@
/**
* Volume Pricing Edge Case Tests
*
* Tests for the three most dangerous correctness gaps in volume pricing —
* cases where a subtle bug in the implementation would produce a wrong number
* silently rather than throwing an error.
*
* Tiers used throughout (billingUnits = 100):
* Tier 1: 0500 units @ $10/pack
* Tier 2: 501+ units @ $5/pack
*
* Tests AC attach both a volume and a graduated product to the same customer.
* To allow both to coexist (they must not replace each other), the volume
* product uses products.pro() and the graduated product uses products.base()
* with an explicit monthly price — different plan types, no mutual exclusion.
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
const BILLING_UNITS = 100;
const BASE_PRICE = 20;
const TIERS = [
{ to: 500, amount: 10 },
{ to: "inf" as const, amount: 5 },
];
// ═══════════════════════════════════════════════════════════════════════════════
// TEST A: Exact tier boundary — 500 units
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("vol-edge: 600 units (included usage and exact tier 1 boundary) → $60")}`, async () => {
const customerId = "vol-included-edge-boundary-500";
const includedUsage = 100;
const quantity = 600;
const expectedPrepaid = (quantity / BILLING_UNITS) * 10;
const volItem = items.volumePrepaidMessages({
includedUsage,
billingUnits: BILLING_UNITS,
tiers: TIERS,
});
// Distinct groups so the two products don't mutually-exclude each other
const volPro = products.pro({
id: "vol-pro-500",
items: [volItem],
group: "vol-500",
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [volPro] }),
],
actions: [],
});
// Both previews must be $70 — volume bumped to tier 2 would return $45
const previewVol = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: volPro.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
});
expect(previewVol.total).toBe(BASE_PRICE + expectedPrepaid);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: volPro.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: volPro.id });
// 2 invoices, both $70
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: BASE_PRICE + expectedPrepaid,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST A: Exact tier boundary — 500 units
// ═══════════════════════════════════════════════════════════════════════════════
/**
* The boundary check in volumeTiersToLineAmount is `roundedUsage <= tierBoundary`
* (inclusive upper bound). Exactly 500 units with `{ to: 500 }` must stay in
* tier 1, not spill into tier 2.
*
* Volume: 500 → tier 1 (500 ≤ 500) → 5 × $10 = $50
* Graduated: 500 → tier 1 (min(500,500)=500) → 5 × $10 = $50 (same)
*
* A regression of `<=` → `<` would bump volume to tier 2: 5 × $5 = $25.
* Both models must agree on $50 here — the agreement is the assertion.
*/
test.concurrent(`${chalk.yellowBright("vol-edge: 500 units (exact tier 1 boundary) → $50, volume = graduated")}`, async () => {
const customerId = "vol-edge-boundary-500";
const quantity = 500;
// Both: 5 packs × $10 = $50
const expectedPrepaid = (quantity / BILLING_UNITS) * 10;
const volItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: TIERS,
});
const gradItem = items.tieredPrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: TIERS,
});
// Distinct groups so the two products don't mutually-exclude each other
const volPro = products.pro({
id: "vol-pro-500",
items: [volItem],
group: "vol-500",
});
const gradBase = products.base({
id: "grad-base-500",
items: [gradItem, items.monthlyPrice({ price: BASE_PRICE })],
group: "grad-500",
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [volPro, gradBase] }),
],
actions: [],
});
// Both previews must be $70 — volume bumped to tier 2 would return $45
const previewVol = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: volPro.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
});
expect(previewVol.total).toBe(BASE_PRICE + expectedPrepaid);
const previewGrad = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: gradBase.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
});
expect(previewGrad.total).toBe(BASE_PRICE + expectedPrepaid);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: volPro.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
redirect_mode: "if_required",
});
await autumnV1.billing.attach({
customer_id: customerId,
product_id: gradBase.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: volPro.id });
await expectProductActive({ customer, productId: gradBase.id });
// 2 invoices, both $70
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: BASE_PRICE + expectedPrepaid,
});
await expectCustomerInvoiceCorrect({
customer,
count: 2,
invoiceIndex: 1,
latestTotal: BASE_PRICE + expectedPrepaid,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST B: Non-pack-aligned quantity — ceiling rounding (501 units)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* The implementation rounds usage up before tier lookup:
* ceil(501 / 100) * 100 = 600. Volume applies tier 2 to 600 units.
*
* Volume: 501 → ceil → 600 → tier 2 → 6 × $5 = $30 → invoice $50
* Graduated: 501 → ceil → 600 → split → 500×($10/100) + 100×($5/100)
* = $50 + $5 = $55 → invoice $75
*
* The balance is 600 (the ceiling-rounded value), not 501.
*/
test.concurrent(`${chalk.yellowBright("vol-edge: 501 units (ceil to 600) → volume $30, graduated $55, balance 600")}`, async () => {
const customerId = "vol-edge-ceiling-501";
const quantity = 501;
const ceiledQuantity = 600; // ceil(501/100)*100
// Volume: 6 packs × $5 = $30
const volExpectedPrepaid = (ceiledQuantity / BILLING_UNITS) * 5;
// Graduated: 500 units at $10/100 + 100 units at $5/100 = $50 + $5 = $55
const gradExpectedPrepaid =
500 * (10 / BILLING_UNITS) + 100 * (5 / BILLING_UNITS);
const volItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: TIERS,
});
const gradItem = items.tieredPrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: TIERS,
});
// Distinct groups so the two products don't mutually-exclude each other
const volPro = products.pro({
id: "vol-pro-501",
items: [volItem],
group: "vol-501",
});
const gradBase = products.base({
id: "grad-base-501",
items: [gradItem, items.monthlyPrice({ price: BASE_PRICE })],
group: "grad-501",
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [volPro, gradBase] }),
],
actions: [],
});
const previewVol = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: volPro.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
});
expect(previewVol.total).toBe(BASE_PRICE + volExpectedPrepaid);
const previewGrad = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: gradBase.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
});
expect(previewGrad.total).toBe(BASE_PRICE + gradExpectedPrepaid);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: volPro.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
redirect_mode: "if_required",
});
await autumnV1.billing.attach({
customer_id: customerId,
product_id: gradBase.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: volPro.id });
await expectProductActive({ customer, productId: gradBase.id });
// Both products contribute 600 units (ceiling-rounded) each
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: ceiledQuantity * 2,
usage: 0,
});
// Invoice 0 (latest): graduated — $75
// Invoice 1: volume — $50
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: BASE_PRICE + gradExpectedPrepaid,
});
await expectCustomerInvoiceCorrect({
customer,
count: 2,
invoiceIndex: 1,
latestTotal: BASE_PRICE + volExpectedPrepaid,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST C: includedUsage + volume, purchased quantity crosses tier boundary
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Volume pricing charges the TOTAL quantity (including included) at whichever
* single tier it falls into. A free $0 tier covers the allowance (2 packs).
* Stripe tiers with allowance=200: [{up_to:2,$0}, {up_to:7,$10}, {up_to:inf,$5}]
*
* includedUsage=200, quantity=800 → 8 total packs
*
* Volume: 8 total packs → tier 2 (>7) → 8 × $5 = $40 → invoice $60
* Graduated: 2 free + 5×$10 + 1×$5 = $0 + $50 + $5 = $55 → invoice $75
*/
test.concurrent(`${chalk.yellowBright("vol-edge: includedUsage=200, qty=800 → volume 8 total packs $40, graduated $55")}`, async () => {
const customerId = "vol-edge-included-800";
const quantity = 800;
const includedUsage = 200;
const totalUnits = quantity;
// Volume: 8 total packs → tier 2 (shifted boundary at 700: (500+200)/100=7) → 8 × $5 = $40
const volExpectedPrepaid = (totalUnits / BILLING_UNITS) * 5;
// Graduated: free tier covers 200 (2 packs), then 500×($10/100) + 100×($5/100) = $55
const gradExpectedPrepaid =
500 * (10 / BILLING_UNITS) + 100 * (5 / BILLING_UNITS);
const volItem = items.volumePrepaidMessages({
includedUsage,
billingUnits: BILLING_UNITS,
tiers: TIERS,
});
const gradItem = items.tieredPrepaidMessages({
includedUsage,
billingUnits: BILLING_UNITS,
tiers: TIERS,
});
// Distinct groups so the two products don't mutually-exclude each other
const volPro = products.pro({
id: "vol-pro-inc",
items: [volItem],
group: "vol-inc",
});
const gradBase = products.base({
id: "grad-base-inc",
items: [gradItem, items.monthlyPrice({ price: BASE_PRICE })],
group: "grad-inc",
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [volPro, gradBase] }),
],
actions: [],
});
// Volume: $20 base + $40 prepaid = $60
const previewVol = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: volPro.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
});
expect(previewVol.total).toBe(BASE_PRICE + volExpectedPrepaid);
// Graduated: $20 + $55 = $75
const previewGrad = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: gradBase.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
});
expect(previewGrad.total).toBe(BASE_PRICE + gradExpectedPrepaid);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: volPro.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
redirect_mode: "if_required",
});
await autumnV1.billing.attach({
customer_id: customerId,
product_id: gradBase.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: volPro.id });
await expectProductActive({ customer, productId: gradBase.id });
// Both products contribute 800 units each
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: quantity * 2,
usage: 0,
});
// Invoice 0 (latest): graduated — $75
// Invoice 1: volume — $50
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: BASE_PRICE + gradExpectedPrepaid,
});
await expectCustomerInvoiceCorrect({
customer,
count: 2,
invoiceIndex: 1,
latestTotal: BASE_PRICE + volExpectedPrepaid,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST D: includedUsage fully covers the requested quantity (0 purchased packs)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* includedUsage=500 (5 free packs), user requests 300 units.
* Purchased packs = 0. featureOptionsToV2StripeQuantity returns 0 for volume.
*
* Volume: 0 purchased packs → $0 prepaid → only base price charged.
*
* The balance is the full includedUsage (500), not the requested quantity (300),
* because the product grants 500 free units regardless of what the user asked for.
*
* Guards against: negative purchased quantity errors, off-by-one that charges
* 1 pack instead of 0, or the zero-quantity path throwing in Stripe price creation.
*/
test.concurrent(`${chalk.yellowBright("vol-edge: includedUsage=500 covers qty=300 → 0 purchased packs, $0 prepaid, balance=500")}`, async () => {
const customerId = "vol-edge-included-all";
const quantity = 300;
const includedUsage = 500;
const volItem = items.volumePrepaidMessages({
includedUsage,
billingUnits: BILLING_UNITS,
tiers: TIERS,
});
const volPro = products.pro({ id: "vol-pro-inc-all", items: [volItem] });
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [volPro] }),
],
actions: [],
});
// Preview must be base-only — allowance covers all requested units
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: volPro.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
});
expect(preview.total).toBe(BASE_PRICE);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: volPro.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: volPro.id });
// Balance is the full includedUsage (500), not the requested 300
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: includedUsage,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: BASE_PRICE,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});

View File

@@ -0,0 +1,435 @@
/**
* Attach Prepaid Volume vs Graduated — Entity-Level Immediate Switch Tests
*
* Two entities share one customer. Each entity is on a different prepaid tier
* pricing model for their Messages feature:
*
* Entity 1 — GRADUATED pricing (tieredPrepaidMessages):
* Usage is split across tiers. 800 units → 5×$10 + 3×$5 = $65.
*
* Entity 2 — VOLUME pricing (volumePrepaidMessages):
* All units charged at the rate of the matching tier. 800 units → 8×$5 = $40.
*
* Tiers (billingUnits = 100):
* Tier 1: 0500 units @ $10/pack
* Tier 2: 501+ units @ $5/pack
*
* Both entities start on pro and immediately switch to premium.
* The invoice totals confirm that Autumn applies the correct pricing model
* per product item and that the two entities remain fully independent.
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import {
expectCustomerProducts,
expectProductActive,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
const BILLING_UNITS = 100;
// Shared tiers — same boundaries for both graduated and volume products
const TIERS = [
{ to: 500, amount: 10 },
{ to: "inf" as const, amount: 5 },
];
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Entity 1 (graduated) and Entity 2 (volume) — both switch pro → premium
// at 800 units (tier 2). Graduated charges $65; volume charges $40.
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Quantity: 800 units (tier 2).
*
* GRADUATED math (entity 1):
* Old prepaid: 5×$10 + 3×$5 = $65
* New prepaid: 5×$10 + 3×$5 = $65
* Prepaid delta: $0 (same model, same quantity)
* Switch cost = prorated base upgrade (> 0)
*
* VOLUME math (entity 2):
* Old prepaid: 8×$5 = $40
* New prepaid: 8×$5 = $40
* Graduated would be: 5×$10+3×$5 = $65 — confirms volume semantics
* Prepaid delta: $0 (same quantity, same tier)
* Switch cost = prorated base upgrade (> 0)
*
* Both have the same switch cost (only base proration, no prepaid delta).
* Total invoices on customer: 4 (initial pro × 2, switch invoice × 2).
*/
test.concurrent(`${chalk.yellowBright("attach-prepaid-volume-entities: graduated (entity 1) vs volume (entity 2), 800 units same tier, pro → premium")}`, async () => {
const customerId = "vol-ent-switch-800-same";
// ── Graduated products (entity 1) ──
const gradProItem = items.tieredPrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: TIERS,
});
const gradPremiumItem = items.tieredPrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: TIERS,
});
const gradPro = products.pro({
id: "grad-pro-800",
items: [gradProItem],
});
const gradPremium = products.premium({
id: "grad-premium-800",
items: [gradPremiumItem],
});
// ── Volume products (entity 2) ──
const volProItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: TIERS,
});
const volPremiumItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: TIERS,
});
const volPro = products.pro({
id: "vol-pro-800",
items: [volProItem],
});
const volPremium = products.premium({
id: "vol-premium-800",
items: [volPremiumItem],
});
const quantity = 800;
const { autumnV1, entities, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [gradPro, gradPremium, volPro, volPremium] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
// Entity 1 → graduated pro
s.billing.attach({
productId: gradPro.id,
entityIndex: 0,
options: [{ feature_id: TestFeature.Messages, quantity }],
}),
// Entity 2 → volume pro
s.billing.attach({
productId: volPro.id,
entityIndex: 1,
options: [{ feature_id: TestFeature.Messages, quantity }],
}),
],
});
// ── Verify initial state ──
const entity1Before = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entities[0].id,
);
await expectProductActive({ customer: entity1Before, productId: gradPro.id });
expectCustomerFeatureCorrect({
customer: entity1Before,
featureId: TestFeature.Messages,
balance: quantity,
usage: 0,
});
const entity2Before = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entities[1].id,
);
await expectProductActive({ customer: entity2Before, productId: volPro.id });
expectCustomerFeatureCorrect({
customer: entity2Before,
featureId: TestFeature.Messages,
balance: quantity,
usage: 0,
});
// ── Preview entity 1 switch (graduated): prepaid delta = $0 ──
const previewEnt1 = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: gradPremium.id,
entity_id: entities[0].id,
options: [{ feature_id: TestFeature.Messages, quantity }],
});
expect(previewEnt1.total).toBeGreaterThan(0);
// ── Preview entity 2 switch (volume): prepaid delta = $0 ──
const previewEnt2 = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: volPremium.id,
entity_id: entities[1].id,
options: [{ feature_id: TestFeature.Messages, quantity }],
});
expect(previewEnt2.total).toBeGreaterThan(0);
// ── Switch entity 1: graduated pro → graduated premium ──
await autumnV1.billing.attach({
customer_id: customerId,
product_id: gradPremium.id,
entity_id: entities[0].id,
options: [{ feature_id: TestFeature.Messages, quantity }],
redirect_mode: "if_required",
});
// ── Switch entity 2: volume pro → volume premium ──
await autumnV1.billing.attach({
customer_id: customerId,
product_id: volPremium.id,
entity_id: entities[1].id,
options: [{ feature_id: TestFeature.Messages, quantity }],
redirect_mode: "if_required",
});
// ── Assert entity 1 post-switch ──
const entity1 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entities[0].id,
);
await expectCustomerProducts({
customer: entity1,
active: [gradPremium.id],
notPresent: [gradPro.id],
});
expectCustomerFeatureCorrect({
customer: entity1,
featureId: TestFeature.Messages,
balance: quantity,
usage: 0,
});
// ── Assert entity 2 post-switch ──
const entity2 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entities[1].id,
);
await expectCustomerProducts({
customer: entity2,
active: [volPremium.id],
notPresent: [volPro.id],
});
expectCustomerFeatureCorrect({
customer: entity2,
featureId: TestFeature.Messages,
balance: quantity,
usage: 0,
});
// ── Verify customer-level invoices ──
// Invoice 0: entity 1 switch (prorated base upgrade, graduated, prepaid delta $0)
// Invoice 1: entity 2 switch (prorated base upgrade, volume, prepaid delta $0)
// Invoice 2: entity 1 initial (graduated pro + 800 units = $20 + $65 = $85)
// Invoice 3: entity 2 initial (volume pro + 800 units = $20 + $40 = $60)
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 4,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Entity 1 (graduated) and Entity 2 (volume) — switch from 300 → 800 units
// This is the KEY differentiator: graduated charges $65, volume charges $40.
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Initial quantity: 300 units (tier 1). New quantity: 800 units (tier 2).
*
* GRADUATED math (entity 1):
* Old prepaid: 3×$10 = $30 (tier 1)
* New prepaid: 5×$10 + 3×$5 = $65 (graduated across tiers)
* Prepaid delta: $65 $30 = +$35
* Switch invoice ≈ base proration + $35
*
* VOLUME math (entity 2):
* Old prepaid: 3×$10 = $30 (tier 1, volume same as graduated here)
* New prepaid: 8×$5 = $40 ← volume: all 8 packs at tier-2 rate
* Graduated would be: 5×$10+3×$5 = $65 — KEY DIFFERENTIATOR
* Prepaid delta: $40 $30 = +$10
* Switch invoice ≈ base proration + $10
*
* The graduated entity pays $25 more in prepaid delta than the volume entity,
* confirming that each item uses its own pricing model independently.
*/
test.concurrent(`${chalk.yellowBright("attach-prepaid-volume-entities: graduated ($65) vs volume ($40), 300 → 800 units tier 1 → tier 2")}`, async () => {
const customerId = "vol-ent-switch-300-800";
// ── Graduated products (entity 1) ──
const gradProItem = items.tieredPrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: TIERS,
});
const gradPremiumItem = items.tieredPrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: TIERS,
});
const gradPro = products.pro({
id: "grad-pro-300-800",
items: [gradProItem],
});
const gradPremium = products.premium({
id: "grad-premium-300-800",
items: [gradPremiumItem],
});
// ── Volume products (entity 2) ──
const volProItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: TIERS,
});
const volPremiumItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: TIERS,
});
const volPro = products.pro({
id: "vol-pro-300-800",
items: [volProItem],
});
const volPremium = products.premium({
id: "vol-premium-300-800",
items: [volPremiumItem],
});
const initQuantity = 300;
const newQuantity = 800;
const { autumnV1, entities, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [gradPro, gradPremium, volPro, volPremium] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.billing.attach({
productId: gradPro.id,
entityIndex: 0,
options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }],
}),
s.billing.attach({
productId: volPro.id,
entityIndex: 1,
options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }],
}),
],
});
// ── Preview switches to capture expected switch totals ──
// Entity 1 (graduated): prepaid delta = +$35 (new $65 old $30)
const previewEnt1 = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: gradPremium.id,
entity_id: entities[0].id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
});
expect(previewEnt1.total).toBeGreaterThan(0);
// Entity 2 (volume): prepaid delta = +$10 (new $40 old $30)
// previewEnt2.total should be LESS than previewEnt1.total by ~$25
const previewEnt2 = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: volPremium.id,
entity_id: entities[1].id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
});
expect(previewEnt2.total).toBeGreaterThan(0);
// The graduated entity pays more: graduated new prepaid ($65) > volume new prepaid ($40)
// so graduated switch invoice > volume switch invoice by ~$25
expect(previewEnt1.total).toBeGreaterThan(previewEnt2.total);
// ── Perform switches ──
await autumnV1.billing.attach({
customer_id: customerId,
product_id: gradPremium.id,
entity_id: entities[0].id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
redirect_mode: "if_required",
});
await autumnV1.billing.attach({
customer_id: customerId,
product_id: volPremium.id,
entity_id: entities[1].id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
redirect_mode: "if_required",
});
// ── Assert entity 1 (graduated) post-switch ──
const entity1 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entities[0].id,
);
await expectCustomerProducts({
customer: entity1,
active: [gradPremium.id],
notPresent: [gradPro.id],
});
expectCustomerFeatureCorrect({
customer: entity1,
featureId: TestFeature.Messages,
balance: newQuantity,
usage: 0,
});
// ── Assert entity 2 (volume) post-switch ──
const entity2 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entities[1].id,
);
await expectCustomerProducts({
customer: entity2,
active: [volPremium.id],
notPresent: [volPro.id],
});
expectCustomerFeatureCorrect({
customer: entity2,
featureId: TestFeature.Messages,
balance: newQuantity,
usage: 0,
});
// ── Verify customer invoices ──
// Invoice 0 (latest): entity 2 switch (volume, prepaid delta +$10 = preview)
// Invoice 1: entity 1 switch (graduated, prepaid delta +$35 = preview)
// Invoice 2: entity 2 initial (volume pro + 300 units = $20 + $30 = $50)
// Invoice 3: entity 1 initial (graduated pro + 300 units = $20 + $30 = $50)
// Both initial invoices are equal because 300 units is all tier 1 (volume=graduated there)
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 4,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});

View File

@@ -0,0 +1,336 @@
/**
* Attach Prepaid Volume Pricing — Immediate Switch Tests (Upgrade: pro → premium)
*
* Autumn charges the prorated base difference plus the prepaid delta immediately.
* Old product disappears; new product is immediately active.
*
* Tiers used throughout (billingUnits = 100):
* Tier 1: 0500 units @ $10/pack (100 units/pack)
* Tier 2: 501+ units @ $5/pack
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import {
expectCustomerProducts,
expectProductActive,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
const BILLING_UNITS = 100;
// Tiers (in Autumn units, i.e. packs of 100):
// 0500 units @ $10/pack → Stripe tier boundary: 500/100 = 5
// 501+ units @ $5/pack
const VOLUME_TIERS = [
{ to: 500, amount: 10 },
{ to: "inf" as const, amount: 5 },
];
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 5: Immediate switch, 300 units, tier 1 → tier 1 (same quantity)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* pro ($20/mo) + 300 units → tier 1 → 3 packs × $10 = $30
* premium ($50/mo) + 300 units → tier 1 → 3 packs × $10 = $30
*
* Volume math:
* Old prepaid: 3 × $10 = $30
* New prepaid: 3 × $10 = $30
* Prepaid delta: $0
* Switch total = prorated base upgrade ($50 - $20 × remaining fraction) > 0
*/
test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: immediate switch, 300 units tier 1 → tier 1")}`, async () => {
const customerId = "attach-prepaid-volume-imm-t1";
const initQuantity = 300;
const newQuantity = 300;
const proItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const premiumItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const pro = products.pro({ id: "pro-volume-imm-t1", items: [proItem] });
const premium = products.premium({
id: "premium-volume-imm-t1",
items: [premiumItem],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }],
}),
],
});
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer: customerBefore, productId: pro.id });
expectCustomerFeatureCorrect({
customer: customerBefore,
featureId: TestFeature.Messages,
balance: initQuantity,
usage: 0,
});
// Preview — prepaid delta = $0 (same tier, same quantity); only prorated base upgrade
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
});
expect(preview.total).toEqual(30);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: newQuantity,
usage: 0,
});
// latestTotal ≈ preview.total (prorated base upgrade, no prepaid delta)
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: preview.total,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 6: Immediate switch, 300 → 800 units, tier 1 → tier 2
// ═══════════════════════════════════════════════════════════════════════════════
/**
* pro ($20/mo) + 300 units → tier 1 → 3 packs × $10 = $30
* premium ($50/mo) + 800 units → tier 2 → 8 packs × $5 = $40
*
* Volume math (key differentiator vs graduated):
* Old prepaid: 3 × $10 = $30
* New prepaid: 8 × $5 = $40 ← volume: all 8 packs at tier-2 rate
* Graduated would be: 5×$10 + 3×$5 = $65 (different — confirms volume)
* Prepaid delta: $40 $30 = +$10
*/
test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: immediate switch, 300 → 800 units tier 1 → tier 2 ($40 not $65 graduated)")}`, async () => {
const customerId = "attach-prepaid-volume-imm-t1-t2";
const initQuantity = 300;
const newQuantity = 800;
const proItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const premiumItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const pro = products.pro({ id: "pro-volume-imm-t1-t2", items: [proItem] });
const premium = products.premium({
id: "premium-volume-imm-t1-t2",
items: [premiumItem],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }],
}),
],
});
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer: customerBefore, productId: pro.id });
expectCustomerFeatureCorrect({
customer: customerBefore,
featureId: TestFeature.Messages,
balance: initQuantity,
usage: 0,
});
// Preview — prepaid delta = +$10 (new: $40 volume, old: $30) plus base proration
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
});
expect(preview.total).toEqual(10 + 30);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: newQuantity,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: preview.total,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 7: Immediate switch, 800 → 600 units, tier 2 → tier 2 (quantity decreases)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* pro ($20/mo) + 800 units → tier 2 → 8 packs × $5 = $40
* premium ($50/mo) + 600 units → tier 2 → 6 packs × $5 = $30
*
* Volume math (key differentiator vs graduated):
* Old prepaid: 8 × $5 = $40
* New prepaid: 6 × $5 = $30 ← volume: all 6 packs at tier-2 rate
* Graduated would be: 5×$10 + 1×$5 = $55 (different — confirms volume)
* Prepaid delta: $30 $40 = $10 (credit, offsets base proration)
*/
test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: immediate switch, 800 → 600 units tier 2 → tier 2 ($30 not $55 graduated)")}`, async () => {
const customerId = "attach-prepaid-volume-imm-t2-t2";
const initQuantity = 800;
const newQuantity = 600;
const proItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const premiumItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const pro = products.pro({ id: "pro-volume-imm-t2-t2", items: [proItem] });
const premium = products.premium({
id: "premium-volume-imm-t2-t2",
items: [premiumItem],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }],
}),
],
});
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer: customerBefore, productId: pro.id });
expectCustomerFeatureCorrect({
customer: customerBefore,
featureId: TestFeature.Messages,
balance: initQuantity,
usage: 0,
});
// Preview — prepaid delta = $10 (new: $30 volume, old: $40) credited against base proration
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
});
expect(typeof preview.total).toBe("number");
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: newQuantity,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: preview.total,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});

View File

@@ -0,0 +1,155 @@
/**
* Attach Prepaid Volume vs Graduated — Entity-Level Initial Attach Test
*
* Two entities share one customer. One is on graduated pricing, the other on
* volume pricing, for the same tier structure. Confirms that the initial
* invoice totals correctly reflect each pricing model independently.
*
* Tiers (billingUnits = 100):
* Tier 1: 0500 units @ $10/pack
* Tier 2: 501+ units @ $5/pack
*
* 800 units, tier 2:
* Graduated: 5×$10 + 3×$5 = $65
* Volume: 8×$5 = $40 ← cheaper, all at tier-2 rate
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
const BILLING_UNITS = 100;
const BASE_PRICE = 20;
const TIERS = [
{ to: 500, amount: 10 },
{ to: "inf" as const, amount: 5 },
];
// ═══════════════════════════════════════════════════════════════════════════════
// TEST: Entity 1 (graduated) and Entity 2 (volume) — initial attach at 800 units
// ═══════════════════════════════════════════════════════════════════════════════
/**
* 800 units crosses into tier 2.
*
* Entity 1 (graduated): 5×$10 + 3×$5 = $65 → invoice = $20 + $65 = $85
* Entity 2 (volume): 8×$5 = $40 → invoice = $20 + $40 = $60
*
* Confirms both pricing models are applied independently per entity.
*/
test.concurrent(`${chalk.yellowBright("attach-prepaid-volume-entities: graduated ($65) vs volume ($40) at 800 units tier 2")}`, async () => {
const customerId = "vol-ent-initial-800";
const quantity = 800;
// Graduated: 5×$10 + 3×$5 = $65
const gradExpectedPrepaid = 5 * 10 + 3 * 5;
// Volume: 8×$5 = $40
const volExpectedPrepaid = (quantity / BILLING_UNITS) * 5;
const gradItem = items.tieredPrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: TIERS,
});
const volItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: TIERS,
});
const gradPro = products.pro({ id: "grad-pro-ent-800", items: [gradItem] });
const volPro = products.pro({ id: "vol-pro-ent-800", items: [volItem] });
const { autumnV1, entities } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [gradPro, volPro] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [],
});
// ── Preview entity 1 (graduated): $20 + $65 = $85 ──
const previewGrad = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: gradPro.id,
entity_id: entities[0].id,
options: [{ feature_id: TestFeature.Messages, quantity }],
});
expect(previewGrad.total).toBe(BASE_PRICE + gradExpectedPrepaid);
// ── Preview entity 2 (volume): $20 + $40 = $60 ──
const previewVol = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: volPro.id,
entity_id: entities[1].id,
options: [{ feature_id: TestFeature.Messages, quantity }],
});
expect(previewVol.total).toBe(BASE_PRICE + volExpectedPrepaid);
// ── Attach both ──
await autumnV1.billing.attach({
customer_id: customerId,
product_id: gradPro.id,
entity_id: entities[0].id,
options: [{ feature_id: TestFeature.Messages, quantity }],
redirect_mode: "if_required",
});
await autumnV1.billing.attach({
customer_id: customerId,
product_id: volPro.id,
entity_id: entities[1].id,
options: [{ feature_id: TestFeature.Messages, quantity }],
redirect_mode: "if_required",
});
// ── Assert entity 1 (graduated) ──
const entity1 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entities[0].id,
);
await expectProductActive({ customer: entity1, productId: gradPro.id });
expectCustomerFeatureCorrect({
customer: entity1,
featureId: TestFeature.Messages,
balance: quantity,
usage: 0,
});
// ── Assert entity 2 (volume) ──
const entity2 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entities[1].id,
);
await expectProductActive({ customer: entity2, productId: volPro.id });
expectCustomerFeatureCorrect({
customer: entity2,
featureId: TestFeature.Messages,
balance: quantity,
usage: 0,
});
// ── Customer invoices: 2 total, latest is the volume attach ($60) ──
// Invoice 0 (latest): entity 2 volume — $60
// Invoice 1: entity 1 graduated — $85
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: BASE_PRICE + volExpectedPrepaid,
});
await expectCustomerInvoiceCorrect({
customer,
count: 2,
invoiceIndex: 1,
latestTotal: BASE_PRICE + gradExpectedPrepaid,
});
});

View File

@@ -0,0 +1,173 @@
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect.js";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect.js";
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect.js";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect.js";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
/**
* Volume Pricing with flat_amount — Attach Tests
*
* Verifies that attaching a volume-priced prepaid product with `flat_amount`
* charges the correct total. Volume pricing charges the entire quantity at
* the rate of the matching tier, plus the tier's flat fee.
*
* billingUnits = 1 throughout so quantity maps directly to units.
*
* Test 1: flat_amount only (per-unit amount = 0)
* Tier 1: 0100 → $0/unit + $20 flat
* Tier 2: 101+ → $0/unit + $50 flat
* Attach 50 → tier 1 → 50 × $0 + $20 = $20
*
* Test 2: mixed per-unit + flat_amount
* Tier 1: 0100 → $1/unit + $10 flat
* Tier 2: 101+ → $0.50/unit + $25 flat
* Attach 50 → tier 1 → 50 × $1 + $10 = $60
*/
// ─── Test 1: Flat amount only (per-unit = 0) ─────────────────────────────────
test.concurrent(`${chalk.yellowBright("attach-prepaid-volume-flat: flat_amount only")}`, async () => {
const customerId = "vol-flat-only-attach";
const quantity = 50;
// Tier 1: 0-100 → $0/unit, $20 flat. Tier 2: 101+ → $0/unit, $50 flat.
// 50 units → falls in tier 1 → 50 × $0 + $20 = $20
const expectedTotal = 20;
const volumeItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: 1,
tiers: [
{ to: 100, amount: 0, flat_amount: 20 },
{ to: "inf", amount: 0, flat_amount: 50 },
],
});
const product = products.base({
id: "vol-flat-only",
items: [volumeItem],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [product] }),
],
actions: [],
});
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: product.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
});
expect(preview.total).toBe(expectedTotal);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: product.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: product.id });
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: quantity,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: expectedTotal,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ─── Test 2: Mixed per-unit amount + flat_amount ─────────────────────────────
test.concurrent(`${chalk.yellowBright("attach-prepaid-volume-flat: mixed per-unit + flat_amount")}`, async () => {
const customerId = "vol-flat-mixed-attach";
const quantity = 50;
// Tier 1: 0-100 → $1/unit, $10 flat. Tier 2: 101+ → $0.50/unit, $25 flat.
// 50 units → falls in tier 1 → 50 × $1 + $10 = $60
const expectedTotal = 60;
const volumeItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: 1,
tiers: [
{ to: 100, amount: 1, flat_amount: 10 },
{ to: "inf", amount: 0.5, flat_amount: 25 },
],
});
const product = products.base({
id: "vol-flat-mixed",
items: [volumeItem],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [product] }),
],
actions: [],
});
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: product.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
});
expect(preview.total).toBe(expectedTotal);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: product.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: product.id });
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: quantity,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: expectedTotal,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});

View File

@@ -0,0 +1,422 @@
/**
* Attach Prepaid Volume Pricing Tests
*
* Verifies that volume-based tier pricing charges the entire purchased quantity
* at the rate of whichever single tier it falls into — not split across tiers
* the way graduated pricing works.
*
* Tiers used throughout (billingUnits = 100):
* Tier 1: 0500 units @ $10/pack (100 units/pack)
* Tier 2: 501+ units @ $5/pack
*
* Volume pricing examples vs graduated:
* 300 units (tier 1): volume = 3 packs × $10 = $30
* graduated would also be $30 (all within tier 1)
* 800 units (tier 2): volume = 8 packs × $5 = $40
* graduated would be 5×$10 + 3×$5 = $65 (different!)
*
* Plan switch tests (immediate + scheduled) are in:
* attach-prepaid-volume-switch.test.ts
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
const BILLING_UNITS = 100;
const BASE_PRICE = 20; // pro product base price
// Tiers (in Autumn units, i.e. packs of 100):
// 0500 units @ $10/pack → Stripe tier boundary: 500/100 = 5
// 501+ units @ $5/pack
const VOLUME_TIERS = [
{ to: 500, amount: 10 },
{ to: "inf" as const, amount: 5 },
];
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Quantity within tier 1 — volume and graduated produce the same result
// ═══════════════════════════════════════════════════════════════════════════════
/**
* 300 units = 3 packs, all within tier 1.
* Volume: 3 × $10 = $30.
* Graduated would also be $30 here (same tier).
*/
test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: 300 units, tier 1 only → $30")}`, async () => {
const customerId = "attach-prepaid-volume-tier1";
const quantity = 300;
const expectedPrepaidCost = (quantity / BILLING_UNITS) * 10; // 3 × $10 = $30
const volumeItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const pro = products.pro({
id: "pro-volume-tier1",
items: [volumeItem],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
// Preview must reflect volume pricing
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
});
expect(preview.total).toBe(BASE_PRICE + expectedPrepaidCost);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: pro.id });
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: quantity,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: BASE_PRICE + expectedPrepaidCost,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Quantity crossing into tier 2 — volume differs from graduated
// ═══════════════════════════════════════════════════════════════════════════════
/**
* 800 units = 8 packs, falling into tier 2.
* Volume: entire 8 packs × $5 = $40.
* Graduated: 5 packs×$10 + 3 packs×$5 = $65.
*
* This test confirms volume semantics are applied end-to-end, not graduated.
*/
test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: 800 units, tier 2 → $40 (not $65 graduated)")}`, async () => {
const customerId = "attach-prepaid-volume-tier2";
const quantity = 800;
// Volume: 8 packs × $5 = $40
const expectedPrepaidCost = (quantity / BILLING_UNITS) * 5;
const volumeItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const pro = products.pro({
id: "pro-volume-tier2",
items: [volumeItem],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
// Preview must reflect volume pricing ($40), not graduated ($65)
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
});
expect(preview.total).toBe(BASE_PRICE + expectedPrepaidCost);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: pro.id });
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: quantity,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: BASE_PRICE + expectedPrepaidCost,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Quantity with included (free) usage in tier 1
// ═══════════════════════════════════════════════════════════════════════════════
/**
* 300 total units, 100 free (1 pack included).
* Purchased above free: 200 units = 2 packs, both in tier 1.
* Volume: 2 × $10 = $20.
* Balance: 300.
*/
test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: 300 units, 100 included, tier 1 → $20")}`, async () => {
const customerId = "attach-prepaid-volume-included-tier1";
const quantity = 300;
const includedUsage = 100;
// After free pack: 200 units = 2 packs in tier 1 → 2 × $10 = $20
const expectedPrepaidCost = (quantity / BILLING_UNITS) * 10;
const volumeItem = items.volumePrepaidMessages({
includedUsage,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const pro = products.pro({
id: "pro-volume-included-tier1",
items: [volumeItem],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
});
expect(preview.total).toBe(BASE_PRICE + expectedPrepaidCost);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: pro.id });
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: quantity,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: BASE_PRICE + expectedPrepaidCost,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4: Zero quantity — no prepaid charge, only base price
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: quantity 0 → no prepaid charge")}`, async () => {
const customerId = "attach-prepaid-volume-zero";
const volumeItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const pro = products.pro({
id: "pro-volume-zero",
items: [volumeItem],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 0 }],
});
expect(preview.total).toBe(BASE_PRICE);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 0 }],
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: pro.id });
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 0,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: BASE_PRICE,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 5: 4 tiers with included usage — volume pricing at correct tier
// ═══════════════════════════════════════════════════════════════════════════════
/**
* 4 tiers (billingUnits = 100):
* Tier 1: 0-200 units @ $15/pack
* Tier 2: 201-500 units @ $10/pack
* Tier 3: 501-1000 units @ $7/pack
* Tier 4: 1001+ units @ $5/pack
*
* With 100 included (free) and 900 total quantity:
* Paid packs: (900 - 100) / 100 = 8 packs
* Volume: all 8 packs at tier 3 rate ($7) = $56
* Total: $20 base + $56 = $76
*/
test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: 4 tiers, 100 included, 900 total → $76")}`, async () => {
const customerId = "attach-prepaid-volume-4tier-incl";
const quantity = 900;
const includedUsage = 100;
// 4-tier pricing structure
const fourTiers = [
{ to: 200, amount: 15 },
{ to: 500, amount: 10 },
{ to: 1000, amount: 7 },
{ to: "inf" as const, amount: 5 },
];
// Paid packs after free: (900 - 100) / 100 = 8 packs
const expectedPrepaidCost = (quantity / BILLING_UNITS) * 7;
const volumeItem = items.volumePrepaidMessages({
includedUsage,
billingUnits: BILLING_UNITS,
tiers: fourTiers,
});
const pro = products.pro({
id: "pro-volume-4tier",
items: [volumeItem],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
// Preview must reflect volume pricing at tier 3
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
});
expect(preview.total).toBe(BASE_PRICE + expectedPrepaidCost);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity }],
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: pro.id });
// Balance should equal total quantity (free + paid)
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: quantity,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: BASE_PRICE + expectedPrepaidCost,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});

View File

@@ -0,0 +1,521 @@
/**
* Attach Prepaid Volume Pricing — Scheduled Switch Tests (Downgrade: premium → pro)
*
* Downgrading to a lower base price is scheduled. preview.total === 0 always.
* Old product becomes "canceling" (active + canceled_at set), new product is "scheduled".
* After the billing cycle advances the new product becomes active with a fresh invoice.
*
* Each scenario has two test.concurrent calls:
* ...-mid — asserts mid-cycle state (canceling + scheduled)
* ...-after-cycle — advances clock and asserts post-cycle state
*
* Tiers used throughout (billingUnits = 100):
* Tier 1: 0500 units @ $10/pack (100 units/pack)
* Tier 2: 501+ units @ $5/pack
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import {
expectCustomerProducts,
expectProductCanceling,
expectProductScheduled,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
const BILLING_UNITS = 100;
const PRO_BASE_PRICE = 20;
// Tiers (in Autumn units, i.e. packs of 100):
// 0500 units @ $10/pack → Stripe tier boundary: 500/100 = 5
// 501+ units @ $5/pack
const VOLUME_TIERS = [
{ to: 500, amount: 10 },
{ to: "inf" as const, amount: 5 },
];
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 8: Scheduled switch, 300 units, tier 1 → tier 1 (same quantity)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* premium ($50/mo) + 300 units → tier 1 → 3 packs × $10 = $30
* Downgrade: pro ($20/mo) + 300 units
*
* Scheduled: premium stays active (canceling) until cycle ends.
* preview.total === 0 always for downgrades.
*/
// --- Mid-cycle ---
test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: scheduled switch mid-cycle, 300 units tier 1 → tier 1")}`, async () => {
const customerId = "attach-prepaid-volume-sched-mid-t1";
const initQuantity = 300;
const newQuantity = 300;
const premiumItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const proItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const premium = products.premium({
id: "premium-volume-sched-mid-t1",
items: [premiumItem],
});
const pro = products.pro({
id: "pro-volume-sched-mid-t1",
items: [proItem],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [premium, pro] }),
],
actions: [
s.billing.attach({
productId: premium.id,
options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }],
}),
],
});
// Preview for downgrade is always $0
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
});
expect(preview.total).toBe(0);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({ customer, productId: premium.id });
await expectProductScheduled({ customer, productId: pro.id });
// Old plan still active — balance reflects original quantity
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: initQuantity,
usage: 0,
});
// No new invoice charged for a downgrade
await expectCustomerInvoiceCorrect({ customer, count: 1 });
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// --- After-cycle ---
test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: scheduled switch after cycle, 300 units tier 1 → tier 1 (pro $20 + $30)")}`, async () => {
const customerId = "attach-prepaid-volume-sched-after-t1";
const initQuantity = 300;
const newQuantity = 300;
// Volume: 3 packs × $10 = $30 (all tier 1)
const expectedNewPrepaid = (newQuantity / BILLING_UNITS) * 10;
const premiumItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const proItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const premium = products.premium({
id: "premium-volume-sched-after-t1",
items: [premiumItem],
});
const pro = products.pro({
id: "pro-volume-sched-after-t1",
items: [proItem],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [premium, pro] }),
],
actions: [
s.billing.attach({
productId: premium.id,
options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }],
}),
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
}),
s.advanceToNextInvoice(),
],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [pro.id],
notPresent: [premium.id],
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: newQuantity,
usage: 0,
});
// New invoice: pro base $20 + 3 × $10 = $50
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: PRO_BASE_PRICE + expectedNewPrepaid,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 9: Scheduled switch, 800 → 300 units, tier 2 → tier 1
// ═══════════════════════════════════════════════════════════════════════════════
/**
* premium ($50/mo) + 800 units → tier 2 → 8 packs × $5 = $40
* Downgrade: pro ($20/mo) + 300 units
*
* After cycle: pro base $20 + 300 units tier 1 (3 × $10 = $30) = $50.
* Volume vs graduated: same here (300 units all in tier 1 — no difference).
*/
// --- Mid-cycle ---
test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: scheduled switch mid-cycle, 800 → 300 units tier 2 → tier 1")}`, async () => {
const customerId = "attach-prepaid-volume-sched-mid-t2-t1";
const initQuantity = 800;
const newQuantity = 300;
const premiumItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const proItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const premium = products.premium({
id: "premium-volume-sched-mid-t2-t1",
items: [premiumItem],
});
const pro = products.pro({
id: "pro-volume-sched-mid-t2-t1",
items: [proItem],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [premium, pro] }),
],
actions: [
s.billing.attach({
productId: premium.id,
options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }],
}),
],
});
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
});
expect(preview.total).toBe(0);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({ customer, productId: premium.id });
await expectProductScheduled({ customer, productId: pro.id });
// Old plan still active — balance reflects original (800) quantity
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: initQuantity,
usage: 0,
});
await expectCustomerInvoiceCorrect({ customer, count: 1 });
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// --- After-cycle ---
test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: scheduled switch after cycle, 800 → 300 units tier 2 → tier 1 (pro $20 + $30)")}`, async () => {
const customerId = "attach-prepaid-volume-sched-after-t2-t1";
const initQuantity = 800;
const newQuantity = 300;
// Volume: 3 packs × $10 = $30 (all tier 1; graduated same here)
const expectedNewPrepaid = (newQuantity / BILLING_UNITS) * 10;
const premiumItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const proItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const premium = products.premium({
id: "premium-volume-sched-after-t2-t1",
items: [premiumItem],
});
const pro = products.pro({
id: "pro-volume-sched-after-t2-t1",
items: [proItem],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [premium, pro] }),
],
actions: [
s.billing.attach({
productId: premium.id,
options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }],
}),
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
}),
s.advanceToNextInvoice(),
],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [pro.id],
notPresent: [premium.id],
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: newQuantity,
usage: 0,
});
// New invoice: pro base $20 + 3 × $10 = $50
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: PRO_BASE_PRICE + expectedNewPrepaid,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 10: Scheduled switch, 1000 → 600 units, tier 2 → tier 2
// ═══════════════════════════════════════════════════════════════════════════════
/**
* premium ($50/mo) + 1000 units → tier 2 → 10 packs × $5 = $50
* Downgrade: pro ($20/mo) + 600 units
*
* After cycle: pro base $20 + 600 units → tier 2 → 6 × $5 = $30 (volume!)
* Graduated would be: 5×$10 + 1×$5 = $55 — KEY DIFFERENTIATOR
* latestTotal: $20 + $30 = $50
*/
// --- Mid-cycle ---
test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: scheduled switch mid-cycle, 1000 → 600 units tier 2 → tier 2")}`, async () => {
const customerId = "attach-prepaid-volume-sched-mid-t2-t2";
const initQuantity = 1000;
const newQuantity = 600;
const premiumItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const proItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const premium = products.premium({
id: "premium-volume-sched-mid-t2-t2",
items: [premiumItem],
});
const pro = products.pro({
id: "pro-volume-sched-mid-t2-t2",
items: [proItem],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [premium, pro] }),
],
actions: [
s.billing.attach({
productId: premium.id,
options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }],
}),
],
});
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
});
expect(preview.total).toBe(0);
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({ customer, productId: premium.id });
await expectProductScheduled({ customer, productId: pro.id });
// Old plan still active — balance reflects original (1000) quantity
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: initQuantity,
usage: 0,
});
await expectCustomerInvoiceCorrect({ customer, count: 1 });
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// --- After-cycle ---
test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: scheduled switch after cycle, 1000 → 600 units tier 2 → tier 2 (pro $20 + $30 volume, not $55 graduated)")}`, async () => {
const customerId = "attach-prepaid-volume-sched-after-t2-t2";
const initQuantity = 1000;
const newQuantity = 600;
// Volume: 6 packs × $5 = $30 (all at tier-2 rate)
// Graduated would be: 5×$10 + 1×$5 = $55 — this confirms volume semantics
const expectedNewPrepaid = (newQuantity / BILLING_UNITS) * 5;
const premiumItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const proItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const premium = products.premium({
id: "premium-volume-sched-after-t2-t2",
items: [premiumItem],
});
const pro = products.pro({
id: "pro-volume-sched-after-t2-t2",
items: [proItem],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [premium, pro] }),
],
actions: [
s.billing.attach({
productId: premium.id,
options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }],
}),
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
}),
s.advanceToNextInvoice(),
],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [pro.id],
notPresent: [premium.id],
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: newQuantity,
usage: 0,
});
// New invoice: pro base $20 + 6 × $5 (volume) = $50
// Graduated would be: $20 + 5×$10 + 1×$5 = $75 — confirms volume semantics
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: PRO_BASE_PRICE + expectedNewPrepaid,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});

View File

@@ -1,5 +1,10 @@
import { expect, test } from "bun:test";
import type { ApiCustomerV3, AttachParamsV1Input } from "@autumn/shared";
import type {
ApiCustomerV3,
AttachParamsV1Input,
CheckResponseV3,
} from "@autumn/shared";
import { BillingMethod, TierInfinite } from "@autumn/shared";
import {
expectCustomerFeatureCorrect,
expectCustomerFeatureExists,
@@ -394,3 +399,81 @@ test.concurrent(`${chalk.yellowBright("v2-customize attach: paid feature mix (co
latestTotal: preview.total,
});
});
test.concurrent(`${chalk.yellowBright("v2-customize attach: tiered prepaid with included (tiers include included)")}`, async () => {
const customerId = "v2-attach-customize-tiered-prepaid";
const base = products.base({
id: "base",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1, autumnV2, autumnV2_1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [base] }),
],
actions: [],
});
// Customize with tiered prepaid messages
// Tiers `to` INCLUDES included (200): [{to:700}, {to:"inf"}]
// Internally stored as [{to:500}, {to:"inf"}] (without included)
const params: AttachParamsV1Input = {
customer_id: customerId,
plan_id: base.id,
redirect_mode: "if_required",
feature_quantities: [{ feature_id: TestFeature.Messages, quantity: 700 }],
customize: {
price: null,
items: [
itemsV2.volumePrepaidMessages({
included: 200,
tiers: [
{ to: 700, amount: 10 },
{ to: TierInfinite, amount: 5 },
],
}),
],
},
};
const PREPAID_PRICE = 70; // 7 packs * 10
const preview =
await autumnV2.billing.previewAttach<AttachParamsV1Input>(params);
expect(preview.total).toBe(PREPAID_PRICE);
await autumnV2.billing.attach<AttachParamsV1Input>(params);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: base.id });
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 700,
balance: 700,
usage: 0,
});
// Verify via V2.1 check that balance.price tiers include included
const checkRes = (await autumnV2_1.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV3;
expect(checkRes.allowed).toBe(true);
const breakdown = checkRes.balance?.breakdown?.[0];
expect(breakdown?.price).toBeDefined();
expect(breakdown?.price?.billing_method).toBe(BillingMethod.Prepaid);
expect(breakdown?.price?.tiers).toEqual([
{ to: 700, amount: 10 },
{ to: "inf", amount: 5 },
]);
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: PREPAID_PRICE,
});
});

View File

@@ -0,0 +1,392 @@
/**
* Legacy New Attach — Volume Pricing Tests
*
* Tests that the V1 attach() path applies volume tier semantics correctly.
* V1 quantity = purchased units (excludes includedUsage).
* Volume pricing charges the ENTIRE purchased quantity at the rate of its tier
* (not split across tiers like graduated pricing).
*
* Tiers (billingUnits = 100):
* Tier 1: 0500 units @ $10/pack
* Tier 2: 501+ units @ $5/pack
*
* Base product price: $20/month (products.pro)
*
* V1 vs V2 quantity reminder:
* V1: options[].quantity = purchased units (EXCLUDES includedUsage)
* V2: options[].quantity = total units (INCLUDES includedUsage)
* For includedUsage=0, V1 and V2 quantities are identical.
*/
/** biome-ignore-all lint/suspicious/noExplicitAny: test file */
import { test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import ctx from "@tests/utils/testInitUtils/createTestContext";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
const BILLING_UNITS = 100;
const BASE_PRICE = 20;
const VOLUME_TIERS = [
{ to: 500, amount: 10 },
{ to: "inf" as const, amount: 5 },
];
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: purchased=300, allowance=0, tier 1 → invoice $50
//
// V1 options.quantity: 300 (purchased; allowance = 0)
// Total balance: 300
//
// Volume math:
// 300 units → tier 1 → 3 × $10 = $30
// Graduated would also be $30 (same tier — no differentiator)
//
// Invoice total: $20 (base) + $30 (prepaid) = $50
// ═══════════════════════════════════════════════════════════════════════════════
test.skip(`${chalk.yellowBright("legacy-new-volume 1: purchased=300, no allowance, tier 1 → $50")}`, async () => {
const customerId = "legacy-new-volume-t1";
const purchasedQuantity = 300;
const includedUsage = 0;
// Volume: 3 packs × $10 = $30; graduated would also be $30 (same tier)
const expectedPrepaid = (purchasedQuantity / BILLING_UNITS) * 10;
const volumeItem = items.volumePrepaidMessages({
includedUsage,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const pro = products.pro({ id: "pro-legacy-volume-t1", items: [volumeItem] });
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({
productId: pro.id,
options: [
{ feature_id: TestFeature.Messages, quantity: purchasedQuantity },
],
}),
],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectProductAttached({ customer: customer as any, product: pro });
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: includedUsage + purchasedQuantity,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: BASE_PRICE + expectedPrepaid,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: purchased=800, allowance=0, tier 2 → invoice $60
//
// V1 options.quantity: 800
// Total balance: 800
//
// Volume math:
// 800 units → tier 2 → 8 × $5 = $40
// Graduated would be: 5×$10 + 3×$5 = $65 — KEY DIFFERENTIATOR
//
// Invoice total: $20 (base) + $40 (prepaid) = $60
// ═══════════════════════════════════════════════════════════════════════════════
test.skip(`${chalk.yellowBright("legacy-new-volume 2: purchased=800, no allowance, tier 2 → $60 (graduated would be $65)")}`, async () => {
const customerId = "legacy-new-volume-t2";
const purchasedQuantity = 800;
const includedUsage = 0;
// Volume: 8 packs × $5 = $40; graduated would be: 5×$10 + 3×$5 = $65
const expectedPrepaid = (purchasedQuantity / BILLING_UNITS) * 5;
const volumeItem = items.volumePrepaidMessages({
includedUsage,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const pro = products.pro({ id: "pro-legacy-volume-t2", items: [volumeItem] });
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({
productId: pro.id,
options: [
{ feature_id: TestFeature.Messages, quantity: purchasedQuantity },
],
}),
],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectProductAttached({ customer: customer as any, product: pro });
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: includedUsage + purchasedQuantity,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: BASE_PRICE + expectedPrepaid,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: purchased=200, allowance=100, tier 1 → invoice $40
//
// V1 options.quantity: 200 (purchased only; allowance excluded per V1 semantics)
// includedUsage: 100
// Total balance: 300 (100 allowance + 200 purchased)
//
// Volume math applies to the PURCHASED portion (200 units):
// 200 units → tier 1 → 2 × $10 = $20
// Graduated would also be $20 (same tier — no differentiator)
//
// Invoice total: $20 (base) + $20 (prepaid) = $40
// ═══════════════════════════════════════════════════════════════════════════════
test.skip(`${chalk.yellowBright("legacy-new-volume 3: purchased=200, allowance=100, tier 1 → $20 (balance=300)")}`, async () => {
const customerId = "legacy-new-volume-t3";
const purchasedQuantity = 200;
const includedUsage = 100;
// V1: quantity = purchased only (200); balance = allowance + purchased = 300
// Volume: 2 packs × $10 = $20; graduated would also be $20 (same tier)
const expectedPrepaid =
(purchasedQuantity + includedUsage / BILLING_UNITS) * 10;
const volumeItem = items.volumePrepaidMessages({
includedUsage,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const pro = products.pro({ id: "pro-legacy-volume-t3", items: [volumeItem] });
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({
productId: pro.id,
// V1 quantity = purchased units only (excludes includedUsage)
options: [
{ feature_id: TestFeature.Messages, quantity: purchasedQuantity },
],
}),
],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectProductAttached({ customer: customer as any, product: pro });
// balance = allowance (100) + purchased (200) = 300
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: includedUsage + purchasedQuantity,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: BASE_PRICE + expectedPrepaid,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4: purchased=700, allowance=100, tier 2 → invoice $55
//
// V1 options.quantity: 700 (purchased only; allowance excluded per V1 semantics)
// includedUsage: 100
// Total balance: 800 (100 allowance + 700 purchased)
//
// Volume math applies to the PURCHASED portion (700 units):
// 700 units → tier 2 → 7 × $5 = $35
// Graduated would be: 5×$10 + 2×$5 = $60 — KEY DIFFERENTIATOR
//
// Invoice total: $20 (base) + $35 (prepaid) = $55
// ═══════════════════════════════════════════════════════════════════════════════
test.skip(`${chalk.yellowBright("legacy-new-volume 4: purchased=700, allowance=100, tier 2 → $40 (balance=800)")}`, async () => {
const customerId = "legacy-new-volume-t4";
const purchasedQuantity = 700;
const includedUsage = 100;
// V1: quantity = purchased only (700); balance = allowance + purchased = 800
// Volume: 7 packs × $5 = $35; graduated would be: 5×$10 + 2×$5 = $60
const expectedPrepaid =
(purchasedQuantity + includedUsage / BILLING_UNITS) * 5;
const volumeItem = items.volumePrepaidMessages({
includedUsage,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const pro = products.pro({ id: "pro-legacy-volume-t4", items: [volumeItem] });
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({
productId: pro.id,
// V1 quantity = purchased units only (excludes includedUsage)
options: [
{ feature_id: TestFeature.Messages, quantity: purchasedQuantity },
],
}),
],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectProductAttached({ customer: customer as any, product: pro });
// balance = allowance (100) + purchased (700) = 800
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: includedUsage + purchasedQuantity,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: BASE_PRICE + expectedPrepaid,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 5: purchased=0, allowance=100 → invoice $20 (base only)
//
// V1 options.quantity: 0
// includedUsage: 100
// Total balance: 100 (allowance only, no purchased units)
//
// Volume math: 0 units → no prepaid charge
// Invoice total: $20 (base only)
// ═══════════════════════════════════════════════════════════════════════════════
test.skip(`${chalk.yellowBright("legacy-new-volume 5: purchased=0, allowance=100 → $20 base only (balance=100)")}`, async () => {
const customerId = "legacy-new-volume-t5";
const purchasedQuantity = 0;
const includedUsage = 100;
// Volume: 0 units → $0 prepaid charge
const expectedPrepaid = 0;
const volumeItem = items.volumePrepaidMessages({
includedUsage,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const pro = products.pro({ id: "pro-legacy-volume-t5", items: [volumeItem] });
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({
productId: pro.id,
// V1 quantity = 0 (no purchased units; allowance comes from includedUsage)
options: [
{ feature_id: TestFeature.Messages, quantity: purchasedQuantity },
],
}),
],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectProductAttached({ customer: customer as any, product: pro });
// balance = allowance only (100)
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: includedUsage + purchasedQuantity,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: BASE_PRICE + expectedPrepaid,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});

View File

@@ -1,5 +1,5 @@
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { type ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";

View File

@@ -0,0 +1,235 @@
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { calculateProration } from "@tests/integration/billing/utils/proration/calculateProration";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { advanceTestClock } from "@tests/utils/stripeUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════════════════
// PAID-TO-PAID: TIER BEHAVIOR TRANSITIONS
// ═══════════════════════════════════════════════════════════════════════════════
const BILLING_UNITS = 100;
// Tiers for prepaid pricing tests (same for graduated and volume)
const PREPAID_TIERS = [
{ to: 500, amount: 10 },
{ to: "inf" as const, amount: 5 },
];
// 4.8 Graduated prepaid to volume prepaid (mid-cycle with proration)
test.concurrent(`${chalk.yellowBright("p2p: graduated prepaid to volume prepaid (mid-cycle proration)")}`, async () => {
const quantity = 800;
const basePrice = 20;
// Graduated calculation: 5 packs × $10 + 3 packs × $5 = $65
const graduatedCost = 5 * 10 + 3 * 5;
// Volume calculation: 8 packs × $5 (tier 2 rate for all) = $40
const volumeCost = 8 * 5;
const graduatedItem = items.tieredPrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: PREPAID_TIERS,
});
const priceItem = items.monthlyPrice({ price: basePrice });
const pro = products.base({ id: "pro", items: [graduatedItem, priceItem] });
const { customerId, autumnV1, ctx, testClockId } = await initScenario({
customerId: "p2p-grad-to-vol-proration",
setup: [
s.customer({ paymentMethod: "success", testClock: true }),
s.products({ list: [pro] }),
],
actions: [
s.billing.attach({
productId: "pro",
options: [{ feature_id: TestFeature.Messages, quantity }],
}),
],
});
// Verify initial invoice (base price + graduated prepaid)
const initialCustomer =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer: initialCustomer,
count: 1,
latestTotal: basePrice + graduatedCost,
});
// Advance 15 days (mid-cycle)
const advancedTo = await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 15,
});
// Change to volume prepaid with same quantity
const volumeItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: PREPAID_TIERS,
});
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [volumeItem, priceItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Calculate prorated difference: credit old graduated, charge new volume
const proratedGraduated = await calculateProration({
customerId,
advancedTo,
amount: graduatedCost,
});
const proratedVolume = await calculateProration({
customerId,
advancedTo,
amount: volumeCost,
});
// Volume is cheaper, so this should be a credit (negative)
const expectedAmount = proratedVolume - proratedGraduated;
expect(preview.total).toEqual(expectedAmount);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Balance should remain at 800 (quantity unchanged)
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: quantity,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: preview.total,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// Tiers for consumable pricing tests
const CONSUMABLE_TIERS = [
{ to: 500, amount: 0.1 },
{ to: "inf" as const, amount: 0.05 },
];
// 4.9 Volume prepaid to graduated tiered consumable
test.concurrent(`${chalk.yellowBright("p2p: volume prepaid to graduated tiered consumable")}`, async () => {
const quantity = 800;
const basePrice = 20;
// Volume prepaid: 8 packs × $5 = $40
const volumeCost = 8 * 5;
const volumeItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: PREPAID_TIERS,
});
const priceItem = items.monthlyPrice({ price: basePrice });
const pro = products.base({ id: "pro", items: [volumeItem, priceItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "p2p-vol-to-tiered-cons",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.billing.attach({
productId: "pro",
options: [{ feature_id: TestFeature.Messages, quantity }],
}),
],
});
// Track some usage (600 of 800 = 200 remaining)
const messagesUsage = 600;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Verify customer has usage tracked
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customerBefore.features[TestFeature.Messages].usage).toBe(
messagesUsage,
);
// Change to tiered consumable with 100 included
// tier_behavior should be undefined (defaults to graduated)
const tieredConsumableItem = items.tieredConsumableMessages({
includedUsage: 100,
billingUnits: 1,
tiers: CONSUMABLE_TIERS,
});
// Verify the item has no tier_behavior set (defaults to graduated)
expect(tieredConsumableItem.tier_behavior).toBeUndefined();
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [tieredConsumableItem, priceItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should refund full prepaid amount ($40)
expect(preview.total).toBe(-volumeCost);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Usage should be preserved (600 tracked)
// New included usage is 100, so balance = 100 - 600 = -500 (in overage)
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: tieredConsumableItem.included_usage,
balance: tieredConsumableItem.included_usage - messagesUsage,
usage: messagesUsage,
});
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: preview.total,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});

View File

@@ -0,0 +1,506 @@
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectLatestInvoiceCorrect } from "@tests/integration/billing/utils/expectLatestInvoiceCorrect.js";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
/**
* Volume Pricing — Update Quantity Tests
*
* 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
*
* Cost table used throughout:
* 300 units → tier 1 → 3 × $10 = $30
* 500 units → tier 1 → 5 × $10 = $50
* 600 units → tier 2 → 6 × $5 = $30
* 800 units → tier 2 → 8 × $5 = $40
* 1000 units → tier 2 → 10 × $5 = $50
*
* Volume vs Graduated delta comparison (the KEY tests are 2 and 5):
* Test 1: 300→500 same tier volume +$20 graduated +$20 (no diff)
* Test 2: 300→800 tier 1→2 volume +$10 graduated +$35 ← DIFFERENTIATOR
* Test 3: 600→1000 same tier volume +$20 graduated +$20 (no diff)
* Test 4: 500→300 same tier volume $20 graduated $20 (no diff)
* Test 5: 800→300 tier 2→1 volume $10 graduated $35 ← DIFFERENTIATOR
*/
const BILLING_UNITS = 100;
const VOLUME_TIERS = [
{ to: 500, amount: 10 },
{ to: "inf" as const, amount: 5 },
];
// ─── Test 1: Increase — same tier (300 → 500, both tier 1) ───────────────────
test.concurrent(`${chalk.yellowBright("volume-tiers-update-quantity: increase same tier 300→500")}`, async () => {
const customerId = "volume-update-qty-increase-t1";
const initQuantity = 300;
const newQuantity = 500;
// Old cost: 3 × $10 = $30. New cost: 5 × $10 = $50. Delta: +$20.
// Graduated delta: same (+$20) — no difference within same tier.
const expectedDelta =
(newQuantity / BILLING_UNITS) * 10 - (initQuantity / BILLING_UNITS) * 10; // $20
const volumeItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const product = products.base({
id: "vol-upd-qty-inc-t1",
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 }],
}),
],
});
// Preview update
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);
// Execute update
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: product.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customer.features?.[TestFeature.Messages]?.balance).toBe(newQuantity);
expectLatestInvoiceCorrect({
customer,
productId: product.id,
amount: expectedDelta,
});
});
// ─── Test 2: Increase — crosses tier boundary (300 → 800, tier 1 → tier 2) ──
test.concurrent(`${chalk.yellowBright("volume-tiers-update-quantity: increase crossing tier 300→800")}`, async () => {
const customerId = "volume-update-qty-increase-t2";
const initQuantity = 300;
const newQuantity = 800;
// Volume: old cost = 3 × $10 = $30. New cost = 8 × $5 = $40. Delta: +$10.
// Graduated would charge: 5×$10 + 3×$5 = $65 for new; delta = $65 $30 = +$35. KEY DIFF.
const expectedDelta =
(newQuantity / BILLING_UNITS) * 5 - (initQuantity / BILLING_UNITS) * 10; // $40 $30 = $10
const volumeItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const product = products.base({
id: "vol-upd-qty-inc-t2",
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 }],
}),
],
});
// Preview update
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: product.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
});
// Volume charges $10; graduated would charge $35 — assert the volume amount
expect(preview.total).toBe(expectedDelta);
// Execute update
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: product.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customer.features?.[TestFeature.Messages]?.balance).toBe(newQuantity);
expectLatestInvoiceCorrect({
customer,
productId: product.id,
amount: expectedDelta,
});
});
// ─── Test 3: Increase — within tier 2 already (600 → 1000) ──────────────────
test.concurrent(`${chalk.yellowBright("volume-tiers-update-quantity: increase same tier 600→1000")}`, async () => {
const customerId = "volume-update-qty-increase-t3";
const initQuantity = 600;
const newQuantity = 1000;
// Old cost: 6 × $5 = $30. New cost: 10 × $5 = $50. Delta: +$20.
// Graduated delta: same (+$20) — no difference within same tier.
const expectedDelta =
(newQuantity / BILLING_UNITS) * 5 - (initQuantity / BILLING_UNITS) * 5; // $50 $30 = $20
const volumeItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const product = products.base({
id: "vol-upd-qty-inc-t3",
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 }],
}),
],
});
// Preview update
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);
// Execute update
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: product.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customer.features?.[TestFeature.Messages]?.balance).toBe(newQuantity);
expectLatestInvoiceCorrect({
customer,
productId: product.id,
amount: expectedDelta,
});
});
// ─── Test 4: Decrease — same tier (500 → 300, both tier 1) ──────────────────
test.concurrent(`${chalk.yellowBright("volume-tiers-update-quantity: decrease same tier 500→300")}`, async () => {
const customerId = "volume-update-qty-decrease-t1";
const initQuantity = 500;
const newQuantity = 300;
// Old cost: 5 × $10 = $50. New cost: 3 × $10 = $30. Delta: $20.
// Graduated delta: same ($20) — no difference within same tier.
const expectedDelta =
(newQuantity / BILLING_UNITS) * 10 - (initQuantity / BILLING_UNITS) * 10; // $30 $50 = $20
const volumeItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const product = products.base({
id: "vol-upd-qty-dec-t1",
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 }],
}),
],
});
// Preview downgrade
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);
// Execute downgrade
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: product.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customer.features?.[TestFeature.Messages]?.balance).toBe(newQuantity);
expectLatestInvoiceCorrect({
customer,
productId: product.id,
amount: expectedDelta,
});
});
// ─── Test 5: Decrease — crosses tier boundary (800 → 300, tier 2 → tier 1) ──
test.concurrent(`${chalk.yellowBright("volume-tiers-update-quantity: decrease crossing tier 800→300")}`, async () => {
const customerId = "volume-update-qty-decrease-t2";
const initQuantity = 800;
const newQuantity = 300;
// Volume: old cost = 8 × $5 = $40. New cost = 3 × $10 = $30. Delta: $10.
// Graduated old cost: 5×$10 + 3×$5 = $65. New: 3×$10 = $30. Graduated delta = $35. KEY DIFF.
const expectedDelta =
(newQuantity / BILLING_UNITS) * 10 - (initQuantity / BILLING_UNITS) * 5; // $30 $40 = $10
const volumeItem = items.volumePrepaidMessages({
includedUsage: 0,
billingUnits: BILLING_UNITS,
tiers: VOLUME_TIERS,
});
const product = products.base({
id: "vol-upd-qty-dec-t2",
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 }],
}),
],
});
// Preview downgrade
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: product.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
});
// Volume credits $10; graduated would credit $35 — assert the volume amount
expect(preview.total).toBe(expectedDelta);
// Execute downgrade
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: product.id,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customer.features?.[TestFeature.Messages]?.balance).toBe(newQuantity);
expectLatestInvoiceCorrect({
customer,
productId: product.id,
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

@@ -1,6 +1,7 @@
import { expect, test } from "bun:test";
import {
type ApiPlan,
type ApiPlanV1,
type ApiProduct,
ApiVersion,
BillingInterval,
@@ -10,6 +11,7 @@ import {
Infinite,
ProductItemInterval,
ResetInterval,
TierBehavior,
TierInfinite,
UsageModel,
} from "@autumn/shared";
@@ -18,6 +20,7 @@ import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
const autumnV2_1 = new AutumnInt({ version: ApiVersion.V2_1 });
const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 });
const autumnV1_2 = new AutumnInt({ version: ApiVersion.V1_2 });
@@ -104,11 +107,11 @@ test.concurrent(`${chalk.yellowBright("create: usage pricing (pay-per-use)")}`,
test.concurrent(`${chalk.yellowBright("create: feature with tiered pricing")}`, async () => {
const productId = "tiered_pricing";
try {
await autumnV2.products.delete(productId);
await autumnV2_1.products.delete(productId);
} catch (_error) {}
const created = await autumnV2.products.create<
ApiPlan,
const created = await autumnV2_1.products.create<
ApiPlanV1,
CreatePlanParamsInput
>({
id: productId,
@@ -130,15 +133,25 @@ test.concurrent(`${chalk.yellowBright("create: feature with tiered pricing")}`,
],
});
const feature = created.features[0];
expect(feature.price!.tiers).toHaveLength(3);
expect(feature.price!.tiers).toEqual([
// V2.1: ApiPlanV1 — no included, so tiers are same across all versions
const item = created.items[0];
expect(item.price!.tiers).toHaveLength(3);
expect(item.price!.tiers).toEqual([
{ to: 100, amount: 0.1 },
{ to: 500, amount: 0.08 },
{ to: TierInfinite, amount: 0.05 },
]);
expect(feature.price!.usage_model).toBe(UsageModel.PayPerUse);
expect(item.price!.billing_method).toBe(BillingMethod.UsageBased);
// V2.0: ApiPlan — same tiers (no included to subtract)
const v2 = await autumnV2.products.get<ApiPlan>(productId);
expect(v2.features[0].price!.tiers).toEqual([
{ to: 100, amount: 0.1 },
{ to: 500, amount: 0.08 },
{ to: TierInfinite, amount: 0.05 },
]);
// V1.2: ApiProduct — same tiers
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.items[0].tiers).toHaveLength(3);
expect(v1_2.items[0].tiers).toEqual([
@@ -309,6 +322,123 @@ test.concurrent(`${chalk.yellowBright("cross-version: tiered pricing transformat
expect(v1_2.items[0].tiers![2]).toMatchObject({ to: TierInfinite });
});
test.concurrent(`${chalk.yellowBright("create: tiered pricing with included usage (to INCLUDES included)")}`, async () => {
const productId = "tiered_with_included";
try {
await autumnV2_1.products.delete(productId);
} catch (_error) {}
// User-facing API: tier `to` INCLUDES included usage.
// included=200, tiers=[{to:700}, {to:1200}, {to:inf}]
// Internally stored as tiers=[{to:500}, {to:1000}, {to:inf}] (without included)
const created = await autumnV2_1.products.create<
ApiPlanV1,
CreatePlanParamsInput
>({
id: productId,
name: "Tiered With Included",
items: [
{
feature_id: TestFeature.Messages,
included: 200,
price: {
tiers: [
{ to: 700, amount: 10 },
{ to: 1200, amount: 5 },
{ to: TierInfinite, amount: 2 },
],
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
billing_units: 100,
},
},
],
});
// V2.1: ApiPlanV1 — tiers INCLUDE included usage
const item = created.items[0];
expect(item.price!.tiers).toHaveLength(3);
expect(item.price!.tiers).toEqual([
{ to: 700, amount: 10 },
{ to: 1200, amount: 5 },
{ to: TierInfinite, amount: 2 },
]);
expect(item.included).toBe(200);
// V2.0: ApiPlan — tiers do NOT include included usage (subtracted in V2.1→V2.0 conversion)
const v2 = await autumnV2.products.get<ApiPlan>(productId);
expect(v2.features[0].price!.tiers).toEqual([
{ to: 500, amount: 10 },
{ to: 1000, amount: 5 },
{ to: TierInfinite, amount: 2 },
]);
expect(v2.features[0].granted_balance).toBe(200);
// V1.2: ApiProduct — internal tiers do NOT include included usage
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.items[0].tiers).toHaveLength(3);
expect(v1_2.items[0].tiers).toEqual([
{ to: 500, amount: 10 },
{ to: 1000, amount: 5 },
{ to: TierInfinite, amount: 2 },
]);
expect(v1_2.items[0].included_usage).toBe(200);
});
test.concurrent(`${chalk.yellowBright("create: volume tiered pricing with included usage (to INCLUDES included)")}`, async () => {
const productId = "volume_tiered_with_included";
try {
await autumnV2_1.products.delete(productId);
} catch (_error) {}
const created = await autumnV2_1.products.create<
ApiPlanV1,
CreatePlanParamsInput
>({
id: productId,
name: "Volume Tiered With Included",
items: [
{
feature_id: TestFeature.Messages,
included: 100,
price: {
tiers: [
{ to: 600, amount: 10 },
{ to: TierInfinite, amount: 5 },
],
tier_behavior: TierBehavior.VolumeBased,
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
billing_units: 100,
},
},
],
});
// V2.1: ApiPlanV1 — tiers INCLUDE included
const item = created.items[0];
expect(item.price!.tiers).toEqual([
{ to: 600, amount: 10 },
{ to: TierInfinite, amount: 5 },
]);
expect(item.included).toBe(100);
// V2.0: ApiPlan — tiers do NOT include included (subtracted in V2.1→V2.0 conversion)
const v2 = await autumnV2.products.get<ApiPlan>(productId);
expect(v2.features[0].price!.tiers).toEqual([
{ to: 500, amount: 10 },
{ to: TierInfinite, amount: 5 },
]);
expect(v2.features[0].granted_balance).toBe(100);
// V1.2: ApiProduct — internal tiers do NOT include included
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.items[0].tiers).toEqual([
{ to: 500, amount: 10 },
{ to: TierInfinite, amount: 5 },
]);
});
// ═══════════════════════════════════════════════════════════════════════════════
// VALIDATION / REJECTION TESTS
// ═══════════════════════════════════════════════════════════════════════════════

View File

@@ -0,0 +1,454 @@
import { test } from "bun:test";
import {
type ApiPlan,
type ApiPlanV1,
ApiVersion,
BillingInterval,
BillingMethod,
type CreatePlanParamsInput,
type CreatePlanParamsV2Input,
TierBehavior,
TierInfinite,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features";
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { AutumnRpcCli } from "@/external/autumn/autumnRpcCli.js";
const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 });
const autumnRpc = new AutumnRpcCli({ version: ApiVersion.V2_1 });
const getSuffix = () => Math.random().toString(36).slice(2, 9);
/** Helper: create plan via REST (v1.2 / v2.0) and expect rejection */
const expectRestError = async ({
productId,
items,
errMessage,
}: {
productId: string;
items: CreatePlanParamsInput["items"];
errMessage?: string;
}) => {
try {
await autumnV2.products.delete(productId);
} catch (_e) {}
await expectAutumnError({
errCode: "invalid_inputs",
errMessage,
func: async () => {
await autumnV2.products.create<ApiPlan, CreatePlanParamsInput>({
id: productId,
name: `Test ${productId}`,
items,
});
},
});
};
/** Helper: create plan via RPC (v2.1) and expect rejection */
const expectRpcError = async ({
productId,
items,
errMessage,
}: {
productId: string;
items: CreatePlanParamsV2Input["items"];
errMessage?: string;
}) => {
try {
await autumnRpc.plans.delete(productId, { allVersions: true });
} catch (_e) {}
await expectAutumnError({
errCode: "invalid_inputs",
errMessage,
func: async () => {
await autumnRpc.plans.create<ApiPlanV1, CreatePlanParamsV2Input>({
plan_id: productId,
name: `Test ${productId}`,
group: `grp_${productId}`,
auto_enable: false,
items,
});
},
});
};
// ═══════════════════════════════════════════════════════════════════════════════
// PRICE: amount OR tiers (not neither, not both)
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("tier-errors REST: REJECT price with neither amount nor tiers")}`, async () => {
const id = `err_neither_${getSuffix()}`;
await expectRestError({
productId: id,
items: [
{
feature_id: TestFeature.Messages,
price: {
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
},
},
],
errMessage: "either 'amount' or 'tiers' must be defined",
});
});
test.concurrent(`${chalk.yellowBright("tier-errors RPC: REJECT price with neither amount nor tiers")}`, async () => {
const id = `err_neither_rpc_${getSuffix()}`;
await expectRpcError({
productId: id,
items: [
{
feature_id: TestFeature.Messages,
price: {
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
},
},
],
errMessage: "either 'amount' or 'tiers' must be defined",
});
});
test.concurrent(`${chalk.yellowBright("tier-errors REST: REJECT price with both amount and tiers")}`, async () => {
const id = `err_both_${getSuffix()}`;
await expectRestError({
productId: id,
items: [
{
feature_id: TestFeature.Messages,
price: {
amount: 10,
tiers: [
{ to: 100, amount: 5 },
{ to: TierInfinite, amount: 2 },
],
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
},
},
],
errMessage: "'amount' and 'tiers' cannot both be defined",
});
});
test.concurrent(`${chalk.yellowBright("tier-errors RPC: REJECT price with both amount and tiers")}`, async () => {
const id = `err_both_rpc_${getSuffix()}`;
await expectRpcError({
productId: id,
items: [
{
feature_id: TestFeature.Messages,
price: {
amount: 10,
tiers: [
{ to: 100, amount: 5 },
{ to: TierInfinite, amount: 2 },
],
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
},
},
],
errMessage: "'amount' and 'tiers' cannot both be defined",
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// flat_amount only for volume-based pricing
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("tier-errors REST: REJECT flat_amount on graduated tiers")}`, async () => {
const id = `err_flat_grad_${getSuffix()}`;
await expectRestError({
productId: id,
items: [
{
feature_id: TestFeature.Messages,
price: {
tiers: [
{ to: 100, amount: 5, flat_amount: 10 },
{ to: TierInfinite, amount: 2 },
],
tier_behavior: TierBehavior.Graduated,
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
},
},
],
errMessage:
"flat_amount on tiers is only supported for volume-based pricing",
});
});
test.concurrent(`${chalk.yellowBright("tier-errors RPC: REJECT flat_amount on graduated tiers")}`, async () => {
const id = `err_flat_grad_rpc_${getSuffix()}`;
await expectRpcError({
productId: id,
items: [
{
feature_id: TestFeature.Messages,
price: {
tiers: [
{ to: 100, amount: 5, flat_amount: 10 },
{ to: TierInfinite, amount: 2 },
],
tier_behavior: TierBehavior.Graduated,
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
},
},
],
errMessage:
"flat_amount on tiers is only supported for volume-based pricing",
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// flat_amount not on single-tier
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("tier-errors REST: REJECT flat_amount on single-tier")}`, async () => {
const id = `err_flat_single_${getSuffix()}`;
await expectRestError({
productId: id,
items: [
{
feature_id: TestFeature.Messages,
price: {
tiers: [{ to: TierInfinite, amount: 5, flat_amount: 10 }],
tier_behavior: TierBehavior.VolumeBased,
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
},
},
],
errMessage: "flat_amount is not supported on single-tier pricing",
});
});
test.concurrent(`${chalk.yellowBright("tier-errors RPC: REJECT flat_amount on single-tier")}`, async () => {
const id = `err_flat_single_rpc_${getSuffix()}`;
await expectRpcError({
productId: id,
items: [
{
feature_id: TestFeature.Messages,
price: {
tiers: [{ to: TierInfinite, amount: 5, flat_amount: 10 }],
tier_behavior: TierBehavior.VolumeBased,
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
},
},
],
errMessage: "flat_amount is not supported on single-tier pricing",
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// flat_amount must be >= 0
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("tier-errors REST: REJECT negative flat_amount")}`, async () => {
const id = `err_flat_neg_${getSuffix()}`;
await expectRestError({
productId: id,
items: [
{
feature_id: TestFeature.Messages,
price: {
tiers: [
{ to: 100, amount: 5, flat_amount: -10 },
{ to: TierInfinite, amount: 2 },
],
tier_behavior: TierBehavior.VolumeBased,
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
},
},
],
errMessage: "flat_amount must be 0 or greater",
});
});
test.concurrent(`${chalk.yellowBright("tier-errors RPC: REJECT negative flat_amount")}`, async () => {
const id = `err_flat_neg_rpc_${getSuffix()}`;
await expectRpcError({
productId: id,
items: [
{
feature_id: TestFeature.Messages,
price: {
tiers: [
{ to: 100, amount: 5, flat_amount: -10 },
{ to: TierInfinite, amount: 2 },
],
tier_behavior: TierBehavior.VolumeBased,
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
},
},
],
errMessage: "flat_amount must be 0 or greater",
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// volume-based only for prepaid
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("tier-errors REST: REJECT volume-based with usage_based billing")}`, async () => {
const id = `err_vol_usage_${getSuffix()}`;
await expectRestError({
productId: id,
items: [
{
feature_id: TestFeature.Messages,
price: {
tiers: [
{ to: 100, amount: 5 },
{ to: TierInfinite, amount: 2 },
],
tier_behavior: TierBehavior.VolumeBased,
interval: BillingInterval.Month,
billing_method: BillingMethod.UsageBased,
},
},
],
errMessage: "volume-based pricing is only supported for prepaid",
});
});
test.concurrent(`${chalk.yellowBright("tier-errors RPC: REJECT volume-based with usage_based billing")}`, async () => {
const id = `err_vol_usage_rpc_${getSuffix()}`;
await expectRpcError({
productId: id,
items: [
{
feature_id: TestFeature.Messages,
price: {
tiers: [
{ to: 100, amount: 5 },
{ to: TierInfinite, amount: 2 },
],
tier_behavior: TierBehavior.VolumeBased,
interval: BillingInterval.Month,
billing_method: BillingMethod.UsageBased,
},
},
],
errMessage: "volume-based pricing is only supported for prepaid",
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// tiers[0].to must be greater than included
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("tier-errors REST: REJECT tiers[0].to <= included")}`, async () => {
const id = `err_tier_incl_${getSuffix()}`;
await expectRestError({
productId: id,
items: [
{
feature_id: TestFeature.Messages,
included: 200,
price: {
tiers: [
{ to: 100, amount: 5 },
{ to: TierInfinite, amount: 2 },
],
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
},
},
],
errMessage: "tiers[0].to must be greater than included",
});
});
test.concurrent(`${chalk.yellowBright("tier-errors RPC: REJECT tiers[0].to <= included")}`, async () => {
const id = `err_tier_incl_rpc_${getSuffix()}`;
await expectRpcError({
productId: id,
items: [
{
feature_id: TestFeature.Messages,
included: 200,
price: {
tiers: [
{ to: 100, amount: 5 },
{ to: TierInfinite, amount: 2 },
],
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
},
},
],
errMessage: "tiers[0].to must be greater than included",
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// ACCEPT: valid volume-based with flat_amount (positive case)
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("tier-errors REST: ACCEPT valid volume-based flat_amount")}`, async () => {
const id = `ok_vol_flat_${getSuffix()}`;
try {
await autumnV2.products.delete(id);
} catch (_e) {}
await autumnV2.products.create<ApiPlan, CreatePlanParamsInput>({
id,
name: `Test ${id}`,
items: [
{
feature_id: TestFeature.Messages,
price: {
tiers: [
{ to: 100, amount: 5, flat_amount: 10 },
{ to: TierInfinite, amount: 2, flat_amount: 20 },
],
tier_behavior: TierBehavior.VolumeBased,
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
},
},
],
});
});
test.concurrent(`${chalk.yellowBright("tier-errors RPC: ACCEPT valid volume-based flat_amount")}`, async () => {
const id = `ok_vol_flat_rpc_${getSuffix()}`;
try {
await autumnRpc.plans.delete(id, { allVersions: true });
} catch (_e) {}
await autumnRpc.plans.create<ApiPlanV1, CreatePlanParamsV2Input>({
plan_id: id,
name: `Test ${id}`,
group: `grp_${id}`,
auto_enable: false,
items: [
{
feature_id: TestFeature.Messages,
price: {
tiers: [
{ to: 100, amount: 5, flat_amount: 10 },
{ to: TierInfinite, amount: 2, flat_amount: 20 },
],
tier_behavior: TierBehavior.VolumeBased,
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
},
},
],
});
});

View File

@@ -0,0 +1,236 @@
import { expect, test } from "bun:test";
import {
type ApiPlan,
type ApiPlanV1,
type ApiProduct,
ApiVersion,
BillingInterval,
BillingMethod,
type CreatePlanParamsInput,
TierBehavior,
TierInfinite,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
const autumnV2_1 = new AutumnInt({ version: ApiVersion.V2_1 });
const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 });
const autumnV1_2 = new AutumnInt({ version: ApiVersion.V1_2 });
const inf = TierInfinite as "inf";
// ═══════════════════════════════════════════════════════════════════════════════
// GET: TIERED PRICING WITHOUT INCLUDED
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("get: tiered pricing without included — same tiers across all versions")}`, async () => {
const productId = "get_tiered_no_included";
try {
await autumnV2_1.products.delete(productId);
} catch (_error) {}
await autumnV2_1.products.create<ApiPlanV1, CreatePlanParamsInput>({
id: productId,
name: "Get Tiered No Included",
items: [
{
feature_id: TestFeature.Messages,
price: {
tiers: [
{ to: 100, amount: 0.1 },
{ to: 500, amount: 0.08 },
{ to: TierInfinite, amount: 0.05 },
],
interval: BillingInterval.Month,
billing_method: BillingMethod.UsageBased,
billing_units: 1,
},
},
],
});
const expectedTiers = [
{ to: 100, amount: 0.1 },
{ to: 500, amount: 0.08 },
{ to: inf, amount: 0.05 },
];
// V2.1: tiers unchanged (no included to add)
const v2_1 = await autumnV2_1.products.get<ApiPlanV1>(productId);
expect(v2_1.items[0].price!.tiers).toEqual(expectedTiers);
// V2.0: tiers unchanged (no included to subtract)
const v2 = await autumnV2.products.get<ApiPlan>(productId);
expect(v2.features[0].price!.tiers).toEqual(expectedTiers);
// V1.2: tiers unchanged
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.items[0].tiers).toEqual(expectedTiers);
});
// ═══════════════════════════════════════════════════════════════════════════════
// GET: TIERED PRICING WITH INCLUDED — CROSS-VERSION TIER VALUES
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("get: graduated tiered pricing with included — V2.1 includes, V2.0/V1.2 do not")}`, async () => {
const productId = "get_tiered_with_included";
try {
await autumnV2_1.products.delete(productId);
} catch (_error) {}
// Create via V2.1: tier `to` INCLUDES included.
// included=200, tiers=[{to:700}, {to:1200}, {to:inf}]
// Internally stored as [{to:500}, {to:1000}, {to:inf}]
await autumnV2_1.products.create<ApiPlanV1, CreatePlanParamsInput>({
id: productId,
name: "Get Tiered With Included",
items: [
{
feature_id: TestFeature.Messages,
included: 200,
price: {
tiers: [
{ to: 700, amount: 10 },
{ to: 1200, amount: 5 },
{ to: TierInfinite, amount: 2 },
],
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
billing_units: 100,
},
},
],
});
// V2.1 GET: tiers INCLUDE included (200 added back)
const v2_1 = await autumnV2_1.products.get<ApiPlanV1>(productId);
expect(v2_1.items[0].included).toBe(200);
expect(v2_1.items[0].price!.tiers).toEqual([
{ to: 700, amount: 10 },
{ to: 1200, amount: 5 },
{ to: inf, amount: 2 },
]);
// V2.0 GET: tiers do NOT include included
const v2 = await autumnV2.products.get<ApiPlan>(productId);
expect(v2.features[0].granted_balance).toBe(200);
expect(v2.features[0].price!.tiers).toEqual([
{ to: 500, amount: 10 },
{ to: 1000, amount: 5 },
{ to: inf, amount: 2 },
]);
// V1.2 GET: tiers do NOT include included
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.items[0].included_usage).toBe(200);
expect(v1_2.items[0].tiers).toEqual([
{ to: 500, amount: 10 },
{ to: 1000, amount: 5 },
{ to: inf, amount: 2 },
]);
});
test.concurrent(`${chalk.yellowBright("get: volume tiered pricing with included — V2.1 includes, V2.0/V1.2 do not")}`, async () => {
const productId = "get_volume_with_included";
try {
await autumnV2_1.products.delete(productId);
} catch (_error) {}
// Create via V2.1: included=100, tiers=[{to:600}, {to:inf}]
// Internally stored as [{to:500}, {to:inf}]
await autumnV2_1.products.create<ApiPlanV1, CreatePlanParamsInput>({
id: productId,
name: "Get Volume With Included",
items: [
{
feature_id: TestFeature.Messages,
included: 100,
price: {
tiers: [
{ to: 600, amount: 10 },
{ to: TierInfinite, amount: 5 },
],
tier_behavior: TierBehavior.VolumeBased,
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
billing_units: 100,
},
},
],
});
// V2.1 GET: tiers INCLUDE included
const v2_1 = await autumnV2_1.products.get<ApiPlanV1>(productId);
expect(v2_1.items[0].included).toBe(100);
expect(v2_1.items[0].price!.tiers).toEqual([
{ to: 600, amount: 10 },
{ to: inf, amount: 5 },
]);
// V2.0 GET: tiers do NOT include included
const v2 = await autumnV2.products.get<ApiPlan>(productId);
expect(v2.features[0].granted_balance).toBe(100);
expect(v2.features[0].price!.tiers).toEqual([
{ to: 500, amount: 10 },
{ to: inf, amount: 5 },
]);
// V1.2 GET: tiers do NOT include included
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.items[0].included_usage).toBe(100);
expect(v1_2.items[0].tiers).toEqual([
{ to: 500, amount: 10 },
{ to: inf, amount: 5 },
]);
});
// ═══════════════════════════════════════════════════════════════════════════════
// GET: CREATE VIA V2.0, READ ACROSS VERSIONS
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("get: create tiered via V2.0 (no included offset) — V2.1 adds included=0")}`, async () => {
const productId = "get_v2_created_tiered";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
// Create via V2.0: tiers do NOT include included (V2.0 has no offset behavior)
await autumnV2.products.create<ApiPlan, CreatePlanParamsInput>({
id: productId,
name: "V2 Created Tiered",
items: [
{
feature_id: TestFeature.Messages,
price: {
tiers: [
{ to: 100, amount: 10 },
{ to: 1000, amount: 5 },
{ to: TierInfinite, amount: 2 },
],
interval: BillingInterval.Month,
billing_method: BillingMethod.UsageBased,
billing_units: 1,
},
},
],
});
const expectedTiers = [
{ to: 100, amount: 10 },
{ to: 1000, amount: 5 },
{ to: inf, amount: 2 },
];
// V2.1 GET: included defaults to 0, so tiers stay the same
const v2_1 = await autumnV2_1.products.get<ApiPlanV1>(productId);
expect(v2_1.items[0].price!.tiers).toEqual(expectedTiers);
// V2.0 GET: same tiers
const v2 = await autumnV2.products.get<ApiPlan>(productId);
expect(v2.features[0].price!.tiers).toEqual(expectedTiers);
// V1.2 GET: same tiers
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.items[0].tiers).toEqual(expectedTiers);
});

View File

@@ -0,0 +1,217 @@
import { describe, expect, test } from "bun:test";
import { Infinite, type UsageTier } from "@autumn/shared";
import { graduatedTiersToLineAmount } from "@utils/billingUtils/invoicingUtils/lineItemUtils/graduatedTiersToLineAmount";
// Standard multi-tier schedule used across several tests:
// 0100 @ $0.10, 101500 @ $0.05, 501+ @ $0.02
const THREE_TIERS = [
{ to: 100, amount: 0.1 },
{ to: 500, amount: 0.05 },
{ to: Infinite, amount: 0.02 },
] as UsageTier[];
describe("graduatedTiersToLineAmount", () => {
describe("basic graduated math", () => {
test("single flat-rate tier: 100 units @ $0.10 = $10", () => {
expect(
graduatedTiersToLineAmount({
tiers: [{ to: Infinite, amount: 0.1 }],
usage: 100,
}),
).toBe(10);
});
test("0 usage = $0", () => {
expect(
graduatedTiersToLineAmount({
tiers: [{ to: Infinite, amount: 0.1 }],
usage: 0,
}),
).toBe(0);
});
test("usage within first tier only: 50 units = $5", () => {
expect(
graduatedTiersToLineAmount({ tiers: THREE_TIERS, usage: 50 }),
).toBe(5);
});
test("usage exactly at tier 1 boundary: 100 units = $10", () => {
expect(
graduatedTiersToLineAmount({ tiers: THREE_TIERS, usage: 100 }),
).toBe(10);
});
test("usage spanning tier 1 + partial tier 2: 250 units = $17.50", () => {
// 100×$0.10 + 150×$0.05 = $10 + $7.50 = $17.50
expect(
graduatedTiersToLineAmount({ tiers: THREE_TIERS, usage: 250 }),
).toBe(17.5);
});
test("usage spanning tier 1 + full tier 2: 500 units = $30", () => {
// 100×$0.10 + 400×$0.05 = $10 + $20 = $30
expect(
graduatedTiersToLineAmount({ tiers: THREE_TIERS, usage: 500 }),
).toBe(30);
});
test("usage across all three tiers: 1000 units = $40", () => {
// 100×$0.10 + 400×$0.05 + 500×$0.02 = $10 + $20 + $10 = $40
expect(
graduatedTiersToLineAmount({ tiers: THREE_TIERS, usage: 1000 }),
).toBe(40);
});
test("tier.to = -1 treated as Infinite", () => {
expect(
graduatedTiersToLineAmount({
tiers: [{ to: -1, amount: 0.1 }],
usage: 100,
}),
).toBe(10);
});
test("throws when tiers is null/undefined", () => {
expect(() =>
graduatedTiersToLineAmount({
tiers: null as unknown as UsageTier[],
usage: 100,
}),
).toThrow();
});
});
describe("billing units", () => {
const tiers = [{ to: Infinite, amount: 1 }] as UsageTier[]; // $1 per billing unit
test("rounds up to nearest billing unit: 15 usage, billingUnits=10 → 20 units → $2", () => {
expect(
graduatedTiersToLineAmount({ tiers, usage: 15, billingUnits: 10 }),
).toBe(2);
});
test("exact billing unit multiple: 20 usage, billingUnits=10 → $2", () => {
expect(
graduatedTiersToLineAmount({ tiers, usage: 20, billingUnits: 10 }),
).toBe(2);
});
test("sub-unit usage rounds up to 1 billing unit: 1 usage, billingUnits=10 → $1", () => {
expect(
graduatedTiersToLineAmount({ tiers, usage: 1, billingUnits: 10 }),
).toBe(1);
});
});
describe("decimal precision", () => {
test("floating point safe: 3 units @ $0.10/unit = $0.30 (not 0.30000000004)", () => {
expect(
graduatedTiersToLineAmount({
tiers: [{ to: Infinite, amount: 0.1 }],
usage: 3,
}),
).toBe(0.3);
});
test("very small rate: 1,000,000 units @ $0.000001 = $1", () => {
expect(
graduatedTiersToLineAmount({
tiers: [{ to: Infinite, amount: 0.000001 }],
usage: 1000000,
}),
).toBe(1);
});
test("fractional rate across tiers: 75 units (050 @ $0.0075, 50+ @ $0.0025) = $0.4375", () => {
// 50×$0.0075 + 25×$0.0025 = $0.375 + $0.0625 = $0.4375
expect(
graduatedTiersToLineAmount({
tiers: [
{ to: 50, amount: 0.0075 },
{ to: Infinite, amount: 0.0025 },
],
usage: 75,
}),
).toBe(0.4375);
});
});
describe("negative usage (allowNegative)", () => {
test("negative usage with allowNegative=false (default): absolute value priced, result is positive", () => {
// With allowNegative=false, negative usage is treated as-is.
// The isNegative flag is only set when allowNegative=true AND usage<0.
// With allowNegative=false, absoluteUsage = usage (stays negative),
// but roundUsageToNearestBillingUnit rounds to 0 for negative → $0.
const result = graduatedTiersToLineAmount({
tiers: [{ to: Infinite, amount: 0.1 }],
usage: -100,
});
expect(result).toBe(0);
});
test("negative usage with allowNegative=true: produces negative dollar amount", () => {
// -100 units: abs=100, priced at $10, then negated → -$10
const result = graduatedTiersToLineAmount({
tiers: [{ to: Infinite, amount: 0.1 }],
usage: -100,
allowNegative: true,
});
expect(result).toBe(-10);
});
test("negative usage with allowNegative=true, multi-tier: correct tier bands used on absolute value", () => {
// -250 units: abs=250, graduated: 100×$0.10 + 150×$0.05 = $17.50, negated → -$17.50
const result = graduatedTiersToLineAmount({
tiers: THREE_TIERS,
usage: -250,
allowNegative: true,
});
expect(result).toBe(-17.5);
});
test("negative usage with allowNegative=true, spanning all tiers: -1000 units = -$40", () => {
const result = graduatedTiersToLineAmount({
tiers: THREE_TIERS,
usage: -1000,
allowNegative: true,
});
expect(result).toBe(-40);
});
test("negative usage with allowNegative=true and billingUnits: rounds up absolute value first", () => {
// -15 units, billingUnits=10 → abs=15 → rounds to 20 → 20×($1/10) = $2 → -$2
const result = graduatedTiersToLineAmount({
tiers: [{ to: Infinite, amount: 1 }],
usage: -15,
billingUnits: 10,
allowNegative: true,
});
expect(result).toBe(-2);
});
test("positive usage is unaffected by allowNegative=true", () => {
// allowNegative has no effect on positive usage
const withFlag = graduatedTiersToLineAmount({
tiers: THREE_TIERS,
usage: 250,
allowNegative: true,
});
const withoutFlag = graduatedTiersToLineAmount({
tiers: THREE_TIERS,
usage: 250,
});
expect(withFlag).toBe(withoutFlag);
expect(withFlag).toBe(17.5);
});
test("zero usage with allowNegative=true = $0", () => {
const result = graduatedTiersToLineAmount({
tiers: [{ to: Infinite, amount: 0.1 }],
usage: 0,
allowNegative: true,
});
expect(result).toBe(0);
});
});
});

View File

@@ -1,12 +1,19 @@
import { describe, expect, test } from "bun:test";
import { Infinite, type Price, tiersToLineAmount } from "@autumn/shared";
import {
Infinite,
type Price,
TierBehavior,
tiersToLineAmount,
} from "@autumn/shared";
const createMockPrice = (
tiers: { to: number | typeof Infinite; amount: number }[],
tierBehaviour?: TierBehavior,
): Price =>
({
id: "test-price",
internal_product_id: "test-product",
tier_behavior: tierBehaviour,
config: {
type: "usage",
usage_tiers: tiers,
@@ -14,153 +21,302 @@ const createMockPrice = (
}) as unknown as Price;
describe("tiersToLineAmount", () => {
describe("single tier (flat rate)", () => {
test("100 overage @ $0.10/unit = $10", () => {
const price = createMockPrice([{ to: Infinite, amount: 0.1 }]);
const result = tiersToLineAmount({ price, overage: 100 });
describe("graduated pricing", () => {
describe("single tier (flat rate)", () => {
test("100 overage @ $0.10/unit = $10", () => {
const price = createMockPrice([{ to: Infinite, amount: 0.1 }]);
const result = tiersToLineAmount({ price, overage: 100 });
expect(result).toBe(10);
});
test("0 overage = $0", () => {
const price = createMockPrice([{ to: Infinite, amount: 0.1 }]);
const result = tiersToLineAmount({ price, overage: 0 });
expect(result).toBe(0);
});
});
describe("multiple tiers", () => {
// Tiers: 0-100 @ $0.10, 100-500 @ $0.05, 500+ @ $0.02
const tieredPrice = createMockPrice([
{ to: 100, amount: 0.1 },
{ to: 500, amount: 0.05 },
{ to: Infinite, amount: 0.02 },
]);
test("50 overage (within tier 1) = $5", () => {
const result = tiersToLineAmount({ price: tieredPrice, overage: 50 });
expect(result).toBe(5);
});
test("100 overage (exactly tier 1) = $10", () => {
const result = tiersToLineAmount({ price: tieredPrice, overage: 100 });
expect(result).toBe(10);
});
test("250 overage (tier 1 + partial tier 2) = $17.50", () => {
// 100 × $0.10 = $10
// 150 × $0.05 = $7.50
// Total = $17.50
const result = tiersToLineAmount({ price: tieredPrice, overage: 250 });
expect(result).toBe(17.5);
});
test("500 overage (tier 1 + full tier 2) = $30", () => {
// 100 × $0.10 = $10
// 400 × $0.05 = $20
// Total = $30
const result = tiersToLineAmount({ price: tieredPrice, overage: 500 });
expect(result).toBe(30);
});
test("1000 overage (all tiers) = $40", () => {
// 100 × $0.10 = $10
// 400 × $0.05 = $20
// 500 × $0.02 = $10
// Total = $40
const result = tiersToLineAmount({ price: tieredPrice, overage: 1000 });
expect(result).toBe(40);
});
});
describe("billing units", () => {
const price = createMockPrice([{ to: Infinite, amount: 1 }]); // $1 per billing unit
test("rounds up to nearest billing unit (billingUnits=10)", () => {
// 15 overage, billingUnits=10 → rounds to 20
// 20 × ($1/10) = $2
const result = tiersToLineAmount({
price,
overage: 15,
billingUnits: 10,
expect(result).toBe(10);
});
expect(result).toBe(2);
});
test("exact billing unit multiple", () => {
// 20 overage, billingUnits=10 → stays 20
// 20 × ($1/10) = $2
const result = tiersToLineAmount({
price,
overage: 20,
billingUnits: 10,
test("0 overage = $0", () => {
const price = createMockPrice([{ to: Infinite, amount: 0.1 }]);
const result = tiersToLineAmount({ price, overage: 0 });
expect(result).toBe(0);
});
expect(result).toBe(2);
});
test("small overage rounds up", () => {
// 1 overage, billingUnits=10 → rounds to 10
// 10 × ($1/10) = $1
const result = tiersToLineAmount({ price, overage: 1, billingUnits: 10 });
expect(result).toBe(1);
});
});
describe("decimal precision", () => {
test("fractional rate: 7 overage @ $0.0033/unit", () => {
const price = createMockPrice([{ to: Infinite, amount: 0.0033 }]);
const result = tiersToLineAmount({ price, overage: 7 });
expect(result).toBe(0.0231);
});
test("fractional rate with many decimals: 13 overage @ $0.00123/unit", () => {
const price = createMockPrice([{ to: Infinite, amount: 0.00123 }]);
const result = tiersToLineAmount({ price, overage: 13 });
expect(result).toBe(0.01599);
});
test("large overage with small rate: 1000000 @ $0.000001/unit", () => {
const price = createMockPrice([{ to: Infinite, amount: 0.000001 }]);
const result = tiersToLineAmount({ price, overage: 1000000 });
expect(result).toBe(1);
});
test("tiered with fractional rates", () => {
// 0-50 @ $0.0075, 50+ @ $0.0025
const price = createMockPrice([
{ to: 50, amount: 0.0075 },
{ to: Infinite, amount: 0.0025 },
describe("multiple tiers", () => {
// Tiers: 0-100 @ $0.10, 100-500 @ $0.05, 500+ @ $0.02
const tieredPrice = createMockPrice([
{ to: 100, amount: 0.1 },
{ to: 500, amount: 0.05 },
{ to: Infinite, amount: 0.02 },
]);
// 75 overage: 50 × $0.0075 = $0.375, 25 × $0.0025 = $0.0625
// Total = $0.4375
const result = tiersToLineAmount({ price, overage: 75 });
expect(result).toBe(0.4375);
test("50 overage (within tier 1) = $5", () => {
const result = tiersToLineAmount({ price: tieredPrice, overage: 50 });
expect(result).toBe(5);
});
test("100 overage (exactly tier 1) = $10", () => {
const result = tiersToLineAmount({ price: tieredPrice, overage: 100 });
expect(result).toBe(10);
});
test("250 overage (tier 1 + partial tier 2) = $17.50", () => {
// 100 × $0.10 = $10
// 150 × $0.05 = $7.50
// Total = $17.50
const result = tiersToLineAmount({ price: tieredPrice, overage: 250 });
expect(result).toBe(17.5);
});
test("500 overage (tier 1 + full tier 2) = $30", () => {
// 100 × $0.10 = $10
// 400 × $0.05 = $20
// Total = $30
const result = tiersToLineAmount({ price: tieredPrice, overage: 500 });
expect(result).toBe(30);
});
test("1000 overage (all tiers) = $40", () => {
// 100 × $0.10 = $10
// 400 × $0.05 = $20
// 500 × $0.02 = $10
// Total = $40
const result = tiersToLineAmount({
price: tieredPrice,
overage: 1000,
});
expect(result).toBe(40);
});
});
test("very small overage with fractional rate: 3 @ $0.33/unit", () => {
const price = createMockPrice([{ to: Infinite, amount: 0.33 }]);
const result = tiersToLineAmount({ price, overage: 3 });
expect(result).toBe(0.99);
describe("billing units", () => {
const price = createMockPrice([{ to: Infinite, amount: 1 }]); // $1 per billing unit
test("rounds up to nearest billing unit (billingUnits=10)", () => {
// 15 overage, billingUnits=10 → rounds to 20
// 20 × ($1/10) = $2
const result = tiersToLineAmount({
price,
overage: 15,
billingUnits: 10,
});
expect(result).toBe(2);
});
test("exact billing unit multiple", () => {
// 20 overage, billingUnits=10 → stays 20
// 20 × ($1/10) = $2
const result = tiersToLineAmount({
price,
overage: 20,
billingUnits: 10,
});
expect(result).toBe(2);
});
test("small overage rounds up", () => {
// 1 overage, billingUnits=10 → rounds to 10
// 10 × ($1/10) = $1
const result = tiersToLineAmount({
price,
overage: 1,
billingUnits: 10,
});
expect(result).toBe(1);
});
});
test("floating point edge case: 0.1 + 0.2 precision", () => {
// 3 overage @ $0.1/unit = $0.3 (tests floating point handling)
const price = createMockPrice([{ to: Infinite, amount: 0.1 }]);
const result = tiersToLineAmount({ price, overage: 3 });
expect(result).toBe(0.3);
describe("decimal precision", () => {
test("fractional rate: 7 overage @ $0.0033/unit", () => {
const price = createMockPrice([{ to: Infinite, amount: 0.0033 }]);
const result = tiersToLineAmount({ price, overage: 7 });
expect(result).toBe(0.0231);
});
test("fractional rate with many decimals: 13 overage @ $0.00123/unit", () => {
const price = createMockPrice([{ to: Infinite, amount: 0.00123 }]);
const result = tiersToLineAmount({ price, overage: 13 });
expect(result).toBe(0.01599);
});
test("large overage with small rate: 1000000 @ $0.000001/unit", () => {
const price = createMockPrice([{ to: Infinite, amount: 0.000001 }]);
const result = tiersToLineAmount({ price, overage: 1000000 });
expect(result).toBe(1);
});
test("tiered with fractional rates", () => {
// 0-50 @ $0.0075, 50+ @ $0.0025
const price = createMockPrice([
{ to: 50, amount: 0.0075 },
{ to: Infinite, amount: 0.0025 },
]);
// 75 overage: 50 × $0.0075 = $0.375, 25 × $0.0025 = $0.0625
// Total = $0.4375
const result = tiersToLineAmount({ price, overage: 75 });
expect(result).toBe(0.4375);
});
test("very small overage with fractional rate: 3 @ $0.33/unit", () => {
const price = createMockPrice([{ to: Infinite, amount: 0.33 }]);
const result = tiersToLineAmount({ price, overage: 3 });
expect(result).toBe(0.99);
});
test("floating point edge case: 0.1 + 0.2 precision", () => {
// 3 overage @ $0.1/unit = $0.3 (tests floating point handling)
const price = createMockPrice([{ to: Infinite, amount: 0.1 }]);
const result = tiersToLineAmount({ price, overage: 3 });
expect(result).toBe(0.3);
});
});
describe("edge cases", () => {
test("tier.to = -1 treated same as Infinite", () => {
const price = createMockPrice([{ to: -1, amount: 0.1 }]);
const result = tiersToLineAmount({ price, overage: 100 });
expect(result).toBe(10);
});
test("throws if no tiers", () => {
const price = { config: {} } as unknown as Price;
expect(() => tiersToLineAmount({ price, overage: 100 })).toThrow();
});
});
});
describe("edge cases", () => {
test("tier.to = -1 treated same as Infinite", () => {
const price = createMockPrice([{ to: -1, amount: 0.1 }]);
const result = tiersToLineAmount({ price, overage: 100 });
expect(result).toBe(10);
describe("volume pricing", () => {
// Tiers: 0-100 @ $0.10/unit, 101-500 @ $0.05/unit, 501+ @ $0.02/unit
// Volume: entire quantity charged at the single tier it falls into
const volumePrice = createMockPrice(
[
{ to: 100, amount: 0.1 },
{ to: 500, amount: 0.05 },
{ to: Infinite, amount: 0.02 },
],
TierBehavior.VolumeBased,
);
describe("tier selection", () => {
test("50 units falls in tier 1 → entire 50 @ $0.10 = $5", () => {
const result = tiersToLineAmount({ price: volumePrice, overage: 50 });
expect(result).toBe(5);
});
test("100 units (exactly tier 1 boundary) → entire 100 @ $0.10 = $10", () => {
const result = tiersToLineAmount({ price: volumePrice, overage: 100 });
expect(result).toBe(10);
});
test("101 units (just into tier 2) → entire 101 @ $0.05 = $5.05", () => {
const result = tiersToLineAmount({ price: volumePrice, overage: 101 });
expect(result).toBe(5.05);
});
test("250 units (mid tier 2) → entire 250 @ $0.05 = $12.50", () => {
// Graduated would be: 100×$0.10 + 150×$0.05 = $17.50
// Volume is: 250×$0.05 = $12.50
const result = tiersToLineAmount({ price: volumePrice, overage: 250 });
expect(result).toBe(12.5);
});
test("500 units (exactly tier 2 boundary) → entire 500 @ $0.05 = $25", () => {
const result = tiersToLineAmount({ price: volumePrice, overage: 500 });
expect(result).toBe(25);
});
test("1000 units (in tier 3) → entire 1000 @ $0.02 = $20", () => {
// Graduated would be: 100×$0.10 + 400×$0.05 + 500×$0.02 = $40
// Volume is: 1000×$0.02 = $20
const result = tiersToLineAmount({
price: volumePrice,
overage: 1000,
});
expect(result).toBe(20);
});
});
test("throws if no tiers", () => {
const price = { config: {} } as unknown as Price;
expect(() => tiersToLineAmount({ price, overage: 100 })).toThrow();
describe("single tier (flat rate)", () => {
test("100 units @ $0.10/unit = $10", () => {
const price = createMockPrice(
[{ to: Infinite, amount: 0.1 }],
TierBehavior.VolumeBased,
);
const result = tiersToLineAmount({ price, overage: 100 });
expect(result).toBe(10);
});
test("0 units = $0", () => {
const price = createMockPrice(
[{ to: Infinite, amount: 0.1 }],
TierBehavior.VolumeBased,
);
const result = tiersToLineAmount({ price, overage: 0 });
expect(result).toBe(0);
});
test("tier.to = -1 treated same as Infinite", () => {
const price = createMockPrice(
[{ to: -1, amount: 0.1 }],
TierBehavior.VolumeBased,
);
const result = tiersToLineAmount({ price, overage: 100 });
expect(result).toBe(10);
});
});
describe("billing units", () => {
// $1.00 per 1000 units (e.g. API tokens)
const tokenPrice = createMockPrice(
[
{ to: 100000, amount: 1.0 },
{ to: Infinite, amount: 0.5 },
],
TierBehavior.VolumeBased,
);
test("50k tokens (tier 1) @ $1/1k = $50", () => {
const result = tiersToLineAmount({
price: tokenPrice,
overage: 50000,
billingUnits: 1000,
});
expect(result).toBe(50);
});
test("150k tokens (tier 2) @ $0.50/1k = $75", () => {
const result = tiersToLineAmount({
price: tokenPrice,
overage: 150000,
billingUnits: 1000,
});
expect(result).toBe(75);
});
test("rounds up to nearest billing unit before pricing", () => {
// 1500 tokens, billingUnits=1000 → rounds to 2000 → tier 1 → 2000×($1/1000) = $2
const result = tiersToLineAmount({
price: tokenPrice,
overage: 1500,
billingUnits: 1000,
});
expect(result).toBe(2);
});
});
describe("negative overage (credits)", () => {
test("negative overage produces negative amount", () => {
// -250 units falls in tier 2 → -(250 × $0.05) = -$12.50
const result = tiersToLineAmount({
price: volumePrice,
overage: -250,
});
expect(result).toBe(-12.5);
});
test("negative overage in tier 1", () => {
// -50 units falls in tier 1 → -(50 × $0.10) = -$5
const result = tiersToLineAmount({
price: volumePrice,
overage: -50,
});
expect(result).toBe(-5);
});
});
});
});

View File

@@ -0,0 +1,364 @@
import { describe, expect, test } from "bun:test";
import { Infinite, type UsageTier } from "@autumn/shared";
import { volumeTiersToLineAmount } from "@utils/billingUtils/invoicingUtils/lineItemUtils/volumeTiersToLineAmount";
// Standard multi-tier volume schedule used across several tests:
// 0100 @ $0.10/unit, 101500 @ $0.05/unit, 501+ @ $0.02/unit
// Volume: entire quantity is priced at the rate of whichever tier it falls into.
const THREE_TIERS = [
{ to: 100, amount: 0.1 },
{ to: 500, amount: 0.05 },
{ to: Infinite, amount: 0.02 },
] as UsageTier[];
describe("volumeTiersToLineAmount", () => {
describe("tier selection", () => {
test("usage within tier 1: 50 units → entire 50 @ $0.10 = $5", () => {
expect(volumeTiersToLineAmount({ tiers: THREE_TIERS, usage: 50 })).toBe(
5,
);
});
test("usage exactly at tier 1 boundary: 100 units → entire 100 @ $0.10 = $10", () => {
expect(volumeTiersToLineAmount({ tiers: THREE_TIERS, usage: 100 })).toBe(
10,
);
});
test("usage just into tier 2: 101 units → entire 101 @ $0.05 = $5.05", () => {
expect(volumeTiersToLineAmount({ tiers: THREE_TIERS, usage: 101 })).toBe(
5.05,
);
});
test("usage mid tier 2: 250 units → entire 250 @ $0.05 = $12.50 (not $17.50 graduated)", () => {
// Graduated would be: 100×$0.10 + 150×$0.05 = $17.50
// Volume charges entire quantity at tier 2 rate
expect(volumeTiersToLineAmount({ tiers: THREE_TIERS, usage: 250 })).toBe(
12.5,
);
});
test("usage exactly at tier 2 boundary: 500 units → entire 500 @ $0.05 = $25", () => {
expect(volumeTiersToLineAmount({ tiers: THREE_TIERS, usage: 500 })).toBe(
25,
);
});
test("usage in tier 3: 1000 units → entire 1000 @ $0.02 = $20 (not $40 graduated)", () => {
// Graduated would be: 100×$0.10 + 400×$0.05 + 500×$0.02 = $40
// Volume charges entire quantity at tier 3 rate
expect(volumeTiersToLineAmount({ tiers: THREE_TIERS, usage: 1000 })).toBe(
20,
);
});
});
describe("single tier (flat rate)", () => {
test("100 units @ $0.10 = $10", () => {
expect(
volumeTiersToLineAmount({
tiers: [{ to: Infinite, amount: 0.1 }],
usage: 100,
}),
).toBe(10);
});
test("0 usage = $0", () => {
expect(
volumeTiersToLineAmount({
tiers: [{ to: Infinite, amount: 0.1 }],
usage: 0,
}),
).toBe(0);
});
test("tier.to = -1 treated as Infinite", () => {
expect(
volumeTiersToLineAmount({
tiers: [{ to: -1, amount: 0.1 }],
usage: 100,
}),
).toBe(10);
});
});
describe("billing units", () => {
// $1.00 per 1000 tokens; tier boundary at 100k tokens
const TOKEN_TIERS = [
{ to: 100000, amount: 1.0 },
{ to: Infinite, amount: 0.5 },
] as UsageTier[];
test("50k tokens (tier 1) @ $1/1k = $50", () => {
expect(
volumeTiersToLineAmount({
tiers: TOKEN_TIERS,
usage: 50000,
billingUnits: 1000,
}),
).toBe(50);
});
test("150k tokens (tier 2) @ $0.50/1k = $75", () => {
expect(
volumeTiersToLineAmount({
tiers: TOKEN_TIERS,
usage: 150000,
billingUnits: 1000,
}),
).toBe(75);
});
test("rounds up to nearest billing unit before selecting tier: 1500 tokens → 2000 → tier 1 → $2", () => {
// 1500 tokens rounds up to 2000 (nearest 1000), still in tier 1
expect(
volumeTiersToLineAmount({
tiers: TOKEN_TIERS,
usage: 1500,
billingUnits: 1000,
}),
).toBe(2);
});
test("rounding can push usage into higher tier: 99500 tokens → 100000 → stays tier 1 boundary", () => {
// 99500 rounds up to 100000 which is exactly at tier 1 boundary → tier 1 rate
expect(
volumeTiersToLineAmount({
tiers: TOKEN_TIERS,
usage: 99500,
billingUnits: 1000,
}),
).toBe(100);
});
});
describe("decimal precision", () => {
test("fractional rate: 3 units @ $0.10/unit = $0.30 (not 0.30000000004)", () => {
expect(
volumeTiersToLineAmount({
tiers: [{ to: Infinite, amount: 0.1 }],
usage: 3,
}),
).toBe(0.3);
});
test("large usage, tiny rate: 1,000,000 units @ $0.000001 = $1", () => {
expect(
volumeTiersToLineAmount({
tiers: [{ to: Infinite, amount: 0.000001 }],
usage: 1_000_000,
}),
).toBe(1);
});
});
describe("negative usage (allowNegative)", () => {
test("negative usage with allowNegative=false (default): rounds abs to 0 → $0", () => {
// Without allowNegative, negative usage is treated as-is.
// roundUsageToNearestBillingUnit of a negative number returns 0.
const result = volumeTiersToLineAmount({
tiers: THREE_TIERS,
usage: -100,
});
expect(result).toBe(0);
});
test("negative usage with allowNegative=true: abs value is priced then negated", () => {
// -250 units: abs=250, falls in tier 2 → 250×$0.05=$12.50, negated → -$12.50
const result = volumeTiersToLineAmount({
tiers: THREE_TIERS,
usage: -250,
allowNegative: true,
});
expect(result).toBe(-12.5);
});
test("negative usage with allowNegative=true, tier 1: -50 units → -$5", () => {
const result = volumeTiersToLineAmount({
tiers: THREE_TIERS,
usage: -50,
allowNegative: true,
});
expect(result).toBe(-5);
});
test("negative usage with allowNegative=true, tier 3: -1000 units → -$20", () => {
const result = volumeTiersToLineAmount({
tiers: THREE_TIERS,
usage: -1000,
allowNegative: true,
});
expect(result).toBe(-20);
});
test("negative usage with allowNegative=true and billingUnits: rounds abs up first", () => {
// -1500 tokens: abs=1500, billingUnits=1000 → rounds to 2000 → tier 1 → 2000×($1/1000)=$2 → -$2
expect(
volumeTiersToLineAmount({
tiers: [
{ to: 100000, amount: 1.0 },
{ to: Infinite, amount: 0.5 },
],
usage: -1500,
billingUnits: 1000,
allowNegative: true,
}),
).toBe(-2);
});
test("positive usage is unaffected by allowNegative=true", () => {
const withFlag = volumeTiersToLineAmount({
tiers: THREE_TIERS,
usage: 250,
allowNegative: true,
});
const withoutFlag = volumeTiersToLineAmount({
tiers: THREE_TIERS,
usage: 250,
});
expect(withFlag).toBe(withoutFlag);
expect(withFlag).toBe(12.5);
});
test("zero usage with allowNegative=true = $0", () => {
expect(
volumeTiersToLineAmount({
tiers: THREE_TIERS,
usage: 0,
allowNegative: true,
}),
).toBe(0);
});
});
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(() =>
volumeTiersToLineAmount({
tiers: null as unknown as UsageTier[],
usage: 100,
}),
).toThrow();
});
});
});

View File

@@ -82,7 +82,7 @@ export const checkUsageInvoiceAmount = async ({
const overage = new Decimal(totalUsage)
.minus(featureEntitlement.allowance)
.toNumber();
const overagePrice = getPriceForOverage(meteredPrice, overage);
const overagePrice = getPriceForOverage({ price: meteredPrice, overage });
let basePrice = 0;
if (includeBase && product.prices.length > 1) {

View File

@@ -4,6 +4,7 @@ import {
type ProductItemConfig,
ProductItemInterval,
type RolloverConfig,
TierBehavior,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features";
import { constructPriceItem } from "@/internal/products/product-items/productItemUtils.js";
@@ -321,16 +322,9 @@ const prepaidUsers = ({
}) as LimitedItem;
/**
* Tiered prepaid messages - volume pricing with tiers
* Default tiers:
* - 0-500 units: $10/pack (100 units/pack)
* - 501+ units: $5/pack
*
* Tiered prepaid messages - graduated pricing with tiers.
* Default tiers: 0-500 units at $10/pack, 501+ at $5/pack (100 units/pack).
* IMPORTANT: Last tier MUST have `to: "inf"` - Stripe requires a catch-all tier.
*
* @param includedUsage - Free units (default: 0)
* @param billingUnits - Units per pack (default: 100)
* @param tiers - Volume tiers (default: standard volume discount). Last tier must have `to: "inf"`.
*/
const tieredPrepaidMessages = ({
includedUsage = 0,
@@ -354,6 +348,39 @@ const tieredPrepaidMessages = ({
config,
}) as LimitedItem;
/**
* Volume-priced prepaid messages — the entire purchased quantity is charged at
* the rate of whichever tier it falls into (not split across tiers).
* Default tiers: 0-500 units at $10/pack, 501+ at $5/pack (100 units/pack).
* IMPORTANT: Last tier MUST have `to: "inf"` - Stripe requires a catch-all tier.
*/
const volumePrepaidMessages = ({
includedUsage = 0,
billingUnits = 100,
tiers = [
{ to: 500, amount: 10 },
{ to: "inf", amount: 5 },
],
config,
}: {
includedUsage?: number;
billingUnits?: number;
tiers?: { to: number | "inf"; amount: number; flat_amount?: number | null }[];
config?: ProductItemConfig;
} = {}): LimitedItem =>
constructPrepaidItem({
featureId: TestFeature.Messages,
tiers: tiers as {
to: number;
amount: number;
flat_amount?: number | null;
}[],
tierBehaviour: TierBehavior.VolumeBased,
billingUnits,
includedUsage,
config,
}) as LimitedItem;
// ═══════════════════════════════════════════════════════════════════
// ONE-OFF (interval: null, no recurring charges)
// ═══════════════════════════════════════════════════════════════════
@@ -556,6 +583,31 @@ const consumableWords = ({
interval,
});
/**
* Tiered consumable messages - graduated pricing with tiers (pay-per-use).
* Default tiers: 0-500 units at $0.10/unit, 501+ at $0.05/unit.
* IMPORTANT: Last tier MUST have `to: "inf"` - Stripe requires a catch-all tier.
* Note: tier_behavior is undefined, defaulting to graduated pricing.
*/
const tieredConsumableMessages = ({
includedUsage = 0,
billingUnits = 1,
tiers = [
{ to: 500, amount: 0.1 },
{ to: "inf", amount: 0.05 },
],
}: {
includedUsage?: number;
billingUnits?: number;
tiers?: { to: number | "inf"; amount: number }[];
} = {}): LimitedItem =>
constructArrearItem({
featureId: TestFeature.Messages,
tiers: tiers as { to: number; amount: number }[],
billingUnits,
includedUsage,
}) as LimitedItem;
// ═══════════════════════════════════════════════════════════════════
// ALLOCATED / SEATS (prorated billing)
// ═══════════════════════════════════════════════════════════════════
@@ -666,6 +718,7 @@ export const items = {
prepaidMessages,
prepaidUsers,
tieredPrepaidMessages,
volumePrepaidMessages,
// One-off
oneOffMessages,
@@ -677,6 +730,7 @@ export const items = {
consumable,
consumableMessages,
consumableWords,
tieredConsumableMessages,
// Allocated
allocatedUsers,

View File

@@ -4,6 +4,8 @@ import {
OnDecrease,
OnIncrease,
ResetInterval,
TierBehavior,
TierInfinite,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features";
@@ -120,6 +122,61 @@ const allocatedUsers = ({
},
});
/**
* Tiered prepaid messages - tier `to` values INCLUDE the included amount.
* Default: included=100, tiers=[{to:600, amount:10}, {to:"inf", amount:5}]
* (internally stored as [{to:500}, {to:"inf"}] after subtracting included)
*/
const tieredPrepaidMessages = ({
included = 100,
billingUnits = 100,
tiers = [
{ to: 600, amount: 10 },
{ to: TierInfinite, amount: 5 },
],
}: {
included?: number;
billingUnits?: number;
tiers?: { to: number | typeof TierInfinite; amount: number }[];
} = {}) => ({
feature_id: TestFeature.Messages,
included,
price: {
tiers,
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
billing_units: billingUnits,
},
});
/**
* Volume prepaid messages - tier `to` values INCLUDE the included amount.
* Entire quantity is charged at whichever single tier it falls into.
* Default: included=100, tiers=[{to:600, amount:10}, {to:"inf", amount:5}]
*/
const volumePrepaidMessages = ({
included = 100,
billingUnits = 100,
tiers = [
{ to: 600, amount: 10 },
{ to: TierInfinite, amount: 5 },
],
}: {
included?: number;
billingUnits?: number;
tiers?: { to: number | typeof TierInfinite; amount: number }[];
} = {}) => ({
feature_id: TestFeature.Messages,
included,
price: {
tiers,
tier_behavior: TierBehavior.VolumeBased,
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
billing_units: billingUnits,
},
});
export const itemsV2 = {
monthlyPrice,
annualPrice,
@@ -130,4 +187,6 @@ export const itemsV2 = {
prepaidWords,
consumableMessages,
allocatedUsers,
tieredPrepaidMessages,
volumePrepaidMessages,
} as const;

View File

@@ -67,19 +67,23 @@ const base = ({
* Pro product - $20/month base price
* @param items - Product items (features)
* @param id - Product ID (default: "pro")
* @param group - Optional product group
*/
const pro = ({
items,
id = "pro",
group,
}: {
items: ProductItem[];
id?: string;
group?: string;
}): ProductV2 =>
constructProduct({
id,
items: [...items],
type: "pro",
isDefault: false,
group,
});
/**

View File

@@ -1,5 +1,8 @@
import { BillingMethod } from "@api/products/components/billingMethod";
import { UsageTierSchema } from "@models/productModels/priceModels/priceConfig/usagePriceConfig";
import {
TierBehavior,
UsageTierSchema,
} from "@models/productModels/priceModels/priceConfig/usagePriceConfig";
import { z } from "zod/v4";
import { ApiFeatureV1Schema } from "../../features/apiFeatureV1";
import { ApiBalanceResetSchema, ApiBalanceRolloverSchema } from "./apiBalance";
@@ -39,6 +42,10 @@ export const ApiBalanceBreakdownPriceSchema = z.object({
tiers: z.array(UsageTierSchema).optional().meta({
description: "Tiered pricing configuration if applicable.",
}),
tier_behavior: z.enum(TierBehavior).optional().meta({
description:
"How tiers are applied: graduated (split across bands) or volume (flat rate for the matched tier).",
}),
billing_units: z.number().meta({
description:
"The number of units per billing increment (eg. $9 / 250 units).",

View File

@@ -1,10 +1,13 @@
import { ApiFeatureV0Schema } from "@api/features/prevVersions/apiFeatureV0";
import { BillingMethod } from "@api/products/components/billingMethod";
import { DisplaySchema } from "@api/products/components/display";
import { RolloverExpiryDurationType } from "@models/productModels/durationTypes/rolloverExpiryDurationType";
import { BillingInterval } from "@models/productModels/intervals/billingInterval";
import { ResetInterval } from "@models/productModels/intervals/resetInterval";
import { UsageTierSchema } from "@models/productModels/priceModels/priceConfig/usagePriceConfig";
import { ApiFeatureV0Schema } from "@api/features/prevVersions/apiFeatureV0.js";
import { BillingMethod } from "@api/products/components/billingMethod.js";
import { DisplaySchema } from "@api/products/components/display.js";
import { RolloverExpiryDurationType } from "@models/productModels/durationTypes/rolloverExpiryDurationType.js";
import { BillingInterval } from "@models/productModels/intervals/billingInterval.js";
import { ResetInterval } from "@models/productModels/intervals/resetInterval.js";
import {
TierBehavior,
UsageTierSchema,
} from "@models/productModels/priceModels/priceConfig/usagePriceConfig.js";
import {
OnDecrease,
OnIncrease,
@@ -89,8 +92,9 @@ export const ApiPlanItemV1Schema = z
}),
tiers: z.array(UsageTierSchema).optional().meta({
description:
"Tiered pricing configuration. Each tier's 'up_to' does NOT include the included amount. Either 'tiers' or 'amount' is required.",
"Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required.",
}),
tier_behavior: z.enum(TierBehavior).optional(),
interval: z.enum(BillingInterval).meta({
description:

View File

@@ -2,7 +2,11 @@ import { BillingMethod } from "@api/products/components/billingMethod";
import { RolloverExpiryDurationType } from "@models/productModels/durationTypes/rolloverExpiryDurationType";
import { BillingInterval } from "@models/productModels/intervals/billingInterval";
import { ResetInterval } from "@models/productModels/intervals/resetInterval";
import { UsageTierSchema } from "@models/productModels/priceModels/priceConfig/usagePriceConfig";
import {
TierBehavior,
UsageTierSchema,
} from "@models/productModels/priceModels/priceConfig/usagePriceConfig";
import {
OnDecrease,
OnIncrease,
@@ -46,8 +50,9 @@ export const CreatePlanItemParamsV1Schema = z
}),
tiers: z.array(UsageTierSchema).optional().meta({
description:
"Tiered pricing. Each tier's 'to' does NOT include included amount. Either 'amount' or 'tiers' is required.",
"Tiered pricing. Either 'amount' or 'tiers' is required.",
}),
tier_behavior: z.enum(TierBehavior).optional(),
interval: z.enum(BillingInterval).meta({
description:
@@ -158,6 +163,74 @@ export const CreatePlanItemParamsV1Schema = z
});
}
}
if (ctx.value.price?.tiers) {
const hasFlatAmount = ctx.value.price.tiers.some(
(t) => t.flat_amount && t.flat_amount > 0,
);
if (
hasFlatAmount &&
ctx.value.price.tier_behavior !== TierBehavior.VolumeBased
) {
ctx.issues.push({
code: "custom",
message:
"flat_amount on tiers is only supported for volume-based pricing.",
input: ctx.value.price,
});
}
if (hasFlatAmount && ctx.value.price.tiers.length <= 1) {
ctx.issues.push({
code: "custom",
message: "flat_amount is not supported on single-tier pricing.",
input: ctx.value.price,
});
}
if (
ctx.value.price.tiers.some(
(t) => t.flat_amount != null && t.flat_amount < 0,
)
) {
ctx.issues.push({
code: "custom",
message: "flat_amount must be 0 or greater.",
input: ctx.value.price,
});
}
if (
ctx.value.price?.tier_behavior === TierBehavior.VolumeBased &&
ctx.value.price?.billing_method !== BillingMethod.Prepaid
) {
ctx.issues.push({
code: "custom",
message:
"volume-based pricing is only supported for prepaid features.",
input: ctx.value.price,
});
}
if (ctx.value.price?.tiers.length === 0) {
ctx.issues.push({
code: "custom",
message: "tiers cannot be empty.",
input: ctx.value.price,
});
} else if (
ctx.value.included &&
typeof ctx.value.price?.tiers[0].to === "number" &&
ctx.value.price?.tiers[0].to <= ctx.value.included
) {
ctx.issues.push({
code: "custom",
message: "tiers[0].to must be greater than included.",
input: ctx.value.price,
});
}
}
})
.meta({
title: "PlanItem",

View File

@@ -3,6 +3,7 @@ import { billingMethodToUsageModel } from "@api/products/components/mappers/bill
import type { CreatePlanItemParamsV1 } from "@api/products/items/crud/createPlanItemParamsV1.js";
import type { ApiPlanItemV0 } from "@api/products/items/previousVersions/apiPlanItemV0.js";
import { featureUtils } from "@utils/index";
import { subtractIncludedFromTiers } from "@utils/productV2Utils/productItemUtils/tierUtils.js";
import type { SharedContext } from "../../../../types/sharedContext.js";
/**
@@ -24,9 +25,17 @@ export function planItemParamsV1ToPlanItemV0({
const isAllocatedFeature = featureUtils.isAllocated(feature);
const included = item.included ?? 0;
// V1 API: tier `to` values INCLUDE included usage.
// Internal: tier `to` values do NOT include included usage.
const internalTiers = item.price?.tiers
? subtractIncludedFromTiers({ tiers: item.price.tiers, included })
: undefined;
return {
feature_id: item.feature_id,
granted_balance: item.included ?? 0,
granted_balance: included,
unlimited: item.unlimited ?? false,
reset: item.reset
@@ -40,7 +49,8 @@ export function planItemParamsV1ToPlanItemV0({
price: item.price
? {
amount: item.price.amount,
tiers: item.price.tiers,
tiers: internalTiers,
tier_behavior: item.price.tier_behavior,
interval: item.price.interval,
interval_count: item.price.interval_count,
billing_units: item.price.billing_units ?? 1,

View File

@@ -146,6 +146,7 @@ export const planItemV0ToProductItem = ({
amount: tier.amount,
to: tier.to,
})),
tier_behavior: planItem.price?.tier_behavior,
usage_model: planItem.price?.usage_model,
billing_units: planItem.price?.billing_units,

View File

@@ -1,7 +1,9 @@
import type { CreatePlanItemParamsV1 } from "@api/models";
import { billingMethodToUsageModel } from "@api/products/components/mappers/billingMethodTousageModel";
import type { ApiPlanItemV0 } from "@api/products/items/previousVersions/apiPlanItemV0";
import { TierBehavior } from "@models/productModels/priceModels/priceConfig/usagePriceConfig";
import { featureUtils } from "@utils/featureUtils/index";
import { subtractIncludedFromTiers } from "@utils/productV2Utils/productItemUtils/tierUtils";
import type { SharedContext } from "../../../../types/sharedContext";
import type { ApiPlanItemV1 } from "../apiPlanItemV1";
@@ -22,6 +24,12 @@ export function planItemV1ToV0({
? featureUtils.isConsumable(feature)
: true;
// V1 API: tier `to` values INCLUDE included usage.
// Internal: tier `to` values do NOT include included usage.
const internalTiers = price?.tiers
? subtractIncludedFromTiers({ tiers: price.tiers, included })
: undefined;
return {
...restItem,
unlimited: item.unlimited ?? false,
@@ -36,7 +44,10 @@ export function planItemV1ToV0({
price: price
? {
amount: price.amount,
tiers: price.tiers,
tiers: internalTiers,
tier_behavior: internalTiers?.length
? (price.tier_behavior ?? TierBehavior.Graduated)
: undefined,
interval: price.interval,
interval_count: price.interval_count,
billing_units: billingUnits,

View File

@@ -1,9 +1,12 @@
import { ApiFeatureV0Schema } from "@api/features/prevVersions/apiFeatureV0";
import { DisplaySchema } from "@api/products/components/display";
import { RolloverExpiryDurationType } from "@models/productModels/durationTypes/rolloverExpiryDurationType";
import { BillingInterval } from "@models/productModels/intervals/billingInterval";
import { ResetInterval } from "@models/productModels/intervals/resetInterval";
import { UsageTierSchema } from "@models/productModels/priceModels/priceConfig/usagePriceConfig";
import { ApiFeatureV0Schema } from "@api/features/prevVersions/apiFeatureV0.js";
import { DisplaySchema } from "@api/products/components/display.js";
import { RolloverExpiryDurationType } from "@models/productModels/durationTypes/rolloverExpiryDurationType.js";
import { BillingInterval } from "@models/productModels/intervals/billingInterval.js";
import { ResetInterval } from "@models/productModels/intervals/resetInterval.js";
import {
TierBehavior,
UsageTierSchema,
} from "@models/productModels/priceModels/priceConfig/usagePriceConfig.js";
import {
OnDecrease,
OnIncrease,
@@ -31,6 +34,7 @@ export const ApiPlanItemV0Schema = z
.object({
amount: z.number().optional(),
tiers: z.array(UsageTierSchema).optional(),
tier_behavior: z.enum(TierBehavior).optional(),
interval: z.enum(BillingInterval),
interval_count: z.number().optional(),

View File

@@ -1,5 +1,6 @@
import { ApiFeatureV0Schema } from "@api/features/prevVersions/apiFeatureV0.js";
import { ProductItemInterval } from "@models/productModels/intervals/productItemInterval.js";
import { TierBehavior } from "@models/productModels/priceModels/priceConfig/usagePriceConfig.js";
import { Infinite } from "@models/productModels/productEnums.js";
import {
OnDecrease,
@@ -63,6 +64,11 @@ export const ApiProductItemV0Schema = z
"Tiered pricing for the product item. Not applicable for fixed price items.",
}),
tier_behavior: z.enum(TierBehavior).nullish().meta({
description:
"How tiers are applied: graduated (split across bands) or volume (flat rate for the matched tier). Defaults to graduated.",
}),
usage_model: z.enum(UsageModel).nullish().meta({
description:
"Whether the feature should be prepaid upfront or billed for how much they use end of billing period.",

View File

@@ -8,9 +8,15 @@ export enum BillWhen {
EndOfPeriod = "end_of_period",
}
export enum TierBehavior {
Graduated = "graduated",
VolumeBased = "volume",
}
export const UsageTierSchema = z.object({
to: z.number().or(z.literal(Infinite)),
amount: z.number(),
flat_amount: z.number().nullish(),
});
export type UsageTier = z.infer<typeof UsageTierSchema>;

View File

@@ -2,10 +2,13 @@ import { z } from "zod/v4";
import {
OnDecrease,
OnIncrease,
} from "../../productV2Models/productItemModels/productItemEnums";
import { FixedPriceConfigSchema } from "./priceConfig/fixedPriceConfig";
import { UsagePriceConfigSchema } from "./priceConfig/usagePriceConfig";
import { BillingType } from "./priceEnums";
} from "../../productV2Models/productItemModels/productItemEnums.js";
import { FixedPriceConfigSchema } from "./priceConfig/fixedPriceConfig.js";
import {
TierBehavior,
UsagePriceConfigSchema,
} from "./priceConfig/usagePriceConfig.js";
import { BillingType } from "./priceEnums.js";
const ProrationConfigSchema = z.object({
on_increase: z.nativeEnum(OnIncrease).default(OnIncrease.ProrateImmediately),
@@ -19,6 +22,7 @@ export const PriceSchema = z.object({
org_id: z.string().optional(),
created_at: z.number().optional(),
billing_type: z.nativeEnum(BillingType).nullish(),
tier_behavior: z.nativeEnum(TierBehavior).nullish(),
is_custom: z.boolean().optional(),
config: FixedPriceConfigSchema.or(UsagePriceConfigSchema),

View File

@@ -9,12 +9,15 @@ import {
text,
unique,
} from "drizzle-orm/pg-core";
import { collatePgColumn } from "../../../db/utils";
import { entitlements } from "../entModels/entTable";
import { products } from "../productTable";
import type { FixedPriceConfig } from "./priceConfig/fixedPriceConfig";
import type { UsagePriceConfig } from "./priceConfig/usagePriceConfig";
import type { ProrationConfig } from "./priceModels";
import { collatePgColumn } from "../../../db/utils.js";
import { entitlements } from "../entModels/entTable.js";
import { products } from "../productTable.js";
import type { FixedPriceConfig } from "./priceConfig/fixedPriceConfig.js";
import type {
TierBehavior,
UsagePriceConfig,
} from "./priceConfig/usagePriceConfig.js";
import type { ProrationConfig } from "./priceModels.js";
export const prices = pgTable(
"prices",
@@ -25,6 +28,9 @@ export const prices = pgTable(
config: jsonb().$type<FixedPriceConfig | UsagePriceConfig>(),
created_at: numeric({ mode: "number" }).notNull(),
billing_type: text("billing_type"),
tier_behavior: text("tier_behavior")
.$type<TierBehavior>()
.default(sql`null`),
is_custom: boolean("is_custom").default(false),
entitlement_id: text("entitlement_id").default(sql`null`),
proration_config: jsonb("proration_config")

View File

@@ -11,6 +11,7 @@ export const FeaturePriceItemSchema = ProductItemSchema.pick({
price: true,
tiers: true,
tier_behavior: true,
billing_units: true,
reset_usage_when_enabled: true,

View File

@@ -2,6 +2,7 @@ import { z } from "zod/v4";
import { ApiFeatureV0Schema } from "../../../api/features/prevVersions/apiFeatureV0.js";
import { RolloverExpiryDurationType } from "../../productModels/durationTypes/rolloverExpiryDurationType.js";
import { ProductItemInterval } from "../../productModels/intervals/productItemInterval.js";
import { TierBehavior } from "../../productModels/priceModels/priceConfig/usagePriceConfig.js";
import { Infinite } from "../../productModels/productEnums.js";
import { OnDecrease, OnIncrease } from "./productItemEnums.js";
@@ -22,6 +23,10 @@ export const PriceTierSchema = z.object({
description: "The price of the product item for this tier.",
example: 10,
}),
flat_amount: z.number().nullish().meta({
description:
"A flat fee charged for this tier, in addition to the per-unit amount.",
}),
});
export enum UsageModel {
@@ -119,6 +124,10 @@ export const ProductItemSchema = z.object({
"The billing units of the product item (eg $1 for 30 credits).",
}),
tier_behavior: z.enum(TierBehavior).nullish().meta({
description: "The type of tiered pricing: graduated or volume-based.",
}),
// Others
// carry_over_usage: z.boolean().nullish(),
reset_usage_when_enabled: z.boolean().nullish().meta({

View File

@@ -41,6 +41,7 @@
"@types/bun": "latest",
"@types/node": "^24.0.3",
"cross-env": "^7.0.3",
"tsx": "^4.21.0",
"typescript": "^5.7.2"
},
"private": true

View File

@@ -6,14 +6,15 @@ export * from "./intervalUtils/intervalArithmetic";
// Invoicing utils
export * from "./invoicingUtils/filterUnchangedPricesFromLineItems";
export * from "./invoicingUtils/lineItemBuilders/buildLineItem";
export * from "./invoicingUtils/lineItemBuilders/fixedPriceToLineItem";
export * from "./invoicingUtils/lineItemBuilders/usagePriceToLineItem";
export * from "./invoicingUtils/lineItemUtils/lineItemToCustomerEntitlement";
export * from "./invoicingUtils/lineItemUtils/priceToLineAmount";
export * from "./invoicingUtils/lineItemUtils/tiersToLineAmount";
export * from "./invoicingUtils/prorationUtils/applyProration";
export * from "./invoicingUtils/prorationUtils/getEffectivePeriod";
export * from "./invoicingUtils/prorationUtils/prorationConfigUtils";
export * from "./usageUtils/roundUsageToNearestBillingUnit";
export * from "./invoicingUtils/filterUnchangedPricesFromLineItems.js";
export * from "./invoicingUtils/lineItemBuilders/buildLineItem.js";
export * from "./invoicingUtils/lineItemBuilders/fixedPriceToLineItem.js";
export * from "./invoicingUtils/lineItemBuilders/usagePriceToLineItem.js";
export * from "./invoicingUtils/lineItemUtils/graduatedTiersToLineAmount.js";
export * from "./invoicingUtils/lineItemUtils/lineItemToCustomerEntitlement.js";
export * from "./invoicingUtils/lineItemUtils/priceToLineAmount.js";
export * from "./invoicingUtils/lineItemUtils/tiersToLineAmount.js";
export * from "./invoicingUtils/prorationUtils/applyProration.js";
export * from "./invoicingUtils/prorationUtils/getEffectivePeriod.js";
export * from "./invoicingUtils/prorationUtils/prorationConfigUtils.js";
export * from "./usageUtils/roundUsageToNearestBillingUnit.js";

View File

@@ -11,6 +11,7 @@ import { cusEntToInvoiceUsage } from "../../../cusEntUtils/overageUtils/cusEntTo
import {
isConsumablePrice,
isPrepaidPrice,
isVolumePrice,
} from "../../../productUtils/priceUtils/classifyPriceUtils";
import { usagePriceToLineDescription } from "../descriptionUtils/usagePriceToLineDescription";
import { priceToLineAmount } from "../lineItemUtils/priceToLineAmount";
@@ -58,10 +59,17 @@ export const usagePriceToLineItem = ({
overage = cusEntToInvoiceOverage({ cusEnt });
}
// Volume pricing: the total quantity (purchased + allowance) determines
// which tier applies, and the ENTIRE total is charged at that tier's rate.
// So we add allowance back to overage before pricing.
const allowance = cusEntsToAllowance({ cusEnts: [cusEnt] });
if (isVolumePrice(cusPrice.price)) {
overage = new Decimal(overage).add(allowance).toNumber();
}
// 2. Get usage
let usage = 0;
if (isPrepaidPrice(cusPrice.price)) {
const allowance = cusEntsToAllowance({ cusEnts: [cusEnt] });
const prepaidQuantity = cusEntsToPrepaidQuantity({
cusEnts: [cusEnt],
sumAcrossEntities: false,
@@ -90,6 +98,7 @@ export const usagePriceToLineItem = ({
const amount = priceToLineAmount({
price,
overage,
allowance: allowance,
});
// 5. Get stripe price / product IDs

View File

@@ -0,0 +1,84 @@
import { Decimal } from "decimal.js";
import type { UsageTier } from "../../../../models/productModels/priceModels/priceConfig/usagePriceConfig";
import { Infinite } from "../../../../models/productModels/productEnums";
import { nullish } from "../../../utils";
import { roundUsageToNearestBillingUnit } from "../../usageUtils/roundUsageToNearestBillingUnit";
/**
* Core graduated tiered pricing calculation used across all billing contexts:
* included usage (free allowances), prepaid purchased quantities, and paid overage.
*
* Graduated pricing splits usage across tier bands — each band is charged at
* its own rate. For example, if tier 1 covers 0100 units at $1 and tier 2
* covers 100+ at $0.50, then 150 units costs (100 × $1) + (50 × $0.50) = $125.
*
* - **Included usage** (free allowance): pass `usage = includedQuantity`. The
* result represents the monetary value of the free bucket — used to compute
* how much of the prepaid charge is "used up" vs remaining.
* - **Prepaid quantity** (usage_in_advance): pass `usage = quantityPurchased`.
* The result is what the customer is charged upfront for the units they bought.
* - **Paid overage** (usage_in_arrear / pay-per-use): pass `usage = overageUnits`
* (raw usage minus any included or prepaid allowance). The result is the
* end-of-period charge for units consumed beyond the free/prepaid bucket.
*
* @param tiers - Ordered array of tier bands from the price config (`usage_tiers`).
* @param usage - The quantity to price. Meaning depends on context: purchased
* quantity for prepaid, overage units for arrear billing, or free-bucket size
* for included-usage valuation. Must be non-negative unless `allowNegative` is true.
* @param billingUnits - Divisor applied before multiplying by tier rate (e.g. 1000
* for "per 1k tokens"). Defaults to 1.
* @param allowNegative - When true, a negative `usage` is priced on its absolute
* value and the result is negated. Used for downgrade credits / proration refunds.
* Defaults to false.
* @returns The total dollar amount as a number rounded to 10 decimal places.
*/
export const graduatedTiersToLineAmount = ({
tiers,
usage,
billingUnits = 1,
allowNegative = false,
}: {
tiers: UsageTier[];
usage: number;
billingUnits?: number;
allowNegative?: boolean;
}): number => {
if (nullish(tiers)) {
throw new Error(
"[graduatedTiersToLineAmount] usage_tiers required for usage-based prices",
);
}
const isNegative = allowNegative && usage < 0;
const absoluteUsage = allowNegative ? Math.abs(usage) : usage;
const roundedUsage = roundUsageToNearestBillingUnit({
usage: absoluteUsage,
billingUnits,
});
let amount = new Decimal(0);
let remaining = new Decimal(roundedUsage);
let lastTierTo = 0;
for (const tier of tiers) {
if (remaining.lte(0)) break;
const isFinalTier = tier.to === Infinite || tier.to === -1;
const tierSize = isFinalTier
? remaining
: Decimal.min(remaining, new Decimal(tier.to).minus(lastTierTo));
const rate = new Decimal(tier.amount).div(billingUnits);
amount = amount.plus(rate.mul(tierSize));
remaining = remaining.minus(tierSize);
if (!isFinalTier) {
lastTierTo = tier.to as number;
}
}
const finalAmount = amount.toDecimalPlaces(10).toNumber();
return isNegative ? -finalAmount : finalAmount;
};

View File

@@ -16,10 +16,12 @@ import { Decimal } from "decimal.js";
export const priceToLineAmount = ({
price,
overage,
allowance = 0,
multiplier = 1,
}: {
price: Price;
overage?: number;
allowance?: number;
multiplier?: number;
}): number => {
// Fixed prices: flat amount × multiplier
@@ -38,6 +40,7 @@ export const priceToLineAmount = ({
return tiersToLineAmount({
price,
overage,
allowance,
billingUnits: price.config.billing_units ?? 1,
});
};

View File

@@ -1,55 +1,57 @@
import { Decimal } from "decimal.js";
import { TierBehavior } from "@models/productModels/priceModels/priceConfig/usagePriceConfig";
import { volumeTiersToLineAmount } from "@utils/billingUtils/invoicingUtils/lineItemUtils/volumeTiersToLineAmount";
import type { Price } from "../../../../models/productModels/priceModels/priceModels";
import { Infinite } from "../../../../models/productModels/productEnums";
import { nullish } from "../../../utils";
import { roundUsageToNearestBillingUnit } from "../../usageUtils/roundUsageToNearestBillingUnit";
import { graduatedTiersToLineAmount } from "./graduatedTiersToLineAmount";
/**
* Translates usage into a dollar amount using the price's tier behaviour.
*
* - **Graduated**: `overage` should be net of allowance. Each tier band is
* charged at its own rate. `allowance` param is unused.
* - **Volume**: `overage` should be total usage (purchased + allowance).
* `allowance` is passed through to prepend a free $0 tier and shift
* boundaries. If total exceeds the free tier, the ENTIRE quantity
* (including included) is charged at the matching tier's rate.
*
* Callers (e.g. `usagePriceToLineItem`) are responsible for adjusting
* `overage` before calling — volume adds allowance to overage, graduated
* does not.
*/
export const tiersToLineAmount = ({
price,
overage,
allowance = 0,
billingUnits = 1,
}: {
price: Price;
overage: number;
allowance?: number;
billingUnits?: number;
}): number => {
const isNegative = overage < 0;
const absoluteOverage = Math.abs(overage);
const roundedOverage = roundUsageToNearestBillingUnit({
usage: absoluteOverage,
billingUnits,
});
let amount = new Decimal(0);
let remaining = new Decimal(roundedOverage);
let lastTierTo = 0;
const tiers = price.config.usage_tiers;
const isVolume = price.tier_behavior === TierBehavior.VolumeBased;
if (nullish(tiers)) {
throw new Error(
`[tiersToLineAmount] usage_tiers required for usage-based prices`,
"[tiersToLineAmount] usage_tiers required for usage-based or prepaid prices",
);
}
for (const tier of tiers) {
if (remaining.lte(0)) break;
const isFinalTier = tier.to === Infinite || tier.to === -1;
const tierSize = isFinalTier
? remaining
: Decimal.min(remaining, new Decimal(tier.to).minus(lastTierTo));
const rate = new Decimal(tier.amount).div(billingUnits);
amount = amount.plus(rate.mul(tierSize));
remaining = remaining.minus(tierSize);
if (tier.to !== Infinite && tier.to !== -1) {
lastTierTo = tier.to;
}
if (isVolume) {
return volumeTiersToLineAmount({
tiers,
usage: overage,
billingUnits,
allowNegative: true,
allowance,
});
}
const finalAmount = amount.toDecimalPlaces(10).toNumber();
return isNegative ? -finalAmount : finalAmount;
return graduatedTiersToLineAmount({
tiers,
usage: overage,
billingUnits,
allowNegative: true,
});
};

View File

@@ -0,0 +1,72 @@
import type { UsageTier } from "@models/productModels/priceModels/priceConfig/usagePriceConfig";
import { Infinite } from "@models/productModels/productEnums";
import { roundUsageToNearestBillingUnit } from "@utils/billingUtils/usageUtils/roundUsageToNearestBillingUnit";
import { addAllowanceToTiers } from "@utils/productV2Utils/productItemUtils/tierUtils";
import { nullish } from "@utils/utils";
import { Decimal } from "decimal.js";
/**
* Volume-based tier pricing: the ENTIRE usage is charged at the rate of
* whichever single tier it falls into (unlike graduated, which splits across bands).
*
* When `allowance` > 0, a free $0 tier is prepended and paid-tier boundaries
* are shifted up. If usage <= allowance, cost is $0. If usage exceeds the
* allowance, the ENTIRE usage (including the free portion) is charged at the
* matching paid tier's rate. This is intentional — volume pricing does not
* subtract included usage before applying the rate.
*/
export const volumeTiersToLineAmount = ({
tiers,
usage,
allowance = 0,
billingUnits = 1,
allowNegative = false,
}: {
tiers: UsageTier[];
usage: number;
allowance?: number;
billingUnits?: number;
allowNegative?: boolean;
}): number => {
if (nullish(tiers)) {
throw new Error(
"[volumeTiersToLineAmount] usage_tiers required for volume-based prices",
);
}
const isNegative = allowNegative && usage < 0;
const absoluteUsage = allowNegative ? Math.abs(usage) : Math.max(0, usage);
const roundedUsage = roundUsageToNearestBillingUnit({
usage: absoluteUsage,
billingUnits,
});
let amount = new Decimal(0);
const tiersWithAllowance = addAllowanceToTiers({
tiers,
allowance,
});
for (const tier of tiersWithAllowance) {
const isFinalTier = tier.to === Infinite || tier.to === -1;
const tierBoundary = isFinalTier ? Infinity : (tier.to as number);
// If the usage is within this current tier,
if (roundedUsage <= tierBoundary) {
// Assume the total amount is THIS tier's cost * the usage
const rate = new Decimal(tier.amount).div(billingUnits);
amount = rate.mul(roundedUsage);
// Add the flat fee for this tier if present
if (tier.flat_amount) {
amount = amount.plus(tier.flat_amount);
}
// Do not consider each tier individually, just use the total amount for this tier.
break;
}
}
const finalAmount = amount.toDecimalPlaces(10).toNumber();
return isNegative ? -finalAmount : finalAmount;
};

View File

@@ -1,7 +1,10 @@
import type { ApiBalanceBreakdownPrice } from "@api/customers/cusFeatures/apiBalanceV1.js";
import { BillingMethod } from "@api/products/components/billingMethod.js";
import type { FullCusEntWithFullCusProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct.js";
import type { UsagePriceConfig } from "@models/productModels/priceModels/priceConfig/usagePriceConfig.js";
import {
TierBehavior,
type UsagePriceConfig,
} from "@models/productModels/priceModels/priceConfig/usagePriceConfig.js";
import { cusEntsToMaxPurchase } from "@utils/cusEntUtils/convertCusEntUtils/cusEntsToMaxPurchase.js";
import { cusEntToCusPrice } from "@utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice.js";
import { customerPriceToBillingUnits } from "@utils/cusPriceUtils/convertCustomerPrice/customerPriceToBillingUnits.js";
@@ -10,6 +13,7 @@ import {
isPrepaidPrice,
isUsagePrice,
} from "@utils/productUtils/priceUtils/classifyPriceUtils.js";
import { addIncludedToTiers } from "@utils/productV2Utils/productItemUtils/tierUtils.js";
export const customerEntitlementToBalancePrice = ({
customerEntitlement,
@@ -33,6 +37,10 @@ export const customerEntitlementToBalancePrice = ({
// If usage price with multiple tiers, use tiers array
let amount: number | undefined;
let tiers: UsagePriceConfig["usage_tiers"] | undefined;
let tier_behavior: TierBehavior | undefined;
// Get the entitlement's allowance (included usage) to add to tier `to` values
const allowance = customerEntitlement.entitlement.allowance ?? 0;
if (isFixedPrice(price)) {
amount = price.config.amount;
@@ -41,13 +49,17 @@ export const customerEntitlementToBalancePrice = ({
if (usageTiers.length === 1) {
amount = usageTiers[0].amount;
} else {
tiers = usageTiers;
// Internal: tier `to` does NOT include included usage.
// User-facing: tier `to` INCLUDES included usage.
tiers = addIncludedToTiers({ tiers: usageTiers, included: allowance });
tier_behavior = price.tier_behavior ?? TierBehavior.Graduated;
}
}
return {
amount,
tiers,
tier_behavior,
billing_units: billingUnits,
billing_method: billingMethod,
max_purchase: maxPurchase,

View File

@@ -4,6 +4,18 @@ import type { Price } from "@models/productModels/priceModels/priceModels";
import { priceUtils } from "@utils/productUtils/priceUtils/index";
import { Decimal } from "decimal.js";
/**
* Computes the Stripe subscription-item quantity for a V2 prepaid price.
*
* Always returns total packs (purchased + allowance) for both graduated and
* volume pricing. The V2 Stripe price has a free leading tier that covers
* the allowance, so Stripe needs the full quantity to bill correctly.
*
* For **volume** prices: if total quantity exceeds the free tier, the ENTIRE
* quantity (including included) is charged at the matching paid tier's rate.
* This is the intended behavior — volume pricing does not subtract included
* usage before applying the tier rate.
*/
export const featureOptionsToV2StripeQuantity = ({
featureOptions,
price,
@@ -21,9 +33,7 @@ export const featureOptionsToV2StripeQuantity = ({
entitlement,
});
// 1. If no packs, return allowance
if (!packsExcludingAllowance) return allowanceInPacks;
// 2. Otherwise, return the total quantity
return new Decimal(packsExcludingAllowance).add(allowanceInPacks).toNumber();
};

View File

@@ -32,7 +32,10 @@ export const isFreeProduct = ({ prices }: { prices: Price[] }) => {
if ("usage_tiers" in price.config) {
const tiers = price.config.usage_tiers;
if (nullish(tiers) || tiers.length === 0) continue;
totalPrice += tiers.reduce((acc, tier) => acc + tier.amount, 0);
totalPrice += tiers.reduce(
(acc, tier) => acc + tier.amount + (tier.flat_amount ?? 0),
0,
);
} else {
totalPrice += price.config.amount;
}

View File

@@ -7,6 +7,7 @@ import {
type ProductItem,
UsageModel,
} from "../../models/productV2Models/productItemModels/productItemModels.js";
import { tiersToLineAmount } from "../billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.js";
import { isPriceItem } from "../productV2Utils/productItemUtils/getItemType.js";
import {
calculateProrationAmount,
@@ -16,6 +17,27 @@ import { nullish } from "../utils.js";
import { isFixedPrice } from "./priceUtils/classifyPriceUtils.js";
import { getBillingType } from "./priceUtils.js";
/**
* Prices an arbitrary quantity against a usage price's tier schedule.
* This is the single entry point for converting a raw unit count into dollars —
* it does NOT know whether that count represents included allowance, prepaid
* purchased units, or paid overage. The caller is responsible for passing the
* correct quantity for the billing context:
*
* - **Included usage** (free allowance): pass the size of the free bucket to
* find out its monetary value (used internally for proration math).
* - **Prepaid** (`UsageInAdvance`): pass the quantity the customer is buying
* upfront. The result is the immediate charge.
* - **Pay-per-use overage** (`UsageInArrear`): pass only the units consumed
* *above* any included free allowance — i.e. `totalUsage includedFree`.
* Do NOT pass raw total usage here; the caller must subtract the free tier first.
*
* @param price - The usage price whose config contains `usage_tiers` and
* optionally `billing_units`.
* @param quantity - The unit count to price. Must already be net of any free
* included allowance when called in an overage context.
* @returns Dollar amount as a number rounded to 10 decimal places.
*/
export const getAmountForQuantity = ({
price,
quantity,
@@ -24,46 +46,13 @@ export const getAmountForQuantity = ({
quantity: number;
}) => {
const config = price.config as UsagePriceConfig;
const billingUnits = config.billing_units || 1;
const roundedQuantity = new Decimal(quantity)
.div(billingUnits)
.ceil()
.mul(billingUnits)
.toNumber();
let lastTierTo: number = 0;
let amount = new Decimal(0);
let remainingUsage = new Decimal(roundedQuantity);
// console.log("Getting amount for quantity:", roundedQuantity);
// console.log("Usage tiers:", config.usage_tiers);
for (let i = 0; i < config.usage_tiers.length; i++) {
const tier = config.usage_tiers[i];
let usageWithinTier = new Decimal(0);
if (tier.to === Infinite || tier.to === -1) {
usageWithinTier = remainingUsage;
} else {
const tierUsage = new Decimal(tier.to).minus(lastTierTo);
usageWithinTier = Decimal.min(remainingUsage, tierUsage);
lastTierTo = tier.to;
}
const amountPerUnit = new Decimal(tier.amount).div(billingUnits);
const amountWithinTier = amountPerUnit.mul(usageWithinTier);
amount = amount.plus(amountWithinTier);
remainingUsage = remainingUsage.minus(usageWithinTier);
if (remainingUsage.lte(0)) {
break;
}
}
return amount.toDecimalPlaces(10).toNumber();
return tiersToLineAmount({
price,
overage: quantity,
billingUnits,
});
};
export const itemToInvoiceAmount = ({
@@ -87,6 +76,7 @@ export const itemToInvoiceAmount = ({
}
const price = {
tier_behavior: item.tier_behavior,
config: {
usage_tiers: item.tiers || [
{

View File

@@ -2,9 +2,10 @@ import type { Feature } from "@models/featureModels/featureModels";
import { Infinite } from "@models/productModels/productEnums";
import { BillingInterval } from "../../../models/productModels/intervals/billingInterval";
import type { FixedPriceConfig } from "../../../models/productModels/priceModels/priceConfig/fixedPriceConfig";
import type {
UsagePriceConfig,
UsageTier,
import {
TierBehavior,
type UsagePriceConfig,
type UsageTier,
} from "../../../models/productModels/priceModels/priceConfig/usagePriceConfig";
import { BillingType } from "../../../models/productModels/priceModels/priceEnums";
import type { Price } from "../../../models/productModels/priceModels/priceModels";
@@ -103,3 +104,9 @@ export const priceOnFeature = ({
price.config.feature_id === feature.id
);
};
export const isVolumePrice = (
price: Price,
): price is Price & { config: UsagePriceConfig } => {
return price.tier_behavior === TierBehavior.VolumeBased;
};

View File

@@ -7,6 +7,7 @@ import { priceToStripePrepaidV2Tiers } from "@utils/productUtils/priceUtils/conv
import { priceToStripeProductName } from "@utils/productUtils/priceUtils/convertPrice/priceToStripeProductName";
import { priceToStripeRecurringParams } from "@utils/productUtils/priceUtils/convertPrice/priceToStripeRecurringParams";
import type Stripe from "stripe";
import { priceToStripeTiersMode } from "./priceToStripeTiersMode";
export const priceToStripeCreatePriceParams = ({
price,
@@ -40,6 +41,7 @@ export const priceToStripeCreatePriceParams = ({
};
const tiers = priceToStripePrepaidV2Tiers({ price, entitlement, org });
const tiersMode = priceToStripeTiersMode({ price });
let priceAmountData = {};
if (tiers.length === 1) {
@@ -49,7 +51,7 @@ export const priceToStripeCreatePriceParams = ({
} else {
priceAmountData = {
billing_scheme: "tiered",
tiers_mode: "graduated",
tiers_mode: tiersMode,
tiers: tiers,
};
}

View File

@@ -11,6 +11,20 @@ import { atmnToStripeAmountDecimal } from "@utils/productUtils/priceUtils/conver
import { Decimal } from "decimal.js";
import type Stripe from "stripe";
/**
* Builds the Stripe tier array for a V2 prepaid price.
*
* For both graduated and volume prices with an allowance, a free $0 leading
* tier is inserted and all paid-tier boundaries are shifted up by the allowance.
* Stripe receives total packs (purchased + allowance) as the quantity.
*
* - **Graduated**: Stripe splits charges across tier bands. The free tier
* covers the included units at $0, so only units above the allowance incur cost.
* - **Volume**: if total quantity exceeds the free tier, the ENTIRE quantity
* (including the included portion) is charged at the matching paid tier's
* rate. This is intentional — volume pricing does not subtract included
* usage before applying the rate.
*/
export const priceToStripePrepaidV2Tiers = ({
price,
entitlement,
@@ -21,9 +35,11 @@ export const priceToStripePrepaidV2Tiers = ({
org: Organization;
}) => {
const config = price.config as UsagePriceConfig;
const tiers: Stripe.PriceCreateParams.Tier[] = [];
// If there is an allowance, first tier is free
// Insert a free leading tier and shift paid-tier boundaries up by the
// allowance. Applies to both graduated and volume pricing.
if (entitlement.allowance) {
tiers.push({
unit_amount_decimal: "0",
@@ -47,10 +63,19 @@ export const priceToStripePrepaidV2Tiers = ({
upTo = tier.to + entitlement.allowance;
}
tiers.push({
const stripeTier: Stripe.PriceCreateParams.Tier = {
unit_amount_decimal: stripeUnitAmountDecimal,
up_to: isFinalTier(tier) ? "inf" : upTo,
});
};
if (tier.flat_amount) {
stripeTier.flat_amount_decimal = atmnToStripeAmountDecimal({
amount: tier.flat_amount,
currency: orgToCurrency({ org }),
});
}
tiers.push(stripeTier);
}
// Divide all tiers by billing units

View File

@@ -0,0 +1,7 @@
import { type Price, TierBehavior } from "../../../..";
export const priceToStripeTiersMode = ({ price }: { price: Price }) => {
return price.tier_behavior === TierBehavior.VolumeBased
? "volume"
: "graduated";
};

View File

@@ -0,0 +1,55 @@
import type { FixedPriceConfig } from "../../../models/productModels/priceModels/priceConfig/fixedPriceConfig";
import type { UsagePriceConfig } from "../../../models/productModels/priceModels/priceConfig/usagePriceConfig";
import { BillingType } from "../../../models/productModels/priceModels/priceEnums";
import type { Price } from "../../../models/productModels/priceModels/priceModels";
import { tiersToLineAmount } from "../../billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount";
import { getBillingType } from "../priceUtils";
/**
* Returns the dollar cost for a given overage quantity on a price.
*
* "Overage" is the portion of usage that is **not** covered by included free
* allowance. How it is calculated depends on the billing model:
*
* - **Included usage (free tier):** no overage is charged; the free bucket is
* consumed first and this function is not called for those units.
* - **Prepaid** (`UsageInAdvance`): the customer purchased units upfront.
* Overage here is any consumption beyond what was prepaid — typically priced
* via `tiersToLineAmount` at the end of the period, not this function.
* - **Pay-per-use** (`UsageInArrear`): overage is `totalUsage includedFree`.
* Pass that net quantity as `overage`; this function prices it against the
* graduated tier schedule.
* - **Fixed** (`FixedCycle` / `OneOff`): no usage tiers apply; returns the flat
* `config.amount` regardless of the `overage` argument.
*
* @param price - The price to evaluate. Determines billing type and tier schedule.
* @param overage - Net units consumed above the free/prepaid allowance.
* Ignored for fixed-price billing types. Must be pre-subtracted by the caller.
* @returns Dollar amount for the overage, or the flat price amount for fixed prices.
*/
export const getPriceForOverage = ({
price,
overage,
}: {
price: Price;
overage?: number;
}) => {
const usageConfig = price.config as UsagePriceConfig;
const billingType = getBillingType(usageConfig);
if (
billingType === BillingType.FixedCycle ||
billingType === BillingType.OneOff
) {
const config = price.config as FixedPriceConfig;
return config.amount;
}
const billingUnits = usageConfig.billing_units || 1;
return tiersToLineAmount({
price,
overage: overage!,
billingUnits,
});
};

View File

@@ -1,18 +1,22 @@
import { priceIsTieredOneOff } from "@utils/productUtils/priceUtils/classifyPrice/priceIsTieredOneOff.js";
import { priceToAllowanceInPacks } from "@utils/productUtils/priceUtils/convertPrice/priceToAllowanceInPacks.js";
import { priceToStripeCreatePriceParams } from "@utils/productUtils/priceUtils/convertPrice/priceToStripeCreatePriceParams.js";
import { priceToStripeTiersMode } from "./convertPrice/priceToStripeTiersMode.js";
export * from "./classifyPrice/priceIsTieredOneOff.js";
export * from "./classifyPriceUtils.js";
export * from "./convertAmountUtils.js";
export * from "./convertPrice/priceToStripeTiersMode.js";
export * from "./convertPriceUtils.js";
export * from "./findPrice/findPriceByFeatureId.js";
export * from "./formatPriceUtils.js";
export * from "./getPriceForOverage.js";
export const priceUtils = {
convert: {
toAllowanceInPacks: priceToAllowanceInPacks,
toStripeCreatePriceParams: priceToStripeCreatePriceParams,
toStripeTiersMode: priceToStripeTiersMode,
},
isTieredOneOff: priceIsTieredOneOff,

View File

@@ -81,9 +81,11 @@ export const findSimilarItem = ({
return null;
};
type TierLike = { to: number | "inf"; amount: number; flat_amount?: number | null };
const tiersAreSame = (
tiers1: UsageTier[] | null,
tiers2: UsageTier[] | null,
tiers1: TierLike[] | null,
tiers2: TierLike[] | null,
) => {
if (!tiers1 && !tiers2) {
return true;
@@ -98,8 +100,10 @@ const tiersAreSame = (
}
return tiers1.every(
(tier: UsageTier, index: number) =>
tier.amount === tiers2[index].amount && tier.to === tiers2[index].to,
(tier, index) =>
tier.amount === tiers2[index].amount &&
tier.to === tiers2[index].to &&
(tier.flat_amount ?? 0) === (tiers2[index].flat_amount ?? 0),
);
};
@@ -306,6 +310,10 @@ export const featurePriceItemsAreSame = ({
condition: tiersAreSame(item1.tiers || null, item2.tiers || null),
message: `Tiers different`,
},
tier_behavior: {
condition: item1.tier_behavior == item2.tier_behavior,
message: `Tiers type different: ${item1.tier_behavior} != ${item2.tier_behavior}`,
},
billing_units: {
condition: item1.billing_units == item2.billing_units,
message: `Billing units different: ${item1.billing_units} !== ${item2.billing_units}`,

View File

@@ -68,16 +68,25 @@ function formatTierPricing({
}): string {
if (tiers.length === 0) return "Tiered";
const firstPricedTier = tiers.find((tier) => tier.amount > 0);
const firstPricedTier = tiers.find(
(tier) => tier.amount > 0 || (tier.flat_amount ?? 0) > 0,
);
if (!firstPricedTier) {
const lastTier = tiers[tiers.length - 1];
if (lastTier.to === "inf") return "Unlimited free";
return `${lastTier.to} free`;
}
return billingUnits > 1
? `$${firstPricedTier.amount} per ${billingUnits}`
: `$${firstPricedTier.amount} per unit`;
const perUnit =
billingUnits > 1
? `$${firstPricedTier.amount} per ${billingUnits}`
: `$${firstPricedTier.amount} per unit`;
if (firstPricedTier.flat_amount && firstPricedTier.flat_amount > 0) {
return `${perUnit} + $${firstPricedTier.flat_amount} flat`;
}
return perUnit;
}
/** Generates edit items for product item additions, removals, and modifications */

View File

@@ -41,6 +41,7 @@ export const productItemToPlanItemParamsV1 = ({
billing_units: planItemV1.price.billing_units,
billing_method: planItemV1.price.billing_method,
max_purchase: planItemV1.price.max_purchase ?? undefined,
tier_behavior: planItemV1.price.tier_behavior ?? undefined,
}
: undefined,
proration: planItemV1.proration

View File

@@ -20,6 +20,7 @@ import { toApiFeature } from "../../../featureUtils.js";
import { getProductItemDisplay } from "../../../productDisplayUtils.js";
import { isFeaturePriceItem } from "../getItemType.js";
import { itemToBillingInterval } from "../itemIntervalUtils.js";
import { addIncludedToTiers } from "../tierUtils.js";
import { itemIntvToResetIntv } from "./planItemIntervals.js";
const itemToReset = ({
@@ -62,12 +63,17 @@ const itemToPlanFeaturePrice = ({
const price =
item.tiers && item.tiers.length === 1 ? item.tiers[0].amount : item.price;
// Internal: tier `to` does NOT include included usage.
// V1 API: tier `to` INCLUDES included usage.
const tiers =
item.tiers && item.tiers.length > 1
? item.tiers.map((tier) => ({
to: tier.to,
amount: tier.amount,
}))
? addIncludedToTiers({
tiers: item.tiers.map((tier) => ({
to: tier.to,
amount: tier.amount,
})),
included: includedUsage,
})
: undefined;
// V1 schema uses billing_method, NOT usage_model
@@ -79,6 +85,7 @@ const itemToPlanFeaturePrice = ({
return {
amount: price ?? undefined,
tiers: tiers,
tier_behavior: item.tier_behavior ?? undefined,
interval: itemToBillingInterval({ item }),
interval_count:

View File

@@ -80,6 +80,7 @@ export const toFeaturePriceItem = ({
return {
amount: tier.amount,
to: tier.to === -1 ? TierInfinite : tier.to,
flat_amount: tier.flat_amount,
};
});
@@ -105,6 +106,7 @@ export const toFeaturePriceItem = ({
price: null,
tiers,
billing_units: config.billing_units,
tier_behavior: price.tier_behavior ?? null,
entity_feature_id: ent.entity_feature_id,
reset_usage_when_enabled: !ent.carry_from_previous,

View File

@@ -0,0 +1,68 @@
import type { UsageTier } from "@models/productModels/priceModels/priceConfig/usagePriceConfig";
import { Decimal } from "decimal.js";
/**
* Subtracts included usage from tier `to` values (user-facing → internal).
* Infinite (`"inf"`) tier `to` values are left unchanged.
*/
export const subtractIncludedFromTiers = ({
tiers,
included,
}: {
tiers: UsageTier[];
included: number;
}): UsageTier[] => {
if (included === 0) return tiers;
return tiers.map((tier) => ({
...tier,
to:
typeof tier.to === "number" && tier.to > 0
? new Decimal(tier.to).minus(included).toNumber()
: tier.to,
}));
};
/**
* Adds included usage to tier `to` values (internal → user-facing).
* Infinite (`"inf"`) tier `to` values are left unchanged.
*/
export const addIncludedToTiers = ({
tiers,
included,
}: {
tiers: UsageTier[];
included: number;
}): UsageTier[] => {
if (included === 0) return tiers;
return tiers.map((tier) => ({
...tier,
to:
typeof tier.to === "number" && tier.to > 0
? new Decimal(tier.to).plus(included).toNumber()
: tier.to,
}));
};
export const addAllowanceToTiers = ({
tiers,
allowance,
}: {
tiers: UsageTier[];
allowance: number;
}): UsageTier[] => {
if (allowance === 0) return tiers;
const firstTier: UsageTier = {
to: allowance,
amount: 0,
};
const tiersWithAllowance = addIncludedToTiers({
tiers,
included: allowance,
});
return [firstTier, ...tiersWithAllowance];
};

View File

@@ -16,7 +16,11 @@ const tiersMatch = (oldTiers: UsageTier[], newTiers: UsageTier[]): boolean => {
return oldTiers.every((oldTier, index) => {
const newTier = newTiers[index];
return oldTier.to === newTier.to && oldTier.amount === newTier.amount;
return (
oldTier.to === newTier.to &&
oldTier.amount === newTier.amount &&
(oldTier.flat_amount ?? 0) === (newTier.flat_amount ?? 0)
);
});
};

View File

@@ -38,7 +38,9 @@ function tiersAreEqual({
return tiersA.every(
(tier, index) =>
tier.amount === tiersB[index]?.amount && tier.to === tiersB[index]?.to,
tier.amount === tiersB[index]?.amount &&
tier.to === tiersB[index]?.to &&
(tier.flat_amount ?? 0) === (tiersB[index]?.flat_amount ?? 0),
);
}

View File

@@ -1,5 +1,9 @@
import { FeatureType } from "@autumn/shared";
import { PencilSimpleIcon } from "@phosphor-icons/react";
import { FeatureType, TierBehavior } from "@autumn/shared";
import {
DropSimpleIcon,
PencilSimpleIcon,
RulerIcon,
} from "@phosphor-icons/react";
import { useState } from "react";
import { IconButton } from "@/components/v2/buttons/IconButton";
import {
@@ -7,6 +11,13 @@ import {
useProduct,
useSheet,
} from "@/components/v2/inline-custom-plan-editor/PlanEditorContext";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/v2/selects/Select";
import { SheetHeader, SheetSection } from "@/components/v2/sheets/InlineSheet";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { getFeature } from "@/utils/product/entitlementUtils";
@@ -27,7 +38,7 @@ export function EditPlanFeatureSheet({
}: {
isOnboarding?: boolean;
}) {
const { item } = useProductItemContext();
const { item, setItem } = useProductItemContext();
const { features, refetch } = useFeaturesQuery();
const { product, setProduct } = useProduct();
const { setInitialItem } = useSheet();
@@ -107,7 +118,62 @@ export function EditPlanFeatureSheet({
</SheetSection>
{isFeaturePrice && (
<SheetSection title="Price" className="space-y-8">
<SheetSection
title={
item.tiers && item.tiers.length > 1 ? (
<div className="flex items-center justify-between w-full">
<span>Price</span>
<Select
value={item.tier_behavior ?? TierBehavior.Graduated}
onValueChange={(val) =>
setItem({
...item,
tier_behavior: val as TierBehavior,
})
}
>
<SelectTrigger className="w-40 h-6 text-xs" size="sm">
<SelectValue>
{item.tier_behavior === TierBehavior.VolumeBased ? (
<span className="flex items-center gap-2">
<DropSimpleIcon
className="size-3.5"
weight="regular"
/>
Volume-based
</span>
) : (
<span className="flex items-center gap-2">
<RulerIcon
className="size-3.5"
weight="regular"
/>
Graduated
</span>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={TierBehavior.Graduated}>
<RulerIcon className="size-4" weight="regular" />
Graduated
</SelectItem>
<SelectItem value={TierBehavior.VolumeBased}>
<DropSimpleIcon
className="size-4"
weight="regular"
/>
Volume-based
</SelectItem>
</SelectContent>
</Select>
</div>
) : (
"Price"
)
}
className="space-y-3"
>
<div>
<PriceTiers />
<UsageReset showBillingLabel={true} />

View File

@@ -1,6 +1,11 @@
import { Infinite, type PriceTier } from "@autumn/shared";
import {
Infinite,
type PriceTier,
TierBehavior,
UsageModel,
} from "@autumn/shared";
import { PlusIcon, TrashSimpleIcon } from "@phosphor-icons/react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { Input } from "@/components/v2/inputs/Input";
import {
@@ -87,6 +92,17 @@ export function PriceTiers() {
);
const [isEditing, setIsEditing] = useState<Record<string, boolean>>({});
// Auto-select prepaid when volume-based is active with multiple tiers
useEffect(() => {
if (
item?.tier_behavior === TierBehavior.VolumeBased &&
(item?.tiers?.length ?? 0) > 1 &&
item?.usage_model !== UsageModel.Prepaid
) {
setItem({ ...item, usage_model: UsageModel.Prepaid });
}
}, [item?.tier_behavior, item?.tiers?.length]);
if (!item) return null;
const tiers = item.tiers || [];
@@ -235,7 +251,7 @@ export function PriceTiers() {
})}
<IconButton
variant="muted"
className="w-full text-t3 text-xs mb-2 mt-1"
className="w-full text-t3 text-xs"
size="sm"
onClick={() => addTier({ item, setItem })}
icon={<PlusIcon size={8} />}

View File

@@ -3,6 +3,7 @@ import {
itemToBillingInterval,
nullish,
ProductItemInterval,
TierBehavior,
UsageModel,
} from "@autumn/shared";
import { FormLabel } from "@/components/v2/form/FormLabel";
@@ -16,6 +17,9 @@ export function PricedFeatureSettings() {
if (!item) return null;
const isOneOff = itemToBillingInterval({ item }) === BillingInterval.OneOff;
const isVolumeBased =
item.tier_behavior === TierBehavior.VolumeBased &&
(item.tiers?.length ?? 0) > 1;
const handleUsageModelChange = (value: string) => {
const usageModel = value as UsageModel;
@@ -30,7 +34,7 @@ export function PricedFeatureSettings() {
};
return (
<div className="mt-3">
<div>
<FormLabel>Billing Method</FormLabel>
<RadioGroup
value={item.usage_model}
@@ -42,7 +46,11 @@ export function PricedFeatureSettings() {
label="Usage-based"
description={"Bill for how much the customer uses"}
disabledReason={
isOneOff ? "Usage based prices must have an interval." : undefined
isVolumeBased
? "Volume-based pricing requires prepaid billing."
: isOneOff
? "Usage based prices must have an interval."
: undefined
}
/>
<AreaRadioGroupItem