@easypayment/medusa-paypal 0.6.2 → 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 -159
  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,1121 +1,1079 @@
1
- import { AbstractPaymentProvider } from "@medusajs/framework/utils"
2
- import { randomUUID } from "crypto"
3
- import type {
4
- AuthorizePaymentInput,
5
- AuthorizePaymentOutput,
6
- CapturePaymentInput,
7
- CapturePaymentOutput,
8
- CancelPaymentInput,
9
- CancelPaymentOutput,
10
- CreateAccountHolderInput,
11
- CreateAccountHolderOutput,
12
- DeletePaymentInput,
13
- DeletePaymentOutput,
14
- GetPaymentStatusInput,
15
- GetPaymentStatusOutput,
16
- InitiatePaymentInput,
17
- InitiatePaymentOutput,
18
- RefundPaymentInput,
19
- RefundPaymentOutput,
20
- RetrievePaymentInput,
21
- RetrievePaymentOutput,
22
- UpdatePaymentInput,
23
- UpdatePaymentOutput,
24
- ProviderWebhookPayload,
25
- WebhookActionResult,
26
- } from "@medusajs/framework/types"
27
- import { formatAmountForPayPal } from "../utils/amounts"
28
- import {
29
- assertPayPalCurrencySupported,
30
- normalizeCurrencyCode,
31
- } from "../utils/currencies"
32
- import type PayPalModuleService from "../service"
33
- import { getPayPalWebhookActionAndData } from "./webhook-utils"
34
-
35
- type Options = {}
36
-
37
- function generateSessionId() {
38
- try {
39
- return randomUUID()
40
- } catch {
41
- // Fallback for environments where randomUUID isn't available
42
- return `pp_${Date.now()}_${Math.random().toString(16).slice(2)}`
43
- }
44
- }
45
-
46
- class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
47
- static identifier = "paypal"
48
-
49
- protected readonly options_: Options
50
-
51
- constructor(cradle: Record<string, any>, options: Options) {
52
- super(cradle, options)
53
- this.options_ = options
54
- }
55
-
56
- private resolvePayPalService() {
57
- const container = this.container as {
58
- resolve<T>(key: string): T
59
- }
60
- try {
61
- return container.resolve<PayPalModuleService>("paypal_onboarding")
62
- } catch {
63
- return null
64
- }
65
- }
66
-
67
- async resolveSettings() {
68
- const paypal = this.resolvePayPalService();
69
- if (!paypal) {
70
- try {
71
- const { Pool: _SettingsPool } = require("pg")
72
- const _sPool = new _SettingsPool({ connectionString: process.env.DATABASE_URL })
73
- const _sResult = await _sPool
74
- .query("SELECT data FROM paypal_settings ORDER BY created_at DESC LIMIT 1")
75
- .finally(() => _sPool.end())
76
- const _sData = _sResult.rows[0]?.data || {}
77
- return {
78
- additionalSettings: (_sData.additional_settings || {}) as Record<string, unknown>,
79
- apiDetails: (_sData.api_details || {}) as Record<string, unknown>,
80
- }
81
- } catch {
82
- return {
83
- additionalSettings: {} as Record<string, unknown>,
84
- apiDetails: {} as Record<string, unknown>,
85
- }
86
- }
87
- }
88
- const settings = await paypal.getSettings().catch(() => ({}))
89
- const data = settings && typeof settings === "object" && "data" in settings
90
- ? ((settings as any).data ?? {})
91
- : {}
92
- return {
93
- additionalSettings: (data.additional_settings || {}) as Record<string, unknown>,
94
- apiDetails: (data.api_details || {}) as Record<string, unknown>,
95
- }
96
- }
97
-
98
- private async resolveCurrencyOverride() {
99
- const { apiDetails } = await this.resolveSettings()
100
- if (typeof apiDetails.currency_code === "string" && apiDetails.currency_code.trim()) {
101
- return normalizeCurrencyCode(apiDetails.currency_code)
102
- }
103
- return normalizeCurrencyCode(process.env.PAYPAL_CURRENCY || "EUR")
104
- }
105
-
106
- private async getPayPalAccessToken() {
107
- const paypal = this.resolvePayPalService()
108
- if (!paypal) {
109
- // Fallback: load credentials directly from paypal_connection table
110
- const { Pool: _FbPool } = require("pg")
111
- const _fbPool = new _FbPool({ connectionString: process.env.DATABASE_URL })
112
- const _fbResult = await _fbPool.query(
113
- "SELECT metadata, environment, seller_client_id, seller_client_secret FROM paypal_connection WHERE status='connected' ORDER BY created_at DESC LIMIT 1"
114
- ).finally(() => _fbPool.end())
115
- const _fbRow = _fbResult.rows[0]
116
- if (!_fbRow) throw new Error("No active PayPal connection found in DB")
117
- const _fbEnv = _fbRow.environment || "sandbox"
118
- const _fbCreds = (_fbRow.metadata && _fbRow.metadata.credentials && _fbRow.metadata.credentials[_fbEnv]) || {}
119
- const _fbId = _fbCreds.client_id || _fbRow.seller_client_id
120
- const _fbSec = _fbCreds.client_secret || _fbRow.seller_client_secret
121
- const _fbBase = _fbEnv === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"
122
- const _fbAuth = Buffer.from(`${_fbId}:${_fbSec}`).toString("base64")
123
- const _fbResp = await fetch(`${_fbBase}/v1/oauth2/token`, {
124
- method: "POST",
125
- headers: { Authorization: `Basic ${_fbAuth}`, "Content-Type": "application/x-www-form-urlencoded" },
126
- body: "grant_type=client_credentials",
127
- })
128
- const _fbText = await _fbResp.text()
129
- if (!_fbResp.ok) throw new Error(`PayPal token error (${_fbResp.status}): ${_fbText}`)
130
- const _fbJson = JSON.parse(_fbText)
131
- console.info("[PayPal] getPayPalAccessToken fallback via DB for env:", _fbEnv)
132
- return { accessToken: String(_fbJson.access_token), base: _fbBase }
133
- }
134
- const creds = await paypal.getActiveCredentials()
135
- const base =
136
- creds.environment === "live"
137
- ? "https://api-m.paypal.com"
138
- : "https://api-m.sandbox.paypal.com"
139
- const auth = Buffer.from(`${creds.client_id}:${creds.client_secret}`).toString("base64")
140
-
141
- const resp = await fetch(`${base}/v1/oauth2/token`, {
142
- method: "POST",
143
- headers: {
144
- Authorization: `Basic ${auth}`,
145
- "Content-Type": "application/x-www-form-urlencoded",
146
- },
147
- body: "grant_type=client_credentials",
148
- })
149
-
150
- const text = await resp.text()
151
- if (!resp.ok) {
152
- throw new Error(`PayPal token error (${resp.status}): ${text}`)
153
- }
154
-
155
- const json = JSON.parse(text)
156
- return { accessToken: String(json.access_token), base }
157
- }
158
-
159
-
160
- private async getOrderDetails(orderId: string) {
161
- const { accessToken, base } = await this.getPayPalAccessToken()
162
- const resp = await fetch(`${base}/v2/checkout/orders/${orderId}`, {
163
- method: "GET",
164
- headers: {
165
- Authorization: `Bearer ${accessToken}`,
166
- "Content-Type": "application/json",
167
- },
168
- })
169
-
170
- const text = await resp.text()
171
- if (!resp.ok) {
172
- throw new Error(`PayPal get order error (${resp.status}): ${text}`)
173
- }
174
-
175
- return JSON.parse(text)
176
- }
177
-
178
- private getIdempotencyKey(input: { context?: { idempotency_key?: string } }, suffix: string) {
179
- const key = input?.context?.idempotency_key?.trim()
180
- if (key) {
181
- return `${key}-${suffix}`
182
- }
183
- return `pp-${suffix}-${generateSessionId()}`
184
- }
185
-
186
- private async normalizePaymentData(input: { data?: Record<string, unknown> }) {
187
- const data = (input.data || {}) as Record<string, any>
188
- const amount = Number(data.amount ?? 0)
189
- const currencyOverride = await this.resolveCurrencyOverride()
190
- const currencyCode = normalizeCurrencyCode(
191
- data.currency_code || currencyOverride || "EUR"
192
- )
193
- assertPayPalCurrencySupported({
194
- currencyCode,
195
- paypalCurrencyOverride: currencyOverride,
196
- })
197
- return { data, amount, currencyCode }
198
- }
199
-
200
- private mapCaptureStatus(status?: string) {
201
- const normalized = String(status || "").toUpperCase()
202
- if (!normalized) {
203
- return null
204
- }
205
-
206
- if (normalized === "COMPLETED") {
207
- return "captured"
208
- }
209
-
210
- if (normalized === "PENDING") {
211
- return "pending"
212
- }
213
-
214
- if (["DENIED", "DECLINED", "FAILED"].includes(normalized)) {
215
- return "error"
216
- }
217
-
218
- if (["REFUNDED", "PARTIALLY_REFUNDED", "REVERSED"].includes(normalized)) {
219
- return "canceled"
220
- }
221
-
222
- return null
223
- }
224
-
225
- private mapAuthorizationStatus(status?: string) {
226
- const normalized = String(status || "").toUpperCase()
227
- if (!normalized) {
228
- return null
229
- }
230
-
231
- if (["CREATED", "APPROVED", "PENDING"].includes(normalized)) {
232
- return "authorized"
233
- }
234
-
235
- if (["VOIDED", "EXPIRED"].includes(normalized)) {
236
- return "canceled"
237
- }
238
-
239
- if (["DENIED", "DECLINED", "FAILED"].includes(normalized)) {
240
- return "error"
241
- }
242
-
243
- return null
244
- }
245
-
246
- private serializeError(error: unknown) {
247
- if (error instanceof Error) {
248
- const errorWithCause = error as Error & { cause?: unknown }
249
- const cause = errorWithCause.cause
250
- return {
251
- name: error.name,
252
- message: error.message,
253
- stack: error.stack,
254
- cause:
255
- cause instanceof Error
256
- ? {
257
- name: cause.name,
258
- message: cause.message,
259
- stack: cause.stack,
260
- }
261
- : cause,
262
- }
263
- }
264
-
265
- return {
266
- message: String(error),
267
- }
268
- }
269
-
270
- private mapOrderStatus(status?: string) {
271
- const normalized = String(status || "").toUpperCase()
272
- if (!normalized) {
273
- return "pending"
274
- }
275
-
276
- if (normalized === "COMPLETED") {
277
- return "captured"
278
- }
279
-
280
- if (normalized === "APPROVED") {
281
- return "authorized"
282
- }
283
-
284
- if (["VOIDED", "CANCELLED"].includes(normalized)) {
285
- return "canceled"
286
- }
287
-
288
- if (["CREATED", "SAVED", "PAYER_ACTION_REQUIRED"].includes(normalized)) {
289
- return "pending"
290
- }
291
-
292
- if (["FAILED", "EXPIRED"].includes(normalized)) {
293
- return "error"
294
- }
295
-
296
- return "pending"
297
- }
298
-
299
- private async recordFailure(eventType: string, metadata?: Record<string, unknown>) {
300
- const paypal = this.resolvePayPalService()
301
- if (!paypal) {
302
- return
303
- }
304
-
305
- try {
306
- await paypal.recordPaymentLog(eventType, metadata)
307
- await paypal.recordAuditEvent(eventType, metadata)
308
- await paypal.recordMetric(eventType)
309
- } catch {
310
- // ignore audit logging failures
311
- }
312
- }
313
-
314
- private async recordSuccess(metricName: string) {
315
- const paypal = this.resolvePayPalService()
316
- if (!paypal) {
317
- return
318
- }
319
-
320
- try {
321
- await paypal.recordMetric(metricName)
322
- } catch {
323
- // ignore metrics failures
324
- }
325
- }
326
-
327
- private async recordPaymentEvent(eventType: string, metadata?: Record<string, unknown>) {
328
- const paypal = this.resolvePayPalService()
329
- if (!paypal) {
330
- return
331
- }
332
-
333
- try {
334
- await paypal.recordPaymentLog(eventType, metadata)
335
- } catch {
336
- // ignore payment logging failures
337
- }
338
- }
339
-
340
- async createAccountHolder(
341
- input: CreateAccountHolderInput
342
- ): Promise<CreateAccountHolderOutput> {
343
- const customerId = input.context?.customer?.id
344
- const externalId = customerId ? `paypal_${customerId}` : `paypal_${generateSessionId()}`
345
-
346
- return {
347
- id: externalId,
348
- data: {
349
- email: input.context?.customer?.email || null,
350
- customer_id: customerId || null,
351
- },
352
- }
353
- }
354
-
355
- /**
356
- * Create a payment session when the customer selects PayPal.
357
- * Must return an object containing an `id` and `data`.
358
- */
359
- async initiatePayment(
360
- input: InitiatePaymentInput
361
- ): Promise<InitiatePaymentOutput> {
362
- const providerId = (input.data as Record<string, any> | undefined)?.provider_id
363
- try {
364
- const currencyOverride = await this.resolveCurrencyOverride()
365
- const currencyCode = normalizeCurrencyCode(
366
- input.currency_code || currencyOverride || "EUR"
367
- )
368
- assertPayPalCurrencySupported({
369
- currencyCode,
370
- paypalCurrencyOverride: currencyOverride,
371
- })
372
-
373
- console.info("[PayPal] provider initiate", {
374
- provider_id: providerId,
375
- payment_collection_id: (input.data as Record<string, any> | undefined)
376
- ?.payment_collection_id,
377
- cart_id: (input.data as Record<string, any> | undefined)?.cart_id,
378
- amount: input.amount,
379
- currency_code: currencyCode,
380
- })
381
-
382
- return {
383
- id: generateSessionId(),
384
- data: {
385
- ...(input.data || {}),
386
- ...(providerId ? { provider_id: providerId } : {}),
387
- amount: input.amount,
388
- currency_code: currencyCode,
389
- },
390
- }
391
- } catch (error) {
392
- console.error("[PayPal] provider initiate failed", {
393
- provider_id: providerId,
394
- payment_collection_id: (input.data as Record<string, any> | undefined)
395
- ?.payment_collection_id,
396
- cart_id: (input.data as Record<string, any> | undefined)?.cart_id,
397
- amount: input.amount,
398
- currency_code: input.currency_code,
399
- error: this.serializeError(error),
400
- })
401
- await this.recordFailure("initiate_failed", {
402
- error: this.serializeError(error),
403
- currency_code: input.currency_code,
404
- amount: input.amount,
405
- provider_id: providerId,
406
- data: input.data ?? null,
407
- })
408
- throw error
409
- }
410
- }
411
-
412
- async updatePayment(input: UpdatePaymentInput): Promise<UpdatePaymentOutput> {
413
- const currencyOverride = await this.resolveCurrencyOverride()
414
- const currencyCode = normalizeCurrencyCode(
415
- input.currency_code || currencyOverride || "EUR"
416
- )
417
- assertPayPalCurrencySupported({
418
- currencyCode,
419
- paypalCurrencyOverride: currencyOverride,
420
- })
421
-
422
- const providerId = (input.data as Record<string, any> | undefined)?.provider_id
423
-
424
- return {
425
- data: {
426
- ...(input.data || {}),
427
- ...(providerId ? { provider_id: providerId } : {}),
428
- amount: input.amount,
429
- currency_code: currencyCode,
430
- },
431
- }
432
- }
433
-
434
- async authorizePayment(
435
- input: AuthorizePaymentInput
436
- ): Promise<AuthorizePaymentOutput> {
437
- const { data, amount, currencyCode } = await this.normalizePaymentData(input)
438
-
439
- const existingPayPal = (data.paypal || {}) as Record<string, any>
440
- if (
441
- existingPayPal.capture_id ||
442
- existingPayPal.authorization_id ||
443
- (data as any).authorized_at ||
444
- (data as any).captured_at
445
- ) {
446
- const { additionalSettings } = await this.resolveSettings()
447
- const paymentAction =
448
- typeof additionalSettings.paymentAction === "string"
449
- ? additionalSettings.paymentAction
450
- : "capture"
451
- const returnStatus = paymentAction === "authorize" ? "authorized" : "captured"
452
- console.info("[PayPal] authorizePayment: already processed, returning", returnStatus)
453
- return {
454
- status: returnStatus,
455
- data: {
456
- ...(input.data || {}),
457
- ...(paymentAction === "authorize"
458
- ? { authorized_at: new Date().toISOString() }
459
- : { captured_at: new Date().toISOString() }),
460
- },
461
- }
462
- }
463
-
464
-
465
- const requestId = this.getIdempotencyKey(input, "authorize")
466
- let debugId: string | null = null
467
- const { additionalSettings } = await this.resolveSettings()
468
- const paymentActionRaw =
469
- typeof additionalSettings.paymentAction === "string"
470
- ? additionalSettings.paymentAction
471
- : "capture"
472
- const orderIntent = paymentActionRaw === "authorize" ? "AUTHORIZE" : "CAPTURE"
473
-
474
- try {
475
- const { accessToken, base } = await this.getPayPalAccessToken()
476
- const existingPayPal = (data.paypal || {}) as Record<string, any>
477
- let orderId = String(existingPayPal.order_id || data.order_id || "")
478
- let order: Record<string, any> | null = null
479
- let authorization: any = null
480
-
481
- if (!orderId) {
482
- const value = formatAmountForPayPal(amount, currencyCode || "EUR")
483
-
484
- const orderPayload = {
485
- intent: orderIntent,
486
- purchase_units: [
487
- {
488
- reference_id: data.cart_id || data.payment_collection_id || undefined,
489
- custom_id: data.session_id || data.cart_id || data.payment_collection_id || undefined,
490
- amount: {
491
- currency_code: currencyCode || "EUR",
492
- value,
493
- },
494
- },
495
- ],
496
- custom_id: data.session_id || data.cart_id || data.payment_collection_id || undefined,
497
- }
498
-
499
- const ppResp = await fetch(`${base}/v2/checkout/orders`, {
500
- method: "POST",
501
- headers: {
502
- Authorization: `Bearer ${accessToken}`,
503
- "Content-Type": "application/json",
504
- "PayPal-Request-Id": requestId,
505
- },
506
- body: JSON.stringify(orderPayload),
507
- })
508
-
509
- const ppText = await ppResp.text()
510
- debugId = ppResp.headers.get("paypal-debug-id")
511
- if (!ppResp.ok) {
512
- throw new Error(
513
- `PayPal create order error (${ppResp.status}): ${ppText}${
514
- debugId ? ` debug_id=${debugId}` : ""
515
- }`
516
- )
517
- }
518
-
519
- order = JSON.parse(ppText) as Record<string, any>
520
- orderId = String(order.id || "")
521
- } else {
522
- order = (await this.getOrderDetails(orderId)) as Record<string, any> | null
523
- }
524
-
525
- if (!order || !orderId) {
526
- throw new Error("Unable to resolve PayPal order details for authorization.")
527
- }
528
-
529
- const existingAuthorization =
530
- order?.purchase_units?.[0]?.payments?.authorizations?.[0] || null
531
-
532
- if (existingAuthorization) {
533
- authorization = order
534
- } else {
535
- const authorizeResp = await fetch(
536
- `${base}/v2/checkout/orders/${orderId}/authorize`,
537
- {
538
- method: "POST",
539
- headers: {
540
- Authorization: `Bearer ${accessToken}`,
541
- "Content-Type": "application/json",
542
- "PayPal-Request-Id": `${requestId}-auth`,
543
- },
544
- }
545
- )
546
-
547
- const authorizeText = await authorizeResp.text()
548
- const authorizeDebugId = authorizeResp.headers.get("paypal-debug-id")
549
- if (!authorizeResp.ok) {
550
- throw new Error(
551
- `PayPal authorize order error (${authorizeResp.status}): ${authorizeText}${
552
- authorizeDebugId ? ` debug_id=${authorizeDebugId}` : ""
553
- }`
554
- )
555
- }
556
-
557
- authorization = JSON.parse(authorizeText)
558
- }
559
-
560
- const authorizationId =
561
- authorization?.purchase_units?.[0]?.payments?.authorizations?.[0]?.id ||
562
- existingAuthorization?.id
563
-
564
- console.info("[PayPal] provider authorize", {
565
- order_id: orderId,
566
- authorization_id: authorizationId,
567
- request_id: requestId,
568
- debug_id: debugId,
569
- })
570
- await this.recordSuccess("authorize_success")
571
- await this.recordPaymentEvent("authorize", {
572
- order_id: orderId,
573
- authorization_id: authorizationId,
574
- amount,
575
- currency_code: currencyCode,
576
- request_id: requestId,
577
- })
578
-
579
- return {
580
- status: "authorized",
581
- data: {
582
- ...(input.data || {}),
583
- paypal: {
584
- ...((input.data || {}).paypal as Record<string, unknown>),
585
- order_id: orderId,
586
- order: order || authorization,
587
- authorization_id: authorizationId,
588
- authorizations:
589
- authorization?.purchase_units?.[0]?.payments?.authorizations || [],
590
- },
591
- authorized_at: new Date().toISOString(),
592
- },
593
- }
594
- } catch (error: any) {
595
- await this.recordFailure("authorize_failed", {
596
- request_id: requestId,
597
- cart_id: data.cart_id,
598
- payment_collection_id: data.payment_collection_id,
599
- debug_id: debugId,
600
- message: error?.message,
601
- })
602
- throw error
603
- }
604
- }
605
-
606
- async retrievePayment(
607
- input: RetrievePaymentInput
608
- ): Promise<RetrievePaymentOutput> {
609
- const data = (input.data || {}) as Record<string, any>
610
- const paypalData = (data.paypal || {}) as Record<string, any>
611
- const orderId = String(paypalData.order_id || data.order_id || "")
612
- if (!orderId) {
613
- return { data: { ...(input.data || {}) } }
614
- }
615
-
616
- const order = await this.getOrderDetails(orderId)
617
- const capture = order?.purchase_units?.[0]?.payments?.captures?.[0]
618
- const authorization = order?.purchase_units?.[0]?.payments?.authorizations?.[0]
619
-
620
- return {
621
- data: {
622
- ...(input.data || {}),
623
- paypal: {
624
- ...((input.data || {}).paypal as Record<string, unknown>),
625
- order,
626
- authorization_id: authorization?.id || paypalData.authorization_id,
627
- capture_id: capture?.id || paypalData.capture_id,
628
- },
629
- },
630
- }
631
- }
632
-
633
- async getPaymentStatus(
634
- input: GetPaymentStatusInput
635
- ): Promise<GetPaymentStatusOutput> {
636
- const data = (input.data || {}) as Record<string, any>
637
- const paypalData = (data.paypal || {}) as Record<string, any>
638
- const orderId = String(paypalData.order_id || data.order_id || "")
639
- if (!orderId) {
640
- return { status: "pending", data: { ...(input.data || {}) } }
641
- }
642
-
643
- try {
644
- const order = await this.getOrderDetails(orderId)
645
- const capture = order?.purchase_units?.[0]?.payments?.captures?.[0]
646
- const authorization = order?.purchase_units?.[0]?.payments?.authorizations?.[0]
647
- const mappedStatus =
648
- this.mapCaptureStatus(capture?.status) ||
649
- this.mapAuthorizationStatus(authorization?.status) ||
650
- this.mapOrderStatus(order?.status) ||
651
- "pending"
652
-
653
- await this.recordSuccess("status_success")
654
- return {
655
- status: mappedStatus,
656
- data: {
657
- ...(input.data || {}),
658
- paypal: {
659
- ...((input.data || {}).paypal as Record<string, unknown>),
660
- order,
661
- authorization_id: authorization?.id || paypalData.authorization_id,
662
- capture_id: capture?.id || paypalData.capture_id,
663
- },
664
- },
665
- }
666
- } catch (error: any) {
667
- await this.recordFailure("status_failed", {
668
- order_id: orderId,
669
- message: error?.message,
670
- })
671
- throw error
672
- }
673
- }
674
-
675
- async capturePayment(
676
- input: CapturePaymentInput
677
- ): Promise<CapturePaymentOutput> {
678
- const data = (input.data || {}) as Record<string, any>
679
- const paypalData = (data.paypal || {}) as Record<string, any>
680
- const orderId = String(paypalData.order_id || data.order_id || "")
681
- let authorizationId = String(
682
- paypalData.authorization_id || data.authorization_id || ""
683
- )
684
- if (!orderId) {
685
- throw new Error("PayPal order_id is required to capture payment")
686
- }
687
-
688
- if (paypalData.capture_id || paypalData.capture) {
689
- return {
690
- data: {
691
- ...(input.data || {}),
692
- paypal: {
693
- ...((input.data || {}).paypal as Record<string, unknown>),
694
- capture_id: paypalData.capture_id,
695
- capture: paypalData.capture,
696
- },
697
- captured_at: new Date().toISOString(),
698
- },
699
- }
700
- }
701
-
702
- const requestId = this.getIdempotencyKey(input, `capture-${orderId}`)
703
- const { amount, currencyCode } = await this.normalizePaymentData(input)
704
- let debugId: string | null = null
705
-
706
- try {
707
- const { accessToken, base } = await this.getPayPalAccessToken()
708
- const order = await this.getOrderDetails(orderId).catch(() => null)
709
- const existingCapture = order?.purchase_units?.[0]?.payments?.captures?.[0]
710
- if (existingCapture?.id) {
711
- return {
712
- data: {
713
- ...(input.data || {}),
714
- paypal: {
715
- ...((input.data || {}).paypal as Record<string, unknown>),
716
- capture_id: existingCapture.id,
717
- capture: existingCapture,
718
- },
719
- captured_at: new Date().toISOString(),
720
- },
721
- }
722
- }
723
- const resolvedIntent = String(
724
- order?.intent || paypalData.order?.intent || data.intent || ""
725
- ).toUpperCase()
726
- if (!authorizationId && resolvedIntent === "AUTHORIZE") {
727
- const authorizeResp = await fetch(
728
- `${base}/v2/checkout/orders/${orderId}/authorize`,
729
- {
730
- method: "POST",
731
- headers: {
732
- Authorization: `Bearer ${accessToken}`,
733
- "Content-Type": "application/json",
734
- "PayPal-Request-Id": `${requestId}-auth`,
735
- },
736
- }
737
- )
738
- const authorizeText = await authorizeResp.text()
739
- debugId = authorizeResp.headers.get("paypal-debug-id")
740
- if (!authorizeResp.ok) {
741
- throw new Error(
742
- `PayPal authorize order error (${authorizeResp.status}): ${authorizeText}${
743
- debugId ? ` debug_id=${debugId}` : ""
744
- }`
745
- )
746
- }
747
- const authorization = JSON.parse(authorizeText)
748
- authorizationId =
749
- authorization?.purchase_units?.[0]?.payments?.authorizations?.[0]?.id
750
- }
751
-
752
- const isFinalCapture =
753
- paypalData.is_final_capture ??
754
- data.is_final_capture ??
755
- data.final_capture ??
756
- undefined
757
- const capturePayload =
758
- amount > 0
759
- ? {
760
- amount: {
761
- currency_code: currencyCode || "EUR",
762
- value: formatAmountForPayPal(amount, currencyCode || "EUR"),
763
- },
764
- ...(typeof isFinalCapture === "boolean"
765
- ? { is_final_capture: isFinalCapture }
766
- : {}),
767
- }
768
- : {
769
- ...(typeof isFinalCapture === "boolean"
770
- ? { is_final_capture: isFinalCapture }
771
- : {}),
772
- }
773
-
774
- const captureUrl = authorizationId
775
- ? `${base}/v2/payments/authorizations/${authorizationId}/capture`
776
- : `${base}/v2/checkout/orders/${orderId}/capture`
777
-
778
- const ppResp = await fetch(captureUrl, {
779
- method: "POST",
780
- headers: {
781
- Authorization: `Bearer ${accessToken}`,
782
- "Content-Type": "application/json",
783
- "PayPal-Request-Id": requestId,
784
- },
785
- body: JSON.stringify(capturePayload),
786
- })
787
-
788
- const ppText = await ppResp.text()
789
- debugId = ppResp.headers.get("paypal-debug-id")
790
- if (!ppResp.ok) {
791
- throw new Error(
792
- `PayPal capture error (${ppResp.status}): ${ppText}${
793
- debugId ? ` debug_id=${debugId}` : ""
794
- }`
795
- )
796
- }
797
-
798
- const capture = JSON.parse(ppText)
799
- const captureId =
800
- capture?.id || capture?.purchase_units?.[0]?.payments?.captures?.[0]?.id
801
- const existingCaptures = Array.isArray(paypalData.captures)
802
- ? paypalData.captures
803
- : []
804
- const captureEntry = {
805
- id: captureId,
806
- status: capture?.status,
807
- amount: capture?.amount,
808
- raw: capture,
809
- }
810
-
811
- console.info("[PayPal] provider capture", {
812
- order_id: orderId,
813
- capture_id: captureId,
814
- authorization_id: authorizationId || undefined,
815
- request_id: requestId,
816
- debug_id: ppResp.headers.get("paypal-debug-id"),
817
- })
818
- await this.recordSuccess("capture_success")
819
- await this.recordPaymentEvent("capture", {
820
- order_id: orderId,
821
- capture_id: captureId,
822
- authorization_id: authorizationId || undefined,
823
- amount,
824
- currency_code: currencyCode,
825
- request_id: requestId,
826
- })
827
-
828
- return {
829
- data: {
830
- ...(input.data || {}),
831
- paypal: {
832
- ...((input.data || {}).paypal as Record<string, unknown>),
833
- order_id: orderId,
834
- capture_id: captureId,
835
- capture,
836
- authorization_id: authorizationId || paypalData.authorization_id,
837
- captures: [...existingCaptures, captureEntry],
838
- },
839
- captured_at: new Date().toISOString(),
840
- },
841
- }
842
- } catch (error: any) {
843
- await this.recordFailure("capture_failed", {
844
- order_id: orderId,
845
- request_id: requestId,
846
- debug_id: debugId,
847
- message: error?.message,
848
- })
849
- throw error
850
- }
851
- }
852
-
853
- async refundPayment(
854
- input: RefundPaymentInput
855
- ): Promise<RefundPaymentOutput> {
856
- const data = (input.data || {}) as Record<string, any>
857
- const paypalData = (data.paypal || {}) as Record<string, any>
858
- const captureId = String(paypalData.capture_id || data.capture_id || "")
859
- const refundReason = String(
860
- paypalData.refund_reason || data.refund_reason || data.reason || ""
861
- ).trim()
862
- const refundReasonCode = String(
863
- paypalData.refund_reason_code || data.refund_reason_code || data.reason_code || ""
864
- ).trim()
865
- if (!captureId) {
866
- return {
867
- data: {
868
- ...(input.data || {}),
869
- refunded_at: new Date().toISOString(),
870
- },
871
- }
872
- }
873
-
874
- const requestId = this.getIdempotencyKey(input, `refund-${captureId}`)
875
- const { amount, currencyCode } = await this.normalizePaymentData(input)
876
- let debugId: string | null = null
877
-
878
- try {
879
- const { accessToken, base } = await this.getPayPalAccessToken()
880
- const refundPayload: Record<string, any> =
881
- amount > 0
882
- ? {
883
- amount: {
884
- currency_code: currencyCode || "EUR",
885
- value: formatAmountForPayPal(amount, currencyCode || "EUR"),
886
- },
887
- }
888
- : {}
889
-
890
- if (refundReason) {
891
- refundPayload.note_to_payer = refundReason
892
- }
893
-
894
- const ppResp = await fetch(`${base}/v2/payments/captures/${captureId}/refund`, {
895
- method: "POST",
896
- headers: {
897
- Authorization: `Bearer ${accessToken}`,
898
- "Content-Type": "application/json",
899
- "PayPal-Request-Id": requestId,
900
- },
901
- body: JSON.stringify(refundPayload),
902
- })
903
-
904
- const ppText = await ppResp.text()
905
- debugId = ppResp.headers.get("paypal-debug-id")
906
- if (!ppResp.ok) {
907
- throw new Error(
908
- `PayPal refund error (${ppResp.status}): ${ppText}${
909
- debugId ? ` debug_id=${debugId}` : ""
910
- }`
911
- )
912
- }
913
-
914
- const refund = JSON.parse(ppText)
915
- const existingRefunds = Array.isArray(paypalData.refunds) ? paypalData.refunds : []
916
- const refundEntry = {
917
- id: refund?.id,
918
- status: refund?.status,
919
- amount: refund?.amount,
920
- reason: refundReason || refund?.note_to_payer,
921
- reason_code: refundReasonCode || refund?.reason_code,
922
- raw: refund,
923
- }
924
-
925
- console.info("[PayPal] provider refund", {
926
- capture_id: captureId,
927
- refund_id: refund?.id,
928
- request_id: requestId,
929
- debug_id: ppResp.headers.get("paypal-debug-id"),
930
- })
931
- await this.recordSuccess("refund_success")
932
- await this.recordPaymentEvent("refund", {
933
- capture_id: captureId,
934
- refund_id: refund?.id,
935
- amount,
936
- currency_code: currencyCode,
937
- request_id: requestId,
938
- reason: refundReason,
939
- reason_code: refundReasonCode,
940
- })
941
-
942
- return {
943
- data: {
944
- ...(input.data || {}),
945
- paypal: {
946
- ...((input.data || {}).paypal as Record<string, unknown>),
947
- refund_id: refund?.id,
948
- refund_status: refund?.status,
949
- refund_reason: refundReason || refund?.note_to_payer,
950
- refund_reason_code: refundReasonCode || refund?.reason_code,
951
- refunds: [...existingRefunds, refundEntry],
952
- refund,
953
- },
954
- refunded_at: new Date().toISOString(),
955
- },
956
- }
957
- } catch (error: any) {
958
- await this.recordFailure("refund_failed", {
959
- capture_id: captureId,
960
- request_id: requestId,
961
- debug_id: debugId,
962
- message: error?.message,
963
- })
964
- throw error
965
- }
966
- }
967
-
968
- async cancelPayment(
969
- input: CancelPaymentInput
970
- ): Promise<CancelPaymentOutput> {
971
- const data = (input.data || {}) as Record<string, any>
972
- const paypalData = (data.paypal || {}) as Record<string, any>
973
- const orderId = String(paypalData.order_id || data.order_id || "")
974
- const captureId = String(paypalData.capture_id || data.capture_id || "")
975
- const storedAuthorizationId = String(
976
- paypalData.authorization_id || data.authorization_id || ""
977
- )
978
- let debugId: string | null = null
979
-
980
- try {
981
- const order = orderId ? await this.getOrderDetails(orderId) : null
982
- const intent = String(order?.intent || "").toUpperCase()
983
- const authorizationId =
984
- order?.purchase_units?.[0]?.payments?.authorizations?.[0]?.id ||
985
- storedAuthorizationId
986
-
987
- if (intent === "AUTHORIZE" && authorizationId) {
988
- const { accessToken, base } = await this.getPayPalAccessToken()
989
- const requestId = this.getIdempotencyKey(input, `void-${authorizationId}`)
990
-
991
- const resp = await fetch(
992
- `${base}/v2/payments/authorizations/${authorizationId}/void`,
993
- {
994
- method: "POST",
995
- headers: {
996
- Authorization: `Bearer ${accessToken}`,
997
- "Content-Type": "application/json",
998
- "PayPal-Request-Id": requestId,
999
- },
1000
- }
1001
- )
1002
-
1003
- if (!resp.ok) {
1004
- const text = await resp.text()
1005
- debugId = resp.headers.get("paypal-debug-id")
1006
- throw new Error(
1007
- `PayPal void error (${resp.status}): ${text}${
1008
- debugId ? ` debug_id=${debugId}` : ""
1009
- }`
1010
- )
1011
- }
1012
-
1013
- console.info("[PayPal] provider void", {
1014
- order_id: orderId,
1015
- authorization_id: authorizationId,
1016
- request_id: requestId,
1017
- debug_id: resp.headers.get("paypal-debug-id"),
1018
- })
1019
- await this.recordSuccess("void_success")
1020
- await this.recordPaymentEvent("void", {
1021
- order_id: orderId,
1022
- authorization_id: authorizationId,
1023
- request_id: requestId,
1024
- })
1025
- } else if (captureId) {
1026
- const { accessToken, base } = await this.getPayPalAccessToken()
1027
- const requestId = this.getIdempotencyKey(input, `refund-${captureId}`)
1028
-
1029
- const resp = await fetch(`${base}/v2/payments/captures/${captureId}/refund`, {
1030
- method: "POST",
1031
- headers: {
1032
- Authorization: `Bearer ${accessToken}`,
1033
- "Content-Type": "application/json",
1034
- "PayPal-Request-Id": requestId,
1035
- },
1036
- body: JSON.stringify({}),
1037
- })
1038
-
1039
- if (!resp.ok) {
1040
- const text = await resp.text()
1041
- debugId = resp.headers.get("paypal-debug-id")
1042
- throw new Error(
1043
- `PayPal refund error (${resp.status}): ${text}${
1044
- debugId ? ` debug_id=${debugId}` : ""
1045
- }`
1046
- )
1047
- }
1048
-
1049
- const refund = await resp.json().catch(() => ({}))
1050
- const existingRefunds = Array.isArray(paypalData.refunds) ? paypalData.refunds : []
1051
- const refundEntry = {
1052
- id: refund?.id,
1053
- status: refund?.status,
1054
- amount: refund?.amount,
1055
- raw: refund,
1056
- }
1057
- paypalData.refund_id = refund?.id
1058
- paypalData.refund_status = refund?.status
1059
- paypalData.refunds = [...existingRefunds, refundEntry]
1060
-
1061
- console.info("[PayPal] provider refund", {
1062
- order_id: orderId,
1063
- capture_id: captureId,
1064
- refund_id: refund?.id,
1065
- request_id: requestId,
1066
- debug_id: resp.headers.get("paypal-debug-id"),
1067
- })
1068
- await this.recordSuccess("cancel_refund_success")
1069
- await this.recordPaymentEvent("cancel_refund", {
1070
- order_id: orderId,
1071
- capture_id: captureId,
1072
- refund_id: refund?.id,
1073
- request_id: requestId,
1074
- })
1075
- }
1076
-
1077
- return {
1078
- data: {
1079
- ...(input.data || {}),
1080
- paypal: {
1081
- ...((input.data || {}).paypal as Record<string, unknown>),
1082
- order: order || undefined,
1083
- authorization_id: authorizationId || storedAuthorizationId,
1084
- capture_id: captureId || paypalData.capture_id,
1085
- refund_id: paypalData.refund_id,
1086
- refund_status: paypalData.refund_status,
1087
- refunds: paypalData.refunds,
1088
- },
1089
- canceled_at: new Date().toISOString(),
1090
- },
1091
- }
1092
- } catch (error: any) {
1093
- await this.recordFailure("cancel_failed", {
1094
- order_id: orderId,
1095
- capture_id: captureId,
1096
- debug_id: debugId,
1097
- message: error?.message,
1098
- })
1099
- throw error
1100
- }
1101
- }
1102
-
1103
- async deletePayment(
1104
- _input: DeletePaymentInput
1105
- ): Promise<DeletePaymentOutput> {
1106
- return { data: {} }
1107
- }
1108
-
1109
- /**
1110
- * Required by AbstractPaymentProvider in Medusa v2.
1111
- * This is used by /hooks/payment/{identifier}_{providerId}
1112
- */
1113
- async getWebhookActionAndData(
1114
- payload: ProviderWebhookPayload["payload"]
1115
- ): Promise<WebhookActionResult> {
1116
- return getPayPalWebhookActionAndData(payload)
1117
- }
1118
- }
1119
-
1120
- export default PayPalPaymentProvider
1121
- export { PayPalPaymentProvider }
1
+ import { AbstractPaymentProvider } from "@medusajs/framework/utils"
2
+ import { randomUUID } from "crypto"
3
+ import type {
4
+ AuthorizePaymentInput,
5
+ AuthorizePaymentOutput,
6
+ CapturePaymentInput,
7
+ CapturePaymentOutput,
8
+ CancelPaymentInput,
9
+ CancelPaymentOutput,
10
+ CreateAccountHolderInput,
11
+ CreateAccountHolderOutput,
12
+ DeletePaymentInput,
13
+ DeletePaymentOutput,
14
+ GetPaymentStatusInput,
15
+ GetPaymentStatusOutput,
16
+ InitiatePaymentInput,
17
+ InitiatePaymentOutput,
18
+ RefundPaymentInput,
19
+ RefundPaymentOutput,
20
+ RetrievePaymentInput,
21
+ RetrievePaymentOutput,
22
+ UpdatePaymentInput,
23
+ UpdatePaymentOutput,
24
+ ProviderWebhookPayload,
25
+ WebhookActionResult,
26
+ } from "@medusajs/framework/types"
27
+ import { formatAmountForPayPal } from "../utils/amounts"
28
+ import {
29
+ assertPayPalCurrencySupported,
30
+ normalizeCurrencyCode,
31
+ } from "../utils/currencies"
32
+ import type PayPalModuleService from "../service"
33
+ import { getPayPalWebhookActionAndData } from "./webhook-utils"
34
+
35
+ type Options = {}
36
+
37
+ function generateSessionId() {
38
+ try {
39
+ return randomUUID()
40
+ } catch {
41
+ // Fallback for environments where randomUUID isn't available
42
+ return `pp_${Date.now()}_${Math.random().toString(16).slice(2)}`
43
+ }
44
+ }
45
+
46
+ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
47
+ static identifier = "paypal"
48
+
49
+ protected readonly options_: Options
50
+
51
+ constructor(cradle: Record<string, any>, options: Options) {
52
+ super(cradle, options)
53
+ this.options_ = options
54
+ }
55
+
56
+ private resolvePayPalService() {
57
+ const container = this.container as {
58
+ resolve<T>(key: string): T
59
+ }
60
+ try {
61
+ return container.resolve<PayPalModuleService>("paypal_onboarding")
62
+ } catch {
63
+ return null
64
+ }
65
+ }
66
+
67
+ async resolveSettings() {
68
+ const paypal = this.resolvePayPalService();
69
+ if (!paypal) {
70
+ try {
71
+ const { Pool: _SettingsPool } = require("pg")
72
+ const _sPool = new _SettingsPool({ connectionString: process.env.DATABASE_URL })
73
+ const _sResult = await _sPool
74
+ .query("SELECT data FROM paypal_settings ORDER BY created_at DESC LIMIT 1")
75
+ .finally(() => _sPool.end())
76
+ const _sData = _sResult.rows[0]?.data || {}
77
+ return {
78
+ additionalSettings: (_sData.additional_settings || {}) as Record<string, unknown>,
79
+ apiDetails: (_sData.api_details || {}) as Record<string, unknown>,
80
+ }
81
+ } catch {
82
+ return {
83
+ additionalSettings: {} as Record<string, unknown>,
84
+ apiDetails: {} as Record<string, unknown>,
85
+ }
86
+ }
87
+ }
88
+ const settings = await paypal.getSettings().catch(() => ({}))
89
+ const data = settings && typeof settings === "object" && "data" in settings
90
+ ? ((settings as any).data ?? {})
91
+ : {}
92
+ return {
93
+ additionalSettings: (data.additional_settings || {}) as Record<string, unknown>,
94
+ apiDetails: (data.api_details || {}) as Record<string, unknown>,
95
+ }
96
+ }
97
+
98
+ private async resolveCurrencyOverride() {
99
+ const { apiDetails } = await this.resolveSettings()
100
+ if (typeof apiDetails.currency_code === "string" && apiDetails.currency_code.trim()) {
101
+ return normalizeCurrencyCode(apiDetails.currency_code)
102
+ }
103
+ return normalizeCurrencyCode(process.env.PAYPAL_CURRENCY || "EUR")
104
+ }
105
+
106
+ private async getPayPalAccessToken() {
107
+ const paypal = this.resolvePayPalService()
108
+ if (!paypal) {
109
+ // Fallback: load credentials directly from paypal_connection table
110
+ const { Pool: _FbPool } = require("pg")
111
+ const _fbPool = new _FbPool({ connectionString: process.env.DATABASE_URL })
112
+ const _fbResult = await _fbPool.query(
113
+ "SELECT metadata, environment, seller_client_id, seller_client_secret FROM paypal_connection WHERE status='connected' ORDER BY created_at DESC LIMIT 1"
114
+ ).finally(() => _fbPool.end())
115
+ const _fbRow = _fbResult.rows[0]
116
+ if (!_fbRow) throw new Error("No active PayPal connection found in DB")
117
+ const _fbEnv = _fbRow.environment || "sandbox"
118
+ const _fbCreds = (_fbRow.metadata && _fbRow.metadata.credentials && _fbRow.metadata.credentials[_fbEnv]) || {}
119
+ const _fbId = _fbCreds.client_id || _fbRow.seller_client_id
120
+ const _fbSec = _fbCreds.client_secret || _fbRow.seller_client_secret
121
+ const _fbBase = _fbEnv === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"
122
+ const _fbAuth = Buffer.from(`${_fbId}:${_fbSec}`).toString("base64")
123
+ const _fbResp = await fetch(`${_fbBase}/v1/oauth2/token`, {
124
+ method: "POST",
125
+ headers: { Authorization: `Basic ${_fbAuth}`, "Content-Type": "application/x-www-form-urlencoded" },
126
+ body: "grant_type=client_credentials",
127
+ })
128
+ const _fbText = await _fbResp.text()
129
+ if (!_fbResp.ok) throw new Error(`PayPal token error (${_fbResp.status}): ${_fbText}`)
130
+ const _fbJson = JSON.parse(_fbText)
131
+ return { accessToken: String(_fbJson.access_token), base: _fbBase }
132
+ }
133
+ const creds = await paypal.getActiveCredentials()
134
+ const base =
135
+ creds.environment === "live"
136
+ ? "https://api-m.paypal.com"
137
+ : "https://api-m.sandbox.paypal.com"
138
+ const auth = Buffer.from(`${creds.client_id}:${creds.client_secret}`).toString("base64")
139
+
140
+ const resp = await fetch(`${base}/v1/oauth2/token`, {
141
+ method: "POST",
142
+ headers: {
143
+ Authorization: `Basic ${auth}`,
144
+ "Content-Type": "application/x-www-form-urlencoded",
145
+ },
146
+ body: "grant_type=client_credentials",
147
+ })
148
+
149
+ const text = await resp.text()
150
+ if (!resp.ok) {
151
+ throw new Error(`PayPal token error (${resp.status}): ${text}`)
152
+ }
153
+
154
+ const json = JSON.parse(text)
155
+ return { accessToken: String(json.access_token), base }
156
+ }
157
+
158
+
159
+ private async getOrderDetails(orderId: string) {
160
+ const { accessToken, base } = await this.getPayPalAccessToken()
161
+ const resp = await fetch(`${base}/v2/checkout/orders/${orderId}`, {
162
+ method: "GET",
163
+ headers: {
164
+ Authorization: `Bearer ${accessToken}`,
165
+ "Content-Type": "application/json",
166
+ },
167
+ })
168
+
169
+ const text = await resp.text()
170
+ if (!resp.ok) {
171
+ throw new Error(`PayPal get order error (${resp.status}): ${text}`)
172
+ }
173
+
174
+ return JSON.parse(text)
175
+ }
176
+
177
+ private getIdempotencyKey(input: { context?: { idempotency_key?: string } }, suffix: string) {
178
+ const key = input?.context?.idempotency_key?.trim()
179
+ if (key) {
180
+ return `${key}-${suffix}`
181
+ }
182
+ return `pp-${suffix}-${generateSessionId()}`
183
+ }
184
+
185
+ private async normalizePaymentData(input: { data?: Record<string, unknown> }) {
186
+ const data = (input.data || {}) as Record<string, any>
187
+ const amount = Number(data.amount ?? 0)
188
+ const currencyOverride = await this.resolveCurrencyOverride()
189
+ const currencyCode = normalizeCurrencyCode(
190
+ data.currency_code || currencyOverride || "EUR"
191
+ )
192
+ assertPayPalCurrencySupported({
193
+ currencyCode,
194
+ paypalCurrencyOverride: currencyOverride,
195
+ })
196
+ return { data, amount, currencyCode }
197
+ }
198
+
199
+ private mapCaptureStatus(status?: string) {
200
+ const normalized = String(status || "").toUpperCase()
201
+ if (!normalized) {
202
+ return null
203
+ }
204
+
205
+ if (normalized === "COMPLETED") {
206
+ return "captured"
207
+ }
208
+
209
+ if (normalized === "PENDING") {
210
+ return "pending"
211
+ }
212
+
213
+ if (["DENIED", "DECLINED", "FAILED"].includes(normalized)) {
214
+ return "error"
215
+ }
216
+
217
+ if (["REFUNDED", "PARTIALLY_REFUNDED", "REVERSED"].includes(normalized)) {
218
+ return "canceled"
219
+ }
220
+
221
+ return null
222
+ }
223
+
224
+ private mapAuthorizationStatus(status?: string) {
225
+ const normalized = String(status || "").toUpperCase()
226
+ if (!normalized) {
227
+ return null
228
+ }
229
+
230
+ if (["CREATED", "APPROVED", "PENDING"].includes(normalized)) {
231
+ return "authorized"
232
+ }
233
+
234
+ if (["VOIDED", "EXPIRED"].includes(normalized)) {
235
+ return "canceled"
236
+ }
237
+
238
+ if (["DENIED", "DECLINED", "FAILED"].includes(normalized)) {
239
+ return "error"
240
+ }
241
+
242
+ return null
243
+ }
244
+
245
+ private serializeError(error: unknown) {
246
+ if (error instanceof Error) {
247
+ const errorWithCause = error as Error & { cause?: unknown }
248
+ const cause = errorWithCause.cause
249
+ return {
250
+ name: error.name,
251
+ message: error.message,
252
+ stack: error.stack,
253
+ cause:
254
+ cause instanceof Error
255
+ ? {
256
+ name: cause.name,
257
+ message: cause.message,
258
+ stack: cause.stack,
259
+ }
260
+ : cause,
261
+ }
262
+ }
263
+
264
+ return {
265
+ message: String(error),
266
+ }
267
+ }
268
+
269
+ private mapOrderStatus(status?: string) {
270
+ const normalized = String(status || "").toUpperCase()
271
+ if (!normalized) {
272
+ return "pending"
273
+ }
274
+
275
+ if (normalized === "COMPLETED") {
276
+ return "captured"
277
+ }
278
+
279
+ if (normalized === "APPROVED") {
280
+ return "authorized"
281
+ }
282
+
283
+ if (["VOIDED", "CANCELLED"].includes(normalized)) {
284
+ return "canceled"
285
+ }
286
+
287
+ if (["CREATED", "SAVED", "PAYER_ACTION_REQUIRED"].includes(normalized)) {
288
+ return "pending"
289
+ }
290
+
291
+ if (["FAILED", "EXPIRED"].includes(normalized)) {
292
+ return "error"
293
+ }
294
+
295
+ return "pending"
296
+ }
297
+
298
+ private async recordFailure(eventType: string, metadata?: Record<string, unknown>) {
299
+ const paypal = this.resolvePayPalService()
300
+ if (!paypal) {
301
+ return
302
+ }
303
+
304
+ try {
305
+ await paypal.recordPaymentLog(eventType, metadata)
306
+ await paypal.recordAuditEvent(eventType, metadata)
307
+ await paypal.recordMetric(eventType)
308
+ } catch {
309
+ // ignore audit logging failures
310
+ }
311
+ }
312
+
313
+ private async recordSuccess(metricName: string) {
314
+ const paypal = this.resolvePayPalService()
315
+ if (!paypal) {
316
+ return
317
+ }
318
+
319
+ try {
320
+ await paypal.recordMetric(metricName)
321
+ } catch {
322
+ // ignore metrics failures
323
+ }
324
+ }
325
+
326
+ private async recordPaymentEvent(eventType: string, metadata?: Record<string, unknown>) {
327
+ const paypal = this.resolvePayPalService()
328
+ if (!paypal) {
329
+ return
330
+ }
331
+
332
+ try {
333
+ await paypal.recordPaymentLog(eventType, metadata)
334
+ } catch {
335
+ // ignore payment logging failures
336
+ }
337
+ }
338
+
339
+ async createAccountHolder(
340
+ input: CreateAccountHolderInput
341
+ ): Promise<CreateAccountHolderOutput> {
342
+ const customerId = input.context?.customer?.id
343
+ const externalId = customerId ? `paypal_${customerId}` : `paypal_${generateSessionId()}`
344
+
345
+ return {
346
+ id: externalId,
347
+ data: {
348
+ email: input.context?.customer?.email || null,
349
+ customer_id: customerId || null,
350
+ },
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Create a payment session when the customer selects PayPal.
356
+ * Must return an object containing an `id` and `data`.
357
+ */
358
+ async initiatePayment(
359
+ input: InitiatePaymentInput
360
+ ): Promise<InitiatePaymentOutput> {
361
+ const providerId = (input.data as Record<string, any> | undefined)?.provider_id
362
+ try {
363
+ const currencyOverride = await this.resolveCurrencyOverride()
364
+ const currencyCode = normalizeCurrencyCode(
365
+ input.currency_code || currencyOverride || "EUR"
366
+ )
367
+ assertPayPalCurrencySupported({
368
+ currencyCode,
369
+ paypalCurrencyOverride: currencyOverride,
370
+ })
371
+
372
+
373
+ return {
374
+ id: generateSessionId(),
375
+ data: {
376
+ ...(input.data || {}),
377
+ ...(providerId ? { provider_id: providerId } : {}),
378
+ amount: input.amount,
379
+ currency_code: currencyCode,
380
+ },
381
+ }
382
+ } catch (error) {
383
+ console.error("[PayPal] provider initiate failed", {
384
+ provider_id: providerId,
385
+ payment_collection_id: (input.data as Record<string, any> | undefined)
386
+ ?.payment_collection_id,
387
+ cart_id: (input.data as Record<string, any> | undefined)?.cart_id,
388
+ amount: input.amount,
389
+ currency_code: input.currency_code,
390
+ error: this.serializeError(error),
391
+ })
392
+ await this.recordFailure("initiate_failed", {
393
+ error: this.serializeError(error),
394
+ currency_code: input.currency_code,
395
+ amount: input.amount,
396
+ provider_id: providerId,
397
+ data: input.data ?? null,
398
+ })
399
+ throw error
400
+ }
401
+ }
402
+
403
+ async updatePayment(input: UpdatePaymentInput): Promise<UpdatePaymentOutput> {
404
+ const currencyOverride = await this.resolveCurrencyOverride()
405
+ const currencyCode = normalizeCurrencyCode(
406
+ input.currency_code || currencyOverride || "EUR"
407
+ )
408
+ assertPayPalCurrencySupported({
409
+ currencyCode,
410
+ paypalCurrencyOverride: currencyOverride,
411
+ })
412
+
413
+ const providerId = (input.data as Record<string, any> | undefined)?.provider_id
414
+
415
+ return {
416
+ data: {
417
+ ...(input.data || {}),
418
+ ...(providerId ? { provider_id: providerId } : {}),
419
+ amount: input.amount,
420
+ currency_code: currencyCode,
421
+ },
422
+ }
423
+ }
424
+
425
+ async authorizePayment(
426
+ input: AuthorizePaymentInput
427
+ ): Promise<AuthorizePaymentOutput> {
428
+ const { data, amount, currencyCode } = await this.normalizePaymentData(input)
429
+
430
+ const existingPayPal = (data.paypal || {}) as Record<string, any>
431
+ if (
432
+ existingPayPal.capture_id ||
433
+ existingPayPal.authorization_id ||
434
+ (data as any).authorized_at ||
435
+ (data as any).captured_at
436
+ ) {
437
+ const { additionalSettings } = await this.resolveSettings()
438
+ const paymentAction =
439
+ typeof additionalSettings.paymentAction === "string"
440
+ ? additionalSettings.paymentAction
441
+ : "capture"
442
+ const returnStatus = paymentAction === "authorize" ? "authorized" : "captured"
443
+ return {
444
+ status: returnStatus,
445
+ data: {
446
+ ...(input.data || {}),
447
+ ...(paymentAction === "authorize"
448
+ ? { authorized_at: new Date().toISOString() }
449
+ : { captured_at: new Date().toISOString() }),
450
+ },
451
+ }
452
+ }
453
+
454
+
455
+ const requestId = this.getIdempotencyKey(input, "authorize")
456
+ let debugId: string | null = null
457
+ const { additionalSettings } = await this.resolveSettings()
458
+ const paymentActionRaw =
459
+ typeof additionalSettings.paymentAction === "string"
460
+ ? additionalSettings.paymentAction
461
+ : "capture"
462
+ const orderIntent = paymentActionRaw === "authorize" ? "AUTHORIZE" : "CAPTURE"
463
+
464
+ try {
465
+ const { accessToken, base } = await this.getPayPalAccessToken()
466
+ const existingPayPal = (data.paypal || {}) as Record<string, any>
467
+ let orderId = String(existingPayPal.order_id || data.order_id || "")
468
+ let order: Record<string, any> | null = null
469
+ let authorization: any = null
470
+
471
+ if (!orderId) {
472
+ const value = formatAmountForPayPal(amount, currencyCode || "EUR")
473
+
474
+ const orderPayload = {
475
+ intent: orderIntent,
476
+ purchase_units: [
477
+ {
478
+ reference_id: data.cart_id || data.payment_collection_id || undefined,
479
+ custom_id: data.session_id || data.cart_id || data.payment_collection_id || undefined,
480
+ amount: {
481
+ currency_code: currencyCode || "EUR",
482
+ value,
483
+ },
484
+ },
485
+ ],
486
+ custom_id: data.session_id || data.cart_id || data.payment_collection_id || undefined,
487
+ }
488
+
489
+ const ppResp = await fetch(`${base}/v2/checkout/orders`, {
490
+ method: "POST",
491
+ headers: {
492
+ Authorization: `Bearer ${accessToken}`,
493
+ "Content-Type": "application/json",
494
+ "PayPal-Request-Id": requestId,
495
+ },
496
+ body: JSON.stringify(orderPayload),
497
+ })
498
+
499
+ const ppText = await ppResp.text()
500
+ debugId = ppResp.headers.get("paypal-debug-id")
501
+ if (!ppResp.ok) {
502
+ throw new Error(
503
+ `PayPal create order error (${ppResp.status}): ${ppText}${
504
+ debugId ? ` debug_id=${debugId}` : ""
505
+ }`
506
+ )
507
+ }
508
+
509
+ order = JSON.parse(ppText) as Record<string, any>
510
+ orderId = String(order.id || "")
511
+ } else {
512
+ order = (await this.getOrderDetails(orderId)) as Record<string, any> | null
513
+ }
514
+
515
+ if (!order || !orderId) {
516
+ throw new Error("Unable to resolve PayPal order details for authorization.")
517
+ }
518
+
519
+ const existingAuthorization =
520
+ order?.purchase_units?.[0]?.payments?.authorizations?.[0] || null
521
+
522
+ if (existingAuthorization) {
523
+ authorization = order
524
+ } else {
525
+ const authorizeResp = await fetch(
526
+ `${base}/v2/checkout/orders/${orderId}/authorize`,
527
+ {
528
+ method: "POST",
529
+ headers: {
530
+ Authorization: `Bearer ${accessToken}`,
531
+ "Content-Type": "application/json",
532
+ "PayPal-Request-Id": `${requestId}-auth`,
533
+ },
534
+ }
535
+ )
536
+
537
+ const authorizeText = await authorizeResp.text()
538
+ const authorizeDebugId = authorizeResp.headers.get("paypal-debug-id")
539
+ if (!authorizeResp.ok) {
540
+ throw new Error(
541
+ `PayPal authorize order error (${authorizeResp.status}): ${authorizeText}${
542
+ authorizeDebugId ? ` debug_id=${authorizeDebugId}` : ""
543
+ }`
544
+ )
545
+ }
546
+
547
+ authorization = JSON.parse(authorizeText)
548
+ }
549
+
550
+ const authorizationId =
551
+ authorization?.purchase_units?.[0]?.payments?.authorizations?.[0]?.id ||
552
+ existingAuthorization?.id
553
+
554
+ await this.recordSuccess("authorize_success")
555
+ await this.recordPaymentEvent("authorize", {
556
+ order_id: orderId,
557
+ authorization_id: authorizationId,
558
+ amount,
559
+ currency_code: currencyCode,
560
+ request_id: requestId,
561
+ })
562
+
563
+ return {
564
+ status: "authorized",
565
+ data: {
566
+ ...(input.data || {}),
567
+ paypal: {
568
+ ...((input.data || {}).paypal as Record<string, unknown>),
569
+ order_id: orderId,
570
+ order: order || authorization,
571
+ authorization_id: authorizationId,
572
+ authorizations:
573
+ authorization?.purchase_units?.[0]?.payments?.authorizations || [],
574
+ },
575
+ authorized_at: new Date().toISOString(),
576
+ },
577
+ }
578
+ } catch (error: any) {
579
+ await this.recordFailure("authorize_failed", {
580
+ request_id: requestId,
581
+ cart_id: data.cart_id,
582
+ payment_collection_id: data.payment_collection_id,
583
+ debug_id: debugId,
584
+ message: error?.message,
585
+ })
586
+ throw error
587
+ }
588
+ }
589
+
590
+ async retrievePayment(
591
+ input: RetrievePaymentInput
592
+ ): Promise<RetrievePaymentOutput> {
593
+ const data = (input.data || {}) as Record<string, any>
594
+ const paypalData = (data.paypal || {}) as Record<string, any>
595
+ const orderId = String(paypalData.order_id || data.order_id || "")
596
+ if (!orderId) {
597
+ return { data: { ...(input.data || {}) } }
598
+ }
599
+
600
+ const order = await this.getOrderDetails(orderId)
601
+ const capture = order?.purchase_units?.[0]?.payments?.captures?.[0]
602
+ const authorization = order?.purchase_units?.[0]?.payments?.authorizations?.[0]
603
+
604
+ return {
605
+ data: {
606
+ ...(input.data || {}),
607
+ paypal: {
608
+ ...((input.data || {}).paypal as Record<string, unknown>),
609
+ order,
610
+ authorization_id: authorization?.id || paypalData.authorization_id,
611
+ capture_id: capture?.id || paypalData.capture_id,
612
+ },
613
+ },
614
+ }
615
+ }
616
+
617
+ async getPaymentStatus(
618
+ input: GetPaymentStatusInput
619
+ ): Promise<GetPaymentStatusOutput> {
620
+ const data = (input.data || {}) as Record<string, any>
621
+ const paypalData = (data.paypal || {}) as Record<string, any>
622
+ const orderId = String(paypalData.order_id || data.order_id || "")
623
+ if (!orderId) {
624
+ return { status: "pending", data: { ...(input.data || {}) } }
625
+ }
626
+
627
+ try {
628
+ const order = await this.getOrderDetails(orderId)
629
+ const capture = order?.purchase_units?.[0]?.payments?.captures?.[0]
630
+ const authorization = order?.purchase_units?.[0]?.payments?.authorizations?.[0]
631
+ const mappedStatus =
632
+ this.mapCaptureStatus(capture?.status) ||
633
+ this.mapAuthorizationStatus(authorization?.status) ||
634
+ this.mapOrderStatus(order?.status) ||
635
+ "pending"
636
+
637
+ await this.recordSuccess("status_success")
638
+ return {
639
+ status: mappedStatus,
640
+ data: {
641
+ ...(input.data || {}),
642
+ paypal: {
643
+ ...((input.data || {}).paypal as Record<string, unknown>),
644
+ order,
645
+ authorization_id: authorization?.id || paypalData.authorization_id,
646
+ capture_id: capture?.id || paypalData.capture_id,
647
+ },
648
+ },
649
+ }
650
+ } catch (error: any) {
651
+ await this.recordFailure("status_failed", {
652
+ order_id: orderId,
653
+ message: error?.message,
654
+ })
655
+ throw error
656
+ }
657
+ }
658
+
659
+ async capturePayment(
660
+ input: CapturePaymentInput
661
+ ): Promise<CapturePaymentOutput> {
662
+ const data = (input.data || {}) as Record<string, any>
663
+ const paypalData = (data.paypal || {}) as Record<string, any>
664
+ const orderId = String(paypalData.order_id || data.order_id || "")
665
+ let authorizationId = String(
666
+ paypalData.authorization_id || data.authorization_id || ""
667
+ )
668
+ if (!orderId) {
669
+ throw new Error("PayPal order_id is required to capture payment")
670
+ }
671
+
672
+ if (paypalData.capture_id || paypalData.capture) {
673
+ return {
674
+ data: {
675
+ ...(input.data || {}),
676
+ paypal: {
677
+ ...((input.data || {}).paypal as Record<string, unknown>),
678
+ capture_id: paypalData.capture_id,
679
+ capture: paypalData.capture,
680
+ },
681
+ captured_at: new Date().toISOString(),
682
+ },
683
+ }
684
+ }
685
+
686
+ const requestId = this.getIdempotencyKey(input, `capture-${orderId}`)
687
+ const { amount, currencyCode } = await this.normalizePaymentData(input)
688
+ let debugId: string | null = null
689
+
690
+ try {
691
+ const { accessToken, base } = await this.getPayPalAccessToken()
692
+ const order = await this.getOrderDetails(orderId).catch(() => null)
693
+ const existingCapture = order?.purchase_units?.[0]?.payments?.captures?.[0]
694
+ if (existingCapture?.id) {
695
+ return {
696
+ data: {
697
+ ...(input.data || {}),
698
+ paypal: {
699
+ ...((input.data || {}).paypal as Record<string, unknown>),
700
+ capture_id: existingCapture.id,
701
+ capture: existingCapture,
702
+ },
703
+ captured_at: new Date().toISOString(),
704
+ },
705
+ }
706
+ }
707
+ const resolvedIntent = String(
708
+ order?.intent || paypalData.order?.intent || data.intent || ""
709
+ ).toUpperCase()
710
+ if (!authorizationId && resolvedIntent === "AUTHORIZE") {
711
+ const authorizeResp = await fetch(
712
+ `${base}/v2/checkout/orders/${orderId}/authorize`,
713
+ {
714
+ method: "POST",
715
+ headers: {
716
+ Authorization: `Bearer ${accessToken}`,
717
+ "Content-Type": "application/json",
718
+ "PayPal-Request-Id": `${requestId}-auth`,
719
+ },
720
+ }
721
+ )
722
+ const authorizeText = await authorizeResp.text()
723
+ debugId = authorizeResp.headers.get("paypal-debug-id")
724
+ if (!authorizeResp.ok) {
725
+ throw new Error(
726
+ `PayPal authorize order error (${authorizeResp.status}): ${authorizeText}${
727
+ debugId ? ` debug_id=${debugId}` : ""
728
+ }`
729
+ )
730
+ }
731
+ const authorization = JSON.parse(authorizeText)
732
+ authorizationId =
733
+ authorization?.purchase_units?.[0]?.payments?.authorizations?.[0]?.id
734
+ }
735
+
736
+ const isFinalCapture =
737
+ paypalData.is_final_capture ??
738
+ data.is_final_capture ??
739
+ data.final_capture ??
740
+ undefined
741
+ const capturePayload =
742
+ amount > 0
743
+ ? {
744
+ amount: {
745
+ currency_code: currencyCode || "EUR",
746
+ value: formatAmountForPayPal(amount, currencyCode || "EUR"),
747
+ },
748
+ ...(typeof isFinalCapture === "boolean"
749
+ ? { is_final_capture: isFinalCapture }
750
+ : {}),
751
+ }
752
+ : {
753
+ ...(typeof isFinalCapture === "boolean"
754
+ ? { is_final_capture: isFinalCapture }
755
+ : {}),
756
+ }
757
+
758
+ const captureUrl = authorizationId
759
+ ? `${base}/v2/payments/authorizations/${authorizationId}/capture`
760
+ : `${base}/v2/checkout/orders/${orderId}/capture`
761
+
762
+ const ppResp = await fetch(captureUrl, {
763
+ method: "POST",
764
+ headers: {
765
+ Authorization: `Bearer ${accessToken}`,
766
+ "Content-Type": "application/json",
767
+ "PayPal-Request-Id": requestId,
768
+ },
769
+ body: JSON.stringify(capturePayload),
770
+ })
771
+
772
+ const ppText = await ppResp.text()
773
+ debugId = ppResp.headers.get("paypal-debug-id")
774
+ if (!ppResp.ok) {
775
+ throw new Error(
776
+ `PayPal capture error (${ppResp.status}): ${ppText}${
777
+ debugId ? ` debug_id=${debugId}` : ""
778
+ }`
779
+ )
780
+ }
781
+
782
+ const capture = JSON.parse(ppText)
783
+ const captureId =
784
+ capture?.id || capture?.purchase_units?.[0]?.payments?.captures?.[0]?.id
785
+ const existingCaptures = Array.isArray(paypalData.captures)
786
+ ? paypalData.captures
787
+ : []
788
+ const captureEntry = {
789
+ id: captureId,
790
+ status: capture?.status,
791
+ amount: capture?.amount,
792
+ raw: capture,
793
+ }
794
+
795
+ await this.recordSuccess("capture_success")
796
+ await this.recordPaymentEvent("capture", {
797
+ order_id: orderId,
798
+ capture_id: captureId,
799
+ authorization_id: authorizationId || undefined,
800
+ amount,
801
+ currency_code: currencyCode,
802
+ request_id: requestId,
803
+ })
804
+
805
+ return {
806
+ data: {
807
+ ...(input.data || {}),
808
+ paypal: {
809
+ ...((input.data || {}).paypal as Record<string, unknown>),
810
+ order_id: orderId,
811
+ capture_id: captureId,
812
+ capture,
813
+ authorization_id: authorizationId || paypalData.authorization_id,
814
+ captures: [...existingCaptures, captureEntry],
815
+ },
816
+ captured_at: new Date().toISOString(),
817
+ },
818
+ }
819
+ } catch (error: any) {
820
+ await this.recordFailure("capture_failed", {
821
+ order_id: orderId,
822
+ request_id: requestId,
823
+ debug_id: debugId,
824
+ message: error?.message,
825
+ })
826
+ throw error
827
+ }
828
+ }
829
+
830
+ async refundPayment(
831
+ input: RefundPaymentInput
832
+ ): Promise<RefundPaymentOutput> {
833
+ const data = (input.data || {}) as Record<string, any>
834
+ const paypalData = (data.paypal || {}) as Record<string, any>
835
+ const captureId = String(paypalData.capture_id || data.capture_id || "")
836
+ const refundReason = String(
837
+ paypalData.refund_reason || data.refund_reason || data.reason || ""
838
+ ).trim()
839
+ const refundReasonCode = String(
840
+ paypalData.refund_reason_code || data.refund_reason_code || data.reason_code || ""
841
+ ).trim()
842
+ if (!captureId) {
843
+ return {
844
+ data: {
845
+ ...(input.data || {}),
846
+ refunded_at: new Date().toISOString(),
847
+ },
848
+ }
849
+ }
850
+
851
+ const requestId = this.getIdempotencyKey(input, `refund-${captureId}`)
852
+ const { amount, currencyCode } = await this.normalizePaymentData(input)
853
+ let debugId: string | null = null
854
+
855
+ try {
856
+ const { accessToken, base } = await this.getPayPalAccessToken()
857
+ const refundPayload: Record<string, any> =
858
+ amount > 0
859
+ ? {
860
+ amount: {
861
+ currency_code: currencyCode || "EUR",
862
+ value: formatAmountForPayPal(amount, currencyCode || "EUR"),
863
+ },
864
+ }
865
+ : {}
866
+
867
+ if (refundReason) {
868
+ refundPayload.note_to_payer = refundReason
869
+ }
870
+
871
+ const ppResp = await fetch(`${base}/v2/payments/captures/${captureId}/refund`, {
872
+ method: "POST",
873
+ headers: {
874
+ Authorization: `Bearer ${accessToken}`,
875
+ "Content-Type": "application/json",
876
+ "PayPal-Request-Id": requestId,
877
+ },
878
+ body: JSON.stringify(refundPayload),
879
+ })
880
+
881
+ const ppText = await ppResp.text()
882
+ debugId = ppResp.headers.get("paypal-debug-id")
883
+ if (!ppResp.ok) {
884
+ throw new Error(
885
+ `PayPal refund error (${ppResp.status}): ${ppText}${
886
+ debugId ? ` debug_id=${debugId}` : ""
887
+ }`
888
+ )
889
+ }
890
+
891
+ const refund = JSON.parse(ppText)
892
+ const existingRefunds = Array.isArray(paypalData.refunds) ? paypalData.refunds : []
893
+ const refundEntry = {
894
+ id: refund?.id,
895
+ status: refund?.status,
896
+ amount: refund?.amount,
897
+ reason: refundReason || refund?.note_to_payer,
898
+ reason_code: refundReasonCode || refund?.reason_code,
899
+ raw: refund,
900
+ }
901
+
902
+ await this.recordSuccess("refund_success")
903
+ await this.recordPaymentEvent("refund", {
904
+ capture_id: captureId,
905
+ refund_id: refund?.id,
906
+ amount,
907
+ currency_code: currencyCode,
908
+ request_id: requestId,
909
+ reason: refundReason,
910
+ reason_code: refundReasonCode,
911
+ })
912
+
913
+ return {
914
+ data: {
915
+ ...(input.data || {}),
916
+ paypal: {
917
+ ...((input.data || {}).paypal as Record<string, unknown>),
918
+ refund_id: refund?.id,
919
+ refund_status: refund?.status,
920
+ refund_reason: refundReason || refund?.note_to_payer,
921
+ refund_reason_code: refundReasonCode || refund?.reason_code,
922
+ refunds: [...existingRefunds, refundEntry],
923
+ refund,
924
+ },
925
+ refunded_at: new Date().toISOString(),
926
+ },
927
+ }
928
+ } catch (error: any) {
929
+ await this.recordFailure("refund_failed", {
930
+ capture_id: captureId,
931
+ request_id: requestId,
932
+ debug_id: debugId,
933
+ message: error?.message,
934
+ })
935
+ throw error
936
+ }
937
+ }
938
+
939
+ async cancelPayment(
940
+ input: CancelPaymentInput
941
+ ): Promise<CancelPaymentOutput> {
942
+ const data = (input.data || {}) as Record<string, any>
943
+ const paypalData = (data.paypal || {}) as Record<string, any>
944
+ const orderId = String(paypalData.order_id || data.order_id || "")
945
+ const captureId = String(paypalData.capture_id || data.capture_id || "")
946
+ const storedAuthorizationId = String(
947
+ paypalData.authorization_id || data.authorization_id || ""
948
+ )
949
+ let debugId: string | null = null
950
+
951
+ try {
952
+ const order = orderId ? await this.getOrderDetails(orderId) : null
953
+ const intent = String(order?.intent || "").toUpperCase()
954
+ const authorizationId =
955
+ order?.purchase_units?.[0]?.payments?.authorizations?.[0]?.id ||
956
+ storedAuthorizationId
957
+
958
+ if (intent === "AUTHORIZE" && authorizationId) {
959
+ const { accessToken, base } = await this.getPayPalAccessToken()
960
+ const requestId = this.getIdempotencyKey(input, `void-${authorizationId}`)
961
+
962
+ const resp = await fetch(
963
+ `${base}/v2/payments/authorizations/${authorizationId}/void`,
964
+ {
965
+ method: "POST",
966
+ headers: {
967
+ Authorization: `Bearer ${accessToken}`,
968
+ "Content-Type": "application/json",
969
+ "PayPal-Request-Id": requestId,
970
+ },
971
+ }
972
+ )
973
+
974
+ if (!resp.ok) {
975
+ const text = await resp.text()
976
+ debugId = resp.headers.get("paypal-debug-id")
977
+ throw new Error(
978
+ `PayPal void error (${resp.status}): ${text}${
979
+ debugId ? ` debug_id=${debugId}` : ""
980
+ }`
981
+ )
982
+ }
983
+
984
+ await this.recordSuccess("void_success")
985
+ await this.recordPaymentEvent("void", {
986
+ order_id: orderId,
987
+ authorization_id: authorizationId,
988
+ request_id: requestId,
989
+ })
990
+ } else if (captureId) {
991
+ const { accessToken, base } = await this.getPayPalAccessToken()
992
+ const requestId = this.getIdempotencyKey(input, `refund-${captureId}`)
993
+
994
+ const resp = await fetch(`${base}/v2/payments/captures/${captureId}/refund`, {
995
+ method: "POST",
996
+ headers: {
997
+ Authorization: `Bearer ${accessToken}`,
998
+ "Content-Type": "application/json",
999
+ "PayPal-Request-Id": requestId,
1000
+ },
1001
+ body: JSON.stringify({}),
1002
+ })
1003
+
1004
+ if (!resp.ok) {
1005
+ const text = await resp.text()
1006
+ debugId = resp.headers.get("paypal-debug-id")
1007
+ throw new Error(
1008
+ `PayPal refund error (${resp.status}): ${text}${
1009
+ debugId ? ` debug_id=${debugId}` : ""
1010
+ }`
1011
+ )
1012
+ }
1013
+
1014
+ const refund = await resp.json().catch(() => ({}))
1015
+ const existingRefunds = Array.isArray(paypalData.refunds) ? paypalData.refunds : []
1016
+ const refundEntry = {
1017
+ id: refund?.id,
1018
+ status: refund?.status,
1019
+ amount: refund?.amount,
1020
+ raw: refund,
1021
+ }
1022
+ paypalData.refund_id = refund?.id
1023
+ paypalData.refund_status = refund?.status
1024
+ paypalData.refunds = [...existingRefunds, refundEntry]
1025
+
1026
+ await this.recordSuccess("cancel_refund_success")
1027
+ await this.recordPaymentEvent("cancel_refund", {
1028
+ order_id: orderId,
1029
+ capture_id: captureId,
1030
+ refund_id: refund?.id,
1031
+ request_id: requestId,
1032
+ })
1033
+ }
1034
+
1035
+ return {
1036
+ data: {
1037
+ ...(input.data || {}),
1038
+ paypal: {
1039
+ ...((input.data || {}).paypal as Record<string, unknown>),
1040
+ order: order || undefined,
1041
+ authorization_id: authorizationId || storedAuthorizationId,
1042
+ capture_id: captureId || paypalData.capture_id,
1043
+ refund_id: paypalData.refund_id,
1044
+ refund_status: paypalData.refund_status,
1045
+ refunds: paypalData.refunds,
1046
+ },
1047
+ canceled_at: new Date().toISOString(),
1048
+ },
1049
+ }
1050
+ } catch (error: any) {
1051
+ await this.recordFailure("cancel_failed", {
1052
+ order_id: orderId,
1053
+ capture_id: captureId,
1054
+ debug_id: debugId,
1055
+ message: error?.message,
1056
+ })
1057
+ throw error
1058
+ }
1059
+ }
1060
+
1061
+ async deletePayment(
1062
+ _input: DeletePaymentInput
1063
+ ): Promise<DeletePaymentOutput> {
1064
+ return { data: {} }
1065
+ }
1066
+
1067
+ /**
1068
+ * Required by AbstractPaymentProvider in Medusa v2.
1069
+ * This is used by /hooks/payment/{identifier}_{providerId}
1070
+ */
1071
+ async getWebhookActionAndData(
1072
+ payload: ProviderWebhookPayload["payload"]
1073
+ ): Promise<WebhookActionResult> {
1074
+ return getPayPalWebhookActionAndData(payload)
1075
+ }
1076
+ }
1077
+
1078
+ export default PayPalPaymentProvider
1079
+ export { PayPalPaymentProvider }