feat: 🎸 centralise logic for graduated tiers

This commit is contained in:
amianthus
2026-02-20 18:24:52 +00:00
parent c865b37e35
commit 9af29efa5b
12 changed files with 125 additions and 128 deletions

View File

@@ -28,7 +28,7 @@ export const priceToOneOffAndTiered = ({
const quantity = options?.quantity ?? 0;
const overage = new Decimal(quantity).mul(config.billing_units!).toNumber();
const amount = getPriceForOverage(price, overage);
const amount = getPriceForOverage({ price, overage });
if (!config.stripe_product_id) {
console.log(
`WARNING: One off & tiered in advance price has no stripe product id: ${price.id}, ${relatedEnt.feature.name}`,

View File

@@ -98,7 +98,7 @@ const getContUseNewItems = async ({
}
}
const amount = getPriceForOverage(price, overage);
const amount = getPriceForOverage({ price, overage });
const description = getFeatureInvoiceDescription({
feature: ent.feature,
usage: usage,

View File

@@ -72,7 +72,10 @@ export const getCusPriceUsage = ({
const roundedQuantity =
Math.ceil(new Decimal(usage).div(billingUnits).toNumber()) * billingUnits;
const amount = getPriceForOverage(cusPrice.price, -totalNegativeBalance);
const amount = getPriceForOverage({
price: cusPrice.price,
overage: -totalNegativeBalance,
});
let description = getFeatureInvoiceDescription({
feature: cusEnt.entitlement.feature,

View File

@@ -221,9 +221,9 @@ export const getItemsForNewProduct = async ({
periodEnd: finalProration.end,
periodStart: finalProration.start,
now,
amount: getPriceForOverage(price),
})
: getPriceForOverage(price, 0);
amount: getPriceForOverage({ price }),
})
: getPriceForOverage({ price, overage: 0 });
if (freeTrial) {
amount = 0;

View File

@@ -26,6 +26,8 @@ import { Decimal } from "decimal.js";
import { StatusCodes } from "http-status-codes";
import { compareBillingIntervals } from "./priceUtils/priceIntervalUtils.js";
export { getPriceForOverage } from "@autumn/shared";
export const constructPrice = ({
internalProductId,
entitlementId,
@@ -268,54 +270,6 @@ const getUsageTier = (price: Price, quantity: number) => {
return usageConfig.usage_tiers[0];
};
export const getPriceForOverage = (price: Price, overage?: number) => {
const usageConfig = price.config as UsagePriceConfig;
const billingType = getBillingType(usageConfig);
if (
billingType === BillingType.FixedCycle ||
billingType === BillingType.OneOff
) {
const config = price.config as FixedPriceConfig;
return config.amount;
}
let amount = 0;
const billingUnits = usageConfig.billing_units || 1;
let remainingUsage = new Decimal(
Math.ceil(new Decimal(overage!).div(billingUnits).toNumber()),
)
.mul(billingUnits)
.toNumber();
let lastTo: number = 0;
for (let i = 0; i < usageConfig.usage_tiers.length; i++) {
const tier = usageConfig.usage_tiers[i];
let amountUsed = 0;
if (tier.to === TierInfinite || tier.to === -1) {
amountUsed = remainingUsage;
} else {
amountUsed = Math.min(remainingUsage, tier.to - lastTo);
lastTo = tier.to;
}
// Divide amount by billing units
const amountPerUnit = new Decimal(tier.amount)
.div(usageConfig.billing_units!)
.toNumber();
amount += amountPerUnit * amountUsed;
remainingUsage -= amountUsed;
if (remainingUsage <= 0) {
break;
}
}
return Number(amount.toFixed(10));
};
const priceToEventName = (productName: string, featureName: string) => {
return `${productName} - ${featureName}`;
};

View File

@@ -82,7 +82,7 @@ export const checkUsageInvoiceAmount = async ({
const overage = new Decimal(totalUsage)
.minus(featureEntitlement.allowance)
.toNumber();
const overagePrice = getPriceForOverage(meteredPrice, overage);
const overagePrice = getPriceForOverage({ price: meteredPrice, overage });
let basePrice = 0;
if (includeBase && product.prices.length > 1) {

View File

@@ -10,6 +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/lineItemToCustomerEntitlement.js";
export * from "./invoicingUtils/lineItemUtils/priceToLineAmount.js";
export * from "./invoicingUtils/lineItemUtils/tiersToLineAmount.js";

View File

@@ -0,0 +1,60 @@
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

@@ -1,9 +1,10 @@
import { Decimal } from "decimal.js";
import type { Price } from "../../../../models/productModels/priceModels/priceModels";
import { Infinite } from "../../../../models/productModels/productEnums";
import { nullish } from "../../../utils";
import { roundUsageToNearestBillingUnit } from "../../usageUtils/roundUsageToNearestBillingUnit";
import { calculateGraduatedTiersAmount } from "./calculateGraduatedTiersAmount";
/**
* Overage is ANY usage outside of included usage.
*/
export const tiersToLineAmount = ({
price,
overage,
@@ -13,43 +14,18 @@ export const tiersToLineAmount = ({
overage: number;
billingUnits?: number;
}): number => {
const isNegative = overage < 0;
const absoluteOverage = Math.abs(overage);
const roundedOverage = roundUsageToNearestBillingUnit({
usage: absoluteOverage,
billingUnits,
});
let amount = new Decimal(0);
let remaining = new Decimal(roundedOverage);
let lastTierTo = 0;
const tiers = price.config.usage_tiers;
if (nullish(tiers)) {
throw new Error(
`[tiersToLineAmount] usage_tiers required for usage-based prices`,
"[tiersToLineAmount] usage_tiers required for usage-based prices",
);
}
for (const tier of tiers) {
if (remaining.lte(0)) break;
const isFinalTier = tier.to === Infinite || tier.to === -1;
const tierSize = isFinalTier
? remaining
: Decimal.min(remaining, new Decimal(tier.to).minus(lastTierTo));
const rate = new Decimal(tier.amount).div(billingUnits);
amount = amount.plus(rate.mul(tierSize));
remaining = remaining.minus(tierSize);
if (tier.to !== Infinite && tier.to !== -1) {
lastTierTo = tier.to;
}
}
const finalAmount = amount.toDecimalPlaces(10).toNumber();
return isNegative ? -finalAmount : finalAmount;
return calculateGraduatedTiersAmount({
tiers,
usage: overage,
billingUnits,
allowNegative: true,
});
};

View File

@@ -7,6 +7,7 @@ import {
type ProductItem,
UsageModel,
} from "../../models/productV2Models/productItemModels/productItemModels.js";
import { calculateGraduatedTiersAmount } from "../billingUtils/invoicingUtils/lineItemUtils/calculateGraduatedTiersAmount.js";
import { isPriceItem } from "../productV2Utils/productItemUtils/getItemType.js";
import {
calculateProrationAmount,
@@ -24,46 +25,14 @@ export const getAmountForQuantity = ({
quantity: number;
}) => {
const config = price.config as UsagePriceConfig;
const billingUnits = config.billing_units || 1;
const tiers = config.usage_tiers;
const roundedQuantity = new Decimal(quantity)
.div(billingUnits)
.ceil()
.mul(billingUnits)
.toNumber();
let lastTierTo: number = 0;
let amount = new Decimal(0);
let remainingUsage = new Decimal(roundedQuantity);
// console.log("Getting amount for quantity:", roundedQuantity);
// console.log("Usage tiers:", config.usage_tiers);
for (let i = 0; i < config.usage_tiers.length; i++) {
const tier = config.usage_tiers[i];
let usageWithinTier = new Decimal(0);
if (tier.to === Infinite || tier.to === -1) {
usageWithinTier = remainingUsage;
} else {
const tierUsage = new Decimal(tier.to).minus(lastTierTo);
usageWithinTier = Decimal.min(remainingUsage, tierUsage);
lastTierTo = tier.to;
}
const amountPerUnit = new Decimal(tier.amount).div(billingUnits);
const amountWithinTier = amountPerUnit.mul(usageWithinTier);
amount = amount.plus(amountWithinTier);
remainingUsage = remainingUsage.minus(usageWithinTier);
if (remainingUsage.lte(0)) {
break;
}
}
return amount.toDecimalPlaces(10).toNumber();
return calculateGraduatedTiersAmount({
tiers,
usage: quantity,
billingUnits,
});
};
export const itemToInvoiceAmount = ({

View File

@@ -0,0 +1,33 @@
import type { FixedPriceConfig } from "../../../models/productModels/priceModels/priceConfig/fixedPriceConfig";
import type { UsagePriceConfig } from "../../../models/productModels/priceModels/priceConfig/usagePriceConfig";
import { BillingType } from "../../../models/productModels/priceModels/priceEnums";
import type { Price } from "../../../models/productModels/priceModels/priceModels";
import { calculateGraduatedTiersAmount } from "../../billingUtils/invoicingUtils/lineItemUtils/calculateGraduatedTiersAmount";
import { getBillingType } from "../priceUtils";
export const getPriceForOverage = ({
price,
overage,
}: {
price: Price;
overage?: number;
}) => {
const usageConfig = price.config as UsagePriceConfig;
const billingType = getBillingType(usageConfig);
if (
billingType === BillingType.FixedCycle ||
billingType === BillingType.OneOff
) {
const config = price.config as FixedPriceConfig;
return config.amount;
}
const billingUnits = usageConfig.billing_units || 1;
return calculateGraduatedTiersAmount({
tiers: usageConfig.usage_tiers,
usage: overage!,
billingUnits,
});
};

View File

@@ -8,6 +8,7 @@ export * from "./convertAmountUtils.js";
export * from "./convertPriceUtils.js";
export * from "./findPrice/findPriceByFeatureId.js";
export * from "./formatPriceUtils.js";
export * from "./getPriceForOverage.js";
export const priceUtils = {
convert: {