@flopay/js 1.2.8 → 1.3.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.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/load.ts
2
- import { FloPayError as FloPayError5 } from "@flopay/shared";
2
+ import { FloPayError as FloPayError6 } from "@flopay/shared";
3
3
 
4
4
  // src/stripe-adapter.ts
5
5
  import { FloPayError, isSetupIntentClientSecret } from "@flopay/shared";
@@ -461,7 +461,7 @@ var StripeAdapter = class {
461
461
  };
462
462
 
463
463
  // src/flopay.ts
464
- import { FloPayError as FloPayError4, resolveBillingApiUrl } from "@flopay/shared";
464
+ import { FloPayError as FloPayError5, resolveBillingApiUrl } from "@flopay/shared";
465
465
 
466
466
  // src/elements.ts
467
467
  import "@flopay/shared";
@@ -516,6 +516,7 @@ var FloPayElements = class {
516
516
  import {
517
517
  FloPayError as FloPayError3,
518
518
  SDK_VERSION,
519
+ FLO_SDK_VERSION_HEADER,
519
520
  buildProductPayload,
520
521
  foldIntoProducts,
521
522
  resolveSessionCurrency
@@ -599,6 +600,15 @@ function readString(payload, key) {
599
600
  const value = payload?.[key];
600
601
  return typeof value === "string" && value.trim() ? value : void 0;
601
602
  }
603
+ function readMessage(payload, key) {
604
+ const value = payload?.[key];
605
+ if (typeof value === "string") return value.trim() ? value : void 0;
606
+ if (Array.isArray(value)) {
607
+ const joined = value.filter((entry) => typeof entry === "string" && entry.trim().length > 0).join("; ");
608
+ return joined || void 0;
609
+ }
610
+ return void 0;
611
+ }
602
612
  function readNumber(payload, key) {
603
613
  const value = payload?.[key];
604
614
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
@@ -616,7 +626,7 @@ function createCheckoutProcessingTimeoutError() {
616
626
  async function buildApiErrorFromResponse(response, fallbackMessage) {
617
627
  const payload = await response.json().catch(() => null);
618
628
  const nestedError = isRecord(payload?.error) ? payload.error : null;
619
- const message = readString(payload, "message") ?? readString(nestedError, "message") ?? fallbackMessage;
629
+ const message = readMessage(payload, "message") ?? readMessage(nestedError, "message") ?? fallbackMessage;
620
630
  const code = readString(payload, "code") ?? readString(payload, "gatewayErrorCode") ?? readString(nestedError, "code") ?? `http_${response.status}`;
621
631
  return new FloPayError3(message, "api_error", {
622
632
  code,
@@ -651,9 +661,11 @@ var PaymentAPI = class {
651
661
  * Backends that don't yet enforce it ignore the extra header.
652
662
  */
653
663
  async getCheckoutSession(checkoutSessionId, nonce) {
664
+ const headers = { [FLO_SDK_VERSION_HEADER]: SDK_VERSION };
665
+ if (nonce) headers["x-checkout-session-token"] = nonce;
654
666
  const response = await fetchWithNetworkRetry(
655
667
  `${this.baseUrl}/v1/checkouts/sessions/${encodeURIComponent(checkoutSessionId)}`,
656
- nonce ? { headers: { "x-checkout-session-token": nonce } } : void 0
668
+ { headers }
657
669
  );
658
670
  if (!response.ok) {
659
671
  throw await buildApiErrorFromResponse(response, "Failed to get checkout session");
@@ -690,6 +702,43 @@ var PaymentAPI = class {
690
702
  clearSessionDisplayData(sessionId) {
691
703
  clearSessionDisplayData(sessionId);
692
704
  }
705
+ /**
706
+ * Fetch (re-mint) the hosted vault capture widget for a session
707
+ * (TeamFloPay/backend#823).
708
+ *
709
+ * `POST /v1/checkouts/sessions/{id}/vault/capture` returns the SDK-ready
710
+ * {@link VaultCaptureBlock} (`html` + `url`, plus `messageToken` /
711
+ * `expectedOrigin` once the backend mints them). The SDK injects `html` as
712
+ * the card-capture widget. This is the fallback path for sessions that did
713
+ * not receive the embedded `vault` block on create (e.g. a session loaded by
714
+ * id via `GET`, or a pre-1.3.0 create); the endpoint is idempotent and reuses
715
+ * session-cached creds when available.
716
+ *
717
+ * Because the endpoint is idempotent, the request is wrapped in
718
+ * `fetchWithNetworkRetry`: a transient network blip (dropped connection, DNS
719
+ * hiccup, failed CORS preflight) would otherwise leave the secure card form
720
+ * unable to load and hard-block checkout.
721
+ *
722
+ * The PCIVault submit *secret* the backend may include in the response is
723
+ * intentionally **not** read or surfaced — it is server-only and never enters
724
+ * the SDK runtime.
725
+ *
726
+ * `nonce` is forwarded as `x-checkout-session-token` (required by post-#640
727
+ * backends, matched against the session's stored nonce).
728
+ */
729
+ async getVaultCapture(checkoutSessionId, nonce) {
730
+ const headers = { "Content-Type": "application/json" };
731
+ if (nonce) headers["x-checkout-session-token"] = nonce;
732
+ const response = await fetchWithNetworkRetry(
733
+ `${this.baseUrl}/v1/checkouts/sessions/${encodeURIComponent(checkoutSessionId)}/vault/capture`,
734
+ { method: "POST", headers }
735
+ );
736
+ if (!response.ok) {
737
+ throw await buildApiErrorFromResponse(response, "Failed to load the secure card form");
738
+ }
739
+ const block = await response.json();
740
+ return this.toVaultBlock(block);
741
+ }
693
742
  /**
694
743
  * Fetch and normalize a checkout session.
695
744
  *
@@ -699,7 +748,12 @@ var PaymentAPI = class {
699
748
  */
700
749
  async getUnifiedCheckoutSession(checkoutSessionId, nonce) {
701
750
  const res = await this.getCheckoutSession(checkoutSessionId, nonce);
702
- return this.normalizeRawSession(res.data);
751
+ const normalized = this.normalizeRawSession(res.data);
752
+ const vault = res.vault;
753
+ if (vault && normalized.data.session) {
754
+ normalized.data.session.vault = this.toVaultBlock(vault);
755
+ }
756
+ return normalized;
703
757
  }
704
758
  /**
705
759
  * Submit a tokenized payment to the billing backend.
@@ -740,6 +794,43 @@ var PaymentAPI = class {
740
794
  );
741
795
  return this.resolveProcessResponse(response, data.sessionId, { ...options, nonce });
742
796
  }
797
+ /**
798
+ * Patch the buyer's account snapshot (email, name, billing address, AVS
799
+ * intent) onto a checkout session via
800
+ * `PATCH /v1/checkouts/sessions/{id}/account` (TeamFloPay/backend#823).
801
+ *
802
+ * The vault path's hosted form owns the charge end-to-end so the SDK
803
+ * never calls `/process` on this path; the buyer-typed AVS / billing
804
+ * address would otherwise be lost. The SDK calls this just before
805
+ * submitting the vault widget so the downstream listener mints the
806
+ * Stripe PaymentMethod with the right `billing_details.address` and the
807
+ * per-attempt + per-PM address snapshots are populated.
808
+ *
809
+ * Body shape mirrors the relevant subset of `/process`'s
810
+ * `ProcessCheckoutBodyDto` — same keys, same validators. The endpoint
811
+ * is idempotent: empty/undefined fields are not written, addresses are
812
+ * last-writer-wins, AVS analytics are first-writer-wins.
813
+ *
814
+ * Wrapped in `fetchWithNetworkRetry` because a transient blip on this
815
+ * pre-pay PATCH would silently leave AVS unsent and cause an
816
+ * AVS-protected charge to decline downstream.
817
+ */
818
+ async patchAccountSnapshot(sessionId, nonce, body) {
819
+ const response = await fetchWithNetworkRetry(
820
+ `${this.baseUrl}/v1/checkouts/sessions/${encodeURIComponent(sessionId)}/account`,
821
+ {
822
+ method: "PATCH",
823
+ headers: {
824
+ "Content-Type": "application/json",
825
+ "x-checkout-session-token": nonce
826
+ },
827
+ body: JSON.stringify(body)
828
+ }
829
+ );
830
+ if (!response.ok) {
831
+ throw await buildApiErrorFromResponse(response, "Failed to persist account snapshot");
832
+ }
833
+ }
743
834
  /**
744
835
  * Create a PaymentIntent on the backend.
745
836
  *
@@ -750,6 +841,13 @@ var PaymentAPI = class {
750
841
  * session-bound checkout token returned by session creation. Post-#640
751
842
  * backends reject this call with a 401 when the header is missing or does
752
843
  * not match the session's stored nonce.
844
+ *
845
+ * `paymentMethodType` is optional for the vault card-capture flow
846
+ * (TeamFloPay/backend#823): the frontend starts checkout *without* an upfront
847
+ * card payment method, so it may be omitted (or `null`). The hosted vault PCI
848
+ * form captures the card afterwards and the backend attaches the resulting
849
+ * payment method to the PaymentIntent it returns here. Legacy callers keep
850
+ * passing the concrete payment method id / type.
753
851
  */
754
852
  async createPaymentIntent(sessionId, email, paymentMethodType, options) {
755
853
  const headers = { "Content-Type": "application/json" };
@@ -762,7 +860,7 @@ var PaymentAPI = class {
762
860
  body: JSON.stringify({
763
861
  sessionId,
764
862
  email,
765
- paymentMethodType,
863
+ paymentMethodType: paymentMethodType ?? null,
766
864
  isPaypal: options?.isPaypal ?? false
767
865
  }),
768
866
  signal: options?.signal
@@ -871,7 +969,13 @@ var PaymentAPI = class {
871
969
  `${this.baseUrl}/v1/checkouts/sessions?expand=true`,
872
970
  {
873
971
  method: "POST",
874
- headers: { "Content-Type": "application/json" },
972
+ // Declare the SDK version so backends at TeamFloPay/backend#823 embed
973
+ // the hosted vault capture block (`body.vault`) in the response for
974
+ // SDKs ≥ 1.3.0. Older backends ignore the header.
975
+ headers: {
976
+ "Content-Type": "application/json",
977
+ [FLO_SDK_VERSION_HEADER]: SDK_VERSION
978
+ },
875
979
  body: JSON.stringify(payload)
876
980
  }
877
981
  );
@@ -889,8 +993,12 @@ var PaymentAPI = class {
889
993
  if (body.data && "gateways" in body.data) {
890
994
  this.autoCacheDisplayData(body.data.uuid, params);
891
995
  const merged = this.mergeCachedDisplayData(body.data);
996
+ const normalized = this.normalizeRawSession(merged);
997
+ if (body.vault && normalized.data.session) {
998
+ normalized.data.session.vault = this.toVaultBlock(body.vault);
999
+ }
892
1000
  return {
893
- ...this.normalizeRawSession(merged),
1001
+ ...normalized,
894
1002
  autoProcessingError: body.autoProcessingError,
895
1003
  autoProcessingAttempted: body.autoProcessingAttempted,
896
1004
  autoProcessingPending: body.autoProcessingPending
@@ -1009,6 +1117,7 @@ var PaymentAPI = class {
1009
1117
  },
1010
1118
  metadata: {},
1011
1119
  checkoutMode: raw.checkoutMode,
1120
+ providerPaymentMethodId: typeof raw.providerPaymentMethodId === "string" ? raw.providerPaymentMethodId : null,
1012
1121
  products: rawProducts.map((p) => ({
1013
1122
  ...p,
1014
1123
  totalAmount: typeof p.totalAmount === "number" ? p.totalAmount : void 0,
@@ -1028,6 +1137,19 @@ var PaymentAPI = class {
1028
1137
  tagsData: raw.tagsData
1029
1138
  };
1030
1139
  }
1140
+ /**
1141
+ * Coerce a raw vault block into a typed {@link VaultCaptureBlock}. The
1142
+ * server-only PCIVault submit `secret` is deliberately dropped so it never
1143
+ * lands on the public session surface (logs / telemetry / client inspection).
1144
+ */
1145
+ toVaultBlock(raw) {
1146
+ return {
1147
+ html: typeof raw.html === "string" ? raw.html : void 0,
1148
+ url: typeof raw.url === "string" ? raw.url : void 0,
1149
+ messageToken: typeof raw.messageToken === "string" ? raw.messageToken : void 0,
1150
+ expectedOrigin: typeof raw.expectedOrigin === "string" ? raw.expectedOrigin : void 0
1151
+ };
1152
+ }
1031
1153
  toCheckoutSessionStatus(status) {
1032
1154
  if (status === "completed") {
1033
1155
  return "complete";
@@ -1142,6 +1264,378 @@ var PaymentAPI = class {
1142
1264
  }
1143
1265
  };
1144
1266
 
1267
+ // src/pci-vault-card-capture.ts
1268
+ import { FloPayError as FloPayError4 } from "@flopay/shared";
1269
+ var VAULT_MESSAGE_SOURCE = "flopay-vault";
1270
+ function addBreadcrumb(message, data) {
1271
+ const sentry = globalThis.Sentry;
1272
+ sentry?.addBreadcrumb?.({ category: "flopay.card-capture", level: "info", message, data });
1273
+ }
1274
+ function isVaultResultMessage(value) {
1275
+ if (typeof value !== "object" || value === null) return false;
1276
+ const record = value;
1277
+ return record["source"] === VAULT_MESSAGE_SOURCE && (record["type"] === "ready" || record["type"] === "submitting" || record["type"] === "blocked" || record["type"] === "complete" || record["type"] === "decline" || record["type"] === "error" || record["type"] === "action_required");
1278
+ }
1279
+ function isVaultValidationMessage(value) {
1280
+ if (typeof value !== "object" || value === null) return false;
1281
+ const record = value;
1282
+ return record["source"] === VAULT_MESSAGE_SOURCE && record["type"] === "validation" && Array.isArray(record["messages"]);
1283
+ }
1284
+ function isVaultResizeMessage(value) {
1285
+ if (typeof value !== "object" || value === null) return false;
1286
+ const record = value;
1287
+ return record["source"] === VAULT_MESSAGE_SOURCE && record["type"] === "resize" && typeof record["height"] === "number" && Number.isFinite(record["height"]);
1288
+ }
1289
+ var PciVaultCardCapture = class {
1290
+ constructor(config = {}) {
1291
+ this.provider = "pcivault";
1292
+ this.container = null;
1293
+ this.messageHandler = null;
1294
+ /**
1295
+ * Parent-page-level overlay rendering the provider's verification challenge
1296
+ * (3DS-2 iframe) on `action_required`. Owned by the adapter — not the
1297
+ * widget — so it can sit above the host SDK's processing backdrop, which
1298
+ * would otherwise visually cover an in-widget challenge iframe.
1299
+ */
1300
+ this.actionOverlay = null;
1301
+ /**
1302
+ * Listener that catches the `flopay-vault-3ds-return` postMessage from the
1303
+ * provider's challenge return page. When the SDK owns the challenge iframe
1304
+ * the return page lives inside *that* iframe (not the widget's), so
1305
+ * `window.parent` is the host page — the widget's existing message
1306
+ * listener can't see it. The SDK forwards completion into the widget via
1307
+ * `action_completed` so the widget kicks `/3ds/complete` immediately
1308
+ * instead of waiting on the eventual provider webhook.
1309
+ */
1310
+ this.threeDsReturnHandler = null;
1311
+ /** Per-session integrity token to require on outcomes (from mount options). */
1312
+ this.messageToken = null;
1313
+ /** Strict origin to require on outcomes, when configured. */
1314
+ this.expectedOrigin = null;
1315
+ /** Latest merchant theme to push into the (cross-origin) widget. */
1316
+ this.theme = null;
1317
+ /** Latest host submit-gate state to push into the widget (block its submit). */
1318
+ this.submitGateBlocked = false;
1319
+ /** Latest card-field order + autofocus directive to push into the widget. */
1320
+ this.cardFieldOrder = null;
1321
+ this.cardAutoFocus = true;
1322
+ this.listeners = /* @__PURE__ */ new Map();
1323
+ this.config = config;
1324
+ }
1325
+ async mount(container, options) {
1326
+ if (typeof window === "undefined" || typeof document === "undefined") {
1327
+ throw new FloPayError4(
1328
+ "The vault card form is only available in the browser.",
1329
+ "api_error",
1330
+ { code: "card_capture_no_window" }
1331
+ );
1332
+ }
1333
+ if (!options?.html || !options.html.trim()) {
1334
+ throw new FloPayError4(
1335
+ "No vault capture widget HTML was provided to mount the secure card form.",
1336
+ "api_error",
1337
+ { code: "card_capture_no_widget_html" }
1338
+ );
1339
+ }
1340
+ this.container = container;
1341
+ this.messageToken = options.messageToken ?? null;
1342
+ this.expectedOrigin = options.expectedOrigin ?? this.config.expectedOrigin ?? null;
1343
+ this.theme = options.theme ?? null;
1344
+ this.attachMessageListener();
1345
+ this.injectWidget(container, options.html);
1346
+ this.postTheme();
1347
+ this.postSubmitGate();
1348
+ this.postCardFieldOrder();
1349
+ addBreadcrumb("vault widget mounted", { sessionId: this.config.sessionId });
1350
+ this.emit("ready", { sessionId: this.config.sessionId });
1351
+ }
1352
+ on(event, handler) {
1353
+ let set = this.listeners.get(event);
1354
+ if (!set) {
1355
+ set = /* @__PURE__ */ new Set();
1356
+ this.listeners.set(event, set);
1357
+ }
1358
+ set.add(handler);
1359
+ return () => {
1360
+ this.listeners.get(event)?.delete(handler);
1361
+ };
1362
+ }
1363
+ unmount() {
1364
+ this.hideActionRequiredOverlay();
1365
+ if (this.messageHandler) {
1366
+ window.removeEventListener("message", this.messageHandler);
1367
+ this.messageHandler = null;
1368
+ }
1369
+ if (this.container) {
1370
+ this.container.replaceChildren();
1371
+ this.container = null;
1372
+ }
1373
+ this.messageToken = null;
1374
+ this.expectedOrigin = null;
1375
+ }
1376
+ // ── internals ──
1377
+ /**
1378
+ * Inject the server-rendered widget HTML. `innerHTML` does not execute
1379
+ * embedded `<script>` tags, so each script node is replaced with a freshly
1380
+ * created element that the browser will load and run (this is what boots the
1381
+ * PCIVault form bundle against the `data-flopay-config` container).
1382
+ */
1383
+ injectWidget(container, html) {
1384
+ container.innerHTML = html;
1385
+ const scripts = Array.from(container.querySelectorAll("script"));
1386
+ for (const oldScript of scripts) {
1387
+ const script = document.createElement("script");
1388
+ for (const attr of Array.from(oldScript.attributes)) {
1389
+ script.setAttribute(attr.name, attr.value);
1390
+ }
1391
+ script.text = oldScript.text;
1392
+ oldScript.replaceWith(script);
1393
+ }
1394
+ }
1395
+ attachMessageListener() {
1396
+ if (this.messageHandler) return;
1397
+ const handler = (event) => {
1398
+ if (this.expectedOrigin && event.origin !== this.expectedOrigin) return;
1399
+ const data = event.data;
1400
+ if (isVaultResizeMessage(data)) {
1401
+ if (this.messageToken && data.messageToken !== this.messageToken) return;
1402
+ this.applyHeight(data.height);
1403
+ return;
1404
+ }
1405
+ if (isVaultValidationMessage(data)) {
1406
+ if (this.messageToken && data.messageToken !== this.messageToken) return;
1407
+ const text = data.messages.filter((m) => typeof m === "string" && m.trim()).join(" ");
1408
+ this.emit("validation", { sessionId: this.config.sessionId, message: text || void 0 });
1409
+ return;
1410
+ }
1411
+ if (!isVaultResultMessage(data)) return;
1412
+ if (this.messageToken && data.messageToken !== this.messageToken) return;
1413
+ const boundSession = this.config.sessionId;
1414
+ const incomingSession = typeof data.sessionId === "string" ? data.sessionId : void 0;
1415
+ if (boundSession && incomingSession && incomingSession !== boundSession) {
1416
+ return;
1417
+ }
1418
+ if ((data.type === "complete" || data.type === "decline") && boundSession && incomingSession !== boundSession) {
1419
+ return;
1420
+ }
1421
+ const outcome = {
1422
+ sessionId: data.sessionId ?? this.config.sessionId,
1423
+ intentId: data.intentId,
1424
+ declineReason: data.declineReason,
1425
+ message: data.message,
1426
+ nextActionRedirectUrl: data.nextActionRedirectUrl
1427
+ };
1428
+ addBreadcrumb(`vault widget ${data.type}`, {
1429
+ sessionId: outcome.sessionId,
1430
+ declineReason: outcome.declineReason
1431
+ });
1432
+ if (data.type === "ready") {
1433
+ this.postTheme();
1434
+ this.postSubmitGate();
1435
+ this.postCardFieldOrder();
1436
+ }
1437
+ if (data.type === "action_required" && data.nextActionRedirectUrl) {
1438
+ this.showActionRequiredOverlay(data.nextActionRedirectUrl);
1439
+ }
1440
+ if (data.type === "complete" || data.type === "decline" || data.type === "error" || data.type === "submitting") {
1441
+ this.hideActionRequiredOverlay();
1442
+ }
1443
+ this.emit(data.type, outcome);
1444
+ };
1445
+ this.messageHandler = handler;
1446
+ window.addEventListener("message", handler);
1447
+ }
1448
+ /**
1449
+ * Push merchant theme colors into the hosted widget (live). The host calls
1450
+ * this on a runtime theme switch; the widget applies them to its CSS variables
1451
+ * without a remount. Stores the latest theme so `ready` can re-push it.
1452
+ */
1453
+ applyTheme(theme) {
1454
+ this.theme = theme;
1455
+ this.postTheme();
1456
+ }
1457
+ /** postMessage the current theme to the widget's (cross-origin) document. */
1458
+ postTheme() {
1459
+ if (!this.theme || !this.container) return;
1460
+ const iframe = this.container.querySelector("iframe");
1461
+ const target = iframe?.contentWindow;
1462
+ if (!target) return;
1463
+ try {
1464
+ target.postMessage({ source: "flopay-vault-host", type: "theme", theme: this.theme }, "*");
1465
+ } catch {
1466
+ }
1467
+ }
1468
+ /**
1469
+ * Gate the widget's submit from the host. When `blocked`, the widget cancels
1470
+ * its next submit and emits `'blocked'` instead of `'submitting'` so the host
1471
+ * can validate merchant-DOM fields (AVS) first. Stored so `ready` re-pushes it.
1472
+ */
1473
+ setSubmitGate(blocked) {
1474
+ this.submitGateBlocked = blocked;
1475
+ this.postSubmitGate();
1476
+ }
1477
+ /** postMessage the current submit-gate state to the widget's document. */
1478
+ postSubmitGate() {
1479
+ if (!this.container) return;
1480
+ const iframe = this.container.querySelector("iframe");
1481
+ const target = iframe?.contentWindow;
1482
+ if (!target) return;
1483
+ try {
1484
+ target.postMessage(
1485
+ { source: "flopay-vault-host", type: "gate", blocked: this.submitGateBlocked },
1486
+ "*"
1487
+ );
1488
+ } catch {
1489
+ }
1490
+ }
1491
+ /**
1492
+ * Push the card-field order + autofocus directive into the widget (live). The
1493
+ * widget re-sequences its rows (DOM order, so tab order follows) and focuses
1494
+ * its first field unless `autoFocus` is false. Stored so `ready` re-pushes it.
1495
+ */
1496
+ setCardFieldOrder(order, autoFocus) {
1497
+ this.cardFieldOrder = order;
1498
+ this.cardAutoFocus = autoFocus;
1499
+ this.postCardFieldOrder();
1500
+ }
1501
+ /** postMessage the current field order + autofocus to the widget's document. */
1502
+ postCardFieldOrder() {
1503
+ if (!this.container) return;
1504
+ const iframe = this.container.querySelector("iframe");
1505
+ const target = iframe?.contentWindow;
1506
+ if (!target) return;
1507
+ try {
1508
+ target.postMessage(
1509
+ {
1510
+ source: "flopay-vault-host",
1511
+ type: "fieldOrder",
1512
+ order: this.cardFieldOrder,
1513
+ autoFocus: this.cardAutoFocus
1514
+ },
1515
+ "*"
1516
+ );
1517
+ } catch {
1518
+ }
1519
+ }
1520
+ emit(event, payload) {
1521
+ for (const handler of this.listeners.get(event) ?? []) {
1522
+ handler(payload);
1523
+ }
1524
+ }
1525
+ /**
1526
+ * Render the provider-hosted verification challenge (e.g. Stripe 3DS-2) in a
1527
+ * full-page overlay at the PARENT page level. The widget's inline-iframe
1528
+ * approach is unusable because the SDK's processing backdrop sits above the
1529
+ * vault iframe, hiding any challenge mounted inside it — by lifting the
1530
+ * iframe to the host page the adapter can give it a z-index that wins.
1531
+ *
1532
+ * The overlay tears down on the next terminal outcome
1533
+ * (`complete`/`decline`/`error`) or when the buyer closes it via the backdrop
1534
+ * close button. Closing manually is a soft abandon — the next `/status` poll
1535
+ * either reveals a real outcome (the challenge completed via the issuer's
1536
+ * own redirect to `/vault/3ds/return`, which posts back into the widget) or
1537
+ * surfaces `requires_action` again so the host can decide what to do.
1538
+ */
1539
+ showActionRequiredOverlay(challengeUrl) {
1540
+ if (typeof document === "undefined") return;
1541
+ if (this.actionOverlay) {
1542
+ const existingIframe = this.actionOverlay.querySelector("iframe");
1543
+ if (existingIframe instanceof HTMLIFrameElement) {
1544
+ existingIframe.src = challengeUrl;
1545
+ }
1546
+ return;
1547
+ }
1548
+ const backdrop = document.createElement("div");
1549
+ backdrop.setAttribute("data-flopay-action-required", "1");
1550
+ backdrop.style.cssText = [
1551
+ "position:fixed",
1552
+ "inset:0",
1553
+ // Maximum signed 32-bit z-index; the SDK's own processing backdrop sits
1554
+ // well below this so the challenge is visible and interactive.
1555
+ "z-index:2147483647",
1556
+ "background:rgba(15,23,42,0.6)",
1557
+ "display:flex",
1558
+ "align-items:center",
1559
+ "justify-content:center",
1560
+ "padding:16px"
1561
+ ].join(";");
1562
+ const frame = document.createElement("iframe");
1563
+ frame.setAttribute("title", "Card authentication");
1564
+ frame.setAttribute("allow", "payment");
1565
+ frame.style.cssText = [
1566
+ "width:min(100%,460px)",
1567
+ "height:min(100%,640px)",
1568
+ "border:0",
1569
+ "border-radius:12px",
1570
+ "background:#fff",
1571
+ "box-shadow:0 12px 30px rgba(0,0,0,0.35)"
1572
+ ].join(";");
1573
+ frame.src = challengeUrl;
1574
+ backdrop.appendChild(frame);
1575
+ const returnHandler = (event) => {
1576
+ if (event.source !== frame.contentWindow) return;
1577
+ const data = event.data;
1578
+ if (!data || typeof data !== "object") return;
1579
+ const record = data;
1580
+ if (record["source"] !== "flopay-vault-3ds-return") return;
1581
+ this.hideActionRequiredOverlay();
1582
+ this.postActionCompleted(record["status"]);
1583
+ };
1584
+ window.addEventListener("message", returnHandler);
1585
+ this.threeDsReturnHandler = returnHandler;
1586
+ document.body.appendChild(backdrop);
1587
+ this.actionOverlay = backdrop;
1588
+ addBreadcrumb("vault 3ds challenge overlay shown");
1589
+ }
1590
+ /**
1591
+ * Tell the vault widget that the buyer has completed (or abandoned) the
1592
+ * challenge. The widget responds by POSTing `/3ds/complete` — its
1593
+ * sub-300ms sync resolver writes the follow-up attempt row immediately,
1594
+ * so the next `/status` poll resolves to a terminal outcome instead of
1595
+ * waiting for the eventual provider webhook.
1596
+ */
1597
+ postActionCompleted(status) {
1598
+ if (!this.container) return;
1599
+ const iframe = this.container.querySelector("iframe");
1600
+ const target = iframe?.contentWindow;
1601
+ if (!target) return;
1602
+ try {
1603
+ target.postMessage(
1604
+ {
1605
+ source: "flopay-vault-host",
1606
+ type: "action_completed",
1607
+ status: typeof status === "string" ? status : "unknown"
1608
+ },
1609
+ "*"
1610
+ );
1611
+ } catch {
1612
+ }
1613
+ }
1614
+ hideActionRequiredOverlay() {
1615
+ if (this.threeDsReturnHandler) {
1616
+ window.removeEventListener("message", this.threeDsReturnHandler);
1617
+ this.threeDsReturnHandler = null;
1618
+ }
1619
+ if (!this.actionOverlay) return;
1620
+ this.actionOverlay.parentNode?.removeChild(this.actionOverlay);
1621
+ this.actionOverlay = null;
1622
+ addBreadcrumb("vault 3ds challenge overlay hidden");
1623
+ }
1624
+ /**
1625
+ * Size the hosted-widget iframe to the height reported by the form inside it.
1626
+ * Cross-origin iframes don't auto-size to their content, so the widget posts
1627
+ * its measured height and we apply it here (clamped to a sane range). This is
1628
+ * what lets the card form shrink/grow to fit instead of sitting at a fixed
1629
+ * height.
1630
+ */
1631
+ applyHeight(height) {
1632
+ const iframe = this.container?.querySelector("iframe");
1633
+ if (!iframe) return;
1634
+ const clamped = Math.max(0, Math.min(Math.ceil(height), 2e3));
1635
+ iframe.style.height = `${clamped}px`;
1636
+ }
1637
+ };
1638
+
1145
1639
  // src/flopay.ts
1146
1640
  var FloPay = class {
1147
1641
  constructor(provider, config) {
@@ -1177,6 +1671,23 @@ var FloPay = class {
1177
1671
  async confirmCardPayment(params) {
1178
1672
  return this.provider.confirmCardPayment(params);
1179
1673
  }
1674
+ /**
1675
+ * Create a {@link CardCaptureAdapter} for collecting card details through the
1676
+ * backend-rendered hosted vault PCI widget instead of provider-owned (Stripe)
1677
+ * card fields (TeamFloPay/backend#823).
1678
+ *
1679
+ * The returned adapter injects the server-supplied widget HTML (the session's
1680
+ * {@link CheckoutSession.vault} block, or one fetched via
1681
+ * `PaymentAPI.getVaultCapture`) and relays the widget's terminal outcome. The
1682
+ * backend owns tokenization, the PaymentIntent, 3DS, and fulfilment — no
1683
+ * Stripe.js is involved on the card path and PCI-sensitive fields never enter
1684
+ * the SDK runtime.
1685
+ */
1686
+ cardCapture(options) {
1687
+ return new PciVaultCardCapture({
1688
+ sessionId: options?.sessionId
1689
+ });
1690
+ }
1180
1691
  /** Confirm a PayPal payment: create intent via billing API → confirm → redirect if needed. */
1181
1692
  async confirmPayPalPayment(params) {
1182
1693
  return this.provider.confirmPayPalPayment(params);
@@ -1200,7 +1711,7 @@ var FloPay = class {
1200
1711
  */
1201
1712
  async retrieveSession(sessionId, billingApiUrl) {
1202
1713
  if (!sessionId) {
1203
- throw new FloPayError4(
1714
+ throw new FloPayError5(
1204
1715
  "sessionId is required to retrieve a session.",
1205
1716
  "validation_error",
1206
1717
  { param: "sessionId" }
@@ -1210,7 +1721,7 @@ var FloPay = class {
1210
1721
  const api = new PaymentAPI(apiUrl);
1211
1722
  const unified = await api.getUnifiedCheckoutSession(sessionId);
1212
1723
  if (!unified.data.session) {
1213
- throw new FloPayError4("Session not found", "api_error");
1724
+ throw new FloPayError5("Session not found", "api_error");
1214
1725
  }
1215
1726
  return unified.data.session;
1216
1727
  }
@@ -1223,7 +1734,7 @@ var FloPay = class {
1223
1734
  */
1224
1735
  async retrieveUnifiedSession(sessionId, billingApiUrl) {
1225
1736
  if (!sessionId) {
1226
- throw new FloPayError4(
1737
+ throw new FloPayError5(
1227
1738
  "sessionId is required.",
1228
1739
  "validation_error",
1229
1740
  { param: "sessionId" }
@@ -1253,7 +1764,7 @@ var FloPay = class {
1253
1764
  var instanceCache = /* @__PURE__ */ new Map();
1254
1765
  async function loadFloPay(publishableKey, options) {
1255
1766
  if (!publishableKey) {
1256
- throw new FloPayError5(
1767
+ throw new FloPayError6(
1257
1768
  "A publishable key is required to initialize FloPay.",
1258
1769
  "validation_error",
1259
1770
  { param: "publishableKey" }
@@ -1274,7 +1785,7 @@ async function loadFloPay(publishableKey, options) {
1274
1785
 
1275
1786
  // src/create-checkout-session.ts
1276
1787
  import {
1277
- FloPayError as FloPayError6,
1788
+ FloPayError as FloPayError7,
1278
1789
  SDK_VERSION as SDK_VERSION2,
1279
1790
  buildProductPayload as buildProductPayload2,
1280
1791
  foldIntoProducts as foldIntoProducts2,
@@ -1288,7 +1799,7 @@ function buildCheckoutSessionError(status, payload) {
1288
1799
  const nested = payload?.error;
1289
1800
  const code = readString2(payload?.code) ?? readString2(nested?.code) ?? `http_${status}`;
1290
1801
  const message = readString2(payload?.message) ?? readString2(nested?.message) ?? defaultMessageForCode(code, status);
1291
- return new FloPayError6(message, "api_error", { code, statusCode: status });
1802
+ return new FloPayError7(message, "api_error", { code, statusCode: status });
1292
1803
  }
1293
1804
  function defaultMessageForCode(code, status) {
1294
1805
  switch (code) {
@@ -1321,7 +1832,7 @@ async function createCheckoutSession(options) {
1321
1832
  utmMetadata
1322
1833
  } = options;
1323
1834
  if (couponCodes.length > MAX_COUPON_CODES) {
1324
- throw new FloPayError6(
1835
+ throw new FloPayError7(
1325
1836
  `Too many coupon codes \u2014 a checkout session accepts at most ${MAX_COUPON_CODES}.`,
1326
1837
  "validation_error",
1327
1838
  { code: "CouponLimitExceeded", param: "couponCodes" }
@@ -1330,7 +1841,7 @@ async function createCheckoutSession(options) {
1330
1841
  const wireProducts = products ?? foldIntoProducts2(items, subscriptions);
1331
1842
  const sessionCurrency = resolveSessionCurrency2(currency, items, subscriptions, wireProducts);
1332
1843
  if (!sessionCurrency) {
1333
- throw new FloPayError6(
1844
+ throw new FloPayError7(
1334
1845
  "currency is required: pass `currency` on the session, or include a `currency` on the first item/subscription/product.",
1335
1846
  "validation_error",
1336
1847
  { code: "CurrencyRequired", param: "currency" }
@@ -1395,7 +1906,7 @@ async function createCheckoutSession(options) {
1395
1906
  throw new Error("Checkout session created but no UUID was returned by the billing API");
1396
1907
  }
1397
1908
  if (!nonce) {
1398
- throw new FloPayError6(
1909
+ throw new FloPayError7(
1399
1910
  "Checkout session created but no `nonce` was returned by the billing API. Upgrade the billing service to TeamFloPay/backend#640 or later.",
1400
1911
  "api_error",
1401
1912
  { code: "MissingCheckoutSessionToken" }
@@ -1462,6 +1973,7 @@ export {
1462
1973
  FloPay,
1463
1974
  FloPayElements,
1464
1975
  PaymentAPI,
1976
+ PciVaultCardCapture,
1465
1977
  StripeAdapter,
1466
1978
  cacheSessionDisplayData,
1467
1979
  clearSessionDisplayData,