chore: fix update sub volume based bug
This commit is contained in:
@@ -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<string, number>;
|
||||
initialPrepaidOptions: Record<string, number>;
|
||||
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]
|
||||
|
||||
@@ -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) => (
|
||||
<field.QuantityField
|
||||
label=""
|
||||
min={0}
|
||||
min={minPrepaidQuantity}
|
||||
step={billingUnitStep}
|
||||
hideFieldInfo
|
||||
/>
|
||||
@@ -345,7 +347,7 @@ export function SubscriptionItemRow({
|
||||
);
|
||||
const prepaidTooltipContent = (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p>Quantity is exclusive of included usage.</p>
|
||||
<p>Quantity includes included usage.</p>
|
||||
{shouldShowRoundingHint && (
|
||||
<p>
|
||||
Rounded up to {roundedQuantity} to match {normalizedBillingUnits}-unit
|
||||
|
||||
@@ -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<string, number> = {};
|
||||
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<string, number> = {};
|
||||
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,
|
||||
|
||||
@@ -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<string, number>;
|
||||
initialPrepaidOptions: Record<string, number>;
|
||||
items?: ProductItem[] | null;
|
||||
initialBackendQuantities: Record<string, number>;
|
||||
}): { 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<string, number>,
|
||||
);
|
||||
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 };
|
||||
|
||||
@@ -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<string, number>;
|
||||
}): FeatureOptions[] {
|
||||
if (!prepaidOptions) return [];
|
||||
|
||||
return Object.entries(prepaidOptions).map(([featureId, quantity]) => ({
|
||||
feature_id: featureId,
|
||||
quantity: quantity || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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<string, number> {
|
||||
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<string, number>,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }]);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 }],
|
||||
|
||||
Reference in New Issue
Block a user