diff --git a/server/src/external/stripe/createStripePrice/createStripePrepaid.ts b/server/src/external/stripe/createStripePrice/createStripePrepaid.ts index 7f35be82f..39d435b6e 100644 --- a/server/src/external/stripe/createStripePrice/createStripePrepaid.ts +++ b/server/src/external/stripe/createStripePrice/createStripePrepaid.ts @@ -92,10 +92,6 @@ export const createStripePrepaid = async ({ const config = price.config as UsagePriceConfig; - // 1. Product name - // const productName = `${product.name} - ${ - // config.billing_units === 1 ? "" : `${config.billing_units} ` - // }${relatedEnt.feature.name}`; const productName = `${product.name} - ${relatedEnt.feature.name}`; const productData = curStripeProd diff --git a/server/src/external/stripe/createStripePrice/createStripePrepaidPriceV2.ts b/server/src/external/stripe/createStripePrice/createStripePrepaidPriceV2.ts index 84560f799..889666c0d 100644 --- a/server/src/external/stripe/createStripePrice/createStripePrepaidPriceV2.ts +++ b/server/src/external/stripe/createStripePrice/createStripePrepaidPriceV2.ts @@ -30,6 +30,7 @@ export const createStripePrepaidPriceV2 = async ({ entitlements: product.entitlements, }); + // No allowance → V2 price is identical to V1. Reuse the same Stripe price. if (!entitlement?.allowance) { price.config = { ...(price.config as UsagePriceConfig), diff --git a/server/src/internal/products/prices/priceInitUtils.ts b/server/src/internal/products/prices/priceInitUtils.ts index b5d53d255..899ae1258 100644 --- a/server/src/internal/products/prices/priceInitUtils.ts +++ b/server/src/internal/products/prices/priceInitUtils.ts @@ -20,6 +20,7 @@ export const tiersAreSame = (tiers1: UsageTier[], tiers2: UsageTier[]) => { } if (tier1.amount !== tier2.amount) return false; + if ((tier1.flat_amount ?? 0) !== (tier2.flat_amount ?? 0)) return false; } return true; }; diff --git a/server/src/internal/products/product-items/validateProductItems.ts b/server/src/internal/products/product-items/validateProductItems.ts index 98772a263..faa39753a 100644 --- a/server/src/internal/products/product-items/validateProductItems.ts +++ b/server/src/internal/products/product-items/validateProductItems.ts @@ -147,9 +147,9 @@ const validateProductItem = ({ if (isFeaturePriceItem(item) && item.tiers) { if ( - item.tiers.some((x) => { - return x.amount <= 0 && (x.flat_amount ?? 0) <= 0; - }) + item.tiers.some( + (x) => x.amount < 0 || (x.amount === 0 && (x.flat_amount ?? 0) <= 0), + ) ) { throw new RecaseError({ message: `Price must be a number and greater than 0 for feature ${item.feature_id}`, @@ -185,6 +185,37 @@ const validateProductItem = ({ statusCode: StatusCodes.BAD_REQUEST, }); } + + // flat_amount validations + const hasFlatAmount = item.tiers.some( + (t) => t.flat_amount != null && t.flat_amount > 0, + ); + + if (hasFlatAmount) { + if (item.tier_behavior !== TierBehavior.VolumeBased) { + throw new RecaseError({ + message: `flat_amount on tiers is only supported for volume-based pricing`, + code: ErrCode.InvalidInputs, + statusCode: StatusCodes.BAD_REQUEST, + }); + } + + if (item.tiers.length <= 1) { + throw new RecaseError({ + message: `flat_amount is not supported on single-tier pricing`, + code: ErrCode.InvalidInputs, + statusCode: StatusCodes.BAD_REQUEST, + }); + } + } + + if (item.tiers.some((t) => t.flat_amount != null && t.flat_amount < 0)) { + throw new RecaseError({ + message: `flat_amount must be 0 or greater`, + code: ErrCode.InvalidInputs, + statusCode: StatusCodes.BAD_REQUEST, + }); + } } if ( diff --git a/server/tests/_groups/index.ts b/server/tests/_groups/index.ts index 05adee934..05d0bf488 100644 --- a/server/tests/_groups/index.ts +++ b/server/tests/_groups/index.ts @@ -44,8 +44,6 @@ const allGroups: TestGroup[] = [ webhooks, advanced, misc, - - prepaidVolume, ]; export const getAllGroups = (): TestGroup[] => allGroups; 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 b48f6f158..ff5f1e001 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 @@ -331,29 +331,24 @@ test.concurrent(`${chalk.yellowBright("vol-edge: 501 units (ceil to 600) → vol // ═══════════════════════════════════════════════════════════════════════════════ /** - * Volume pricing applies to PURCHASED units only (total − includedUsage). - * featureOptionsToV2StripeQuantity sends only purchased packs to Stripe for - * volume prices — no free leading tier is inserted in the Stripe price object. + * Volume pricing charges the TOTAL quantity (including included) at whichever + * single tier it falls into. A free $0 tier covers the allowance (2 packs). + * Stripe tiers with allowance=200: [{up_to:2,$0}, {up_to:7,$10}, {up_to:inf,$5}] * - * includedUsage=200, quantity=800 → purchased = 600 units (6 packs), tier 2 + * includedUsage=200, quantity=800 → 8 total packs * - * Volume: 600 purchased → tier 2 → 6 × $5 = $30 → invoice $50 - * Graduated: 600 purchased → split → 500×($10/100) + 100×($5/100) - * = $50 + $5 = $55 → invoice $75 - * - * A bug that sent 8 total packs (not 6 purchased) to Stripe for volume would - * charge 8 × $5 = $40 instead of $30. The $50 preview assertion catches this. + * Volume: 8 total packs → tier 2 (>7) → 8 × $5 = $40 → invoice $60 + * Graduated: 2 free + 5×$10 + 1×$5 = $0 + $50 + $5 = $55 → invoice $75 */ -test.concurrent(`${chalk.yellowBright("vol-edge: includedUsage=200, qty=800 → volume 6 purchased packs $30 (not 8 packs $40), graduated $55")}`, async () => { +test.concurrent(`${chalk.yellowBright("vol-edge: includedUsage=200, qty=800 → volume 8 total packs $40, graduated $55")}`, async () => { const customerId = "vol-edge-included-800"; const quantity = 800; const includedUsage = 200; - // Purchased = 800 - 200 = 600 units, all in tier 2 - const purchasedUnits = quantity; + const totalUnits = quantity; - // Volume: 6 packs × $5 = $30 - const volExpectedPrepaid = (purchasedUnits / BILLING_UNITS) * 5; - // Graduated: 500×($10/100) + 100×($5/100) = $50 + $5 = $55 + // Volume: 8 total packs → tier 2 (shifted boundary at 700: (500+200)/100=7) → 8 × $5 = $40 + const volExpectedPrepaid = (totalUnits / BILLING_UNITS) * 5; + // Graduated: free tier covers 200 (2 packs), then 500×($10/100) + 100×($5/100) = $55 const gradExpectedPrepaid = 500 * (10 / BILLING_UNITS) + 100 * (5 / BILLING_UNITS); @@ -389,7 +384,7 @@ test.concurrent(`${chalk.yellowBright("vol-edge: includedUsage=200, qty=800 → actions: [], }); - // Volume: $20 + $30 = $50 (wrong impl sending 8 packs would give $60) + // Volume: $20 base + $40 prepaid = $60 const previewVol = await autumnV1.billing.previewAttach({ customer_id: customerId, product_id: volPro.id, diff --git a/server/tests/integration/crud/plans/create-plan-errors.test.ts b/server/tests/integration/crud/plans/create-plan-errors.test.ts new file mode 100644 index 000000000..048eba2f1 --- /dev/null +++ b/server/tests/integration/crud/plans/create-plan-errors.test.ts @@ -0,0 +1,454 @@ +import { test } from "bun:test"; +import { + type ApiPlan, + type ApiPlanV1, + ApiVersion, + BillingInterval, + BillingMethod, + type CreatePlanParamsInput, + type CreatePlanParamsV2Input, + TierBehavior, + TierInfinite, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { AutumnRpcCli } from "@/external/autumn/autumnRpcCli.js"; + +const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); +const autumnRpc = new AutumnRpcCli({ version: ApiVersion.V2_1 }); + +const getSuffix = () => Math.random().toString(36).slice(2, 9); + +/** Helper: create plan via REST (v1.2 / v2.0) and expect rejection */ +const expectRestError = async ({ + productId, + items, + errMessage, +}: { + productId: string; + items: CreatePlanParamsInput["items"]; + errMessage?: string; +}) => { + try { + await autumnV2.products.delete(productId); + } catch (_e) {} + + await expectAutumnError({ + errCode: "invalid_inputs", + errMessage, + func: async () => { + await autumnV2.products.create({ + id: productId, + name: `Test ${productId}`, + items, + }); + }, + }); +}; + +/** Helper: create plan via RPC (v2.1) and expect rejection */ +const expectRpcError = async ({ + productId, + items, + errMessage, +}: { + productId: string; + items: CreatePlanParamsV2Input["items"]; + errMessage?: string; +}) => { + try { + await autumnRpc.plans.delete(productId, { allVersions: true }); + } catch (_e) {} + + await expectAutumnError({ + errCode: "invalid_inputs", + errMessage, + func: async () => { + await autumnRpc.plans.create({ + plan_id: productId, + name: `Test ${productId}`, + group: `grp_${productId}`, + auto_enable: false, + items, + }); + }, + }); +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// PRICE: amount OR tiers (not neither, not both) +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("tier-errors REST: REJECT price with neither amount nor tiers")}`, async () => { + const id = `err_neither_${getSuffix()}`; + await expectRestError({ + productId: id, + items: [ + { + feature_id: TestFeature.Messages, + price: { + interval: BillingInterval.Month, + billing_method: BillingMethod.Prepaid, + }, + }, + ], + errMessage: "either 'amount' or 'tiers' must be defined", + }); +}); + +test.concurrent(`${chalk.yellowBright("tier-errors RPC: REJECT price with neither amount nor tiers")}`, async () => { + const id = `err_neither_rpc_${getSuffix()}`; + await expectRpcError({ + productId: id, + items: [ + { + feature_id: TestFeature.Messages, + price: { + interval: BillingInterval.Month, + billing_method: BillingMethod.Prepaid, + }, + }, + ], + errMessage: "either 'amount' or 'tiers' must be defined", + }); +}); + +test.concurrent(`${chalk.yellowBright("tier-errors REST: REJECT price with both amount and tiers")}`, async () => { + const id = `err_both_${getSuffix()}`; + await expectRestError({ + productId: id, + items: [ + { + feature_id: TestFeature.Messages, + price: { + amount: 10, + tiers: [ + { to: 100, amount: 5 }, + { to: TierInfinite, amount: 2 }, + ], + interval: BillingInterval.Month, + billing_method: BillingMethod.Prepaid, + }, + }, + ], + errMessage: "'amount' and 'tiers' cannot both be defined", + }); +}); + +test.concurrent(`${chalk.yellowBright("tier-errors RPC: REJECT price with both amount and tiers")}`, async () => { + const id = `err_both_rpc_${getSuffix()}`; + await expectRpcError({ + productId: id, + items: [ + { + feature_id: TestFeature.Messages, + price: { + amount: 10, + tiers: [ + { to: 100, amount: 5 }, + { to: TierInfinite, amount: 2 }, + ], + interval: BillingInterval.Month, + billing_method: BillingMethod.Prepaid, + }, + }, + ], + errMessage: "'amount' and 'tiers' cannot both be defined", + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// flat_amount only for volume-based pricing +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("tier-errors REST: REJECT flat_amount on graduated tiers")}`, async () => { + const id = `err_flat_grad_${getSuffix()}`; + await expectRestError({ + productId: id, + items: [ + { + feature_id: TestFeature.Messages, + price: { + tiers: [ + { to: 100, amount: 5, flat_amount: 10 }, + { to: TierInfinite, amount: 2 }, + ], + tier_behavior: TierBehavior.Graduated, + interval: BillingInterval.Month, + billing_method: BillingMethod.Prepaid, + }, + }, + ], + errMessage: + "flat_amount on tiers is only supported for volume-based pricing", + }); +}); + +test.concurrent(`${chalk.yellowBright("tier-errors RPC: REJECT flat_amount on graduated tiers")}`, async () => { + const id = `err_flat_grad_rpc_${getSuffix()}`; + await expectRpcError({ + productId: id, + items: [ + { + feature_id: TestFeature.Messages, + price: { + tiers: [ + { to: 100, amount: 5, flat_amount: 10 }, + { to: TierInfinite, amount: 2 }, + ], + tier_behavior: TierBehavior.Graduated, + interval: BillingInterval.Month, + billing_method: BillingMethod.Prepaid, + }, + }, + ], + errMessage: + "flat_amount on tiers is only supported for volume-based pricing", + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// flat_amount not on single-tier +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("tier-errors REST: REJECT flat_amount on single-tier")}`, async () => { + const id = `err_flat_single_${getSuffix()}`; + await expectRestError({ + productId: id, + items: [ + { + feature_id: TestFeature.Messages, + price: { + tiers: [{ to: TierInfinite, amount: 5, flat_amount: 10 }], + tier_behavior: TierBehavior.VolumeBased, + interval: BillingInterval.Month, + billing_method: BillingMethod.Prepaid, + }, + }, + ], + errMessage: "flat_amount is not supported on single-tier pricing", + }); +}); + +test.concurrent(`${chalk.yellowBright("tier-errors RPC: REJECT flat_amount on single-tier")}`, async () => { + const id = `err_flat_single_rpc_${getSuffix()}`; + await expectRpcError({ + productId: id, + items: [ + { + feature_id: TestFeature.Messages, + price: { + tiers: [{ to: TierInfinite, amount: 5, flat_amount: 10 }], + tier_behavior: TierBehavior.VolumeBased, + interval: BillingInterval.Month, + billing_method: BillingMethod.Prepaid, + }, + }, + ], + errMessage: "flat_amount is not supported on single-tier pricing", + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// flat_amount must be >= 0 +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("tier-errors REST: REJECT negative flat_amount")}`, async () => { + const id = `err_flat_neg_${getSuffix()}`; + await expectRestError({ + productId: id, + items: [ + { + feature_id: TestFeature.Messages, + price: { + tiers: [ + { to: 100, amount: 5, flat_amount: -10 }, + { to: TierInfinite, amount: 2 }, + ], + tier_behavior: TierBehavior.VolumeBased, + interval: BillingInterval.Month, + billing_method: BillingMethod.Prepaid, + }, + }, + ], + errMessage: "flat_amount must be 0 or greater", + }); +}); + +test.concurrent(`${chalk.yellowBright("tier-errors RPC: REJECT negative flat_amount")}`, async () => { + const id = `err_flat_neg_rpc_${getSuffix()}`; + await expectRpcError({ + productId: id, + items: [ + { + feature_id: TestFeature.Messages, + price: { + tiers: [ + { to: 100, amount: 5, flat_amount: -10 }, + { to: TierInfinite, amount: 2 }, + ], + tier_behavior: TierBehavior.VolumeBased, + interval: BillingInterval.Month, + billing_method: BillingMethod.Prepaid, + }, + }, + ], + errMessage: "flat_amount must be 0 or greater", + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// volume-based only for prepaid +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("tier-errors REST: REJECT volume-based with usage_based billing")}`, async () => { + const id = `err_vol_usage_${getSuffix()}`; + await expectRestError({ + productId: id, + items: [ + { + feature_id: TestFeature.Messages, + price: { + tiers: [ + { to: 100, amount: 5 }, + { to: TierInfinite, amount: 2 }, + ], + tier_behavior: TierBehavior.VolumeBased, + interval: BillingInterval.Month, + billing_method: BillingMethod.UsageBased, + }, + }, + ], + errMessage: "volume-based pricing is only supported for prepaid", + }); +}); + +test.concurrent(`${chalk.yellowBright("tier-errors RPC: REJECT volume-based with usage_based billing")}`, async () => { + const id = `err_vol_usage_rpc_${getSuffix()}`; + await expectRpcError({ + productId: id, + items: [ + { + feature_id: TestFeature.Messages, + price: { + tiers: [ + { to: 100, amount: 5 }, + { to: TierInfinite, amount: 2 }, + ], + tier_behavior: TierBehavior.VolumeBased, + interval: BillingInterval.Month, + billing_method: BillingMethod.UsageBased, + }, + }, + ], + errMessage: "volume-based pricing is only supported for prepaid", + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// tiers[0].to must be greater than included +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("tier-errors REST: REJECT tiers[0].to <= included")}`, async () => { + const id = `err_tier_incl_${getSuffix()}`; + await expectRestError({ + productId: id, + items: [ + { + feature_id: TestFeature.Messages, + included: 200, + price: { + tiers: [ + { to: 100, amount: 5 }, + { to: TierInfinite, amount: 2 }, + ], + interval: BillingInterval.Month, + billing_method: BillingMethod.Prepaid, + }, + }, + ], + errMessage: "tiers[0].to must be greater than included", + }); +}); + +test.concurrent(`${chalk.yellowBright("tier-errors RPC: REJECT tiers[0].to <= included")}`, async () => { + const id = `err_tier_incl_rpc_${getSuffix()}`; + await expectRpcError({ + productId: id, + items: [ + { + feature_id: TestFeature.Messages, + included: 200, + price: { + tiers: [ + { to: 100, amount: 5 }, + { to: TierInfinite, amount: 2 }, + ], + interval: BillingInterval.Month, + billing_method: BillingMethod.Prepaid, + }, + }, + ], + errMessage: "tiers[0].to must be greater than included", + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// ACCEPT: valid volume-based with flat_amount (positive case) +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("tier-errors REST: ACCEPT valid volume-based flat_amount")}`, async () => { + const id = `ok_vol_flat_${getSuffix()}`; + try { + await autumnV2.products.delete(id); + } catch (_e) {} + + await autumnV2.products.create({ + id, + name: `Test ${id}`, + items: [ + { + feature_id: TestFeature.Messages, + price: { + tiers: [ + { to: 100, amount: 5, flat_amount: 10 }, + { to: TierInfinite, amount: 2, flat_amount: 20 }, + ], + tier_behavior: TierBehavior.VolumeBased, + interval: BillingInterval.Month, + billing_method: BillingMethod.Prepaid, + }, + }, + ], + }); +}); + +test.concurrent(`${chalk.yellowBright("tier-errors RPC: ACCEPT valid volume-based flat_amount")}`, async () => { + const id = `ok_vol_flat_rpc_${getSuffix()}`; + try { + await autumnRpc.plans.delete(id, { allVersions: true }); + } catch (_e) {} + + await autumnRpc.plans.create({ + plan_id: id, + name: `Test ${id}`, + group: `grp_${id}`, + auto_enable: false, + items: [ + { + feature_id: TestFeature.Messages, + price: { + tiers: [ + { to: 100, amount: 5, flat_amount: 10 }, + { to: TierInfinite, amount: 2, flat_amount: 20 }, + ], + tier_behavior: TierBehavior.VolumeBased, + interval: BillingInterval.Month, + billing_method: BillingMethod.Prepaid, + }, + }, + ], + }); +}); diff --git a/shared/api/products/items/crud/createPlanItemParamsV1.ts b/shared/api/products/items/crud/createPlanItemParamsV1.ts index 09fb44c5e..874083536 100644 --- a/shared/api/products/items/crud/createPlanItemParamsV1.ts +++ b/shared/api/products/items/crud/createPlanItemParamsV1.ts @@ -181,6 +181,26 @@ export const CreatePlanItemParamsV1Schema = z }); } + if (hasFlatAmount && ctx.value.price.tiers.length <= 1) { + ctx.issues.push({ + code: "custom", + message: "flat_amount is not supported on single-tier pricing.", + input: ctx.value.price, + }); + } + + if ( + ctx.value.price.tiers.some( + (t) => t.flat_amount != null && t.flat_amount < 0, + ) + ) { + ctx.issues.push({ + code: "custom", + message: "flat_amount must be 0 or greater.", + input: ctx.value.price, + }); + } + if ( ctx.value.price?.tier_behavior === TierBehavior.VolumeBased && ctx.value.price?.billing_method !== BillingMethod.Prepaid diff --git a/shared/models/productModels/priceModels/priceConfig/usagePriceConfig.ts b/shared/models/productModels/priceModels/priceConfig/usagePriceConfig.ts index 3c5555d2f..2671f7976 100644 --- a/shared/models/productModels/priceModels/priceConfig/usagePriceConfig.ts +++ b/shared/models/productModels/priceModels/priceConfig/usagePriceConfig.ts @@ -16,7 +16,7 @@ export enum TierBehavior { export const UsageTierSchema = z.object({ to: z.number().or(z.literal(Infinite)), amount: z.number(), - flat_amount: z.number().optional(), // defaults to 0 + flat_amount: z.number().nullish(), }); export type UsageTier = z.infer; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts index 723f9468d..42998c241 100644 --- a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts +++ b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts @@ -59,6 +59,9 @@ export const usagePriceToLineItem = ({ overage = cusEntToInvoiceOverage({ cusEnt }); } + // Volume pricing: the total quantity (purchased + allowance) determines + // which tier applies, and the ENTIRE total is charged at that tier's rate. + // So we add allowance back to overage before pricing. const allowance = cusEntsToAllowance({ cusEnts: [cusEnt] }); if (isVolumePrice(cusPrice.price)) { overage = new Decimal(overage).add(allowance).toNumber(); diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts index aafaf3a50..381ddbb86 100644 --- a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts +++ b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts @@ -5,24 +5,18 @@ import { nullish } from "../../../utils"; import { graduatedTiersToLineAmount } from "./graduatedTiersToLineAmount"; /** - * Translates a price's overage quantity into a dollar amount using the price's - * tier behaviour (graduated or volume). Called at invoicing time for both - * prepaid and pay-per-use prices. + * Translates usage into a dollar amount using the price's tier behaviour. * - * "Overage" here means any usage that exceeds the customer's free included - * allowance (if any). For **prepaid** prices this is the quantity purchased - * upfront above the free tier. For **pay-per-use** (arrear) prices this is - * total consumption minus any included free units. + * - **Graduated**: `overage` should be net of allowance. Each tier band is + * charged at its own rate. `allowance` param is unused. + * - **Volume**: `overage` should be total usage (purchased + allowance). + * `allowance` is passed through to prepend a free $0 tier and shift + * boundaries. If total exceeds the free tier, the ENTIRE quantity + * (including included) is charged at the matching tier's rate. * - * Negative overage is allowed — used when a downgrade or proration produces a - * credit line-item that needs to be negated. - * - * @param price - The price whose `config.usage_tiers` defines the rate schedule. - * @param overage - Units to price. Positive = charge, negative = credit. - * Must be net of any included free allowance before calling. - * @param billingUnits - Passed through to the underlying tier calculator. - * Defaults to 1 (per-unit pricing). - * @returns Dollar amount (positive = charge, negative = credit). + * Callers (e.g. `usagePriceToLineItem`) are responsible for adjusting + * `overage` before calling — volume adds allowance to overage, graduated + * does not. */ export const tiersToLineAmount = ({ price, diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/volumeTiersToLineAmount.ts b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/volumeTiersToLineAmount.ts index 354998172..300fc170b 100644 --- a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/volumeTiersToLineAmount.ts +++ b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/volumeTiersToLineAmount.ts @@ -5,6 +5,16 @@ import { addAllowanceToTiers } from "@utils/productV2Utils/productItemUtils/tier import { nullish } from "@utils/utils"; import { Decimal } from "decimal.js"; +/** + * Volume-based tier pricing: the ENTIRE usage is charged at the rate of + * whichever single tier it falls into (unlike graduated, which splits across bands). + * + * When `allowance` > 0, a free $0 tier is prepended and paid-tier boundaries + * are shifted up. If usage <= allowance, cost is $0. If usage exceeds the + * allowance, the ENTIRE usage (including the free portion) is charged at the + * matching paid tier's rate. This is intentional — volume pricing does not + * subtract included usage before applying the rate. + */ export const volumeTiersToLineAmount = ({ tiers, usage, @@ -34,12 +44,6 @@ export const volumeTiersToLineAmount = ({ let amount = new Decimal(0); - // for each tier - // if the usage is less than the tier.to, - // add the tier.amount * usage to the amount - // then break - // else keep going.- - const tiersWithAllowance = addAllowanceToTiers({ tiers, allowance, diff --git a/shared/utils/cusProductUtils/featureOptionUtils/convertFeatureOptions.ts b/shared/utils/cusProductUtils/featureOptionUtils/convertFeatureOptions.ts index a68b9e9b3..39ec610f6 100644 --- a/shared/utils/cusProductUtils/featureOptionUtils/convertFeatureOptions.ts +++ b/shared/utils/cusProductUtils/featureOptionUtils/convertFeatureOptions.ts @@ -7,14 +7,14 @@ import { Decimal } from "decimal.js"; /** * Computes the Stripe subscription-item quantity for a V2 prepaid price. * - * For **graduated** prices the allowance is encoded as a free leading tier in - * the Stripe price object, so Stripe needs the *total* packs (purchased + - * allowance) to bill correctly. + * Always returns total packs (purchased + allowance) for both graduated and + * volume pricing. The V2 Stripe price has a free leading tier that covers + * the allowance, so Stripe needs the full quantity to bill correctly. * - * For **volume** prices the Stripe price has no free tier offset (the allowance - * is tracked purely in Autumn), so only the purchased packs are sent. Stripe - * then applies the single matching tier rate to the purchased quantity only, - * which matches Autumn's own `volumeTiersToLineAmount` calculation. + * For **volume** prices: if total quantity exceeds the free tier, the ENTIRE + * quantity (including included) is charged at the matching paid tier's rate. + * This is the intended behavior — volume pricing does not subtract included + * usage before applying the tier rate. */ export const featureOptionsToV2StripeQuantity = ({ featureOptions, @@ -33,8 +33,6 @@ export const featureOptionsToV2StripeQuantity = ({ entitlement, }); - // Graduated: Stripe needs total packs (purchased + allowance) because the - // V2 price has a free leading tier that covers the allowance. if (!packsExcludingAllowance) return allowanceInPacks; return new Decimal(packsExcludingAllowance).add(allowanceInPacks).toNumber(); diff --git a/shared/utils/productUtils/priceUtils/convertPrice/priceToStripePrepaidV2Tiers.ts b/shared/utils/productUtils/priceUtils/convertPrice/priceToStripePrepaidV2Tiers.ts index ab32bb94e..81a9e2d3f 100644 --- a/shared/utils/productUtils/priceUtils/convertPrice/priceToStripePrepaidV2Tiers.ts +++ b/shared/utils/productUtils/priceUtils/convertPrice/priceToStripePrepaidV2Tiers.ts @@ -14,17 +14,16 @@ import type Stripe from "stripe"; /** * Builds the Stripe tier array for a V2 prepaid price. * - * For **graduated** prices with an allowance, a free leading tier is inserted - * and all paid-tier boundaries are shifted up by the allowance. Stripe's - * graduated mode splits charges across tiers, so the free tier naturally - * covers the included units. + * For both graduated and volume prices with an allowance, a free $0 leading + * tier is inserted and all paid-tier boundaries are shifted up by the allowance. + * Stripe receives total packs (purchased + allowance) as the quantity. * - * For **volume** prices, the free-tier offset approach does not work: Stripe - * volume mode charges the *entire* quantity at the rate of the single matching - * tier, so a leading $0 tier would corrupt the math. Volume prices therefore - * use the same flat tier boundaries as the V1 price. The allowance is tracked - * purely by Autumn; see `featureOptionsToV2StripeQuantity` for how the - * Stripe quantity is kept to paid packs only for volume prices. + * - **Graduated**: Stripe splits charges across tier bands. The free tier + * covers the included units at $0, so only units above the allowance incur cost. + * - **Volume**: if total quantity exceeds the free tier, the ENTIRE quantity + * (including the included portion) is charged at the matching paid tier's + * rate. This is intentional — volume pricing does not subtract included + * usage before applying the rate. */ export const priceToStripePrepaidV2Tiers = ({ price, @@ -39,10 +38,8 @@ export const priceToStripePrepaidV2Tiers = ({ 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). + // Insert a free leading tier and shift paid-tier boundaries up by the + // allowance. Applies to both graduated and volume pricing. if (entitlement.allowance) { tiers.push({ unit_amount_decimal: "0", diff --git a/shared/utils/productV2Utils/compareProductUtils/generateItemChanges.ts b/shared/utils/productV2Utils/compareProductUtils/generateItemChanges.ts index 0ffc34f2f..4b5b365b5 100644 --- a/shared/utils/productV2Utils/compareProductUtils/generateItemChanges.ts +++ b/shared/utils/productV2Utils/compareProductUtils/generateItemChanges.ts @@ -68,16 +68,25 @@ function formatTierPricing({ }): string { if (tiers.length === 0) return "Tiered"; - const firstPricedTier = tiers.find((tier) => tier.amount > 0); + const firstPricedTier = tiers.find( + (tier) => tier.amount > 0 || (tier.flat_amount ?? 0) > 0, + ); if (!firstPricedTier) { const lastTier = tiers[tiers.length - 1]; if (lastTier.to === "inf") return "Unlimited free"; return `${lastTier.to} free`; } - return billingUnits > 1 - ? `$${firstPricedTier.amount} per ${billingUnits}` - : `$${firstPricedTier.amount} per unit`; + const perUnit = + billingUnits > 1 + ? `$${firstPricedTier.amount} per ${billingUnits}` + : `$${firstPricedTier.amount} per unit`; + + if (firstPricedTier.flat_amount && firstPricedTier.flat_amount > 0) { + return `${perUnit} + $${firstPricedTier.flat_amount} flat`; + } + + return perUnit; } /** Generates edit items for product item additions, removals, and modifications */ diff --git a/shared/utils/rewardUtils/rewardMigrationUtils.ts b/shared/utils/rewardUtils/rewardMigrationUtils.ts index a14f15d19..e9f05f531 100644 --- a/shared/utils/rewardUtils/rewardMigrationUtils.ts +++ b/shared/utils/rewardUtils/rewardMigrationUtils.ts @@ -16,7 +16,11 @@ const tiersMatch = (oldTiers: UsageTier[], newTiers: UsageTier[]): boolean => { return oldTiers.every((oldTier, index) => { const newTier = newTiers[index]; - return oldTier.to === newTier.to && oldTier.amount === newTier.amount; + return ( + oldTier.to === newTier.to && + oldTier.amount === newTier.amount && + (oldTier.flat_amount ?? 0) === (newTier.flat_amount ?? 0) + ); }); }; diff --git a/vite/src/components/forms/attach-v2/utils/attachDiffUtils.ts b/vite/src/components/forms/attach-v2/utils/attachDiffUtils.ts index 2ee98417a..d7f1d37d0 100644 --- a/vite/src/components/forms/attach-v2/utils/attachDiffUtils.ts +++ b/vite/src/components/forms/attach-v2/utils/attachDiffUtils.ts @@ -38,7 +38,9 @@ function tiersAreEqual({ return tiersA.every( (tier, index) => - tier.amount === tiersB[index]?.amount && tier.to === tiersB[index]?.to, + tier.amount === tiersB[index]?.amount && + tier.to === tiersB[index]?.to && + (tier.flat_amount ?? 0) === (tiersB[index]?.flat_amount ?? 0), ); }