diff --git a/server/src/external/stripe/priceToStripeItem/priceToUsageInAdvance.ts b/server/src/external/stripe/priceToStripeItem/priceToUsageInAdvance.ts index 94a672a3f..e2cef6792 100644 --- a/server/src/external/stripe/priceToStripeItem/priceToUsageInAdvance.ts +++ b/server/src/external/stripe/priceToStripeItem/priceToUsageInAdvance.ts @@ -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}`, diff --git a/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.ts b/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.ts index 19ecc110e..33db47330 100644 --- a/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.ts +++ b/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.ts @@ -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, diff --git a/server/src/internal/customers/cusProducts/cusPrices/cusPriceUtils.ts b/server/src/internal/customers/cusProducts/cusPrices/cusPriceUtils.ts index 6a56050b5..b07fbcf58 100644 --- a/server/src/internal/customers/cusProducts/cusPrices/cusPriceUtils.ts +++ b/server/src/internal/customers/cusProducts/cusPrices/cusPriceUtils.ts @@ -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, diff --git a/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts b/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts index 99e87a972..94675d26a 100644 --- a/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts +++ b/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts @@ -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; diff --git a/server/src/internal/products/prices/priceUtils.ts b/server/src/internal/products/prices/priceUtils.ts index 6e8187da6..6fda98556 100644 --- a/server/src/internal/products/prices/priceUtils.ts +++ b/server/src/internal/products/prices/priceUtils.ts @@ -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}`; }; diff --git a/server/tests/utils/advancedUsageUtils.ts b/server/tests/utils/advancedUsageUtils.ts index 6cb13757f..40ee22674 100644 --- a/server/tests/utils/advancedUsageUtils.ts +++ b/server/tests/utils/advancedUsageUtils.ts @@ -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) { diff --git a/shared/utils/billingUtils/index.ts b/shared/utils/billingUtils/index.ts index 4abb8eafb..ba1244c0e 100644 --- a/shared/utils/billingUtils/index.ts +++ b/shared/utils/billingUtils/index.ts @@ -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"; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/calculateGraduatedTiersAmount.ts b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/calculateGraduatedTiersAmount.ts new file mode 100644 index 000000000..fba382c1d --- /dev/null +++ b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/calculateGraduatedTiersAmount.ts @@ -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; +}; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts index 6de45f2f2..dc5edf61d 100644 --- a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts +++ b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts @@ -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, + }); }; diff --git a/shared/utils/productUtils/priceToInvoiceAmount.ts b/shared/utils/productUtils/priceToInvoiceAmount.ts index 025d8117f..3f00dd1a5 100644 --- a/shared/utils/productUtils/priceToInvoiceAmount.ts +++ b/shared/utils/productUtils/priceToInvoiceAmount.ts @@ -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 = ({ diff --git a/shared/utils/productUtils/priceUtils/getPriceForOverage.ts b/shared/utils/productUtils/priceUtils/getPriceForOverage.ts new file mode 100644 index 000000000..46e8f15b8 --- /dev/null +++ b/shared/utils/productUtils/priceUtils/getPriceForOverage.ts @@ -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, + }); +}; diff --git a/shared/utils/productUtils/priceUtils/index.ts b/shared/utils/productUtils/priceUtils/index.ts index 9930d3f85..cd67d2543 100644 --- a/shared/utils/productUtils/priceUtils/index.ts +++ b/shared/utils/productUtils/priceUtils/index.ts @@ -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: {