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