@easypayment/medusa-payment-paypal 0.9.15 → 0.9.17

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 (79) hide show
  1. package/.medusa/server/src/admin/index.js +14 -14
  2. package/.medusa/server/src/admin/index.mjs +14 -14
  3. package/.medusa/server/src/api/store/paypal/capture-order/route.d.ts.map +1 -1
  4. package/.medusa/server/src/api/store/paypal/capture-order/route.js +27 -66
  5. package/.medusa/server/src/api/store/paypal/capture-order/route.js.map +1 -1
  6. package/.medusa/server/src/api/store/paypal/create-order/route.d.ts.map +1 -1
  7. package/.medusa/server/src/api/store/paypal/create-order/route.js +93 -97
  8. package/.medusa/server/src/api/store/paypal/create-order/route.js.map +1 -1
  9. package/.medusa/server/src/api/store/paypal/webhook/route.d.ts.map +1 -1
  10. package/.medusa/server/src/api/store/paypal/webhook/route.js +2 -1
  11. package/.medusa/server/src/api/store/paypal/webhook/route.js.map +1 -1
  12. package/.medusa/server/src/api/store/paypal-complete/route.d.ts.map +1 -1
  13. package/.medusa/server/src/api/store/paypal-complete/route.js +29 -27
  14. package/.medusa/server/src/api/store/paypal-complete/route.js.map +1 -1
  15. package/.medusa/server/src/jobs/paypal-webhook-retry.d.ts.map +1 -1
  16. package/.medusa/server/src/jobs/paypal-webhook-retry.js +42 -1
  17. package/.medusa/server/src/jobs/paypal-webhook-retry.js.map +1 -1
  18. package/.medusa/server/src/modules/paypal/clients/paypal-seller.client.d.ts.map +1 -1
  19. package/.medusa/server/src/modules/paypal/clients/paypal-seller.client.js +5 -4
  20. package/.medusa/server/src/modules/paypal/clients/paypal-seller.client.js.map +1 -1
  21. package/.medusa/server/src/modules/paypal/payment-provider/card-service.d.ts.map +1 -1
  22. package/.medusa/server/src/modules/paypal/payment-provider/card-service.js +13 -10
  23. package/.medusa/server/src/modules/paypal/payment-provider/card-service.js.map +1 -1
  24. package/.medusa/server/src/modules/paypal/payment-provider/service.d.ts.map +1 -1
  25. package/.medusa/server/src/modules/paypal/payment-provider/service.js +10 -9
  26. package/.medusa/server/src/modules/paypal/payment-provider/service.js.map +1 -1
  27. package/.medusa/server/src/modules/paypal/payment-provider/webhook-utils.d.ts +12 -0
  28. package/.medusa/server/src/modules/paypal/payment-provider/webhook-utils.d.ts.map +1 -1
  29. package/.medusa/server/src/modules/paypal/payment-provider/webhook-utils.js +12 -0
  30. package/.medusa/server/src/modules/paypal/payment-provider/webhook-utils.js.map +1 -1
  31. package/.medusa/server/src/modules/paypal/service.d.ts +10 -1
  32. package/.medusa/server/src/modules/paypal/service.d.ts.map +1 -1
  33. package/.medusa/server/src/modules/paypal/service.js +71 -21
  34. package/.medusa/server/src/modules/paypal/service.js.map +1 -1
  35. package/.medusa/server/src/modules/paypal/utils/amounts.d.ts +9 -1
  36. package/.medusa/server/src/modules/paypal/utils/amounts.d.ts.map +1 -1
  37. package/.medusa/server/src/modules/paypal/utils/amounts.js +17 -5
  38. package/.medusa/server/src/modules/paypal/utils/amounts.js.map +1 -1
  39. package/.medusa/server/src/modules/paypal/utils/payment-session.d.ts +35 -0
  40. package/.medusa/server/src/modules/paypal/utils/payment-session.d.ts.map +1 -0
  41. package/.medusa/server/src/modules/paypal/utils/payment-session.js +78 -0
  42. package/.medusa/server/src/modules/paypal/utils/payment-session.js.map +1 -0
  43. package/.medusa/server/src/modules/paypal/utils/paypal-auth.d.ts +0 -8
  44. package/.medusa/server/src/modules/paypal/utils/paypal-auth.d.ts.map +1 -1
  45. package/.medusa/server/src/modules/paypal/utils/paypal-auth.js +0 -22
  46. package/.medusa/server/src/modules/paypal/utils/paypal-auth.js.map +1 -1
  47. package/.medusa/server/src/modules/paypal/utils/paypal-fetch.d.ts +16 -0
  48. package/.medusa/server/src/modules/paypal/utils/paypal-fetch.d.ts.map +1 -0
  49. package/.medusa/server/src/modules/paypal/utils/paypal-fetch.js +31 -0
  50. package/.medusa/server/src/modules/paypal/utils/paypal-fetch.js.map +1 -0
  51. package/.medusa/server/src/modules/paypal/utils/secret-crypto.d.ts +14 -0
  52. package/.medusa/server/src/modules/paypal/utils/secret-crypto.d.ts.map +1 -0
  53. package/.medusa/server/src/modules/paypal/utils/secret-crypto.js +91 -0
  54. package/.medusa/server/src/modules/paypal/utils/secret-crypto.js.map +1 -0
  55. package/.medusa/server/src/modules/paypal/webhook-processor.d.ts.map +1 -1
  56. package/.medusa/server/src/modules/paypal/webhook-processor.js +29 -13
  57. package/.medusa/server/src/modules/paypal/webhook-processor.js.map +1 -1
  58. package/.medusa/server/src/subscribers/paypal-order-invoice.d.ts.map +1 -1
  59. package/.medusa/server/src/subscribers/paypal-order-invoice.js +5 -3
  60. package/.medusa/server/src/subscribers/paypal-order-invoice.js.map +1 -1
  61. package/README.md +12 -0
  62. package/package.json +5 -2
  63. package/src/api/store/paypal/capture-order/route.ts +28 -80
  64. package/src/api/store/paypal/create-order/route.ts +118 -109
  65. package/src/api/store/paypal/webhook/route.ts +2 -1
  66. package/src/api/store/paypal-complete/route.ts +32 -38
  67. package/src/jobs/paypal-webhook-retry.ts +51 -1
  68. package/src/modules/paypal/clients/paypal-seller.client.ts +6 -4
  69. package/src/modules/paypal/payment-provider/card-service.ts +13 -10
  70. package/src/modules/paypal/payment-provider/service.ts +10 -9
  71. package/src/modules/paypal/payment-provider/webhook-utils.ts +12 -0
  72. package/src/modules/paypal/service.ts +76 -21
  73. package/src/modules/paypal/utils/amounts.ts +17 -8
  74. package/src/modules/paypal/utils/payment-session.ts +104 -0
  75. package/src/modules/paypal/utils/paypal-auth.ts +0 -27
  76. package/src/modules/paypal/utils/paypal-fetch.ts +30 -0
  77. package/src/modules/paypal/utils/secret-crypto.ts +94 -0
  78. package/src/modules/paypal/webhook-processor.ts +32 -15
  79. package/src/subscribers/paypal-order-invoice.ts +6 -4
@@ -31,6 +31,7 @@ import {
31
31
  } from "../utils/currencies"
32
32
  import type PayPalModuleService from "../service"
33
33
  import { getPayPalWebhookActionAndData } from "./webhook-utils"
34
+ import { paypalFetch } from "../utils/paypal-fetch"
34
35
 
35
36
  type Options = {}
36
37
 
@@ -110,7 +111,7 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
110
111
 
111
112
  private async getOrderDetails(orderId: string) {
112
113
  const { accessToken, base } = await this.getPayPalAccessToken()
113
- const resp = await fetch(`${base}/v2/checkout/orders/${encodeURIComponent(orderId)}`, {
114
+ const resp = await paypalFetch(`${base}/v2/checkout/orders/${encodeURIComponent(orderId)}`, {
114
115
  method: "GET",
115
116
  headers: {
116
117
  Authorization: `Bearer ${accessToken}`,
@@ -395,7 +396,7 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
395
396
  data.session_id || data.cart_id || data.payment_collection_id || undefined,
396
397
  }
397
398
 
398
- const ppResp = await fetch(`${base}/v2/checkout/orders`, {
399
+ const ppResp = await paypalFetch(`${base}/v2/checkout/orders`, {
399
400
  method: "POST",
400
401
  headers: {
401
402
  Authorization: `Bearer ${accessToken}`,
@@ -430,7 +431,7 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
430
431
  if (existingAuthorization) {
431
432
  authorization = order
432
433
  } else {
433
- const authorizeResp = await fetch(
434
+ const authorizeResp = await paypalFetch(
434
435
  `${base}/v2/checkout/orders/${encodeURIComponent(newOrderId)}/authorize`,
435
436
  {
436
437
  method: "POST",
@@ -613,7 +614,7 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
613
614
  ).toUpperCase()
614
615
 
615
616
  if (!authorizationId && resolvedIntent === "AUTHORIZE") {
616
- const authorizeResp = await fetch(
617
+ const authorizeResp = await paypalFetch(
617
618
  `${base}/v2/checkout/orders/${encodeURIComponent(orderId)}/authorize`,
618
619
  {
619
620
  method: "POST",
@@ -668,7 +669,7 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
668
669
  ? `${base}/v2/payments/authorizations/${encodeURIComponent(authorizationId)}/capture`
669
670
  : `${base}/v2/checkout/orders/${encodeURIComponent(orderId)}/capture`
670
671
 
671
- const ppResp = await fetch(captureUrl, {
672
+ const ppResp = await paypalFetch(captureUrl, {
672
673
  method: "POST",
673
674
  headers: {
674
675
  Authorization: `Bearer ${accessToken}`,
@@ -781,7 +782,7 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
781
782
  refundPayload.note_to_payer = refundReason
782
783
  }
783
784
 
784
- const ppResp = await fetch(`${base}/v2/payments/captures/${encodeURIComponent(captureId)}/refund`, {
785
+ const ppResp = await paypalFetch(`${base}/v2/payments/captures/${encodeURIComponent(captureId)}/refund`, {
785
786
  method: "POST",
786
787
  headers: {
787
788
  Authorization: `Bearer ${accessToken}`,
@@ -871,7 +872,7 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
871
872
  const { accessToken, base } = await this.getPayPalAccessToken()
872
873
  const requestId = this.getIdempotencyKey(input, `void-${authorizationId}`)
873
874
 
874
- const resp = await fetch(
875
+ const resp = await paypalFetch(
875
876
  `${base}/v2/payments/authorizations/${encodeURIComponent(authorizationId)}/void`,
876
877
  {
877
878
  method: "POST",
@@ -901,9 +902,9 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
901
902
  })
902
903
  } else if (captureId) {
903
904
  const { accessToken, base } = await this.getPayPalAccessToken()
904
- const requestId = this.getIdempotencyKey(input, `refund-${captureId}`)
905
+ const requestId = this.getIdempotencyKey(input, `cancel-refund-${captureId}`)
905
906
 
906
- const resp = await fetch(`${base}/v2/payments/captures/${encodeURIComponent(captureId)}/refund`, {
907
+ const resp = await paypalFetch(`${base}/v2/payments/captures/${encodeURIComponent(captureId)}/refund`, {
907
908
  method: "POST",
908
909
  headers: {
909
910
  Authorization: `Bearer ${accessToken}`,
@@ -66,6 +66,18 @@ function resolveEventType(payload: ProviderWebhookPayload["payload"]) {
66
66
  return raw?.event_type || raw?.eventType
67
67
  }
68
68
 
69
+ /**
70
+ * Maps a PayPal webhook payload to the generic Medusa payment-provider webhook
71
+ * result (the `getWebhookActionAndData` hook on AbstractPaymentProvider).
72
+ *
73
+ * NOTE: the authoritative webhook handling for this plugin is the dedicated
74
+ * `/store/paypal/webhook` route (signature verification, replay window,
75
+ * dedup, dead-letter retry). This helper exists only for the framework's
76
+ * generic `/hooks/payment/:provider` path. The `session_id` returned here is
77
+ * derived from the PayPal resource `custom_id`, which this plugin sets to the
78
+ * cart id (see create-order) — so consumers must resolve the cart, not treat
79
+ * it as a payment-session id directly.
80
+ */
69
81
  export function getPayPalWebhookActionAndData(
70
82
  payload: ProviderWebhookPayload["payload"]
71
83
  ): WebhookActionResult {
@@ -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
  }
@@ -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
+ }
@@ -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
+ }