@flopay/js 1.3.1 → 1.3.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.
- package/README.md +39 -0
- package/dist/index.cjs +79 -38
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +10 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.mjs +85 -38
- 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
|
@@ -632,6 +632,7 @@ var DEFAULT_PROCESSING_RETRY_AFTER_MS = 1e3;
|
|
|
632
632
|
var MIN_PROCESSING_RETRY_AFTER_MS = 500;
|
|
633
633
|
var DEFAULT_PROCESSING_TIMEOUT_MS = 15e3;
|
|
634
634
|
var MAX_PROCESSING_RETRY_AFTER_MS = 3e3;
|
|
635
|
+
var DEFAULT_ACCOUNT_SNAPSHOT_TIMEOUT_MS = 1e4;
|
|
635
636
|
function isRecord(value) {
|
|
636
637
|
return typeof value === "object" && value !== null;
|
|
637
638
|
}
|
|
@@ -673,6 +674,7 @@ async function buildApiErrorFromResponse(response, fallbackMessage) {
|
|
|
673
674
|
});
|
|
674
675
|
}
|
|
675
676
|
var NETWORK_RETRY_ATTEMPTS = 2;
|
|
677
|
+
var IDEMPOTENCY_IN_PROGRESS_RETRY_ATTEMPTS = 2;
|
|
676
678
|
async function fetchWithNetworkRetry(input, init, attempts = NETWORK_RETRY_ATTEMPTS) {
|
|
677
679
|
let lastErr;
|
|
678
680
|
for (let attempt = 0; ; attempt++) {
|
|
@@ -854,18 +856,33 @@ var PaymentAPI = class {
|
|
|
854
856
|
* pre-pay PATCH would silently leave AVS unsent and cause an
|
|
855
857
|
* AVS-protected charge to decline downstream.
|
|
856
858
|
*/
|
|
857
|
-
async patchAccountSnapshot(sessionId, nonce, body) {
|
|
858
|
-
const
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
859
|
+
async patchAccountSnapshot(sessionId, nonce, body, options) {
|
|
860
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_ACCOUNT_SNAPSHOT_TIMEOUT_MS;
|
|
861
|
+
const controller = new AbortController();
|
|
862
|
+
const onCallerAbort = () => controller.abort();
|
|
863
|
+
if (options?.signal) {
|
|
864
|
+
if (options.signal.aborted) controller.abort();
|
|
865
|
+
else options.signal.addEventListener("abort", onCallerAbort, { once: true });
|
|
866
|
+
}
|
|
867
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
868
|
+
let response;
|
|
869
|
+
try {
|
|
870
|
+
response = await fetchWithNetworkRetry(
|
|
871
|
+
`${this.baseUrl}/v1/checkouts/sessions/${encodeURIComponent(sessionId)}/account`,
|
|
872
|
+
{
|
|
873
|
+
method: "PATCH",
|
|
874
|
+
headers: {
|
|
875
|
+
"Content-Type": "application/json",
|
|
876
|
+
"x-checkout-session-token": nonce
|
|
877
|
+
},
|
|
878
|
+
body: JSON.stringify(body),
|
|
879
|
+
signal: controller.signal
|
|
880
|
+
}
|
|
881
|
+
);
|
|
882
|
+
} finally {
|
|
883
|
+
clearTimeout(timer);
|
|
884
|
+
options?.signal?.removeEventListener("abort", onCallerAbort);
|
|
885
|
+
}
|
|
869
886
|
if (!response.ok) {
|
|
870
887
|
throw await buildApiErrorFromResponse(response, "Failed to persist account snapshot");
|
|
871
888
|
}
|
|
@@ -1004,29 +1021,41 @@ var PaymentAPI = class {
|
|
|
1004
1021
|
if (params.checkoutType) payload["checkoutType"] = params.checkoutType;
|
|
1005
1022
|
if (params.checkoutLayout) payload["checkoutLayout"] = params.checkoutLayout;
|
|
1006
1023
|
if (params.avsConfig) payload["avsConfig"] = params.avsConfig;
|
|
1007
|
-
const
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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;
|
|
1034
|
+
}
|
|
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
|
+
}
|
|
1026
1044
|
);
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
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;
|
|
1030
1059
|
}
|
|
1031
1060
|
const body = await response.json();
|
|
1032
1061
|
if (body.data && "gateways" in body.data) {
|
|
@@ -1862,8 +1891,10 @@ async function createCheckoutSession(options) {
|
|
|
1862
1891
|
timeoutMs = 12e3,
|
|
1863
1892
|
clientId,
|
|
1864
1893
|
currency,
|
|
1865
|
-
utmMetadata
|
|
1894
|
+
utmMetadata,
|
|
1895
|
+
idempotencyKey
|
|
1866
1896
|
} = options;
|
|
1897
|
+
const resolvedIdempotencyKey = (0, import_shared7.resolveIdempotencyKey)(idempotencyKey);
|
|
1867
1898
|
if (couponCodes.length > MAX_COUPON_CODES) {
|
|
1868
1899
|
throw new import_shared7.FloPayError(
|
|
1869
1900
|
`Too many coupon codes \u2014 a checkout session accepts at most ${MAX_COUPON_CODES}.`,
|
|
@@ -1912,12 +1943,16 @@ async function createCheckoutSession(options) {
|
|
|
1912
1943
|
const url = `${billingApiUrl.replace(/\/+$/, "")}/v1/checkouts/sessions`;
|
|
1913
1944
|
const controller = new AbortController();
|
|
1914
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
|
+
}
|
|
1915
1950
|
let status;
|
|
1916
1951
|
let body;
|
|
1917
1952
|
try {
|
|
1918
1953
|
const response = await fetch(url, {
|
|
1919
1954
|
method: "POST",
|
|
1920
|
-
headers
|
|
1955
|
+
headers,
|
|
1921
1956
|
body: JSON.stringify(payload),
|
|
1922
1957
|
signal: controller.signal
|
|
1923
1958
|
});
|
|
@@ -1987,13 +2022,19 @@ async function createCheckoutSessionWithRetries(options) {
|
|
|
1987
2022
|
if (!Number.isFinite(maxRetries) || !Number.isInteger(maxRetries) || maxRetries <= 0) {
|
|
1988
2023
|
throw new Error("Number of retries must be greater than 0");
|
|
1989
2024
|
}
|
|
2025
|
+
const attemptOptions = {
|
|
2026
|
+
...sessionOptions,
|
|
2027
|
+
idempotencyKey: (0, import_shared7.resolveIdempotencyKey)(sessionOptions.idempotencyKey)
|
|
2028
|
+
};
|
|
1990
2029
|
let lastErr;
|
|
1991
2030
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
1992
2031
|
try {
|
|
1993
|
-
return await createCheckoutSession(
|
|
2032
|
+
return await createCheckoutSession(attemptOptions);
|
|
1994
2033
|
} catch (err) {
|
|
1995
2034
|
lastErr = err;
|
|
1996
|
-
|
|
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) {
|
|
1997
2038
|
await new Promise((r) => setTimeout(r, 100 * Math.pow(2, attempt)));
|
|
1998
2039
|
continue;
|
|
1999
2040
|
}
|