diff --git a/.changeset/checkout-totals-new-shape.md b/.changeset/checkout-totals-new-shape.md new file mode 100644 index 00000000000..8920513c4f2 --- /dev/null +++ b/.changeset/checkout-totals-new-shape.md @@ -0,0 +1,16 @@ +--- +'@clerk/clerk-js': patch +'@clerk/shared': patch +'@clerk/ui': patch +'@clerk/localizations': patch +--- + +Update checkout totals to support the new `totals` shape from the API: + +- Add `discounts` to `BillingCheckoutTotals` / `BillingCheckoutTotalsJSON`. It exposes a `proration` (a `BillingProrationDiscountDetail` with `amount`, `cycleDaysPassed`, `cycleDaysTotal`, `cyclePassedPercent`) describing the prorated discount applied when adding seats mid-billing-period — i.e. the part of the cycle that has already passed and is not charged — plus a `total` of all discounts. +- Add `totalsDuePerPeriod` / `totals_due_per_period` (`BillingPerPeriodTotals`) with `subtotal`, `baseFee`, `taxTotal`, `grandTotal`, and `perUnitTotals`: the full renewal charge breakdown covering all seats and the base plan fee, as opposed to the top-level totals which now only cover what is actively being purchased in the current checkout. +- Add `totalDuePerPeriod` / `total_due_per_period` (money amount) for backwards compatibility. +- Add `baseFee` / `base_fee` to the top-level totals. +- Parse all new fields in `billingTotalsFromJSON`. +- Render the prorated discount line item in `CheckoutForm` when present. +- Render a renewal subtotal and renewal total section in `CheckoutForm` when `totalsDuePerPeriod` is present. diff --git a/.changeset/payment-attempt-subtotal.md b/.changeset/payment-attempt-subtotal.md new file mode 100644 index 00000000000..622a72b8589 --- /dev/null +++ b/.changeset/payment-attempt-subtotal.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Fix the `Subtotal` row in the payment attempt details view to read from `paymentAttempt.totals.subtotal` so it matches the value shown in the Clerk Dashboard. Previously it rendered `subscriptionItem.amount` which caused the displayed subtotal to disagree with the dashboard. diff --git a/packages/clerk-js/src/utils/billing.ts b/packages/clerk-js/src/utils/billing.ts index 4e0fd1528c1..5ed5bd727fc 100644 --- a/packages/clerk-js/src/utils/billing.ts +++ b/packages/clerk-js/src/utils/billing.ts @@ -1,4 +1,6 @@ import type { + BillingCheckoutDiscounts, + BillingCheckoutDiscountsJSON, BillingCheckoutTotals, BillingCheckoutTotalsJSON, BillingCredits, @@ -7,6 +9,8 @@ import type { BillingMoneyAmountJSON, BillingPaymentTotals, BillingPaymentTotalsJSON, + BillingPerPeriodTotals, + BillingPerPeriodTotalsJSON, BillingPerUnitTotal, BillingPerUnitTotalJSON, BillingPlanUnitPriceJSON, @@ -76,6 +80,30 @@ export const billingCreditsFromJSON = (data: BillingCreditsJSON): BillingCredits }; }; +const billingCheckoutDiscountsFromJSON = (data: BillingCheckoutDiscountsJSON): BillingCheckoutDiscounts => { + return { + proration: data.proration + ? { + amount: billingMoneyAmountFromJSON(data.proration.amount), + cycleDaysPassed: data.proration.cycle_days_passed, + cycleDaysTotal: data.proration.cycle_days_total, + cyclePassedPercent: data.proration.cycle_passed_percent, + } + : null, + total: billingMoneyAmountFromJSON(data.total), + }; +}; + +const billingPerPeriodTotalsFromJSON = (data: BillingPerPeriodTotalsJSON): BillingPerPeriodTotals => { + return { + subtotal: billingMoneyAmountFromJSON(data.subtotal), + baseFee: billingMoneyAmountFromJSON(data.base_fee), + taxTotal: billingMoneyAmountFromJSON(data.tax_total), + grandTotal: billingMoneyAmountFromJSON(data.grand_total), + perUnitTotals: data.per_unit_totals ? billingPerUnitTotalsFromJSON(data.per_unit_totals) : undefined, + }; +}; + export const billingTotalsFromJSON = ( data: T, ): T extends { total_due_now: BillingMoneyAmountJSON } ? BillingCheckoutTotals : BillingStatementTotals => { @@ -85,6 +113,9 @@ export const billingTotalsFromJSON = ; cancelSubscriptionTitle: LocalizationValue<'plan'>; diff --git a/packages/ui/src/components/Checkout/CheckoutForm.tsx b/packages/ui/src/components/Checkout/CheckoutForm.tsx index 2adca757fe4..b0c75cae562 100644 --- a/packages/ui/src/components/Checkout/CheckoutForm.tsx +++ b/packages/ui/src/components/Checkout/CheckoutForm.tsx @@ -44,6 +44,9 @@ export const CheckoutForm = withCardStateProvider(() => { const showProratedCredit = !!totals.credits?.proration?.amount && totals.credits.proration.amount.amount > 0; const showAccountCredits = !!totals.credits?.payer?.appliedAmount && totals.credits.payer.appliedAmount.amount > 0; const showPastDue = !!totals.pastDue?.amount && totals.pastDue.amount > 0; + const showProratedDiscount = !!totals.discounts?.proration?.amount && totals.discounts.proration.amount.amount > 0; + const showRenewalTotals = + !!totals.totalsDuePerPeriod && totals.totalsDuePerPeriod.grandTotal.amount !== totals.totalDueNow.amount; const showDowngradeInfo = !isImmediatePlanChange; const fee = @@ -124,6 +127,14 @@ export const CheckoutForm = withCardStateProvider(() => { + {showProratedDiscount && ( + + + + + )} {showProratedCredit && ( @@ -166,7 +177,7 @@ export const CheckoutForm = withCardStateProvider(() => { text={`${totals.totalDueAfterFreeTrial.currencySymbol}${totals.totalDueAfterFreeTrial.amountFormatted}`} /> - ) : ( + ) : showRenewalTotals ? null : ( { + + {showRenewalTotals && ( + <> + + + + + + + + + + )} diff --git a/packages/ui/src/components/Checkout/__tests__/Checkout.test.tsx b/packages/ui/src/components/Checkout/__tests__/Checkout.test.tsx index 100a669ecc2..5400c617472 100644 --- a/packages/ui/src/components/Checkout/__tests__/Checkout.test.tsx +++ b/packages/ui/src/components/Checkout/__tests__/Checkout.test.tsx @@ -1644,4 +1644,239 @@ describe('Checkout', () => { }); }); }); + + describe('differentiates between new subscriptions and mid-cycle seat additions in checkout totals', () => { + const proPlan = { + id: 'plan_totals', + name: 'Pro', + description: 'Pro plan', + features: [], + fee: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' }, + annualFee: { amount: 12000, amountFormatted: '120.00', currency: 'USD', currencySymbol: '$' }, + annualMonthlyFee: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' }, + slug: 'pro', + avatarUrl: '', + publiclyVisible: true, + isDefault: true, + isRecurring: true, + hasBaseFee: false, + forPayerType: 'user', + freeTrialDays: 0, + freeTrialEnabled: false, + }; + + const money = (amount: number, amountFormatted: string) => ({ + amount, + amountFormatted, + currency: 'USD', + currencySymbol: '$', + }); + + it('shows prorated discount and renewal totals for a mid-cycle seat addition', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); + }); + + fixtures.clerk.user?.getPaymentMethods.mockResolvedValue({ data: [], total_count: 0 }); + + fixtures.clerk.billing.startCheckout.mockResolvedValue({ + id: 'chk_totals_proration', + status: 'needs_confirmation', + externalClientSecret: 'cs_test_totals_proration', + externalGatewayId: 'gw_test', + totals: { + subtotal: money(1500, '15.00'), + baseFee: money(0, '0.00'), + grandTotal: money(1000, '10.00'), + taxTotal: money(0, '0.00'), + credit: money(0, '0.00'), + pastDue: money(0, '0.00'), + totalDueNow: money(1000, '10.00'), + discounts: { + proration: { + amount: money(500, '5.00'), + cycleDaysPassed: 15, + cycleDaysTotal: 30, + cyclePassedPercent: 50, + }, + total: money(500, '5.00'), + }, + totalDuePerPeriod: money(3000, '30.00'), + totalsDuePerPeriod: { + subtotal: money(3000, '30.00'), + baseFee: money(0, '0.00'), + taxTotal: money(0, '0.00'), + grandTotal: money(3000, '30.00'), + }, + }, + isImmediatePlanChange: true, + planPeriod: 'month', + plan: proPlan, + paymentMethod: undefined, + confirm: vi.fn(), + freeTrialEndsAt: null, + needsPaymentMethod: false, + } as any); + + const { getByRole, getByText } = render( + {}} + > + + , + { wrapper }, + ); + + await waitFor(() => { + expect(getByRole('heading', { name: 'Checkout' })).toBeVisible(); + }); + + const proratedDiscountRow = getByText('Prorated discount').closest('.cl-lineItemsGroup'); + expect(proratedDiscountRow).toBeInTheDocument(); + expect(proratedDiscountRow).toHaveTextContent('- $5.00'); + + const subtotalRenewalRow = getByText('Subtotal per period').closest('.cl-lineItemsGroup'); + expect(subtotalRenewalRow).toBeInTheDocument(); + expect(subtotalRenewalRow).toHaveTextContent('$30.00'); + + const totalPerPeriodRow = getByText('Total per period').closest('.cl-lineItemsGroup'); + expect(totalPerPeriodRow).toBeInTheDocument(); + expect(totalPerPeriodRow).toHaveTextContent('$30.00'); + }); + + it('hides renewal totals for a new subscription where the renewal equals the amount due now', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); + }); + + fixtures.clerk.user?.getPaymentMethods.mockResolvedValue({ data: [], total_count: 0 }); + + fixtures.clerk.billing.startCheckout.mockResolvedValue({ + id: 'chk_totals_new_sub', + status: 'needs_confirmation', + externalClientSecret: 'cs_test_totals_new_sub', + externalGatewayId: 'gw_test', + totals: { + subtotal: money(1000, '10.00'), + baseFee: money(1000, '10.00'), + grandTotal: money(1000, '10.00'), + taxTotal: money(0, '0.00'), + credit: money(0, '0.00'), + pastDue: money(0, '0.00'), + totalDueNow: money(1000, '10.00'), + discounts: null, + totalDuePerPeriod: money(1000, '10.00'), + totalsDuePerPeriod: { + subtotal: money(1000, '10.00'), + baseFee: money(1000, '10.00'), + taxTotal: money(0, '0.00'), + grandTotal: money(1000, '10.00'), + }, + }, + isImmediatePlanChange: true, + planPeriod: 'month', + plan: proPlan, + paymentMethod: undefined, + confirm: vi.fn(), + freeTrialEndsAt: null, + needsPaymentMethod: false, + } as any); + + const { getByRole, getByText, queryByText } = render( + {}} + > + + , + { wrapper }, + ); + + await waitFor(() => { + expect(getByRole('heading', { name: 'Checkout' })).toBeVisible(); + }); + + // The "Total Due Today" row is still shown, but no renewal/discount rows are added + expect(getByText('Total Due Today')).toBeVisible(); + expect(queryByText('Subtotal per period')).toBeNull(); + expect(queryByText('Total per period')).toBeNull(); + expect(queryByText('Prorated discount')).toBeNull(); + }); + + it('hides the prorated discount row when the discount amount is zero', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); + }); + + fixtures.clerk.user?.getPaymentMethods.mockResolvedValue({ data: [], total_count: 0 }); + + fixtures.clerk.billing.startCheckout.mockResolvedValue({ + id: 'chk_totals_zero_discount', + status: 'needs_confirmation', + externalClientSecret: 'cs_test_totals_zero_discount', + externalGatewayId: 'gw_test', + totals: { + subtotal: money(1000, '10.00'), + baseFee: money(0, '0.00'), + grandTotal: money(1000, '10.00'), + taxTotal: money(0, '0.00'), + credit: money(0, '0.00'), + pastDue: money(0, '0.00'), + totalDueNow: money(1000, '10.00'), + discounts: { + proration: { + amount: money(0, '0.00'), + cycleDaysPassed: 0, + cycleDaysTotal: 30, + cyclePassedPercent: 0, + }, + total: money(0, '0.00'), + }, + totalDuePerPeriod: money(1000, '10.00'), + totalsDuePerPeriod: { + subtotal: money(1000, '10.00'), + baseFee: money(0, '0.00'), + taxTotal: money(0, '0.00'), + grandTotal: money(1000, '10.00'), + }, + }, + isImmediatePlanChange: true, + planPeriod: 'month', + plan: proPlan, + paymentMethod: undefined, + confirm: vi.fn(), + freeTrialEndsAt: null, + needsPaymentMethod: false, + } as any); + + const { getByRole, queryByText } = render( + {}} + > + + , + { wrapper }, + ); + + await waitFor(() => { + expect(getByRole('heading', { name: 'Checkout' })).toBeVisible(); + }); + + expect(queryByText('Prorated discount')).toBeNull(); + }); + }); }); diff --git a/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx b/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx index 3b796b5feea..692c1762104 100644 --- a/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx +++ b/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx @@ -273,7 +273,7 @@ function PaymentAttemptBody({ paymentAttempt }: { paymentAttempt: BillingPayment > {subscriptionItem.credits &&