feat(efectivo): the EFE cash receipt book, folio and posting in one step
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m47s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m17s

The office still issues cash receipts from Access (EFE MENU -> EFE). This is
that form: cliente, fecha, monto + moneda, concepto, elaboró (the logged-in
user), fecha de depósito, and a folio that continues the legacy book.

- cash_receipts, folio unique. Next folio = one past the highest of the
  platform's receipts and the imported utilities EFECTIVO journal, so after
  the cutover import it lands on the office's next number (13935 on prod
  today). A typed folio the legacy book already used is refused.
- Issuing a receipt posts its credit in the same database transaction, as
  legacy posted to DATOS2: reference C<folio>, CASH DEPOSIT, pseudo check
  CASH<MMDD><P|D>. Dollars with a rate credit a pesos ledger (1,500 USD at
  16.80 -> 25,200 MXN, as C13830 did). Voiding that row voids the receipt.
- /efectivo: issue, search, record the deposit date; /efectivo/<folio>
  prints the receipt.
- Reporte de efectivo now lists platform receipts by receipt, not by check,
  so each is counted once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rmancinasandClaude Opus 5.5 committed 2026-10-12 19:24:43 -07:00
1 parent fd5df240bc
commit ca76f4d57a
14 files changed
+959 -3

No files matched your search

+2
View File
@@ -12,6 +12,7 @@ import { PoliciesModule } from "./policies/policies.module";
import { PropertiesModule } from "./properties/properties.module";
import { BillingModule } from "./billing/billing.module";
import { StatementsModule } from "./statements/statements.module";
import { CashReceiptsModule } from "./cash-receipts/cash-receipts.module";
import { PolicyOcrModule } from "./policy-ocr/policy-ocr.module";
import { BankModule } from "./bank/bank.module";
import { OpsModule } from "./ops/ops.module";
@@ -35,6 +36,7 @@ import { AppController } from "./app.controller";
PropertiesModule,
BillingModule,
StatementsModule,
CashReceiptsModule,
PolicyOcrModule,
BankModule,
OpsModule,
@@ -0,0 +1,29 @@
import {
IsEnum,
IsInt,
IsNumber,
IsOptional,
IsString,
Min,
MinLength,
} from "class-validator";
import { Currency } from "@jorgecuadros/database";
/** The legacy EFE form: cliente, fecha, monto + moneda, concepto, depósito. */
export class CreateCashReceiptDto {
@IsString() @MinLength(1) customerId!: string;
/** Defaults to the next folio in the book. */
@IsOptional() @IsInt() @Min(1) folio?: number;
@IsString() @MinLength(1) receivedAt!: string;
@IsNumber() amount!: number;
@IsEnum(Currency) currency!: Currency;
/** Dollars credited to a pesos ledger: the rate they were taken at. */
@IsOptional() @IsNumber() exchangeRate?: number;
@IsString() @MinLength(1) concept!: string;
@IsOptional() @IsString() depositedAt?: string;
}
export class SetDepositDto {
/** Empty clears it. */
@IsOptional() @IsString() depositedAt?: string;
}
@@ -0,0 +1,80 @@
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
Req,
UseGuards,
} from "@nestjs/common";
import type { Request } from "express";
import { AuthenticatedGuard } from "../auth/authenticated.guard";
import { AbilityGuard } from "../auth/ability.guard";
import { RequireAbility } from "../auth/require-ability.decorator";
import { AuditService } from "../common/audit.service";
import { CashReceiptsService } from "./cash-receipts.service";
import { CreateCashReceiptDto, SetDepositDto } from "./cash-receipt.dto";
/** The EFE cash receipt book. Literal routes sit above `:folio`. */
@Controller("cash-receipts")
@UseGuards(AuthenticatedGuard, AbilityGuard)
export class CashReceiptsController {
constructor(
private readonly receipts: CashReceiptsService,
private readonly audit: AuditService,
) {}
private actingId(req: Request): string {
return (req.user as { id: string }).id;
}
@Get("next-folio")
async nextFolio() {
return { folio: await this.receipts.nextFolio() };
}
@Get()
list(
@Query("query") query?: string,
@Query("page") page?: string,
@Query("pageSize") pageSize?: string,
) {
return this.receipts.list(
query,
Math.max(1, Number(page) || 1),
Math.min(100, Math.max(1, Number(pageSize) || 25)),
);
}
@Get(":folio")
byFolio(@Param("folio", ParseIntPipe) folio: number) {
return this.receipts.byFolio(folio);
}
@Post()
@RequireAbility("ledger:create")
async create(@Body() dto: CreateCashReceiptDto, @Req() req: Request) {
const r = await this.receipts.create(dto, this.actingId(req));
void this.audit.log(this.actingId(req), "cash-receipt.create", {
folio: r.folio,
customerId: r.customerId,
amount: r.amount.toFixed(2),
currency: r.currency,
});
return r;
}
@Patch(":id/deposit")
@RequireAbility("ledger:create")
async setDeposit(@Param("id") id: string, @Body() dto: SetDepositDto, @Req() req: Request) {
const r = await this.receipts.setDeposited(id, dto.depositedAt);
void this.audit.log(this.actingId(req), "cash-receipt.deposit", {
folio: r.folio,
depositedAt: r.depositedAt,
});
return r;
}
}
@@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { CashReceiptsController } from "./cash-receipts.controller";
import { CashReceiptsService } from "./cash-receipts.service";
@Module({
controllers: [CashReceiptsController],
providers: [CashReceiptsService],
// Customer onboarding issues the opening cash receipt in its own transaction.
exports: [CashReceiptsService],
})
export class CashReceiptsModule {}
@@ -0,0 +1,214 @@
import { randomUUID } from "node:crypto";
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { Prisma } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
import { CASH_JOURNAL_SOURCE_DB } from "../billing/billing.service";
import { getCompanyInfo } from "../reports/company";
import type { CreateCashReceiptDto } from "./cash-receipt.dto";
/** The ledger concept legacy posted every cash receipt under. */
const CASH_CONCEPT = "CASH DEPOSIT";
const RECEIPT_INCLUDE = {
customer: { select: { id: true, name: true, folderNumber: true } },
preparedBy: { select: { name: true } },
transaction: { select: { id: true, voidedAt: true, amount: true, currency: true } },
} satisfies Prisma.CashReceiptInclude;
/**
* Legacy grouped a day's cash under one pseudo check: CASH + MMDD + P(esos) or
* D(ólares) — `CASH0714P`. Kept so by-check views put these rows where the
* office expects them.
*/
export function cashCheckNumber(receivedAt: Date, currency: "MXN" | "USD"): string {
const mm = String(receivedAt.getUTCMonth() + 1).padStart(2, "0");
const dd = String(receivedAt.getUTCDate()).padStart(2, "0");
return `CASH${mm}${dd}${currency === "USD" ? "D" : "P"}`;
}
function parseDay(v: string | undefined, field: string): Date | undefined {
if (!v) return undefined;
const d = new Date(v.length === 10 ? `${v}T00:00:00.000Z` : v);
if (isNaN(d.getTime())) throw new BadRequestException(`${field} inválida.`);
return d;
}
@Injectable()
export class CashReceiptsService {
constructor(private readonly prisma: PrismaService) {}
/**
* One past the highest folio in the book — the platform's own receipts or,
* before the first one, the legacy EFECTIVO journal imported at cutover.
* Only the utilities book: insurance kept its own EFECTIVO with its own
* numbers.
*/
async nextFolio(db: Prisma.TransactionClient = this.prisma): Promise<number> {
const native = await db.cashReceipt.aggregate({ _max: { folio: true } });
const [legacy] = await db.$queryRaw<{ mx: bigint | number | null }[]>`
SELECT MAX(CAST(reference AS UNSIGNED)) AS mx FROM transactions
WHERE legacySourceDb = ${CASH_JOURNAL_SOURCE_DB}
AND legacySourceTable = 'EFECTIVO'
AND reference REGEXP '^[0-9]+$'`;
return Math.max(native._max.folio ?? 0, Number(legacy?.mx ?? 0)) + 1;
}
/**
* Issue a receipt and post its credit, in one database transaction: a
* receipt without its posting is money the customer paid and never sees,
* and a posting without its receipt is the folio-13536 kind of hole.
*
* Pass `db` to run inside a caller's transaction (customer onboarding).
*/
async create(
dto: CreateCashReceiptDto,
preparedById: string,
db?: Prisma.TransactionClient,
) {
if (!(dto.amount > 0)) throw new BadRequestException("El monto debe ser mayor a cero.");
if (dto.exchangeRate != null) {
if (dto.currency !== "USD") {
throw new BadRequestException("El tipo de cambio sólo aplica a dólares.");
}
if (!(dto.exchangeRate > 0)) throw new BadRequestException("Tipo de cambio inválido.");
}
const receivedAt = parseDay(dto.receivedAt, "Fecha")!;
const depositedAt = parseDay(dto.depositedAt, "Fecha de depósito");
const run = (tx: Prisma.TransactionClient) => this.issue(tx, dto, receivedAt, depositedAt, preparedById);
if (db) return run(db);
try {
return await this.prisma.$transaction(run);
} catch (err) {
// Two receipts issued in the same instant both computed the same next
// folio; the loser simply takes the one after.
if (dto.folio == null && isUniqueViolation(err)) return this.prisma.$transaction(run);
throw err;
}
}
private async issue(
tx: Prisma.TransactionClient,
dto: CreateCashReceiptDto,
receivedAt: Date,
depositedAt: Date | undefined,
preparedById: string,
) {
const customer = await tx.customer.findUnique({
where: { id: dto.customerId },
select: { id: true },
});
if (!customer) throw new NotFoundException("Cliente no encontrado.");
const folio = dto.folio ?? (await this.nextFolio(tx));
// A typed folio may belong to the legacy book, which has no unique key here.
const taken =
(await tx.cashReceipt.count({ where: { folio } })) +
(await tx.transaction.count({
where: {
legacySourceDb: CASH_JOURNAL_SOURCE_DB,
legacySourceTable: "EFECTIVO",
reference: String(folio),
},
}));
if (taken) throw new ConflictException(`El folio ${folio} ya existe.`);
const type = await tx.typeTransaction.findFirst({
where: { nameEn: CASH_CONCEPT },
select: { id: true },
});
// Dollars taken against a pesos account are credited in pesos at the
// rate given, as legacy did (C13830: 1,500 USD posted as 25,200 MXN).
const converted = dto.exchangeRate != null;
const amount = new Prisma.Decimal(dto.amount.toFixed(2));
const posted = converted
? amount.times(dto.exchangeRate!).toDecimalPlaces(2)
: amount;
const id = randomUUID();
const transaction = await tx.transaction.create({
data: {
customerId: dto.customerId,
domain: "UTILITY",
typeId: type?.id,
transactionDate: receivedAt,
reference: `C${folio}`,
amount: posted,
currency: converted ? "MXN" : dto.currency,
exchangeRate: converted ? new Prisma.Decimal(dto.exchangeRate!) : undefined,
checkNumber: cashCheckNumber(receivedAt, dto.currency),
message: dto.concept,
captureSource: "MANUAL",
captureRef: id,
},
});
return tx.cashReceipt.create({
data: {
id,
folio,
customerId: dto.customerId,
receivedAt,
amount,
currency: dto.currency,
exchangeRate: converted ? new Prisma.Decimal(dto.exchangeRate!) : undefined,
concept: dto.concept,
preparedById,
depositedAt,
transactionId: transaction.id,
},
include: RECEIPT_INCLUDE,
});
}
async list(query: string | undefined, page: number, pageSize: number) {
const q = query?.trim();
const where: Prisma.CashReceiptWhereInput = q
? /^\d+$/.test(q)
? { OR: [{ folio: Number(q) }, { customer: { folderNumber: Number(q) } }] }
: { customer: { name: { contains: q } } }
: {};
const [total, items] = await this.prisma.$transaction([
this.prisma.cashReceipt.count({ where }),
this.prisma.cashReceipt.findMany({
where,
orderBy: { folio: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
include: RECEIPT_INCLUDE,
}),
]);
return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) };
}
/** One receipt, with what the printout needs to head it. */
async byFolio(folio: number) {
const receipt = await this.prisma.cashReceipt.findUnique({
where: { folio },
include: RECEIPT_INCLUDE,
});
if (!receipt) throw new NotFoundException(`No existe el folio ${folio}.`);
const { logo: _logo, logoPath: _path, ...company } = getCompanyInfo();
return { ...receipt, company };
}
async setDeposited(id: string, depositedAt: string | undefined) {
const found = await this.prisma.cashReceipt.findUnique({ where: { id }, select: { id: true } });
if (!found) throw new NotFoundException("Recibo no encontrado.");
return this.prisma.cashReceipt.update({
where: { id },
data: { depositedAt: parseDay(depositedAt, "Fecha de depósito") ?? null },
include: RECEIPT_INCLUDE,
});
}
}
function isUniqueViolation(err: unknown): boolean {
return err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002";
}
@@ -0,0 +1,78 @@
import { ConflictException } from "@nestjs/common";
import { CashReceiptsService, cashCheckNumber } from "./cash-receipts.service";
/**
* A cash receipt is two writes that must agree: the receipt and its C<folio>
* credit. Checked here: the credit legacy would have posted, and that a folio
* the legacy book already used is refused. Folio numbering itself runs against
* real data (it reads the imported EFECTIVO journal).
*/
function build(opts: { legacyTaken?: boolean } = {}) {
const posted: Record<string, unknown>[] = [];
const tx = {
customer: { findUnique: jest.fn().mockResolvedValue({ id: "c1" }) },
cashReceipt: {
aggregate: jest.fn().mockResolvedValue({ _max: { folio: 13934 } }),
count: jest.fn().mockResolvedValue(0),
create: jest.fn(({ data }) => Promise.resolve(data)),
},
transaction: {
count: jest.fn().mockResolvedValue(opts.legacyTaken ? 1 : 0),
create: jest.fn(({ data }) => {
posted.push(data);
return Promise.resolve({ id: "tx1", ...data });
}),
},
typeTransaction: { findFirst: jest.fn().mockResolvedValue({ id: "cash-deposit" }) },
$queryRaw: jest.fn().mockResolvedValue([{ mx: 13900 }]),
};
const prisma = { $transaction: jest.fn((fn) => fn(tx)) };
return { service: new CashReceiptsService(prisma as never), posted };
}
const base = {
customerId: "c1",
receivedAt: "2026-07-14",
concept: "RECEIVED $1500 DLLS FOR ACCOUNT.",
};
describe("cash receipts", () => {
it("takes the next folio and posts a C<folio> credit", async () => {
const { service, posted } = build();
const r = await service.create({ ...base, amount: 9000, currency: "MXN" }, "u1");
expect(r.folio).toBe(13935);
expect(posted[0]).toMatchObject({
reference: "C13935",
checkNumber: "CASH0714P",
currency: "MXN",
typeId: "cash-deposit",
});
expect(Number(posted[0].amount)).toBe(9000);
});
it("credits dollars to a pesos ledger at the given rate, as legacy did", async () => {
// Legacy C13830: 1,500 USD at 16.80 posted as 25,200 MXN.
const { service, posted } = build();
await service.create(
{ ...base, amount: 1500, currency: "USD", exchangeRate: 16.8 },
"u1",
);
expect(posted[0]).toMatchObject({ currency: "MXN", checkNumber: "CASH0714D" });
expect(Number(posted[0].amount)).toBe(25200);
});
it("refuses a typed folio the legacy book already used", async () => {
const { service, posted } = build({ legacyTaken: true });
await expect(
service.create({ ...base, folio: 13800, amount: 1, currency: "MXN" }, "u1"),
).rejects.toBeInstanceOf(ConflictException);
expect(posted).toHaveLength(0);
});
it("names the pseudo check by day and currency", () => {
expect(cashCheckNumber(new Date("2026-01-05T00:00:00Z"), "USD")).toBe("CASH0105D");
});
});
+7 -3
View File
@@ -550,8 +550,8 @@ const reporteDeEfectivo: ReportDef = {
slug: "reporte-de-efectivo",
title: "Reporte de efectivo",
description:
"Recibos de efectivo en el periodo seleccionado. Cubre los abonos " +
"provenientes de las tablas legacy EFECTIVO* y los créditos sin " +
"Recibos de efectivo en el periodo seleccionado: los recibos EFE de la " +
"plataforma, los de las tablas legacy EFECTIVO* y los créditos sin " +
"número de cheque (efectivo real). El match del reporte original.",
domain: "chequera",
legacyName: "REPORTE DE EFECTIVO",
@@ -591,6 +591,9 @@ const reporteDeEfectivo: ReportDef = {
{ OR: [{ typeId: null }, { type: { nameEn: { not: BALANCE_FORWARD_TYPE } } }] },
{
OR: [
// A platform receipt posts one row (its C<folio> credit, under a
// CASH… pseudo check), so it is matched by the receipt, not the check.
{ cashReceipt: { isNot: null } },
{ legacySourceTable: { in: ["EFECTIVO", "EFECTIVO_BACKUP"] } },
{
AND: [
@@ -620,6 +623,7 @@ const reporteDeEfectivo: ReportDef = {
checkNumber: true,
message: true,
legacySourceTable: true,
cashReceipt: { select: { folio: true } },
type: { select: { nameEs: true, nameEn: true } },
customer: { select: { id: true, name: true, nameMissing: true } },
},
@@ -634,7 +638,7 @@ const reporteDeEfectivo: ReportDef = {
folder: nums.folder(r.customer.id),
customerName: nameOf(r.customer),
concept: r.message ?? r.type?.nameEs ?? r.type?.nameEn ?? "—",
source: r.legacySourceTable ?? "—",
source: r.cashReceipt ? `EFE ${r.cashReceipt.folio}` : r.legacySourceTable ?? "—",
amount: r.amount.toFixed(2),
};
});
@@ -0,0 +1,98 @@
"use client";
import { useEffect, useState } from "react";
import { getCashReceipt } from "@/lib/api";
import { formatDate, formatMoney } from "@/lib/labels";
import type { CashReceipt, CompanyHeader } from "@/lib/types";
/**
* One EFE receipt, laid out to print and hand to the customer. No app shell:
* the page is the paper.
*/
export default function CashReceiptPrint({ params }: { params: { folio: string } }) {
const [r, setR] = useState<(CashReceipt & { company: CompanyHeader }) | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getCashReceipt(Number(params.folio))
.then(setR)
.catch((e) => setError((e as Error)?.message ?? "No se encontró el recibo."));
}, [params.folio]);
if (error) return <div className="state-box state-error">{error}</div>;
if (!r) return <div className="state-box">Cargando…</div>;
const c = r.company;
const voided = r.transaction.voidedAt != null;
const converted = r.exchangeRate != null;
return (
<div style={{ maxWidth: 720, margin: "24px auto", padding: 24, fontSize: 14 }}>
<style>{"@media print { .no-print { display: none } body { background: #fff } }"}</style>
<div className="no-print" style={{ marginBottom: 16 }}>
<button type="button" className="btn btn-primary" onClick={() => window.print()}>
Imprimir
</button>
</div>
<div style={{ border: "1px solid #333", padding: 24 }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 16 }}>
<div>
<strong style={{ fontSize: 18 }}>{c.name}</strong>
<div>{c.addressLine1}</div>
{c.addressLine2 && <div>{c.addressLine2}</div>}
<div>{c.cityState}</div>
{c.mailingAddress && <div>{c.mailingAddress}</div>}
<div>
{c.phone} · {c.email}
</div>
{c.taxId && <div>RFC {c.taxId}</div>}
</div>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: 18, fontWeight: 700 }}>RECIBO DE EFECTIVO</div>
<div style={{ fontSize: 22, fontWeight: 700 }}>Folio {r.folio}</div>
<div>{formatDate(r.receivedAt)}</div>
{voided && <div style={{ color: "#b00", fontWeight: 700 }}>ANULADO</div>}
</div>
</div>
<table style={{ width: "100%", marginTop: 24, borderCollapse: "collapse" }}>
<tbody>
<Row label="Cliente">
{r.customer.name}
{r.customer.folderNumber != null && ` · Carpeta ${r.customer.folderNumber}`}
</Row>
<Row label="Monto">
<strong>{formatMoney(r.amount, r.currency)}</strong>
{converted &&
` (abonado ${formatMoney(r.transaction.amount, r.transaction.currency)} a T.C. ${Number(r.exchangeRate).toFixed(4)})`}
</Row>
<Row label="Concepto">{r.concept}</Row>
<Row label="Elaboró">{r.preparedBy.name}</Row>
{r.depositedAt && <Row label="Fecha de depósito">{formatDate(r.depositedAt)}</Row>}
</tbody>
</table>
<div style={{ marginTop: 56, display: "flex", justifyContent: "space-between" }}>
<div style={{ borderTop: "1px solid #333", width: 240, textAlign: "center", paddingTop: 4 }}>
Recibí
</div>
<div style={{ borderTop: "1px solid #333", width: 240, textAlign: "center", paddingTop: 4 }}>
Entregó
</div>
</div>
</div>
</div>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<tr>
<td style={{ padding: "6px 12px 6px 0", width: 160, color: "#555", verticalAlign: "top" }}>
{label}
</td>
<td style={{ padding: "6px 0" }}>{children}</td>
</tr>
);
}
+291
View File
@@ -0,0 +1,291 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { CustomerPicker } from "@/components/CustomerPicker";
import {
createCashReceipt,
getNextCashFolio,
listCashReceipts,
setCashReceiptDeposit,
} from "@/lib/api";
import { useCan } from "@/lib/abilities";
import { formatDate, formatMoney } from "@/lib/labels";
import type { CashReceipt, Currency } from "@/lib/types";
/**
* The EFE cash receipt book — the legacy EFE form. Each receipt takes the next
* folio, prints for the customer, and credits their account as C<folio> in the
* same step. Elaboró is whoever is logged in.
*/
export default function EfectivoPage() {
return (
<AppShell>
<CashBook />
</AppShell>
);
}
const today = () => new Date().toISOString().slice(0, 10);
function CashBook() {
const canCapture = useCan("ledger:create");
const [query, setQuery] = useState("");
const [items, setItems] = useState<CashReceipt[]>([]);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const r = await listCashReceipts({ query: query.trim() || undefined });
setItems(r.items);
setError(null);
} catch (e) {
setError((e as Error)?.message ?? "No se pudieron cargar los recibos.");
}
}, [query]);
useEffect(() => {
const t = setTimeout(() => void load(), 250);
return () => clearTimeout(t);
}, [load]);
return (
<div className="stack">
<header className="page-head">
<div>
<h1 className="page-title">Recibos de efectivo</h1>
<p className="page-sub">
Cada recibo toma el siguiente folio y abona la cuenta del cliente como C‹folio›.
</p>
</div>
</header>
{canCapture && <ReceiptForm onIssued={load} />}
<section className="card" style={{ padding: 16 }}>
<input
className="input"
type="search"
placeholder="Buscar por folio, carpeta o cliente…"
value={query}
onChange={(e) => setQuery(e.target.value)}
style={{ marginBottom: 12, maxWidth: 360 }}
/>
{error && <div className="state-box state-error">{error}</div>}
<table className="tx-table">
<thead>
<tr>
<th>Folio</th>
<th>Fecha</th>
<th>Carpeta</th>
<th>Cliente</th>
<th style={{ textAlign: "right" }}>Monto</th>
<th>Concepto</th>
<th>Elaboró</th>
<th>Depósito</th>
<th />
</tr>
</thead>
<tbody>
{items.map((r) => (
<ReceiptRow key={r.id} r={r} canCapture={canCapture} onChange={load} />
))}
{!items.length && (
<tr>
<td colSpan={9} className="page-sub">Sin recibos.</td>
</tr>
)}
</tbody>
</table>
</section>
</div>
);
}
function ReceiptForm({ onIssued }: { onIssued: () => void }) {
const [customerId, setCustomerId] = useState("");
const [customerName, setCustomerName] = useState("");
const [folio, setFolio] = useState("");
const [receivedAt, setReceivedAt] = useState(today);
const [amount, setAmount] = useState("");
const [currency, setCurrency] = useState<Currency>("MXN");
const [exchangeRate, setExchangeRate] = useState("");
const [concept, setConcept] = useState("");
const [depositedAt, setDepositedAt] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [issued, setIssued] = useState<CashReceipt | null>(null);
const refreshFolio = useCallback(() => {
getNextCashFolio()
.then((r) => setFolio(String(r.folio)))
.catch(() => {});
}, []);
useEffect(refreshFolio, [refreshFolio]);
// The wording the office already writes on every receipt.
const suggested = amount
? currency === "USD"
? `RECEIVED $${amount} DLLS FOR ACCOUNT.`
: `RECIBI $${amount} PESOS PARA CUENTA.`
: "";
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
const r = await createCashReceipt({
customerId,
folio: folio ? Number(folio) : undefined,
receivedAt,
amount: Number(amount),
currency,
exchangeRate: currency === "USD" && exchangeRate ? Number(exchangeRate) : undefined,
concept: concept.trim() || suggested,
depositedAt: depositedAt || undefined,
});
setIssued(r);
setCustomerId("");
setCustomerName("");
setAmount("");
setExchangeRate("");
setConcept("");
setDepositedAt("");
refreshFolio();
onIssued();
} catch (e2) {
setError((e2 as Error)?.message ?? "No se pudo emitir el recibo.");
} finally {
setBusy(false);
}
}
return (
<form className="card" style={{ padding: 16 }} onSubmit={submit}>
<h2 className="section-title" style={{ marginTop: 0 }}>Nuevo recibo</h2>
{error && <div className="state-box state-error">{error}</div>}
{issued && (
<div className="state-box" style={{ marginBottom: 12 }}>
Folio {issued.folio} emitido a {issued.customer.name} por{" "}
{formatMoney(issued.amount, issued.currency)}.{" "}
<Link href={`/efectivo/${issued.folio}`} target="_blank">
Imprimir
</Link>
</div>
)}
<div className="form-grid">
<label className="field">
<span className="field-label">Folio</span>
<input className="input" type="number" min={1} value={folio}
onChange={(e) => setFolio(e.target.value)} />
</label>
<div className="field" style={{ minWidth: 260 }}>
<span className="field-label">Cliente *</span>
<CustomerPicker
value={customerId}
valueName={customerName}
onPick={(id, name) => {
setCustomerId(id);
setCustomerName(name);
}}
/>
</div>
<label className="field">
<span className="field-label">Fecha *</span>
<input className="input" type="date" required value={receivedAt}
onChange={(e) => setReceivedAt(e.target.value)} />
</label>
<label className="field">
<span className="field-label">Monto *</span>
<input className="input" type="number" step="0.01" min="0.01" required value={amount}
onChange={(e) => setAmount(e.target.value)} />
</label>
<label className="field">
<span className="field-label">Moneda</span>
<select className="select" value={currency}
onChange={(e) => setCurrency(e.target.value as Currency)}>
<option value="MXN">MXN</option>
<option value="USD">USD</option>
</select>
</label>
{currency === "USD" && (
<label className="field">
<span className="field-label">Tipo de cambio (abonar en pesos)</span>
<input className="input" type="number" step="0.0001" min="0" value={exchangeRate}
placeholder="Vacío = abonar en dólares"
onChange={(e) => setExchangeRate(e.target.value)} />
</label>
)}
<label className="field">
<span className="field-label">Fecha de depósito</span>
<input className="input" type="date" value={depositedAt}
onChange={(e) => setDepositedAt(e.target.value)} />
</label>
</div>
<label className="field" style={{ marginTop: 12 }}>
<span className="field-label">Concepto</span>
<input className="input" value={concept} placeholder={suggested}
onChange={(e) => setConcept(e.target.value)} />
</label>
<div className="form-actions">
<button type="submit" className="btn btn-primary"
disabled={busy || !customerId || !(Number(amount) > 0)}>
{busy ? "Emitiendo…" : "Emitir recibo"}
</button>
</div>
</form>
);
}
function ReceiptRow({
r,
canCapture,
onChange,
}: {
r: CashReceipt;
canCapture: boolean;
onChange: () => void;
}) {
const [deposit, setDeposit] = useState("");
const voided = r.transaction.voidedAt != null;
async function saveDeposit() {
await setCashReceiptDeposit(r.id, deposit);
onChange();
}
return (
<tr style={voided ? { textDecoration: "line-through", opacity: 0.6 } : undefined}>
<td>{r.folio}</td>
<td>{formatDate(r.receivedAt)}</td>
<td>{r.customer.folderNumber ?? "—"}</td>
<td>
<Link href={`/clientes/${r.customer.id}`}>{r.customer.name}</Link>
</td>
<td style={{ textAlign: "right" }}>{formatMoney(r.amount, r.currency)}</td>
<td>{r.concept}</td>
<td>{r.preparedBy.name}</td>
<td>
{r.depositedAt ? (
formatDate(r.depositedAt)
) : canCapture && !voided ? (
<span className="inline-form" style={{ gap: 4 }}>
<input className="input" type="date" value={deposit}
onChange={(e) => setDeposit(e.target.value)} />
<button type="button" className="btn btn-ghost" disabled={!deposit}
onClick={saveDeposit}>
Guardar
</button>
</span>
) : (
"—"
)}
</td>
<td>
{voided ? "Anulado" : <Link href={`/efectivo/${r.folio}`} target="_blank">Imprimir</Link>}
</td>
</tr>
);
}
+2
View File
@@ -66,6 +66,8 @@ const NAV: NavEntry[] = [
ability: "ledger:create",
aliases: ["/recibos"],
},
// The EFE cash receipt book (legacy EFE MENU → EFE).
{ href: "/efectivo", label: "Recibos de efectivo", ability: "ledger:create" },
{ href: "/estado-cuenta", label: "Estado de cuenta" },
{ href: "/banco", label: "Chequera" },
],
+37
View File
@@ -81,6 +81,9 @@ import type {
TransactionDomain,
TrustFilter,
UserRow,
CashReceipt,
CashReceiptInput,
CompanyHeader,
} from "./types";
// Resolve the API origin at runtime, not build time — so one built image serves
@@ -1463,6 +1466,40 @@ export function statementPageUrl(documentId: string): string {
return `${API_ORIGIN}/statements/documents/${documentId}/page`;
}
/* ------------------------------------------------ EFE cash receipt book */
export function getNextCashFolio(): Promise<{ folio: number }> {
return apiFetch("/cash-receipts/next-folio");
}
export function listCashReceipts(q: { query?: string; page?: number } = {}): Promise<{
items: CashReceipt[];
total: number;
page: number;
pageSize: number;
pageCount: number;
}> {
const params = new URLSearchParams();
if (q.query) params.set("query", q.query);
if (q.page) params.set("page", String(q.page));
return apiFetch(`/cash-receipts?${params}`);
}
export function getCashReceipt(folio: number): Promise<CashReceipt & { company: CompanyHeader }> {
return apiFetch(`/cash-receipts/${folio}`);
}
export function createCashReceipt(input: CashReceiptInput): Promise<CashReceipt> {
return apiFetch("/cash-receipts", { method: "POST", body: JSON.stringify(input) });
}
export function setCashReceiptDeposit(id: string, depositedAt?: string): Promise<CashReceipt> {
return apiFetch(`/cash-receipts/${id}/deposit`, {
method: "PATCH",
body: JSON.stringify({ depositedAt }),
});
}
/* ----------------------------------------------- Policy OCR (GMX / ANA) */
export function getPolicyOcrStatus(): Promise<{
+42
View File
@@ -1681,3 +1681,45 @@ export interface PolicyOcrConfirmResult {
policies: string[];
postedTransactions: number;
}
/* ------------------------------------------------ EFE cash receipt book */
export interface CashReceipt {
id: string;
folio: number;
receivedAt: string;
amount: string;
currency: Currency;
exchangeRate: string | null;
concept: string;
depositedAt: string | null;
createdAt: string;
customer: { id: string; name: string; folderNumber: number | null };
preparedBy: { name: string };
/** The C<folio> credit. Voiding it voids the receipt. */
transaction: { id: string; voidedAt: string | null; amount: string; currency: Currency };
}
/** Office details the printed receipt is headed with. */
export interface CompanyHeader {
name: string;
addressLine1: string;
addressLine2: string;
cityState: string;
mailingAddress: string;
phone: string;
email: string;
taxId: string;
website: string;
}
export interface CashReceiptInput {
customerId: string;
folio?: number;
receivedAt: string;
amount: number;
currency: Currency;
exchangeRate?: number;
concept: string;
depositedAt?: string;
}
@@ -0,0 +1,33 @@
-- EFE cash receipt book (2026-10-08 meeting). Each receipt posts its own ledger
-- credit, reference C<folio>, in the same database transaction.
-- CreateTable
CREATE TABLE `cash_receipts` (
`id` VARCHAR(191) NOT NULL,
`folio` INTEGER NOT NULL,
`customerId` VARCHAR(191) NOT NULL,
`receivedAt` DATETIME(3) NOT NULL,
`amount` DECIMAL(12, 2) NOT NULL,
`currency` ENUM('USD', 'MXN') NOT NULL,
`exchangeRate` DECIMAL(10, 4) NULL,
`concept` TEXT NOT NULL,
`preparedById` VARCHAR(191) NOT NULL,
`depositedAt` DATETIME(3) NULL,
`transactionId` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `cash_receipts_folio_key`(`folio`),
UNIQUE INDEX `cash_receipts_transactionId_key`(`transactionId`),
INDEX `cash_receipts_customerId_idx`(`customerId`),
INDEX `cash_receipts_receivedAt_idx`(`receivedAt`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `cash_receipts` ADD CONSTRAINT `cash_receipts_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `cash_receipts` ADD CONSTRAINT `cash_receipts_preparedById_fkey` FOREIGN KEY (`preparedById`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `cash_receipts` ADD CONSTRAINT `cash_receipts_transactionId_fkey` FOREIGN KEY (`transactionId`) REFERENCES `transactions`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+35
View File
@@ -131,6 +131,7 @@ model Customer {
statementDocuments StatementDocument[]
statementSplits StatementDocumentSplit[]
cashReceipts CashReceipt[]
policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer")
emailNotificationLogs EmailNotificationLog[]
@@ -884,6 +885,7 @@ model Transaction {
/// Set only on OCR-posted rows — the statement page this came from.
statementDocument StatementDocument?
statementSplit StatementDocumentSplit? @relation("StatementDocumentSplitTransaction")
cashReceipt CashReceipt?
policyOcrDocument PolicyOcrDocument? @relation("PolicyOcrDocumentTransaction")
@@unique([legacySourceDb, legacySourceTable, legacyId])
@@ -1005,6 +1007,7 @@ model User {
statementBatches StatementBatch[] @relation("StatementBatchUploader")
statementsReviewed StatementDocument[] @relation("StatementDocumentReviewer")
cashReceipts CashReceipt[] @relation("CashReceiptPreparer")
policyOcrBatches PolicyOcrBatch[] @relation("PolicyOcrBatchUploader")
policyOcrReviewed PolicyOcrDocument[] @relation("PolicyOcrDocumentReviewer")
@@ -1287,3 +1290,35 @@ model AppSetting {
@@map("app_settings")
}
/// The EFE cash receipt book (legacy EFE form / EFECTIVO table): the paper the
/// office hands a customer who pays in cash. A journal, not a ledger — the
/// money reaches the customer's balance through `transaction`, posted with it
/// as `C<folio>` exactly as legacy posted to DATOS2. Voiding that row voids
/// the receipt.
model CashReceipt {
id String @id @default(uuid())
/// Printed receipt number. Continues the legacy book's sequence.
folio Int @unique
customerId String
customer Customer @relation(fields: [customerId], references: [id])
receivedAt DateTime
amount Decimal @db.Decimal(12, 2)
currency Currency
/// Set when dollars are credited to a pesos ledger, as legacy did; the
/// posted row then carries amount × rate in MXN.
exchangeRate Decimal? @db.Decimal(10, 4)
concept String @db.Text
/// ELABORÓ — whoever was logged in.
preparedById String
preparedBy User @relation("CashReceiptPreparer", fields: [preparedById], references: [id])
/// FECHA DEPÓSITO — filled in when the cash reaches the bank.
depositedAt DateTime?
transactionId String @unique
transaction Transaction @relation(fields: [transactionId], references: [id])
createdAt DateTime @default(now())
@@index([customerId])
@@index([receivedAt])
@@map("cash_receipts")
}