Skip to content

Commit 454faa6

Browse files
committed
add seats info to payment attempts
1 parent 0f8aed2 commit 454faa6

9 files changed

Lines changed: 344 additions & 8 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/shared': minor
3+
'@clerk/clerk-js': minor
4+
'@clerk/ui': minor
5+
---
6+
7+
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.

packages/clerk-js/src/core/resources/BillingPayment.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ import type {
55
BillingPaymentMethodResource,
66
BillingPaymentResource,
77
BillingPaymentStatus,
8+
BillingPaymentTotals,
89
BillingSubscriptionItemResource,
910
} from '@clerk/shared/types';
1011

11-
import { billingMoneyAmountFromJSON } from '../../utils';
12+
import { billingMoneyAmountFromJSON, billingPaymentTotalsFromJSON } from '../../utils';
1213
import { unixEpochToDate } from '../../utils/date';
1314
import { BaseResource, BillingPaymentMethod, BillingSubscriptionItem } from './internal';
1415

@@ -22,6 +23,7 @@ export class BillingPayment extends BaseResource implements BillingPaymentResour
2223
subscriptionItem!: BillingSubscriptionItemResource;
2324
chargeType!: BillingPaymentChargeType;
2425
status!: BillingPaymentStatus;
26+
totals: BillingPaymentTotals | null = null;
2527

2628
constructor(data: BillingPaymentJSON) {
2729
super();
@@ -42,6 +44,7 @@ export class BillingPayment extends BaseResource implements BillingPaymentResour
4244
this.subscriptionItem = new BillingSubscriptionItem(data.subscription_item);
4345
this.chargeType = data.charge_type;
4446
this.status = data.status;
47+
this.totals = data.totals ? billingPaymentTotalsFromJSON(data.totals) : null;
4548
return this;
4649
}
4750
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { BillingMoneyAmountJSON, BillingPaymentTotalsJSON } from '@clerk/shared/types';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { billingPaymentTotalsFromJSON } from '../billing';
5+
6+
const moneyJSON = (amount: number): BillingMoneyAmountJSON => ({
7+
amount,
8+
amount_formatted: (amount / 100).toFixed(2),
9+
currency: 'USD',
10+
currency_symbol: '$',
11+
});
12+
13+
describe('billingPaymentTotalsFromJSON', () => {
14+
it('maps subtotal, grand_total, and tax_total', () => {
15+
const data: BillingPaymentTotalsJSON = {
16+
subtotal: moneyJSON(4500),
17+
grand_total: moneyJSON(5000),
18+
tax_total: moneyJSON(500),
19+
};
20+
21+
const totals = billingPaymentTotalsFromJSON(data);
22+
23+
expect(totals.subtotal.amount).toBe(4500);
24+
expect(totals.grandTotal.amount).toBe(5000);
25+
expect(totals.taxTotal.amount).toBe(500);
26+
expect(totals.baseFee).toBeNull();
27+
expect(totals.perUnitTotals).toBeUndefined();
28+
});
29+
30+
it('maps base_fee when present', () => {
31+
const data: BillingPaymentTotalsJSON = {
32+
subtotal: moneyJSON(5000),
33+
grand_total: moneyJSON(5000),
34+
tax_total: moneyJSON(0),
35+
base_fee: moneyJSON(1000),
36+
};
37+
38+
expect(billingPaymentTotalsFromJSON(data).baseFee?.amount).toBe(1000);
39+
});
40+
41+
it('maps per_unit_totals tiers with snake_case → camelCase conversion', () => {
42+
const data: BillingPaymentTotalsJSON = {
43+
subtotal: moneyJSON(5000),
44+
grand_total: moneyJSON(5000),
45+
tax_total: moneyJSON(0),
46+
per_unit_totals: [
47+
{
48+
name: 'seats',
49+
block_size: 1,
50+
tiers: [
51+
{ quantity: 5, fee_per_block: moneyJSON(1000), total: moneyJSON(5000) },
52+
{ quantity: null, fee_per_block: moneyJSON(0), total: moneyJSON(0) },
53+
],
54+
},
55+
],
56+
};
57+
58+
const totals = billingPaymentTotalsFromJSON(data);
59+
60+
expect(totals.perUnitTotals).toHaveLength(1);
61+
expect(totals.perUnitTotals?.[0].name).toBe('seats');
62+
expect(totals.perUnitTotals?.[0].blockSize).toBe(1);
63+
expect(totals.perUnitTotals?.[0].tiers[0]).toMatchObject({
64+
quantity: 5,
65+
feePerBlock: { amount: 1000 },
66+
total: { amount: 5000 },
67+
});
68+
expect(totals.perUnitTotals?.[0].tiers[1].quantity).toBeNull();
69+
});
70+
});

packages/clerk-js/src/utils/billing.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import type {
55
BillingCreditsJSON,
66
BillingMoneyAmount,
77
BillingMoneyAmountJSON,
8+
BillingPaymentTotals,
9+
BillingPaymentTotalsJSON,
810
BillingPerUnitTotal,
911
BillingPerUnitTotalJSON,
1012
BillingStatementTotals,
@@ -32,6 +34,16 @@ const billingPerUnitTotalsFromJSON = (data: BillingPerUnitTotalJSON[]): BillingP
3234
}));
3335
};
3436

37+
export const billingPaymentTotalsFromJSON = (data: BillingPaymentTotalsJSON): BillingPaymentTotals => {
38+
return {
39+
subtotal: billingMoneyAmountFromJSON(data.subtotal),
40+
grandTotal: billingMoneyAmountFromJSON(data.grand_total),
41+
taxTotal: billingMoneyAmountFromJSON(data.tax_total),
42+
baseFee: data.base_fee ? billingMoneyAmountFromJSON(data.base_fee) : null,
43+
perUnitTotals: data.per_unit_totals ? billingPerUnitTotalsFromJSON(data.per_unit_totals) : undefined,
44+
};
45+
};
46+
3547
export const billingCreditsFromJSON = (data: BillingCreditsJSON): BillingCredits => {
3648
return {
3749
proration: data.proration

packages/shared/src/types/billing.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,35 @@ export type BillingPaymentChargeType = 'checkout' | 'recurring';
505505
*/
506506
export type BillingPaymentStatus = 'pending' | 'paid' | 'failed';
507507

508+
/**
509+
* The `BillingPaymentTotals` type represents the per-payment cost breakdown, including any base fee
510+
* and per-unit (for example, seats) subtotals.
511+
*
512+
* @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.
513+
*/
514+
export interface BillingPaymentTotals {
515+
/**
516+
* The price of the items before taxes, credits, or discounts are applied.
517+
*/
518+
subtotal: BillingMoneyAmount;
519+
/**
520+
* The total amount for the payment, including taxes and after credits/discounts are applied.
521+
*/
522+
grandTotal: BillingMoneyAmount;
523+
/**
524+
* The amount of tax included in the payment.
525+
*/
526+
taxTotal: BillingMoneyAmount;
527+
/**
528+
* The flat base fee charged on top of any per-unit fees.
529+
*/
530+
baseFee?: BillingMoneyAmount | null;
531+
/**
532+
* Per-unit cost breakdown for this payment (for example, seats).
533+
*/
534+
perUnitTotals?: BillingPerUnitTotal[];
535+
}
536+
508537
/**
509538
* The `BillingPaymentResource` type represents a payment attempt for a user or Organization.
510539
*
@@ -547,6 +576,11 @@ export interface BillingPaymentResource extends ClerkResource {
547576
* The current status of the payment.
548577
*/
549578
status: BillingPaymentStatus;
579+
/**
580+
* Per-payment breakdown with optional base fee and per-unit (for example, seats) subtotals.
581+
* Absent on older responses.
582+
*/
583+
totals?: BillingPaymentTotals | null;
550584
}
551585

552586
/**

packages/shared/src/types/json.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,19 @@ export interface BillingStatementGroupJSON extends ClerkResourceJSON {
740740
items: BillingPaymentJSON[];
741741
}
742742

743+
/**
744+
* @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.
745+
*
746+
* Per-payment cost breakdown including optional base fee and per-unit (for example, seats) subtotals.
747+
*/
748+
export interface BillingPaymentTotalsJSON {
749+
subtotal: BillingMoneyAmountJSON;
750+
grand_total: BillingMoneyAmountJSON;
751+
tax_total: BillingMoneyAmountJSON;
752+
base_fee?: BillingMoneyAmountJSON | null;
753+
per_unit_totals?: BillingPerUnitTotalJSON[];
754+
}
755+
743756
/**
744757
* @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.
745758
*/
@@ -754,6 +767,11 @@ export interface BillingPaymentJSON extends ClerkResourceJSON {
754767
subscription_item: BillingSubscriptionItemJSON;
755768
charge_type: BillingPaymentChargeType;
756769
status: BillingPaymentStatus;
770+
/**
771+
* Per-payment breakdown with optional base fee and per-unit (for example, seats)
772+
* subtotals. Absent on older responses.
773+
*/
774+
totals?: BillingPaymentTotalsJSON | null;
757775
}
758776

759777
/**

packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { __internal_usePaymentAttemptQuery } from '@clerk/shared/react/index';
2-
import type { BillingSubscriptionItemResource } from '@clerk/shared/types';
2+
import type { BillingPaymentResource } from '@clerk/shared/types';
33

44
import { Alert } from '@/ui/elements/Alert';
55
import { Header } from '@/ui/elements/Header';
66
import { LineItems } from '@/ui/elements/LineItems';
7+
import { getSeatsPerUnitTotal, summarizeSeatCharges } from '@/ui/utils/billingPlanSeats';
78
import { formatDate } from '@/ui/utils/formatDate';
89
import { truncateWithEndVisible } from '@/ui/utils/truncateTextWithEndVisible';
910

@@ -42,8 +43,6 @@ export const PaymentAttemptPage = () => {
4243
enabled: Boolean(params.paymentAttemptId),
4344
});
4445

45-
const subscriptionItem = paymentAttempt?.subscriptionItem;
46-
4746
if (isLoading) {
4847
return (
4948
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
@@ -147,7 +146,7 @@ export const PaymentAttemptPage = () => {
147146
{paymentAttempt.status}
148147
</Badge>
149148
</Box>
150-
<PaymentAttemptBody subscriptionItem={subscriptionItem} />
149+
<PaymentAttemptBody paymentAttempt={paymentAttempt} />
151150
<Box
152151
elementDescriptor={descriptors.paymentAttemptFooter}
153152
as='footer'
@@ -198,18 +197,23 @@ export const PaymentAttemptPage = () => {
198197
);
199198
};
200199

201-
function PaymentAttemptBody({ subscriptionItem }: { subscriptionItem: BillingSubscriptionItemResource | undefined }) {
202-
if (!subscriptionItem) {
200+
function PaymentAttemptBody({ paymentAttempt }: { paymentAttempt: BillingPaymentResource | undefined }) {
201+
if (!paymentAttempt) {
203202
return null;
204203
}
205204

205+
const { subscriptionItem } = paymentAttempt;
206+
206207
const fee =
207208
subscriptionItem.planPeriod === 'month'
208209
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
209210
subscriptionItem.plan.fee!
210211
: // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
211212
subscriptionItem.plan.annualMonthlyFee!;
212213

214+
const seatsTotal = subscriptionItem.seats != null ? getSeatsPerUnitTotal(paymentAttempt.totals) : undefined;
215+
const seatSummary = summarizeSeatCharges(seatsTotal);
216+
213217
return (
214218
<Box
215219
elementDescriptor={descriptors.paymentAttemptBody}
@@ -225,6 +229,19 @@ function PaymentAttemptBody({ subscriptionItem }: { subscriptionItem: BillingSub
225229
text={`${fee.currencySymbol}${fee.amountFormatted}`}
226230
/>
227231
</LineItems.Group>
232+
{seatSummary && (
233+
<LineItems.Group variant='tertiary'>
234+
<LineItems.Title
235+
title={localizationKeys('billing.seats')}
236+
description={`${seatSummary.used} ${seatSummary.used === 1 ? 'seat' : 'seats'}${
237+
seatSummary.included > 0 ? ` (${seatSummary.included} included)` : ''
238+
} × ${seatSummary.paidTier.feePerBlock.currencySymbol}${seatSummary.paidTier.feePerBlock.amountFormatted}`}
239+
/>
240+
<LineItems.Description
241+
text={`${seatSummary.paidTier.total.currencySymbol}${seatSummary.paidTier.total.amountFormatted}`}
242+
/>
243+
</LineItems.Group>
244+
)}
228245
<LineItems.Group
229246
borderTop
230247
variant='tertiary'

0 commit comments

Comments
 (0)