import { expect, test } from "bun:test"; import { type ApiPlan, type ApiPlanV1, type ApiProduct, ApiVersion, BillingInterval, BillingMethod, type CreatePlanParamsInput, TierBehavior, TierInfinite, } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features"; 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 }); const inf = TierInfinite as "inf"; // ═══════════════════════════════════════════════════════════════════════════════ // GET: TIERED PRICING WITHOUT INCLUDED // ═══════════════════════════════════════════════════════════════════════════════ test.concurrent(`${chalk.yellowBright("get: tiered pricing without included — same tiers across all versions")}`, async () => { const productId = "get_tiered_no_included"; try { await autumnV2_1.products.delete(productId); } catch (_error) {} await autumnV2_1.products.create({ id: productId, name: "Get Tiered No Included", 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 expectedTiers = [ { to: 100, amount: 0.1 }, { to: 500, amount: 0.08 }, { to: inf, amount: 0.05 }, ]; // V2.1: tiers unchanged (no included to add) const v2_1 = await autumnV2_1.products.get(productId); expect(v2_1.items[0].price!.tiers).toEqual(expectedTiers); // V2.0: tiers unchanged (no included to subtract) const v2 = await autumnV2.products.get(productId); expect(v2.features[0].price!.tiers).toEqual(expectedTiers); // V1.2: tiers unchanged const v1_2 = await autumnV1_2.products.get(productId); expect(v1_2.items[0].tiers).toEqual(expectedTiers); }); // ═══════════════════════════════════════════════════════════════════════════════ // GET: TIERED PRICING WITH INCLUDED — CROSS-VERSION TIER VALUES // ═══════════════════════════════════════════════════════════════════════════════ test.concurrent(`${chalk.yellowBright("get: graduated tiered pricing with included — V2.1 includes, V2.0/V1.2 do not")}`, async () => { const productId = "get_tiered_with_included"; try { await autumnV2_1.products.delete(productId); } catch (_error) {} // Create via V2.1: tier `to` INCLUDES included. // included=200, tiers=[{to:700}, {to:1200}, {to:inf}] // Internally stored as [{to:500}, {to:1000}, {to:inf}] await autumnV2_1.products.create({ id: productId, name: "Get 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 GET: tiers INCLUDE included (200 added back) const v2_1 = await autumnV2_1.products.get(productId); expect(v2_1.items[0].included).toBe(200); expect(v2_1.items[0].price!.tiers).toEqual([ { to: 700, amount: 10 }, { to: 1200, amount: 5 }, { to: inf, amount: 2 }, ]); // V2.0 GET: tiers do NOT include included const v2 = await autumnV2.products.get(productId); expect(v2.features[0].granted_balance).toBe(200); expect(v2.features[0].price!.tiers).toEqual([ { to: 500, amount: 10 }, { to: 1000, amount: 5 }, { to: inf, amount: 2 }, ]); // V1.2 GET: tiers do NOT include included const v1_2 = await autumnV1_2.products.get(productId); expect(v1_2.items[0].included_usage).toBe(200); expect(v1_2.items[0].tiers).toEqual([ { to: 500, amount: 10 }, { to: 1000, amount: 5 }, { to: inf, amount: 2 }, ]); }); test.concurrent(`${chalk.yellowBright("get: volume tiered pricing with included — V2.1 includes, V2.0/V1.2 do not")}`, async () => { const productId = "get_volume_with_included"; try { await autumnV2_1.products.delete(productId); } catch (_error) {} // Create via V2.1: included=100, tiers=[{to:600}, {to:inf}] // Internally stored as [{to:500}, {to:inf}] await autumnV2_1.products.create({ id: productId, name: "Get Volume 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 GET: tiers INCLUDE included const v2_1 = await autumnV2_1.products.get(productId); expect(v2_1.items[0].included).toBe(100); expect(v2_1.items[0].price!.tiers).toEqual([ { to: 600, amount: 10 }, { to: inf, amount: 5 }, ]); // V2.0 GET: tiers do NOT include included const v2 = await autumnV2.products.get(productId); expect(v2.features[0].granted_balance).toBe(100); expect(v2.features[0].price!.tiers).toEqual([ { to: 500, amount: 10 }, { to: inf, amount: 5 }, ]); // V1.2 GET: tiers do NOT include included const v1_2 = await autumnV1_2.products.get(productId); expect(v1_2.items[0].included_usage).toBe(100); expect(v1_2.items[0].tiers).toEqual([ { to: 500, amount: 10 }, { to: inf, amount: 5 }, ]); }); // ═══════════════════════════════════════════════════════════════════════════════ // GET: CREATE VIA V2.0, READ ACROSS VERSIONS // ═══════════════════════════════════════════════════════════════════════════════ test.concurrent(`${chalk.yellowBright("get: create tiered via V2.0 (no included offset) — V2.1 adds included=0")}`, async () => { const productId = "get_v2_created_tiered"; try { await autumnV2.products.delete(productId); } catch (_error) {} // Create via V2.0: tiers do NOT include included (V2.0 has no offset behavior) await autumnV2.products.create({ id: productId, name: "V2 Created Tiered", items: [ { feature_id: TestFeature.Messages, price: { tiers: [ { to: 100, amount: 10 }, { to: 1000, amount: 5 }, { to: TierInfinite, amount: 2 }, ], interval: BillingInterval.Month, billing_method: BillingMethod.UsageBased, billing_units: 1, }, }, ], }); const expectedTiers = [ { to: 100, amount: 10 }, { to: 1000, amount: 5 }, { to: inf, amount: 2 }, ]; // V2.1 GET: included defaults to 0, so tiers stay the same const v2_1 = await autumnV2_1.products.get(productId); expect(v2_1.items[0].price!.tiers).toEqual(expectedTiers); // V2.0 GET: same tiers const v2 = await autumnV2.products.get(productId); expect(v2.features[0].price!.tiers).toEqual(expectedTiers); // V1.2 GET: same tiers const v1_2 = await autumnV1_2.products.get(productId); expect(v1_2.items[0].tiers).toEqual(expectedTiers); }); // ═══════════════════════════════════════════════════════════════════════════════ // GET: PLAN CONFIG (ignore_past_due) — CROSS-VERSION // // `config.ignore_past_due` is exposed on V2.1 (ApiPlanV1) only. V2.0 (ApiPlan) // and V1.2 (ApiProduct) do not surface a `config` field in their response // shape, but the underlying flag must persist on the DB row regardless of // which version is used to read or write the plan. These tests mirror the // V2.1-only assertions in `plan-config.test.ts` and add cross-version GET // + update guarantees. // ═══════════════════════════════════════════════════════════════════════════════ test.concurrent(`${chalk.yellowBright("get plan config: defaults to ignore_past_due=false on create — visible across versions")}`, async () => { const productId = "get_plan_config_default"; try { await autumnV2_1.products.delete(productId); } catch (_error) {} await autumnV2_1.products.create({ id: productId, name: "Get Plan Config Default", }); // V2.1 GET: config defaults to { ignore_past_due: false } const v2_1 = await autumnV2_1.products.get(productId); expect(v2_1.config).toBeDefined(); expect(v2_1.config.ignore_past_due).toBe(false); // V2.0 / V1.2 GET: response succeeds; older shapes do not surface `config` const v2 = await autumnV2.products.get(productId); expect(v2.id).toBe(productId); const v1_2 = await autumnV1_2.products.get(productId); expect(v1_2.id).toBe(productId); }); test.concurrent(`${chalk.yellowBright("get plan config: V2.1 create with ignore_past_due=true persists across versions")}`, async () => { const productId = "get_plan_config_v2_1_true"; try { await autumnV2_1.products.delete(productId); } catch (_error) {} await autumnV2_1.products.create({ id: productId, name: "Get Plan Config V2.1 True", config: { ignore_past_due: true }, }); // V2.1 GET: ignore_past_due=true exposed on the response const v2_1 = await autumnV2_1.products.get(productId); expect(v2_1.config.ignore_past_due).toBe(true); // V2.0 / V1.2 GET: response succeeds, config stays out of the schema const v2 = await autumnV2.products.get(productId); expect(v2.id).toBe(productId); const v1_2 = await autumnV1_2.products.get(productId); expect(v1_2.id).toBe(productId); // Re-read via V2.1 to confirm older-version reads did not clobber the flag const v2_1Again = await autumnV2_1.products.get(productId); expect(v2_1Again.config.ignore_past_due).toBe(true); }); test.concurrent(`${chalk.yellowBright("get plan config: V2.0 create with ignore_past_due=true is visible via V2.1")}`, async () => { const productId = "get_plan_config_v2_0_true"; try { await autumnV2.products.delete(productId); } catch (_error) {} // V2.0 also accepts `config` on the create payload (CreateProductV2Params). await autumnV2.products.create({ id: productId, name: "Get Plan Config V2.0 True", config: { ignore_past_due: true }, }); // V2.1 surfaces the flag set via V2.0 const v2_1 = await autumnV2_1.products.get(productId); expect(v2_1.config.ignore_past_due).toBe(true); }); test.concurrent(`${chalk.yellowBright("get plan config: V2.1 update flips ignore_past_due — older readers still succeed")}`, async () => { const productId = "get_plan_config_update_flip"; try { await autumnV2_1.products.delete(productId); } catch (_error) {} await autumnV2_1.products.create({ id: productId, name: "Get Plan Config Update Flip", }); const before = await autumnV2_1.products.get(productId); expect(before.config.ignore_past_due).toBe(false); await autumnV2_1.products.update>( productId, { config: { ignore_past_due: true } }, ); const after = await autumnV2_1.products.get(productId); expect(after.config.ignore_past_due).toBe(true); // V2.0 / V1.2 reads still succeed; they just don't expose `config` const v2 = await autumnV2.products.get(productId); expect(v2.id).toBe(productId); const v1_2 = await autumnV1_2.products.get(productId); expect(v1_2.id).toBe(productId); }); test.concurrent(`${chalk.yellowBright("get plan config: V2.1 update flips ignore_past_due true → false")}`, async () => { const productId = "get_plan_config_update_off"; try { await autumnV2_1.products.delete(productId); } catch (_error) {} await autumnV2_1.products.create({ id: productId, name: "Get Plan Config Update Off", config: { ignore_past_due: true }, }); const before = await autumnV2_1.products.get(productId); expect(before.config.ignore_past_due).toBe(true); await autumnV2_1.products.update>( productId, { config: { ignore_past_due: false } }, ); const after = await autumnV2_1.products.get(productId); expect(after.config.ignore_past_due).toBe(false); }); test.concurrent(`${chalk.yellowBright("get plan config: partial update via V2.0 (no config) preserves ignore_past_due")}`, async () => { // Regression guard — both update paths (versioning + non-versioning) deep-merge // `config` so a PATCH from an older client that doesn't know about `config` // must not clobber the existing flag. const productId = "get_plan_config_partial_update"; try { await autumnV2_1.products.delete(productId); } catch (_error) {} await autumnV2_1.products.create({ id: productId, name: "Get Plan Config Partial Original", config: { ignore_past_due: true }, }); // V2.0 update body has no `config` field — flag must survive. await autumnV2.products.update(productId, { name: "Get Plan Config Partial Renamed", }); const v2_1 = await autumnV2_1.products.get(productId); expect(v2_1.name).toBe("Get Plan Config Partial Renamed"); expect(v2_1.config.ignore_past_due).toBe(true); }); test.concurrent(`${chalk.yellowBright("get plan config: omitting config in V2.1 update does not reset it")}`, async () => { const productId = "get_plan_config_omit_update"; try { await autumnV2_1.products.delete(productId); } catch (_error) {} await autumnV2_1.products.create({ id: productId, name: "Get Plan Config Persist", config: { ignore_past_due: true }, }); // Update something unrelated via V2.1 — config should survive. await autumnV2_1.products.update>( productId, { name: "Get Plan Config Persist Renamed" }, ); const after = await autumnV2_1.products.get(productId); expect(after.name).toBe("Get Plan Config Persist Renamed"); expect(after.config.ignore_past_due).toBe(true); });