@easypayment/medusa-paypal 0.6.3 → 0.6.4

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.
Files changed (68) hide show
  1. package/.medusa/server/src/admin/index.js +12 -15
  2. package/.medusa/server/src/admin/index.mjs +12 -15
  3. package/.medusa/server/src/api/store/payment-collections/[id]/payment-sessions/route.d.ts.map +1 -1
  4. package/.medusa/server/src/api/store/payment-collections/[id]/payment-sessions/route.js.map +1 -1
  5. package/.medusa/server/src/api/store/paypal/capture-order/route.d.ts.map +1 -1
  6. package/.medusa/server/src/api/store/paypal/capture-order/route.js +1 -11
  7. package/.medusa/server/src/api/store/paypal/capture-order/route.js.map +1 -1
  8. package/.medusa/server/src/api/store/paypal/create-order/route.d.ts.map +1 -1
  9. package/.medusa/server/src/api/store/paypal/create-order/route.js +0 -9
  10. package/.medusa/server/src/api/store/paypal/create-order/route.js.map +1 -1
  11. package/.medusa/server/src/api/store/paypal/webhook/route.d.ts.map +1 -1
  12. package/.medusa/server/src/api/store/paypal/webhook/route.js +162 -115
  13. package/.medusa/server/src/api/store/paypal/webhook/route.js.map +1 -1
  14. package/.medusa/server/src/api/store/paypal-complete/route.d.ts.map +1 -1
  15. package/.medusa/server/src/api/store/paypal-complete/route.js +0 -6
  16. package/.medusa/server/src/api/store/paypal-complete/route.js.map +1 -1
  17. package/.medusa/server/src/jobs/paypal-webhook-retry.d.ts.map +1 -1
  18. package/.medusa/server/src/jobs/paypal-webhook-retry.js +97 -43
  19. package/.medusa/server/src/jobs/paypal-webhook-retry.js.map +1 -1
  20. package/.medusa/server/src/modules/paypal/migrations/20270201000000_add_webhook_dead_letter.d.ts +6 -0
  21. package/.medusa/server/src/modules/paypal/migrations/20270201000000_add_webhook_dead_letter.d.ts.map +1 -0
  22. package/.medusa/server/src/modules/paypal/migrations/20270201000000_add_webhook_dead_letter.js +20 -0
  23. package/.medusa/server/src/modules/paypal/migrations/20270201000000_add_webhook_dead_letter.js.map +1 -0
  24. package/.medusa/server/src/modules/paypal/payment-provider/service.d.ts.map +1 -1
  25. package/.medusa/server/src/modules/paypal/payment-provider/service.js +0 -42
  26. package/.medusa/server/src/modules/paypal/payment-provider/service.js.map +1 -1
  27. package/.medusa/server/src/modules/paypal/service.d.ts +0 -8
  28. package/.medusa/server/src/modules/paypal/service.d.ts.map +1 -1
  29. package/.medusa/server/src/modules/paypal/service.js +6 -114
  30. package/.medusa/server/src/modules/paypal/service.js.map +1 -1
  31. package/.medusa/server/src/modules/paypal/types/config.d.ts +0 -2
  32. package/.medusa/server/src/modules/paypal/types/config.d.ts.map +1 -1
  33. package/.medusa/server/src/modules/paypal/types/config.js +0 -9
  34. package/.medusa/server/src/modules/paypal/types/config.js.map +1 -1
  35. package/.medusa/server/src/modules/paypal/webhook-processor.d.ts +21 -17
  36. package/.medusa/server/src/modules/paypal/webhook-processor.d.ts.map +1 -1
  37. package/.medusa/server/src/modules/paypal/webhook-processor.js +195 -99
  38. package/.medusa/server/src/modules/paypal/webhook-processor.js.map +1 -1
  39. package/README.md +156 -152
  40. package/package.json +1 -1
  41. package/src/admin/routes/settings/paypal/_components/Tabs.tsx +48 -52
  42. package/src/admin/routes/settings/paypal/paypal-settings/page.tsx +0 -23
  43. package/src/api/store/payment-collections/[id]/payment-sessions/route.ts +56 -65
  44. package/src/api/store/paypal/capture-order/route.ts +266 -276
  45. package/src/api/store/paypal/create-order/route.ts +0 -9
  46. package/src/api/store/paypal/webhook/route.ts +325 -246
  47. package/src/api/store/paypal-complete/route.ts +69 -75
  48. package/src/jobs/paypal-webhook-retry.ts +149 -85
  49. package/src/modules/paypal/migrations/20270201000000_add_webhook_dead_letter.ts +17 -0
  50. package/src/modules/paypal/payment-provider/service.ts +1079 -1121
  51. package/src/modules/paypal/service.ts +6 -127
  52. package/src/modules/paypal/types/config.ts +33 -47
  53. package/src/modules/paypal/webhook-processor.ts +377 -215
  54. package/.medusa/server/src/api/admin/paypal/rotate-credentials/route.d.ts +0 -3
  55. package/.medusa/server/src/api/admin/paypal/rotate-credentials/route.d.ts.map +0 -1
  56. package/.medusa/server/src/api/admin/paypal/rotate-credentials/route.js +0 -9
  57. package/.medusa/server/src/api/admin/paypal/rotate-credentials/route.js.map +0 -1
  58. package/.medusa/server/src/jobs/paypal-reconcile.d.ts +0 -7
  59. package/.medusa/server/src/jobs/paypal-reconcile.d.ts.map +0 -1
  60. package/.medusa/server/src/jobs/paypal-reconcile.js +0 -109
  61. package/.medusa/server/src/jobs/paypal-reconcile.js.map +0 -1
  62. package/.medusa/server/src/modules/paypal/utils/crypto.d.ts +0 -4
  63. package/.medusa/server/src/modules/paypal/utils/crypto.d.ts.map +0 -1
  64. package/.medusa/server/src/modules/paypal/utils/crypto.js +0 -47
  65. package/.medusa/server/src/modules/paypal/utils/crypto.js.map +0 -1
  66. package/src/api/admin/paypal/rotate-credentials/route.ts +0 -8
  67. package/src/jobs/paypal-reconcile.ts +0 -113
  68. package/src/modules/paypal/utils/crypto.ts +0 -51
@@ -1,246 +1,325 @@
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
+ isRetryableError,
7
+ normalizeEventVersion,
8
+ processPayPalWebhookEvent,
9
+ } from "../../../../modules/paypal/webhook-processor"
10
+
11
+ const REPLAY_WINDOW_MINUTES = (() => {
12
+ const v = Number(process.env.PAYPAL_WEBHOOK_REPLAY_WINDOW_MINUTES)
13
+ return Number.isFinite(v) && v > 0 ? v : 60
14
+ })()
15
+
16
+ function getHeader(
17
+ headers: Record<string, string | string[] | undefined>,
18
+ name: string
19
+ ): string | undefined {
20
+ const direct = headers[name]
21
+ if (Array.isArray(direct)) return direct[0]
22
+ if (typeof direct === "string") return direct
23
+ const lower = name.toLowerCase()
24
+ const key = Object.keys(headers).find((h) => h.toLowerCase() === lower)
25
+ if (!key) return undefined
26
+ const val = headers[key]
27
+ return Array.isArray(val) ? val[0] : val
28
+ }
29
+
30
+ interface ValidationFail {
31
+ ok: false
32
+ status: number
33
+ message: string
34
+ }
35
+
36
+ interface ValidationPass {
37
+ ok: true
38
+ eventId: string
39
+ eventType: string
40
+ transmissionId: string | null
41
+ transmissionTime: Date | null
42
+ }
43
+
44
+ function validateRequest(req: MedusaRequest): ValidationFail | ValidationPass {
45
+ const payload = (req.body || {}) as Record<string, any>
46
+ const eventId = String(payload?.id || payload?.event_id || "").trim()
47
+ const eventType = String(payload?.event_type || payload?.eventType || "").trim()
48
+
49
+ if (!eventId || !eventType) {
50
+ return { ok: false, status: 400, message: "Missing required fields: id and event_type" }
51
+ }
52
+
53
+ const transmissionTimeHeader = getHeader(req.headers, "paypal-transmission-time")
54
+ if (!transmissionTimeHeader) {
55
+ return {
56
+ ok: false,
57
+ status: 400,
58
+ message: "Missing required header: paypal-transmission-time",
59
+ }
60
+ }
61
+
62
+ const transmissionMs = Date.parse(transmissionTimeHeader)
63
+ if (!Number.isFinite(transmissionMs)) {
64
+ return {
65
+ ok: false,
66
+ status: 400,
67
+ message: "Invalid paypal-transmission-time header value",
68
+ }
69
+ }
70
+
71
+ const ageMs = Math.abs(Date.now() - transmissionMs)
72
+ if (ageMs > REPLAY_WINDOW_MINUTES * 60 * 1000) {
73
+ return {
74
+ ok: false,
75
+ status: 400,
76
+ message: `Webhook rejected: outside ${REPLAY_WINDOW_MINUTES}-minute replay window`,
77
+ }
78
+ }
79
+
80
+ return {
81
+ ok: true,
82
+ eventId,
83
+ eventType,
84
+ transmissionId: getHeader(req.headers, "paypal-transmission-id") || null,
85
+ transmissionTime: new Date(transmissionMs),
86
+ }
87
+ }
88
+
89
+ function resolveWebhookId(
90
+ environment: string,
91
+ settings: Record<string, unknown>
92
+ ): string | undefined {
93
+ const ids = (settings?.webhook_ids || {}) as Record<string, string | undefined>
94
+ if (environment === "live") {
95
+ return (
96
+ ids.live ||
97
+ (settings?.webhook_id_live as string) ||
98
+ process.env.PAYPAL_WEBHOOK_ID_LIVE
99
+ )
100
+ }
101
+ return (
102
+ ids.sandbox ||
103
+ (settings?.webhook_id_sandbox as string) ||
104
+ process.env.PAYPAL_WEBHOOK_ID_SANDBOX
105
+ )
106
+ }
107
+
108
+ async function verifyWebhookSignature(
109
+ paypal: PayPalModuleService,
110
+ environment: string,
111
+ body: Record<string, unknown>,
112
+ headers: Record<string, string | string[] | undefined>
113
+ ): Promise<void> {
114
+ const settings = await paypal.getSettings().catch(() => ({ data: {} }))
115
+ const webhookId = resolveWebhookId(
116
+ environment,
117
+ (settings?.data as Record<string, unknown>) || {}
118
+ )
119
+
120
+ if (!webhookId) {
121
+ throw new Error(
122
+ `PayPal webhook ID not configured for environment "${environment}". Set PAYPAL_WEBHOOK_ID_${environment.toUpperCase()} or configure it in admin settings.`
123
+ )
124
+ }
125
+
126
+ const base =
127
+ environment === "live"
128
+ ? "https://api-m.paypal.com"
129
+ : "https://api-m.sandbox.paypal.com"
130
+
131
+ const accessToken = await paypal.getAppAccessToken()
132
+
133
+ const verifyPayload = {
134
+ auth_algo: getHeader(headers, "paypal-auth-algo"),
135
+ cert_url: getHeader(headers, "paypal-cert-url"),
136
+ transmission_id: getHeader(headers, "paypal-transmission-id"),
137
+ transmission_sig: getHeader(headers, "paypal-transmission-sig"),
138
+ transmission_time: getHeader(headers, "paypal-transmission-time"),
139
+ webhook_id: webhookId,
140
+ webhook_event: body,
141
+ }
142
+
143
+ const missing = Object.entries(verifyPayload)
144
+ .filter(([k, v]) => k !== "webhook_id" && k !== "webhook_event" && !v)
145
+ .map(([k]) => k)
146
+
147
+ if (missing.length > 0) {
148
+ throw new Error(`Missing required PayPal webhook headers: ${missing.join(", ")}`)
149
+ }
150
+
151
+ const resp = await fetch(`${base}/v1/notifications/verify-webhook-signature`, {
152
+ method: "POST",
153
+ headers: {
154
+ Authorization: `Bearer ${accessToken}`,
155
+ "Content-Type": "application/json",
156
+ },
157
+ body: JSON.stringify(verifyPayload),
158
+ })
159
+
160
+ const json = await resp.json().catch(() => ({}))
161
+ const debugId = resp.headers.get("paypal-debug-id") || json?.debug_id
162
+
163
+ if (!resp.ok) {
164
+ throw new Error(
165
+ `PayPal signature verification API error (${resp.status}): ${JSON.stringify(json)}` +
166
+ (debugId ? ` debug_id=${debugId}` : "")
167
+ )
168
+ }
169
+ if (json?.verification_status !== "VERIFIED") {
170
+ throw new Error(
171
+ `PayPal webhook signature not verified. Status: ${json?.verification_status}` +
172
+ (debugId ? ` debug_id=${debugId}` : "")
173
+ )
174
+ }
175
+ }
176
+
177
+ export async function POST(req: MedusaRequest, res: MedusaResponse) {
178
+ const paypal = req.scope.resolve<PayPalModuleService>("paypal_onboarding")
179
+
180
+ const validation = validateRequest(req)
181
+ if (!validation.ok) {
182
+ console.warn("[PayPal] webhook: validation failed:", validation.message)
183
+ return res.status(validation.status).json({ message: validation.message })
184
+ }
185
+
186
+ const { eventId, eventType, transmissionId, transmissionTime } = validation
187
+ const payload = (req.body || {}) as Record<string, any>
188
+
189
+ if (transmissionId) {
190
+ try {
191
+ const existing = await paypal.listPayPalWebhookEvents({ transmission_id: transmissionId })
192
+ if ((existing || []).length > 0) {
193
+ console.info("[PayPal] webhook: duplicate transmission_id", {
194
+ transmissionId,
195
+ eventId,
196
+ })
197
+ return res.json({ ok: true, duplicate: true })
198
+ }
199
+ } catch (e: any) {
200
+ console.warn("[PayPal] webhook: transmission_id dedup check failed:", e?.message)
201
+ }
202
+ }
203
+
204
+ try {
205
+ const creds = await paypal.getActiveCredentials()
206
+ await verifyWebhookSignature(paypal, creds.environment, payload, req.headers)
207
+ } catch (e: any) {
208
+ console.error("[PayPal] webhook: signature verification failed:", e?.message)
209
+ return res
210
+ .status(401)
211
+ .json({ message: e?.message || "Webhook signature verification failed" })
212
+ }
213
+
214
+ const eventVersion = normalizeEventVersion(payload)
215
+ let recordId: string | null = null
216
+
217
+ try {
218
+ const recordResult = await paypal.createWebhookEventRecord({
219
+ event_id: eventId,
220
+ event_type: eventType,
221
+ payload,
222
+ event_version: eventVersion,
223
+ transmission_id: transmissionId,
224
+ transmission_time: transmissionTime,
225
+ status: "processing",
226
+ attempt_count: 1,
227
+ })
228
+
229
+ if (!recordResult.created) {
230
+ console.info("[PayPal] webhook: duplicate event_id", { eventId, eventType })
231
+ return res.json({ ok: true, duplicate: true })
232
+ }
233
+
234
+ recordId = recordResult.event?.id ?? null
235
+ } catch (e: any) {
236
+ console.error("[PayPal] webhook: failed to create DB record:", e?.message)
237
+ return res.status(500).json({ message: "Failed to record webhook event" })
238
+ }
239
+
240
+ if (!isAllowedEventType(eventType)) {
241
+ console.info("[PayPal] webhook: unsupported event type, ignoring", { eventType })
242
+ await paypal.recordAuditEvent("webhook_unsupported_event", {
243
+ event_id: eventId,
244
+ event_type: eventType,
245
+ })
246
+ if (recordId) {
247
+ await paypal
248
+ .updateWebhookEventRecord({
249
+ id: recordId,
250
+ status: "ignored",
251
+ processed_at: new Date(),
252
+ })
253
+ .catch(() => {})
254
+ }
255
+ return res.json({ ok: true, ignored: true })
256
+ }
257
+
258
+ try {
259
+ const processed = await processPayPalWebhookEvent(req.scope, { eventType, payload })
260
+
261
+ if (recordId) {
262
+ await paypal
263
+ .updateWebhookEventRecord({
264
+ id: recordId,
265
+ status: "processed",
266
+ processed_at: new Date(),
267
+ resource_id:
268
+ processed.refundId || processed.captureId || processed.orderId || null,
269
+ })
270
+ .catch(() => {})
271
+ }
272
+
273
+ console.info("[PayPal] webhook: processed", {
274
+ event_id: eventId,
275
+ event_type: eventType,
276
+ order_id: processed.orderId,
277
+ capture_id: processed.captureId,
278
+ refund_id: processed.refundId,
279
+ cart_id: processed.cartId,
280
+ session_updated: processed.sessionUpdated,
281
+ })
282
+
283
+ await paypal.recordMetric("webhook_success").catch(() => {})
284
+ return res.json({ ok: true })
285
+ } catch (e: any) {
286
+ console.error("[PayPal] webhook: processing failed", {
287
+ event_id: eventId,
288
+ event_type: eventType,
289
+ error: e?.message,
290
+ })
291
+
292
+ const retryable = isRetryableError(e)
293
+ const nextStatus = retryable ? "failed" : "dead_letter"
294
+
295
+ if (recordId) {
296
+ await paypal
297
+ .updateWebhookEventRecord({
298
+ id: recordId,
299
+ status: nextStatus,
300
+ attempt_count: 1,
301
+ next_retry_at: retryable ? computeNextRetryAt(1) : null,
302
+ last_error: e?.message || String(e),
303
+ })
304
+ .catch(() => {})
305
+ }
306
+
307
+ await paypal
308
+ .recordAuditEvent("webhook_processing_failed", {
309
+ event_id: eventId,
310
+ event_type: eventType,
311
+ retryable,
312
+ message: e?.message || String(e),
313
+ })
314
+ .catch(() => {})
315
+
316
+ await paypal.recordMetric("webhook_failed").catch(() => {})
317
+
318
+ if (!retryable) {
319
+ return res.status(200).json({ ok: false, message: e?.message })
320
+ }
321
+ return res
322
+ .status(500)
323
+ .json({ message: e?.message || "PayPal webhook processing error" })
324
+ }
325
+ }