Finance-Accounts/ar-aging-app/frontend/src/api/client.ts

649 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

const BASE = "/api";
const TOKEN_KEY = "ar_token";
export const getToken = () => localStorage.getItem(TOKEN_KEY);
export const setToken = (t: string) => localStorage.setItem(TOKEN_KEY, t);
export const clearToken = () => localStorage.removeItem(TOKEN_KEY);
/** Set by the auth provider: called on a 401 so the app can drop to the login screen. */
export let onUnauthorized: (() => void) | null = null;
export const setOnUnauthorized = (fn: (() => void) | null) => { onUnauthorized = fn; };
async function req<T>(path: string, opts: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {};
if (opts.body && !(opts.body instanceof FormData)) headers["Content-Type"] = "application/json";
const token = getToken();
if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(`${BASE}${path}`, { headers, ...opts });
if (!res.ok) {
let detail = res.statusText;
try {
detail = (await res.json()).detail ?? detail;
} catch {
/* ignore */
}
if (res.status === 401 && !path.startsWith("/auth/")) {
clearToken();
onUnauthorized?.();
}
throw new Error(detail);
}
const ct = res.headers.get("content-type") ?? "";
return ct.includes("json") ? res.json() : (undefined as unknown as T);
}
// ---- Types -------------------------------------------------------------
export interface SessionT {
id: number;
name: string;
reporting_month: string | null;
month_end_date: string | null;
reporting_currency: string;
clearing_lag_days: number;
rounding_tolerance: number;
allowance_for_returns: number;
manual_adjustment: number;
opening_mode?: string;
opening_source_session_id?: number | null;
status: string;
progress_stage: string;
progress_pct: number;
progress_rows_done: number;
progress_rows_total: number;
eta_seconds: number;
error: string;
created_at: string;
updated_at: string;
/** A month-end control failed with error severity — no receivable figure is published. */
blocked?: boolean;
blocked_reason?: string;
/** auto = bank-receipt date wins, clearing-lag fallback · manual = bank dates only. */
payout_mode?: string;
/** Bank receipts / payout mode changed after the last run — re-process to apply. */
needs_reprocess?: boolean;
/** Journal approved = published to the Accounts Summary (list endpoint only). */
journal_approved?: boolean;
/** Another closing exists for the same reporting month (list endpoint only). */
duplicate_month?: boolean;
}
export interface UploadResultT {
files: FileT[];
skipped: { filename: string; reason: string }[];
}
export interface AuthUserT {
username: string;
display_name: string;
}
export interface PayoutT {
marketplace: string;
account_type: string;
settlement_id: string;
amazon_date: string | null;
amount: number;
rows: number;
bank_date: string | null;
bank_amount: number | null;
note: string;
entered_by: string;
received_now: boolean | null;
received_next_run: boolean;
}
export interface PayoutsT {
payout_mode: string;
clearing_lag_days: number;
month_end: string | null;
needs_reprocess: boolean;
payouts: PayoutT[];
}
/** One month-end control (core/controls.py). Distinct from ControlRowT, which is a row of
* the Finance reconciliation control sheet. */
export interface MonthEndControlT {
key: string;
label: string;
status: "pass" | "fail" | "not_applicable";
severity: "error" | "warning" | "info";
detail: string;
evidence: string[];
checked_at: string | null;
}
export interface ControlsT {
available: boolean;
controls: MonthEndControlT[];
blocked: boolean;
blocked_reason: string;
passed: number;
failed: number;
total: number;
confirmed?: number;
}
export interface FileT {
id: number;
filename: string;
size_bytes: number;
sha256: string;
data_sheet: string | null;
imported_rows: number;
min_date: string | null;
max_date: string | null;
currency: string | null;
marketplace: string | null;
status: string;
message: string;
worksheets: string[];
}
/**
* Endpoints that publish a receivable figure return this instead when a month-end control
* has failed: `available:false, blocked:true` and NO numbers. Every consumer must check
* `blocked` before reading a figure — the fields below are absent in that case.
*/
export interface BlockableT {
blocked?: boolean;
blocked_reason?: string;
available?: boolean;
}
export interface SummaryT extends BlockableT {
closing_receivable_usd: number | null;
reconciliation_status: string | null;
reserve_total: number;
transfers_total: number;
receivable_orders: number;
paid_orders: number;
num_settlements: number;
num_receivable_settlements: number;
num_paid_settlements: number;
num_transactions: number;
num_receivable_transactions: number;
exceptions_by_severity: Record<string, number>;
receivable_by_marketplace: {
marketplace: string;
receivable_local: number;
receivable_usd: number;
currency: string;
}[];
}
export interface SettlementT {
marketplace: string;
account_type: string;
settlement_id: string;
order_total: number;
transfer_total: number;
transfer_amount: number | null;
transfer_date: string | null;
transfer_received: boolean | null;
row_count: number;
first_date: string | null;
last_date: string | null;
status: string;
}
export interface TxnT {
id: number;
source_file: string;
source_row: number;
marketplace: string;
settlement_id: string;
order_id: string | null;
sku: string | null;
txn_type: string;
account_type: string;
posted_date: string | null;
total: number;
currency: string;
settlement_status: string | null;
receivable_flag: boolean;
}
export interface ReconT {
uploaded_total: number;
receivable_orders: number;
paid_orders: number;
transfers_total: number;
reserve_total: number;
manual_adjustments: number;
final_receivable_usd: number;
identity_difference: number;
status: string;
notes: string[];
}
export interface ExceptionT {
category: string;
severity: string;
detail: string;
source: string;
}
export interface ReceivableRowT {
marketplace: string;
account_type: string;
additional_sales: number;
reserve: number;
receivable_local: number;
fx_rate: number;
receivable_usd: number;
currency: string;
}
export interface JournalLineT {
key: string;
gl_account: string;
values: number[];
total: number;
}
export interface JournalT {
available: boolean;
entry_no?: string;
marketplace?: string;
marketplaces?: string[];
periods?: { key: string; label: string; min_date: string | null; max_date: string | null }[];
lines?: JournalLineT[];
receivable?: JournalLineT;
/** Balancing figure of the accrual entry (Transfer excluded): Dr A/R by net revenue. */
receivable_accrual?: JournalLineT;
reviewed_by?: string;
reviewed_at?: string | null;
approved_by?: string;
approved_at?: string | null;
}
export interface AccountsSummaryT {
available: boolean;
line_keys: string[];
receivable_key: string;
months: {
month: string; session_id: number; session_name: string;
reviewed_by: string; approved_by: string; approved_at: string | null; entry_no: string;
}[];
marketplaces: string[];
cells: {
month: string; session_id: number; marketplace: string; currency: string; fx_rate: number;
values: Record<string, number>; receivable: number;
}[];
/** Months with results that are NOT published (unapproved / blocked), with the reason —
* so a month never silently vanishes from this view. */
pending?: { month: string; session_id: number; session_name: string; reason: string }[];
}
export interface ComponentT {
key: string;
label: string;
group: "revenue" | "fee" | "payout" | "memo";
values: number[];
total: number;
}
export interface FinanceSummaryT extends BlockableT {
available: boolean;
marketplace?: string;
marketplaces?: string[];
currency?: string;
reporting_month?: string;
month_end?: string;
period_labels?: string[];
opening_balance?: number;
components?: ComponentT[];
gross_revenue?: number;
net_revenue?: number;
disbursements?: number;
in_transit_payouts?: number;
closing_receivable?: number;
settlement_closing?: number | null;
finance_closing?: number | null;
difference?: number | null;
status?: string;
verified_by?: string;
verified_at?: string | null;
ledger?: LedgerRowT[];
}
export interface LedgerPeriodT {
key: string; label: string; revenue: number; payouts_received: number;
payouts_in_transit: number; rows: number; balance: number;
/** USD equivalents, converted at each transaction date's FX rate. */
revenue_usd: number; payouts_received_usd: number;
payouts_in_transit_usd: number; balance_usd: number;
}
export interface LedgerDetailT {
available: boolean;
marketplace?: string; marketplaces?: string[]; currency?: string;
granularity?: string; date_from?: string | null; date_to?: string | null;
opening?: number; periods?: LedgerPeriodT[]; closing?: number;
session_closing?: number; filtered?: boolean; in_transit_total?: number;
/** The marketplace month rate; the opening balance converts at this rate. */
month_rate?: number;
opening_usd?: number;
/** Roll-forward valued at transaction-date rates — differs from closing × month rate
* whenever daily overrides exist. */
closing_usd?: number;
in_transit_total_usd?: number;
}
export interface FxDailyRowT {
date: string; local: number; rate: number; usd: number;
source: string; rows: number; revenue: number; payouts: number;
}
export interface FxDailyT {
available: boolean;
marketplace?: string; marketplaces?: string[]; currency?: string;
month_rate?: number; rows?: FxDailyRowT[];
total_local?: number; total_usd?: number;
}
export interface ReconLineT {
key: string; label: string; amount: number;
effect: "increase" | "decrease" | "mixed" | "neutral"; note: string;
}
export interface ReconGroupT {
key: string; title: string; lines: ReconLineT[];
subtotal: number; subtotal_label: string;
}
export interface ReconDetailT {
available: boolean;
marketplace?: string; marketplaces?: string[]; currency?: string;
sign_legend?: Record<string, string>;
groups?: ReconGroupT[];
net_revenue?: number; opening?: number; closing?: number;
settlement_closing?: number | null; variance?: number | null; variance_status?: string;
}
export interface AllMarketRowT {
marketplace: string; currency: string; fx_rate: number;
opening: number; net_revenue: number; gross_revenue: number;
payouts_received: number; in_transit: number;
closing_local: number; closing_usd: number;
settlement_closing: number | null; settlement_closing_usd: number | null;
variance: number | null;
}
export interface AllMarketsT {
available: boolean;
markets?: AllMarketRowT[];
total?: {
currency: string; opening: number; gross_revenue: number; net_revenue: number;
payouts_received: number; in_transit: number; closing_usd: number;
settlement_closing_usd: number; variance: number;
};
}
export interface MappingRulesT {
fields: string[];
rules: { id: number; normalized_header: string; field: string }[];
}
export interface ControlRowT {
key: string;
label: string;
critical: boolean;
dashboard: number;
finance: number | null;
difference: number | null;
status: string;
}
export interface ControlT {
available: boolean;
tolerance?: number;
rows?: ControlRowT[];
verified_by?: string;
verified_at?: string | null;
comment?: string;
can_complete?: boolean;
completed?: boolean;
}
export interface OpeningCandidateT {
session_id: number;
name: string;
reporting_month: string | null;
month_end: string | null;
markets: { marketplace: string; amount: number; currency: string }[];
}
export interface OpeningCandidatesT {
current_mode: string;
current_source: number | null;
candidates: OpeningCandidateT[];
}
export interface OpeningBalanceT {
marketplace: string;
amount: number;
reason: string;
source: string;
}
export interface DefinitionT {
formula: string;
source: string;
note?: string;
}
export interface OpeningWorksheetRowT {
marketplace: string;
currency: string;
fx_rate: number;
opening: number;
source: string;
reason: string;
net_revenue: number;
payouts_received: number;
movement: number;
roll_forward_closing: number;
settlement_closing: number | null;
variance: number | null;
implied_opening: number | null;
reconciled: boolean;
}
export interface OpeningWorksheetT {
available: boolean;
reporting_month: string;
mode: string;
source_session_id: number | null;
rows: OpeningWorksheetRowT[];
all_zero: boolean;
unreconciled: number;
total_abs_variance_usd: number;
candidates: OpeningCandidateT[];
}
export interface LedgerRowT {
period: string;
description: string;
debit: number | null;
credit: number | null;
balance: number;
}
export interface ArMovementT {
available: boolean;
marketplace?: string;
marketplaces?: string[];
currency?: string;
opening?: number;
opening_source?: string;
opening_reason?: string;
gross_revenue?: number;
net_revenue?: number;
net_revenue_per_period?: number[];
total_receivable?: number;
received_payouts?: number;
all_payouts?: number;
in_transit_payouts?: number;
closing?: number;
settlement_closing?: number | null;
difference_vs_settlement?: number | null;
period_labels?: string[];
revenue_lines?: JournalLineT[];
ledger?: LedgerRowT[];
}
const q = (o: Record<string, string | undefined>) =>
Object.entries(o).filter(([, v]) => v).map(([k, v]) => `${k}=${encodeURIComponent(v!)}`).join("&");
// ---- Endpoints ---------------------------------------------------------
export const api = {
health: () => req<{ status: string; version: string }>("/health"),
authStatus: () => req<{ auth_required: boolean; email_enabled: boolean }>("/auth/status"),
login: (username: string, password: string) =>
req<{ token: string; user: AuthUserT }>("/auth/login", {
method: "POST", body: JSON.stringify({ username, password }),
}),
me: () => req<{ authenticated: boolean; username?: string; display_name?: string }>("/auth/me"),
changePassword: (current_password: string, new_password: string) =>
req<{ changed: boolean }>("/auth/change-password", {
method: "POST", body: JSON.stringify({ current_password, new_password }),
}),
requestPasswordCode: (username = "") =>
req<{ sent: boolean; detail: string }>("/auth/request-code", {
method: "POST", body: JSON.stringify({ username }),
}),
resetPassword: (code: string, new_password: string, username = "") =>
req<{ changed: boolean }>("/auth/reset-password", {
method: "POST", body: JSON.stringify({ username, code, new_password }),
}),
listSessions: () => req<SessionT[]>("/sessions"),
createSession: (body: Partial<SessionT> & { allow_duplicate?: boolean }) =>
req<SessionT>("/sessions", { method: "POST", body: JSON.stringify(body) }),
getSession: (id: number) => req<SessionT>(`/sessions/${id}`),
updateSession: (id: number, body: Partial<SessionT>) =>
req<SessionT>(`/sessions/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteSession: (id: number) => req<void>(`/sessions/${id}`, { method: "DELETE" }),
reopenSession: (id: number) => req<SessionT>(`/sessions/${id}/reopen`, { method: "POST" }),
listFiles: (id: number) => req<FileT[]>(`/sessions/${id}/files`),
uploadFiles: (id: number, files: File[]) => {
const fd = new FormData();
files.forEach((f) => fd.append("files", f));
return req<UploadResultT>(`/sessions/${id}/files`, { method: "POST", body: fd });
},
deleteFile: (id: number, fileId: number) =>
req<void>(`/sessions/${id}/files/${fileId}`, { method: "DELETE" }),
process: (id: number) => req<{ started: boolean }>(`/sessions/${id}/process`, { method: "POST" }),
status: (id: number) =>
req<{ status: string; stage: string; pct: number; error: string; session: SessionT }>(
`/sessions/${id}/status`
),
summary: (id: number) => req<SummaryT>(`/sessions/${id}/summary`),
receivable: (id: number) => req<ReceivableRowT[]>(`/sessions/${id}/receivable`),
settlements: (id: number) => req<SettlementT[]>(`/sessions/${id}/settlements`),
transactions: (id: number, params: Record<string, string | number | boolean | undefined>) => {
const q = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => v !== undefined && v !== "" && q.set(k, String(v)));
return req<{ total: number; limit: number; offset: number; rows: TxnT[] }>(
`/sessions/${id}/transactions?${q}`
);
},
exceptions: (id: number) => req<ExceptionT[]>(`/sessions/${id}/exceptions`),
mappingRules: () => req<MappingRulesT>("/mapping-rules"),
saveMappingRules: (items: { header: string; field: string }[]) =>
req<MappingRulesT>("/mapping-rules", { method: "PUT", body: JSON.stringify(items) }),
deleteMappingRule: (ruleId: number) =>
req<MappingRulesT>(`/mapping-rules/${ruleId}`, { method: "DELETE" }),
reconciliation: (id: number) => req<ReconT>(`/sessions/${id}/reconciliation`),
aging: (id: number) =>
req<BlockableT & { bands: string[]; rows: Record<string, number | string>[] }>(
`/sessions/${id}/aging`),
journal: (id: number, marketplace?: string) =>
req<JournalT>(`/sessions/${id}/journal${marketplace ? `?marketplace=${encodeURIComponent(marketplace)}` : ""}`),
reviewJournal: (id: number, name: string) =>
req<JournalT>(`/sessions/${id}/journal/review`, { method: "POST", body: JSON.stringify({ name }) }),
approveJournal: (id: number, name: string) =>
req<JournalT>(`/sessions/${id}/journal/approve`, { method: "POST", body: JSON.stringify({ name }) }),
resetJournalSignoff: (id: number) =>
req<JournalT>(`/sessions/${id}/journal/reset-signoff`, { method: "POST" }),
accountsSummary: () => req<AccountsSummaryT>("/accounts-summary"),
setJournalEntryNo: (id: number, entry_no: string) =>
req(`/sessions/${id}/journal/entry-no`, { method: "PUT", body: JSON.stringify({ entry_no }) }),
openings: (id: number) => req<OpeningBalanceT[]>(`/sessions/${id}/opening-balances`),
openingWorksheet: (id: number) =>
req<OpeningWorksheetT>(`/sessions/${id}/opening-balances/worksheet`),
putOpenings: (id: number, items: OpeningBalanceT[]) =>
req<OpeningBalanceT[]>(`/sessions/${id}/opening-balances`, { method: "PUT", body: JSON.stringify(items) }),
openingCandidates: (id: number) =>
req<OpeningCandidatesT>(`/sessions/${id}/opening-candidates`),
carryForwardOpenings: (id: number, from_session_id?: number) =>
req<{ applied: number; from: string; balances: OpeningBalanceT[] }>(
`/sessions/${id}/opening-balances/carry-forward`,
{ method: "POST", body: JSON.stringify({ from_session_id: from_session_id ?? null }) }),
resetOpenings: (id: number) =>
req<{ balances: OpeningBalanceT[] }>(`/sessions/${id}/opening-balances/reset`, { method: "POST" }),
arMovement: (id: number, marketplace?: string) =>
req<ArMovementT>(`/sessions/${id}/ar-movement${marketplace ? `?marketplace=${encodeURIComponent(marketplace)}` : ""}`),
ledgerDetail: (id: number, marketplace?: string, granularity = "day", from?: string, to?: string) =>
req<LedgerDetailT>(`/sessions/${id}/ledger-detail?${q({ marketplace, granularity, date_from: from, date_to: to })}`),
fxDaily: (id: number, marketplace?: string, from?: string, to?: string) =>
req<FxDailyT>(`/sessions/${id}/fx-daily?${q({ marketplace, date_from: from, date_to: to })}`),
putFxDaily: (id: number, items: { marketplace: string; rate_date: string; rate: number; source?: string }[]) =>
req<FxDailyT>(`/sessions/${id}/fx-daily`, { method: "PUT", body: JSON.stringify(items) }),
reconDetail: (id: number, marketplace?: string) =>
req<ReconDetailT>(`/sessions/${id}/reconciliation-detail?${q({ marketplace })}`),
allMarkets: (id: number) => req<AllMarketsT>(`/sessions/${id}/all-markets`),
control: (id: number) => req<ControlT>(`/sessions/${id}/reconciliation-control`),
putControl: (id: number, body: Record<string, number | string>) =>
req<ControlT>(`/sessions/${id}/reconciliation-control`, { method: "PUT", body: JSON.stringify(body) }),
verifyControl: (id: number, verified_by: string, comment: string) =>
req<ControlT>(`/sessions/${id}/reconciliation-control/verify`, {
method: "POST", body: JSON.stringify({ verified_by, comment }),
}),
completeSession: (id: number) => req<{ status: string }>(`/sessions/${id}/complete`, { method: "POST" }),
/** Formula + source for every dashboard figure — content of the (i) info buttons. */
definitions: () => req<Record<string, DefinitionT>>("/definitions"),
payouts: (id: number, marketplace?: string) =>
req<PayoutsT>(`/sessions/${id}/payouts${marketplace ? `?marketplace=${encodeURIComponent(marketplace)}` : ""}`),
putPayoutReceipts: (id: number, items: {
marketplace: string; account_type: string; settlement_id: string;
bank_date: string | null; bank_amount?: number | null; note?: string; entered_by?: string;
}[]) => req<{ saved: number; removed: number; needs_reprocess: boolean }>(
`/sessions/${id}/payouts/receipts`, { method: "PUT", body: JSON.stringify(items) }),
putPayoutMode: (id: number, mode: "auto" | "manual") =>
req<{ payout_mode: string; needs_reprocess: boolean }>(
`/sessions/${id}/payouts/mode`, { method: "PUT", body: JSON.stringify({ mode }) }),
controls: (id: number) => req<ControlsT>(`/sessions/${id}/controls`),
runControls: (id: number) => req<ControlsT>(`/sessions/${id}/controls/run`, { method: "POST" }),
confirmAllFx: (id: number, confirmed_by: string) =>
req<ControlsT>(`/sessions/${id}/fx/confirm-all`, {
method: "POST", body: JSON.stringify({ confirmed_by }),
}),
getReserves: (id: number) =>
req<{ marketplace: string; account_type: string; amount: number }[]>(`/sessions/${id}/reserves`),
putReserves: (id: number, items: { marketplace: string; account_type: string; amount: number }[]) =>
req(`/sessions/${id}/reserves`, { method: "PUT", body: JSON.stringify(items) }),
getFx: (id: number) =>
req<{ marketplace: string; currency: string; rate: number;
source: string; rate_date: string | null }[]>(`/sessions/${id}/fx`),
putFx: (id: number, items: { marketplace: string; currency: string; rate: number }[]) =>
req(`/sessions/${id}/fx`, { method: "PUT", body: JSON.stringify(items) }),
fetchFx: (id: number) =>
req<{ updated: { marketplace: string; currency: string; rate: number }[];
missing: string[]; source: string; rate_date: string }>(
`/sessions/${id}/fx/fetch`, { method: "POST" }),
fetchFxDaily: (id: number, body: { marketplace?: string; date_from?: string; date_to?: string } = {}) =>
req<{ saved: number; date_from: string; date_to: string; provider: string;
marketplaces: string[] }>(
`/sessions/${id}/fx/fetch-daily`, { method: "POST", body: JSON.stringify(body) }),
startExport: (id: number, kind: "full" | "summary" = "full") =>
req<{ started: boolean; kind: string }>(`/sessions/${id}/export?kind=${kind}`, { method: "POST" }),
financeSummary: (id: number, marketplace?: string) =>
req<FinanceSummaryT>(`/sessions/${id}/finance-summary${marketplace ? `?marketplace=${encodeURIComponent(marketplace)}` : ""}`),
listExports: (id: number) =>
req<{ id: number; kind?: string; size_bytes: number; generated_at: string; available: boolean }[]>(
`/sessions/${id}/exports`
),
downloadUrl: (id: number, kind: "full" | "summary" = "full") =>
`${BASE}/sessions/${id}/export/download?kind=${kind}`,
};