diff --git a/server/tests/scenarios/attach/billing-units-1000-scenario.test.ts b/server/tests/scenarios/attach/billing-units-1000-scenario.test.ts new file mode 100644 index 000000000..f2645970c --- /dev/null +++ b/server/tests/scenarios/attach/billing-units-1000-scenario.test.ts @@ -0,0 +1,32 @@ +import { test } from "bun:test"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +/** + * Billing Units 1000 Scenario + * + * Sets up a pro product ($20/mo) with prepaid messages at 1000 billing units, + * and a customer with a payment method ready for attachment. + */ + +test(`${chalk.yellowBright("billing-units-1000: product with 1000 billing units + customer")}`, async () => { + const customerId = "billing-units-1000"; + + const prepaidMessagesItem = items.prepaidMessages({ + billingUnits: 1000, + price: 10, + }); + + const pro = products.pro({ items: [prepaidMessagesItem] }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); +}); diff --git a/shared/utils/productV2Utils/compareProductUtils/generatePrepaidChanges.ts b/shared/utils/productV2Utils/compareProductUtils/generatePrepaidChanges.ts index f5cea64e4..dcba43c9d 100644 --- a/shared/utils/productV2Utils/compareProductUtils/generatePrepaidChanges.ts +++ b/shared/utils/productV2Utils/compareProductUtils/generatePrepaidChanges.ts @@ -1,5 +1,6 @@ import type { ProductItem } from "../../../models/productV2Models/productItemModels/productItemModels.js"; import { formatAmount } from "../../common/formatUtils/formatAmount.js"; +import { getPrepaidDisplayQuantity } from "../productItemUtils/productItemUtils.js"; import type { ItemEdit } from "./itemEditTypes.js"; /** Generates edit items for prepaid quantity changes */ @@ -22,9 +23,14 @@ export function generatePrepaidChanges({ if (oldQuantity === newQuantity) return null; - const billingUnits = item.billing_units ?? 1; - const oldDisplayQuantity = oldQuantity * billingUnits; - const newDisplayQuantity = newQuantity * billingUnits; + const oldDisplayQuantity = getPrepaidDisplayQuantity({ + quantity: oldQuantity, + billingUnits: item.billing_units, + }); + const newDisplayQuantity = getPrepaidDisplayQuantity({ + quantity: newQuantity, + billingUnits: item.billing_units, + }); const unitPrice = item.price ?? null; const costDelta = diff --git a/shared/utils/productV2Utils/productItemUtils/getProductItemRes.ts b/shared/utils/productV2Utils/productItemUtils/getProductItemRes.ts index fce6affea..e976578c7 100644 --- a/shared/utils/productV2Utils/productItemUtils/getProductItemRes.ts +++ b/shared/utils/productV2Utils/productItemUtils/getProductItemRes.ts @@ -11,6 +11,7 @@ import { getProductItemDisplay } from "@utils/productDisplayUtils.js"; import { notNullish } from "@utils/utils.js"; import { Decimal } from "decimal.js"; import { getItemType } from "./getItemType.js"; +import { getPrepaidDisplayQuantity } from "./productItemUtils.js"; /** * @deprecated Use `applyProration` from `@autumn/shared` instead. @@ -131,11 +132,17 @@ export const getProductItemResponse = ({ if (item.usage_model === UsageModel.Prepaid && notNullish(options)) { const option = options!.find((o) => o.feature_id === item.feature_id); quantity = option?.quantity - ? option?.quantity * (item.billing_units ?? 1) + ? getPrepaidDisplayQuantity({ + quantity: option.quantity, + billingUnits: item.billing_units, + }) : undefined; upcomingQuantity = option?.upcoming_quantity - ? option?.upcoming_quantity * (item.billing_units ?? 1) + ? getPrepaidDisplayQuantity({ + quantity: option.upcoming_quantity, + billingUnits: item.billing_units, + }) : undefined; } diff --git a/shared/utils/productV2Utils/productItemUtils/productItemUtils.ts b/shared/utils/productV2Utils/productItemUtils/productItemUtils.ts index 9dff796ea..b8f851d8f 100644 --- a/shared/utils/productV2Utils/productItemUtils/productItemUtils.ts +++ b/shared/utils/productV2Utils/productItemUtils/productItemUtils.ts @@ -37,6 +37,17 @@ export const getItemFeatureType = ({ return undefined; }; +/** Converts a prepaid item's stored quantity (in packs) to total units for display. */ +export const getPrepaidDisplayQuantity = ({ + quantity, + billingUnits, +}: { + quantity: number; + billingUnits?: number | null; +}): number => { + return quantity * (billingUnits ?? 1); +}; + export const getResetUsage = ({ item, feature, diff --git a/vite/src/components/forms/attach-product/use-attach-body-builder.ts b/vite/src/components/forms/attach-product/use-attach-body-builder.ts index 7a60939ee..2ffa441c9 100644 --- a/vite/src/components/forms/attach-product/use-attach-body-builder.ts +++ b/vite/src/components/forms/attach-product/use-attach-body-builder.ts @@ -1,10 +1,10 @@ -import { AppEnv, type ProductV2, UsageModel } from "@autumn/shared"; -import Decimal from "decimal.js"; +import { AppEnv, type ProductV2 } from "@autumn/shared"; import { useMemo } from "react"; import { useOrg } from "@/hooks/common/useOrg"; import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; import { useHasChanges, useProductStore } from "@/hooks/stores/useProductStore"; import { useEntity } from "@/hooks/stores/useSubscriptionStore"; +import { convertPrepaidOptionsToFeatureOptions } from "@/utils/billing/prepaidQuantityUtils"; import { useEnv } from "@/utils/envUtils"; import { getRedirectUrl } from "@/utils/genUtils"; import { getAttachBody } from "@/views/customers/customer/product/components/attachProductUtils"; @@ -61,37 +61,18 @@ export function useAttachBodyBuilder(params: AttachBodyBuilderParams = {}) { // 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, - ); - - if (!prepaidItem) { - return { - feature_id: featureId, - quantity: quantity, - }; - } - - return { - feature_id: featureId, - quantity: new Decimal(quantity || 0) - .mul(prepaidItem.billing_units || 1) - .toNumber(), - }; - }, - ) - : []; + ? convertPrepaidOptionsToFeatureOptions({ + prepaidOptions: mergedParams.prepaidOptions, + product, + }) + : undefined; // Build the attach body return getAttachBody({ customerId: mergedParams.customerId, product, entityId: mergedParams.entityId ?? storeEntityId ?? undefined, - optionsInput: options.length > 0 ? options : undefined, + optionsInput: options, isCustom, version, useInvoice: mergedParams.useInvoice, diff --git a/vite/src/components/forms/attach-v2/hooks/useAttachRequestBody.ts b/vite/src/components/forms/attach-v2/hooks/useAttachRequestBody.ts index f4215abbc..5a8edc296 100644 --- a/vite/src/components/forms/attach-v2/hooks/useAttachRequestBody.ts +++ b/vite/src/components/forms/attach-v2/hooks/useAttachRequestBody.ts @@ -1,18 +1,16 @@ -import { - type AttachParamsV0, - type AttachParamsV0Input, - type BillingBehavior, - type FeatureOptions, - type FreeTrialDuration, - type PlanTiming, - type ProductItem, - type ProductItemInterval, - type ProductV2, - UsageModel, +import type { + AttachParamsV0, + AttachParamsV0Input, + BillingBehavior, + FreeTrialDuration, + PlanTiming, + ProductItem, + ProductItemInterval, + ProductV2, } from "@autumn/shared"; -import Decimal from "decimal.js"; import { useMemo } from "react"; import { getFreeTrial } from "@/components/forms/update-subscription-v2/utils/getFreeTrial"; +import { convertPrepaidOptionsToFeatureOptions } from "@/utils/billing/prepaidQuantityUtils"; import { normalizeAttachBillingBehavior } from "../utils/attachBillingBehaviorRules"; import { type FormDiscount, @@ -36,44 +34,6 @@ export interface BuildAttachRequestBodyParams { discounts: FormDiscount[]; } -function convertPrepaidOptionsToFeatureOptions({ - prepaidOptions, - product, -}: { - prepaidOptions: Record; - product: ProductV2 | undefined; -}): FeatureOptions[] | undefined { - if (!product || Object.keys(prepaidOptions).length === 0) { - return undefined; - } - - const options: FeatureOptions[] = []; - - for (const [featureId, quantity] of Object.entries(prepaidOptions)) { - const prepaidItem = product.items.find( - (item) => - item.feature_id === featureId && - item.usage_model === UsageModel.Prepaid, - ); - - if (prepaidItem) { - options.push({ - feature_id: featureId, - quantity: new Decimal(quantity || 0) - .mul(prepaidItem.billing_units || 1) - .toNumber(), - }); - } else { - options.push({ - feature_id: featureId, - quantity: quantity, - }); - } - } - - return options.length > 0 ? options : undefined; -} - /** Pure function to build the attach request body. Extracted for testability. */ export function buildAttachRequestBody({ customerId, diff --git a/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionForm.ts b/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionForm.ts index 363d470d2..8696335d2 100644 --- a/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionForm.ts +++ b/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionForm.ts @@ -5,6 +5,7 @@ import { } from "@autumn/shared"; import { useMemo } from "react"; import { useAppForm } from "@/hooks/form/form"; +import { backendToDisplayQuantity } from "@/utils/billing/prepaidQuantityUtils"; import type { UpdateSubscriptionFormContext } from "../context/UpdateSubscriptionFormProvider"; import { type UpdateSubscriptionForm, @@ -21,24 +22,14 @@ export function useUpdateSubscriptionForm({ const { customerProduct, prepaidItems, currentVersion, product } = updateSubscriptionFormContext; - const initialPrepaidOptions = useMemo(() => { - const subscriptionValues = customerProduct.options.reduce( - (accumulator, option) => { - accumulator[option.feature_id] = option.quantity; - return accumulator; - }, - {} as Record, - ); - - return prepaidItems.reduce( - (accumulator, item) => { - const featureId = item.feature_id as string; - accumulator[featureId] = subscriptionValues[featureId] ?? 0; - return accumulator; - }, - {} as Record, - ); - }, [customerProduct.options, prepaidItems]); + const initialPrepaidOptions = useMemo( + () => + backendToDisplayQuantity({ + backendOptions: customerProduct.options, + prepaidItems, + }), + [customerProduct.options, prepaidItems], + ); const isTrialing = isCustomerProductTrialing(customerProduct); const remainingTrialDays = isTrialing 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 76efe85a7..057cf2a2d 100644 --- a/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionRequestBody.ts +++ b/vite/src/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionRequestBody.ts @@ -64,7 +64,6 @@ export function useUpdateSubscriptionRequestBody({ const featureId = item.feature_id ?? item.feature?.internal_id ?? ""; const inputQuantity = prepaidOptions[featureId]; const initialQuantity = initialPrepaidOptions[featureId]; - const billingUnits = item.billing_units ?? 1; const includedUsage = typeof item.included_usage === "number" ? item.included_usage : 0; @@ -76,7 +75,7 @@ export function useUpdateSubscriptionRequestBody({ ) { return { feature_id: featureId, - quantity: inputQuantity * billingUnits + includedUsage, + quantity: inputQuantity + includedUsage, }; } return null; @@ -93,14 +92,13 @@ export function useUpdateSubscriptionRequestBody({ !existingFeatureIds.has(item.feature_id) ) { const inputQuantity = prepaidOptions[item.feature_id]; - const billingUnits = item.billing_units ?? 1; 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 * billingUnits + includedUsage, + quantity: inputQuantity + includedUsage, }); } } 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 27cf98f33..db6763dcd 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 @@ -6,7 +6,6 @@ import { type ProductV2, UsageModel, } from "@autumn/shared"; -import { Decimal } from "decimal.js"; import { useMemo } from "react"; import type { CancelActionValue, @@ -89,24 +88,14 @@ export function useUpdateSubscriptionBodyBuilder( item.usage_model === UsageModel.Prepaid, ); - if (!prepaidItem) { - return { - feature_id: featureId, - quantity: quantity, - }; - } - const includedUsage = - typeof prepaidItem.included_usage === "number" + prepaidItem && typeof prepaidItem.included_usage === "number" ? prepaidItem.included_usage : 0; return { feature_id: featureId, - quantity: new Decimal(quantity || 0) - .mul(prepaidItem.billing_units || 1) - .add(includedUsage) - .toNumber(), + quantity: (quantity || 0) + includedUsage, }; }, ) diff --git a/vite/src/utils/billing/prepaidQuantityUtils.ts b/vite/src/utils/billing/prepaidQuantityUtils.ts new file mode 100644 index 000000000..d1ee6f8c6 --- /dev/null +++ b/vite/src/utils/billing/prepaidQuantityUtils.ts @@ -0,0 +1,65 @@ +import { + type FeatureOptions, + getPrepaidDisplayQuantity, + type ProductV2, +} from "@autumn/shared"; + +/** + * Bulk-converts backend option quantities to display quantities for form initialization. + * e.g. backend quantity=1 with billing_units=1000 → display quantity=1000 + */ +export function backendToDisplayQuantity({ + backendOptions, + prepaidItems, +}: { + backendOptions: { feature_id: string; quantity: number }[]; + prepaidItems: { feature_id?: string | null; billing_units?: number | null }[]; +}): Record { + const backendLookup = backendOptions.reduce( + (acc, option) => { + acc[option.feature_id] = option.quantity; + return acc; + }, + {} as Record, + ); + + return prepaidItems.reduce( + (acc, item) => { + const featureId = item.feature_id as string; + const backendQuantity = backendLookup[featureId] ?? 0; + acc[featureId] = getPrepaidDisplayQuantity({ + quantity: backendQuantity, + billingUnits: item.billing_units, + }); + return acc; + }, + {} as Record, + ); +} + +/** + * Converts a prepaid options record into a FeatureOptions array. + * Quantities are passed through as-is (inclusive of billing units). + */ +export function convertPrepaidOptionsToFeatureOptions({ + prepaidOptions, + product, +}: { + prepaidOptions: Record; + product: ProductV2 | undefined; +}): FeatureOptions[] | undefined { + if (!product || Object.keys(prepaidOptions).length === 0) { + return undefined; + } + + const options: FeatureOptions[] = []; + + for (const [featureId, quantity] of Object.entries(prepaidOptions)) { + options.push({ + feature_id: featureId, + quantity: quantity || 0, + }); + } + + return options.length > 0 ? options : undefined; +} diff --git a/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx b/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx index 18f37fcaf..5fad54902 100644 --- a/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx +++ b/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx @@ -2,6 +2,7 @@ import { CusProductStatus, type Entity, featureToOptions, + getPrepaidDisplayQuantity, isCustomerProductTrialing, isOneOffProductV2, type ProductItem, @@ -36,6 +37,7 @@ import { } from "@/hooks/stores/useProductStore"; import { useSheetStore } from "@/hooks/stores/useSheetStore"; import { useSubscriptionById } from "@/hooks/stores/useSubscriptionStore"; + import { useEnv } from "@/utils/envUtils"; import { pushPage } from "@/utils/genUtils"; import { getStripeSubLink } from "@/utils/linkUtils"; @@ -197,10 +199,13 @@ export function SubscriptionDetailSheet() { options: cusProduct.options, }); - // const prepaidQuantity = prepaidOption ? prepaidOption.quantity / (item.billing_units || 1) : null; const prepaidQuantity = - item.usage_model === UsageModel.Prepaid - ? prepaidOption?.quantity + item.usage_model === UsageModel.Prepaid && + prepaidOption?.quantity + ? getPrepaidDisplayQuantity({ + quantity: prepaidOption.quantity, + billingUnits: item.billing_units, + }) : null; return ( diff --git a/vite/src/views/customers2/components/sheets/SubscriptionUpdateSheet.tsx b/vite/src/views/customers2/components/sheets/SubscriptionUpdateSheet.tsx index 5ee3df27d..7db3b496c 100644 --- a/vite/src/views/customers2/components/sheets/SubscriptionUpdateSheet.tsx +++ b/vite/src/views/customers2/components/sheets/SubscriptionUpdateSheet.tsx @@ -19,6 +19,7 @@ import { SheetHeader } from "@/components/v2/sheets/InlineSheet"; import { usePrepaidItems } from "@/hooks/stores/useProductStore"; import { useSheetStore } from "@/hooks/stores/useSheetStore"; import { useSubscriptionById } from "@/hooks/stores/useSubscriptionStore"; +import { backendToDisplayQuantity } from "@/utils/billing/prepaidQuantityUtils"; import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; const FormContent = ({ @@ -117,33 +118,15 @@ function SheetContent({ : (productV2 ?? undefined); const { prepaidItems } = usePrepaidItems({ product }); - const subscriptionPrepaidValues = useMemo( + const initialPrepaidOptions = useMemo( () => - cusProduct.options.reduce( - (acc, option) => { - acc[option.feature_id] = option.quantity; - return acc; - }, - {} as Record, - ), - [cusProduct.options], + backendToDisplayQuantity({ + backendOptions: cusProduct.options, + prepaidItems, + }), + [cusProduct.options, prepaidItems], ); - const initialPrepaidOptions = useMemo(() => { - if (prepaidItems.length === 0) { - return {}; - } - - return prepaidItems.reduce( - (acc, item) => { - const featureId = item.feature_id as string; - acc[featureId] = subscriptionPrepaidValues[featureId] ?? undefined; - return acc; - }, - {} as Record, - ) as Record; - }, [prepaidItems, subscriptionPrepaidValues]); - const form = useAttachProductForm({ initialProductId: cusProduct?.product.id ?? undefined, initialPrepaidOptions,