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