Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 469
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
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
454faa63b80b87caaf316a6ba4874d085c40dac161File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
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 |
|---|---|---|
| @@ -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; | ||
aeliox marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| const seatSummary = summarizeSeatCharges(seatsTotal); | ||
| const seatsChargeable = seatSummary ? seatSummary.totalSeats - seatSummary.included : 0; | ||
| const planSeatLimit = getPlanSeatLimit(subscriptionItem.plan); | ||
| return ( | ||
| <Box | ||
| elementDescriptor={descriptors.paymentAttemptBody} | ||
| @@ -225,6 +231,42 @@ 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 rate = `${seatSummary.paidTier.feePerBlock.currencySymbol}${seatSummary.paidTier.feePerBlock.amountFormatted}`; | ||
| const isSingular = seatsChargeable === 1; | ||
| if (seatSummary.included > 0) { | ||
| return isSingular | ||
| ? localizationKeys('billing.seatBreakdownIncludedSingular', { | ||
| totalSeats: seatSummary.totalSeats, | ||
| included: seatSummary.included, | ||
| rate, | ||
| }) | ||
| : localizationKeys('billing.seatBreakdownIncludedPlural', { | ||
| totalSeats: seatSummary.totalSeats, | ||
| included: seatSummary.included, | ||
| chargeable: seatsChargeable, | ||
| rate, | ||
| }); | ||
| } | ||
| return isSingular | ||
| ? localizationKeys('billing.seatBreakdownSingular', { rate }) | ||
| : localizationKeys('billing.seatBreakdownPlural', { chargeable: seatsChargeable, rate }); | ||
| })()} | ||
| /> | ||
| <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.
Uh oh!There was an error while loading. Please reload this page. | ||
| )} | ||
| <LineItems.Group | ||
| borderTop | ||
| variant='tertiary' | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.