@easypayment/medusa-paypal 0.4.7 → 0.4.9

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 (70) hide show
  1. package/.medusa/server/src/admin/index.js +7 -7
  2. package/.medusa/server/src/admin/index.mjs +7 -7
  3. package/.medusa/server/src/api/store/paypal/create-order/route.d.ts.map +1 -1
  4. package/.medusa/server/src/api/store/paypal/create-order/route.js +62 -139
  5. package/.medusa/server/src/api/store/paypal/create-order/route.js.map +1 -1
  6. package/.medusa/server/src/modules/paypal/migrations/20260115120000_create_paypal_connection.js +22 -22
  7. package/.medusa/server/src/modules/paypal/migrations/20260123090000_create_paypal_settings.js +11 -11
  8. package/.medusa/server/src/modules/paypal/migrations/20260201090000_create_paypal_webhook_event.js +18 -18
  9. package/.medusa/server/src/modules/paypal/migrations/20260401090000_create_paypal_metric.js +16 -16
  10. package/.medusa/server/src/modules/paypal/migrations/20260701090000_add_paypal_webhook_event_processing.js +20 -20
  11. package/.medusa/server/src/modules/paypal/migrations/20261101090000_remove_paypal_reconciliation_status.js +14 -14
  12. package/.medusa/server/src/modules/paypal/migrations/20261201090000_remove_paypal_audit_log.js +15 -15
  13. package/README.md +142 -142
  14. package/package.json +75 -75
  15. package/src/admin/index.ts +7 -7
  16. package/src/admin/routes/settings/paypal/_components/Tabs.tsx +52 -52
  17. package/src/admin/routes/settings/paypal/_components/Toast.tsx +51 -51
  18. package/src/admin/routes/settings/paypal/additional-settings/page.tsx +200 -200
  19. package/src/admin/routes/settings/paypal/advanced-card-payments/page.tsx +183 -183
  20. package/src/admin/routes/settings/paypal/apple-pay/page.tsx +5 -5
  21. package/src/admin/routes/settings/paypal/connection/page.tsx +754 -754
  22. package/src/admin/routes/settings/paypal/google-pay/page.tsx +5 -5
  23. package/src/admin/routes/settings/paypal/pay-later-messaging/page.tsx +5 -5
  24. package/src/admin/routes/settings/paypal/paypal-settings/page.tsx +376 -376
  25. package/src/api/admin/payment-collections/[id]/payment-sessions/route.ts +24 -24
  26. package/src/api/admin/paypal/disconnect/route.ts +8 -8
  27. package/src/api/admin/paypal/environment/route.ts +25 -25
  28. package/src/api/admin/paypal/onboard-complete/route.ts +44 -44
  29. package/src/api/admin/paypal/onboarding-link/route.ts +45 -45
  30. package/src/api/admin/paypal/onboarding-status/route.ts +18 -18
  31. package/src/api/admin/paypal/rotate-credentials/route.ts +8 -8
  32. package/src/api/admin/paypal/save-credentials/route.ts +14 -14
  33. package/src/api/admin/paypal/settings/route.ts +14 -14
  34. package/src/api/admin/paypal/status/route.ts +12 -12
  35. package/src/api/store/payment-collections/[id]/payment-sessions/route.ts +65 -65
  36. package/src/api/store/paypal/capture-order/route.ts +276 -276
  37. package/src/api/store/paypal/config/route.ts +102 -102
  38. package/src/api/store/paypal/create-order/route.ts +79 -176
  39. package/src/api/store/paypal/settings/route.ts +19 -19
  40. package/src/api/store/paypal/webhook/route.ts +246 -246
  41. package/src/api/store/paypal-complete/route.ts +75 -75
  42. package/src/jobs/paypal-reconcile.ts +112 -112
  43. package/src/jobs/paypal-webhook-retry.ts +85 -85
  44. package/src/modules/paypal/clients/paypal-seller.client.ts +59 -59
  45. package/src/modules/paypal/index.ts +8 -8
  46. package/src/modules/paypal/migrations/20260115120000_create_paypal_connection.ts +33 -33
  47. package/src/modules/paypal/migrations/20260123090000_create_paypal_settings.ts +22 -22
  48. package/src/modules/paypal/migrations/20260201090000_create_paypal_webhook_event.ts +29 -29
  49. package/src/modules/paypal/migrations/20260401090000_create_paypal_metric.ts +27 -27
  50. package/src/modules/paypal/migrations/20260701090000_add_paypal_webhook_event_processing.ts +31 -31
  51. package/src/modules/paypal/migrations/20261101090000_remove_paypal_reconciliation_status.ts +25 -25
  52. package/src/modules/paypal/migrations/20261201090000_remove_paypal_audit_log.ts +26 -26
  53. package/src/modules/paypal/migrations/20270101090000_set_paypal_environment_default_live.ts +11 -11
  54. package/src/modules/paypal/models/paypal_connection.ts +21 -21
  55. package/src/modules/paypal/models/paypal_metric.ts +9 -9
  56. package/src/modules/paypal/models/paypal_settings.ts +8 -8
  57. package/src/modules/paypal/models/paypal_webhook_event.ts +19 -19
  58. package/src/modules/paypal/payment-provider/README.md +22 -22
  59. package/src/modules/paypal/payment-provider/card-service.ts +760 -760
  60. package/src/modules/paypal/payment-provider/index.ts +19 -19
  61. package/src/modules/paypal/payment-provider/service.ts +1121 -1121
  62. package/src/modules/paypal/payment-provider/webhook-utils.ts +88 -88
  63. package/src/modules/paypal/service.ts +1247 -1247
  64. package/src/modules/paypal/types/config.ts +47 -47
  65. package/src/modules/paypal/utils/amounts.ts +41 -41
  66. package/src/modules/paypal/utils/crypto.ts +51 -51
  67. package/src/modules/paypal/utils/currencies.ts +84 -84
  68. package/src/modules/paypal/utils/paypal-auth.ts +32 -32
  69. package/src/modules/paypal/utils/provider-ids.ts +15 -15
  70. 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
  }