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