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 5374806db9c..deaf51ee985 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..49fc96e2420 --- /dev/null +++ b/packages/clerk-js/src/core/modules/checkout/instance.ts @@ -0,0 +1,88 @@ +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 complete = (params?: { redirectUrl: string }) => { + const { redirectUrl } = params || {}; + 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, + complete, + 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..4240e528ca5 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,8 @@ export const CheckoutComplete = () => { const router = useRouter(); const { setIsOpen } = useDrawerContext(); const { newSubscriptionRedirectUrl } = useCheckoutContext(); - const { checkout } = useCheckoutContextRoot(); + const { checkout } = useCheckout(); + const { totals, paymentSource, planPeriodStart } = checkout; const [mousePosition, setMousePosition] = useState({ x: 256, y: 256 }); const [currentPosition, setCurrentPosition] = useState({ x: 256, y: 256 }); @@ -82,7 +83,7 @@ export const CheckoutComplete = () => { } }; - if (!checkout) { + if (!totals) { return null; } @@ -309,7 +310,7 @@ export const CheckoutComplete = () => { as='h2' textVariant='h2' localizationKey={ - checkout.totals.totalDueNow.amount > 0 + totals.totalDueNow.amount > 0 ? localizationKeys('commerce.checkout.title__paymentSuccessful') : localizationKeys('commerce.checkout.title__subscriptionSuccessful') } @@ -364,7 +365,7 @@ export const CheckoutComplete = () => { }), })} localizationKey={ - checkout.totals.totalDueNow.amount > 0 + totals.totalDueNow.amount > 0 ? localizationKeys('commerce.checkout.description__paymentSuccessful') : localizationKeys('commerce.checkout.description__subscriptionSuccessful') } @@ -396,28 +397,26 @@ export const CheckoutComplete = () => { - + 0 + totals.totalDueNow.amount > 0 ? localizationKeys('commerce.checkout.lineItems.title__paymentMethod') : localizationKeys('commerce.checkout.lineItems.title__subscriptionBegins') } /> 0 - ? checkout.paymentSource - ? checkout.paymentSource.paymentMethod !== 'card' - ? `${capitalize(checkout.paymentSource.paymentMethod)}` - : `${capitalize(checkout.paymentSource.cardType)} ⋯ ${checkout.paymentSource.last4}` + totals.totalDueNow.amount > 0 + ? paymentSource + ? paymentSource.paymentMethod !== 'card' + ? `${capitalize(paymentSource.paymentMethod)}` + : `${capitalize(paymentSource.cardType)} ⋯ ${paymentSource.last4}` : '–' - : checkout.planPeriodStart - ? formatDate(new Date(checkout.planPeriodStart)) + : planPeriodStart + ? formatDate(new Date(planPeriodStart)) : '–' } /> diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx index 5e605dcb55c..78aa1079e14 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx @@ -1,10 +1,5 @@ -import { useOrganization } from '@clerk/shared/react'; -import type { - CommerceCheckoutResource, - CommerceMoney, - CommercePaymentSourceResource, - ConfirmCheckoutParams, -} from '@clerk/types'; +import { __experimental_useCheckout as useCheckout, useOrganization } from '@clerk/shared/react'; +import type { CommerceMoney, CommercePaymentSourceResource, ConfirmCheckoutParams } from '@clerk/types'; import { useMemo, useState } from 'react'; import { Card } from '@/ui/elements/Card'; @@ -22,21 +17,20 @@ 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(); - if (!checkout) { + const { id, plan, totals, isImmediatePlanChange, planPeriod } = 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; @@ -114,18 +108,19 @@ export const CheckoutForm = withCardStateProvider(() => { )} - + ); }); const useCheckoutMutations = () => { const { organization } = useOrganization(); - const { subscriberType } = useCheckoutContext(); - const { updateCheckout, checkout } = useCheckoutContextRoot(); + const { subscriberType, onSubscriptionComplete } = useCheckoutContext(); + const { checkout } = useCheckout(); + const { id, confirm } = checkout; const card = useCardState(); - if (!checkout) { + if (!id) { throw new Error('Checkout not found'); } @@ -133,11 +128,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 { @@ -158,22 +153,11 @@ const useCheckoutMutations = () => { const addPaymentSourceAndPay = (ctx: { gateway: 'stripe'; paymentToken: string }) => confirmCheckout(ctx); - 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, @@ -182,13 +166,19 @@ const useCheckoutMutations = () => { }; }; -const CheckoutFormElements = ({ checkout }: { checkout: CommerceCheckoutResource }) => { +const CheckoutFormElements = () => { + const { checkout } = useCheckout(); + const { id, totals } = checkout; const { data: paymentSources } = usePaymentMethods(); const [paymentMethodSource, setPaymentMethodSource] = useState(() => paymentSources.length > 0 ? 'existing' : 'new', ); + if (!id) { + return null; + } + return ( ({ padding: t.space.$4 })} > {/* only show if there are payment sources and there is a total due now */} - {paymentSources.length > 0 && checkout.totals.totalDueNow.amount > 0 && ( + {paymentSources.length > 0 && totals.totalDueNow.amount > 0 && ( )} @@ -287,9 +276,10 @@ export const PayWithTestPaymentSource = () => { const AddPaymentSourceForCheckout = withCardStateProvider(() => { const { addPaymentSourceAndPay } = useCheckoutMutations(); - const { checkout } = useCheckoutContextRoot(); + const { checkout } = useCheckout(); + const { id, totals } = checkout; - if (!checkout) { + if (!id) { return null; } @@ -302,10 +292,10 @@ const AddPaymentSourceForCheckout = withCardStateProvider(() => { - {checkout.totals.totalDueNow.amount > 0 ? ( + {totals.totalDueNow.amount > 0 ? ( ) : ( @@ -317,18 +307,19 @@ const AddPaymentSourceForCheckout = withCardStateProvider(() => { const ExistingPaymentSourceForm = withCardStateProvider( ({ - checkout, totalDueNow, paymentSources, }: { - checkout: CommerceCheckoutResource; totalDueNow: CommerceMoney; paymentSources: CommercePaymentSourceResource[]; }) => { + const { checkout } = useCheckout(); + const { paymentSource } = checkout; + const { payWithExistingPaymentSource } = useCheckoutMutations(); const card = useCardState(); const [selectedPaymentSource, setSelectedPaymentSource] = useState( - checkout.paymentSource || paymentSources.find(p => p.isDefault), + paymentSource || paymentSources.find(p => p.isDefault), ); const options = useMemo(() => { @@ -354,7 +345,7 @@ const ExistingPaymentSourceForm = withCardStateProvider( rowGap: t.space.$4, })} > - {checkout.totals.totalDueNow.amount > 0 ? ( + {totalDueNow.amount > 0 ? (