@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/dist/index.d.cts CHANGED
@@ -432,6 +432,9 @@ declare class PaymentAPI {
432
432
  };
433
433
  avsCheck?: boolean;
434
434
  avsConfig?: Record<string, unknown>;
435
+ }, options?: {
436
+ signal?: AbortSignal;
437
+ timeoutMs?: number;
435
438
  }): Promise<void>;
436
439
  /**
437
440
  * Create a PaymentIntent on the backend.
@@ -692,6 +695,13 @@ declare function createCheckoutSession(options: CreateSessionParams): Promise<Ch
692
695
  * Creates a checkout session with automatic retry on timeout/abort errors.
693
696
  *
694
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.
695
705
  */
696
706
  declare function createCheckoutSessionWithRetries(options: CreateSessionParams & {
697
707
  maxRetries?: number;
package/dist/index.d.ts CHANGED
@@ -432,6 +432,9 @@ declare class PaymentAPI {
432
432
  };
433
433
  avsCheck?: boolean;
434
434
  avsConfig?: Record<string, unknown>;
435
+ }, options?: {
436
+ signal?: AbortSignal;
437
+ timeoutMs?: number;
435
438
  }): Promise<void>;
436
439
  /**
437
440
  * Create a PaymentIntent on the backend.
@@ -692,6 +695,13 @@ declare function createCheckoutSession(options: CreateSessionParams): Promise<Ch
692
695
  * Creates a checkout session with automatic retry on timeout/abort errors.
693
696
  *
694
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.
695
705
  */
696
706
  declare function createCheckoutSessionWithRetries(options: CreateSessionParams & {
697
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
 
@@ -593,6 +596,7 @@ var DEFAULT_PROCESSING_RETRY_AFTER_MS = 1e3;
593
596
  var MIN_PROCESSING_RETRY_AFTER_MS = 500;
594
597
  var DEFAULT_PROCESSING_TIMEOUT_MS = 15e3;
595
598
  var MAX_PROCESSING_RETRY_AFTER_MS = 3e3;
599
+ var DEFAULT_ACCOUNT_SNAPSHOT_TIMEOUT_MS = 1e4;
596
600
  function isRecord(value) {
597
601
  return typeof value === "object" && value !== null;
598
602
  }
@@ -634,6 +638,7 @@ async function buildApiErrorFromResponse(response, fallbackMessage) {
634
638
  });
635
639
  }
636
640
  var NETWORK_RETRY_ATTEMPTS = 2;
641
+ var IDEMPOTENCY_IN_PROGRESS_RETRY_ATTEMPTS = 2;
637
642
  async function fetchWithNetworkRetry(input, init, attempts = NETWORK_RETRY_ATTEMPTS) {
638
643
  let lastErr;
639
644
  for (let attempt = 0; ; attempt++) {
@@ -815,18 +820,33 @@ var PaymentAPI = class {
815
820
  * pre-pay PATCH would silently leave AVS unsent and cause an
816
821
  * AVS-protected charge to decline downstream.
817
822
  */
818
- async patchAccountSnapshot(sessionId, nonce, body) {
819
- const response = await fetchWithNetworkRetry(
820
- `${this.baseUrl}/v1/checkouts/sessions/${encodeURIComponent(sessionId)}/account`,
821
- {
822
- method: "PATCH",
823
- headers: {
824
- "Content-Type": "application/json",
825
- "x-checkout-session-token": nonce
826
- },
827
- body: JSON.stringify(body)
828
- }
829
- );
823
+ async patchAccountSnapshot(sessionId, nonce, body, options) {
824
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_ACCOUNT_SNAPSHOT_TIMEOUT_MS;
825
+ const controller = new AbortController();
826
+ const onCallerAbort = () => controller.abort();
827
+ if (options?.signal) {
828
+ if (options.signal.aborted) controller.abort();
829
+ else options.signal.addEventListener("abort", onCallerAbort, { once: true });
830
+ }
831
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
832
+ let response;
833
+ try {
834
+ response = await fetchWithNetworkRetry(
835
+ `${this.baseUrl}/v1/checkouts/sessions/${encodeURIComponent(sessionId)}/account`,
836
+ {
837
+ method: "PATCH",
838
+ headers: {
839
+ "Content-Type": "application/json",
840
+ "x-checkout-session-token": nonce
841
+ },
842
+ body: JSON.stringify(body),
843
+ signal: controller.signal
844
+ }
845
+ );
846
+ } finally {
847
+ clearTimeout(timer);
848
+ options?.signal?.removeEventListener("abort", onCallerAbort);
849
+ }
830
850
  if (!response.ok) {
831
851
  throw await buildApiErrorFromResponse(response, "Failed to persist account snapshot");
832
852
  }
@@ -965,29 +985,41 @@ var PaymentAPI = class {
965
985
  if (params.checkoutType) payload["checkoutType"] = params.checkoutType;
966
986
  if (params.checkoutLayout) payload["checkoutLayout"] = params.checkoutLayout;
967
987
  if (params.avsConfig) payload["avsConfig"] = params.avsConfig;
968
- const response = await fetchWithNetworkRetry(
969
- `${this.baseUrl}/v1/checkouts/sessions?expand=true`,
970
- {
971
- method: "POST",
972
- // Declare the SDK version so backends at TeamFloPay/backend#823 embed
973
- // the hosted vault capture block (`body.vault`) in the response for
974
- // SDKs ≥ 1.3.0. Older backends ignore the header.
975
- headers: {
976
- "Content-Type": "application/json",
977
- [FLO_SDK_VERSION_HEADER]: SDK_VERSION
978
- },
979
- body: JSON.stringify(payload)
980
- }
981
- );
982
- if (response.status === 204) {
983
- throw new FloPayError3(
984
- "Session auto-completed \u2014 payment method already on file",
985
- "api_error",
986
- { code: "session_auto_completed" }
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;
998
+ }
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
+ }
987
1008
  );
988
- }
989
- if (!response.ok) {
990
- throw await buildApiErrorFromResponse(response, "Failed to create checkout session");
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;
991
1023
  }
992
1024
  const body = await response.json();
993
1025
  if (body.data && "gateways" in body.data) {
@@ -1786,9 +1818,12 @@ async function loadFloPay(publishableKey, options) {
1786
1818
  // src/create-checkout-session.ts
1787
1819
  import {
1788
1820
  FloPayError as FloPayError7,
1821
+ IDEMPOTENCY_IN_PROGRESS_CODE as IDEMPOTENCY_IN_PROGRESS_CODE2,
1822
+ IDEMPOTENCY_KEY_HEADER as IDEMPOTENCY_KEY_HEADER2,
1789
1823
  SDK_VERSION as SDK_VERSION2,
1790
1824
  buildProductPayload as buildProductPayload2,
1791
1825
  foldIntoProducts as foldIntoProducts2,
1826
+ resolveIdempotencyKey as resolveIdempotencyKey2,
1792
1827
  resolveSessionCurrency as resolveSessionCurrency2
1793
1828
  } from "@flopay/shared";
1794
1829
  var MAX_COUPON_CODES = 5;
@@ -1829,8 +1864,10 @@ async function createCheckoutSession(options) {
1829
1864
  timeoutMs = 12e3,
1830
1865
  clientId,
1831
1866
  currency,
1832
- utmMetadata
1867
+ utmMetadata,
1868
+ idempotencyKey
1833
1869
  } = options;
1870
+ const resolvedIdempotencyKey = resolveIdempotencyKey2(idempotencyKey);
1834
1871
  if (couponCodes.length > MAX_COUPON_CODES) {
1835
1872
  throw new FloPayError7(
1836
1873
  `Too many coupon codes \u2014 a checkout session accepts at most ${MAX_COUPON_CODES}.`,
@@ -1879,12 +1916,16 @@ async function createCheckoutSession(options) {
1879
1916
  const url = `${billingApiUrl.replace(/\/+$/, "")}/v1/checkouts/sessions`;
1880
1917
  const controller = new AbortController();
1881
1918
  const timer = setTimeout(() => controller.abort(), timeoutMs);
1919
+ const headers = { "Content-Type": "application/json" };
1920
+ if (resolvedIdempotencyKey) {
1921
+ headers[IDEMPOTENCY_KEY_HEADER2] = resolvedIdempotencyKey;
1922
+ }
1882
1923
  let status;
1883
1924
  let body;
1884
1925
  try {
1885
1926
  const response = await fetch(url, {
1886
1927
  method: "POST",
1887
- headers: { "Content-Type": "application/json" },
1928
+ headers,
1888
1929
  body: JSON.stringify(payload),
1889
1930
  signal: controller.signal
1890
1931
  });
@@ -1954,13 +1995,19 @@ async function createCheckoutSessionWithRetries(options) {
1954
1995
  if (!Number.isFinite(maxRetries) || !Number.isInteger(maxRetries) || maxRetries <= 0) {
1955
1996
  throw new Error("Number of retries must be greater than 0");
1956
1997
  }
1998
+ const attemptOptions = {
1999
+ ...sessionOptions,
2000
+ idempotencyKey: resolveIdempotencyKey2(sessionOptions.idempotencyKey)
2001
+ };
1957
2002
  let lastErr;
1958
2003
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
1959
2004
  try {
1960
- return await createCheckoutSession(sessionOptions);
2005
+ return await createCheckoutSession(attemptOptions);
1961
2006
  } catch (err) {
1962
2007
  lastErr = err;
1963
- 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) {
1964
2011
  await new Promise((r) => setTimeout(r, 100 * Math.pow(2, attempt)));
1965
2012
  continue;
1966
2013
  }