@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.
- package/CHANGELOG.md +25 -0
- package/dist/index.cjs +62 -18
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +62 -18
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
- package/src/client/http.ts +70 -8
- package/src/client/paypal.ts +26 -10
package/src/client/http.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|
package/src/client/paypal.ts
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
}
|