@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 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 response = await fetchWithNetworkRetry(
859
- `${this.baseUrl}/v1/checkouts/sessions/${encodeURIComponent(sessionId)}/account`,
860
- {
861
- method: "PATCH",
862
- headers: {
863
- "Content-Type": "application/json",
864
- "x-checkout-session-token": nonce
865
- },
866
- body: JSON.stringify(body)
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 response = await fetchWithNetworkRetry(
1008
- `${this.baseUrl}/v1/checkouts/sessions?expand=true`,
1009
- {
1010
- method: "POST",
1011
- // Declare the SDK version so backends at TeamFloPay/backend#823 embed
1012
- // the hosted vault capture block (`body.vault`) in the response for
1013
- // SDKs ≥ 1.3.0. Older backends ignore the header.
1014
- headers: {
1015
- "Content-Type": "application/json",
1016
- [import_shared3.FLO_SDK_VERSION_HEADER]: import_shared3.SDK_VERSION
1017
- },
1018
- body: JSON.stringify(payload)
1019
- }
1020
- );
1021
- if (response.status === 204) {
1022
- throw new import_shared3.FloPayError(
1023
- "Session auto-completed \u2014 payment method already on file",
1024
- "api_error",
1025
- { code: "session_auto_completed" }
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
- if (!response.ok) {
1029
- throw await buildApiErrorFromResponse(response, "Failed to create checkout session");
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: { "Content-Type": "application/json" },
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(sessionOptions);
2032
+ return await createCheckoutSession(attemptOptions);
1994
2033
  } catch (err) {
1995
2034
  lastErr = err;
1996
- 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) {
1997
2038
  await new Promise((r) => setTimeout(r, 100 * Math.pow(2, attempt)));
1998
2039
  continue;
1999
2040
  }