diff --git a/bun.lock b/bun.lock index 110b864c7..b5b04eabc 100644 --- a/bun.lock +++ b/bun.lock @@ -331,7 +331,6 @@ "@types/bun": "latest", "@types/node": "^24.0.3", "cross-env": "^7.0.3", - "tsx": "^4.21.0", "typescript": "^5.7.2", }, "peerDependencies": { diff --git a/package.json b/package.json index 020a48a0d..5c7adc731 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,6 @@ "setup": "node scripts/setup/setup.js", "setup:test": "infisical run --env=dev -- bun scripts/setup/setup-test.ts", "migrate-functions": "infisical run --env=dev -- bun scripts/migrations/migrate-functions.ts", - "migrate-functions:test": "infisical run --env=test -- bun scripts/migrations/migrate-functions.ts", "migrate-functions:prod": "infisical run --env=prod -- bun scripts/migrations/migrate-functions.ts", "validate-schema": "infisical run --env=prod -- bun scripts/migrations/validate-schema.ts", "setupci": "node scripts/setup/setupci.js", diff --git a/server/src/external/stripe/createStripePrice/createStripeInArrear.ts b/server/src/external/stripe/createStripePrice/createStripeInArrear.ts index 315f86f96..64bcac016 100644 --- a/server/src/external/stripe/createStripePrice/createStripeInArrear.ts +++ b/server/src/external/stripe/createStripePrice/createStripeInArrear.ts @@ -271,14 +271,12 @@ export const createStripeInArrearPrice = async ({ ...productData, ...priceAmountData, currency: org.default_currency || "usd", - recurring: recurringData?.interval - ? { - interval: recurringData.interval, - interval_count: recurringData.interval_count, - meter: meter.id, - usage_type: "metered", - } - : undefined, + recurring: { + interval: recurringData.interval, + interval_count: recurringData.interval_count, + meter: meter.id, + usage_type: "metered", + }, nickname: `Autumn Price (${relatedEnt.feature.name})`, }); diff --git a/server/src/external/stripe/createStripePrice/createStripePrepaid.ts b/server/src/external/stripe/createStripePrice/createStripePrepaid.ts index 752b1bf25..32eb702c8 100644 --- a/server/src/external/stripe/createStripePrice/createStripePrepaid.ts +++ b/server/src/external/stripe/createStripePrice/createStripePrepaid.ts @@ -5,7 +5,6 @@ import { type Organization, type Price, type Product, - priceToStripeTiersMode, TierInfinite, type UsagePriceConfig, } from "@autumn/shared"; @@ -71,14 +70,12 @@ export const createStripePrepaid = async ({ }) => { const relatedEnt = getPriceEntitlement(price, entitlements); - let recurringData: Partial | undefined; + let recurringData; if (price.config!.interval !== BillingInterval.OneOff) { - recurringData = { - ...billingIntervalToStripe({ - interval: price.config!.interval, - intervalCount: price.config!.interval_count, - }), - }; + recurringData = billingIntervalToStripe({ + interval: price.config!.interval, + intervalCount: price.config!.interval_count, + }); } const config = price.config as UsagePriceConfig; @@ -116,7 +113,6 @@ export const createStripePrepaid = async ({ config.stripe_price_id = stripePrice.id; } else { const tiers = prepaidToStripeTiers({ price, org }); - const tiersMode = priceToStripeTiersMode({ price }); let priceAmountData = {}; if (tiers.length === 1) { @@ -126,7 +122,7 @@ export const createStripePrepaid = async ({ } else { priceAmountData = { billing_scheme: "tiered", - tiers_mode: tiersMode, + tiers_mode: "graduated", tiers: tiers, }; } diff --git a/server/src/external/stripe/createStripePrice/createStripePrepaidPriceV2.ts b/server/src/external/stripe/createStripePrice/createStripePrepaidPriceV2.ts index 9191c83a4..5d6b5676a 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"; @@ -26,31 +25,13 @@ export const createStripePrepaidPriceV2 = async ({ }) => { const { org, db, env } = ctx; + // 1. If no entitlement, re-use current stripe price const entitlement = priceToEnt({ price, entitlements: product.entitlements, }); - 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/external/stripe/priceToStripeItem/priceToUsageInAdvance.ts b/server/src/external/stripe/priceToStripeItem/priceToUsageInAdvance.ts index e2cef6792..94a672a3f 100644 --- a/server/src/external/stripe/priceToStripeItem/priceToUsageInAdvance.ts +++ b/server/src/external/stripe/priceToStripeItem/priceToUsageInAdvance.ts @@ -28,7 +28,7 @@ export const priceToOneOffAndTiered = ({ const quantity = options?.quantity ?? 0; const overage = new Decimal(quantity).mul(config.billing_units!).toNumber(); - const amount = getPriceForOverage({ price, overage }); + const amount = getPriceForOverage(price, overage); if (!config.stripe_product_id) { console.log( `WARNING: One off & tiered in advance price has no stripe product id: ${price.id}, ${relatedEnt.feature.name}`, diff --git a/server/src/external/stripe/stripePriceUtils.ts b/server/src/external/stripe/stripePriceUtils.ts index 6067b9ca0..f63e4fb8c 100644 --- a/server/src/external/stripe/stripePriceUtils.ts +++ b/server/src/external/stripe/stripePriceUtils.ts @@ -25,7 +25,7 @@ export const billingIntervalToStripe = ({ }: { interval: BillingInterval; intervalCount?: number | null; -}): Partial => { +}): Stripe.PriceCreateParams.Recurring | Record => { const finalCount = intervalCount ?? 1; switch (interval) { case BillingInterval.Week: diff --git a/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.ts b/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.ts index 33db47330..19ecc110e 100644 --- a/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.ts +++ b/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.ts @@ -98,7 +98,7 @@ const getContUseNewItems = async ({ } } - const amount = getPriceForOverage({ price, overage }); + const amount = getPriceForOverage(price, overage); const description = getFeatureInvoiceDescription({ feature: ent.feature, usage: usage, diff --git a/server/src/internal/customers/cusProducts/cusPrices/cusPriceUtils.ts b/server/src/internal/customers/cusProducts/cusPrices/cusPriceUtils.ts index b07fbcf58..6a56050b5 100644 --- a/server/src/internal/customers/cusProducts/cusPrices/cusPriceUtils.ts +++ b/server/src/internal/customers/cusProducts/cusPrices/cusPriceUtils.ts @@ -72,10 +72,7 @@ export const getCusPriceUsage = ({ const roundedQuantity = Math.ceil(new Decimal(usage).div(billingUnits).toNumber()) * billingUnits; - const amount = getPriceForOverage({ - price: cusPrice.price, - overage: -totalNegativeBalance, - }); + const amount = getPriceForOverage(cusPrice.price, -totalNegativeBalance); let description = getFeatureInvoiceDescription({ feature: cusEnt.entitlement.feature, diff --git a/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts b/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts index 94675d26a..99e87a972 100644 --- a/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts +++ b/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts @@ -221,9 +221,9 @@ export const getItemsForNewProduct = async ({ periodEnd: finalProration.end, periodStart: finalProration.start, now, - amount: getPriceForOverage({ price }), - }) - : getPriceForOverage({ price, overage: 0 }); + amount: getPriceForOverage(price), + }) + : getPriceForOverage(price, 0); if (freeTrial) { amount = 0; diff --git a/server/src/internal/products/prices/priceInitUtils.ts b/server/src/internal/products/prices/priceInitUtils.ts index 9095f463b..a5e0843c3 100644 --- a/server/src/internal/products/prices/priceInitUtils.ts +++ b/server/src/internal/products/prices/priceInitUtils.ts @@ -217,10 +217,6 @@ export const pricesAreSame = ( prorationConfig1?.on_decrease != prorationConfig2?.on_decrease, message: `On decrease different: ${prorationConfig1?.on_decrease} != ${prorationConfig2?.on_decrease}`, }, - tier_behavior: { - condition: price1.tier_behavior != price2.tier_behavior, - message: `Tier behaviour different: ${price1.tier_behavior} != ${price2.tier_behavior}`, - }, }; const pricesAreDiff = diff --git a/server/src/internal/products/prices/priceUtils.ts b/server/src/internal/products/prices/priceUtils.ts index 6fda98556..6e8187da6 100644 --- a/server/src/internal/products/prices/priceUtils.ts +++ b/server/src/internal/products/prices/priceUtils.ts @@ -26,8 +26,6 @@ import { Decimal } from "decimal.js"; import { StatusCodes } from "http-status-codes"; import { compareBillingIntervals } from "./priceUtils/priceIntervalUtils.js"; -export { getPriceForOverage } from "@autumn/shared"; - export const constructPrice = ({ internalProductId, entitlementId, @@ -270,6 +268,54 @@ const getUsageTier = (price: Price, quantity: number) => { return usageConfig.usage_tiers[0]; }; +export const getPriceForOverage = (price: Price, overage?: number) => { + const usageConfig = price.config as UsagePriceConfig; + const billingType = getBillingType(usageConfig); + + if ( + billingType === BillingType.FixedCycle || + billingType === BillingType.OneOff + ) { + const config = price.config as FixedPriceConfig; + return config.amount; + } + + let amount = 0; + const billingUnits = usageConfig.billing_units || 1; + let remainingUsage = new Decimal( + Math.ceil(new Decimal(overage!).div(billingUnits).toNumber()), + ) + .mul(billingUnits) + .toNumber(); + + let lastTo: number = 0; + for (let i = 0; i < usageConfig.usage_tiers.length; i++) { + const tier = usageConfig.usage_tiers[i]; + + let amountUsed = 0; + if (tier.to === TierInfinite || tier.to === -1) { + amountUsed = remainingUsage; + } else { + amountUsed = Math.min(remainingUsage, tier.to - lastTo); + lastTo = tier.to; + } + + // Divide amount by billing units + const amountPerUnit = new Decimal(tier.amount) + .div(usageConfig.billing_units!) + .toNumber(); + + amount += amountPerUnit * amountUsed; + remainingUsage -= amountUsed; + + if (remainingUsage <= 0) { + break; + } + } + + return Number(amount.toFixed(10)); +}; + const priceToEventName = (productName: string, featureName: string) => { return `${productName} - ${featureName}`; }; diff --git a/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts b/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts index 8c27f4d93..dc85ec240 100644 --- a/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts +++ b/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts @@ -277,7 +277,6 @@ const toFeatureAndPrice = ({ config, entitlement_id: ent.id, proration_config: prorationConfig, - tier_behavior: item.tier_behavior ?? null, }; const billingType = getBillingType(price.config!); diff --git a/server/src/internal/products/product-items/validateProductItems.ts b/server/src/internal/products/product-items/validateProductItems.ts index ae57361be..f1315cf34 100644 --- a/server/src/internal/products/product-items/validateProductItems.ts +++ b/server/src/internal/products/product-items/validateProductItems.ts @@ -15,10 +15,10 @@ import { RecaseError, type RolloverConfig, RolloverExpiryDurationType, - TierBehavior, UsageModel, } from "@autumn/shared"; import { createFeaturesFromItems } from "@server/internal/products/product-items/createFeaturesFromItems"; + import { StatusCodes } from "http-status-codes"; import { isBooleanFeatureItem, @@ -173,18 +173,6 @@ const validateProductItem = ({ statusCode: StatusCodes.BAD_REQUEST, }); } - - if ( - item.tier_behavior === TierBehavior.VolumeBased && - item.tiers.length > 1 && - item.usage_model !== UsageModel.Prepaid - ) { - throw new RecaseError({ - message: `Volume-based pricing is only supported for prepaid items`, - code: ErrCode.InvalidInputs, - statusCode: StatusCodes.BAD_REQUEST, - }); - } } if ( diff --git a/server/src/utils/scriptUtils/constructItem.ts b/server/src/utils/scriptUtils/constructItem.ts index 2ebbd22dc..285afb578 100644 --- a/server/src/utils/scriptUtils/constructItem.ts +++ b/server/src/utils/scriptUtils/constructItem.ts @@ -7,7 +7,6 @@ import { type ProductItemFeatureType, ProductItemInterval, type RolloverConfig, - type TierBehavior, UsageModel, } from "@autumn/shared"; @@ -70,7 +69,6 @@ export const constructPrepaidItem = ({ featureId, price = 9, tiers, - tierBehaviour, billingUnits = 100, includedUsage = 0, isOneOff = false, @@ -87,7 +85,6 @@ export const constructPrepaidItem = ({ featureId: string; price?: number; tiers?: { amount: number; to: number | "inf" }[]; - tierBehaviour?: TierBehavior; billingUnits?: number; includedUsage?: number; isOneOff?: boolean; @@ -104,7 +101,6 @@ export const constructPrepaidItem = ({ price: tiers ? undefined : price, tiers: tiers, - tier_behavior: tierBehaviour, billing_units: billingUnits || 100, interval: isOneOff ? null : ProductItemInterval.Month, interval_count: intervalCount, @@ -126,7 +122,6 @@ export const constructArrearItem = ({ featureId, includedUsage = 10000, price = 0.1, - tiers, billingUnits = 1000, config = { on_increase: OnIncrease.ProrateImmediately, @@ -141,7 +136,6 @@ export const constructArrearItem = ({ featureId: string; includedUsage?: number; price?: number; - tiers?: { amount: number; to: number | "inf" }[]; billingUnits?: number; config?: ProductItemConfig; rolloverConfig?: RolloverConfig; @@ -154,8 +148,7 @@ export const constructArrearItem = ({ feature_id: featureId, usage_model: UsageModel.PayPerUse, included_usage: includedUsage, - price: tiers ? undefined : price, - tiers: tiers, + price: price, billing_units: billingUnits, interval: interval, interval_count: intervalCount, 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 deleted file mode 100644 index 39640d0a2..000000000 --- a/server/tests/integration/billing/attach/edge-cases/v1-v2-compatibility/prepaid/attach-prepaid-volume-edge-cases.test.ts +++ /dev/null @@ -1,455 +0,0 @@ -/** - * Volume Pricing Edge Case Tests - * - * Tests for the three most dangerous correctness gaps in volume pricing — - * cases where a subtle bug in the implementation would produce a wrong number - * silently rather than throwing an error. - * - * Tiers used throughout (billingUnits = 100): - * Tier 1: 0–500 units @ $10/pack - * Tier 2: 501+ units @ $5/pack - * - * Tests A–C attach both a volume and a graduated product to the same customer. - * To allow both to coexist (they must not replace each other), the volume - * product uses products.pro() and the graduated product uses products.base() - * with an explicit monthly price — different plan types, no mutual exclusion. - */ - -import { expect, test } from "bun:test"; -import type { ApiCustomerV3 } from "@autumn/shared"; -import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; -import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; -import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; -import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; -import { TestFeature } from "@tests/setup/v2Features"; -import { items } from "@tests/utils/fixtures/items"; -import { products } from "@tests/utils/fixtures/products"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; -import chalk from "chalk"; - -const BILLING_UNITS = 100; -const BASE_PRICE = 20; -const TIERS = [ - { to: 500, amount: 10 }, - { to: "inf" as const, amount: 5 }, -]; - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST A: Exact tier boundary — 500 units -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * The boundary check in volumeTiersToLineAmount is `roundedUsage <= tierBoundary` - * (inclusive upper bound). Exactly 500 units with `{ to: 500 }` must stay in - * tier 1, not spill into tier 2. - * - * Volume: 500 → tier 1 (500 ≤ 500) → 5 × $10 = $50 - * Graduated: 500 → tier 1 (min(500,500)=500) → 5 × $10 = $50 (same) - * - * A regression of `<=` → `<` would bump volume to tier 2: 5 × $5 = $25. - * Both models must agree on $50 here — the agreement is the assertion. - */ -test.concurrent(`${chalk.yellowBright("vol-edge: 500 units (exact tier 1 boundary) → $50, volume = graduated")}`, async () => { - const customerId = "vol-edge-boundary-500"; - const quantity = 500; - // Both: 5 packs × $10 = $50 - const expectedPrepaid = (quantity / BILLING_UNITS) * 10; - - const volItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: TIERS, - }); - const gradItem = items.tieredPrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: TIERS, - }); - - // Distinct groups so the two products don't mutually-exclude each other - const volPro = products.pro({ id: "vol-pro-500", items: [volItem], group: "vol-500" }); - const gradBase = products.base({ - id: "grad-base-500", - items: [gradItem, items.monthlyPrice({ price: BASE_PRICE })], - group: "grad-500", - }); - - const { autumnV1, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [volPro, gradBase] }), - ], - actions: [], - }); - - // Both previews must be $70 — volume bumped to tier 2 would return $45 - const previewVol = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: volPro.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - }); - expect(previewVol.total).toBe(BASE_PRICE + expectedPrepaid); - - const previewGrad = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: gradBase.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - }); - expect(previewGrad.total).toBe(BASE_PRICE + expectedPrepaid); - - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: volPro.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - redirect_mode: "if_required", - }); - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: gradBase.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - redirect_mode: "if_required", - }); - - const customer = await autumnV1.customers.get(customerId); - - await expectProductActive({ customer, productId: volPro.id }); - await expectProductActive({ customer, productId: gradBase.id }); - - // 2 invoices, both $70 - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: BASE_PRICE + expectedPrepaid, - }); - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - invoiceIndex: 1, - latestTotal: BASE_PRICE + expectedPrepaid, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST B: Non-pack-aligned quantity — ceiling rounding (501 units) -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * The implementation rounds usage up before tier lookup: - * ceil(501 / 100) * 100 = 600. Volume applies tier 2 to 600 units. - * - * Volume: 501 → ceil → 600 → tier 2 → 6 × $5 = $30 → invoice $50 - * Graduated: 501 → ceil → 600 → split → 500×($10/100) + 100×($5/100) - * = $50 + $5 = $55 → invoice $75 - * - * The balance is 600 (the ceiling-rounded value), not 501. - */ -test.concurrent(`${chalk.yellowBright("vol-edge: 501 units (ceil to 600) → volume $30, graduated $55, balance 600")}`, async () => { - const customerId = "vol-edge-ceiling-501"; - const quantity = 501; - const ceiledQuantity = 600; // ceil(501/100)*100 - - // Volume: 6 packs × $5 = $30 - const volExpectedPrepaid = (ceiledQuantity / BILLING_UNITS) * 5; - // Graduated: 500 units at $10/100 + 100 units at $5/100 = $50 + $5 = $55 - const gradExpectedPrepaid = - 500 * (10 / BILLING_UNITS) + 100 * (5 / BILLING_UNITS); - - const volItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: TIERS, - }); - const gradItem = items.tieredPrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: TIERS, - }); - - // Distinct groups so the two products don't mutually-exclude each other - const volPro = products.pro({ id: "vol-pro-501", items: [volItem], group: "vol-501" }); - const gradBase = products.base({ - id: "grad-base-501", - items: [gradItem, items.monthlyPrice({ price: BASE_PRICE })], - group: "grad-501", - }); - - const { autumnV1, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [volPro, gradBase] }), - ], - actions: [], - }); - - const previewVol = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: volPro.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - }); - expect(previewVol.total).toBe(BASE_PRICE + volExpectedPrepaid); - - const previewGrad = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: gradBase.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - }); - expect(previewGrad.total).toBe(BASE_PRICE + gradExpectedPrepaid); - - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: volPro.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - redirect_mode: "if_required", - }); - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: gradBase.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - redirect_mode: "if_required", - }); - - const customer = await autumnV1.customers.get(customerId); - - await expectProductActive({ customer, productId: volPro.id }); - await expectProductActive({ customer, productId: gradBase.id }); - - // Both products contribute 600 units (ceiling-rounded) each - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: ceiledQuantity * 2, - usage: 0, - }); - - // Invoice 0 (latest): graduated — $75 - // Invoice 1: volume — $50 - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: BASE_PRICE + gradExpectedPrepaid, - }); - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - invoiceIndex: 1, - latestTotal: BASE_PRICE + volExpectedPrepaid, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST C: includedUsage + volume, purchased quantity crosses tier boundary -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Volume pricing 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. - * - * includedUsage=200, quantity=800 → purchased = 600 units (6 packs), tier 2 - * - * 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. - */ -test.concurrent(`${chalk.yellowBright("vol-edge: includedUsage=200, qty=800 → volume 6 purchased packs $30 (not 8 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 - includedUsage; - - // Volume: 6 packs × $5 = $30 - const volExpectedPrepaid = (purchasedUnits / BILLING_UNITS) * 5; - // Graduated: 500×($10/100) + 100×($5/100) = $50 + $5 = $55 - const gradExpectedPrepaid = - 500 * (10 / BILLING_UNITS) + 100 * (5 / BILLING_UNITS); - - const volItem = items.volumePrepaidMessages({ - includedUsage, - billingUnits: BILLING_UNITS, - tiers: TIERS, - }); - const gradItem = items.tieredPrepaidMessages({ - includedUsage, - billingUnits: BILLING_UNITS, - tiers: TIERS, - }); - - // Distinct groups so the two products don't mutually-exclude each other - const volPro = products.pro({ id: "vol-pro-inc", items: [volItem], group: "vol-inc" }); - const gradBase = products.base({ - id: "grad-base-inc", - items: [gradItem, items.monthlyPrice({ price: BASE_PRICE })], - group: "grad-inc", - }); - - const { autumnV1, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [volPro, gradBase] }), - ], - actions: [], - }); - - // Volume: $20 + $30 = $50 (wrong impl sending 8 packs would give $60) - const previewVol = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: volPro.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - }); - expect(previewVol.total).toBe(BASE_PRICE + volExpectedPrepaid); - - // Graduated: $20 + $55 = $75 - const previewGrad = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: gradBase.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - }); - expect(previewGrad.total).toBe(BASE_PRICE + gradExpectedPrepaid); - - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: volPro.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - redirect_mode: "if_required", - }); - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: gradBase.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - redirect_mode: "if_required", - }); - - const customer = await autumnV1.customers.get(customerId); - - await expectProductActive({ customer, productId: volPro.id }); - await expectProductActive({ customer, productId: gradBase.id }); - - // Both products contribute 800 units each - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: quantity * 2, - usage: 0, - }); - - // Invoice 0 (latest): graduated — $75 - // Invoice 1: volume — $50 - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: BASE_PRICE + gradExpectedPrepaid, - }); - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - invoiceIndex: 1, - latestTotal: BASE_PRICE + volExpectedPrepaid, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST D: includedUsage fully covers the requested quantity (0 purchased packs) -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * includedUsage=500 (5 free packs), user requests 300 units. - * Purchased packs = 0. featureOptionsToV2StripeQuantity returns 0 for volume. - * - * Volume: 0 purchased packs → $0 prepaid → only base price charged. - * - * The balance is the full includedUsage (500), not the requested quantity (300), - * because the product grants 500 free units regardless of what the user asked for. - * - * Guards against: negative purchased quantity errors, off-by-one that charges - * 1 pack instead of 0, or the zero-quantity path throwing in Stripe price creation. - */ -test.concurrent(`${chalk.yellowBright("vol-edge: includedUsage=500 covers qty=300 → 0 purchased packs, $0 prepaid, balance=500")}`, async () => { - const customerId = "vol-edge-included-all"; - const quantity = 300; - const includedUsage = 500; - - const volItem = items.volumePrepaidMessages({ - includedUsage, - billingUnits: BILLING_UNITS, - tiers: TIERS, - }); - - const volPro = products.pro({ id: "vol-pro-inc-all", items: [volItem] }); - - const { autumnV1, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [volPro] }), - ], - actions: [], - }); - - // Preview must be base-only — allowance covers all requested units - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: volPro.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - }); - expect(preview.total).toBe(BASE_PRICE); - - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: volPro.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - redirect_mode: "if_required", - }); - - const customer = await autumnV1.customers.get(customerId); - - await expectProductActive({ customer, productId: volPro.id }); - - // Balance is the full includedUsage (500), not the requested 300 - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: includedUsage, - usage: 0, - }); - - await expectCustomerInvoiceCorrect({ - customer, - count: 1, - latestTotal: BASE_PRICE, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); diff --git a/server/tests/integration/billing/attach/immediate-switch/immediate-switch-entities-prepaid-volume.test.ts b/server/tests/integration/billing/attach/immediate-switch/immediate-switch-entities-prepaid-volume.test.ts deleted file mode 100644 index bb1874d65..000000000 --- a/server/tests/integration/billing/attach/immediate-switch/immediate-switch-entities-prepaid-volume.test.ts +++ /dev/null @@ -1,435 +0,0 @@ -/** - * Attach Prepaid Volume vs Graduated — Entity-Level Immediate Switch Tests - * - * Two entities share one customer. Each entity is on a different prepaid tier - * pricing model for their Messages feature: - * - * Entity 1 — GRADUATED pricing (tieredPrepaidMessages): - * Usage is split across tiers. 800 units → 5×$10 + 3×$5 = $65. - * - * Entity 2 — VOLUME pricing (volumePrepaidMessages): - * All units charged at the rate of the matching tier. 800 units → 8×$5 = $40. - * - * Tiers (billingUnits = 100): - * Tier 1: 0–500 units @ $10/pack - * Tier 2: 501+ units @ $5/pack - * - * Both entities start on pro and immediately switch to premium. - * The invoice totals confirm that Autumn applies the correct pricing model - * per product item and that the two entities remain fully independent. - */ - -import { expect, test } from "bun:test"; -import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared"; -import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; -import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; -import { - expectCustomerProducts, - expectProductActive, -} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; -import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; -import { TestFeature } from "@tests/setup/v2Features"; -import { items } from "@tests/utils/fixtures/items"; -import { products } from "@tests/utils/fixtures/products"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; -import chalk from "chalk"; - -const BILLING_UNITS = 100; - -// Shared tiers — same boundaries for both graduated and volume products -const TIERS = [ - { to: 500, amount: 10 }, - { to: "inf" as const, amount: 5 }, -]; - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 1: Entity 1 (graduated) and Entity 2 (volume) — both switch pro → premium -// at 800 units (tier 2). Graduated charges $65; volume charges $40. -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Quantity: 800 units (tier 2). - * - * GRADUATED math (entity 1): - * Old prepaid: 5×$10 + 3×$5 = $65 - * New prepaid: 5×$10 + 3×$5 = $65 - * Prepaid delta: $0 (same model, same quantity) - * Switch cost = prorated base upgrade (> 0) - * - * VOLUME math (entity 2): - * Old prepaid: 8×$5 = $40 - * New prepaid: 8×$5 = $40 - * Graduated would be: 5×$10+3×$5 = $65 — confirms volume semantics - * Prepaid delta: $0 (same quantity, same tier) - * Switch cost = prorated base upgrade (> 0) - * - * Both have the same switch cost (only base proration, no prepaid delta). - * Total invoices on customer: 4 (initial pro × 2, switch invoice × 2). - */ -test.concurrent(`${chalk.yellowBright("attach-prepaid-volume-entities: graduated (entity 1) vs volume (entity 2), 800 units same tier, pro → premium")}`, async () => { - const customerId = "vol-ent-switch-800-same"; - - // ── Graduated products (entity 1) ── - const gradProItem = items.tieredPrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: TIERS, - }); - const gradPremiumItem = items.tieredPrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: TIERS, - }); - const gradPro = products.pro({ - id: "grad-pro-800", - items: [gradProItem], - }); - const gradPremium = products.premium({ - id: "grad-premium-800", - items: [gradPremiumItem], - }); - - // ── Volume products (entity 2) ── - const volProItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: TIERS, - }); - const volPremiumItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: TIERS, - }); - const volPro = products.pro({ - id: "vol-pro-800", - items: [volProItem], - }); - const volPremium = products.premium({ - id: "vol-premium-800", - items: [volPremiumItem], - }); - - const quantity = 800; - - const { autumnV1, entities, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [gradPro, gradPremium, volPro, volPremium] }), - s.entities({ count: 2, featureId: TestFeature.Users }), - ], - actions: [ - // Entity 1 → graduated pro - s.billing.attach({ - productId: gradPro.id, - entityIndex: 0, - options: [{ feature_id: TestFeature.Messages, quantity }], - }), - // Entity 2 → volume pro - s.billing.attach({ - productId: volPro.id, - entityIndex: 1, - options: [{ feature_id: TestFeature.Messages, quantity }], - }), - ], - }); - - // ── Verify initial state ── - const entity1Before = await autumnV1.entities.get( - customerId, - entities[0].id, - ); - await expectProductActive({ customer: entity1Before, productId: gradPro.id }); - expectCustomerFeatureCorrect({ - customer: entity1Before, - featureId: TestFeature.Messages, - balance: quantity, - usage: 0, - }); - - const entity2Before = await autumnV1.entities.get( - customerId, - entities[1].id, - ); - await expectProductActive({ customer: entity2Before, productId: volPro.id }); - expectCustomerFeatureCorrect({ - customer: entity2Before, - featureId: TestFeature.Messages, - balance: quantity, - usage: 0, - }); - - // ── Preview entity 1 switch (graduated): prepaid delta = $0 ── - const previewEnt1 = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: gradPremium.id, - entity_id: entities[0].id, - options: [{ feature_id: TestFeature.Messages, quantity }], - }); - expect(previewEnt1.total).toBeGreaterThan(0); - - // ── Preview entity 2 switch (volume): prepaid delta = $0 ── - const previewEnt2 = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: volPremium.id, - entity_id: entities[1].id, - options: [{ feature_id: TestFeature.Messages, quantity }], - }); - expect(previewEnt2.total).toBeGreaterThan(0); - - // ── Switch entity 1: graduated pro → graduated premium ── - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: gradPremium.id, - entity_id: entities[0].id, - options: [{ feature_id: TestFeature.Messages, quantity }], - redirect_mode: "if_required", - }); - - // ── Switch entity 2: volume pro → volume premium ── - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: volPremium.id, - entity_id: entities[1].id, - options: [{ feature_id: TestFeature.Messages, quantity }], - redirect_mode: "if_required", - }); - - // ── Assert entity 1 post-switch ── - const entity1 = await autumnV1.entities.get( - customerId, - entities[0].id, - ); - await expectCustomerProducts({ - customer: entity1, - active: [gradPremium.id], - notPresent: [gradPro.id], - }); - expectCustomerFeatureCorrect({ - customer: entity1, - featureId: TestFeature.Messages, - balance: quantity, - usage: 0, - }); - - // ── Assert entity 2 post-switch ── - const entity2 = await autumnV1.entities.get( - customerId, - entities[1].id, - ); - await expectCustomerProducts({ - customer: entity2, - active: [volPremium.id], - notPresent: [volPro.id], - }); - expectCustomerFeatureCorrect({ - customer: entity2, - featureId: TestFeature.Messages, - balance: quantity, - usage: 0, - }); - - // ── Verify customer-level invoices ── - // Invoice 0: entity 1 switch (prorated base upgrade, graduated, prepaid delta $0) - // Invoice 1: entity 2 switch (prorated base upgrade, volume, prepaid delta $0) - // Invoice 2: entity 1 initial (graduated pro + 800 units = $20 + $65 = $85) - // Invoice 3: entity 2 initial (volume pro + 800 units = $20 + $40 = $60) - const customer = await autumnV1.customers.get(customerId); - await expectCustomerInvoiceCorrect({ - customer, - count: 4, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 2: Entity 1 (graduated) and Entity 2 (volume) — switch from 300 → 800 units -// This is the KEY differentiator: graduated charges $65, volume charges $40. -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * Initial quantity: 300 units (tier 1). New quantity: 800 units (tier 2). - * - * GRADUATED math (entity 1): - * Old prepaid: 3×$10 = $30 (tier 1) - * New prepaid: 5×$10 + 3×$5 = $65 (graduated across tiers) - * Prepaid delta: $65 − $30 = +$35 - * Switch invoice ≈ base proration + $35 - * - * VOLUME math (entity 2): - * Old prepaid: 3×$10 = $30 (tier 1, volume same as graduated here) - * New prepaid: 8×$5 = $40 ← volume: all 8 packs at tier-2 rate - * Graduated would be: 5×$10+3×$5 = $65 — KEY DIFFERENTIATOR - * Prepaid delta: $40 − $30 = +$10 - * Switch invoice ≈ base proration + $10 - * - * The graduated entity pays $25 more in prepaid delta than the volume entity, - * confirming that each item uses its own pricing model independently. - */ -test.concurrent(`${chalk.yellowBright("attach-prepaid-volume-entities: graduated ($65) vs volume ($40), 300 → 800 units tier 1 → tier 2")}`, async () => { - const customerId = "vol-ent-switch-300-800"; - - // ── Graduated products (entity 1) ── - const gradProItem = items.tieredPrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: TIERS, - }); - const gradPremiumItem = items.tieredPrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: TIERS, - }); - const gradPro = products.pro({ - id: "grad-pro-300-800", - items: [gradProItem], - }); - const gradPremium = products.premium({ - id: "grad-premium-300-800", - items: [gradPremiumItem], - }); - - // ── Volume products (entity 2) ── - const volProItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: TIERS, - }); - const volPremiumItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: TIERS, - }); - const volPro = products.pro({ - id: "vol-pro-300-800", - items: [volProItem], - }); - const volPremium = products.premium({ - id: "vol-premium-300-800", - items: [volPremiumItem], - }); - - const initQuantity = 300; - const newQuantity = 800; - - const { autumnV1, entities, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [gradPro, gradPremium, volPro, volPremium] }), - s.entities({ count: 2, featureId: TestFeature.Users }), - ], - actions: [ - s.billing.attach({ - productId: gradPro.id, - entityIndex: 0, - options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }], - }), - s.billing.attach({ - productId: volPro.id, - entityIndex: 1, - options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }], - }), - ], - }); - - // ── Preview switches to capture expected switch totals ── - // Entity 1 (graduated): prepaid delta = +$35 (new $65 − old $30) - const previewEnt1 = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: gradPremium.id, - entity_id: entities[0].id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - }); - expect(previewEnt1.total).toBeGreaterThan(0); - - // Entity 2 (volume): prepaid delta = +$10 (new $40 − old $30) - // previewEnt2.total should be LESS than previewEnt1.total by ~$25 - const previewEnt2 = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: volPremium.id, - entity_id: entities[1].id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - }); - expect(previewEnt2.total).toBeGreaterThan(0); - - // The graduated entity pays more: graduated new prepaid ($65) > volume new prepaid ($40) - // so graduated switch invoice > volume switch invoice by ~$25 - expect(previewEnt1.total).toBeGreaterThan(previewEnt2.total); - - // ── Perform switches ── - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: gradPremium.id, - entity_id: entities[0].id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - redirect_mode: "if_required", - }); - - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: volPremium.id, - entity_id: entities[1].id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - redirect_mode: "if_required", - }); - - // ── Assert entity 1 (graduated) post-switch ── - const entity1 = await autumnV1.entities.get( - customerId, - entities[0].id, - ); - await expectCustomerProducts({ - customer: entity1, - active: [gradPremium.id], - notPresent: [gradPro.id], - }); - expectCustomerFeatureCorrect({ - customer: entity1, - featureId: TestFeature.Messages, - balance: newQuantity, - usage: 0, - }); - - // ── Assert entity 2 (volume) post-switch ── - const entity2 = await autumnV1.entities.get( - customerId, - entities[1].id, - ); - await expectCustomerProducts({ - customer: entity2, - active: [volPremium.id], - notPresent: [volPro.id], - }); - expectCustomerFeatureCorrect({ - customer: entity2, - featureId: TestFeature.Messages, - balance: newQuantity, - usage: 0, - }); - - // ── Verify customer invoices ── - // Invoice 0 (latest): entity 2 switch (volume, prepaid delta +$10 = preview) - // Invoice 1: entity 1 switch (graduated, prepaid delta +$35 = preview) - // Invoice 2: entity 2 initial (volume pro + 300 units = $20 + $30 = $50) - // Invoice 3: entity 1 initial (graduated pro + 300 units = $20 + $30 = $50) - // Both initial invoices are equal because 300 units is all tier 1 (volume=graduated there) - const customer = await autumnV1.customers.get(customerId); - await expectCustomerInvoiceCorrect({ - customer, - count: 4, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); diff --git a/server/tests/integration/billing/attach/immediate-switch/immediate-switch-prepaid-volume.test.ts b/server/tests/integration/billing/attach/immediate-switch/immediate-switch-prepaid-volume.test.ts deleted file mode 100644 index 9c96b8e3f..000000000 --- a/server/tests/integration/billing/attach/immediate-switch/immediate-switch-prepaid-volume.test.ts +++ /dev/null @@ -1,336 +0,0 @@ -/** - * Attach Prepaid Volume Pricing — Immediate Switch Tests (Upgrade: pro → premium) - * - * Autumn charges the prorated base difference plus the prepaid delta immediately. - * Old product disappears; new product is immediately active. - * - * Tiers used throughout (billingUnits = 100): - * Tier 1: 0–500 units @ $10/pack (100 units/pack) - * Tier 2: 501+ units @ $5/pack - */ - -import { expect, test } from "bun:test"; -import type { ApiCustomerV3 } from "@autumn/shared"; -import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; -import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; -import { - expectCustomerProducts, - expectProductActive, -} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; -import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; -import { TestFeature } from "@tests/setup/v2Features"; -import { items } from "@tests/utils/fixtures/items"; -import { products } from "@tests/utils/fixtures/products"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; -import chalk from "chalk"; - -const BILLING_UNITS = 100; - -// Tiers (in Autumn units, i.e. packs of 100): -// 0–500 units @ $10/pack → Stripe tier boundary: 500/100 = 5 -// 501+ units @ $5/pack -const VOLUME_TIERS = [ - { to: 500, amount: 10 }, - { to: "inf" as const, amount: 5 }, -]; - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 5: Immediate switch, 300 units, tier 1 → tier 1 (same quantity) -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * pro ($20/mo) + 300 units → tier 1 → 3 packs × $10 = $30 - * premium ($50/mo) + 300 units → tier 1 → 3 packs × $10 = $30 - * - * Volume math: - * Old prepaid: 3 × $10 = $30 - * New prepaid: 3 × $10 = $30 - * Prepaid delta: $0 - * Switch total = prorated base upgrade ($50 - $20 × remaining fraction) > 0 - */ -test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: immediate switch, 300 units tier 1 → tier 1")}`, async () => { - const customerId = "attach-prepaid-volume-imm-t1"; - const initQuantity = 300; - const newQuantity = 300; - - const proItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - const premiumItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - - const pro = products.pro({ id: "pro-volume-imm-t1", items: [proItem] }); - const premium = products.premium({ - id: "premium-volume-imm-t1", - items: [premiumItem], - }); - - const { autumnV1, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium] }), - ], - actions: [ - s.billing.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }], - }), - ], - }); - - const customerBefore = - await autumnV1.customers.get(customerId); - await expectProductActive({ customer: customerBefore, productId: pro.id }); - expectCustomerFeatureCorrect({ - customer: customerBefore, - featureId: TestFeature.Messages, - balance: initQuantity, - usage: 0, - }); - - // Preview — prepaid delta = $0 (same tier, same quantity); only prorated base upgrade - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: premium.id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - }); - expect(preview.total).toEqual(30); - - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: premium.id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - redirect_mode: "if_required", - }); - - const customer = await autumnV1.customers.get(customerId); - await expectCustomerProducts({ - customer, - active: [premium.id], - notPresent: [pro.id], - }); - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: newQuantity, - usage: 0, - }); - // latestTotal ≈ preview.total (prorated base upgrade, no prepaid delta) - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: preview.total, - }); - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 6: Immediate switch, 300 → 800 units, tier 1 → tier 2 -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * pro ($20/mo) + 300 units → tier 1 → 3 packs × $10 = $30 - * premium ($50/mo) + 800 units → tier 2 → 8 packs × $5 = $40 - * - * Volume math (key differentiator vs graduated): - * Old prepaid: 3 × $10 = $30 - * New prepaid: 8 × $5 = $40 ← volume: all 8 packs at tier-2 rate - * Graduated would be: 5×$10 + 3×$5 = $65 (different — confirms volume) - * Prepaid delta: $40 − $30 = +$10 - */ -test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: immediate switch, 300 → 800 units tier 1 → tier 2 ($40 not $65 graduated)")}`, async () => { - const customerId = "attach-prepaid-volume-imm-t1-t2"; - const initQuantity = 300; - const newQuantity = 800; - - const proItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - const premiumItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - - const pro = products.pro({ id: "pro-volume-imm-t1-t2", items: [proItem] }); - const premium = products.premium({ - id: "premium-volume-imm-t1-t2", - items: [premiumItem], - }); - - const { autumnV1, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium] }), - ], - actions: [ - s.billing.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }], - }), - ], - }); - - const customerBefore = - await autumnV1.customers.get(customerId); - await expectProductActive({ customer: customerBefore, productId: pro.id }); - expectCustomerFeatureCorrect({ - customer: customerBefore, - featureId: TestFeature.Messages, - balance: initQuantity, - usage: 0, - }); - - // Preview — prepaid delta = +$10 (new: $40 volume, old: $30) plus base proration - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: premium.id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - }); - expect(preview.total).toEqual(10 + 30); - - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: premium.id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - redirect_mode: "if_required", - }); - - const customer = await autumnV1.customers.get(customerId); - await expectCustomerProducts({ - customer, - active: [premium.id], - notPresent: [pro.id], - }); - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: newQuantity, - usage: 0, - }); - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: preview.total, - }); - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 7: Immediate switch, 800 → 600 units, tier 2 → tier 2 (quantity decreases) -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * pro ($20/mo) + 800 units → tier 2 → 8 packs × $5 = $40 - * premium ($50/mo) + 600 units → tier 2 → 6 packs × $5 = $30 - * - * Volume math (key differentiator vs graduated): - * Old prepaid: 8 × $5 = $40 - * New prepaid: 6 × $5 = $30 ← volume: all 6 packs at tier-2 rate - * Graduated would be: 5×$10 + 1×$5 = $55 (different — confirms volume) - * Prepaid delta: $30 − $40 = −$10 (credit, offsets base proration) - */ -test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: immediate switch, 800 → 600 units tier 2 → tier 2 ($30 not $55 graduated)")}`, async () => { - const customerId = "attach-prepaid-volume-imm-t2-t2"; - const initQuantity = 800; - const newQuantity = 600; - - const proItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - const premiumItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - - const pro = products.pro({ id: "pro-volume-imm-t2-t2", items: [proItem] }); - const premium = products.premium({ - id: "premium-volume-imm-t2-t2", - items: [premiumItem], - }); - - const { autumnV1, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro, premium] }), - ], - actions: [ - s.billing.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }], - }), - ], - }); - - const customerBefore = - await autumnV1.customers.get(customerId); - await expectProductActive({ customer: customerBefore, productId: pro.id }); - expectCustomerFeatureCorrect({ - customer: customerBefore, - featureId: TestFeature.Messages, - balance: initQuantity, - usage: 0, - }); - - // Preview — prepaid delta = −$10 (new: $30 volume, old: $40) credited against base proration - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: premium.id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - }); - expect(typeof preview.total).toBe("number"); - - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: premium.id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - redirect_mode: "if_required", - }); - - const customer = await autumnV1.customers.get(customerId); - await expectCustomerProducts({ - customer, - active: [premium.id], - notPresent: [pro.id], - }); - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: newQuantity, - usage: 0, - }); - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: preview.total, - }); - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); diff --git a/server/tests/integration/billing/attach/new-plan/attach-prepaid-volume-entities.test.ts b/server/tests/integration/billing/attach/new-plan/attach-prepaid-volume-entities.test.ts deleted file mode 100644 index 358d3c335..000000000 --- a/server/tests/integration/billing/attach/new-plan/attach-prepaid-volume-entities.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * Attach Prepaid Volume vs Graduated — Entity-Level Initial Attach Test - * - * Two entities share one customer. One is on graduated pricing, the other on - * volume pricing, for the same tier structure. Confirms that the initial - * invoice totals correctly reflect each pricing model independently. - * - * Tiers (billingUnits = 100): - * Tier 1: 0–500 units @ $10/pack - * Tier 2: 501+ units @ $5/pack - * - * 800 units, tier 2: - * Graduated: 5×$10 + 3×$5 = $65 - * Volume: 8×$5 = $40 ← cheaper, all at tier-2 rate - */ - -import { expect, test } from "bun:test"; -import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared"; -import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; -import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; -import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; -import { TestFeature } from "@tests/setup/v2Features"; -import { items } from "@tests/utils/fixtures/items"; -import { products } from "@tests/utils/fixtures/products"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; -import chalk from "chalk"; - -const BILLING_UNITS = 100; -const BASE_PRICE = 20; -const TIERS = [ - { to: 500, amount: 10 }, - { to: "inf" as const, amount: 5 }, -]; - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST: Entity 1 (graduated) and Entity 2 (volume) — initial attach at 800 units -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * 800 units crosses into tier 2. - * - * Entity 1 (graduated): 5×$10 + 3×$5 = $65 → invoice = $20 + $65 = $85 - * Entity 2 (volume): 8×$5 = $40 → invoice = $20 + $40 = $60 - * - * Confirms both pricing models are applied independently per entity. - */ -test.concurrent(`${chalk.yellowBright("attach-prepaid-volume-entities: graduated ($65) vs volume ($40) at 800 units tier 2")}`, async () => { - const customerId = "vol-ent-initial-800"; - const quantity = 800; - - // Graduated: 5×$10 + 3×$5 = $65 - const gradExpectedPrepaid = 5 * 10 + 3 * 5; - // Volume: 8×$5 = $40 - const volExpectedPrepaid = (quantity / BILLING_UNITS) * 5; - - const gradItem = items.tieredPrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: TIERS, - }); - const volItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: TIERS, - }); - - const gradPro = products.pro({ id: "grad-pro-ent-800", items: [gradItem] }); - const volPro = products.pro({ id: "vol-pro-ent-800", items: [volItem] }); - - const { autumnV1, entities } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [gradPro, volPro] }), - s.entities({ count: 2, featureId: TestFeature.Users }), - ], - actions: [], - }); - - // ── Preview entity 1 (graduated): $20 + $65 = $85 ── - const previewGrad = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: gradPro.id, - entity_id: entities[0].id, - options: [{ feature_id: TestFeature.Messages, quantity }], - }); - expect(previewGrad.total).toBe(BASE_PRICE + gradExpectedPrepaid); - - // ── Preview entity 2 (volume): $20 + $40 = $60 ── - const previewVol = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: volPro.id, - entity_id: entities[1].id, - options: [{ feature_id: TestFeature.Messages, quantity }], - }); - expect(previewVol.total).toBe(BASE_PRICE + volExpectedPrepaid); - - // ── Attach both ── - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: gradPro.id, - entity_id: entities[0].id, - options: [{ feature_id: TestFeature.Messages, quantity }], - redirect_mode: "if_required", - }); - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: volPro.id, - entity_id: entities[1].id, - options: [{ feature_id: TestFeature.Messages, quantity }], - redirect_mode: "if_required", - }); - - // ── Assert entity 1 (graduated) ── - const entity1 = await autumnV1.entities.get( - customerId, - entities[0].id, - ); - await expectProductActive({ customer: entity1, productId: gradPro.id }); - expectCustomerFeatureCorrect({ - customer: entity1, - featureId: TestFeature.Messages, - balance: quantity, - usage: 0, - }); - - // ── Assert entity 2 (volume) ── - const entity2 = await autumnV1.entities.get( - customerId, - entities[1].id, - ); - await expectProductActive({ customer: entity2, productId: volPro.id }); - expectCustomerFeatureCorrect({ - customer: entity2, - featureId: TestFeature.Messages, - balance: quantity, - usage: 0, - }); - - // ── Customer invoices: 2 total, latest is the volume attach ($60) ── - // Invoice 0 (latest): entity 2 volume — $60 - // Invoice 1: entity 1 graduated — $85 - const customer = await autumnV1.customers.get(customerId); - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: BASE_PRICE + volExpectedPrepaid, - }); - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - invoiceIndex: 1, - latestTotal: BASE_PRICE + gradExpectedPrepaid, - }); -}); 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 deleted file mode 100644 index 84e9d6770..000000000 --- a/server/tests/integration/billing/attach/new-plan/attach-prepaid-volume.test.ts +++ /dev/null @@ -1,424 +0,0 @@ -/** - * Attach Prepaid Volume Pricing Tests - * - * Verifies that volume-based tier pricing charges the entire purchased quantity - * at the rate of whichever single tier it falls into — not split across tiers - * the way graduated pricing works. - * - * Tiers used throughout (billingUnits = 100): - * Tier 1: 0–500 units @ $10/pack (100 units/pack) - * Tier 2: 501+ units @ $5/pack - * - * Volume pricing examples vs graduated: - * 300 units (tier 1): volume = 3 packs × $10 = $30 - * graduated would also be $30 (all within tier 1) - * 800 units (tier 2): volume = 8 packs × $5 = $40 - * graduated would be 5×$10 + 3×$5 = $65 (different!) - * - * Plan switch tests (immediate + scheduled) are in: - * attach-prepaid-volume-switch.test.ts - */ - -import { expect, test } from "bun:test"; -import type { ApiCustomerV3 } from "@autumn/shared"; -import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; -import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; -import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; -import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; -import { TestFeature } from "@tests/setup/v2Features"; -import { items } from "@tests/utils/fixtures/items"; -import { products } from "@tests/utils/fixtures/products"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; -import chalk from "chalk"; - -const BILLING_UNITS = 100; -const BASE_PRICE = 20; // pro product base price - -// Tiers (in Autumn units, i.e. packs of 100): -// 0–500 units @ $10/pack → Stripe tier boundary: 500/100 = 5 -// 501+ units @ $5/pack -const VOLUME_TIERS = [ - { to: 500, amount: 10 }, - { to: "inf" as const, amount: 5 }, -]; - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 1: Quantity within tier 1 — volume and graduated produce the same result -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * 300 units = 3 packs, all within tier 1. - * Volume: 3 × $10 = $30. - * Graduated would also be $30 here (same tier). - */ -test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: 300 units, tier 1 only → $30")}`, async () => { - const customerId = "attach-prepaid-volume-tier1"; - const quantity = 300; - const expectedPrepaidCost = (quantity / BILLING_UNITS) * 10; // 3 × $10 = $30 - - const volumeItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - - const pro = products.pro({ - id: "pro-volume-tier1", - items: [volumeItem], - }); - - const { autumnV1, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [], - }); - - // Preview must reflect volume pricing - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - }); - expect(preview.total).toBe(BASE_PRICE + expectedPrepaidCost); - - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - redirect_mode: "if_required", - }); - - const customer = await autumnV1.customers.get(customerId); - - await expectProductActive({ customer, productId: pro.id }); - - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: quantity, - usage: 0, - }); - - await expectCustomerInvoiceCorrect({ - customer, - count: 1, - latestTotal: BASE_PRICE + expectedPrepaidCost, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 2: Quantity crossing into tier 2 — volume differs from graduated -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * 800 units = 8 packs, falling into tier 2. - * Volume: entire 8 packs × $5 = $40. - * Graduated: 5 packs×$10 + 3 packs×$5 = $65. - * - * This test confirms volume semantics are applied end-to-end, not graduated. - */ -test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: 800 units, tier 2 → $40 (not $65 graduated)")}`, async () => { - const customerId = "attach-prepaid-volume-tier2"; - const quantity = 800; - // Volume: 8 packs × $5 = $40 - const expectedPrepaidCost = (quantity / BILLING_UNITS) * 5; - - const volumeItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - - const pro = products.pro({ - id: "pro-volume-tier2", - items: [volumeItem], - }); - - const { autumnV1, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [], - }); - - // Preview must reflect volume pricing ($40), not graduated ($65) - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - }); - expect(preview.total).toBe(BASE_PRICE + expectedPrepaidCost); - - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - redirect_mode: "if_required", - }); - - const customer = await autumnV1.customers.get(customerId); - - await expectProductActive({ customer, productId: pro.id }); - - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: quantity, - usage: 0, - }); - - await expectCustomerInvoiceCorrect({ - customer, - count: 1, - latestTotal: BASE_PRICE + expectedPrepaidCost, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 3: Quantity with included (free) usage in tier 1 -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * 300 total units, 100 free (1 pack included). - * Purchased above free: 200 units = 2 packs, both in tier 1. - * Volume: 2 × $10 = $20. - * Balance: 300. - */ -test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: 300 units, 100 included, tier 1 → $20")}`, async () => { - const customerId = "attach-prepaid-volume-included-tier1"; - const quantity = 300; - const includedUsage = 100; - // After free pack: 200 units = 2 packs in tier 1 → 2 × $10 = $20 - const expectedPrepaidCost = ((quantity - includedUsage) / BILLING_UNITS) * 10; - - const volumeItem = items.volumePrepaidMessages({ - includedUsage, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - - const pro = products.pro({ - id: "pro-volume-included-tier1", - items: [volumeItem], - }); - - const { autumnV1, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [], - }); - - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - }); - expect(preview.total).toBe(BASE_PRICE + expectedPrepaidCost); - - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - redirect_mode: "if_required", - }); - - const customer = await autumnV1.customers.get(customerId); - - await expectProductActive({ customer, productId: pro.id }); - - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: quantity, - usage: 0, - }); - - await expectCustomerInvoiceCorrect({ - customer, - count: 1, - latestTotal: BASE_PRICE + expectedPrepaidCost, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 4: Zero quantity — no prepaid charge, only base price -// ═══════════════════════════════════════════════════════════════════════════════ - -test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: quantity 0 → no prepaid charge")}`, async () => { - const customerId = "attach-prepaid-volume-zero"; - - const volumeItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - - const pro = products.pro({ - id: "pro-volume-zero", - items: [volumeItem], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [], - }); - - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: 0 }], - }); - expect(preview.total).toBe(BASE_PRICE); - - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: 0 }], - redirect_mode: "if_required", - }); - - const customer = await autumnV1.customers.get(customerId); - - await expectProductActive({ customer, productId: pro.id }); - - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: 0, - usage: 0, - }); - - await expectCustomerInvoiceCorrect({ - customer, - count: 1, - latestTotal: BASE_PRICE, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 5: 4 tiers with included usage — volume pricing at correct tier -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * 4 tiers (billingUnits = 100): - * Tier 1: 0-200 units @ $15/pack - * Tier 2: 201-500 units @ $10/pack - * Tier 3: 501-1000 units @ $7/pack - * Tier 4: 1001+ units @ $5/pack - * - * With 100 included (free) and 900 total quantity: - * Paid packs: (900 - 100) / 100 = 8 packs - * Volume: all 8 packs at tier 3 rate ($7) = $56 - * Total: $20 base + $56 = $76 - */ -test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: 4 tiers, 100 included, 900 total → $76")}`, async () => { - const customerId = "attach-prepaid-volume-4tier-incl"; - const quantity = 900; - const includedUsage = 100; - - // 4-tier pricing structure - const fourTiers = [ - { to: 200, amount: 15 }, - { to: 500, amount: 10 }, - { to: 1000, amount: 7 }, - { to: "inf" as const, amount: 5 }, - ]; - - // Paid packs after free: (900 - 100) / 100 = 8 packs - // 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 volumeItem = items.volumePrepaidMessages({ - includedUsage, - billingUnits: BILLING_UNITS, - tiers: fourTiers, - }); - - const pro = products.pro({ - id: "pro-volume-4tier", - items: [volumeItem], - }); - - const { autumnV1, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [], - }); - - // Preview must reflect volume pricing at tier 3 - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - }); - expect(preview.total).toBe(BASE_PRICE + expectedPrepaidCost); - - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - redirect_mode: "if_required", - }); - - const customer = await autumnV1.customers.get(customerId); - - await expectProductActive({ customer, productId: pro.id }); - - // Balance should equal total quantity (free + paid) - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: quantity, - usage: 0, - }); - - await expectCustomerInvoiceCorrect({ - customer, - count: 1, - latestTotal: BASE_PRICE + expectedPrepaidCost, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); diff --git a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-prepaid-volume.test.ts b/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-prepaid-volume.test.ts deleted file mode 100644 index 5468691a9..000000000 --- a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-prepaid-volume.test.ts +++ /dev/null @@ -1,521 +0,0 @@ -/** - * Attach Prepaid Volume Pricing — Scheduled Switch Tests (Downgrade: premium → pro) - * - * Downgrading to a lower base price is scheduled. preview.total === 0 always. - * Old product becomes "canceling" (active + canceled_at set), new product is "scheduled". - * After the billing cycle advances the new product becomes active with a fresh invoice. - * - * Each scenario has two test.concurrent calls: - * ...-mid — asserts mid-cycle state (canceling + scheduled) - * ...-after-cycle — advances clock and asserts post-cycle state - * - * Tiers used throughout (billingUnits = 100): - * Tier 1: 0–500 units @ $10/pack (100 units/pack) - * Tier 2: 501+ units @ $5/pack - */ - -import { expect, test } from "bun:test"; -import type { ApiCustomerV3 } from "@autumn/shared"; -import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; -import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; -import { - expectCustomerProducts, - expectProductCanceling, - expectProductScheduled, -} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; -import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; -import { TestFeature } from "@tests/setup/v2Features"; -import { items } from "@tests/utils/fixtures/items"; -import { products } from "@tests/utils/fixtures/products"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; -import chalk from "chalk"; - -const BILLING_UNITS = 100; -const PRO_BASE_PRICE = 20; - -// Tiers (in Autumn units, i.e. packs of 100): -// 0–500 units @ $10/pack → Stripe tier boundary: 500/100 = 5 -// 501+ units @ $5/pack -const VOLUME_TIERS = [ - { to: 500, amount: 10 }, - { to: "inf" as const, amount: 5 }, -]; - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 8: Scheduled switch, 300 units, tier 1 → tier 1 (same quantity) -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * premium ($50/mo) + 300 units → tier 1 → 3 packs × $10 = $30 - * Downgrade: pro ($20/mo) + 300 units - * - * Scheduled: premium stays active (canceling) until cycle ends. - * preview.total === 0 always for downgrades. - */ - -// --- Mid-cycle --- -test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: scheduled switch mid-cycle, 300 units tier 1 → tier 1")}`, async () => { - const customerId = "attach-prepaid-volume-sched-mid-t1"; - const initQuantity = 300; - const newQuantity = 300; - - const premiumItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - const proItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - - const premium = products.premium({ - id: "premium-volume-sched-mid-t1", - items: [premiumItem], - }); - const pro = products.pro({ - id: "pro-volume-sched-mid-t1", - items: [proItem], - }); - - const { autumnV1, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [premium, pro] }), - ], - actions: [ - s.billing.attach({ - productId: premium.id, - options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }], - }), - ], - }); - - // Preview for downgrade is always $0 - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - }); - expect(preview.total).toBe(0); - - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - redirect_mode: "if_required", - }); - - const customer = await autumnV1.customers.get(customerId); - await expectProductCanceling({ customer, productId: premium.id }); - await expectProductScheduled({ customer, productId: pro.id }); - // Old plan still active — balance reflects original quantity - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: initQuantity, - usage: 0, - }); - // No new invoice charged for a downgrade - await expectCustomerInvoiceCorrect({ customer, count: 1 }); - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// --- After-cycle --- -test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: scheduled switch after cycle, 300 units tier 1 → tier 1 (pro $20 + $30)")}`, async () => { - const customerId = "attach-prepaid-volume-sched-after-t1"; - const initQuantity = 300; - const newQuantity = 300; - // Volume: 3 packs × $10 = $30 (all tier 1) - const expectedNewPrepaid = (newQuantity / BILLING_UNITS) * 10; - - const premiumItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - const proItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - - const premium = products.premium({ - id: "premium-volume-sched-after-t1", - items: [premiumItem], - }); - const pro = products.pro({ - id: "pro-volume-sched-after-t1", - items: [proItem], - }); - - const { autumnV1, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ testClock: true, paymentMethod: "success" }), - s.products({ list: [premium, pro] }), - ], - actions: [ - s.billing.attach({ - productId: premium.id, - options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }], - }), - s.billing.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - }), - s.advanceToNextInvoice(), - ], - }); - - const customer = await autumnV1.customers.get(customerId); - await expectCustomerProducts({ - customer, - active: [pro.id], - notPresent: [premium.id], - }); - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: newQuantity, - usage: 0, - }); - // New invoice: pro base $20 + 3 × $10 = $50 - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: PRO_BASE_PRICE + expectedNewPrepaid, - }); - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 9: Scheduled switch, 800 → 300 units, tier 2 → tier 1 -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * premium ($50/mo) + 800 units → tier 2 → 8 packs × $5 = $40 - * Downgrade: pro ($20/mo) + 300 units - * - * After cycle: pro base $20 + 300 units tier 1 (3 × $10 = $30) = $50. - * Volume vs graduated: same here (300 units all in tier 1 — no difference). - */ - -// --- Mid-cycle --- -test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: scheduled switch mid-cycle, 800 → 300 units tier 2 → tier 1")}`, async () => { - const customerId = "attach-prepaid-volume-sched-mid-t2-t1"; - const initQuantity = 800; - const newQuantity = 300; - - const premiumItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - const proItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - - const premium = products.premium({ - id: "premium-volume-sched-mid-t2-t1", - items: [premiumItem], - }); - const pro = products.pro({ - id: "pro-volume-sched-mid-t2-t1", - items: [proItem], - }); - - const { autumnV1, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [premium, pro] }), - ], - actions: [ - s.billing.attach({ - productId: premium.id, - options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }], - }), - ], - }); - - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - }); - expect(preview.total).toBe(0); - - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - redirect_mode: "if_required", - }); - - const customer = await autumnV1.customers.get(customerId); - await expectProductCanceling({ customer, productId: premium.id }); - await expectProductScheduled({ customer, productId: pro.id }); - // Old plan still active — balance reflects original (800) quantity - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: initQuantity, - usage: 0, - }); - await expectCustomerInvoiceCorrect({ customer, count: 1 }); - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// --- After-cycle --- -test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: scheduled switch after cycle, 800 → 300 units tier 2 → tier 1 (pro $20 + $30)")}`, async () => { - const customerId = "attach-prepaid-volume-sched-after-t2-t1"; - const initQuantity = 800; - const newQuantity = 300; - // Volume: 3 packs × $10 = $30 (all tier 1; graduated same here) - const expectedNewPrepaid = (newQuantity / BILLING_UNITS) * 10; - - const premiumItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - const proItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - - const premium = products.premium({ - id: "premium-volume-sched-after-t2-t1", - items: [premiumItem], - }); - const pro = products.pro({ - id: "pro-volume-sched-after-t2-t1", - items: [proItem], - }); - - const { autumnV1, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ testClock: true, paymentMethod: "success" }), - s.products({ list: [premium, pro] }), - ], - actions: [ - s.billing.attach({ - productId: premium.id, - options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }], - }), - s.billing.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - }), - s.advanceToNextInvoice(), - ], - }); - - const customer = await autumnV1.customers.get(customerId); - await expectCustomerProducts({ - customer, - active: [pro.id], - notPresent: [premium.id], - }); - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: newQuantity, - usage: 0, - }); - // New invoice: pro base $20 + 3 × $10 = $50 - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: PRO_BASE_PRICE + expectedNewPrepaid, - }); - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 10: Scheduled switch, 1000 → 600 units, tier 2 → tier 2 -// ═══════════════════════════════════════════════════════════════════════════════ - -/** - * premium ($50/mo) + 1000 units → tier 2 → 10 packs × $5 = $50 - * Downgrade: pro ($20/mo) + 600 units - * - * After cycle: pro base $20 + 600 units → tier 2 → 6 × $5 = $30 (volume!) - * Graduated would be: 5×$10 + 1×$5 = $55 — KEY DIFFERENTIATOR - * latestTotal: $20 + $30 = $50 - */ - -// --- Mid-cycle --- -test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: scheduled switch mid-cycle, 1000 → 600 units tier 2 → tier 2")}`, async () => { - const customerId = "attach-prepaid-volume-sched-mid-t2-t2"; - const initQuantity = 1000; - const newQuantity = 600; - - const premiumItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - const proItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - - const premium = products.premium({ - id: "premium-volume-sched-mid-t2-t2", - items: [premiumItem], - }); - const pro = products.pro({ - id: "pro-volume-sched-mid-t2-t2", - items: [proItem], - }); - - const { autumnV1, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [premium, pro] }), - ], - actions: [ - s.billing.attach({ - productId: premium.id, - options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }], - }), - ], - }); - - const preview = await autumnV1.billing.previewAttach({ - customer_id: customerId, - product_id: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - }); - expect(preview.total).toBe(0); - - await autumnV1.billing.attach({ - customer_id: customerId, - product_id: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - redirect_mode: "if_required", - }); - - const customer = await autumnV1.customers.get(customerId); - await expectProductCanceling({ customer, productId: premium.id }); - await expectProductScheduled({ customer, productId: pro.id }); - // Old plan still active — balance reflects original (1000) quantity - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: initQuantity, - usage: 0, - }); - await expectCustomerInvoiceCorrect({ customer, count: 1 }); - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// --- After-cycle --- -test.concurrent(`${chalk.yellowBright("attach-prepaid-volume: scheduled switch after cycle, 1000 → 600 units tier 2 → tier 2 (pro $20 + $30 volume, not $55 graduated)")}`, async () => { - const customerId = "attach-prepaid-volume-sched-after-t2-t2"; - const initQuantity = 1000; - const newQuantity = 600; - // Volume: 6 packs × $5 = $30 (all at tier-2 rate) - // Graduated would be: 5×$10 + 1×$5 = $55 — this confirms volume semantics - const expectedNewPrepaid = (newQuantity / BILLING_UNITS) * 5; - - const premiumItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - const proItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - - const premium = products.premium({ - id: "premium-volume-sched-after-t2-t2", - items: [premiumItem], - }); - const pro = products.pro({ - id: "pro-volume-sched-after-t2-t2", - items: [proItem], - }); - - const { autumnV1, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ testClock: true, paymentMethod: "success" }), - s.products({ list: [premium, pro] }), - ], - actions: [ - s.billing.attach({ - productId: premium.id, - options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }], - }), - s.billing.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - }), - s.advanceToNextInvoice(), - ], - }); - - const customer = await autumnV1.customers.get(customerId); - await expectCustomerProducts({ - customer, - active: [pro.id], - notPresent: [premium.id], - }); - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: newQuantity, - usage: 0, - }); - // New invoice: pro base $20 + 6 × $5 (volume) = $50 - // Graduated would be: $20 + 5×$10 + 1×$5 = $75 — confirms volume semantics - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: PRO_BASE_PRICE + expectedNewPrepaid, - }); - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); 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 deleted file mode 100644 index a057ce8dc..000000000 --- a/server/tests/integration/billing/legacy/attach/new/legacy-new-volume.test.ts +++ /dev/null @@ -1,390 +0,0 @@ -/** - * Legacy New Attach — Volume Pricing Tests - * - * Tests that the V1 attach() path applies volume tier semantics correctly. - * V1 quantity = purchased units (excludes includedUsage). - * Volume pricing charges the ENTIRE purchased quantity at the rate of its tier - * (not split across tiers like graduated pricing). - * - * Tiers (billingUnits = 100): - * Tier 1: 0–500 units @ $10/pack - * Tier 2: 501+ units @ $5/pack - * - * Base product price: $20/month (products.pro) - * - * V1 vs V2 quantity reminder: - * V1: options[].quantity = purchased units (EXCLUDES includedUsage) - * V2: options[].quantity = total units (INCLUDES includedUsage) - * For includedUsage=0, V1 and V2 quantities are identical. - */ - -/** biome-ignore-all lint/suspicious/noExplicitAny: test file */ - -import { test } from "bun:test"; -import type { ApiCustomerV3 } from "@autumn/shared"; -import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; -import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; -import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; -import { TestFeature } from "@tests/setup/v2Features"; -import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached"; -import { items } from "@tests/utils/fixtures/items"; -import { products } from "@tests/utils/fixtures/products"; -import ctx from "@tests/utils/testInitUtils/createTestContext"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; -import chalk from "chalk"; - -const BILLING_UNITS = 100; -const BASE_PRICE = 20; - -const VOLUME_TIERS = [ - { to: 500, amount: 10 }, - { to: "inf" as const, amount: 5 }, -]; - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 1: purchased=300, allowance=0, tier 1 → invoice $50 -// -// V1 options.quantity: 300 (purchased; allowance = 0) -// Total balance: 300 -// -// Volume math: -// 300 units → tier 1 → 3 × $10 = $30 -// Graduated would also be $30 (same tier — no differentiator) -// -// Invoice total: $20 (base) + $30 (prepaid) = $50 -// ═══════════════════════════════════════════════════════════════════════════════ - -test.concurrent(`${chalk.yellowBright("legacy-new-volume 1: purchased=300, no allowance, tier 1 → $50")}`, async () => { - const customerId = "legacy-new-volume-t1"; - const purchasedQuantity = 300; - const includedUsage = 0; - // Volume: 3 packs × $10 = $30; graduated would also be $30 (same tier) - const expectedPrepaid = (purchasedQuantity / BILLING_UNITS) * 10; - - const volumeItem = items.volumePrepaidMessages({ - includedUsage, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - const pro = products.pro({ id: "pro-legacy-volume-t1", items: [volumeItem] }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [ - s.attach({ - productId: pro.id, - options: [ - { feature_id: TestFeature.Messages, quantity: purchasedQuantity }, - ], - }), - ], - }); - - const customer = await autumnV1.customers.get(customerId); - - expectProductAttached({ customer: customer as any, product: pro }); - - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: includedUsage + purchasedQuantity, - usage: 0, - }); - - await expectCustomerInvoiceCorrect({ - customer, - count: 1, - latestTotal: BASE_PRICE + expectedPrepaid, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 2: purchased=800, allowance=0, tier 2 → invoice $60 -// -// V1 options.quantity: 800 -// Total balance: 800 -// -// Volume math: -// 800 units → tier 2 → 8 × $5 = $40 -// Graduated would be: 5×$10 + 3×$5 = $65 — KEY DIFFERENTIATOR -// -// Invoice total: $20 (base) + $40 (prepaid) = $60 -// ═══════════════════════════════════════════════════════════════════════════════ - -test.concurrent(`${chalk.yellowBright("legacy-new-volume 2: purchased=800, no allowance, tier 2 → $60 (graduated would be $65)")}`, async () => { - const customerId = "legacy-new-volume-t2"; - const purchasedQuantity = 800; - const includedUsage = 0; - // Volume: 8 packs × $5 = $40; graduated would be: 5×$10 + 3×$5 = $65 - const expectedPrepaid = (purchasedQuantity / BILLING_UNITS) * 5; - - const volumeItem = items.volumePrepaidMessages({ - includedUsage, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - const pro = products.pro({ id: "pro-legacy-volume-t2", items: [volumeItem] }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [ - s.attach({ - productId: pro.id, - options: [ - { feature_id: TestFeature.Messages, quantity: purchasedQuantity }, - ], - }), - ], - }); - - const customer = await autumnV1.customers.get(customerId); - - expectProductAttached({ customer: customer as any, product: pro }); - - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: includedUsage + purchasedQuantity, - usage: 0, - }); - - await expectCustomerInvoiceCorrect({ - customer, - count: 1, - latestTotal: BASE_PRICE + expectedPrepaid, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 3: purchased=200, allowance=100, tier 1 → invoice $40 -// -// V1 options.quantity: 200 (purchased only; allowance excluded per V1 semantics) -// includedUsage: 100 -// Total balance: 300 (100 allowance + 200 purchased) -// -// Volume math applies to the PURCHASED portion (200 units): -// 200 units → tier 1 → 2 × $10 = $20 -// Graduated would also be $20 (same tier — no differentiator) -// -// Invoice total: $20 (base) + $20 (prepaid) = $40 -// ═══════════════════════════════════════════════════════════════════════════════ - -test.concurrent(`${chalk.yellowBright("legacy-new-volume 3: purchased=200, allowance=100, tier 1 → $40 (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 volumeItem = items.volumePrepaidMessages({ - includedUsage, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - const pro = products.pro({ id: "pro-legacy-volume-t3", items: [volumeItem] }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [ - s.attach({ - productId: pro.id, - // V1 quantity = purchased units only (excludes includedUsage) - options: [ - { feature_id: TestFeature.Messages, quantity: purchasedQuantity }, - ], - }), - ], - }); - - const customer = await autumnV1.customers.get(customerId); - - expectProductAttached({ customer: customer as any, product: pro }); - - // balance = allowance (100) + purchased (200) = 300 - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: includedUsage + purchasedQuantity, - usage: 0, - }); - - await expectCustomerInvoiceCorrect({ - customer, - count: 1, - latestTotal: BASE_PRICE + expectedPrepaid, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 4: purchased=700, allowance=100, tier 2 → invoice $55 -// -// V1 options.quantity: 700 (purchased only; allowance excluded per V1 semantics) -// includedUsage: 100 -// Total balance: 800 (100 allowance + 700 purchased) -// -// Volume math applies to the PURCHASED portion (700 units): -// 700 units → tier 2 → 7 × $5 = $35 -// Graduated would be: 5×$10 + 2×$5 = $60 — KEY DIFFERENTIATOR -// -// Invoice total: $20 (base) + $35 (prepaid) = $55 -// ═══════════════════════════════════════════════════════════════════════════════ - -test.concurrent(`${chalk.yellowBright("legacy-new-volume 4: purchased=700, allowance=100, tier 2 → $55 (graduated would be $60, 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 volumeItem = items.volumePrepaidMessages({ - includedUsage, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - const pro = products.pro({ id: "pro-legacy-volume-t4", items: [volumeItem] }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [ - s.attach({ - productId: pro.id, - // V1 quantity = purchased units only (excludes includedUsage) - options: [ - { feature_id: TestFeature.Messages, quantity: purchasedQuantity }, - ], - }), - ], - }); - - const customer = await autumnV1.customers.get(customerId); - - expectProductAttached({ customer: customer as any, product: pro }); - - // balance = allowance (100) + purchased (700) = 800 - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: includedUsage + purchasedQuantity, - usage: 0, - }); - - await expectCustomerInvoiceCorrect({ - customer, - count: 1, - latestTotal: BASE_PRICE + expectedPrepaid, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// TEST 5: purchased=0, allowance=100 → invoice $20 (base only) -// -// V1 options.quantity: 0 -// includedUsage: 100 -// Total balance: 100 (allowance only, no purchased units) -// -// Volume math: 0 units → no prepaid charge -// Invoice total: $20 (base only) -// ═══════════════════════════════════════════════════════════════════════════════ - -test.concurrent(`${chalk.yellowBright("legacy-new-volume 5: purchased=0, allowance=100 → $20 base only (balance=100)")}`, async () => { - const customerId = "legacy-new-volume-t5"; - const purchasedQuantity = 0; - const includedUsage = 100; - // Volume: 0 units → $0 prepaid charge - const expectedPrepaid = 0; - - const volumeItem = items.volumePrepaidMessages({ - includedUsage, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - const pro = products.pro({ id: "pro-legacy-volume-t5", items: [volumeItem] }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [ - s.attach({ - productId: pro.id, - // V1 quantity = 0 (no purchased units; allowance comes from includedUsage) - options: [ - { feature_id: TestFeature.Messages, quantity: purchasedQuantity }, - ], - }), - ], - }); - - const customer = await autumnV1.customers.get(customerId); - - expectProductAttached({ customer: customer as any, product: pro }); - - // balance = allowance only (100) - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: includedUsage + purchasedQuantity, - usage: 0, - }); - - await expectCustomerInvoiceCorrect({ - customer, - count: 1, - latestTotal: BASE_PRICE + expectedPrepaid, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); diff --git a/server/tests/integration/billing/update-subscription/custom-plan/update-paid-billing-method.test.ts b/server/tests/integration/billing/update-subscription/custom-plan/update-paid-billing-method.test.ts index e39303841..42ddac947 100644 --- a/server/tests/integration/billing/update-subscription/custom-plan/update-paid-billing-method.test.ts +++ b/server/tests/integration/billing/update-subscription/custom-plan/update-paid-billing-method.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { type ApiCustomerV3 } from "@autumn/shared"; +import type { ApiCustomerV3 } from "@autumn/shared"; import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; diff --git a/server/tests/integration/billing/update-subscription/custom-plan/update-paid-tier-behavior.test.ts b/server/tests/integration/billing/update-subscription/custom-plan/update-paid-tier-behavior.test.ts deleted file mode 100644 index b3fad383f..000000000 --- a/server/tests/integration/billing/update-subscription/custom-plan/update-paid-tier-behavior.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { expect, test } from "bun:test"; -import type { ApiCustomerV3 } from "@autumn/shared"; -import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; -import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; -import { calculateProration } from "@tests/integration/billing/utils/proration/calculateProration"; -import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; -import { TestFeature } from "@tests/setup/v2Features"; -import { items } from "@tests/utils/fixtures/items"; -import { products } from "@tests/utils/fixtures/products"; -import { advanceTestClock } from "@tests/utils/stripeUtils"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; -import chalk from "chalk"; - -// ═══════════════════════════════════════════════════════════════════════════════ -// PAID-TO-PAID: TIER BEHAVIOR TRANSITIONS -// ═══════════════════════════════════════════════════════════════════════════════ - -const BILLING_UNITS = 100; - -// Tiers for prepaid pricing tests (same for graduated and volume) -const PREPAID_TIERS = [ - { to: 500, amount: 10 }, - { to: "inf" as const, amount: 5 }, -]; - -// 4.8 Graduated prepaid to volume prepaid (mid-cycle with proration) -test.concurrent(`${chalk.yellowBright("p2p: graduated prepaid to volume prepaid (mid-cycle proration)")}`, async () => { - const quantity = 800; - const basePrice = 20; - - // Graduated calculation: 5 packs × $10 + 3 packs × $5 = $65 - const graduatedCost = 5 * 10 + 3 * 5; - - // Volume calculation: 8 packs × $5 (tier 2 rate for all) = $40 - const volumeCost = 8 * 5; - - const graduatedItem = items.tieredPrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: PREPAID_TIERS, - }); - const priceItem = items.monthlyPrice({ price: basePrice }); - const pro = products.base({ id: "pro", items: [graduatedItem, priceItem] }); - - const { customerId, autumnV1, ctx, testClockId } = await initScenario({ - customerId: "p2p-grad-to-vol-proration", - setup: [ - s.customer({ paymentMethod: "success", testClock: true }), - s.products({ list: [pro] }), - ], - actions: [ - s.attach({ - productId: "pro", - options: [{ feature_id: TestFeature.Messages, quantity }], - }), - ], - }); - - // Verify initial invoice (base price + graduated prepaid) - const initialCustomer = - await autumnV1.customers.get(customerId); - await expectCustomerInvoiceCorrect({ - customer: initialCustomer, - count: 1, - latestTotal: basePrice + graduatedCost, - }); - - // Advance 15 days (mid-cycle) - const advancedTo = await advanceTestClock({ - stripeCli: ctx.stripeCli, - testClockId: testClockId!, - numberOfDays: 15, - }); - - // Change to volume prepaid with same quantity - const volumeItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: PREPAID_TIERS, - }); - - const updateParams = { - customer_id: customerId, - product_id: pro.id, - items: [volumeItem, priceItem], - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // Calculate prorated difference: credit old graduated, charge new volume - const proratedGraduated = await calculateProration({ - customerId, - advancedTo, - amount: graduatedCost, - }); - const proratedVolume = await calculateProration({ - customerId, - advancedTo, - amount: volumeCost, - }); - - // Volume is cheaper, so this should be a credit (negative) - const expectedAmount = proratedVolume - proratedGraduated; - - expect(preview.total).toEqual(expectedAmount); - - await autumnV1.subscriptions.update(updateParams); - - const customer = await autumnV1.customers.get(customerId); - - // Balance should remain at 800 (quantity unchanged) - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - balance: quantity, - usage: 0, - }); - - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: preview.total, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); - -// Tiers for consumable pricing tests -const CONSUMABLE_TIERS = [ - { to: 500, amount: 0.1 }, - { to: "inf" as const, amount: 0.05 }, -]; - -// 4.9 Volume prepaid to graduated tiered consumable -test.concurrent(`${chalk.yellowBright("p2p: volume prepaid to graduated tiered consumable")}`, async () => { - const quantity = 800; - const basePrice = 20; - - // Volume prepaid: 8 packs × $5 = $40 - const volumeCost = 8 * 5; - - const volumeItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: PREPAID_TIERS, - }); - const priceItem = items.monthlyPrice({ price: basePrice }); - const pro = products.base({ id: "pro", items: [volumeItem, priceItem] }); - - const { customerId, autumnV1, ctx } = await initScenario({ - customerId: "p2p-vol-to-tiered-cons", - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [ - s.attach({ - productId: "pro", - options: [{ feature_id: TestFeature.Messages, quantity }], - }), - ], - }); - - // Track some usage (600 of 800 = 200 remaining) - const messagesUsage = 600; - await autumnV1.track( - { - customer_id: customerId, - feature_id: TestFeature.Messages, - value: messagesUsage, - }, - { timeout: 2000 }, - ); - - // Verify customer has usage tracked - const customerBefore = - await autumnV1.customers.get(customerId); - expect(customerBefore.features[TestFeature.Messages].usage).toBe( - messagesUsage, - ); - - // Change to tiered consumable with 100 included - // tier_behavior should be undefined (defaults to graduated) - const tieredConsumableItem = items.tieredConsumableMessages({ - includedUsage: 100, - billingUnits: 1, - tiers: CONSUMABLE_TIERS, - }); - - // Verify the item has no tier_behavior set (defaults to graduated) - expect(tieredConsumableItem.tier_behavior).toBeUndefined(); - - const updateParams = { - customer_id: customerId, - product_id: pro.id, - items: [tieredConsumableItem, priceItem], - }; - - const preview = await autumnV1.subscriptions.previewUpdate(updateParams); - - // Should refund full prepaid amount ($40) - expect(preview.total).toBe(-volumeCost); - - await autumnV1.subscriptions.update(updateParams); - - const customer = await autumnV1.customers.get(customerId); - - // Usage should be preserved (600 tracked) - // New included usage is 100, so balance = 100 - 600 = -500 (in overage) - expectCustomerFeatureCorrect({ - customer, - featureId: TestFeature.Messages, - includedUsage: tieredConsumableItem.included_usage, - balance: tieredConsumableItem.included_usage - messagesUsage, - usage: messagesUsage, - }); - - await expectCustomerInvoiceCorrect({ - customer, - count: 2, - latestTotal: preview.total, - }); - - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); -}); diff --git a/server/tests/integration/billing/update-subscription/update-quantity/volume-tiers-update-quantity.test.ts b/server/tests/integration/billing/update-subscription/update-quantity/volume-tiers-update-quantity.test.ts deleted file mode 100644 index c497e6231..000000000 --- a/server/tests/integration/billing/update-subscription/update-quantity/volume-tiers-update-quantity.test.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { expect, test } from "bun:test"; -import type { ApiCustomerV3 } from "@autumn/shared"; -import { expectLatestInvoiceCorrect } from "@tests/integration/billing/utils/expectLatestInvoiceCorrect.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { items } from "@tests/utils/fixtures/items.js"; -import { products } from "@tests/utils/fixtures/products.js"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; -import chalk from "chalk"; - -/** - * Volume Pricing — Update Quantity Tests - * - * Verifies that `subscriptions.update` charges the correct delta when quantity changes - * under VOLUME pricing (entire quantity billed at the rate of its tier). - * - * Tier setup (billingUnits = 100): - * Tier 1: 0–500 units → $10 / pack → volumeCost(n) = (n/100) × $10 - * Tier 2: 501+ units → $5 / pack → volumeCost(n) = (n/100) × $5 - * - * Cost table used throughout: - * 300 units → tier 1 → 3 × $10 = $30 - * 500 units → tier 1 → 5 × $10 = $50 - * 600 units → tier 2 → 6 × $5 = $30 - * 800 units → tier 2 → 8 × $5 = $40 - * 1000 units → tier 2 → 10 × $5 = $50 - * - * Volume vs Graduated delta comparison (the KEY tests are 2 and 5): - * Test 1: 300→500 same tier volume +$20 graduated +$20 (no diff) - * Test 2: 300→800 tier 1→2 volume +$10 graduated +$35 ← DIFFERENTIATOR - * Test 3: 600→1000 same tier volume +$20 graduated +$20 (no diff) - * Test 4: 500→300 same tier volume −$20 graduated −$20 (no diff) - * Test 5: 800→300 tier 2→1 volume −$10 graduated −$35 ← DIFFERENTIATOR - */ - -const BILLING_UNITS = 100; - -const VOLUME_TIERS = [ - { to: 500, amount: 10 }, - { to: "inf" as const, amount: 5 }, -]; - -// ─── Test 1: Increase — same tier (300 → 500, both tier 1) ─────────────────── - -test.concurrent(`${chalk.yellowBright("volume-tiers-update-quantity: increase same tier 300→500")}`, async () => { - const customerId = "volume-update-qty-increase-t1"; - const initQuantity = 300; - const newQuantity = 500; - - // Old cost: 3 × $10 = $30. New cost: 5 × $10 = $50. Delta: +$20. - // Graduated delta: same (+$20) — no difference within same tier. - const expectedDelta = - (newQuantity / BILLING_UNITS) * 10 - (initQuantity / BILLING_UNITS) * 10; // $20 - - const volumeItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - - const product = products.base({ - id: "vol-upd-qty-inc-t1", - items: [volumeItem], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.attach({ - productId: product.id, - options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }], - }), - ], - }); - - // Preview update - const preview = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - product_id: product.id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - }); - expect(preview.total).toBe(expectedDelta); - - // Execute update - await autumnV1.subscriptions.update({ - customer_id: customerId, - product_id: product.id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - }); - - const customer = await autumnV1.customers.get(customerId); - - expect(customer.features?.[TestFeature.Messages]?.balance).toBe(newQuantity); - - expectLatestInvoiceCorrect({ - customer, - productId: product.id, - amount: expectedDelta, - }); -}); - -// ─── Test 2: Increase — crosses tier boundary (300 → 800, tier 1 → tier 2) ── - -test.concurrent(`${chalk.yellowBright("volume-tiers-update-quantity: increase crossing tier 300→800")}`, async () => { - const customerId = "volume-update-qty-increase-t2"; - const initQuantity = 300; - const newQuantity = 800; - - // Volume: old cost = 3 × $10 = $30. New cost = 8 × $5 = $40. Delta: +$10. - // Graduated would charge: 5×$10 + 3×$5 = $65 for new; delta = $65 − $30 = +$35. KEY DIFF. - const expectedDelta = - (newQuantity / BILLING_UNITS) * 5 - (initQuantity / BILLING_UNITS) * 10; // $40 − $30 = $10 - - const volumeItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - - const product = products.base({ - id: "vol-upd-qty-inc-t2", - items: [volumeItem], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.attach({ - productId: product.id, - options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }], - }), - ], - }); - - // Preview update - const preview = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - product_id: product.id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - }); - // Volume charges $10; graduated would charge $35 — assert the volume amount - expect(preview.total).toBe(expectedDelta); - - // Execute update - await autumnV1.subscriptions.update({ - customer_id: customerId, - product_id: product.id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - }); - - const customer = await autumnV1.customers.get(customerId); - - expect(customer.features?.[TestFeature.Messages]?.balance).toBe(newQuantity); - - expectLatestInvoiceCorrect({ - customer, - productId: product.id, - amount: expectedDelta, - }); -}); - -// ─── Test 3: Increase — within tier 2 already (600 → 1000) ────────────────── - -test.concurrent(`${chalk.yellowBright("volume-tiers-update-quantity: increase same tier 600→1000")}`, async () => { - const customerId = "volume-update-qty-increase-t3"; - const initQuantity = 600; - const newQuantity = 1000; - - // Old cost: 6 × $5 = $30. New cost: 10 × $5 = $50. Delta: +$20. - // Graduated delta: same (+$20) — no difference within same tier. - const expectedDelta = - (newQuantity / BILLING_UNITS) * 5 - (initQuantity / BILLING_UNITS) * 5; // $50 − $30 = $20 - - const volumeItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - - const product = products.base({ - id: "vol-upd-qty-inc-t3", - items: [volumeItem], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.attach({ - productId: product.id, - options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }], - }), - ], - }); - - // Preview update - const preview = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - product_id: product.id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - }); - expect(preview.total).toBe(expectedDelta); - - // Execute update - await autumnV1.subscriptions.update({ - customer_id: customerId, - product_id: product.id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - }); - - const customer = await autumnV1.customers.get(customerId); - - expect(customer.features?.[TestFeature.Messages]?.balance).toBe(newQuantity); - - expectLatestInvoiceCorrect({ - customer, - productId: product.id, - amount: expectedDelta, - }); -}); - -// ─── Test 4: Decrease — same tier (500 → 300, both tier 1) ────────────────── - -test.concurrent(`${chalk.yellowBright("volume-tiers-update-quantity: decrease same tier 500→300")}`, async () => { - const customerId = "volume-update-qty-decrease-t1"; - const initQuantity = 500; - const newQuantity = 300; - - // Old cost: 5 × $10 = $50. New cost: 3 × $10 = $30. Delta: −$20. - // Graduated delta: same (−$20) — no difference within same tier. - const expectedDelta = - (newQuantity / BILLING_UNITS) * 10 - (initQuantity / BILLING_UNITS) * 10; // $30 − $50 = −$20 - - const volumeItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - - const product = products.base({ - id: "vol-upd-qty-dec-t1", - items: [volumeItem], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.attach({ - productId: product.id, - options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }], - }), - ], - }); - - // Preview downgrade - const preview = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - product_id: product.id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - }); - expect(preview.total).toBe(expectedDelta); - - // Execute downgrade - await autumnV1.subscriptions.update({ - customer_id: customerId, - product_id: product.id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - }); - - const customer = await autumnV1.customers.get(customerId); - - expect(customer.features?.[TestFeature.Messages]?.balance).toBe(newQuantity); - - expectLatestInvoiceCorrect({ - customer, - productId: product.id, - amount: expectedDelta, - }); -}); - -// ─── Test 5: Decrease — crosses tier boundary (800 → 300, tier 2 → tier 1) ── - -test.concurrent(`${chalk.yellowBright("volume-tiers-update-quantity: decrease crossing tier 800→300")}`, async () => { - const customerId = "volume-update-qty-decrease-t2"; - const initQuantity = 800; - const newQuantity = 300; - - // Volume: old cost = 8 × $5 = $40. New cost = 3 × $10 = $30. Delta: −$10. - // Graduated old cost: 5×$10 + 3×$5 = $65. New: 3×$10 = $30. Graduated delta = −$35. KEY DIFF. - const expectedDelta = - (newQuantity / BILLING_UNITS) * 10 - (initQuantity / BILLING_UNITS) * 5; // $30 − $40 = −$10 - - const volumeItem = items.volumePrepaidMessages({ - includedUsage: 0, - billingUnits: BILLING_UNITS, - tiers: VOLUME_TIERS, - }); - - const product = products.base({ - id: "vol-upd-qty-dec-t2", - items: [volumeItem], - }); - - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - ], - actions: [ - s.attach({ - productId: product.id, - options: [{ feature_id: TestFeature.Messages, quantity: initQuantity }], - }), - ], - }); - - // Preview downgrade - const preview = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - product_id: product.id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - }); - // Volume credits $10; graduated would credit $35 — assert the volume amount - expect(preview.total).toBe(expectedDelta); - - // Execute downgrade - await autumnV1.subscriptions.update({ - customer_id: customerId, - product_id: product.id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], - }); - - const customer = await autumnV1.customers.get(customerId); - - expect(customer.features?.[TestFeature.Messages]?.balance).toBe(newQuantity); - - expectLatestInvoiceCorrect({ - customer, - productId: product.id, - amount: expectedDelta, - }); -}); diff --git a/server/tests/unit/billing/invoicing/line-item-utils/graduated-tiers-to-line-amount.test.ts b/server/tests/unit/billing/invoicing/line-item-utils/graduated-tiers-to-line-amount.test.ts deleted file mode 100644 index 36d942d03..000000000 --- a/server/tests/unit/billing/invoicing/line-item-utils/graduated-tiers-to-line-amount.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { Infinite, type UsageTier } from "@autumn/shared"; -import { graduatedTiersToLineAmount } from "@utils/billingUtils/invoicingUtils/lineItemUtils/graduatedTiersToLineAmount"; - -// Standard multi-tier schedule used across several tests: -// 0–100 @ $0.10, 101–500 @ $0.05, 501+ @ $0.02 -const THREE_TIERS = [ - { to: 100, amount: 0.1 }, - { to: 500, amount: 0.05 }, - { to: Infinite, amount: 0.02 }, -] as UsageTier[]; - -describe("graduatedTiersToLineAmount", () => { - describe("basic graduated math", () => { - test("single flat-rate tier: 100 units @ $0.10 = $10", () => { - expect( - graduatedTiersToLineAmount({ - tiers: [{ to: Infinite, amount: 0.1 }], - usage: 100, - }), - ).toBe(10); - }); - - test("0 usage = $0", () => { - expect( - graduatedTiersToLineAmount({ - tiers: [{ to: Infinite, amount: 0.1 }], - usage: 0, - }), - ).toBe(0); - }); - - test("usage within first tier only: 50 units = $5", () => { - expect( - graduatedTiersToLineAmount({ tiers: THREE_TIERS, usage: 50 }), - ).toBe(5); - }); - - test("usage exactly at tier 1 boundary: 100 units = $10", () => { - expect( - graduatedTiersToLineAmount({ tiers: THREE_TIERS, usage: 100 }), - ).toBe(10); - }); - - test("usage spanning tier 1 + partial tier 2: 250 units = $17.50", () => { - // 100×$0.10 + 150×$0.05 = $10 + $7.50 = $17.50 - expect( - graduatedTiersToLineAmount({ tiers: THREE_TIERS, usage: 250 }), - ).toBe(17.5); - }); - - test("usage spanning tier 1 + full tier 2: 500 units = $30", () => { - // 100×$0.10 + 400×$0.05 = $10 + $20 = $30 - expect( - graduatedTiersToLineAmount({ tiers: THREE_TIERS, usage: 500 }), - ).toBe(30); - }); - - test("usage across all three tiers: 1000 units = $40", () => { - // 100×$0.10 + 400×$0.05 + 500×$0.02 = $10 + $20 + $10 = $40 - expect( - graduatedTiersToLineAmount({ tiers: THREE_TIERS, usage: 1000 }), - ).toBe(40); - }); - - test("tier.to = -1 treated as Infinite", () => { - expect( - graduatedTiersToLineAmount({ - tiers: [{ to: -1, amount: 0.1 }], - usage: 100, - }), - ).toBe(10); - }); - - test("throws when tiers is null/undefined", () => { - expect(() => - graduatedTiersToLineAmount({ - tiers: null as unknown as UsageTier[], - usage: 100, - }), - ).toThrow(); - }); - }); - - describe("billing units", () => { - const tiers = [{ to: Infinite, amount: 1 }] as UsageTier[]; // $1 per billing unit - - test("rounds up to nearest billing unit: 15 usage, billingUnits=10 → 20 units → $2", () => { - expect( - graduatedTiersToLineAmount({ tiers, usage: 15, billingUnits: 10 }), - ).toBe(2); - }); - - test("exact billing unit multiple: 20 usage, billingUnits=10 → $2", () => { - expect( - graduatedTiersToLineAmount({ tiers, usage: 20, billingUnits: 10 }), - ).toBe(2); - }); - - test("sub-unit usage rounds up to 1 billing unit: 1 usage, billingUnits=10 → $1", () => { - expect( - graduatedTiersToLineAmount({ tiers, usage: 1, billingUnits: 10 }), - ).toBe(1); - }); - }); - - describe("decimal precision", () => { - test("floating point safe: 3 units @ $0.10/unit = $0.30 (not 0.30000000004)", () => { - expect( - graduatedTiersToLineAmount({ - tiers: [{ to: Infinite, amount: 0.1 }], - usage: 3, - }), - ).toBe(0.3); - }); - - test("very small rate: 1,000,000 units @ $0.000001 = $1", () => { - expect( - graduatedTiersToLineAmount({ - tiers: [{ to: Infinite, amount: 0.000001 }], - usage: 1000000, - }), - ).toBe(1); - }); - - test("fractional rate across tiers: 75 units (0–50 @ $0.0075, 50+ @ $0.0025) = $0.4375", () => { - // 50×$0.0075 + 25×$0.0025 = $0.375 + $0.0625 = $0.4375 - expect( - graduatedTiersToLineAmount({ - tiers: [ - { to: 50, amount: 0.0075 }, - { to: Infinite, amount: 0.0025 }, - ], - usage: 75, - }), - ).toBe(0.4375); - }); - }); - - describe("negative usage (allowNegative)", () => { - test("negative usage with allowNegative=false (default): absolute value priced, result is positive", () => { - // With allowNegative=false, negative usage is treated as-is. - // The isNegative flag is only set when allowNegative=true AND usage<0. - // With allowNegative=false, absoluteUsage = usage (stays negative), - // but roundUsageToNearestBillingUnit rounds to 0 for negative → $0. - const result = graduatedTiersToLineAmount({ - tiers: [{ to: Infinite, amount: 0.1 }], - usage: -100, - }); - expect(result).toBe(0); - }); - - test("negative usage with allowNegative=true: produces negative dollar amount", () => { - // -100 units: abs=100, priced at $10, then negated → -$10 - const result = graduatedTiersToLineAmount({ - tiers: [{ to: Infinite, amount: 0.1 }], - usage: -100, - allowNegative: true, - }); - expect(result).toBe(-10); - }); - - test("negative usage with allowNegative=true, multi-tier: correct tier bands used on absolute value", () => { - // -250 units: abs=250, graduated: 100×$0.10 + 150×$0.05 = $17.50, negated → -$17.50 - const result = graduatedTiersToLineAmount({ - tiers: THREE_TIERS, - usage: -250, - allowNegative: true, - }); - expect(result).toBe(-17.5); - }); - - test("negative usage with allowNegative=true, spanning all tiers: -1000 units = -$40", () => { - const result = graduatedTiersToLineAmount({ - tiers: THREE_TIERS, - usage: -1000, - allowNegative: true, - }); - expect(result).toBe(-40); - }); - - test("negative usage with allowNegative=true and billingUnits: rounds up absolute value first", () => { - // -15 units, billingUnits=10 → abs=15 → rounds to 20 → 20×($1/10) = $2 → -$2 - const result = graduatedTiersToLineAmount({ - tiers: [{ to: Infinite, amount: 1 }], - usage: -15, - billingUnits: 10, - allowNegative: true, - }); - expect(result).toBe(-2); - }); - - test("positive usage is unaffected by allowNegative=true", () => { - // allowNegative has no effect on positive usage - const withFlag = graduatedTiersToLineAmount({ - tiers: THREE_TIERS, - usage: 250, - allowNegative: true, - }); - const withoutFlag = graduatedTiersToLineAmount({ - tiers: THREE_TIERS, - usage: 250, - }); - expect(withFlag).toBe(withoutFlag); - expect(withFlag).toBe(17.5); - }); - - test("zero usage with allowNegative=true = $0", () => { - const result = graduatedTiersToLineAmount({ - tiers: [{ to: Infinite, amount: 0.1 }], - usage: 0, - allowNegative: true, - }); - expect(result).toBe(0); - }); - }); -}); diff --git a/server/tests/unit/billing/invoicing/line-item-utils/tiers-to-line-amount.test.ts b/server/tests/unit/billing/invoicing/line-item-utils/tiers-to-line-amount.test.ts index 80804d796..e85e407fb 100644 --- a/server/tests/unit/billing/invoicing/line-item-utils/tiers-to-line-amount.test.ts +++ b/server/tests/unit/billing/invoicing/line-item-utils/tiers-to-line-amount.test.ts @@ -1,19 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { - Infinite, - type Price, - TierBehavior, - tiersToLineAmount, -} from "@autumn/shared"; +import { Infinite, type Price, tiersToLineAmount } from "@autumn/shared"; const createMockPrice = ( tiers: { to: number | typeof Infinite; amount: number }[], - tierBehaviour?: TierBehavior, ): Price => ({ id: "test-price", internal_product_id: "test-product", - tier_behavior: tierBehaviour, config: { type: "usage", usage_tiers: tiers, @@ -21,302 +14,153 @@ const createMockPrice = ( }) as unknown as Price; describe("tiersToLineAmount", () => { - describe("graduated pricing", () => { - describe("single tier (flat rate)", () => { - test("100 overage @ $0.10/unit = $10", () => { - const price = createMockPrice([{ to: Infinite, amount: 0.1 }]); - const result = tiersToLineAmount({ price, overage: 100 }); + describe("single tier (flat rate)", () => { + test("100 overage @ $0.10/unit = $10", () => { + const price = createMockPrice([{ to: Infinite, amount: 0.1 }]); + const result = tiersToLineAmount({ price, overage: 100 }); - expect(result).toBe(10); - }); - - test("0 overage = $0", () => { - const price = createMockPrice([{ to: Infinite, amount: 0.1 }]); - const result = tiersToLineAmount({ price, overage: 0 }); - expect(result).toBe(0); - }); + expect(result).toBe(10); }); - describe("multiple tiers", () => { - // Tiers: 0-100 @ $0.10, 100-500 @ $0.05, 500+ @ $0.02 - const tieredPrice = createMockPrice([ - { to: 100, amount: 0.1 }, - { to: 500, amount: 0.05 }, - { to: Infinite, amount: 0.02 }, - ]); - - test("50 overage (within tier 1) = $5", () => { - const result = tiersToLineAmount({ price: tieredPrice, overage: 50 }); - expect(result).toBe(5); - }); - - test("100 overage (exactly tier 1) = $10", () => { - const result = tiersToLineAmount({ price: tieredPrice, overage: 100 }); - expect(result).toBe(10); - }); - - test("250 overage (tier 1 + partial tier 2) = $17.50", () => { - // 100 × $0.10 = $10 - // 150 × $0.05 = $7.50 - // Total = $17.50 - const result = tiersToLineAmount({ price: tieredPrice, overage: 250 }); - expect(result).toBe(17.5); - }); - - test("500 overage (tier 1 + full tier 2) = $30", () => { - // 100 × $0.10 = $10 - // 400 × $0.05 = $20 - // Total = $30 - const result = tiersToLineAmount({ price: tieredPrice, overage: 500 }); - expect(result).toBe(30); - }); - - test("1000 overage (all tiers) = $40", () => { - // 100 × $0.10 = $10 - // 400 × $0.05 = $20 - // 500 × $0.02 = $10 - // Total = $40 - const result = tiersToLineAmount({ - price: tieredPrice, - overage: 1000, - }); - expect(result).toBe(40); - }); - }); - - describe("billing units", () => { - const price = createMockPrice([{ to: Infinite, amount: 1 }]); // $1 per billing unit - - test("rounds up to nearest billing unit (billingUnits=10)", () => { - // 15 overage, billingUnits=10 → rounds to 20 - // 20 × ($1/10) = $2 - const result = tiersToLineAmount({ - price, - overage: 15, - billingUnits: 10, - }); - expect(result).toBe(2); - }); - - test("exact billing unit multiple", () => { - // 20 overage, billingUnits=10 → stays 20 - // 20 × ($1/10) = $2 - const result = tiersToLineAmount({ - price, - overage: 20, - billingUnits: 10, - }); - expect(result).toBe(2); - }); - - test("small overage rounds up", () => { - // 1 overage, billingUnits=10 → rounds to 10 - // 10 × ($1/10) = $1 - const result = tiersToLineAmount({ - price, - overage: 1, - billingUnits: 10, - }); - expect(result).toBe(1); - }); - }); - - describe("decimal precision", () => { - test("fractional rate: 7 overage @ $0.0033/unit", () => { - const price = createMockPrice([{ to: Infinite, amount: 0.0033 }]); - const result = tiersToLineAmount({ price, overage: 7 }); - expect(result).toBe(0.0231); - }); - - test("fractional rate with many decimals: 13 overage @ $0.00123/unit", () => { - const price = createMockPrice([{ to: Infinite, amount: 0.00123 }]); - const result = tiersToLineAmount({ price, overage: 13 }); - expect(result).toBe(0.01599); - }); - - test("large overage with small rate: 1000000 @ $0.000001/unit", () => { - const price = createMockPrice([{ to: Infinite, amount: 0.000001 }]); - const result = tiersToLineAmount({ price, overage: 1000000 }); - expect(result).toBe(1); - }); - - test("tiered with fractional rates", () => { - // 0-50 @ $0.0075, 50+ @ $0.0025 - const price = createMockPrice([ - { to: 50, amount: 0.0075 }, - { to: Infinite, amount: 0.0025 }, - ]); - // 75 overage: 50 × $0.0075 = $0.375, 25 × $0.0025 = $0.0625 - // Total = $0.4375 - const result = tiersToLineAmount({ price, overage: 75 }); - expect(result).toBe(0.4375); - }); - - test("very small overage with fractional rate: 3 @ $0.33/unit", () => { - const price = createMockPrice([{ to: Infinite, amount: 0.33 }]); - const result = tiersToLineAmount({ price, overage: 3 }); - expect(result).toBe(0.99); - }); - - test("floating point edge case: 0.1 + 0.2 precision", () => { - // 3 overage @ $0.1/unit = $0.3 (tests floating point handling) - const price = createMockPrice([{ to: Infinite, amount: 0.1 }]); - const result = tiersToLineAmount({ price, overage: 3 }); - expect(result).toBe(0.3); - }); - }); - - describe("edge cases", () => { - test("tier.to = -1 treated same as Infinite", () => { - const price = createMockPrice([{ to: -1, amount: 0.1 }]); - const result = tiersToLineAmount({ price, overage: 100 }); - expect(result).toBe(10); - }); - - test("throws if no tiers", () => { - const price = { config: {} } as unknown as Price; - expect(() => tiersToLineAmount({ price, overage: 100 })).toThrow(); - }); + test("0 overage = $0", () => { + const price = createMockPrice([{ to: Infinite, amount: 0.1 }]); + const result = tiersToLineAmount({ price, overage: 0 }); + expect(result).toBe(0); }); }); - describe("volume pricing", () => { - // Tiers: 0-100 @ $0.10/unit, 101-500 @ $0.05/unit, 501+ @ $0.02/unit - // Volume: entire quantity charged at the single tier it falls into - const volumePrice = createMockPrice( - [ - { to: 100, amount: 0.1 }, - { to: 500, amount: 0.05 }, - { to: Infinite, amount: 0.02 }, - ], - TierBehavior.VolumeBased, - ); + describe("multiple tiers", () => { + // Tiers: 0-100 @ $0.10, 100-500 @ $0.05, 500+ @ $0.02 + const tieredPrice = createMockPrice([ + { to: 100, amount: 0.1 }, + { to: 500, amount: 0.05 }, + { to: Infinite, amount: 0.02 }, + ]); - describe("tier selection", () => { - test("50 units falls in tier 1 → entire 50 @ $0.10 = $5", () => { - const result = tiersToLineAmount({ price: volumePrice, overage: 50 }); - expect(result).toBe(5); - }); - - test("100 units (exactly tier 1 boundary) → entire 100 @ $0.10 = $10", () => { - const result = tiersToLineAmount({ price: volumePrice, overage: 100 }); - expect(result).toBe(10); - }); - - test("101 units (just into tier 2) → entire 101 @ $0.05 = $5.05", () => { - const result = tiersToLineAmount({ price: volumePrice, overage: 101 }); - expect(result).toBe(5.05); - }); - - test("250 units (mid tier 2) → entire 250 @ $0.05 = $12.50", () => { - // Graduated would be: 100×$0.10 + 150×$0.05 = $17.50 - // Volume is: 250×$0.05 = $12.50 - const result = tiersToLineAmount({ price: volumePrice, overage: 250 }); - expect(result).toBe(12.5); - }); - - test("500 units (exactly tier 2 boundary) → entire 500 @ $0.05 = $25", () => { - const result = tiersToLineAmount({ price: volumePrice, overage: 500 }); - expect(result).toBe(25); - }); - - test("1000 units (in tier 3) → entire 1000 @ $0.02 = $20", () => { - // Graduated would be: 100×$0.10 + 400×$0.05 + 500×$0.02 = $40 - // Volume is: 1000×$0.02 = $20 - const result = tiersToLineAmount({ - price: volumePrice, - overage: 1000, - }); - expect(result).toBe(20); - }); + test("50 overage (within tier 1) = $5", () => { + const result = tiersToLineAmount({ price: tieredPrice, overage: 50 }); + expect(result).toBe(5); }); - describe("single tier (flat rate)", () => { - test("100 units @ $0.10/unit = $10", () => { - const price = createMockPrice( - [{ to: Infinite, amount: 0.1 }], - TierBehavior.VolumeBased, - ); - const result = tiersToLineAmount({ price, overage: 100 }); - expect(result).toBe(10); - }); - - test("0 units = $0", () => { - const price = createMockPrice( - [{ to: Infinite, amount: 0.1 }], - TierBehavior.VolumeBased, - ); - const result = tiersToLineAmount({ price, overage: 0 }); - expect(result).toBe(0); - }); - - test("tier.to = -1 treated same as Infinite", () => { - const price = createMockPrice( - [{ to: -1, amount: 0.1 }], - TierBehavior.VolumeBased, - ); - const result = tiersToLineAmount({ price, overage: 100 }); - expect(result).toBe(10); - }); + test("100 overage (exactly tier 1) = $10", () => { + const result = tiersToLineAmount({ price: tieredPrice, overage: 100 }); + expect(result).toBe(10); }); - describe("billing units", () => { - // $1.00 per 1000 units (e.g. API tokens) - const tokenPrice = createMockPrice( - [ - { to: 100000, amount: 1.0 }, - { to: Infinite, amount: 0.5 }, - ], - TierBehavior.VolumeBased, - ); - - test("50k tokens (tier 1) @ $1/1k = $50", () => { - const result = tiersToLineAmount({ - price: tokenPrice, - overage: 50000, - billingUnits: 1000, - }); - expect(result).toBe(50); - }); - - test("150k tokens (tier 2) @ $0.50/1k = $75", () => { - const result = tiersToLineAmount({ - price: tokenPrice, - overage: 150000, - billingUnits: 1000, - }); - expect(result).toBe(75); - }); - - test("rounds up to nearest billing unit before pricing", () => { - // 1500 tokens, billingUnits=1000 → rounds to 2000 → tier 1 → 2000×($1/1000) = $2 - const result = tiersToLineAmount({ - price: tokenPrice, - overage: 1500, - billingUnits: 1000, - }); - expect(result).toBe(2); - }); + test("250 overage (tier 1 + partial tier 2) = $17.50", () => { + // 100 × $0.10 = $10 + // 150 × $0.05 = $7.50 + // Total = $17.50 + const result = tiersToLineAmount({ price: tieredPrice, overage: 250 }); + expect(result).toBe(17.5); }); - describe("negative overage (credits)", () => { - test("negative overage produces negative amount", () => { - // -250 units falls in tier 2 → -(250 × $0.05) = -$12.50 - const result = tiersToLineAmount({ - price: volumePrice, - overage: -250, - }); - expect(result).toBe(-12.5); - }); + test("500 overage (tier 1 + full tier 2) = $30", () => { + // 100 × $0.10 = $10 + // 400 × $0.05 = $20 + // Total = $30 + const result = tiersToLineAmount({ price: tieredPrice, overage: 500 }); + expect(result).toBe(30); + }); - test("negative overage in tier 1", () => { - // -50 units falls in tier 1 → -(50 × $0.10) = -$5 - const result = tiersToLineAmount({ - price: volumePrice, - overage: -50, - }); - expect(result).toBe(-5); + test("1000 overage (all tiers) = $40", () => { + // 100 × $0.10 = $10 + // 400 × $0.05 = $20 + // 500 × $0.02 = $10 + // Total = $40 + const result = tiersToLineAmount({ price: tieredPrice, overage: 1000 }); + expect(result).toBe(40); + }); + }); + + describe("billing units", () => { + const price = createMockPrice([{ to: Infinite, amount: 1 }]); // $1 per billing unit + + test("rounds up to nearest billing unit (billingUnits=10)", () => { + // 15 overage, billingUnits=10 → rounds to 20 + // 20 × ($1/10) = $2 + const result = tiersToLineAmount({ + price, + overage: 15, + billingUnits: 10, }); + expect(result).toBe(2); + }); + + test("exact billing unit multiple", () => { + // 20 overage, billingUnits=10 → stays 20 + // 20 × ($1/10) = $2 + const result = tiersToLineAmount({ + price, + overage: 20, + billingUnits: 10, + }); + expect(result).toBe(2); + }); + + test("small overage rounds up", () => { + // 1 overage, billingUnits=10 → rounds to 10 + // 10 × ($1/10) = $1 + const result = tiersToLineAmount({ price, overage: 1, billingUnits: 10 }); + expect(result).toBe(1); + }); + }); + + describe("decimal precision", () => { + test("fractional rate: 7 overage @ $0.0033/unit", () => { + const price = createMockPrice([{ to: Infinite, amount: 0.0033 }]); + const result = tiersToLineAmount({ price, overage: 7 }); + expect(result).toBe(0.0231); + }); + + test("fractional rate with many decimals: 13 overage @ $0.00123/unit", () => { + const price = createMockPrice([{ to: Infinite, amount: 0.00123 }]); + const result = tiersToLineAmount({ price, overage: 13 }); + expect(result).toBe(0.01599); + }); + + test("large overage with small rate: 1000000 @ $0.000001/unit", () => { + const price = createMockPrice([{ to: Infinite, amount: 0.000001 }]); + const result = tiersToLineAmount({ price, overage: 1000000 }); + expect(result).toBe(1); + }); + + test("tiered with fractional rates", () => { + // 0-50 @ $0.0075, 50+ @ $0.0025 + const price = createMockPrice([ + { to: 50, amount: 0.0075 }, + { to: Infinite, amount: 0.0025 }, + ]); + // 75 overage: 50 × $0.0075 = $0.375, 25 × $0.0025 = $0.0625 + // Total = $0.4375 + const result = tiersToLineAmount({ price, overage: 75 }); + expect(result).toBe(0.4375); + }); + + test("very small overage with fractional rate: 3 @ $0.33/unit", () => { + const price = createMockPrice([{ to: Infinite, amount: 0.33 }]); + const result = tiersToLineAmount({ price, overage: 3 }); + expect(result).toBe(0.99); + }); + + test("floating point edge case: 0.1 + 0.2 precision", () => { + // 3 overage @ $0.1/unit = $0.3 (tests floating point handling) + const price = createMockPrice([{ to: Infinite, amount: 0.1 }]); + const result = tiersToLineAmount({ price, overage: 3 }); + expect(result).toBe(0.3); + }); + }); + + describe("edge cases", () => { + test("tier.to = -1 treated same as Infinite", () => { + const price = createMockPrice([{ to: -1, amount: 0.1 }]); + const result = tiersToLineAmount({ price, overage: 100 }); + expect(result).toBe(10); + }); + + test("throws if no tiers", () => { + const price = { config: {} } as unknown as Price; + expect(() => tiersToLineAmount({ price, overage: 100 })).toThrow(); }); }); }); diff --git a/server/tests/unit/billing/invoicing/line-item-utils/volume-tiers-to-line-amount.test.ts b/server/tests/unit/billing/invoicing/line-item-utils/volume-tiers-to-line-amount.test.ts deleted file mode 100644 index 4e7ff4bde..000000000 --- a/server/tests/unit/billing/invoicing/line-item-utils/volume-tiers-to-line-amount.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { Infinite, type UsageTier } from "@autumn/shared"; -import { volumeTiersToLineAmount } from "@utils/billingUtils/invoicingUtils/lineItemUtils/volumeTiersToLineAmount"; - -// Standard multi-tier volume schedule used across several tests: -// 0–100 @ $0.10/unit, 101–500 @ $0.05/unit, 501+ @ $0.02/unit -// Volume: entire quantity is priced at the rate of whichever tier it falls into. -const THREE_TIERS = [ - { to: 100, amount: 0.1 }, - { to: 500, amount: 0.05 }, - { to: Infinite, amount: 0.02 }, -] as UsageTier[]; - -describe("volumeTiersToLineAmount", () => { - describe("tier selection", () => { - test("usage within tier 1: 50 units → entire 50 @ $0.10 = $5", () => { - expect(volumeTiersToLineAmount({ tiers: THREE_TIERS, usage: 50 })).toBe( - 5, - ); - }); - - test("usage exactly at tier 1 boundary: 100 units → entire 100 @ $0.10 = $10", () => { - expect(volumeTiersToLineAmount({ tiers: THREE_TIERS, usage: 100 })).toBe( - 10, - ); - }); - - test("usage just into tier 2: 101 units → entire 101 @ $0.05 = $5.05", () => { - expect(volumeTiersToLineAmount({ tiers: THREE_TIERS, usage: 101 })).toBe( - 5.05, - ); - }); - - test("usage mid tier 2: 250 units → entire 250 @ $0.05 = $12.50 (not $17.50 graduated)", () => { - // Graduated would be: 100×$0.10 + 150×$0.05 = $17.50 - // Volume charges entire quantity at tier 2 rate - expect(volumeTiersToLineAmount({ tiers: THREE_TIERS, usage: 250 })).toBe( - 12.5, - ); - }); - - test("usage exactly at tier 2 boundary: 500 units → entire 500 @ $0.05 = $25", () => { - expect(volumeTiersToLineAmount({ tiers: THREE_TIERS, usage: 500 })).toBe( - 25, - ); - }); - - test("usage in tier 3: 1000 units → entire 1000 @ $0.02 = $20 (not $40 graduated)", () => { - // Graduated would be: 100×$0.10 + 400×$0.05 + 500×$0.02 = $40 - // Volume charges entire quantity at tier 3 rate - expect(volumeTiersToLineAmount({ tiers: THREE_TIERS, usage: 1000 })).toBe( - 20, - ); - }); - }); - - describe("single tier (flat rate)", () => { - test("100 units @ $0.10 = $10", () => { - expect( - volumeTiersToLineAmount({ - tiers: [{ to: Infinite, amount: 0.1 }], - usage: 100, - }), - ).toBe(10); - }); - - test("0 usage = $0", () => { - expect( - volumeTiersToLineAmount({ - tiers: [{ to: Infinite, amount: 0.1 }], - usage: 0, - }), - ).toBe(0); - }); - - test("tier.to = -1 treated as Infinite", () => { - expect( - volumeTiersToLineAmount({ - tiers: [{ to: -1, amount: 0.1 }], - usage: 100, - }), - ).toBe(10); - }); - }); - - describe("billing units", () => { - // $1.00 per 1000 tokens; tier boundary at 100k tokens - const TOKEN_TIERS = [ - { to: 100000, amount: 1.0 }, - { to: Infinite, amount: 0.5 }, - ] as UsageTier[]; - - test("50k tokens (tier 1) @ $1/1k = $50", () => { - expect( - volumeTiersToLineAmount({ - tiers: TOKEN_TIERS, - usage: 50000, - billingUnits: 1000, - }), - ).toBe(50); - }); - - test("150k tokens (tier 2) @ $0.50/1k = $75", () => { - expect( - volumeTiersToLineAmount({ - tiers: TOKEN_TIERS, - usage: 150000, - billingUnits: 1000, - }), - ).toBe(75); - }); - - test("rounds up to nearest billing unit before selecting tier: 1500 tokens → 2000 → tier 1 → $2", () => { - // 1500 tokens rounds up to 2000 (nearest 1000), still in tier 1 - expect( - volumeTiersToLineAmount({ - tiers: TOKEN_TIERS, - usage: 1500, - billingUnits: 1000, - }), - ).toBe(2); - }); - - test("rounding can push usage into higher tier: 99500 tokens → 100000 → stays tier 1 boundary", () => { - // 99500 rounds up to 100000 which is exactly at tier 1 boundary → tier 1 rate - expect( - volumeTiersToLineAmount({ - tiers: TOKEN_TIERS, - usage: 99500, - billingUnits: 1000, - }), - ).toBe(100); - }); - }); - - describe("decimal precision", () => { - test("fractional rate: 3 units @ $0.10/unit = $0.30 (not 0.30000000004)", () => { - expect( - volumeTiersToLineAmount({ - tiers: [{ to: Infinite, amount: 0.1 }], - usage: 3, - }), - ).toBe(0.3); - }); - - test("large usage, tiny rate: 1,000,000 units @ $0.000001 = $1", () => { - expect( - volumeTiersToLineAmount({ - tiers: [{ to: Infinite, amount: 0.000001 }], - usage: 1_000_000, - }), - ).toBe(1); - }); - }); - - describe("negative usage (allowNegative)", () => { - test("negative usage with allowNegative=false (default): rounds abs to 0 → $0", () => { - // Without allowNegative, negative usage is treated as-is. - // roundUsageToNearestBillingUnit of a negative number returns 0. - const result = volumeTiersToLineAmount({ - tiers: THREE_TIERS, - usage: -100, - }); - expect(result).toBe(0); - }); - - test("negative usage with allowNegative=true: abs value is priced then negated", () => { - // -250 units: abs=250, falls in tier 2 → 250×$0.05=$12.50, negated → -$12.50 - const result = volumeTiersToLineAmount({ - tiers: THREE_TIERS, - usage: -250, - allowNegative: true, - }); - expect(result).toBe(-12.5); - }); - - test("negative usage with allowNegative=true, tier 1: -50 units → -$5", () => { - const result = volumeTiersToLineAmount({ - tiers: THREE_TIERS, - usage: -50, - allowNegative: true, - }); - expect(result).toBe(-5); - }); - - test("negative usage with allowNegative=true, tier 3: -1000 units → -$20", () => { - const result = volumeTiersToLineAmount({ - tiers: THREE_TIERS, - usage: -1000, - allowNegative: true, - }); - expect(result).toBe(-20); - }); - - test("negative usage with allowNegative=true and billingUnits: rounds abs up first", () => { - // -1500 tokens: abs=1500, billingUnits=1000 → rounds to 2000 → tier 1 → 2000×($1/1000)=$2 → -$2 - expect( - volumeTiersToLineAmount({ - tiers: [ - { to: 100000, amount: 1.0 }, - { to: Infinite, amount: 0.5 }, - ], - usage: -1500, - billingUnits: 1000, - allowNegative: true, - }), - ).toBe(-2); - }); - - test("positive usage is unaffected by allowNegative=true", () => { - const withFlag = volumeTiersToLineAmount({ - tiers: THREE_TIERS, - usage: 250, - allowNegative: true, - }); - const withoutFlag = volumeTiersToLineAmount({ - tiers: THREE_TIERS, - usage: 250, - }); - expect(withFlag).toBe(withoutFlag); - expect(withFlag).toBe(12.5); - }); - - test("zero usage with allowNegative=true = $0", () => { - expect( - volumeTiersToLineAmount({ - tiers: THREE_TIERS, - usage: 0, - allowNegative: true, - }), - ).toBe(0); - }); - }); - - describe("throws on bad input", () => { - test("throws when tiers is null/undefined", () => { - expect(() => - volumeTiersToLineAmount({ - tiers: null as unknown as UsageTier[], - usage: 100, - }), - ).toThrow(); - }); - }); -}); diff --git a/server/tests/utils/advancedUsageUtils.ts b/server/tests/utils/advancedUsageUtils.ts index 40ee22674..6cb13757f 100644 --- a/server/tests/utils/advancedUsageUtils.ts +++ b/server/tests/utils/advancedUsageUtils.ts @@ -82,7 +82,7 @@ export const checkUsageInvoiceAmount = async ({ const overage = new Decimal(totalUsage) .minus(featureEntitlement.allowance) .toNumber(); - const overagePrice = getPriceForOverage({ price: meteredPrice, overage }); + const overagePrice = getPriceForOverage(meteredPrice, overage); let basePrice = 0; if (includeBase && product.prices.length > 1) { diff --git a/server/tests/utils/fixtures/items.ts b/server/tests/utils/fixtures/items.ts index 7e4e5359a..fb0ab5824 100644 --- a/server/tests/utils/fixtures/items.ts +++ b/server/tests/utils/fixtures/items.ts @@ -4,7 +4,6 @@ import { type ProductItemConfig, ProductItemInterval, type RolloverConfig, - TierBehavior, } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features"; import { constructPriceItem } from "@/internal/products/product-items/productItemUtils.js"; @@ -322,9 +321,16 @@ const prepaidUsers = ({ }) as LimitedItem; /** - * Tiered prepaid messages - graduated pricing with tiers. - * Default tiers: 0-500 units at $10/pack, 501+ at $5/pack (100 units/pack). + * Tiered prepaid messages - volume pricing with tiers + * Default tiers: + * - 0-500 units: $10/pack (100 units/pack) + * - 501+ units: $5/pack + * * IMPORTANT: Last tier MUST have `to: "inf"` - Stripe requires a catch-all tier. + * + * @param includedUsage - Free units (default: 0) + * @param billingUnits - Units per pack (default: 100) + * @param tiers - Volume tiers (default: standard volume discount). Last tier must have `to: "inf"`. */ const tieredPrepaidMessages = ({ includedUsage = 0, @@ -348,35 +354,6 @@ const tieredPrepaidMessages = ({ config, }) as LimitedItem; -/** - * Volume-priced prepaid messages — the entire purchased quantity is charged at - * the rate of whichever tier it falls into (not split across tiers). - * Default tiers: 0-500 units at $10/pack, 501+ at $5/pack (100 units/pack). - * IMPORTANT: Last tier MUST have `to: "inf"` - Stripe requires a catch-all tier. - */ -const volumePrepaidMessages = ({ - includedUsage = 0, - billingUnits = 100, - tiers = [ - { to: 500, amount: 10 }, - { to: "inf", amount: 5 }, - ], - config, -}: { - includedUsage?: number; - billingUnits?: number; - tiers?: { to: number | "inf"; amount: number }[]; - config?: ProductItemConfig; -} = {}): LimitedItem => - constructPrepaidItem({ - featureId: TestFeature.Messages, - tiers: tiers as { to: number; amount: number }[], - tierBehaviour: TierBehavior.VolumeBased, - billingUnits, - includedUsage, - config, - }) as LimitedItem; - // ═══════════════════════════════════════════════════════════════════ // ONE-OFF (interval: null, no recurring charges) // ═══════════════════════════════════════════════════════════════════ @@ -579,31 +556,6 @@ const consumableWords = ({ interval, }); -/** - * Tiered consumable messages - graduated pricing with tiers (pay-per-use). - * Default tiers: 0-500 units at $0.10/unit, 501+ at $0.05/unit. - * IMPORTANT: Last tier MUST have `to: "inf"` - Stripe requires a catch-all tier. - * Note: tier_behavior is undefined, defaulting to graduated pricing. - */ -const tieredConsumableMessages = ({ - includedUsage = 0, - billingUnits = 1, - tiers = [ - { to: 500, amount: 0.1 }, - { to: "inf", amount: 0.05 }, - ], -}: { - includedUsage?: number; - billingUnits?: number; - tiers?: { to: number | "inf"; amount: number }[]; -} = {}): LimitedItem => - constructArrearItem({ - featureId: TestFeature.Messages, - tiers: tiers as { to: number; amount: number }[], - billingUnits, - includedUsage, - }) as LimitedItem; - // ═══════════════════════════════════════════════════════════════════ // ALLOCATED / SEATS (prorated billing) // ═══════════════════════════════════════════════════════════════════ @@ -714,7 +666,6 @@ export const items = { prepaidMessages, prepaidUsers, tieredPrepaidMessages, - volumePrepaidMessages, // One-off oneOffMessages, @@ -726,7 +677,6 @@ export const items = { consumable, consumableMessages, consumableWords, - tieredConsumableMessages, // Allocated allocatedUsers, diff --git a/server/tests/utils/fixtures/products.ts b/server/tests/utils/fixtures/products.ts index e0a7189e1..20273b827 100644 --- a/server/tests/utils/fixtures/products.ts +++ b/server/tests/utils/fixtures/products.ts @@ -67,23 +67,19 @@ const base = ({ * Pro product - $20/month base price * @param items - Product items (features) * @param id - Product ID (default: "pro") - * @param group - Optional product group */ const pro = ({ items, id = "pro", - group, }: { items: ProductItem[]; id?: string; - group?: string; }): ProductV2 => constructProduct({ id, items: [...items], type: "pro", isDefault: false, - group, }); /** diff --git a/shared/api/customers/cusFeatures/apiBalanceV1.ts b/shared/api/customers/cusFeatures/apiBalanceV1.ts index aea90f09d..8737951d9 100644 --- a/shared/api/customers/cusFeatures/apiBalanceV1.ts +++ b/shared/api/customers/cusFeatures/apiBalanceV1.ts @@ -1,8 +1,5 @@ import { BillingMethod } from "@api/products/components/billingMethod"; -import { - TierBehavior, - UsageTierSchema, -} from "@models/productModels/priceModels/priceConfig/usagePriceConfig"; +import { UsageTierSchema } from "@models/productModels/priceModels/priceConfig/usagePriceConfig"; import { z } from "zod/v4"; import { ApiFeatureV1Schema } from "../../features/apiFeatureV1"; import { ApiBalanceResetSchema, ApiBalanceRolloverSchema } from "./apiBalance"; @@ -42,10 +39,6 @@ export const ApiBalanceBreakdownPriceSchema = z.object({ tiers: z.array(UsageTierSchema).optional().meta({ description: "Tiered pricing configuration if applicable.", }), - tier_behavior: z.enum(TierBehavior).optional().meta({ - description: - "How tiers are applied: graduated (split across bands) or volume (flat rate for the matched tier).", - }), billing_units: z.number().meta({ description: "The number of units per billing increment (eg. $9 / 250 units).", diff --git a/shared/api/products/items/apiPlanItemV1.ts b/shared/api/products/items/apiPlanItemV1.ts index 92403cdf1..7d249e763 100644 --- a/shared/api/products/items/apiPlanItemV1.ts +++ b/shared/api/products/items/apiPlanItemV1.ts @@ -1,13 +1,10 @@ -import { ApiFeatureV0Schema } from "@api/features/prevVersions/apiFeatureV0.js"; -import { BillingMethod } from "@api/products/components/billingMethod.js"; -import { DisplaySchema } from "@api/products/components/display.js"; -import { RolloverExpiryDurationType } from "@models/productModels/durationTypes/rolloverExpiryDurationType.js"; -import { BillingInterval } from "@models/productModels/intervals/billingInterval.js"; -import { ResetInterval } from "@models/productModels/intervals/resetInterval.js"; -import { - TierBehavior, - UsageTierSchema, -} from "@models/productModels/priceModels/priceConfig/usagePriceConfig.js"; +import { ApiFeatureV0Schema } from "@api/features/prevVersions/apiFeatureV0"; +import { BillingMethod } from "@api/products/components/billingMethod"; +import { DisplaySchema } from "@api/products/components/display"; +import { RolloverExpiryDurationType } from "@models/productModels/durationTypes/rolloverExpiryDurationType"; +import { BillingInterval } from "@models/productModels/intervals/billingInterval"; +import { ResetInterval } from "@models/productModels/intervals/resetInterval"; +import { UsageTierSchema } from "@models/productModels/priceModels/priceConfig/usagePriceConfig"; import { OnDecrease, OnIncrease, @@ -94,7 +91,6 @@ export const ApiPlanItemV1Schema = z description: "Tiered pricing configuration. Each tier's 'up_to' does NOT include the included amount. Either 'tiers' or 'amount' is required.", }), - tier_behavior: z.enum(TierBehavior).optional(), interval: z.enum(BillingInterval).meta({ description: diff --git a/shared/api/products/items/crud/createPlanItemParamsV1.ts b/shared/api/products/items/crud/createPlanItemParamsV1.ts index 8f1ae4da9..6c5b12829 100644 --- a/shared/api/products/items/crud/createPlanItemParamsV1.ts +++ b/shared/api/products/items/crud/createPlanItemParamsV1.ts @@ -2,11 +2,7 @@ import { BillingMethod } from "@api/products/components/billingMethod"; import { RolloverExpiryDurationType } from "@models/productModels/durationTypes/rolloverExpiryDurationType"; import { BillingInterval } from "@models/productModels/intervals/billingInterval"; import { ResetInterval } from "@models/productModels/intervals/resetInterval"; -import { - TierBehavior, - UsageTierSchema, -} from "@models/productModels/priceModels/priceConfig/usagePriceConfig"; - +import { UsageTierSchema } from "@models/productModels/priceModels/priceConfig/usagePriceConfig"; import { OnDecrease, OnIncrease, @@ -52,7 +48,6 @@ export const CreatePlanItemParamsV1Schema = z description: "Tiered pricing. Each tier's 'to' does NOT include included amount. Either 'amount' or 'tiers' is required.", }), - tier_behavior: z.enum(TierBehavior).optional(), interval: z.enum(BillingInterval).meta({ description: diff --git a/shared/api/products/items/mappers/planItemParamsV1ToPlanItemV0.ts b/shared/api/products/items/mappers/planItemParamsV1ToPlanItemV0.ts index 5b64c82f1..a1effd429 100644 --- a/shared/api/products/items/mappers/planItemParamsV1ToPlanItemV0.ts +++ b/shared/api/products/items/mappers/planItemParamsV1ToPlanItemV0.ts @@ -41,7 +41,6 @@ export function planItemParamsV1ToPlanItemV0({ ? { amount: item.price.amount, tiers: item.price.tiers, - tier_behavior: item.price.tier_behavior, interval: item.price.interval, interval_count: item.price.interval_count, billing_units: item.price.billing_units ?? 1, diff --git a/shared/api/products/items/mappers/planItemV0ToProductItem.ts b/shared/api/products/items/mappers/planItemV0ToProductItem.ts index effe8736f..1fb7b92e1 100644 --- a/shared/api/products/items/mappers/planItemV0ToProductItem.ts +++ b/shared/api/products/items/mappers/planItemV0ToProductItem.ts @@ -146,7 +146,6 @@ export const planItemV0ToProductItem = ({ amount: tier.amount, to: tier.to, })), - tier_behavior: planItem.price?.tier_behavior, usage_model: planItem.price?.usage_model, billing_units: planItem.price?.billing_units, diff --git a/shared/api/products/items/mappers/planItemV1ToV0.ts b/shared/api/products/items/mappers/planItemV1ToV0.ts index 391f3392c..eaf5cb61d 100644 --- a/shared/api/products/items/mappers/planItemV1ToV0.ts +++ b/shared/api/products/items/mappers/planItemV1ToV0.ts @@ -1,7 +1,6 @@ import type { CreatePlanItemParamsV1 } from "@api/models"; import { billingMethodToUsageModel } from "@api/products/components/mappers/billingMethodTousageModel"; import type { ApiPlanItemV0 } from "@api/products/items/previousVersions/apiPlanItemV0"; -import { TierBehavior } from "@models/productModels/priceModels/priceConfig/usagePriceConfig"; import { featureUtils } from "@utils/featureUtils/index"; import type { SharedContext } from "../../../../types/sharedContext"; import type { ApiPlanItemV1 } from "../apiPlanItemV1"; @@ -38,9 +37,6 @@ export function planItemV1ToV0({ ? { amount: price.amount, tiers: price.tiers, - tier_behavior: price.tiers?.length - ? (price.tier_behavior ?? TierBehavior.Graduated) - : undefined, interval: price.interval, interval_count: price.interval_count, billing_units: billingUnits, diff --git a/shared/api/products/items/previousVersions/apiPlanItemV0.ts b/shared/api/products/items/previousVersions/apiPlanItemV0.ts index 07b5d412b..1aab747c6 100644 --- a/shared/api/products/items/previousVersions/apiPlanItemV0.ts +++ b/shared/api/products/items/previousVersions/apiPlanItemV0.ts @@ -1,12 +1,9 @@ -import { ApiFeatureV0Schema } from "@api/features/prevVersions/apiFeatureV0.js"; -import { DisplaySchema } from "@api/products/components/display.js"; -import { RolloverExpiryDurationType } from "@models/productModels/durationTypes/rolloverExpiryDurationType.js"; -import { BillingInterval } from "@models/productModels/intervals/billingInterval.js"; -import { ResetInterval } from "@models/productModels/intervals/resetInterval.js"; -import { - TierBehavior, - UsageTierSchema, -} from "@models/productModels/priceModels/priceConfig/usagePriceConfig.js"; +import { ApiFeatureV0Schema } from "@api/features/prevVersions/apiFeatureV0"; +import { DisplaySchema } from "@api/products/components/display"; +import { RolloverExpiryDurationType } from "@models/productModels/durationTypes/rolloverExpiryDurationType"; +import { BillingInterval } from "@models/productModels/intervals/billingInterval"; +import { ResetInterval } from "@models/productModels/intervals/resetInterval"; +import { UsageTierSchema } from "@models/productModels/priceModels/priceConfig/usagePriceConfig"; import { OnDecrease, OnIncrease, @@ -34,7 +31,6 @@ export const ApiPlanItemV0Schema = z .object({ amount: z.number().optional(), tiers: z.array(UsageTierSchema).optional(), - tier_behavior: z.enum(TierBehavior).optional(), interval: z.enum(BillingInterval), interval_count: z.number().optional(), diff --git a/shared/api/products/items/previousVersions/apiProductItemV0.ts b/shared/api/products/items/previousVersions/apiProductItemV0.ts index a5b013f47..fff0c744c 100644 --- a/shared/api/products/items/previousVersions/apiProductItemV0.ts +++ b/shared/api/products/items/previousVersions/apiProductItemV0.ts @@ -1,6 +1,5 @@ import { ApiFeatureV0Schema } from "@api/features/prevVersions/apiFeatureV0.js"; import { ProductItemInterval } from "@models/productModels/intervals/productItemInterval.js"; -import { TierBehavior } from "@models/productModels/priceModels/priceConfig/usagePriceConfig.js"; import { Infinite } from "@models/productModels/productEnums.js"; import { OnDecrease, @@ -64,11 +63,6 @@ export const ApiProductItemV0Schema = z "Tiered pricing for the product item. Not applicable for fixed price items.", }), - tier_behavior: z.enum(TierBehavior).nullish().meta({ - description: - "How tiers are applied: graduated (split across bands) or volume (flat rate for the matched tier). Defaults to graduated.", - }), - usage_model: z.enum(UsageModel).nullish().meta({ description: "Whether the feature should be prepaid upfront or billed for how much they use end of billing period.", diff --git a/shared/models/productModels/priceModels/priceConfig/usagePriceConfig.ts b/shared/models/productModels/priceModels/priceConfig/usagePriceConfig.ts index 9f27550bc..0fa510b25 100644 --- a/shared/models/productModels/priceModels/priceConfig/usagePriceConfig.ts +++ b/shared/models/productModels/priceModels/priceConfig/usagePriceConfig.ts @@ -8,11 +8,6 @@ export enum BillWhen { EndOfPeriod = "end_of_period", } -export enum TierBehavior { - Graduated = "graduated", - VolumeBased = "volume", -} - export const UsageTierSchema = z.object({ to: z.number().or(z.literal(Infinite)), amount: z.number(), diff --git a/shared/models/productModels/priceModels/priceModels.ts b/shared/models/productModels/priceModels/priceModels.ts index 1b5facca1..5be0067a6 100644 --- a/shared/models/productModels/priceModels/priceModels.ts +++ b/shared/models/productModels/priceModels/priceModels.ts @@ -2,13 +2,10 @@ import { z } from "zod/v4"; import { OnDecrease, OnIncrease, -} from "../../productV2Models/productItemModels/productItemEnums.js"; -import { FixedPriceConfigSchema } from "./priceConfig/fixedPriceConfig.js"; -import { - TierBehavior, - UsagePriceConfigSchema, -} from "./priceConfig/usagePriceConfig.js"; -import { BillingType } from "./priceEnums.js"; +} from "../../productV2Models/productItemModels/productItemEnums"; +import { FixedPriceConfigSchema } from "./priceConfig/fixedPriceConfig"; +import { UsagePriceConfigSchema } from "./priceConfig/usagePriceConfig"; +import { BillingType } from "./priceEnums"; const ProrationConfigSchema = z.object({ on_increase: z.nativeEnum(OnIncrease).default(OnIncrease.ProrateImmediately), @@ -22,7 +19,6 @@ export const PriceSchema = z.object({ org_id: z.string().optional(), created_at: z.number().optional(), billing_type: z.nativeEnum(BillingType).nullish(), - tier_behavior: z.nativeEnum(TierBehavior).nullish(), is_custom: z.boolean().optional(), config: FixedPriceConfigSchema.or(UsagePriceConfigSchema), diff --git a/shared/models/productModels/priceModels/priceTable.ts b/shared/models/productModels/priceModels/priceTable.ts index b4cd5a869..9a829bfe7 100644 --- a/shared/models/productModels/priceModels/priceTable.ts +++ b/shared/models/productModels/priceModels/priceTable.ts @@ -9,15 +9,12 @@ import { text, unique, } from "drizzle-orm/pg-core"; -import { collatePgColumn } from "../../../db/utils.js"; -import { entitlements } from "../entModels/entTable.js"; -import { products } from "../productTable.js"; -import type { FixedPriceConfig } from "./priceConfig/fixedPriceConfig.js"; -import type { - TierBehavior, - UsagePriceConfig, -} from "./priceConfig/usagePriceConfig.js"; -import type { ProrationConfig } from "./priceModels.js"; +import { collatePgColumn } from "../../../db/utils"; +import { entitlements } from "../entModels/entTable"; +import { products } from "../productTable"; +import type { FixedPriceConfig } from "./priceConfig/fixedPriceConfig"; +import type { UsagePriceConfig } from "./priceConfig/usagePriceConfig"; +import type { ProrationConfig } from "./priceModels"; export const prices = pgTable( "prices", @@ -28,9 +25,6 @@ export const prices = pgTable( config: jsonb().$type(), created_at: numeric({ mode: "number" }).notNull(), billing_type: text("billing_type"), - tier_behavior: text("tier_behavior") - .$type() - .default(sql`null`), is_custom: boolean("is_custom").default(false), entitlement_id: text("entitlement_id").default(sql`null`), proration_config: jsonb("proration_config") diff --git a/shared/models/productV2Models/productItemModels/featurePriceItem.ts b/shared/models/productV2Models/productItemModels/featurePriceItem.ts index 4ba7fee6a..4c3dafae2 100644 --- a/shared/models/productV2Models/productItemModels/featurePriceItem.ts +++ b/shared/models/productV2Models/productItemModels/featurePriceItem.ts @@ -11,7 +11,6 @@ export const FeaturePriceItemSchema = ProductItemSchema.pick({ price: true, tiers: true, - tier_behavior: true, billing_units: true, reset_usage_when_enabled: true, diff --git a/shared/models/productV2Models/productItemModels/productItemModels.ts b/shared/models/productV2Models/productItemModels/productItemModels.ts index 19b8a0287..4978a5766 100644 --- a/shared/models/productV2Models/productItemModels/productItemModels.ts +++ b/shared/models/productV2Models/productItemModels/productItemModels.ts @@ -2,7 +2,6 @@ import { z } from "zod/v4"; import { ApiFeatureV0Schema } from "../../../api/features/prevVersions/apiFeatureV0.js"; import { RolloverExpiryDurationType } from "../../productModels/durationTypes/rolloverExpiryDurationType.js"; import { ProductItemInterval } from "../../productModels/intervals/productItemInterval.js"; -import { TierBehavior } from "../../productModels/priceModels/priceConfig/usagePriceConfig.js"; import { Infinite } from "../../productModels/productEnums.js"; import { OnDecrease, OnIncrease } from "./productItemEnums.js"; @@ -120,10 +119,6 @@ export const ProductItemSchema = z.object({ "The billing units of the product item (eg $1 for 30 credits).", }), - tier_behavior: z.enum(TierBehavior).nullish().meta({ - description: "The type of tiered pricing: graduated or volume-based.", - }), - // Others // carry_over_usage: z.boolean().nullish(), reset_usage_when_enabled: z.boolean().nullish().meta({ diff --git a/shared/package.json b/shared/package.json index b9470676e..acc956b43 100644 --- a/shared/package.json +++ b/shared/package.json @@ -41,7 +41,6 @@ "@types/bun": "latest", "@types/node": "^24.0.3", "cross-env": "^7.0.3", - "tsx": "^4.21.0", "typescript": "^5.7.2" }, "private": true diff --git a/shared/utils/billingUtils/index.ts b/shared/utils/billingUtils/index.ts index 8bed944d3..ad9324b09 100644 --- a/shared/utils/billingUtils/index.ts +++ b/shared/utils/billingUtils/index.ts @@ -6,15 +6,14 @@ export * from "./intervalUtils/intervalArithmetic"; // Invoicing utils -export * from "./invoicingUtils/filterUnchangedPricesFromLineItems.js"; -export * from "./invoicingUtils/lineItemBuilders/buildLineItem.js"; -export * from "./invoicingUtils/lineItemBuilders/fixedPriceToLineItem.js"; -export * from "./invoicingUtils/lineItemBuilders/usagePriceToLineItem.js"; -export * from "./invoicingUtils/lineItemUtils/graduatedTiersToLineAmount.js"; -export * from "./invoicingUtils/lineItemUtils/lineItemToCustomerEntitlement.js"; -export * from "./invoicingUtils/lineItemUtils/priceToLineAmount.js"; -export * from "./invoicingUtils/lineItemUtils/tiersToLineAmount.js"; -export * from "./invoicingUtils/prorationUtils/applyProration.js"; -export * from "./invoicingUtils/prorationUtils/getEffectivePeriod.js"; -export * from "./invoicingUtils/prorationUtils/prorationConfigUtils.js"; -export * from "./usageUtils/roundUsageToNearestBillingUnit.js"; +export * from "./invoicingUtils/filterUnchangedPricesFromLineItems"; +export * from "./invoicingUtils/lineItemBuilders/buildLineItem"; +export * from "./invoicingUtils/lineItemBuilders/fixedPriceToLineItem"; +export * from "./invoicingUtils/lineItemBuilders/usagePriceToLineItem"; +export * from "./invoicingUtils/lineItemUtils/lineItemToCustomerEntitlement"; +export * from "./invoicingUtils/lineItemUtils/priceToLineAmount"; +export * from "./invoicingUtils/lineItemUtils/tiersToLineAmount"; +export * from "./invoicingUtils/prorationUtils/applyProration"; +export * from "./invoicingUtils/prorationUtils/getEffectivePeriod"; +export * from "./invoicingUtils/prorationUtils/prorationConfigUtils"; +export * from "./usageUtils/roundUsageToNearestBillingUnit"; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/graduatedTiersToLineAmount.ts b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/graduatedTiersToLineAmount.ts deleted file mode 100644 index f2d160246..000000000 --- a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/graduatedTiersToLineAmount.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Decimal } from "decimal.js"; -import type { UsageTier } from "../../../../models/productModels/priceModels/priceConfig/usagePriceConfig"; -import { Infinite } from "../../../../models/productModels/productEnums"; -import { nullish } from "../../../utils"; -import { roundUsageToNearestBillingUnit } from "../../usageUtils/roundUsageToNearestBillingUnit"; - -/** - * Core graduated tiered pricing calculation used across all billing contexts: - * included usage (free allowances), prepaid purchased quantities, and paid overage. - * - * Graduated pricing splits usage across tier bands — each band is charged at - * its own rate. For example, if tier 1 covers 0–100 units at $1 and tier 2 - * covers 100+ at $0.50, then 150 units costs (100 × $1) + (50 × $0.50) = $125. - * - * - **Included usage** (free allowance): pass `usage = includedQuantity`. The - * result represents the monetary value of the free bucket — used to compute - * how much of the prepaid charge is "used up" vs remaining. - * - **Prepaid quantity** (usage_in_advance): pass `usage = quantityPurchased`. - * The result is what the customer is charged upfront for the units they bought. - * - **Paid overage** (usage_in_arrear / pay-per-use): pass `usage = overageUnits` - * (raw usage minus any included or prepaid allowance). The result is the - * end-of-period charge for units consumed beyond the free/prepaid bucket. - * - * @param tiers - Ordered array of tier bands from the price config (`usage_tiers`). - * @param usage - The quantity to price. Meaning depends on context: purchased - * quantity for prepaid, overage units for arrear billing, or free-bucket size - * for included-usage valuation. Must be non-negative unless `allowNegative` is true. - * @param billingUnits - Divisor applied before multiplying by tier rate (e.g. 1000 - * for "per 1k tokens"). Defaults to 1. - * @param allowNegative - When true, a negative `usage` is priced on its absolute - * value and the result is negated. Used for downgrade credits / proration refunds. - * Defaults to false. - * @returns The total dollar amount as a number rounded to 10 decimal places. - */ -export const graduatedTiersToLineAmount = ({ - tiers, - usage, - billingUnits = 1, - allowNegative = false, -}: { - tiers: UsageTier[]; - usage: number; - billingUnits?: number; - allowNegative?: boolean; -}): number => { - if (nullish(tiers)) { - throw new Error( - "[graduatedTiersToLineAmount] usage_tiers required for usage-based prices", - ); - } - - const isNegative = allowNegative && usage < 0; - const absoluteUsage = allowNegative ? Math.abs(usage) : usage; - - const roundedUsage = roundUsageToNearestBillingUnit({ - usage: absoluteUsage, - billingUnits, - }); - - let amount = new Decimal(0); - let remaining = new Decimal(roundedUsage); - let lastTierTo = 0; - - for (const tier of tiers) { - if (remaining.lte(0)) break; - - const isFinalTier = tier.to === Infinite || tier.to === -1; - - const tierSize = isFinalTier - ? remaining - : Decimal.min(remaining, new Decimal(tier.to).minus(lastTierTo)); - - const rate = new Decimal(tier.amount).div(billingUnits); - amount = amount.plus(rate.mul(tierSize)); - remaining = remaining.minus(tierSize); - - if (!isFinalTier) { - lastTierTo = tier.to as number; - } - } - - const finalAmount = amount.toDecimalPlaces(10).toNumber(); - return isNegative ? -finalAmount : finalAmount; -}; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts index 372296db9..6de45f2f2 100644 --- a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts +++ b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts @@ -1,29 +1,9 @@ -import { TierBehavior } from "@models/productModels/priceModels/priceConfig/usagePriceConfig"; -import { volumeTiersToLineAmount } from "@utils/billingUtils/invoicingUtils/lineItemUtils/volumeTiersToLineAmount"; +import { Decimal } from "decimal.js"; import type { Price } from "../../../../models/productModels/priceModels/priceModels"; +import { Infinite } from "../../../../models/productModels/productEnums"; import { nullish } from "../../../utils"; -import { graduatedTiersToLineAmount } from "./graduatedTiersToLineAmount"; +import { roundUsageToNearestBillingUnit } from "../../usageUtils/roundUsageToNearestBillingUnit"; -/** - * 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. - * - * "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. - * - * 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). - */ export const tiersToLineAmount = ({ price, overage, @@ -33,28 +13,43 @@ export const tiersToLineAmount = ({ overage: number; billingUnits?: number; }): number => { + const isNegative = overage < 0; + const absoluteOverage = Math.abs(overage); + + const roundedOverage = roundUsageToNearestBillingUnit({ + usage: absoluteOverage, + billingUnits, + }); + + let amount = new Decimal(0); + let remaining = new Decimal(roundedOverage); + let lastTierTo = 0; const tiers = price.config.usage_tiers; - const isVolume = price.tier_behavior === TierBehavior.VolumeBased; if (nullish(tiers)) { throw new Error( - "[tiersToLineAmount] usage_tiers required for usage-based or prepaid prices", + `[tiersToLineAmount] usage_tiers required for usage-based prices`, ); } - if (isVolume) { - return volumeTiersToLineAmount({ - tiers, - usage: overage, - billingUnits, - allowNegative: true, - }); + for (const tier of tiers) { + if (remaining.lte(0)) break; + + const isFinalTier = tier.to === Infinite || tier.to === -1; + + const tierSize = isFinalTier + ? remaining + : Decimal.min(remaining, new Decimal(tier.to).minus(lastTierTo)); + + const rate = new Decimal(tier.amount).div(billingUnits); + amount = amount.plus(rate.mul(tierSize)); + remaining = remaining.minus(tierSize); + + if (tier.to !== Infinite && tier.to !== -1) { + lastTierTo = tier.to; + } } - return graduatedTiersToLineAmount({ - tiers, - usage: overage, - billingUnits, - allowNegative: true, - }); + const finalAmount = amount.toDecimalPlaces(10).toNumber(); + return isNegative ? -finalAmount : finalAmount; }; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/volumeTiersToLineAmount.ts b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/volumeTiersToLineAmount.ts deleted file mode 100644 index d62019854..000000000 --- a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/volumeTiersToLineAmount.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { UsageTier } from "@models/productModels/priceModels/priceConfig/usagePriceConfig"; -import { Infinite } from "@models/productModels/productEnums"; -import { roundUsageToNearestBillingUnit } from "@utils/billingUtils/usageUtils/roundUsageToNearestBillingUnit"; -import { nullish } from "@utils/utils"; -import { Decimal } from "decimal.js"; - -export const volumeTiersToLineAmount = ({ - tiers, - usage, - billingUnits = 1, - allowNegative = false, -}: { - tiers: UsageTier[]; - usage: number; - billingUnits?: number; - allowNegative?: boolean; -}): number => { - if (nullish(tiers)) { - throw new Error( - "[volumeTiersToLineAmount] usage_tiers required for volume-based prices", - ); - } - - const isNegative = allowNegative && usage < 0; - const absoluteUsage = allowNegative ? Math.abs(usage) : Math.max(0, usage); - - const roundedUsage = roundUsageToNearestBillingUnit({ - usage: absoluteUsage, - billingUnits, - }); - - let amount = new Decimal(0); - - // 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. - for (const tier of tiers) { - const isFinalTier = tier.to === Infinite || tier.to === -1; - const tierBoundary = isFinalTier ? Infinity : (tier.to as number); - - // If the usage is within this current tier, - if (roundedUsage <= tierBoundary) { - // Assume the total amount is THIS tier's cost * the usage - const rate = new Decimal(tier.amount).div(billingUnits); - amount = rate.mul(roundedUsage); - // Do not consider each tier individually, just use the total amount for this tier. - break; - } - } - - const finalAmount = amount.toDecimalPlaces(10).toNumber(); - return isNegative ? -finalAmount : finalAmount; -}; diff --git a/shared/utils/cusEntUtils/balanceUtils/customerEntitlementToBalancePrice.ts b/shared/utils/cusEntUtils/balanceUtils/customerEntitlementToBalancePrice.ts index f6f7458fd..0c1350838 100644 --- a/shared/utils/cusEntUtils/balanceUtils/customerEntitlementToBalancePrice.ts +++ b/shared/utils/cusEntUtils/balanceUtils/customerEntitlementToBalancePrice.ts @@ -1,10 +1,7 @@ import type { ApiBalanceBreakdownPrice } from "@api/customers/cusFeatures/apiBalanceV1.js"; import { BillingMethod } from "@api/products/components/billingMethod.js"; import type { FullCusEntWithFullCusProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct.js"; -import { - TierBehavior, - type UsagePriceConfig, -} from "@models/productModels/priceModels/priceConfig/usagePriceConfig.js"; +import type { UsagePriceConfig } from "@models/productModels/priceModels/priceConfig/usagePriceConfig.js"; import { cusEntsToMaxPurchase } from "@utils/cusEntUtils/convertCusEntUtils/cusEntsToMaxPurchase.js"; import { cusEntToCusPrice } from "@utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice.js"; import { customerPriceToBillingUnits } from "@utils/cusPriceUtils/convertCustomerPrice/customerPriceToBillingUnits.js"; @@ -36,7 +33,6 @@ export const customerEntitlementToBalancePrice = ({ // If usage price with multiple tiers, use tiers array let amount: number | undefined; let tiers: UsagePriceConfig["usage_tiers"] | undefined; - let tier_behavior: TierBehavior | undefined; if (isFixedPrice(price)) { amount = price.config.amount; @@ -46,14 +42,12 @@ export const customerEntitlementToBalancePrice = ({ amount = usageTiers[0].amount; } else { tiers = usageTiers; - tier_behavior = price.tier_behavior ?? TierBehavior.Graduated; } } return { amount, tiers, - tier_behavior, billing_units: billingUnits, billing_method: billingMethod, max_purchase: maxPurchase, diff --git a/shared/utils/cusProductUtils/featureOptionUtils/convertFeatureOptions.ts b/shared/utils/cusProductUtils/featureOptionUtils/convertFeatureOptions.ts index 01c2eb33a..b455740e0 100644 --- a/shared/utils/cusProductUtils/featureOptionUtils/convertFeatureOptions.ts +++ b/shared/utils/cusProductUtils/featureOptionUtils/convertFeatureOptions.ts @@ -1,22 +1,9 @@ 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"; -/** - * 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. - * - * 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. - */ export const featureOptionsToV2StripeQuantity = ({ featureOptions, price, @@ -29,22 +16,14 @@ 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, }); - // Graduated: Stripe needs total packs (purchased + allowance) because the - // V2 price has a free leading tier that covers the allowance. + // 1. If no packs, return allowance if (!packsExcludingAllowance) return allowanceInPacks; + // 2. Otherwise, return the total quantity return new Decimal(packsExcludingAllowance).add(allowanceInPacks).toNumber(); }; diff --git a/shared/utils/productUtils/priceToInvoiceAmount.ts b/shared/utils/productUtils/priceToInvoiceAmount.ts index 9e5378672..025d8117f 100644 --- a/shared/utils/productUtils/priceToInvoiceAmount.ts +++ b/shared/utils/productUtils/priceToInvoiceAmount.ts @@ -7,7 +7,6 @@ import { type ProductItem, UsageModel, } from "../../models/productV2Models/productItemModels/productItemModels.js"; -import { tiersToLineAmount } from "../billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.js"; import { isPriceItem } from "../productV2Utils/productItemUtils/getItemType.js"; import { calculateProrationAmount, @@ -17,27 +16,6 @@ import { nullish } from "../utils.js"; import { isFixedPrice } from "./priceUtils/classifyPriceUtils.js"; import { getBillingType } from "./priceUtils.js"; -/** - * Prices an arbitrary quantity against a usage price's tier schedule. - * This is the single entry point for converting a raw unit count into dollars — - * it does NOT know whether that count represents included allowance, prepaid - * purchased units, or paid overage. The caller is responsible for passing the - * correct quantity for the billing context: - * - * - **Included usage** (free allowance): pass the size of the free bucket to - * find out its monetary value (used internally for proration math). - * - **Prepaid** (`UsageInAdvance`): pass the quantity the customer is buying - * upfront. The result is the immediate charge. - * - **Pay-per-use overage** (`UsageInArrear`): pass only the units consumed - * *above* any included free allowance — i.e. `totalUsage − includedFree`. - * Do NOT pass raw total usage here; the caller must subtract the free tier first. - * - * @param price - The usage price whose config contains `usage_tiers` and - * optionally `billing_units`. - * @param quantity - The unit count to price. Must already be net of any free - * included allowance when called in an overage context. - * @returns Dollar amount as a number rounded to 10 decimal places. - */ export const getAmountForQuantity = ({ price, quantity, @@ -46,13 +24,46 @@ export const getAmountForQuantity = ({ quantity: number; }) => { const config = price.config as UsagePriceConfig; + const billingUnits = config.billing_units || 1; - return tiersToLineAmount({ - price, - overage: quantity, - billingUnits, - }); + const roundedQuantity = new Decimal(quantity) + .div(billingUnits) + .ceil() + .mul(billingUnits) + .toNumber(); + + let lastTierTo: number = 0; + + let amount = new Decimal(0); + let remainingUsage = new Decimal(roundedQuantity); + + // console.log("Getting amount for quantity:", roundedQuantity); + // console.log("Usage tiers:", config.usage_tiers); + + for (let i = 0; i < config.usage_tiers.length; i++) { + const tier = config.usage_tiers[i]; + + let usageWithinTier = new Decimal(0); + if (tier.to === Infinite || tier.to === -1) { + usageWithinTier = remainingUsage; + } else { + const tierUsage = new Decimal(tier.to).minus(lastTierTo); + usageWithinTier = Decimal.min(remainingUsage, tierUsage); + lastTierTo = tier.to; + } + + const amountPerUnit = new Decimal(tier.amount).div(billingUnits); + const amountWithinTier = amountPerUnit.mul(usageWithinTier); + amount = amount.plus(amountWithinTier); + remainingUsage = remainingUsage.minus(usageWithinTier); + + if (remainingUsage.lte(0)) { + break; + } + } + + return amount.toDecimalPlaces(10).toNumber(); }; export const itemToInvoiceAmount = ({ @@ -76,7 +87,6 @@ export const itemToInvoiceAmount = ({ } const price = { - tier_behavior: item.tier_behavior, config: { usage_tiers: item.tiers || [ { diff --git a/shared/utils/productUtils/priceUtils/convertPrice/priceToStripeCreatePriceParams.ts b/shared/utils/productUtils/priceUtils/convertPrice/priceToStripeCreatePriceParams.ts index 7b7e503d7..d9e4461cf 100644 --- a/shared/utils/productUtils/priceUtils/convertPrice/priceToStripeCreatePriceParams.ts +++ b/shared/utils/productUtils/priceUtils/convertPrice/priceToStripeCreatePriceParams.ts @@ -7,7 +7,6 @@ import { priceToStripePrepaidV2Tiers } from "@utils/productUtils/priceUtils/conv import { priceToStripeProductName } from "@utils/productUtils/priceUtils/convertPrice/priceToStripeProductName"; import { priceToStripeRecurringParams } from "@utils/productUtils/priceUtils/convertPrice/priceToStripeRecurringParams"; import type Stripe from "stripe"; -import { priceToStripeTiersMode } from "./priceToStripeTiersMode"; export const priceToStripeCreatePriceParams = ({ price, @@ -41,7 +40,6 @@ export const priceToStripeCreatePriceParams = ({ }; const tiers = priceToStripePrepaidV2Tiers({ price, entitlement, org }); - const tiersMode = priceToStripeTiersMode({ price }); let priceAmountData = {}; if (tiers.length === 1) { @@ -51,7 +49,7 @@ export const priceToStripeCreatePriceParams = ({ } else { priceAmountData = { billing_scheme: "tiered", - tiers_mode: tiersMode, + tiers_mode: "graduated", tiers: tiers, }; } diff --git a/shared/utils/productUtils/priceUtils/convertPrice/priceToStripePrepaidV2Tiers.ts b/shared/utils/productUtils/priceUtils/convertPrice/priceToStripePrepaidV2Tiers.ts index 5a6f55259..9a190010e 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 { @@ -14,21 +11,6 @@ import { atmnToStripeAmountDecimal } from "@utils/productUtils/priceUtils/conver import { Decimal } from "decimal.js"; import type Stripe from "stripe"; -/** - * Builds the Stripe tier array for a V2 prepaid price. - * - * For **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 **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. - */ export const priceToStripePrepaidV2Tiers = ({ price, entitlement, @@ -39,14 +21,10 @@ 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 there is an allowance, first tier is free + if (entitlement.allowance) { tiers.push({ unit_amount_decimal: "0", up_to: entitlement.allowance, @@ -65,7 +43,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/productUtils/priceUtils/convertPrice/priceToStripeTiersMode.ts b/shared/utils/productUtils/priceUtils/convertPrice/priceToStripeTiersMode.ts deleted file mode 100644 index f5c79da92..000000000 --- a/shared/utils/productUtils/priceUtils/convertPrice/priceToStripeTiersMode.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { type Price, TierBehavior } from "../../../.."; - -export const priceToStripeTiersMode = ({ price }: { price: Price }) => { - return price.tier_behavior === TierBehavior.VolumeBased - ? "volume" - : "graduated"; -}; diff --git a/shared/utils/productUtils/priceUtils/getPriceForOverage.ts b/shared/utils/productUtils/priceUtils/getPriceForOverage.ts deleted file mode 100644 index f935f037a..000000000 --- a/shared/utils/productUtils/priceUtils/getPriceForOverage.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { FixedPriceConfig } from "../../../models/productModels/priceModels/priceConfig/fixedPriceConfig"; -import type { UsagePriceConfig } from "../../../models/productModels/priceModels/priceConfig/usagePriceConfig"; -import { BillingType } from "../../../models/productModels/priceModels/priceEnums"; -import type { Price } from "../../../models/productModels/priceModels/priceModels"; -import { tiersToLineAmount } from "../../billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount"; -import { getBillingType } from "../priceUtils"; - -/** - * Returns the dollar cost for a given overage quantity on a price. - * - * "Overage" is the portion of usage that is **not** covered by included free - * allowance. How it is calculated depends on the billing model: - * - * - **Included usage (free tier):** no overage is charged; the free bucket is - * consumed first and this function is not called for those units. - * - **Prepaid** (`UsageInAdvance`): the customer purchased units upfront. - * Overage here is any consumption beyond what was prepaid — typically priced - * via `tiersToLineAmount` at the end of the period, not this function. - * - **Pay-per-use** (`UsageInArrear`): overage is `totalUsage − includedFree`. - * Pass that net quantity as `overage`; this function prices it against the - * graduated tier schedule. - * - **Fixed** (`FixedCycle` / `OneOff`): no usage tiers apply; returns the flat - * `config.amount` regardless of the `overage` argument. - * - * @param price - The price to evaluate. Determines billing type and tier schedule. - * @param overage - Net units consumed above the free/prepaid allowance. - * Ignored for fixed-price billing types. Must be pre-subtracted by the caller. - * @returns Dollar amount for the overage, or the flat price amount for fixed prices. - */ -export const getPriceForOverage = ({ - price, - overage, -}: { - price: Price; - overage?: number; -}) => { - const usageConfig = price.config as UsagePriceConfig; - const billingType = getBillingType(usageConfig); - - if ( - billingType === BillingType.FixedCycle || - billingType === BillingType.OneOff - ) { - const config = price.config as FixedPriceConfig; - return config.amount; - } - - const billingUnits = usageConfig.billing_units || 1; - - return tiersToLineAmount({ - price, - overage: overage!, - billingUnits, - }); -}; diff --git a/shared/utils/productUtils/priceUtils/index.ts b/shared/utils/productUtils/priceUtils/index.ts index 9de0fcd3f..9930d3f85 100644 --- a/shared/utils/productUtils/priceUtils/index.ts +++ b/shared/utils/productUtils/priceUtils/index.ts @@ -1,22 +1,18 @@ import { priceIsTieredOneOff } from "@utils/productUtils/priceUtils/classifyPrice/priceIsTieredOneOff.js"; import { priceToAllowanceInPacks } from "@utils/productUtils/priceUtils/convertPrice/priceToAllowanceInPacks.js"; import { priceToStripeCreatePriceParams } from "@utils/productUtils/priceUtils/convertPrice/priceToStripeCreatePriceParams.js"; -import { priceToStripeTiersMode } from "./convertPrice/priceToStripeTiersMode.js"; export * from "./classifyPrice/priceIsTieredOneOff.js"; export * from "./classifyPriceUtils.js"; export * from "./convertAmountUtils.js"; -export * from "./convertPrice/priceToStripeTiersMode.js"; export * from "./convertPriceUtils.js"; export * from "./findPrice/findPriceByFeatureId.js"; export * from "./formatPriceUtils.js"; -export * from "./getPriceForOverage.js"; export const priceUtils = { convert: { toAllowanceInPacks: priceToAllowanceInPacks, toStripeCreatePriceParams: priceToStripeCreatePriceParams, - toStripeTiersMode: priceToStripeTiersMode, }, isTieredOneOff: priceIsTieredOneOff, diff --git a/shared/utils/productV2Utils/compareProductUtils/compareItemUtils.ts b/shared/utils/productV2Utils/compareProductUtils/compareItemUtils.ts index 58aee9076..ed0fb3277 100644 --- a/shared/utils/productV2Utils/compareProductUtils/compareItemUtils.ts +++ b/shared/utils/productV2Utils/compareProductUtils/compareItemUtils.ts @@ -306,10 +306,6 @@ export const featurePriceItemsAreSame = ({ condition: tiersAreSame(item1.tiers || null, item2.tiers || null), message: `Tiers different`, }, - tier_behavior: { - condition: item1.tier_behavior == item2.tier_behavior, - message: `Tiers type different: ${item1.tier_behavior} != ${item2.tier_behavior}`, - }, billing_units: { condition: item1.billing_units == item2.billing_units, message: `Billing units different: ${item1.billing_units} !== ${item2.billing_units}`, diff --git a/shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemToPlanItemParamsV1.ts b/shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemToPlanItemParamsV1.ts index 458a39ab7..74e88531b 100644 --- a/shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemToPlanItemParamsV1.ts +++ b/shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemToPlanItemParamsV1.ts @@ -41,7 +41,6 @@ export const productItemToPlanItemParamsV1 = ({ billing_units: planItemV1.price.billing_units, billing_method: planItemV1.price.billing_method, max_purchase: planItemV1.price.max_purchase ?? undefined, - tier_behavior: planItemV1.price.tier_behavior ?? undefined, } : undefined, proration: planItemV1.proration diff --git a/shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemToPlanItemV1.ts b/shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemToPlanItemV1.ts index 43d88c058..33a80fe3e 100644 --- a/shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemToPlanItemV1.ts +++ b/shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemToPlanItemV1.ts @@ -79,7 +79,6 @@ const itemToPlanFeaturePrice = ({ return { amount: price ?? undefined, tiers: tiers, - tier_behavior: item.tier_behavior ?? undefined, interval: itemToBillingInterval({ item }), interval_count: diff --git a/shared/utils/productV2Utils/productItemUtils/mapToItem.ts b/shared/utils/productV2Utils/productItemUtils/mapToItem.ts index ae4193a4e..b4a6c7da1 100644 --- a/shared/utils/productV2Utils/productItemUtils/mapToItem.ts +++ b/shared/utils/productV2Utils/productItemUtils/mapToItem.ts @@ -105,7 +105,6 @@ export const toFeaturePriceItem = ({ price: null, tiers, billing_units: config.billing_units, - tier_behavior: price.tier_behavior ?? null, entity_feature_id: ent.entity_feature_id, reset_usage_when_enabled: !ent.carry_from_previous, diff --git a/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx b/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx index 10284f481..949501787 100644 --- a/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx +++ b/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx @@ -1,9 +1,5 @@ -import { FeatureType, TierBehavior } from "@autumn/shared"; -import { - DropSimpleIcon, - PencilSimpleIcon, - RulerIcon, -} from "@phosphor-icons/react"; +import { FeatureType } from "@autumn/shared"; +import { PencilSimpleIcon } from "@phosphor-icons/react"; import { useState } from "react"; import { IconButton } from "@/components/v2/buttons/IconButton"; import { @@ -11,13 +7,6 @@ import { useProduct, useSheet, } from "@/components/v2/inline-custom-plan-editor/PlanEditorContext"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/v2/selects/Select"; import { SheetHeader, SheetSection } from "@/components/v2/sheets/InlineSheet"; import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import { getFeature } from "@/utils/product/entitlementUtils"; @@ -38,7 +27,7 @@ export function EditPlanFeatureSheet({ }: { isOnboarding?: boolean; }) { - const { item, setItem } = useProductItemContext(); + const { item } = useProductItemContext(); const { features, refetch } = useFeaturesQuery(); const { product, setProduct } = useProduct(); const { setInitialItem } = useSheet(); @@ -118,62 +107,7 @@ export function EditPlanFeatureSheet({ {isFeaturePrice && ( - 1 ? ( -
- Price - -
- ) : ( - "Price" - ) - } - className="space-y-3" - > +
diff --git a/vite/src/views/products/plan/components/edit-plan-feature/PriceTiers.tsx b/vite/src/views/products/plan/components/edit-plan-feature/PriceTiers.tsx index 3852703ea..9547a464d 100644 --- a/vite/src/views/products/plan/components/edit-plan-feature/PriceTiers.tsx +++ b/vite/src/views/products/plan/components/edit-plan-feature/PriceTiers.tsx @@ -1,11 +1,6 @@ -import { - Infinite, - type PriceTier, - TierBehavior, - UsageModel, -} from "@autumn/shared"; +import { Infinite, type PriceTier } from "@autumn/shared"; import { PlusIcon, TrashSimpleIcon } from "@phosphor-icons/react"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { IconButton } from "@/components/v2/buttons/IconButton"; import { Input } from "@/components/v2/inputs/Input"; import { @@ -92,17 +87,6 @@ export function PriceTiers() { ); const [isEditing, setIsEditing] = useState>({}); - // Auto-select prepaid when volume-based is active with multiple tiers - useEffect(() => { - if ( - item?.tier_behavior === TierBehavior.VolumeBased && - (item?.tiers?.length ?? 0) > 1 && - item?.usage_model !== UsageModel.Prepaid - ) { - setItem({ ...item, usage_model: UsageModel.Prepaid }); - } - }, [item?.tier_behavior, item?.tiers?.length]); - if (!item) return null; const tiers = item.tiers || []; @@ -251,7 +235,7 @@ export function PriceTiers() { })} addTier({ item, setItem })} icon={} diff --git a/vite/src/views/products/plan/components/edit-plan-feature/PricedFeatureSettings.tsx b/vite/src/views/products/plan/components/edit-plan-feature/PricedFeatureSettings.tsx index f9187a619..98e3581b9 100644 --- a/vite/src/views/products/plan/components/edit-plan-feature/PricedFeatureSettings.tsx +++ b/vite/src/views/products/plan/components/edit-plan-feature/PricedFeatureSettings.tsx @@ -3,7 +3,6 @@ import { itemToBillingInterval, nullish, ProductItemInterval, - TierBehavior, UsageModel, } from "@autumn/shared"; import { FormLabel } from "@/components/v2/form/FormLabel"; @@ -17,9 +16,6 @@ export function PricedFeatureSettings() { if (!item) return null; const isOneOff = itemToBillingInterval({ item }) === BillingInterval.OneOff; - const isVolumeBased = - item.tier_behavior === TierBehavior.VolumeBased && - (item.tiers?.length ?? 0) > 1; const handleUsageModelChange = (value: string) => { const usageModel = value as UsageModel; @@ -34,7 +30,7 @@ export function PricedFeatureSettings() { }; return ( -
+
Billing Method