@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.
- package/.medusa/server/src/admin/index.js +12 -12
- package/.medusa/server/src/admin/index.mjs +12 -12
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js.map +1 -1
- package/.medusa/server/src/api/store/paypal/capture-order/route.d.ts.map +1 -1
- package/.medusa/server/src/api/store/paypal/capture-order/route.js +27 -12
- package/.medusa/server/src/api/store/paypal/capture-order/route.js.map +1 -1
- package/.medusa/server/src/api/store/paypal/create-order/route.d.ts.map +1 -1
- package/.medusa/server/src/api/store/paypal/create-order/route.js +83 -56
- package/.medusa/server/src/api/store/paypal/create-order/route.js.map +1 -1
- package/.medusa/server/src/api/store/paypal/webhook/route.d.ts.map +1 -1
- package/.medusa/server/src/api/store/paypal/webhook/route.js +2 -1
- package/.medusa/server/src/api/store/paypal/webhook/route.js.map +1 -1
- package/.medusa/server/src/api/store/paypal-complete/route.d.ts.map +1 -1
- package/.medusa/server/src/api/store/paypal-complete/route.js +37 -32
- package/.medusa/server/src/api/store/paypal-complete/route.js.map +1 -1
- package/.medusa/server/src/jobs/paypal-webhook-retry.d.ts.map +1 -1
- package/.medusa/server/src/jobs/paypal-webhook-retry.js +42 -1
- package/.medusa/server/src/jobs/paypal-webhook-retry.js.map +1 -1
- package/.medusa/server/src/modules/paypal/clients/paypal-seller.client.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/clients/paypal-seller.client.js +5 -4
- package/.medusa/server/src/modules/paypal/clients/paypal-seller.client.js.map +1 -1
- package/.medusa/server/src/modules/paypal/payment-provider/card-service.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/payment-provider/card-service.js +13 -10
- package/.medusa/server/src/modules/paypal/payment-provider/card-service.js.map +1 -1
- package/.medusa/server/src/modules/paypal/payment-provider/service.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/payment-provider/service.js +10 -9
- package/.medusa/server/src/modules/paypal/payment-provider/service.js.map +1 -1
- package/.medusa/server/src/modules/paypal/payment-provider/webhook-utils.d.ts +12 -0
- package/.medusa/server/src/modules/paypal/payment-provider/webhook-utils.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/payment-provider/webhook-utils.js +12 -0
- package/.medusa/server/src/modules/paypal/payment-provider/webhook-utils.js.map +1 -1
- package/.medusa/server/src/modules/paypal/service.d.ts +10 -1
- package/.medusa/server/src/modules/paypal/service.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/service.js +71 -21
- package/.medusa/server/src/modules/paypal/service.js.map +1 -1
- package/.medusa/server/src/modules/paypal/utils/amounts.d.ts +9 -1
- package/.medusa/server/src/modules/paypal/utils/amounts.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/utils/amounts.js +17 -5
- package/.medusa/server/src/modules/paypal/utils/amounts.js.map +1 -1
- package/.medusa/server/src/modules/paypal/utils/paypal-auth.d.ts +0 -8
- package/.medusa/server/src/modules/paypal/utils/paypal-auth.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/utils/paypal-auth.js +0 -22
- package/.medusa/server/src/modules/paypal/utils/paypal-auth.js.map +1 -1
- package/.medusa/server/src/modules/paypal/utils/paypal-fetch.d.ts +16 -0
- package/.medusa/server/src/modules/paypal/utils/paypal-fetch.d.ts.map +1 -0
- package/.medusa/server/src/modules/paypal/utils/paypal-fetch.js +31 -0
- package/.medusa/server/src/modules/paypal/utils/paypal-fetch.js.map +1 -0
- package/.medusa/server/src/modules/paypal/utils/secret-crypto.d.ts +14 -0
- package/.medusa/server/src/modules/paypal/utils/secret-crypto.d.ts.map +1 -0
- package/.medusa/server/src/modules/paypal/utils/secret-crypto.js +91 -0
- package/.medusa/server/src/modules/paypal/utils/secret-crypto.js.map +1 -0
- package/.medusa/server/src/modules/paypal/webhook-processor.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/webhook-processor.js +29 -13
- package/.medusa/server/src/modules/paypal/webhook-processor.js.map +1 -1
- package/.medusa/server/src/subscribers/paypal-order-invoice.d.ts.map +1 -1
- package/.medusa/server/src/subscribers/paypal-order-invoice.js +5 -3
- package/.medusa/server/src/subscribers/paypal-order-invoice.js.map +1 -1
- package/README.md +12 -0
- package/package.json +5 -2
- package/src/admin/routes/settings/paypal/connection/page.tsx +1 -1
- package/src/api/store/paypal/capture-order/route.ts +39 -17
- package/src/api/store/paypal/create-order/route.ts +106 -64
- package/src/api/store/paypal/webhook/route.ts +2 -1
- package/src/api/store/paypal-complete/route.ts +58 -44
- package/src/jobs/paypal-webhook-retry.ts +51 -1
- package/src/modules/paypal/clients/paypal-seller.client.ts +6 -4
- package/src/modules/paypal/payment-provider/card-service.ts +13 -10
- package/src/modules/paypal/payment-provider/service.ts +10 -9
- package/src/modules/paypal/payment-provider/webhook-utils.ts +12 -0
- package/src/modules/paypal/service.ts +76 -21
- package/src/modules/paypal/utils/amounts.ts +17 -8
- package/src/modules/paypal/utils/paypal-auth.ts +0 -27
- package/src/modules/paypal/utils/paypal-fetch.ts +30 -0
- package/src/modules/paypal/utils/secret-crypto.ts +94 -0
- package/src/modules/paypal/webhook-processor.ts +32 -15
- 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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
133
|
+
const patchResp = await paypalFetch(
|
|
132
134
|
`${base}/v2/checkout/orders/${encodeURIComponent(paypalOrderId)}`,
|
|
133
135
|
{
|
|
134
136
|
method: "PATCH",
|