@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.
- package/.medusa/server/src/admin/index.js +14 -14
- package/.medusa/server/src/admin/index.mjs +14 -14
- 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 -66
- 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 +93 -97
- 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 +29 -27
- 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/payment-session.d.ts +35 -0
- package/.medusa/server/src/modules/paypal/utils/payment-session.d.ts.map +1 -0
- package/.medusa/server/src/modules/paypal/utils/payment-session.js +78 -0
- package/.medusa/server/src/modules/paypal/utils/payment-session.js.map +1 -0
- 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/api/store/paypal/capture-order/route.ts +28 -80
- package/src/api/store/paypal/create-order/route.ts +118 -109
- package/src/api/store/paypal/webhook/route.ts +2 -1
- package/src/api/store/paypal-complete/route.ts +32 -38
- 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/payment-session.ts +104 -0
- 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
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
+
}
|