@easypayment/medusa-payment-paypal 0.9.25 → 0.9.27

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 (30) hide show
  1. package/.medusa/server/src/admin/index.js +183 -297
  2. package/.medusa/server/src/admin/index.mjs +183 -297
  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 -368
  6. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js.map +1 -1
  7. package/.medusa/server/src/api/store/paypal/onboard-return/route.d.ts +11 -9
  8. package/.medusa/server/src/api/store/paypal/onboard-return/route.d.ts.map +1 -1
  9. package/.medusa/server/src/api/store/paypal/onboard-return/route.js +28 -9
  10. package/.medusa/server/src/api/store/paypal/onboard-return/route.js.map +1 -1
  11. package/.medusa/server/src/modules/paypal/payment-provider/card-service.d.ts.map +1 -1
  12. package/.medusa/server/src/modules/paypal/payment-provider/card-service.js +67 -55
  13. package/.medusa/server/src/modules/paypal/payment-provider/card-service.js.map +1 -1
  14. package/.medusa/server/src/modules/paypal/payment-provider/service.d.ts.map +1 -1
  15. package/.medusa/server/src/modules/paypal/payment-provider/service.js +15 -5
  16. package/.medusa/server/src/modules/paypal/payment-provider/service.js.map +1 -1
  17. package/.medusa/server/src/modules/paypal/service.d.ts.map +1 -1
  18. package/.medusa/server/src/modules/paypal/service.js +32 -6
  19. package/.medusa/server/src/modules/paypal/service.js.map +1 -1
  20. package/.medusa/server/src/modules/paypal/utils/amounts.d.ts +10 -0
  21. package/.medusa/server/src/modules/paypal/utils/amounts.d.ts.map +1 -1
  22. package/.medusa/server/src/modules/paypal/utils/amounts.js +36 -0
  23. package/.medusa/server/src/modules/paypal/utils/amounts.js.map +1 -1
  24. package/package.json +1 -1
  25. package/src/admin/routes/settings/paypal/connection/page.tsx +230 -389
  26. package/src/api/store/paypal/onboard-return/route.ts +34 -9
  27. package/src/modules/paypal/payment-provider/card-service.ts +85 -65
  28. package/src/modules/paypal/payment-provider/service.ts +22 -7
  29. package/src/modules/paypal/service.ts +34 -7
  30. package/src/modules/paypal/utils/amounts.ts +36 -0
@@ -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.
@@ -1,4 +1,4 @@
1
- import { AbstractPaymentProvider } from "@medusajs/framework/utils"
1
+ import { AbstractPaymentProvider, MedusaError } from "@medusajs/framework/utils"
2
2
  import { randomUUID } from "crypto"
3
3
  import type {
4
4
  AuthorizePaymentInput,
@@ -25,7 +25,7 @@ import type {
25
25
  WebhookActionResult,
26
26
  } from "@medusajs/framework/types"
27
27
  import { getPayPalWebhookActionAndData } from "./webhook-utils"
28
- import { formatAmountForPayPal } from "../utils/amounts"
28
+ import { formatAmountForPayPal, toAmountNumber } from "../utils/amounts"
29
29
  import { paypalFetch } from "../utils/paypal-fetch"
30
30
  import {
31
31
  assertPayPalCurrencySupported,
@@ -727,84 +727,104 @@ class PayPalAdvancedCardProvider extends AbstractPaymentProvider<Options> {
727
727
  const paypalData = (data.paypal || {}) as Record<string, any>
728
728
  const captureId = String(paypalData.capture_id || data.capture_id || "")
729
729
  if (!captureId) {
730
- throw new Error("PayPal capture_id is required to refund payment. No capture found in session data.")
730
+ throw new MedusaError(
731
+ MedusaError.Types.INVALID_DATA,
732
+ "PayPal capture_id is required to refund payment. No capture found in session data."
733
+ )
731
734
  }
732
735
 
733
- const requestId = this.getIdempotencyKey(_input, `refund-${captureId}`)
734
736
  // Use the refund amount Medusa passes (top-level input), not the session
735
737
  // amount in `data` — otherwise a partial refund would refund the full order.
736
- const amount = Number(_input.amount ?? 0)
738
+ // Medusa passes it as a BigNumberInput, so coerce it to a number; a naive
739
+ // Number() of the object form yields NaN and silently refunds the full
740
+ // capture.
741
+ const amount = toAmountNumber(_input.amount)
742
+ const requestId = this.getIdempotencyKey(_input, `refund-${captureId}-${amount}`)
737
743
  const currencyOverride = await this.resolveCurrencyOverride()
738
744
  const currencyCode = normalizeCurrencyCode(
739
745
  data.currency_code || currencyOverride || "EUR"
740
746
  )
741
- const { accessToken, base } = await this.getPayPalAccessToken()
742
- const refundPayload: Record<string, any> =
743
- amount > 0
744
- ? {
745
- amount: {
746
- currency_code: currencyCode,
747
- value: formatAmountForPayPal(amount, currencyCode),
748
- },
749
- }
750
- : {}
751
747
 
752
- const resp = await paypalFetch(`${base}/v2/payments/captures/${encodeURIComponent(captureId)}/refund`, {
753
- method: "POST",
754
- headers: {
755
- Authorization: `Bearer ${accessToken}`,
756
- "Content-Type": "application/json",
757
- "PayPal-Request-Id": requestId,
758
- "PayPal-Partner-Attribution-Id": BN_CODE,
759
- },
760
- body: JSON.stringify(refundPayload),
761
- })
748
+ try {
749
+ const { accessToken, base } = await this.getPayPalAccessToken()
750
+ const refundPayload: Record<string, any> =
751
+ amount > 0
752
+ ? {
753
+ amount: {
754
+ currency_code: currencyCode,
755
+ value: formatAmountForPayPal(amount, currencyCode),
756
+ },
757
+ }
758
+ : {}
762
759
 
763
- const text = await resp.text()
764
- if (!resp.ok) {
765
- const debugId = resp.headers.get("paypal-debug-id")
766
- throw new Error(
767
- `PayPal refund error (${resp.status}): ${text}${
768
- debugId ? ` debug_id=${debugId}` : ""
769
- }`
770
- )
771
- }
760
+ const resp = await paypalFetch(`${base}/v2/payments/captures/${encodeURIComponent(captureId)}/refund`, {
761
+ method: "POST",
762
+ headers: {
763
+ Authorization: `Bearer ${accessToken}`,
764
+ "Content-Type": "application/json",
765
+ "PayPal-Request-Id": requestId,
766
+ "PayPal-Partner-Attribution-Id": BN_CODE,
767
+ },
768
+ body: JSON.stringify(refundPayload),
769
+ })
772
770
 
773
- const refund = JSON.parse(text)
771
+ const text = await resp.text()
772
+ if (!resp.ok) {
773
+ const debugId = resp.headers.get("paypal-debug-id")
774
+ throw new MedusaError(
775
+ MedusaError.Types.UNEXPECTED_STATE,
776
+ `PayPal refund error (${resp.status}): ${text}${
777
+ debugId ? ` debug_id=${debugId}` : ""
778
+ }`
779
+ )
780
+ }
774
781
 
775
- // As with captures, a 2xx response does not guarantee the refund stuck.
776
- // FAILED / CANCELLED / DENIED refunds also return 2xx and must not be
777
- // recorded as a successful refund. PENDING is accepted: PayPal processes
778
- // refunds asynchronously and a pending refund will settle.
779
- const refundStatus = String(refund?.status || "").toUpperCase()
780
- if (isRefundFailureStatus(refundStatus)) {
781
- const refundDebugId = resp.headers.get("paypal-debug-id")
782
- throw new Error(
783
- `PayPal refund did not succeed (status=${refundStatus}). The refund was not issued.` +
784
- (refundDebugId ? ` debug_id=${refundDebugId}` : "")
785
- )
786
- }
782
+ const refund = JSON.parse(text)
783
+
784
+ // As with captures, a 2xx response does not guarantee the refund stuck.
785
+ // FAILED / CANCELLED / DENIED refunds also return 2xx and must not be
786
+ // recorded as a successful refund. PENDING is accepted: PayPal processes
787
+ // refunds asynchronously and a pending refund will settle.
788
+ const refundStatus = String(refund?.status || "").toUpperCase()
789
+ if (isRefundFailureStatus(refundStatus)) {
790
+ const refundDebugId = resp.headers.get("paypal-debug-id")
791
+ throw new MedusaError(
792
+ MedusaError.Types.UNEXPECTED_STATE,
793
+ `PayPal refund did not succeed (status=${refundStatus}). The refund was not issued.` +
794
+ (refundDebugId ? ` debug_id=${refundDebugId}` : "")
795
+ )
796
+ }
787
797
 
788
- const existingRefunds = Array.isArray(paypalData.refunds) ? paypalData.refunds : []
789
- const refundEntry = {
790
- id: refund?.id,
791
- status: refund?.status,
792
- amount: refund?.amount,
793
- raw: refund,
794
- }
798
+ const existingRefunds = Array.isArray(paypalData.refunds) ? paypalData.refunds : []
799
+ const refundEntry = {
800
+ id: refund?.id,
801
+ status: refund?.status,
802
+ amount: refund?.amount,
803
+ raw: refund,
804
+ }
795
805
 
796
- return {
797
- data: {
798
- ...(data || {}),
799
- paypal: {
800
- ...paypalData,
801
- refund_id: refund?.id,
802
- refund_status: refund?.status,
803
- refunds: [...existingRefunds, refundEntry],
804
- refund,
806
+ return {
807
+ data: {
808
+ ...(data || {}),
809
+ paypal: {
810
+ ...paypalData,
811
+ refund_id: refund?.id,
812
+ refund_status: refund?.status,
813
+ refunds: [...existingRefunds, refundEntry],
814
+ refund,
815
+ },
816
+ refunded_at: new Date().toISOString(),
805
817
  },
806
- refunded_at: new Date().toISOString(),
807
- },
818
+ }
819
+ } catch (error: any) {
820
+ // Surface the real reason: Medusa masks any non-MedusaError as a generic
821
+ // "An unknown error occurred." in production, hiding the PayPal failure.
822
+ throw error instanceof MedusaError
823
+ ? error
824
+ : new MedusaError(
825
+ MedusaError.Types.UNEXPECTED_STATE,
826
+ error?.message || "PayPal refund failed."
827
+ )
808
828
  }
809
829
  }
810
830
 
@@ -1,4 +1,4 @@
1
- import { AbstractPaymentProvider } from "@medusajs/framework/utils"
1
+ import { AbstractPaymentProvider, MedusaError } from "@medusajs/framework/utils"
2
2
  import { randomUUID } from "crypto"
3
3
  import type {
4
4
  AuthorizePaymentInput,
@@ -24,7 +24,7 @@ import type {
24
24
  ProviderWebhookPayload,
25
25
  WebhookActionResult,
26
26
  } from "@medusajs/framework/types"
27
- import { formatAmountForPayPal, getCurrencyExponent } from "../utils/amounts"
27
+ import { formatAmountForPayPal, getCurrencyExponent, toAmountNumber } from "../utils/amounts"
28
28
  import {
29
29
  assertPayPalCurrencySupported,
30
30
  normalizeCurrencyCode,
@@ -773,16 +773,22 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
773
773
  paypalData.refund_reason_code || data.refund_reason_code || data.reason_code || ""
774
774
  ).trim()
775
775
  if (!captureId) {
776
- throw new Error(
776
+ throw new MedusaError(
777
+ MedusaError.Types.INVALID_DATA,
777
778
  "PayPal capture_id is required to refund payment. No capture found in session data."
778
779
  )
779
780
  }
780
781
 
782
+ // Medusa passes the refund amount as a BigNumberInput (BigNumber instance or
783
+ // serialized { value, precision }); coerce it to a number so partial refunds
784
+ // send the correct value instead of silently refunding the full capture.
785
+ const refundAmount = toAmountNumber(input.amount)
786
+
781
787
  // Include the amount in the idempotency suffix so two sequential partial
782
788
  // refunds of the same capture are not deduplicated by PayPal into one.
783
789
  const requestId = this.getIdempotencyKey(
784
790
  input,
785
- `refund-${captureId}-${Number(input.amount ?? 0)}`
791
+ `refund-${captureId}-${refundAmount}`
786
792
  )
787
793
 
788
794
  const currencyOverride = await this.resolveCurrencyOverride()
@@ -790,7 +796,6 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
790
796
  data.currency_code || currencyOverride || "EUR"
791
797
  )
792
798
 
793
- const refundAmount = Number(input.amount ?? 0)
794
799
  const refundValue = refundAmount > 0
795
800
  ? formatAmountForPayPal(refundAmount, currencyCode)
796
801
  : null
@@ -810,7 +815,9 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
810
815
  : {}
811
816
 
812
817
  if (refundReason) {
813
- refundPayload.note_to_payer = refundReason
818
+ // PayPal rejects a note_to_payer longer than 255 characters with a 422
819
+ // (which would otherwise fail the whole refund); truncate defensively.
820
+ refundPayload.note_to_payer = refundReason.slice(0, 255)
814
821
  }
815
822
 
816
823
  const ppResp = await paypalFetch(`${base}/v2/payments/captures/${encodeURIComponent(captureId)}/refund`, {
@@ -891,7 +898,15 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
891
898
  debug_id: debugId,
892
899
  message: error?.message,
893
900
  })
894
- throw error
901
+ // Surface the real reason to the admin. Medusa's error handler masks any
902
+ // non-MedusaError as a generic "An unknown error occurred." in production,
903
+ // which hid the actual PayPal failure from the order refund UI.
904
+ throw error instanceof MedusaError
905
+ ? error
906
+ : new MedusaError(
907
+ MedusaError.Types.UNEXPECTED_STATE,
908
+ error?.message || "PayPal refund failed."
909
+ )
895
910
  }
896
911
  }
897
912
 
@@ -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
  )
@@ -36,6 +36,42 @@ export function getCurrencyExponent(currencyCode: string) {
36
36
  return 2
37
37
  }
38
38
 
39
+ /**
40
+ * Coerce a Medusa monetary amount into a plain JS number.
41
+ *
42
+ * Medusa passes amounts to payment providers as a `BigNumberInput`, which can be
43
+ * a plain `number`/`string`, a `BigNumber` instance (exposing a `numeric`
44
+ * getter), or the serialized raw form `{ value, precision }`. A naive
45
+ * `Number(amount)` returns `NaN` for the object forms — which silently turns a
46
+ * partial refund/capture into a full one and produces a `…-NaN` idempotency key.
47
+ */
48
+ export function toAmountNumber(amount: unknown): number {
49
+ if (amount === null || amount === undefined) {
50
+ return 0
51
+ }
52
+ if (typeof amount === "number") {
53
+ return Number.isFinite(amount) ? amount : 0
54
+ }
55
+ if (typeof amount === "string") {
56
+ const parsed = Number(amount)
57
+ return Number.isFinite(parsed) ? parsed : 0
58
+ }
59
+ if (typeof amount === "object") {
60
+ const obj = amount as Record<string, unknown>
61
+ // Medusa BigNumber instance.
62
+ if (typeof obj.numeric === "number" && Number.isFinite(obj.numeric)) {
63
+ return obj.numeric
64
+ }
65
+ // Serialized raw form: { value: "20", precision: 20 }.
66
+ if (obj.value !== undefined && obj.value !== null) {
67
+ const parsed = Number(obj.value)
68
+ return Number.isFinite(parsed) ? parsed : 0
69
+ }
70
+ }
71
+ const fallback = Number(amount as never)
72
+ return Number.isFinite(fallback) ? fallback : 0
73
+ }
74
+
39
75
  /**
40
76
  * Format a Medusa amount for the PayPal REST API.
41
77
  *