From b89eda8c542f1f5ab9171a7015be093a65c9ccaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Pa=C5=BAdziurek?= Date: Tue, 5 May 2026 15:58:55 +0200 Subject: [PATCH] feat: add DeepLink "support" --- package.json | 2 +- src/apm/API.ts | 72 +++++++++++++++++++++++++-------------- src/apm/views/Redirect.ts | 6 ++-- 3 files changed, 51 insertions(+), 29 deletions(-) diff --git a/package.json b/package.json index eac4bde..4b6112a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "processout.js", - "version": "1.9.9", + "version": "1.9.10", "description": "ProcessOut.js is a JavaScript library for ProcessOut's payment processing API.", "scripts": { "build:processout": "tsc -p src/processout && uglifyjs --compress --keep-fnames --ie8 dist/processout.js -o dist/processout.js", diff --git a/src/apm/API.ts b/src/apm/API.ts index 64dbedb..87bbfd4 100644 --- a/src/apm/API.ts +++ b/src/apm/API.ts @@ -51,7 +51,7 @@ module ProcessOut { } & {} // Single conditional with multiple branches - much cleaner - type TransformFormField = + type TransformFormField = T extends { type: "phone" } ? T & { dialing_codes: Array<{ region_code: string; value: string; name: string }> } : T @@ -64,10 +64,10 @@ module ProcessOut { parameter_definitions: Array } } - + export type InstructionData = { type: 'instruction', - instruction: + instruction: | { type: 'message', value: string, @@ -109,6 +109,8 @@ module ProcessOut { redirect?: { hint: string, url: string, + type?: 'web' | 'deep_link', + confirmation_required?: boolean, } } @@ -123,6 +125,8 @@ module ProcessOut { redirect: { hint: string, url: string, + type?: 'web' | 'deep_link', + confirmation_required?: boolean, } } @@ -234,7 +238,7 @@ module ProcessOut { if (!data.error_type) { const isMismatch = (data as any).state === 'FAILED' && (data as any).success === true; let message = `${request} failed`; - + if (isMismatch) { message = `${request} failed, state and success are mismatched.`; } @@ -255,7 +259,7 @@ module ProcessOut { message: data.message } }; - + options.onFailure && options.onFailure(defaultError); return } @@ -269,7 +273,7 @@ module ProcessOut { message: `${request} failed as route does not exist`, category: 'APM - API' }) - + const routeNotFoundError = { success: false as const, state: 'FAILURE' as const, @@ -278,7 +282,7 @@ module ProcessOut { message: 'We were unable to connect to our API. Please contact support if you think this is an error.', } }; - + options.onFailure && options.onFailure(routeNotFoundError); break; default: { @@ -289,7 +293,7 @@ module ProcessOut { message: `${request} failed because of an error: ${data.message}`, category: 'APM - API' }) - + const defaultError = { success: false as const, state: 'FAILURE' as const, @@ -298,7 +302,7 @@ module ProcessOut { message: data.message } }; - + options.onFailure && options.onFailure(defaultError); break; } @@ -317,7 +321,7 @@ module ProcessOut { if (flow === 'authorization') { source = context.customerTokenId; } - + return this.post({ gateway_configuration_id: context.gatewayConfigurationId, @@ -342,7 +346,7 @@ module ProcessOut { source, }, options) } - + public static sendFormData = Record>(formData: F) { return (options: APIOptions) => { const context = ContextImpl.context; @@ -350,11 +354,21 @@ module ProcessOut { gateway_configuration_id: context.gatewayConfigurationId, submit_data: { parameters: formData } }; - + return this.post(data, options) } } + public static sendRedirectResult(success: boolean) { + return (options: APIOptions) => { + const context = ContextImpl.context; + return this.post({ + gateway_configuration_id: context.gatewayConfigurationId, + redirect: { success }, + }, options) + } + } + private static get( pathOrOptions: string | APIOptions = '', options: APIOptions = {} @@ -363,8 +377,8 @@ module ProcessOut { } private static post = Record>( - data: T, - pathOrOptions: string | APIOptions = '', + data: T, + pathOrOptions: string | APIOptions = '', options: APIOptions = {} ) { this.makeRequest('POST', pathOrOptions, data, options); @@ -405,7 +419,7 @@ module ProcessOut { } const context = ContextImpl.context; - + // Build endpoint based on flow type let endpoint = ['customers', context.customerId, 'apm-tokens', context.customerTokenId, 'tokenize'].join('/'); @@ -416,7 +430,7 @@ module ProcessOut { endpoint += `?source=${context.customerTokenId}` } } - + ContextImpl.context.poClient.apiRequest( method, endpoint, @@ -428,13 +442,13 @@ module ProcessOut { if (isErrorResponse(apiResponse)) { INITIAL_MAX_RETRIES = 0; - + // Clear polling timeout since we have an error if (POLLING_TIMEOUT_ID) { window.clearTimeout(POLLING_TIMEOUT_ID); POLLING_TIMEOUT_ID = null; } - + handleError(`${method} ${endpoint}`, apiResponse, internalOptions); return; } @@ -445,7 +459,7 @@ module ProcessOut { if (context.flow === 'authorization') { isValidation = isValidationResponse(apiResponse as AuthorizationNetworkResponse); } - + if (isValidation) { INITIAL_MAX_RETRIES = 0; @@ -482,7 +496,7 @@ module ProcessOut { if (apiResponse.state === 'PENDING') { // Reset cancellation flag when we get a PENDING response - this is when we need to start/resume polling POLLING_CANCELLED = false; - + if (internalOptions.initialTimestamp) { const currentTimestamp = Date.now(); const elapsedTime = currentTimestamp - internalOptions.initialTimestamp; @@ -513,12 +527,12 @@ module ProcessOut { // Return on first PENDING response OR anytime there are elements const shouldReturn = !internalOptions.hasReturnedFirstPending || apiResponse.elements; - + if (shouldReturn) { if (!internalOptions.hasReturnedFirstPending) { internalOptions.hasReturnedFirstPending = true; } - + internalOptions.onSuccess && internalOptions.onSuccess(this.transformResponse(apiResponse)); if (ContextImpl.context.confirmation.requiresAction && !storage.get('pending.startTime')) { INITIAL_MAX_RETRIES = 0; @@ -539,13 +553,13 @@ module ProcessOut { } INITIAL_MAX_RETRIES = 0; - + // Clear polling timeout since we're done if (POLLING_TIMEOUT_ID) { window.clearTimeout(POLLING_TIMEOUT_ID); POLLING_TIMEOUT_ID = null; } - + if (apiResponse.state === 'SUCCESS' && !ContextImpl.context.success.enabled) { storage.remove('pending.startTime') ContextImpl.context.events.emit('success', { trigger: 'immediate' }); @@ -553,6 +567,12 @@ module ProcessOut { } if (apiResponse.state === 'NEXT_STEP_REQUIRED' && apiResponse.redirect) { + // web flow doesn't support deep links, immediately share redirect failed + // BE should return a fallback URL or alternative action / customer instructions + if (apiResponse.redirect.type === 'deep_link') { + ContextImpl.context.page.load(APIImpl.sendRedirectResult(false)); + return; + } internalOptions.onSuccess && internalOptions.onSuccess(this.transformResponse( { ...apiResponse, @@ -583,13 +603,13 @@ module ProcessOut { } INITIAL_MAX_RETRIES = 0; - + // Clear polling timeout since we have a network error if (POLLING_TIMEOUT_ID) { window.clearTimeout(POLLING_TIMEOUT_ID); POLLING_TIMEOUT_ID = null; } - + const networkError = { success: false as const, state: 'FAILURE' as const, diff --git a/src/apm/views/Redirect.ts b/src/apm/views/Redirect.ts index e8c6c5b..6d9bea7 100644 --- a/src/apm/views/Redirect.ts +++ b/src/apm/views/Redirect.ts @@ -147,7 +147,9 @@ module ProcessOut { return page({ className: 'redirect-headless-empty', 'aria-hidden': 'true' }) } - const redirectLabel = `Pay ${formatCurrency(this.props.config.invoice.amount, this.props.config.invoice.currency)}`; + const redirectLabel = this.props.config.invoice + ? `Pay ${formatCurrency(this.props.config.invoice.amount, this.props.config.invoice.currency)}` + : (this.props.config.redirect?.hint ?? `Continue`); return ( Main({ config: this.props.config, @@ -172,4 +174,4 @@ module ProcessOut { ) } } -} \ No newline at end of file +}