@easypayment/medusa-payment-paypal 0.9.16 → 0.9.18

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 (53) hide show
  1. package/.medusa/server/src/admin/index.js +59 -32
  2. package/.medusa/server/src/admin/index.mjs +59 -32
  3. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts +4 -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 +64 -2
  6. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js.map +1 -1
  7. package/.medusa/server/src/api/middlewares.d.ts.map +1 -1
  8. package/.medusa/server/src/api/middlewares.js +9 -0
  9. package/.medusa/server/src/api/middlewares.js.map +1 -1
  10. package/.medusa/server/src/api/store/paypal/capture-order/route.d.ts.map +1 -1
  11. package/.medusa/server/src/api/store/paypal/capture-order/route.js +8 -58
  12. package/.medusa/server/src/api/store/paypal/capture-order/route.js.map +1 -1
  13. package/.medusa/server/src/api/store/paypal/create-order/route.d.ts.map +1 -1
  14. package/.medusa/server/src/api/store/paypal/create-order/route.js +17 -43
  15. package/.medusa/server/src/api/store/paypal/create-order/route.js.map +1 -1
  16. package/.medusa/server/src/api/store/paypal/webhook/route.d.ts.map +1 -1
  17. package/.medusa/server/src/api/store/paypal/webhook/route.js +13 -8
  18. package/.medusa/server/src/api/store/paypal/webhook/route.js.map +1 -1
  19. package/.medusa/server/src/modules/paypal/payment-provider/service.d.ts.map +1 -1
  20. package/.medusa/server/src/modules/paypal/payment-provider/service.js +54 -31
  21. package/.medusa/server/src/modules/paypal/payment-provider/service.js.map +1 -1
  22. package/.medusa/server/src/modules/paypal/payment-provider/status-utils.d.ts +32 -0
  23. package/.medusa/server/src/modules/paypal/payment-provider/status-utils.d.ts.map +1 -0
  24. package/.medusa/server/src/modules/paypal/payment-provider/status-utils.js +65 -0
  25. package/.medusa/server/src/modules/paypal/payment-provider/status-utils.js.map +1 -0
  26. package/.medusa/server/src/modules/paypal/service.d.ts +12 -1
  27. package/.medusa/server/src/modules/paypal/service.d.ts.map +1 -1
  28. package/.medusa/server/src/modules/paypal/service.js +14 -3
  29. package/.medusa/server/src/modules/paypal/service.js.map +1 -1
  30. package/.medusa/server/src/modules/paypal/utils/payment-session.d.ts +35 -0
  31. package/.medusa/server/src/modules/paypal/utils/payment-session.d.ts.map +1 -0
  32. package/.medusa/server/src/modules/paypal/utils/payment-session.js +78 -0
  33. package/.medusa/server/src/modules/paypal/utils/payment-session.js.map +1 -0
  34. package/.medusa/server/src/modules/paypal/utils/secret-crypto.d.ts +2 -1
  35. package/.medusa/server/src/modules/paypal/utils/secret-crypto.d.ts.map +1 -1
  36. package/.medusa/server/src/modules/paypal/utils/secret-crypto.js +82 -26
  37. package/.medusa/server/src/modules/paypal/utils/secret-crypto.js.map +1 -1
  38. package/.medusa/server/src/modules/paypal/utils/webhook-verify.d.ts +28 -0
  39. package/.medusa/server/src/modules/paypal/utils/webhook-verify.d.ts.map +1 -0
  40. package/.medusa/server/src/modules/paypal/utils/webhook-verify.js +58 -0
  41. package/.medusa/server/src/modules/paypal/utils/webhook-verify.js.map +1 -0
  42. package/package.json +1 -1
  43. package/src/admin/routes/settings/paypal/connection/page.tsx +68 -12
  44. package/src/api/middlewares.ts +9 -0
  45. package/src/api/store/paypal/capture-order/route.ts +8 -71
  46. package/src/api/store/paypal/create-order/route.ts +25 -47
  47. package/src/api/store/paypal/webhook/route.ts +22 -8
  48. package/src/modules/paypal/payment-provider/service.ts +73 -29
  49. package/src/modules/paypal/payment-provider/status-utils.ts +59 -0
  50. package/src/modules/paypal/service.ts +17 -3
  51. package/src/modules/paypal/utils/payment-session.ts +104 -0
  52. package/src/modules/paypal/utils/secret-crypto.ts +90 -27
  53. package/src/modules/paypal/utils/webhook-verify.ts +61 -0
@@ -10,7 +10,11 @@ import {
10
10
  import { getPayPalApiBase } from "../../../../modules/paypal/utils/paypal-auth"
11
11
  import { paypalFetch } from "../../../../modules/paypal/utils/paypal-fetch"
12
12
  import type PayPalModuleService from "../../../../modules/paypal/service"
13
- import { isPayPalProviderId } from "../../../../modules/paypal/utils/provider-ids"
13
+ import {
14
+ findPayPalSessionForCart,
15
+ getStoredPayPalOrderId,
16
+ updatePayPalSessionData,
17
+ } from "../../../../modules/paypal/utils/payment-session"
14
18
 
15
19
  const BN_CODE = "MBJTechnolabs_SI_SPB"
16
20
 
@@ -32,61 +36,35 @@ function resolveIdempotencyKey(req: MedusaRequest, suffix: string, fallback: str
32
36
  return fallback || `pp-${suffix}-${randomUUID()}`
33
37
  }
34
38
 
39
+ // Persist the PayPal order id onto the cart's PayPal payment session so that
40
+ // capture-order can bind the capture to the order this cart created. Resolved
41
+ // through the Payment module service (the previous direct-container resolve of
42
+ // "payment_collection"/"payment_session" did not exist in the request scope, so
43
+ // the id was silently never stored and every capture failed fail-closed).
35
44
  async function attachPayPalOrderToSession(
36
45
  req: MedusaRequest,
37
46
  cartId: string,
38
47
  orderId: string
39
48
  ) {
40
- try {
41
- const paymentCollectionService = req.scope.resolve("payment_collection") as any
42
- const paymentSessionService = req.scope.resolve("payment_session") as any
43
-
44
- const pc = await paymentCollectionService.retrieveByCartId(cartId).catch(() => null)
45
- if (!pc?.id) {
46
- return
47
- }
48
-
49
- const sessions = await paymentSessionService.list({ payment_collection_id: pc.id })
50
- const paypalSession = sessions?.find((s: any) => isPayPalProviderId(s.provider_id))
51
- if (!paypalSession) {
52
- return
53
- }
54
-
55
- await paymentSessionService.update(paypalSession.id, {
56
- amount: paypalSession.amount,
57
- data: {
58
- ...(paypalSession.data || {}),
59
- paypal: {
60
- ...((paypalSession.data || {}).paypal || {}),
61
- order_id: orderId,
62
- },
63
- },
64
- })
65
- } catch {
49
+ const session = await findPayPalSessionForCart(cartId, req.scope)
50
+ if (!session) {
51
+ return
66
52
  }
53
+ await updatePayPalSessionData(
54
+ session.session_id,
55
+ {
56
+ paypal: {
57
+ ...(session.session_data.paypal || {}),
58
+ order_id: orderId,
59
+ },
60
+ },
61
+ req.scope
62
+ )
67
63
  }
68
64
 
69
65
  async function getExistingPayPalOrderId(req: MedusaRequest, cartId: string) {
70
- try {
71
- const paymentCollectionService = req.scope.resolve("payment_collection") as any
72
- const paymentSessionService = req.scope.resolve("payment_session") as any
73
-
74
- const pc = await paymentCollectionService.retrieveByCartId(cartId).catch(() => null)
75
- if (!pc?.id) {
76
- return null
77
- }
78
-
79
- const sessions = await paymentSessionService.list({ payment_collection_id: pc.id })
80
- const paypalSession = sessions?.find((s: any) => isPayPalProviderId(s.provider_id))
81
- if (!paypalSession) {
82
- return null
83
- }
84
-
85
- const paypalData = (paypalSession.data || {}).paypal || {}
86
- return paypalData.order_id ? String(paypalData.order_id) : null
87
- } catch {
88
- return null
89
- }
66
+ const session = await findPayPalSessionForCart(cartId, req.scope)
67
+ return getStoredPayPalOrderId(session?.session_data)
90
68
  }
91
69
 
92
70
  function resolveReturnUrl(req: MedusaRequest) {
@@ -8,6 +8,11 @@ import {
8
8
  normalizeEventVersion,
9
9
  processPayPalWebhookEvent,
10
10
  } from "../../../../modules/paypal/webhook-processor"
11
+ import {
12
+ composeVerifyRequestBody,
13
+ rawBodyToString,
14
+ resolveWebhookEventJson,
15
+ } from "../../../../modules/paypal/utils/webhook-verify"
11
16
 
12
17
  const REPLAY_WINDOW_MINUTES = (() => {
13
18
  const v = Number(process.env.PAYPAL_WEBHOOK_REPLAY_WINDOW_MINUTES)
@@ -110,7 +115,8 @@ async function verifyWebhookSignature(
110
115
  paypal: PayPalModuleService,
111
116
  environment: string,
112
117
  body: Record<string, unknown>,
113
- headers: Record<string, string | string[] | undefined>
118
+ headers: Record<string, string | string[] | undefined>,
119
+ rawBody: string | null
114
120
  ): Promise<void> {
115
121
  const settings = await paypal.getSettings().catch(() => ({ data: {} }))
116
122
  const webhookId = resolveWebhookId(
@@ -131,17 +137,16 @@ async function verifyWebhookSignature(
131
137
 
132
138
  const accessToken = await paypal.getAppAccessToken()
133
139
 
134
- const verifyPayload = {
140
+ const verifyFields = {
135
141
  auth_algo: getHeader(headers, "paypal-auth-algo"),
136
142
  cert_url: getHeader(headers, "paypal-cert-url"),
137
143
  transmission_id: getHeader(headers, "paypal-transmission-id"),
138
144
  transmission_sig: getHeader(headers, "paypal-transmission-sig"),
139
145
  transmission_time: getHeader(headers, "paypal-transmission-time"),
140
146
  webhook_id: webhookId,
141
- webhook_event: body,
142
147
  }
143
148
 
144
- const certUrl = verifyPayload.cert_url
149
+ const certUrl = verifyFields.cert_url
145
150
  if (
146
151
  certUrl &&
147
152
  !certUrl.startsWith("https://www.paypal.com/") &&
@@ -151,21 +156,29 @@ async function verifyWebhookSignature(
151
156
  throw new Error("Invalid paypal-cert-url: must originate from paypal.com")
152
157
  }
153
158
 
154
- const missing = Object.entries(verifyPayload)
155
- .filter(([k, v]) => k !== "webhook_id" && k !== "webhook_event" && !v)
159
+ const missing = Object.entries(verifyFields)
160
+ .filter(([k, v]) => k !== "webhook_id" && !v)
156
161
  .map(([k]) => k)
157
162
 
158
163
  if (missing.length > 0) {
159
164
  throw new Error(`Missing required PayPal webhook headers: ${missing.join(", ")}`)
160
165
  }
161
166
 
167
+ // Use the raw bytes PayPal signed for `webhook_event` whenever available
168
+ // (preserved by the route's `bodyParser: { preserveRawBody: true }`); fall
169
+ // back to serializing the parsed body so verification still runs if it isn't.
170
+ const requestBody = composeVerifyRequestBody(
171
+ verifyFields,
172
+ resolveWebhookEventJson(rawBody, body)
173
+ )
174
+
162
175
  const resp = await paypalFetch(`${base}/v1/notifications/verify-webhook-signature`, {
163
176
  method: "POST",
164
177
  headers: {
165
178
  Authorization: `Bearer ${accessToken}`,
166
179
  "Content-Type": "application/json",
167
180
  },
168
- body: JSON.stringify(verifyPayload),
181
+ body: requestBody,
169
182
  })
170
183
 
171
184
  const json = await resp.json().catch(() => ({}))
@@ -214,7 +227,8 @@ export async function POST(req: MedusaRequest, res: MedusaResponse) {
214
227
 
215
228
  try {
216
229
  const creds = await paypal.getActiveCredentials()
217
- await verifyWebhookSignature(paypal, creds.environment, payload, req.headers)
230
+ const rawBody = rawBodyToString((req as unknown as { rawBody?: unknown }).rawBody)
231
+ await verifyWebhookSignature(paypal, creds.environment, payload, req.headers, rawBody)
218
232
  } catch (e: any) {
219
233
  console.error("[PayPal] webhook: signature verification failed:", e?.message)
220
234
  return res
@@ -32,6 +32,12 @@ import {
32
32
  import type PayPalModuleService from "../service"
33
33
  import { getPayPalWebhookActionAndData } from "./webhook-utils"
34
34
  import { paypalFetch } from "../utils/paypal-fetch"
35
+ import {
36
+ extractCaptureStatus,
37
+ isCaptureCompleted,
38
+ isRefundFailureStatus,
39
+ mapPayPalCaptureStatus,
40
+ } from "./status-utils"
35
41
 
36
42
  type Options = {}
37
43
 
@@ -154,13 +160,7 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
154
160
  }
155
161
 
156
162
  private mapCaptureStatus(status?: string) {
157
- const normalized = String(status || "").toUpperCase()
158
- if (!normalized) return null
159
- if (normalized === "COMPLETED") return "captured"
160
- if (normalized === "PENDING") return "pending"
161
- if (["DENIED", "DECLINED", "FAILED"].includes(normalized)) return "error"
162
- if (["REFUNDED", "PARTIALLY_REFUNDED", "REVERSED"].includes(normalized)) return "canceled"
163
- return null
163
+ return mapPayPalCaptureStatus(status)
164
164
  }
165
165
 
166
166
  private mapAuthorizationStatus(status?: string) {
@@ -587,15 +587,19 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
587
587
  }
588
588
  }
589
589
 
590
- const requestId = this.getIdempotencyKey(input, `capture-${orderId}`)
591
590
  const { amount, currencyCode } = await this.normalizePaymentData(input)
591
+ // Include the amount in the idempotency suffix: PayPal deduplicates by
592
+ // PayPal-Request-Id, so two sequential partial captures of the same order
593
+ // that share an upstream idempotency_key would otherwise collide and the
594
+ // second capture would silently return the first one's result.
595
+ const requestId = this.getIdempotencyKey(input, `capture-${orderId}-${amount}`)
592
596
  let debugId: string | null = null
593
597
 
594
598
  try {
595
599
  const { accessToken, base } = await this.getPayPalAccessToken()
596
600
  const order = await this.getOrderDetails(orderId).catch(() => null)
597
601
  const existingCapture = order?.purchase_units?.[0]?.payments?.captures?.[0]
598
- if (existingCapture?.id) {
602
+ if (existingCapture?.id && isCaptureCompleted(existingCapture)) {
599
603
  return {
600
604
  data: {
601
605
  ...(input.data || {}),
@@ -648,26 +652,35 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
648
652
  const captureValue = amount > 0
649
653
  ? formatAmountForPayPal(amount, currencyCode || "EUR")
650
654
  : null
651
- const capturePayload =
652
- captureValue
653
- ? {
654
- amount: {
655
- currency_code: currencyCode || "EUR",
656
- value: captureValue,
657
- },
658
- ...(typeof isFinalCapture === "boolean"
659
- ? { is_final_capture: isFinalCapture }
660
- : {}),
661
- }
662
- : {
663
- ...(typeof isFinalCapture === "boolean"
664
- ? { is_final_capture: isFinalCapture }
665
- : {}),
666
- }
667
655
 
668
- const captureUrl = authorizationId
669
- ? `${base}/v2/payments/authorizations/${encodeURIComponent(authorizationId)}/capture`
670
- : `${base}/v2/checkout/orders/${encodeURIComponent(orderId)}/capture`
656
+ // `amount` and `is_final_capture` are only valid on the authorizations
657
+ // capture endpoint. The orders capture endpoint always captures the FULL
658
+ // order and silently ignores an `amount` body — so sending a partial
659
+ // amount there would over-capture while we record the (smaller) requested
660
+ // value. Route partial captures to the authorization, and fail closed if a
661
+ // partial capture is attempted against a capture-intent order.
662
+ let capturePayload: Record<string, unknown>
663
+ let captureUrl: string
664
+ if (authorizationId) {
665
+ captureUrl = `${base}/v2/payments/authorizations/${encodeURIComponent(authorizationId)}/capture`
666
+ capturePayload = {
667
+ ...(captureValue
668
+ ? { amount: { currency_code: currencyCode || "EUR", value: captureValue } }
669
+ : {}),
670
+ ...(typeof isFinalCapture === "boolean" ? { is_final_capture: isFinalCapture } : {}),
671
+ }
672
+ } else {
673
+ captureUrl = `${base}/v2/checkout/orders/${encodeURIComponent(orderId)}/capture`
674
+ capturePayload = {}
675
+ const orderTotal = order?.purchase_units?.[0]?.amount?.value
676
+ if (captureValue && orderTotal && captureValue !== String(orderTotal)) {
677
+ throw new Error(
678
+ `PayPal partial capture (${captureValue} ${currencyCode || "EUR"}) is not supported for ` +
679
+ `capture-intent orders (order total ${orderTotal}). Create the order with intent ` +
680
+ `AUTHORIZE to capture a partial amount.`
681
+ )
682
+ }
683
+ }
671
684
 
672
685
  const ppResp = await paypalFetch(captureUrl, {
673
686
  method: "POST",
@@ -691,6 +704,19 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
691
704
  }
692
705
 
693
706
  const capture = JSON.parse(ppText)
707
+
708
+ // A 2xx response does NOT mean the funds were captured. PayPal returns 201
709
+ // for captures that are PENDING (e.g. pending review / eCheck), DECLINED,
710
+ // or FAILED. Recording any of these as "captured" books money that never
711
+ // settled, so only a COMPLETED capture is treated as success.
712
+ const captureStatus = extractCaptureStatus(capture)
713
+ if (captureStatus !== "COMPLETED") {
714
+ throw new Error(
715
+ `PayPal capture did not complete (status=${captureStatus || "UNKNOWN"}). ` +
716
+ `The payment was not captured.${debugId ? ` debug_id=${debugId}` : ""}`
717
+ )
718
+ }
719
+
694
720
  const captureId =
695
721
  capture?.id || capture?.purchase_units?.[0]?.payments?.captures?.[0]?.id
696
722
  const existingCaptures = Array.isArray(paypalData.captures) ? paypalData.captures : []
@@ -752,7 +778,12 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
752
778
  )
753
779
  }
754
780
 
755
- const requestId = this.getIdempotencyKey(input, `refund-${captureId}`)
781
+ // Include the amount in the idempotency suffix so two sequential partial
782
+ // refunds of the same capture are not deduplicated by PayPal into one.
783
+ const requestId = this.getIdempotencyKey(
784
+ input,
785
+ `refund-${captureId}-${Number(input.amount ?? 0)}`
786
+ )
756
787
 
757
788
  const currencyOverride = await this.resolveCurrencyOverride()
758
789
  const currencyCode = normalizeCurrencyCode(
@@ -804,6 +835,19 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
804
835
  }
805
836
 
806
837
  const refund = JSON.parse(ppText)
838
+
839
+ // As with captures, a 2xx response does not guarantee the refund stuck.
840
+ // FAILED / CANCELLED / DENIED refunds also return 2xx and must not be
841
+ // recorded as a successful refund. PENDING is accepted: PayPal processes
842
+ // refunds asynchronously and a pending refund will settle.
843
+ const refundStatus = String(refund?.status || "").toUpperCase()
844
+ if (isRefundFailureStatus(refundStatus)) {
845
+ throw new Error(
846
+ `PayPal refund did not succeed (status=${refundStatus}). The refund was not issued.` +
847
+ (debugId ? ` debug_id=${debugId}` : "")
848
+ )
849
+ }
850
+
807
851
  const existingRefunds = Array.isArray(paypalData.refunds) ? paypalData.refunds : []
808
852
  const refundEntry = {
809
853
  id: refund?.id,
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Pure mapping/validation helpers for PayPal capture & refund resource statuses.
3
+ *
4
+ * Kept free of any container/IO dependencies so the money-flow decisions (what
5
+ * counts as a settled capture, when a refund must be rejected, how a PayPal
6
+ * status maps onto a Medusa payment status) can be unit-tested in isolation.
7
+ */
8
+
9
+ /** Map a PayPal capture status onto a Medusa payment status. */
10
+ export function mapPayPalCaptureStatus(
11
+ status?: string
12
+ ): "captured" | "pending" | "error" | "canceled" | null {
13
+ const normalized = String(status || "").toUpperCase()
14
+ if (!normalized) return null
15
+ if (normalized === "COMPLETED") return "captured"
16
+ if (normalized === "PENDING") return "pending"
17
+ if (["DENIED", "DECLINED", "FAILED"].includes(normalized)) return "error"
18
+ // A partially-refunded capture is still a captured payment — only a portion of
19
+ // the funds was returned. Mapping it to "canceled" would wrongly unwind a live
20
+ // capture, so it must stay "captured". A full refund/reversal does unwind it.
21
+ if (normalized === "PARTIALLY_REFUNDED") return "captured"
22
+ if (["REFUNDED", "REVERSED"].includes(normalized)) return "canceled"
23
+ return null
24
+ }
25
+
26
+ /**
27
+ * Effective status of a PayPal capture resource.
28
+ *
29
+ * The orders-capture response nests the capture under
30
+ * `purchase_units[].payments.captures[]` — the order's own top-level status can
31
+ * read COMPLETED while the capture is still PENDING — whereas the
32
+ * authorizations-capture response is the capture object directly. Prefer the
33
+ * nested capture status, falling back to the top level.
34
+ */
35
+ export function extractCaptureStatus(resource: any): string {
36
+ if (!resource || typeof resource !== "object") return ""
37
+ const nested = resource?.purchase_units?.[0]?.payments?.captures?.[0]?.status
38
+ return String(nested ?? resource.status ?? "").toUpperCase()
39
+ }
40
+
41
+ /**
42
+ * True only when a capture resource is COMPLETED. A 2xx HTTP response does NOT
43
+ * imply this: PayPal returns 201 for PENDING (pending review / eCheck),
44
+ * DECLINED, and FAILED captures too.
45
+ */
46
+ export function isCaptureCompleted(resource: any): boolean {
47
+ return extractCaptureStatus(resource) === "COMPLETED"
48
+ }
49
+
50
+ /**
51
+ * Refund statuses that mean the refund did NOT go through and must never be
52
+ * recorded as a successful refund. PENDING is intentionally excluded: PayPal
53
+ * processes refunds asynchronously and a pending refund settles later.
54
+ */
55
+ export function isRefundFailureStatus(status?: string): boolean {
56
+ return ["FAILED", "CANCELLED", "CANCELED", "DENIED"].includes(
57
+ String(status || "").toUpperCase()
58
+ )
59
+ }
@@ -899,7 +899,9 @@ class PayPalModuleService extends MedusaService({
899
899
  console.warn("[PayPal] saveAndHydrateSellerCredentials lookup failed:", e?.message || e)
900
900
  }
901
901
 
902
- return await this.getStatus(env)
902
+ // This is a write context (credentials were just saved), so it is allowed to
903
+ // backfill the seller profile if it is still missing.
904
+ return await this.getStatus(env, { hydrateMissingProfile: true })
903
905
  }
904
906
 
905
907
  private async resolveWebhookUrl() {
@@ -1028,7 +1030,19 @@ class PayPalModuleService extends MedusaService({
1028
1030
  )}`
1029
1031
  }
1030
1032
 
1031
- async getStatus(envOverride?: Environment) {
1033
+ /**
1034
+ * Report the current connection status.
1035
+ *
1036
+ * `hydrateMissingProfile` is opt-in and OFF by default: status is read on
1037
+ * `GET /admin/paypal/status` (and other read-only routes), where it must be a
1038
+ * safe, side-effect-free read. The seller-profile backfill makes an outbound
1039
+ * PayPal call and persists to the DB, so it only runs from write contexts
1040
+ * (e.g. saving credentials) that explicitly request it.
1041
+ */
1042
+ async getStatus(
1043
+ envOverride?: Environment,
1044
+ opts: { hydrateMissingProfile?: boolean } = {}
1045
+ ) {
1032
1046
  const row = await this.getCurrentRow()
1033
1047
  const env = envOverride ?? (await this.getCurrentEnvironment())
1034
1048
 
@@ -1041,7 +1055,7 @@ class PayPalModuleService extends MedusaService({
1041
1055
  let sellerEmail: string | null = c.sellerEmail || row.seller_email || null
1042
1056
  let sellerMerchantId: string | null = c.sellerMerchantId || row.seller_merchant_id || null
1043
1057
 
1044
- if (!sellerEmail && hasCreds) {
1058
+ if (!sellerEmail && hasCreds && opts.hydrateMissingProfile) {
1045
1059
  try {
1046
1060
  const hydrated = await this.fetchSellerProfileFromDirectCredentials(env)
1047
1061
  if (hydrated.sellerEmail || hydrated.sellerMerchantId) {
@@ -0,0 +1,104 @@
1
+ import type { IPaymentModuleService } from "@medusajs/framework/types"
2
+ import { Modules } from "@medusajs/framework/utils"
3
+ import { isPayPalProviderId } from "./provider-ids"
4
+
5
+ /**
6
+ * Shared PayPal payment-session helpers.
7
+ *
8
+ * `create-order` and `capture-order` must agree on (a) which payment session
9
+ * belongs to a cart and (b) how the PayPal order id is stored/read on it —
10
+ * otherwise the capture's fail-closed binding (the order id stored by
11
+ * create-order must equal the one being captured) can never be satisfied.
12
+ * Keeping the logic here, resolved through the Payment module service
13
+ * (`Modules.PAYMENT`), guarantees both routes use the same, correct path.
14
+ */
15
+
16
+ export type PayPalCartSession = {
17
+ session_id: string
18
+ session_data: Record<string, any>
19
+ session_status: string
20
+ }
21
+
22
+ /**
23
+ * Resolve the cart's active PayPal payment session (wallet or card). When a
24
+ * cart has more than one PayPal session, the most recently created one wins —
25
+ * both create-order and capture-order rely on this same selection so they
26
+ * always read and write the same session.
27
+ */
28
+ export async function findPayPalSessionForCart(
29
+ cartId: string,
30
+ scope: any
31
+ ): Promise<PayPalCartSession | null> {
32
+ try {
33
+ const query = scope.resolve("query")
34
+ const { data: carts } = await query.graph({
35
+ entity: "cart",
36
+ fields: [
37
+ "id",
38
+ "payment_collection.payment_sessions.id",
39
+ "payment_collection.payment_sessions.data",
40
+ "payment_collection.payment_sessions.status",
41
+ "payment_collection.payment_sessions.provider_id",
42
+ "payment_collection.payment_sessions.created_at",
43
+ ],
44
+ filters: { id: cartId },
45
+ })
46
+ const cart = carts?.[0]
47
+ const sessions = cart?.payment_collection?.payment_sessions || []
48
+ const session = sessions
49
+ .filter((s: any) => isPayPalProviderId(s.provider_id))
50
+ .sort(
51
+ (a: any, b: any) =>
52
+ new Date(b.created_at || 0).getTime() - new Date(a.created_at || 0).getTime()
53
+ )[0]
54
+
55
+ if (!session) return null
56
+
57
+ return {
58
+ session_id: session.id,
59
+ session_data: (session.data || {}) as Record<string, any>,
60
+ session_status: session.status,
61
+ }
62
+ } catch (e: any) {
63
+ console.warn("[PayPal] findPayPalSessionForCart failed:", e?.message)
64
+ return null
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Read the PayPal order id stored on a session's `data`. Accepts both the
70
+ * canonical `data.paypal.order_id` and the legacy top-level `data.order_id`.
71
+ * Returns null when none is present.
72
+ */
73
+ export function getStoredPayPalOrderId(
74
+ sessionData?: Record<string, any> | null
75
+ ): string | null {
76
+ const paypal = (sessionData?.paypal || {}) as Record<string, unknown>
77
+ const id = String(paypal.order_id || sessionData?.order_id || "")
78
+ return id || null
79
+ }
80
+
81
+ /**
82
+ * Shallow-merge `extraData` into a payment session's `data`, preserving the
83
+ * session's amount/currency. Callers that update the nested `paypal` object are
84
+ * responsible for spreading its existing keys (see `getStoredPayPalOrderId`).
85
+ */
86
+ export async function updatePayPalSessionData(
87
+ sessionId: string,
88
+ extraData: Record<string, any>,
89
+ scope: any
90
+ ): Promise<void> {
91
+ try {
92
+ const paymentModule = scope.resolve(Modules.PAYMENT) as IPaymentModuleService
93
+ const [existing] = await paymentModule.listPaymentSessions({ id: [sessionId] }, { take: 1 })
94
+ const mergedData = { ...(existing?.data || {}), ...extraData }
95
+ await (paymentModule as any).updatePaymentSession({
96
+ id: sessionId,
97
+ data: mergedData,
98
+ amount: existing?.amount,
99
+ currency_code: existing?.currency_code,
100
+ })
101
+ } catch (e: any) {
102
+ console.error("[PayPal] updatePayPalSessionData failed:", e?.message)
103
+ }
104
+ }