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.
*/