diff --git a/prepaid-volume.md b/prepaid-volume.md new file mode 100644 index 000000000..f9b2ff41c --- /dev/null +++ b/prepaid-volume.md @@ -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 +``` diff --git a/server/src/external/stripe/createStripePrice/createStripePrepaidPriceV2.ts b/server/src/external/stripe/createStripePrice/createStripePrepaidPriceV2.ts index 9191c83a4..84560f799 100644 --- a/server/src/external/stripe/createStripePrice/createStripePrepaidPriceV2.ts +++ b/server/src/external/stripe/createStripePrice/createStripePrepaidPriceV2.ts @@ -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, diff --git a/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts b/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts index 1a3e3a6b9..07062585b 100644 --- a/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts +++ b/server/src/internal/customers/attach/attachUtils/handleAttachErrors.ts @@ -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, diff --git a/server/tests/_groups/domains/billing/prepaidVolume.ts b/server/tests/_groups/domains/billing/prepaidVolume.ts new file mode 100644 index 000000000..b4d330ae3 --- /dev/null +++ b/server/tests/_groups/domains/billing/prepaidVolume.ts @@ -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", + ], +}; diff --git a/server/tests/_groups/index.ts b/server/tests/_groups/index.ts index 9602b49ef..05adee934 100644 --- a/server/tests/_groups/index.ts +++ b/server/tests/_groups/index.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; diff --git a/server/tests/balances/check/prepaid/check-prepaid1.test.ts b/server/tests/balances/check/prepaid/check-prepaid1.test.ts deleted file mode 100644 index 82c027317..000000000 --- a/server/tests/balances/check/prepaid/check-prepaid1.test.ts +++ /dev/null @@ -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, - }); - }); -}); diff --git a/server/tests/integration/balances/check/check-balance-price.test.ts b/server/tests/integration/balances/check/check-balance-price.test.ts index 4dfae0cd9..2e7d846ef 100644 --- a/server/tests/integration/balances/check/check-balance-price.test.ts +++ b/server/tests/integration/balances/check/check-balance-price.test.ts @@ -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 }, ]); }); diff --git a/server/tests/integration/balances/check/check-prepaid.test.ts b/server/tests/integration/balances/check/check-prepaid.test.ts new file mode 100644 index 000000000..688cc9bdb --- /dev/null +++ b/server/tests/integration/balances/check/check-prepaid.test.ts @@ -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, + }); +}); diff --git a/server/tests/integration/billing/attach/checkout/stripe-checkout/stripe-checkout-prepaid.test.ts b/server/tests/integration/billing/attach/checkout/stripe-checkout/stripe-checkout-prepaid.test.ts index 592e31822..a7905e356 100644 --- a/server/tests/integration/billing/attach/checkout/stripe-checkout/stripe-checkout-prepaid.test.ts +++ b/server/tests/integration/billing/attach/checkout/stripe-checkout/stripe-checkout-prepaid.test.ts @@ -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(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, + }); +}); diff --git a/server/tests/integration/billing/attach/edge-cases/v1-v2-compatibility/prepaid/attach-prepaid-volume-edge-cases.test.ts b/server/tests/integration/billing/attach/edge-cases/v1-v2-compatibility/prepaid/attach-prepaid-volume-edge-cases.test.ts index 39640d0a2..b48f6f158 100644 --- a/server/tests/integration/billing/attach/edge-cases/v1-v2-compatibility/prepaid/attach-prepaid-volume-edge-cases.test.ts +++ b/server/tests/integration/billing/attach/edge-cases/v1-v2-compatibility/prepaid/attach-prepaid-volume-edge-cases.test.ts @@ -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(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 })], diff --git a/server/tests/integration/billing/attach/new-plan/attach-prepaid-volume.test.ts b/server/tests/integration/billing/attach/new-plan/attach-prepaid-volume.test.ts index 84e9d6770..01bd2b4c9 100644 --- a/server/tests/integration/billing/attach/new-plan/attach-prepaid-volume.test.ts +++ b/server/tests/integration/billing/attach/new-plan/attach-prepaid-volume.test.ts @@ -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, diff --git a/server/tests/integration/billing/attach/v2-params/v2-customize.test.ts b/server/tests/integration/billing/attach/v2-params/v2-customize.test.ts index 870925245..eebce8b00 100644 --- a/server/tests/integration/billing/attach/v2-params/v2-customize.test.ts +++ b/server/tests/integration/billing/attach/v2-params/v2-customize.test.ts @@ -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(params); + expect(preview.total).toBe(PREPAID_PRICE); + + await autumnV2.billing.attach(params); + + const customer = await autumnV1.customers.get(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, + }); +}); diff --git a/server/tests/integration/billing/legacy/attach/new/legacy-new-volume.test.ts b/server/tests/integration/billing/legacy/attach/new/legacy-new-volume.test.ts index a057ce8dc..ad61b33bc 100644 --- a/server/tests/integration/billing/legacy/attach/new/legacy-new-volume.test.ts +++ b/server/tests/integration/billing/legacy/attach/new/legacy-new-volume.test.ts @@ -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; diff --git a/server/tests/integration/crud/plans/create-plan-advanced.test.ts b/server/tests/integration/crud/plans/create-plan-advanced.test.ts index 1b0c273a9..d025f0ec2 100644 --- a/server/tests/integration/crud/plans/create-plan-advanced.test.ts +++ b/server/tests/integration/crud/plans/create-plan-advanced.test.ts @@ -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(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(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(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(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(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(productId); + expect(v1_2.items[0].tiers).toEqual([ + { to: 500, amount: 10 }, + { to: TierInfinite, amount: 5 }, + ]); +}); + // ═══════════════════════════════════════════════════════════════════════════════ // VALIDATION / REJECTION TESTS // ═══════════════════════════════════════════════════════════════════════════════ diff --git a/server/tests/integration/crud/plans/get-plan-advanced.test.ts b/server/tests/integration/crud/plans/get-plan-advanced.test.ts new file mode 100644 index 000000000..28a8f3eb3 --- /dev/null +++ b/server/tests/integration/crud/plans/get-plan-advanced.test.ts @@ -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({ + 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(productId); + expect(v2_1.items[0].price!.tiers).toEqual(expectedTiers); + + // V2.0: tiers unchanged (no included to subtract) + const v2 = await autumnV2.products.get(productId); + expect(v2.features[0].price!.tiers).toEqual(expectedTiers); + + // V1.2: tiers unchanged + const v1_2 = await autumnV1_2.products.get(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({ + 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(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(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(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({ + 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(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(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(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({ + 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(productId); + expect(v2_1.items[0].price!.tiers).toEqual(expectedTiers); + + // V2.0 GET: same tiers + const v2 = await autumnV2.products.get(productId); + expect(v2.features[0].price!.tiers).toEqual(expectedTiers); + + // V1.2 GET: same tiers + const v1_2 = await autumnV1_2.products.get(productId); + expect(v1_2.items[0].tiers).toEqual(expectedTiers); +}); diff --git a/server/tests/utils/fixtures/itemsV2.ts b/server/tests/utils/fixtures/itemsV2.ts index 3dc792c3e..696cf48df 100644 --- a/server/tests/utils/fixtures/itemsV2.ts +++ b/server/tests/utils/fixtures/itemsV2.ts @@ -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; diff --git a/shared/api/products/items/apiPlanItemV1.ts b/shared/api/products/items/apiPlanItemV1.ts index 92403cdf1..78d7867ff 100644 --- a/shared/api/products/items/apiPlanItemV1.ts +++ b/shared/api/products/items/apiPlanItemV1.ts @@ -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(), diff --git a/shared/api/products/items/crud/createPlanItemParamsV1.ts b/shared/api/products/items/crud/createPlanItemParamsV1.ts index 8f1ae4da9..523336c43 100644 --- a/shared/api/products/items/crud/createPlanItemParamsV1.ts +++ b/shared/api/products/items/crud/createPlanItemParamsV1.ts @@ -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", diff --git a/shared/api/products/items/mappers/planItemParamsV1ToPlanItemV0.ts b/shared/api/products/items/mappers/planItemParamsV1ToPlanItemV0.ts index 5b64c82f1..eb764c8bb 100644 --- a/shared/api/products/items/mappers/planItemParamsV1ToPlanItemV0.ts +++ b/shared/api/products/items/mappers/planItemParamsV1ToPlanItemV0.ts @@ -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, diff --git a/shared/api/products/items/mappers/planItemV1ToV0.ts b/shared/api/products/items/mappers/planItemV1ToV0.ts index 391f3392c..82e16cfef 100644 --- a/shared/api/products/items/mappers/planItemV1ToV0.ts +++ b/shared/api/products/items/mappers/planItemV1ToV0.ts @@ -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, diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts index b8360e3a4..723f9468d 100644 --- a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts +++ b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts @@ -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 diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/priceToLineAmount.ts b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/priceToLineAmount.ts index 8f3ea798d..e0b0d8af3 100644 --- a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/priceToLineAmount.ts +++ b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/priceToLineAmount.ts @@ -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, }); }; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts index 372296db9..aafaf3a50 100644 --- a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts +++ b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts @@ -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, }); } diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/volumeTiersToLineAmount.ts b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/volumeTiersToLineAmount.ts index d62019854..aa8cad807 100644 --- a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/volumeTiersToLineAmount.ts +++ b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/volumeTiersToLineAmount.ts @@ -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); diff --git a/shared/utils/cusEntUtils/balanceUtils/customerEntitlementToBalancePrice.ts b/shared/utils/cusEntUtils/balanceUtils/customerEntitlementToBalancePrice.ts index f6f7458fd..6dea88e8a 100644 --- a/shared/utils/cusEntUtils/balanceUtils/customerEntitlementToBalancePrice.ts +++ b/shared/utils/cusEntUtils/balanceUtils/customerEntitlementToBalancePrice.ts @@ -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; } } diff --git a/shared/utils/cusProductUtils/featureOptionUtils/convertFeatureOptions.ts b/shared/utils/cusProductUtils/featureOptionUtils/convertFeatureOptions.ts index 01c2eb33a..a68b9e9b3 100644 --- a/shared/utils/cusProductUtils/featureOptionUtils/convertFeatureOptions.ts +++ b/shared/utils/cusProductUtils/featureOptionUtils/convertFeatureOptions.ts @@ -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, diff --git a/shared/utils/productUtils/priceUtils/classifyPriceUtils.ts b/shared/utils/productUtils/priceUtils/classifyPriceUtils.ts index b1f52ea26..8ab19cf2d 100644 --- a/shared/utils/productUtils/priceUtils/classifyPriceUtils.ts +++ b/shared/utils/productUtils/priceUtils/classifyPriceUtils.ts @@ -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; +}; diff --git a/shared/utils/productUtils/priceUtils/convertPrice/priceToStripePrepaidV2Tiers.ts b/shared/utils/productUtils/priceUtils/convertPrice/priceToStripePrepaidV2Tiers.ts index 5a6f55259..d28f3d6d6 100644 --- a/shared/utils/productUtils/priceUtils/convertPrice/priceToStripePrepaidV2Tiers.ts +++ b/shared/utils/productUtils/priceUtils/convertPrice/priceToStripePrepaidV2Tiers.ts @@ -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; } diff --git a/shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemToPlanItemV1.ts b/shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemToPlanItemV1.ts index 43d88c058..999a45506 100644 --- a/shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemToPlanItemV1.ts +++ b/shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemToPlanItemV1.ts @@ -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 diff --git a/shared/utils/productV2Utils/productItemUtils/tierUtils.ts b/shared/utils/productV2Utils/productItemUtils/tierUtils.ts new file mode 100644 index 000000000..7cdd4df0f --- /dev/null +++ b/shared/utils/productV2Utils/productItemUtils/tierUtils.ts @@ -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]; +};