feat: 🎸 centralise logic for graduated tiers
This commit is contained in:
@@ -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}`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
33
shared/utils/productUtils/priceUtils/getPriceForOverage.ts
Normal file
33
shared/utils/productUtils/priceUtils/getPriceForOverage.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user