@flopay/js 1.3.2 → 1.3.4
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/README.md +39 -0
- package/dist/index.cjs +51 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.mjs +57 -26
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -171,6 +171,45 @@ const result = await createCheckoutSession({
|
|
|
171
171
|
|
|
172
172
|
Use `createCheckoutSessionWithRetries` for automatic retry with exponential backoff on timeout errors.
|
|
173
173
|
|
|
174
|
+
#### Idempotent checkout creation
|
|
175
|
+
|
|
176
|
+
When a secure RNG is available, every checkout-session create sends a stable
|
|
177
|
+
`Idempotency-Key` header so a timeout or lost response cannot mint a second
|
|
178
|
+
session. The behavior is fully additive — existing integrations need no changes:
|
|
179
|
+
|
|
180
|
+
- **Automatic (default):** the SDK generates one cryptographically random,
|
|
181
|
+
high-entropy key per logical `createCheckoutSession` call and reuses it for
|
|
182
|
+
every transport retry of that same operation. Two independent calls get
|
|
183
|
+
different keys, even when their request bodies are identical. The generated
|
|
184
|
+
key is never derived from customer data and is never logged or returned.
|
|
185
|
+
Automatic generation requires a secure RNG (`crypto.randomUUID` /
|
|
186
|
+
`crypto.getRandomValues`); in an environment without one the SDK omits the
|
|
187
|
+
header rather than emit a weak key — supply a valid `idempotencyKey` yourself
|
|
188
|
+
to stay idempotent there.
|
|
189
|
+
- **Merchant-supplied:** pass `idempotencyKey` to control the key yourself. It
|
|
190
|
+
is sent unchanged and reused across retries, so you can keep it stable across
|
|
191
|
+
React remounts, multiple SDK instances, or server retries you control. It
|
|
192
|
+
identifies **exactly one** logical checkout creation and must not be reused
|
|
193
|
+
for a new purchase. It must be non-empty and at most 255 characters (an
|
|
194
|
+
invalid value throws a `FloPayError('validation_error')` before any request).
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
// Merchant-controlled key — reused for this one logical checkout only.
|
|
198
|
+
await createCheckoutSession({ /* … */ idempotencyKey: `checkout:${orderId}` });
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`createCheckoutSessionWithRetries` resolves the key once, before its retry loop,
|
|
202
|
+
so all attempts — timeouts, lost responses, and the backend's documented
|
|
203
|
+
in-progress reply — replay the same key. A payload-conflict (`409`) is surfaced
|
|
204
|
+
without retrying. The header is optional on the backend: older SDKs and direct
|
|
205
|
+
API clients that omit it keep the legacy, unkeyed path. The same automatic and
|
|
206
|
+
merchant-supplied behavior applies to inline session creation via
|
|
207
|
+
`PaymentAPI.createAndFetchSession` (used by `@flopay/react`).
|
|
208
|
+
|
|
209
|
+
`@flopay/shared` also exports the primitives directly if you build your own
|
|
210
|
+
create flow: `generateIdempotencyKey()`, `resolveIdempotencyKey(supplied?)`,
|
|
211
|
+
`IDEMPOTENCY_KEY_HEADER`, and `MAX_IDEMPOTENCY_KEY_LENGTH`.
|
|
212
|
+
|
|
174
213
|
Coupon validation errors are surfaced as `FloPayError` with structured `code`:
|
|
175
214
|
|
|
176
215
|
- `CouponLimitExceeded` — more than 5 coupon codes supplied (also enforced client-side before the request leaves the browser).
|
package/dist/index.cjs
CHANGED
|
@@ -674,6 +674,7 @@ async function buildApiErrorFromResponse(response, fallbackMessage) {
|
|
|
674
674
|
});
|
|
675
675
|
}
|
|
676
676
|
var NETWORK_RETRY_ATTEMPTS = 2;
|
|
677
|
+
var IDEMPOTENCY_IN_PROGRESS_RETRY_ATTEMPTS = 2;
|
|
677
678
|
async function fetchWithNetworkRetry(input, init, attempts = NETWORK_RETRY_ATTEMPTS) {
|
|
678
679
|
let lastErr;
|
|
679
680
|
for (let attempt = 0; ; attempt++) {
|
|
@@ -1020,29 +1021,41 @@ var PaymentAPI = class {
|
|
|
1020
1021
|
if (params.checkoutType) payload["checkoutType"] = params.checkoutType;
|
|
1021
1022
|
if (params.checkoutLayout) payload["checkoutLayout"] = params.checkoutLayout;
|
|
1022
1023
|
if (params.avsConfig) payload["avsConfig"] = params.avsConfig;
|
|
1023
|
-
const
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
},
|
|
1034
|
-
body: JSON.stringify(payload)
|
|
1035
|
-
}
|
|
1036
|
-
);
|
|
1037
|
-
if (response.status === 204) {
|
|
1038
|
-
throw new import_shared3.FloPayError(
|
|
1039
|
-
"Session auto-completed \u2014 payment method already on file",
|
|
1040
|
-
"api_error",
|
|
1041
|
-
{ code: "session_auto_completed" }
|
|
1042
|
-
);
|
|
1024
|
+
const headers = {
|
|
1025
|
+
"Content-Type": "application/json",
|
|
1026
|
+
// Declare the SDK version so backends at TeamFloPay/backend#823 embed
|
|
1027
|
+
// the hosted vault capture block (`body.vault`) in the response for
|
|
1028
|
+
// SDKs ≥ 1.3.0. Older backends ignore the header.
|
|
1029
|
+
[import_shared3.FLO_SDK_VERSION_HEADER]: import_shared3.SDK_VERSION
|
|
1030
|
+
};
|
|
1031
|
+
const idempotencyKey = (0, import_shared3.resolveIdempotencyKey)(params.idempotencyKey);
|
|
1032
|
+
if (idempotencyKey) {
|
|
1033
|
+
headers[import_shared3.IDEMPOTENCY_KEY_HEADER] = idempotencyKey;
|
|
1043
1034
|
}
|
|
1044
|
-
|
|
1045
|
-
|
|
1035
|
+
let response;
|
|
1036
|
+
for (let attempt = 0; ; attempt++) {
|
|
1037
|
+
response = await fetchWithNetworkRetry(
|
|
1038
|
+
`${this.baseUrl}/v1/checkouts/sessions?expand=true`,
|
|
1039
|
+
{
|
|
1040
|
+
method: "POST",
|
|
1041
|
+
headers,
|
|
1042
|
+
body: JSON.stringify(payload)
|
|
1043
|
+
}
|
|
1044
|
+
);
|
|
1045
|
+
if (response.status === 204) {
|
|
1046
|
+
throw new import_shared3.FloPayError(
|
|
1047
|
+
"Session auto-completed \u2014 payment method already on file",
|
|
1048
|
+
"api_error",
|
|
1049
|
+
{ code: "session_auto_completed" }
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
if (response.ok) break;
|
|
1053
|
+
const error = await buildApiErrorFromResponse(response, "Failed to create checkout session");
|
|
1054
|
+
if (error.code === import_shared3.IDEMPOTENCY_IN_PROGRESS_CODE && attempt < IDEMPOTENCY_IN_PROGRESS_RETRY_ATTEMPTS) {
|
|
1055
|
+
await delay(150 * 2 ** attempt);
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
throw error;
|
|
1046
1059
|
}
|
|
1047
1060
|
const body = await response.json();
|
|
1048
1061
|
if (body.data && "gateways" in body.data) {
|
|
@@ -1878,8 +1891,10 @@ async function createCheckoutSession(options) {
|
|
|
1878
1891
|
timeoutMs = 12e3,
|
|
1879
1892
|
clientId,
|
|
1880
1893
|
currency,
|
|
1881
|
-
utmMetadata
|
|
1894
|
+
utmMetadata,
|
|
1895
|
+
idempotencyKey
|
|
1882
1896
|
} = options;
|
|
1897
|
+
const resolvedIdempotencyKey = (0, import_shared7.resolveIdempotencyKey)(idempotencyKey);
|
|
1883
1898
|
if (couponCodes.length > MAX_COUPON_CODES) {
|
|
1884
1899
|
throw new import_shared7.FloPayError(
|
|
1885
1900
|
`Too many coupon codes \u2014 a checkout session accepts at most ${MAX_COUPON_CODES}.`,
|
|
@@ -1928,12 +1943,16 @@ async function createCheckoutSession(options) {
|
|
|
1928
1943
|
const url = `${billingApiUrl.replace(/\/+$/, "")}/v1/checkouts/sessions`;
|
|
1929
1944
|
const controller = new AbortController();
|
|
1930
1945
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1946
|
+
const headers = { "Content-Type": "application/json" };
|
|
1947
|
+
if (resolvedIdempotencyKey) {
|
|
1948
|
+
headers[import_shared7.IDEMPOTENCY_KEY_HEADER] = resolvedIdempotencyKey;
|
|
1949
|
+
}
|
|
1931
1950
|
let status;
|
|
1932
1951
|
let body;
|
|
1933
1952
|
try {
|
|
1934
1953
|
const response = await fetch(url, {
|
|
1935
1954
|
method: "POST",
|
|
1936
|
-
headers
|
|
1955
|
+
headers,
|
|
1937
1956
|
body: JSON.stringify(payload),
|
|
1938
1957
|
signal: controller.signal
|
|
1939
1958
|
});
|
|
@@ -2003,13 +2022,19 @@ async function createCheckoutSessionWithRetries(options) {
|
|
|
2003
2022
|
if (!Number.isFinite(maxRetries) || !Number.isInteger(maxRetries) || maxRetries <= 0) {
|
|
2004
2023
|
throw new Error("Number of retries must be greater than 0");
|
|
2005
2024
|
}
|
|
2025
|
+
const attemptOptions = {
|
|
2026
|
+
...sessionOptions,
|
|
2027
|
+
idempotencyKey: (0, import_shared7.resolveIdempotencyKey)(sessionOptions.idempotencyKey)
|
|
2028
|
+
};
|
|
2006
2029
|
let lastErr;
|
|
2007
2030
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
2008
2031
|
try {
|
|
2009
|
-
return await createCheckoutSession(
|
|
2032
|
+
return await createCheckoutSession(attemptOptions);
|
|
2010
2033
|
} catch (err) {
|
|
2011
2034
|
lastErr = err;
|
|
2012
|
-
|
|
2035
|
+
const isTransportAbort = err instanceof Error && err.name === "AbortError";
|
|
2036
|
+
const isInProgressReplay = err instanceof import_shared7.FloPayError && err.code === import_shared7.IDEMPOTENCY_IN_PROGRESS_CODE;
|
|
2037
|
+
if ((isTransportAbort || isInProgressReplay) && attempt < maxRetries) {
|
|
2013
2038
|
await new Promise((r) => setTimeout(r, 100 * Math.pow(2, attempt)));
|
|
2014
2039
|
continue;
|
|
2015
2040
|
}
|