@easypayment/medusa-paypal-ui 1.0.55 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,38 @@ export type HttpOptions = {
3
3
  publishableApiKey?: string
4
4
  }
5
5
 
6
+ export type RequestOptions = {
7
+ /**
8
+ * Number of extra attempts on transient failures (network error, timeout,
9
+ * HTTP 429/5xx). Only use for idempotent calls — the PayPal capture/complete
10
+ * endpoints are idempotent server-side, so a retried request after a network
11
+ * blip re-uses the existing result instead of duplicating work. Default 0.
12
+ */
13
+ retries?: number
14
+ }
15
+
16
+ class HttpError extends Error {
17
+ status: number
18
+ constructor(message: string, status: number) {
19
+ super(message)
20
+ this.name = "HttpError"
21
+ this.status = status
22
+ }
23
+ }
24
+
25
+ const RETRY_BASE_DELAY_MS = 400
26
+
27
+ function isRetryableStatus(status: number): boolean {
28
+ return status === 429 || status >= 500
29
+ }
30
+
31
+ function isRetryableError(err: unknown): boolean {
32
+ // Network failures and our own timeout error are safe to retry.
33
+ return err instanceof Error && !(err instanceof HttpError)
34
+ }
35
+
36
+ const wait = (ms: number) => new Promise((r) => setTimeout(r, ms))
37
+
6
38
  function toHeaderRecord(headers?: RequestInit["headers"]): Record<string, string> {
7
39
  if (!headers) {
8
40
  return {}
@@ -22,7 +54,7 @@ function toHeaderRecord(headers?: RequestInit["headers"]): Record<string, string
22
54
  export function createHttpClient(opts: HttpOptions) {
23
55
  const base = opts.baseUrl.replace(/\/+$/, "")
24
56
 
25
- async function request<T>(path: string, init?: RequestInit): Promise<T> {
57
+ async function attempt<T>(path: string, init?: RequestInit): Promise<T> {
26
58
  const url = `${base}${path.startsWith("/") ? "" : "/"}${path}`
27
59
  const headers: Record<string, string> = {
28
60
  Accept: "application/json",
@@ -55,21 +87,24 @@ export function createHttpClient(opts: HttpOptions) {
55
87
 
56
88
  if (!res.ok) {
57
89
  if (res.status === 401) {
58
- throw new Error(
59
- "[PayPal] Unauthorized (401) — check that your publishable API key is correct and set in NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY"
90
+ throw new HttpError(
91
+ "[PayPal] Unauthorized (401) — check that your publishable API key is correct and set in NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY",
92
+ 401
60
93
  )
61
94
  }
62
95
  if (res.status === 403) {
63
- throw new Error(
64
- "[PayPal] Forbidden (403) — this request is not allowed. Check your CORS and API key settings."
96
+ throw new HttpError(
97
+ "[PayPal] Forbidden (403) — this request is not allowed. Check your CORS and API key settings.",
98
+ 403
65
99
  )
66
100
  }
67
- throw new Error(
68
- text.slice(0, 500).replace(/<[^>]*>/g, "") || `Request failed (${res.status})`
101
+ throw new HttpError(
102
+ text.slice(0, 500).replace(/<[^>]*>/g, "") || `Request failed (${res.status})`,
103
+ res.status
69
104
  )
70
105
  }
71
106
  if (!text) {
72
- throw new Error(`[PayPal] Empty response body from ${path} (${res.status})`)
107
+ throw new HttpError(`[PayPal] Empty response body from ${path} (${res.status})`, res.status)
73
108
  }
74
109
 
75
110
  const contentType = res.headers.get("content-type") || ""
@@ -86,5 +121,32 @@ export function createHttpClient(opts: HttpOptions) {
86
121
  }
87
122
  }
88
123
 
124
+ async function request<T>(
125
+ path: string,
126
+ init?: RequestInit,
127
+ reqOpts?: RequestOptions
128
+ ): Promise<T> {
129
+ const retries = Math.max(0, reqOpts?.retries ?? 0)
130
+ let lastError: unknown
131
+ for (let i = 0; i <= retries; i++) {
132
+ try {
133
+ return await attempt<T>(path, init)
134
+ } catch (err) {
135
+ lastError = err
136
+ const transient =
137
+ (err instanceof HttpError && isRetryableStatus(err.status)) ||
138
+ isRetryableError(err)
139
+ // Don't retry a caller-supplied AbortSignal cancellation.
140
+ const aborted =
141
+ err instanceof Error && err.name === "AbortError" && !!init?.signal
142
+ if (i >= retries || !transient || aborted) {
143
+ throw err
144
+ }
145
+ await wait(RETRY_BASE_DELAY_MS * Math.pow(2, i) + Math.random() * RETRY_BASE_DELAY_MS)
146
+ }
147
+ }
148
+ throw lastError
149
+ }
150
+
89
151
  return { request }
90
152
  }
@@ -7,11 +7,19 @@ export async function markPaymentComplete(
7
7
  publishableApiKey?: string
8
8
  ): Promise<Record<string, unknown>> {
9
9
  const http = createHttpClient({ baseUrl, publishableApiKey })
10
- return http.request<Record<string, unknown>>(`/store/paypal-complete`, {
11
- method: "POST",
12
- headers: { "Content-Type": "application/json" },
13
- body: JSON.stringify({ cart_id: cartId }),
14
- })
10
+ // Retry transient failures: /store/paypal-complete is idempotent (it returns
11
+ // already_completed for a finished cart), so a network blip right after
12
+ // capture — the classic "captured but cart not completed" gap — is retried
13
+ // instead of stranding a paid-but-unfinalized order.
14
+ return http.request<Record<string, unknown>>(
15
+ `/store/paypal-complete`,
16
+ {
17
+ method: "POST",
18
+ headers: { "Content-Type": "application/json" },
19
+ body: JSON.stringify({ cart_id: cartId }),
20
+ },
21
+ { retries: 3 }
22
+ )
15
23
  }
16
24
 
17
25
  export function createPayPalStoreApi(opts: HttpOptions) {
@@ -36,11 +44,19 @@ export function createPayPalStoreApi(opts: HttpOptions) {
36
44
  },
37
45
 
38
46
  captureOrder(cartId: string, orderId: string) {
39
- return http.request<Record<string, unknown>>(`/store/paypal/capture-order`, {
40
- method: "POST",
41
- headers: { "Content-Type": "application/json" },
42
- body: JSON.stringify({ cart_id: cartId, order_id: orderId }),
43
- })
47
+ // Retry transient failures: capture-order is idempotent server-side (it
48
+ // short-circuits on an existing capture and PayPal dedupes by request id),
49
+ // so a network blip during capture doesn't leave the buyer's funds
50
+ // authorized-but-not-captured with no recovery.
51
+ return http.request<Record<string, unknown>>(
52
+ `/store/paypal/capture-order`,
53
+ {
54
+ method: "POST",
55
+ headers: { "Content-Type": "application/json" },
56
+ body: JSON.stringify({ cart_id: cartId, order_id: orderId }),
57
+ },
58
+ { retries: 2 }
59
+ )
44
60
  },
45
61
  }
46
62
  }