From 2c6a6bf60b3a48c2faa170bb63b45b37e8ecef18 Mon Sep 17 00:00:00 2001 From: Ricardo Mancinas Date: Wed, 22 Jul 2026 23:29:19 -0700 Subject: [PATCH] feat(billing): shared statements module across both business lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/app.module.ts | 2 + apps/api/src/billing/billing.controller.ts | 116 +++ apps/api/src/billing/billing.module.ts | 9 + apps/api/src/billing/billing.service.ts | 725 ++++++++++++++++ apps/web/src/app/clientes/[id]/page.tsx | 11 + apps/web/src/app/estado-cuenta/[id]/page.tsx | 500 +++++++++++ apps/web/src/app/estado-cuenta/page.tsx | 845 +++++++++++++++++++ apps/web/src/app/globals.css | 225 +++++ apps/web/src/app/servicios/[id]/page.tsx | 5 +- apps/web/src/components/AppShell.tsx | 1 + apps/web/src/lib/api.ts | 82 ++ apps/web/src/lib/labels.ts | 96 +++ apps/web/src/lib/types.ts | 173 ++++ 13 files changed, 2789 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/billing/billing.controller.ts create mode 100644 apps/api/src/billing/billing.module.ts create mode 100644 apps/api/src/billing/billing.service.ts create mode 100644 apps/web/src/app/estado-cuenta/[id]/page.tsx create mode 100644 apps/web/src/app/estado-cuenta/page.tsx diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 6a8ea3b..1f3b586 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -6,6 +6,7 @@ import { AuthModule } from "./auth/auth.module"; import { CustomersModule } from "./customers/customers.module"; import { PoliciesModule } from "./policies/policies.module"; import { PropertiesModule } from "./properties/properties.module"; +import { BillingModule } from "./billing/billing.module"; import { AppController } from "./app.controller"; @Module({ @@ -17,6 +18,7 @@ import { AppController } from "./app.controller"; CustomersModule, PoliciesModule, PropertiesModule, + BillingModule, ], controllers: [AppController], }) diff --git a/apps/api/src/billing/billing.controller.ts b/apps/api/src/billing/billing.controller.ts new file mode 100644 index 0000000..52d8529 --- /dev/null +++ b/apps/api/src/billing/billing.controller.ts @@ -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(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", + }); + } +} diff --git a/apps/api/src/billing/billing.module.ts b/apps/api/src/billing/billing.module.ts new file mode 100644 index 0000000..ef9f33e --- /dev/null +++ b/apps/api/src/billing/billing.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; +import { BillingController } from "./billing.controller"; +import { BillingService } from "./billing.service"; + +@Module({ + controllers: [BillingController], + providers: [BillingService], +}) +export class BillingModule {} diff --git a/apps/api/src/billing/billing.service.ts b/apps/api/src/billing/billing.service.ts new file mode 100644 index 0000000..ad3422c --- /dev/null +++ b/apps/api/src/billing/billing.service.ts @@ -0,0 +1,725 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Prisma, TransactionDomain } from "@jorgecuadros/database"; +import { PrismaService } from "../prisma/prisma.service"; + +/** + * Shared billing / statements module — plan step 6. + * + * SIGN CONVENTION (established from the migrated data, not assumed): + * `transactions.amount` is a *signed* ledger amount. + * - negative = cargo (a charge: a utility bill, predial, trust fee, HOA due, + * insurance premium the office paid or billed on the customer's behalf). + * Every legacy `type_of_trx` on the charge side is negative without + * exception — WATER 3115/3117 negative, ELECTRIC 2191/2191, PROPERTY TAXES + * 926/926, TRUST FEE 188/188. + * - positive = abono (a credit: CHECK DEPOSIT, CASH DEPOSIT, PAYPAL, and + * every `EFECTIVO` cash receipt). + * So `SUM(amount)` is the balance: negative means the customer owes the office, + * positive means the customer is in credit. + * + * CURRENCY IS NOT SUMMABLE. 912 of the 1269 customers with a ledger have + * movements in both MXN and USD, and the charge side (`datos2`/`FEE ANUAL`/ + * `fee15`) is MXN-only while receipts arrive in both. Applying a historical + * exchange rate to a 20-year ledger to produce one number would invent a figure + * the source data never had, so every total in this module is reported *per + * currency* and never collapsed. Balance filters and sorts therefore operate on + * one caller-chosen currency at a time. + */ + +/** Which side of the ledger a movement is on. */ +export type LedgerDirection = "charge" | "credit"; + +/** Balance buckets for the receivables worklist, on the selected currency. */ +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 type LedgerCurrency = "MXN" | "USD"; + +export interface MovementParams { + query?: string; + page: number; + pageSize: number; + domain?: TransactionDomain; + currency?: LedgerCurrency; + direction?: LedgerDirection; + typeId?: string; + source?: string; + customerId?: string; + /** Inclusive ISO date bounds on `transactionDate`. */ + from?: Date; + to?: Date; + sort: MovementSort; +} + +export interface BalanceParams { + query?: string; + page: number; + pageSize: number; + currency: LedgerCurrency; + balance: BalanceFilter; + /** Restricts the whole balance to one business line. */ + domain?: TransactionDomain; + sort: BalanceSort; +} + +/** Raw shape of the per-customer balance aggregate. */ +interface BalanceRow { + id: string; + name: string; + nameSource: string | null; + nameMissing: number; + city: string | null; + state: string | null; + movements: bigint | number | string; + balanceMxn: Prisma.Decimal | null; + balanceUsd: Prisma.Decimal | null; + chargesMxn: Prisma.Decimal | null; + creditsMxn: Prisma.Decimal | null; + chargesUsd: Prisma.Decimal | null; + creditsUsd: Prisma.Decimal | null; + utilityMovements: bigint | number | string; + insuranceMovements: bigint | number | string; + lastMovement: Date | null; +} + +/** + * Raw-query counts come back in three shapes depending on the aggregate: + * `COUNT(*)` as bigint, `SUM(bool)` as a decimal *string*, and plain numbers. + * Normalize all of them before they reach the client as JSON. + */ +function num(v: bigint | number | string | null | undefined): number { + if (v === null || v === undefined) return 0; + return typeof v === "number" ? v : Number(v); +} + +function dec(v: Prisma.Decimal | null | undefined): string { + return (v ?? new Prisma.Decimal(0)).toFixed(2); +} + +@Injectable() +export class BillingService { + constructor(private readonly prisma: PrismaService) {} + + private movementWhere(p: MovementParams): Prisma.TransactionWhereInput { + const and: Prisma.TransactionWhereInput[] = []; + + if (p.query && p.query.trim()) { + const q = p.query.trim(); + and.push({ + OR: [ + { customer: { name: { contains: q } } }, + { reference: { contains: q } }, + { checkNumber: { contains: q } }, + { message: { contains: q } }, + { period: { contains: q } }, + ], + }); + } + if (p.domain) and.push({ domain: p.domain }); + if (p.currency) and.push({ currency: p.currency }); + // A charge is strictly negative and a credit strictly positive; the ~193 + // zero-amount rows are neither and are excluded from both sides on purpose. + if (p.direction === "charge") and.push({ amount: { lt: 0 } }); + if (p.direction === "credit") and.push({ amount: { gt: 0 } }); + if (p.typeId) and.push({ typeId: p.typeId }); + if (p.source) and.push({ legacySourceTable: p.source }); + if (p.customerId) and.push({ customerId: p.customerId }); + if (p.from || p.to) { + and.push({ + transactionDate: { + ...(p.from ? { gte: p.from } : {}), + ...(p.to ? { lte: p.to } : {}), + }, + }); + } + + return and.length ? { AND: and } : {}; + } + + private movementOrderBy( + sort: MovementSort, + ): Prisma.TransactionOrderByWithRelationInput[] { + switch (sort) { + case "date_asc": + return [{ transactionDate: "asc" }]; + case "amount_desc": + return [{ amount: "desc" }]; + case "amount_asc": + return [{ amount: "asc" }]; + case "customer": + return [ + { customer: { nameMissing: "asc" } }, + { customer: { name: "asc" } }, + { transactionDate: "desc" }, + ]; + default: + return [{ transactionDate: "desc" }]; + } + } + + /** Cross-customer movement browser — every charge and credit, filterable. */ + async movements(params: MovementParams) { + const where = this.movementWhere(params); + + const [total, rows] = await this.prisma.$transaction([ + this.prisma.transaction.count({ where }), + this.prisma.transaction.findMany({ + where, + skip: (params.page - 1) * params.pageSize, + take: params.pageSize, + orderBy: this.movementOrderBy(params.sort), + select: { + id: true, + transactionDate: true, + domain: true, + amount: true, + currency: true, + reference: true, + period: true, + checkNumber: true, + message: true, + legacySourceTable: true, + type: { select: { nameEn: true, nameEs: true } }, + customer: { + select: { id: true, name: true, nameSource: true, city: true }, + }, + }, + }), + ]); + + // Totals for the *filtered set*, not just the page — the number staff read + // off a filtered view ("how much did we bill for water in April") has to + // cover everything the filter matched. + const totals = await this.prisma.transaction.groupBy({ + by: ["currency"], + where, + _sum: { amount: true }, + _count: { _all: true }, + }); + const charges = await this.prisma.transaction.groupBy({ + by: ["currency"], + where: { AND: [where, { amount: { lt: 0 } }] }, + _sum: { amount: true }, + _count: { _all: true }, + }); + const credits = await this.prisma.transaction.groupBy({ + by: ["currency"], + where: { AND: [where, { amount: { gt: 0 } }] }, + _sum: { amount: true }, + _count: { _all: true }, + }); + const chargeMap = new Map(charges.map((c) => [c.currency, c])); + const creditMap = new Map(credits.map((c) => [c.currency, c])); + + return { + items: rows.map((r) => ({ + id: r.id, + transactionDate: r.transactionDate, + domain: r.domain, + amount: r.amount, + currency: r.currency, + direction: r.amount.lessThan(0) ? "charge" : "credit", + reference: r.reference, + period: r.period, + checkNumber: r.checkNumber, + message: r.message, + source: r.legacySourceTable, + type: r.type, + customerId: r.customer.id, + customerName: r.customer.name, + customerNameSource: r.customer.nameSource, + customerCity: r.customer.city, + })), + total, + page: params.page, + pageSize: params.pageSize, + pageCount: Math.ceil(total / params.pageSize), + totals: totals.map((t) => ({ + currency: t.currency, + net: t._sum.amount, + count: t._count._all, + charges: chargeMap.get(t.currency)?._sum.amount ?? null, + chargeCount: chargeMap.get(t.currency)?._count._all ?? 0, + credits: creditMap.get(t.currency)?._sum.amount ?? null, + creditCount: creditMap.get(t.currency)?._count._all ?? 0, + })), + }; + } + + /** + * Receivables worklist: one row per customer with a ledger, carrying both + * currency balances, filtered/sorted on the caller's chosen currency. + * + * Raw SQL rather than Prisma `groupBy` because this needs conditional sums + * per currency *and* per direction in a single pass, plus ordering and + * pagination on a computed balance — none of which groupBy expresses. + */ + async balances(params: BalanceParams) { + const { query, page, pageSize, currency, balance, domain, sort } = params; + + const filters: Prisma.Sql[] = []; + if (domain) filters.push(Prisma.sql`t.domain = ${domain}`); + const txFilter = filters.length + ? Prisma.sql`AND ${Prisma.join(filters, " AND ")}` + : Prisma.empty; + + const nameFilter = + query && query.trim() + ? Prisma.sql`AND (c.name LIKE ${`%${query.trim()}%`} OR c.city LIKE ${`%${query.trim()}%`})` + : Prisma.empty; + + // The balance column the filter and sort act on. + const bal = + currency === "USD" + ? Prisma.sql`SUM(CASE WHEN t.currency = 'USD' THEN t.amount ELSE 0 END)` + : Prisma.sql`SUM(CASE WHEN t.currency = 'MXN' THEN t.amount ELSE 0 END)`; + + // "Owing" is a *negative* balance (see the sign convention above). The + // 0.005 threshold keeps rounding dust out of both worklists. + let having = Prisma.empty; + if (balance === "owing") having = Prisma.sql`HAVING ${bal} < -0.005`; + else if (balance === "credit") having = Prisma.sql`HAVING ${bal} > 0.005`; + else if (balance === "settled") + having = Prisma.sql`HAVING ${bal} BETWEEN -0.005 AND 0.005`; + + let orderBy: Prisma.Sql; + switch (sort) { + case "credit_desc": + orderBy = Prisma.sql`ORDER BY ${bal} DESC`; + break; + case "recent": + orderBy = Prisma.sql`ORDER BY MAX(t.transactionDate) DESC`; + break; + case "customer": + orderBy = Prisma.sql`ORDER BY c.nameMissing ASC, c.name ASC`; + break; + default: + // Deepest debt first — the point of the worklist. + orderBy = Prisma.sql`ORDER BY ${bal} ASC`; + } + + const rows = await this.prisma.$queryRaw` + SELECT + c.id, + c.name, + c.nameSource, + c.nameMissing, + c.city, + c.state, + COUNT(*) AS movements, + SUM(CASE WHEN t.currency = 'MXN' THEN t.amount ELSE 0 END) AS balanceMxn, + SUM(CASE WHEN t.currency = 'USD' THEN t.amount ELSE 0 END) AS balanceUsd, + SUM(CASE WHEN t.currency = 'MXN' AND t.amount < 0 THEN t.amount ELSE 0 END) AS chargesMxn, + SUM(CASE WHEN t.currency = 'MXN' AND t.amount > 0 THEN t.amount ELSE 0 END) AS creditsMxn, + SUM(CASE WHEN t.currency = 'USD' AND t.amount < 0 THEN t.amount ELSE 0 END) AS chargesUsd, + SUM(CASE WHEN t.currency = 'USD' AND t.amount > 0 THEN t.amount ELSE 0 END) AS creditsUsd, + SUM(t.domain = 'UTILITY') AS utilityMovements, + SUM(t.domain = 'INSURANCE') AS insuranceMovements, + MAX(t.transactionDate) AS lastMovement + FROM customers c + JOIN transactions t ON t.customerId = c.id + WHERE 1 = 1 ${nameFilter} ${txFilter} + GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state + ${having} + ${orderBy} + LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize} + `; + + const counted = await this.prisma.$queryRaw<{ total: bigint | number | string }[]>` + SELECT COUNT(*) AS total FROM ( + SELECT c.id + FROM customers c + JOIN transactions t ON t.customerId = c.id + WHERE 1 = 1 ${nameFilter} ${txFilter} + GROUP BY c.id + ${having} + ) x + `; + const total = num(counted[0]?.total); + + return { + items: rows.map((r) => ({ + id: r.id, + name: r.name, + nameSource: r.nameSource, + city: r.city, + state: r.state, + movements: num(r.movements), + utilityMovements: num(r.utilityMovements), + insuranceMovements: num(r.insuranceMovements), + lastMovement: r.lastMovement, + balances: [ + { + currency: "MXN", + balance: dec(r.balanceMxn), + charges: dec(r.chargesMxn), + credits: dec(r.creditsMxn), + }, + { + currency: "USD", + balance: dec(r.balanceUsd), + charges: dec(r.chargesUsd), + credits: dec(r.creditsUsd), + }, + ], + })), + total, + page, + pageSize, + pageCount: Math.ceil(total / pageSize), + currency, + }; + } + + /** Top-line figures for the billing page header. */ + async stats() { + const [movements, ledgerCustomers, byCurrency, byDomain] = await Promise.all([ + this.prisma.transaction.count(), + this.prisma.transaction + .findMany({ distinct: ["customerId"], select: { customerId: true } }) + .then((r) => r.length), + this.prisma.transaction.groupBy({ + by: ["currency"], + _sum: { amount: true }, + _count: { _all: true }, + }), + this.prisma.transaction.groupBy({ + by: ["domain", "currency"], + _sum: { amount: true }, + _count: { _all: true }, + }), + ]); + + const charges = await this.prisma.transaction.groupBy({ + by: ["currency"], + where: { amount: { lt: 0 } }, + _sum: { amount: true }, + _count: { _all: true }, + }); + const credits = await this.prisma.transaction.groupBy({ + by: ["currency"], + where: { amount: { gt: 0 } }, + _sum: { amount: true }, + _count: { _all: true }, + }); + const chargeMap = new Map(charges.map((c) => [c.currency, c])); + const creditMap = new Map(credits.map((c) => [c.currency, c])); + + // How many customers sit on each side of the line, per currency — the + // headline for a receivables view. Counted in SQL; a customer can be + // "owing" in MXN and "in credit" in USD, and both are true at once. + const sides = await this.prisma.$queryRaw< + { + currency: string; + owing: bigint | number | string; + inCredit: bigint | number | string; + }[] + >` + SELECT currency, + SUM(bal < -0.005) AS owing, + SUM(bal > 0.005) AS inCredit + FROM ( + SELECT customerId, currency, SUM(amount) AS bal + FROM transactions GROUP BY customerId, currency + ) x + GROUP BY currency + `; + const sideMap = new Map(sides.map((s) => [s.currency, s])); + + const [firstRow, lastRow] = await Promise.all([ + this.prisma.transaction.findFirst({ + orderBy: { transactionDate: "asc" }, + select: { transactionDate: true }, + }), + this.prisma.transaction.findFirst({ + orderBy: { transactionDate: "desc" }, + select: { transactionDate: true }, + }), + ]); + + // Customers whose ledger spans both business lines — the whole reason this + // module is one view instead of two. + const crossLine = await this.prisma.$queryRaw<{ n: bigint | number | string }[]>` + SELECT COUNT(*) AS n FROM ( + SELECT customerId FROM transactions + GROUP BY customerId HAVING COUNT(DISTINCT domain) > 1 + ) x + `; + + return { + movements, + ledgerCustomers, + crossLineCustomers: num(crossLine[0]?.n), + firstMovement: firstRow?.transactionDate ?? null, + lastMovement: lastRow?.transactionDate ?? null, + byCurrency: byCurrency.map((c) => ({ + currency: c.currency, + net: c._sum.amount, + count: c._count._all, + charges: chargeMap.get(c.currency)?._sum.amount ?? null, + chargeCount: chargeMap.get(c.currency)?._count._all ?? 0, + credits: creditMap.get(c.currency)?._sum.amount ?? null, + creditCount: creditMap.get(c.currency)?._count._all ?? 0, + owing: num(sideMap.get(c.currency)?.owing), + inCredit: num(sideMap.get(c.currency)?.inCredit), + })), + byDomain: byDomain.map((d) => ({ + domain: d.domain, + currency: d.currency, + net: d._sum.amount, + count: d._count._all, + })), + }; + } + + /** Filter dropdown options for the movement browser. */ + async facets() { + const types = await this.prisma.transaction.groupBy({ + by: ["typeId"], + where: { typeId: { not: null } }, + _count: { _all: true }, + orderBy: { _count: { typeId: "desc" } }, + }); + const typeRows = await this.prisma.typeTransaction.findMany({ + where: { id: { in: types.map((t) => t.typeId as string) } }, + select: { id: true, nameEn: true, nameEs: true }, + }); + const typeMap = new Map(typeRows.map((t) => [t.id, t])); + + const sources = await this.prisma.transaction.groupBy({ + by: ["legacySourceTable"], + _count: { _all: true }, + orderBy: { _count: { legacySourceTable: "desc" } }, + }); + + const years = await this.prisma.$queryRaw< + { year: number; count: bigint | number | string }[] + >` + SELECT YEAR(transactionDate) AS year, COUNT(*) AS count + FROM transactions GROUP BY year ORDER BY year DESC + `; + + return { + types: types + .map((t) => { + const row = typeMap.get(t.typeId as string); + return { + id: t.typeId as string, + name: row?.nameEs || row?.nameEn || "—", + count: t._count._all, + }; + }) + .filter((t) => t.name !== "—"), + sources: sources.map((s) => ({ + name: s.legacySourceTable ?? "—", + count: s._count._all, + })), + years: years.map((y) => ({ year: Number(y.year), count: num(y.count) })), + }; + } + + /** + * One customer's statement across both business lines. + * + * Returns the *whole* ledger rather than a page of it: the heaviest customer + * carries 365 movements (mean 26), and a running balance is meaningless if + * the client only holds a slice. The running balance is accumulated per + * currency in chronological order, then the list is handed back newest-first + * with each row's balance-after already attached. + */ + async statement(customerId: string) { + const customer = await this.prisma.customer.findUnique({ + where: { id: customerId }, + select: { + id: true, + name: true, + nameSource: true, + addressLine1: true, + city: true, + state: true, + phone: true, + mobile: true, + email: true, + customerSince: true, + preferredCurrency: true, + status: true, + _count: { select: { properties: true, policies: true } }, + }, + }); + + if (!customer) { + throw new NotFoundException(`Customer ${customerId} not found`); + } + + const rows = await this.prisma.transaction.findMany({ + where: { customerId }, + orderBy: [{ transactionDate: "asc" }, { id: "asc" }], + select: { + id: true, + transactionDate: true, + domain: true, + amount: true, + currency: true, + reference: true, + period: true, + checkNumber: true, + message: true, + legacySourceTable: true, + type: { select: { nameEn: true, nameEs: true } }, + }, + }); + + const running = new Map(); + const movements = rows.map((r) => { + const prev = running.get(r.currency) ?? new Prisma.Decimal(0); + const next = prev.plus(r.amount); + running.set(r.currency, next); + return { + id: r.id, + transactionDate: r.transactionDate, + domain: r.domain, + amount: r.amount, + currency: r.currency, + direction: r.amount.lessThan(0) ? "charge" : "credit", + reference: r.reference, + period: r.period, + checkNumber: r.checkNumber, + message: r.message, + source: r.legacySourceTable, + type: r.type, + /** Balance in this row's currency after applying it. */ + balanceAfter: next.toFixed(2), + }; + }); + movements.reverse(); + + // Per-currency summary, and the same split by business line so the two + // ledgers are visibly one statement without being illegally added up. + const perCurrency = new Map< + string, + { + currency: string; + charges: Prisma.Decimal; + credits: Prisma.Decimal; + chargeCount: number; + creditCount: number; + count: number; + first: Date | null; + last: Date | null; + } + >(); + const perDomain = new Map< + string, + { + domain: TransactionDomain; + currency: string; + charges: Prisma.Decimal; + credits: Prisma.Decimal; + count: number; + } + >(); + + for (const r of rows) { + const c = + perCurrency.get(r.currency) ?? + { + currency: r.currency, + charges: new Prisma.Decimal(0), + credits: new Prisma.Decimal(0), + chargeCount: 0, + creditCount: 0, + count: 0, + first: null as Date | null, + last: null as Date | null, + }; + c.count += 1; + if (r.amount.lessThan(0)) { + c.charges = c.charges.plus(r.amount); + c.chargeCount += 1; + } else if (r.amount.greaterThan(0)) { + c.credits = c.credits.plus(r.amount); + c.creditCount += 1; + } + if (!c.first) c.first = r.transactionDate; + c.last = r.transactionDate; + perCurrency.set(r.currency, c); + + const dk = `${r.domain}|${r.currency}`; + const d = + perDomain.get(dk) ?? + { + domain: r.domain, + currency: r.currency, + charges: new Prisma.Decimal(0), + credits: new Prisma.Decimal(0), + count: 0, + }; + d.count += 1; + if (r.amount.lessThan(0)) d.charges = d.charges.plus(r.amount); + else if (r.amount.greaterThan(0)) d.credits = d.credits.plus(r.amount); + perDomain.set(dk, d); + } + + // Where the money goes, per charge type — the question a customer asks + // when they query their balance. + const byType = new Map< + string, + { name: string; currency: string; total: Prisma.Decimal; count: number } + >(); + for (const r of rows) { + if (!r.amount.lessThan(0)) continue; + const name = r.type?.nameEs || r.type?.nameEn || "Sin clasificar"; + const key = `${name}|${r.currency}`; + const e = + byType.get(key) ?? + { name, currency: r.currency, total: new Prisma.Decimal(0), count: 0 }; + e.total = e.total.plus(r.amount); + e.count += 1; + byType.set(key, e); + } + + return { + customer: { + ...customer, + propertyCount: customer._count.properties, + policyCount: customer._count.policies, + }, + summary: [...perCurrency.values()].map((c) => ({ + currency: c.currency, + charges: c.charges.toFixed(2), + credits: c.credits.toFixed(2), + balance: c.charges.plus(c.credits).toFixed(2), + chargeCount: c.chargeCount, + creditCount: c.creditCount, + count: c.count, + firstMovement: c.first, + lastMovement: c.last, + })), + byDomain: [...perDomain.values()].map((d) => ({ + domain: d.domain, + currency: d.currency, + charges: d.charges.toFixed(2), + credits: d.credits.toFixed(2), + balance: d.charges.plus(d.credits).toFixed(2), + count: d.count, + })), + byType: [...byType.values()] + .map((t) => ({ + name: t.name, + currency: t.currency, + total: t.total.toFixed(2), + count: t.count, + })) + .sort((a, b) => Number(a.total) - Number(b.total)), + movements, + }; + } +} diff --git a/apps/web/src/app/clientes/[id]/page.tsx b/apps/web/src/app/clientes/[id]/page.tsx index c276990..b13bf7c 100644 --- a/apps/web/src/app/clientes/[id]/page.tsx +++ b/apps/web/src/app/clientes/[id]/page.tsx @@ -93,6 +93,7 @@ function Detail({ id }: { id: string }) { @@ -574,9 +575,11 @@ function InstallmentRow({ inst }: { inst: Installment }) { /* ------------------------------------------------------- Estado de cuenta */ function EstadoCuentaSection({ + customerId, summary, transactions, }: { + customerId: string; summary: TransactionSummaryRow[]; transactions: Transaction[]; }) { @@ -639,6 +642,14 @@ function EstadoCuentaSection({ )} + {transactions.length > 0 && ( +

+ + Ver estado de cuenta completo → + {" "} + con saldo, saldo corrido y desglose por línea de negocio. +

+ )} ); } diff --git a/apps/web/src/app/estado-cuenta/[id]/page.tsx b/apps/web/src/app/estado-cuenta/[id]/page.tsx new file mode 100644 index 0000000..a684c09 --- /dev/null +++ b/apps/web/src/app/estado-cuenta/[id]/page.tsx @@ -0,0 +1,500 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { AppShell } from "@/components/AppShell"; +import { getStatement } from "@/lib/api"; +import { + balancePhrase, + balanceTone, + directionLabel, + domainLabel, + formatDate, + formatMoney, + formatNumber, + ledgerSourceLabel, + SIN_NOMBRE, + txTypeLabel, +} from "@/lib/labels"; +import type { + LedgerCurrency, + Statement, + StatementMovement, + TransactionDomain, +} from "@/lib/types"; + +/** + * One customer's statement across both business lines — the payoff of plan + * step 6 and, ultimately, of the whole unified-customer project: a utility + * charge and an insurance payment finally sit on the same page, under the same + * person, with a running balance. + * + * The running balance is per currency (the API accumulates it chronologically + * before handing the list back newest-first), so the movement table is scoped + * to one currency at a time — a column that alternated between pesos and + * dollars would be a meaningless number. + */ +export default function EstadoCuentaDetailPage({ + params, +}: { + params: { id: string }; +}) { + const { id } = params; + return ( + + + + ); +} + +function StatementView({ id }: { id: string }) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [currency, setCurrency] = useState(null); + const [domain, setDomain] = useState(""); + + useEffect(() => { + let alive = true; + setLoading(true); + setError(null); + getStatement(id) + .then((d) => { + if (!alive) return; + setData(d); + // Default to the currency the customer actually moves the most in. + const busiest = [...d.summary].sort((a, b) => b.count - a.count)[0]; + setCurrency(busiest?.currency ?? "MXN"); + setLoading(false); + }) + .catch((e) => { + if (!alive) return; + setError( + e?.status === 404 + ? "No encontramos este cliente." + : e?.message ?? "No se pudo cargar el estado de cuenta.", + ); + setLoading(false); + }); + return () => { + alive = false; + }; + }, [id]); + + const movements = useMemo(() => { + if (!data || !currency) return []; + return data.movements.filter( + (m) => m.currency === currency && (!domain || m.domain === domain), + ); + }, [data, currency, domain]); + + if (loading) return ; + + if (error) + return ( + <> + +
+ {error} +
+ + ); + + if (!data || !currency) return null; + + const active = data.summary.find((s) => s.currency === currency); + + return ( +
+ + + +
+ + {data.summary.length === 0 ? ( +
+
+ Este cliente no tiene movimientos registrados. +
+
+ ) : ( +
+ {data.summary.map((s) => { + const tone = balanceTone(s.balance); + return ( + + ); + })} +
+ )} +

+ Los saldos se muestran por separado en cada moneda. La contabilidad + heredada registró los cargos únicamente en pesos y los recibos en + ambas monedas, sin guardar el tipo de cambio aplicado a cada + movimiento, por lo que sumarlas produciría una cifra que nunca existió + en los libros. +

+
+ + + + +
+ + +
+ + +
+ +
+ {movements.length === 0 ? ( +
+ Sin movimientos en {currency} + {domain ? ` para ${domainLabel(domain)}` : ""}. +
+ ) : ( +
+ + + + + + + + + + + + + {movements.map((m) => ( + + ))} + +
FechaLíneaConceptoReferenciaCargo / AbonoSaldo
+
+ )} + {domain && movements.length > 0 && ( +
+ La columna de saldo es el saldo acumulado del cliente en{" "} + {currency} sobre todas sus líneas — filtrar por + línea oculta filas, no las descuenta. +
+ )} +
+ {active && ( +

+ Saldo final en {currency}:{" "} + {formatMoney(active.balance, currency)} ( + {balancePhrase(active.balance).toLowerCase()}). +

+ )} +
+
+ ); +} + +function BackLink() { + return ( + + ← Volver a Estado de cuenta + + ); +} + +function Hero({ data }: { data: Statement }) { + const c = data.customer; + const location = [c.city?.replace(/,\s*$/, ""), c.state] + .filter(Boolean) + .join(", "); + + const facts: { label: string; value: string }[] = [ + { label: "Cliente desde", value: formatDate(c.customerSince) }, + { label: "Propiedades", value: String(c.propertyCount) }, + { label: "Pólizas", value: String(c.policyCount) }, + { label: "Teléfono", value: c.phone || c.mobile || "—" }, + { label: "Correo", value: c.email || "—" }, + ]; + + return ( +
+
+
+

+ {c.name} +

+ {location &&
{location}
} + {c.nameSource && ( +
+ Nombre recuperado de {c.nameSource} — el registro original no + tenía nombre. +
+ )} +
+
+ {c.propertyCount > 0 && ( + + Servicios + + )} + {c.policyCount > 0 && ( + + Seguros + + )} + + {c.status ? "Activo" : "Inactivo"} + +
+
+
+ {facts.map((f) => ( +
+
{f.label}
+
{f.value}
+
+ ))} +
+
+ + Ver ficha del cliente + +
+
+ ); +} + +/** The cross-line split — the same balance, broken out by business line. */ +function PorLineaSection({ + data, + currency, +}: { + data: Statement; + currency: LedgerCurrency; +}) { + const rows = data.byDomain.filter((d) => d.currency === currency); + if (rows.length === 0) return null; + + return ( +
+ +
+ {rows.map((r) => ( +
+
+ + {domainLabel(r.domain)} +
+
+ {formatMoney(r.balance, currency)} +
+
+ + {formatMoney(r.charges, currency)} + + en cargos + + {formatMoney(r.credits, currency)} + + en abonos +
+
+ {formatNumber(r.count)}{" "} + {r.count === 1 ? "movimiento" : "movimientos"} +
+
+ ))} +
+
+ ); +} + +/** Where the charges went — the question a customer asks about their balance. */ +function ConceptosSection({ + data, + currency, +}: { + data: Statement; + currency: LedgerCurrency; +}) { + const rows = data.byType.filter((t) => t.currency === currency).slice(0, 12); + if (rows.length === 0) return null; + + const largest = Math.abs(Number(rows[0]?.total ?? 0)) || 1; + + return ( +
+ +
+
+ {rows.map((t) => ( +
+
+ {txTypeLabel({ nameEn: t.name })} + + {formatNumber(t.count)}{" "} + {t.count === 1 ? "cargo" : "cargos"} + +
+
+ +
+
+ {formatMoney(t.total, currency)} +
+
+ ))} +
+
+
+ ); +} + +function StatementRow({ m }: { m: StatementMovement }) { + const concept = m.message || m.period || null; + return ( + + + {formatDate(m.transactionDate)} + + + + {domainLabel(m.domain)} + + + {txTypeLabel(m.type)} + {concept &&
{concept}
} + + + {m.reference || m.checkNumber || "—"} +
{ledgerSourceLabel(m.source)}
+ + + + {formatMoney(m.amount, m.currency)} + + {directionLabel(m.direction)} + + + + {formatMoney(m.balanceAfter, m.currency)} + + + + ); +} + +function SectionHead({ + rule, + title, + count, + countSuffix, +}: { + rule: string; + title: string; + count?: number; + countSuffix?: string; +}) { + return ( +
+ +

{title}

+ {count != null && ( + + {formatNumber(count)} {countSuffix ?? ""} + + )} +
+ ); +} + +function StatementSkeleton() { + return ( +
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/estado-cuenta/page.tsx b/apps/web/src/app/estado-cuenta/page.tsx new file mode 100644 index 0000000..9034c3f --- /dev/null +++ b/apps/web/src/app/estado-cuenta/page.tsx @@ -0,0 +1,845 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { AppShell } from "@/components/AppShell"; +import { + getBillingFacets, + getBillingStats, + listBalances, + listMovements, +} from "@/lib/api"; +import { + balancePhrase, + balanceTone, + directionLabel, + domainLabel, + formatDate, + formatMoney, + formatNumber, + ledgerSourceLabel, + SIN_NOMBRE, + txTypeLabel, +} from "@/lib/labels"; +import type { + BalanceFilter, + BalanceListItem, + BalanceListResponse, + BalanceSort, + BillingFacets, + BillingStats, + LedgerCurrency, + LedgerDirection, + MovementListItem, + MovementListResponse, + MovementSort, + TransactionDomain, +} from "@/lib/types"; + +/** + * Shared billing / statements browser — plan step 6. + * + * Two views over the same ledger, because staff ask two different questions: + * - "Saldos": who owes what, one row per customer. The receivables worklist. + * - "Movimientos": every individual charge and credit, filterable — the + * answer to "what did we bill for water in April". + * + * Both are cross-line: a customer's utility charges and insurance movements sit + * in the same ledger, which is the point of the unified customer record. + * + * Balances are always shown *per currency* and never added together — see the + * currency note in `billing.service.ts`. + */ +type View = "saldos" | "movimientos"; + +const BALANCE_FILTERS: { key: BalanceFilter; label: string }[] = [ + { key: "owing", label: "Con adeudo" }, + { key: "credit", label: "Con saldo a favor" }, + { key: "settled", label: "En ceros" }, + { key: "all", label: "Todos" }, +]; + +const BALANCE_SORTS: { key: BalanceSort; label: string }[] = [ + { key: "owing_desc", label: "Mayor adeudo primero" }, + { key: "credit_desc", label: "Mayor saldo a favor primero" }, + { key: "recent", label: "Movimiento más reciente" }, + { key: "customer", label: "Cliente (A–Z)" }, +]; + +const MOVEMENT_SORTS: { key: MovementSort; label: string }[] = [ + { key: "date_desc", label: "Fecha (más reciente)" }, + { key: "date_asc", label: "Fecha (más antigua)" }, + { key: "amount_asc", label: "Cargo más grande" }, + { key: "amount_desc", label: "Abono más grande" }, + { key: "customer", label: "Cliente (A–Z)" }, +]; + +const DIRECTIONS: { key: LedgerDirection | ""; label: string }[] = [ + { key: "", label: "Cargos y abonos" }, + { key: "charge", label: "Sólo cargos" }, + { key: "credit", label: "Sólo abonos" }, +]; + +const DOMAINS: { key: TransactionDomain | ""; label: string }[] = [ + { key: "", label: "Ambas líneas" }, + { key: "UTILITY", label: "Servicios" }, + { key: "INSURANCE", label: "Seguros" }, +]; + +export default function EstadoCuentaPage() { + return ( + + + + ); +} + +function BillingBrowser() { + const [stats, setStats] = useState(null); + const [facets, setFacets] = useState(null); + const [view, setView] = useState("saldos"); + + // The currency every balance figure is filtered and sorted on. MXN is the + // default because the charge side of the ledger is MXN-only. + const [currency, setCurrency] = useState("MXN"); + const [query, setQuery] = useState(""); + const [domain, setDomain] = useState(""); + + const [balanceFilter, setBalanceFilter] = useState("owing"); + const [balanceSort, setBalanceSort] = useState("owing_desc"); + + const [direction, setDirection] = useState(""); + const [typeId, setTypeId] = useState(""); + const [source, setSource] = useState(""); + const [from, setFrom] = useState(""); + const [to, setTo] = useState(""); + const [movementSort, setMovementSort] = useState("date_desc"); + + const [balances, setBalances] = useState(null); + const [movements, setMovements] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const debounceRef = useRef>(); + + useEffect(() => { + getBillingStats().then(setStats).catch(() => setStats(null)); + getBillingFacets().then(setFacets).catch(() => setFacets(null)); + }, []); + + const runSearch = useCallback( + (p: number) => { + setLoading(true); + setError(null); + const done = (fn: () => void) => { + fn(); + setLoading(false); + }; + if (view === "saldos") { + listBalances({ + query: query || undefined, + currency, + balance: balanceFilter, + domain: domain || undefined, + sort: balanceSort, + page: p, + pageSize: 25, + }) + .then((res) => done(() => setBalances(res))) + .catch((e) => { + setError(e?.message ?? "No se pudieron cargar los saldos."); + setLoading(false); + }); + } else { + listMovements({ + query: query || undefined, + currency, + domain: domain || undefined, + direction: direction || undefined, + typeId: typeId || undefined, + source: source || undefined, + from: from || undefined, + to: to || undefined, + sort: movementSort, + page: p, + pageSize: 25, + }) + .then((res) => done(() => setMovements(res))) + .catch((e) => { + setError(e?.message ?? "No se pudieron cargar los movimientos."); + setLoading(false); + }); + } + }, + [ + view, + query, + currency, + domain, + balanceFilter, + balanceSort, + direction, + typeId, + source, + from, + to, + movementSort, + ], + ); + + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => runSearch(1), 280); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [runSearch]); + + function goToPage(p: number) { + runSearch(p); + if (typeof window !== "undefined") + window.scrollTo({ top: 0, behavior: "smooth" }); + } + + /** Jumping in from a headline count should land on the matching worklist. */ + function pickBalance(f: BalanceFilter, cur?: LedgerCurrency) { + setView("saldos"); + setBalanceFilter(f); + if (cur) setCurrency(cur); + setBalanceSort(f === "credit" ? "credit_desc" : "owing_desc"); + } + + const data = view === "saldos" ? balances : movements; + const filtered = + query !== "" || + domain !== "" || + (view === "saldos" + ? balanceFilter !== "owing" || balanceSort !== "owing_desc" + : direction !== "" || + typeId !== "" || + source !== "" || + from !== "" || + to !== "" || + movementSort !== "date_desc"); + + function clearFilters() { + setQuery(""); + setDomain(""); + setCurrency("MXN"); + setBalanceFilter("owing"); + setBalanceSort("owing_desc"); + setDirection(""); + setTypeId(""); + setSource(""); + setFrom(""); + setTo(""); + setMovementSort("date_desc"); + } + + return ( + <> +
+

Cobranza y facturación

+

Estado de cuenta

+ + +
+ +
+
+ + ⌕ + + setQuery(e.target.value)} + placeholder={ + view === "saldos" + ? "Buscar por cliente o ciudad…" + : "Buscar por cliente, referencia, cheque, concepto…" + } + aria-label="Buscar en el estado de cuenta" + /> +
+
+ {( + [ + { key: "saldos" as View, label: "Saldos por cliente" }, + { key: "movimientos" as View, label: "Movimientos" }, + ] + ).map((v) => ( + + ))} +
+
+ +
+ + + + + {view === "saldos" ? ( + <> + + + + ) : ( + <> + + + + + + + + + + + + + )} + + {filtered && ( + + )} +
+ + {data && !loading && !error && ( +
+ {data.total === 0 + ? "Sin resultados" + : view === "saldos" + ? `${formatNumber(data.total)} ${ + data.total === 1 ? "cliente" : "clientes" + } · saldo en ${currency}` + : `${formatNumber(data.total)} ${ + data.total === 1 ? "movimiento" : "movimientos" + }`} + {query ? ` para “${query}”` : ""} +
+ )} + + {view === "movimientos" && movements && !loading && ( + + )} + + {error ? ( +
+ {error} +
+ ) : loading ? ( + + ) : data && data.total === 0 ? ( + + ) : view === "saldos" ? ( + <> +
+ {balances?.items.map((b) => ( + + ))} +
+ {balances && balances.pageCount > 1 && ( + + )} + + ) : ( + <> +
+
+ + + + + + + + + + + + + {movements?.items.map((m) => ( + + ))} + +
FechaClienteLíneaConceptoReferenciaMonto
+
+
+ {movements && movements.pageCount > 1 && ( + + )} + + )} + + ); +} + +/** Headline counts; the owing/credit cells double as worklist shortcuts. */ +function BillingStatStrip({ + stats, + currency, + balanceFilter, + onPickBalance, +}: { + stats: BillingStats | null; + currency: LedgerCurrency; + balanceFilter: BalanceFilter | null; + onPickBalance: (f: BalanceFilter, cur?: LedgerCurrency) => void; +}) { + if (!stats) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+ ))} +
+ ); + } + + const cur = stats.byCurrency.find((c) => c.currency === currency); + + return ( +
+ + + +
+
{formatNumber(stats.movements)}
+
+ Movimientos · {formatDate(stats.firstMovement)} a{" "} + {formatDate(stats.lastMovement)} +
+
+
+
+ {formatNumber(stats.crossLineCustomers)} +
+
Con movimientos en ambas líneas
+
+
+ ); +} + +/** + * Charges vs credits for the whole ledger, per currency. Kept as two separate + * chips rather than one figure: the two currencies are never added together. + */ +function LedgerTotalsStrip({ stats }: { stats: BillingStats | null }) { + if (!stats || stats.byCurrency.length === 0) return null; + return ( +
+ Movimiento histórico + {stats.byCurrency.map((c) => ( +
+ {c.currency} + + + {formatMoney(c.charges, c.currency)} + + + en cargos · {formatNumber(c.chargeCount)} + + + + + {formatMoney(c.credits, c.currency)} + + + en abonos · {formatNumber(c.creditCount)} + + +
+ ))} +
+ ); +} + +/** Totals for everything the current movement filter matched, not just the page. */ +function FilteredTotals({ + totals, +}: { + totals: MovementListResponse["totals"]; +}) { + if (totals.length === 0) return null; + return ( +
+ {totals.map((t) => ( +
+ {t.currency} + + + {formatMoney(t.charges, t.currency)} + {" "} + cargos + + + + {formatMoney(t.credits, t.currency)} + {" "} + abonos + + + Neto {formatMoney(t.net, t.currency)} + +
+ ))} +
+ ); +} + +function BalanceRow({ + b, + currency, +}: { + b: BalanceListItem; + currency: LedgerCurrency; +}) { + const selected = + b.balances.find((x) => x.currency === currency) ?? b.balances[0]; + const other = b.balances.find((x) => x.currency !== currency); + const tone = balanceTone(selected.balance); + const location = [b.city?.replace(/,\s*$/, ""), b.state] + .filter(Boolean) + .join(", "); + + return ( + +
+
+ + {b.name} + + {b.utilityMovements > 0 && ( + + Servicios + + )} + {b.insuranceMovements > 0 && ( + + Seguros + + )} +
+
+ {location && {location}} + {location && ·} + + {formatNumber(b.movements)}{" "} + {b.movements === 1 ? "movimiento" : "movimientos"} + + · + último {formatDate(b.lastMovement)} +
+
+
+
+ {formatMoney(selected.balance, selected.currency)} +
+
+ {balancePhrase(selected.balance)} · {selected.currency} +
+ {other && Math.abs(Number(other.balance)) >= 0.005 && ( +
+ {formatMoney(other.balance, other.currency)} en {other.currency} +
+ )} +
+ + ); +} + +function MovementRow({ m }: { m: MovementListItem }) { + return ( + + + {formatDate(m.transactionDate)} + + + + + {m.customerName} + + + + + + {domainLabel(m.domain)} + + + {txTypeLabel(m.type)} + {m.message &&
{m.message}
} + + + {m.reference || m.checkNumber || "—"} +
{ledgerSourceLabel(m.source)}
+ + + + {formatMoney(m.amount, m.currency)} + + + {m.currency} · {directionLabel(m.direction)} + + + + ); +} + +function Pager({ + page, + pageCount, + onChange, +}: { + page: number; + pageCount: number; + onChange: (p: number) => void; +}) { + return ( + + ); +} + +function ListSkeleton() { + return ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+ ); +} + +function EmptyState({ query, view }: { query: string; view: View }) { + return ( +
+
+ ⌕ +
+

Sin resultados

+

+ {query + ? `No encontramos ${ + view === "saldos" ? "clientes" : "movimientos" + } para “${query}”.` + : `No hay ${ + view === "saldos" ? "saldos" : "movimientos" + } que coincidan con los filtros.`} +

+
+ ); +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 3d348ff..f8f6505 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1820,3 +1820,228 @@ button { white-space: nowrap; border: 0; } + +/* ================================================================ + Estado de cuenta (billing / statements) — plan step 6 + ================================================================ */ + +/* A balance is signed: negative means the customer owes the office, positive + means they hold a credit. `flat` is a real state (settled to zero), not a + fallback, so it gets its own muted treatment rather than inheriting either. */ +.bal-amount { + font-family: var(--font-mono); + font-weight: 700; + font-feature-settings: "tnum" 1; + white-space: nowrap; +} +.bal-amount.owing { + color: var(--negative); +} +.bal-amount.credit { + color: var(--positive); +} +.bal-amount.flat { + color: var(--muted); +} +.bal-running { + font-family: var(--font-mono); + font-size: 12.5px; + font-feature-settings: "tnum" 1; + white-space: nowrap; + color: var(--ink-soft); +} +.bal-running.owing { + color: var(--negative); +} +.bal-running.credit { + color: var(--positive); +} + +.bal-row .bal-side { + text-align: right; + display: flex; + flex-direction: column; + gap: 2px; + align-items: flex-end; + min-width: 160px; +} +.bal-side .bal-amount { + font-size: 16px; +} +.bal-phrase { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; +} +.bal-phrase.owing { + color: var(--negative); +} +.bal-phrase.credit { + color: var(--positive); +} +.bal-phrase.flat { + color: var(--muted); +} +/* The customer's other-currency balance, shown so a peso figure is never + mistaken for the whole picture. */ +.bal-other { + font-size: 11.5px; + color: var(--muted); + font-family: var(--font-mono); +} +@media (max-width: 640px) { + .bal-row .bal-side { + align-items: flex-start; + text-align: left; + min-width: 0; + } +} + +/* Currency cards on the statement double as the movement-table currency + switch, so they are buttons, not divs. */ +.bal-card { + text-align: left; + cursor: pointer; + font: inherit; + border: 1px solid var(--line); + transition: border-color 0.15s ease, transform 0.15s ease; +} +.bal-card:hover { + transform: translateY(-1px); +} +.bal-card.selected { + border-color: var(--brand-600); + box-shadow: 0 0 0 1px var(--brand-600); +} +.bal-breakdown { + display: grid; + grid-template-columns: auto 1fr; + gap: 1px 8px; + margin-top: 8px; + font-size: 12px; + align-items: baseline; +} +.bal-breakdown-label { + color: var(--muted); + font-size: 11.5px; +} + +/* Ledger-wide charge/credit totals in the page header. */ +.ledger-chip { + display: flex; + align-items: center; + gap: 14px; + padding: 8px 14px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface-2); +} +.ledger-chip-cur { + font-weight: 700; + font-size: 12px; + letter-spacing: 0.06em; + color: var(--muted); +} +.ledger-chip-figs { + display: flex; + flex-direction: column; + line-height: 1.3; +} +.ledger-chip-label { + font-size: 11px; + color: var(--muted); +} + +/* Totals for the current movement filter — deliberately above the table, so a + filtered view can't be read as if it were the whole ledger. */ +.filtered-totals { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 12px; +} +.filtered-total { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 14px; + padding: 9px 14px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface-2); + font-size: 12.5px; + color: var(--muted); +} +.filtered-total-cur { + font-weight: 700; + letter-spacing: 0.06em; + color: var(--ink-soft); +} +.filtered-total-net strong { + font-family: var(--font-mono); + color: var(--ink); +} + +/* Charges broken out by concept, with a proportional bar. */ +.concept-list { + display: flex; + flex-direction: column; +} +.concept-row { + display: grid; + grid-template-columns: minmax(150px, 1.2fr) minmax(60px, 2fr) auto; + gap: 14px; + align-items: center; + padding: 9px 0; + border-bottom: 1px solid var(--line); +} +.concept-row:last-child { + border-bottom: none; +} +.concept-name { + font-size: 13.5px; + font-weight: 600; + display: flex; + flex-direction: column; +} +.concept-count { + font-size: 11.5px; + font-weight: 500; + color: var(--muted); +} +.concept-bar { + height: 7px; + background: var(--surface-2); + border-radius: 4px; + overflow: hidden; +} +.concept-bar span { + display: block; + height: 100%; + border-radius: 4px; + background: var(--negative); + opacity: 0.55; +} +.concept-total { + text-align: right; + font-size: 13px; +} +@media (max-width: 640px) { + .concept-row { + grid-template-columns: 1fr auto; + } + .concept-bar { + display: none; + } +} + +/* Cross-link out of a detail hero (statement -> customer file). */ +.hero-links { + margin-top: 16px; + display: flex; + gap: 10px; + flex-wrap: wrap; + position: relative; + z-index: 1; +} diff --git a/apps/web/src/app/servicios/[id]/page.tsx b/apps/web/src/app/servicios/[id]/page.tsx index 021c1d8..7b422d2 100644 --- a/apps/web/src/app/servicios/[id]/page.tsx +++ b/apps/web/src/app/servicios/[id]/page.tsx @@ -438,7 +438,10 @@ function MovimientosSection({ data }: { data: PropertyDetail }) {
Los movimientos pertenecen al cliente, no a esta propiedad: el sistema anterior nunca ligó un pago a una propiedad concreta. Ver el{" "} - + estado de cuenta completo . diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index d89a146..dbfe530 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -15,6 +15,7 @@ const NAV = [ { href: "/clientes", label: "Clientes" }, { href: "/servicios", label: "Propiedades" }, { href: "/polizas", label: "Pólizas" }, + { href: "/estado-cuenta", label: "Estado de cuenta" }, ]; export function AppShell({ children }: { children: ReactNode }) { diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 70403f9..c00a804 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -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 { return apiFetch(`/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 { + 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(`/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 { + 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(`/billing/balances${qs ? `?${qs}` : ""}`); +} + +export function getBillingStats(): Promise { + return apiFetch("/billing/stats"); +} + +export function getBillingFacets(): Promise { + return apiFetch("/billing/facets"); +} + +export function getStatement(customerId: string): Promise { + return apiFetch(`/billing/customers/${customerId}`); +} diff --git a/apps/web/src/lib/labels.ts b/apps/web/src/lib/labels.ts index 0f59cc7..b71e5d7 100644 --- a/apps/web/src/lib/labels.ts +++ b/apps/web/src/lib/labels.ts @@ -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 = { + 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 = { + 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 = { + datos2: "Facturación 2025–26", + "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( diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index ecfc92e..a369800 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -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;