Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4e6cafc
feat: add credits information in the user/org profile
l-armstrong Jun 24, 2026
3f25810
feat: add the ability for the payer to view their credit history
l-armstrong Jun 24, 2026
36a6370
fix: fix the leftIcon lint error
l-armstrong Jun 24, 2026
47f88d7
chore: remove reason column from table and increase maxsize in bundle
l-armstrong Jun 24, 2026
383a3eb
fix: increase bundlewatch sizes
l-armstrong Jun 24, 2026
936fd7a
fix: fix import sort and non-null assertion
l-armstrong Jun 24, 2026
a5080a2
feat: add changeset
l-armstrong Jun 24, 2026
bfd5f33
fix added the explicit JSX.Element return type to CreditHistoryPage
l-armstrong Jun 24, 2026
3da2c54
fix: change the description of the changeset
l-armstrong Jun 25, 2026
98e5505
fix: remove the need for the payerid from the client side
l-armstrong Jun 26, 2026
eb85c11
fix: use $ formatter from useLocalizations and stop use of Box with as
l-armstrong Jun 26, 2026
747b6d9
fix: fix format issue
l-armstrong Jun 26, 2026
9468645
fix: fix balance hook
l-armstrong Jun 26, 2026
ed992f6
fix: clean up using payer_id
l-armstrong Jun 29, 2026
ff37c1a
fix make the credit balance internal
l-armstrong Jun 29, 2026
cf80e51
fix: use the correct BillingMoneyAmount type instead of a raw amount
l-armstrong Jun 29, 2026
6bdb860
fix: fix linter issue
l-armstrong Jun 29, 2026
19d215b
fix: fix the entry amount.
l-armstrong Jun 30, 2026
4902172
fix: remove arrow
l-armstrong Jun 30, 2026
2878666
fix: fix borders in credit history page.
l-armstrong Jun 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/fancy-rats-stick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@clerk/localizations': minor
'@clerk/clerk-js': minor
'@clerk/shared': minor
'@clerk/ui': minor
---

Add account credits section and credit history page to the billing tab for payers with an existing credit balance.
33 changes: 33 additions & 0 deletions packages/clerk-js/src/core/modules/billing/namespace.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type {
BillingCheckoutJSON,
BillingCreditBalanceJSON,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❔ Should it be BillingAccountCreditBalance?
cc @dstaley

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, the internal type is CommerceCreditBalanceResponse, so BillingCreditBalanceJSON would be the correct version of the JavaScript type.

BillingCreditBalanceResource,
BillingCreditLedgerJSON,
BillingCreditLedgerResource,
BillingNamespace,
BillingPaymentJSON,
BillingPaymentResource,
Expand All @@ -11,6 +15,8 @@ import type {
BillingSubscriptionResource,
ClerkPaginatedResponse,
CreateCheckoutParams,
GetCreditBalanceParams,
GetCreditHistoryParams,
GetPaymentAttemptsParams,
GetPlansParams,
GetStatementsParams,
Expand All @@ -21,6 +27,8 @@ import { convertPageToOffsetSearchParams } from '../../../utils/convertPageToOff
import {
BaseResource,
BillingCheckout,
BillingCreditBalance,
BillingCreditLedger,
BillingPayment,
BillingPlan,
BillingStatement,
Expand Down Expand Up @@ -140,4 +148,29 @@ export class Billing implements BillingNamespace {

return new BillingCheckout(json);
};

getCreditBalance = async (params: GetCreditBalanceParams): Promise<BillingCreditBalanceResource> => {
return await BaseResource._fetch({
path: Billing.path('/credits', { orgId: params.orgId }),
method: 'GET',
}).then(res => new BillingCreditBalance(res?.response as unknown as BillingCreditBalanceJSON));
};

getCreditHistory = async (
params: GetCreditHistoryParams,
): Promise<ClerkPaginatedResponse<BillingCreditLedgerResource>> => {
return await BaseResource._fetch({
path: Billing.path('/credits/history', { orgId: params.orgId }),
method: 'GET',
}).then(res => {
const { data, total_count } = res?.response as unknown as {
data: BillingCreditLedgerJSON[];
total_count: number;
};
return {
total_count,
data: data.map(item => new BillingCreditLedger(item)),
};
});
};
}
11 changes: 11 additions & 0 deletions packages/clerk-js/src/core/resources/BillingCreditBalance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { BillingCreditBalanceJSON, BillingCreditBalanceResource, BillingMoneyAmount } from '@clerk/shared/types';

import { billingMoneyAmountFromJSON } from '../../utils';

export class BillingCreditBalance implements BillingCreditBalanceResource {
balance: BillingMoneyAmount | null;

constructor(data: BillingCreditBalanceJSON) {
this.balance = data.balance ? billingMoneyAmountFromJSON(data.balance) : null;
}
}
32 changes: 32 additions & 0 deletions packages/clerk-js/src/core/resources/BillingCreditLedger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { BillingCreditLedgerJSON, BillingCreditLedgerResource, BillingMoneyAmount } from '@clerk/shared/types';

import { billingMoneyAmountFromJSON } from '@/utils/billing';
import { unixEpochToDate } from '@/utils/date';

import { BaseResource } from './internal';

export class BillingCreditLedger extends BaseResource implements BillingCreditLedgerResource {
id!: string;
amount!: BillingMoneyAmount;
sourceType!: string;
sourceId!: string;
createdAt!: Date;

constructor(data: BillingCreditLedgerJSON) {
super();
this.fromJSON(data);
}

protected fromJSON(data: BillingCreditLedgerJSON | null): this {
if (!data) {
return this;
}

this.id = data.id;
this.amount = billingMoneyAmountFromJSON(data.amount);
this.sourceType = data.source_type;
this.sourceId = data.source_id;
this.createdAt = unixEpochToDate(data.created_at);
return this;
}
}
2 changes: 2 additions & 0 deletions packages/clerk-js/src/core/resources/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export * from './Base';
export * from './APIKey';
export * from './AuthConfig';
export * from './BillingCheckout';
export * from './BillingCreditBalance';
export * from './BillingCreditLedger';
export * from './BillingPayment';
export * from './BillingPaymentMethod';
export * from './BillingPlan';
Expand Down
18 changes: 18 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,15 @@ export const enUS: LocalizationResource = {
badge__manualInvitation: 'No automatic enrollment',
badge__unverified: 'Unverified',
billingPage: {
accountCreditsSection: {
title: 'Account credits',
viewHistory: 'View credit history',
},
creditHistoryPage: {
title: 'Account credit history',
tableHeader__amount: 'Amount',
tableHeader__date: 'Date',
},
paymentHistorySection: {
empty: 'No payment history',
notFound: 'Payment attempt not found',
Expand Down Expand Up @@ -1784,6 +1793,15 @@ export const enUS: LocalizationResource = {
title__codelist: 'Backup codes',
},
billingPage: {
accountCreditsSection: {
title: 'Account credits',
viewHistory: 'View credit history',
},
creditHistoryPage: {
title: 'Account credit history',
tableHeader__amount: 'Amount',
tableHeader__date: 'Date',
},
paymentHistorySection: {
empty: 'No payment history',
notFound: 'Payment attempt not found',
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/react/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ export { usePaymentMethods as __experimental_usePaymentMethods } from './usePaym
export { usePlans as __experimental_usePlans } from './usePlans';
export { useSubscription as __experimental_useSubscription } from './useSubscription';
export { useCheckout as __experimental_useCheckout } from './useCheckout';
export { __internal_useCreditBalanceQuery } from './useCreditBalance';

/**
* Internal hooks to be consumed only by `@clerk/clerk-js`.
* These are not considered part of the public API and their query keys can change without notice.
*
* These exist here in order to keep React Query implementations in a centralized place.
*/
export { __internal_useCreditHistoryQuery } from './useCreditHistory';
export { __internal_useStatementQuery } from './useStatementQuery';
export { __internal_usePlanDetailsQuery } from './usePlanDetailsQuery';
export { __internal_usePaymentAttemptQuery } from './usePaymentAttemptQuery';
Expand Down
103 changes: 103 additions & 0 deletions packages/shared/src/react/hooks/useCreditBalance.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';

import { eventMethodCalled } from '../../telemetry/events';
import type { BillingCreditBalanceResource, ForPayerType } from '../../types';
import { useAssertWrappedByClerkProvider, useClerkInstanceContext } from '../contexts';
import { defineKeepPreviousDataFn } from '../query/keep-previous-data';
import { useClerkQueryClient } from '../query/use-clerk-query-client';
import { useClerkQuery } from '../query/useQuery';
import { STABLE_KEYS } from '../stable-keys';
import { useOrganizationBase } from './base/useOrganizationBase';
import { useUserBase } from './base/useUserBase';
import { createCacheKeys } from './createCacheKeys';
import { useBillingIsEnabled } from './useBillingIsEnabled';
import { useClearQueriesOnSignOut } from './useClearQueriesOnSignOut';

const HOOK_NAME = 'useCreditBalance';

export type UseCreditBalanceParams = {
for?: ForPayerType;
keepPreviousData?: boolean;
enabled?: boolean;
Comment thread
dstaley marked this conversation as resolved.
};

export type CreditBalanceResult = {
data: BillingCreditBalanceResource | undefined | null;
error: Error | undefined;
isLoading: boolean;
isFetching: boolean;
revalidate: () => Promise<void> | void;
};

/**
* @internal
*/
export function __internal_useCreditBalanceQuery(params?: UseCreditBalanceParams): CreditBalanceResult {
useAssertWrappedByClerkProvider(HOOK_NAME);

const clerk = useClerkInstanceContext();
const user = useUserBase();
const organization = useOrganizationBase();

const billingEnabled = useBillingIsEnabled(params);

const recordedRef = useRef(false);
useEffect(() => {
if (!recordedRef.current && clerk?.telemetry) {
clerk.telemetry.record(eventMethodCalled(HOOK_NAME));
recordedRef.current = true;
}
}, [clerk]);

const keepPreviousData = params?.keepPreviousData ?? false;

const [queryClient] = useClerkQueryClient();

const { queryKey, invalidationKey, stableKey, authenticated } = useMemo(() => {
const isOrganization = params?.for === 'organization';
const safeOrgId = isOrganization ? organization?.id : undefined;

return createCacheKeys({
stablePrefix: STABLE_KEYS.CREDIT_BALANCE_KEY,
authenticated: true,
tracked: {
userId: user?.id,
orgId: safeOrgId,
},
untracked: {
args: { orgId: safeOrgId },
},
});
}, [user?.id, organization?.id, params?.for]);

const queriesEnabled = Boolean(user?.id && billingEnabled && (params?.enabled ?? true));
useClearQueriesOnSignOut({
isSignedOut: user === null,
authenticated,
stableKeys: stableKey,
});

const query = useClerkQuery({
queryKey,
queryFn: ({ queryKey }) => {
const obj = queryKey[3];
return clerk.billing.getCreditBalance(obj.args);
},
staleTime: 1_000 * 60,
enabled: queriesEnabled,
placeholderData: defineKeepPreviousDataFn(keepPreviousData && queriesEnabled),
});

const revalidate = useCallback(
() => queryClient.invalidateQueries({ queryKey: invalidationKey }),
[queryClient, invalidationKey],
);

return {
data: query.data,
error: query.error ?? undefined,
isLoading: query.isLoading,
isFetching: query.isFetching,
revalidate,
};
}
98 changes: 98 additions & 0 deletions packages/shared/src/react/hooks/useCreditHistory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';

import { eventMethodCalled } from '../../telemetry/events';
import type { BillingCreditLedgerResource, ClerkPaginatedResponse, ForPayerType } from '../../types';
import { useAssertWrappedByClerkProvider, useClerkInstanceContext } from '../contexts';
import { useClerkQueryClient } from '../query/use-clerk-query-client';
import { useClerkQuery } from '../query/useQuery';
import { INTERNAL_STABLE_KEYS } from '../stable-keys';
import { useOrganizationBase } from './base/useOrganizationBase';
import { useUserBase } from './base/useUserBase';
import { createCacheKeys } from './createCacheKeys';
import { useBillingIsEnabled } from './useBillingIsEnabled';
import { useClearQueriesOnSignOut } from './useClearQueriesOnSignOut';

const HOOK_NAME = 'useCreditHistory';

export type UseCreditHistoryParams = {
for?: ForPayerType;
enabled?: boolean;
};

export type CreditHistoryResult = {
data: ClerkPaginatedResponse<BillingCreditLedgerResource> | undefined;
error: Error | undefined;
isLoading: boolean;
isFetching: boolean;
revalidate: () => Promise<void> | void;
};

/**
* @internal
*/
export function __internal_useCreditHistoryQuery(params?: UseCreditHistoryParams): CreditHistoryResult {
useAssertWrappedByClerkProvider(HOOK_NAME);

const clerk = useClerkInstanceContext();
const user = useUserBase();
const organization = useOrganizationBase();

const billingEnabled = useBillingIsEnabled(params);

const recordedRef = useRef(false);
useEffect(() => {
if (!recordedRef.current && clerk?.telemetry) {
clerk.telemetry.record(eventMethodCalled(HOOK_NAME));
recordedRef.current = true;
}
}, [clerk]);

const [queryClient] = useClerkQueryClient();

const { queryKey, invalidationKey, stableKey, authenticated } = useMemo(() => {
const isOrganization = params?.for === 'organization';
const safeOrgId = isOrganization ? organization?.id : undefined;

return createCacheKeys({
stablePrefix: INTERNAL_STABLE_KEYS.CREDIT_HISTORY_KEY,
authenticated: true,
tracked: {
userId: user?.id,
orgId: safeOrgId,
},
untracked: {
args: { orgId: safeOrgId },
},
});
}, [user?.id, organization?.id, params?.for]);

const queriesEnabled = Boolean(user?.id && billingEnabled && (params?.enabled ?? true));
useClearQueriesOnSignOut({
isSignedOut: user === null,
authenticated,
stableKeys: stableKey,
});

const query = useClerkQuery({
queryKey,
queryFn: ({ queryKey }) => {
const obj = queryKey[3];
return clerk.billing.getCreditHistory(obj.args);
},
staleTime: 1_000 * 60,
enabled: queriesEnabled,
});

const revalidate = useCallback(
() => queryClient.invalidateQueries({ queryKey: invalidationKey }),
[queryClient, invalidationKey],
);

return {
data: query.data,
error: query.error ?? undefined,
isLoading: query.isLoading,
isFetching: query.isFetching,
revalidate,
};
}
Loading
Loading