@funnelfox/billing 0.8.0-ffb-631.40 → 0.8.0-ffb-704.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -561,7 +561,7 @@ exports.PaymentMethod = void 0;
561
561
  /**
562
562
  * @fileoverview Constants for Funnefox SDK
563
563
  */
564
- const SDK_VERSION = '0.8.0-ffb-631.40';
564
+ const SDK_VERSION = '0.8.0-ffb-704.42';
565
565
  const DEFAULTS = {
566
566
  BASE_URL: 'https://billing.funnelfox.com',
567
567
  REGION: 'default',
@@ -615,6 +615,9 @@ const ERROR_CODES = {
615
615
  CONFIGURATION_ERROR: 'CONFIGURATION_ERROR',
616
616
  NETWORK_ERROR: 'NETWORK_ERROR',
617
617
  };
618
+ const PRIMER_DEFAULTS = {
619
+ PAYMENT_HANDLING: 'MANUAL',
620
+ API_VERSION: '2.4'};
618
621
  const ALLOWED_BUTTON_PAYMENT_METHODS = [
619
622
  exports.PaymentMethod.GOOGLE_PAY,
620
623
  exports.PaymentMethod.APPLE_PAY,
@@ -678,6 +681,13 @@ const APPLE_PAY_COLLECTING_EMAIL_OPTIONS = {
678
681
  requiredShippingContactFields: ['emailAddress'],
679
682
  },
680
683
  };
684
+ /**
685
+ * Backend decline codes that mean "the 3DS check failed" (as opposed to "3DS passed, then the
686
+ * issuer declined"). `null`: the codes cannot tell the two apart, so any decline after a
687
+ * challenge is `3ds_failed` (FFB-704 spike: in production both cases share REFER_TO_CARD_ISSUER,
688
+ * DO_NOT_HONOR and AUTHENTICATION_REQUIRED; human decision 10).
689
+ */
690
+ const THREE_DS_AUTH_DECLINE_CODES = null;
681
691
 
682
692
  /**
683
693
  * @fileoverview Input validation utilities for Funnefox SDK
@@ -1366,6 +1376,37 @@ class APIClient {
1366
1376
  throw new NetworkError('Network request failed', error);
1367
1377
  }
1368
1378
  }
1379
+ /**
1380
+ * Sends the request exactly once: no retry and no client timeout (FFB-704, decision 9).
1381
+ * Same URL, headers and error classes as `request()`. Used only by `silentPurchase`'s
1382
+ * `handle3DS` mode for `one_click` and `resume_payment`, which must not be POSTed twice.
1383
+ */
1384
+ async requestOnce(endpoint, options = {}) {
1385
+ const url = `${this.baseUrl}/${this.orgId}${endpoint}`;
1386
+ const requestOptions = {
1387
+ method: 'GET',
1388
+ headers: {
1389
+ 'Content-Type': 'application/json',
1390
+ 'X-SDK-Version': SDK_VERSION,
1391
+ ...(options.headers || {}),
1392
+ },
1393
+ ...options,
1394
+ };
1395
+ try {
1396
+ return await this._makeRequest(url, requestOptions);
1397
+ }
1398
+ catch (error) {
1399
+ if (error instanceof APIError) {
1400
+ // Same instance, enriched: _makeRequest leaves errorCode empty; fill it from the body.
1401
+ error.errorCode ?? (error.errorCode = getResponseErrorCodes(error.response)[0] ?? null);
1402
+ throw error;
1403
+ }
1404
+ if (error instanceof Error && error.name === 'NetworkError') {
1405
+ throw error;
1406
+ }
1407
+ throw new NetworkError('Network request failed', error);
1408
+ }
1409
+ }
1369
1410
  async _makeRequest(url, options) {
1370
1411
  let response;
1371
1412
  try {
@@ -1557,6 +1598,23 @@ class APIClient {
1557
1598
  body: JSON.stringify(payload),
1558
1599
  }));
1559
1600
  }
1601
+ /** `one_click`, POSTed exactly once (see `requestOnce`). */
1602
+ async oneClickOnce(payload) {
1603
+ return (await this.requestOnce(API_ENDPOINTS.ONE_CLICK, {
1604
+ method: 'POST',
1605
+ body: JSON.stringify(payload),
1606
+ }));
1607
+ }
1608
+ /** `resume_payment`, POSTed exactly once (see `requestOnce`). */
1609
+ async resumePaymentOnce(params) {
1610
+ return (await this.requestOnce(API_ENDPOINTS.RESUME_PAYMENT, {
1611
+ method: 'POST',
1612
+ body: JSON.stringify({
1613
+ order_id: params.orderId,
1614
+ resume_token: params.resumeToken,
1615
+ }),
1616
+ }));
1617
+ }
1560
1618
  async createSimpleClientSession(payload) {
1561
1619
  return (await this.request(API_ENDPOINTS.CREATE_SIMPLE_CLIENT_SESSION, {
1562
1620
  method: 'POST',
@@ -1568,6 +1626,19 @@ class APIClient {
1568
1626
  }));
1569
1627
  }
1570
1628
  }
1629
+ /**
1630
+ * Returns the `error[].code` values of a backend error envelope
1631
+ * (`{status: 'error', error: [{msg, code, type}]}`), e.g. from `APIError.response`.
1632
+ */
1633
+ function getResponseErrorCodes(response) {
1634
+ const errors = response?.error;
1635
+ if (!Array.isArray(errors)) {
1636
+ return [];
1637
+ }
1638
+ return errors
1639
+ .map(item => item?.code)
1640
+ .filter((code) => typeof code === 'string');
1641
+ }
1571
1642
 
1572
1643
  class SessionService {
1573
1644
  constructor() {
@@ -2085,9 +2156,6 @@ class CheckoutInstance extends EventEmitter {
2085
2156
  this.counter = 0;
2086
2157
  this.cachedSessionResponse = null;
2087
2158
  this.taxRecalcSeq = 0;
2088
- // WebKit allows one ApplePaySession per page: a second tap while the sheet is
2089
- // still opening throws InvalidAccessError, but Primer asks to start first.
2090
- this.isApplePaySheetOpen = false;
2091
2159
  this.cardSessionFieldConfig = {};
2092
2160
  this.isTelemetryEnabled = false;
2093
2161
  this.telemetryCleanup = null;
@@ -2254,7 +2322,6 @@ class CheckoutInstance extends EventEmitter {
2254
2322
  primerHandler.handleFailure(error?.message || 'Payment processing failed');
2255
2323
  }
2256
2324
  finally {
2257
- this.isApplePaySheetOpen = false;
2258
2325
  this.onLoaderChangeWithRace(false);
2259
2326
  this._setState('ready');
2260
2327
  }
@@ -2280,7 +2347,6 @@ class CheckoutInstance extends EventEmitter {
2280
2347
  primerHandler.handleFailure(error?.message || 'Payment processing failed');
2281
2348
  }
2282
2349
  finally {
2283
- this.isApplePaySheetOpen = false;
2284
2350
  this.emit(EVENTS.PURCHASE_COMPLETED);
2285
2351
  this.onLoaderChangeWithRace(false);
2286
2352
  this._setState('ready');
@@ -2609,7 +2675,6 @@ class CheckoutInstance extends EventEmitter {
2609
2675
  onTokenizeSuccess: this.handleTokenizeSuccess,
2610
2676
  onResumeSuccess: this.handleResumeSuccess,
2611
2677
  onResumeError: error => {
2612
- this.isApplePaySheetOpen = false;
2613
2678
  if (error.stack?.includes('PROCESSOR_3DS') &&
2614
2679
  error.code === 'RESUME_ERROR' &&
2615
2680
  error.message?.includes('fetch resume key')) {
@@ -2619,23 +2684,15 @@ class CheckoutInstance extends EventEmitter {
2619
2684
  this.emit(EVENTS.PURCHASE_FAILURE, error);
2620
2685
  },
2621
2686
  onCheckoutFail: error => {
2622
- this.isApplePaySheetOpen = false;
2623
2687
  this.emit(EVENTS.PURCHASE_FAILURE, error);
2624
2688
  },
2625
2689
  onTokenizeError: error => {
2626
- this.isApplePaySheetOpen = false;
2627
2690
  this.emit(EVENTS.PURCHASE_FAILURE, error);
2628
2691
  },
2629
2692
  onTokenizeStart: () => {
2630
2693
  wasPaymentProcessedStarted = true;
2631
2694
  },
2632
2695
  onTokenizeShouldStart: data => {
2633
- if (data.paymentMethodType === exports.PaymentMethod.APPLE_PAY) {
2634
- if (this.isApplePaySheetOpen) {
2635
- return false;
2636
- }
2637
- this.isApplePaySheetOpen = true;
2638
- }
2639
2696
  this.activePaymentMethodType = data.paymentMethodType;
2640
2697
  this.emit(EVENTS.ERROR, undefined);
2641
2698
  this.emit(EVENTS.START_PURCHASE, data.paymentMethodType);
@@ -2647,7 +2704,6 @@ class CheckoutInstance extends EventEmitter {
2647
2704
  this.emit(EVENTS.ERROR, undefined);
2648
2705
  break;
2649
2706
  case 'PAYMENT_METHOD_UNSELECTED':
2650
- this.isApplePaySheetOpen = false;
2651
2707
  if (!wasPaymentProcessedStarted) {
2652
2708
  this.emit(EVENTS.PURCHASE_CANCELLED);
2653
2709
  }
@@ -2917,6 +2973,256 @@ class CheckoutInstance extends EventEmitter {
2917
2973
  }
2918
2974
  }
2919
2975
 
2976
+ /**
2977
+ * @fileoverview 3DS challenge for one-click purchases (FFB-704). Internal module.
2978
+ *
2979
+ * Primer can run a 3DS challenge only from a tokenization handler
2980
+ * (`handler.continueWithNewClientToken`), and a one-click purchase has no tokenization in the
2981
+ * browser: the backend charges the vaulted instrument. Mechanism M1 (proven by the FFB-704 spike
2982
+ * on the Primer sandbox): open a customer-bound CHECKOUT client session, start a headless
2983
+ * checkout, tokenize a vaulted instrument through the vault manager as a stand-in, and in
2984
+ * `onTokenizeSuccess` continue with the one-click `action_required_token` instead. Primer then
2985
+ * shows its 3DS overlay; `onResumeSuccess` yields the resume token for `resume_payment`.
2986
+ */
2987
+ /** Primer client-token intents that carry a 3DS step. */
2988
+ const PRIMER_3DS_INTENTS = ['3DS_AUTHENTICATION', 'PROCESSOR_3DS'];
2989
+ /** Default `threeDSTimeoutMs`. */
2990
+ const DEFAULT_THREE_DS_TIMEOUT_MS = 600000;
2991
+ /** Close button of Primer's 3DS overlay (Primer Web SDK 2.57.3, verified in the spike). */
2992
+ const PRIMER_OVERLAY_CLOSE_SELECTOR = '[data-testid="OverlayContent.closeButton"]';
2993
+ /** Decodes the `intent` of a Primer client token (a JWT); `null` if it is not one. */
2994
+ function getPrimerTokenIntent(token) {
2995
+ try {
2996
+ const parts = token.split('.');
2997
+ if (parts.length !== 3) {
2998
+ return null;
2999
+ }
3000
+ const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
3001
+ const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
3002
+ const payload = JSON.parse(atob(padded));
3003
+ return typeof payload?.intent === 'string' ? payload.intent : null;
3004
+ }
3005
+ catch {
3006
+ return null;
3007
+ }
3008
+ }
3009
+ /** The error Primer reports when the user closes the 3DS overlay (same check as checkout.ts). */
3010
+ function isThreeDSCloseError(error) {
3011
+ return Boolean(error?.stack?.includes('PROCESSOR_3DS') &&
3012
+ error.code === 'RESUME_ERROR' &&
3013
+ error.message?.includes('fetch resume key'));
3014
+ }
3015
+ /**
3016
+ * Picks the vaulted instrument used as the stand-in tokenization. Primer returns the vault
3017
+ * oldest-first and has no "default" flag; the 3DS check runs on the action token, not on this
3018
+ * instrument. Cards are preferred because their vault tokenization is silent (no CVV prompt).
3019
+ */
3020
+ function pickVaultedMethod(methods) {
3021
+ if (!methods || methods.length === 0) {
3022
+ return null;
3023
+ }
3024
+ return (methods.find(method => method.paymentInstrumentType === 'PAYMENT_CARD') ||
3025
+ methods[0]);
3026
+ }
3027
+ function dismissPrimerOverlay() {
3028
+ if (typeof document === 'undefined') {
3029
+ return;
3030
+ }
3031
+ const button = document.querySelector(PRIMER_OVERLAY_CLOSE_SELECTOR);
3032
+ button?.click();
3033
+ }
3034
+ function toPrimerError(message, error) {
3035
+ return error instanceof PrimerError ? error : new PrimerError(message, error);
3036
+ }
3037
+ function callSafely(fn) {
3038
+ try {
3039
+ fn();
3040
+ }
3041
+ catch {
3042
+ // Primer handler calls are best effort once the result is decided.
3043
+ }
3044
+ }
3045
+ /**
3046
+ * Runs the 3DS challenge for a one-click order and, when Primer hands back a resume token,
3047
+ * calls `resume_payment` once. Never rejects; the caller maps the result to an outcome.
3048
+ */
3049
+ async function runOneClickThreeDS(params) {
3050
+ const { apiClient, region, priceId, externalId, orderId, actionToken } = params;
3051
+ const intent = getPrimerTokenIntent(actionToken);
3052
+ if (!intent || !PRIMER_3DS_INTENTS.includes(intent)) {
3053
+ return {
3054
+ kind: 'error',
3055
+ error: new PrimerError('Unsupported one-click action: the action token is not a Primer 3DS client token', { intent }),
3056
+ };
3057
+ }
3058
+ const timeoutMs = Number.isFinite(params.timeoutMs) && params.timeoutMs > 0
3059
+ ? params.timeoutMs
3060
+ : DEFAULT_THREE_DS_TIMEOUT_MS;
3061
+ return new Promise(resolve => {
3062
+ let settled = false;
3063
+ let tokenized = false;
3064
+ let timer = null;
3065
+ let headless = null;
3066
+ const settle = (result) => {
3067
+ if (settled) {
3068
+ return;
3069
+ }
3070
+ settled = true;
3071
+ if (timer) {
3072
+ clearTimeout(timer);
3073
+ timer = null;
3074
+ }
3075
+ headless = null;
3076
+ resolve(result);
3077
+ };
3078
+ timer = setTimeout(() => {
3079
+ timer = null;
3080
+ settle({ kind: 'abandoned' });
3081
+ // Close Primer's overlay; the close error it raises is ignored (already settled).
3082
+ dismissPrimerOverlay();
3083
+ }, timeoutMs);
3084
+ const onTokenizeSuccess = (_data, handler) => {
3085
+ if (settled) {
3086
+ callSafely(() => handler.handleFailure());
3087
+ return;
3088
+ }
3089
+ tokenized = true;
3090
+ // Ignore the stand-in vaulted token: run the one-click 3DS action instead.
3091
+ handler.continueWithNewClientToken(actionToken);
3092
+ };
3093
+ const onResumeSuccess = async (data, handler) => {
3094
+ if (settled) {
3095
+ callSafely(() => handler.handleFailure());
3096
+ return;
3097
+ }
3098
+ // The challenge is over: the timeout bounds only the challenge, never the POST.
3099
+ if (timer) {
3100
+ clearTimeout(timer);
3101
+ timer = null;
3102
+ }
3103
+ let response;
3104
+ try {
3105
+ response = await apiClient.resumePaymentOnce({
3106
+ orderId,
3107
+ resumeToken: data.resumeToken,
3108
+ });
3109
+ }
3110
+ catch (error) {
3111
+ callSafely(() => handler.handleFailure());
3112
+ settle({
3113
+ kind: 'error',
3114
+ error: error,
3115
+ failedRequest: 'resume_payment',
3116
+ });
3117
+ return;
3118
+ }
3119
+ const resumeData = response?.data;
3120
+ if (response?.status === 'success' &&
3121
+ resumeData?.checkout_status === 'succeeded' &&
3122
+ !resumeData.action_required_token) {
3123
+ callSafely(() => handler.handleSuccess());
3124
+ }
3125
+ else {
3126
+ // A chained action token is never continued (no second challenge).
3127
+ callSafely(() => handler.handleFailure(resumeData?.failed_message_for_user || undefined));
3128
+ }
3129
+ settle({ kind: 'resumed', response });
3130
+ };
3131
+ const onResumeError = (error) => {
3132
+ if (isThreeDSCloseError(error)) {
3133
+ settle({ kind: 'abandoned' });
3134
+ return;
3135
+ }
3136
+ settle({
3137
+ kind: 'failed',
3138
+ error: toPrimerError('3DS authentication failed', error),
3139
+ });
3140
+ };
3141
+ const onCheckoutFail = (error) => {
3142
+ if (tokenized) {
3143
+ settle({
3144
+ kind: 'failed',
3145
+ error: toPrimerError('3DS authentication failed', error),
3146
+ });
3147
+ }
3148
+ else {
3149
+ settle({
3150
+ kind: 'error',
3151
+ error: toPrimerError('Primer checkout failed before the 3DS challenge', error),
3152
+ });
3153
+ }
3154
+ };
3155
+ const onTokenizeError = (error) => {
3156
+ settle({
3157
+ kind: 'error',
3158
+ error: toPrimerError('Failed to tokenize the saved payment method', error),
3159
+ });
3160
+ };
3161
+ (async () => {
3162
+ await new PrimerWrapper().ensurePrimerLoaded();
3163
+ if (settled) {
3164
+ return;
3165
+ }
3166
+ let clientToken;
3167
+ try {
3168
+ const session = await apiClient.createClientSession({
3169
+ priceId,
3170
+ externalId,
3171
+ region,
3172
+ clientMetadata: {},
3173
+ });
3174
+ clientToken = apiClient.processSessionResponse(session).clientToken;
3175
+ }
3176
+ catch (error) {
3177
+ settle({
3178
+ kind: 'error',
3179
+ error: error,
3180
+ failedRequest: 'create_client_session',
3181
+ });
3182
+ return;
3183
+ }
3184
+ if (settled) {
3185
+ return;
3186
+ }
3187
+ // A dedicated instance, not the HeadlessManager cache used by checkouts.
3188
+ const instance = await window.Primer.createHeadless(clientToken, {
3189
+ paymentHandling: PRIMER_DEFAULTS.PAYMENT_HANDLING,
3190
+ apiVersion: PRIMER_DEFAULTS.API_VERSION,
3191
+ onAvailablePaymentMethodsLoad: () => { },
3192
+ onTokenizeSuccess,
3193
+ onTokenizeError,
3194
+ onResumeSuccess,
3195
+ onResumeError,
3196
+ onCheckoutFail,
3197
+ });
3198
+ headless = await instance;
3199
+ await headless.start();
3200
+ if (settled) {
3201
+ return;
3202
+ }
3203
+ const vaultManager = headless.createVaultManager();
3204
+ const methods = await vaultManager.fetchVaultedPaymentMethods();
3205
+ const method = pickVaultedMethod(methods);
3206
+ if (!method) {
3207
+ settle({
3208
+ kind: 'error',
3209
+ error: new PrimerError('No saved payment method found for the one-click 3DS challenge'),
3210
+ });
3211
+ return;
3212
+ }
3213
+ if (settled) {
3214
+ return;
3215
+ }
3216
+ await vaultManager.startPaymentFlow(method.id);
3217
+ })().catch(error => {
3218
+ settle({
3219
+ kind: 'error',
3220
+ error: toPrimerError('Failed to start the one-click 3DS challenge', error),
3221
+ });
3222
+ });
3223
+ });
3224
+ }
3225
+
2920
3226
  /**
2921
3227
  * @fileoverview Public API with configuration and orchestration logic
2922
3228
  */
@@ -2983,20 +3289,46 @@ async function createClientSession(params) {
2983
3289
  });
2984
3290
  return apiClient.processSessionResponse(sessionResponse);
2985
3291
  }
2986
- async function silentPurchase(options) {
2987
- const { priceId, externalId, clientMetadata, orgId, baseUrl } = options;
3292
+ /**
3293
+ * Builds the `one_click` body. The CIT fields are added only when the caller set them.
3294
+ */
3295
+ function buildOneClickRequest(options) {
3296
+ const payload = {
3297
+ pp_ident: options.priceId,
3298
+ external_id: options.externalId,
3299
+ client_metadata: options.clientMetadata,
3300
+ };
3301
+ if (options.initiationType !== undefined) {
3302
+ payload.initiation_type = options.initiationType;
3303
+ }
3304
+ if (options.citCardNetworks !== undefined) {
3305
+ payload.cit_card_networks = options.citCardNetworks;
3306
+ }
3307
+ if (options.citGooglePayPanOnly !== undefined) {
3308
+ payload.cit_google_pay_pan_only = options.citGooglePayPanOnly;
3309
+ }
3310
+ return payload;
3311
+ }
3312
+ function silentPurchase(options) {
3313
+ if (options?.handle3DS === true) {
3314
+ return silentPurchaseDetailed(options);
3315
+ }
3316
+ return silentPurchaseLegacy(options);
3317
+ }
3318
+ async function silentPurchaseLegacy(options) {
3319
+ const { orgId, baseUrl } = options;
2988
3320
  const apiClient = new APIClient({
2989
3321
  baseUrl: baseUrl,
2990
3322
  orgId: orgId,
2991
3323
  timeout: DEFAULTS.REQUEST_TIMEOUT,
2992
3324
  retryAttempts: DEFAULTS.RETRY_ATTEMPTS,
2993
3325
  });
3326
+ if (options.initiationType === 'cit') {
3327
+ // eslint-disable-next-line no-console
3328
+ console.warn('[FunnelfoxSDK] silentPurchase: initiationType "cit" can trigger a 3DS challenge, which only silentPurchase({ handle3DS: true }) handles.');
3329
+ }
2994
3330
  try {
2995
- const response = await apiClient.oneClick({
2996
- pp_ident: priceId,
2997
- external_id: externalId,
2998
- client_metadata: clientMetadata,
2999
- });
3331
+ const response = await apiClient.oneClick(buildOneClickRequest(options));
3000
3332
  if (response.status !== 'success' &&
3001
3333
  response.error.some(({ code }) => code === 'double_purchase')) {
3002
3334
  throw new APIError('This product was already purchased');
@@ -3016,6 +3348,308 @@ async function silentPurchase(options) {
3016
3348
  throw error;
3017
3349
  }
3018
3350
  }
3351
+ const ONE_CLICK_IN_PROGRESS = 'one_click_in_progress';
3352
+ const DOUBLE_PURCHASE = 'double_purchase';
3353
+ const oneClickInFlight = new Set();
3354
+ function declineFields(data) {
3355
+ return {
3356
+ failedMessageForUser: data.failed_message_for_user ?? null,
3357
+ declineCode: data.decline_code ?? null,
3358
+ declineCodeSource: data.decline_code_source ?? null,
3359
+ };
3360
+ }
3361
+ function envelopeError(response, fallbackMessage) {
3362
+ const firstError = response?.error?.[0];
3363
+ return new APIError(firstError?.msg || fallbackMessage, null, {
3364
+ errorCode: firstError?.code,
3365
+ errorType: firstError?.type,
3366
+ response,
3367
+ });
3368
+ }
3369
+ /** Maps a 2xx `one_click` response (rows 1-4, plus "needs action"). */
3370
+ function mapOneClickResponse(response) {
3371
+ const data = response?.data;
3372
+ if (response?.status !== 'success' || !data) {
3373
+ if (getResponseErrorCodes(response).includes(DOUBLE_PURCHASE)) {
3374
+ return {
3375
+ kind: 'result',
3376
+ result: {
3377
+ outcome: 'already_purchased',
3378
+ orderId: null,
3379
+ threeDSChallenged: false,
3380
+ },
3381
+ };
3382
+ }
3383
+ return {
3384
+ kind: 'result',
3385
+ result: {
3386
+ outcome: 'error',
3387
+ orderId: data?.order_id ?? null,
3388
+ threeDSChallenged: false,
3389
+ error: envelopeError(response, 'One-click purchase failed'),
3390
+ failedRequest: 'one_click',
3391
+ },
3392
+ };
3393
+ }
3394
+ const orderId = data.order_id ?? null;
3395
+ if (data.action_required_token) {
3396
+ return { kind: 'action', orderId, actionToken: data.action_required_token };
3397
+ }
3398
+ switch (data.checkout_status) {
3399
+ case 'succeeded':
3400
+ return {
3401
+ kind: 'result',
3402
+ result: { outcome: 'succeeded', orderId, threeDSChallenged: false },
3403
+ };
3404
+ case 'failed':
3405
+ return {
3406
+ kind: 'result',
3407
+ result: {
3408
+ outcome: 'declined',
3409
+ orderId,
3410
+ threeDSChallenged: false,
3411
+ ...declineFields(data),
3412
+ },
3413
+ };
3414
+ case 'processing':
3415
+ return {
3416
+ kind: 'result',
3417
+ result: { outcome: 'processing', orderId, threeDSChallenged: false },
3418
+ };
3419
+ case 'cancelled':
3420
+ return {
3421
+ kind: 'result',
3422
+ result: {
3423
+ outcome: 'error',
3424
+ orderId,
3425
+ threeDSChallenged: false,
3426
+ error: new APIError('Payment was cancelled', null, { response }),
3427
+ },
3428
+ };
3429
+ default:
3430
+ return {
3431
+ kind: 'result',
3432
+ result: {
3433
+ outcome: 'error',
3434
+ orderId,
3435
+ threeDSChallenged: false,
3436
+ error: new APIError(`Unhandled checkout status: ${data.checkout_status}`, null, { response }),
3437
+ },
3438
+ };
3439
+ }
3440
+ }
3441
+ /** Maps a `resume_payment` response after a challenge (rows 5, 6, 6b, 6c, 7, 8). */
3442
+ function mapResumeResponse(response, orderId, threeDSAuthDeclineCodes = THREE_DS_AUTH_DECLINE_CODES) {
3443
+ const data = response?.data;
3444
+ if (response?.status !== 'success' || !data) {
3445
+ return {
3446
+ threeDS: 'error',
3447
+ result: {
3448
+ outcome: 'error',
3449
+ orderId,
3450
+ threeDSChallenged: true,
3451
+ error: envelopeError(response, 'Resume payment failed'),
3452
+ failedRequest: 'resume_payment',
3453
+ },
3454
+ };
3455
+ }
3456
+ if (data.action_required_token) {
3457
+ // Chained action: not supported, no second challenge (human decision F3).
3458
+ return {
3459
+ threeDS: 'error',
3460
+ result: {
3461
+ outcome: 'error',
3462
+ orderId,
3463
+ threeDSChallenged: true,
3464
+ error: new FunnefoxSDKError('resume_payment returned another action token; chained 3DS is not supported', 'one_click_chained_action'),
3465
+ },
3466
+ };
3467
+ }
3468
+ switch (data.checkout_status) {
3469
+ case 'succeeded':
3470
+ return {
3471
+ threeDS: 'passed',
3472
+ result: { outcome: 'succeeded', orderId, threeDSChallenged: true },
3473
+ };
3474
+ case 'processing':
3475
+ return {
3476
+ threeDS: 'passed',
3477
+ result: { outcome: 'processing', orderId, threeDSChallenged: true },
3478
+ };
3479
+ case 'failed': {
3480
+ const authFailed = threeDSAuthDeclineCodes === null ||
3481
+ threeDSAuthDeclineCodes.includes(data.decline_code ?? '');
3482
+ return {
3483
+ threeDS: authFailed ? 'failed' : 'passed',
3484
+ result: {
3485
+ outcome: authFailed ? '3ds_failed' : 'declined',
3486
+ orderId,
3487
+ threeDSChallenged: true,
3488
+ ...declineFields(data),
3489
+ },
3490
+ };
3491
+ }
3492
+ default:
3493
+ return {
3494
+ threeDS: 'error',
3495
+ result: {
3496
+ outcome: 'error',
3497
+ orderId,
3498
+ threeDSChallenged: true,
3499
+ error: new APIError(data.checkout_status === 'cancelled'
3500
+ ? 'Payment was cancelled'
3501
+ : `Unhandled checkout status: ${data.checkout_status}`, null, { response }),
3502
+ },
3503
+ };
3504
+ }
3505
+ }
3506
+ function mapThreeDSRun(run, orderId) {
3507
+ switch (run.kind) {
3508
+ case 'resumed':
3509
+ return mapResumeResponse(run.response, orderId);
3510
+ case 'failed':
3511
+ return {
3512
+ threeDS: 'failed',
3513
+ result: {
3514
+ outcome: '3ds_failed',
3515
+ orderId,
3516
+ threeDSChallenged: true,
3517
+ error: run.error,
3518
+ },
3519
+ };
3520
+ case 'abandoned':
3521
+ return {
3522
+ threeDS: 'abandoned',
3523
+ result: { outcome: '3ds_abandoned', orderId, threeDSChallenged: true },
3524
+ };
3525
+ default: {
3526
+ const result = {
3527
+ outcome: 'error',
3528
+ orderId,
3529
+ threeDSChallenged: true,
3530
+ error: run.error,
3531
+ };
3532
+ if (run.failedRequest) {
3533
+ result.failedRequest = run.failedRequest;
3534
+ }
3535
+ return { threeDS: 'error', result };
3536
+ }
3537
+ }
3538
+ }
3539
+ function callHook(name, hook, info) {
3540
+ if (typeof hook !== 'function') {
3541
+ return;
3542
+ }
3543
+ try {
3544
+ hook(info);
3545
+ }
3546
+ catch (error) {
3547
+ // eslint-disable-next-line no-console
3548
+ console.error(`[FunnelfoxSDK] silentPurchase ${name} hook threw`, error);
3549
+ }
3550
+ }
3551
+ async function silentPurchaseDetailed(options) {
3552
+ for (const field of ['orgId', 'baseUrl', 'priceId', 'externalId']) {
3553
+ if (!options?.[field]) {
3554
+ throw new ValidationError(field, 'is required', options?.[field]);
3555
+ }
3556
+ }
3557
+ const { orgId, baseUrl, externalId } = options;
3558
+ const inFlightKey = `${orgId}\u0000${externalId}`;
3559
+ if (oneClickInFlight.has(inFlightKey)) {
3560
+ return {
3561
+ outcome: 'error',
3562
+ orderId: null,
3563
+ threeDSChallenged: false,
3564
+ error: new FunnefoxSDKError('A one-click purchase is already in progress for this customer', ONE_CLICK_IN_PROGRESS),
3565
+ };
3566
+ }
3567
+ oneClickInFlight.add(inFlightKey);
3568
+ try {
3569
+ const result = await runSilentPurchaseDetailed(options);
3570
+ if (result.outcome === 'error' && result.error) {
3571
+ const error = result.error;
3572
+ getErrorImage(orgId, {
3573
+ baseUrl,
3574
+ message: error.message,
3575
+ code: error.code,
3576
+ req_id: error.response?.req_id,
3577
+ });
3578
+ }
3579
+ return result;
3580
+ }
3581
+ finally {
3582
+ oneClickInFlight.delete(inFlightKey);
3583
+ }
3584
+ }
3585
+ async function runSilentPurchaseDetailed(options) {
3586
+ const apiClient = new APIClient({
3587
+ baseUrl: options.baseUrl,
3588
+ orgId: options.orgId,
3589
+ timeout: DEFAULTS.REQUEST_TIMEOUT,
3590
+ retryAttempts: DEFAULTS.RETRY_ATTEMPTS,
3591
+ });
3592
+ let response;
3593
+ try {
3594
+ response = await apiClient.oneClickOnce(buildOneClickRequest(options));
3595
+ }
3596
+ catch (error) {
3597
+ if (error instanceof APIError &&
3598
+ getResponseErrorCodes(error.response).includes(DOUBLE_PURCHASE)) {
3599
+ return {
3600
+ outcome: 'already_purchased',
3601
+ orderId: null,
3602
+ threeDSChallenged: false,
3603
+ };
3604
+ }
3605
+ return {
3606
+ outcome: 'error',
3607
+ orderId: null,
3608
+ threeDSChallenged: false,
3609
+ error: error,
3610
+ failedRequest: 'one_click',
3611
+ };
3612
+ }
3613
+ const step = mapOneClickResponse(response);
3614
+ if (step.kind === 'result') {
3615
+ return step.result;
3616
+ }
3617
+ const { orderId, actionToken } = step;
3618
+ let ended = false;
3619
+ const end = (result) => {
3620
+ if (ended) {
3621
+ return;
3622
+ }
3623
+ ended = true;
3624
+ callHook('onThreeDSEnd', options.onThreeDSEnd, { result });
3625
+ };
3626
+ callHook('onThreeDSStart', options.onThreeDSStart, { orderId });
3627
+ try {
3628
+ const run = await runOneClickThreeDS({
3629
+ apiClient,
3630
+ region: defaultConfig?.region || DEFAULTS.REGION,
3631
+ priceId: options.priceId,
3632
+ externalId: options.externalId,
3633
+ orderId,
3634
+ actionToken,
3635
+ timeoutMs: options.threeDSTimeoutMs ?? DEFAULT_THREE_DS_TIMEOUT_MS,
3636
+ });
3637
+ const mapped = mapThreeDSRun(run, orderId);
3638
+ end(mapped.threeDS);
3639
+ return mapped.result;
3640
+ }
3641
+ catch (error) {
3642
+ end('error');
3643
+ return {
3644
+ outcome: 'error',
3645
+ orderId,
3646
+ threeDSChallenged: true,
3647
+ error: error instanceof Error
3648
+ ? error
3649
+ : new FunnefoxSDKError('One-click 3DS step failed', undefined, error),
3650
+ };
3651
+ }
3652
+ }
3019
3653
  async function initMethod(method, element, options) {
3020
3654
  try {
3021
3655
  const checkoutInstance = new CheckoutInstance({