@easypayment/medusa-payment-paypal 0.9.14 → 0.9.16

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 (76) hide show
  1. package/.medusa/server/src/admin/index.js +12 -12
  2. package/.medusa/server/src/admin/index.mjs +12 -12
  3. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js.map +1 -1
  4. package/.medusa/server/src/api/store/paypal/capture-order/route.d.ts.map +1 -1
  5. package/.medusa/server/src/api/store/paypal/capture-order/route.js +27 -12
  6. package/.medusa/server/src/api/store/paypal/capture-order/route.js.map +1 -1
  7. package/.medusa/server/src/api/store/paypal/create-order/route.d.ts.map +1 -1
  8. package/.medusa/server/src/api/store/paypal/create-order/route.js +83 -56
  9. package/.medusa/server/src/api/store/paypal/create-order/route.js.map +1 -1
  10. package/.medusa/server/src/api/store/paypal/webhook/route.d.ts.map +1 -1
  11. package/.medusa/server/src/api/store/paypal/webhook/route.js +2 -1
  12. package/.medusa/server/src/api/store/paypal/webhook/route.js.map +1 -1
  13. package/.medusa/server/src/api/store/paypal-complete/route.d.ts.map +1 -1
  14. package/.medusa/server/src/api/store/paypal-complete/route.js +37 -32
  15. package/.medusa/server/src/api/store/paypal-complete/route.js.map +1 -1
  16. package/.medusa/server/src/jobs/paypal-webhook-retry.d.ts.map +1 -1
  17. package/.medusa/server/src/jobs/paypal-webhook-retry.js +42 -1
  18. package/.medusa/server/src/jobs/paypal-webhook-retry.js.map +1 -1
  19. package/.medusa/server/src/modules/paypal/clients/paypal-seller.client.d.ts.map +1 -1
  20. package/.medusa/server/src/modules/paypal/clients/paypal-seller.client.js +5 -4
  21. package/.medusa/server/src/modules/paypal/clients/paypal-seller.client.js.map +1 -1
  22. package/.medusa/server/src/modules/paypal/payment-provider/card-service.d.ts.map +1 -1
  23. package/.medusa/server/src/modules/paypal/payment-provider/card-service.js +13 -10
  24. package/.medusa/server/src/modules/paypal/payment-provider/card-service.js.map +1 -1
  25. package/.medusa/server/src/modules/paypal/payment-provider/service.d.ts.map +1 -1
  26. package/.medusa/server/src/modules/paypal/payment-provider/service.js +10 -9
  27. package/.medusa/server/src/modules/paypal/payment-provider/service.js.map +1 -1
  28. package/.medusa/server/src/modules/paypal/payment-provider/webhook-utils.d.ts +12 -0
  29. package/.medusa/server/src/modules/paypal/payment-provider/webhook-utils.d.ts.map +1 -1
  30. package/.medusa/server/src/modules/paypal/payment-provider/webhook-utils.js +12 -0
  31. package/.medusa/server/src/modules/paypal/payment-provider/webhook-utils.js.map +1 -1
  32. package/.medusa/server/src/modules/paypal/service.d.ts +10 -1
  33. package/.medusa/server/src/modules/paypal/service.d.ts.map +1 -1
  34. package/.medusa/server/src/modules/paypal/service.js +71 -21
  35. package/.medusa/server/src/modules/paypal/service.js.map +1 -1
  36. package/.medusa/server/src/modules/paypal/utils/amounts.d.ts +9 -1
  37. package/.medusa/server/src/modules/paypal/utils/amounts.d.ts.map +1 -1
  38. package/.medusa/server/src/modules/paypal/utils/amounts.js +17 -5
  39. package/.medusa/server/src/modules/paypal/utils/amounts.js.map +1 -1
  40. package/.medusa/server/src/modules/paypal/utils/paypal-auth.d.ts +0 -8
  41. package/.medusa/server/src/modules/paypal/utils/paypal-auth.d.ts.map +1 -1
  42. package/.medusa/server/src/modules/paypal/utils/paypal-auth.js +0 -22
  43. package/.medusa/server/src/modules/paypal/utils/paypal-auth.js.map +1 -1
  44. package/.medusa/server/src/modules/paypal/utils/paypal-fetch.d.ts +16 -0
  45. package/.medusa/server/src/modules/paypal/utils/paypal-fetch.d.ts.map +1 -0
  46. package/.medusa/server/src/modules/paypal/utils/paypal-fetch.js +31 -0
  47. package/.medusa/server/src/modules/paypal/utils/paypal-fetch.js.map +1 -0
  48. package/.medusa/server/src/modules/paypal/utils/secret-crypto.d.ts +14 -0
  49. package/.medusa/server/src/modules/paypal/utils/secret-crypto.d.ts.map +1 -0
  50. package/.medusa/server/src/modules/paypal/utils/secret-crypto.js +91 -0
  51. package/.medusa/server/src/modules/paypal/utils/secret-crypto.js.map +1 -0
  52. package/.medusa/server/src/modules/paypal/webhook-processor.d.ts.map +1 -1
  53. package/.medusa/server/src/modules/paypal/webhook-processor.js +29 -13
  54. package/.medusa/server/src/modules/paypal/webhook-processor.js.map +1 -1
  55. package/.medusa/server/src/subscribers/paypal-order-invoice.d.ts.map +1 -1
  56. package/.medusa/server/src/subscribers/paypal-order-invoice.js +5 -3
  57. package/.medusa/server/src/subscribers/paypal-order-invoice.js.map +1 -1
  58. package/README.md +12 -0
  59. package/package.json +5 -2
  60. package/src/admin/routes/settings/paypal/connection/page.tsx +1 -1
  61. package/src/api/store/paypal/capture-order/route.ts +39 -17
  62. package/src/api/store/paypal/create-order/route.ts +106 -64
  63. package/src/api/store/paypal/webhook/route.ts +2 -1
  64. package/src/api/store/paypal-complete/route.ts +58 -44
  65. package/src/jobs/paypal-webhook-retry.ts +51 -1
  66. package/src/modules/paypal/clients/paypal-seller.client.ts +6 -4
  67. package/src/modules/paypal/payment-provider/card-service.ts +13 -10
  68. package/src/modules/paypal/payment-provider/service.ts +10 -9
  69. package/src/modules/paypal/payment-provider/webhook-utils.ts +12 -0
  70. package/src/modules/paypal/service.ts +76 -21
  71. package/src/modules/paypal/utils/amounts.ts +17 -8
  72. package/src/modules/paypal/utils/paypal-auth.ts +0 -27
  73. package/src/modules/paypal/utils/paypal-fetch.ts +30 -0
  74. package/src/modules/paypal/utils/secret-crypto.ts +94 -0
  75. package/src/modules/paypal/webhook-processor.ts +32 -15
  76. package/src/subscribers/paypal-order-invoice.ts +6 -4
@@ -1,4 +1,6 @@
1
1
  import { MedusaService } from "@medusajs/framework/utils"
2
+ import { paypalFetch } from "./utils/paypal-fetch"
3
+ import { decryptSecret, encryptSecret } from "./utils/secret-crypto"
2
4
  import PayPalConnection from "./models/paypal_connection"
3
5
  import PayPalMetric from "./models/paypal_metric"
4
6
  import PayPalSettings from "./models/paypal_settings"
@@ -15,6 +17,28 @@ type Status =
15
17
  | "connected"
16
18
  | "revoked"
17
19
 
20
+ const SENSITIVE_KEY_RE =
21
+ /secret|password|client_secret|access_token|refresh_token|authorization|auth_code|api[_-]?key/i
22
+
23
+ /**
24
+ * Defensive redaction for audit logs: replace values of keys that look
25
+ * sensitive with "[REDACTED]". Bounded depth/breadth so a pathological payload
26
+ * can't blow up logging.
27
+ */
28
+ function redactSensitive(value: unknown, depth = 0): unknown {
29
+ if (depth > 6 || value === null || typeof value !== "object") {
30
+ return value
31
+ }
32
+ if (Array.isArray(value)) {
33
+ return value.slice(0, 50).map((v) => redactSensitive(v, depth + 1))
34
+ }
35
+ const out: Record<string, unknown> = {}
36
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
37
+ out[k] = SENSITIVE_KEY_RE.test(k) ? "[REDACTED]" : redactSensitive(v, depth + 1)
38
+ }
39
+ return out
40
+ }
41
+
18
42
  class PayPalModuleService extends MedusaService({
19
43
  PayPalConnection,
20
44
  PayPalMetric,
@@ -131,7 +155,10 @@ class PayPalModuleService extends MedusaService({
131
155
  const creds = meta?.credentials?.[env] || {}
132
156
  return {
133
157
  clientId: creds.client_id || creds.clientId || undefined,
134
- clientSecret: creds.client_secret || creds.clientSecret || undefined,
158
+ // Secrets are encrypted at rest when PAYPAL_ENCRYPTION_KEY is set; decrypt
159
+ // here so every consumer (auth, status) sees plaintext. Legacy plaintext
160
+ // values pass through unchanged.
161
+ clientSecret: decryptSecret(creds.client_secret || creds.clientSecret) || undefined,
135
162
  sellerMerchantId:
136
163
  creds.seller_merchant_id ||
137
164
  creds.sellerMerchantId ||
@@ -217,7 +244,7 @@ class PayPalModuleService extends MedusaService({
217
244
  const baseUrl = env === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"
218
245
  const accessToken = accessTokenOverride ?? await this.getAppAccessToken()
219
246
 
220
- const resp = await fetch(
247
+ const resp = await paypalFetch(
221
248
  `${baseUrl}/v1/customer/partners/${encodeURIComponent(
222
249
  partnerMerchantId
223
250
  )}/merchant-integrations/${encodeURIComponent(merchantId)}`,
@@ -258,7 +285,7 @@ class PayPalModuleService extends MedusaService({
258
285
  const body = new URLSearchParams()
259
286
  body.set("grant_type", "client_credentials")
260
287
 
261
- const res = await fetch(`${baseUrl}/v1/oauth2/token`, {
288
+ const res = await paypalFetch(`${baseUrl}/v1/oauth2/token`, {
262
289
  method: "POST",
263
290
  headers: {
264
291
  "Content-Type": "application/x-www-form-urlencoded",
@@ -314,7 +341,7 @@ class PayPalModuleService extends MedusaService({
314
341
  ).trim() || null
315
342
 
316
343
  try {
317
- const userInfoResp = await fetch(`${baseUrl}/v1/identity/oauth2/userinfo?schema=paypalv1`, {
344
+ const userInfoResp = await paypalFetch(`${baseUrl}/v1/identity/oauth2/userinfo?schema=paypalv1`, {
318
345
  method: "GET",
319
346
  headers: {
320
347
  Authorization: `Bearer ${accessToken}`,
@@ -337,7 +364,7 @@ class PayPalModuleService extends MedusaService({
337
364
 
338
365
  if (partnerMerchantId) {
339
366
  try {
340
- const credsResp = await fetch(
367
+ const credsResp = await paypalFetch(
341
368
  `${baseUrl}/v1/customer/partners/${encodeURIComponent(
342
369
  partnerMerchantId
343
370
  )}/merchant-integrations/credentials/`,
@@ -406,7 +433,8 @@ class PayPalModuleService extends MedusaService({
406
433
  id: row.id,
407
434
  status: c.clientId && c.clientSecret ? "connected" : "disconnected",
408
435
  seller_client_id: c.clientId || null,
409
- seller_client_secret: c.clientSecret || null,
436
+ // c.clientSecret is decrypted (from getEnvCreds); re-encrypt for the column.
437
+ seller_client_secret: encryptSecret(c.clientSecret) || null,
410
438
  seller_merchant_id: c.sellerMerchantId || null,
411
439
  seller_email: c.sellerEmail || null,
412
440
  metadata: {
@@ -493,7 +521,7 @@ class PayPalModuleService extends MedusaService({
493
521
  form.append("products[]", p)
494
522
  })
495
523
 
496
- const res = await fetch(onboarding.partner_service_url, {
524
+ const res = await paypalFetch(onboarding.partner_service_url, {
497
525
  method: "POST",
498
526
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
499
527
  body: form.toString(),
@@ -634,7 +662,7 @@ class PayPalModuleService extends MedusaService({
634
662
 
635
663
  const basic = Buffer.from(`${input.sharedId}:`).toString("base64")
636
664
 
637
- const tokenRes = await fetch(`${baseUrl}/v1/oauth2/token`, {
665
+ const tokenRes = await paypalFetch(`${baseUrl}/v1/oauth2/token`, {
638
666
  method: "POST",
639
667
  headers: {
640
668
  "Content-Type": "application/x-www-form-urlencoded",
@@ -668,7 +696,7 @@ class PayPalModuleService extends MedusaService({
668
696
  throw new Error("Missing PayPal partner merchant id configuration.")
669
697
  }
670
698
 
671
- const credRes = await fetch(
699
+ const credRes = await paypalFetch(
672
700
  `${baseUrl}/v1/customer/partners/${encodeURIComponent(partnerMerchantId)}/merchant-integrations/credentials/`,
673
701
  {
674
702
  method: "GET",
@@ -767,9 +795,13 @@ class PayPalModuleService extends MedusaService({
767
795
  const nextSellerEmail =
768
796
  (input.sellerEmail || "").trim() || existingCreds.sellerEmail || row?.seller_email || null
769
797
 
798
+ // Encrypt once and store the same ciphertext in both the metadata copy and
799
+ // the denormalized column (no-op when encryption is disabled).
800
+ const storedClientSecret = encryptSecret(input.clientSecret)
801
+
770
802
  const nextCreds = {
771
803
  client_id: input.clientId,
772
- client_secret: input.clientSecret,
804
+ client_secret: storedClientSecret,
773
805
  merchant_id: nextSellerMerchantId,
774
806
  seller_merchant_id: nextSellerMerchantId,
775
807
  seller_email: nextSellerEmail,
@@ -780,7 +812,7 @@ class PayPalModuleService extends MedusaService({
780
812
  environment: env,
781
813
  status: "connected",
782
814
  seller_client_id: input.clientId,
783
- seller_client_secret: input.clientSecret,
815
+ seller_client_secret: storedClientSecret,
784
816
  seller_merchant_id: nextSellerMerchantId,
785
817
  seller_email: nextSellerEmail,
786
818
  app_access_token: null,
@@ -811,7 +843,7 @@ class PayPalModuleService extends MedusaService({
811
843
  id: row.id,
812
844
  status: "connected",
813
845
  seller_client_id: input.clientId,
814
- seller_client_secret: input.clientSecret,
846
+ seller_client_secret: storedClientSecret,
815
847
  seller_merchant_id: nextSellerMerchantId,
816
848
  seller_email: nextSellerEmail,
817
849
  app_access_token: null,
@@ -909,7 +941,7 @@ class PayPalModuleService extends MedusaService({
909
941
  return webhookIds[env] || ""
910
942
  }
911
943
 
912
- const listResp = await fetch(`${baseUrl}/v1/notifications/webhooks`, {
944
+ const listResp = await paypalFetch(`${baseUrl}/v1/notifications/webhooks`, {
913
945
  headers: {
914
946
  Authorization: `Bearer ${accessToken}`,
915
947
  "Content-Type": "application/json",
@@ -929,7 +961,7 @@ class PayPalModuleService extends MedusaService({
929
961
  let webhookId = existing?.id ? String(existing.id) : ""
930
962
 
931
963
  if (!webhookId) {
932
- const createResp = await fetch(`${baseUrl}/v1/notifications/webhooks`, {
964
+ const createResp = await paypalFetch(`${baseUrl}/v1/notifications/webhooks`, {
933
965
  method: "POST",
934
966
  headers: {
935
967
  Authorization: `Bearer ${accessToken}`,
@@ -1095,7 +1127,9 @@ class PayPalModuleService extends MedusaService({
1095
1127
  const expiresAt = row.app_access_token_expires_at ? new Date(row.app_access_token_expires_at as string) : null
1096
1128
  if (row.app_access_token && expiresAt) {
1097
1129
  const msLeft = expiresAt.getTime() - Date.now()
1098
- if (msLeft > 2 * 60 * 1000) return row.app_access_token as string
1130
+ // Token is encrypted at rest when a key is configured; decrypt the cached
1131
+ // value (legacy plaintext passes through unchanged).
1132
+ if (msLeft > 2 * 60 * 1000) return decryptSecret(row.app_access_token as string) as string
1099
1133
  }
1100
1134
 
1101
1135
  if (this.tokenRefreshPromise) {
@@ -1118,7 +1152,7 @@ class PayPalModuleService extends MedusaService({
1118
1152
  const body = new URLSearchParams()
1119
1153
  body.set("grant_type", "client_credentials")
1120
1154
 
1121
- const res = await fetch(`${baseUrl}/v1/oauth2/token`, {
1155
+ const res = await paypalFetch(`${baseUrl}/v1/oauth2/token`, {
1122
1156
  method: "POST",
1123
1157
  headers: {
1124
1158
  "Content-Type": "application/x-www-form-urlencoded",
@@ -1141,7 +1175,7 @@ class PayPalModuleService extends MedusaService({
1141
1175
 
1142
1176
  await this.updatePayPalConnections({
1143
1177
  id: row.id,
1144
- app_access_token: accessToken,
1178
+ app_access_token: encryptSecret(accessToken),
1145
1179
  app_access_token_expires_at: newExpiresAt as unknown as Date,
1146
1180
  })
1147
1181
 
@@ -1154,7 +1188,7 @@ class PayPalModuleService extends MedusaService({
1154
1188
 
1155
1189
  const accessToken = await this.getAppAccessToken()
1156
1190
 
1157
- const res = await fetch(`${baseUrl}/v1/identity/generate-token`, {
1191
+ const res = await paypalFetch(`${baseUrl}/v1/identity/generate-token`, {
1158
1192
  method: "POST",
1159
1193
  headers: {
1160
1194
  Authorization: `Bearer ${accessToken}`,
@@ -1261,7 +1295,7 @@ class PayPalModuleService extends MedusaService({
1261
1295
 
1262
1296
  const accessToken = await this.getAppAccessToken()
1263
1297
 
1264
- const resp = await fetch(
1298
+ const resp = await paypalFetch(
1265
1299
  `${base}/v2/checkout/orders/${encodeURIComponent(orderId)}`,
1266
1300
  {
1267
1301
  method: "GET",
@@ -1344,7 +1378,28 @@ class PayPalModuleService extends MedusaService({
1344
1378
  })
1345
1379
  }
1346
1380
 
1347
- async recordAuditEvent(_eventType: string, _metadata?: Record<string, unknown>) {
1381
+ /**
1382
+ * Emit an audit event as a single structured log line.
1383
+ *
1384
+ * The dedicated audit-log table was removed; for high-volume/containerized
1385
+ * deployments the audit trail lives in aggregated stdout logs. This records a
1386
+ * greppable `paypal_audit` line (filter on `"log":"paypal_audit"`) with
1387
+ * sensitive keys redacted. It never throws — audit logging must not break the
1388
+ * caller's payment flow.
1389
+ */
1390
+ async recordAuditEvent(eventType: string, metadata?: Record<string, unknown>) {
1391
+ try {
1392
+ console.info(
1393
+ JSON.stringify({
1394
+ log: "paypal_audit",
1395
+ event_type: eventType,
1396
+ at: new Date().toISOString(),
1397
+ ...(metadata ? { metadata: redactSensitive(metadata) } : {}),
1398
+ })
1399
+ )
1400
+ } catch {
1401
+ // never let audit logging interfere with the payment flow
1402
+ }
1348
1403
  return null
1349
1404
  }
1350
1405
 
@@ -1398,7 +1453,7 @@ class PayPalModuleService extends MedusaService({
1398
1453
  await Promise.all(
1399
1454
  urls.map(async (url) => {
1400
1455
  try {
1401
- const resp = await fetch(url, {
1456
+ const resp = await paypalFetch(url, {
1402
1457
  method: "POST",
1403
1458
  headers: {
1404
1459
  "Content-Type": "application/json",
@@ -3,12 +3,18 @@ const ZERO_DECIMAL_CURRENCIES = new Set([
3
3
  "CLP",
4
4
  "DJF",
5
5
  "GNF",
6
+ // PayPal does NOT support decimals for HUF, JPY and TWD — even though HUF and
7
+ // TWD have 2 ISO 4217 minor digits, PayPal rejects amounts like "1000.00" for
8
+ // them (DECIMAL_PRECISION). They must be sent as whole numbers ("1000").
9
+ // See https://developer.paypal.com/api/rest/reference/currency-codes/
10
+ "HUF",
6
11
  "JPY",
7
12
  "KMF",
8
13
  "KRW",
9
14
  "MGA",
10
15
  "PYG",
11
16
  "RWF",
17
+ "TWD",
12
18
  "UGX",
13
19
  "VND",
14
20
  "VUV",
@@ -17,7 +23,7 @@ const ZERO_DECIMAL_CURRENCIES = new Set([
17
23
  "XPF",
18
24
  ])
19
25
 
20
- const THREE_DECIMAL_CURRENCIES = new Set(["BHD", "JOD", "KWD", "OMR", "TND"])
26
+ const THREE_DECIMAL_CURRENCIES = new Set(["BHD", "IQD", "JOD", "KWD", "OMR", "TND"])
21
27
 
22
28
  export function getCurrencyExponent(currencyCode: string) {
23
29
  const code = currencyCode.toUpperCase()
@@ -30,12 +36,15 @@ export function getCurrencyExponent(currencyCode: string) {
30
36
  return 2
31
37
  }
32
38
 
33
- export function formatAmountForPayPal(
34
- minorAmount: number,
35
- currencyCode: string
36
- ) {
39
+ /**
40
+ * Format a Medusa amount for the PayPal REST API.
41
+ *
42
+ * Medusa passes monetary amounts to payment providers in MAJOR units
43
+ * (e.g. `10` for €10.00), and PayPal's Orders/Payments API also expects major
44
+ * units as a string (e.g. `"10.00"`). So this only fixes the decimal precision
45
+ * for the currency — it must NOT divide/convert to minor units.
46
+ */
47
+ export function formatAmountForPayPal(amount: number, currencyCode: string) {
37
48
  const exponent = getCurrencyExponent(currencyCode)
38
- const factor = 10 ** exponent
39
- const majorAmount = Number(minorAmount || 0) / factor
40
- return majorAmount.toFixed(exponent)
49
+ return Number(amount || 0).toFixed(exponent)
41
50
  }
@@ -3,30 +3,3 @@ export function getPayPalApiBase(environment: string): string {
3
3
  ? "https://api-m.paypal.com"
4
4
  : "https://api-m.sandbox.paypal.com"
5
5
  }
6
-
7
- const BN_CODE = "MBJTechnolabs_SI_SPB"
8
-
9
- export async function getPayPalAccessToken(opts: {
10
- environment: string
11
- client_id: string
12
- client_secret: string
13
- }): Promise<{ accessToken: string; base: string }> {
14
- const base = getPayPalApiBase(opts.environment)
15
- const auth = Buffer.from(`${opts.client_id}:${opts.client_secret}`).toString("base64")
16
- const resp = await fetch(`${base}/v1/oauth2/token`, {
17
- method: "POST",
18
- headers: {
19
- Authorization: `Basic ${auth}`,
20
- "Content-Type": "application/x-www-form-urlencoded",
21
- "PayPal-Partner-Attribution-Id": BN_CODE,
22
- },
23
- body: "grant_type=client_credentials",
24
- signal: AbortSignal.timeout(30_000),
25
- })
26
- const text = await resp.text()
27
- if (!resp.ok) {
28
- throw new Error(`PayPal token error (${resp.status}): ${text}`)
29
- }
30
- const json = JSON.parse(text)
31
- return { accessToken: String(json.access_token), base }
32
- }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Default timeout (ms) for all server-side outbound PayPal HTTP calls.
3
+ *
4
+ * Without a timeout, a slow or hung PayPal socket holds a Node request handler
5
+ * (and any DB connection it owns) open indefinitely; under load these
6
+ * accumulate and exhaust the event loop / connection pool, cascading into
7
+ * checkout-wide failures. Override with PAYPAL_HTTP_TIMEOUT_MS.
8
+ */
9
+ export const PAYPAL_HTTP_TIMEOUT_MS = (() => {
10
+ const v = Number(process.env.PAYPAL_HTTP_TIMEOUT_MS)
11
+ return Number.isFinite(v) && v > 0 ? v : 30_000
12
+ })()
13
+
14
+ /**
15
+ * `fetch` that always enforces a timeout for PayPal / outbound calls. If the
16
+ * caller already supplies an AbortSignal, it is respected as-is (the caller is
17
+ * assumed to manage its own deadline).
18
+ */
19
+ export function paypalFetch(
20
+ input: string | URL,
21
+ init: RequestInit = {}
22
+ ): Promise<Response> {
23
+ if (init.signal) {
24
+ return fetch(input, init)
25
+ }
26
+ return fetch(input, {
27
+ ...init,
28
+ signal: AbortSignal.timeout(PAYPAL_HTTP_TIMEOUT_MS),
29
+ })
30
+ }
@@ -0,0 +1,94 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto"
2
+
3
+ /**
4
+ * Opt-in encryption-at-rest for PayPal secrets (client secret, app access
5
+ * token).
6
+ *
7
+ * Activated only when `PAYPAL_ENCRYPTION_KEY` is set. When it is unset (the
8
+ * default), both functions are the identity — behavior is byte-for-byte
9
+ * unchanged, so existing deployments are unaffected.
10
+ *
11
+ * Format: `enc:v1:<iv_b64>:<tag_b64>:<ciphertext_b64>` using AES-256-GCM
12
+ * (authenticated, so tampering is detected on decrypt). Legacy plaintext values
13
+ * (no prefix) are returned as-is on decrypt, so secrets stored before
14
+ * encryption was enabled keep working and are upgraded on the next save.
15
+ */
16
+ const PREFIX = "enc:v1:"
17
+
18
+ let cachedRaw: string | undefined
19
+ let cachedKey: Buffer | null = null
20
+
21
+ /**
22
+ * `@types/node` 22.0-22.6 typed `Buffer` as `Buffer<ArrayBufferLike>`, which is
23
+ * rejected where the `crypto` APIs and `Buffer.concat` expect a plain
24
+ * `Uint8Array` (the regression was fixed in 22.8). Narrowing each byte view to
25
+ * `Uint8Array` at the call boundary sidesteps it without changing runtime
26
+ * behavior, and stays valid on every other `@types/node` / TypeScript version.
27
+ */
28
+ const bytes = (view: Buffer): Uint8Array => view as unknown as Uint8Array
29
+
30
+ function getKey(): Buffer | null {
31
+ const raw = (process.env.PAYPAL_ENCRYPTION_KEY || "").trim()
32
+ if (raw !== cachedRaw) {
33
+ cachedRaw = raw
34
+ // Accept a key of any length/format; derive a stable 32-byte AES key.
35
+ cachedKey = raw ? createHash("sha256").update(raw).digest() : null
36
+ }
37
+ return cachedKey
38
+ }
39
+
40
+ export function isSecretEncryptionEnabled(): boolean {
41
+ return getKey() !== null
42
+ }
43
+
44
+ export function isEncrypted(value: unknown): boolean {
45
+ return typeof value === "string" && value.startsWith(PREFIX)
46
+ }
47
+
48
+ /**
49
+ * Encrypt a secret for storage. Returns the input unchanged when no key is
50
+ * configured or when the value is empty/already encrypted.
51
+ */
52
+ export function encryptSecret<T extends string | null | undefined>(value: T): T | string {
53
+ if (!value) {
54
+ return value
55
+ }
56
+ const key = getKey()
57
+ if (!key || isEncrypted(value)) {
58
+ return value
59
+ }
60
+ const iv = randomBytes(12)
61
+ const cipher = createCipheriv("aes-256-gcm", bytes(key), bytes(iv))
62
+ const ciphertext = Buffer.concat([bytes(cipher.update(String(value), "utf8")), bytes(cipher.final())])
63
+ const tag = cipher.getAuthTag()
64
+ return `${PREFIX}${iv.toString("base64")}:${tag.toString("base64")}:${ciphertext.toString("base64")}`
65
+ }
66
+
67
+ /**
68
+ * Decrypt a stored secret. Returns non-encrypted (legacy plaintext) values
69
+ * unchanged. Throws if the value is encrypted but no key is configured, or if
70
+ * authentication fails (wrong key / tampering).
71
+ */
72
+ export function decryptSecret<T extends string | null | undefined>(value: T): T | string {
73
+ if (!value || !isEncrypted(value)) {
74
+ return value
75
+ }
76
+ const key = getKey()
77
+ if (!key) {
78
+ throw new Error(
79
+ "PayPal secret is encrypted but PAYPAL_ENCRYPTION_KEY is not set. Restore the key to decrypt stored credentials."
80
+ )
81
+ }
82
+ const body = String(value).slice(PREFIX.length)
83
+ const [ivB64, tagB64, ctB64] = body.split(":")
84
+ if (!ivB64 || !tagB64 || !ctB64) {
85
+ throw new Error("PayPal secret ciphertext is malformed.")
86
+ }
87
+ const decipher = createDecipheriv("aes-256-gcm", bytes(key), bytes(Buffer.from(ivB64, "base64")))
88
+ decipher.setAuthTag(bytes(Buffer.from(tagB64, "base64")))
89
+ const plaintext = Buffer.concat([
90
+ bytes(decipher.update(bytes(Buffer.from(ctB64, "base64")))),
91
+ bytes(decipher.final()),
92
+ ])
93
+ return plaintext.toString("utf8")
94
+ }
@@ -1,6 +1,6 @@
1
1
  import type { MedusaContainer } from "@medusajs/framework/types"
2
2
  import { Modules } from "@medusajs/framework/utils"
3
- import { isPayPalProviderId } from "./utils/provider-ids"
3
+ import { isPayPalProviderId, PAYPAL_PROVIDER_IDS } from "./utils/provider-ids"
4
4
 
5
5
 
6
6
  export const EVENT_STATUS_MAP: Record<
@@ -143,7 +143,10 @@ export function extractIdentifiers(
143
143
  refundId = String(resource?.id || "").trim() || null
144
144
  orderId = String(related?.order_id || "").trim() || null
145
145
  captureId = String(related?.capture_id || "").trim() || null
146
- cartId = null
146
+ // The refund resource carries the capture's custom_id (the cart id) when it
147
+ // was set on the purchase unit — use it so refunds resolve directly instead
148
+ // of always falling back to a session scan.
149
+ cartId = String(resource?.custom_id || "").trim() || null
147
150
  }
148
151
 
149
152
  return { orderId, captureId, refundId, cartId }
@@ -294,21 +297,35 @@ export async function processPayPalWebhookEvent(
294
297
 
295
298
  let cartId = rawCartId
296
299
 
297
- if (!cartId) {
300
+ if (!cartId && (orderId || captureId)) {
298
301
  try {
299
302
  const paymentModule = container.resolve(Modules.PAYMENT) as any
300
- const allSessions = await paymentModule.listPaymentSessions(
301
- {
302
- provider_id: ["pp_paypal_paypal", "pp_paypal_card_paypal_card"],
303
- },
304
- { take: 200 }
305
- )
306
- const matchedSession = (allSessions || []).find((s: Record<string, unknown>) => {
307
- const pp = ((s.data || {}) as Record<string, unknown>).paypal as Record<string, unknown> || {}
308
- if (orderId && pp.order_id === orderId) return true
309
- if (captureId && pp.capture_id === captureId) return true
310
- return false
311
- })
303
+ // Fallback when the webhook resource carries no custom_id: locate the
304
+ // PayPal session whose stored data matches this order_id / capture_id.
305
+ // Paginate to exhaustion rather than scanning a single fixed page — on a
306
+ // busy store the matching session is frequently beyond the first page, so
307
+ // a hard cap would silently drop capture/refund status updates.
308
+ const PAGE_SIZE = 200
309
+ const MAX_PAGES = 100 // safety bound (20k sessions) against a misbehaving driver
310
+ let matchedSession: Record<string, unknown> | null = null
311
+
312
+ for (let page = 0; page < MAX_PAGES && !matchedSession; page++) {
313
+ const sessions = await paymentModule.listPaymentSessions(
314
+ { provider_id: [...PAYPAL_PROVIDER_IDS] },
315
+ { take: PAGE_SIZE, skip: page * PAGE_SIZE }
316
+ )
317
+ if (!sessions || sessions.length === 0) break
318
+ matchedSession =
319
+ sessions.find((s: Record<string, unknown>) => {
320
+ const pp =
321
+ (((s.data || {}) as Record<string, unknown>).paypal as Record<string, unknown>) || {}
322
+ if (orderId && pp.order_id === orderId) return true
323
+ if (captureId && pp.capture_id === captureId) return true
324
+ return false
325
+ }) || null
326
+ if (sessions.length < PAGE_SIZE) break
327
+ }
328
+
312
329
  if (matchedSession?.payment_collection_id) {
313
330
  const colls = await paymentModule.listPaymentCollections(
314
331
  { id: [matchedSession.payment_collection_id] },
@@ -1,6 +1,7 @@
1
1
  import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
2
2
  import type PayPalModuleService from "../modules/paypal/service"
3
- import { getPayPalAccessToken } from "../modules/paypal/utils/paypal-auth"
3
+ import { getPayPalApiBase } from "../modules/paypal/utils/paypal-auth"
4
+ import { paypalFetch } from "../modules/paypal/utils/paypal-fetch"
4
5
  import { isPayPalProviderId } from "../modules/paypal/utils/provider-ids"
5
6
 
6
7
  const PATCHABLE_STATUSES = new Set(["CREATED", "APPROVED", "SAVED"])
@@ -83,12 +84,13 @@ export default async function paypalOrderInvoiceHandler({
83
84
  if (!invoiceId) return
84
85
 
85
86
  const creds = await paypal.getActiveCredentials()
86
- const { accessToken, base } = await getPayPalAccessToken(creds)
87
+ const base = getPayPalApiBase(creds.environment)
88
+ const accessToken = await paypal.getAppAccessToken()
87
89
 
88
90
  let paypalOrderStatus = ""
89
91
  let currentInvoiceId = ""
90
92
  try {
91
- const statusResp = await fetch(
93
+ const statusResp = await paypalFetch(
92
94
  `${base}/v2/checkout/orders/${encodeURIComponent(paypalOrderId)}`,
93
95
  { headers: { Authorization: `Bearer ${accessToken}` } }
94
96
  )
@@ -128,7 +130,7 @@ export default async function paypalOrderInvoiceHandler({
128
130
  return
129
131
  }
130
132
 
131
- const patchResp = await fetch(
133
+ const patchResp = await paypalFetch(
132
134
  `${base}/v2/checkout/orders/${encodeURIComponent(paypalOrderId)}`,
133
135
  {
134
136
  method: "PATCH",