Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
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,
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)),
};
});
};
}
10 changes: 10 additions & 0 deletions packages/clerk-js/src/core/resources/BillingCreditBalance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
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;
}
}
37 changes: 37 additions & 0 deletions packages/clerk-js/src/core/resources/BillingCreditLedger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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;
note: string | null = null;
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.note = data.note ?? null;
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;

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
20 changes: 20 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,16 @@ 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',
tableHeader__reason: 'Reason',
},
paymentHistorySection: {
empty: 'No payment history',
notFound: 'Payment attempt not found',
Expand Down Expand Up @@ -1779,6 +1789,16 @@ 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',
tableHeader__reason: 'Reason',
},
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 { useBillingIsEnabled } from './useBillingIsEnabled';
import { useClearQueriesOnSignOut } from './useClearQueriesOnSignOut';
import { createCacheKeys } from './createCacheKeys';

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!, 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,
};
}
102 changes: 102 additions & 0 deletions packages/shared/src/react/hooks/useCreditHistory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
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 { useBillingIsEnabled } from './useBillingIsEnabled';
import { useClearQueriesOnSignOut } from './useClearQueriesOnSignOut';
import { createCacheKeys } from './createCacheKeys';

const HOOK_NAME = 'useCreditHistory';

export type UseCreditHistoryParams = {
for?: ForPayerType;
payerId?: string;
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 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: INTERNAL_STABLE_KEYS.CREDIT_HISTORY_KEY,
authenticated: true,
tracked: {
userId: user?.id,
orgId: safeOrgId,
payerId,
},
untracked: {
args: { payerId: payerId!, orgId: safeOrgId },
},
});
}, [user?.id, organization?.id, params?.for, payerId]);

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