From 004459ee5a144a93d3246e5433529df66b14a5e5 Mon Sep 17 00:00:00 2001 From: Keiran Flanigan Date: Thu, 5 Jun 2025 11:14:05 -0700 Subject: [PATCH 1/4] WIP payment history --- .../core/modules/commerce/CommerceBilling.ts | 23 ++ .../src/core/resources/CommercePayment.ts | 44 ++++ .../src/core/resources/CommerceStatement.ts | 35 +-- .../clerk-js/src/core/resources/internal.ts | 1 + .../OrganizationBillingPage.tsx | 11 +- .../OrganizationPaymentAttemptPage.tsx | 10 + .../OrganizationProfileRoutes.tsx | 7 + .../PaymentAttempts/PaymentAttemptPage.tsx | 192 ++++++++++++++++ .../PaymentAttempts/PaymentAttemptsList.tsx | 205 ++++++++++++++++++ .../ui/components/PaymentAttempts/index.ts | 2 + .../PaymentSources/AddPaymentSource.tsx | 3 + .../ui/components/UserProfile/BillingPage.tsx | 9 +- .../UserProfile/UserProfileRoutes.tsx | 7 + .../contexts/components/PaymentAttempts.tsx | 17 ++ .../src/ui/contexts/components/Plans.tsx | 19 ++ .../src/ui/contexts/components/index.ts | 1 + packages/localizations/src/en-US.ts | 2 + packages/types/src/commerce.ts | 32 +-- packages/types/src/json.ts | 3 + packages/types/src/localization.ts | 2 + 20 files changed, 575 insertions(+), 50 deletions(-) create mode 100644 packages/clerk-js/src/core/resources/CommercePayment.ts create mode 100644 packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationPaymentAttemptPage.tsx create mode 100644 packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx create mode 100644 packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptsList.tsx create mode 100644 packages/clerk-js/src/ui/components/PaymentAttempts/index.ts create mode 100644 packages/clerk-js/src/ui/contexts/components/PaymentAttempts.tsx diff --git a/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts b/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts index d583693450c..5426968522b 100644 --- a/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts +++ b/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts @@ -2,6 +2,8 @@ import type { ClerkPaginatedResponse, CommerceBillingNamespace, CommerceCheckoutJSON, + CommercePaymentJSON, + CommercePaymentResource, CommercePlanResource, CommerceProductJSON, CommerceStatementJSON, @@ -9,6 +11,7 @@ import type { CommerceSubscriptionJSON, CommerceSubscriptionResource, CreateCheckoutParams, + GetPaymentAttemptsParams, GetPlansParams, GetStatementsParams, GetSubscriptionsParams, @@ -18,6 +21,7 @@ import { convertPageToOffsetSearchParams } from '../../../utils/convertPageToOff import { BaseResource, CommerceCheckout, + CommercePayment, CommercePlan, CommerceStatement, CommerceSubscription, @@ -73,6 +77,25 @@ export class CommerceBilling implements CommerceBillingNamespace { }); }; + getPaymentAttempts = async ( + params: GetPaymentAttemptsParams, + ): Promise> => { + const { orgId, ...rest } = params; + + return await BaseResource._fetch({ + path: orgId ? `/organizations/${orgId}/commerce/payment_attempts` : `/me/commerce/payment_attempts`, + method: 'GET', + search: convertPageToOffsetSearchParams(rest), + }).then(res => { + const { data: payments, total_count } = res as unknown as ClerkPaginatedResponse; + + return { + total_count, + data: payments.map(payment => new CommercePayment(payment)), + }; + }); + }; + startCheckout = async (params: CreateCheckoutParams) => { const { orgId, ...rest } = params; const json = ( diff --git a/packages/clerk-js/src/core/resources/CommercePayment.ts b/packages/clerk-js/src/core/resources/CommercePayment.ts new file mode 100644 index 00000000000..5a2933657d7 --- /dev/null +++ b/packages/clerk-js/src/core/resources/CommercePayment.ts @@ -0,0 +1,44 @@ +import type { + CommerceMoney, + CommercePaymentChargeType, + CommercePaymentJSON, + CommercePaymentResource, + CommercePaymentStatus, +} from '@clerk/types'; + +import { commerceMoneyFromJSON } from '../../utils'; +import { BaseResource, CommercePaymentSource, CommerceSubscription } from './internal'; + +export class CommercePayment extends BaseResource implements CommercePaymentResource { + id!: string; + amount!: CommerceMoney; + failedAt?: number; + paidAt?: number; + updatedAt!: number; + paymentSource!: CommercePaymentSource; + subscription!: CommerceSubscription; + chargeType!: CommercePaymentChargeType; + status!: CommercePaymentStatus; + + constructor(data: CommercePaymentJSON) { + super(); + this.fromJSON(data); + } + + protected fromJSON(data: CommercePaymentJSON | null): this { + if (!data) { + return this; + } + + this.id = data.id; + this.amount = commerceMoneyFromJSON(data.amount); + this.paidAt = data.paid_at; + this.failedAt = data.failed_at; + this.updatedAt = data.updated_at; + this.paymentSource = new CommercePaymentSource(data.payment_source); + this.subscription = new CommerceSubscription(data.subscription); + this.chargeType = data.charge_type; + this.status = data.status; + return this; + } +} diff --git a/packages/clerk-js/src/core/resources/CommerceStatement.ts b/packages/clerk-js/src/core/resources/CommerceStatement.ts index 4c91723ef92..b51f6b71957 100644 --- a/packages/clerk-js/src/core/resources/CommerceStatement.ts +++ b/packages/clerk-js/src/core/resources/CommerceStatement.ts @@ -1,8 +1,4 @@ import type { - CommerceMoney, - CommercePaymentChargeType, - CommercePaymentJSON, - CommercePaymentStatus, CommerceStatementGroupJSON, CommerceStatementJSON, CommerceStatementResource, @@ -10,8 +6,8 @@ import type { CommerceStatementTotals, } from '@clerk/types'; -import { commerceMoneyFromJSON, commerceTotalsFromJSON } from '../../utils'; -import { BaseResource, CommercePaymentSource, CommerceSubscription } from './internal'; +import { commerceTotalsFromJSON } from '../../utils'; +import { BaseResource, CommercePayment } from './internal'; export class CommerceStatement extends BaseResource implements CommerceStatementResource { id!: string; @@ -59,30 +55,3 @@ export class CommerceStatementGroup { return this; } } - -export class CommercePayment { - id!: string; - amount!: CommerceMoney; - paymentSource!: CommercePaymentSource; - subscription!: CommerceSubscription; - chargeType!: CommercePaymentChargeType; - status!: CommercePaymentStatus; - - constructor(data: CommercePaymentJSON) { - this.fromJSON(data); - } - - protected fromJSON(data: CommercePaymentJSON | null): this { - if (!data) { - return this; - } - - this.id = data.id; - this.amount = commerceMoneyFromJSON(data.amount); - this.paymentSource = new CommercePaymentSource(data.payment_source); - this.subscription = new CommerceSubscription(data.subscription); - this.chargeType = data.charge_type; - this.status = data.status; - return this; - } -} diff --git a/packages/clerk-js/src/core/resources/internal.ts b/packages/clerk-js/src/core/resources/internal.ts index 6ae1cb0c007..f9ce0f4e82a 100644 --- a/packages/clerk-js/src/core/resources/internal.ts +++ b/packages/clerk-js/src/core/resources/internal.ts @@ -7,6 +7,7 @@ export * from './Client'; export * from './CommerceCheckout'; export * from './CommerceFeature'; export * from './CommerceStatement'; +export * from './CommercePayment'; export * from './CommercePaymentSource'; export * from './CommercePlan'; export * from './CommerceProduct'; diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationBillingPage.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationBillingPage.tsx index 58bde2cca08..a5b0c46336a 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationBillingPage.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationBillingPage.tsx @@ -7,14 +7,15 @@ import { Protect } from '../../common'; import { SubscriberTypeContext } from '../../contexts'; import { Col, descriptors, localizationKeys } from '../../customizables'; import { useTabState } from '../../hooks/useTabState'; +import { PaymentAttemptsList } from '../PaymentAttempts'; import { PaymentSources } from '../PaymentSources'; import { StatementsList } from '../Statements'; import { SubscriptionsList } from '../Subscriptions'; const orgTabMap = { - 0: 'plans', + 0: 'subscriptions', 1: 'statements', - 2: 'payment-methods', + 2: 'payment-history', } as const; const OrganizationBillingPageInternal = withCardStateProvider(() => { @@ -50,6 +51,9 @@ const OrganizationBillingPageInternal = withCardStateProvider(() => { localizationKey={localizationKeys('organizationProfile.billingPage.start.headerTitle__subscriptions')} /> + @@ -69,6 +73,9 @@ const OrganizationBillingPageInternal = withCardStateProvider(() => { + + + diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationPaymentAttemptPage.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationPaymentAttemptPage.tsx new file mode 100644 index 00000000000..d1204914fb5 --- /dev/null +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationPaymentAttemptPage.tsx @@ -0,0 +1,10 @@ +import { SubscriberTypeContext } from '../../contexts'; +import { PaymentAttemptPage } from '../PaymentAttempts'; + +export const OrganizationPaymentAttemptPage = () => { + return ( + + + + ); +}; diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx index 1b51625a650..e506bc9cb80 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx @@ -6,6 +6,7 @@ import { useEnvironment, useOrganizationProfileContext } from '../../contexts'; import { Route, Switch } from '../../router'; import { OrganizationGeneralPage } from './OrganizationGeneralPage'; import { OrganizationMembers } from './OrganizationMembers'; +import { OrganizationPaymentAttemptPage } from './OrganizationPaymentAttemptPage'; import { OrganizationPlansPage } from './OrganizationPlansPage'; import { OrganizationStatementPage } from './OrganizationStatementPage'; @@ -85,6 +86,12 @@ export const OrganizationProfileRoutes = () => { + + {/* TODO(@commerce): Should this be lazy loaded ? */} + + + + diff --git a/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx b/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx new file mode 100644 index 00000000000..fa396e4494b --- /dev/null +++ b/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx @@ -0,0 +1,192 @@ +import { Header } from '@/ui/elements/Header'; + +import { usePaymentAttemptsContext, useStatements } from '../../contexts'; +import { Badge, Box, Button, descriptors, Heading, Icon, Span, Spinner, Text } from '../../customizables'; +import { useClipboard } from '../../hooks'; +import { Check, Copy } from '../../icons'; +import { useRouter } from '../../router'; +import { truncateWithEndVisible } from '../../utils/truncateTextWithEndVisible'; + +export const PaymentAttemptPage = () => { + const { params, navigate } = useRouter(); + const { isLoading } = useStatements(); + const { getPaymentAttemptById } = usePaymentAttemptsContext(); + + const paymentAttempt = params.paymentAttemptId ? getPaymentAttemptById(params.paymentAttemptId) : null; + + if (isLoading) { + return ( + + + + ); + } + + if (!paymentAttempt) { + return Payment attempt not found; + } + + return ( + <> + ({ + borderBlockEndWidth: t.borderWidths.$normal, + borderBlockEndStyle: t.borderStyles.$solid, + borderBlockEndColor: t.colors.$neutralAlpha100, + marginBlockEnd: t.space.$4, + paddingBlockEnd: t.space.$4, + })} + > + void navigate('../../', { searchParams: new URLSearchParams('tab=payment-history') })} + > + + + + + ({ + borderWidth: t.borderWidths.$normal, + borderStyle: t.borderStyles.$solid, + borderColor: t.colors.$neutralAlpha100, + 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.$colorTextSecondary, + })} + > + + + {truncateWithEndVisible(paymentAttempt.id)} + + + + + {paymentAttempt.status} + + + (CONTENT) + ({ + paddingInline: t.space.$4, + paddingBlock: t.space.$3, + background: t.colors.$neutralAlpha25, + borderBlockStartWidth: t.borderWidths.$normal, + borderBlockStartStyle: t.borderStyles.$solid, + borderBlockStartColor: t.colors.$neutralAlpha100, + display: 'flex', + justifyContent: 'space-between', + })} + > + + ({ + display: 'flex', + alignItems: 'center', + gap: t.space.$2x5, + })} + > + + USD + + + {paymentAttempt.amount.currencySymbol} + {paymentAttempt.amount.amountFormatted} + + + + + + ); +}; + +function CopyButton({ text, copyLabel = 'Copy' }: { text: string; copyLabel?: string }) { + const { onCopy, hasCopied } = useClipboard(text); + + return ( + + ); +} diff --git a/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptsList.tsx b/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptsList.tsx new file mode 100644 index 00000000000..76ec97c9413 --- /dev/null +++ b/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptsList.tsx @@ -0,0 +1,205 @@ +import type { CommercePaymentResource } from '@clerk/types'; +import React from 'react'; + +import { Pagination } from '@/ui/elements/Pagination'; + +import { usePaymentAttempts } from '../../../ui/contexts'; +import type { LocalizationKey } from '../../customizables'; +import { Badge, Col, descriptors, Flex, Spinner, Table, Tbody, Td, Text, Th, Thead, Tr } from '../../customizables'; +import { useRouter } from '../../router'; +import type { PropsOfComponent } from '../../styledSystem'; +import { truncateWithEndVisible } from '../../utils/truncateTextWithEndVisible'; + +/* ------------------------------------------------------------------------------------------------- + * PaymentAttemptsList + * -----------------------------------------------------------------------------------------------*/ + +export const PaymentAttemptsList = () => { + const { data: paymentAttempts, isLoading } = usePaymentAttempts(); + + return ( + {}} + itemCount={paymentAttempts?.total_count || 0} + pageCount={1} + itemsPerPage={10} + isLoading={isLoading} + emptyStateLocalizationKey='No payment history' + headers={['Date', 'Amount', 'Status']} + rows={(paymentAttempts?.data || []).map(i => ( + + ))} + /> + ); +}; + +const PaymentAttemptsListRow = ({ paymentAttempt }: { paymentAttempt: CommercePaymentResource }) => { + const { id, amount, failedAt, paidAt, updatedAt, status } = paymentAttempt; + const { navigate } = useRouter(); + const handleClick = () => { + void navigate(`payment-attempt/${id}`); + }; + return ( + + + + {new Date(paidAt || failedAt || updatedAt).toLocaleString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + })} + + ({ marginTop: t.space.$0x5 })} + > + {truncateWithEndVisible(id)} + + + + + {amount.currencySymbol} + {amount.amountFormatted} + + + + + {status} + + + + ); +}; + +/* ------------------------------------------------------------------------------------------------- + * DataTable + * -----------------------------------------------------------------------------------------------*/ + +type DataTableProps = { + headers: (LocalizationKey | string)[]; + rows: React.ReactNode[]; + isLoading?: boolean; + page: number; + onPageChange: (page: number) => void; + itemCount: number; + emptyStateLocalizationKey: LocalizationKey | string; + pageCount: number; + itemsPerPage: number; +}; + +const DataTable = (props: DataTableProps) => { + const { + headers, + page, + onPageChange, + rows, + isLoading, + itemCount, + itemsPerPage, + pageCount, + emptyStateLocalizationKey, + } = props; + + const startingRow = itemCount > 0 ? Math.max(0, (page - 1) * itemsPerPage) + 1 : 0; + const endingRow = Math.min(page * itemsPerPage, itemCount); + + return ( + + ({ overflowX: 'auto', padding: t.space.$1 })}> + + + + {headers.map((h, index) => ( + + + + {isLoading ? ( + + + + ) : !rows.length ? ( + + ) : ( + rows + )} + +
+ ))} +
+ +
+
+ {pageCount > 1 && ( + + )} + + ); +}; + +const DataTableEmptyRow = (props: { localizationKey: LocalizationKey | string }) => { + return ( + + + + + + ); +}; + +const DataTableRow = (props: PropsOfComponent) => { + return ( + ({ ':hover': { backgroundColor: t.colors.$neutralAlpha50 } })} + /> + ); +}; diff --git a/packages/clerk-js/src/ui/components/PaymentAttempts/index.ts b/packages/clerk-js/src/ui/components/PaymentAttempts/index.ts new file mode 100644 index 00000000000..15ae203ebd7 --- /dev/null +++ b/packages/clerk-js/src/ui/components/PaymentAttempts/index.ts @@ -0,0 +1,2 @@ +export * from './PaymentAttemptsList'; +export * from './PaymentAttemptPage'; diff --git a/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx b/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx index 90354c6d39c..072bcafbc42 100644 --- a/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx +++ b/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx @@ -255,13 +255,16 @@ const AddPaymentSourceForm = ({ children }: PropsWithChildren) => { redirect: 'if_required', }); if (error) { + console.log('error', error); return; // just return, since stripe will handle the error } await onSuccess({ stripeSetupIntent: setupIntent }); } catch (error) { + console.log('catch', error); void handleError(error, [], card.setError); } finally { + console.log('finally'); card.setIdle(); initializePaymentSource(); // resets the payment intent } diff --git a/packages/clerk-js/src/ui/components/UserProfile/BillingPage.tsx b/packages/clerk-js/src/ui/components/UserProfile/BillingPage.tsx index e40986eac03..45a4d64ed5b 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/BillingPage.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/BillingPage.tsx @@ -6,14 +6,15 @@ import { Tab, TabPanel, TabPanels, Tabs, TabsList } from '@/ui/elements/Tabs'; import { SubscriberTypeContext } from '../../contexts'; import { Col, descriptors, localizationKeys } from '../../customizables'; import { useTabState } from '../../hooks/useTabState'; +import { PaymentAttemptsList } from '../PaymentAttempts'; import { PaymentSources } from '../PaymentSources'; import { StatementsList } from '../Statements'; import { SubscriptionsList } from '../Subscriptions'; const tabMap = { - 0: 'plans', + 0: 'subscriptions', 1: 'statements', - 2: 'payment-methods', + 2: 'payment-history', } as const; const BillingPageInternal = withCardStateProvider(() => { @@ -46,6 +47,7 @@ const BillingPageInternal = withCardStateProvider(() => { ({ gap: t.space.$6 })}> + ({ width: '100%', flexDirection: 'column' })}> @@ -63,6 +65,9 @@ const BillingPageInternal = withCardStateProvider(() => { + + + diff --git a/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx b/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx index 6252af0e006..e98e1637988 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx @@ -4,6 +4,7 @@ import { CustomPageContentContainer } from '../../common/CustomPageContentContai import { USER_PROFILE_NAVBAR_ROUTE_ID } from '../../constants'; import { useEnvironment, useUserProfileContext } from '../../contexts'; import { Route, Switch } from '../../router'; +import { PaymentAttemptPage } from '../PaymentAttempts'; import { StatementPage } from '../Statements'; import { AccountPage } from './AccountPage'; import { PlansPage } from './PlansPage'; @@ -77,6 +78,12 @@ export const UserProfileRoutes = () => { + + {/* TODO(@commerce): Should this be lazy loaded ? */} + + + + )} diff --git a/packages/clerk-js/src/ui/contexts/components/PaymentAttempts.tsx b/packages/clerk-js/src/ui/contexts/components/PaymentAttempts.tsx new file mode 100644 index 00000000000..9a50ed3d285 --- /dev/null +++ b/packages/clerk-js/src/ui/contexts/components/PaymentAttempts.tsx @@ -0,0 +1,17 @@ +import { useCallback } from 'react'; + +import { usePaymentAttempts } from './Plans'; + +export const usePaymentAttemptsContext = () => { + const { data: payments } = usePaymentAttempts(); + const getPaymentAttemptById = useCallback( + (paymentAttemptId: string) => { + return payments?.data.find(payment => payment.id === paymentAttemptId); + }, + [payments?.data], + ); + + return { + getPaymentAttemptById, + }; +}; diff --git a/packages/clerk-js/src/ui/contexts/components/Plans.tsx b/packages/clerk-js/src/ui/contexts/components/Plans.tsx index 969d3fa254c..0ec0005d9b5 100644 --- a/packages/clerk-js/src/ui/contexts/components/Plans.tsx +++ b/packages/clerk-js/src/ui/contexts/components/Plans.tsx @@ -39,6 +39,25 @@ export const usePaymentSources = () => { return useSWR(cacheKey, () => (subscriberType === 'org' ? organization : user)?.getPaymentSources({}), dedupeOptions); }; +export const usePaymentAttemptsCacheKey = () => { + const { organization } = useOrganization(); + const { user } = useUser(); + const subscriberType = useSubscriberTypeContext(); + + return { + key: `commerce-payment-history`, + userId: user?.id, + args: { orgId: subscriberType === 'org' ? organization?.id : undefined }, + }; +}; + +export const usePaymentAttempts = () => { + const { billing } = useClerk(); + const cacheKey = usePaymentAttemptsCacheKey(); + + return useSWR(cacheKey, ({ args, userId }) => (userId ? billing.getPaymentAttempts(args) : undefined), dedupeOptions); +}; + export const useStatementsCacheKey = () => { const { organization } = useOrganization(); const { user } = useUser(); diff --git a/packages/clerk-js/src/ui/contexts/components/index.ts b/packages/clerk-js/src/ui/contexts/components/index.ts index f3db145e4ef..1c091e23834 100644 --- a/packages/clerk-js/src/ui/contexts/components/index.ts +++ b/packages/clerk-js/src/ui/contexts/components/index.ts @@ -14,5 +14,6 @@ export * from './Waitlist'; export * from './PricingTable'; export * from './Checkout'; export * from './Statements'; +export * from './PaymentAttempts'; export * from './Plans'; export * from './OAuthConsent'; diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index c7da953291e..90c432b7fc4 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -207,6 +207,7 @@ export const enUS: LocalizationResource = { title: 'Payment methods', }, start: { + headerTitle__paymentHistory: 'Payment history', headerTitle__plans: 'Plans', headerTitle__statements: 'Statements', headerTitle__subscriptions: 'Subscriptions', @@ -834,6 +835,7 @@ export const enUS: LocalizationResource = { title: 'Payment methods', }, start: { + headerTitle__paymentHistory: 'Payment history', headerTitle__plans: 'Plans', headerTitle__statements: 'Statements', headerTitle__subscriptions: 'Subscription', diff --git a/packages/types/src/commerce.ts b/packages/types/src/commerce.ts index 92baf5e246e..71466c46862 100644 --- a/packages/types/src/commerce.ts +++ b/packages/types/src/commerce.ts @@ -8,6 +8,7 @@ type WithOptionalOrgType = T & { }; export interface CommerceBillingNamespace { + getPaymentAttempts: (params: GetPaymentAttemptsParams) => Promise>; getPlans: (params?: GetPlansParams) => Promise; getSubscriptions: (params: GetSubscriptionsParams) => Promise>; getStatements: (params: GetStatementsParams) => Promise>; @@ -109,6 +110,23 @@ export interface CommerceInitializedPaymentSourceResource extends ClerkResource paymentMethodOrder: string[]; } +export type CommercePaymentChargeType = 'checkout' | 'recurring'; +export type CommercePaymentStatus = 'pending' | 'paid' | 'failed'; + +export interface CommercePaymentResource extends ClerkResource { + id: string; + amount: CommerceMoney; + paidAt?: number; + failedAt?: number; + updatedAt: number; + paymentSource: CommercePaymentSourceResource; + subscription: CommerceSubscriptionResource; + chargeType: CommercePaymentChargeType; + status: CommercePaymentStatus; +} + +export type GetPaymentAttemptsParams = WithOptionalOrgType; + export type GetStatementsParams = WithOptionalOrgType; export type CommerceStatementStatus = 'open' | 'closed'; @@ -123,19 +141,7 @@ export interface CommerceStatementResource extends ClerkResource { export interface CommerceStatementGroup { timestamp: number; - items: CommercePayment[]; -} - -export type CommercePaymentChargeType = 'checkout' | 'recurring'; -export type CommercePaymentStatus = 'pending' | 'paid' | 'failed'; - -export interface CommercePayment { - id: string; - amount: CommerceMoney; - paymentSource: CommercePaymentSourceResource; - subscription: CommerceSubscriptionResource; - chargeType: CommercePaymentChargeType; - status: CommercePaymentStatus; + items: CommercePaymentResource[]; } export type GetSubscriptionsParams = WithOptionalOrgType; diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index ae49c10e6db..77f56d86909 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -671,6 +671,9 @@ export interface CommercePaymentJSON extends ClerkResourceJSON { object: 'commerce_payment'; id: string; amount: CommerceMoneyJSON; + paid_at?: number; + failed_at?: number; + updated_at: number; payment_source: CommercePaymentSourceJSON; subscription: CommerceSubscriptionJSON; charge_type: CommercePaymentChargeType; diff --git a/packages/types/src/localization.ts b/packages/types/src/localization.ts index 68869284b34..e762e783cd6 100644 --- a/packages/types/src/localization.ts +++ b/packages/types/src/localization.ts @@ -755,6 +755,7 @@ type _LocalizationResource = { billingPage: { title: LocalizationValue; start: { + headerTitle__paymentHistory: LocalizationValue; headerTitle__plans: LocalizationValue; headerTitle__subscriptions: LocalizationValue; headerTitle__statements: LocalizationValue; @@ -959,6 +960,7 @@ type _LocalizationResource = { billingPage: { title: LocalizationValue; start: { + headerTitle__paymentHistory: LocalizationValue; headerTitle__plans: LocalizationValue; headerTitle__subscriptions: LocalizationValue; headerTitle__statements: LocalizationValue; From d38be4499e189f9bb45af80f975b816cd7b219bc Mon Sep 17 00:00:00 2001 From: Keiran Flanigan Date: Thu, 5 Jun 2025 16:55:22 -0700 Subject: [PATCH 2/4] finish wireup --- .changeset/brown-masks-admire.md | 7 +++ packages/clerk-js/bundlewatch.config.json | 2 +- .../src/core/resources/CommercePayment.ts | 2 + .../PaymentAttempts/PaymentAttemptPage.tsx | 51 ++++++++++++++++++- packages/localizations/src/en-US.ts | 1 + packages/types/src/commerce.ts | 1 + packages/types/src/json.ts | 1 + packages/types/src/localization.ts | 1 + 8 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 .changeset/brown-masks-admire.md diff --git a/.changeset/brown-masks-admire.md b/.changeset/brown-masks-admire.md new file mode 100644 index 00000000000..c14a7a81049 --- /dev/null +++ b/.changeset/brown-masks-admire.md @@ -0,0 +1,7 @@ +--- +'@clerk/localizations': patch +'@clerk/clerk-js': patch +'@clerk/types': patch +--- + +Add payment history tab to UserProfile and OrgProfile diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index cade569eb87..294643df722 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -4,7 +4,7 @@ { "path": "./dist/clerk.browser.js", "maxSize": "69KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "113KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "52KB" }, - { "path": "./dist/ui-common*.js", "maxSize": "106.1KB" }, + { "path": "./dist/ui-common*.js", "maxSize": "106.3KB" }, { "path": "./dist/vendors*.js", "maxSize": "39.8KB" }, { "path": "./dist/coinbase*.js", "maxSize": "38KB" }, { "path": "./dist/createorganization*.js", "maxSize": "5KB" }, diff --git a/packages/clerk-js/src/core/resources/CommercePayment.ts b/packages/clerk-js/src/core/resources/CommercePayment.ts index 5a2933657d7..91fa656dfa1 100644 --- a/packages/clerk-js/src/core/resources/CommercePayment.ts +++ b/packages/clerk-js/src/core/resources/CommercePayment.ts @@ -17,6 +17,7 @@ export class CommercePayment extends BaseResource implements CommercePaymentReso updatedAt!: number; paymentSource!: CommercePaymentSource; subscription!: CommerceSubscription; + subscriptionItem!: CommerceSubscription; chargeType!: CommercePaymentChargeType; status!: CommercePaymentStatus; @@ -37,6 +38,7 @@ export class CommercePayment extends BaseResource implements CommercePaymentReso this.updatedAt = data.updated_at; this.paymentSource = new CommercePaymentSource(data.payment_source); this.subscription = new CommerceSubscription(data.subscription); + this.subscriptionItem = new CommerceSubscription(data.subscription_item); this.chargeType = data.charge_type; this.status = data.status; return this; diff --git a/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx b/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx index fa396e4494b..bc3594cf1f1 100644 --- a/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx +++ b/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx @@ -1,7 +1,19 @@ import { Header } from '@/ui/elements/Header'; +import { LineItems } from '@/ui/elements/LineItems'; import { usePaymentAttemptsContext, useStatements } from '../../contexts'; -import { Badge, Box, Button, descriptors, Heading, Icon, Span, Spinner, Text } from '../../customizables'; +import { + Badge, + Box, + Button, + descriptors, + Heading, + Icon, + localizationKeys, + Span, + Spinner, + Text, +} from '../../customizables'; import { useClipboard } from '../../hooks'; import { Check, Copy } from '../../icons'; import { useRouter } from '../../router'; @@ -13,6 +25,7 @@ export const PaymentAttemptPage = () => { const { getPaymentAttemptById } = usePaymentAttemptsContext(); const paymentAttempt = params.paymentAttemptId ? getPaymentAttemptById(params.paymentAttemptId) : null; + const subscriptionItem = paymentAttempt?.subscriptionItem; if (isLoading) { return ( @@ -111,7 +124,41 @@ export const PaymentAttemptPage = () => { {paymentAttempt.status} - (CONTENT) + ({ + padding: t.space.$4, + })} + > + {subscriptionItem && ( + + + + + + + + + + {subscriptionItem.credit && subscriptionItem.credit.amount.amount > 0 && ( + + + + + )} + + )} + Date: Thu, 5 Jun 2025 17:03:56 -0700 Subject: [PATCH 3/4] descriptors --- .../src/ui/customizables/elementDescriptors.ts | 13 +++++++++++++ packages/types/src/appearance.ts | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts index 6db1c61cc31..3e7f8804341 100644 --- a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts +++ b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts @@ -385,6 +385,19 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'menuList', 'menuItem', + 'paymentAttemptRoot', + 'paymentAttemptHeader', + 'paymentAttemptHeaderTitleContainer', + 'paymentAttemptHeaderTitle', + 'paymentAttemptHeaderBadge', + 'paymentAttemptBody', + 'paymentAttemptFooter', + 'paymentAttemptFooterLabel', + 'paymentAttemptFooterValueContainer', + 'paymentAttemptFooterCurrency', + 'paymentAttemptFooterValue', + 'paymentAttemptCopyButton', + 'modalBackdrop', 'modalContent', 'modalCloseButton', diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index ec27a4810cc..ce49aaa02e3 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -510,6 +510,19 @@ export type ElementsConfig = { menuList: WithOptions; menuItem: WithOptions; + paymentAttemptRoot: WithOptions; + paymentAttemptHeader: WithOptions; + paymentAttemptHeaderTitleContainer: WithOptions; + paymentAttemptHeaderTitle: WithOptions; + paymentAttemptHeaderBadge: WithOptions; + paymentAttemptBody: WithOptions; + paymentAttemptFooter: WithOptions; + paymentAttemptFooterLabel: WithOptions; + paymentAttemptFooterValueContainer: WithOptions; + paymentAttemptFooterCurrency: WithOptions; + paymentAttemptFooterValue: WithOptions; + paymentAttemptCopyButton: WithOptions; + modalBackdrop: WithOptions; modalContent: WithOptions; modalCloseButton: WithOptions; From 9a433a4cc70a598a57923303cfbdb48d970c989d Mon Sep 17 00:00:00 2001 From: Keiran Flanigan Date: Fri, 6 Jun 2025 11:55:03 -0700 Subject: [PATCH 4/4] change name to payments --- .../OrganizationProfile/OrganizationBillingPage.tsx | 6 ++---- .../ui/components/PaymentAttempts/PaymentAttemptPage.tsx | 6 ++---- .../clerk-js/src/ui/components/UserProfile/BillingPage.tsx | 4 ++-- packages/localizations/src/en-US.ts | 4 ++-- packages/types/src/localization.ts | 4 ++-- 5 files changed, 10 insertions(+), 14 deletions(-) diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationBillingPage.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationBillingPage.tsx index a5b0c46336a..67e3b272fc1 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationBillingPage.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationBillingPage.tsx @@ -15,7 +15,7 @@ import { SubscriptionsList } from '../Subscriptions'; const orgTabMap = { 0: 'subscriptions', 1: 'statements', - 2: 'payment-history', + 2: 'payments', } as const; const OrganizationBillingPageInternal = withCardStateProvider(() => { @@ -51,9 +51,7 @@ const OrganizationBillingPageInternal = withCardStateProvider(() => { localizationKey={localizationKeys('organizationProfile.billingPage.start.headerTitle__subscriptions')} /> - + diff --git a/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx b/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx index aaa8a5656f4..fbed842320d 100644 --- a/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx +++ b/packages/clerk-js/src/ui/components/PaymentAttempts/PaymentAttemptPage.tsx @@ -54,11 +54,9 @@ export const PaymentAttemptPage = () => { paddingBlockEnd: t.space.$4, })} > - void navigate('../../', { searchParams: new URLSearchParams('tab=payment-history') })} - > + void navigate('../../', { searchParams: new URLSearchParams('tab=payments') })}> diff --git a/packages/clerk-js/src/ui/components/UserProfile/BillingPage.tsx b/packages/clerk-js/src/ui/components/UserProfile/BillingPage.tsx index 45a4d64ed5b..1b4fbe9f368 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/BillingPage.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/BillingPage.tsx @@ -14,7 +14,7 @@ import { SubscriptionsList } from '../Subscriptions'; const tabMap = { 0: 'subscriptions', 1: 'statements', - 2: 'payment-history', + 2: 'payments', } as const; const BillingPageInternal = withCardStateProvider(() => { @@ -47,7 +47,7 @@ const BillingPageInternal = withCardStateProvider(() => { ({ gap: t.space.$6 })}> - + ({ width: '100%', flexDirection: 'column' })}> diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 74d7dbcbb1f..533cf82048b 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -208,7 +208,7 @@ export const enUS: LocalizationResource = { title: 'Payment methods', }, start: { - headerTitle__paymentHistory: 'Payment history', + headerTitle__payments: 'Payments', headerTitle__plans: 'Plans', headerTitle__statements: 'Statements', headerTitle__subscriptions: 'Subscriptions', @@ -836,7 +836,7 @@ export const enUS: LocalizationResource = { title: 'Payment methods', }, start: { - headerTitle__paymentHistory: 'Payment history', + headerTitle__payments: 'Payments', headerTitle__plans: 'Plans', headerTitle__statements: 'Statements', headerTitle__subscriptions: 'Subscription', diff --git a/packages/types/src/localization.ts b/packages/types/src/localization.ts index 7d0a57992c8..77b54c7b90d 100644 --- a/packages/types/src/localization.ts +++ b/packages/types/src/localization.ts @@ -756,7 +756,7 @@ type _LocalizationResource = { billingPage: { title: LocalizationValue; start: { - headerTitle__paymentHistory: LocalizationValue; + headerTitle__payments: LocalizationValue; headerTitle__plans: LocalizationValue; headerTitle__subscriptions: LocalizationValue; headerTitle__statements: LocalizationValue; @@ -961,7 +961,7 @@ type _LocalizationResource = { billingPage: { title: LocalizationValue; start: { - headerTitle__paymentHistory: LocalizationValue; + headerTitle__payments: LocalizationValue; headerTitle__plans: LocalizationValue; headerTitle__subscriptions: LocalizationValue; headerTitle__statements: LocalizationValue;