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:
@@ -0,0 +1,116 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common";
|
||||
import { TransactionDomain } from "@jorgecuadros/database";
|
||||
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||
import {
|
||||
BalanceFilter,
|
||||
BalanceSort,
|
||||
BillingService,
|
||||
LedgerCurrency,
|
||||
LedgerDirection,
|
||||
MovementSort,
|
||||
} from "./billing.service";
|
||||
|
||||
const DOMAINS: TransactionDomain[] = ["UTILITY", "INSURANCE", "TRUST"];
|
||||
const CURRENCIES: LedgerCurrency[] = ["MXN", "USD"];
|
||||
const DIRECTIONS: LedgerDirection[] = ["charge", "credit"];
|
||||
const BALANCES: BalanceFilter[] = ["all", "owing", "credit", "settled"];
|
||||
const MOVEMENT_SORTS: MovementSort[] = [
|
||||
"date_desc",
|
||||
"date_asc",
|
||||
"amount_desc",
|
||||
"amount_asc",
|
||||
"customer",
|
||||
];
|
||||
const BALANCE_SORTS: BalanceSort[] = [
|
||||
"owing_desc",
|
||||
"credit_desc",
|
||||
"recent",
|
||||
"customer",
|
||||
];
|
||||
|
||||
function one<T>(allowed: T[], value: string | undefined): T | undefined {
|
||||
return allowed.includes(value as T) ? (value as T) : undefined;
|
||||
}
|
||||
|
||||
/** A `YYYY-MM-DD` bound; anything unparseable is treated as absent. */
|
||||
function parseDate(v: string | undefined, endOfDay = false): Date | undefined {
|
||||
if (!v) return undefined;
|
||||
const d = new Date(endOfDay ? `${v}T23:59:59.999Z` : `${v}T00:00:00.000Z`);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
@UseGuards(AuthenticatedGuard)
|
||||
@Controller("billing")
|
||||
export class BillingController {
|
||||
constructor(private readonly billing: BillingService) {}
|
||||
|
||||
@Get("stats")
|
||||
stats() {
|
||||
return this.billing.stats();
|
||||
}
|
||||
|
||||
@Get("facets")
|
||||
facets() {
|
||||
return this.billing.facets();
|
||||
}
|
||||
|
||||
/** Per-customer balances — the receivables worklist. */
|
||||
@Get("balances")
|
||||
balances(
|
||||
@Query("query") query?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
@Query("currency") currency?: string,
|
||||
@Query("balance") balance?: string,
|
||||
@Query("domain") domain?: string,
|
||||
@Query("sort") sort?: string,
|
||||
) {
|
||||
return this.billing.balances({
|
||||
query,
|
||||
page: Math.max(1, Number(page) || 1),
|
||||
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
||||
currency: one(CURRENCIES, currency) ?? "MXN",
|
||||
balance: one(BALANCES, balance) ?? "all",
|
||||
domain: one(DOMAINS, domain),
|
||||
sort: one(BALANCE_SORTS, sort) ?? "owing_desc",
|
||||
});
|
||||
}
|
||||
|
||||
/** One customer's full statement across both business lines. */
|
||||
@Get("customers/:id")
|
||||
statement(@Param("id") id: string) {
|
||||
return this.billing.statement(id);
|
||||
}
|
||||
|
||||
/** Cross-customer movement browser. */
|
||||
@Get()
|
||||
movements(
|
||||
@Query("query") query?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
@Query("domain") domain?: string,
|
||||
@Query("currency") currency?: string,
|
||||
@Query("direction") direction?: string,
|
||||
@Query("typeId") typeId?: string,
|
||||
@Query("source") source?: string,
|
||||
@Query("customerId") customerId?: string,
|
||||
@Query("from") from?: string,
|
||||
@Query("to") to?: string,
|
||||
@Query("sort") sort?: string,
|
||||
) {
|
||||
return this.billing.movements({
|
||||
query,
|
||||
page: Math.max(1, Number(page) || 1),
|
||||
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
||||
domain: one(DOMAINS, domain),
|
||||
currency: one(CURRENCIES, currency),
|
||||
direction: one(DIRECTIONS, direction),
|
||||
typeId: typeId || undefined,
|
||||
source: source || undefined,
|
||||
customerId: customerId || undefined,
|
||||
from: parseDate(from),
|
||||
to: parseDate(to, true),
|
||||
sort: one(MOVEMENT_SORTS, sort) ?? "date_desc",
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user