-
Notifications
You must be signed in to change notification settings - Fork 452
feat(clerk-js,shared,ui): Add seats info to payment attempts page #8527
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/per-seat-costs
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is actually our |
||
| 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; | ||
| } | ||
|
|
||
| /** | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 { getPlanSeatLimit, 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 ( | ||
| <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}> | ||
|
|
@@ -147,7 +146,7 @@ export const PaymentAttemptPage = () => { | |
| {paymentAttempt.status} | ||
| </Badge> | ||
| </Box> | ||
| <PaymentAttemptBody subscriptionItem={subscriptionItem} /> | ||
| <PaymentAttemptBody paymentAttempt={paymentAttempt} /> | ||
| <Box | ||
| elementDescriptor={descriptors.paymentAttemptFooter} | ||
| as='footer' | ||
|
|
@@ -198,18 +197,25 @@ export const PaymentAttemptPage = () => { | |
| ); | ||
| }; | ||
|
|
||
| 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 | ||
| subscriptionItem.plan.fee! | ||
| : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion | ||
| subscriptionItem.plan.annualMonthlyFee!; | ||
|
|
||
| const seatsTotal = subscriptionItem.seats != null ? getSeatsPerUnitTotal(paymentAttempt.totals) : undefined; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think in this situation a |
||
| const seatSummary = summarizeSeatCharges(seatsTotal); | ||
| const seatsChargeable = seatSummary ? seatSummary.used - seatSummary.included : 0; | ||
| const planSeatLimit = getPlanSeatLimit(subscriptionItem.plan); | ||
|
|
||
| return ( | ||
| <Box | ||
| elementDescriptor={descriptors.paymentAttemptBody} | ||
|
|
@@ -225,6 +231,28 @@ function PaymentAttemptBody({ subscriptionItem }: { subscriptionItem: BillingSub | |
| text={`${fee.currencySymbol}${fee.amountFormatted}`} | ||
| /> | ||
| </LineItems.Group> | ||
| {seatSummary && ( | ||
| <LineItems.Group> | ||
| <LineItems.Title | ||
| 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}` | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think a comma instead of an arrow here would probably be better (especially since people will be writing this string in their localizations): |
||
| : `${seatLabel} at ${rate}`; | ||
|
Comment on lines
+245
to
+247
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should be done through the localization system. |
||
| })()} | ||
| /> | ||
| <LineItems.Description | ||
| prefix={subscriptionItem.planPeriod === 'annual' ? 'x12' : undefined} | ||
| text={`${seatSummary.paidTier.total.currencySymbol}${seatSummary.paidTier.total.amountFormatted}`} | ||
| /> | ||
| </LineItems.Group> | ||
|
aeliox marked this conversation as resolved.
|
||
| )} | ||
| <LineItems.Group | ||
| borderTop | ||
| variant='tertiary' | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
would it make sense to use our existing
upToSeatslocalization value? It'd read a little differentUp to X seats