volume prepaid 2: handling included usage properly...

This commit is contained in:
John Yeo
2026-02-25 22:18:33 +00:00
parent 7527e0d5f4
commit b8a7f8081c
30 changed files with 1189 additions and 216 deletions

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

@@ -5,7 +5,6 @@ import {
priceToEnt,
priceUtils,
RecaseError,
TierBehavior,
type UsagePriceConfig,
} from "@autumn/shared";
import { PriceService } from "@server/internal/products/prices/PriceService";
@@ -31,26 +30,7 @@ export const createStripePrepaidPriceV2 = async ({
entitlements: product.entitlements,
});
const isVolume = price.tier_behavior === TierBehavior.VolumeBased;
// A separate V2 Stripe price is only needed for graduated prices that have
// an allowance. In that case, priceToStripePrepaidV2Tiers encodes the free
// units as a $0 leading tier and shifts all paid-tier boundaries up by the
// allowance, so Stripe's graduated splitting produces the right charge.
//
// In every other case stripe_prepaid_price_v2_id just points at the same
// Stripe price as stripe_price_id:
//
// No allowance — the V2 price would be identical to V1 (nothing to shift),
// so there is no point creating a second Stripe object.
//
// Volume + allowance — the free-tier-offset trick does not work with
// Stripe's volume mode because volume charges the *entire* quantity at
// one rate, so a $0 leading tier corrupts the math. Instead the allowance
// is tracked purely by Autumn; Stripe only ever sees the purchased packs
// (see featureOptionsToV2StripeQuantity). The V1 price tiers are already
// correct for that, so reuse it.
if (!entitlement?.allowance || isVolume) {
if (!entitlement?.allowance) {
price.config = {
...(price.config as UsagePriceConfig),
stripe_prepaid_price_v2_id: price.config.stripe_price_id,

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

@@ -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,10 +39,13 @@ const allGroups: TestGroup[] = [
billing,
billingV1,
billingV2,
prepaidVolume,
crud,
webhooks,
advanced,
misc,
prepaidVolume,
];
export const getAllGroups = (): TestGroup[] => allGroups;

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

@@ -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

@@ -38,6 +38,72 @@ const TIERS = [
// 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
@@ -67,7 +133,11 @@ test.concurrent(`${chalk.yellowBright("vol-edge: 500 units (exact tier 1 boundar
});
// 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 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 })],
@@ -174,7 +244,11 @@ test.concurrent(`${chalk.yellowBright("vol-edge: 501 units (ceil to 600) → vol
});
// 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 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 })],
@@ -275,7 +349,7 @@ test.concurrent(`${chalk.yellowBright("vol-edge: includedUsage=200, qty=800 →
const quantity = 800;
const includedUsage = 200;
// Purchased = 800 - 200 = 600 units, all in tier 2
const purchasedUnits = quantity - includedUsage;
const purchasedUnits = quantity;
// Volume: 6 packs × $5 = $30
const volExpectedPrepaid = (purchasedUnits / BILLING_UNITS) * 5;
@@ -295,7 +369,11 @@ test.concurrent(`${chalk.yellowBright("vol-edge: includedUsage=200, qty=800 →
});
// 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 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 })],

View File

@@ -208,7 +208,7 @@ test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: 300 units, 100 inc
const quantity = 300;
const includedUsage = 100;
// After free pack: 200 units = 2 packs in tier 1 → 2 × $10 = $20
const expectedPrepaidCost = ((quantity - includedUsage) / BILLING_UNITS) * 10;
const expectedPrepaidCost = (quantity / BILLING_UNITS) * 10;
const volumeItem = items.volumePrepaidMessages({
includedUsage,
@@ -358,9 +358,7 @@ test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: 4 tiers, 100 inclu
];
// Paid packs after free: (900 - 100) / 100 = 8 packs
// 8 packs falls into tier 3 (800 paid units = 501-1000 range)
// Volume pricing: all 8 packs at $7 = $56
const expectedPrepaidCost = 8 * 7;
const expectedPrepaidCost = (quantity / BILLING_UNITS) * 7;
const volumeItem = items.volumePrepaidMessages({
includedUsage,

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

@@ -54,7 +54,7 @@ const VOLUME_TIERS = [
// Invoice total: $20 (base) + $30 (prepaid) = $50
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("legacy-new-volume 1: purchased=300, no allowance, tier 1 → $50")}`, async () => {
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;
@@ -122,7 +122,7 @@ test.concurrent(`${chalk.yellowBright("legacy-new-volume 1: purchased=300, no al
// Invoice total: $20 (base) + $40 (prepaid) = $60
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("legacy-new-volume 2: purchased=800, no allowance, tier 2 → $60 (graduated would be $65)")}`, async () => {
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;
@@ -191,13 +191,14 @@ test.concurrent(`${chalk.yellowBright("legacy-new-volume 2: purchased=800, no al
// Invoice total: $20 (base) + $20 (prepaid) = $40
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("legacy-new-volume 3: purchased=200, allowance=100, tier 1 → $40 (balance=300)")}`, async () => {
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 / BILLING_UNITS) * 10;
const expectedPrepaid =
(purchasedQuantity + includedUsage / BILLING_UNITS) * 10;
const volumeItem = items.volumePrepaidMessages({
includedUsage,
@@ -263,13 +264,14 @@ test.concurrent(`${chalk.yellowBright("legacy-new-volume 3: purchased=200, allow
// Invoice total: $20 (base) + $35 (prepaid) = $55
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("legacy-new-volume 4: purchased=700, allowance=100, tier 2 → $55 (graduated would be $60, balance=800)")}`, async () => {
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 / BILLING_UNITS) * 5;
const expectedPrepaid =
(purchasedQuantity + includedUsage / BILLING_UNITS) * 5;
const volumeItem = items.volumePrepaidMessages({
includedUsage,
@@ -332,7 +334,7 @@ test.concurrent(`${chalk.yellowBright("legacy-new-volume 4: purchased=700, allow
// Invoice total: $20 (base only)
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("legacy-new-volume 5: purchased=0, allowance=100 → $20 base only (balance=100)")}`, async () => {
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;

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,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

@@ -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

@@ -92,7 +92,7 @@ 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(),

View File

@@ -50,7 +50,7 @@ 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(),
@@ -163,6 +163,41 @@ export const CreatePlanItemParamsV1Schema = z
});
}
}
if (ctx.value.price?.tiers) {
console.log("Tiers:", ctx.value.price?.tiers);
console.log("Tier behavior:", ctx.value.price?.tier_behavior);
console.log("Billing method:", ctx.value.price?.billing_method);
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,7 @@ 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,

View File

@@ -3,6 +3,7 @@ import { billingMethodToUsageModel } from "@api/products/components/mappers/bill
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";
@@ -23,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,
@@ -37,8 +44,8 @@ export function planItemV1ToV0({
price: price
? {
amount: price.amount,
tiers: price.tiers,
tier_behavior: price.tiers?.length
tiers: internalTiers,
tier_behavior: internalTiers?.length
? (price.tier_behavior ?? TierBehavior.Graduated)
: undefined,
interval: price.interval,

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,14 @@ export const usagePriceToLineItem = ({
overage = cusEntToInvoiceOverage({ cusEnt });
}
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 +95,7 @@ export const usagePriceToLineItem = ({
const amount = priceToLineAmount({
price,
overage,
allowance: allowance,
});
// 5. Get stripe price / product IDs

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

@@ -27,10 +27,12 @@ import { graduatedTiersToLineAmount } from "./graduatedTiersToLineAmount";
export const tiersToLineAmount = ({
price,
overage,
allowance = 0,
billingUnits = 1,
}: {
price: Price;
overage: number;
allowance?: number;
billingUnits?: number;
}): number => {
const tiers = price.config.usage_tiers;
@@ -48,6 +50,7 @@ export const tiersToLineAmount = ({
usage: overage,
billingUnits,
allowNegative: true,
allowance,
});
}

View File

@@ -1,17 +1,20 @@
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";
export const volumeTiersToLineAmount = ({
tiers,
usage,
allowance = 0,
billingUnits = 1,
allowNegative = false,
}: {
tiers: UsageTier[];
usage: number;
allowance?: number;
billingUnits?: number;
allowNegative?: boolean;
}): number => {
@@ -35,8 +38,14 @@ export const volumeTiersToLineAmount = ({
// if the usage is less than the tier.to,
// add the tier.amount * usage to the amount
// then break
// else keep going.
for (const tier of tiers) {
// else keep going.-
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);

View File

@@ -13,6 +13,7 @@ import {
isPrepaidPrice,
isUsagePrice,
} from "@utils/productUtils/priceUtils/classifyPriceUtils.js";
import { addIncludedToTiers } from "@utils/productV2Utils/productItemUtils/tierUtils.js";
export const customerEntitlementToBalancePrice = ({
customerEntitlement,
@@ -38,6 +39,9 @@ export const customerEntitlementToBalancePrice = ({
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;
} else if (isUsagePrice({ price })) {
@@ -45,7 +49,9 @@ 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;
}
}

View File

@@ -1,6 +1,5 @@
import type { FeatureOptions } from "@models/cusProductModels/cusProductModels";
import type { EntitlementWithFeature } from "@models/productModels/entModels/entModels";
import { TierBehavior } from "@models/productModels/priceModels/priceConfig/usagePriceConfig";
import type { Price } from "@models/productModels/priceModels/priceModels";
import { priceUtils } from "@utils/productUtils/priceUtils/index";
import { Decimal } from "decimal.js";
@@ -29,14 +28,6 @@ export const featureOptionsToV2StripeQuantity = ({
const packsExcludingAllowance =
featureOptions?.upcoming_quantity ?? featureOptions?.quantity;
const isVolume = price.tier_behavior === TierBehavior.VolumeBased;
// Volume: the Stripe price has no free-tier offset, so only send purchased
// packs. Autumn tracks the allowance internally.
if (isVolume) {
return packsExcludingAllowance ?? 0;
}
const allowanceInPacks = priceUtils.convert.toAllowanceInPacks({
price,
entitlement,

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

@@ -1,9 +1,6 @@
import type { Organization } from "@models/orgModels/orgTable";
import type { Entitlement } from "@models/productModels/entModels/entModels";
import {
TierBehavior,
type UsagePriceConfig,
} from "@models/productModels/priceModels/priceConfig/usagePriceConfig";
import { type UsagePriceConfig } from "@models/productModels/priceModels/priceConfig/usagePriceConfig";
import type { Price } from "@models/productModels/priceModels/priceModels";
import { orgToCurrency } from "@utils/orgUtils/convertOrgUtils";
import {
@@ -39,14 +36,14 @@ export const priceToStripePrepaidV2Tiers = ({
org: Organization;
}) => {
const config = price.config as UsagePriceConfig;
const isVolume = price.tier_behavior === TierBehavior.VolumeBased;
const tiers: Stripe.PriceCreateParams.Tier[] = [];
// Graduated + allowance: insert a free leading tier and shift paid-tier
// boundaries up by the allowance so Stripe's per-tier splitting gives the
// right amount. Volume prices skip this — the allowance is handled outside
// of Stripe (see featureOptionsToV2StripeQuantity).
if (!isVolume && entitlement.allowance) {
if (entitlement.allowance) {
tiers.push({
unit_amount_decimal: "0",
up_to: entitlement.allowance,
@@ -65,7 +62,7 @@ export const priceToStripePrepaidV2Tiers = ({
});
let upTo = tier.to;
if (!isVolume && isNotFinalTier(tier) && entitlement.allowance) {
if (isNotFinalTier(tier) && entitlement.allowance) {
upTo = tier.to + entitlement.allowance;
}

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

View File

@@ -0,0 +1,72 @@
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) => ({
amount: tier.amount,
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 = <
T extends { to: number | string; amount: number },
>({
tiers,
included,
}: {
tiers: T[];
included: number;
}): T[] => {
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 = <
T extends { to: number | "inf"; amount: number },
>({
tiers,
allowance,
}: {
tiers: T[];
allowance: number;
}): T[] => {
if (allowance === 0) return tiers;
const firstTier = {
to: allowance,
amount: 0,
} as T;
const tiersWithAllowance = addIncludedToTiers({
tiers,
included: allowance,
});
return [firstTier, ...tiersWithAllowance];
};