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;