@easypayment/medusa-paypal-ui 1.2.2 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,204 +1,214 @@
1
- export type HttpOptions = {
2
- baseUrl: string
3
- publishableApiKey?: string
4
- /**
5
- * `credentials` mode for every request. Defaults to "include" (the
6
- * historical behavior, required when the backend sits behind cookie-based
7
- * gateways like Cloudflare Access). Set to "omit" or "same-origin" when the
8
- * backend answers with `Access-Control-Allow-Origin: *` — browsers reject
9
- * credentialed requests against a wildcard CORS origin outright.
10
- */
11
- credentials?: RequestCredentials
12
- }
13
-
14
- export type RequestOptions = {
15
- /**
16
- * Number of extra attempts on transient failures (network error, timeout,
17
- * HTTP 429/5xx). Only use for idempotent calls — the PayPal capture/complete
18
- * endpoints are idempotent server-side, so a retried request after a network
19
- * blip re-uses the existing result instead of duplicating work. Default 0.
20
- */
21
- retries?: number
22
- }
23
-
24
- class HttpError extends Error {
25
- status: number
26
- constructor(message: string, status: number) {
27
- super(message)
28
- this.name = "HttpError"
29
- this.status = status
30
- }
31
- }
32
-
33
- const RETRY_BASE_DELAY_MS = 400
34
-
35
- function isRetryableStatus(status: number): boolean {
36
- return status === 429 || status >= 500
37
- }
38
-
39
- function isRetryableError(err: unknown): boolean {
40
- // Network failures and our own timeout error are safe to retry.
41
- return err instanceof Error && !(err instanceof HttpError)
42
- }
43
-
44
- const wait = (ms: number) => new Promise((r) => setTimeout(r, ms))
45
-
46
- /** Best-effort extraction of a `{ message: string }` payload from a response body. */
47
- function extractJsonMessage(text: string): string | null {
48
- if (!text) return null
49
- try {
50
- const parsed = JSON.parse(text)
51
- const message = parsed?.message
52
- if (typeof message === "string" && message.trim()) {
53
- return message.trim().slice(0, 500)
54
- }
55
- } catch {
56
- // not JSON — caller falls back to the raw text
57
- }
58
- return null
59
- }
60
-
61
- function toHeaderRecord(headers?: RequestInit["headers"]): Record<string, string> {
62
- if (!headers) {
63
- return {}
64
- }
65
-
66
- if (headers instanceof Headers) {
67
- return Object.fromEntries(headers.entries())
68
- }
69
-
70
- if (Array.isArray(headers)) {
71
- return Object.fromEntries(headers)
72
- }
73
-
74
- return { ...headers }
75
- }
76
-
77
- export function createHttpClient(opts: HttpOptions) {
78
- const base = opts.baseUrl.replace(/\/+$/, "")
79
-
80
- async function attempt<T>(path: string, init?: RequestInit): Promise<T> {
81
- const url = `${base}${path.startsWith("/") ? "" : "/"}${path}`
82
- const headers: Record<string, string> = {
83
- Accept: "application/json",
84
- ...toHeaderRecord(init?.headers),
85
- }
86
-
87
- if (opts.publishableApiKey) {
88
- headers["x-publishable-api-key"] = opts.publishableApiKey
89
- }
90
-
91
- // Always arm the timeout — a caller-supplied signal composes with it
92
- // rather than replacing it. Without this, hook-issued requests (which
93
- // always pass an abort signal) could hang forever on a stalled
94
- // connection. The timer also covers reading the body: a server that sends
95
- // headers then stalls the response stream would otherwise hang the
96
- // request indefinitely with the old header-only timeout.
97
- const timeoutMs = 30_000
98
- const controller = new AbortController()
99
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
100
- const callerSignal = init?.signal ?? null
101
- const onCallerAbort = () => controller.abort()
102
- if (callerSignal) {
103
- if (callerSignal.aborted) controller.abort()
104
- else callerSignal.addEventListener("abort", onCallerAbort)
105
- }
106
- let res: Response
107
- let text: string
108
- try {
109
- res = await fetch(url, {
110
- ...init,
111
- headers,
112
- credentials: opts.credentials ?? "include",
113
- signal: controller.signal,
114
- })
115
- text = await res.text().catch(() => "")
116
- } catch (err) {
117
- if (
118
- err instanceof Error &&
119
- err.name === "AbortError" &&
120
- !(callerSignal && callerSignal.aborted)
121
- ) {
122
- throw new Error(`[PayPal] Request to ${path} timed out after ${timeoutMs / 1000}s`)
123
- }
124
- throw err
125
- } finally {
126
- clearTimeout(timeoutId)
127
- if (callerSignal) callerSignal.removeEventListener("abort", onCallerAbort)
128
- }
129
-
130
- if (!res.ok) {
131
- // Backend routes return structured JSON errors ({ message }) written for
132
- // the buyer (e.g. unsupported currency, rate-limit, "payment processed
133
- // but order not finalized"). Prefer that message over raw response text
134
- // or a generic hint so the user sees something actionable instead of a
135
- // JSON blob.
136
- const parsedMessage = extractJsonMessage(text)
137
- if (res.status === 401) {
138
- throw new HttpError(
139
- parsedMessage ||
140
- "[PayPal] Unauthorized (401) — check that your publishable API key is correct and set in NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY",
141
- 401
142
- )
143
- }
144
- if (res.status === 403) {
145
- throw new HttpError(
146
- parsedMessage ||
147
- "[PayPal] Forbidden (403) — this request is not allowed. Check your CORS and API key settings.",
148
- 403
149
- )
150
- }
151
- throw new HttpError(
152
- parsedMessage ||
153
- text.slice(0, 500).replace(/<[^>]*>/g, "") ||
154
- `Request failed (${res.status})`,
155
- res.status
156
- )
157
- }
158
- if (!text) {
159
- throw new HttpError(`[PayPal] Empty response body from ${path} (${res.status})`, res.status)
160
- }
161
-
162
- const contentType = res.headers.get("content-type") || ""
163
- if (!contentType.includes("application/json")) {
164
- throw new Error(
165
- `[PayPal] Unexpected non-JSON response (${contentType}) from ${path}`
166
- )
167
- }
168
-
169
- try {
170
- return JSON.parse(text) as T
171
- } catch {
172
- throw new Error(`[PayPal] Failed to parse JSON response from ${path}`)
173
- }
174
- }
175
-
176
- async function request<T>(
177
- path: string,
178
- init?: RequestInit,
179
- reqOpts?: RequestOptions
180
- ): Promise<T> {
181
- const retries = Math.max(0, reqOpts?.retries ?? 0)
182
- let lastError: unknown
183
- for (let i = 0; i <= retries; i++) {
184
- try {
185
- return await attempt<T>(path, init)
186
- } catch (err) {
187
- lastError = err
188
- const transient =
189
- (err instanceof HttpError && isRetryableStatus(err.status)) ||
190
- isRetryableError(err)
191
- // Don't retry a caller-supplied AbortSignal cancellation.
192
- const aborted =
193
- err instanceof Error && err.name === "AbortError" && !!init?.signal
194
- if (i >= retries || !transient || aborted) {
195
- throw err
196
- }
197
- await wait(RETRY_BASE_DELAY_MS * Math.pow(2, i) + Math.random() * RETRY_BASE_DELAY_MS)
198
- }
199
- }
200
- throw lastError
201
- }
202
-
203
- return { request }
204
- }
1
+ export type HttpOptions = {
2
+ baseUrl: string
3
+ publishableApiKey?: string
4
+ /**
5
+ * `credentials` mode for every request. Defaults to "include" (the
6
+ * historical behavior, required when the backend sits behind cookie-based
7
+ * gateways like Cloudflare Access). Set to "omit" or "same-origin" when the
8
+ * backend answers with `Access-Control-Allow-Origin: *` — browsers reject
9
+ * credentialed requests against a wildcard CORS origin outright.
10
+ */
11
+ credentials?: RequestCredentials
12
+ }
13
+
14
+ export type RequestOptions = {
15
+ /**
16
+ * Number of extra attempts on transient failures (network error, timeout,
17
+ * HTTP 429/5xx). Only use for idempotent calls — the PayPal capture/complete
18
+ * endpoints are idempotent server-side, so a retried request after a network
19
+ * blip re-uses the existing result instead of duplicating work. Default 0.
20
+ */
21
+ retries?: number
22
+ }
23
+
24
+ class HttpError extends Error {
25
+ status: number
26
+ constructor(message: string, status: number) {
27
+ super(message)
28
+ this.name = "HttpError"
29
+ this.status = status
30
+ }
31
+ }
32
+
33
+ const RETRY_BASE_DELAY_MS = 400
34
+
35
+ function isRetryableStatus(status: number): boolean {
36
+ return status === 429 || status >= 500
37
+ }
38
+
39
+ function isRetryableError(err: unknown): boolean {
40
+ // Network failures and our own timeout error are safe to retry.
41
+ return err instanceof Error && !(err instanceof HttpError)
42
+ }
43
+
44
+ const wait = (ms: number) => new Promise((r) => setTimeout(r, ms))
45
+
46
+ /** Best-effort extraction of a `{ message: string }` payload from a response body. */
47
+ function extractJsonMessage(text: string): string | null {
48
+ if (!text) return null
49
+ try {
50
+ const parsed = JSON.parse(text)
51
+ const message = parsed?.message
52
+ if (typeof message === "string" && message.trim()) {
53
+ return message.trim().slice(0, 500)
54
+ }
55
+ } catch {
56
+ // not JSON — caller falls back to the raw text
57
+ }
58
+ return null
59
+ }
60
+
61
+ function toHeaderRecord(
62
+ headers?: RequestInit["headers"]
63
+ ): Record<string, string> {
64
+ if (!headers) {
65
+ return {}
66
+ }
67
+
68
+ if (headers instanceof Headers) {
69
+ return Object.fromEntries(headers.entries())
70
+ }
71
+
72
+ if (Array.isArray(headers)) {
73
+ return Object.fromEntries(headers)
74
+ }
75
+
76
+ return { ...headers }
77
+ }
78
+
79
+ export function createHttpClient(opts: HttpOptions) {
80
+ const base = opts.baseUrl.replace(/\/+$/, "")
81
+
82
+ async function attempt<T>(path: string, init?: RequestInit): Promise<T> {
83
+ const url = `${base}${path.startsWith("/") ? "" : "/"}${path}`
84
+ const headers: Record<string, string> = {
85
+ Accept: "application/json",
86
+ ...toHeaderRecord(init?.headers),
87
+ }
88
+
89
+ if (opts.publishableApiKey) {
90
+ headers["x-publishable-api-key"] = opts.publishableApiKey
91
+ }
92
+
93
+ // Always arm the timeout — a caller-supplied signal composes with it
94
+ // rather than replacing it. Without this, hook-issued requests (which
95
+ // always pass an abort signal) could hang forever on a stalled
96
+ // connection. The timer also covers reading the body: a server that sends
97
+ // headers then stalls the response stream would otherwise hang the
98
+ // request indefinitely with the old header-only timeout.
99
+ const timeoutMs = 30_000
100
+ const controller = new AbortController()
101
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
102
+ const callerSignal = init?.signal ?? null
103
+ const onCallerAbort = () => controller.abort()
104
+ if (callerSignal) {
105
+ if (callerSignal.aborted) controller.abort()
106
+ else callerSignal.addEventListener("abort", onCallerAbort)
107
+ }
108
+ let res: Response
109
+ let text: string
110
+ try {
111
+ res = await fetch(url, {
112
+ ...init,
113
+ headers,
114
+ credentials: opts.credentials ?? "include",
115
+ signal: controller.signal,
116
+ })
117
+ text = await res.text().catch(() => "")
118
+ } catch (err) {
119
+ if (
120
+ err instanceof Error &&
121
+ err.name === "AbortError" &&
122
+ !(callerSignal && callerSignal.aborted)
123
+ ) {
124
+ throw new Error(
125
+ `[PayPal] Request to ${path} timed out after ${timeoutMs / 1000}s`
126
+ )
127
+ }
128
+ throw err
129
+ } finally {
130
+ clearTimeout(timeoutId)
131
+ if (callerSignal) callerSignal.removeEventListener("abort", onCallerAbort)
132
+ }
133
+
134
+ if (!res.ok) {
135
+ // Backend routes return structured JSON errors ({ message }) written for
136
+ // the buyer (e.g. unsupported currency, rate-limit, "payment processed
137
+ // but order not finalized"). Prefer that message over raw response text
138
+ // or a generic hint so the user sees something actionable instead of a
139
+ // JSON blob.
140
+ const parsedMessage = extractJsonMessage(text)
141
+ if (res.status === 401) {
142
+ throw new HttpError(
143
+ parsedMessage ||
144
+ "[PayPal] Unauthorized (401) — check that your publishable API key is correct and set in NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY",
145
+ 401
146
+ )
147
+ }
148
+ if (res.status === 403) {
149
+ throw new HttpError(
150
+ parsedMessage ||
151
+ "[PayPal] Forbidden (403) — this request is not allowed. Check your CORS and API key settings.",
152
+ 403
153
+ )
154
+ }
155
+ throw new HttpError(
156
+ parsedMessage ||
157
+ text.slice(0, 500).replace(/<[^>]*>/g, "") ||
158
+ `Request failed (${res.status})`,
159
+ res.status
160
+ )
161
+ }
162
+ if (!text) {
163
+ throw new HttpError(
164
+ `[PayPal] Empty response body from ${path} (${res.status})`,
165
+ res.status
166
+ )
167
+ }
168
+
169
+ const contentType = res.headers.get("content-type") || ""
170
+ if (!contentType.includes("application/json")) {
171
+ throw new Error(
172
+ `[PayPal] Unexpected non-JSON response (${contentType}) from ${path}`
173
+ )
174
+ }
175
+
176
+ try {
177
+ return JSON.parse(text) as T
178
+ } catch {
179
+ throw new Error(`[PayPal] Failed to parse JSON response from ${path}`)
180
+ }
181
+ }
182
+
183
+ async function request<T>(
184
+ path: string,
185
+ init?: RequestInit,
186
+ reqOpts?: RequestOptions
187
+ ): Promise<T> {
188
+ const retries = Math.max(0, reqOpts?.retries ?? 0)
189
+ let lastError: unknown
190
+ for (let i = 0; i <= retries; i++) {
191
+ try {
192
+ return await attempt<T>(path, init)
193
+ } catch (err) {
194
+ lastError = err
195
+ const transient =
196
+ (err instanceof HttpError && isRetryableStatus(err.status)) ||
197
+ isRetryableError(err)
198
+ // Don't retry a caller-supplied AbortSignal cancellation.
199
+ const aborted =
200
+ err instanceof Error && err.name === "AbortError" && !!init?.signal
201
+ if (i >= retries || !transient || aborted) {
202
+ throw err
203
+ }
204
+ await wait(
205
+ RETRY_BASE_DELAY_MS * Math.pow(2, i) +
206
+ Math.random() * RETRY_BASE_DELAY_MS
207
+ )
208
+ }
209
+ }
210
+ throw lastError
211
+ }
212
+
213
+ return { request }
214
+ }
@@ -1,88 +1,91 @@
1
- import type { PayPalConfig, PayPalSettingsResponse } from "./types"
2
- import { createHttpClient, type HttpOptions } from "./http"
3
-
4
- /**
5
- * Fresh idempotency key per create-order ATTEMPT (i.e. per buyer click).
6
- *
7
- * The backend derives the PayPal-Request-Id from this header. It must be
8
- * unique per attempt: PayPal replays the cached response for a reused request
9
- * id, so a key stable across cart changes would hand back the ORIGINAL order —
10
- * at the original total — after the buyer edits the cart, charging a stale
11
- * amount. Reuse/dedup of an unchanged order is handled server-side by the
12
- * stored-order staleness check, not by this key.
13
- */
14
- export function generateIdempotencyKey(): string {
15
- try {
16
- const c = (globalThis as { crypto?: Crypto }).crypto
17
- if (c?.randomUUID) return c.randomUUID()
18
- } catch {
19
- // fall through
20
- }
21
- return `pp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`
22
- }
23
-
24
- export async function markPaymentComplete(
25
- baseUrl: string,
26
- cartId: string,
27
- publishableApiKey?: string
28
- ): Promise<Record<string, unknown>> {
29
- const http = createHttpClient({ baseUrl, publishableApiKey })
30
- // Retry transient failures: /store/paypal-complete is idempotent (it returns
31
- // already_completed for a finished cart), so a network blip right after
32
- // capture — the classic "captured but cart not completed" gap — is retried
33
- // instead of stranding a paid-but-unfinalized order.
34
- return http.request<Record<string, unknown>>(
35
- `/store/paypal-complete`,
36
- {
37
- method: "POST",
38
- headers: { "Content-Type": "application/json" },
39
- body: JSON.stringify({ cart_id: cartId }),
40
- },
41
- { retries: 3 }
42
- )
43
- }
44
-
45
- export function createPayPalStoreApi(opts: HttpOptions) {
46
- const http = createHttpClient(opts)
47
-
48
- return {
49
- getConfig(cartId?: string, signal?: AbortSignal) {
50
- const q = cartId ? `?cart_id=${encodeURIComponent(cartId)}` : ""
51
- return http.request<PayPalConfig>(`/store/paypal/config${q}`, { signal })
52
- },
53
-
54
- getSettings() {
55
- return http.request<PayPalSettingsResponse>(`/store/paypal/settings`)
56
- },
57
-
58
- createOrder(cartId: string, isCardPayment = false) {
59
- return http.request<{ id: string }>(`/store/paypal/create-order`, {
60
- method: "POST",
61
- headers: {
62
- "Content-Type": "application/json",
63
- // One key per attempt — see generateIdempotencyKey. Deliberately NOT
64
- // sent for capture-order: there the server's deterministic
65
- // per-order-id fallback is the correct idempotency scope.
66
- "Idempotency-Key": generateIdempotencyKey(),
67
- },
68
- body: JSON.stringify({ cart_id: cartId, is_card_payment: isCardPayment }),
69
- })
70
- },
71
-
72
- captureOrder(cartId: string, orderId: string) {
73
- // Retry transient failures: capture-order is idempotent server-side (it
74
- // short-circuits on an existing capture and PayPal dedupes by request id),
75
- // so a network blip during capture doesn't leave the buyer's funds
76
- // authorized-but-not-captured with no recovery.
77
- return http.request<Record<string, unknown>>(
78
- `/store/paypal/capture-order`,
79
- {
80
- method: "POST",
81
- headers: { "Content-Type": "application/json" },
82
- body: JSON.stringify({ cart_id: cartId, order_id: orderId }),
83
- },
84
- { retries: 2 }
85
- )
86
- },
87
- }
88
- }
1
+ import type { PayPalConfig, PayPalSettingsResponse } from "./types"
2
+ import { createHttpClient, type HttpOptions } from "./http"
3
+
4
+ /**
5
+ * Fresh idempotency key per create-order ATTEMPT (i.e. per buyer click).
6
+ *
7
+ * The backend derives the PayPal-Request-Id from this header. It must be
8
+ * unique per attempt: PayPal replays the cached response for a reused request
9
+ * id, so a key stable across cart changes would hand back the ORIGINAL order —
10
+ * at the original total — after the buyer edits the cart, charging a stale
11
+ * amount. Reuse/dedup of an unchanged order is handled server-side by the
12
+ * stored-order staleness check, not by this key.
13
+ */
14
+ export function generateIdempotencyKey(): string {
15
+ try {
16
+ const c = (globalThis as { crypto?: Crypto }).crypto
17
+ if (c?.randomUUID) return c.randomUUID()
18
+ } catch {
19
+ // fall through
20
+ }
21
+ return `pp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`
22
+ }
23
+
24
+ export async function markPaymentComplete(
25
+ baseUrl: string,
26
+ cartId: string,
27
+ publishableApiKey?: string
28
+ ): Promise<Record<string, unknown>> {
29
+ const http = createHttpClient({ baseUrl, publishableApiKey })
30
+ // Retry transient failures: /store/paypal-complete is idempotent (it returns
31
+ // already_completed for a finished cart), so a network blip right after
32
+ // capture — the classic "captured but cart not completed" gap — is retried
33
+ // instead of stranding a paid-but-unfinalized order.
34
+ return http.request<Record<string, unknown>>(
35
+ `/store/paypal-complete`,
36
+ {
37
+ method: "POST",
38
+ headers: { "Content-Type": "application/json" },
39
+ body: JSON.stringify({ cart_id: cartId }),
40
+ },
41
+ { retries: 3 }
42
+ )
43
+ }
44
+
45
+ export function createPayPalStoreApi(opts: HttpOptions) {
46
+ const http = createHttpClient(opts)
47
+
48
+ return {
49
+ getConfig(cartId?: string, signal?: AbortSignal) {
50
+ const q = cartId ? `?cart_id=${encodeURIComponent(cartId)}` : ""
51
+ return http.request<PayPalConfig>(`/store/paypal/config${q}`, { signal })
52
+ },
53
+
54
+ getSettings() {
55
+ return http.request<PayPalSettingsResponse>(`/store/paypal/settings`)
56
+ },
57
+
58
+ createOrder(cartId: string, isCardPayment = false) {
59
+ return http.request<{ id: string }>(`/store/paypal/create-order`, {
60
+ method: "POST",
61
+ headers: {
62
+ "Content-Type": "application/json",
63
+ // One key per attempt — see generateIdempotencyKey. Deliberately NOT
64
+ // sent for capture-order: there the server's deterministic
65
+ // per-order-id fallback is the correct idempotency scope.
66
+ "Idempotency-Key": generateIdempotencyKey(),
67
+ },
68
+ body: JSON.stringify({
69
+ cart_id: cartId,
70
+ is_card_payment: isCardPayment,
71
+ }),
72
+ })
73
+ },
74
+
75
+ captureOrder(cartId: string, orderId: string) {
76
+ // Retry transient failures: capture-order is idempotent server-side (it
77
+ // short-circuits on an existing capture and PayPal dedupes by request id),
78
+ // so a network blip during capture doesn't leave the buyer's funds
79
+ // authorized-but-not-captured with no recovery.
80
+ return http.request<Record<string, unknown>>(
81
+ `/store/paypal/capture-order`,
82
+ {
83
+ method: "POST",
84
+ headers: { "Content-Type": "application/json" },
85
+ body: JSON.stringify({ cart_id: cartId, order_id: orderId }),
86
+ },
87
+ { retries: 2 }
88
+ )
89
+ },
90
+ }
91
+ }