@easypayment/medusa-paypal 0.4.7 → 0.4.8
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 +7 -7
- package/.medusa/server/src/admin/index.mjs +7 -7
- 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 +62 -139
- package/.medusa/server/src/api/store/paypal/create-order/route.js.map +1 -1
- package/.medusa/server/src/modules/paypal/migrations/20260115120000_create_paypal_connection.js +22 -22
- package/.medusa/server/src/modules/paypal/migrations/20260123090000_create_paypal_settings.js +11 -11
- package/.medusa/server/src/modules/paypal/migrations/20260201090000_create_paypal_webhook_event.js +18 -18
- package/.medusa/server/src/modules/paypal/migrations/20260401090000_create_paypal_metric.js +16 -16
- package/.medusa/server/src/modules/paypal/migrations/20260701090000_add_paypal_webhook_event_processing.js +20 -20
- package/.medusa/server/src/modules/paypal/migrations/20261101090000_remove_paypal_reconciliation_status.js +14 -14
- package/.medusa/server/src/modules/paypal/migrations/20261201090000_remove_paypal_audit_log.js +15 -15
- package/README.md +142 -142
- package/package.json +75 -75
- package/src/admin/index.ts +7 -7
- package/src/admin/routes/settings/paypal/_components/Tabs.tsx +52 -52
- package/src/admin/routes/settings/paypal/_components/Toast.tsx +51 -51
- package/src/admin/routes/settings/paypal/additional-settings/page.tsx +200 -200
- package/src/admin/routes/settings/paypal/advanced-card-payments/page.tsx +183 -183
- package/src/admin/routes/settings/paypal/apple-pay/page.tsx +5 -5
- package/src/admin/routes/settings/paypal/connection/page.tsx +754 -754
- package/src/admin/routes/settings/paypal/google-pay/page.tsx +5 -5
- package/src/admin/routes/settings/paypal/pay-later-messaging/page.tsx +5 -5
- package/src/admin/routes/settings/paypal/paypal-settings/page.tsx +376 -376
- package/src/api/admin/payment-collections/[id]/payment-sessions/route.ts +24 -24
- package/src/api/admin/paypal/disconnect/route.ts +8 -8
- package/src/api/admin/paypal/environment/route.ts +25 -25
- package/src/api/admin/paypal/onboard-complete/route.ts +44 -44
- package/src/api/admin/paypal/onboarding-link/route.ts +45 -45
- package/src/api/admin/paypal/onboarding-status/route.ts +18 -18
- package/src/api/admin/paypal/rotate-credentials/route.ts +8 -8
- package/src/api/admin/paypal/save-credentials/route.ts +14 -14
- package/src/api/admin/paypal/settings/route.ts +14 -14
- package/src/api/admin/paypal/status/route.ts +12 -12
- package/src/api/store/payment-collections/[id]/payment-sessions/route.ts +65 -65
- package/src/api/store/paypal/capture-order/route.ts +276 -276
- package/src/api/store/paypal/config/route.ts +102 -102
- package/src/api/store/paypal/create-order/route.ts +77 -176
- package/src/api/store/paypal/settings/route.ts +19 -19
- package/src/api/store/paypal/webhook/route.ts +246 -246
- package/src/api/store/paypal-complete/route.ts +75 -75
- package/src/jobs/paypal-reconcile.ts +112 -112
- package/src/jobs/paypal-webhook-retry.ts +85 -85
- package/src/modules/paypal/clients/paypal-seller.client.ts +59 -59
- package/src/modules/paypal/index.ts +8 -8
- package/src/modules/paypal/migrations/20260115120000_create_paypal_connection.ts +33 -33
- package/src/modules/paypal/migrations/20260123090000_create_paypal_settings.ts +22 -22
- package/src/modules/paypal/migrations/20260201090000_create_paypal_webhook_event.ts +29 -29
- package/src/modules/paypal/migrations/20260401090000_create_paypal_metric.ts +27 -27
- package/src/modules/paypal/migrations/20260701090000_add_paypal_webhook_event_processing.ts +31 -31
- package/src/modules/paypal/migrations/20261101090000_remove_paypal_reconciliation_status.ts +25 -25
- package/src/modules/paypal/migrations/20261201090000_remove_paypal_audit_log.ts +26 -26
- package/src/modules/paypal/migrations/20270101090000_set_paypal_environment_default_live.ts +11 -11
- package/src/modules/paypal/models/paypal_connection.ts +21 -21
- package/src/modules/paypal/models/paypal_metric.ts +9 -9
- package/src/modules/paypal/models/paypal_settings.ts +8 -8
- package/src/modules/paypal/models/paypal_webhook_event.ts +19 -19
- package/src/modules/paypal/payment-provider/README.md +22 -22
- package/src/modules/paypal/payment-provider/card-service.ts +760 -760
- package/src/modules/paypal/payment-provider/index.ts +19 -19
- package/src/modules/paypal/payment-provider/service.ts +1121 -1121
- package/src/modules/paypal/payment-provider/webhook-utils.ts +88 -88
- package/src/modules/paypal/service.ts +1247 -1247
- package/src/modules/paypal/types/config.ts +47 -47
- package/src/modules/paypal/utils/amounts.ts +41 -41
- package/src/modules/paypal/utils/crypto.ts +51 -51
- package/src/modules/paypal/utils/currencies.ts +84 -84
- package/src/modules/paypal/utils/paypal-auth.ts +32 -32
- package/src/modules/paypal/utils/provider-ids.ts +15 -15
- package/src/modules/paypal/webhook-processor.ts +215 -215
|
@@ -1,246 +1,246 @@
|
|
|
1
|
-
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
|
2
|
-
import type PayPalModuleService from "../../../../modules/paypal/service"
|
|
3
|
-
import {
|
|
4
|
-
computeNextRetryAt,
|
|
5
|
-
isAllowedEventType,
|
|
6
|
-
normalizeEventVersion,
|
|
7
|
-
processPayPalWebhookEvent,
|
|
8
|
-
} from "../../../../modules/paypal/webhook-processor"
|
|
9
|
-
|
|
10
|
-
const REPLAY_WINDOW_MINUTES = (() => {
|
|
11
|
-
const configured = Number(process.env.PAYPAL_WEBHOOK_REPLAY_WINDOW_MINUTES)
|
|
12
|
-
return Number.isFinite(configured) ? configured : 60
|
|
13
|
-
})()
|
|
14
|
-
|
|
15
|
-
function getHeader(headers: Record<string, string | string[] | undefined>, name: string) {
|
|
16
|
-
const direct = headers[name]
|
|
17
|
-
if (Array.isArray(direct)) {
|
|
18
|
-
return direct[0]
|
|
19
|
-
}
|
|
20
|
-
if (typeof direct === "string") {
|
|
21
|
-
return direct
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const needle = name.toLowerCase()
|
|
25
|
-
const key = Object.keys(headers).find((header) => header.toLowerCase() === needle)
|
|
26
|
-
const value = key ? headers[key] : undefined
|
|
27
|
-
if (Array.isArray(value)) {
|
|
28
|
-
return value[0]
|
|
29
|
-
}
|
|
30
|
-
return value
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function isReplay(headers: Record<string, string | string[] | undefined>) {
|
|
34
|
-
const transmissionTime = getHeader(headers, "paypal-transmission-time")
|
|
35
|
-
if (!transmissionTime) {
|
|
36
|
-
throw new Error("Missing PayPal transmission time header")
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const parsed = Date.parse(transmissionTime)
|
|
40
|
-
if (!Number.isFinite(parsed)) {
|
|
41
|
-
throw new Error("Invalid PayPal transmission time header")
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const deltaMs = Math.abs(Date.now() - parsed)
|
|
45
|
-
return deltaMs > REPLAY_WINDOW_MINUTES * 60 * 1000
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function resolveWebhookId(
|
|
49
|
-
environment: string,
|
|
50
|
-
settings?: Record<string, unknown>
|
|
51
|
-
) {
|
|
52
|
-
const ids = (settings?.webhook_ids || {}) as Record<string, string | undefined>
|
|
53
|
-
const legacyLive = settings?.webhook_id_live as string | undefined
|
|
54
|
-
const legacySandbox = settings?.webhook_id_sandbox as string | undefined
|
|
55
|
-
|
|
56
|
-
if (environment === "live") {
|
|
57
|
-
return ids.live || legacyLive || process.env.PAYPAL_WEBHOOK_ID_LIVE
|
|
58
|
-
}
|
|
59
|
-
return ids.sandbox || legacySandbox || process.env.PAYPAL_WEBHOOK_ID_SANDBOX
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
async function verifyWebhookSignature(
|
|
63
|
-
paypal: PayPalModuleService,
|
|
64
|
-
environment: string,
|
|
65
|
-
body: Record<string, unknown>,
|
|
66
|
-
headers: Record<string, string | string[] | undefined>
|
|
67
|
-
) {
|
|
68
|
-
if (isReplay(headers)) {
|
|
69
|
-
throw new Error("PayPal webhook replay protection triggered")
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const settings = await paypal.getSettings().catch(() => ({ data: {} }))
|
|
73
|
-
const webhookId = resolveWebhookId(
|
|
74
|
-
environment,
|
|
75
|
-
(settings?.data as Record<string, unknown>) || {}
|
|
76
|
-
)
|
|
77
|
-
if (!webhookId) {
|
|
78
|
-
throw new Error(`Missing PayPal webhook ID for environment "${environment}"`)
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const base =
|
|
82
|
-
environment === "live"
|
|
83
|
-
? "https://api-m.paypal.com"
|
|
84
|
-
: "https://api-m.sandbox.paypal.com"
|
|
85
|
-
const accessToken = await paypal.getAppAccessToken()
|
|
86
|
-
|
|
87
|
-
const verifyPayload = {
|
|
88
|
-
auth_algo: getHeader(headers, "paypal-auth-algo"),
|
|
89
|
-
cert_url: getHeader(headers, "paypal-cert-url"),
|
|
90
|
-
transmission_id: getHeader(headers, "paypal-transmission-id"),
|
|
91
|
-
transmission_sig: getHeader(headers, "paypal-transmission-sig"),
|
|
92
|
-
transmission_time: getHeader(headers, "paypal-transmission-time"),
|
|
93
|
-
webhook_id: webhookId,
|
|
94
|
-
webhook_event: body,
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const requiredHeaders = [
|
|
98
|
-
verifyPayload.auth_algo,
|
|
99
|
-
verifyPayload.cert_url,
|
|
100
|
-
verifyPayload.transmission_id,
|
|
101
|
-
verifyPayload.transmission_sig,
|
|
102
|
-
verifyPayload.transmission_time,
|
|
103
|
-
]
|
|
104
|
-
if (requiredHeaders.some((value) => !value)) {
|
|
105
|
-
throw new Error("Missing required PayPal webhook signature headers")
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const resp = await fetch(`${base}/v1/notifications/verify-webhook-signature`, {
|
|
109
|
-
method: "POST",
|
|
110
|
-
headers: {
|
|
111
|
-
Authorization: `Bearer ${accessToken}`,
|
|
112
|
-
"Content-Type": "application/json",
|
|
113
|
-
},
|
|
114
|
-
body: JSON.stringify(verifyPayload),
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
const json = await resp.json().catch(() => ({}))
|
|
118
|
-
if (!resp.ok || json?.verification_status !== "VERIFIED") {
|
|
119
|
-
const debugId = resp.headers.get("paypal-debug-id") || json?.debug_id
|
|
120
|
-
throw new Error(
|
|
121
|
-
`PayPal webhook verification failed (${resp.status}): ${JSON.stringify(json)}${
|
|
122
|
-
debugId ? ` debug_id=${debugId}` : ""
|
|
123
|
-
}`
|
|
124
|
-
)
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
|
129
|
-
const paypal = req.scope.resolve<PayPalModuleService>("paypal_onboarding")
|
|
130
|
-
|
|
131
|
-
try {
|
|
132
|
-
const payload = (req.body || {}) as Record<string, any>
|
|
133
|
-
const eventId = String(payload?.id || payload?.event_id || "")
|
|
134
|
-
const eventType = String(payload?.event_type || payload?.eventType || "")
|
|
135
|
-
|
|
136
|
-
if (!eventId || !eventType) {
|
|
137
|
-
return res.status(400).json({ message: "Missing PayPal event id or type" })
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const creds = await paypal.getActiveCredentials()
|
|
141
|
-
await verifyWebhookSignature(paypal, creds.environment, payload, req.headers)
|
|
142
|
-
const transmissionId = getHeader(req.headers, "paypal-transmission-id") || null
|
|
143
|
-
const transmissionTimeHeader = getHeader(req.headers, "paypal-transmission-time")
|
|
144
|
-
const transmissionTime = transmissionTimeHeader ? new Date(transmissionTimeHeader) : null
|
|
145
|
-
const eventVersion = normalizeEventVersion(payload)
|
|
146
|
-
|
|
147
|
-
if (transmissionId) {
|
|
148
|
-
const existingByTransmission = await paypal.listPayPalWebhookEvents({
|
|
149
|
-
transmission_id: transmissionId,
|
|
150
|
-
})
|
|
151
|
-
if ((existingByTransmission || []).length > 0) {
|
|
152
|
-
return res.json({ ok: true, duplicate: true })
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const recordResult = await paypal.createWebhookEventRecord({
|
|
157
|
-
event_id: eventId,
|
|
158
|
-
event_type: eventType,
|
|
159
|
-
payload,
|
|
160
|
-
event_version: eventVersion,
|
|
161
|
-
transmission_id: transmissionId,
|
|
162
|
-
transmission_time: transmissionTime,
|
|
163
|
-
status: "processing",
|
|
164
|
-
attempt_count: 1,
|
|
165
|
-
})
|
|
166
|
-
if (!recordResult.created) {
|
|
167
|
-
return res.json({ ok: true, duplicate: true })
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
if (!isAllowedEventType(eventType)) {
|
|
171
|
-
await paypal.recordAuditEvent("webhook_unsupported_event", {
|
|
172
|
-
event_id: eventId,
|
|
173
|
-
event_type: eventType,
|
|
174
|
-
})
|
|
175
|
-
if (recordResult.event?.id) {
|
|
176
|
-
await paypal.updateWebhookEventRecord({
|
|
177
|
-
id: recordResult.event.id,
|
|
178
|
-
status: "ignored",
|
|
179
|
-
processed_at: new Date(),
|
|
180
|
-
})
|
|
181
|
-
}
|
|
182
|
-
return res.json({ ok: true, ignored: true })
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const processed = await processPayPalWebhookEvent(req.scope, {
|
|
186
|
-
eventType,
|
|
187
|
-
payload,
|
|
188
|
-
})
|
|
189
|
-
|
|
190
|
-
if (recordResult.event?.id) {
|
|
191
|
-
await paypal.updateWebhookEventRecord({
|
|
192
|
-
id: recordResult.event.id,
|
|
193
|
-
status: "processed",
|
|
194
|
-
processed_at: new Date(),
|
|
195
|
-
resource_id: processed.refundId || processed.captureId || processed.orderId || null,
|
|
196
|
-
})
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
console.info("[PayPal] webhook", {
|
|
200
|
-
event_id: eventId,
|
|
201
|
-
event_type: eventType,
|
|
202
|
-
order_id: processed.orderId,
|
|
203
|
-
capture_id: processed.captureId,
|
|
204
|
-
refund_id: processed.refundId,
|
|
205
|
-
cart_id: processed.cartId,
|
|
206
|
-
})
|
|
207
|
-
|
|
208
|
-
try {
|
|
209
|
-
await paypal.recordMetric("webhook_success")
|
|
210
|
-
} catch {
|
|
211
|
-
// ignore metrics failures
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
return res.json({ ok: true })
|
|
215
|
-
} catch (e: any) {
|
|
216
|
-
try {
|
|
217
|
-
const payload = (req.body || {}) as Record<string, any>
|
|
218
|
-
const eventId = String(payload?.id || payload?.event_id || "")
|
|
219
|
-
const eventType = String(payload?.event_type || payload?.eventType || "")
|
|
220
|
-
if (eventId) {
|
|
221
|
-
const existing = await paypal.listPayPalWebhookEvents({ event_id: eventId })
|
|
222
|
-
const record = existing?.[0]
|
|
223
|
-
if (record?.id) {
|
|
224
|
-
const attemptCount = Number(record.attempt_count || 0) + 1
|
|
225
|
-
await paypal.updateWebhookEventRecord({
|
|
226
|
-
id: record.id,
|
|
227
|
-
status: "failed",
|
|
228
|
-
attempt_count: attemptCount,
|
|
229
|
-
next_retry_at: computeNextRetryAt(attemptCount),
|
|
230
|
-
last_error: e?.message || String(e),
|
|
231
|
-
})
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
await paypal.recordAuditEvent("webhook_failed", {
|
|
235
|
-
event_id: payload?.id || payload?.event_id,
|
|
236
|
-
event_type: payload?.event_type || payload?.eventType,
|
|
237
|
-
message: e?.message || String(e),
|
|
238
|
-
})
|
|
239
|
-
await paypal.recordMetric("webhook_failed")
|
|
240
|
-
} catch {
|
|
241
|
-
// ignore audit logging failures
|
|
242
|
-
}
|
|
243
|
-
console.error("[PayPal] webhook error", e?.message || e)
|
|
244
|
-
return res.status(500).json({ message: e?.message || "PayPal webhook error" })
|
|
245
|
-
}
|
|
246
|
-
}
|
|
1
|
+
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
|
2
|
+
import type PayPalModuleService from "../../../../modules/paypal/service"
|
|
3
|
+
import {
|
|
4
|
+
computeNextRetryAt,
|
|
5
|
+
isAllowedEventType,
|
|
6
|
+
normalizeEventVersion,
|
|
7
|
+
processPayPalWebhookEvent,
|
|
8
|
+
} from "../../../../modules/paypal/webhook-processor"
|
|
9
|
+
|
|
10
|
+
const REPLAY_WINDOW_MINUTES = (() => {
|
|
11
|
+
const configured = Number(process.env.PAYPAL_WEBHOOK_REPLAY_WINDOW_MINUTES)
|
|
12
|
+
return Number.isFinite(configured) ? configured : 60
|
|
13
|
+
})()
|
|
14
|
+
|
|
15
|
+
function getHeader(headers: Record<string, string | string[] | undefined>, name: string) {
|
|
16
|
+
const direct = headers[name]
|
|
17
|
+
if (Array.isArray(direct)) {
|
|
18
|
+
return direct[0]
|
|
19
|
+
}
|
|
20
|
+
if (typeof direct === "string") {
|
|
21
|
+
return direct
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const needle = name.toLowerCase()
|
|
25
|
+
const key = Object.keys(headers).find((header) => header.toLowerCase() === needle)
|
|
26
|
+
const value = key ? headers[key] : undefined
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
return value[0]
|
|
29
|
+
}
|
|
30
|
+
return value
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isReplay(headers: Record<string, string | string[] | undefined>) {
|
|
34
|
+
const transmissionTime = getHeader(headers, "paypal-transmission-time")
|
|
35
|
+
if (!transmissionTime) {
|
|
36
|
+
throw new Error("Missing PayPal transmission time header")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const parsed = Date.parse(transmissionTime)
|
|
40
|
+
if (!Number.isFinite(parsed)) {
|
|
41
|
+
throw new Error("Invalid PayPal transmission time header")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const deltaMs = Math.abs(Date.now() - parsed)
|
|
45
|
+
return deltaMs > REPLAY_WINDOW_MINUTES * 60 * 1000
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveWebhookId(
|
|
49
|
+
environment: string,
|
|
50
|
+
settings?: Record<string, unknown>
|
|
51
|
+
) {
|
|
52
|
+
const ids = (settings?.webhook_ids || {}) as Record<string, string | undefined>
|
|
53
|
+
const legacyLive = settings?.webhook_id_live as string | undefined
|
|
54
|
+
const legacySandbox = settings?.webhook_id_sandbox as string | undefined
|
|
55
|
+
|
|
56
|
+
if (environment === "live") {
|
|
57
|
+
return ids.live || legacyLive || process.env.PAYPAL_WEBHOOK_ID_LIVE
|
|
58
|
+
}
|
|
59
|
+
return ids.sandbox || legacySandbox || process.env.PAYPAL_WEBHOOK_ID_SANDBOX
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function verifyWebhookSignature(
|
|
63
|
+
paypal: PayPalModuleService,
|
|
64
|
+
environment: string,
|
|
65
|
+
body: Record<string, unknown>,
|
|
66
|
+
headers: Record<string, string | string[] | undefined>
|
|
67
|
+
) {
|
|
68
|
+
if (isReplay(headers)) {
|
|
69
|
+
throw new Error("PayPal webhook replay protection triggered")
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const settings = await paypal.getSettings().catch(() => ({ data: {} }))
|
|
73
|
+
const webhookId = resolveWebhookId(
|
|
74
|
+
environment,
|
|
75
|
+
(settings?.data as Record<string, unknown>) || {}
|
|
76
|
+
)
|
|
77
|
+
if (!webhookId) {
|
|
78
|
+
throw new Error(`Missing PayPal webhook ID for environment "${environment}"`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const base =
|
|
82
|
+
environment === "live"
|
|
83
|
+
? "https://api-m.paypal.com"
|
|
84
|
+
: "https://api-m.sandbox.paypal.com"
|
|
85
|
+
const accessToken = await paypal.getAppAccessToken()
|
|
86
|
+
|
|
87
|
+
const verifyPayload = {
|
|
88
|
+
auth_algo: getHeader(headers, "paypal-auth-algo"),
|
|
89
|
+
cert_url: getHeader(headers, "paypal-cert-url"),
|
|
90
|
+
transmission_id: getHeader(headers, "paypal-transmission-id"),
|
|
91
|
+
transmission_sig: getHeader(headers, "paypal-transmission-sig"),
|
|
92
|
+
transmission_time: getHeader(headers, "paypal-transmission-time"),
|
|
93
|
+
webhook_id: webhookId,
|
|
94
|
+
webhook_event: body,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const requiredHeaders = [
|
|
98
|
+
verifyPayload.auth_algo,
|
|
99
|
+
verifyPayload.cert_url,
|
|
100
|
+
verifyPayload.transmission_id,
|
|
101
|
+
verifyPayload.transmission_sig,
|
|
102
|
+
verifyPayload.transmission_time,
|
|
103
|
+
]
|
|
104
|
+
if (requiredHeaders.some((value) => !value)) {
|
|
105
|
+
throw new Error("Missing required PayPal webhook signature headers")
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const resp = await fetch(`${base}/v1/notifications/verify-webhook-signature`, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: {
|
|
111
|
+
Authorization: `Bearer ${accessToken}`,
|
|
112
|
+
"Content-Type": "application/json",
|
|
113
|
+
},
|
|
114
|
+
body: JSON.stringify(verifyPayload),
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const json = await resp.json().catch(() => ({}))
|
|
118
|
+
if (!resp.ok || json?.verification_status !== "VERIFIED") {
|
|
119
|
+
const debugId = resp.headers.get("paypal-debug-id") || json?.debug_id
|
|
120
|
+
throw new Error(
|
|
121
|
+
`PayPal webhook verification failed (${resp.status}): ${JSON.stringify(json)}${
|
|
122
|
+
debugId ? ` debug_id=${debugId}` : ""
|
|
123
|
+
}`
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
|
129
|
+
const paypal = req.scope.resolve<PayPalModuleService>("paypal_onboarding")
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const payload = (req.body || {}) as Record<string, any>
|
|
133
|
+
const eventId = String(payload?.id || payload?.event_id || "")
|
|
134
|
+
const eventType = String(payload?.event_type || payload?.eventType || "")
|
|
135
|
+
|
|
136
|
+
if (!eventId || !eventType) {
|
|
137
|
+
return res.status(400).json({ message: "Missing PayPal event id or type" })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const creds = await paypal.getActiveCredentials()
|
|
141
|
+
await verifyWebhookSignature(paypal, creds.environment, payload, req.headers)
|
|
142
|
+
const transmissionId = getHeader(req.headers, "paypal-transmission-id") || null
|
|
143
|
+
const transmissionTimeHeader = getHeader(req.headers, "paypal-transmission-time")
|
|
144
|
+
const transmissionTime = transmissionTimeHeader ? new Date(transmissionTimeHeader) : null
|
|
145
|
+
const eventVersion = normalizeEventVersion(payload)
|
|
146
|
+
|
|
147
|
+
if (transmissionId) {
|
|
148
|
+
const existingByTransmission = await paypal.listPayPalWebhookEvents({
|
|
149
|
+
transmission_id: transmissionId,
|
|
150
|
+
})
|
|
151
|
+
if ((existingByTransmission || []).length > 0) {
|
|
152
|
+
return res.json({ ok: true, duplicate: true })
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const recordResult = await paypal.createWebhookEventRecord({
|
|
157
|
+
event_id: eventId,
|
|
158
|
+
event_type: eventType,
|
|
159
|
+
payload,
|
|
160
|
+
event_version: eventVersion,
|
|
161
|
+
transmission_id: transmissionId,
|
|
162
|
+
transmission_time: transmissionTime,
|
|
163
|
+
status: "processing",
|
|
164
|
+
attempt_count: 1,
|
|
165
|
+
})
|
|
166
|
+
if (!recordResult.created) {
|
|
167
|
+
return res.json({ ok: true, duplicate: true })
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!isAllowedEventType(eventType)) {
|
|
171
|
+
await paypal.recordAuditEvent("webhook_unsupported_event", {
|
|
172
|
+
event_id: eventId,
|
|
173
|
+
event_type: eventType,
|
|
174
|
+
})
|
|
175
|
+
if (recordResult.event?.id) {
|
|
176
|
+
await paypal.updateWebhookEventRecord({
|
|
177
|
+
id: recordResult.event.id,
|
|
178
|
+
status: "ignored",
|
|
179
|
+
processed_at: new Date(),
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
return res.json({ ok: true, ignored: true })
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const processed = await processPayPalWebhookEvent(req.scope, {
|
|
186
|
+
eventType,
|
|
187
|
+
payload,
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
if (recordResult.event?.id) {
|
|
191
|
+
await paypal.updateWebhookEventRecord({
|
|
192
|
+
id: recordResult.event.id,
|
|
193
|
+
status: "processed",
|
|
194
|
+
processed_at: new Date(),
|
|
195
|
+
resource_id: processed.refundId || processed.captureId || processed.orderId || null,
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
console.info("[PayPal] webhook", {
|
|
200
|
+
event_id: eventId,
|
|
201
|
+
event_type: eventType,
|
|
202
|
+
order_id: processed.orderId,
|
|
203
|
+
capture_id: processed.captureId,
|
|
204
|
+
refund_id: processed.refundId,
|
|
205
|
+
cart_id: processed.cartId,
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
await paypal.recordMetric("webhook_success")
|
|
210
|
+
} catch {
|
|
211
|
+
// ignore metrics failures
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return res.json({ ok: true })
|
|
215
|
+
} catch (e: any) {
|
|
216
|
+
try {
|
|
217
|
+
const payload = (req.body || {}) as Record<string, any>
|
|
218
|
+
const eventId = String(payload?.id || payload?.event_id || "")
|
|
219
|
+
const eventType = String(payload?.event_type || payload?.eventType || "")
|
|
220
|
+
if (eventId) {
|
|
221
|
+
const existing = await paypal.listPayPalWebhookEvents({ event_id: eventId })
|
|
222
|
+
const record = existing?.[0]
|
|
223
|
+
if (record?.id) {
|
|
224
|
+
const attemptCount = Number(record.attempt_count || 0) + 1
|
|
225
|
+
await paypal.updateWebhookEventRecord({
|
|
226
|
+
id: record.id,
|
|
227
|
+
status: "failed",
|
|
228
|
+
attempt_count: attemptCount,
|
|
229
|
+
next_retry_at: computeNextRetryAt(attemptCount),
|
|
230
|
+
last_error: e?.message || String(e),
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
await paypal.recordAuditEvent("webhook_failed", {
|
|
235
|
+
event_id: payload?.id || payload?.event_id,
|
|
236
|
+
event_type: payload?.event_type || payload?.eventType,
|
|
237
|
+
message: e?.message || String(e),
|
|
238
|
+
})
|
|
239
|
+
await paypal.recordMetric("webhook_failed")
|
|
240
|
+
} catch {
|
|
241
|
+
// ignore audit logging failures
|
|
242
|
+
}
|
|
243
|
+
console.error("[PayPal] webhook error", e?.message || e)
|
|
244
|
+
return res.status(500).json({ message: e?.message || "PayPal webhook error" })
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -1,76 +1,76 @@
|
|
|
1
|
-
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
|
2
|
-
import type { IPaymentModuleService } from "@medusajs/framework/types"
|
|
3
|
-
import { Modules } from "@medusajs/framework/utils"
|
|
4
|
-
import type PayPalModuleService from "../../../modules/paypal/service"
|
|
5
|
-
|
|
6
|
-
export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
|
7
|
-
const { cart_id } = req.body as { cart_id: string }
|
|
8
|
-
|
|
9
|
-
if (!cart_id) {
|
|
10
|
-
return res.status(400).json({ error: "cart_id is required" })
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
try {
|
|
14
|
-
// Step 1 — read paymentAction from DB settings (same pattern as capture-order)
|
|
15
|
-
const paypal = req.scope.resolve<PayPalModuleService>("paypal_onboarding")
|
|
16
|
-
const settings = await paypal.getSettings().catch(() => ({}))
|
|
17
|
-
const settingsData =
|
|
18
|
-
settings && typeof settings === "object" && "data" in settings
|
|
19
|
-
? ((settings as { data?: Record<string, any> }).data ?? {})
|
|
20
|
-
: {}
|
|
21
|
-
const additionalSettings = (settingsData.additional_settings || {}) as Record<string, any>
|
|
22
|
-
const paymentAction =
|
|
23
|
-
typeof additionalSettings.paymentAction === "string"
|
|
24
|
-
? additionalSettings.paymentAction
|
|
25
|
-
: "capture"
|
|
26
|
-
|
|
27
|
-
// "authorize" mode → session status = "authorized"
|
|
28
|
-
// "capture" mode → session status = "captured"
|
|
29
|
-
const sessionStatus = paymentAction === "authorize" ? "authorized" : "captured"
|
|
30
|
-
const timestampKey = paymentAction === "authorize" ? "authorized_at" : "captured_at"
|
|
31
|
-
|
|
32
|
-
// Step 2 — find the PayPal session for this cart
|
|
33
|
-
const query = req.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.provider_id",
|
|
41
|
-
"payment_collection.payment_sessions.created_at",
|
|
42
|
-
"payment_collection.payment_sessions.amount",
|
|
43
|
-
"payment_collection.payment_sessions.currency_code",
|
|
44
|
-
],
|
|
45
|
-
filters: { id: cart_id },
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
const sessions = carts?.[0]?.payment_collection?.payment_sessions || []
|
|
49
|
-
const session = sessions
|
|
50
|
-
.filter((s: any) => String(s.provider_id || "").includes("paypal"))
|
|
51
|
-
.sort(
|
|
52
|
-
(a: any, b: any) =>
|
|
53
|
-
new Date(b.created_at || 0).getTime() - new Date(a.created_at || 0).getTime()
|
|
54
|
-
)[0]
|
|
55
|
-
|
|
56
|
-
if (!session) {
|
|
57
|
-
return res.status(400).json({ error: "No PayPal payment session found for cart" })
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// Step 3 — update session with correct status + amount
|
|
61
|
-
const paymentModule = req.scope.resolve(Modules.PAYMENT) as IPaymentModuleService
|
|
62
|
-
await (paymentModule as any).updatePaymentSession({
|
|
63
|
-
id: session.id,
|
|
64
|
-
data: { ...(session.data || {}), [timestampKey]: new Date().toISOString() },
|
|
65
|
-
status: sessionStatus as any,
|
|
66
|
-
amount: session.amount,
|
|
67
|
-
currency_code: session.currency_code,
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
console.log(`[paypal-complete] session ${sessionStatus}:`, session.id)
|
|
71
|
-
return res.json({ success: true, session_id: session.id })
|
|
72
|
-
} catch (e: any) {
|
|
73
|
-
console.error("[paypal-complete] error:", e?.message || e)
|
|
74
|
-
return res.status(500).json({ error: e?.message || "Internal error" })
|
|
75
|
-
}
|
|
1
|
+
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
|
2
|
+
import type { IPaymentModuleService } from "@medusajs/framework/types"
|
|
3
|
+
import { Modules } from "@medusajs/framework/utils"
|
|
4
|
+
import type PayPalModuleService from "../../../modules/paypal/service"
|
|
5
|
+
|
|
6
|
+
export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
|
7
|
+
const { cart_id } = req.body as { cart_id: string }
|
|
8
|
+
|
|
9
|
+
if (!cart_id) {
|
|
10
|
+
return res.status(400).json({ error: "cart_id is required" })
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
// Step 1 — read paymentAction from DB settings (same pattern as capture-order)
|
|
15
|
+
const paypal = req.scope.resolve<PayPalModuleService>("paypal_onboarding")
|
|
16
|
+
const settings = await paypal.getSettings().catch(() => ({}))
|
|
17
|
+
const settingsData =
|
|
18
|
+
settings && typeof settings === "object" && "data" in settings
|
|
19
|
+
? ((settings as { data?: Record<string, any> }).data ?? {})
|
|
20
|
+
: {}
|
|
21
|
+
const additionalSettings = (settingsData.additional_settings || {}) as Record<string, any>
|
|
22
|
+
const paymentAction =
|
|
23
|
+
typeof additionalSettings.paymentAction === "string"
|
|
24
|
+
? additionalSettings.paymentAction
|
|
25
|
+
: "capture"
|
|
26
|
+
|
|
27
|
+
// "authorize" mode → session status = "authorized"
|
|
28
|
+
// "capture" mode → session status = "captured"
|
|
29
|
+
const sessionStatus = paymentAction === "authorize" ? "authorized" : "captured"
|
|
30
|
+
const timestampKey = paymentAction === "authorize" ? "authorized_at" : "captured_at"
|
|
31
|
+
|
|
32
|
+
// Step 2 — find the PayPal session for this cart
|
|
33
|
+
const query = req.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.provider_id",
|
|
41
|
+
"payment_collection.payment_sessions.created_at",
|
|
42
|
+
"payment_collection.payment_sessions.amount",
|
|
43
|
+
"payment_collection.payment_sessions.currency_code",
|
|
44
|
+
],
|
|
45
|
+
filters: { id: cart_id },
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const sessions = carts?.[0]?.payment_collection?.payment_sessions || []
|
|
49
|
+
const session = sessions
|
|
50
|
+
.filter((s: any) => String(s.provider_id || "").includes("paypal"))
|
|
51
|
+
.sort(
|
|
52
|
+
(a: any, b: any) =>
|
|
53
|
+
new Date(b.created_at || 0).getTime() - new Date(a.created_at || 0).getTime()
|
|
54
|
+
)[0]
|
|
55
|
+
|
|
56
|
+
if (!session) {
|
|
57
|
+
return res.status(400).json({ error: "No PayPal payment session found for cart" })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Step 3 — update session with correct status + amount
|
|
61
|
+
const paymentModule = req.scope.resolve(Modules.PAYMENT) as IPaymentModuleService
|
|
62
|
+
await (paymentModule as any).updatePaymentSession({
|
|
63
|
+
id: session.id,
|
|
64
|
+
data: { ...(session.data || {}), [timestampKey]: new Date().toISOString() },
|
|
65
|
+
status: sessionStatus as any,
|
|
66
|
+
amount: session.amount,
|
|
67
|
+
currency_code: session.currency_code,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
console.log(`[paypal-complete] session ${sessionStatus}:`, session.id)
|
|
71
|
+
return res.json({ success: true, session_id: session.id })
|
|
72
|
+
} catch (e: any) {
|
|
73
|
+
console.error("[paypal-complete] error:", e?.message || e)
|
|
74
|
+
return res.status(500).json({ error: e?.message || "Internal error" })
|
|
75
|
+
}
|
|
76
76
|
}
|