@easypayment/medusa-paypal 0.4.7 → 0.4.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.
Files changed (70) hide show
  1. package/.medusa/server/src/admin/index.js +7 -7
  2. package/.medusa/server/src/admin/index.mjs +7 -7
  3. package/.medusa/server/src/api/store/paypal/create-order/route.d.ts.map +1 -1
  4. package/.medusa/server/src/api/store/paypal/create-order/route.js +62 -139
  5. package/.medusa/server/src/api/store/paypal/create-order/route.js.map +1 -1
  6. package/.medusa/server/src/modules/paypal/migrations/20260115120000_create_paypal_connection.js +22 -22
  7. package/.medusa/server/src/modules/paypal/migrations/20260123090000_create_paypal_settings.js +11 -11
  8. package/.medusa/server/src/modules/paypal/migrations/20260201090000_create_paypal_webhook_event.js +18 -18
  9. package/.medusa/server/src/modules/paypal/migrations/20260401090000_create_paypal_metric.js +16 -16
  10. package/.medusa/server/src/modules/paypal/migrations/20260701090000_add_paypal_webhook_event_processing.js +20 -20
  11. package/.medusa/server/src/modules/paypal/migrations/20261101090000_remove_paypal_reconciliation_status.js +14 -14
  12. package/.medusa/server/src/modules/paypal/migrations/20261201090000_remove_paypal_audit_log.js +15 -15
  13. package/README.md +142 -142
  14. package/package.json +75 -75
  15. package/src/admin/index.ts +7 -7
  16. package/src/admin/routes/settings/paypal/_components/Tabs.tsx +52 -52
  17. package/src/admin/routes/settings/paypal/_components/Toast.tsx +51 -51
  18. package/src/admin/routes/settings/paypal/additional-settings/page.tsx +200 -200
  19. package/src/admin/routes/settings/paypal/advanced-card-payments/page.tsx +183 -183
  20. package/src/admin/routes/settings/paypal/apple-pay/page.tsx +5 -5
  21. package/src/admin/routes/settings/paypal/connection/page.tsx +754 -754
  22. package/src/admin/routes/settings/paypal/google-pay/page.tsx +5 -5
  23. package/src/admin/routes/settings/paypal/pay-later-messaging/page.tsx +5 -5
  24. package/src/admin/routes/settings/paypal/paypal-settings/page.tsx +376 -376
  25. package/src/api/admin/payment-collections/[id]/payment-sessions/route.ts +24 -24
  26. package/src/api/admin/paypal/disconnect/route.ts +8 -8
  27. package/src/api/admin/paypal/environment/route.ts +25 -25
  28. package/src/api/admin/paypal/onboard-complete/route.ts +44 -44
  29. package/src/api/admin/paypal/onboarding-link/route.ts +45 -45
  30. package/src/api/admin/paypal/onboarding-status/route.ts +18 -18
  31. package/src/api/admin/paypal/rotate-credentials/route.ts +8 -8
  32. package/src/api/admin/paypal/save-credentials/route.ts +14 -14
  33. package/src/api/admin/paypal/settings/route.ts +14 -14
  34. package/src/api/admin/paypal/status/route.ts +12 -12
  35. package/src/api/store/payment-collections/[id]/payment-sessions/route.ts +65 -65
  36. package/src/api/store/paypal/capture-order/route.ts +276 -276
  37. package/src/api/store/paypal/config/route.ts +102 -102
  38. package/src/api/store/paypal/create-order/route.ts +77 -176
  39. package/src/api/store/paypal/settings/route.ts +19 -19
  40. package/src/api/store/paypal/webhook/route.ts +246 -246
  41. package/src/api/store/paypal-complete/route.ts +75 -75
  42. package/src/jobs/paypal-reconcile.ts +112 -112
  43. package/src/jobs/paypal-webhook-retry.ts +85 -85
  44. package/src/modules/paypal/clients/paypal-seller.client.ts +59 -59
  45. package/src/modules/paypal/index.ts +8 -8
  46. package/src/modules/paypal/migrations/20260115120000_create_paypal_connection.ts +33 -33
  47. package/src/modules/paypal/migrations/20260123090000_create_paypal_settings.ts +22 -22
  48. package/src/modules/paypal/migrations/20260201090000_create_paypal_webhook_event.ts +29 -29
  49. package/src/modules/paypal/migrations/20260401090000_create_paypal_metric.ts +27 -27
  50. package/src/modules/paypal/migrations/20260701090000_add_paypal_webhook_event_processing.ts +31 -31
  51. package/src/modules/paypal/migrations/20261101090000_remove_paypal_reconciliation_status.ts +25 -25
  52. package/src/modules/paypal/migrations/20261201090000_remove_paypal_audit_log.ts +26 -26
  53. package/src/modules/paypal/migrations/20270101090000_set_paypal_environment_default_live.ts +11 -11
  54. package/src/modules/paypal/models/paypal_connection.ts +21 -21
  55. package/src/modules/paypal/models/paypal_metric.ts +9 -9
  56. package/src/modules/paypal/models/paypal_settings.ts +8 -8
  57. package/src/modules/paypal/models/paypal_webhook_event.ts +19 -19
  58. package/src/modules/paypal/payment-provider/README.md +22 -22
  59. package/src/modules/paypal/payment-provider/card-service.ts +760 -760
  60. package/src/modules/paypal/payment-provider/index.ts +19 -19
  61. package/src/modules/paypal/payment-provider/service.ts +1121 -1121
  62. package/src/modules/paypal/payment-provider/webhook-utils.ts +88 -88
  63. package/src/modules/paypal/service.ts +1247 -1247
  64. package/src/modules/paypal/types/config.ts +47 -47
  65. package/src/modules/paypal/utils/amounts.ts +41 -41
  66. package/src/modules/paypal/utils/crypto.ts +51 -51
  67. package/src/modules/paypal/utils/currencies.ts +84 -84
  68. package/src/modules/paypal/utils/paypal-auth.ts +32 -32
  69. package/src/modules/paypal/utils/provider-ids.ts +15 -15
  70. package/src/modules/paypal/webhook-processor.ts +215 -215
@@ -1,1247 +1,1247 @@
1
- import { MedusaService } from "@medusajs/framework/utils"
2
- import PayPalConnection from "./models/paypal_connection"
3
- import PayPalMetric from "./models/paypal_metric"
4
- import PayPalSettings from "./models/paypal_settings"
5
- import PayPalWebhookEvent from "./models/paypal_webhook_event"
6
- import { getPayPalConfig } from "./types/config"
7
- import { decryptSecret, encryptSecret, isEncryptedSecret } from "./utils/crypto"
8
- import { normalizeCurrencyCode } from "./utils/currencies"
9
-
10
- type Environment = "sandbox" | "live"
11
-
12
- type Status =
13
- | "disconnected"
14
- | "pending"
15
- | "pending_credentials"
16
- | "connected"
17
- | "revoked"
18
-
19
- class PayPalModuleService extends MedusaService({
20
- PayPalConnection,
21
- PayPalMetric,
22
- PayPalSettings,
23
- PayPalWebhookEvent,
24
- }) {
25
- protected cfg = getPayPalConfig()
26
-
27
- private async getSettingsData() {
28
- const settings = await this.getSettings()
29
- return (settings?.data || {}) as Record<string, any>
30
- }
31
-
32
- private async ensureSettingsDefaults() {
33
- const data = await this.getSettingsData()
34
- const onboarding = { ...(data.onboarding_config || {}) } as Record<string, any>
35
- const apiDetails = { ...(data.api_details || {}) } as Record<string, any>
36
- let changed = false
37
-
38
- if (!onboarding.partner_service_url) {
39
- onboarding.partner_service_url = this.cfg.partnerServiceUrl
40
- changed = true
41
- }
42
- if (!onboarding.partner_js_url) {
43
- onboarding.partner_js_url = this.cfg.partnerJsUrl
44
- changed = true
45
- }
46
- if (!onboarding.backend_url) {
47
- onboarding.backend_url = this.cfg.backendUrl
48
- changed = true
49
- }
50
- if (!onboarding.seller_nonce) {
51
- onboarding.seller_nonce = this.cfg.sellerNonce
52
- changed = true
53
- }
54
- if (!onboarding.bn_code && this.cfg.bnCode) {
55
- onboarding.bn_code = this.cfg.bnCode
56
- changed = true
57
- }
58
- if (!onboarding.partner_merchant_id_sandbox) {
59
- onboarding.partner_merchant_id_sandbox = this.cfg.partnerMerchantIdSandbox
60
- changed = true
61
- }
62
- if (!onboarding.partner_merchant_id_live) {
63
- onboarding.partner_merchant_id_live = this.cfg.partnerMerchantIdLive
64
- changed = true
65
- }
66
-
67
- if (!apiDetails.currency_code) {
68
- const raw = (process.env.PAYPAL_CURRENCY || "").trim()
69
- apiDetails.currency_code = raw ? normalizeCurrencyCode(raw) : "EUR"
70
- changed = true
71
- }
72
- if (!apiDetails.storefront_url) {
73
- const storeUrl = process.env.STOREFRONT_URL || process.env.STORE_URL
74
- if (storeUrl) {
75
- apiDetails.storefront_url = storeUrl
76
- changed = true
77
- }
78
- }
79
-
80
- if (changed) {
81
- await this.saveSettings({
82
- onboarding_config: onboarding,
83
- api_details: apiDetails,
84
- })
85
- }
86
-
87
- return { onboarding, apiDetails }
88
- }
89
-
90
- async getApiDetails() {
91
- const { onboarding, apiDetails } = await this.ensureSettingsDefaults()
92
- return {
93
- onboarding,
94
- apiDetails,
95
- }
96
- }
97
-
98
- private getAlertWebhookUrls() {
99
- return (this.cfg.alertWebhookUrls || []).map((url) => url.trim()).filter(Boolean)
100
- }
101
-
102
- private getEncryptionKey() {
103
- return (this.cfg.credentialsEncryptionKey || "").trim()
104
- }
105
-
106
- private getDecryptionKeys() {
107
- const current = this.getEncryptionKey()
108
- const previous = this.cfg.credentialsEncryptionKeyPrevious || []
109
- const keys = [current, ...previous].map((key) => (key || "").trim()).filter(Boolean)
110
- return Array.from(new Set(keys))
111
- }
112
-
113
- private decryptSecretWithKeys(secret: string, keys: string[]) {
114
- let lastError: unknown
115
- for (const key of keys) {
116
- try {
117
- return decryptSecret(secret, key)
118
- } catch (err) {
119
- lastError = err
120
- }
121
- }
122
- if (lastError) {
123
- throw lastError
124
- }
125
- return secret
126
- }
127
-
128
- private maybeEncryptSecret(secret: string) {
129
- const key = this.getEncryptionKey()
130
- if (!key) {
131
- return secret
132
- }
133
- return encryptSecret(secret, key)
134
- }
135
-
136
- private maybeDecryptSecret(secret?: string | null) {
137
- if (!secret) {
138
- return ""
139
- }
140
- const keys = this.getDecryptionKeys()
141
- if (keys.length === 0) {
142
- if (isEncryptedSecret(secret)) {
143
- throw new Error(
144
- "PayPal client secret is encrypted. Set PAYPAL_CREDENTIALS_ENCRYPTION_KEY to decrypt."
145
- )
146
- }
147
- return secret
148
- }
149
- if (!isEncryptedSecret(secret)) {
150
- return secret
151
- }
152
- return this.decryptSecretWithKeys(secret, keys)
153
- }
154
-
155
- private async getPartnerMerchantId(env: Environment) {
156
- const { onboarding } = await this.ensureSettingsDefaults()
157
- return env === "live" ? onboarding.partner_merchant_id_live : onboarding.partner_merchant_id_sandbox
158
- }
159
-
160
- /**
161
- * We keep a single row in DB and store the currently selected environment there.
162
- * If no row exists yet, default to live (production).
163
- */
164
- private async getCurrentRow(): Promise<any | null> {
165
- const rows = await this.listPayPalConnections({})
166
- return rows?.[0] ?? null
167
- }
168
-
169
- private async getCurrentEnvironment(): Promise<Environment> {
170
- try {
171
- const row = await this.getCurrentRow()
172
- const env = (row?.environment as Environment) || "live"
173
- return env === "sandbox" ? "sandbox" : "live"
174
- } catch {
175
- return "live"
176
- }
177
- }
178
-
179
- private getEnvCreds(
180
- row: any,
181
- env: Environment
182
- ): { clientId?: string; clientSecret?: string } {
183
- const meta = (row?.metadata || {}) as any
184
- const creds = meta?.credentials?.[env] || {}
185
- return {
186
- clientId: creds.client_id || creds.clientId || undefined,
187
- clientSecret: creds.client_secret || creds.clientSecret || undefined,
188
- }
189
- }
190
-
191
- private async fetchMerchantIntegrationDetails(env: Environment, merchantId: string) {
192
- const partnerMerchantId = await this.getPartnerMerchantId(env)
193
- if (!partnerMerchantId) {
194
- throw new Error("Missing PayPal partner merchant id configuration.")
195
- }
196
-
197
- const { onboarding } = await this.ensureSettingsDefaults()
198
- const baseUrl = env === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"
199
- const accessToken = await this.getAppAccessToken()
200
-
201
- const resp = await fetch(
202
- `${baseUrl}/v1/customer/partners/${encodeURIComponent(
203
- partnerMerchantId
204
- )}/merchant-integrations/${encodeURIComponent(merchantId)}`,
205
- {
206
- method: "GET",
207
- headers: {
208
- "Content-Type": "application/json",
209
- Authorization: `Bearer ${accessToken}`,
210
- ...(onboarding.bn_code ? { "PayPal-Partner-Attribution-Id": onboarding.bn_code } : {}),
211
- },
212
- }
213
- )
214
-
215
- const text = await resp.text().catch(() => "")
216
- let json: any = {}
217
- try {
218
- json = text ? JSON.parse(text) : {}
219
- } catch (e: any) {
220
- console.warn("[PayPal] Failed to parse response JSON — using empty object:", e?.message)
221
- }
222
-
223
- if (!resp.ok) {
224
- throw new Error(
225
- `PayPal merchant integration lookup failed (${resp.status}): ${text || JSON.stringify(json)}`
226
- )
227
- }
228
-
229
- return json
230
- }
231
-
232
- private async syncRowFieldsFromMetadata(row: any, env: Environment) {
233
- const c = this.getEnvCreds(row, env)
234
- await this.updatePayPalConnections({
235
- id: row.id,
236
- status: c.clientId && c.clientSecret ? "connected" : "disconnected",
237
- seller_client_id: c.clientId || null,
238
- seller_client_secret: c.clientSecret || null,
239
- metadata: {
240
- ...(row.metadata || {}),
241
- active_environment: env,
242
- },
243
- })
244
- }
245
-
246
- /**
247
- * Set environment based on admin UI selection (WooCommerce-style).
248
- * Switching environment clears stored credentials and requires re-onboarding.
249
- */
250
-
251
- async setEnvironment(env: Environment) {
252
- const nextEnv: Environment = env === "sandbox" ? "sandbox" : "live"
253
- const row = await this.getCurrentRow()
254
- const previousEnv = (row?.environment as Environment) || "live"
255
-
256
- if (!row) {
257
- // Create a row with no credentials yet for either environment
258
- const created = await this.createPayPalConnections({
259
- environment: nextEnv,
260
- status: "disconnected",
261
- shared_id: null,
262
- auth_code: null,
263
- seller_client_id: null,
264
- seller_client_secret: null,
265
- app_access_token: null,
266
- app_access_token_expires_at: null,
267
- metadata: { credentials: {}, active_environment: nextEnv },
268
- })
269
- await this.recordAuditEvent("environment_switched", {
270
- previous_environment: previousEnv,
271
- environment: nextEnv,
272
- })
273
- return created
274
- }
275
-
276
- // Just switch the active environment (do NOT wipe other env credentials)
277
- await this.updatePayPalConnections({
278
- id: row.id,
279
- environment: nextEnv,
280
- app_access_token: null,
281
- app_access_token_expires_at: null,
282
- metadata: {
283
- ...(row.metadata || {}),
284
- active_environment: nextEnv,
285
- },
286
- })
287
-
288
- // Sync top-level fields/status for the active env so existing code keeps working
289
- const updated = await this.getCurrentRow()
290
- if (updated) {
291
- await this.syncRowFieldsFromMetadata(updated, nextEnv)
292
- }
293
-
294
- await this.recordAuditEvent("environment_switched", {
295
- previous_environment: previousEnv,
296
- environment: nextEnv,
297
- })
298
- return await this.getCurrentRow()
299
- }
300
-
301
- /**
302
- * ✅ WooCommerce-style signup link generation (your service returns PayPal partner-referrals JSON)
303
- *
304
- * - POST to your PHP service (WPG_ONBOARDING_URL)
305
- * - Content-Type: application/x-www-form-urlencoded
306
- * - fields: email, sandbox, return_url, return_url_description, products[], partner_merchant_id
307
- *
308
- * Response formats supported:
309
- * 1) PayPal partner-referrals JSON: { links: [ { rel: "action_url", href: "..." }, ... ] }
310
- * 2) Custom JSON: { onboarding_url: "..." }
311
- * 3) Plain URL string
312
- */
313
- async createOnboardingLink(input?: { email?: string; products?: string[] }) {
314
- const { onboarding } = await this.ensureSettingsDefaults()
315
- const return_url = `${String(onboarding.backend_url || "").replace(/\/$/, "")}/admin/paypal/onboard-complete`
316
- const env = await this.getCurrentEnvironment()
317
- const partner_merchant_id = await this.getPartnerMerchantId(env)
318
-
319
- // Match WooCommerce behavior: prefer the current admin user email when available.
320
- // If it's missing, continue without it (some services can infer or ignore the field).
321
- const email = (input?.email || "").trim()
322
-
323
- if (!partner_merchant_id) {
324
- throw new Error("Missing PAYPAL_PARTNER_MERCHANT_ID_* env for current environment")
325
- }
326
-
327
- // NOTE:
328
- // We intentionally avoid DB access here because Medusa v2 has a known issue where
329
- // MedusaService-generated DB methods can throw 'fork' errors when called outside a transaction context.
330
- // We store onboarding state later when the JS callback returns authCode/sharedId.
331
- const form = new URLSearchParams()
332
- if (email) {
333
- form.set("email", email)
334
- }
335
- form.set("sandbox", env === "live" ? "no" : "yes")
336
- form.set("return_url", return_url)
337
- form.set("return_url_description", "Return to your shop.")
338
- form.set("partner_merchant_id", partner_merchant_id)
339
-
340
- const products = input?.products?.length ? input.products : ["PPCP"]
341
-
342
- // WooCommerce/wp_remote_request encodes PHP arrays like products[0]=PPCP.
343
- // To maximize compatibility with your existing PHP bridge, we send BOTH:
344
- // - products[0], products[1], ...
345
- // - products[] (common PHP convention)
346
- products.forEach((p, i) => {
347
- form.append(`products[${i}]`, p)
348
- form.append("products[]", p)
349
- })
350
-
351
- const res = await fetch(onboarding.partner_service_url, {
352
- method: "POST",
353
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
354
- body: form.toString(),
355
- })
356
-
357
- const text = await res.text().catch(() => "")
358
- if (!res.ok) {
359
- throw new Error(`Onboarding service failed (${res.status}): ${text}`)
360
- }
361
-
362
- const trimmed = text.trim()
363
-
364
- // plain URL
365
- if (trimmed.startsWith("http")) {
366
- return { onboarding_url: trimmed, return_url }
367
- }
368
-
369
- let json: any
370
- try {
371
- json = JSON.parse(trimmed)
372
- } catch {
373
- throw new Error(`Invalid onboarding link response (not JSON / URL): ${trimmed.slice(0, 200)}`)
374
- }
375
-
376
- /**
377
- * ✅ WooCommerce-style wrapper support
378
- *
379
- * Your PHP service (same as WooCommerce) often returns a wrapper like:
380
- * { result, http_code, headers, body }
381
- * where `body` is the actual PayPal partner-referrals JSON string.
382
- */
383
- if (json?.body) {
384
- const inner = typeof json.body === "string" ? json.body.trim() : json.body
385
-
386
- // body is a plain URL
387
- if (typeof inner === "string" && inner.startsWith("http")) {
388
- return { onboarding_url: inner, return_url }
389
- }
390
-
391
- // body is JSON string/object
392
- try {
393
- json = typeof inner === "string" ? JSON.parse(inner) : inner
394
- } catch {
395
- throw new Error(
396
- `Onboarding wrapper JSON 'body' is not valid JSON / URL: ${
397
- typeof inner === "string" ? inner.slice(0, 200) : "[object]"
398
- }`
399
- )
400
- }
401
- }
402
-
403
- // ✅ If PayPal returned an error object, surface it clearly.
404
- // Typical error shape: { name, message, debug_id, details: [...], links: [...] }
405
- if (json?.name && json?.message && (json?.debug_id || json?.details || json?.links)) {
406
- const debug = json.debug_id ? ` debug_id=${json.debug_id}` : ""
407
- const details = Array.isArray(json.details)
408
- ? json.details
409
- .slice(0, 3)
410
- .map((d: any) => {
411
- const issue = d?.issue ? String(d.issue) : ""
412
- const desc = d?.description ? String(d.description) : ""
413
- const field = d?.field ? String(d.field) : ""
414
- return [issue, desc, field].filter(Boolean).join(" | ")
415
- })
416
- .filter(Boolean)
417
- .join("; ")
418
- : ""
419
-
420
- throw new Error(`PayPal onboarding error: ${json.name}: ${json.message}.${debug}${details ? ` Details: ${details}` : ""}`)
421
- }
422
-
423
- // custom json
424
- if (json?.onboarding_url && String(json.onboarding_url).startsWith("http")) {
425
- return { onboarding_url: String(json.onboarding_url), return_url }
426
- }
427
-
428
- // PayPal partner-referrals format: links[] rel=action_url
429
- const links = Array.isArray(json?.links) ? json.links : null
430
- if (links) {
431
- const action = links.find(
432
- (l: any) => l?.rel === "action_url" || l?.rel === "actionUrl" || l?.rel === "action-url"
433
- )
434
- const href = action?.href ? String(action.href) : null
435
- if (href && href.startsWith("http")) {
436
- return { onboarding_url: href, return_url }
437
- }
438
- }
439
-
440
- throw new Error(
441
- `Onboarding JSON missing action_url link. Keys: ${Object.keys(json || {}).join(", ")}`
442
- )
443
- }
444
-
445
- async startOnboarding() {
446
- const row = await this.getCurrentRow()
447
- const env = await this.getCurrentEnvironment()
448
-
449
- if (row) {
450
- // MedusaService-generated update methods expect an object that includes the entity id
451
- await this.updatePayPalConnections({ id: row.id, status: "pending" })
452
- return
453
- }
454
-
455
- await this.createPayPalConnections({
456
- environment: env,
457
- status: "pending",
458
- metadata: {},
459
- })
460
- }
461
-
462
- async saveOnboardCallback(input: { authCode: string; sharedId: string }) {
463
- const row = await this.getCurrentRow()
464
- const env = await this.getCurrentEnvironment()
465
-
466
- if (!row) {
467
- return await this.createPayPalConnections({
468
- environment: env,
469
- status: "pending_credentials",
470
- auth_code: input.authCode,
471
- shared_id: input.sharedId,
472
- metadata: {},
473
- })
474
- }
475
-
476
- return await this.updatePayPalConnections({
477
- id: row.id,
478
- status: "pending_credentials",
479
- auth_code: input.authCode,
480
- shared_id: input.sharedId,
481
- })
482
- }
483
-
484
- /**
485
- * Exchange authCode/sharedId for seller API credentials (server-side) and save in DB.
486
- *
487
- * This calls an optional exchange service you control:
488
- * PAYPAL_EXCHANGE_SERVICE_URL or PAYPAL_PARTNER_EXCHANGE_URL
489
- *
490
- * Expected JSON response:
491
- * { clientId: string, clientSecret: string, merchantId?: string }
492
- */
493
- async exchangeAndSaveSellerCredentials(input: {
494
- authCode: string
495
- sharedId: string
496
- env?: "sandbox" | "live"
497
- }) {
498
- // 1) Persist callback (sharedId/authCode) first
499
- await this.saveOnboardCallback({ authCode: input.authCode, sharedId: input.sharedId })
500
-
501
- // 2) Exchange authCode + sharedId (+ seller nonce) to get SELLER access token
502
- const env = (input.env || (await this.getCurrentEnvironment())) as Environment
503
- const baseUrl = env === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"
504
-
505
- // IMPORTANT: code_verifier MUST match the seller_nonce used when creating the onboarding link.
506
- // In WooCommerce you use a fixed nonce() and it works, so we do the same here (no DB storage).
507
- const { onboarding } = await this.ensureSettingsDefaults()
508
- const sellerNonce = (onboarding.seller_nonce || "").trim()
509
- if (!sellerNonce) {
510
- throw new Error("PayPal seller nonce is not configured. Set PAYPAL_SELLER_NONCE.")
511
- }
512
-
513
- const tokenBody = new URLSearchParams()
514
- tokenBody.set("grant_type", "authorization_code")
515
- tokenBody.set("code", input.authCode)
516
- tokenBody.set("code_verifier", sellerNonce)
517
-
518
- const basic = Buffer.from(`${input.sharedId}:`).toString("base64")
519
-
520
- const tokenRes = await fetch(`${baseUrl}/v1/oauth2/token`, {
521
- method: "POST",
522
- headers: {
523
- "Content-Type": "application/x-www-form-urlencoded",
524
- Authorization: `Basic ${basic}`,
525
- },
526
- body: tokenBody,
527
- })
528
-
529
- const tokenText = await tokenRes.text().catch(() => "")
530
- let tokenJson: any = {}
531
- try {
532
- tokenJson = tokenText ? JSON.parse(tokenText) : {}
533
- } catch (e: any) {
534
- console.warn("[PayPal] Failed to parse token response JSON:", e?.message)
535
- }
536
-
537
- if (!tokenRes.ok) {
538
- throw new Error(
539
- `PayPal authorization_code token exchange failed (${tokenRes.status}): ${tokenText || JSON.stringify(tokenJson)}`
540
- )
541
- }
542
-
543
- const sellerAccessToken = String(tokenJson.access_token || "")
544
- if (!sellerAccessToken) {
545
- throw new Error("PayPal token exchange succeeded but access_token is missing.")
546
- }
547
-
548
- // 3) Use SELLER access token to fetch seller REST API credentials
549
- const partnerMerchantId = await this.getPartnerMerchantId(env)
550
- if (!partnerMerchantId) {
551
- throw new Error("Missing PayPal partner merchant id configuration.")
552
- }
553
-
554
- const credRes = await fetch(
555
- `${baseUrl}/v1/customer/partners/${encodeURIComponent(partnerMerchantId)}/merchant-integrations/credentials/`,
556
- {
557
- method: "GET",
558
- headers: {
559
- "Content-Type": "application/json",
560
- Authorization: `Bearer ${sellerAccessToken}`,
561
- ...(onboarding.bn_code ? { "PayPal-Partner-Attribution-Id": onboarding.bn_code } : {}),
562
- },
563
- }
564
- )
565
-
566
- const credText = await credRes.text().catch(() => "")
567
- let credJson: any = {}
568
- try {
569
- credJson = credText ? JSON.parse(credText) : {}
570
- } catch (e: any) {
571
- console.warn("[PayPal] Failed to parse token response JSON:", e?.message)
572
- }
573
-
574
- if (!credRes.ok) {
575
- throw new Error(
576
- `PayPal credentials fetch failed (${credRes.status}): ${credText || JSON.stringify(credJson)}`
577
- )
578
- }
579
-
580
- const clientId = String(credJson.client_id || "")
581
- const clientSecret = String(credJson.client_secret || "")
582
- if (!clientId || !clientSecret) {
583
- throw new Error(
584
- `PayPal credentials response missing client_id/client_secret. Keys: ${Object.keys(credJson || {}).join(", ")}`
585
- )
586
- }
587
-
588
- // 4) Save seller credentials (marks status = connected)
589
- await this.saveSellerCredentials({ clientId, clientSecret })
590
-
591
- }
592
-
593
-
594
- async saveSellerCredentials(input: { clientId: string; clientSecret: string }) {
595
- const row = await this.getCurrentRow()
596
- const env = await this.getCurrentEnvironment()
597
-
598
- const encryptedSecret = this.maybeEncryptSecret(input.clientSecret)
599
- const nextCreds = {
600
- client_id: input.clientId,
601
- client_secret: encryptedSecret,
602
- }
603
-
604
- if (!row) {
605
- const created = await this.createPayPalConnections({
606
- environment: env,
607
- status: "connected",
608
- seller_client_id: input.clientId,
609
- seller_client_secret: encryptedSecret,
610
- app_access_token: null,
611
- app_access_token_expires_at: null,
612
- metadata: {
613
- credentials: {
614
- [env]: nextCreds,
615
- },
616
- active_environment: env,
617
- },
618
- })
619
- await this.recordAuditEvent("credentials_saved", {
620
- environment: env,
621
- client_id: input.clientId,
622
- })
623
- await this.ensureWebhookRegistration()
624
- return created
625
- }
626
-
627
- const meta = (row.metadata || {}) as any
628
- const creds = { ...(meta.credentials || {}) }
629
- creds[env] = {
630
- ...(creds[env] || {}),
631
- ...nextCreds,
632
- }
633
-
634
- const updated = await this.updatePayPalConnections({
635
- id: row.id,
636
- status: "connected",
637
- seller_client_id: input.clientId,
638
- seller_client_secret: encryptedSecret,
639
- app_access_token: null,
640
- app_access_token_expires_at: null,
641
- metadata: {
642
- ...(row.metadata || {}),
643
- credentials: creds,
644
- active_environment: env,
645
- },
646
- })
647
- await this.recordAuditEvent("credentials_saved", {
648
- environment: env,
649
- client_id: input.clientId,
650
- })
651
- await this.ensureWebhookRegistration()
652
- return updated
653
- }
654
-
655
- private async resolveWebhookUrl() {
656
- const { onboarding } = await this.ensureSettingsDefaults()
657
- const base = String(onboarding.backend_url || "").replace(/\/$/, "")
658
- if (!base) {
659
- throw new Error("PayPal backend URL is not configured.")
660
- }
661
- return `${base}/store/paypal/webhook`
662
- }
663
-
664
- private isLocalWebhookUrl(url: string) {
665
- try {
666
- const parsed = new URL(url)
667
- return ["localhost", "127.0.0.1", "::1"].includes(parsed.hostname)
668
- } catch {
669
- return false
670
- }
671
- }
672
-
673
- private async ensureWebhookRegistration() {
674
- const env = await this.getCurrentEnvironment()
675
- const { apiDetails } = await this.ensureSettingsDefaults()
676
- const webhookIds = { ...(apiDetails.webhook_ids || {}) } as Record<string, string>
677
-
678
- if (webhookIds[env]) {
679
- return webhookIds[env]
680
- }
681
-
682
- const accessToken = await this.getAppAccessToken()
683
- const baseUrl = env === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"
684
- const webhookUrl = await this.resolveWebhookUrl()
685
-
686
- if (this.isLocalWebhookUrl(webhookUrl)) {
687
- await this.recordAuditEvent("webhook_skipped_localhost", {
688
- environment: env,
689
- webhook_url: webhookUrl,
690
- })
691
- return webhookIds[env] || ""
692
- }
693
-
694
- const listResp = await fetch(`${baseUrl}/v1/notifications/webhooks`, {
695
- headers: {
696
- Authorization: `Bearer ${accessToken}`,
697
- "Content-Type": "application/json",
698
- },
699
- })
700
-
701
- const listJson = await listResp.json().catch(() => ({}))
702
- if (!listResp.ok) {
703
- throw new Error(`PayPal webhook list failed (${listResp.status}): ${JSON.stringify(listJson)}`)
704
- }
705
-
706
- const existing = Array.isArray(listJson?.webhooks)
707
- ? listJson.webhooks.find((hook: any) => hook?.url === webhookUrl)
708
- : null
709
-
710
- let webhookId = existing?.id ? String(existing.id) : ""
711
-
712
- if (!webhookId) {
713
- const createResp = await fetch(`${baseUrl}/v1/notifications/webhooks`, {
714
- method: "POST",
715
- headers: {
716
- Authorization: `Bearer ${accessToken}`,
717
- "Content-Type": "application/json",
718
- },
719
- body: JSON.stringify({
720
- url: webhookUrl,
721
- event_types: [
722
- { name: "CHECKOUT.ORDER.APPROVED" },
723
- { name: "CHECKOUT.ORDER.CANCELLED" },
724
- { name: "PAYMENT.CAPTURE.COMPLETED" },
725
- { name: "PAYMENT.CAPTURE.DENIED" },
726
- { name: "PAYMENT.CAPTURE.REFUNDED" },
727
- { name: "PAYMENT.CAPTURE.REVERSED" },
728
- { name: "PAYMENT.AUTHORIZATION.CREATED" },
729
- { name: "PAYMENT.AUTHORIZATION.VOIDED" },
730
- { name: "PAYMENT.AUTHORIZATION.DENIED" },
731
- { name: "PAYMENT.REFUND.COMPLETED" },
732
- { name: "PAYMENT.REFUND.DENIED" },
733
- ],
734
- }),
735
- })
736
-
737
- const createJson = await createResp.json().catch(() => ({}))
738
- if (!createResp.ok) {
739
- throw new Error(
740
- `PayPal webhook create failed (${createResp.status}): ${JSON.stringify(createJson)}`
741
- )
742
- }
743
-
744
- webhookId = String(createJson?.id || "")
745
- }
746
-
747
- if (!webhookId) {
748
- throw new Error("PayPal webhook registration did not return an id")
749
- }
750
-
751
- const nextWebhookIds = { ...webhookIds, [env]: webhookId }
752
- await this.saveSettings({
753
- api_details: {
754
- ...apiDetails,
755
- webhook_ids: nextWebhookIds,
756
- },
757
- })
758
-
759
- await this.recordAuditEvent("webhook_registered", {
760
- environment: env,
761
- webhook_id: webhookId,
762
- webhook_url: webhookUrl,
763
- })
764
-
765
- return webhookId
766
- }
767
-
768
- private maskValue(value?: string | null, visibleChars = 4) {
769
- if (!value) return null
770
- const trimmed = String(value)
771
- if (trimmed.length <= visibleChars) {
772
- return "•".repeat(trimmed.length)
773
- }
774
- return `${"•".repeat(Math.max(0, trimmed.length - visibleChars))}${trimmed.slice(
775
- -visibleChars
776
- )}`
777
- }
778
-
779
- async rotateCredentialEncryptionKey() {
780
- const currentKey = this.getEncryptionKey()
781
- if (!currentKey) {
782
- throw new Error("PAYPAL_CREDENTIALS_ENCRYPTION_KEY must be set to rotate credentials.")
783
- }
784
-
785
- const row = await this.getCurrentRow()
786
- if (!row) {
787
- return { rotated: 0 }
788
- }
789
-
790
- const meta = (row.metadata || {}) as any
791
- const credentials = { ...(meta.credentials || {}) }
792
- let rotated = 0
793
-
794
- for (const [env, envCreds] of Object.entries(credentials)) {
795
- if (!envCreds || typeof envCreds !== "object") continue
796
- const clientSecret = (envCreds as any).client_secret
797
- if (!clientSecret) continue
798
-
799
- const decrypted = this.maybeDecryptSecret(clientSecret)
800
- const reEncrypted = this.maybeEncryptSecret(decrypted)
801
- if (reEncrypted !== clientSecret) {
802
- credentials[env] = {
803
- ...(envCreds as any),
804
- client_secret: reEncrypted,
805
- }
806
- rotated += 1
807
- }
808
- }
809
-
810
- if (rotated === 0) {
811
- return { rotated: 0 }
812
- }
813
-
814
- await this.updatePayPalConnections({
815
- id: row.id,
816
- metadata: {
817
- ...(row.metadata || {}),
818
- credentials,
819
- },
820
- seller_client_secret: credentials?.[row.environment as Environment]?.client_secret || null,
821
- })
822
-
823
- const updated = await this.getCurrentRow()
824
- if (updated) {
825
- await this.syncRowFieldsFromMetadata(updated, (updated.environment as Environment) || "live")
826
- }
827
-
828
- await this.recordAuditEvent("credentials_rotated", {
829
- environments: Object.keys(credentials),
830
- })
831
-
832
- return { rotated }
833
- }
834
-
835
- async getStatus(envOverride?: Environment) {
836
- const row = await this.getCurrentRow()
837
- const env = envOverride ?? (await this.getCurrentEnvironment())
838
-
839
- if (!row) {
840
- return { environment: env, status: "disconnected" as Status, seller_client_id_present: false }
841
- }
842
-
843
- const c = this.getEnvCreds(row, env)
844
- const hasCreds = !!(c.clientId && c.clientSecret)
845
- const sellerEmail: string | null = null
846
-
847
- return {
848
- environment: env,
849
- status: (hasCreds ? "connected" : "disconnected") as Status,
850
- shared_id: row.shared_id ?? null,
851
- auth_code: row.auth_code ? "***stored***" : null,
852
- seller_client_id_present: hasCreds,
853
- seller_client_id_masked: this.maskValue(c.clientId),
854
- seller_client_secret_masked: c.clientSecret ? "••••••••" : null,
855
- seller_email: sellerEmail,
856
- updated_at: (row.updated_at as any)?.toISOString?.() ?? null,
857
- }
858
- }
859
-
860
- async disconnect() {
861
- const row = await this.getCurrentRow()
862
- if (!row) return
863
- const env = await this.getCurrentEnvironment()
864
-
865
- const meta = (row.metadata || {}) as any
866
- const creds = { ...(meta.credentials || {}) }
867
- // Remove only the active environment credentials
868
- delete creds[env]
869
-
870
- const hasAnyCreds = Object.values(creds).some((v: any) => {
871
- return v && typeof v === "object" && (v as any).client_id && (v as any).client_secret
872
- })
873
-
874
- await this.updatePayPalConnections({
875
- id: row.id,
876
- status: hasAnyCreds ? "connected" : "disconnected",
877
- shared_id: null,
878
- auth_code: null,
879
- seller_client_id: null,
880
- seller_client_secret: null,
881
- app_access_token: null,
882
- app_access_token_expires_at: null,
883
- metadata: {
884
- ...(row.metadata || {}),
885
- credentials: creds,
886
- active_environment: env,
887
- },
888
- })
889
- const updated = await this.getCurrentRow()
890
- if (updated) {
891
- await this.syncRowFieldsFromMetadata(updated, env)
892
- }
893
- await this.recordAuditEvent("disconnected", { environment: env })
894
- }
895
-
896
- async getAppAccessToken(): Promise<string> {
897
- const row = await this.getCurrentRow()
898
- const env = await this.getCurrentEnvironment()
899
- const creds = await this.getActiveCredentials()
900
-
901
- if (!row) {
902
- throw new Error("PayPal connection row not found. Please complete onboarding.")
903
- }
904
-
905
- const expiresAt = row.app_access_token_expires_at ? new Date(row.app_access_token_expires_at as any) : null
906
- if (row.app_access_token && expiresAt) {
907
- const msLeft = expiresAt.getTime() - Date.now()
908
- if (msLeft > 2 * 60 * 1000) return row.app_access_token
909
- }
910
-
911
- const baseUrl = env === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"
912
- const basic = Buffer.from(`${creds.client_id}:${creds.client_secret}`).toString("base64")
913
-
914
- const body = new URLSearchParams()
915
- body.set("grant_type", "client_credentials")
916
-
917
- const res = await fetch(`${baseUrl}/v1/oauth2/token`, {
918
- method: "POST",
919
- headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: `Basic ${basic}` },
920
- body,
921
- })
922
-
923
- const json = await res.json().catch(() => ({}))
924
- if (!res.ok) throw new Error(`PayPal client_credentials failed (${res.status}): ${JSON.stringify(json)}`)
925
-
926
- const accessToken = String(json.access_token)
927
- const expiresIn = Number(json.expires_in || 3600)
928
- const newExpiresAt = new Date(Date.now() + expiresIn * 1000)
929
-
930
- await this.updatePayPalConnections({
931
- id: row.id,
932
- app_access_token: accessToken,
933
- app_access_token_expires_at: newExpiresAt as any,
934
- })
935
-
936
- return accessToken
937
- }
938
-
939
- /**
940
- * Generate a client token for PayPal JS SDK (required for CardFields/PaymentFields).
941
- * This token is short-lived and safe to send to the browser.
942
- *
943
- * PayPal endpoint: POST /v1/identity/generate-token
944
- */
945
- async generateClientToken(opts?: { locale?: string }): Promise<string> {
946
- const env = await this.getCurrentEnvironment()
947
- const baseUrl = env === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"
948
-
949
- const accessToken = await this.getAppAccessToken()
950
-
951
- const res = await fetch(`${baseUrl}/v1/identity/generate-token`, {
952
- method: "POST",
953
- headers: {
954
- Authorization: `Bearer ${accessToken}`,
955
- "Content-Type": "application/json",
956
- Accept: "application/json",
957
- ...(opts?.locale ? { "Accept-Language": opts.locale } : {}),
958
- ...(this.cfg.bnCode ? { "PayPal-Partner-Attribution-Id": this.cfg.bnCode } : {}),
959
- },
960
- })
961
-
962
- const json = await res.json().catch(() => ({}))
963
- if (!res.ok) {
964
- throw new Error(`PayPal generate-token failed (${res.status}): ${JSON.stringify(json)}`)
965
- }
966
-
967
- const token = String((json as any)?.client_token || "")
968
- if (!token) {
969
- throw new Error("PayPal client_token is missing in generate-token response")
970
- }
971
-
972
- return token
973
- }
974
-
975
- /**
976
- * GLOBAL PayPal settings (single row)
977
- */
978
- async getSettings() {
979
- const rows = await this.listPayPalSettings({})
980
- const row = rows?.[0]
981
- return { data: (row?.data || {}) as Record<string, any> }
982
- }
983
-
984
- /**
985
- * Deep-merge patch into current settings.
986
- * Nested objects (additional_settings, api_details, etc.) are merged,
987
- * not replaced.
988
- */
989
- private deepMerge(
990
- target: Record<string, any>,
991
- source: Record<string, any>
992
- ): Record<string, any> {
993
- const result = { ...target }
994
- for (const key of Object.keys(source)) {
995
- const sv = source[key]
996
- const tv = target[key]
997
- if (
998
- sv !== null &&
999
- typeof sv === "object" &&
1000
- !Array.isArray(sv) &&
1001
- tv !== null &&
1002
- typeof tv === "object" &&
1003
- !Array.isArray(tv)
1004
- ) {
1005
- result[key] = this.deepMerge(tv, sv)
1006
- } else {
1007
- result[key] = sv
1008
- }
1009
- }
1010
- return result
1011
- }
1012
-
1013
- async saveSettings(patch: Record<string, any>) {
1014
- const rows = await this.listPayPalSettings({})
1015
- const row = rows?.[0]
1016
- const current = (row?.data || {}) as Record<string, any>
1017
-
1018
- const next = this.deepMerge(current, patch)
1019
-
1020
- if (!row) {
1021
- const created = await this.createPayPalSettings({ data: next })
1022
- return { data: (created.data || {}) as Record<string, any> }
1023
- }
1024
-
1025
- await this.updatePayPalSettings({ id: row.id, data: next })
1026
- return { data: next }
1027
- }
1028
-
1029
- /**
1030
- * Active credentials based on selected environment in the single connection row.
1031
- */
1032
- async getActiveCredentials() {
1033
- const row = await this.getCurrentRow()
1034
- const env = await this.getCurrentEnvironment()
1035
-
1036
- if (!row) {
1037
- throw new Error("PayPal connection row not found. Please complete onboarding.")
1038
- }
1039
-
1040
- const c = this.getEnvCreds(row, env)
1041
- const clientSecret = this.maybeDecryptSecret(c.clientSecret)
1042
-
1043
- if (!c.clientId || !clientSecret) {
1044
- throw new Error(
1045
- `PayPal credentials missing for environment "${env}". Please save credentials.`
1046
- )
1047
- }
1048
-
1049
- return {
1050
- environment: env,
1051
- client_id: c.clientId,
1052
- client_secret: clientSecret,
1053
- }
1054
- }
1055
-
1056
- async getOrderDetails(orderId: string) {
1057
- if (!orderId) {
1058
- throw new Error("PayPal orderId is required")
1059
- }
1060
-
1061
- const creds = await this.getActiveCredentials()
1062
- const base =
1063
- creds.environment === "live"
1064
- ? "https://api-m.paypal.com"
1065
- : "https://api-m.sandbox.paypal.com"
1066
- const auth = Buffer.from(`${creds.client_id}:${creds.client_secret}`).toString("base64")
1067
-
1068
- const tokenResp = await fetch(`${base}/v1/oauth2/token`, {
1069
- method: "POST",
1070
- headers: {
1071
- Authorization: `Basic ${auth}`,
1072
- "Content-Type": "application/x-www-form-urlencoded",
1073
- },
1074
- body: "grant_type=client_credentials",
1075
- })
1076
-
1077
- const tokenText = await tokenResp.text()
1078
- if (!tokenResp.ok) {
1079
- throw new Error(`PayPal token error (${tokenResp.status}): ${tokenText}`)
1080
- }
1081
-
1082
- const tokenJson = JSON.parse(tokenText)
1083
- const accessToken = String(tokenJson.access_token)
1084
-
1085
- const resp = await fetch(`${base}/v2/checkout/orders/${orderId}`, {
1086
- method: "GET",
1087
- headers: {
1088
- Authorization: `Bearer ${accessToken}`,
1089
- "Content-Type": "application/json",
1090
- },
1091
- })
1092
-
1093
- const text = await resp.text()
1094
- if (!resp.ok) {
1095
- throw new Error(`PayPal get order error (${resp.status}): ${text}`)
1096
- }
1097
-
1098
- return JSON.parse(text)
1099
- }
1100
-
1101
- async createWebhookEventRecord(input: {
1102
- event_id: string
1103
- event_type: string
1104
- resource_id?: string | null
1105
- payload?: Record<string, unknown>
1106
- event_version?: string | null
1107
- transmission_id?: string | null
1108
- transmission_time?: Date | null
1109
- status?: string
1110
- attempt_count?: number
1111
- }) {
1112
- try {
1113
- const created = await this.createPayPalWebhookEvents({
1114
- event_id: input.event_id,
1115
- event_type: input.event_type,
1116
- resource_id: input.resource_id ?? null,
1117
- payload: input.payload ?? {},
1118
- event_version: input.event_version ?? null,
1119
- transmission_id: input.transmission_id ?? null,
1120
- transmission_time: input.transmission_time ?? null,
1121
- status: input.status ?? "pending",
1122
- attempt_count: input.attempt_count ?? 0,
1123
- next_retry_at: null,
1124
- processed_at: null,
1125
- last_error: null,
1126
- })
1127
- return { created: true, event: created }
1128
- } catch (error: any) {
1129
- const message = String(error?.message || "")
1130
- if (message.includes("paypal_webhook_event_event_id_unique") || message.includes("unique")) {
1131
- const existing = await this.listPayPalWebhookEvents({ event_id: input.event_id })
1132
- return { created: false, event: existing?.[0] ?? null }
1133
- }
1134
- throw error
1135
- }
1136
- }
1137
-
1138
- async updateWebhookEventRecord(input: {
1139
- id: string
1140
- status?: string
1141
- attempt_count?: number
1142
- next_retry_at?: Date | null
1143
- processed_at?: Date | null
1144
- last_error?: string | null
1145
- resource_id?: string | null
1146
- }) {
1147
- return await this.updatePayPalWebhookEvents({
1148
- id: input.id,
1149
- status: input.status,
1150
- attempt_count: input.attempt_count,
1151
- next_retry_at: input.next_retry_at ?? null,
1152
- processed_at: input.processed_at ?? null,
1153
- last_error: input.last_error ?? null,
1154
- resource_id: input.resource_id ?? null,
1155
- })
1156
- }
1157
-
1158
- async recordAuditEvent(_eventType: string, _metadata?: Record<string, unknown>) {
1159
- return null
1160
- }
1161
-
1162
-
1163
- async recordMetric(name: string, metadata?: Record<string, unknown>) {
1164
- const existing = await this.listPayPalMetrics({ name })
1165
- const row = existing?.[0]
1166
- const current = (row?.data || {}) as Record<string, any>
1167
- const next = {
1168
- ...current,
1169
- ...(metadata || {}),
1170
- count: Number(current.count || 0) + 1,
1171
- last_recorded_at: new Date().toISOString(),
1172
- }
1173
-
1174
- if (!row) {
1175
- return await this.createPayPalMetrics({
1176
- name,
1177
- data: next,
1178
- })
1179
- }
1180
-
1181
- return await this.updatePayPalMetrics({
1182
- id: row.id,
1183
- name,
1184
- data: next,
1185
- })
1186
- }
1187
-
1188
- async recordPaymentLog(eventType: string, metadata?: Record<string, unknown>) {
1189
- const payload = {
1190
- event_type: eventType,
1191
- metadata: metadata ?? {},
1192
- created_at: new Date().toISOString(),
1193
- }
1194
- console.info("[PayPal] payment_event", payload)
1195
- return await this.recordAuditEvent(`payment_${eventType}`, metadata)
1196
- }
1197
-
1198
- async sendAlert(input: {
1199
- type: string
1200
- message: string
1201
- metadata?: Record<string, unknown>
1202
- }) {
1203
- const urls = this.getAlertWebhookUrls()
1204
- if (urls.length === 0) {
1205
- return
1206
- }
1207
-
1208
- const payload = {
1209
- type: input.type,
1210
- message: input.message,
1211
- metadata: input.metadata ?? {},
1212
- source: "paypal",
1213
- timestamp: new Date().toISOString(),
1214
- }
1215
-
1216
- await Promise.all(
1217
- urls.map(async (url) => {
1218
- try {
1219
- const resp = await fetch(url, {
1220
- method: "POST",
1221
- headers: {
1222
- "Content-Type": "application/json",
1223
- },
1224
- body: JSON.stringify(payload),
1225
- })
1226
- if (!resp.ok) {
1227
- const text = await resp.text().catch(() => "")
1228
- await this.recordAuditEvent("alert_failed", {
1229
- url,
1230
- status: resp.status,
1231
- response: text,
1232
- })
1233
- } else {
1234
- await this.recordAuditEvent("alert_sent", { url, type: input.type })
1235
- }
1236
- } catch (error: any) {
1237
- await this.recordAuditEvent("alert_failed", {
1238
- url,
1239
- message: error?.message,
1240
- })
1241
- }
1242
- })
1243
- )
1244
- }
1245
- }
1246
-
1247
- export default PayPalModuleService
1
+ import { MedusaService } from "@medusajs/framework/utils"
2
+ import PayPalConnection from "./models/paypal_connection"
3
+ import PayPalMetric from "./models/paypal_metric"
4
+ import PayPalSettings from "./models/paypal_settings"
5
+ import PayPalWebhookEvent from "./models/paypal_webhook_event"
6
+ import { getPayPalConfig } from "./types/config"
7
+ import { decryptSecret, encryptSecret, isEncryptedSecret } from "./utils/crypto"
8
+ import { normalizeCurrencyCode } from "./utils/currencies"
9
+
10
+ type Environment = "sandbox" | "live"
11
+
12
+ type Status =
13
+ | "disconnected"
14
+ | "pending"
15
+ | "pending_credentials"
16
+ | "connected"
17
+ | "revoked"
18
+
19
+ class PayPalModuleService extends MedusaService({
20
+ PayPalConnection,
21
+ PayPalMetric,
22
+ PayPalSettings,
23
+ PayPalWebhookEvent,
24
+ }) {
25
+ protected cfg = getPayPalConfig()
26
+
27
+ private async getSettingsData() {
28
+ const settings = await this.getSettings()
29
+ return (settings?.data || {}) as Record<string, any>
30
+ }
31
+
32
+ private async ensureSettingsDefaults() {
33
+ const data = await this.getSettingsData()
34
+ const onboarding = { ...(data.onboarding_config || {}) } as Record<string, any>
35
+ const apiDetails = { ...(data.api_details || {}) } as Record<string, any>
36
+ let changed = false
37
+
38
+ if (!onboarding.partner_service_url) {
39
+ onboarding.partner_service_url = this.cfg.partnerServiceUrl
40
+ changed = true
41
+ }
42
+ if (!onboarding.partner_js_url) {
43
+ onboarding.partner_js_url = this.cfg.partnerJsUrl
44
+ changed = true
45
+ }
46
+ if (!onboarding.backend_url) {
47
+ onboarding.backend_url = this.cfg.backendUrl
48
+ changed = true
49
+ }
50
+ if (!onboarding.seller_nonce) {
51
+ onboarding.seller_nonce = this.cfg.sellerNonce
52
+ changed = true
53
+ }
54
+ if (!onboarding.bn_code && this.cfg.bnCode) {
55
+ onboarding.bn_code = this.cfg.bnCode
56
+ changed = true
57
+ }
58
+ if (!onboarding.partner_merchant_id_sandbox) {
59
+ onboarding.partner_merchant_id_sandbox = this.cfg.partnerMerchantIdSandbox
60
+ changed = true
61
+ }
62
+ if (!onboarding.partner_merchant_id_live) {
63
+ onboarding.partner_merchant_id_live = this.cfg.partnerMerchantIdLive
64
+ changed = true
65
+ }
66
+
67
+ if (!apiDetails.currency_code) {
68
+ const raw = (process.env.PAYPAL_CURRENCY || "").trim()
69
+ apiDetails.currency_code = raw ? normalizeCurrencyCode(raw) : "EUR"
70
+ changed = true
71
+ }
72
+ if (!apiDetails.storefront_url) {
73
+ const storeUrl = process.env.STOREFRONT_URL || process.env.STORE_URL
74
+ if (storeUrl) {
75
+ apiDetails.storefront_url = storeUrl
76
+ changed = true
77
+ }
78
+ }
79
+
80
+ if (changed) {
81
+ await this.saveSettings({
82
+ onboarding_config: onboarding,
83
+ api_details: apiDetails,
84
+ })
85
+ }
86
+
87
+ return { onboarding, apiDetails }
88
+ }
89
+
90
+ async getApiDetails() {
91
+ const { onboarding, apiDetails } = await this.ensureSettingsDefaults()
92
+ return {
93
+ onboarding,
94
+ apiDetails,
95
+ }
96
+ }
97
+
98
+ private getAlertWebhookUrls() {
99
+ return (this.cfg.alertWebhookUrls || []).map((url) => url.trim()).filter(Boolean)
100
+ }
101
+
102
+ private getEncryptionKey() {
103
+ return (this.cfg.credentialsEncryptionKey || "").trim()
104
+ }
105
+
106
+ private getDecryptionKeys() {
107
+ const current = this.getEncryptionKey()
108
+ const previous = this.cfg.credentialsEncryptionKeyPrevious || []
109
+ const keys = [current, ...previous].map((key) => (key || "").trim()).filter(Boolean)
110
+ return Array.from(new Set(keys))
111
+ }
112
+
113
+ private decryptSecretWithKeys(secret: string, keys: string[]) {
114
+ let lastError: unknown
115
+ for (const key of keys) {
116
+ try {
117
+ return decryptSecret(secret, key)
118
+ } catch (err) {
119
+ lastError = err
120
+ }
121
+ }
122
+ if (lastError) {
123
+ throw lastError
124
+ }
125
+ return secret
126
+ }
127
+
128
+ private maybeEncryptSecret(secret: string) {
129
+ const key = this.getEncryptionKey()
130
+ if (!key) {
131
+ return secret
132
+ }
133
+ return encryptSecret(secret, key)
134
+ }
135
+
136
+ private maybeDecryptSecret(secret?: string | null) {
137
+ if (!secret) {
138
+ return ""
139
+ }
140
+ const keys = this.getDecryptionKeys()
141
+ if (keys.length === 0) {
142
+ if (isEncryptedSecret(secret)) {
143
+ throw new Error(
144
+ "PayPal client secret is encrypted. Set PAYPAL_CREDENTIALS_ENCRYPTION_KEY to decrypt."
145
+ )
146
+ }
147
+ return secret
148
+ }
149
+ if (!isEncryptedSecret(secret)) {
150
+ return secret
151
+ }
152
+ return this.decryptSecretWithKeys(secret, keys)
153
+ }
154
+
155
+ private async getPartnerMerchantId(env: Environment) {
156
+ const { onboarding } = await this.ensureSettingsDefaults()
157
+ return env === "live" ? onboarding.partner_merchant_id_live : onboarding.partner_merchant_id_sandbox
158
+ }
159
+
160
+ /**
161
+ * We keep a single row in DB and store the currently selected environment there.
162
+ * If no row exists yet, default to live (production).
163
+ */
164
+ private async getCurrentRow(): Promise<any | null> {
165
+ const rows = await this.listPayPalConnections({})
166
+ return rows?.[0] ?? null
167
+ }
168
+
169
+ private async getCurrentEnvironment(): Promise<Environment> {
170
+ try {
171
+ const row = await this.getCurrentRow()
172
+ const env = (row?.environment as Environment) || "live"
173
+ return env === "sandbox" ? "sandbox" : "live"
174
+ } catch {
175
+ return "live"
176
+ }
177
+ }
178
+
179
+ private getEnvCreds(
180
+ row: any,
181
+ env: Environment
182
+ ): { clientId?: string; clientSecret?: string } {
183
+ const meta = (row?.metadata || {}) as any
184
+ const creds = meta?.credentials?.[env] || {}
185
+ return {
186
+ clientId: creds.client_id || creds.clientId || undefined,
187
+ clientSecret: creds.client_secret || creds.clientSecret || undefined,
188
+ }
189
+ }
190
+
191
+ private async fetchMerchantIntegrationDetails(env: Environment, merchantId: string) {
192
+ const partnerMerchantId = await this.getPartnerMerchantId(env)
193
+ if (!partnerMerchantId) {
194
+ throw new Error("Missing PayPal partner merchant id configuration.")
195
+ }
196
+
197
+ const { onboarding } = await this.ensureSettingsDefaults()
198
+ const baseUrl = env === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"
199
+ const accessToken = await this.getAppAccessToken()
200
+
201
+ const resp = await fetch(
202
+ `${baseUrl}/v1/customer/partners/${encodeURIComponent(
203
+ partnerMerchantId
204
+ )}/merchant-integrations/${encodeURIComponent(merchantId)}`,
205
+ {
206
+ method: "GET",
207
+ headers: {
208
+ "Content-Type": "application/json",
209
+ Authorization: `Bearer ${accessToken}`,
210
+ ...(onboarding.bn_code ? { "PayPal-Partner-Attribution-Id": onboarding.bn_code } : {}),
211
+ },
212
+ }
213
+ )
214
+
215
+ const text = await resp.text().catch(() => "")
216
+ let json: any = {}
217
+ try {
218
+ json = text ? JSON.parse(text) : {}
219
+ } catch (e: any) {
220
+ console.warn("[PayPal] Failed to parse response JSON — using empty object:", e?.message)
221
+ }
222
+
223
+ if (!resp.ok) {
224
+ throw new Error(
225
+ `PayPal merchant integration lookup failed (${resp.status}): ${text || JSON.stringify(json)}`
226
+ )
227
+ }
228
+
229
+ return json
230
+ }
231
+
232
+ private async syncRowFieldsFromMetadata(row: any, env: Environment) {
233
+ const c = this.getEnvCreds(row, env)
234
+ await this.updatePayPalConnections({
235
+ id: row.id,
236
+ status: c.clientId && c.clientSecret ? "connected" : "disconnected",
237
+ seller_client_id: c.clientId || null,
238
+ seller_client_secret: c.clientSecret || null,
239
+ metadata: {
240
+ ...(row.metadata || {}),
241
+ active_environment: env,
242
+ },
243
+ })
244
+ }
245
+
246
+ /**
247
+ * Set environment based on admin UI selection (WooCommerce-style).
248
+ * Switching environment clears stored credentials and requires re-onboarding.
249
+ */
250
+
251
+ async setEnvironment(env: Environment) {
252
+ const nextEnv: Environment = env === "sandbox" ? "sandbox" : "live"
253
+ const row = await this.getCurrentRow()
254
+ const previousEnv = (row?.environment as Environment) || "live"
255
+
256
+ if (!row) {
257
+ // Create a row with no credentials yet for either environment
258
+ const created = await this.createPayPalConnections({
259
+ environment: nextEnv,
260
+ status: "disconnected",
261
+ shared_id: null,
262
+ auth_code: null,
263
+ seller_client_id: null,
264
+ seller_client_secret: null,
265
+ app_access_token: null,
266
+ app_access_token_expires_at: null,
267
+ metadata: { credentials: {}, active_environment: nextEnv },
268
+ })
269
+ await this.recordAuditEvent("environment_switched", {
270
+ previous_environment: previousEnv,
271
+ environment: nextEnv,
272
+ })
273
+ return created
274
+ }
275
+
276
+ // Just switch the active environment (do NOT wipe other env credentials)
277
+ await this.updatePayPalConnections({
278
+ id: row.id,
279
+ environment: nextEnv,
280
+ app_access_token: null,
281
+ app_access_token_expires_at: null,
282
+ metadata: {
283
+ ...(row.metadata || {}),
284
+ active_environment: nextEnv,
285
+ },
286
+ })
287
+
288
+ // Sync top-level fields/status for the active env so existing code keeps working
289
+ const updated = await this.getCurrentRow()
290
+ if (updated) {
291
+ await this.syncRowFieldsFromMetadata(updated, nextEnv)
292
+ }
293
+
294
+ await this.recordAuditEvent("environment_switched", {
295
+ previous_environment: previousEnv,
296
+ environment: nextEnv,
297
+ })
298
+ return await this.getCurrentRow()
299
+ }
300
+
301
+ /**
302
+ * ✅ WooCommerce-style signup link generation (your service returns PayPal partner-referrals JSON)
303
+ *
304
+ * - POST to your PHP service (WPG_ONBOARDING_URL)
305
+ * - Content-Type: application/x-www-form-urlencoded
306
+ * - fields: email, sandbox, return_url, return_url_description, products[], partner_merchant_id
307
+ *
308
+ * Response formats supported:
309
+ * 1) PayPal partner-referrals JSON: { links: [ { rel: "action_url", href: "..." }, ... ] }
310
+ * 2) Custom JSON: { onboarding_url: "..." }
311
+ * 3) Plain URL string
312
+ */
313
+ async createOnboardingLink(input?: { email?: string; products?: string[] }) {
314
+ const { onboarding } = await this.ensureSettingsDefaults()
315
+ const return_url = `${String(onboarding.backend_url || "").replace(/\/$/, "")}/admin/paypal/onboard-complete`
316
+ const env = await this.getCurrentEnvironment()
317
+ const partner_merchant_id = await this.getPartnerMerchantId(env)
318
+
319
+ // Match WooCommerce behavior: prefer the current admin user email when available.
320
+ // If it's missing, continue without it (some services can infer or ignore the field).
321
+ const email = (input?.email || "").trim()
322
+
323
+ if (!partner_merchant_id) {
324
+ throw new Error("Missing PAYPAL_PARTNER_MERCHANT_ID_* env for current environment")
325
+ }
326
+
327
+ // NOTE:
328
+ // We intentionally avoid DB access here because Medusa v2 has a known issue where
329
+ // MedusaService-generated DB methods can throw 'fork' errors when called outside a transaction context.
330
+ // We store onboarding state later when the JS callback returns authCode/sharedId.
331
+ const form = new URLSearchParams()
332
+ if (email) {
333
+ form.set("email", email)
334
+ }
335
+ form.set("sandbox", env === "live" ? "no" : "yes")
336
+ form.set("return_url", return_url)
337
+ form.set("return_url_description", "Return to your shop.")
338
+ form.set("partner_merchant_id", partner_merchant_id)
339
+
340
+ const products = input?.products?.length ? input.products : ["PPCP"]
341
+
342
+ // WooCommerce/wp_remote_request encodes PHP arrays like products[0]=PPCP.
343
+ // To maximize compatibility with your existing PHP bridge, we send BOTH:
344
+ // - products[0], products[1], ...
345
+ // - products[] (common PHP convention)
346
+ products.forEach((p, i) => {
347
+ form.append(`products[${i}]`, p)
348
+ form.append("products[]", p)
349
+ })
350
+
351
+ const res = await fetch(onboarding.partner_service_url, {
352
+ method: "POST",
353
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
354
+ body: form.toString(),
355
+ })
356
+
357
+ const text = await res.text().catch(() => "")
358
+ if (!res.ok) {
359
+ throw new Error(`Onboarding service failed (${res.status}): ${text}`)
360
+ }
361
+
362
+ const trimmed = text.trim()
363
+
364
+ // plain URL
365
+ if (trimmed.startsWith("http")) {
366
+ return { onboarding_url: trimmed, return_url }
367
+ }
368
+
369
+ let json: any
370
+ try {
371
+ json = JSON.parse(trimmed)
372
+ } catch {
373
+ throw new Error(`Invalid onboarding link response (not JSON / URL): ${trimmed.slice(0, 200)}`)
374
+ }
375
+
376
+ /**
377
+ * ✅ WooCommerce-style wrapper support
378
+ *
379
+ * Your PHP service (same as WooCommerce) often returns a wrapper like:
380
+ * { result, http_code, headers, body }
381
+ * where `body` is the actual PayPal partner-referrals JSON string.
382
+ */
383
+ if (json?.body) {
384
+ const inner = typeof json.body === "string" ? json.body.trim() : json.body
385
+
386
+ // body is a plain URL
387
+ if (typeof inner === "string" && inner.startsWith("http")) {
388
+ return { onboarding_url: inner, return_url }
389
+ }
390
+
391
+ // body is JSON string/object
392
+ try {
393
+ json = typeof inner === "string" ? JSON.parse(inner) : inner
394
+ } catch {
395
+ throw new Error(
396
+ `Onboarding wrapper JSON 'body' is not valid JSON / URL: ${
397
+ typeof inner === "string" ? inner.slice(0, 200) : "[object]"
398
+ }`
399
+ )
400
+ }
401
+ }
402
+
403
+ // ✅ If PayPal returned an error object, surface it clearly.
404
+ // Typical error shape: { name, message, debug_id, details: [...], links: [...] }
405
+ if (json?.name && json?.message && (json?.debug_id || json?.details || json?.links)) {
406
+ const debug = json.debug_id ? ` debug_id=${json.debug_id}` : ""
407
+ const details = Array.isArray(json.details)
408
+ ? json.details
409
+ .slice(0, 3)
410
+ .map((d: any) => {
411
+ const issue = d?.issue ? String(d.issue) : ""
412
+ const desc = d?.description ? String(d.description) : ""
413
+ const field = d?.field ? String(d.field) : ""
414
+ return [issue, desc, field].filter(Boolean).join(" | ")
415
+ })
416
+ .filter(Boolean)
417
+ .join("; ")
418
+ : ""
419
+
420
+ throw new Error(`PayPal onboarding error: ${json.name}: ${json.message}.${debug}${details ? ` Details: ${details}` : ""}`)
421
+ }
422
+
423
+ // custom json
424
+ if (json?.onboarding_url && String(json.onboarding_url).startsWith("http")) {
425
+ return { onboarding_url: String(json.onboarding_url), return_url }
426
+ }
427
+
428
+ // PayPal partner-referrals format: links[] rel=action_url
429
+ const links = Array.isArray(json?.links) ? json.links : null
430
+ if (links) {
431
+ const action = links.find(
432
+ (l: any) => l?.rel === "action_url" || l?.rel === "actionUrl" || l?.rel === "action-url"
433
+ )
434
+ const href = action?.href ? String(action.href) : null
435
+ if (href && href.startsWith("http")) {
436
+ return { onboarding_url: href, return_url }
437
+ }
438
+ }
439
+
440
+ throw new Error(
441
+ `Onboarding JSON missing action_url link. Keys: ${Object.keys(json || {}).join(", ")}`
442
+ )
443
+ }
444
+
445
+ async startOnboarding() {
446
+ const row = await this.getCurrentRow()
447
+ const env = await this.getCurrentEnvironment()
448
+
449
+ if (row) {
450
+ // MedusaService-generated update methods expect an object that includes the entity id
451
+ await this.updatePayPalConnections({ id: row.id, status: "pending" })
452
+ return
453
+ }
454
+
455
+ await this.createPayPalConnections({
456
+ environment: env,
457
+ status: "pending",
458
+ metadata: {},
459
+ })
460
+ }
461
+
462
+ async saveOnboardCallback(input: { authCode: string; sharedId: string }) {
463
+ const row = await this.getCurrentRow()
464
+ const env = await this.getCurrentEnvironment()
465
+
466
+ if (!row) {
467
+ return await this.createPayPalConnections({
468
+ environment: env,
469
+ status: "pending_credentials",
470
+ auth_code: input.authCode,
471
+ shared_id: input.sharedId,
472
+ metadata: {},
473
+ })
474
+ }
475
+
476
+ return await this.updatePayPalConnections({
477
+ id: row.id,
478
+ status: "pending_credentials",
479
+ auth_code: input.authCode,
480
+ shared_id: input.sharedId,
481
+ })
482
+ }
483
+
484
+ /**
485
+ * Exchange authCode/sharedId for seller API credentials (server-side) and save in DB.
486
+ *
487
+ * This calls an optional exchange service you control:
488
+ * PAYPAL_EXCHANGE_SERVICE_URL or PAYPAL_PARTNER_EXCHANGE_URL
489
+ *
490
+ * Expected JSON response:
491
+ * { clientId: string, clientSecret: string, merchantId?: string }
492
+ */
493
+ async exchangeAndSaveSellerCredentials(input: {
494
+ authCode: string
495
+ sharedId: string
496
+ env?: "sandbox" | "live"
497
+ }) {
498
+ // 1) Persist callback (sharedId/authCode) first
499
+ await this.saveOnboardCallback({ authCode: input.authCode, sharedId: input.sharedId })
500
+
501
+ // 2) Exchange authCode + sharedId (+ seller nonce) to get SELLER access token
502
+ const env = (input.env || (await this.getCurrentEnvironment())) as Environment
503
+ const baseUrl = env === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"
504
+
505
+ // IMPORTANT: code_verifier MUST match the seller_nonce used when creating the onboarding link.
506
+ // In WooCommerce you use a fixed nonce() and it works, so we do the same here (no DB storage).
507
+ const { onboarding } = await this.ensureSettingsDefaults()
508
+ const sellerNonce = (onboarding.seller_nonce || "").trim()
509
+ if (!sellerNonce) {
510
+ throw new Error("PayPal seller nonce is not configured. Set PAYPAL_SELLER_NONCE.")
511
+ }
512
+
513
+ const tokenBody = new URLSearchParams()
514
+ tokenBody.set("grant_type", "authorization_code")
515
+ tokenBody.set("code", input.authCode)
516
+ tokenBody.set("code_verifier", sellerNonce)
517
+
518
+ const basic = Buffer.from(`${input.sharedId}:`).toString("base64")
519
+
520
+ const tokenRes = await fetch(`${baseUrl}/v1/oauth2/token`, {
521
+ method: "POST",
522
+ headers: {
523
+ "Content-Type": "application/x-www-form-urlencoded",
524
+ Authorization: `Basic ${basic}`,
525
+ },
526
+ body: tokenBody,
527
+ })
528
+
529
+ const tokenText = await tokenRes.text().catch(() => "")
530
+ let tokenJson: any = {}
531
+ try {
532
+ tokenJson = tokenText ? JSON.parse(tokenText) : {}
533
+ } catch (e: any) {
534
+ console.warn("[PayPal] Failed to parse token response JSON:", e?.message)
535
+ }
536
+
537
+ if (!tokenRes.ok) {
538
+ throw new Error(
539
+ `PayPal authorization_code token exchange failed (${tokenRes.status}): ${tokenText || JSON.stringify(tokenJson)}`
540
+ )
541
+ }
542
+
543
+ const sellerAccessToken = String(tokenJson.access_token || "")
544
+ if (!sellerAccessToken) {
545
+ throw new Error("PayPal token exchange succeeded but access_token is missing.")
546
+ }
547
+
548
+ // 3) Use SELLER access token to fetch seller REST API credentials
549
+ const partnerMerchantId = await this.getPartnerMerchantId(env)
550
+ if (!partnerMerchantId) {
551
+ throw new Error("Missing PayPal partner merchant id configuration.")
552
+ }
553
+
554
+ const credRes = await fetch(
555
+ `${baseUrl}/v1/customer/partners/${encodeURIComponent(partnerMerchantId)}/merchant-integrations/credentials/`,
556
+ {
557
+ method: "GET",
558
+ headers: {
559
+ "Content-Type": "application/json",
560
+ Authorization: `Bearer ${sellerAccessToken}`,
561
+ ...(onboarding.bn_code ? { "PayPal-Partner-Attribution-Id": onboarding.bn_code } : {}),
562
+ },
563
+ }
564
+ )
565
+
566
+ const credText = await credRes.text().catch(() => "")
567
+ let credJson: any = {}
568
+ try {
569
+ credJson = credText ? JSON.parse(credText) : {}
570
+ } catch (e: any) {
571
+ console.warn("[PayPal] Failed to parse token response JSON:", e?.message)
572
+ }
573
+
574
+ if (!credRes.ok) {
575
+ throw new Error(
576
+ `PayPal credentials fetch failed (${credRes.status}): ${credText || JSON.stringify(credJson)}`
577
+ )
578
+ }
579
+
580
+ const clientId = String(credJson.client_id || "")
581
+ const clientSecret = String(credJson.client_secret || "")
582
+ if (!clientId || !clientSecret) {
583
+ throw new Error(
584
+ `PayPal credentials response missing client_id/client_secret. Keys: ${Object.keys(credJson || {}).join(", ")}`
585
+ )
586
+ }
587
+
588
+ // 4) Save seller credentials (marks status = connected)
589
+ await this.saveSellerCredentials({ clientId, clientSecret })
590
+
591
+ }
592
+
593
+
594
+ async saveSellerCredentials(input: { clientId: string; clientSecret: string }) {
595
+ const row = await this.getCurrentRow()
596
+ const env = await this.getCurrentEnvironment()
597
+
598
+ const encryptedSecret = this.maybeEncryptSecret(input.clientSecret)
599
+ const nextCreds = {
600
+ client_id: input.clientId,
601
+ client_secret: encryptedSecret,
602
+ }
603
+
604
+ if (!row) {
605
+ const created = await this.createPayPalConnections({
606
+ environment: env,
607
+ status: "connected",
608
+ seller_client_id: input.clientId,
609
+ seller_client_secret: encryptedSecret,
610
+ app_access_token: null,
611
+ app_access_token_expires_at: null,
612
+ metadata: {
613
+ credentials: {
614
+ [env]: nextCreds,
615
+ },
616
+ active_environment: env,
617
+ },
618
+ })
619
+ await this.recordAuditEvent("credentials_saved", {
620
+ environment: env,
621
+ client_id: input.clientId,
622
+ })
623
+ await this.ensureWebhookRegistration()
624
+ return created
625
+ }
626
+
627
+ const meta = (row.metadata || {}) as any
628
+ const creds = { ...(meta.credentials || {}) }
629
+ creds[env] = {
630
+ ...(creds[env] || {}),
631
+ ...nextCreds,
632
+ }
633
+
634
+ const updated = await this.updatePayPalConnections({
635
+ id: row.id,
636
+ status: "connected",
637
+ seller_client_id: input.clientId,
638
+ seller_client_secret: encryptedSecret,
639
+ app_access_token: null,
640
+ app_access_token_expires_at: null,
641
+ metadata: {
642
+ ...(row.metadata || {}),
643
+ credentials: creds,
644
+ active_environment: env,
645
+ },
646
+ })
647
+ await this.recordAuditEvent("credentials_saved", {
648
+ environment: env,
649
+ client_id: input.clientId,
650
+ })
651
+ await this.ensureWebhookRegistration()
652
+ return updated
653
+ }
654
+
655
+ private async resolveWebhookUrl() {
656
+ const { onboarding } = await this.ensureSettingsDefaults()
657
+ const base = String(onboarding.backend_url || "").replace(/\/$/, "")
658
+ if (!base) {
659
+ throw new Error("PayPal backend URL is not configured.")
660
+ }
661
+ return `${base}/store/paypal/webhook`
662
+ }
663
+
664
+ private isLocalWebhookUrl(url: string) {
665
+ try {
666
+ const parsed = new URL(url)
667
+ return ["localhost", "127.0.0.1", "::1"].includes(parsed.hostname)
668
+ } catch {
669
+ return false
670
+ }
671
+ }
672
+
673
+ private async ensureWebhookRegistration() {
674
+ const env = await this.getCurrentEnvironment()
675
+ const { apiDetails } = await this.ensureSettingsDefaults()
676
+ const webhookIds = { ...(apiDetails.webhook_ids || {}) } as Record<string, string>
677
+
678
+ if (webhookIds[env]) {
679
+ return webhookIds[env]
680
+ }
681
+
682
+ const accessToken = await this.getAppAccessToken()
683
+ const baseUrl = env === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"
684
+ const webhookUrl = await this.resolveWebhookUrl()
685
+
686
+ if (this.isLocalWebhookUrl(webhookUrl)) {
687
+ await this.recordAuditEvent("webhook_skipped_localhost", {
688
+ environment: env,
689
+ webhook_url: webhookUrl,
690
+ })
691
+ return webhookIds[env] || ""
692
+ }
693
+
694
+ const listResp = await fetch(`${baseUrl}/v1/notifications/webhooks`, {
695
+ headers: {
696
+ Authorization: `Bearer ${accessToken}`,
697
+ "Content-Type": "application/json",
698
+ },
699
+ })
700
+
701
+ const listJson = await listResp.json().catch(() => ({}))
702
+ if (!listResp.ok) {
703
+ throw new Error(`PayPal webhook list failed (${listResp.status}): ${JSON.stringify(listJson)}`)
704
+ }
705
+
706
+ const existing = Array.isArray(listJson?.webhooks)
707
+ ? listJson.webhooks.find((hook: any) => hook?.url === webhookUrl)
708
+ : null
709
+
710
+ let webhookId = existing?.id ? String(existing.id) : ""
711
+
712
+ if (!webhookId) {
713
+ const createResp = await fetch(`${baseUrl}/v1/notifications/webhooks`, {
714
+ method: "POST",
715
+ headers: {
716
+ Authorization: `Bearer ${accessToken}`,
717
+ "Content-Type": "application/json",
718
+ },
719
+ body: JSON.stringify({
720
+ url: webhookUrl,
721
+ event_types: [
722
+ { name: "CHECKOUT.ORDER.APPROVED" },
723
+ { name: "CHECKOUT.ORDER.CANCELLED" },
724
+ { name: "PAYMENT.CAPTURE.COMPLETED" },
725
+ { name: "PAYMENT.CAPTURE.DENIED" },
726
+ { name: "PAYMENT.CAPTURE.REFUNDED" },
727
+ { name: "PAYMENT.CAPTURE.REVERSED" },
728
+ { name: "PAYMENT.AUTHORIZATION.CREATED" },
729
+ { name: "PAYMENT.AUTHORIZATION.VOIDED" },
730
+ { name: "PAYMENT.AUTHORIZATION.DENIED" },
731
+ { name: "PAYMENT.REFUND.COMPLETED" },
732
+ { name: "PAYMENT.REFUND.DENIED" },
733
+ ],
734
+ }),
735
+ })
736
+
737
+ const createJson = await createResp.json().catch(() => ({}))
738
+ if (!createResp.ok) {
739
+ throw new Error(
740
+ `PayPal webhook create failed (${createResp.status}): ${JSON.stringify(createJson)}`
741
+ )
742
+ }
743
+
744
+ webhookId = String(createJson?.id || "")
745
+ }
746
+
747
+ if (!webhookId) {
748
+ throw new Error("PayPal webhook registration did not return an id")
749
+ }
750
+
751
+ const nextWebhookIds = { ...webhookIds, [env]: webhookId }
752
+ await this.saveSettings({
753
+ api_details: {
754
+ ...apiDetails,
755
+ webhook_ids: nextWebhookIds,
756
+ },
757
+ })
758
+
759
+ await this.recordAuditEvent("webhook_registered", {
760
+ environment: env,
761
+ webhook_id: webhookId,
762
+ webhook_url: webhookUrl,
763
+ })
764
+
765
+ return webhookId
766
+ }
767
+
768
+ private maskValue(value?: string | null, visibleChars = 4) {
769
+ if (!value) return null
770
+ const trimmed = String(value)
771
+ if (trimmed.length <= visibleChars) {
772
+ return "•".repeat(trimmed.length)
773
+ }
774
+ return `${"•".repeat(Math.max(0, trimmed.length - visibleChars))}${trimmed.slice(
775
+ -visibleChars
776
+ )}`
777
+ }
778
+
779
+ async rotateCredentialEncryptionKey() {
780
+ const currentKey = this.getEncryptionKey()
781
+ if (!currentKey) {
782
+ throw new Error("PAYPAL_CREDENTIALS_ENCRYPTION_KEY must be set to rotate credentials.")
783
+ }
784
+
785
+ const row = await this.getCurrentRow()
786
+ if (!row) {
787
+ return { rotated: 0 }
788
+ }
789
+
790
+ const meta = (row.metadata || {}) as any
791
+ const credentials = { ...(meta.credentials || {}) }
792
+ let rotated = 0
793
+
794
+ for (const [env, envCreds] of Object.entries(credentials)) {
795
+ if (!envCreds || typeof envCreds !== "object") continue
796
+ const clientSecret = (envCreds as any).client_secret
797
+ if (!clientSecret) continue
798
+
799
+ const decrypted = this.maybeDecryptSecret(clientSecret)
800
+ const reEncrypted = this.maybeEncryptSecret(decrypted)
801
+ if (reEncrypted !== clientSecret) {
802
+ credentials[env] = {
803
+ ...(envCreds as any),
804
+ client_secret: reEncrypted,
805
+ }
806
+ rotated += 1
807
+ }
808
+ }
809
+
810
+ if (rotated === 0) {
811
+ return { rotated: 0 }
812
+ }
813
+
814
+ await this.updatePayPalConnections({
815
+ id: row.id,
816
+ metadata: {
817
+ ...(row.metadata || {}),
818
+ credentials,
819
+ },
820
+ seller_client_secret: credentials?.[row.environment as Environment]?.client_secret || null,
821
+ })
822
+
823
+ const updated = await this.getCurrentRow()
824
+ if (updated) {
825
+ await this.syncRowFieldsFromMetadata(updated, (updated.environment as Environment) || "live")
826
+ }
827
+
828
+ await this.recordAuditEvent("credentials_rotated", {
829
+ environments: Object.keys(credentials),
830
+ })
831
+
832
+ return { rotated }
833
+ }
834
+
835
+ async getStatus(envOverride?: Environment) {
836
+ const row = await this.getCurrentRow()
837
+ const env = envOverride ?? (await this.getCurrentEnvironment())
838
+
839
+ if (!row) {
840
+ return { environment: env, status: "disconnected" as Status, seller_client_id_present: false }
841
+ }
842
+
843
+ const c = this.getEnvCreds(row, env)
844
+ const hasCreds = !!(c.clientId && c.clientSecret)
845
+ const sellerEmail: string | null = null
846
+
847
+ return {
848
+ environment: env,
849
+ status: (hasCreds ? "connected" : "disconnected") as Status,
850
+ shared_id: row.shared_id ?? null,
851
+ auth_code: row.auth_code ? "***stored***" : null,
852
+ seller_client_id_present: hasCreds,
853
+ seller_client_id_masked: this.maskValue(c.clientId),
854
+ seller_client_secret_masked: c.clientSecret ? "••••••••" : null,
855
+ seller_email: sellerEmail,
856
+ updated_at: (row.updated_at as any)?.toISOString?.() ?? null,
857
+ }
858
+ }
859
+
860
+ async disconnect() {
861
+ const row = await this.getCurrentRow()
862
+ if (!row) return
863
+ const env = await this.getCurrentEnvironment()
864
+
865
+ const meta = (row.metadata || {}) as any
866
+ const creds = { ...(meta.credentials || {}) }
867
+ // Remove only the active environment credentials
868
+ delete creds[env]
869
+
870
+ const hasAnyCreds = Object.values(creds).some((v: any) => {
871
+ return v && typeof v === "object" && (v as any).client_id && (v as any).client_secret
872
+ })
873
+
874
+ await this.updatePayPalConnections({
875
+ id: row.id,
876
+ status: hasAnyCreds ? "connected" : "disconnected",
877
+ shared_id: null,
878
+ auth_code: null,
879
+ seller_client_id: null,
880
+ seller_client_secret: null,
881
+ app_access_token: null,
882
+ app_access_token_expires_at: null,
883
+ metadata: {
884
+ ...(row.metadata || {}),
885
+ credentials: creds,
886
+ active_environment: env,
887
+ },
888
+ })
889
+ const updated = await this.getCurrentRow()
890
+ if (updated) {
891
+ await this.syncRowFieldsFromMetadata(updated, env)
892
+ }
893
+ await this.recordAuditEvent("disconnected", { environment: env })
894
+ }
895
+
896
+ async getAppAccessToken(): Promise<string> {
897
+ const row = await this.getCurrentRow()
898
+ const env = await this.getCurrentEnvironment()
899
+ const creds = await this.getActiveCredentials()
900
+
901
+ if (!row) {
902
+ throw new Error("PayPal connection row not found. Please complete onboarding.")
903
+ }
904
+
905
+ const expiresAt = row.app_access_token_expires_at ? new Date(row.app_access_token_expires_at as any) : null
906
+ if (row.app_access_token && expiresAt) {
907
+ const msLeft = expiresAt.getTime() - Date.now()
908
+ if (msLeft > 2 * 60 * 1000) return row.app_access_token
909
+ }
910
+
911
+ const baseUrl = env === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"
912
+ const basic = Buffer.from(`${creds.client_id}:${creds.client_secret}`).toString("base64")
913
+
914
+ const body = new URLSearchParams()
915
+ body.set("grant_type", "client_credentials")
916
+
917
+ const res = await fetch(`${baseUrl}/v1/oauth2/token`, {
918
+ method: "POST",
919
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: `Basic ${basic}` },
920
+ body,
921
+ })
922
+
923
+ const json = await res.json().catch(() => ({}))
924
+ if (!res.ok) throw new Error(`PayPal client_credentials failed (${res.status}): ${JSON.stringify(json)}`)
925
+
926
+ const accessToken = String(json.access_token)
927
+ const expiresIn = Number(json.expires_in || 3600)
928
+ const newExpiresAt = new Date(Date.now() + expiresIn * 1000)
929
+
930
+ await this.updatePayPalConnections({
931
+ id: row.id,
932
+ app_access_token: accessToken,
933
+ app_access_token_expires_at: newExpiresAt as any,
934
+ })
935
+
936
+ return accessToken
937
+ }
938
+
939
+ /**
940
+ * Generate a client token for PayPal JS SDK (required for CardFields/PaymentFields).
941
+ * This token is short-lived and safe to send to the browser.
942
+ *
943
+ * PayPal endpoint: POST /v1/identity/generate-token
944
+ */
945
+ async generateClientToken(opts?: { locale?: string }): Promise<string> {
946
+ const env = await this.getCurrentEnvironment()
947
+ const baseUrl = env === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"
948
+
949
+ const accessToken = await this.getAppAccessToken()
950
+
951
+ const res = await fetch(`${baseUrl}/v1/identity/generate-token`, {
952
+ method: "POST",
953
+ headers: {
954
+ Authorization: `Bearer ${accessToken}`,
955
+ "Content-Type": "application/json",
956
+ Accept: "application/json",
957
+ ...(opts?.locale ? { "Accept-Language": opts.locale } : {}),
958
+ ...(this.cfg.bnCode ? { "PayPal-Partner-Attribution-Id": this.cfg.bnCode } : {}),
959
+ },
960
+ })
961
+
962
+ const json = await res.json().catch(() => ({}))
963
+ if (!res.ok) {
964
+ throw new Error(`PayPal generate-token failed (${res.status}): ${JSON.stringify(json)}`)
965
+ }
966
+
967
+ const token = String((json as any)?.client_token || "")
968
+ if (!token) {
969
+ throw new Error("PayPal client_token is missing in generate-token response")
970
+ }
971
+
972
+ return token
973
+ }
974
+
975
+ /**
976
+ * GLOBAL PayPal settings (single row)
977
+ */
978
+ async getSettings() {
979
+ const rows = await this.listPayPalSettings({})
980
+ const row = rows?.[0]
981
+ return { data: (row?.data || {}) as Record<string, any> }
982
+ }
983
+
984
+ /**
985
+ * Deep-merge patch into current settings.
986
+ * Nested objects (additional_settings, api_details, etc.) are merged,
987
+ * not replaced.
988
+ */
989
+ private deepMerge(
990
+ target: Record<string, any>,
991
+ source: Record<string, any>
992
+ ): Record<string, any> {
993
+ const result = { ...target }
994
+ for (const key of Object.keys(source)) {
995
+ const sv = source[key]
996
+ const tv = target[key]
997
+ if (
998
+ sv !== null &&
999
+ typeof sv === "object" &&
1000
+ !Array.isArray(sv) &&
1001
+ tv !== null &&
1002
+ typeof tv === "object" &&
1003
+ !Array.isArray(tv)
1004
+ ) {
1005
+ result[key] = this.deepMerge(tv, sv)
1006
+ } else {
1007
+ result[key] = sv
1008
+ }
1009
+ }
1010
+ return result
1011
+ }
1012
+
1013
+ async saveSettings(patch: Record<string, any>) {
1014
+ const rows = await this.listPayPalSettings({})
1015
+ const row = rows?.[0]
1016
+ const current = (row?.data || {}) as Record<string, any>
1017
+
1018
+ const next = this.deepMerge(current, patch)
1019
+
1020
+ if (!row) {
1021
+ const created = await this.createPayPalSettings({ data: next })
1022
+ return { data: (created.data || {}) as Record<string, any> }
1023
+ }
1024
+
1025
+ await this.updatePayPalSettings({ id: row.id, data: next })
1026
+ return { data: next }
1027
+ }
1028
+
1029
+ /**
1030
+ * Active credentials based on selected environment in the single connection row.
1031
+ */
1032
+ async getActiveCredentials() {
1033
+ const row = await this.getCurrentRow()
1034
+ const env = await this.getCurrentEnvironment()
1035
+
1036
+ if (!row) {
1037
+ throw new Error("PayPal connection row not found. Please complete onboarding.")
1038
+ }
1039
+
1040
+ const c = this.getEnvCreds(row, env)
1041
+ const clientSecret = this.maybeDecryptSecret(c.clientSecret)
1042
+
1043
+ if (!c.clientId || !clientSecret) {
1044
+ throw new Error(
1045
+ `PayPal credentials missing for environment "${env}". Please save credentials.`
1046
+ )
1047
+ }
1048
+
1049
+ return {
1050
+ environment: env,
1051
+ client_id: c.clientId,
1052
+ client_secret: clientSecret,
1053
+ }
1054
+ }
1055
+
1056
+ async getOrderDetails(orderId: string) {
1057
+ if (!orderId) {
1058
+ throw new Error("PayPal orderId is required")
1059
+ }
1060
+
1061
+ const creds = await this.getActiveCredentials()
1062
+ const base =
1063
+ creds.environment === "live"
1064
+ ? "https://api-m.paypal.com"
1065
+ : "https://api-m.sandbox.paypal.com"
1066
+ const auth = Buffer.from(`${creds.client_id}:${creds.client_secret}`).toString("base64")
1067
+
1068
+ const tokenResp = await fetch(`${base}/v1/oauth2/token`, {
1069
+ method: "POST",
1070
+ headers: {
1071
+ Authorization: `Basic ${auth}`,
1072
+ "Content-Type": "application/x-www-form-urlencoded",
1073
+ },
1074
+ body: "grant_type=client_credentials",
1075
+ })
1076
+
1077
+ const tokenText = await tokenResp.text()
1078
+ if (!tokenResp.ok) {
1079
+ throw new Error(`PayPal token error (${tokenResp.status}): ${tokenText}`)
1080
+ }
1081
+
1082
+ const tokenJson = JSON.parse(tokenText)
1083
+ const accessToken = String(tokenJson.access_token)
1084
+
1085
+ const resp = await fetch(`${base}/v2/checkout/orders/${orderId}`, {
1086
+ method: "GET",
1087
+ headers: {
1088
+ Authorization: `Bearer ${accessToken}`,
1089
+ "Content-Type": "application/json",
1090
+ },
1091
+ })
1092
+
1093
+ const text = await resp.text()
1094
+ if (!resp.ok) {
1095
+ throw new Error(`PayPal get order error (${resp.status}): ${text}`)
1096
+ }
1097
+
1098
+ return JSON.parse(text)
1099
+ }
1100
+
1101
+ async createWebhookEventRecord(input: {
1102
+ event_id: string
1103
+ event_type: string
1104
+ resource_id?: string | null
1105
+ payload?: Record<string, unknown>
1106
+ event_version?: string | null
1107
+ transmission_id?: string | null
1108
+ transmission_time?: Date | null
1109
+ status?: string
1110
+ attempt_count?: number
1111
+ }) {
1112
+ try {
1113
+ const created = await this.createPayPalWebhookEvents({
1114
+ event_id: input.event_id,
1115
+ event_type: input.event_type,
1116
+ resource_id: input.resource_id ?? null,
1117
+ payload: input.payload ?? {},
1118
+ event_version: input.event_version ?? null,
1119
+ transmission_id: input.transmission_id ?? null,
1120
+ transmission_time: input.transmission_time ?? null,
1121
+ status: input.status ?? "pending",
1122
+ attempt_count: input.attempt_count ?? 0,
1123
+ next_retry_at: null,
1124
+ processed_at: null,
1125
+ last_error: null,
1126
+ })
1127
+ return { created: true, event: created }
1128
+ } catch (error: any) {
1129
+ const message = String(error?.message || "")
1130
+ if (message.includes("paypal_webhook_event_event_id_unique") || message.includes("unique")) {
1131
+ const existing = await this.listPayPalWebhookEvents({ event_id: input.event_id })
1132
+ return { created: false, event: existing?.[0] ?? null }
1133
+ }
1134
+ throw error
1135
+ }
1136
+ }
1137
+
1138
+ async updateWebhookEventRecord(input: {
1139
+ id: string
1140
+ status?: string
1141
+ attempt_count?: number
1142
+ next_retry_at?: Date | null
1143
+ processed_at?: Date | null
1144
+ last_error?: string | null
1145
+ resource_id?: string | null
1146
+ }) {
1147
+ return await this.updatePayPalWebhookEvents({
1148
+ id: input.id,
1149
+ status: input.status,
1150
+ attempt_count: input.attempt_count,
1151
+ next_retry_at: input.next_retry_at ?? null,
1152
+ processed_at: input.processed_at ?? null,
1153
+ last_error: input.last_error ?? null,
1154
+ resource_id: input.resource_id ?? null,
1155
+ })
1156
+ }
1157
+
1158
+ async recordAuditEvent(_eventType: string, _metadata?: Record<string, unknown>) {
1159
+ return null
1160
+ }
1161
+
1162
+
1163
+ async recordMetric(name: string, metadata?: Record<string, unknown>) {
1164
+ const existing = await this.listPayPalMetrics({ name })
1165
+ const row = existing?.[0]
1166
+ const current = (row?.data || {}) as Record<string, any>
1167
+ const next = {
1168
+ ...current,
1169
+ ...(metadata || {}),
1170
+ count: Number(current.count || 0) + 1,
1171
+ last_recorded_at: new Date().toISOString(),
1172
+ }
1173
+
1174
+ if (!row) {
1175
+ return await this.createPayPalMetrics({
1176
+ name,
1177
+ data: next,
1178
+ })
1179
+ }
1180
+
1181
+ return await this.updatePayPalMetrics({
1182
+ id: row.id,
1183
+ name,
1184
+ data: next,
1185
+ })
1186
+ }
1187
+
1188
+ async recordPaymentLog(eventType: string, metadata?: Record<string, unknown>) {
1189
+ const payload = {
1190
+ event_type: eventType,
1191
+ metadata: metadata ?? {},
1192
+ created_at: new Date().toISOString(),
1193
+ }
1194
+ console.info("[PayPal] payment_event", payload)
1195
+ return await this.recordAuditEvent(`payment_${eventType}`, metadata)
1196
+ }
1197
+
1198
+ async sendAlert(input: {
1199
+ type: string
1200
+ message: string
1201
+ metadata?: Record<string, unknown>
1202
+ }) {
1203
+ const urls = this.getAlertWebhookUrls()
1204
+ if (urls.length === 0) {
1205
+ return
1206
+ }
1207
+
1208
+ const payload = {
1209
+ type: input.type,
1210
+ message: input.message,
1211
+ metadata: input.metadata ?? {},
1212
+ source: "paypal",
1213
+ timestamp: new Date().toISOString(),
1214
+ }
1215
+
1216
+ await Promise.all(
1217
+ urls.map(async (url) => {
1218
+ try {
1219
+ const resp = await fetch(url, {
1220
+ method: "POST",
1221
+ headers: {
1222
+ "Content-Type": "application/json",
1223
+ },
1224
+ body: JSON.stringify(payload),
1225
+ })
1226
+ if (!resp.ok) {
1227
+ const text = await resp.text().catch(() => "")
1228
+ await this.recordAuditEvent("alert_failed", {
1229
+ url,
1230
+ status: resp.status,
1231
+ response: text,
1232
+ })
1233
+ } else {
1234
+ await this.recordAuditEvent("alert_sent", { url, type: input.type })
1235
+ }
1236
+ } catch (error: any) {
1237
+ await this.recordAuditEvent("alert_failed", {
1238
+ url,
1239
+ message: error?.message,
1240
+ })
1241
+ }
1242
+ })
1243
+ )
1244
+ }
1245
+ }
1246
+
1247
+ export default PayPalModuleService