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