volume prepaid 2: handling included usage properly...
This commit is contained in:
22
prepaid-volume.md
Normal file
22
prepaid-volume.md
Normal 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
|
||||
```
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
27
server/tests/_groups/domains/billing/prepaidVolume.ts
Normal file
27
server/tests/_groups/domains/billing/prepaidVolume.ts
Normal 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",
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
|
||||
240
server/tests/integration/balances/check/check-prepaid.test.ts
Normal file
240
server/tests/integration/balances/check/check-prepaid.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 })],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
236
server/tests/integration/crud/plans/get-plan-advanced.test.ts
Normal file
236
server/tests/integration/crud/plans/get-plan-advanced.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
72
shared/utils/productV2Utils/productItemUtils/tierUtils.ts
Normal file
72
shared/utils/productV2Utils/productItemUtils/tierUtils.ts
Normal 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];
|
||||
};
|
||||
Reference in New Issue
Block a user