diff --git a/.changeset/fancy-rats-stick.md b/.changeset/fancy-rats-stick.md new file mode 100644 index 00000000000..d6b0124392a --- /dev/null +++ b/.changeset/fancy-rats-stick.md @@ -0,0 +1,8 @@ +--- +'@clerk/localizations': minor +'@clerk/clerk-js': minor +'@clerk/shared': minor +'@clerk/ui': minor +--- + +Display the C2s Account Credits in the user/org profile. diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index df17a62a863..2c77b14c39d 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -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" }, diff --git a/packages/clerk-js/src/core/modules/billing/namespace.ts b/packages/clerk-js/src/core/modules/billing/namespace.ts index b85730adc43..f255f1b6feb 100644 --- a/packages/clerk-js/src/core/modules/billing/namespace.ts +++ b/packages/clerk-js/src/core/modules/billing/namespace.ts @@ -1,5 +1,9 @@ import type { BillingCheckoutJSON, + BillingCreditBalanceJSON, + BillingCreditBalanceResource, + BillingCreditLedgerJSON, + BillingCreditLedgerResource, BillingNamespace, BillingPaymentJSON, BillingPaymentResource, @@ -11,6 +15,8 @@ import type { BillingSubscriptionResource, ClerkPaginatedResponse, CreateCheckoutParams, + GetCreditBalanceParams, + GetCreditHistoryParams, GetPaymentAttemptsParams, GetPlansParams, GetStatementsParams, @@ -21,6 +27,8 @@ import { convertPageToOffsetSearchParams } from '../../../utils/convertPageToOff import { BaseResource, BillingCheckout, + BillingCreditBalance, + BillingCreditLedger, BillingPayment, BillingPlan, BillingStatement, @@ -140,4 +148,29 @@ export class Billing implements BillingNamespace { return new BillingCheckout(json); }; + + getCreditBalance = async (params: GetCreditBalanceParams): Promise => { + 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> => { + 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)), + }; + }); + }; } diff --git a/packages/clerk-js/src/core/resources/BillingCreditBalance.ts b/packages/clerk-js/src/core/resources/BillingCreditBalance.ts new file mode 100644 index 00000000000..d63e985929b --- /dev/null +++ b/packages/clerk-js/src/core/resources/BillingCreditBalance.ts @@ -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; + } +} diff --git a/packages/clerk-js/src/core/resources/BillingCreditLedger.ts b/packages/clerk-js/src/core/resources/BillingCreditLedger.ts new file mode 100644 index 00000000000..7225c4d3d6b --- /dev/null +++ b/packages/clerk-js/src/core/resources/BillingCreditLedger.ts @@ -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; + } +} diff --git a/packages/clerk-js/src/core/resources/BillingSubscription.ts b/packages/clerk-js/src/core/resources/BillingSubscription.ts index 3ba88bfe442..35aeb5e1041 100644 --- a/packages/clerk-js/src/core/resources/BillingSubscription.ts +++ b/packages/clerk-js/src/core/resources/BillingSubscription.ts @@ -36,6 +36,7 @@ export class BillingSubscription extends BaseResource implements BillingSubscrip nextPayment?: BillingSubscriptionNextPayment | null; subscriptionItems!: BillingSubscriptionItemResource[]; eligibleForFreeTrial!: boolean; + payerId!: string; constructor(data: BillingSubscriptionJSON) { super(); @@ -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; } } diff --git a/packages/clerk-js/src/core/resources/internal.ts b/packages/clerk-js/src/core/resources/internal.ts index 9ac3efbd232..fc1232779e6 100644 --- a/packages/clerk-js/src/core/resources/internal.ts +++ b/packages/clerk-js/src/core/resources/internal.ts @@ -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'; diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index b13d72e4c04..e3b1fdce85b 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -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', @@ -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', diff --git a/packages/shared/src/react/hooks/index.ts b/packages/shared/src/react/hooks/index.ts index 34b1f771e1b..c97d1d4b2ad 100644 --- a/packages/shared/src/react/hooks/index.ts +++ b/packages/shared/src/react/hooks/index.ts @@ -23,6 +23,7 @@ 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`. @@ -30,6 +31,7 @@ export { useCheckout as __experimental_useCheckout } from './useCheckout'; * * 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'; diff --git a/packages/shared/src/react/hooks/useCreditBalance.tsx b/packages/shared/src/react/hooks/useCreditBalance.tsx new file mode 100644 index 00000000000..6cc32538998 --- /dev/null +++ b/packages/shared/src/react/hooks/useCreditBalance.tsx @@ -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; +}; + +/** + * @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, + }; +} diff --git a/packages/shared/src/react/hooks/useCreditHistory.tsx b/packages/shared/src/react/hooks/useCreditHistory.tsx new file mode 100644 index 00000000000..d4202ee44cc --- /dev/null +++ b/packages/shared/src/react/hooks/useCreditHistory.tsx @@ -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 { createCacheKeys } from './createCacheKeys'; +import { useBillingIsEnabled } from './useBillingIsEnabled'; +import { useClearQueriesOnSignOut } from './useClearQueriesOnSignOut'; + +const HOOK_NAME = 'useCreditHistory'; + +export type UseCreditHistoryParams = { + for?: ForPayerType; + payerId?: string; + enabled?: boolean; +}; + +export type CreditHistoryResult = { + data: ClerkPaginatedResponse | undefined; + error: Error | undefined; + isLoading: boolean; + isFetching: boolean; + revalidate: () => Promise | 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 as string, 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, + }; +} diff --git a/packages/shared/src/react/stable-keys.ts b/packages/shared/src/react/stable-keys.ts index eeab3cd627e..6d7c6be925c 100644 --- a/packages/shared/src/react/stable-keys.ts +++ b/packages/shared/src/react/stable-keys.ts @@ -33,6 +33,9 @@ const PAYMENT_ATTEMPTS_KEY = 'billing-payment-attempts'; // Keys for `useStatements` const STATEMENTS_KEY = 'billing-statements'; +// Keys for `useCreditBalance` +const CREDIT_BALANCE_KEY = 'billing-credit-balance'; + export const STABLE_KEYS = { // Keys for `useOrganizationList` USER_MEMBERSHIPS_KEY, @@ -60,6 +63,9 @@ export const STABLE_KEYS = { // Keys for `useOAuthConsent` OAUTH_CONSENT_INFO_KEY, + + // Keys for `useCreditBalance` + CREDIT_BALANCE_KEY, } as const; export type ResourceCacheStableKey = (typeof STABLE_KEYS)[keyof typeof STABLE_KEYS]; @@ -78,10 +84,13 @@ const ORGANIZATION_ENTERPRISE_CONNECTIONS_KEY = 'organizationEnterpriseConnectio const ORGANIZATION_ENTERPRISE_CONNECTION_TEST_RUNS_KEY = 'organizationEnterpriseConnectionTestRuns'; const ORGANIZATION_DOMAINS_KEY = 'organizationDomains'; +const CREDIT_HISTORY_KEY = 'billing-credit-history'; + export const INTERNAL_STABLE_KEYS = { PAYMENT_ATTEMPT_KEY, BILLING_PLANS_KEY, BILLING_STATEMENTS_KEY, + CREDIT_HISTORY_KEY, USER_ENTERPRISE_CONNECTIONS_KEY, ENTERPRISE_CONNECTION_TEST_RUNS_KEY, ORGANIZATION_ENTERPRISE_CONNECTIONS_KEY, diff --git a/packages/shared/src/types/billing.ts b/packages/shared/src/types/billing.ts index 60390e1206e..97cf68168e3 100644 --- a/packages/shared/src/types/billing.ts +++ b/packages/shared/src/types/billing.ts @@ -82,6 +82,20 @@ export interface BillingNamespace { * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ startCheckout: (params: CreateCheckoutParams) => Promise; + + /** + * Gets the credit balance for the current payer. + * + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. + */ + getCreditBalance: (params: GetCreditBalanceParams) => Promise; + + /** + * Gets the credit history for the current payer. + * + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. + */ + getCreditHistory: (params: GetCreditHistoryParams) => Promise>; } /** @@ -926,6 +940,59 @@ export interface BillingSubscriptionResource extends ClerkResource { * Whether the payer is eligible for a free trial. */ eligibleForFreeTrial: boolean; + + /** + * The ID of the payer for this subscription. + */ + payerId: string; +} + +/** + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. + */ +export interface BillingCreditBalanceResource { + /** + * The balance of the credit. + */ + balance: BillingMoneyAmount | null; +} + +export type GetCreditBalanceParams = { + /** + * The ID of the Organization to get the credit balance for. + */ + orgId?: string; + /** + * The ID of the payer to get the credit balance for. + */ + payerId: string; +}; + +/** + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. + */ +export type GetCreditHistoryParams = { + /** + * The ID of the Organization to get the credit history for. + */ + orgId?: string; + /** + * The ID of the payer to get the credit history for. + */ + payerId: string; +}; + +/** + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. + */ +export interface BillingCreditLedgerResource { + id: string; + payerId: string; + amount: number; + currency: string; + sourceType: string; + sourceId: string; + createdAt: Date; } /** diff --git a/packages/shared/src/types/elementIds.ts b/packages/shared/src/types/elementIds.ts index 0a05f8c9b57..b685d908db7 100644 --- a/packages/shared/src/types/elementIds.ts +++ b/packages/shared/src/types/elementIds.ts @@ -62,7 +62,8 @@ export type ProfileSectionId = | 'configureAgain' | 'resetSso' | 'testSsoUrl' - | 'testResults'; + | 'testResults' + | 'accountCredits'; export type ProfilePageId = | 'account' | 'security' diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index 765ca1d6208..2a4f01ce4f6 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -925,6 +925,29 @@ export interface BillingSubscriptionJSON extends ClerkResourceJSON { past_due_at: number | null; subscription_items: BillingSubscriptionItemJSON[] | null; eligible_for_free_trial: boolean; + payer_id: string; +} + +/** + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. + */ +export interface BillingCreditBalanceJSON { + object: 'commerce_credit_balance'; + balance: BillingMoneyAmountJSON | null; +} + +/** + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. + */ +export interface BillingCreditLedgerJSON { + object: 'commerce_credit_ledger'; + id: string; + payer_id: string; + amount: number; + currency: string; + source_type: string; + source_id: string; + created_at: number; } /** diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 72d1c1afa0f..9bce4a89a31 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -938,6 +938,15 @@ export type __internal_LocalizationResource = { }; billingPage: { title: LocalizationValue; + accountCreditsSection: { + title: LocalizationValue; + viewHistory: LocalizationValue; + }; + creditHistoryPage: { + title: LocalizationValue; + tableHeader__amount: LocalizationValue; + tableHeader__date: LocalizationValue; + }; start: { headerTitle__payments: LocalizationValue; headerTitle__plans: LocalizationValue; @@ -1211,6 +1220,15 @@ export type __internal_LocalizationResource = { }; billingPage: { title: LocalizationValue; + accountCreditsSection: { + title: LocalizationValue; + viewHistory: LocalizationValue; + }; + creditHistoryPage: { + title: LocalizationValue; + tableHeader__amount: LocalizationValue; + tableHeader__date: LocalizationValue; + }; start: { headerTitle__payments: LocalizationValue; headerTitle__plans: LocalizationValue; diff --git a/packages/ui/src/components/AccountCredits/AccountCredits.tsx b/packages/ui/src/components/AccountCredits/AccountCredits.tsx new file mode 100644 index 00000000000..eaec2afc578 --- /dev/null +++ b/packages/ui/src/components/AccountCredits/AccountCredits.tsx @@ -0,0 +1,53 @@ +import { ProfileSection } from '@/ui/elements/Section'; +import { useCreditBalance, useSubscriberTypeLocalizationRoot } from '../../contexts'; +import { localizationKeys, Text } from '../../customizables'; +import { ArrowRight } from '../../icons'; +import { useRouter } from '../../router'; + +export const AccountCredits = () => { + const { data: creditBalance, isLoading } = useCreditBalance(); + const localizationRoot = useSubscriberTypeLocalizationRoot(); + const { navigate } = useRouter(); + + // Only show if balance is not null + if (!creditBalance?.balance || isLoading) { + return null; + } + + return ( + ({ + borderTopWidth: t.borderWidths.$normal, + borderTopStyle: t.borderStyles.$solid, + borderTopColor: t.colors.$borderAlpha100, + })} + > + + + + {creditBalance.balance.currencySymbol} + {creditBalance.balance.amountFormatted} + + + ({ justifyContent: 'start', height: t.sizes.$8 })]} + leftIconSx={() => ({ display: 'none' })} + rightIcon={ArrowRight} + rightIconSx={t => ({ + width: t.sizes.$4, + height: t.sizes.$4, + })} + onClick={() => void navigate('credit-history')} + /> + + + ); +}; diff --git a/packages/ui/src/components/AccountCredits/CreditHistoryPage.tsx b/packages/ui/src/components/AccountCredits/CreditHistoryPage.tsx new file mode 100644 index 00000000000..d0c056db007 --- /dev/null +++ b/packages/ui/src/components/AccountCredits/CreditHistoryPage.tsx @@ -0,0 +1,149 @@ +import { Header } from '@/ui/elements/Header'; +import { ProfileCard } from '@/ui/elements/ProfileCard'; + +import { useCreditBalance, useCreditHistory, useSubscriberTypeLocalizationRoot } from '../../contexts'; +import { Box, descriptors, localizationKeys, Spinner, Text } from '../../customizables'; +import { useRouter } from '../../router'; + +function formatCreditDate(date: Date): string { + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }).format(date); +} + +function formatCreditAmount(amount: number, currencySymbol: string): string { + const absAmount = Math.abs(amount); + const dollars = (absAmount / 100).toFixed(2); + const prefix = amount >= 0 ? '+' : '-'; + return `${prefix}${currencySymbol}${dollars}`; +} + +export const CreditHistoryPage = (): JSX.Element => { + const { navigate } = useRouter(); + const localizationRoot = useSubscriberTypeLocalizationRoot(); + const { data: creditBalance } = useCreditBalance(); + const { data: creditHistory, isLoading } = useCreditHistory(); + + const currencySymbol = creditBalance?.balance?.currencySymbol ?? '$'; + + if (isLoading) { + return ( + + + + + + ); + } + + return ( + + ({ + borderBlockEndWidth: t.borderWidths.$normal, + borderBlockEndStyle: t.borderStyles.$solid, + borderBlockEndColor: t.colors.$borderAlpha100, + marginBlockEnd: t.space.$4, + paddingBlockEnd: t.space.$4, + })} + > + void navigate('../')}> + + + + + ({ + borderWidth: t.borderWidths.$normal, + borderStyle: t.borderStyles.$solid, + borderColor: t.colors.$borderAlpha100, + borderRadius: t.radii.$lg, + overflow: 'clip', + })} + > + + + ({ + background: t.colors.$neutralAlpha25, + borderBlockEndWidth: t.borderWidths.$normal, + borderBlockEndStyle: t.borderStyles.$solid, + borderBlockEndColor: t.colors.$borderAlpha100, + })} + > + ({ padding: t.space.$3, textAlign: 'left' })} + > + + + ({ padding: t.space.$3, textAlign: 'left' })} + > + + + + + + {creditHistory?.data?.map(entry => ( + ({ + borderBlockEndWidth: t.borderWidths.$normal, + borderBlockEndStyle: t.borderStyles.$solid, + borderBlockEndColor: t.colors.$borderAlpha100, + '&:last-child': { borderBlockEnd: 'none' }, + })} + > + ({ padding: t.space.$3 })} + > + = 0 ? undefined : 'inherit' }} + > + {formatCreditAmount(entry.amount, currencySymbol)} + + + ({ padding: t.space.$3 })} + > + {formatCreditDate(entry.createdAt)} + + + ))} + + + + + ); +}; diff --git a/packages/ui/src/components/AccountCredits/index.ts b/packages/ui/src/components/AccountCredits/index.ts new file mode 100644 index 00000000000..64c3571e3d3 --- /dev/null +++ b/packages/ui/src/components/AccountCredits/index.ts @@ -0,0 +1,2 @@ +export * from './AccountCredits'; +export * from './CreditHistoryPage'; diff --git a/packages/ui/src/components/OrganizationProfile/OrganizationBillingPage.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationBillingPage.tsx index fe8018cd709..f47d28b8aad 100644 --- a/packages/ui/src/components/OrganizationProfile/OrganizationBillingPage.tsx +++ b/packages/ui/src/components/OrganizationProfile/OrganizationBillingPage.tsx @@ -12,6 +12,7 @@ import { PaymentAttemptsList } from '../PaymentAttempts'; import { PaymentMethods } from '../PaymentMethods'; import { StatementsList } from '../Statements'; import { SubscriptionsList } from '../Subscriptions'; +import { AccountCredits } from '../AccountCredits'; const orgTabMap = { 0: 'subscriptions', @@ -74,6 +75,7 @@ const OrganizationBillingPageInternal = withCardStateProvider(() => { has({ permission: 'org:sys_billing:manage' })}> + diff --git a/packages/ui/src/components/OrganizationProfile/OrganizationProfileRoutes.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationProfileRoutes.tsx index 72b8f115f43..8ee1198a057 100644 --- a/packages/ui/src/components/OrganizationProfile/OrganizationProfileRoutes.tsx +++ b/packages/ui/src/components/OrganizationProfile/OrganizationProfileRoutes.tsx @@ -37,6 +37,12 @@ const OrganizationPaymentAttemptPage = lazy(() => })), ); +const CreditHistoryPage = lazy(() => + import(/* webpackChunkName: "credit-history-page"*/ '../AccountCredits').then(module => ({ + default: module.CreditHistoryPage, + })), +); + const OrganizationSecurityPage = lazy(() => import(/* webpackChunkName: "op-security-page"*/ './OrganizationSecurityPage').then(module => ({ default: module.OrganizationSecurityPage, @@ -133,6 +139,11 @@ export const OrganizationProfileRoutes = ({ contentRef }: OrganizationProfileRou + + + + + diff --git a/packages/ui/src/components/UserProfile/BillingPage.tsx b/packages/ui/src/components/UserProfile/BillingPage.tsx index 51976e905de..3d555676142 100644 --- a/packages/ui/src/components/UserProfile/BillingPage.tsx +++ b/packages/ui/src/components/UserProfile/BillingPage.tsx @@ -11,6 +11,7 @@ import { PaymentAttemptsList } from '../PaymentAttempts'; import { PaymentMethods } from '../PaymentMethods'; import { StatementsList } from '../Statements'; import { SubscriptionsList } from '../Subscriptions'; +import { AccountCredits } from '../AccountCredits'; const tabMap = { 0: 'subscriptions', @@ -66,6 +67,7 @@ const BillingPageInternal = withCardStateProvider(() => { )} /> + diff --git a/packages/ui/src/components/UserProfile/UserProfileRoutes.tsx b/packages/ui/src/components/UserProfile/UserProfileRoutes.tsx index cf9899370af..fa0d669d06e 100644 --- a/packages/ui/src/components/UserProfile/UserProfileRoutes.tsx +++ b/packages/ui/src/components/UserProfile/UserProfileRoutes.tsx @@ -37,6 +37,12 @@ const PaymentAttemptPage = lazy(() => })), ); +const CreditHistoryPage = lazy(() => + import(/* webpackChunkName: "credit-history-page"*/ '../AccountCredits').then(module => ({ + default: module.CreditHistoryPage, + })), +); + export const UserProfileRoutes = () => { const { pages, shouldShowBilling, apiKeysProps } = useUserProfileContext(); const { apiKeysSettings, commerceSettings } = useEnvironment(); @@ -105,6 +111,11 @@ export const UserProfileRoutes = () => { + + + + + ) : null} diff --git a/packages/ui/src/contexts/components/Plans.tsx b/packages/ui/src/contexts/components/Plans.tsx index ad54266b733..b57e409d21c 100644 --- a/packages/ui/src/contexts/components/Plans.tsx +++ b/packages/ui/src/contexts/components/Plans.tsx @@ -1,9 +1,11 @@ import { + __experimental_useCreditBalance, __experimental_usePaymentAttempts, __experimental_usePaymentMethods, __experimental_usePlans, __experimental_useStatements, __experimental_useSubscription, + __internal_useCreditHistoryQuery, __internal_useOrganizationBase, useClerk, useSession, @@ -93,6 +95,24 @@ export const usePlans = (params?: { mode: 'cache' }) => { }); }; +export const useCreditBalance = () => { + const params = useBillingHookParams(); + const { data: subscription } = useSubscription(); + return __experimental_useCreditBalance({ + ...params, + payerId: subscription?.payerId, + }); +}; + +export const useCreditHistory = () => { + const params = useBillingHookParams(); + const { data: subscription } = useSubscription(); + return __internal_useCreditHistoryQuery({ + ...params, + payerId: subscription?.payerId, + }); +}; + type HandleSelectPlanProps = { plan: BillingPlanResource; planPeriod: BillingSubscriptionPlanPeriod;