@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 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 response = await fetchWithNetworkRetry(
1024
- `${this.baseUrl}/v1/checkouts/sessions?expand=true`,
1025
- {
1026
- method: "POST",
1027
- // Declare the SDK version so backends at TeamFloPay/backend#823 embed
1028
- // the hosted vault capture block (`body.vault`) in the response for
1029
- // SDKs ≥ 1.3.0. Older backends ignore the header.
1030
- headers: {
1031
- "Content-Type": "application/json",
1032
- [import_shared3.FLO_SDK_VERSION_HEADER]: import_shared3.SDK_VERSION
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
- if (!response.ok) {
1045
- throw await buildApiErrorFromResponse(response, "Failed to create checkout session");
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: { "Content-Type": "application/json" },
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(sessionOptions);
2032
+ return await createCheckoutSession(attemptOptions);
2010
2033
  } catch (err) {
2011
2034
  lastErr = err;
2012
- if (err instanceof Error && err.name === "AbortError" && attempt < maxRetries) {
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
  }