@easypayment/medusa-payment-paypal 0.9.17 → 0.9.19
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 +326 -130
- package/.medusa/server/src/admin/index.mjs +326 -130
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts +4 -1
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts.map +1 -1
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js +434 -113
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js.map +1 -1
- package/.medusa/server/src/api/admin/paypal/onboarding-link/route.d.ts.map +1 -1
- package/.medusa/server/src/api/admin/paypal/onboarding-link/route.js +4 -0
- package/.medusa/server/src/api/admin/paypal/onboarding-link/route.js.map +1 -1
- package/.medusa/server/src/api/middlewares.d.ts.map +1 -1
- package/.medusa/server/src/api/middlewares.js +9 -0
- package/.medusa/server/src/api/middlewares.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 +13 -8
- package/.medusa/server/src/api/store/paypal/webhook/route.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 +54 -31
- package/.medusa/server/src/modules/paypal/payment-provider/service.js.map +1 -1
- package/.medusa/server/src/modules/paypal/payment-provider/status-utils.d.ts +32 -0
- package/.medusa/server/src/modules/paypal/payment-provider/status-utils.d.ts.map +1 -0
- package/.medusa/server/src/modules/paypal/payment-provider/status-utils.js +65 -0
- package/.medusa/server/src/modules/paypal/payment-provider/status-utils.js.map +1 -0
- package/.medusa/server/src/modules/paypal/service.d.ts +13 -1
- package/.medusa/server/src/modules/paypal/service.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/service.js +19 -4
- package/.medusa/server/src/modules/paypal/service.js.map +1 -1
- package/.medusa/server/src/modules/paypal/utils/secret-crypto.d.ts +2 -1
- package/.medusa/server/src/modules/paypal/utils/secret-crypto.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/utils/secret-crypto.js +82 -26
- package/.medusa/server/src/modules/paypal/utils/secret-crypto.js.map +1 -1
- package/.medusa/server/src/modules/paypal/utils/webhook-verify.d.ts +28 -0
- package/.medusa/server/src/modules/paypal/utils/webhook-verify.d.ts.map +1 -0
- package/.medusa/server/src/modules/paypal/utils/webhook-verify.js +58 -0
- package/.medusa/server/src/modules/paypal/utils/webhook-verify.js.map +1 -0
- package/package.json +1 -1
- package/src/admin/routes/settings/paypal/connection/page.tsx +471 -127
- package/src/api/admin/paypal/onboarding-link/route.ts +6 -0
- package/src/api/middlewares.ts +9 -0
- package/src/api/store/paypal/webhook/route.ts +22 -8
- package/src/modules/paypal/payment-provider/service.ts +73 -29
- package/src/modules/paypal/payment-provider/status-utils.ts +59 -0
- package/src/modules/paypal/service.ts +24 -5
- package/src/modules/paypal/utils/secret-crypto.ts +90 -27
- package/src/modules/paypal/utils/webhook-verify.ts +61 -0
|
@@ -8,6 +8,7 @@ import type PayPalModuleService from "../../../../modules/paypal/service"
|
|
|
8
8
|
type Body = {
|
|
9
9
|
email?: string
|
|
10
10
|
products?: string[]
|
|
11
|
+
environment?: "sandbox" | "live"
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export async function POST(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
|
|
@@ -19,10 +20,15 @@ export async function POST(req: AuthenticatedMedusaRequest, res: MedusaResponse)
|
|
|
19
20
|
? String(req.auth_context.user_metadata.email)
|
|
20
21
|
: undefined
|
|
21
22
|
const email = authEmail ?? body.email ?? "admin@paypal.com"
|
|
23
|
+
const env =
|
|
24
|
+
body.environment === "sandbox" || body.environment === "live"
|
|
25
|
+
? body.environment
|
|
26
|
+
: undefined
|
|
22
27
|
|
|
23
28
|
const link = await paypal.createOnboardingLink({
|
|
24
29
|
email,
|
|
25
30
|
products: body.products,
|
|
31
|
+
env,
|
|
26
32
|
})
|
|
27
33
|
|
|
28
34
|
return res.json({
|
package/src/api/middlewares.ts
CHANGED
|
@@ -2,6 +2,15 @@ import { defineMiddlewares } from "@medusajs/framework/http"
|
|
|
2
2
|
|
|
3
3
|
export default defineMiddlewares({
|
|
4
4
|
routes: [
|
|
5
|
+
{
|
|
6
|
+
// Keep the exact bytes PayPal signed (`req.rawBody`) so webhook signature
|
|
7
|
+
// verification hashes the original payload, not a re-serialized copy. This
|
|
8
|
+
// entry must precede the "/store/paypal/:path*" catch-all below.
|
|
9
|
+
matcher: "/store/paypal/webhook",
|
|
10
|
+
method: ["POST"],
|
|
11
|
+
bodyParser: { preserveRawBody: true },
|
|
12
|
+
middlewares: [],
|
|
13
|
+
},
|
|
5
14
|
{
|
|
6
15
|
matcher: "/store/paypal-complete",
|
|
7
16
|
middlewares: [],
|
|
@@ -8,6 +8,11 @@ import {
|
|
|
8
8
|
normalizeEventVersion,
|
|
9
9
|
processPayPalWebhookEvent,
|
|
10
10
|
} from "../../../../modules/paypal/webhook-processor"
|
|
11
|
+
import {
|
|
12
|
+
composeVerifyRequestBody,
|
|
13
|
+
rawBodyToString,
|
|
14
|
+
resolveWebhookEventJson,
|
|
15
|
+
} from "../../../../modules/paypal/utils/webhook-verify"
|
|
11
16
|
|
|
12
17
|
const REPLAY_WINDOW_MINUTES = (() => {
|
|
13
18
|
const v = Number(process.env.PAYPAL_WEBHOOK_REPLAY_WINDOW_MINUTES)
|
|
@@ -110,7 +115,8 @@ async function verifyWebhookSignature(
|
|
|
110
115
|
paypal: PayPalModuleService,
|
|
111
116
|
environment: string,
|
|
112
117
|
body: Record<string, unknown>,
|
|
113
|
-
headers: Record<string, string | string[] | undefined
|
|
118
|
+
headers: Record<string, string | string[] | undefined>,
|
|
119
|
+
rawBody: string | null
|
|
114
120
|
): Promise<void> {
|
|
115
121
|
const settings = await paypal.getSettings().catch(() => ({ data: {} }))
|
|
116
122
|
const webhookId = resolveWebhookId(
|
|
@@ -131,17 +137,16 @@ async function verifyWebhookSignature(
|
|
|
131
137
|
|
|
132
138
|
const accessToken = await paypal.getAppAccessToken()
|
|
133
139
|
|
|
134
|
-
const
|
|
140
|
+
const verifyFields = {
|
|
135
141
|
auth_algo: getHeader(headers, "paypal-auth-algo"),
|
|
136
142
|
cert_url: getHeader(headers, "paypal-cert-url"),
|
|
137
143
|
transmission_id: getHeader(headers, "paypal-transmission-id"),
|
|
138
144
|
transmission_sig: getHeader(headers, "paypal-transmission-sig"),
|
|
139
145
|
transmission_time: getHeader(headers, "paypal-transmission-time"),
|
|
140
146
|
webhook_id: webhookId,
|
|
141
|
-
webhook_event: body,
|
|
142
147
|
}
|
|
143
148
|
|
|
144
|
-
const certUrl =
|
|
149
|
+
const certUrl = verifyFields.cert_url
|
|
145
150
|
if (
|
|
146
151
|
certUrl &&
|
|
147
152
|
!certUrl.startsWith("https://www.paypal.com/") &&
|
|
@@ -151,21 +156,29 @@ async function verifyWebhookSignature(
|
|
|
151
156
|
throw new Error("Invalid paypal-cert-url: must originate from paypal.com")
|
|
152
157
|
}
|
|
153
158
|
|
|
154
|
-
const missing = Object.entries(
|
|
155
|
-
.filter(([k, v]) => k !== "webhook_id" &&
|
|
159
|
+
const missing = Object.entries(verifyFields)
|
|
160
|
+
.filter(([k, v]) => k !== "webhook_id" && !v)
|
|
156
161
|
.map(([k]) => k)
|
|
157
162
|
|
|
158
163
|
if (missing.length > 0) {
|
|
159
164
|
throw new Error(`Missing required PayPal webhook headers: ${missing.join(", ")}`)
|
|
160
165
|
}
|
|
161
166
|
|
|
167
|
+
// Use the raw bytes PayPal signed for `webhook_event` whenever available
|
|
168
|
+
// (preserved by the route's `bodyParser: { preserveRawBody: true }`); fall
|
|
169
|
+
// back to serializing the parsed body so verification still runs if it isn't.
|
|
170
|
+
const requestBody = composeVerifyRequestBody(
|
|
171
|
+
verifyFields,
|
|
172
|
+
resolveWebhookEventJson(rawBody, body)
|
|
173
|
+
)
|
|
174
|
+
|
|
162
175
|
const resp = await paypalFetch(`${base}/v1/notifications/verify-webhook-signature`, {
|
|
163
176
|
method: "POST",
|
|
164
177
|
headers: {
|
|
165
178
|
Authorization: `Bearer ${accessToken}`,
|
|
166
179
|
"Content-Type": "application/json",
|
|
167
180
|
},
|
|
168
|
-
body:
|
|
181
|
+
body: requestBody,
|
|
169
182
|
})
|
|
170
183
|
|
|
171
184
|
const json = await resp.json().catch(() => ({}))
|
|
@@ -214,7 +227,8 @@ export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
|
|
214
227
|
|
|
215
228
|
try {
|
|
216
229
|
const creds = await paypal.getActiveCredentials()
|
|
217
|
-
|
|
230
|
+
const rawBody = rawBodyToString((req as unknown as { rawBody?: unknown }).rawBody)
|
|
231
|
+
await verifyWebhookSignature(paypal, creds.environment, payload, req.headers, rawBody)
|
|
218
232
|
} catch (e: any) {
|
|
219
233
|
console.error("[PayPal] webhook: signature verification failed:", e?.message)
|
|
220
234
|
return res
|
|
@@ -32,6 +32,12 @@ import {
|
|
|
32
32
|
import type PayPalModuleService from "../service"
|
|
33
33
|
import { getPayPalWebhookActionAndData } from "./webhook-utils"
|
|
34
34
|
import { paypalFetch } from "../utils/paypal-fetch"
|
|
35
|
+
import {
|
|
36
|
+
extractCaptureStatus,
|
|
37
|
+
isCaptureCompleted,
|
|
38
|
+
isRefundFailureStatus,
|
|
39
|
+
mapPayPalCaptureStatus,
|
|
40
|
+
} from "./status-utils"
|
|
35
41
|
|
|
36
42
|
type Options = {}
|
|
37
43
|
|
|
@@ -154,13 +160,7 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
|
|
|
154
160
|
}
|
|
155
161
|
|
|
156
162
|
private mapCaptureStatus(status?: string) {
|
|
157
|
-
|
|
158
|
-
if (!normalized) return null
|
|
159
|
-
if (normalized === "COMPLETED") return "captured"
|
|
160
|
-
if (normalized === "PENDING") return "pending"
|
|
161
|
-
if (["DENIED", "DECLINED", "FAILED"].includes(normalized)) return "error"
|
|
162
|
-
if (["REFUNDED", "PARTIALLY_REFUNDED", "REVERSED"].includes(normalized)) return "canceled"
|
|
163
|
-
return null
|
|
163
|
+
return mapPayPalCaptureStatus(status)
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
private mapAuthorizationStatus(status?: string) {
|
|
@@ -587,15 +587,19 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
|
|
|
587
587
|
}
|
|
588
588
|
}
|
|
589
589
|
|
|
590
|
-
const requestId = this.getIdempotencyKey(input, `capture-${orderId}`)
|
|
591
590
|
const { amount, currencyCode } = await this.normalizePaymentData(input)
|
|
591
|
+
// Include the amount in the idempotency suffix: PayPal deduplicates by
|
|
592
|
+
// PayPal-Request-Id, so two sequential partial captures of the same order
|
|
593
|
+
// that share an upstream idempotency_key would otherwise collide and the
|
|
594
|
+
// second capture would silently return the first one's result.
|
|
595
|
+
const requestId = this.getIdempotencyKey(input, `capture-${orderId}-${amount}`)
|
|
592
596
|
let debugId: string | null = null
|
|
593
597
|
|
|
594
598
|
try {
|
|
595
599
|
const { accessToken, base } = await this.getPayPalAccessToken()
|
|
596
600
|
const order = await this.getOrderDetails(orderId).catch(() => null)
|
|
597
601
|
const existingCapture = order?.purchase_units?.[0]?.payments?.captures?.[0]
|
|
598
|
-
if (existingCapture?.id) {
|
|
602
|
+
if (existingCapture?.id && isCaptureCompleted(existingCapture)) {
|
|
599
603
|
return {
|
|
600
604
|
data: {
|
|
601
605
|
...(input.data || {}),
|
|
@@ -648,26 +652,35 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
|
|
|
648
652
|
const captureValue = amount > 0
|
|
649
653
|
? formatAmountForPayPal(amount, currencyCode || "EUR")
|
|
650
654
|
: null
|
|
651
|
-
const capturePayload =
|
|
652
|
-
captureValue
|
|
653
|
-
? {
|
|
654
|
-
amount: {
|
|
655
|
-
currency_code: currencyCode || "EUR",
|
|
656
|
-
value: captureValue,
|
|
657
|
-
},
|
|
658
|
-
...(typeof isFinalCapture === "boolean"
|
|
659
|
-
? { is_final_capture: isFinalCapture }
|
|
660
|
-
: {}),
|
|
661
|
-
}
|
|
662
|
-
: {
|
|
663
|
-
...(typeof isFinalCapture === "boolean"
|
|
664
|
-
? { is_final_capture: isFinalCapture }
|
|
665
|
-
: {}),
|
|
666
|
-
}
|
|
667
655
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
656
|
+
// `amount` and `is_final_capture` are only valid on the authorizations
|
|
657
|
+
// capture endpoint. The orders capture endpoint always captures the FULL
|
|
658
|
+
// order and silently ignores an `amount` body — so sending a partial
|
|
659
|
+
// amount there would over-capture while we record the (smaller) requested
|
|
660
|
+
// value. Route partial captures to the authorization, and fail closed if a
|
|
661
|
+
// partial capture is attempted against a capture-intent order.
|
|
662
|
+
let capturePayload: Record<string, unknown>
|
|
663
|
+
let captureUrl: string
|
|
664
|
+
if (authorizationId) {
|
|
665
|
+
captureUrl = `${base}/v2/payments/authorizations/${encodeURIComponent(authorizationId)}/capture`
|
|
666
|
+
capturePayload = {
|
|
667
|
+
...(captureValue
|
|
668
|
+
? { amount: { currency_code: currencyCode || "EUR", value: captureValue } }
|
|
669
|
+
: {}),
|
|
670
|
+
...(typeof isFinalCapture === "boolean" ? { is_final_capture: isFinalCapture } : {}),
|
|
671
|
+
}
|
|
672
|
+
} else {
|
|
673
|
+
captureUrl = `${base}/v2/checkout/orders/${encodeURIComponent(orderId)}/capture`
|
|
674
|
+
capturePayload = {}
|
|
675
|
+
const orderTotal = order?.purchase_units?.[0]?.amount?.value
|
|
676
|
+
if (captureValue && orderTotal && captureValue !== String(orderTotal)) {
|
|
677
|
+
throw new Error(
|
|
678
|
+
`PayPal partial capture (${captureValue} ${currencyCode || "EUR"}) is not supported for ` +
|
|
679
|
+
`capture-intent orders (order total ${orderTotal}). Create the order with intent ` +
|
|
680
|
+
`AUTHORIZE to capture a partial amount.`
|
|
681
|
+
)
|
|
682
|
+
}
|
|
683
|
+
}
|
|
671
684
|
|
|
672
685
|
const ppResp = await paypalFetch(captureUrl, {
|
|
673
686
|
method: "POST",
|
|
@@ -691,6 +704,19 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
|
|
|
691
704
|
}
|
|
692
705
|
|
|
693
706
|
const capture = JSON.parse(ppText)
|
|
707
|
+
|
|
708
|
+
// A 2xx response does NOT mean the funds were captured. PayPal returns 201
|
|
709
|
+
// for captures that are PENDING (e.g. pending review / eCheck), DECLINED,
|
|
710
|
+
// or FAILED. Recording any of these as "captured" books money that never
|
|
711
|
+
// settled, so only a COMPLETED capture is treated as success.
|
|
712
|
+
const captureStatus = extractCaptureStatus(capture)
|
|
713
|
+
if (captureStatus !== "COMPLETED") {
|
|
714
|
+
throw new Error(
|
|
715
|
+
`PayPal capture did not complete (status=${captureStatus || "UNKNOWN"}). ` +
|
|
716
|
+
`The payment was not captured.${debugId ? ` debug_id=${debugId}` : ""}`
|
|
717
|
+
)
|
|
718
|
+
}
|
|
719
|
+
|
|
694
720
|
const captureId =
|
|
695
721
|
capture?.id || capture?.purchase_units?.[0]?.payments?.captures?.[0]?.id
|
|
696
722
|
const existingCaptures = Array.isArray(paypalData.captures) ? paypalData.captures : []
|
|
@@ -752,7 +778,12 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
|
|
|
752
778
|
)
|
|
753
779
|
}
|
|
754
780
|
|
|
755
|
-
|
|
781
|
+
// Include the amount in the idempotency suffix so two sequential partial
|
|
782
|
+
// refunds of the same capture are not deduplicated by PayPal into one.
|
|
783
|
+
const requestId = this.getIdempotencyKey(
|
|
784
|
+
input,
|
|
785
|
+
`refund-${captureId}-${Number(input.amount ?? 0)}`
|
|
786
|
+
)
|
|
756
787
|
|
|
757
788
|
const currencyOverride = await this.resolveCurrencyOverride()
|
|
758
789
|
const currencyCode = normalizeCurrencyCode(
|
|
@@ -804,6 +835,19 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
|
|
|
804
835
|
}
|
|
805
836
|
|
|
806
837
|
const refund = JSON.parse(ppText)
|
|
838
|
+
|
|
839
|
+
// As with captures, a 2xx response does not guarantee the refund stuck.
|
|
840
|
+
// FAILED / CANCELLED / DENIED refunds also return 2xx and must not be
|
|
841
|
+
// recorded as a successful refund. PENDING is accepted: PayPal processes
|
|
842
|
+
// refunds asynchronously and a pending refund will settle.
|
|
843
|
+
const refundStatus = String(refund?.status || "").toUpperCase()
|
|
844
|
+
if (isRefundFailureStatus(refundStatus)) {
|
|
845
|
+
throw new Error(
|
|
846
|
+
`PayPal refund did not succeed (status=${refundStatus}). The refund was not issued.` +
|
|
847
|
+
(debugId ? ` debug_id=${debugId}` : "")
|
|
848
|
+
)
|
|
849
|
+
}
|
|
850
|
+
|
|
807
851
|
const existingRefunds = Array.isArray(paypalData.refunds) ? paypalData.refunds : []
|
|
808
852
|
const refundEntry = {
|
|
809
853
|
id: refund?.id,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure mapping/validation helpers for PayPal capture & refund resource statuses.
|
|
3
|
+
*
|
|
4
|
+
* Kept free of any container/IO dependencies so the money-flow decisions (what
|
|
5
|
+
* counts as a settled capture, when a refund must be rejected, how a PayPal
|
|
6
|
+
* status maps onto a Medusa payment status) can be unit-tested in isolation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Map a PayPal capture status onto a Medusa payment status. */
|
|
10
|
+
export function mapPayPalCaptureStatus(
|
|
11
|
+
status?: string
|
|
12
|
+
): "captured" | "pending" | "error" | "canceled" | null {
|
|
13
|
+
const normalized = String(status || "").toUpperCase()
|
|
14
|
+
if (!normalized) return null
|
|
15
|
+
if (normalized === "COMPLETED") return "captured"
|
|
16
|
+
if (normalized === "PENDING") return "pending"
|
|
17
|
+
if (["DENIED", "DECLINED", "FAILED"].includes(normalized)) return "error"
|
|
18
|
+
// A partially-refunded capture is still a captured payment — only a portion of
|
|
19
|
+
// the funds was returned. Mapping it to "canceled" would wrongly unwind a live
|
|
20
|
+
// capture, so it must stay "captured". A full refund/reversal does unwind it.
|
|
21
|
+
if (normalized === "PARTIALLY_REFUNDED") return "captured"
|
|
22
|
+
if (["REFUNDED", "REVERSED"].includes(normalized)) return "canceled"
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Effective status of a PayPal capture resource.
|
|
28
|
+
*
|
|
29
|
+
* The orders-capture response nests the capture under
|
|
30
|
+
* `purchase_units[].payments.captures[]` — the order's own top-level status can
|
|
31
|
+
* read COMPLETED while the capture is still PENDING — whereas the
|
|
32
|
+
* authorizations-capture response is the capture object directly. Prefer the
|
|
33
|
+
* nested capture status, falling back to the top level.
|
|
34
|
+
*/
|
|
35
|
+
export function extractCaptureStatus(resource: any): string {
|
|
36
|
+
if (!resource || typeof resource !== "object") return ""
|
|
37
|
+
const nested = resource?.purchase_units?.[0]?.payments?.captures?.[0]?.status
|
|
38
|
+
return String(nested ?? resource.status ?? "").toUpperCase()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* True only when a capture resource is COMPLETED. A 2xx HTTP response does NOT
|
|
43
|
+
* imply this: PayPal returns 201 for PENDING (pending review / eCheck),
|
|
44
|
+
* DECLINED, and FAILED captures too.
|
|
45
|
+
*/
|
|
46
|
+
export function isCaptureCompleted(resource: any): boolean {
|
|
47
|
+
return extractCaptureStatus(resource) === "COMPLETED"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Refund statuses that mean the refund did NOT go through and must never be
|
|
52
|
+
* recorded as a successful refund. PENDING is intentionally excluded: PayPal
|
|
53
|
+
* processes refunds asynchronously and a pending refund settles later.
|
|
54
|
+
*/
|
|
55
|
+
export function isRefundFailureStatus(status?: string): boolean {
|
|
56
|
+
return ["FAILED", "CANCELLED", "CANCELED", "DENIED"].includes(
|
|
57
|
+
String(status || "").toUpperCase()
|
|
58
|
+
)
|
|
59
|
+
}
|
|
@@ -493,10 +493,15 @@ class PayPalModuleService extends MedusaService({
|
|
|
493
493
|
return await this.getCurrentRow()
|
|
494
494
|
}
|
|
495
495
|
|
|
496
|
-
async createOnboardingLink(input?: { email?: string; products?: string[] }) {
|
|
496
|
+
async createOnboardingLink(input?: { email?: string; products?: string[]; env?: Environment }) {
|
|
497
497
|
const { onboarding } = await this.ensureSettingsDefaults()
|
|
498
498
|
const return_url = `${String(onboarding.backend_url || "").replace(/\/$/, "")}/admin/paypal/onboard-complete`
|
|
499
|
-
|
|
499
|
+
// Honor an explicit environment from the caller so the generated link can't
|
|
500
|
+
// depend on a racing "set environment" request having landed first.
|
|
501
|
+
const env =
|
|
502
|
+
input?.env === "sandbox" || input?.env === "live"
|
|
503
|
+
? input.env
|
|
504
|
+
: await this.getCurrentEnvironment()
|
|
500
505
|
const partner_merchant_id = await this.getPartnerMerchantId(env)
|
|
501
506
|
|
|
502
507
|
const email = (input?.email || "").trim()
|
|
@@ -899,7 +904,9 @@ class PayPalModuleService extends MedusaService({
|
|
|
899
904
|
console.warn("[PayPal] saveAndHydrateSellerCredentials lookup failed:", e?.message || e)
|
|
900
905
|
}
|
|
901
906
|
|
|
902
|
-
|
|
907
|
+
// This is a write context (credentials were just saved), so it is allowed to
|
|
908
|
+
// backfill the seller profile if it is still missing.
|
|
909
|
+
return await this.getStatus(env, { hydrateMissingProfile: true })
|
|
903
910
|
}
|
|
904
911
|
|
|
905
912
|
private async resolveWebhookUrl() {
|
|
@@ -1028,7 +1035,19 @@ class PayPalModuleService extends MedusaService({
|
|
|
1028
1035
|
)}`
|
|
1029
1036
|
}
|
|
1030
1037
|
|
|
1031
|
-
|
|
1038
|
+
/**
|
|
1039
|
+
* Report the current connection status.
|
|
1040
|
+
*
|
|
1041
|
+
* `hydrateMissingProfile` is opt-in and OFF by default: status is read on
|
|
1042
|
+
* `GET /admin/paypal/status` (and other read-only routes), where it must be a
|
|
1043
|
+
* safe, side-effect-free read. The seller-profile backfill makes an outbound
|
|
1044
|
+
* PayPal call and persists to the DB, so it only runs from write contexts
|
|
1045
|
+
* (e.g. saving credentials) that explicitly request it.
|
|
1046
|
+
*/
|
|
1047
|
+
async getStatus(
|
|
1048
|
+
envOverride?: Environment,
|
|
1049
|
+
opts: { hydrateMissingProfile?: boolean } = {}
|
|
1050
|
+
) {
|
|
1032
1051
|
const row = await this.getCurrentRow()
|
|
1033
1052
|
const env = envOverride ?? (await this.getCurrentEnvironment())
|
|
1034
1053
|
|
|
@@ -1041,7 +1060,7 @@ class PayPalModuleService extends MedusaService({
|
|
|
1041
1060
|
let sellerEmail: string | null = c.sellerEmail || row.seller_email || null
|
|
1042
1061
|
let sellerMerchantId: string | null = c.sellerMerchantId || row.seller_merchant_id || null
|
|
1043
1062
|
|
|
1044
|
-
if (!sellerEmail && hasCreds) {
|
|
1063
|
+
if (!sellerEmail && hasCreds && opts.hydrateMissingProfile) {
|
|
1045
1064
|
try {
|
|
1046
1065
|
const hydrated = await this.fetchSellerProfileFromDirectCredentials(env)
|
|
1047
1066
|
if (hydrated.sellerEmail || hydrated.sellerMerchantId) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto"
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes, scryptSync } from "crypto"
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Opt-in encryption-at-rest for PayPal secrets (client secret, app access
|
|
@@ -8,15 +8,35 @@ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypt
|
|
|
8
8
|
* default), both functions are the identity — behavior is byte-for-byte
|
|
9
9
|
* unchanged, so existing deployments are unaffected.
|
|
10
10
|
*
|
|
11
|
-
* Format: `enc:
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* Format (current): `enc:v2:<salt_b64>:<iv_b64>:<tag_b64>:<ciphertext_b64>`
|
|
12
|
+
* using AES-256-GCM with a key derived via **scrypt** over the env key and a
|
|
13
|
+
* random per-secret salt. scrypt is a memory-hard KDF, so even a low-entropy
|
|
14
|
+
* passphrase resists brute-force/rainbow-table attacks (the previous unsalted,
|
|
15
|
+
* single-pass SHA-256 did not).
|
|
16
|
+
*
|
|
17
|
+
* Format (legacy): `enc:v1:<iv_b64>:<tag_b64>:<ciphertext_b64>` with an
|
|
18
|
+
* unsalted SHA-256 key. Still decryptable so secrets written before the KDF
|
|
19
|
+
* upgrade keep working; they are transparently upgraded to v2 on the next save.
|
|
20
|
+
*
|
|
21
|
+
* Legacy plaintext values (no prefix) are returned as-is on decrypt.
|
|
15
22
|
*/
|
|
16
|
-
const
|
|
23
|
+
const PREFIX_V2 = "enc:v2:"
|
|
24
|
+
const PREFIX_V1 = "enc:v1:"
|
|
25
|
+
|
|
26
|
+
// scrypt cost parameters. N=2^14 with r=8 needs ~16MB (128*N*r), which stays
|
|
27
|
+
// under Node's default 32MB scrypt memory limit while imposing a meaningful
|
|
28
|
+
// per-derivation cost. Derived keys are cached, so this runs at most once per
|
|
29
|
+
// (env key, salt) pair rather than on every encrypt/decrypt.
|
|
30
|
+
const SCRYPT_N = 16384
|
|
31
|
+
const SCRYPT_R = 8
|
|
32
|
+
const SCRYPT_P = 1
|
|
33
|
+
const KEY_LEN = 32
|
|
34
|
+
const SALT_LEN = 16
|
|
17
35
|
|
|
18
36
|
let cachedRaw: string | undefined
|
|
19
|
-
let
|
|
37
|
+
let cachedRawKey: string | null = null
|
|
38
|
+
let legacyV1Key: Buffer | null = null
|
|
39
|
+
const v2KeyCache = new Map<string, Buffer>()
|
|
20
40
|
|
|
21
41
|
/**
|
|
22
42
|
* `@types/node` 22.0-22.6 typed `Buffer` as `Buffer<ArrayBufferLike>`, which is
|
|
@@ -27,41 +47,75 @@ let cachedKey: Buffer | null = null
|
|
|
27
47
|
*/
|
|
28
48
|
const bytes = (view: Buffer): Uint8Array => view as unknown as Uint8Array
|
|
29
49
|
|
|
30
|
-
|
|
50
|
+
/** The raw env key (trimmed), or null when encryption is disabled. */
|
|
51
|
+
function getRawKey(): string | null {
|
|
31
52
|
const raw = (process.env.PAYPAL_ENCRYPTION_KEY || "").trim()
|
|
32
53
|
if (raw !== cachedRaw) {
|
|
33
54
|
cachedRaw = raw
|
|
34
|
-
|
|
35
|
-
|
|
55
|
+
cachedRawKey = raw || null
|
|
56
|
+
// The unsalted SHA-256 key is only needed to decrypt legacy v1 values.
|
|
57
|
+
legacyV1Key = raw ? createHash("sha256").update(raw).digest() : null
|
|
58
|
+
v2KeyCache.clear()
|
|
59
|
+
}
|
|
60
|
+
return cachedRawKey
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Derive (and cache) the AES key for a v2 secret from the env key + salt. */
|
|
64
|
+
function deriveV2Key(raw: string, salt: Buffer): Buffer {
|
|
65
|
+
const cacheKey = `${raw}::${salt.toString("base64")}`
|
|
66
|
+
let key = v2KeyCache.get(cacheKey)
|
|
67
|
+
if (!key) {
|
|
68
|
+
key = scryptSync(raw, bytes(salt), KEY_LEN, {
|
|
69
|
+
N: SCRYPT_N,
|
|
70
|
+
r: SCRYPT_R,
|
|
71
|
+
p: SCRYPT_P,
|
|
72
|
+
})
|
|
73
|
+
v2KeyCache.set(cacheKey, key)
|
|
36
74
|
}
|
|
37
|
-
return
|
|
75
|
+
return key
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function gcmDecrypt(key: Buffer, ivB64: string, tagB64: string, ctB64: string): string {
|
|
79
|
+
const decipher = createDecipheriv("aes-256-gcm", bytes(key), bytes(Buffer.from(ivB64, "base64")))
|
|
80
|
+
decipher.setAuthTag(bytes(Buffer.from(tagB64, "base64")))
|
|
81
|
+
const plaintext = Buffer.concat([
|
|
82
|
+
bytes(decipher.update(bytes(Buffer.from(ctB64, "base64")))),
|
|
83
|
+
bytes(decipher.final()),
|
|
84
|
+
])
|
|
85
|
+
return plaintext.toString("utf8")
|
|
38
86
|
}
|
|
39
87
|
|
|
40
88
|
export function isSecretEncryptionEnabled(): boolean {
|
|
41
|
-
return
|
|
89
|
+
return getRawKey() !== null
|
|
42
90
|
}
|
|
43
91
|
|
|
44
92
|
export function isEncrypted(value: unknown): boolean {
|
|
45
|
-
return
|
|
93
|
+
return (
|
|
94
|
+
typeof value === "string" &&
|
|
95
|
+
(value.startsWith(PREFIX_V2) || value.startsWith(PREFIX_V1))
|
|
96
|
+
)
|
|
46
97
|
}
|
|
47
98
|
|
|
48
99
|
/**
|
|
49
100
|
* Encrypt a secret for storage. Returns the input unchanged when no key is
|
|
50
|
-
* configured or when the value is empty/already encrypted.
|
|
101
|
+
* configured or when the value is empty/already encrypted. Always writes the
|
|
102
|
+
* current (v2) format.
|
|
51
103
|
*/
|
|
52
104
|
export function encryptSecret<T extends string | null | undefined>(value: T): T | string {
|
|
53
105
|
if (!value) {
|
|
54
106
|
return value
|
|
55
107
|
}
|
|
56
|
-
const
|
|
57
|
-
if (!
|
|
108
|
+
const raw = getRawKey()
|
|
109
|
+
if (!raw || isEncrypted(value)) {
|
|
58
110
|
return value
|
|
59
111
|
}
|
|
112
|
+
const salt = randomBytes(SALT_LEN)
|
|
113
|
+
const key = deriveV2Key(raw, salt)
|
|
60
114
|
const iv = randomBytes(12)
|
|
61
115
|
const cipher = createCipheriv("aes-256-gcm", bytes(key), bytes(iv))
|
|
62
116
|
const ciphertext = Buffer.concat([bytes(cipher.update(String(value), "utf8")), bytes(cipher.final())])
|
|
63
117
|
const tag = cipher.getAuthTag()
|
|
64
|
-
return `${
|
|
118
|
+
return `${PREFIX_V2}${salt.toString("base64")}:${iv.toString("base64")}:${tag.toString("base64")}:${ciphertext.toString("base64")}`
|
|
65
119
|
}
|
|
66
120
|
|
|
67
121
|
/**
|
|
@@ -73,22 +127,31 @@ export function decryptSecret<T extends string | null | undefined>(value: T): T
|
|
|
73
127
|
if (!value || !isEncrypted(value)) {
|
|
74
128
|
return value
|
|
75
129
|
}
|
|
76
|
-
const
|
|
77
|
-
if (!
|
|
130
|
+
const raw = getRawKey()
|
|
131
|
+
if (!raw) {
|
|
78
132
|
throw new Error(
|
|
79
133
|
"PayPal secret is encrypted but PAYPAL_ENCRYPTION_KEY is not set. Restore the key to decrypt stored credentials."
|
|
80
134
|
)
|
|
81
135
|
}
|
|
82
|
-
|
|
136
|
+
|
|
137
|
+
if (String(value).startsWith(PREFIX_V2)) {
|
|
138
|
+
const body = String(value).slice(PREFIX_V2.length)
|
|
139
|
+
const [saltB64, ivB64, tagB64, ctB64] = body.split(":")
|
|
140
|
+
if (!saltB64 || !ivB64 || !tagB64 || !ctB64) {
|
|
141
|
+
throw new Error("PayPal secret ciphertext is malformed.")
|
|
142
|
+
}
|
|
143
|
+
const key = deriveV2Key(raw, Buffer.from(saltB64, "base64"))
|
|
144
|
+
return gcmDecrypt(key, ivB64, tagB64, ctB64)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Legacy v1 (unsalted SHA-256 key).
|
|
148
|
+
const body = String(value).slice(PREFIX_V1.length)
|
|
83
149
|
const [ivB64, tagB64, ctB64] = body.split(":")
|
|
84
150
|
if (!ivB64 || !tagB64 || !ctB64) {
|
|
85
151
|
throw new Error("PayPal secret ciphertext is malformed.")
|
|
86
152
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
bytes(decipher.final()),
|
|
92
|
-
])
|
|
93
|
-
return plaintext.toString("utf8")
|
|
153
|
+
if (!legacyV1Key) {
|
|
154
|
+
throw new Error("PayPal secret is encrypted but PAYPAL_ENCRYPTION_KEY is not set.")
|
|
155
|
+
}
|
|
156
|
+
return gcmDecrypt(legacyV1Key, ivB64, tagB64, ctB64)
|
|
94
157
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for calling PayPal's `verify-webhook-signature` API correctly.
|
|
3
|
+
*
|
|
4
|
+
* PayPal computes the expected signature over the EXACT bytes it transmitted.
|
|
5
|
+
* Re-serializing the parsed body (`JSON.stringify(req.body)`) can change those
|
|
6
|
+
* bytes — key ordering, non-ASCII escaping (`José` vs `José`), number and
|
|
7
|
+
* whitespace formatting — which makes verification intermittently return
|
|
8
|
+
* FAILURE for legitimate webhooks. When the original raw body is available we
|
|
9
|
+
* inject it verbatim as `webhook_event`; otherwise we fall back to serializing
|
|
10
|
+
* the parsed body so behavior never regresses.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Normalize Medusa's `req.rawBody` (a Buffer when `preserveRawBody` is set) to a string. */
|
|
14
|
+
export function rawBodyToString(rawBody: unknown): string | null {
|
|
15
|
+
if (rawBody == null) {
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
if (typeof rawBody === "string") {
|
|
19
|
+
return rawBody.length > 0 ? rawBody : null
|
|
20
|
+
}
|
|
21
|
+
if (Buffer.isBuffer(rawBody)) {
|
|
22
|
+
return rawBody.length > 0 ? rawBody.toString("utf8") : null
|
|
23
|
+
}
|
|
24
|
+
if (rawBody instanceof Uint8Array) {
|
|
25
|
+
return rawBody.length > 0 ? Buffer.from(rawBody).toString("utf8") : null
|
|
26
|
+
}
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Compose the JSON request body for `verify-webhook-signature`.
|
|
32
|
+
*
|
|
33
|
+
* `fields` are the transmission headers + webhook_id; `webhookEventJson` is the
|
|
34
|
+
* raw webhook body verbatim (preferred) or a serialized fallback. Undefined /
|
|
35
|
+
* null header values are omitted. The webhook event is concatenated as raw JSON
|
|
36
|
+
* (not re-encoded) so the bytes PayPal hashes match what it signed.
|
|
37
|
+
*/
|
|
38
|
+
export function composeVerifyRequestBody(
|
|
39
|
+
fields: Record<string, string | undefined | null>,
|
|
40
|
+
webhookEventJson: string
|
|
41
|
+
): string {
|
|
42
|
+
const head = Object.entries(fields)
|
|
43
|
+
.filter(([, v]) => v !== undefined && v !== null)
|
|
44
|
+
.map(([k, v]) => `${JSON.stringify(k)}:${JSON.stringify(v)}`)
|
|
45
|
+
.join(",")
|
|
46
|
+
return `{${head}${head ? "," : ""}"webhook_event":${webhookEventJson}}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Pick the bytes to use for `webhook_event`: the raw body when present and
|
|
51
|
+
* non-blank, otherwise a JSON serialization of the parsed body.
|
|
52
|
+
*/
|
|
53
|
+
export function resolveWebhookEventJson(
|
|
54
|
+
rawBody: string | null | undefined,
|
|
55
|
+
parsedBody: unknown
|
|
56
|
+
): string {
|
|
57
|
+
if (typeof rawBody === "string" && rawBody.trim().length > 0) {
|
|
58
|
+
return rawBody
|
|
59
|
+
}
|
|
60
|
+
return JSON.stringify(parsedBody ?? {})
|
|
61
|
+
}
|