wip
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { FullCusEntWithFullCusProduct } from "../../../../../../shared/models/cusProductModels/cusEntModels/cusEntWithProduct";
|
||||
|
||||
export const deductFromCusEnts = async ({
|
||||
cusEnts,
|
||||
amountToDeduct,
|
||||
}: {
|
||||
cusEnts: FullCusEntWithFullCusProduct[];
|
||||
amountToDeduct: number;
|
||||
}) => {};
|
||||
@@ -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(),
|
||||
// };
|
||||
// };
|
||||
@@ -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(),
|
||||
// };
|
||||
// };
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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}`,
|
||||
// );
|
||||
// }
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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,
|
||||
// }),
|
||||
// ]);
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
};
|
||||
@@ -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,
|
||||
})!;
|
||||
|
||||
@@ -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,
|
||||
})!;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
// });
|
||||
// };
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { formatUnixToDate } from "@/utils/genUtils.js";
|
||||
import { Decimal } from "decimal.js";
|
||||
|
||||
export type Proration = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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?
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -119,4 +119,3 @@ describe("get-cycle-end: quarterly intervals", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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;
|
||||
|
||||
43
server/tests/utils/mockUtils/cusEntitlementMocks.ts
Normal file
43
server/tests/utils/mockUtils/cusEntitlementMocks.ts
Normal 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: [],
|
||||
});
|
||||
40
server/tests/utils/mockUtils/cusProductMocks.ts
Normal file
40
server/tests/utils/mockUtils/cusProductMocks.ts
Normal 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,
|
||||
});
|
||||
37
server/tests/utils/mockUtils/entitlementMocks.ts
Normal file
37
server/tests/utils/mockUtils/entitlementMocks.ts
Normal 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,
|
||||
}),
|
||||
});
|
||||
25
server/tests/utils/mockUtils/entityMocks.ts
Normal file
25
server/tests/utils/mockUtils/entityMocks.ts
Normal 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}`,
|
||||
});
|
||||
|
||||
25
server/tests/utils/mockUtils/featureMocks.ts
Normal file
25
server/tests/utils/mockUtils/featureMocks.ts
Normal 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: [],
|
||||
});
|
||||
19
server/tests/utils/mockUtils/productMocks.ts
Normal file
19
server/tests/utils/mockUtils/productMocks.ts
Normal 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,
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
11
shared/models/billingModels/existingUsages.ts
Normal file
11
shared/models/billingModels/existingUsages.ts
Normal 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>;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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}`;
|
||||
// }
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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)}`;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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}`,
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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();
|
||||
24
shared/utils/cusEntUtils/balanceUtils/cusEntsToUsage.ts
Normal file
24
shared/utils/cusEntUtils/balanceUtils/cusEntsToUsage.ts
Normal 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();
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
};
|
||||
14
shared/utils/cusEntUtils/overageUtils/cusEntToOptions.ts
Normal file
14
shared/utils/cusEntUtils/overageUtils/cusEntToOptions.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
36
shared/utils/cusPriceUtils/convertCusPriceUtils.ts
Normal file
36
shared/utils/cusPriceUtils/convertCusPriceUtils.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
14
shared/utils/cusPriceUtils/findCusPriceUtils.ts
Normal file
14
shared/utils/cusPriceUtils/findCusPriceUtils.ts
Normal 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;
|
||||
});
|
||||
};
|
||||
@@ -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),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -157,7 +157,7 @@ export function BalanceEditSheet() {
|
||||
const feature = firstEnt.entitlement.feature;
|
||||
|
||||
const isUnlimited = selectedCusEnt
|
||||
? isUnlimitedCusEnt({ cusEnt: selectedCusEnt })
|
||||
? isUnlimitedCusEnt(selectedCusEnt)
|
||||
: false;
|
||||
|
||||
if (!selectedCusEnt) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user