All files / src/services/api index.ts

67.32% Statements 103/153
62.8% Branches 76/121
61.9% Functions 13/21
67.1% Lines 102/152

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431                                                                  533x 533x 533x   533x       533x 150x 516x 1x     515x 8x     507x 161x 161x     358x   161x     346x     150x 150x     150x 150x 8964x 8964x     150x     533x 154x 2x   152x 152x     533x                                   533x 533x           533x       533x                       533x 533x   533x 533x   533x   4341x     4341x   146967x       146967x   593x   146967x 7456x   7456x 148x       148x   148x     148x 148x 141x         7x 7x     7x               146967x         4341x 4341x   90487x       55991x 165x       165x       55826x 1x       1x   55825x     55825x       55825x                                                     55825x                   55825x           25x 25x     55800x                   55800x                                           55800x           55800x               77x   55800x                                                                                                                   55800x         4341x 4341x     2673x     4341x     4341x     4341x 4341x 4341x   1655x   4328x         4341x       533x     4341x 4341x   4341x   4341x 4341x 4341x 110x   110x           4341x   4341x             4341x    
import { showMessage } from "@common/Toast";
import NiceModal from "@ebay/nice-modal-react";
import { isSessionExpired } from "@hooks/useSessionTimeoutWithWorker";
import * as Sentry from "@sentry/react";
import { safeParse } from "@utils/index";
import { parsedQueryString } from "@utils/queryString";
import {
  default as Axios,
  default as axios,
  AxiosError,
  AxiosInstance,
  AxiosRequestConfig,
  AxiosResponse,
  CancelTokenSource,
} from "axios";
import Cookies from "js-cookie";
import {
  ACCESS_DENIED_POPUP_V2,
  SESSION_EXPIRED_MODAL,
} from "modals/modal_names";
import { cleanupAsyncExports } from "@hooks/transactions/useAsyncTransactionExport";
import {
  CAPTCHA_CHALLENGE_REQUIRED_CODE,
  PURCHASE_VERIFICATION_REQUIRED_CODE,
} from "@utils/errorHandling";
import { BASE_URL, RESEND_EMAIL_URL } from "./api.constant";
import { isExemptPath } from "./utils.api";
 
import {
  getGlobalGroupKey,
  shouldInjectWriteMethod,
} from "./GlobalGroupIdempotencyKey";
 
export const getCSRFtoken = () => {
  const token = document.head.querySelector('meta[name="csrf-token"]');
  Iif (token) {
    return token?.getAttribute("content") as string;
  } else return "";
};
 
// Hash function for data structures produces consistent hash regardless of key order or nesting
export const hashDataStructure = (data: any): string => {
  const normalize = (obj: any): any => {
    if (obj === null || obj === undefined) {
      return obj;
    }
 
    if (Array.isArray(obj)) {
      return obj.map(normalize);
    }
 
    if (typeof obj === "object" && obj.constructor === Object) {
      const sorted: any = {};
      Object.keys(obj)
        .sort()
        .forEach((key) => {
          sorted[key] = normalize(obj[key]);
        });
      return sorted;
    }
 
    return obj;
  };
 
  const normalized = normalize(data);
  const str = JSON.stringify(normalized);
 
  // Simple but effective hash function (djb2)
  let hash = 5381;
  for (let i = 0; i < str.length; i++) {
    hash = (hash << 5) + hash + str.charCodeAt(i);
    hash = hash & hash; // Convert to 32bit integer
  }
 
  return Math.abs(hash).toString(36);
};
 
export const payloadForIdempotencyHash = (data: unknown) => {
  if (!data || typeof data !== "object" || Array.isArray(data)) {
    return data;
  }
  const { captchaToken, ...rest } = data as Record<string, unknown>;
  return rest;
};
 
const csrf = getCSRFtoken();
 
/**
 * The idea here is to prevent A -> B -> A edge cases in which the same hash is being used for
 * non-adjacent requests.
 * A1 -> B -> A2 should result in A1 being different from A2.
 * So the algorithm is as follows:
 * if incoming key is equal to current registered key we just send adding a salt
 * if incoming key is differnt to current registered key we first increase the salt and we send the new key with new salt and incoming key gets registered as new base key
 *
 * A1 -> sent with Salt 1 -> A1-key-1
 * B -> sent with Salt 2 -> B2-key-2
 * A2 -> even though has same hash of A1 -> A2-key-3
 *
 * For adjacent requests flow would be like:
 * A1 -> sent with Salt 1 -> A1-key-1
 * A2 -> sent with Salt 1 (as incoming key and registered keys are equal) -> A2-key-1
 */
let salt = 0;
let currentBaseKey = "";
 
// Adjacent identical payloads reuse salt so a double-click shares one
// Idempotency-Key. Challenge retry is a new attempt with the same cart
// payload (captchaToken is stripped from the hash) — bump first or the
// BE replays the cached 400 for IDEMPOTENCY_KEY_TTL.
export const bumpIdempotencySalt = () => {
  salt++;
};
 
export const axiosInstance = Axios.create({
  baseURL: process.env.VITE_API_ENDPOINT,
  withCredentials: true,
  headers: {
    "Content-Type": "application/json",
    ...(csrf && {
      "X-CSRF-Token": csrf,
    }),
  },
});
 
// Custom SNACKBAR FOR NOTIFY MERCHANT GB-7582
const regexPattern = /^\/merchants\/\d+\/underwriting-challenges\/\d+\/notify$/;
const taskRegexPattern = /^\/merchants\/\d+\/tasks\/\d+$/;
 
export let isRedirecting = false;
let cancelTokenSource: CancelTokenSource | null = null;
 
export const customInstanceFactory = (axiosBuilder: AxiosInstance) => {
  // Add a request interceptor to cancel requests if already redirecting
  let retry = 0;
 
  // Request interceptor
  axiosBuilder.interceptors.request.use(
    (config) => {
      Iif (isRedirecting) {
        // Cancel the request if already redirecting
        return Promise.reject(new axios.Cancel("Redirecting to login"));
      }
      if (config.method?.toUpperCase() === "DELETE") {
        // if for example we are deleting somethign from the cart
        currentBaseKey = "";
      }
      if (shouldInjectWriteMethod(config.method)) {
        const key = getGlobalGroupKey();
 
        if (key) {
          config.headers = config.headers || {};
 
          // The idea is to treat the global key as wrapper so
          // if we have a key and payload, automatically hash the payload and append it
          if (config.data) {
            // 1. Check if current key is equal to incoming key
            const payloadHash = hashDataStructure(
              payloadForIdempotencyHash(config.data),
            );
            const constructedKey = `${key}-${payloadHash}`;
            if (currentBaseKey === constructedKey) {
              config.headers["Idempotency-Key"] = `${constructedKey}-${salt}`;
            } else {
              // 2. If the incoming key is different we increase salt
              // to avoid A -> B -> A
              // edge cases
              salt++;
              config.headers["Idempotency-Key"] = `${constructedKey}-${salt}`;
 
              // 3. We set current base key as the incoming key.
              currentBaseKey = constructedKey;
            }
          } else E{
            config.headers["Idempotency-Key"] = key;
          }
        }
      }
 
      return config;
    },
    (error) => Promise.reject(error),
  );
  // Add an interceptor to set the session token on all subsequent requests
  Eif (axiosBuilder?.interceptors) {
    axiosBuilder.interceptors.response.use(
      (response: AxiosResponse<any, any>) => {
        return response;
      },
      (error: AxiosError<any, any>) => {
        // Ignore canceled requests
        if (axios.isCancel(error)) {
          console.log(
            "[Sentry] Axios canceled request ignored:",
            error.message,
          );
          return Promise.resolve(null); // swallow canceled request
        }
 
        // Ignore aborted requests (ECONNABORTED)
        if (error.code === "ECONNABORTED") {
          console.log(
            "[Sentry] Axios aborted request ignored:",
            error.config?.url,
          );
          return Promise.resolve(null); // swallow aborted request
        }
        const { status, data, config } = error.response || {};
 
        const isBpsValidationError =
          /^(.+?) Value can't be lower than (\d+) BPS$/.test(
            data?.message ?? "",
          );
 
        Iif (
          status === 400 &&
          data?.message === "Invalid CSRF token session." &&
          config?.url &&
          ["signup", "signin"].includes(config.url.replaceAll("/", ""))
        ) {
          //clear the cookie only once
          if (retry === 0) {
            return axiosInstance({ url: "/cookies/clear", method: "POST" })
              .then(() => {
                if (++retry < 2) {
                  return axiosInstance(config)
                    .then((res) => {
                      retry = 0;
                      return res;
                    })
                    .catch(async () => {
                      return Promise.reject(error);
                    });
                }
              })
              .catch((err) => {
                return Promise.reject(err);
              });
          }
        }
 
        Iif (
          status === 403 &&
          data?.message === "You cannot select a closed merchant account."
        ) {
          if (window.location.pathname !== "/closed-account") {
            window.location.assign("/closed-account");
          }
          return Promise.reject(error);
        }
 
        if (
          status === 403 &&
          data?.code === "not_authorized" &&
          error.config?.method !== "get" &&
          Cookies.get("user")
        ) {
          NiceModal.show(ACCESS_DENIED_POPUP_V2);
          return Promise.reject(error);
        }
 
        Iif (
          status === 403 &&
          (data?.code === "not_authorized" || data?.code === "access_denied") &&
          error.config?.method === "get"
        ) {
          return Promise.reject({
            not_authorized: true,
          });
        }
 
        const hideErrorSnackbar = [
          regexPattern.test(config?.url as string),
          data?.message?.startsWith("Plaid login required"),
          config?.url?.includes("mastercard-match"),
          config?.url?.includes("sponsor-status"), //for sponsor approve we need to show warning snackbar, so we use different logic for error handling in the component itself
          config?.url?.includes("checkout") &&
            data?.message?.includes("email address needs to be verified"),
          // scoped like the gate above it: the checkout opens the confirm
          // modal, which states the hold; elsewhere this should still be heard
          config?.url?.includes("checkout") &&
            data?.code === PURCHASE_VERIFICATION_REQUIRED_CODE,
          // useResendEmail is the single reporter for this one: its callers
          // choose between a toast and an inline message, and both firing puts
          // the API's wording on top of whichever they chose.
          config?.url?.includes(RESEND_EMAIL_URL),
          data?.code === CAPTCHA_CHALLENGE_REQUIRED_CODE,
          (config?.url?.includes("underwriting-status") ||
            config?.url?.includes("sponsor-status")) &&
            data?.message?.includes("missing mandatory"), // when moving to sponsor there is a custom error handling showing a modal when missing mandatory fields
          taskRegexPattern.test(config?.url as string),
          config?.url?.includes("/owner-reassignments"), // ChangePAHModal surfaces 4xx field/non-field errors inline under the email input
        ];
        Iif (status === 400 && data?.message?.includes("hard-bounce")) {
          showMessage(
            "Error",
            "Email message sent was rejected because The email address might be incorrect or blocked.",
          );
        }
        if (
          status === 400 &&
          !hideErrorSnackbar.includes(true) &&
          !isBpsValidationError &&
          !data?.message?.includes(
            "The requested interval cannot be used with this variant",
          )
        ) {
          showMessage("Error", data.message);
        }
        Iif (
          status === 401 &&
          data?.code === "not_authenticated" &&
          window.location.pathname.replace("/", "") !== "signup" //during signup we have onboarding wich is depending on severals api calls which, for know reasons might fail with 401
        ) {
          if (isExemptPath(window.location.pathname)) {
            // allow Privacy and TOS pages to be viewed without login/session
            return Promise.reject(error);
          }
 
          // Check if session has actually expired (current time > session start time + 24 hours)
          const sessionActuallyExpired = isSessionExpired();
 
          if (!isRedirecting && location.search.includes("new_version=true")) {
            isRedirecting = true;
            cancelTokenSource = axios.CancelToken.source();
 
            Cookies.remove("transfer-verification");
            Cookies.remove("user");
            // queryClient.removeQueries();
            cleanupAsyncExports();
            setTimeout(() => {
              window.location.href = "/session-expired";
            }, 100);
          } else if (!isRedirecting && sessionActuallyExpired) {
            // Only show session expired modal if session has actually expired based on our 24-hour timer
            cancelTokenSource = axios.CancelToken.source();
 
            Cookies.remove("transfer-verification");
            Cookies.remove("user");
 
            localStorage.removeItem("session_start_time");
            sessionStorage.removeItem("last_activity_reset");
            cleanupAsyncExports();
 
            NiceModal.show(SESSION_EXPIRED_MODAL);
          } else if (!isRedirecting && !sessionActuallyExpired) {
            // 401 error but session hasn't expired - treat as regular authentication error
            // Don't show session expired modal, just redirect to login
            isRedirecting = true;
            cancelTokenSource = axios.CancelToken.source();
 
            Cookies.remove("transfer-verification");
            Cookies.remove("user");
 
            // Clear session data
            localStorage.removeItem("session_start_time");
            sessionStorage.removeItem("last_activity_reset");
            cleanupAsyncExports();
 
            // Redirect to login without session expired modal
            setTimeout(() => {
              window.location.href = "/login";
            }, 100);
          } else {
            return Promise.reject(new axios.Cancel("Redirecting to login"));
          }
        }
        return Promise.reject(error);
      },
    );
  }
 
  return function <T>(config: AxiosRequestConfig): Promise<T | null> {
    const promise: Promise<T | null> = axiosBuilder({
      ...config,
    }).then((response: AxiosResponse<T> | null) =>
      response ? response.data : null,
    );
 
    Sentry.startSpan(
      { op: "http.client", name: config.url ?? "manual-tracing" },
      async (span) => {
        span.setAttributes({
          "server.address": process.env.VITE_API_ENDPOINT,
        });
        span.setAttribute("http.request.method", config.method);
        try {
          await promise;
        } catch (error: any) {
          console.debug("API error:", error);
        } finally {
          span.end();
        }
      },
    );
 
    return promise;
  };
};
 
export const customInstance = (
  args: AxiosRequestConfig,
): Promise<any | null> => {
  const Cookie_Session = Cookies.get("user");
  const localMasquerade = localStorage.getItem("masquerade-mode");
  const masquerade =
    localMasquerade !== null ? safeParse(localMasquerade) : null;
 
  let str = "";
  try {
    if (Cookie_Session) {
      const user = safeParse(Cookie_Session);
 
      str = masquerade?.name ? `accID:${masquerade.id}` : `accID:${user?.id}`;
    }
  } catch (err) {
    console.log("cookie is either undefined or cannot be parsed");
  }
 
  const newUrl = parsedQueryString(`${BASE_URL}${args.url}`, str);
 
  Iif (newUrl && args.url) {
    return customInstanceFactory(axiosInstance)({
      ...args,
      url: newUrl,
    });
  }
 
  return customInstanceFactory(axiosInstance)(args);
};