The office keeps more than one operating account (Utilities banks in MXN, Seguros in USD), but bank_transactions was a single implicit MXN register by design. Adds Bank/BankAccount and makes every read and write in the module scoped to exactly one account. Schema: - Bank / BankAccount. Currency is fixed per account and BankTransaction has no currency column of its own — a movement inherits its account's, the way a real bank account doesn't mix currencies. - BankTransaction.bankAccountId, required. A movement with no known account isn't reconcilable against a statement. - @@index([bankAccountId, transactionDate]): every read now filters by account and orders/groups by date. Migration: - backfill_bank_accounts.py seeds Scotiabank + "Utilities — Scotiabank (MXN)" and backfills all 22,669 existing rows onto it, then promotes the column to NOT NULL and attaches the FK. Standalone because prisma db push cannot add a required column to a populated table. Idempotent; re-running once a second account exists does not re-point rows. - run_all.py runs it (both modes) before transform_bank.py, which now resolves the account by label and fails fast if it is missing. API: - ?bankAccountId= required on list/stats/facets/summary — not optional with an "all accounts" default, since summing an MXN and a USD register repeats the currency-collapsing mistake the billing module exists to prevent. Missing is 400, unknown is 404. - facets() had no account clause at all and summary() has two raw-SQL rollups; all three are now parameterised. Scoping only one of summary's queries would leave the year list and its drill-down describing different books. - New bank/accounts + bank/banks sub-resource under a MANAGER bank:manage-accounts ability. currency is absent from the update DTO: booked movements are denominated in it, so editing would re-denominate history. Capture into a closed account is rejected. Web: - /banco gains an account picker (remembered per browser) and reads every figure in the selected account's currency; the "single currency (MXN)" doc-comment and the hardcoded MXN formatting are gone. - New /banco/cuentas for banks and accounts. Accounts are closed, never deleted — the FK is required, so deleting one would destroy its register. - /inicio's chequera card names the account it is reading instead of implying a single register. Verified against dev + browser: a second USD account showed full read/write isolation from the MXN register, whose totals were unchanged (22,669 movements, net 1,014,266.97). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
886 lines
26 KiB
TypeScript
886 lines
26 KiB
TypeScript
// Cross-origin API client. All requests send the session cookie (connect.sid)
|
|
// via credentials: "include". The API origin comes from NEXT_PUBLIC_API_ORIGIN.
|
|
|
|
import type {
|
|
AuthUser,
|
|
BalanceFilter,
|
|
BalanceListResponse,
|
|
BalanceSort,
|
|
BankAccount,
|
|
BankCleared,
|
|
BankDirection,
|
|
BankFacets,
|
|
BankInstitution,
|
|
BankListResponse,
|
|
BankSort,
|
|
BankStats,
|
|
BankSummary,
|
|
BatchCreateInput,
|
|
BatchCreateResponse,
|
|
BillingFacets,
|
|
BillingStats,
|
|
BusinessLine,
|
|
ByCheckResponse,
|
|
CreateBankAccountInput,
|
|
CreateBankInput,
|
|
CreateBankMovementInput,
|
|
CreateMovementInput,
|
|
UpdateBankAccountInput,
|
|
ResolveOutstandingInput,
|
|
CustomerDetail,
|
|
CustomerInput,
|
|
CustomerListResponse,
|
|
CustomerStats,
|
|
LedgerCurrency,
|
|
LedgerDirection,
|
|
MovementListResponse,
|
|
MovementSort,
|
|
PolicyDetail,
|
|
PolicyFacets,
|
|
PolicyInput,
|
|
PolicyListResponse,
|
|
PolicySort,
|
|
PolicyStats,
|
|
PolicyStatus,
|
|
LookupsResponse,
|
|
OpsJob,
|
|
OpsJobKind,
|
|
IngestFile,
|
|
BackupFile,
|
|
PropertyDetail,
|
|
PropertyFacets,
|
|
PropertyInput,
|
|
PropertyListResponse,
|
|
PropertySort,
|
|
PropertyStats,
|
|
ReportCatalog,
|
|
ReportRunResult,
|
|
ServiceInput,
|
|
TrustInput,
|
|
Role,
|
|
ServiceKind,
|
|
Statement,
|
|
Transaction,
|
|
TransactionDomain,
|
|
TrustFilter,
|
|
UserRow,
|
|
} from "./types";
|
|
|
|
// Resolve the API origin at runtime, not build time. In the browser it comes
|
|
// from window.__API_ORIGIN__, injected server-side by the root layout from the
|
|
// deploy .env (API_ORIGIN) — so one built image serves any deployment. On the
|
|
// server (SSR) read process.env directly. NEXT_PUBLIC_API_ORIGIN stays as the
|
|
// dev/build fallback.
|
|
function resolveApiOrigin(): string {
|
|
if (typeof window !== "undefined") {
|
|
const injected = (window as { __API_ORIGIN__?: string }).__API_ORIGIN__;
|
|
if (injected) return injected;
|
|
}
|
|
return (
|
|
process.env.API_ORIGIN ??
|
|
process.env.NEXT_PUBLIC_API_ORIGIN ??
|
|
"http://localhost:3001"
|
|
);
|
|
}
|
|
|
|
export const API_ORIGIN = resolveApiOrigin();
|
|
|
|
export class ApiError extends Error {
|
|
status: number;
|
|
constructor(status: number, message: string) {
|
|
super(message);
|
|
this.status = status;
|
|
this.name = "ApiError";
|
|
}
|
|
}
|
|
|
|
async function apiFetch<T>(
|
|
path: string,
|
|
init?: RequestInit,
|
|
): Promise<T> {
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(`${API_ORIGIN}${path}`, {
|
|
credentials: "include",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(init?.headers ?? {}),
|
|
},
|
|
...init,
|
|
});
|
|
} catch (e) {
|
|
throw new ApiError(
|
|
0,
|
|
"No se pudo conectar con el servidor. Verifica tu conexión.",
|
|
);
|
|
}
|
|
|
|
if (!res.ok) {
|
|
let message = `Error ${res.status}`;
|
|
try {
|
|
const body = await res.json();
|
|
if (body?.message) message = body.message;
|
|
} catch {
|
|
/* ignore non-JSON error bodies */
|
|
}
|
|
throw new ApiError(res.status, message);
|
|
}
|
|
|
|
if (res.status === 204) return undefined as T;
|
|
return (await res.json()) as T;
|
|
}
|
|
|
|
export function login(email: string, password: string): Promise<AuthUser> {
|
|
return apiFetch<AuthUser>("/auth/login", {
|
|
method: "POST",
|
|
body: JSON.stringify({ email, password }),
|
|
});
|
|
}
|
|
|
|
export function me(): Promise<AuthUser> {
|
|
return apiFetch<AuthUser>("/auth/me");
|
|
}
|
|
|
|
/** Persist the caller's own text-size preference on their account. */
|
|
export function updateUiScale(uiScale: number): Promise<AuthUser> {
|
|
return apiFetch<AuthUser>("/auth/preferences", {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ uiScale }),
|
|
});
|
|
}
|
|
|
|
export function logout(): Promise<{ success: boolean }> {
|
|
return apiFetch<{ success: boolean }>("/auth/logout", { method: "POST" });
|
|
}
|
|
|
|
export function getStats(): Promise<CustomerStats> {
|
|
return apiFetch<CustomerStats>("/customers/stats");
|
|
}
|
|
|
|
export interface CustomerQuery {
|
|
query?: string;
|
|
page?: number;
|
|
pageSize?: number;
|
|
line?: BusinessLine;
|
|
}
|
|
|
|
export function listCustomers(
|
|
q: CustomerQuery,
|
|
): Promise<CustomerListResponse> {
|
|
const params = new URLSearchParams();
|
|
if (q.query) params.set("query", q.query);
|
|
if (q.page) params.set("page", String(q.page));
|
|
if (q.pageSize) params.set("pageSize", String(q.pageSize));
|
|
if (q.line) params.set("line", q.line);
|
|
const qs = params.toString();
|
|
return apiFetch<CustomerListResponse>(`/customers${qs ? `?${qs}` : ""}`);
|
|
}
|
|
|
|
export function getCustomer(id: string): Promise<CustomerDetail> {
|
|
return apiFetch<CustomerDetail>(`/customers/${id}`);
|
|
}
|
|
|
|
export function createCustomer(input: CustomerInput): Promise<CustomerDetail> {
|
|
return apiFetch<CustomerDetail>("/customers", {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
export function updateCustomer(
|
|
id: string,
|
|
input: Partial<CustomerInput>,
|
|
): Promise<CustomerDetail> {
|
|
return apiFetch<CustomerDetail>(`/customers/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
export function archiveCustomer(id: string): Promise<CustomerDetail> {
|
|
return apiFetch<CustomerDetail>(`/customers/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
export function restoreCustomer(id: string): Promise<CustomerDetail> {
|
|
return apiFetch<CustomerDetail>(`/customers/${id}/restore`, { method: "POST" });
|
|
}
|
|
|
|
/* ------------------------------------------------------ Policies module */
|
|
|
|
/** Renewal horizon in days, shared by the list, stats and detail calls so the
|
|
* "por vencer" bucket means the same thing everywhere. */
|
|
export const EXPIRY_WINDOW_DAYS = 30;
|
|
|
|
export interface PolicyQuery {
|
|
query?: string;
|
|
page?: number;
|
|
pageSize?: number;
|
|
status?: PolicyStatus;
|
|
days?: number;
|
|
typeId?: string;
|
|
providerId?: string;
|
|
liquidated?: boolean;
|
|
sort?: PolicySort;
|
|
}
|
|
|
|
export function listPolicies(q: PolicyQuery): Promise<PolicyListResponse> {
|
|
const params = new URLSearchParams();
|
|
if (q.query) params.set("query", q.query);
|
|
if (q.page) params.set("page", String(q.page));
|
|
if (q.pageSize) params.set("pageSize", String(q.pageSize));
|
|
if (q.status) params.set("status", q.status);
|
|
if (q.days) params.set("days", String(q.days));
|
|
if (q.typeId) params.set("typeId", q.typeId);
|
|
if (q.providerId) params.set("providerId", q.providerId);
|
|
if (q.liquidated !== undefined) params.set("liquidated", String(q.liquidated));
|
|
if (q.sort) params.set("sort", q.sort);
|
|
const qs = params.toString();
|
|
return apiFetch<PolicyListResponse>(`/policies${qs ? `?${qs}` : ""}`);
|
|
}
|
|
|
|
export function getPolicyStats(
|
|
days: number = EXPIRY_WINDOW_DAYS,
|
|
): Promise<PolicyStats> {
|
|
return apiFetch<PolicyStats>(`/policies/stats?days=${days}`);
|
|
}
|
|
|
|
export function getPolicyFacets(): Promise<PolicyFacets> {
|
|
return apiFetch<PolicyFacets>("/policies/facets");
|
|
}
|
|
|
|
export function getPolicy(
|
|
id: string,
|
|
days: number = EXPIRY_WINDOW_DAYS,
|
|
): Promise<PolicyDetail> {
|
|
return apiFetch<PolicyDetail>(`/policies/${id}?days=${days}`);
|
|
}
|
|
|
|
export function createPolicy(input: PolicyInput): Promise<PolicyDetail> {
|
|
return apiFetch<PolicyDetail>("/policies", {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
export function updatePolicy(
|
|
id: string,
|
|
input: Partial<PolicyInput>,
|
|
): Promise<PolicyDetail> {
|
|
return apiFetch<PolicyDetail>(`/policies/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
export function archivePolicy(id: string): Promise<PolicyDetail> {
|
|
return apiFetch<PolicyDetail>(`/policies/${id}`, { method: "DELETE" });
|
|
}
|
|
export function restorePolicy(id: string): Promise<PolicyDetail> {
|
|
return apiFetch<PolicyDetail>(`/policies/${id}/restore`, { method: "POST" });
|
|
}
|
|
|
|
// Generic policy-child CRUD. `kind` is the URL segment
|
|
// (installments|vehicles|drivers|beneficiaries|claims).
|
|
export function addPolicyChild<T>(
|
|
policyId: string,
|
|
kind: string,
|
|
input: T,
|
|
): Promise<unknown> {
|
|
return apiFetch(`/policies/${policyId}/${kind}`, {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
export function updatePolicyChild<T>(
|
|
policyId: string,
|
|
kind: string,
|
|
childId: string,
|
|
input: T,
|
|
): Promise<unknown> {
|
|
return apiFetch(`/policies/${policyId}/${kind}/${childId}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
export function removePolicyChild(
|
|
policyId: string,
|
|
kind: string,
|
|
childId: string,
|
|
): Promise<unknown> {
|
|
return apiFetch(`/policies/${policyId}/${kind}/${childId}`, {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
/* ------------------------------------------------- Lookups (insurance ref) */
|
|
|
|
export function getLookups(): Promise<LookupsResponse> {
|
|
return apiFetch<LookupsResponse>("/lookups");
|
|
}
|
|
export function createLookup(kind: string, input: unknown): Promise<unknown> {
|
|
return apiFetch(`/lookups/${kind}`, { method: "POST", body: JSON.stringify(input) });
|
|
}
|
|
export function updateLookup(
|
|
kind: string,
|
|
id: string,
|
|
input: unknown,
|
|
): Promise<unknown> {
|
|
return apiFetch(`/lookups/${kind}/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
export function removeLookup(kind: string, id: string): Promise<unknown> {
|
|
return apiFetch(`/lookups/${kind}/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
/* ----------------------------------------------------- Utilities module */
|
|
|
|
export interface PropertyQuery {
|
|
query?: string;
|
|
page?: number;
|
|
pageSize?: number;
|
|
serviceKind?: ServiceKind;
|
|
municipality?: string;
|
|
bank?: string;
|
|
trust?: TrustFilter;
|
|
hasServices?: boolean;
|
|
customerId?: string;
|
|
days?: number;
|
|
sort?: PropertySort;
|
|
}
|
|
|
|
export function listProperties(q: PropertyQuery): Promise<PropertyListResponse> {
|
|
const params = new URLSearchParams();
|
|
if (q.query) params.set("query", q.query);
|
|
if (q.page) params.set("page", String(q.page));
|
|
if (q.pageSize) params.set("pageSize", String(q.pageSize));
|
|
if (q.serviceKind) params.set("serviceKind", q.serviceKind);
|
|
if (q.municipality) params.set("municipality", q.municipality);
|
|
if (q.bank) params.set("bank", q.bank);
|
|
if (q.trust) params.set("trust", q.trust);
|
|
if (q.hasServices !== undefined)
|
|
params.set("hasServices", String(q.hasServices));
|
|
if (q.customerId) params.set("customerId", q.customerId);
|
|
if (q.days) params.set("days", String(q.days));
|
|
if (q.sort) params.set("sort", q.sort);
|
|
const qs = params.toString();
|
|
return apiFetch<PropertyListResponse>(`/properties${qs ? `?${qs}` : ""}`);
|
|
}
|
|
|
|
export function getPropertyStats(
|
|
days: number = EXPIRY_WINDOW_DAYS,
|
|
): Promise<PropertyStats> {
|
|
return apiFetch<PropertyStats>(`/properties/stats?days=${days}`);
|
|
}
|
|
|
|
export function getPropertyFacets(): Promise<PropertyFacets> {
|
|
return apiFetch<PropertyFacets>("/properties/facets");
|
|
}
|
|
|
|
export function getProperty(
|
|
id: string,
|
|
days: number = EXPIRY_WINDOW_DAYS,
|
|
): Promise<PropertyDetail> {
|
|
return apiFetch<PropertyDetail>(`/properties/${id}?days=${days}`);
|
|
}
|
|
|
|
export function createProperty(input: PropertyInput): Promise<PropertyDetail> {
|
|
return apiFetch<PropertyDetail>("/properties", {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
export function updateProperty(
|
|
id: string,
|
|
input: Partial<PropertyInput>,
|
|
): Promise<PropertyDetail> {
|
|
return apiFetch<PropertyDetail>(`/properties/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
export function archiveProperty(id: string): Promise<PropertyDetail> {
|
|
return apiFetch<PropertyDetail>(`/properties/${id}`, { method: "DELETE" });
|
|
}
|
|
export function restoreProperty(id: string): Promise<PropertyDetail> {
|
|
return apiFetch<PropertyDetail>(`/properties/${id}/restore`, { method: "POST" });
|
|
}
|
|
|
|
// Service child CRUD.
|
|
export function addService(propertyId: string, input: ServiceInput): Promise<unknown> {
|
|
return apiFetch(`/properties/${propertyId}/services`, {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
export function updateService(
|
|
propertyId: string,
|
|
serviceId: string,
|
|
input: Partial<ServiceInput>,
|
|
): Promise<unknown> {
|
|
return apiFetch(`/properties/${propertyId}/services/${serviceId}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
export function removeService(propertyId: string, serviceId: string): Promise<unknown> {
|
|
return apiFetch(`/properties/${propertyId}/services/${serviceId}`, {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
// Trust account (1:1 upsert).
|
|
export function upsertTrust(propertyId: string, input: TrustInput): Promise<unknown> {
|
|
return apiFetch(`/properties/${propertyId}/trust`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
export function removeTrust(propertyId: string): Promise<unknown> {
|
|
return apiFetch(`/properties/${propertyId}/trust`, { method: "DELETE" });
|
|
}
|
|
|
|
export function removePropertyDocument(
|
|
propertyId: string,
|
|
documentId: string,
|
|
): Promise<unknown> {
|
|
return apiFetch(`/properties/${propertyId}/documents/${documentId}`, {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
export function propertyDocumentDownloadUrl(
|
|
propertyId: string,
|
|
documentId: string,
|
|
): string {
|
|
return `${API_ORIGIN}/properties/${propertyId}/documents/${documentId}/download`;
|
|
}
|
|
|
|
export function uploadPropertyDocument(
|
|
propertyId: string,
|
|
file: File,
|
|
type?: string,
|
|
): Promise<unknown> {
|
|
const q = type ? `?type=${encodeURIComponent(type)}` : "";
|
|
return uploadFile(`/properties/${propertyId}/documents${q}`, file);
|
|
}
|
|
|
|
export function removePolicyDocument(
|
|
policyId: string,
|
|
documentId: string,
|
|
): Promise<unknown> {
|
|
return apiFetch(`/policies/${policyId}/documents/${documentId}`, {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
export function policyDocumentDownloadUrl(
|
|
policyId: string,
|
|
documentId: string,
|
|
): string {
|
|
return `${API_ORIGIN}/policies/${policyId}/documents/${documentId}/download`;
|
|
}
|
|
|
|
export function uploadPolicyDocument(
|
|
policyId: string,
|
|
file: File,
|
|
type?: string,
|
|
): Promise<unknown> {
|
|
const q = type ? `?type=${encodeURIComponent(type)}` : "";
|
|
return uploadFile(`/policies/${policyId}/documents${q}`, file);
|
|
}
|
|
|
|
/* ------------------------------------------- Billing / statements module */
|
|
|
|
export interface MovementQuery {
|
|
query?: string;
|
|
page?: number;
|
|
pageSize?: number;
|
|
domain?: TransactionDomain;
|
|
currency?: LedgerCurrency;
|
|
direction?: LedgerDirection;
|
|
typeId?: string;
|
|
source?: string;
|
|
customerId?: string;
|
|
/** Restrict to captured-but-unpaid rows (the NOPAGO worklist). */
|
|
outstanding?: boolean;
|
|
/** Exact check number — the by-check reconciliation lookup. */
|
|
checkNumber?: string;
|
|
/** `YYYY-MM-DD`, inclusive on both ends. */
|
|
from?: string;
|
|
to?: string;
|
|
sort?: MovementSort;
|
|
}
|
|
|
|
export function listMovements(q: MovementQuery): Promise<MovementListResponse> {
|
|
const params = new URLSearchParams();
|
|
if (q.query) params.set("query", q.query);
|
|
if (q.page) params.set("page", String(q.page));
|
|
if (q.pageSize) params.set("pageSize", String(q.pageSize));
|
|
if (q.domain) params.set("domain", q.domain);
|
|
if (q.currency) params.set("currency", q.currency);
|
|
if (q.direction) params.set("direction", q.direction);
|
|
if (q.typeId) params.set("typeId", q.typeId);
|
|
if (q.source) params.set("source", q.source);
|
|
if (q.customerId) params.set("customerId", q.customerId);
|
|
if (q.outstanding !== undefined) params.set("outstanding", String(q.outstanding));
|
|
if (q.checkNumber) params.set("checkNumber", q.checkNumber);
|
|
if (q.from) params.set("from", q.from);
|
|
if (q.to) params.set("to", q.to);
|
|
if (q.sort) params.set("sort", q.sort);
|
|
const qs = params.toString();
|
|
return apiFetch<MovementListResponse>(`/billing${qs ? `?${qs}` : ""}`);
|
|
}
|
|
|
|
export interface BalanceQuery {
|
|
query?: string;
|
|
page?: number;
|
|
pageSize?: number;
|
|
currency?: LedgerCurrency;
|
|
balance?: BalanceFilter;
|
|
domain?: TransactionDomain;
|
|
sort?: BalanceSort;
|
|
}
|
|
|
|
export function listBalances(q: BalanceQuery): Promise<BalanceListResponse> {
|
|
const params = new URLSearchParams();
|
|
if (q.query) params.set("query", q.query);
|
|
if (q.page) params.set("page", String(q.page));
|
|
if (q.pageSize) params.set("pageSize", String(q.pageSize));
|
|
if (q.currency) params.set("currency", q.currency);
|
|
if (q.balance) params.set("balance", q.balance);
|
|
if (q.domain) params.set("domain", q.domain);
|
|
if (q.sort) params.set("sort", q.sort);
|
|
const qs = params.toString();
|
|
return apiFetch<BalanceListResponse>(`/billing/balances${qs ? `?${qs}` : ""}`);
|
|
}
|
|
|
|
export function getBillingStats(): Promise<BillingStats> {
|
|
return apiFetch<BillingStats>("/billing/stats");
|
|
}
|
|
|
|
export function getBillingFacets(): Promise<BillingFacets> {
|
|
return apiFetch<BillingFacets>("/billing/facets");
|
|
}
|
|
|
|
export function getStatement(customerId: string): Promise<Statement> {
|
|
return apiFetch<Statement>(`/billing/customers/${customerId}`);
|
|
}
|
|
|
|
/** Append a new ledger movement. Booked movements are never edited — fix
|
|
* mistakes with voidMovement + a fresh capture. */
|
|
export function createMovement(
|
|
input: CreateMovementInput,
|
|
): Promise<Transaction> {
|
|
return apiFetch<Transaction>("/billing", {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
/** Reverse a movement by marking it voided; totals drop it. 400 if already void. */
|
|
export function voidMovement(id: string): Promise<Transaction> {
|
|
return apiFetch<Transaction>(`/billing/${id}/void`, { method: "POST" });
|
|
}
|
|
|
|
/** Capture many customers' receipts against one check, in one transaction. The
|
|
* returned `items` are positionally parallel to `input.lines`. */
|
|
export function createMovementBatch(
|
|
input: BatchCreateInput,
|
|
): Promise<BatchCreateResponse> {
|
|
return apiFetch<BatchCreateResponse>("/billing/batch", {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
/** Clear an outstanding (NOPAGO) row: stamps the check number + resolution date
|
|
* and starts counting it toward the balance. 400 if not outstanding or voided. */
|
|
export function resolveOutstanding(
|
|
id: string,
|
|
input: ResolveOutstandingInput,
|
|
): Promise<Transaction> {
|
|
return apiFetch<Transaction>(`/billing/${id}/resolve-outstanding`, {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
/** Everything captured against one check, with its reconciliation total. */
|
|
export function getByCheck(checkNumber: string): Promise<ByCheckResponse> {
|
|
return apiFetch<ByCheckResponse>(
|
|
`/billing/by-check?checkNumber=${encodeURIComponent(checkNumber)}`,
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------- Bank register (chequera) */
|
|
|
|
/**
|
|
* Every read below is scoped to one chequera. `bankAccountId` is required, not
|
|
* defaulted to "all accounts": the office's registers are in different
|
|
* currencies, and a combined total would be a figure that never existed.
|
|
*/
|
|
export interface BankQuery {
|
|
bankAccountId: string;
|
|
query?: string;
|
|
page?: number;
|
|
pageSize?: number;
|
|
direction?: BankDirection;
|
|
cleared?: BankCleared;
|
|
/** `YYYY-MM-DD`, inclusive on both ends. */
|
|
from?: string;
|
|
to?: string;
|
|
sort?: BankSort;
|
|
}
|
|
|
|
export function listBankMovements(q: BankQuery): Promise<BankListResponse> {
|
|
const params = new URLSearchParams({ bankAccountId: q.bankAccountId });
|
|
if (q.query) params.set("query", q.query);
|
|
if (q.page) params.set("page", String(q.page));
|
|
if (q.pageSize) params.set("pageSize", String(q.pageSize));
|
|
if (q.direction) params.set("direction", q.direction);
|
|
if (q.cleared) params.set("cleared", q.cleared);
|
|
if (q.from) params.set("from", q.from);
|
|
if (q.to) params.set("to", q.to);
|
|
if (q.sort) params.set("sort", q.sort);
|
|
return apiFetch<BankListResponse>(`/bank?${params.toString()}`);
|
|
}
|
|
|
|
export function getBankStats(bankAccountId: string): Promise<BankStats> {
|
|
return apiFetch<BankStats>(
|
|
`/bank/stats?bankAccountId=${encodeURIComponent(bankAccountId)}`,
|
|
);
|
|
}
|
|
|
|
export function getBankFacets(bankAccountId: string): Promise<BankFacets> {
|
|
return apiFetch<BankFacets>(
|
|
`/bank/facets?bankAccountId=${encodeURIComponent(bankAccountId)}`,
|
|
);
|
|
}
|
|
|
|
export function getBankSummary(
|
|
bankAccountId: string,
|
|
year?: number,
|
|
): Promise<BankSummary> {
|
|
const params = new URLSearchParams({ bankAccountId });
|
|
if (year) params.set("year", String(year));
|
|
return apiFetch<BankSummary>(`/bank/summary?${params.toString()}`);
|
|
}
|
|
|
|
/* --------------------------------------------- Chequera accounts (catalog) */
|
|
|
|
/** The account picker's source. Includes closed accounts, which stay readable. */
|
|
export function listBankAccounts(): Promise<BankAccount[]> {
|
|
return apiFetch<BankAccount[]>("/bank/accounts");
|
|
}
|
|
|
|
export function listBankInstitutions(): Promise<BankInstitution[]> {
|
|
return apiFetch<BankInstitution[]>("/bank/banks");
|
|
}
|
|
|
|
export function createBankInstitution(
|
|
input: CreateBankInput,
|
|
): Promise<BankInstitution> {
|
|
return apiFetch<BankInstitution>("/bank/banks", {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
export function updateBankInstitution(
|
|
id: string,
|
|
input: Partial<CreateBankInput>,
|
|
): Promise<BankInstitution> {
|
|
return apiFetch<BankInstitution>(`/bank/banks/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
export function createBankAccount(
|
|
input: CreateBankAccountInput,
|
|
): Promise<unknown> {
|
|
return apiFetch("/bank/accounts", {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
/** No `currency` — an account's booked movements are denominated in it. */
|
|
export function updateBankAccount(
|
|
id: string,
|
|
input: UpdateBankAccountInput,
|
|
): Promise<unknown> {
|
|
return apiFetch(`/bank/accounts/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
/** Append a new chequera movement. Booked rows are never edited — fix mistakes
|
|
* with voidBankMovement + a fresh capture. */
|
|
export function createBankMovement(
|
|
input: CreateBankMovementInput,
|
|
): Promise<unknown> {
|
|
return apiFetch("/bank", {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
/** Reverse a chequera movement by marking it voided; totals drop it. */
|
|
export function voidBankMovement(id: string): Promise<unknown> {
|
|
return apiFetch(`/bank/${id}/void`, { method: "POST" });
|
|
}
|
|
|
|
/* ------------------------------------------------- Users / administration */
|
|
|
|
export function listUsers(): Promise<UserRow[]> {
|
|
return apiFetch<UserRow[]>("/users");
|
|
}
|
|
|
|
export interface CreateUserInput {
|
|
name: string;
|
|
email: string;
|
|
password: string;
|
|
role: Role;
|
|
active?: boolean;
|
|
}
|
|
|
|
export function createUser(input: CreateUserInput): Promise<UserRow> {
|
|
return apiFetch<UserRow>("/users", {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
export interface UpdateUserInput {
|
|
name?: string;
|
|
email?: string;
|
|
role?: Role;
|
|
active?: boolean;
|
|
}
|
|
|
|
export function updateUser(id: string, input: UpdateUserInput): Promise<UserRow> {
|
|
return apiFetch<UserRow>(`/users/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
export function resetUserPassword(id: string, password: string): Promise<UserRow> {
|
|
return apiFetch<UserRow>(`/users/${id}/reset-password`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ password }),
|
|
});
|
|
}
|
|
|
|
export function deleteUser(id: string): Promise<void> {
|
|
return apiFetch<void>(`/users/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
/* ------------------------------------------- DB operations (admin only) */
|
|
|
|
export function listIngest(): Promise<IngestFile[]> {
|
|
return apiFetch<IngestFile[]>("/ops/ingest");
|
|
}
|
|
|
|
/**
|
|
* Multipart upload — not JSON, so it bypasses apiFetch's Content-Type. `path`
|
|
* is API-relative (may include a query string); `filename` overrides the part
|
|
* name sent to the server.
|
|
*/
|
|
export async function uploadFile(
|
|
path: string,
|
|
file: File,
|
|
filename?: string,
|
|
): Promise<unknown> {
|
|
const body = new FormData();
|
|
body.append("file", file, filename ?? file.name);
|
|
const res = await fetch(`${API_ORIGIN}${path}`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
body,
|
|
});
|
|
if (!res.ok) {
|
|
let message = `Error ${res.status}`;
|
|
try {
|
|
const b = await res.json();
|
|
if (b?.message) message = b.message;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
throw new ApiError(res.status, message);
|
|
}
|
|
return res.status === 204 ? undefined : res.json().catch(() => undefined);
|
|
}
|
|
|
|
export function uploadIngest(name: string, file: File): Promise<unknown> {
|
|
return uploadFile(`/ops/ingest/${encodeURIComponent(name)}`, file, name);
|
|
}
|
|
|
|
export function deleteIngest(name: string): Promise<unknown> {
|
|
return apiFetch(`/ops/ingest/${encodeURIComponent(name)}`, { method: "DELETE" });
|
|
}
|
|
|
|
export function listBackups(): Promise<BackupFile[]> {
|
|
return apiFetch<BackupFile[]>("/ops/backups");
|
|
}
|
|
|
|
export function backupDownloadUrl(name: string): string {
|
|
return `${API_ORIGIN}/ops/backups/${encodeURIComponent(name)}/download`;
|
|
}
|
|
|
|
export function deleteBackup(name: string): Promise<unknown> {
|
|
return apiFetch(`/ops/backups/${encodeURIComponent(name)}`, { method: "DELETE" });
|
|
}
|
|
|
|
export function listOpsJobs(): Promise<OpsJob[]> {
|
|
return apiFetch<OpsJob[]>("/ops/jobs");
|
|
}
|
|
|
|
export function getOpsJob(id: string): Promise<OpsJob> {
|
|
return apiFetch<OpsJob>(`/ops/jobs/${id}`);
|
|
}
|
|
|
|
/** Start a mutating op. `file` is required for RESTORE. 409 if one is running. */
|
|
export function startOpsJob(kind: OpsJobKind, file?: string): Promise<OpsJob> {
|
|
return apiFetch<OpsJob>("/ops/jobs", {
|
|
method: "POST",
|
|
body: JSON.stringify({ kind, file }),
|
|
});
|
|
}
|
|
|
|
/* ----------------------------------------------------------- Reports module */
|
|
|
|
export function getReportCatalog(): Promise<ReportCatalog> {
|
|
return apiFetch<ReportCatalog>("/reports");
|
|
}
|
|
|
|
export function runReport(
|
|
slug: string,
|
|
params: Record<string, string | undefined>,
|
|
): Promise<ReportRunResult> {
|
|
const qs = new URLSearchParams();
|
|
for (const [k, v] of Object.entries(params)) {
|
|
if (v != null && v !== "") qs.set(k, v);
|
|
}
|
|
const tail = qs.toString();
|
|
return apiFetch<ReportRunResult>(`/reports/${slug}${tail ? `?${tail}` : ""}`);
|
|
}
|
|
|
|
/** Build a download URL for a report's file output. The session cookie
|
|
* travels with the browser's same-origin navigation, so a plain `href`
|
|
* is enough — no fetch-with-credentials dance. */
|
|
export function reportDownloadUrl(
|
|
slug: string,
|
|
format: "csv" | "xlsx" | "pdf" | "print",
|
|
params: Record<string, string | undefined>,
|
|
): string {
|
|
const qs = new URLSearchParams();
|
|
for (const [k, v] of Object.entries(params)) {
|
|
if (v != null && v !== "") qs.set(k, v);
|
|
}
|
|
const tail = qs.toString();
|
|
return `${API_ORIGIN}/reports/${slug}/${format}${tail ? `?${tail}` : ""}`;
|
|
}
|