@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/dist/index.d.cts CHANGED
@@ -695,6 +695,13 @@ declare function createCheckoutSession(options: CreateSessionParams): Promise<Ch
695
695
  * Creates a checkout session with automatic retry on timeout/abort errors.
696
696
  *
697
697
  * Uses exponential backoff: 100ms, 200ms, 400ms, etc.
698
+ *
699
+ * The idempotency key (supplied or generated) is resolved **once**, before the
700
+ * retry loop, and reused for every attempt — so a timeout, a lost response, or
701
+ * a documented in-progress reply all replay the *same* key and cannot mint a
702
+ * second checkout session (TeamFloPay/backend#972). A later independent call
703
+ * resolves its own fresh key. A payload-conflict (`409`) is surfaced without
704
+ * retrying, since only the exact same request may safely replay a key.
698
705
  */
699
706
  declare function createCheckoutSessionWithRetries(options: CreateSessionParams & {
700
707
  maxRetries?: number;
package/dist/index.d.ts CHANGED
@@ -695,6 +695,13 @@ declare function createCheckoutSession(options: CreateSessionParams): Promise<Ch
695
695
  * Creates a checkout session with automatic retry on timeout/abort errors.
696
696
  *
697
697
  * Uses exponential backoff: 100ms, 200ms, 400ms, etc.
698
+ *
699
+ * The idempotency key (supplied or generated) is resolved **once**, before the
700
+ * retry loop, and reused for every attempt — so a timeout, a lost response, or
701
+ * a documented in-progress reply all replay the *same* key and cannot mint a
702
+ * second checkout session (TeamFloPay/backend#972). A later independent call
703
+ * resolves its own fresh key. A payload-conflict (`409`) is surfaced without
704
+ * retrying, since only the exact same request may safely replay a key.
698
705
  */
699
706
  declare function createCheckoutSessionWithRetries(options: CreateSessionParams & {
700
707
  maxRetries?: number;
package/dist/index.mjs CHANGED
@@ -517,8 +517,11 @@ import {
517
517
  FloPayError as FloPayError3,
518
518
  SDK_VERSION,
519
519
  FLO_SDK_VERSION_HEADER,
520
+ IDEMPOTENCY_KEY_HEADER,
521
+ IDEMPOTENCY_IN_PROGRESS_CODE,
520
522
  buildProductPayload,
521
523
  foldIntoProducts,
524
+ resolveIdempotencyKey,
522
525
  resolveSessionCurrency
523
526
  } from "@flopay/shared";
524
527
 
@@ -635,6 +638,7 @@ async function buildApiErrorFromResponse(response, fallbackMessage) {
635
638
  });
636
639
  }
637
640
  var NETWORK_RETRY_ATTEMPTS = 2;
641
+ var IDEMPOTENCY_IN_PROGRESS_RETRY_ATTEMPTS = 2;
638
642
  async function fetchWithNetworkRetry(input, init, attempts = NETWORK_RETRY_ATTEMPTS) {
639
643
  let lastErr;
640
644
  for (let attempt = 0; ; attempt++) {
@@ -981,29 +985,41 @@ var PaymentAPI = class {
981
985
  if (params.checkoutType) payload["checkoutType"] = params.checkoutType;
982
986
  if (params.checkoutLayout) payload["checkoutLayout"] = params.checkoutLayout;
983
987
  if (params.avsConfig) payload["avsConfig"] = params.avsConfig;
984
- const response = await fetchWithNetworkRetry(
985
- `${this.baseUrl}/v1/checkouts/sessions?expand=true`,
986
- {
987
- method: "POST",
988
- // Declare the SDK version so backends at TeamFloPay/backend#823 embed
989
- // the hosted vault capture block (`body.vault`) in the response for
990
- // SDKs ≥ 1.3.0. Older backends ignore the header.
991
- headers: {
992
- "Content-Type": "application/json",
993
- [FLO_SDK_VERSION_HEADER]: SDK_VERSION
994
- },
995
- body: JSON.stringify(payload)
996
- }
997
- );
998
- if (response.status === 204) {
999
- throw new FloPayError3(
1000
- "Session auto-completed \u2014 payment method already on file",
1001
- "api_error",
1002
- { code: "session_auto_completed" }
1003
- );
988
+ const headers = {
989
+ "Content-Type": "application/json",
990
+ // Declare the SDK version so backends at TeamFloPay/backend#823 embed
991
+ // the hosted vault capture block (`body.vault`) in the response for
992
+ // SDKs 1.3.0. Older backends ignore the header.
993
+ [FLO_SDK_VERSION_HEADER]: SDK_VERSION
994
+ };
995
+ const idempotencyKey = resolveIdempotencyKey(params.idempotencyKey);
996
+ if (idempotencyKey) {
997
+ headers[IDEMPOTENCY_KEY_HEADER] = idempotencyKey;
1004
998
  }
1005
- if (!response.ok) {
1006
- throw await buildApiErrorFromResponse(response, "Failed to create checkout session");
999
+ let response;
1000
+ for (let attempt = 0; ; attempt++) {
1001
+ response = await fetchWithNetworkRetry(
1002
+ `${this.baseUrl}/v1/checkouts/sessions?expand=true`,
1003
+ {
1004
+ method: "POST",
1005
+ headers,
1006
+ body: JSON.stringify(payload)
1007
+ }
1008
+ );
1009
+ if (response.status === 204) {
1010
+ throw new FloPayError3(
1011
+ "Session auto-completed \u2014 payment method already on file",
1012
+ "api_error",
1013
+ { code: "session_auto_completed" }
1014
+ );
1015
+ }
1016
+ if (response.ok) break;
1017
+ const error = await buildApiErrorFromResponse(response, "Failed to create checkout session");
1018
+ if (error.code === IDEMPOTENCY_IN_PROGRESS_CODE && attempt < IDEMPOTENCY_IN_PROGRESS_RETRY_ATTEMPTS) {
1019
+ await delay(150 * 2 ** attempt);
1020
+ continue;
1021
+ }
1022
+ throw error;
1007
1023
  }
1008
1024
  const body = await response.json();
1009
1025
  if (body.data && "gateways" in body.data) {
@@ -1802,9 +1818,12 @@ async function loadFloPay(publishableKey, options) {
1802
1818
  // src/create-checkout-session.ts
1803
1819
  import {
1804
1820
  FloPayError as FloPayError7,
1821
+ IDEMPOTENCY_IN_PROGRESS_CODE as IDEMPOTENCY_IN_PROGRESS_CODE2,
1822
+ IDEMPOTENCY_KEY_HEADER as IDEMPOTENCY_KEY_HEADER2,
1805
1823
  SDK_VERSION as SDK_VERSION2,
1806
1824
  buildProductPayload as buildProductPayload2,
1807
1825
  foldIntoProducts as foldIntoProducts2,
1826
+ resolveIdempotencyKey as resolveIdempotencyKey2,
1808
1827
  resolveSessionCurrency as resolveSessionCurrency2
1809
1828
  } from "@flopay/shared";
1810
1829
  var MAX_COUPON_CODES = 5;
@@ -1845,8 +1864,10 @@ async function createCheckoutSession(options) {
1845
1864
  timeoutMs = 12e3,
1846
1865
  clientId,
1847
1866
  currency,
1848
- utmMetadata
1867
+ utmMetadata,
1868
+ idempotencyKey
1849
1869
  } = options;
1870
+ const resolvedIdempotencyKey = resolveIdempotencyKey2(idempotencyKey);
1850
1871
  if (couponCodes.length > MAX_COUPON_CODES) {
1851
1872
  throw new FloPayError7(
1852
1873
  `Too many coupon codes \u2014 a checkout session accepts at most ${MAX_COUPON_CODES}.`,
@@ -1895,12 +1916,16 @@ async function createCheckoutSession(options) {
1895
1916
  const url = `${billingApiUrl.replace(/\/+$/, "")}/v1/checkouts/sessions`;
1896
1917
  const controller = new AbortController();
1897
1918
  const timer = setTimeout(() => controller.abort(), timeoutMs);
1919
+ const headers = { "Content-Type": "application/json" };
1920
+ if (resolvedIdempotencyKey) {
1921
+ headers[IDEMPOTENCY_KEY_HEADER2] = resolvedIdempotencyKey;
1922
+ }
1898
1923
  let status;
1899
1924
  let body;
1900
1925
  try {
1901
1926
  const response = await fetch(url, {
1902
1927
  method: "POST",
1903
- headers: { "Content-Type": "application/json" },
1928
+ headers,
1904
1929
  body: JSON.stringify(payload),
1905
1930
  signal: controller.signal
1906
1931
  });
@@ -1970,13 +1995,19 @@ async function createCheckoutSessionWithRetries(options) {
1970
1995
  if (!Number.isFinite(maxRetries) || !Number.isInteger(maxRetries) || maxRetries <= 0) {
1971
1996
  throw new Error("Number of retries must be greater than 0");
1972
1997
  }
1998
+ const attemptOptions = {
1999
+ ...sessionOptions,
2000
+ idempotencyKey: resolveIdempotencyKey2(sessionOptions.idempotencyKey)
2001
+ };
1973
2002
  let lastErr;
1974
2003
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
1975
2004
  try {
1976
- return await createCheckoutSession(sessionOptions);
2005
+ return await createCheckoutSession(attemptOptions);
1977
2006
  } catch (err) {
1978
2007
  lastErr = err;
1979
- if (err instanceof Error && err.name === "AbortError" && attempt < maxRetries) {
2008
+ const isTransportAbort = err instanceof Error && err.name === "AbortError";
2009
+ const isInProgressReplay = err instanceof FloPayError7 && err.code === IDEMPOTENCY_IN_PROGRESS_CODE2;
2010
+ if ((isTransportAbort || isInProgressReplay) && attempt < maxRetries) {
1980
2011
  await new Promise((r) => setTimeout(r, 100 * Math.pow(2, attempt)));
1981
2012
  continue;
1982
2013
  }