feat(billing): receipt capture — outstanding workflow, batch by check, reconciliation
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m1s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m3s

Implements docs/RECEIPT_CAPTURE_SPEC.md §1, the legacy "Editor"
replacement, on top of the single-movement capture from plan step 6.
No new abilities: batching and resolving are both capturing.

- outstanding (legacy NOPAGO): capture flag, ?outstanding= filter, and
  POST /billing/:id/resolve-outstanding (gated ledger:create, not
  ledger:void — resolving completes a capture rather than reversing
  one). Outstanding rows are excluded from every balance aggregate,
  matching the legacy SALDOS ULTIMO 0 query's HAVING NOPAGO = 0, but
  still count in the movement browser's filtered totals.
- POST /billing/batch: many customers' receipts against one check, in
  one $transaction. Deliberately not a persisted batch entity —
  checkNumber is already a column and grouping by it answers every
  legacy by-check query.
- GET /billing/by-check + a cheque-count report, replacing REPORTE
  CHEQUE COUNT / REPORTE POR CHEQUE / EDITA CHEQUE ALF|COUNT|NUM. Print,
  PDF, CSV and XLSX come free from the existing /reportes/:slug machinery.
- Web: /estado-cuenta/lote (the Editor screen, with live reconciliation
  against the physical check amount), an "Estado de pago" filter, a
  "sin fondos" row tag and a Resolver dialog, plus a top-level "Captura"
  nav entry.

Integration seam for the OCR auto-capture module (spec §2), which is
required to post through createBatch rather than writing Transaction
rows itself: items[i] maps to lines[i] so postedTransactionId can be
zipped back on; opts.refs[i] stamps captureRef with a duplicate-post
guard that a voided row deliberately does not block; opts.source is
service-level only, so an HTTP client cannot label hand-keyed rows as
machine-captured. captureSource/captureRef are nullable so the 40,136
migrated rows stay NULL rather than being mislabelled.

Fixes two pre-existing bugs found while building this:

- statement() filtered legacySourceTable with `notIn`, which compiles to
  SQL NOT IN — and `NULL NOT IN (...)` is NULL, so every app-captured
  movement was invisible on the customer statement (438 rows in the
  movement browser vs 392 on the statement) while showing everywhere
  else. This would have made the whole capture feature look broken.
- The balances count query omitted the void filter its own page query
  applied, so the total disagreed with the rows.

Nav highlighting now resolves by longest match; the previous
first-startsWith logic lit up both the parent and any nested entry.

Verified end-to-end against the dev DB, API and browser; all test rows
removed afterwards. Also corrects RESUME.md, which documented the dev
ports as :3001/:3000 — they are :4501/:4500, from the env files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 21:54:41 -07:00
co-authored by Claude Opus 5
parent 26a4faa33e
commit 7df928c3ab
14 changed files with 1476 additions and 30 deletions
+63 -1
View File
@@ -1,4 +1,5 @@
import {
BadRequestException,
Body,
Controller,
Get,
@@ -22,7 +23,11 @@ import {
LedgerDirection,
MovementSort,
} from "./billing.service";
import { CreateMovementDto } from "./movement.dto";
import {
BatchCreateDto,
CreateMovementDto,
ResolveOutstandingDto,
} from "./movement.dto";
const DOMAINS: TransactionDomain[] = ["UTILITY", "INSURANCE", "TRUST"];
const CURRENCIES: LedgerCurrency[] = ["MXN", "USD"];
@@ -46,6 +51,11 @@ function one<T>(allowed: T[], value: string | undefined): T | undefined {
return allowed.includes(value as T) ? (value as T) : undefined;
}
/** Tri-state query flag: "true"/"false" filter, anything else means no filter. */
function flag(v: string | undefined): boolean | undefined {
return v === "true" ? true : v === "false" ? false : 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;
@@ -97,6 +107,18 @@ export class BillingController {
});
}
/**
* Every movement cut against one check, with its total — the reconciliation
* view replacing the legacy REPORTE CHEQUE COUNT. Declared before the
* `customers/:id` and `:id`-shaped routes so the literal path wins.
*/
@Get("by-check")
byCheck(@Query("checkNumber") checkNumber?: string) {
const n = checkNumber?.trim();
if (!n) throw new BadRequestException("checkNumber es obligatorio");
return this.billing.byCheck(n);
}
/** One customer's full statement across both business lines. */
@Get("customers/:id")
statement(@Param("id") id: string) {
@@ -115,6 +137,8 @@ export class BillingController {
@Query("typeId") typeId?: string,
@Query("source") source?: string,
@Query("customerId") customerId?: string,
@Query("outstanding") outstanding?: string,
@Query("checkNumber") checkNumber?: string,
@Query("from") from?: string,
@Query("to") to?: string,
@Query("sort") sort?: string,
@@ -129,6 +153,8 @@ export class BillingController {
typeId: typeId || undefined,
source: source || undefined,
customerId: customerId || undefined,
outstanding: flag(outstanding),
checkNumber: checkNumber?.trim() || undefined,
from: parseDate(from),
to: parseDate(to, true),
sort: one(MOVEMENT_SORTS, sort) ?? "date_desc",
@@ -150,6 +176,42 @@ export class BillingController {
return tx;
}
/**
* Batch capture: many customers' receipts against one physical check.
* Same ability as single capture — batching is still capturing.
*/
@Post("batch")
@RequireAbility("ledger:create")
async createBatch(@Body() dto: BatchCreateDto, @Req() req: Request) {
const result = await this.billing.createBatch(dto);
void this.audit.log(this.actingId(req), "ledger.batch", {
checkNumber: dto.checkNumber,
count: result.count,
total: result.total,
currency: result.currency,
});
return result;
}
/**
* Resolve an outstanding (NOPAGO) row — `ledger:create`, not `ledger:void`:
* resolving completes a capture, it doesn't reverse one.
*/
@Post(":id/resolve-outstanding")
@RequireAbility("ledger:create")
async resolveOutstanding(
@Param("id") id: string,
@Body() dto: ResolveOutstandingDto,
@Req() req: Request,
) {
const tx = await this.billing.resolveOutstanding(id, dto);
void this.audit.log(this.actingId(req), "ledger.resolve-outstanding", {
transactionId: id,
checkNumber: dto.checkNumber,
});
return tx;
}
@Post(":id/void")
@RequireAbility("ledger:void")
async void(@Param("id") id: string, @Req() req: Request) {
+287 -10
View File
@@ -1,7 +1,15 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { Prisma, TransactionDomain } from "@jorgecuadros/database";
import {
Prisma,
TransactionCaptureSource,
TransactionDomain,
} from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
import { CreateMovementDto } from "./movement.dto";
import {
BatchCreateDto,
CreateMovementDto,
ResolveOutstandingDto,
} from "./movement.dto";
/**
* Shared billing / statements module — plan step 6.
@@ -54,12 +62,29 @@ export interface MovementParams {
typeId?: string;
source?: string;
customerId?: string;
/** Restrict to captured-but-unpaid rows (the legacy NOPAGO worklist). */
outstanding?: boolean;
/** Groups a capture batch: every row cut against one physical check. */
checkNumber?: string;
/** Inclusive ISO date bounds on `transactionDate`. */
from?: Date;
to?: Date;
sort: MovementSort;
}
/**
* Non-client-supplied options for a capture. Kept out of the DTO on purpose:
* these are set by the calling *module*, never by an HTTP body, so a client
* can't label its own rows as machine-captured or forge a capture ref.
* See `BillingService.createBatch` for the seam contract.
*/
export interface CaptureOptions {
/** Defaults to BATCH for the HTTP path; the OCR pipeline passes OCR. */
source?: TransactionCaptureSource;
/** Per-line artifact ids, positionally parallel to `dto.lines`. */
refs?: (string | undefined)[];
}
export interface BalanceParams {
query?: string;
page: number;
@@ -112,6 +137,21 @@ function dec(v: Prisma.Decimal | null | undefined): string {
*/
const NOT_VOIDED: Prisma.TransactionWhereInput = { voidedAt: null };
/**
* Outstanding ("NOPAGO") rows are captured but unpaid — the office recorded the
* bill without funds to cover it. They are excluded from every *balance*
* aggregate, exactly as the legacy `SALDOS ULTIMO 0` query did with its
* `HAVING NOPAGO = 0`: the office hasn't paid the bill, so it isn't yet owed by
* the customer. Resolving one (POST /billing/:id/resolve-outstanding) clears the
* flag and the amount starts counting.
*
* This is deliberately narrower than NOT_VOIDED. Voided rows are excluded
* everywhere; outstanding rows are excluded only from balances — the movement
* browser still totals them, because "how much water did we capture in April"
* means every captured row regardless of whether the check cleared.
*/
const NOT_OUTSTANDING: Prisma.TransactionWhereInput = { outstanding: false };
/**
* Source tables excluded from the customer-facing statement.
*
@@ -160,6 +200,10 @@ export class BillingService {
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.outstanding !== undefined) and.push({ outstanding: p.outstanding });
// Exact match, not `contains`: this is the by-check reconciliation lookup,
// where "1234" must not drag in "51234".
if (p.checkNumber) and.push({ checkNumber: p.checkNumber });
if (p.from || p.to) {
and.push({
transactionDate: {
@@ -216,6 +260,7 @@ export class BillingService {
message: true,
legacySourceTable: true,
voidedAt: true,
outstanding: true,
type: { select: { nameEn: true, nameEs: true } },
customer: {
select: { id: true, name: true, nameSource: true, city: true },
@@ -263,6 +308,7 @@ export class BillingService {
source: r.legacySourceTable,
type: r.type,
voided: r.voidedAt != null,
outstanding: r.outstanding,
customerId: r.customer.id,
customerName: r.customer.name,
customerNameSource: r.customer.nameSource,
@@ -356,7 +402,7 @@ export class BillingService {
MAX(t.transactionDate) AS lastMovement
FROM customers c
JOIN transactions t ON t.customerId = c.id
WHERE t.voidedAt IS NULL ${nameFilter} ${txFilter}
WHERE t.voidedAt IS NULL AND t.outstanding = 0 ${nameFilter} ${txFilter}
GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state
${having}
${orderBy}
@@ -368,7 +414,10 @@ export class BillingService {
SELECT c.id
FROM customers c
JOIN transactions t ON t.customerId = c.id
WHERE 1 = 1 ${nameFilter} ${txFilter}
-- Must match the page query's filters exactly, or the total disagrees
-- with the rows. (The void exclusion was missing here before the
-- outstanding work; a voided-only customer inflated the count.)
WHERE t.voidedAt IS NULL AND t.outstanding = 0 ${nameFilter} ${txFilter}
GROUP BY c.id
${having}
) x
@@ -601,7 +650,20 @@ export class BillingService {
const rows = await this.prisma.transaction.findMany({
where: {
customerId,
legacySourceTable: { notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[] },
// NULL-safe exclusion. `notIn` alone compiles to SQL `NOT IN`, and
// `NULL NOT IN (...)` is NULL, not true — so every app-captured row
// (which has no legacySourceTable) silently vanished from the
// statement while still showing in the movement browser. Rows the app
// books must appear on the customer's statement, so the null case is
// spelled out.
OR: [
{ legacySourceTable: null },
{
legacySourceTable: {
notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[],
},
},
],
},
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
select: {
@@ -616,6 +678,7 @@ export class BillingService {
message: true,
legacySourceTable: true,
voidedAt: true,
outstanding: true,
type: { select: { nameEn: true, nameEs: true } },
},
});
@@ -624,9 +687,10 @@ export class BillingService {
const movements = rows.map((r) => {
const voided = r.voidedAt != null;
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
// A voided row does not move the running balance — it shows struck-through
// with the balance unchanged from the previous live movement.
const next = voided ? prev : prev.plus(r.amount);
// Neither a voided row nor an outstanding (unpaid) one moves the running
// balance — both show tagged, with the balance unchanged from the previous
// live movement. Outstanding rows start counting once resolved.
const next = voided || r.outstanding ? prev : prev.plus(r.amount);
running.set(r.currency, next);
return {
id: r.id,
@@ -642,6 +706,7 @@ export class BillingService {
source: r.legacySourceTable,
type: r.type,
voided,
outstanding: r.outstanding,
/** Balance in this row's currency after applying it. */
balanceAfter: next.toFixed(2),
};
@@ -675,7 +740,9 @@ export class BillingService {
>();
for (const r of rows) {
if (r.voidedAt != null) continue; // voided rows never enter a total
// Voided rows never enter a total; outstanding rows don't either until
// they're resolved (legacy SALDOS ULTIMO 0's `HAVING NOPAGO = 0`).
if (r.voidedAt != null || r.outstanding) continue;
const c =
perCurrency.get(r.currency) ??
{
@@ -723,7 +790,7 @@ export class BillingService {
{ name: string; currency: string; total: Prisma.Decimal; count: number }
>();
for (const r of rows) {
if (r.voidedAt != null) continue;
if (r.voidedAt != null || r.outstanding) continue;
if (!r.amount.lessThan(0)) continue;
const name = r.type?.nameEs || r.type?.nameEn || "Sin clasificar";
const key = `${name}|${r.currency}`;
@@ -795,10 +862,220 @@ export class BillingService {
reference: dto.reference,
checkNumber: dto.checkNumber,
message: dto.message,
outstanding: dto.outstanding ?? false,
captureSource: "MANUAL",
},
});
}
/**
* Batch capture by check — many customers' receipts against one physical
* check. One `$transaction`, so a bad line rejects the whole batch rather
* than leaving a half-captured check that reconciles against nothing.
*
* Returns the check-level total alongside the rows so the UI can show it
* against the physical check amount, which is the entire point of the legacy
* flow this replaces (`CAPTURA *` feeding `EDITA CHEQUE COUNT`).
*
* ── Integration seam for OCR auto-capture (RECEIPT_CAPTURE_SPEC §2) ────────
* This method is the SINGLE write path for multi-row capture, and the OCR
* pipeline is required to post through it rather than writing `Transaction`
* rows itself — one validation path, one audit trail. Three guarantees exist
* for that caller specifically, and must not be broken:
*
* 1. `items[i]` corresponds to `dto.lines[i]`. Prisma's array
* `$transaction` preserves order, so the caller can zip the result back
* onto its own records — which is how `StatementDocument.postedTransactionId`
* gets set after a confirmed batch posts.
* 2. `opts.refs[i]` stamps `captureRef` on row `i` (a `StatementDocument.id`).
* Re-posting a ref that already has a live row is rejected, so a
* double-clicked "confirm" or a retried job cannot double-charge a
* customer. Voided rows don't block a re-post — a corrected statement
* must be re-postable after its bad row is voided.
* 3. `opts.source` records the capture path; it is NOT accepted over HTTP,
* so a client cannot label its hand-keyed rows as machine-captured.
*
* Everything the OCR module adds on top (batches, per-document status, the
* review queue) lives in its own module; nothing about it needs to change
* this signature.
*/
async createBatch(dto: BatchCreateDto, opts: CaptureOptions = {}) {
const date = new Date(dto.transactionDate);
if (isNaN(date.getTime())) throw new BadRequestException("Fecha inválida");
// Validate every customer up front, in one query — a per-line lookup inside
// the transaction would be N round-trips and would fail halfway through.
const ids = [...new Set(dto.lines.map((l) => l.customerId))];
const found = await this.prisma.customer.findMany({
where: { id: { in: ids } },
select: { id: true },
});
if (found.length !== ids.length) {
const known = new Set(found.map((c) => c.id));
const missing = ids.filter((id) => !known.has(id));
throw new BadRequestException(
`Cliente(s) no encontrado(s): ${missing.join(", ")}`,
);
}
// Duplicate-post guard (seam guarantee 2). Only live rows block: a voided
// row means the earlier post was reversed, so the corrected statement must
// be allowed through.
const refs = (opts.refs ?? []).filter((r): r is string => !!r);
if (refs.length) {
const clash = await this.prisma.transaction.findMany({
where: { captureRef: { in: refs }, voidedAt: null },
select: { captureRef: true },
});
if (clash.length) {
const dupes = [...new Set(clash.map((c) => c.captureRef))];
throw new BadRequestException(
`Ya existen movimientos para: ${dupes.join(", ")}`,
);
}
}
const currency = dto.currency ?? "MXN";
const source = opts.source ?? "BATCH";
const created = await this.prisma.$transaction(
dto.lines.map((line, i) =>
this.prisma.transaction.create({
data: {
customerId: line.customerId,
domain: dto.domain,
amount: line.amount,
transactionDate: date,
currency,
typeId: dto.typeId,
checkNumber: dto.checkNumber,
period: line.period,
reference: line.reference,
message: line.message,
outstanding: line.outstanding ?? false,
captureSource: source,
captureRef: opts.refs?.[i],
},
}),
),
);
// Outstanding lines are captured but unfunded, so they don't belong in the
// figure staff reconcile against the physical check.
const total = created.reduce(
(sum, t) => (t.outstanding ? sum : sum.plus(t.amount)),
new Prisma.Decimal(0),
);
return {
/** Parallel to `dto.lines` — see seam guarantee 1. */
items: created,
checkNumber: dto.checkNumber,
currency,
source,
count: created.length,
outstandingCount: created.filter((t) => t.outstanding).length,
total: total.toFixed(2),
};
}
/**
* Resolve an outstanding row: the check was finally cut. Takes the resolution
* date and check number and clears the flag, so the amount starts counting
* toward the balance. Legacy: "se actualiza registro con fecha del día y el
* cheque a pagar y quitas outstanding".
*/
async resolveOutstanding(id: string, dto: ResolveOutstandingDto) {
const tx = await this.prisma.transaction.findUnique({
where: { id },
select: { id: true, voidedAt: true, outstanding: true },
});
if (!tx) throw new NotFoundException(`Transaction ${id} not found`);
if (tx.voidedAt) {
throw new BadRequestException("El movimiento está anulado");
}
if (!tx.outstanding) {
throw new BadRequestException("El movimiento no está pendiente de pago");
}
const date = new Date(dto.resolvedDate);
if (isNaN(date.getTime())) throw new BadRequestException("Fecha inválida");
return this.prisma.transaction.update({
where: { id },
data: {
outstanding: false,
checkNumber: dto.checkNumber,
transactionDate: date,
},
});
}
/**
* Every live movement cut against one check, plus its total — the
* reconciliation view replacing `EDITA CHEQUE ALF/COUNT/NUM` and
* `REPORTE POR CHEQUE`. Voided rows are dropped entirely (they reconcile
* against nothing); outstanding rows are listed but excluded from the total,
* since the check didn't fund them.
*/
async byCheck(checkNumber: string) {
const rows = await this.prisma.transaction.findMany({
where: { checkNumber, voidedAt: null },
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
select: {
id: true,
transactionDate: true,
domain: true,
amount: true,
currency: true,
reference: true,
period: true,
message: true,
outstanding: true,
type: { select: { nameEn: true, nameEs: true } },
customer: { select: { id: true, name: true, nameSource: true } },
},
});
// Per currency: a check is one currency in practice, but the ledger has
// both and this module never sums across them.
const totals = new Map<string, { currency: string; total: Prisma.Decimal; count: number }>();
for (const r of rows) {
if (r.outstanding) continue;
const e =
totals.get(r.currency) ??
{ currency: r.currency, total: new Prisma.Decimal(0), count: 0 };
e.total = e.total.plus(r.amount);
e.count += 1;
totals.set(r.currency, e);
}
return {
checkNumber,
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,
message: r.message,
outstanding: r.outstanding,
type: r.type,
customerId: r.customer.id,
customerName: r.customer.name,
customerNameSource: r.customer.nameSource,
})),
count: rows.length,
outstandingCount: rows.filter((r) => r.outstanding).length,
totals: [...totals.values()].map((t) => ({
currency: t.currency,
total: t.total.toFixed(2),
count: t.count,
})),
};
}
/** Reverse a movement by marking it voided; it stops counting toward totals. */
async voidMovement(id: string, userId: string) {
const tx = await this.prisma.transaction.findUnique({
+58
View File
@@ -1,10 +1,16 @@
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsBoolean,
IsEnum,
IsNumber,
IsOptional,
IsString,
MinLength,
ValidateNested,
} from "class-validator";
import { Type } from "class-transformer";
import { Currency, TransactionDomain } from "@jorgecuadros/database";
/**
@@ -24,4 +30,56 @@ export class CreateMovementDto {
@IsOptional() @IsString() reference?: string;
@IsOptional() @IsString() checkNumber?: string;
@IsOptional() @IsString() message?: string;
/**
* Legacy "NOPAGO": the bill was captured but not actually paid (no funds).
* The row posts normally and stays visible, but is kept out of every balance
* aggregate until resolved — see BillingService's NOT_OUTSTANDING.
*/
@IsOptional() @IsBoolean() outstanding?: boolean;
}
/**
* Resolving an outstanding row: the check finally got cut, so the movement
* takes the resolution date and check number and starts counting toward the
* balance. Legacy behavior: "se actualiza registro con fecha del día y el
* cheque a pagar y quitas outstanding".
*/
export class ResolveOutstandingDto {
@IsString() @MinLength(1) checkNumber!: string;
@IsString() @MinLength(1) resolvedDate!: string;
}
/** One customer's line within a batch; check-level fields live on the parent. */
export class BatchLineDto {
@IsString() @MinLength(1) customerId!: string;
@IsNumber() amount!: number;
@IsOptional() @IsString() reference?: string;
@IsOptional() @IsString() period?: string;
@IsOptional() @IsString() message?: string;
@IsOptional() @IsBoolean() outstanding?: boolean;
}
/**
* Batch capture by check — the legacy "Editor" flow: key many customers'
* receipts against one check, then reconcile the captured total against the
* physical check. Deliberately NOT a persisted batch entity: `checkNumber` is
* already a column, and grouping by it answers every legacy by-check query.
*/
export class BatchCreateDto {
@IsEnum(TransactionDomain) domain!: TransactionDomain;
@IsString() @MinLength(1) transactionDate!: string;
@IsString() @MinLength(1) checkNumber!: string;
@IsOptional() @IsEnum(Currency) currency?: Currency;
@IsOptional() @IsString() typeId?: string;
// Capped so one request can't open a transaction over an unbounded row set;
// a physical check batch is tens of lines, not thousands.
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(500)
@ValidateNested({ each: true })
@Type(() => BatchLineDto)
lines!: BatchLineDto[];
}
+106
View File
@@ -949,6 +949,111 @@ const edoCuentaDatos: ReportDef = {
},
};
/**
* REPORTE CHEQUE COUNT — everything captured against one check.
*
* The reconciliation half of the batch-capture flow (docs/RECEIPT_CAPTURE_SPEC
* §1.3): staff key many customers' receipts against one physical check, then
* check that what was captured adds up to what the check was cut for. Replaces
* `EDITA CHEQUE ALF/COUNT/NUM`, `REPORTE POR CHEQUE` and
* `REPORTE POR CHEQUE PARA ALFA` — four legacy objects, one parameterized
* report.
*
* Deliberately mirrors `BillingService.byCheck`'s rules rather than inventing
* its own: voided rows are dropped entirely, and outstanding (NOPAGO) rows are
* listed but excluded from the total, because the check never funded them.
*/
const chequeCount: ReportDef = {
slug: "cheque-count",
title: "Reporte por cheque",
description:
"Todos los movimientos capturados contra un mismo cheque, con el total " +
"para conciliar contra el importe físico del cheque. Los movimientos " +
"pendientes de pago (sin fondos) se listan pero no suman al total.",
domain: "estado-cuenta",
legacyName: "REPORTE CHEQUE COUNT / REPORTE POR CHEQUE / EDITA CHEQUE COUNT",
format: "tabular",
params: [
{
key: "checkNumber",
label: "Número de cheque",
kind: "text",
placeholder: "Ej. 10432",
},
],
columns: [
{ key: "customerName", label: "Cliente", type: "text" },
{ key: "reference", label: "Referencia", type: "text" },
{ key: "period", label: "Periodo", type: "text" },
{ key: "concept", label: "Concepto", type: "text" },
{ key: "transactionDate", label: "Fecha", type: "date" },
{ key: "status", label: "Estado", type: "text" },
{ key: "amount", label: "Importe", type: "money", align: "right" },
],
async run(prisma, p) {
const checkNumber = p.checkNumber?.trim();
if (!checkNumber) {
return {
rows: [],
totals: { movimientos: 0 },
subtitle: "Indique un número de cheque",
};
}
const rows = await prisma.transaction.findMany({
where: { checkNumber, ...NOT_VOIDED },
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
select: {
transactionDate: true,
amount: true,
currency: true,
reference: true,
period: true,
outstanding: true,
type: { select: { nameEn: true, nameEs: true } },
customer: { select: { name: true, nameMissing: true } },
},
});
// Per currency, and never collapsed — same rule as the rest of the ledger.
const totals = new Map<string, Prisma.Decimal>();
let outstandingCount = 0;
for (const r of rows) {
if (r.outstanding) {
outstandingCount++;
continue;
}
totals.set(
r.currency,
(totals.get(r.currency) ?? new Prisma.Decimal(0)).plus(r.amount),
);
}
const totalsOut: Record<string, string | number> = {
movimientos: rows.length,
};
for (const [currency, sum] of totals) {
totalsOut[`total ${currency}`] = sum.toFixed(2);
}
if (outstandingCount) totalsOut["sin fondos"] = outstandingCount;
return {
rows: rows.map((r) => ({
customerName: nameOf(r.customer),
reference: r.reference ?? "—",
period: r.period ?? "—",
concept: r.type?.nameEs || r.type?.nameEn || "Sin clasificar",
transactionDate: r.transactionDate.toISOString().slice(0, 10),
status: r.outstanding ? "Sin fondos" : "Pagado",
amount: r.amount.toFixed(2),
currency: r.currency,
})),
totals: totalsOut,
subtitle: `Cheque ${checkNumber} · ${rows.length} movimientos`,
};
},
};
/* ------------------------------------------------------------------ export */
export const REPORTS: ReportDef[] = [
@@ -959,6 +1064,7 @@ export const REPORTS: ReportDef[] = [
vigente,
avisoRenovacion,
edoCuentaDatos,
chequeCount,
];
export function findReport(slug: string): ReportDef | undefined {
@@ -0,0 +1,557 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { CustomerPicker } from "@/components/CustomerPicker";
import { createMovementBatch, getBillingFacets, getByCheck } from "@/lib/api";
import { useCan } from "@/lib/abilities";
import { formatMoney, formatNumber, txTypeLabel } from "@/lib/labels";
import type {
BatchCreateInput,
BillingFacets,
ByCheckResponse,
Currency,
LedgerCurrency,
TransactionDomain,
} from "@/lib/types";
/**
* Batch capture by check — the "Editor" screen from the legacy system
* (docs/RECEIPT_CAPTURE_SPEC.md §1.2).
*
* Staff key many customers' receipts against ONE physical check before cutting
* it, then check that the captured total matches the check's amount. That
* reconciliation is the whole point, so the running total is the most prominent
* thing on the page and an optional "importe del cheque" field turns it into a
* live difference.
*
* No batch entity is persisted: `checkNumber` is a plain column, and grouping
* by it answers every by-check question (see the "Reporte por cheque" report).
*/
const DOMAINS: { key: TransactionDomain; label: string }[] = [
{ key: "UTILITY", label: "Servicios" },
{ key: "INSURANCE", label: "Seguros" },
{ key: "TRUST", label: "Fideicomiso" },
];
interface Line {
/** Local row key — lines have no server identity until the batch posts. */
key: number;
customerId: string;
customerName: string;
amount: string;
reference: string;
period: string;
outstanding: boolean;
}
function blankLine(key: number): Line {
return {
key,
customerId: "",
customerName: "",
amount: "",
reference: "",
period: "",
outstanding: false,
};
}
export default function BatchCapturePage() {
return (
<AppShell>
<BatchCapture />
</AppShell>
);
}
function BatchCapture() {
const canCapture = useCan("ledger:create");
const [facets, setFacets] = useState<BillingFacets | null>(null);
// Check-level fields — shared by every line.
const [domain, setDomain] = useState<TransactionDomain>("UTILITY");
const [currency, setCurrency] = useState<LedgerCurrency>("MXN");
const [typeId, setTypeId] = useState("");
const [checkNumber, setCheckNumber] = useState("");
const [transactionDate, setTransactionDate] = useState(
new Date().toISOString().slice(0, 10),
);
/** The physical check's amount, for reconciliation only — never submitted. */
const [checkAmount, setCheckAmount] = useState("");
const [lines, setLines] = useState<Line[]>([blankLine(1), blankLine(2), blankLine(3)]);
const [nextKey, setNextKey] = useState(4);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [posted, setPosted] = useState<ByCheckResponse | null>(null);
useEffect(() => {
getBillingFacets().then(setFacets).catch(() => setFacets(null));
}, []);
const filled = lines.filter(
(l) => l.customerId && l.amount.trim() !== "" && Number.isFinite(Number(l.amount)),
);
// Charges are captured as positive numbers and signed on submit, matching
// MovementForm — staff type what's on the bill, not a negative.
const total = useMemo(
() =>
filled
.filter((l) => !l.outstanding)
.reduce((sum, l) => sum + Math.abs(Number(l.amount)), 0),
[filled],
);
const outstandingTotal = useMemo(
() =>
filled
.filter((l) => l.outstanding)
.reduce((sum, l) => sum + Math.abs(Number(l.amount)), 0),
[filled],
);
const checkAmt = Number(checkAmount);
const hasCheckAmt = checkAmount.trim() !== "" && Number.isFinite(checkAmt);
const diff = hasCheckAmt ? checkAmt - total : 0;
const reconciled = hasCheckAmt && Math.abs(diff) < 0.005;
function update(key: number, patch: Partial<Line>) {
setLines((ls) => ls.map((l) => (l.key === key ? { ...l, ...patch } : l)));
}
function addLine() {
setLines((ls) => [...ls, blankLine(nextKey)]);
setNextKey((k) => k + 1);
}
function removeLine(key: number) {
setLines((ls) => (ls.length === 1 ? ls : ls.filter((l) => l.key !== key)));
}
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!checkNumber.trim()) {
setError("Indica el número de cheque.");
return;
}
if (filled.length === 0) {
setError("Captura al menos una línea con cliente y monto.");
return;
}
const dupes = filled
.map((l) => l.customerId)
.filter((id, i, arr) => arr.indexOf(id) !== i);
if (dupes.length) {
const names = filled
.filter((l) => dupes.includes(l.customerId))
.map((l) => l.customerName);
if (
!window.confirm(
`Hay más de una línea para el mismo cliente (${[...new Set(names)].join(
", ",
)}). ¿Continuar?`,
)
)
return;
}
const payload: BatchCreateInput = {
domain,
transactionDate,
checkNumber: checkNumber.trim(),
currency: currency as Currency,
typeId: typeId || undefined,
lines: filled.map((l) => ({
customerId: l.customerId,
// Every line of a check batch is a charge the office paid out.
amount: -Math.abs(Number(l.amount)),
reference: l.reference.trim() || undefined,
period: l.period.trim() || undefined,
outstanding: l.outstanding || undefined,
})),
};
setSaving(true);
setError(null);
try {
await createMovementBatch(payload);
// Re-read through the by-check view so the confirmation shows what's
// actually stored (including anything captured against this check
// earlier), not just what this request sent.
setPosted(await getByCheck(payload.checkNumber));
} catch (e2) {
setError((e2 as Error)?.message ?? "No se pudo guardar el lote.");
} finally {
setSaving(false);
}
}
function reset() {
setPosted(null);
setLines([blankLine(nextKey), blankLine(nextKey + 1), blankLine(nextKey + 2)]);
setNextKey((k) => k + 3);
setCheckNumber("");
setCheckAmount("");
}
if (!canCapture) {
return (
<div className="state-box state-error">
No tienes permiso para capturar movimientos.
</div>
);
}
if (posted) {
return (
<>
<div className="page-head">
<div>
<h1 className="page-title">Lote capturado</h1>
<p className="eyebrow">
Cheque {posted.checkNumber} · {formatNumber(posted.count)}{" "}
{posted.count === 1 ? "movimiento" : "movimientos"}
</p>
</div>
<div style={{ display: "flex", gap: 10 }}>
<button type="button" className="btn btn-primary" onClick={reset}>
Capturar otro cheque
</button>
<Link href="/estado-cuenta" className="btn btn-outline">
Volver a estado de cuenta
</Link>
</div>
</div>
<div className="filtered-totals" style={{ marginBottom: 16 }}>
{posted.totals.map((t) => (
<div className="filtered-total" key={t.currency}>
<span className="filtered-total-cur">{t.currency}</span>
<span className="filtered-total-net">
Total del cheque <strong>{formatMoney(t.total, t.currency)}</strong>
</span>
<span>{formatNumber(t.count)} movimientos</span>
</div>
))}
{posted.outstandingCount > 0 && (
<div className="filtered-total">
<span>
{formatNumber(posted.outstandingCount)} sin fondos (no suman al
total)
</span>
</div>
)}
</div>
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Cliente</th>
<th>Referencia</th>
<th>Periodo</th>
<th>Estado</th>
<th className="num">Monto</th>
</tr>
</thead>
<tbody>
{posted.items.map((i) => (
<tr key={i.id}>
<td>
<Link
href={`/estado-cuenta/${i.customerId}`}
className="inline-link"
>
{i.customerName}
</Link>
</td>
<td>{i.reference || "—"}</td>
<td>{i.period || "—"}</td>
<td>{i.outstanding ? "Sin fondos" : "Pagado"}</td>
<td className="num">
<span className="tx-amount neg">
{formatMoney(i.amount, i.currency)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="muted" style={{ marginTop: 14 }}>
Para imprimir la conciliación, usa el reporte{" "}
<Link
href={`/reportes/cheque-count?checkNumber=${encodeURIComponent(
posted.checkNumber,
)}`}
className="inline-link"
>
Reporte por cheque
</Link>
.
</p>
</>
);
}
return (
<>
<div className="page-head">
<div>
<h1 className="page-title">Captura por cheque</h1>
<p className="eyebrow">
Captura los recibos de varios clientes contra un mismo cheque y
concilia el total antes de guardar.
</p>
</div>
<Link href="/estado-cuenta" className="btn btn-outline">
Cancelar
</Link>
</div>
{error && <div className="state-box state-error">{error}</div>}
<form onSubmit={submit}>
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 14 }}>
Datos del cheque
</h2>
<div className="form-grid">
<label className="field">
<span className="field-label">Número de cheque *</span>
<input
className="input"
value={checkNumber}
onChange={(e) => setCheckNumber(e.target.value)}
required
/>
</label>
<label className="field">
<span className="field-label">Fecha *</span>
<input
className="input"
type="date"
required
value={transactionDate}
onChange={(e) => setTransactionDate(e.target.value)}
/>
</label>
<label className="field">
<span className="field-label">Línea de negocio *</span>
<select
className="select"
value={domain}
onChange={(e) => setDomain(e.target.value as TransactionDomain)}
>
{DOMAINS.map((d) => (
<option key={d.key} value={d.key}>
{d.label}
</option>
))}
</select>
</label>
<label className="field">
<span className="field-label">Moneda *</span>
<select
className="select"
value={currency}
onChange={(e) => setCurrency(e.target.value as LedgerCurrency)}
>
<option value="MXN">Pesos (MXN)</option>
<option value="USD">Dólares (USD)</option>
</select>
</label>
<label className="field">
<span className="field-label">Concepto</span>
<select
className="select"
value={typeId}
onChange={(e) => setTypeId(e.target.value)}
>
<option value="">(sin concepto)</option>
{facets?.types.map((t) => (
<option key={t.id} value={t.id}>
{txTypeLabel({ nameEn: t.name })}
</option>
))}
</select>
</label>
<label className="field">
<span className="field-label">Importe del cheque</span>
<input
className="input"
type="number"
step="0.01"
min="0"
value={checkAmount}
onChange={(e) => setCheckAmount(e.target.value)}
placeholder="Para conciliar"
/>
</label>
</div>
</div>
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 14,
}}
>
<h2 className="section-title" style={{ margin: 0 }}>
Recibos ({formatNumber(filled.length)})
</h2>
<button type="button" className="btn btn-outline" onClick={addLine}>
Agregar línea
</button>
</div>
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th style={{ minWidth: 240 }}>Cliente *</th>
<th style={{ minWidth: 120 }}>Referencia</th>
<th style={{ minWidth: 100 }}>Periodo</th>
<th style={{ minWidth: 110 }} className="num">
Monto *
</th>
<th style={{ whiteSpace: "nowrap" }}>Sin fondos</th>
<th style={{ width: 1 }} />
</tr>
</thead>
<tbody>
{lines.map((l) => (
<tr key={l.key}>
<td>
<CustomerPicker
value={l.customerId}
valueName={l.customerId ? l.customerName : undefined}
onPick={(id, name) =>
update(l.key, { customerId: id, customerName: name })
}
/>
</td>
<td>
<input
className="input"
value={l.reference}
onChange={(e) =>
update(l.key, { reference: e.target.value })
}
/>
</td>
<td>
<input
className="input"
value={l.period}
onChange={(e) => update(l.key, { period: e.target.value })}
placeholder="2026-07"
/>
</td>
<td>
<input
className="input num"
type="number"
step="0.01"
min="0"
value={l.amount}
onChange={(e) => update(l.key, { amount: e.target.value })}
placeholder="0.00"
/>
</td>
<td style={{ textAlign: "center" }}>
<input
type="checkbox"
checked={l.outstanding}
onChange={(e) =>
update(l.key, { outstanding: e.target.checked })
}
aria-label="Sin fondos"
/>
</td>
<td>
<button
type="button"
className="btn btn-ghost"
style={{ padding: "4px 10px", fontSize: 12 }}
onClick={() => removeLine(l.key)}
disabled={lines.length === 1}
>
Quitar
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 14 }}>
Conciliación
</h2>
<div className="filtered-totals">
<div className="filtered-total">
<span className="filtered-total-cur">{currency}</span>
<span className="filtered-total-net">
Capturado <strong>{formatMoney(String(-total), currency)}</strong>
</span>
<span>{formatNumber(filled.filter((l) => !l.outstanding).length)} recibos</span>
</div>
{outstandingTotal > 0 && (
<div className="filtered-total">
<span>
Sin fondos{" "}
<strong>{formatMoney(String(-outstandingTotal), currency)}</strong>{" "}
(no suma al cheque)
</span>
</div>
)}
{hasCheckAmt && (
<div className="filtered-total">
<span className="filtered-total-net">
{reconciled ? (
<strong className="tx-amount pos">Cuadra con el cheque</strong>
) : (
<>
Diferencia{" "}
<strong className="tx-amount neg">
{formatMoney(String(diff), currency)}
</strong>
</>
)}
</span>
</div>
)}
</div>
</div>
<div className="form-actions">
<button
type="submit"
className="btn btn-primary"
disabled={saving || filled.length === 0}
>
{saving
? "Guardando…"
: `Capturar ${formatNumber(filled.length)} ${
filled.length === 1 ? "recibo" : "recibos"
}`}
</button>
<Link href="/estado-cuenta" className="btn btn-outline">
Cancelar
</Link>
</div>
</form>
</>
);
}
+167 -10
View File
@@ -10,6 +10,7 @@ import {
getBillingStats,
listBalances,
listMovements,
resolveOutstanding,
voidMovement,
} from "@/lib/api";
import { useCan } from "@/lib/abilities";
@@ -117,6 +118,8 @@ function BillingBrowser() {
const [direction, setDirection] = useState<LedgerDirection | "">("");
const [typeId, setTypeId] = useState("");
const [source, setSource] = useState("");
// "" = no filter, "true" = only NOPAGO rows, "false" = only settled ones.
const [outstanding, setOutstanding] = useState<"" | "true" | "false">("");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [movementSort, setMovementSort] = useState<MovementSort>("date_desc");
@@ -126,6 +129,7 @@ function BillingBrowser() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [captureOpen, setCaptureOpen] = useState(false);
const [resolving, setResolving] = useState<MovementListItem | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
@@ -165,6 +169,7 @@ function BillingBrowser() {
direction: direction || undefined,
typeId: typeId || undefined,
source: source || undefined,
outstanding: outstanding === "" ? undefined : outstanding === "true",
from: from || undefined,
to: to || undefined,
sort: movementSort,
@@ -188,6 +193,7 @@ function BillingBrowser() {
direction,
typeId,
source,
outstanding,
from,
to,
movementSort,
@@ -304,16 +310,35 @@ function BillingBrowser() {
))}
</div>
{view === "movimientos" && canCapture && (
<button
type="button"
className="btn btn-primary"
onClick={() => setCaptureOpen((v) => !v)}
>
{captureOpen ? "Cerrar captura" : "Capturar movimiento"}
</button>
<div style={{ display: "flex", gap: 10 }}>
<Link href="/estado-cuenta/lote" className="btn btn-outline">
Captura por cheque
</Link>
<button
type="button"
className="btn btn-primary"
onClick={() => setCaptureOpen((v) => !v)}
>
{captureOpen ? "Cerrar captura" : "Capturar movimiento"}
</button>
</div>
)}
</div>
{view === "movimientos" && resolving && (
<ResolveDialog
movement={resolving}
onCancel={() => setResolving(null)}
onDone={() => {
setResolving(null);
runSearch(movements?.page ?? 1);
getBillingStats()
.then(setStats)
.catch(() => setStats(null));
}}
/>
)}
{view === "movimientos" && captureOpen && (
<section className="section">
<div className="section-head">
@@ -447,6 +472,21 @@ function BillingBrowser() {
</select>
</label>
<label className="filter-field">
<span className="filter-label">Estado de pago</span>
<select
className="input select"
value={outstanding}
onChange={(e) =>
setOutstanding(e.target.value as "" | "true" | "false")
}
>
<option value="">Todos</option>
<option value="true">Sin fondos (pendientes)</option>
<option value="false">Pagados</option>
</select>
</label>
<label className="filter-field">
<span className="filter-label">Desde</span>
<input
@@ -552,7 +592,9 @@ function BillingBrowser() {
<th>Concepto</th>
<th>Referencia</th>
<th className="num">Monto</th>
{canVoid && <th style={{ width: 1, whiteSpace: "nowrap" }}>Acciones</th>}
{(canVoid || canCapture) && (
<th style={{ width: 1, whiteSpace: "nowrap" }}>Acciones</th>
)}
</tr>
</thead>
<tbody>
@@ -561,6 +603,8 @@ function BillingBrowser() {
key={m.id}
m={m}
canVoid={canVoid}
canCapture={canCapture}
onResolve={setResolving}
onVoided={() => {
runSearch(movements?.page ?? 1);
getBillingStats()
@@ -799,11 +843,15 @@ function BalanceRow({
function MovementRow({
m,
canVoid,
canCapture,
onVoided,
onResolve,
}: {
m: MovementListItem;
canVoid: boolean;
canCapture: boolean;
onVoided: () => void;
onResolve: (m: MovementListItem) => void;
}) {
const [busy, setBusy] = useState(false);
@@ -854,11 +902,29 @@ function MovementRow({
</span>
<div className="tx-cur">
{m.currency} · {directionLabel(m.direction)}
{m.outstanding && !m.voided && (
<>
{" · "}
<span className="tx-outstanding">sin fondos</span>
</>
)}
</div>
</td>
{canVoid && (
{(canVoid || canCapture) && (
<td style={{ whiteSpace: "nowrap" }}>
{!m.voided && (
{/* Resolver only makes sense on a live outstanding row, and it's a
capture action (completing one), not a void. */}
{!m.voided && m.outstanding && canCapture && (
<button
type="button"
className="btn btn-ghost"
style={{ padding: "4px 10px", fontSize: 12 }}
onClick={() => onResolve(m)}
>
Resolver
</button>
)}
{!m.voided && canVoid && (
<button
type="button"
className="btn btn-ghost"
@@ -875,6 +941,97 @@ function MovementRow({
);
}
/**
* Resolve an outstanding row: the check finally got cut. Takes the check number
* and the date it was paid, which also becomes the movement's date — the legacy
* behavior, since the ledger date is when money actually moved.
*/
function ResolveDialog({
movement,
onDone,
onCancel,
}: {
movement: MovementListItem;
onDone: () => void;
onCancel: () => void;
}) {
const [checkNumber, setCheckNumber] = useState("");
const [resolvedDate, setResolvedDate] = useState(
new Date().toISOString().slice(0, 10),
);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!checkNumber.trim()) {
setError("Indica el número de cheque.");
return;
}
setBusy(true);
setError(null);
try {
await resolveOutstanding(movement.id, {
checkNumber: checkNumber.trim(),
resolvedDate,
});
onDone();
} catch (e2) {
setError((e2 as Error)?.message ?? "No se pudo resolver el movimiento.");
setBusy(false);
}
}
return (
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 6 }}>
Resolver movimiento sin fondos
</h2>
<p className="muted" style={{ marginBottom: 14 }}>
{movement.customerName} · {formatMoney(movement.amount, movement.currency)}{" "}
{movement.currency}
{movement.reference ? ` · ${movement.reference}` : ""}
</p>
{error && <div className="state-box state-error">{error}</div>}
<form onSubmit={submit}>
<div className="form-grid">
<label className="field">
<span className="field-label">Número de cheque *</span>
<input
className="input"
value={checkNumber}
onChange={(e) => setCheckNumber(e.target.value)}
autoFocus
/>
</label>
<label className="field">
<span className="field-label">Fecha de pago *</span>
<input
className="input"
type="date"
required
value={resolvedDate}
onChange={(e) => setResolvedDate(e.target.value)}
/>
</label>
</div>
<p className="muted" style={{ fontSize: 13, marginTop: 10 }}>
El movimiento tomará esta fecha y empezará a contar en el saldo del
cliente.
</p>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? "Resolviendo…" : "Resolver"}
</button>
<button type="button" className="btn btn-outline" onClick={onCancel}>
Cancelar
</button>
</div>
</form>
</div>
);
}
function Pager({
page,
pageCount,
+8
View File
@@ -2102,6 +2102,14 @@ button {
color: var(--ink);
}
/* Captured-but-unpaid (legacy NOPAGO). Deliberately not the same treatment as
a voided row: the movement is real and still pending, it just doesn't count
toward the balance until a check resolves it. */
.tx-outstanding {
color: var(--warn, #b45309);
font-weight: 600;
}
/* Charges broken out by concept, with a proportional bar. `.card` carries no
padding, so the list pads itself — otherwise the total sits on the border. */
.concept-list {
+26 -3
View File
@@ -20,6 +20,10 @@ const NAV: { href: string; label: string; ability?: Ability; exact?: boolean }[]
{ href: "/servicios", label: "Propiedades" },
{ href: "/polizas", label: "Pólizas" },
{ href: "/estado-cuenta", label: "Estado de cuenta" },
// Daily data-entry screen (the legacy "Editor"), so it earns a top-level
// entry rather than living one click inside the Movimientos tab. Hidden from
// VIEWER, who can't capture anyway — the page itself also refuses.
{ href: "/estado-cuenta/lote", label: "Captura", ability: "ledger:create" },
{ href: "/banco", label: "Chequera" },
{ href: "/reportes", label: "Reportes" },
{ href: "/catalogos", label: "Catálogos", ability: "lookup:manage" },
@@ -27,12 +31,33 @@ const NAV: { href: string; label: string; ability?: Ability; exact?: boolean }[]
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" },
];
/**
* Which nav entry is highlighted for a path. Longest matching href wins, so a
* nested route (`/estado-cuenta/lote`) highlights its own entry instead of also
* lighting up its parent (`/estado-cuenta`) — while `/estado-cuenta/<id>`, which
* has no entry of its own, still correctly highlights the parent.
*/
function activeHref(pathname: string | null): string | null {
if (!pathname) return null;
let best: string | null = null;
for (const item of NAV) {
const match = item.exact
? pathname === item.href
: pathname === item.href || pathname.startsWith(`${item.href}/`);
if (match && (best === null || item.href.length > best.length)) {
best = item.href;
}
}
return best;
}
export function AppShell({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [user, setUser] = useState<AuthUser | null>(null);
const [checking, setChecking] = useState(true);
const [loggingOut, setLoggingOut] = useState(false);
const current = activeHref(pathname);
useEffect(() => {
let alive = true;
@@ -94,9 +119,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<nav className="appbar-nav" aria-label="Principal">
{NAV.filter((item) => !item.ability || can(user, item.ability)).map(
(item) => {
const active = item.exact
? pathname === item.href
: pathname?.startsWith(item.href) ?? false;
const active = current === item.href;
return (
<Link
key={item.href}
+31
View File
@@ -62,9 +62,15 @@ export function MovementForm({
const [reference, setReference] = useState("");
const [checkNumber, setCheckNumber] = useState("");
const [message, setMessage] = useState("");
const [outstanding, setOutstanding] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// "Sin fondos" is a per-service *charge* concept: the office recorded a
// utility bill it couldn't cover. It never applies to a credit (a payment
// that arrived is, by definition, funded) or to the insurance/trust lines.
const canBeOutstanding = domain === "UTILITY" && direction === "charge";
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!customerId) {
@@ -88,6 +94,9 @@ export function MovementForm({
reference: s(reference),
checkNumber: s(checkNumber),
message: s(message),
// Guarded by canBeOutstanding so a stale checkbox can't ride along after
// the user switches the row to a credit or another business line.
outstanding: canBeOutstanding && outstanding ? true : undefined,
};
setSaving(true);
setError(null);
@@ -225,6 +234,28 @@ export function MovementForm({
onChange={(e) => setMessage(e.target.value)}
/>
</label>
{canBeOutstanding && (
<label
className="field"
style={{ marginTop: 16, flexDirection: "row", alignItems: "center", gap: 10 }}
>
<input
type="checkbox"
checked={outstanding}
onChange={(e) => setOutstanding(e.target.checked)}
/>
<span>
<span className="field-label" style={{ display: "block" }}>
Sin fondos (pendiente de pago)
</span>
<span className="muted" style={{ fontSize: 13 }}>
El cargo se registra pero no afecta el saldo del cliente hasta
que se resuelva con un cheque.
</span>
</span>
</label>
)}
</div>
<div className="form-actions">
+40
View File
@@ -13,11 +13,15 @@ import type {
BankSort,
BankStats,
BankSummary,
BatchCreateInput,
BatchCreateResponse,
BillingFacets,
BillingStats,
BusinessLine,
ByCheckResponse,
CreateBankMovementInput,
CreateMovementInput,
ResolveOutstandingInput,
CustomerDetail,
CustomerInput,
CustomerListResponse,
@@ -484,6 +488,10 @@ export interface MovementQuery {
typeId?: string;
source?: string;
customerId?: string;
/** Restrict to captured-but-unpaid rows (the NOPAGO worklist). */
outstanding?: boolean;
/** Exact check number — the by-check reconciliation lookup. */
checkNumber?: string;
/** `YYYY-MM-DD`, inclusive on both ends. */
from?: string;
to?: string;
@@ -501,6 +509,8 @@ export function listMovements(q: MovementQuery): Promise<MovementListResponse> {
if (q.typeId) params.set("typeId", q.typeId);
if (q.source) params.set("source", q.source);
if (q.customerId) params.set("customerId", q.customerId);
if (q.outstanding !== undefined) params.set("outstanding", String(q.outstanding));
if (q.checkNumber) params.set("checkNumber", q.checkNumber);
if (q.from) params.set("from", q.from);
if (q.to) params.set("to", q.to);
if (q.sort) params.set("sort", q.sort);
@@ -559,6 +569,36 @@ export function voidMovement(id: string): Promise<Transaction> {
return apiFetch<Transaction>(`/billing/${id}/void`, { method: "POST" });
}
/** Capture many customers' receipts against one check, in one transaction. The
* returned `items` are positionally parallel to `input.lines`. */
export function createMovementBatch(
input: BatchCreateInput,
): Promise<BatchCreateResponse> {
return apiFetch<BatchCreateResponse>("/billing/batch", {
method: "POST",
body: JSON.stringify(input),
});
}
/** Clear an outstanding (NOPAGO) row: stamps the check number + resolution date
* and starts counting it toward the balance. 400 if not outstanding or voided. */
export function resolveOutstanding(
id: string,
input: ResolveOutstandingInput,
): Promise<Transaction> {
return apiFetch<Transaction>(`/billing/${id}/resolve-outstanding`, {
method: "POST",
body: JSON.stringify(input),
});
}
/** Everything captured against one check, with its reconciliation total. */
export function getByCheck(checkNumber: string): Promise<ByCheckResponse> {
return apiFetch<ByCheckResponse>(
`/billing/by-check?checkNumber=${encodeURIComponent(checkNumber)}`,
);
}
/* ------------------------------------------------- Bank register (chequera) */
export interface BankQuery {
+70
View File
@@ -718,6 +718,9 @@ export interface Movement {
type: TransactionType | null;
/** App-voided (`voidedAt` set). UI strikes; totals exclude. */
voided: boolean;
/** Legacy "NOPAGO": captured but unpaid (no funds). Shown tagged, and kept
* out of every balance until resolved via resolveOutstanding(). */
outstanding?: boolean;
}
/** Payload for POST /billing — a new ledger movement. Sign convention: negative
@@ -733,6 +736,73 @@ export interface CreateMovementInput {
reference?: string;
checkNumber?: string;
message?: string;
/** Legacy NOPAGO — captured but unpaid; excluded from balances until resolved. */
outstanding?: boolean;
}
/** One customer's line inside a check batch; check-level fields sit on the parent. */
export interface BatchLineInput {
customerId: string;
amount: number;
reference?: string;
period?: string;
message?: string;
outstanding?: boolean;
}
/** Payload for POST /billing/batch — many receipts cut against one check. */
export interface BatchCreateInput {
domain: TransactionDomain;
transactionDate: string;
checkNumber: string;
currency?: Currency;
typeId?: string;
lines: BatchLineInput[];
}
export interface BatchCreateResponse {
/** Positionally parallel to the submitted `lines`. */
items: Transaction[];
checkNumber: string;
currency: LedgerCurrency;
source: "MANUAL" | "BATCH" | "OCR";
count: number;
outstandingCount: number;
/** Excludes outstanding lines — this is the figure to reconcile against the
* physical check. */
total: string;
}
/** Payload for POST /billing/:id/resolve-outstanding. */
export interface ResolveOutstandingInput {
checkNumber: string;
resolvedDate: string;
}
export interface ByCheckItem {
id: string;
transactionDate: string | null;
domain: TransactionDomain;
amount: string;
currency: LedgerCurrency;
direction: LedgerDirection;
reference: string | null;
period: string | null;
message: string | null;
outstanding: boolean;
type: TransactionType | null;
customerId: string;
customerName: string;
customerNameSource: string | null;
}
/** GET /billing/by-check — everything cut against one check, for reconciliation. */
export interface ByCheckResponse {
checkNumber: string;
items: ByCheckItem[];
count: number;
outstandingCount: number;
totals: { currency: LedgerCurrency; total: string; count: number }[];
}
export interface MovementListItem extends Movement {