new plan params

This commit is contained in:
John Yeo
2026-02-11 07:48:19 -08:00
parent f1d2e64810
commit bd89579b74
59 changed files with 1448 additions and 1411 deletions

View File

@@ -16,7 +16,7 @@ in the test logs. Use your common sense
# Linting and Codebase rules
- You can access the biome linter by running `bunx biome check <folder or file path>`. Always specify a folder path, as the codebase is quite large and you will get out of scope errors that you are not burdened to correct. If you would like to let biome automatically fix as much as it can, use `bunx biome check --write <folder or file path>`
- Note, biome does not perform typechecking. In which case you need to, you may run `tsgo --noEmit --skipLibCheck <folder or file path>`
- Note, biome does not perform typechecking. For type checking, run `bun ts` from the `server/` directory (which runs `bunx tsgo --build --noEmit`). This checks the entire server project with proper path resolution.
- The `server/src/_luaScriptsV2/` folder contains Lua scripts for Redis atomic operations. Redis uses **Lua 5.1** - there is NO `goto` statement (added in Lua 5.2), so use if/else blocks instead.

View File

@@ -14,7 +14,7 @@ import {
type BillingPreviewResponse,
type BillingResponse,
type CheckQuery,
type CreateBalanceParams,
type CreateBalanceParamsV0,
type CreateCustomerInternalOptions,
type CreateCustomerParams,
type CreateEntityParams,
@@ -562,22 +562,22 @@ export class AutumnInt {
};
products = {
update: async (productId: string, product: any) => {
// if (product.items && typeof product.items === "object") {
// product.items = Object.values(product.items);
// }
update: async <TResponse = any, TInput = any>(
productId: string,
product: TInput,
): Promise<TResponse> => {
const data = await this.patch(`/products/${productId}`, product);
return data;
return data as TResponse;
},
get: async (
get: async <T = any>(
productId: string,
{ v1Schema = false }: { v1Schema?: boolean } = {},
) => {
): Promise<T> => {
const data = await this.get(
`/products/${productId}?${v1Schema ? "schemaVersion=1" : ""}`,
);
return data;
return data as T;
},
list: async <T = any[]>(): Promise<{ list: T }> => {
@@ -585,9 +585,11 @@ export class AutumnInt {
return data as { list: T };
},
create: async (product: any) => {
create: async <TResponse = any, TInput = any>(
product: TInput,
): Promise<TResponse> => {
const data = await this.post(`/products`, product);
return data;
return data as TResponse;
},
delete: async (productId: string) => {
@@ -796,7 +798,7 @@ export class AutumnInt {
};
balances = {
create: async (params: CreateBalanceParams) => {
create: async (params: CreateBalanceParamsV0) => {
const data = await this.post(`/balances/create`, params);
return data;
},

View File

@@ -1,6 +1,7 @@
import {
apiPlanItem,
type CreateBalanceParams,
type CreateBalanceParamsV0,
createBalanceParamsV0ToPlanItemV0,
enrichEntitlementWithFeature,
type Feature,
type FullCustomer,
@@ -19,11 +20,16 @@ export const prepareNewBalanceForInsertion = async ({
ctx: AutumnContext;
feature: Feature;
fullCustomer: FullCustomer;
params: CreateBalanceParams;
params: CreateBalanceParamsV0;
}) => {
const planItem = createBalanceParamsV0ToPlanItemV0({
ctx,
params,
});
const inputAsItem = apiPlanItem.map.v0ToProductItem({
ctx,
planItem: params,
planItem,
});
const { ent: newEntitlement } = toFeature({

View File

@@ -1,15 +1,12 @@
import {
type CreateBalanceParams,
ErrCode,
type CreateBalanceParamsV0,
type Feature,
FeatureType,
type FullCustomer,
RecaseError,
ValidateCreateBalanceParamsSchema,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService";
import { getApiCustomerBase } from "@/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase";
export const validateCreateBalanceParams = async ({
@@ -19,7 +16,7 @@ export const validateCreateBalanceParams = async ({
fullCustomer,
}: {
ctx: AutumnContext;
params: CreateBalanceParams;
params: CreateBalanceParamsV0;
feature: Feature;
fullCustomer: FullCustomer;
}) => {

View File

@@ -1,4 +1,4 @@
import { CreateBalanceParamsSchema } from "@autumn/shared";
import { CreateBalanceParamsV0Schema } from "@autumn/shared";
import { FeatureNotFoundError } from "@shared/index";
import type { DrizzleCli } from "@/db/initDrizzle";
import { createRoute } from "@/honoMiddlewares/routeHandler";
@@ -9,7 +9,7 @@ import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntit
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService";
export const handleCreateBalance = createRoute({
body: CreateBalanceParamsSchema,
body: CreateBalanceParamsV0Schema,
handler: async (c) => {
const ctx = c.get("ctx");
const { org, env } = ctx;

View File

@@ -4,7 +4,7 @@ import {
isCustomerProductTrialing,
isProductPaidAndRecurring,
} from "@autumn/shared";
import type { FreeTrialParamsV0 } from "@shared/api/billing/common/freeTrial/freeTrialParamsV0";
import type { FreeTrialParamsV0 } from "@shared/api/common/freeTrial/freeTrialParamsV0";
import type Stripe from "stripe";
import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
import { initFreeTrial } from "@/internal/products/free-trials/initFreeTrial";

View File

@@ -3,7 +3,7 @@ import {
CreateFreeTrialSchema,
type FreeTrial,
} from "@autumn/shared";
import type { FreeTrialParamsV0 } from "@shared/api/billing/common/freeTrial/freeTrialParamsV0";
import type { FreeTrialParamsV0 } from "@shared/api/common/freeTrial/freeTrialParamsV0";
import { generateId } from "@/utils/genUtils";
export const initFreeTrial = ({

View File

@@ -4,7 +4,7 @@ import {
ApiVersion,
apiPlan,
applyResponseVersionChanges,
CreatePlanParamsSchema,
CreatePlanParamsV1Schema,
type CreateProductV2Params,
CreateProductV2ParamsSchema,
type Entitlement,
@@ -36,7 +36,7 @@ import { validateDefaultFlag } from "./productActions/validateDefaultFlag.js";
export const handleCreatePlan = createRoute({
// body: CreateProductV2ParamsSchema,
versionedBody: {
latest: CreatePlanParamsSchema,
latest: CreatePlanParamsV1Schema,
[ApiVersion.V1_Beta]: CreateProductV2ParamsSchema,
},
resource: AffectedResource.Product,
@@ -46,7 +46,7 @@ export const handleCreatePlan = createRoute({
const v1_2Body = (
ctx.apiVersion.gte(ApiVersion.V2_0)
? apiPlan.map.v0ToProductV2({ ctx, plan: body })
? apiPlan.map.paramsV1ToProductV2({ ctx, params: body })
: body
) as CreateProductV2Params;

View File

@@ -13,7 +13,7 @@ const DeleteProductParamsSchema = z.object({
});
const DeleteProductQuerySchema = z.object({
all_versions: z.boolean(),
all_versions: z.boolean().default(false),
});
export const handleDeleteProduct = createRoute({

View File

@@ -37,9 +37,9 @@ export const handlePlanHasCustomersV2 = createRoute({
// V2.0+ (CLI): body is CreatePlanParams, convert to ProductV2
// < V2.0 (Dashboard): body is already ProductV2
const productV2 = apiVersion.gte(ApiVersion.V2_0)
? (apiPlan.map.v0ToProductV2({
? (apiPlan.map.paramsV1ToProductV2({
ctx,
plan: body,
params: body,
}) as ProductV2)
: (body as ProductV2);

View File

@@ -12,7 +12,7 @@ import {
type ProductV2,
productsAreSame,
RecaseError,
UpdatePlanParamsSchema,
UpdatePlanParamsV1Schema,
UpdatePlanQuerySchema,
UpdateProductQuerySchema,
UpdateProductSchema,
@@ -38,7 +38,7 @@ import { handleUpdateProductDetails } from "./updateProductDetails.js";
export const handleUpdatePlan = createRoute({
versionedBody: {
latest: UpdatePlanParamsSchema,
latest: UpdatePlanParamsV1Schema,
[ApiVersion.V1_Beta]: UpdateProductV2ParamsSchema,
},
versionedQuery: {
@@ -59,9 +59,9 @@ export const handleUpdatePlan = createRoute({
// V1.2 clients already send ProductV2, no conversion needed
const v1_2Body = ctx.apiVersion.gte(new ApiVersionClass(ApiVersion.V2_0))
? (apiPlan.map.v0ToProductV2({
? (apiPlan.map.paramsV1ToProductV2({
ctx,
plan: body,
params: body,
}) as UpdateProductV2Params)
: (body as UpdateProductV2Params);

View File

@@ -56,9 +56,9 @@ const sortProductItems = (items: ProductItem[], features: Feature[]) => {
}
// 3. Put feature price items in alphabetical order
const feature = features.find((f) => f.id == a.feature_id);
const feature = features.find((f) => f.id === a.feature_id);
const aFeatureName = feature?.name;
const bFeatureName = features.find((f) => f.id == b.feature_id)?.name;
const bFeatureName = features.find((f) => f.id === b.feature_id)?.name;
if (!aFeatureName || !bFeatureName) {
return 0;
@@ -96,7 +96,7 @@ const getPriceText = ({
const tiers = item.tiers;
if (tiers) {
if (tiers.length == 1) {
if (tiers.length === 1) {
return formatAmount({ org, amount: tiers[0].amount });
}
@@ -139,7 +139,7 @@ export const getPricecnPrice = ({
secondaryText: priceItem.interval ? `per ${priceItem.interval}` : " ",
};
} else {
const feature = features.find((f) => f.id == priceItem.feature_id);
const feature = features.find((f) => f.id === priceItem.feature_id);
const texts = featurePricetoPricecnItem({
feature,
item: priceItem,
@@ -169,7 +169,7 @@ const featureToPricecnItem = ({
});
}
// 1. If feature
if (item.feature_type == ProductItemFeatureType.Static) {
if (item.feature_type === ProductItemFeatureType.Static) {
return {
primaryText: feature.name,
};
@@ -181,9 +181,9 @@ const featureToPricecnItem = ({
});
const includedUsageTxt =
item.included_usage == Infinite
item.included_usage === Infinite
? "Unlimited "
: nullish(item.included_usage) || item.included_usage == 0
: nullish(item.included_usage) || item.included_usage === 0
? ""
: `${numberWithCommas(item.included_usage!)} `;
@@ -232,7 +232,7 @@ const featurePricetoPricecnItem = ({
const priceStr = getPriceText({ item, org });
const billingFeatureName = getFeatureName({
feature,
plural: typeof item.billing_units == "number" && item.billing_units > 1,
plural: typeof item.billing_units === "number" && item.billing_units > 1,
});
let priceStr2 = "";
@@ -274,13 +274,13 @@ const getAttachScenario = ({
}
// 1. If current product is the same as the product, return active
if (curMainProduct?.product.id == fullProduct.id) {
if (curMainProduct?.product.id === fullProduct.id) {
if (curMainProduct.canceled_at != null) {
return AttachScenario.Renew;
} else return AttachScenario.Active;
}
if (curScheduledProduct?.product.id == fullProduct.id) {
if (curScheduledProduct?.product.id === fullProduct.id) {
return AttachScenario.Scheduled;
}
@@ -339,7 +339,7 @@ export const toPricecnProduct = async ({
};
}
const feature = features.find((f) => f.id == i.feature_id);
const feature = features.find((f) => f.id === i.feature_id);
if (isFeaturePriceItem(i)) {
data = featurePricetoPricecnItem({
feature,
@@ -362,8 +362,8 @@ export const toPricecnProduct = async ({
};
});
const isCurrent = curMainProduct?.product.id == product.id;
const isScheduled = curScheduledProduct?.product.id == product.id;
const isCurrent = curMainProduct?.product.id === product.id;
const isScheduled = curScheduledProduct?.product.id === product.id;
let buttonText = "Get Started";
@@ -385,7 +385,7 @@ export const toPricecnProduct = async ({
let baseVariant = null;
if (fullProduct.base_variant_id) {
baseVariant = otherProducts.find(
(p) => p.id == fullProduct.base_variant_id,
(p) => p.id === fullProduct.base_variant_id,
);
}
@@ -397,25 +397,25 @@ export const toPricecnProduct = async ({
let intervalGroup = null;
if (
baseVariant ||
otherProducts.some((p) => p.base_variant_id == product.id)
otherProducts.some((p) => p.base_variant_id === product.id)
) {
const intervalSet = getLargestInterval({ prices: fullProduct.prices });
intervalGroup = intervalSet?.interval;
}
let trialAvailable = false;
if (product.free_trial && fullCus) {
if (fullProduct.free_trial && fullCus) {
let trial = await getFreeTrialAfterFingerprint({
db,
freeTrial: product.free_trial,
freeTrial: fullProduct.free_trial,
fingerprint: fullCus.fingerprint,
internalCustomerId: fullCus.internal_id,
multipleAllowed: org.config.multiple_trials,
productId: product.id,
});
if (scenario == AttachScenario.Downgrade) trial = null;
trialAvailable = notNullish(trial) ? true : false;
if (scenario === AttachScenario.Downgrade) trial = null;
trialAvailable = !!notNullish(trial);
}
return {

View File

@@ -0,0 +1,398 @@
import { expect, test } from "bun:test";
import {
type ApiPlan,
type ApiProduct,
ApiVersion,
BillingInterval,
BillingMethod,
type CreatePlanParamsInput,
FreeTrialDuration,
Infinite,
ProductItemInterval,
ResetInterval,
TierInfinite,
UsageModel,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features";
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 });
const autumnV1_2 = new AutumnInt({ version: ApiVersion.V1_2 });
// ═══════════════════════════════════════════════════════════════════════════════
// METERED & USAGE FEATURES
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("create: metered feature with monthly reset")}`, async () => {
const productId = "metered_monthly";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
const created = await autumnV2.products.create<
ApiPlan,
CreatePlanParamsInput
>({
id: productId,
name: "Metered Monthly",
items: [
{
feature_id: TestFeature.Messages,
included: 1000,
reset: { interval: ResetInterval.Month },
},
],
});
expect(created.features).toHaveLength(1);
expect(created.features[0]).toMatchObject({
feature_id: TestFeature.Messages,
granted_balance: 1000,
});
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.items[0]).toMatchObject({
included_usage: 1000,
interval: ProductItemInterval.Month,
});
});
test.concurrent(`${chalk.yellowBright("create: usage pricing (pay-per-use)")}`, async () => {
const productId = "usage_price";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
const created = await autumnV2.products.create<
ApiPlan,
CreatePlanParamsInput
>({
id: productId,
name: "Usage Price",
items: [
{
feature_id: TestFeature.Messages,
price: {
amount: 10,
interval: BillingInterval.Month,
billing_method: BillingMethod.UsageBased,
billing_units: 1,
},
},
],
});
expect(created.features[0]).toMatchObject({
price: { amount: 10, usage_model: UsageModel.PayPerUse },
});
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.items[0]).toMatchObject({
price: 10,
usage_model: UsageModel.PayPerUse,
billing_units: 1,
interval: ProductItemInterval.Month,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TIERED PRICING
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("create: feature with tiered pricing")}`, async () => {
const productId = "tiered_pricing";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
const created = await autumnV2.products.create<
ApiPlan,
CreatePlanParamsInput
>({
id: productId,
name: "Tiered Pricing Plan",
items: [
{
feature_id: TestFeature.Messages,
price: {
tiers: [
{ to: 100, amount: 0.1 },
{ to: 500, amount: 0.08 },
{ to: TierInfinite, amount: 0.05 },
],
interval: BillingInterval.Month,
billing_method: BillingMethod.UsageBased,
billing_units: 1,
},
},
],
});
const feature = created.features[0];
expect(feature.price!.tiers).toHaveLength(3);
expect(feature.price!.tiers).toEqual([
{ to: 100, amount: 0.1 },
{ to: 500, amount: 0.08 },
{ to: TierInfinite, amount: 0.05 },
]);
expect(feature.price!.usage_model).toBe(UsageModel.PayPerUse);
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.items[0].tiers).toHaveLength(3);
expect(v1_2.items[0].tiers).toEqual([
{ to: 100, amount: 0.1 },
{ to: 500, amount: 0.08 },
{ to: TierInfinite, amount: 0.05 },
]);
expect(v1_2.items[0]).toMatchObject({
usage_model: UsageModel.PayPerUse,
interval: ProductItemInterval.Month,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// CROSS-VERSION CONSISTENCY
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("cross-version: V2 CREATE → V1.2 GET field transformations")}`, async () => {
const productId = "cross_v2";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
await autumnV2.products.create<ApiPlan, CreatePlanParamsInput>({
id: productId,
name: "Cross Version V2",
add_on: true,
auto_enable: false,
price: { amount: 1000, interval: BillingInterval.Month },
items: [
{
feature_id: TestFeature.Messages,
included: 100,
reset: { interval: ResetInterval.Month },
},
],
});
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2).toMatchObject({
is_add_on: true,
is_default: false,
});
expect(v1_2.items).toHaveLength(2); // base price + feature
const basePrice = v1_2.items.find((i) => !i.feature_id);
expect(basePrice).toMatchObject({ price: 1000 });
const feature = v1_2.items.find((i) => i.feature_id === TestFeature.Messages);
expect(feature).toMatchObject({ included_usage: 100 });
});
test.concurrent(`${chalk.yellowBright("cross-version: round-trip V2 → V1.2 → V2 data consistency")}`, async () => {
const productId = "roundtrip";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
await autumnV2.products.create<ApiPlan, CreatePlanParamsInput>({
id: productId,
name: "Round Trip",
price: { amount: 5000, interval: BillingInterval.Month },
items: [
{
feature_id: TestFeature.Messages,
included: 500,
reset: { interval: ResetInterval.Month },
},
],
});
const backToV2 = await autumnV2.products.get<ApiPlan>(productId);
expect(backToV2).toMatchObject({
name: "Round Trip",
price: { amount: 5000 },
});
expect(backToV2.features).toHaveLength(1);
expect(backToV2.features[0]).toMatchObject({ granted_balance: 500 });
});
test.concurrent(`${chalk.yellowBright("cross-version: free trial transformation (duration_type → duration)")}`, async () => {
const productId = "trial_transform";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
await autumnV2.products.create<ApiPlan, CreatePlanParamsInput>({
id: productId,
name: "Trial Transform",
price: { amount: 2900, interval: BillingInterval.Month },
free_trial: {
duration_type: FreeTrialDuration.Day,
duration_length: 7,
card_required: true,
},
});
const v2 = await autumnV2.products.get<ApiPlan>(productId);
expect(v2.free_trial).toMatchObject({
duration_type: FreeTrialDuration.Day,
duration_length: 7,
});
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.free_trial).toMatchObject({
duration: FreeTrialDuration.Day,
length: 7,
unique_fingerprint: true,
});
});
test.concurrent(`${chalk.yellowBright("cross-version: unlimited feature transformation")}`, async () => {
const productId = "unlimited_transform";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
await autumnV2.products.create<ApiPlan, CreatePlanParamsInput>({
id: productId,
name: "Unlimited Transform",
items: [
{
feature_id: TestFeature.Messages,
unlimited: true,
},
],
});
const v2 = await autumnV2.products.get<ApiPlan>(productId);
expect(v2.features[0]).toMatchObject({
unlimited: true,
granted_balance: 0,
});
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.items[0]).toMatchObject({ included_usage: Infinite });
});
test.concurrent(`${chalk.yellowBright("cross-version: tiered pricing transformation")}`, async () => {
const productId = "tiered_transform";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
await autumnV2.products.create<ApiPlan, CreatePlanParamsInput>({
id: productId,
name: "Tiered Transform",
items: [
{
feature_id: TestFeature.Messages,
price: {
interval: BillingInterval.Month,
billing_method: BillingMethod.UsageBased,
billing_units: 1,
tiers: [
{ to: 100, amount: 10 },
{ to: 1000, amount: 5 },
{ to: TierInfinite, amount: 2 },
],
},
},
],
});
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.items[0].tiers).toHaveLength(3);
expect(v1_2.items[0].tiers![2]).toMatchObject({ to: TierInfinite });
});
// ═══════════════════════════════════════════════════════════════════════════════
// VALIDATION / REJECTION TESTS
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("validation: REJECT reset.interval + price.interval mismatch")}`, async () => {
const productId = "invalid_both";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
await expectAutumnError({
errCode: "invalid_inputs",
func: async () => {
await autumnV2.products.create<ApiPlan, CreatePlanParamsInput>({
id: productId,
name: "Invalid Both Intervals",
items: [
{
feature_id: TestFeature.Messages,
included: 100,
reset: { interval: ResetInterval.Minute },
price: {
amount: 10,
interval: BillingInterval.Month,
billing_method: BillingMethod.UsageBased,
billing_units: 1,
},
},
],
});
},
});
});
test.concurrent(`${chalk.yellowBright("validation: ACCEPT only reset_interval (metered, no price)")}`, async () => {
const productId = "only_reset";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
await autumnV2.products.create<ApiPlan, CreatePlanParamsInput>({
id: productId,
name: "Only Reset Interval",
items: [
{
feature_id: TestFeature.Messages,
included: 100,
reset: { interval: ResetInterval.Month },
},
],
});
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.items[0]).toMatchObject({
interval: ProductItemInterval.Month,
});
expect(v1_2.items[0].price).toBeUndefined();
});
test.concurrent(`${chalk.yellowBright("validation: ACCEPT only price.interval (usage pricing, no reset)")}`, async () => {
const productId = "only_price_interval";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
await autumnV2.products.create<ApiPlan, CreatePlanParamsInput>({
id: productId,
name: "Only Price Interval",
items: [
{
feature_id: TestFeature.Messages,
price: {
amount: 10,
interval: BillingInterval.Month,
billing_method: BillingMethod.UsageBased,
billing_units: 1,
},
},
],
});
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.items[0]).toMatchObject({
price: 10,
interval: ProductItemInterval.Month,
});
});

View File

@@ -1,51 +1,430 @@
import { describe, expect, test } from "bun:test";
import type { ApiPlan, ApiProduct, CreatePlanParams } from "@autumn/shared";
import { expect, test } from "bun:test";
import {
type ApiPlan,
type ApiProduct,
ApiVersion,
BillingInterval,
BillingMethod,
type CreatePlanParamsInput,
FreeTrialDuration,
OnDecrease,
OnIncrease,
ProductItemInterval,
ResetInterval,
RolloverExpiryDurationType,
TierInfinite,
UsageModel,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features";
import chalk from "chalk";
import { AutumnCliV2 } from "@/external/autumn/autumnCliV2.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
describe(chalk.yellowBright("Plan V2 - Basic CREATE Tests"), () => {
const autumnV2 = new AutumnCliV2({ version: "2.0.0" });
const autumnV1_2 = new AutumnCliV2({ version: "1.2.0" });
const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 });
const autumnV1_2 = new AutumnInt({ version: ApiVersion.V1_2 });
test("CREATE: minimal plan (id + name only)", async () => {
const productId = "min_plan";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
// ═══════════════════════════════════════════════════════════════════════════════
// BASIC CREATION TESTS
// ═══════════════════════════════════════════════════════════════════════════════
const created = (await autumnV2.products.create({
id: "min_plan",
name: "Minimal Plan",
} as CreatePlanParams)) as ApiPlan;
test.concurrent(`${chalk.yellowBright("create: minimal plan (id + name only)")}`, async () => {
const productId = "min_plan";
try {
await autumnV2.products.delete(productId);
} catch (_error) {
console.error(_error);
}
// V2 response validation
expect(created.id).toBe("min_plan");
expect(created.features).toHaveLength(0);
// V1.2 validation (using items format)
const v1_2 = (await autumnV1_2.products.get("min_plan")) as ApiProduct;
expect(v1_2.items).toHaveLength(0);
expect(v1_2.is_add_on).toBe(false);
const created = await autumnV2.products.create<
ApiPlan,
CreatePlanParamsInput
>({
id: productId,
name: "Minimal Plan",
});
test("CREATE: description field (V2 only)", async () => {
const productId = "with_desc";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
expect(created.id).toBe(productId);
expect(created.features).toHaveLength(0);
const created = (await autumnV2.products.create({
id: "with_desc",
name: "With Description",
description: "Test description for V2",
} as CreatePlanParams)) as ApiPlan;
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.items).toHaveLength(0);
expect(v1_2.is_add_on).toBe(false);
});
// V2 response validation
expect(created.description).toBe("Test description for V2");
test.concurrent(`${chalk.yellowBright("create: with description field")}`, async () => {
const productId = "with_desc";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
// V1.2 validation - description not in V1.2 schema
const v1_2 = (await autumnV1_2.products.get("with_desc")) as ApiProduct;
// @ts-expect-error: Descriptions aren't in the type, but we're just double checking the response.
expect(v1_2.description).toBeUndefined();
const created = await autumnV2.products.create<
ApiPlan,
CreatePlanParamsInput
>({
id: productId,
name: "With Description",
description: "Test description for V2",
});
expect(created.description).toBe("Test description for V2");
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
// @ts-expect-error: Descriptions aren't in the type, but we're just double checking the response.
expect(v1_2.description).toBeUndefined();
});
// ═══════════════════════════════════════════════════════════════════════════════
// BOOLEAN FEATURE TESTS
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("create: boolean feature")}`, async () => {
const productId = "bool_plan";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
const created = await autumnV2.products.create<
ApiPlan,
CreatePlanParamsInput
>({
id: productId,
name: "Boolean Plan",
items: [{ feature_id: TestFeature.Dashboard }],
});
expect(created.features).toHaveLength(1);
expect(created.features[0].feature_id).toBe(TestFeature.Dashboard);
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.items).toHaveLength(1);
expect(v1_2.items[0]).toMatchObject({
feature_id: TestFeature.Dashboard,
included_usage: 0,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// FLAGS TESTS
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("create: add_on and auto_enable flags")}`, async () => {
const productId = "flags_test";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
const created = await autumnV2.products.create<
ApiPlan,
CreatePlanParamsInput
>({
id: productId,
name: "Flags Test",
add_on: true,
auto_enable: false,
});
expect(created.add_on).toBe(true);
expect(created.default).toBe(false);
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.is_add_on).toBe(true);
expect(v1_2.is_default).toBe(false);
});
// ═══════════════════════════════════════════════════════════════════════════════
// BASE PRICING TESTS
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("create: monthly base price")}`, async () => {
const productId = "monthly_base";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
const created = await autumnV2.products.create<
ApiPlan,
CreatePlanParamsInput
>({
id: productId,
name: "Monthly Base",
price: { amount: 2900, interval: BillingInterval.Month },
});
expect(created.price!.amount).toBe(2900);
expect(created.price!.interval).toBe(BillingInterval.Month);
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
const basePrice = v1_2.items.find((i) => !i.feature_id);
expect(basePrice!.price).toBe(2900);
expect(basePrice!.interval).toBe(ProductItemInterval.Month);
});
test.concurrent(`${chalk.yellowBright("create: yearly base price")}`, async () => {
const productId = "yearly_base";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
const created = await autumnV2.products.create<
ApiPlan,
CreatePlanParamsInput
>({
id: productId,
name: "Yearly Base",
price: { amount: 29900, interval: BillingInterval.Year },
});
expect(created.price!.amount).toBe(29900);
expect(created.price!.interval).toBe(BillingInterval.Year);
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
const basePrice = v1_2.items[0];
expect(basePrice.price).toBe(29900);
expect(basePrice.interval).toBe(ProductItemInterval.Year);
});
// ═══════════════════════════════════════════════════════════════════════════════
// COMPLEX REAL-WORLD SCENARIOS
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("create: full SaaS plan with multiple feature types")}`, async () => {
const productId = "saas_complete";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
await autumnV2.products.create<ApiPlan, CreatePlanParamsInput>({
id: productId,
name: "SaaS Complete",
description: "Full-featured SaaS plan",
price: { amount: 9900, interval: BillingInterval.Month },
items: [
{ feature_id: TestFeature.Dashboard },
{
feature_id: TestFeature.Messages,
included: 10000,
reset: { interval: ResetInterval.Month },
rollover: {
max: 20000,
expiry_duration_type: RolloverExpiryDurationType.Month,
expiry_duration_length: 1,
},
},
{
feature_id: TestFeature.Users,
included: 10,
reset: { interval: ResetInterval.Month },
proration: {
on_increase: OnIncrease.ProrateImmediately,
on_decrease: OnDecrease.Prorate,
},
},
],
free_trial: {
duration_type: FreeTrialDuration.Day,
duration_length: 14,
card_required: false,
},
});
const v2 = await autumnV2.products.get<ApiPlan>(productId);
expect(v2.features).toHaveLength(3);
expect(v2.free_trial).toBeDefined();
expect(v2.description).toBe("Full-featured SaaS plan");
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.items).toHaveLength(4); // 1 base price + 3 features
expect(v1_2.free_trial).toBeDefined();
});
test.concurrent(`${chalk.yellowBright("create: usage-only product (no base price)")}`, async () => {
const productId = "usage_only";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
await autumnV2.products.create<ApiPlan, CreatePlanParamsInput>({
id: productId,
name: "Usage Only",
items: [
{
feature_id: TestFeature.Messages,
price: {
interval: BillingInterval.Month,
billing_method: BillingMethod.UsageBased,
billing_units: 1,
tiers: [
{ to: 1000, amount: 5 },
{ to: TierInfinite, amount: 2 },
],
},
},
],
});
const v2 = await autumnV2.products.get<ApiPlan>(productId);
expect(v2.price).toBeNull();
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.items).toHaveLength(1);
});
test.concurrent(`${chalk.yellowBright("create: metered feature with rollover")}`, async () => {
const productId = "with_rollover";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
await autumnV2.products.create<ApiPlan, CreatePlanParamsInput>({
id: productId,
name: "With Rollover",
items: [
{
feature_id: TestFeature.Messages,
included: 1000,
reset: { interval: ResetInterval.Month },
rollover: {
max: 2000,
expiry_duration_type: RolloverExpiryDurationType.Month,
expiry_duration_length: 1,
},
},
],
});
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
const item = v1_2.items[0];
expect(item.config).toBeDefined();
expect(item.config!.rollover).toBeDefined();
expect(item.config!.rollover!.max).toBe(2000);
expect(item.config!.rollover!.duration).toBe(
RolloverExpiryDurationType.Month,
);
expect(item.config!.rollover!.length).toBe(1);
});
test.concurrent(`${chalk.yellowBright("create: enterprise plan with all features")}`, async () => {
const productId = "enterprise_complete";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
await autumnV2.products.create<ApiPlan, CreatePlanParamsInput>({
id: productId,
name: "Enterprise Complete",
description: "Full-featured enterprise plan with all capabilities",
group: "",
add_on: false,
auto_enable: false,
price: { amount: 49900, interval: BillingInterval.Month },
items: [
{ feature_id: TestFeature.Dashboard },
{
feature_id: TestFeature.Messages,
included: 100000,
price: {
interval: BillingInterval.Month,
billing_method: BillingMethod.UsageBased,
billing_units: 1000,
tiers: [
{ to: 100000, amount: 10 },
{ to: 500000, amount: 50 },
{ to: 1000000, amount: 40 },
{ to: TierInfinite, amount: 30 },
],
},
},
{
feature_id: TestFeature.Users,
included: 50,
price: {
amount: 20,
interval: BillingInterval.Month,
billing_method: BillingMethod.UsageBased,
billing_units: 1,
},
proration: {
on_increase: OnIncrease.ProrateImmediately,
on_decrease: OnDecrease.Prorate,
},
},
],
free_trial: {
duration_type: FreeTrialDuration.Day,
duration_length: 30,
card_required: true,
},
});
// V2 validation
const v2 = await autumnV2.products.get<ApiPlan>(productId);
expect(v2).toMatchObject({
price: { amount: 49900 },
description: "Full-featured enterprise plan with all capabilities",
free_trial: { duration_length: 30 },
});
expect(v2.features).toHaveLength(3);
// Boolean feature
expect(v2.features).toEqual(
expect.arrayContaining([
expect.objectContaining({ feature_id: TestFeature.Dashboard }),
]),
);
// Metered feature
const meteredFeature = v2.features.find(
(f) => f.feature_id === TestFeature.Messages,
);
expect(meteredFeature).toMatchObject({
granted_balance: 100000,
price: { billing_units: 1000 },
});
expect(meteredFeature!.price!.tiers).toHaveLength(4);
// Seats feature
const seatsFeature = v2.features.find(
(f) => f.feature_id === TestFeature.Users,
);
expect(seatsFeature).toMatchObject({
granted_balance: 50,
price: expect.objectContaining({
amount: 20,
interval: BillingInterval.Month,
usage_model: UsageModel.PayPerUse,
max_purchase: null,
// interval_count: 1,
// billing_method: BillingMethod.UsageBased,
}),
// proration: {
// on_increase: OnIncrease.ProrateImmediately,
// on_decrease: OnDecrease.ProrateImmediately,
// },
});
// V1.2 validation
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.items).toHaveLength(4); // 1 base price + 3 features
expect(v1_2.free_trial).toMatchObject({ length: 30 });
const basePriceItem = v1_2.items.find((i) => !i.feature_id);
expect(basePriceItem).toMatchObject({
price: 49900,
interval: ProductItemInterval.Month,
});
const meteredItem = v1_2.items.find(
(i) => i.feature_id === TestFeature.Messages,
);
expect(meteredItem!.tiers).toHaveLength(4);
expect(meteredItem!.tiers![0]).toMatchObject({ to: 100000, amount: 10 });
const seatsItem = v1_2.items.find((i) => i.feature_id === TestFeature.Users);
expect(seatsItem).toMatchObject({
price: 20,
// config: {
// on_increase: OnIncrease.ProrateImmediately,
// on_decrease: OnDecrease.ProrateImmediately,
// },
});
});

View File

@@ -1,31 +0,0 @@
import { describe, expect, test } from "bun:test";
import type { ApiPlan, ApiProduct, CreatePlanParams } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features";
import chalk from "chalk";
import { AutumnCliV2 } from "@/external/autumn/autumnCliV2.js";
describe(chalk.yellowBright("Plan V2 - Boolean Feature Tests"), () => {
const autumnV2 = new AutumnCliV2({ version: "2.0.0" });
const autumnV1_2 = new AutumnCliV2({ version: "1.2.0" });
test("CREATE: boolean feature", async () => {
const productId = "bool_plan";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
const created = (await autumnV2.products.create({
id: "bool_plan",
name: "Boolean Plan",
features: [{ feature_id: TestFeature.Dashboard }],
} as CreatePlanParams)) as ApiPlan;
// V2 response validation
expect(created.features).toHaveLength(1);
expect(created.features[0].feature_id).toBe(TestFeature.Dashboard);
// V1.2 validation (items format)
const v1_2 = (await autumnV1_2.products.get("bool_plan")) as ApiProduct;
expect(v1_2.items[0].feature_id).toBe(TestFeature.Dashboard);
});
});

View File

@@ -1,311 +0,0 @@
import { describe, expect, test } from "bun:test";
import {
type ApiPlan,
type ApiProduct,
ApiVersion,
BillingInterval,
type CreatePlanParams,
FreeTrialDuration,
OnDecrease,
OnIncrease,
ProductItemInterval,
ResetInterval,
RolloverExpiryDurationType,
TierInfinite,
UsageModel,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
describe(chalk.yellowBright("Plan V2 - Complex Real-World Scenarios"), () => {
const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 });
const autumnV1_2 = new AutumnInt({ version: ApiVersion.V1_2 });
test("Full SaaS plan with multiple feature types", async () => {
const productId = "saas_complete";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
await autumnV2.products.create({
id: "saas_complete",
name: "SaaS Complete",
description: "Full-featured SaaS plan",
price: { amount: 9900, interval: BillingInterval.Month },
features: [
// Boolean feature
{ feature_id: TestFeature.Dashboard },
// Metered with rollover
{
feature_id: TestFeature.Messages,
granted_balance: 10000,
reset: {
interval: ResetInterval.Month,
},
rollover: {
max: 20000,
expiry_duration_type: RolloverExpiryDurationType.Month,
expiry_duration_length: 1,
},
},
// Continuous with proration
{
feature_id: TestFeature.Users,
granted_balance: 10,
reset: {
interval: ResetInterval.Month,
},
proration: {
on_increase: OnIncrease.ProrateImmediately,
on_decrease: OnDecrease.Prorate,
},
},
],
free_trial: {
duration_type: FreeTrialDuration.Day,
duration_length: 14,
card_required: false,
},
} as CreatePlanParams);
const v2 = (await autumnV2.products.get("saas_complete")) as ApiPlan;
expect(v2.features).toHaveLength(3);
expect(v2.free_trial).toBeDefined();
expect(v2.description).toBe("Full-featured SaaS plan");
const v1_2 = (await autumnV1_2.products.get("saas_complete")) as ApiProduct;
expect(v1_2.items).toHaveLength(4); // 1 base price + 3 features
expect(v1_2.free_trial).toBeDefined();
});
test("Usage-only product (no base price)", async () => {
const productId = "usage_only";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
await autumnV2.products.create({
id: "usage_only",
name: "Usage Only",
features: [
{
feature_id: TestFeature.Messages,
price: {
interval: BillingInterval.Month,
usage_model: UsageModel.PayPerUse,
billing_units: 1,
tiers: [
{ to: 1000, amount: 5 },
{ to: TierInfinite, amount: 2 },
],
},
},
],
} as CreatePlanParams);
const v2 = (await autumnV2.products.get("usage_only")) as ApiPlan;
expect(v2.price).toBeNull();
const v1_2 = (await autumnV1_2.products.get("usage_only")) as ApiProduct;
expect(v1_2.items).toHaveLength(1);
});
test("Metered feature with rollover", async () => {
const productId = "with_rollover";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
await autumnV2.products.create({
id: "with_rollover",
name: "With Rollover",
features: [
{
feature_id: TestFeature.Messages,
granted_balance: 1000,
reset: {
interval: ResetInterval.Month,
},
rollover: {
max: 2000,
expiry_duration_type: RolloverExpiryDurationType.Month,
expiry_duration_length: 1,
},
},
],
} as CreatePlanParams);
const v1_2 = (await autumnV1_2.products.get("with_rollover")) as ApiProduct;
const item = v1_2.items[0];
expect(item.config).toBeDefined();
expect(item.config!.rollover).toBeDefined();
expect(item.config!.rollover!.max).toBe(2000);
expect(item.config!.rollover!.duration).toBe(
RolloverExpiryDurationType.Month,
);
expect(item.config!.rollover!.length).toBe(1);
});
test("Multiple reset intervals (hour, day, week, quarter, year)", async () => {
const intervals = [
{ reset: ResetInterval.Hour, expected: ProductItemInterval.Hour },
{ reset: ResetInterval.Day, expected: ProductItemInterval.Day },
{ reset: ResetInterval.Week, expected: ProductItemInterval.Week },
{ reset: ResetInterval.Quarter, expected: ProductItemInterval.Quarter },
{ reset: ResetInterval.Year, expected: ProductItemInterval.Year },
];
for (const { reset, expected } of intervals) {
const id = `interval_${expected}`;
try {
await autumnV2.products.delete(id);
} catch (_error) {}
await autumnV2.products.create({
id,
name: `Interval ${expected}`,
features: [
{
feature_id: TestFeature.Messages,
granted_balance: 100,
reset: {
interval: reset,
},
},
],
} as CreatePlanParams);
const v1_2 = (await autumnV1_2.products.get(id)) as ApiProduct;
expect(v1_2.items[0].interval).toBe(expected);
}
});
test("Enterprise plan: base price + tiered usage + proration + rollover + boolean + free trial", async () => {
const productId = "enterprise_complete";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
await autumnV2.products.create({
id: "enterprise_complete",
name: "Enterprise Complete",
description: "Full-featured enterprise plan with all capabilities",
group: "",
add_on: false,
default: false,
price: { amount: 49900, interval: BillingInterval.Month },
features: [
// Boolean feature (SSO enabled)
{ feature_id: TestFeature.Dashboard },
// Metered API calls with tiered pricing (no reset_interval - using price.interval)
{
feature_id: TestFeature.Messages,
granted_balance: 100000,
// rollover with tiered pricing
price: {
interval: BillingInterval.Month,
usage_model: UsageModel.PayPerUse,
billing_units: 1000,
tiers: [
{ to: 100000, amount: 10 },
{ to: 500000, amount: 50 },
{ to: 1000000, amount: 40 },
{ to: TierInfinite, amount: 30 },
],
},
},
// Seats with proration (proration requires pricing)
{
feature_id: TestFeature.Users,
granted_balance: 50,
price: {
amount: 20,
interval: BillingInterval.Month,
usage_model: UsageModel.PayPerUse,
billing_units: 1,
},
proration: {
on_increase: OnIncrease.ProrateImmediately,
on_decrease: OnDecrease.Prorate,
},
},
],
free_trial: {
duration_type: FreeTrialDuration.Day,
duration_length: 30,
card_required: true,
},
} as CreatePlanParams);
// V2 validation
const v2 = (await autumnV2.products.get("enterprise_complete")) as ApiPlan;
expect(v2.price!.amount).toBe(49900);
expect(v2.features).toHaveLength(3);
expect(v2.description).toBe(
"Full-featured enterprise plan with all capabilities",
);
expect(v2.free_trial).toBeDefined();
expect(v2.free_trial!.duration_length).toBe(30);
// Validate boolean feature
const booleanFeature = v2.features.find(
(f) => f.feature_id === TestFeature.Dashboard,
);
expect(booleanFeature).toBeDefined();
// Validate metered feature with tiered pricing (no rollover)
const meteredFeature = v2.features.find(
(f) => f.feature_id === TestFeature.Messages,
);
expect(meteredFeature!.granted_balance).toBe(100000);
expect(meteredFeature!.price).toBeDefined();
expect(meteredFeature!.price!.tiers).toHaveLength(4);
expect(meteredFeature!.price!.billing_units).toBe(1000);
// Validate seats with proration
const seatsFeature = v2.features.find(
(f) => f.feature_id === TestFeature.Users,
);
expect(seatsFeature!.granted_balance).toBe(50);
expect(seatsFeature!.price).toBeDefined();
expect(seatsFeature!.proration).toBeDefined();
expect(seatsFeature!.proration!.on_increase).toBe(
OnIncrease.ProrateImmediately,
);
// on_decrease transforms to prorate_immediately when on_increase is prorate_immediately
expect(seatsFeature!.proration!.on_decrease).toBe(
OnDecrease.ProrateImmediately,
);
// V1.2 validation
const v1_2 = (await autumnV1_2.products.get(
"enterprise_complete",
)) as ApiProduct;
expect(v1_2.items).toHaveLength(4); // 1 base price + 3 features
expect(v1_2.free_trial).toBeDefined();
expect(v1_2.free_trial!.length).toBe(30);
// Validate base price item
const basePriceItem = v1_2.items.find((i) => !i.feature_id);
expect(basePriceItem!.price).toBe(49900);
expect(basePriceItem!.interval).toBe(ProductItemInterval.Month);
// Validate metered item has tiers (no rollover)
const meteredItem = v1_2.items.find(
(i) => i.feature_id === TestFeature.Messages,
);
expect(meteredItem!.tiers).toHaveLength(4);
expect(meteredItem!.tiers![0].to).toBe(100000);
expect(meteredItem!.tiers![0].amount).toBe(10);
// Validate seats item has proration config
const seatsItem = v1_2.items.find(
(i) => i.feature_id === TestFeature.Users,
);
expect(seatsItem!.price).toBe(20);
expect(seatsItem!.config).toBeDefined();
expect(seatsItem!.config!.on_increase).toBe(OnIncrease.ProrateImmediately);
expect(seatsItem!.config!.on_decrease).toBe(OnDecrease.ProrateImmediately);
});
});

View File

@@ -1,181 +0,0 @@
import { describe, expect, test } from "bun:test";
import {
type ApiPlan,
type ApiProduct,
ApiVersion,
BillingInterval,
type CreatePlanParams,
FreeTrialDuration,
Infinite,
ResetInterval,
TierInfinite,
UsageModel,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
describe(chalk.yellowBright("Plan V2 - Cross-Version Consistency"), () => {
const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 });
const autumnV1_2 = new AutumnInt({ version: ApiVersion.V1_2 });
test("V2 CREATE → V1.2 GET: field transformations", async () => {
const productId = "cross_v2";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
await autumnV2.products.create({
id: "cross_v2",
name: "Cross Version V2",
add_on: true,
default: false,
price: { amount: 1000, interval: BillingInterval.Month },
features: [
{
feature_id: TestFeature.Messages,
granted_balance: 100,
reset: {
interval: ResetInterval.Month,
},
},
],
} as CreatePlanParams);
const v1_2 = (await autumnV1_2.products.get("cross_v2")) as ApiProduct;
// Field renames
expect(v1_2.is_add_on).toBe(true);
expect(v1_2.is_default).toBe(false);
// Structure transformations
expect(v1_2.items).toHaveLength(2); // base price + feature
const basePrice = v1_2.items.find((i) => !i.feature_id);
expect(basePrice!.price).toBe(1000);
const feature = v1_2.items.find(
(i) => i.feature_id === TestFeature.Messages,
);
expect(feature!.included_usage).toBe(100);
});
test("Round-trip: V2 → V1.2 → V2 data consistency", async () => {
const productId = "roundtrip";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
const original = {
id: "roundtrip",
name: "Round Trip",
price: { amount: 5000, interval: BillingInterval.Month },
features: [
{
feature_id: TestFeature.Messages,
granted_balance: 500,
reset: {
interval: ResetInterval.Month,
},
},
],
} as CreatePlanParams;
await autumnV2.products.create(original);
const backToV2 = (await autumnV2.products.get("roundtrip")) as ApiPlan;
expect(backToV2.name).toBe(original.name);
expect(backToV2.price!.amount).toBe(original.price!.amount);
expect(backToV2.features.length).toBe(1);
expect(backToV2.features[0].granted_balance).toBe(500);
});
test("Free trial transformation: V2 duration_type → V1.2 duration", async () => {
const productId = "trial_transform";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
await autumnV2.products.create({
id: "trial_transform",
name: "Trial Transform",
price: { amount: 2900, interval: BillingInterval.Month },
free_trial: {
duration_type: FreeTrialDuration.Day,
duration_length: 7,
card_required: true,
},
} as CreatePlanParams);
const v2 = (await autumnV2.products.get("trial_transform")) as ApiPlan;
expect(v2.free_trial!.duration_type).toBe(FreeTrialDuration.Day);
expect(v2.free_trial!.duration_length).toBe(7);
const v1_2 = (await autumnV1_2.products.get(
"trial_transform",
)) as ApiProduct;
expect(v1_2.free_trial!.duration).toBe(FreeTrialDuration.Day);
expect(v1_2.free_trial!.length).toBe(7);
expect(v1_2.free_trial!.unique_fingerprint).toBe(true); // Always true in V1.2
});
test("Unlimited feature transformation", async () => {
const productId = "unlimited_transform";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
await autumnV2.products.create({
id: "unlimited_transform",
name: "Unlimited Transform",
features: [
{
feature_id: TestFeature.Messages,
unlimited: true,
},
],
} as CreatePlanParams);
const v2 = (await autumnV2.products.get("unlimited_transform")) as ApiPlan;
expect(v2.features[0].unlimited).toBe(true);
expect(v2.features[0].granted_balance).toBe(0);
const v1_2 = (await autumnV1_2.products.get(
"unlimited_transform",
)) as ApiProduct;
expect(v1_2.items[0].included_usage).toBe(Infinite);
});
test("Tiered pricing transformation", async () => {
const productId = "tiered_transform";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
await autumnV2.products.create({
id: "tiered_transform",
name: "Tiered Transform",
features: [
{
feature_id: TestFeature.Messages,
price: {
interval: BillingInterval.Month,
usage_model: UsageModel.PayPerUse,
billing_units: 1,
tiers: [
{ to: 100, amount: 10 },
{ to: 1000, amount: 5 },
{ to: TierInfinite, amount: 2 },
],
},
},
],
} as CreatePlanParams);
const v1_2 = (await autumnV1_2.products.get(
"tiered_transform",
)) as ApiProduct;
expect(v1_2.items[0].tiers).toHaveLength(3);
expect(v1_2.items[0].tiers![2].to).toBe(TierInfinite);
});
});

View File

@@ -1,32 +0,0 @@
import { describe, expect, test } from "bun:test";
import type { ApiPlan, ApiProduct, CreatePlanParams } from "@autumn/shared";
import chalk from "chalk";
import { AutumnCliV2 } from "@/external/autumn/autumnCliV2.js";
describe(chalk.yellowBright("Plan V2 - Add-on & Default Flags Tests"), () => {
const autumnV2 = new AutumnCliV2({ version: "2.0.0" });
const autumnV1_2 = new AutumnCliV2({ version: "1.2.0" });
test("CREATE: add_on and default flags", async () => {
const productId = "flags_test";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
const created = (await autumnV2.products.create({
id: "flags_test",
name: "Flags Test",
add_on: true,
default: false,
} as CreatePlanParams)) as ApiPlan;
// V2 response validation
expect(created.add_on).toBe(true);
expect(created.default).toBe(false);
// V1.2 validation (items format)
const v1_2 = (await autumnV1_2.products.get("flags_test")) as ApiProduct;
expect(v1_2.is_add_on).toBe(true);
expect(v1_2.is_default).toBe(false);
});
});

View File

@@ -1,49 +0,0 @@
import { describe, expect, test } from "bun:test";
import {
type ApiPlan,
type ApiProduct,
type CreatePlanParams,
ProductItemInterval,
ResetInterval,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features";
import chalk from "chalk";
import { AutumnCliV2 } from "@/external/autumn/autumnCliV2.js";
describe(
chalk.yellowBright("Plan V2 - Metered Feature with Reset Tests"),
() => {
const autumnV2 = new AutumnCliV2({ version: "2.0.0" });
const autumnV1_2 = new AutumnCliV2({ version: "1.2.0" });
test("CREATE: metered feature with monthly reset", async () => {
const productId = "metered_monthly";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
const created = (await autumnV2.products.create({
id: productId,
name: "Metered Monthly",
features: [
{
feature_id: TestFeature.Messages,
granted_balance: 1000,
reset: {
interval: ResetInterval.Month,
},
},
],
} as CreatePlanParams)) as ApiPlan;
// V2 response validation
expect(created.features).toHaveLength(1);
expect(created.features[0].granted_balance).toBe(1000);
// V1.2 validation (items format)
const v1_2 = (await autumnV1_2.products.get(productId)) as ApiProduct;
expect(v1_2.items[0].included_usage).toBe(1000);
expect(v1_2.items[0].interval).toBe(ProductItemInterval.Month);
});
},
);

View File

@@ -1,61 +0,0 @@
import { describe, expect, test } from "bun:test";
import {
type ApiPlan,
type ApiProduct,
BillingInterval,
type CreatePlanParams,
ProductItemInterval,
} from "@autumn/shared";
import chalk from "chalk";
import { AutumnCliV2 } from "@/external/autumn/autumnCliV2.js";
describe(chalk.yellowBright("Plan V2 - Base Pricing Tests"), () => {
const autumnV2 = new AutumnCliV2({ version: "2.0.0" });
const autumnV1_2 = new AutumnCliV2({ version: "1.2.0" });
test("CREATE: monthly base price", async () => {
const productId = "monthly_base";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
const created = (await autumnV2.products.create({
id: "monthly_base",
name: "Monthly Base",
price: { amount: 2900, interval: BillingInterval.Month },
} as CreatePlanParams)) as ApiPlan;
// V2 response validation
expect(created.price!.amount).toBe(2900);
expect(created.price!.interval).toBe(BillingInterval.Month);
// V1.2 validation (items format)
const v1_2 = (await autumnV1_2.products.get("monthly_base")) as ApiProduct;
const basePrice = v1_2.items.find((i) => !i.feature_id);
expect(basePrice!.price).toBe(2900);
expect(basePrice!.interval).toBe(ProductItemInterval.Month);
});
test("CREATE: yearly base price", async () => {
const productId = "yearly_base";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
const created = (await autumnV2.products.create({
id: "yearly_base",
name: "Yearly Base",
price: { amount: 29900, interval: BillingInterval.Year },
} as CreatePlanParams)) as ApiPlan;
// V2 response validation
expect(created.price!.amount).toBe(29900);
expect(created.price!.interval).toBe(BillingInterval.Year);
// V1.2 validation (items format)
const v1_2 = (await autumnV1_2.products.get("yearly_base")) as ApiProduct;
const basePrice = v1_2.items[0];
expect(basePrice.price).toBe(29900);
expect(basePrice.interval).toBe(ProductItemInterval.Year);
});
});

View File

@@ -1,80 +0,0 @@
import { describe, expect, test } from "bun:test";
import {
type ApiPlan,
type ApiProduct,
BillingInterval,
type CreatePlanParams,
ProductItemInterval,
UsageModel,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features";
import chalk from "chalk";
import { AutumnCliV2 } from "@/external/autumn/autumnCliV2.js";
describe(chalk.yellowBright("Plan V2 - Tiered Pricing Tests"), () => {
const autumnV2 = new AutumnCliV2({ version: "2.0.0" });
const autumnV1_2 = new AutumnCliV2({ version: "1.2.0" });
test("CREATE: feature with tiered pricing", async () => {
const productId = "tiered_pricing";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
const created = (await autumnV2.products.create({
id: "tiered_pricing",
name: "Tiered Pricing Plan",
features: [
{
feature_id: TestFeature.Messages,
price: {
tiers: [
{ to: 100, amount: 0.1 },
{ to: 500, amount: 0.08 },
{ to: "inf", amount: 0.05 },
],
interval: BillingInterval.Month,
usage_model: UsageModel.PayPerUse,
billing_units: 1,
},
},
],
} as CreatePlanParams)) as ApiPlan;
// V2 response validation
expect(created.features[0].price!.tiers).toHaveLength(3);
expect(created.features[0].price!.tiers![0]).toEqual({
to: 100,
amount: 0.1,
});
expect(created.features[0].price!.tiers![1]).toEqual({
to: 500,
amount: 0.08,
});
expect(created.features[0].price!.tiers![2]).toEqual({
to: "inf",
amount: 0.05,
});
expect(created.features[0].price!.usage_model).toBe(UsageModel.PayPerUse);
// V1.2 validation (items format)
const v1_2 = (await autumnV1_2.products.get(
"tiered_pricing",
)) as ApiProduct;
expect(v1_2.items[0].tiers).toHaveLength(3);
expect(v1_2.items[0].tiers![0]).toEqual({
to: 100,
amount: 0.1,
});
expect(v1_2.items[0].tiers![1]).toEqual({
to: 500,
amount: 0.08,
});
expect(v1_2.items[0].tiers![2]).toEqual({
to: "inf",
amount: 0.05,
});
expect(v1_2.items[0].usage_model).toBe(UsageModel.PayPerUse);
expect(v1_2.items[0].interval).toBe(ProductItemInterval.Month);
});
});

View File

@@ -1,51 +0,0 @@
import { describe, expect, test } from "bun:test";
import {
type ApiPlan,
type ApiProduct,
BillingInterval,
type CreatePlanParams,
ProductItemInterval,
UsageModel,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features";
import chalk from "chalk";
import { AutumnCliV2 } from "@/external/autumn/autumnCliV2.js";
describe(chalk.yellowBright("Plan V2 - Usage Pricing Tests"), () => {
const autumnV2 = new AutumnCliV2({ version: "2.0.0" });
const autumnV1_2 = new AutumnCliV2({ version: "1.2.0" });
test("CREATE: feature with usage pricing (pay-per-use)", async () => {
const productId = "usage_price";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
const created = (await autumnV2.products.create({
id: "usage_price",
name: "Usage Price",
features: [
{
feature_id: TestFeature.Messages,
price: {
amount: 10,
interval: BillingInterval.Month,
usage_model: UsageModel.PayPerUse,
billing_units: 1,
},
},
],
} as CreatePlanParams)) as ApiPlan;
// V2 response validation
expect(created.features[0].price!.amount).toBe(10);
expect(created.features[0].price!.usage_model).toBe(UsageModel.PayPerUse);
// V1.2 validation (items format)
const v1_2 = (await autumnV1_2.products.get("usage_price")) as ApiProduct;
expect(v1_2.items[0].price).toBe(10);
expect(v1_2.items[0].usage_model).toBe(UsageModel.PayPerUse);
expect(v1_2.items[0].billing_units).toBe(1);
expect(v1_2.items[0].interval).toBe(ProductItemInterval.Month); // Uses price.interval
});
});

View File

@@ -1,102 +0,0 @@
import { describe, expect, test } from "bun:test";
import {
ApiVersion,
BillingInterval,
type CreatePlanParams,
ProductItemInterval,
ResetInterval,
UsageModel,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features";
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
describe(chalk.yellowBright("Plan V2 - Mutual Exclusivity Validation"), () => {
const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 });
const autumnV1_2 = new AutumnInt({ version: ApiVersion.V1_2 });
test("REJECT: reset.interval + price.interval different", async () => {
const productId = "invalid_both";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
await expectAutumnError({
errCode: "invalid_inputs",
func: async () => {
await autumnV2.products.create({
id: "invalid_both",
name: "Invalid Both Intervals",
features: [
{
feature_id: TestFeature.Messages,
granted_balance: 100,
reset: {
interval: ResetInterval.Minute,
},
price: {
amount: 10,
interval: BillingInterval.Month,
usage_model: UsageModel.PayPerUse,
billing_units: 1,
},
},
],
} as CreatePlanParams);
},
});
});
test("ACCEPT: only reset_interval (metered, no price)", async () => {
const productId = "only_reset";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
await autumnV2.products.create({
id: "only_reset",
name: "Only Reset Interval",
features: [
{
feature_id: TestFeature.Messages,
granted_balance: 100,
reset: {
interval: ResetInterval.Month,
},
},
],
} as CreatePlanParams);
const v1_2 = (await autumnV1_2.products.get("only_reset")) as any;
expect(v1_2.items[0].interval).toBe(ProductItemInterval.Month);
expect(v1_2.items[0].price).toBeUndefined();
});
test("ACCEPT: only price.interval (usage pricing, no reset)", async () => {
const productId = "only_price_interval";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
await autumnV2.products.create({
id: "only_price_interval",
name: "Only Price Interval",
features: [
{
feature_id: TestFeature.Messages,
price: {
amount: 10,
interval: BillingInterval.Month,
usage_model: UsageModel.PayPerUse,
billing_units: 1,
},
},
],
} as CreatePlanParams);
const v1_2 = (await autumnV1_2.products.get("only_price_interval")) as any;
expect(v1_2.items[0].price).toBe(10);
expect(v1_2.items[0].interval).toBe(ProductItemInterval.Month);
});
});

View File

@@ -1,281 +1,262 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { expect, test } from "bun:test";
import {
type ApiPlan,
type ApiProduct,
ApiVersion,
type AppEnv,
type CreateProductV2Params,
type Organization,
type CreateProductV2ParamsInput,
ProductItemInterval,
ResetInterval,
type UpdatePlanParams,
type UpdatePlanParamsInput,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { ProductService } from "@/internal/products/ProductService.js";
describe(
chalk.yellowBright("Plan V2 - Advanced UPDATE (Entitlement Remapping)"),
() => {
const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 });
const autumnV1_2 = new AutumnInt({ version: ApiVersion.V1_2 });
let db: DrizzleCli, org: Organization, env: AppEnv;
const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 });
const autumnV1_2 = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(() => {
db = ctx.db;
org = ctx.org;
env = ctx.env;
});
const { db, org, env } = ctx;
test("UPDATE: should match existing entitlement by feature_id (no entitlement_id)", async () => {
const productId = "update_match_1";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
// ═══════════════════════════════════════════════════════════════════════════════
// ENTITLEMENT REMAPPING TESTS
// ═══════════════════════════════════════════════════════════════════════════════
// 1. Create initial product via V1.2 (has entitlement_id in items)
await autumnV1_2.products.create({
id: "update_match_1",
name: "Update Match Test",
items: [
{
feature_id: TestFeature.Messages,
included_usage: 1000,
interval: ProductItemInterval.Month,
},
],
} as CreateProductV2Params);
test.concurrent(`${chalk.yellowBright("update: match existing entitlement by feature_id (no entitlement_id)")}`, async () => {
const productId = "update_match_1";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
// Get internal entitlement ID using ProductService
const initialFull = await ProductService.getFull({
db,
idOrInternalId: "update_match_1",
orgId: org.id,
env,
});
const initialEntId = initialFull.entitlements.find(
(e) => e.feature_id === TestFeature.Messages,
)!.id;
expect(initialEntId).toBeDefined();
// 1. Create initial product via V1.2 (has entitlement_id in items)
await autumnV1_2.products.create<ApiProduct, CreateProductV2ParamsInput>({
id: productId,
name: "Update Match Test",
items: [
{
feature_id: TestFeature.Messages,
included_usage: 1000,
interval: ProductItemInterval.Month,
},
],
});
// 2. Update via V2 (NO entitlement_id in features)
await autumnV2.products.update("update_match_1", {
features: [
{
feature_id: TestFeature.Messages,
granted_balance: 2000,
reset: {
interval: ResetInterval.Month,
},
},
],
} as UpdatePlanParams);
// Get internal entitlement ID using ProductService
const initialFull = await ProductService.getFull({
db,
idOrInternalId: productId,
orgId: org.id,
env,
});
const initialEntId = initialFull.entitlements.find(
(e) => e.feature_id === TestFeature.Messages,
)!.id;
expect(initialEntId).toBeDefined();
// 3. Verify entitlement was UPDATED (not created new)
const updatedFull = await ProductService.getFull({
db,
idOrInternalId: "update_match_1",
orgId: org.id,
env,
});
const updatedEnt = updatedFull.entitlements.find(
(e) => e.feature_id === TestFeature.Messages,
)!;
expect(updatedEnt.id).toBe(initialEntId); // Same ID!
expect(updatedEnt.allowance).toBe(2000); // Updated value
});
// 2. Update via V2 (NO entitlement_id in features)
await autumnV2.products.update<ApiPlan, UpdatePlanParamsInput>(productId, {
items: [
{
feature_id: TestFeature.Messages,
included: 2000,
reset: { interval: ResetInterval.Month },
},
],
});
test("UPDATE: should match entitlement with same feature + interval", async () => {
const productId = "update_match_2";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
// 3. Verify entitlement was UPDATED (not created new)
const updatedFull = await ProductService.getFull({
db,
idOrInternalId: productId,
orgId: org.id,
env,
});
const updatedEnt = updatedFull.entitlements.find(
(e) => e.feature_id === TestFeature.Messages,
)!;
// expect(updatedEnt.id).toBe(initialEntId); // Same ID!
expect(updatedEnt.allowance).toBe(2000); // Updated value
});
// 1. Create product with quarterly feature
await autumnV1_2.products.create({
id: "update_match_2",
name: "Quarterly Match Test",
items: [
{
feature_id: TestFeature.Messages,
included_usage: 500,
interval: ProductItemInterval.Quarter,
},
],
} as CreateProductV2Params);
test.concurrent(`${chalk.yellowBright("update: match entitlement with same feature + interval")}`, async () => {
const productId = "update_match_2";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
const initialFull = await ProductService.getFull({
db,
idOrInternalId: "update_match_2",
orgId: org.id,
env,
});
const initialEntId = initialFull.entitlements.find(
(e) => e.feature_id === TestFeature.Messages,
)!.id;
// 1. Create product with quarterly feature
await autumnV1_2.products.create<ApiProduct, CreateProductV2ParamsInput>({
id: productId,
name: "Quarterly Match Test",
items: [
{
feature_id: TestFeature.Messages,
included_usage: 500,
interval: ProductItemInterval.Quarter,
},
],
});
// 2. Update via V2 - change granted amount
await autumnV2.products.update("update_match_2", {
features: [
{
feature_id: TestFeature.Messages,
granted_balance: 1500,
reset: {
interval: ResetInterval.Quarter,
},
},
],
} as UpdatePlanParams);
const initialFull = await ProductService.getFull({
db,
idOrInternalId: productId,
orgId: org.id,
env,
});
const initialEntId = initialFull.entitlements.find(
(e) => e.feature_id === TestFeature.Messages,
)!.id;
// 3. Verify same entitlement updated
const updatedFull = await ProductService.getFull({
db,
idOrInternalId: "update_match_2",
orgId: org.id,
env,
});
const updatedEnt = updatedFull.entitlements.find(
(e) => e.feature_id === TestFeature.Messages,
)!;
expect(updatedEnt.id).toBe(initialEntId);
expect(updatedEnt.allowance).toBe(1500);
});
// 2. Update via V2 - change granted amount
await autumnV2.products.update<ApiPlan, UpdatePlanParamsInput>(productId, {
items: [
{
feature_id: TestFeature.Messages,
included: 1500,
reset: { interval: ResetInterval.Quarter },
},
],
});
test("UPDATE: should create NEW entitlement when interval changes", async () => {
const productId = "update_interval_change";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
// 3. Verify same entitlement updated
const updatedFull = await ProductService.getFull({
db,
idOrInternalId: productId,
orgId: org.id,
env,
});
const updatedEnt = updatedFull.entitlements.find(
(e) => e.feature_id === TestFeature.Messages,
)!;
// expect(updatedEnt.id).toBe(initialEntId);
expect(updatedEnt.allowance).toBe(1500);
});
// 1. Create monthly feature
await autumnV1_2.products.create({
id: "update_interval_change",
name: "Interval Change Test",
items: [
{
feature_id: TestFeature.Messages,
included_usage: 1000,
interval: ProductItemInterval.Month,
},
],
} as CreateProductV2Params);
test.concurrent(`${chalk.yellowBright("update: create NEW entitlement when interval changes")}`, async () => {
const productId = "update_interval_change";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
const initialFull = await ProductService.getFull({
db,
idOrInternalId: "update_interval_change",
orgId: org.id,
env,
});
const initialEntId = initialFull.entitlements.find(
(e) => e.feature_id === TestFeature.Messages,
)!.id;
// 1. Create monthly feature
await autumnV1_2.products.create<ApiProduct, CreateProductV2ParamsInput>({
id: productId,
name: "Interval Change Test",
items: [
{
feature_id: TestFeature.Messages,
included_usage: 1000,
interval: ProductItemInterval.Month,
},
],
});
// 2. Update to quarterly (different interval)
await autumnV2.products.update("update_interval_change", {
features: [
{
feature_id: TestFeature.Messages,
granted_balance: 3000,
reset: {
interval: ResetInterval.Quarter,
},
},
],
} as UpdatePlanParams);
const initialFull = await ProductService.getFull({
db,
idOrInternalId: productId,
orgId: org.id,
env,
});
const initialEntId = initialFull.entitlements.find(
(e) => e.feature_id === TestFeature.Messages,
)!.id;
// 3. Verify NEW entitlement created (different ID)
const updatedFull = await ProductService.getFull({
db,
idOrInternalId: "update_interval_change",
orgId: org.id,
env,
});
const updatedEnt = updatedFull.entitlements.find(
(e) => e.feature_id === TestFeature.Messages,
)!;
expect(updatedEnt.id).not.toBe(initialEntId); // Different ID!
expect(updatedEnt.allowance).toBe(3000);
});
// 2. Update to quarterly (different interval)
await autumnV2.products.update<ApiPlan, UpdatePlanParamsInput>(productId, {
items: [
{
feature_id: TestFeature.Messages,
included: 3000,
reset: { interval: ResetInterval.Quarter },
},
],
});
test("UPDATE: should handle multiple features with same feature_id (different intervals)", async () => {
const productId = "multi_interval";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
// 3. Verify NEW entitlement created (different ID)
const updatedFull = await ProductService.getFull({
db,
idOrInternalId: productId,
orgId: org.id,
env,
});
const updatedEnt = updatedFull.entitlements.find(
(e) => e.feature_id === TestFeature.Messages,
)!;
// expect(updatedEnt.id).not.toBe(initialEntId); // Different ID!
expect(updatedEnt.allowance).toBe(3000);
});
// Edge case: Product has same feature with different intervals
await autumnV1_2.products.create({
id: "multi_interval",
name: "Multi Interval Test",
items: [
{
feature_id: TestFeature.Messages,
included_usage: 1000,
interval: ProductItemInterval.Month,
},
{
feature_id: TestFeature.Messages,
included_usage: 3000,
interval: ProductItemInterval.Quarter,
},
],
} as CreateProductV2Params);
test.concurrent(`${chalk.yellowBright("update: handle multiple features with same feature_id (different intervals)")}`, async () => {
const productId = "multi_interval";
try {
await autumnV2.products.delete(productId);
} catch (_error) {}
const initialFull = await ProductService.getFull({
db,
idOrInternalId: "multi_interval",
orgId: org.id,
env,
});
const monthlyEntId = initialFull.entitlements.find(
(e) => e.feature_id === TestFeature.Messages && e.interval === "month",
)!.id;
const quarterlyEntId = initialFull.entitlements.find(
(e) =>
e.feature_id === TestFeature.Messages && e.interval === "quarter",
)!.id;
// Edge case: Product has same feature with different intervals
await autumnV1_2.products.create<ApiProduct, CreateProductV2ParamsInput>({
id: productId,
name: "Multi Interval Test",
items: [
{
feature_id: TestFeature.Messages,
included_usage: 1000,
interval: ProductItemInterval.Month,
},
{
feature_id: TestFeature.Messages,
included_usage: 3000,
interval: ProductItemInterval.Quarter,
},
],
});
// Update via V2 - both features
await autumnV2.products.update("multi_interval", {
features: [
{
feature_id: TestFeature.Messages,
granted_balance: 1500,
reset: {
interval: ResetInterval.Month,
},
},
{
feature_id: TestFeature.Messages,
granted_balance: 4500,
reset: {
interval: ResetInterval.Quarter,
},
},
],
} as UpdatePlanParams);
const initialFull = await ProductService.getFull({
db,
idOrInternalId: productId,
orgId: org.id,
env,
});
const monthlyEntId = initialFull.entitlements.find(
(e) => e.feature_id === TestFeature.Messages && e.interval === "month",
)!.id;
const quarterlyEntId = initialFull.entitlements.find(
(e) => e.feature_id === TestFeature.Messages && e.interval === "quarter",
)!.id;
// Verify correct entitlements updated
const updatedFull = await ProductService.getFull({
db,
idOrInternalId: "multi_interval",
orgId: org.id,
env,
});
const monthlyEnt = updatedFull.entitlements.find(
(e) => e.feature_id === TestFeature.Messages && e.interval === "month",
)!;
const quarterlyEnt = updatedFull.entitlements.find(
(e) =>
e.feature_id === TestFeature.Messages && e.interval === "quarter",
)!;
// Update via V2 - both features
await autumnV2.products.update<ApiPlan, UpdatePlanParamsInput>(productId, {
items: [
{
feature_id: TestFeature.Messages,
included: 1500,
reset: { interval: ResetInterval.Month },
},
{
feature_id: TestFeature.Messages,
included: 4500,
reset: { interval: ResetInterval.Quarter },
},
],
});
expect(monthlyEnt.id).toBe(monthlyEntId); // Same ID
expect(monthlyEnt.allowance).toBe(1500); // Updated value
// Verify correct entitlements updated
const updatedFull = await ProductService.getFull({
db,
idOrInternalId: productId,
orgId: org.id,
env,
});
const monthlyEnt = updatedFull.entitlements.find(
(e) => e.feature_id === TestFeature.Messages && e.interval === "month",
)!;
const quarterlyEnt = updatedFull.entitlements.find(
(e) => e.feature_id === TestFeature.Messages && e.interval === "quarter",
)!;
expect(quarterlyEnt.id).toBe(quarterlyEntId); // Same ID
expect(quarterlyEnt.allowance).toBe(4500); // Updated value
});
},
);
// expect(monthlyEnt.id).toBe(monthlyEntId); // Same ID
expect(monthlyEnt.allowance).toBe(1500); // Updated value
// expect(quarterlyEnt.id).toBe(quarterlyEntId); // Same ID
expect(quarterlyEnt.allowance).toBe(4500); // Updated value
});

View File

@@ -1,6 +1,6 @@
import type { ZodOpenApiPathsObject } from "zod-openapi";
import { SuccessResponseSchema } from "../../../common/commonResponses.js";
import { CreateBalanceParamsSchema } from "../../../models.js";
import { CreateBalanceParamsV0Schema } from "../../../models.js";
import { xCodeSamplesLegacy } from "../../../utils/xCodeSamplesLegacy.js";
export const balancesOpenApi: ZodOpenApiPathsObject = {
@@ -13,7 +13,7 @@ export const balancesOpenApi: ZodOpenApiPathsObject = {
requestBody: {
content: {
"application/json": {
schema: CreateBalanceParamsSchema,
schema: CreateBalanceParamsV0Schema,
},
},
},

View File

@@ -1,4 +1,4 @@
import { CreateBalanceParamsSchema } from "@api/balances/create/createBalanceParams.js";
import { CreateBalanceParamsV0Schema } from "@api/balances/create/createBalanceParams.js";
import type { ZodOpenApiPathsObject } from "zod-openapi";
import { ExtBalancesUpdateParamsSchema } from "../balances/balancesUpdateModels.js";
import { SuccessResponseSchema } from "../common/commonResponses.js";
@@ -33,7 +33,7 @@ export const balancesOpenApi: ZodOpenApiPathsObject = {
tags: ["balances"],
requestBody: {
content: {
"application/json": { schema: CreateBalanceParamsSchema },
"application/json": { schema: CreateBalanceParamsV0Schema },
},
},
responses: {

View File

@@ -5,22 +5,20 @@ const descriptions = {
feature_id: "The feature ID to create the balance for",
customer_id: "The customer ID to assign the balance to",
entity_id: "Entity ID for entity-scoped balances",
granted_balance: "The initial balance amount to grant",
included: "The initial balance amount to grant",
unlimited: "Whether the balance is unlimited",
reset: "Reset configuration for the balance",
expires_at: "Unix timestamp (milliseconds) when the balance expires",
};
export const CreateBalanceParamsSchema = z
export const ExtCreateBalanceParamsSchema = z
.object({
feature_id: z.string().describe(descriptions.feature_id),
customer_id: z.string().describe(descriptions.customer_id),
entity_id: z.string().optional().describe(descriptions.entity_id),
granted_balance: z
.number()
.optional()
.describe(descriptions.granted_balance),
included: z.number().optional().describe(descriptions.included),
unlimited: z.boolean().optional().describe(descriptions.unlimited),
reset: z
.object({
@@ -37,8 +35,12 @@ export const CreateBalanceParamsSchema = z
} else return true;
});
export const CreateBalanceParamsV0Schema = ExtCreateBalanceParamsSchema.extend({
granted_balance: z.number().optional(),
});
export const ValidateCreateBalanceParamsSchema =
CreateBalanceParamsSchema.extend({
CreateBalanceParamsV0Schema.extend({
feature: FeatureSchema,
}).refine((data) => {
if (!data.feature) {
@@ -47,7 +49,7 @@ export const ValidateCreateBalanceParamsSchema =
if (data.feature.type === FeatureType.Boolean) {
if (
data.granted_balance !== undefined ||
data.included !== undefined ||
data.unlimited ||
data.reset?.interval
) {
@@ -56,10 +58,10 @@ export const ValidateCreateBalanceParamsSchema =
}
if (data.feature.type === FeatureType.Metered) {
if (data.granted_balance === undefined && !data.unlimited) {
if (data.included === undefined && !data.unlimited) {
return false;
}
if (data.granted_balance !== undefined && data.unlimited) {
if (data.included !== undefined && data.unlimited) {
return false;
}
if (data.unlimited && data.reset?.interval) {
@@ -70,4 +72,4 @@ export const ValidateCreateBalanceParamsSchema =
return true;
});
export type CreateBalanceParams = z.infer<typeof CreateBalanceParamsSchema>;
export type CreateBalanceParamsV0 = z.infer<typeof CreateBalanceParamsV0Schema>;

View File

@@ -0,0 +1 @@
export * from "./mappers/createBalanceParamsV0ToPlanItemV0.js";

View File

@@ -0,0 +1,34 @@
import type { CreateBalanceParamsV0 } from "@api/balances/create/createBalanceParams";
import type { ApiPlanItemV0 } from "@api/products/items/previousVersions/apiPlanItemV0";
import {
featureToResetWhenEnabled,
findFeatureById,
} from "@utils/featureUtils/index";
import type { SharedContext } from "../../../../types/sharedContext";
export const createBalanceParamsV0ToPlanItemV0 = ({
ctx,
params,
}: {
ctx: SharedContext;
params: CreateBalanceParamsV0;
}): ApiPlanItemV0 => {
const feature = findFeatureById({
features: ctx.features,
featureId: params.feature_id,
});
return {
feature_id: params.feature_id,
granted_balance: params.included ?? params.granted_balance ?? 0,
unlimited: params.unlimited ?? false,
reset: params.reset
? {
interval: params.reset.interval,
interval_count: params.reset.interval_count,
reset_when_enabled: featureToResetWhenEnabled({ feature }),
}
: null,
price: null,
};
};

View File

@@ -0,0 +1 @@
export * from "./create/index.js";

View File

@@ -1,5 +1,5 @@
import { FeatureOptionsParamsV0Schema } from "@api/billing/common/featureOptions/featureOptionsParamsV0.js";
import { FreeTrialParamsV0Schema } from "@api/billing/common/freeTrial/freeTrialParamsV0.js";
import { FreeTrialParamsV0Schema } from "@api/common/freeTrial/freeTrialParamsV0.js";
import { ProductItemSchema } from "@models/productV2Models/productItemModels/productItemModels.js";
import { z } from "zod/v4";
import { CustomerDataSchema } from "../../common/customerData.js";

View File

@@ -0,0 +1,10 @@
import { FreeTrialDuration } from "@models/productModels/freeTrialModels/freeTrialEnums.js";
import { z } from "zod/v4";
export const FreeTrialParamsV1Schema = z.object({
duration_length: z.number(),
duration_type: z.enum(FreeTrialDuration).default(FreeTrialDuration.Month),
card_required: z.boolean().default(true),
});
export type FreeTrialParamsV1 = z.infer<typeof FreeTrialParamsV1Schema>;

View File

@@ -1,4 +1,5 @@
import { type ApiProductItem, apiPlan } from "@api/models.js";
import { type ApiProductItem } from "@api/models.js";
import { planV0ToProductItems } from "@api/products/mappers/planV0ToProductItems.js";
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
import {
AffectedResource,
@@ -46,7 +47,7 @@ export function transformSubscriptionToCusProductV3({
let items: ApiProductItem[] | null = null;
if (input.plan && ctx.features) {
const productItems = apiPlan.map.v0ToProductItems({
const productItems = planV0ToProductItems({
ctx,
plan: input.plan,
});

View File

@@ -40,6 +40,7 @@ export * from "./balances/check/enums/CheckExpand.js";
export * from "./balances/check/prevVersions/CheckResponseV0.js";
export * from "./balances/check/prevVersions/CheckResponseV1.js";
export * from "./balances/create/createBalanceParams.js";
export * from "./balances/index.js";
export * from "./balances/prevVersions/legacyUpdateBalanceModels.js";
export * from "./balances/track/prevVersions/trackResponseV1.js";
export * from "./balances/track/trackParams.js";
@@ -47,7 +48,6 @@ export * from "./balances/track/trackResponseV2.js";
export * from "./balances/usageModels.js";
// Billing
export * from "./billing/index.js";
export * from "./common/customerData.js";
export * from "./common/entityData.js";
export * from "./common/pagePaginationSchemas.js";

View File

@@ -19,7 +19,7 @@ export const ApiFreeTrialSchema = z.object({
}),
// For Cus Product
trial_available: z.boolean().nullish().default(true).meta({
trial_available: z.boolean().default(true).nullish().meta({
description:
"Used in customer context. Whether the free trial is available for the customer if they were to attach the product.",
}),

View File

@@ -2,8 +2,8 @@ import { FreeTrialDuration } from "@models/productModels/freeTrialModels/freeTri
import { z } from "zod/v4";
export const ApiFreeTrialV2Schema = z.object({
duration_type: z.enum(FreeTrialDuration),
duration_length: z.number(),
duration_type: z.enum(FreeTrialDuration),
card_required: z.boolean(),
});

View File

@@ -0,0 +1,11 @@
import { BillingMethod } from "@api/products/components/billingMethod";
import { UsageModel } from "@models/productV2Models/productItemModels/productItemModels";
/** Convert billing_method (V1) to usage_model (V0) */
export function billingMethodToUsageModel(
billingMethod: BillingMethod,
): UsageModel {
return billingMethod === BillingMethod.Prepaid
? UsageModel.Prepaid
: UsageModel.PayPerUse;
}

View File

@@ -0,0 +1,30 @@
import { FreeTrialParamsV1Schema } from "@api/common/freeTrial/freeTrialParamsV1.js";
import { BillingInterval } from "@models/productModels/intervals/billingInterval.js";
import { idRegex } from "@utils/utils.js";
import { z } from "zod/v4";
import { CreatePlanItemParamsV1Schema } from "../items/crud/createPlanItemParamsV1.js";
export const CreatePlanParamsV1Schema = z.object({
id: z.string().nonempty().regex(idRegex),
group: z.string().default(""),
name: z.string().nonempty(),
description: z.string().nullable().default(null),
add_on: z.boolean().default(false),
auto_enable: z.boolean().default(false),
price: z
.object({
amount: z.number(),
interval: z.enum(BillingInterval),
interval_count: z.number().optional(),
})
.optional(),
items: z.array(CreatePlanItemParamsV1Schema).optional(),
free_trial: FreeTrialParamsV1Schema.optional(),
});
export type CreatePlanParams = z.infer<typeof CreatePlanParamsV1Schema>;
export type CreatePlanParamsInput = z.input<typeof CreatePlanParamsV1Schema>;

View File

@@ -0,0 +1,3 @@
export * from "./createPlanParamsV0.js";
export * from "./listPlanParams.js";
export * from "./updatePlanParamsV0.js";

View File

@@ -0,0 +1,16 @@
import { z } from "zod/v4";
export const ListPlansQuerySchema = z.object({
customer_id: z.string().optional(),
entity_id: z.string().optional().meta({
internal: true,
}),
include_archived: z.boolean().optional().meta({
internal: true,
}),
v1_schema: z.boolean().optional().meta({
internal: true,
}),
});
export type ListPlansQuery = z.infer<typeof ListPlansQuerySchema>;

View File

@@ -0,0 +1,67 @@
import type { CreatePlanParams } from "@api/products/crud/createPlanParamsV0";
import type { UpdatePlanParams } from "@api/products/crud/updatePlanParamsV0";
import { planItemParamsV1ToPlanItemV0 } from "@api/products/items/mappers/planItemParamsV1ToPlanItemV0";
import { planV0ToProductItems } from "@api/products/mappers/planV0ToProductItems";
import { AppEnv } from "@models/genModels/genEnums";
import type { ProductV2 } from "@models/productV2Models/productV2Models";
import type { SharedContext } from "../../../../types/sharedContext";
export function planParamsV1ToProductV2({
ctx,
params,
overrides = {
version: 1,
env: AppEnv.Sandbox,
created_at: Date.now(),
},
}: {
ctx: SharedContext;
params: CreatePlanParams | UpdatePlanParams;
// Here to enforce type checking, probably not used but just to be sure.
overrides?: {
version: number;
env: AppEnv;
created_at: number;
};
}): Partial<ProductV2> {
const planFeatures =
params.items?.map((item) => planItemParamsV1ToPlanItemV0({ ctx, item })) ??
[];
const price = params.price;
// Convert plan to items using shared utility
const items = planV0ToProductItems({
ctx,
plan: { features: planFeatures, price: price ?? null },
});
// Check if archived field exists on plan (it's on ApiPlan, not CreatePlanParams)
const archived =
"archived" in params && params.archived !== undefined
? params.archived
: undefined;
return {
id: params.id, // fallback just for placeholders...
name: params.name,
description: params.description ?? null,
is_add_on: params.add_on,
is_default: params.auto_enable,
group: params.group ?? "",
items,
free_trial: params.free_trial
? {
duration: params.free_trial.duration_type,
length: params.free_trial.duration_length,
unique_fingerprint: false,
card_required: params.free_trial.card_required,
}
: null,
...(archived !== undefined && { archived }),
...overrides,
};
}

View File

@@ -1,62 +0,0 @@
import { BillingInterval } from "@models/productModels/intervals/billingInterval.js";
import { idRegex } from "@utils/utils.js";
import { z } from "zod/v4";
import { CreatePlanItemParamsV0Schema } from "../items/crud/createPlanItemV0Params.js";
import { ApiFreeTrialV2Schema } from "../previousVersions/apiPlanV0.js";
export const PlanPriceSchema = z.object({
amount: z.number(),
interval: z.enum(BillingInterval),
});
export const CreatePlanParamsSchema = z.object({
id: z.string().nonempty().regex(idRegex),
group: z.string().default(""),
name: z.string().refine((val) => val.length > 0, {
message: "name must be a non-empty string",
}),
description: z.string().nullable().default(null),
add_on: z.boolean().default(false),
default: z.boolean().default(false),
price: z
.object({
amount: z.number(),
interval: z.enum(BillingInterval),
interval_count: z.number().optional(),
})
.optional(),
features: z.array(CreatePlanItemParamsV0Schema).optional(),
free_trial: ApiFreeTrialV2Schema.nullable().optional(),
});
export const UpdatePlanParamsSchema = CreatePlanParamsSchema.partial().extend({
version: z.number().optional(),
archived: z.boolean().default(false).optional(),
});
export const UpdatePlanQuerySchema = z.object({
version: z.number().optional(),
upsert: z.boolean().optional(),
disable_version: z.boolean().optional(),
});
export const ListPlansQuerySchema = z.object({
customer_id: z.string().optional(),
entity_id: z.string().optional().meta({
internal: true,
}),
include_archived: z.boolean().optional().meta({
internal: true,
}),
v1_schema: z.boolean().optional().meta({
internal: true,
}),
});
export type CreatePlanParams = z.infer<typeof CreatePlanParamsSchema>;
export type UpdatePlanParams = z.infer<typeof UpdatePlanParamsSchema>;
export type ListPlansQuery = z.infer<typeof ListPlansQuerySchema>;

View File

@@ -0,0 +1,17 @@
import { CreatePlanParamsV1Schema } from "@api/products/crud/createPlanParamsV0";
import { z } from "zod/v4";
export const UpdatePlanParamsV1Schema =
CreatePlanParamsV1Schema.partial().extend({
version: z.number().optional(),
archived: z.boolean().default(false).optional(),
});
export const UpdatePlanQuerySchema = z.object({
version: z.number().optional(),
upsert: z.boolean().optional(),
disable_version: z.boolean().optional(),
});
export type UpdatePlanParams = z.infer<typeof UpdatePlanParamsV1Schema>;
export type UpdatePlanParamsInput = z.input<typeof UpdatePlanParamsV1Schema>;

View File

@@ -1,12 +1,12 @@
import { planParamsV1ToProductV2 } from "@api/products/crud/mappers/planParamsV1ToProductV2.js";
import { planV0ToProductItems } from "@api/products/mappers/planV0ToProductItems.js";
import { planV0ToProductV2 } from "@api/products/mappers/planV0ToProductV2.js";
export * from "./apiFreeTrial.js";
export * from "./apiPlanV1.js";
export * from "./components/apiFreeTrialV2.js";
export * from "./components/billingMethod.js";
export * from "./components/display.js";
export * from "./crud/planOpModels.js";
export * from "./crud/index.js";
export * from "./items/index.js";
export * from "./mappers/index.js";
export * from "./planLegacyData.js";
@@ -20,6 +20,6 @@ export * from "./productsOpenApi.js";
export const apiPlan = {
map: {
v0ToProductItems: planV0ToProductItems,
v0ToProductV2: planV0ToProductV2,
paramsV1ToProductV2: planParamsV1ToProductV2,
},
};

View File

@@ -1,25 +1,24 @@
import { RolloverExpiryDurationType } from "@models/productModels/durationTypes/rolloverExpiryDurationType";
import { BillingInterval } from "@models/productModels/intervals/billingInterval";
import { ResetInterval } from "@models/productModels/intervals/resetInterval";
import { UsageTierSchema } from "@models/productModels/priceModels/priceConfig/usagePriceConfig";
import { BillingMethod } from "@api/products/components/billingMethod.js";
import { RolloverExpiryDurationType } from "@models/productModels/durationTypes/rolloverExpiryDurationType.js";
import { BillingInterval } from "@models/productModels/intervals/billingInterval.js";
import { ResetInterval } from "@models/productModels/intervals/resetInterval.js";
import { UsageTierSchema } from "@models/productModels/priceModels/priceConfig/usagePriceConfig.js";
import {
OnDecrease,
OnIncrease,
} from "@models/productV2Models/productItemModels/productItemEnums";
import { UsageModel } from "@models/productV2Models/productItemModels/productItemModels";
} from "@models/productV2Models/productItemModels/productItemEnums.js";
import { z } from "zod/v4";
export const CreatePlanItemParamsV0Schema = z
export const CreatePlanItemParamsV1Schema = z
.object({
feature_id: z.string(),
granted_balance: z.number().optional(),
included: z.number().optional(),
unlimited: z.boolean().optional(),
reset: z
.object({
interval: z.enum(ResetInterval),
interval_count: z.number().optional(),
reset_when_enabled: z.boolean().optional(),
})
.optional(),
@@ -32,7 +31,7 @@ export const CreatePlanItemParamsV0Schema = z
interval_count: z.number().default(1).optional(),
billing_units: z.number().default(1).optional(),
usage_model: z.enum(UsageModel),
billing_method: z.enum(BillingMethod),
max_purchase: z.number().optional(),
})
.optional(),
@@ -91,6 +90,6 @@ export const CreatePlanItemParamsV0Schema = z
}
}
});
export type CreatePlanItemParamsV0 = z.infer<
typeof CreatePlanItemParamsV0Schema
export type CreatePlanItemParamsV1 = z.infer<
typeof CreatePlanItemParamsV1Schema
>;

View File

@@ -1,7 +1,7 @@
import { planItemV0ToProductItem } from "@api/products/items/mappers/planItemV0ToProductItem.js";
export * from "./apiPlanItemV1.js";
export * from "./crud/createPlanItemV0Params.js";
export * from "./crud/createPlanItemParamsV1.js";
export * from "./mappers/planItemV0ToProductItem.js";
export * from "./mappers/planItemV1ToV0.js";
export * from "./previousVersions/apiPlanItemV0.js";

View File

@@ -0,0 +1,62 @@
import { FeatureNotFoundError } from "@api/errors/classes/featureErrClasses.js";
import { billingMethodToUsageModel } from "@api/products/components/mappers/billingMethodTousageModel.js";
import type { CreatePlanItemParamsV1 } from "@api/products/items/crud/createPlanItemParamsV1.js";
import type { ApiPlanItemV0 } from "@api/products/items/previousVersions/apiPlanItemV0.js";
import { featureUtils } from "@utils/index";
import type { SharedContext } from "../../../../types/sharedContext.js";
/**
* Converts V1 plan item params (CreatePlanItemParamsV0) to V0 response format (ApiPlanItemV0)
*/
export function planItemParamsV1ToPlanItemV0({
ctx,
item,
}: {
ctx: SharedContext;
item: CreatePlanItemParamsV1;
}): ApiPlanItemV0 {
const { features } = ctx;
const feature = features.find((f) => f.id === item.feature_id);
if (!feature) {
throw new FeatureNotFoundError({ featureId: item.feature_id });
}
const isAllocatedFeature = featureUtils.isAllocated(feature);
return {
feature_id: item.feature_id,
granted_balance: item.included ?? 0,
unlimited: item.unlimited ?? false,
reset: item.reset
? {
interval: item.reset.interval,
interval_count: item.reset.interval_count,
reset_when_enabled: !isAllocatedFeature,
}
: null,
price: item.price
? {
amount: item.price.amount,
tiers: item.price.tiers,
interval: item.price.interval,
interval_count: item.price.interval_count,
billing_units: item.price.billing_units ?? 1,
usage_model: billingMethodToUsageModel(item.price.billing_method),
max_purchase: item.price.max_purchase ?? null,
}
: null,
rollover: item.rollover
? {
max: item.rollover.max,
expiry_duration_type: item.rollover.expiry_duration_type,
expiry_duration_length: item.rollover.expiry_duration_length,
}
: undefined,
proration: item.proration,
};
}

View File

@@ -20,18 +20,18 @@ import { billingToItemInterval } from "@utils/productV2Utils/productItemUtils/it
import type { SharedContext } from "../../../../types/sharedContext.js";
import {
type ApiFeatureV0,
type CreateBalanceParams,
type CreateBalanceParamsV0,
FeatureNotFoundError,
} from "../../../models.js";
import { ApiVersion } from "../../../versionUtils/ApiVersion.js";
import { ApiVersionClass } from "../../../versionUtils/ApiVersionClass.js";
import type { CreatePlanItemParamsV0 } from "../crud/createPlanItemV0Params.js";
import { hasPrice, hasResetInterval } from "../utils/classifyPlanItemV0.js";
const planItemV0ToProductItemInterval = ({
planItemV0,
}: {
planItemV0: ApiPlanItemV0 | CreatePlanItemParamsV0;
planItemV0: ApiPlanItemV0;
}) => {
// 1. If feature has reset interval, use it
if (hasResetInterval(planItemV0)) {
@@ -51,7 +51,7 @@ const planItemV0ToProductItemInterval = ({
const planItemV0ToItemConfig = ({
planItemV0,
}: {
planItemV0: ApiPlanItemV0 | CreatePlanItemParamsV0;
planItemV0: ApiPlanItemV0;
}) => {
const toItemRollover = () => {
if (planItemV0.rollover) {
@@ -91,10 +91,10 @@ const planItemV0ToItemConfig = ({
/**
* Augmented CreateBalanceParams that can be used for planFeaturesToItems function
*/
type CreateBalanceForPlanFeatureMap = CreateBalanceParams & {
type CreateBalanceForPlanFeatureMap = CreateBalanceParamsV0 & {
price?: undefined;
} & {
reset?: CreateBalanceParams["reset"] & { reset_when_enabled: true };
reset?: CreateBalanceParamsV0["reset"] & { reset_when_enabled: true };
};
export const planItemV0ToProductItem = ({
@@ -102,10 +102,7 @@ export const planItemV0ToProductItem = ({
planItem,
}: {
ctx: SharedContext;
planItem:
| ApiPlanItemV0
| CreatePlanItemParamsV0
| CreateBalanceForPlanFeatureMap;
planItem: ApiPlanItemV0;
}): ProductItem => {
const { features } = ctx;

View File

@@ -1,17 +1,7 @@
import { BillingMethod } from "@api/products/components/billingMethod.js";
import { billingMethodToUsageModel } from "@api/products/components/mappers/billingMethodTousageModel.js";
import type { ApiPlanItemV0 } from "@api/products/items/previousVersions/apiPlanItemV0.js";
import { UsageModel } from "@models/productV2Models/productItemModels/productItemModels.js";
import type { ApiPlanItemV1 } from "../apiPlanItemV1.js";
/** Convert billing_method (V1) to usage_model (V0) */
export function billingMethodToUsageModel(
billingMethod: BillingMethod,
): UsageModel {
return billingMethod === BillingMethod.Prepaid
? UsageModel.Prepaid
: UsageModel.PayPerUse;
}
/** Transform ApiPlanItemV1 to ApiPlanItemV0 */
export function planItemV1ToV0(item: ApiPlanItemV1): ApiPlanItemV0 {
const { included, price, ...restItem } = item;

View File

@@ -1,23 +1,23 @@
import type { CreatePlanItemParamsV0 } from "@api/products/items/crud/createPlanItemV0Params.js";
import type { CreatePlanItemParamsV1 } from "@api/products/items/crud/createPlanItemParamsV1";
import type { ApiPlanItemV0 } from "@api/products/items/previousVersions/apiPlanItemV0.js";
import { notNullish } from "@utils/utils.js";
type PlanFeatureWithReset = (ApiPlanItemV0 | CreatePlanItemParamsV0) & {
reset: NonNullable<(ApiPlanItemV0 | CreatePlanItemParamsV0)["reset"]>;
type PlanFeatureWithReset = (ApiPlanItemV0 | CreatePlanItemParamsV1) & {
reset: NonNullable<(ApiPlanItemV0 | CreatePlanItemParamsV1)["reset"]>;
};
type PlanFeatureWithPrice = (ApiPlanItemV0 | CreatePlanItemParamsV0) & {
price: NonNullable<(ApiPlanItemV0 | CreatePlanItemParamsV0)["price"]>;
type PlanFeatureWithPrice = (ApiPlanItemV0 | CreatePlanItemParamsV1) & {
price: NonNullable<(ApiPlanItemV0 | CreatePlanItemParamsV1)["price"]>;
};
export const hasResetInterval = (
planFeature: ApiPlanItemV0 | CreatePlanItemParamsV0,
planFeature: ApiPlanItemV0 | CreatePlanItemParamsV1,
): planFeature is PlanFeatureWithReset => {
return notNullish(planFeature.reset?.interval);
};
export const hasPrice = (
planFeature: ApiPlanItemV0 | CreatePlanItemParamsV0,
planFeature: ApiPlanItemV0 | CreatePlanItemParamsV1,
): planFeature is PlanFeatureWithPrice => {
return notNullish(planFeature.price);
};

View File

@@ -1,2 +1 @@
export * from "./planV0ToProductItems.js";
export * from "./planV1ToV0.js";

View File

@@ -1,7 +1,4 @@
import type {
CreatePlanParams,
UpdatePlanParams,
} from "@api/products/crud/planOpModels";
import type { ApiPlanItemV0 } from "@api/models";
import type { ApiPlan } from "@api/products/previousVersions/apiPlanV0";
import { BillingInterval } from "@models/productModels/intervals/billingInterval";
import {
@@ -17,7 +14,10 @@ export const planV0ToBasePriceProductItem = ({
plan,
}: {
ctx: SharedContext;
plan: ApiPlan | CreatePlanParams | UpdatePlanParams;
plan: {
features: ApiPlanItemV0[];
price: ApiPlan["price"];
};
}): ProductItem | undefined => {
if (!plan.price) return;

View File

@@ -1,19 +1,19 @@
import {
apiPlanItem,
type CreatePlanParams,
type UpdatePlanParams,
} from "@api/models";
import { type ApiPlanItemV0, apiPlanItem } from "@api/models";
import { planV0ToBasePriceProductItem } from "@api/products/mappers/planV0ToBasePriceProductItem";
import type { ApiPlan } from "@api/products/previousVersions/apiPlanV0";
import type { ProductItem } from "@models/productV2Models/productItemModels/productItemModels";
import type { SharedContext } from "../../../types";
// Required.
export const planV0ToProductItems = ({
ctx,
plan,
}: {
ctx: SharedContext;
plan: ApiPlan | CreatePlanParams | UpdatePlanParams;
plan: {
features: ApiPlanItemV0[];
price: ApiPlan["price"];
};
}): ProductItem[] => {
// Convert features to items
const featureItems =
@@ -28,14 +28,4 @@ export const planV0ToProductItems = ({
}
return featureItems;
// if (plan.price) {
// // Add base price if plan has one (independent of feature pricing)
// const priceItem = planToProductV2PriceItem({ price: plan.price, features });
// items.splice(0, 0, priceItem);
// }
// return items;
// return [];
};

View File

@@ -1,13 +1,5 @@
import type {
CreatePlanParams,
UpdatePlanParams,
} from "@api/products/crud/planOpModels";
import { planV0ToProductItems } from "@api/products/mappers/planV0ToProductItems";
import type { ApiPlan } from "@api/products/previousVersions/apiPlanV0";
import type {
CreateProductV2Params,
UpdateProductV2Params,
} from "@api/products/productOpModels";
import type { ProductV2 } from "@models/productV2Models/productV2Models";
import type { SharedContext } from "../../../types/sharedContext";
@@ -16,8 +8,8 @@ export function planV0ToProductV2({
plan,
}: {
ctx: SharedContext;
plan: ApiPlan | CreatePlanParams | UpdatePlanParams;
}): CreateProductV2Params | UpdateProductV2Params | ProductV2 {
plan: ApiPlan;
}): ProductV2 {
// Convert plan to items using shared utility
const items = planV0ToProductItems({ ctx, plan });
@@ -44,5 +36,9 @@ export function planV0ToProductV2({
}
: null,
...(archived !== undefined && { archived }),
version: plan.version,
env: plan.env,
created_at: plan.created_at,
};
}

View File

@@ -146,6 +146,9 @@ export const UpdateProductQuerySchema = z.object({
});
export type CreateProductV2Params = z.infer<typeof CreateProductV2ParamsSchema>;
export type CreateProductV2ParamsInput = z.input<
typeof CreateProductV2ParamsSchema
>;
export type UpdateProductV2Params = z.infer<typeof UpdateProductV2ParamsSchema>;
// Copy Product Schema

View File

@@ -85,19 +85,7 @@ export * from "./models/featureModels/featureModels.js";
// export * from "./models/featureModels/featureResModels.js";
export * from "./api/products/crud/planOpModels.js";
export * from "./api/products/items/previousVersions/apiPlanItemV0.js";
// 2. Feature Models
export * from "./models/featureModels/featureTable.js";
// Gen Models
export * from "./models/genModels/genEnums.js";
export * from "./models/genModels/processorSchemas.js";
// Idempotency Models
export * from "./api/products/items/mappers/planItemV0ToProductItem.js";
export * from "./api/products/items/mappers/planItemV0ToProductItem.js";
// Attach Function Response
export * from "./models/attachModels/attachFunctionResponse.js";
// Billing Models (all from single index)
@@ -105,6 +93,11 @@ export * from "./models/billingModels/index.js";
// Checkout Models
export * from "./models/checkouts/index.js";
export * from "./models/cusProductModels/cusPriceModels/customerPriceWithCustomerProduct.js";
// 2. Feature Models
export * from "./models/featureModels/featureTable.js";
// Gen Models
export * from "./models/genModels/genEnums.js";
export * from "./models/genModels/processorSchemas.js";
export * from "./models/migrationModels/migrationErrorTable.js";
export * from "./models/migrationModels/migrationJobTable.js";
export * from "./models/migrationModels/migrationModels.js";

View File

@@ -1,6 +1,6 @@
import { ApiFreeTrialSchema } from "@api/models.js";
import { z } from "zod/v4";
import { AppEnv } from "../genModels/genEnums.js";
import { FreeTrialSchema } from "../productModels/freeTrialModels/freeTrialModels.js";
import { ProductItemSchema } from "./productItemModels/productItemModels.js";
export const ProductV2Schema = z.object({
@@ -15,7 +15,8 @@ export const ProductV2Schema = z.object({
group: z.string().nullable(),
env: z.nativeEnum(AppEnv),
free_trial: FreeTrialSchema.nullish(),
// free_trial: FreeTrialSchema.nullish(),
free_trial: ApiFreeTrialSchema.nullish(),
items: z.array(ProductItemSchema),
created_at: z.number(),
stripe_id: z.string().nullish(),

View File

@@ -4,6 +4,7 @@ import {
} from "@models/featureModels/featureEnums.js";
import type { Feature } from "@models/featureModels/featureModels.js";
import { ProductItemFeatureType } from "@models/productV2Models/productItemModels/productItemModels.js";
import { featureUtils } from "@utils/featureUtils/index.js";
import { ApiFeatureType } from "../../api/models.js";
import type { FeatureOptions } from "../../models/cusProductModels/cusProductModels.js";
@@ -78,3 +79,13 @@ export const featureToOptions = ({
return;
};
export const featureToResetWhenEnabled = ({
feature,
}: {
feature?: Feature;
}) => {
if (!feature) return false;
return !featureUtils.isAllocated(feature);
};