handled existing usages utilities completed

This commit is contained in:
John Yeo
2025-12-15 15:47:26 +00:00
parent 1000c2cca3
commit 245943be7b
31 changed files with 1033 additions and 243 deletions

View File

@@ -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,

View File

@@ -1,9 +0,0 @@
import type { FullCusEntWithFullCusProduct } from "../../../../../../shared/models/cusProductModels/cusEntModels/cusEntWithProduct";
export const deductFromCusEnts = async ({
cusEnts,
amountToDeduct,
}: {
cusEnts: FullCusEntWithFullCusProduct[];
amountToDeduct: number;
}) => {};

View File

@@ -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,
});
}
};

View File

@@ -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(),
// };
// };

View File

@@ -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,

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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,
// ),
// );
}
};

View File

@@ -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,
)

View File

@@ -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;

View File

@@ -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"),

View File

@@ -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({

View File

@@ -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,
};
};

View File

@@ -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,

View File

@@ -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;
});
},
);

View File

@@ -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);
});
});
},
);

View File

@@ -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
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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: [],

View File

@@ -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,

View 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,
});

View File

@@ -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 {

View 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;
};

View File

@@ -21,5 +21,6 @@ export const cusEntToStartingBalance = ({
entitlement: cusEnt.entitlement,
options,
relatedPrice: price,
productQuantity: cusEnt.customer_product.quantity,
});
};

View File

@@ -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))
);
};

View File

@@ -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);
};

View File

@@ -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));

View File

@@ -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);

View File

@@ -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";