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),
|
||||
};
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user