@easypayment/medusa-paypal-ui 1.2.2 → 1.2.7
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 +23 -1
- package/README.md +85 -52
- package/dist/index.cjs +197 -88
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +197 -88
- package/dist/index.mjs.map +1 -1
- package/dist/order.cjs.map +1 -1
- package/dist/order.mjs.map +1 -1
- package/package.json +1 -1
- package/src/adapters/MedusaNextPayPalAdapter.tsx +196 -195
- package/src/client/http.ts +214 -204
- package/src/client/paypal.ts +91 -88
- package/src/client/types.ts +40 -40
- package/src/components/PayPalAdvancedCard.tsx +646 -611
- package/src/components/PayPalCurrencyNotice.tsx +24 -19
- package/src/components/PayPalPaymentSection.tsx +5 -1
- package/src/components/PayPalProvider.tsx +44 -38
- package/src/components/PayPalSmartButtons.tsx +489 -445
- package/src/hooks/usePayPalConfig.ts +99 -96
- package/src/hooks/usePayPalPaymentMethods.ts +145 -145
- package/src/index.ts +19 -16
- package/src/order.ts +140 -134
- package/src/utils/next-errors.ts +21 -21
- package/src/utils/processing-overlay.ts +96 -96
package/src/client/http.ts
CHANGED
|
@@ -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(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
// JSON
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
)
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
+
}
|
package/src/client/paypal.ts
CHANGED
|
@@ -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({
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
+
}
|