Skip to content
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.
4 changes: 2 additions & 2 deletions packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
"files": [
{ "path": "./dist/clerk.js", "maxSize": "549KB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "74KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "114KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "115KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "72KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "73KB" },
{ "path": "./dist/vendors*.js", "maxSize": "7KB" },
{ "path": "./dist/coinbase*.js", "maxSize": "36KB" },
{ "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" },
Expand Down
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(`/payers/${params.payerId}/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(`/payers/${params.payerId}/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;
}
}
35 changes: 35 additions & 0 deletions packages/clerk-js/src/core/resources/BillingCreditLedger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { BillingCreditLedgerJSON, BillingCreditLedgerResource } from '@clerk/shared/types';

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

import { BaseResource } from './internal';

export class BillingCreditLedger extends BaseResource implements BillingCreditLedgerResource {
id!: string;
payerId!: string;
amount!: number;
currency!: string;
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.payerId = data.payer_id;
this.amount = data.amount;
this.currency = data.currency;
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/BillingSubscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export class BillingSubscription extends BaseResource implements BillingSubscrip
nextPayment?: BillingSubscriptionNextPayment | null;
subscriptionItems!: BillingSubscriptionItemResource[];
eligibleForFreeTrial!: boolean;
payerId!: string;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

constructor(data: BillingSubscriptionJSON) {
super();
Expand Down Expand Up @@ -63,6 +64,7 @@ export class BillingSubscription extends BaseResource implements BillingSubscrip

this.subscriptionItems = (data.subscription_items || []).map(item => new BillingSubscriptionItem(item));
this.eligibleForFreeTrial = this.withDefault(data.eligible_for_free_trial, false);
this.payerId = data.payer_id;
return this;
}
}
Expand Down
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 @@ -898,6 +898,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 @@ -1779,6 +1788,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 { useCreditBalance as __experimental_useCreditBalance } 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
106 changes: 106 additions & 0 deletions packages/shared/src/react/hooks/useCreditBalance.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
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;
payerId?: string;
keepPreviousData?: boolean;
enabled?: boolean;
};

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

/**
* @internal
*/
export function useCreditBalance(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 payerId = params?.payerId;

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,
payerId,
},
untracked: {
args: { payerId: payerId as string, orgId: safeOrgId },
},
});
}, [user?.id, organization?.id, params?.for, payerId]);

const queriesEnabled = Boolean(user?.id && billingEnabled && payerId);
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,
};
}
Loading
Loading