fix: normalize billing request items

This commit is contained in:
johnyeo
2026-06-01 20:05:01 +01:00
parent 159e239cb5
commit 0b08f3cc27
5 changed files with 185 additions and 7 deletions

View File

@@ -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) {

View File

@@ -0,0 +1,31 @@
import type { ProductItem } from "@autumn/shared";
type DraftProductItem = Omit<ProductItem, "price"> & {
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;
}

View File

@@ -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,

View File

@@ -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,
},
]);
});
});

View File

@@ -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();
});
});