diff --git a/server/src/internal/balances/handlers/handleUpdateBalance.ts b/server/src/internal/balances/handlers/handleUpdateBalance.ts index 2c0a316a4..82c29afe0 100644 --- a/server/src/internal/balances/handlers/handleUpdateBalance.ts +++ b/server/src/internal/balances/handlers/handleUpdateBalance.ts @@ -90,6 +90,10 @@ export const handleUpdateBalance = createRoute({ }); } + // if (notNullish(body.next_reset_at)) { + // // sortParams.cusEntId must be passed in + // } + await deleteCachedApiCustomer({ orgId: ctx.org.id, env: ctx.env, diff --git a/server/src/internal/balances/track/deductUtils/deductFromCusEnts.ts b/server/src/internal/balances/track/deductUtils/deductFromCusEnts.ts deleted file mode 100644 index 8b334471d..000000000 --- a/server/src/internal/balances/track/deductUtils/deductFromCusEnts.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { FullCusEntWithFullCusProduct } from "../../../../../../shared/models/cusProductModels/cusEntModels/cusEntWithProduct"; - -export const deductFromCusEnts = async ({ - cusEnts, - amountToDeduct, -}: { - cusEnts: FullCusEntWithFullCusProduct[]; - amountToDeduct: number; -}) => {}; diff --git a/server/src/internal/balances/track/deductUtils/deductFromCusEntsTypescript.ts b/server/src/internal/balances/track/deductUtils/deductFromCusEntsTypescript.ts new file mode 100644 index 000000000..db7e75c9f --- /dev/null +++ b/server/src/internal/balances/track/deductUtils/deductFromCusEntsTypescript.ts @@ -0,0 +1,83 @@ +import type { + EntityBalance, + FullCusEntWithFullCusProduct, +} from "@autumn/shared"; +import { deductFromMainBalance } from "./deductFromMainBalance"; + +const applyUpdatesToCusEnt = ({ + cusEnt, + newBalance, + newEntities, + newAdjustment, +}: { + cusEnt: FullCusEntWithFullCusProduct; + newBalance: number; + newEntities: Record | null; + newAdjustment: number; +}): FullCusEntWithFullCusProduct => { + cusEnt.balance = newBalance; + cusEnt.entities = newEntities; + cusEnt.adjustment = newAdjustment; + + return cusEnt; +}; + +export const deductFromCusEntsTypescript = ({ + cusEnts, + amountToDeduct, + targetEntityId, + + // biome-ignore lint/correctness/noUnusedFunctionParameters: Not used yet, but can add in the future + alterGrantedBalance, +}: { + cusEnts: FullCusEntWithFullCusProduct[]; + amountToDeduct: number; + targetEntityId?: string; + alterGrantedBalance?: boolean; +}) => { + // Pass 2: Deduct from main balance to 0 + for (const cusEnt of cusEnts) { + if (amountToDeduct === 0) continue; + + // biome-ignore lint/correctness/noUnusedVariables: Might use deducted in the future + const { deducted, newBalance, newEntities, newAdjustment, remaining } = + deductFromMainBalance({ + cusEnt, + amountToDeduct, + targetEntityId, + minBalance: 0, + }); + + amountToDeduct = remaining; + + // Update cusEnt with new values + applyUpdatesToCusEnt({ + cusEnt, + newBalance, + newEntities, + newAdjustment, + }); + } + + // Pass 3: Deduct from main balance if amountToDeduct is still not 0 + for (const cusEnt of cusEnts) { + if (amountToDeduct === 0) continue; + + const { newBalance, newEntities, newAdjustment, remaining } = + deductFromMainBalance({ + cusEnt, + amountToDeduct, + targetEntityId, + }); + + amountToDeduct = remaining; + + // Update cusEnt with new values + applyUpdatesToCusEnt({ + cusEnt, + newBalance, + newEntities, + newAdjustment, + }); + } +}; diff --git a/server/src/internal/balances/track/deductUtils/deductFromMainBalance.ts b/server/src/internal/balances/track/deductUtils/deductFromMainBalance.ts index 02938be39..bed258f19 100644 --- a/server/src/internal/balances/track/deductUtils/deductFromMainBalance.ts +++ b/server/src/internal/balances/track/deductUtils/deductFromMainBalance.ts @@ -1,14 +1,21 @@ import { + cusEntToMinBalance, + cusEntToUsageAllowed, type EntityBalance, type FullCusEntWithFullCusProduct, isEntityScopedCusEnt, } from "@autumn/shared"; +import { Decimal } from "decimal.js"; +import { cusEntToStartingBalance } from "../../../../../../shared/utils/cusEntUtils/balanceUtils/cusEntToStartingBalance"; +import { calculateDeduction } from "./calculateDeduction"; export type DeductFromMainBalanceParams = { cusEnt: FullCusEntWithFullCusProduct; amountToDeduct: number; targetEntityId?: string; alterGrantedBalance?: boolean; + minBalance?: number; + maxBalance?: number; }; export type DeductFromMainBalanceResult = { @@ -30,75 +37,129 @@ export const deductFromMainBalance = ({ cusEnt, amountToDeduct, targetEntityId, + minBalance, alterGrantedBalance = false, }: DeductFromMainBalanceParams): DeductFromMainBalanceResult => { - const hasEntityScope = isEntityScopedCusEnt({ cusEnt }); + minBalance = minBalance ?? cusEntToMinBalance({ cusEnt }); // If minBalance is not provided, use the min balance from the cusEnt + const usageAllowed = cusEntToUsageAllowed({ cusEnt }); - const currentBalance = cusEnt.balance ?? 0; + const currentTopLevelBalance = cusEnt.balance ?? 0; + const currentTopLevelAdjustment = cusEnt.adjustment ?? 0; const currentEntities = cusEnt.entities ?? null; - const currentAdjustment = cusEnt.adjustment ?? 0; - // CASE 1: Deduct from top level balance - // const { deducted, newBalance, newAdjustment, remaining } = calculateDeduction( - // { - // currentBalance, - // currentAdjustment, - // amountToDeduct, - // allowNegative, - // alterGrantedBalance, - // }, - // ); - // return deductFromTopLevelBalance({ - // currentBalance, - // currentEntities, - // currentAdjustment, - // amountToDeduct, - // creditCost, - // allowNegative, - // alterGrantedBalance, - // }); + const baseMaxBalance = cusEntToStartingBalance({ cusEnt }); + + minBalance = usageAllowed !== true ? 0 : minBalance; + + if (targetEntityId || isEntityScopedCusEnt(cusEnt)) { + // CASE 1: ENTITY SCOPED, SINGLE ENTITY + if (targetEntityId) { + const entity = currentEntities?.[targetEntityId]; + if (entity) { + const entityBalance = entity.balance ?? 0; + const entityAdjustment = entity.adjustment ?? 0; + const maxBalance = new Decimal(baseMaxBalance) + .add(entityAdjustment) + .toNumber(); + + const { deducted, newBalance, newAdjustment, remaining } = + calculateDeduction({ + currentBalance: entityBalance, + currentAdjustment: entityAdjustment, + amountToDeduct, + alterGrantedBalance, + minBalance, + maxBalance, + }); + + const newEntities = { + ...currentEntities, + [targetEntityId]: { + id: targetEntityId, + balance: newBalance, + adjustment: newAdjustment, + additional_balance: 0, + }, + }; + + return { + deducted, + newBalance, + newEntities, + newAdjustment, + remaining, + }; + } + + return { + deducted: 0, + newBalance: currentTopLevelBalance, + newEntities: currentEntities, + newAdjustment: currentTopLevelAdjustment, + remaining: amountToDeduct, + }; + } else { + // CASE 2: ENTITY SCOPED, ALL ENTITIES + const newEntities: Record = { ...currentEntities }; + for (const entityId in currentEntities) { + if (amountToDeduct === 0) break; + + const entity = currentEntities[entityId]; + const entityBalance = entity.balance ?? 0; + const entityAdjustment = entity.adjustment ?? 0; + const maxBalance = new Decimal(baseMaxBalance) + .add(entityAdjustment) + .toNumber(); + + const { newBalance, newAdjustment, remaining } = calculateDeduction({ + currentBalance: entityBalance, + currentAdjustment: entityAdjustment, + amountToDeduct, + alterGrantedBalance, + minBalance, + maxBalance, + }); + + amountToDeduct = remaining; + + newEntities[entityId] = { + ...entity, + balance: newBalance, + adjustment: newAdjustment, + }; + } + + return { + deducted: amountToDeduct, + newBalance: currentTopLevelBalance, + newAdjustment: currentTopLevelAdjustment, + newEntities, + remaining: amountToDeduct, + }; + } + } + + // CASE 3: TOP-LEVEL BALANCE + const maxBalance = new Decimal(baseMaxBalance) + .add(currentTopLevelAdjustment) + .toNumber(); + + const { deducted, newBalance, newAdjustment, remaining } = calculateDeduction( + { + currentBalance: currentTopLevelBalance, + currentAdjustment: currentTopLevelAdjustment, + amountToDeduct, + alterGrantedBalance, + minBalance, + maxBalance, + }, + ); + + return { + deducted, + newBalance, + newEntities: currentEntities, + newAdjustment, + remaining, + }; }; - -// // ============================================================================= -// // CASE 3: Deduct from TOP-LEVEL balance -// // ============================================================================= -// const deductFromTopLevelBalance = ({ -// currentBalance, -// currentEntities, -// currentAdjustment, -// amountToDeduct, -// creditCost, -// allowNegative, -// alterGrantedBalance, -// }: { -// currentBalance: number; -// currentEntities: Record | null; -// currentAdjustment: number; -// amountToDeduct: number; -// creditCost: number; -// allowNegative: boolean; -// alterGrantedBalance: boolean; -// }): DeductFromMainBalanceResult => { -// const amountInCredits = new Decimal(amountToDeduct) -// .mul(creditCost) -// .toNumber(); - -// const { deducted, newBalance, newAdjustment } = calculateDeduction({ -// currentBalance, -// currentAdjustment, -// amountToDeduct: amountInCredits, -// allowNegative, -// alterGrantedBalance, -// }); - -// return { -// deducted, -// newBalance, -// newEntities: currentEntities, // Entities unchanged for top-level -// newAdjustment, -// remaining: new Decimal(amountInCredits) -// .sub(deducted) -// .div(creditCost) -// .toNumber(), -// }; -// }; diff --git a/server/src/internal/balances/track/deductUtils/deductTypes.ts b/server/src/internal/balances/track/deductUtils/deductTypes.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/server/src/internal/balances/track/syncUtils/syncItem.ts b/server/src/internal/balances/track/syncUtils/syncItem.ts index 3ec33560e..bd30043f2 100644 --- a/server/src/internal/balances/track/syncUtils/syncItem.ts +++ b/server/src/internal/balances/track/syncUtils/syncItem.ts @@ -21,7 +21,7 @@ import { getCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheU import { handleThresholdReached } from "../../../../trigger/handleThresholdReached.js"; import { getCachedApiEntity } from "../../../entities/entityUtils/apiEntityCacheUtils/getCachedApiEntity.js"; import type { FeatureDeduction } from "../trackUtils/getFeatureDeductions.js"; -import { deductFromCusEnts } from "../trackUtils/runDeductionTx.js"; +import { deductFromCusEntsPostgres } from "../trackUtils/runDeductionTx.js"; export interface SyncItem { customerId: string; @@ -166,7 +166,7 @@ export const syncItem = async ({ // Sync from Redis to Postgres - deduct using target balance - const result = await deductFromCusEnts({ + const result = await deductFromCusEntsPostgres({ ctx, customerId, entityId, diff --git a/server/src/internal/balances/track/trackUtils/runDeductionTx.ts b/server/src/internal/balances/track/trackUtils/runDeductionTx.ts index 0796f73c9..be61ba10b 100644 --- a/server/src/internal/balances/track/trackUtils/runDeductionTx.ts +++ b/server/src/internal/balances/track/trackUtils/runDeductionTx.ts @@ -6,15 +6,12 @@ import type { import { CusProductStatus, cusEntToCusPrice, - cusEntToPrepaidQuantity, + cusEntToMinBalance, cusProductsToCusEnts, FeatureUsageType, type FullCustomer, - getMaxOverage, getRelevantFeatures, - getStartingBalance, InternalError, - isPrepaidCusEnt, notNullish, nullish, orgToInStatuses, @@ -22,8 +19,7 @@ import { } from "@autumn/shared"; import { Decimal } from "decimal.js"; import { sql } from "drizzle-orm"; -import { getResetBalancesUpdate } from "@/internal/customers/cusProducts/cusEnts/groupByUtils.js"; -import { getEntOptions } from "@/internal/products/prices/priceUtils.js"; +import { cusEntToStartingBalance } from "../../../../../../shared/utils/cusEntUtils/balanceUtils/cusEntToStartingBalance.js"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; import { EventService } from "../../../api/events/EventService.js"; import { CusService } from "../../../customers/CusService.js"; @@ -52,7 +48,7 @@ export type DeductionTxParams = { sortParams?: SortCusEntParams; }; -export const deductFromCusEnts = async ({ +export const deductFromCusEntsPostgres = async ({ ctx, customerId, entityId, @@ -137,22 +133,14 @@ export const deductFromCusEnts = async ({ creditSystem: ce.entitlement.feature, }); - const maxOverage = getMaxOverage({ cusEnt: ce }); + const minBalance = cusEntToMinBalance({ cusEnt: ce }); + const maxBalance = cusEntToStartingBalance({ cusEnt: ce }); const cusPrice = cusEntToCusPrice({ cusEnt: ce }); const isFreeAllocated = ce.entitlement.feature.config?.usage_type === FeatureUsageType.Continuous && nullish(cusPrice); - const resetBalance = getStartingBalance({ - entitlement: ce.entitlement, - options: - getEntOptions(ce.customer_product.options, ce.entitlement) || - undefined, - relatedPrice: cusPrice?.price, - productQuantity: ce.customer_product.quantity, - }); - return { customer_entitlement_id: ce.id, credit_cost: creditCost, @@ -160,9 +148,9 @@ export const deductFromCusEnts = async ({ usage_allowed: ce.usage_allowed || (isFreeAllocated && overageBehaviour !== "reject"), - min_balance: notNullish(maxOverage) ? -maxOverage : undefined, add_to_adjustment: addToAdjustment, - max_balance: resetBalance, + min_balance: minBalance, + max_balance: maxBalance, }; }); @@ -324,7 +312,7 @@ export const runDeductionTx = async ( let event: Event | undefined; let actualDeductions: Record = {}; - const result = await deductFromCusEnts(params); + const result = await deductFromCusEntsPostgres(params); fullCus = result.fullCus; actualDeductions = result.actualDeductions; diff --git a/server/src/internal/balances/updateGrantedBalance/updateGrantedBalance.ts b/server/src/internal/balances/updateGrantedBalance/updateGrantedBalance.ts index f6f3a6151..5796e19b7 100644 --- a/server/src/internal/balances/updateGrantedBalance/updateGrantedBalance.ts +++ b/server/src/internal/balances/updateGrantedBalance/updateGrantedBalance.ts @@ -65,7 +65,7 @@ export const updateGrantedBalance = async ({ .toNumber(); const targetCusEnt = cusEnts[0]; - const isEntityScoped = isEntityScopedCusEnt({ cusEnt: targetCusEnt }); + const isEntityScoped = isEntityScopedCusEnt(targetCusEnt); const entityId = fullCus.entity?.id; if (isEntityScoped) { diff --git a/server/src/internal/billing/billingUtils/enrichAttachActions/enrichAttachActions.ts b/server/src/internal/billing/billingUtils/enrichAttachActions/enrichAttachActions.ts index 89137fcd4..835ca7fb0 100644 --- a/server/src/internal/billing/billingUtils/enrichAttachActions/enrichAttachActions.ts +++ b/server/src/internal/billing/billingUtils/enrichAttachActions/enrichAttachActions.ts @@ -10,7 +10,6 @@ import { } from "@autumn/shared"; import { createStripeCli } from "../../../../external/connect/createStripeCli"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; -import { applyExistingUsages } from "../handleExistingUsages/applyExistingUsages"; import { cusProductToExistingUsages } from "../handleExistingUsages/cusProductToExistingUsages"; import { initFullCusProduct } from "../initFullCusProduct/initFullCusProduct"; @@ -40,29 +39,23 @@ export const enrichAttachActions = async ({ excludeOneOff: true, }); - // Initialize new cus product - const newCusProduct = await initFullCusProduct({ - ctx, - fullCus, - insertContext: { - fullCus, - product, - featureQuantities: [], - replaceables: [], - }, - }); - // Get existing usages const existingUsages = cusProductToExistingUsages({ cusProduct: ongoingCusProduct, entityId: fullCus.entity?.id, }); - applyExistingUsages({ - features: ctx.features, - cusProduct: newCusProduct, - existingUsages, - entities: fullCus.entities, + // Initialize new cus product + const newCusProduct = await initFullCusProduct({ + ctx, + fullCus, + initContext: { + fullCus, + product, + featureQuantities: [], + replaceables: [], + existingUsages, + }, }); return actions; diff --git a/server/src/internal/billing/billingUtils/handleExistingUsages/applyExistingUsages.ts b/server/src/internal/billing/billingUtils/handleExistingUsages/applyExistingUsages.ts index cfbb5b9e5..5957afa1d 100644 --- a/server/src/internal/billing/billingUtils/handleExistingUsages/applyExistingUsages.ts +++ b/server/src/internal/billing/billingUtils/handleExistingUsages/applyExistingUsages.ts @@ -2,21 +2,18 @@ import { cusProductsToCusEnts, type Entity, type ExistingUsages, - type Feature, type FullCusProduct, - getRelevantFeatures, } from "@autumn/shared"; +import { deductFromCusEntsTypescript } from "../../../balances/track/deductUtils/deductFromCusEntsTypescript"; import { mergeEntitiesWithExistingUsages } from "./mergeEntitiesWithExistingUsages"; export const applyExistingUsages = ({ - features, cusProduct, - existingUsages, + existingUsages = {}, entities, }: { - features: Feature[]; cusProduct: FullCusProduct; - existingUsages: ExistingUsages; + existingUsages?: ExistingUsages; entities: Entity[]; }) => { console.log( @@ -37,41 +34,51 @@ export const applyExistingUsages = ({ existingUsage, ); - // 1. Get relevant features? - const cusEnts = cusProductsToCusEnts({ cusProducts: [cusProduct], - internalFeatureId: internalFeatureId, + internalFeatureId, }); - const relevatnFeatures = getRelevantFeatures({ - features, - featureId: internalFeatureId, + // 1. Deduct entity usages + for (const [entityId, entityUsage] of Object.entries( + existingUsage.entityUsages, + )) { + deductFromCusEntsTypescript({ + cusEnts, + amountToDeduct: entityUsage, + targetEntityId: entityId, + }); + } + + // 2. Deduct top level usages + deductFromCusEntsTypescript({ + cusEnts, + amountToDeduct: existingUsage.usage, }); - // for (const cusEnt of cusEnts) { - // // 1. If it's entity scoped - // if (isEntityScopedCusEnt({ cusEnt })) { - // continue; - // } + for (const newCusEnt of cusEnts) { + const original = cusProduct.customer_entitlements.find( + (ce) => ce.id === newCusEnt.id, + ); + if (original) { + original.balance = newCusEnt.balance; + original.entities = newCusEnt.entities; + original.adjustment = newCusEnt.adjustment; + } + } - // // 2. If it's not entity scoped - // const startingBalance = cusEntToStartingBalance({ cusEnt }); - // const newBalance = new Decimal(startingBalance) - // .sub(existingUsage.usage) - // .toNumber(); - - // // Update the original cusEnt in the cusProduct (cusProductsToCusEnts returns copies) - // const originalCusEnt = cusProduct.customer_entitlements.find( - // (ce) => ce.id === cusEnt.id, - // ); - // if (originalCusEnt) { - // originalCusEnt.balance = newBalance; - // } - - // console.log( - // `Feature: ${originalCusEnt?.entitlement.feature.id}, Starting balance: ${startingBalance}, New balance: ${newBalance}`, - // ); - // } + // console.log( + // "New cus ents:", + // JSON.stringify( + // cusProduct.customer_entitlements.map((ce) => ({ + // feature_id: ce.feature_id, + // balance: ce.balance, + // entities: ce.entities, + // adjustment: ce.adjustment, + // })), + // null, + // 2, + // ), + // ); } }; diff --git a/server/src/internal/billing/billingUtils/handleExistingUsages/cusProductToExistingUsages.ts b/server/src/internal/billing/billingUtils/handleExistingUsages/cusProductToExistingUsages.ts index 565a81148..836877ec2 100644 --- a/server/src/internal/billing/billingUtils/handleExistingUsages/cusProductToExistingUsages.ts +++ b/server/src/internal/billing/billingUtils/handleExistingUsages/cusProductToExistingUsages.ts @@ -26,15 +26,27 @@ export const cusProductToExistingUsages = ({ > = {}; for (const cusEnt of cusEnts) { - const feature = cusEnt.entitlement.feature; - if (isBooleanCusEnt({ cusEnt })) continue; if (cusEnts.some(isUnlimitedCusEnt)) continue; + const internalFeatureId = cusEnt.entitlement.internal_feature_id; + + if (!existingUsages[internalFeatureId]) { + existingUsages[internalFeatureId] = { + usage: 0, + entityUsages: {}, + }; + } + + const currentExistingUsage = existingUsages[internalFeatureId]; + // 1. If it's entity scoped - if (isEntityScopedCusEnt({ cusEnt })) { + if (isEntityScopedCusEnt(cusEnt)) { // const entityUsages = cusEnt.entities; + for (const [entityId, entityBalance] of Object.entries(cusEnt.entities)) { + currentExistingUsage.entityUsages![entityId] = entityBalance.balance; + } continue; } @@ -49,15 +61,6 @@ export const cusProductToExistingUsages = ({ entityId, }); - const internalFeatureId = cusEnt.entitlement.internal_feature_id; - - if (!existingUsages[internalFeatureId]) { - existingUsages[internalFeatureId] = { - usage: 0, - entityUsages: {}, - }; - } - existingUsages[internalFeatureId].usage = new Decimal( existingUsages[internalFeatureId].usage, ) diff --git a/server/src/internal/billing/billingUtils/initFullCusProduct/initCusEntitlementV2/initCusEntUsageAllowed.ts b/server/src/internal/billing/billingUtils/initFullCusProduct/initCusEntitlementV2/initCusEntUsageAllowed.ts index 3d288c935..487efb77d 100644 --- a/server/src/internal/billing/billingUtils/initFullCusProduct/initCusEntitlementV2/initCusEntUsageAllowed.ts +++ b/server/src/internal/billing/billingUtils/initFullCusProduct/initCusEntitlementV2/initCusEntUsageAllowed.ts @@ -1,20 +1,20 @@ import { type EntitlementWithFeature, entToPrice, - type InsertFullCusProductContext, + type InitFullCusProductContext, isPayPerUsePrice, } from "@autumn/shared"; export const initCusEntUsageAllowed = ({ - insertContext, + initContext, entitlement, }: { - insertContext: InsertFullCusProductContext; + initContext: InitFullCusProductContext; entitlement: EntitlementWithFeature; }) => { const price = entToPrice({ ent: entitlement, - prices: insertContext.product.prices, + prices: initContext.product.prices, }); if (!price) return false; diff --git a/server/src/internal/billing/billingUtils/initFullCusProduct/initCusEntitlementV2/initCusEntitlement.ts b/server/src/internal/billing/billingUtils/initFullCusProduct/initCusEntitlementV2/initCusEntitlement.ts index 4eb45befb..afb7f8f46 100644 --- a/server/src/internal/billing/billingUtils/initFullCusProduct/initCusEntitlementV2/initCusEntitlement.ts +++ b/server/src/internal/billing/billingUtils/initFullCusProduct/initCusEntitlementV2/initCusEntitlement.ts @@ -1,7 +1,7 @@ import { type CustomerEntitlement, type EntitlementWithFeature, - type InsertFullCusProductContext, + type InitFullCusProductContext, isBooleanEntitlement, isUnlimitedEntitlement, } from "@autumn/shared"; @@ -9,23 +9,18 @@ import { generateId } from "@server/utils/genUtils"; import { initCusEntitlementBalance } from "./initCusEntitlementBalance"; import { initCusEntUsageAllowed } from "./initCusEntUsageAllowed"; -// Init cus ent context -export interface InitCusEntContext { - insertContext: InsertFullCusProductContext; -} - // MAIN FUNCTION export const initCusEntitlement = ({ - insertContext, + initContext, entitlement, cusProductId, }: { - insertContext: InsertFullCusProductContext; + initContext: InitFullCusProductContext; entitlement: EntitlementWithFeature; cusProductId: string; }): CustomerEntitlement => { const { balance, entities } = initCusEntitlementBalance({ - insertContext, + initContext, entitlement, }); @@ -35,7 +30,7 @@ export const initCusEntitlement = ({ // Usage allowed: const usageAllowed = initCusEntUsageAllowed({ - insertContext, + initContext, entitlement, }); @@ -55,7 +50,7 @@ export const initCusEntitlement = ({ const nextResetAt = Date.now(); - const { fullCus, product } = insertContext; + const { fullCus, product } = initContext; return { id: generateId("cus_ent"), diff --git a/server/src/internal/billing/billingUtils/initFullCusProduct/initCusEntitlementV2/initCusEntitlementBalance.ts b/server/src/internal/billing/billingUtils/initFullCusProduct/initCusEntitlementV2/initCusEntitlementBalance.ts index 7b7560281..8e5d3d451 100644 --- a/server/src/internal/billing/billingUtils/initFullCusProduct/initCusEntitlementV2/initCusEntitlementBalance.ts +++ b/server/src/internal/billing/billingUtils/initFullCusProduct/initCusEntitlementV2/initCusEntitlementBalance.ts @@ -4,7 +4,7 @@ import { entToOptions, entToPrice, getStartingBalance, - type InsertFullCusProductContext, + type InitFullCusProductContext, isBooleanEntitlement, isUnlimitedEntitlement, } from "@autumn/shared"; @@ -17,10 +17,10 @@ export interface InitCusEntitlementBalanceResult { } export const initCusEntitlementBalance = ({ - insertContext, + initContext, entitlement, }: { - insertContext: InsertFullCusProductContext; + initContext: InitFullCusProductContext; entitlement: EntitlementWithFeature; }): { balance: number; entities: Record | null } => { // 1. If entitlement is boolean or unlimited, return 0 @@ -32,11 +32,11 @@ export const initCusEntitlementBalance = ({ } // 2. Get starting balance - const { fullCus, featureQuantities, replaceables } = insertContext; + const { fullCus, featureQuantities, replaceables } = initContext; const price = entToPrice({ ent: entitlement, - prices: insertContext.product.prices, + prices: initContext.product.prices, }); const options = entToOptions({ diff --git a/server/src/internal/billing/billingUtils/initFullCusProduct/initCusProduct.ts b/server/src/internal/billing/billingUtils/initFullCusProduct/initCusProduct.ts index 852012c7c..eedea55bd 100644 --- a/server/src/internal/billing/billingUtils/initFullCusProduct/initCusProduct.ts +++ b/server/src/internal/billing/billingUtils/initFullCusProduct/initCusProduct.ts @@ -2,46 +2,44 @@ import { CollectionMethod, type CusProduct, CusProductStatus, - type InsertCusProductOptions, - type InsertFullCusProductContext, + type InitFullCusProductContext, + type InitFullCusProductOptions, notNullish, } from "@autumn/shared"; export const initCusProduct = ({ - insertContext, - insertOptions, + initContext, + initOptions, cusProductId, }: { - insertContext: InsertFullCusProductContext; - insertOptions?: InsertCusProductOptions; + initContext: InitFullCusProductContext; + initOptions?: InitFullCusProductOptions; cusProductId: string; }): CusProduct => { - const { fullCus, product, featureQuantities } = insertContext; + const { fullCus, product, featureQuantities } = initContext; + const { + subscriptionId, + subscriptionScheduleId, + collectionMethod, + isCustom, + apiSemver, + } = initOptions ?? {}; const internalEntityId = fullCus.entity?.internal_id; const entityId = fullCus.entity?.id; - const status = insertOptions?.status ?? CusProductStatus.Active; - const startsAt = insertOptions?.startsAt ?? Date.now(); + const status = initOptions?.status ?? CusProductStatus.Active; + const startsAt = initOptions?.startsAt ?? Date.now(); - const canceled = notNullish(insertOptions?.canceledAt); - const canceledAt = insertOptions?.canceledAt; + const canceled = notNullish(initOptions?.canceledAt); + const canceledAt = initOptions?.canceledAt; - const subscriptionIds = insertOptions?.subscriptionId - ? [insertOptions.subscriptionId] + const subscriptionIds = subscriptionId ? [subscriptionId] : undefined; + + const scheduleIds = subscriptionScheduleId + ? [subscriptionScheduleId] : undefined; - const scheduleIds = insertOptions?.subscriptionScheduleId - ? [insertOptions.subscriptionScheduleId] - : undefined; - - const collectionMethod = - insertOptions?.collectionMethod ?? CollectionMethod.ChargeAutomatically; - - const isCustom = insertOptions?.isCustom ?? false; - - const apiSemver = insertOptions?.apiSemver ?? null; - return { id: cusProductId, @@ -71,13 +69,13 @@ export const initCusProduct = ({ subscription_ids: subscriptionIds, scheduled_ids: scheduleIds, - collection_method: collectionMethod, + collection_method: collectionMethod ?? CollectionMethod.ChargeAutomatically, quantity: 1, - is_custom: isCustom, + is_custom: isCustom ?? false, - api_semver: apiSemver, + api_semver: apiSemver ?? null, }; }; diff --git a/server/src/internal/billing/billingUtils/initFullCusProduct/initFullCusProduct.ts b/server/src/internal/billing/billingUtils/initFullCusProduct/initFullCusProduct.ts index c1fb41a32..9207a3189 100644 --- a/server/src/internal/billing/billingUtils/initFullCusProduct/initFullCusProduct.ts +++ b/server/src/internal/billing/billingUtils/initFullCusProduct/initFullCusProduct.ts @@ -6,6 +6,7 @@ import type { } from "@autumn/shared"; import type { AutumnContext } from "@server/honoUtils/HonoEnv"; import { generateId } from "@/utils/genUtils"; +import { applyExistingUsages } from "../handleExistingUsages/applyExistingUsages"; import { initCusEntitlement } from "./initCusEntitlementV2/initCusEntitlement"; import { initCusPrice } from "./initCusPrice"; import { initCusProduct } from "./initCusProduct"; @@ -13,21 +14,21 @@ import { initCusProduct } from "./initCusProduct"; export const initFullCusProduct = async ({ ctx, fullCus, - insertContext, - insertOptions, + initContext, + initOptions, }: { ctx: AutumnContext; fullCus: FullCustomer; - insertContext: InitFullCusProductContext; - insertOptions?: InitFullCusProductOptions; + initContext: InitFullCusProductContext; + initOptions?: InitFullCusProductOptions; }): Promise => { - const { product } = insertContext; + const { product } = initContext; const cusProductId = generateId("cus_prod"); const newFullCusEnts = product.entitlements.map((entitlement) => ({ ...initCusEntitlement({ - insertContext, + initContext, entitlement, cusProductId, }), @@ -45,12 +46,8 @@ export const initFullCusProduct = async ({ price, })); - // TODO: Add existing usage to customer entitlements - - // TODO: Add rollovers to customer entitlements - const newCusProduct = initCusProduct({ - insertContext, + initContext, cusProductId, }); @@ -59,13 +56,25 @@ export const initFullCusProduct = async ({ ); const { entitlements: _ents, prices: _prices, ...rawProduct } = product; - return { + + const newFullCusProduct = { ...newCusProduct, product: rawProduct, customer_entitlements: newFullCusEnts, customer_prices: newCusPrices, }; + // Finally, apply existing usages to new cus product + applyExistingUsages({ + cusProduct: newFullCusProduct, + existingUsages: initContext.existingUsages, + entities: fullCus.entities, + }); + + // TODO: Add rollovers to customer entitlements + + return newFullCusProduct; + // await CusProductService.insert({ // db, // data: newCusProduct, diff --git a/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages2.test.ts b/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages2.test.ts index c9c496174..344297c97 100644 --- a/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages2.test.ts +++ b/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages2.test.ts @@ -171,7 +171,6 @@ describe( // Act applyExistingUsages({ cusProduct, existingUsages, entities }); - // Assert: First cusEnt depleted (balance 0), second cusEnt has 1 remaining // Total usage = 3, distributed: first cusEnt uses 2, second cusEnt uses 1 const updatedCusEnts = cusProduct.customer_entitlements.filter( (ce) => ce.feature_id === "feature_a", @@ -179,8 +178,6 @@ describe( expect(updatedCusEnts[0]?.balance).toBe(0); expect(updatedCusEnts[1]?.balance).toBe(1); }); - - return; }); }, ); diff --git a/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages3.test.ts b/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages3.test.ts new file mode 100644 index 000000000..cffc0114c --- /dev/null +++ b/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages3.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, test } from "bun:test"; +import { EntInterval, type ExistingUsages } from "@autumn/shared"; +import { createMockCusEntitlement } from "@tests/utils/mockUtils/cusEntitlementMocks"; +import { createMockCusProduct } from "@tests/utils/mockUtils/cusProductMocks"; +import chalk from "chalk"; +import { applyExistingUsages } from "@/internal/billing/billingUtils/handleExistingUsages/applyExistingUsages"; + +describe( + chalk.yellowBright("applyExistingUsages (testing deduction order)"), + () => { + describe("interval-based deduction order", () => { + test("monthly cusEnt is deducted before lifetime cusEnt", () => { + const internalFeatureId = "internal_feature_a"; + + // Lifetime cusEnt with balance 5 + const lifetimeCusEnt = createMockCusEntitlement({ + internalFeatureId, + featureId: "feature_a", + featureName: "Feature A", + allowance: 5, + balance: 5, + interval: EntInterval.Lifetime, + }); + + // Monthly cusEnt with balance 5 + const monthlyCusEnt = createMockCusEntitlement({ + internalFeatureId, + featureId: "feature_a", + featureName: "Feature A", + allowance: 5, + balance: 5, + interval: EntInterval.Month, + nextResetAt: Date.now() + 30 * 24 * 60 * 60 * 1000, // 30 days from now + }); + + const cusProduct = createMockCusProduct({ + cusEntitlements: [monthlyCusEnt, lifetimeCusEnt], // Monthly first in array + }); + + // Apply 7 usage (should deplete lifetime first, then take 2 from monthly) + const existingUsages: ExistingUsages = { + [internalFeatureId]: { usage: 7, entityUsages: {} }, + }; + + // Act + applyExistingUsages({ cusProduct, existingUsages, entities: [] }); + + // Assert: Lifetime should be depleted first (0), then monthly should have 3 remaining + const updatedLifetime = cusProduct.customer_entitlements.find( + (ce) => ce.entitlement.interval === EntInterval.Lifetime, + ); + const updatedMonthly = cusProduct.customer_entitlements.find( + (ce) => ce.entitlement.interval === EntInterval.Month, + ); + + expect(updatedMonthly?.balance).toBe(0); + expect(updatedLifetime?.balance).toBe(3); + }); + }); + + describe("usage_allowed-based deduction order", () => { + test("prepaid cusEnt (usage_allowed=false) is deducted before pay-per-use cusEnt (usage_allowed=true)", () => { + const internalFeatureId = "internal_feature_a"; + + // Prepaid cusEnt (usage_allowed = false) with balance 5 + const prepaidCusEnt = createMockCusEntitlement({ + internalFeatureId, + featureId: "feature_a", + featureName: "Feature A", + allowance: 5, + balance: 5, + usageAllowed: false, + }); + + // Pay-per-use cusEnt (usage_allowed = true) with balance 5 + const payPerUseCusEnt = createMockCusEntitlement({ + internalFeatureId, + featureId: "feature_a", + featureName: "Feature A", + allowance: 5, + balance: 5, + usageAllowed: true, + }); + + const cusProduct = createMockCusProduct({ + cusEntitlements: [payPerUseCusEnt, prepaidCusEnt], // Pay-per-use first in array + }); + + // Apply 7 usage (should deplete prepaid first, then take 2 from pay-per-use) + const existingUsages: ExistingUsages = { + [internalFeatureId]: { usage: 7, entityUsages: {} }, + }; + + // Act + applyExistingUsages({ cusProduct, existingUsages, entities: [] }); + + // Assert: Prepaid should be depleted first (0), then pay-per-use should have 3 remaining + const updatedPrepaid = cusProduct.customer_entitlements.find( + (ce) => ce.usage_allowed === false, + ); + const updatedPayPerUse = cusProduct.customer_entitlements.find( + (ce) => ce.usage_allowed === true, + ); + + expect(updatedPrepaid?.balance).toBe(0); + expect(updatedPayPerUse?.balance).toBe(3); + }); + }); + + describe("negative balance deduction", () => { + test("prepaid monthly is deducted to 0, pay-per-use monthly goes negative", () => { + const internalFeatureId = "internal_feature_a"; + + // Prepaid monthly (usage_allowed = false, cannot go negative) + const prepaidMonthly = createMockCusEntitlement({ + internalFeatureId, + featureId: "feature_a", + featureName: "Feature A", + allowance: 5, + balance: 5, + usageAllowed: false, + interval: EntInterval.Month, + nextResetAt: Date.now() + 30 * 24 * 60 * 60 * 1000, + }); + + // Pay-per-use monthly (usage_allowed = true, can go negative) + const payPerUseMonthly = createMockCusEntitlement({ + internalFeatureId, + featureId: "feature_a", + featureName: "Feature A", + allowance: 5, + balance: 5, + usageAllowed: true, + interval: EntInterval.Month, + nextResetAt: Date.now() + 30 * 24 * 60 * 60 * 1000, + }); + + const cusProduct = createMockCusProduct({ + cusEntitlements: [payPerUseMonthly, prepaidMonthly], // Random order + }); + + // Apply 12 usage (5 from prepaid, 7 from pay-per-use -> goes to -2) + const existingUsages: ExistingUsages = { + [internalFeatureId]: { usage: 12, entityUsages: {} }, + }; + + // Act + applyExistingUsages({ cusProduct, existingUsages, entities: [] }); + + const updatedPrepaid = cusProduct.customer_entitlements.find( + (ce) => ce.usage_allowed === false, + ); + const updatedPayPerUse = cusProduct.customer_entitlements.find( + (ce) => ce.usage_allowed === true, + ); + + // Prepaid should be at 0 (cannot go negative) + expect(updatedPrepaid?.balance).toBe(0); + // Pay-per-use should be at -2 (5 - 7 = -2) + expect(updatedPayPerUse?.balance).toBe(-2); + }); + }); + + describe("combined ordering", () => { + test("prepaid monthly -> prepaid lifetime -> pay-per-use monthly", () => { + const internalFeatureId = "internal_feature_a"; + + // 3 cusEnts with different combinations + const prepaidMonthly = createMockCusEntitlement({ + internalFeatureId, + featureId: "feature_a", + featureName: "Feature A", + allowance: 2, + balance: 2, + usageAllowed: false, + interval: EntInterval.Month, + nextResetAt: Date.now() + 30 * 24 * 60 * 60 * 1000, + }); + + const prepaidLifetime = createMockCusEntitlement({ + internalFeatureId, + featureId: "feature_a", + featureName: "Feature A", + allowance: 2, + balance: 2, + usageAllowed: false, + interval: EntInterval.Lifetime, + }); + + const payPerUseMonthly = createMockCusEntitlement({ + internalFeatureId, + featureId: "feature_a", + featureName: "Feature A", + allowance: 2, + balance: 2, + usageAllowed: true, + interval: EntInterval.Month, + nextResetAt: Date.now() + 30 * 24 * 60 * 60 * 1000, + }); + + // Add in random order + const cusProduct = createMockCusProduct({ + cusEntitlements: [payPerUseMonthly, prepaidLifetime, prepaidMonthly], + }); + + // Apply 5 usage (should take 2 from prepaid monthly, 2 from prepaid lifetime, 1 from pay-per-use monthly) + const existingUsages: ExistingUsages = { + [internalFeatureId]: { usage: 5, entityUsages: {} }, + }; + + // Act + applyExistingUsages({ cusProduct, existingUsages, entities: [] }); + + // Find each cusEnt by their unique characteristics + const findCusEnt = (usageAllowed: boolean, interval: EntInterval) => + cusProduct.customer_entitlements.find( + (ce) => + ce.usage_allowed === usageAllowed && + ce.entitlement.interval === interval, + ); + + const updatedPrepaidMonthly = findCusEnt(false, EntInterval.Month); + const updatedPrepaidLifetime = findCusEnt(false, EntInterval.Lifetime); + const updatedPayPerUseMonthly = findCusEnt(true, EntInterval.Month); + + // Expected order: prepaid monthly (0) -> prepaid lifetime (0) -> pay-per-use monthly (1) + expect(updatedPrepaidMonthly?.balance).toBe(0); + expect(updatedPayPerUseMonthly?.balance).toBe(0); + expect(updatedPrepaidLifetime?.balance).toBe(1); + }); + }); + }, +); diff --git a/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages4.test.ts b/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages4.test.ts new file mode 100644 index 000000000..eca99f181 --- /dev/null +++ b/server/tests/unit-tests/billing/existing-usages/apply-existing-usages/apply-existing-usages4.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, test } from "bun:test"; +import type { ExistingUsages } from "@autumn/shared"; +import { createMockCusEntitlement } from "@tests/utils/mockUtils/cusEntitlementMocks"; +import { createMockCusProduct } from "@tests/utils/mockUtils/cusProductMocks"; +import chalk from "chalk"; +import { applyExistingUsages } from "@/internal/billing/billingUtils/handleExistingUsages/applyExistingUsages"; + +describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => { + describe("entity usages deduction on entity-scoped cusEnt", () => { + test("each entity's balance is reduced by its respective usage", () => { + const internalFeatureId = "internal_feature_a"; + + // Entity-scoped cusEnt with 3 entities, each with balance 100 + const entityScopedCusEnt = createMockCusEntitlement({ + internalFeatureId, + featureId: "feature_a", + featureName: "Feature A", + allowance: 100, + balance: 0, // Top-level balance not used for entity-scoped + entityFeatureId: "entity_feature_id", // Makes it entity-scoped + entities: { + entity1: { id: "entity1", balance: 100, adjustment: 0 }, + entity2: { id: "entity2", balance: 100, adjustment: 0 }, + entity3: { id: "entity3", balance: 100, adjustment: 0 }, + }, + }); + + const cusProduct = createMockCusProduct({ + cusEntitlements: [entityScopedCusEnt], + }); + + // Apply entity usages: entity1: 50, entity2: 100, entity3: 25 + const existingUsages: ExistingUsages = { + [internalFeatureId]: { + usage: 0, + entityUsages: { + entity1: 50, + entity2: 100, + entity3: 25, + }, + }, + }; + + // Act + applyExistingUsages({ cusProduct, existingUsages, entities: [] }); + + // Assert + const updatedCusEnt = cusProduct.customer_entitlements[0]; + expect(updatedCusEnt.entities).not.toBeNull(); + expect(updatedCusEnt.entities?.entity1.balance).toBe(50); // 100 - 50 + expect(updatedCusEnt.entities?.entity2.balance).toBe(0); // 100 - 100 + expect(updatedCusEnt.entities?.entity3.balance).toBe(75); // 100 - 25 + }); + }); + + describe("entity usages on non-entity-scoped cusEnt", () => { + test("nothing is deducted when entityUsages are applied to non-entity-scoped cusEnt", () => { + const internalFeatureId = "internal_feature_a"; + + // Non-entity-scoped cusEnt (no entityFeatureId, entities is null) + const nonEntityScopedCusEnt = createMockCusEntitlement({ + internalFeatureId, + featureId: "feature_a", + featureName: "Feature A", + allowance: 100, + balance: 100, + // No entityFeatureId - not entity-scoped + }); + + const cusProduct = createMockCusProduct({ + cusEntitlements: [nonEntityScopedCusEnt], + }); + + // Try to apply entity usages to non-entity-scoped cusEnt + const existingUsages: ExistingUsages = { + [internalFeatureId]: { + usage: 0, + entityUsages: { + entity1: 50, + entity2: 100, + }, + }, + }; + + // Act + applyExistingUsages({ cusProduct, existingUsages, entities: [] }); + + // Assert: Balance should remain unchanged since cusEnt is not entity-scoped + const updatedCusEnt = cusProduct.customer_entitlements[0]; + expect(updatedCusEnt.balance).toBe(100); // Unchanged + expect(updatedCusEnt.entities).toBeNull(); // Still null + }); + }); + + describe("top-level usage on entity-scoped cusEnt", () => { + test("entity balances are deducted as if aggregated", () => { + const internalFeatureId = "internal_feature_a"; + + // Entity-scoped cusEnt with 3 entities + const entityScopedCusEnt = createMockCusEntitlement({ + internalFeatureId, + featureId: "feature_a", + featureName: "Feature A", + allowance: 50, + balance: 0, // Top-level balance not used for entity-scoped + entityFeatureId: "entity_feature_id", // Makes it entity-scoped + entities: { + entity1: { id: "entity1", balance: 50, adjustment: 0 }, + entity2: { id: "entity2", balance: 50, adjustment: 0 }, + entity3: { id: "entity3", balance: 50, adjustment: 0 }, + }, + }); + + const cusProduct = createMockCusProduct({ + cusEntitlements: [entityScopedCusEnt], + }); + + // Apply top-level usage (no targetEntityId) - should aggregate across entities + // Total entity balance = 150, usage = 80 + // Should deduct from entities in order: entity1: 50->0, entity2: 50->20 + const existingUsages: ExistingUsages = { + [internalFeatureId]: { + usage: 80, + entityUsages: {}, + }, + }; + + // Act + applyExistingUsages({ cusProduct, existingUsages, entities: [] }); + + // Assert: Deduction should flow through entities + const updatedCusEnt = cusProduct.customer_entitlements[0]; + expect(updatedCusEnt.entities).not.toBeNull(); + + // The total deducted should be 80, distributed across entities + const totalRemainingBalance = + (updatedCusEnt.entities?.entity1.balance ?? 0) + + (updatedCusEnt.entities?.entity2.balance ?? 0) + + (updatedCusEnt.entities?.entity3.balance ?? 0); + + expect(totalRemainingBalance).toBe(70); // 150 - 80 = 70 + }); + }); + + describe("entity balances can go negative when usage_allowed is true", () => { + test("each entity balance can be deducted below 0", () => { + const internalFeatureId = "internal_feature_a"; + + // Entity-scoped cusEnt with usage_allowed = true + const entityScopedCusEnt = createMockCusEntitlement({ + internalFeatureId, + featureId: "feature_a", + featureName: "Feature A", + allowance: 50, + balance: 0, + usageAllowed: true, // Can go negative + entityFeatureId: "entity_feature_id", + entities: { + entity1: { id: "entity1", balance: 50, adjustment: 0 }, + entity2: { id: "entity2", balance: 30, adjustment: 0 }, + }, + }); + + const cusProduct = createMockCusProduct({ + cusEntitlements: [entityScopedCusEnt], + }); + + // Apply entity usages that exceed balances + const existingUsages: ExistingUsages = { + [internalFeatureId]: { + usage: 0, + entityUsages: { + entity1: 70, // 50 - 70 = -20 + entity2: 50, // 30 - 50 = -20 + }, + }, + }; + + // Act + applyExistingUsages({ cusProduct, existingUsages, entities: [] }); + + // Assert: Entity balances should go negative + const updatedCusEnt = cusProduct.customer_entitlements[0]; + expect(updatedCusEnt.entities).not.toBeNull(); + expect(updatedCusEnt.entities?.entity1.balance).toBe(-20); // 50 - 70 = -20 + expect(updatedCusEnt.entities?.entity2.balance).toBe(-20); // 30 - 50 = -20 + }); + }); +}); diff --git a/server/tests/unit-tests/billing/existing-usages/cus-product-to-existing-usages/cus-product-to-existing-usages.test.ts b/server/tests/unit-tests/billing/existing-usages/cus-product-to-existing-usages/cus-product-to-existing-usages.test.ts new file mode 100644 index 000000000..0e8cb390b --- /dev/null +++ b/server/tests/unit-tests/billing/existing-usages/cus-product-to-existing-usages/cus-product-to-existing-usages.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from "bun:test"; +import { EntInterval } from "@autumn/shared"; +import { createMockCusEntitlement } from "@tests/utils/mockUtils/cusEntitlementMocks"; +import { createMockCusProduct } from "@tests/utils/mockUtils/cusProductMocks"; +import { createMockRollover } from "@tests/utils/mockUtils/rolloverMocks"; +import chalk from "chalk"; +import { cusProductToExistingUsages } from "@/internal/billing/billingUtils/handleExistingUsages/cusProductToExistingUsages"; + +describe(chalk.yellowBright("cusProductToExistingUsages"), () => { + describe("multiple cusEnts (lifetime and monthly)", () => { + test("aggregates usage across lifetime and monthly cusEnts", () => { + const internalFeatureId = "internal_feature_a"; + + // Lifetime cusEnt: allowance 100, balance 80 -> usage = 20 + const lifetimeCusEnt = createMockCusEntitlement({ + internalFeatureId, + featureId: "feature_a", + featureName: "Feature A", + allowance: 100, + balance: 80, + interval: EntInterval.Lifetime, + }); + + // Monthly cusEnt: allowance 50, balance 30 -> usage = 20 + const monthlyCusEnt = createMockCusEntitlement({ + internalFeatureId, + featureId: "feature_a", + featureName: "Feature A", + allowance: 50, + balance: 30, + interval: EntInterval.Month, + nextResetAt: Date.now() + 30 * 24 * 60 * 60 * 1000, + }); + + const cusProduct = createMockCusProduct({ + cusEntitlements: [lifetimeCusEnt, monthlyCusEnt], + }); + + // Act + const existingUsages = cusProductToExistingUsages({ cusProduct }); + + // Assert: Total usage should be 40 (20 + 20) + expect(existingUsages[internalFeatureId]).toBeDefined(); + expect(existingUsages[internalFeatureId].usage).toBe(40); + expect(existingUsages[internalFeatureId].entityUsages).toEqual({}); + }); + }); + + describe("top-level and entity-scoped cusEnts for same feature", () => { + test("reflects both top-level usage and entity usages", () => { + const internalFeatureId = "internal_feature_a"; + + // Top-level cusEnt: allowance 100, balance 70 -> usage = 30 + const topLevelCusEnt = createMockCusEntitlement({ + internalFeatureId, + featureId: "feature_a", + featureName: "Feature A", + allowance: 100, + balance: 70, + }); + + // Entity-scoped cusEnt with entities + const entityScopedCusEnt = createMockCusEntitlement({ + internalFeatureId, + featureId: "feature_a", + featureName: "Feature A", + allowance: 50, + balance: 0, // Top-level balance not used for entity-scoped + entityFeatureId: "entity_feature_id", + entities: { + entity1: { id: "entity1", balance: 40, adjustment: 0 }, + entity2: { id: "entity2", balance: 25, adjustment: 0 }, + }, + }); + + const cusProduct = createMockCusProduct({ + cusEntitlements: [topLevelCusEnt, entityScopedCusEnt], + }); + + // Act + const existingUsages = cusProductToExistingUsages({ cusProduct }); + + // Assert + expect(existingUsages[internalFeatureId]).toBeDefined(); + // Top-level usage: 30 + expect(existingUsages[internalFeatureId].usage).toBe(30); + // Entity usages reflect current balances (not usage) + expect(existingUsages[internalFeatureId].entityUsages).toEqual({ + entity1: 40, + entity2: 25, + }); + }); + }); + + describe("cusEnt with rollovers", () => { + test("usage calculation excludes rollover balance", () => { + const internalFeatureId = "internal_feature_a"; + + // CusEnt: allowance 100, balance 120 (includes 50 from rollover) + // Expected usage = allowance - (balance - rollover) = 100 - (120 - 50) = 100 - 70 = 30 + // But since we calculate: usage = grantedBalance - currentBalance + // And grantedBalance doesn't include rollovers by default + // usage = 100 - 120 = -20 (which would be wrong if we just did it this way) + // + // Actually, the current balance is 120 (which includes rollover usage), + // so if allowance is 100 and balance is 120, it means user got +20 from somewhere + // In this test, we're checking that the rollover doesn't inflate the "starting" balance + // + // Let's set up: allowance 100, current balance 80, rollover balance 30 (unused) + // The usage should be: 100 - 80 = 20 (rollover's 30 is NOT counted in starting balance) + const cusEntWithRollover = createMockCusEntitlement({ + internalFeatureId, + featureId: "feature_a", + featureName: "Feature A", + allowance: 100, + balance: 80, // Current balance + }); + + // Add rollover to the cusEnt + cusEntWithRollover.rollovers = [ + createMockRollover({ + cusEntId: cusEntWithRollover.id, + balance: 30, // Rollover balance (should not be counted in usage calculation) + expiresAt: Date.now() + 60 * 24 * 60 * 60 * 1000, // 60 days + }), + ]; + + const cusProduct = createMockCusProduct({ + cusEntitlements: [cusEntWithRollover], + }); + + // Act + const existingUsages = cusProductToExistingUsages({ cusProduct }); + + // Assert: Usage should be 20 (allowance 100 - balance 80) + // NOT 50 (allowance 100 + rollover 30 - balance 80) + expect(existingUsages[internalFeatureId]).toBeDefined(); + expect(existingUsages[internalFeatureId].usage).toBe(20); + }); + }); +}); diff --git a/server/tests/utils/mockUtils/cusEntitlementMocks.ts b/server/tests/utils/mockUtils/cusEntitlementMocks.ts index 6a3fc34e2..1aa360a85 100644 --- a/server/tests/utils/mockUtils/cusEntitlementMocks.ts +++ b/server/tests/utils/mockUtils/cusEntitlementMocks.ts @@ -1,22 +1,41 @@ -import { FeatureType, type FullCustomerEntitlement } from "@autumn/shared"; +import { + type EntInterval, + type EntityBalance, + FeatureType, + type FullCustomerEntitlement, +} from "@autumn/shared"; import { createMockEntitlement } from "./entitlementMocks"; export const createMockCusEntitlement = ({ + id, featureId, internalFeatureId, featureName, allowance, balance, featureType = FeatureType.Metered, + interval = null, + intervalCount = 1, + usageAllowed = true, + nextResetAt = null, + entities = null, + entityFeatureId = null, }: { + id?: string; featureId: string; internalFeatureId?: string; featureName: string; allowance: number; balance: number; featureType?: FeatureType; + interval?: EntInterval | null; + intervalCount?: number; + usageAllowed?: boolean; + nextResetAt?: number | null; + entities?: Record | null; + entityFeatureId?: string | null; }): FullCustomerEntitlement => ({ - id: `cus_ent_${featureId}`, + id: id ?? `cus_ent_${featureId}_${crypto.randomUUID().slice(0, 8)}`, internal_customer_id: "cus_internal", internal_feature_id: internalFeatureId ?? `internal_${featureId}`, customer_id: "cus_test", @@ -27,16 +46,19 @@ export const createMockCusEntitlement = ({ unlimited: false, balance, additional_balance: 0, - usage_allowed: true, - next_reset_at: null, + usage_allowed: usageAllowed, + next_reset_at: nextResetAt, adjustment: 0, - entities: null, + entities, entitlement: createMockEntitlement({ featureId, internalFeatureId, featureName, allowance, featureType, + interval, + intervalCount, + entityFeatureId, }), replaceables: [], rollovers: [], diff --git a/server/tests/utils/mockUtils/entitlementMocks.ts b/server/tests/utils/mockUtils/entitlementMocks.ts index e377f8712..ea293dde9 100644 --- a/server/tests/utils/mockUtils/entitlementMocks.ts +++ b/server/tests/utils/mockUtils/entitlementMocks.ts @@ -1,4 +1,4 @@ -import { AllowanceType, FeatureType } from "@autumn/shared"; +import { AllowanceType, type EntInterval, FeatureType } from "@autumn/shared"; import { createMockFeature } from "./featureMocks"; export const createMockEntitlement = ({ @@ -7,24 +7,30 @@ export const createMockEntitlement = ({ featureName, allowance, featureType = FeatureType.Metered, + interval = null, + intervalCount = 1, + entityFeatureId = null, }: { featureId: string; internalFeatureId?: string; featureName: string; allowance: number; featureType?: FeatureType; + interval?: EntInterval | null; + intervalCount?: number; + entityFeatureId?: string | null; }) => ({ - id: `ent_${featureId}`, + id: `ent_${featureId}_${crypto.randomUUID().slice(0, 8)}`, created_at: Date.now(), internal_feature_id: internalFeatureId ?? `internal_${featureId}`, internal_product_id: "prod_internal", is_custom: false, allowance_type: AllowanceType.Fixed, allowance, - interval: null, - interval_count: 1, + interval, + interval_count: intervalCount, carry_from_previous: false, - entity_feature_id: null, + entity_feature_id: entityFeatureId, feature_id: featureId, usage_limit: null, rollover: null, diff --git a/server/tests/utils/mockUtils/rolloverMocks.ts b/server/tests/utils/mockUtils/rolloverMocks.ts new file mode 100644 index 000000000..a02f90e67 --- /dev/null +++ b/server/tests/utils/mockUtils/rolloverMocks.ts @@ -0,0 +1,28 @@ +import type { + EntityRolloverBalance, + Rollover, +} from "@autumn/shared"; + +export const createMockRollover = ({ + id, + cusEntId, + balance, + usage = 0, + expiresAt = null, + entities = {}, +}: { + id?: string; + cusEntId: string; + balance: number; + usage?: number; + expiresAt?: number | null; + entities?: Record; +}): Rollover => ({ + id: id ?? `rollover_${crypto.randomUUID().slice(0, 8)}`, + cus_ent_id: cusEntId, + balance, + usage, + expires_at: expiresAt, + entities, +}); + diff --git a/shared/models/billingModels/initFullCusProductContext.ts b/shared/models/billingModels/initFullCusProductContext.ts index d3e10382a..ac4c3a0ff 100644 --- a/shared/models/billingModels/initFullCusProductContext.ts +++ b/shared/models/billingModels/initFullCusProductContext.ts @@ -7,12 +7,16 @@ import type { } from "../cusProductModels/cusProductEnums"; import type { FeatureOptions } from "../cusProductModels/cusProductModels"; import type { FullProduct } from "../productModels/productModels"; +import type { ExistingUsages } from "./existingUsages"; export interface InitFullCusProductContext { fullCus: FullCustomer; product: FullProduct; featureQuantities: FeatureOptions[]; replaceables: AttachReplaceable[]; + + // For customer entitlements + existingUsages?: ExistingUsages; } export interface InitFullCusProductOptions { diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntToMinBalance.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntToMinBalance.ts new file mode 100644 index 000000000..975382999 --- /dev/null +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntToMinBalance.ts @@ -0,0 +1,12 @@ +import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; +import { notNullish } from "../../utils"; +import { getMaxOverage } from "../balanceUtils"; + +export const cusEntToMinBalance = ({ + cusEnt, +}: { + cusEnt: FullCusEntWithFullCusProduct; +}) => { + const maxOverage = getMaxOverage({ cusEnt }); + return notNullish(maxOverage) ? -maxOverage : undefined; +}; diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntToStartingBalance.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntToStartingBalance.ts index 81b69b70e..30866ad53 100644 --- a/shared/utils/cusEntUtils/balanceUtils/cusEntToStartingBalance.ts +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntToStartingBalance.ts @@ -21,5 +21,6 @@ export const cusEntToStartingBalance = ({ entitlement: cusEnt.entitlement, options, relatedPrice: price, + productQuantity: cusEnt.customer_product.quantity, }); }; diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntToUsageAllowed.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntToUsageAllowed.ts new file mode 100644 index 000000000..1c220d1c2 --- /dev/null +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntToUsageAllowed.ts @@ -0,0 +1,18 @@ +import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; +import { FeatureUsageType } from "../../../models/featureModels/featureEnums"; +import { cusEntToCusPrice } from "../../productUtils/convertUtils"; +import { nullish } from "../../utils"; + +export const cusEntToUsageAllowed = ({ + cusEnt, +}: { + cusEnt: FullCusEntWithFullCusProduct; +}) => { + const cusPrice = cusEntToCusPrice({ cusEnt }); + return ( + cusEnt.usage_allowed || + (cusEnt.entitlement.feature.config?.usage_type === + FeatureUsageType.Continuous && + nullish(cusPrice)) + ); +}; diff --git a/shared/utils/cusEntUtils/classifyCusEntUtils.ts b/shared/utils/cusEntUtils/classifyCusEntUtils.ts index 582c7ebf8..0f5f4c217 100644 --- a/shared/utils/cusEntUtils/classifyCusEntUtils.ts +++ b/shared/utils/cusEntUtils/classifyCusEntUtils.ts @@ -1,4 +1,7 @@ -import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels"; +import type { + EntityBalance, + FullCustomerEntitlement, +} from "../../models/cusProductModels/cusEntModels/cusEntModels"; import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct"; import { FeatureType } from "../../models/featureModels/featureEnums"; import { AllowanceType } from "../../models/productModels/entModels/entModels"; @@ -9,11 +12,13 @@ export const isUnlimitedCusEnt = (cusEnt: FullCustomerEntitlement) => { return cusEnt.entitlement.allowance_type === AllowanceType.Unlimited; }; -export const isEntityScopedCusEnt = ({ - cusEnt, -}: { - cusEnt: FullCustomerEntitlement; -}) => { +/** + * Type guard that narrows cusEnt to have non-null entities. + * Use directly with cusEnt (not wrapped in object) for type narrowing to work. + */ +export const isEntityScopedCusEnt = ( + cusEnt: T, +): cusEnt is T & { entities: Record } => { return notNullish(cusEnt.entitlement.entity_feature_id); }; diff --git a/shared/utils/cusEntUtils/overageUtils/cusEntToInvoiceOverage.ts b/shared/utils/cusEntUtils/overageUtils/cusEntToInvoiceOverage.ts index 4abbe8e68..4e4df97ff 100644 --- a/shared/utils/cusEntUtils/overageUtils/cusEntToInvoiceOverage.ts +++ b/shared/utils/cusEntUtils/overageUtils/cusEntToInvoiceOverage.ts @@ -8,7 +8,7 @@ export const cusEntToInvoiceOverage = ({ cusEnt: FullCusEntWithFullCusProduct; }) => { // 1. If entity scoped - if (isEntityScopedCusEnt({ cusEnt })) { + if (isEntityScopedCusEnt(cusEnt)) { let totalOverage = new Decimal(0); for (const [_, entity] of Object.entries(cusEnt.entities || {})) { const overage = Decimal.max(0, new Decimal(-entity.balance)); diff --git a/shared/utils/cusEntUtils/overageUtils/cusEntToInvoiceUsage.ts b/shared/utils/cusEntUtils/overageUtils/cusEntToInvoiceUsage.ts index 946a7b1ce..d94ba7d90 100644 --- a/shared/utils/cusEntUtils/overageUtils/cusEntToInvoiceUsage.ts +++ b/shared/utils/cusEntUtils/overageUtils/cusEntToInvoiceUsage.ts @@ -18,7 +18,7 @@ export const cusEntToInvoiceUsage = ({ } // 1. If entity scoped - if (isEntityScopedCusEnt({ cusEnt })) { + if (isEntityScopedCusEnt(cusEnt)) { let maxUsage = new Decimal(0); for (const [_, entity] of Object.entries(cusEnt.entities || {})) { const usage = new Decimal(startingBalance).sub(entity.balance); diff --git a/shared/utils/index.ts b/shared/utils/index.ts index 88c3fce93..d7ad85cb9 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -11,7 +11,9 @@ export * from "./common/unixUtils.js"; export * from "./cusEntUtils/balanceUtils/cusEntsToBalance.js"; export * from "./cusEntUtils/balanceUtils/cusEntsToPurchasedBalance.js"; export * from "./cusEntUtils/balanceUtils/cusEntsToUsage.js"; +export * from "./cusEntUtils/balanceUtils/cusEntToMinBalance.js"; export * from "./cusEntUtils/balanceUtils/cusEntToPrepaidQuantity.js"; +export * from "./cusEntUtils/balanceUtils/cusEntToUsageAllowed.js"; // Cus ent utils export * from "./cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAdjustment.js"; export * from "./cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAllowance.js";