@easypayment/medusa-payment-paypal 0.9.21 → 0.9.24

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 (40) hide show
  1. package/.medusa/server/src/admin/index.js +258 -214
  2. package/.medusa/server/src/admin/index.mjs +258 -214
  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 +210 -223
  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/onboarding-status/route.d.ts.map +1 -1
  13. package/.medusa/server/src/api/admin/paypal/onboarding-status/route.js +4 -1
  14. package/.medusa/server/src/api/admin/paypal/onboarding-status/route.js.map +1 -1
  15. package/.medusa/server/src/api/store/payment-collections/[id]/payment-sessions/route.d.ts.map +1 -1
  16. package/.medusa/server/src/api/store/payment-collections/[id]/payment-sessions/route.js +6 -8
  17. package/.medusa/server/src/api/store/payment-collections/[id]/payment-sessions/route.js.map +1 -1
  18. package/.medusa/server/src/api/store/paypal/capture-order/route.d.ts.map +1 -1
  19. package/.medusa/server/src/api/store/paypal/capture-order/route.js +11 -0
  20. package/.medusa/server/src/api/store/paypal/capture-order/route.js.map +1 -1
  21. package/.medusa/server/src/api/store/paypal-complete/route.d.ts.map +1 -1
  22. package/.medusa/server/src/api/store/paypal-complete/route.js +2 -4
  23. package/.medusa/server/src/api/store/paypal-complete/route.js.map +1 -1
  24. package/.medusa/server/src/modules/paypal/payment-provider/card-service.d.ts.map +1 -1
  25. package/.medusa/server/src/modules/paypal/payment-provider/card-service.js +55 -34
  26. package/.medusa/server/src/modules/paypal/payment-provider/card-service.js.map +1 -1
  27. package/.medusa/server/src/modules/paypal/utils/core-workflow.d.ts +32 -0
  28. package/.medusa/server/src/modules/paypal/utils/core-workflow.d.ts.map +1 -0
  29. package/.medusa/server/src/modules/paypal/utils/core-workflow.js +40 -0
  30. package/.medusa/server/src/modules/paypal/utils/core-workflow.js.map +1 -0
  31. package/package.json +1 -1
  32. package/src/admin/routes/settings/paypal/connection/page.tsx +273 -293
  33. package/src/api/admin/payment-collections/[id]/payment-sessions/route.ts +6 -8
  34. package/src/api/admin/paypal/environment/route.ts +10 -3
  35. package/src/api/admin/paypal/onboarding-status/route.ts +4 -1
  36. package/src/api/store/payment-collections/[id]/payment-sessions/route.ts +6 -8
  37. package/src/api/store/paypal/capture-order/route.ts +15 -0
  38. package/src/api/store/paypal-complete/route.ts +6 -4
  39. package/src/modules/paypal/payment-provider/card-service.ts +72 -38
  40. package/src/modules/paypal/utils/core-workflow.ts +46 -0
@@ -36,18 +36,13 @@ declare global {
36
36
 
37
37
 
38
38
  const SERVICE_URL = "/admin/paypal/onboarding-link"
39
- // We open the mini-browser popup ourselves, synchronously inside the click
40
- // gesture (see openOnboardingPopup) — the only reliable way to get a popup
41
- // rather than a blocked window or a new tab. The window name matches the
42
- // anchor's `target` and PayPal's conventional "PPFrame": partner.js opens the
43
- // flow with `window.open(url, target, ...)`, so when it runs right after us it
44
- // *reuses this already-open window* (a named-window navigation needs no user
45
- // activation) instead of trying — and failing, asynchronously — to spawn its
46
- // own. That lets partner.js still wire its completion bridge without ever
47
- // opening a second/late window.
39
+ // PayPal's conventional mini-browser window name (also the anchor target).
48
40
  const POPUP_NAME = "PPFrame"
41
+
42
+ // The onboarding link is cached in the browser for 6 hours so it is always
43
+ // present for the mini-browser flow without regenerating on every load.
49
44
  const CACHE_PREFIX = "pp_onboard_cache"
50
- const CACHE_EXPIRY = 10 * 60 * 1000
45
+ const CACHE_EXPIRY = 6 * 60 * 60 * 1000 // 6 hours
51
46
 
52
47
  const ONBOARDING_COMPLETE_ENDPOINT = "/admin/paypal/onboard-complete"
53
48
  const STATUS_ENDPOINT = "/admin/paypal/status"
@@ -57,9 +52,6 @@ const ENVIRONMENT_ENDPOINT = "/admin/paypal/environment"
57
52
 
58
53
  type Env = "sandbox" | "live"
59
54
 
60
- // Onboarding URLs are environment-specific and short-lived, so the cache must be
61
- // keyed per environment — a single shared key let a stale sandbox URL leak into
62
- // the live flow (and vice-versa) after a reload.
63
55
  const cacheKeyFor = (env: Env) => `${CACHE_PREFIX}_${env}`
64
56
 
65
57
  function readCachedUrl(env: Env): string | null {
@@ -81,11 +73,12 @@ function readCachedUrl(env: Env): string | null {
81
73
  return null
82
74
  }
83
75
 
84
- function writeCachedUrl(env: Env, url: string) {
76
+ function writeCachedUrl(env: Env, url: string): boolean {
85
77
  try {
86
78
  localStorage.setItem(cacheKeyFor(env), JSON.stringify({ url, ts: Date.now() }))
79
+ return readCachedUrl(env) === url
87
80
  } catch {
88
- /* ignore quota / disabled storage */
81
+ return false
89
82
  }
90
83
  }
91
84
 
@@ -102,9 +95,6 @@ function clearCachedUrl(env?: Env) {
102
95
  }
103
96
  }
104
97
 
105
- // PayPal delivers the seamless-onboarding result by posting a message to
106
- // window.opener. We validate the sender is actually a PayPal origin before
107
- // trusting authCode/sharedId.
108
98
  function isPayPalOrigin(origin: string): boolean {
109
99
  try {
110
100
  const host = new URL(origin).hostname.toLowerCase()
@@ -119,25 +109,29 @@ function isPayPalOrigin(origin: string): boolean {
119
109
  }
120
110
  }
121
111
 
122
- // The completion message shape is not strongly documented, so extract
123
- // authCode/sharedId defensively from objects, JSON strings, or query strings.
124
- // We only ever act when BOTH values are present, which keeps the many unrelated
125
- // intermediate postMessages PayPal emits from triggering a false completion.
112
+ // Defensively pull authCode/sharedId out of whatever shape PayPal posts back
113
+ // (object, JSON string, or query string; possibly nested). We only act when both
114
+ // are present, which ignores the many unrelated intermediate messages PayPal
115
+ // emits.
126
116
  function extractAuth(data: unknown): { authCode?: string; sharedId?: string } {
127
117
  if (!data) return {}
128
118
 
129
119
  if (typeof data === "object") {
130
120
  const obj = data as Record<string, any>
131
- const authCode = obj.authCode ?? obj.auth_code ?? obj.authcode
121
+ const authCode = obj.authCode ?? obj.auth_code ?? obj.authcode ?? obj.code
132
122
  const sharedId = obj.sharedId ?? obj.shared_id ?? obj.sharedid
133
123
  if (authCode && sharedId) {
134
124
  return { authCode: String(authCode), sharedId: String(sharedId) }
135
125
  }
136
- for (const key of ["data", "payload", "message", "detail", "body"]) {
126
+ for (const key of ["data", "payload", "message", "detail", "body", "params"]) {
137
127
  if (obj[key] && typeof obj[key] === "object") {
138
128
  const inner = extractAuth(obj[key])
139
129
  if (inner.authCode && inner.sharedId) return inner
140
130
  }
131
+ if (typeof obj[key] === "string") {
132
+ const inner = extractAuth(obj[key])
133
+ if (inner.authCode && inner.sharedId) return inner
134
+ }
141
135
  }
142
136
  return {}
143
137
  }
@@ -145,7 +139,7 @@ function extractAuth(data: unknown): { authCode?: string; sharedId?: string } {
145
139
  if (typeof data === "string") {
146
140
  const s = data.trim()
147
141
  if (!s) return {}
148
- if (s.startsWith("{")) {
142
+ if (s.startsWith("{") || s.startsWith("[")) {
149
143
  try {
150
144
  return extractAuth(JSON.parse(s))
151
145
  } catch {
@@ -170,9 +164,6 @@ function extractAuth(data: unknown): { authCode?: string; sharedId?: string } {
170
164
 
171
165
  export default function PayPalConnectionPage() {
172
166
  const [env, setEnv] = useState<Env>("live")
173
- // We must not generate/fetch an onboarding link until we know which
174
- // environment the server is actually on, otherwise the first fetch races the
175
- // GET /environment response and can target the wrong environment.
176
167
  const [envReady, setEnvReady] = useState(false)
177
168
 
178
169
  const [connState, setConnState] = useState<
@@ -198,21 +189,17 @@ export default function PayPalConnectionPage() {
198
189
  const runIdRef = useRef(0)
199
190
  const currentRunId = useRef(0)
200
191
 
201
- // Live mirrors of state so the document-level capture click listener (set up
202
- // once) and the stable completion callback always see current values.
192
+ const envRef = useRef<Env>(env)
193
+ envRef.current = env
203
194
  const finalUrlRef = useRef(finalUrl)
204
195
  finalUrlRef.current = finalUrl
205
196
  const connStateRef = useRef(connState)
206
197
  connStateRef.current = connState
207
198
  const inProgressRef = useRef(onboardingInProgress)
208
199
  inProgressRef.current = onboardingInProgress
209
- const envRef = useRef<Env>(env)
210
- envRef.current = env
211
200
 
212
201
  const popupRef = useRef<Window | null>(null)
213
- const popupPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
214
- // Guards against the two redundant completion paths (our own message listener
215
- // and partner.js's onboardingCallback bridge) both POSTing the same result.
202
+ const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
216
203
  const completedRef = useRef(false)
217
204
 
218
205
  const ppBtnMeasureRef = useRef<HTMLAnchorElement | null>(null)
@@ -227,76 +214,60 @@ export default function PayPalConnectionPage() {
227
214
  setError(msg)
228
215
  }, [])
229
216
 
230
- const stopPopupPoll = useCallback(() => {
231
- if (popupPollRef.current) {
232
- clearInterval(popupPollRef.current)
233
- popupPollRef.current = null
217
+ const stopPoll = useCallback(() => {
218
+ if (pollRef.current) {
219
+ clearInterval(pollRef.current)
220
+ pollRef.current = null
234
221
  }
235
222
  }, [])
236
223
 
237
- // Open the PayPal mini-browser ourselves, synchronously, inside the click
238
- // gesture. This is the crux of the fix: browsers only render window.open() as
239
- // a real popup (instead of a tab or blocking it) when it runs synchronously
240
- // in a user gesture. Because finalUrl is already in state at click time, we
241
- // never need an await before opening, and we never depend on winning a race
242
- // with partner.js's asynchronous click binding.
243
- const openOnboardingPopup = useCallback(
244
- (url: string): Window | null => {
245
- const w = 450
246
- const h = 600
247
- const baseLeft =
248
- typeof window.screenX === "number" ? window.screenX : (window as any).screenLeft || 0
249
- const baseTop =
250
- typeof window.screenY === "number" ? window.screenY : (window as any).screenTop || 0
251
- const outerW =
252
- window.outerWidth || document.documentElement.clientWidth || 1024
253
- const outerH =
254
- window.outerHeight || document.documentElement.clientHeight || 768
255
- const left = Math.round(baseLeft + Math.max(0, (outerW - w) / 2))
256
- const top = Math.round(baseTop + Math.max(0, (outerH - h) / 2))
257
- const features = `popup=yes,width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`
258
-
259
- let popup: Window | null = null
224
+ // Open PayPal's mini-browser ourselves, synchronously inside the click gesture.
225
+ // This is the only reliable way to get a popup (rather than a new tab, which is
226
+ // what happens when partner.js's asynchronous open misses the user-activation
227
+ // window). Named POPUP_NAME and sized like PayPal's mini-browser.
228
+ const openOnboardingPopup = useCallback((url: string): Window | null => {
229
+ const w = 450
230
+ const h = 600
231
+ const baseLeft =
232
+ typeof window.screenX === "number" ? window.screenX : (window as any).screenLeft || 0
233
+ const baseTop =
234
+ typeof window.screenY === "number" ? window.screenY : (window as any).screenTop || 0
235
+ const outerW = window.outerWidth || document.documentElement.clientWidth || 1024
236
+ const outerH = window.outerHeight || document.documentElement.clientHeight || 768
237
+ const left = Math.round(baseLeft + Math.max(0, (outerW - w) / 2))
238
+ const top = Math.round(baseTop + Math.max(0, (outerH - h) / 2))
239
+ const features = `popup=yes,width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`
240
+
241
+ let popup: Window | null = null
242
+ try {
243
+ popup = window.open(url, POPUP_NAME, features)
244
+ } catch {
245
+ popup = null
246
+ }
247
+ if (popup) {
248
+ popupRef.current = popup
260
249
  try {
261
- popup = window.open(url, POPUP_NAME, features)
250
+ popup.focus()
262
251
  } catch {
263
- popup = null
264
- }
265
-
266
- if (popup) {
267
- popupRef.current = popup
268
- try {
269
- popup.focus()
270
- } catch {
271
- /* ignore */
272
- }
273
- stopPopupPoll()
274
- popupPollRef.current = setInterval(() => {
275
- if (!popupRef.current || popupRef.current.closed) {
276
- stopPopupPoll()
277
- }
278
- }, 1000)
252
+ /* ignore */
279
253
  }
254
+ }
255
+ return popup
256
+ }, [])
280
257
 
281
- return popup
282
- },
283
- [stopPopupPoll]
284
- )
285
-
286
- // Exchange the seller authCode/sharedId for credentials. Reached from BOTH
287
- // PayPal's partner.js bridge (window.onboardingCallback) and our own postMessage
288
- // listener; completedRef makes it idempotent so we only POST once.
258
+ // Exchange authCode/sharedId for seller credentials. Idempotent so the multiple
259
+ // completion signals (our message listener, partner.js's bridge, the status
260
+ // poll) only run it once.
289
261
  const completeOnboarding = useCallback(
290
262
  async (authCode: string, sharedId: string) => {
291
263
  if (!authCode || !sharedId) return
292
264
  if (completedRef.current) return
293
265
  completedRef.current = true
294
266
 
267
+ stopPoll()
295
268
  try {
296
269
  window.onbeforeunload = null
297
- } catch {
298
- /* ignore */
299
- }
270
+ } catch {}
300
271
 
301
272
  const activeEnv: Env = envRef.current === "sandbox" ? "sandbox" : "live"
302
273
 
@@ -318,45 +289,34 @@ export default function PayPalConnectionPage() {
318
289
  throw new Error(txt || `Onboarding exchange failed (${res.status})`)
319
290
  }
320
291
 
321
- // Close the popup we opened, plus any partner.js-managed flow.
322
292
  try {
323
293
  popupRef.current?.close()
324
- } catch {
325
- /* ignore */
326
- }
327
- stopPopupPoll()
294
+ } catch {}
328
295
  try {
329
296
  const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow
330
297
  if (typeof close1 === "function") close1()
331
- } catch {
332
- /* ignore */
333
- }
298
+ } catch {}
334
299
  try {
335
300
  const close2 =
336
301
  window.PAYPAL?.apps?.Signup?.miniBrowser &&
337
302
  (window.PAYPAL.apps.Signup.miniBrowser as any).closeFlow
338
303
  if (typeof close2 === "function") close2()
339
- } catch {
340
- /* ignore */
341
- }
304
+ } catch {}
342
305
 
343
306
  clearCachedUrl(activeEnv)
344
307
 
345
308
  try {
346
- const statusRes = await fetch(
347
- `${STATUS_ENDPOINT}?environment=${activeEnv}`,
348
- { method: "GET", credentials: "include" }
349
- )
309
+ const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${activeEnv}`, {
310
+ method: "GET",
311
+ credentials: "include",
312
+ })
350
313
  const refreshedStatus = await statusRes.json().catch(() => ({}))
351
314
  setStatusInfo(refreshedStatus || null)
352
- } catch {
353
- /* ignore — still consider it connected below */
354
- }
315
+ } catch {}
355
316
 
356
317
  setConnState("connected")
357
318
  setShowManual(false)
358
319
  } catch (e: any) {
359
- // Allow a retry after a failed exchange.
360
320
  completedRef.current = false
361
321
  console.error(e)
362
322
  setConnState("error")
@@ -365,58 +325,91 @@ export default function PayPalConnectionPage() {
365
325
  setOnboardingInProgress(false)
366
326
  }
367
327
  },
368
- [stopPopupPoll]
328
+ [stopPoll]
369
329
  )
370
330
 
371
- const fetchFreshLink = useCallback(
372
- (runId: number, targetEnv: Env) => {
331
+ // After the popup is opened, poll the server. If the credentials get saved by
332
+ // any completion path, the status flips to "connected" and we sync the UI even
333
+ // if a message was missed.
334
+ const startStatusPoll = useCallback(() => {
335
+ stopPoll()
336
+ let ticks = 0
337
+ pollRef.current = setInterval(async () => {
338
+ ticks += 1
339
+ if (completedRef.current || ticks > 150 /* ~5 min */) {
340
+ stopPoll()
341
+ return
342
+ }
343
+ try {
344
+ const r = await fetch(`${STATUS_ENDPOINT}?environment=${envRef.current}`, {
345
+ method: "GET",
346
+ credentials: "include",
347
+ })
348
+ const st = await r.json().catch(() => ({}))
349
+ if (st?.status === "connected" && st?.seller_client_id_present === true) {
350
+ completedRef.current = true
351
+ stopPoll()
352
+ try {
353
+ popupRef.current?.close()
354
+ } catch {}
355
+ clearCachedUrl(envRef.current)
356
+ setStatusInfo(st)
357
+ setConnState("connected")
358
+ setShowManual(false)
359
+ setOnboardingInProgress(false)
360
+ }
361
+ } catch {
362
+ /* keep polling */
363
+ }
364
+ }, 2000)
365
+ }, [stopPoll])
366
+
367
+ const generateLinkAndReload = useCallback(
368
+ async (targetEnv: Env, runId: number) => {
373
369
  if (initLoaderRef.current) {
374
370
  const loaderText = initLoaderRef.current.querySelector("#loader-text")
375
- if (loaderText)
376
- loaderText.textContent = "Generating onboarding session..."
371
+ if (loaderText) loaderText.textContent = "Generating onboarding session..."
377
372
  }
378
373
 
379
- fetch(SERVICE_URL, {
380
- method: "POST",
381
- headers: { "content-type": "application/json" },
382
- credentials: "include",
383
- body: JSON.stringify({
384
- products: ["PPCP"],
385
- // Generate the link for the explicitly selected environment so it can
386
- // never depend on a racing POST /environment having landed yet.
387
- environment: targetEnv,
388
- }),
389
- })
390
- .then((r) => {
391
- if (!r.ok) throw new Error(`Service returned ${r.status}`)
392
- return r.json()
374
+ try {
375
+ const res = await fetch(SERVICE_URL, {
376
+ method: "POST",
377
+ headers: { "content-type": "application/json" },
378
+ credentials: "include",
379
+ body: JSON.stringify({ products: ["PPCP"], environment: targetEnv }),
393
380
  })
394
- .then((data) => {
395
- if (runId !== currentRunId.current) return
396
381
 
397
- const href = data?.onboarding_url
398
- if (!href) {
399
- showError("Onboarding URL not returned.")
400
- return
401
- }
382
+ if (!res.ok) throw new Error(`Service returned ${res.status}`)
402
383
 
403
- const url =
404
- href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser"
384
+ const data = await res.json()
385
+ if (runId !== currentRunId.current) return
405
386
 
406
- writeCachedUrl(targetEnv, url)
387
+ const href = data?.onboarding_url
388
+ if (!href) {
389
+ showError("Onboarding URL not returned.")
390
+ return
391
+ }
407
392
 
408
- setFinalUrl(url)
409
- setConnState("ready")
410
- })
411
- .catch(() => {
412
- if (runId !== currentRunId.current) return
413
- showError("Unable to connect to service.")
414
- })
393
+ const url =
394
+ href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser"
395
+
396
+ const persisted = writeCachedUrl(targetEnv, url)
397
+ if (persisted) {
398
+ window.location.reload()
399
+ return
400
+ }
401
+
402
+ setFinalUrl(url)
403
+ setConnState("ready")
404
+ } catch {
405
+ if (runId !== currentRunId.current) return
406
+ showError("Unable to connect to service.")
407
+ }
415
408
  },
416
409
  [showError]
417
410
  )
418
411
 
419
- // Resolve the server's current environment before doing anything else.
412
+ // Resolve the server environment first.
420
413
  useEffect(() => {
421
414
  let alive = true
422
415
  fetch(ENVIRONMENT_ENDPOINT, { method: "GET", credentials: "include" })
@@ -434,14 +427,67 @@ export default function PayPalConnectionPage() {
434
427
  }
435
428
  }, [])
436
429
 
437
- // partner.js is loaded ONLY for its postMessage->callback bridge
438
- // (window.onboardingCallback), as a redundant path next to our own message
439
- // listener below. It is NO LONGER responsible for opening the window — we do
440
- // that synchronously in the click handler. We still init it so its message
441
- // bridge registers inside this React SPA (its own DOMContentLoaded hook fired
442
- // long before this route mounted). If partner.js also intercepts the click,
443
- // the shared POPUP_NAME means it re-targets our single popup rather than
444
- // opening a second window.
430
+ // Page-load flow: connected? -> cached link? -> else generate + cache + reload.
431
+ useEffect(() => {
432
+ if (!envReady) return
433
+
434
+ currentRunId.current = ++runIdRef.current
435
+ const runId = currentRunId.current
436
+
437
+ let cancelled = false
438
+ completedRef.current = false
439
+
440
+ const run = async () => {
441
+ setConnState("loading")
442
+ setError(null)
443
+ setPopupBlocked(false)
444
+ setFinalUrl("")
445
+
446
+ try {
447
+ const r = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
448
+ method: "GET",
449
+ credentials: "include",
450
+ })
451
+ const st = await r.json().catch(() => ({}))
452
+
453
+ if (cancelled || runId !== currentRunId.current) return
454
+
455
+ setStatusInfo(st)
456
+
457
+ const isConnected =
458
+ st?.status === "connected" && st?.seller_client_id_present === true
459
+
460
+ if (isConnected) {
461
+ setConnState("connected")
462
+ setShowManual(false)
463
+ return
464
+ }
465
+ } catch (e) {
466
+ console.error(e)
467
+ }
468
+
469
+ if (cancelled || runId !== currentRunId.current) return
470
+
471
+ const cached = readCachedUrl(env)
472
+ if (cached) {
473
+ setFinalUrl(cached)
474
+ setConnState("ready")
475
+ return
476
+ }
477
+
478
+ await generateLinkAndReload(env, runId)
479
+ }
480
+
481
+ run()
482
+
483
+ return () => {
484
+ cancelled = true
485
+ }
486
+ }, [env, envReady, generateLinkAndReload])
487
+
488
+ // Load partner.js + init for its postMessage->onboardingCallback bridge (a
489
+ // secondary completion path next to our own message listener). We open the
490
+ // popup ourselves, so partner.js is NOT relied on to open the window.
445
491
  useEffect(() => {
446
492
  if (connState !== "ready" || !finalUrl) return
447
493
 
@@ -462,16 +508,12 @@ export default function PayPalConnectionPage() {
462
508
 
463
509
  if (target?.init) {
464
510
  try {
465
- // Wires partner.js's click interceptor + its postMessage->callback
466
- // bridge. It will adopt the window we pre-open (same name) rather than
467
- // open its own.
468
511
  target.init()
469
512
  } catch (e) {
470
513
  console.error("[paypal] partner.js init failed:", e)
471
514
  }
472
515
  return
473
516
  }
474
-
475
517
  if (typeof signup?.render === "function") {
476
518
  try {
477
519
  signup.render()
@@ -480,19 +522,15 @@ export default function PayPalConnectionPage() {
480
522
  }
481
523
  return
482
524
  }
483
-
484
525
  if (attempt < 40) {
485
526
  pollTimer = setTimeout(() => initPartner(attempt + 1), 50)
486
527
  return
487
528
  }
488
-
489
529
  try {
490
530
  document.dispatchEvent(new Event("DOMContentLoaded"))
491
531
  } catch {}
492
532
  }
493
533
 
494
- // Remove any stale copy + namespace so the fresh load re-registers against
495
- // the current environment (sandbox vs live partner.js differ).
496
534
  const existingScript = document.getElementById("paypal-partner-js")
497
535
  if (existingScript) {
498
536
  existingScript.parentNode?.removeChild(existingScript)
@@ -512,20 +550,38 @@ export default function PayPalConnectionPage() {
512
550
 
513
551
  return () => {
514
552
  cancelled = true
515
- if (pollTimer) {
516
- clearTimeout(pollTimer)
517
- }
518
- if (ppScript.parentNode) {
519
- ppScript.parentNode.removeChild(ppScript)
520
- }
553
+ if (pollTimer) clearTimeout(pollTimer)
554
+ if (ppScript.parentNode) ppScript.parentNode.removeChild(ppScript)
521
555
  }
522
556
  }, [connState, finalUrl, env])
523
557
 
524
- // Primary completion path: receive PayPal's seamless-onboarding postMessage
525
- // ourselves. Independent of partner.js, so it works even when we opened the
526
- // popup directly.
558
+ // PRIMARY completion path: receive PayPal's onboarding postMessage ourselves.
559
+ // The diagnostic log lets us confirm the exact origin/shape PayPal sends in a
560
+ // real run if a tweak to the parser is ever needed.
527
561
  useEffect(() => {
528
562
  const onMessage = (ev: MessageEvent) => {
563
+ let serialized = ""
564
+ try {
565
+ serialized =
566
+ typeof ev.data === "string" ? ev.data : JSON.stringify(ev.data)
567
+ } catch {
568
+ serialized = String(ev.data)
569
+ }
570
+
571
+ const looksRelevant =
572
+ isPayPalOrigin(ev.origin) ||
573
+ /auth_?code|shared_?id|onboard|merchantId/i.test(serialized || "")
574
+
575
+ if (looksRelevant) {
576
+ // eslint-disable-next-line no-console
577
+ console.info(
578
+ "[PayPal onboarding] message from",
579
+ ev.origin,
580
+ "·",
581
+ (serialized || "").slice(0, 500)
582
+ )
583
+ }
584
+
529
585
  if (!isPayPalOrigin(ev.origin)) return
530
586
  const { authCode, sharedId } = extractAuth(ev.data)
531
587
  if (authCode && sharedId) {
@@ -536,66 +592,7 @@ export default function PayPalConnectionPage() {
536
592
  return () => window.removeEventListener("message", onMessage)
537
593
  }, [completeOnboarding])
538
594
 
539
- // Status check + link generation. Gated on envReady so we always use the
540
- // correct environment. runId guards against a stale response from a previous
541
- // environment overwriting the current one.
542
- useEffect(() => {
543
- if (!envReady) return
544
-
545
- currentRunId.current = ++runIdRef.current
546
- const runId = currentRunId.current
547
-
548
- let cancelled = false
549
- completedRef.current = false
550
-
551
- const run = async () => {
552
- setConnState("loading")
553
- setError(null)
554
- setPopupBlocked(false)
555
- setFinalUrl("")
556
-
557
- try {
558
- const r = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
559
- method: "GET",
560
- credentials: "include",
561
- })
562
- const st = await r.json().catch(() => ({}))
563
-
564
- if (cancelled || runId !== currentRunId.current) return
565
-
566
- setStatusInfo(st)
567
-
568
- const isConnected =
569
- st?.status === "connected" && st?.seller_client_id_present === true
570
-
571
- if (isConnected) {
572
- setConnState("connected")
573
- setShowManual(false)
574
- return
575
- }
576
- } catch (e) {
577
- console.error(e)
578
- }
579
-
580
- if (cancelled || runId !== currentRunId.current) return
581
-
582
- const cached = readCachedUrl(env)
583
- if (cached) {
584
- setFinalUrl(cached)
585
- setConnState("ready")
586
- } else {
587
- fetchFreshLink(runId, env)
588
- }
589
- }
590
-
591
- run()
592
-
593
- return () => {
594
- cancelled = true
595
- }
596
- }, [env, envReady, fetchFreshLink])
597
-
598
- // Bridge partner.js's callback into our single idempotent completion handler.
595
+ // SECONDARY completion path: partner.js's bridge calls this by name.
599
596
  useLayoutEffect(() => {
600
597
  window.onboardingCallback = (authCode: string, sharedId: string) => {
601
598
  completeOnboarding(authCode, sharedId)
@@ -605,12 +602,44 @@ export default function PayPalConnectionPage() {
605
602
  }
606
603
  }, [completeOnboarding])
607
604
 
608
- // Clean up the popup poll on unmount.
605
+ // Capture-phase opener: runs before partner.js, opens the popup synchronously,
606
+ // and stops partner.js's (unreliable, async) open + the native anchor so we get
607
+ // exactly one popup and never a new tab.
609
608
  useEffect(() => {
610
- return () => {
611
- stopPopupPoll()
609
+ const onCaptureClick = (e: MouseEvent) => {
610
+ const btn = paypalButtonRef.current
611
+ if (!btn) return
612
+ const target = e.target as Node | null
613
+ if (!target || !btn.contains(target)) return
614
+
615
+ e.preventDefault()
616
+ e.stopImmediatePropagation()
617
+
618
+ if (
619
+ connStateRef.current !== "ready" ||
620
+ !finalUrlRef.current ||
621
+ inProgressRef.current
622
+ ) {
623
+ return
624
+ }
625
+
626
+ completedRef.current = false
627
+ const popup = openOnboardingPopup(finalUrlRef.current)
628
+ if (!popup) {
629
+ setPopupBlocked(true)
630
+ return
631
+ }
632
+ setPopupBlocked(false)
633
+ startStatusPoll()
612
634
  }
613
- }, [stopPopupPoll])
635
+
636
+ document.addEventListener("click", onCaptureClick, true)
637
+ return () => document.removeEventListener("click", onCaptureClick, true)
638
+ }, [openOnboardingPopup, startStatusPoll])
639
+
640
+ useEffect(() => {
641
+ return () => stopPoll()
642
+ }, [stopPoll])
614
643
 
615
644
  useLayoutEffect(() => {
616
645
  const el = ppBtnMeasureRef.current
@@ -637,53 +666,8 @@ export default function PayPalConnectionPage() {
637
666
  }
638
667
  }, [connState, env, finalUrl])
639
668
 
640
- // Capture-phase click handler the single, reliable opener.
641
- //
642
- // It runs on `document` in the CAPTURE phase, i.e. BEFORE the event reaches
643
- // the anchor where partner.js binds its own (target-phase) handler. That
644
- // ordering is the whole point:
645
- // 1. We synchronously open the mini-browser popup here, inside the user
646
- // gesture, so the browser always renders a real popup (never a blocked
647
- // window, never a new tab).
648
- // 2. We do NOT stop propagation, so partner.js's handler still runs a beat
649
- // later. Since it opens with the same window name (POPUP_NAME), it adopts
650
- // the window we just opened instead of asynchronously spawning its own
651
- // (which the browser was blocking — "multiple popups ... lack of user
652
- // activation") — and it still wires its postMessage->onboardingCallback
653
- // bridge that auto-closes the popup and saves credentials.
654
- // 3. We always preventDefault so the anchor's href can never open a tab.
655
- //
656
- // Completion is handled by partner.js's bridge AND by our own postMessage
657
- // listener (see below), funnelling through one idempotent completeOnboarding.
658
- useEffect(() => {
659
- const onCaptureClick = (e: MouseEvent) => {
660
- const btn = paypalButtonRef.current
661
- if (!btn) return
662
- const target = e.target as Node | null
663
- if (!target || !btn.contains(target)) return
664
-
665
- e.preventDefault()
666
-
667
- if (
668
- connStateRef.current !== "ready" ||
669
- !finalUrlRef.current ||
670
- inProgressRef.current
671
- ) {
672
- return
673
- }
674
-
675
- completedRef.current = false
676
- const popup = openOnboardingPopup(finalUrlRef.current)
677
- setPopupBlocked(!popup)
678
- }
679
-
680
- document.addEventListener("click", onCaptureClick, true)
681
- return () => document.removeEventListener("click", onCaptureClick, true)
682
- }, [openOnboardingPopup])
683
-
684
- // Defensive only: the capture-phase listener above already opened the popup
685
- // and prevented the default. If for some reason it didn't run, make sure the
686
- // native target still can't open a tab.
669
+ // Defensive: the capture listener handles the click; this just guarantees the
670
+ // native href can never navigate.
687
671
  const handleConnectClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
688
672
  e.preventDefault()
689
673
  }
@@ -756,11 +740,10 @@ export default function PayPalConnectionPage() {
756
740
  }
757
741
 
758
742
  clearCachedUrl(env)
759
-
760
743
  completedRef.current = false
744
+
761
745
  currentRunId.current = ++runIdRef.current
762
- const runId = currentRunId.current
763
- fetchFreshLink(runId, env)
746
+ await generateLinkAndReload(env, currentRunId.current)
764
747
  } catch (e: any) {
765
748
  console.error(e)
766
749
  setConnState("error")
@@ -772,17 +755,12 @@ export default function PayPalConnectionPage() {
772
755
 
773
756
  const handleEnvChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
774
757
  const next = e.target.value as Env
758
+ if (next === env || onboardingInProgress) return
775
759
 
776
760
  completedRef.current = false
777
- setPopupBlocked(false)
778
- // Drop both cached URLs so neither environment can serve a stale link.
779
761
  clearCachedUrl()
780
-
781
- // Update local state immediately (drives the status/link refetch with the
782
- // explicit environment) and persist the choice. Because the link/status
783
- // fetches are environment-explicit, correctness no longer depends on this
784
- // POST winning a race.
785
- setEnv(next)
762
+ setPopupBlocked(false)
763
+ setConnState("loading")
786
764
 
787
765
  try {
788
766
  await fetch(ENVIRONMENT_ENDPOINT, {
@@ -792,6 +770,8 @@ export default function PayPalConnectionPage() {
792
770
  body: JSON.stringify({ environment: next }),
793
771
  })
794
772
  } catch {}
773
+
774
+ setEnv(next)
795
775
  }
796
776
 
797
777
  return (