chore: finalise attach billing units fixes

This commit is contained in:
Charlie Lamb
2026-02-23 19:10:47 +00:00
parent 8955c245a2
commit d9c588acf2
12 changed files with 172 additions and 144 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, number>;
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,

View File

@@ -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<string, number>,
);
return prepaidItems.reduce(
(accumulator, item) => {
const featureId = item.feature_id as string;
accumulator[featureId] = subscriptionValues[featureId] ?? 0;
return accumulator;
},
{} as Record<string, number>,
);
}, [customerProduct.options, prepaidItems]);
const initialPrepaidOptions = useMemo(
() =>
backendToDisplayQuantity({
backendOptions: customerProduct.options,
prepaidItems,
}),
[customerProduct.options, prepaidItems],
);
const isTrialing = isCustomerProductTrialing(customerProduct);
const remainingTrialDays = isTrialing

View File

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

View File

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

View File

@@ -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<string, number> {
const backendLookup = backendOptions.reduce(
(acc, option) => {
acc[option.feature_id] = option.quantity;
return acc;
},
{} as Record<string, number>,
);
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<string, number>,
);
}
/**
* 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<string, number>;
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;
}

View File

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

View File

@@ -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<string, number>,
),
[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<string, number | undefined>,
) as Record<string, number>;
}, [prepaidItems, subscriptionPrepaidValues]);
const form = useAttachProductForm({
initialProductId: cusProduct?.product.id ?? undefined,
initialPrepaidOptions,