feat(efectivo): the EFE cash receipt book, folio and posting in one step
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:
1 parent
fd5df240bc
commit
ca76f4d57a
14 files changed
+959
-3
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
],
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user