@easypayment/medusa-paypal 0.6.6 → 0.6.8

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