From 454faa61d49bf47323b841ad9cf27d4a334060d0 Mon Sep 17 00:00:00 2001 From: Keiran Flanigan Date: Mon, 11 May 2026 21:27:10 -0700 Subject: [PATCH 1/4] add seats info to payment attempts --- .../billing-seat-tier-rows-payment-attempt.md | 7 + .../src/core/resources/BillingPayment.ts | 5 +- .../src/utils/__tests__/billing.test.ts | 70 ++++++++++ packages/clerk-js/src/utils/billing.ts | 12 ++ packages/shared/src/types/billing.ts | 34 +++++ packages/shared/src/types/json.ts | 18 +++ .../PaymentAttempts/PaymentAttemptPage.tsx | 29 ++++- .../utils/__tests__/billingPlanSeats.test.ts | 123 ++++++++++++++++++ packages/ui/src/utils/billingPlanSeats.ts | 54 +++++++- 9 files changed, 344 insertions(+), 8 deletions(-) create mode 100644 .changeset/billing-seat-tier-rows-payment-attempt.md create mode 100644 packages/clerk-js/src/utils/__tests__/billing.test.ts create mode 100644 packages/ui/src/utils/__tests__/billingPlanSeats.test.ts diff --git a/.changeset/billing-seat-tier-rows-payment-attempt.md b/.changeset/billing-seat-tier-rows-payment-attempt.md new file mode 100644 index 00000000000..a48ab47b1ed --- /dev/null +++ b/.changeset/billing-seat-tier-rows-payment-attempt.md @@ -0,0 +1,7 @@ +--- +'@clerk/shared': minor +'@clerk/clerk-js': minor +'@clerk/ui': minor +--- + +Surface seat-based billing details on payment attempts. The payment attempt resource now exposes a `totals` field (`BillingPaymentTotals`) carrying optional `baseFee` and `perUnitTotals` breakdowns. The payment-attempt detail page renders a "Seats" line (`{quantity} × {feePerBlock}`, or the tier total for unlimited tiers) between the plan title and subtotal when the subscription item is seat-billed. diff --git a/packages/clerk-js/src/core/resources/BillingPayment.ts b/packages/clerk-js/src/core/resources/BillingPayment.ts index 890f6362a65..307093e57b4 100644 --- a/packages/clerk-js/src/core/resources/BillingPayment.ts +++ b/packages/clerk-js/src/core/resources/BillingPayment.ts @@ -5,10 +5,11 @@ import type { BillingPaymentMethodResource, BillingPaymentResource, BillingPaymentStatus, + BillingPaymentTotals, BillingSubscriptionItemResource, } from '@clerk/shared/types'; -import { billingMoneyAmountFromJSON } from '../../utils'; +import { billingMoneyAmountFromJSON, billingPaymentTotalsFromJSON } from '../../utils'; import { unixEpochToDate } from '../../utils/date'; import { BaseResource, BillingPaymentMethod, BillingSubscriptionItem } from './internal'; @@ -22,6 +23,7 @@ export class BillingPayment extends BaseResource implements BillingPaymentResour subscriptionItem!: BillingSubscriptionItemResource; chargeType!: BillingPaymentChargeType; status!: BillingPaymentStatus; + totals: BillingPaymentTotals | null = null; constructor(data: BillingPaymentJSON) { super(); @@ -42,6 +44,7 @@ export class BillingPayment extends BaseResource implements BillingPaymentResour this.subscriptionItem = new BillingSubscriptionItem(data.subscription_item); this.chargeType = data.charge_type; this.status = data.status; + this.totals = data.totals ? billingPaymentTotalsFromJSON(data.totals) : null; return this; } } diff --git a/packages/clerk-js/src/utils/__tests__/billing.test.ts b/packages/clerk-js/src/utils/__tests__/billing.test.ts new file mode 100644 index 00000000000..5b8afccbc29 --- /dev/null +++ b/packages/clerk-js/src/utils/__tests__/billing.test.ts @@ -0,0 +1,70 @@ +import type { BillingMoneyAmountJSON, BillingPaymentTotalsJSON } from '@clerk/shared/types'; +import { describe, expect, it } from 'vitest'; + +import { billingPaymentTotalsFromJSON } from '../billing'; + +const moneyJSON = (amount: number): BillingMoneyAmountJSON => ({ + amount, + amount_formatted: (amount / 100).toFixed(2), + currency: 'USD', + currency_symbol: '$', +}); + +describe('billingPaymentTotalsFromJSON', () => { + it('maps subtotal, grand_total, and tax_total', () => { + const data: BillingPaymentTotalsJSON = { + subtotal: moneyJSON(4500), + grand_total: moneyJSON(5000), + tax_total: moneyJSON(500), + }; + + const totals = billingPaymentTotalsFromJSON(data); + + expect(totals.subtotal.amount).toBe(4500); + expect(totals.grandTotal.amount).toBe(5000); + expect(totals.taxTotal.amount).toBe(500); + expect(totals.baseFee).toBeNull(); + expect(totals.perUnitTotals).toBeUndefined(); + }); + + it('maps base_fee when present', () => { + const data: BillingPaymentTotalsJSON = { + subtotal: moneyJSON(5000), + grand_total: moneyJSON(5000), + tax_total: moneyJSON(0), + base_fee: moneyJSON(1000), + }; + + expect(billingPaymentTotalsFromJSON(data).baseFee?.amount).toBe(1000); + }); + + it('maps per_unit_totals tiers with snake_case → camelCase conversion', () => { + const data: BillingPaymentTotalsJSON = { + subtotal: moneyJSON(5000), + grand_total: moneyJSON(5000), + tax_total: moneyJSON(0), + per_unit_totals: [ + { + name: 'seats', + block_size: 1, + tiers: [ + { quantity: 5, fee_per_block: moneyJSON(1000), total: moneyJSON(5000) }, + { quantity: null, fee_per_block: moneyJSON(0), total: moneyJSON(0) }, + ], + }, + ], + }; + + const totals = billingPaymentTotalsFromJSON(data); + + expect(totals.perUnitTotals).toHaveLength(1); + expect(totals.perUnitTotals?.[0].name).toBe('seats'); + expect(totals.perUnitTotals?.[0].blockSize).toBe(1); + expect(totals.perUnitTotals?.[0].tiers[0]).toMatchObject({ + quantity: 5, + feePerBlock: { amount: 1000 }, + total: { amount: 5000 }, + }); + expect(totals.perUnitTotals?.[0].tiers[1].quantity).toBeNull(); + }); +}); diff --git a/packages/clerk-js/src/utils/billing.ts b/packages/clerk-js/src/utils/billing.ts index 77b28782197..a8b2b9c2283 100644 --- a/packages/clerk-js/src/utils/billing.ts +++ b/packages/clerk-js/src/utils/billing.ts @@ -5,6 +5,8 @@ import type { BillingCreditsJSON, BillingMoneyAmount, BillingMoneyAmountJSON, + BillingPaymentTotals, + BillingPaymentTotalsJSON, BillingPerUnitTotal, BillingPerUnitTotalJSON, BillingStatementTotals, @@ -32,6 +34,16 @@ const billingPerUnitTotalsFromJSON = (data: BillingPerUnitTotalJSON[]): BillingP })); }; +export const billingPaymentTotalsFromJSON = (data: BillingPaymentTotalsJSON): BillingPaymentTotals => { + return { + subtotal: billingMoneyAmountFromJSON(data.subtotal), + grandTotal: billingMoneyAmountFromJSON(data.grand_total), + taxTotal: billingMoneyAmountFromJSON(data.tax_total), + baseFee: data.base_fee ? billingMoneyAmountFromJSON(data.base_fee) : null, + perUnitTotals: data.per_unit_totals ? billingPerUnitTotalsFromJSON(data.per_unit_totals) : undefined, + }; +}; + export const billingCreditsFromJSON = (data: BillingCreditsJSON): BillingCredits => { return { proration: data.proration diff --git a/packages/shared/src/types/billing.ts b/packages/shared/src/types/billing.ts index 786887fd2b6..1d9a8d66d3d 100644 --- a/packages/shared/src/types/billing.ts +++ b/packages/shared/src/types/billing.ts @@ -505,6 +505,35 @@ export type BillingPaymentChargeType = 'checkout' | 'recurring'; */ export type BillingPaymentStatus = 'pending' | 'paid' | 'failed'; +/** + * The `BillingPaymentTotals` type represents the per-payment cost breakdown, including any base fee + * and per-unit (for example, seats) subtotals. + * + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. + */ +export interface BillingPaymentTotals { + /** + * The price of the items before taxes, credits, or discounts are applied. + */ + subtotal: BillingMoneyAmount; + /** + * The total amount for the payment, including taxes and after credits/discounts are applied. + */ + grandTotal: BillingMoneyAmount; + /** + * The amount of tax included in the payment. + */ + taxTotal: BillingMoneyAmount; + /** + * The flat base fee charged on top of any per-unit fees. + */ + baseFee?: BillingMoneyAmount | null; + /** + * Per-unit cost breakdown for this payment (for example, seats). + */ + perUnitTotals?: BillingPerUnitTotal[]; +} + /** * The `BillingPaymentResource` type represents a payment attempt for a user or Organization. * @@ -547,6 +576,11 @@ export interface BillingPaymentResource extends ClerkResource { * The current status of the payment. */ status: BillingPaymentStatus; + /** + * Per-payment breakdown with optional base fee and per-unit (for example, seats) subtotals. + * Absent on older responses. + */ + totals?: BillingPaymentTotals | null; } /** diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index 7c91ed39498..66ed88b4d01 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -740,6 +740,19 @@ export interface BillingStatementGroupJSON extends ClerkResourceJSON { items: BillingPaymentJSON[]; } +/** + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. + * + * Per-payment cost breakdown including optional base fee and per-unit (for example, seats) subtotals. + */ +export interface BillingPaymentTotalsJSON { + subtotal: BillingMoneyAmountJSON; + grand_total: BillingMoneyAmountJSON; + tax_total: BillingMoneyAmountJSON; + base_fee?: BillingMoneyAmountJSON | null; + per_unit_totals?: BillingPerUnitTotalJSON[]; +} + /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ @@ -754,6 +767,11 @@ export interface BillingPaymentJSON extends ClerkResourceJSON { subscription_item: BillingSubscriptionItemJSON; charge_type: BillingPaymentChargeType; status: BillingPaymentStatus; + /** + * Per-payment breakdown with optional base fee and per-unit (for example, seats) + * subtotals. Absent on older responses. + */ + totals?: BillingPaymentTotalsJSON | null; } /** diff --git a/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx b/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx index 2ebd2973aa2..10a14492529 100644 --- a/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx +++ b/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx @@ -1,9 +1,10 @@ import { __internal_usePaymentAttemptQuery } from '@clerk/shared/react/index'; -import type { BillingSubscriptionItemResource } from '@clerk/shared/types'; +import type { BillingPaymentResource } from '@clerk/shared/types'; import { Alert } from '@/ui/elements/Alert'; import { Header } from '@/ui/elements/Header'; import { LineItems } from '@/ui/elements/LineItems'; +import { getSeatsPerUnitTotal, summarizeSeatCharges } from '@/ui/utils/billingPlanSeats'; import { formatDate } from '@/ui/utils/formatDate'; import { truncateWithEndVisible } from '@/ui/utils/truncateTextWithEndVisible'; @@ -42,8 +43,6 @@ export const PaymentAttemptPage = () => { enabled: Boolean(params.paymentAttemptId), }); - const subscriptionItem = paymentAttempt?.subscriptionItem; - if (isLoading) { return ( @@ -147,7 +146,7 @@ export const PaymentAttemptPage = () => { {paymentAttempt.status} - + { ); }; -function PaymentAttemptBody({ subscriptionItem }: { subscriptionItem: BillingSubscriptionItemResource | undefined }) { - if (!subscriptionItem) { +function PaymentAttemptBody({ paymentAttempt }: { paymentAttempt: BillingPaymentResource | undefined }) { + if (!paymentAttempt) { return null; } + const { subscriptionItem } = paymentAttempt; + const fee = subscriptionItem.planPeriod === 'month' ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -210,6 +211,9 @@ function PaymentAttemptBody({ subscriptionItem }: { subscriptionItem: BillingSub : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion subscriptionItem.plan.annualMonthlyFee!; + const seatsTotal = subscriptionItem.seats != null ? getSeatsPerUnitTotal(paymentAttempt.totals) : undefined; + const seatSummary = summarizeSeatCharges(seatsTotal); + return ( + {seatSummary && ( + + 0 ? ` (${seatSummary.included} included)` : '' + } × ${seatSummary.paidTier.feePerBlock.currencySymbol}${seatSummary.paidTier.feePerBlock.amountFormatted}`} + /> + + + )} ({ + amount, + amountFormatted: (amount / 100).toFixed(2), + currency: 'USD', + currencySymbol: '$', +}); + +const baseTotals = (): BillingPaymentTotals => ({ + subtotal: money(5000), + grandTotal: money(5000), + taxTotal: money(0), +}); + +describe('getSeatsPerUnitTotal', () => { + test('returns undefined when totals is null', () => { + expect(getSeatsPerUnitTotal(null)).toBeUndefined(); + }); + + test('returns undefined when totals is undefined', () => { + expect(getSeatsPerUnitTotal(undefined)).toBeUndefined(); + }); + + test('returns undefined when perUnitTotals is absent', () => { + expect(getSeatsPerUnitTotal(baseTotals())).toBeUndefined(); + }); + + test('returns undefined when no per-unit total has name "seats"', () => { + const totals: BillingPaymentTotals = { + ...baseTotals(), + perUnitTotals: [ + { + name: 'requests', + blockSize: 1, + tiers: [{ quantity: 100, feePerBlock: money(10), total: money(1000) }], + }, + ], + }; + expect(getSeatsPerUnitTotal(totals)).toBeUndefined(); + }); + + test('finds the seats per-unit total', () => { + const seats = { + name: 'seats', + blockSize: 1, + tiers: [{ quantity: 5, feePerBlock: money(1000), total: money(5000) }], + }; + const totals: BillingPaymentTotals = { ...baseTotals(), perUnitTotals: [seats] }; + expect(getSeatsPerUnitTotal(totals)).toBe(seats); + }); + + test('matches "seats" case-insensitively', () => { + const seats = { + name: 'Seats', + blockSize: 1, + tiers: [{ quantity: null, feePerBlock: money(0), total: money(0) }], + }; + const totals: BillingPaymentTotals = { ...baseTotals(), perUnitTotals: [seats] }; + expect(getSeatsPerUnitTotal(totals)).toBe(seats); + }); +}); + +describe('summarizeSeatCharges', () => { + test('returns null when seatsTotal is undefined', () => { + expect(summarizeSeatCharges(undefined)).toBeNull(); + }); + + test('returns null when no tier has a positive fee (plan with only a free tier / under-included scenario)', () => { + const seats: BillingPerUnitTotal = { + name: 'seats', + blockSize: 1, + tiers: [{ quantity: 10, feePerBlock: money(0), total: money(0) }], + }; + expect(summarizeSeatCharges(seats)).toBeNull(); + }); + + test('summarizes a paid-only plan (no included tier)', () => { + const seats: BillingPerUnitTotal = { + name: 'seats', + blockSize: 1, + tiers: [{ quantity: 5, feePerBlock: money(500), total: money(2500) }], + }; + const summary = summarizeSeatCharges(seats); + expect(summary).not.toBeNull(); + expect(summary!.used).toBe(5); + expect(summary!.included).toBe(0); + expect(summary!.paidTier.feePerBlock.amount).toBe(500); + expect(summary!.paidTier.total.amount).toBe(2500); + }); + + test('summarizes a mixed (included + paid) plan', () => { + const seats: BillingPerUnitTotal = { + name: 'seats', + blockSize: 1, + tiers: [ + { quantity: 3, feePerBlock: money(0), total: money(0) }, + { quantity: 2, feePerBlock: money(500), total: money(1000) }, + ], + }; + const summary = summarizeSeatCharges(seats); + expect(summary).not.toBeNull(); + expect(summary!.used).toBe(5); + expect(summary!.included).toBe(3); + expect(summary!.paidTier.feePerBlock.amount).toBe(500); + expect(summary!.paidTier.total.amount).toBe(1000); + }); + + test('treats null-quantity (unlimited) tiers as 0 in the count', () => { + const seats: BillingPerUnitTotal = { + name: 'seats', + blockSize: 1, + tiers: [{ quantity: null, feePerBlock: money(500), total: money(0) }], + }; + const summary = summarizeSeatCharges(seats); + expect(summary).not.toBeNull(); + expect(summary!.used).toBe(0); + expect(summary!.included).toBe(0); + }); +}); diff --git a/packages/ui/src/utils/billingPlanSeats.ts b/packages/ui/src/utils/billingPlanSeats.ts index e5732c2d530..481e24b1c3c 100644 --- a/packages/ui/src/utils/billingPlanSeats.ts +++ b/packages/ui/src/utils/billingPlanSeats.ts @@ -1,4 +1,11 @@ -import type { BillingPlanResource, BillingPlanUnitPrice, OrganizationResource } from '@clerk/shared/types'; +import type { + BillingPaymentTotals, + BillingPerUnitTotal, + BillingPerUnitTotalTier, + BillingPlanResource, + BillingPlanUnitPrice, + OrganizationResource, +} from '@clerk/shared/types'; /** * Given a plan, return the unit price for seats. @@ -17,6 +24,51 @@ export const getSeatUnitPrice = (plan: { unitPrices?: BillingPlanUnitPrice[] }): return null; }; +/** + * Given payment totals, return the per-unit total entry for seats, if present. + */ +export const getSeatsPerUnitTotal = ( + totals: BillingPaymentTotals | null | undefined, +): BillingPerUnitTotal | undefined => { + return totals?.perUnitTotals?.find(unitTotal => unitTotal.name.toLowerCase() === 'seats'); +}; + +export type SeatChargeSummary = { + /** Sum of `quantity` across all tiers (paid + included). */ + used: number; + /** Sum of `quantity` across $0 (included) tiers. `0` when the plan has no included seats. */ + included: number; + /** The first tier with `feePerBlock > 0`. Used for the rate and total. */ + paidTier: BillingPerUnitTotalTier; +}; + +/** + * Summarize a seats per-unit total for display in a payment breakdown. + * + * Returns `null` when there is no paid quantity to charge for — either because the plan has no + * per-seat pricing at all (only a seat limit), or because the org's occupied seats fall entirely + * within the included tier (right-sized by the backend so the only tier carries `feePerBlock = $0`). + * + * Returns `{ used, included, paidTier }` otherwise. `used` is the actual occupied seat count when a + * paid tier is present, because the backend right-sizes only when occupancy is entirely in the free + * tier; once a paid tier is crossed, tier quantities reflect real occupancy. + */ +export const summarizeSeatCharges = (seatsTotal: BillingPerUnitTotal | null | undefined): SeatChargeSummary | null => { + if (!seatsTotal) return null; + const paidTier = seatsTotal.tiers.find(tier => tier.feePerBlock.amount > 0); + if (!paidTier) return null; + let used = 0; + let included = 0; + for (const tier of seatsTotal.tiers) { + if (tier.quantity === null) continue; + used += tier.quantity; + if (tier.feePerBlock.amount === 0) { + included += tier.quantity; + } + } + return { used, included, paidTier }; +}; + /** * Given a plan, return the seat limit for the plan, or undefined if the plan does not have a seat limit. */ From 3b80b87583ce9240743529cc76ab4c2b51d6cd61 Mon Sep 17 00:00:00 2001 From: Keiran Flanigan Date: Wed, 13 May 2026 15:24:43 -0700 Subject: [PATCH 2/4] updated layout --- packages/localizations/src/en-US.ts | 1 + packages/shared/src/types/localization.ts | 1 + .../PaymentAttempts/PaymentAttemptPage.tsx | 23 ++++++++++++++----- packages/ui/src/utils/billingPlanSeats.ts | 6 +++-- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 090841b697f..b3546f2f40e 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -167,6 +167,7 @@ export const enUS: LocalizationResource = { }, reSubscribe: 'Resubscribe', seats: 'Seats', + seatsWithLimit: 'Seats ({{limit}} limit)', seeAllFeatures: 'See all features', startFreeTrial: 'Start free trial', startFreeTrial__days: 'Start {{days}}-day free trial', diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 49ce96a0616..a87768a1b5d 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -191,6 +191,7 @@ export type __internal_LocalizationResource = { keepSubscription: LocalizationValue; reSubscribe: LocalizationValue; seats: LocalizationValue; + seatsWithLimit: LocalizationValue<'limit'>; subscribe: LocalizationValue; startFreeTrial: LocalizationValue; startFreeTrial__days: LocalizationValue<'days'>; diff --git a/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx b/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx index 10a14492529..5727b82eacd 100644 --- a/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx +++ b/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx @@ -4,7 +4,7 @@ import type { BillingPaymentResource } from '@clerk/shared/types'; import { Alert } from '@/ui/elements/Alert'; import { Header } from '@/ui/elements/Header'; import { LineItems } from '@/ui/elements/LineItems'; -import { getSeatsPerUnitTotal, summarizeSeatCharges } from '@/ui/utils/billingPlanSeats'; +import { getPlanSeatLimit, getSeatsPerUnitTotal, summarizeSeatCharges } from '@/ui/utils/billingPlanSeats'; import { formatDate } from '@/ui/utils/formatDate'; import { truncateWithEndVisible } from '@/ui/utils/truncateTextWithEndVisible'; @@ -213,6 +213,8 @@ function PaymentAttemptBody({ paymentAttempt }: { paymentAttempt: BillingPayment const seatsTotal = subscriptionItem.seats != null ? getSeatsPerUnitTotal(paymentAttempt.totals) : undefined; const seatSummary = summarizeSeatCharges(seatsTotal); + const seatsChargeable = seatSummary ? seatSummary.used - seatSummary.included : 0; + const planSeatLimit = getPlanSeatLimit(subscriptionItem.plan); return ( {seatSummary && ( - + 0 ? ` (${seatSummary.included} included)` : '' - } × ${seatSummary.paidTier.feePerBlock.currencySymbol}${seatSummary.paidTier.feePerBlock.amountFormatted}`} + title={ + planSeatLimit != null + ? localizationKeys('billing.seatsWithLimit', { limit: planSeatLimit }) + : localizationKeys('billing.seats') + } + description={(() => { + const seatLabel = `${seatsChargeable} ${seatsChargeable === 1 ? 'seat' : 'seats'}`; + const rate = `${seatSummary.paidTier.feePerBlock.currencySymbol}${seatSummary.paidTier.feePerBlock.amountFormatted}/mo`; + return seatSummary.included > 0 + ? `${seatSummary.used} used − ${seatSummary.included} included → ${seatLabel} at ${rate}` + : `${seatLabel} at ${rate}`; + })()} /> diff --git a/packages/ui/src/utils/billingPlanSeats.ts b/packages/ui/src/utils/billingPlanSeats.ts index 481e24b1c3c..1b62277a0ca 100644 --- a/packages/ui/src/utils/billingPlanSeats.ts +++ b/packages/ui/src/utils/billingPlanSeats.ts @@ -70,7 +70,8 @@ export const summarizeSeatCharges = (seatsTotal: BillingPerUnitTotal | null | un }; /** - * Given a plan, return the seat limit for the plan, or undefined if the plan does not have a seat limit. + * Given a plan, return the seat limit for the plan in seats (not blocks), or `null` if seats are + * unlimited, or `undefined` if the plan has no seat-based pricing. */ export const getPlanSeatLimit = (plan: BillingPlanResource): number | null | undefined => { const seatUnitPrice = getSeatUnitPrice(plan); @@ -79,7 +80,8 @@ export const getPlanSeatLimit = (plan: BillingPlanResource): number | null | und return undefined; } - return seatUnitPrice.tiers[seatUnitPrice.tiers.length - 1]?.endsAfterBlock; + const lastTier = seatUnitPrice.tiers[seatUnitPrice.tiers.length - 1]; + return lastTier.endsAfterBlock != null ? lastTier.endsAfterBlock * seatUnitPrice.blockSize : null; }; /** From a6ba487fff912a446f26c4bf71f199f929527d56 Mon Sep 17 00:00:00 2001 From: Keiran Flanigan Date: Fri, 15 May 2026 13:31:35 -0700 Subject: [PATCH 3/4] address feedback --- packages/localizations/src/en-US.ts | 4 +++ packages/shared/src/types/localization.ts | 4 +++ .../PaymentAttempts/PaymentAttemptPage.tsx | 26 ++++++++++++++----- .../utils/__tests__/billingPlanSeats.test.ts | 6 ++--- packages/ui/src/utils/billingPlanSeats.ts | 19 ++++++++------ 5 files changed, 42 insertions(+), 17 deletions(-) diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 32d0af2329d..f823e0d2dac 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -168,6 +168,10 @@ export const enUS: LocalizationResource = { reSubscribe: 'Resubscribe', seats: 'Seats', seatsWithLimit: 'Seats ({{limit}} limit)', + seatBreakdownSingular: '1 seat at {{rate}}/mo', + seatBreakdownPlural: '{{chargeable}} seats at {{rate}}/mo', + seatBreakdownIncludedSingular: '{{used}} used − {{included}} included → 1 seat at {{rate}}/mo', + seatBreakdownIncludedPlural: '{{used}} used − {{included}} included → {{chargeable}} seats at {{rate}}/mo', seeAllFeatures: 'See all features', startFreeTrial: 'Start free trial', startFreeTrial__days: 'Start {{days}}-day free trial', diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 6626eb0205c..a05802a9c23 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -192,6 +192,10 @@ export type __internal_LocalizationResource = { reSubscribe: LocalizationValue; seats: LocalizationValue; seatsWithLimit: LocalizationValue<'limit'>; + seatBreakdownSingular: LocalizationValue<'rate'>; + seatBreakdownPlural: LocalizationValue<'chargeable' | 'rate'>; + seatBreakdownIncludedSingular: LocalizationValue<'used' | 'included' | 'rate'>; + seatBreakdownIncludedPlural: LocalizationValue<'used' | 'included' | 'chargeable' | 'rate'>; subscribe: LocalizationValue; startFreeTrial: LocalizationValue; startFreeTrial__days: LocalizationValue<'days'>; diff --git a/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx b/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx index 5727b82eacd..791fd032ce3 100644 --- a/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx +++ b/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx @@ -213,7 +213,7 @@ function PaymentAttemptBody({ paymentAttempt }: { paymentAttempt: BillingPayment const seatsTotal = subscriptionItem.seats != null ? getSeatsPerUnitTotal(paymentAttempt.totals) : undefined; const seatSummary = summarizeSeatCharges(seatsTotal); - const seatsChargeable = seatSummary ? seatSummary.used - seatSummary.included : 0; + const seatsChargeable = seatSummary ? seatSummary.totalSeats - seatSummary.included : 0; const planSeatLimit = getPlanSeatLimit(subscriptionItem.plan); return ( @@ -240,11 +240,25 @@ function PaymentAttemptBody({ paymentAttempt }: { paymentAttempt: BillingPayment : localizationKeys('billing.seats') } description={(() => { - const seatLabel = `${seatsChargeable} ${seatsChargeable === 1 ? 'seat' : 'seats'}`; - const rate = `${seatSummary.paidTier.feePerBlock.currencySymbol}${seatSummary.paidTier.feePerBlock.amountFormatted}/mo`; - return seatSummary.included > 0 - ? `${seatSummary.used} used − ${seatSummary.included} included → ${seatLabel} at ${rate}` - : `${seatLabel} at ${rate}`; + const rate = `${seatSummary.paidTier.feePerBlock.currencySymbol}${seatSummary.paidTier.feePerBlock.amountFormatted}`; + const isSingular = seatsChargeable === 1; + if (seatSummary.included > 0) { + return isSingular + ? localizationKeys('billing.seatBreakdownIncludedSingular', { + used: seatSummary.totalSeats, + included: seatSummary.included, + rate, + }) + : localizationKeys('billing.seatBreakdownIncludedPlural', { + used: seatSummary.totalSeats, + included: seatSummary.included, + chargeable: seatsChargeable, + rate, + }); + } + return isSingular + ? localizationKeys('billing.seatBreakdownSingular', { rate }) + : localizationKeys('billing.seatBreakdownPlural', { chargeable: seatsChargeable, rate }); })()} /> { }; const summary = summarizeSeatCharges(seats); expect(summary).not.toBeNull(); - expect(summary!.used).toBe(5); + expect(summary!.totalSeats).toBe(5); expect(summary!.included).toBe(0); expect(summary!.paidTier.feePerBlock.amount).toBe(500); expect(summary!.paidTier.total.amount).toBe(2500); @@ -103,7 +103,7 @@ describe('summarizeSeatCharges', () => { }; const summary = summarizeSeatCharges(seats); expect(summary).not.toBeNull(); - expect(summary!.used).toBe(5); + expect(summary!.totalSeats).toBe(5); expect(summary!.included).toBe(3); expect(summary!.paidTier.feePerBlock.amount).toBe(500); expect(summary!.paidTier.total.amount).toBe(1000); @@ -117,7 +117,7 @@ describe('summarizeSeatCharges', () => { }; const summary = summarizeSeatCharges(seats); expect(summary).not.toBeNull(); - expect(summary!.used).toBe(0); + expect(summary!.totalSeats).toBe(0); expect(summary!.included).toBe(0); }); }); diff --git a/packages/ui/src/utils/billingPlanSeats.ts b/packages/ui/src/utils/billingPlanSeats.ts index 1b62277a0ca..2e11e526eab 100644 --- a/packages/ui/src/utils/billingPlanSeats.ts +++ b/packages/ui/src/utils/billingPlanSeats.ts @@ -34,8 +34,13 @@ export const getSeatsPerUnitTotal = ( }; export type SeatChargeSummary = { - /** Sum of `quantity` across all tiers (paid + included). */ - used: number; + /** + * Sum of `quantity` across all tiers (paid + included) — the seats accounted for in this + * breakdown. In every case where this helper returns a non-null summary, the backend guarantees + * `totalSeats` equals the org's occupied seat count (right-sizing only inflates counts when + * occupancy is entirely within a free tier, which is a case this helper short-circuits on). + */ + totalSeats: number; /** Sum of `quantity` across $0 (included) tiers. `0` when the plan has no included seats. */ included: number; /** The first tier with `feePerBlock > 0`. Used for the rate and total. */ @@ -49,24 +54,22 @@ export type SeatChargeSummary = { * per-seat pricing at all (only a seat limit), or because the org's occupied seats fall entirely * within the included tier (right-sized by the backend so the only tier carries `feePerBlock = $0`). * - * Returns `{ used, included, paidTier }` otherwise. `used` is the actual occupied seat count when a - * paid tier is present, because the backend right-sizes only when occupancy is entirely in the free - * tier; once a paid tier is crossed, tier quantities reflect real occupancy. + * Returns `{ totalSeats, included, paidTier }` otherwise. */ export const summarizeSeatCharges = (seatsTotal: BillingPerUnitTotal | null | undefined): SeatChargeSummary | null => { if (!seatsTotal) return null; const paidTier = seatsTotal.tiers.find(tier => tier.feePerBlock.amount > 0); if (!paidTier) return null; - let used = 0; + let totalSeats = 0; let included = 0; for (const tier of seatsTotal.tiers) { if (tier.quantity === null) continue; - used += tier.quantity; + totalSeats += tier.quantity; if (tier.feePerBlock.amount === 0) { included += tier.quantity; } } - return { used, included, paidTier }; + return { totalSeats, included, paidTier }; }; /** From 4d085c482c3228a28bf6a1c3f1af9d01dcb24197 Mon Sep 17 00:00:00 2001 From: Keiran Flanigan Date: Fri, 15 May 2026 13:50:04 -0700 Subject: [PATCH 4/4] more feedback --- packages/localizations/src/en-US.ts | 6 +++--- packages/shared/src/types/localization.ts | 4 ++-- .../src/components/PaymentAttempts/PaymentAttemptPage.tsx | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index f823e0d2dac..af3356e8922 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -167,11 +167,11 @@ export const enUS: LocalizationResource = { }, reSubscribe: 'Resubscribe', seats: 'Seats', - seatsWithLimit: 'Seats ({{limit}} limit)', + seatsWithLimit: 'Seats (up to {{limit}})', seatBreakdownSingular: '1 seat at {{rate}}/mo', seatBreakdownPlural: '{{chargeable}} seats at {{rate}}/mo', - seatBreakdownIncludedSingular: '{{used}} used − {{included}} included → 1 seat at {{rate}}/mo', - seatBreakdownIncludedPlural: '{{used}} used − {{included}} included → {{chargeable}} seats at {{rate}}/mo', + seatBreakdownIncludedSingular: '1 seat at {{rate}}/mo ({{totalSeats}} total - {{included}} included)', + seatBreakdownIncludedPlural: '{{chargeable}} seats at {{rate}}/mo ({{totalSeats}} total - {{included}} included)', seeAllFeatures: 'See all features', startFreeTrial: 'Start free trial', startFreeTrial__days: 'Start {{days}}-day free trial', diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index a05802a9c23..d65458a8702 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -194,8 +194,8 @@ export type __internal_LocalizationResource = { seatsWithLimit: LocalizationValue<'limit'>; seatBreakdownSingular: LocalizationValue<'rate'>; seatBreakdownPlural: LocalizationValue<'chargeable' | 'rate'>; - seatBreakdownIncludedSingular: LocalizationValue<'used' | 'included' | 'rate'>; - seatBreakdownIncludedPlural: LocalizationValue<'used' | 'included' | 'chargeable' | 'rate'>; + seatBreakdownIncludedSingular: LocalizationValue<'totalSeats' | 'included' | 'rate'>; + seatBreakdownIncludedPlural: LocalizationValue<'totalSeats' | 'included' | 'chargeable' | 'rate'>; subscribe: LocalizationValue; startFreeTrial: LocalizationValue; startFreeTrial__days: LocalizationValue<'days'>; diff --git a/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx b/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx index 791fd032ce3..3b796b5feea 100644 --- a/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx +++ b/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx @@ -245,12 +245,12 @@ function PaymentAttemptBody({ paymentAttempt }: { paymentAttempt: BillingPayment if (seatSummary.included > 0) { return isSingular ? localizationKeys('billing.seatBreakdownIncludedSingular', { - used: seatSummary.totalSeats, + totalSeats: seatSummary.totalSeats, included: seatSummary.included, rate, }) : localizationKeys('billing.seatBreakdownIncludedPlural', { - used: seatSummary.totalSeats, + totalSeats: seatSummary.totalSeats, included: seatSummary.included, chargeable: seatsChargeable, rate,