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
+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,