@easypayment/medusa-payment-paypal 0.9.22 → 0.9.25

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.
Files changed (54) hide show
  1. package/.medusa/server/src/admin/index.js +264 -80
  2. package/.medusa/server/src/admin/index.mjs +264 -80
  3. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts.map +1 -1
  4. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js +340 -146
  5. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js.map +1 -1
  6. package/.medusa/server/src/api/admin/payment-collections/[id]/payment-sessions/route.d.ts.map +1 -1
  7. package/.medusa/server/src/api/admin/payment-collections/[id]/payment-sessions/route.js +6 -8
  8. package/.medusa/server/src/api/admin/payment-collections/[id]/payment-sessions/route.js.map +1 -1
  9. package/.medusa/server/src/api/admin/paypal/environment/route.d.ts.map +1 -1
  10. package/.medusa/server/src/api/admin/paypal/environment/route.js +11 -3
  11. package/.medusa/server/src/api/admin/paypal/environment/route.js.map +1 -1
  12. package/.medusa/server/src/api/admin/paypal/onboard-complete/route.d.ts +7 -0
  13. package/.medusa/server/src/api/admin/paypal/onboard-complete/route.d.ts.map +1 -1
  14. package/.medusa/server/src/api/admin/paypal/onboard-complete/route.js +7 -0
  15. package/.medusa/server/src/api/admin/paypal/onboard-complete/route.js.map +1 -1
  16. package/.medusa/server/src/api/admin/paypal/onboarding-status/route.d.ts.map +1 -1
  17. package/.medusa/server/src/api/admin/paypal/onboarding-status/route.js +4 -1
  18. package/.medusa/server/src/api/admin/paypal/onboarding-status/route.js.map +1 -1
  19. package/.medusa/server/src/api/store/payment-collections/[id]/payment-sessions/route.d.ts.map +1 -1
  20. package/.medusa/server/src/api/store/payment-collections/[id]/payment-sessions/route.js +6 -8
  21. package/.medusa/server/src/api/store/payment-collections/[id]/payment-sessions/route.js.map +1 -1
  22. package/.medusa/server/src/api/store/paypal/capture-order/route.d.ts.map +1 -1
  23. package/.medusa/server/src/api/store/paypal/capture-order/route.js +11 -0
  24. package/.medusa/server/src/api/store/paypal/capture-order/route.js.map +1 -1
  25. package/.medusa/server/src/api/store/paypal/onboard-return/route.d.ts +23 -0
  26. package/.medusa/server/src/api/store/paypal/onboard-return/route.d.ts.map +1 -0
  27. package/.medusa/server/src/api/store/paypal/onboard-return/route.js +63 -0
  28. package/.medusa/server/src/api/store/paypal/onboard-return/route.js.map +1 -0
  29. package/.medusa/server/src/api/store/paypal-complete/route.d.ts.map +1 -1
  30. package/.medusa/server/src/api/store/paypal-complete/route.js +2 -4
  31. package/.medusa/server/src/api/store/paypal-complete/route.js.map +1 -1
  32. package/.medusa/server/src/modules/paypal/payment-provider/card-service.d.ts.map +1 -1
  33. package/.medusa/server/src/modules/paypal/payment-provider/card-service.js +55 -34
  34. package/.medusa/server/src/modules/paypal/payment-provider/card-service.js.map +1 -1
  35. package/.medusa/server/src/modules/paypal/service.d.ts.map +1 -1
  36. package/.medusa/server/src/modules/paypal/service.js +5 -1
  37. package/.medusa/server/src/modules/paypal/service.js.map +1 -1
  38. package/.medusa/server/src/modules/paypal/utils/core-workflow.d.ts +32 -0
  39. package/.medusa/server/src/modules/paypal/utils/core-workflow.d.ts.map +1 -0
  40. package/.medusa/server/src/modules/paypal/utils/core-workflow.js +40 -0
  41. package/.medusa/server/src/modules/paypal/utils/core-workflow.js.map +1 -0
  42. package/package.json +1 -1
  43. package/src/admin/routes/settings/paypal/connection/page.tsx +364 -160
  44. package/src/api/admin/payment-collections/[id]/payment-sessions/route.ts +6 -8
  45. package/src/api/admin/paypal/environment/route.ts +10 -3
  46. package/src/api/admin/paypal/onboard-complete/route.ts +7 -0
  47. package/src/api/admin/paypal/onboarding-status/route.ts +4 -1
  48. package/src/api/store/payment-collections/[id]/payment-sessions/route.ts +6 -8
  49. package/src/api/store/paypal/capture-order/route.ts +15 -0
  50. package/src/api/store/paypal/onboard-return/route.ts +63 -0
  51. package/src/api/store/paypal-complete/route.ts +6 -4
  52. package/src/modules/paypal/payment-provider/card-service.ts +72 -38
  53. package/src/modules/paypal/service.ts +5 -1
  54. package/src/modules/paypal/utils/core-workflow.ts +46 -0
@@ -17,15 +17,10 @@ const PARTNER_JS_URLS = {
17
17
  live: "https://www.paypal.com/webapps/merchantboarding/js/lib/lightbox/partner.js",
18
18
  };
19
19
  const SERVICE_URL = "/admin/paypal/onboarding-link";
20
- // PayPal's conventional mini-browser window name; also used as the anchor's
21
- // `target` so partner.js opens the flow in a popup.
20
+ // PayPal's conventional mini-browser window name (also the anchor target).
22
21
  const POPUP_NAME = "PPFrame";
23
- // The onboarding link is cached in the browser for 6 hours. This is the heart of
24
- // the integration: partner.js can only wire the mini-browser + completion
25
- // callback if the anchor already carries a valid onboarding href when partner.js
26
- // initializes. By caching the link (and reloading once to populate the cache on
27
- // a cold start) the link is always present on page load, so partner.js can take
28
- // over the click and run its flow exactly as documented.
22
+ // The onboarding link is cached in the browser for 6 hours so it is always
23
+ // present for the mini-browser flow without regenerating on every load.
29
24
  const CACHE_PREFIX = "pp_onboard_cache";
30
25
  const CACHE_EXPIRY = 6 * 60 * 60 * 1000; // 6 hours
31
26
  const ONBOARDING_COMPLETE_ENDPOINT = "/admin/paypal/onboard-complete";
@@ -33,9 +28,6 @@ const STATUS_ENDPOINT = "/admin/paypal/status";
33
28
  const SAVE_CREDENTIALS_ENDPOINT = "/admin/paypal/save-credentials";
34
29
  const DISCONNECT_ENDPOINT = "/admin/paypal/disconnect";
35
30
  const ENVIRONMENT_ENDPOINT = "/admin/paypal/environment";
36
- // Onboarding URLs are environment-specific, so the cache is keyed per
37
- // environment — a single shared key let a stale sandbox URL leak into the live
38
- // flow (and vice-versa).
39
31
  const cacheKeyFor = (env) => `${CACHE_PREFIX}_${env}`;
40
32
  function readCachedUrl(env) {
41
33
  if (typeof window === "undefined")
@@ -56,8 +48,6 @@ function readCachedUrl(env) {
56
48
  }
57
49
  return null;
58
50
  }
59
- // Returns true only if the value was actually persisted (storage enabled), which
60
- // lets the caller decide whether reloading to pick up the cache is safe.
61
51
  function writeCachedUrl(env, url) {
62
52
  try {
63
53
  localStorage.setItem(cacheKeyFor(env), JSON.stringify({ url, ts: Date.now() }));
@@ -81,18 +71,79 @@ function clearCachedUrl(env) {
81
71
  /* ignore */
82
72
  }
83
73
  }
74
+ function isPayPalOrigin(origin) {
75
+ try {
76
+ const host = new URL(origin).hostname.toLowerCase();
77
+ return (host === "www.paypal.com" ||
78
+ host === "www.sandbox.paypal.com" ||
79
+ host.endsWith(".paypal.com") ||
80
+ host.endsWith(".paypalobjects.com"));
81
+ }
82
+ catch {
83
+ return false;
84
+ }
85
+ }
86
+ // Defensively pull authCode/sharedId out of whatever shape PayPal posts back
87
+ // (object, JSON string, or query string; possibly nested). We only act when both
88
+ // are present, which ignores the many unrelated intermediate messages PayPal
89
+ // emits.
90
+ function extractAuth(data) {
91
+ if (!data)
92
+ return {};
93
+ if (typeof data === "object") {
94
+ const obj = data;
95
+ const authCode = obj.authCode ?? obj.auth_code ?? obj.authcode ?? obj.code;
96
+ const sharedId = obj.sharedId ?? obj.shared_id ?? obj.sharedid;
97
+ if (authCode && sharedId) {
98
+ return { authCode: String(authCode), sharedId: String(sharedId) };
99
+ }
100
+ for (const key of ["data", "payload", "message", "detail", "body", "params"]) {
101
+ if (obj[key] && typeof obj[key] === "object") {
102
+ const inner = extractAuth(obj[key]);
103
+ if (inner.authCode && inner.sharedId)
104
+ return inner;
105
+ }
106
+ if (typeof obj[key] === "string") {
107
+ const inner = extractAuth(obj[key]);
108
+ if (inner.authCode && inner.sharedId)
109
+ return inner;
110
+ }
111
+ }
112
+ return {};
113
+ }
114
+ if (typeof data === "string") {
115
+ const s = data.trim();
116
+ if (!s)
117
+ return {};
118
+ if (s.startsWith("{") || s.startsWith("[")) {
119
+ try {
120
+ return extractAuth(JSON.parse(s));
121
+ }
122
+ catch {
123
+ /* not JSON */
124
+ }
125
+ }
126
+ if (/auth_?code/i.test(s) && /shared_?id/i.test(s)) {
127
+ try {
128
+ const sp = new URLSearchParams(s.replace(/^[?#]/, ""));
129
+ const authCode = sp.get("authCode") || sp.get("auth_code") || undefined;
130
+ const sharedId = sp.get("sharedId") || sp.get("shared_id") || undefined;
131
+ if (authCode && sharedId)
132
+ return { authCode, sharedId };
133
+ }
134
+ catch {
135
+ /* not a query string */
136
+ }
137
+ }
138
+ }
139
+ return {};
140
+ }
84
141
  function PayPalConnectionPage() {
85
142
  const [env, setEnv] = (0, react_1.useState)("live");
86
- // We must know the server's environment before generating/looking up a link so
87
- // the per-environment cache and the generated link always match.
88
143
  const [envReady, setEnvReady] = (0, react_1.useState)(false);
89
144
  const [connState, setConnState] = (0, react_1.useState)("loading");
90
- // True once partner.js has loaded and bound its click interceptor to the
91
- // button. The button stays in the DOM but is not clickable until then, so a
92
- // click can never fall through to the native anchor (which would open a tab
93
- // without partner.js's onboarding flow / completion callback).
94
- const [partnerReady, setPartnerReady] = (0, react_1.useState)(false);
95
145
  const [error, setError] = (0, react_1.useState)(null);
146
+ const [popupBlocked, setPopupBlocked] = (0, react_1.useState)(false);
96
147
  const [finalUrl, setFinalUrl] = (0, react_1.useState)("");
97
148
  const [showManual, setShowManual] = (0, react_1.useState)(false);
98
149
  const [clientId, setClientId] = (0, react_1.useState)("");
@@ -100,13 +151,20 @@ function PayPalConnectionPage() {
100
151
  const [statusInfo, setStatusInfo] = (0, react_1.useState)(null);
101
152
  const [onboardingInProgress, setOnboardingInProgress] = (0, react_1.useState)(false);
102
153
  const initLoaderRef = (0, react_1.useRef)(null);
154
+ const paypalButtonRef = (0, react_1.useRef)(null);
103
155
  const errorLogRef = (0, react_1.useRef)(null);
104
156
  const runIdRef = (0, react_1.useRef)(0);
105
157
  const currentRunId = (0, react_1.useRef)(0);
106
158
  const envRef = (0, react_1.useRef)(env);
107
159
  envRef.current = env;
108
- // Guards against partner.js's bridge invoking the completion callback more
109
- // than once.
160
+ const finalUrlRef = (0, react_1.useRef)(finalUrl);
161
+ finalUrlRef.current = finalUrl;
162
+ const connStateRef = (0, react_1.useRef)(connState);
163
+ connStateRef.current = connState;
164
+ const inProgressRef = (0, react_1.useRef)(onboardingInProgress);
165
+ inProgressRef.current = onboardingInProgress;
166
+ const popupRef = (0, react_1.useRef)(null);
167
+ const pollRef = (0, react_1.useRef)(null);
110
168
  const completedRef = (0, react_1.useRef)(false);
111
169
  const ppBtnMeasureRef = (0, react_1.useRef)(null);
112
170
  const [ppBtnWidth, setPpBtnWidth] = (0, react_1.useState)(null);
@@ -117,16 +175,156 @@ function PayPalConnectionPage() {
117
175
  setConnState("error");
118
176
  setError(msg);
119
177
  }, []);
120
- // Generate a fresh onboarding link, cache it for 6 hours, then reload the page
121
- // so that on the next load the cached link is already present for partner.js.
122
- // If browser storage is unavailable we fall back to using the link in-place
123
- // (no reload) so we can never get stuck in a reload loop.
178
+ const stopPoll = (0, react_1.useCallback)(() => {
179
+ if (pollRef.current) {
180
+ clearInterval(pollRef.current);
181
+ pollRef.current = null;
182
+ }
183
+ }, []);
184
+ // Open PayPal's mini-browser ourselves, synchronously inside the click gesture.
185
+ // This is the only reliable way to get a popup (rather than a new tab, which is
186
+ // what happens when partner.js's asynchronous open misses the user-activation
187
+ // window). Named POPUP_NAME and sized like PayPal's mini-browser.
188
+ const openOnboardingPopup = (0, react_1.useCallback)((url) => {
189
+ const w = 450;
190
+ const h = 600;
191
+ const baseLeft = typeof window.screenX === "number" ? window.screenX : window.screenLeft || 0;
192
+ const baseTop = typeof window.screenY === "number" ? window.screenY : window.screenTop || 0;
193
+ const outerW = window.outerWidth || document.documentElement.clientWidth || 1024;
194
+ const outerH = window.outerHeight || document.documentElement.clientHeight || 768;
195
+ const left = Math.round(baseLeft + Math.max(0, (outerW - w) / 2));
196
+ const top = Math.round(baseTop + Math.max(0, (outerH - h) / 2));
197
+ const features = `popup=yes,width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`;
198
+ let popup = null;
199
+ try {
200
+ popup = window.open(url, POPUP_NAME, features);
201
+ }
202
+ catch {
203
+ popup = null;
204
+ }
205
+ if (popup) {
206
+ popupRef.current = popup;
207
+ try {
208
+ popup.focus();
209
+ }
210
+ catch {
211
+ /* ignore */
212
+ }
213
+ }
214
+ return popup;
215
+ }, []);
216
+ // Exchange authCode/sharedId for seller credentials. Idempotent so the multiple
217
+ // completion signals (our message listener, partner.js's bridge, the status
218
+ // poll) only run it once.
219
+ const completeOnboarding = (0, react_1.useCallback)(async (authCode, sharedId) => {
220
+ if (!authCode || !sharedId)
221
+ return;
222
+ if (completedRef.current)
223
+ return;
224
+ completedRef.current = true;
225
+ stopPoll();
226
+ try {
227
+ window.onbeforeunload = null;
228
+ }
229
+ catch { }
230
+ const activeEnv = envRef.current === "sandbox" ? "sandbox" : "live";
231
+ setOnboardingInProgress(true);
232
+ setConnState("loading");
233
+ setError(null);
234
+ setPopupBlocked(false);
235
+ try {
236
+ const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
237
+ method: "POST",
238
+ headers: { "content-type": "application/json" },
239
+ credentials: "include",
240
+ body: JSON.stringify({ authCode, sharedId, env: activeEnv }),
241
+ });
242
+ if (!res.ok) {
243
+ const txt = await res.text().catch(() => "");
244
+ throw new Error(txt || `Onboarding exchange failed (${res.status})`);
245
+ }
246
+ try {
247
+ popupRef.current?.close();
248
+ }
249
+ catch { }
250
+ try {
251
+ const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow;
252
+ if (typeof close1 === "function")
253
+ close1();
254
+ }
255
+ catch { }
256
+ try {
257
+ const close2 = window.PAYPAL?.apps?.Signup?.miniBrowser &&
258
+ window.PAYPAL.apps.Signup.miniBrowser.closeFlow;
259
+ if (typeof close2 === "function")
260
+ close2();
261
+ }
262
+ catch { }
263
+ clearCachedUrl(activeEnv);
264
+ try {
265
+ const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${activeEnv}`, {
266
+ method: "GET",
267
+ credentials: "include",
268
+ });
269
+ const refreshedStatus = await statusRes.json().catch(() => ({}));
270
+ setStatusInfo(refreshedStatus || null);
271
+ }
272
+ catch { }
273
+ setConnState("connected");
274
+ setShowManual(false);
275
+ }
276
+ catch (e) {
277
+ completedRef.current = false;
278
+ console.error(e);
279
+ setConnState("error");
280
+ setError(e?.message || "Exchange failed while saving credentials.");
281
+ }
282
+ finally {
283
+ setOnboardingInProgress(false);
284
+ }
285
+ }, [stopPoll]);
286
+ // After the popup is opened, poll the server. If the credentials get saved by
287
+ // any completion path, the status flips to "connected" and we sync the UI even
288
+ // if a message was missed.
289
+ const startStatusPoll = (0, react_1.useCallback)(() => {
290
+ stopPoll();
291
+ let ticks = 0;
292
+ pollRef.current = setInterval(async () => {
293
+ ticks += 1;
294
+ if (completedRef.current || ticks > 150 /* ~5 min */) {
295
+ stopPoll();
296
+ return;
297
+ }
298
+ try {
299
+ const r = await fetch(`${STATUS_ENDPOINT}?environment=${envRef.current}`, {
300
+ method: "GET",
301
+ credentials: "include",
302
+ });
303
+ const st = await r.json().catch(() => ({}));
304
+ if (st?.status === "connected" && st?.seller_client_id_present === true) {
305
+ completedRef.current = true;
306
+ stopPoll();
307
+ try {
308
+ popupRef.current?.close();
309
+ }
310
+ catch { }
311
+ clearCachedUrl(envRef.current);
312
+ setStatusInfo(st);
313
+ setConnState("connected");
314
+ setShowManual(false);
315
+ setOnboardingInProgress(false);
316
+ }
317
+ }
318
+ catch {
319
+ /* keep polling */
320
+ }
321
+ }, 2000);
322
+ }, [stopPoll]);
124
323
  const generateLinkAndReload = (0, react_1.useCallback)(async (targetEnv, runId) => {
125
324
  if (initLoaderRef.current) {
126
325
  const loaderText = initLoaderRef.current.querySelector("#loader-text");
127
- if (loaderText) {
326
+ if (loaderText)
128
327
  loaderText.textContent = "Generating onboarding session...";
129
- }
130
328
  }
131
329
  try {
132
330
  const res = await fetch(SERVICE_URL, {
@@ -148,13 +346,9 @@ function PayPalConnectionPage() {
148
346
  const url = href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser";
149
347
  const persisted = writeCachedUrl(targetEnv, url);
150
348
  if (persisted) {
151
- // The cache survived; reload so the link is present at first render and
152
- // partner.js initializes against it. The post-reload load finds the
153
- // cache (no regeneration), so this can't loop.
154
349
  window.location.reload();
155
350
  return;
156
351
  }
157
- // Storage disabled — use the link directly without reloading.
158
352
  setFinalUrl(url);
159
353
  setConnState("ready");
160
354
  }
@@ -183,11 +377,7 @@ function PayPalConnectionPage() {
183
377
  alive = false;
184
378
  };
185
379
  }, []);
186
- // Page-load flow:
187
- // 1. If already connected, show the connected state.
188
- // 2. Else if a valid cached link exists, use it (partner.js will bind it).
189
- // 3. Else generate a link, cache it for 6h, and reload so the cached link is
190
- // present for partner.js on the next load.
380
+ // Page-load flow: connected? -> cached link? -> else generate + cache + reload.
191
381
  (0, react_1.useEffect)(() => {
192
382
  if (!envReady)
193
383
  return;
@@ -198,6 +388,7 @@ function PayPalConnectionPage() {
198
388
  const run = async () => {
199
389
  setConnState("loading");
200
390
  setError(null);
391
+ setPopupBlocked(false);
201
392
  setFinalUrl("");
202
393
  try {
203
394
  const r = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
@@ -233,30 +424,15 @@ function PayPalConnectionPage() {
233
424
  cancelled = true;
234
425
  };
235
426
  }, [env, envReady, generateLinkAndReload]);
236
- // Load partner.js once the anchor exists with its real (cached) href, then run
237
- // PayPal's initializer. In a React SPA partner.js's own DOMContentLoaded hook
238
- // already fired before this route mounted, so we must call init()/render()
239
- // ourselves for it to discover the button and take over the click into the
240
- // mini-browser — opening the popup, and wiring its postMessage bridge that
241
- // invokes window.onboardingCallback and closes the popup on completion.
427
+ // Load partner.js + init for its postMessage->onboardingCallback bridge (a
428
+ // secondary completion path next to our own message listener). We open the
429
+ // popup ourselves, so partner.js is NOT relied on to open the window.
242
430
  (0, react_1.useEffect)(() => {
243
431
  if (connState !== "ready" || !finalUrl)
244
432
  return;
245
433
  const scriptUrl = PARTNER_JS_URLS[env];
246
434
  let cancelled = false;
247
435
  let pollTimer;
248
- // The button is gated on partnerReady; reset it for this (re)load.
249
- setPartnerReady(false);
250
- // Safety net: if partner.js can't load (e.g. blocked), enable the button
251
- // anyway after a few seconds rather than leaving it permanently disabled.
252
- const readyFallback = setTimeout(() => {
253
- if (!cancelled)
254
- setPartnerReady(true);
255
- }, 6000);
256
- const markReady = () => {
257
- if (!cancelled)
258
- setPartnerReady(true);
259
- };
260
436
  const initPartner = (attempt = 0) => {
261
437
  if (cancelled)
262
438
  return;
@@ -273,7 +449,6 @@ function PayPalConnectionPage() {
273
449
  catch (e) {
274
450
  console.error("[paypal] partner.js init failed:", e);
275
451
  }
276
- markReady();
277
452
  return;
278
453
  }
279
454
  if (typeof signup?.render === "function") {
@@ -283,11 +458,8 @@ function PayPalConnectionPage() {
283
458
  catch (e) {
284
459
  console.error("[paypal] partner.js render failed:", e);
285
460
  }
286
- markReady();
287
461
  return;
288
462
  }
289
- // The namespace may not be populated the instant onload fires; retry
290
- // briefly before giving up.
291
463
  if (attempt < 40) {
292
464
  pollTimer = setTimeout(() => initPartner(attempt + 1), 50);
293
465
  return;
@@ -296,10 +468,7 @@ function PayPalConnectionPage() {
296
468
  document.dispatchEvent(new Event("DOMContentLoaded"));
297
469
  }
298
470
  catch { }
299
- markReady();
300
471
  };
301
- // Remove any stale copy + namespace so the fresh load re-registers against
302
- // the current environment (sandbox vs live partner.js differ).
303
472
  const existingScript = document.getElementById("paypal-partner-js");
304
473
  if (existingScript) {
305
474
  existingScript.parentNode?.removeChild(existingScript);
@@ -315,85 +484,69 @@ function PayPalConnectionPage() {
315
484
  ppScript.src = scriptUrl;
316
485
  ppScript.async = true;
317
486
  ppScript.onload = () => initPartner();
318
- ppScript.onerror = () => markReady();
319
487
  document.body.appendChild(ppScript);
320
488
  return () => {
321
489
  cancelled = true;
322
- clearTimeout(readyFallback);
323
- if (pollTimer) {
490
+ if (pollTimer)
324
491
  clearTimeout(pollTimer);
325
- }
326
- if (ppScript.parentNode) {
492
+ if (ppScript.parentNode)
327
493
  ppScript.parentNode.removeChild(ppScript);
328
- }
329
494
  };
330
495
  }, [connState, finalUrl, env]);
331
- // partner.js calls this (by the name in data-paypal-onboard-complete) when the
332
- // seller finishes onboarding, passing the authCode + sharedId to exchange for
333
- // seller credentials.
334
- const completeOnboarding = (0, react_1.useCallback)(async (authCode, sharedId) => {
335
- if (!authCode || !sharedId)
336
- return;
337
- if (completedRef.current)
338
- return;
339
- completedRef.current = true;
340
- try {
341
- window.onbeforeunload = null;
342
- }
343
- catch { }
344
- const activeEnv = envRef.current === "sandbox" ? "sandbox" : "live";
345
- setOnboardingInProgress(true);
346
- setConnState("loading");
347
- setError(null);
348
- try {
349
- const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
350
- method: "POST",
351
- headers: { "content-type": "application/json" },
352
- credentials: "include",
353
- body: JSON.stringify({ authCode, sharedId, env: activeEnv }),
354
- });
355
- if (!res.ok) {
356
- const txt = await res.text().catch(() => "");
357
- throw new Error(txt || `Onboarding exchange failed (${res.status})`);
358
- }
359
- // Close the partner.js mini-browser.
496
+ // PRIMARY completion path: receive PayPal's onboarding postMessage ourselves.
497
+ // The diagnostic log lets us confirm the exact origin/shape PayPal sends in a
498
+ // real run if a tweak to the parser is ever needed.
499
+ (0, react_1.useEffect)(() => {
500
+ const onMessage = (ev) => {
501
+ let serialized = "";
360
502
  try {
361
- const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow;
362
- if (typeof close1 === "function")
363
- close1();
503
+ serialized =
504
+ typeof ev.data === "string" ? ev.data : JSON.stringify(ev.data);
364
505
  }
365
- catch { }
366
- try {
367
- const close2 = window.PAYPAL?.apps?.Signup?.miniBrowser &&
368
- window.PAYPAL.apps.Signup.miniBrowser.closeFlow;
369
- if (typeof close2 === "function")
370
- close2();
506
+ catch {
507
+ serialized = String(ev.data);
371
508
  }
372
- catch { }
373
- // The link has been consumed; drop it so a future flow regenerates one.
374
- clearCachedUrl(activeEnv);
375
- try {
376
- const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${activeEnv}`, {
377
- method: "GET",
378
- credentials: "include",
379
- });
380
- const refreshedStatus = await statusRes.json().catch(() => ({}));
381
- setStatusInfo(refreshedStatus || null);
509
+ const looksRelevant = isPayPalOrigin(ev.origin) ||
510
+ /auth_?code|shared_?id|onboard|merchantId/i.test(serialized || "");
511
+ if (looksRelevant) {
512
+ // eslint-disable-next-line no-console
513
+ console.info("[PayPal onboarding] message from", ev.origin, "·", (serialized || "").slice(0, 500));
382
514
  }
383
- catch { }
384
- setConnState("connected");
385
- setShowManual(false);
386
- }
387
- catch (e) {
388
- completedRef.current = false;
389
- console.error(e);
390
- setConnState("error");
391
- setError(e?.message || "Exchange failed while saving credentials.");
392
- }
393
- finally {
394
- setOnboardingInProgress(false);
395
- }
396
- }, []);
515
+ // TERTIARY completion path: our own return_url bridge page (served by
516
+ // GET /store/paypal/onboard-return) posts this from inside the popup when
517
+ // PayPal redirects there at the end of onboarding. It runs on our backend
518
+ // origin (not paypal.com), so it is handled before the PayPal-origin gate
519
+ // below. It guarantees the popup is closed and the status is re-checked even
520
+ // if every PayPal postMessage was missed.
521
+ const d = ev.data;
522
+ if (d && typeof d === "object" && d.source === "paypal-onboarding-return") {
523
+ try {
524
+ popupRef.current?.close();
525
+ }
526
+ catch { }
527
+ const fromReturn = extractAuth(d.params);
528
+ if (fromReturn.authCode && fromReturn.sharedId) {
529
+ completeOnboarding(fromReturn.authCode, fromReturn.sharedId);
530
+ }
531
+ else if (!completedRef.current) {
532
+ // No authCode/sharedId on the return URL (the usual case): credentials
533
+ // are exchanged via the postMessage/onboardingCallback path. Poll the
534
+ // server so the UI flips to "connected" as soon as that lands.
535
+ startStatusPoll();
536
+ }
537
+ return;
538
+ }
539
+ if (!isPayPalOrigin(ev.origin))
540
+ return;
541
+ const { authCode, sharedId } = extractAuth(ev.data);
542
+ if (authCode && sharedId) {
543
+ completeOnboarding(authCode, sharedId);
544
+ }
545
+ };
546
+ window.addEventListener("message", onMessage);
547
+ return () => window.removeEventListener("message", onMessage);
548
+ }, [completeOnboarding, startStatusPoll]);
549
+ // SECONDARY completion path: partner.js's bridge calls this by name.
397
550
  (0, react_1.useLayoutEffect)(() => {
398
551
  window.onboardingCallback = (authCode, sharedId) => {
399
552
  completeOnboarding(authCode, sharedId);
@@ -402,6 +555,51 @@ function PayPalConnectionPage() {
402
555
  window.onboardingCallback = undefined;
403
556
  };
404
557
  }, [completeOnboarding]);
558
+ // Capture-phase opener: runs before partner.js and opens the popup
559
+ // synchronously inside the user gesture so the mini-browser reliably opens as a
560
+ // popup (never a blocked window or a new tab). We open it under PayPal's
561
+ // conventional window name (POPUP_NAME / "PPFrame"); because partner.js also
562
+ // targets that same name, letting its click handler run next makes it *adopt*
563
+ // this very window instead of opening a second one. That adoption is what lets
564
+ // partner.js receive PayPal's completion postMessage, invoke onboardingCallback
565
+ // and auto-close the popup.
566
+ //
567
+ // IMPORTANT: we only preventDefault() (to stop the native anchor from opening a
568
+ // new tab) and deliberately DO NOT stopImmediatePropagation(): stopping it was
569
+ // what previously prevented partner.js from tracking the window, which broke
570
+ // onboarding completion (the popup never closed and credentials were never
571
+ // exchanged).
572
+ (0, react_1.useEffect)(() => {
573
+ const onCaptureClick = (e) => {
574
+ const btn = paypalButtonRef.current;
575
+ if (!btn)
576
+ return;
577
+ const target = e.target;
578
+ if (!target || !btn.contains(target))
579
+ return;
580
+ // Stop the native anchor navigation only. Let the event keep propagating so
581
+ // partner.js's own click handler still runs and adopts our popup window.
582
+ e.preventDefault();
583
+ if (connStateRef.current !== "ready" ||
584
+ !finalUrlRef.current ||
585
+ inProgressRef.current) {
586
+ return;
587
+ }
588
+ completedRef.current = false;
589
+ const popup = openOnboardingPopup(finalUrlRef.current);
590
+ if (!popup) {
591
+ setPopupBlocked(true);
592
+ return;
593
+ }
594
+ setPopupBlocked(false);
595
+ startStatusPoll();
596
+ };
597
+ document.addEventListener("click", onCaptureClick, true);
598
+ return () => document.removeEventListener("click", onCaptureClick, true);
599
+ }, [openOnboardingPopup, startStatusPoll]);
600
+ (0, react_1.useEffect)(() => {
601
+ return () => stopPoll();
602
+ }, [stopPoll]);
405
603
  (0, react_1.useLayoutEffect)(() => {
406
604
  const el = ppBtnMeasureRef.current;
407
605
  if (!el)
@@ -427,6 +625,11 @@ function PayPalConnectionPage() {
427
625
  window.removeEventListener("resize", update);
428
626
  };
429
627
  }, [connState, env, finalUrl]);
628
+ // Defensive: the capture listener handles the click; this just guarantees the
629
+ // native href can never navigate.
630
+ const handleConnectClick = (e) => {
631
+ e.preventDefault();
632
+ };
430
633
  const handleSaveManual = async () => {
431
634
  if (!canSaveManual || onboardingInProgress)
432
635
  return;
@@ -491,8 +694,6 @@ function PayPalConnectionPage() {
491
694
  }
492
695
  clearCachedUrl(env);
493
696
  completedRef.current = false;
494
- // Generate a fresh link for the now-disconnected environment and reload so
495
- // the onboarding button is ready again.
496
697
  currentRunId.current = ++runIdRef.current;
497
698
  await generateLinkAndReload(env, currentRunId.current);
498
699
  }
@@ -511,9 +712,8 @@ function PayPalConnectionPage() {
511
712
  return;
512
713
  completedRef.current = false;
513
714
  clearCachedUrl();
715
+ setPopupBlocked(false);
514
716
  setConnState("loading");
515
- // Persist the environment BEFORE switching local state so the post-reload
516
- // GET /environment returns the same value the cache was keyed under.
517
717
  try {
518
718
  await fetch(ENVIRONMENT_ENDPOINT, {
519
719
  method: "POST",
@@ -528,19 +728,13 @@ function PayPalConnectionPage() {
528
728
  return ((0, jsx_runtime_1.jsxs)("div", { className: "p-6", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-6", children: [(0, jsx_runtime_1.jsx)("div", { className: "flex items-start justify-between gap-4", children: (0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsx)("h1", { className: "text-xl font-semibold text-ui-fg-base", children: "PayPal Gateway By Easy Payment" }) }) }), (0, jsx_runtime_1.jsx)(Tabs_1.default, {}), (0, jsx_runtime_1.jsx)("div", { className: "rounded-xl border border-ui-border-base bg-ui-bg-base shadow-sm", children: (0, jsx_runtime_1.jsxs)("div", { className: "grid grid-cols-1 gap-y-6 p-4 md:grid-cols-[260px_1fr] md:items-start", children: [(0, jsx_runtime_1.jsx)("div", { className: "text-sm font-medium pt-2", children: "Environment" }), (0, jsx_runtime_1.jsx)("div", { className: "max-w-xl", children: (0, jsx_runtime_1.jsxs)("select", { value: env, onChange: handleEnvChange, disabled: onboardingInProgress, className: "w-full rounded-md border border-ui-border-base bg-transparent px-3 py-2 text-sm", children: [(0, jsx_runtime_1.jsx)("option", { value: "sandbox", children: "Sandbox (Test Mode)" }), (0, jsx_runtime_1.jsx)("option", { value: "live", children: "Live (Production)" })] }) }), (0, jsx_runtime_1.jsx)("div", { className: "text-sm font-medium pt-2", children: env === "sandbox" ? "Connect to PayPal (Sandbox)" : "Connect to PayPal" }), (0, jsx_runtime_1.jsx)("div", { className: "max-w-xl", children: connState === "connected" ? ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("div", { className: "text-sm text-green-600 bg-green-50 p-3 rounded border border-green-200", children: "\u2705 Successfully connected to PayPal!" }), (0, jsx_runtime_1.jsxs)("div", { className: "mt-3 rounded-md border border-ui-border-base bg-ui-bg-subtle p-3 text-xs text-ui-fg-subtle", children: [(0, jsx_runtime_1.jsx)("div", { className: "font-medium text-ui-fg-base", children: "Connected PayPal account" }), (0, jsx_runtime_1.jsxs)("div", { className: "mt-1", children: ["Email:", " ", (0, jsx_runtime_1.jsx)("span", { className: "font-mono text-ui-fg-base", children: statusInfo?.seller_email || "Unavailable" })] })] }), (0, jsx_runtime_1.jsx)("div", { className: "mt-3 flex items-center gap-2", children: (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: handleDisconnect, disabled: onboardingInProgress, className: "transition-fg relative inline-flex w-fit items-center justify-center overflow-hidden rounded-md outline-none shadow-buttons-neutral text-ui-fg-base bg-ui-button-neutral after:transition-fg after:absolute after:inset-0 after:content-[''] after:button-neutral-gradient hover:bg-ui-button-neutral-hover hover:after:button-neutral-hover-gradient active:bg-ui-button-neutral-pressed active:after:button-neutral-pressed-gradient focus-visible:shadow-buttons-neutral-focus disabled:bg-ui-bg-disabled disabled:border-ui-border-base disabled:text-ui-fg-disabled disabled:shadow-buttons-neutral disabled:after:hidden txt-compact-small-plus px-3 py-1.5", children: "Disconnect" }) })] })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("div", { ref: initLoaderRef, id: "init-loader", className: `status-msg mb-4 ${connState !== "loading" ? "hidden" : "block"}`, children: [(0, jsx_runtime_1.jsx)("div", { className: "loader inline-block align-middle mr-2" }), (0, jsx_runtime_1.jsx)("span", { id: "loader-text", className: "text-sm", children: onboardingInProgress
529
729
  ? "Configuring connection to PayPal…"
530
730
  : "Checking connection..." })] }), (0, jsx_runtime_1.jsxs)("div", { className: `${connState === "ready" ? "block" : "hidden"}`, children: [(0, jsx_runtime_1.jsx)("a", { ref: (node) => {
731
+ paypalButtonRef.current = node;
531
732
  ppBtnMeasureRef.current = node;
532
- }, id: "paypal-button", target: POPUP_NAME, "data-paypal-button": "true", href: finalUrl || "#", "data-paypal-onboard-complete": "onboardingCallback", "aria-disabled": onboardingInProgress || !partnerReady, className: "transition-fg relative inline-flex w-fit items-center justify-center overflow-hidden rounded-md outline-none no-underline shadow-buttons-neutral text-ui-fg-base bg-ui-button-neutral after:transition-fg after:absolute after:inset-0 after:content-[''] after:button-neutral-gradient hover:bg-ui-button-neutral-hover hover:after:button-neutral-hover-gradient active:bg-ui-button-neutral-pressed active:after:button-neutral-pressed-gradient focus-visible:shadow-buttons-neutral-focus disabled:bg-ui-bg-disabled disabled:border-ui-border-base disabled:text-ui-fg-disabled disabled:shadow-buttons-neutral disabled:after:hidden txt-compact-small-plus px-3 py-1.5", style: {
533
- cursor: onboardingInProgress || !partnerReady
534
- ? "not-allowed"
535
- : "pointer",
536
- opacity: onboardingInProgress || !partnerReady ? 0.6 : 1,
537
- // Keep the anchor mounted (partner.js must find it to bind)
538
- // but un-clickable until partner.js has taken over the
539
- // click — so it can never open a tab via the native href.
540
- pointerEvents: onboardingInProgress || !partnerReady ? "none" : "auto",
541
- }, children: partnerReady || onboardingInProgress
542
- ? "Connect to PayPal"
543
- : "Preparing PayPal…" }), (0, jsx_runtime_1.jsx)("div", { className: "mt-2", style: {
733
+ }, id: "paypal-button", target: POPUP_NAME, "data-paypal-button": "true", href: finalUrl || "#", "data-paypal-onboard-complete": "onboardingCallback", onClick: handleConnectClick, className: "transition-fg relative inline-flex w-fit items-center justify-center overflow-hidden rounded-md outline-none no-underline shadow-buttons-neutral text-ui-fg-base bg-ui-button-neutral after:transition-fg after:absolute after:inset-0 after:content-[''] after:button-neutral-gradient hover:bg-ui-button-neutral-hover hover:after:button-neutral-hover-gradient active:bg-ui-button-neutral-pressed active:after:button-neutral-pressed-gradient focus-visible:shadow-buttons-neutral-focus disabled:bg-ui-bg-disabled disabled:border-ui-border-base disabled:text-ui-fg-disabled disabled:shadow-buttons-neutral disabled:after:hidden txt-compact-small-plus px-3 py-1.5", style: {
734
+ cursor: onboardingInProgress ? "not-allowed" : "pointer",
735
+ opacity: onboardingInProgress ? 0.6 : 1,
736
+ pointerEvents: onboardingInProgress ? "none" : "auto",
737
+ }, children: "Connect to PayPal" }), popupBlocked && ((0, jsx_runtime_1.jsx)("div", { className: "mt-3 text-left text-xs bg-amber-50 text-amber-700 p-3 border border-amber-200 rounded", children: "Your browser blocked the PayPal popup. Please allow popups for this site, then click \u201CConnect to PayPal\u201D again." })), (0, jsx_runtime_1.jsx)("div", { className: "mt-2", style: {
544
738
  width: ppBtnWidth ? `${ppBtnWidth}px` : "auto",
545
739
  marginTop: "20px",
546
740
  marginBottom: "10px",