From c41c2c94230741419cb7bbc4434287ceff3b08c5 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:52:26 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20implement=20volume=20tie?= =?UTF-8?q?r=20loop=20+=20tests=20+=20dispatcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../createStripePrice/createStripePrepaid.ts | 7 +- .../tiers-to-line-amount.test.ts | 421 ++++++++++++------ shared/utils/billingUtils/index.ts | 2 +- .../calculateGraduatedTiersAmount.ts | 60 --- .../graduatedTiersToLineAmount.ts | 84 ++++ .../lineItemUtils/tiersToLineAmount.ts | 37 +- .../lineItemUtils/volumeTiersToLineAmount.ts | 55 +++ .../productUtils/priceToInvoiceAmount.ts | 31 +- .../priceToStripeCreatePriceParams.ts | 8 +- .../priceUtils/getPriceForOverage.ts | 30 +- 10 files changed, 527 insertions(+), 208 deletions(-) delete mode 100644 shared/utils/billingUtils/invoicingUtils/lineItemUtils/calculateGraduatedTiersAmount.ts create mode 100644 shared/utils/billingUtils/invoicingUtils/lineItemUtils/graduatedTiersToLineAmount.ts create mode 100644 shared/utils/billingUtils/invoicingUtils/lineItemUtils/volumeTiersToLineAmount.ts diff --git a/server/src/external/stripe/createStripePrice/createStripePrepaid.ts b/server/src/external/stripe/createStripePrice/createStripePrepaid.ts index 32eb702c8..7282b941c 100644 --- a/server/src/external/stripe/createStripePrice/createStripePrepaid.ts +++ b/server/src/external/stripe/createStripePrice/createStripePrepaid.ts @@ -5,6 +5,7 @@ import { type Organization, type Price, type Product, + TierBehaviours, TierInfinite, type UsagePriceConfig, } from "@autumn/shared"; @@ -113,6 +114,10 @@ export const createStripePrepaid = async ({ config.stripe_price_id = stripePrice.id; } else { const tiers = prepaidToStripeTiers({ price, org }); + const tiersMode = + price.tier_behaviour === TierBehaviours.VolumeBased + ? "volume" + : "graduated"; let priceAmountData = {}; if (tiers.length === 1) { @@ -122,7 +127,7 @@ export const createStripePrepaid = async ({ } else { priceAmountData = { billing_scheme: "tiered", - tiers_mode: "graduated", + tiers_mode: tiersMode, tiers: tiers, }; } 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 e85e407fb..c6d3b265d 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,12 +1,19 @@ import { describe, expect, test } from "bun:test"; -import { Infinite, type Price, tiersToLineAmount } from "@autumn/shared"; +import { + Infinite, + TierBehaviours, + type Price, + tiersToLineAmount, +} from "@autumn/shared"; const createMockPrice = ( tiers: { to: number | typeof Infinite; amount: number }[], + tierBehaviour?: TierBehaviours, ): Price => ({ id: "test-price", internal_product_id: "test-product", + tier_behaviour: tierBehaviour, config: { type: "usage", usage_tiers: tiers, @@ -14,153 +21,303 @@ const createMockPrice = ( }) as unknown as Price; describe("tiersToLineAmount", () => { - 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("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 }); - 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); - }); - }); - - 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(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, + 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(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 }, + 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 }, ]); - // 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("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); + }); }); - 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); + 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); + }); }); - 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("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(); + }); }); }); - 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); + 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 }, + ], + TierBehaviours.VolumeBased, + ); + + 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("throws if no tiers", () => { - const price = { config: {} } as unknown as Price; - expect(() => tiersToLineAmount({ price, overage: 100 })).toThrow(); + describe("single tier (flat rate)", () => { + test("100 units @ $0.10/unit = $10", () => { + const price = createMockPrice( + [{ to: Infinite, amount: 0.1 }], + TierBehaviours.VolumeBased, + ); + const result = tiersToLineAmount({ price, overage: 100 }); + expect(result).toBe(10); + }); + + test("0 units = $0", () => { + const price = createMockPrice( + [{ to: Infinite, amount: 0.1 }], + TierBehaviours.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 }], + TierBehaviours.VolumeBased, + ); + const result = tiersToLineAmount({ price, 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 }, + ], + TierBehaviours.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); + }); + }); + + 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("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); + }); }); }); + }); diff --git a/shared/utils/billingUtils/index.ts b/shared/utils/billingUtils/index.ts index ba1244c0e..6843d14a8 100644 --- a/shared/utils/billingUtils/index.ts +++ b/shared/utils/billingUtils/index.ts @@ -10,7 +10,7 @@ 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/calculateGraduatedTiersAmount.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"; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/calculateGraduatedTiersAmount.ts b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/calculateGraduatedTiersAmount.ts deleted file mode 100644 index fba382c1d..000000000 --- a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/calculateGraduatedTiersAmount.ts +++ /dev/null @@ -1,60 +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. - * Walks usage_tiers, accumulating cost per tier band. - */ -export const calculateGraduatedTiersAmount = ({ - tiers, - usage, - billingUnits = 1, - allowNegative = false, -}: { - tiers: UsageTier[]; - usage: number; - billingUnits?: number; - allowNegative?: boolean; -}): number => { - if (nullish(tiers)) { - throw new Error( - "[calculateGraduatedTiersAmount] 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/graduatedTiersToLineAmount.ts b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/graduatedTiersToLineAmount.ts new file mode 100644 index 000000000..f2d160246 --- /dev/null +++ b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/graduatedTiersToLineAmount.ts @@ -0,0 +1,84 @@ +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 dc5edf61d..0bf01b640 100644 --- a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts +++ b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts @@ -1,9 +1,28 @@ +import { TierBehaviours } from "@models/productModels/priceModels/priceConfig/usagePriceConfig"; +import { volumeTiersToLineAmount } from "@utils/billingUtils/invoicingUtils/lineItemUtils/volumeTiersToLineAmount"; import type { Price } from "../../../../models/productModels/priceModels/priceModels"; import { nullish } from "../../../utils"; -import { calculateGraduatedTiersAmount } from "./calculateGraduatedTiersAmount"; +import { graduatedTiersToLineAmount } from "./graduatedTiersToLineAmount"; /** - * Overage is ANY usage outside of included usage. + * 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, @@ -15,14 +34,24 @@ export const tiersToLineAmount = ({ billingUnits?: number; }): number => { const tiers = price.config.usage_tiers; + const isVolume = price.tier_behaviour === TierBehaviours.VolumeBased; if (nullish(tiers)) { throw new Error( - "[tiersToLineAmount] usage_tiers required for usage-based prices", + "[tiersToLineAmount] usage_tiers required for usage-based or prepaid prices", ); } - return calculateGraduatedTiersAmount({ + if (isVolume) { + return volumeTiersToLineAmount({ + tiers, + usage: overage, + billingUnits, + allowNegative: true, + }); + } + + return graduatedTiersToLineAmount({ tiers, usage: overage, billingUnits, diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/volumeTiersToLineAmount.ts b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/volumeTiersToLineAmount.ts new file mode 100644 index 000000000..72e608b78 --- /dev/null +++ b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/volumeTiersToLineAmount.ts @@ -0,0 +1,55 @@ +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) : 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/productUtils/priceToInvoiceAmount.ts b/shared/utils/productUtils/priceToInvoiceAmount.ts index 3f00dd1a5..e6254df0e 100644 --- a/shared/utils/productUtils/priceToInvoiceAmount.ts +++ b/shared/utils/productUtils/priceToInvoiceAmount.ts @@ -7,7 +7,7 @@ import { type ProductItem, UsageModel, } from "../../models/productV2Models/productItemModels/productItemModels.js"; -import { calculateGraduatedTiersAmount } from "../billingUtils/invoicingUtils/lineItemUtils/calculateGraduatedTiersAmount.js"; +import { tiersToLineAmount } from "../billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.js"; import { isPriceItem } from "../productV2Utils/productItemUtils/getItemType.js"; import { calculateProrationAmount, @@ -17,6 +17,27 @@ 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, @@ -26,11 +47,10 @@ export const getAmountForQuantity = ({ }) => { const config = price.config as UsagePriceConfig; const billingUnits = config.billing_units || 1; - const tiers = config.usage_tiers; - return calculateGraduatedTiersAmount({ - tiers, - usage: quantity, + return tiersToLineAmount({ + price, + overage: quantity, billingUnits, }); }; @@ -56,6 +76,7 @@ export const itemToInvoiceAmount = ({ } const price = { + tier_behaviour: item.tier_behaviour, config: { usage_tiers: item.tiers || [ { diff --git a/shared/utils/productUtils/priceUtils/convertPrice/priceToStripeCreatePriceParams.ts b/shared/utils/productUtils/priceUtils/convertPrice/priceToStripeCreatePriceParams.ts index d9e4461cf..b7eb77bb7 100644 --- a/shared/utils/productUtils/priceUtils/convertPrice/priceToStripeCreatePriceParams.ts +++ b/shared/utils/productUtils/priceUtils/convertPrice/priceToStripeCreatePriceParams.ts @@ -1,4 +1,5 @@ import type { Organization } from "@models/orgModels/orgTable"; +import { TierBehaviours } from "@models/productModels/priceModels/priceConfig/usagePriceConfig"; import type { Price } from "@models/productModels/priceModels/priceModels"; import type { FullProduct } from "@models/productModels/productModels"; import { orgToCurrency } from "@utils/orgUtils/convertOrgUtils"; @@ -41,6 +42,11 @@ export const priceToStripeCreatePriceParams = ({ const tiers = priceToStripePrepaidV2Tiers({ price, entitlement, org }); + const tiersMode = + price.tier_behaviour === TierBehaviours.VolumeBased + ? "volume" + : "graduated"; + let priceAmountData = {}; if (tiers.length === 1) { priceAmountData = { @@ -49,7 +55,7 @@ export const priceToStripeCreatePriceParams = ({ } else { priceAmountData = { billing_scheme: "tiered", - tiers_mode: "graduated", + tiers_mode: tiersMode, tiers: tiers, }; } diff --git a/shared/utils/productUtils/priceUtils/getPriceForOverage.ts b/shared/utils/productUtils/priceUtils/getPriceForOverage.ts index 46e8f15b8..f935f037a 100644 --- a/shared/utils/productUtils/priceUtils/getPriceForOverage.ts +++ b/shared/utils/productUtils/priceUtils/getPriceForOverage.ts @@ -2,9 +2,31 @@ import type { FixedPriceConfig } from "../../../models/productModels/priceModels 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 { calculateGraduatedTiersAmount } from "../../billingUtils/invoicingUtils/lineItemUtils/calculateGraduatedTiersAmount"; +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, @@ -25,9 +47,9 @@ export const getPriceForOverage = ({ const billingUnits = usageConfig.billing_units || 1; - return calculateGraduatedTiersAmount({ - tiers: usageConfig.usage_tiers, - usage: overage!, + return tiersToLineAmount({ + price, + overage: overage!, billingUnits, }); };