@flopay/js 1.3.4 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1,9 +1,7 @@
1
1
  "use strict";
2
- var __create = Object.create;
3
2
  var __defProp = Object.defineProperty;
4
3
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
- var __getProtoOf = Object.getPrototypeOf;
7
5
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
6
  var __export = (target, all) => {
9
7
  for (var name in all)
@@ -17,14 +15,6 @@ var __copyProps = (to, from, except, desc) => {
17
15
  }
18
16
  return to;
19
17
  };
20
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
- // If the importer is in node compatibility mode or this is not an ESM
22
- // file that has been converted to a CommonJS file using a Babel-
23
- // compatible transform (i.e. "__esModule" has not been set), then set
24
- // "default" to the CommonJS "module.exports" for node compatibility.
25
- isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
- mod
27
- ));
28
18
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
19
 
30
20
  // src/index.ts
@@ -45,10 +35,17 @@ __export(index_exports, {
45
35
  module.exports = __toCommonJS(index_exports);
46
36
 
47
37
  // src/load.ts
48
- var import_shared6 = require("@flopay/shared");
38
+ var import_shared7 = require("@flopay/shared");
49
39
 
50
40
  // src/stripe-adapter.ts
41
+ var import_stripe_js = require("@stripe/stripe-js");
51
42
  var import_shared = require("@flopay/shared");
43
+
44
+ // src/card-three-ds.ts
45
+ var CARD_THREE_DS_ATTEMPT_TTL_MS = 15 * 6e4;
46
+ var MAX_CARD_THREE_DS_ATTEMPTS = 32;
47
+
48
+ // src/stripe-adapter.ts
52
49
  function toStripeElementType(type) {
53
50
  const map = {
54
51
  payment: "payment",
@@ -71,6 +68,9 @@ function toStripeAppearanceTheme(theme) {
71
68
  return "stripe";
72
69
  }
73
70
  }
71
+ function cardThreeDsNow() {
72
+ return globalThis.performance?.now() ?? Date.now();
73
+ }
74
74
  function wrapStripeElement(stripeElement) {
75
75
  const el = stripeElement;
76
76
  return {
@@ -94,11 +94,31 @@ function wrapStripeElement(stripeElement) {
94
94
  }
95
95
  };
96
96
  }
97
+ function toStripeBillingDetails(billing) {
98
+ return {
99
+ billing_details: {
100
+ ...billing.email ? { email: billing.email } : {},
101
+ ...billing.name ? { name: billing.name } : {},
102
+ ...billing.address ? {
103
+ address: {
104
+ ...billing.address.country ? { country: billing.address.country } : {},
105
+ ...billing.address.postal_code ? { postal_code: billing.address.postal_code } : {},
106
+ ...billing.address.city ? { city: billing.address.city } : {},
107
+ ...billing.address.line1 ? { line1: billing.address.line1 } : {},
108
+ ...billing.address.line2 ? { line2: billing.address.line2 } : {},
109
+ ...billing.address.state ? { state: billing.address.state } : {}
110
+ }
111
+ } : {}
112
+ }
113
+ };
114
+ }
97
115
  var StripeAdapter = class {
98
116
  constructor() {
99
117
  this.name = "stripe";
100
118
  this.stripe = null;
101
119
  this.elements = null;
120
+ this.pendingCardThreeDsAttempts = /* @__PURE__ */ new Map();
121
+ this.cardThreeDsAttemptSequence = 0;
102
122
  // Serialized appearance currently applied to `this.elements`. Used to detect
103
123
  // when consumers swap themes mid-session so we can live-update the Stripe
104
124
  // Elements group instead of returning a stale-styled cache. `null` while no
@@ -109,17 +129,7 @@ var StripeAdapter = class {
109
129
  if (typeof window === "undefined") {
110
130
  return;
111
131
  }
112
- let loadStripe;
113
- try {
114
- ({ loadStripe } = await import("@stripe/stripe-js"));
115
- } catch {
116
- throw new import_shared.FloPayError(
117
- "Failed to load @stripe/stripe-js. Reinstall @flopay/js.",
118
- "api_error",
119
- { code: "StripeJsLoadFailed" }
120
- );
121
- }
122
- const stripe = await loadStripe(config.publishableKey, {
132
+ const stripe = await (0, import_stripe_js.loadStripe)(config.publishableKey, {
123
133
  locale: config.locale ?? "auto"
124
134
  });
125
135
  if (!stripe) {
@@ -230,22 +240,7 @@ var StripeAdapter = class {
230
240
  };
231
241
  }
232
242
  const cardNumberEl = this.elements.getElement("cardNumber");
233
- const stripeBilling = billingDetails ? {
234
- billing_details: {
235
- ...billingDetails.email ? { email: billingDetails.email } : {},
236
- ...billingDetails.name ? { name: billingDetails.name } : {},
237
- ...billingDetails.address ? {
238
- address: {
239
- ...billingDetails.address.country ? { country: billingDetails.address.country } : {},
240
- ...billingDetails.address.postal_code ? { postal_code: billingDetails.address.postal_code } : {},
241
- ...billingDetails.address.city ? { city: billingDetails.address.city } : {},
242
- ...billingDetails.address.line1 ? { line1: billingDetails.address.line1 } : {},
243
- ...billingDetails.address.line2 ? { line2: billingDetails.address.line2 } : {},
244
- ...billingDetails.address.state ? { state: billingDetails.address.state } : {}
245
- }
246
- } : {}
247
- }
248
- } : {};
243
+ const stripeBilling = billingDetails ? toStripeBillingDetails(billingDetails) : {};
249
244
  const { error, paymentMethod } = cardNumberEl ? await this.stripe.createPaymentMethod({
250
245
  type: "card",
251
246
  card: cardNumberEl,
@@ -279,41 +274,84 @@ var StripeAdapter = class {
279
274
  { payment_method: params.paymentMethodId }
280
275
  );
281
276
  if (setupError) {
282
- return {
277
+ return this.withCardThreeDsLifecycle(params, {
283
278
  status: "failed",
284
279
  error: new import_shared.FloPayError(
285
280
  setupError.message ?? "Payment failed",
286
281
  "api_error",
287
282
  { code: setupError.code, declineCode: setupError.decline_code }
288
283
  )
289
- };
284
+ });
290
285
  }
291
- return {
286
+ return this.withCardThreeDsLifecycle(params, {
292
287
  status: setupIntent?.status ?? "failed",
293
288
  paymentIntentId: setupIntent?.id,
294
289
  paymentMethodId: this.extractPaymentMethodId(setupIntent?.payment_method)
295
- };
290
+ });
296
291
  }
297
292
  const { error, paymentIntent } = await this.stripe.confirmCardPayment(
298
293
  params.clientSecret,
299
294
  { payment_method: params.paymentMethodId }
300
295
  );
301
296
  if (error) {
302
- return {
297
+ return this.withCardThreeDsLifecycle(params, {
303
298
  status: "failed",
304
299
  error: new import_shared.FloPayError(
305
300
  error.message ?? "Payment failed",
306
301
  "api_error",
307
302
  { code: error.code, declineCode: error.decline_code }
308
303
  )
309
- };
304
+ });
310
305
  }
311
- return {
306
+ return this.withCardThreeDsLifecycle(params, {
312
307
  status: paymentIntent?.status ?? "failed",
313
308
  paymentIntentId: paymentIntent?.id,
314
309
  paymentMethodId: this.extractPaymentMethodId(paymentIntent?.payment_method)
310
+ });
311
+ }
312
+ /**
313
+ * Bind provider-observed 3DS milestones to the exact confirmation context.
314
+ * The sensitive client secret/payment method pair stays only in this private,
315
+ * in-memory key; callers and telemetry receive an unrelated opaque id.
316
+ */
317
+ withCardThreeDsLifecycle(params, result) {
318
+ const contextKey = `${params.clientSecret}\0${params.paymentMethodId}`;
319
+ const now = cardThreeDsNow();
320
+ this.pruneExpiredCardThreeDsAttempts(now);
321
+ if (result.status === "requires_action") {
322
+ let attempt2 = this.pendingCardThreeDsAttempts.get(contextKey);
323
+ if (!attempt2) {
324
+ while (this.pendingCardThreeDsAttempts.size >= MAX_CARD_THREE_DS_ATTEMPTS) {
325
+ const oldestContextKey = this.pendingCardThreeDsAttempts.keys().next().value;
326
+ if (oldestContextKey === void 0) break;
327
+ this.pendingCardThreeDsAttempts.delete(oldestContextKey);
328
+ }
329
+ attempt2 = {
330
+ attemptId: `card_3ds_${this.cardThreeDsAttemptSequence++}`,
331
+ startedAt: now
332
+ };
333
+ this.pendingCardThreeDsAttempts.set(contextKey, attempt2);
334
+ }
335
+ return { ...result, threeDs: { attemptId: attempt2.attemptId, status: "handoff" } };
336
+ }
337
+ const attempt = this.pendingCardThreeDsAttempts.get(contextKey);
338
+ if (!attempt) return result;
339
+ this.pendingCardThreeDsAttempts.delete(contextKey);
340
+ return {
341
+ ...result,
342
+ threeDs: {
343
+ attemptId: attempt.attemptId,
344
+ status: result.error || result.status === "failed" ? "failed" : "returned"
345
+ }
315
346
  };
316
347
  }
348
+ pruneExpiredCardThreeDsAttempts(now) {
349
+ for (const [contextKey, attempt] of this.pendingCardThreeDsAttempts) {
350
+ if (now - attempt.startedAt >= CARD_THREE_DS_ATTEMPT_TTL_MS) {
351
+ this.pendingCardThreeDsAttempts.delete(contextKey);
352
+ }
353
+ }
354
+ }
317
355
  async confirmPayment(params) {
318
356
  if (!this.stripe || !this.elements) {
319
357
  throw new import_shared.FloPayError(
@@ -322,22 +360,7 @@ var StripeAdapter = class {
322
360
  );
323
361
  }
324
362
  const billing = params.billingDetails;
325
- const paymentMethodData = billing ? {
326
- billing_details: {
327
- ...billing.email ? { email: billing.email } : {},
328
- ...billing.name ? { name: billing.name } : {},
329
- ...billing.address ? {
330
- address: {
331
- ...billing.address.country ? { country: billing.address.country } : {},
332
- ...billing.address.postal_code ? { postal_code: billing.address.postal_code } : {},
333
- ...billing.address.city ? { city: billing.address.city } : {},
334
- ...billing.address.line1 ? { line1: billing.address.line1 } : {},
335
- ...billing.address.line2 ? { line2: billing.address.line2 } : {},
336
- ...billing.address.state ? { state: billing.address.state } : {}
337
- }
338
- } : {}
339
- }
340
- } : void 0;
363
+ const paymentMethodData = billing ? toStripeBillingDetails(billing) : void 0;
341
364
  const { error, paymentIntent } = await this.stripe.confirmPayment({
342
365
  elements: this.elements,
343
366
  clientSecret: params.clientSecret,
@@ -500,6 +523,7 @@ var StripeAdapter = class {
500
523
  return this.stripe.elements(elementsOptions);
501
524
  }
502
525
  destroy() {
526
+ this.pendingCardThreeDsAttempts.clear();
503
527
  this.elements = null;
504
528
  this.appliedAppearanceKey = null;
505
529
  this.stripe = null;
@@ -507,7 +531,7 @@ var StripeAdapter = class {
507
531
  };
508
532
 
509
533
  // src/flopay.ts
510
- var import_shared5 = require("@flopay/shared");
534
+ var import_shared6 = require("@flopay/shared");
511
535
 
512
536
  // src/elements.ts
513
537
  var import_shared2 = require("@flopay/shared");
@@ -559,7 +583,20 @@ var FloPayElements = class {
559
583
  };
560
584
 
561
585
  // src/payment-api.ts
562
- var import_shared3 = require("@flopay/shared");
586
+ var import_shared4 = require("@flopay/shared");
587
+
588
+ // src/api-error.ts
589
+ function readErrorString(value) {
590
+ return typeof value === "string" && value.trim() ? value : void 0;
591
+ }
592
+ function readErrorMessage(value) {
593
+ if (typeof value === "string") return value.trim() ? value : void 0;
594
+ if (Array.isArray(value)) {
595
+ const joined = value.filter((entry) => typeof entry === "string" && entry.trim().length > 0).join("; ");
596
+ return joined || void 0;
597
+ }
598
+ return void 0;
599
+ }
563
600
 
564
601
  // src/session-display-cache.ts
565
602
  var STORAGE_KEY_PREFIX = "flopay_session_display:";
@@ -627,6 +664,334 @@ function clearSessionDisplayData(sessionId) {
627
664
  }
628
665
  }
629
666
 
667
+ // src/telemetry-reporter.ts
668
+ var import_shared3 = require("@flopay/shared");
669
+ var TELEMETRY_PATH = "/v1/sdk-telemetry/events";
670
+ var MAX_BATCH_SIZE = 16;
671
+ var MAX_QUEUE_SIZE = 64;
672
+ var UPLOAD_TIMEOUT_MS = 1500;
673
+ var ERROR_DEDUPLICATION_WINDOW_MS = 1e3;
674
+ var MAX_REPORTED_FAILURES = 64;
675
+ var DEDUPLICATION_EVENT_ID = "00000000-0000-4000-8000-000000000000";
676
+ var EVENT_BUDGETS = {
677
+ technical_error: 8,
678
+ lifecycle: 32,
679
+ expected_outcome: 32,
680
+ performance: 24
681
+ };
682
+ function createUuidV4() {
683
+ try {
684
+ return globalThis.crypto.randomUUID();
685
+ } catch {
686
+ const bytes = new Uint8Array(16);
687
+ try {
688
+ globalThis.crypto.getRandomValues(bytes);
689
+ } catch {
690
+ for (let index = 0; index < bytes.length; index += 1) {
691
+ bytes[index] = Math.floor(Math.random() * 256);
692
+ }
693
+ }
694
+ bytes[6] = bytes[6] & 15 | 64;
695
+ bytes[8] = bytes[8] & 63 | 128;
696
+ const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
697
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
698
+ }
699
+ }
700
+ function bodyByteLength(body) {
701
+ try {
702
+ return new TextEncoder().encode(body).byteLength;
703
+ } catch {
704
+ return body.length;
705
+ }
706
+ }
707
+ function failureDeduplicationKey(event) {
708
+ return JSON.stringify([
709
+ event.code,
710
+ event.stage,
711
+ event.provider,
712
+ event.attempt,
713
+ event.statusClass,
714
+ event.requestCategory,
715
+ event.paymentMethodCategory,
716
+ event.checkoutMode,
717
+ event.layout
718
+ ]);
719
+ }
720
+ function telemetryNow() {
721
+ return globalThis.performance?.now() ?? 0;
722
+ }
723
+ var TelemetryReporter = class {
724
+ constructor(options) {
725
+ this.ingestionDisabled = false;
726
+ this.queue = [];
727
+ this.sequence = 0;
728
+ this.flushTimer = null;
729
+ this.flushInFlight = null;
730
+ this.reportedFailures = /* @__PURE__ */ new Map();
731
+ this.checkoutContext = {};
732
+ this.checkoutStartedAt = null;
733
+ this.destroyed = false;
734
+ this.pageExitHandler = () => {
735
+ this.drainQueue();
736
+ };
737
+ this.visibilityHandler = () => {
738
+ if (document.visibilityState === "hidden") void this.flush();
739
+ };
740
+ this.eventCounts = {
741
+ technical_error: 0,
742
+ lifecycle: 0,
743
+ expected_outcome: 0,
744
+ performance: 0
745
+ };
746
+ this.endpoint = `${options.billingApiUrl.replace(/\/+$/, "")}${TELEMETRY_PATH}`;
747
+ this.sdkPackage = options.sdkPackage ?? "@flopay/js";
748
+ this.sdkVersion = options.sdkVersion;
749
+ this.correlationId = createUuidV4();
750
+ this.merchantEnabled = options.enabled !== false;
751
+ this.clock = options.clock ?? telemetryNow;
752
+ this.browserTransportAvailable = typeof window !== "undefined" && typeof document !== "undefined";
753
+ if (this.browserTransportAvailable) {
754
+ window.addEventListener("pagehide", this.pageExitHandler);
755
+ document.addEventListener("visibilitychange", this.visibilityHandler);
756
+ }
757
+ }
758
+ log(input) {
759
+ if (!this.canCollect()) return;
760
+ this.enqueue((0, import_shared3.buildTelemetryLogEvent)({
761
+ ...this.checkoutContext,
762
+ ...input,
763
+ eventId: createUuidV4(),
764
+ sequence: this.sequence++
765
+ }));
766
+ }
767
+ error(input) {
768
+ if (!this.canCollect()) return;
769
+ const normalizedFailure = (0, import_shared3.buildTelemetryErrorEvent)({
770
+ ...this.checkoutContext,
771
+ ...input,
772
+ eventId: DEDUPLICATION_EVENT_ID,
773
+ sequence: 0
774
+ });
775
+ const deduplicationKey = failureDeduplicationKey(normalizedFailure);
776
+ const now = this.now();
777
+ this.pruneReportedFailures(now);
778
+ const previouslyReportedAt = this.reportedFailures.get(deduplicationKey);
779
+ if (previouslyReportedAt !== void 0 && now >= previouslyReportedAt && now - previouslyReportedAt < ERROR_DEDUPLICATION_WINDOW_MS) {
780
+ this.log({
781
+ name: "operation.deduplicated",
782
+ stage: input.stage,
783
+ provider: input.provider,
784
+ paymentMethodCategory: input.paymentMethodCategory,
785
+ attempt: input.attempt
786
+ });
787
+ return;
788
+ }
789
+ this.rememberReportedFailure(deduplicationKey, now);
790
+ this.enqueue((0, import_shared3.buildTelemetryErrorEvent)({
791
+ ...this.checkoutContext,
792
+ ...input,
793
+ eventId: createUuidV4(),
794
+ sequence: this.sequence++
795
+ }));
796
+ }
797
+ performance(input) {
798
+ if (!this.canCollect()) return;
799
+ this.enqueue((0, import_shared3.buildTelemetryPerformanceEvent)({
800
+ ...this.checkoutContext,
801
+ ...input,
802
+ eventId: createUuidV4(),
803
+ sequence: this.sequence++
804
+ }));
805
+ }
806
+ terminal(input) {
807
+ if (!this.canCollect()) return;
808
+ this.enqueue((0, import_shared3.buildTelemetryTerminalEvent)({
809
+ ...this.checkoutContext,
810
+ ...input,
811
+ eventId: createUuidV4(),
812
+ sequence: this.sequence++
813
+ }));
814
+ if (input.outcome !== "action_required" && this.checkoutStartedAt !== null) {
815
+ const checkoutStartedAt = this.checkoutStartedAt;
816
+ this.checkoutStartedAt = null;
817
+ this.performance({
818
+ stage: "total_journey",
819
+ durationMs: Math.max(0, this.now() - checkoutStartedAt),
820
+ durationMode: "total",
821
+ provider: input.provider,
822
+ paymentMethodCategory: input.paymentMethodCategory
823
+ });
824
+ }
825
+ }
826
+ /** @internal Read the reporter's monotonic clock without Resource Timing. */
827
+ now() {
828
+ try {
829
+ return this.clock();
830
+ } catch {
831
+ return telemetryNow();
832
+ }
833
+ }
834
+ pruneReportedFailures(now) {
835
+ for (const [key, reportedAt] of this.reportedFailures) {
836
+ if (now < reportedAt || now - reportedAt >= ERROR_DEDUPLICATION_WINDOW_MS) {
837
+ this.reportedFailures.delete(key);
838
+ }
839
+ }
840
+ }
841
+ rememberReportedFailure(key, reportedAt) {
842
+ while (this.reportedFailures.size >= MAX_REPORTED_FAILURES) {
843
+ const oldest = this.reportedFailures.keys().next();
844
+ if (oldest.done) break;
845
+ this.reportedFailures.delete(oldest.value);
846
+ }
847
+ this.reportedFailures.set(key, reportedAt);
848
+ }
849
+ /** @internal Add closed checkout dimensions to subsequent SDK events. */
850
+ setCheckoutContext(context) {
851
+ this.checkoutContext = {
852
+ checkoutMode: context.checkoutMode,
853
+ layout: context.layout
854
+ };
855
+ }
856
+ /** @internal Start a fresh checkout budget, dedupe window, and total span. */
857
+ beginCheckout(context = {}) {
858
+ if (!this.canCollect()) return 0;
859
+ this.drainQueue();
860
+ this.setCheckoutContext(context);
861
+ this.sequence = 0;
862
+ this.reportedFailures.clear();
863
+ this.eventCounts = {
864
+ technical_error: 0,
865
+ lifecycle: 0,
866
+ expected_outcome: 0,
867
+ performance: 0
868
+ };
869
+ this.checkoutStartedAt = this.now();
870
+ return this.checkoutStartedAt;
871
+ }
872
+ enqueue(event) {
873
+ if (!this.canCollect()) return;
874
+ if (this.queue.length >= MAX_QUEUE_SIZE || this.eventCounts[event.class] >= EVENT_BUDGETS[event.class]) return;
875
+ this.eventCounts[event.class] += 1;
876
+ this.queue.push(event);
877
+ if (this.queue.length >= MAX_BATCH_SIZE) {
878
+ void this.flush();
879
+ return;
880
+ }
881
+ this.scheduleFlush();
882
+ }
883
+ canCollect() {
884
+ return this.browserTransportAvailable && this.merchantEnabled && !this.ingestionDisabled && !this.destroyed;
885
+ }
886
+ /** Flush one bounded batch. Failures are intentionally dropped. */
887
+ async flush() {
888
+ if (this.flushInFlight) return this.flushInFlight;
889
+ if (!this.browserTransportAvailable || this.destroyed || this.ingestionDisabled || this.queue.length === 0) return;
890
+ this.clearFlushTimer();
891
+ const events = this.queue.splice(0, MAX_BATCH_SIZE);
892
+ this.flushInFlight = this.sendBatch(events).finally(() => {
893
+ this.flushInFlight = null;
894
+ if (this.queue.length > 0) this.scheduleFlush();
895
+ });
896
+ return this.flushInFlight;
897
+ }
898
+ /** Flush pending work and detach browser lifecycle listeners. */
899
+ destroy() {
900
+ if (this.destroyed) return;
901
+ this.drainQueue();
902
+ this.destroyed = true;
903
+ this.clearFlushTimer();
904
+ if (this.browserTransportAvailable) {
905
+ window.removeEventListener("pagehide", this.pageExitHandler);
906
+ document.removeEventListener("visibilitychange", this.visibilityHandler);
907
+ }
908
+ this.queue.splice(0);
909
+ this.reportedFailures.clear();
910
+ }
911
+ /** Permanently honor a merchant opt-out and discard queued events. */
912
+ disable() {
913
+ this.merchantEnabled = false;
914
+ this.queue.splice(0);
915
+ this.reportedFailures.clear();
916
+ this.clearFlushTimer();
917
+ }
918
+ /** Start every bounded keepalive request synchronously before page teardown. */
919
+ drainQueue() {
920
+ if (!this.browserTransportAvailable || this.destroyed || this.ingestionDisabled || this.queue.length === 0) return;
921
+ this.clearFlushTimer();
922
+ while (this.queue.length > 0) {
923
+ const events = this.queue.splice(0, MAX_BATCH_SIZE);
924
+ void this.sendBatch(events);
925
+ }
926
+ }
927
+ async sendBatch(events) {
928
+ if (!this.browserTransportAvailable || this.ingestionDisabled) return;
929
+ const body = (0, import_shared3.serializeTelemetryBatch)(events, {
930
+ correlationId: this.correlationId,
931
+ sdkPackage: this.sdkPackage,
932
+ sdkVersion: this.sdkVersion,
933
+ batchId: createUuidV4()
934
+ });
935
+ if (bodyByteLength(body) > import_shared3.TELEMETRY_MAX_BATCH_BYTES) return;
936
+ const controller = typeof AbortController === "undefined" ? null : new AbortController();
937
+ let timeout = null;
938
+ try {
939
+ const request = fetch(this.endpoint, {
940
+ method: "POST",
941
+ headers: { "content-type": "text/plain;charset=UTF-8" },
942
+ body,
943
+ credentials: "omit",
944
+ keepalive: true,
945
+ referrerPolicy: "no-referrer",
946
+ signal: controller?.signal
947
+ }).then(async (response) => {
948
+ if (response.status !== 202) return null;
949
+ const payload = await response.json().catch(() => null);
950
+ return payload?.status === "disabled" ? "disabled" : null;
951
+ }).catch(() => null);
952
+ const expired = new Promise((resolve) => {
953
+ timeout = setTimeout(() => {
954
+ controller?.abort();
955
+ resolve(null);
956
+ }, UPLOAD_TIMEOUT_MS);
957
+ });
958
+ const status = await Promise.race([request, expired]);
959
+ if (status === "disabled") this.disableFromIngestion();
960
+ } catch {
961
+ } finally {
962
+ if (timeout) clearTimeout(timeout);
963
+ }
964
+ }
965
+ disableFromIngestion() {
966
+ this.ingestionDisabled = true;
967
+ this.queue.splice(0);
968
+ this.reportedFailures.clear();
969
+ this.clearFlushTimer();
970
+ }
971
+ scheduleFlush() {
972
+ if (this.flushTimer || this.destroyed || this.ingestionDisabled) return;
973
+ this.flushTimer = setTimeout(() => {
974
+ this.flushTimer = null;
975
+ void this.flush();
976
+ }, 0);
977
+ }
978
+ clearFlushTimer() {
979
+ if (!this.flushTimer) return;
980
+ clearTimeout(this.flushTimer);
981
+ this.flushTimer = null;
982
+ }
983
+ };
984
+ var TELEMETRY_REPORTER_FACTORY = /* @__PURE__ */ Symbol.for("@flopay/js.telemetry.reporter-factory.v1");
985
+ var telemetryGlobal = globalThis;
986
+ if (telemetryGlobal[TELEMETRY_REPORTER_FACTORY] === void 0) {
987
+ Object.defineProperty(telemetryGlobal, TELEMETRY_REPORTER_FACTORY, {
988
+ configurable: true,
989
+ enumerable: false,
990
+ writable: false,
991
+ value: (options) => new TelemetryReporter(options)
992
+ });
993
+ }
994
+
630
995
  // src/payment-api.ts
631
996
  var DEFAULT_PROCESSING_RETRY_AFTER_MS = 1e3;
632
997
  var MIN_PROCESSING_RETRY_AFTER_MS = 500;
@@ -636,18 +1001,30 @@ var DEFAULT_ACCOUNT_SNAPSHOT_TIMEOUT_MS = 1e4;
636
1001
  function isRecord(value) {
637
1002
  return typeof value === "object" && value !== null;
638
1003
  }
1004
+ function telemetryStatusClass(status) {
1005
+ if (status === void 0) return "network_error";
1006
+ const statusClass = `${Math.floor(status / 100)}xx`;
1007
+ return statusClass === "2xx" || statusClass === "3xx" || statusClass === "4xx" || statusClass === "5xx" ? statusClass : "unknown";
1008
+ }
1009
+ function telemetryFailure(error, fallbackCode) {
1010
+ if (error instanceof Error && (error.name === "AbortError" || error instanceof import_shared4.FloPayError && error.code === "checkout_processing_timeout")) {
1011
+ return { errorCode: "REQUEST_TIMEOUT", statusClass: "timeout" };
1012
+ }
1013
+ if (error instanceof TypeError) {
1014
+ return { errorCode: "NETWORK_REQUEST_FAILED", statusClass: "network_error" };
1015
+ }
1016
+ return {
1017
+ errorCode: fallbackCode,
1018
+ statusClass: telemetryStatusClass(
1019
+ error instanceof import_shared4.FloPayError ? error.statusCode : void 0
1020
+ )
1021
+ };
1022
+ }
639
1023
  function readString(payload, key) {
640
- const value = payload?.[key];
641
- return typeof value === "string" && value.trim() ? value : void 0;
1024
+ return readErrorString(payload?.[key]);
642
1025
  }
643
1026
  function readMessage(payload, key) {
644
- const value = payload?.[key];
645
- if (typeof value === "string") return value.trim() ? value : void 0;
646
- if (Array.isArray(value)) {
647
- const joined = value.filter((entry) => typeof entry === "string" && entry.trim().length > 0).join("; ");
648
- return joined || void 0;
649
- }
650
- return void 0;
1027
+ return readErrorMessage(payload?.[key]);
651
1028
  }
652
1029
  function readNumber(payload, key) {
653
1030
  const value = payload?.[key];
@@ -657,7 +1034,7 @@ function delay(ms) {
657
1034
  return new Promise((resolve) => setTimeout(resolve, ms));
658
1035
  }
659
1036
  function createCheckoutProcessingTimeoutError() {
660
- return new import_shared3.FloPayError(
1037
+ return new import_shared4.FloPayError(
661
1038
  "Checkout is still processing. Please try again shortly.",
662
1039
  "api_error",
663
1040
  { code: "checkout_processing_timeout" }
@@ -668,14 +1045,14 @@ async function buildApiErrorFromResponse(response, fallbackMessage) {
668
1045
  const nestedError = isRecord(payload?.error) ? payload.error : null;
669
1046
  const message = readMessage(payload, "message") ?? readMessage(nestedError, "message") ?? fallbackMessage;
670
1047
  const code = readString(payload, "code") ?? readString(payload, "gatewayErrorCode") ?? readString(nestedError, "code") ?? `http_${response.status}`;
671
- return new import_shared3.FloPayError(message, "api_error", {
1048
+ return new import_shared4.FloPayError(message, "api_error", {
672
1049
  code,
673
1050
  statusCode: response.status
674
1051
  });
675
1052
  }
676
1053
  var NETWORK_RETRY_ATTEMPTS = 2;
677
1054
  var IDEMPOTENCY_IN_PROGRESS_RETRY_ATTEMPTS = 2;
678
- async function fetchWithNetworkRetry(input, init, attempts = NETWORK_RETRY_ATTEMPTS) {
1055
+ async function fetchWithNetworkRetry(input, init, attempts = NETWORK_RETRY_ATTEMPTS, onRetry) {
679
1056
  let lastErr;
680
1057
  for (let attempt = 0; ; attempt++) {
681
1058
  try {
@@ -684,13 +1061,59 @@ async function fetchWithNetworkRetry(input, init, attempts = NETWORK_RETRY_ATTEM
684
1061
  if (err instanceof Error && err.name === "AbortError") throw err;
685
1062
  lastErr = err;
686
1063
  if (attempt >= attempts) throw lastErr;
1064
+ try {
1065
+ onRetry?.(attempt + 1);
1066
+ } catch {
1067
+ }
687
1068
  await delay(150 * 2 ** attempt);
688
1069
  }
689
1070
  }
690
1071
  }
1072
+ function isPaymentApiTelemetryHooks(value) {
1073
+ return "now" in value || "onFirstByte" in value || "onSessionCreateFailure" in value || "onRetry" in value;
1074
+ }
691
1075
  var PaymentAPI = class {
692
- constructor(billingApiUrl) {
1076
+ constructor(billingApiUrl, telemetryOptionsOrHooks = {}) {
693
1077
  this.baseUrl = billingApiUrl.replace(/\/+$/, "");
1078
+ const hasInternalHooks = isPaymentApiTelemetryHooks(telemetryOptionsOrHooks);
1079
+ this.telemetryHooks = hasInternalHooks ? telemetryOptionsOrHooks : void 0;
1080
+ this.directTelemetry = hasInternalHooks || telemetryOptionsOrHooks.telemetry === false ? void 0 : new TelemetryReporter({
1081
+ billingApiUrl: this.baseUrl,
1082
+ sdkVersion: import_shared4.SDK_VERSION
1083
+ });
1084
+ }
1085
+ /** Dispose the reporter owned by direct public usage. Internal hooks are never disposed here. */
1086
+ destroy() {
1087
+ this.directTelemetry?.destroy();
1088
+ }
1089
+ reportDirectFailure(error, fallbackCode, stage, requestCategory, paymentMethodCategory = "unknown") {
1090
+ const failure = telemetryFailure(error, fallbackCode);
1091
+ this.directTelemetry?.error({
1092
+ ...failure,
1093
+ stage,
1094
+ requestCategory,
1095
+ paymentMethodCategory
1096
+ });
1097
+ }
1098
+ telemetryTimestamp() {
1099
+ try {
1100
+ return this.telemetryHooks?.now?.() ?? this.directTelemetry?.now() ?? telemetryNow();
1101
+ } catch {
1102
+ return telemetryNow();
1103
+ }
1104
+ }
1105
+ beginDirectTelemetryCheckout(checkoutSessionId) {
1106
+ if (!this.directTelemetry || this.directTelemetryCheckoutId === checkoutSessionId) return;
1107
+ this.directTelemetryCheckoutId = checkoutSessionId;
1108
+ this.directTelemetry.beginCheckout();
1109
+ }
1110
+ beginDirectTelemetryOperation() {
1111
+ if (!this.directTelemetry) return;
1112
+ this.directTelemetryCheckoutId = void 0;
1113
+ this.directTelemetry.beginCheckout();
1114
+ }
1115
+ adoptDirectTelemetryCheckout(checkoutSessionId) {
1116
+ if (checkoutSessionId) this.directTelemetryCheckoutId = checkoutSessionId;
694
1117
  }
695
1118
  /**
696
1119
  * Fetch a raw checkout session by ID.
@@ -702,17 +1125,77 @@ var PaymentAPI = class {
702
1125
  * Backends that don't yet enforce it ignore the extra header.
703
1126
  */
704
1127
  async getCheckoutSession(checkoutSessionId, nonce) {
705
- const headers = { [import_shared3.FLO_SDK_VERSION_HEADER]: import_shared3.SDK_VERSION };
1128
+ this.beginDirectTelemetryCheckout(checkoutSessionId);
1129
+ const requestStarted = this.telemetryTimestamp();
1130
+ this.directTelemetry?.log({
1131
+ name: "session.read.started",
1132
+ stage: "session_read",
1133
+ requestCategory: "session_read"
1134
+ });
1135
+ const headers = { [import_shared4.FLO_SDK_VERSION_HEADER]: import_shared4.SDK_VERSION };
706
1136
  if (nonce) headers["x-checkout-session-token"] = nonce;
707
- const response = await fetchWithNetworkRetry(
708
- `${this.baseUrl}/v1/checkouts/sessions/${encodeURIComponent(checkoutSessionId)}`,
709
- { headers }
710
- );
711
- if (!response.ok) {
712
- throw await buildApiErrorFromResponse(response, "Failed to get checkout session");
1137
+ try {
1138
+ const response = await fetchWithNetworkRetry(
1139
+ `${this.baseUrl}/v1/checkouts/sessions/${encodeURIComponent(checkoutSessionId)}`,
1140
+ { headers },
1141
+ NETWORK_RETRY_ATTEMPTS,
1142
+ (attempt) => {
1143
+ this.telemetryHooks?.onRetry?.("session_read", attempt);
1144
+ this.directTelemetry?.log({
1145
+ name: "operation.retry",
1146
+ stage: "session_read",
1147
+ requestCategory: "session_read",
1148
+ attempt
1149
+ });
1150
+ }
1151
+ );
1152
+ const firstByteDuration = Math.max(0, this.telemetryTimestamp() - requestStarted);
1153
+ try {
1154
+ this.telemetryHooks?.onFirstByte?.(firstByteDuration);
1155
+ } catch {
1156
+ }
1157
+ const statusClass = `${Math.floor(response.status / 100)}xx`;
1158
+ this.directTelemetry?.log({
1159
+ name: "session.request.first_byte",
1160
+ stage: "session_first_byte",
1161
+ requestCategory: "session_read",
1162
+ statusClass
1163
+ });
1164
+ this.directTelemetry?.performance({
1165
+ stage: "session_first_byte",
1166
+ durationMs: firstByteDuration,
1167
+ durationMode: "machine",
1168
+ requestCategory: "session_read",
1169
+ statusClass
1170
+ });
1171
+ if (!response.ok) {
1172
+ throw await buildApiErrorFromResponse(response, "Failed to get checkout session");
1173
+ }
1174
+ const body = await response.json();
1175
+ this.directTelemetry?.log({
1176
+ name: "session.request.completed",
1177
+ stage: "session_complete",
1178
+ requestCategory: "session_read",
1179
+ statusClass
1180
+ });
1181
+ this.directTelemetry?.performance({
1182
+ stage: "session_complete",
1183
+ durationMs: Math.max(0, this.telemetryTimestamp() - requestStarted),
1184
+ durationMode: "machine",
1185
+ requestCategory: "session_read",
1186
+ statusClass
1187
+ });
1188
+ return { ...body, data: this.mergeCachedDisplayData(body.data) };
1189
+ } catch (error) {
1190
+ const statusCode = error instanceof import_shared4.FloPayError ? error.statusCode : void 0;
1191
+ this.directTelemetry?.error({
1192
+ errorCode: error instanceof import_shared4.FloPayError && error.code === "checkout_processing_timeout" ? "REQUEST_TIMEOUT" : "NETWORK_REQUEST_FAILED",
1193
+ stage: "session_read",
1194
+ requestCategory: "session_read",
1195
+ statusClass: statusCode ? `${Math.floor(statusCode / 100)}xx` : "network_error"
1196
+ });
1197
+ throw error;
713
1198
  }
714
- const body = await response.json();
715
- return { ...body, data: this.mergeCachedDisplayData(body.data) };
716
1199
  }
717
1200
  /**
718
1201
  * Stash display-only data for a session so subsequent fetches can fill in
@@ -768,17 +1251,55 @@ var PaymentAPI = class {
768
1251
  * backends, matched against the session's stored nonce).
769
1252
  */
770
1253
  async getVaultCapture(checkoutSessionId, nonce) {
1254
+ this.beginDirectTelemetryCheckout(checkoutSessionId);
1255
+ const startedAt = this.telemetryTimestamp();
1256
+ this.directTelemetry?.log({
1257
+ name: "vault.capture.requested",
1258
+ stage: "vault_request",
1259
+ requestCategory: "vault_capture",
1260
+ paymentMethodCategory: "card"
1261
+ });
771
1262
  const headers = { "Content-Type": "application/json" };
772
1263
  if (nonce) headers["x-checkout-session-token"] = nonce;
773
- const response = await fetchWithNetworkRetry(
774
- `${this.baseUrl}/v1/checkouts/sessions/${encodeURIComponent(checkoutSessionId)}/vault/capture`,
775
- { method: "POST", headers }
776
- );
777
- if (!response.ok) {
778
- throw await buildApiErrorFromResponse(response, "Failed to load the secure card form");
1264
+ try {
1265
+ const response = await fetchWithNetworkRetry(
1266
+ `${this.baseUrl}/v1/checkouts/sessions/${encodeURIComponent(checkoutSessionId)}/vault/capture`,
1267
+ { method: "POST", headers },
1268
+ NETWORK_RETRY_ATTEMPTS,
1269
+ (attempt) => {
1270
+ this.telemetryHooks?.onRetry?.("vault_capture", attempt);
1271
+ this.directTelemetry?.log({
1272
+ name: "operation.retry",
1273
+ stage: "vault_request",
1274
+ requestCategory: "vault_capture",
1275
+ paymentMethodCategory: "card",
1276
+ attempt
1277
+ });
1278
+ }
1279
+ );
1280
+ if (!response.ok) {
1281
+ throw await buildApiErrorFromResponse(response, "Failed to load the secure card form");
1282
+ }
1283
+ const block = await response.json();
1284
+ this.directTelemetry?.performance({
1285
+ stage: "vault_request",
1286
+ durationMs: Math.max(0, this.telemetryTimestamp() - startedAt),
1287
+ durationMode: "machine",
1288
+ requestCategory: "vault_capture",
1289
+ paymentMethodCategory: "card",
1290
+ statusClass: "2xx"
1291
+ });
1292
+ return this.toVaultBlock(block);
1293
+ } catch (error) {
1294
+ this.reportDirectFailure(
1295
+ error,
1296
+ "VAULT_LOAD_FAILED",
1297
+ "vault_request",
1298
+ "vault_capture",
1299
+ "card"
1300
+ );
1301
+ throw error;
779
1302
  }
780
- const block = await response.json();
781
- return this.toVaultBlock(block);
782
1303
  }
783
1304
  /**
784
1305
  * Fetch and normalize a checkout session.
@@ -814,26 +1335,88 @@ var PaymentAPI = class {
814
1335
  * future major version.
815
1336
  */
816
1337
  async processPayment(_userId, data, options) {
1338
+ this.beginDirectTelemetryCheckout(data.sessionId);
817
1339
  if (!data.nonce) {
818
- throw new import_shared3.FloPayError(
1340
+ this.directTelemetry?.terminal({
1341
+ outcome: "validation_rejected",
1342
+ stage: "processing",
1343
+ requestCategory: "process_payment"
1344
+ });
1345
+ throw new import_shared4.FloPayError(
819
1346
  "processPayment requires `nonce` \u2014 pass the value returned from session creation.",
820
1347
  "validation_error",
821
1348
  { code: "MissingCheckoutSessionToken", param: "nonce" }
822
1349
  );
823
1350
  }
1351
+ const startedAt = this.telemetryTimestamp();
1352
+ this.directTelemetry?.log({
1353
+ name: "payment.processing.started",
1354
+ stage: "processing",
1355
+ requestCategory: "process_payment"
1356
+ });
824
1357
  const { nonce, ...processBody } = data;
825
- const response = await fetch(
826
- `${this.baseUrl}/v1/checkouts/sessions/${encodeURIComponent(data.sessionId)}/process`,
827
- {
828
- method: "POST",
829
- headers: {
830
- "Content-Type": "application/json",
831
- "x-checkout-session-token": nonce
832
- },
833
- body: JSON.stringify(processBody)
1358
+ let response;
1359
+ try {
1360
+ response = await fetch(
1361
+ `${this.baseUrl}/v1/checkouts/sessions/${encodeURIComponent(data.sessionId)}/process`,
1362
+ {
1363
+ method: "POST",
1364
+ headers: {
1365
+ "Content-Type": "application/json",
1366
+ "x-checkout-session-token": nonce
1367
+ },
1368
+ body: JSON.stringify(processBody)
1369
+ }
1370
+ );
1371
+ } catch (error) {
1372
+ this.reportDirectFailure(
1373
+ error,
1374
+ "PAYMENT_PROCESSING_FAILED",
1375
+ "processing",
1376
+ "process_payment"
1377
+ );
1378
+ throw error;
1379
+ }
1380
+ if (!response.ok && response.status !== 202) {
1381
+ this.directTelemetry?.error({
1382
+ errorCode: "PAYMENT_PROCESSING_FAILED",
1383
+ stage: "processing",
1384
+ requestCategory: "process_payment",
1385
+ statusClass: telemetryStatusClass(response.status)
1386
+ });
1387
+ return response;
1388
+ }
1389
+ try {
1390
+ const result = await this.resolveProcessResponse(
1391
+ response,
1392
+ data.sessionId,
1393
+ { ...options, nonce }
1394
+ );
1395
+ this.directTelemetry?.log({
1396
+ name: "payment.processing.completed",
1397
+ stage: "processing",
1398
+ requestCategory: "process_payment",
1399
+ statusClass: telemetryStatusClass(result.status)
1400
+ });
1401
+ this.directTelemetry?.performance({
1402
+ stage: "processing",
1403
+ durationMs: Math.max(0, this.telemetryTimestamp() - startedAt),
1404
+ durationMode: "machine",
1405
+ requestCategory: "process_payment",
1406
+ statusClass: telemetryStatusClass(result.status)
1407
+ });
1408
+ return result;
1409
+ } catch (error) {
1410
+ if (!(error instanceof import_shared4.FloPayError && error.code === "checkout_processing_timeout")) {
1411
+ this.reportDirectFailure(
1412
+ error,
1413
+ "PAYMENT_PROCESSING_FAILED",
1414
+ "processing",
1415
+ "process_payment"
1416
+ );
834
1417
  }
835
- );
836
- return this.resolveProcessResponse(response, data.sessionId, { ...options, nonce });
1418
+ throw error;
1419
+ }
837
1420
  }
838
1421
  /**
839
1422
  * Patch the buyer's account snapshot (email, name, billing address, AVS
@@ -857,6 +1440,13 @@ var PaymentAPI = class {
857
1440
  * AVS-protected charge to decline downstream.
858
1441
  */
859
1442
  async patchAccountSnapshot(sessionId, nonce, body, options) {
1443
+ this.beginDirectTelemetryCheckout(sessionId);
1444
+ const startedAt = this.telemetryTimestamp();
1445
+ this.directTelemetry?.log({
1446
+ name: "operation.state_transition",
1447
+ stage: "processing",
1448
+ requestCategory: "account_snapshot"
1449
+ });
860
1450
  const timeoutMs = options?.timeoutMs ?? DEFAULT_ACCOUNT_SNAPSHOT_TIMEOUT_MS;
861
1451
  const controller = new AbortController();
862
1452
  const onCallerAbort = () => controller.abort();
@@ -865,26 +1455,53 @@ var PaymentAPI = class {
865
1455
  else options.signal.addEventListener("abort", onCallerAbort, { once: true });
866
1456
  }
867
1457
  const timer = setTimeout(() => controller.abort(), timeoutMs);
868
- let response;
869
1458
  try {
870
- response = await fetchWithNetworkRetry(
871
- `${this.baseUrl}/v1/checkouts/sessions/${encodeURIComponent(sessionId)}/account`,
872
- {
873
- method: "PATCH",
874
- headers: {
875
- "Content-Type": "application/json",
876
- "x-checkout-session-token": nonce
1459
+ let response;
1460
+ try {
1461
+ response = await fetchWithNetworkRetry(
1462
+ `${this.baseUrl}/v1/checkouts/sessions/${encodeURIComponent(sessionId)}/account`,
1463
+ {
1464
+ method: "PATCH",
1465
+ headers: {
1466
+ "Content-Type": "application/json",
1467
+ "x-checkout-session-token": nonce
1468
+ },
1469
+ body: JSON.stringify(body),
1470
+ signal: controller.signal
877
1471
  },
878
- body: JSON.stringify(body),
879
- signal: controller.signal
880
- }
1472
+ NETWORK_RETRY_ATTEMPTS,
1473
+ (attempt) => {
1474
+ this.telemetryHooks?.onRetry?.("account_snapshot", attempt);
1475
+ this.directTelemetry?.log({
1476
+ name: "operation.retry",
1477
+ stage: "processing",
1478
+ requestCategory: "account_snapshot",
1479
+ attempt
1480
+ });
1481
+ }
1482
+ );
1483
+ } finally {
1484
+ clearTimeout(timer);
1485
+ options?.signal?.removeEventListener("abort", onCallerAbort);
1486
+ }
1487
+ if (!response.ok) {
1488
+ throw await buildApiErrorFromResponse(response, "Failed to persist account snapshot");
1489
+ }
1490
+ this.directTelemetry?.performance({
1491
+ stage: "processing",
1492
+ durationMs: Math.max(0, this.telemetryTimestamp() - startedAt),
1493
+ durationMode: "machine",
1494
+ requestCategory: "account_snapshot",
1495
+ statusClass: "2xx"
1496
+ });
1497
+ } catch (error) {
1498
+ this.reportDirectFailure(
1499
+ error,
1500
+ "NETWORK_REQUEST_FAILED",
1501
+ "processing",
1502
+ "account_snapshot"
881
1503
  );
882
- } finally {
883
- clearTimeout(timer);
884
- options?.signal?.removeEventListener("abort", onCallerAbort);
885
- }
886
- if (!response.ok) {
887
- throw await buildApiErrorFromResponse(response, "Failed to persist account snapshot");
1504
+ throw error;
888
1505
  }
889
1506
  }
890
1507
  /**
@@ -906,22 +1523,62 @@ var PaymentAPI = class {
906
1523
  * passing the concrete payment method id / type.
907
1524
  */
908
1525
  async createPaymentIntent(sessionId, email, paymentMethodType, options) {
1526
+ this.beginDirectTelemetryCheckout(sessionId);
1527
+ const startedAt = this.telemetryTimestamp();
1528
+ this.directTelemetry?.log({
1529
+ name: "payment.intent.started",
1530
+ stage: "processing",
1531
+ requestCategory: "intent_create"
1532
+ });
909
1533
  const headers = { "Content-Type": "application/json" };
910
1534
  if (options?.nonce) headers["x-checkout-session-token"] = options.nonce;
911
- return fetch(
912
- `${this.baseUrl}/v1/checkouts/payments/intents`,
913
- {
914
- method: "POST",
915
- headers,
916
- body: JSON.stringify({
917
- sessionId,
918
- email,
919
- paymentMethodType: paymentMethodType ?? null,
920
- isPaypal: options?.isPaypal ?? false
921
- }),
922
- signal: options?.signal
1535
+ try {
1536
+ const response = await fetch(
1537
+ `${this.baseUrl}/v1/checkouts/payments/intents`,
1538
+ {
1539
+ method: "POST",
1540
+ headers,
1541
+ body: JSON.stringify({
1542
+ sessionId,
1543
+ email,
1544
+ paymentMethodType: paymentMethodType ?? null,
1545
+ isPaypal: options?.isPaypal ?? false
1546
+ }),
1547
+ signal: options?.signal
1548
+ }
1549
+ );
1550
+ if (!response.ok) {
1551
+ this.directTelemetry?.error({
1552
+ errorCode: "PAYMENT_PROCESSING_FAILED",
1553
+ stage: "processing",
1554
+ requestCategory: "intent_create",
1555
+ statusClass: telemetryStatusClass(response.status)
1556
+ });
1557
+ return response;
923
1558
  }
924
- );
1559
+ this.directTelemetry?.log({
1560
+ name: "payment.intent.completed",
1561
+ stage: "processing",
1562
+ requestCategory: "intent_create",
1563
+ statusClass: telemetryStatusClass(response.status)
1564
+ });
1565
+ this.directTelemetry?.performance({
1566
+ stage: "processing",
1567
+ durationMs: Math.max(0, this.telemetryTimestamp() - startedAt),
1568
+ durationMode: "machine",
1569
+ requestCategory: "intent_create",
1570
+ statusClass: telemetryStatusClass(response.status)
1571
+ });
1572
+ return response;
1573
+ } catch (error) {
1574
+ this.reportDirectFailure(
1575
+ error,
1576
+ "PAYMENT_PROCESSING_FAILED",
1577
+ "processing",
1578
+ "intent_create"
1579
+ );
1580
+ throw error;
1581
+ }
925
1582
  }
926
1583
  /**
927
1584
  * Create a SetupIntent for saving payment methods.
@@ -930,23 +1587,71 @@ var PaymentAPI = class {
930
1587
  * required by post-#640 backends, ignored by earlier versions.
931
1588
  */
932
1589
  async createSetupIntent(sessionId, email, paymentMethodType, options) {
1590
+ this.beginDirectTelemetryCheckout(sessionId);
1591
+ const startedAt = this.telemetryTimestamp();
1592
+ this.directTelemetry?.log({
1593
+ name: "payment.intent.started",
1594
+ stage: "processing",
1595
+ requestCategory: "intent_create"
1596
+ });
933
1597
  const headers = { "Content-Type": "application/json" };
934
1598
  if (options?.nonce) headers["x-checkout-session-token"] = options.nonce;
935
- return fetch(
936
- `${this.baseUrl}/v1/checkouts/payments/setup-intents`,
937
- {
938
- method: "POST",
939
- headers,
940
- body: JSON.stringify({ sessionId, email, paymentMethodType }),
941
- signal: options?.signal
1599
+ try {
1600
+ const response = await fetch(
1601
+ `${this.baseUrl}/v1/checkouts/payments/setup-intents`,
1602
+ {
1603
+ method: "POST",
1604
+ headers,
1605
+ body: JSON.stringify({ sessionId, email, paymentMethodType }),
1606
+ signal: options?.signal
1607
+ }
1608
+ );
1609
+ if (!response.ok) {
1610
+ this.directTelemetry?.error({
1611
+ errorCode: "PAYMENT_PROCESSING_FAILED",
1612
+ stage: "processing",
1613
+ requestCategory: "intent_create",
1614
+ statusClass: telemetryStatusClass(response.status)
1615
+ });
1616
+ return response;
942
1617
  }
943
- );
1618
+ this.directTelemetry?.log({
1619
+ name: "payment.intent.completed",
1620
+ stage: "processing",
1621
+ requestCategory: "intent_create",
1622
+ statusClass: telemetryStatusClass(response.status)
1623
+ });
1624
+ this.directTelemetry?.performance({
1625
+ stage: "processing",
1626
+ durationMs: Math.max(0, this.telemetryTimestamp() - startedAt),
1627
+ durationMode: "machine",
1628
+ requestCategory: "intent_create",
1629
+ statusClass: telemetryStatusClass(response.status)
1630
+ });
1631
+ return response;
1632
+ } catch (error) {
1633
+ this.reportDirectFailure(
1634
+ error,
1635
+ "PAYMENT_PROCESSING_FAILED",
1636
+ "processing",
1637
+ "intent_create"
1638
+ );
1639
+ throw error;
1640
+ }
944
1641
  }
945
1642
  /**
946
1643
  * Fetch user's prior payments by email.
947
1644
  * Used to determine if saved card UX should be shown.
948
1645
  */
949
1646
  async getPaymentsByEmail(email, options) {
1647
+ this.beginDirectTelemetryOperation();
1648
+ const startedAt = this.telemetryTimestamp();
1649
+ this.directTelemetry?.log({
1650
+ name: "operation.recovery.started",
1651
+ stage: "recovery",
1652
+ requestCategory: "other",
1653
+ paymentMethodCategory: "saved"
1654
+ });
950
1655
  const page = options?.page ?? 1;
951
1656
  const limit = options?.limit ?? 1;
952
1657
  const params = new URLSearchParams({
@@ -956,18 +1661,43 @@ var PaymentAPI = class {
956
1661
  sortField: "createdAt",
957
1662
  sortDirection: "DESC"
958
1663
  });
959
- const response = await fetch(
960
- `${this.baseUrl}/v1/payments?${params.toString()}`,
961
- {
962
- method: "GET",
963
- signal: options?.signal,
964
- keepalive: true
1664
+ try {
1665
+ const response = await fetch(
1666
+ `${this.baseUrl}/v1/payments?${params.toString()}`,
1667
+ {
1668
+ method: "GET",
1669
+ signal: options?.signal,
1670
+ keepalive: true
1671
+ }
1672
+ );
1673
+ if (!response.ok) {
1674
+ throw new import_shared4.FloPayError(
1675
+ "Failed to fetch payments",
1676
+ "api_error",
1677
+ { statusCode: response.status }
1678
+ );
965
1679
  }
966
- );
967
- if (!response.ok) {
968
- throw new import_shared3.FloPayError("Failed to fetch payments", "api_error");
1680
+ const result = await response.json();
1681
+ this.directTelemetry?.log({
1682
+ name: "operation.recovery.completed",
1683
+ stage: "recovery",
1684
+ requestCategory: "other",
1685
+ paymentMethodCategory: "saved",
1686
+ statusClass: "2xx"
1687
+ });
1688
+ this.directTelemetry?.performance({
1689
+ stage: "recovery",
1690
+ durationMs: Math.max(0, this.telemetryTimestamp() - startedAt),
1691
+ durationMode: "machine",
1692
+ requestCategory: "other",
1693
+ paymentMethodCategory: "saved",
1694
+ statusClass: "2xx"
1695
+ });
1696
+ return result;
1697
+ } catch (error) {
1698
+ this.reportDirectFailure(error, "RECOVERY_FAILED", "recovery", "other", "saved");
1699
+ throw error;
969
1700
  }
970
- return response.json();
971
1701
  }
972
1702
  /**
973
1703
  * Create a checkout session AND return the full session data in one call.
@@ -977,15 +1707,65 @@ var PaymentAPI = class {
977
1707
  * Falls back to create + GET if the backend doesn't support `expand`.
978
1708
  */
979
1709
  async createAndFetchSession(params) {
980
- const wireProducts = params.products ?? (0, import_shared3.foldIntoProducts)(params.items, params.subscriptions);
981
- const sessionCurrency = (0, import_shared3.resolveSessionCurrency)(
1710
+ this.beginDirectTelemetryOperation();
1711
+ const startedAt = this.telemetryTimestamp();
1712
+ this.directTelemetry?.log({
1713
+ name: "session.create.started",
1714
+ stage: "session_create",
1715
+ requestCategory: "session_create"
1716
+ });
1717
+ try {
1718
+ const result = await this.createAndFetchSessionRequest(params, startedAt);
1719
+ this.adoptDirectTelemetryCheckout(result.data.session?.id);
1720
+ this.directTelemetry?.log({
1721
+ name: "session.request.completed",
1722
+ stage: "session_complete",
1723
+ requestCategory: "session_create",
1724
+ statusClass: "2xx"
1725
+ });
1726
+ this.directTelemetry?.performance({
1727
+ stage: "session_create",
1728
+ durationMs: this.telemetryTimestamp() - startedAt,
1729
+ durationMode: "machine",
1730
+ requestCategory: "session_create",
1731
+ statusClass: "2xx"
1732
+ });
1733
+ return result;
1734
+ } catch (error) {
1735
+ if (!(error instanceof import_shared4.FloPayError && error.code === "session_auto_completed")) {
1736
+ try {
1737
+ this.telemetryHooks?.onSessionCreateFailure?.(error);
1738
+ } catch {
1739
+ }
1740
+ }
1741
+ if (error instanceof import_shared4.FloPayError && error.type === "validation_error") {
1742
+ this.directTelemetry?.terminal({
1743
+ outcome: "validation_rejected",
1744
+ stage: "session_create",
1745
+ requestCategory: "session_create"
1746
+ });
1747
+ } else if (!(error instanceof import_shared4.FloPayError && error.code === "session_auto_completed")) {
1748
+ const statusCode = error instanceof import_shared4.FloPayError ? error.statusCode : void 0;
1749
+ this.directTelemetry?.error({
1750
+ errorCode: error instanceof Error && error.name === "AbortError" ? "REQUEST_TIMEOUT" : error instanceof TypeError ? "NETWORK_REQUEST_FAILED" : "CHECKOUT_SESSION_CREATE_FAILED",
1751
+ stage: "session_create",
1752
+ requestCategory: "session_create",
1753
+ statusClass: error instanceof Error && error.name === "AbortError" ? "timeout" : statusCode ? `${Math.floor(statusCode / 100)}xx` : "network_error"
1754
+ });
1755
+ }
1756
+ throw error;
1757
+ }
1758
+ }
1759
+ async createAndFetchSessionRequest(params, telemetryStartedAt) {
1760
+ const wireProducts = params.products ?? (0, import_shared4.foldIntoProducts)(params.items, params.subscriptions);
1761
+ const sessionCurrency = (0, import_shared4.resolveSessionCurrency)(
982
1762
  params.currency,
983
1763
  params.items,
984
1764
  params.subscriptions,
985
1765
  wireProducts
986
1766
  );
987
1767
  if (!sessionCurrency) {
988
- throw new import_shared3.FloPayError(
1768
+ throw new import_shared4.FloPayError(
989
1769
  "currency is required: pass `currency` on the session, or include a `currency` on the first item/subscription/product.",
990
1770
  "validation_error",
991
1771
  { code: "CurrencyRequired", param: "currency" }
@@ -993,12 +1773,12 @@ var PaymentAPI = class {
993
1773
  }
994
1774
  const payload = {
995
1775
  clientId: params.clientId,
996
- checkoutVersion: import_shared3.SDK_VERSION,
1776
+ checkoutVersion: import_shared4.SDK_VERSION,
997
1777
  successUrl: params.successUrl,
998
1778
  cancelUrl: params.cancelUrl,
999
1779
  currency: sessionCurrency,
1000
1780
  checkoutMode: params.checkoutMode ?? "full",
1001
- products: wireProducts.map((product) => (0, import_shared3.buildProductPayload)(product, sessionCurrency)),
1781
+ products: wireProducts.map((product) => (0, import_shared4.buildProductPayload)(product, sessionCurrency)),
1002
1782
  accountData: {
1003
1783
  userId: params.account.userId,
1004
1784
  firstName: params.account.firstName ?? null,
@@ -1026,13 +1806,14 @@ var PaymentAPI = class {
1026
1806
  // Declare the SDK version so backends at TeamFloPay/backend#823 embed
1027
1807
  // the hosted vault capture block (`body.vault`) in the response for
1028
1808
  // SDKs ≥ 1.3.0. Older backends ignore the header.
1029
- [import_shared3.FLO_SDK_VERSION_HEADER]: import_shared3.SDK_VERSION
1809
+ [import_shared4.FLO_SDK_VERSION_HEADER]: import_shared4.SDK_VERSION
1030
1810
  };
1031
- const idempotencyKey = (0, import_shared3.resolveIdempotencyKey)(params.idempotencyKey);
1811
+ const idempotencyKey = (0, import_shared4.resolveIdempotencyKey)(params.idempotencyKey);
1032
1812
  if (idempotencyKey) {
1033
- headers[import_shared3.IDEMPOTENCY_KEY_HEADER] = idempotencyKey;
1813
+ headers[import_shared4.IDEMPOTENCY_KEY_HEADER] = idempotencyKey;
1034
1814
  }
1035
1815
  let response;
1816
+ let firstByteReported = false;
1036
1817
  for (let attempt = 0; ; attempt++) {
1037
1818
  response = await fetchWithNetworkRetry(
1038
1819
  `${this.baseUrl}/v1/checkouts/sessions?expand=true`,
@@ -1040,10 +1821,37 @@ var PaymentAPI = class {
1040
1821
  method: "POST",
1041
1822
  headers,
1042
1823
  body: JSON.stringify(payload)
1824
+ },
1825
+ NETWORK_RETRY_ATTEMPTS,
1826
+ (networkAttempt) => {
1827
+ this.telemetryHooks?.onRetry?.("session_create", networkAttempt);
1828
+ this.directTelemetry?.log({
1829
+ name: "operation.retry",
1830
+ stage: "session_create",
1831
+ requestCategory: "session_create",
1832
+ attempt: networkAttempt
1833
+ });
1043
1834
  }
1044
1835
  );
1836
+ if (!firstByteReported) {
1837
+ firstByteReported = true;
1838
+ const statusClass = `${Math.floor(response.status / 100)}xx`;
1839
+ this.directTelemetry?.log({
1840
+ name: "session.request.first_byte",
1841
+ stage: "session_first_byte",
1842
+ requestCategory: "session_create",
1843
+ statusClass
1844
+ });
1845
+ this.directTelemetry?.performance({
1846
+ stage: "session_first_byte",
1847
+ durationMs: this.telemetryTimestamp() - telemetryStartedAt,
1848
+ durationMode: "machine",
1849
+ requestCategory: "session_create",
1850
+ statusClass
1851
+ });
1852
+ }
1045
1853
  if (response.status === 204) {
1046
- throw new import_shared3.FloPayError(
1854
+ throw new import_shared4.FloPayError(
1047
1855
  "Session auto-completed \u2014 payment method already on file",
1048
1856
  "api_error",
1049
1857
  { code: "session_auto_completed" }
@@ -1051,7 +1859,17 @@ var PaymentAPI = class {
1051
1859
  }
1052
1860
  if (response.ok) break;
1053
1861
  const error = await buildApiErrorFromResponse(response, "Failed to create checkout session");
1054
- if (error.code === import_shared3.IDEMPOTENCY_IN_PROGRESS_CODE && attempt < IDEMPOTENCY_IN_PROGRESS_RETRY_ATTEMPTS) {
1862
+ if (error.code === import_shared4.IDEMPOTENCY_IN_PROGRESS_CODE && attempt < IDEMPOTENCY_IN_PROGRESS_RETRY_ATTEMPTS) {
1863
+ try {
1864
+ this.telemetryHooks?.onRetry?.("session_create", attempt + 1);
1865
+ this.directTelemetry?.log({
1866
+ name: "operation.retry",
1867
+ stage: "session_create",
1868
+ requestCategory: "session_create",
1869
+ attempt: attempt + 1
1870
+ });
1871
+ } catch {
1872
+ }
1055
1873
  await delay(150 * 2 ** attempt);
1056
1874
  continue;
1057
1875
  }
@@ -1074,9 +1892,10 @@ var PaymentAPI = class {
1074
1892
  }
1075
1893
  const uuid = body.data?.uuid;
1076
1894
  if (!uuid) {
1077
- throw new import_shared3.FloPayError("No session ID returned", "api_error");
1895
+ throw new import_shared4.FloPayError("No session ID returned", "api_error");
1078
1896
  }
1079
1897
  this.autoCacheDisplayData(uuid, params);
1898
+ this.adoptDirectTelemetryCheckout(uuid);
1080
1899
  const unifiedSession = await this.getUnifiedCheckoutSession(uuid);
1081
1900
  return {
1082
1901
  ...unifiedSession,
@@ -1086,31 +1905,78 @@ var PaymentAPI = class {
1086
1905
  };
1087
1906
  }
1088
1907
  async waitForCheckoutSessionCompletion(checkoutSessionId, options) {
1908
+ this.beginDirectTelemetryCheckout(checkoutSessionId);
1909
+ const startedAt = this.telemetryTimestamp();
1910
+ this.directTelemetry?.log({
1911
+ name: "operation.recovery.started",
1912
+ stage: "recovery",
1913
+ requestCategory: "session_read",
1914
+ paymentMethodCategory: "saved"
1915
+ });
1089
1916
  const timeoutMs = options?.timeoutMs ?? DEFAULT_PROCESSING_TIMEOUT_MS;
1090
1917
  const deadline = Date.now() + timeoutMs;
1091
1918
  let nextDelayMs = this.clampRetryAfterMs(options?.initialDelayMs ?? DEFAULT_PROCESSING_RETRY_AFTER_MS);
1092
- while (true) {
1093
- const remainingMs = deadline - Date.now();
1094
- if (remainingMs <= 0) {
1095
- throw createCheckoutProcessingTimeoutError();
1096
- }
1097
- if (nextDelayMs > 0) {
1098
- await delay(Math.min(nextDelayMs, remainingMs));
1919
+ let pollAttempt = 0;
1920
+ try {
1921
+ while (true) {
1922
+ const remainingMs = deadline - Date.now();
1923
+ if (remainingMs <= 0) {
1924
+ throw createCheckoutProcessingTimeoutError();
1925
+ }
1926
+ if (nextDelayMs > 0) {
1927
+ try {
1928
+ pollAttempt += 1;
1929
+ this.telemetryHooks?.onRetry?.("session_read", pollAttempt);
1930
+ this.directTelemetry?.log({
1931
+ name: "operation.retry",
1932
+ stage: "recovery",
1933
+ requestCategory: "session_read",
1934
+ paymentMethodCategory: "saved",
1935
+ attempt: pollAttempt
1936
+ });
1937
+ } catch {
1938
+ }
1939
+ await delay(Math.min(nextDelayMs, remainingMs));
1940
+ if (Date.now() >= deadline) {
1941
+ throw createCheckoutProcessingTimeoutError();
1942
+ }
1943
+ }
1944
+ const session = await this.getUnifiedCheckoutSession(checkoutSessionId, options?.nonce);
1945
+ const status = session.data.session?.status;
1946
+ if (status === "complete" || status === "expired") {
1947
+ this.directTelemetry?.log({
1948
+ name: "operation.recovery.completed",
1949
+ stage: "recovery",
1950
+ requestCategory: "session_read",
1951
+ paymentMethodCategory: "saved"
1952
+ });
1953
+ this.directTelemetry?.performance({
1954
+ stage: "recovery",
1955
+ durationMs: Math.max(0, this.telemetryTimestamp() - startedAt),
1956
+ durationMode: "machine",
1957
+ requestCategory: "session_read",
1958
+ paymentMethodCategory: "saved"
1959
+ });
1960
+ return session;
1961
+ }
1099
1962
  if (Date.now() >= deadline) {
1100
1963
  throw createCheckoutProcessingTimeoutError();
1101
1964
  }
1965
+ nextDelayMs = this.clampRetryAfterMs(
1966
+ Math.max(nextDelayMs * 2, MIN_PROCESSING_RETRY_AFTER_MS)
1967
+ );
1102
1968
  }
1103
- const session = await this.getUnifiedCheckoutSession(checkoutSessionId, options?.nonce);
1104
- const status = session.data.session?.status;
1105
- if (status === "complete" || status === "expired") {
1106
- return session;
1107
- }
1108
- if (Date.now() >= deadline) {
1109
- throw createCheckoutProcessingTimeoutError();
1969
+ } catch (error) {
1970
+ if (error instanceof import_shared4.FloPayError && error.code === "checkout_processing_timeout") {
1971
+ this.reportDirectFailure(
1972
+ error,
1973
+ "RECOVERY_FAILED",
1974
+ "recovery",
1975
+ "session_read",
1976
+ "saved"
1977
+ );
1110
1978
  }
1111
- nextDelayMs = this.clampRetryAfterMs(
1112
- Math.max(nextDelayMs * 2, MIN_PROCESSING_RETRY_AFTER_MS)
1113
- );
1979
+ throw error;
1114
1980
  }
1115
1981
  }
1116
1982
  /** Normalize a raw session into a provider-agnostic shape. */
@@ -1242,7 +2108,7 @@ var PaymentAPI = class {
1242
2108
  return new Response(null, { status: 204, statusText: "No Content" });
1243
2109
  }
1244
2110
  if (session.data.session?.status === "expired") {
1245
- throw new import_shared3.FloPayError(
2111
+ throw new import_shared4.FloPayError(
1246
2112
  "Checkout session has expired.",
1247
2113
  "api_error",
1248
2114
  { code: "checkout_session_expired" }
@@ -1277,12 +2143,12 @@ var PaymentAPI = class {
1277
2143
  */
1278
2144
  autoCacheDisplayData(sessionId, params) {
1279
2145
  if (!sessionId) return;
1280
- const products = params.products ?? (0, import_shared3.foldIntoProducts)(params.items, params.subscriptions);
2146
+ const products = params.products ?? (0, import_shared4.foldIntoProducts)(params.items, params.subscriptions);
1281
2147
  if (products.length === 0 && !params.currency) {
1282
2148
  return;
1283
2149
  }
1284
2150
  const usingUnifiedProducts = params.products !== void 0;
1285
- const sessionCurrency = (0, import_shared3.resolveSessionCurrency)(
2151
+ const sessionCurrency = (0, import_shared4.resolveSessionCurrency)(
1286
2152
  params.currency,
1287
2153
  usingUnifiedProducts ? void 0 : params.items,
1288
2154
  usingUnifiedProducts ? void 0 : params.subscriptions,
@@ -1331,13 +2197,79 @@ var PaymentAPI = class {
1331
2197
  };
1332
2198
  }
1333
2199
  };
2200
+ function createInstrumentedPaymentAPI(billingApiUrl, hooks) {
2201
+ const InstrumentedPaymentAPI = PaymentAPI;
2202
+ return new InstrumentedPaymentAPI(billingApiUrl, hooks);
2203
+ }
1334
2204
 
1335
2205
  // src/pci-vault-card-capture.ts
1336
- var import_shared4 = require("@flopay/shared");
2206
+ var import_shared5 = require("@flopay/shared");
1337
2207
  var VAULT_MESSAGE_SOURCE = "flopay-vault";
1338
- function addBreadcrumb(message, data) {
1339
- const sentry = globalThis.Sentry;
1340
- sentry?.addBreadcrumb?.({ category: "flopay.card-capture", level: "info", message, data });
2208
+ var VAULT_NON_TERMINAL_OUTCOME_LOGS = {
2209
+ ready: ["vault.widget.ready", "vault_ready"],
2210
+ submitting: ["vault.submission.started", "vault_submit"],
2211
+ blocked: ["operation.state_transition", "vault_submit"],
2212
+ action_required: ["vault.action.required", "three_ds_handoff"]
2213
+ };
2214
+ function addBreadcrumb(event) {
2215
+ const data = {
2216
+ class: event.class,
2217
+ stage: event.stage
2218
+ };
2219
+ if ("code" in event) data["code"] = event.code;
2220
+ if ("provider" in event && event.provider) data["provider"] = event.provider;
2221
+ if ("paymentMethodCategory" in event && event.paymentMethodCategory) {
2222
+ data["paymentMethodCategory"] = event.paymentMethodCategory;
2223
+ }
2224
+ if ("outcome" in event) data["outcome"] = event.outcome;
2225
+ if ("durationMs" in event && event.durationMs !== void 0) data["durationMs"] = event.durationMs;
2226
+ if ("durationMode" in event && event.durationMode) data["durationMode"] = event.durationMode;
2227
+ try {
2228
+ const sentry = globalThis.Sentry;
2229
+ sentry?.addBreadcrumb?.({
2230
+ category: "flopay.telemetry",
2231
+ level: event.class === "technical_error" ? "error" : "info",
2232
+ message: event.class === "lifecycle" ? event.name : event.class === "technical_error" ? event.code : event.class === "expected_outcome" ? event.outcome : "sdk.performance",
2233
+ data
2234
+ });
2235
+ } catch {
2236
+ }
2237
+ }
2238
+ function vaultLog(name, stage) {
2239
+ return (0, import_shared5.buildTelemetryLogEvent)({
2240
+ eventId: "11111111-1111-4111-8111-111111111111",
2241
+ name,
2242
+ stage,
2243
+ sequence: 0,
2244
+ provider: "pcivault",
2245
+ paymentMethodCategory: "card"
2246
+ });
2247
+ }
2248
+ function vaultErrorClassification(submissionStarted) {
2249
+ return submissionStarted ? { errorCode: "VAULT_SUBMIT_FAILED", stage: "vault_submit" } : { errorCode: "VAULT_LOAD_FAILED", stage: "vault_mount" };
2250
+ }
2251
+ function vaultOutcomeBreadcrumb(type, submissionStarted) {
2252
+ if (type === "complete" || type === "decline") {
2253
+ return (0, import_shared5.buildTelemetryTerminalEvent)({
2254
+ eventId: "22222222-2222-4222-8222-222222222222",
2255
+ outcome: type === "complete" ? "payment_succeeded" : "payment_declined",
2256
+ sequence: 0,
2257
+ provider: "pcivault",
2258
+ paymentMethodCategory: "card"
2259
+ });
2260
+ }
2261
+ if (type === "error") {
2262
+ const classification = vaultErrorClassification(submissionStarted);
2263
+ return (0, import_shared5.buildTelemetryErrorEvent)({
2264
+ eventId: "33333333-3333-4333-8333-333333333333",
2265
+ ...classification,
2266
+ sequence: 0,
2267
+ provider: "pcivault",
2268
+ paymentMethodCategory: "card"
2269
+ });
2270
+ }
2271
+ const [name, stage] = VAULT_NON_TERMINAL_OUTCOME_LOGS[type];
2272
+ return vaultLog(name, stage);
1341
2273
  }
1342
2274
  function isVaultResultMessage(value) {
1343
2275
  if (typeof value !== "object" || value === null) return false;
@@ -1355,7 +2287,7 @@ function isVaultResizeMessage(value) {
1355
2287
  return record["source"] === VAULT_MESSAGE_SOURCE && record["type"] === "resize" && typeof record["height"] === "number" && Number.isFinite(record["height"]);
1356
2288
  }
1357
2289
  var PciVaultCardCapture = class {
1358
- constructor(config = {}) {
2290
+ constructor(config = {}, internalTelemetry) {
1359
2291
  this.provider = "pcivault";
1360
2292
  this.container = null;
1361
2293
  this.messageHandler = null;
@@ -1387,19 +2319,42 @@ var PciVaultCardCapture = class {
1387
2319
  /** Latest card-field order + autofocus directive to push into the widget. */
1388
2320
  this.cardFieldOrder = null;
1389
2321
  this.cardAutoFocus = true;
2322
+ this.captureRequestedAt = 0;
2323
+ this.vaultReadyReported = false;
2324
+ this.submissionStarted = false;
2325
+ this.submissionStartedAt = null;
1390
2326
  this.listeners = /* @__PURE__ */ new Map();
1391
2327
  this.config = config;
2328
+ this.ownsTelemetryReporter = !internalTelemetry && config.telemetry !== false;
2329
+ this.telemetryReporter = internalTelemetry?.reporter ?? (this.ownsTelemetryReporter ? new TelemetryReporter({
2330
+ billingApiUrl: (0, import_shared5.resolveBillingApiUrl)(),
2331
+ sdkVersion: import_shared5.SDK_VERSION
2332
+ }) : void 0);
2333
+ this.requestedAt = internalTelemetry?.requestedAt;
1392
2334
  }
1393
2335
  async mount(container, options) {
2336
+ if (this.ownsTelemetryReporter && !this.telemetryReporter) {
2337
+ this.telemetryReporter = new TelemetryReporter({
2338
+ billingApiUrl: (0, import_shared5.resolveBillingApiUrl)(),
2339
+ sdkVersion: import_shared5.SDK_VERSION
2340
+ });
2341
+ }
2342
+ const mountedAt = this.telemetryReporter?.now?.() ?? telemetryNow();
2343
+ this.captureRequestedAt = this.requestedAt ?? mountedAt;
2344
+ this.vaultReadyReported = false;
2345
+ this.submissionStarted = false;
2346
+ this.submissionStartedAt = null;
1394
2347
  if (typeof window === "undefined" || typeof document === "undefined") {
1395
- throw new import_shared4.FloPayError(
2348
+ this.reportVaultLoadFailure();
2349
+ throw new import_shared5.FloPayError(
1396
2350
  "The vault card form is only available in the browser.",
1397
2351
  "api_error",
1398
2352
  { code: "card_capture_no_window" }
1399
2353
  );
1400
2354
  }
1401
2355
  if (!options?.html || !options.html.trim()) {
1402
- throw new import_shared4.FloPayError(
2356
+ this.reportVaultLoadFailure();
2357
+ throw new import_shared5.FloPayError(
1403
2358
  "No vault capture widget HTML was provided to mount the secure card form.",
1404
2359
  "api_error",
1405
2360
  { code: "card_capture_no_widget_html" }
@@ -1409,14 +2364,33 @@ var PciVaultCardCapture = class {
1409
2364
  this.messageToken = options.messageToken ?? null;
1410
2365
  this.expectedOrigin = options.expectedOrigin ?? this.config.expectedOrigin ?? null;
1411
2366
  this.theme = options.theme ?? null;
1412
- this.attachMessageListener();
1413
- this.injectWidget(container, options.html);
2367
+ try {
2368
+ this.attachMessageListener();
2369
+ this.injectWidget(container, options.html);
2370
+ } catch (error) {
2371
+ this.reportVaultLoadFailure();
2372
+ throw error;
2373
+ }
1414
2374
  this.postTheme();
1415
2375
  this.postSubmitGate();
1416
2376
  this.postCardFieldOrder();
1417
- addBreadcrumb("vault widget mounted", { sessionId: this.config.sessionId });
2377
+ addBreadcrumb(vaultLog("vault.widget.mounted", "vault_mount"));
2378
+ this.telemetryReporter?.log({
2379
+ name: "vault.widget.mounted",
2380
+ stage: "vault_mount",
2381
+ provider: "pcivault",
2382
+ paymentMethodCategory: "card"
2383
+ });
1418
2384
  this.emit("ready", { sessionId: this.config.sessionId });
1419
2385
  }
2386
+ reportVaultLoadFailure() {
2387
+ this.telemetryReporter?.error({
2388
+ errorCode: "VAULT_LOAD_FAILED",
2389
+ stage: "vault_mount",
2390
+ provider: "pcivault",
2391
+ paymentMethodCategory: "card"
2392
+ });
2393
+ }
1420
2394
  on(event, handler) {
1421
2395
  let set = this.listeners.get(event);
1422
2396
  if (!set) {
@@ -1440,6 +2414,10 @@ var PciVaultCardCapture = class {
1440
2414
  }
1441
2415
  this.messageToken = null;
1442
2416
  this.expectedOrigin = null;
2417
+ if (this.ownsTelemetryReporter) {
2418
+ this.telemetryReporter?.destroy();
2419
+ this.telemetryReporter = void 0;
2420
+ }
1443
2421
  }
1444
2422
  // ── internals ──
1445
2423
  /**
@@ -1493,10 +2471,12 @@ var PciVaultCardCapture = class {
1493
2471
  message: data.message,
1494
2472
  nextActionRedirectUrl: data.nextActionRedirectUrl
1495
2473
  };
1496
- addBreadcrumb(`vault widget ${data.type}`, {
1497
- sessionId: outcome.sessionId,
1498
- declineReason: outcome.declineReason
1499
- });
2474
+ if (data.type === "submitting") {
2475
+ this.submissionStarted = true;
2476
+ this.submissionStartedAt = this.telemetryReporter?.now?.() ?? telemetryNow();
2477
+ }
2478
+ addBreadcrumb(vaultOutcomeBreadcrumb(data.type, this.submissionStarted));
2479
+ this.reportOutcome(data.type);
1500
2480
  if (data.type === "ready") {
1501
2481
  this.postTheme();
1502
2482
  this.postSubmitGate();
@@ -1513,6 +2493,88 @@ var PciVaultCardCapture = class {
1513
2493
  this.messageHandler = handler;
1514
2494
  window.addEventListener("message", handler);
1515
2495
  }
2496
+ reportOutcome(type) {
2497
+ const reporter = this.telemetryReporter;
2498
+ if (!reporter) return;
2499
+ if (type === "ready" && !this.vaultReadyReported) {
2500
+ this.vaultReadyReported = true;
2501
+ reporter.performance({
2502
+ stage: "vault_ready",
2503
+ durationMs: Math.max(0, reporter.now() - this.captureRequestedAt),
2504
+ durationMode: "machine",
2505
+ provider: "pcivault",
2506
+ paymentMethodCategory: "card"
2507
+ });
2508
+ }
2509
+ if (type === "complete" || type === "decline") {
2510
+ reporter.log({
2511
+ name: "vault.terminal",
2512
+ stage: "completion",
2513
+ provider: "pcivault",
2514
+ paymentMethodCategory: "card"
2515
+ });
2516
+ reporter.terminal({
2517
+ outcome: type === "complete" ? "payment_succeeded" : "payment_declined",
2518
+ provider: "pcivault",
2519
+ paymentMethodCategory: "card"
2520
+ });
2521
+ return;
2522
+ }
2523
+ if (type === "error") {
2524
+ const classification = vaultErrorClassification(this.submissionStarted);
2525
+ reporter.log({
2526
+ name: "vault.terminal",
2527
+ stage: classification.stage,
2528
+ provider: "pcivault",
2529
+ paymentMethodCategory: "card"
2530
+ });
2531
+ reporter.error({
2532
+ ...classification,
2533
+ provider: "pcivault",
2534
+ paymentMethodCategory: "card"
2535
+ });
2536
+ return;
2537
+ }
2538
+ if (type === "action_required") {
2539
+ reporter.log({
2540
+ name: "vault.action.required",
2541
+ stage: "three_ds_handoff",
2542
+ provider: "pcivault",
2543
+ paymentMethodCategory: "card"
2544
+ });
2545
+ reporter.log({
2546
+ name: "vault.three_ds.handoff",
2547
+ stage: "three_ds_handoff",
2548
+ provider: "pcivault",
2549
+ paymentMethodCategory: "card"
2550
+ });
2551
+ const submissionStartedAt = this.submissionStartedAt;
2552
+ this.submissionStartedAt = null;
2553
+ if (submissionStartedAt !== null) {
2554
+ reporter.performance({
2555
+ stage: "three_ds_handoff",
2556
+ durationMs: Math.max(0, reporter.now() - submissionStartedAt),
2557
+ durationMode: "machine",
2558
+ provider: "pcivault",
2559
+ paymentMethodCategory: "card"
2560
+ });
2561
+ }
2562
+ reporter.terminal({
2563
+ outcome: "action_required",
2564
+ stage: "three_ds_handoff",
2565
+ provider: "pcivault",
2566
+ paymentMethodCategory: "card"
2567
+ });
2568
+ return;
2569
+ }
2570
+ const [name, stage] = VAULT_NON_TERMINAL_OUTCOME_LOGS[type];
2571
+ reporter.log({
2572
+ name,
2573
+ stage,
2574
+ provider: "pcivault",
2575
+ paymentMethodCategory: "card"
2576
+ });
2577
+ }
1516
2578
  /**
1517
2579
  * Push merchant theme colors into the hosted widget (live). The host calls
1518
2580
  * this on a runtime theme switch; the widget applies them to its CSS variables
@@ -1640,12 +2702,43 @@ var PciVaultCardCapture = class {
1640
2702
  ].join(";");
1641
2703
  frame.src = challengeUrl;
1642
2704
  backdrop.appendChild(frame);
2705
+ const closeButton = document.createElement("button");
2706
+ closeButton.type = "button";
2707
+ closeButton.setAttribute("aria-label", "Close card authentication");
2708
+ closeButton.textContent = "\xD7";
2709
+ closeButton.style.cssText = [
2710
+ "position:fixed",
2711
+ "top:20px",
2712
+ "right:20px",
2713
+ "width:40px",
2714
+ "height:40px",
2715
+ "border:0",
2716
+ "border-radius:9999px",
2717
+ "background:#fff",
2718
+ "color:#0f172a",
2719
+ "font-size:28px",
2720
+ "line-height:40px",
2721
+ "cursor:pointer",
2722
+ "box-shadow:0 4px 14px rgba(0,0,0,0.25)"
2723
+ ].join(";");
2724
+ closeButton.addEventListener("click", () => this.abandonActionRequiredOverlay());
2725
+ backdrop.appendChild(closeButton);
2726
+ backdrop.addEventListener("click", (event) => {
2727
+ if (event.target === backdrop) this.abandonActionRequiredOverlay();
2728
+ });
1643
2729
  const returnHandler = (event) => {
1644
2730
  if (event.source !== frame.contentWindow) return;
1645
2731
  const data = event.data;
1646
2732
  if (!data || typeof data !== "object") return;
1647
2733
  const record = data;
1648
2734
  if (record["source"] !== "flopay-vault-3ds-return") return;
2735
+ addBreadcrumb(vaultLog("vault.three_ds.returned", "three_ds_return"));
2736
+ this.telemetryReporter?.log({
2737
+ name: "vault.three_ds.returned",
2738
+ stage: "three_ds_return",
2739
+ provider: "pcivault",
2740
+ paymentMethodCategory: "card"
2741
+ });
1649
2742
  this.hideActionRequiredOverlay();
1650
2743
  this.postActionCompleted(record["status"]);
1651
2744
  };
@@ -1653,7 +2746,7 @@ var PciVaultCardCapture = class {
1653
2746
  this.threeDsReturnHandler = returnHandler;
1654
2747
  document.body.appendChild(backdrop);
1655
2748
  this.actionOverlay = backdrop;
1656
- addBreadcrumb("vault 3ds challenge overlay shown");
2749
+ addBreadcrumb(vaultLog("vault.three_ds.handoff", "three_ds_handoff"));
1657
2750
  }
1658
2751
  /**
1659
2752
  * Tell the vault widget that the buyer has completed (or abandoned) the
@@ -1679,6 +2772,26 @@ var PciVaultCardCapture = class {
1679
2772
  } catch {
1680
2773
  }
1681
2774
  }
2775
+ abandonActionRequiredOverlay() {
2776
+ if (!this.actionOverlay) return;
2777
+ const breadcrumb = (0, import_shared5.buildTelemetryTerminalEvent)({
2778
+ eventId: "77777777-7777-4777-8777-777777777777",
2779
+ outcome: "customer_abandoned",
2780
+ stage: "three_ds_handoff",
2781
+ sequence: 0,
2782
+ provider: "pcivault",
2783
+ paymentMethodCategory: "card"
2784
+ });
2785
+ addBreadcrumb(breadcrumb);
2786
+ this.telemetryReporter?.terminal({
2787
+ outcome: "customer_abandoned",
2788
+ stage: "three_ds_handoff",
2789
+ provider: "pcivault",
2790
+ paymentMethodCategory: "card"
2791
+ });
2792
+ this.hideActionRequiredOverlay();
2793
+ this.postActionCompleted("abandoned");
2794
+ }
1682
2795
  hideActionRequiredOverlay() {
1683
2796
  if (this.threeDsReturnHandler) {
1684
2797
  window.removeEventListener("message", this.threeDsReturnHandler);
@@ -1687,7 +2800,6 @@ var PciVaultCardCapture = class {
1687
2800
  if (!this.actionOverlay) return;
1688
2801
  this.actionOverlay.parentNode?.removeChild(this.actionOverlay);
1689
2802
  this.actionOverlay = null;
1690
- addBreadcrumb("vault 3ds challenge overlay hidden");
1691
2803
  }
1692
2804
  /**
1693
2805
  * Size the hosted-widget iframe to the height reported by the form inside it.
@@ -1703,13 +2815,62 @@ var PciVaultCardCapture = class {
1703
2815
  iframe.style.height = `${clamped}px`;
1704
2816
  }
1705
2817
  };
2818
+ function createInstrumentedPciVaultCardCapture(config, reporter, requestedAt) {
2819
+ const InstrumentedCapture = PciVaultCardCapture;
2820
+ return new InstrumentedCapture(config, { reporter, requestedAt });
2821
+ }
2822
+
2823
+ // src/telemetry-bridge.ts
2824
+ var FLOPAY_TELEMETRY_BRIDGE = /* @__PURE__ */ Symbol.for("@flopay/js.telemetry.bridge.v1");
2825
+ function attachFloPayTelemetryBridge(target, reporter, fallbackNow) {
2826
+ const now = () => reporter?.now() ?? fallbackNow();
2827
+ const bridge = {
2828
+ error: (input) => reporter?.error(input),
2829
+ log: (input) => reporter?.log(input),
2830
+ performance: (input) => reporter?.performance(input),
2831
+ terminal: (input) => reporter?.terminal(input),
2832
+ now,
2833
+ elapsed: (startedAt) => Math.max(0, now() - startedAt),
2834
+ setCheckoutContext: (context) => reporter?.setCheckoutContext(context),
2835
+ beginCheckout: (context = {}) => reporter?.beginCheckout(context) ?? now(),
2836
+ disable: () => reporter?.disable()
2837
+ };
2838
+ Object.defineProperty(target, FLOPAY_TELEMETRY_BRIDGE, {
2839
+ configurable: false,
2840
+ enumerable: false,
2841
+ writable: false,
2842
+ value: bridge
2843
+ });
2844
+ }
2845
+ function getFloPayTelemetryBridge(target) {
2846
+ return target[FLOPAY_TELEMETRY_BRIDGE];
2847
+ }
1706
2848
 
1707
2849
  // src/flopay.ts
2850
+ function isExpectedDecline(error) {
2851
+ if (!error) return false;
2852
+ const code = error.code?.toLowerCase() ?? "";
2853
+ return Boolean(error.declineCode) || code.includes("declin");
2854
+ }
2855
+ function telemetryProvider(name) {
2856
+ if (name === "stripe" || name === "paypal" || name === "pcivault") return name;
2857
+ return "other";
2858
+ }
1708
2859
  var FloPay = class {
1709
- constructor(provider, config) {
2860
+ constructor(provider, config, telemetryReporter) {
1710
2861
  this.currentElements = null;
2862
+ this.cardThreeDsStartedAt = /* @__PURE__ */ new Map();
1711
2863
  this.provider = provider;
1712
2864
  this.config = config;
2865
+ this.telemetryReporter = telemetryReporter ?? new TelemetryReporter({
2866
+ billingApiUrl: (0, import_shared6.resolveBillingApiUrl)(config.billingApiUrl),
2867
+ sdkVersion: import_shared6.SDK_VERSION,
2868
+ enabled: config.telemetry !== false
2869
+ });
2870
+ attachFloPayTelemetryBridge(this, this.telemetryReporter, telemetryNow);
2871
+ }
2872
+ now() {
2873
+ return this.telemetryReporter?.now?.() ?? telemetryNow();
1713
2874
  }
1714
2875
  /**
1715
2876
  * Creates a new `FloPayElements` group for mounting payment fields.
@@ -1733,11 +2894,169 @@ var FloPay = class {
1733
2894
  }
1734
2895
  /** Create a payment method from the current elements (tokenize card). */
1735
2896
  async createPaymentMethod(billingDetails) {
1736
- return this.provider.createPaymentMethod(billingDetails);
2897
+ const started = this.now();
2898
+ const provider = telemetryProvider(this.provider.name);
2899
+ this.telemetryReporter?.log({
2900
+ name: "payment.tokenization.started",
2901
+ stage: "tokenization",
2902
+ provider,
2903
+ paymentMethodCategory: "card"
2904
+ });
2905
+ try {
2906
+ const result = await this.provider.createPaymentMethod(billingDetails);
2907
+ this.telemetryReporter?.performance({
2908
+ stage: "tokenization",
2909
+ durationMs: this.now() - started,
2910
+ durationMode: "machine",
2911
+ provider,
2912
+ paymentMethodCategory: "card"
2913
+ });
2914
+ if (!result.error) {
2915
+ this.telemetryReporter?.log({
2916
+ name: "payment.tokenization.completed",
2917
+ stage: "tokenization",
2918
+ provider,
2919
+ paymentMethodCategory: "card"
2920
+ });
2921
+ } else if (result.error.type !== "validation_error") {
2922
+ this.telemetryReporter?.error({
2923
+ errorCode: "TOKENIZATION_FAILED",
2924
+ stage: "tokenization",
2925
+ provider,
2926
+ paymentMethodCategory: "card"
2927
+ });
2928
+ }
2929
+ return result;
2930
+ } catch (error) {
2931
+ this.telemetryReporter?.performance({
2932
+ stage: "tokenization",
2933
+ durationMs: this.now() - started,
2934
+ durationMode: "machine",
2935
+ provider,
2936
+ paymentMethodCategory: "card"
2937
+ });
2938
+ this.telemetryReporter?.error({
2939
+ errorCode: "TOKENIZATION_FAILED",
2940
+ stage: "tokenization",
2941
+ provider,
2942
+ paymentMethodCategory: "card"
2943
+ });
2944
+ throw error;
2945
+ }
1737
2946
  }
1738
2947
  /** Confirm a card payment with a known client secret and payment method ID. */
1739
2948
  async confirmCardPayment(params) {
1740
- return this.provider.confirmCardPayment(params);
2949
+ const provider = telemetryProvider(this.provider.name);
2950
+ const operationStartedAt = this.now();
2951
+ try {
2952
+ const result = await this.provider.confirmCardPayment(params);
2953
+ const completedAt = this.now();
2954
+ const threeDs = result.threeDs;
2955
+ let threeDsFailed = false;
2956
+ if (threeDs?.status === "handoff") {
2957
+ if (this.trackCardThreeDsAttempt(threeDs.attemptId, completedAt)) {
2958
+ this.telemetryReporter?.log({
2959
+ name: "payment.three_ds.handoff",
2960
+ stage: "three_ds_handoff",
2961
+ provider,
2962
+ paymentMethodCategory: "card"
2963
+ });
2964
+ this.telemetryReporter?.performance({
2965
+ stage: "three_ds_handoff",
2966
+ durationMs: completedAt - operationStartedAt,
2967
+ durationMode: "machine",
2968
+ provider,
2969
+ paymentMethodCategory: "card"
2970
+ });
2971
+ this.telemetryReporter?.terminal({
2972
+ outcome: "action_required",
2973
+ stage: "three_ds_handoff",
2974
+ provider,
2975
+ paymentMethodCategory: "card"
2976
+ });
2977
+ }
2978
+ } else if (threeDs) {
2979
+ const threeDsStartedAt = this.takeCardThreeDsAttempt(threeDs.attemptId, completedAt);
2980
+ if (threeDsStartedAt !== void 0) {
2981
+ if (threeDs.status === "returned") {
2982
+ this.telemetryReporter?.log({
2983
+ name: "payment.three_ds.returned",
2984
+ stage: "three_ds_return",
2985
+ provider,
2986
+ paymentMethodCategory: "card"
2987
+ });
2988
+ this.telemetryReporter?.performance({
2989
+ stage: "three_ds_return",
2990
+ durationMs: completedAt - threeDsStartedAt,
2991
+ durationMode: "machine",
2992
+ provider,
2993
+ paymentMethodCategory: "card"
2994
+ });
2995
+ } else {
2996
+ threeDsFailed = true;
2997
+ }
2998
+ }
2999
+ }
3000
+ if (isExpectedDecline(result.error)) {
3001
+ this.telemetryReporter?.terminal({
3002
+ outcome: "payment_declined",
3003
+ provider,
3004
+ paymentMethodCategory: "card"
3005
+ });
3006
+ } else if (result.error?.type === "validation_error") {
3007
+ this.telemetryReporter?.terminal({
3008
+ outcome: "validation_rejected",
3009
+ provider,
3010
+ paymentMethodCategory: "card"
3011
+ });
3012
+ } else if (result.error) {
3013
+ this.telemetryReporter?.error({
3014
+ errorCode: threeDsFailed ? "THREE_DS_FAILED" : "PROVIDER_RUNTIME_FAILED",
3015
+ stage: threeDsFailed ? "three_ds_return" : "processing",
3016
+ provider,
3017
+ paymentMethodCategory: "card"
3018
+ });
3019
+ } else if (!threeDsFailed && threeDs?.status !== "handoff" && result.status === "succeeded") {
3020
+ this.telemetryReporter?.terminal({
3021
+ outcome: "payment_succeeded",
3022
+ provider,
3023
+ paymentMethodCategory: "card"
3024
+ });
3025
+ }
3026
+ return result;
3027
+ } catch (error) {
3028
+ this.telemetryReporter?.error({
3029
+ errorCode: "PROVIDER_RUNTIME_FAILED",
3030
+ stage: "processing",
3031
+ provider,
3032
+ paymentMethodCategory: "card"
3033
+ });
3034
+ throw error;
3035
+ }
3036
+ }
3037
+ trackCardThreeDsAttempt(attemptId, startedAt) {
3038
+ this.pruneExpiredCardThreeDsAttempts(startedAt);
3039
+ if (this.cardThreeDsStartedAt.has(attemptId)) return false;
3040
+ while (this.cardThreeDsStartedAt.size >= MAX_CARD_THREE_DS_ATTEMPTS) {
3041
+ const oldestAttemptId = this.cardThreeDsStartedAt.keys().next().value;
3042
+ if (oldestAttemptId === void 0) break;
3043
+ this.cardThreeDsStartedAt.delete(oldestAttemptId);
3044
+ }
3045
+ this.cardThreeDsStartedAt.set(attemptId, startedAt);
3046
+ return true;
3047
+ }
3048
+ takeCardThreeDsAttempt(attemptId, now) {
3049
+ this.pruneExpiredCardThreeDsAttempts(now);
3050
+ const startedAt = this.cardThreeDsStartedAt.get(attemptId);
3051
+ if (startedAt !== void 0) this.cardThreeDsStartedAt.delete(attemptId);
3052
+ return startedAt;
3053
+ }
3054
+ pruneExpiredCardThreeDsAttempts(now) {
3055
+ for (const [attemptId, startedAt] of this.cardThreeDsStartedAt) {
3056
+ if (now - startedAt >= CARD_THREE_DS_ATTEMPT_TTL_MS) {
3057
+ this.cardThreeDsStartedAt.delete(attemptId);
3058
+ }
3059
+ }
1741
3060
  }
1742
3061
  /**
1743
3062
  * Create a {@link CardCaptureAdapter} for collecting card details through the
@@ -1752,21 +3071,270 @@ var FloPay = class {
1752
3071
  * the SDK runtime.
1753
3072
  */
1754
3073
  cardCapture(options) {
3074
+ const requestedAt = this.now();
3075
+ this.telemetryReporter?.log({
3076
+ name: "vault.capture.requested",
3077
+ stage: "vault_request",
3078
+ provider: "pcivault",
3079
+ paymentMethodCategory: "card"
3080
+ });
3081
+ if (this.telemetryReporter) {
3082
+ return createInstrumentedPciVaultCardCapture(
3083
+ { sessionId: options?.sessionId },
3084
+ this.telemetryReporter,
3085
+ requestedAt
3086
+ );
3087
+ }
1755
3088
  return new PciVaultCardCapture({
1756
- sessionId: options?.sessionId
3089
+ sessionId: options?.sessionId,
3090
+ telemetry: false
1757
3091
  });
1758
3092
  }
1759
3093
  /** Confirm a PayPal payment: create intent via billing API → confirm → redirect if needed. */
1760
3094
  async confirmPayPalPayment(params) {
1761
- return this.provider.confirmPayPalPayment(params);
3095
+ const startedAt = this.now();
3096
+ this.telemetryReporter?.log({
3097
+ name: "payment.method.selected",
3098
+ stage: "processing",
3099
+ provider: "paypal",
3100
+ paymentMethodCategory: "paypal"
3101
+ });
3102
+ this.telemetryReporter?.log({
3103
+ name: "payment.intent.started",
3104
+ stage: "processing",
3105
+ provider: "paypal",
3106
+ paymentMethodCategory: "paypal",
3107
+ requestCategory: "intent_create"
3108
+ });
3109
+ try {
3110
+ const result = await this.provider.confirmPayPalPayment(params);
3111
+ this.telemetryReporter?.performance({
3112
+ stage: "processing",
3113
+ durationMs: this.now() - startedAt,
3114
+ durationMode: "machine",
3115
+ provider: "paypal",
3116
+ paymentMethodCategory: "paypal"
3117
+ });
3118
+ if (!result.error) {
3119
+ this.telemetryReporter?.log({
3120
+ name: "payment.intent.completed",
3121
+ stage: "processing",
3122
+ provider: "paypal",
3123
+ paymentMethodCategory: "paypal",
3124
+ requestCategory: "intent_create",
3125
+ statusClass: "2xx"
3126
+ });
3127
+ }
3128
+ if (result.status === "requires_action") {
3129
+ this.telemetryReporter?.log({
3130
+ name: "provider.redirect.started",
3131
+ stage: "redirect",
3132
+ provider: "paypal",
3133
+ paymentMethodCategory: "paypal"
3134
+ });
3135
+ this.telemetryReporter?.terminal({
3136
+ outcome: "action_required",
3137
+ stage: "redirect",
3138
+ provider: "paypal",
3139
+ paymentMethodCategory: "paypal"
3140
+ });
3141
+ } else if (result.status === "succeeded") {
3142
+ this.telemetryReporter?.terminal({
3143
+ outcome: "payment_succeeded",
3144
+ provider: "paypal",
3145
+ paymentMethodCategory: "paypal"
3146
+ });
3147
+ } else if (result.status !== "processing") {
3148
+ if (isExpectedDecline(result.error)) {
3149
+ this.telemetryReporter?.terminal({
3150
+ outcome: "payment_declined",
3151
+ provider: "paypal",
3152
+ paymentMethodCategory: "paypal"
3153
+ });
3154
+ } else if (result.error?.type === "validation_error") {
3155
+ this.telemetryReporter?.terminal({
3156
+ outcome: "validation_rejected",
3157
+ provider: "paypal",
3158
+ paymentMethodCategory: "paypal"
3159
+ });
3160
+ } else if (result.error) {
3161
+ this.telemetryReporter?.error({
3162
+ errorCode: "PAYMENT_PROCESSING_FAILED",
3163
+ stage: "processing",
3164
+ provider: "paypal",
3165
+ paymentMethodCategory: "paypal",
3166
+ requestCategory: "intent_create"
3167
+ });
3168
+ }
3169
+ }
3170
+ return result;
3171
+ } catch (error) {
3172
+ this.telemetryReporter?.performance({
3173
+ stage: "processing",
3174
+ durationMs: this.now() - startedAt,
3175
+ durationMode: "machine",
3176
+ provider: "paypal",
3177
+ paymentMethodCategory: "paypal"
3178
+ });
3179
+ this.telemetryReporter?.error({
3180
+ errorCode: "NETWORK_REQUEST_FAILED",
3181
+ stage: "processing",
3182
+ provider: "paypal",
3183
+ paymentMethodCategory: "paypal",
3184
+ requestCategory: "intent_create",
3185
+ statusClass: "network_error"
3186
+ });
3187
+ throw error;
3188
+ }
1762
3189
  }
1763
3190
  /** Resume a PayPal payment after redirect return. Returns null if no PayPal params in URL. */
1764
3191
  async resumePayPalPayment() {
1765
- return this.provider.resumePayPalPayment();
3192
+ const startedAt = this.now();
3193
+ try {
3194
+ const result = await this.provider.resumePayPalPayment();
3195
+ if (result === null) return null;
3196
+ this.telemetryReporter?.log({
3197
+ name: "provider.redirect.resumed",
3198
+ stage: "redirect_resume",
3199
+ provider: "paypal",
3200
+ paymentMethodCategory: "paypal"
3201
+ });
3202
+ this.telemetryReporter?.performance({
3203
+ stage: "redirect_resume",
3204
+ durationMs: this.now() - startedAt,
3205
+ durationMode: "machine",
3206
+ provider: "paypal",
3207
+ paymentMethodCategory: "paypal"
3208
+ });
3209
+ if (result.status === "succeeded") {
3210
+ this.telemetryReporter?.terminal({
3211
+ outcome: "payment_succeeded",
3212
+ provider: "paypal",
3213
+ paymentMethodCategory: "paypal"
3214
+ });
3215
+ } else if (isExpectedDecline(result.error)) {
3216
+ this.telemetryReporter?.terminal({
3217
+ outcome: "payment_declined",
3218
+ provider: "paypal",
3219
+ paymentMethodCategory: "paypal"
3220
+ });
3221
+ } else if (result.error?.type === "validation_error") {
3222
+ this.telemetryReporter?.terminal({
3223
+ outcome: "validation_rejected",
3224
+ provider: "paypal",
3225
+ paymentMethodCategory: "paypal"
3226
+ });
3227
+ } else if (result.error) {
3228
+ this.telemetryReporter?.error({
3229
+ errorCode: "REDIRECT_RESUME_FAILED",
3230
+ stage: "redirect_resume",
3231
+ provider: "paypal",
3232
+ paymentMethodCategory: "paypal"
3233
+ });
3234
+ }
3235
+ return result;
3236
+ } catch (error) {
3237
+ this.telemetryReporter?.performance({
3238
+ stage: "redirect_resume",
3239
+ durationMs: this.now() - startedAt,
3240
+ durationMode: "machine",
3241
+ provider: "paypal",
3242
+ paymentMethodCategory: "paypal"
3243
+ });
3244
+ this.telemetryReporter?.error({
3245
+ errorCode: "REDIRECT_RESUME_FAILED",
3246
+ stage: "redirect_resume",
3247
+ provider: "paypal",
3248
+ paymentMethodCategory: "paypal"
3249
+ });
3250
+ throw error;
3251
+ }
1766
3252
  }
1767
3253
  /** Confirms a payment using the mounted elements. */
1768
3254
  async confirmPayment(params) {
1769
- return this.provider.confirmPayment(params);
3255
+ const started = this.now();
3256
+ const provider = telemetryProvider(this.provider.name);
3257
+ this.telemetryReporter?.log({
3258
+ name: "payment.processing.started",
3259
+ stage: "processing",
3260
+ provider,
3261
+ paymentMethodCategory: "unknown"
3262
+ });
3263
+ try {
3264
+ const result = await this.provider.confirmPayment(params);
3265
+ const durationMs = this.now() - started;
3266
+ this.telemetryReporter?.performance({
3267
+ stage: "processing",
3268
+ durationMs,
3269
+ durationMode: "machine",
3270
+ provider,
3271
+ paymentMethodCategory: "unknown"
3272
+ });
3273
+ if (result.status === "succeeded") {
3274
+ this.telemetryReporter?.terminal({
3275
+ outcome: "payment_succeeded",
3276
+ provider,
3277
+ paymentMethodCategory: "unknown"
3278
+ });
3279
+ } else if (isExpectedDecline(result.error)) {
3280
+ this.telemetryReporter?.terminal({
3281
+ outcome: "payment_declined",
3282
+ provider,
3283
+ paymentMethodCategory: "unknown"
3284
+ });
3285
+ } else if (result.error?.type === "validation_error") {
3286
+ this.telemetryReporter?.terminal({
3287
+ outcome: "validation_rejected",
3288
+ provider,
3289
+ paymentMethodCategory: "unknown"
3290
+ });
3291
+ } else if (result.status === "requires_action") {
3292
+ this.telemetryReporter?.terminal({
3293
+ outcome: "action_required",
3294
+ stage: "three_ds_handoff",
3295
+ provider,
3296
+ paymentMethodCategory: "unknown"
3297
+ });
3298
+ } else if (result.status === "failed" && result.error) {
3299
+ this.telemetryReporter?.error({
3300
+ errorCode: "PAYMENT_PROCESSING_FAILED",
3301
+ stage: "processing",
3302
+ provider,
3303
+ paymentMethodCategory: "unknown"
3304
+ });
3305
+ }
3306
+ if (result.status === "succeeded" || result.status === "failed") {
3307
+ this.telemetryReporter?.log({
3308
+ name: "payment.processing.completed",
3309
+ stage: "processing",
3310
+ provider,
3311
+ paymentMethodCategory: "unknown"
3312
+ });
3313
+ } else {
3314
+ this.telemetryReporter?.log({
3315
+ name: "operation.state_transition",
3316
+ stage: result.status === "requires_action" ? "three_ds_handoff" : "processing",
3317
+ provider,
3318
+ paymentMethodCategory: "unknown"
3319
+ });
3320
+ }
3321
+ return result;
3322
+ } catch (error) {
3323
+ this.telemetryReporter?.performance({
3324
+ stage: "processing",
3325
+ durationMs: this.now() - started,
3326
+ durationMode: "machine",
3327
+ provider,
3328
+ paymentMethodCategory: "unknown"
3329
+ });
3330
+ this.telemetryReporter?.error({
3331
+ errorCode: "PAYMENT_PROCESSING_FAILED",
3332
+ stage: "processing",
3333
+ provider,
3334
+ paymentMethodCategory: "unknown"
3335
+ });
3336
+ throw error;
3337
+ }
1770
3338
  }
1771
3339
  /**
1772
3340
  * Retrieves a checkout session by ID via the billing API.
@@ -1779,17 +3347,15 @@ var FloPay = class {
1779
3347
  */
1780
3348
  async retrieveSession(sessionId, billingApiUrl) {
1781
3349
  if (!sessionId) {
1782
- throw new import_shared5.FloPayError(
3350
+ throw new import_shared6.FloPayError(
1783
3351
  "sessionId is required to retrieve a session.",
1784
3352
  "validation_error",
1785
3353
  { param: "sessionId" }
1786
3354
  );
1787
3355
  }
1788
- const apiUrl = (0, import_shared5.resolveBillingApiUrl)(billingApiUrl ?? this.config.billingApiUrl);
1789
- const api = new PaymentAPI(apiUrl);
1790
- const unified = await api.getUnifiedCheckoutSession(sessionId);
3356
+ const unified = await this.retrieveUnifiedSession(sessionId, billingApiUrl);
1791
3357
  if (!unified.data.session) {
1792
- throw new import_shared5.FloPayError("Session not found", "api_error");
3358
+ throw new import_shared6.FloPayError("Session not found", "api_error");
1793
3359
  }
1794
3360
  return unified.data.session;
1795
3361
  }
@@ -1802,15 +3368,86 @@ var FloPay = class {
1802
3368
  */
1803
3369
  async retrieveUnifiedSession(sessionId, billingApiUrl) {
1804
3370
  if (!sessionId) {
1805
- throw new import_shared5.FloPayError(
3371
+ throw new import_shared6.FloPayError(
1806
3372
  "sessionId is required.",
1807
3373
  "validation_error",
1808
3374
  { param: "sessionId" }
1809
3375
  );
1810
3376
  }
1811
- const apiUrl = (0, import_shared5.resolveBillingApiUrl)(billingApiUrl ?? this.config.billingApiUrl);
1812
- const api = new PaymentAPI(apiUrl);
1813
- return api.getUnifiedCheckoutSession(sessionId);
3377
+ const apiUrl = (0, import_shared6.resolveBillingApiUrl)(billingApiUrl ?? this.config.billingApiUrl);
3378
+ const started = this.now();
3379
+ this.telemetryReporter?.log({
3380
+ name: "session.read.started",
3381
+ stage: "session_read",
3382
+ requestCategory: "session_read"
3383
+ });
3384
+ let firstByteDuration;
3385
+ const api = createInstrumentedPaymentAPI(apiUrl, {
3386
+ now: () => this.telemetryReporter?.now() ?? telemetryNow(),
3387
+ onFirstByte: (durationMs) => {
3388
+ firstByteDuration = durationMs;
3389
+ },
3390
+ onRetry: (requestCategory, attempt) => {
3391
+ this.telemetryReporter?.log({
3392
+ name: "operation.retry",
3393
+ stage: requestCategory === "session_read" ? "session_read" : "processing",
3394
+ requestCategory,
3395
+ attempt
3396
+ });
3397
+ }
3398
+ });
3399
+ try {
3400
+ const result = await api.getUnifiedCheckoutSession(sessionId);
3401
+ if (firstByteDuration !== void 0) {
3402
+ this.telemetryReporter?.log({
3403
+ name: "session.request.first_byte",
3404
+ stage: "session_first_byte",
3405
+ requestCategory: "session_read",
3406
+ statusClass: "2xx"
3407
+ });
3408
+ this.telemetryReporter?.performance({
3409
+ stage: "session_first_byte",
3410
+ durationMs: firstByteDuration,
3411
+ durationMode: "machine",
3412
+ requestCategory: "session_read",
3413
+ statusClass: "2xx"
3414
+ });
3415
+ }
3416
+ this.telemetryReporter?.log({
3417
+ name: "session.request.completed",
3418
+ stage: "session_complete",
3419
+ requestCategory: "session_read",
3420
+ statusClass: "2xx"
3421
+ });
3422
+ this.telemetryReporter?.log({
3423
+ name: "checkout.data.ready",
3424
+ stage: "checkout_data_ready"
3425
+ });
3426
+ this.telemetryReporter?.performance({
3427
+ stage: "session_complete",
3428
+ durationMs: this.now() - started,
3429
+ durationMode: "machine",
3430
+ requestCategory: "session_read",
3431
+ statusClass: "2xx"
3432
+ });
3433
+ return result;
3434
+ } catch (error) {
3435
+ const statusCode = error instanceof import_shared6.FloPayError ? error.statusCode : void 0;
3436
+ this.telemetryReporter?.error({
3437
+ errorCode: error instanceof import_shared6.FloPayError && error.code === "checkout_processing_timeout" ? "REQUEST_TIMEOUT" : "NETWORK_REQUEST_FAILED",
3438
+ stage: "session_read",
3439
+ provider: "flo",
3440
+ paymentMethodCategory: "unknown"
3441
+ });
3442
+ this.telemetryReporter?.performance({
3443
+ stage: "session_complete",
3444
+ durationMs: this.now() - started,
3445
+ durationMode: "machine",
3446
+ requestCategory: "session_read",
3447
+ statusClass: statusCode ? `${Math.floor(statusCode / 100)}xx` : "network_error"
3448
+ });
3449
+ throw error;
3450
+ }
1814
3451
  }
1815
3452
  /**
1816
3453
  * Returns the raw underlying provider instance (e.g. Stripe object).
@@ -1822,46 +3459,147 @@ var FloPay = class {
1822
3459
  }
1823
3460
  /** Tears down the SDK instance and releases resources. */
1824
3461
  destroy() {
3462
+ this.telemetryReporter?.log({ name: "checkout.unmount", stage: "unmount" });
3463
+ this.telemetryReporter?.destroy();
3464
+ this.cardThreeDsStartedAt.clear();
1825
3465
  this.currentElements?.destroy();
1826
3466
  this.currentElements = null;
1827
3467
  this.provider.destroy();
1828
3468
  }
1829
3469
  };
3470
+ function createInstrumentedFloPay(provider, config, reporter) {
3471
+ const InstrumentedFloPay = FloPay;
3472
+ return new InstrumentedFloPay(provider, config, reporter);
3473
+ }
1830
3474
 
1831
3475
  // src/load.ts
1832
3476
  var instanceCache = /* @__PURE__ */ new Map();
3477
+ function stableCacheValue(value) {
3478
+ if (Array.isArray(value)) return value.map(stableCacheValue);
3479
+ if (value && typeof value === "object") {
3480
+ return Object.fromEntries(
3481
+ Object.entries(value).filter(([, entry]) => entry !== void 0).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, stableCacheValue(entry)])
3482
+ );
3483
+ }
3484
+ return value;
3485
+ }
3486
+ function instanceCacheKey(publishableKey, options) {
3487
+ return JSON.stringify([
3488
+ publishableKey,
3489
+ (0, import_shared7.resolveBillingApiUrl)(options?.billingApiUrl),
3490
+ options?.telemetry !== false,
3491
+ options?.locale ?? "auto",
3492
+ options?.apiVersion ?? null,
3493
+ stableCacheValue(options?.appearance ?? null)
3494
+ ]);
3495
+ }
3496
+ var initializationCache = /* @__PURE__ */ new Map();
1833
3497
  async function loadFloPay(publishableKey, options) {
1834
3498
  if (!publishableKey) {
1835
- throw new import_shared6.FloPayError(
3499
+ const reporter = new TelemetryReporter({
3500
+ billingApiUrl: (0, import_shared7.resolveBillingApiUrl)(options?.billingApiUrl),
3501
+ sdkVersion: import_shared7.SDK_VERSION,
3502
+ enabled: options?.telemetry !== false
3503
+ });
3504
+ reporter.error({
3505
+ errorCode: "CONFIGURATION_INVALID",
3506
+ stage: "sdk_initialize",
3507
+ paymentMethodCategory: "unknown"
3508
+ });
3509
+ void reporter.flush().catch(() => {
3510
+ }).finally(() => reporter.destroy());
3511
+ throw new import_shared7.FloPayError(
1836
3512
  "A publishable key is required to initialize FloPay.",
1837
3513
  "validation_error",
1838
3514
  { param: "publishableKey" }
1839
3515
  );
1840
3516
  }
1841
- const cached = instanceCache.get(publishableKey);
1842
- if (cached) return cached;
3517
+ const cacheKey = instanceCacheKey(publishableKey, options);
3518
+ const cached = instanceCache.get(cacheKey);
3519
+ if (cached) {
3520
+ if (options?.telemetry !== false) {
3521
+ getFloPayTelemetryBridge(cached)?.log({
3522
+ name: "sdk.cache.hit",
3523
+ stage: "sdk_initialize"
3524
+ });
3525
+ }
3526
+ return cached;
3527
+ }
3528
+ const initializing = initializationCache.get(cacheKey);
3529
+ if (initializing) return initializing;
1843
3530
  const config = {
1844
- publishableKey,
1845
- ...options
3531
+ ...options,
3532
+ publishableKey
1846
3533
  };
1847
- const adapter = new StripeAdapter();
1848
- await adapter.initialize(config);
1849
- const instance = new FloPay(adapter, config);
1850
- instanceCache.set(publishableKey, instance);
1851
- return instance;
3534
+ const initialization = (async () => {
3535
+ const reporter = new TelemetryReporter({
3536
+ billingApiUrl: (0, import_shared7.resolveBillingApiUrl)(config.billingApiUrl),
3537
+ sdkVersion: import_shared7.SDK_VERSION,
3538
+ enabled: config.telemetry !== false
3539
+ });
3540
+ const initializationStarted = reporter.now();
3541
+ reporter.log({ name: "sdk.initialize.started", stage: "sdk_initialize" });
3542
+ reporter.log({ name: "sdk.cache.miss", stage: "sdk_initialize" });
3543
+ reporter.log({
3544
+ name: "provider.load.started",
3545
+ stage: "provider_load",
3546
+ provider: "stripe"
3547
+ });
3548
+ const adapter = new StripeAdapter();
3549
+ try {
3550
+ await adapter.initialize(config);
3551
+ } catch (error) {
3552
+ reporter.error({
3553
+ errorCode: "SDK_INITIALIZATION_FAILED",
3554
+ stage: "sdk_initialize",
3555
+ provider: "stripe",
3556
+ paymentMethodCategory: "unknown"
3557
+ });
3558
+ reporter.destroy();
3559
+ throw error;
3560
+ }
3561
+ reporter.log({ name: "provider.ready", stage: "provider_ready", provider: "stripe" });
3562
+ reporter.log({
3563
+ name: "provider.availability.checked",
3564
+ stage: "provider_ready",
3565
+ provider: "stripe"
3566
+ });
3567
+ reporter.log({ name: "sdk.initialize.ready", stage: "sdk_initialize" });
3568
+ const initializationDuration = reporter.now() - initializationStarted;
3569
+ reporter.performance({
3570
+ stage: "sdk_initialize",
3571
+ durationMs: initializationDuration,
3572
+ durationMode: "machine",
3573
+ provider: "stripe"
3574
+ });
3575
+ reporter.performance({
3576
+ stage: "provider_ready",
3577
+ durationMs: initializationDuration,
3578
+ durationMode: "machine",
3579
+ provider: "stripe"
3580
+ });
3581
+ const instance = createInstrumentedFloPay(adapter, config, reporter);
3582
+ instanceCache.set(cacheKey, instance);
3583
+ return instance;
3584
+ })();
3585
+ initializationCache.set(cacheKey, initialization);
3586
+ try {
3587
+ return await initialization;
3588
+ } finally {
3589
+ if (initializationCache.get(cacheKey) === initialization) {
3590
+ initializationCache.delete(cacheKey);
3591
+ }
3592
+ }
1852
3593
  }
1853
3594
 
1854
3595
  // src/create-checkout-session.ts
1855
- var import_shared7 = require("@flopay/shared");
3596
+ var import_shared8 = require("@flopay/shared");
1856
3597
  var MAX_COUPON_CODES = 5;
1857
- function readString2(value) {
1858
- return typeof value === "string" && value.trim() ? value : void 0;
1859
- }
1860
3598
  function buildCheckoutSessionError(status, payload) {
1861
3599
  const nested = payload?.error;
1862
- const code = readString2(payload?.code) ?? readString2(nested?.code) ?? `http_${status}`;
1863
- const message = readString2(payload?.message) ?? readString2(nested?.message) ?? defaultMessageForCode(code, status);
1864
- return new import_shared7.FloPayError(message, "api_error", { code, statusCode: status });
3600
+ const code = readErrorString(payload?.code) ?? readErrorString(nested?.code) ?? `http_${status}`;
3601
+ const message = readErrorString(payload?.message) ?? readErrorString(nested?.message) ?? defaultMessageForCode(code, status);
3602
+ return new import_shared8.FloPayError(message, "api_error", { code, statusCode: status });
1865
3603
  }
1866
3604
  function defaultMessageForCode(code, status) {
1867
3605
  switch (code) {
@@ -1873,7 +3611,7 @@ function defaultMessageForCode(code, status) {
1873
3611
  return `Failed to create checkout session (HTTP ${status}).`;
1874
3612
  }
1875
3613
  }
1876
- async function createCheckoutSession(options) {
3614
+ async function createCheckoutSessionCore(options, onFirstByte) {
1877
3615
  const {
1878
3616
  billingApiUrl,
1879
3617
  checkoutBaseUrl,
@@ -1894,18 +3632,18 @@ async function createCheckoutSession(options) {
1894
3632
  utmMetadata,
1895
3633
  idempotencyKey
1896
3634
  } = options;
1897
- const resolvedIdempotencyKey = (0, import_shared7.resolveIdempotencyKey)(idempotencyKey);
3635
+ const resolvedIdempotencyKey = (0, import_shared8.resolveIdempotencyKey)(idempotencyKey);
1898
3636
  if (couponCodes.length > MAX_COUPON_CODES) {
1899
- throw new import_shared7.FloPayError(
3637
+ throw new import_shared8.FloPayError(
1900
3638
  `Too many coupon codes \u2014 a checkout session accepts at most ${MAX_COUPON_CODES}.`,
1901
3639
  "validation_error",
1902
3640
  { code: "CouponLimitExceeded", param: "couponCodes" }
1903
3641
  );
1904
3642
  }
1905
- const wireProducts = products ?? (0, import_shared7.foldIntoProducts)(items, subscriptions);
1906
- const sessionCurrency = (0, import_shared7.resolveSessionCurrency)(currency, items, subscriptions, wireProducts);
3643
+ const wireProducts = products ?? (0, import_shared8.foldIntoProducts)(items, subscriptions);
3644
+ const sessionCurrency = (0, import_shared8.resolveSessionCurrency)(currency, items, subscriptions, wireProducts);
1907
3645
  if (!sessionCurrency) {
1908
- throw new import_shared7.FloPayError(
3646
+ throw new import_shared8.FloPayError(
1909
3647
  "currency is required: pass `currency` on the session, or include a `currency` on the first item/subscription/product.",
1910
3648
  "validation_error",
1911
3649
  { code: "CurrencyRequired", param: "currency" }
@@ -1913,12 +3651,12 @@ async function createCheckoutSession(options) {
1913
3651
  }
1914
3652
  const payload = {
1915
3653
  clientId,
1916
- checkoutVersion: import_shared7.SDK_VERSION,
3654
+ checkoutVersion: import_shared8.SDK_VERSION,
1917
3655
  successUrl,
1918
3656
  cancelUrl,
1919
3657
  currency: sessionCurrency,
1920
3658
  checkoutMode,
1921
- products: wireProducts.map((product) => (0, import_shared7.buildProductPayload)(product, sessionCurrency)),
3659
+ products: wireProducts.map((product) => (0, import_shared8.buildProductPayload)(product, sessionCurrency)),
1922
3660
  accountData: {
1923
3661
  userId: account.userId,
1924
3662
  firstName: account.firstName ?? null,
@@ -1945,7 +3683,7 @@ async function createCheckoutSession(options) {
1945
3683
  const timer = setTimeout(() => controller.abort(), timeoutMs);
1946
3684
  const headers = { "Content-Type": "application/json" };
1947
3685
  if (resolvedIdempotencyKey) {
1948
- headers[import_shared7.IDEMPOTENCY_KEY_HEADER] = resolvedIdempotencyKey;
3686
+ headers[import_shared8.IDEMPOTENCY_KEY_HEADER] = resolvedIdempotencyKey;
1949
3687
  }
1950
3688
  let status;
1951
3689
  let body;
@@ -1956,6 +3694,10 @@ async function createCheckoutSession(options) {
1956
3694
  body: JSON.stringify(payload),
1957
3695
  signal: controller.signal
1958
3696
  });
3697
+ try {
3698
+ onFirstByte?.(response.status);
3699
+ } catch {
3700
+ }
1959
3701
  status = response.status;
1960
3702
  try {
1961
3703
  body = await response.json();
@@ -1974,7 +3716,7 @@ async function createCheckoutSession(options) {
1974
3716
  throw new Error("Checkout session created but no UUID was returned by the billing API");
1975
3717
  }
1976
3718
  if (!nonce) {
1977
- throw new import_shared7.FloPayError(
3719
+ throw new import_shared8.FloPayError(
1978
3720
  "Checkout session created but no `nonce` was returned by the billing API. Upgrade the billing service to TeamFloPay/backend#640 or later.",
1979
3721
  "api_error",
1980
3722
  { code: "MissingCheckoutSessionToken" }
@@ -2017,30 +3759,137 @@ async function createCheckoutSession(options) {
2017
3759
  }
2018
3760
  return { status };
2019
3761
  }
3762
+ async function createCheckoutSession(options) {
3763
+ const telemetry = beginCreateSessionTelemetry(options);
3764
+ const recordFirstByte = createFirstByteRecorder(telemetry);
3765
+ try {
3766
+ const result = await createCheckoutSessionCore(options, recordFirstByte);
3767
+ completeCreateSessionTelemetry(telemetry, result);
3768
+ return result;
3769
+ } catch (error) {
3770
+ failCreateSessionTelemetry(telemetry.reporter, error);
3771
+ throw error;
3772
+ } finally {
3773
+ finishCreateSessionTelemetry(telemetry.reporter);
3774
+ }
3775
+ }
3776
+ function createFirstByteRecorder(telemetry) {
3777
+ let recorded = false;
3778
+ return (status) => {
3779
+ if (recorded) return;
3780
+ recorded = true;
3781
+ const statusClass = `${Math.floor(status / 100)}xx`;
3782
+ telemetry.reporter.log({
3783
+ name: "session.request.first_byte",
3784
+ stage: "session_first_byte",
3785
+ requestCategory: "session_create",
3786
+ statusClass
3787
+ });
3788
+ telemetry.reporter.performance({
3789
+ stage: "session_first_byte",
3790
+ durationMs: telemetry.reporter.now() - telemetry.startedAt,
3791
+ durationMode: "machine",
3792
+ requestCategory: "session_create",
3793
+ statusClass
3794
+ });
3795
+ };
3796
+ }
3797
+ function beginCreateSessionTelemetry(options) {
3798
+ const reporter = new TelemetryReporter({
3799
+ billingApiUrl: options.billingApiUrl,
3800
+ sdkVersion: import_shared8.SDK_VERSION,
3801
+ enabled: options.telemetry !== false
3802
+ });
3803
+ const startedAt = reporter.now();
3804
+ reporter.log({
3805
+ name: "session.create.started",
3806
+ stage: "session_create",
3807
+ requestCategory: "session_create"
3808
+ });
3809
+ return { reporter, startedAt };
3810
+ }
3811
+ function completeCreateSessionTelemetry(telemetry, result) {
3812
+ const statusClass = `${Math.floor(result.status / 100)}xx`;
3813
+ telemetry.reporter.log({
3814
+ name: "session.request.completed",
3815
+ stage: "session_complete",
3816
+ requestCategory: "session_create",
3817
+ statusClass
3818
+ });
3819
+ telemetry.reporter.performance({
3820
+ stage: "session_complete",
3821
+ durationMs: telemetry.reporter.now() - telemetry.startedAt,
3822
+ durationMode: "machine",
3823
+ requestCategory: "session_create",
3824
+ statusClass
3825
+ });
3826
+ }
3827
+ function failCreateSessionTelemetry(reporter, error) {
3828
+ if (error instanceof import_shared8.FloPayError && error.type === "validation_error") {
3829
+ reporter.terminal({
3830
+ outcome: "validation_rejected",
3831
+ stage: "session_create",
3832
+ requestCategory: "session_create"
3833
+ });
3834
+ return;
3835
+ }
3836
+ const statusCode = error instanceof import_shared8.FloPayError ? error.statusCode : void 0;
3837
+ reporter.error({
3838
+ errorCode: error instanceof Error && error.name === "AbortError" ? "REQUEST_TIMEOUT" : error instanceof TypeError ? "NETWORK_REQUEST_FAILED" : "CHECKOUT_SESSION_CREATE_FAILED",
3839
+ stage: "session_create",
3840
+ requestCategory: "session_create",
3841
+ statusClass: error instanceof Error && error.name === "AbortError" ? "timeout" : statusCode ? `${Math.floor(statusCode / 100)}xx` : "network_error"
3842
+ });
3843
+ }
3844
+ function finishCreateSessionTelemetry(reporter) {
3845
+ void reporter.flush().catch(() => {
3846
+ }).finally(() => reporter.destroy());
3847
+ }
2020
3848
  async function createCheckoutSessionWithRetries(options) {
2021
3849
  const { maxRetries = 3, ...sessionOptions } = options;
3850
+ const telemetry = beginCreateSessionTelemetry(options);
3851
+ const recordFirstByte = createFirstByteRecorder(telemetry);
2022
3852
  if (!Number.isFinite(maxRetries) || !Number.isInteger(maxRetries) || maxRetries <= 0) {
3853
+ telemetry.reporter.terminal({
3854
+ outcome: "validation_rejected",
3855
+ stage: "session_create",
3856
+ requestCategory: "session_create"
3857
+ });
3858
+ finishCreateSessionTelemetry(telemetry.reporter);
2023
3859
  throw new Error("Number of retries must be greater than 0");
2024
3860
  }
2025
3861
  const attemptOptions = {
2026
3862
  ...sessionOptions,
2027
- idempotencyKey: (0, import_shared7.resolveIdempotencyKey)(sessionOptions.idempotencyKey)
3863
+ idempotencyKey: (0, import_shared8.resolveIdempotencyKey)(sessionOptions.idempotencyKey)
2028
3864
  };
2029
3865
  let lastErr;
2030
3866
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
2031
3867
  try {
2032
- return await createCheckoutSession(attemptOptions);
3868
+ const result = await createCheckoutSessionCore(attemptOptions, recordFirstByte);
3869
+ completeCreateSessionTelemetry(telemetry, result);
3870
+ finishCreateSessionTelemetry(telemetry.reporter);
3871
+ return result;
2033
3872
  } catch (err) {
2034
3873
  lastErr = err;
2035
3874
  const isTransportAbort = err instanceof Error && err.name === "AbortError";
2036
- const isInProgressReplay = err instanceof import_shared7.FloPayError && err.code === import_shared7.IDEMPOTENCY_IN_PROGRESS_CODE;
3875
+ const isInProgressReplay = err instanceof import_shared8.FloPayError && err.code === import_shared8.IDEMPOTENCY_IN_PROGRESS_CODE;
2037
3876
  if ((isTransportAbort || isInProgressReplay) && attempt < maxRetries) {
3877
+ telemetry.reporter.log({
3878
+ name: "operation.retry",
3879
+ stage: "session_create",
3880
+ requestCategory: "session_create",
3881
+ attempt: attempt + 1
3882
+ });
2038
3883
  await new Promise((r) => setTimeout(r, 100 * Math.pow(2, attempt)));
2039
3884
  continue;
2040
3885
  }
3886
+ failCreateSessionTelemetry(telemetry.reporter, err);
3887
+ finishCreateSessionTelemetry(telemetry.reporter);
2041
3888
  throw err;
2042
3889
  }
2043
3890
  }
3891
+ failCreateSessionTelemetry(telemetry.reporter, lastErr);
3892
+ finishCreateSessionTelemetry(telemetry.reporter);
2044
3893
  throw lastErr ?? new Error("Unknown error during checkout session creation");
2045
3894
  }
2046
3895
  // Annotate the CommonJS export names for ESM import in node: