@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.
- package/dist/chunk-index.cjs.js +655 -23
- package/dist/chunk-index.es.js +655 -23
- package/dist/funnelfox-billing.js +655 -23
- package/dist/funnelfox-billing.min.js +1 -1
- package/package.json +2 -2
- package/src/types.d.ts +77 -2
package/dist/chunk-index.cjs.js
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
2987
|
-
|
|
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({
|