feat(billing): shared statements module across both business lines

Plan step 6 — the payoff of the unified customer record: a utility charge
and an insurance payment finally sit on the same page, under the same
person, with a running balance.

API (apps/api/src/billing/):
- GET /billing — cross-customer movement browser. Search over customer,
  referencia, cheque, concepto and periodo; filters for business line,
  currency, charge-vs-credit, concept, origin table and a from/to date
  range; 5 sorts. Returns totals for the whole filtered set, not just the
  page, so a filtered view can't be misread as the full ledger.
- GET /billing/balances — per-customer receivables worklist with
  owing/credit/settled buckets and 4 sorts. Raw SQL (parameterized via
  Prisma.sql): needs conditional sums per currency and per direction in
  one pass plus ordering and pagination on a computed balance, none of
  which groupBy expresses.
- GET /billing/stats, /billing/facets, /billing/customers/:id.

Web:
- /estado-cuenta — two views over the same ledger, because staff ask two
  different questions: "Saldos por cliente" (who owes what) and
  "Movimientos" (every charge and credit).
- /estado-cuenta/[id] — the statement: balance per currency, the same
  balance split by business line, charges broken out by concept, and the
  full movement list with a running balance.
- Cross-linked from the customer and property detail pages.

Two data findings shape the whole module:

1. transactions.amount is a signed ledger. Every charge type is negative
   without exception (WATER 3115/3117, ELECTRIC 2191/2191, PROPERTY TAXES
   926/926, TRUST FEE 188/188) and every deposit type positive (CHECK and
   CASH DEPOSIT, PAYPAL, all of EFECTIVO). So SUM(amount) is the balance
   and negative means the customer owes the office.

2. Currency is not summable. 912 of the 1269 customers with a ledger move
   in both MXN and USD, the charge side is MXN-only while receipts arrive
   in both, and no per-movement exchange rate was ever stored. A single
   "total balance" would be a figure that never existed in the books, so
   every total is reported per currency and the balance filter/sort takes
   a currency argument rather than collapsing.

Also: type_transactions.nameEs is entirely null (the legacy TYPE OF TRX
ESPAÑOL column is empty in all 79 rows), so Spanish concept names come
from a label map in labels.ts; the entries that are payee names rather
than categories fall through untranslated, which is correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 23:29:19 -07:00
co-authored by Claude Opus 4.8
parent 9de8e4e6c0
commit 2c6a6bf60b
13 changed files with 2789 additions and 1 deletions
+82
View File
@@ -3,10 +3,19 @@
import type {
AuthUser,
BalanceFilter,
BalanceListResponse,
BalanceSort,
BillingFacets,
BillingStats,
BusinessLine,
CustomerDetail,
CustomerListResponse,
CustomerStats,
LedgerCurrency,
LedgerDirection,
MovementListResponse,
MovementSort,
PolicyDetail,
PolicyFacets,
PolicyListResponse,
@@ -19,6 +28,8 @@ import type {
PropertySort,
PropertyStats,
ServiceKind,
Statement,
TransactionDomain,
TrustFilter,
} from "./types";
@@ -212,3 +223,74 @@ export function getProperty(
): Promise<PropertyDetail> {
return apiFetch<PropertyDetail>(`/properties/${id}?days=${days}`);
}
/* ------------------------------------------- Billing / statements module */
export interface MovementQuery {
query?: string;
page?: number;
pageSize?: number;
domain?: TransactionDomain;
currency?: LedgerCurrency;
direction?: LedgerDirection;
typeId?: string;
source?: string;
customerId?: 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.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}`);
}
+96
View File
@@ -1,6 +1,7 @@
// Spanish label maps + formatting helpers. Single source of truth for i18n.
import type {
LedgerDirection,
PolicyStatus,
ServiceKind,
TransactionDomain,
@@ -123,6 +124,101 @@ export function expiryPhrase(days: number | null): string | null {
return `venció hace ${past} ${past === 1 ? "día" : "días"}`;
}
// ----- ledger / estado de cuenta -----
/**
* A charge is negative and a credit positive (see `billing.service.ts`), so the
* balance is the plain sum. These are the two words the office uses.
*/
export const DIRECTION_LABELS: Record<LedgerDirection, string> = {
charge: "Cargo",
credit: "Abono",
};
export function directionLabel(d: LedgerDirection): string {
return DIRECTION_LABELS[d] ?? d;
}
/**
* Spanish names for the legacy `TYPE OF TRX` lookup.
*
* The lookup ships an `ESPAÑOL` column, but it is **empty in the source** — all
* 79 rows are null — so the API can only return the English name. This map
* covers the entries that are real service/payment categories; the rest of the
* 79 are payee names (LORETO GONZALEZ, ALBERCAS VALLARTA…) that shouldn't be
* translated anyway, and fall through to the raw value.
*/
export const TX_TYPE_LABELS: Record<string, string> = {
WATER: "Agua",
ELECTRIC: "Electricidad",
TELEPHONE: "Teléfono",
"PROPERTY TAXES": "Predial",
"FEDERAL ZONE": "Zona federal",
"GAS BUTANO": "Gas butano",
"GAS REFILL": "Recarga de gas",
"TRUST FEE": "Cuota de fideicomiso",
"HOA DUES": "Cuota de asociación",
"ALARM SYSTEM": "Sistema de alarma",
"HOUSE INSURANCE": "Seguro de casa",
"AUTO INSURANCE": "Seguro de auto",
"CHECK DEPOSIT": "Depósito con cheque",
"CASH DEPOSIT": "Depósito en efectivo",
PAYPAL: "PayPal",
"RETURNED CHECK": "Cheque devuelto",
"ACCOUNT CANCELED": "Cuenta cancelada",
"BANK FEE": "Comisión bancaria",
"BANK INTEREST": "Interés bancario",
SECURITY: "Vigilancia",
BALANCE: "Saldo",
ACCOUNTANT: "Contador",
"RENEWAL CONCESSION": "Renovación de concesión",
};
export function txTypeLabel(
type: { nameEs?: string | null; nameEn?: string | null } | null | undefined,
): string {
const raw = type?.nameEs || type?.nameEn;
if (!raw) return "Sin clasificar";
return TX_TYPE_LABELS[raw.toUpperCase()] ?? raw;
}
/**
* Legacy table a movement came from. Shown so a staff member checking a
* surprising figure can trace it back to the Access table it was migrated from.
*/
export const LEDGER_SOURCE_LABELS: Record<string, string> = {
datos2: "Facturación 202526",
"FEE ANUAL": "Cuota anual 2018",
fee15: "Cuota anual 2017",
"IVA 2015": "IVA 2015",
EFECTIVO: "Recibos de caja",
EFECTIVO_BACKUP: "Recibos de caja (respaldo)",
"EFECTIVO FM3": "Trámites FM3",
"CHEQUE FM3": "Trámites FM3 (cheque)",
};
export function ledgerSourceLabel(source: string | null | undefined): string {
if (!source) return "—";
return LEDGER_SOURCE_LABELS[source] ?? source;
}
/**
* Balance wording. Negative = the customer owes the office; positive = the
* customer is in credit (they have money on account).
*/
export function balancePhrase(balance: string | number): string {
const n = typeof balance === "string" ? Number(balance) : balance;
if (!Number.isFinite(n) || Math.abs(n) < 0.005) return "Sin saldo";
return n < 0 ? "Adeudo" : "A favor";
}
/** CSS-class suffix matching `balancePhrase`, for colouring a figure. */
export function balanceTone(balance: string | number): "owing" | "credit" | "flat" {
const n = typeof balance === "string" ? Number(balance) : balance;
if (!Number.isFinite(n) || Math.abs(n) < 0.005) return "flat";
return n < 0 ? "owing" : "credit";
}
// ----- formatting -----
export function formatMoney(
+173
View File
@@ -470,6 +470,179 @@ export interface TransactionSummaryRow {
count: number;
}
/* ------------------------------------------- Billing / statements module */
/**
* Which side of the ledger a movement sits on. `transactions.amount` is signed:
* a charge (cargo) is negative, a credit (abono) is positive, so the balance is
* simply the sum — negative means the customer owes the office.
*/
export type LedgerDirection = "charge" | "credit";
/** The only two currencies in the ledger. Totals are never summed across them. */
export type LedgerCurrency = "MXN" | "USD";
export type BalanceFilter = "all" | "owing" | "credit" | "settled";
export type MovementSort =
| "date_desc"
| "date_asc"
| "amount_desc"
| "amount_asc"
| "customer";
export type BalanceSort = "owing_desc" | "credit_desc" | "recent" | "customer";
export interface Movement {
id: string;
transactionDate: string | null;
domain: TransactionDomain;
amount: string;
currency: LedgerCurrency;
direction: LedgerDirection;
reference: string | null;
period: string | null;
checkNumber: string | null;
message: string | null;
/** Legacy table the row came from — `datos2`, `EFECTIVO`, `fee15`, … */
source: string | null;
type: TransactionType | null;
}
export interface MovementListItem extends Movement {
customerId: string;
customerName: string;
customerNameSource: string | null;
customerCity: string | null;
}
export interface CurrencyTotals {
currency: LedgerCurrency;
net: string | null;
count: number;
charges: string | null;
chargeCount: number;
credits: string | null;
creditCount: number;
}
export interface MovementListResponse {
items: MovementListItem[];
total: number;
page: number;
pageSize: number;
pageCount: number;
/** Totals for the whole filtered set, not just the current page. */
totals: CurrencyTotals[];
}
export interface CurrencyBalance {
currency: LedgerCurrency;
balance: string;
charges: string;
credits: string;
}
export interface BalanceListItem {
id: string;
name: string;
nameSource: string | null;
city: string | null;
state: string | null;
movements: number;
utilityMovements: number;
insuranceMovements: number;
lastMovement: string | null;
balances: CurrencyBalance[];
}
export interface BalanceListResponse {
items: BalanceListItem[];
total: number;
page: number;
pageSize: number;
pageCount: number;
currency: LedgerCurrency;
}
export interface BillingStats {
movements: number;
ledgerCustomers: number;
/** Customers whose ledger spans utilities *and* insurance. */
crossLineCustomers: number;
firstMovement: string | null;
lastMovement: string | null;
byCurrency: (CurrencyTotals & { owing: number; inCredit: number })[];
byDomain: {
domain: TransactionDomain;
currency: LedgerCurrency;
net: string | null;
count: number;
}[];
}
export interface BillingFacets {
types: Facet[];
sources: { name: string; count: number }[];
years: { year: number; count: number }[];
}
export interface StatementSummary {
currency: LedgerCurrency;
charges: string;
credits: string;
balance: string;
chargeCount: number;
creditCount: number;
count: number;
firstMovement: string | null;
lastMovement: string | null;
}
export interface StatementDomainRow {
domain: TransactionDomain;
currency: LedgerCurrency;
charges: string;
credits: string;
balance: string;
count: number;
}
export interface StatementTypeRow {
name: string;
currency: LedgerCurrency;
total: string;
count: number;
}
export interface StatementMovement extends Movement {
/** Balance in this row's currency after the movement was applied. */
balanceAfter: string;
}
export interface Statement {
customer: {
id: string;
name: string;
nameSource: string | null;
addressLine1: string | null;
city: string | null;
state: string | null;
phone: string | null;
mobile: string | null;
email: string | null;
customerSince: string | null;
preferredCurrency: string | null;
status: boolean;
propertyCount: number;
policyCount: number;
};
summary: StatementSummary[];
byDomain: StatementDomainRow[];
byType: StatementTypeRow[];
movements: StatementMovement[];
}
export interface CustomerDetail {
id: string;
name: string;