@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
@@ -36,16 +36,11 @@ declare global {
36
36
 
37
37
 
38
38
  const SERVICE_URL = "/admin/paypal/onboarding-link"
39
- // PayPal's conventional mini-browser window name; also used as the anchor's
40
- // `target` so partner.js opens the flow in a popup.
39
+ // PayPal's conventional mini-browser window name (also the anchor target).
41
40
  const POPUP_NAME = "PPFrame"
42
41
 
43
- // The onboarding link is cached in the browser for 6 hours. This is the heart of
44
- // the integration: partner.js can only wire the mini-browser + completion
45
- // callback if the anchor already carries a valid onboarding href when partner.js
46
- // initializes. By caching the link (and reloading once to populate the cache on
47
- // a cold start) the link is always present on page load, so partner.js can take
48
- // over the click and run its flow exactly as documented.
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
45
  const CACHE_EXPIRY = 6 * 60 * 60 * 1000 // 6 hours
51
46
 
@@ -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, so the cache is keyed per
61
- // environment — a single shared key let a stale sandbox URL leak into the live
62
- // flow (and vice-versa).
63
55
  const cacheKeyFor = (env: Env) => `${CACHE_PREFIX}_${env}`
64
56
 
65
57
  function readCachedUrl(env: Env): string | null {
@@ -81,8 +73,6 @@ function readCachedUrl(env: Env): string | null {
81
73
  return null
82
74
  }
83
75
 
84
- // Returns true only if the value was actually persisted (storage enabled), which
85
- // lets the caller decide whether reloading to pick up the cache is safe.
86
76
  function writeCachedUrl(env: Env, url: string): boolean {
87
77
  try {
88
78
  localStorage.setItem(cacheKeyFor(env), JSON.stringify({ url, ts: Date.now() }))
@@ -105,22 +95,82 @@ function clearCachedUrl(env?: Env) {
105
95
  }
106
96
  }
107
97
 
98
+ function isPayPalOrigin(origin: string): boolean {
99
+ try {
100
+ const host = new URL(origin).hostname.toLowerCase()
101
+ return (
102
+ host === "www.paypal.com" ||
103
+ host === "www.sandbox.paypal.com" ||
104
+ host.endsWith(".paypal.com") ||
105
+ host.endsWith(".paypalobjects.com")
106
+ )
107
+ } catch {
108
+ return false
109
+ }
110
+ }
111
+
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.
116
+ function extractAuth(data: unknown): { authCode?: string; sharedId?: string } {
117
+ if (!data) return {}
118
+
119
+ if (typeof data === "object") {
120
+ const obj = data as Record<string, any>
121
+ const authCode = obj.authCode ?? obj.auth_code ?? obj.authcode ?? obj.code
122
+ const sharedId = obj.sharedId ?? obj.shared_id ?? obj.sharedid
123
+ if (authCode && sharedId) {
124
+ return { authCode: String(authCode), sharedId: String(sharedId) }
125
+ }
126
+ for (const key of ["data", "payload", "message", "detail", "body", "params"]) {
127
+ if (obj[key] && typeof obj[key] === "object") {
128
+ const inner = extractAuth(obj[key])
129
+ if (inner.authCode && inner.sharedId) return inner
130
+ }
131
+ if (typeof obj[key] === "string") {
132
+ const inner = extractAuth(obj[key])
133
+ if (inner.authCode && inner.sharedId) return inner
134
+ }
135
+ }
136
+ return {}
137
+ }
138
+
139
+ if (typeof data === "string") {
140
+ const s = data.trim()
141
+ if (!s) return {}
142
+ if (s.startsWith("{") || s.startsWith("[")) {
143
+ try {
144
+ return extractAuth(JSON.parse(s))
145
+ } catch {
146
+ /* not JSON */
147
+ }
148
+ }
149
+ if (/auth_?code/i.test(s) && /shared_?id/i.test(s)) {
150
+ try {
151
+ const sp = new URLSearchParams(s.replace(/^[?#]/, ""))
152
+ const authCode = sp.get("authCode") || sp.get("auth_code") || undefined
153
+ const sharedId = sp.get("sharedId") || sp.get("shared_id") || undefined
154
+ if (authCode && sharedId) return { authCode, sharedId }
155
+ } catch {
156
+ /* not a query string */
157
+ }
158
+ }
159
+ }
160
+
161
+ return {}
162
+ }
163
+
108
164
 
109
165
  export default function PayPalConnectionPage() {
110
166
  const [env, setEnv] = useState<Env>("live")
111
- // We must know the server's environment before generating/looking up a link so
112
- // the per-environment cache and the generated link always match.
113
167
  const [envReady, setEnvReady] = useState(false)
114
168
 
115
169
  const [connState, setConnState] = useState<
116
170
  "loading" | "ready" | "connected" | "error"
117
171
  >("loading")
118
- // True once partner.js has loaded and bound its click interceptor to the
119
- // button. The button stays in the DOM but is not clickable until then, so a
120
- // click can never fall through to the native anchor (which would open a tab
121
- // without partner.js's onboarding flow / completion callback).
122
- const [partnerReady, setPartnerReady] = useState(false)
123
172
  const [error, setError] = useState<string | null>(null)
173
+ const [popupBlocked, setPopupBlocked] = useState(false)
124
174
  const [finalUrl, setFinalUrl] = useState<string>("")
125
175
  const [showManual, setShowManual] = useState(false)
126
176
  const [clientId, setClientId] = useState("")
@@ -134,15 +184,22 @@ export default function PayPalConnectionPage() {
134
184
  const [onboardingInProgress, setOnboardingInProgress] = useState(false)
135
185
 
136
186
  const initLoaderRef = useRef<HTMLDivElement>(null)
187
+ const paypalButtonRef = useRef<HTMLAnchorElement | null>(null)
137
188
  const errorLogRef = useRef<HTMLDivElement>(null)
138
189
  const runIdRef = useRef(0)
139
190
  const currentRunId = useRef(0)
140
191
 
141
192
  const envRef = useRef<Env>(env)
142
193
  envRef.current = env
143
-
144
- // Guards against partner.js's bridge invoking the completion callback more
145
- // than once.
194
+ const finalUrlRef = useRef(finalUrl)
195
+ finalUrlRef.current = finalUrl
196
+ const connStateRef = useRef(connState)
197
+ connStateRef.current = connState
198
+ const inProgressRef = useRef(onboardingInProgress)
199
+ inProgressRef.current = onboardingInProgress
200
+
201
+ const popupRef = useRef<Window | null>(null)
202
+ const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
146
203
  const completedRef = useRef(false)
147
204
 
148
205
  const ppBtnMeasureRef = useRef<HTMLAnchorElement | null>(null)
@@ -157,17 +214,161 @@ export default function PayPalConnectionPage() {
157
214
  setError(msg)
158
215
  }, [])
159
216
 
160
- // Generate a fresh onboarding link, cache it for 6 hours, then reload the page
161
- // so that on the next load the cached link is already present for partner.js.
162
- // If browser storage is unavailable we fall back to using the link in-place
163
- // (no reload) so we can never get stuck in a reload loop.
217
+ const stopPoll = useCallback(() => {
218
+ if (pollRef.current) {
219
+ clearInterval(pollRef.current)
220
+ pollRef.current = null
221
+ }
222
+ }, [])
223
+
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
249
+ try {
250
+ popup.focus()
251
+ } catch {
252
+ /* ignore */
253
+ }
254
+ }
255
+ return popup
256
+ }, [])
257
+
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.
261
+ const completeOnboarding = useCallback(
262
+ async (authCode: string, sharedId: string) => {
263
+ if (!authCode || !sharedId) return
264
+ if (completedRef.current) return
265
+ completedRef.current = true
266
+
267
+ stopPoll()
268
+ try {
269
+ window.onbeforeunload = null
270
+ } catch {}
271
+
272
+ const activeEnv: Env = envRef.current === "sandbox" ? "sandbox" : "live"
273
+
274
+ setOnboardingInProgress(true)
275
+ setConnState("loading")
276
+ setError(null)
277
+ setPopupBlocked(false)
278
+
279
+ try {
280
+ const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
281
+ method: "POST",
282
+ headers: { "content-type": "application/json" },
283
+ credentials: "include",
284
+ body: JSON.stringify({ authCode, sharedId, env: activeEnv }),
285
+ })
286
+
287
+ if (!res.ok) {
288
+ const txt = await res.text().catch(() => "")
289
+ throw new Error(txt || `Onboarding exchange failed (${res.status})`)
290
+ }
291
+
292
+ try {
293
+ popupRef.current?.close()
294
+ } catch {}
295
+ try {
296
+ const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow
297
+ if (typeof close1 === "function") close1()
298
+ } catch {}
299
+ try {
300
+ const close2 =
301
+ window.PAYPAL?.apps?.Signup?.miniBrowser &&
302
+ (window.PAYPAL.apps.Signup.miniBrowser as any).closeFlow
303
+ if (typeof close2 === "function") close2()
304
+ } catch {}
305
+
306
+ clearCachedUrl(activeEnv)
307
+
308
+ try {
309
+ const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${activeEnv}`, {
310
+ method: "GET",
311
+ credentials: "include",
312
+ })
313
+ const refreshedStatus = await statusRes.json().catch(() => ({}))
314
+ setStatusInfo(refreshedStatus || null)
315
+ } catch {}
316
+
317
+ setConnState("connected")
318
+ setShowManual(false)
319
+ } catch (e: any) {
320
+ completedRef.current = false
321
+ console.error(e)
322
+ setConnState("error")
323
+ setError(e?.message || "Exchange failed while saving credentials.")
324
+ } finally {
325
+ setOnboardingInProgress(false)
326
+ }
327
+ },
328
+ [stopPoll]
329
+ )
330
+
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
+
164
367
  const generateLinkAndReload = useCallback(
165
368
  async (targetEnv: Env, runId: number) => {
166
369
  if (initLoaderRef.current) {
167
370
  const loaderText = initLoaderRef.current.querySelector("#loader-text")
168
- if (loaderText) {
169
- loaderText.textContent = "Generating onboarding session..."
170
- }
371
+ if (loaderText) loaderText.textContent = "Generating onboarding session..."
171
372
  }
172
373
 
173
374
  try {
@@ -193,16 +394,11 @@ export default function PayPalConnectionPage() {
193
394
  href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser"
194
395
 
195
396
  const persisted = writeCachedUrl(targetEnv, url)
196
-
197
397
  if (persisted) {
198
- // The cache survived; reload so the link is present at first render and
199
- // partner.js initializes against it. The post-reload load finds the
200
- // cache (no regeneration), so this can't loop.
201
398
  window.location.reload()
202
399
  return
203
400
  }
204
401
 
205
- // Storage disabled — use the link directly without reloading.
206
402
  setFinalUrl(url)
207
403
  setConnState("ready")
208
404
  } catch {
@@ -231,11 +427,7 @@ export default function PayPalConnectionPage() {
231
427
  }
232
428
  }, [])
233
429
 
234
- // Page-load flow:
235
- // 1. If already connected, show the connected state.
236
- // 2. Else if a valid cached link exists, use it (partner.js will bind it).
237
- // 3. Else generate a link, cache it for 6h, and reload so the cached link is
238
- // present for partner.js on the next load.
430
+ // Page-load flow: connected? -> cached link? -> else generate + cache + reload.
239
431
  useEffect(() => {
240
432
  if (!envReady) return
241
433
 
@@ -248,6 +440,7 @@ export default function PayPalConnectionPage() {
248
440
  const run = async () => {
249
441
  setConnState("loading")
250
442
  setError(null)
443
+ setPopupBlocked(false)
251
444
  setFinalUrl("")
252
445
 
253
446
  try {
@@ -292,12 +485,9 @@ export default function PayPalConnectionPage() {
292
485
  }
293
486
  }, [env, envReady, generateLinkAndReload])
294
487
 
295
- // Load partner.js once the anchor exists with its real (cached) href, then run
296
- // PayPal's initializer. In a React SPA partner.js's own DOMContentLoaded hook
297
- // already fired before this route mounted, so we must call init()/render()
298
- // ourselves for it to discover the button and take over the click into the
299
- // mini-browser — opening the popup, and wiring its postMessage bridge that
300
- // invokes window.onboardingCallback and closes the popup on completion.
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.
301
491
  useEffect(() => {
302
492
  if (connState !== "ready" || !finalUrl) return
303
493
 
@@ -305,19 +495,6 @@ export default function PayPalConnectionPage() {
305
495
  let cancelled = false
306
496
  let pollTimer: ReturnType<typeof setTimeout> | undefined
307
497
 
308
- // The button is gated on partnerReady; reset it for this (re)load.
309
- setPartnerReady(false)
310
-
311
- // Safety net: if partner.js can't load (e.g. blocked), enable the button
312
- // anyway after a few seconds rather than leaving it permanently disabled.
313
- const readyFallback = setTimeout(() => {
314
- if (!cancelled) setPartnerReady(true)
315
- }, 6000)
316
-
317
- const markReady = () => {
318
- if (!cancelled) setPartnerReady(true)
319
- }
320
-
321
498
  const initPartner = (attempt = 0) => {
322
499
  if (cancelled) return
323
500
 
@@ -335,35 +512,25 @@ export default function PayPalConnectionPage() {
335
512
  } catch (e) {
336
513
  console.error("[paypal] partner.js init failed:", e)
337
514
  }
338
- markReady()
339
515
  return
340
516
  }
341
-
342
517
  if (typeof signup?.render === "function") {
343
518
  try {
344
519
  signup.render()
345
520
  } catch (e) {
346
521
  console.error("[paypal] partner.js render failed:", e)
347
522
  }
348
- markReady()
349
523
  return
350
524
  }
351
-
352
- // The namespace may not be populated the instant onload fires; retry
353
- // briefly before giving up.
354
525
  if (attempt < 40) {
355
526
  pollTimer = setTimeout(() => initPartner(attempt + 1), 50)
356
527
  return
357
528
  }
358
-
359
529
  try {
360
530
  document.dispatchEvent(new Event("DOMContentLoaded"))
361
531
  } catch {}
362
- markReady()
363
532
  }
364
533
 
365
- // Remove any stale copy + namespace so the fresh load re-registers against
366
- // the current environment (sandbox vs live partner.js differ).
367
534
  const existingScript = document.getElementById("paypal-partner-js")
368
535
  if (existingScript) {
369
536
  existingScript.parentNode?.removeChild(existingScript)
@@ -379,88 +546,76 @@ export default function PayPalConnectionPage() {
379
546
  ppScript.src = scriptUrl
380
547
  ppScript.async = true
381
548
  ppScript.onload = () => initPartner()
382
- ppScript.onerror = () => markReady()
383
549
  document.body.appendChild(ppScript)
384
550
 
385
551
  return () => {
386
552
  cancelled = true
387
- clearTimeout(readyFallback)
388
- if (pollTimer) {
389
- clearTimeout(pollTimer)
390
- }
391
- if (ppScript.parentNode) {
392
- ppScript.parentNode.removeChild(ppScript)
393
- }
553
+ if (pollTimer) clearTimeout(pollTimer)
554
+ if (ppScript.parentNode) ppScript.parentNode.removeChild(ppScript)
394
555
  }
395
556
  }, [connState, finalUrl, env])
396
557
 
397
- // partner.js calls this (by the name in data-paypal-onboard-complete) when the
398
- // seller finishes onboarding, passing the authCode + sharedId to exchange for
399
- // seller credentials.
400
- const completeOnboarding = useCallback(async (authCode: string, sharedId: string) => {
401
- if (!authCode || !sharedId) return
402
- if (completedRef.current) return
403
- completedRef.current = true
404
-
405
- try {
406
- window.onbeforeunload = null
407
- } catch {}
408
-
409
- const activeEnv: Env = envRef.current === "sandbox" ? "sandbox" : "live"
410
-
411
- setOnboardingInProgress(true)
412
- setConnState("loading")
413
- setError(null)
414
-
415
- try {
416
- const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
417
- method: "POST",
418
- headers: { "content-type": "application/json" },
419
- credentials: "include",
420
- body: JSON.stringify({ authCode, sharedId, env: activeEnv }),
421
- })
422
-
423
- if (!res.ok) {
424
- const txt = await res.text().catch(() => "")
425
- throw new Error(txt || `Onboarding exchange failed (${res.status})`)
426
- }
427
-
428
- // Close the partner.js mini-browser.
429
- try {
430
- const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow
431
- if (typeof close1 === "function") close1()
432
- } catch {}
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.
561
+ useEffect(() => {
562
+ const onMessage = (ev: MessageEvent) => {
563
+ let serialized = ""
433
564
  try {
434
- const close2 =
435
- window.PAYPAL?.apps?.Signup?.miniBrowser &&
436
- (window.PAYPAL.apps.Signup.miniBrowser as any).closeFlow
437
- if (typeof close2 === "function") close2()
438
- } catch {}
565
+ serialized =
566
+ typeof ev.data === "string" ? ev.data : JSON.stringify(ev.data)
567
+ } catch {
568
+ serialized = String(ev.data)
569
+ }
439
570
 
440
- // The link has been consumed; drop it so a future flow regenerates one.
441
- clearCachedUrl(activeEnv)
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
+ }
442
584
 
443
- try {
444
- const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${activeEnv}`, {
445
- method: "GET",
446
- credentials: "include",
447
- })
448
- const refreshedStatus = await statusRes.json().catch(() => ({}))
449
- setStatusInfo(refreshedStatus || null)
450
- } catch {}
585
+ // TERTIARY completion path: our own return_url bridge page (served by
586
+ // GET /store/paypal/onboard-return) posts this from inside the popup when
587
+ // PayPal redirects there at the end of onboarding. It runs on our backend
588
+ // origin (not paypal.com), so it is handled before the PayPal-origin gate
589
+ // below. It guarantees the popup is closed and the status is re-checked even
590
+ // if every PayPal postMessage was missed.
591
+ const d = ev.data as any
592
+ if (d && typeof d === "object" && d.source === "paypal-onboarding-return") {
593
+ try {
594
+ popupRef.current?.close()
595
+ } catch {}
596
+ const fromReturn = extractAuth(d.params)
597
+ if (fromReturn.authCode && fromReturn.sharedId) {
598
+ completeOnboarding(fromReturn.authCode, fromReturn.sharedId)
599
+ } else if (!completedRef.current) {
600
+ // No authCode/sharedId on the return URL (the usual case): credentials
601
+ // are exchanged via the postMessage/onboardingCallback path. Poll the
602
+ // server so the UI flips to "connected" as soon as that lands.
603
+ startStatusPoll()
604
+ }
605
+ return
606
+ }
451
607
 
452
- setConnState("connected")
453
- setShowManual(false)
454
- } catch (e: any) {
455
- completedRef.current = false
456
- console.error(e)
457
- setConnState("error")
458
- setError(e?.message || "Exchange failed while saving credentials.")
459
- } finally {
460
- setOnboardingInProgress(false)
608
+ if (!isPayPalOrigin(ev.origin)) return
609
+ const { authCode, sharedId } = extractAuth(ev.data)
610
+ if (authCode && sharedId) {
611
+ completeOnboarding(authCode, sharedId)
612
+ }
461
613
  }
462
- }, [])
614
+ window.addEventListener("message", onMessage)
615
+ return () => window.removeEventListener("message", onMessage)
616
+ }, [completeOnboarding, startStatusPoll])
463
617
 
618
+ // SECONDARY completion path: partner.js's bridge calls this by name.
464
619
  useLayoutEffect(() => {
465
620
  window.onboardingCallback = (authCode: string, sharedId: string) => {
466
621
  completeOnboarding(authCode, sharedId)
@@ -470,6 +625,57 @@ export default function PayPalConnectionPage() {
470
625
  }
471
626
  }, [completeOnboarding])
472
627
 
628
+ // Capture-phase opener: runs before partner.js and opens the popup
629
+ // synchronously inside the user gesture so the mini-browser reliably opens as a
630
+ // popup (never a blocked window or a new tab). We open it under PayPal's
631
+ // conventional window name (POPUP_NAME / "PPFrame"); because partner.js also
632
+ // targets that same name, letting its click handler run next makes it *adopt*
633
+ // this very window instead of opening a second one. That adoption is what lets
634
+ // partner.js receive PayPal's completion postMessage, invoke onboardingCallback
635
+ // and auto-close the popup.
636
+ //
637
+ // IMPORTANT: we only preventDefault() (to stop the native anchor from opening a
638
+ // new tab) and deliberately DO NOT stopImmediatePropagation(): stopping it was
639
+ // what previously prevented partner.js from tracking the window, which broke
640
+ // onboarding completion (the popup never closed and credentials were never
641
+ // exchanged).
642
+ useEffect(() => {
643
+ const onCaptureClick = (e: MouseEvent) => {
644
+ const btn = paypalButtonRef.current
645
+ if (!btn) return
646
+ const target = e.target as Node | null
647
+ if (!target || !btn.contains(target)) return
648
+
649
+ // Stop the native anchor navigation only. Let the event keep propagating so
650
+ // partner.js's own click handler still runs and adopts our popup window.
651
+ e.preventDefault()
652
+
653
+ if (
654
+ connStateRef.current !== "ready" ||
655
+ !finalUrlRef.current ||
656
+ inProgressRef.current
657
+ ) {
658
+ return
659
+ }
660
+
661
+ completedRef.current = false
662
+ const popup = openOnboardingPopup(finalUrlRef.current)
663
+ if (!popup) {
664
+ setPopupBlocked(true)
665
+ return
666
+ }
667
+ setPopupBlocked(false)
668
+ startStatusPoll()
669
+ }
670
+
671
+ document.addEventListener("click", onCaptureClick, true)
672
+ return () => document.removeEventListener("click", onCaptureClick, true)
673
+ }, [openOnboardingPopup, startStatusPoll])
674
+
675
+ useEffect(() => {
676
+ return () => stopPoll()
677
+ }, [stopPoll])
678
+
473
679
  useLayoutEffect(() => {
474
680
  const el = ppBtnMeasureRef.current
475
681
  if (!el) return
@@ -495,6 +701,12 @@ export default function PayPalConnectionPage() {
495
701
  }
496
702
  }, [connState, env, finalUrl])
497
703
 
704
+ // Defensive: the capture listener handles the click; this just guarantees the
705
+ // native href can never navigate.
706
+ const handleConnectClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
707
+ e.preventDefault()
708
+ }
709
+
498
710
  const handleSaveManual = async () => {
499
711
  if (!canSaveManual || onboardingInProgress) return
500
712
  setOnboardingInProgress(true)
@@ -565,8 +777,6 @@ export default function PayPalConnectionPage() {
565
777
  clearCachedUrl(env)
566
778
  completedRef.current = false
567
779
 
568
- // Generate a fresh link for the now-disconnected environment and reload so
569
- // the onboarding button is ready again.
570
780
  currentRunId.current = ++runIdRef.current
571
781
  await generateLinkAndReload(env, currentRunId.current)
572
782
  } catch (e: any) {
@@ -584,10 +794,9 @@ export default function PayPalConnectionPage() {
584
794
 
585
795
  completedRef.current = false
586
796
  clearCachedUrl()
797
+ setPopupBlocked(false)
587
798
  setConnState("loading")
588
799
 
589
- // Persist the environment BEFORE switching local state so the post-reload
590
- // GET /environment returns the same value the cache was keyed under.
591
800
  try {
592
801
  await fetch(ENVIRONMENT_ENDPOINT, {
593
802
  method: "POST",
@@ -676,12 +885,9 @@ export default function PayPalConnectionPage() {
676
885
  </div>
677
886
 
678
887
  <div className={`${connState === "ready" ? "block" : "hidden"}`}>
679
- {/* Standard PayPal partner onboarding button. partner.js binds
680
- this anchor (it carries the cached onboarding href) and
681
- takes over the click into the mini-browser; on completion
682
- it calls window.onboardingCallback. */}
683
888
  <a
684
889
  ref={(node) => {
890
+ paypalButtonRef.current = node
685
891
  ppBtnMeasureRef.current = node
686
892
  }}
687
893
  id="paypal-button"
@@ -689,26 +895,24 @@ export default function PayPalConnectionPage() {
689
895
  data-paypal-button="true"
690
896
  href={finalUrl || "#"}
691
897
  data-paypal-onboard-complete="onboardingCallback"
692
- aria-disabled={onboardingInProgress || !partnerReady}
898
+ onClick={handleConnectClick}
693
899
  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"
694
900
  style={{
695
- cursor:
696
- onboardingInProgress || !partnerReady
697
- ? "not-allowed"
698
- : "pointer",
699
- opacity: onboardingInProgress || !partnerReady ? 0.6 : 1,
700
- // Keep the anchor mounted (partner.js must find it to bind)
701
- // but un-clickable until partner.js has taken over the
702
- // click — so it can never open a tab via the native href.
703
- pointerEvents:
704
- onboardingInProgress || !partnerReady ? "none" : "auto",
901
+ cursor: onboardingInProgress ? "not-allowed" : "pointer",
902
+ opacity: onboardingInProgress ? 0.6 : 1,
903
+ pointerEvents: onboardingInProgress ? "none" : "auto",
705
904
  }}
706
905
  >
707
- {partnerReady || onboardingInProgress
708
- ? "Connect to PayPal"
709
- : "Preparing PayPal…"}
906
+ Connect to PayPal
710
907
  </a>
711
908
 
909
+ {popupBlocked && (
910
+ <div className="mt-3 text-left text-xs bg-amber-50 text-amber-700 p-3 border border-amber-200 rounded">
911
+ Your browser blocked the PayPal popup. Please allow popups
912
+ for this site, then click “Connect to PayPal” again.
913
+ </div>
914
+ )}
915
+
712
916
  <div
713
917
  className="mt-2"
714
918
  style={{