@easypayment/medusa-payment-paypal 0.9.25 → 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.
@@ -1,4 +1,5 @@
1
1
  import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
2
+ import type PayPalModuleService from "../../../../modules/paypal/service"
2
3
 
3
4
  /**
4
5
  * Public PayPal onboarding `return_url`.
@@ -9,16 +10,18 @@ import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
9
10
  * than `/admin/*`. Leaving the popup on a raw JSON / 401 / 405 page is exactly
10
11
  * what made the popup appear to "never close".
11
12
  *
12
- * This route serves a tiny HTML bridge that runs inside the popup and:
13
- * 1. relays whatever query params PayPal appended back to the opener
14
- * (the Medusa Admin connection page) via postMessage, and
15
- * 2. closes the popup.
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.
16
21
  *
17
- * The actual credential exchange happens via the admin POST
18
- * /admin/paypal/onboard-complete (driven by partner.js's onboardingCallback and
19
- * the admin page's postMessage listener). This page is the safety net that
20
- * guarantees the popup closes and the admin page re-checks status even if the
21
- * in-flight PayPal completion message was missed.
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.
22
25
  */
23
26
  export async function GET(req: MedusaRequest, res: MedusaResponse) {
24
27
  const query = (req.query || {}) as Record<string, unknown>
@@ -28,6 +31,28 @@ export async function GET(req: MedusaRequest, res: MedusaResponse) {
28
31
  params[k] = Array.isArray(v) ? String(v[0]) : String(v)
29
32
  }
30
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
+
31
56
  // JSON-encode then escape the characters that could break out of the inline
32
57
  // <script> string/tag (including the U+2028/U+2029 separators that are legal in
33
58
  // JSON but illegal in a JS string literal), so the relayed params embed safely.
@@ -495,17 +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
- // The popup lands here after onboarding, as a plain top-level navigation with
499
- // no auth token — so it must be the PUBLIC store bridge route, not an
500
- // /admin/* route (which would 401 and leave the popup stuck). The bridge
501
- // relays the result to the opener and closes the popup.
502
- const return_url = `${String(onboarding.backend_url || "").replace(/\/$/, "")}/store/paypal/onboard-return`
503
498
  // Honor an explicit environment from the caller so the generated link can't
504
499
  // depend on a racing "set environment" request having landed first.
505
500
  const env =
506
501
  input?.env === "sandbox" || input?.env === "live"
507
502
  ? input.env
508
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}`
509
512
  const partner_merchant_id = await this.getPartnerMerchantId(env)
510
513
 
511
514
  const email = (input?.email || "").trim()
@@ -653,9 +656,23 @@ class PayPalModuleService extends MedusaService({
653
656
  sharedId: string
654
657
  env?: "sandbox" | "live"
655
658
  }) {
656
- await this.saveOnboardCallback({ authCode: input.authCode, sharedId: input.sharedId })
657
-
658
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 })
659
676
  const baseUrl = env === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"
660
677
 
661
678
  const { onboarding } = await this.ensureSettingsDefaults()
@@ -690,6 +707,16 @@ class PayPalModuleService extends MedusaService({
690
707
  }
691
708
 
692
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
+ }
693
720
  throw new Error(
694
721
  `PayPal authorization_code token exchange failed (${tokenRes.status}): ${tokenText || JSON.stringify(tokenJson)}`
695
722
  )