wip
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
- When writing DB queries, for the `customers`, `products` and `features` tables (and others possibly not mentioned here), the primary key when updating is `internal_id`, not `id`
|
||||
- When using db schemas in Drizzle, import them from '@autumn/shared', and don't do schemas.
|
||||
- Do NOT use "any" type.
|
||||
- **Spell out variable names in full form** - avoid abbreviations in variable/function names. Use `customerProduct` not `cusProduct`, `customerEntitlements` not `cusEnts`, `organization` not `org` (in new code). Clarity over brevity.
|
||||
|
||||
# Testing
|
||||
- When writing tests, ALWAYS read:
|
||||
@@ -21,6 +22,8 @@
|
||||
|
||||
- **ALWAYS import from `zod/v4`**, not from `zod` directly. Example: `import { z } from "zod/v4";`
|
||||
|
||||
- **ALWAYS use named import for Decimal.js**: `import { Decimal } from "decimal.js";` NOT `import Decimal from "decimal.js";`
|
||||
|
||||
- **ALWAYS use `.meta()` for zod-openapi schema registration**, NOT `.openapi()`. Example: `ApiProductSchema.meta({ id: "Product" })`
|
||||
|
||||
## Import Conventions
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
- DO NOT alter .gitignore
|
||||
- JS Doc comments should be SHORT and SWEET. Don't need examples unless ABSOLUTELY necessary
|
||||
- When using db schemas in Drizzle, import them from '@autumn/shared', and don't do schemas.
|
||||
- **Spell out variable names in full form** - avoid abbreviations in variable/function names. Use `customerProduct` not `cusProduct`, `customerEntitlements` not `cusEnts`, `organization` not `org` (in new code). Clarity over brevity.
|
||||
|
||||
# Testing
|
||||
- When writing tests, ALWAYS read:
|
||||
@@ -19,6 +20,8 @@
|
||||
|
||||
- **ALWAYS import from `zod/v4`**, not from `zod` directly. Example: `import { z } from "zod/v4";`
|
||||
|
||||
- **ALWAYS use named import for Decimal.js**: `import { Decimal } from "decimal.js";` NOT `import Decimal from "decimal.js";`
|
||||
|
||||
- Always prefer foo({ bar }) over foo(bar) method signatures - no matter if we are using only one argument or not, object as param are always better, as in the future when wanting to change the order of parameters, or add new ones - its easier.
|
||||
|
||||
- When creating "hooks" folders, don't nest them under "components"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ExistingRollover, FullCusProduct } from "@shared/index";
|
||||
|
||||
export const applyExistingRollovers = ({
|
||||
newCusProduct,
|
||||
customerProduct,
|
||||
existingRollovers,
|
||||
}: {
|
||||
newCusProduct: FullCusProduct;
|
||||
customerProduct: FullCusProduct;
|
||||
existingRollovers: ExistingRollover[];
|
||||
}) => {
|
||||
const getApplicableRollovers = (): ExistingRollover[] => {
|
||||
@@ -16,7 +16,7 @@ export const applyExistingRollovers = ({
|
||||
};
|
||||
|
||||
for (const existingRollover of getApplicableRollovers()) {
|
||||
const targetCusEnt = newCusProduct.customer_entitlements.find(
|
||||
const targetCusEnt = customerProduct.customer_entitlements.find(
|
||||
(cusEnt) =>
|
||||
cusEnt.entitlement.internal_feature_id ===
|
||||
existingRollover.internal_feature_id,
|
||||
|
||||
@@ -8,18 +8,14 @@ import { deductFromCusEntsTypescript } from "../../../balances/track/deductUtils
|
||||
import { mergeEntitiesWithExistingUsages } from "./mergeEntitiesWithExistingUsages";
|
||||
|
||||
export const applyExistingUsages = ({
|
||||
cusProduct,
|
||||
customerProduct,
|
||||
existingUsages = {},
|
||||
entities,
|
||||
}: {
|
||||
cusProduct: FullCusProduct;
|
||||
customerProduct: 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,
|
||||
@@ -29,13 +25,8 @@ export const applyExistingUsages = ({
|
||||
for (const [internalFeatureId, existingUsage] of Object.entries(
|
||||
mergedExistingUsages,
|
||||
)) {
|
||||
// console.log(
|
||||
// `Applying existing usage for feature: ${internalFeatureId}, usage: `,
|
||||
// existingUsage,
|
||||
// );
|
||||
|
||||
const cusEnts = cusProductsToCusEnts({
|
||||
cusProducts: [cusProduct],
|
||||
cusProducts: [customerProduct],
|
||||
internalFeatureId,
|
||||
});
|
||||
|
||||
@@ -57,7 +48,7 @@ export const applyExistingUsages = ({
|
||||
});
|
||||
|
||||
for (const newCusEnt of cusEnts) {
|
||||
const original = cusProduct.customer_entitlements.find(
|
||||
const original = customerProduct.customer_entitlements.find(
|
||||
(ce) => ce.id === newCusEnt.id,
|
||||
);
|
||||
if (original) {
|
||||
@@ -66,19 +57,5 @@ export const applyExistingUsages = ({
|
||||
original.adjustment = newCusEnt.adjustment;
|
||||
}
|
||||
}
|
||||
|
||||
// console.log(
|
||||
// "New cus ents:",
|
||||
// JSON.stringify(
|
||||
// cusProduct.customer_entitlements.map((ce) => ({
|
||||
// feature_id: ce.feature_id,
|
||||
// balance: ce.balance,
|
||||
// entities: ce.entities,
|
||||
// adjustment: ce.adjustment,
|
||||
// })),
|
||||
// null,
|
||||
// 2,
|
||||
// ),
|
||||
// );
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,17 +6,18 @@ import {
|
||||
type InitFullCusProductOptions,
|
||||
notNullish,
|
||||
} from "@autumn/shared";
|
||||
import { generateId } from "../../../../utils/genUtils";
|
||||
|
||||
export const initCusProduct = ({
|
||||
export const initCustomerProduct = ({
|
||||
initContext,
|
||||
initOptions,
|
||||
cusProductId,
|
||||
customerProductId,
|
||||
}: {
|
||||
initContext: InitFullCusProductContext;
|
||||
initOptions?: InitFullCusProductOptions;
|
||||
cusProductId: string;
|
||||
customerProductId?: string;
|
||||
}): CusProduct => {
|
||||
const { fullCus, product, featureQuantities } = initContext;
|
||||
const { fullCustomer, fullProduct, featureQuantities } = initContext;
|
||||
const {
|
||||
subscriptionId,
|
||||
subscriptionScheduleId,
|
||||
@@ -25,8 +26,8 @@ export const initCusProduct = ({
|
||||
apiSemver,
|
||||
} = initOptions ?? {};
|
||||
|
||||
const internalEntityId = fullCus.entity?.internal_id;
|
||||
const entityId = fullCus.entity?.id;
|
||||
const internalEntityId = fullCustomer.entity?.internal_id;
|
||||
const entityId = fullCustomer.entity?.id;
|
||||
|
||||
const status = initOptions?.status ?? CusProductStatus.Active;
|
||||
const startsAt = initOptions?.startsAt ?? Date.now();
|
||||
@@ -41,14 +42,14 @@ export const initCusProduct = ({
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: cusProductId,
|
||||
id: customerProductId ?? generateId("cus_prod"),
|
||||
|
||||
internal_customer_id: fullCus.internal_id,
|
||||
customer_id: fullCus.id,
|
||||
internal_customer_id: fullCustomer.internal_id,
|
||||
customer_id: fullCustomer.id,
|
||||
internal_entity_id: internalEntityId,
|
||||
entity_id: entityId,
|
||||
internal_product_id: product.internal_id,
|
||||
product_id: product.id,
|
||||
internal_product_id: fullProduct.internal_id,
|
||||
product_id: fullProduct.id,
|
||||
|
||||
created_at: Date.now(),
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import type {
|
||||
FullCusProduct,
|
||||
FullCustomer,
|
||||
InitFullCusProductContext,
|
||||
InitFullCusProductOptions,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
||||
import { generateId } from "@/utils/genUtils";
|
||||
import { applyExistingUsages } from "../handleExistingUsages/applyExistingUsages";
|
||||
import { initCusEntitlement } from "./initCusEntitlementV2/initCusEntitlement";
|
||||
import { initCusPrice } from "./initCusPrice";
|
||||
import { initCusProduct } from "./initCusProduct";
|
||||
|
||||
export const initFullCusProduct = ({
|
||||
ctx,
|
||||
fullCus,
|
||||
initContext,
|
||||
initOptions,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
fullCus: FullCustomer;
|
||||
initContext: InitFullCusProductContext;
|
||||
initOptions?: InitFullCusProductOptions;
|
||||
}): FullCusProduct => {
|
||||
const { product } = initContext;
|
||||
|
||||
const cusProductId = generateId("cus_prod");
|
||||
|
||||
const newFullCusEnts = product.entitlements.map((entitlement) => ({
|
||||
...initCusEntitlement({
|
||||
initContext,
|
||||
entitlement,
|
||||
cusProductId,
|
||||
}),
|
||||
entitlement,
|
||||
replaceables: [],
|
||||
rollovers: [],
|
||||
}));
|
||||
|
||||
const newCusPrices = product.prices.map((price) => ({
|
||||
...initCusPrice({
|
||||
fullCus,
|
||||
price,
|
||||
cusProductId,
|
||||
}),
|
||||
price,
|
||||
}));
|
||||
|
||||
const newCusProduct = initCusProduct({
|
||||
initContext,
|
||||
cusProductId,
|
||||
});
|
||||
|
||||
ctx.logger.info(
|
||||
`[insertFullCusProduct] inserting new cus product ${product.id}`,
|
||||
);
|
||||
|
||||
const { entitlements: _ents, prices: _prices, ...rawProduct } = product;
|
||||
|
||||
const newFullCusProduct = {
|
||||
...newCusProduct,
|
||||
product: rawProduct,
|
||||
customer_entitlements: newFullCusEnts,
|
||||
customer_prices: newCusPrices,
|
||||
};
|
||||
|
||||
// Finally, apply existing usages to new cus product
|
||||
applyExistingUsages({
|
||||
cusProduct: newFullCusProduct,
|
||||
existingUsages: initContext.existingUsages,
|
||||
entities: fullCus.entities,
|
||||
});
|
||||
|
||||
// TODO: Add rollovers to customer entitlements
|
||||
|
||||
return newFullCusProduct;
|
||||
|
||||
// await CusProductService.insert({
|
||||
// db,
|
||||
// data: newCusProduct,
|
||||
// });
|
||||
|
||||
// await Promise.all([
|
||||
// CusEntService.insert({
|
||||
// db,
|
||||
// data: newCusEnts,
|
||||
// }),
|
||||
// CusPriceService.insert({
|
||||
// db,
|
||||
// data: newCusPrices,
|
||||
// }),
|
||||
// ]);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import type {
|
||||
FullCusProduct,
|
||||
InitFullCustomerProductContext,
|
||||
InitFullCustomerProductOptions,
|
||||
} from "@autumn/shared";
|
||||
import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers";
|
||||
import { generateId } from "@/utils/genUtils";
|
||||
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
|
||||
import { applyExistingUsages } from "../handleExistingUsages/applyExistingUsages";
|
||||
import { initCusEntitlement } from "./initCusEntitlementV2/initCusEntitlement";
|
||||
import { initCusPrice } from "./initCusPrice";
|
||||
import { initCustomerProduct } from "./initCustomerProduct";
|
||||
|
||||
export const initFullCustomerProduct = ({
|
||||
// biome-ignore lint/correctness/noUnusedFunctionParameters: will need it at some point
|
||||
ctx,
|
||||
initContext,
|
||||
initOptions,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
initContext: InitFullCustomerProductContext;
|
||||
initOptions?: InitFullCustomerProductOptions;
|
||||
}): FullCusProduct => {
|
||||
const { fullCustomer, fullProduct } = initContext;
|
||||
|
||||
const cusProductId = generateId("cus_prod");
|
||||
|
||||
const newFullCusEnts = fullProduct.entitlements.map((entitlement) => ({
|
||||
...initCusEntitlement({
|
||||
initContext,
|
||||
entitlement,
|
||||
cusProductId,
|
||||
}),
|
||||
entitlement,
|
||||
replaceables: [],
|
||||
rollovers: [],
|
||||
}));
|
||||
|
||||
const newCusPrices = fullProduct.prices.map((price) => ({
|
||||
...initCusPrice({
|
||||
fullCus: fullCustomer,
|
||||
price,
|
||||
cusProductId,
|
||||
}),
|
||||
price,
|
||||
}));
|
||||
|
||||
const newCusProduct = initCustomerProduct({
|
||||
initContext,
|
||||
customerProductId: cusProductId,
|
||||
});
|
||||
|
||||
const { entitlements: _ents, prices: _prices, ...rawProduct } = fullProduct;
|
||||
|
||||
const newFullCustomerProduct = {
|
||||
...newCusProduct,
|
||||
product: rawProduct,
|
||||
customer_entitlements: newFullCusEnts,
|
||||
customer_prices: newCusPrices,
|
||||
};
|
||||
|
||||
// Finally, apply existing usages to new cus product
|
||||
applyExistingUsages({
|
||||
customerProduct: newFullCustomerProduct,
|
||||
existingUsages: initContext.existingUsages,
|
||||
entities: fullCustomer.entities,
|
||||
});
|
||||
|
||||
// TODO: Add rollovers to customer entitlements
|
||||
applyExistingRollovers({
|
||||
customerProduct: newFullCustomerProduct,
|
||||
existingRollovers: initContext.existingRollovers ?? [],
|
||||
});
|
||||
|
||||
return newFullCustomerProduct;
|
||||
};
|
||||
@@ -1,9 +1,12 @@
|
||||
import { type FeatureOptions, isPrepaidPrice } from "@autumn/shared";
|
||||
import {
|
||||
type FeatureOptions,
|
||||
isPrepaidPrice,
|
||||
priceToFeature,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
|
||||
import { attachParamsToProduct } from "../../../customers/attach/attachUtils/convertAttachParams";
|
||||
import type { AttachParams } from "../../../customers/cusProducts/AttachParams";
|
||||
import { getPriceOptions } from "../../../products/prices/priceUtils";
|
||||
import { priceToFeature } from "../../../products/prices/priceUtils/convertPrice";
|
||||
|
||||
export const getCheckoutOptions = async ({
|
||||
ctx,
|
||||
@@ -13,9 +16,7 @@ export const getCheckoutOptions = async ({
|
||||
attachParams: AttachParams;
|
||||
}) => {
|
||||
const product = attachParamsToProduct({ attachParams });
|
||||
const prepaidPrices = product.prices.filter((p) =>
|
||||
isPrepaidPrice({ price: p }),
|
||||
);
|
||||
const prepaidPrices = product.prices.filter((p) => isPrepaidPrice(p));
|
||||
|
||||
const newOptions: FeatureOptions[] = structuredClone(
|
||||
attachParams.optionsList,
|
||||
|
||||
56
server/src/internal/billing/v2/billingPlan.ts
Normal file
56
server/src/internal/billing/v2/billingPlan.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { EntitlementSchema, PriceSchema } from "@autumn/shared";
|
||||
import { z } from "zod/v4";
|
||||
import { FullCusProductSchema } from "../../../../../shared/models/cusProductModels/cusProductModels";
|
||||
|
||||
// manualInvoice?: {
|
||||
// items: Stripe.InvoiceItemCreateParams[];
|
||||
// finalize: boolean;
|
||||
// chargeAutomatically: boolean;
|
||||
// };
|
||||
// subscription?: {
|
||||
// action: "create" | "update" | "cancel";
|
||||
// params: SubscriptionParams;
|
||||
// };
|
||||
// subscriptionItemUpdates?: { itemId: string; quantity: number }[];
|
||||
// checkout?: Stripe.Checkout.SessionCreateParams;
|
||||
|
||||
export const StripeBillingPlanSchema = z.object({
|
||||
subscription: z.object({
|
||||
action: z.enum(["create", "update", "cancel"]),
|
||||
params: z.object({
|
||||
items: z.array(z.object({ id: z.string(), quantity: z.number() })),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const AutumnBillingPlanSchema = z.object({
|
||||
insertCusProducts: z.array(FullCusProductSchema),
|
||||
|
||||
updateCusProduct: z.object({
|
||||
cusProductId: z.string(),
|
||||
action: z.enum(["expire"]),
|
||||
}),
|
||||
|
||||
insertCustomPrices: z.array(PriceSchema),
|
||||
insertCustomEntitlements: z.array(EntitlementSchema),
|
||||
|
||||
// expireCusProducts: z.array(z.string()),
|
||||
|
||||
// updateCusProduct: z.object({
|
||||
// cusProductId: z.string(),
|
||||
// options: z.array(FeatureOptionsSchema),
|
||||
// }),
|
||||
// entitlementChanges: z.array(
|
||||
// z.object({ cusEntId: z.string(), delta: z.number() }),
|
||||
// ),
|
||||
});
|
||||
|
||||
export const BillingPlanSchema = z.object({
|
||||
intent: z.enum(["update_quantity", "update_plan"]),
|
||||
featureQuantities: z.array(
|
||||
z.object({
|
||||
featureId: z.string(),
|
||||
quantity: z.number(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { secondsToMs } from "@autumn/shared";
|
||||
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
|
||||
import type { AttachContext } from "../types";
|
||||
import type { AttachContext } from "../typesOld";
|
||||
import { buildAutumnLineItems } from "./computeAutumnUtils/buildAutumnLineItems";
|
||||
import { buildNewCusProducts } from "./computeAutumnUtils/buildNewCusProducts";
|
||||
import { buildStripeCheckoutAction } from "./computeStripeUtils/buildStripeCheckoutAction";
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
cusProductToArrearLineItems,
|
||||
cusProductToLineItems,
|
||||
type FullCusProduct,
|
||||
type OngoingCusProductAction,
|
||||
} from "@autumn/shared";
|
||||
|
||||
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
|
||||
@@ -10,13 +9,13 @@ import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
|
||||
export const buildAutumnLineItems = ({
|
||||
ctx,
|
||||
newCusProducts,
|
||||
ongoingCusProductAction,
|
||||
ongoingCustomerProduct,
|
||||
billingCycleAnchor,
|
||||
testClockFrozenTime,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
newCusProducts: FullCusProduct[];
|
||||
ongoingCusProductAction?: OngoingCusProductAction;
|
||||
ongoingCustomerProduct?: FullCusProduct;
|
||||
billingCycleAnchor?: number;
|
||||
testClockFrozenTime?: number;
|
||||
}) => {
|
||||
@@ -24,11 +23,10 @@ export const buildAutumnLineItems = ({
|
||||
billingCycleAnchor = billingCycleAnchor ?? now;
|
||||
|
||||
const { org } = ctx;
|
||||
const ongoingCusProduct = ongoingCusProductAction?.cusProduct;
|
||||
|
||||
const arrearLineItems = ongoingCusProduct
|
||||
const arrearLineItems = ongoingCustomerProduct
|
||||
? cusProductToArrearLineItems({
|
||||
cusProduct: ongoingCusProduct,
|
||||
cusProduct: ongoingCustomerProduct,
|
||||
billingCycleAnchor: billingCycleAnchor!,
|
||||
now,
|
||||
org,
|
||||
@@ -36,9 +34,9 @@ export const buildAutumnLineItems = ({
|
||||
: [];
|
||||
|
||||
// Get line items for ongoing cus product
|
||||
const ongoingLineItems = ongoingCusProduct
|
||||
const ongoingLineItems = ongoingCustomerProduct
|
||||
? cusProductToLineItems({
|
||||
cusProduct: ongoingCusProduct,
|
||||
cusProduct: ongoingCustomerProduct,
|
||||
now,
|
||||
billingCycleAnchor: billingCycleAnchor!,
|
||||
direction: "refund",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
|
||||
import { cusProductToExistingUsages } from "../../../billingUtils/handleExistingUsages/cusProductToExistingUsages";
|
||||
import { initFullCusProduct } from "../../../billingUtils/initFullCusProduct/initFullCusProduct";
|
||||
import type { AttachContext } from "../../types";
|
||||
import { initFullCustomerProduct } from "../../../billingUtils/initFullCusProduct/initFullCustomerProduct";
|
||||
import type { AttachContext } from "../../typesOld";
|
||||
|
||||
export const buildNewCusProducts = ({
|
||||
ctx,
|
||||
@@ -21,12 +21,12 @@ export const buildNewCusProducts = ({
|
||||
});
|
||||
|
||||
// Initialize new cus product
|
||||
const newCusProduct = initFullCusProduct({
|
||||
const newCusProduct = initFullCustomerProduct({
|
||||
ctx,
|
||||
fullCus,
|
||||
initContext: {
|
||||
fullCus,
|
||||
product: products[0],
|
||||
fullCustomer: fullCus,
|
||||
fullProduct: products[0],
|
||||
featureQuantities: [],
|
||||
replaceables: [],
|
||||
existingUsages,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FullCusProduct } from "@autumn/shared";
|
||||
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
|
||||
import type { AttachContext } from "../../types";
|
||||
import type { AttachContext } from "../../typesOld";
|
||||
|
||||
export const buildUpdateOneOffAction = ({
|
||||
ctx,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { FullProduct, ProductItem } from "@autumn/shared";
|
||||
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
|
||||
import { getEntsWithFeature } from "../../../../products/entitlements/entitlementUtils";
|
||||
import { handleNewProductItems } from "../../../../products/product-items/productItemUtils/handleNewProductItems";
|
||||
|
||||
export const computeCustomFullProduct = async ({
|
||||
ctx,
|
||||
customItems,
|
||||
currentFullProduct,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
customItems?: ProductItem[];
|
||||
currentFullProduct: FullProduct;
|
||||
}) => {
|
||||
if (!customItems) {
|
||||
return {
|
||||
fullProduct: currentFullProduct,
|
||||
customPrices: [],
|
||||
customEnts: [],
|
||||
};
|
||||
}
|
||||
|
||||
const { db, logger, features } = ctx;
|
||||
|
||||
const { prices: currentPrices, entitlements: currentEntitlements } =
|
||||
currentFullProduct;
|
||||
|
||||
const { prices, entitlements, customPrices, customEnts } =
|
||||
await handleNewProductItems({
|
||||
db,
|
||||
curPrices: currentPrices,
|
||||
curEnts: currentEntitlements,
|
||||
newItems: customItems,
|
||||
features,
|
||||
product: currentFullProduct,
|
||||
logger,
|
||||
isCustom: true,
|
||||
});
|
||||
|
||||
const newFullProduct = {
|
||||
...currentFullProduct,
|
||||
prices,
|
||||
entitlements: getEntsWithFeature({ ents: entitlements, features }),
|
||||
};
|
||||
|
||||
return {
|
||||
fullProduct: newFullProduct,
|
||||
customPrices,
|
||||
customEnts,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import type {
|
||||
Feature,
|
||||
FeatureOptions,
|
||||
Price,
|
||||
SubscriptionUpdateV0Params,
|
||||
} from "@autumn/shared";
|
||||
import { roundUsageToNearestBillingUnit } from "@autumn/shared";
|
||||
import { Decimal } from "decimal.js";
|
||||
|
||||
export const paramsToFeatureOptions = ({
|
||||
params,
|
||||
price,
|
||||
feature,
|
||||
}: {
|
||||
params: SubscriptionUpdateV0Params;
|
||||
price: Price;
|
||||
feature: Feature;
|
||||
}): FeatureOptions | undefined => {
|
||||
const options = params.options?.find(
|
||||
(option) => option.feature_id === feature.id,
|
||||
);
|
||||
|
||||
const billingUnits = price.config.billing_units ?? 1;
|
||||
|
||||
if (options?.quantity) {
|
||||
// 1. Round options quantity to nearest billing units:
|
||||
const roundedQuantity = roundUsageToNearestBillingUnit({
|
||||
usage: options.quantity,
|
||||
billingUnits,
|
||||
});
|
||||
|
||||
const quantityDividedByBillingUnits = new Decimal(roundedQuantity)
|
||||
.div(billingUnits)
|
||||
.toNumber();
|
||||
|
||||
return {
|
||||
internal_feature_id: feature.internal_id,
|
||||
feature_id: feature.id,
|
||||
quantity: quantityDividedByBillingUnits,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -9,7 +9,7 @@ import type Stripe from "stripe";
|
||||
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
|
||||
import { toSuccessUrl } from "../../../../orgs/orgUtils/convertOrgUtils";
|
||||
import { cusProductToStripeItemSpecs } from "../../../billingUtils/stripeAdapter/cusProductToStripeItemSpecs";
|
||||
import type { AttachContext } from "../../types";
|
||||
import type { AttachContext } from "../../typesOld";
|
||||
import { computeShouldCreateStripeCheckout } from "./computeShouldCreateStripeCheckout";
|
||||
|
||||
export const buildCheckoutSessionCreateSubscriptionData = ({
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { applyStripeDiscountsToLineItems } from "../../../billingUtils/stripeAdapter/applyStripeDiscounts/applyStripeDiscountsToLineItems";
|
||||
import { subToDiscounts } from "../../../billingUtils/stripeAdapter/applyStripeDiscounts/subToDiscounts";
|
||||
import { lineItemsToStripeLines } from "../../../billingUtils/stripeAdapter/stripeInvoiceOps/lineItemsToStripeLines";
|
||||
import type { AttachContext, StripeSubAction } from "../../types";
|
||||
import type { AttachContext, StripeSubAction } from "../../typesOld";
|
||||
|
||||
export const buildStripeInvoiceAction = ({
|
||||
attachContext,
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
import type Stripe from "stripe";
|
||||
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
|
||||
import { buildSubItemUpdate } from "../../../billingUtils/stripeAdapter/buildSubItems/buildSubItemUpdate";
|
||||
import type { StripeSubAction } from "../../types";
|
||||
import type { StripeSubAction } from "../../typesOld";
|
||||
|
||||
export const buildStripeSubAction = ({
|
||||
ctx,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
isFreeProduct,
|
||||
} from "@autumn/shared";
|
||||
import { notNullish } from "../../../../../utils/genUtils";
|
||||
import type { AttachContext } from "../../types";
|
||||
import type { AttachContext } from "../../typesOld";
|
||||
|
||||
export const computeShouldCreateStripeCheckout = ({
|
||||
attachContext,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
|
||||
import type { AttachContext, AttachPlan } from "../types";
|
||||
import type { AttachContext, AttachPlan } from "../typesOld";
|
||||
import { applyCusProductActions } from "./executeAutumnActions/applyCusProductActions";
|
||||
import { executeStripeCheckoutAction } from "./executeStripeCheckoutAction";
|
||||
import { executeStripeInvoiceAction } from "./executeStripeInvoiceAction";
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
OngoingCusProductAction,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
|
||||
import type { QuantityUpdateDetails } from "../../types";
|
||||
import type { QuantityUpdateDetails } from "../../typesOld";
|
||||
import { applyOngoingCusProductAction } from "./applyOngoingCusProductAction";
|
||||
import { insertNewCusProducts } from "./insertNewCusProducts";
|
||||
import { updateCustomerEntitlements } from "./updateCustomerEntitlements";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService";
|
||||
import type { QuantityUpdateDetails } from "../../types";
|
||||
import type { QuantityUpdateDetails } from "../../typesOld";
|
||||
|
||||
/**
|
||||
* Update customer entitlement balances based on quantity changes.
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { InvoiceService } from "@/internal/invoices/InvoiceService";
|
||||
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils";
|
||||
import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice";
|
||||
import type { SubscriptionUpdateInvoiceAction } from "../types";
|
||||
import type { SubscriptionUpdateInvoiceAction } from "../typesOld";
|
||||
|
||||
/**
|
||||
* Execute invoice creation and finalization for subscription updates.
|
||||
|
||||
@@ -2,7 +2,7 @@ import type Stripe from "stripe";
|
||||
import { createStripeCli } from "../../../../external/connect/createStripeCli";
|
||||
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
|
||||
import { notNullish } from "../../../../utils/genUtils";
|
||||
import type { StripeCheckoutAction } from "../types";
|
||||
import type { StripeCheckoutAction } from "../typesOld";
|
||||
|
||||
export const executeStripeCheckoutAction = async ({
|
||||
ctx,
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
AttachContext,
|
||||
StripeCheckoutAction,
|
||||
StripeInvoiceAction,
|
||||
} from "../types";
|
||||
} from "../typesOld";
|
||||
import { executeStripeCheckoutAction } from "./executeStripeCheckoutAction";
|
||||
|
||||
export const executeStripeInvoiceAction = async ({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
|
||||
import type { StripeSubAction } from "../types";
|
||||
import type { StripeSubAction } from "../typesOld";
|
||||
import { executeStripeSubscriptionUpdate } from "./executeStripeSubscriptionActions/executeStripeSubscriptionUpdate";
|
||||
|
||||
export const executeStripeSubAction = async ({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { StripeSubAction } from "../../types";
|
||||
import type { StripeSubAction } from "../../typesOld";
|
||||
|
||||
/**
|
||||
* Execute Stripe subscription item updates.
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
// import type { PlanOverride } from "@autumn/shared";
|
||||
import {
|
||||
type FullProduct,
|
||||
InternalError,
|
||||
type ProductItem,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
|
||||
import { getEntsWithFeature } from "../../../../products/entitlements/entitlementUtils";
|
||||
import { handleNewProductItems } from "../../../../products/product-items/productItemUtils/handleNewProductItems";
|
||||
|
||||
// 1. Only override base product
|
||||
// 2. Assume features + prices are passed in together
|
||||
export const overrideProduct = async ({
|
||||
ctx,
|
||||
newItems,
|
||||
products,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
newItems?: ProductItem[];
|
||||
products: FullProduct[];
|
||||
}) => {
|
||||
if (!newItems) {
|
||||
return {
|
||||
fullProducts: products,
|
||||
customPrices: [],
|
||||
customEnts: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (products.length === 0) {
|
||||
throw new InternalError({
|
||||
message: "[overrideProduct] products array is empty",
|
||||
});
|
||||
}
|
||||
|
||||
if (products.length > 1) {
|
||||
throw new InternalError({
|
||||
message: "[overrideProduct] products array has more than one product",
|
||||
});
|
||||
}
|
||||
|
||||
const { db, logger, features } = ctx;
|
||||
// const { price, features: planFeatures } = planOverride;
|
||||
|
||||
// const newBasePriceItem = planToProductV2PriceItem({
|
||||
// price: price ?? null,
|
||||
// features,
|
||||
// });
|
||||
|
||||
// const featureItems = planFeaturesToItems({
|
||||
// planFeatures: planFeatures ?? [],
|
||||
// features,
|
||||
// });
|
||||
|
||||
// const newItems = [newBasePriceItem, ...featureItems];
|
||||
|
||||
const product = products[0];
|
||||
|
||||
const { prices, entitlements, customPrices, customEnts } =
|
||||
await handleNewProductItems({
|
||||
db,
|
||||
curPrices: product.prices,
|
||||
curEnts: product.entitlements,
|
||||
newItems,
|
||||
features,
|
||||
product,
|
||||
logger,
|
||||
isCustom: true,
|
||||
});
|
||||
|
||||
const newFullProduct = {
|
||||
...product,
|
||||
prices,
|
||||
entitlements: getEntsWithFeature({ ents: entitlements, features }),
|
||||
};
|
||||
|
||||
return {
|
||||
fullProducts: [newFullProduct],
|
||||
customPrices,
|
||||
customEnts,
|
||||
};
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type {
|
||||
QuantityUpdateDetails,
|
||||
SubscriptionUpdateInvoiceAction,
|
||||
} from "../../types";
|
||||
} from "../../typesOld";
|
||||
|
||||
/**
|
||||
* Aggregate invoice items and determine invoice creation strategy.
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
shouldProrate,
|
||||
} from "@/internal/products/prices/priceUtils/prorationConfigUtils";
|
||||
import { notNullish } from "@/utils/genUtils";
|
||||
import type { QuantityUpdateDetails } from "../../types";
|
||||
import type { QuantityUpdateDetails } from "../../typesOld";
|
||||
|
||||
/**
|
||||
* Compute all details for a single feature quantity update.
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
// const {
|
||||
// fullProducts: [newFullProduct],
|
||||
// customPrices,
|
||||
// customEnts,
|
||||
// } = await overrideProduct({
|
||||
// ctx,
|
||||
// newItems: body.items,
|
||||
// products: [product],
|
||||
// });
|
||||
|
||||
import type { SubscriptionUpdateV0Params } from "../../../../../../../shared";
|
||||
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
|
||||
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
|
||||
|
||||
export const computeSubscriptionUpdateCustomConfigurationPlan = ({
|
||||
ctx: AutumnContext,
|
||||
updateSubscriptionContext,
|
||||
params,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
||||
params: SubscriptionUpdateV0Params;
|
||||
}) => {
|
||||
return {};
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
cusProductToProduct,
|
||||
type SubscriptionUpdateV0Params,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
||||
import type { UpdateSubscriptionContext } from "@server/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
|
||||
import { computeSubscriptionUpdateNewCustomerProduct } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct";
|
||||
import { computeCustomFullProduct } from "../../../compute/computeAutumnUtils/computeCustomFullProduct";
|
||||
|
||||
export const computeSubscriptionUpdateCustomPlan = async ({
|
||||
ctx,
|
||||
subscriptionUpdateContext,
|
||||
params,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
subscriptionUpdateContext: UpdateSubscriptionContext;
|
||||
params: SubscriptionUpdateV0Params;
|
||||
}) => {
|
||||
// 1. Compute the override plan
|
||||
const { customerProduct } = subscriptionUpdateContext;
|
||||
|
||||
const currentFullProduct = cusProductToProduct({
|
||||
cusProduct: customerProduct,
|
||||
});
|
||||
|
||||
const {
|
||||
fullProduct: customFullProduct,
|
||||
customPrices,
|
||||
customEnts,
|
||||
} = await computeCustomFullProduct({
|
||||
ctx,
|
||||
currentFullProduct,
|
||||
customItems: params.items,
|
||||
});
|
||||
|
||||
// 2. Compute the new customer product
|
||||
const newFullCustomerProduct = computeSubscriptionUpdateNewCustomerProduct({
|
||||
ctx,
|
||||
subscriptionUpdateContext,
|
||||
params,
|
||||
fullProduct: customFullProduct,
|
||||
});
|
||||
|
||||
// // 2. Compute the invoice action
|
||||
// const invoiceAction = computeSubscriptionUpdateCustomPlanInvoiceAction({
|
||||
// ctx,
|
||||
// updateSubscriptionContext,
|
||||
// newFullCustomerProduct,
|
||||
// params,
|
||||
// });
|
||||
|
||||
return newFullCustomerProduct;
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
type FullCusProduct,
|
||||
type SubscriptionUpdateV0Params,
|
||||
secondsToMs,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
||||
import { buildAutumnLineItems } from "../../../compute/computeAutumnUtils/buildAutumnLineItems";
|
||||
import type { UpdateSubscriptionContext } from "../../fetch/updateSubscriptionContextSchema";
|
||||
import { computeSubscriptionUpdateCustomPlanInvoiceRequired } from "./computeSubscriptionUpdateICustomPlanInvoiceRequired";
|
||||
|
||||
/**
|
||||
* Computes the invoice action for a custom subscription update.
|
||||
*
|
||||
* Determines what invoice operations (create, prorate, void, etc.) are needed
|
||||
* when a subscription is updated with custom item configurations.
|
||||
*
|
||||
* @param ctx - The Autumn request context
|
||||
* @param updateSubscriptionContext - Context containing customer product and subscription details
|
||||
* @param params - The subscription update parameters from the API request
|
||||
* @returns The computed invoice action to be executed
|
||||
*/
|
||||
export const computeSubscriptionUpdateCustomPlanInvoiceAction = ({
|
||||
ctx,
|
||||
updateSubscriptionContext,
|
||||
params,
|
||||
newFullCustomerProduct,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
||||
params: SubscriptionUpdateV0Params;
|
||||
newFullCustomerProduct: FullCusProduct;
|
||||
}) => {
|
||||
// 1. Early return and don't create an invoice
|
||||
|
||||
const invoiceRequired = computeSubscriptionUpdateCustomPlanInvoiceRequired({
|
||||
ctx,
|
||||
updateSubscriptionContext,
|
||||
params,
|
||||
});
|
||||
|
||||
if (!invoiceRequired) return undefined;
|
||||
|
||||
const { customerProduct, stripeSubscription, testClockFrozenTime } =
|
||||
updateSubscriptionContext;
|
||||
|
||||
// 2. Calculate line items
|
||||
const lineItems = buildAutumnLineItems({
|
||||
ctx,
|
||||
newCusProducts: [newFullCustomerProduct],
|
||||
ongoingCustomerProduct: customerProduct,
|
||||
billingCycleAnchor: secondsToMs(stripeSubscription?.billing_cycle_anchor),
|
||||
testClockFrozenTime,
|
||||
});
|
||||
|
||||
// 3.
|
||||
|
||||
console.log("New line items", lineItems);
|
||||
|
||||
return {};
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
cusProductToConvertedFeatureOptions,
|
||||
type FeatureOptions,
|
||||
type FullCusProduct,
|
||||
type FullProduct,
|
||||
InternalError,
|
||||
isPrepaidPrice,
|
||||
priceToFeature,
|
||||
type SubscriptionUpdateV0Params,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { paramsToFeatureOptions } from "@/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions";
|
||||
|
||||
/**
|
||||
* Compute the feature quantities for a subscription update
|
||||
*/
|
||||
export const computeSubscriptionUpdateFeatureQuantities = ({
|
||||
ctx,
|
||||
fullProduct,
|
||||
currentCustomerProduct,
|
||||
params,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
fullProduct: FullProduct;
|
||||
currentCustomerProduct: FullCusProduct;
|
||||
params: SubscriptionUpdateV0Params;
|
||||
}) => {
|
||||
const newFeatureQuantities: FeatureOptions[] = [];
|
||||
for (const price of fullProduct.prices) {
|
||||
if (!isPrepaidPrice(price)) continue;
|
||||
|
||||
const feature = priceToFeature({
|
||||
price,
|
||||
features: ctx.features,
|
||||
});
|
||||
|
||||
if (!feature)
|
||||
throw new InternalError({
|
||||
message: `computing feature quantities for price ${price.id} but no feature found`,
|
||||
});
|
||||
|
||||
const newFeatureQuantity = paramsToFeatureOptions({
|
||||
params,
|
||||
price,
|
||||
feature,
|
||||
});
|
||||
|
||||
// Convert current quantity from old price's billing units to new price's billing units
|
||||
const currentFeatureQuantity = cusProductToConvertedFeatureOptions({
|
||||
cusProduct: currentCustomerProduct,
|
||||
feature,
|
||||
newPrice: price,
|
||||
});
|
||||
|
||||
const featureQuantity = newFeatureQuantity ?? currentFeatureQuantity;
|
||||
|
||||
if (featureQuantity) newFeatureQuantities.push(featureQuantity);
|
||||
}
|
||||
|
||||
return newFeatureQuantities;
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { SubscriptionUpdateV0Params } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
||||
import type { UpdateSubscriptionContext } from "../../fetch/updateSubscriptionContextSchema";
|
||||
|
||||
/**
|
||||
* Determines whether an invoice is required for a custom subscription update.
|
||||
*
|
||||
* Evaluates the subscription changes to decide if billing adjustments
|
||||
* (prorations, charges, credits) necessitate creating an invoice.
|
||||
*
|
||||
* @param ctx - The Autumn request context
|
||||
* @param updateSubscriptionContext - Context containing customer product and subscription details
|
||||
* @param params - The subscription update parameters from the API request
|
||||
* @returns `true` if an invoice is required, `false` otherwise
|
||||
*/
|
||||
export const computeSubscriptionUpdateCustomPlanInvoiceRequired = ({
|
||||
ctx,
|
||||
updateSubscriptionContext,
|
||||
params,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
||||
params: SubscriptionUpdateV0Params;
|
||||
}) => {
|
||||
// 1. When to calculate invoice...?
|
||||
|
||||
return false;
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { FullProduct, SubscriptionUpdateV0Params } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { cusProductToExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/cusProductToExistingRollovers";
|
||||
import { cusProductToExistingUsages } from "@/internal/billing/billingUtils/handleExistingUsages/cusProductToExistingUsages";
|
||||
import { initFullCustomerProduct } from "@/internal/billing/billingUtils/initFullCusProduct/initFullCustomerProduct";
|
||||
import { computeSubscriptionUpdateFeatureQuantities } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFeatureQuantities";
|
||||
import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
|
||||
|
||||
export const computeSubscriptionUpdateNewCustomerProduct = async ({
|
||||
ctx,
|
||||
subscriptionUpdateContext,
|
||||
params,
|
||||
fullProduct,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
subscriptionUpdateContext: UpdateSubscriptionContext;
|
||||
params: SubscriptionUpdateV0Params;
|
||||
fullProduct: FullProduct;
|
||||
}) => {
|
||||
const {
|
||||
customerProduct,
|
||||
fullCustomer,
|
||||
stripeSubscription,
|
||||
stripeSubscriptionSchedule,
|
||||
} = subscriptionUpdateContext;
|
||||
|
||||
// 1. Get feature quantities
|
||||
const existingUsages = cusProductToExistingUsages({
|
||||
cusProduct: customerProduct,
|
||||
entityId: fullCustomer.entity?.id,
|
||||
});
|
||||
|
||||
const existingRollovers = cusProductToExistingRollovers({
|
||||
cusProduct: customerProduct,
|
||||
});
|
||||
|
||||
const featureQuantities = computeSubscriptionUpdateFeatureQuantities({
|
||||
ctx,
|
||||
fullProduct,
|
||||
currentCustomerProduct: customerProduct,
|
||||
params,
|
||||
});
|
||||
|
||||
// 1. Compute the new full customer product
|
||||
const newFullCustomerProduct = initFullCustomerProduct({
|
||||
ctx,
|
||||
initContext: {
|
||||
fullCustomer,
|
||||
fullProduct,
|
||||
featureQuantities,
|
||||
existingUsages,
|
||||
existingRollovers,
|
||||
},
|
||||
initOptions: {
|
||||
isCustom: true,
|
||||
// resetCycleAnchor,
|
||||
subscriptionId: stripeSubscription?.id,
|
||||
subscriptionScheduleId: stripeSubscriptionSchedule?.id,
|
||||
},
|
||||
});
|
||||
|
||||
return newFullCustomerProduct;
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
|
||||
|
||||
export const computeSubscriptionUpdateResetCycleAnchor = ({
|
||||
ctx,
|
||||
subscriptionUpdateContext,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
subscriptionUpdateContext: UpdateSubscriptionContext;
|
||||
}) => {
|
||||
return {};
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
|
||||
|
||||
export const computeSubscriptionUpdateTrialDetails = ({
|
||||
ctx,
|
||||
subscriptionUpdateContext,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
subscriptionUpdateContext: UpdateSubscriptionContext;
|
||||
}) => {
|
||||
return {};
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SubscriptionUpdateV0Params } from "@shared/index";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { SubscriptionUpdatePlan } from "../../types";
|
||||
import type { SubscriptionUpdatePlan } from "../../typesOld";
|
||||
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
|
||||
import { computeSubscriptionUpdateIntent } from "./computeSubscriptionUpdateIntent";
|
||||
import { getComputeSubscriptionUpdatePlanFunction } from "./computeSubscriptionUpdatePlanIntentMap";
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type SubscriptionUpdateV0Params,
|
||||
} from "@shared/index";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { SubscriptionUpdatePlan } from "../../types";
|
||||
import type { SubscriptionUpdatePlan } from "../../typesOld";
|
||||
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
|
||||
import { computeSubscriptionUpdateQuantityPlan } from "./computeSubscriptionUpdateQuantityPlan";
|
||||
import { SubscriptionUpdateIntentEnum } from "./computeSubscriptionUpdateSchema";
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from "@shared/index";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { buildAutumnLineItems } from "../../compute/computeAutumnUtils/buildAutumnLineItems";
|
||||
import type { SubscriptionUpdateQuantityPlan } from "../../types";
|
||||
import type { SubscriptionUpdateQuantityPlan } from "../../typesOld";
|
||||
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
|
||||
import { computeInvoiceAction } from "./computeInvoiceAction";
|
||||
import { computeQuantityUpdateDetails } from "./computeQuantityUpdateDetails";
|
||||
|
||||
@@ -7,7 +7,7 @@ import { PriceService } from "@/internal/products/prices/PriceService";
|
||||
import { executeCusProductActions } from "../../execute/executeAutumnActions/executeCusProductActions";
|
||||
import { executeInvoiceAction } from "../../execute/executeInvoiceAction";
|
||||
import { executeStripeSubAction } from "../../execute/executeStripeSubAction";
|
||||
import type { SubscriptionUpdatePlan } from "../../types";
|
||||
import type { SubscriptionUpdatePlan } from "../../typesOld";
|
||||
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
|
||||
|
||||
export const executeSubscriptionUpdate = async ({
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
cusProductToProduct,
|
||||
InternalError,
|
||||
type SubscriptionUpdateV0Params,
|
||||
} from "@shared/index";
|
||||
import { InternalError, type SubscriptionUpdateV0Params } from "@shared/index";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { mapOptionsList } from "@/internal/customers/attach/attachUtils/mapOptionsList";
|
||||
import { CusService } from "../../../../customers/CusService";
|
||||
@@ -47,10 +43,6 @@ export const fetchApiSubscriptionUpdateContext = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const targetProduct = cusProductToProduct({
|
||||
cusProduct: targetCustomerProduct,
|
||||
});
|
||||
|
||||
const stripeSubscription = await fetchStripeSubscriptionForBilling({
|
||||
ctx,
|
||||
fullCus: fullCustomer,
|
||||
@@ -84,7 +76,6 @@ export const fetchApiSubscriptionUpdateContext = async ({
|
||||
|
||||
return {
|
||||
fullCustomer,
|
||||
product: targetProduct,
|
||||
customerProduct: targetCustomerProduct,
|
||||
stripeSubscription,
|
||||
stripeCustomer,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { FullCusProduct, FullCustomer, FullProduct } from "@shared/index";
|
||||
import type { FullCusProduct, FullCustomer } from "@shared/index";
|
||||
import type Stripe from "stripe";
|
||||
|
||||
export type UpdateSubscriptionContext = {
|
||||
fullCustomer: FullCustomer;
|
||||
product: FullProduct;
|
||||
// product: FullProduct;
|
||||
customerProduct: FullCusProduct;
|
||||
stripeSubscription: Stripe.Subscription;
|
||||
stripeSubscription?: Stripe.Subscription;
|
||||
stripeSubscriptionSchedule?: Stripe.SubscriptionSchedule;
|
||||
stripeCustomer: Stripe.Customer;
|
||||
paymentMethod?: Stripe.PaymentMethod;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type FreeTrial,
|
||||
type FullCusProduct,
|
||||
isCusProductTrialing,
|
||||
isPrepaidPrice,
|
||||
OnDecrease,
|
||||
OnIncrease,
|
||||
type PreviewLineItem,
|
||||
@@ -21,7 +22,6 @@ import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getI
|
||||
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
|
||||
import { getAlignedUnix } from "@/internal/products/prices/billingIntervalUtils2.js";
|
||||
import { getLargestInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js";
|
||||
import { isPrepaidPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
|
||||
import { isFreeProduct } from "@/internal/products/productUtils.js";
|
||||
import { mapToProductItems } from "@/internal/products/productV2Utils.js";
|
||||
import { nullish } from "@/utils/genUtils.js";
|
||||
@@ -103,7 +103,7 @@ const filterNoProratePrepaidItems = ({
|
||||
const curPrice = curPrices.find(
|
||||
(p) =>
|
||||
(p.config as UsagePriceConfig)?.internal_feature_id ===
|
||||
internal_feature_id && isPrepaidPrice({ price: p }),
|
||||
internal_feature_id && isPrepaidPrice(p),
|
||||
);
|
||||
|
||||
const onDecrease = curPrice?.proration_config?.on_decrease;
|
||||
|
||||
@@ -3,14 +3,12 @@ import {
|
||||
cusProductToEnts,
|
||||
type Entity,
|
||||
type FullCusProduct,
|
||||
isAllocatedPrice,
|
||||
isFixedPrice,
|
||||
isPrepaidPrice,
|
||||
type Price,
|
||||
} from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import {
|
||||
isContUsePrice,
|
||||
isPrepaidPrice,
|
||||
} from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
|
||||
import {
|
||||
getPriceEntitlement,
|
||||
getPriceOptions,
|
||||
@@ -48,7 +46,7 @@ export const getQuantityToRemove = ({
|
||||
let finalQuantity = 1;
|
||||
const fixedPriceMultiplier = cusProduct.quantity || 1;
|
||||
|
||||
if (isPrepaidPrice({ price })) {
|
||||
if (isPrepaidPrice(price)) {
|
||||
const options = getPriceOptions(price, cusProduct.options);
|
||||
|
||||
if (!options) return finalQuantity;
|
||||
@@ -57,7 +55,7 @@ export const getQuantityToRemove = ({
|
||||
finalQuantity = options.upcoming_quantity || options.quantity || 1;
|
||||
}
|
||||
|
||||
if (isContUsePrice({ price })) {
|
||||
if (isAllocatedPrice(price)) {
|
||||
const ents = cusProductToEnts({ cusProduct });
|
||||
const relatedEnt = getPriceEntitlement(price, ents);
|
||||
const existingUsage = getExistingUsageFromCusProducts({
|
||||
|
||||
@@ -120,7 +120,7 @@ export const cusEntsToPrepaidQuantity = ({
|
||||
// 2. If cus ent is not prepaid, skip
|
||||
const cusPrice = cusEntToCusPrice({ cusEnt });
|
||||
|
||||
if (!cusPrice || !isPrepaidPrice({ price: cusPrice.price })) continue;
|
||||
if (!cusPrice || !isPrepaidPrice(cusPrice.price)) continue;
|
||||
|
||||
// 3. Get quantity
|
||||
const options = cusEnt.customer_product.options.find(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getFeatureInvoiceDescription,
|
||||
InternalError,
|
||||
type PreviewLineItem,
|
||||
priceToFeature,
|
||||
stripeToAtmnAmount,
|
||||
type UsagePriceConfig,
|
||||
} from "@autumn/shared";
|
||||
@@ -15,10 +16,7 @@ import { findPriceInStripeItems } from "@/external/stripe/stripeSubUtils/stripeS
|
||||
import { attachParamsToCurCusProduct } from "@/internal/customers/attach/attachUtils/convertAttachParams.js";
|
||||
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
|
||||
import { getExistingUsageFromCusProducts } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
|
||||
import {
|
||||
priceToFeature,
|
||||
priceToUsageModel,
|
||||
} from "@/internal/products/prices/priceUtils/convertPrice.js";
|
||||
import { priceToUsageModel } from "@/internal/products/prices/priceUtils/convertPrice.js";
|
||||
import { getPriceEntitlement } from "@/internal/products/prices/priceUtils.js";
|
||||
import { formatUnixToDate } from "@/utils/genUtils.js";
|
||||
import { calculateProrationAmount } from "../prorationUtils.js";
|
||||
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
type IntervalConfig,
|
||||
isFixedPrice,
|
||||
isOneOffPrice,
|
||||
isPrepaidPrice,
|
||||
isUsagePrice,
|
||||
type Organization,
|
||||
type PreviewLineItem,
|
||||
type Price,
|
||||
priceToFeature,
|
||||
priceToInvoiceAmount,
|
||||
toProductItem,
|
||||
UsageModel,
|
||||
@@ -27,16 +29,12 @@ import {
|
||||
getAlignedUnix,
|
||||
getPeriodStartForEnd,
|
||||
} from "@/internal/products/prices/billingIntervalUtils2.js";
|
||||
import {
|
||||
priceToFeature,
|
||||
priceToUsageModel,
|
||||
} from "@/internal/products/prices/priceUtils/convertPrice.js";
|
||||
import { priceToUsageModel } from "@/internal/products/prices/priceUtils/convertPrice.js";
|
||||
import { sortPricesByType } from "@/internal/products/prices/priceUtils/sortPriceUtils.js";
|
||||
import { formatUnixToDate, notNullish } from "@/utils/genUtils.js";
|
||||
import type { AttachParams } from "../../customers/cusProducts/AttachParams.js";
|
||||
import { getPricecnPrice } from "../../products/pricecn/pricecnUtils.js";
|
||||
import { subtractIntervalForProration } from "../../products/prices/billingIntervalUtils.js";
|
||||
import { isPrepaidPrice } from "../../products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
|
||||
|
||||
import {
|
||||
formatPrice,
|
||||
@@ -242,7 +240,7 @@ export const getItemsForNewProduct = async ({
|
||||
continue;
|
||||
}
|
||||
|
||||
if (withPrepaid && isPrepaidPrice({ price })) {
|
||||
if (withPrepaid && isPrepaidPrice(price)) {
|
||||
const options = getPriceOptions(price, attachParams.optionsList);
|
||||
const quantity = notNullish(options?.quantity) ? options?.quantity : 1;
|
||||
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import {
|
||||
BillingInterval,
|
||||
BillingType,
|
||||
type EntitlementWithFeature,
|
||||
type Feature,
|
||||
type FullProduct,
|
||||
isFixedPrice,
|
||||
type Price,
|
||||
type ProductOptions,
|
||||
UsageModel,
|
||||
type UsagePriceConfig,
|
||||
} from "@autumn/shared";
|
||||
import { getBillingType, getPriceEntitlement } from "../priceUtils.js";
|
||||
import { getBillingType } from "../priceUtils.js";
|
||||
|
||||
export const priceToIntervalKey = (price: Price) => {
|
||||
return toIntervalKey({
|
||||
@@ -52,31 +49,6 @@ export const intervalKeyToPrice = (intervalKey: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const priceToFeature = ({
|
||||
price,
|
||||
ents,
|
||||
features,
|
||||
}: {
|
||||
price: Price;
|
||||
ents?: EntitlementWithFeature[];
|
||||
features?: Feature[];
|
||||
}) => {
|
||||
if (!features && !ents) {
|
||||
throw new Error("priceToFeature requires either ents or features as arg");
|
||||
}
|
||||
|
||||
if (features) {
|
||||
return features.find(
|
||||
(f) =>
|
||||
f.internal_id ===
|
||||
(price.config as UsagePriceConfig).internal_feature_id,
|
||||
);
|
||||
}
|
||||
|
||||
const ent = getPriceEntitlement(price, ents!);
|
||||
return ent?.feature;
|
||||
};
|
||||
|
||||
export const priceToUsageModel = (price: Price) => {
|
||||
const billingType = getBillingType(price.config);
|
||||
if (isFixedPrice(price)) {
|
||||
|
||||
@@ -24,11 +24,6 @@ export const isContUsePrice = ({ price }: { price?: Price }) => {
|
||||
return billingType === BillingType.InArrearProrated;
|
||||
};
|
||||
|
||||
export const isPrepaidPrice = ({ price }: { price: Price }) => {
|
||||
const billingType = getBillingType(price.config);
|
||||
return billingType === BillingType.UsageInAdvance;
|
||||
};
|
||||
|
||||
export const hasPrepaidPrice = ({
|
||||
prices,
|
||||
excludeOneOff,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import {
|
||||
BillingType,
|
||||
Feature,
|
||||
FullProduct,
|
||||
Price,
|
||||
UsagePriceConfig,
|
||||
type FullProduct,
|
||||
type Price,
|
||||
priceToFeature,
|
||||
type UsagePriceConfig,
|
||||
} from "@autumn/shared";
|
||||
import { priceToFeature } from "../convertPrice.js";
|
||||
import { getBillingType } from "../../priceUtils.js";
|
||||
|
||||
export const usagePriceToProductName = ({
|
||||
@@ -15,7 +14,7 @@ export const usagePriceToProductName = ({
|
||||
price: Price;
|
||||
fullProduct: FullProduct;
|
||||
}) => {
|
||||
let feature = priceToFeature({
|
||||
const feature = priceToFeature({
|
||||
price,
|
||||
ents: fullProduct.entitlements,
|
||||
});
|
||||
@@ -24,7 +23,7 @@ export const usagePriceToProductName = ({
|
||||
return fullProduct.name;
|
||||
}
|
||||
|
||||
let billingType = getBillingType(price.config);
|
||||
const billingType = getBillingType(price.config);
|
||||
const billingUnits = (price.config as UsagePriceConfig).billing_units;
|
||||
if (
|
||||
billingType == BillingType.UsageInAdvance &&
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type FullCustomer,
|
||||
type FullProduct,
|
||||
getProductItemDisplay,
|
||||
isPrepaidPrice,
|
||||
type Price,
|
||||
type ProductItem,
|
||||
toApiFeature,
|
||||
@@ -21,7 +22,6 @@ import { notNullish } from "@/utils/genUtils.js";
|
||||
import { getFreeTrialAfterFingerprint } from "../../free-trials/freeTrialUtils.js";
|
||||
import { sortProductItems } from "../../pricecn/pricecnUtils.js";
|
||||
import { getLargestInterval } from "../../prices/priceUtils/priceIntervalUtils.js";
|
||||
import { isPrepaidPrice } from "../../prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
|
||||
import { getItemType } from "../../product-items/productItemUtils/getItemType.js";
|
||||
import { itemToPriceOrTiers } from "../../product-items/productItemUtils.js";
|
||||
import { isFreeProduct, isOneOff } from "../../productUtils.js";
|
||||
@@ -149,8 +149,7 @@ export const getProductProperties = ({
|
||||
has_trial: hasFreeTrial,
|
||||
updateable: product.prices.some(
|
||||
(p: Price) =>
|
||||
isPrepaidPrice({ price: p }) &&
|
||||
p.config.interval !== BillingInterval.OneOff,
|
||||
isPrepaidPrice(p) && p.config.interval !== BillingInterval.OneOff,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type FullCustomer,
|
||||
isFixedPrice,
|
||||
isOneOffPrice,
|
||||
isPrepaidPrice,
|
||||
type Organization,
|
||||
} from "@autumn/shared";
|
||||
import type { DrizzleCli } from "@server/db/initDrizzle";
|
||||
@@ -31,7 +32,6 @@ import {
|
||||
getPriceEntitlement,
|
||||
getPriceOptions,
|
||||
} from "@server/internal/products/prices/priceUtils";
|
||||
import { isPrepaidPrice } from "@server/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice";
|
||||
import { isFreeProduct } from "@server/internal/products/productUtils";
|
||||
import type Stripe from "stripe";
|
||||
import { formatUnixToDateTime, nullish } from "../genUtils";
|
||||
@@ -411,12 +411,12 @@ export const checkCusSubCorrect = async ({
|
||||
withEntity: !!cusProduct.internal_entity_id,
|
||||
isCheckout: false,
|
||||
apiVersion,
|
||||
productOptions: cusProduct.quantity
|
||||
? {
|
||||
product_id: product.id,
|
||||
quantity: Number(cusProduct.quantity || 1),
|
||||
}
|
||||
: undefined,
|
||||
// productOptions: cusProduct.quantity
|
||||
// ? {
|
||||
// product_id: product.id,
|
||||
// quantity: Number(cusProduct.quantity || 1),
|
||||
// }
|
||||
// : undefined,
|
||||
});
|
||||
|
||||
if (res?.lineItem && nullish(res.lineItem.quantity)) {
|
||||
@@ -445,8 +445,7 @@ export const checkCusSubCorrect = async ({
|
||||
priceStr: `${product.id}-${formatPrice({ price })}`,
|
||||
stripeProdId: product.processor?.id,
|
||||
autumnPrice: price,
|
||||
canSkip:
|
||||
isPrepaidPrice({ price }) && res?.lineItem?.quantity === 0,
|
||||
canSkip: isPrepaidPrice(price) && res?.lineItem?.quantity === 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,748 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { SubscriptionUpdateV0Params } from "@autumn/shared";
|
||||
import { createMockCtx } from "@tests/utils/mockUtils/contextMocks";
|
||||
import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks";
|
||||
import { createMockFeature } from "@tests/utils/mockUtils/featureMocks";
|
||||
import {
|
||||
createMockCustomerPrice,
|
||||
createMockFixedPrice,
|
||||
createMockPrepaidPrice,
|
||||
} from "@tests/utils/mockUtils/priceMocks";
|
||||
import { createMockFullProduct } from "@tests/utils/mockUtils/productMocks";
|
||||
import chalk from "chalk";
|
||||
import { computeSubscriptionUpdateFeatureQuantities } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFeatureQuantities";
|
||||
|
||||
// ============ TESTS ============
|
||||
|
||||
describe(
|
||||
chalk.yellowBright("computeSubscriptionUpdateFeatureQuantities"),
|
||||
() => {
|
||||
describe("basic quantity inheritance", () => {
|
||||
test("1. current has quantity, new params has none → uses current", () => {
|
||||
const feature = createMockFeature({
|
||||
id: "credits",
|
||||
name: "Credits",
|
||||
});
|
||||
|
||||
const price = createMockPrepaidPrice({
|
||||
id: "price_credits",
|
||||
featureId: "credits",
|
||||
});
|
||||
|
||||
const fullProduct = createMockFullProduct({ prices: [price] });
|
||||
const cusProduct = createMockCustomerProduct({
|
||||
options: [
|
||||
{
|
||||
feature_id: "credits",
|
||||
internal_feature_id: "internal_credits",
|
||||
quantity: 100,
|
||||
},
|
||||
],
|
||||
customerPrices: [createMockCustomerPrice({ price })],
|
||||
});
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
// No options provided
|
||||
};
|
||||
|
||||
const ctx = createMockCtx({ features: [feature] });
|
||||
|
||||
const result = computeSubscriptionUpdateFeatureQuantities({
|
||||
ctx,
|
||||
fullProduct,
|
||||
currentCustomerProduct: cusProduct,
|
||||
params,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].feature_id).toBe("credits");
|
||||
expect(result[0].quantity).toBe(100);
|
||||
});
|
||||
|
||||
test("2. current has no quantity, new params has quantity → uses new", () => {
|
||||
const feature = createMockFeature({
|
||||
id: "credits",
|
||||
name: "Credits",
|
||||
});
|
||||
|
||||
const price = createMockPrepaidPrice({
|
||||
id: "price_credits",
|
||||
featureId: "credits",
|
||||
});
|
||||
|
||||
const fullProduct = createMockFullProduct({ prices: [price] });
|
||||
const cusProduct = createMockCustomerProduct({ options: [] });
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
options: [{ feature_id: "credits", quantity: 50 }],
|
||||
};
|
||||
|
||||
const ctx = createMockCtx({ features: [feature] });
|
||||
|
||||
const result = computeSubscriptionUpdateFeatureQuantities({
|
||||
ctx,
|
||||
fullProduct,
|
||||
currentCustomerProduct: cusProduct,
|
||||
params,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].feature_id).toBe("credits");
|
||||
expect(result[0].quantity).toBe(50);
|
||||
});
|
||||
|
||||
test("3. both have quantity → uses new (new takes precedence)", () => {
|
||||
const feature = createMockFeature({
|
||||
id: "credits",
|
||||
name: "Credits",
|
||||
});
|
||||
|
||||
const price = createMockPrepaidPrice({
|
||||
id: "price_credits",
|
||||
featureId: "credits",
|
||||
});
|
||||
|
||||
const fullProduct = createMockFullProduct({ prices: [price] });
|
||||
const cusProduct = createMockCustomerProduct({
|
||||
options: [
|
||||
{
|
||||
feature_id: "credits",
|
||||
internal_feature_id: "internal_credits",
|
||||
quantity: 100,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
options: [{ feature_id: "credits", quantity: 200 }],
|
||||
};
|
||||
|
||||
const ctx = createMockCtx({ features: [feature] });
|
||||
|
||||
const result = computeSubscriptionUpdateFeatureQuantities({
|
||||
ctx,
|
||||
fullProduct,
|
||||
currentCustomerProduct: cusProduct,
|
||||
params,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].feature_id).toBe("credits");
|
||||
expect(result[0].quantity).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple features", () => {
|
||||
test("4. previous has all quantities, new has just one → updates only the specified one", () => {
|
||||
const creditsFeature = createMockFeature({
|
||||
id: "credits",
|
||||
name: "Credits",
|
||||
});
|
||||
const seatsFeature = createMockFeature({
|
||||
id: "seats",
|
||||
name: "Seats",
|
||||
});
|
||||
const storageFeature = createMockFeature({
|
||||
id: "storage",
|
||||
name: "Storage",
|
||||
});
|
||||
|
||||
const creditsPrice = createMockPrepaidPrice({
|
||||
id: "price_credits",
|
||||
featureId: "credits",
|
||||
});
|
||||
const seatsPrice = createMockPrepaidPrice({
|
||||
id: "price_seats",
|
||||
featureId: "seats",
|
||||
});
|
||||
const storagePrice = createMockPrepaidPrice({
|
||||
id: "price_storage",
|
||||
featureId: "storage",
|
||||
});
|
||||
|
||||
const fullProduct = createMockFullProduct({
|
||||
prices: [creditsPrice, seatsPrice, storagePrice],
|
||||
});
|
||||
|
||||
const cusProduct = createMockCustomerProduct({
|
||||
options: [
|
||||
{
|
||||
feature_id: "credits",
|
||||
internal_feature_id: "internal_credits",
|
||||
quantity: 100,
|
||||
},
|
||||
{
|
||||
feature_id: "seats",
|
||||
internal_feature_id: "internal_seats",
|
||||
quantity: 5,
|
||||
},
|
||||
{
|
||||
feature_id: "storage",
|
||||
internal_feature_id: "internal_storage",
|
||||
quantity: 50,
|
||||
},
|
||||
],
|
||||
customerPrices: [
|
||||
createMockCustomerPrice({ price: creditsPrice }),
|
||||
createMockCustomerPrice({ price: seatsPrice }),
|
||||
createMockCustomerPrice({ price: storagePrice }),
|
||||
],
|
||||
});
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
options: [{ feature_id: "seats", quantity: 10 }], // Only updating seats
|
||||
};
|
||||
|
||||
const ctx = createMockCtx({
|
||||
features: [creditsFeature, seatsFeature, storageFeature],
|
||||
});
|
||||
|
||||
const result = computeSubscriptionUpdateFeatureQuantities({
|
||||
ctx,
|
||||
fullProduct,
|
||||
currentCustomerProduct: cusProduct,
|
||||
params,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
|
||||
const credits = result.find((r) => r.feature_id === "credits");
|
||||
const seats = result.find((r) => r.feature_id === "seats");
|
||||
const storage = result.find((r) => r.feature_id === "storage");
|
||||
|
||||
expect(credits?.quantity).toBe(100); // Unchanged
|
||||
expect(seats?.quantity).toBe(10); // Updated
|
||||
expect(storage?.quantity).toBe(50); // Unchanged
|
||||
});
|
||||
});
|
||||
|
||||
describe("billing units handling", () => {
|
||||
test("5. new params quantity is rounded to billing units", () => {
|
||||
const feature = createMockFeature({
|
||||
id: "credits",
|
||||
name: "Credits",
|
||||
});
|
||||
|
||||
const price = createMockPrepaidPrice({
|
||||
id: "price_credits",
|
||||
featureId: "credits",
|
||||
billingUnits: 100, // Billing in units of 100
|
||||
});
|
||||
|
||||
const fullProduct = createMockFullProduct({ prices: [price] });
|
||||
const cusProduct = createMockCustomerProduct({ options: [] });
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
options: [{ feature_id: "credits", quantity: 150 }], // Should round up to 200
|
||||
};
|
||||
|
||||
const ctx = createMockCtx({ features: [feature] });
|
||||
|
||||
const result = computeSubscriptionUpdateFeatureQuantities({
|
||||
ctx,
|
||||
fullProduct,
|
||||
currentCustomerProduct: cusProduct,
|
||||
params,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].feature_id).toBe("credits");
|
||||
// 150 rounded up to 200, then divided by billingUnits (100) = 2
|
||||
expect(result[0].quantity).toBe(2);
|
||||
});
|
||||
|
||||
test("exact billing unit multiple is not changed", () => {
|
||||
const feature = createMockFeature({
|
||||
id: "credits",
|
||||
name: "Credits",
|
||||
});
|
||||
|
||||
const price = createMockPrepaidPrice({
|
||||
id: "price_credits",
|
||||
featureId: "credits",
|
||||
billingUnits: 50,
|
||||
});
|
||||
|
||||
const fullProduct = createMockFullProduct({ prices: [price] });
|
||||
const cusProduct = createMockCustomerProduct({ options: [] });
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
options: [{ feature_id: "credits", quantity: 200 }], // Exact multiple
|
||||
};
|
||||
|
||||
const ctx = createMockCtx({ features: [feature] });
|
||||
|
||||
const result = computeSubscriptionUpdateFeatureQuantities({
|
||||
ctx,
|
||||
fullProduct,
|
||||
currentCustomerProduct: cusProduct,
|
||||
params,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
// 200 / 50 = 4
|
||||
expect(result[0].quantity).toBe(4);
|
||||
});
|
||||
|
||||
test("small quantity rounds up to 1 billing unit", () => {
|
||||
const feature = createMockFeature({
|
||||
id: "credits",
|
||||
name: "Credits",
|
||||
});
|
||||
|
||||
const price = createMockPrepaidPrice({
|
||||
id: "price_credits",
|
||||
featureId: "credits",
|
||||
billingUnits: 1000,
|
||||
});
|
||||
|
||||
const fullProduct = createMockFullProduct({ prices: [price] });
|
||||
const cusProduct = createMockCustomerProduct({ options: [] });
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
options: [{ feature_id: "credits", quantity: 1 }], // Should round to 1000
|
||||
};
|
||||
|
||||
const ctx = createMockCtx({ features: [feature] });
|
||||
|
||||
const result = computeSubscriptionUpdateFeatureQuantities({
|
||||
ctx,
|
||||
fullProduct,
|
||||
currentCustomerProduct: cusProduct,
|
||||
params,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
// 1 rounded to 1000, then divided by billingUnits (1000) = 1
|
||||
expect(result[0].quantity).toBe(1);
|
||||
});
|
||||
|
||||
test("converts current quantity from old billing units to new billing units", () => {
|
||||
// Scenario: Customer has 5 packs of 100 credits (500 actual credits)
|
||||
// New price uses packs of 250
|
||||
// Expected: 500 credits → rounds to 500 (nearest 250) → 2 packs
|
||||
const feature = createMockFeature({
|
||||
id: "credits",
|
||||
name: "Credits",
|
||||
});
|
||||
|
||||
// Old price: billing units of 100
|
||||
const oldPrice = createMockPrepaidPrice({
|
||||
id: "price_credits_old",
|
||||
featureId: "credits",
|
||||
billingUnits: 100,
|
||||
});
|
||||
|
||||
// New price: billing units of 250
|
||||
const newPrice = createMockPrepaidPrice({
|
||||
id: "price_credits_new",
|
||||
featureId: "credits",
|
||||
billingUnits: 250,
|
||||
});
|
||||
|
||||
const fullProduct = createMockFullProduct({ prices: [newPrice] });
|
||||
|
||||
const cusProduct = createMockCustomerProduct({
|
||||
options: [
|
||||
{
|
||||
feature_id: "credits",
|
||||
internal_feature_id: "internal_credits",
|
||||
quantity: 5, // 5 packs of 100 = 500 actual credits
|
||||
},
|
||||
],
|
||||
customerPrices: [createMockCustomerPrice({ price: oldPrice })],
|
||||
});
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
// No options - should inherit from current
|
||||
};
|
||||
|
||||
const ctx = createMockCtx({ features: [feature] });
|
||||
|
||||
const result = computeSubscriptionUpdateFeatureQuantities({
|
||||
ctx,
|
||||
fullProduct,
|
||||
currentCustomerProduct: cusProduct,
|
||||
params,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].feature_id).toBe("credits");
|
||||
// Old: 5 packs * 100 = 500 credits
|
||||
// Round 500 to nearest 250 = 500
|
||||
// New: 500 / 250 = 2 packs
|
||||
expect(result[0].quantity).toBe(2);
|
||||
});
|
||||
|
||||
test("converts and rounds up when not exact multiple of new billing units", () => {
|
||||
// Scenario: Customer has 3 packs of 100 credits (300 actual credits)
|
||||
// New price uses packs of 250
|
||||
// Expected: 300 credits → rounds to 500 (nearest 250) → 2 packs
|
||||
const feature = createMockFeature({
|
||||
id: "credits",
|
||||
name: "Credits",
|
||||
});
|
||||
|
||||
const oldPrice = createMockPrepaidPrice({
|
||||
id: "price_credits_old",
|
||||
featureId: "credits",
|
||||
billingUnits: 100,
|
||||
});
|
||||
|
||||
const newPrice = createMockPrepaidPrice({
|
||||
id: "price_credits_new",
|
||||
featureId: "credits",
|
||||
billingUnits: 250,
|
||||
});
|
||||
|
||||
const fullProduct = createMockFullProduct({ prices: [newPrice] });
|
||||
|
||||
const cusProduct = createMockCustomerProduct({
|
||||
options: [
|
||||
{
|
||||
feature_id: "credits",
|
||||
internal_feature_id: "internal_credits",
|
||||
quantity: 3, // 3 packs of 100 = 300 actual credits
|
||||
},
|
||||
],
|
||||
customerPrices: [createMockCustomerPrice({ price: oldPrice })],
|
||||
});
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
};
|
||||
|
||||
const ctx = createMockCtx({ features: [feature] });
|
||||
|
||||
const result = computeSubscriptionUpdateFeatureQuantities({
|
||||
ctx,
|
||||
fullProduct,
|
||||
currentCustomerProduct: cusProduct,
|
||||
params,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
// Old: 3 packs * 100 = 300 credits
|
||||
// Round 300 to nearest 250 (ceiling) = 500
|
||||
// New: 500 / 250 = 2 packs
|
||||
expect(result[0].quantity).toBe(2);
|
||||
});
|
||||
|
||||
test("no old customer price returns undefined (no quantity inherited)", () => {
|
||||
// Scenario: No old price found - can't interpret the stored quantity
|
||||
// Should return empty result (no feature quantities)
|
||||
const feature = createMockFeature({
|
||||
id: "credits",
|
||||
name: "Credits",
|
||||
});
|
||||
|
||||
const newPrice = createMockPrepaidPrice({
|
||||
id: "price_credits_new",
|
||||
featureId: "credits",
|
||||
billingUnits: 250,
|
||||
});
|
||||
|
||||
const fullProduct = createMockFullProduct({ prices: [newPrice] });
|
||||
|
||||
const cusProduct = createMockCustomerProduct({
|
||||
options: [
|
||||
{
|
||||
feature_id: "credits",
|
||||
internal_feature_id: "internal_credits",
|
||||
quantity: 500,
|
||||
},
|
||||
],
|
||||
// No customerPrices - can't interpret stored quantity
|
||||
});
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
};
|
||||
|
||||
const ctx = createMockCtx({ features: [feature] });
|
||||
|
||||
const result = computeSubscriptionUpdateFeatureQuantities({
|
||||
ctx,
|
||||
fullProduct,
|
||||
currentCustomerProduct: cusProduct,
|
||||
params,
|
||||
});
|
||||
|
||||
// No old price means we can't interpret the stored quantity
|
||||
// So no feature quantity is inherited
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("price type filtering", () => {
|
||||
test("skips non-prepaid prices (fixed prices)", () => {
|
||||
const feature = createMockFeature({
|
||||
id: "credits",
|
||||
name: "Credits",
|
||||
});
|
||||
|
||||
const fixedPrice = createMockFixedPrice({ id: "price_fixed" });
|
||||
const prepaidPrice = createMockPrepaidPrice({
|
||||
id: "price_credits",
|
||||
featureId: "credits",
|
||||
});
|
||||
|
||||
const fullProduct = createMockFullProduct({
|
||||
prices: [fixedPrice, prepaidPrice],
|
||||
});
|
||||
|
||||
const cusProduct = createMockCustomerProduct({ options: [] });
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
options: [{ feature_id: "credits", quantity: 50 }],
|
||||
};
|
||||
|
||||
const ctx = createMockCtx({ features: [feature] });
|
||||
|
||||
const result = computeSubscriptionUpdateFeatureQuantities({
|
||||
ctx,
|
||||
fullProduct,
|
||||
currentCustomerProduct: cusProduct,
|
||||
params,
|
||||
});
|
||||
|
||||
// Only the prepaid price should be processed
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].feature_id).toBe("credits");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("empty prices array returns empty array", () => {
|
||||
const fullProduct = createMockFullProduct({ prices: [] });
|
||||
const cusProduct = createMockCustomerProduct({ options: [] });
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
};
|
||||
|
||||
const ctx = createMockCtx({ features: [] });
|
||||
|
||||
const result = computeSubscriptionUpdateFeatureQuantities({
|
||||
ctx,
|
||||
fullProduct,
|
||||
currentCustomerProduct: cusProduct,
|
||||
params,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("throws InternalError when feature not found for price", () => {
|
||||
const price = createMockPrepaidPrice({
|
||||
id: "price_credits",
|
||||
featureId: "credits",
|
||||
});
|
||||
|
||||
const fullProduct = createMockFullProduct({ prices: [price] });
|
||||
const cusProduct = createMockCustomerProduct({ options: [] });
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
};
|
||||
|
||||
// Empty features array - feature won't be found
|
||||
const ctx = createMockCtx({ features: [] });
|
||||
|
||||
expect(() =>
|
||||
computeSubscriptionUpdateFeatureQuantities({
|
||||
ctx,
|
||||
fullProduct,
|
||||
currentCustomerProduct: cusProduct,
|
||||
params,
|
||||
}),
|
||||
).toThrow("computing feature quantities for price");
|
||||
});
|
||||
|
||||
test("neither current nor new has quantity → feature not included", () => {
|
||||
const feature = createMockFeature({
|
||||
id: "credits",
|
||||
name: "Credits",
|
||||
});
|
||||
|
||||
const price = createMockPrepaidPrice({
|
||||
id: "price_credits",
|
||||
featureId: "credits",
|
||||
});
|
||||
|
||||
const fullProduct = createMockFullProduct({ prices: [price] });
|
||||
const cusProduct = createMockCustomerProduct({ options: [] }); // No current options
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
// No options in params either
|
||||
};
|
||||
|
||||
const ctx = createMockCtx({ features: [feature] });
|
||||
|
||||
const result = computeSubscriptionUpdateFeatureQuantities({
|
||||
ctx,
|
||||
fullProduct,
|
||||
currentCustomerProduct: cusProduct,
|
||||
params,
|
||||
});
|
||||
|
||||
// Since neither current nor new has quantity, nothing should be included
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("params.options explicitly set to empty array → uses current quantities", () => {
|
||||
const feature = createMockFeature({
|
||||
id: "credits",
|
||||
name: "Credits",
|
||||
});
|
||||
|
||||
const price = createMockPrepaidPrice({
|
||||
id: "price_credits",
|
||||
featureId: "credits",
|
||||
});
|
||||
|
||||
const fullProduct = createMockFullProduct({ prices: [price] });
|
||||
const cusProduct = createMockCustomerProduct({
|
||||
options: [
|
||||
{
|
||||
feature_id: "credits",
|
||||
internal_feature_id: "internal_credits",
|
||||
quantity: 100,
|
||||
},
|
||||
],
|
||||
customerPrices: [createMockCustomerPrice({ price })],
|
||||
});
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
options: [], // Explicitly empty
|
||||
};
|
||||
|
||||
const ctx = createMockCtx({ features: [feature] });
|
||||
|
||||
const result = computeSubscriptionUpdateFeatureQuantities({
|
||||
ctx,
|
||||
fullProduct,
|
||||
currentCustomerProduct: cusProduct,
|
||||
params,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].feature_id).toBe("credits");
|
||||
expect(result[0].quantity).toBe(100); // Falls back to current
|
||||
});
|
||||
|
||||
test("new quantity of 0 is valid and replaces current", () => {
|
||||
const feature = createMockFeature({
|
||||
id: "credits",
|
||||
name: "Credits",
|
||||
});
|
||||
|
||||
const price = createMockPrepaidPrice({
|
||||
id: "price_credits",
|
||||
featureId: "credits",
|
||||
});
|
||||
|
||||
const fullProduct = createMockFullProduct({ prices: [price] });
|
||||
const cusProduct = createMockCustomerProduct({
|
||||
options: [
|
||||
{
|
||||
feature_id: "credits",
|
||||
internal_feature_id: "internal_credits",
|
||||
quantity: 100,
|
||||
},
|
||||
],
|
||||
customerPrices: [createMockCustomerPrice({ price })],
|
||||
});
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
options: [{ feature_id: "credits", quantity: 0 }],
|
||||
};
|
||||
|
||||
const ctx = createMockCtx({ features: [feature] });
|
||||
|
||||
const result = computeSubscriptionUpdateFeatureQuantities({
|
||||
ctx,
|
||||
fullProduct,
|
||||
currentCustomerProduct: cusProduct,
|
||||
params,
|
||||
});
|
||||
|
||||
// quantity: 0 is falsy, so paramsToFeatureOptions returns undefined
|
||||
// Falls back to current quantity
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].quantity).toBe(100);
|
||||
});
|
||||
|
||||
test("handles feature matched by internal_feature_id in current options", () => {
|
||||
const feature = createMockFeature({
|
||||
id: "credits",
|
||||
internalId: "internal_credits_v2",
|
||||
name: "Credits",
|
||||
});
|
||||
|
||||
const price = createMockPrepaidPrice({
|
||||
id: "price_credits",
|
||||
featureId: "credits",
|
||||
internalFeatureId: "internal_credits_v2",
|
||||
});
|
||||
|
||||
const fullProduct = createMockFullProduct({ prices: [price] });
|
||||
const cusProduct = createMockCustomerProduct({
|
||||
options: [
|
||||
{
|
||||
feature_id: "old_credits_id", // Different feature_id
|
||||
internal_feature_id: "internal_credits_v2", // Matches by internal_id
|
||||
quantity: 75,
|
||||
},
|
||||
],
|
||||
customerPrices: [createMockCustomerPrice({ price })],
|
||||
});
|
||||
|
||||
const params: SubscriptionUpdateV0Params = {
|
||||
customer_id: "cus_test",
|
||||
product_id: "prod_test",
|
||||
};
|
||||
|
||||
const ctx = createMockCtx({ features: [feature] });
|
||||
|
||||
const result = computeSubscriptionUpdateFeatureQuantities({
|
||||
ctx,
|
||||
fullProduct,
|
||||
currentCustomerProduct: cusProduct,
|
||||
params,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].quantity).toBe(75);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ExistingRollover } from "@autumn/shared";
|
||||
import { createMockCusEntitlement } from "@tests/utils/mockUtils/cusEntitlementMocks";
|
||||
import { createMockCusProduct } from "@tests/utils/mockUtils/cusProductMocks";
|
||||
import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks";
|
||||
import chalk from "chalk";
|
||||
import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers";
|
||||
|
||||
@@ -18,7 +18,7 @@ describe(chalk.yellowBright("applyExistingRollovers"), () => {
|
||||
balance: 5000,
|
||||
});
|
||||
|
||||
const newCusProduct = createMockCusProduct({
|
||||
const newCusProduct = createMockCustomerProduct({
|
||||
cusEntitlements: [cusEnt],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ExistingRollover } from "@autumn/shared";
|
||||
import { createMockCusEntitlement } from "@tests/utils/mockUtils/cusEntitlementMocks";
|
||||
import { createMockCusProduct } from "@tests/utils/mockUtils/cusProductMocks";
|
||||
import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks";
|
||||
import chalk from "chalk";
|
||||
import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers";
|
||||
|
||||
@@ -15,7 +15,7 @@ describe(chalk.yellowBright("applyExistingRollovers (no matching cusEnt)"), () =
|
||||
balance: 100,
|
||||
});
|
||||
|
||||
const newCusProduct = createMockCusProduct({
|
||||
const newCusProduct = createMockCustomerProduct({
|
||||
cusEntitlements: [cusEnt],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ExistingRollover } from "@autumn/shared";
|
||||
import { createMockCusEntitlement } from "@tests/utils/mockUtils/cusEntitlementMocks";
|
||||
import { createMockCusProduct } from "@tests/utils/mockUtils/cusProductMocks";
|
||||
import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks";
|
||||
import chalk from "chalk";
|
||||
import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers";
|
||||
|
||||
@@ -17,7 +17,7 @@ describe(chalk.yellowBright("applyExistingRollovers (multiple rollovers same fea
|
||||
balance: 5000,
|
||||
});
|
||||
|
||||
const newCusProduct = createMockCusProduct({
|
||||
const newCusProduct = createMockCustomerProduct({
|
||||
cusEntitlements: [cusEnt],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ExistingRollover } from "@autumn/shared";
|
||||
import { createMockCusEntitlement } from "@tests/utils/mockUtils/cusEntitlementMocks";
|
||||
import { createMockCusProduct } from "@tests/utils/mockUtils/cusProductMocks";
|
||||
import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks";
|
||||
import chalk from "chalk";
|
||||
import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers";
|
||||
|
||||
@@ -23,7 +23,7 @@ describe(chalk.yellowBright("applyExistingRollovers (multiple cusEnts, one match
|
||||
balance: 200,
|
||||
});
|
||||
|
||||
const newCusProduct = createMockCusProduct({
|
||||
const newCusProduct = createMockCustomerProduct({
|
||||
cusEntitlements: [cusEntA, cusEntB],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ExistingRollover } from "@autumn/shared";
|
||||
import { createMockCusEntitlement } from "@tests/utils/mockUtils/cusEntitlementMocks";
|
||||
import { createMockCusProduct } from "@tests/utils/mockUtils/cusProductMocks";
|
||||
import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks";
|
||||
import chalk from "chalk";
|
||||
import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers";
|
||||
|
||||
@@ -27,7 +27,7 @@ describe(chalk.yellowBright("applyExistingRollovers (duplicate internal_feature_
|
||||
balance: 200,
|
||||
});
|
||||
|
||||
const newCusProduct = createMockCusProduct({
|
||||
const newCusProduct = createMockCustomerProduct({
|
||||
cusEntitlements: [cusEntFirst, cusEntSecond],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ExistingRollover } from "@autumn/shared";
|
||||
import { createMockCusEntitlement } from "@tests/utils/mockUtils/cusEntitlementMocks";
|
||||
import { createMockCusProduct } from "@tests/utils/mockUtils/cusProductMocks";
|
||||
import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks";
|
||||
import chalk from "chalk";
|
||||
import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers";
|
||||
|
||||
@@ -17,7 +17,7 @@ describe(chalk.yellowBright("applyExistingRollovers (zero balance, positive enti
|
||||
balance: 10,
|
||||
});
|
||||
|
||||
const newCusProduct = createMockCusProduct({
|
||||
const newCusProduct = createMockCustomerProduct({
|
||||
cusEntitlements: [cusEnt],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ExistingRollover } from "@autumn/shared";
|
||||
import { createMockCusEntitlement } from "@tests/utils/mockUtils/cusEntitlementMocks";
|
||||
import { createMockCusProduct } from "@tests/utils/mockUtils/cusProductMocks";
|
||||
import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks";
|
||||
import chalk from "chalk";
|
||||
import { applyExistingRollovers } from "@/internal/billing/billingUtils/handleExistingRollovers/applyExistingRollovers";
|
||||
|
||||
@@ -17,7 +17,7 @@ describe(chalk.yellowBright("applyExistingRollovers (zero balance, all-zero enti
|
||||
balance: 10,
|
||||
});
|
||||
|
||||
const newCusProduct = createMockCusProduct({
|
||||
const newCusProduct = createMockCustomerProduct({
|
||||
cusEntitlements: [cusEnt],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks";
|
||||
import chalk from "chalk";
|
||||
import { applyExistingUsages } from "@/internal/billing/billingUtils/handleExistingUsages/applyExistingUsages";
|
||||
|
||||
@@ -19,7 +19,7 @@ describe(chalk.yellowBright("applyExistingUsages"), () => {
|
||||
balance: 5000, // Initial balance before applying existing usages
|
||||
});
|
||||
|
||||
const cusProduct = createMockCusProduct({
|
||||
const cusProduct = createMockCustomerProduct({
|
||||
cusEntitlements: [cusEnt],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks";
|
||||
import { createMockEntity } from "@tests/utils/mockUtils/entityMocks";
|
||||
import chalk from "chalk";
|
||||
import { applyExistingUsages } from "@/internal/billing/billingUtils/handleExistingUsages/applyExistingUsages";
|
||||
@@ -30,7 +30,7 @@ describe(
|
||||
balance: 10,
|
||||
});
|
||||
|
||||
const cusProduct = createMockCusProduct({
|
||||
const cusProduct = createMockCustomerProduct({
|
||||
cusEntitlements: [cusEntA, cusEntB],
|
||||
});
|
||||
|
||||
@@ -90,7 +90,7 @@ describe(
|
||||
balance: 10,
|
||||
});
|
||||
|
||||
const cusProduct = createMockCusProduct({
|
||||
const cusProduct = createMockCustomerProduct({
|
||||
cusEntitlements: [cusEntA],
|
||||
});
|
||||
|
||||
@@ -143,7 +143,7 @@ describe(
|
||||
balance: 2,
|
||||
});
|
||||
|
||||
const cusProduct = createMockCusProduct({
|
||||
const cusProduct = createMockCustomerProduct({
|
||||
cusEntitlements: [cusEntA1, cusEntA2],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { EntInterval, type ExistingUsages } from "@autumn/shared";
|
||||
import { createMockCusEntitlement } from "@tests/utils/mockUtils/cusEntitlementMocks";
|
||||
import { createMockCusProduct } from "@tests/utils/mockUtils/cusProductMocks";
|
||||
import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks";
|
||||
import chalk from "chalk";
|
||||
import { applyExistingUsages } from "@/internal/billing/billingUtils/handleExistingUsages/applyExistingUsages";
|
||||
|
||||
@@ -33,7 +33,7 @@ describe(
|
||||
nextResetAt: Date.now() + 30 * 24 * 60 * 60 * 1000, // 30 days from now
|
||||
});
|
||||
|
||||
const cusProduct = createMockCusProduct({
|
||||
const cusProduct = createMockCustomerProduct({
|
||||
cusEntitlements: [monthlyCusEnt, lifetimeCusEnt], // Monthly first in array
|
||||
});
|
||||
|
||||
@@ -82,7 +82,7 @@ describe(
|
||||
usageAllowed: true,
|
||||
});
|
||||
|
||||
const cusProduct = createMockCusProduct({
|
||||
const cusProduct = createMockCustomerProduct({
|
||||
cusEntitlements: [payPerUseCusEnt, prepaidCusEnt], // Pay-per-use first in array
|
||||
});
|
||||
|
||||
@@ -135,7 +135,7 @@ describe(
|
||||
nextResetAt: Date.now() + 30 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
const cusProduct = createMockCusProduct({
|
||||
const cusProduct = createMockCustomerProduct({
|
||||
cusEntitlements: [payPerUseMonthly, prepaidMonthly], // Random order
|
||||
});
|
||||
|
||||
@@ -199,7 +199,7 @@ describe(
|
||||
});
|
||||
|
||||
// Add in random order
|
||||
const cusProduct = createMockCusProduct({
|
||||
const cusProduct = createMockCustomerProduct({
|
||||
cusEntitlements: [payPerUseMonthly, prepaidLifetime, prepaidMonthly],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks";
|
||||
import chalk from "chalk";
|
||||
import { applyExistingUsages } from "@/internal/billing/billingUtils/handleExistingUsages/applyExistingUsages";
|
||||
|
||||
@@ -25,7 +25,7 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => {
|
||||
},
|
||||
});
|
||||
|
||||
const cusProduct = createMockCusProduct({
|
||||
const cusProduct = createMockCustomerProduct({
|
||||
cusEntitlements: [entityScopedCusEnt],
|
||||
});
|
||||
|
||||
@@ -67,7 +67,7 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => {
|
||||
// No entityFeatureId - not entity-scoped
|
||||
});
|
||||
|
||||
const cusProduct = createMockCusProduct({
|
||||
const cusProduct = createMockCustomerProduct({
|
||||
cusEntitlements: [nonEntityScopedCusEnt],
|
||||
});
|
||||
|
||||
@@ -111,7 +111,7 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => {
|
||||
},
|
||||
});
|
||||
|
||||
const cusProduct = createMockCusProduct({
|
||||
const cusProduct = createMockCustomerProduct({
|
||||
cusEntitlements: [entityScopedCusEnt],
|
||||
});
|
||||
|
||||
@@ -161,7 +161,7 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => {
|
||||
},
|
||||
});
|
||||
|
||||
const cusProduct = createMockCusProduct({
|
||||
const cusProduct = createMockCustomerProduct({
|
||||
cusEntitlements: [entityScopedCusEnt],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { EntInterval } from "@autumn/shared";
|
||||
import { createMockCusEntitlement } from "@tests/utils/mockUtils/cusEntitlementMocks";
|
||||
import { createMockCusProduct } from "@tests/utils/mockUtils/cusProductMocks";
|
||||
import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks";
|
||||
import { createMockRollover } from "@tests/utils/mockUtils/rolloverMocks";
|
||||
import chalk from "chalk";
|
||||
import { cusProductToExistingUsages } from "@/internal/billing/billingUtils/handleExistingUsages/cusProductToExistingUsages";
|
||||
@@ -32,7 +32,7 @@ describe(chalk.yellowBright("cusProductToExistingUsages"), () => {
|
||||
nextResetAt: Date.now() + 30 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
const cusProduct = createMockCusProduct({
|
||||
const cusProduct = createMockCustomerProduct({
|
||||
cusEntitlements: [lifetimeCusEnt, monthlyCusEnt],
|
||||
});
|
||||
|
||||
@@ -73,7 +73,7 @@ describe(chalk.yellowBright("cusProductToExistingUsages"), () => {
|
||||
},
|
||||
});
|
||||
|
||||
const cusProduct = createMockCusProduct({
|
||||
const cusProduct = createMockCustomerProduct({
|
||||
cusEntitlements: [topLevelCusEnt, entityScopedCusEnt],
|
||||
});
|
||||
|
||||
@@ -125,7 +125,7 @@ describe(chalk.yellowBright("cusProductToExistingUsages"), () => {
|
||||
}),
|
||||
];
|
||||
|
||||
const cusProduct = createMockCusProduct({
|
||||
const cusProduct = createMockCustomerProduct({
|
||||
cusEntitlements: [cusEntWithRollover],
|
||||
});
|
||||
|
||||
|
||||
12
server/tests/utils/mockUtils/contextMocks.ts
Normal file
12
server/tests/utils/mockUtils/contextMocks.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Feature } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
|
||||
export const createMockCtx = ({
|
||||
features,
|
||||
}: {
|
||||
features: Feature[];
|
||||
}): AutumnContext =>
|
||||
({
|
||||
features,
|
||||
}) as AutumnContext;
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import {
|
||||
CollectionMethod,
|
||||
CusProductStatus,
|
||||
type FeatureOptions,
|
||||
type FullCusProduct,
|
||||
type FullCustomerEntitlement,
|
||||
type FullCustomerPrice,
|
||||
} from "@autumn/shared";
|
||||
import { createMockProduct } from "./productMocks";
|
||||
|
||||
export const createMockCusProduct = ({
|
||||
cusEntitlements,
|
||||
export const createMockCustomerProduct = ({
|
||||
customerEntitlements = [],
|
||||
customerPrices = [],
|
||||
options = [],
|
||||
}: {
|
||||
cusEntitlements: FullCustomerEntitlement[];
|
||||
customerEntitlements?: FullCustomerEntitlement[];
|
||||
customerPrices?: FullCustomerPrice[];
|
||||
options?: FeatureOptions[];
|
||||
}): FullCusProduct => ({
|
||||
id: "cus_prod_test",
|
||||
internal_product_id: "prod_internal",
|
||||
@@ -25,7 +31,7 @@ export const createMockCusProduct = ({
|
||||
trial_ends_at: null,
|
||||
canceled_at: null,
|
||||
ended_at: null,
|
||||
options: [],
|
||||
options,
|
||||
free_trial_id: null,
|
||||
collection_method: CollectionMethod.ChargeAutomatically,
|
||||
subscription_ids: [],
|
||||
@@ -33,8 +39,8 @@ export const createMockCusProduct = ({
|
||||
quantity: 1,
|
||||
api_semver: null,
|
||||
is_custom: false,
|
||||
customer_prices: [],
|
||||
customer_entitlements: cusEntitlements,
|
||||
customer_prices: customerPrices,
|
||||
customer_entitlements: customerEntitlements,
|
||||
product: createMockProduct(),
|
||||
free_trial: null,
|
||||
});
|
||||
|
||||
69
server/tests/utils/mockUtils/priceMocks.ts
Normal file
69
server/tests/utils/mockUtils/priceMocks.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
BillingInterval,
|
||||
BillWhen,
|
||||
type FullCustomerPrice,
|
||||
Infinite,
|
||||
type Price,
|
||||
PriceType,
|
||||
} from "@autumn/shared";
|
||||
|
||||
export const createMockPrepaidPrice = ({
|
||||
id,
|
||||
featureId,
|
||||
internalFeatureId,
|
||||
billingUnits = 1,
|
||||
}: {
|
||||
id: string;
|
||||
featureId: string;
|
||||
internalFeatureId?: string;
|
||||
billingUnits?: number;
|
||||
}): Price =>
|
||||
({
|
||||
id,
|
||||
internal_product_id: "prod_internal",
|
||||
org_id: "org_test",
|
||||
created_at: Date.now(),
|
||||
billing_type: "usage_in_advance",
|
||||
is_custom: false,
|
||||
entitlement_id: null,
|
||||
proration_config: null,
|
||||
config: {
|
||||
type: PriceType.Usage,
|
||||
bill_when: BillWhen.InAdvance,
|
||||
billing_units: billingUnits,
|
||||
internal_feature_id: internalFeatureId ?? `internal_${featureId}`,
|
||||
feature_id: featureId,
|
||||
usage_tiers: [{ to: Infinite, amount: 10 }],
|
||||
interval: BillingInterval.Month,
|
||||
},
|
||||
}) as Price;
|
||||
|
||||
export const createMockFixedPrice = ({ id }: { id: string }): Price =>
|
||||
({
|
||||
id,
|
||||
internal_product_id: "prod_internal",
|
||||
org_id: "org_test",
|
||||
created_at: Date.now(),
|
||||
billing_type: "fixed_cycle",
|
||||
is_custom: false,
|
||||
entitlement_id: null,
|
||||
proration_config: null,
|
||||
config: {
|
||||
type: PriceType.Fixed,
|
||||
amount: 100,
|
||||
interval: BillingInterval.Month,
|
||||
},
|
||||
}) as Price;
|
||||
|
||||
export const createMockCustomerPrice = ({
|
||||
price,
|
||||
}: {
|
||||
price: Price;
|
||||
}): FullCustomerPrice => ({
|
||||
id: `cus_price_${price.id}`,
|
||||
internal_customer_id: "cus_internal",
|
||||
customer_product_id: "cus_prod_test",
|
||||
created_at: Date.now(),
|
||||
price_id: price.id,
|
||||
price,
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AppEnv } from "@autumn/shared";
|
||||
import { AppEnv, type FullProduct, type Price } from "@autumn/shared";
|
||||
|
||||
export const createMockProduct = () => ({
|
||||
id: "prod_test",
|
||||
@@ -17,5 +17,27 @@ export const createMockProduct = () => ({
|
||||
archived: false,
|
||||
});
|
||||
|
||||
|
||||
|
||||
export const createMockFullProduct = ({
|
||||
prices,
|
||||
}: {
|
||||
prices: Price[];
|
||||
}): FullProduct =>
|
||||
({
|
||||
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,
|
||||
prices,
|
||||
entitlements: [],
|
||||
free_trial: null,
|
||||
}) as FullProduct;
|
||||
|
||||
@@ -77,6 +77,7 @@ export * from "./billing/checkout/prevVersions/checkoutParamsV0.js";
|
||||
export * from "./billing/checkout/prevVersions/checkoutParamsV0.js";
|
||||
export * from "./billing/checkout/prevVersions/checkoutResponseV0.js";
|
||||
export * from "./billing/subscriptionUpdate/subscriptionUpdateV0Params.js";
|
||||
export * from "./billing/subscriptionUpdate/subscriptionUpdateV0Params.js";
|
||||
export * from "./billing/subscriptionUpdate/subscriptionUpdateV1Params.js";
|
||||
export * from "./common/customerData.js";
|
||||
export * from "./common/entityData.js";
|
||||
|
||||
@@ -100,8 +100,8 @@ export * from "./models/attachModels/attachFunctionResponse.js";
|
||||
export * from "./models/billingModels/cusProductActions.js";
|
||||
export * from "./models/billingModels/existingRollovers.js";
|
||||
export * from "./models/billingModels/existingUsages.js";
|
||||
export * from "./models/billingModels/index.js";
|
||||
export * from "./models/billingModels/initFullCusProductContext.js";
|
||||
|
||||
export * from "./models/billingModels/initFullCustomerProductContext.js";
|
||||
export * from "./models/billingModels/invoicingModels/lineItem.js";
|
||||
// Billing Models
|
||||
export * from "./models/billingModels/newProductAction.js";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ExistingRollover } from "@models/billingModels/existingRollovers";
|
||||
import type { ApiVersion } from "../../api/versionUtils/ApiVersion";
|
||||
import type { FullCustomer } from "../cusModels/fullCusModel";
|
||||
import type { AttachReplaceable } from "../cusProductModels/cusEntModels/replaceableSchema";
|
||||
import type {
|
||||
CollectionMethod,
|
||||
CusProductStatus,
|
||||
@@ -9,17 +9,17 @@ import type { FeatureOptions } from "../cusProductModels/cusProductModels";
|
||||
import type { FullProduct } from "../productModels/productModels";
|
||||
import type { ExistingUsages } from "./existingUsages";
|
||||
|
||||
export interface InitFullCusProductContext {
|
||||
fullCus: FullCustomer;
|
||||
product: FullProduct;
|
||||
export interface InitFullCustomerProductContext {
|
||||
fullCustomer: FullCustomer;
|
||||
fullProduct: FullProduct;
|
||||
featureQuantities: FeatureOptions[];
|
||||
replaceables: AttachReplaceable[];
|
||||
|
||||
// For customer entitlements
|
||||
existingUsages?: ExistingUsages;
|
||||
existingRollovers?: ExistingRollover[];
|
||||
}
|
||||
|
||||
export interface InitFullCusProductOptions {
|
||||
export interface InitFullCustomerProductOptions {
|
||||
subscriptionId?: string;
|
||||
subscriptionScheduleId?: string;
|
||||
isCustom?: boolean;
|
||||
@@ -9,3 +9,4 @@ export * from "./invoicingUtils/lineItemBuilders/usagePriceToLineItem";
|
||||
export * from "./invoicingUtils/lineItemUtils/priceToLineAmount";
|
||||
export * from "./invoicingUtils/lineItemUtils/tiersToLineAmount";
|
||||
export * from "./invoicingUtils/prorationUtils/applyProration";
|
||||
export * from "./usageUtils/roundUsageToNearestBillingUnit";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Feature } from "../../../../models/featureModels/featureModels";
|
||||
import { getSingularAndPlural, numberWithCommas } from "../../../displayUtils";
|
||||
import { roundUsageToNearestBillingUnit } from "../lineItemUtils/roundUsageToNearestBillingUnit";
|
||||
import { roundUsageToNearestBillingUnit } from "../../usageUtils/roundUsageToNearestBillingUnit";
|
||||
|
||||
/**
|
||||
* Generates base usage description for a feature.
|
||||
|
||||
@@ -6,8 +6,10 @@ import { cusEntToStripeIds } from "../../../cusEntUtils/convertCusEntUtils/cusEn
|
||||
import { cusEntToInvoiceOverage } from "../../../cusEntUtils/overageUtils/cusEntToInvoiceOverage";
|
||||
import { cusEntToInvoiceUsage } from "../../../cusEntUtils/overageUtils/cusEntToInvoiceUsage";
|
||||
import { cusEntToCusPrice } from "../../../productUtils/convertUtils";
|
||||
import { isPrepaidPrice } from "../../../productUtils/priceUtils";
|
||||
import { isConsumablePrice } from "../../../productUtils/priceUtils/classifyPriceUtils";
|
||||
import {
|
||||
isConsumablePrice,
|
||||
isPrepaidPrice,
|
||||
} from "../../../productUtils/priceUtils/classifyPriceUtils";
|
||||
import { usagePriceToLineDescription } from "../descriptionUtils/usagePriceToLineDescription";
|
||||
import { priceToLineAmount } from "../lineItemUtils/priceToLineAmount";
|
||||
import { buildLineItem } from "./buildLineItem";
|
||||
@@ -38,7 +40,7 @@ export const usagePriceToLineItem = ({
|
||||
|
||||
// 1. Get overage
|
||||
let overage = 0;
|
||||
if (isPrepaidPrice({ price: cusPrice.price })) {
|
||||
if (isPrepaidPrice(cusPrice.price)) {
|
||||
overage = cusEntToPrepaidQuantity({ cusEnt });
|
||||
} else {
|
||||
overage = cusEntToInvoiceOverage({ cusEnt });
|
||||
@@ -46,7 +48,7 @@ export const usagePriceToLineItem = ({
|
||||
|
||||
// 2. Get usage
|
||||
let usage = 0;
|
||||
if (isPrepaidPrice({ price: cusPrice.price })) {
|
||||
if (isPrepaidPrice(cusPrice.price)) {
|
||||
usage = cusEntToPrepaidQuantity({ cusEnt });
|
||||
} else {
|
||||
usage = cusEntToInvoiceUsage({ cusEnt });
|
||||
|
||||
@@ -2,7 +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";
|
||||
import { roundUsageToNearestBillingUnit } from "../../usageUtils/roundUsageToNearestBillingUnit";
|
||||
|
||||
export const tiersToLineAmount = ({
|
||||
price,
|
||||
|
||||
@@ -109,8 +109,6 @@ export const getMaxOverage = ({
|
||||
const usageLimit = cusEnt.entitlement.usage_limit;
|
||||
if (nullish(usageLimit)) return undefined;
|
||||
|
||||
// const cusPrice = cusEntToCusPrice({ cusEnt });
|
||||
// if (cusPrice && isPrepaidPrice({ price: cusPrice.price })) return undefined;
|
||||
if (!cusEnt.usage_allowed) return undefined;
|
||||
|
||||
const maxOverage = new Decimal(usageLimit)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Decimal } from "decimal.js";
|
||||
import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js";
|
||||
import { cusEntToCusPrice } from "../../productUtils/convertUtils.js";
|
||||
import { isPrepaidPrice } from "../../productUtils/priceUtils.js";
|
||||
import { isPrepaidPrice } from "../../productUtils/priceUtils/classifyPriceUtils.js";
|
||||
|
||||
export const cusEntToPrepaidQuantity = ({
|
||||
cusEnt,
|
||||
@@ -11,7 +11,7 @@ export const cusEntToPrepaidQuantity = ({
|
||||
// 2. If cus ent is not prepaid, skip
|
||||
const cusPrice = cusEntToCusPrice({ cusEnt });
|
||||
|
||||
if (!cusPrice || !isPrepaidPrice({ price: cusPrice.price })) return 0;
|
||||
if (!cusPrice || !isPrepaidPrice(cusPrice.price)) return 0;
|
||||
|
||||
// 3. Get quantity
|
||||
const options = cusEnt.customer_product.options.find(
|
||||
|
||||
@@ -4,7 +4,7 @@ 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";
|
||||
import { isPrepaidPrice } from "../productUtils/priceUtils/classifyPriceUtils.js";
|
||||
|
||||
export const formatCusEnt = ({
|
||||
cusEnt,
|
||||
@@ -80,7 +80,7 @@ export const isPrepaidCusEnt = ({
|
||||
}) => {
|
||||
// 2. If cus ent is not prepaid, skip
|
||||
const cusPrice = cusEntToCusPrice({ cusEnt });
|
||||
if (!cusPrice || !isPrepaidPrice({ price: cusPrice.price })) return false;
|
||||
if (!cusPrice || !isPrepaidPrice(cusPrice.price)) return false;
|
||||
|
||||
// 3. Get quantity
|
||||
const options = cusEnt.customer_product.options.find(
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { FullCustomerPrice } from "@models/cusProductModels/cusPriceModels/cusPriceModels";
|
||||
import type { Feature } from "@models/featureModels/featureModels";
|
||||
import { isPrepaidPrice } from "@utils/productUtils/priceUtils/classifyPriceUtils";
|
||||
|
||||
export const findPrepaidCusPriceByFeature = ({
|
||||
customerPrices,
|
||||
feature,
|
||||
}: {
|
||||
customerPrices: FullCustomerPrice[];
|
||||
feature: Feature;
|
||||
}) => {
|
||||
return customerPrices.find((cp) => {
|
||||
if (!isPrepaidPrice(cp.price)) return false;
|
||||
return cp.price.config.internal_feature_id === feature.internal_id;
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import type {
|
||||
FeatureOptions,
|
||||
FullCusProduct,
|
||||
} from "@models/cusProductModels/cusProductModels";
|
||||
import type { Feature } from "@models/featureModels/featureModels";
|
||||
import type { Price } from "@models/productModels/priceModels/priceModels";
|
||||
import { roundUsageToNearestBillingUnit } from "@utils/billingUtils/usageUtils/roundUsageToNearestBillingUnit";
|
||||
import { findPrepaidCusPriceByFeature } from "@utils/cusPriceUtils/findCusPriceUtils/findPrepaidCusPriceByFeature";
|
||||
import { Decimal } from "decimal.js";
|
||||
import { cusProductToFeatureOptions } from "./cusProductToFeatureOptions";
|
||||
|
||||
/**
|
||||
* Get the feature options from a customer product, converted to new price billing units
|
||||
*/
|
||||
export const cusProductToConvertedFeatureOptions = ({
|
||||
cusProduct,
|
||||
feature,
|
||||
newPrice,
|
||||
}: {
|
||||
cusProduct: FullCusProduct;
|
||||
feature: Feature;
|
||||
newPrice: Price;
|
||||
}): FeatureOptions | undefined => {
|
||||
const currentOption = cusProductToFeatureOptions({ cusProduct, feature });
|
||||
|
||||
if (!currentOption?.quantity) return undefined;
|
||||
|
||||
const oldCusPrice = findPrepaidCusPriceByFeature({
|
||||
customerPrices: cusProduct.customer_prices,
|
||||
feature,
|
||||
});
|
||||
|
||||
// If no old price found, we can't interpret the stored quantity
|
||||
if (!oldCusPrice) return undefined;
|
||||
|
||||
const oldBillingUnits = oldCusPrice.price.config.billing_units ?? 1;
|
||||
const newBillingUnits = newPrice.config.billing_units ?? 1;
|
||||
|
||||
// 1. Multiply by old billing units to get actual quantity
|
||||
const actualQuantity = new Decimal(currentOption.quantity)
|
||||
.mul(oldBillingUnits)
|
||||
.toNumber();
|
||||
|
||||
// 2. Round to nearest new billing unit
|
||||
const roundedQuantity = roundUsageToNearestBillingUnit({
|
||||
usage: actualQuantity,
|
||||
billingUnits: newBillingUnits,
|
||||
});
|
||||
|
||||
// 3. Divide by new billing units
|
||||
const convertedQuantity = new Decimal(roundedQuantity)
|
||||
.div(newBillingUnits)
|
||||
.toNumber();
|
||||
|
||||
return {
|
||||
internal_feature_id: feature.internal_id,
|
||||
feature_id: feature.id,
|
||||
quantity: convertedQuantity,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import type {
|
||||
FeatureOptions,
|
||||
FullCusProduct,
|
||||
} from "@models/cusProductModels/cusProductModels";
|
||||
import type { Feature } from "@models/featureModels/featureModels";
|
||||
/**
|
||||
* Get the feature quantity for a cus product
|
||||
* @param cusProduct - The cus product to get the feature quantity for
|
||||
* @returns The feature quantity
|
||||
*/
|
||||
export const cusProductToFeatureOptions = ({
|
||||
cusProduct,
|
||||
feature,
|
||||
}: {
|
||||
cusProduct: FullCusProduct;
|
||||
feature: Feature;
|
||||
}): FeatureOptions | undefined => {
|
||||
return cusProduct.options.find(
|
||||
(option) =>
|
||||
option.internal_feature_id === feature.internal_id ||
|
||||
option.feature_id === feature.id,
|
||||
);
|
||||
};
|
||||
@@ -31,6 +31,8 @@ export * from "./cusPriceUtils/convertCusPriceUtils.js";
|
||||
export * from "./cusPriceUtils/findCusPriceUtils.js";
|
||||
// Cus product utils
|
||||
export * from "./cusProductUtils/classifyCusProduct.js";
|
||||
export * from "./cusProductUtils/convertCusProduct/cusProductToConvertedFeatureOptions.js";
|
||||
export * from "./cusProductUtils/convertCusProduct/cusProductToFeatureOptions.js";
|
||||
export * from "./cusProductUtils/convertCusProduct.js";
|
||||
export * from "./cusProductUtils/cusProductConstants.js";
|
||||
export * from "./cusProductUtils/cusProductUtils.js";
|
||||
@@ -61,6 +63,7 @@ export * from "./productUtils/entUtils/formatEntUtils.js";
|
||||
export * from "./productUtils/isProductUpgrade.js";
|
||||
export * from "./productUtils/priceUtils/classifyPriceUtils.js";
|
||||
export * from "./productUtils/priceUtils/convertAmountUtils.js";
|
||||
export * from "./productUtils/priceUtils/convertPriceUtils.js";
|
||||
export * from "./productUtils/priceUtils/formatPriceUtils.js";
|
||||
export * from "./productUtils/priceUtils.js";
|
||||
export * from "./productV2Utils/mapToProductV2.js";
|
||||
|
||||
@@ -48,11 +48,6 @@ export const getBillingType = (config: FixedPriceConfig | UsagePriceConfig) => {
|
||||
return BillingType.UsageInArrear;
|
||||
};
|
||||
|
||||
export const isPrepaidPrice = ({ price }: { price: Price }) => {
|
||||
const billingType = getBillingType(price.config);
|
||||
return billingType === BillingType.UsageInAdvance;
|
||||
};
|
||||
|
||||
export const hasPrepaidPrice = ({
|
||||
prices,
|
||||
excludeOneOff,
|
||||
|
||||
@@ -66,3 +66,10 @@ export const isAllocatedPrice = (
|
||||
const billingType = getBillingType(price.config);
|
||||
return billingType === BillingType.InArrearProrated;
|
||||
};
|
||||
|
||||
export const isPrepaidPrice = (
|
||||
price: Price,
|
||||
): price is Price & { config: UsagePriceConfig } => {
|
||||
const billingType = getBillingType(price.config);
|
||||
return billingType === BillingType.UsageInAdvance;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Feature } from "@models/featureModels/featureModels";
|
||||
import type { EntitlementWithFeature } from "@models/productModels/entModels/entModels";
|
||||
import type { UsagePriceConfig } from "@models/productModels/priceModels/priceConfig/usagePriceConfig";
|
||||
import type { Price } from "@models/productModels/priceModels/priceModels";
|
||||
import { priceToEnt } from "@utils/productUtils/convertUtils";
|
||||
|
||||
export const priceToFeature = ({
|
||||
price,
|
||||
ents,
|
||||
features,
|
||||
}: {
|
||||
price: Price;
|
||||
ents?: EntitlementWithFeature[];
|
||||
features?: Feature[];
|
||||
}) => {
|
||||
if (!features && !ents) {
|
||||
throw new Error("priceToFeature requires either ents or features as arg");
|
||||
}
|
||||
|
||||
if (features) {
|
||||
return features.find(
|
||||
(f) =>
|
||||
f.internal_id ===
|
||||
(price.config as UsagePriceConfig).internal_feature_id,
|
||||
);
|
||||
}
|
||||
|
||||
const ent = priceToEnt({ price, entitlements: ents ?? [] });
|
||||
return ent?.feature;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user