@flopay/js 1.2.7 → 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/README.md +45 -2
- package/dist/index.cjs +559 -26
- 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 +554 -21
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
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
|
-
import { FloPayError } from "@flopay/shared";
|
|
5
|
+
import { FloPayError, isSetupIntentClientSecret } from "@flopay/shared";
|
|
6
6
|
function toStripeElementType(type) {
|
|
7
7
|
const map = {
|
|
8
8
|
payment: "payment",
|
|
@@ -100,18 +100,27 @@ var StripeAdapter = class {
|
|
|
100
100
|
const nextAppearanceKey = stripeAppearance ? JSON.stringify(stripeAppearance) : null;
|
|
101
101
|
if (!this.elements) {
|
|
102
102
|
let elementsOptions;
|
|
103
|
+
const deferredAmount = options?.amount ?? 0;
|
|
104
|
+
const deferredCurrency = (options?.currency ?? "usd").toLowerCase();
|
|
105
|
+
const paymentMethodCreation = options?.paymentMethodCreation ?? "manual";
|
|
103
106
|
if (options?.clientSecret) {
|
|
104
107
|
elementsOptions = { clientSecret: options.clientSecret };
|
|
105
|
-
} else {
|
|
108
|
+
} else if (deferredAmount > 0) {
|
|
106
109
|
elementsOptions = {
|
|
107
110
|
mode: "payment",
|
|
108
|
-
amount:
|
|
109
|
-
currency:
|
|
110
|
-
paymentMethodCreation
|
|
111
|
+
amount: deferredAmount,
|
|
112
|
+
currency: deferredCurrency,
|
|
113
|
+
paymentMethodCreation
|
|
111
114
|
};
|
|
112
115
|
if (options?.setupFutureUsage) {
|
|
113
116
|
elementsOptions["setupFutureUsage"] = options.setupFutureUsage;
|
|
114
117
|
}
|
|
118
|
+
} else {
|
|
119
|
+
elementsOptions = {
|
|
120
|
+
mode: "setup",
|
|
121
|
+
currency: deferredCurrency,
|
|
122
|
+
paymentMethodCreation
|
|
123
|
+
};
|
|
115
124
|
}
|
|
116
125
|
if (stripeAppearance) {
|
|
117
126
|
elementsOptions["appearance"] = stripeAppearance;
|
|
@@ -218,6 +227,27 @@ var StripeAdapter = class {
|
|
|
218
227
|
error: new FloPayError("Stripe not initialized", "api_error")
|
|
219
228
|
};
|
|
220
229
|
}
|
|
230
|
+
if (isSetupIntentClientSecret(params.clientSecret)) {
|
|
231
|
+
const { error: setupError, setupIntent } = await this.stripe.confirmCardSetup(
|
|
232
|
+
params.clientSecret,
|
|
233
|
+
{ payment_method: params.paymentMethodId }
|
|
234
|
+
);
|
|
235
|
+
if (setupError) {
|
|
236
|
+
return {
|
|
237
|
+
status: "failed",
|
|
238
|
+
error: new FloPayError(
|
|
239
|
+
setupError.message ?? "Payment failed",
|
|
240
|
+
"api_error",
|
|
241
|
+
{ code: setupError.code, declineCode: setupError.decline_code }
|
|
242
|
+
)
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
status: setupIntent?.status ?? "failed",
|
|
247
|
+
paymentIntentId: setupIntent?.id,
|
|
248
|
+
paymentMethodId: this.extractPaymentMethodId(setupIntent?.payment_method)
|
|
249
|
+
};
|
|
250
|
+
}
|
|
221
251
|
const { error, paymentIntent } = await this.stripe.confirmCardPayment(
|
|
222
252
|
params.clientSecret,
|
|
223
253
|
{ payment_method: params.paymentMethodId }
|
|
@@ -431,7 +461,7 @@ var StripeAdapter = class {
|
|
|
431
461
|
};
|
|
432
462
|
|
|
433
463
|
// src/flopay.ts
|
|
434
|
-
import { FloPayError as
|
|
464
|
+
import { FloPayError as FloPayError5, resolveBillingApiUrl } from "@flopay/shared";
|
|
435
465
|
|
|
436
466
|
// src/elements.ts
|
|
437
467
|
import "@flopay/shared";
|
|
@@ -486,6 +516,7 @@ var FloPayElements = class {
|
|
|
486
516
|
import {
|
|
487
517
|
FloPayError as FloPayError3,
|
|
488
518
|
SDK_VERSION,
|
|
519
|
+
FLO_SDK_VERSION_HEADER,
|
|
489
520
|
buildProductPayload,
|
|
490
521
|
foldIntoProducts,
|
|
491
522
|
resolveSessionCurrency
|
|
@@ -621,9 +652,11 @@ var PaymentAPI = class {
|
|
|
621
652
|
* Backends that don't yet enforce it ignore the extra header.
|
|
622
653
|
*/
|
|
623
654
|
async getCheckoutSession(checkoutSessionId, nonce) {
|
|
655
|
+
const headers = { [FLO_SDK_VERSION_HEADER]: SDK_VERSION };
|
|
656
|
+
if (nonce) headers["x-checkout-session-token"] = nonce;
|
|
624
657
|
const response = await fetchWithNetworkRetry(
|
|
625
658
|
`${this.baseUrl}/v1/checkouts/sessions/${encodeURIComponent(checkoutSessionId)}`,
|
|
626
|
-
|
|
659
|
+
{ headers }
|
|
627
660
|
);
|
|
628
661
|
if (!response.ok) {
|
|
629
662
|
throw await buildApiErrorFromResponse(response, "Failed to get checkout session");
|
|
@@ -660,6 +693,43 @@ var PaymentAPI = class {
|
|
|
660
693
|
clearSessionDisplayData(sessionId) {
|
|
661
694
|
clearSessionDisplayData(sessionId);
|
|
662
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
|
+
}
|
|
663
733
|
/**
|
|
664
734
|
* Fetch and normalize a checkout session.
|
|
665
735
|
*
|
|
@@ -669,7 +739,12 @@ var PaymentAPI = class {
|
|
|
669
739
|
*/
|
|
670
740
|
async getUnifiedCheckoutSession(checkoutSessionId, nonce) {
|
|
671
741
|
const res = await this.getCheckoutSession(checkoutSessionId, nonce);
|
|
672
|
-
|
|
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;
|
|
673
748
|
}
|
|
674
749
|
/**
|
|
675
750
|
* Submit a tokenized payment to the billing backend.
|
|
@@ -710,6 +785,43 @@ var PaymentAPI = class {
|
|
|
710
785
|
);
|
|
711
786
|
return this.resolveProcessResponse(response, data.sessionId, { ...options, nonce });
|
|
712
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
|
+
}
|
|
713
825
|
/**
|
|
714
826
|
* Create a PaymentIntent on the backend.
|
|
715
827
|
*
|
|
@@ -720,6 +832,13 @@ var PaymentAPI = class {
|
|
|
720
832
|
* session-bound checkout token returned by session creation. Post-#640
|
|
721
833
|
* backends reject this call with a 401 when the header is missing or does
|
|
722
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.
|
|
723
842
|
*/
|
|
724
843
|
async createPaymentIntent(sessionId, email, paymentMethodType, options) {
|
|
725
844
|
const headers = { "Content-Type": "application/json" };
|
|
@@ -732,7 +851,7 @@ var PaymentAPI = class {
|
|
|
732
851
|
body: JSON.stringify({
|
|
733
852
|
sessionId,
|
|
734
853
|
email,
|
|
735
|
-
paymentMethodType,
|
|
854
|
+
paymentMethodType: paymentMethodType ?? null,
|
|
736
855
|
isPaypal: options?.isPaypal ?? false
|
|
737
856
|
}),
|
|
738
857
|
signal: options?.signal
|
|
@@ -841,7 +960,13 @@ var PaymentAPI = class {
|
|
|
841
960
|
`${this.baseUrl}/v1/checkouts/sessions?expand=true`,
|
|
842
961
|
{
|
|
843
962
|
method: "POST",
|
|
844
|
-
|
|
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
|
+
},
|
|
845
970
|
body: JSON.stringify(payload)
|
|
846
971
|
}
|
|
847
972
|
);
|
|
@@ -859,8 +984,12 @@ var PaymentAPI = class {
|
|
|
859
984
|
if (body.data && "gateways" in body.data) {
|
|
860
985
|
this.autoCacheDisplayData(body.data.uuid, params);
|
|
861
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
|
+
}
|
|
862
991
|
return {
|
|
863
|
-
...
|
|
992
|
+
...normalized,
|
|
864
993
|
autoProcessingError: body.autoProcessingError,
|
|
865
994
|
autoProcessingAttempted: body.autoProcessingAttempted,
|
|
866
995
|
autoProcessingPending: body.autoProcessingPending
|
|
@@ -979,6 +1108,7 @@ var PaymentAPI = class {
|
|
|
979
1108
|
},
|
|
980
1109
|
metadata: {},
|
|
981
1110
|
checkoutMode: raw.checkoutMode,
|
|
1111
|
+
providerPaymentMethodId: typeof raw.providerPaymentMethodId === "string" ? raw.providerPaymentMethodId : null,
|
|
982
1112
|
products: rawProducts.map((p) => ({
|
|
983
1113
|
...p,
|
|
984
1114
|
totalAmount: typeof p.totalAmount === "number" ? p.totalAmount : void 0,
|
|
@@ -998,6 +1128,19 @@ var PaymentAPI = class {
|
|
|
998
1128
|
tagsData: raw.tagsData
|
|
999
1129
|
};
|
|
1000
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
|
+
}
|
|
1001
1144
|
toCheckoutSessionStatus(status) {
|
|
1002
1145
|
if (status === "completed") {
|
|
1003
1146
|
return "complete";
|
|
@@ -1112,6 +1255,378 @@ var PaymentAPI = class {
|
|
|
1112
1255
|
}
|
|
1113
1256
|
};
|
|
1114
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
|
+
|
|
1115
1630
|
// src/flopay.ts
|
|
1116
1631
|
var FloPay = class {
|
|
1117
1632
|
constructor(provider, config) {
|
|
@@ -1147,6 +1662,23 @@ var FloPay = class {
|
|
|
1147
1662
|
async confirmCardPayment(params) {
|
|
1148
1663
|
return this.provider.confirmCardPayment(params);
|
|
1149
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
|
+
}
|
|
1150
1682
|
/** Confirm a PayPal payment: create intent via billing API → confirm → redirect if needed. */
|
|
1151
1683
|
async confirmPayPalPayment(params) {
|
|
1152
1684
|
return this.provider.confirmPayPalPayment(params);
|
|
@@ -1170,7 +1702,7 @@ var FloPay = class {
|
|
|
1170
1702
|
*/
|
|
1171
1703
|
async retrieveSession(sessionId, billingApiUrl) {
|
|
1172
1704
|
if (!sessionId) {
|
|
1173
|
-
throw new
|
|
1705
|
+
throw new FloPayError5(
|
|
1174
1706
|
"sessionId is required to retrieve a session.",
|
|
1175
1707
|
"validation_error",
|
|
1176
1708
|
{ param: "sessionId" }
|
|
@@ -1180,7 +1712,7 @@ var FloPay = class {
|
|
|
1180
1712
|
const api = new PaymentAPI(apiUrl);
|
|
1181
1713
|
const unified = await api.getUnifiedCheckoutSession(sessionId);
|
|
1182
1714
|
if (!unified.data.session) {
|
|
1183
|
-
throw new
|
|
1715
|
+
throw new FloPayError5("Session not found", "api_error");
|
|
1184
1716
|
}
|
|
1185
1717
|
return unified.data.session;
|
|
1186
1718
|
}
|
|
@@ -1193,7 +1725,7 @@ var FloPay = class {
|
|
|
1193
1725
|
*/
|
|
1194
1726
|
async retrieveUnifiedSession(sessionId, billingApiUrl) {
|
|
1195
1727
|
if (!sessionId) {
|
|
1196
|
-
throw new
|
|
1728
|
+
throw new FloPayError5(
|
|
1197
1729
|
"sessionId is required.",
|
|
1198
1730
|
"validation_error",
|
|
1199
1731
|
{ param: "sessionId" }
|
|
@@ -1223,7 +1755,7 @@ var FloPay = class {
|
|
|
1223
1755
|
var instanceCache = /* @__PURE__ */ new Map();
|
|
1224
1756
|
async function loadFloPay(publishableKey, options) {
|
|
1225
1757
|
if (!publishableKey) {
|
|
1226
|
-
throw new
|
|
1758
|
+
throw new FloPayError6(
|
|
1227
1759
|
"A publishable key is required to initialize FloPay.",
|
|
1228
1760
|
"validation_error",
|
|
1229
1761
|
{ param: "publishableKey" }
|
|
@@ -1244,7 +1776,7 @@ async function loadFloPay(publishableKey, options) {
|
|
|
1244
1776
|
|
|
1245
1777
|
// src/create-checkout-session.ts
|
|
1246
1778
|
import {
|
|
1247
|
-
FloPayError as
|
|
1779
|
+
FloPayError as FloPayError7,
|
|
1248
1780
|
SDK_VERSION as SDK_VERSION2,
|
|
1249
1781
|
buildProductPayload as buildProductPayload2,
|
|
1250
1782
|
foldIntoProducts as foldIntoProducts2,
|
|
@@ -1258,7 +1790,7 @@ function buildCheckoutSessionError(status, payload) {
|
|
|
1258
1790
|
const nested = payload?.error;
|
|
1259
1791
|
const code = readString2(payload?.code) ?? readString2(nested?.code) ?? `http_${status}`;
|
|
1260
1792
|
const message = readString2(payload?.message) ?? readString2(nested?.message) ?? defaultMessageForCode(code, status);
|
|
1261
|
-
return new
|
|
1793
|
+
return new FloPayError7(message, "api_error", { code, statusCode: status });
|
|
1262
1794
|
}
|
|
1263
1795
|
function defaultMessageForCode(code, status) {
|
|
1264
1796
|
switch (code) {
|
|
@@ -1291,7 +1823,7 @@ async function createCheckoutSession(options) {
|
|
|
1291
1823
|
utmMetadata
|
|
1292
1824
|
} = options;
|
|
1293
1825
|
if (couponCodes.length > MAX_COUPON_CODES) {
|
|
1294
|
-
throw new
|
|
1826
|
+
throw new FloPayError7(
|
|
1295
1827
|
`Too many coupon codes \u2014 a checkout session accepts at most ${MAX_COUPON_CODES}.`,
|
|
1296
1828
|
"validation_error",
|
|
1297
1829
|
{ code: "CouponLimitExceeded", param: "couponCodes" }
|
|
@@ -1300,7 +1832,7 @@ async function createCheckoutSession(options) {
|
|
|
1300
1832
|
const wireProducts = products ?? foldIntoProducts2(items, subscriptions);
|
|
1301
1833
|
const sessionCurrency = resolveSessionCurrency2(currency, items, subscriptions, wireProducts);
|
|
1302
1834
|
if (!sessionCurrency) {
|
|
1303
|
-
throw new
|
|
1835
|
+
throw new FloPayError7(
|
|
1304
1836
|
"currency is required: pass `currency` on the session, or include a `currency` on the first item/subscription/product.",
|
|
1305
1837
|
"validation_error",
|
|
1306
1838
|
{ code: "CurrencyRequired", param: "currency" }
|
|
@@ -1365,7 +1897,7 @@ async function createCheckoutSession(options) {
|
|
|
1365
1897
|
throw new Error("Checkout session created but no UUID was returned by the billing API");
|
|
1366
1898
|
}
|
|
1367
1899
|
if (!nonce) {
|
|
1368
|
-
throw new
|
|
1900
|
+
throw new FloPayError7(
|
|
1369
1901
|
"Checkout session created but no `nonce` was returned by the billing API. Upgrade the billing service to TeamFloPay/backend#640 or later.",
|
|
1370
1902
|
"api_error",
|
|
1371
1903
|
{ code: "MissingCheckoutSessionToken" }
|
|
@@ -1432,6 +1964,7 @@ export {
|
|
|
1432
1964
|
FloPay,
|
|
1433
1965
|
FloPayElements,
|
|
1434
1966
|
PaymentAPI,
|
|
1967
|
+
PciVaultCardCapture,
|
|
1435
1968
|
StripeAdapter,
|
|
1436
1969
|
cacheSessionDisplayData,
|
|
1437
1970
|
clearSessionDisplayData,
|