@flopay/js 1.4.1 → 1.4.2

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