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

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