From e9c2ca9fea4319d6cf8286afbfede077694f786a Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:18:53 -0500 Subject: [PATCH 1/4] feat(ui): Add per-seat costs to checkout totals --- .../src/components/Checkout/CheckoutForm.tsx | 36 ++++++++++++++++++- packages/ui/src/elements/LineItems.tsx | 1 - 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/Checkout/CheckoutForm.tsx b/packages/ui/src/components/Checkout/CheckoutForm.tsx index 2a6dc9c4122..da366388493 100644 --- a/packages/ui/src/components/Checkout/CheckoutForm.tsx +++ b/packages/ui/src/components/Checkout/CheckoutForm.tsx @@ -48,10 +48,34 @@ export const CheckoutForm = withCardStateProvider(() => { : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion plan.annualMonthlyFee!; - const descriptionElements = []; + const seatPerUnitTotal = totals.perUnitTotals?.find(({ name }) => name === 'seats'); + const seatPerUnitTotalTiers = seatPerUnitTotal?.tiers ?? []; + const firstSeatTotalTier = seatPerUnitTotalTiers[0]; + const secondSeatTotalTier = seatPerUnitTotalTiers[1]; + const paidSeatTotalTier = + firstSeatTotalTier?.feePerBlock.amount && + firstSeatTotalTier.feePerBlock.amount > 0 && + seatPerUnitTotalTiers.length === 1 + ? firstSeatTotalTier + : secondSeatTotalTier?.feePerBlock.amount && secondSeatTotalTier.feePerBlock.amount > 0 + ? secondSeatTotalTier + : undefined; + + const descriptionElements: Array> = []; if (planPeriod === 'annual') { descriptionElements.push(localizationKeys('billing.billedAnnually')); } + if ( + seatPerUnitTotalTiers.length > 1 && + firstSeatTotalTier?.feePerBlock.amount === 0 && + firstSeatTotalTier.quantity !== null + ) { + descriptionElements.push( + localizationKeys('billing.pricingTable.seatCost.includedSeats', { + includedSeats: firstSeatTotalTier.quantity, + }), + ); + } const seatUnitPrice = getSeatUnitPrice(plan); if (seatUnitPrice && seatUnitPrice.tiers.length === 1 && seatUnitPrice.tiers[0].feePerBlock.amount === 0) { descriptionElements.push( @@ -91,6 +115,16 @@ export const CheckoutForm = withCardStateProvider(() => { suffix={localizationKeys('billing.checkout.perMonth')} /> + {paidSeatTotalTier && paidSeatTotalTier.quantity !== null && ( + + + + + )} (({ title, descr ({ display: 'inline-flex', - alignItems: 'center', gap: t.space.$1, })} > From f77bda61ded1fd7efbb330822156cb78cfa5c251 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Mon, 4 May 2026 10:34:50 -0500 Subject: [PATCH 2/4] feat(clerk-js,localizations,shared,ui): Add support for total_due_per_period --- .changeset/khaki-hairs-punch.md | 8 ++++++++ packages/clerk-js/src/utils/billing.ts | 3 +++ packages/localizations/src/en-US.ts | 1 + packages/shared/src/types/billing.ts | 4 ++++ packages/shared/src/types/json.ts | 1 + packages/shared/src/types/localization.ts | 1 + packages/ui/src/components/Checkout/CheckoutForm.tsx | 9 ++++++++- 7 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 .changeset/khaki-hairs-punch.md diff --git a/.changeset/khaki-hairs-punch.md b/.changeset/khaki-hairs-punch.md new file mode 100644 index 00000000000..7b4e9fe5ebc --- /dev/null +++ b/.changeset/khaki-hairs-punch.md @@ -0,0 +1,8 @@ +--- +'@clerk/localizations': minor +'@clerk/clerk-js': minor +'@clerk/shared': minor +'@clerk/ui': minor +--- + +Add support for total due per period to checkout diff --git a/packages/clerk-js/src/utils/billing.ts b/packages/clerk-js/src/utils/billing.ts index 77b28782197..e994843361f 100644 --- a/packages/clerk-js/src/utils/billing.ts +++ b/packages/clerk-js/src/utils/billing.ts @@ -77,6 +77,9 @@ export const billingTotalsFromJSON = ; + totalDuePerPeriod: LocalizationValue; perMonth: LocalizationValue; }; }; diff --git a/packages/ui/src/components/Checkout/CheckoutForm.tsx b/packages/ui/src/components/Checkout/CheckoutForm.tsx index da366388493..f5933e69465 100644 --- a/packages/ui/src/components/Checkout/CheckoutForm.tsx +++ b/packages/ui/src/components/Checkout/CheckoutForm.tsx @@ -163,7 +163,7 @@ export const CheckoutForm = withCardStateProvider(() => { )} - {!!freeTrialEndsAt && !!plan.freeTrialDays && totals.totalDueAfterFreeTrial && ( + {!!freeTrialEndsAt && !!plan.freeTrialDays && totals.totalDueAfterFreeTrial ? ( { text={`${totals.totalDueAfterFreeTrial.currencySymbol}${totals.totalDueAfterFreeTrial.amountFormatted}`} /> + ) : ( + + + + )} From eadf76119de32887aa92096754dd7ed77d9dbd15 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Mon, 4 May 2026 10:39:06 -0500 Subject: [PATCH 3/4] chore(repo): Add changeset --- .changeset/good-ads-greet.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/good-ads-greet.md diff --git a/.changeset/good-ads-greet.md b/.changeset/good-ads-greet.md new file mode 100644 index 00000000000..06dfb32119a --- /dev/null +++ b/.changeset/good-ads-greet.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': minor +--- + +Add support for rendering per-seat costs in checkout From 5308653e250a85cd1f01da87794b69c6db55a41b Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Fri, 8 May 2026 10:31:00 -0500 Subject: [PATCH 4/4] chore(ui): Refactor into helpers --- .../src/components/Checkout/CheckoutForm.tsx | 36 ++++------ packages/ui/src/utils/billingPlanSeats.ts | 71 ++++++++++++++++++- 2 files changed, 84 insertions(+), 23 deletions(-) diff --git a/packages/ui/src/components/Checkout/CheckoutForm.tsx b/packages/ui/src/components/Checkout/CheckoutForm.tsx index f5933e69465..2adca757fe4 100644 --- a/packages/ui/src/components/Checkout/CheckoutForm.tsx +++ b/packages/ui/src/components/Checkout/CheckoutForm.tsx @@ -9,7 +9,12 @@ import { LineItems } from '@/ui/elements/LineItems'; import { SegmentedControl } from '@/ui/elements/SegmentedControl'; import { Select, SelectButton, SelectOptionList } from '@/ui/elements/Select'; import { Tooltip } from '@/ui/elements/Tooltip'; -import { getSeatUnitPrice } from '@/ui/utils/billingPlanSeats'; +import { + getCheckoutSeatUnitTotal, + getIncludedSeatsUnitTotalTier, + getPaidSeatsUnitTotalTier, + getSeatUnitPrice, +} from '@/ui/utils/billingPlanSeats'; import { handleError } from '@/ui/utils/errorHandler'; import { DevOnly } from '../../common/DevOnly'; @@ -48,31 +53,18 @@ export const CheckoutForm = withCardStateProvider(() => { : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion plan.annualMonthlyFee!; - const seatPerUnitTotal = totals.perUnitTotals?.find(({ name }) => name === 'seats'); - const seatPerUnitTotalTiers = seatPerUnitTotal?.tiers ?? []; - const firstSeatTotalTier = seatPerUnitTotalTiers[0]; - const secondSeatTotalTier = seatPerUnitTotalTiers[1]; - const paidSeatTotalTier = - firstSeatTotalTier?.feePerBlock.amount && - firstSeatTotalTier.feePerBlock.amount > 0 && - seatPerUnitTotalTiers.length === 1 - ? firstSeatTotalTier - : secondSeatTotalTier?.feePerBlock.amount && secondSeatTotalTier.feePerBlock.amount > 0 - ? secondSeatTotalTier - : undefined; + const seatPerUnitTotal = getCheckoutSeatUnitTotal(totals); + const includedSeatsTier = getIncludedSeatsUnitTotalTier(seatPerUnitTotal); + const paidSeatsTier = getPaidSeatsUnitTotalTier(seatPerUnitTotal); const descriptionElements: Array> = []; if (planPeriod === 'annual') { descriptionElements.push(localizationKeys('billing.billedAnnually')); } - if ( - seatPerUnitTotalTiers.length > 1 && - firstSeatTotalTier?.feePerBlock.amount === 0 && - firstSeatTotalTier.quantity !== null - ) { + if (includedSeatsTier && includedSeatsTier.quantity !== null) { descriptionElements.push( localizationKeys('billing.pricingTable.seatCost.includedSeats', { - includedSeats: firstSeatTotalTier.quantity, + includedSeats: includedSeatsTier.quantity, }), ); } @@ -115,12 +107,12 @@ export const CheckoutForm = withCardStateProvider(() => { suffix={localizationKeys('billing.checkout.perMonth')} /> - {paidSeatTotalTier && paidSeatTotalTier.quantity !== null && ( + {paidSeatsTier && paidSeatsTier.quantity !== null && ( diff --git a/packages/ui/src/utils/billingPlanSeats.ts b/packages/ui/src/utils/billingPlanSeats.ts index e5732c2d530..c7805ee8f2b 100644 --- a/packages/ui/src/utils/billingPlanSeats.ts +++ b/packages/ui/src/utils/billingPlanSeats.ts @@ -1,4 +1,10 @@ -import type { BillingPlanResource, BillingPlanUnitPrice, OrganizationResource } from '@clerk/shared/types'; +import type { + BillingPerUnitTotal, + BillingPerUnitTotalTier, + BillingPlanResource, + BillingPlanUnitPrice, + OrganizationResource, +} from '@clerk/shared/types'; /** * Given a plan, return the unit price for seats. @@ -17,6 +23,69 @@ export const getSeatUnitPrice = (plan: { unitPrices?: BillingPlanUnitPrice[] }): return null; }; +/** + * Similar to the above, given a checkout totals, return the unit price for seats. + */ +export const getCheckoutSeatUnitTotal = (checkout: { + perUnitTotals?: BillingPerUnitTotal[]; +}): BillingPerUnitTotal | null => { + if (!checkout.perUnitTotals?.length) { + return null; + } + + const seatUnitPrice = checkout.perUnitTotals.find(unitTotal => unitTotal.name === 'seats'); + + if (seatUnitPrice) { + return seatUnitPrice; + } + + return null; +}; + +/** + * Given a checkout unit total, return the unit total tier that represents per-seat costs. If no tier is found, return null. + */ +export const getPaidSeatsUnitTotalTier = (unitTotal: BillingPerUnitTotal | null): BillingPerUnitTotalTier | null => { + if (!unitTotal) { + return null; + } + + if (unitTotal.tiers.length === 1 && unitTotal.tiers[0].feePerBlock.amount > 0) { + return unitTotal.tiers[0]; + } + + if ( + unitTotal.tiers.length === 2 && + unitTotal.tiers[0].feePerBlock.amount === 0 && + unitTotal.tiers[1].feePerBlock.amount > 0 + ) { + return unitTotal.tiers[1]; + } + + return null; +}; + +/** + * Given a checkout unit total, return the unit total tier that represents included seats. If no tier is found, return null. + */ +export const getIncludedSeatsUnitTotalTier = ( + unitTotal: BillingPerUnitTotal | null, +): BillingPerUnitTotalTier | null => { + if (!unitTotal) { + return null; + } + + if ( + unitTotal.tiers.length === 2 && + unitTotal.tiers[0].feePerBlock.amount === 0 && + unitTotal.tiers[1].feePerBlock.amount > 0 + ) { + return unitTotal.tiers[0]; + } + + return null; +}; + /** * Given a plan, return the seat limit for the plan, or undefined if the plan does not have a seat limit. */