Files
cfw-autumn/server/tests/integration/crud/plans/create-plan-advanced.test.ts

529 lines
16 KiB
TypeScript

import { expect, test } from "bun:test";
import {
type ApiPlan,
type ApiPlanV1,
type ApiProduct,
ApiVersion,
BillingInterval,
BillingMethod,
type CreatePlanParamsInput,
FreeTrialDuration,
Infinite,
ProductItemInterval,
ResetInterval,
TierBehavior,
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_1 = new AutumnInt({ version: ApiVersion.V2_1 });
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_1.products.delete(productId);
} catch (_error) {}
const created = await autumnV2_1.products.create<
ApiPlanV1,
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,
},
},
],
});
// V2.1: ApiPlanV1 — no included, so tiers are same across all versions
const item = created.items[0];
expect(item.price!.tiers).toHaveLength(3);
expect(item.price!.tiers).toEqual([
{ to: 100, amount: 0.1 },
{ to: 500, amount: 0.08 },
{ to: TierInfinite, amount: 0.05 },
]);
expect(item.price!.billing_method).toBe(BillingMethod.UsageBased);
// V2.0: ApiPlan — same tiers (no included to subtract)
const v2 = await autumnV2.products.get<ApiPlan>(productId);
expect(v2.features[0].price!.tiers).toEqual([
{ to: 100, amount: 0.1 },
{ to: 500, amount: 0.08 },
{ to: TierInfinite, amount: 0.05 },
]);
// V1.2: ApiProduct — same tiers
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 });
});
test.concurrent(`${chalk.yellowBright("create: tiered pricing with included usage (to INCLUDES included)")}`, async () => {
const productId = "tiered_with_included";
try {
await autumnV2_1.products.delete(productId);
} catch (_error) {}
// User-facing API: tier `to` INCLUDES included usage.
// included=200, tiers=[{to:700}, {to:1200}, {to:inf}]
// Internally stored as tiers=[{to:500}, {to:1000}, {to:inf}] (without included)
const created = await autumnV2_1.products.create<
ApiPlanV1,
CreatePlanParamsInput
>({
id: productId,
name: "Tiered With Included",
items: [
{
feature_id: TestFeature.Messages,
included: 200,
price: {
tiers: [
{ to: 700, amount: 10 },
{ to: 1200, amount: 5 },
{ to: TierInfinite, amount: 2 },
],
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
billing_units: 100,
},
},
],
});
// V2.1: ApiPlanV1 — tiers INCLUDE included usage
const item = created.items[0];
expect(item.price!.tiers).toHaveLength(3);
expect(item.price!.tiers).toEqual([
{ to: 700, amount: 10 },
{ to: 1200, amount: 5 },
{ to: TierInfinite, amount: 2 },
]);
expect(item.included).toBe(200);
// V2.0: ApiPlan — tiers do NOT include included usage (subtracted in V2.1→V2.0 conversion)
const v2 = await autumnV2.products.get<ApiPlan>(productId);
expect(v2.features[0].price!.tiers).toEqual([
{ to: 500, amount: 10 },
{ to: 1000, amount: 5 },
{ to: TierInfinite, amount: 2 },
]);
expect(v2.features[0].granted_balance).toBe(200);
// V1.2: ApiProduct — internal tiers do NOT include included usage
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: 500, amount: 10 },
{ to: 1000, amount: 5 },
{ to: TierInfinite, amount: 2 },
]);
expect(v1_2.items[0].included_usage).toBe(200);
});
test.concurrent(`${chalk.yellowBright("create: volume tiered pricing with included usage (to INCLUDES included)")}`, async () => {
const productId = "volume_tiered_with_included";
try {
await autumnV2_1.products.delete(productId);
} catch (_error) {}
const created = await autumnV2_1.products.create<
ApiPlanV1,
CreatePlanParamsInput
>({
id: productId,
name: "Volume Tiered With Included",
items: [
{
feature_id: TestFeature.Messages,
included: 100,
price: {
tiers: [
{ to: 600, amount: 10 },
{ to: TierInfinite, amount: 5 },
],
tier_behavior: TierBehavior.VolumeBased,
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
billing_units: 100,
},
},
],
});
// V2.1: ApiPlanV1 — tiers INCLUDE included
const item = created.items[0];
expect(item.price!.tiers).toEqual([
{ to: 600, amount: 10 },
{ to: TierInfinite, amount: 5 },
]);
expect(item.included).toBe(100);
// V2.0: ApiPlan — tiers do NOT include included (subtracted in V2.1→V2.0 conversion)
const v2 = await autumnV2.products.get<ApiPlan>(productId);
expect(v2.features[0].price!.tiers).toEqual([
{ to: 500, amount: 10 },
{ to: TierInfinite, amount: 5 },
]);
expect(v2.features[0].granted_balance).toBe(100);
// V1.2: ApiProduct — internal tiers do NOT include included
const v1_2 = await autumnV1_2.products.get<ApiProduct>(productId);
expect(v1_2.items[0].tiers).toEqual([
{ to: 500, amount: 10 },
{ to: TierInfinite, amount: 5 },
]);
});
// ═══════════════════════════════════════════════════════════════════════════════
// 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,
});
});