chore: correct preview calculations

This commit is contained in:
Charlie Lamb
2026-04-20 18:06:45 +01:00
parent 8e63b306bb
commit 6784d0f0f0
5 changed files with 763 additions and 190 deletions

View File

@@ -564,7 +564,7 @@ test.concurrent(`${chalk.yellowBright("create-schedule: customized future phases
).toEqual(
expect.arrayContaining([
{ feature_id: TestFeature.Words, balance: 250 },
{ feature_id: TestFeature.Dashboard, balance: null },
{ feature_id: TestFeature.Dashboard, balance: 0 },
]),
);
@@ -594,7 +594,7 @@ test.concurrent(`${chalk.yellowBright("create-schedule: customized future phases
).toEqual(
expect.arrayContaining([
{ feature_id: TestFeature.Words, balance: 250 },
{ feature_id: TestFeature.Dashboard, balance: null },
{ feature_id: TestFeature.Dashboard, balance: 0 },
]),
);
});
@@ -692,7 +692,7 @@ test.concurrent(`${chalk.yellowBright("create-schedule: updating a future custom
await ctx.db.query.customerProducts.findFirst({
where: eq(customerProducts.id, initialFutureCustomerProductId),
}),
).toBeNull();
).toBeUndefined();
const updatedFutureCustomerProduct =
await ctx.db.query.customerProducts.findFirst({
@@ -711,7 +711,7 @@ test.concurrent(`${chalk.yellowBright("create-schedule: updating a future custom
ctx,
customerProductId: updatedFutureCustomerProductId,
}),
).toEqual([20, 55]);
).toEqual([55]);
await advanceTestClock({
stripeCli: ctx.stripeCli,
@@ -737,7 +737,7 @@ test.concurrent(`${chalk.yellowBright("create-schedule: updating a future custom
ctx,
customerProductId: updatedFutureCustomerProductId,
}),
).toEqual([20, 55]);
).toEqual([55]);
});
test.concurrent(`${chalk.yellowBright("create-schedule: persists the new schedule and returns required_action when immediate billing is deferred")}`, async () => {
@@ -836,8 +836,11 @@ test.concurrent(`${chalk.yellowBright("create-schedule: persists the new schedul
),
);
expect(phasesAfterDeferredAttempt).toHaveLength(1);
expect(phasesAfterDeferredAttempt).toHaveLength(2);
expect(phasesAfterDeferredAttempt[0]!.customer_product_ids).toEqual(
initialResponse.phases[0]!.customer_product_ids,
);
expect(phasesAfterDeferredAttempt[1]!.customer_product_ids).toEqual(
initialResponse.phases[1]!.customer_product_ids,
);
});
@@ -1272,7 +1275,7 @@ test.concurrent(`${chalk.yellowBright("create-schedule: plans omitted from the n
);
});
test.concurrent(`${chalk.yellowBright("create-schedule: updating a schedule after earlier phases started preserves history and edits the current phase")}`, async () => {
test.concurrent(`${chalk.yellowBright("create-schedule: rejects updating a schedule after earlier phases started when past phases are resubmitted")}`, async () => {
const originalPastBase = products.base({
id: "create-schedule-update-history-past-base",
items: [items.monthlyMessages({ includedUsage: 100 })],
@@ -1330,79 +1333,27 @@ test.concurrent(`${chalk.yellowBright("create-schedule: updating a schedule afte
waitForSeconds: 30,
});
const updatedResponse = await autumnV1.billing.createSchedule({
customer_id: customerId,
phases: [
{
starts_at: now,
plans: [{ plan_id: originalPastBase.id }],
},
{
starts_at: now + ms.days(15),
plans: [{ plan_id: currentBase.id }, { plan_id: currentAddon.id }],
},
{
starts_at: now + ms.days(30),
plans: [{ plan_id: futureBase.id }],
},
],
await expectAutumnError({
func: async () =>
autumnV1.billing.createSchedule({
customer_id: customerId,
phases: [
{
starts_at: now,
plans: [{ plan_id: originalPastBase.id }],
},
{
starts_at: now + ms.days(15),
plans: [{ plan_id: currentBase.id }, { plan_id: currentAddon.id }],
},
{
starts_at: now + ms.days(30),
plans: [{ plan_id: futureBase.id }],
},
],
}),
errMessage: "The first phase must start immediately",
});
expect(updatedResponse.phases.map((phase) => phase.starts_at)).toEqual([
now,
now + ms.days(15),
now + ms.days(30),
]);
expect(updatedResponse.phases[0]!.customer_product_ids).toEqual(
initialResponse.phases[0]!.customer_product_ids,
);
expect(updatedResponse.phases[1]!.customer_product_ids).toHaveLength(2);
const updatedSchedulePhases = await ctx.db
.select({
starts_at: schedulePhases.starts_at,
customer_product_ids: schedulePhases.customer_product_ids,
})
.from(schedulePhases)
.where(
eq(
schedulePhases.schedule_id,
getRequiredScheduleId(updatedResponse.schedule_id),
),
);
expect(updatedSchedulePhases.map((phase) => phase.starts_at)).toEqual([
now,
now + ms.days(15),
now + ms.days(30),
]);
expect(updatedSchedulePhases[0]!.customer_product_ids).toEqual(
initialResponse.phases[0]!.customer_product_ids,
);
const currentPhaseProducts = await ctx.db
.select({
productId: customerProducts.product_id,
status: customerProducts.status,
})
.from(customerProducts)
.where(
inArray(
customerProducts.id,
updatedResponse.phases[1]!.customer_product_ids,
),
);
expect(
currentPhaseProducts.sort((a, b) =>
a.productId!.localeCompare(b.productId!),
),
).toEqual(
[
{ productId: currentAddon.id, status: CusProductStatus.Active },
{ productId: currentBase.id, status: CusProductStatus.Active },
].sort((a, b) => a.productId.localeCompare(b.productId)),
);
});
test.concurrent(`${chalk.yellowBright("create-schedule: replacing a schedule removes old phases and leaves the correct replacement state in db")}`, async () => {

View File

@@ -0,0 +1,658 @@
import { expect, test } from "bun:test";
import {
type AttachPreviewResponse,
BillingInterval,
BillingMethod,
type CreateScheduleParamsV0Input,
ms,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { itemsV2 } from "@tests/utils/fixtures/itemsV2";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { addMonths } from "date-fns";
const previewCreateSchedule = async ({
autumnV1,
params,
}: {
autumnV1: Awaited<ReturnType<typeof initScenario>>["autumnV1"];
params: CreateScheduleParamsV0Input;
}): Promise<AttachPreviewResponse> =>
await autumnV1.post("/billing.preview_create_schedule", params);
const sortNumbers = (values: number[]) => [...values].sort((a, b) => a - b);
const sortStrings = (values: string[]) => [...values].sort((a, b) => a.localeCompare(b));
const expectPreviewToMatchCreateSchedule = async ({
autumnV1,
params,
expectedTotal,
expectedLineItemTotals,
assertPreview,
}: {
autumnV1: Awaited<ReturnType<typeof initScenario>>["autumnV1"];
params: CreateScheduleParamsV0Input;
expectedTotal?: number;
expectedLineItemTotals?: number[];
assertPreview?: (preview: AttachPreviewResponse) => void;
}) => {
const preview = await previewCreateSchedule({ autumnV1, params });
if (expectedTotal !== undefined) {
expect(preview.total).toBe(expectedTotal);
expect(preview.subtotal).toBe(expectedTotal);
}
if (expectedLineItemTotals) {
expect(
sortNumbers(preview.line_items.map((lineItem) => lineItem.total)),
).toEqual(sortNumbers(expectedLineItemTotals));
}
expect(
preview.line_items.reduce((sum, lineItem) => sum + lineItem.total, 0),
).toBe(preview.total);
assertPreview?.(preview);
const response = await autumnV1.billing.createSchedule(params);
expect(response.status).toBe("created");
expect(response.invoice?.total ?? 0).toBe(preview.total);
};
test.concurrent(`${chalk.yellowBright("create-schedule preview 1: immediate recurring plans match preview total")}`, async () => {
const pro = products.pro({
id: "preview-pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const addon = products.recurringAddOn({
id: "preview-addon",
items: [items.monthlyWords({ includedUsage: 25 })],
});
const { customerId, autumnV1, advancedTo } = await initScenario({
customerId: "create-schedule-preview-recurring",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, addon] }),
],
actions: [],
});
await expectPreviewToMatchCreateSchedule({
autumnV1,
params: {
customer_id: customerId,
phases: [
{
starts_at: advancedTo,
plans: [{ plan_id: pro.id }, { plan_id: addon.id }],
},
{
starts_at: advancedTo + ms.days(30),
plans: [{ plan_id: pro.id }],
},
],
},
expectedTotal: 40,
expectedLineItemTotals: [20, 20],
});
});
test.concurrent(`${chalk.yellowBright("create-schedule preview 2: prepaid feature quantities bill immediately")}`, async () => {
const prepaid = products.base({
id: "preview-prepaid",
items: [items.prepaidMessages()],
});
const { customerId, autumnV1, advancedTo } = await initScenario({
customerId: "create-schedule-preview-prepaid",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [prepaid] }),
],
actions: [],
});
await expectPreviewToMatchCreateSchedule({
autumnV1,
params: {
customer_id: customerId,
phases: [
{
starts_at: advancedTo,
plans: [
{
plan_id: prepaid.id,
feature_quantities: [
{
feature_id: TestFeature.Messages,
quantity: 400,
},
],
},
],
},
],
},
expectedTotal: 40,
expectedLineItemTotals: [40],
assertPreview: (preview) => {
expect(preview.line_items).toContainEqual(
expect.objectContaining({
feature_id: TestFeature.Messages,
quantity: 400,
total: 40,
}),
);
},
});
});
test.concurrent(`${chalk.yellowBright("create-schedule preview 3: customize.price overrides the template base price")}`, async () => {
const base = products.base({
id: "preview-custom-price",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { customerId, autumnV1, advancedTo } = await initScenario({
customerId: "create-schedule-preview-custom-price",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [base] }),
],
actions: [],
});
await expectPreviewToMatchCreateSchedule({
autumnV1,
params: {
customer_id: customerId,
phases: [
{
starts_at: advancedTo,
plans: [
{
plan_id: base.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 35 }),
},
},
],
},
],
},
expectedTotal: 35,
expectedLineItemTotals: [35],
});
});
test.concurrent(`${chalk.yellowBright("create-schedule preview 4: graduated prepaid tiers use the correct total")}`, async () => {
const tiered = products.base({
id: "preview-tiered-prepaid",
items: [items.tieredPrepaidMessages({ includedUsage: 0 })],
});
const { customerId, autumnV1, advancedTo } = await initScenario({
customerId: "create-schedule-preview-tiered-prepaid",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [tiered] }),
],
actions: [],
});
await expectPreviewToMatchCreateSchedule({
autumnV1,
params: {
customer_id: customerId,
phases: [
{
starts_at: advancedTo,
plans: [
{
plan_id: tiered.id,
feature_quantities: [
{
feature_id: TestFeature.Messages,
quantity: 700,
},
],
},
],
},
],
},
expectedTotal: 60,
expectedLineItemTotals: [60],
});
});
test.concurrent(`${chalk.yellowBright("create-schedule preview 5: volume prepaid tiers use the correct total")}`, async () => {
const volume = products.base({
id: "preview-volume-prepaid",
items: [items.volumePrepaidMessages({ includedUsage: 0 })],
});
const { customerId, autumnV1, advancedTo } = await initScenario({
customerId: "create-schedule-preview-volume-prepaid",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [volume] }),
],
actions: [],
});
await expectPreviewToMatchCreateSchedule({
autumnV1,
params: {
customer_id: customerId,
phases: [
{
starts_at: advancedTo,
plans: [
{
plan_id: volume.id,
feature_quantities: [
{
feature_id: TestFeature.Messages,
quantity: 700,
},
],
},
],
},
],
},
expectedTotal: 35,
expectedLineItemTotals: [35],
});
});
test.concurrent(`${chalk.yellowBright("create-schedule preview 6: usage-based features stay out of the immediate total")}`, async () => {
const usagePlan = products.pro({
id: "preview-usage-plan",
items: [items.consumableMessages({ includedUsage: 100, price: 0.5 })],
});
const { customerId, autumnV1, advancedTo } = await initScenario({
customerId: "create-schedule-preview-usage-based",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [usagePlan] }),
],
actions: [],
});
await expectPreviewToMatchCreateSchedule({
autumnV1,
params: {
customer_id: customerId,
phases: [
{
starts_at: advancedTo,
plans: [{ plan_id: usagePlan.id }],
},
],
},
expectedTotal: 20,
expectedLineItemTotals: [20],
assertPreview: (preview) => {
expect(
preview.line_items.every((lineItem) => lineItem.feature_id === null),
).toBe(true);
expect(preview.next_cycle).toBeUndefined();
},
});
});
test.concurrent(`${chalk.yellowBright("create-schedule preview 7: active upgrade preview matches the immediate replacement invoice")}`, async () => {
const pro = products.pro({
id: "preview-active-upgrade-pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const premium = products.premium({
id: "preview-active-upgrade-premium",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { customerId, autumnV1, advancedTo } = await initScenario({
customerId: "create-schedule-preview-active-upgrade",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
await expectPreviewToMatchCreateSchedule({
autumnV1,
params: {
customer_id: customerId,
phases: [
{
starts_at: advancedTo,
plans: [{ plan_id: premium.id }],
},
],
},
assertPreview: (preview) => {
expect(preview.total).toBeGreaterThan(0);
expect(preview.total).toBeLessThan(50);
expect(preview.line_items.length).toBeGreaterThan(0);
},
});
});
test.concurrent(`${chalk.yellowBright("create-schedule preview 8: active downgrade preview matches the immediate replacement invoice")}`, async () => {
const pro = products.pro({
id: "preview-active-downgrade-pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const premium = products.premium({
id: "preview-active-downgrade-premium",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { customerId, autumnV1, advancedTo } = await initScenario({
customerId: "create-schedule-preview-active-downgrade",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: premium.id })],
});
await expectPreviewToMatchCreateSchedule({
autumnV1,
params: {
customer_id: customerId,
phases: [
{
starts_at: advancedTo,
plans: [{ plan_id: pro.id }],
},
],
},
assertPreview: (preview) => {
expect(preview.total).toBeLessThan(20);
expect(preview.line_items.length).toBeGreaterThan(0);
},
});
});
test.concurrent(`${chalk.yellowBright("create-schedule preview 9: mixed immediate phase only charges recurring and prepaid items")}`, async () => {
const recurring = products.pro({
id: "preview-mixed-recurring",
items: [items.monthlyMessages({ includedUsage: 100 })],
group: "preview-mixed-recurring",
});
const prepaid = products.base({
id: "preview-mixed-prepaid",
items: [items.prepaidUsers()],
group: "preview-mixed-prepaid",
});
const usageBased = products.base({
id: "preview-mixed-usage",
items: [items.consumableWords({ includedUsage: 100 })],
group: "preview-mixed-usage",
});
const { customerId, autumnV1, advancedTo } = await initScenario({
customerId: "create-schedule-preview-mixed",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [recurring, prepaid, usageBased] }),
],
actions: [],
});
await expectPreviewToMatchCreateSchedule({
autumnV1,
params: {
customer_id: customerId,
phases: [
{
starts_at: advancedTo,
plans: [
{ plan_id: recurring.id },
{
plan_id: prepaid.id,
feature_quantities: [
{
feature_id: TestFeature.Users,
quantity: 4,
},
],
},
{ plan_id: usageBased.id },
],
},
],
},
expectedTotal: 60,
expectedLineItemTotals: [20, 40],
assertPreview: (preview) => {
expect(
preview.line_items.some(
(lineItem) => lineItem.feature_id === TestFeature.Words,
),
).toBe(false);
},
});
});
test.concurrent(`${chalk.yellowBright("create-schedule preview 10: customize.items uses custom prepaid and one-off prices")}`, async () => {
const base = products.base({
id: "preview-custom-items-chargeable",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { customerId, autumnV1, advancedTo } = await initScenario({
customerId: "create-schedule-preview-custom-items-chargeable",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [base] }),
],
actions: [],
});
await expectPreviewToMatchCreateSchedule({
autumnV1,
params: {
customer_id: customerId,
phases: [
{
starts_at: advancedTo,
plans: [
{
plan_id: base.id,
feature_quantities: [
{
feature_id: TestFeature.Messages,
quantity: 300,
},
{
feature_id: TestFeature.Words,
quantity: 200,
},
],
customize: {
items: [
itemsV2.prepaidMessages({
amount: 12,
billingUnits: 100,
}),
{
feature_id: TestFeature.Words,
included: 0,
price: {
amount: 15,
interval: BillingInterval.OneOff,
billing_method: BillingMethod.Prepaid,
billing_units: 100,
},
},
{
feature_id: TestFeature.Users,
included: 0,
price: {
amount: 7,
interval: BillingInterval.Month,
billing_method: BillingMethod.UsageBased,
billing_units: 1,
},
},
],
},
},
],
},
],
},
expectedTotal: 66,
expectedLineItemTotals: [0, 30, 36],
assertPreview: (preview) => {
expect(
sortStrings(
preview.line_items.map((lineItem) => lineItem.feature_id ?? "base"),
),
).toEqual(
sortStrings([
TestFeature.Messages,
TestFeature.Users,
TestFeature.Words,
]),
);
},
});
});
test.concurrent(`${chalk.yellowBright("create-schedule preview 11: one-off plan charges now and has no next cycle")}`, async () => {
const oneOff = products.base({
id: "preview-one-off-base",
items: [items.oneOffPrice({ price: 50 }), items.monthlyMessages()],
});
const { customerId, autumnV1, advancedTo } = await initScenario({
customerId: "create-schedule-preview-one-off",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [oneOff] }),
],
actions: [],
});
await expectPreviewToMatchCreateSchedule({
autumnV1,
params: {
customer_id: customerId,
phases: [
{
starts_at: advancedTo,
plans: [{ plan_id: oneOff.id }],
},
],
},
expectedTotal: 50,
expectedLineItemTotals: [50],
assertPreview: (preview) => {
expect(preview.next_cycle).toBeUndefined();
},
});
});
test.concurrent(`${chalk.yellowBright("create-schedule preview 12: prepaid quantities only charge for units above included usage")}`, async () => {
const prepaid = products.base({
id: "preview-prepaid-included-usage",
items: [items.prepaidMessages({ includedUsage: 200 })],
group: "preview-prepaid-included-usage",
});
const { customerId, autumnV1, advancedTo } = await initScenario({
customerId: "create-schedule-preview-prepaid-included-usage",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [prepaid] }),
],
actions: [],
});
await expectPreviewToMatchCreateSchedule({
autumnV1,
params: {
customer_id: customerId,
phases: [
{
starts_at: advancedTo,
plans: [
{
plan_id: prepaid.id,
feature_quantities: [
{
feature_id: TestFeature.Messages,
quantity: 200,
},
],
},
],
},
],
},
expectedTotal: 0,
assertPreview: (preview) => {
expect(
preview.line_items.every((lineItem) => lineItem.total === 0),
).toBe(true);
},
});
});
test.concurrent(`${chalk.yellowBright("create-schedule preview 13: active schedules can defer a future replacement without charging now")}`, async () => {
const pro = products.pro({
id: "preview-future-replacement-pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const premium = products.premium({
id: "preview-future-replacement-premium",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { customerId, autumnV1, advancedTo } = await initScenario({
customerId: "create-schedule-preview-future-replacement",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
await expectPreviewToMatchCreateSchedule({
autumnV1,
params: {
customer_id: customerId,
phases: [
{
starts_at: advancedTo,
plans: [{ plan_id: pro.id }],
},
{
starts_at: advancedTo + ms.days(15),
plans: [{ plan_id: premium.id }],
},
],
},
expectedTotal: 0,
assertPreview: (preview) => {
expect(preview.line_items).toHaveLength(0);
expect(preview.next_cycle).toBeDefined();
expect(preview.next_cycle?.total).toBe(50);
expect(preview.next_cycle?.starts_at).toBeCloseTo(
addMonths(advancedTo, 1).getTime(),
-ms.days(1),
);
},
});
});

View File

@@ -1,4 +1,4 @@
import { formatAmount, type ProductItem, type ProductV2 } from "@autumn/shared";
import type { ProductItem, ProductV2 } from "@autumn/shared";
import {
PackageIcon,
PencilSimpleIcon,
@@ -7,55 +7,23 @@ import {
} from "@phosphor-icons/react";
import { Badge } from "@/components/v2/badges/Badge";
import { Button } from "@/components/v2/buttons/Button";
import { PriceDisplay } from "@/components/forms/update-subscription-v2/components/PriceDisplay";
import { SearchableSelect } from "@/components/v2/selects/SearchableSelect";
import { useOrg } from "@/hooks/common/useOrg";
import { cn } from "@/lib/utils";
import { useCreateScheduleFormContext } from "../context/CreateScheduleFormProvider";
export function getItemUnitPrice(item: ProductItem): number | null {
if (item.price != null) return item.price;
if (item.tiers?.length === 1) return item.tiers[0].amount ?? null;
return null;
}
export function getPlanPriceLabel({
export function getSchedulePlanPriceProduct({
product,
customItems,
prepaidOptions,
}: {
product: ProductV2;
customItems?: ProductItem[] | null;
prepaidOptions?: Record<string, number>;
}): string | null {
const items = customItems ?? product.items;
const pricedItems =
items?.filter((item) => getItemUnitPrice(item) != null && item.interval) ??
[];
if (pricedItems.length === 0) return "Free";
const totalAmount = pricedItems.reduce((sum, item) => {
const unitPrice = getItemUnitPrice(item) ?? 0;
const quantity =
item.feature_id && prepaidOptions?.[item.feature_id]
? prepaidOptions[item.feature_id]
: 1;
return sum + unitPrice * quantity;
}, 0);
const firstItem = pricedItems[0]!;
const intervalCount = firstItem.interval_count ?? 1;
const intervalLabel =
intervalCount === 1
? `/${firstItem.interval}`
: `/${intervalCount} ${firstItem.interval}s`;
return `${formatAmount({
currency: "USD",
amount: totalAmount,
amountFormatOptions: {
style: "currency",
currencyDisplay: "narrowSymbol",
},
})}${intervalLabel}`;
}): ProductV2 {
return {
...product,
items: customItems ?? product.items,
};
}
export function SchedulePlanRow({
@@ -75,6 +43,7 @@ export function SchedulePlanRow({
isPhaseLocked,
setEditingPlan,
} = useCreateScheduleFormContext();
const { org } = useOrg();
const plan = formValues.phases[phaseIndex]?.plans[planIndex];
if (!plan) return null;
@@ -83,11 +52,10 @@ export function SchedulePlanRow({
const availableProducts = products.filter((p) => !p.archived);
const selectedProduct = products.find((p) => p.id === plan.productId);
const hasCustomizations = plan.isCustom;
const priceLabel = selectedProduct
? getPlanPriceLabel({
const priceProduct = selectedProduct
? getSchedulePlanPriceProduct({
product: selectedProduct,
customItems: plan.items,
prepaidOptions: plan.prepaidOptions,
})
: null;
@@ -184,8 +152,13 @@ export function SchedulePlanRow({
Custom
</Badge>
)}
{priceLabel && (
<span className="text-xs text-t3 tabular-nums">{priceLabel}</span>
{priceProduct && (
<span className="text-xs text-t3 tabular-nums">
<PriceDisplay
product={priceProduct}
currency={org?.default_currency ?? "USD"}
/>
</span>
)}
</div>
<div

View File

@@ -10,11 +10,18 @@ interface PriceDisplayProps {
currency: string;
}
export function PriceDisplay({ product, currency }: PriceDisplayProps) {
type ProductPriceDisplay =
| { type: "free" }
| { type: "price"; formattedPrice: string; intervalText: string };
export function getProductPriceDisplay({
product,
currency,
}: PriceDisplayProps): ProductPriceDisplay {
const priceItem = product?.items?.find((i) => isPriceItem(i));
if (!priceItem || priceItem.price === 0 || priceItem.price === undefined) {
return <span className="text-t2">Free</span>;
return { type: "free" };
}
const formattedPrice = formatAmount({
@@ -33,10 +40,20 @@ export function PriceDisplay({ product, currency }: PriceDisplayProps) {
})
: "one-off";
return { type: "price", formattedPrice, intervalText };
}
export function PriceDisplay({ product, currency }: PriceDisplayProps) {
const priceDisplay = getProductPriceDisplay({ product, currency });
if (priceDisplay.type === "free") {
return <span className="text-t2">Free</span>;
}
return (
<span className="flex items-center gap-1">
<span className="text-t1 font-semibold">{formattedPrice}</span>
<span className="text-t3">{intervalText}</span>
<span className="text-t1 font-semibold">{priceDisplay.formattedPrice}</span>
<span className="text-t3">{priceDisplay.intervalText}</span>
</span>
);
}

View File

@@ -1,9 +1,7 @@
import { describe, expect, test } from "bun:test";
import { AppEnv, type ProductItem, type ProductV2 } from "@autumn/shared";
import {
getItemUnitPrice,
getPlanPriceLabel,
} from "@/components/forms/create-schedule/components/SchedulePlanRow";
import { getSchedulePlanPriceProduct } from "@/components/forms/create-schedule/components/SchedulePlanRow";
import { getProductPriceDisplay } from "@/components/forms/update-subscription-v2/components/PriceDisplay";
function makeProduct({
id = "prod_1",
@@ -66,29 +64,25 @@ const freeFeatureItem: ProductItem = {
interval: null,
} as ProductItem;
describe("getItemUnitPrice", () => {
test("returns price for base price item", () => {
expect(getItemUnitPrice(basePriceItem)).toBe(15);
});
test("returns price for feature item with direct price", () => {
expect(getItemUnitPrice(featurePriceViaPrice)).toBe(10);
});
test("returns tiers[0].amount for single-tier feature item", () => {
expect(getItemUnitPrice(featurePriceViaTiers)).toBe(10);
});
test("returns null for multi-tier feature item", () => {
expect(getItemUnitPrice(multiTierItem)).toBeNull();
});
test("returns null for free feature item", () => {
expect(getItemUnitPrice(freeFeatureItem)).toBeNull();
});
});
describe("getPlanPriceLabel", () => {
const getPlanPriceLabel = ({
product,
customItems,
}: {
product: ProductV2;
customItems?: ProductItem[] | null;
}) => {
const priceProduct = getSchedulePlanPriceProduct({ product, customItems });
const priceDisplay = getProductPriceDisplay({
product: priceProduct,
currency: "USD",
});
return priceDisplay.type === "free"
? "Free"
: `${priceDisplay.formattedPrice} ${priceDisplay.intervalText}`;
};
test("returns Free when no priced items", () => {
const product = makeProduct({ items: [freeFeatureItem] });
expect(getPlanPriceLabel({ product })).toBe("Free");
@@ -98,39 +92,24 @@ describe("getPlanPriceLabel", () => {
const product = makeProduct({ items: [basePriceItem] });
const label = getPlanPriceLabel({ product });
expect(label).toContain("15");
expect(label).toContain("/month");
expect(label).toContain("per month");
});
test("sums base price + feature price with prepaid quantity", () => {
const product = makeProduct({
items: [basePriceItem, featurePriceViaTiers],
});
const label = getPlanPriceLabel({
product,
prepaidOptions: { users: 5 },
});
expect(label).toContain("65");
expect(label).toContain("/month");
});
test("uses single-tier amount for feature items with tiers", () => {
const product = makeProduct({ items: [featurePriceViaTiers] });
const label = getPlanPriceLabel({
product,
prepaidOptions: { users: 3 },
});
expect(label).toContain("30");
});
test("multiplies by 1 when feature has no prepaid option", () => {
test("uses base price display when plan also has priced features", () => {
const product = makeProduct({
items: [basePriceItem, featurePriceViaTiers],
});
const label = getPlanPriceLabel({ product });
expect(label).toContain("25");
expect(label).toContain("15");
expect(label).toContain("per month");
});
test("skips multi-tier items (cannot compute fixed total)", () => {
test("returns Free when plan only has priced feature items", () => {
const product = makeProduct({ items: [featurePriceViaTiers] });
expect(getPlanPriceLabel({ product })).toBe("Free");
});
test("uses base price even when priced feature is multi-tier", () => {
const product = makeProduct({ items: [basePriceItem, multiTierItem] });
const label = getPlanPriceLabel({ product });
expect(label).toContain("15");
@@ -142,18 +121,13 @@ describe("getPlanPriceLabel", () => {
{ ...basePriceItem, price: 20 } as ProductItem,
featurePriceViaTiers,
];
const label = getPlanPriceLabel({
product,
customItems,
prepaidOptions: { users: 5 },
});
expect(label).toContain("70");
const label = getPlanPriceLabel({ product, customItems });
expect(label).toContain("20");
expect(label).toContain("per month");
});
test("returns Free when customItems has no priced items", () => {
test("returns Free when customItems only has priced features", () => {
const product = makeProduct({ items: [basePriceItem] });
expect(
getPlanPriceLabel({ product, customItems: [freeFeatureItem] }),
).toBe("Free");
expect(getPlanPriceLabel({ product, customItems: [featurePriceViaPrice] })).toBe("Free");
});
});