@easypayment/medusa-payment-paypal 0.9.29 → 0.9.31

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 (117) hide show
  1. package/.medusa/server/src/admin/index.js +15 -13
  2. package/.medusa/server/src/admin/index.mjs +15 -13
  3. package/.medusa/server/src/api/admin/paypal/onboarding-link/route.d.ts.map +1 -1
  4. package/.medusa/server/src/api/admin/paypal/onboarding-link/route.js +24 -4
  5. package/.medusa/server/src/api/admin/paypal/onboarding-link/route.js.map +1 -1
  6. package/.medusa/server/src/api/store/paypal/capture-order/route.d.ts.map +1 -1
  7. package/.medusa/server/src/api/store/paypal/capture-order/route.js +4 -2
  8. package/.medusa/server/src/api/store/paypal/capture-order/route.js.map +1 -1
  9. package/.medusa/server/src/api/store/paypal/webhook/route.js +1 -1
  10. package/.medusa/server/src/api/store/paypal/webhook/route.js.map +1 -1
  11. package/.medusa/server/src/modules/paypal/migrations/20270301000000_add_webhook_transmission_id_index.d.ts +6 -0
  12. package/.medusa/server/src/modules/paypal/migrations/20270301000000_add_webhook_transmission_id_index.d.ts.map +1 -0
  13. package/.medusa/server/src/modules/paypal/migrations/20270301000000_add_webhook_transmission_id_index.js +14 -0
  14. package/.medusa/server/src/modules/paypal/migrations/20270301000000_add_webhook_transmission_id_index.js.map +1 -0
  15. package/.medusa/server/src/modules/paypal/payment-provider/card-service.d.ts +1 -1
  16. package/.medusa/server/src/modules/paypal/payment-provider/card-service.d.ts.map +1 -1
  17. package/.medusa/server/src/modules/paypal/payment-provider/card-service.js +9 -35
  18. package/.medusa/server/src/modules/paypal/payment-provider/card-service.js.map +1 -1
  19. package/.medusa/server/src/modules/paypal/payment-provider/service.d.ts +2 -5
  20. package/.medusa/server/src/modules/paypal/payment-provider/service.d.ts.map +1 -1
  21. package/.medusa/server/src/modules/paypal/payment-provider/service.js +14 -66
  22. package/.medusa/server/src/modules/paypal/payment-provider/service.js.map +1 -1
  23. package/.medusa/server/src/modules/paypal/service.d.ts +1 -1
  24. package/.medusa/server/src/modules/paypal/service.d.ts.map +1 -1
  25. package/.medusa/server/src/modules/paypal/service.js +41 -22
  26. package/.medusa/server/src/modules/paypal/service.js.map +1 -1
  27. package/.medusa/server/src/modules/paypal/utils/amounts.d.ts +1 -1
  28. package/.medusa/server/src/modules/paypal/utils/amounts.d.ts.map +1 -1
  29. package/.medusa/server/src/modules/paypal/utils/amounts.js +17 -6
  30. package/.medusa/server/src/modules/paypal/utils/amounts.js.map +1 -1
  31. package/.medusa/server/src/modules/paypal/utils/credential-resolver.d.ts +42 -0
  32. package/.medusa/server/src/modules/paypal/utils/credential-resolver.d.ts.map +1 -0
  33. package/.medusa/server/src/modules/paypal/utils/credential-resolver.js +186 -0
  34. package/.medusa/server/src/modules/paypal/utils/credential-resolver.js.map +1 -0
  35. package/.medusa/server/src/modules/paypal/utils/payment-session.d.ts.map +1 -1
  36. package/.medusa/server/src/modules/paypal/utils/payment-session.js +7 -1
  37. package/.medusa/server/src/modules/paypal/utils/payment-session.js.map +1 -1
  38. package/.medusa/server/src/modules/paypal/utils/paypal-fetch.d.ts +3 -11
  39. package/.medusa/server/src/modules/paypal/utils/paypal-fetch.d.ts.map +1 -1
  40. package/.medusa/server/src/modules/paypal/utils/paypal-fetch.js +119 -18
  41. package/.medusa/server/src/modules/paypal/utils/paypal-fetch.js.map +1 -1
  42. package/.medusa/server/src/modules/paypal/utils/secret-crypto.d.ts.map +1 -1
  43. package/.medusa/server/src/modules/paypal/utils/secret-crypto.js +6 -0
  44. package/.medusa/server/src/modules/paypal/utils/secret-crypto.js.map +1 -1
  45. package/.medusa/server/src/modules/paypal/webhook-processor.d.ts.map +1 -1
  46. package/.medusa/server/src/modules/paypal/webhook-processor.js +10 -9
  47. package/.medusa/server/src/modules/paypal/webhook-processor.js.map +1 -1
  48. package/package.json +15 -8
  49. package/src/admin/routes/settings/paypal/connection/page.tsx +6 -4
  50. package/src/api/admin/paypal/onboarding-link/route.ts +30 -5
  51. package/src/api/store/paypal/capture-order/route.ts +4 -2
  52. package/src/api/store/paypal/webhook/route.ts +1 -1
  53. package/src/modules/paypal/migrations/20270301000000_add_webhook_transmission_id_index.ts +15 -0
  54. package/src/modules/paypal/payment-provider/card-service.ts +9 -40
  55. package/src/modules/paypal/payment-provider/service.ts +18 -68
  56. package/src/modules/paypal/service.ts +43 -23
  57. package/src/modules/paypal/utils/amounts.ts +17 -6
  58. package/src/modules/paypal/utils/credential-resolver.ts +257 -0
  59. package/src/modules/paypal/utils/payment-session.ts +11 -2
  60. package/src/modules/paypal/utils/paypal-fetch.ts +139 -17
  61. package/src/modules/paypal/utils/secret-crypto.ts +5 -0
  62. package/src/modules/paypal/webhook-processor.ts +15 -10
  63. package/.medusa/server/src/admin/index.d.ts +0 -2
  64. package/.medusa/server/src/admin/index.d.ts.map +0 -1
  65. package/.medusa/server/src/admin/index.js.map +0 -1
  66. package/.medusa/server/src/admin/routes/settings/paypal/_components/FieldRow.d.ts +0 -8
  67. package/.medusa/server/src/admin/routes/settings/paypal/_components/FieldRow.d.ts.map +0 -1
  68. package/.medusa/server/src/admin/routes/settings/paypal/_components/FieldRow.js +0 -8
  69. package/.medusa/server/src/admin/routes/settings/paypal/_components/FieldRow.js.map +0 -1
  70. package/.medusa/server/src/admin/routes/settings/paypal/_components/SectionCard.d.ts +0 -8
  71. package/.medusa/server/src/admin/routes/settings/paypal/_components/SectionCard.d.ts.map +0 -1
  72. package/.medusa/server/src/admin/routes/settings/paypal/_components/SectionCard.js +0 -8
  73. package/.medusa/server/src/admin/routes/settings/paypal/_components/SectionCard.js.map +0 -1
  74. package/.medusa/server/src/admin/routes/settings/paypal/_components/Tabs.d.ts +0 -2
  75. package/.medusa/server/src/admin/routes/settings/paypal/_components/Tabs.d.ts.map +0 -1
  76. package/.medusa/server/src/admin/routes/settings/paypal/_components/Tabs.js +0 -25
  77. package/.medusa/server/src/admin/routes/settings/paypal/_components/Tabs.js.map +0 -1
  78. package/.medusa/server/src/admin/routes/settings/paypal/_components/Toast.d.ts +0 -12
  79. package/.medusa/server/src/admin/routes/settings/paypal/_components/Toast.d.ts.map +0 -1
  80. package/.medusa/server/src/admin/routes/settings/paypal/_components/Toast.js +0 -22
  81. package/.medusa/server/src/admin/routes/settings/paypal/_components/Toast.js.map +0 -1
  82. package/.medusa/server/src/admin/routes/settings/paypal/_utils/adminFetch.d.ts +0 -7
  83. package/.medusa/server/src/admin/routes/settings/paypal/_utils/adminFetch.d.ts.map +0 -1
  84. package/.medusa/server/src/admin/routes/settings/paypal/_utils/adminFetch.js +0 -40
  85. package/.medusa/server/src/admin/routes/settings/paypal/_utils/adminFetch.js.map +0 -1
  86. package/.medusa/server/src/admin/routes/settings/paypal/additional-settings/page.d.ts +0 -2
  87. package/.medusa/server/src/admin/routes/settings/paypal/additional-settings/page.d.ts.map +0 -1
  88. package/.medusa/server/src/admin/routes/settings/paypal/additional-settings/page.js +0 -76
  89. package/.medusa/server/src/admin/routes/settings/paypal/additional-settings/page.js.map +0 -1
  90. package/.medusa/server/src/admin/routes/settings/paypal/advanced-card-payments/page.d.ts +0 -2
  91. package/.medusa/server/src/admin/routes/settings/paypal/advanced-card-payments/page.d.ts.map +0 -1
  92. package/.medusa/server/src/admin/routes/settings/paypal/advanced-card-payments/page.js +0 -81
  93. package/.medusa/server/src/admin/routes/settings/paypal/advanced-card-payments/page.js.map +0 -1
  94. package/.medusa/server/src/admin/routes/settings/paypal/apple-pay/page.d.ts +0 -2
  95. package/.medusa/server/src/admin/routes/settings/paypal/apple-pay/page.d.ts.map +0 -1
  96. package/.medusa/server/src/admin/routes/settings/paypal/apple-pay/page.js +0 -9
  97. package/.medusa/server/src/admin/routes/settings/paypal/apple-pay/page.js.map +0 -1
  98. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts +0 -22
  99. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts.map +0 -1
  100. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js +0 -595
  101. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js.map +0 -1
  102. package/.medusa/server/src/admin/routes/settings/paypal/google-pay/page.d.ts +0 -2
  103. package/.medusa/server/src/admin/routes/settings/paypal/google-pay/page.d.ts.map +0 -1
  104. package/.medusa/server/src/admin/routes/settings/paypal/google-pay/page.js +0 -9
  105. package/.medusa/server/src/admin/routes/settings/paypal/google-pay/page.js.map +0 -1
  106. package/.medusa/server/src/admin/routes/settings/paypal/page.d.ts +0 -4
  107. package/.medusa/server/src/admin/routes/settings/paypal/page.d.ts.map +0 -1
  108. package/.medusa/server/src/admin/routes/settings/paypal/page.js +0 -14
  109. package/.medusa/server/src/admin/routes/settings/paypal/page.js.map +0 -1
  110. package/.medusa/server/src/admin/routes/settings/paypal/pay-later-messaging/page.d.ts +0 -2
  111. package/.medusa/server/src/admin/routes/settings/paypal/pay-later-messaging/page.d.ts.map +0 -1
  112. package/.medusa/server/src/admin/routes/settings/paypal/pay-later-messaging/page.js +0 -9
  113. package/.medusa/server/src/admin/routes/settings/paypal/pay-later-messaging/page.js.map +0 -1
  114. package/.medusa/server/src/admin/routes/settings/paypal/paypal-settings/page.d.ts +0 -2
  115. package/.medusa/server/src/admin/routes/settings/paypal/paypal-settings/page.d.ts.map +0 -1
  116. package/.medusa/server/src/admin/routes/settings/paypal/paypal-settings/page.js +0 -109
  117. package/.medusa/server/src/admin/routes/settings/paypal/paypal-settings/page.js.map +0 -1
@@ -0,0 +1,257 @@
1
+ import { paypalFetch } from "./paypal-fetch"
2
+ import { decryptSecret, encryptSecret } from "./secret-crypto"
3
+
4
+ const BN_CODE = "MBJTechnolabs_SI_SPB"
5
+ const TOKEN_MARGIN_MS = 2 * 60 * 1000
6
+
7
+ type Environment = "sandbox" | "live"
8
+
9
+ interface ActiveCredentials {
10
+ environment: Environment
11
+ client_id: string
12
+ client_secret: string
13
+ }
14
+
15
+ interface AccessTokenResult {
16
+ accessToken: string
17
+ base: string
18
+ }
19
+
20
+ export interface ResolvedSettings {
21
+ additionalSettings: Record<string, unknown>
22
+ advancedCardSettings: Record<string, unknown>
23
+ apiDetails: Record<string, unknown>
24
+ }
25
+
26
+ /**
27
+ * Self-contained credential and token management for PayPal payment providers.
28
+ *
29
+ * Payment providers in Medusa v2 run inside the payment module's scoped
30
+ * container, which is isolated from the application container where the
31
+ * PayPal module service lives. This resolver bypasses the container boundary
32
+ * entirely by reading credentials directly from the database via pgConnection
33
+ * (knex), which Medusa injects into every module container.
34
+ *
35
+ * Same source of truth (paypal_connection / paypal_settings tables), zero
36
+ * cross-container dependencies.
37
+ */
38
+ export class PayPalCredentialResolver {
39
+ private db: any
40
+ private tokenRefreshPromise: Promise<string> | null = null
41
+
42
+ constructor(pgConnection: any) {
43
+ this.db = pgConnection
44
+ }
45
+
46
+ private async getConnectionRow(): Promise<any | null> {
47
+ const rows = await this.db("paypal_connection")
48
+ .select("*")
49
+ .orderBy("created_at", "desc")
50
+ .limit(1)
51
+
52
+ return rows?.[0] ?? null
53
+ }
54
+
55
+ private getEnvCreds(
56
+ row: any,
57
+ env: Environment
58
+ ): { clientId?: string; clientSecret?: string } {
59
+ const meta = (row?.metadata || {}) as any
60
+ const creds = meta?.credentials?.[env] || {}
61
+ return {
62
+ clientId: creds.client_id || creds.clientId || undefined,
63
+ clientSecret:
64
+ decryptSecret(creds.client_secret || creds.clientSecret) || undefined,
65
+ }
66
+ }
67
+
68
+ async getActiveCredentials(): Promise<ActiveCredentials> {
69
+ const row = await this.getConnectionRow()
70
+ if (!row) {
71
+ throw new Error(
72
+ "PayPal connection not found. Please complete onboarding in the admin panel."
73
+ )
74
+ }
75
+
76
+ const env: Environment =
77
+ (row.environment as Environment) === "sandbox" ? "sandbox" : "live"
78
+ const c = this.getEnvCreds(row, env)
79
+
80
+ if (!c.clientId || !c.clientSecret) {
81
+ throw new Error(
82
+ `PayPal credentials missing for environment "${env}". Please save credentials in the admin panel.`
83
+ )
84
+ }
85
+
86
+ return {
87
+ environment: env,
88
+ client_id: c.clientId,
89
+ client_secret: c.clientSecret,
90
+ }
91
+ }
92
+
93
+ async getAccessToken(): Promise<AccessTokenResult> {
94
+ const creds = await this.getActiveCredentials()
95
+ const base =
96
+ creds.environment === "live"
97
+ ? "https://api-m.paypal.com"
98
+ : "https://api-m.sandbox.paypal.com"
99
+
100
+ const row = await this.getConnectionRow()
101
+ if (row?.app_access_token && row.app_access_token_expires_at) {
102
+ const expiresAt = new Date(row.app_access_token_expires_at)
103
+ if (expiresAt.getTime() - Date.now() > TOKEN_MARGIN_MS) {
104
+ return {
105
+ accessToken: decryptSecret(row.app_access_token) as string,
106
+ base,
107
+ }
108
+ }
109
+ }
110
+
111
+ if (this.tokenRefreshPromise) {
112
+ const accessToken = await this.tokenRefreshPromise
113
+ return { accessToken, base }
114
+ }
115
+
116
+ this.tokenRefreshPromise = this.refreshAccessToken(row, creds).finally(
117
+ () => {
118
+ this.tokenRefreshPromise = null
119
+ }
120
+ )
121
+
122
+ const accessToken = await this.tokenRefreshPromise
123
+ return { accessToken, base }
124
+ }
125
+
126
+ private async refreshAccessToken(
127
+ row: any,
128
+ creds: ActiveCredentials
129
+ ): Promise<string> {
130
+ const base =
131
+ creds.environment === "live"
132
+ ? "https://api-m.paypal.com"
133
+ : "https://api-m.sandbox.paypal.com"
134
+ const basic = Buffer.from(
135
+ `${creds.client_id}:${creds.client_secret}`
136
+ ).toString("base64")
137
+
138
+ const body = new URLSearchParams()
139
+ body.set("grant_type", "client_credentials")
140
+
141
+ const res = await paypalFetch(`${base}/v1/oauth2/token`, {
142
+ method: "POST",
143
+ headers: {
144
+ "Content-Type": "application/x-www-form-urlencoded",
145
+ Authorization: `Basic ${basic}`,
146
+ "PayPal-Partner-Attribution-Id": BN_CODE,
147
+ },
148
+ body,
149
+ signal: AbortSignal.timeout(30_000),
150
+ })
151
+
152
+ const json = await res.json().catch(() => ({}))
153
+ if (!res.ok) {
154
+ throw new Error(
155
+ `PayPal client_credentials failed (${res.status}): ${JSON.stringify(json)}`
156
+ )
157
+ }
158
+
159
+ const accessToken = String(json.access_token || "")
160
+ if (!accessToken) {
161
+ throw new Error(
162
+ "PayPal client_credentials succeeded but access_token is missing."
163
+ )
164
+ }
165
+
166
+ const expiresIn = Number(json.expires_in || 3600)
167
+ const newExpiresAt = new Date(Date.now() + expiresIn * 1000)
168
+
169
+ if (row?.id) {
170
+ await this.db("paypal_connection").where("id", row.id).update({
171
+ app_access_token: encryptSecret(accessToken),
172
+ app_access_token_expires_at: newExpiresAt,
173
+ updated_at: new Date(),
174
+ })
175
+ }
176
+
177
+ return accessToken
178
+ }
179
+
180
+ async getSettings(): Promise<ResolvedSettings> {
181
+ try {
182
+ const rows = await this.db("paypal_settings")
183
+ .select("data")
184
+ .limit(1)
185
+
186
+ const data = (rows?.[0]?.data || {}) as Record<string, any>
187
+ return {
188
+ additionalSettings: (data.additional_settings || {}) as Record<
189
+ string,
190
+ unknown
191
+ >,
192
+ advancedCardSettings: (data.advanced_card_payments || {}) as Record<
193
+ string,
194
+ unknown
195
+ >,
196
+ apiDetails: (data.api_details || {}) as Record<string, unknown>,
197
+ }
198
+ } catch {
199
+ return {
200
+ additionalSettings: {},
201
+ advancedCardSettings: {},
202
+ apiDetails: {},
203
+ }
204
+ }
205
+ }
206
+
207
+ async recordAuditEvent(
208
+ eventType: string,
209
+ metadata?: Record<string, unknown>
210
+ ) {
211
+ try {
212
+ console.info(
213
+ JSON.stringify({
214
+ log: "paypal_audit",
215
+ event_type: eventType,
216
+ at: new Date().toISOString(),
217
+ ...(metadata ? { metadata } : {}),
218
+ })
219
+ )
220
+ } catch {
221
+ // audit logging must never break the payment flow
222
+ }
223
+ }
224
+
225
+ async recordMetric(name: string, metadata?: Record<string, unknown>) {
226
+ try {
227
+ const rows = await this.db("paypal_metric")
228
+ .select("*")
229
+ .where("name", name)
230
+ .limit(1)
231
+ const row = rows?.[0]
232
+ const current = (row?.data || {}) as Record<string, any>
233
+ const next = {
234
+ ...current,
235
+ ...(metadata || {}),
236
+ count: Number(current.count || 0) + 1,
237
+ last_recorded_at: new Date().toISOString(),
238
+ }
239
+
240
+ if (row) {
241
+ await this.db("paypal_metric")
242
+ .where("id", row.id)
243
+ .update({ data: JSON.stringify(next), updated_at: new Date() })
244
+ } else {
245
+ await this.db("paypal_metric").insert({
246
+ id: `ppmet_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`,
247
+ name,
248
+ data: JSON.stringify(next),
249
+ created_at: new Date(),
250
+ updated_at: new Date(),
251
+ })
252
+ }
253
+ } catch {
254
+ // metrics must never break the payment flow
255
+ }
256
+ }
257
+ }
@@ -98,7 +98,16 @@ export async function updatePayPalSessionData(
98
98
  amount: existing?.amount,
99
99
  currency_code: existing?.currency_code,
100
100
  })
101
- } catch (e: any) {
102
- console.error("[PayPal] updatePayPalSessionData failed:", e?.message)
101
+ } catch (e: unknown) {
102
+ const msg = e instanceof Error ? e.message : String(e)
103
+ console.error("[PayPal] updatePayPalSessionData failed:", msg)
104
+ const hasCaptureData = "capture_id" in extraData || "order_id" in extraData
105
+ if (hasCaptureData) {
106
+ console.error(
107
+ "[PayPal] CRITICAL: Payment data (capture/order) was NOT persisted to session.",
108
+ { sessionId, keys: Object.keys(extraData) }
109
+ )
110
+ }
111
+ throw e
103
112
  }
104
113
  }
@@ -1,30 +1,152 @@
1
1
  /**
2
- * Default timeout (ms) for all server-side outbound PayPal HTTP calls.
3
- *
4
- * Without a timeout, a slow or hung PayPal socket holds a Node request handler
5
- * (and any DB connection it owns) open indefinitely; under load these
6
- * accumulate and exhaust the event loop / connection pool, cascading into
7
- * checkout-wide failures. Override with PAYPAL_HTTP_TIMEOUT_MS.
2
+ * Outbound HTTP for PayPal API calls with timeout, circuit breaker, and
3
+ * optional retry for critical payment operations.
8
4
  */
5
+
9
6
  export const PAYPAL_HTTP_TIMEOUT_MS = (() => {
10
7
  const v = Number(process.env.PAYPAL_HTTP_TIMEOUT_MS)
11
8
  return Number.isFinite(v) && v > 0 ? v : 30_000
12
9
  })()
13
10
 
14
- /**
15
- * `fetch` that always enforces a timeout for PayPal / outbound calls. If the
16
- * caller already supplies an AbortSignal, it is respected as-is (the caller is
17
- * assumed to manage its own deadline).
18
- */
11
+ // ---------------------------------------------------------------------------
12
+ // Circuit breaker
13
+ // ---------------------------------------------------------------------------
14
+
15
+ const CB_FAILURE_THRESHOLD = 5
16
+ const CB_WINDOW_MS = 30_000
17
+ const CB_HALF_OPEN_MS = 60_000
18
+
19
+ type CBState = "closed" | "open" | "half-open"
20
+
21
+ let cbState: CBState = "closed"
22
+ let cbFailures: number[] = []
23
+ let cbOpenedAt = 0
24
+ let cbHalfOpenProbeInFlight = false
25
+
26
+ function cbRecordSuccess() {
27
+ cbState = "closed"
28
+ cbFailures = []
29
+ cbHalfOpenProbeInFlight = false
30
+ }
31
+
32
+ function cbRecordFailure() {
33
+ cbHalfOpenProbeInFlight = false
34
+ const now = Date.now()
35
+ cbFailures = cbFailures.filter((t) => now - t < CB_WINDOW_MS)
36
+ cbFailures.push(now)
37
+ if (cbFailures.length >= CB_FAILURE_THRESHOLD) {
38
+ cbState = "open"
39
+ cbOpenedAt = now
40
+ console.warn(
41
+ `[PayPal] circuit breaker OPEN after ${CB_FAILURE_THRESHOLD} failures in ${CB_WINDOW_MS / 1000}s`
42
+ )
43
+ }
44
+ }
45
+
46
+ function cbAllowRequest(): boolean {
47
+ if (cbState === "closed") return true
48
+ if (cbState === "open") {
49
+ if (Date.now() - cbOpenedAt >= CB_HALF_OPEN_MS) {
50
+ cbState = "half-open"
51
+ cbHalfOpenProbeInFlight = true
52
+ return true
53
+ }
54
+ return false
55
+ }
56
+ // half-open: allow only one probe at a time
57
+ if (cbHalfOpenProbeInFlight) return false
58
+ cbHalfOpenProbeInFlight = true
59
+ return true
60
+ }
61
+
62
+ class CircuitOpenError extends Error {
63
+ constructor() {
64
+ super(
65
+ "PayPal circuit breaker is open — API calls are temporarily blocked after repeated failures. Will retry automatically."
66
+ )
67
+ this.name = "CircuitOpenError"
68
+ }
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Core fetch with timeout + circuit breaker
73
+ // ---------------------------------------------------------------------------
74
+
19
75
  export function paypalFetch(
20
76
  input: string | URL,
21
77
  init: RequestInit = {}
22
78
  ): Promise<Response> {
23
- if (init.signal) {
24
- return fetch(input, init)
79
+ if (!cbAllowRequest()) {
80
+ return Promise.reject(new CircuitOpenError())
81
+ }
82
+
83
+ const fetchPromise = init.signal
84
+ ? fetch(input, init)
85
+ : fetch(input, {
86
+ ...init,
87
+ signal: AbortSignal.timeout(PAYPAL_HTTP_TIMEOUT_MS),
88
+ })
89
+
90
+ return fetchPromise.then(
91
+ (res) => {
92
+ if (res.status >= 500) {
93
+ cbRecordFailure()
94
+ } else {
95
+ cbRecordSuccess()
96
+ }
97
+ return res
98
+ },
99
+ (err) => {
100
+ cbRecordFailure()
101
+ throw err
102
+ }
103
+ )
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Retry wrapper for critical payment operations (capture, refund)
108
+ // ---------------------------------------------------------------------------
109
+
110
+ const MAX_RETRIES = 2
111
+ const BASE_DELAY_MS = 500
112
+
113
+ function jitteredDelay(attempt: number): number {
114
+ const base = BASE_DELAY_MS * Math.pow(2, attempt)
115
+ return base + Math.random() * base
116
+ }
117
+
118
+ function isRetryable(err: unknown): boolean {
119
+ if (err instanceof CircuitOpenError) return true
120
+ if (err instanceof Error) {
121
+ if (err.name === "TimeoutError" || err.name === "AbortError") return true
122
+ }
123
+ return false
124
+ }
125
+
126
+ function isRetryableStatus(status: number): boolean {
127
+ return status >= 500 || status === 429
128
+ }
129
+
130
+ export async function paypalFetchWithRetry(
131
+ input: string | URL,
132
+ init: RequestInit = {}
133
+ ): Promise<Response> {
134
+ let lastError: unknown
135
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
136
+ try {
137
+ const res = await paypalFetch(input, init)
138
+ if (isRetryableStatus(res.status) && attempt < MAX_RETRIES) {
139
+ const delay = jitteredDelay(attempt)
140
+ await new Promise((r) => setTimeout(r, delay))
141
+ continue
142
+ }
143
+ return res
144
+ } catch (err) {
145
+ lastError = err
146
+ if (!isRetryable(err) || attempt >= MAX_RETRIES) throw err
147
+ const delay = jitteredDelay(attempt)
148
+ await new Promise((r) => setTimeout(r, delay))
149
+ }
25
150
  }
26
- return fetch(input, {
27
- ...init,
28
- signal: AbortSignal.timeout(PAYPAL_HTTP_TIMEOUT_MS),
29
- })
151
+ throw lastError
30
152
  }
@@ -36,6 +36,7 @@ const SALT_LEN = 16
36
36
  let cachedRaw: string | undefined
37
37
  let cachedRawKey: string | null = null
38
38
  let legacyV1Key: Buffer | null = null
39
+ const V2_KEY_CACHE_MAX = 100
39
40
  const v2KeyCache = new Map<string, Buffer>()
40
41
 
41
42
  /**
@@ -70,6 +71,10 @@ function deriveV2Key(raw: string, salt: Buffer): Buffer {
70
71
  r: SCRYPT_R,
71
72
  p: SCRYPT_P,
72
73
  })
74
+ if (v2KeyCache.size >= V2_KEY_CACHE_MAX) {
75
+ const oldest = v2KeyCache.keys().next().value
76
+ if (oldest !== undefined) v2KeyCache.delete(oldest)
77
+ }
73
78
  v2KeyCache.set(cacheKey, key)
74
79
  }
75
80
  return key
@@ -183,7 +183,7 @@ async function findPayPalSession(
183
183
 
184
184
  const sessions = await paymentModule.listPaymentSessions({
185
185
  payment_collection_id: collection.id,
186
- })
186
+ }, { take: 50 })
187
187
 
188
188
  const paypalSession = (sessions || [])
189
189
  .filter((s: any) => isPayPalProviderId(s.provider_id))
@@ -300,21 +300,18 @@ export async function processPayPalWebhookEvent(
300
300
  if (!cartId && (orderId || captureId)) {
301
301
  try {
302
302
  const paymentModule = container.resolve(Modules.PAYMENT) as any
303
- // Fallback when the webhook resource carries no custom_id: locate the
304
- // PayPal session whose stored data matches this order_id / capture_id.
305
- // Paginate to exhaustion rather than scanning a single fixed page — on a
306
- // busy store the matching session is frequently beyond the first page, so
307
- // a hard cap would silently drop capture/refund status updates.
308
303
  const PAGE_SIZE = 200
309
- const MAX_PAGES = 100 // safety bound (20k sessions) against a misbehaving driver
304
+ const MAX_PAGES = 5
310
305
  let matchedSession: Record<string, unknown> | null = null
306
+ let totalScanned = 0
311
307
 
312
308
  for (let page = 0; page < MAX_PAGES && !matchedSession; page++) {
313
309
  const sessions = await paymentModule.listPaymentSessions(
314
310
  { provider_id: [...PAYPAL_PROVIDER_IDS] },
315
- { take: PAGE_SIZE, skip: page * PAGE_SIZE }
311
+ { take: PAGE_SIZE, skip: page * PAGE_SIZE, order: { created_at: "DESC" } }
316
312
  )
317
313
  if (!sessions || sessions.length === 0) break
314
+ totalScanned += sessions.length
318
315
  matchedSession =
319
316
  sessions.find((s: Record<string, unknown>) => {
320
317
  const pp =
@@ -326,6 +323,14 @@ export async function processPayPalWebhookEvent(
326
323
  if (sessions.length < PAGE_SIZE) break
327
324
  }
328
325
 
326
+ if (!matchedSession && totalScanned >= MAX_PAGES * PAGE_SIZE) {
327
+ console.warn(
328
+ `[PayPal] webhook: cartId fallback scan hit limit (${totalScanned} sessions). ` +
329
+ `Event ${input.eventType} may be lost. Ensure custom_id is set on PayPal orders.`,
330
+ { orderId, captureId }
331
+ )
332
+ }
333
+
329
334
  if (matchedSession?.payment_collection_id) {
330
335
  const colls = await paymentModule.listPaymentCollections(
331
336
  { id: [matchedSession.payment_collection_id] },
@@ -333,10 +338,10 @@ export async function processPayPalWebhookEvent(
333
338
  )
334
339
  cartId = String(colls?.[0]?.cart_id || "").trim() || null
335
340
  }
336
- } catch (e: any) {
341
+ } catch (e: unknown) {
337
342
  console.warn(
338
343
  `[PayPal] webhook: cartId fallback lookup failed for ${input.eventType}:`,
339
- e?.message
344
+ e instanceof Error ? e.message : e
340
345
  )
341
346
  }
342
347
  }
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=index.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/admin/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAA"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/admin/index.ts"],"names":[],"mappings":""}
@@ -1,8 +0,0 @@
1
- import React from "react";
2
- export default function FieldRow({ label, hint, htmlFor, children, }: {
3
- label: string;
4
- hint?: React.ReactNode;
5
- htmlFor?: string;
6
- children: React.ReactNode;
7
- }): import("react/jsx-runtime").JSX.Element;
8
- //# sourceMappingURL=FieldRow.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"FieldRow.d.ts","sourceRoot":"","sources":["../../../../../../../../src/admin/routes/settings/paypal/_components/FieldRow.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAEzB,MAAM,CAAC,OAAO,UAAU,QAAQ,CAAC,EAC/B,KAAK,EACL,IAAI,EACJ,OAAO,EACP,QAAQ,GACT,EAAE;IACD,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;IACtB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;CAC1B,2CAgBA"}
@@ -1,8 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.default = FieldRow;
4
- const jsx_runtime_1 = require("react/jsx-runtime");
5
- function FieldRow({ label, hint, htmlFor, children, }) {
6
- return ((0, jsx_runtime_1.jsxs)("div", { className: "grid grid-cols-12 items-start gap-4 py-3", children: [(0, jsx_runtime_1.jsxs)("div", { className: "col-span-12 md:col-span-4", children: [htmlFor ? ((0, jsx_runtime_1.jsx)("label", { htmlFor: htmlFor, className: "text-sm font-medium text-ui-fg-base", children: label })) : ((0, jsx_runtime_1.jsx)("div", { className: "text-sm font-medium text-ui-fg-base", children: label })), hint ? (0, jsx_runtime_1.jsx)("div", { className: "mt-1 text-xs text-ui-fg-subtle", children: hint }) : null] }), (0, jsx_runtime_1.jsx)("div", { className: "col-span-12 md:col-span-8", children: children })] }));
7
- }
8
- //# sourceMappingURL=FieldRow.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"FieldRow.js","sourceRoot":"","sources":["../../../../../../../../src/admin/routes/settings/paypal/_components/FieldRow.tsx"],"names":[],"mappings":";;AAEA,2BA0BC;;AA1BD,SAAwB,QAAQ,CAAC,EAC/B,KAAK,EACL,IAAI,EACJ,OAAO,EACP,QAAQ,GAMT;IACC,OAAO,CACL,iCAAK,SAAS,EAAC,0CAA0C,aACvD,iCAAK,SAAS,EAAC,2BAA2B,aACvC,OAAO,CAAC,CAAC,CAAC,CACT,kCAAO,OAAO,EAAE,OAAO,EAAE,SAAS,EAAC,qCAAqC,YACrE,KAAK,GACA,CACT,CAAC,CAAC,CAAC,CACF,gCAAK,SAAS,EAAC,qCAAqC,YAAE,KAAK,GAAO,CACnE,EACA,IAAI,CAAC,CAAC,CAAC,gCAAK,SAAS,EAAC,gCAAgC,YAAE,IAAI,GAAO,CAAC,CAAC,CAAC,IAAI,IACvE,EACN,gCAAK,SAAS,EAAC,2BAA2B,YAAE,QAAQ,GAAO,IACvD,CACP,CAAA;AACH,CAAC"}
@@ -1,8 +0,0 @@
1
- import React from "react";
2
- export default function SectionCard({ title, description, children, right, }: {
3
- title: string;
4
- description?: React.ReactNode;
5
- children: React.ReactNode;
6
- right?: React.ReactNode;
7
- }): import("react/jsx-runtime").JSX.Element;
8
- //# sourceMappingURL=SectionCard.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"SectionCard.d.ts","sourceRoot":"","sources":["../../../../../../../../src/admin/routes/settings/paypal/_components/SectionCard.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAEzB,MAAM,CAAC,OAAO,UAAU,WAAW,CAAC,EAClC,KAAK,EACL,WAAW,EACX,QAAQ,EACR,KAAK,GACN,EAAE;IACD,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;IAC7B,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;IACzB,KAAK,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;CACxB,2CAaA"}
@@ -1,8 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.default = SectionCard;
4
- const jsx_runtime_1 = require("react/jsx-runtime");
5
- function SectionCard({ title, description, children, right, }) {
6
- return ((0, jsx_runtime_1.jsxs)("div", { className: "rounded-xl border border-ui-border-base bg-ui-bg-base shadow-sm", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-start justify-between gap-4 border-b border-ui-border-base p-4", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("div", { className: "text-base font-semibold text-ui-fg-base", children: title }), description ? (0, jsx_runtime_1.jsx)("div", { className: "mt-1 text-sm text-ui-fg-subtle", children: description }) : null] }), right] }), (0, jsx_runtime_1.jsx)("div", { className: "p-4", children: children })] }));
7
- }
8
- //# sourceMappingURL=SectionCard.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"SectionCard.js","sourceRoot":"","sources":["../../../../../../../../src/admin/routes/settings/paypal/_components/SectionCard.tsx"],"names":[],"mappings":";;AAEA,8BAuBC;;AAvBD,SAAwB,WAAW,CAAC,EAClC,KAAK,EACL,WAAW,EACX,QAAQ,EACR,KAAK,GAMN;IACC,OAAO,CACL,iCAAK,SAAS,EAAC,iEAAiE,aAC9E,iCAAK,SAAS,EAAC,2EAA2E,aACxF,4CACE,gCAAK,SAAS,EAAC,yCAAyC,YAAE,KAAK,GAAO,EACrE,WAAW,CAAC,CAAC,CAAC,gCAAK,SAAS,EAAC,gCAAgC,YAAE,WAAW,GAAO,CAAC,CAAC,CAAC,IAAI,IACrF,EACL,KAAK,IACF,EACN,gCAAK,SAAS,EAAC,KAAK,YAAE,QAAQ,GAAO,IACjC,CACP,CAAA;AACH,CAAC"}
@@ -1,2 +0,0 @@
1
- export default function PayPalTabs(): import("react/jsx-runtime").JSX.Element;
2
- //# sourceMappingURL=Tabs.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"Tabs.d.ts","sourceRoot":"","sources":["../../../../../../../../src/admin/routes/settings/paypal/_components/Tabs.tsx"],"names":[],"mappings":"AAqBA,MAAM,CAAC,OAAO,UAAU,UAAU,4CA0BjC"}
@@ -1,25 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.default = PayPalTabs;
4
- const jsx_runtime_1 = require("react/jsx-runtime");
5
- const react_router_dom_1 = require("react-router-dom");
6
- const BASE = "/settings/paypal";
7
- const TABS = [
8
- { label: "PayPal Connection", to: `${BASE}/connection` },
9
- { label: "PayPal Settings", to: `${BASE}/paypal-settings` },
10
- { label: "Advanced Card Payments", to: `${BASE}/advanced-card-payments` },
11
- { label: "Additional Settings", to: `${BASE}/additional-settings` },
12
- ];
13
- function isActive(pathname, to) {
14
- return pathname === to || pathname.startsWith(to + "/");
15
- }
16
- function PayPalTabs() {
17
- const { pathname } = (0, react_router_dom_1.useLocation)();
18
- return ((0, jsx_runtime_1.jsx)("div", { className: "border-b border-ui-border-base", children: (0, jsx_runtime_1.jsx)("div", { className: "flex flex-wrap gap-6 text-sm", children: TABS.map((t) => {
19
- const active = isActive(pathname, t.to);
20
- return ((0, jsx_runtime_1.jsx)(react_router_dom_1.Link, { to: t.to, className: active
21
- ? "border-b-2 border-ui-fg-base pb-2 font-medium text-ui-fg-base"
22
- : "pb-2 text-ui-fg-subtle hover:text-ui-fg-base", children: t.label }, t.to));
23
- }) }) }));
24
- }
25
- //# sourceMappingURL=Tabs.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"Tabs.js","sourceRoot":"","sources":["../../../../../../../../src/admin/routes/settings/paypal/_components/Tabs.tsx"],"names":[],"mappings":";;AAqBA,6BA0BC;;AA9CD,uDAAoD;AAOpD,MAAM,IAAI,GAAG,kBAAkB,CAAA;AAE/B,MAAM,IAAI,GAAU;IAClB,EAAE,KAAK,EAAE,mBAAmB,EAAE,EAAE,EAAE,GAAG,IAAI,aAAa,EAAE;IACxD,EAAE,KAAK,EAAE,iBAAiB,EAAE,EAAE,EAAE,GAAG,IAAI,kBAAkB,EAAE;IAC3D,EAAE,KAAK,EAAE,wBAAwB,EAAE,EAAE,EAAE,GAAG,IAAI,yBAAyB,EAAE;IACzE,EAAE,KAAK,EAAE,qBAAqB,EAAE,EAAE,EAAE,GAAG,IAAI,sBAAsB,EAAE;CACpE,CAAA;AAED,SAAS,QAAQ,CAAC,QAAgB,EAAE,EAAU;IAC5C,OAAO,QAAQ,KAAK,EAAE,IAAI,QAAQ,CAAC,UAAU,CAAC,EAAE,GAAG,GAAG,CAAC,CAAA;AACzD,CAAC;AAED,SAAwB,UAAU;IAChC,MAAM,EAAE,QAAQ,EAAE,GAAG,IAAA,8BAAW,GAAE,CAAA;IAElC,OAAO,CACL,gCAAK,SAAS,EAAC,gCAAgC,YAC7C,gCAAK,SAAS,EAAC,8BAA8B,YAC1C,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE;gBACd,MAAM,MAAM,GAAG,QAAQ,CAAC,QAAQ,EAAE,CAAC,CAAC,EAAE,CAAC,CAAA;gBAEvC,OAAO,CACL,uBAAC,uBAAI,IAEH,EAAE,EAAE,CAAC,CAAC,EAAE,EACR,SAAS,EACP,MAAM;wBACJ,CAAC,CAAC,+DAA+D;wBACjE,CAAC,CAAC,8CAA8C,YAGnD,CAAC,CAAC,KAAK,IARH,CAAC,CAAC,EAAE,CASJ,CACR,CAAA;YACH,CAAC,CAAC,GACE,GACF,CACP,CAAA;AACH,CAAC"}
@@ -1,12 +0,0 @@
1
- export type ToastKind = "success" | "error";
2
- export type ToastState = {
3
- kind: ToastKind;
4
- message: string;
5
- } | null;
6
- type Props = {
7
- toast: ToastState;
8
- onClose: () => void;
9
- };
10
- export default function Toast({ toast, onClose }: Props): import("react/jsx-runtime").JSX.Element | null;
11
- export {};
12
- //# sourceMappingURL=Toast.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"Toast.d.ts","sourceRoot":"","sources":["../../../../../../../../src/admin/routes/settings/paypal/_components/Toast.tsx"],"names":[],"mappings":"AAEA,MAAM,MAAM,SAAS,GAAG,SAAS,GAAG,OAAO,CAAA;AAE3C,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,EAAE,SAAS,CAAA;IACf,OAAO,EAAE,MAAM,CAAA;CAChB,GAAG,IAAI,CAAA;AAER,KAAK,KAAK,GAAG;IACX,KAAK,EAAE,UAAU,CAAA;IACjB,OAAO,EAAE,MAAM,IAAI,CAAA;CACpB,CAAA;AAED,MAAM,CAAC,OAAO,UAAU,KAAK,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,KAAK,kDAqCtD"}