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 { PropertiesModule } from "./properties/properties.module";
|
||||||
import { BillingModule } from "./billing/billing.module";
|
import { BillingModule } from "./billing/billing.module";
|
||||||
import { StatementsModule } from "./statements/statements.module";
|
import { StatementsModule } from "./statements/statements.module";
|
||||||
|
import { CashReceiptsModule } from "./cash-receipts/cash-receipts.module";
|
||||||
import { PolicyOcrModule } from "./policy-ocr/policy-ocr.module";
|
import { PolicyOcrModule } from "./policy-ocr/policy-ocr.module";
|
||||||
import { BankModule } from "./bank/bank.module";
|
import { BankModule } from "./bank/bank.module";
|
||||||
import { OpsModule } from "./ops/ops.module";
|
import { OpsModule } from "./ops/ops.module";
|
||||||
@@ -35,6 +36,7 @@ import { AppController } from "./app.controller";
|
|||||||
PropertiesModule,
|
PropertiesModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
StatementsModule,
|
StatementsModule,
|
||||||
|
CashReceiptsModule,
|
||||||
PolicyOcrModule,
|
PolicyOcrModule,
|
||||||
BankModule,
|
BankModule,
|
||||||
OpsModule,
|
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",
|
slug: "reporte-de-efectivo",
|
||||||
title: "Reporte de efectivo",
|
title: "Reporte de efectivo",
|
||||||
description:
|
description:
|
||||||
"Recibos de efectivo en el periodo seleccionado. Cubre los abonos " +
|
"Recibos de efectivo en el periodo seleccionado: los recibos EFE de la " +
|
||||||
"provenientes de las tablas legacy EFECTIVO* y los créditos sin " +
|
"plataforma, los de las tablas legacy EFECTIVO* y los créditos sin " +
|
||||||
"número de cheque (efectivo real). El match del reporte original.",
|
"número de cheque (efectivo real). El match del reporte original.",
|
||||||
domain: "chequera",
|
domain: "chequera",
|
||||||
legacyName: "REPORTE DE EFECTIVO",
|
legacyName: "REPORTE DE EFECTIVO",
|
||||||
@@ -591,6 +591,9 @@ const reporteDeEfectivo: ReportDef = {
|
|||||||
{ OR: [{ typeId: null }, { type: { nameEn: { not: BALANCE_FORWARD_TYPE } } }] },
|
{ OR: [{ typeId: null }, { type: { nameEn: { not: BALANCE_FORWARD_TYPE } } }] },
|
||||||
{
|
{
|
||||||
OR: [
|
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"] } },
|
{ legacySourceTable: { in: ["EFECTIVO", "EFECTIVO_BACKUP"] } },
|
||||||
{
|
{
|
||||||
AND: [
|
AND: [
|
||||||
@@ -620,6 +623,7 @@ const reporteDeEfectivo: ReportDef = {
|
|||||||
checkNumber: true,
|
checkNumber: true,
|
||||||
message: true,
|
message: true,
|
||||||
legacySourceTable: true,
|
legacySourceTable: true,
|
||||||
|
cashReceipt: { select: { folio: true } },
|
||||||
type: { select: { nameEs: true, nameEn: true } },
|
type: { select: { nameEs: true, nameEn: true } },
|
||||||
customer: { select: { id: true, name: true, nameMissing: true } },
|
customer: { select: { id: true, name: true, nameMissing: true } },
|
||||||
},
|
},
|
||||||
@@ -634,7 +638,7 @@ const reporteDeEfectivo: ReportDef = {
|
|||||||
folder: nums.folder(r.customer.id),
|
folder: nums.folder(r.customer.id),
|
||||||
customerName: nameOf(r.customer),
|
customerName: nameOf(r.customer),
|
||||||
concept: r.message ?? r.type?.nameEs ?? r.type?.nameEn ?? "—",
|
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),
|
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",
|
ability: "ledger:create",
|
||||||
aliases: ["/recibos"],
|
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: "/estado-cuenta", label: "Estado de cuenta" },
|
||||||
{ href: "/banco", label: "Chequera" },
|
{ href: "/banco", label: "Chequera" },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ import type {
|
|||||||
TransactionDomain,
|
TransactionDomain,
|
||||||
TrustFilter,
|
TrustFilter,
|
||||||
UserRow,
|
UserRow,
|
||||||
|
CashReceipt,
|
||||||
|
CashReceiptInput,
|
||||||
|
CompanyHeader,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
// Resolve the API origin at runtime, not build time — so one built image serves
|
// 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`;
|
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) */
|
/* ----------------------------------------------- Policy OCR (GMX / ANA) */
|
||||||
|
|
||||||
export function getPolicyOcrStatus(): Promise<{
|
export function getPolicyOcrStatus(): Promise<{
|
||||||
|
|||||||
@@ -1681,3 +1681,45 @@ export interface PolicyOcrConfirmResult {
|
|||||||
policies: string[];
|
policies: string[];
|
||||||
postedTransactions: number;
|
postedTransactions: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------ EFE cash receipt book */
|
||||||
|
|
||||||
|
export interface CashReceipt {
|
||||||
|
id: string;
|
||||||
|
folio: number;
|
||||||
|
receivedAt: string;
|
||||||
|
amount: string;
|
||||||
|
currency: Currency;
|
||||||
|
exchangeRate: string | null;
|
||||||
|
concept: string;
|
||||||
|
depositedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
customer: { id: string; name: string; folderNumber: number | null };
|
||||||
|
preparedBy: { name: string };
|
||||||
|
/** The C<folio> credit. Voiding it voids the receipt. */
|
||||||
|
transaction: { id: string; voidedAt: string | null; amount: string; currency: Currency };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Office details the printed receipt is headed with. */
|
||||||
|
export interface CompanyHeader {
|
||||||
|
name: string;
|
||||||
|
addressLine1: string;
|
||||||
|
addressLine2: string;
|
||||||
|
cityState: string;
|
||||||
|
mailingAddress: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
taxId: string;
|
||||||
|
website: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CashReceiptInput {
|
||||||
|
customerId: string;
|
||||||
|
folio?: number;
|
||||||
|
receivedAt: string;
|
||||||
|
amount: number;
|
||||||
|
currency: Currency;
|
||||||
|
exchangeRate?: number;
|
||||||
|
concept: string;
|
||||||
|
depositedAt?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- EFE cash receipt book (2026-10-08 meeting). Each receipt posts its own ledger
|
||||||
|
-- credit, reference C<folio>, in the same database transaction.
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `cash_receipts` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`folio` INTEGER NOT NULL,
|
||||||
|
`customerId` VARCHAR(191) NOT NULL,
|
||||||
|
`receivedAt` DATETIME(3) NOT NULL,
|
||||||
|
`amount` DECIMAL(12, 2) NOT NULL,
|
||||||
|
`currency` ENUM('USD', 'MXN') NOT NULL,
|
||||||
|
`exchangeRate` DECIMAL(10, 4) NULL,
|
||||||
|
`concept` TEXT NOT NULL,
|
||||||
|
`preparedById` VARCHAR(191) NOT NULL,
|
||||||
|
`depositedAt` DATETIME(3) NULL,
|
||||||
|
`transactionId` VARCHAR(191) NOT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
UNIQUE INDEX `cash_receipts_folio_key`(`folio`),
|
||||||
|
UNIQUE INDEX `cash_receipts_transactionId_key`(`transactionId`),
|
||||||
|
INDEX `cash_receipts_customerId_idx`(`customerId`),
|
||||||
|
INDEX `cash_receipts_receivedAt_idx`(`receivedAt`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `cash_receipts` ADD CONSTRAINT `cash_receipts_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `cash_receipts` ADD CONSTRAINT `cash_receipts_preparedById_fkey` FOREIGN KEY (`preparedById`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `cash_receipts` ADD CONSTRAINT `cash_receipts_transactionId_fkey` FOREIGN KEY (`transactionId`) REFERENCES `transactions`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -131,6 +131,7 @@ model Customer {
|
|||||||
|
|
||||||
statementDocuments StatementDocument[]
|
statementDocuments StatementDocument[]
|
||||||
statementSplits StatementDocumentSplit[]
|
statementSplits StatementDocumentSplit[]
|
||||||
|
cashReceipts CashReceipt[]
|
||||||
policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer")
|
policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer")
|
||||||
|
|
||||||
emailNotificationLogs EmailNotificationLog[]
|
emailNotificationLogs EmailNotificationLog[]
|
||||||
@@ -884,6 +885,7 @@ model Transaction {
|
|||||||
/// Set only on OCR-posted rows — the statement page this came from.
|
/// Set only on OCR-posted rows — the statement page this came from.
|
||||||
statementDocument StatementDocument?
|
statementDocument StatementDocument?
|
||||||
statementSplit StatementDocumentSplit? @relation("StatementDocumentSplitTransaction")
|
statementSplit StatementDocumentSplit? @relation("StatementDocumentSplitTransaction")
|
||||||
|
cashReceipt CashReceipt?
|
||||||
policyOcrDocument PolicyOcrDocument? @relation("PolicyOcrDocumentTransaction")
|
policyOcrDocument PolicyOcrDocument? @relation("PolicyOcrDocumentTransaction")
|
||||||
|
|
||||||
@@unique([legacySourceDb, legacySourceTable, legacyId])
|
@@unique([legacySourceDb, legacySourceTable, legacyId])
|
||||||
@@ -1005,6 +1007,7 @@ model User {
|
|||||||
|
|
||||||
statementBatches StatementBatch[] @relation("StatementBatchUploader")
|
statementBatches StatementBatch[] @relation("StatementBatchUploader")
|
||||||
statementsReviewed StatementDocument[] @relation("StatementDocumentReviewer")
|
statementsReviewed StatementDocument[] @relation("StatementDocumentReviewer")
|
||||||
|
cashReceipts CashReceipt[] @relation("CashReceiptPreparer")
|
||||||
|
|
||||||
policyOcrBatches PolicyOcrBatch[] @relation("PolicyOcrBatchUploader")
|
policyOcrBatches PolicyOcrBatch[] @relation("PolicyOcrBatchUploader")
|
||||||
policyOcrReviewed PolicyOcrDocument[] @relation("PolicyOcrDocumentReviewer")
|
policyOcrReviewed PolicyOcrDocument[] @relation("PolicyOcrDocumentReviewer")
|
||||||
@@ -1287,3 +1290,35 @@ model AppSetting {
|
|||||||
|
|
||||||
@@map("app_settings")
|
@@map("app_settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The EFE cash receipt book (legacy EFE form / EFECTIVO table): the paper the
|
||||||
|
/// office hands a customer who pays in cash. A journal, not a ledger — the
|
||||||
|
/// money reaches the customer's balance through `transaction`, posted with it
|
||||||
|
/// as `C<folio>` exactly as legacy posted to DATOS2. Voiding that row voids
|
||||||
|
/// the receipt.
|
||||||
|
model CashReceipt {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
/// Printed receipt number. Continues the legacy book's sequence.
|
||||||
|
folio Int @unique
|
||||||
|
customerId String
|
||||||
|
customer Customer @relation(fields: [customerId], references: [id])
|
||||||
|
receivedAt DateTime
|
||||||
|
amount Decimal @db.Decimal(12, 2)
|
||||||
|
currency Currency
|
||||||
|
/// Set when dollars are credited to a pesos ledger, as legacy did; the
|
||||||
|
/// posted row then carries amount × rate in MXN.
|
||||||
|
exchangeRate Decimal? @db.Decimal(10, 4)
|
||||||
|
concept String @db.Text
|
||||||
|
/// ELABORÓ — whoever was logged in.
|
||||||
|
preparedById String
|
||||||
|
preparedBy User @relation("CashReceiptPreparer", fields: [preparedById], references: [id])
|
||||||
|
/// FECHA DEPÓSITO — filled in when the cash reaches the bank.
|
||||||
|
depositedAt DateTime?
|
||||||
|
transactionId String @unique
|
||||||
|
transaction Transaction @relation(fields: [transactionId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([customerId])
|
||||||
|
@@index([receivedAt])
|
||||||
|
@@map("cash_receipts")
|
||||||
|
}
|
||||||
Reference in new issue
Block a user