@flopay/vue 0.0.0-stage → 1.13.2

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.
@@ -0,0 +1,833 @@
1
+ // src/stripe-elements.ts
2
+ import { PaymentAPI } from "@flopay/js/card-setup";
3
+ import {
4
+ buildWalletSheetWindowMessage,
5
+ denyPaymentPermissionToNewFrames,
6
+ detectPayPalEnvironment,
7
+ FloPayError,
8
+ invokeMerchantCallback as invokeMerchantCallback2,
9
+ isProviderDecline,
10
+ isWalletSheetRefusedForWindowSize,
11
+ readStripeRedirectParams as readStripeRedirectParams2,
12
+ resolveSetupFutureUsage,
13
+ resolveWalletModeOptions,
14
+ shouldPreferWalletPopup,
15
+ toStripeAppearance,
16
+ WALLET_SHEET_WINDOW_TOO_SMALL_CODE
17
+ } from "@flopay/shared";
18
+ import { defineComponent as defineComponent2, h, inject, onBeforeUnmount, onMounted as onMounted2, ref } from "vue";
19
+
20
+ // src/context.ts
21
+ var FLOPAY_CONTEXT = /* @__PURE__ */ Symbol("flopay.vue.provider");
22
+ var CHECKOUT_STATE = /* @__PURE__ */ Symbol("flopay.vue.checkout");
23
+ var CHECKOUT_FORM_DEFAULTS = /* @__PURE__ */ Symbol(
24
+ "flopay.vue.checkout-form-defaults"
25
+ );
26
+
27
+ // src/provider.ts
28
+ import {
29
+ FLO_INSTRUMENT_ONCE_NAMES,
30
+ invokeMerchantCallback,
31
+ projectFloInstrument,
32
+ resolveBillingApiUrl
33
+ } from "@flopay/shared";
34
+ import {
35
+ defineComponent,
36
+ getCurrentInstance,
37
+ nextTick,
38
+ onMounted,
39
+ provide,
40
+ shallowRef,
41
+ watch
42
+ } from "vue";
43
+ var FLOPAY_TELEMETRY_BRIDGE = /* @__PURE__ */ Symbol.for("@flopay/js.telemetry.bridge.v1");
44
+ function telemetryBridge(flopay) {
45
+ return flopay[FLOPAY_TELEMETRY_BRIDGE];
46
+ }
47
+ function isPromiseLike(value) {
48
+ return typeof value?.then === "function";
49
+ }
50
+ function createInstrumentEmitter(notify) {
51
+ const emittedOnce = /* @__PURE__ */ new Set();
52
+ return (source) => {
53
+ const event = projectFloInstrument(source);
54
+ if (!event) return;
55
+ if (FLO_INSTRUMENT_ONCE_NAMES.has(event.name)) {
56
+ if (emittedOnce.has(event.name)) return;
57
+ emittedOnce.add(event.name);
58
+ }
59
+ invokeMerchantCallback(() => notify(event));
60
+ };
61
+ }
62
+ var FloPayProvider = defineComponent({
63
+ name: "FloPayProvider",
64
+ props: {
65
+ flopay: {
66
+ type: [Object, Promise],
67
+ default: null
68
+ },
69
+ paypalFlopay: {
70
+ type: [Object, Promise],
71
+ default: null
72
+ },
73
+ options: { type: Object, default: void 0 }
74
+ },
75
+ emits: { instrument: (_event) => true },
76
+ setup(props, { emit, slots }) {
77
+ const initial = (source) => isPromiseLike(source) ? null : source ?? null;
78
+ const flopay = shallowRef(initial(props.flopay));
79
+ const paypalFlopay = shallowRef(initial(props.paypalFlopay));
80
+ const component = getCurrentInstance();
81
+ const emitInstrument = createInstrumentEmitter((event) => {
82
+ const listener = component?.vnode.props?.onInstrument;
83
+ if (!listener) return emit("instrument", event);
84
+ for (const handler of [listener].flat()) handler(event);
85
+ });
86
+ provide(FLOPAY_CONTEXT, {
87
+ flopay,
88
+ paypalFlopay,
89
+ billingApiUrl: () => resolveBillingApiUrl(props.options?.billingApiUrl),
90
+ emitInstrument
91
+ });
92
+ const follow = (read, target, provider) => watch(
93
+ read,
94
+ (source, _previous, onCleanup) => {
95
+ let active = true;
96
+ onCleanup(() => {
97
+ active = false;
98
+ });
99
+ if (!isPromiseLike(source)) {
100
+ target.value = source ?? null;
101
+ return;
102
+ }
103
+ if (provider === "paypal") target.value = null;
104
+ source.then(
105
+ (instance) => {
106
+ if (active) target.value = instance;
107
+ },
108
+ () => {
109
+ if (!active) return;
110
+ emitInstrument({
111
+ class: "technical_error",
112
+ errorCode: "SDK_INITIALIZATION_FAILED",
113
+ ...provider ? { provider } : {}
114
+ });
115
+ }
116
+ );
117
+ },
118
+ { immediate: true }
119
+ );
120
+ onMounted(() => {
121
+ emitInstrument({ class: "lifecycle", name: "checkout.mount" });
122
+ follow(() => props.flopay, flopay);
123
+ follow(() => props.paypalFlopay, paypalFlopay, "paypal");
124
+ watch(
125
+ flopay,
126
+ (instance, _previous, onCleanup) => {
127
+ if (!instance) return;
128
+ emitInstrument({ class: "lifecycle", name: "sdk.initialize.ready" });
129
+ const unsubscribe = telemetryBridge(instance)?.subscribe?.(emitInstrument);
130
+ onCleanup(() => unsubscribe?.());
131
+ void nextTick(() => emitInstrument({ class: "lifecycle", name: "checkout.rendered" }));
132
+ },
133
+ { immediate: true }
134
+ );
135
+ watch(paypalFlopay, (instance, _previous, onCleanup) => {
136
+ if (!instance || instance === flopay.value) return;
137
+ const unsubscribe = telemetryBridge(instance)?.subscribe?.(emitInstrument);
138
+ onCleanup(() => unsubscribe?.());
139
+ });
140
+ });
141
+ return () => slots.default?.();
142
+ }
143
+ });
144
+
145
+ // src/stripe-paypal-continuation.ts
146
+ import { readStripeRedirectParams, stripeRedirectReturnUrl } from "@flopay/shared";
147
+ var STRIPE_PAYPAL_CONTINUATION_KEY = "flopay_vue_paypal_resume";
148
+ var STRIPE_PAYPAL_ATTEMPT_PARAM = "flopay_paypal_attempt";
149
+ function sessionStore() {
150
+ if (typeof window === "undefined") return null;
151
+ try {
152
+ return window.sessionStorage;
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+ function createStripePayPalAttempt() {
158
+ const random = globalThis.crypto?.randomUUID?.();
159
+ if (random) return random;
160
+ return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
161
+ }
162
+ function saveStripePayPalContinuation(continuation) {
163
+ const store = sessionStore();
164
+ if (!store) return false;
165
+ try {
166
+ store.setItem(
167
+ STRIPE_PAYPAL_CONTINUATION_KEY,
168
+ JSON.stringify({
169
+ attempt: continuation.attempt,
170
+ sessionId: continuation.sessionId,
171
+ ...continuation.nonce ? { nonce: continuation.nonce } : {}
172
+ })
173
+ );
174
+ return true;
175
+ } catch {
176
+ return false;
177
+ }
178
+ }
179
+ function clearStripePayPalContinuation() {
180
+ try {
181
+ sessionStore()?.removeItem(STRIPE_PAYPAL_CONTINUATION_KEY);
182
+ } catch {
183
+ }
184
+ }
185
+ function stripePayPalReturnUrl(attempt) {
186
+ const url = new URL(stripeRedirectReturnUrl());
187
+ url.searchParams.delete(STRIPE_PAYPAL_ATTEMPT_PARAM);
188
+ if (attempt) url.searchParams.set(STRIPE_PAYPAL_ATTEMPT_PARAM, attempt);
189
+ return url.toString();
190
+ }
191
+ function readStripePayPalReturnContinuation() {
192
+ if (typeof window === "undefined") return null;
193
+ const [paymentIntentId] = readStripeRedirectParams();
194
+ if (!paymentIntentId) return null;
195
+ const attempt = new URLSearchParams(window.location.search).get(STRIPE_PAYPAL_ATTEMPT_PARAM);
196
+ if (!attempt) return null;
197
+ try {
198
+ const raw = sessionStore()?.getItem(STRIPE_PAYPAL_CONTINUATION_KEY);
199
+ if (!raw) return null;
200
+ const parsed = JSON.parse(raw);
201
+ if (!parsed || parsed.attempt !== attempt || typeof parsed.sessionId !== "string") return null;
202
+ return {
203
+ attempt,
204
+ sessionId: parsed.sessionId,
205
+ ...typeof parsed.nonce === "string" ? { nonce: parsed.nonce } : {}
206
+ };
207
+ } catch {
208
+ return null;
209
+ }
210
+ }
211
+ function hasStripePayPalReturn() {
212
+ return readStripePayPalReturnContinuation() !== null;
213
+ }
214
+ function consumeStripePayPalReturn() {
215
+ clearStripePayPalContinuation();
216
+ window.history.replaceState({}, "", stripePayPalReturnUrl());
217
+ }
218
+
219
+ // src/stripe-elements.ts
220
+ var WALLET_METHODS = /* @__PURE__ */ new Set(["apple_pay", "google_pay", "link", "paypal", "amazon_pay"]);
221
+ var EXPRESS_CHECKOUT_CLICK_BUDGET_MS = 900;
222
+ function checkoutButtonMethodFromProviderMethod(method) {
223
+ if (method === "paypal") return "paypal";
224
+ if (method === "apple_pay") return "apple_pay";
225
+ return "google_pay";
226
+ }
227
+ var PAYPAL_REDIRECT_TILE_HEIGHT = 44;
228
+ var stripePayPalResumeAttempts = /* @__PURE__ */ new Map();
229
+ function walletClockNow() {
230
+ return globalThis.performance?.now() ?? Date.now();
231
+ }
232
+ function stripeMethodCategory(method) {
233
+ return WALLET_METHODS.has(method) ? "wallet" : "apm";
234
+ }
235
+ function declineReason(error) {
236
+ return error.decline_code ?? error.code ?? "provider_declined";
237
+ }
238
+ var StripePaymentMethods = defineComponent2({
239
+ name: "FloPayStripePaymentMethods",
240
+ props: {
241
+ session: { type: Object, default: null },
242
+ sessionId: { type: String, required: true },
243
+ nonce: { type: String, default: void 0 },
244
+ billingApiUrl: { type: String, required: true },
245
+ publishableKey: { type: String, required: true },
246
+ methods: { type: Array, required: true },
247
+ /** `payment` for checkout, `setup` for payment-method setup. */
248
+ intentKind: { type: String, default: "payment" },
249
+ currency: { type: String, default: void 0 },
250
+ amount: { type: Number, default: 0 },
251
+ /**
252
+ * React's `totalAmount`: the cart total due today, in minor units. When it
253
+ * is given on a checkout (`intentKind: 'payment'`), the wallet row follows
254
+ * React's wallet group: its Elements mode, amount and capture method come
255
+ * from the shared `resolveWalletModeOptions` (setup mode for a $0 total),
256
+ * and its intent is `totalAmount > 0 ? 'payment' : 'setup'`.
257
+ */
258
+ totalAmount: { type: Number, default: void 0 },
259
+ returnUrl: { type: String, default: void 0 },
260
+ /** The theme's appearance, normalized for Stripe Elements (React's setup branch does the same). */
261
+ appearance: { type: Object, default: void 0 },
262
+ /**
263
+ * React's `stripePayPalPresentation`, which the form resolves from its
264
+ * `walletPresentation` prop through the shared
265
+ * `resolveStripePayPalPresentation`. With `redirect`, Stripe-routed PayPal
266
+ * leaves the wallet row and renders as React's full-page "Pay with PayPal"
267
+ * redirect tile, which resumes the payment when the buyer returns.
268
+ */
269
+ paypalPresentation: { type: String, default: "popup" },
270
+ /**
271
+ * The form's click gate (React's `runBeforeButtonClick`), run by the
272
+ * redirect tile and by the wallet row's Express Checkout click.
273
+ */
274
+ runBeforeButtonClick: {
275
+ type: Function,
276
+ default: void 0
277
+ },
278
+ /**
279
+ * React's `onTokenizedBody={dispatchTokenizedBody}` for the redirect tile:
280
+ * the form posts `/process` with the approved payment. Without it, the
281
+ * tile reports `complete` itself, as this component's wallet row does.
282
+ */
283
+ onTokenizedBody: {
284
+ type: Function,
285
+ default: void 0
286
+ },
287
+ /**
288
+ * React's `isProcessing={isSubmitting}`: while the form is processing, the
289
+ * PayPal redirect tile and the APM button are disabled and ignore clicks.
290
+ */
291
+ isProcessing: { type: Boolean, default: false },
292
+ /**
293
+ * The form's storage-free stand-in for the click-time account patch React
294
+ * restores from its `localStorage` continuation (issue #410, triage AC 4:
295
+ * Vue never stores it). On an authorized redirect return it is asked once
296
+ * for the account, with the returned session id; what it answers goes to
297
+ * `onTokenizedBody` as the attempt's `accountPatch`. A throw or no answer
298
+ * continues without one, because the payment is already authorized.
299
+ */
300
+ recoverAccountPatch: {
301
+ type: Function,
302
+ default: void 0
303
+ }
304
+ },
305
+ emits: {
306
+ /** The buyer confirmed a wallet or APM and its element validated: the attempt starts. */
307
+ submitting: () => true,
308
+ complete: (_result) => true,
309
+ decline: (_decline) => true,
310
+ error: (_error) => true
311
+ },
312
+ setup(props, { emit }) {
313
+ const walletContainer = ref(null);
314
+ const apmContainer = ref(null);
315
+ const processing = ref(false);
316
+ const busy = () => processing.value || props.isProcessing;
317
+ const cleanups = [];
318
+ let apmElements = null;
319
+ let stripe = null;
320
+ const elementsOptions = (methods, surface) => {
321
+ const setupFutureUsage = resolveSetupFutureUsage(props.session, methods, surface);
322
+ const currency = (props.currency ?? props.session?.currency ?? "usd").toLowerCase();
323
+ const appearance = props.appearance ? { appearance: toStripeAppearance(props.appearance) } : {};
324
+ return props.intentKind === "setup" ? { mode: "setup", currency, paymentMethodTypes: methods, ...appearance } : {
325
+ mode: props.session?.mode === "subscription" ? "subscription" : "payment",
326
+ amount: props.amount,
327
+ currency,
328
+ paymentMethodTypes: methods,
329
+ ...setupFutureUsage ? { setupFutureUsage } : {},
330
+ ...appearance
331
+ };
332
+ };
333
+ const walletFollowsTotal = () => props.intentKind === "payment" && typeof props.totalAmount === "number";
334
+ const walletIntentKind = () => {
335
+ if (!walletFollowsTotal()) return props.intentKind;
336
+ return (props.totalAmount ?? 0) > 0 ? "payment" : "setup";
337
+ };
338
+ const walletElementsOptions = (methods) => {
339
+ if (!walletFollowsTotal()) return elementsOptions(methods, "wallet");
340
+ const setupFutureUsage = resolveSetupFutureUsage(props.session, methods, "wallet");
341
+ const currency = (props.currency ?? props.session?.currency ?? "usd").toLowerCase();
342
+ return {
343
+ ...resolveWalletModeOptions(props.totalAmount, props.session?.captureMethod),
344
+ currency,
345
+ paymentMethodTypes: methods,
346
+ ...setupFutureUsage ? { setupFutureUsage } : {},
347
+ ...props.appearance ? { appearance: toStripeAppearance(props.appearance) } : {}
348
+ };
349
+ };
350
+ let walletAttempt = null;
351
+ const floPayContext = inject(FLOPAY_CONTEXT, null);
352
+ const walletTelemetry = () => {
353
+ const instance = floPayContext?.flopay.value;
354
+ return instance ? telemetryBridge(instance) : void 0;
355
+ };
356
+ let walletSheetClick = null;
357
+ let walletSheetAttempts = 0;
358
+ const endWalletSheetClick = () => {
359
+ walletSheetClick?.stopObserving();
360
+ walletSheetClick = null;
361
+ };
362
+ const observeWalletSheetClick = (method, startedAt) => {
363
+ endWalletSheetClick();
364
+ walletSheetAttempts += 1;
365
+ const click = {
366
+ attempt: walletSheetAttempts,
367
+ method,
368
+ startedAt,
369
+ handoffObserved: false,
370
+ stopObserving: () => {
371
+ }
372
+ };
373
+ const onBlur = () => {
374
+ click.handoffObserved = true;
375
+ };
376
+ const onVisibilityChange = () => {
377
+ if (document.visibilityState === "hidden") click.handoffObserved = true;
378
+ };
379
+ window.addEventListener("blur", onBlur);
380
+ document.addEventListener("visibilitychange", onVisibilityChange);
381
+ click.stopObserving = () => {
382
+ window.removeEventListener("blur", onBlur);
383
+ document.removeEventListener("visibilitychange", onVisibilityChange);
384
+ };
385
+ walletSheetClick = click;
386
+ };
387
+ cleanups.push(endWalletSheetClick);
388
+ const reportWalletSheetRefusal = (click) => {
389
+ const environment = detectPayPalEnvironment();
390
+ walletTelemetry()?.error?.({
391
+ errorCode: "PROVIDER_RUNTIME_FAILED",
392
+ failureCategory: "provider_runtime",
393
+ stage: "overlay_open",
394
+ provider: "stripe",
395
+ paymentMethodCategory: "wallet",
396
+ requestCategory: "provider_sdk",
397
+ terminalReason: "provider_error",
398
+ attempt: click.attempt,
399
+ walletType: "google_pay",
400
+ ...environment,
401
+ presentationMode: "popup"
402
+ });
403
+ const failure = new FloPayError(buildWalletSheetWindowMessage("Google Pay"), "api_error", {
404
+ code: "external_payment_method_failed",
405
+ param: click.method,
406
+ details: {
407
+ providerCode: WALLET_SHEET_WINDOW_TOO_SMALL_CODE,
408
+ popupBlocked: false,
409
+ ...environment,
410
+ presentationMode: "popup"
411
+ }
412
+ });
413
+ invokeMerchantCallback2(() => emit("error", failure));
414
+ };
415
+ const gateWalletClick = async (event) => {
416
+ const clickStartedAt = walletClockNow();
417
+ walletAttempt = null;
418
+ endWalletSheetClick();
419
+ const gate = props.runBeforeButtonClick;
420
+ if (!gate) {
421
+ observeWalletSheetClick(event.expressPaymentType, clickStartedAt);
422
+ event.resolve();
423
+ return;
424
+ }
425
+ let clickActive = true;
426
+ const clickTimer = setTimeout(() => {
427
+ clickActive = false;
428
+ event.reject();
429
+ }, EXPRESS_CHECKOUT_CLICK_BUDGET_MS);
430
+ let beforeClick;
431
+ try {
432
+ beforeClick = await gate(checkoutButtonMethodFromProviderMethod(event.expressPaymentType));
433
+ } catch {
434
+ beforeClick = { proceed: false };
435
+ } finally {
436
+ clearTimeout(clickTimer);
437
+ }
438
+ if (!clickActive) return;
439
+ if (!beforeClick.proceed) {
440
+ event.reject();
441
+ return;
442
+ }
443
+ walletAttempt = {
444
+ method: event.expressPaymentType,
445
+ ...beforeClick.sessionId ? { sessionId: beforeClick.sessionId } : {},
446
+ ...beforeClick.nonce ? { nonce: beforeClick.nonce } : {}
447
+ };
448
+ observeWalletSheetClick(event.expressPaymentType, clickStartedAt);
449
+ event.resolve();
450
+ };
451
+ const confirm = async (elements, method, intentKind = props.intentKind, attempt = null) => {
452
+ if (!stripe || processing.value) return;
453
+ processing.value = true;
454
+ const api = new PaymentAPI(props.billingApiUrl, { telemetry: false });
455
+ const category = stripeMethodCategory(method);
456
+ const sessionId = attempt?.sessionId ?? props.sessionId;
457
+ const nonce = attempt?.nonce ?? props.nonce;
458
+ try {
459
+ const submitted = await elements.submit();
460
+ if (submitted.error) return;
461
+ invokeMerchantCallback2(() => emit("submitting"));
462
+ const intent = await api.createSessionIntent(sessionId, nonce ?? "", {
463
+ provider: "stripe",
464
+ paymentMethodCategory: category,
465
+ paymentMethodType: method,
466
+ paymentMethodId: null,
467
+ intentKind
468
+ });
469
+ const clientSecret = intent.clientSecret ?? "";
470
+ const returnUrl = props.returnUrl ?? window.location.href;
471
+ const { error } = intentKind === "setup" ? await stripe.confirmSetup({
472
+ elements,
473
+ clientSecret,
474
+ confirmParams: { return_url: returnUrl },
475
+ redirect: "if_required"
476
+ }) : await stripe.confirmPayment({
477
+ elements,
478
+ clientSecret,
479
+ confirmParams: { return_url: returnUrl },
480
+ redirect: "if_required"
481
+ });
482
+ if (error) {
483
+ if (intentKind === "payment") {
484
+ void api.reportSessionIntentDecline(sessionId, nonce ?? "", {
485
+ provider: "stripe",
486
+ paymentMethodCategory: category,
487
+ paymentMethodType: method,
488
+ providerDeclineReason: declineReason(error),
489
+ providerIntentId: intent.providerObjectId
490
+ }).catch(() => {
491
+ });
492
+ }
493
+ const decline = {
494
+ method,
495
+ message: error.message ?? "Your payment was declined.",
496
+ code: error.code,
497
+ declineCode: error.decline_code ?? error.code,
498
+ sessionId
499
+ };
500
+ invokeMerchantCallback2(() => emit("decline", decline));
501
+ return;
502
+ }
503
+ const result = {
504
+ status: "succeeded",
505
+ paymentIntentId: intent.providerObjectId,
506
+ checkoutMethod: method
507
+ };
508
+ invokeMerchantCallback2(() => emit("complete", result));
509
+ } catch (error) {
510
+ const failure = error instanceof FloPayError ? error : new FloPayError(
511
+ error instanceof Error ? error.message : "Payment failed.",
512
+ "api_error"
513
+ );
514
+ invokeMerchantCallback2(() => emit("error", failure));
515
+ } finally {
516
+ processing.value = false;
517
+ }
518
+ };
519
+ const paypalRedirect = () => props.paypalPresentation === "redirect" && props.intentKind === "payment" && props.methods.includes("paypal");
520
+ let active = true;
521
+ const emitPayPalDecline = (message, sessionId, error) => {
522
+ const decline = {
523
+ method: "paypal",
524
+ message,
525
+ ...error ? { code: error.code, declineCode: error.decline_code ?? error.code } : {},
526
+ sessionId
527
+ };
528
+ invokeMerchantCallback2(() => emit("decline", decline));
529
+ };
530
+ const emitPayPalFailure = (message, code) => {
531
+ const failure = new FloPayError(message, "api_error", code ? { code } : void 0);
532
+ invokeMerchantCallback2(() => emit("error", failure));
533
+ };
534
+ const handOffPayPal = (body, overrides) => {
535
+ if (props.onTokenizedBody) {
536
+ props.onTokenizedBody(body, overrides);
537
+ return;
538
+ }
539
+ const result = {
540
+ status: "succeeded",
541
+ paymentIntentId: body.threeDSecureActionResultTokenId,
542
+ checkoutMethod: "paypal"
543
+ };
544
+ invokeMerchantCallback2(() => emit("complete", result));
545
+ };
546
+ const redirectToPayPal = async () => {
547
+ if (!stripe || busy()) return;
548
+ processing.value = true;
549
+ const api = new PaymentAPI(props.billingApiUrl, { telemetry: false });
550
+ let sessionId = props.sessionId;
551
+ try {
552
+ const beforeClick = props.runBeforeButtonClick ? await props.runBeforeButtonClick("paypal") : { proceed: true };
553
+ if (!beforeClick.proceed) return;
554
+ sessionId = beforeClick.sessionId ?? props.sessionId;
555
+ const nonce = beforeClick.nonce ?? props.nonce;
556
+ const createPaymentMethod = stripe.createPaymentMethod;
557
+ const { error: pmError, paymentMethod } = await createPaymentMethod.call(stripe, {
558
+ type: "paypal"
559
+ });
560
+ if (pmError) {
561
+ emitPayPalFailure(
562
+ pmError.message ?? "PayPal could not be started.",
563
+ "stripe_paypal_create_payment_method_failed"
564
+ );
565
+ return;
566
+ }
567
+ const intent = await api.createSessionIntent(sessionId, nonce ?? "", {
568
+ provider: "stripe",
569
+ paymentMethodCategory: "wallet",
570
+ paymentMethodType: "paypal",
571
+ paymentMethodId: paymentMethod?.id ?? null,
572
+ intentKind: "payment"
573
+ });
574
+ if (intent.provider !== "stripe") {
575
+ throw new Error("Invalid provider returned for PayPal intent");
576
+ }
577
+ const clientSecret = intent.clientSecret ?? "";
578
+ const attempt = createStripePayPalAttempt();
579
+ const continuationSaved = saveStripePayPalContinuation({ attempt, sessionId, nonce });
580
+ if (!continuationSaved) {
581
+ throw new Error("Unable to save PayPal checkout progress. Please try again.");
582
+ }
583
+ const { error, paymentIntent } = await stripe.confirmPayment({
584
+ clientSecret,
585
+ confirmParams: {
586
+ return_url: stripePayPalReturnUrl(attempt),
587
+ ...paymentMethod?.id ? { payment_method: paymentMethod.id } : {}
588
+ },
589
+ redirect: "if_required"
590
+ });
591
+ clearStripePayPalContinuation();
592
+ if (error) {
593
+ if (isProviderDecline(error)) {
594
+ void api.reportSessionIntentDecline(sessionId, nonce ?? "", {
595
+ provider: "stripe",
596
+ paymentMethodCategory: "wallet",
597
+ paymentMethodType: "paypal",
598
+ providerDeclineReason: error.code ?? "provider_declined",
599
+ providerIntentId: intent.providerObjectId
600
+ }).catch(() => {
601
+ });
602
+ emitPayPalDecline(error.message ?? "Your payment was declined.", sessionId, error);
603
+ return;
604
+ }
605
+ emitPayPalFailure(
606
+ error.message ?? "PayPal payment failed.",
607
+ "stripe_paypal_confirm_failed"
608
+ );
609
+ return;
610
+ }
611
+ const paymentMethodId = typeof paymentIntent?.payment_method === "string" ? paymentIntent.payment_method : paymentIntent?.payment_method?.id;
612
+ handOffPayPal(
613
+ {
614
+ id: paymentMethodId ?? paymentIntent?.id,
615
+ type: "card",
616
+ threeDSecureActionResultTokenId: paymentIntent?.id,
617
+ isPaypal: true
618
+ },
619
+ {
620
+ accountPatch: beforeClick.accountPatch,
621
+ paymentMethodCategory: "paypal",
622
+ presentationMode: "redirect",
623
+ sessionId,
624
+ nonce
625
+ }
626
+ );
627
+ } catch (error) {
628
+ emitPayPalFailure(
629
+ error instanceof Error ? error.message : "PayPal payment failed.",
630
+ "stripe_paypal_failed"
631
+ );
632
+ } finally {
633
+ processing.value = false;
634
+ }
635
+ };
636
+ const recoverReturnAccount = async (sessionId) => {
637
+ const recover = props.recoverAccountPatch;
638
+ if (!recover) return void 0;
639
+ try {
640
+ return await recover(sessionId) ?? void 0;
641
+ } catch {
642
+ return void 0;
643
+ }
644
+ };
645
+ const continueAuthorizedReturn = async (paymentIntent, continuation) => {
646
+ const paymentMethodId = typeof paymentIntent.payment_method === "string" ? paymentIntent.payment_method : paymentIntent.payment_method?.id;
647
+ const accountPatch = await recoverReturnAccount(continuation.sessionId);
648
+ if (!active) return;
649
+ handOffPayPal(
650
+ {
651
+ id: paymentMethodId ?? paymentIntent.id,
652
+ type: "card",
653
+ threeDSecureActionResultTokenId: paymentIntent.id,
654
+ isPaypal: true
655
+ },
656
+ {
657
+ ...accountPatch ? { accountPatch } : {},
658
+ sessionId: continuation.sessionId,
659
+ nonce: continuation.nonce,
660
+ presentationMode: "redirect"
661
+ }
662
+ );
663
+ };
664
+ const resumeStripePayPal = async (provider) => {
665
+ const [paymentIntentId, clientSecret, redirectStatus] = readStripeRedirectParams2();
666
+ if (!paymentIntentId || !clientSecret) return;
667
+ const continuation = readStripePayPalReturnContinuation();
668
+ if (!continuation) return;
669
+ const sessionId = continuation.sessionId;
670
+ const retrievalKey = continuation.attempt;
671
+ let retrieval = stripePayPalResumeAttempts.get(retrievalKey);
672
+ if (!retrieval) {
673
+ retrieval = redirectStatus === "failed" ? Promise.resolve(null) : Promise.resolve().then(() => provider.retrievePaymentIntent(clientSecret));
674
+ stripePayPalResumeAttempts.set(retrievalKey, retrieval);
675
+ }
676
+ processing.value = true;
677
+ try {
678
+ const result = await retrieval;
679
+ if (!active) return;
680
+ if (result?.error) {
681
+ stripePayPalResumeAttempts.delete(retrievalKey);
682
+ emitPayPalFailure(
683
+ result.error.message ?? "Failed to retrieve PayPal payment status.",
684
+ "REDIRECT_RESUME_FAILED"
685
+ );
686
+ return;
687
+ }
688
+ consumeStripePayPalReturn();
689
+ stripePayPalResumeAttempts.delete(retrievalKey);
690
+ if (redirectStatus === "failed") {
691
+ emitPayPalDecline("PayPal payment was declined. Please try again.", sessionId);
692
+ return;
693
+ }
694
+ const paymentIntent = result?.paymentIntent;
695
+ if (paymentIntent && paymentIntent.id !== paymentIntentId) {
696
+ emitPayPalFailure(
697
+ "Failed to match the returned PayPal payment.",
698
+ "REDIRECT_RESUME_FAILED"
699
+ );
700
+ return;
701
+ }
702
+ if (paymentIntent && (paymentIntent.status === "requires_capture" || paymentIntent.status === "succeeded")) {
703
+ await continueAuthorizedReturn(paymentIntent, continuation);
704
+ return;
705
+ }
706
+ emitPayPalDecline("PayPal payment was not completed. Please try again.", sessionId);
707
+ } catch (error) {
708
+ if (!active) return;
709
+ stripePayPalResumeAttempts.delete(retrievalKey);
710
+ emitPayPalFailure(
711
+ error instanceof Error ? error.message : "Failed to complete PayPal payment.",
712
+ "REDIRECT_RESUME_FAILED"
713
+ );
714
+ } finally {
715
+ if (active) processing.value = false;
716
+ }
717
+ };
718
+ onMounted2(async () => {
719
+ const wallets = props.methods.filter(
720
+ (method) => WALLET_METHODS.has(method) && !(method === "paypal" && paypalRedirect())
721
+ );
722
+ const apms = props.methods.filter((method) => !WALLET_METHODS.has(method));
723
+ try {
724
+ const { loadStripe } = await import("@stripe/stripe-js");
725
+ stripe = await loadStripe(props.publishableKey);
726
+ if (!stripe) throw new Error("Stripe.js did not initialize.");
727
+ if (paypalRedirect()) void resumeStripePayPal(stripe);
728
+ if (wallets.length > 0 && walletContainer.value) {
729
+ if (shouldPreferWalletPopup()) cleanups.push(denyPaymentPermissionToNewFrames(document));
730
+ const elements = stripe.elements(walletElementsOptions(wallets));
731
+ const express = elements.create("expressCheckout");
732
+ express.on("click", (event) => gateWalletClick(event));
733
+ express.on("confirm", (event) => {
734
+ const attempt = walletAttempt;
735
+ walletAttempt = null;
736
+ endWalletSheetClick();
737
+ void confirm(elements, event.expressPaymentType, walletIntentKind(), attempt);
738
+ });
739
+ express.on("cancel", () => {
740
+ walletAttempt = null;
741
+ const click = walletSheetClick;
742
+ endWalletSheetClick();
743
+ if (click && isWalletSheetRefusedForWindowSize({
744
+ method: click.method,
745
+ elapsedMs: walletClockNow() - click.startedAt,
746
+ handoffObserved: click.handoffObserved
747
+ })) {
748
+ reportWalletSheetRefusal(click);
749
+ }
750
+ });
751
+ express.mount(walletContainer.value);
752
+ cleanups.push(() => express.destroy());
753
+ }
754
+ if (apms.length > 0 && apmContainer.value) {
755
+ apmElements = stripe.elements(elementsOptions(apms, "inline_apm"));
756
+ const payment = apmElements.create("payment", { layout: "accordion" });
757
+ payment.mount(apmContainer.value);
758
+ cleanups.push(() => payment.destroy());
759
+ }
760
+ } catch (error) {
761
+ const failure = new FloPayError(
762
+ error instanceof Error ? error.message : "Payment methods failed to load.",
763
+ "api_error",
764
+ { code: "PROVIDER_LOAD_FAILED" }
765
+ );
766
+ invokeMerchantCallback2(() => emit("error", failure));
767
+ }
768
+ });
769
+ onBeforeUnmount(() => {
770
+ active = false;
771
+ for (const cleanup of cleanups.splice(0)) cleanup();
772
+ });
773
+ return () => h("div", { "data-testid": "flopay-stripe-methods" }, [
774
+ // React renders the Stripe PayPal group above the wallet row.
775
+ paypalRedirect() ? h(
776
+ "div",
777
+ {
778
+ role: "group",
779
+ "aria-label": "PayPal payment method",
780
+ "data-testid": "flopay-paypal-focus-target",
781
+ style: { borderRadius: "8px" }
782
+ },
783
+ [
784
+ h(
785
+ "button",
786
+ {
787
+ type: "button",
788
+ "data-testid": "flopay-stripe-paypal-redirect",
789
+ disabled: busy(),
790
+ onClick: () => void redirectToPayPal(),
791
+ style: {
792
+ width: "100%",
793
+ minHeight: `${PAYPAL_REDIRECT_TILE_HEIGHT}px`,
794
+ border: "1px solid #d1d5db",
795
+ borderRadius: "8px",
796
+ background: "#ffc439",
797
+ color: "#111827",
798
+ cursor: busy() ? "not-allowed" : "pointer",
799
+ fontSize: "1rem",
800
+ fontWeight: 700
801
+ }
802
+ },
803
+ "Pay with PayPal"
804
+ )
805
+ ]
806
+ ) : null,
807
+ h("div", { ref: walletContainer, "data-testid": "flopay-express-checkout" }),
808
+ h("div", { ref: apmContainer, "data-testid": "flopay-payment-element" }),
809
+ props.methods.some((method) => !WALLET_METHODS.has(method)) ? h(
810
+ "button",
811
+ {
812
+ type: "button",
813
+ disabled: busy(),
814
+ onClick: () => {
815
+ if (props.isProcessing) return;
816
+ const method = props.methods.find((candidate) => !WALLET_METHODS.has(candidate));
817
+ if (apmElements && method) void confirm(apmElements, method);
818
+ }
819
+ },
820
+ processing.value ? "Processing\u2026" : "Continue"
821
+ ) : null
822
+ ]);
823
+ }
824
+ });
825
+
826
+ export {
827
+ FLOPAY_CONTEXT,
828
+ CHECKOUT_STATE,
829
+ CHECKOUT_FORM_DEFAULTS,
830
+ FloPayProvider,
831
+ hasStripePayPalReturn,
832
+ StripePaymentMethods
833
+ };