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