@easypayment/medusa-paypal 0.6.8 → 0.7.0

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,760 +1,762 @@
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 { getPayPalWebhookActionAndData } from "./webhook-utils"
28
- import { formatAmountForPayPal } from "../utils/amounts"
29
- import {
30
- assertPayPalCurrencySupported,
31
- normalizeCurrencyCode,
32
- } from "../utils/currencies"
33
- import type PayPalModuleService from "../service"
34
-
35
- type Options = {}
36
-
37
- function generateSessionId() {
38
- try {
39
- return randomUUID()
40
- } catch {
41
- return `pp_card_${Date.now()}_${Math.random().toString(16).slice(2)}`
42
- }
43
- }
44
-
45
- class PayPalAdvancedCardProvider extends AbstractPaymentProvider<Options> {
46
- static identifier = "paypal_card"
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 as any
63
- }
64
- }
65
-
66
- private 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, any>,
78
- advancedCardSettings: (_sData.advanced_card_payments || {}) as Record<string, any>,
79
- apiDetails: (_sData.api_details || {}) as Record<string, any>,
80
- }
81
- } catch {
82
- return {
83
- additionalSettings: {} as Record<string, any>,
84
- advancedCardSettings: {} as Record<string, any>,
85
- apiDetails: {} as Record<string, any>,
86
- }
87
- }
88
- }
89
- const settings = await paypal.getSettings().catch(() => ({}))
90
- const data =
91
- settings && typeof settings === "object" && "data" in settings
92
- ? ((settings as { data?: Record<string, any> }).data ?? {})
93
- : {}
94
- return {
95
- additionalSettings: (data.additional_settings || {}) as Record<string, any>,
96
- advancedCardSettings: (data.advanced_card_payments || {}) as Record<string, any>,
97
- apiDetails: (data.api_details || {}) as Record<string, any>,
98
- }
99
- }
100
-
101
- private async resolveCurrencyOverride() {
102
- const { apiDetails } = await this.resolveSettings()
103
- if (typeof apiDetails.currency_code === "string" && apiDetails.currency_code.trim()) {
104
- return normalizeCurrencyCode(apiDetails.currency_code)
105
- }
106
- return normalizeCurrencyCode(process.env.PAYPAL_CURRENCY || "EUR")
107
- }
108
-
109
- private async getPayPalAccessToken() {
110
- const paypal = this.resolvePayPalService()
111
- let client_id: string
112
- let client_secret: string
113
- let environment: string
114
-
115
- if (!paypal) {
116
- const { Pool: _FbPool } = require("pg")
117
- const _fbPool = new _FbPool({ connectionString: process.env.DATABASE_URL })
118
- const _fbResult = await _fbPool
119
- .query(
120
- "SELECT metadata, environment, seller_client_id, seller_client_secret FROM paypal_connection WHERE status='connected' ORDER BY created_at DESC LIMIT 1"
121
- )
122
- .finally(() => _fbPool.end())
123
- const _fbRow = _fbResult.rows[0]
124
- if (!_fbRow) throw new Error("No active PayPal connection found in DB")
125
- environment = _fbRow.environment || "sandbox"
126
- const _fbCreds = (_fbRow.metadata?.credentials?.[environment]) || {}
127
- client_id = _fbCreds.client_id || _fbRow.seller_client_id
128
- client_secret = _fbCreds.client_secret || _fbRow.seller_client_secret
129
- console.info("[PayPal Card] getPayPalAccessToken fallback via DB for env:", environment)
130
- } else {
131
- const creds = await paypal.getActiveCredentials()
132
- client_id = creds.client_id
133
- client_secret = creds.client_secret
134
- environment = creds.environment
135
- }
136
-
137
- const base =
138
- environment === "live"
139
- ? "https://api-m.paypal.com"
140
- : "https://api-m.sandbox.paypal.com"
141
- const auth = Buffer.from(`${client_id}:${client_secret}`).toString("base64")
142
-
143
- const resp = await fetch(`${base}/v1/oauth2/token`, {
144
- method: "POST",
145
- headers: {
146
- Authorization: `Basic ${auth}`,
147
- "Content-Type": "application/x-www-form-urlencoded",
148
- },
149
- body: "grant_type=client_credentials",
150
- })
151
-
152
- const text = await resp.text()
153
- if (!resp.ok) {
154
- throw new Error(`PayPal token error (${resp.status}): ${text}`)
155
- }
156
-
157
- const json = JSON.parse(text)
158
- return { accessToken: String(json.access_token), base }
159
- }
160
-
161
- private async getOrderDetails(orderId: string) {
162
- const { accessToken, base } = await this.getPayPalAccessToken()
163
- const resp = await fetch(`${base}/v2/checkout/orders/${orderId}`, {
164
- method: "GET",
165
- headers: {
166
- Authorization: `Bearer ${accessToken}`,
167
- "Content-Type": "application/json",
168
- },
169
- })
170
-
171
- const text = await resp.text()
172
- if (!resp.ok) {
173
- throw new Error(`PayPal get order error (${resp.status}): ${text}`)
174
- }
175
-
176
- return JSON.parse(text)
177
- }
178
-
179
- private getIdempotencyKey(
180
- input: { context?: { idempotency_key?: string } },
181
- suffix: string
182
- ) {
183
- const key = input?.context?.idempotency_key?.trim()
184
- if (key) {
185
- return `${key}-${suffix}`
186
- }
187
- return `pp-card-${suffix}-${generateSessionId()}`
188
- }
189
-
190
- private async normalizePaymentData(input: { data?: Record<string, unknown> }) {
191
- const data = (input.data || {}) as Record<string, any>
192
- const amount = Number(data.amount ?? 0)
193
- const currencyOverride = await this.resolveCurrencyOverride()
194
- const currencyCode = normalizeCurrencyCode(
195
- data.currency_code || currencyOverride || "EUR"
196
- )
197
- assertPayPalCurrencySupported({
198
- currencyCode,
199
- paypalCurrencyOverride: currencyOverride,
200
- })
201
- return { data, amount, currencyCode }
202
- }
203
-
204
- private mapCaptureStatus(status?: string) {
205
- const normalized = String(status || "").toUpperCase()
206
- if (!normalized) {
207
- return null
208
- }
209
- if (normalized === "COMPLETED") {
210
- return "captured"
211
- }
212
- if (normalized === "PENDING") {
213
- return "pending"
214
- }
215
- if (["DENIED", "DECLINED", "FAILED"].includes(normalized)) {
216
- return "error"
217
- }
218
- if (["REFUNDED", "PARTIALLY_REFUNDED", "REVERSED"].includes(normalized)) {
219
- return "canceled"
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
- if (["CREATED", "APPROVED", "PENDING"].includes(normalized)) {
230
- return "authorized"
231
- }
232
- if (["VOIDED", "EXPIRED"].includes(normalized)) {
233
- return "canceled"
234
- }
235
- if (["DENIED", "DECLINED", "FAILED"].includes(normalized)) {
236
- return "error"
237
- }
238
- return null
239
- }
240
-
241
- private mapOrderStatus(status?: string) {
242
- const normalized = String(status || "").toUpperCase()
243
- if (!normalized) {
244
- return "pending"
245
- }
246
- if (normalized === "COMPLETED") {
247
- return "captured"
248
- }
249
- if (normalized === "APPROVED") {
250
- return "authorized"
251
- }
252
- if (["VOIDED", "CANCELLED"].includes(normalized)) {
253
- return "canceled"
254
- }
255
- if (["CREATED", "SAVED", "PAYER_ACTION_REQUIRED"].includes(normalized)) {
256
- return "pending"
257
- }
258
- if (["FAILED", "EXPIRED"].includes(normalized)) {
259
- return "error"
260
- }
261
- return "pending"
262
- }
263
-
264
- async createAccountHolder(
265
- input: CreateAccountHolderInput
266
- ): Promise<CreateAccountHolderOutput> {
267
- const customerId = input.context?.customer?.id
268
- const externalId = customerId ? `paypal_${customerId}` : `paypal_${generateSessionId()}`
269
-
270
- return {
271
- id: externalId,
272
- data: {
273
- email: input.context?.customer?.email || null,
274
- customer_id: customerId || null,
275
- },
276
- }
277
- }
278
-
279
- async initiatePayment(input: InitiatePaymentInput): Promise<InitiatePaymentOutput> {
280
- const currencyOverride = await this.resolveCurrencyOverride()
281
- const currencyCode = normalizeCurrencyCode(
282
- input.currency_code || currencyOverride || "EUR"
283
- )
284
- assertPayPalCurrencySupported({
285
- currencyCode,
286
- paypalCurrencyOverride: currencyOverride,
287
- })
288
-
289
- return {
290
- id: generateSessionId(),
291
- data: {
292
- ...(input.data || {}),
293
- // store amount/currency so Medusa has something consistent
294
- amount: input.amount,
295
- currency_code: currencyCode,
296
- },
297
- }
298
- }
299
-
300
- async updatePayment(input: UpdatePaymentInput): Promise<UpdatePaymentOutput> {
301
- const currencyOverride = await this.resolveCurrencyOverride()
302
- const currencyCode = normalizeCurrencyCode(
303
- input.currency_code || currencyOverride || "EUR"
304
- )
305
- assertPayPalCurrencySupported({
306
- currencyCode,
307
- paypalCurrencyOverride: currencyOverride,
308
- })
309
-
310
- return {
311
- data: {
312
- ...(input.data || {}),
313
- amount: input.amount,
314
- currency_code: currencyCode,
315
- },
316
- }
317
- }
318
-
319
- async authorizePayment(_input: AuthorizePaymentInput): Promise<AuthorizePaymentOutput> {
320
- const { data, amount, currencyCode } = await this.normalizePaymentData(_input)
321
- const requestId = this.getIdempotencyKey(_input, "authorize")
322
- let debugId: string | null = null
323
- const { additionalSettings, advancedCardSettings } = await this.resolveSettings()
324
- const paymentActionRaw =
325
- typeof additionalSettings.paymentAction === "string"
326
- ? additionalSettings.paymentAction
327
- : "capture"
328
- const orderIntent = paymentActionRaw === "authorize" ? "AUTHORIZE" : "CAPTURE"
329
- const threeDsRaw =
330
- typeof advancedCardSettings.threeDS === "string"
331
- ? advancedCardSettings.threeDS
332
- : "when_required"
333
- const threeDsMethod =
334
- threeDsRaw === "always"
335
- ? "SCA_ALWAYS"
336
- : threeDsRaw === "when_required" || threeDsRaw === "sli"
337
- ? "SCA_WHEN_REQUIRED"
338
- : null
339
- const disabledCards = Array.isArray(advancedCardSettings.disabledCards)
340
- ? advancedCardSettings.disabledCards.map((card: string) => String(card).toLowerCase())
341
- : []
342
- const cardBrand = String(
343
- data.card_brand || data.cardBrand || data?.paypal?.card_brand || ""
344
- ).toLowerCase()
345
- if (cardBrand && disabledCards.includes(cardBrand)) {
346
- throw new Error(`Card brand ${cardBrand} is disabled by admin settings.`)
347
- }
348
-
349
- const { accessToken, base } = await this.getPayPalAccessToken()
350
- const existingPayPal = (data.paypal || {}) as Record<string, any>
351
- let orderId = String(existingPayPal.order_id || data.order_id || "")
352
- let order: Record<string, any> | null = null
353
- let authorization: Record<string, any> | null = null
354
-
355
- if (!orderId) {
356
- const value = formatAmountForPayPal(amount, currencyCode || "EUR")
357
- const orderPayload = {
358
- intent: orderIntent,
359
- purchase_units: [
360
- {
361
- reference_id: data.cart_id || data.payment_collection_id || undefined,
362
- custom_id: data.session_id || data.cart_id || data.payment_collection_id || undefined,
363
- amount: {
364
- currency_code: currencyCode || "EUR",
365
- value,
366
- },
367
- },
368
- ],
369
- custom_id: data.session_id || data.cart_id || data.payment_collection_id || undefined,
370
- ...(threeDsMethod
371
- ? {
372
- payment_source: {
373
- card: {
374
- attributes: {
375
- verification: {
376
- method: threeDsMethod,
377
- },
378
- },
379
- },
380
- },
381
- }
382
- : {}),
383
- }
384
-
385
- const ppResp = await fetch(`${base}/v2/checkout/orders`, {
386
- method: "POST",
387
- headers: {
388
- Authorization: `Bearer ${accessToken}`,
389
- "Content-Type": "application/json",
390
- "PayPal-Request-Id": requestId,
391
- },
392
- body: JSON.stringify(orderPayload),
393
- })
394
-
395
- const ppText = await ppResp.text()
396
- debugId = ppResp.headers.get("paypal-debug-id")
397
- if (!ppResp.ok) {
398
- throw new Error(
399
- `PayPal create order error (${ppResp.status}): ${ppText}${
400
- debugId ? ` debug_id=${debugId}` : ""
401
- }`
402
- )
403
- }
404
-
405
- order = JSON.parse(ppText) as Record<string, any>
406
- orderId = String(order.id || "")
407
- } else {
408
- order = (await this.getOrderDetails(orderId)) as Record<string, any> | null
409
- }
410
-
411
- if (!order || !orderId) {
412
- throw new Error("Unable to resolve PayPal order details for authorization.")
413
- }
414
-
415
- const existingAuthorization =
416
- order?.purchase_units?.[0]?.payments?.authorizations?.[0] || null
417
-
418
- if (existingAuthorization) {
419
- authorization = order
420
- } else {
421
- const authorizeResp = await fetch(`${base}/v2/checkout/orders/${orderId}/authorize`, {
422
- method: "POST",
423
- headers: {
424
- Authorization: `Bearer ${accessToken}`,
425
- "Content-Type": "application/json",
426
- "PayPal-Request-Id": `${requestId}-auth`,
427
- },
428
- })
429
-
430
- const authorizeText = await authorizeResp.text()
431
- const authorizeDebugId = authorizeResp.headers.get("paypal-debug-id")
432
- if (!authorizeResp.ok) {
433
- throw new Error(
434
- `PayPal authorize order error (${authorizeResp.status}): ${authorizeText}${
435
- authorizeDebugId ? ` debug_id=${authorizeDebugId}` : ""
436
- }`
437
- )
438
- }
439
-
440
- authorization = JSON.parse(authorizeText)
441
- }
442
-
443
- const authorizationId =
444
- authorization?.purchase_units?.[0]?.payments?.authorizations?.[0]?.id ||
445
- existingAuthorization?.id
446
-
447
- return {
448
- status: "authorized",
449
- data: {
450
- ...(data || {}),
451
- paypal: {
452
- ...existingPayPal,
453
- order_id: orderId,
454
- order: order || authorization,
455
- authorization_id: authorizationId,
456
- authorizations: authorization?.purchase_units?.[0]?.payments?.authorizations || [],
457
- },
458
- authorized_at: new Date().toISOString(),
459
- },
460
- }
461
- }
462
-
463
- async capturePayment(_input: CapturePaymentInput): Promise<CapturePaymentOutput> {
464
- const data = (_input.data || {}) as Record<string, any>
465
- const paypalData = (data.paypal || {}) as Record<string, any>
466
- const orderId = String(paypalData.order_id || data.order_id || "")
467
- let authorizationId = String(paypalData.authorization_id || data.authorization_id || "")
468
- if (!orderId) {
469
- throw new Error("PayPal order_id is required to capture payment")
470
- }
471
-
472
- if (paypalData.capture_id || paypalData.capture) {
473
- return {
474
- data: {
475
- ...(data || {}),
476
- paypal: {
477
- ...paypalData,
478
- capture_id: paypalData.capture_id,
479
- capture: paypalData.capture,
480
- },
481
- captured_at: new Date().toISOString(),
482
- },
483
- }
484
- }
485
-
486
- const requestId = this.getIdempotencyKey(_input, `capture-${orderId}`)
487
- const { amount, currencyCode } = await this.normalizePaymentData(_input)
488
- let debugId: string | null = null
489
-
490
- const { accessToken, base } = await this.getPayPalAccessToken()
491
- const order = await this.getOrderDetails(orderId).catch(() => null)
492
- const existingCapture = order?.purchase_units?.[0]?.payments?.captures?.[0]
493
- if (existingCapture?.id) {
494
- return {
495
- data: {
496
- ...(data || {}),
497
- paypal: {
498
- ...paypalData,
499
- capture_id: existingCapture.id,
500
- capture: existingCapture,
501
- },
502
- captured_at: new Date().toISOString(),
503
- },
504
- }
505
- }
506
-
507
- const resolvedIntent = String(
508
- order?.intent || paypalData.order?.intent || data.intent || ""
509
- ).toUpperCase()
510
- if (!authorizationId && resolvedIntent === "AUTHORIZE") {
511
- const authorizeResp = await fetch(`${base}/v2/checkout/orders/${orderId}/authorize`, {
512
- method: "POST",
513
- headers: {
514
- Authorization: `Bearer ${accessToken}`,
515
- "Content-Type": "application/json",
516
- "PayPal-Request-Id": `${requestId}-auth`,
517
- },
518
- })
519
- const authorizeText = await authorizeResp.text()
520
- debugId = authorizeResp.headers.get("paypal-debug-id")
521
- if (!authorizeResp.ok) {
522
- throw new Error(
523
- `PayPal authorize order error (${authorizeResp.status}): ${authorizeText}${
524
- debugId ? ` debug_id=${debugId}` : ""
525
- }`
526
- )
527
- }
528
- const authorization = JSON.parse(authorizeText)
529
- authorizationId =
530
- authorization?.purchase_units?.[0]?.payments?.authorizations?.[0]?.id
531
- }
532
-
533
- const isFinalCapture =
534
- paypalData.is_final_capture ??
535
- data.is_final_capture ??
536
- data.final_capture ??
537
- undefined
538
- const capturePayload =
539
- amount > 0
540
- ? {
541
- amount: {
542
- currency_code: currencyCode || "EUR",
543
- value: formatAmountForPayPal(amount, currencyCode || "EUR"),
544
- },
545
- ...(typeof isFinalCapture === "boolean"
546
- ? { is_final_capture: isFinalCapture }
547
- : {}),
548
- }
549
- : {
550
- ...(typeof isFinalCapture === "boolean"
551
- ? { is_final_capture: isFinalCapture }
552
- : {}),
553
- }
554
-
555
- const captureUrl = authorizationId
556
- ? `${base}/v2/payments/authorizations/${authorizationId}/capture`
557
- : `${base}/v2/checkout/orders/${orderId}/capture`
558
-
559
- const ppResp = await fetch(captureUrl, {
560
- method: "POST",
561
- headers: {
562
- Authorization: `Bearer ${accessToken}`,
563
- "Content-Type": "application/json",
564
- "PayPal-Request-Id": requestId,
565
- },
566
- body: JSON.stringify(capturePayload),
567
- })
568
-
569
- const ppText = await ppResp.text()
570
- debugId = ppResp.headers.get("paypal-debug-id")
571
- if (!ppResp.ok) {
572
- throw new Error(
573
- `PayPal capture error (${ppResp.status}): ${ppText}${
574
- debugId ? ` debug_id=${debugId}` : ""
575
- }`
576
- )
577
- }
578
-
579
- const capture = JSON.parse(ppText)
580
- const captureId =
581
- capture?.id || capture?.purchase_units?.[0]?.payments?.captures?.[0]?.id
582
- const existingCaptures = Array.isArray(paypalData.captures) ? paypalData.captures : []
583
- const captureEntry = {
584
- id: captureId,
585
- status: capture?.status,
586
- amount: capture?.amount,
587
- raw: capture,
588
- }
589
-
590
- return {
591
- data: {
592
- ...(data || {}),
593
- paypal: {
594
- ...paypalData,
595
- order_id: orderId,
596
- capture_id: captureId,
597
- capture,
598
- authorization_id: authorizationId || paypalData.authorization_id,
599
- captures: [...existingCaptures, captureEntry],
600
- },
601
- captured_at: new Date().toISOString(),
602
- },
603
- }
604
- }
605
-
606
- async cancelPayment(_input: CancelPaymentInput): Promise<CancelPaymentOutput> {
607
- const data = (_input.data || {}) as Record<string, any>
608
- return {
609
- data: {
610
- ...(data || {}),
611
- canceled_at: new Date().toISOString(),
612
- },
613
- }
614
- }
615
-
616
- async refundPayment(_input: RefundPaymentInput): Promise<RefundPaymentOutput> {
617
- const data = (_input.data || {}) as Record<string, any>
618
- const paypalData = (data.paypal || {}) as Record<string, any>
619
- const captureId = String(paypalData.capture_id || data.capture_id || "")
620
- if (!captureId) {
621
- return {
622
- data: {
623
- ...(data || {}),
624
- refunded_at: new Date().toISOString(),
625
- },
626
- }
627
- }
628
-
629
- const requestId = this.getIdempotencyKey(_input, `refund-${captureId}`)
630
- const amount = Number(data.amount ?? 0)
631
- const currencyCode = normalizeCurrencyCode(
632
- data.currency_code || process.env.PAYPAL_CURRENCY || "EUR"
633
- )
634
- const { accessToken, base } = await this.getPayPalAccessToken()
635
- const refundPayload: Record<string, any> =
636
- amount > 0
637
- ? {
638
- amount: {
639
- currency_code: currencyCode,
640
- value: formatAmountForPayPal(amount, currencyCode),
641
- },
642
- }
643
- : {}
644
-
645
- const resp = await fetch(`${base}/v2/payments/captures/${captureId}/refund`, {
646
- method: "POST",
647
- headers: {
648
- Authorization: `Bearer ${accessToken}`,
649
- "Content-Type": "application/json",
650
- "PayPal-Request-Id": requestId,
651
- },
652
- body: JSON.stringify(refundPayload),
653
- })
654
-
655
- const text = await resp.text()
656
- if (!resp.ok) {
657
- const debugId = resp.headers.get("paypal-debug-id")
658
- throw new Error(
659
- `PayPal refund error (${resp.status}): ${text}${
660
- debugId ? ` debug_id=${debugId}` : ""
661
- }`
662
- )
663
- }
664
-
665
- const refund = JSON.parse(text)
666
- const existingRefunds = Array.isArray(paypalData.refunds) ? paypalData.refunds : []
667
- const refundEntry = {
668
- id: refund?.id,
669
- status: refund?.status,
670
- amount: refund?.amount,
671
- raw: refund,
672
- }
673
-
674
- return {
675
- data: {
676
- ...(data || {}),
677
- paypal: {
678
- ...paypalData,
679
- refund_id: refund?.id,
680
- refund_status: refund?.status,
681
- refunds: [...existingRefunds, refundEntry],
682
- refund,
683
- },
684
- refunded_at: new Date().toISOString(),
685
- },
686
- }
687
- }
688
-
689
- async retrievePayment(_input: RetrievePaymentInput): Promise<RetrievePaymentOutput> {
690
- const data = (_input.data || {}) as Record<string, any>
691
- const paypalData = (data.paypal || {}) as Record<string, any>
692
- const orderId = String(paypalData.order_id || data.order_id || "")
693
- if (!orderId) {
694
- return { data: { ...(data || {}) } }
695
- }
696
-
697
- const order = await this.getOrderDetails(orderId)
698
- const capture = order?.purchase_units?.[0]?.payments?.captures?.[0]
699
- const authorization = order?.purchase_units?.[0]?.payments?.authorizations?.[0]
700
-
701
- return {
702
- data: {
703
- ...(data || {}),
704
- paypal: {
705
- ...paypalData,
706
- order,
707
- authorization_id: authorization?.id || paypalData.authorization_id,
708
- capture_id: capture?.id || paypalData.capture_id,
709
- },
710
- },
711
- }
712
- }
713
-
714
- async getPaymentStatus(_input: GetPaymentStatusInput): Promise<GetPaymentStatusOutput> {
715
- const data = (_input.data || {}) as Record<string, any>
716
- const paypalData = (data.paypal || {}) as Record<string, any>
717
- const orderId = String(paypalData.order_id || data.order_id || "")
718
- if (!orderId) {
719
- return { status: "pending", data: { ...(data || {}) } }
720
- }
721
-
722
- const order = await this.getOrderDetails(orderId)
723
- const capture = order?.purchase_units?.[0]?.payments?.captures?.[0]
724
- const authorization = order?.purchase_units?.[0]?.payments?.authorizations?.[0]
725
- const mappedStatus =
726
- this.mapCaptureStatus(capture?.status) ||
727
- this.mapAuthorizationStatus(authorization?.status) ||
728
- this.mapOrderStatus(order?.status) ||
729
- "pending"
730
-
731
- return {
732
- status: mappedStatus,
733
- data: {
734
- ...(data || {}),
735
- paypal: {
736
- ...paypalData,
737
- order,
738
- authorization_id: authorization?.id || paypalData.authorization_id,
739
- capture_id: capture?.id || paypalData.capture_id,
740
- },
741
- },
742
- }
743
- }
744
-
745
- async deletePayment(_input: DeletePaymentInput): Promise<DeletePaymentOutput> {
746
- return { data: {} }
747
- }
748
-
749
- /**
750
- * Medusa requires this method even if you don't support webhooks yet.
751
- */
752
- async getWebhookActionAndData(
753
- payload: ProviderWebhookPayload["payload"]
754
- ): Promise<WebhookActionResult> {
755
- return getPayPalWebhookActionAndData(payload)
756
- }
757
- }
758
-
759
- export default PayPalAdvancedCardProvider
760
- export { PayPalAdvancedCardProvider }
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 { getPayPalWebhookActionAndData } from "./webhook-utils"
28
+ import { formatAmountForPayPal, getCurrencyExponent } from "../utils/amounts"
29
+ import {
30
+ assertPayPalCurrencySupported,
31
+ normalizeCurrencyCode,
32
+ } from "../utils/currencies"
33
+ import type PayPalModuleService from "../service"
34
+
35
+ type Options = {}
36
+
37
+ function generateSessionId() {
38
+ try {
39
+ return randomUUID()
40
+ } catch {
41
+ return `pp_card_${Date.now()}_${Math.random().toString(16).slice(2)}`
42
+ }
43
+ }
44
+
45
+ class PayPalAdvancedCardProvider extends AbstractPaymentProvider<Options> {
46
+ static identifier = "paypal_card"
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 as any
63
+ }
64
+ }
65
+
66
+ private 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, any>,
78
+ advancedCardSettings: (_sData.advanced_card_payments || {}) as Record<string, any>,
79
+ apiDetails: (_sData.api_details || {}) as Record<string, any>,
80
+ }
81
+ } catch {
82
+ return {
83
+ additionalSettings: {} as Record<string, any>,
84
+ advancedCardSettings: {} as Record<string, any>,
85
+ apiDetails: {} as Record<string, any>,
86
+ }
87
+ }
88
+ }
89
+ const settings = await paypal.getSettings().catch(() => ({}))
90
+ const data =
91
+ settings && typeof settings === "object" && "data" in settings
92
+ ? ((settings as { data?: Record<string, any> }).data ?? {})
93
+ : {}
94
+ return {
95
+ additionalSettings: (data.additional_settings || {}) as Record<string, any>,
96
+ advancedCardSettings: (data.advanced_card_payments || {}) as Record<string, any>,
97
+ apiDetails: (data.api_details || {}) as Record<string, any>,
98
+ }
99
+ }
100
+
101
+ private async resolveCurrencyOverride() {
102
+ const { apiDetails } = await this.resolveSettings()
103
+ if (typeof apiDetails.currency_code === "string" && apiDetails.currency_code.trim()) {
104
+ return normalizeCurrencyCode(apiDetails.currency_code)
105
+ }
106
+ return normalizeCurrencyCode(process.env.PAYPAL_CURRENCY || "EUR")
107
+ }
108
+
109
+ private async getPayPalAccessToken() {
110
+ const paypal = this.resolvePayPalService()
111
+ let client_id: string
112
+ let client_secret: string
113
+ let environment: string
114
+
115
+ if (!paypal) {
116
+ const { Pool: _FbPool } = require("pg")
117
+ const _fbPool = new _FbPool({ connectionString: process.env.DATABASE_URL })
118
+ const _fbResult = await _fbPool
119
+ .query(
120
+ "SELECT metadata, environment, seller_client_id, seller_client_secret FROM paypal_connection WHERE status='connected' ORDER BY created_at DESC LIMIT 1"
121
+ )
122
+ .finally(() => _fbPool.end())
123
+ const _fbRow = _fbResult.rows[0]
124
+ if (!_fbRow) throw new Error("No active PayPal connection found in DB")
125
+ environment = _fbRow.environment || "sandbox"
126
+ const _fbCreds = (_fbRow.metadata?.credentials?.[environment]) || {}
127
+ client_id = _fbCreds.client_id || _fbRow.seller_client_id
128
+ client_secret = _fbCreds.client_secret || _fbRow.seller_client_secret
129
+ console.info("[PayPal Card] getPayPalAccessToken fallback via DB for env:", environment)
130
+ } else {
131
+ const creds = await paypal.getActiveCredentials()
132
+ client_id = creds.client_id
133
+ client_secret = creds.client_secret
134
+ environment = creds.environment
135
+ }
136
+
137
+ const base =
138
+ environment === "live"
139
+ ? "https://api-m.paypal.com"
140
+ : "https://api-m.sandbox.paypal.com"
141
+ const auth = Buffer.from(`${client_id}:${client_secret}`).toString("base64")
142
+
143
+ const resp = await fetch(`${base}/v1/oauth2/token`, {
144
+ method: "POST",
145
+ headers: {
146
+ Authorization: `Basic ${auth}`,
147
+ "Content-Type": "application/x-www-form-urlencoded",
148
+ },
149
+ body: "grant_type=client_credentials",
150
+ })
151
+
152
+ const text = await resp.text()
153
+ if (!resp.ok) {
154
+ throw new Error(`PayPal token error (${resp.status}): ${text}`)
155
+ }
156
+
157
+ const json = JSON.parse(text)
158
+ return { accessToken: String(json.access_token), base }
159
+ }
160
+
161
+ private async getOrderDetails(orderId: string) {
162
+ const { accessToken, base } = await this.getPayPalAccessToken()
163
+ const resp = await fetch(`${base}/v2/checkout/orders/${orderId}`, {
164
+ method: "GET",
165
+ headers: {
166
+ Authorization: `Bearer ${accessToken}`,
167
+ "Content-Type": "application/json",
168
+ },
169
+ })
170
+
171
+ const text = await resp.text()
172
+ if (!resp.ok) {
173
+ throw new Error(`PayPal get order error (${resp.status}): ${text}`)
174
+ }
175
+
176
+ return JSON.parse(text)
177
+ }
178
+
179
+ private getIdempotencyKey(
180
+ input: { context?: { idempotency_key?: string } },
181
+ suffix: string
182
+ ) {
183
+ const key = input?.context?.idempotency_key?.trim()
184
+ if (key) {
185
+ return `${key}-${suffix}`
186
+ }
187
+ return `pp-card-${suffix}-${generateSessionId()}`
188
+ }
189
+
190
+ private async normalizePaymentData(input: { data?: Record<string, unknown> }) {
191
+ const data = (input.data || {}) as Record<string, any>
192
+ const amount = Number(data.amount ?? 0)
193
+ const currencyOverride = await this.resolveCurrencyOverride()
194
+ const currencyCode = normalizeCurrencyCode(
195
+ data.currency_code || currencyOverride || "EUR"
196
+ )
197
+ assertPayPalCurrencySupported({
198
+ currencyCode,
199
+ paypalCurrencyOverride: currencyOverride,
200
+ })
201
+ return { data, amount, currencyCode }
202
+ }
203
+
204
+ private mapCaptureStatus(status?: string) {
205
+ const normalized = String(status || "").toUpperCase()
206
+ if (!normalized) {
207
+ return null
208
+ }
209
+ if (normalized === "COMPLETED") {
210
+ return "captured"
211
+ }
212
+ if (normalized === "PENDING") {
213
+ return "pending"
214
+ }
215
+ if (["DENIED", "DECLINED", "FAILED"].includes(normalized)) {
216
+ return "error"
217
+ }
218
+ if (["REFUNDED", "PARTIALLY_REFUNDED", "REVERSED"].includes(normalized)) {
219
+ return "canceled"
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
+ if (["CREATED", "APPROVED", "PENDING"].includes(normalized)) {
230
+ return "authorized"
231
+ }
232
+ if (["VOIDED", "EXPIRED"].includes(normalized)) {
233
+ return "canceled"
234
+ }
235
+ if (["DENIED", "DECLINED", "FAILED"].includes(normalized)) {
236
+ return "error"
237
+ }
238
+ return null
239
+ }
240
+
241
+ private mapOrderStatus(status?: string) {
242
+ const normalized = String(status || "").toUpperCase()
243
+ if (!normalized) {
244
+ return "pending"
245
+ }
246
+ if (normalized === "COMPLETED") {
247
+ return "captured"
248
+ }
249
+ if (normalized === "APPROVED") {
250
+ return "authorized"
251
+ }
252
+ if (["VOIDED", "CANCELLED"].includes(normalized)) {
253
+ return "canceled"
254
+ }
255
+ if (["CREATED", "SAVED", "PAYER_ACTION_REQUIRED"].includes(normalized)) {
256
+ return "pending"
257
+ }
258
+ if (["FAILED", "EXPIRED"].includes(normalized)) {
259
+ return "error"
260
+ }
261
+ return "pending"
262
+ }
263
+
264
+ async createAccountHolder(
265
+ input: CreateAccountHolderInput
266
+ ): Promise<CreateAccountHolderOutput> {
267
+ const customerId = input.context?.customer?.id
268
+ const externalId = customerId ? `paypal_${customerId}` : `paypal_${generateSessionId()}`
269
+
270
+ return {
271
+ id: externalId,
272
+ data: {
273
+ email: input.context?.customer?.email || null,
274
+ customer_id: customerId || null,
275
+ },
276
+ }
277
+ }
278
+
279
+ async initiatePayment(input: InitiatePaymentInput): Promise<InitiatePaymentOutput> {
280
+ const currencyOverride = await this.resolveCurrencyOverride()
281
+ const currencyCode = normalizeCurrencyCode(
282
+ input.currency_code || currencyOverride || "EUR"
283
+ )
284
+ assertPayPalCurrencySupported({
285
+ currencyCode,
286
+ paypalCurrencyOverride: currencyOverride,
287
+ })
288
+
289
+ return {
290
+ id: generateSessionId(),
291
+ data: {
292
+ ...(input.data || {}),
293
+ // store amount/currency so Medusa has something consistent
294
+ amount: input.amount,
295
+ currency_code: currencyCode,
296
+ },
297
+ }
298
+ }
299
+
300
+ async updatePayment(input: UpdatePaymentInput): Promise<UpdatePaymentOutput> {
301
+ const currencyOverride = await this.resolveCurrencyOverride()
302
+ const currencyCode = normalizeCurrencyCode(
303
+ input.currency_code || currencyOverride || "EUR"
304
+ )
305
+ assertPayPalCurrencySupported({
306
+ currencyCode,
307
+ paypalCurrencyOverride: currencyOverride,
308
+ })
309
+
310
+ return {
311
+ data: {
312
+ ...(input.data || {}),
313
+ amount: input.amount,
314
+ currency_code: currencyCode,
315
+ },
316
+ }
317
+ }
318
+
319
+ async authorizePayment(_input: AuthorizePaymentInput): Promise<AuthorizePaymentOutput> {
320
+ const { data, amount, currencyCode } = await this.normalizePaymentData(_input)
321
+ const requestId = this.getIdempotencyKey(_input, "authorize")
322
+ let debugId: string | null = null
323
+ const { additionalSettings, advancedCardSettings } = await this.resolveSettings()
324
+ const paymentActionRaw =
325
+ typeof additionalSettings.paymentAction === "string"
326
+ ? additionalSettings.paymentAction
327
+ : "capture"
328
+ const orderIntent = paymentActionRaw === "authorize" ? "AUTHORIZE" : "CAPTURE"
329
+ const threeDsRaw =
330
+ typeof advancedCardSettings.threeDS === "string"
331
+ ? advancedCardSettings.threeDS
332
+ : "when_required"
333
+ const threeDsMethod =
334
+ threeDsRaw === "always"
335
+ ? "SCA_ALWAYS"
336
+ : threeDsRaw === "when_required" || threeDsRaw === "sli"
337
+ ? "SCA_WHEN_REQUIRED"
338
+ : null
339
+ const disabledCards = Array.isArray(advancedCardSettings.disabledCards)
340
+ ? advancedCardSettings.disabledCards.map((card: string) => String(card).toLowerCase())
341
+ : []
342
+ const cardBrand = String(
343
+ data.card_brand || data.cardBrand || data?.paypal?.card_brand || ""
344
+ ).toLowerCase()
345
+ if (cardBrand && disabledCards.includes(cardBrand)) {
346
+ throw new Error(`Card brand ${cardBrand} is disabled by admin settings.`)
347
+ }
348
+
349
+ const { accessToken, base } = await this.getPayPalAccessToken()
350
+ const existingPayPal = (data.paypal || {}) as Record<string, any>
351
+ let orderId = String(existingPayPal.order_id || data.order_id || "")
352
+ let order: Record<string, any> | null = null
353
+ let authorization: Record<string, any> | null = null
354
+
355
+ if (!orderId) {
356
+ const value = formatAmountForPayPal(amount, currencyCode || "EUR")
357
+ const orderPayload = {
358
+ intent: orderIntent,
359
+ purchase_units: [
360
+ {
361
+ reference_id: data.cart_id || data.payment_collection_id || undefined,
362
+ custom_id: data.session_id || data.cart_id || data.payment_collection_id || undefined,
363
+ amount: {
364
+ currency_code: currencyCode || "EUR",
365
+ value,
366
+ },
367
+ },
368
+ ],
369
+ custom_id: data.session_id || data.cart_id || data.payment_collection_id || undefined,
370
+ ...(threeDsMethod
371
+ ? {
372
+ payment_source: {
373
+ card: {
374
+ attributes: {
375
+ verification: {
376
+ method: threeDsMethod,
377
+ },
378
+ },
379
+ },
380
+ },
381
+ }
382
+ : {}),
383
+ }
384
+
385
+ const ppResp = await fetch(`${base}/v2/checkout/orders`, {
386
+ method: "POST",
387
+ headers: {
388
+ Authorization: `Bearer ${accessToken}`,
389
+ "Content-Type": "application/json",
390
+ "PayPal-Request-Id": requestId,
391
+ },
392
+ body: JSON.stringify(orderPayload),
393
+ })
394
+
395
+ const ppText = await ppResp.text()
396
+ debugId = ppResp.headers.get("paypal-debug-id")
397
+ if (!ppResp.ok) {
398
+ throw new Error(
399
+ `PayPal create order error (${ppResp.status}): ${ppText}${
400
+ debugId ? ` debug_id=${debugId}` : ""
401
+ }`
402
+ )
403
+ }
404
+
405
+ order = JSON.parse(ppText) as Record<string, any>
406
+ orderId = String(order.id || "")
407
+ } else {
408
+ order = (await this.getOrderDetails(orderId)) as Record<string, any> | null
409
+ }
410
+
411
+ if (!order || !orderId) {
412
+ throw new Error("Unable to resolve PayPal order details for authorization.")
413
+ }
414
+
415
+ const existingAuthorization =
416
+ order?.purchase_units?.[0]?.payments?.authorizations?.[0] || null
417
+
418
+ if (existingAuthorization) {
419
+ authorization = order
420
+ } else {
421
+ const authorizeResp = await fetch(`${base}/v2/checkout/orders/${orderId}/authorize`, {
422
+ method: "POST",
423
+ headers: {
424
+ Authorization: `Bearer ${accessToken}`,
425
+ "Content-Type": "application/json",
426
+ "PayPal-Request-Id": `${requestId}-auth`,
427
+ },
428
+ })
429
+
430
+ const authorizeText = await authorizeResp.text()
431
+ const authorizeDebugId = authorizeResp.headers.get("paypal-debug-id")
432
+ if (!authorizeResp.ok) {
433
+ throw new Error(
434
+ `PayPal authorize order error (${authorizeResp.status}): ${authorizeText}${
435
+ authorizeDebugId ? ` debug_id=${authorizeDebugId}` : ""
436
+ }`
437
+ )
438
+ }
439
+
440
+ authorization = JSON.parse(authorizeText)
441
+ }
442
+
443
+ const authorizationId =
444
+ authorization?.purchase_units?.[0]?.payments?.authorizations?.[0]?.id ||
445
+ existingAuthorization?.id
446
+
447
+ return {
448
+ status: "authorized",
449
+ data: {
450
+ ...(data || {}),
451
+ paypal: {
452
+ ...existingPayPal,
453
+ order_id: orderId,
454
+ order: order || authorization,
455
+ authorization_id: authorizationId,
456
+ authorizations: authorization?.purchase_units?.[0]?.payments?.authorizations || [],
457
+ },
458
+ authorized_at: new Date().toISOString(),
459
+ },
460
+ }
461
+ }
462
+
463
+ async capturePayment(_input: CapturePaymentInput): Promise<CapturePaymentOutput> {
464
+ const data = (_input.data || {}) as Record<string, any>
465
+ const paypalData = (data.paypal || {}) as Record<string, any>
466
+ const orderId = String(paypalData.order_id || data.order_id || "")
467
+ let authorizationId = String(paypalData.authorization_id || data.authorization_id || "")
468
+ if (!orderId) {
469
+ throw new Error("PayPal order_id is required to capture payment")
470
+ }
471
+
472
+ if (paypalData.capture_id || paypalData.capture) {
473
+ return {
474
+ data: {
475
+ ...(data || {}),
476
+ paypal: {
477
+ ...paypalData,
478
+ capture_id: paypalData.capture_id,
479
+ capture: paypalData.capture,
480
+ },
481
+ captured_at: new Date().toISOString(),
482
+ },
483
+ }
484
+ }
485
+
486
+ const requestId = this.getIdempotencyKey(_input, `capture-${orderId}`)
487
+ const { amount, currencyCode } = await this.normalizePaymentData(_input)
488
+ let debugId: string | null = null
489
+
490
+ const { accessToken, base } = await this.getPayPalAccessToken()
491
+ const order = await this.getOrderDetails(orderId).catch(() => null)
492
+ const existingCapture = order?.purchase_units?.[0]?.payments?.captures?.[0]
493
+ if (existingCapture?.id) {
494
+ return {
495
+ data: {
496
+ ...(data || {}),
497
+ paypal: {
498
+ ...paypalData,
499
+ capture_id: existingCapture.id,
500
+ capture: existingCapture,
501
+ },
502
+ captured_at: new Date().toISOString(),
503
+ },
504
+ }
505
+ }
506
+
507
+ const resolvedIntent = String(
508
+ order?.intent || paypalData.order?.intent || data.intent || ""
509
+ ).toUpperCase()
510
+ if (!authorizationId && resolvedIntent === "AUTHORIZE") {
511
+ const authorizeResp = await fetch(`${base}/v2/checkout/orders/${orderId}/authorize`, {
512
+ method: "POST",
513
+ headers: {
514
+ Authorization: `Bearer ${accessToken}`,
515
+ "Content-Type": "application/json",
516
+ "PayPal-Request-Id": `${requestId}-auth`,
517
+ },
518
+ })
519
+ const authorizeText = await authorizeResp.text()
520
+ debugId = authorizeResp.headers.get("paypal-debug-id")
521
+ if (!authorizeResp.ok) {
522
+ throw new Error(
523
+ `PayPal authorize order error (${authorizeResp.status}): ${authorizeText}${
524
+ debugId ? ` debug_id=${debugId}` : ""
525
+ }`
526
+ )
527
+ }
528
+ const authorization = JSON.parse(authorizeText)
529
+ authorizationId =
530
+ authorization?.purchase_units?.[0]?.payments?.authorizations?.[0]?.id
531
+ }
532
+
533
+ const isFinalCapture =
534
+ paypalData.is_final_capture ??
535
+ data.is_final_capture ??
536
+ data.final_capture ??
537
+ undefined
538
+ const captureExponent = getCurrencyExponent(currencyCode || "EUR")
539
+ const capturePayload =
540
+ amount > 0
541
+ ? {
542
+ amount: {
543
+ currency_code: currencyCode || "EUR",
544
+ value: amount.toFixed(captureExponent),
545
+ },
546
+ ...(typeof isFinalCapture === "boolean"
547
+ ? { is_final_capture: isFinalCapture }
548
+ : {}),
549
+ }
550
+ : {
551
+ ...(typeof isFinalCapture === "boolean"
552
+ ? { is_final_capture: isFinalCapture }
553
+ : {}),
554
+ }
555
+
556
+ const captureUrl = authorizationId
557
+ ? `${base}/v2/payments/authorizations/${authorizationId}/capture`
558
+ : `${base}/v2/checkout/orders/${orderId}/capture`
559
+
560
+ const ppResp = await fetch(captureUrl, {
561
+ method: "POST",
562
+ headers: {
563
+ Authorization: `Bearer ${accessToken}`,
564
+ "Content-Type": "application/json",
565
+ "PayPal-Request-Id": requestId,
566
+ },
567
+ body: JSON.stringify(capturePayload),
568
+ })
569
+
570
+ const ppText = await ppResp.text()
571
+ debugId = ppResp.headers.get("paypal-debug-id")
572
+ if (!ppResp.ok) {
573
+ throw new Error(
574
+ `PayPal capture error (${ppResp.status}): ${ppText}${
575
+ debugId ? ` debug_id=${debugId}` : ""
576
+ }`
577
+ )
578
+ }
579
+
580
+ const capture = JSON.parse(ppText)
581
+ const captureId =
582
+ capture?.id || capture?.purchase_units?.[0]?.payments?.captures?.[0]?.id
583
+ const existingCaptures = Array.isArray(paypalData.captures) ? paypalData.captures : []
584
+ const captureEntry = {
585
+ id: captureId,
586
+ status: capture?.status,
587
+ amount: capture?.amount,
588
+ raw: capture,
589
+ }
590
+
591
+ return {
592
+ data: {
593
+ ...(data || {}),
594
+ paypal: {
595
+ ...paypalData,
596
+ order_id: orderId,
597
+ capture_id: captureId,
598
+ capture,
599
+ authorization_id: authorizationId || paypalData.authorization_id,
600
+ captures: [...existingCaptures, captureEntry],
601
+ },
602
+ captured_at: new Date().toISOString(),
603
+ },
604
+ }
605
+ }
606
+
607
+ async cancelPayment(_input: CancelPaymentInput): Promise<CancelPaymentOutput> {
608
+ const data = (_input.data || {}) as Record<string, any>
609
+ return {
610
+ data: {
611
+ ...(data || {}),
612
+ canceled_at: new Date().toISOString(),
613
+ },
614
+ }
615
+ }
616
+
617
+ async refundPayment(_input: RefundPaymentInput): Promise<RefundPaymentOutput> {
618
+ const data = (_input.data || {}) as Record<string, any>
619
+ const paypalData = (data.paypal || {}) as Record<string, any>
620
+ const captureId = String(paypalData.capture_id || data.capture_id || "")
621
+ if (!captureId) {
622
+ return {
623
+ data: {
624
+ ...(data || {}),
625
+ refunded_at: new Date().toISOString(),
626
+ },
627
+ }
628
+ }
629
+
630
+ const requestId = this.getIdempotencyKey(_input, `refund-${captureId}`)
631
+ const amount = Number(data.amount ?? 0)
632
+ const currencyCode = normalizeCurrencyCode(
633
+ data.currency_code || process.env.PAYPAL_CURRENCY || "EUR"
634
+ )
635
+ const { accessToken, base } = await this.getPayPalAccessToken()
636
+ const refundExponent = getCurrencyExponent(currencyCode)
637
+ const refundPayload: Record<string, any> =
638
+ amount > 0
639
+ ? {
640
+ amount: {
641
+ currency_code: currencyCode,
642
+ value: amount.toFixed(refundExponent),
643
+ },
644
+ }
645
+ : {}
646
+
647
+ const resp = await fetch(`${base}/v2/payments/captures/${captureId}/refund`, {
648
+ method: "POST",
649
+ headers: {
650
+ Authorization: `Bearer ${accessToken}`,
651
+ "Content-Type": "application/json",
652
+ "PayPal-Request-Id": requestId,
653
+ },
654
+ body: JSON.stringify(refundPayload),
655
+ })
656
+
657
+ const text = await resp.text()
658
+ if (!resp.ok) {
659
+ const debugId = resp.headers.get("paypal-debug-id")
660
+ throw new Error(
661
+ `PayPal refund error (${resp.status}): ${text}${
662
+ debugId ? ` debug_id=${debugId}` : ""
663
+ }`
664
+ )
665
+ }
666
+
667
+ const refund = JSON.parse(text)
668
+ const existingRefunds = Array.isArray(paypalData.refunds) ? paypalData.refunds : []
669
+ const refundEntry = {
670
+ id: refund?.id,
671
+ status: refund?.status,
672
+ amount: refund?.amount,
673
+ raw: refund,
674
+ }
675
+
676
+ return {
677
+ data: {
678
+ ...(data || {}),
679
+ paypal: {
680
+ ...paypalData,
681
+ refund_id: refund?.id,
682
+ refund_status: refund?.status,
683
+ refunds: [...existingRefunds, refundEntry],
684
+ refund,
685
+ },
686
+ refunded_at: new Date().toISOString(),
687
+ },
688
+ }
689
+ }
690
+
691
+ async retrievePayment(_input: RetrievePaymentInput): Promise<RetrievePaymentOutput> {
692
+ const data = (_input.data || {}) as Record<string, any>
693
+ const paypalData = (data.paypal || {}) as Record<string, any>
694
+ const orderId = String(paypalData.order_id || data.order_id || "")
695
+ if (!orderId) {
696
+ return { data: { ...(data || {}) } }
697
+ }
698
+
699
+ const order = await this.getOrderDetails(orderId)
700
+ const capture = order?.purchase_units?.[0]?.payments?.captures?.[0]
701
+ const authorization = order?.purchase_units?.[0]?.payments?.authorizations?.[0]
702
+
703
+ return {
704
+ data: {
705
+ ...(data || {}),
706
+ paypal: {
707
+ ...paypalData,
708
+ order,
709
+ authorization_id: authorization?.id || paypalData.authorization_id,
710
+ capture_id: capture?.id || paypalData.capture_id,
711
+ },
712
+ },
713
+ }
714
+ }
715
+
716
+ async getPaymentStatus(_input: GetPaymentStatusInput): Promise<GetPaymentStatusOutput> {
717
+ const data = (_input.data || {}) as Record<string, any>
718
+ const paypalData = (data.paypal || {}) as Record<string, any>
719
+ const orderId = String(paypalData.order_id || data.order_id || "")
720
+ if (!orderId) {
721
+ return { status: "pending", data: { ...(data || {}) } }
722
+ }
723
+
724
+ const order = await this.getOrderDetails(orderId)
725
+ const capture = order?.purchase_units?.[0]?.payments?.captures?.[0]
726
+ const authorization = order?.purchase_units?.[0]?.payments?.authorizations?.[0]
727
+ const mappedStatus =
728
+ this.mapCaptureStatus(capture?.status) ||
729
+ this.mapAuthorizationStatus(authorization?.status) ||
730
+ this.mapOrderStatus(order?.status) ||
731
+ "pending"
732
+
733
+ return {
734
+ status: mappedStatus,
735
+ data: {
736
+ ...(data || {}),
737
+ paypal: {
738
+ ...paypalData,
739
+ order,
740
+ authorization_id: authorization?.id || paypalData.authorization_id,
741
+ capture_id: capture?.id || paypalData.capture_id,
742
+ },
743
+ },
744
+ }
745
+ }
746
+
747
+ async deletePayment(_input: DeletePaymentInput): Promise<DeletePaymentOutput> {
748
+ return { data: {} }
749
+ }
750
+
751
+ /**
752
+ * Medusa requires this method even if you don't support webhooks yet.
753
+ */
754
+ async getWebhookActionAndData(
755
+ payload: ProviderWebhookPayload["payload"]
756
+ ): Promise<WebhookActionResult> {
757
+ return getPayPalWebhookActionAndData(payload)
758
+ }
759
+ }
760
+
761
+ export default PayPalAdvancedCardProvider
762
+ export { PayPalAdvancedCardProvider }