From 877f976c51cfc58b404efe1c9dc3967ae4c7e0df Mon Sep 17 00:00:00 2001 From: Stephen Sibley Date: Tue, 7 Jul 2026 15:17:39 -0600 Subject: [PATCH 1/2] feat(ui): add downloadable PDF receipt on billing payment page Add a Download action to the payment attempt page in and billing. It opens the browser print dialog (Save as PDF) with a receipt that mirrors the emailed billing_receipt template. Reuses the existing usePrintable/PrintableComponent utility; the app logo is inlined as a data URI so it renders in the print iframe. A tooltip guides users to choose 'Save as PDF'. Adds a paymentAttemptDownloadButton appearance descriptor. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/bill-1034-download-receipt.md | 5 + .../PaymentAttempts/PaymentAttemptPage.tsx | 511 ++++++++++++++---- .../src/customizables/elementDescriptors.ts | 1 + packages/ui/src/internal/appearance.ts | 1 + 4 files changed, 412 insertions(+), 106 deletions(-) create mode 100644 .changeset/bill-1034-download-receipt.md diff --git a/.changeset/bill-1034-download-receipt.md b/.changeset/bill-1034-download-receipt.md new file mode 100644 index 00000000000..05fd353c817 --- /dev/null +++ b/.changeset/bill-1034-download-receipt.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': minor +--- + +Add a download action to the payment receipt (payment attempt) page in the `` and `` billing sections. Payers can now save a receipt as a PDF through the browser's print dialog instead of digging through their email. The downloaded receipt mirrors the emailed billing receipt (plan, seats, proration, credits, totals, payment details). Adds a new `paymentAttemptDownloadButton` appearance element for customization. diff --git a/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx b/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx index 55d57251b3d..eaf5ad895bb 100644 --- a/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx +++ b/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx @@ -1,15 +1,20 @@ -import { __internal_usePaymentAttemptQuery } from '@clerk/shared/react/index'; +import { getIdentifier } from '@clerk/shared/internal/clerk-js/user'; +import { __internal_usePaymentAttemptQuery, useOrganization, useUser } from '@clerk/shared/react'; import type { BillingPaymentResource } from '@clerk/shared/types'; +import { useEffect, useState } from 'react'; import { Alert } from '@/ui/elements/Alert'; import { Header } from '@/ui/elements/Header'; import { LineItems } from '@/ui/elements/LineItems'; import { ProfileCard } from '@/ui/elements/ProfileCard'; +import { Tooltip } from '@/ui/elements/Tooltip'; import { toNegativeAmount } from '@/ui/utils/billing'; import { getPlanSeatLimit, getSeatsPerUnitTotal, summarizeSeatCharges } from '@/ui/utils/billingPlanSeats'; import { formatDate } from '@/ui/utils/formatDate'; import { truncateWithEndVisible } from '@/ui/utils/truncateTextWithEndVisible'; +import { PrintableComponent, usePrintable } from '../../common'; +import { useEnvironment } from '../../contexts'; import { useSubscriberTypeContext, useSubscriberTypeLocalizationRoot } from '../../contexts/components'; import { Badge, @@ -25,14 +30,15 @@ import { useLocalizations, } from '../../customizables'; import { useClipboard } from '../../hooks'; -import { Checkmark, Copy } from '../../icons'; +import { ArrowDownTray, Checkmark, Copy } from '../../icons'; import { useRouter } from '../../router'; export const PaymentAttemptPage = () => { const { params, navigate } = useRouter(); const subscriberType = useSubscriberTypeContext(); const localizationRoot = useSubscriberTypeLocalizationRoot(); - const { t, translateError, $ } = useLocalizations(); + const { t, translateError } = useLocalizations(); + const { print, printableProps } = usePrintable(); const requesterType = subscriberType === 'organization' ? 'organization' : 'user'; const { @@ -89,116 +95,173 @@ export const PaymentAttemptPage = () => { ) : ( - ({ - borderWidth: t.borderWidths.$normal, - borderStyle: t.borderStyles.$solid, - borderColor: t.colors.$borderAlpha100, - borderRadius: t.radii.$lg, - overflow: 'clip', - })} - > - ({ - padding: t.space.$4, - background: t.colors.$neutralAlpha25, - display: 'flex', - justifyContent: 'space-between', - alignItems: 'flex-start', - })} - > - - - ({ - display: 'flex', - alignItems: 'center', - gap: t.space.$0x25, - color: t.colors.$colorMutedForeground, - })} - > - - - {truncateWithEndVisible(paymentAttempt.id)} - - - - - {paymentAttempt.status} - - - - + + {/* Off-screen copy rendered into an iframe for the browser print / "Save as PDF" dialog. + Mirrors the emailed billing receipt rather than the in-app card. */} + + + + + )} + + ); +}; + +function PaymentAttemptCard({ + paymentAttempt, + onDownload, +}: { + paymentAttempt: BillingPaymentResource; + onDownload?: () => void; +}) { + const { $ } = useLocalizations(); + + return ( + ({ + borderWidth: t.borderWidths.$normal, + borderStyle: t.borderStyles.$solid, + borderColor: t.colors.$borderAlpha100, + borderRadius: t.radii.$lg, + overflow: 'clip', + })} + > + ({ + padding: t.space.$4, + background: t.colors.$neutralAlpha25, + display: 'flex', + justifyContent: 'space-between', + alignItems: 'flex-start', + })} + > + + + ({ - paddingInline: t.space.$4, - paddingBlock: t.space.$3, - background: t.colors.$neutralAlpha25, - borderBlockStartWidth: t.borderWidths.$normal, - borderBlockStartStyle: t.borderStyles.$solid, - borderBlockStartColor: t.colors.$borderAlpha100, display: 'flex', - justifyContent: 'space-between', + alignItems: 'center', + gap: t.space.$0x25, + color: t.colors.$colorMutedForeground, })} > - - ({ - display: 'flex', - alignItems: 'center', - gap: t.space.$2x5, - })} + - - {paymentAttempt.amount.currency} - - - {$(paymentAttempt.amount)} - - - - - )} - + {truncateWithEndVisible(paymentAttempt.id)} + + + + ({ + display: 'flex', + alignItems: 'center', + gap: t.space.$2, + })} + > + {onDownload && ( + + + + + + + )} + + {paymentAttempt.status} + + + + + ({ + paddingInline: t.space.$4, + paddingBlock: t.space.$3, + background: t.colors.$neutralAlpha25, + borderBlockStartWidth: t.borderWidths.$normal, + borderBlockStartStyle: t.borderStyles.$solid, + borderBlockStartColor: t.colors.$borderAlpha100, + display: 'flex', + justifyContent: 'space-between', + })} + > + + ({ + display: 'flex', + alignItems: 'center', + gap: t.space.$2x5, + })} + > + + {paymentAttempt.amount.currency} + + + {$(paymentAttempt.amount)} + + + + ); -}; +} function PaymentAttemptBody({ paymentAttempt }: { paymentAttempt: BillingPaymentResource | undefined }) { const { $ } = useLocalizations(); @@ -306,6 +369,242 @@ function PaymentAttemptBody({ paymentAttempt }: { paymentAttempt: BillingPayment ); } +const RECEIPT_MUTED = '#757575'; + +function ReceiptDivider() { + return ; +} + +function ReceiptDetailRow({ label, value }: { label: string; value: string }) { + if (!value) { + return null; + } + return ( + + {label} + {value} + + ); +} + +/** + * Fetches an image and returns it as a base64 data URI, or `null` until ready / on failure. + * Inlining the logo this way means the printable receipt is fully self-contained, so it renders + * synchronously when `PrintableComponent` clones it into the print iframe (an `` pointing at a + * remote URL would not load before `window.print()` fires). + */ +function useImageDataUrl(url: string | undefined): string | null { + const [dataUrl, setDataUrl] = useState(null); + + useEffect(() => { + if (!url) { + setDataUrl(null); + return; + } + let cancelled = false; + void (async () => { + try { + const response = await fetch(url, { mode: 'cors' }); + const blob = await response.blob(); + const reader = new FileReader(); + reader.onloadend = () => { + if (!cancelled && typeof reader.result === 'string') { + setDataUrl(reader.result); + } + }; + reader.readAsDataURL(blob); + } catch { + if (!cancelled) { + setDataUrl(null); + } + } + })(); + return () => { + cancelled = true; + }; + }, [url]); + + return dataUrl; +} + +/** + * Print/PDF representation of a payment, styled to mirror the emailed billing receipt + * (`billing_receipt` template) rather than the in-app card. Rendered off-screen inside + * `PrintableComponent` and cloned into the browser's print dialog. + */ +function PaymentReceiptDocument({ paymentAttempt }: { paymentAttempt: BillingPaymentResource }) { + const { $ } = useLocalizations(); + const subscriberType = useSubscriberTypeContext(); + const { applicationName, logoImageUrl } = useEnvironment().displayConfig; + const logoDataUrl = useImageDataUrl(logoImageUrl); + const { user } = useUser(); + const { organization } = useOrganization(); + + 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; + const seatSummary = summarizeSeatCharges(seatsTotal); + const seatsChargeable = seatSummary ? seatSummary.totalSeats - seatSummary.included : 0; + const planSeatLimit = getPlanSeatLimit(subscriptionItem.plan); + + const proration = subscriptionItem.credits?.proration; + const hasProration = Boolean(proration && proration.amount.amount > 0); + const payerCredit = subscriptionItem.credits?.payer; + const hasPayerCredit = Boolean(payerCredit && payerCredit.appliedAmount.amount > 0); + + const receiptDate = paymentAttempt.paidAt ?? paymentAttempt.updatedAt; + const payerName = + subscriberType === 'organization' + ? (organization?.name ?? '') + : user + ? (user.fullName ?? getIdentifier(user) ?? '') + : ''; + + const paymentMethod = paymentAttempt.paymentMethod; + const paymentMethodLabel = paymentMethod + ? (paymentMethod.walletType ?? + [paymentMethod.cardType, paymentMethod.last4 ? `•••• ${paymentMethod.last4}` : null].filter(Boolean).join(' ')) + : ''; + + const subscriptionRange = subscriptionItem.periodStart + ? `(${formatDate(subscriptionItem.periodStart, 'long')}${ + subscriptionItem.periodEnd ? ` - ${formatDate(subscriptionItem.periodEnd, 'long')}` : '' + })` + : ''; + + return ( + + {logoDataUrl ? ( + {applicationName} + ) : ( + + {applicationName} + + )} + + + + Receipt + + {formatDate(receiptDate, 'long')} + + + + + + Plan + + {subscriptionItem.plan.name} + + {subscriptionItem.planPeriod === 'annual' ? 'x12 ' : ''} + {$(fee)} + + + + {seatSummary && ( + <> + + + {seatsChargeable} seats × {$(seatSummary.paidTier.feePerBlock)} per seat + + + {$(seatSummary.paidTier.total)} + + + + {planSeatLimit != null + ? `Seats (${seatSummary.totalSeats} of ${planSeatLimit} used)` + : `Seats (${seatSummary.totalSeats} used)`} + {seatSummary.included > 0 ? ` (${seatSummary.included} included)` : ''} + + + )} + + {hasProration && proration && ( + + + Proration + + Prorated credit for the remainder of your subscription. + + + + {$(toNegativeAmount(proration.amount))} + + + )} + + {hasPayerCredit && payerCredit && ( + + + Credit + + Applied from your credit balance. + + + + {$(toNegativeAmount(payerCredit.appliedAmount))} + + + )} + + + Total paid + + {$(paymentAttempt.amount)} + + + + + + + + + + + + + If you have questions about this receipt, please contact an administrator. + + + + ); +} + function CopyButton({ text, copyLabel = 'Copy' }: { text: string; copyLabel?: string }) { const { onCopy, hasCopied } = useClipboard(text); diff --git a/packages/ui/src/customizables/elementDescriptors.ts b/packages/ui/src/customizables/elementDescriptors.ts index 69eca6184fe..593f93678d8 100644 --- a/packages/ui/src/customizables/elementDescriptors.ts +++ b/packages/ui/src/customizables/elementDescriptors.ts @@ -446,6 +446,7 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'paymentAttemptFooterCurrency', 'paymentAttemptFooterValue', 'paymentAttemptCopyButton', + 'paymentAttemptDownloadButton', 'modalBackdrop', 'modalContent', diff --git a/packages/ui/src/internal/appearance.ts b/packages/ui/src/internal/appearance.ts index 71d20488744..0193fc3f775 100644 --- a/packages/ui/src/internal/appearance.ts +++ b/packages/ui/src/internal/appearance.ts @@ -580,6 +580,7 @@ export type ElementsConfig = { paymentAttemptFooterCurrency: WithOptions; paymentAttemptFooterValue: WithOptions; paymentAttemptCopyButton: WithOptions; + paymentAttemptDownloadButton: WithOptions; modalBackdrop: WithOptions; modalContent: WithOptions; From 429cbaf9c615fbeb17fcb1eb457d6fabb46aa4a3 Mon Sep 17 00:00:00 2001 From: Stephen Sibley Date: Tue, 7 Jul 2026 15:47:19 -0600 Subject: [PATCH 2/2] fix(ui): render printed receipt with inline styles PrintableComponent copies emotion styles by reading the style tags' text, which is empty in production (speedy mode inserts rules into the CSSOM). That stripped all styling from the printed receipt in cloud/production builds while working locally in dev. Style the receipt document with inline style attributes instead, so the layout travels with the cloned markup and renders in both dev and production. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PaymentAttempts/PaymentAttemptPage.tsx | 120 ++++++++++-------- 1 file changed, 64 insertions(+), 56 deletions(-) diff --git a/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx b/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx index eaf5ad895bb..d17081d5c43 100644 --- a/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx +++ b/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx @@ -371,8 +371,14 @@ function PaymentAttemptBody({ paymentAttempt }: { paymentAttempt: BillingPayment const RECEIPT_MUTED = '#757575'; +// NOTE: The receipt below is styled with inline `style` attributes rather than the `sx`/emotion +// system on purpose. `PrintableComponent` clones this markup into a print iframe and copies emotion +// styles by reading the `