@easypayment/medusa-payment-paypal 0.9.24 → 0.9.26

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 (22) hide show
  1. package/.medusa/server/src/admin/index.js +187 -287
  2. package/.medusa/server/src/admin/index.mjs +187 -287
  3. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts +0 -1
  4. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts.map +1 -1
  5. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js +204 -332
  6. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js.map +1 -1
  7. package/.medusa/server/src/api/admin/paypal/onboard-complete/route.d.ts +7 -0
  8. package/.medusa/server/src/api/admin/paypal/onboard-complete/route.d.ts.map +1 -1
  9. package/.medusa/server/src/api/admin/paypal/onboard-complete/route.js +7 -0
  10. package/.medusa/server/src/api/admin/paypal/onboard-complete/route.js.map +1 -1
  11. package/.medusa/server/src/api/store/paypal/onboard-return/route.d.ts +25 -0
  12. package/.medusa/server/src/api/store/paypal/onboard-return/route.d.ts.map +1 -0
  13. package/.medusa/server/src/api/store/paypal/onboard-return/route.js +82 -0
  14. package/.medusa/server/src/api/store/paypal/onboard-return/route.js.map +1 -0
  15. package/.medusa/server/src/modules/paypal/service.d.ts.map +1 -1
  16. package/.medusa/server/src/modules/paypal/service.js +32 -2
  17. package/.medusa/server/src/modules/paypal/service.js.map +1 -1
  18. package/package.json +1 -1
  19. package/src/admin/routes/settings/paypal/connection/page.tsx +230 -354
  20. package/src/api/admin/paypal/onboard-complete/route.ts +7 -0
  21. package/src/api/store/paypal/onboard-return/route.ts +88 -0
  22. package/src/modules/paypal/service.ts +34 -3
@@ -1,6 +1,13 @@
1
1
  import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
2
2
  import type PayPalModuleService from "../../../../modules/paypal/service"
3
3
 
4
+ /**
5
+ * The PayPal onboarding `return_url` (the page the mini-browser popup lands on)
6
+ * is the PUBLIC route `GET /store/paypal/onboard-return` — `/admin/*` routes
7
+ * require an auth token that a top-level popup navigation cannot carry, which
8
+ * would leave the popup stuck on a 401 page. This admin GET is therefore not part
9
+ * of the browser flow; it only documents the contract for the POST below.
10
+ */
4
11
  export async function GET(_req: MedusaRequest, res: MedusaResponse) {
5
12
  return res.status(405).json({
6
13
  message:
@@ -0,0 +1,88 @@
1
+ import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
2
+ import type PayPalModuleService from "../../../../modules/paypal/service"
3
+
4
+ /**
5
+ * Public PayPal onboarding `return_url`.
6
+ *
7
+ * When a seller finishes onboarding, PayPal redirects the mini-browser popup to
8
+ * this URL as a plain top-level navigation (no auth header / no publishable key),
9
+ * which is why it must live under the public `/store/paypal/*` namespace rather
10
+ * than `/admin/*`. Leaving the popup on a raw JSON / 401 / 405 page is exactly
11
+ * what made the popup appear to "never close".
12
+ *
13
+ * This route is the authoritative, partner.js-independent completion path. It:
14
+ * 1. exchanges PayPal's `authCode` + `sharedId` for seller credentials
15
+ * server-side (idempotent — safe even if partner.js's onboardingCallback or
16
+ * admin status polling also completes the exchange),
17
+ * 2. relays whatever query params PayPal appended back to the opener (the
18
+ * Medusa Admin connection page) via postMessage so it can refresh
19
+ * immediately, and
20
+ * 3. closes the popup.
21
+ *
22
+ * Because the exchange no longer depends on the opener receiving an in-flight
23
+ * postMessage, onboarding completes (and the admin page flips to "connected" via
24
+ * its status poll) even when partner.js never fires its callback.
25
+ */
26
+ export async function GET(req: MedusaRequest, res: MedusaResponse) {
27
+ const query = (req.query || {}) as Record<string, unknown>
28
+ const params: Record<string, string> = {}
29
+ for (const [k, v] of Object.entries(query)) {
30
+ if (v == null) continue
31
+ params[k] = Array.isArray(v) ? String(v[0]) : String(v)
32
+ }
33
+
34
+ // PayPal appends the seller's authorization code + shared id to the return_url
35
+ // (plus the env we pinned when generating the link). When present, complete the
36
+ // exchange right here so credentials are saved even if no other path runs.
37
+ const authCode = params.authCode || params.auth_code
38
+ const sharedId = params.sharedId || params.shared_id
39
+ const env =
40
+ params.env === "sandbox" || params.env === "live" ? params.env : undefined
41
+
42
+ if (authCode && sharedId) {
43
+ try {
44
+ const paypal = req.scope.resolve<PayPalModuleService>("paypal_onboarding")
45
+ await paypal.exchangeAndSaveSellerCredentials({ authCode, sharedId, env })
46
+ } catch (e: unknown) {
47
+ // Don't break the popup close on failure — relay the params so the opener
48
+ // (partner.js / admin polling) can still attempt to complete.
49
+ console.error(
50
+ "[PayPal] onboard-return exchange failed:",
51
+ e instanceof Error ? e.message : e
52
+ )
53
+ }
54
+ }
55
+
56
+ // JSON-encode then escape the characters that could break out of the inline
57
+ // <script> string/tag (including the U+2028/U+2029 separators that are legal in
58
+ // JSON but illegal in a JS string literal), so the relayed params embed safely.
59
+ const safePayload = JSON.stringify(params)
60
+ .replace(/</g, "\\u003c")
61
+ .replace(/>/g, "\\u003e")
62
+ .replace(/&/g, "\\u0026")
63
+ .replace(/[\u2028\u2029]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"))
64
+
65
+ const html = `<!doctype html>
66
+ <html lang="en">
67
+ <head><meta charset="utf-8" /><title>PayPal onboarding complete</title></head>
68
+ <body style="font-family: system-ui, sans-serif; padding: 24px; text-align: center; color: #1f2937;">
69
+ <p>PayPal onboarding complete. You can close this window.</p>
70
+ <script>
71
+ (function () {
72
+ try {
73
+ var message = { source: "paypal-onboarding-return", params: ${safePayload} };
74
+ if (window.opener && !window.opener.closed) {
75
+ try { window.opener.postMessage(message, "*"); } catch (e) {}
76
+ }
77
+ } catch (e) {}
78
+ // Give the opener a moment to react, then close the popup.
79
+ setTimeout(function () { try { window.close(); } catch (e) {} }, 600);
80
+ })();
81
+ </script>
82
+ </body>
83
+ </html>`
84
+
85
+ res.setHeader("Content-Type", "text/html; charset=utf-8")
86
+ res.setHeader("Cache-Control", "no-store")
87
+ return res.status(200).send(html)
88
+ }
@@ -495,13 +495,20 @@ class PayPalModuleService extends MedusaService({
495
495
 
496
496
  async createOnboardingLink(input?: { email?: string; products?: string[]; env?: Environment }) {
497
497
  const { onboarding } = await this.ensureSettingsDefaults()
498
- const return_url = `${String(onboarding.backend_url || "").replace(/\/$/, "")}/admin/paypal/onboard-complete`
499
498
  // Honor an explicit environment from the caller so the generated link can't
500
499
  // depend on a racing "set environment" request having landed first.
501
500
  const env =
502
501
  input?.env === "sandbox" || input?.env === "live"
503
502
  ? input.env
504
503
  : await this.getCurrentEnvironment()
504
+ // The popup lands here after onboarding, as a plain top-level navigation with
505
+ // no auth token — so it must be the PUBLIC store bridge route, not an
506
+ // /admin/* route (which would 401 and leave the popup stuck). The bridge
507
+ // exchanges the auth code for seller credentials server-side, relays the
508
+ // result to the opener, and closes the popup. We pin the environment in the
509
+ // URL so the bridge saves credentials under the correct env even though
510
+ // PayPal's redirect carries no auth/session.
511
+ const return_url = `${String(onboarding.backend_url || "").replace(/\/$/, "")}/store/paypal/onboard-return?env=${env}`
505
512
  const partner_merchant_id = await this.getPartnerMerchantId(env)
506
513
 
507
514
  const email = (input?.email || "").trim()
@@ -649,9 +656,23 @@ class PayPalModuleService extends MedusaService({
649
656
  sharedId: string
650
657
  env?: "sandbox" | "live"
651
658
  }) {
652
- await this.saveOnboardCallback({ authCode: input.authCode, sharedId: input.sharedId })
653
-
654
659
  const env = (input.env || (await this.getCurrentEnvironment())) as Environment
660
+
661
+ // Onboarding completion can now arrive via several redundant paths
662
+ // (partner.js's onboardingCallback in the opener, the return_url bridge that
663
+ // runs inside the popup, and admin status polling). PayPal's authorization
664
+ // code is single-use, so a second exchange of the same code would fail with
665
+ // invalid_grant. If we already exchanged this exact code and hold seller
666
+ // credentials for this environment, treat the duplicate as a success no-op.
667
+ const existingRow = await this.getCurrentRow()
668
+ if (existingRow && existingRow.auth_code === input.authCode) {
669
+ const existingCreds = this.getEnvCreds(existingRow, env)
670
+ if (existingCreds.clientId && existingCreds.clientSecret) {
671
+ return
672
+ }
673
+ }
674
+
675
+ await this.saveOnboardCallback({ authCode: input.authCode, sharedId: input.sharedId })
655
676
  const baseUrl = env === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"
656
677
 
657
678
  const { onboarding } = await this.ensureSettingsDefaults()
@@ -686,6 +707,16 @@ class PayPalModuleService extends MedusaService({
686
707
  }
687
708
 
688
709
  if (!tokenRes.ok) {
710
+ // Two redundant completion paths can race to exchange the same single-use
711
+ // code; the loser gets invalid_grant. If credentials were saved in the
712
+ // meantime (the other path won), the onboarding actually succeeded.
713
+ const racedRow = await this.getCurrentRow()
714
+ if (racedRow) {
715
+ const racedCreds = this.getEnvCreds(racedRow, env)
716
+ if (racedCreds.clientId && racedCreds.clientSecret) {
717
+ return
718
+ }
719
+ }
689
720
  throw new Error(
690
721
  `PayPal authorization_code token exchange failed (${tokenRes.status}): ${tokenText || JSON.stringify(tokenJson)}`
691
722
  )