All files / src/features/Settlements/ReconciliationHub useHubFilters.ts

100% Statements 32/32
100% Branches 10/10
100% Functions 14/14
100% Lines 26/26

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                                                            8x             8x           8x 183x               17x                 8x       145x   145x   15x         1x         1x         5x     123x                   8x             52x                     52x   4x       52x 9x       52x             8x               52x   4x       52x 21x       52x                  
import moment from "moment";
import { useCallback, useMemo, useState } from "react";
import { CANARY_TZ } from "@utils/date.helpers";
 
/**
 * UI period tabs for the Reconciliation Hub. These are a frontend convenience
 * only — the backend takes explicit startDate/endDate, so the selected tab +
 * anchor are resolved into a date range via {@link resolvePeriodRange} before
 * the data hooks send them.
 */
export type HubPeriodType = "day" | "month" | "quarter" | "yearly" | "all_time";
 
export interface HubFilters {
  periodType: HubPeriodType;
  /** Anchor date in ISO `YYYY-MM-DD`; the period is resolved relative to it. */
  anchorDate: string;
  /** Processor name (`all` for parent rows, or a specific processor), or
   * {@link UNRESOLVED_PROCESSOR} until HubProcessorDropdown resolves it from
   * the acquirer's default. The data hooks stay disabled while unresolved. */
  processorName: string;
  /** Optional submerchant scope; omitted = all merchants in the acquirer. */
  merchantAccId?: number;
}
 
export interface HubPeriodRange {
  /** Omitted for `all_time` (open lower bound). */
  startDate?: string;
  endDate: string;
}
 
const ISO = "YYYY-MM-DD";
 
/**
 * Initial `processorName`: no processor has been resolved yet. Guessing one
 * here would query the wrong acquirer's processor (GB-21646) — the hub instead
 * waits for HubProcessorDropdown to resolve the acquirer's default.
 */
export const UNRESOLVED_PROCESSOR = "";
 
/**
 * Whether a processor has been resolved yet. Every hub request is
 * processor-scoped, so all of them stay disabled until this is true.
 */
export const isProcessorResolved = (filters: HubFilters) =>
  filters.processorName !== UNRESOLVED_PROCESSOR;
 
/**
 * "Today" in the app's canonical (Canary) timezone, as ISO `YYYY-MM-DD`. The
 * backend resolves day/period bounds in this timezone, so the default anchor
 * must be derived from it too — using the browser's local time would let a user
 * near midnight in another timezone request the adjacent day.
 */
export const canaryToday = () => moment().tz(CANARY_TZ).format(ISO);
 
/**
 * Pure helper turning the selected period tab + anchor into the explicit
 * `[startDate, endDate]` the backend expects (the FE analog of the backend's
 * former `ResolvePeriod`). `day` → that day; `month`/`quarter`/`yearly` → the
 * calendar bounds; `all_time` → omit `startDate` (open lower bound) with
 * `endDate = anchor`.
 */
export const resolvePeriodRange = (
  periodType: HubPeriodType,
  anchorDate: string,
): HubPeriodRange => {
  const anchor = moment(anchorDate, ISO);
 
  switch (periodType) {
    case "month":
      return {
        startDate: anchor.clone().startOf("month").format(ISO),
        endDate: anchor.clone().endOf("month").format(ISO),
      };
    case "quarter":
      return {
        startDate: anchor.clone().startOf("quarter").format(ISO),
        endDate: anchor.clone().endOf("quarter").format(ISO),
      };
    case "yearly":
      return {
        startDate: anchor.clone().startOf("year").format(ISO),
        endDate: anchor.clone().endOf("year").format(ISO),
      };
    case "all_time":
      return { startDate: undefined, endDate: anchor.format(ISO) };
    case "day":
    default:
      return { startDate: anchor.format(ISO), endDate: anchor.format(ISO) };
  }
};
 
/**
 * Shared filter state for the Reconciliation Hub. Holds the period tab, anchor
 * date, processor, and (optional) merchant scope, and exposes the derived
 * `periodRange` consumed by the data hooks. Defaults: today / Day / no
 * processor until HubProcessorDropdown resolves the acquirer's default.
 */
export const useHubFilters = (options?: {
  /** Seed the anchor date (ISO `YYYY-MM-DD`); defaults to today. Used when the
   * hub is opened for a specific settlement date picked from the listing. */
  initialAnchorDate?: string;
  /** Seed the period tab; defaults to `day`. */
  initialPeriodType?: HubPeriodType;
}) => {
  const [filters, setFilters] = useState<HubFilters>(() => ({
    periodType: options?.initialPeriodType ?? "day",
    anchorDate: options?.initialAnchorDate ?? canaryToday(),
    processorName: UNRESOLVED_PROCESSOR,
  }));
 
  // Period/anchor changes deliberately KEEP the merchant scope: stepping the
  // date or switching tabs is routine navigation, and a merchant with no
  // activity in the new window just shows zeros — still truthfully labeled,
  // because HubMerchantDropdown retains the last-known selection label even
  // when the re-scoped option list omits the merchant.
  const setPeriodType = useCallback(
    (periodType: HubPeriodType) =>
      setFilters((prev) => ({ ...prev, periodType })),
    [],
  );
 
  const setAnchorDate = useCallback(
    (anchorDate: string) => setFilters((prev) => ({ ...prev, anchorDate })),
    [],
  );
 
  const setProcessor = useCallback(
    (processorName: string) =>
      // The processor is the one scope change that must clear the merchant:
      // the merchant list is processor-scoped, so a merchant selected under the
      // previous processor is semantically stale under the new one and would
      // produce an empty/incorrect processor+merchant combination (e.g. a
      // Worldpay merchant + RS2 processor).
      setFilters((prev) => ({
        ...prev,
        processorName,
        merchantAccId: undefined,
      })),
    [],
  );
 
  const setMerchant = useCallback(
    (merchantAccId?: number) =>
      setFilters((prev) => ({ ...prev, merchantAccId })),
    [],
  );
 
  const periodRange = useMemo(
    () => resolvePeriodRange(filters.periodType, filters.anchorDate),
    [filters.periodType, filters.anchorDate],
  );
 
  return {
    filters,
    periodRange,
    setPeriodType,
    setAnchorDate,
    setProcessor,
    setMerchant,
  };
};