From ca72a27b14bcf9eace87111f42d24ecd9017dfb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maur=C3=ADcio=20Antunes?= Date: Tue, 2 Jun 2026 15:17:40 -0300 Subject: [PATCH 1/4] feat(js): support new checkout totals shape Add `discounts` (prorated seat discount + total), `totals_due_per_period` (full recurring renewal breakdown), `total_due_per_period`, and top-level `base_fee` to checkout totals. The top-level totals now reflect only what is actively being purchased, while `totals_due_per_period` conveys the recurring charge. Parse the new fields and surface the prorated discount and renewal totals in the checkout form --- .changeset/checkout-totals-new-shape.md | 17 ++ packages/clerk-js/src/utils/billing.ts | 46 +++- packages/localizations/src/en-US.ts | 3 + packages/msw/BillingService.ts | 16 +- packages/shared/src/types/billing.ts | 71 +++++- packages/shared/src/types/json.ts | 81 +++++- packages/shared/src/types/localization.ts | 3 + .../src/components/Checkout/CheckoutForm.tsx | 33 ++- .../Checkout/__tests__/Checkout.test.tsx | 235 ++++++++++++++++++ 9 files changed, 494 insertions(+), 11 deletions(-) create mode 100644 .changeset/checkout-totals-new-shape.md diff --git a/.changeset/checkout-totals-new-shape.md b/.changeset/checkout-totals-new-shape.md new file mode 100644 index 00000000000..272a7a93d6a --- /dev/null +++ b/.changeset/checkout-totals-new-shape.md @@ -0,0 +1,17 @@ +--- +'@clerk/clerk-js': patch +'@clerk/shared': patch +'@clerk/ui': patch +'@clerk/localizations': patch +'@clerk/msw': 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/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(); + }); + }); }); From a81cd4151a13fbafca8e8578742426064e946678 Mon Sep 17 00:00:00 2001 From: Keiran Flanigan Date: Tue, 2 Jun 2026 17:00:19 -0700 Subject: [PATCH 2/4] fix(ui): Use new paymentAttempt.totals object in UI (#8731) --- .changeset/payment-attempt-subtotal.md | 5 +++++ .../ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/payment-attempt-subtotal.md 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/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 && From 01b38e14c0bb9758c7af451a00143e7d85e0f01a Mon Sep 17 00:00:00 2001 From: Mauricio Antunes Date: Wed, 3 Jun 2026 12:34:09 -0300 Subject: [PATCH 3/4] chore: don't bump msw It's internal only Co-authored-by: Dylan Staley <88163+dstaley@users.noreply.github.com> --- .changeset/checkout-totals-new-shape.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.changeset/checkout-totals-new-shape.md b/.changeset/checkout-totals-new-shape.md index 272a7a93d6a..8920513c4f2 100644 --- a/.changeset/checkout-totals-new-shape.md +++ b/.changeset/checkout-totals-new-shape.md @@ -3,7 +3,6 @@ '@clerk/shared': patch '@clerk/ui': patch '@clerk/localizations': patch -'@clerk/msw': patch --- Update checkout totals to support the new `totals` shape from the API: From 7fab36a8c025f7237b22b477673326d9898b7b00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maur=C3=ADcio=20Antunes?= Date: Wed, 3 Jun 2026 12:40:43 -0300 Subject: [PATCH 4/4] chore: remove disclaimer --- packages/shared/src/types/json.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index 97f76275b94..0be09f55496 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -957,7 +957,6 @@ export interface BillingCheckoutTotalsJSON { total_due_after_free_trial: BillingMoneyAmountJSON | null; /** * Discounts applied to this checkout such as mid-cycle prorated seat discounts. - * The key is always present; the value is `null` when no discounts apply. */ discounts: BillingCheckoutDiscountsJSON | null; /**