From 7281372fd33b51b1c1eacca5776de145c753cdb1 Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Fri, 27 Mar 2026 21:46:59 +0000 Subject: [PATCH] chore: fix update sub volume based bug --- .../forms/shared/plan-items/PlanItemRow.tsx | 50 ++++++-- .../components/SubscriptionItemRow.tsx | 6 +- .../UpdateSubscriptionFormProvider.tsx | 85 +++++++++---- .../hooks/useUpdateSubscriptionRequestBody.ts | 118 ++++++++++-------- .../use-update-subscription-body-builder.ts | 40 +++--- .../src/utils/billing/prepaidQuantityUtils.ts | 17 ++- .../shared/plan-items/plan-item-row.test.ts | 28 +++++ .../build-update-subscription-options.test.ts | 106 ++++++++++------ ...legacy-update-subscription-options.test.ts | 18 +++ .../billing/prepaid-quantity-utils.test.ts | 23 +++- 10 files changed, 340 insertions(+), 151 deletions(-) create mode 100644 vite/tests/components/forms/shared/plan-items/plan-item-row.test.ts create mode 100644 vite/tests/components/forms/update-subscription/build-legacy-update-subscription-options.test.ts diff --git a/vite/src/components/forms/shared/plan-items/PlanItemRow.tsx b/vite/src/components/forms/shared/plan-items/PlanItemRow.tsx index ef809178a..72766bc74 100644 --- a/vite/src/components/forms/shared/plan-items/PlanItemRow.tsx +++ b/vite/src/components/forms/shared/plan-items/PlanItemRow.tsx @@ -11,6 +11,38 @@ import { STAGGER_ITEM_LAYOUT } from "@/components/forms/update-subscription-v2/c import type { UseUpdateSubscriptionForm } from "@/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionForm"; import { LAYOUT_TRANSITION } from "@/components/v2/sheets/SharedSheetComponents"; +export function getPlanItemPrepaidQuantity({ + featureId, + prepaidOptions, + initialPrepaidOptions, + existingOptions, + features, +}: { + featureId: string; + prepaidOptions: Record; + initialPrepaidOptions: Record; + existingOptions?: FeatureOptions[]; + features: Feature[]; +}) { + const formQuantity = prepaidOptions[featureId]; + if (formQuantity !== undefined) return formQuantity; + + const initialQuantity = initialPrepaidOptions[featureId]; + if (initialQuantity !== undefined) return initialQuantity; + + if (!existingOptions) return undefined; + + const featureForOptions = features?.find((f) => f.id === featureId); + if (!featureForOptions) return undefined; + + const prepaidOption = featureToOptions({ + feature: featureForOptions, + options: existingOptions, + }); + + return prepaidOption?.quantity; +} + export function PlanItemRow({ item, index, @@ -41,17 +73,13 @@ export function PlanItemRow({ const featureId = item.feature_id; const isPrepaid = item.usage_model === UsageModel.Prepaid; - let currentPrepaidQuantity: number | undefined; - if (isPrepaid) { - currentPrepaidQuantity = prepaidOptions[featureId]; - } else if (existingOptions) { - const featureForOptions = features?.find((f) => f.id === featureId); - const prepaidOption = featureToOptions({ - feature: featureForOptions, - options: existingOptions, - }); - currentPrepaidQuantity = prepaidOption?.quantity; - } + const currentPrepaidQuantity = getPlanItemPrepaidQuantity({ + featureId, + prepaidOptions, + initialPrepaidOptions, + existingOptions: isPrepaid ? undefined : existingOptions, + features, + }); const initialPrepaidQuantity = isPrepaid ? initialPrepaidOptions[featureId] diff --git a/vite/src/components/forms/update-subscription-v2/components/SubscriptionItemRow.tsx b/vite/src/components/forms/update-subscription-v2/components/SubscriptionItemRow.tsx index 8037f455c..cc1c70053 100644 --- a/vite/src/components/forms/update-subscription-v2/components/SubscriptionItemRow.tsx +++ b/vite/src/components/forms/update-subscription-v2/components/SubscriptionItemRow.tsx @@ -214,6 +214,8 @@ export function SubscriptionItemRow({ const showPrepaidOutside = isPrepaid && form && featureId && !hasEditableEdit; const inputQuantity = prepaidQuantity ?? 0; const billingUnitStep = item.billing_units ?? 1; + const minPrepaidQuantity = + typeof item.included_usage === "number" ? item.included_usage : 0; const { roundedQuantity, normalizedBillingUnits, shouldShowRoundingHint } = getPrepaidQuantityTooltipData({ inputQuantity, @@ -302,7 +304,7 @@ export function SubscriptionItemRow({ {(field) => ( @@ -345,7 +347,7 @@ export function SubscriptionItemRow({ ); const prepaidTooltipContent = (
-

Quantity is exclusive of included usage.

+

Quantity includes included usage.

{shouldShowRoundingHint && (

Rounded up to {roundedQuantity} to match {normalizedBillingUnits}-unit diff --git a/vite/src/components/forms/update-subscription-v2/context/UpdateSubscriptionFormProvider.tsx b/vite/src/components/forms/update-subscription-v2/context/UpdateSubscriptionFormProvider.tsx index 900873081..6e680bd80 100644 --- a/vite/src/components/forms/update-subscription-v2/context/UpdateSubscriptionFormProvider.tsx +++ b/vite/src/components/forms/update-subscription-v2/context/UpdateSubscriptionFormProvider.tsx @@ -169,27 +169,6 @@ export function UpdateSubscriptionFormProvider({ const initialPrepaidOptions = defaultValues?.prepaidOptions ?? {}; const initialBillingBehavior = defaultValues?.billingBehavior ?? null; - const hasChanges = useHasSubscriptionChanges({ - formValues, - initialPrepaidOptions, - initialBillingBehavior, - prepaidItems, - customerProduct, - currentVersion, - originalItems, - features, - }); - - const changedPrepaidOptions = useMemo(() => { - const changed: Record = {}; - for (const [featureId, quantity] of Object.entries(prepaidOptions)) { - if (quantity !== initialPrepaidOptions[featureId]) { - changed[featureId] = quantity; - } - } - return Object.keys(changed).length > 0 ? changed : undefined; - }, [prepaidOptions, initialPrepaidOptions]); - const productWithFormItems = useMemo((): FrontendProduct | undefined => { if (!effectiveProduct) return undefined; @@ -203,6 +182,63 @@ export function UpdateSubscriptionFormProvider({ }); }, [effectiveProduct, formValues]); + const currentPrepaidItems = useMemo( + () => + (productWithFormItems?.items ?? []).filter( + (item) => item.usage_model === "prepaid" && item.feature_id, + ), + [productWithFormItems?.items], + ); + + const normalizedPrepaidOptions = useMemo(() => { + const normalizedOptions = { ...prepaidOptions }; + + for (const item of currentPrepaidItems) { + if (!item.feature_id) continue; + + const minQuantity = + typeof item.included_usage === "number" ? item.included_usage : 0; + const currentQuantity = normalizedOptions[item.feature_id]; + + if (currentQuantity === undefined || currentQuantity < minQuantity) { + normalizedOptions[item.feature_id] = minQuantity; + } + } + + return normalizedOptions; + }, [prepaidOptions, currentPrepaidItems]); + + const normalizedFormValues = useMemo( + () => ({ + ...formValues, + prepaidOptions: normalizedPrepaidOptions, + }), + [formValues, normalizedPrepaidOptions], + ); + + const hasChanges = useHasSubscriptionChanges({ + formValues: normalizedFormValues, + initialPrepaidOptions, + initialBillingBehavior, + prepaidItems, + customerProduct, + currentVersion, + originalItems, + features, + }); + + const changedPrepaidOptions = useMemo(() => { + const changed: Record = {}; + for (const [featureId, quantity] of Object.entries( + normalizedPrepaidOptions, + )) { + if (quantity !== initialPrepaidOptions[featureId]) { + changed[featureId] = quantity; + } + } + return Object.keys(changed).length > 0 ? changed : undefined; + }, [normalizedPrepaidOptions, initialPrepaidOptions]); + const baseProduct = useMemo((): FrontendProduct | undefined => { if (!product) return undefined; return productV2ToFrontendProduct({ product: product as ProductV2 }); @@ -217,9 +253,9 @@ export function UpdateSubscriptionFormProvider({ return getProductWithSupportedFormValues({ baseProduct: base, - formValues, + formValues: normalizedFormValues, }); - }, [effectiveProduct, formValues]); + }, [effectiveProduct, normalizedFormValues]); const hasBillingChanges = useHasBillingChanges({ baseProduct: baseProduct as FrontendProduct, @@ -237,6 +273,7 @@ export function UpdateSubscriptionFormProvider({ const { buildRequestBody } = useUpdateSubscriptionRequestBody({ updateSubscriptionFormContext: formContext, form, + currentPrepaidItems, }); // Build the preview body reactively — formValues triggers recomputation, @@ -340,7 +377,7 @@ export function UpdateSubscriptionFormProvider({ () => ({ formContext, form, - formValues, + formValues: normalizedFormValues, features, trialState, originalItems, 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 f992bca8d..3071974a1 100644 --- a/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionRequestBody.ts +++ b/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionRequestBody.ts @@ -1,90 +1,106 @@ -import { - type ProductItem, - type UpdateSubscriptionV0Params, - UsageModel, -} from "@autumn/shared"; +import type { ProductItem, UpdateSubscriptionV0Params } from "@autumn/shared"; import { useCallback } from "react"; import type { UpdateSubscriptionFormContext } from "../context/UpdateSubscriptionFormProvider"; import { getFreeTrial } from "../utils/getFreeTrial"; import type { UseUpdateSubscriptionForm } from "./useUpdateSubscriptionForm"; +type PrepaidItemInput = { + feature_id?: string | null; + feature?: { internal_id?: string } | null; + included_usage?: number | "inf" | null; +}; + /** Pure function to build update subscription options from prepaid form values. Extracted for testability. */ export function buildUpdateSubscriptionOptions({ prepaidItems, prepaidOptions, initialPrepaidOptions, - items, + initialBackendQuantities, }: { - prepaidItems: { - feature_id?: string | null; - feature?: { internal_id?: string } | null; - included_usage?: number | "inf" | null; - }[]; + prepaidItems: PrepaidItemInput[]; prepaidOptions: Record; initialPrepaidOptions: Record; - items?: ProductItem[] | null; + initialBackendQuantities: Record; }): { feature_id: string; quantity: number }[] { - const options = prepaidItems + const getFeatureId = ({ item }: { item: PrepaidItemInput }) => + item.feature_id ?? item.feature?.internal_id ?? ""; + + const getIncludedUsage = ({ item }: { item?: PrepaidItemInput | null }) => + typeof item?.included_usage === "number" ? item.included_usage : 0; + + const normalizeQuantity = ({ + quantity, + includedUsage, + }: { + quantity: number; + includedUsage: number; + }) => Math.max(quantity, includedUsage); + + const getPurchasedQuantity = ({ + totalQuantity, + includedUsage, + }: { + totalQuantity: number; + includedUsage: number; + }) => Math.max(0, totalQuantity - includedUsage); + + return prepaidItems .map((item) => { - const featureId = item.feature_id ?? item.feature?.internal_id ?? ""; + const featureId = getFeatureId({ item }); const inputQuantity = prepaidOptions[featureId]; - const initialQuantity = initialPrepaidOptions[featureId]; - const includedUsage = - typeof item.included_usage === "number" ? item.included_usage : 0; + const initialQuantity = initialPrepaidOptions[featureId] ?? 0; + const normalizedInputQuantity = + inputQuantity === undefined || inputQuantity === null + ? undefined + : normalizeQuantity({ + quantity: inputQuantity, + includedUsage: getIncludedUsage({ item }), + }); + const currentIncludedUsage = getIncludedUsage({ item }); + const purchasedQuantityChanged = + getPurchasedQuantity({ + totalQuantity: normalizedInputQuantity ?? initialQuantity, + includedUsage: currentIncludedUsage, + }) !== (initialBackendQuantities[featureId] ?? 0); if ( - inputQuantity !== undefined && - inputQuantity !== null && + normalizedInputQuantity !== undefined && + normalizedInputQuantity !== null && featureId && - inputQuantity !== initialQuantity + (normalizedInputQuantity !== initialQuantity || + purchasedQuantityChanged) ) { return { feature_id: featureId, - quantity: inputQuantity + includedUsage, + quantity: normalizedInputQuantity, }; } return null; }) .filter((o): o is { feature_id: string; quantity: number } => o !== null); - - if (items && items.length > 0) { - const existingFeatureIds = new Set(options.map((o) => o.feature_id)); - - for (const item of items) { - if ( - item.usage_model === UsageModel.Prepaid && - item.feature_id && - !existingFeatureIds.has(item.feature_id) - ) { - const inputQuantity = prepaidOptions[item.feature_id]; - const includedUsage = - typeof item.included_usage === "number" ? item.included_usage : 0; - - if (inputQuantity !== undefined && inputQuantity !== null) { - options.push({ - feature_id: item.feature_id, - quantity: inputQuantity + includedUsage, - }); - } - } - } - } - - return options; } export function useUpdateSubscriptionRequestBody({ updateSubscriptionFormContext, form, + currentPrepaidItems, }: { updateSubscriptionFormContext: UpdateSubscriptionFormContext; form: UseUpdateSubscriptionForm; + currentPrepaidItems: ProductItem[]; }) { - const { customerId, product, entityId, customerProduct, prepaidItems } = + const { customerId, product, entityId, customerProduct } = updateSubscriptionFormContext; const initialPrepaidOptions = form.options.defaultValues?.prepaidOptions ?? {}; + const initialBackendQuantities = customerProduct.options.reduce( + (acc, option) => { + acc[option.feature_id] = option.quantity; + return acc; + }, + {} as Record, + ); const initialVersion = form.options.defaultValues?.version; const buildRequestBody = useCallback((): UpdateSubscriptionV0Params => { @@ -123,10 +139,10 @@ export function useUpdateSubscriptionRequestBody({ } const options = buildUpdateSubscriptionOptions({ - prepaidItems, + prepaidItems: currentPrepaidItems, prepaidOptions, initialPrepaidOptions, - items, + initialBackendQuantities, }); const freeTrial = getFreeTrial({ @@ -152,9 +168,11 @@ export function useUpdateSubscriptionRequestBody({ entityId, customerProduct.id, customerProduct.internal_product_id, + customerProduct.options, initialVersion, - prepaidItems, + currentPrepaidItems, initialPrepaidOptions, + initialBackendQuantities, ]); return { buildRequestBody }; diff --git a/vite/src/components/forms/update-subscription/use-update-subscription-body-builder.ts b/vite/src/components/forms/update-subscription/use-update-subscription-body-builder.ts index db6763dcd..17c95a0b7 100644 --- a/vite/src/components/forms/update-subscription/use-update-subscription-body-builder.ts +++ b/vite/src/components/forms/update-subscription/use-update-subscription-body-builder.ts @@ -2,9 +2,9 @@ import type { BillingBehavior } from "@autumn/shared"; import { AppEnv, type CreateFreeTrial, + type FeatureOptions, type ProductItem, type ProductV2, - UsageModel, } from "@autumn/shared"; import { useMemo } from "react"; import type { @@ -78,28 +78,9 @@ export function useUpdateSubscriptionBodyBuilder( mergedParams.version ?? (storeProduct?.id ? storeProduct.version : undefined); - // Convert prepaidOptions to options array - const options = mergedParams.prepaidOptions - ? Object.entries(mergedParams.prepaidOptions).map( - ([featureId, quantity]) => { - const prepaidItem = product?.items.find( - (item) => - item.feature_id === featureId && - item.usage_model === UsageModel.Prepaid, - ); - - const includedUsage = - prepaidItem && typeof prepaidItem.included_usage === "number" - ? prepaidItem.included_usage - : 0; - - return { - feature_id: featureId, - quantity: (quantity || 0) + includedUsage, - }; - }, - ) - : []; + const options = buildLegacyUpdateSubscriptionOptions({ + prepaidOptions: mergedParams.prepaidOptions, + }); // Build the body using getUpdateSubscriptionBody (includes freeTrial support) return getUpdateSubscriptionBody({ @@ -133,3 +114,16 @@ export function useUpdateSubscriptionBodyBuilder( return { updateSubscriptionBody, buildUpdateSubscriptionBody }; } + +export function buildLegacyUpdateSubscriptionOptions({ + prepaidOptions, +}: { + prepaidOptions?: Record; +}): FeatureOptions[] { + if (!prepaidOptions) return []; + + return Object.entries(prepaidOptions).map(([featureId, quantity]) => ({ + feature_id: featureId, + quantity: quantity || 0, + })); +} diff --git a/vite/src/utils/billing/prepaidQuantityUtils.ts b/vite/src/utils/billing/prepaidQuantityUtils.ts index 1b829193e..9ef89d8ef 100644 --- a/vite/src/utils/billing/prepaidQuantityUtils.ts +++ b/vite/src/utils/billing/prepaidQuantityUtils.ts @@ -13,7 +13,11 @@ export function backendToDisplayQuantity({ prepaidItems, }: { backendOptions: { feature_id: string; quantity: number }[]; - prepaidItems: { feature_id?: string | null; billing_units?: number | null }[]; + prepaidItems: { + feature_id?: string | null; + billing_units?: number | null; + included_usage?: number | "inf" | null; + }[]; }): Record { const backendLookup = backendOptions.reduce( (acc, option) => { @@ -28,10 +32,13 @@ export function backendToDisplayQuantity({ if (!item.feature_id) return acc; const backendQuantity = backendLookup[item.feature_id] ?? 0; - acc[item.feature_id] = getPrepaidDisplayQuantity({ - quantity: backendQuantity, - billingUnits: item.billing_units, - }); + const includedUsage = + typeof item.included_usage === "number" ? item.included_usage : 0; + acc[item.feature_id] = + getPrepaidDisplayQuantity({ + quantity: backendQuantity, + billingUnits: item.billing_units, + }) + includedUsage; return acc; }, {} as Record, diff --git a/vite/tests/components/forms/shared/plan-items/plan-item-row.test.ts b/vite/tests/components/forms/shared/plan-items/plan-item-row.test.ts new file mode 100644 index 000000000..a74eb0824 --- /dev/null +++ b/vite/tests/components/forms/shared/plan-items/plan-item-row.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { getPlanItemPrepaidQuantity } from "@/components/forms/shared/plan-items/PlanItemRow"; + +describe("getPlanItemPrepaidQuantity", () => { + test("should prefer the form quantity over existing backend options", () => { + const result = getPlanItemPrepaidQuantity({ + featureId: "AI_CREDITS", + prepaidOptions: { AI_CREDITS: 750 }, + initialPrepaidOptions: { AI_CREDITS: 750 }, + existingOptions: [{ feature_id: "AI_CREDITS", quantity: 500 }], + features: [], + }); + + expect(result).toBe(750); + }); + + test("should fall back to the initial quantity before existing options", () => { + const result = getPlanItemPrepaidQuantity({ + featureId: "AI_CREDITS", + prepaidOptions: {}, + initialPrepaidOptions: { AI_CREDITS: 750 }, + existingOptions: [{ feature_id: "AI_CREDITS", quantity: 500 }], + features: [], + }); + + expect(result).toBe(750); + }); +}); 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 faf3f6300..c34971123 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,13 +1,13 @@ import { describe, expect, test } from "bun:test"; -import { UsageModel } from "@autumn/shared"; import { buildUpdateSubscriptionOptions } from "@/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionRequestBody"; -describe("buildUpdateSubscriptionOptions — billing_units handling", () => { +describe("buildUpdateSubscriptionOptions — included usage handling", () => { test("should pass display quantities through, not multiply by billing_units", () => { const result = buildUpdateSubscriptionOptions({ prepaidItems: [{ feature_id: "messages", included_usage: 0 }], prepaidOptions: { messages: 5000 }, initialPrepaidOptions: { messages: 1000 }, + initialBackendQuantities: { messages: 1000 }, }); expect(result).toEqual([{ feature_id: "messages", quantity: 5000 }]); @@ -18,20 +18,55 @@ describe("buildUpdateSubscriptionOptions — billing_units handling", () => { prepaidItems: [{ feature_id: "messages", included_usage: 0 }], prepaidOptions: { messages: 5000 }, initialPrepaidOptions: { messages: 1000 }, + initialBackendQuantities: { messages: 1000 }, }); // Must NOT be 5,000,000 (5000 * 1000) or 5 (5000 / 1000) expect(result[0]?.quantity).toBe(5000); }); - test("should add included_usage to quantity", () => { + test("should pass inclusive quantities through unchanged", () => { const result = buildUpdateSubscriptionOptions({ prepaidItems: [{ feature_id: "messages", included_usage: 200 }], prepaidOptions: { messages: 5000 }, initialPrepaidOptions: { messages: 1000 }, + initialBackendQuantities: { messages: 800 }, }); - expect(result).toEqual([{ feature_id: "messages", quantity: 5200 }]); + expect(result).toEqual([{ feature_id: "messages", quantity: 5000 }]); + }); + + test("should skip unchanged inclusive quantities", () => { + const result = buildUpdateSubscriptionOptions({ + prepaidItems: [{ feature_id: "AI_CREDITS", included_usage: 250 }], + prepaidOptions: { AI_CREDITS: 750 }, + initialPrepaidOptions: { AI_CREDITS: 750 }, + initialBackendQuantities: { AI_CREDITS: 500 }, + }); + + expect(result).toEqual([]); + }); + + test("should serialize inclusive 750 as 750", () => { + const result = buildUpdateSubscriptionOptions({ + prepaidItems: [{ feature_id: "AI_CREDITS", included_usage: 250 }], + prepaidOptions: { AI_CREDITS: 750 }, + initialPrepaidOptions: { AI_CREDITS: 500 }, + initialBackendQuantities: { AI_CREDITS: 250 }, + }); + + expect(result).toEqual([{ feature_id: "AI_CREDITS", quantity: 750 }]); + }); + + test("should serialize inclusive 1000 as 1000", () => { + const result = buildUpdateSubscriptionOptions({ + prepaidItems: [{ feature_id: "AI_CREDITS", included_usage: 250 }], + prepaidOptions: { AI_CREDITS: 1000 }, + initialPrepaidOptions: { AI_CREDITS: 750 }, + initialBackendQuantities: { AI_CREDITS: 500 }, + }); + + expect(result).toEqual([{ feature_id: "AI_CREDITS", quantity: 1000 }]); }); test("should skip items where quantity has not changed", () => { @@ -39,6 +74,7 @@ describe("buildUpdateSubscriptionOptions — billing_units handling", () => { prepaidItems: [{ feature_id: "messages", included_usage: 0 }], prepaidOptions: { messages: 1000 }, initialPrepaidOptions: { messages: 1000 }, + initialBackendQuantities: { messages: 1000 }, }); expect(result).toEqual([]); @@ -52,63 +88,49 @@ describe("buildUpdateSubscriptionOptions — billing_units handling", () => { ], prepaidOptions: { messages: 10000, tokens: 2500 }, initialPrepaidOptions: { messages: 5000, tokens: 1000 }, + initialBackendQuantities: { messages: 5000, tokens: 900 }, }); expect(result).toEqual([ { feature_id: "messages", quantity: 10000 }, - { feature_id: "tokens", quantity: 2600 }, + { feature_id: "tokens", quantity: 2500 }, ]); }); - test("should include new prepaid items from items array that are not in prepaidItems", () => { + test("should include new prepaid items from the current plan", () => { const result = buildUpdateSubscriptionOptions({ - prepaidItems: [{ feature_id: "messages", included_usage: 0 }], + prepaidItems: [ + { feature_id: "messages", included_usage: 0 }, + { feature_id: "tokens", included_usage: 50 }, + ], prepaidOptions: { messages: 5000, tokens: 3000 }, initialPrepaidOptions: { messages: 1000 }, - items: [ - { - feature_id: "tokens", - usage_model: UsageModel.Prepaid, - included_usage: 50, - }, - ], + initialBackendQuantities: { messages: 1000 }, }); expect(result).toEqual([ { feature_id: "messages", quantity: 5000 }, - { feature_id: "tokens", quantity: 3050 }, + { feature_id: "tokens", quantity: 3000 }, ]); }); - test("should not duplicate items already in prepaidItems when also in items array", () => { + test("should resend the same total when included usage changes", () => { const result = buildUpdateSubscriptionOptions({ - prepaidItems: [{ feature_id: "messages", included_usage: 0 }], - prepaidOptions: { messages: 5000 }, - initialPrepaidOptions: { messages: 1000 }, - items: [ - { - feature_id: "messages", - usage_model: UsageModel.Prepaid, - included_usage: 0, - }, - ], + prepaidItems: [{ feature_id: "AI_CREDITS", included_usage: 500 }], + prepaidOptions: { AI_CREDITS: 750 }, + initialPrepaidOptions: { AI_CREDITS: 750 }, + initialBackendQuantities: { AI_CREDITS: 500 }, }); - expect(result).toEqual([{ feature_id: "messages", quantity: 5000 }]); + expect(result).toEqual([{ feature_id: "AI_CREDITS", quantity: 750 }]); }); - test("should skip non-prepaid items from items array", () => { + test("should ignore prepaid options for removed current items", () => { const result = buildUpdateSubscriptionOptions({ prepaidItems: [], prepaidOptions: { storage: 100 }, initialPrepaidOptions: {}, - items: [ - { - feature_id: "storage", - usage_model: UsageModel.PayPerUse, - included_usage: 0, - }, - ], + initialBackendQuantities: { storage: 100 }, }); expect(result).toEqual([]); @@ -122,6 +144,7 @@ describe("buildUpdateSubscriptionOptions — billing_units handling", () => { ], prepaidOptions: { messages: 1000, tokens: 500 }, initialPrepaidOptions: { messages: 1000, tokens: 500 }, + initialBackendQuantities: { messages: 1000, tokens: 500 }, }); expect(result).toEqual([]); @@ -132,12 +155,24 @@ describe("buildUpdateSubscriptionOptions — billing_units handling", () => { prepaidItems: [{ feature_id: "messages", included_usage: "inf" }], prepaidOptions: { messages: 5000 }, initialPrepaidOptions: { messages: 1000 }, + initialBackendQuantities: { messages: 1000 }, }); // typeof "inf" !== "number", so includedUsage defaults to 0 expect(result).toEqual([{ feature_id: "messages", quantity: 5000 }]); }); + test("should clamp totals below the current included usage", () => { + const result = buildUpdateSubscriptionOptions({ + prepaidItems: [{ feature_id: "messages", included_usage: 200 }], + prepaidOptions: { messages: 150 }, + initialPrepaidOptions: { messages: 0 }, + initialBackendQuantities: { messages: 0 }, + }); + + expect(result).toEqual([{ feature_id: "messages", quantity: 200 }]); + }); + test("should use feature.internal_id as fallback when feature_id is null", () => { const result = buildUpdateSubscriptionOptions({ prepaidItems: [ @@ -149,6 +184,7 @@ describe("buildUpdateSubscriptionOptions — billing_units handling", () => { ], prepaidOptions: { int_messages: 3000 }, initialPrepaidOptions: { int_messages: 1000 }, + initialBackendQuantities: { int_messages: 1000 }, }); expect(result).toEqual([{ feature_id: "int_messages", quantity: 3000 }]); diff --git a/vite/tests/components/forms/update-subscription/build-legacy-update-subscription-options.test.ts b/vite/tests/components/forms/update-subscription/build-legacy-update-subscription-options.test.ts new file mode 100644 index 000000000..2f3cbf090 --- /dev/null +++ b/vite/tests/components/forms/update-subscription/build-legacy-update-subscription-options.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test"; +import { buildLegacyUpdateSubscriptionOptions } from "@/components/forms/update-subscription/use-update-subscription-body-builder"; + +describe("buildLegacyUpdateSubscriptionOptions", () => { + test("should pass displayed prepaid quantities through unchanged", () => { + const result = buildLegacyUpdateSubscriptionOptions({ + prepaidOptions: { AI_CREDITS: 750 }, + }); + + expect(result).toEqual([{ feature_id: "AI_CREDITS", quantity: 750 }]); + }); + + test("should return an empty array when prepaid options are missing", () => { + const result = buildLegacyUpdateSubscriptionOptions({}); + + expect(result).toEqual([]); + }); +}); diff --git a/vite/tests/utils/billing/prepaid-quantity-utils.test.ts b/vite/tests/utils/billing/prepaid-quantity-utils.test.ts index cfdec9159..8c493da3c 100644 --- a/vite/tests/utils/billing/prepaid-quantity-utils.test.ts +++ b/vite/tests/utils/billing/prepaid-quantity-utils.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { + AppEnv, getPrepaidDisplayQuantity, type ProductV2, UsageModel, @@ -17,7 +18,7 @@ function makeProduct({ items }: { items: ProductV2["items"] }): ProductV2 { is_default: false, version: 1, group: null, - env: "sandbox" as any, + env: AppEnv.Sandbox, items, created_at: Date.now(), }; @@ -48,6 +49,26 @@ describe("backendToDisplayQuantity", () => { expect(result).toEqual({ messages: 10000, tokens: 2500 }); }); + test("should add included_usage on top of the purchased quantity", () => { + const result = backendToDisplayQuantity({ + backendOptions: [{ feature_id: "credits", quantity: 500 }], + prepaidItems: [{ feature_id: "credits", included_usage: 250 }], + }); + + expect(result).toEqual({ credits: 750 }); + }); + + test("should add included_usage after expanding billing_units", () => { + const result = backendToDisplayQuantity({ + backendOptions: [{ feature_id: "credits", quantity: 1 }], + prepaidItems: [ + { feature_id: "credits", billing_units: 500, included_usage: 250 }, + ], + }); + + expect(result).toEqual({ credits: 750 }); + }); + test("should default to billing_units=1 when nullish", () => { const result = backendToDisplayQuantity({ backendOptions: [{ feature_id: "messages", quantity: 5 }],