@flopay/js 1.4.1 → 1.4.2
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/README.md +46 -52
- package/dist/index.cjs +1719 -1869
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +24 -64
- package/dist/index.d.ts +24 -64
- package/dist/index.mjs +1987 -2135
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -3,2185 +3,2199 @@ import { FloPayError as FloPayError6, resolveBillingApiUrl as resolveBillingApiU
|
|
|
3
3
|
|
|
4
4
|
// src/stripe-adapter.ts
|
|
5
5
|
import { loadStripe } from "@stripe/stripe-js";
|
|
6
|
-
import { FloPayError, isSetupIntentClientSecret } from "@flopay/shared";
|
|
6
|
+
import { FloPayError as FloPayError2, isSetupIntentClientSecret } from "@flopay/shared";
|
|
7
7
|
|
|
8
|
-
// src/
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
// src/payment-api.ts
|
|
9
|
+
import {
|
|
10
|
+
FloPayError,
|
|
11
|
+
SDK_VERSION,
|
|
12
|
+
FLO_SDK_VERSION_HEADER,
|
|
13
|
+
IDEMPOTENCY_KEY_HEADER,
|
|
14
|
+
IDEMPOTENCY_IN_PROGRESS_CODE,
|
|
15
|
+
buildProductPayload,
|
|
16
|
+
foldIntoProducts,
|
|
17
|
+
isUuidV4,
|
|
18
|
+
randomUuidV4,
|
|
19
|
+
resolveIdempotencyKey,
|
|
20
|
+
resolveSessionCurrency
|
|
21
|
+
} from "@flopay/shared";
|
|
11
22
|
|
|
12
|
-
// src/
|
|
13
|
-
function
|
|
14
|
-
|
|
15
|
-
payment: "payment",
|
|
16
|
-
card: "card",
|
|
17
|
-
cardNumber: "cardNumber",
|
|
18
|
-
cardExpiry: "cardExpiry",
|
|
19
|
-
cardCvc: "cardCvc",
|
|
20
|
-
address: "address"
|
|
21
|
-
};
|
|
22
|
-
return map[type];
|
|
23
|
+
// src/api-error.ts
|
|
24
|
+
function readErrorString(value) {
|
|
25
|
+
return typeof value === "string" && value.trim() ? value : void 0;
|
|
23
26
|
}
|
|
24
|
-
function
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
return "flat";
|
|
30
|
-
// 'default', 'none', undefined, or any unexpected value → Stripe's baseline.
|
|
31
|
-
default:
|
|
32
|
-
return "stripe";
|
|
27
|
+
function readErrorMessage(value) {
|
|
28
|
+
if (typeof value === "string") return value.trim() ? value : void 0;
|
|
29
|
+
if (Array.isArray(value)) {
|
|
30
|
+
const joined = value.filter((entry) => typeof entry === "string" && entry.trim().length > 0).join("; ");
|
|
31
|
+
return joined || void 0;
|
|
33
32
|
}
|
|
33
|
+
return void 0;
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
el.mount(container);
|
|
43
|
-
},
|
|
44
|
-
unmount() {
|
|
45
|
-
el.unmount();
|
|
46
|
-
},
|
|
47
|
-
update(options) {
|
|
48
|
-
el.update(options);
|
|
49
|
-
},
|
|
50
|
-
on(event, handler) {
|
|
51
|
-
el["on"]?.(event, handler);
|
|
52
|
-
},
|
|
53
|
-
off(event, handler) {
|
|
54
|
-
el["off"]?.(event, handler);
|
|
55
|
-
},
|
|
56
|
-
destroy() {
|
|
57
|
-
el.destroy();
|
|
58
|
-
}
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
function toStripeBillingDetails(billing) {
|
|
62
|
-
return {
|
|
63
|
-
billing_details: {
|
|
64
|
-
...billing.email ? { email: billing.email } : {},
|
|
65
|
-
...billing.name ? { name: billing.name } : {},
|
|
66
|
-
...billing.address ? {
|
|
67
|
-
address: {
|
|
68
|
-
...billing.address.country ? { country: billing.address.country } : {},
|
|
69
|
-
...billing.address.postal_code ? { postal_code: billing.address.postal_code } : {},
|
|
70
|
-
...billing.address.city ? { city: billing.address.city } : {},
|
|
71
|
-
...billing.address.line1 ? { line1: billing.address.line1 } : {},
|
|
72
|
-
...billing.address.line2 ? { line2: billing.address.line2 } : {},
|
|
73
|
-
...billing.address.state ? { state: billing.address.state } : {}
|
|
74
|
-
}
|
|
75
|
-
} : {}
|
|
76
|
-
}
|
|
77
|
-
};
|
|
35
|
+
|
|
36
|
+
// src/session-display-cache.ts
|
|
37
|
+
var STORAGE_KEY_PREFIX = "flopay_session_display:";
|
|
38
|
+
var DEFAULT_TTL_MS = 60 * 60 * 1e3;
|
|
39
|
+
var memoryStore = /* @__PURE__ */ new Map();
|
|
40
|
+
function storageKey(sessionId) {
|
|
41
|
+
return `${STORAGE_KEY_PREFIX}${sessionId}`;
|
|
78
42
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
this.cardThreeDsAttemptSequence = 0;
|
|
86
|
-
// Serialized appearance currently applied to `this.elements`. Used to detect
|
|
87
|
-
// when consumers swap themes mid-session so we can live-update the Stripe
|
|
88
|
-
// Elements group instead of returning a stale-styled cache. `null` while no
|
|
89
|
-
// elements group exists.
|
|
90
|
-
this.appliedAppearanceKey = null;
|
|
43
|
+
function getSessionStorage() {
|
|
44
|
+
if (typeof window === "undefined") return null;
|
|
45
|
+
try {
|
|
46
|
+
return window.sessionStorage;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
91
49
|
}
|
|
92
|
-
|
|
93
|
-
|
|
50
|
+
}
|
|
51
|
+
function cacheSessionDisplayData(sessionId, data, options) {
|
|
52
|
+
if (!sessionId) return;
|
|
53
|
+
const ttl = options?.ttlMs ?? DEFAULT_TTL_MS;
|
|
54
|
+
const entry = { data, expiresAt: Date.now() + ttl };
|
|
55
|
+
const storage = getSessionStorage();
|
|
56
|
+
if (storage) {
|
|
57
|
+
try {
|
|
58
|
+
storage.setItem(storageKey(sessionId), JSON.stringify(entry));
|
|
94
59
|
return;
|
|
60
|
+
} catch {
|
|
95
61
|
}
|
|
96
|
-
const stripe = await loadStripe(config.publishableKey, {
|
|
97
|
-
locale: config.locale ?? "auto"
|
|
98
|
-
});
|
|
99
|
-
if (!stripe) {
|
|
100
|
-
throw new FloPayError(
|
|
101
|
-
"Failed to initialize Stripe. Check your publishable key.",
|
|
102
|
-
"authentication_error"
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
this.stripe = stripe;
|
|
106
62
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
} : void 0;
|
|
120
|
-
const nextAppearanceKey = stripeAppearance ? JSON.stringify(stripeAppearance) : null;
|
|
121
|
-
if (!this.elements) {
|
|
122
|
-
let elementsOptions;
|
|
123
|
-
const deferredAmount = options?.amount ?? 0;
|
|
124
|
-
const deferredCurrency = (options?.currency ?? "usd").toLowerCase();
|
|
125
|
-
const paymentMethodCreation = options?.paymentMethodCreation ?? "manual";
|
|
126
|
-
if (options?.clientSecret) {
|
|
127
|
-
elementsOptions = { clientSecret: options.clientSecret };
|
|
128
|
-
} else if (deferredAmount > 0) {
|
|
129
|
-
elementsOptions = {
|
|
130
|
-
mode: "payment",
|
|
131
|
-
amount: deferredAmount,
|
|
132
|
-
currency: deferredCurrency,
|
|
133
|
-
paymentMethodCreation
|
|
134
|
-
};
|
|
135
|
-
if (options?.setupFutureUsage) {
|
|
136
|
-
elementsOptions["setupFutureUsage"] = options.setupFutureUsage;
|
|
63
|
+
memoryStore.set(sessionId, entry);
|
|
64
|
+
}
|
|
65
|
+
function getSessionDisplayData(sessionId) {
|
|
66
|
+
if (!sessionId) return null;
|
|
67
|
+
const storage = getSessionStorage();
|
|
68
|
+
if (storage) {
|
|
69
|
+
try {
|
|
70
|
+
const raw = storage.getItem(storageKey(sessionId));
|
|
71
|
+
if (raw) {
|
|
72
|
+
const entry = JSON.parse(raw);
|
|
73
|
+
if (entry && typeof entry.expiresAt === "number" && entry.expiresAt > Date.now()) {
|
|
74
|
+
return entry.data;
|
|
137
75
|
}
|
|
138
|
-
|
|
139
|
-
elementsOptions = {
|
|
140
|
-
mode: "setup",
|
|
141
|
-
currency: deferredCurrency,
|
|
142
|
-
paymentMethodCreation
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
if (stripeAppearance) {
|
|
146
|
-
elementsOptions["appearance"] = stripeAppearance;
|
|
76
|
+
storage.removeItem(storageKey(sessionId));
|
|
147
77
|
}
|
|
148
|
-
|
|
149
|
-
this.appliedAppearanceKey = nextAppearanceKey;
|
|
150
|
-
} else if (nextAppearanceKey !== this.appliedAppearanceKey) {
|
|
151
|
-
this.elements.update({
|
|
152
|
-
appearance: stripeAppearance ?? {}
|
|
153
|
-
});
|
|
154
|
-
this.appliedAppearanceKey = nextAppearanceKey;
|
|
155
|
-
}
|
|
156
|
-
return this.elements;
|
|
157
|
-
}
|
|
158
|
-
async createElement(type, options) {
|
|
159
|
-
const elements = this.getElements(options);
|
|
160
|
-
const stripeType = toStripeElementType(type);
|
|
161
|
-
const elementOptions = {};
|
|
162
|
-
if (options.layout) {
|
|
163
|
-
elementOptions["layout"] = options.layout;
|
|
164
|
-
}
|
|
165
|
-
if (options.defaultValues) {
|
|
166
|
-
elementOptions["defaultValues"] = options.defaultValues;
|
|
167
|
-
}
|
|
168
|
-
if (options.readOnly) {
|
|
169
|
-
elementOptions["readOnly"] = options.readOnly;
|
|
170
|
-
}
|
|
171
|
-
if (options.mode) {
|
|
172
|
-
elementOptions["mode"] = options.mode;
|
|
173
|
-
}
|
|
174
|
-
if (options.style) {
|
|
175
|
-
elementOptions["style"] = options.style;
|
|
78
|
+
} catch {
|
|
176
79
|
}
|
|
177
|
-
const stripeElement = elements.create(stripeType, elementOptions);
|
|
178
|
-
return wrapStripeElement(stripeElement);
|
|
179
|
-
}
|
|
180
|
-
getElement(type) {
|
|
181
|
-
if (!this.elements) return null;
|
|
182
|
-
const stripeType = toStripeElementType(type);
|
|
183
|
-
const existing = this.elements.getElement(stripeType);
|
|
184
|
-
if (!existing) return null;
|
|
185
|
-
return wrapStripeElement(existing);
|
|
186
80
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const { error } = await this.elements.submit();
|
|
192
|
-
if (error) {
|
|
193
|
-
return {
|
|
194
|
-
error: new FloPayError(error.message ?? "Validation failed", "validation_error")
|
|
195
|
-
};
|
|
81
|
+
const memEntry = memoryStore.get(sessionId);
|
|
82
|
+
if (memEntry) {
|
|
83
|
+
if (memEntry.expiresAt > Date.now()) {
|
|
84
|
+
return memEntry.data;
|
|
196
85
|
}
|
|
197
|
-
|
|
86
|
+
memoryStore.delete(sessionId);
|
|
198
87
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
type: "card",
|
|
210
|
-
card: cardNumberEl,
|
|
211
|
-
...stripeBilling
|
|
212
|
-
}) : await this.stripe.createPaymentMethod({
|
|
213
|
-
elements: this.elements,
|
|
214
|
-
...stripeBilling
|
|
215
|
-
});
|
|
216
|
-
if (error) {
|
|
217
|
-
return {
|
|
218
|
-
paymentMethodId: null,
|
|
219
|
-
error: new FloPayError(
|
|
220
|
-
error.message ?? "Failed to create payment method",
|
|
221
|
-
"api_error",
|
|
222
|
-
{ code: error.code }
|
|
223
|
-
)
|
|
224
|
-
};
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
function clearSessionDisplayData(sessionId) {
|
|
91
|
+
if (!sessionId) return;
|
|
92
|
+
memoryStore.delete(sessionId);
|
|
93
|
+
const storage = getSessionStorage();
|
|
94
|
+
if (storage) {
|
|
95
|
+
try {
|
|
96
|
+
storage.removeItem(storageKey(sessionId));
|
|
97
|
+
} catch {
|
|
225
98
|
}
|
|
226
|
-
return { paymentMethodId: paymentMethod.id };
|
|
227
99
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
status: "failed",
|
|
263
|
-
error: new FloPayError(
|
|
264
|
-
error.message ?? "Payment failed",
|
|
265
|
-
"api_error",
|
|
266
|
-
{ code: error.code, declineCode: error.decline_code }
|
|
267
|
-
)
|
|
268
|
-
});
|
|
269
|
-
}
|
|
270
|
-
return this.withCardThreeDsLifecycle(params, {
|
|
271
|
-
status: paymentIntent?.status ?? "failed",
|
|
272
|
-
paymentIntentId: paymentIntent?.id,
|
|
273
|
-
paymentMethodId: this.extractPaymentMethodId(paymentIntent?.payment_method)
|
|
274
|
-
});
|
|
275
|
-
}
|
|
276
|
-
/**
|
|
277
|
-
* Bind provider-observed 3DS milestones to the exact confirmation context.
|
|
278
|
-
* The sensitive client secret/payment method pair stays only in this private,
|
|
279
|
-
* in-memory key; callers and telemetry receive an unrelated opaque id.
|
|
280
|
-
*/
|
|
281
|
-
withCardThreeDsLifecycle(params, result) {
|
|
282
|
-
const contextKey = `${params.clientSecret}\0${params.paymentMethodId}`;
|
|
283
|
-
const now = cardThreeDsNow();
|
|
284
|
-
this.pruneExpiredCardThreeDsAttempts(now);
|
|
285
|
-
if (result.status === "requires_action") {
|
|
286
|
-
let attempt2 = this.pendingCardThreeDsAttempts.get(contextKey);
|
|
287
|
-
if (!attempt2) {
|
|
288
|
-
while (this.pendingCardThreeDsAttempts.size >= MAX_CARD_THREE_DS_ATTEMPTS) {
|
|
289
|
-
const oldestContextKey = this.pendingCardThreeDsAttempts.keys().next().value;
|
|
290
|
-
if (oldestContextKey === void 0) break;
|
|
291
|
-
this.pendingCardThreeDsAttempts.delete(oldestContextKey);
|
|
292
|
-
}
|
|
293
|
-
attempt2 = {
|
|
294
|
-
attemptId: `card_3ds_${this.cardThreeDsAttemptSequence++}`,
|
|
295
|
-
startedAt: now
|
|
296
|
-
};
|
|
297
|
-
this.pendingCardThreeDsAttempts.set(contextKey, attempt2);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/telemetry-reporter.ts
|
|
103
|
+
import {
|
|
104
|
+
buildTelemetryErrorEvent,
|
|
105
|
+
buildTelemetryLogEvent,
|
|
106
|
+
buildTelemetryPerformanceEvent,
|
|
107
|
+
buildTelemetryTerminalEvent,
|
|
108
|
+
serializeTelemetryBatch,
|
|
109
|
+
TELEMETRY_MAX_BATCH_BYTES
|
|
110
|
+
} from "@flopay/shared";
|
|
111
|
+
var TELEMETRY_PATH = "/v1/sdk-telemetry/events";
|
|
112
|
+
var MAX_BATCH_SIZE = 16;
|
|
113
|
+
var MAX_QUEUE_SIZE = 64;
|
|
114
|
+
var UPLOAD_TIMEOUT_MS = 1500;
|
|
115
|
+
var ERROR_DEDUPLICATION_WINDOW_MS = 1e3;
|
|
116
|
+
var MAX_REPORTED_FAILURES = 64;
|
|
117
|
+
var DEDUPLICATION_EVENT_ID = "00000000-0000-4000-8000-000000000000";
|
|
118
|
+
var EVENT_BUDGETS = {
|
|
119
|
+
technical_error: 8,
|
|
120
|
+
lifecycle: 32,
|
|
121
|
+
expected_outcome: 32,
|
|
122
|
+
performance: 24
|
|
123
|
+
};
|
|
124
|
+
function createUuidV4() {
|
|
125
|
+
try {
|
|
126
|
+
return globalThis.crypto.randomUUID();
|
|
127
|
+
} catch {
|
|
128
|
+
const bytes = new Uint8Array(16);
|
|
129
|
+
try {
|
|
130
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
131
|
+
} catch {
|
|
132
|
+
for (let index = 0; index < bytes.length; index += 1) {
|
|
133
|
+
bytes[index] = Math.floor(Math.random() * 256);
|
|
298
134
|
}
|
|
299
|
-
return { ...result, threeDs: { attemptId: attempt2.attemptId, status: "handoff" } };
|
|
300
135
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
return {
|
|
305
|
-
...result,
|
|
306
|
-
threeDs: {
|
|
307
|
-
attemptId: attempt.attemptId,
|
|
308
|
-
status: result.error || result.status === "failed" ? "failed" : "returned"
|
|
309
|
-
}
|
|
310
|
-
};
|
|
136
|
+
bytes[6] = bytes[6] & 15 | 64;
|
|
137
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
138
|
+
const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
139
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
311
140
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
141
|
+
}
|
|
142
|
+
function bodyByteLength(body) {
|
|
143
|
+
try {
|
|
144
|
+
return new TextEncoder().encode(body).byteLength;
|
|
145
|
+
} catch {
|
|
146
|
+
return body.length;
|
|
318
147
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
}
|
|
350
|
-
if (!paymentIntent) {
|
|
351
|
-
return { status: "failed", error: new FloPayError("No payment intent returned", "api_error") };
|
|
352
|
-
}
|
|
353
|
-
const statusMap = {
|
|
354
|
-
succeeded: "succeeded",
|
|
355
|
-
processing: "processing",
|
|
356
|
-
requires_action: "requires_action",
|
|
357
|
-
requires_payment_method: "failed",
|
|
358
|
-
canceled: "failed"
|
|
148
|
+
}
|
|
149
|
+
function failureDeduplicationKey(event) {
|
|
150
|
+
return JSON.stringify([
|
|
151
|
+
event.code,
|
|
152
|
+
event.stage,
|
|
153
|
+
event.provider,
|
|
154
|
+
event.attempt,
|
|
155
|
+
event.statusClass,
|
|
156
|
+
event.requestCategory,
|
|
157
|
+
event.paymentMethodCategory,
|
|
158
|
+
event.checkoutMode,
|
|
159
|
+
event.layout
|
|
160
|
+
]);
|
|
161
|
+
}
|
|
162
|
+
function telemetryNow() {
|
|
163
|
+
return globalThis.performance?.now() ?? 0;
|
|
164
|
+
}
|
|
165
|
+
var TelemetryReporter = class {
|
|
166
|
+
constructor(options) {
|
|
167
|
+
this.ingestionDisabled = false;
|
|
168
|
+
this.queue = [];
|
|
169
|
+
this.sequence = 0;
|
|
170
|
+
this.flushTimer = null;
|
|
171
|
+
this.flushInFlight = null;
|
|
172
|
+
this.reportedFailures = /* @__PURE__ */ new Map();
|
|
173
|
+
this.checkoutContext = {};
|
|
174
|
+
this.checkoutStartedAt = null;
|
|
175
|
+
this.destroyed = false;
|
|
176
|
+
this.pageExitHandler = () => {
|
|
177
|
+
this.drainQueue();
|
|
359
178
|
};
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
paymentIntentId: paymentIntent.id,
|
|
363
|
-
paymentMethodId: this.extractPaymentMethodId(paymentIntent.payment_method)
|
|
179
|
+
this.visibilityHandler = () => {
|
|
180
|
+
if (document.visibilityState === "hidden") void this.flush();
|
|
364
181
|
};
|
|
182
|
+
this.eventCounts = {
|
|
183
|
+
technical_error: 0,
|
|
184
|
+
lifecycle: 0,
|
|
185
|
+
expected_outcome: 0,
|
|
186
|
+
performance: 0
|
|
187
|
+
};
|
|
188
|
+
this.endpoint = `${options.billingApiUrl.replace(/\/+$/, "")}${TELEMETRY_PATH}`;
|
|
189
|
+
this.sdkPackage = options.sdkPackage ?? "@flopay/js";
|
|
190
|
+
this.sdkVersion = options.sdkVersion;
|
|
191
|
+
this.correlationId = createUuidV4();
|
|
192
|
+
this.merchantEnabled = options.enabled !== false;
|
|
193
|
+
this.clock = options.clock ?? telemetryNow;
|
|
194
|
+
this.browserTransportAvailable = typeof window !== "undefined" && typeof document !== "undefined";
|
|
195
|
+
if (this.browserTransportAvailable) {
|
|
196
|
+
window.addEventListener("pagehide", this.pageExitHandler);
|
|
197
|
+
document.addEventListener("visibilitychange", this.visibilityHandler);
|
|
198
|
+
}
|
|
365
199
|
}
|
|
366
|
-
|
|
367
|
-
if (
|
|
368
|
-
|
|
200
|
+
log(input) {
|
|
201
|
+
if (!this.canCollect()) return;
|
|
202
|
+
this.enqueue(buildTelemetryLogEvent({
|
|
203
|
+
...this.checkoutContext,
|
|
204
|
+
...input,
|
|
205
|
+
eventId: createUuidV4(),
|
|
206
|
+
sequence: this.sequence++
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
error(input) {
|
|
210
|
+
if (!this.canCollect()) return;
|
|
211
|
+
const normalizedFailure = buildTelemetryErrorEvent({
|
|
212
|
+
...this.checkoutContext,
|
|
213
|
+
...input,
|
|
214
|
+
eventId: DEDUPLICATION_EVENT_ID,
|
|
215
|
+
sequence: 0
|
|
216
|
+
});
|
|
217
|
+
const deduplicationKey = failureDeduplicationKey(normalizedFailure);
|
|
218
|
+
const now = this.now();
|
|
219
|
+
this.pruneReportedFailures(now);
|
|
220
|
+
const previouslyReportedAt = this.reportedFailures.get(deduplicationKey);
|
|
221
|
+
if (previouslyReportedAt !== void 0 && now >= previouslyReportedAt && now - previouslyReportedAt < ERROR_DEDUPLICATION_WINDOW_MS) {
|
|
222
|
+
this.log({
|
|
223
|
+
name: "operation.deduplicated",
|
|
224
|
+
stage: input.stage,
|
|
225
|
+
provider: input.provider,
|
|
226
|
+
paymentMethodCategory: input.paymentMethodCategory,
|
|
227
|
+
attempt: input.attempt
|
|
228
|
+
});
|
|
229
|
+
return;
|
|
369
230
|
}
|
|
370
|
-
|
|
371
|
-
|
|
231
|
+
this.rememberReportedFailure(deduplicationKey, now);
|
|
232
|
+
this.enqueue(buildTelemetryErrorEvent({
|
|
233
|
+
...this.checkoutContext,
|
|
234
|
+
...input,
|
|
235
|
+
eventId: createUuidV4(),
|
|
236
|
+
sequence: this.sequence++
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
239
|
+
performance(input) {
|
|
240
|
+
if (!this.canCollect()) return;
|
|
241
|
+
this.enqueue(buildTelemetryPerformanceEvent({
|
|
242
|
+
...this.checkoutContext,
|
|
243
|
+
...input,
|
|
244
|
+
eventId: createUuidV4(),
|
|
245
|
+
sequence: this.sequence++
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
terminal(input) {
|
|
249
|
+
if (!this.canCollect()) return;
|
|
250
|
+
this.enqueue(buildTelemetryTerminalEvent({
|
|
251
|
+
...this.checkoutContext,
|
|
252
|
+
...input,
|
|
253
|
+
eventId: createUuidV4(),
|
|
254
|
+
sequence: this.sequence++
|
|
255
|
+
}));
|
|
256
|
+
if (input.outcome !== "action_required" && this.checkoutStartedAt !== null) {
|
|
257
|
+
const checkoutStartedAt = this.checkoutStartedAt;
|
|
258
|
+
this.checkoutStartedAt = null;
|
|
259
|
+
this.performance({
|
|
260
|
+
stage: "total_journey",
|
|
261
|
+
durationMs: Math.max(0, this.now() - checkoutStartedAt),
|
|
262
|
+
durationMode: "total",
|
|
263
|
+
provider: input.provider,
|
|
264
|
+
paymentMethodCategory: input.paymentMethodCategory
|
|
265
|
+
});
|
|
372
266
|
}
|
|
373
|
-
return void 0;
|
|
374
267
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
268
|
+
/** @internal Read the reporter's monotonic clock without Resource Timing. */
|
|
269
|
+
now() {
|
|
270
|
+
try {
|
|
271
|
+
return this.clock();
|
|
272
|
+
} catch {
|
|
273
|
+
return telemetryNow();
|
|
378
274
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
if (
|
|
383
|
-
|
|
384
|
-
status: "failed",
|
|
385
|
-
error: new FloPayError(
|
|
386
|
-
submitError.message ?? "PayPal payment failed",
|
|
387
|
-
"validation_error",
|
|
388
|
-
{ code: submitError.code }
|
|
389
|
-
)
|
|
390
|
-
};
|
|
275
|
+
}
|
|
276
|
+
pruneReportedFailures(now) {
|
|
277
|
+
for (const [key, reportedAt] of this.reportedFailures) {
|
|
278
|
+
if (now < reportedAt || now - reportedAt >= ERROR_DEDUPLICATION_WINDOW_MS) {
|
|
279
|
+
this.reportedFailures.delete(key);
|
|
391
280
|
}
|
|
392
281
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
sessionId: params.sessionId,
|
|
400
|
-
email: params.email,
|
|
401
|
-
paymentMethodType: "paypal",
|
|
402
|
-
isPaypal: "true"
|
|
403
|
-
})
|
|
404
|
-
});
|
|
405
|
-
if (!intentResponse.ok) {
|
|
406
|
-
return { status: "failed", error: new FloPayError("Failed to create PayPal payment intent", "api_error") };
|
|
407
|
-
}
|
|
408
|
-
const intentJson = await intentResponse.json();
|
|
409
|
-
const intentClientSecret = intentJson.data?.id;
|
|
410
|
-
if (!intentClientSecret) {
|
|
411
|
-
return { status: "failed", error: new FloPayError("No client_secret in response", "api_error") };
|
|
282
|
+
}
|
|
283
|
+
rememberReportedFailure(key, reportedAt) {
|
|
284
|
+
while (this.reportedFailures.size >= MAX_REPORTED_FAILURES) {
|
|
285
|
+
const oldest = this.reportedFailures.keys().next();
|
|
286
|
+
if (oldest.done) break;
|
|
287
|
+
this.reportedFailures.delete(oldest.value);
|
|
412
288
|
}
|
|
413
|
-
|
|
414
|
-
clientSecret: intentClientSecret,
|
|
415
|
-
elements: this.elements ?? void 0,
|
|
416
|
-
confirmParams: { return_url: params.returnUrl }
|
|
417
|
-
});
|
|
418
|
-
if (error) {
|
|
419
|
-
return {
|
|
420
|
-
status: "failed",
|
|
421
|
-
error: new FloPayError(error.message ?? "PayPal payment failed", "api_error", { code: error.code })
|
|
422
|
-
};
|
|
423
|
-
}
|
|
424
|
-
return {
|
|
425
|
-
status: "processing"
|
|
426
|
-
};
|
|
289
|
+
this.reportedFailures.set(key, reportedAt);
|
|
427
290
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
const redirectStatus = params.get("redirect_status");
|
|
434
|
-
if (!paymentIntentId || !clientSecret) return null;
|
|
435
|
-
if (redirectStatus === "failed") {
|
|
436
|
-
return {
|
|
437
|
-
status: "failed",
|
|
438
|
-
error: new FloPayError("PayPal payment was declined. Please try again.", "api_error")
|
|
439
|
-
};
|
|
440
|
-
}
|
|
441
|
-
const { paymentIntent, error } = await this.stripe.retrievePaymentIntent(clientSecret);
|
|
442
|
-
if (error) {
|
|
443
|
-
return {
|
|
444
|
-
status: "failed",
|
|
445
|
-
error: new FloPayError(error.message ?? "Failed to retrieve PayPal payment", "api_error")
|
|
446
|
-
};
|
|
447
|
-
}
|
|
448
|
-
if (paymentIntent && (paymentIntent.status === "requires_capture" || paymentIntent.status === "succeeded")) {
|
|
449
|
-
const pmId = typeof paymentIntent.payment_method === "string" ? paymentIntent.payment_method : paymentIntent.payment_method?.id;
|
|
450
|
-
const url = new URL(window.location.href);
|
|
451
|
-
url.searchParams.delete("payment_intent");
|
|
452
|
-
url.searchParams.delete("payment_intent_client_secret");
|
|
453
|
-
url.searchParams.delete("redirect_status");
|
|
454
|
-
window.history.replaceState({}, "", url.toString());
|
|
455
|
-
return {
|
|
456
|
-
status: paymentIntent.status,
|
|
457
|
-
paymentIntentId: paymentIntent.id,
|
|
458
|
-
paymentMethodId: pmId
|
|
459
|
-
};
|
|
460
|
-
}
|
|
461
|
-
return {
|
|
462
|
-
status: "failed",
|
|
463
|
-
error: new FloPayError("PayPal payment was not completed. Please try again.", "api_error")
|
|
291
|
+
/** @internal Add closed checkout dimensions to subsequent SDK events. */
|
|
292
|
+
setCheckoutContext(context) {
|
|
293
|
+
this.checkoutContext = {
|
|
294
|
+
checkoutMode: context.checkoutMode,
|
|
295
|
+
layout: context.layout
|
|
464
296
|
};
|
|
465
297
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
298
|
+
/** @internal Start a fresh checkout budget, dedupe window, and total span. */
|
|
299
|
+
beginCheckout(context = {}) {
|
|
300
|
+
if (!this.canCollect()) return 0;
|
|
301
|
+
this.drainQueue();
|
|
302
|
+
this.setCheckoutContext(context);
|
|
303
|
+
this.sequence = 0;
|
|
304
|
+
this.reportedFailures.clear();
|
|
305
|
+
this.eventCounts = {
|
|
306
|
+
technical_error: 0,
|
|
307
|
+
lifecycle: 0,
|
|
308
|
+
expected_outcome: 0,
|
|
309
|
+
performance: 0
|
|
476
310
|
};
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
311
|
+
this.checkoutStartedAt = this.now();
|
|
312
|
+
return this.checkoutStartedAt;
|
|
313
|
+
}
|
|
314
|
+
enqueue(event) {
|
|
315
|
+
if (!this.canCollect()) return;
|
|
316
|
+
if (this.queue.length >= MAX_QUEUE_SIZE || this.eventCounts[event.class] >= EVENT_BUDGETS[event.class]) return;
|
|
317
|
+
this.eventCounts[event.class] += 1;
|
|
318
|
+
this.queue.push(event);
|
|
319
|
+
if (this.queue.length >= MAX_BATCH_SIZE) {
|
|
320
|
+
void this.flush();
|
|
321
|
+
return;
|
|
486
322
|
}
|
|
487
|
-
|
|
323
|
+
this.scheduleFlush();
|
|
324
|
+
}
|
|
325
|
+
canCollect() {
|
|
326
|
+
return this.browserTransportAvailable && this.merchantEnabled && !this.ingestionDisabled && !this.destroyed;
|
|
327
|
+
}
|
|
328
|
+
/** Flush one bounded batch. Failures are intentionally dropped. */
|
|
329
|
+
async flush() {
|
|
330
|
+
if (this.flushInFlight) return this.flushInFlight;
|
|
331
|
+
if (!this.browserTransportAvailable || this.destroyed || this.ingestionDisabled || this.queue.length === 0) return;
|
|
332
|
+
this.clearFlushTimer();
|
|
333
|
+
const events = this.queue.splice(0, MAX_BATCH_SIZE);
|
|
334
|
+
this.flushInFlight = this.sendBatch(events).finally(() => {
|
|
335
|
+
this.flushInFlight = null;
|
|
336
|
+
if (this.queue.length > 0) this.scheduleFlush();
|
|
337
|
+
});
|
|
338
|
+
return this.flushInFlight;
|
|
488
339
|
}
|
|
340
|
+
/** Flush pending work and detach browser lifecycle listeners. */
|
|
489
341
|
destroy() {
|
|
490
|
-
this.
|
|
491
|
-
this.
|
|
492
|
-
this.
|
|
493
|
-
this.
|
|
342
|
+
if (this.destroyed) return;
|
|
343
|
+
this.drainQueue();
|
|
344
|
+
this.destroyed = true;
|
|
345
|
+
this.clearFlushTimer();
|
|
346
|
+
if (this.browserTransportAvailable) {
|
|
347
|
+
window.removeEventListener("pagehide", this.pageExitHandler);
|
|
348
|
+
document.removeEventListener("visibilitychange", this.visibilityHandler);
|
|
349
|
+
}
|
|
350
|
+
this.queue.splice(0);
|
|
351
|
+
this.reportedFailures.clear();
|
|
494
352
|
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
import "@flopay/shared";
|
|
502
|
-
var FloPayElements = class {
|
|
503
|
-
constructor(provider, options) {
|
|
504
|
-
this.elementMap = /* @__PURE__ */ new Map();
|
|
505
|
-
this.provider = provider;
|
|
506
|
-
this.baseOptions = options ?? {};
|
|
353
|
+
/** Permanently honor a merchant opt-out and discard queued events. */
|
|
354
|
+
disable() {
|
|
355
|
+
this.merchantEnabled = false;
|
|
356
|
+
this.queue.splice(0);
|
|
357
|
+
this.reportedFailures.clear();
|
|
358
|
+
this.clearFlushTimer();
|
|
507
359
|
}
|
|
508
|
-
/**
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
this.elementMap.set(type, providerExisting);
|
|
516
|
-
return providerExisting;
|
|
360
|
+
/** Start every bounded keepalive request synchronously before page teardown. */
|
|
361
|
+
drainQueue() {
|
|
362
|
+
if (!this.browserTransportAvailable || this.destroyed || this.ingestionDisabled || this.queue.length === 0) return;
|
|
363
|
+
this.clearFlushTimer();
|
|
364
|
+
while (this.queue.length > 0) {
|
|
365
|
+
const events = this.queue.splice(0, MAX_BATCH_SIZE);
|
|
366
|
+
void this.sendBatch(events);
|
|
517
367
|
}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
368
|
+
}
|
|
369
|
+
async sendBatch(events) {
|
|
370
|
+
if (!this.browserTransportAvailable || this.ingestionDisabled) return;
|
|
371
|
+
const body = serializeTelemetryBatch(events, {
|
|
372
|
+
correlationId: this.correlationId,
|
|
373
|
+
sdkPackage: this.sdkPackage,
|
|
374
|
+
sdkVersion: this.sdkVersion,
|
|
375
|
+
batchId: createUuidV4()
|
|
376
|
+
});
|
|
377
|
+
if (bodyByteLength(body) > TELEMETRY_MAX_BATCH_BYTES) return;
|
|
378
|
+
const controller = typeof AbortController === "undefined" ? null : new AbortController();
|
|
379
|
+
let timeout = null;
|
|
380
|
+
try {
|
|
381
|
+
const request = fetch(this.endpoint, {
|
|
382
|
+
method: "POST",
|
|
383
|
+
headers: { "content-type": "text/plain;charset=UTF-8" },
|
|
384
|
+
body,
|
|
385
|
+
credentials: "omit",
|
|
386
|
+
keepalive: true,
|
|
387
|
+
referrerPolicy: "no-referrer",
|
|
388
|
+
signal: controller?.signal
|
|
389
|
+
}).then(async (response) => {
|
|
390
|
+
if (response.status !== 202) return null;
|
|
391
|
+
const payload = await response.json().catch(() => null);
|
|
392
|
+
return payload?.status === "disabled" ? "disabled" : null;
|
|
393
|
+
}).catch(() => null);
|
|
394
|
+
const expired = new Promise((resolve) => {
|
|
395
|
+
timeout = setTimeout(() => {
|
|
396
|
+
controller?.abort();
|
|
397
|
+
resolve(null);
|
|
398
|
+
}, UPLOAD_TIMEOUT_MS);
|
|
399
|
+
});
|
|
400
|
+
const status = await Promise.race([request, expired]);
|
|
401
|
+
if (status === "disabled") this.disableFromIngestion();
|
|
402
|
+
} catch {
|
|
403
|
+
} finally {
|
|
404
|
+
if (timeout) clearTimeout(timeout);
|
|
521
405
|
}
|
|
522
|
-
const merged = { ...this.baseOptions, ...options };
|
|
523
|
-
const element = await this.provider.createElement(type, merged);
|
|
524
|
-
this.elementMap.set(type, element);
|
|
525
|
-
return element;
|
|
526
406
|
}
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
407
|
+
disableFromIngestion() {
|
|
408
|
+
this.ingestionDisabled = true;
|
|
409
|
+
this.queue.splice(0);
|
|
410
|
+
this.reportedFailures.clear();
|
|
411
|
+
this.clearFlushTimer();
|
|
530
412
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
async submit() {
|
|
538
|
-
return {};
|
|
413
|
+
scheduleFlush() {
|
|
414
|
+
if (this.flushTimer || this.destroyed || this.ingestionDisabled) return;
|
|
415
|
+
this.flushTimer = setTimeout(() => {
|
|
416
|
+
this.flushTimer = null;
|
|
417
|
+
void this.flush();
|
|
418
|
+
}, 0);
|
|
539
419
|
}
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
}
|
|
545
|
-
this.elementMap.clear();
|
|
420
|
+
clearFlushTimer() {
|
|
421
|
+
if (!this.flushTimer) return;
|
|
422
|
+
clearTimeout(this.flushTimer);
|
|
423
|
+
this.flushTimer = null;
|
|
546
424
|
}
|
|
547
425
|
};
|
|
426
|
+
var TELEMETRY_REPORTER_FACTORY = /* @__PURE__ */ Symbol.for("@flopay/js.telemetry.reporter-factory.v1");
|
|
427
|
+
var telemetryGlobal = globalThis;
|
|
428
|
+
if (telemetryGlobal[TELEMETRY_REPORTER_FACTORY] === void 0) {
|
|
429
|
+
Object.defineProperty(telemetryGlobal, TELEMETRY_REPORTER_FACTORY, {
|
|
430
|
+
configurable: true,
|
|
431
|
+
enumerable: false,
|
|
432
|
+
writable: false,
|
|
433
|
+
value: (options) => new TelemetryReporter(options)
|
|
434
|
+
});
|
|
435
|
+
}
|
|
548
436
|
|
|
549
437
|
// src/payment-api.ts
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
foldIntoProducts,
|
|
558
|
-
resolveIdempotencyKey,
|
|
559
|
-
resolveSessionCurrency
|
|
560
|
-
} from "@flopay/shared";
|
|
561
|
-
|
|
562
|
-
// src/api-error.ts
|
|
563
|
-
function readErrorString(value) {
|
|
564
|
-
return typeof value === "string" && value.trim() ? value : void 0;
|
|
565
|
-
}
|
|
566
|
-
function readErrorMessage(value) {
|
|
567
|
-
if (typeof value === "string") return value.trim() ? value : void 0;
|
|
568
|
-
if (Array.isArray(value)) {
|
|
569
|
-
const joined = value.filter((entry) => typeof entry === "string" && entry.trim().length > 0).join("; ");
|
|
570
|
-
return joined || void 0;
|
|
571
|
-
}
|
|
572
|
-
return void 0;
|
|
438
|
+
var DEFAULT_PROCESSING_RETRY_AFTER_MS = 1e3;
|
|
439
|
+
var MIN_PROCESSING_RETRY_AFTER_MS = 500;
|
|
440
|
+
var DEFAULT_PROCESSING_TIMEOUT_MS = 15e3;
|
|
441
|
+
var MAX_PROCESSING_RETRY_AFTER_MS = 3e3;
|
|
442
|
+
var DEFAULT_ACCOUNT_SNAPSHOT_TIMEOUT_MS = 1e4;
|
|
443
|
+
function isRecord(value) {
|
|
444
|
+
return typeof value === "object" && value !== null;
|
|
573
445
|
}
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
var memoryStore = /* @__PURE__ */ new Map();
|
|
579
|
-
function storageKey(sessionId) {
|
|
580
|
-
return `${STORAGE_KEY_PREFIX}${sessionId}`;
|
|
446
|
+
function telemetryStatusClass(status) {
|
|
447
|
+
if (status === void 0) return "network_error";
|
|
448
|
+
const statusClass = `${Math.floor(status / 100)}xx`;
|
|
449
|
+
return statusClass === "2xx" || statusClass === "3xx" || statusClass === "4xx" || statusClass === "5xx" ? statusClass : "unknown";
|
|
581
450
|
}
|
|
582
|
-
function
|
|
583
|
-
if (
|
|
584
|
-
|
|
585
|
-
return window.sessionStorage;
|
|
586
|
-
} catch {
|
|
587
|
-
return null;
|
|
451
|
+
function telemetryFailure(error, fallbackCode) {
|
|
452
|
+
if (error instanceof Error && (error.name === "AbortError" || error instanceof FloPayError && error.code === "checkout_processing_timeout")) {
|
|
453
|
+
return { errorCode: "REQUEST_TIMEOUT", statusClass: "timeout" };
|
|
588
454
|
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
if (!sessionId) return;
|
|
592
|
-
const ttl = options?.ttlMs ?? DEFAULT_TTL_MS;
|
|
593
|
-
const entry = { data, expiresAt: Date.now() + ttl };
|
|
594
|
-
const storage = getSessionStorage();
|
|
595
|
-
if (storage) {
|
|
596
|
-
try {
|
|
597
|
-
storage.setItem(storageKey(sessionId), JSON.stringify(entry));
|
|
598
|
-
return;
|
|
599
|
-
} catch {
|
|
600
|
-
}
|
|
455
|
+
if (error instanceof TypeError) {
|
|
456
|
+
return { errorCode: "NETWORK_REQUEST_FAILED", statusClass: "network_error" };
|
|
601
457
|
}
|
|
602
|
-
|
|
458
|
+
return {
|
|
459
|
+
errorCode: fallbackCode,
|
|
460
|
+
statusClass: telemetryStatusClass(
|
|
461
|
+
error instanceof FloPayError ? error.statusCode : void 0
|
|
462
|
+
)
|
|
463
|
+
};
|
|
603
464
|
}
|
|
604
|
-
function
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
465
|
+
function readString(payload, key) {
|
|
466
|
+
return readErrorString(payload?.[key]);
|
|
467
|
+
}
|
|
468
|
+
function readMessage(payload, key) {
|
|
469
|
+
return readErrorMessage(payload?.[key]);
|
|
470
|
+
}
|
|
471
|
+
function readNumber(payload, key) {
|
|
472
|
+
const value = payload?.[key];
|
|
473
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
474
|
+
}
|
|
475
|
+
function delay(ms) {
|
|
476
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
477
|
+
}
|
|
478
|
+
function createCheckoutProcessingTimeoutError() {
|
|
479
|
+
return new FloPayError(
|
|
480
|
+
"Checkout is still processing. Please try again shortly.",
|
|
481
|
+
"api_error",
|
|
482
|
+
{ code: "checkout_processing_timeout" }
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
async function buildApiErrorFromResponse(response, fallbackMessage) {
|
|
486
|
+
const payload = await response.json().catch(() => null);
|
|
487
|
+
const nestedError = isRecord(payload?.error) ? payload.error : null;
|
|
488
|
+
const message = readMessage(payload, "message") ?? readMessage(nestedError, "message") ?? fallbackMessage;
|
|
489
|
+
const code = readString(payload, "code") ?? readString(payload, "gatewayErrorCode") ?? readString(nestedError, "code") ?? `http_${response.status}`;
|
|
490
|
+
return new FloPayError(message, "api_error", {
|
|
491
|
+
code,
|
|
492
|
+
statusCode: response.status
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
var NETWORK_RETRY_ATTEMPTS = 2;
|
|
496
|
+
var IDEMPOTENCY_IN_PROGRESS_RETRY_ATTEMPTS = 2;
|
|
497
|
+
async function fetchWithNetworkRetry(input, init, attempts = NETWORK_RETRY_ATTEMPTS, onRetry) {
|
|
498
|
+
let lastErr;
|
|
499
|
+
for (let attempt = 0; ; attempt++) {
|
|
608
500
|
try {
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
501
|
+
return await fetch(input, init);
|
|
502
|
+
} catch (err) {
|
|
503
|
+
if (err instanceof Error && err.name === "AbortError") throw err;
|
|
504
|
+
lastErr = err;
|
|
505
|
+
if (attempt >= attempts) throw lastErr;
|
|
506
|
+
try {
|
|
507
|
+
onRetry?.(attempt + 1);
|
|
508
|
+
} catch {
|
|
616
509
|
}
|
|
617
|
-
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
const memEntry = memoryStore.get(sessionId);
|
|
621
|
-
if (memEntry) {
|
|
622
|
-
if (memEntry.expiresAt > Date.now()) {
|
|
623
|
-
return memEntry.data;
|
|
510
|
+
await delay(150 * 2 ** attempt);
|
|
624
511
|
}
|
|
625
|
-
memoryStore.delete(sessionId);
|
|
626
512
|
}
|
|
627
|
-
return null;
|
|
628
513
|
}
|
|
629
|
-
function
|
|
630
|
-
|
|
631
|
-
memoryStore.delete(sessionId);
|
|
632
|
-
const storage = getSessionStorage();
|
|
633
|
-
if (storage) {
|
|
634
|
-
try {
|
|
635
|
-
storage.removeItem(storageKey(sessionId));
|
|
636
|
-
} catch {
|
|
637
|
-
}
|
|
638
|
-
}
|
|
514
|
+
function isPaymentApiTelemetryHooks(value) {
|
|
515
|
+
return "now" in value || "onFirstByte" in value || "onSessionCreateFailure" in value || "onRetry" in value;
|
|
639
516
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
try {
|
|
665
|
-
return globalThis.crypto.randomUUID();
|
|
666
|
-
} catch {
|
|
667
|
-
const bytes = new Uint8Array(16);
|
|
517
|
+
var PaymentAPI = class {
|
|
518
|
+
constructor(billingApiUrl, telemetryOptionsOrHooks = {}) {
|
|
519
|
+
this.baseUrl = billingApiUrl.replace(/\/+$/, "");
|
|
520
|
+
const hasInternalHooks = isPaymentApiTelemetryHooks(telemetryOptionsOrHooks);
|
|
521
|
+
this.telemetryHooks = hasInternalHooks ? telemetryOptionsOrHooks : void 0;
|
|
522
|
+
this.directTelemetry = hasInternalHooks || telemetryOptionsOrHooks.telemetry === false ? void 0 : new TelemetryReporter({
|
|
523
|
+
billingApiUrl: this.baseUrl,
|
|
524
|
+
sdkVersion: SDK_VERSION
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
/** Dispose the reporter owned by direct public usage. Internal hooks are never disposed here. */
|
|
528
|
+
destroy() {
|
|
529
|
+
this.directTelemetry?.destroy();
|
|
530
|
+
}
|
|
531
|
+
reportDirectFailure(error, fallbackCode, stage, requestCategory, paymentMethodCategory = "unknown") {
|
|
532
|
+
const failure = telemetryFailure(error, fallbackCode);
|
|
533
|
+
this.directTelemetry?.error({
|
|
534
|
+
...failure,
|
|
535
|
+
stage,
|
|
536
|
+
requestCategory,
|
|
537
|
+
paymentMethodCategory
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
telemetryTimestamp() {
|
|
668
541
|
try {
|
|
669
|
-
|
|
542
|
+
return this.telemetryHooks?.now?.() ?? this.directTelemetry?.now() ?? telemetryNow();
|
|
670
543
|
} catch {
|
|
671
|
-
|
|
672
|
-
bytes[index] = Math.floor(Math.random() * 256);
|
|
673
|
-
}
|
|
544
|
+
return telemetryNow();
|
|
674
545
|
}
|
|
675
|
-
bytes[6] = bytes[6] & 15 | 64;
|
|
676
|
-
bytes[8] = bytes[8] & 63 | 128;
|
|
677
|
-
const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
678
|
-
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
679
546
|
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
} catch {
|
|
685
|
-
return body.length;
|
|
547
|
+
beginDirectTelemetryCheckout(checkoutSessionId) {
|
|
548
|
+
if (!this.directTelemetry || this.directTelemetryCheckoutId === checkoutSessionId) return;
|
|
549
|
+
this.directTelemetryCheckoutId = checkoutSessionId;
|
|
550
|
+
this.directTelemetry.beginCheckout();
|
|
686
551
|
}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
event.stage,
|
|
692
|
-
event.provider,
|
|
693
|
-
event.attempt,
|
|
694
|
-
event.statusClass,
|
|
695
|
-
event.requestCategory,
|
|
696
|
-
event.paymentMethodCategory,
|
|
697
|
-
event.checkoutMode,
|
|
698
|
-
event.layout
|
|
699
|
-
]);
|
|
700
|
-
}
|
|
701
|
-
function telemetryNow() {
|
|
702
|
-
return globalThis.performance?.now() ?? 0;
|
|
703
|
-
}
|
|
704
|
-
var TelemetryReporter = class {
|
|
705
|
-
constructor(options) {
|
|
706
|
-
this.ingestionDisabled = false;
|
|
707
|
-
this.queue = [];
|
|
708
|
-
this.sequence = 0;
|
|
709
|
-
this.flushTimer = null;
|
|
710
|
-
this.flushInFlight = null;
|
|
711
|
-
this.reportedFailures = /* @__PURE__ */ new Map();
|
|
712
|
-
this.checkoutContext = {};
|
|
713
|
-
this.checkoutStartedAt = null;
|
|
714
|
-
this.destroyed = false;
|
|
715
|
-
this.pageExitHandler = () => {
|
|
716
|
-
this.drainQueue();
|
|
717
|
-
};
|
|
718
|
-
this.visibilityHandler = () => {
|
|
719
|
-
if (document.visibilityState === "hidden") void this.flush();
|
|
720
|
-
};
|
|
721
|
-
this.eventCounts = {
|
|
722
|
-
technical_error: 0,
|
|
723
|
-
lifecycle: 0,
|
|
724
|
-
expected_outcome: 0,
|
|
725
|
-
performance: 0
|
|
726
|
-
};
|
|
727
|
-
this.endpoint = `${options.billingApiUrl.replace(/\/+$/, "")}${TELEMETRY_PATH}`;
|
|
728
|
-
this.sdkPackage = options.sdkPackage ?? "@flopay/js";
|
|
729
|
-
this.sdkVersion = options.sdkVersion;
|
|
730
|
-
this.correlationId = createUuidV4();
|
|
731
|
-
this.merchantEnabled = options.enabled !== false;
|
|
732
|
-
this.clock = options.clock ?? telemetryNow;
|
|
733
|
-
this.browserTransportAvailable = typeof window !== "undefined" && typeof document !== "undefined";
|
|
734
|
-
if (this.browserTransportAvailable) {
|
|
735
|
-
window.addEventListener("pagehide", this.pageExitHandler);
|
|
736
|
-
document.addEventListener("visibilitychange", this.visibilityHandler);
|
|
737
|
-
}
|
|
552
|
+
beginDirectTelemetryOperation() {
|
|
553
|
+
if (!this.directTelemetry) return;
|
|
554
|
+
this.directTelemetryCheckoutId = void 0;
|
|
555
|
+
this.directTelemetry.beginCheckout();
|
|
738
556
|
}
|
|
739
|
-
|
|
740
|
-
if (
|
|
741
|
-
this.enqueue(buildTelemetryLogEvent({
|
|
742
|
-
...this.checkoutContext,
|
|
743
|
-
...input,
|
|
744
|
-
eventId: createUuidV4(),
|
|
745
|
-
sequence: this.sequence++
|
|
746
|
-
}));
|
|
557
|
+
adoptDirectTelemetryCheckout(checkoutSessionId) {
|
|
558
|
+
if (checkoutSessionId) this.directTelemetryCheckoutId = checkoutSessionId;
|
|
747
559
|
}
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
560
|
+
/**
|
|
561
|
+
* Fetch a raw checkout session by ID.
|
|
562
|
+
*
|
|
563
|
+
* `nonce` is the session-bound checkout token returned when the session
|
|
564
|
+
* was created. When supplied it is sent as the `x-checkout-session-token`
|
|
565
|
+
* header that post-#640 backends match against `checkout_session.nonce`
|
|
566
|
+
* before returning the row — the UUID alone is no longer sufficient.
|
|
567
|
+
* Backends that don't yet enforce it ignore the extra header.
|
|
568
|
+
*/
|
|
569
|
+
async getCheckoutSession(checkoutSessionId, nonce) {
|
|
570
|
+
this.beginDirectTelemetryCheckout(checkoutSessionId);
|
|
571
|
+
const requestStarted = this.telemetryTimestamp();
|
|
572
|
+
this.directTelemetry?.log({
|
|
573
|
+
name: "session.read.started",
|
|
574
|
+
stage: "session_read",
|
|
575
|
+
requestCategory: "session_read"
|
|
755
576
|
});
|
|
756
|
-
const
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
577
|
+
const headers = { [FLO_SDK_VERSION_HEADER]: SDK_VERSION };
|
|
578
|
+
if (nonce) headers["x-checkout-session-token"] = nonce;
|
|
579
|
+
try {
|
|
580
|
+
const response = await fetchWithNetworkRetry(
|
|
581
|
+
`${this.baseUrl}/v1/checkouts/sessions/${encodeURIComponent(checkoutSessionId)}`,
|
|
582
|
+
{ headers },
|
|
583
|
+
NETWORK_RETRY_ATTEMPTS,
|
|
584
|
+
(attempt) => {
|
|
585
|
+
this.telemetryHooks?.onRetry?.("session_read", attempt);
|
|
586
|
+
this.directTelemetry?.log({
|
|
587
|
+
name: "operation.retry",
|
|
588
|
+
stage: "session_read",
|
|
589
|
+
requestCategory: "session_read",
|
|
590
|
+
attempt
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
);
|
|
594
|
+
const firstByteDuration = Math.max(0, this.telemetryTimestamp() - requestStarted);
|
|
595
|
+
try {
|
|
596
|
+
this.telemetryHooks?.onFirstByte?.(firstByteDuration);
|
|
597
|
+
} catch {
|
|
598
|
+
}
|
|
599
|
+
const statusClass = `${Math.floor(response.status / 100)}xx`;
|
|
600
|
+
this.directTelemetry?.log({
|
|
601
|
+
name: "session.request.first_byte",
|
|
602
|
+
stage: "session_first_byte",
|
|
603
|
+
requestCategory: "session_read",
|
|
604
|
+
statusClass
|
|
767
605
|
});
|
|
768
|
-
|
|
606
|
+
this.directTelemetry?.performance({
|
|
607
|
+
stage: "session_first_byte",
|
|
608
|
+
durationMs: firstByteDuration,
|
|
609
|
+
durationMode: "machine",
|
|
610
|
+
requestCategory: "session_read",
|
|
611
|
+
statusClass
|
|
612
|
+
});
|
|
613
|
+
if (!response.ok) {
|
|
614
|
+
throw await buildApiErrorFromResponse(response, "Failed to get checkout session");
|
|
615
|
+
}
|
|
616
|
+
const body = await response.json();
|
|
617
|
+
this.directTelemetry?.log({
|
|
618
|
+
name: "session.request.completed",
|
|
619
|
+
stage: "session_complete",
|
|
620
|
+
requestCategory: "session_read",
|
|
621
|
+
statusClass
|
|
622
|
+
});
|
|
623
|
+
this.directTelemetry?.performance({
|
|
624
|
+
stage: "session_complete",
|
|
625
|
+
durationMs: Math.max(0, this.telemetryTimestamp() - requestStarted),
|
|
626
|
+
durationMode: "machine",
|
|
627
|
+
requestCategory: "session_read",
|
|
628
|
+
statusClass
|
|
629
|
+
});
|
|
630
|
+
return { ...body, data: this.mergeCachedDisplayData(body.data) };
|
|
631
|
+
} catch (error) {
|
|
632
|
+
const statusCode = error instanceof FloPayError ? error.statusCode : void 0;
|
|
633
|
+
this.directTelemetry?.error({
|
|
634
|
+
errorCode: error instanceof FloPayError && error.code === "checkout_processing_timeout" ? "REQUEST_TIMEOUT" : "NETWORK_REQUEST_FAILED",
|
|
635
|
+
stage: "session_read",
|
|
636
|
+
requestCategory: "session_read",
|
|
637
|
+
statusClass: statusCode ? `${Math.floor(statusCode / 100)}xx` : "network_error"
|
|
638
|
+
});
|
|
639
|
+
throw error;
|
|
769
640
|
}
|
|
770
|
-
this.rememberReportedFailure(deduplicationKey, now);
|
|
771
|
-
this.enqueue(buildTelemetryErrorEvent({
|
|
772
|
-
...this.checkoutContext,
|
|
773
|
-
...input,
|
|
774
|
-
eventId: createUuidV4(),
|
|
775
|
-
sequence: this.sequence++
|
|
776
|
-
}));
|
|
777
641
|
}
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
642
|
+
/**
|
|
643
|
+
* Stash display-only data for a session so subsequent fetches can fill in
|
|
644
|
+
* fields the backend no longer persists (`overrideAmount`, `totalAmount`,
|
|
645
|
+
* `providerItemName`, `providerPlanName`).
|
|
646
|
+
*
|
|
647
|
+
* Backed by `sessionStorage` in the browser, with an in-memory fallback in
|
|
648
|
+
* Node/SSR contexts. Default TTL: 1 hour.
|
|
649
|
+
*
|
|
650
|
+
* Server-returned values always win — cached values fill in only where the
|
|
651
|
+
* server returned `null` / `undefined`.
|
|
652
|
+
*
|
|
653
|
+
* @example
|
|
654
|
+
* ```ts
|
|
655
|
+
* paymentAPI.cacheSessionDisplayData(sessionId, {
|
|
656
|
+
* currency: 'USD',
|
|
657
|
+
* items: [{ code: 'pro_plan', overrideAmount: 24.99, providerItemName: 'Pro' }],
|
|
658
|
+
* });
|
|
659
|
+
* ```
|
|
660
|
+
*/
|
|
661
|
+
cacheSessionDisplayData(sessionId, data, options) {
|
|
662
|
+
cacheSessionDisplayData(sessionId, data, options);
|
|
786
663
|
}
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
sequence: this.sequence++
|
|
794
|
-
}));
|
|
795
|
-
if (input.outcome !== "action_required" && this.checkoutStartedAt !== null) {
|
|
796
|
-
const checkoutStartedAt = this.checkoutStartedAt;
|
|
797
|
-
this.checkoutStartedAt = null;
|
|
798
|
-
this.performance({
|
|
799
|
-
stage: "total_journey",
|
|
800
|
-
durationMs: Math.max(0, this.now() - checkoutStartedAt),
|
|
801
|
-
durationMode: "total",
|
|
802
|
-
provider: input.provider,
|
|
803
|
-
paymentMethodCategory: input.paymentMethodCategory
|
|
804
|
-
});
|
|
805
|
-
}
|
|
664
|
+
/**
|
|
665
|
+
* Drop any cached display data for a session. Call after the payment
|
|
666
|
+
* completes; otherwise the TTL handles cleanup.
|
|
667
|
+
*/
|
|
668
|
+
clearSessionDisplayData(sessionId) {
|
|
669
|
+
clearSessionDisplayData(sessionId);
|
|
806
670
|
}
|
|
807
|
-
/**
|
|
808
|
-
|
|
671
|
+
/**
|
|
672
|
+
* Fetch (re-mint) the hosted vault capture widget for a session
|
|
673
|
+
* (TeamFloPay/backend#823).
|
|
674
|
+
*
|
|
675
|
+
* `POST /v1/checkouts/sessions/{id}/vault/capture` returns the SDK-ready
|
|
676
|
+
* {@link VaultCaptureBlock} (`html` + `url`, plus `messageToken` /
|
|
677
|
+
* `expectedOrigin` once the backend mints them). The SDK injects `html` as
|
|
678
|
+
* the card-capture widget. This is the fallback path for sessions that did
|
|
679
|
+
* not receive the embedded `vault` block on create (e.g. a session loaded by
|
|
680
|
+
* id via `GET`, or a pre-1.3.0 create); the endpoint is idempotent and reuses
|
|
681
|
+
* session-cached creds when available.
|
|
682
|
+
*
|
|
683
|
+
* Because the endpoint is idempotent, the request is wrapped in
|
|
684
|
+
* `fetchWithNetworkRetry`: a transient network blip (dropped connection, DNS
|
|
685
|
+
* hiccup, failed CORS preflight) would otherwise leave the secure card form
|
|
686
|
+
* unable to load and hard-block checkout.
|
|
687
|
+
*
|
|
688
|
+
* The PCIVault submit *secret* the backend may include in the response is
|
|
689
|
+
* intentionally **not** read or surfaced — it is server-only and never enters
|
|
690
|
+
* the SDK runtime.
|
|
691
|
+
*
|
|
692
|
+
* `nonce` is forwarded as `x-checkout-session-token` (required by post-#640
|
|
693
|
+
* backends, matched against the session's stored nonce).
|
|
694
|
+
*/
|
|
695
|
+
async getVaultCapture(checkoutSessionId, nonce) {
|
|
696
|
+
this.beginDirectTelemetryCheckout(checkoutSessionId);
|
|
697
|
+
const startedAt = this.telemetryTimestamp();
|
|
698
|
+
this.directTelemetry?.log({
|
|
699
|
+
name: "vault.capture.requested",
|
|
700
|
+
stage: "vault_request",
|
|
701
|
+
requestCategory: "vault_capture",
|
|
702
|
+
paymentMethodCategory: "card"
|
|
703
|
+
});
|
|
704
|
+
const headers = { "Content-Type": "application/json" };
|
|
705
|
+
if (nonce) headers["x-checkout-session-token"] = nonce;
|
|
809
706
|
try {
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
707
|
+
const response = await fetchWithNetworkRetry(
|
|
708
|
+
`${this.baseUrl}/v1/checkouts/sessions/${encodeURIComponent(checkoutSessionId)}/vault/capture`,
|
|
709
|
+
{ method: "POST", headers },
|
|
710
|
+
NETWORK_RETRY_ATTEMPTS,
|
|
711
|
+
(attempt) => {
|
|
712
|
+
this.telemetryHooks?.onRetry?.("vault_capture", attempt);
|
|
713
|
+
this.directTelemetry?.log({
|
|
714
|
+
name: "operation.retry",
|
|
715
|
+
stage: "vault_request",
|
|
716
|
+
requestCategory: "vault_capture",
|
|
717
|
+
paymentMethodCategory: "card",
|
|
718
|
+
attempt
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
);
|
|
722
|
+
if (!response.ok) {
|
|
723
|
+
throw await buildApiErrorFromResponse(response, "Failed to load the secure card form");
|
|
724
|
+
}
|
|
725
|
+
const block = await response.json();
|
|
726
|
+
this.directTelemetry?.performance({
|
|
727
|
+
stage: "vault_request",
|
|
728
|
+
durationMs: Math.max(0, this.telemetryTimestamp() - startedAt),
|
|
729
|
+
durationMode: "machine",
|
|
730
|
+
requestCategory: "vault_capture",
|
|
731
|
+
paymentMethodCategory: "card",
|
|
732
|
+
statusClass: "2xx"
|
|
733
|
+
});
|
|
734
|
+
return this.toVaultBlock(block);
|
|
735
|
+
} catch (error) {
|
|
736
|
+
this.reportDirectFailure(
|
|
737
|
+
error,
|
|
738
|
+
"VAULT_LOAD_FAILED",
|
|
739
|
+
"vault_request",
|
|
740
|
+
"vault_capture",
|
|
741
|
+
"card"
|
|
742
|
+
);
|
|
743
|
+
throw error;
|
|
813
744
|
}
|
|
814
745
|
}
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
746
|
+
/**
|
|
747
|
+
* Fetch and normalize a checkout session.
|
|
748
|
+
*
|
|
749
|
+
* Reads the backend's `gateways` map to enumerate provider-specific data,
|
|
750
|
+
* then wraps the session in a `NormalizedCheckoutSession` for provider-
|
|
751
|
+
* agnostic consumption.
|
|
752
|
+
*/
|
|
753
|
+
async getUnifiedCheckoutSession(checkoutSessionId, nonce) {
|
|
754
|
+
const res = await this.getCheckoutSession(checkoutSessionId, nonce);
|
|
755
|
+
const normalized = this.normalizeRawSession(res.data);
|
|
756
|
+
const vault = res.vault;
|
|
757
|
+
if (vault && normalized.data.session) {
|
|
758
|
+
normalized.data.session.vault = this.toVaultBlock(vault);
|
|
820
759
|
}
|
|
760
|
+
return normalized;
|
|
821
761
|
}
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
this.
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
lifecycle: 0,
|
|
847
|
-
expected_outcome: 0,
|
|
848
|
-
performance: 0
|
|
849
|
-
};
|
|
850
|
-
this.checkoutStartedAt = this.now();
|
|
851
|
-
return this.checkoutStartedAt;
|
|
852
|
-
}
|
|
853
|
-
enqueue(event) {
|
|
854
|
-
if (!this.canCollect()) return;
|
|
855
|
-
if (this.queue.length >= MAX_QUEUE_SIZE || this.eventCounts[event.class] >= EVENT_BUDGETS[event.class]) return;
|
|
856
|
-
this.eventCounts[event.class] += 1;
|
|
857
|
-
this.queue.push(event);
|
|
858
|
-
if (this.queue.length >= MAX_BATCH_SIZE) {
|
|
859
|
-
void this.flush();
|
|
860
|
-
return;
|
|
861
|
-
}
|
|
862
|
-
this.scheduleFlush();
|
|
863
|
-
}
|
|
864
|
-
canCollect() {
|
|
865
|
-
return this.browserTransportAvailable && this.merchantEnabled && !this.ingestionDisabled && !this.destroyed;
|
|
866
|
-
}
|
|
867
|
-
/** Flush one bounded batch. Failures are intentionally dropped. */
|
|
868
|
-
async flush() {
|
|
869
|
-
if (this.flushInFlight) return this.flushInFlight;
|
|
870
|
-
if (!this.browserTransportAvailable || this.destroyed || this.ingestionDisabled || this.queue.length === 0) return;
|
|
871
|
-
this.clearFlushTimer();
|
|
872
|
-
const events = this.queue.splice(0, MAX_BATCH_SIZE);
|
|
873
|
-
this.flushInFlight = this.sendBatch(events).finally(() => {
|
|
874
|
-
this.flushInFlight = null;
|
|
875
|
-
if (this.queue.length > 0) this.scheduleFlush();
|
|
876
|
-
});
|
|
877
|
-
return this.flushInFlight;
|
|
878
|
-
}
|
|
879
|
-
/** Flush pending work and detach browser lifecycle listeners. */
|
|
880
|
-
destroy() {
|
|
881
|
-
if (this.destroyed) return;
|
|
882
|
-
this.drainQueue();
|
|
883
|
-
this.destroyed = true;
|
|
884
|
-
this.clearFlushTimer();
|
|
885
|
-
if (this.browserTransportAvailable) {
|
|
886
|
-
window.removeEventListener("pagehide", this.pageExitHandler);
|
|
887
|
-
document.removeEventListener("visibilitychange", this.visibilityHandler);
|
|
888
|
-
}
|
|
889
|
-
this.queue.splice(0);
|
|
890
|
-
this.reportedFailures.clear();
|
|
891
|
-
}
|
|
892
|
-
/** Permanently honor a merchant opt-out and discard queued events. */
|
|
893
|
-
disable() {
|
|
894
|
-
this.merchantEnabled = false;
|
|
895
|
-
this.queue.splice(0);
|
|
896
|
-
this.reportedFailures.clear();
|
|
897
|
-
this.clearFlushTimer();
|
|
898
|
-
}
|
|
899
|
-
/** Start every bounded keepalive request synchronously before page teardown. */
|
|
900
|
-
drainQueue() {
|
|
901
|
-
if (!this.browserTransportAvailable || this.destroyed || this.ingestionDisabled || this.queue.length === 0) return;
|
|
902
|
-
this.clearFlushTimer();
|
|
903
|
-
while (this.queue.length > 0) {
|
|
904
|
-
const events = this.queue.splice(0, MAX_BATCH_SIZE);
|
|
905
|
-
void this.sendBatch(events);
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
async sendBatch(events) {
|
|
909
|
-
if (!this.browserTransportAvailable || this.ingestionDisabled) return;
|
|
910
|
-
const body = serializeTelemetryBatch(events, {
|
|
911
|
-
correlationId: this.correlationId,
|
|
912
|
-
sdkPackage: this.sdkPackage,
|
|
913
|
-
sdkVersion: this.sdkVersion,
|
|
914
|
-
batchId: createUuidV4()
|
|
915
|
-
});
|
|
916
|
-
if (bodyByteLength(body) > TELEMETRY_MAX_BATCH_BYTES) return;
|
|
917
|
-
const controller = typeof AbortController === "undefined" ? null : new AbortController();
|
|
918
|
-
let timeout = null;
|
|
919
|
-
try {
|
|
920
|
-
const request = fetch(this.endpoint, {
|
|
921
|
-
method: "POST",
|
|
922
|
-
headers: { "content-type": "text/plain;charset=UTF-8" },
|
|
923
|
-
body,
|
|
924
|
-
credentials: "omit",
|
|
925
|
-
keepalive: true,
|
|
926
|
-
referrerPolicy: "no-referrer",
|
|
927
|
-
signal: controller?.signal
|
|
928
|
-
}).then(async (response) => {
|
|
929
|
-
if (response.status !== 202) return null;
|
|
930
|
-
const payload = await response.json().catch(() => null);
|
|
931
|
-
return payload?.status === "disabled" ? "disabled" : null;
|
|
932
|
-
}).catch(() => null);
|
|
933
|
-
const expired = new Promise((resolve) => {
|
|
934
|
-
timeout = setTimeout(() => {
|
|
935
|
-
controller?.abort();
|
|
936
|
-
resolve(null);
|
|
937
|
-
}, UPLOAD_TIMEOUT_MS);
|
|
762
|
+
/**
|
|
763
|
+
* Submit a tokenized payment to the billing backend.
|
|
764
|
+
*
|
|
765
|
+
* The backend will either succeed, return `type: '3ds_required'`
|
|
766
|
+
* (with a `threeDSecureToken`), or return `type: 'paypal_redirect_required'`.
|
|
767
|
+
*
|
|
768
|
+
* Hits the session-scoped route `POST /v1/checkouts/sessions/:id/process`
|
|
769
|
+
* and forwards `data.nonce` as `x-checkout-session-token`. Backend
|
|
770
|
+
* `TeamFloPay/backend#640` rejects callers without a matching nonce with a
|
|
771
|
+
* 401 — this method throws synchronously when `data.nonce` is missing so the
|
|
772
|
+
* problem surfaces before the network round trip.
|
|
773
|
+
*
|
|
774
|
+
* @param userId Vestigial — backend's GatewayInterceptor routes via session,
|
|
775
|
+
* not headers, so this value is no longer sent on the wire. Kept in the
|
|
776
|
+
* signature for back-compat with existing callers; will be removed in a
|
|
777
|
+
* future major version.
|
|
778
|
+
*/
|
|
779
|
+
async processPayment(_userId, data, options) {
|
|
780
|
+
this.beginDirectTelemetryCheckout(data.sessionId);
|
|
781
|
+
if (!data.nonce) {
|
|
782
|
+
this.directTelemetry?.terminal({
|
|
783
|
+
outcome: "validation_rejected",
|
|
784
|
+
stage: "processing",
|
|
785
|
+
requestCategory: "process_payment"
|
|
938
786
|
});
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
}
|
|
945
|
-
}
|
|
946
|
-
disableFromIngestion() {
|
|
947
|
-
this.ingestionDisabled = true;
|
|
948
|
-
this.queue.splice(0);
|
|
949
|
-
this.reportedFailures.clear();
|
|
950
|
-
this.clearFlushTimer();
|
|
951
|
-
}
|
|
952
|
-
scheduleFlush() {
|
|
953
|
-
if (this.flushTimer || this.destroyed || this.ingestionDisabled) return;
|
|
954
|
-
this.flushTimer = setTimeout(() => {
|
|
955
|
-
this.flushTimer = null;
|
|
956
|
-
void this.flush();
|
|
957
|
-
}, 0);
|
|
958
|
-
}
|
|
959
|
-
clearFlushTimer() {
|
|
960
|
-
if (!this.flushTimer) return;
|
|
961
|
-
clearTimeout(this.flushTimer);
|
|
962
|
-
this.flushTimer = null;
|
|
963
|
-
}
|
|
964
|
-
};
|
|
965
|
-
var TELEMETRY_REPORTER_FACTORY = /* @__PURE__ */ Symbol.for("@flopay/js.telemetry.reporter-factory.v1");
|
|
966
|
-
var telemetryGlobal = globalThis;
|
|
967
|
-
if (telemetryGlobal[TELEMETRY_REPORTER_FACTORY] === void 0) {
|
|
968
|
-
Object.defineProperty(telemetryGlobal, TELEMETRY_REPORTER_FACTORY, {
|
|
969
|
-
configurable: true,
|
|
970
|
-
enumerable: false,
|
|
971
|
-
writable: false,
|
|
972
|
-
value: (options) => new TelemetryReporter(options)
|
|
973
|
-
});
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
// src/payment-api.ts
|
|
977
|
-
var DEFAULT_PROCESSING_RETRY_AFTER_MS = 1e3;
|
|
978
|
-
var MIN_PROCESSING_RETRY_AFTER_MS = 500;
|
|
979
|
-
var DEFAULT_PROCESSING_TIMEOUT_MS = 15e3;
|
|
980
|
-
var MAX_PROCESSING_RETRY_AFTER_MS = 3e3;
|
|
981
|
-
var DEFAULT_ACCOUNT_SNAPSHOT_TIMEOUT_MS = 1e4;
|
|
982
|
-
function isRecord(value) {
|
|
983
|
-
return typeof value === "object" && value !== null;
|
|
984
|
-
}
|
|
985
|
-
function telemetryStatusClass(status) {
|
|
986
|
-
if (status === void 0) return "network_error";
|
|
987
|
-
const statusClass = `${Math.floor(status / 100)}xx`;
|
|
988
|
-
return statusClass === "2xx" || statusClass === "3xx" || statusClass === "4xx" || statusClass === "5xx" ? statusClass : "unknown";
|
|
989
|
-
}
|
|
990
|
-
function telemetryFailure(error, fallbackCode) {
|
|
991
|
-
if (error instanceof Error && (error.name === "AbortError" || error instanceof FloPayError3 && error.code === "checkout_processing_timeout")) {
|
|
992
|
-
return { errorCode: "REQUEST_TIMEOUT", statusClass: "timeout" };
|
|
993
|
-
}
|
|
994
|
-
if (error instanceof TypeError) {
|
|
995
|
-
return { errorCode: "NETWORK_REQUEST_FAILED", statusClass: "network_error" };
|
|
996
|
-
}
|
|
997
|
-
return {
|
|
998
|
-
errorCode: fallbackCode,
|
|
999
|
-
statusClass: telemetryStatusClass(
|
|
1000
|
-
error instanceof FloPayError3 ? error.statusCode : void 0
|
|
1001
|
-
)
|
|
1002
|
-
};
|
|
1003
|
-
}
|
|
1004
|
-
function readString(payload, key) {
|
|
1005
|
-
return readErrorString(payload?.[key]);
|
|
1006
|
-
}
|
|
1007
|
-
function readMessage(payload, key) {
|
|
1008
|
-
return readErrorMessage(payload?.[key]);
|
|
1009
|
-
}
|
|
1010
|
-
function readNumber(payload, key) {
|
|
1011
|
-
const value = payload?.[key];
|
|
1012
|
-
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
1013
|
-
}
|
|
1014
|
-
function delay(ms) {
|
|
1015
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1016
|
-
}
|
|
1017
|
-
function createCheckoutProcessingTimeoutError() {
|
|
1018
|
-
return new FloPayError3(
|
|
1019
|
-
"Checkout is still processing. Please try again shortly.",
|
|
1020
|
-
"api_error",
|
|
1021
|
-
{ code: "checkout_processing_timeout" }
|
|
1022
|
-
);
|
|
1023
|
-
}
|
|
1024
|
-
async function buildApiErrorFromResponse(response, fallbackMessage) {
|
|
1025
|
-
const payload = await response.json().catch(() => null);
|
|
1026
|
-
const nestedError = isRecord(payload?.error) ? payload.error : null;
|
|
1027
|
-
const message = readMessage(payload, "message") ?? readMessage(nestedError, "message") ?? fallbackMessage;
|
|
1028
|
-
const code = readString(payload, "code") ?? readString(payload, "gatewayErrorCode") ?? readString(nestedError, "code") ?? `http_${response.status}`;
|
|
1029
|
-
return new FloPayError3(message, "api_error", {
|
|
1030
|
-
code,
|
|
1031
|
-
statusCode: response.status
|
|
1032
|
-
});
|
|
1033
|
-
}
|
|
1034
|
-
var NETWORK_RETRY_ATTEMPTS = 2;
|
|
1035
|
-
var IDEMPOTENCY_IN_PROGRESS_RETRY_ATTEMPTS = 2;
|
|
1036
|
-
async function fetchWithNetworkRetry(input, init, attempts = NETWORK_RETRY_ATTEMPTS, onRetry) {
|
|
1037
|
-
let lastErr;
|
|
1038
|
-
for (let attempt = 0; ; attempt++) {
|
|
1039
|
-
try {
|
|
1040
|
-
return await fetch(input, init);
|
|
1041
|
-
} catch (err) {
|
|
1042
|
-
if (err instanceof Error && err.name === "AbortError") throw err;
|
|
1043
|
-
lastErr = err;
|
|
1044
|
-
if (attempt >= attempts) throw lastErr;
|
|
1045
|
-
try {
|
|
1046
|
-
onRetry?.(attempt + 1);
|
|
1047
|
-
} catch {
|
|
1048
|
-
}
|
|
1049
|
-
await delay(150 * 2 ** attempt);
|
|
787
|
+
throw new FloPayError(
|
|
788
|
+
"processPayment requires `nonce` \u2014 pass the value returned from session creation.",
|
|
789
|
+
"validation_error",
|
|
790
|
+
{ code: "MissingCheckoutSessionToken", param: "nonce" }
|
|
791
|
+
);
|
|
1050
792
|
}
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
var PaymentAPI = class {
|
|
1057
|
-
constructor(billingApiUrl, telemetryOptionsOrHooks = {}) {
|
|
1058
|
-
this.baseUrl = billingApiUrl.replace(/\/+$/, "");
|
|
1059
|
-
const hasInternalHooks = isPaymentApiTelemetryHooks(telemetryOptionsOrHooks);
|
|
1060
|
-
this.telemetryHooks = hasInternalHooks ? telemetryOptionsOrHooks : void 0;
|
|
1061
|
-
this.directTelemetry = hasInternalHooks || telemetryOptionsOrHooks.telemetry === false ? void 0 : new TelemetryReporter({
|
|
1062
|
-
billingApiUrl: this.baseUrl,
|
|
1063
|
-
sdkVersion: SDK_VERSION
|
|
1064
|
-
});
|
|
1065
|
-
}
|
|
1066
|
-
/** Dispose the reporter owned by direct public usage. Internal hooks are never disposed here. */
|
|
1067
|
-
destroy() {
|
|
1068
|
-
this.directTelemetry?.destroy();
|
|
1069
|
-
}
|
|
1070
|
-
reportDirectFailure(error, fallbackCode, stage, requestCategory, paymentMethodCategory = "unknown") {
|
|
1071
|
-
const failure = telemetryFailure(error, fallbackCode);
|
|
1072
|
-
this.directTelemetry?.error({
|
|
1073
|
-
...failure,
|
|
1074
|
-
stage,
|
|
1075
|
-
requestCategory,
|
|
1076
|
-
paymentMethodCategory
|
|
793
|
+
const startedAt = this.telemetryTimestamp();
|
|
794
|
+
this.directTelemetry?.log({
|
|
795
|
+
name: "payment.processing.started",
|
|
796
|
+
stage: "processing",
|
|
797
|
+
requestCategory: "process_payment"
|
|
1077
798
|
});
|
|
1078
|
-
|
|
1079
|
-
|
|
799
|
+
const { nonce, ...processBody } = data;
|
|
800
|
+
let response;
|
|
801
|
+
try {
|
|
802
|
+
response = await fetch(
|
|
803
|
+
`${this.baseUrl}/v1/checkouts/sessions/${encodeURIComponent(data.sessionId)}/process`,
|
|
804
|
+
{
|
|
805
|
+
method: "POST",
|
|
806
|
+
headers: {
|
|
807
|
+
"Content-Type": "application/json",
|
|
808
|
+
"x-checkout-session-token": nonce
|
|
809
|
+
},
|
|
810
|
+
body: JSON.stringify(processBody)
|
|
811
|
+
}
|
|
812
|
+
);
|
|
813
|
+
} catch (error) {
|
|
814
|
+
this.reportDirectFailure(
|
|
815
|
+
error,
|
|
816
|
+
"PAYMENT_PROCESSING_FAILED",
|
|
817
|
+
"processing",
|
|
818
|
+
"process_payment"
|
|
819
|
+
);
|
|
820
|
+
throw error;
|
|
821
|
+
}
|
|
822
|
+
if (!response.ok && response.status !== 202) {
|
|
823
|
+
this.directTelemetry?.error({
|
|
824
|
+
errorCode: "PAYMENT_PROCESSING_FAILED",
|
|
825
|
+
stage: "processing",
|
|
826
|
+
requestCategory: "process_payment",
|
|
827
|
+
statusClass: telemetryStatusClass(response.status)
|
|
828
|
+
});
|
|
829
|
+
return response;
|
|
830
|
+
}
|
|
1080
831
|
try {
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
832
|
+
const result = await this.resolveProcessResponse(
|
|
833
|
+
response,
|
|
834
|
+
data.sessionId,
|
|
835
|
+
{ ...options, nonce }
|
|
836
|
+
);
|
|
837
|
+
this.directTelemetry?.log({
|
|
838
|
+
name: "payment.processing.completed",
|
|
839
|
+
stage: "processing",
|
|
840
|
+
requestCategory: "process_payment",
|
|
841
|
+
statusClass: telemetryStatusClass(result.status)
|
|
842
|
+
});
|
|
843
|
+
this.directTelemetry?.performance({
|
|
844
|
+
stage: "processing",
|
|
845
|
+
durationMs: Math.max(0, this.telemetryTimestamp() - startedAt),
|
|
846
|
+
durationMode: "machine",
|
|
847
|
+
requestCategory: "process_payment",
|
|
848
|
+
statusClass: telemetryStatusClass(result.status)
|
|
849
|
+
});
|
|
850
|
+
return result;
|
|
851
|
+
} catch (error) {
|
|
852
|
+
if (!(error instanceof FloPayError && error.code === "checkout_processing_timeout")) {
|
|
853
|
+
this.reportDirectFailure(
|
|
854
|
+
error,
|
|
855
|
+
"PAYMENT_PROCESSING_FAILED",
|
|
856
|
+
"processing",
|
|
857
|
+
"process_payment"
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
throw error;
|
|
1084
861
|
}
|
|
1085
862
|
}
|
|
1086
|
-
beginDirectTelemetryCheckout(checkoutSessionId) {
|
|
1087
|
-
if (!this.directTelemetry || this.directTelemetryCheckoutId === checkoutSessionId) return;
|
|
1088
|
-
this.directTelemetryCheckoutId = checkoutSessionId;
|
|
1089
|
-
this.directTelemetry.beginCheckout();
|
|
1090
|
-
}
|
|
1091
|
-
beginDirectTelemetryOperation() {
|
|
1092
|
-
if (!this.directTelemetry) return;
|
|
1093
|
-
this.directTelemetryCheckoutId = void 0;
|
|
1094
|
-
this.directTelemetry.beginCheckout();
|
|
1095
|
-
}
|
|
1096
|
-
adoptDirectTelemetryCheckout(checkoutSessionId) {
|
|
1097
|
-
if (checkoutSessionId) this.directTelemetryCheckoutId = checkoutSessionId;
|
|
1098
|
-
}
|
|
1099
863
|
/**
|
|
1100
|
-
*
|
|
864
|
+
* Patch the buyer's account snapshot (email, name, billing address, AVS
|
|
865
|
+
* intent) onto a checkout session via
|
|
866
|
+
* `PATCH /v1/checkouts/sessions/{id}/account` (TeamFloPay/backend#823).
|
|
1101
867
|
*
|
|
1102
|
-
*
|
|
1103
|
-
*
|
|
1104
|
-
*
|
|
1105
|
-
*
|
|
1106
|
-
*
|
|
868
|
+
* The vault path's hosted form owns the charge end-to-end so the SDK
|
|
869
|
+
* never calls `/process` on this path; the buyer-typed AVS / billing
|
|
870
|
+
* address would otherwise be lost. The SDK calls this just before
|
|
871
|
+
* submitting the vault widget so the downstream listener mints the
|
|
872
|
+
* Stripe PaymentMethod with the right `billing_details.address` and the
|
|
873
|
+
* per-attempt + per-PM address snapshots are populated.
|
|
874
|
+
*
|
|
875
|
+
* Body shape mirrors the relevant subset of `/process`'s
|
|
876
|
+
* `ProcessCheckoutBodyDto` — same keys, same validators. The endpoint
|
|
877
|
+
* is idempotent: empty/undefined fields are not written, addresses are
|
|
878
|
+
* last-writer-wins, AVS analytics are first-writer-wins.
|
|
879
|
+
*
|
|
880
|
+
* Wrapped in `fetchWithNetworkRetry` because a transient blip on this
|
|
881
|
+
* pre-pay PATCH would silently leave AVS unsent and cause an
|
|
882
|
+
* AVS-protected charge to decline downstream.
|
|
1107
883
|
*/
|
|
1108
|
-
async
|
|
1109
|
-
this.beginDirectTelemetryCheckout(
|
|
1110
|
-
const
|
|
884
|
+
async patchAccountSnapshot(sessionId, nonce, body, options) {
|
|
885
|
+
this.beginDirectTelemetryCheckout(sessionId);
|
|
886
|
+
const startedAt = this.telemetryTimestamp();
|
|
1111
887
|
this.directTelemetry?.log({
|
|
1112
|
-
name: "
|
|
1113
|
-
stage: "
|
|
1114
|
-
requestCategory: "
|
|
888
|
+
name: "operation.state_transition",
|
|
889
|
+
stage: "processing",
|
|
890
|
+
requestCategory: "account_snapshot"
|
|
1115
891
|
});
|
|
1116
|
-
const
|
|
1117
|
-
|
|
892
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_ACCOUNT_SNAPSHOT_TIMEOUT_MS;
|
|
893
|
+
const controller = new AbortController();
|
|
894
|
+
const onCallerAbort = () => controller.abort();
|
|
895
|
+
if (options?.signal) {
|
|
896
|
+
if (options.signal.aborted) controller.abort();
|
|
897
|
+
else options.signal.addEventListener("abort", onCallerAbort, { once: true });
|
|
898
|
+
}
|
|
899
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1118
900
|
try {
|
|
1119
|
-
|
|
1120
|
-
`${this.baseUrl}/v1/checkouts/sessions/${encodeURIComponent(checkoutSessionId)}`,
|
|
1121
|
-
{ headers },
|
|
1122
|
-
NETWORK_RETRY_ATTEMPTS,
|
|
1123
|
-
(attempt) => {
|
|
1124
|
-
this.telemetryHooks?.onRetry?.("session_read", attempt);
|
|
1125
|
-
this.directTelemetry?.log({
|
|
1126
|
-
name: "operation.retry",
|
|
1127
|
-
stage: "session_read",
|
|
1128
|
-
requestCategory: "session_read",
|
|
1129
|
-
attempt
|
|
1130
|
-
});
|
|
1131
|
-
}
|
|
1132
|
-
);
|
|
1133
|
-
const firstByteDuration = Math.max(0, this.telemetryTimestamp() - requestStarted);
|
|
901
|
+
let response;
|
|
1134
902
|
try {
|
|
1135
|
-
|
|
1136
|
-
|
|
903
|
+
response = await fetchWithNetworkRetry(
|
|
904
|
+
`${this.baseUrl}/v1/checkouts/sessions/${encodeURIComponent(sessionId)}/account`,
|
|
905
|
+
{
|
|
906
|
+
method: "PATCH",
|
|
907
|
+
headers: {
|
|
908
|
+
"Content-Type": "application/json",
|
|
909
|
+
"x-checkout-session-token": nonce
|
|
910
|
+
},
|
|
911
|
+
body: JSON.stringify(body),
|
|
912
|
+
signal: controller.signal
|
|
913
|
+
},
|
|
914
|
+
NETWORK_RETRY_ATTEMPTS,
|
|
915
|
+
(attempt) => {
|
|
916
|
+
this.telemetryHooks?.onRetry?.("account_snapshot", attempt);
|
|
917
|
+
this.directTelemetry?.log({
|
|
918
|
+
name: "operation.retry",
|
|
919
|
+
stage: "processing",
|
|
920
|
+
requestCategory: "account_snapshot",
|
|
921
|
+
attempt
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
);
|
|
925
|
+
} finally {
|
|
926
|
+
clearTimeout(timer);
|
|
927
|
+
options?.signal?.removeEventListener("abort", onCallerAbort);
|
|
928
|
+
}
|
|
929
|
+
if (!response.ok) {
|
|
930
|
+
throw await buildApiErrorFromResponse(response, "Failed to persist account snapshot");
|
|
1137
931
|
}
|
|
1138
|
-
const statusClass = `${Math.floor(response.status / 100)}xx`;
|
|
1139
|
-
this.directTelemetry?.log({
|
|
1140
|
-
name: "session.request.first_byte",
|
|
1141
|
-
stage: "session_first_byte",
|
|
1142
|
-
requestCategory: "session_read",
|
|
1143
|
-
statusClass
|
|
1144
|
-
});
|
|
1145
932
|
this.directTelemetry?.performance({
|
|
1146
|
-
stage: "
|
|
1147
|
-
durationMs:
|
|
933
|
+
stage: "processing",
|
|
934
|
+
durationMs: Math.max(0, this.telemetryTimestamp() - startedAt),
|
|
1148
935
|
durationMode: "machine",
|
|
1149
|
-
requestCategory: "
|
|
1150
|
-
statusClass
|
|
936
|
+
requestCategory: "account_snapshot",
|
|
937
|
+
statusClass: "2xx"
|
|
1151
938
|
});
|
|
939
|
+
} catch (error) {
|
|
940
|
+
this.reportDirectFailure(
|
|
941
|
+
error,
|
|
942
|
+
"NETWORK_REQUEST_FAILED",
|
|
943
|
+
"processing",
|
|
944
|
+
"account_snapshot"
|
|
945
|
+
);
|
|
946
|
+
throw error;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
/** Create a wallet/APM/PayPal intent through the session-scoped contract. */
|
|
950
|
+
async createSessionIntent(sessionId, nonce, request, options) {
|
|
951
|
+
if (!nonce) {
|
|
952
|
+
throw new FloPayError(
|
|
953
|
+
"createSessionIntent requires the checkout session nonce.",
|
|
954
|
+
"validation_error",
|
|
955
|
+
{ code: "MissingCheckoutSessionToken", param: "nonce" }
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
const rawRequest = request;
|
|
959
|
+
const paymentMethodType = rawRequest["paymentMethodType"];
|
|
960
|
+
const directCardType = typeof paymentMethodType === "string" && paymentMethodType.trim().toLowerCase() === "card";
|
|
961
|
+
const commonRequestFieldsValid = typeof paymentMethodType === "string" && paymentMethodType.length > 0 && !directCardType && (typeof rawRequest["paymentMethodId"] === "string" || rawRequest["paymentMethodId"] === null);
|
|
962
|
+
const stripeRequestValid = rawRequest["provider"] === "stripe" && (rawRequest["paymentMethodCategory"] === "wallet" || rawRequest["paymentMethodCategory"] === "apm") && (rawRequest["intentKind"] === "payment" || rawRequest["intentKind"] === "setup");
|
|
963
|
+
const paypalRequestValid = rawRequest["provider"] === "paypal" && rawRequest["paymentMethodCategory"] === "wallet" && rawRequest["paymentMethodType"] === "paypal" && rawRequest["paymentMethodId"] === null && (rawRequest["intentKind"] === "order" || rawRequest["intentKind"] === "subscription");
|
|
964
|
+
if (!commonRequestFieldsValid || !stripeRequestValid && !paypalRequestValid) {
|
|
965
|
+
throw new FloPayError(
|
|
966
|
+
"Only wallet, APM, and PayPal session intents are supported.",
|
|
967
|
+
"validation_error",
|
|
968
|
+
{ code: "InvalidSessionIntentRequest" }
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
const suppliedAttemptId = rawRequest["authorizationAttemptId"];
|
|
972
|
+
if (suppliedAttemptId !== void 0 && !isUuidV4(suppliedAttemptId)) {
|
|
973
|
+
throw new FloPayError(
|
|
974
|
+
"authorizationAttemptId must be a v4 UUID identifying one buyer authorization attempt.",
|
|
975
|
+
"validation_error",
|
|
976
|
+
{ code: "InvalidAuthorizationAttemptId", param: "authorizationAttemptId" }
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
const authorizationAttemptId = isUuidV4(suppliedAttemptId) ? suppliedAttemptId : randomUuidV4();
|
|
980
|
+
this.beginDirectTelemetryCheckout(sessionId);
|
|
981
|
+
const startedAt = this.telemetryTimestamp();
|
|
982
|
+
this.directTelemetry?.log({
|
|
983
|
+
name: "payment.intent.started",
|
|
984
|
+
stage: "processing",
|
|
985
|
+
requestCategory: "intent_create"
|
|
986
|
+
});
|
|
987
|
+
const headers = {
|
|
988
|
+
"Content-Type": "application/json",
|
|
989
|
+
"x-checkout-session-token": nonce,
|
|
990
|
+
[IDEMPOTENCY_KEY_HEADER]: options?.idempotencyKey || authorizationAttemptId
|
|
991
|
+
};
|
|
992
|
+
let failureReported = false;
|
|
993
|
+
try {
|
|
994
|
+
const response = await fetch(
|
|
995
|
+
`${this.baseUrl}/v1/checkouts/sessions/${encodeURIComponent(sessionId)}/intents`,
|
|
996
|
+
{
|
|
997
|
+
method: "POST",
|
|
998
|
+
headers,
|
|
999
|
+
body: JSON.stringify({ ...request, authorizationAttemptId }),
|
|
1000
|
+
signal: options?.signal
|
|
1001
|
+
}
|
|
1002
|
+
);
|
|
1152
1003
|
if (!response.ok) {
|
|
1153
|
-
|
|
1004
|
+
failureReported = true;
|
|
1005
|
+
this.directTelemetry?.error({
|
|
1006
|
+
errorCode: "PAYMENT_PROCESSING_FAILED",
|
|
1007
|
+
stage: "processing",
|
|
1008
|
+
requestCategory: "intent_create",
|
|
1009
|
+
statusClass: telemetryStatusClass(response.status)
|
|
1010
|
+
});
|
|
1011
|
+
throw await buildApiErrorFromResponse(response, "Failed to create checkout intent");
|
|
1154
1012
|
}
|
|
1155
1013
|
const body = await response.json();
|
|
1014
|
+
const data = body.data;
|
|
1015
|
+
if (!data || typeof data !== "object") {
|
|
1016
|
+
throw new FloPayError("Invalid checkout intent response.", "api_error", {
|
|
1017
|
+
code: "InvalidSessionIntentResponse"
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
const intent = data;
|
|
1021
|
+
const responsePaymentMethodType = intent["paymentMethodType"];
|
|
1022
|
+
const commonFieldsValid = (intent["paymentMethodCategory"] === "wallet" || intent["paymentMethodCategory"] === "apm") && typeof responsePaymentMethodType === "string" && responsePaymentMethodType.trim().toLowerCase() !== "card" && (typeof intent["paymentMethodId"] === "string" || intent["paymentMethodId"] === null) && typeof intent["providerObjectId"] === "string";
|
|
1023
|
+
const stripeValid = intent["provider"] === "stripe" && (intent["intentKind"] === "payment" || intent["intentKind"] === "setup") && typeof intent["clientSecret"] === "string";
|
|
1024
|
+
const paypalValid = intent["provider"] === "paypal" && intent["paymentMethodCategory"] === "wallet" && intent["paymentMethodType"] === "paypal" && intent["paymentMethodId"] === null && (intent["intentKind"] === "order" || intent["intentKind"] === "subscription") && intent["clientSecret"] === null;
|
|
1025
|
+
const discriminantsMatchRequest = intent["provider"] === request.provider && intent["paymentMethodCategory"] === request.paymentMethodCategory && intent["paymentMethodType"] === request.paymentMethodType && intent["paymentMethodId"] === request.paymentMethodId && intent["intentKind"] === request.intentKind;
|
|
1026
|
+
if (!commonFieldsValid || !stripeValid && !paypalValid || !discriminantsMatchRequest) {
|
|
1027
|
+
throw new FloPayError("Invalid checkout intent response.", "api_error", {
|
|
1028
|
+
code: "InvalidSessionIntentResponse"
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1156
1031
|
this.directTelemetry?.log({
|
|
1157
|
-
name: "
|
|
1158
|
-
stage: "
|
|
1159
|
-
requestCategory: "
|
|
1160
|
-
statusClass
|
|
1032
|
+
name: "payment.intent.completed",
|
|
1033
|
+
stage: "processing",
|
|
1034
|
+
requestCategory: "intent_create",
|
|
1035
|
+
statusClass: telemetryStatusClass(response.status)
|
|
1161
1036
|
});
|
|
1162
1037
|
this.directTelemetry?.performance({
|
|
1163
|
-
stage: "
|
|
1164
|
-
durationMs: Math.max(0, this.telemetryTimestamp() -
|
|
1038
|
+
stage: "processing",
|
|
1039
|
+
durationMs: Math.max(0, this.telemetryTimestamp() - startedAt),
|
|
1165
1040
|
durationMode: "machine",
|
|
1166
|
-
requestCategory: "
|
|
1167
|
-
statusClass
|
|
1041
|
+
requestCategory: "intent_create",
|
|
1042
|
+
statusClass: telemetryStatusClass(response.status)
|
|
1168
1043
|
});
|
|
1169
|
-
return
|
|
1044
|
+
return intent;
|
|
1170
1045
|
} catch (error) {
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1046
|
+
if (!failureReported) {
|
|
1047
|
+
this.reportDirectFailure(
|
|
1048
|
+
error,
|
|
1049
|
+
"PAYMENT_PROCESSING_FAILED",
|
|
1050
|
+
"processing",
|
|
1051
|
+
"intent_create"
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1178
1054
|
throw error;
|
|
1179
1055
|
}
|
|
1180
1056
|
}
|
|
1181
|
-
/**
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1057
|
+
/** Record a provider-neutral non-card decline without sensitive identifiers. */
|
|
1058
|
+
async reportSessionIntentDecline(sessionId, nonce, request, options) {
|
|
1059
|
+
if (!nonce) {
|
|
1060
|
+
throw new FloPayError(
|
|
1061
|
+
"reportSessionIntentDecline requires the checkout session nonce.",
|
|
1062
|
+
"validation_error",
|
|
1063
|
+
{ code: "MissingCheckoutSessionToken", param: "nonce" }
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
const rawRequest = request;
|
|
1067
|
+
const reason = rawRequest["providerDeclineReason"];
|
|
1068
|
+
const paymentMethodType = rawRequest["paymentMethodType"];
|
|
1069
|
+
const safeReason = typeof reason === "string" && /^[a-z0-9][a-z0-9_.:-]{0,63}$/i.test(reason) && !/^(?:pm|pi|seti|tok|src|cus|sess|sk|pk)_/i.test(reason);
|
|
1070
|
+
const commonFieldsValid = typeof paymentMethodType === "string" && paymentMethodType.length > 0 && paymentMethodType.trim().toLowerCase() !== "card" && safeReason;
|
|
1071
|
+
const stripeFieldsValid = rawRequest["provider"] === "stripe" && (rawRequest["paymentMethodCategory"] === "wallet" || rawRequest["paymentMethodCategory"] === "apm");
|
|
1072
|
+
const paypalFieldsValid = rawRequest["provider"] === "paypal" && rawRequest["paymentMethodCategory"] === "wallet" && rawRequest["paymentMethodType"] === "paypal";
|
|
1073
|
+
if (!commonFieldsValid || !stripeFieldsValid && !paypalFieldsValid) {
|
|
1074
|
+
throw new FloPayError(
|
|
1075
|
+
"Invalid non-card decline classification.",
|
|
1076
|
+
"validation_error",
|
|
1077
|
+
{ code: "InvalidSessionIntentDeclineRequest" }
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
const safeRequest = paypalFieldsValid ? {
|
|
1081
|
+
provider: "paypal",
|
|
1082
|
+
paymentMethodCategory: "wallet",
|
|
1083
|
+
paymentMethodType: "paypal",
|
|
1084
|
+
providerDeclineReason: reason
|
|
1085
|
+
} : {
|
|
1086
|
+
provider: "stripe",
|
|
1087
|
+
paymentMethodCategory: rawRequest["paymentMethodCategory"],
|
|
1088
|
+
paymentMethodType: rawRequest["paymentMethodType"],
|
|
1089
|
+
providerDeclineReason: reason
|
|
1090
|
+
};
|
|
1091
|
+
const response = await fetch(
|
|
1092
|
+
`${this.baseUrl}/v1/checkouts/sessions/${encodeURIComponent(sessionId)}/intents/decline`,
|
|
1093
|
+
{
|
|
1094
|
+
method: "POST",
|
|
1095
|
+
headers: {
|
|
1096
|
+
"Content-Type": "application/json",
|
|
1097
|
+
"x-checkout-session-token": nonce
|
|
1098
|
+
},
|
|
1099
|
+
body: JSON.stringify(safeRequest),
|
|
1100
|
+
signal: options?.signal
|
|
1101
|
+
}
|
|
1102
|
+
);
|
|
1103
|
+
if (!response.ok) {
|
|
1104
|
+
throw await buildApiErrorFromResponse(response, "Failed to report checkout decline");
|
|
1105
|
+
}
|
|
1209
1106
|
}
|
|
1210
1107
|
/**
|
|
1211
|
-
* Fetch
|
|
1212
|
-
*
|
|
1213
|
-
*
|
|
1214
|
-
* `POST /v1/checkouts/sessions/{id}/vault/capture` returns the SDK-ready
|
|
1215
|
-
* {@link VaultCaptureBlock} (`html` + `url`, plus `messageToken` /
|
|
1216
|
-
* `expectedOrigin` once the backend mints them). The SDK injects `html` as
|
|
1217
|
-
* the card-capture widget. This is the fallback path for sessions that did
|
|
1218
|
-
* not receive the embedded `vault` block on create (e.g. a session loaded by
|
|
1219
|
-
* id via `GET`, or a pre-1.3.0 create); the endpoint is idempotent and reuses
|
|
1220
|
-
* session-cached creds when available.
|
|
1221
|
-
*
|
|
1222
|
-
* Because the endpoint is idempotent, the request is wrapped in
|
|
1223
|
-
* `fetchWithNetworkRetry`: a transient network blip (dropped connection, DNS
|
|
1224
|
-
* hiccup, failed CORS preflight) would otherwise leave the secure card form
|
|
1225
|
-
* unable to load and hard-block checkout.
|
|
1226
|
-
*
|
|
1227
|
-
* The PCIVault submit *secret* the backend may include in the response is
|
|
1228
|
-
* intentionally **not** read or surfaced — it is server-only and never enters
|
|
1229
|
-
* the SDK runtime.
|
|
1230
|
-
*
|
|
1231
|
-
* `nonce` is forwarded as `x-checkout-session-token` (required by post-#640
|
|
1232
|
-
* backends, matched against the session's stored nonce).
|
|
1108
|
+
* Fetch user's prior payments by email.
|
|
1109
|
+
* Used to determine if saved card UX should be shown.
|
|
1233
1110
|
*/
|
|
1234
|
-
async
|
|
1235
|
-
this.
|
|
1111
|
+
async getPaymentsByEmail(email, options) {
|
|
1112
|
+
this.beginDirectTelemetryOperation();
|
|
1236
1113
|
const startedAt = this.telemetryTimestamp();
|
|
1237
1114
|
this.directTelemetry?.log({
|
|
1238
|
-
name: "
|
|
1239
|
-
stage: "
|
|
1240
|
-
requestCategory: "
|
|
1241
|
-
paymentMethodCategory: "
|
|
1115
|
+
name: "operation.recovery.started",
|
|
1116
|
+
stage: "recovery",
|
|
1117
|
+
requestCategory: "other",
|
|
1118
|
+
paymentMethodCategory: "saved"
|
|
1119
|
+
});
|
|
1120
|
+
const page = options?.page ?? 1;
|
|
1121
|
+
const limit = options?.limit ?? 1;
|
|
1122
|
+
const params = new URLSearchParams({
|
|
1123
|
+
email,
|
|
1124
|
+
page: String(page),
|
|
1125
|
+
limit: String(limit),
|
|
1126
|
+
sortField: "createdAt",
|
|
1127
|
+
sortDirection: "DESC"
|
|
1242
1128
|
});
|
|
1243
|
-
const headers = { "Content-Type": "application/json" };
|
|
1244
|
-
if (nonce) headers["x-checkout-session-token"] = nonce;
|
|
1245
1129
|
try {
|
|
1246
|
-
const response = await
|
|
1247
|
-
`${this.baseUrl}/v1/
|
|
1248
|
-
{
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
this.directTelemetry?.log({
|
|
1253
|
-
name: "operation.retry",
|
|
1254
|
-
stage: "vault_request",
|
|
1255
|
-
requestCategory: "vault_capture",
|
|
1256
|
-
paymentMethodCategory: "card",
|
|
1257
|
-
attempt
|
|
1258
|
-
});
|
|
1130
|
+
const response = await fetch(
|
|
1131
|
+
`${this.baseUrl}/v1/payments?${params.toString()}`,
|
|
1132
|
+
{
|
|
1133
|
+
method: "GET",
|
|
1134
|
+
signal: options?.signal,
|
|
1135
|
+
keepalive: true
|
|
1259
1136
|
}
|
|
1260
1137
|
);
|
|
1261
1138
|
if (!response.ok) {
|
|
1262
|
-
throw
|
|
1139
|
+
throw new FloPayError(
|
|
1140
|
+
"Failed to fetch payments",
|
|
1141
|
+
"api_error",
|
|
1142
|
+
{ statusCode: response.status }
|
|
1143
|
+
);
|
|
1263
1144
|
}
|
|
1264
|
-
const
|
|
1145
|
+
const result = await response.json();
|
|
1146
|
+
this.directTelemetry?.log({
|
|
1147
|
+
name: "operation.recovery.completed",
|
|
1148
|
+
stage: "recovery",
|
|
1149
|
+
requestCategory: "other",
|
|
1150
|
+
paymentMethodCategory: "saved",
|
|
1151
|
+
statusClass: "2xx"
|
|
1152
|
+
});
|
|
1265
1153
|
this.directTelemetry?.performance({
|
|
1266
|
-
stage: "
|
|
1154
|
+
stage: "recovery",
|
|
1267
1155
|
durationMs: Math.max(0, this.telemetryTimestamp() - startedAt),
|
|
1268
1156
|
durationMode: "machine",
|
|
1269
|
-
requestCategory: "
|
|
1270
|
-
paymentMethodCategory: "
|
|
1157
|
+
requestCategory: "other",
|
|
1158
|
+
paymentMethodCategory: "saved",
|
|
1271
1159
|
statusClass: "2xx"
|
|
1272
1160
|
});
|
|
1273
|
-
return
|
|
1161
|
+
return result;
|
|
1274
1162
|
} catch (error) {
|
|
1275
|
-
this.reportDirectFailure(
|
|
1276
|
-
error,
|
|
1277
|
-
"VAULT_LOAD_FAILED",
|
|
1278
|
-
"vault_request",
|
|
1279
|
-
"vault_capture",
|
|
1280
|
-
"card"
|
|
1281
|
-
);
|
|
1163
|
+
this.reportDirectFailure(error, "RECOVERY_FAILED", "recovery", "other", "saved");
|
|
1282
1164
|
throw error;
|
|
1283
1165
|
}
|
|
1284
1166
|
}
|
|
1285
1167
|
/**
|
|
1286
|
-
*
|
|
1168
|
+
* Create a checkout session AND return the full session data in one call.
|
|
1169
|
+
* Uses `?expand=true` so the backend returns the complete session
|
|
1170
|
+
* instead of just a UUID — eliminating the need for a second GET.
|
|
1287
1171
|
*
|
|
1288
|
-
*
|
|
1289
|
-
* then wraps the session in a `NormalizedCheckoutSession` for provider-
|
|
1290
|
-
* agnostic consumption.
|
|
1172
|
+
* Falls back to create + GET if the backend doesn't support `expand`.
|
|
1291
1173
|
*/
|
|
1292
|
-
async
|
|
1293
|
-
|
|
1294
|
-
const
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1174
|
+
async createAndFetchSession(params) {
|
|
1175
|
+
this.beginDirectTelemetryOperation();
|
|
1176
|
+
const startedAt = this.telemetryTimestamp();
|
|
1177
|
+
this.directTelemetry?.log({
|
|
1178
|
+
name: "session.create.started",
|
|
1179
|
+
stage: "session_create",
|
|
1180
|
+
requestCategory: "session_create"
|
|
1181
|
+
});
|
|
1182
|
+
try {
|
|
1183
|
+
const result = await this.createAndFetchSessionRequest(params, startedAt);
|
|
1184
|
+
this.adoptDirectTelemetryCheckout(result.data.session?.id);
|
|
1185
|
+
this.directTelemetry?.log({
|
|
1186
|
+
name: "session.request.completed",
|
|
1187
|
+
stage: "session_complete",
|
|
1188
|
+
requestCategory: "session_create",
|
|
1189
|
+
statusClass: "2xx"
|
|
1190
|
+
});
|
|
1191
|
+
this.directTelemetry?.performance({
|
|
1192
|
+
stage: "session_create",
|
|
1193
|
+
durationMs: this.telemetryTimestamp() - startedAt,
|
|
1194
|
+
durationMode: "machine",
|
|
1195
|
+
requestCategory: "session_create",
|
|
1196
|
+
statusClass: "2xx"
|
|
1197
|
+
});
|
|
1198
|
+
return result;
|
|
1199
|
+
} catch (error) {
|
|
1200
|
+
if (!(error instanceof FloPayError && error.code === "session_auto_completed")) {
|
|
1201
|
+
try {
|
|
1202
|
+
this.telemetryHooks?.onSessionCreateFailure?.(error);
|
|
1203
|
+
} catch {
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
if (error instanceof FloPayError && error.type === "validation_error") {
|
|
1207
|
+
this.directTelemetry?.terminal({
|
|
1208
|
+
outcome: "validation_rejected",
|
|
1209
|
+
stage: "session_create",
|
|
1210
|
+
requestCategory: "session_create"
|
|
1211
|
+
});
|
|
1212
|
+
} else if (!(error instanceof FloPayError && error.code === "session_auto_completed")) {
|
|
1213
|
+
const statusCode = error instanceof FloPayError ? error.statusCode : void 0;
|
|
1214
|
+
this.directTelemetry?.error({
|
|
1215
|
+
errorCode: error instanceof Error && error.name === "AbortError" ? "REQUEST_TIMEOUT" : error instanceof TypeError ? "NETWORK_REQUEST_FAILED" : "CHECKOUT_SESSION_CREATE_FAILED",
|
|
1216
|
+
stage: "session_create",
|
|
1217
|
+
requestCategory: "session_create",
|
|
1218
|
+
statusClass: error instanceof Error && error.name === "AbortError" ? "timeout" : statusCode ? `${Math.floor(statusCode / 100)}xx` : "network_error"
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
throw error;
|
|
1298
1222
|
}
|
|
1299
|
-
return normalized;
|
|
1300
1223
|
}
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
*
|
|
1313
|
-
* @param userId Vestigial — backend's GatewayInterceptor routes via session,
|
|
1314
|
-
* not headers, so this value is no longer sent on the wire. Kept in the
|
|
1315
|
-
* signature for back-compat with existing callers; will be removed in a
|
|
1316
|
-
* future major version.
|
|
1317
|
-
*/
|
|
1318
|
-
async processPayment(_userId, data, options) {
|
|
1319
|
-
this.beginDirectTelemetryCheckout(data.sessionId);
|
|
1320
|
-
if (!data.nonce) {
|
|
1321
|
-
this.directTelemetry?.terminal({
|
|
1322
|
-
outcome: "validation_rejected",
|
|
1323
|
-
stage: "processing",
|
|
1324
|
-
requestCategory: "process_payment"
|
|
1325
|
-
});
|
|
1326
|
-
throw new FloPayError3(
|
|
1327
|
-
"processPayment requires `nonce` \u2014 pass the value returned from session creation.",
|
|
1224
|
+
async createAndFetchSessionRequest(params, telemetryStartedAt) {
|
|
1225
|
+
const wireProducts = params.products ?? foldIntoProducts(params.items, params.subscriptions);
|
|
1226
|
+
const sessionCurrency = resolveSessionCurrency(
|
|
1227
|
+
params.currency,
|
|
1228
|
+
params.items,
|
|
1229
|
+
params.subscriptions,
|
|
1230
|
+
wireProducts
|
|
1231
|
+
);
|
|
1232
|
+
if (!sessionCurrency) {
|
|
1233
|
+
throw new FloPayError(
|
|
1234
|
+
"currency is required: pass `currency` on the session, or include a `currency` on the first item/subscription/product.",
|
|
1328
1235
|
"validation_error",
|
|
1329
|
-
{ code: "
|
|
1236
|
+
{ code: "CurrencyRequired", param: "currency" }
|
|
1330
1237
|
);
|
|
1331
1238
|
}
|
|
1332
|
-
const
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1239
|
+
const payload = {
|
|
1240
|
+
clientId: params.clientId,
|
|
1241
|
+
checkoutVersion: SDK_VERSION,
|
|
1242
|
+
successUrl: params.successUrl,
|
|
1243
|
+
cancelUrl: params.cancelUrl,
|
|
1244
|
+
currency: sessionCurrency,
|
|
1245
|
+
checkoutMode: params.checkoutMode ?? "full",
|
|
1246
|
+
products: wireProducts.map((product) => buildProductPayload(product, sessionCurrency)),
|
|
1247
|
+
accountData: {
|
|
1248
|
+
userId: params.account.userId,
|
|
1249
|
+
firstName: params.account.firstName ?? null,
|
|
1250
|
+
lastName: params.account.lastName ?? null,
|
|
1251
|
+
email: params.account.email,
|
|
1252
|
+
country: params.account.country ?? null,
|
|
1253
|
+
gender: params.account.gender ?? null,
|
|
1254
|
+
city: params.account.city ?? null,
|
|
1255
|
+
state: params.account.state ?? null,
|
|
1256
|
+
zip: params.account.zip ?? null,
|
|
1257
|
+
addressLine1: params.account.addressLine1 ?? null,
|
|
1258
|
+
addressLine2: params.account.addressLine2 ?? null
|
|
1259
|
+
},
|
|
1260
|
+
couponCodes: params.couponCodes ?? []
|
|
1261
|
+
};
|
|
1262
|
+
if (params.tokenizedData) payload["tokenizedData"] = params.tokenizedData;
|
|
1263
|
+
if (params.tagsData) payload["tagsData"] = params.tagsData;
|
|
1264
|
+
if (params.utmMetadata?.length) payload["utmMetadata"] = params.utmMetadata;
|
|
1265
|
+
if (params.avsCheck !== void 0) payload["avsCheck"] = params.avsCheck;
|
|
1266
|
+
if (params.checkoutType) payload["checkoutType"] = params.checkoutType;
|
|
1267
|
+
if (params.checkoutLayout) payload["checkoutLayout"] = params.checkoutLayout;
|
|
1268
|
+
if (params.avsConfig) payload["avsConfig"] = params.avsConfig;
|
|
1269
|
+
const headers = {
|
|
1270
|
+
"Content-Type": "application/json",
|
|
1271
|
+
// Retain the SDK version header for compatibility visibility. Backends
|
|
1272
|
+
// must not gate the hosted vault block on this value.
|
|
1273
|
+
[FLO_SDK_VERSION_HEADER]: SDK_VERSION
|
|
1274
|
+
};
|
|
1275
|
+
const idempotencyKey = resolveIdempotencyKey(params.idempotencyKey);
|
|
1276
|
+
if (idempotencyKey) {
|
|
1277
|
+
headers[IDEMPOTENCY_KEY_HEADER] = idempotencyKey;
|
|
1278
|
+
}
|
|
1339
1279
|
let response;
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1280
|
+
let firstByteReported = false;
|
|
1281
|
+
for (let attempt = 0; ; attempt++) {
|
|
1282
|
+
response = await fetchWithNetworkRetry(
|
|
1283
|
+
`${this.baseUrl}/v1/checkouts/sessions?expand=true`,
|
|
1343
1284
|
{
|
|
1344
1285
|
method: "POST",
|
|
1345
|
-
headers
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1286
|
+
headers,
|
|
1287
|
+
body: JSON.stringify(payload)
|
|
1288
|
+
},
|
|
1289
|
+
NETWORK_RETRY_ATTEMPTS,
|
|
1290
|
+
(networkAttempt) => {
|
|
1291
|
+
this.telemetryHooks?.onRetry?.("session_create", networkAttempt);
|
|
1292
|
+
this.directTelemetry?.log({
|
|
1293
|
+
name: "operation.retry",
|
|
1294
|
+
stage: "session_create",
|
|
1295
|
+
requestCategory: "session_create",
|
|
1296
|
+
attempt: networkAttempt
|
|
1297
|
+
});
|
|
1350
1298
|
}
|
|
1351
1299
|
);
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
{ ...options, nonce }
|
|
1375
|
-
);
|
|
1376
|
-
this.directTelemetry?.log({
|
|
1377
|
-
name: "payment.processing.completed",
|
|
1378
|
-
stage: "processing",
|
|
1379
|
-
requestCategory: "process_payment",
|
|
1380
|
-
statusClass: telemetryStatusClass(result.status)
|
|
1381
|
-
});
|
|
1382
|
-
this.directTelemetry?.performance({
|
|
1383
|
-
stage: "processing",
|
|
1384
|
-
durationMs: Math.max(0, this.telemetryTimestamp() - startedAt),
|
|
1385
|
-
durationMode: "machine",
|
|
1386
|
-
requestCategory: "process_payment",
|
|
1387
|
-
statusClass: telemetryStatusClass(result.status)
|
|
1388
|
-
});
|
|
1389
|
-
return result;
|
|
1390
|
-
} catch (error) {
|
|
1391
|
-
if (!(error instanceof FloPayError3 && error.code === "checkout_processing_timeout")) {
|
|
1392
|
-
this.reportDirectFailure(
|
|
1393
|
-
error,
|
|
1394
|
-
"PAYMENT_PROCESSING_FAILED",
|
|
1395
|
-
"processing",
|
|
1396
|
-
"process_payment"
|
|
1300
|
+
if (!firstByteReported) {
|
|
1301
|
+
firstByteReported = true;
|
|
1302
|
+
const statusClass = `${Math.floor(response.status / 100)}xx`;
|
|
1303
|
+
this.directTelemetry?.log({
|
|
1304
|
+
name: "session.request.first_byte",
|
|
1305
|
+
stage: "session_first_byte",
|
|
1306
|
+
requestCategory: "session_create",
|
|
1307
|
+
statusClass
|
|
1308
|
+
});
|
|
1309
|
+
this.directTelemetry?.performance({
|
|
1310
|
+
stage: "session_first_byte",
|
|
1311
|
+
durationMs: this.telemetryTimestamp() - telemetryStartedAt,
|
|
1312
|
+
durationMode: "machine",
|
|
1313
|
+
requestCategory: "session_create",
|
|
1314
|
+
statusClass
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
if (response.status === 204) {
|
|
1318
|
+
throw new FloPayError(
|
|
1319
|
+
"Session auto-completed \u2014 payment method already on file",
|
|
1320
|
+
"api_error",
|
|
1321
|
+
{ code: "session_auto_completed" }
|
|
1397
1322
|
);
|
|
1398
1323
|
}
|
|
1324
|
+
if (response.ok) break;
|
|
1325
|
+
const error = await buildApiErrorFromResponse(response, "Failed to create checkout session");
|
|
1326
|
+
if (error.code === IDEMPOTENCY_IN_PROGRESS_CODE && attempt < IDEMPOTENCY_IN_PROGRESS_RETRY_ATTEMPTS) {
|
|
1327
|
+
try {
|
|
1328
|
+
this.telemetryHooks?.onRetry?.("session_create", attempt + 1);
|
|
1329
|
+
this.directTelemetry?.log({
|
|
1330
|
+
name: "operation.retry",
|
|
1331
|
+
stage: "session_create",
|
|
1332
|
+
requestCategory: "session_create",
|
|
1333
|
+
attempt: attempt + 1
|
|
1334
|
+
});
|
|
1335
|
+
} catch {
|
|
1336
|
+
}
|
|
1337
|
+
await delay(150 * 2 ** attempt);
|
|
1338
|
+
continue;
|
|
1339
|
+
}
|
|
1399
1340
|
throw error;
|
|
1400
1341
|
}
|
|
1342
|
+
const body = await response.json();
|
|
1343
|
+
if (body.data && "gateways" in body.data) {
|
|
1344
|
+
this.autoCacheDisplayData(body.data.uuid, params);
|
|
1345
|
+
const merged = this.mergeCachedDisplayData(body.data);
|
|
1346
|
+
const normalized = this.normalizeRawSession(merged);
|
|
1347
|
+
if (body.vault && normalized.data.session) {
|
|
1348
|
+
normalized.data.session.vault = this.toVaultBlock(body.vault);
|
|
1349
|
+
}
|
|
1350
|
+
return {
|
|
1351
|
+
...normalized,
|
|
1352
|
+
autoProcessingError: body.autoProcessingError,
|
|
1353
|
+
autoProcessingAttempted: body.autoProcessingAttempted,
|
|
1354
|
+
autoProcessingPending: body.autoProcessingPending
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
const uuid = body.data?.uuid;
|
|
1358
|
+
if (!uuid) {
|
|
1359
|
+
throw new FloPayError("No session ID returned", "api_error");
|
|
1360
|
+
}
|
|
1361
|
+
this.autoCacheDisplayData(uuid, params);
|
|
1362
|
+
this.adoptDirectTelemetryCheckout(uuid);
|
|
1363
|
+
const unifiedSession = await this.getUnifiedCheckoutSession(uuid);
|
|
1364
|
+
return {
|
|
1365
|
+
...unifiedSession,
|
|
1366
|
+
autoProcessingError: body.autoProcessingError,
|
|
1367
|
+
autoProcessingAttempted: body.autoProcessingAttempted,
|
|
1368
|
+
autoProcessingPending: body.autoProcessingPending
|
|
1369
|
+
};
|
|
1401
1370
|
}
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
* intent) onto a checkout session via
|
|
1405
|
-
* `PATCH /v1/checkouts/sessions/{id}/account` (TeamFloPay/backend#823).
|
|
1406
|
-
*
|
|
1407
|
-
* The vault path's hosted form owns the charge end-to-end so the SDK
|
|
1408
|
-
* never calls `/process` on this path; the buyer-typed AVS / billing
|
|
1409
|
-
* address would otherwise be lost. The SDK calls this just before
|
|
1410
|
-
* submitting the vault widget so the downstream listener mints the
|
|
1411
|
-
* Stripe PaymentMethod with the right `billing_details.address` and the
|
|
1412
|
-
* per-attempt + per-PM address snapshots are populated.
|
|
1413
|
-
*
|
|
1414
|
-
* Body shape mirrors the relevant subset of `/process`'s
|
|
1415
|
-
* `ProcessCheckoutBodyDto` — same keys, same validators. The endpoint
|
|
1416
|
-
* is idempotent: empty/undefined fields are not written, addresses are
|
|
1417
|
-
* last-writer-wins, AVS analytics are first-writer-wins.
|
|
1418
|
-
*
|
|
1419
|
-
* Wrapped in `fetchWithNetworkRetry` because a transient blip on this
|
|
1420
|
-
* pre-pay PATCH would silently leave AVS unsent and cause an
|
|
1421
|
-
* AVS-protected charge to decline downstream.
|
|
1422
|
-
*/
|
|
1423
|
-
async patchAccountSnapshot(sessionId, nonce, body, options) {
|
|
1424
|
-
this.beginDirectTelemetryCheckout(sessionId);
|
|
1371
|
+
async waitForCheckoutSessionCompletion(checkoutSessionId, options) {
|
|
1372
|
+
this.beginDirectTelemetryCheckout(checkoutSessionId);
|
|
1425
1373
|
const startedAt = this.telemetryTimestamp();
|
|
1426
1374
|
this.directTelemetry?.log({
|
|
1427
|
-
name: "operation.
|
|
1428
|
-
stage: "
|
|
1429
|
-
requestCategory: "
|
|
1375
|
+
name: "operation.recovery.started",
|
|
1376
|
+
stage: "recovery",
|
|
1377
|
+
requestCategory: "session_read",
|
|
1378
|
+
paymentMethodCategory: "saved"
|
|
1430
1379
|
});
|
|
1431
|
-
const timeoutMs = options?.timeoutMs ??
|
|
1432
|
-
const
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
if (options.signal.aborted) controller.abort();
|
|
1436
|
-
else options.signal.addEventListener("abort", onCallerAbort, { once: true });
|
|
1437
|
-
}
|
|
1438
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1380
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_PROCESSING_TIMEOUT_MS;
|
|
1381
|
+
const deadline = Date.now() + timeoutMs;
|
|
1382
|
+
let nextDelayMs = this.clampRetryAfterMs(options?.initialDelayMs ?? DEFAULT_PROCESSING_RETRY_AFTER_MS);
|
|
1383
|
+
let pollAttempt = 0;
|
|
1439
1384
|
try {
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
},
|
|
1450
|
-
body: JSON.stringify(body),
|
|
1451
|
-
signal: controller.signal
|
|
1452
|
-
},
|
|
1453
|
-
NETWORK_RETRY_ATTEMPTS,
|
|
1454
|
-
(attempt) => {
|
|
1455
|
-
this.telemetryHooks?.onRetry?.("account_snapshot", attempt);
|
|
1385
|
+
while (true) {
|
|
1386
|
+
const remainingMs = deadline - Date.now();
|
|
1387
|
+
if (remainingMs <= 0) {
|
|
1388
|
+
throw createCheckoutProcessingTimeoutError();
|
|
1389
|
+
}
|
|
1390
|
+
if (nextDelayMs > 0) {
|
|
1391
|
+
try {
|
|
1392
|
+
pollAttempt += 1;
|
|
1393
|
+
this.telemetryHooks?.onRetry?.("session_read", pollAttempt);
|
|
1456
1394
|
this.directTelemetry?.log({
|
|
1457
1395
|
name: "operation.retry",
|
|
1458
|
-
stage: "
|
|
1459
|
-
requestCategory: "
|
|
1460
|
-
|
|
1396
|
+
stage: "recovery",
|
|
1397
|
+
requestCategory: "session_read",
|
|
1398
|
+
paymentMethodCategory: "saved",
|
|
1399
|
+
attempt: pollAttempt
|
|
1461
1400
|
});
|
|
1401
|
+
} catch {
|
|
1402
|
+
}
|
|
1403
|
+
await delay(Math.min(nextDelayMs, remainingMs));
|
|
1404
|
+
if (Date.now() >= deadline) {
|
|
1405
|
+
throw createCheckoutProcessingTimeoutError();
|
|
1462
1406
|
}
|
|
1407
|
+
}
|
|
1408
|
+
const session = await this.getUnifiedCheckoutSession(checkoutSessionId, options?.nonce);
|
|
1409
|
+
const status = session.data.session?.status;
|
|
1410
|
+
if (status === "complete" || status === "expired") {
|
|
1411
|
+
this.directTelemetry?.log({
|
|
1412
|
+
name: "operation.recovery.completed",
|
|
1413
|
+
stage: "recovery",
|
|
1414
|
+
requestCategory: "session_read",
|
|
1415
|
+
paymentMethodCategory: "saved"
|
|
1416
|
+
});
|
|
1417
|
+
this.directTelemetry?.performance({
|
|
1418
|
+
stage: "recovery",
|
|
1419
|
+
durationMs: Math.max(0, this.telemetryTimestamp() - startedAt),
|
|
1420
|
+
durationMode: "machine",
|
|
1421
|
+
requestCategory: "session_read",
|
|
1422
|
+
paymentMethodCategory: "saved"
|
|
1423
|
+
});
|
|
1424
|
+
return session;
|
|
1425
|
+
}
|
|
1426
|
+
if (Date.now() >= deadline) {
|
|
1427
|
+
throw createCheckoutProcessingTimeoutError();
|
|
1428
|
+
}
|
|
1429
|
+
nextDelayMs = this.clampRetryAfterMs(
|
|
1430
|
+
Math.max(nextDelayMs * 2, MIN_PROCESSING_RETRY_AFTER_MS)
|
|
1463
1431
|
);
|
|
1464
|
-
} finally {
|
|
1465
|
-
clearTimeout(timer);
|
|
1466
|
-
options?.signal?.removeEventListener("abort", onCallerAbort);
|
|
1467
|
-
}
|
|
1468
|
-
if (!response.ok) {
|
|
1469
|
-
throw await buildApiErrorFromResponse(response, "Failed to persist account snapshot");
|
|
1470
1432
|
}
|
|
1471
|
-
this.directTelemetry?.performance({
|
|
1472
|
-
stage: "processing",
|
|
1473
|
-
durationMs: Math.max(0, this.telemetryTimestamp() - startedAt),
|
|
1474
|
-
durationMode: "machine",
|
|
1475
|
-
requestCategory: "account_snapshot",
|
|
1476
|
-
statusClass: "2xx"
|
|
1477
|
-
});
|
|
1478
1433
|
} catch (error) {
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1434
|
+
if (error instanceof FloPayError && error.code === "checkout_processing_timeout") {
|
|
1435
|
+
this.reportDirectFailure(
|
|
1436
|
+
error,
|
|
1437
|
+
"RECOVERY_FAILED",
|
|
1438
|
+
"recovery",
|
|
1439
|
+
"session_read",
|
|
1440
|
+
"saved"
|
|
1441
|
+
);
|
|
1442
|
+
}
|
|
1485
1443
|
throw error;
|
|
1486
1444
|
}
|
|
1487
1445
|
}
|
|
1488
|
-
/**
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
`${this.baseUrl}/v1/checkouts/payments/intents`,
|
|
1519
|
-
{
|
|
1520
|
-
method: "POST",
|
|
1521
|
-
headers,
|
|
1522
|
-
body: JSON.stringify({
|
|
1523
|
-
sessionId,
|
|
1524
|
-
email,
|
|
1525
|
-
paymentMethodType: paymentMethodType ?? null,
|
|
1526
|
-
isPaypal: options?.isPaypal ?? false
|
|
1527
|
-
}),
|
|
1528
|
-
signal: options?.signal
|
|
1529
|
-
}
|
|
1530
|
-
);
|
|
1531
|
-
if (!response.ok) {
|
|
1532
|
-
this.directTelemetry?.error({
|
|
1533
|
-
errorCode: "PAYMENT_PROCESSING_FAILED",
|
|
1534
|
-
stage: "processing",
|
|
1535
|
-
requestCategory: "intent_create",
|
|
1536
|
-
statusClass: telemetryStatusClass(response.status)
|
|
1537
|
-
});
|
|
1538
|
-
return response;
|
|
1539
|
-
}
|
|
1540
|
-
this.directTelemetry?.log({
|
|
1541
|
-
name: "payment.intent.completed",
|
|
1542
|
-
stage: "processing",
|
|
1543
|
-
requestCategory: "intent_create",
|
|
1544
|
-
statusClass: telemetryStatusClass(response.status)
|
|
1545
|
-
});
|
|
1546
|
-
this.directTelemetry?.performance({
|
|
1547
|
-
stage: "processing",
|
|
1548
|
-
durationMs: Math.max(0, this.telemetryTimestamp() - startedAt),
|
|
1549
|
-
durationMode: "machine",
|
|
1550
|
-
requestCategory: "intent_create",
|
|
1551
|
-
statusClass: telemetryStatusClass(response.status)
|
|
1552
|
-
});
|
|
1553
|
-
return response;
|
|
1554
|
-
} catch (error) {
|
|
1555
|
-
this.reportDirectFailure(
|
|
1556
|
-
error,
|
|
1557
|
-
"PAYMENT_PROCESSING_FAILED",
|
|
1558
|
-
"processing",
|
|
1559
|
-
"intent_create"
|
|
1560
|
-
);
|
|
1561
|
-
throw error;
|
|
1446
|
+
/** Normalize a raw session into a provider-agnostic shape. */
|
|
1447
|
+
normalizeRawSession(session) {
|
|
1448
|
+
const gateways = session.gateways ?? {};
|
|
1449
|
+
const providers = [];
|
|
1450
|
+
const data = {
|
|
1451
|
+
session: this.toCheckoutSession(session)
|
|
1452
|
+
};
|
|
1453
|
+
const stripeGateway = gateways.stripe;
|
|
1454
|
+
if (stripeGateway?.publishableKey) {
|
|
1455
|
+
providers.push("stripe");
|
|
1456
|
+
const rawSession = session;
|
|
1457
|
+
const stripeClientSecret = [
|
|
1458
|
+
rawSession["stripeClientSecret"],
|
|
1459
|
+
stripeGateway.stripeClientSecret
|
|
1460
|
+
].find((value) => typeof value === "string" && value.length > 0);
|
|
1461
|
+
data.stripe = {
|
|
1462
|
+
clientSecret: stripeClientSecret ?? "",
|
|
1463
|
+
publishableKey: stripeGateway.publishableKey ?? void 0,
|
|
1464
|
+
paypalPublishableKey: stripeGateway.paypalPublishableKey ?? void 0,
|
|
1465
|
+
environment: stripeGateway.environment,
|
|
1466
|
+
enabledPaymentMethods: Array.isArray(stripeGateway.enabledPaymentMethods) ? stripeGateway.enabledPaymentMethods.filter((m) => typeof m === "string") : void 0
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
const paypalGateway = gateways.paypal;
|
|
1470
|
+
if (paypalGateway?.publishableKey) {
|
|
1471
|
+
providers.push("paypal");
|
|
1472
|
+
data.paypal = {
|
|
1473
|
+
publishableKey: paypalGateway.publishableKey,
|
|
1474
|
+
environment: paypalGateway.environment
|
|
1475
|
+
};
|
|
1562
1476
|
}
|
|
1477
|
+
return {
|
|
1478
|
+
providers,
|
|
1479
|
+
mode: "tokenize",
|
|
1480
|
+
data,
|
|
1481
|
+
raw: { data: session }
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
/** Convert raw session to the SDK CheckoutSession shape. */
|
|
1485
|
+
toCheckoutSession(raw) {
|
|
1486
|
+
const rawProducts = raw.products ?? [];
|
|
1487
|
+
const hasBackendTotal = typeof raw.totalAmount === "number" && Number.isFinite(raw.totalAmount);
|
|
1488
|
+
const computedTotal = rawProducts.reduce(
|
|
1489
|
+
(sum, p) => sum + (p.overrideAmount ?? p.totalAmount ?? 0),
|
|
1490
|
+
0
|
|
1491
|
+
);
|
|
1492
|
+
const totalAmount = hasBackendTotal ? raw.totalAmount : computedTotal;
|
|
1493
|
+
const amountInCents = Math.round(totalAmount * 100);
|
|
1494
|
+
const currency = raw.currency ?? rawProducts[0]?.currency ?? "USD";
|
|
1495
|
+
const mode = rawProducts.some((p) => p.type === "subscription") ? "subscription" : "payment";
|
|
1496
|
+
return {
|
|
1497
|
+
id: raw.uuid,
|
|
1498
|
+
clientSecret: raw.nonce,
|
|
1499
|
+
mode,
|
|
1500
|
+
status: this.toCheckoutSessionStatus(raw.status),
|
|
1501
|
+
amount: amountInCents,
|
|
1502
|
+
currency,
|
|
1503
|
+
customer: {
|
|
1504
|
+
id: raw.accountData.userId,
|
|
1505
|
+
email: raw.accountData.email,
|
|
1506
|
+
firstName: raw.accountData.firstName,
|
|
1507
|
+
lastName: raw.accountData.lastName,
|
|
1508
|
+
country: raw.accountData.country ?? void 0,
|
|
1509
|
+
city: raw.accountData.city ?? void 0,
|
|
1510
|
+
state: raw.accountData.state ?? void 0,
|
|
1511
|
+
zip: raw.accountData.zip ?? void 0,
|
|
1512
|
+
gender: raw.accountData.gender ?? void 0,
|
|
1513
|
+
line1: raw.accountData.addressLine1 ?? void 0,
|
|
1514
|
+
line2: raw.accountData.addressLine2 ?? void 0
|
|
1515
|
+
},
|
|
1516
|
+
metadata: {},
|
|
1517
|
+
checkoutMode: raw.checkoutMode,
|
|
1518
|
+
providerPaymentMethodId: typeof raw.providerPaymentMethodId === "string" ? raw.providerPaymentMethodId : null,
|
|
1519
|
+
products: rawProducts.map((p) => ({
|
|
1520
|
+
...p,
|
|
1521
|
+
totalAmount: typeof p.totalAmount === "number" ? p.totalAmount : void 0,
|
|
1522
|
+
overrideAmount: typeof p.overrideAmount === "number" ? p.overrideAmount : null,
|
|
1523
|
+
currency: typeof p.currency === "string" ? p.currency : void 0,
|
|
1524
|
+
metadata: p.metadata ?? null
|
|
1525
|
+
})),
|
|
1526
|
+
successUrl: raw.successUrl,
|
|
1527
|
+
cancelUrl: raw.cancelUrl,
|
|
1528
|
+
coupons: raw.coupons,
|
|
1529
|
+
subtotalAmount: raw.subtotalAmount,
|
|
1530
|
+
discountAmount: raw.discountAmount,
|
|
1531
|
+
totalAmount: raw.totalAmount,
|
|
1532
|
+
createdAt: raw.createdAt,
|
|
1533
|
+
gateways: raw.gateways,
|
|
1534
|
+
accountData: raw.accountData,
|
|
1535
|
+
tagsData: raw.tagsData
|
|
1536
|
+
};
|
|
1563
1537
|
}
|
|
1564
1538
|
/**
|
|
1565
|
-
*
|
|
1566
|
-
*
|
|
1567
|
-
*
|
|
1568
|
-
* required by post-#640 backends, ignored by earlier versions.
|
|
1539
|
+
* Coerce a raw vault block into a typed {@link VaultCaptureBlock}. The
|
|
1540
|
+
* server-only PCIVault submit `secret` is deliberately dropped so it never
|
|
1541
|
+
* lands on the public session surface (logs / telemetry / client inspection).
|
|
1569
1542
|
*/
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
if (
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
);
|
|
1590
|
-
if (!response.ok) {
|
|
1591
|
-
this.directTelemetry?.error({
|
|
1592
|
-
errorCode: "PAYMENT_PROCESSING_FAILED",
|
|
1593
|
-
stage: "processing",
|
|
1594
|
-
requestCategory: "intent_create",
|
|
1595
|
-
statusClass: telemetryStatusClass(response.status)
|
|
1596
|
-
});
|
|
1597
|
-
return response;
|
|
1598
|
-
}
|
|
1599
|
-
this.directTelemetry?.log({
|
|
1600
|
-
name: "payment.intent.completed",
|
|
1601
|
-
stage: "processing",
|
|
1602
|
-
requestCategory: "intent_create",
|
|
1603
|
-
statusClass: telemetryStatusClass(response.status)
|
|
1604
|
-
});
|
|
1605
|
-
this.directTelemetry?.performance({
|
|
1606
|
-
stage: "processing",
|
|
1607
|
-
durationMs: Math.max(0, this.telemetryTimestamp() - startedAt),
|
|
1608
|
-
durationMode: "machine",
|
|
1609
|
-
requestCategory: "intent_create",
|
|
1610
|
-
statusClass: telemetryStatusClass(response.status)
|
|
1611
|
-
});
|
|
1543
|
+
toVaultBlock(raw) {
|
|
1544
|
+
return {
|
|
1545
|
+
html: typeof raw.html === "string" ? raw.html : void 0,
|
|
1546
|
+
url: typeof raw.url === "string" ? raw.url : void 0,
|
|
1547
|
+
messageToken: typeof raw.messageToken === "string" ? raw.messageToken : void 0,
|
|
1548
|
+
expectedOrigin: typeof raw.expectedOrigin === "string" ? raw.expectedOrigin : void 0
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
toCheckoutSessionStatus(status) {
|
|
1552
|
+
if (status === "completed") {
|
|
1553
|
+
return "complete";
|
|
1554
|
+
}
|
|
1555
|
+
if (status === "expired") {
|
|
1556
|
+
return "expired";
|
|
1557
|
+
}
|
|
1558
|
+
return "open";
|
|
1559
|
+
}
|
|
1560
|
+
async resolveProcessResponse(response, checkoutSessionId, options) {
|
|
1561
|
+
if (response.status !== 202) {
|
|
1612
1562
|
return response;
|
|
1613
|
-
}
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1563
|
+
}
|
|
1564
|
+
const payload = await response.json().catch(() => null);
|
|
1565
|
+
const pending = this.toCheckoutProcessingPending(payload, response, checkoutSessionId);
|
|
1566
|
+
const session = await this.waitForCheckoutSessionCompletion(pending.sessionId, {
|
|
1567
|
+
initialDelayMs: pending.retryAfterMs,
|
|
1568
|
+
timeoutMs: options?.pollTimeoutMs,
|
|
1569
|
+
nonce: options?.nonce
|
|
1570
|
+
});
|
|
1571
|
+
if (session.data.session?.status === "complete") {
|
|
1572
|
+
return new Response(null, { status: 204, statusText: "No Content" });
|
|
1573
|
+
}
|
|
1574
|
+
if (session.data.session?.status === "expired") {
|
|
1575
|
+
throw new FloPayError(
|
|
1576
|
+
"Checkout session has expired.",
|
|
1577
|
+
"api_error",
|
|
1578
|
+
{ code: "checkout_session_expired" }
|
|
1619
1579
|
);
|
|
1620
|
-
throw error;
|
|
1621
1580
|
}
|
|
1581
|
+
throw createCheckoutProcessingTimeoutError();
|
|
1582
|
+
}
|
|
1583
|
+
toCheckoutProcessingPending(payload, response, checkoutSessionId) {
|
|
1584
|
+
const retryAfterHeader = response.headers.get("Retry-After");
|
|
1585
|
+
const headerRetryAfterSeconds = retryAfterHeader === null || retryAfterHeader.trim() === "" ? void 0 : Number(retryAfterHeader);
|
|
1586
|
+
const headerRetryAfterMs = headerRetryAfterSeconds !== void 0 && Number.isFinite(headerRetryAfterSeconds) ? headerRetryAfterSeconds * 1e3 : void 0;
|
|
1587
|
+
return {
|
|
1588
|
+
type: "checkout_processing",
|
|
1589
|
+
sessionId: readString(payload, "sessionId") ?? checkoutSessionId,
|
|
1590
|
+
retryAfterMs: this.clampRetryAfterMs(
|
|
1591
|
+
readNumber(payload, "retryAfterMs") ?? headerRetryAfterMs ?? DEFAULT_PROCESSING_RETRY_AFTER_MS
|
|
1592
|
+
),
|
|
1593
|
+
statusUrl: readString(payload, "statusUrl"),
|
|
1594
|
+
sessionUrl: readString(payload, "sessionUrl")
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
clampRetryAfterMs(retryAfterMs) {
|
|
1598
|
+
return Math.max(0, Math.min(retryAfterMs, MAX_PROCESSING_RETRY_AFTER_MS));
|
|
1622
1599
|
}
|
|
1623
1600
|
/**
|
|
1624
|
-
*
|
|
1625
|
-
*
|
|
1601
|
+
* Stash the display-only fields the consumer passed into a create-session
|
|
1602
|
+
* call. Runs after the backend assigns a UUID so a later GET on the same
|
|
1603
|
+
* session (typically after a redirect) can fill in fields the backend no
|
|
1604
|
+
* longer persists — `overrideAmount`, `totalAmount`, `name`, etc.
|
|
1605
|
+
*
|
|
1606
|
+
* No-op when no UUID is available.
|
|
1626
1607
|
*/
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
const
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1608
|
+
autoCacheDisplayData(sessionId, params) {
|
|
1609
|
+
if (!sessionId) return;
|
|
1610
|
+
const products = params.products ?? foldIntoProducts(params.items, params.subscriptions);
|
|
1611
|
+
if (products.length === 0 && !params.currency) {
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
const usingUnifiedProducts = params.products !== void 0;
|
|
1615
|
+
const sessionCurrency = resolveSessionCurrency(
|
|
1616
|
+
params.currency,
|
|
1617
|
+
usingUnifiedProducts ? void 0 : params.items,
|
|
1618
|
+
usingUnifiedProducts ? void 0 : params.subscriptions,
|
|
1619
|
+
products
|
|
1620
|
+
);
|
|
1621
|
+
cacheSessionDisplayData(sessionId, {
|
|
1622
|
+
currency: sessionCurrency ?? void 0,
|
|
1623
|
+
products: products.map((p) => ({
|
|
1624
|
+
code: p.code ?? p.providerItemId ?? p.providerPlanId,
|
|
1625
|
+
type: p.type,
|
|
1626
|
+
name: p.name ?? p.itemName ?? p.providerItemName ?? p.subscriptionName ?? p.providerPlanName ?? null,
|
|
1627
|
+
totalAmount: p.totalAmount,
|
|
1628
|
+
overrideAmount: p.overrideAmount,
|
|
1629
|
+
currency: p.currency ?? sessionCurrency ?? void 0
|
|
1630
|
+
}))
|
|
1635
1631
|
});
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1632
|
+
}
|
|
1633
|
+
/**
|
|
1634
|
+
* Merge cached display-only fields (set by {@link cacheSessionDisplayData})
|
|
1635
|
+
* into a raw session response. Server values always win — cache fills in
|
|
1636
|
+
* only where the server returned `null` / `undefined`.
|
|
1637
|
+
*/
|
|
1638
|
+
mergeCachedDisplayData(raw) {
|
|
1639
|
+
const cached = getSessionDisplayData(raw.uuid);
|
|
1640
|
+
const cachedProducts = /* @__PURE__ */ new Map();
|
|
1641
|
+
const productKey = (code) => code ? `code:${code}` : void 0;
|
|
1642
|
+
for (const p of cached?.products ?? []) {
|
|
1643
|
+
const key = productKey(p.code);
|
|
1644
|
+
if (key) cachedProducts.set(key, p);
|
|
1645
|
+
}
|
|
1646
|
+
const mergedProducts = (raw.products ?? []).map((p) => {
|
|
1647
|
+
const key = productKey(p.code);
|
|
1648
|
+
const fallback = key ? cachedProducts.get(key) : void 0;
|
|
1649
|
+
return {
|
|
1650
|
+
...p,
|
|
1651
|
+
name: p.name ?? fallback?.name ?? null,
|
|
1652
|
+
totalAmount: p.totalAmount ?? fallback?.totalAmount,
|
|
1653
|
+
overrideAmount: p.overrideAmount ?? fallback?.overrideAmount,
|
|
1654
|
+
currency: p.currency ?? fallback?.currency
|
|
1655
|
+
};
|
|
1644
1656
|
});
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1657
|
+
return {
|
|
1658
|
+
...raw,
|
|
1659
|
+
currency: raw.currency ?? cached?.currency,
|
|
1660
|
+
products: mergedProducts
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
};
|
|
1664
|
+
function createInstrumentedPaymentAPI(billingApiUrl, hooks) {
|
|
1665
|
+
const InstrumentedPaymentAPI = PaymentAPI;
|
|
1666
|
+
return new InstrumentedPaymentAPI(billingApiUrl, hooks);
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
// src/stripe-adapter.ts
|
|
1670
|
+
function toStripeElementType(type) {
|
|
1671
|
+
const map = {
|
|
1672
|
+
payment: "payment",
|
|
1673
|
+
address: "address"
|
|
1674
|
+
};
|
|
1675
|
+
return map[type];
|
|
1676
|
+
}
|
|
1677
|
+
function toStripeAppearanceTheme(theme) {
|
|
1678
|
+
switch (theme) {
|
|
1679
|
+
case "night":
|
|
1680
|
+
return "night";
|
|
1681
|
+
case "flat":
|
|
1682
|
+
return "flat";
|
|
1683
|
+
// 'default', 'none', undefined, or any unexpected value → Stripe's baseline.
|
|
1684
|
+
default:
|
|
1685
|
+
return "stripe";
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
function paymentMethodTypesKey(paymentMethodTypes) {
|
|
1689
|
+
return paymentMethodTypes ? JSON.stringify(paymentMethodTypes.map((paymentMethodType) => paymentMethodType.trim().toLowerCase())) : null;
|
|
1690
|
+
}
|
|
1691
|
+
function wrapStripeElement(stripeElement) {
|
|
1692
|
+
const el = stripeElement;
|
|
1693
|
+
return {
|
|
1694
|
+
mount(container) {
|
|
1695
|
+
el.mount(container);
|
|
1696
|
+
},
|
|
1697
|
+
unmount() {
|
|
1698
|
+
el.unmount();
|
|
1699
|
+
},
|
|
1700
|
+
update(options) {
|
|
1701
|
+
el.update(options);
|
|
1702
|
+
},
|
|
1703
|
+
on(event, handler) {
|
|
1704
|
+
el["on"]?.(event, handler);
|
|
1705
|
+
},
|
|
1706
|
+
off(event, handler) {
|
|
1707
|
+
el["off"]?.(event, handler);
|
|
1708
|
+
},
|
|
1709
|
+
destroy() {
|
|
1710
|
+
el.destroy();
|
|
1711
|
+
}
|
|
1712
|
+
};
|
|
1713
|
+
}
|
|
1714
|
+
function toStripeBillingDetails(billing) {
|
|
1715
|
+
return {
|
|
1716
|
+
billing_details: {
|
|
1717
|
+
...billing.email ? { email: billing.email } : {},
|
|
1718
|
+
...billing.name ? { name: billing.name } : {},
|
|
1719
|
+
...billing.address ? {
|
|
1720
|
+
address: {
|
|
1721
|
+
...billing.address.country ? { country: billing.address.country } : {},
|
|
1722
|
+
...billing.address.postal_code ? { postal_code: billing.address.postal_code } : {},
|
|
1723
|
+
...billing.address.city ? { city: billing.address.city } : {},
|
|
1724
|
+
...billing.address.line1 ? { line1: billing.address.line1 } : {},
|
|
1725
|
+
...billing.address.line2 ? { line2: billing.address.line2 } : {},
|
|
1726
|
+
...billing.address.state ? { state: billing.address.state } : {}
|
|
1652
1727
|
}
|
|
1653
|
-
|
|
1654
|
-
if (!response.ok) {
|
|
1655
|
-
throw new FloPayError3(
|
|
1656
|
-
"Failed to fetch payments",
|
|
1657
|
-
"api_error",
|
|
1658
|
-
{ statusCode: response.status }
|
|
1659
|
-
);
|
|
1660
|
-
}
|
|
1661
|
-
const result = await response.json();
|
|
1662
|
-
this.directTelemetry?.log({
|
|
1663
|
-
name: "operation.recovery.completed",
|
|
1664
|
-
stage: "recovery",
|
|
1665
|
-
requestCategory: "other",
|
|
1666
|
-
paymentMethodCategory: "saved",
|
|
1667
|
-
statusClass: "2xx"
|
|
1668
|
-
});
|
|
1669
|
-
this.directTelemetry?.performance({
|
|
1670
|
-
stage: "recovery",
|
|
1671
|
-
durationMs: Math.max(0, this.telemetryTimestamp() - startedAt),
|
|
1672
|
-
durationMode: "machine",
|
|
1673
|
-
requestCategory: "other",
|
|
1674
|
-
paymentMethodCategory: "saved",
|
|
1675
|
-
statusClass: "2xx"
|
|
1676
|
-
});
|
|
1677
|
-
return result;
|
|
1678
|
-
} catch (error) {
|
|
1679
|
-
this.reportDirectFailure(error, "RECOVERY_FAILED", "recovery", "other", "saved");
|
|
1680
|
-
throw error;
|
|
1728
|
+
} : {}
|
|
1681
1729
|
}
|
|
1730
|
+
};
|
|
1731
|
+
}
|
|
1732
|
+
var StripeAdapter = class {
|
|
1733
|
+
constructor() {
|
|
1734
|
+
this.name = "stripe";
|
|
1735
|
+
this.stripe = null;
|
|
1736
|
+
this.elements = null;
|
|
1737
|
+
// Serialized appearance currently applied to `this.elements`. Used to detect
|
|
1738
|
+
// when consumers swap themes mid-session so we can live-update the Stripe
|
|
1739
|
+
// Elements group instead of returning a stale-styled cache. `null` while no
|
|
1740
|
+
// elements group exists.
|
|
1741
|
+
this.appliedAppearanceKey = null;
|
|
1742
|
+
this.appliedPaymentMethodTypesKey = null;
|
|
1743
|
+
this.appliedClientSecret = null;
|
|
1744
|
+
this.verifiedClientSecret = null;
|
|
1745
|
+
this.verifiedPaymentMethodTypesKey = null;
|
|
1682
1746
|
}
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
*/
|
|
1690
|
-
async createAndFetchSession(params) {
|
|
1691
|
-
this.beginDirectTelemetryOperation();
|
|
1692
|
-
const startedAt = this.telemetryTimestamp();
|
|
1693
|
-
this.directTelemetry?.log({
|
|
1694
|
-
name: "session.create.started",
|
|
1695
|
-
stage: "session_create",
|
|
1696
|
-
requestCategory: "session_create"
|
|
1747
|
+
async initialize(config) {
|
|
1748
|
+
if (typeof window === "undefined") {
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
const stripe = await loadStripe(config.publishableKey, {
|
|
1752
|
+
locale: config.locale ?? "auto"
|
|
1697
1753
|
});
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1754
|
+
if (!stripe) {
|
|
1755
|
+
throw new FloPayError2(
|
|
1756
|
+
"Failed to initialize Stripe. Check your publishable key.",
|
|
1757
|
+
"authentication_error"
|
|
1758
|
+
);
|
|
1759
|
+
}
|
|
1760
|
+
this.stripe = stripe;
|
|
1761
|
+
}
|
|
1762
|
+
/** Lazily creates the Stripe Elements group for the given options. */
|
|
1763
|
+
getElements(options) {
|
|
1764
|
+
if (!this.stripe) {
|
|
1765
|
+
throw new FloPayError2(
|
|
1766
|
+
"StripeAdapter not initialized. Call initialize() first.",
|
|
1767
|
+
"api_error"
|
|
1768
|
+
);
|
|
1769
|
+
}
|
|
1770
|
+
const stripeAppearance = options?.appearance ? {
|
|
1771
|
+
theme: toStripeAppearanceTheme(options.appearance.theme),
|
|
1772
|
+
variables: options.appearance.variables,
|
|
1773
|
+
rules: options.appearance.rules
|
|
1774
|
+
} : void 0;
|
|
1775
|
+
const nextAppearanceKey = stripeAppearance ? JSON.stringify(stripeAppearance) : null;
|
|
1776
|
+
const nextPaymentMethodTypesKey = paymentMethodTypesKey(options?.paymentMethodTypes);
|
|
1777
|
+
const nextClientSecret = options?.clientSecret ?? null;
|
|
1778
|
+
if (this.elements && nextPaymentMethodTypesKey && (nextPaymentMethodTypesKey !== this.appliedPaymentMethodTypesKey || nextClientSecret !== this.appliedClientSecret)) {
|
|
1779
|
+
this.elements = null;
|
|
1780
|
+
this.appliedAppearanceKey = null;
|
|
1781
|
+
this.appliedPaymentMethodTypesKey = null;
|
|
1782
|
+
this.appliedClientSecret = null;
|
|
1783
|
+
this.verifiedClientSecret = null;
|
|
1784
|
+
this.verifiedPaymentMethodTypesKey = null;
|
|
1785
|
+
}
|
|
1786
|
+
if (!this.elements) {
|
|
1787
|
+
let elementsOptions;
|
|
1788
|
+
const deferredAmount = options?.amount ?? 0;
|
|
1789
|
+
const deferredCurrency = (options?.currency ?? "usd").toLowerCase();
|
|
1790
|
+
const paymentMethodCreation = options?.paymentMethodCreation ?? "manual";
|
|
1791
|
+
if (options?.clientSecret) {
|
|
1792
|
+
elementsOptions = { clientSecret: options.clientSecret };
|
|
1793
|
+
} else if (deferredAmount > 0) {
|
|
1794
|
+
elementsOptions = {
|
|
1795
|
+
mode: "payment",
|
|
1796
|
+
amount: deferredAmount,
|
|
1797
|
+
currency: deferredCurrency,
|
|
1798
|
+
paymentMethodCreation
|
|
1799
|
+
};
|
|
1800
|
+
if (options?.setupFutureUsage) {
|
|
1801
|
+
elementsOptions["setupFutureUsage"] = options.setupFutureUsage;
|
|
1720
1802
|
}
|
|
1803
|
+
} else {
|
|
1804
|
+
elementsOptions = {
|
|
1805
|
+
mode: "setup",
|
|
1806
|
+
currency: deferredCurrency,
|
|
1807
|
+
paymentMethodCreation
|
|
1808
|
+
};
|
|
1721
1809
|
}
|
|
1722
|
-
if (
|
|
1723
|
-
|
|
1724
|
-
outcome: "validation_rejected",
|
|
1725
|
-
stage: "session_create",
|
|
1726
|
-
requestCategory: "session_create"
|
|
1727
|
-
});
|
|
1728
|
-
} else if (!(error instanceof FloPayError3 && error.code === "session_auto_completed")) {
|
|
1729
|
-
const statusCode = error instanceof FloPayError3 ? error.statusCode : void 0;
|
|
1730
|
-
this.directTelemetry?.error({
|
|
1731
|
-
errorCode: error instanceof Error && error.name === "AbortError" ? "REQUEST_TIMEOUT" : error instanceof TypeError ? "NETWORK_REQUEST_FAILED" : "CHECKOUT_SESSION_CREATE_FAILED",
|
|
1732
|
-
stage: "session_create",
|
|
1733
|
-
requestCategory: "session_create",
|
|
1734
|
-
statusClass: error instanceof Error && error.name === "AbortError" ? "timeout" : statusCode ? `${Math.floor(statusCode / 100)}xx` : "network_error"
|
|
1735
|
-
});
|
|
1810
|
+
if (!options?.clientSecret && options?.paymentMethodTypes) {
|
|
1811
|
+
elementsOptions["paymentMethodTypes"] = options.paymentMethodTypes;
|
|
1736
1812
|
}
|
|
1737
|
-
|
|
1813
|
+
if (stripeAppearance) {
|
|
1814
|
+
elementsOptions["appearance"] = stripeAppearance;
|
|
1815
|
+
}
|
|
1816
|
+
this.elements = this.stripe.elements(elementsOptions);
|
|
1817
|
+
this.appliedAppearanceKey = nextAppearanceKey;
|
|
1818
|
+
this.appliedPaymentMethodTypesKey = nextPaymentMethodTypesKey;
|
|
1819
|
+
this.appliedClientSecret = nextClientSecret;
|
|
1820
|
+
this.verifiedClientSecret = null;
|
|
1821
|
+
this.verifiedPaymentMethodTypesKey = null;
|
|
1822
|
+
} else if (nextAppearanceKey !== this.appliedAppearanceKey) {
|
|
1823
|
+
this.elements.update({
|
|
1824
|
+
appearance: stripeAppearance ?? {}
|
|
1825
|
+
});
|
|
1826
|
+
this.appliedAppearanceKey = nextAppearanceKey;
|
|
1738
1827
|
}
|
|
1828
|
+
return this.elements;
|
|
1739
1829
|
}
|
|
1740
|
-
async
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1830
|
+
async assertClientSecretPaymentMethods(clientSecret, allowedPaymentMethodTypes) {
|
|
1831
|
+
if (!this.stripe) {
|
|
1832
|
+
throw new FloPayError2(
|
|
1833
|
+
"StripeAdapter not initialized. Call initialize() first.",
|
|
1834
|
+
"api_error"
|
|
1835
|
+
);
|
|
1836
|
+
}
|
|
1837
|
+
let intent;
|
|
1838
|
+
let retrievalFailed = false;
|
|
1839
|
+
if (isSetupIntentClientSecret(clientSecret)) {
|
|
1840
|
+
const { setupIntent, error } = await this.stripe.retrieveSetupIntent(clientSecret);
|
|
1841
|
+
intent = setupIntent;
|
|
1842
|
+
retrievalFailed = Boolean(error);
|
|
1843
|
+
} else {
|
|
1844
|
+
const { paymentIntent, error } = await this.stripe.retrievePaymentIntent(clientSecret);
|
|
1845
|
+
intent = paymentIntent;
|
|
1846
|
+
retrievalFailed = Boolean(error);
|
|
1847
|
+
}
|
|
1848
|
+
const providerPaymentMethodTypes = intent?.payment_method_types;
|
|
1849
|
+
if (retrievalFailed || !Array.isArray(providerPaymentMethodTypes)) {
|
|
1850
|
+
throw new FloPayError2(
|
|
1851
|
+
"Unable to verify the payment methods configured for this client secret.",
|
|
1852
|
+
"api_error",
|
|
1853
|
+
{ param: "clientSecret" }
|
|
1854
|
+
);
|
|
1855
|
+
}
|
|
1856
|
+
const allowlist = new Set(
|
|
1857
|
+
allowedPaymentMethodTypes.map((paymentMethodType) => paymentMethodType.toLowerCase())
|
|
1747
1858
|
);
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1859
|
+
const hasDisallowedProviderMethod = providerPaymentMethodTypes.some(
|
|
1860
|
+
(paymentMethodType) => typeof paymentMethodType !== "string" || !allowlist.has(paymentMethodType.trim().toLowerCase())
|
|
1861
|
+
);
|
|
1862
|
+
if (hasDisallowedProviderMethod || providerPaymentMethodTypes.length === 0) {
|
|
1863
|
+
throw new FloPayError2(
|
|
1864
|
+
"The client-secret intent must enable only declared non-card payment methods.",
|
|
1751
1865
|
"validation_error",
|
|
1752
|
-
{
|
|
1866
|
+
{ param: "clientSecret" }
|
|
1753
1867
|
);
|
|
1754
1868
|
}
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1869
|
+
}
|
|
1870
|
+
async createElement(type, options) {
|
|
1871
|
+
let resolvedOptions = options;
|
|
1872
|
+
if (type === "payment") {
|
|
1873
|
+
const paymentMethodTypes = options.paymentMethodTypes?.map((paymentMethodType) => paymentMethodType.trim()).filter((paymentMethodType) => paymentMethodType && paymentMethodType.toLowerCase() !== "card");
|
|
1874
|
+
if (!paymentMethodTypes?.length) {
|
|
1875
|
+
throw new FloPayError2(
|
|
1876
|
+
"At least one supported non-card payment method is required.",
|
|
1877
|
+
"validation_error",
|
|
1878
|
+
{ param: "paymentMethodTypes" }
|
|
1879
|
+
);
|
|
1880
|
+
}
|
|
1881
|
+
resolvedOptions = { ...options, paymentMethodTypes };
|
|
1882
|
+
if (resolvedOptions.clientSecret) {
|
|
1883
|
+
const resolvedPaymentMethodTypesKey = paymentMethodTypesKey(paymentMethodTypes);
|
|
1884
|
+
const verificationIsCached = Boolean(
|
|
1885
|
+
this.elements && this.appliedClientSecret === resolvedOptions.clientSecret && this.appliedPaymentMethodTypesKey === resolvedPaymentMethodTypesKey && this.verifiedClientSecret === resolvedOptions.clientSecret && this.verifiedPaymentMethodTypesKey === resolvedPaymentMethodTypesKey
|
|
1886
|
+
);
|
|
1887
|
+
if (!verificationIsCached) {
|
|
1888
|
+
await this.assertClientSecretPaymentMethods(
|
|
1889
|
+
resolvedOptions.clientSecret,
|
|
1890
|
+
paymentMethodTypes
|
|
1891
|
+
);
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
const elements = this.getElements(resolvedOptions);
|
|
1896
|
+
if (type === "payment" && resolvedOptions.clientSecret) {
|
|
1897
|
+
this.verifiedClientSecret = resolvedOptions.clientSecret;
|
|
1898
|
+
this.verifiedPaymentMethodTypesKey = paymentMethodTypesKey(
|
|
1899
|
+
resolvedOptions.paymentMethodTypes
|
|
1900
|
+
);
|
|
1901
|
+
}
|
|
1902
|
+
const stripeType = toStripeElementType(type);
|
|
1903
|
+
const elementOptions = {};
|
|
1904
|
+
if (resolvedOptions.layout) {
|
|
1905
|
+
elementOptions["layout"] = resolvedOptions.layout;
|
|
1906
|
+
}
|
|
1907
|
+
if (resolvedOptions.defaultValues) {
|
|
1908
|
+
elementOptions["defaultValues"] = resolvedOptions.defaultValues;
|
|
1909
|
+
}
|
|
1910
|
+
if (resolvedOptions.readOnly) {
|
|
1911
|
+
elementOptions["readOnly"] = resolvedOptions.readOnly;
|
|
1912
|
+
}
|
|
1913
|
+
if (resolvedOptions.mode) {
|
|
1914
|
+
elementOptions["mode"] = resolvedOptions.mode;
|
|
1915
|
+
}
|
|
1916
|
+
const stripeElement = elements.create(stripeType, elementOptions);
|
|
1917
|
+
return wrapStripeElement(stripeElement);
|
|
1918
|
+
}
|
|
1919
|
+
getElement(type) {
|
|
1920
|
+
if (!this.elements) return null;
|
|
1921
|
+
const stripeType = toStripeElementType(type);
|
|
1922
|
+
const existing = this.elements.getElement(stripeType);
|
|
1923
|
+
if (!existing) return null;
|
|
1924
|
+
return wrapStripeElement(existing);
|
|
1925
|
+
}
|
|
1926
|
+
async submitElements() {
|
|
1927
|
+
if (!this.stripe || !this.elements) {
|
|
1928
|
+
return { error: new FloPayError2("Stripe not initialized", "api_error") };
|
|
1929
|
+
}
|
|
1930
|
+
const { error } = await this.elements.submit();
|
|
1931
|
+
if (error) {
|
|
1932
|
+
return {
|
|
1933
|
+
error: new FloPayError2(error.message ?? "Validation failed", "validation_error")
|
|
1934
|
+
};
|
|
1935
|
+
}
|
|
1936
|
+
return {};
|
|
1937
|
+
}
|
|
1938
|
+
async confirmPayment(params) {
|
|
1939
|
+
if (!this.stripe || !this.elements) {
|
|
1940
|
+
throw new FloPayError2(
|
|
1941
|
+
"StripeAdapter not initialized or no elements created.",
|
|
1942
|
+
"api_error"
|
|
1943
|
+
);
|
|
1944
|
+
}
|
|
1945
|
+
const billing = params.billingDetails;
|
|
1946
|
+
const paymentMethodData = billing ? toStripeBillingDetails(billing) : void 0;
|
|
1947
|
+
const { error, paymentIntent } = await this.stripe.confirmPayment({
|
|
1948
|
+
elements: this.elements,
|
|
1949
|
+
clientSecret: params.clientSecret,
|
|
1950
|
+
confirmParams: {
|
|
1951
|
+
return_url: params.returnUrl ?? window.location.href,
|
|
1952
|
+
...paymentMethodData ? { payment_method_data: paymentMethodData } : {}
|
|
1775
1953
|
},
|
|
1776
|
-
|
|
1954
|
+
redirect: "if_required"
|
|
1955
|
+
});
|
|
1956
|
+
if (error) {
|
|
1957
|
+
return {
|
|
1958
|
+
status: "failed",
|
|
1959
|
+
error: new FloPayError2(
|
|
1960
|
+
error.message ?? "Payment failed",
|
|
1961
|
+
"api_error",
|
|
1962
|
+
{
|
|
1963
|
+
code: error.code,
|
|
1964
|
+
declineCode: error.decline_code
|
|
1965
|
+
}
|
|
1966
|
+
)
|
|
1967
|
+
};
|
|
1968
|
+
}
|
|
1969
|
+
if (!paymentIntent) {
|
|
1970
|
+
return { status: "failed", error: new FloPayError2("No payment intent returned", "api_error") };
|
|
1971
|
+
}
|
|
1972
|
+
const statusMap = {
|
|
1973
|
+
succeeded: "succeeded",
|
|
1974
|
+
processing: "processing",
|
|
1975
|
+
requires_action: "requires_action",
|
|
1976
|
+
requires_payment_method: "failed",
|
|
1977
|
+
canceled: "failed"
|
|
1777
1978
|
};
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
if (params.checkoutType) payload["checkoutType"] = params.checkoutType;
|
|
1783
|
-
if (params.checkoutLayout) payload["checkoutLayout"] = params.checkoutLayout;
|
|
1784
|
-
if (params.avsConfig) payload["avsConfig"] = params.avsConfig;
|
|
1785
|
-
const headers = {
|
|
1786
|
-
"Content-Type": "application/json",
|
|
1787
|
-
// Declare the SDK version so backends at TeamFloPay/backend#823 embed
|
|
1788
|
-
// the hosted vault capture block (`body.vault`) in the response for
|
|
1789
|
-
// SDKs ≥ 1.3.0. Older backends ignore the header.
|
|
1790
|
-
[FLO_SDK_VERSION_HEADER]: SDK_VERSION
|
|
1979
|
+
return {
|
|
1980
|
+
status: statusMap[paymentIntent.status] ?? "failed",
|
|
1981
|
+
paymentIntentId: paymentIntent.id,
|
|
1982
|
+
paymentMethodId: this.extractPaymentMethodId(paymentIntent.payment_method)
|
|
1791
1983
|
};
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1984
|
+
}
|
|
1985
|
+
extractPaymentMethodId(paymentMethod) {
|
|
1986
|
+
if (typeof paymentMethod === "string" && paymentMethod.startsWith("pm_")) {
|
|
1987
|
+
return paymentMethod;
|
|
1795
1988
|
}
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1989
|
+
if (paymentMethod && typeof paymentMethod === "object" && typeof paymentMethod.id === "string") {
|
|
1990
|
+
return paymentMethod.id;
|
|
1991
|
+
}
|
|
1992
|
+
return void 0;
|
|
1993
|
+
}
|
|
1994
|
+
async confirmPayPalPayment(params) {
|
|
1995
|
+
if (!this.stripe) {
|
|
1996
|
+
return { status: "failed", error: new FloPayError2("Stripe not initialized", "api_error") };
|
|
1997
|
+
}
|
|
1998
|
+
const baseUrl = params.billingApiUrl.replace(/\/+$/, "");
|
|
1999
|
+
if (this.elements) {
|
|
2000
|
+
const { error: submitError } = await this.elements.submit();
|
|
2001
|
+
if (submitError) {
|
|
2002
|
+
return {
|
|
2003
|
+
status: "failed",
|
|
2004
|
+
error: new FloPayError2(
|
|
2005
|
+
submitError.message ?? "PayPal payment failed",
|
|
2006
|
+
"validation_error",
|
|
2007
|
+
{ code: submitError.code }
|
|
2008
|
+
)
|
|
2009
|
+
};
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
let intentClientSecret;
|
|
2013
|
+
try {
|
|
2014
|
+
const intent = await new PaymentAPI(baseUrl).createSessionIntent(
|
|
2015
|
+
params.sessionId,
|
|
2016
|
+
params.nonce ?? "",
|
|
1801
2017
|
{
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
(networkAttempt) => {
|
|
1808
|
-
this.telemetryHooks?.onRetry?.("session_create", networkAttempt);
|
|
1809
|
-
this.directTelemetry?.log({
|
|
1810
|
-
name: "operation.retry",
|
|
1811
|
-
stage: "session_create",
|
|
1812
|
-
requestCategory: "session_create",
|
|
1813
|
-
attempt: networkAttempt
|
|
1814
|
-
});
|
|
2018
|
+
provider: "stripe",
|
|
2019
|
+
paymentMethodCategory: "wallet",
|
|
2020
|
+
paymentMethodType: "paypal",
|
|
2021
|
+
paymentMethodId: null,
|
|
2022
|
+
intentKind: "payment"
|
|
1815
2023
|
}
|
|
1816
|
-
);
|
|
1817
|
-
if (
|
|
1818
|
-
|
|
1819
|
-
const statusClass = `${Math.floor(response.status / 100)}xx`;
|
|
1820
|
-
this.directTelemetry?.log({
|
|
1821
|
-
name: "session.request.first_byte",
|
|
1822
|
-
stage: "session_first_byte",
|
|
1823
|
-
requestCategory: "session_create",
|
|
1824
|
-
statusClass
|
|
1825
|
-
});
|
|
1826
|
-
this.directTelemetry?.performance({
|
|
1827
|
-
stage: "session_first_byte",
|
|
1828
|
-
durationMs: this.telemetryTimestamp() - telemetryStartedAt,
|
|
1829
|
-
durationMode: "machine",
|
|
1830
|
-
requestCategory: "session_create",
|
|
1831
|
-
statusClass
|
|
1832
|
-
});
|
|
1833
|
-
}
|
|
1834
|
-
if (response.status === 204) {
|
|
1835
|
-
throw new FloPayError3(
|
|
1836
|
-
"Session auto-completed \u2014 payment method already on file",
|
|
1837
|
-
"api_error",
|
|
1838
|
-
{ code: "session_auto_completed" }
|
|
1839
|
-
);
|
|
2024
|
+
);
|
|
2025
|
+
if (intent.provider !== "stripe") {
|
|
2026
|
+
throw new FloPayError2("Invalid provider returned for PayPal intent", "api_error");
|
|
1840
2027
|
}
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
2028
|
+
intentClientSecret = intent.clientSecret;
|
|
2029
|
+
} catch (error2) {
|
|
2030
|
+
return {
|
|
2031
|
+
status: "failed",
|
|
2032
|
+
error: error2 instanceof FloPayError2 ? error2 : new FloPayError2("Failed to create PayPal payment intent", "api_error")
|
|
2033
|
+
};
|
|
2034
|
+
}
|
|
2035
|
+
const { error } = await this.stripe.confirmPayment({
|
|
2036
|
+
clientSecret: intentClientSecret,
|
|
2037
|
+
elements: this.elements ?? void 0,
|
|
2038
|
+
confirmParams: { return_url: params.returnUrl }
|
|
2039
|
+
});
|
|
2040
|
+
if (error) {
|
|
2041
|
+
if (params.nonce) {
|
|
1844
2042
|
try {
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
2043
|
+
await new PaymentAPI(baseUrl).reportSessionIntentDecline(
|
|
2044
|
+
params.sessionId,
|
|
2045
|
+
params.nonce,
|
|
2046
|
+
{
|
|
2047
|
+
provider: "stripe",
|
|
2048
|
+
paymentMethodCategory: "wallet",
|
|
2049
|
+
paymentMethodType: "paypal",
|
|
2050
|
+
providerDeclineReason: error.code ?? "provider_declined"
|
|
2051
|
+
}
|
|
2052
|
+
);
|
|
1852
2053
|
} catch {
|
|
1853
2054
|
}
|
|
1854
|
-
await delay(150 * 2 ** attempt);
|
|
1855
|
-
continue;
|
|
1856
|
-
}
|
|
1857
|
-
throw error;
|
|
1858
|
-
}
|
|
1859
|
-
const body = await response.json();
|
|
1860
|
-
if (body.data && "gateways" in body.data) {
|
|
1861
|
-
this.autoCacheDisplayData(body.data.uuid, params);
|
|
1862
|
-
const merged = this.mergeCachedDisplayData(body.data);
|
|
1863
|
-
const normalized = this.normalizeRawSession(merged);
|
|
1864
|
-
if (body.vault && normalized.data.session) {
|
|
1865
|
-
normalized.data.session.vault = this.toVaultBlock(body.vault);
|
|
1866
2055
|
}
|
|
1867
2056
|
return {
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
autoProcessingAttempted: body.autoProcessingAttempted,
|
|
1871
|
-
autoProcessingPending: body.autoProcessingPending
|
|
2057
|
+
status: "failed",
|
|
2058
|
+
error: new FloPayError2(error.message ?? "PayPal payment failed", "api_error", { code: error.code })
|
|
1872
2059
|
};
|
|
1873
2060
|
}
|
|
1874
|
-
const uuid = body.data?.uuid;
|
|
1875
|
-
if (!uuid) {
|
|
1876
|
-
throw new FloPayError3("No session ID returned", "api_error");
|
|
1877
|
-
}
|
|
1878
|
-
this.autoCacheDisplayData(uuid, params);
|
|
1879
|
-
this.adoptDirectTelemetryCheckout(uuid);
|
|
1880
|
-
const unifiedSession = await this.getUnifiedCheckoutSession(uuid);
|
|
1881
2061
|
return {
|
|
1882
|
-
|
|
1883
|
-
autoProcessingError: body.autoProcessingError,
|
|
1884
|
-
autoProcessingAttempted: body.autoProcessingAttempted,
|
|
1885
|
-
autoProcessingPending: body.autoProcessingPending
|
|
2062
|
+
status: "processing"
|
|
1886
2063
|
};
|
|
1887
2064
|
}
|
|
1888
|
-
async
|
|
1889
|
-
this.
|
|
1890
|
-
const
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
let pollAttempt = 0;
|
|
1901
|
-
try {
|
|
1902
|
-
while (true) {
|
|
1903
|
-
const remainingMs = deadline - Date.now();
|
|
1904
|
-
if (remainingMs <= 0) {
|
|
1905
|
-
throw createCheckoutProcessingTimeoutError();
|
|
1906
|
-
}
|
|
1907
|
-
if (nextDelayMs > 0) {
|
|
1908
|
-
try {
|
|
1909
|
-
pollAttempt += 1;
|
|
1910
|
-
this.telemetryHooks?.onRetry?.("session_read", pollAttempt);
|
|
1911
|
-
this.directTelemetry?.log({
|
|
1912
|
-
name: "operation.retry",
|
|
1913
|
-
stage: "recovery",
|
|
1914
|
-
requestCategory: "session_read",
|
|
1915
|
-
paymentMethodCategory: "saved",
|
|
1916
|
-
attempt: pollAttempt
|
|
1917
|
-
});
|
|
1918
|
-
} catch {
|
|
1919
|
-
}
|
|
1920
|
-
await delay(Math.min(nextDelayMs, remainingMs));
|
|
1921
|
-
if (Date.now() >= deadline) {
|
|
1922
|
-
throw createCheckoutProcessingTimeoutError();
|
|
1923
|
-
}
|
|
1924
|
-
}
|
|
1925
|
-
const session = await this.getUnifiedCheckoutSession(checkoutSessionId, options?.nonce);
|
|
1926
|
-
const status = session.data.session?.status;
|
|
1927
|
-
if (status === "complete" || status === "expired") {
|
|
1928
|
-
this.directTelemetry?.log({
|
|
1929
|
-
name: "operation.recovery.completed",
|
|
1930
|
-
stage: "recovery",
|
|
1931
|
-
requestCategory: "session_read",
|
|
1932
|
-
paymentMethodCategory: "saved"
|
|
1933
|
-
});
|
|
1934
|
-
this.directTelemetry?.performance({
|
|
1935
|
-
stage: "recovery",
|
|
1936
|
-
durationMs: Math.max(0, this.telemetryTimestamp() - startedAt),
|
|
1937
|
-
durationMode: "machine",
|
|
1938
|
-
requestCategory: "session_read",
|
|
1939
|
-
paymentMethodCategory: "saved"
|
|
1940
|
-
});
|
|
1941
|
-
return session;
|
|
1942
|
-
}
|
|
1943
|
-
if (Date.now() >= deadline) {
|
|
1944
|
-
throw createCheckoutProcessingTimeoutError();
|
|
1945
|
-
}
|
|
1946
|
-
nextDelayMs = this.clampRetryAfterMs(
|
|
1947
|
-
Math.max(nextDelayMs * 2, MIN_PROCESSING_RETRY_AFTER_MS)
|
|
1948
|
-
);
|
|
1949
|
-
}
|
|
1950
|
-
} catch (error) {
|
|
1951
|
-
if (error instanceof FloPayError3 && error.code === "checkout_processing_timeout") {
|
|
1952
|
-
this.reportDirectFailure(
|
|
1953
|
-
error,
|
|
1954
|
-
"RECOVERY_FAILED",
|
|
1955
|
-
"recovery",
|
|
1956
|
-
"session_read",
|
|
1957
|
-
"saved"
|
|
1958
|
-
);
|
|
1959
|
-
}
|
|
1960
|
-
throw error;
|
|
2065
|
+
async resumePayPalPayment() {
|
|
2066
|
+
if (!this.stripe || typeof window === "undefined") return null;
|
|
2067
|
+
const params = new URLSearchParams(window.location.search);
|
|
2068
|
+
const paymentIntentId = params.get("payment_intent");
|
|
2069
|
+
const clientSecret = params.get("payment_intent_client_secret");
|
|
2070
|
+
const redirectStatus = params.get("redirect_status");
|
|
2071
|
+
if (!paymentIntentId || !clientSecret) return null;
|
|
2072
|
+
if (redirectStatus === "failed") {
|
|
2073
|
+
return {
|
|
2074
|
+
status: "failed",
|
|
2075
|
+
error: new FloPayError2("PayPal payment was declined. Please try again.", "api_error")
|
|
2076
|
+
};
|
|
1961
2077
|
}
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
const data = {
|
|
1968
|
-
session: this.toCheckoutSession(session)
|
|
1969
|
-
};
|
|
1970
|
-
const stripeGateway = gateways.stripe;
|
|
1971
|
-
if (stripeGateway?.publishableKey) {
|
|
1972
|
-
providers.push("stripe");
|
|
1973
|
-
const rawSession = session;
|
|
1974
|
-
const stripeClientSecret = [
|
|
1975
|
-
rawSession["stripeClientSecret"],
|
|
1976
|
-
stripeGateway.stripeClientSecret
|
|
1977
|
-
].find((value) => typeof value === "string" && value.length > 0);
|
|
1978
|
-
data.stripe = {
|
|
1979
|
-
clientSecret: stripeClientSecret ?? "",
|
|
1980
|
-
publishableKey: stripeGateway.publishableKey ?? void 0,
|
|
1981
|
-
paypalPublishableKey: stripeGateway.paypalPublishableKey ?? void 0,
|
|
1982
|
-
environment: stripeGateway.environment,
|
|
1983
|
-
enabledPaymentMethods: Array.isArray(stripeGateway.enabledPaymentMethods) ? stripeGateway.enabledPaymentMethods.filter((m) => typeof m === "string") : void 0
|
|
2078
|
+
const { paymentIntent, error } = await this.stripe.retrievePaymentIntent(clientSecret);
|
|
2079
|
+
if (error) {
|
|
2080
|
+
return {
|
|
2081
|
+
status: "failed",
|
|
2082
|
+
error: new FloPayError2(error.message ?? "Failed to retrieve PayPal payment", "api_error")
|
|
1984
2083
|
};
|
|
1985
2084
|
}
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
2085
|
+
if (paymentIntent && (paymentIntent.status === "requires_capture" || paymentIntent.status === "succeeded")) {
|
|
2086
|
+
const pmId = typeof paymentIntent.payment_method === "string" ? paymentIntent.payment_method : paymentIntent.payment_method?.id;
|
|
2087
|
+
const url = new URL(window.location.href);
|
|
2088
|
+
url.searchParams.delete("payment_intent");
|
|
2089
|
+
url.searchParams.delete("payment_intent_client_secret");
|
|
2090
|
+
url.searchParams.delete("redirect_status");
|
|
2091
|
+
window.history.replaceState({}, "", url.toString());
|
|
2092
|
+
return {
|
|
2093
|
+
status: paymentIntent.status,
|
|
2094
|
+
paymentIntentId: paymentIntent.id,
|
|
2095
|
+
paymentMethodId: pmId
|
|
1992
2096
|
};
|
|
1993
2097
|
}
|
|
1994
2098
|
return {
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
data,
|
|
1998
|
-
raw: { data: session }
|
|
1999
|
-
};
|
|
2000
|
-
}
|
|
2001
|
-
/** Convert raw session to the SDK CheckoutSession shape. */
|
|
2002
|
-
toCheckoutSession(raw) {
|
|
2003
|
-
const rawProducts = raw.products ?? [];
|
|
2004
|
-
const hasBackendTotal = typeof raw.totalAmount === "number" && Number.isFinite(raw.totalAmount);
|
|
2005
|
-
const computedTotal = rawProducts.reduce(
|
|
2006
|
-
(sum, p) => sum + (p.overrideAmount ?? p.totalAmount ?? 0),
|
|
2007
|
-
0
|
|
2008
|
-
);
|
|
2009
|
-
const totalAmount = hasBackendTotal ? raw.totalAmount : computedTotal;
|
|
2010
|
-
const amountInCents = Math.round(totalAmount * 100);
|
|
2011
|
-
const currency = raw.currency ?? rawProducts[0]?.currency ?? "USD";
|
|
2012
|
-
const mode = rawProducts.some((p) => p.type === "subscription") ? "subscription" : "payment";
|
|
2013
|
-
return {
|
|
2014
|
-
id: raw.uuid,
|
|
2015
|
-
clientSecret: raw.nonce,
|
|
2016
|
-
mode,
|
|
2017
|
-
status: this.toCheckoutSessionStatus(raw.status),
|
|
2018
|
-
amount: amountInCents,
|
|
2019
|
-
currency,
|
|
2020
|
-
customer: {
|
|
2021
|
-
id: raw.accountData.userId,
|
|
2022
|
-
email: raw.accountData.email,
|
|
2023
|
-
firstName: raw.accountData.firstName,
|
|
2024
|
-
lastName: raw.accountData.lastName,
|
|
2025
|
-
country: raw.accountData.country ?? void 0,
|
|
2026
|
-
city: raw.accountData.city ?? void 0,
|
|
2027
|
-
state: raw.accountData.state ?? void 0,
|
|
2028
|
-
zip: raw.accountData.zip ?? void 0,
|
|
2029
|
-
gender: raw.accountData.gender ?? void 0,
|
|
2030
|
-
line1: raw.accountData.addressLine1 ?? void 0,
|
|
2031
|
-
line2: raw.accountData.addressLine2 ?? void 0
|
|
2032
|
-
},
|
|
2033
|
-
metadata: {},
|
|
2034
|
-
checkoutMode: raw.checkoutMode,
|
|
2035
|
-
providerPaymentMethodId: typeof raw.providerPaymentMethodId === "string" ? raw.providerPaymentMethodId : null,
|
|
2036
|
-
products: rawProducts.map((p) => ({
|
|
2037
|
-
...p,
|
|
2038
|
-
totalAmount: typeof p.totalAmount === "number" ? p.totalAmount : void 0,
|
|
2039
|
-
overrideAmount: typeof p.overrideAmount === "number" ? p.overrideAmount : null,
|
|
2040
|
-
currency: typeof p.currency === "string" ? p.currency : void 0,
|
|
2041
|
-
metadata: p.metadata ?? null
|
|
2042
|
-
})),
|
|
2043
|
-
successUrl: raw.successUrl,
|
|
2044
|
-
cancelUrl: raw.cancelUrl,
|
|
2045
|
-
coupons: raw.coupons,
|
|
2046
|
-
subtotalAmount: raw.subtotalAmount,
|
|
2047
|
-
discountAmount: raw.discountAmount,
|
|
2048
|
-
totalAmount: raw.totalAmount,
|
|
2049
|
-
createdAt: raw.createdAt,
|
|
2050
|
-
gateways: raw.gateways,
|
|
2051
|
-
accountData: raw.accountData,
|
|
2052
|
-
tagsData: raw.tagsData
|
|
2099
|
+
status: "failed",
|
|
2100
|
+
error: new FloPayError2("PayPal payment was not completed. Please try again.", "api_error")
|
|
2053
2101
|
};
|
|
2054
2102
|
}
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
* server-only PCIVault submit `secret` is deliberately dropped so it never
|
|
2058
|
-
* lands on the public session surface (logs / telemetry / client inspection).
|
|
2059
|
-
*/
|
|
2060
|
-
toVaultBlock(raw) {
|
|
2061
|
-
return {
|
|
2062
|
-
html: typeof raw.html === "string" ? raw.html : void 0,
|
|
2063
|
-
url: typeof raw.url === "string" ? raw.url : void 0,
|
|
2064
|
-
messageToken: typeof raw.messageToken === "string" ? raw.messageToken : void 0,
|
|
2065
|
-
expectedOrigin: typeof raw.expectedOrigin === "string" ? raw.expectedOrigin : void 0
|
|
2066
|
-
};
|
|
2103
|
+
getRawProvider() {
|
|
2104
|
+
return this.stripe;
|
|
2067
2105
|
}
|
|
2068
|
-
|
|
2069
|
-
if (
|
|
2070
|
-
|
|
2106
|
+
createPayPalElements(options) {
|
|
2107
|
+
if (!this.stripe) return null;
|
|
2108
|
+
const elementsOptions = {
|
|
2109
|
+
mode: "payment",
|
|
2110
|
+
amount: options.amount ?? 0,
|
|
2111
|
+
currency: (options.currency ?? "usd").toLowerCase(),
|
|
2112
|
+
captureMethod: "manual"
|
|
2113
|
+
};
|
|
2114
|
+
if (options.setupFutureUsage) {
|
|
2115
|
+
elementsOptions["setupFutureUsage"] = options.setupFutureUsage;
|
|
2071
2116
|
}
|
|
2072
|
-
if (
|
|
2073
|
-
|
|
2117
|
+
if (options.appearance) {
|
|
2118
|
+
elementsOptions["appearance"] = {
|
|
2119
|
+
theme: toStripeAppearanceTheme(options.appearance.theme),
|
|
2120
|
+
variables: options.appearance.variables,
|
|
2121
|
+
rules: options.appearance.rules
|
|
2122
|
+
};
|
|
2074
2123
|
}
|
|
2075
|
-
return
|
|
2124
|
+
return this.stripe.elements(elementsOptions);
|
|
2076
2125
|
}
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2126
|
+
destroy() {
|
|
2127
|
+
this.elements = null;
|
|
2128
|
+
this.appliedAppearanceKey = null;
|
|
2129
|
+
this.appliedPaymentMethodTypesKey = null;
|
|
2130
|
+
this.appliedClientSecret = null;
|
|
2131
|
+
this.verifiedClientSecret = null;
|
|
2132
|
+
this.verifiedPaymentMethodTypesKey = null;
|
|
2133
|
+
this.stripe = null;
|
|
2134
|
+
}
|
|
2135
|
+
};
|
|
2136
|
+
|
|
2137
|
+
// src/flopay.ts
|
|
2138
|
+
import { FloPayError as FloPayError5, resolveBillingApiUrl as resolveBillingApiUrl2, SDK_VERSION as SDK_VERSION3 } from "@flopay/shared";
|
|
2139
|
+
|
|
2140
|
+
// src/elements.ts
|
|
2141
|
+
import { FloPayError as FloPayError3 } from "@flopay/shared";
|
|
2142
|
+
var FloPayElements = class {
|
|
2143
|
+
constructor(provider, options) {
|
|
2144
|
+
this.elementMap = /* @__PURE__ */ new Map();
|
|
2145
|
+
this.provider = provider;
|
|
2146
|
+
this.baseOptions = options ?? {};
|
|
2147
|
+
}
|
|
2148
|
+
/**
|
|
2149
|
+
* Creates a new element of the given type.
|
|
2150
|
+
* If an element of that type already exists, it is destroyed first.
|
|
2151
|
+
*/
|
|
2152
|
+
async create(type, options) {
|
|
2153
|
+
const merged = { ...this.baseOptions, ...options };
|
|
2154
|
+
if (type === "payment") {
|
|
2155
|
+
const paymentMethodTypes = merged.paymentMethodTypes?.map((paymentMethodType) => paymentMethodType.trim()).filter((paymentMethodType) => paymentMethodType && paymentMethodType.toLowerCase() !== "card");
|
|
2156
|
+
if (!paymentMethodTypes?.length) {
|
|
2157
|
+
throw new FloPayError3(
|
|
2158
|
+
"At least one supported non-card payment method is required.",
|
|
2159
|
+
"validation_error",
|
|
2160
|
+
{ param: "paymentMethodTypes" }
|
|
2161
|
+
);
|
|
2162
|
+
}
|
|
2163
|
+
merged.paymentMethodTypes = paymentMethodTypes;
|
|
2080
2164
|
}
|
|
2081
|
-
const
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
timeoutMs: options?.pollTimeoutMs,
|
|
2086
|
-
nonce: options?.nonce
|
|
2087
|
-
});
|
|
2088
|
-
if (session.data.session?.status === "complete") {
|
|
2089
|
-
return new Response(null, { status: 204, statusText: "No Content" });
|
|
2165
|
+
const providerExisting = this.provider.getElement(type);
|
|
2166
|
+
if (providerExisting) {
|
|
2167
|
+
this.elementMap.set(type, providerExisting);
|
|
2168
|
+
return providerExisting;
|
|
2090
2169
|
}
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
"api_error",
|
|
2095
|
-
{ code: "checkout_session_expired" }
|
|
2096
|
-
);
|
|
2170
|
+
const existing = this.elementMap.get(type);
|
|
2171
|
+
if (existing) {
|
|
2172
|
+
existing.destroy();
|
|
2097
2173
|
}
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
const retryAfterHeader = response.headers.get("Retry-After");
|
|
2102
|
-
const headerRetryAfterSeconds = retryAfterHeader === null || retryAfterHeader.trim() === "" ? void 0 : Number(retryAfterHeader);
|
|
2103
|
-
const headerRetryAfterMs = headerRetryAfterSeconds !== void 0 && Number.isFinite(headerRetryAfterSeconds) ? headerRetryAfterSeconds * 1e3 : void 0;
|
|
2104
|
-
return {
|
|
2105
|
-
type: "checkout_processing",
|
|
2106
|
-
sessionId: readString(payload, "sessionId") ?? checkoutSessionId,
|
|
2107
|
-
retryAfterMs: this.clampRetryAfterMs(
|
|
2108
|
-
readNumber(payload, "retryAfterMs") ?? headerRetryAfterMs ?? DEFAULT_PROCESSING_RETRY_AFTER_MS
|
|
2109
|
-
),
|
|
2110
|
-
statusUrl: readString(payload, "statusUrl"),
|
|
2111
|
-
sessionUrl: readString(payload, "sessionUrl")
|
|
2112
|
-
};
|
|
2174
|
+
const element = await this.provider.createElement(type, merged);
|
|
2175
|
+
this.elementMap.set(type, element);
|
|
2176
|
+
return element;
|
|
2113
2177
|
}
|
|
2114
|
-
|
|
2115
|
-
|
|
2178
|
+
/** Returns a previously created element, or `null`. */
|
|
2179
|
+
getElement(type) {
|
|
2180
|
+
return this.elementMap.get(type) ?? null;
|
|
2116
2181
|
}
|
|
2117
2182
|
/**
|
|
2118
|
-
*
|
|
2119
|
-
* call. Runs after the backend assigns a UUID so a later GET on the same
|
|
2120
|
-
* session (typically after a redirect) can fill in fields the backend no
|
|
2121
|
-
* longer persists — `overrideAmount`, `totalAmount`, `name`, etc.
|
|
2183
|
+
* Submits all mounted elements for validation.
|
|
2122
2184
|
*
|
|
2123
|
-
*
|
|
2185
|
+
* Returns an object with an optional error if validation fails.
|
|
2186
|
+
* This does NOT confirm the payment — call `floPay.confirmPayment()` for that.
|
|
2124
2187
|
*/
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
const products = params.products ?? foldIntoProducts(params.items, params.subscriptions);
|
|
2128
|
-
if (products.length === 0 && !params.currency) {
|
|
2129
|
-
return;
|
|
2130
|
-
}
|
|
2131
|
-
const usingUnifiedProducts = params.products !== void 0;
|
|
2132
|
-
const sessionCurrency = resolveSessionCurrency(
|
|
2133
|
-
params.currency,
|
|
2134
|
-
usingUnifiedProducts ? void 0 : params.items,
|
|
2135
|
-
usingUnifiedProducts ? void 0 : params.subscriptions,
|
|
2136
|
-
products
|
|
2137
|
-
);
|
|
2138
|
-
cacheSessionDisplayData(sessionId, {
|
|
2139
|
-
currency: sessionCurrency ?? void 0,
|
|
2140
|
-
products: products.map((p) => ({
|
|
2141
|
-
code: p.code ?? p.providerItemId ?? p.providerPlanId,
|
|
2142
|
-
type: p.type,
|
|
2143
|
-
name: p.name ?? p.itemName ?? p.providerItemName ?? p.subscriptionName ?? p.providerPlanName ?? null,
|
|
2144
|
-
totalAmount: p.totalAmount,
|
|
2145
|
-
overrideAmount: p.overrideAmount,
|
|
2146
|
-
currency: p.currency ?? sessionCurrency ?? void 0
|
|
2147
|
-
}))
|
|
2148
|
-
});
|
|
2188
|
+
async submit() {
|
|
2189
|
+
return {};
|
|
2149
2190
|
}
|
|
2150
|
-
/**
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
*/
|
|
2155
|
-
mergeCachedDisplayData(raw) {
|
|
2156
|
-
const cached = getSessionDisplayData(raw.uuid);
|
|
2157
|
-
const cachedProducts = /* @__PURE__ */ new Map();
|
|
2158
|
-
const productKey = (code) => code ? `code:${code}` : void 0;
|
|
2159
|
-
for (const p of cached?.products ?? []) {
|
|
2160
|
-
const key = productKey(p.code);
|
|
2161
|
-
if (key) cachedProducts.set(key, p);
|
|
2191
|
+
/** Destroys all created elements and clears the internal map. */
|
|
2192
|
+
destroy() {
|
|
2193
|
+
for (const element of this.elementMap.values()) {
|
|
2194
|
+
element.destroy();
|
|
2162
2195
|
}
|
|
2163
|
-
|
|
2164
|
-
const key = productKey(p.code);
|
|
2165
|
-
const fallback = key ? cachedProducts.get(key) : void 0;
|
|
2166
|
-
return {
|
|
2167
|
-
...p,
|
|
2168
|
-
name: p.name ?? fallback?.name ?? null,
|
|
2169
|
-
totalAmount: p.totalAmount ?? fallback?.totalAmount,
|
|
2170
|
-
overrideAmount: p.overrideAmount ?? fallback?.overrideAmount,
|
|
2171
|
-
currency: p.currency ?? fallback?.currency
|
|
2172
|
-
};
|
|
2173
|
-
});
|
|
2174
|
-
return {
|
|
2175
|
-
...raw,
|
|
2176
|
-
currency: raw.currency ?? cached?.currency,
|
|
2177
|
-
products: mergedProducts
|
|
2178
|
-
};
|
|
2196
|
+
this.elementMap.clear();
|
|
2179
2197
|
}
|
|
2180
2198
|
};
|
|
2181
|
-
function createInstrumentedPaymentAPI(billingApiUrl, hooks) {
|
|
2182
|
-
const InstrumentedPaymentAPI = PaymentAPI;
|
|
2183
|
-
return new InstrumentedPaymentAPI(billingApiUrl, hooks);
|
|
2184
|
-
}
|
|
2185
2199
|
|
|
2186
2200
|
// src/pci-vault-card-capture.ts
|
|
2187
2201
|
import {
|
|
@@ -2847,7 +2861,6 @@ function telemetryProvider(name) {
|
|
|
2847
2861
|
var FloPay = class {
|
|
2848
2862
|
constructor(provider, config, telemetryReporter) {
|
|
2849
2863
|
this.currentElements = null;
|
|
2850
|
-
this.cardThreeDsStartedAt = /* @__PURE__ */ new Map();
|
|
2851
2864
|
this.provider = provider;
|
|
2852
2865
|
this.config = config;
|
|
2853
2866
|
this.telemetryReporter = telemetryReporter ?? new TelemetryReporter({
|
|
@@ -2880,176 +2893,9 @@ var FloPay = class {
|
|
|
2880
2893
|
async submitElements() {
|
|
2881
2894
|
return this.provider.submitElements();
|
|
2882
2895
|
}
|
|
2883
|
-
/** Create a payment method from the current elements (tokenize card). */
|
|
2884
|
-
async createPaymentMethod(billingDetails) {
|
|
2885
|
-
const started = this.now();
|
|
2886
|
-
const provider = telemetryProvider(this.provider.name);
|
|
2887
|
-
this.telemetryReporter?.log({
|
|
2888
|
-
name: "payment.tokenization.started",
|
|
2889
|
-
stage: "tokenization",
|
|
2890
|
-
provider,
|
|
2891
|
-
paymentMethodCategory: "card"
|
|
2892
|
-
});
|
|
2893
|
-
try {
|
|
2894
|
-
const result = await this.provider.createPaymentMethod(billingDetails);
|
|
2895
|
-
this.telemetryReporter?.performance({
|
|
2896
|
-
stage: "tokenization",
|
|
2897
|
-
durationMs: this.now() - started,
|
|
2898
|
-
durationMode: "machine",
|
|
2899
|
-
provider,
|
|
2900
|
-
paymentMethodCategory: "card"
|
|
2901
|
-
});
|
|
2902
|
-
if (!result.error) {
|
|
2903
|
-
this.telemetryReporter?.log({
|
|
2904
|
-
name: "payment.tokenization.completed",
|
|
2905
|
-
stage: "tokenization",
|
|
2906
|
-
provider,
|
|
2907
|
-
paymentMethodCategory: "card"
|
|
2908
|
-
});
|
|
2909
|
-
} else if (result.error.type !== "validation_error") {
|
|
2910
|
-
this.telemetryReporter?.error({
|
|
2911
|
-
errorCode: "TOKENIZATION_FAILED",
|
|
2912
|
-
stage: "tokenization",
|
|
2913
|
-
provider,
|
|
2914
|
-
paymentMethodCategory: "card"
|
|
2915
|
-
});
|
|
2916
|
-
}
|
|
2917
|
-
return result;
|
|
2918
|
-
} catch (error) {
|
|
2919
|
-
this.telemetryReporter?.performance({
|
|
2920
|
-
stage: "tokenization",
|
|
2921
|
-
durationMs: this.now() - started,
|
|
2922
|
-
durationMode: "machine",
|
|
2923
|
-
provider,
|
|
2924
|
-
paymentMethodCategory: "card"
|
|
2925
|
-
});
|
|
2926
|
-
this.telemetryReporter?.error({
|
|
2927
|
-
errorCode: "TOKENIZATION_FAILED",
|
|
2928
|
-
stage: "tokenization",
|
|
2929
|
-
provider,
|
|
2930
|
-
paymentMethodCategory: "card"
|
|
2931
|
-
});
|
|
2932
|
-
throw error;
|
|
2933
|
-
}
|
|
2934
|
-
}
|
|
2935
|
-
/** Confirm a card payment with a known client secret and payment method ID. */
|
|
2936
|
-
async confirmCardPayment(params) {
|
|
2937
|
-
const provider = telemetryProvider(this.provider.name);
|
|
2938
|
-
const operationStartedAt = this.now();
|
|
2939
|
-
try {
|
|
2940
|
-
const result = await this.provider.confirmCardPayment(params);
|
|
2941
|
-
const completedAt = this.now();
|
|
2942
|
-
const threeDs = result.threeDs;
|
|
2943
|
-
let threeDsFailed = false;
|
|
2944
|
-
if (threeDs?.status === "handoff") {
|
|
2945
|
-
if (this.trackCardThreeDsAttempt(threeDs.attemptId, completedAt)) {
|
|
2946
|
-
this.telemetryReporter?.log({
|
|
2947
|
-
name: "payment.three_ds.handoff",
|
|
2948
|
-
stage: "three_ds_handoff",
|
|
2949
|
-
provider,
|
|
2950
|
-
paymentMethodCategory: "card"
|
|
2951
|
-
});
|
|
2952
|
-
this.telemetryReporter?.performance({
|
|
2953
|
-
stage: "three_ds_handoff",
|
|
2954
|
-
durationMs: completedAt - operationStartedAt,
|
|
2955
|
-
durationMode: "machine",
|
|
2956
|
-
provider,
|
|
2957
|
-
paymentMethodCategory: "card"
|
|
2958
|
-
});
|
|
2959
|
-
this.telemetryReporter?.terminal({
|
|
2960
|
-
outcome: "action_required",
|
|
2961
|
-
stage: "three_ds_handoff",
|
|
2962
|
-
provider,
|
|
2963
|
-
paymentMethodCategory: "card"
|
|
2964
|
-
});
|
|
2965
|
-
}
|
|
2966
|
-
} else if (threeDs) {
|
|
2967
|
-
const threeDsStartedAt = this.takeCardThreeDsAttempt(threeDs.attemptId, completedAt);
|
|
2968
|
-
if (threeDsStartedAt !== void 0) {
|
|
2969
|
-
if (threeDs.status === "returned") {
|
|
2970
|
-
this.telemetryReporter?.log({
|
|
2971
|
-
name: "payment.three_ds.returned",
|
|
2972
|
-
stage: "three_ds_return",
|
|
2973
|
-
provider,
|
|
2974
|
-
paymentMethodCategory: "card"
|
|
2975
|
-
});
|
|
2976
|
-
this.telemetryReporter?.performance({
|
|
2977
|
-
stage: "three_ds_return",
|
|
2978
|
-
durationMs: completedAt - threeDsStartedAt,
|
|
2979
|
-
durationMode: "machine",
|
|
2980
|
-
provider,
|
|
2981
|
-
paymentMethodCategory: "card"
|
|
2982
|
-
});
|
|
2983
|
-
} else {
|
|
2984
|
-
threeDsFailed = true;
|
|
2985
|
-
}
|
|
2986
|
-
}
|
|
2987
|
-
}
|
|
2988
|
-
if (isExpectedDecline(result.error)) {
|
|
2989
|
-
this.telemetryReporter?.terminal({
|
|
2990
|
-
outcome: "payment_declined",
|
|
2991
|
-
provider,
|
|
2992
|
-
paymentMethodCategory: "card"
|
|
2993
|
-
});
|
|
2994
|
-
} else if (result.error?.type === "validation_error") {
|
|
2995
|
-
this.telemetryReporter?.terminal({
|
|
2996
|
-
outcome: "validation_rejected",
|
|
2997
|
-
provider,
|
|
2998
|
-
paymentMethodCategory: "card"
|
|
2999
|
-
});
|
|
3000
|
-
} else if (result.error) {
|
|
3001
|
-
this.telemetryReporter?.error({
|
|
3002
|
-
errorCode: threeDsFailed ? "THREE_DS_FAILED" : "PROVIDER_RUNTIME_FAILED",
|
|
3003
|
-
stage: threeDsFailed ? "three_ds_return" : "processing",
|
|
3004
|
-
provider,
|
|
3005
|
-
paymentMethodCategory: "card"
|
|
3006
|
-
});
|
|
3007
|
-
} else if (!threeDsFailed && threeDs?.status !== "handoff" && result.status === "succeeded") {
|
|
3008
|
-
this.telemetryReporter?.terminal({
|
|
3009
|
-
outcome: "payment_succeeded",
|
|
3010
|
-
provider,
|
|
3011
|
-
paymentMethodCategory: "card"
|
|
3012
|
-
});
|
|
3013
|
-
}
|
|
3014
|
-
return result;
|
|
3015
|
-
} catch (error) {
|
|
3016
|
-
this.telemetryReporter?.error({
|
|
3017
|
-
errorCode: "PROVIDER_RUNTIME_FAILED",
|
|
3018
|
-
stage: "processing",
|
|
3019
|
-
provider,
|
|
3020
|
-
paymentMethodCategory: "card"
|
|
3021
|
-
});
|
|
3022
|
-
throw error;
|
|
3023
|
-
}
|
|
3024
|
-
}
|
|
3025
|
-
trackCardThreeDsAttempt(attemptId, startedAt) {
|
|
3026
|
-
this.pruneExpiredCardThreeDsAttempts(startedAt);
|
|
3027
|
-
if (this.cardThreeDsStartedAt.has(attemptId)) return false;
|
|
3028
|
-
while (this.cardThreeDsStartedAt.size >= MAX_CARD_THREE_DS_ATTEMPTS) {
|
|
3029
|
-
const oldestAttemptId = this.cardThreeDsStartedAt.keys().next().value;
|
|
3030
|
-
if (oldestAttemptId === void 0) break;
|
|
3031
|
-
this.cardThreeDsStartedAt.delete(oldestAttemptId);
|
|
3032
|
-
}
|
|
3033
|
-
this.cardThreeDsStartedAt.set(attemptId, startedAt);
|
|
3034
|
-
return true;
|
|
3035
|
-
}
|
|
3036
|
-
takeCardThreeDsAttempt(attemptId, now) {
|
|
3037
|
-
this.pruneExpiredCardThreeDsAttempts(now);
|
|
3038
|
-
const startedAt = this.cardThreeDsStartedAt.get(attemptId);
|
|
3039
|
-
if (startedAt !== void 0) this.cardThreeDsStartedAt.delete(attemptId);
|
|
3040
|
-
return startedAt;
|
|
3041
|
-
}
|
|
3042
|
-
pruneExpiredCardThreeDsAttempts(now) {
|
|
3043
|
-
for (const [attemptId, startedAt] of this.cardThreeDsStartedAt) {
|
|
3044
|
-
if (now - startedAt >= CARD_THREE_DS_ATTEMPT_TTL_MS) {
|
|
3045
|
-
this.cardThreeDsStartedAt.delete(attemptId);
|
|
3046
|
-
}
|
|
3047
|
-
}
|
|
3048
|
-
}
|
|
3049
2896
|
/**
|
|
3050
2897
|
* Create a {@link CardCaptureAdapter} for collecting card details through the
|
|
3051
|
-
* backend-rendered hosted vault PCI widget
|
|
3052
|
-
* card fields (TeamFloPay/backend#823).
|
|
2898
|
+
* backend-rendered hosted vault PCI widget (TeamFloPay/backend#823).
|
|
3053
2899
|
*
|
|
3054
2900
|
* The returned adapter injects the server-supplied widget HTML (the session's
|
|
3055
2901
|
* {@link CheckoutSession.vault} block, or one fetched via
|
|
@@ -3238,15 +3084,22 @@ var FloPay = class {
|
|
|
3238
3084
|
throw error;
|
|
3239
3085
|
}
|
|
3240
3086
|
}
|
|
3241
|
-
/** Confirms a payment using the mounted
|
|
3087
|
+
/** Confirms a non-card wallet/APM payment using the mounted PaymentElement. */
|
|
3242
3088
|
async confirmPayment(params) {
|
|
3089
|
+
if (params.paymentMethodCategory !== "wallet" && params.paymentMethodCategory !== "apm" || !params.paymentMethodType?.trim() || params.paymentMethodType.trim().toLowerCase() === "card") {
|
|
3090
|
+
throw new FloPayError5(
|
|
3091
|
+
"A supported non-card payment method is required.",
|
|
3092
|
+
"validation_error",
|
|
3093
|
+
{ param: "paymentMethodType" }
|
|
3094
|
+
);
|
|
3095
|
+
}
|
|
3243
3096
|
const started = this.now();
|
|
3244
3097
|
const provider = telemetryProvider(this.provider.name);
|
|
3245
3098
|
this.telemetryReporter?.log({
|
|
3246
3099
|
name: "payment.processing.started",
|
|
3247
3100
|
stage: "processing",
|
|
3248
3101
|
provider,
|
|
3249
|
-
paymentMethodCategory:
|
|
3102
|
+
paymentMethodCategory: params.paymentMethodCategory
|
|
3250
3103
|
});
|
|
3251
3104
|
try {
|
|
3252
3105
|
const result = await this.provider.confirmPayment(params);
|
|
@@ -3256,39 +3109,39 @@ var FloPay = class {
|
|
|
3256
3109
|
durationMs,
|
|
3257
3110
|
durationMode: "machine",
|
|
3258
3111
|
provider,
|
|
3259
|
-
paymentMethodCategory:
|
|
3112
|
+
paymentMethodCategory: params.paymentMethodCategory
|
|
3260
3113
|
});
|
|
3261
3114
|
if (result.status === "succeeded") {
|
|
3262
3115
|
this.telemetryReporter?.terminal({
|
|
3263
3116
|
outcome: "payment_succeeded",
|
|
3264
3117
|
provider,
|
|
3265
|
-
paymentMethodCategory:
|
|
3118
|
+
paymentMethodCategory: params.paymentMethodCategory
|
|
3266
3119
|
});
|
|
3267
3120
|
} else if (isExpectedDecline(result.error)) {
|
|
3268
3121
|
this.telemetryReporter?.terminal({
|
|
3269
3122
|
outcome: "payment_declined",
|
|
3270
3123
|
provider,
|
|
3271
|
-
paymentMethodCategory:
|
|
3124
|
+
paymentMethodCategory: params.paymentMethodCategory
|
|
3272
3125
|
});
|
|
3273
3126
|
} else if (result.error?.type === "validation_error") {
|
|
3274
3127
|
this.telemetryReporter?.terminal({
|
|
3275
3128
|
outcome: "validation_rejected",
|
|
3276
3129
|
provider,
|
|
3277
|
-
paymentMethodCategory:
|
|
3130
|
+
paymentMethodCategory: params.paymentMethodCategory
|
|
3278
3131
|
});
|
|
3279
3132
|
} else if (result.status === "requires_action") {
|
|
3280
3133
|
this.telemetryReporter?.terminal({
|
|
3281
3134
|
outcome: "action_required",
|
|
3282
3135
|
stage: "three_ds_handoff",
|
|
3283
3136
|
provider,
|
|
3284
|
-
paymentMethodCategory:
|
|
3137
|
+
paymentMethodCategory: params.paymentMethodCategory
|
|
3285
3138
|
});
|
|
3286
3139
|
} else if (result.status === "failed" && result.error) {
|
|
3287
3140
|
this.telemetryReporter?.error({
|
|
3288
3141
|
errorCode: "PAYMENT_PROCESSING_FAILED",
|
|
3289
3142
|
stage: "processing",
|
|
3290
3143
|
provider,
|
|
3291
|
-
paymentMethodCategory:
|
|
3144
|
+
paymentMethodCategory: params.paymentMethodCategory
|
|
3292
3145
|
});
|
|
3293
3146
|
}
|
|
3294
3147
|
if (result.status === "succeeded" || result.status === "failed") {
|
|
@@ -3296,14 +3149,14 @@ var FloPay = class {
|
|
|
3296
3149
|
name: "payment.processing.completed",
|
|
3297
3150
|
stage: "processing",
|
|
3298
3151
|
provider,
|
|
3299
|
-
paymentMethodCategory:
|
|
3152
|
+
paymentMethodCategory: params.paymentMethodCategory
|
|
3300
3153
|
});
|
|
3301
3154
|
} else {
|
|
3302
3155
|
this.telemetryReporter?.log({
|
|
3303
3156
|
name: "operation.state_transition",
|
|
3304
3157
|
stage: result.status === "requires_action" ? "three_ds_handoff" : "processing",
|
|
3305
3158
|
provider,
|
|
3306
|
-
paymentMethodCategory:
|
|
3159
|
+
paymentMethodCategory: params.paymentMethodCategory
|
|
3307
3160
|
});
|
|
3308
3161
|
}
|
|
3309
3162
|
return result;
|
|
@@ -3313,13 +3166,13 @@ var FloPay = class {
|
|
|
3313
3166
|
durationMs: this.now() - started,
|
|
3314
3167
|
durationMode: "machine",
|
|
3315
3168
|
provider,
|
|
3316
|
-
paymentMethodCategory:
|
|
3169
|
+
paymentMethodCategory: params.paymentMethodCategory
|
|
3317
3170
|
});
|
|
3318
3171
|
this.telemetryReporter?.error({
|
|
3319
3172
|
errorCode: "PAYMENT_PROCESSING_FAILED",
|
|
3320
3173
|
stage: "processing",
|
|
3321
3174
|
provider,
|
|
3322
|
-
paymentMethodCategory:
|
|
3175
|
+
paymentMethodCategory: params.paymentMethodCategory
|
|
3323
3176
|
});
|
|
3324
3177
|
throw error;
|
|
3325
3178
|
}
|
|
@@ -3449,7 +3302,6 @@ var FloPay = class {
|
|
|
3449
3302
|
destroy() {
|
|
3450
3303
|
this.telemetryReporter?.log({ name: "checkout.unmount", stage: "unmount" });
|
|
3451
3304
|
this.telemetryReporter?.destroy();
|
|
3452
|
-
this.cardThreeDsStartedAt.clear();
|
|
3453
3305
|
this.currentElements?.destroy();
|
|
3454
3306
|
this.currentElements = null;
|
|
3455
3307
|
this.provider.destroy();
|