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),
};
});