feat: 🎸 implement volume tier loop + tests + dispatcher
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 || [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user