From 6784d0f0f04a1d1eb5f15cc8284ceacd7848e030 Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Mon, 20 Apr 2026 18:06:45 +0100 Subject: [PATCH] chore: correct preview calculations --- .../create-schedule-basic.test.ts | 109 +-- .../create-schedule-preview.test.ts | 658 ++++++++++++++++++ .../components/SchedulePlanRow.tsx | 65 +- .../components/PriceDisplay.tsx | 25 +- .../components/schedule-plan-row.test.ts | 96 +-- 5 files changed, 763 insertions(+), 190 deletions(-) create mode 100644 server/tests/integration/billing/create-schedule/create-schedule-preview.test.ts diff --git a/server/tests/integration/billing/create-schedule/create-schedule-basic.test.ts b/server/tests/integration/billing/create-schedule/create-schedule-basic.test.ts index 5cc97a397..302d6bb83 100644 --- a/server/tests/integration/billing/create-schedule/create-schedule-basic.test.ts +++ b/server/tests/integration/billing/create-schedule/create-schedule-basic.test.ts @@ -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 () => { diff --git a/server/tests/integration/billing/create-schedule/create-schedule-preview.test.ts b/server/tests/integration/billing/create-schedule/create-schedule-preview.test.ts new file mode 100644 index 000000000..e5df9c3f7 --- /dev/null +++ b/server/tests/integration/billing/create-schedule/create-schedule-preview.test.ts @@ -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>["autumnV1"]; + params: CreateScheduleParamsV0Input; +}): Promise => + 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>["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), + ); + }, + }); +}); diff --git a/vite/src/components/forms/create-schedule/components/SchedulePlanRow.tsx b/vite/src/components/forms/create-schedule/components/SchedulePlanRow.tsx index a06e03b33..2007877ac 100644 --- a/vite/src/components/forms/create-schedule/components/SchedulePlanRow.tsx +++ b/vite/src/components/forms/create-schedule/components/SchedulePlanRow.tsx @@ -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 | 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 )} - {priceLabel && ( - {priceLabel} + {priceProduct && ( + + + )}
isPriceItem(i)); if (!priceItem || priceItem.price === 0 || priceItem.price === undefined) { - return Free; + 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 Free; + } + return ( - {formattedPrice} - {intervalText} + {priceDisplay.formattedPrice} + {priceDisplay.intervalText} ); } diff --git a/vite/tests/components/forms/create-schedule/components/schedule-plan-row.test.ts b/vite/tests/components/forms/create-schedule/components/schedule-plan-row.test.ts index 5c17ef162..93c71bfa5 100644 --- a/vite/tests/components/forms/create-schedule/components/schedule-plan-row.test.ts +++ b/vite/tests/components/forms/create-schedule/components/schedule-plan-row.test.ts @@ -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"); }); });