@flopay/js 1.2.8 → 1.3.0

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
@@ -33,6 +33,7 @@ __export(index_exports, {
33
33
  FloPay: () => FloPay,
34
34
  FloPayElements: () => FloPayElements,
35
35
  PaymentAPI: () => PaymentAPI,
36
+ PciVaultCardCapture: () => PciVaultCardCapture,
36
37
  StripeAdapter: () => StripeAdapter,
37
38
  cacheSessionDisplayData: () => cacheSessionDisplayData,
38
39
  clearSessionDisplayData: () => clearSessionDisplayData,
@@ -44,7 +45,7 @@ __export(index_exports, {
44
45
  module.exports = __toCommonJS(index_exports);
45
46
 
46
47
  // src/load.ts
47
- var import_shared5 = require("@flopay/shared");
48
+ var import_shared6 = require("@flopay/shared");
48
49
 
49
50
  // src/stripe-adapter.ts
50
51
  var import_shared = require("@flopay/shared");
@@ -506,7 +507,7 @@ var StripeAdapter = class {
506
507
  };
507
508
 
508
509
  // src/flopay.ts
509
- var import_shared4 = require("@flopay/shared");
510
+ var import_shared5 = require("@flopay/shared");
510
511
 
511
512
  // src/elements.ts
512
513
  var import_shared2 = require("@flopay/shared");
@@ -690,9 +691,11 @@ var PaymentAPI = class {
690
691
  * Backends that don't yet enforce it ignore the extra header.
691
692
  */
692
693
  async getCheckoutSession(checkoutSessionId, nonce) {
694
+ const headers = { [import_shared3.FLO_SDK_VERSION_HEADER]: import_shared3.SDK_VERSION };
695
+ if (nonce) headers["x-checkout-session-token"] = nonce;
693
696
  const response = await fetchWithNetworkRetry(
694
697
  `${this.baseUrl}/v1/checkouts/sessions/${encodeURIComponent(checkoutSessionId)}`,
695
- nonce ? { headers: { "x-checkout-session-token": nonce } } : void 0
698
+ { headers }
696
699
  );
697
700
  if (!response.ok) {
698
701
  throw await buildApiErrorFromResponse(response, "Failed to get checkout session");
@@ -729,6 +732,43 @@ var PaymentAPI = class {
729
732
  clearSessionDisplayData(sessionId) {
730
733
  clearSessionDisplayData(sessionId);
731
734
  }
735
+ /**
736
+ * Fetch (re-mint) the hosted vault capture widget for a session
737
+ * (TeamFloPay/backend#823).
738
+ *
739
+ * `POST /v1/checkouts/sessions/{id}/vault/capture` returns the SDK-ready
740
+ * {@link VaultCaptureBlock} (`html` + `url`, plus `messageToken` /
741
+ * `expectedOrigin` once the backend mints them). The SDK injects `html` as
742
+ * the card-capture widget. This is the fallback path for sessions that did
743
+ * not receive the embedded `vault` block on create (e.g. a session loaded by
744
+ * id via `GET`, or a pre-1.3.0 create); the endpoint is idempotent and reuses
745
+ * session-cached creds when available.
746
+ *
747
+ * Because the endpoint is idempotent, the request is wrapped in
748
+ * `fetchWithNetworkRetry`: a transient network blip (dropped connection, DNS
749
+ * hiccup, failed CORS preflight) would otherwise leave the secure card form
750
+ * unable to load and hard-block checkout.
751
+ *
752
+ * The PCIVault submit *secret* the backend may include in the response is
753
+ * intentionally **not** read or surfaced — it is server-only and never enters
754
+ * the SDK runtime.
755
+ *
756
+ * `nonce` is forwarded as `x-checkout-session-token` (required by post-#640
757
+ * backends, matched against the session's stored nonce).
758
+ */
759
+ async getVaultCapture(checkoutSessionId, nonce) {
760
+ const headers = { "Content-Type": "application/json" };
761
+ if (nonce) headers["x-checkout-session-token"] = nonce;
762
+ const response = await fetchWithNetworkRetry(
763
+ `${this.baseUrl}/v1/checkouts/sessions/${encodeURIComponent(checkoutSessionId)}/vault/capture`,
764
+ { method: "POST", headers }
765
+ );
766
+ if (!response.ok) {
767
+ throw await buildApiErrorFromResponse(response, "Failed to load the secure card form");
768
+ }
769
+ const block = await response.json();
770
+ return this.toVaultBlock(block);
771
+ }
732
772
  /**
733
773
  * Fetch and normalize a checkout session.
734
774
  *
@@ -738,7 +778,12 @@ var PaymentAPI = class {
738
778
  */
739
779
  async getUnifiedCheckoutSession(checkoutSessionId, nonce) {
740
780
  const res = await this.getCheckoutSession(checkoutSessionId, nonce);
741
- return this.normalizeRawSession(res.data);
781
+ const normalized = this.normalizeRawSession(res.data);
782
+ const vault = res.vault;
783
+ if (vault && normalized.data.session) {
784
+ normalized.data.session.vault = this.toVaultBlock(vault);
785
+ }
786
+ return normalized;
742
787
  }
743
788
  /**
744
789
  * Submit a tokenized payment to the billing backend.
@@ -779,6 +824,43 @@ var PaymentAPI = class {
779
824
  );
780
825
  return this.resolveProcessResponse(response, data.sessionId, { ...options, nonce });
781
826
  }
827
+ /**
828
+ * Patch the buyer's account snapshot (email, name, billing address, AVS
829
+ * intent) onto a checkout session via
830
+ * `PATCH /v1/checkouts/sessions/{id}/account` (TeamFloPay/backend#823).
831
+ *
832
+ * The vault path's hosted form owns the charge end-to-end so the SDK
833
+ * never calls `/process` on this path; the buyer-typed AVS / billing
834
+ * address would otherwise be lost. The SDK calls this just before
835
+ * submitting the vault widget so the downstream listener mints the
836
+ * Stripe PaymentMethod with the right `billing_details.address` and the
837
+ * per-attempt + per-PM address snapshots are populated.
838
+ *
839
+ * Body shape mirrors the relevant subset of `/process`'s
840
+ * `ProcessCheckoutBodyDto` — same keys, same validators. The endpoint
841
+ * is idempotent: empty/undefined fields are not written, addresses are
842
+ * last-writer-wins, AVS analytics are first-writer-wins.
843
+ *
844
+ * Wrapped in `fetchWithNetworkRetry` because a transient blip on this
845
+ * pre-pay PATCH would silently leave AVS unsent and cause an
846
+ * AVS-protected charge to decline downstream.
847
+ */
848
+ async patchAccountSnapshot(sessionId, nonce, body) {
849
+ const response = await fetchWithNetworkRetry(
850
+ `${this.baseUrl}/v1/checkouts/sessions/${encodeURIComponent(sessionId)}/account`,
851
+ {
852
+ method: "PATCH",
853
+ headers: {
854
+ "Content-Type": "application/json",
855
+ "x-checkout-session-token": nonce
856
+ },
857
+ body: JSON.stringify(body)
858
+ }
859
+ );
860
+ if (!response.ok) {
861
+ throw await buildApiErrorFromResponse(response, "Failed to persist account snapshot");
862
+ }
863
+ }
782
864
  /**
783
865
  * Create a PaymentIntent on the backend.
784
866
  *
@@ -789,6 +871,13 @@ var PaymentAPI = class {
789
871
  * session-bound checkout token returned by session creation. Post-#640
790
872
  * backends reject this call with a 401 when the header is missing or does
791
873
  * not match the session's stored nonce.
874
+ *
875
+ * `paymentMethodType` is optional for the vault card-capture flow
876
+ * (TeamFloPay/backend#823): the frontend starts checkout *without* an upfront
877
+ * card payment method, so it may be omitted (or `null`). The hosted vault PCI
878
+ * form captures the card afterwards and the backend attaches the resulting
879
+ * payment method to the PaymentIntent it returns here. Legacy callers keep
880
+ * passing the concrete payment method id / type.
792
881
  */
793
882
  async createPaymentIntent(sessionId, email, paymentMethodType, options) {
794
883
  const headers = { "Content-Type": "application/json" };
@@ -801,7 +890,7 @@ var PaymentAPI = class {
801
890
  body: JSON.stringify({
802
891
  sessionId,
803
892
  email,
804
- paymentMethodType,
893
+ paymentMethodType: paymentMethodType ?? null,
805
894
  isPaypal: options?.isPaypal ?? false
806
895
  }),
807
896
  signal: options?.signal
@@ -910,7 +999,13 @@ var PaymentAPI = class {
910
999
  `${this.baseUrl}/v1/checkouts/sessions?expand=true`,
911
1000
  {
912
1001
  method: "POST",
913
- headers: { "Content-Type": "application/json" },
1002
+ // Declare the SDK version so backends at TeamFloPay/backend#823 embed
1003
+ // the hosted vault capture block (`body.vault`) in the response for
1004
+ // SDKs ≥ 1.3.0. Older backends ignore the header.
1005
+ headers: {
1006
+ "Content-Type": "application/json",
1007
+ [import_shared3.FLO_SDK_VERSION_HEADER]: import_shared3.SDK_VERSION
1008
+ },
914
1009
  body: JSON.stringify(payload)
915
1010
  }
916
1011
  );
@@ -928,8 +1023,12 @@ var PaymentAPI = class {
928
1023
  if (body.data && "gateways" in body.data) {
929
1024
  this.autoCacheDisplayData(body.data.uuid, params);
930
1025
  const merged = this.mergeCachedDisplayData(body.data);
1026
+ const normalized = this.normalizeRawSession(merged);
1027
+ if (body.vault && normalized.data.session) {
1028
+ normalized.data.session.vault = this.toVaultBlock(body.vault);
1029
+ }
931
1030
  return {
932
- ...this.normalizeRawSession(merged),
1031
+ ...normalized,
933
1032
  autoProcessingError: body.autoProcessingError,
934
1033
  autoProcessingAttempted: body.autoProcessingAttempted,
935
1034
  autoProcessingPending: body.autoProcessingPending
@@ -1048,6 +1147,7 @@ var PaymentAPI = class {
1048
1147
  },
1049
1148
  metadata: {},
1050
1149
  checkoutMode: raw.checkoutMode,
1150
+ providerPaymentMethodId: typeof raw.providerPaymentMethodId === "string" ? raw.providerPaymentMethodId : null,
1051
1151
  products: rawProducts.map((p) => ({
1052
1152
  ...p,
1053
1153
  totalAmount: typeof p.totalAmount === "number" ? p.totalAmount : void 0,
@@ -1067,6 +1167,19 @@ var PaymentAPI = class {
1067
1167
  tagsData: raw.tagsData
1068
1168
  };
1069
1169
  }
1170
+ /**
1171
+ * Coerce a raw vault block into a typed {@link VaultCaptureBlock}. The
1172
+ * server-only PCIVault submit `secret` is deliberately dropped so it never
1173
+ * lands on the public session surface (logs / telemetry / client inspection).
1174
+ */
1175
+ toVaultBlock(raw) {
1176
+ return {
1177
+ html: typeof raw.html === "string" ? raw.html : void 0,
1178
+ url: typeof raw.url === "string" ? raw.url : void 0,
1179
+ messageToken: typeof raw.messageToken === "string" ? raw.messageToken : void 0,
1180
+ expectedOrigin: typeof raw.expectedOrigin === "string" ? raw.expectedOrigin : void 0
1181
+ };
1182
+ }
1070
1183
  toCheckoutSessionStatus(status) {
1071
1184
  if (status === "completed") {
1072
1185
  return "complete";
@@ -1181,6 +1294,378 @@ var PaymentAPI = class {
1181
1294
  }
1182
1295
  };
1183
1296
 
1297
+ // src/pci-vault-card-capture.ts
1298
+ var import_shared4 = require("@flopay/shared");
1299
+ var VAULT_MESSAGE_SOURCE = "flopay-vault";
1300
+ function addBreadcrumb(message, data) {
1301
+ const sentry = globalThis.Sentry;
1302
+ sentry?.addBreadcrumb?.({ category: "flopay.card-capture", level: "info", message, data });
1303
+ }
1304
+ function isVaultResultMessage(value) {
1305
+ if (typeof value !== "object" || value === null) return false;
1306
+ const record = value;
1307
+ 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");
1308
+ }
1309
+ function isVaultValidationMessage(value) {
1310
+ if (typeof value !== "object" || value === null) return false;
1311
+ const record = value;
1312
+ return record["source"] === VAULT_MESSAGE_SOURCE && record["type"] === "validation" && Array.isArray(record["messages"]);
1313
+ }
1314
+ function isVaultResizeMessage(value) {
1315
+ if (typeof value !== "object" || value === null) return false;
1316
+ const record = value;
1317
+ return record["source"] === VAULT_MESSAGE_SOURCE && record["type"] === "resize" && typeof record["height"] === "number" && Number.isFinite(record["height"]);
1318
+ }
1319
+ var PciVaultCardCapture = class {
1320
+ constructor(config = {}) {
1321
+ this.provider = "pcivault";
1322
+ this.container = null;
1323
+ this.messageHandler = null;
1324
+ /**
1325
+ * Parent-page-level overlay rendering the provider's verification challenge
1326
+ * (3DS-2 iframe) on `action_required`. Owned by the adapter — not the
1327
+ * widget — so it can sit above the host SDK's processing backdrop, which
1328
+ * would otherwise visually cover an in-widget challenge iframe.
1329
+ */
1330
+ this.actionOverlay = null;
1331
+ /**
1332
+ * Listener that catches the `flopay-vault-3ds-return` postMessage from the
1333
+ * provider's challenge return page. When the SDK owns the challenge iframe
1334
+ * the return page lives inside *that* iframe (not the widget's), so
1335
+ * `window.parent` is the host page — the widget's existing message
1336
+ * listener can't see it. The SDK forwards completion into the widget via
1337
+ * `action_completed` so the widget kicks `/3ds/complete` immediately
1338
+ * instead of waiting on the eventual provider webhook.
1339
+ */
1340
+ this.threeDsReturnHandler = null;
1341
+ /** Per-session integrity token to require on outcomes (from mount options). */
1342
+ this.messageToken = null;
1343
+ /** Strict origin to require on outcomes, when configured. */
1344
+ this.expectedOrigin = null;
1345
+ /** Latest merchant theme to push into the (cross-origin) widget. */
1346
+ this.theme = null;
1347
+ /** Latest host submit-gate state to push into the widget (block its submit). */
1348
+ this.submitGateBlocked = false;
1349
+ /** Latest card-field order + autofocus directive to push into the widget. */
1350
+ this.cardFieldOrder = null;
1351
+ this.cardAutoFocus = true;
1352
+ this.listeners = /* @__PURE__ */ new Map();
1353
+ this.config = config;
1354
+ }
1355
+ async mount(container, options) {
1356
+ if (typeof window === "undefined" || typeof document === "undefined") {
1357
+ throw new import_shared4.FloPayError(
1358
+ "The vault card form is only available in the browser.",
1359
+ "api_error",
1360
+ { code: "card_capture_no_window" }
1361
+ );
1362
+ }
1363
+ if (!options?.html || !options.html.trim()) {
1364
+ throw new import_shared4.FloPayError(
1365
+ "No vault capture widget HTML was provided to mount the secure card form.",
1366
+ "api_error",
1367
+ { code: "card_capture_no_widget_html" }
1368
+ );
1369
+ }
1370
+ this.container = container;
1371
+ this.messageToken = options.messageToken ?? null;
1372
+ this.expectedOrigin = options.expectedOrigin ?? this.config.expectedOrigin ?? null;
1373
+ this.theme = options.theme ?? null;
1374
+ this.attachMessageListener();
1375
+ this.injectWidget(container, options.html);
1376
+ this.postTheme();
1377
+ this.postSubmitGate();
1378
+ this.postCardFieldOrder();
1379
+ addBreadcrumb("vault widget mounted", { sessionId: this.config.sessionId });
1380
+ this.emit("ready", { sessionId: this.config.sessionId });
1381
+ }
1382
+ on(event, handler) {
1383
+ let set = this.listeners.get(event);
1384
+ if (!set) {
1385
+ set = /* @__PURE__ */ new Set();
1386
+ this.listeners.set(event, set);
1387
+ }
1388
+ set.add(handler);
1389
+ return () => {
1390
+ this.listeners.get(event)?.delete(handler);
1391
+ };
1392
+ }
1393
+ unmount() {
1394
+ this.hideActionRequiredOverlay();
1395
+ if (this.messageHandler) {
1396
+ window.removeEventListener("message", this.messageHandler);
1397
+ this.messageHandler = null;
1398
+ }
1399
+ if (this.container) {
1400
+ this.container.replaceChildren();
1401
+ this.container = null;
1402
+ }
1403
+ this.messageToken = null;
1404
+ this.expectedOrigin = null;
1405
+ }
1406
+ // ── internals ──
1407
+ /**
1408
+ * Inject the server-rendered widget HTML. `innerHTML` does not execute
1409
+ * embedded `<script>` tags, so each script node is replaced with a freshly
1410
+ * created element that the browser will load and run (this is what boots the
1411
+ * PCIVault form bundle against the `data-flopay-config` container).
1412
+ */
1413
+ injectWidget(container, html) {
1414
+ container.innerHTML = html;
1415
+ const scripts = Array.from(container.querySelectorAll("script"));
1416
+ for (const oldScript of scripts) {
1417
+ const script = document.createElement("script");
1418
+ for (const attr of Array.from(oldScript.attributes)) {
1419
+ script.setAttribute(attr.name, attr.value);
1420
+ }
1421
+ script.text = oldScript.text;
1422
+ oldScript.replaceWith(script);
1423
+ }
1424
+ }
1425
+ attachMessageListener() {
1426
+ if (this.messageHandler) return;
1427
+ const handler = (event) => {
1428
+ if (this.expectedOrigin && event.origin !== this.expectedOrigin) return;
1429
+ const data = event.data;
1430
+ if (isVaultResizeMessage(data)) {
1431
+ if (this.messageToken && data.messageToken !== this.messageToken) return;
1432
+ this.applyHeight(data.height);
1433
+ return;
1434
+ }
1435
+ if (isVaultValidationMessage(data)) {
1436
+ if (this.messageToken && data.messageToken !== this.messageToken) return;
1437
+ const text = data.messages.filter((m) => typeof m === "string" && m.trim()).join(" ");
1438
+ this.emit("validation", { sessionId: this.config.sessionId, message: text || void 0 });
1439
+ return;
1440
+ }
1441
+ if (!isVaultResultMessage(data)) return;
1442
+ if (this.messageToken && data.messageToken !== this.messageToken) return;
1443
+ const boundSession = this.config.sessionId;
1444
+ const incomingSession = typeof data.sessionId === "string" ? data.sessionId : void 0;
1445
+ if (boundSession && incomingSession && incomingSession !== boundSession) {
1446
+ return;
1447
+ }
1448
+ if ((data.type === "complete" || data.type === "decline") && boundSession && incomingSession !== boundSession) {
1449
+ return;
1450
+ }
1451
+ const outcome = {
1452
+ sessionId: data.sessionId ?? this.config.sessionId,
1453
+ intentId: data.intentId,
1454
+ declineReason: data.declineReason,
1455
+ message: data.message,
1456
+ nextActionRedirectUrl: data.nextActionRedirectUrl
1457
+ };
1458
+ addBreadcrumb(`vault widget ${data.type}`, {
1459
+ sessionId: outcome.sessionId,
1460
+ declineReason: outcome.declineReason
1461
+ });
1462
+ if (data.type === "ready") {
1463
+ this.postTheme();
1464
+ this.postSubmitGate();
1465
+ this.postCardFieldOrder();
1466
+ }
1467
+ if (data.type === "action_required" && data.nextActionRedirectUrl) {
1468
+ this.showActionRequiredOverlay(data.nextActionRedirectUrl);
1469
+ }
1470
+ if (data.type === "complete" || data.type === "decline" || data.type === "error" || data.type === "submitting") {
1471
+ this.hideActionRequiredOverlay();
1472
+ }
1473
+ this.emit(data.type, outcome);
1474
+ };
1475
+ this.messageHandler = handler;
1476
+ window.addEventListener("message", handler);
1477
+ }
1478
+ /**
1479
+ * Push merchant theme colors into the hosted widget (live). The host calls
1480
+ * this on a runtime theme switch; the widget applies them to its CSS variables
1481
+ * without a remount. Stores the latest theme so `ready` can re-push it.
1482
+ */
1483
+ applyTheme(theme) {
1484
+ this.theme = theme;
1485
+ this.postTheme();
1486
+ }
1487
+ /** postMessage the current theme to the widget's (cross-origin) document. */
1488
+ postTheme() {
1489
+ if (!this.theme || !this.container) return;
1490
+ const iframe = this.container.querySelector("iframe");
1491
+ const target = iframe?.contentWindow;
1492
+ if (!target) return;
1493
+ try {
1494
+ target.postMessage({ source: "flopay-vault-host", type: "theme", theme: this.theme }, "*");
1495
+ } catch {
1496
+ }
1497
+ }
1498
+ /**
1499
+ * Gate the widget's submit from the host. When `blocked`, the widget cancels
1500
+ * its next submit and emits `'blocked'` instead of `'submitting'` so the host
1501
+ * can validate merchant-DOM fields (AVS) first. Stored so `ready` re-pushes it.
1502
+ */
1503
+ setSubmitGate(blocked) {
1504
+ this.submitGateBlocked = blocked;
1505
+ this.postSubmitGate();
1506
+ }
1507
+ /** postMessage the current submit-gate state to the widget's document. */
1508
+ postSubmitGate() {
1509
+ if (!this.container) return;
1510
+ const iframe = this.container.querySelector("iframe");
1511
+ const target = iframe?.contentWindow;
1512
+ if (!target) return;
1513
+ try {
1514
+ target.postMessage(
1515
+ { source: "flopay-vault-host", type: "gate", blocked: this.submitGateBlocked },
1516
+ "*"
1517
+ );
1518
+ } catch {
1519
+ }
1520
+ }
1521
+ /**
1522
+ * Push the card-field order + autofocus directive into the widget (live). The
1523
+ * widget re-sequences its rows (DOM order, so tab order follows) and focuses
1524
+ * its first field unless `autoFocus` is false. Stored so `ready` re-pushes it.
1525
+ */
1526
+ setCardFieldOrder(order, autoFocus) {
1527
+ this.cardFieldOrder = order;
1528
+ this.cardAutoFocus = autoFocus;
1529
+ this.postCardFieldOrder();
1530
+ }
1531
+ /** postMessage the current field order + autofocus to the widget's document. */
1532
+ postCardFieldOrder() {
1533
+ if (!this.container) return;
1534
+ const iframe = this.container.querySelector("iframe");
1535
+ const target = iframe?.contentWindow;
1536
+ if (!target) return;
1537
+ try {
1538
+ target.postMessage(
1539
+ {
1540
+ source: "flopay-vault-host",
1541
+ type: "fieldOrder",
1542
+ order: this.cardFieldOrder,
1543
+ autoFocus: this.cardAutoFocus
1544
+ },
1545
+ "*"
1546
+ );
1547
+ } catch {
1548
+ }
1549
+ }
1550
+ emit(event, payload) {
1551
+ for (const handler of this.listeners.get(event) ?? []) {
1552
+ handler(payload);
1553
+ }
1554
+ }
1555
+ /**
1556
+ * Render the provider-hosted verification challenge (e.g. Stripe 3DS-2) in a
1557
+ * full-page overlay at the PARENT page level. The widget's inline-iframe
1558
+ * approach is unusable because the SDK's processing backdrop sits above the
1559
+ * vault iframe, hiding any challenge mounted inside it — by lifting the
1560
+ * iframe to the host page the adapter can give it a z-index that wins.
1561
+ *
1562
+ * The overlay tears down on the next terminal outcome
1563
+ * (`complete`/`decline`/`error`) or when the buyer closes it via the backdrop
1564
+ * close button. Closing manually is a soft abandon — the next `/status` poll
1565
+ * either reveals a real outcome (the challenge completed via the issuer's
1566
+ * own redirect to `/vault/3ds/return`, which posts back into the widget) or
1567
+ * surfaces `requires_action` again so the host can decide what to do.
1568
+ */
1569
+ showActionRequiredOverlay(challengeUrl) {
1570
+ if (typeof document === "undefined") return;
1571
+ if (this.actionOverlay) {
1572
+ const existingIframe = this.actionOverlay.querySelector("iframe");
1573
+ if (existingIframe instanceof HTMLIFrameElement) {
1574
+ existingIframe.src = challengeUrl;
1575
+ }
1576
+ return;
1577
+ }
1578
+ const backdrop = document.createElement("div");
1579
+ backdrop.setAttribute("data-flopay-action-required", "1");
1580
+ backdrop.style.cssText = [
1581
+ "position:fixed",
1582
+ "inset:0",
1583
+ // Maximum signed 32-bit z-index; the SDK's own processing backdrop sits
1584
+ // well below this so the challenge is visible and interactive.
1585
+ "z-index:2147483647",
1586
+ "background:rgba(15,23,42,0.6)",
1587
+ "display:flex",
1588
+ "align-items:center",
1589
+ "justify-content:center",
1590
+ "padding:16px"
1591
+ ].join(";");
1592
+ const frame = document.createElement("iframe");
1593
+ frame.setAttribute("title", "Card authentication");
1594
+ frame.setAttribute("allow", "payment");
1595
+ frame.style.cssText = [
1596
+ "width:min(100%,460px)",
1597
+ "height:min(100%,640px)",
1598
+ "border:0",
1599
+ "border-radius:12px",
1600
+ "background:#fff",
1601
+ "box-shadow:0 12px 30px rgba(0,0,0,0.35)"
1602
+ ].join(";");
1603
+ frame.src = challengeUrl;
1604
+ backdrop.appendChild(frame);
1605
+ const returnHandler = (event) => {
1606
+ if (event.source !== frame.contentWindow) return;
1607
+ const data = event.data;
1608
+ if (!data || typeof data !== "object") return;
1609
+ const record = data;
1610
+ if (record["source"] !== "flopay-vault-3ds-return") return;
1611
+ this.hideActionRequiredOverlay();
1612
+ this.postActionCompleted(record["status"]);
1613
+ };
1614
+ window.addEventListener("message", returnHandler);
1615
+ this.threeDsReturnHandler = returnHandler;
1616
+ document.body.appendChild(backdrop);
1617
+ this.actionOverlay = backdrop;
1618
+ addBreadcrumb("vault 3ds challenge overlay shown");
1619
+ }
1620
+ /**
1621
+ * Tell the vault widget that the buyer has completed (or abandoned) the
1622
+ * challenge. The widget responds by POSTing `/3ds/complete` — its
1623
+ * sub-300ms sync resolver writes the follow-up attempt row immediately,
1624
+ * so the next `/status` poll resolves to a terminal outcome instead of
1625
+ * waiting for the eventual provider webhook.
1626
+ */
1627
+ postActionCompleted(status) {
1628
+ if (!this.container) return;
1629
+ const iframe = this.container.querySelector("iframe");
1630
+ const target = iframe?.contentWindow;
1631
+ if (!target) return;
1632
+ try {
1633
+ target.postMessage(
1634
+ {
1635
+ source: "flopay-vault-host",
1636
+ type: "action_completed",
1637
+ status: typeof status === "string" ? status : "unknown"
1638
+ },
1639
+ "*"
1640
+ );
1641
+ } catch {
1642
+ }
1643
+ }
1644
+ hideActionRequiredOverlay() {
1645
+ if (this.threeDsReturnHandler) {
1646
+ window.removeEventListener("message", this.threeDsReturnHandler);
1647
+ this.threeDsReturnHandler = null;
1648
+ }
1649
+ if (!this.actionOverlay) return;
1650
+ this.actionOverlay.parentNode?.removeChild(this.actionOverlay);
1651
+ this.actionOverlay = null;
1652
+ addBreadcrumb("vault 3ds challenge overlay hidden");
1653
+ }
1654
+ /**
1655
+ * Size the hosted-widget iframe to the height reported by the form inside it.
1656
+ * Cross-origin iframes don't auto-size to their content, so the widget posts
1657
+ * its measured height and we apply it here (clamped to a sane range). This is
1658
+ * what lets the card form shrink/grow to fit instead of sitting at a fixed
1659
+ * height.
1660
+ */
1661
+ applyHeight(height) {
1662
+ const iframe = this.container?.querySelector("iframe");
1663
+ if (!iframe) return;
1664
+ const clamped = Math.max(0, Math.min(Math.ceil(height), 2e3));
1665
+ iframe.style.height = `${clamped}px`;
1666
+ }
1667
+ };
1668
+
1184
1669
  // src/flopay.ts
1185
1670
  var FloPay = class {
1186
1671
  constructor(provider, config) {
@@ -1216,6 +1701,23 @@ var FloPay = class {
1216
1701
  async confirmCardPayment(params) {
1217
1702
  return this.provider.confirmCardPayment(params);
1218
1703
  }
1704
+ /**
1705
+ * Create a {@link CardCaptureAdapter} for collecting card details through the
1706
+ * backend-rendered hosted vault PCI widget instead of provider-owned (Stripe)
1707
+ * card fields (TeamFloPay/backend#823).
1708
+ *
1709
+ * The returned adapter injects the server-supplied widget HTML (the session's
1710
+ * {@link CheckoutSession.vault} block, or one fetched via
1711
+ * `PaymentAPI.getVaultCapture`) and relays the widget's terminal outcome. The
1712
+ * backend owns tokenization, the PaymentIntent, 3DS, and fulfilment — no
1713
+ * Stripe.js is involved on the card path and PCI-sensitive fields never enter
1714
+ * the SDK runtime.
1715
+ */
1716
+ cardCapture(options) {
1717
+ return new PciVaultCardCapture({
1718
+ sessionId: options?.sessionId
1719
+ });
1720
+ }
1219
1721
  /** Confirm a PayPal payment: create intent via billing API → confirm → redirect if needed. */
1220
1722
  async confirmPayPalPayment(params) {
1221
1723
  return this.provider.confirmPayPalPayment(params);
@@ -1239,17 +1741,17 @@ var FloPay = class {
1239
1741
  */
1240
1742
  async retrieveSession(sessionId, billingApiUrl) {
1241
1743
  if (!sessionId) {
1242
- throw new import_shared4.FloPayError(
1744
+ throw new import_shared5.FloPayError(
1243
1745
  "sessionId is required to retrieve a session.",
1244
1746
  "validation_error",
1245
1747
  { param: "sessionId" }
1246
1748
  );
1247
1749
  }
1248
- const apiUrl = (0, import_shared4.resolveBillingApiUrl)(billingApiUrl ?? this.config.billingApiUrl);
1750
+ const apiUrl = (0, import_shared5.resolveBillingApiUrl)(billingApiUrl ?? this.config.billingApiUrl);
1249
1751
  const api = new PaymentAPI(apiUrl);
1250
1752
  const unified = await api.getUnifiedCheckoutSession(sessionId);
1251
1753
  if (!unified.data.session) {
1252
- throw new import_shared4.FloPayError("Session not found", "api_error");
1754
+ throw new import_shared5.FloPayError("Session not found", "api_error");
1253
1755
  }
1254
1756
  return unified.data.session;
1255
1757
  }
@@ -1262,13 +1764,13 @@ var FloPay = class {
1262
1764
  */
1263
1765
  async retrieveUnifiedSession(sessionId, billingApiUrl) {
1264
1766
  if (!sessionId) {
1265
- throw new import_shared4.FloPayError(
1767
+ throw new import_shared5.FloPayError(
1266
1768
  "sessionId is required.",
1267
1769
  "validation_error",
1268
1770
  { param: "sessionId" }
1269
1771
  );
1270
1772
  }
1271
- const apiUrl = (0, import_shared4.resolveBillingApiUrl)(billingApiUrl ?? this.config.billingApiUrl);
1773
+ const apiUrl = (0, import_shared5.resolveBillingApiUrl)(billingApiUrl ?? this.config.billingApiUrl);
1272
1774
  const api = new PaymentAPI(apiUrl);
1273
1775
  return api.getUnifiedCheckoutSession(sessionId);
1274
1776
  }
@@ -1292,7 +1794,7 @@ var FloPay = class {
1292
1794
  var instanceCache = /* @__PURE__ */ new Map();
1293
1795
  async function loadFloPay(publishableKey, options) {
1294
1796
  if (!publishableKey) {
1295
- throw new import_shared5.FloPayError(
1797
+ throw new import_shared6.FloPayError(
1296
1798
  "A publishable key is required to initialize FloPay.",
1297
1799
  "validation_error",
1298
1800
  { param: "publishableKey" }
@@ -1312,7 +1814,7 @@ async function loadFloPay(publishableKey, options) {
1312
1814
  }
1313
1815
 
1314
1816
  // src/create-checkout-session.ts
1315
- var import_shared6 = require("@flopay/shared");
1817
+ var import_shared7 = require("@flopay/shared");
1316
1818
  var MAX_COUPON_CODES = 5;
1317
1819
  function readString2(value) {
1318
1820
  return typeof value === "string" && value.trim() ? value : void 0;
@@ -1321,7 +1823,7 @@ function buildCheckoutSessionError(status, payload) {
1321
1823
  const nested = payload?.error;
1322
1824
  const code = readString2(payload?.code) ?? readString2(nested?.code) ?? `http_${status}`;
1323
1825
  const message = readString2(payload?.message) ?? readString2(nested?.message) ?? defaultMessageForCode(code, status);
1324
- return new import_shared6.FloPayError(message, "api_error", { code, statusCode: status });
1826
+ return new import_shared7.FloPayError(message, "api_error", { code, statusCode: status });
1325
1827
  }
1326
1828
  function defaultMessageForCode(code, status) {
1327
1829
  switch (code) {
@@ -1354,16 +1856,16 @@ async function createCheckoutSession(options) {
1354
1856
  utmMetadata
1355
1857
  } = options;
1356
1858
  if (couponCodes.length > MAX_COUPON_CODES) {
1357
- throw new import_shared6.FloPayError(
1859
+ throw new import_shared7.FloPayError(
1358
1860
  `Too many coupon codes \u2014 a checkout session accepts at most ${MAX_COUPON_CODES}.`,
1359
1861
  "validation_error",
1360
1862
  { code: "CouponLimitExceeded", param: "couponCodes" }
1361
1863
  );
1362
1864
  }
1363
- const wireProducts = products ?? (0, import_shared6.foldIntoProducts)(items, subscriptions);
1364
- const sessionCurrency = (0, import_shared6.resolveSessionCurrency)(currency, items, subscriptions, wireProducts);
1865
+ const wireProducts = products ?? (0, import_shared7.foldIntoProducts)(items, subscriptions);
1866
+ const sessionCurrency = (0, import_shared7.resolveSessionCurrency)(currency, items, subscriptions, wireProducts);
1365
1867
  if (!sessionCurrency) {
1366
- throw new import_shared6.FloPayError(
1868
+ throw new import_shared7.FloPayError(
1367
1869
  "currency is required: pass `currency` on the session, or include a `currency` on the first item/subscription/product.",
1368
1870
  "validation_error",
1369
1871
  { code: "CurrencyRequired", param: "currency" }
@@ -1371,12 +1873,12 @@ async function createCheckoutSession(options) {
1371
1873
  }
1372
1874
  const payload = {
1373
1875
  clientId,
1374
- checkoutVersion: import_shared6.SDK_VERSION,
1876
+ checkoutVersion: import_shared7.SDK_VERSION,
1375
1877
  successUrl,
1376
1878
  cancelUrl,
1377
1879
  currency: sessionCurrency,
1378
1880
  checkoutMode,
1379
- products: wireProducts.map((product) => (0, import_shared6.buildProductPayload)(product, sessionCurrency)),
1881
+ products: wireProducts.map((product) => (0, import_shared7.buildProductPayload)(product, sessionCurrency)),
1380
1882
  accountData: {
1381
1883
  userId: account.userId,
1382
1884
  firstName: account.firstName ?? null,
@@ -1428,7 +1930,7 @@ async function createCheckoutSession(options) {
1428
1930
  throw new Error("Checkout session created but no UUID was returned by the billing API");
1429
1931
  }
1430
1932
  if (!nonce) {
1431
- throw new import_shared6.FloPayError(
1933
+ throw new import_shared7.FloPayError(
1432
1934
  "Checkout session created but no `nonce` was returned by the billing API. Upgrade the billing service to TeamFloPay/backend#640 or later.",
1433
1935
  "api_error",
1434
1936
  { code: "MissingCheckoutSessionToken" }
@@ -1496,6 +1998,7 @@ async function createCheckoutSessionWithRetries(options) {
1496
1998
  FloPay,
1497
1999
  FloPayElements,
1498
2000
  PaymentAPI,
2001
+ PciVaultCardCapture,
1499
2002
  StripeAdapter,
1500
2003
  cacheSessionDisplayData,
1501
2004
  clearSessionDisplayData,