chore: finalise attach billing units fixes
This commit is contained in:
@@ -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: [],
|
||||
});
|
||||
});
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
)
|
||||
|
||||
65
vite/src/utils/billing/prepaidQuantityUtils.ts
Normal file
65
vite/src/utils/billing/prepaidQuantityUtils.ts
Normal 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;
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user