This commit is contained in:
John Yeo
2025-12-15 13:43:55 +00:00
parent 30f3f5dd82
commit 1000c2cca3
94 changed files with 1987 additions and 1568 deletions

View File

@@ -3,13 +3,13 @@ import {
type Feature,
type FreeTrial,
type FullCusProduct,
findCusPriceByFeature,
isCusProductTrialing,
type ProductItem,
priceToInvoiceAmount,
UsageModel,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import { featureToCusPrice } from "@/internal/customers/cusProducts/cusPrices/convertCusPriceUtils.js";
import { getProration } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js";
import { isFeaturePriceItem } from "@/internal/products/product-items/productItemUtils/getItemType.js";
import { itemToPriceOrTiers } from "@/internal/products/product-items/productItemUtils.js";
@@ -84,7 +84,7 @@ export const getOptions = ({
if (currentQuantity && internalFeatureId) {
currentQuantity = currentQuantity * (i.billing_units || 1);
const curPrice = featureToCusPrice({
const curPrice = findCusPriceByFeature({
internalFeatureId: internalFeatureId,
cusPrices: cusProduct?.customer_prices ?? [],
})?.price;

View File

@@ -0,0 +1,49 @@
import { Decimal } from "decimal.js";
export type CalculateDeductionParams = {
currentBalance: number;
currentAdjustment: number;
amountToDeduct: number;
minBalance?: number; // undefined = no floor
maxBalance?: number; // undefined = no ceiling
alterGrantedBalance?: boolean;
};
export type CalculateDeductionResult = {
deducted: number;
newBalance: number;
newAdjustment: number;
remaining: number;
};
export const calculateDeduction = ({
currentBalance,
currentAdjustment,
amountToDeduct,
minBalance,
maxBalance,
alterGrantedBalance = false,
}: CalculateDeductionParams): CalculateDeductionResult => {
let newBalance = new Decimal(currentBalance).sub(amountToDeduct).toNumber();
// Apply floor (minBalance)
if (minBalance !== undefined && newBalance < minBalance) {
newBalance = minBalance;
}
// Apply ceiling (maxBalance) - for when adding credits
if (maxBalance !== undefined && newBalance > maxBalance) {
newBalance = maxBalance;
}
const deducted = new Decimal(currentBalance).sub(newBalance).toNumber();
const remaining = new Decimal(amountToDeduct).sub(deducted).toNumber();
// Update adjustment if alterGrantedBalance is true
let newAdjustment = currentAdjustment;
if (alterGrantedBalance && deducted !== 0) {
newAdjustment = new Decimal(currentAdjustment).sub(deducted).toNumber();
}
return { deducted, newBalance, newAdjustment, remaining };
};

View File

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

View File

@@ -0,0 +1,104 @@
import {
type EntityBalance,
type FullCusEntWithFullCusProduct,
isEntityScopedCusEnt,
} from "@autumn/shared";
export type DeductFromMainBalanceParams = {
cusEnt: FullCusEntWithFullCusProduct;
amountToDeduct: number;
targetEntityId?: string;
alterGrantedBalance?: boolean;
};
export type DeductFromMainBalanceResult = {
deducted: number;
newBalance: number;
newEntities: Record<string, EntityBalance> | null;
newAdjustment: number;
remaining: number;
};
/**
* Deducts from the main balance of a customer entitlement.
* Handles three cases:
* 1. Entity-scoped, all entities - loops through all entities
* 2. Entity-scoped, single entity - deducts from specific entity
* 3. Top-level balance - deducts from cusEnt.balance
*/
export const deductFromMainBalance = ({
cusEnt,
amountToDeduct,
targetEntityId,
alterGrantedBalance = false,
}: DeductFromMainBalanceParams): DeductFromMainBalanceResult => {
const hasEntityScope = isEntityScopedCusEnt({ cusEnt });
const currentBalance = cusEnt.balance ?? 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,
// });
};
// // =============================================================================
// // 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

@@ -0,0 +1,63 @@
// // =============================================================================
// // CASE 1: Deduct from ALL entities
// // =============================================================================
// const deductFromAllEntities = ({
// 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 => {
// let remaining = new Decimal(amountToDeduct).mul(creditCost).toNumber();
// let totalDeducted = 0;
// const newEntities: Record<string, EntityBalance> = structuredClone(
// currentEntities ?? {},
// );
// // Sort entity keys for consistency (matches SQL ORDER BY)
// const sortedEntityKeys = Object.keys(newEntities).sort();
// for (const entityKey of sortedEntityKeys) {
// if (remaining === 0) break;
// const entityBalance = newEntities[entityKey]?.balance ?? 0;
// const entityAdjustment = newEntities[entityKey]?.adjustment ?? 0;
// const { deducted, newBalance, newAdjustment } = calculateDeduction({
// currentBalance: entityBalance,
// currentAdjustment: entityAdjustment,
// amountToDeduct: remaining,
// allowNegative,
// alterGrantedBalance,
// });
// if (deducted !== 0) {
// newEntities[entityKey] = {
// ...newEntities[entityKey],
// balance: newBalance,
// adjustment: newAdjustment,
// };
// remaining = new Decimal(remaining).sub(deducted).toNumber();
// totalDeducted = new Decimal(totalDeducted).add(deducted).toNumber();
// }
// }
// return {
// deducted: totalDeducted,
// newBalance: currentBalance, // Top-level balance unchanged for entity-scoped
// newEntities,
// newAdjustment: currentAdjustment, // Top-level adjustment unchanged
// remaining: new Decimal(remaining).div(creditCost).toNumber(),
// };
// };

View File

@@ -4,7 +4,7 @@ import type {
NewProductAction,
} from "@autumn/shared";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
import { insertFullCusProduct } from "../insertFullCusProduct/insertFullCusProduct";
import { initFullCusProduct } from "../initFullCusProduct/initFullCusProduct";
export const applyNewProductAction = async ({
ctx,
@@ -23,6 +23,6 @@ export const applyNewProductAction = async ({
};
if (newProductAction.timing === "scheduled") {
return await insertFullCusProduct({ ctx, fullCus, insertContext });
return await initFullCusProduct({ ctx, fullCus, insertContext });
}
};

View File

@@ -1,14 +1,18 @@
import {
type AttachContext,
type CusProductActions,
cusProductToArrearLineItems,
cusProductToLineItems,
type FullCustomer,
formatMs,
getCycleStart,
getLargestInterval,
secondsToMs,
} 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";
export const enrichAttachActions = async ({
ctx,
@@ -27,12 +31,8 @@ export const enrichAttachActions = async ({
const { sub, testClockFrozenTime } = attachContext;
const billingCycleAnchor = secondsToMs(sub?.billing_cycle_anchor);
const now = testClockFrozenTime ?? Date.now();
const product = attachContext.products[0];
console.log(
`Sub: ${sub?.id}, Billing cycle anchor: ${formatMs(billingCycleAnchor)}, Now: ${formatMs(now)}`,
);
const ongoingCusProduct = ongoingCusProductAction?.cusProduct!;
// Get latest cycle end for each product
const largestInterval = getLargestInterval({
@@ -40,27 +40,82 @@ 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,
});
return actions;
// 1. Get the starts at if new product is scheduled
// 2. Get reset cycle anchor
// 3. Get usage to apply to new product
// 4. Get trial ends at (either from current subscription that we're merging with, or from new product)*
// 5. Calculate line items for new product / upgrade* [let's do this]
// 6. Get existing usages
// 1. Calculate line items for usages
// const newCusProduct = newProductAction?.product;
console.log(`ongoing cus product:, ${ongoingCusProduct?.product.name}`);
console.log(`new cus product:, ${newCusProduct.product.name}`);
console.log(`billing cycle anchor: ${formatMs(billingCycleAnchor)}`);
console.log(`test clock frozen time: ${formatMs(testClockFrozenTime)}`);
// Get line items for ongoing cus product
const ongoingLineItems = cusProductToLineItems({
cusProduct: ongoingCusProduct!,
testClockFrozenTime,
billingCycleAnchor: billingCycleAnchor!,
direction: "refund",
});
const arrearLineItems = cusProductToArrearLineItems({
cusProduct: ongoingCusProduct!,
billingCycleAnchor: billingCycleAnchor!,
testClockFrozenTime,
});
const newLineItems = cusProductToLineItems({
cusProduct: newCusProduct,
testClockFrozenTime,
billingCycleAnchor: billingCycleAnchor!,
direction: "charge",
});
// From billing cycle anchor, now, and interval, calculate latest cycle start:
if (largestInterval && billingCycleAnchor) {
const cycleStart = getCycleStart({
anchor: billingCycleAnchor,
interval: largestInterval.interval,
intervalCount: largestInterval.intervalCount,
now,
});
// if (largestInterval && billingCycleAnchor) {
// const cycleStart = getCycleStart({
// anchor: billingCycleAnchor,
// interval: largestInterval.interval,
// intervalCount: largestInterval.intervalCount,
// testClockFrozenTime,
// });
console.log(`Now: ${formatMs(now)}`);
console.log(`Billing cycle anchor: ${formatMs(billingCycleAnchor)}`);
console.log(`Cycle start: ${formatMs(cycleStart)}`);
}
// console.log(`Now: ${formatMs(testClockFrozenTime)}`);
// console.log(`Billing cycle anchor: ${formatMs(billingCycleAnchor)}`);
// console.log(`Cycle start: ${formatMs(cycleStart)}`);
// }
return actions;
};

View File

@@ -0,0 +1,77 @@
import {
cusProductsToCusEnts,
type Entity,
type ExistingUsages,
type Feature,
type FullCusProduct,
getRelevantFeatures,
} from "@autumn/shared";
import { mergeEntitiesWithExistingUsages } from "./mergeEntitiesWithExistingUsages";
export const applyExistingUsages = ({
features,
cusProduct,
existingUsages,
entities,
}: {
features: Feature[];
cusProduct: FullCusProduct;
existingUsages: ExistingUsages;
entities: Entity[];
}) => {
console.log(
`applying existing usages to new cus product: ${cusProduct.product.name}`,
);
// 1. Merge entities with existing usages
const mergedExistingUsages = mergeEntitiesWithExistingUsages({
entities,
existingUsages,
});
for (const [internalFeatureId, existingUsage] of Object.entries(
mergedExistingUsages,
)) {
console.log(
`Applying existing usage for feature: ${internalFeatureId}, usage: `,
existingUsage,
);
// 1. Get relevant features?
const cusEnts = cusProductsToCusEnts({
cusProducts: [cusProduct],
internalFeatureId: internalFeatureId,
});
const relevatnFeatures = getRelevantFeatures({
features,
featureId: internalFeatureId,
});
// for (const cusEnt of cusEnts) {
// // 1. If it's entity scoped
// if (isEntityScopedCusEnt({ cusEnt })) {
// continue;
// }
// // 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}`,
// );
// }
}
};

View File

@@ -0,0 +1,69 @@
import { addCusProductToCusEnt, type ExistingUsages } from "@autumn/shared";
import { Decimal } from "decimal.js";
import type { FullCusProduct } from "../../../../../../shared/models/cusProductModels/cusProductModels";
import { cusEntsToUsage } from "../../../../../../shared/utils/cusEntUtils/balanceUtils/cusEntsToUsage";
import {
isBooleanCusEnt,
isEntityScopedCusEnt,
isUnlimitedCusEnt,
} from "../../../../../../shared/utils/cusEntUtils/classifyCusEntUtils";
export const cusProductToExistingUsages = ({
cusProduct,
entityId,
}: {
cusProduct: FullCusProduct;
entityId?: string;
}): ExistingUsages => {
const cusEnts = cusProduct.customer_entitlements;
const existingUsages: Record<
string,
{
usage: number;
entityUsages: Record<string, number>;
}
> = {};
for (const cusEnt of cusEnts) {
const feature = cusEnt.entitlement.feature;
if (isBooleanCusEnt({ cusEnt })) continue;
if (cusEnts.some(isUnlimitedCusEnt)) continue;
// 1. If it's entity scoped
if (isEntityScopedCusEnt({ cusEnt })) {
// const entityUsages = cusEnt.entities;
continue;
}
const cusEntWithCusProduct = addCusProductToCusEnt({
cusEnt,
cusProduct,
});
// 2. If it's not entity scoped
const usage = cusEntsToUsage({
cusEnts: [cusEntWithCusProduct],
entityId,
});
const internalFeatureId = cusEnt.entitlement.internal_feature_id;
if (!existingUsages[internalFeatureId]) {
existingUsages[internalFeatureId] = {
usage: 0,
entityUsages: {},
};
}
existingUsages[internalFeatureId].usage = new Decimal(
existingUsages[internalFeatureId].usage,
)
.add(usage)
.toNumber();
}
return existingUsages;
};

View File

@@ -0,0 +1,27 @@
import type { ExistingUsages } from "../../../../../../shared/models/billingModels/existingUsages";
import type { Entity } from "../../../../../../shared/models/cusModels/entityModels/entityModels";
export const mergeEntitiesWithExistingUsages = ({
entities,
existingUsages,
}: {
entities: Entity[];
existingUsages: ExistingUsages;
}): ExistingUsages => {
const internalFeatureIdToUsage = new Map<string, number>();
for (const entity of entities) {
internalFeatureIdToUsage.set(
entity.internal_feature_id,
(internalFeatureIdToUsage.get(entity.internal_feature_id) || 0) + 1,
);
}
for (const [internalFeatureId, usage] of internalFeatureIdToUsage.entries()) {
existingUsages[internalFeatureId] = {
usage,
entityUsages: {},
};
}
return existingUsages;
};

View File

@@ -4,7 +4,6 @@ import {
type InsertFullCusProductContext,
isPayPerUsePrice,
} from "@autumn/shared";
import { isArrearPrice } from "../../../../products/prices/priceUtils/usagePriceUtils/classifyUsagePrice";
export const initCusEntUsageAllowed = ({
insertContext,
@@ -20,5 +19,5 @@ export const initCusEntUsageAllowed = ({
if (!price) return false;
return isArrearPrice({ price }) || isPayPerUsePrice({ price });
return isPayPerUsePrice({ price });
};

View File

@@ -0,0 +1,84 @@
import type {
FullCusProduct,
FullCustomer,
InitFullCusProductContext,
InitFullCusProductOptions,
} from "@autumn/shared";
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
import { generateId } from "@/utils/genUtils";
import { initCusEntitlement } from "./initCusEntitlementV2/initCusEntitlement";
import { initCusPrice } from "./initCusPrice";
import { initCusProduct } from "./initCusProduct";
export const initFullCusProduct = async ({
ctx,
fullCus,
insertContext,
insertOptions,
}: {
ctx: AutumnContext;
fullCus: FullCustomer;
insertContext: InitFullCusProductContext;
insertOptions?: InitFullCusProductOptions;
}): Promise<FullCusProduct> => {
const { product } = insertContext;
const cusProductId = generateId("cus_prod");
const newFullCusEnts = product.entitlements.map((entitlement) => ({
...initCusEntitlement({
insertContext,
entitlement,
cusProductId,
}),
entitlement,
replaceables: [],
rollovers: [],
}));
const newCusPrices = product.prices.map((price) => ({
...initCusPrice({
fullCus,
price,
cusProductId,
}),
price,
}));
// TODO: Add existing usage to customer entitlements
// TODO: Add rollovers to customer entitlements
const newCusProduct = initCusProduct({
insertContext,
cusProductId,
});
ctx.logger.info(
`[insertFullCusProduct] inserting new cus product ${product.id}`,
);
const { entitlements: _ents, prices: _prices, ...rawProduct } = product;
return {
...newCusProduct,
product: rawProduct,
customer_entitlements: newFullCusEnts,
customer_prices: newCusPrices,
};
// await CusProductService.insert({
// db,
// data: newCusProduct,
// });
// await Promise.all([
// CusEntService.insert({
// db,
// data: newCusEnts,
// }),
// CusPriceService.insert({
// db,
// data: newCusPrices,
// }),
// ]);
};

View File

@@ -1,75 +0,0 @@
import type {
FullCustomer,
InsertCusProductOptions,
InsertFullCusProductContext,
} from "@autumn/shared";
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
import { generateId } from "@/utils/genUtils";
import { CusProductService } from "../../../customers/cusProducts/CusProductService";
import { CusEntService } from "../../../customers/cusProducts/cusEnts/CusEntitlementService";
import { CusPriceService } from "../../../customers/cusProducts/cusPrices/CusPriceService";
import { initCusEntitlement } from "./initCusEntitlementV2/initCusEntitlement";
import { initCusPrice } from "./initCusPrice";
import { initCusProduct } from "./initCusProduct";
export const insertFullCusProduct = async ({
ctx,
fullCus,
insertContext,
insertOptions,
}: {
ctx: AutumnContext;
fullCus: FullCustomer;
insertContext: InsertFullCusProductContext;
insertOptions?: InsertCusProductOptions;
}) => {
const { db } = ctx;
const { product } = insertContext;
const cusProductId = generateId("cus_prod");
const newCusEnts = product.entitlements.map((entitlement) =>
initCusEntitlement({
insertContext,
entitlement,
cusProductId,
}),
);
const newCusPrices = product.prices.map((price) =>
initCusPrice({
fullCus,
price,
cusProductId,
}),
);
// TODO: Add existing usage to customer entitlements
// TODO: Add rollovers to customer entitlements
const newCusProduct = initCusProduct({
insertContext,
cusProductId,
});
ctx.logger.info(
`[insertFullCusProduct] inserting new cus product ${product.id}`,
);
await CusProductService.insert({
db,
data: newCusProduct,
});
await Promise.all([
CusEntService.insert({
db,
data: newCusEnts,
}),
CusPriceService.insert({
db,
data: newCusPrices,
}),
]);
};

View File

@@ -5,6 +5,7 @@ import {
type Feature,
type FeatureOptions,
type FullCusProduct,
findCusPriceByFeature,
getFeatureInvoiceDescription,
OnDecrease,
priceToInvoiceAmount,
@@ -15,7 +16,6 @@ import type { Stripe } from "stripe";
import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { featureToCusPrice } from "@/internal/customers/cusProducts/cusPrices/convertCusPriceUtils.js";
import { getRelatedCusEnt } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js";
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
import { constructStripeInvoiceItem } from "@/internal/invoices/invoiceItemUtils/invoiceItemUtils.js";
@@ -50,7 +50,7 @@ export const handleQuantityDowngrade = async ({
const { db, logger, org, features } = ctx;
const { stripeCli, paymentMethod } = attachParams;
const cusPrice = featureToCusPrice({
const cusPrice = findCusPriceByFeature({
internalFeatureId: newOptions.internal_feature_id!,
cusPrices: cusProduct.customer_prices,
})!;

View File

@@ -3,11 +3,11 @@ import {
ErrCode,
type FeatureOptions,
type FullCusProduct,
findCusPriceByFeature,
} from "@autumn/shared";
import type { Stripe } from "stripe";
import { findStripeItemForPrice } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { featureToCusPrice } from "@/internal/customers/cusProducts/cusPrices/convertCusPriceUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js";
import { handleQuantityDowngrade } from "./handleQuantityDowngrade.js";
@@ -32,7 +32,7 @@ export const handleUpdateFeatureQuantity = async ({
}) => {
const subToUpdate = stripeSubs?.[0];
const cusPrice = featureToCusPrice({
const cusPrice = findCusPriceByFeature({
internalFeatureId: newOptions.internal_feature_id!,
cusPrices: cusProduct.customer_prices,
})!;

View File

@@ -1,6 +1,6 @@
import { isArrearPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { ItemSet } from "@/utils/models/ItemSet.js";
import Stripe from "stripe";
import { isConsumablePrice } from "@autumn/shared";
import type Stripe from "stripe";
import type { ItemSet } from "@/utils/models/ItemSet.js";
export const mergeNewSubItems = ({
itemSet,
@@ -15,7 +15,11 @@ export const mergeNewSubItems = ({
newSubItems = newSubItems.filter((newSi) => {
const existingItem = curSubItems.find((si) => si.price?.id === newSi.price);
if (isArrearPrice({ price: newSi.autumnPrice }) && existingItem) {
if (
newSi.autumnPrice &&
isConsumablePrice(newSi.autumnPrice) &&
existingItem
) {
newArrearSubItems.push(newSi);
return false;
}
@@ -50,10 +54,12 @@ export const mergeNewScheduleItems = ({
curScheduleItems: Stripe.SubscriptionSchedule.Phase.Item[];
}) => {
const originalScheduleItems = structuredClone(curScheduleItems);
let newScheduleItems: any[] = structuredClone(curScheduleItems).map((si) => ({
price: (si.price as Stripe.Price).id,
quantity: si.quantity,
}));
const newScheduleItems: any[] = structuredClone(curScheduleItems).map(
(si) => ({
price: (si.price as Stripe.Price).id,
quantity: si.quantity,
}),
);
for (const newItem of itemSet.subItems) {
const existingIndex = newScheduleItems.findIndex(

View File

@@ -2,6 +2,7 @@ import {
type AttachConfig,
cusProductToPrices,
type FullCusProduct,
isConsumablePrice,
} from "@autumn/shared";
import { differenceInDays } from "date-fns";
import type Stripe from "stripe";
@@ -10,7 +11,6 @@ import {
priceToScheduleItem,
scheduleItemInCusProduct,
} from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
import { isArrearPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { formatPrice } from "@/internal/products/prices/priceUtils.js";
import { formatUnixToDateTime } from "@/utils/genUtils.js";
import type { ItemSet } from "@/utils/models/ItemSet.js";
@@ -67,7 +67,7 @@ export const removeCusProductFromScheduleItems = async ({
if (!existingScheduleItem) continue;
// 1. If arrear price
if (isArrearPrice({ price })) {
if (isConsumablePrice(price)) {
if (
allCusProducts.some((cp) => {
if (cp.id === cusProduct.id) return false;

View File

@@ -4,6 +4,7 @@ import {
cusProductToPrices,
cusProductToProduct,
type FullCusProduct,
isConsumablePrice,
} from "@autumn/shared";
import type Stripe from "stripe";
import { getStripeSubItems2 } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js";
@@ -11,7 +12,6 @@ import {
findStripeItemForPrice,
subItemInCusProduct,
} from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
import { isArrearPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { formatPrice } from "@/internal/products/prices/priceUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import type { ItemSet } from "@/utils/models/ItemSet.js";
@@ -165,7 +165,7 @@ export const paramsToSubItems = async ({
if (!existingSubItem) continue;
// 1. If arrear price
if (isArrearPrice({ price })) {
if (isConsumablePrice(price)) {
if (
allCusProducts.some((cp) => {
if (cp.id === cusProduct.id) return false;

View File

@@ -1,14 +1,14 @@
import { FullCustomerPrice, UsagePriceConfig } from "@autumn/shared";
// import type { FullCustomerPrice, UsagePriceConfig } from "@autumn/shared";
export const featureToCusPrice = ({
internalFeatureId,
cusPrices,
}: {
internalFeatureId: string;
cusPrices: FullCustomerPrice[];
}) => {
return cusPrices.find((cusPrice) => {
const config = cusPrice.price.config as UsagePriceConfig;
return config.internal_feature_id === internalFeatureId;
});
};
// export const featureToCusPrice = ({
// internalFeatureId,
// cusPrices,
// }: {
// internalFeatureId: string;
// cusPrices: FullCustomerPrice[];
// }) => {
// return cusPrices.find((cusPrice) => {
// const config = cusPrice.price.config as UsagePriceConfig;
// return config.internal_feature_id === internalFeatureId;
// });
// };

View File

@@ -14,9 +14,9 @@ import {
cusEntsToAllowance,
cusEntsToBalance,
cusEntsToMaxPurchase,
cusEntsToPurchasedBalance,
cusEntToCusPrice,
cusEntToKey,
cusEntToPurchasedBalance,
dbToApiFeatureV1,
expandIncludes,
type Feature,
@@ -218,9 +218,10 @@ export const getApiBalance = ({
.toNumber();
// 2. Purchased balance
const totalPurchasedBalance = sumValues(
cusEnts.map((cusEnt) => cusEntToPurchasedBalance({ cusEnt, entityId })),
);
const totalPurchasedBalance = cusEntsToPurchasedBalance({
cusEnts,
entityId,
});
// 3. Current balance
const totalBalanceWithRollovers = cusEntsToBalance({

View File

@@ -8,7 +8,6 @@ import {
} from "@autumn/shared";
import { mapToProductItems } from "@/internal/products/productV2Utils.js";
import { isFeatureItem } from "../products/product-items/productItemUtils/getItemType.js";
// import { getProductItemDisplay } from "../products/productUtils/productResponseUtils/getProductItemDisplay.js";
export const buildInvoiceMemo = async ({
org,

View File

@@ -5,6 +5,7 @@ import {
cusProductToPrices,
formatAmount,
InternalError,
isConsumablePrice,
type PreviewLineItem,
} from "@autumn/shared";
import type Stripe from "stripe";
@@ -12,13 +13,9 @@ import { priceToUnusedPreviewItem } from "@/internal/customers/attach/attachPrev
import { attachParamsToCurCusProduct } from "@/internal/customers/attach/attachUtils/convertAttachParams.js";
import { getContUseInvoiceItems } from "@/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { getCusPriceUsage } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js";
import { priceToUsageModel } from "@/internal/products/prices/priceUtils/convertPrice.js";
import {
isArrearPrice,
isContUsePrice,
} from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { isContUsePrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
import type { Logger } from "../../../external/logtail/logtailUtils";
@@ -53,7 +50,7 @@ export const getItemsForCurProduct = async ({
// const anchor = sub?.billing_cycle_anchor ? sub.billing_cycle_anchor * 1000 : undefined;
for (const price of curPrices) {
if (isArrearPrice({ price }) || isContUsePrice({ price })) {
if (isConsumablePrice(price) || isContUsePrice({ price })) {
continue;
}

View File

@@ -1,4 +1,3 @@
import { formatUnixToDate } from "@/utils/genUtils.js";
import { Decimal } from "decimal.js";
export type Proration = {

View File

@@ -3,8 +3,6 @@ import {
BillingType,
type EntitlementWithFeature,
type Feature,
type FullCustomerEntitlement,
type FullCustomerPrice,
type FullProduct,
isFixedPrice,
type Price,
@@ -90,18 +88,6 @@ export const priceToUsageModel = (price: Price) => {
return UsageModel.PayPerUse;
};
export const cusPriceToCusEnt = ({
cusPrice,
cusEnts,
}: {
cusPrice: FullCustomerPrice;
cusEnts: FullCustomerEntitlement[];
}) => {
return cusEnts.find(
(ce) => ce.entitlement?.id === cusPrice.price.entitlement_id,
);
};
export const priceToProductOptions = ({
price,
options,

View File

@@ -1,356 +0,0 @@
# Test Migration Guide
## Overview
This guide explains how to migrate test files from the global state pattern to the isolated test context pattern.
## Quick Start Migration Prompt (Copy & Paste)
Use this prompt for AI coding agents to migrate test files:
```
Migrate test file [FILE_PATH] from global state to isolated test context.
**Setup:**
1. Create backup: `cp [FILE_PATH] [FILE_PATH.backup.test.ts]` (DO NOT delete backup)
2. Read migration guide: @server/tests/MIGRATION_GUIDE.md
3. Read original file to understand all test logic
**Critical Rules:**
- PRESERVE ALL test logic, assertions, and edge cases
- DO NOT remove force_checkout tests or any existing tests
- Replace `compareMainProduct` with `expectCustomerV0Correct`
- Use TestFeature enum (Messages, Dashboard, Admin) instead of global features
- Products MUST be created with `initProductsV0` BEFORE customer creation
- **Do NOT include price items (`constructPriceItem`) in test products - they are not needed**
- Free trials use this exact structure:
```typescript
freeTrial: {
length: 7,
duration: FreeTrialDuration.Day, // Import from @autumn/shared
unique_fingerprint: true,
card_required: true,
}
```
**Migration Steps:**
1. Replace global imports with TestFeature enum
2. Define products inline using `constructProduct()` and feature item constructors
3. Set `const customerId = testCase;` at top of describe block
4. Create AutumnInt instance with `ctx.orgSecretKey` and `ApiVersion.V1_2`
5. In beforeAll:
- Call `initProductsV0({ ctx, products, prefix: testCase, customerId })` FIRST
- Then call `initCustomerV3({ ctx, customerId, ... })`
6. Replace `compareMainProduct` with `expectCustomerV0Correct`
7. For entitlement types, use: `ApiCustomerV1["entitlements"][number]`
8. When checking entitlements, iterate through REFERENCE product (what you sent), not customer data
**Testing:**
Run: `bun test --timeout 0 [FILE_PATH]`
If you encounter unfamiliar utility functions, STOP and ASK how to handle them.
```
## Detailed Migration Prompt for Coding Agent
```
Migrate the test file [FILE_PATH] from using global state to isolated test context.
Reference the migration guide at @server/tests/MIGRATION_GUIDE.md for the full pattern.
**Critical Requirements:**
1. DO NOT remove any existing test logic - preserve ALL test cases and assertions
2. DO NOT remove any force_checkout tests or other edge case tests
3. Compare line-by-line with the original file to ensure nothing is lost
4. If you encounter unfamiliar utility functions (beyond `compareMainProduct`), STOP and ASK the user how to handle them - do not attempt to migrate them on your own
**Migration Steps:**
1. **Create a Backup Copy**
- BEFORE making any changes, create a copy of the test file for reference
- Example: `cp basic2.test.ts basic2.test.ts.backup`
- This allows you to compare line-by-line during migration to ensure nothing is lost
- Delete the backup file after migration is complete and verified
2. **Replace Global Imports**
- Remove: `import { features, products } from "tests/global.js";`
- Add: `import { TestFeature } from "tests/setup/v2Features.js";`
3. **Create Inline Product Definitions**
- Use `constructProduct()` to define products directly in the test file
- Use `constructFeatureItem()`, `constructPrepaidItem()`, etc. for items
- **Do NOT include price items (`constructPriceItem`) - they are not needed for tests**
- Reference TestFeature enum instead of global features object
- Add a unique prefix to product IDs (e.g., testCase name)
4. **Update Test Setup**
- Add `const customerId = testCase;` at the top of describe block
- Create AutumnInt instance with org secret key:
```typescript
const autumnV1 = new AutumnInt({
secretKey: ctx.orgSecretKey,
version: ApiVersion.V1_2,
});
```
- In beforeAll:
- Call `initProductsV0({ ctx, products: [...], prefix: testCase, customerId })` BEFORE customer creation
- **IMPORTANT:** Passing `customerId` to `initProductsV0` automatically handles customer cleanup if they exist - you DO NOT need to manually delete the customer first
- Call `initCustomerV3({ ctx, customerId, ... })` AFTER products
5. **Update Test Assertions**
- Replace `compareMainProduct` with `expectCustomerV0Correct` for v0.1 API
- Replace references to `features.metered1` with `TestFeature.Messages`
- Replace references to `features.boolean1` with `TestFeature.Dashboard`
- Use `AutumnCli.getCustomer()` for v0.1 API format (returns `features` object)
- Use `autumnV1.customers.get()` for v1.2 API format (returns `entitlements` array)
**Type Helpers:**
- For entitlement types from v0.1 API, use: `ApiCustomerV1["entitlements"][number]`
- Import from: `import type { ApiCustomerV1 } from "@shared/api/customers/previousVersions/apiCustomerV1.js";`
- Example:
```typescript
const addOnBalance = cusRes.entitlements.find(
(e: ApiCustomerV1["entitlements"][number]) =>
e.feature_id === TestFeature.Messages && e.interval === "lifetime"
);
```
**⚠️ CRITICAL - Entitlement Checking Pattern:**
When testing entitlements with `/check` endpoint, you MUST iterate through the **reference product's entitlements** (what you SENT), NOT the customer's entitlements (what they have).
**WRONG (iterating through customer's entitlements):**
```typescript
const customer = await AutumnCli.getCustomer(customerId);
const entitlements = customer.features; // ❌ WRONG!
for (const featureId of Object.keys(entitlements)) {
const res = await AutumnCli.entitled(customerId, featureId);
// checking against customer data...
}
```
**CORRECT (iterating through reference product's entitlements):**
```typescript
import { convertProductV2ToV1 } from "@/internal/products/productUtils/productV2Utils/convertProductV2ToV1.js";
// Convert ProductV2 to V1 to get reference entitlements
const proProdV1 = convertProductV2ToV1({
productV2: proProd,
orgId: ctx.org.id,
features: ctx.features,
});
const proEntitlements = proProdV1.entitlements;
// Iterate through reference product's entitlements
for (const entitlement of Object.values(proEntitlements)) {
const res = await AutumnCli.entitled(customerId, entitlement.feature_id);
// Check that the response matches what we SENT...
expect(res.allowed).toBe(true);
if (entitlement.allowance) {
const balance = res.balances.find(b => b.feature_id === entitlement.feature_id);
expect(balance?.balance).toBe(entitlement.allowance);
}
}
```
This pattern ensures you're testing what you SENT against what you GET back from the API.
**⚠️ IMPORTANT - Unfamiliar Utility Functions:**
If you encounter utility functions that you're not sure how to migrate (e.g., `compareMainProduct`, `expectProductCorrect`, `checkEntitlements`, or other custom assertion helpers):
- **DO NOT attempt to migrate or replace them on your own**
- **STOP and ASK the user**: "I found utility function [FUNCTION_NAME] at line [LINE]. How should I handle this in the migration?"
- Wait for explicit instructions on the correct replacement function or pattern
- Common replacements so far:
- `compareMainProduct` → `expectCustomerV0Correct`
- But there may be others that need different handling!
6. **Verify All Logic Preserved**
- Check that every test case from the original file exists
- Check that every assertion is present
- Check that force_checkout tests are included
- Check that edge case tests are not removed
7. **Update Test Case ID**
- Change `testCase = "testname"` to keep original name (not "testname-new")
- Update console.log messages to use correct testCase
8. **Run Tests**
- Verify all tests pass with `bun test --timeout 0 [FILE_PATH]`
- The `--timeout 0` flag disables test timeouts, which is necessary for tests that involve checkout flows and longer async operations
**Example Migration:**
Before:
```typescript
import { features, products } from "tests/global.js";
const testCase = "basic1";
describe("basic1", () => {
const customerId = testCase;
beforeAll(async () => {
await initCustomerV3({ ctx, customerId });
});
test("should have correct entitlements", async () => {
const entitled = await AutumnCli.entitled(customerId, features.metered1.id);
expect(entitled.allowed).toBe(true);
});
});
```
After:
```typescript
import { TestFeature } from "tests/setup/v2Features.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
const freeProd = constructProduct({
type: "free",
isDefault: true,
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 5,
interval: ProductItemInterval.Month,
}),
],
});
const testCase = "basic1";
const customerId = testCase;
describe("basic1", () => {
const autumnV1 = new AutumnInt({
secretKey: ctx.orgSecretKey,
version: ApiVersion.V1_2,
});
beforeAll(async () => {
// Passing customerId automatically handles cleanup
await initProductsV0({
ctx,
products: [freeProd],
prefix: testCase,
customerId,
});
await initCustomerV3({
ctx,
customerId,
customerData: { fingerprint: "test" },
withTestClock: false,
});
});
test("should have correct entitlements", async () => {
const entitled = await AutumnCli.entitled(customerId, TestFeature.Messages);
expect(entitled.allowed).toBe(true);
});
});
```
**After Migration:**
- Replace the original file (not create a .new.test.ts file)
- Verify tests pass
- Report any issues or edge cases found
```
## Common Patterns
### Product Construction
```typescript
// Free product with feature
const freeProd = constructProduct({
type: "free",
isDefault: true,
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 5,
interval: ProductItemInterval.Month,
}),
],
});
// Pro product (matches global products.pro)
// - Boolean feature (Dashboard)
// - Metered feature (Messages) with 10 allowance
// - Unlimited feature (Admin)
// NOTE: Do NOT include price items (constructPriceItem) in test products - they are not needed
const proProd = constructProduct({
type: "pro",
items: [
constructFeatureItem({
featureId: TestFeature.Dashboard,
isBoolean: true,
}),
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 10,
interval: ProductItemInterval.Month,
}),
constructFeatureItem({
featureId: TestFeature.Admin,
unlimited: true,
}),
],
});
// Pro product with free trial
// IMPORTANT: Free trial structure must use this exact format
const proWithTrial = constructProduct({
type: "pro",
items: [
constructFeatureItem({
featureId: TestFeature.Dashboard,
isBoolean: true,
}),
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 10,
interval: ProductItemInterval.Month,
}),
constructFeatureItem({
featureId: TestFeature.Admin,
unlimited: true,
}),
],
freeTrial: {
length: 7,
duration: FreeTrialDuration.Day, // Import FreeTrialDuration from @autumn/shared
unique_fingerprint: true, // Set to true to prevent duplicate trials per fingerprint
card_required: true,
},
});
// Add-on product
const addOnProd = constructProduct({
type: "paid",
id: "addon",
isAddOn: true,
items: [
constructPrepaidItem({
featureId: TestFeature.Messages,
price: 500, // $5.00
billingUnits: 100,
}),
],
});
```
### Feature Mapping
- `features.metered1``TestFeature.Messages`
- `features.boolean1``TestFeature.Dashboard`
- `features.metered2` → Create new feature if needed
### API Version Differences
- **v0.1 API** (AutumnCli): Returns `{ features: { [featureId]: {...} } }`
- **v1.2 API** (AutumnInt): Returns `{ entitlements: [...] }`
## Why This Migration?
1. **Parallel Test Isolation**: Tests can run in parallel without conflicting
2. **No Global State**: Each test has its own products and data
3. **Test Independence**: Tests don't depend on setup order
4. **Better Debugging**: Each test is self-contained and easier to understand

View File

@@ -1,457 +0,0 @@
# Test Migration Tracker
Track the progress of migrating test files from global state to isolated test context.
## Migration Status
Legend:
- ✅ = Migrated and passing
- 🚧 = In progress
- ⏳ = Not started
- ⚠️ = Needs review
- ❌ = Skipped/Archived
## Test Files to Migrate
### Basic Tests
- [x]`tests/attach/basic/basic1.test.ts` - Migrated
- [x]`tests/attach/basic/basic2.test.ts` - Migrated (renamed from basic4)
- [x]`tests/attach/basic/basic3.test.ts` - Migrated (renamed from basic5)
- [x]`tests/attach/basic/basic6.test.ts` - Migrated
- [x]`tests/attach/basic/basic7.test.ts` - Migrated
- [x]`tests/attach/basic/basic8.test.ts` - Migrated
- [x]`tests/attach/basic/basic9.test.ts` - Migrated
- [x]`tests/attach/basic/basic10.test.ts` - Migrated
### Downgrade Tests
- [x]`tests/attach/downgrade/downgrade5.test.ts` - Migrated (global→isolated with shared products)
- [x]`tests/attach/downgrade/downgrade6.test.ts` - Migrated (global→isolated with shared products)
- [x]`tests/attach/downgrade/downgrade7.test.ts` - Migrated (global→isolated with shared products)
### Multi-Product Tests
- [ ]`tests/attach/multiProduct/multiProduct1.ts`
- [ ]`tests/attach/multiProduct/multiProduct2.ts`
### Other Tests
- [ ]`tests/attach/others/others4.ts`
- [ ]`tests/attach/others/others5.ts`
### Upgrade (Old) Tests
- [ ]`tests/attach/upgradeOld/upgradeOld1.ts`
- [ ]`tests/attach/upgradeOld/upgradeOld2.ts`
- [ ]`tests/attach/upgradeOld/upgradeOld3.ts`
- [ ]`tests/attach/upgradeOld/upgradeOld4.ts`
### Core Tests
- [ ]`tests/core/cancel/cancel5.test.ts`
### Continuous Use Tests
- [ ]`tests/contUse/track/track5.ts`
### Advanced Tests
- [ ]`tests/advanced/coupons/coupon1.ts`
- [ ]`tests/advanced/multiFeature/multiFeature1.ts`
- [ ]`tests/advanced/multiFeature/multiFeature2.ts`
- [ ]`tests/advanced/multiFeature/multiFeature3.ts`
### Archived Tests (Review if needed)
- [ ]`tests/archives/arrear_prorated/arrear_prorated2.ts`
- [ ]`tests/archives/arrear_prorated/arrear_prorated3.ts`
- [ ]`tests/archives/coupon1 copy.ts`
## Utility Files (Don't Migrate)
These are helper files, not tests:
- `tests/utils/compare.ts`
- `tests/utils/advancedUsageUtils.ts`
## Migration Prompt
When ready to migrate a file, use this prompt:
```
Migrate the test file [FILE_PATH] from using global state to isolated test context.
Reference the migration guide at @server/tests/MIGRATION_GUIDE.md for the full pattern.
**Critical Requirements:**
1. DO NOT remove any existing test logic - preserve ALL test cases and assertions
2. DO NOT remove any force_checkout tests or other edge case tests
3. Compare line-by-line with the original file to ensure nothing is lost
4. Replace the original file (not create a .new.test.ts file)
5. Update testCase ID to match original (e.g., "basic2" not "basic2-new")
After migration, run: `bun test [FILE_PATH]` to verify all tests pass.
```
## Recent Progress (2025-10-24)
### Migration Tests
- [x]`tests/attach/migrations/migration1.test.ts` - Mocha→Bun migration
- [x]`tests/attach/migrations/migration2.test.ts` - Mocha→Bun migration
- [x]`tests/attach/migrations/migration3.test.ts` - Mocha→Bun migration
- [x]`tests/attach/migrations/migration4.test.ts` - Mocha→Bun migration
- [x]`tests/attach/migrations/runMigrationTest.ts` - Chai→Bun assertions
### Shared Products Created
- [x]`tests/attach/downgrade/sharedProducts.ts` - Created shared products for downgrade tests
## Final Status (2025-10-24)
### G1.sh Test Suite Status
**All 48 test files verified using Bun test framework:**
- ✅ tests/check/basic (10 files)
- ✅ tests/attach/basic (6 files)
- ✅ tests/attach/upgrade (7 files)
- ✅ tests/attach/downgrade (7 files)
- ✅ tests/attach/free (2 files)
- ✅ tests/attach/addOn (2 files)
- ✅ tests/attach/entities (5 files)
- ✅ tests/attach/checkout (8 files)
### G2.sh Test Suite Status
**All 35 active test files migrated to Bun:**
- ✅ Migrations (5 files)
- ✅ NewVersion (3 files)
- ✅ UpgradeOld (5 files including sharedProducts)
- ✅ Others (8 files, 1 deleted)
- ✅ UpdateEnts (5 files including utility)
- ✅ Prepaid (5 files, 2 commented out)
- ✅ Advanced/check (1 file)
- ✅ Interval/upgrade (3 files)
- ✅ Interval/multiSub (3 files)
- ✅ Interval utility (1 file)
## Progress Summary
- **Total Test Files in g1+g2**: 83
- **Migrated**: 83 (100%)
- **In Progress**: 0 (0%)
- **Remaining**: 0 (0%)
## ✅ G2.sh Migration Complete! (All 35 files migrated)
### Migration Tests (5 files)
- [x]`tests/attach/migrations/migration1.test.ts` - Mocha→Bun
- [x]`tests/attach/migrations/migration2.test.ts` - Mocha→Bun
- [x]`tests/attach/migrations/migration3.test.ts` - Mocha→Bun
- [x]`tests/attach/migrations/migration4.test.ts` - Mocha→Bun
- [x]`tests/attach/migrations/runMigrationTest.ts` - Utility (Chai→Bun)
### NewVersion Tests (3 files)
- [x]`tests/attach/newVersion/newVersion1.test.ts` - Mocha→Bun + global→isolated
- [x]`tests/attach/newVersion/newVersion2.test.ts` - Mocha→Bun
- [x]`tests/attach/newVersion/newVersion3.test.ts` - Already migrated
### UpgradeOld Tests (5 files)
- [x]`tests/attach/upgradeOld/upgradeOld1.test.ts` - Mocha→Bun + global→isolated
- [x]`tests/attach/upgradeOld/upgradeOld2.test.ts` - Mocha→Bun + global→isolated
- [x]`tests/attach/upgradeOld/upgradeOld3.test.ts` - Mocha→Bun + global→isolated
- [x]`tests/attach/upgradeOld/upgradeOld4.test.ts` - Mocha→Bun + global→isolated
- [x]`tests/attach/upgradeOld/sharedProducts.ts` - Created for global→isolated migration
### Others Tests (9 files)
- [x]`tests/attach/others/others1.test.ts` - Mocha→Bun
- [x]`tests/attach/others/others2.test.ts` - Mocha→Bun
- [x]`tests/attach/others/others3.test.ts` - Mocha→Bun
- [x]`tests/attach/others/others4.ts` - Deleted (was commented out)
- [x]`tests/attach/others/others5.test.ts` - Mocha→Bun
- [x]`tests/attach/others/others6.test.ts` - Mocha→Bun
- [x]`tests/attach/others/others7.test.ts` - Mocha→Bun
- [x]`tests/attach/others/others8.test.ts` - Mocha→Bun
- [x]`tests/attach/others/others9.test.ts` - Mocha→Bun
### UpdateEnts Tests (5 files)
- [x]`tests/attach/updateEnts/updateEnts1.test.ts` - Mocha→Bun
- [x]`tests/attach/updateEnts/updateEnts2.test.ts` - Mocha→Bun
- [x]`tests/attach/updateEnts/updateEnts3.test.ts` - Mocha→Bun
- [x]`tests/attach/updateEnts/updateEnts4.test.ts` - Mocha→Bun
- [x]`tests/attach/updateEnts/expectUpdateEnts.ts` - Utility (Chai→Bun)
### Prepaid Tests (7 files)
- [x]`tests/attach/prepaid/prepaid1.test.ts` - Mocha→Bun
- [x]`tests/attach/prepaid/prepaid2.test.ts` - Mocha→Bun
- [x]`tests/attach/prepaid/prepaid3.test.ts` - Mocha→Bun
- [x]`tests/attach/prepaid/prepaid4.test.ts` - Mocha→Bun
- [x]`tests/attach/prepaid/prepaid5.test.ts` - Mocha→Bun
- [x] 🔕 `tests/attach/prepaid/prepaid6.ts` - Commented out (not migrated)
- [x] 🔕 `tests/attach/prepaid/prepaid7.ts` - Commented out (not migrated)
### Advanced Tests (1 file)
- [x]`tests/advanced/check/check1.test.ts` - Mocha→Bun
### Interval Tests (7 files)
- [x]`tests/interval/upgrade/interval1.test.ts` - Mocha→Bun
- [x]`tests/interval/upgrade/interval2.test.ts` - Mocha→Bun
- [x]`tests/interval/upgrade/interval3.test.ts` - Mocha→Bun
- [x]`tests/interval/multiSub/multiSubInterval1.test.ts` - Mocha→Bun
- [x]`tests/interval/multiSub/multiSubInterval2.test.ts` - Mocha→Bun
- [x]`tests/interval/multiSub/multiSubInterval3.test.ts` - Mocha→Bun
- [x]`tests/interval/intervalUtils1.test.ts` - Mocha→Bun
## G3 Migration Complete! (All 19 files)
### contUse/entities (5 files)
- [x]`tests/contUse/entities/entity1.test.ts` - Mocha→Bun
- [x]`tests/contUse/entities/entity2.test.ts` - Mocha→Bun
- [x]`tests/contUse/entities/entity3.test.ts` - Mocha→Bun
- [x]`tests/contUse/entities/entity4.test.ts` - Mocha→Bun
- [x]`tests/contUse/entities/entity5.test.ts` - Mocha→Bun
### contUse/update (5 files)
- [x]`tests/contUse/update/updateContUse1.test.ts` - Mocha→Bun
- [x]`tests/contUse/update/updateContUse2.test.ts` - Mocha→Bun
- [x]`tests/contUse/update/updateContUse3.test.ts` - Mocha→Bun
- [x]`tests/contUse/update/updateContUse4.test.ts` - Mocha→Bun
- [x]`tests/contUse/update/updateContUse5.test.ts` - Mocha→Bun
### contUse/track (6 files)
- [x]`tests/contUse/track/track1.test.ts` - Mocha→Bun
- [x]`tests/contUse/track/track2.test.ts` - Mocha→Bun
- [x]`tests/contUse/track/track3.test.ts` - Mocha→Bun
- [x]`tests/contUse/track/track4.test.ts` - Mocha→Bun
- [x]`tests/contUse/track/track5.test.ts` - Mocha→Bun
- [x]`tests/contUse/track/track6.test.ts` - Mocha→Bun
### contUse/roles (3 files)
- [x]`tests/contUse/roles/role1.test.ts` - Mocha→Bun
- [x]`tests/contUse/roles/role2.test.ts` - Mocha→Bun
- [x]`tests/contUse/roles/role3.test.ts` - Mocha→Bun
## G4 Migration Complete! (All 47 files)
### merged/downgrade (8 files)
- [x]`tests/merged/downgrade/mergedDowngrade1.test.ts` - Mocha→Bun
- [x]`tests/merged/downgrade/mergedDowngrade2.test.ts` - Mocha→Bun
- [x]`tests/merged/downgrade/mergedDowngrade3.test.ts` - Mocha→Bun
- [x]`tests/merged/downgrade/mergedDowngrade4.test.ts` - Mocha→Bun
- [x]`tests/merged/downgrade/mergedDowngrade5.test.ts` - Already migrated
- [x]`tests/merged/downgrade/mergedDowngrade6.test.ts` - Already migrated
- [x]`tests/merged/downgrade/mergedDowngrade8.test.ts` - Mocha→Bun
- [x]`tests/merged/downgrade/mergedDowngrade9.test.ts` - Mocha→Bun
### merged/prepaid (3 files)
- [x]`tests/merged/prepaid/mergedPrepaid1.test.ts` - Mocha→Bun
- [x]`tests/merged/prepaid/mergedPrepaid2.test.ts` - Mocha→Bun
- [x]`tests/merged/prepaid/mergedPrepaid3.test.ts` - Mocha→Bun
### Other merged/core directories (36 files - all already migrated)
- [x] ✅ merged/group (2 files)
- [x] ✅ merged/add (3 files)
- [x] ✅ merged/separate (2 files)
- [x] ✅ merged/upgrade (4 files)
- [x] ✅ merged/trial (8 files)
- [x] ✅ merged/addOn (6 files)
- [x] ✅ core/cancel (8 files)
- [x] ✅ core/multiAttach (6 files + subdirectories)
- [x] ✅ core/reset (1 file)
### Utility Files Updated:
- [x]`tests/merged/mergeUtils/expectSubCorrect.ts` - Chai→Bun assertions (kept as .ts)
## G5 Migration Complete! (34 files migrated, but only 19 in g5.sh script)
### multiProduct (2 files + sharedProducts)
- [x]`tests/attach/multiProduct/multiProduct1.test.ts` - Mocha→Bun + global→isolated
- [x]`tests/attach/multiProduct/multiProduct2.test.ts` - Mocha→Bun + global→isolated
- [x]`tests/attach/multiProduct/sharedProducts.ts` - Created
### usage (4 files + sharedProducts)
- [x]`tests/advanced/usage/usage1.test.ts` - Mocha→Bun + global→isolated
- [x]`tests/advanced/usage/usage2.test.ts` - Mocha→Bun (GPU products still use global)
- [x]`tests/advanced/usage/usage3.test.ts` - Mocha→Bun (GPU products still use global)
- [x]`tests/advanced/usage/usage4.test.ts` - Mocha→Bun (GPU products still use global)
- [x]`tests/advanced/usage/sharedProducts.ts` - Created
### coupons (3 files)
- [x]`tests/advanced/coupons/coupon1.test.ts` - Mocha→Bun
- [x]`tests/advanced/coupons/coupon2.test.ts` - Mocha→Bun
- [x]`tests/advanced/coupons/coupon3.test.ts` - Mocha→Bun
### referrals (4 files)
- [x]`tests/advanced/referrals/referrals1.test.ts` - Mocha→Bun
- [x]`tests/advanced/referrals/referrals2.test.ts` - Mocha→Bun
- [x]`tests/advanced/referrals/referrals3.test.ts` - Mocha→Bun
- [x]`tests/advanced/referrals/referrals4.test.ts` - Mocha→Bun
### referrals/paid (4 files)
- [x]`tests/advanced/referrals/paid/referrals13.test.ts` - Mocha→Bun
- [x]`tests/advanced/referrals/paid/referrals14.test.ts` - Mocha→Bun
- [x]`tests/advanced/referrals/paid/referrals15.test.ts` - Mocha→Bun
- [x] 🔕 `tests/advanced/referrals/paid/referrals16.test.ts` - Commented out
### updateQuantity (1 file)
- [x]`tests/attach/updateQuantity/updateQuantity1.test.ts` - Mocha→Bun
### rollovers (6 files) ⚠️ NOT IN g5.sh SCRIPT
- [x]`tests/advanced/rollovers/rollover1.test.ts` - Mocha→Bun (migrated but not in g5.sh)
- [x]`tests/advanced/rollovers/rollover2.test.ts` - Mocha→Bun (migrated but not in g5.sh)
- [x]`tests/advanced/rollovers/rollover3.test.ts` - Mocha→Bun (migrated but not in g5.sh)
- [x]`tests/advanced/rollovers/rollover4.test.ts` - Mocha→Bun (migrated but not in g5.sh)
- [x]`tests/advanced/rollovers/rollover5.test.ts` - Mocha→Bun (migrated but not in g5.sh)
- [x]`tests/advanced/rollovers/rollover6.test.ts` - Mocha→Bun (migrated but not in g5.sh)
### customInterval (5 files) ⚠️ NOT IN g5.sh SCRIPT
- [x]`tests/advanced/customInterval/customInterval1.test.ts` - Mocha→Bun (migrated but not in g5.sh)
- [x]`tests/advanced/customInterval/customInterval2.test.ts` - Mocha→Bun (migrated but not in g5.sh)
- [x]`tests/advanced/customInterval/customInterval3.test.ts` - Mocha→Bun (migrated but not in g5.sh)
- [x]`tests/advanced/customInterval/customInterval4.test.ts` - Mocha→Bun (migrated but not in g5.sh)
- [x]`tests/advanced/customInterval/customInterval5.test.ts` - Mocha→Bun (migrated but not in g5.sh)
- [x] 🔕 `tests/advanced/customInterval/customInterval6.ts` - Empty file (skipped)
### usageLimit (4 files) ⚠️ NOT IN g5.sh SCRIPT
- [x]`tests/advanced/usageLimit/usageLimit1.test.ts` - Mocha→Bun (migrated but not in g5.sh)
- [x]`tests/advanced/usageLimit/usageLimit2.test.ts` - Mocha→Bun (migrated but not in g5.sh)
- [x]`tests/advanced/usageLimit/usageLimit3.test.ts` - Mocha→Bun (migrated but not in g5.sh)
- [x]`tests/advanced/usageLimit/usageLimit4.test.ts` - Mocha→Bun (migrated but not in g5.sh)
### G5 Not Migrated (not in g5.sh script):
- [ ] ⏸️ `tests/advanced/multiFeature/multiFeature1.ts` (uses old ProductV1 structure)
- [ ] ⏸️ `tests/advanced/multiFeature/multiFeature2.ts` (uses old ProductV1 structure)
- [ ] ⏸️ `tests/advanced/multiFeature/multiFeature3.ts` (uses old ProductV1 structure)
**⚠️ ACTION REQUIRED:** The g5.sh comment says "rollovers, customInterval, usageLimit still use Mocha (not migrated yet)" but these 15 files ARE migrated. Either:
1. Add these directories to g5.sh script, OR
2. Create a new test group (g7.sh) for these migrated advanced tests
## G6 - Alex Tests (⏳ NOT MIGRATED - Still Using Mocha)
### Alex Integration Tests (6 test files)
- [ ]`tests/alex/01_free.ts` - Uses Mocha (not migrated)
- [ ]`tests/alex/02_pro.ts` - Uses Mocha (not migrated)
- [ ]`tests/alex/03_premium.ts` - Uses Mocha (not migrated)
- [ ]`tests/alex/04_topups.ts` - Uses Mocha (not migrated)
- [ ]`tests/alex/05_cancel.ts` - Uses Mocha (not migrated)
- [ ]`tests/alex/06_switch.ts` - Uses Mocha (not migrated)
### Utility Files (3 files)
- `tests/alex/00_setup.ts` - Setup file (ignored in g6.sh)
- `tests/alex/utils.ts` - Helper utilities
- `tests/alex/init.ts` - Initialization utilities
**Note:** g6.sh runs these tests using `npx mocha --parallel` with comment "will be migrated later"
## Final Migration Summary
### Totals:
- **G1:** 47 files ✅
- **G2:** 35 files ✅ (includes 7 interval tests)
- **G3:** 19 files ✅
- **G4:** 65 files ✅ (all merged/core tests)
- **G5:** 19 files in script ✅ + 15 files migrated but not in script ⚠️
- **G6:** 6 files ⏳ (NOT migrated - still using Mocha)
- **Total Migrated to Bun:** 219 files (204 in scripts + 15 orphaned)
- **Total in Test Scripts (g1-g5):** 185 files
- **Not migrated:**
- 3 files (multiFeature 1-3 - ProductV1 structure) ⏸️
- 6 files (alex tests - still using Mocha) ⏳
- 15 files (rollovers, customInterval, usageLimit - migrated but not in g5.sh) ⚠️
### Helper Functions Created/Updated:
1.`checkUsageInvoiceAmountV2` - V2 wrapper for usage invoice validation
2.`expectSubCorrect.ts` - Updated Chai→Bun assertions
### Shared Products Files Created:
1.`tests/attach/basic/sharedProducts.ts` (pre-existing)
2.`tests/attach/downgrade/sharedProducts.ts`
3.`tests/attach/upgradeOld/sharedProducts.ts`
4.`tests/attach/multiProduct/sharedProducts.ts`
5.`tests/advanced/usage/sharedProducts.ts`
### Shell Scripts Status:
-`scripts/testGroups/g1.sh` - Uses `BUN_PARALLEL_COMPACT` (47 files)
-`scripts/testGroups/g2.sh` - Uses `BUN_PARALLEL_COMPACT` (35 files, includes interval tests)
-`scripts/testGroups/g3.sh` - Uses `BUN_PARALLEL_COMPACT` (19 files)
-`scripts/testGroups/g4.sh` - Uses `BUN_PARALLEL_COMPACT` (65 files)
- ⚠️ `scripts/testGroups/g5.sh` - Uses `BUN_PARALLEL_COMPACT` (19 files)
- **MISSING:** rollovers (6), customInterval (5), usageLimit (4) directories
- Comment says these "still use Mocha" but they ARE migrated
-`scripts/testGroups/g6.sh` - Uses `npx mocha --parallel` (6 files, not migrated)
### All before() → beforeAll() Replaced:
- ✅ Verified: 0 test files still using `before()` (all occurrences replaced with `beforeAll()`)
- ✅ All test files now use proper Bun test syntax
### Cleanup Actions Completed:
- ✅ Deleted 15 Mocha duplicate .ts files where .test.ts versions existed (coupons, referrals, usage)
- ✅ Renamed 1 Bun duplicate to .backup.ts (updateQuantity1.ts)
- ✅ Created backups for all newly migrated files
### Migration Status:
- ✅ All ProductV1→ProductV2 conversions complete (except 3 multiFeature files + 6 alex files)
- ✅ All Mocha→Bun framework migrations complete (except 3 multiFeature files + 6 alex files)
- ✅ All global state → isolated migrations complete for migrated files
- ✅ All tests preserve original logic and assertions
- ✅ Test groups G1-G4 ready for parallel Bun execution
- ⚠️ G5 is partial - missing 15 migrated test files (rollovers, customInterval, usageLimit)
- ⏳ G6 (alex tests) still uses Mocha framework
---
## 🚨 CRITICAL DISCREPANCIES FOUND
### Issue 1: G2 Missing Interval Tests in Tracker
**Status:** FIXED ✅
- Added 7 interval test files to tracker (interval/upgrade, interval/multiSub)
- Updated G2 count from 28 to 35 files
### Issue 2: G5 - Orphaned Migrated Tests
**Status:** ⚠️ NEEDS ACTION
- **15 test files are migrated but NOT in g5.sh script:**
- `tests/advanced/rollovers/` (6 files)
- `tests/advanced/customInterval/` (5 files)
- `tests/advanced/usageLimit/` (4 files)
- **g5.sh comment is outdated:** Says these "still use Mocha (not migrated yet)" but they ARE migrated
- **Action needed:** Either add these to g5.sh OR create g7.sh for them
### Issue 3: G6 Not Tracked
**Status:** FIXED ✅
- Added G6 section tracking 6 alex test files (still using Mocha)
- These are integration tests that will need migration later
### Issue 4: Incorrect Total Counts
**Status:** FIXED ✅
- Old claim: "204 files migrated"
- **Actual:** 219 files migrated to Bun (but only 185 are in test scripts g1-g5)
- 15 orphaned files exist but aren't run by any script
---
## 📋 RECOMMENDED ACTIONS
1. **Update g5.sh to include orphaned tests:**
```bash
# Add to scripts/testGroups/g5.sh:
BUN_PARALLEL_COMPACT \
'server/tests/advanced/coupons' \
'server/tests/attach/updateQuantity' \
'server/tests/advanced/referrals' \
'server/tests/advanced/referrals/paid' \
'server/tests/attach/multiProduct' \
'server/tests/advanced/usage' \
'server/tests/advanced/rollovers' \
'server/tests/advanced/customInterval' \
'server/tests/advanced/usageLimit' \
--max=6
```
2. **Update g5.sh comment:**
- Remove: "Note: advanced/multiFeature, advanced/rollovers, advanced/customInterval, advanced/usageLimit still use Mocha (not migrated yet)"
- Replace: "Note: advanced/multiFeature still uses Mocha (not migrated yet)"
3. **Consider migrating G6 (alex tests):**
- 6 integration test files still using Mocha
- Would complete the Mocha→Bun migration (except multiFeature)
---
## ✅ VERIFIED COUNTS (Post-Sweep)
- **G1:** 47 files ✅ (matches script)
- **G2:** 35 files ✅ (matches script - corrected from 28)
- **G3:** 19 files ✅ (matches script)
- **G4:** 65 files ✅ (matches script)
- **G5:** 19 files in script, 15 files orphaned ⚠️
- **G6:** 6 files using Mocha ⏳
- **Total in scripts (g1-g5):** 185 files
- **Total migrated to Bun:** 219 files
- **Orphaned (migrated but not in scripts):** 15 files
- **Still using Mocha:** 9 files (3 multiFeature + 6 alex)

View File

@@ -1,256 +0,0 @@
# Mocha to Bun Test Migration Guide
## Quick Reference
### 1. Update Imports
**Remove:**
```typescript
import { expect } from "chai";
import { setupBefore } from "tests/before.js";
```
**Add:**
```typescript
import { beforeAll, describe, expect, test } from "bun:test";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
```
**Keep:** All AutumnCli, global products/features imports, and utility imports.
### 2. Replace Test Hooks
**Before:**
```typescript
before(async function () {
await setupBefore(this);
db = this.db;
org = this.org;
env = this.env;
await initCustomer({
autumn: this.autumnJs,
customerId,
db,
org,
env,
fingerprint: "test",
withTestClock: false,
});
});
```
**After:**
```typescript
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
customerData: { fingerprint: "test" },
withTestClock: false,
});
});
```
### 3. Initialize Products
**Before:**
```typescript
addPrefixToProducts({
products: [product1, product2],
prefix: testCase,
});
await createProducts({
db,
orgId: org.id,
env,
autumn,
products: [product1, product2],
});
```
**After:**
```typescript
await initProductsV0({
ctx,
products: [product1, product2],
prefix: testCase,
});
```
**Important:** `initProductsV0` combines both `addPrefixToProducts` and `createProducts` into a single call.
### 4. Update Test Functions
Replace `it` with `test`:
```typescript
// Before
it("should do something", async () => { ... });
// After
test("should do something", async () => { ... });
```
### 5. Update Assertions
| Chai | Bun |
|------|-----|
| `expect(x).to.be.true` | `expect(x).toBe(true)` |
| `expect(x).to.be.false` | `expect(x).toBe(false)` |
| `expect(x).to.exist` | `expect(x).toBeDefined()` |
| `expect(x).to.not.exist` | `expect(x).toBeUndefined()` |
| `expect(x).to.equal(y)` | `expect(x).toBe(y)` |
| `expect(x).to.have.lengthOf(n)` | `expect(x).toHaveLength(n)` |
| `expect(x).to.be.greaterThan(n)` | `expect(x).toBeGreaterThan(n)` |
### 6. Important Parameters
Always preserve these from the original test:
**customerData.fingerprint:**
- Default: no fingerprint (or empty string)
- Common: `{ fingerprint: "test" }`
- Trials: `{ fingerprint: Math.random().toString(36).substring(2, 15) }`
**attachPm:**
- Not needed: tests with checkout flow
- `"success"`: tests that need payment method pre-attached
- `"fail"`: tests for failed payment scenarios
**withTestClock:**
- `true`: most tests (allows time travel)
- `false`: tests that don't need test clocks
### 7. Rename Files
```bash
mv test-name.ts test-name.test.ts
```
All test files must end with `.test.ts` for Bun to recognize them.
### 8. Update Shell Scripts
**Before:**
```bash
$MOCHA_CMD 'tests/attach/basic/*.ts'
```
**After:**
```bash
$BUN_PARALLEL tests/attach/basic
```
## Common Patterns
### Pattern 1: Basic Test
```typescript
import { beforeAll, describe, expect, test } from "bun:test";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
const testCase = "test1";
describe("Test description", () => {
const customerId = testCase;
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: true,
});
});
test("should do something", async () => {
// test code
});
});
```
### Pattern 2: With Custom Products
```typescript
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
beforeAll(async () => {
await initCustomerV3({ ctx, customerId, withTestClock: true });
await initProductsV0({
ctx,
products: [customProduct],
prefix: testCase,
});
});
```
### Pattern 3: With Payment Method
```typescript
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
attachPm: "success",
withTestClock: true,
});
});
```
## Checklist
- [ ] Update imports (add bun:test, ctx, initCustomerV3)
- [ ] Replace `before` with `beforeAll`
- [ ] Replace `it` with `test`
- [ ] Update all chai assertions to Bun
- [ ] Replace initCustomer with initCustomerV3
- [ ] Replace createProducts with initProductsV0
- [ ] Verify fingerprint parameter
- [ ] Verify attachPm parameter
- [ ] Verify withTestClock parameter
- [ ] Rename file to `.test.ts`
- [ ] Update shell script if needed
- [ ] Remove `this` context references
- [ ] Test runs successfully
## Critical Rules
### Order of Operations
**YOU MUST MAINTAIN THE EXACT ORDER** of initialization calls from the original test:
1. **If products are created BEFORE customer init** → Keep that order in migration
2. **If customer is created BEFORE products** → Keep that order in migration
**Example 1: Products First**
```typescript
// Original (Mocha)
addPrefixToProducts({ products: [pro, premium], prefix: testCase });
await createProducts({ autumn, products: [pro, premium], db, orgId: org.id, env });
await initCustomer({ autumn: autumnJs, customerId, db, org, env, attachPm: "success" });
// Migrated (Bun) - SAME ORDER
await initProductsV0({ ctx, products: [pro, premium], prefix: testCase });
await initCustomerV3({ ctx, customerId, customerData: {}, attachPm: "success", withTestClock: true });
```
**Example 2: Customer First**
```typescript
// Original (Mocha)
await initCustomer({ autumn: autumnJs, customerId, db, org, env, attachPm: "success" });
addPrefixToProducts({ products: [pro, premium], prefix: testCase });
await createProducts({ autumn, products: [pro, premium], db, orgId: org.id, env });
// Migrated (Bun) - SAME ORDER
await initCustomerV3({ ctx, customerId, customerData: {}, attachPm: "success", withTestClock: true });
await initProductsV0({ ctx, products: [pro, premium], prefix: testCase });
```
## Notes
- **No timeout needed**: Tests run with `--timeout 0` globally
- **Keep AutumnCli**: Don't replace with AutumnInt for API calls
- **Preserve test logic**: Only change framework, not test behavior
- **ctx is global**: Imported from createTestContext, contains db/org/env/stripeCli
- **initProductsV0 is a helper**: It combines `addPrefixToProducts` + `createProducts` into one call

View File

@@ -6,6 +6,8 @@ import { toUnix } from "@tests/utils/testIntervalUtils/testUnixUtils";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import {
constructArrearItem,
constructArrearProratedItem,
constructFeatureItem,
constructPrepaidItem,
} from "@/utils/scriptUtils/constructItem.js";
@@ -28,28 +30,58 @@ const freeProd = constructProduct({
],
});
const proProd = constructProduct({
const pro = constructProduct({
type: "pro",
isDefault: false,
items: [
constructFeatureItem({
constructArrearProratedItem({
featureId: TestFeature.Users,
includedUsage: 1,
pricePerUnit: 10,
}),
constructArrearProratedItem({
featureId: TestFeature.Workflows,
includedUsage: 1,
pricePerUnit: 25,
}),
constructArrearItem({
featureId: TestFeature.Words,
billingUnits: 1,
price: 0.1,
}),
constructPrepaidItem({
featureId: TestFeature.Messages,
includedUsage: 300,
billingUnits: 100,
price: 8,
}),
],
intervalCount: 2,
// intervalCount: 2,
});
const premium = constructProduct({
type: "premium",
isDefault: false,
items: [
constructFeatureItem({
constructArrearItem({
featureId: TestFeature.Words,
billingUnits: 1,
price: 0.1,
}),
constructArrearProratedItem({
featureId: TestFeature.Users,
includedUsage: 1,
pricePerUnit: 15,
}),
constructPrepaidItem({
featureId: TestFeature.Messages,
includedUsage: 1000,
billingUnits: 100,
price: 12,
}),
],
// intervalCount: 2,
});
const freeAddOn = constructRawProduct({
id: "freeAddOn",
items: [
@@ -86,7 +118,16 @@ const monthlyAddOn = constructRawProduct({
isAddOn: true,
});
const testCase = "temp";
const entities = [
{
id: "entity1",
feature_id: TestFeature.Users,
},
{
id: "entity2",
feature_id: TestFeature.Users,
},
];
describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => {
const customerId = "temp";
@@ -102,45 +143,44 @@ describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => {
await initProductsV0({
ctx,
products: [freeProd, proProd, premium, freeAddOn, monthlyAddOn],
products: [freeProd, pro, premium, freeAddOn, monthlyAddOn],
prefix: customerId,
});
await autumnV1.attach({
customer_id: customerId,
product_id: freeAddOn.id,
});
await autumnV1.attach({
customer_id: customerId,
product_id: proProd.id,
});
await autumnV1.attach({
customer_id: customerId,
product_id: monthlyAddOn.id,
product_id: pro.id,
options: [
{
feature_id: TestFeature.Messages,
quantity: 100,
quantity: 300,
},
],
});
await autumnV1.entities.create(customerId, entities);
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Workflows,
value: 4,
});
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Words,
value: 1000,
});
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: result.testClockId,
advanceTo: toUnix({
year: 2026,
month: 1,
day: 15,
year: 2025,
month: 12,
day: 22,
}),
});
await autumnV1.attach({
customer_id: customerId,
product_id: proProd.id,
});
});
return;
});

View File

@@ -1,29 +0,0 @@
get customer tests
Important apiCusFeature fields:
- granted balance
- purchased balance
- current balance
- usage
1. Free metered feature -- test apiCusFeature fields ()
- Tracking, should alter current balance
- Update customer entitlement (through `handleUpdateEntitlement.ts`), should alter current balance (maybe granted balance too? not sure yet), usage should stay the same
-
2. Pay per use metered feature (with granted balance)
- Track a bit of the granted balance: purchased balance should be 0, granted balance should stay the same, current balance should be granted balance - usage, usage should be usage
- Track into overage: purchased balance should be overage amount, granted balance should stay the same, current balance should be 0
-
3. Prepaid features (with granted balance)
- Granted balance is what was passed into product config, purchased balance is how much was specified in prepaid
4. Max purchase
5. Rollovers
6. Multiple feature balances (one off + metered) -- tests apiCusFeature (stuff should be added) and breakdown field
7. Credit system?

View File

@@ -0,0 +1,40 @@
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"), () => {
describe("basic usage deduction", () => {
test("deducts existing usage from new entitlement balance", () => {
const internalFeatureId = "internal_words";
// Setup: Feature "words" with starting balance 5000, existing usage 1500
const cusEnt = createMockCusEntitlement({
internalFeatureId,
featureId: "words",
featureName: "Words",
allowance: 5000, // Starting balance (no related price)
balance: 5000, // Initial balance before applying existing usages
});
const cusProduct = createMockCusProduct({
cusEntitlements: [cusEnt],
});
const existingUsages: ExistingUsages = {
[internalFeatureId]: { usage: 1500, entityUsages: {} },
};
// Act
applyExistingUsages({ cusProduct, existingUsages, entities: [] });
// Assert: balance should be 5000 - 1500 = 3500
const updatedCusEnt = cusProduct.customer_entitlements.find(
(ce) => ce.feature_id === "words",
);
expect(updatedCusEnt?.balance).toBe(3500);
});
});
});

View File

@@ -0,0 +1,186 @@
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 { createMockEntity } from "@tests/utils/mockUtils/entityMocks";
import chalk from "chalk";
import { applyExistingUsages } from "@/internal/billing/billingUtils/handleExistingUsages/applyExistingUsages";
describe(
chalk.yellowBright("applyExistingUsages (testing entities flow)"),
() => {
describe("entities merge with existing usages", () => {
test("empty existing usages, 2 entities on feature A and 3 entities on feature B", () => {
const internalFeatureIdA = "internal_feature_a";
const internalFeatureIdB = "internal_feature_b";
const cusEntA = createMockCusEntitlement({
internalFeatureId: internalFeatureIdA,
featureId: "feature_a",
featureName: "Feature A",
allowance: 10,
balance: 10,
});
const cusEntB = createMockCusEntitlement({
internalFeatureId: internalFeatureIdB,
featureId: "feature_b",
featureName: "Feature B",
allowance: 10,
balance: 10,
});
const cusProduct = createMockCusProduct({
cusEntitlements: [cusEntA, cusEntB],
});
// 2 entities on feature A, 3 entities on feature B
const entities = [
createMockEntity({
id: "ent1",
featureId: "feature_a",
internalFeatureId: internalFeatureIdA,
}),
createMockEntity({
id: "ent2",
featureId: "feature_a",
internalFeatureId: internalFeatureIdA,
}),
createMockEntity({
id: "ent3",
featureId: "feature_b",
internalFeatureId: internalFeatureIdB,
}),
createMockEntity({
id: "ent4",
featureId: "feature_b",
internalFeatureId: internalFeatureIdB,
}),
createMockEntity({
id: "ent5",
featureId: "feature_b",
internalFeatureId: internalFeatureIdB,
}),
];
const existingUsages: ExistingUsages = {};
// Act
applyExistingUsages({ cusProduct, existingUsages, entities });
// Assert: Feature A balance = 10 - 2 = 8, Feature B balance = 10 - 3 = 7
const updatedCusEntA = cusProduct.customer_entitlements.find(
(ce) => ce.feature_id === "feature_a",
);
const updatedCusEntB = cusProduct.customer_entitlements.find(
(ce) => ce.feature_id === "feature_b",
);
expect(updatedCusEntA?.balance).toBe(8);
expect(updatedCusEntB?.balance).toBe(7);
});
test("existing usages has entry for feature A, 2 entities on feature A (entities take priority)", () => {
const internalFeatureIdA = "internal_feature_a";
const cusEntA = createMockCusEntitlement({
internalFeatureId: internalFeatureIdA,
featureId: "feature_a",
featureName: "Feature A",
allowance: 10,
balance: 10,
});
const cusProduct = createMockCusProduct({
cusEntitlements: [cusEntA],
});
// 2 entities on feature A
const entities = [
createMockEntity({
id: "ent1",
featureId: "feature_a",
internalFeatureId: internalFeatureIdA,
}),
createMockEntity({
id: "ent2",
featureId: "feature_a",
internalFeatureId: internalFeatureIdA,
}),
];
// Existing usage says 5, but entities (2) should take priority
const existingUsages: ExistingUsages = {
[internalFeatureIdA]: { usage: 5, entityUsages: {} },
};
// Act
applyExistingUsages({ cusProduct, existingUsages, entities });
// Assert: Entity count (2) takes priority, balance = 10 - 2 = 8
const updatedCusEntA = cusProduct.customer_entitlements.find(
(ce) => ce.feature_id === "feature_a",
);
expect(updatedCusEntA?.balance).toBe(8);
});
test("two cusEnts for feature A with starting balance 2, 3 entities - distributes usage across cusEnts", () => {
const internalFeatureIdA = "internal_feature_a";
// Two cusEnts for the same feature, each with balance 2
const cusEntA1 = createMockCusEntitlement({
internalFeatureId: internalFeatureIdA,
featureId: "feature_a",
featureName: "Feature A",
allowance: 2,
balance: 2,
});
const cusEntA2 = createMockCusEntitlement({
internalFeatureId: internalFeatureIdA,
featureId: "feature_a",
featureName: "Feature A",
allowance: 2,
balance: 2,
});
const cusProduct = createMockCusProduct({
cusEntitlements: [cusEntA1, cusEntA2],
});
// 3 entities on feature A
const entities = [
createMockEntity({
id: "ent1",
featureId: "feature_a",
internalFeatureId: internalFeatureIdA,
}),
createMockEntity({
id: "ent2",
featureId: "feature_a",
internalFeatureId: internalFeatureIdA,
}),
createMockEntity({
id: "ent3",
featureId: "feature_a",
internalFeatureId: internalFeatureIdA,
}),
];
const existingUsages: ExistingUsages = {};
// 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",
);
expect(updatedCusEnts[0]?.balance).toBe(0);
expect(updatedCusEnts[1]?.balance).toBe(1);
});
return;
});
},
);

View File

@@ -119,4 +119,3 @@ describe("get-cycle-end: quarterly intervals", () => {
});
});
});

View File

@@ -1,6 +1,10 @@
import { describe, expect, test } from "bun:test";
import { BillingInterval, getCycleEnd } from "@autumn/shared";
import { DayOfWeek, fromUnix, toUnixWeekly } from "@tests/utils/testIntervalUtils/testUnixUtils";
import {
DayOfWeek,
fromUnix,
toUnixWeekly,
} from "@tests/utils/testIntervalUtils/testUnixUtils";
/**
* January 2025 calendar (Week 1 = first full Mon-Sun week):

View File

@@ -2,6 +2,7 @@ import {
BillingInterval,
cusProductToEnts,
cusProductToPrices,
isConsumablePrice,
isFixedPrice,
type Organization,
priceToInvoiceAmount,
@@ -11,7 +12,6 @@ import type { AppEnv } from "autumn-js";
import { Decimal } from "decimal.js";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { isArrearPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { getPriceEntitlement } from "@/internal/products/prices/priceUtils.js";
import { getSubsFromCusId } from "./expectSubUtils.js";
@@ -65,7 +65,7 @@ export const getExpectedInvoiceTotal = async ({
if (onlyIncludeUsage && isFixedPrice(price)) continue;
if (onlyIncludeArrear && !isArrearPrice({ price })) continue;
if (onlyIncludeArrear && !isConsumablePrice(price)) continue;
const config = price.config as UsagePriceConfig;
const featureId = config.feature_id;

View File

@@ -0,0 +1,43 @@
import { FeatureType, type FullCustomerEntitlement } from "@autumn/shared";
import { createMockEntitlement } from "./entitlementMocks";
export const createMockCusEntitlement = ({
featureId,
internalFeatureId,
featureName,
allowance,
balance,
featureType = FeatureType.Metered,
}: {
featureId: string;
internalFeatureId?: string;
featureName: string;
allowance: number;
balance: number;
featureType?: FeatureType;
}): FullCustomerEntitlement => ({
id: `cus_ent_${featureId}`,
internal_customer_id: "cus_internal",
internal_feature_id: internalFeatureId ?? `internal_${featureId}`,
customer_id: "cus_test",
feature_id: featureId,
customer_product_id: "cus_prod_test",
entitlement_id: `ent_${featureId}`,
created_at: Date.now(),
unlimited: false,
balance,
additional_balance: 0,
usage_allowed: true,
next_reset_at: null,
adjustment: 0,
entities: null,
entitlement: createMockEntitlement({
featureId,
internalFeatureId,
featureName,
allowance,
featureType,
}),
replaceables: [],
rollovers: [],
});

View File

@@ -0,0 +1,40 @@
import {
CollectionMethod,
CusProductStatus,
type FullCusProduct,
type FullCustomerEntitlement,
} from "@autumn/shared";
import { createMockProduct } from "./productMocks";
export const createMockCusProduct = ({
cusEntitlements,
}: {
cusEntitlements: FullCustomerEntitlement[];
}): FullCusProduct => ({
id: "cus_prod_test",
internal_product_id: "prod_internal",
product_id: "prod_test",
internal_customer_id: "cus_internal",
customer_id: "cus_test",
internal_entity_id: null,
entity_id: null,
created_at: Date.now(),
status: CusProductStatus.Active,
canceled: false,
starts_at: Date.now(),
trial_ends_at: null,
canceled_at: null,
ended_at: null,
options: [],
free_trial_id: null,
collection_method: CollectionMethod.ChargeAutomatically,
subscription_ids: [],
scheduled_ids: [],
quantity: 1,
api_semver: null,
is_custom: false,
customer_prices: [],
customer_entitlements: cusEntitlements,
product: createMockProduct(),
free_trial: null,
});

View File

@@ -0,0 +1,37 @@
import { AllowanceType, FeatureType } from "@autumn/shared";
import { createMockFeature } from "./featureMocks";
export const createMockEntitlement = ({
featureId,
internalFeatureId,
featureName,
allowance,
featureType = FeatureType.Metered,
}: {
featureId: string;
internalFeatureId?: string;
featureName: string;
allowance: number;
featureType?: FeatureType;
}) => ({
id: `ent_${featureId}`,
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,
carry_from_previous: false,
entity_feature_id: null,
feature_id: featureId,
usage_limit: null,
rollover: null,
feature: createMockFeature({
id: featureId,
internalId: internalFeatureId,
name: featureName,
type: featureType,
}),
});

View File

@@ -0,0 +1,25 @@
import { AppEnv, type Entity } from "@autumn/shared";
export const createMockEntity = ({
id,
featureId,
internalFeatureId,
name,
}: {
id: string;
featureId: string;
internalFeatureId?: string;
name?: string;
}): Entity => ({
id,
org_id: "org_test",
created_at: Date.now(),
internal_id: `internal_${id}`,
internal_customer_id: "cus_internal",
env: AppEnv.Sandbox,
name: name ?? id,
deleted: false,
feature_id: featureId,
internal_feature_id: internalFeatureId ?? `internal_${featureId}`,
});

View File

@@ -0,0 +1,25 @@
import { AppEnv, FeatureType } from "@autumn/shared";
export const createMockFeature = ({
id,
internalId,
name,
type = FeatureType.Metered,
}: {
id: string;
internalId?: string;
name: string;
type?: FeatureType;
}) => ({
internal_id: internalId ?? `internal_${id}`,
org_id: "org_test",
created_at: Date.now(),
env: AppEnv.Sandbox,
id,
name,
type,
config: {},
display: null,
archived: false,
event_names: [],
});

View File

@@ -0,0 +1,19 @@
import { AppEnv } from "@autumn/shared";
export const createMockProduct = () => ({
id: "prod_test",
name: "Test Product",
description: null,
is_add_on: false,
is_default: false,
version: 1,
group: "test_group",
env: AppEnv.Sandbox,
internal_id: "prod_internal",
org_id: "org_test",
created_at: Date.now(),
processor: null,
base_variant_id: null,
archived: false,
});

View File

@@ -99,7 +99,8 @@ export * from "./api/events/insights/query/insightsQueryBody.js";
// Attach Function Response
export * from "./models/attachModels/attachFunctionResponse.js";
export * from "./models/billingModels/cusProductActions.js";
export * from "./models/billingModels/insertFullCusProductContext.js";
export * from "./models/billingModels/existingUsages.js";
export * from "./models/billingModels/initFullCusProductContext.js";
export * from "./models/billingModels/invoicingModels/lineItem.js";
// Billing Models
export * from "./models/billingModels/newProductAction.js";
@@ -174,7 +175,6 @@ export * from "./models/subModels/subTable.js";
// Billing utils
export * from "./utils/billingUtils/index";
// Utils
export * from "./utils/common/formatUtils/formatInterval.js";
export * from "./utils/displayUtils.js";
export * from "./utils/index.js";
export * from "./utils/intervalUtils.js";

View File

@@ -0,0 +1,11 @@
import { z } from "zod";
export const ExistingUsagesSchema = z.record(
z.string(),
z.object({
usage: z.number(),
entityUsages: z.record(z.string(), z.number()),
}),
);
export type ExistingUsages = z.infer<typeof ExistingUsagesSchema>;

View File

@@ -8,14 +8,14 @@ import type {
import type { FeatureOptions } from "../cusProductModels/cusProductModels";
import type { FullProduct } from "../productModels/productModels";
export interface InsertFullCusProductContext {
export interface InitFullCusProductContext {
fullCus: FullCustomer;
product: FullProduct;
featureQuantities: FeatureOptions[];
replaceables: AttachReplaceable[];
}
export interface InsertCusProductOptions {
export interface InitFullCusProductOptions {
subscriptionId?: string;
subscriptionScheduleId?: string;
isCustom?: boolean;

View File

@@ -1,6 +1,11 @@
import type { Feature } from "../../featureModels/featureModels";
import type { Price } from "../../productModels/priceModels/priceModels";
import type { LineItemContext } from "./lineItemContext";
export type LineItem = {
amount: number;
description: string;
price_id: string;
feature_id?: string;
price: Price;
feature?: Feature; // Optional - fixed prices don't have features
context: LineItemContext;
};

View File

@@ -0,0 +1,12 @@
export type BillingPeriod = {
start: number;
end: number;
};
export type LineItemContext = {
productName: string;
billingPeriod: BillingPeriod;
direction: "charge" | "refund";
now: number;
billingTiming: "in_arrear" | "in_advance";
};

View File

@@ -5,8 +5,8 @@ import { UsageTierSchema } from "./usagePriceConfig.js";
export const FixedPriceConfigSchema = z.object({
type: z.string(),
amount: z.number().min(0),
interval: z.nativeEnum(BillingInterval),
interval_count: z.number().nullish(),
interval: z.enum(BillingInterval),
interval_count: z.number().optional(),
// Usage price fields
billing_units: z.number().nullish(),

View File

@@ -23,8 +23,8 @@ export const UsagePriceConfigSchema = z.object({
internal_feature_id: z.string(),
feature_id: z.string(),
usage_tiers: z.array(UsageTierSchema),
interval: z.nativeEnum(BillingInterval),
interval_count: z.number().nullish(),
interval: z.enum(BillingInterval),
interval_count: z.number().optional(),
// For usage in arrear
stripe_meter_id: z.string().nullish(),

View File

@@ -26,12 +26,10 @@ export const getCycleEnd = ({
anchor: number;
interval: BillingInterval | EntInterval;
intervalCount?: number;
now?: number;
now: number; // milliseconds since epoch
}): number => {
// EDGE CASE: anchor might be slightly before now due to network latency.
now = now ?? Date.now();
const anchorDate = new UTCDate(anchor);
const nowDate = new UTCDate(now);

View File

@@ -25,10 +25,8 @@ export const getCycleStart = ({
anchor: number;
interval: BillingInterval | EntInterval;
intervalCount?: number;
now?: number;
now: number; // milliseconds since epoch
}): number => {
now = now ?? Date.now();
const anchorDate = new UTCDate(anchor);
const nowDate = new UTCDate(now);

View File

@@ -0,0 +1,31 @@
import type { BillingPeriod } from "../../../models/billingModels/invoicingModels/lineItemContext";
import type { Price } from "../../../models/productModels/priceModels/priceModels";
import { getCycleEnd } from "./getCycleEnd";
import { getCycleStart } from "./getCycleStart";
export const getLineItemBillingPeriod = ({
anchor,
price,
now,
}: {
anchor: number;
price: Price;
now: number;
}): BillingPeriod => {
const { interval, interval_count: intervalCount } = price.config;
return {
start: getCycleStart({
anchor,
interval: price.config.interval,
intervalCount: price.config.interval_count,
now,
}),
end: getCycleEnd({
anchor,
interval,
intervalCount,
now,
}),
};
};

View File

@@ -1,6 +1,10 @@
export * from "./cycleUtils/getCycleEnd";
export * from "./cycleUtils/getCycleStart";
export * from "./intervalUtils/intervalArithmetic";
export * from "./invoicingUtils/lineItemBuilders/consumableToLineItem";
export * from "./invoicingUtils/cusProductToArrearLineItems";
export * from "./invoicingUtils/cusProductToLineItems";
export * from "./invoicingUtils/lineItemBuilders/consumablePriceToLineItem";
export * from "./invoicingUtils/lineItemUtils/priceToLineAmount";
export * from "./invoicingUtils/lineItemUtils/tiersToLineAmount";

View File

@@ -0,0 +1,66 @@
import type { LineItem } from "../../../models/billingModels/invoicingModels/lineItem";
import type { LineItemContext } from "../../../models/billingModels/invoicingModels/lineItemContext";
import type { FullCusProduct } from "../../../models/cusProductModels/cusProductModels";
import { cusPriceToCusEntWithCusProduct } from "../../cusPriceUtils/convertCusPriceUtils";
import { isConsumablePrice } from "../../productUtils/priceUtils/classifyPriceUtils";
import { getLineItemBillingPeriod } from "../cycleUtils/getLineItemBillingPeriod";
import { consumablePriceToLineItem } from "./lineItemBuilders/consumablePriceToLineItem";
export const cusProductToArrearLineItems = ({
cusProduct,
billingCycleAnchor,
testClockFrozenTime,
}: {
cusProduct: FullCusProduct;
billingCycleAnchor: number;
testClockFrozenTime?: number;
}) => {
const lineItems: LineItem[] = [];
const productName = cusProduct.product.name;
const now = testClockFrozenTime ?? Date.now();
for (const cusPrice of cusProduct.customer_prices) {
const price = cusPrice.price;
if (!isConsumablePrice(price)) continue;
// Calculate billing period
const billingPeriod = getLineItemBillingPeriod({
anchor: billingCycleAnchor,
price,
now,
});
const cusEnt = cusPriceToCusEntWithCusProduct({
cusProduct,
cusPrice,
cusEnts: cusProduct.customer_entitlements,
});
if (!cusEnt) {
throw new Error(
`[cusProductToConsumableLineItems] No cusEnt found for cusPrice: ${cusPrice.id}`,
);
}
const context: LineItemContext = {
productName,
billingPeriod,
direction: "charge",
billingTiming: "in_arrear",
now,
};
lineItems.push(consumablePriceToLineItem({ cusEnt, context }));
}
console.log(
`arrear line items: `,
lineItems.map((item) => ({
amount: item.amount,
description: item.description,
})),
);
return lineItems;
};

View File

@@ -0,0 +1,154 @@
import type { LineItem } from "../../../models/billingModels/invoicingModels/lineItem";
import type { LineItemContext } from "../../../models/billingModels/invoicingModels/lineItemContext";
import type { FullCusProduct } from "../../../models/cusProductModels/cusProductModels";
import { addCusProductToCusEnt } from "../../cusEntUtils/cusEntUtils";
import { cusPriceToCusEnt } from "../../cusPriceUtils/convertCusPriceUtils";
import { isPrepaidPrice } from "../../productUtils/priceUtils";
import {
isAllocatedPrice,
isFixedPrice,
} from "../../productUtils/priceUtils/classifyPriceUtils";
import { getLineItemBillingPeriod } from "../cycleUtils/getLineItemBillingPeriod";
import { consumablePriceToLineItem } from "./lineItemBuilders/consumablePriceToLineItem";
import { fixedPriceToLineItem } from "./lineItemBuilders/fixedPriceToLineItem";
import { prepaidPriceToLineItem } from "./lineItemBuilders/prepaidPriceToLineItem";
// TODO: import these once implemented
// import { prepaidPriceToLineItem } from "./lineItemBuilders/prepaidPriceToLineItem";
// import { allocatedPriceToLineItem } from "./lineItemBuilders/allocatedPriceToLineItem";
export type LineItemDirection = "charge" | "refund";
/**
* Generates line items for a customer product.
* - "charge" direction: positive amounts (for NEW product)
* - "credit" direction: negative amounts with "Unused" prefix (for OLD product)
*
* NOTE: Consumable (UsageInArrear) prices are NOT included - they're always
* positive charges for past usage and handled separately.
*/
export const cusProductToLineItems = ({
cusProduct,
testClockFrozenTime,
billingCycleAnchor,
direction,
}: {
cusProduct: FullCusProduct;
testClockFrozenTime?: number;
billingCycleAnchor: number;
direction: "charge" | "refund";
}): LineItem[] => {
const lineItems: LineItem[] = [];
const productName = cusProduct.product.name;
const now = testClockFrozenTime ?? Date.now();
for (const cusPrice of cusProduct.customer_prices) {
const price = cusPrice.price;
const { interval, interval_count: intervalCount } = price.config;
// Calculate billing period
const billingPeriod = getLineItemBillingPeriod({
anchor: billingCycleAnchor,
price,
now,
});
// Build line item context
const context: LineItemContext = {
productName,
billingPeriod,
direction,
billingTiming: "in_advance",
now,
};
if (isFixedPrice(price)) {
lineItems.push(
fixedPriceToLineItem({
price,
context,
quantity: cusProduct.quantity ?? 1,
}),
);
continue;
}
const cusEnt = cusPriceToCusEnt({
cusPrice,
cusEnts: cusProduct.customer_entitlements,
});
if (!cusEnt) {
throw new Error(
`[cusProductToLineItems] No cusEnt found for cusPrice: ${cusPrice.id}`,
);
}
const cusEntWithCusProduct = addCusProductToCusEnt({
cusEnt,
cusProduct,
});
if (isPrepaidPrice({ price })) {
lineItems.push(
prepaidPriceToLineItem({
cusEnt: cusEntWithCusProduct,
context,
}),
);
}
if (isAllocatedPrice(price)) {
lineItems.push(
consumablePriceToLineItem({
cusEnt: cusEntWithCusProduct,
context,
}),
);
}
// if (isFixedPrice(price)) {
// item = fixedPriceToLineItem({
// price,
// productName,
// currency,
// billingPeriod,
// now,
// quantity: cusProduct.quantity ?? 1,
// });
// }
// TODO: Add prepaid and allocated once implemented
// if (isPrepaidPrice(price)) {
// const cusEnt = findCusEntForPrice({ cusProduct, price });
// const overage = cusEntToTotalOverage({ cusEnt });
// item = prepaidPriceToLineItem({ price, overage, billingPeriod });
// }
// if (isAllocatedPrice(price)) {
// const cusEnt = findCusEntForPrice({ cusProduct, price });
// const quantity = cusEnt?.balance ?? 0;
// item = allocatedPriceToLineItem({ price, quantity, billingPeriod });
// }
// if (item) {
// // Negate amount for credits (OLD product)
// if (direction === "credit") {
// item = lineItemToCredit(item);
// }
// lineItems.push(item);
// }
}
console.log(
"Line items:",
lineItems.map((item) => ({
amount: item.amount,
description: item.description,
})),
);
return lineItems;
};

View File

@@ -1,5 +1,6 @@
import type { Feature } from "../../../../models/featureModels/featureModels";
import { getSingularAndPlural, numberWithCommas } from "../../../displayUtils";
import { roundUsageToNearestBillingUnit } from "../lineItemUtils/roundUsageToNearestBillingUnit";
/**
* Generates base usage description for a feature.
@@ -8,13 +9,29 @@ import { getSingularAndPlural, numberWithCommas } from "../../../displayUtils";
export const featureUsageToDescription = ({
feature,
usage,
billingUnits,
}: {
feature: Feature;
usage: number;
billingUnits: number;
}): string => {
const { singular, plural } = getSingularAndPlural({ feature });
const usageStr = numberWithCommas(Math.ceil(usage));
// Ceil usage to nearest billing unit
const roundedUsage = roundUsageToNearestBillingUnit({
usage,
billingUnits,
});
const usageStr = numberWithCommas(roundedUsage);
// 1. If billing units is greater than 1, use plural
const featureName = usage === 1 ? singular : plural;
// billingUnits > 1 ? plural :
return `${usageStr} ${featureName}`;
// if (billingUnits === 1) {
// } else {
// return `${usageStr} x ${billingUnits} ${featureName}`;
// }
};

View File

@@ -1,28 +1,35 @@
import { BillingInterval } from "../../../../models/productModels/intervals/billingInterval";
import type { LineItemContext } from "../../../../models/billingModels/invoicingModels/lineItemContext";
import type { FixedPriceConfig } from "../../../../models/productModels/priceModels/priceConfig/fixedPriceConfig";
import type { Price } from "../../../../models/productModels/priceModels/priceModels";
import { formatAmount } from "../../../common/formatUtils/formatAmount";
import { formatInterval } from "../../../common/formatUtils/formatInterval";
import { isOneOffPrice } from "../../../productUtils/priceUtils/classifyPriceUtils";
import { lineItemToPeriodDescription } from "./lineItemToPeriodDescription";
export const fixedPriceToDescription = ({
price,
currency,
context,
}: {
price: Price; // must be fixed price
currency?: string;
context: LineItemContext;
}): string => {
const config = price.config as FixedPriceConfig;
const { productName } = context;
// biome-ignore lint/correctness/noUnusedVariables: Might be used in the future
const amount = formatAmount({ currency, amount: config.amount });
if (config.interval === BillingInterval.OneOff) {
return amount;
let description = `${productName} - Base Price`;
if (isOneOffPrice(price)) {
const periodDescription = lineItemToPeriodDescription({
context,
});
description = `${description} (${periodDescription})`;
}
const intervalStr = formatInterval({
interval: config.interval,
intervalCount: config.interval_count || 1,
prefix: "",
});
return `${amount} / ${intervalStr}`; // "$10 / month"
return description;
};

View File

@@ -0,0 +1,22 @@
import { isSameDay } from "date-fns";
import type { LineItemContext } from "../../../../models/billingModels/invoicingModels/lineItemContext";
import { formatMs, formatMsToDate } from "../../../common/formatUtils";
export const lineItemToPeriodDescription = ({
context,
}: {
context: LineItemContext;
}): string => {
const { now, billingPeriod, billingTiming } = context;
// In-arrear: billing for past usage (start → now)
// In-advance: billing for future usage (now → end)
const periodStart = billingTiming === "in_arrear" ? billingPeriod.start : now;
const periodEnd = billingTiming === "in_arrear" ? now : billingPeriod.end;
if (isSameDay(periodStart, periodEnd)) {
return `from ${formatMs(periodStart, { excludeSeconds: true })} to ${formatMs(periodEnd, { excludeSeconds: true })}`;
}
return `from ${formatMsToDate(periodStart)} to ${formatMsToDate(periodEnd)}`;
};

View File

@@ -0,0 +1,44 @@
import type { LineItemContext } from "../../../../models/billingModels/invoicingModels/lineItemContext";
import type { Feature } from "../../../../models/featureModels/featureModels";
import type { Price } from "../../../../models/productModels/priceModels/priceModels";
import { isOneOffPrice } from "../../../productUtils/priceUtils/classifyPriceUtils";
import { featureUsageToDescription } from "./featureUsageToDescription";
import { lineItemToPeriodDescription } from "./lineItemToPeriodDescription";
export const usagePriceToLineDescription = ({
price,
feature,
usage,
context,
}: {
price: Price;
feature: Feature;
usage: number;
context: LineItemContext;
}): string => {
const billingUnits = price.config.billing_units ?? 1;
// 1. Get feature usage description (eg. "3 x 150 credits")
const featureUsageDescription = featureUsageToDescription({
feature,
usage,
billingUnits,
});
const { productName } = context;
let description = `${productName} - ${featureUsageDescription}`;
if (!isOneOffPrice(price)) {
const periodDescription = lineItemToPeriodDescription({
context,
});
description = `${description} (${periodDescription})`;
}
// if (billingPeriod) {
// description = `${description} (${billingPeriodToDescription(billingPeriod)})`;
// }
return description;
};

View File

@@ -0,0 +1,55 @@
import { InternalError } from "../../../../api/errors/base/InternalError";
import type { LineItemContext } from "../../../../models/billingModels/invoicingModels/lineItemContext";
import type { FullCusEntWithFullCusProduct } from "../../../../models/cusProductModels/cusEntModels/cusEntWithProduct";
import { cusEntToInvoiceOverage } from "../../../cusEntUtils/overageUtils/cusEntToInvoiceOverage";
import { cusEntToInvoiceUsage } from "../../../cusEntUtils/overageUtils/cusEntToInvoiceUsage";
import { cusEntToCusPrice } from "../../../productUtils/convertUtils";
import { usagePriceToLineDescription } from "../descriptionUtils/usagePriceToLineDescription";
import { priceToLineAmount } from "../lineItemUtils/priceToLineAmount";
// import { usagePriceToLineDescription } from "../descriptionUtils/usagePriceToLineDescription";
/**
* Creates a line item for a consumable (UsageInArrear) price.
* Returns null if there's no overage to charge.
*/
export const consumablePriceToLineItem = ({
cusEnt,
context,
}: {
cusEnt: FullCusEntWithFullCusProduct;
context: LineItemContext;
}) => {
// 1. Get cus price
const cusPrice = cusEntToCusPrice({ cusEnt });
if (!cusPrice) {
throw new InternalError({
message: `[consumablePriceToLineItem] No cus price found for cus ent (feature: ${cusEnt.entitlement.feature_id})`,
});
}
// 1. Get usage / overage
const invoiceUsage = cusEntToInvoiceUsage({ cusEnt });
const invoiceOverage = cusEntToInvoiceOverage({ cusEnt });
// 2. Get amount
const amount = priceToLineAmount({
price: cusPrice.price,
overage: invoiceOverage,
});
// 4. Generate description
const description = usagePriceToLineDescription({
price: cusPrice.price,
feature: cusEnt.entitlement.feature,
usage: invoiceUsage,
context,
});
return {
amount,
description,
price: cusPrice.price,
context,
};
};

View File

@@ -1,50 +0,0 @@
import type { LineItem } from "../../../../models/billingModels/invoicingModels/lineItem";
import type { FullCusEntWithFullCusProduct } from "../../../../models/cusProductModels/cusEntModels/cusEntWithProduct";
import { cusEntToTotalOverage } from "../../../cusEntUtils/overageUtils/cusEntsToTotalOverage";
import { getFeatureInvoiceDescription } from "../../../displayUtils";
import { cusEntToCusPrice } from "../../../productUtils/convertUtils";
import { tiersToLineAmount } from "../lineItemUtils/tiersToLineAmount";
/**
* Creates a line item for a consumable (UsageInArrear) price.
* Returns null if there's no overage to charge.
*/
export const consumableToLineItem = ({
cusEnt,
}: {
cusEnt: FullCusEntWithFullCusProduct;
}): LineItem | null => {
// 1. Get the related price
const cusPrice = cusEntToCusPrice({ cusEnt });
if (!cusPrice) return null;
const price = cusPrice.price;
const billingUnits = price.config.billing_units ?? 1;
// 2. Get overage from cusEnt balance
const overage = cusEntToTotalOverage({ cusEnt });
if (overage <= 0) return null;
// 3. Calculate amount using tiers
const amount = tiersToLineAmount({ price, overage, billingUnits });
// 4. Calculate total usage for description
const allowance = cusEnt.entitlement.allowance ?? 0;
const balance = cusEnt.balance ?? 0;
const usage = allowance - balance;
// 5. Generate description
const description = getFeatureInvoiceDescription({
feature: cusEnt.entitlement.feature,
usage,
billingUnits,
prodName: cusEnt.customer_product.product.name,
});
return {
amount,
description,
price_id: price.id,
feature_id: cusEnt.entitlement.feature_id,
};
};

View File

@@ -0,0 +1,51 @@
import type { LineItem } from "../../../../models/billingModels/invoicingModels/lineItem";
import type { LineItemContext } from "../../../../models/billingModels/invoicingModels/lineItemContext";
import type { Price } from "../../../../models/productModels/priceModels/priceModels";
import { fixedPriceToDescription } from "../descriptionUtils/fixedPriceToLineDescription";
import { priceToLineAmount } from "../lineItemUtils/priceToLineAmount";
import { applyProration } from "../prorationUtils/applyProration";
/**
* Creates a line item for a fixed price.
* Returns positive amount - caller uses lineItemToCredit() for refunds.
*/
export const fixedPriceToLineItem = ({
price,
currency,
quantity = 1,
context,
}: {
price: Price;
currency?: string;
quantity?: number;
context: LineItemContext;
}): LineItem => {
// 1. Calculate base amount
let amount = priceToLineAmount({ price, multiplier: quantity });
// 2. Apply proration
const { now, billingPeriod } = context;
amount = applyProration({
now,
amount,
billingPeriod: billingPeriod,
});
if (context.direction === "refund") {
amount = -amount;
}
// 3. Generate description
const description = fixedPriceToDescription({
price,
currency,
context,
});
return {
amount,
description,
price,
context,
};
};

View File

@@ -0,0 +1,66 @@
import { InternalError } from "../../../../api/errors";
import type { LineItem } from "../../../../models/billingModels/invoicingModels/lineItem";
import type { LineItemContext } from "../../../../models/billingModels/invoicingModels/lineItemContext";
import type { FullCusEntWithFullCusProduct } from "../../../../models/cusProductModels/cusEntModels/cusEntWithProduct";
import { cusEntToPrepaidQuantity } from "../../../cusEntUtils/balanceUtils/cusEntToPrepaidQuantity";
import { cusEntToCusPrice } from "../../../productUtils/convertUtils";
import { usagePriceToLineDescription } from "../descriptionUtils/usagePriceToLineDescription";
import { priceToLineAmount } from "../lineItemUtils/priceToLineAmount";
import { applyProration } from "../prorationUtils/applyProration";
/**
* Creates a line item for a fixed price.
* Returns positive amount - caller uses lineItemToCredit() for refunds.
*/
export const prepaidPriceToLineItem = ({
cusEnt,
context,
}: {
cusEnt: FullCusEntWithFullCusProduct;
context: LineItemContext;
}): LineItem => {
const { now, billingPeriod } = context;
const cusPrice = cusEntToCusPrice({ cusEnt });
if (!cusPrice) {
throw new InternalError({
message: `[prepaidPriceToLineItem] No cus price found for cus ent (feature: ${cusEnt.entitlement.feature_id})`,
});
}
// 1. Get prepaid quantity
const prepaidQuantity = cusEntToPrepaidQuantity({ cusEnt });
// 2. Get amount
let amount = priceToLineAmount({
price: cusPrice.price,
overage: prepaidQuantity,
});
if (context.direction === "refund") {
amount = -amount;
}
// 3. Apply proration
amount = applyProration({
now,
billingPeriod,
amount,
});
// 4. Generate description
const description = usagePriceToLineDescription({
price: cusPrice.price,
feature: cusEnt.entitlement.feature,
usage: prepaidQuantity,
context,
});
return {
amount,
description,
price: cusPrice.price,
context,
};
};

View File

@@ -0,0 +1,8 @@
import type { LineItem } from "../../../../models/billingModels/invoicingModels/lineItem";
// Helper function - lives in lineItemUtils
export const lineItemToCredit = (item: LineItem): LineItem => ({
...item,
amount: -item.amount,
description: `Unused ${item.description}`,
});

View File

@@ -0,0 +1,15 @@
import { Decimal } from "decimal.js";
export const roundUsageToNearestBillingUnit = ({
usage,
billingUnits,
}: {
usage: number;
billingUnits: number;
}): number => {
return new Decimal(usage)
.div(billingUnits)
.ceil()
.mul(billingUnits)
.toNumber();
};

View File

@@ -2,6 +2,7 @@ import { Decimal } from "decimal.js";
import type { Price } from "../../../../models/productModels/priceModels/priceModels";
import { Infinite } from "../../../../models/productModels/productEnums";
import { nullish } from "../../../utils";
import { roundUsageToNearestBillingUnit } from "./roundUsageToNearestBillingUnit";
export const tiersToLineAmount = ({
price,
@@ -12,12 +13,10 @@ export const tiersToLineAmount = ({
overage: number;
billingUnits?: number;
}): number => {
// Round up to billing units
const roundedOverage = new Decimal(overage)
.div(billingUnits)
.ceil()
.mul(billingUnits)
.toNumber();
const roundedOverage = roundUsageToNearestBillingUnit({
usage: overage,
billingUnits,
});
let amount = new Decimal(0);
let remaining = new Decimal(roundedOverage);

View File

@@ -0,0 +1,20 @@
import { Decimal } from "decimal.js";
import type { BillingPeriod } from "../../../../models/billingModels/invoicingModels/lineItemContext";
export const applyProration = ({
now,
billingPeriod,
amount,
}: {
now: number;
billingPeriod: BillingPeriod;
amount: number;
}) => {
const { start, end } = billingPeriod;
const denom = new Decimal(end).minus(start);
const num = new Decimal(now).minus(start);
return num.div(denom).mul(amount).toNumber();
};

View File

@@ -1,16 +1,32 @@
import { format } from "date-fns/format";
export const formatMs = (
export const formatMsToDate = (
unixDate?: number | null,
options?: { withTimezone?: boolean },
) => {
if (!unixDate) {
return "undefined unix date";
}
return format(
new Date(unixDate),
options?.withTimezone ? "dd MMM yyyy HH:mm:ss z" : "dd MMM yyyy HH:mm:ss",
);
return format(new Date(unixDate), "dd MMM yyyy");
};
export const formatMs = (
unixDate?: number | null,
options?: { withTimezone?: boolean; excludeSeconds?: boolean },
) => {
if (!unixDate) {
return "undefined unix date";
}
let formatString = options?.excludeSeconds
? "dd MMM yyyy HH:mm"
: "dd MMM yyyy HH:mm:ss";
if (options?.withTimezone) {
formatString = `${formatString} z`;
}
return format(new Date(unixDate), formatString);
};
/**

View File

@@ -1,62 +0,0 @@
import { Decimal } from "decimal.js";
import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js";
import { BillingType } from "../../../models/productModels/priceModels/priceEnums.js";
import {
cusEntToCusPrice,
entToOptions,
} from "../../productUtils/convertUtils.js";
import { getBillingType } from "../../productUtils/priceUtils.js";
import { nullish } from "../../utils.js";
import { getCusEntBalance } from "../balanceUtils.js";
export const cusEntToPurchasedBalance = ({
cusEnt,
entityId,
}: {
cusEnt: FullCusEntWithFullCusProduct;
entityId?: string;
}) => {
// return 0;
// 1. If prepaid
const cusPrice = cusEntToCusPrice({ cusEnt });
if (nullish(cusPrice)) {
const { balance } = getCusEntBalance({
cusEnt,
entityId,
});
return Math.max(0, -balance);
}
const billingType = getBillingType(cusPrice.price.config);
const billingUnits = cusPrice.price.config.billing_units || 1;
if (billingType === BillingType.UsageInAdvance) {
// Purchased balance is how much was prepaid
const cusProduct = cusEnt.customer_product;
const options = entToOptions({
ent: cusEnt.entitlement,
options: cusProduct.options,
});
const quantity = options?.quantity || 0;
const quantityWithBillingUnits = new Decimal(quantity)
.mul(billingUnits)
.toNumber();
// Add negative cus ent balance too
const { balance } = getCusEntBalance({
cusEnt,
entityId,
});
return quantityWithBillingUnits + Math.max(0, -balance);
}
const { balance } = getCusEntBalance({
cusEnt,
entityId,
});
return Math.max(0, -balance);
};

View File

@@ -0,0 +1,72 @@
import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js";
import { BillingType } from "../../../models/productModels/priceModels/priceEnums.js";
import { cusEntToCusPrice } from "../../productUtils/convertUtils.js";
import { getBillingType } from "../../productUtils/priceUtils.js";
import { nullish, sumValues } from "../../utils.js";
import { getCusEntBalance } from "../balanceUtils.js";
import { cusEntToPrepaidQuantity } from "./cusEntToPrepaidQuantity.js";
export const cusEntsToPurchasedBalance = ({
cusEnts,
entityId,
}: {
cusEnts: FullCusEntWithFullCusProduct[];
entityId?: string;
}) => {
// return 0;
// 1. If prepaid
const getPurchasedBalance = ({
cusEnt,
entityId,
}: {
cusEnt: FullCusEntWithFullCusProduct;
entityId?: string;
}) => {
const cusPrice = cusEntToCusPrice({ cusEnt });
if (nullish(cusPrice)) {
const { balance } = getCusEntBalance({
cusEnt,
entityId,
});
return Math.max(0, -balance);
}
const billingType = getBillingType(cusPrice.price.config);
if (billingType === BillingType.UsageInAdvance) {
const prepaidQuantity = cusEntToPrepaidQuantity({ cusEnt });
// Add negative cus ent balance too
const { balance } = getCusEntBalance({
cusEnt,
entityId,
});
return prepaidQuantity + Math.max(0, -balance);
}
const { balance } = getCusEntBalance({
cusEnt,
entityId,
});
return Math.max(0, -balance);
};
return sumValues(
cusEnts.map((cusEnt) => getPurchasedBalance({ cusEnt, entityId })),
);
};
// // Purchased balance is how much was prepaid
// const cusProduct = cusEnt.customer_product;
// const options = entToOptions({
// ent: cusEnt.entitlement,
// options: cusProduct.options,
// });
// const quantity = options?.quantity || 0;
// const quantityWithBillingUnits = new Decimal(quantity)
// .mul(billingUnits)
// .toNumber();

View File

@@ -0,0 +1,24 @@
import { Decimal } from "decimal.js";
import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct";
import { cusEntsToBalance } from "./cusEntsToBalance";
import { cusEntsToPurchasedBalance } from "./cusEntsToPurchasedBalance";
import { cusEntsToGrantedBalance } from "./grantedBalanceUtils/cusEntsToGrantedBalance";
export const cusEntsToUsage = ({
cusEnts,
entityId,
}: {
cusEnts: FullCusEntWithFullCusProduct[];
entityId?: string;
}) => {
const grantedBalance = cusEntsToGrantedBalance({ cusEnts, entityId });
const purchasedBalance = cusEntsToPurchasedBalance({ cusEnts, entityId });
const currentBalance = cusEntsToBalance({ cusEnts, entityId });
return new Decimal(grantedBalance)
.add(purchasedBalance)
.sub(currentBalance)
.toNumber();
};

View File

@@ -1,14 +1,11 @@
import type { 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";
import { cusEntToCusPrice } from "../productUtils/convertUtils";
import { notNullish } from "../utils";
export const isUnlimitedCusEnt = ({
cusEnt,
}: {
cusEnt: FullCustomerEntitlement;
}) => {
export const isUnlimitedCusEnt = (cusEnt: FullCustomerEntitlement) => {
return cusEnt.entitlement.allowance_type === AllowanceType.Unlimited;
};
@@ -30,3 +27,11 @@ export const cusEntsHavePrice = ({
return notNullish(cusPrice);
});
};
export const isBooleanCusEnt = ({
cusEnt,
}: {
cusEnt: FullCustomerEntitlement;
}) => {
return cusEnt.entitlement.feature.type === FeatureType.Boolean;
};

View File

@@ -2,6 +2,7 @@ import type { FullCustomerEntitlement } from "@models/cusProductModels/cusEntMod
import type { PgDeductionUpdate } from "../../api/balances/track/trackTypes/pgDeductionUpdate.js";
import type { FullCustomer } from "../../models/cusModels/fullCusModel.js";
import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js";
import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js";
import { cusEntToCusPrice } from "../productUtils/convertUtils.js";
import { isPrepaidPrice } from "../productUtils/priceUtils.js";
@@ -72,35 +73,6 @@ export const updateCusEntInFullCus = ({
}
};
// export const cusEntMatchesEntity = ({
// cusEnt,
// entity,
// }: {
// cusEnt: FullCusEntWithFullCusProduct;
// entity?: Entity;
// }) => {
// if (!entity) return true;
// let cusProductMatch = true;
// if (notNullish(cusEnt.customer_product?.internal_entity_id)) {
// cusProductMatch =
// cusEnt.customer_product.internal_entity_id === entity.internal_id;
// }
// let entityFeatureIdMatch = true;
// // let feature = features?.find(
// // (f) => f.id == cusEnt.entitlement.entity_feature_id,
// // );
// if (notNullish(cusEnt.entitlement.entity_feature_id)) {
// entityFeatureIdMatch =
// cusEnt.entitlement.entity_feature_id === entity.feature_id;
// }
// return cusProductMatch && entityFeatureIdMatch;
// };
export const isPrepaidCusEnt = ({
cusEnt,
}: {
@@ -120,3 +92,16 @@ export const isPrepaidCusEnt = ({
return true;
};
export const addCusProductToCusEnt = ({
cusEnt,
cusProduct,
}: {
cusEnt: FullCustomerEntitlement;
cusProduct: FullCusProduct;
}): FullCusEntWithFullCusProduct => {
return {
...cusEnt,
customer_product: cusProduct,
};
};

View File

@@ -2,7 +2,7 @@ import { Decimal } from "decimal.js";
import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct";
import { isEntityScopedCusEnt } from "../classifyCusEntUtils";
export const cusEntToTotalOverage = ({
export const cusEntToInvoiceOverage = ({
cusEnt,
}: {
cusEnt: FullCusEntWithFullCusProduct;

View File

@@ -0,0 +1,35 @@
import { Decimal } from "decimal.js";
import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct";
import { cusEntToStartingBalance } from "../balanceUtils/cusEntToStartingBalance";
import { isEntityScopedCusEnt } from "../classifyCusEntUtils";
import { cusEntToInvoiceOverage } from "./cusEntToInvoiceOverage";
export const cusEntToInvoiceUsage = ({
cusEnt,
}: {
cusEnt: FullCusEntWithFullCusProduct;
}) => {
const startingBalance = cusEntToStartingBalance({ cusEnt });
const invoiceOverage = cusEntToInvoiceOverage({ cusEnt });
// 1. If invoice overage > 0:
if (invoiceOverage > 0) {
return new Decimal(startingBalance).add(invoiceOverage).toNumber();
}
// 1. If entity scoped
if (isEntityScopedCusEnt({ cusEnt })) {
let maxUsage = new Decimal(0);
for (const [_, entity] of Object.entries(cusEnt.entities || {})) {
const usage = new Decimal(startingBalance).sub(entity.balance);
maxUsage = Decimal.max(maxUsage, usage);
}
return maxUsage.toNumber();
}
// 2. If not entity scoped
const usage = new Decimal(startingBalance).sub(cusEnt.balance || 0);
return usage.toNumber();
};

View File

@@ -0,0 +1,14 @@
import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct";
import { entToOptions } from "../../productUtils/convertUtils";
export const cusEntToOptions = ({
cusEnt,
}: {
cusEnt: FullCusEntWithFullCusProduct;
}) => {
const ent = cusEnt.entitlement;
return entToOptions({
ent,
options: cusEnt.customer_product.options,
});
};

View File

@@ -0,0 +1,36 @@
import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels";
import type { FullCustomerPrice } from "../../models/cusProductModels/cusPriceModels/cusPriceModels";
import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels";
export const cusPriceToCusEnt = ({
cusPrice,
cusEnts,
}: {
cusPrice: FullCustomerPrice;
cusEnts: FullCustomerEntitlement[];
}) => {
return cusEnts.find(
(ce) => ce.entitlement?.id === cusPrice.price.entitlement_id,
);
};
export const cusPriceToCusEntWithCusProduct = ({
cusProduct,
cusPrice,
cusEnts,
}: {
cusProduct: FullCusProduct;
cusPrice: FullCustomerPrice;
cusEnts: FullCustomerEntitlement[];
}) => {
const cusEnt = cusPriceToCusEnt({ cusPrice, cusEnts });
if (!cusEnt) {
return undefined;
}
return {
...cusEnt,
customer_product: cusProduct,
};
};

View File

@@ -0,0 +1,14 @@
import type { FullCustomerPrice, UsagePriceConfig } from "@autumn/shared";
export const findCusPriceByFeature = ({
internalFeatureId,
cusPrices,
}: {
internalFeatureId: string;
cusPrices: FullCustomerPrice[];
}) => {
return cusPrices.find((cusPrice) => {
const config = cusPrice.price.config as UsagePriceConfig;
return config.internal_feature_id === internalFeatureId;
});
};

View File

@@ -53,6 +53,7 @@ export const cusProductsToCusEnts = ({
inStatuses = [CusProductStatus.Active, CusProductStatus.PastDue],
reverseOrder = false,
featureId,
internalFeatureId,
featureIds,
entity,
sortParams,
@@ -61,6 +62,7 @@ export const cusProductsToCusEnts = ({
inStatuses?: CusProductStatus[];
reverseOrder?: boolean;
featureId?: string;
internalFeatureId?: string;
featureIds?: string[];
entity?: Entity;
sortParams?: SortCusEntParams;
@@ -84,6 +86,12 @@ export const cusProductsToCusEnts = ({
);
}
if (internalFeatureId) {
cusEnts = cusEnts.filter(
(cusEnt) => cusEnt.entitlement.internal_feature_id === internalFeatureId,
);
}
if (featureIds) {
cusEnts = cusEnts.filter((cusEnt) =>
featureIds.includes(cusEnt.entitlement.feature.id),

View File

@@ -9,8 +9,9 @@ export * from "./billingUtils/resolveAttachUtils/resolveScheduledCusProductActio
export * from "./common/formatUtils/index.js";
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/cusEntToPrepaidQuantity.js";
export * from "./cusEntUtils/balanceUtils/cusEntToPurchasedBalance.js";
// Cus ent utils
export * from "./cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAdjustment.js";
export * from "./cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAllowance.js";
@@ -25,6 +26,8 @@ export * from "./cusEntUtils/filterCusEntUtils.js";
export * from "./cusEntUtils/getRolloverFields.js";
export * from "./cusEntUtils/getStartingBalance.js";
export * from "./cusEntUtils/sortCusEntsForDeduction.js";
export * from "./cusPriceUtils/convertCusPriceUtils.js";
export * from "./cusPriceUtils/findCusPriceUtils.js";
// Cus product utils
export * from "./cusProductUtils/classifyCusProduct.js";
export * from "./cusProductUtils/convertCusProduct.js";

View File

@@ -1,11 +1,14 @@
import { Decimal } from "decimal.js";
import { intervalsSame, isConsumablePayPerUsePrice, nullish } from "../..";
import type { Price } from "../../models/productModels/priceModels/priceModels";
import {
compareBillingIntervals,
getLargestInterval,
intervalsSame,
} from "../intervalUtils/priceIntervalUtils";
import { nullish } from "../utils";
import { isFreeProduct } from "./classifyProductUtils";
import { isConsumablePrice } from "./priceUtils/classifyPriceUtils";
export const isProductUpgrade = ({
prices1,
@@ -25,8 +28,8 @@ export const isProductUpgrade = ({
if (!prod1IsFree && prod2IsFree) return false;
if (
prices1.every((p) => isConsumablePayPerUsePrice({ price: p })) &&
prices2.every((p) => isConsumablePayPerUsePrice({ price: p })) &&
prices1.every(isConsumablePrice) &&
prices2.every(isConsumablePrice) &&
usageAlwaysUpgrade
) {
return true;

View File

@@ -1,5 +1,6 @@
import { BillingInterval } from "../../../models/productModels/intervals/billingInterval";
import type { FixedPriceConfig } from "../../../models/productModels/priceModels/priceConfig/fixedPriceConfig";
import type { UsagePriceConfig } from "../../../models/productModels/priceModels/priceConfig/usagePriceConfig";
import { BillingType } from "../../../models/productModels/priceModels/priceEnums";
import type { Price } from "../../../models/productModels/priceModels/priceModels";
import { getBillingType } from "../priceUtils";
@@ -50,8 +51,18 @@ export const isPayPerUsePrice = ({ price }: { price: Price }) => {
);
};
export const isConsumablePayPerUsePrice = ({ price }: { price?: Price }) => {
export const isConsumablePrice = (
price: Price,
): price is Price & { config: UsagePriceConfig } => {
if (!price) return false;
const billingType = getBillingType(price.config);
return billingType === BillingType.UsageInArrear;
};
export const isAllocatedPrice = (
price: Price,
): price is Price & { config: UsagePriceConfig } => {
if (!price) return false;
const billingType = getBillingType(price.config);
return billingType === BillingType.InArrearProrated;
};

View File

@@ -157,7 +157,7 @@ export function BalanceEditSheet() {
const feature = firstEnt.entitlement.feature;
const isUnlimited = selectedCusEnt
? isUnlimitedCusEnt({ cusEnt: selectedCusEnt })
? isUnlimitedCusEnt(selectedCusEnt)
: false;
if (!selectedCusEnt) {

View File

@@ -150,7 +150,7 @@ export function BalanceSelectionSheet() {
</div>
<div className="bg-muted px-1 py-0.5 rounded-md text-t1 w-fit flex items-center gap-1">
{isUnlimitedCusEnt({ cusEnt })
{isUnlimitedCusEnt(cusEnt)
? "Unlimited"
: notNullish(balance)
? new Intl.NumberFormat().format(balance)

View File

@@ -91,93 +91,3 @@ export function IncludedUsage() {
</div>
);
}
// <div>
// <div className="text-form-label block mb-2">Usage Reset</div>
// <Select
// value={item.interval ?? EntInterval.Lifetime}
// onValueChange={handleBillingIntervalSelected}
// >
// <SelectTrigger className="w-full">
// <SelectValue placeholder="Select interval" />
// </SelectTrigger>
// <SelectContent>
// {/* Add EntInterval.Lifetime for "no reset" */}
// <SelectItem value={EntInterval.Lifetime}>
// {formatIntervalText({
// interval: EntInterval.Lifetime,
// intervalCount: item.interval_count || undefined,
// })}
// </SelectItem>
// {/* Add BillingInterval options except OneOff (since we have "no reset") */}
// {Object.values(BillingInterval)
// .filter((interval) => interval !== BillingInterval.OneOff)
// .map((interval) => (
// <SelectItem key={interval} value={interval}>
// {formatIntervalText({
// billingInterval: interval,
// intervalCount: item.interval_count || undefined,
// isBillingInterval: true,
// })}
// </SelectItem>
// ))}
// {/* Custom interval option */}
// <Popover open={open} onOpenChange={setOpen}>
// <PopoverTrigger asChild>
// <Button
// className="w-full justify-start px-2"
// variant="skeleton"
// disabled={
// item.included_usage === Infinite || item.interval == null
// }
// >
// <p className="text-t3">Customise Interval</p>
// </Button>
// </PopoverTrigger>
// <PopoverContent
// align="start"
// className="p-3 w-[200px]"
// sideOffset={-1}
// onOpenAutoFocus={(e) => e.preventDefault()}
// onCloseAutoFocus={(e) => e.preventDefault()}
// >
// <div className="mb-2">
// <FormLabel>Interval Count</FormLabel>
// </div>
// <div className="flex items-center gap-2">
// <Input
// className="flex-1"
// value={intervalCount}
// onChange={(e) => {
// const value = parseInt(e.target.value) || 1;
// setItem({
// ...item,
// interval_count: value,
// });
// }}
// onKeyDown={(e) => {
// if (e.key === "Enter") {
// handleSaveCustomInterval(intervalCount as number);
// }
// if (e.key === "Escape") {
// setOpen(false);
// }
// }}
// />
// <Button
// variant="secondary"
// className="px-4 h-7"
// onClick={() =>
// handleSaveCustomInterval(intervalCount as number)
// }
// >
// Save
// </Button>
// </div>
// </PopoverContent>
// </Popover>
// </SelectContent>
// </Select>
// </div>

View File

@@ -14,7 +14,7 @@ export default defineConfig({
sentryVitePlugin({
org: process.env.VITE_SENTRY_ORG,
project: process.env.VITE_SENTRY_PROJECT,
telemetry: false
telemetry: false,
}),
],
@@ -49,6 +49,8 @@ export default defineConfig({
"zod/v4",
"drizzle-orm/pg-core",
"drizzle-orm",
"@date-fns/utc",
"date-fns",
],
},