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..d17081d5c43 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,250 @@ 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 `