diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index e96490d..20c50ba 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, diff --git a/apps/api/src/cash-receipts/cash-receipt.dto.ts b/apps/api/src/cash-receipts/cash-receipt.dto.ts new file mode 100644 index 0000000..7ccf64c --- /dev/null +++ b/apps/api/src/cash-receipts/cash-receipt.dto.ts @@ -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; +} diff --git a/apps/api/src/cash-receipts/cash-receipts.controller.ts b/apps/api/src/cash-receipts/cash-receipts.controller.ts new file mode 100644 index 0000000..a7ee909 --- /dev/null +++ b/apps/api/src/cash-receipts/cash-receipts.controller.ts @@ -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; + } +} diff --git a/apps/api/src/cash-receipts/cash-receipts.module.ts b/apps/api/src/cash-receipts/cash-receipts.module.ts new file mode 100644 index 0000000..5a22788 --- /dev/null +++ b/apps/api/src/cash-receipts/cash-receipts.module.ts @@ -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 {} diff --git a/apps/api/src/cash-receipts/cash-receipts.service.ts b/apps/api/src/cash-receipts/cash-receipts.service.ts new file mode 100644 index 0000000..1dc9469 --- /dev/null +++ b/apps/api/src/cash-receipts/cash-receipts.service.ts @@ -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 { + 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"; +} diff --git a/apps/api/src/cash-receipts/cash-receipts.spec.ts b/apps/api/src/cash-receipts/cash-receipts.spec.ts new file mode 100644 index 0000000..71493bd --- /dev/null +++ b/apps/api/src/cash-receipts/cash-receipts.spec.ts @@ -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 + * 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[] = []; + 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 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"); + }); +}); diff --git a/apps/api/src/reports/reports.registry.ts b/apps/api/src/reports/reports.registry.ts index 9c8d9b6..91be5e2 100644 --- a/apps/api/src/reports/reports.registry.ts +++ b/apps/api/src/reports/reports.registry.ts @@ -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 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), }; }); diff --git a/apps/web/src/app/efectivo/[folio]/page.tsx b/apps/web/src/app/efectivo/[folio]/page.tsx new file mode 100644 index 0000000..893edd5 --- /dev/null +++ b/apps/web/src/app/efectivo/[folio]/page.tsx @@ -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(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
{error}
; + if (!r) return
Cargando…
; + + const c = r.company; + const voided = r.transaction.voidedAt != null; + const converted = r.exchangeRate != null; + + return ( +
+ +
+ +
+ +
+
+
+ {c.name} +
{c.addressLine1}
+ {c.addressLine2 &&
{c.addressLine2}
} +
{c.cityState}
+ {c.mailingAddress &&
{c.mailingAddress}
} +
+ {c.phone} · {c.email} +
+ {c.taxId &&
RFC {c.taxId}
} +
+
+
RECIBO DE EFECTIVO
+
Folio {r.folio}
+
{formatDate(r.receivedAt)}
+ {voided &&
ANULADO
} +
+
+ + + + + {r.customer.name} + {r.customer.folderNumber != null && ` · Carpeta ${r.customer.folderNumber}`} + + + {formatMoney(r.amount, r.currency)} + {converted && + ` (abonado ${formatMoney(r.transaction.amount, r.transaction.currency)} a T.C. ${Number(r.exchangeRate).toFixed(4)})`} + + {r.concept} + {r.preparedBy.name} + {r.depositedAt && {formatDate(r.depositedAt)}} + +
+ +
+
+ Recibí +
+
+ Entregó +
+
+
+
+ ); +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + + {label} + + {children} + + ); +} diff --git a/apps/web/src/app/efectivo/page.tsx b/apps/web/src/app/efectivo/page.tsx new file mode 100644 index 0000000..77bd1c9 --- /dev/null +++ b/apps/web/src/app/efectivo/page.tsx @@ -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 in the + * same step. Elaboró is whoever is logged in. + */ +export default function EfectivoPage() { + return ( + + + + ); +} + +const today = () => new Date().toISOString().slice(0, 10); + +function CashBook() { + const canCapture = useCan("ledger:create"); + const [query, setQuery] = useState(""); + const [items, setItems] = useState([]); + const [error, setError] = useState(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 ( +
+
+
+

Recibos de efectivo

+

+ Cada recibo toma el siguiente folio y abona la cuenta del cliente como C‹folio›. +

+
+
+ + {canCapture && } + +
+ setQuery(e.target.value)} + style={{ marginBottom: 12, maxWidth: 360 }} + /> + {error &&
{error}
} + + + + + + + + + + + + + + + {items.map((r) => ( + + ))} + {!items.length && ( + + + + )} + +
FolioFechaCarpetaClienteMontoConceptoElaboróDepósito +
Sin recibos.
+
+
+ ); +} + +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("MXN"); + const [exchangeRate, setExchangeRate] = useState(""); + const [concept, setConcept] = useState(""); + const [depositedAt, setDepositedAt] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [issued, setIssued] = useState(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 ( +
+

Nuevo recibo

+ {error &&
{error}
} + {issued && ( +
+ Folio {issued.folio} emitido a {issued.customer.name} por{" "} + {formatMoney(issued.amount, issued.currency)}.{" "} + + Imprimir + +
+ )} +
+ +
+ Cliente * + { + setCustomerId(id); + setCustomerName(name); + }} + /> +
+ + + + {currency === "USD" && ( + + )} + +
+ +
+ +
+
+ ); +} + +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 ( + + {r.folio} + {formatDate(r.receivedAt)} + {r.customer.folderNumber ?? "—"} + + {r.customer.name} + + {formatMoney(r.amount, r.currency)} + {r.concept} + {r.preparedBy.name} + + {r.depositedAt ? ( + formatDate(r.depositedAt) + ) : canCapture && !voided ? ( + + setDeposit(e.target.value)} /> + + + ) : ( + "—" + )} + + + {voided ? "Anulado" : Imprimir} + + + ); +} diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index 589fb80..e50663e 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -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" }, ], diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 507470b..ba1e6b4 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -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 { + return apiFetch(`/cash-receipts/${folio}`); +} + +export function createCashReceipt(input: CashReceiptInput): Promise { + return apiFetch("/cash-receipts", { method: "POST", body: JSON.stringify(input) }); +} + +export function setCashReceiptDeposit(id: string, depositedAt?: string): Promise { + return apiFetch(`/cash-receipts/${id}/deposit`, { + method: "PATCH", + body: JSON.stringify({ depositedAt }), + }); +} + /* ----------------------------------------------- Policy OCR (GMX / ANA) */ export function getPolicyOcrStatus(): Promise<{ diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index c80cdd5..1aa3bc0 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -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 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; +} diff --git a/packages/database/prisma/migrations/20261013140000_cash_receipts/migration.sql b/packages/database/prisma/migrations/20261013140000_cash_receipts/migration.sql new file mode 100644 index 0000000..d0a3afb --- /dev/null +++ b/packages/database/prisma/migrations/20261013140000_cash_receipts/migration.sql @@ -0,0 +1,33 @@ +-- EFE cash receipt book (2026-10-08 meeting). Each receipt posts its own ledger +-- credit, reference C, 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; + diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 6fa0835..38ce81a 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -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` 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") +}