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