Files
cfw-autumn/vite/tests/components/forms/create-schedule/hooks/build-create-schedule-request-body.test.ts
Ayush Rodrigues 152a749b18 fix: send price:null when schedule plan customize removes base price
buildCustomizeBasePrice treated price=0 as falsy (!0 === true), silently
dropping the price override. The server then fell back to the original
plan price, causing free-first-phase schedules to show a non-zero total
and redirect to checkout instead of creating directly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 12:31:18 +01:00

448 lines
12 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import type { Feature, ProductItem, ProductV2 } from "@autumn/shared";
import { AppEnv } from "@autumn/shared";
import {
buildCustomize,
buildCustomizeBasePrice,
buildCustomizeItems,
buildCreateScheduleRequestBody,
} from "@/components/forms/create-schedule/hooks/useCreateScheduleRequestBody";
import { EMPTY_SCHEDULE_PLAN } from "@/components/forms/create-schedule/createScheduleFormSchema";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function makeProduct({
id = "prod_1",
items = [],
}: {
id?: string;
items?: ProductV2["items"];
} = {}): ProductV2 {
return {
id,
name: "Test Product",
is_add_on: false,
is_default: false,
version: 1,
group: null,
env: AppEnv.Sandbox,
items,
created_at: Date.now(),
};
}
const basePriceItem: ProductItem = {
feature_id: null,
price: 2000,
interval: "month",
interval_count: 1,
} as ProductItem;
const featurePriceItem: ProductItem = {
feature_id: "api_calls",
price: 0.01,
tiers: null,
interval: "month",
interval_count: 1,
included_usage: 1000,
} as ProductItem;
const featureTieredItem: ProductItem = {
feature_id: "storage",
price: null,
tiers: [
{ to: 100, amount: 0.5 },
{ to: -1, amount: 0.25 },
],
interval: "month",
interval_count: 1,
included_usage: 10,
} as ProductItem;
const freeFeatureItem: ProductItem = {
feature_id: "support",
price: null,
tiers: null,
included_usage: 1,
} as ProductItem;
const features: Feature[] = [
{ id: "api_calls", name: "API Calls", internal_id: "int_api", type: "usage" } as Feature,
{ id: "storage", name: "Storage", internal_id: "int_storage", type: "usage" } as Feature,
{ id: "support", name: "Support", internal_id: "int_support", type: "boolean" } as Feature,
];
// ---------------------------------------------------------------------------
// buildCustomizeBasePrice
// ---------------------------------------------------------------------------
describe("buildCustomizeBasePrice", () => {
test("extracts base price from items", () => {
const result = buildCustomizeBasePrice({ items: [basePriceItem, featurePriceItem] });
expect(result).toBeDefined();
expect(result!.amount).toBe(2000);
expect(result!.interval).toBe("month");
});
test("returns null when base price item was removed", () => {
const result = buildCustomizeBasePrice({ items: [featurePriceItem] });
expect(result).toBeNull();
});
test("returns null when price is zero (free)", () => {
const zeroPriceItem = { ...basePriceItem, price: 0 } as ProductItem;
const result = buildCustomizeBasePrice({ items: [zeroPriceItem] });
expect(result).toBeNull();
});
test("returns undefined when price item has no interval", () => {
const noIntervalItem = { ...basePriceItem, interval: null } as unknown as ProductItem;
const result = buildCustomizeBasePrice({ items: [noIntervalItem] });
expect(result).toBeUndefined();
});
test("includes interval_count when present", () => {
const quarterlyItem = { ...basePriceItem, interval_count: 3 } as ProductItem;
const result = buildCustomizeBasePrice({ items: [quarterlyItem] });
expect(result!.interval_count).toBe(3);
});
test("omits interval_count when null", () => {
const item = { ...basePriceItem, interval_count: null } as unknown as ProductItem;
const result = buildCustomizeBasePrice({ items: [item] });
expect(result).toBeDefined();
expect(result!).not.toHaveProperty("interval_count");
});
test("ignores feature items that happen to have a price", () => {
const result = buildCustomizeBasePrice({ items: [featurePriceItem] });
expect(result).toBeNull();
});
});
// ---------------------------------------------------------------------------
// buildCustomizeItems
// ---------------------------------------------------------------------------
describe("buildCustomizeItems", () => {
test("converts priced feature items to plan items", () => {
const result = buildCustomizeItems({
items: [featurePriceItem],
features,
});
expect(result).toBeDefined();
expect(result!.length).toBe(1);
expect(result![0].feature_id).toBe("api_calls");
});
test("includes tiered feature items", () => {
const result = buildCustomizeItems({
items: [featureTieredItem],
features,
});
expect(result).toBeDefined();
expect(result!.length).toBe(1);
expect(result![0].feature_id).toBe("storage");
});
test("includes free features (boolean/included-only)", () => {
const result = buildCustomizeItems({
items: [freeFeatureItem],
features,
});
expect(result).toBeDefined();
expect(result!.length).toBe(1);
expect(result![0].feature_id).toBe("support");
});
test("excludes base price items (no feature_id)", () => {
const result = buildCustomizeItems({
items: [basePriceItem],
features,
});
expect(result).toBeUndefined();
});
test("strips null max_purchase from output", () => {
const itemWithNullMax: ProductItem = {
...featurePriceItem,
price: 5,
} as ProductItem;
const result = buildCustomizeItems({
items: [itemWithNullMax],
features,
});
expect(result).toBeDefined();
if (result![0].price) {
expect(result![0].price.max_purchase).toBeUndefined();
}
});
test("returns items when all feature items are free", () => {
const result = buildCustomizeItems({
items: [freeFeatureItem],
features,
});
expect(result).toBeDefined();
expect(result![0].feature_id).toBe("support");
});
});
// ---------------------------------------------------------------------------
// buildCustomize
// ---------------------------------------------------------------------------
describe("buildCustomize", () => {
test("returns undefined for null items", () => {
const result = buildCustomize({ items: null, features });
expect(result).toBeUndefined();
});
test("returns items and price:null when base price was removed", () => {
const result = buildCustomize({ items: [freeFeatureItem], features });
expect(result).toBeDefined();
expect(result!.items).toBeDefined();
expect(result!.price).toBeNull();
});
test("returns price when only base price is customized", () => {
const result = buildCustomize({ items: [basePriceItem], features });
expect(result).toBeDefined();
expect(result!.price).toBeDefined();
expect(result!.price!.amount).toBe(2000);
expect(result).not.toHaveProperty("items");
});
test("returns items and price:null when items have no base price", () => {
const result = buildCustomize({ items: [featurePriceItem], features });
expect(result).toBeDefined();
expect(result!.items).toBeDefined();
expect(result!.price).toBeNull();
});
test("returns both price and items when fully customized", () => {
const result = buildCustomize({
items: [basePriceItem, featurePriceItem],
features,
});
expect(result).toBeDefined();
expect(result!.price).toBeDefined();
expect(result!.items).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// buildCreateScheduleRequestBody
// ---------------------------------------------------------------------------
describe("buildCreateScheduleRequestBody", () => {
const defaultProducts = [makeProduct({ id: "prod_1" })];
test("returns null when customerId is missing", () => {
const result = buildCreateScheduleRequestBody({
customerId: undefined,
entityId: undefined,
phases: [{ startsAt: Date.now(), persistedStartsAt: undefined, plans: [{ ...EMPTY_SCHEDULE_PLAN, productId: "prod_1" }] }],
products: defaultProducts,
features,
});
expect(result).toBeNull();
});
test("returns null when phases is empty", () => {
const result = buildCreateScheduleRequestBody({
customerId: "cus_1",
entityId: undefined,
phases: [],
products: defaultProducts,
features,
});
expect(result).toBeNull();
});
test("builds valid request body for single phase single plan", () => {
const now = Date.now();
const result = buildCreateScheduleRequestBody({
customerId: "cus_1",
entityId: undefined,
phases: [
{
startsAt: now,
persistedStartsAt: now,
plans: [{ ...EMPTY_SCHEDULE_PLAN, productId: "prod_1" }],
},
],
products: defaultProducts,
features,
});
expect(result).not.toBeNull();
expect(result!.customer_id).toBe("cus_1");
expect(result!.phases).toHaveLength(1);
expect(result!.phases[0].plans).toHaveLength(1);
expect(result!.phases[0].plans[0].plan_id).toBe("prod_1");
});
test("includes customize when plan has custom items and isCustom", () => {
const now = Date.now();
const result = buildCreateScheduleRequestBody({
customerId: "cus_1",
entityId: undefined,
phases: [
{
startsAt: now,
persistedStartsAt: now,
plans: [{
...EMPTY_SCHEDULE_PLAN,
productId: "prod_1",
items: [basePriceItem, featurePriceItem],
isCustom: true,
}],
},
],
products: defaultProducts,
features,
});
expect(result).not.toBeNull();
const plan = result!.phases[0].plans[0];
expect(plan.customize).toBeDefined();
expect(plan.customize!.price).toBeDefined();
expect(plan.customize!.items).toBeDefined();
});
test("omits customize when plan has items but isCustom is false", () => {
const now = Date.now();
const result = buildCreateScheduleRequestBody({
customerId: "cus_1",
entityId: undefined,
phases: [
{
startsAt: now,
persistedStartsAt: now,
plans: [{
...EMPTY_SCHEDULE_PLAN,
productId: "prod_1",
items: [basePriceItem, featurePriceItem],
isCustom: false,
}],
},
],
products: defaultProducts,
features,
});
expect(result).not.toBeNull();
const plan = result!.phases[0].plans[0];
expect(plan.customize).toBeUndefined();
});
test("omits customize when plan has no custom items", () => {
const now = Date.now();
const result = buildCreateScheduleRequestBody({
customerId: "cus_1",
entityId: undefined,
phases: [
{
startsAt: now,
persistedStartsAt: now,
plans: [{ ...EMPTY_SCHEDULE_PLAN, productId: "prod_1" }],
},
],
products: defaultProducts,
features,
});
const plan = result!.phases[0].plans[0];
expect(plan.customize).toBeUndefined();
});
test("skips plans without productId", () => {
const now = Date.now();
const result = buildCreateScheduleRequestBody({
customerId: "cus_1",
entityId: undefined,
phases: [
{
startsAt: now,
persistedStartsAt: now,
plans: [
{ ...EMPTY_SCHEDULE_PLAN },
{ ...EMPTY_SCHEDULE_PLAN, productId: "prod_1" },
],
},
],
products: defaultProducts,
features,
});
expect(result!.phases[0].plans).toHaveLength(1);
expect(result!.phases[0].plans[0].plan_id).toBe("prod_1");
});
test("returns null when phase has null startsAt and is not first without persisted schedule", () => {
const result = buildCreateScheduleRequestBody({
customerId: "cus_1",
entityId: undefined,
phases: [
{
startsAt: null,
persistedStartsAt: undefined,
plans: [{ ...EMPTY_SCHEDULE_PLAN, productId: "prod_1" }],
},
{
startsAt: null,
persistedStartsAt: undefined,
plans: [{ ...EMPTY_SCHEDULE_PLAN, productId: "prod_2" }],
},
],
products: defaultProducts,
features,
});
expect(result).toBeNull();
});
test("includes entityId when provided", () => {
const now = Date.now();
const result = buildCreateScheduleRequestBody({
customerId: "cus_1",
entityId: "entity_1",
phases: [
{
startsAt: now,
persistedStartsAt: now,
plans: [{ ...EMPTY_SCHEDULE_PLAN, productId: "prod_1" }],
},
],
products: defaultProducts,
features,
});
expect(result).not.toBeNull();
expect((result as any).entity_id).toBe("entity_1");
});
});