feat: 🎸 implement volume tier loop + tests + dispatcher

This commit is contained in:
amianthus
2026-02-23 13:52:26 +00:00
parent c22316f7ba
commit c41c2c9423
10 changed files with 527 additions and 208 deletions

View File

@@ -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,
};
}

View File

@@ -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);
});
});
});
});

View File

@@ -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";

View File

@@ -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;
};

View File

@@ -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 0100 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;
};

View File

@@ -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,

View File

@@ -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;
};

View File

@@ -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 || [
{

View File

@@ -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,
};
}

View File

@@ -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,
});
};