From 0b08f3cc278f188a50bae22cc52c29bc1b4fb90d Mon Sep 17 00:00:00 2001 From: johnyeo Date: Mon, 1 Jun 2026 20:05:01 +0100 Subject: [PATCH] fix: normalize billing request items --- .../attach-v2/hooks/useAttachRequestBody.ts | 12 ++- .../utils/normalizeBillingRequestItems.ts | 31 ++++++ .../hooks/useUpdateSubscriptionRequestBody.ts | 3 +- .../hooks/build-attach-request-body.test.ts | 48 ++++++++- .../build-update-subscription-options.test.ts | 98 ++++++++++++++++++- 5 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 vite/src/components/forms/shared/utils/normalizeBillingRequestItems.ts diff --git a/vite/src/components/forms/attach-v2/hooks/useAttachRequestBody.ts b/vite/src/components/forms/attach-v2/hooks/useAttachRequestBody.ts index f129cf70d..807cb7497 100644 --- a/vite/src/components/forms/attach-v2/hooks/useAttachRequestBody.ts +++ b/vite/src/components/forms/attach-v2/hooks/useAttachRequestBody.ts @@ -10,6 +10,7 @@ import type { TrialOnEnd, } from "@autumn/shared"; import { useMemo } from "react"; +import { normalizeBillingRequestItems } from "@/components/forms/shared/utils/normalizeBillingRequestItems"; import { getFreeTrial } from "@/components/forms/update-subscription-v2/utils/getFreeTrial"; import { convertPrepaidOptionsToFeatureOptions } from "@/utils/billing/prepaidQuantityUtils"; import type { FormCustomLineItem } from "../attachFormSchema"; @@ -103,10 +104,13 @@ export function buildAttachRequestBody({ } if (items !== null) { - body.items = items.map((item) => ({ - ...item, - interval: (item.interval ?? null) as ProductItemInterval | null, - })); + const normalizedItems = normalizeBillingRequestItems({ items }); + if (normalizedItems) { + body.items = normalizedItems.map((item) => ({ + ...item, + interval: (item.interval ?? null) as ProductItemInterval | null, + })); + } } if (version !== undefined) { diff --git a/vite/src/components/forms/shared/utils/normalizeBillingRequestItems.ts b/vite/src/components/forms/shared/utils/normalizeBillingRequestItems.ts new file mode 100644 index 000000000..598059403 --- /dev/null +++ b/vite/src/components/forms/shared/utils/normalizeBillingRequestItems.ts @@ -0,0 +1,31 @@ +import type { ProductItem } from "@autumn/shared"; + +type DraftProductItem = Omit & { + price?: ProductItem["price"] | ""; +}; + +const isEmptyStandalonePriceDraft = (item: DraftProductItem) => + item.price === "" && + item.feature_id == null && + item.price_id == null && + item.entitlement_id == null && + item.price_config == null && + !item.tiers?.length; + +export function normalizeBillingRequestItems({ + items, +}: { + items?: ProductItem[] | null; +}): ProductItem[] | undefined { + if (!items?.length) return undefined; + + const normalizedItems = (items as DraftProductItem[]).flatMap((item) => { + if (isEmptyStandalonePriceDraft(item)) return []; + if (item.price !== "") return [item as ProductItem]; + + const { price: _price, ...itemWithoutDraftPrice } = item; + return [itemWithoutDraftPrice as ProductItem]; + }); + + return normalizedItems.length > 0 ? normalizedItems : undefined; +} diff --git a/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionRequestBody.ts b/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionRequestBody.ts index 4610d4900..a9770b3da 100644 --- a/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionRequestBody.ts +++ b/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionRequestBody.ts @@ -4,6 +4,7 @@ import type { UpdateSubscriptionV0Params, } from "@autumn/shared"; import { useCallback } from "react"; +import { normalizeBillingRequestItems } from "@/components/forms/shared/utils/normalizeBillingRequestItems"; import type { UpdateSubscriptionFormContext } from "../context/UpdateSubscriptionFormProvider"; import { getFreeTrial } from "../utils/getFreeTrial"; import type { UseUpdateSubscriptionForm } from "./useUpdateSubscriptionForm"; @@ -181,7 +182,7 @@ export function useUpdateSubscriptionRequestBody({ ...base, options: options.length > 0 ? options : undefined, free_trial: freeTrial, - items: items && items.length > 0 ? items : undefined, + items: normalizeBillingRequestItems({ items }), version: version !== initialVersion ? version : undefined, billing_behavior: billingBehavior || undefined, billing_cycle_anchor: resetBillingCycle ? "now" : undefined, diff --git a/vite/tests/components/forms/attach-v2/hooks/build-attach-request-body.test.ts b/vite/tests/components/forms/attach-v2/hooks/build-attach-request-body.test.ts index cd7f11909..5753027b0 100644 --- a/vite/tests/components/forms/attach-v2/hooks/build-attach-request-body.test.ts +++ b/vite/tests/components/forms/attach-v2/hooks/build-attach-request-body.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { type ProductV2, UsageModel } from "@autumn/shared"; +import { + type ProductItem, + ProductItemInterval, + type ProductV2, + UsageModel, +} from "@autumn/shared"; import { addDays } from "date-fns"; import { buildAttachRequestBody } from "@/components/forms/attach-v2/hooks/useAttachRequestBody"; @@ -222,3 +227,44 @@ describe("buildAttachRequestBody — starts_at handling", () => { expect(result?.starts_at).toBeUndefined(); }); }); + +describe("buildAttachRequestBody — items serialization", () => { + test("drops empty fixed-price draft rows before serialization", () => { + const product = makeProduct({ + items: [{ price: 20, interval: ProductItemInterval.Month }], + }); + const items = [ + { + feature_id: "AI_CREDITS", + price: null, + tiers: [{ amount: 0.01, to: "inf" }], + }, + { + price: "" as unknown as number, + feature_id: null, + price_id: null, + entitlement_id: null, + interval: ProductItemInterval.Month, + interval_count: 1, + tiers: null, + price_config: null, + }, + ] satisfies ProductItem[]; + + const result = buildAttachRequestBody({ + ...baseParams, + product, + prepaidOptions: {}, + items, + }); + + expect(result?.items).toEqual([ + { + feature_id: "AI_CREDITS", + price: null, + tiers: [{ amount: 0.01, to: "inf" }], + interval: null, + }, + ]); + }); +}); diff --git a/vite/tests/components/forms/update-subscription-v2/hooks/build-update-subscription-options.test.ts b/vite/tests/components/forms/update-subscription-v2/hooks/build-update-subscription-options.test.ts index e06a1f535..59d581796 100644 --- a/vite/tests/components/forms/update-subscription-v2/hooks/build-update-subscription-options.test.ts +++ b/vite/tests/components/forms/update-subscription-v2/hooks/build-update-subscription-options.test.ts @@ -1,5 +1,6 @@ -import { ProductItemInterval } from "@autumn/shared"; import { describe, expect, test } from "bun:test"; +import { type ProductItem, ProductItemInterval } from "@autumn/shared"; +import { normalizeBillingRequestItems } from "@/components/forms/shared/utils/normalizeBillingRequestItems"; import { buildUpdateSubscriptionOptions } from "@/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionRequestBody"; describe("buildUpdateSubscriptionOptions — included usage handling", () => { @@ -221,3 +222,98 @@ describe("buildUpdateSubscriptionOptions — included usage handling", () => { expect(result).toEqual([]); }); }); + +describe("normalizeBillingRequestItems", () => { + test("drops empty fixed-price draft rows before serialization", () => { + const items = [ + { + feature_id: "AI_CREDITS", + price: null, + tiers: [{ amount: 0.01, to: "inf" }], + }, + { + price: "" as unknown as number, + feature_id: null, + price_id: null, + entitlement_id: null, + interval: ProductItemInterval.Month, + interval_count: 1, + tiers: null, + billing_units: null, + usage_model: null, + included_usage: null, + config: null, + feature_type: null, + entity_feature_id: null, + price_config: null, + }, + { + price: "" as unknown as number, + feature_id: null, + price_id: null, + entitlement_id: null, + interval: ProductItemInterval.Month, + interval_count: 1, + tiers: null, + billing_units: null, + usage_model: null, + included_usage: null, + config: null, + feature_type: null, + entity_feature_id: null, + price_config: null, + }, + { + feature_id: "SSO", + price: null, + }, + ] satisfies ProductItem[]; + + expect(normalizeBillingRequestItems({ items })).toEqual([ + items[0], + items[3], + ]); + }); + + test("omits draft string prices from non-empty items", () => { + const items = [ + { + feature_id: "seats", + price: "" as unknown as number, + included_usage: 10, + interval: ProductItemInterval.Month, + }, + ] satisfies ProductItem[]; + + expect(normalizeBillingRequestItems({ items })).toEqual([ + { + feature_id: "seats", + included_usage: 10, + interval: ProductItemInterval.Month, + }, + ]); + }); + + test("returns undefined when only empty draft rows are present", () => { + const items = [ + { + price: "" as unknown as number, + feature_id: null, + price_id: null, + entitlement_id: null, + interval: ProductItemInterval.Month, + interval_count: 1, + tiers: null, + billing_units: null, + usage_model: null, + included_usage: null, + config: null, + feature_type: null, + entity_feature_id: null, + price_config: null, + }, + ] satisfies ProductItem[]; + + expect(normalizeBillingRequestItems({ items })).toBeUndefined(); + }); +});