@easypayment/medusa-payment-paypal 0.9.16 → 0.9.18
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 +59 -32
- package/.medusa/server/src/admin/index.mjs +59 -32
- 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 +64 -2
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.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/capture-order/route.d.ts.map +1 -1
- package/.medusa/server/src/api/store/paypal/capture-order/route.js +8 -58
- 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 +17 -43
- 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 +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 +12 -1
- package/.medusa/server/src/modules/paypal/service.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/service.js +14 -3
- package/.medusa/server/src/modules/paypal/service.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/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 +68 -12
- package/src/api/middlewares.ts +9 -0
- package/src/api/store/paypal/capture-order/route.ts +8 -71
- package/src/api/store/paypal/create-order/route.ts +25 -47
- 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 +17 -3
- package/src/modules/paypal/utils/payment-session.ts +104 -0
- package/src/modules/paypal/utils/secret-crypto.ts +90 -27
- package/src/modules/paypal/utils/webhook-verify.ts +61 -0
|
@@ -10,7 +10,11 @@ import {
|
|
|
10
10
|
import { getPayPalApiBase } from "../../../../modules/paypal/utils/paypal-auth"
|
|
11
11
|
import { paypalFetch } from "../../../../modules/paypal/utils/paypal-fetch"
|
|
12
12
|
import type PayPalModuleService from "../../../../modules/paypal/service"
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
findPayPalSessionForCart,
|
|
15
|
+
getStoredPayPalOrderId,
|
|
16
|
+
updatePayPalSessionData,
|
|
17
|
+
} from "../../../../modules/paypal/utils/payment-session"
|
|
14
18
|
|
|
15
19
|
const BN_CODE = "MBJTechnolabs_SI_SPB"
|
|
16
20
|
|
|
@@ -32,61 +36,35 @@ function resolveIdempotencyKey(req: MedusaRequest, suffix: string, fallback: str
|
|
|
32
36
|
return fallback || `pp-${suffix}-${randomUUID()}`
|
|
33
37
|
}
|
|
34
38
|
|
|
39
|
+
// Persist the PayPal order id onto the cart's PayPal payment session so that
|
|
40
|
+
// capture-order can bind the capture to the order this cart created. Resolved
|
|
41
|
+
// through the Payment module service (the previous direct-container resolve of
|
|
42
|
+
// "payment_collection"/"payment_session" did not exist in the request scope, so
|
|
43
|
+
// the id was silently never stored and every capture failed fail-closed).
|
|
35
44
|
async function attachPayPalOrderToSession(
|
|
36
45
|
req: MedusaRequest,
|
|
37
46
|
cartId: string,
|
|
38
47
|
orderId: string
|
|
39
48
|
) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const pc = await paymentCollectionService.retrieveByCartId(cartId).catch(() => null)
|
|
45
|
-
if (!pc?.id) {
|
|
46
|
-
return
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const sessions = await paymentSessionService.list({ payment_collection_id: pc.id })
|
|
50
|
-
const paypalSession = sessions?.find((s: any) => isPayPalProviderId(s.provider_id))
|
|
51
|
-
if (!paypalSession) {
|
|
52
|
-
return
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
await paymentSessionService.update(paypalSession.id, {
|
|
56
|
-
amount: paypalSession.amount,
|
|
57
|
-
data: {
|
|
58
|
-
...(paypalSession.data || {}),
|
|
59
|
-
paypal: {
|
|
60
|
-
...((paypalSession.data || {}).paypal || {}),
|
|
61
|
-
order_id: orderId,
|
|
62
|
-
},
|
|
63
|
-
},
|
|
64
|
-
})
|
|
65
|
-
} catch {
|
|
49
|
+
const session = await findPayPalSessionForCart(cartId, req.scope)
|
|
50
|
+
if (!session) {
|
|
51
|
+
return
|
|
66
52
|
}
|
|
53
|
+
await updatePayPalSessionData(
|
|
54
|
+
session.session_id,
|
|
55
|
+
{
|
|
56
|
+
paypal: {
|
|
57
|
+
...(session.session_data.paypal || {}),
|
|
58
|
+
order_id: orderId,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
req.scope
|
|
62
|
+
)
|
|
67
63
|
}
|
|
68
64
|
|
|
69
65
|
async function getExistingPayPalOrderId(req: MedusaRequest, cartId: string) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const paymentSessionService = req.scope.resolve("payment_session") as any
|
|
73
|
-
|
|
74
|
-
const pc = await paymentCollectionService.retrieveByCartId(cartId).catch(() => null)
|
|
75
|
-
if (!pc?.id) {
|
|
76
|
-
return null
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const sessions = await paymentSessionService.list({ payment_collection_id: pc.id })
|
|
80
|
-
const paypalSession = sessions?.find((s: any) => isPayPalProviderId(s.provider_id))
|
|
81
|
-
if (!paypalSession) {
|
|
82
|
-
return null
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const paypalData = (paypalSession.data || {}).paypal || {}
|
|
86
|
-
return paypalData.order_id ? String(paypalData.order_id) : null
|
|
87
|
-
} catch {
|
|
88
|
-
return null
|
|
89
|
-
}
|
|
66
|
+
const session = await findPayPalSessionForCart(cartId, req.scope)
|
|
67
|
+
return getStoredPayPalOrderId(session?.session_data)
|
|
90
68
|
}
|
|
91
69
|
|
|
92
70
|
function resolveReturnUrl(req: MedusaRequest) {
|
|
@@ -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
|
+
}
|
|
@@ -899,7 +899,9 @@ class PayPalModuleService extends MedusaService({
|
|
|
899
899
|
console.warn("[PayPal] saveAndHydrateSellerCredentials lookup failed:", e?.message || e)
|
|
900
900
|
}
|
|
901
901
|
|
|
902
|
-
|
|
902
|
+
// This is a write context (credentials were just saved), so it is allowed to
|
|
903
|
+
// backfill the seller profile if it is still missing.
|
|
904
|
+
return await this.getStatus(env, { hydrateMissingProfile: true })
|
|
903
905
|
}
|
|
904
906
|
|
|
905
907
|
private async resolveWebhookUrl() {
|
|
@@ -1028,7 +1030,19 @@ class PayPalModuleService extends MedusaService({
|
|
|
1028
1030
|
)}`
|
|
1029
1031
|
}
|
|
1030
1032
|
|
|
1031
|
-
|
|
1033
|
+
/**
|
|
1034
|
+
* Report the current connection status.
|
|
1035
|
+
*
|
|
1036
|
+
* `hydrateMissingProfile` is opt-in and OFF by default: status is read on
|
|
1037
|
+
* `GET /admin/paypal/status` (and other read-only routes), where it must be a
|
|
1038
|
+
* safe, side-effect-free read. The seller-profile backfill makes an outbound
|
|
1039
|
+
* PayPal call and persists to the DB, so it only runs from write contexts
|
|
1040
|
+
* (e.g. saving credentials) that explicitly request it.
|
|
1041
|
+
*/
|
|
1042
|
+
async getStatus(
|
|
1043
|
+
envOverride?: Environment,
|
|
1044
|
+
opts: { hydrateMissingProfile?: boolean } = {}
|
|
1045
|
+
) {
|
|
1032
1046
|
const row = await this.getCurrentRow()
|
|
1033
1047
|
const env = envOverride ?? (await this.getCurrentEnvironment())
|
|
1034
1048
|
|
|
@@ -1041,7 +1055,7 @@ class PayPalModuleService extends MedusaService({
|
|
|
1041
1055
|
let sellerEmail: string | null = c.sellerEmail || row.seller_email || null
|
|
1042
1056
|
let sellerMerchantId: string | null = c.sellerMerchantId || row.seller_merchant_id || null
|
|
1043
1057
|
|
|
1044
|
-
if (!sellerEmail && hasCreds) {
|
|
1058
|
+
if (!sellerEmail && hasCreds && opts.hydrateMissingProfile) {
|
|
1045
1059
|
try {
|
|
1046
1060
|
const hydrated = await this.fetchSellerProfileFromDirectCredentials(env)
|
|
1047
1061
|
if (hydrated.sellerEmail || hydrated.sellerMerchantId) {
|
|
@@ -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
|
+
}
|