handled existing usages utilities completed
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { FullCusEntWithFullCusProduct } from "../../../../../../shared/models/cusProductModels/cusEntModels/cusEntWithProduct";
|
||||
|
||||
export const deductFromCusEnts = async ({
|
||||
cusEnts,
|
||||
amountToDeduct,
|
||||
}: {
|
||||
cusEnts: FullCusEntWithFullCusProduct[];
|
||||
amountToDeduct: number;
|
||||
}) => {};
|
||||
@@ -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<string, EntityBalance> | 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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<string, EntityBalance> = { ...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<string, EntityBalance> | 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(),
|
||||
// };
|
||||
// };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, number> = {};
|
||||
|
||||
const result = await deductFromCusEnts(params);
|
||||
const result = await deductFromCusEntsPostgres(params);
|
||||
fullCus = result.fullCus;
|
||||
actualDeductions = result.actualDeductions;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
// ),
|
||||
// );
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<string, EntityBalance> | 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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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<FullCusProduct> => {
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, EntityBalance> | 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: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
28
server/tests/utils/mockUtils/rolloverMocks.ts
Normal file
28
server/tests/utils/mockUtils/rolloverMocks.ts
Normal file
@@ -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<string, EntityRolloverBalance>;
|
||||
}): Rollover => ({
|
||||
id: id ?? `rollover_${crypto.randomUUID().slice(0, 8)}`,
|
||||
cus_ent_id: cusEntId,
|
||||
balance,
|
||||
usage,
|
||||
expires_at: expiresAt,
|
||||
entities,
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
12
shared/utils/cusEntUtils/balanceUtils/cusEntToMinBalance.ts
Normal file
12
shared/utils/cusEntUtils/balanceUtils/cusEntToMinBalance.ts
Normal file
@@ -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;
|
||||
};
|
||||
@@ -21,5 +21,6 @@ export const cusEntToStartingBalance = ({
|
||||
entitlement: cusEnt.entitlement,
|
||||
options,
|
||||
relatedPrice: price,
|
||||
productQuantity: cusEnt.customer_product.quantity,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
};
|
||||
@@ -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 = <T extends FullCustomerEntitlement>(
|
||||
cusEnt: T,
|
||||
): cusEnt is T & { entities: Record<string, EntityBalance> } => {
|
||||
return notNullish(cusEnt.entitlement.entity_feature_id);
|
||||
};
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user