diff --git a/.changeset/stale-pillows-sneeze.md b/.changeset/stale-pillows-sneeze.md new file mode 100644 index 00000000000..7640b43579e --- /dev/null +++ b/.changeset/stale-pillows-sneeze.md @@ -0,0 +1,9 @@ +--- +'@clerk/clerk-js': minor +'@clerk/nextjs': minor +'@clerk/shared': minor +'@clerk/clerk-react': minor +'@clerk/types': minor +--- + +wip diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 7e27858023f..3a1d10f12b7 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -15,6 +15,8 @@ import { import { addClerkPrefix, isAbsoluteUrl, stripScheme } from '@clerk/shared/url'; import { allSettled, handleValueOrFn, noop } from '@clerk/shared/utils'; import type { + __experimental_CheckoutInstance, + __experimental_CheckoutOptions, __internal_CheckoutProps, __internal_ComponentNavigationContext, __internal_OAuthConsentProps, @@ -136,6 +138,7 @@ import type { FapiClient, FapiRequestCallback } from './fapiClient'; import { createFapiClient } from './fapiClient'; import { createClientFromJwt } from './jwt-client'; import { APIKeys } from './modules/apiKeys'; +import { createCheckoutInstance } from './modules/checkout/instance'; import { CommerceBilling } from './modules/commerce'; import { BaseResource, @@ -195,6 +198,7 @@ export class Clerk implements ClerkInterface { }; private static _billing: CommerceBillingNamespace; private static _apiKeys: APIKeysNamespace; + private _checkout: ClerkInterface['__experimental_checkout'] | undefined; public client: ClientResource | undefined; public session: SignedInSessionResource | null | undefined; @@ -337,6 +341,13 @@ export class Clerk implements ClerkInterface { return Clerk._apiKeys; } + __experimental_checkout(options: __experimental_CheckoutOptions): __experimental_CheckoutInstance { + if (!this._checkout) { + this._checkout = params => createCheckoutInstance(this, params); + } + return this._checkout(options); + } + public __internal_getOption(key: K): ClerkOptions[K] { return this.#options[key]; } diff --git a/packages/clerk-js/src/core/modules/checkout/__tests__/manager.spec.ts b/packages/clerk-js/src/core/modules/checkout/__tests__/manager.spec.ts new file mode 100644 index 00000000000..5085014b742 --- /dev/null +++ b/packages/clerk-js/src/core/modules/checkout/__tests__/manager.spec.ts @@ -0,0 +1,634 @@ +import type { ClerkAPIResponseError, CommerceCheckoutResource } from '@clerk/types'; +import type { MockedFunction } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { type CheckoutCacheState, type CheckoutKey, createCheckoutManager, FETCH_STATUS } from '../manager'; + +// Type-safe mock for CommerceCheckoutResource +const createMockCheckoutResource = (overrides: Partial = {}): CommerceCheckoutResource => ({ + id: 'checkout_123', + status: 'pending', + externalClientSecret: 'cs_test_123', + externalGatewayId: 'gateway_123', + statement_id: 'stmt_123', + totals: { + totalDueNow: { amount: 1000, currency: 'USD', currencySymbol: '$', amountFormatted: '10.00' }, + credit: { amount: 0, currency: 'USD', currencySymbol: '$', amountFormatted: '0.00' }, + pastDue: { amount: 0, currency: 'USD', currencySymbol: '$', amountFormatted: '0.00' }, + subtotal: { amount: 1000, currency: 'USD', currencySymbol: '$', amountFormatted: '10.00' }, + grandTotal: { amount: 1000, currency: 'USD', currencySymbol: '$', amountFormatted: '10.00' }, + taxTotal: { amount: 0, currency: 'USD', currencySymbol: '$', amountFormatted: '0.00' }, + }, + isImmediatePlanChange: false, + planPeriod: 'month', + plan: { + id: 'plan_123', + name: 'Pro Plan', + description: 'Professional plan', + features: [], + amount: 1000, + amountFormatted: '10.00', + annualAmount: 12000, + annualAmountFormatted: '120.00', + currency: 'USD', + currencySymbol: '$', + slug: 'pro-plan', + }, + paymentSource: undefined, + confirm: vi.fn(), + reload: vi.fn(), + pathRoot: '/checkout', + ...overrides, +}); + +// Type-safe mock for ClerkAPIResponseError +const createMockError = (message = 'Test error'): ClerkAPIResponseError => { + const error = new Error(message) as ClerkAPIResponseError; + error.status = 400; + error.clerkTraceId = 'trace_123'; + error.clerkError = true; + return error; +}; + +// Helper to create a typed cache key +const createCacheKey = (key: string): CheckoutKey => key as CheckoutKey; + +describe('createCheckoutManager', () => { + const testCacheKey = createCacheKey('user-123-plan-456-monthly'); + let manager: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + manager = createCheckoutManager(testCacheKey); + }); + + describe('getCacheState', () => { + it('should return default state when cache is empty', () => { + const state = manager.getCacheState(); + + expect(state).toEqual({ + isStarting: false, + isConfirming: false, + error: null, + checkout: null, + fetchStatus: 'idle', + status: 'awaiting_initialization', + }); + }); + + it('should return immutable state object', () => { + const state = manager.getCacheState(); + + // State should be frozen + expect(Object.isFrozen(state)).toBe(true); + }); + }); + + describe('subscribe', () => { + it('should add listener and return unsubscribe function', () => { + const listener: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn(); + + const unsubscribe = manager.subscribe(listener); + + expect(typeof unsubscribe).toBe('function'); + expect(listener).not.toHaveBeenCalled(); + }); + + it('should remove listener when unsubscribe is called', async () => { + const listener: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn(); + + const unsubscribe = manager.subscribe(listener); + + // Trigger a state change + const mockCheckout = createMockCheckoutResource(); + const mockOperation = vi.fn().mockResolvedValue(mockCheckout); + await manager.executeOperation('start', mockOperation); + + expect(listener).toHaveBeenCalled(); + + // Clear the mock and unsubscribe + listener.mockClear(); + unsubscribe(); + + // Trigger another state change + const anotherMockOperation = vi.fn().mockResolvedValue(mockCheckout); + await manager.executeOperation('confirm', anotherMockOperation); + + // Listener should not be called after unsubscribing + expect(listener).not.toHaveBeenCalled(); + }); + + it('should notify all listeners when state changes', async () => { + const listener1: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn(); + const listener2: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn(); + const mockCheckout = createMockCheckoutResource(); + + manager.subscribe(listener1); + manager.subscribe(listener2); + + const mockOperation = vi.fn().mockResolvedValue(mockCheckout); + await manager.executeOperation('start', mockOperation); + + expect(listener1).toHaveBeenCalled(); + expect(listener2).toHaveBeenCalled(); + + // Verify they were called with the updated state + const expectedState = expect.objectContaining({ + checkout: mockCheckout, + isStarting: false, + error: null, + fetchStatus: 'idle', + status: 'awaiting_confirmation', + }); + + expect(listener1).toHaveBeenCalledWith(expectedState); + expect(listener2).toHaveBeenCalledWith(expectedState); + }); + + it('should handle multiple subscribe/unsubscribe cycles', () => { + const listener: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn(); + + // Subscribe and unsubscribe multiple times + const unsubscribe1 = manager.subscribe(listener); + unsubscribe1(); + + const unsubscribe2 = manager.subscribe(listener); + const unsubscribe3 = manager.subscribe(listener); + + unsubscribe2(); + unsubscribe3(); + + // Should not throw errors + expect(() => unsubscribe1()).not.toThrow(); + expect(() => unsubscribe2()).not.toThrow(); + }); + }); + + describe('executeOperation - start operations', () => { + it('should execute start operation successfully', async () => { + const mockCheckout = createMockCheckoutResource(); + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockResolvedValue(mockCheckout); + + const result = await manager.executeOperation('start', mockOperation); + + expect(mockOperation).toHaveBeenCalledOnce(); + expect(result).toBe(mockCheckout); + + const finalState = manager.getCacheState(); + expect(finalState).toEqual( + expect.objectContaining({ + isStarting: false, + checkout: mockCheckout, + error: null, + fetchStatus: 'idle', + status: 'awaiting_confirmation', + }), + ); + }); + + it('should set isStarting to true during operation', async () => { + let capturedState: CheckoutCacheState | null = null; + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(async () => { + // Capture state while operation is running + capturedState = manager.getCacheState(); + return createMockCheckoutResource(); + }); + + await manager.executeOperation('start', mockOperation); + + expect(capturedState).toEqual( + expect.objectContaining({ + isStarting: true, + fetchStatus: 'fetching', + }), + ); + }); + + it('should handle operation errors correctly', async () => { + const mockError = createMockError('Operation failed'); + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockRejectedValue(mockError); + + await expect(manager.executeOperation('start', mockOperation)).rejects.toThrow('Operation failed'); + + const finalState = manager.getCacheState(); + expect(finalState).toEqual( + expect.objectContaining({ + isStarting: false, + error: mockError, + fetchStatus: 'error', + status: 'awaiting_initialization', + }), + ); + }); + + it('should clear previous errors when starting new operation', async () => { + // First, create an error state + const mockError = createMockError('Previous error'); + const failingOperation: MockedFunction<() => Promise> = vi + .fn() + .mockRejectedValue(mockError); + + await expect(manager.executeOperation('start', failingOperation)).rejects.toThrow(); + + const errorState = manager.getCacheState(); + expect(errorState.error).toBe(mockError); + + // Now start a successful operation + const mockCheckout = createMockCheckoutResource(); + const successfulOperation: MockedFunction<() => Promise> = vi + .fn() + .mockResolvedValue(mockCheckout); + + await manager.executeOperation('start', successfulOperation); + + const finalState = manager.getCacheState(); + expect(finalState.error).toBeNull(); + expect(finalState.checkout).toBe(mockCheckout); + }); + }); + + describe('executeOperation - confirm operations', () => { + it('should execute confirm operation successfully', async () => { + const mockCheckout = createMockCheckoutResource({ status: 'completed' }); + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockResolvedValue(mockCheckout); + + const result = await manager.executeOperation('confirm', mockOperation); + + expect(result).toBe(mockCheckout); + + const finalState = manager.getCacheState(); + expect(finalState).toEqual( + expect.objectContaining({ + isConfirming: false, + checkout: mockCheckout, + error: null, + fetchStatus: 'idle', + status: 'completed', + }), + ); + }); + + it('should set isConfirming to true during operation', async () => { + let capturedState: CheckoutCacheState | null = null; + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(async () => { + capturedState = manager.getCacheState(); + return createMockCheckoutResource(); + }); + + await manager.executeOperation('confirm', mockOperation); + + expect(capturedState).toEqual( + expect.objectContaining({ + isConfirming: true, + fetchStatus: 'fetching', + }), + ); + }); + + it('should handle confirm operation errors', async () => { + const mockError = createMockError('Confirm failed'); + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockRejectedValue(mockError); + + await expect(manager.executeOperation('confirm', mockOperation)).rejects.toThrow('Confirm failed'); + + const finalState = manager.getCacheState(); + expect(finalState).toEqual( + expect.objectContaining({ + isConfirming: false, + error: mockError, + fetchStatus: 'error', + }), + ); + }); + }); + + describe('operation deduplication', () => { + it('should deduplicate concurrent start operations', async () => { + const mockCheckout = createMockCheckoutResource(); + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(mockCheckout), 50))); + + // Start multiple operations concurrently + const [result1, result2, result3] = await Promise.all([ + manager.executeOperation('start', mockOperation), + manager.executeOperation('start', mockOperation), + manager.executeOperation('start', mockOperation), + ]); + + // Operation should only be called once + expect(mockOperation).toHaveBeenCalledOnce(); + + // All results should be the same + expect(result1).toBe(mockCheckout); + expect(result2).toBe(mockCheckout); + expect(result3).toBe(mockCheckout); + }); + + it('should deduplicate concurrent confirm operations', async () => { + const mockCheckout = createMockCheckoutResource(); + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(mockCheckout), 50))); + + const [result1, result2] = await Promise.all([ + manager.executeOperation('confirm', mockOperation), + manager.executeOperation('confirm', mockOperation), + ]); + + expect(mockOperation).toHaveBeenCalledOnce(); + expect(result1).toBe(result2); + }); + + it('should allow different operation types to run concurrently', async () => { + const startCheckout = createMockCheckoutResource({ id: 'start_checkout' }); + const confirmCheckout = createMockCheckoutResource({ id: 'confirm_checkout' }); + + const startOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(startCheckout), 50))); + const confirmOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(confirmCheckout), 50))); + + const [startResult, confirmResult] = await Promise.all([ + manager.executeOperation('start', startOperation), + manager.executeOperation('confirm', confirmOperation), + ]); + + expect(startOperation).toHaveBeenCalledOnce(); + expect(confirmOperation).toHaveBeenCalledOnce(); + expect(startResult).toBe(startCheckout); + expect(confirmResult).toBe(confirmCheckout); + }); + + it('should propagate errors to all concurrent callers', async () => { + const mockError = createMockError('Concurrent operation failed'); + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(() => new Promise((_, reject) => setTimeout(() => reject(mockError), 50))); + + const promises = [ + manager.executeOperation('start', mockOperation), + manager.executeOperation('start', mockOperation), + manager.executeOperation('start', mockOperation), + ]; + + // All promises should reject with the same error + await expect(Promise.all(promises)).rejects.toThrow('Concurrent operation failed'); + expect(mockOperation).toHaveBeenCalledOnce(); + }); + + it('should allow sequential operations of the same type', async () => { + const checkout1 = createMockCheckoutResource({ id: 'checkout1' }); + const checkout2 = createMockCheckoutResource({ id: 'checkout2' }); + + const operation1: MockedFunction<() => Promise> = vi.fn().mockResolvedValue(checkout1); + const operation2: MockedFunction<() => Promise> = vi.fn().mockResolvedValue(checkout2); + + const result1 = await manager.executeOperation('start', operation1); + const result2 = await manager.executeOperation('start', operation2); + + expect(operation1).toHaveBeenCalledOnce(); + expect(operation2).toHaveBeenCalledOnce(); + expect(result1).toBe(checkout1); + expect(result2).toBe(checkout2); + }); + }); + + describe('clearCheckout', () => { + it('should clear checkout state when no operations are pending', () => { + const listener: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn(); + manager.subscribe(listener); + + manager.clearCheckout(); + + const state = manager.getCacheState(); + expect(state).toEqual({ + isStarting: false, + isConfirming: false, + error: null, + checkout: null, + fetchStatus: 'idle', + status: 'awaiting_initialization', + }); + + // Should notify listeners + expect(listener).toHaveBeenCalledWith(state); + }); + + it('should not clear checkout state when operations are pending', async () => { + const mockCheckout = createMockCheckoutResource(); + let resolveOperation: ((value: CommerceCheckoutResource) => void) | undefined; + + const mockOperation: MockedFunction<() => Promise> = vi.fn().mockImplementation( + () => + new Promise(resolve => { + resolveOperation = resolve; + }), + ); + + // Start an operation but don't resolve it yet + const operationPromise = manager.executeOperation('start', mockOperation); + + // Verify operation is in progress + let state = manager.getCacheState(); + expect(state.isStarting).toBe(true); + expect(state.fetchStatus).toBe('fetching'); + + // Try to clear while operation is pending + manager.clearCheckout(); + + // State should not be cleared + state = manager.getCacheState(); + expect(state.isStarting).toBe(true); + expect(state.fetchStatus).toBe('fetching'); + + // Resolve the operation + resolveOperation!(mockCheckout); + await operationPromise; + + // Now clearing should work + manager.clearCheckout(); + state = manager.getCacheState(); + expect(state.checkout).toBeNull(); + expect(state.status).toBe('awaiting_initialization'); + }); + }); + + describe('state derivation', () => { + it('should derive fetchStatus correctly based on operation state', async () => { + // Initially idle + expect(manager.getCacheState().fetchStatus).toBe(FETCH_STATUS.IDLE); + + // During operation - fetching + let capturedState: CheckoutCacheState | null = null; + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(async () => { + capturedState = manager.getCacheState(); + return createMockCheckoutResource(); + }); + + await manager.executeOperation('start', mockOperation); + expect(capturedState?.fetchStatus).toBe(FETCH_STATUS.FETCHING); + + // After successful operation - idle + expect(manager.getCacheState().fetchStatus).toBe(FETCH_STATUS.IDLE); + + // After error - error + const mockError = createMockError(); + const failingOperation: MockedFunction<() => Promise> = vi + .fn() + .mockRejectedValue(mockError); + + await expect(manager.executeOperation('start', failingOperation)).rejects.toThrow(); + expect(manager.getCacheState().fetchStatus).toBe(FETCH_STATUS.ERROR); + }); + + it('should derive status based on checkout state', async () => { + // Initially awaiting initialization + expect(manager.getCacheState().status).toBe('awaiting_initialization'); + + // After starting checkout - awaiting confirmation + const pendingCheckout = createMockCheckoutResource({ status: 'pending' }); + const startOperation: MockedFunction<() => Promise> = vi + .fn() + .mockResolvedValue(pendingCheckout); + + await manager.executeOperation('start', startOperation); + expect(manager.getCacheState().status).toBe('awaiting_confirmation'); + + // After completing checkout - completed + const completedCheckout = createMockCheckoutResource({ status: 'completed' }); + const confirmOperation: MockedFunction<() => Promise> = vi + .fn() + .mockResolvedValue(completedCheckout); + + await manager.executeOperation('confirm', confirmOperation); + expect(manager.getCacheState().status).toBe('completed'); + }); + + it('should handle both operations running simultaneously', async () => { + let startCapturedState: CheckoutCacheState | null = null; + let confirmCapturedState: CheckoutCacheState | null = null; + + const startOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(async () => { + await new Promise(resolve => setTimeout(resolve, 30)); + startCapturedState = manager.getCacheState(); + return createMockCheckoutResource({ id: 'start' }); + }); + + const confirmOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(async () => { + await new Promise(resolve => setTimeout(resolve, 20)); + confirmCapturedState = manager.getCacheState(); + return createMockCheckoutResource({ id: 'confirm' }); + }); + + await Promise.all([ + manager.executeOperation('start', startOperation), + manager.executeOperation('confirm', confirmOperation), + ]); + + // Both should have seen fetching status + expect(startCapturedState?.fetchStatus).toBe(FETCH_STATUS.FETCHING); + expect(confirmCapturedState?.fetchStatus).toBe(FETCH_STATUS.FETCHING); + + // At least one should have seen both operations running + expect( + (startCapturedState?.isStarting && startCapturedState?.isConfirming) || + (confirmCapturedState?.isStarting && confirmCapturedState?.isConfirming), + ).toBe(true); + }); + }); + + describe('cache isolation', () => { + it('should isolate state between different cache keys', async () => { + const manager1 = createCheckoutManager(createCacheKey('key1')); + const manager2 = createCheckoutManager(createCacheKey('key2')); + + const checkout1 = createMockCheckoutResource({ id: 'checkout1' }); + const checkout2 = createMockCheckoutResource({ id: 'checkout2' }); + + const operation1: MockedFunction<() => Promise> = vi.fn().mockResolvedValue(checkout1); + const operation2: MockedFunction<() => Promise> = vi.fn().mockResolvedValue(checkout2); + + await manager1.executeOperation('start', operation1); + await manager2.executeOperation('confirm', operation2); + + const state1 = manager1.getCacheState(); + const state2 = manager2.getCacheState(); + + expect(state1.checkout?.id).toBe('checkout1'); + expect(state1.status).toBe('awaiting_confirmation'); + + expect(state2.checkout?.id).toBe('checkout2'); + expect(state2.isStarting).toBe(false); + expect(state2.isConfirming).toBe(false); + }); + + it('should isolate listeners between different cache keys', async () => { + const manager1 = createCheckoutManager(createCacheKey('key1')); + const manager2 = createCheckoutManager(createCacheKey('key2')); + + const listener1: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn(); + const listener2: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn(); + + manager1.subscribe(listener1); + manager2.subscribe(listener2); + + // Trigger operation on manager1 + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockResolvedValue(createMockCheckoutResource()); + await manager1.executeOperation('start', mockOperation); + + // Only listener1 should be called + expect(listener1).toHaveBeenCalled(); + expect(listener2).not.toHaveBeenCalled(); + }); + + it('should isolate pending operations between different cache keys', async () => { + const manager1 = createCheckoutManager(createCacheKey('key1')); + const manager2 = createCheckoutManager(createCacheKey('key2')); + + const checkout1 = createMockCheckoutResource({ id: 'checkout1' }); + const checkout2 = createMockCheckoutResource({ id: 'checkout2' }); + + const operation1: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(checkout1), 50))); + const operation2: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(checkout2), 50))); + + // Start concurrent operations on both managers + const [result1, result2] = await Promise.all([ + manager1.executeOperation('start', operation1), + manager2.executeOperation('start', operation2), + ]); + + // Both operations should execute (not deduplicated across managers) + expect(operation1).toHaveBeenCalledOnce(); + expect(operation2).toHaveBeenCalledOnce(); + expect(result1).toBe(checkout1); + expect(result2).toBe(checkout2); + }); + }); +}); diff --git a/packages/clerk-js/src/core/modules/checkout/instance.ts b/packages/clerk-js/src/core/modules/checkout/instance.ts new file mode 100644 index 00000000000..1b5285724eb --- /dev/null +++ b/packages/clerk-js/src/core/modules/checkout/instance.ts @@ -0,0 +1,87 @@ +import type { + __experimental_CheckoutCacheState, + __experimental_CheckoutInstance, + __experimental_CheckoutOptions, + CommerceCheckoutResource, + ConfirmCheckoutParams, +} from '@clerk/types'; + +import type { Clerk } from '../../clerk'; +import { type CheckoutKey, createCheckoutManager } from './manager'; + +/** + * Generate cache key for checkout instance + */ +function cacheKey(options: { userId: string; orgId?: string; planId: string; planPeriod: string }): CheckoutKey { + const { userId, orgId, planId, planPeriod } = options; + return `${userId}-${orgId || 'user'}-${planId}-${planPeriod}` as CheckoutKey; +} + +/** + * Create a checkout instance with the given options + */ +function createCheckoutInstance( + clerk: Clerk, + options: __experimental_CheckoutOptions, +): __experimental_CheckoutInstance { + const { for: forOrganization, planId, planPeriod } = options; + + if (!clerk.user) { + throw new Error('Clerk: User is not authenticated'); + } + + if (forOrganization === 'organization' && !clerk.organization) { + throw new Error('Clerk: Use `setActive` to set the organization'); + } + + const checkoutKey = cacheKey({ + userId: clerk.user.id, + orgId: forOrganization === 'organization' ? clerk.organization?.id : undefined, + planId, + planPeriod, + }); + + const manager = createCheckoutManager(checkoutKey); + + const start = async (): Promise => { + return manager.executeOperation('start', async () => { + const result = await clerk.billing?.startCheckout({ + ...(forOrganization === 'organization' ? { orgId: clerk.organization?.id } : {}), + planId, + planPeriod, + }); + return result; + }); + }; + + const confirm = async (params: ConfirmCheckoutParams): Promise => { + return manager.executeOperation('confirm', async () => { + const checkout = manager.getCacheState().checkout; + if (!checkout) { + throw new Error('Clerk: Call `start` before `confirm`'); + } + return checkout.confirm(params); + }); + }; + + const finalize = ({ redirectUrl }: { redirectUrl?: string }) => { + void clerk.setActive({ session: clerk.session?.id, redirectUrl }); + }; + + const clear = () => manager.clearCheckout(); + + const subscribe = (listener: (state: __experimental_CheckoutCacheState) => void) => { + return manager.subscribe(listener); + }; + + return { + start, + confirm, + finalize, + clear, + subscribe, + getState: manager.getCacheState, + }; +} + +export { createCheckoutInstance }; diff --git a/packages/clerk-js/src/core/modules/checkout/manager.ts b/packages/clerk-js/src/core/modules/checkout/manager.ts new file mode 100644 index 00000000000..7222d524797 --- /dev/null +++ b/packages/clerk-js/src/core/modules/checkout/manager.ts @@ -0,0 +1,177 @@ +import type { __experimental_CheckoutCacheState, ClerkAPIResponseError, CommerceCheckoutResource } from '@clerk/types'; + +type CheckoutKey = string & { readonly __tag: 'CheckoutKey' }; + +const createManagerCache = () => { + const cache = new Map(); + const listeners = new Map void>>(); + const pendingOperations = new Map>>(); + + return { + cache, + listeners, + pendingOperations, + safeGet>(key: K, map: Map): NonNullable { + if (!map.has(key)) { + map.set(key, new Set() as V); + } + return map.get(key) as NonNullable; + }, + safeGetOperations(key: K): Map> { + if (!this.pendingOperations.has(key)) { + this.pendingOperations.set(key, new Map>()); + } + return this.pendingOperations.get(key) as Map>; + }, + }; +}; + +const managerCache = createManagerCache(); + +const CHECKOUT_STATUS = { + AWAITING_INITIALIZATION: 'awaiting_initialization', + AWAITING_CONFIRMATION: 'awaiting_confirmation', + COMPLETED: 'completed', +} as const; + +export const FETCH_STATUS = { + IDLE: 'idle', + FETCHING: 'fetching', + ERROR: 'error', +} as const; + +/** + * Derives the checkout state from the base state. + */ +function deriveCheckoutState( + baseState: Omit<__experimental_CheckoutCacheState, 'fetchStatus' | 'status'>, +): __experimental_CheckoutCacheState { + const fetchStatus = (() => { + if (baseState.isStarting || baseState.isConfirming) return FETCH_STATUS.FETCHING; + if (baseState.error) return FETCH_STATUS.ERROR; + return FETCH_STATUS.IDLE; + })(); + + const status = (() => { + if (baseState.checkout?.status === CHECKOUT_STATUS.COMPLETED) return CHECKOUT_STATUS.COMPLETED; + if (baseState.checkout) return CHECKOUT_STATUS.AWAITING_CONFIRMATION; + return CHECKOUT_STATUS.AWAITING_INITIALIZATION; + })(); + + return { + ...baseState, + fetchStatus, + status, + }; +} + +const defaultCacheState: __experimental_CheckoutCacheState = Object.freeze( + deriveCheckoutState({ + isStarting: false, + isConfirming: false, + error: null, + checkout: null, + }), +); + +/** + * Creates a checkout manager for handling checkout operations and state management. + * + * @param cacheKey - Unique identifier for the checkout instance + * @returns Manager with methods for checkout operations and state subscription + * + * @example + * ```typescript + * const manager = createCheckoutManager('user-123-plan-456-monthly'); + * const unsubscribe = manager.subscribe(state => console.log(state)); + * ``` + */ +function createCheckoutManager(cacheKey: CheckoutKey) { + const listeners = managerCache.safeGet(cacheKey, managerCache.listeners); + const pendingOperations = managerCache.safeGetOperations(cacheKey); + + const notifyListeners = () => { + listeners.forEach(listener => listener(getCacheState())); + }; + + const getCacheState = (): __experimental_CheckoutCacheState => { + return managerCache.cache.get(cacheKey) || defaultCacheState; + }; + + const updateCacheState = ( + updates: Partial>, + ): void => { + const currentState = getCacheState(); + const baseState = { ...currentState, ...updates }; + const newState = deriveCheckoutState(baseState); + managerCache.cache.set(cacheKey, Object.freeze(newState)); + notifyListeners(); + }; + + return { + subscribe(listener: (newState: __experimental_CheckoutCacheState) => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + + getCacheState, + + // Shared operation handler to eliminate duplication + async executeOperation( + operationType: 'start' | 'confirm', + operationFn: () => Promise, + ): Promise { + const operationId = `${cacheKey}-${operationType}`; + const isRunningField = operationType === 'start' ? 'isStarting' : 'isConfirming'; + + // Check if there's already a pending operation + const existingOperation = pendingOperations.get(operationId); + if (existingOperation) { + // Wait for the existing operation to complete and return its result + // If it fails, all callers should receive the same error + return await existingOperation; + } + + // Create and store the operation promise + const operationPromise = (async () => { + try { + // Mark operation as in progress and clear any previous errors + updateCacheState({ + [isRunningField]: true, + error: null, + ...(operationType === 'start' ? { checkout: null } : {}), + }); + + // Execute the checkout operation + const result = await operationFn(); + + // Update state with successful result + updateCacheState({ [isRunningField]: false, error: null, checkout: result }); + return result; + } catch (error) { + // Cast error to expected type and update state + const clerkError = error as ClerkAPIResponseError; + updateCacheState({ [isRunningField]: false, error: clerkError }); + throw error; + } finally { + // Always clean up pending operation tracker + pendingOperations.delete(operationId); + } + })(); + + pendingOperations.set(operationId, operationPromise); + return operationPromise; + }, + + clearCheckout(): void { + // Only reset the state if there are no pending operations + if (pendingOperations.size === 0) { + updateCacheState(defaultCacheState); + } + }, + }; +} + +export { createCheckoutManager, type CheckoutKey }; diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx index 6b5dad5fd62..eb09c51b776 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx @@ -1,3 +1,4 @@ +import { __experimental_useCheckout as useCheckout } from '@clerk/shared/react'; import { useEffect, useId, useRef, useState } from 'react'; import { Drawer, useDrawerContext } from '@/ui/elements/Drawer'; @@ -9,7 +10,6 @@ import { Box, Button, descriptors, Heading, localizationKeys, Span, Text, useApp import { transitionDurationValues, transitionTiming } from '../../foundations/transitions'; import { usePrefersReducedMotion } from '../../hooks'; import { useRouter } from '../../router'; -import { useCheckoutContextRoot } from './CheckoutPage'; const capitalize = (name: string) => name[0].toUpperCase() + name.slice(1); const lerp = (start: number, end: number, amt: number) => start + (end - start) * amt; @@ -18,7 +18,7 @@ export const CheckoutComplete = () => { const router = useRouter(); const { setIsOpen } = useDrawerContext(); const { newSubscriptionRedirectUrl } = useCheckoutContext(); - const { checkout } = useCheckoutContextRoot(); + const checkout = useCheckout(); const [mousePosition, setMousePosition] = useState({ x: 256, y: 256 }); const [currentPosition, setCurrentPosition] = useState({ x: 256, y: 256 }); diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx index 0c8f5912333..88b3b399cbf 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx @@ -1,4 +1,4 @@ -import { useOrganization } from '@clerk/shared/react'; +import { __experimental_useCheckout as useCheckout, useOrganization } from '@clerk/shared/react'; import type { CommerceCheckoutResource, CommerceMoney, @@ -23,21 +23,19 @@ import { Box, Button, Col, descriptors, Flex, Form, localizationKeys, Text } fro import { ChevronUpDown, InformationCircle } from '../../icons'; import * as AddPaymentSource from '../PaymentSources/AddPaymentSource'; import { PaymentSourceRow } from '../PaymentSources/PaymentSourceRow'; -import { useCheckoutContextRoot } from './CheckoutPage'; type PaymentMethodSource = 'existing' | 'new'; const capitalize = (name: string) => name[0].toUpperCase() + name.slice(1); export const CheckoutForm = withCardStateProvider(() => { - const ctx = useCheckoutContextRoot(); - const { checkout } = ctx; + const checkout = useCheckout(); + const { id, plan, totals, isImmediatePlanChange, __internal_checkout, planPeriod } = checkout; - if (!checkout) { + if (!id) { return null; } - const { plan, planPeriod, totals, isImmediatePlanChange } = checkout; const showCredits = !!totals.credit?.amount && totals.credit.amount > 0; const showPastDue = !!totals.pastDue?.amount && totals.pastDue.amount > 0; const showDowngradeInfo = !isImmediatePlanChange; @@ -115,18 +113,18 @@ export const CheckoutForm = withCardStateProvider(() => { )} - + ); }); const useCheckoutMutations = () => { const { organization } = useOrganization(); - const { subscriberType } = useCheckoutContext(); - const { updateCheckout, checkout } = useCheckoutContextRoot(); + const { subscriberType, onSubscriptionComplete } = useCheckoutContext(); + const { id, confirm } = useCheckout(); const card = useCardState(); - if (!checkout) { + if (!id) { throw new Error('Checkout not found'); } @@ -134,11 +132,11 @@ const useCheckoutMutations = () => { card.setLoading(); card.setError(undefined); try { - const newCheckout = await checkout.confirm({ + await confirm({ ...params, ...(subscriberType === 'org' ? { orgId: organization?.id } : {}), }); - updateCheckout(newCheckout); + onSubscriptionComplete?.(); } catch (error) { handleError(error, [], card.setError); } finally { @@ -152,36 +150,25 @@ const useCheckoutMutations = () => { const data = new FormData(e.currentTarget); const paymentSourceId = data.get('payment_source_id') as string; - await confirmCheckout({ + return confirmCheckout({ paymentSourceId, ...(subscriberType === 'org' ? { orgId: organization?.id } : {}), }); }; const addPaymentSourceAndPay = async (ctx: { stripeSetupIntent?: SetupIntent }) => { - await confirmCheckout({ + return confirmCheckout({ gateway: 'stripe', paymentToken: ctx.stripeSetupIntent?.payment_method as string, ...(subscriberType === 'org' ? { orgId: organization?.id } : {}), }); }; - const payWithTestCard = async () => { - card.setLoading(); - card.setError(undefined); - try { - const newCheckout = await checkout.confirm({ - gateway: 'stripe', - useTestCard: true, - ...(subscriberType === 'org' ? { orgId: organization?.id } : {}), - }); - updateCheckout(newCheckout); - } catch (error) { - handleError(error, [], card.setError); - } finally { - card.setIdle(); - } - }; + const payWithTestCard = () => + confirmCheckout({ + gateway: 'stripe', + useTestCard: true, + }); return { payWithExistingPaymentSource, @@ -295,25 +282,25 @@ export const PayWithTestPaymentSource = () => { const AddPaymentSourceForCheckout = withCardStateProvider(() => { const { addPaymentSourceAndPay } = useCheckoutMutations(); - const { checkout } = useCheckoutContextRoot(); + const { id, __internal_checkout, totals } = useCheckout(); - if (!checkout) { + if (!id) { return null; } return ( - {checkout.totals.totalDueNow.amount > 0 ? ( + {totals.totalDueNow.amount > 0 ? ( ) : ( diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx index 6d8f2747ee2..f183131897d 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx @@ -1,130 +1,73 @@ -import { useClerk, useOrganization, useUser } from '@clerk/shared/react'; -import type { ClerkAPIError, CommerceCheckoutResource } from '@clerk/types'; -import { createContext, useContext, useEffect, useMemo } from 'react'; -import useSWR from 'swr'; -import useSWRMutation from 'swr/mutation'; +import { + __experimental_CheckoutProvider as CheckoutProvider, + __experimental_useCheckout as useCheckout, +} from '@clerk/shared/react'; +import { useEffect, useMemo } from 'react'; -import { useCheckoutContext } from '../../contexts'; +import { useCheckoutContext } from '@/ui/contexts/components'; -type CheckoutStatus = 'pending' | 'ready' | 'completed' | 'missing_payer_email' | 'invalid_plan_change' | 'error'; - -const CheckoutContextRoot = createContext<{ - checkout: CommerceCheckoutResource | undefined; - isLoading: boolean; - updateCheckout: (checkout: CommerceCheckoutResource) => void; - errors: ClerkAPIError[]; - startCheckout: () => void; - status: CheckoutStatus; -} | null>(null); - -export const useCheckoutContextRoot = () => { - const ctx = useContext(CheckoutContextRoot); - if (!ctx) { - throw new Error('CheckoutContextRoot not found'); - } - return ctx; -}; - -const useCheckoutCreator = () => { - const { planId, planPeriod, subscriberType = 'user', onSubscriptionComplete } = useCheckoutContext(); - const clerk = useClerk(); - const { organization } = useOrganization(); - - const { user } = useUser(); - - const cacheKey = { - key: `commerce-checkout`, - userId: user?.id, - arguments: { - ...(subscriberType === 'org' ? { orgId: organization?.id } : {}), - planId, - planPeriod, - }, - }; - - // Manually handle the cache - const { data, mutate } = useSWR(cacheKey); - - // Use `useSWRMutation` to avoid revalidations on stale-data/focus etc. - const { - trigger: startCheckout, - isMutating, - error, - } = useSWRMutation( - cacheKey, - key => - clerk.billing?.startCheckout( - // @ts-expect-error things are typed as optional - key.arguments, - ), - { - // Never throw on error, we want to handle it during rendering - throwOnError: false, - onSuccess: data => { - mutate(data, false); - }, - }, - ); +const Initiator = () => { + const checkout = useCheckout(); useEffect(() => { - void startCheckout(); - return () => { - // Clear the cache on unmount - mutate(undefined, false); - }; + checkout.start().catch(() => null); + return checkout.clear; }, []); - - return { - checkout: data, - startCheckout, - updateCheckout: (checkout: CommerceCheckoutResource) => { - void mutate(checkout, false); - onSubscriptionComplete?.(); - }, - isMutating, - errors: error?.errors, - }; + return null; }; const Root = ({ children }: { children: React.ReactNode }) => { - const { checkout, isMutating, updateCheckout, errors, startCheckout } = useCheckoutCreator(); - - const status = useMemo(() => { - if (isMutating) return 'pending'; - const completedCode = 'completed'; - if (checkout?.status === completedCode) return completedCode; - if (checkout) return 'ready'; - - const missingCode = 'missing_payer_email'; - const isMissingPayerEmail = !!errors?.some((e: ClerkAPIError) => e.code === missingCode); - if (isMissingPayerEmail) return missingCode; - const invalidChangeCode = 'invalid_plan_change'; - if (errors?.[0]?.code === invalidChangeCode) return invalidChangeCode; - return 'error'; - }, [isMutating, errors, checkout, checkout?.status]); + const { planId, planPeriod, subscriberType } = useCheckoutContext(); return ( - + {children} - + ); }; -const Stage = ({ children, name }: { children: React.ReactNode; name: CheckoutStatus }) => { - const ctx = useCheckoutContextRoot(); - if (ctx.status !== name) { +const Stage = ({ children, name }: { children: React.ReactNode; name: ReturnType['status'] }) => { + const { status } = useCheckout(); + if (status !== name) { + return null; + } + return children; +}; + +const FetchStatus = ({ + children, + status, +}: { + children: React.ReactNode; + status: 'idle' | 'fetching' | 'error' | 'invalid_plan_change' | 'missing_payer_email'; +}) => { + const { fetchStatus, error } = useCheckout(); + + const internalFetchStatus = useMemo(() => { + if (fetchStatus === 'error' && error?.errors) { + const errorCodes = error.errors.map(e => e.code); + + if (errorCodes.includes('missing_payer_email')) { + return 'missing_payer_email'; + } + + if (errorCodes.includes('invalid_plan_change')) { + return 'invalid_plan_change'; + } + } + + return fetchStatus; + }, [fetchStatus, error]); + + if (internalFetchStatus !== status) { return null; } return children; }; -export { Root, Stage }; +export { Root, Stage, FetchStatus }; diff --git a/packages/clerk-js/src/ui/components/Checkout/index.tsx b/packages/clerk-js/src/ui/components/Checkout/index.tsx index 67c37daecc4..251510e75b4 100644 --- a/packages/clerk-js/src/ui/components/Checkout/index.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/index.tsx @@ -23,31 +23,33 @@ export const Checkout = (props: __internal_CheckoutProps) => { - - + + + + + + + + + + + + + + + + - - - - - - - - - - - - - + diff --git a/packages/clerk-js/src/ui/components/Checkout/parts.tsx b/packages/clerk-js/src/ui/components/Checkout/parts.tsx index 4fd79a91562..3bbcd29e5f1 100644 --- a/packages/clerk-js/src/ui/components/Checkout/parts.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/parts.tsx @@ -1,3 +1,4 @@ +import { __experimental_useCheckout as useCheckout } from '@clerk/shared/react'; import { useMemo } from 'react'; import { Alert } from '@/ui/elements/Alert'; @@ -8,10 +9,10 @@ import { useCheckoutContext } from '../../contexts'; import { Box, descriptors, Flex, localizationKeys, useLocalizations } from '../../customizables'; // TODO(@COMMERCE): Is this causing bundle size issues ? import { EmailForm } from '../UserProfile/EmailForm'; -import { useCheckoutContextRoot } from './CheckoutPage'; export const GenericError = () => { - const { errors } = useCheckoutContextRoot(); + const { error } = useCheckout(); + const { translateError } = useLocalizations(); const { t } = useLocalizations(); return ( @@ -29,7 +30,7 @@ export const GenericError = () => { variant='danger' colorScheme='danger' > - {errors ? translateError(errors[0]) : t(localizationKeys('unstable__errors.form_param_value_invalid'))} + {error ? translateError(error.errors[0]) : t(localizationKeys('unstable__errors.form_param_value_invalid'))} @@ -37,14 +38,13 @@ export const GenericError = () => { }; export const InvalidPlanScreen = () => { - const { errors } = useCheckoutContextRoot(); + const { planPeriod } = useCheckoutContext(); + const { error } = useCheckout(); const planFromError = useMemo(() => { - const error = errors?.find(e => e.code === 'invalid_plan_change'); - return error?.meta?.plan; - }, [errors]); - - const { planPeriod } = useCheckoutContext(); + const _error = error?.errors.find(e => e.code === 'invalid_plan_change'); + return _error?.meta?.plan; + }, [error]); if (!planFromError) { return null; @@ -92,7 +92,7 @@ export const InvalidPlanScreen = () => { }; export const AddEmailForm = () => { - const { startCheckout } = useCheckoutContextRoot(); + const { start } = useCheckout(); const { setIsOpen } = useDrawerContext(); return ( @@ -105,7 +105,7 @@ export const AddEmailForm = () => { start().catch(() => null)} onReset={() => setIsOpen(false)} disableAutoFocus /> diff --git a/packages/clerk-js/src/ui/contexts/CoreClerkContextWrapper.tsx b/packages/clerk-js/src/ui/contexts/CoreClerkContextWrapper.tsx index 78b186bba95..b4389dc9363 100644 --- a/packages/clerk-js/src/ui/contexts/CoreClerkContextWrapper.tsx +++ b/packages/clerk-js/src/ui/contexts/CoreClerkContextWrapper.tsx @@ -1,4 +1,5 @@ import { + __experimental_CheckoutProvider as CheckoutProvider, ClerkInstanceContext, ClientContext, OrganizationProvider, @@ -54,7 +55,14 @@ export function CoreClerkContextWrapper(props: CoreClerkContextWrapperProps): JS {...organizationCtx.value} swrConfig={props.swrConfig} > - {props.children} + + + {props.children} + + diff --git a/packages/nextjs/src/client-boundary/hooks.ts b/packages/nextjs/src/client-boundary/hooks.ts index c7748535767..b421af88bf4 100644 --- a/packages/nextjs/src/client-boundary/hooks.ts +++ b/packages/nextjs/src/client-boundary/hooks.ts @@ -11,6 +11,8 @@ export { useSignUp, useUser, useReverification, + __experimental_useCheckout, + __experimental_CheckoutProvider, } from '@clerk/clerk-react'; export { diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index f57260044ac..36cfe4a4940 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -54,6 +54,8 @@ export { useSignUp, useUser, useReverification, + __experimental_useCheckout, + __experimental_CheckoutProvider, } from './client-boundary/hooks'; /** diff --git a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap index 3a757e9ec5d..88cf376df50 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -32,6 +32,8 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "UserButton", "UserProfile", "Waitlist", + "__experimental_CheckoutProvider", + "__experimental_useCheckout", "useAuth", "useClerk", "useEmailLink", diff --git a/packages/react/src/contexts/ClerkContextProvider.tsx b/packages/react/src/contexts/ClerkContextProvider.tsx index a86a84a47e1..f38f9f785f5 100644 --- a/packages/react/src/contexts/ClerkContextProvider.tsx +++ b/packages/react/src/contexts/ClerkContextProvider.tsx @@ -1,5 +1,11 @@ import { deriveState } from '@clerk/shared/deriveState'; -import { ClientContext, OrganizationProvider, SessionContext, UserContext } from '@clerk/shared/react'; +import { + __experimental_CheckoutProvider as CheckoutProvider, + ClientContext, + OrganizationProvider, + SessionContext, + UserContext, +} from '@clerk/shared/react'; import type { ClientResource, InitialState, Resources } from '@clerk/types'; import React from 'react'; @@ -89,7 +95,14 @@ export function ClerkContextProvider(props: ClerkContextProvider) { - {children} + + + {children} + + diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 5a6cb60cb6b..b06246921dc 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -10,4 +10,6 @@ export { useUser, useSession, useReverification, + __experimental_useCheckout, + __experimental_CheckoutProvider, } from '@clerk/shared/react'; diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index c92f3d57692..01ffd61a650 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -705,6 +705,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { return this.clerkjs?.apiKeys; } + __experimental_checkout = (...args: Parameters) => { + return this.clerkjs?.__experimental_checkout(...args); + }; + __unstable__setEnvironment(...args: any): void { if (this.clerkjs && '__unstable__setEnvironment' in this.clerkjs) { (this.clerkjs as any).__unstable__setEnvironment(args); diff --git a/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap index e2ae044851f..6cca006a4fb 100644 --- a/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap @@ -34,6 +34,8 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "UserProfile", "Waitlist", "WithClerkState", + "__experimental_CheckoutProvider", + "__experimental_useCheckout", "useAuth", "useClerk", "useEmailLink", diff --git a/packages/shared/src/error.ts b/packages/shared/src/error.ts index 77f2479e516..d5462079bc1 100644 --- a/packages/shared/src/error.ts +++ b/packages/shared/src/error.ts @@ -1,20 +1,36 @@ -import type { ClerkAPIError, ClerkAPIErrorJSON } from '@clerk/types'; +import type { + ClerkAPIError, + ClerkAPIErrorJSON, + ClerkAPIResponseError as ClerkAPIResponseErrorInterface, +} from '@clerk/types'; +/** + * + */ export function isUnauthorizedError(e: any): boolean { const status = e?.status; const code = e?.errors?.[0]?.code; return code === 'authentication_invalid' && status === 401; } +/** + * + */ export function isCaptchaError(e: ClerkAPIResponseError): boolean { return ['captcha_invalid', 'captcha_not_enabled', 'captcha_missing_token'].includes(e.errors[0].code); } +/** + * + */ export function is4xxError(e: any): boolean { const status = e?.status; return !!status && status >= 400 && status < 500; } +/** + * + */ export function isNetworkError(e: any): boolean { // TODO: revise during error handling epic const message = (`${e.message}${e.name}` || '').toLowerCase().replace(/\s+/g, ''); @@ -36,10 +52,16 @@ export interface MetamaskError extends Error { data?: unknown; } +/** + * + */ export function isKnownError(error: any): error is ClerkAPIResponseError | ClerkRuntimeError | MetamaskError { return isClerkAPIResponseError(error) || isMetamaskError(error) || isClerkRuntimeError(error); } +/** + * + */ export function isClerkAPIResponseError(err: any): err is ClerkAPIResponseError { return 'clerkError' in err; } @@ -47,8 +69,8 @@ export function isClerkAPIResponseError(err: any): err is ClerkAPIResponseError /** * Checks if the provided error object is an instance of ClerkRuntimeError. * - * @param {any} err - The error object to check. - * @returns {boolean} True if the error is a ClerkRuntimeError, false otherwise. + * @param err - The error object to check. + * @returns True if the error is a ClerkRuntimeError, false otherwise. * * @example * const error = new ClerkRuntimeError('An error occurred'); @@ -64,26 +86,44 @@ export function isClerkRuntimeError(err: any): err is ClerkRuntimeError { return 'clerkRuntimeError' in err; } +/** + * + */ export function isReverificationCancelledError(err: any) { return isClerkRuntimeError(err) && err.code === 'reverification_cancelled'; } +/** + * + */ export function isMetamaskError(err: any): err is MetamaskError { return 'code' in err && [4001, 32602, 32603].includes(err.code) && 'message' in err; } +/** + * + */ export function isUserLockedError(err: any) { return isClerkAPIResponseError(err) && err.errors?.[0]?.code === 'user_locked'; } +/** + * + */ export function isPasswordPwnedError(err: any) { return isClerkAPIResponseError(err) && err.errors?.[0]?.code === 'form_password_pwned'; } +/** + * + */ export function parseErrors(data: ClerkAPIErrorJSON[] = []): ClerkAPIError[] { return data.length > 0 ? data.map(parseError) : []; } +/** + * + */ export function parseError(error: ClerkAPIErrorJSON): ClerkAPIError { return { code: error.code, @@ -100,6 +140,9 @@ export function parseError(error: ClerkAPIErrorJSON): ClerkAPIError { }; } +/** + * + */ export function errorToJSON(error: ClerkAPIError | null): ClerkAPIErrorJSON { return { code: error?.code || '', @@ -116,7 +159,7 @@ export function errorToJSON(error: ClerkAPIError | null): ClerkAPIErrorJSON { }; } -export class ClerkAPIResponseError extends Error { +export class ClerkAPIResponseError extends Error implements ClerkAPIResponseErrorInterface { clerkError: true; status: number; @@ -156,6 +199,7 @@ export class ClerkAPIResponseError extends Error { * Custom error class for representing Clerk runtime errors. * * @class ClerkRuntimeError + * * @example * throw new ClerkRuntimeError('An error occurred', { code: 'password_invalid' }); */ @@ -194,7 +238,7 @@ export class ClerkRuntimeError extends Error { /** * Returns a string representation of the error. * - * @returns {string} A formatted string with the error name and message. + * @returns A formatted string with the error name and message. */ public toString = () => { return `[${this.name}]\nMessage:${this.message}`; @@ -212,6 +256,9 @@ export class EmailLinkError extends Error { } } +/** + * + */ export function isEmailLinkError(err: Error): err is EmailLinkError { return err.name === 'EmailLinkError'; } @@ -270,6 +317,9 @@ export interface ErrorThrower { throw(message: string): never; } +/** + * + */ export function buildErrorThrower({ packageName, customMessages }: ErrorThrowerOptions): ErrorThrower { let pkg = packageName; @@ -278,6 +328,9 @@ export function buildErrorThrower({ packageName, customMessages }: ErrorThrowerO ...customMessages, }; + /** + * + */ function buildMessage(rawMessage: string, replacements?: Record) { if (!replacements) { return `${pkg}: ${rawMessage}`; diff --git a/packages/shared/src/react/contexts.tsx b/packages/shared/src/react/contexts.tsx index e3170145c09..33b5e231c9f 100644 --- a/packages/shared/src/react/contexts.tsx +++ b/packages/shared/src/react/contexts.tsx @@ -3,6 +3,7 @@ import type { ClerkOptions, ClientResource, + CommerceSubscriptionPlanPeriod, LoadedClerk, OrganizationResource, SignedInSessionResource, @@ -23,6 +24,21 @@ const [SessionContext, useSessionContext] = createContextAndHook({}); +type UseCheckoutOptions = { + for?: 'organization'; + planPeriod: CommerceSubscriptionPlanPeriod; + planId: string; +}; + +const [CheckoutContext, useCheckoutContext] = createContextAndHook('CheckoutContext'); + +const __experimental_CheckoutProvider = ({ children, ...rest }: PropsWithChildren) => { + return {children}; +}; + +/** + * @internal + */ function useOptionsContext(): ClerkOptions { const context = React.useContext(OptionsContext); if (context === undefined) { @@ -61,6 +77,9 @@ const OrganizationProvider = ({ ); }; +/** + * @internal + */ function useAssertWrappedByClerkProvider(displayNameOrFn: string | (() => void)): void { const ctx = React.useContext(ClerkInstanceContext); @@ -95,5 +114,7 @@ export { useSessionContext, ClerkInstanceContext, useClerkInstanceContext, + useCheckoutContext, + __experimental_CheckoutProvider, useAssertWrappedByClerkProvider, }; diff --git a/packages/shared/src/react/hooks/index.ts b/packages/shared/src/react/hooks/index.ts index e801745a0b7..666b60a2f57 100644 --- a/packages/shared/src/react/hooks/index.ts +++ b/packages/shared/src/react/hooks/index.ts @@ -12,3 +12,4 @@ export { useStatements as __experimental_useStatements } from './useStatements'; export { usePaymentAttempts as __experimental_usePaymentAttempts } from './usePaymentAttempts'; export { usePaymentMethods as __experimental_usePaymentMethods } from './usePaymentMethods'; export { useSubscriptionItems as __experimental_useSubscriptionItems } from './useSubscriptionItems'; +export { useCheckout as __experimental_useCheckout } from './useCheckout'; diff --git a/packages/shared/src/react/hooks/useCheckout.ts b/packages/shared/src/react/hooks/useCheckout.ts new file mode 100644 index 00000000000..be14dde4b30 --- /dev/null +++ b/packages/shared/src/react/hooks/useCheckout.ts @@ -0,0 +1,133 @@ +import type { + __experimental_CheckoutCacheState, + CommerceCheckoutResource, + CommerceSubscriptionPlanPeriod, + ConfirmCheckoutParams, +} from '@clerk/types'; +import { useMemo, useSyncExternalStore } from 'react'; + +import type { ClerkAPIResponseError } from '../..'; +import { useCheckoutContext } from '../contexts'; +import { useClerk } from './useClerk'; +import { useOrganization } from './useOrganization'; +import { useUser } from './useUser'; + +/** + * Utility type that removes function properties from a type. + */ +type RemoveFunctions = { + [K in keyof T as T[K] extends (...args: any[]) => any ? never : K]: T[K]; +}; + +/** + * Utility type that makes all properties nullable. + */ +type Nullable = { + [K in keyof T]: null; +}; + +type CheckoutProperties = Omit< + RemoveFunctions, + 'paymentSource' | 'plan' | 'pathRoot' | 'reload' | 'confirm' +> & { + plan: RemoveFunctions; + paymentSource: RemoveFunctions; + __internal_checkout: CommerceCheckoutResource; +}; +type NullableCheckoutProperties = Nullable< + Omit, 'paymentSource' | 'plan' | 'pathRoot' | 'reload' | 'confirm'> +> & { + plan: null; + paymentSource: null; + __internal_checkout: null; +}; + +type UseCheckoutReturn = (CheckoutProperties | NullableCheckoutProperties) & { + confirm: (params: ConfirmCheckoutParams) => Promise; + start: () => Promise; + isStarting: boolean; + isConfirming: boolean; + error: ClerkAPIResponseError | null; + status: __experimental_CheckoutCacheState['status']; + clear: () => void; + finalize: (params: { redirectUrl?: string }) => void; + fetchStatus: 'idle' | 'fetching' | 'error'; + getState: () => __experimental_CheckoutCacheState; +}; + +type UseCheckoutOptions = { + for?: 'organization'; + planPeriod: CommerceSubscriptionPlanPeriod; + planId: string; +}; + +export const useCheckout = (options?: UseCheckoutOptions): UseCheckoutReturn => { + const contextOptions = useCheckoutContext(); + const { for: forOrganization, planId, planPeriod } = options || contextOptions; + + const clerk = useClerk(); + const { organization } = useOrganization(); + const { user } = useUser(); + + if (!user) { + throw new Error('Clerk: User is not authenticated'); + } + + if (forOrganization === 'organization' && !organization) { + throw new Error('Clerk: Use `setActive` to set the organization'); + } + + const manager = useMemo( + () => clerk.__experimental_checkout({ planId, planPeriod, for: forOrganization }), + [user.id, organization?.id, planId, planPeriod, forOrganization], + ); + + const managerProperties = useSyncExternalStore( + cb => manager.subscribe(cb), + () => manager.getState(), + () => manager.getState(), + ); + + const properties = useMemo(() => { + if (!managerProperties.checkout) { + return { + id: null, + externalClientSecret: null, + externalGatewayId: null, + statement_id: null, + status: null, + totals: null, + isImmediatePlanChange: null, + planPeriod: null, + plan: null, + paymentSource: null, + }; + } + const { + reload, + confirm, + pathRoot, + // All the above need to be removed from the properties + ...rest + } = managerProperties.checkout; + return rest; + }, [managerProperties.checkout]); + + return { + ...properties, + getState: manager.getState, + // @ts-expect-error - this is a temporary fix to allow the checkout to be null + checkout: null, + // @ts-expect-error - this is a temporary fix to allow the checkout to be null + __internal_checkout: managerProperties.checkout, + start: manager.start, + confirm: manager.confirm, + clear: manager.clear, + finalize: manager.finalize, + isStarting: managerProperties.isStarting, + isConfirming: managerProperties.isConfirming, + error: managerProperties.error, + status: managerProperties.status, + fetchStatus: managerProperties.fetchStatus, + }; +}; diff --git a/packages/shared/src/react/index.ts b/packages/shared/src/react/index.ts index 4b716f41052..dc0c3d1ecd3 100644 --- a/packages/shared/src/react/index.ts +++ b/packages/shared/src/react/index.ts @@ -14,4 +14,5 @@ export { UserContext, useSessionContext, useUserContext, + __experimental_CheckoutProvider, } from './contexts'; diff --git a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap index 296c327d52e..21505a9fe57 100644 --- a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap @@ -44,6 +44,8 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "UserButton", "UserProfile", "Waitlist", + "__experimental_CheckoutProvider", + "__experimental_useCheckout", "useAuth", "useClerk", "useEmailLink", diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index 80a96be7a51..5e6e0e840df 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -43,3 +43,15 @@ export interface ClerkRuntimeError { code: string; message: string; } + +/** + * Interface representing a Clerk API Response Error. + */ +export interface ClerkAPIResponseError extends Error { + clerkError: true; + status: number; + message: string; + clerkTraceId?: string; + retryAfter?: number; + errors: ClerkAPIError[]; +} diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index bf0fd935b27..7ab740fc85e 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -1,3 +1,4 @@ +import type { ClerkAPIResponseError } from './api'; import type { APIKeysNamespace } from './apiKeys'; import type { APIKeysTheme, @@ -20,9 +21,11 @@ import type { import type { ClientResource } from './client'; import type { CommerceBillingNamespace, + CommerceCheckoutResource, CommercePlanResource, CommerceSubscriberType, CommerceSubscriptionPlanPeriod, + ConfirmCheckoutParams, } from './commerce'; import type { CustomMenuItem } from './customMenuItems'; import type { CustomPage } from './customPages'; @@ -55,6 +58,34 @@ import type { UserResource } from './user'; import type { Autocomplete, DeepPartial, DeepSnakeToCamel } from './utils'; import type { WaitlistResource } from './waitlist'; +type __experimental_CheckoutStatus = 'awaiting_initialization' | 'awaiting_confirmation' | 'completed'; + +export type __experimental_CheckoutCacheState = Readonly<{ + isStarting: boolean; + isConfirming: boolean; + error: ClerkAPIResponseError | null; + checkout: CommerceCheckoutResource | null; + fetchStatus: 'idle' | 'fetching' | 'error'; + status: __experimental_CheckoutStatus; +}>; + +export type __experimental_CheckoutOptions = { + for?: 'organization'; + planPeriod: CommerceSubscriptionPlanPeriod; + planId: string; +}; + +export type __experimental_CheckoutInstance = { + confirm: (params: ConfirmCheckoutParams) => Promise; + start: () => Promise; + clear: () => void; + finalize: (params: { redirectUrl?: string }) => void; + subscribe: (listener: (state: __experimental_CheckoutCacheState) => void) => () => void; + getState: () => __experimental_CheckoutCacheState; +}; + +type __experimental_CheckoutFunction = (options: __experimental_CheckoutOptions) => __experimental_CheckoutInstance; + /** * @inline */ @@ -780,6 +811,13 @@ export interface Clerk { * This API is in early access and may change in future releases. */ apiKeys: APIKeysNamespace; + + /** + * Checkout API + * @experimental + * This API is in early access and may change in future releases. + */ + __experimental_checkout: __experimental_CheckoutFunction; } export type HandleOAuthCallbackParams = TransferableOption &