new plan params
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
26
server/src/external/autumn/autumnCli.ts
vendored
26
server/src/external/autumn/autumnCli.ts
vendored
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
398
server/tests/integration/crud/plans/create-plan-advanced.test.ts
Normal file
398
server/tests/integration/crud/plans/create-plan-advanced.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
// },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
1
shared/api/balances/create/index.ts
Normal file
1
shared/api/balances/create/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./mappers/createBalanceParamsV0ToPlanItemV0.js";
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
1
shared/api/balances/index.ts
Normal file
1
shared/api/balances/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./create/index.js";
|
||||
@@ -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";
|
||||
|
||||
10
shared/api/common/freeTrial/freeTrialParamsV1.ts
Normal file
10
shared/api/common/freeTrial/freeTrialParamsV1.ts
Normal 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>;
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.",
|
||||
}),
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
30
shared/api/products/crud/createPlanParamsV0.ts
Normal file
30
shared/api/products/crud/createPlanParamsV0.ts
Normal 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>;
|
||||
3
shared/api/products/crud/index.ts
Normal file
3
shared/api/products/crud/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./createPlanParamsV0.js";
|
||||
export * from "./listPlanParams.js";
|
||||
export * from "./updatePlanParamsV0.js";
|
||||
16
shared/api/products/crud/listPlanParams.ts
Normal file
16
shared/api/products/crud/listPlanParams.ts
Normal 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>;
|
||||
67
shared/api/products/crud/mappers/planParamsV1ToProductV2.ts
Normal file
67
shared/api/products/crud/mappers/planParamsV1ToProductV2.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
17
shared/api/products/crud/updatePlanParamsV0.ts
Normal file
17
shared/api/products/crud/updatePlanParamsV0.ts
Normal 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>;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export * from "./planV0ToProductItems.js";
|
||||
export * from "./planV1ToV0.js";
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user