Compare commits

..
7 Commits
Author SHA1 Message Date
gitea-actions af5261c644 chore(release): v1.1.0
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m0s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m30s
Deploy on tag / Deploy to galactus (push) Successful in 41s
Cut by rmancinas via the "Cut release" workflow. Pushing the tag triggers build.yml; deploy separately with tag=1.1.0.
2026-10-13 02:32:51 +00:00
rmancinasandClaude Opus 5.5 c239c1a866 fix(web): local default dates, styled selects and currency on the new capture screens
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m6s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m16s
Found driving /clientes/alta and /efectivo in the browser: the default date
was UTC's (tomorrow, every evening), selects lacked the input style, the
service rows stretched one input per line, and the printed receipt showed a
bare $ with no currency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:30:37 -07:00
rmancinasandClaude Opus 5.5 6468811955 feat(clientes): alta de cliente in one screen, opening balance as a payment
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m57s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m51s
The 2026-10-08 ask: open an account without four screens — cliente,
propiedad, servicios and the saldo inicial, taken as the first payment.

POST /customers/onboard writes it all in one Prisma transaction; a bad
service or a taken folder number leaves nothing behind (rolled back on dev).
A cash opening balance goes through the EFE receipt book inside that same
transaction, so it gets its folio and its C<folio> credit; a check posts a
CHECK DEPOSIT against the number given. /clientes/alta pre-fills the next
free folder number and links the receipt for printing. The old
customer-only form stays as "Solo cliente".

Built without the screen sketch from the meeting (it never arrived); the
fields are the legacy ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:27:00 -07:00
rmancinasandClaude Opus 5.5 ca76f4d57a feat(efectivo): the EFE cash receipt book, folio and posting in one step
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m47s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m17s
The office still issues cash receipts from Access (EFE MENU -> EFE). This is
that form: cliente, fecha, monto + moneda, concepto, elaboró (the logged-in
user), fecha de depósito, and a folio that continues the legacy book.

- cash_receipts, folio unique. Next folio = one past the highest of the
  platform's receipts and the imported utilities EFECTIVO journal, so after
  the cutover import it lands on the office's next number (13935 on prod
  today). A typed folio the legacy book already used is refused.
- Issuing a receipt posts its credit in the same database transaction, as
  legacy posted to DATOS2: reference C<folio>, CASH DEPOSIT, pseudo check
  CASH<MMDD><P|D>. Dollars with a rate credit a pesos ledger (1,500 USD at
  16.80 -> 25,200 MXN, as C13830 did). Voiding that row voids the receipt.
- /efectivo: issue, search, record the deposit date; /efectivo/<folio>
  prints the receipt.
- Reporte de efectivo now lists platform receipts by receipt, not by check,
  so each is counted once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:24:43 -07:00
rmancinasandClaude Opus 5.5 fd5df240bc feat(recibos): split a receipt, sin fondos per receipt, keep a batch open
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m57s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m22s
Three asks from the 2026-10-08 meeting, all on the OCR receipt queue.

- Split: one receipt paid by several customers (411 + 523 sharing water
  3014057) posts one charge per part. statement_document_splits holds free
  amounts that must sum to the receipt in cents, checked on save and again
  at confirm; "partes iguales" fills them in. Each part's row carries
  captureRef <docId>#<n>, so the double-post guard still holds.
- Sin fondos per receipt: StatementDocument.outstanding, toggled in review.
  At confirm a line is outstanding if its receipt or the whole check is.
- Open batch: POST /statements/batches/:id/files appends scans, numbered
  after the existing pages; a COMPLETED batch reopens. The confirm card
  prefills the check the batch was last posted against.

Verified on dev against a real batch: 1,892.00 split 946/946 across two
customers, a sibling receipt posted outstanding, second confirm refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:20:20 -07:00
rmancinasandClaude Opus 5.5 f655fcbb82 docs(backlog): folder number built; email portal login and dropping NUMid login pending
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:08:02 -07:00
rmancinasandClaude Opus 5.5 b016e53893 feat(customers): a shelf folder number the office can hand back out
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m42s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m17s
The office files every customer in a numbered paper folder and reuses the
number once an account closes. That number was the legacy NUMid, which is
also an import key and the portal login, so reusing it meant re-pointing an
identity. It is now customers.folderNumber: a plain label nothing joins on.

- Unique only among non-archived customers, checked in CustomersService
  (create, update, restore). Archived customers keep theirs as history.
- GET /customers/folder/next suggests the lowest free number; the new
  customer form pre-fills it.
- Backfilled from the utilities NUMid (dev: 1,171 folders, 0 shared); the
  importer copies it on load. Insurance-only customers start without one.
- Reports, the printed statement and the customer file show "Carpeta"
  instead of NUMid. The list searches by it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:04:01 -07:00
41 changed files with 2545 additions and 113 deletions

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@jorgecuadros/api",
"version": "1.0.26",
"version": "1.1.0",
"private": true,
"scripts": {
"build": "nest build",
+2
View File
@@ -12,6 +12,7 @@ import { PoliciesModule } from "./policies/policies.module";
import { PropertiesModule } from "./properties/properties.module";
import { BillingModule } from "./billing/billing.module";
import { StatementsModule } from "./statements/statements.module";
import { CashReceiptsModule } from "./cash-receipts/cash-receipts.module";
import { PolicyOcrModule } from "./policy-ocr/policy-ocr.module";
import { BankModule } from "./bank/bank.module";
import { OpsModule } from "./ops/ops.module";
@@ -35,6 +36,7 @@ import { AppController } from "./app.controller";
PropertiesModule,
BillingModule,
StatementsModule,
CashReceiptsModule,
PolicyOcrModule,
BankModule,
OpsModule,
@@ -0,0 +1,29 @@
import {
IsEnum,
IsInt,
IsNumber,
IsOptional,
IsString,
Min,
MinLength,
} from "class-validator";
import { Currency } from "@jorgecuadros/database";
/** The legacy EFE form: cliente, fecha, monto + moneda, concepto, depósito. */
export class CreateCashReceiptDto {
@IsString() @MinLength(1) customerId!: string;
/** Defaults to the next folio in the book. */
@IsOptional() @IsInt() @Min(1) folio?: number;
@IsString() @MinLength(1) receivedAt!: string;
@IsNumber() amount!: number;
@IsEnum(Currency) currency!: Currency;
/** Dollars credited to a pesos ledger: the rate they were taken at. */
@IsOptional() @IsNumber() exchangeRate?: number;
@IsString() @MinLength(1) concept!: string;
@IsOptional() @IsString() depositedAt?: string;
}
export class SetDepositDto {
/** Empty clears it. */
@IsOptional() @IsString() depositedAt?: string;
}
@@ -0,0 +1,80 @@
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
Req,
UseGuards,
} from "@nestjs/common";
import type { Request } from "express";
import { AuthenticatedGuard } from "../auth/authenticated.guard";
import { AbilityGuard } from "../auth/ability.guard";
import { RequireAbility } from "../auth/require-ability.decorator";
import { AuditService } from "../common/audit.service";
import { CashReceiptsService } from "./cash-receipts.service";
import { CreateCashReceiptDto, SetDepositDto } from "./cash-receipt.dto";
/** The EFE cash receipt book. Literal routes sit above `:folio`. */
@Controller("cash-receipts")
@UseGuards(AuthenticatedGuard, AbilityGuard)
export class CashReceiptsController {
constructor(
private readonly receipts: CashReceiptsService,
private readonly audit: AuditService,
) {}
private actingId(req: Request): string {
return (req.user as { id: string }).id;
}
@Get("next-folio")
async nextFolio() {
return { folio: await this.receipts.nextFolio() };
}
@Get()
list(
@Query("query") query?: string,
@Query("page") page?: string,
@Query("pageSize") pageSize?: string,
) {
return this.receipts.list(
query,
Math.max(1, Number(page) || 1),
Math.min(100, Math.max(1, Number(pageSize) || 25)),
);
}
@Get(":folio")
byFolio(@Param("folio", ParseIntPipe) folio: number) {
return this.receipts.byFolio(folio);
}
@Post()
@RequireAbility("ledger:create")
async create(@Body() dto: CreateCashReceiptDto, @Req() req: Request) {
const r = await this.receipts.create(dto, this.actingId(req));
void this.audit.log(this.actingId(req), "cash-receipt.create", {
folio: r.folio,
customerId: r.customerId,
amount: r.amount.toFixed(2),
currency: r.currency,
});
return r;
}
@Patch(":id/deposit")
@RequireAbility("ledger:create")
async setDeposit(@Param("id") id: string, @Body() dto: SetDepositDto, @Req() req: Request) {
const r = await this.receipts.setDeposited(id, dto.depositedAt);
void this.audit.log(this.actingId(req), "cash-receipt.deposit", {
folio: r.folio,
depositedAt: r.depositedAt,
});
return r;
}
}
@@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { CashReceiptsController } from "./cash-receipts.controller";
import { CashReceiptsService } from "./cash-receipts.service";
@Module({
controllers: [CashReceiptsController],
providers: [CashReceiptsService],
// Customer onboarding issues the opening cash receipt in its own transaction.
exports: [CashReceiptsService],
})
export class CashReceiptsModule {}
@@ -0,0 +1,214 @@
import { randomUUID } from "node:crypto";
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { Prisma } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
import { CASH_JOURNAL_SOURCE_DB } from "../billing/billing.service";
import { getCompanyInfo } from "../reports/company";
import type { CreateCashReceiptDto } from "./cash-receipt.dto";
/** The ledger concept legacy posted every cash receipt under. */
const CASH_CONCEPT = "CASH DEPOSIT";
const RECEIPT_INCLUDE = {
customer: { select: { id: true, name: true, folderNumber: true } },
preparedBy: { select: { name: true } },
transaction: { select: { id: true, voidedAt: true, amount: true, currency: true } },
} satisfies Prisma.CashReceiptInclude;
/**
* Legacy grouped a day's cash under one pseudo check: CASH + MMDD + P(esos) or
* D(ólares) — `CASH0714P`. Kept so by-check views put these rows where the
* office expects them.
*/
export function cashCheckNumber(receivedAt: Date, currency: "MXN" | "USD"): string {
const mm = String(receivedAt.getUTCMonth() + 1).padStart(2, "0");
const dd = String(receivedAt.getUTCDate()).padStart(2, "0");
return `CASH${mm}${dd}${currency === "USD" ? "D" : "P"}`;
}
function parseDay(v: string | undefined, field: string): Date | undefined {
if (!v) return undefined;
const d = new Date(v.length === 10 ? `${v}T00:00:00.000Z` : v);
if (isNaN(d.getTime())) throw new BadRequestException(`${field} inválida.`);
return d;
}
@Injectable()
export class CashReceiptsService {
constructor(private readonly prisma: PrismaService) {}
/**
* One past the highest folio in the book — the platform's own receipts or,
* before the first one, the legacy EFECTIVO journal imported at cutover.
* Only the utilities book: insurance kept its own EFECTIVO with its own
* numbers.
*/
async nextFolio(db: Prisma.TransactionClient = this.prisma): Promise<number> {
const native = await db.cashReceipt.aggregate({ _max: { folio: true } });
const [legacy] = await db.$queryRaw<{ mx: bigint | number | null }[]>`
SELECT MAX(CAST(reference AS UNSIGNED)) AS mx FROM transactions
WHERE legacySourceDb = ${CASH_JOURNAL_SOURCE_DB}
AND legacySourceTable = 'EFECTIVO'
AND reference REGEXP '^[0-9]+$'`;
return Math.max(native._max.folio ?? 0, Number(legacy?.mx ?? 0)) + 1;
}
/**
* Issue a receipt and post its credit, in one database transaction: a
* receipt without its posting is money the customer paid and never sees,
* and a posting without its receipt is the folio-13536 kind of hole.
*
* Pass `db` to run inside a caller's transaction (customer onboarding).
*/
async create(
dto: CreateCashReceiptDto,
preparedById: string,
db?: Prisma.TransactionClient,
) {
if (!(dto.amount > 0)) throw new BadRequestException("El monto debe ser mayor a cero.");
if (dto.exchangeRate != null) {
if (dto.currency !== "USD") {
throw new BadRequestException("El tipo de cambio sólo aplica a dólares.");
}
if (!(dto.exchangeRate > 0)) throw new BadRequestException("Tipo de cambio inválido.");
}
const receivedAt = parseDay(dto.receivedAt, "Fecha")!;
const depositedAt = parseDay(dto.depositedAt, "Fecha de depósito");
const run = (tx: Prisma.TransactionClient) => this.issue(tx, dto, receivedAt, depositedAt, preparedById);
if (db) return run(db);
try {
return await this.prisma.$transaction(run);
} catch (err) {
// Two receipts issued in the same instant both computed the same next
// folio; the loser simply takes the one after.
if (dto.folio == null && isUniqueViolation(err)) return this.prisma.$transaction(run);
throw err;
}
}
private async issue(
tx: Prisma.TransactionClient,
dto: CreateCashReceiptDto,
receivedAt: Date,
depositedAt: Date | undefined,
preparedById: string,
) {
const customer = await tx.customer.findUnique({
where: { id: dto.customerId },
select: { id: true },
});
if (!customer) throw new NotFoundException("Cliente no encontrado.");
const folio = dto.folio ?? (await this.nextFolio(tx));
// A typed folio may belong to the legacy book, which has no unique key here.
const taken =
(await tx.cashReceipt.count({ where: { folio } })) +
(await tx.transaction.count({
where: {
legacySourceDb: CASH_JOURNAL_SOURCE_DB,
legacySourceTable: "EFECTIVO",
reference: String(folio),
},
}));
if (taken) throw new ConflictException(`El folio ${folio} ya existe.`);
const type = await tx.typeTransaction.findFirst({
where: { nameEn: CASH_CONCEPT },
select: { id: true },
});
// Dollars taken against a pesos account are credited in pesos at the
// rate given, as legacy did (C13830: 1,500 USD posted as 25,200 MXN).
const converted = dto.exchangeRate != null;
const amount = new Prisma.Decimal(dto.amount.toFixed(2));
const posted = converted
? amount.times(dto.exchangeRate!).toDecimalPlaces(2)
: amount;
const id = randomUUID();
const transaction = await tx.transaction.create({
data: {
customerId: dto.customerId,
domain: "UTILITY",
typeId: type?.id,
transactionDate: receivedAt,
reference: `C${folio}`,
amount: posted,
currency: converted ? "MXN" : dto.currency,
exchangeRate: converted ? new Prisma.Decimal(dto.exchangeRate!) : undefined,
checkNumber: cashCheckNumber(receivedAt, dto.currency),
message: dto.concept,
captureSource: "MANUAL",
captureRef: id,
},
});
return tx.cashReceipt.create({
data: {
id,
folio,
customerId: dto.customerId,
receivedAt,
amount,
currency: dto.currency,
exchangeRate: converted ? new Prisma.Decimal(dto.exchangeRate!) : undefined,
concept: dto.concept,
preparedById,
depositedAt,
transactionId: transaction.id,
},
include: RECEIPT_INCLUDE,
});
}
async list(query: string | undefined, page: number, pageSize: number) {
const q = query?.trim();
const where: Prisma.CashReceiptWhereInput = q
? /^\d+$/.test(q)
? { OR: [{ folio: Number(q) }, { customer: { folderNumber: Number(q) } }] }
: { customer: { name: { contains: q } } }
: {};
const [total, items] = await this.prisma.$transaction([
this.prisma.cashReceipt.count({ where }),
this.prisma.cashReceipt.findMany({
where,
orderBy: { folio: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
include: RECEIPT_INCLUDE,
}),
]);
return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) };
}
/** One receipt, with what the printout needs to head it. */
async byFolio(folio: number) {
const receipt = await this.prisma.cashReceipt.findUnique({
where: { folio },
include: RECEIPT_INCLUDE,
});
if (!receipt) throw new NotFoundException(`No existe el folio ${folio}.`);
const { logo: _logo, logoPath: _path, ...company } = getCompanyInfo();
return { ...receipt, company };
}
async setDeposited(id: string, depositedAt: string | undefined) {
const found = await this.prisma.cashReceipt.findUnique({ where: { id }, select: { id: true } });
if (!found) throw new NotFoundException("Recibo no encontrado.");
return this.prisma.cashReceipt.update({
where: { id },
data: { depositedAt: parseDay(depositedAt, "Fecha de depósito") ?? null },
include: RECEIPT_INCLUDE,
});
}
}
function isUniqueViolation(err: unknown): boolean {
return err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002";
}
@@ -0,0 +1,78 @@
import { ConflictException } from "@nestjs/common";
import { CashReceiptsService, cashCheckNumber } from "./cash-receipts.service";
/**
* A cash receipt is two writes that must agree: the receipt and its C<folio>
* credit. Checked here: the credit legacy would have posted, and that a folio
* the legacy book already used is refused. Folio numbering itself runs against
* real data (it reads the imported EFECTIVO journal).
*/
function build(opts: { legacyTaken?: boolean } = {}) {
const posted: Record<string, unknown>[] = [];
const tx = {
customer: { findUnique: jest.fn().mockResolvedValue({ id: "c1" }) },
cashReceipt: {
aggregate: jest.fn().mockResolvedValue({ _max: { folio: 13934 } }),
count: jest.fn().mockResolvedValue(0),
create: jest.fn(({ data }) => Promise.resolve(data)),
},
transaction: {
count: jest.fn().mockResolvedValue(opts.legacyTaken ? 1 : 0),
create: jest.fn(({ data }) => {
posted.push(data);
return Promise.resolve({ id: "tx1", ...data });
}),
},
typeTransaction: { findFirst: jest.fn().mockResolvedValue({ id: "cash-deposit" }) },
$queryRaw: jest.fn().mockResolvedValue([{ mx: 13900 }]),
};
const prisma = { $transaction: jest.fn((fn) => fn(tx)) };
return { service: new CashReceiptsService(prisma as never), posted };
}
const base = {
customerId: "c1",
receivedAt: "2026-07-14",
concept: "RECEIVED $1500 DLLS FOR ACCOUNT.",
};
describe("cash receipts", () => {
it("takes the next folio and posts a C<folio> credit", async () => {
const { service, posted } = build();
const r = await service.create({ ...base, amount: 9000, currency: "MXN" }, "u1");
expect(r.folio).toBe(13935);
expect(posted[0]).toMatchObject({
reference: "C13935",
checkNumber: "CASH0714P",
currency: "MXN",
typeId: "cash-deposit",
});
expect(Number(posted[0].amount)).toBe(9000);
});
it("credits dollars to a pesos ledger at the given rate, as legacy did", async () => {
// Legacy C13830: 1,500 USD at 16.80 posted as 25,200 MXN.
const { service, posted } = build();
await service.create(
{ ...base, amount: 1500, currency: "USD", exchangeRate: 16.8 },
"u1",
);
expect(posted[0]).toMatchObject({ currency: "MXN", checkNumber: "CASH0714D" });
expect(Number(posted[0].amount)).toBe(25200);
});
it("refuses a typed folio the legacy book already used", async () => {
const { service, posted } = build({ legacyTaken: true });
await expect(
service.create({ ...base, folio: 13800, amount: 1, currency: "MXN" }, "u1"),
).rejects.toBeInstanceOf(ConflictException);
expect(posted).toHaveLength(0);
});
it("names the pseudo check by day and currency", () => {
expect(cashCheckNumber(new Date("2026-01-05T00:00:00Z"), "USD")).toBe("CASH0105D");
});
});
@@ -2,9 +2,11 @@ import {
IsBoolean,
IsEmail,
IsEnum,
IsInt,
IsNumber,
IsOptional,
IsString,
Min,
MinLength,
} from "class-validator";
import { Currency } from "@jorgecuadros/database";
@@ -19,6 +21,8 @@ export class CreateCustomerDto {
@MinLength(1)
name!: string;
/** Shelf folder number; null clears it. Unique among non-archived customers. */
@IsOptional() @IsInt() @Min(1) folderNumber?: number | null;
@IsOptional() @IsString() addressLine1?: string;
@IsOptional() @IsString() addressLine2?: string;
@IsOptional() @IsString() city?: string;
@@ -0,0 +1,71 @@
import { ConflictException } from "@nestjs/common";
import { CustomersService } from "./customers.service";
/**
* A folder number belongs to one open account at a time, and an archived
* customer's number is history, not a claim. The nextFolderNumber SQL is
* checked against real data, not here.
*/
function build(opts: { holder?: { name: string } | null; archived?: boolean; folder?: number | null }) {
const prisma = {
customer: {
findFirst: jest.fn().mockResolvedValue(opts.holder ?? null),
findUnique: jest.fn().mockResolvedValue({
id: "c1",
archivedAt: opts.archived ? new Date() : null,
folderNumber: opts.folder ?? null,
}),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve(data)),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve(data)),
},
};
return { service: new CustomersService(prisma as never, {} as never), prisma };
}
describe("customer folder numbers", () => {
it("refuses a number an open account already holds", async () => {
const { service, prisma } = build({ holder: { name: "SWEET, DONALD E." } });
await expect(service.create({ name: "Nuevo", folderNumber: 214 })).rejects.toThrow(
new ConflictException("La carpeta 214 ya la tiene SWEET, DONALD E."),
);
expect(prisma.customer.create).not.toHaveBeenCalled();
// Only open accounts count as holders.
expect(prisma.customer.findFirst.mock.calls[0][0].where).toMatchObject({
folderNumber: 214,
archivedAt: null,
});
});
it("does not count the customer being edited as the holder", async () => {
const { service, prisma } = build({});
await service.update("c1", { folderNumber: 214 });
expect(prisma.customer.findFirst.mock.calls[0][0].where).toMatchObject({
id: { not: "c1" },
});
});
it("lets an archived customer keep a number someone else now holds", async () => {
const { service, prisma } = build({ archived: true, holder: { name: "X" } });
await expect(service.update("c1", { folderNumber: 214 })).resolves.toBeDefined();
expect(prisma.customer.findFirst).not.toHaveBeenCalled();
});
it("will not reopen an account whose folder was handed out meanwhile", async () => {
const { service, prisma } = build({ archived: true, folder: 214, holder: { name: "X" } });
await expect(service.restore("c1")).rejects.toBeInstanceOf(ConflictException);
expect(prisma.customer.update).not.toHaveBeenCalled();
});
it("skips the check when no number is given", async () => {
const { service, prisma } = build({ holder: { name: "X" } });
await service.create({ name: "Nuevo" });
await service.update("c1", { folderNumber: null });
expect(prisma.customer.findFirst).not.toHaveBeenCalled();
});
});
@@ -26,7 +26,7 @@ describe("customer file ledger card", () => {
},
};
return {
service: new CustomersService(prisma as never),
service: new CustomersService(prisma as never, {} as never),
prisma,
groupBy,
};
@@ -0,0 +1,68 @@
import { BadRequestException } from "@nestjs/common";
import { CustomersService } from "./customers.service";
/**
* Onboarding writes four tables in one transaction; what is checked here is
* that a request it can't complete is refused before anything is written, and
* that a cash opening balance goes through the receipt book inside that same
* transaction. The rollback itself was exercised against the dev database.
*/
function build() {
const tx = {
customer: { create: jest.fn().mockResolvedValue({ id: "c1" }) },
property: { create: jest.fn().mockResolvedValue({ id: "p1" }) },
propertyService: { createMany: jest.fn() },
typeTransaction: { findFirst: jest.fn() },
transaction: { create: jest.fn() },
};
const prisma = {
customer: { findFirst: jest.fn().mockResolvedValue(null) },
$transaction: jest.fn((fn) => fn(tx)),
};
const cash = { create: jest.fn().mockResolvedValue({ folio: 13935 }) };
return { service: new CustomersService(prisma as never, cash as never), prisma, tx, cash };
}
const pay = { amount: 500, currency: "MXN" as const, receivedAt: "2026-10-12" };
describe("customer onboarding", () => {
it("refuses a check opening balance with no check number, writing nothing", async () => {
const { service, prisma } = build();
await expect(
service.onboard({ customer: { name: "X" }, openingPayment: { ...pay, method: "CHECK" } }, "u1"),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it("refuses services with no property to hang them on", async () => {
const { service, prisma } = build();
await expect(
service.onboard({ customer: { name: "X" }, services: [{ kind: "WATER" }] }, "u1"),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it("issues the cash receipt inside the onboarding transaction", async () => {
const { service, tx, cash } = build();
const r = await service.onboard(
{
customer: { name: "X" },
property: { addressLine1: "Calle 1" },
services: [{ kind: "WATER", accountNumber: "3014057" }],
openingPayment: { ...pay, method: "CASH" },
},
"u1",
);
expect(cash.create).toHaveBeenCalledWith(
expect.objectContaining({ customerId: "c1", amount: 500, concept: "SALDO INICIAL" }),
"u1",
tx,
);
expect(tx.propertyService.createMany).toHaveBeenCalledWith({
data: [{ kind: "WATER", accountNumber: "3014057", propertyId: "p1" }],
});
expect(r.receipt).toEqual({ folio: 13935 });
});
});
@@ -19,6 +19,7 @@ import { CustomersService } from "./customers.service";
import { NumidService } from "./numid.service";
import { CreateCustomerDto } from "./create-customer.dto";
import { UpdateCustomerDto } from "./update-customer.dto";
import { OnboardCustomerDto } from "./onboard-customer.dto";
@UseGuards(AuthenticatedGuard, AbilityGuard)
@Controller("customers")
@@ -45,6 +46,12 @@ export class CustomersController {
return { candidates: await this.numids.emptyCandidates() };
}
/** Suggested folder number for a new customer. Above `:id` like the rest. */
@Get("folder/next")
async nextFolder() {
return { folderNumber: await this.customers.nextFolderNumber() };
}
@Get()
list(
@Query("query") query?: string,
@@ -77,6 +84,26 @@ export class CustomersController {
return c;
}
/**
* Alta en una pantalla. Gated on customer:create; the property, service and
* ledger abilities it also exercises sit at the same STAFF tier.
*/
@Post("onboard")
@RequireAbility("customer:create")
async onboard(@Body() dto: OnboardCustomerDto, @Req() req: Request) {
const r = await this.customers.onboard(dto, this.actingId(req));
void this.audit.log(this.actingId(req), "customer.onboard", {
customerId: r.customer.id,
name: r.customer.name,
folderNumber: r.customer.folderNumber,
propertyId: r.property?.id,
services: dto.services?.length ?? 0,
folio: r.receipt?.folio,
openingTransactionId: r.receipt?.transactionId ?? r.payment?.id,
});
return r;
}
@Patch(":id")
@RequireAbility("customer:update")
async update(
+2 -1
View File
@@ -1,11 +1,12 @@
import { Module } from "@nestjs/common";
import { SettingsModule } from "../settings/settings.module";
import { CashReceiptsModule } from "../cash-receipts/cash-receipts.module";
import { CustomersController } from "./customers.controller";
import { CustomersService } from "./customers.service";
import { NumidService } from "./numid.service";
@Module({
imports: [SettingsModule],
imports: [SettingsModule, CashReceiptsModule],
controllers: [CustomersController],
providers: [CustomersService, NumidService],
exports: [NumidService],
+135 -5
View File
@@ -1,8 +1,15 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { Prisma } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
import { CreateCustomerDto } from "./create-customer.dto";
import { UpdateCustomerDto } from "./update-customer.dto";
import type { OnboardCustomerDto } from "./onboard-customer.dto";
import { CashReceiptsService } from "../cash-receipts/cash-receipts.service";
import {
BALANCE_FORWARD_TYPE,
notCashJournal,
@@ -51,7 +58,10 @@ function toDate(v?: string): Date | null | undefined {
@Injectable()
export class CustomersService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly cashReceipts: CashReceiptsService,
) {}
/** Unified customer list with search + business-line filter, paginated. */
async list({ query, page, pageSize, line, includeArchived }: ListParams) {
@@ -68,6 +78,7 @@ export class CustomersService {
{ mobile: { contains: q } },
{ city: { contains: q } },
{ legacyRefs: { some: { legacyId: { contains: q } } } },
...(/^\d+$/.test(q) ? [{ folderNumber: Number(q) }] : []),
];
}
if (line === "utility") where.properties = { some: {} };
@@ -90,6 +101,7 @@ export class CustomersService {
id: true,
name: true,
nameSource: true,
folderNumber: true,
city: true,
state: true,
email: true,
@@ -106,6 +118,7 @@ export class CustomersService {
id: r.id,
name: r.name,
nameSource: r.nameSource,
folderNumber: r.folderNumber,
city: r.city,
state: r.state,
email: r.email,
@@ -248,6 +261,7 @@ export class CustomersService {
}
async create(dto: CreateCustomerDto) {
await this.assertFolderFree(dto.folderNumber);
return this.prisma.customer.create({
// App-created rows: nameMissing false (name is required), no legacy
// provenance — those columns stay null, marking a native record.
@@ -255,8 +269,85 @@ export class CustomersService {
});
}
/**
* Open an account from one screen: customer, property, services and the
* opening balance taken as the first payment. One database transaction — a
* half-created account (a customer with no property, a payment with no
* customer) is worse than none, because the office would key it again.
*/
async onboard(dto: OnboardCustomerDto, userId: string) {
const pay = dto.openingPayment;
if (pay) {
if (!(pay.amount > 0)) throw new BadRequestException("El saldo inicial debe ser mayor a cero.");
if (pay.method === "CHECK" && !pay.checkNumber?.trim()) {
throw new BadRequestException("Falta el número de cheque del saldo inicial.");
}
}
if (dto.services?.length && !dto.property) {
throw new BadRequestException("Los servicios necesitan una propiedad.");
}
await this.assertFolderFree(dto.customer.folderNumber);
return this.prisma.$transaction(async (tx) => {
const customer = await tx.customer.create({
data: { ...this.toData(dto.customer), name: dto.customer.name, nameMissing: false },
});
const property = dto.property
? await tx.property.create({ data: { ...dto.property, customerId: customer.id } })
: null;
if (property && dto.services?.length) {
await tx.propertyService.createMany({
data: dto.services.map((s) => ({ ...s, propertyId: property.id })),
});
}
const concept = pay?.concept?.trim() || "SALDO INICIAL";
let receipt = null;
let payment = null;
if (pay?.method === "CASH") {
receipt = await this.cashReceipts.create(
{
customerId: customer.id,
receivedAt: pay.receivedAt,
amount: pay.amount,
currency: pay.currency,
exchangeRate: pay.exchangeRate,
concept,
},
userId,
tx,
);
} else if (pay?.method === "CHECK") {
const date = new Date(pay.receivedAt);
if (isNaN(date.getTime())) throw new BadRequestException("Fecha del saldo inicial inválida.");
const type = await tx.typeTransaction.findFirst({
where: { nameEn: "CHECK DEPOSIT" },
select: { id: true },
});
payment = await tx.transaction.create({
data: {
customerId: customer.id,
domain: "UTILITY",
typeId: type?.id,
transactionDate: date,
amount: new Prisma.Decimal(pay.amount.toFixed(2)),
currency: pay.currency,
checkNumber: pay.checkNumber!.trim(),
message: concept,
captureSource: "MANUAL",
},
});
}
return { customer, property, receipt, payment };
});
}
async update(id: string, dto: UpdateCustomerDto) {
await this.ensureExists(id);
const current = await this.ensureExists(id);
// An archived customer's number is history, not a claim on the shelf.
if (!current.archivedAt) await this.assertFolderFree(dto.folderNumber, id);
return this.prisma.customer.update({ where: { id }, data: this.toData(dto) });
}
@@ -270,7 +361,9 @@ export class CustomersService {
}
async restore(id: string) {
await this.ensureExists(id);
const current = await this.ensureExists(id);
// Its folder may have been handed to someone else while it was closed.
await this.assertFolderFree(current.folderNumber, id);
return this.prisma.customer.update({
where: { id },
data: { archivedAt: null },
@@ -280,9 +373,46 @@ export class CustomersService {
private async ensureExists(id: string) {
const found = await this.prisma.customer.findUnique({
where: { id },
select: { id: true },
select: { id: true, archivedAt: true, folderNumber: true },
});
if (!found) throw new NotFoundException(`Customer ${id} not found`);
return found;
}
// --- folder numbers ---------------------------------------------------------
/**
* A folder number belongs to one open account at a time. Archived customers
* keep theirs as a record of which folder they used, without holding it.
*
* ponytail: check-then-write, not a constraint — two staff saving the same
* number in the same instant both win. A functional unique index on
* IF(archivedAt IS NULL, folderNumber, NULL) closes it, if Prisma ever models one.
*/
private async assertFolderFree(folderNumber: number | null | undefined, exceptId?: string) {
if (folderNumber == null) return;
const holder = await this.prisma.customer.findFirst({
where: { folderNumber, archivedAt: null, ...(exceptId ? { id: { not: exceptId } } : {}) },
select: { name: true },
});
if (holder) {
throw new ConflictException(`La carpeta ${folderNumber} ya la tiene ${holder.name}`);
}
}
/** Lowest folder number no open account holds — how the office reuses them. */
async nextFolderNumber(): Promise<number> {
const [row] = await this.prisma.$queryRaw<{ n: bigint | number }[]>`
SELECT MIN(c.n) AS n FROM (
SELECT 1 AS n
UNION SELECT folderNumber + 1 FROM customers
WHERE archivedAt IS NULL AND folderNumber IS NOT NULL
) c
WHERE c.n NOT IN (
SELECT folderNumber FROM customers
WHERE archivedAt IS NULL AND folderNumber IS NOT NULL
)`;
return Number(row.n);
}
/** Top-line counts for a dashboard header. */
@@ -0,0 +1,56 @@
import {
ArrayMaxSize,
IsArray,
IsEnum,
IsIn,
IsNumber,
IsOptional,
IsString,
MinLength,
ValidateNested,
} from "class-validator";
import { Type } from "class-transformer";
import { Currency } from "@jorgecuadros/database";
import { CreateCustomerDto } from "./create-customer.dto";
import { ServiceDto } from "../properties/property.dto";
/** The property, minus the customer it is being created for. */
export class OnboardPropertyDto {
@IsOptional() @IsString() addressLine1?: string;
@IsOptional() @IsString() addressLine2?: string;
@IsOptional() @IsString() phone1?: string;
}
/**
* The opening balance the office takes when an account is opened, captured as
* the customer's first payment. Cash issues an EFE receipt; a check posts a
* CHECK DEPOSIT against that check number.
*/
export class OpeningPaymentDto {
@IsIn(["CASH", "CHECK"]) method!: "CASH" | "CHECK";
@IsNumber() amount!: number;
@IsEnum(Currency) currency!: Currency;
@IsString() @MinLength(1) receivedAt!: string;
@IsOptional() @IsString() checkNumber?: string;
/** Cash only: dollars credited to a pesos ledger. */
@IsOptional() @IsNumber() exchangeRate?: number;
@IsOptional() @IsString() concept?: string;
}
/** One screen: cliente + propiedad + servicios + saldo inicial. */
export class OnboardCustomerDto {
@ValidateNested() @Type(() => CreateCustomerDto) customer!: CreateCustomerDto;
@IsOptional() @ValidateNested() @Type(() => OnboardPropertyDto)
property?: OnboardPropertyDto;
@IsOptional()
@IsArray()
@ArrayMaxSize(12)
@ValidateNested({ each: true })
@Type(() => ServiceDto)
services?: ServiceDto[];
@IsOptional() @ValidateNested() @Type(() => OpeningPaymentDto)
openingPayment?: OpeningPaymentDto;
}
@@ -2,9 +2,11 @@ import {
IsBoolean,
IsEmail,
IsEnum,
IsInt,
IsNumber,
IsOptional,
IsString,
Min,
MinLength,
} from "class-validator";
import { Currency } from "@jorgecuadros/database";
@@ -12,6 +14,7 @@ import { Currency } from "@jorgecuadros/database";
/** Same editable fields as create, all optional. */
export class UpdateCustomerDto {
@IsOptional() @IsString() @MinLength(1) name?: string;
@IsOptional() @IsInt() @Min(1) folderNumber?: number | null;
@IsOptional() @IsString() addressLine1?: string;
@IsOptional() @IsString() addressLine2?: string;
@IsOptional() @IsString() city?: string;
+50 -45
View File
@@ -23,10 +23,7 @@ import {
notCashJournal,
periodSourceTable,
} from "../billing/billing.service";
import {
UTILITIES_SYSTEM,
UTILITIES_TABLE,
} from "../customers/numid.service";
import { UTILITIES_TABLE } from "../customers/numid.service";
import type { PrismaService } from "../prisma/prisma.service";
import { CONCEPT_BY_KIND } from "../statements/statements.service";
import {
@@ -50,8 +47,8 @@ function nameOf(c: { name: string; nameMissing: boolean }): string {
const isoDay = (d: Date | null | undefined) =>
d ? d.toISOString().slice(0, 10) : "—";
/** Every report leads with the legacy customer number (2026-10 meeting). */
const NUMID_COLUMN: ColumnDef = { key: "numid", label: "NUMid", type: "text" };
/** Every report leads with the customer's shelf folder number (2026-10 meeting). */
const FOLDER_COLUMN: ColumnDef = { key: "folder", label: "Carpeta", type: "text" };
/** Policy reports add the insurance file number beside it. */
const INSURANCE_NO_COLUMN: ColumnDef = {
key: "insuranceNo",
@@ -60,38 +57,42 @@ const INSURANCE_NO_COLUMN: ColumnDef = {
};
/**
* Legacy customer numbers for a page of report rows. The office files,
* captures and talks in NUMids, not platform ids.
* Customer numbers for a page of report rows. The office files, captures and
* talks in folder numbers, not platform ids.
*
* The utilities NUMid is the portal number, one per customer. insurance/DATGRAL
* is a separate id space under the same table name, so it is read on its own
* and never shown as a NUMid; 16 customers hold more than one.
* The insurance file number is the legacy insurance/DATGRAL id, a separate id
* space under the same table name as the utilities NUMid; 16 customers hold
* more than one.
*/
async function legacyNumbers(prisma: PrismaService, customerIds: string[]) {
const ids = [...new Set(customerIds)];
const refs = ids.length
? await prisma.customerLegacyRef.findMany({
const [customers, refs] = ids.length
? await Promise.all([
prisma.customer.findMany({
where: { id: { in: ids } },
select: { id: true, folderNumber: true },
}),
prisma.customerLegacyRef.findMany({
where: {
customerId: { in: ids },
sourceTable: UTILITIES_TABLE,
sourceSystem: { in: [UTILITIES_SYSTEM, "insurance"] },
sourceSystem: "insurance",
},
select: { customerId: true, sourceSystem: true, legacyId: true },
})
: [];
const byCustomer = new Map<string, { numid?: string; insurance: string[] }>();
select: { customerId: true, legacyId: true },
}),
])
: [[], []];
const folders = new Map(customers.map((c) => [c.id, c.folderNumber]));
const insurance = new Map<string, string[]>();
for (const r of refs) {
const e = byCustomer.get(r.customerId) ?? { insurance: [] };
if (r.sourceSystem === UTILITIES_SYSTEM) e.numid = r.legacyId;
else e.insurance.push(r.legacyId);
byCustomer.set(r.customerId, e);
insurance.set(r.customerId, [...(insurance.get(r.customerId) ?? []), r.legacyId]);
}
return {
numid: (id: string) => byCustomer.get(id)?.numid ?? "—",
folder: (id: string) => String(folders.get(id) ?? "—"),
insurance: (id: string) =>
byCustomer
insurance
.get(id)
?.insurance.sort((a, b) => Number(a) - Number(b))
?.sort((a, b) => Number(a) - Number(b))
.join(", ") || "—",
};
}
@@ -182,7 +183,7 @@ const listadoEnRojo: ReportDef = {
},
],
columns: [
NUMID_COLUMN,
FOLDER_COLUMN,
{ key: "name", label: "Cliente", type: "text" },
{ key: "city", label: "Ciudad", type: "text" },
{ key: "lastMovement", label: "Último movimiento", type: "date" },
@@ -258,7 +259,7 @@ const listadoEnRojo: ReportDef = {
const b = r.balance ?? new Prisma.Decimal(0);
totalBalance = totalBalance.plus(b);
return {
numid: nums.numid(r.id),
folder: nums.folder(r.id),
name: nameOf(r),
city: r.city ?? "—",
lastMovement: isoDay(r.lastMovement),
@@ -348,7 +349,7 @@ const pagosNoEfectuados: ReportDef = {
},
],
columns: [
NUMID_COLUMN,
FOLDER_COLUMN,
{ key: "customerName", label: "Cliente", type: "text" },
{ key: "propertyAddress", label: "Dirección", type: "text" },
{ key: "accountNumber", label: "Cuenta", type: "text" },
@@ -418,7 +419,7 @@ const pagosNoEfectuados: ReportDef = {
const out = rows.map((r) => {
if (Number(r.unfunded)) unfunded++;
return {
numid: nums.numid(r.customerId),
folder: nums.folder(r.customerId),
customerName: nameOf({ name: r.customerName, nameMissing: r.nameMissing }),
propertyAddress: r.addresses ?? "—",
accountNumber: r.accounts ?? "—",
@@ -481,7 +482,7 @@ const faltantes: ReportDef = {
},
],
columns: [
NUMID_COLUMN,
FOLDER_COLUMN,
{ key: "customerName", label: "Cliente", type: "text" },
{ key: "propertyAddress", label: "Dirección", type: "text" },
{ key: "missing", label: "Faltante", type: "text" },
@@ -525,7 +526,7 @@ const faltantes: ReportDef = {
const nums = await legacyNumbers(prisma, rows.map((r) => r.customerId));
const out = rows.map((r) => ({
numid: nums.numid(r.customerId),
folder: nums.folder(r.customerId),
customerName: nameOf({ name: r.customerName, nameMissing: r.nameMissing }),
propertyAddress: r.propertyAddress ?? "—",
missing: r.missing,
@@ -549,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",
@@ -571,7 +572,7 @@ const reporteDeEfectivo: ReportDef = {
],
columns: [
{ key: "date", label: "Fecha", type: "date" },
NUMID_COLUMN,
FOLDER_COLUMN,
{ key: "customerName", label: "Cliente", type: "text" },
{ key: "concept", label: "Concepto", type: "text" },
{ key: "source", label: "Origen", type: "text" },
@@ -590,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: [
@@ -619,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 } },
},
@@ -630,10 +635,10 @@ const reporteDeEfectivo: ReportDef = {
total = total.plus(r.amount);
return {
date: r.transactionDate.toISOString().slice(0, 10),
numid: nums.numid(r.customer.id),
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),
};
});
@@ -695,7 +700,7 @@ const vigente: ReportDef = {
],
columns: [
{ key: "policyNumber", label: "Póliza", type: "text" },
NUMID_COLUMN,
FOLDER_COLUMN,
INSURANCE_NO_COLUMN,
{ key: "customerName", label: "Cliente", type: "text" },
{ key: "provider", label: "Aseguradora", type: "text" },
@@ -743,7 +748,7 @@ const vigente: ReportDef = {
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
return {
policyNumber: r.policyNumber,
numid: nums.numid(r.customer.id),
folder: nums.folder(r.customer.id),
insuranceNo: nums.insurance(r.customer.id),
customerName: nameOf(r.customer),
provider: r.insuranceProvider?.name ?? "—",
@@ -848,7 +853,7 @@ const avisoRenovacion: ReportDef = {
// ReportRunner.tsx) — same trade-off edoCuentaDatos makes for "statement".
columns: [
{ key: "policyNumber", label: "Póliza", type: "text" },
NUMID_COLUMN,
FOLDER_COLUMN,
INSURANCE_NO_COLUMN,
{ key: "customerName", label: "Cliente", type: "text" },
{ key: "provider", label: "Aseguradora", type: "text" },
@@ -888,7 +893,7 @@ const avisoRenovacion: ReportDef = {
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
const letter = {
...toRenewalLetterRow(r, generation),
numid: nums.numid(r.customer.id),
folder: nums.folder(r.customer.id),
insuranceNo: nums.insurance(r.customer.id),
};
if (letter.sentAt) sentCount++;
@@ -968,7 +973,7 @@ const edoCuentaDatos: ReportDef = {
},
});
if (!customer) return { rows: [], subtitle: "Cliente no encontrado" };
const numid = (await legacyNumbers(prisma, [customer.id])).numid(customer.id);
const folder = (await legacyNumbers(prisma, [customer.id])).folder(customer.id);
// The source-table exclusion, the balance floor and the year scope below
// are BillingService.statement's, because this report and
@@ -1133,7 +1138,7 @@ const edoCuentaDatos: ReportDef = {
{
__kind: "header",
name: nameOf(customer),
numid,
folder,
address: customer.addressLine1 ?? "",
city: [customer.city, customer.state].filter(Boolean).join(", "),
phone: customer.phone ?? "",
@@ -1151,7 +1156,7 @@ const edoCuentaDatos: ReportDef = {
...carried,
...movements,
],
subtitle: `NUMid ${numid} · ${nameOf(customer)} · ${year} · ${visible.length} movimientos`,
subtitle: `Carpeta ${folder} · ${nameOf(customer)} · ${year} · ${visible.length} movimientos`,
};
},
};
@@ -1189,7 +1194,7 @@ const chequeCount: ReportDef = {
},
],
columns: [
NUMID_COLUMN,
FOLDER_COLUMN,
{ key: "customerName", label: "Cliente", type: "text" },
{ key: "reference", label: "Referencia", type: "text" },
{ key: "period", label: "Periodo", type: "text" },
@@ -1248,7 +1253,7 @@ const chequeCount: ReportDef = {
const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
return {
rows: rows.map((r) => ({
numid: nums.numid(r.customer.id),
folder: nums.folder(r.customer.id),
customerName: nameOf(r.customer),
reference: r.reference ?? "—",
period: r.period ?? "—",
@@ -0,0 +1,127 @@
import { BadRequestException } from "@nestjs/common";
import { Prisma } from "@jorgecuadros/database";
import { StatementsService, assertSplitsSum } from "./statements.service";
/**
* Confirming a batch is where a receipt becomes ledger rows. What must hold:
* a split receipt charges each part to its own customer and nothing to the
* matched one, sin fondos on one receipt holds back only that receipt, and a
* split that no longer adds up never posts.
*/
const D = (v: string) => new Prisma.Decimal(v);
function build(docs: unknown[]) {
const lines: { customerId: string; amount: number; outstanding: boolean }[] = [];
let refs: (string | undefined)[] = [];
const billing = {
createBatch: jest.fn().mockImplementation((dto, opts) => {
lines.push(...dto.lines);
refs = opts.refs;
return Promise.resolve({
items: dto.lines.map((_: unknown, i: number) => ({ id: `tx-${i}` })),
count: dto.lines.length,
total: "0.00",
outstandingCount: 0,
});
}),
};
const prisma = {
statementBatch: {
findUnique: jest.fn().mockResolvedValue({ id: "b1", serviceKind: "WATER" }),
updateMany: jest.fn(),
},
statementDocument: {
findMany: jest.fn().mockResolvedValue(docs),
update: jest.fn((a) => a),
updateMany: jest.fn((a) => a),
count: jest.fn().mockResolvedValue(0),
},
statementDocumentSplit: { update: jest.fn((a) => a) },
typeTransaction: { findFirst: jest.fn().mockResolvedValue({ id: "type-water" }) },
propertyService: { updateMany: jest.fn() },
$transaction: jest.fn().mockResolvedValue([]),
};
const service = new StatementsService(
prisma as never,
{} as never,
billing as never,
{} as never,
{} as never,
);
return { service, prisma, lines, refs: () => refs };
}
const doc = (over: Record<string, unknown>) => ({
id: "d1",
pageNumber: 1,
matchedCustomerId: "c-411",
matchedPropertyServiceId: null,
extractedAmount: D("300.00"),
extractedAccountRef: "3014057",
extractedPeriod: "ENE-FEB",
outstanding: false,
splits: [],
...over,
});
const confirm = { checkNumber: "8728", transactionDate: "2026-10-08" };
describe("confirming OCR receipts", () => {
it("posts one charge per part of a split receipt", async () => {
const { service, prisma, lines, refs } = build([
doc({
splits: [
{ id: "s1", sequence: 1, customerId: "c-411", amount: D("150.00") },
{ id: "s2", sequence: 2, customerId: "c-523", amount: D("150.00") },
],
}),
]);
await service.confirmBatch("b1", confirm, "u1");
expect(lines.map((l) => [l.customerId, l.amount])).toEqual([
["c-411", -150],
["c-523", -150],
]);
expect(refs()).toEqual(["d1#1", "d1#2"]);
// The rows hang off the parts, not the document.
expect(prisma.statementDocumentSplit.update).toHaveBeenCalledTimes(2);
expect(prisma.statementDocument.update).not.toHaveBeenCalled();
});
it("holds back only the receipt marked sin fondos", async () => {
const { service, lines } = build([
doc({ id: "d1", outstanding: true }),
doc({ id: "d2", matchedCustomerId: "c-208" }),
]);
await service.confirmBatch("b1", confirm, "u1");
expect(lines.map((l) => l.outstanding)).toEqual([true, false]);
});
it("refuses a split that no longer adds up to the receipt", async () => {
const { service, prisma } = build([
doc({
extractedAmount: D("320.00"), // edited after the split was saved
splits: [
{ id: "s1", sequence: 1, customerId: "c-411", amount: D("150.00") },
{ id: "s2", sequence: 2, customerId: "c-523", amount: D("150.00") },
],
}),
]);
await expect(service.confirmBatch("b1", confirm, "u1")).rejects.toBeInstanceOf(
BadRequestException,
);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
});
describe("split sums", () => {
it("compares in cents, so float noise is not a mismatch", () => {
expect(() => assertSplitsSum([0.1, 0.2], 0.3)).not.toThrow();
expect(() => assertSplitsSum([100, 33.33, 33.33], 166.67)).toThrow(BadRequestException);
});
});
+20
View File
@@ -1,4 +1,6 @@
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsEnum,
IsInt,
@@ -6,7 +8,9 @@ import {
IsOptional,
IsString,
MinLength,
ValidateNested,
} from "class-validator";
import { Type } from "class-transformer";
import { Currency, ServiceKind, StatementDocumentStatus } from "@jorgecuadros/database";
export class CreateStatementBatchDto {
@@ -27,6 +31,22 @@ export class ReviewDocumentDto {
@IsOptional()
@IsEnum(StatementDocumentStatus)
status?: Extract<StatementDocumentStatus, "MATCHED" | "NEEDS_REVIEW" | "CONFIRMED">;
/** Sin fondos for this receipt alone. */
@IsOptional() @IsBoolean() outstanding?: boolean;
}
export class SplitPartDto {
@IsString() @MinLength(1) customerId!: string;
@IsNumber() amount!: number;
}
/** A receipt's split; an empty list removes it. */
export class SetSplitsDto {
@IsArray()
@ArrayMaxSize(20)
@ValidateNested({ each: true })
@Type(() => SplitPartDto)
parts!: SplitPartDto[];
}
/**
@@ -4,6 +4,7 @@ import {
Get,
Param,
Patch,
Put,
Post,
Query,
Req,
@@ -22,7 +23,7 @@ import { RequireAbility } from "../auth/require-ability.decorator";
import { AuditService } from "../common/audit.service";
import type { UploadedFileLike } from "../storage/upload-file";
import { StatementsService } from "./statements.service";
import { ConfirmBatchDto, ReviewDocumentDto } from "./statement.dto";
import { ConfirmBatchDto, ReviewDocumentDto, SetSplitsDto } from "./statement.dto";
/**
* Statement OCR intake (RECEIPT_CAPTURE_SPEC §2).
@@ -117,6 +118,39 @@ export class StatementsController {
return batch;
}
/** More scans for a batch kept open for one check. */
@Post("batches/:id/files")
@RequireAbility("statement:ingest")
@UseInterceptors(FilesInterceptor("files", 25, { limits: { fileSize: 50 * 1024 * 1024 } }))
async addFiles(
@Param("id") id: string,
@UploadedFiles() files: UploadedFileLike[] | undefined,
@Req() req: Request,
) {
const batch = await this.statements.addFiles(id, files ?? []);
void this.audit.log(this.actingId(req), "statement.batch.add-files", {
batchId: id,
added: files?.length ?? 0,
});
return batch;
}
/** Split one receipt across several customers; empty parts undo it. */
@Put("documents/:id/splits")
@RequireAbility("statement:review")
async setSplits(
@Param("id") id: string,
@Body() dto: SetSplitsDto,
@Req() req: Request,
) {
const doc = await this.statements.setSplits(id, dto.parts, this.actingId(req));
void this.audit.log(this.actingId(req), "statement.document.split", {
documentId: id,
parts: dto.parts.length,
});
return doc;
}
@Patch("documents/:id")
@RequireAbility("statement:review")
async review(
+214 -33
View File
@@ -17,7 +17,7 @@ import type { UploadedFileLike } from "../storage/upload-file";
import { OCR_PROVIDER, type OcrProvider } from "./ocr/ocr.provider";
import { parseStatement } from "./parsers/statement-parser";
import { StatementMatcherService, scopedRefField } from "./statement-matcher.service";
import type { ConfirmBatchDto, ReviewDocumentDto } from "./statement.dto";
import type { ConfirmBatchDto, ReviewDocumentDto, SplitPartDto } from "./statement.dto";
/**
* Default ledger concept per service kind. The names are the legacy
@@ -74,6 +74,49 @@ export class StatementsService {
uploadedById: string,
label?: string,
) {
await this.assertCanIngest(files);
const batch = await this.prisma.statementBatch.create({
data: { serviceKind, uploadedById, label, fileCount: files.length },
});
this.startProcessing(batch.id, files, serviceKind, 0);
return batch;
}
/**
* Add more scans to a batch already under review — the office keeps one
* batch open for one check while the receipts trickle in. New pages are
* numbered after the existing ones; a settled (COMPLETED) batch reopens.
*/
async addFiles(batchId: string, files: UploadedFileLike[]) {
await this.assertCanIngest(files);
const batch = await this.prisma.statementBatch.findUnique({ where: { id: batchId } });
if (!batch) throw new NotFoundException("Lote no encontrado.");
if (batch.status === "DISCARDED") {
throw new BadRequestException("Este lote fue descartado; crea uno nuevo.");
}
// Two passes numbering pages at once would collide on (batchId, pageNumber).
if (batch.status === "PROCESSING" || batch.status === "UPLOADED") {
throw new BadRequestException("El lote todavía se está procesando; espera a que termine.");
}
const last = await this.prisma.statementDocument.aggregate({
where: { batchId },
_max: { pageNumber: true },
});
const updated = await this.prisma.statementBatch.update({
where: { id: batchId },
data: {
status: "UPLOADED",
fileCount: { increment: files.length },
completedAt: null,
error: null,
},
});
this.startProcessing(batchId, files, batch.serviceKind, last._max.pageNumber ?? 0);
return updated;
}
private async assertCanIngest(files: UploadedFileLike[]) {
if (!files?.length) throw new BadRequestException("No se recibió ningún archivo.");
if (!(await this.ocr.available())) {
throw new BadRequestException(
@@ -88,23 +131,24 @@ export class StatementsService {
"guardar los recibos escaneados.",
);
}
}
const batch = await this.prisma.statementBatch.create({
data: { serviceKind, uploadedById, label, fileCount: files.length },
});
private startProcessing(
batchId: string,
files: UploadedFileLike[],
serviceKind: ServiceKind,
afterPage: number,
) {
// Buffers are held for the background pass; the request's own copies would
// otherwise be garbage once the response is sent.
const copies = files.map((f) => ({ buffer: f.buffer, name: f.originalname }));
void this.process(batch.id, copies, serviceKind).catch(async (err) => {
this.logger.error(`Batch ${batch.id} failed: ${(err as Error).message}`);
void this.process(batchId, copies, serviceKind, afterPage).catch(async (err) => {
this.logger.error(`Batch ${batchId} failed: ${(err as Error).message}`);
await this.prisma.statementBatch.update({
where: { id: batch.id },
where: { id: batchId },
data: { status: "FAILED", error: (err as Error).message },
});
});
return batch;
}
/** Render → OCR → parse → match, one document row per page. */
@@ -112,13 +156,14 @@ export class StatementsService {
batchId: string,
files: { buffer: Buffer; name?: string }[],
serviceKind: ServiceKind,
afterPage = 0,
) {
await this.prisma.statementBatch.update({
where: { id: batchId },
data: { status: "PROCESSING" },
});
let pageNumber = 0;
let pageNumber = afterPage;
for (const file of files) {
// The source PDF is kept as well as the page images: it is the artifact
// the office actually received, and the only way to re-run a corrected
@@ -227,11 +272,24 @@ export class StatementsService {
where: { batchId: id, status: { in: OPEN } },
_sum: { extractedAmount: true },
});
// The check this batch was last posted against, so the next confirm of a
// batch kept open for one check doesn't make staff retype it.
const last = await this.prisma.transaction.findFirst({
where: {
OR: [
{ statementDocument: { batchId: id } },
{ statementSplit: { document: { batchId: id } } },
],
},
orderBy: { createdAt: "desc" },
select: { checkNumber: true },
});
return {
...batch,
byStatus: Object.fromEntries(counts.map((c) => [c.status, c._count._all])),
pendingTotal: totals._sum.extractedAmount?.toFixed(2) ?? "0.00",
lastCheckNumber: last?.checkNumber ?? null,
};
}
@@ -250,6 +308,10 @@ export class StatementsService {
property: { select: { id: true, addressLine1: true } },
},
},
splits: {
orderBy: { sequence: "asc" },
include: { customer: { select: { id: true, name: true } } },
},
},
});
}
@@ -268,11 +330,17 @@ export class StatementsService {
/** Staff correction of an extracted field or of the match itself. */
async review(id: string, dto: ReviewDocumentDto, reviewedById: string) {
const doc = await this.prisma.statementDocument.findUnique({ where: { id } });
const doc = await this.prisma.statementDocument.findUnique({
where: { id },
include: { splits: { select: { amount: true } } },
});
if (!doc) throw new NotFoundException("Documento no encontrado.");
if (doc.status === "POSTED") {
throw new BadRequestException("Este documento ya fue registrado.");
}
if (dto.amount != null && doc.splits.length) {
assertSplitsSum(doc.splits.map((s) => s.amount), dto.amount);
}
// Changing the service implies its owner; deriving the customer here rather
// than trusting a client-supplied pair is what stops a page being posted to
@@ -329,6 +397,7 @@ export class StatementsService {
extractedDueDate: dto.dueDate ? new Date(dto.dueDate) : undefined,
matchedPropertyServiceId,
matchedCustomerId,
outstanding: dto.outstanding ?? undefined,
status: dto.status ?? "MATCHED",
reviewedById,
reviewedAt: new Date(),
@@ -336,6 +405,62 @@ export class StatementsService {
});
}
/**
* Replace a receipt's split. Empty clears it, and the receipt posts whole to
* its matched customer again. Otherwise at least two parts, each positive,
* summing to the receipt's amount to the cent — a split that doesn't add up
* would charge the customers more or less than the bill.
*/
async setSplits(id: string, parts: SplitPartDto[], reviewedById: string) {
const doc = await this.prisma.statementDocument.findUnique({ where: { id } });
if (!doc) throw new NotFoundException("Documento no encontrado.");
if (doc.status === "POSTED" || doc.status === "REJECTED") {
throw new BadRequestException("Este documento ya no se puede dividir.");
}
if (parts.length === 1) {
throw new BadRequestException("Una división necesita al menos dos partes.");
}
if (parts.length) {
if (doc.extractedAmount == null) {
throw new BadRequestException("Captura el importe del recibo antes de dividirlo.");
}
if (parts.some((p) => !(p.amount > 0))) {
throw new BadRequestException("Cada parte debe ser mayor a cero.");
}
assertSplitsSum(parts.map((p) => p.amount), Number(doc.extractedAmount));
const ids = [...new Set(parts.map((p) => p.customerId))];
const found = await this.prisma.customer.count({ where: { id: { in: ids } } });
if (found !== ids.length) throw new BadRequestException("Cliente no encontrado.");
}
await this.prisma.$transaction([
this.prisma.statementDocumentSplit.deleteMany({ where: { documentId: id } }),
...parts.map((p, i) =>
this.prisma.statementDocumentSplit.create({
data: {
documentId: id,
sequence: i + 1,
customerId: p.customerId,
amount: new Prisma.Decimal(p.amount.toFixed(2)),
},
}),
),
// A split receipt has its customers; it is ready to post like any match.
this.prisma.statementDocument.update({
where: { id },
data: {
...(parts.length ? { status: "MATCHED" } : {}),
reviewedById,
reviewedAt: new Date(),
},
}),
]);
return this.prisma.statementDocument.findUnique({
where: { id },
include: { splits: { orderBy: { sequence: "asc" } } },
});
}
async reject(id: string, reviewedById: string) {
const doc = await this.prisma.statementDocument.findUnique({ where: { id } });
if (!doc) throw new NotFoundException("Documento no encontrado.");
@@ -414,9 +539,10 @@ export class StatementsService {
where: {
batchId,
status: { in: dto.includeReviewed ? ["MATCHED", "CONFIRMED"] : ["MATCHED"] },
matchedCustomerId: { not: null },
OR: [{ matchedCustomerId: { not: null } }, { splits: { some: {} } }],
},
orderBy: { pageNumber: "asc" },
include: { splits: { orderBy: { sequence: "asc" } } },
});
if (!docs.length) {
throw new BadRequestException("No hay documentos listos para registrar.");
@@ -430,8 +556,39 @@ export class StatementsService {
);
}
// Re-checked here as well as when saved: the amount may have been edited
// after the split was.
for (const d of docs) {
if (d.splits.length) {
assertSplitsSum(d.splits.map((s) => s.amount), Number(d.extractedAmount));
}
}
const typeId = dto.typeId ?? (await this.conceptFor(batch.serviceKind));
// One ledger line per receipt, or per part of a split receipt. `posts`
// remembers which record each line came from, so the created rows can be
// written back below.
const posts = docs.flatMap((d) =>
d.splits.length
? d.splits.map((s) => ({
doc: d,
splitId: s.id as string | null,
customerId: s.customerId,
amount: Number(s.amount),
ref: `${d.id}#${s.sequence}`,
}))
: [
{
doc: d,
splitId: null,
customerId: d.matchedCustomerId!,
amount: Number(d.extractedAmount),
ref: d.id,
},
],
);
const result = await this.billing.createBatch(
{
domain: "UTILITY",
@@ -439,36 +596,44 @@ export class StatementsService {
checkNumber: dto.checkNumber,
currency: dto.currency ?? "MXN",
typeId,
lines: docs.map((d) => ({
customerId: d.matchedCustomerId!,
lines: posts.map((p) => ({
customerId: p.customerId,
// Charges are negative in this ledger: a negative amount is what the
// customer owes. The parser reads the printed (positive) figure, so
// the sign is applied here, at the single point where a statement
// becomes a ledger row.
amount: -Math.abs(Number(d.extractedAmount)),
reference: d.extractedAccountRef ?? undefined,
period: d.extractedPeriod ?? undefined,
outstanding: dto.outstanding ?? false,
amount: -Math.abs(p.amount),
reference: p.doc.extractedAccountRef ?? undefined,
period: p.doc.extractedPeriod ?? undefined,
// Sin fondos is per check or per receipt: either one holds it back.
outstanding: (dto.outstanding ?? false) || p.doc.outstanding,
})),
},
{ source: "OCR", refs: docs.map((d) => d.id) },
{ source: "OCR", refs: posts.map((p) => p.ref) },
);
// `items[i]` is positionally parallel to `lines[i]` (seam guarantee 1), so
// the created rows zip straight back onto the documents that produced them.
await this.prisma.$transaction(
docs.map((d, i) =>
this.prisma.statementDocument.update({
where: { id: d.id },
data: {
status: "POSTED",
postedTransactionId: result.items[i].id,
reviewedById,
reviewedAt: new Date(),
},
// the created rows zip straight back onto the records that produced them.
// A split receipt's rows hang off its parts; the document itself keeps no
// single posted row.
const now = new Date();
await this.prisma.$transaction([
...posts.map((p, i) =>
p.splitId
? this.prisma.statementDocumentSplit.update({
where: { id: p.splitId },
data: { postedTransactionId: result.items[i].id },
})
: this.prisma.statementDocument.update({
where: { id: p.doc.id },
data: { postedTransactionId: result.items[i].id },
}),
),
);
this.prisma.statementDocument.updateMany({
where: { id: { in: docs.map((d) => d.id) } },
data: { status: "POSTED", reviewedById, reviewedAt: now },
}),
]);
// Teach the matcher. When a document was matched by clave catastral or by
// hand because the scoped field was blank, writing the reference back means
@@ -479,7 +644,12 @@ export class StatementsService {
await this.closeIfDone(batchId);
return { posted: result.count, total: result.total, checkNumber: dto.checkNumber };
return {
posted: result.count,
total: result.total,
outstanding: result.outstandingCount,
checkNumber: dto.checkNumber,
};
}
/** Write a confirmed reference onto a service that had none. */
@@ -524,3 +694,14 @@ export class StatementsService {
return row?.id;
}
}
/** Parts of a split must add up to the receipt, compared in cents. */
export function assertSplitsSum(parts: (number | Prisma.Decimal)[], total: number) {
const cents = (v: number | Prisma.Decimal) => Math.round(Number(v) * 100);
const sum = parts.reduce<number>((s, p) => s + cents(p), 0);
if (sum !== cents(total)) {
throw new BadRequestException(
`La división suma ${(sum / 100).toFixed(2)} y el recibo es de ${total.toFixed(2)}.`,
);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@jorgecuadros/web",
"version": "1.0.26",
"version": "1.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 4500",
+1
View File
@@ -256,6 +256,7 @@ function Hero({
.join(" · ");
const facts: { label: string; value: string }[] = [
{ label: "Carpeta", value: data.folderNumber != null ? String(data.folderNumber) : "—" },
{ label: "Cliente desde", value: formatDate(data.customerSince) },
{
label: "Cuota",
+292
View File
@@ -0,0 +1,292 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { getNextFolderNumber, onboardCustomer } from "@/lib/api";
import { useCan } from "@/lib/abilities";
import { formatMoney, SERVICE_KIND_LABELS } from "@/lib/labels";
import type { Currency, OnboardInput, OnboardResult, ServiceKind } from "@/lib/types";
/**
* Alta de cliente in one screen (2026-10-08 meeting): the customer, their
* property, its services and the opening balance taken as the first payment,
* saved together or not at all. Cash issues an EFE receipt with its folio.
*/
export default function AltaClientePage() {
return (
<AppShell>
<Alta />
</AppShell>
);
}
type ServiceRow = { kind: ServiceKind; accountNumber: string; meterNumber: string };
// The office's calendar day, not UTC's — in the evening UTC is already tomorrow.
const today = () => new Date().toLocaleDateString("en-CA");
const t = (v: string) => v.trim() || undefined;
function Alta() {
const allowed = useCan("customer:create");
const [folder, setFolder] = useState("");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [mobile, setMobile] = useState("");
const [preferredCurrency, setPreferredCurrency] = useState<Currency>("USD");
const [address1, setAddress1] = useState("");
const [address2, setAddress2] = useState("");
const [propertyPhone, setPropertyPhone] = useState("");
const [services, setServices] = useState<ServiceRow[]>([]);
const [payAmount, setPayAmount] = useState("");
const [payMethod, setPayMethod] = useState<"CASH" | "CHECK">("CASH");
const [payCurrency, setPayCurrency] = useState<Currency>("MXN");
const [payDate, setPayDate] = useState(today);
const [checkNumber, setCheckNumber] = useState("");
const [exchangeRate, setExchangeRate] = useState("");
const [concept, setConcept] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState<OnboardResult | null>(null);
useEffect(() => {
getNextFolderNumber()
.then((r) => setFolder((prev) => prev || String(r.folderNumber)))
.catch(() => {});
}, []);
const hasProperty = !!(address1.trim() || address2.trim() || propertyPhone.trim() || services.length);
const paying = Number(payAmount) > 0;
function setService(i: number, patch: Partial<ServiceRow>) {
setServices((prev) => prev.map((s, j) => (j === i ? { ...s, ...patch } : s)));
}
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
const input: OnboardInput = {
customer: {
name: name.trim(),
folderNumber: folder ? Number(folder) : undefined,
email: t(email),
phone: t(phone),
mobile: t(mobile),
preferredCurrency,
},
...(hasProperty && {
property: { addressLine1: t(address1), addressLine2: t(address2), phone1: t(propertyPhone) },
services: services.map((s) => ({
kind: s.kind,
accountNumber: t(s.accountNumber),
meterNumber: t(s.meterNumber),
})),
}),
...(paying && {
openingPayment: {
method: payMethod,
amount: Number(payAmount),
currency: payCurrency,
receivedAt: payDate,
checkNumber: payMethod === "CHECK" ? t(checkNumber) : undefined,
exchangeRate:
payMethod === "CASH" && payCurrency === "USD" && exchangeRate
? Number(exchangeRate)
: undefined,
concept: t(concept),
},
}),
};
try {
setDone(await onboardCustomer(input));
} catch (e2) {
setError((e2 as Error)?.message ?? "No se pudo dar de alta al cliente.");
} finally {
setBusy(false);
}
}
if (!allowed) {
return <div className="state-box state-error">No tiene permisos para crear clientes.</div>;
}
if (done) {
return (
<div className="stack">
<h1 className="page-title">Cliente dado de alta</h1>
<div className="card" style={{ padding: 20 }}>
<p>
<strong>{done.customer.name}</strong>
{done.customer.folderNumber != null && ` · Carpeta ${done.customer.folderNumber}`}
</p>
{done.receipt && (
<p>
Saldo inicial recibido en efectivo, folio {done.receipt.folio}.{" "}
<Link href={`/efectivo/${done.receipt.folio}`} target="_blank">
Imprimir recibo
</Link>
</p>
)}
{done.payment && <p>Saldo inicial registrado con el cheque {checkNumber}.</p>}
<div className="form-actions">
<Link href={`/clientes/${done.customer.id}`} className="btn btn-primary">
Ver cliente
</Link>
<button type="button" className="btn btn-outline" onClick={() => location.reload()}>
Otra alta
</button>
</div>
</div>
</div>
);
}
return (
<form onSubmit={submit}>
<div className="page-head">
<Link href="/clientes" className="back-link">← Clientes</Link>
<h1 className="page-title">Alta de cliente</h1>
</div>
{error && <div className="state-box state-error">{error}</div>}
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 14 }}>Cliente</h2>
<div className="form-grid">
<Field label="Carpeta">
<input className="input" type="number" min={1} value={folder}
onChange={(e) => setFolder(e.target.value)} />
</Field>
<Field label="Nombre *">
<input className="input" required value={name} onChange={(e) => setName(e.target.value)} />
</Field>
<Field label="Correo">
<input className="input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
</Field>
<Field label="Teléfono">
<input className="input" value={phone} onChange={(e) => setPhone(e.target.value)} />
</Field>
<Field label="Celular">
<input className="input" value={mobile} onChange={(e) => setMobile(e.target.value)} />
</Field>
<Field label="Moneda preferida">
<select className="input select" value={preferredCurrency}
onChange={(e) => setPreferredCurrency(e.target.value as Currency)}>
<option value="USD">USD</option>
<option value="MXN">MXN</option>
</select>
</Field>
</div>
</div>
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 14 }}>Propiedad y servicios</h2>
<div className="form-grid">
<Field label="Dirección 1">
<input className="input" value={address1} onChange={(e) => setAddress1(e.target.value)} />
</Field>
<Field label="Dirección 2">
<input className="input" value={address2} onChange={(e) => setAddress2(e.target.value)} />
</Field>
<Field label="Teléfono de la propiedad">
<input className="input" value={propertyPhone} onChange={(e) => setPropertyPhone(e.target.value)} />
</Field>
</div>
{services.map((s, i) => (
<div key={i} className="form-grid" style={{ marginTop: 12, alignItems: "end" }}>
<select className="input select" value={s.kind}
onChange={(e) => setService(i, { kind: e.target.value as ServiceKind })}>
{Object.entries(SERVICE_KIND_LABELS).map(([k, label]) => (
<option key={k} value={k}>{label}</option>
))}
</select>
<input className="input" placeholder="Cuenta / referencia" value={s.accountNumber}
onChange={(e) => setService(i, { accountNumber: e.target.value })} />
<input className="input" placeholder="Medidor" value={s.meterNumber}
onChange={(e) => setService(i, { meterNumber: e.target.value })} />
<button type="button" className="btn btn-ghost"
onClick={() => setServices((prev) => prev.filter((_, j) => j !== i))}>
Quitar
</button>
</div>
))}
<button type="button" className="btn btn-ghost" style={{ marginTop: 12 }}
onClick={() => setServices((prev) => [...prev, { kind: "WATER", accountNumber: "", meterNumber: "" }])}>
+ Agregar servicio
</button>
</div>
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 14 }}>Saldo inicial (pago)</h2>
<div className="form-grid">
<Field label="Monto">
<input className="input" type="number" step="0.01" min="0" value={payAmount}
placeholder="Vacío = sin saldo inicial"
onChange={(e) => setPayAmount(e.target.value)} />
</Field>
<Field label="Moneda">
<select className="input select" value={payCurrency}
onChange={(e) => setPayCurrency(e.target.value as Currency)}>
<option value="MXN">MXN</option>
<option value="USD">USD</option>
</select>
</Field>
<Field label="Forma de pago">
<select className="input select" value={payMethod}
onChange={(e) => setPayMethod(e.target.value as "CASH" | "CHECK")}>
<option value="CASH">Efectivo (emite recibo EFE)</option>
<option value="CHECK">Cheque</option>
</select>
</Field>
<Field label="Fecha">
<input className="input" type="date" value={payDate} onChange={(e) => setPayDate(e.target.value)} />
</Field>
{payMethod === "CHECK" && (
<Field label="Número de cheque *">
<input className="input" required={paying} value={checkNumber}
onChange={(e) => setCheckNumber(e.target.value)} />
</Field>
)}
{payMethod === "CASH" && payCurrency === "USD" && (
<Field label="Tipo de cambio (abonar en pesos)">
<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)} />
</Field>
)}
<Field label="Concepto">
<input className="input" placeholder="SALDO INICIAL" value={concept}
onChange={(e) => setConcept(e.target.value)} />
</Field>
</div>
{paying && (
<p className="page-sub" style={{ marginTop: 12 }}>
Se abonará {formatMoney(payAmount, payCurrency)} a la cuenta del cliente
{payMethod === "CASH" ? " y se emitirá el recibo de efectivo con el siguiente folio." : "."}
</p>
)}
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={busy || !name.trim()}>
{busy ? "Guardando…" : "Dar de alta"}
</button>
<Link href="/clientes" className="btn btn-outline">Cancelar</Link>
</div>
</form>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="field">
<span className="field-label">{label}</span>
{children}
</label>
);
}
+10 -3
View File
@@ -107,9 +107,14 @@ function ClientesBrowser() {
]}
/>
{canCreate && (
<Link href="/clientes/nuevo" className="btn btn-primary">
+ Nuevo cliente
<>
<Link href="/clientes/nuevo" className="btn btn-outline">
+ Solo cliente
</Link>
<Link href="/clientes/alta" className="btn btn-primary">
+ Alta de cliente
</Link>
</>
)}
</div>
<StatStrip stats={stats} />
@@ -125,7 +130,7 @@ function ClientesBrowser() {
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar por nombre, ciudad, teléfono…"
placeholder="Buscar por nombre, carpeta, ciudad, teléfono…"
aria-label="Buscar clientes"
/>
</div>
@@ -261,6 +266,8 @@ function CustomerRow({ c }: { c: CustomerListItem }) {
)}
</div>
<div className="cust-sub">
{c.folderNumber != null && <span>Carpeta {c.folderNumber}</span>}
{c.folderNumber != null && (location || contact) && <span className="sep">·</span>}
{location && <span>{location}</span>}
{location && contact && <span className="sep">·</span>}
{contact && <span>{contact}</span>}
+100
View File
@@ -0,0 +1,100 @@
"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)} {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>
);
}
+292
View File
@@ -0,0 +1,292 @@
"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>
);
}
// The office's calendar day, not UTC's — in the evening UTC is already tomorrow.
const today = () => new Date().toLocaleDateString("en-CA");
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="input 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>
);
}
+250 -8
View File
@@ -6,18 +6,21 @@ import { AppShell } from "@/components/AppShell";
import { CustomerPicker } from "@/components/CustomerPicker";
import { DiscardBatchCard } from "@/components/DiscardBatchCard";
import {
addStatementFiles,
confirmStatementBatch,
discardStatementBatch,
getStatementBatch,
listStatementDocuments,
rejectStatementDocument,
reviewStatementDocument,
setStatementSplits,
statementPageUrl,
} from "@/lib/api";
import { useCan } from "@/lib/abilities";
import { formatDate, formatMoney, serviceKindLabel } from "@/lib/labels";
import type {
ConfirmBatchInput,
SplitPartInput,
StatementBatchDetail,
StatementDocument,
StatementDocumentStatus,
@@ -64,6 +67,7 @@ export default function RecibosBatchPage({ params }: { params: { id: string } })
function BatchReview({ id }: { id: string }) {
const canReview = useCan("statement:review");
const canIngest = useCan("statement:ingest");
const [batch, setBatch] = useState<StatementBatchDetail | null>(null);
const [docs, setDocs] = useState<StatementDocument[]>([]);
const [error, setError] = useState<string | null>(null);
@@ -108,7 +112,7 @@ function BatchReview({ id }: { id: string }) {
);
const readyCount = docs.filter(
(d) => d.status === "MATCHED" && d.matchedCustomer,
(d) => d.status === "MATCHED" && (d.matchedCustomer || d.splits.length > 0),
).length;
async function discard() {
@@ -162,9 +166,14 @@ function BatchReview({ id }: { id: string }) {
<SummaryCard batch={batch} readyCount={readyCount} />
{canIngest && !processing && batch.status !== "DISCARDED" && (
<AddFilesCard batchId={id} onDone={load} setError={setError} />
)}
{canReview && readyCount > 0 && (
<ConfirmCard
batchId={id}
lastCheckNumber={batch.lastCheckNumber}
readyCount={readyCount}
onDone={load}
setError={setError}
@@ -291,16 +300,19 @@ function SummaryCard({
*/
function ConfirmCard({
batchId,
lastCheckNumber,
readyCount,
onDone,
setError,
}: {
batchId: string;
lastCheckNumber: string | null;
readyCount: number;
onDone: () => void;
setError: (m: string | null) => void;
}) {
const [checkNumber, setCheckNumber] = useState("");
// A batch kept open for one check is posted in rounds against that check.
const [checkNumber, setCheckNumber] = useState(lastCheckNumber ?? "");
const [transactionDate, setTransactionDate] = useState(
new Date().toISOString().slice(0, 10),
);
@@ -322,9 +334,9 @@ function ConfirmCard({
};
const r = await confirmStatementBatch(batchId, input);
setResult(
`Se registraron ${r.posted} movimiento(s) por ${formatMoney(r.total, "MXN")} con el cheque ${r.checkNumber}.`,
`Se registraron ${r.posted} movimiento(s) por ${formatMoney(r.total, "MXN")} con el cheque ${r.checkNumber}` +
(r.outstanding ? `; ${r.outstanding} sin fondos.` : "."),
);
setCheckNumber("");
onDone();
} catch (e) {
setError((e as Error)?.message ?? "No se pudo registrar el lote.");
@@ -363,7 +375,7 @@ function ConfirmCard({
checked={outstanding}
onChange={(e) => setOutstanding(e.target.checked)}
/>{" "}
Sin fondos (queda pendiente)
Todo el cheque sin fondos
</label>
<label className="check">
<input
@@ -389,12 +401,65 @@ function ConfirmCard({
)}
<p className="page-sub" style={{ marginTop: 12 }}>
Se registran como cargos del cliente, por la misma vía que la captura
manual. Un lote registrado dos veces no duplica cobros.
manual. Un lote registrado dos veces no duplica cobros. Para dejar
pendiente un solo recibo, márcalo «Sin fondos» en el recibo.
</p>
</section>
);
}
/** More scans for this batch — it stays open while receipts keep arriving. */
function AddFilesCard({
batchId,
onDone,
setError,
}: {
batchId: string;
onDone: () => void;
setError: (m: string | null) => void;
}) {
const [files, setFiles] = useState<File[]>([]);
const [busy, setBusy] = useState(false);
async function upload() {
setBusy(true);
setError(null);
try {
await addStatementFiles(batchId, files);
setFiles([]);
onDone();
} catch (e) {
setError((e as Error)?.message ?? "No se pudieron agregar los archivos.");
} finally {
setBusy(false);
}
}
return (
<section className="card" style={{ padding: 16 }}>
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
<label>
<span className="page-sub">Agregar recibos a este lote</span>
<input
type="file"
accept="application/pdf"
multiple
onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
/>
</label>
<button
type="button"
className="btn btn-outline"
disabled={!files.length || busy}
onClick={upload}
>
{busy ? "Subiendo…" : `Agregar ${files.length || ""} archivo(s)`}
</button>
</div>
</section>
);
}
function DocumentRow({
doc,
canReview,
@@ -411,6 +476,7 @@ function DocumentRow({
const [accountRef, setAccountRef] = useState(doc.extractedAccountRef ?? "");
const [customerId, setCustomerId] = useState(doc.matchedCustomer?.id ?? "");
const [customerName, setCustomerName] = useState(doc.matchedCustomer?.name ?? "");
const [outstanding, setOutstanding] = useState(doc.outstanding);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
@@ -424,6 +490,7 @@ function DocumentRow({
accountRef: accountRef.trim() || undefined,
amount: amount ? Number(amount) : undefined,
matchedCustomerId: customerId || undefined,
outstanding,
status,
});
onChange();
@@ -456,9 +523,21 @@ function DocumentRow({
<div>
<strong>Página {doc.pageNumber}</strong>{" "}
<span className="tag">{STATUS_LABEL[doc.status] ?? doc.status}</span>{" "}
{doc.outstanding && <span className="tag">Sin fondos</span>}{" "}
{doc.provider && <span className="page-sub">{doc.provider}</span>}
<div className="page-sub" style={{ marginTop: 4 }}>
{doc.matchedCustomer ? (
{doc.splits.length > 0 ? (
<>
Dividido:{" "}
{doc.splits.map((s, i) => (
<span key={s.id}>
{i > 0 && " + "}
<Link href={`/clientes/${s.customer.id}`}>{s.customer.name}</Link>{" "}
{formatMoney(s.amount, "MXN")}
</span>
))}
</>
) : doc.matchedCustomer ? (
<Link href={`/clientes/${doc.matchedCustomer.id}`}>
{doc.matchedCustomer.name}
</Link>
@@ -533,10 +612,18 @@ function DocumentRow({
}}
/>
</div>
<label className="check">
<input
type="checkbox"
checked={outstanding}
onChange={(e) => setOutstanding(e.target.checked)}
/>{" "}
Sin fondos
</label>
<button
type="button"
className="btn btn-primary"
disabled={busy || !customerId}
disabled={busy || (!customerId && doc.splits.length === 0)}
onClick={() => save("MATCHED")}
>
Guardar
@@ -552,9 +639,164 @@ function DocumentRow({
</div>
)}
{canReview && !settled && (
<SplitEditor doc={doc} onChange={onChange} />
)}
{err && <div className="state-box state-error">{err}</div>}
</div>
)}
</div>
);
}
type Part = { customerId: string; customerName: string; amount: string };
/** Equal parts in cents; the odd cents go to the first parts. */
function equalParts(total: number, n: number): string[] {
const cents = Math.round(total * 100);
const base = Math.floor(cents / n);
return Array.from({ length: n }, (_, i) =>
((base + (i < cents - base * n ? 1 : 0)) / 100).toFixed(2),
);
}
/**
* One receipt paid by several customers — a water bill two neighbours share.
* Amounts are free (the office splits unevenly as often as not) but must add
* up to the receipt; "partes iguales" fills them in.
*/
function SplitEditor({ doc, onChange }: { doc: StatementDocument; onChange: () => void }) {
const total = Number(doc.extractedAmount ?? 0);
const [parts, setParts] = useState<Part[]>(() =>
doc.splits.map((s) => ({
customerId: s.customer.id,
customerName: s.customer.name,
amount: Number(s.amount).toFixed(2),
})),
);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const sum = parts.reduce((s, p) => s + Math.round(Number(p.amount || 0) * 100), 0) / 100;
const balanced = Math.round(sum * 100) === Math.round(total * 100);
const complete = parts.length >= 2 && parts.every((p) => p.customerId && Number(p.amount) > 0);
function setPart(i: number, patch: Partial<Part>) {
setParts((prev) => prev.map((p, j) => (j === i ? { ...p, ...patch } : p)));
}
function start() {
const first = {
customerId: doc.matchedCustomer?.id ?? "",
customerName: doc.matchedCustomer?.name ?? "",
};
const [a, b] = equalParts(total, 2);
setParts([
{ ...first, amount: a },
{ customerId: "", customerName: "", amount: b },
]);
}
function evenly() {
const amounts = equalParts(total, parts.length);
setParts((prev) => prev.map((p, i) => ({ ...p, amount: amounts[i] })));
}
async function save(next: SplitPartInput[]) {
setBusy(true);
setErr(null);
try {
await setStatementSplits(doc.id, next);
onChange();
} catch (e) {
setErr((e as Error)?.message ?? "No se pudo guardar la división.");
} finally {
setBusy(false);
}
}
if (!parts.length) {
return (
<div>
<button
type="button"
className="btn btn-ghost"
disabled={!doc.extractedAmount}
title={doc.extractedAmount ? undefined : "Captura el importe primero"}
onClick={start}
>
Dividir entre varios clientes
</button>
</div>
);
}
return (
<div className="stack" style={{ gap: 8 }}>
<strong>División del recibo ({formatMoney(total, "MXN")})</strong>
{parts.map((p, i) => (
<div key={i} className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
<div style={{ minWidth: 260 }}>
<CustomerPicker
value={p.customerId}
valueName={p.customerName}
onPick={(cid, name) => setPart(i, { customerId: cid, customerName: name })}
/>
</div>
<input
className="input"
inputMode="decimal"
style={{ width: 120 }}
value={p.amount}
onChange={(e) => setPart(i, { amount: e.target.value })}
/>
{parts.length > 2 && (
<button
type="button"
className="btn btn-ghost"
onClick={() => setParts((prev) => prev.filter((_, j) => j !== i))}
>
Quitar
</button>
)}
</div>
))}
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
<button
type="button"
className="btn btn-ghost"
onClick={() => setParts((prev) => [...prev, { customerId: "", customerName: "", amount: "" }])}
>
Agregar cliente
</button>
<button type="button" className="btn btn-ghost" onClick={evenly}>
Partes iguales
</button>
<span className={balanced ? "page-sub" : "state-error"}>
Suma {formatMoney(sum, "MXN")}
{!balanced && ` — diferencia ${formatMoney(total - sum, "MXN")}`}
</span>
<button
type="button"
className="btn btn-primary"
disabled={busy || !balanced || !complete}
onClick={() =>
save(parts.map((p) => ({ customerId: p.customerId, amount: Number(p.amount) })))
}
>
Guardar división
</button>
<button
type="button"
className="btn btn-outline"
disabled={busy}
onClick={() => (doc.splits.length ? save([]) : setParts([]))}
>
Quitar división
</button>
</div>
{err && <div className="state-box state-error">{err}</div>}
</div>
);
}
+2
View File
@@ -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" },
],
+21 -2
View File
@@ -1,9 +1,9 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import type { CustomerDetail, CustomerInput, Currency } from "@/lib/types";
import { createCustomer, updateCustomer } from "@/lib/api";
import { createCustomer, getNextFolderNumber, updateCustomer } from "@/lib/api";
/** ISO date (yyyy-mm-dd) for a date input, from an API date string. */
function toDateInput(v: string | null | undefined): string {
@@ -20,6 +20,7 @@ function numOrUndef(v: string | number | null | undefined): number | undefined {
type Values = {
name: string;
folderNumber: string;
addressLine1: string;
addressLine2: string;
city: string;
@@ -45,6 +46,7 @@ type Values = {
function initial(c?: CustomerDetail): Values {
return {
name: c?.name ?? "",
folderNumber: c?.folderNumber != null ? String(c.folderNumber) : "",
addressLine1: c?.addressLine1 ?? "",
addressLine2: c?.addressLine2 ?? "",
city: c?.city ?? "",
@@ -85,6 +87,17 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) {
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// A new customer is offered the lowest free folder, the way the office
// hands numbers back out; staff can type any other.
useEffect(() => {
if (editing) return;
getNextFolderNumber()
.then(({ folderNumber }) =>
setV((prev) => (prev.folderNumber ? prev : { ...prev, folderNumber: String(folderNumber) })),
)
.catch(() => {});
}, [editing]);
function set<K extends keyof Values>(key: K, val: Values[K]) {
setV((prev) => ({ ...prev, [key]: val }));
}
@@ -95,6 +108,8 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) {
setError(null);
const payload: CustomerInput = {
name: v.name.trim(),
// Blank clears it on edit; s() would leave the old number in place.
folderNumber: v.folderNumber.trim() === "" ? (editing ? null : undefined) : Number(v.folderNumber),
addressLine1: s(v.addressLine1),
addressLine2: s(v.addressLine2),
city: s(v.city),
@@ -138,6 +153,10 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) {
<input className="input" required value={v.name}
onChange={(e) => set("name", e.target.value)} />
</Field>
<Field label="Carpeta">
<input className="input" type="number" min={1} step={1} value={v.folderNumber}
onChange={(e) => set("folderNumber", e.target.value)} />
</Field>
<Field label="Correo">
<input className="input" type="email" value={v.email}
onChange={(e) => set("email", e.target.value)} />
+2 -2
View File
@@ -390,8 +390,8 @@ function StatementLayout({ result }: { result: ReportRunResult }) {
<div className="statement">
<header className="statement-head">
<h2 className="statement-name">{String(header.name ?? "—")}</h2>
{header.numid && header.numid !== "—" ? (
<p className="muted">NUMid {String(header.numid)}</p>
{header.folder && header.folder !== "—" ? (
<p className="muted">Carpeta {String(header.folder)}</p>
) : null}
{Boolean(header.address) && (
<p className="muted">{String(header.address)}</p>
+77 -4
View File
@@ -31,6 +31,7 @@ import type {
UpdateBankAccountInput,
ResolveOutstandingInput,
ReviewDocumentInput,
SplitPartInput,
StatementBatch,
StatementBatchDetail,
StatementDocument,
@@ -80,6 +81,11 @@ import type {
TransactionDomain,
TrustFilter,
UserRow,
CashReceipt,
CashReceiptInput,
CompanyHeader,
OnboardInput,
OnboardResult,
} from "./types";
// Resolve the API origin at runtime, not build time — so one built image serves
@@ -215,10 +221,23 @@ export function listCustomers(
return apiFetch<CustomerListResponse>(`/customers${qs ? `?${qs}` : ""}`);
}
/** Lowest folder number no open account holds — the suggestion for a new customer. */
export function getNextFolderNumber(): Promise<{ folderNumber: number }> {
return apiFetch<{ folderNumber: number }>("/customers/folder/next");
}
export function getCustomer(id: string): Promise<CustomerDetail> {
return apiFetch<CustomerDetail>(`/customers/${id}`);
}
/** Open an account in one step; see OnboardInput. */
export function onboardCustomer(input: OnboardInput): Promise<OnboardResult> {
return apiFetch<OnboardResult>("/customers/onboard", {
method: "POST",
body: JSON.stringify(input),
});
}
export function createCustomer(input: CustomerInput): Promise<CustomerDetail> {
return apiFetch<CustomerDetail>("/customers", {
method: "POST",
@@ -1375,17 +1394,26 @@ export function listStatementDocuments(
}
/** Multi-file upload — one batch is usually several multi-page scans. */
export async function uploadStatementBatch(
export function uploadStatementBatch(
files: File[],
serviceKind: ServiceKind,
label?: string,
): Promise<StatementBatch> {
const body = new FormData();
for (const f of files) body.append("files", f, f.name);
const qs = new URLSearchParams({ serviceKind });
if (label) qs.set("label", label);
return postStatementFiles(`/statements/batches?${qs}`, files);
}
const res = await fetch(`${API_ORIGIN}/statements/batches?${qs}`, {
/** More scans for a batch kept open for one check. */
export function addStatementFiles(batchId: string, files: File[]): Promise<StatementBatch> {
return postStatementFiles(`/statements/batches/${batchId}/files`, files);
}
async function postStatementFiles(path: string, files: File[]): Promise<StatementBatch> {
const body = new FormData();
for (const f of files) body.append("files", f, f.name);
const res = await fetch(`${API_ORIGIN}${path}`, {
method: "POST",
credentials: "include",
body,
@@ -1413,6 +1441,17 @@ export function reviewStatementDocument(
});
}
/** Split a receipt across customers; an empty list undoes the split. */
export function setStatementSplits(
id: string,
parts: SplitPartInput[],
): Promise<StatementDocument> {
return apiFetch(`/statements/documents/${id}/splits`, {
method: "PUT",
body: JSON.stringify({ parts }),
});
}
export function rejectStatementDocument(id: string): Promise<StatementDocument> {
return apiFetch(`/statements/documents/${id}/reject`, { method: "POST" });
}
@@ -1437,6 +1476,40 @@ export function statementPageUrl(documentId: string): string {
return `${API_ORIGIN}/statements/documents/${documentId}/page`;
}
/* ------------------------------------------------ EFE cash receipt book */
export function getNextCashFolio(): Promise<{ folio: number }> {
return apiFetch("/cash-receipts/next-folio");
}
export function listCashReceipts(q: { query?: string; page?: number } = {}): Promise<{
items: CashReceipt[];
total: number;
page: number;
pageSize: number;
pageCount: number;
}> {
const params = new URLSearchParams();
if (q.query) params.set("query", q.query);
if (q.page) params.set("page", String(q.page));
return apiFetch(`/cash-receipts?${params}`);
}
export function getCashReceipt(folio: number): Promise<CashReceipt & { company: CompanyHeader }> {
return apiFetch(`/cash-receipts/${folio}`);
}
export function createCashReceipt(input: CashReceiptInput): Promise<CashReceipt> {
return apiFetch("/cash-receipts", { method: "POST", body: JSON.stringify(input) });
}
export function setCashReceiptDeposit(id: string, depositedAt?: string): Promise<CashReceipt> {
return apiFetch(`/cash-receipts/${id}/deposit`, {
method: "PATCH",
body: JSON.stringify({ depositedAt }),
});
}
/* ----------------------------------------------- Policy OCR (GMX / ANA) */
export function getPolicyOcrStatus(): Promise<{
+90
View File
@@ -220,6 +220,7 @@ export interface CustomerListItem {
name: string;
/** Legacy table the name was recovered from; null when it came from DATGRAL. */
nameSource: string | null;
folderNumber: number | null;
city: string | null;
state: string | null;
email: string | null;
@@ -1113,6 +1114,7 @@ export interface CustomerDetail {
id: string;
name: string;
nameSource: string | null;
folderNumber: number | null;
addressLine1: string | null;
addressLine2: string | null;
city: string | null;
@@ -1146,6 +1148,8 @@ export interface CustomerDetail {
/** Editable customer fields — shared by the create/edit form and the API. */
export interface CustomerInput {
name: string;
/** null clears it. */
folderNumber?: number | null;
addressLine1?: string;
addressLine2?: string;
city?: string;
@@ -1411,6 +1415,8 @@ export interface StatementBatchDetail extends StatementBatch {
byStatus: Partial<Record<StatementDocumentStatus, number>>;
/** Sum of the amounts still awaiting posting. */
pendingTotal: string;
/** Check the batch was last posted against, to prefill the next confirm. */
lastCheckNumber: string | null;
}
export interface StatementDocument {
@@ -1434,6 +1440,22 @@ export interface StatementDocument {
property: { id: string; addressLine1: string | null };
} | null;
postedTransactionId: string | null;
/** Sin fondos for this receipt alone. */
outstanding: boolean;
/** Empty unless the receipt is shared by several customers. */
splits: StatementSplit[];
}
export interface StatementSplit {
id: string;
sequence: number;
amount: string;
customer: { id: string; name: string };
}
export interface SplitPartInput {
customerId: string;
amount: number;
}
export interface ReviewDocumentInput {
@@ -1444,6 +1466,7 @@ export interface ReviewDocumentInput {
matchedPropertyServiceId?: string;
matchedCustomerId?: string;
status?: "MATCHED" | "NEEDS_REVIEW" | "CONFIRMED";
outstanding?: boolean;
}
/** Check-level fields shared by every line posted from a batch. */
@@ -1459,6 +1482,8 @@ export interface ConfirmBatchInput {
export interface ConfirmBatchResult {
posted: number;
total: string;
/** How many of the posted rows went in sin fondos. */
outstanding: number;
checkNumber: string;
}
@@ -1656,3 +1681,68 @@ export interface PolicyOcrConfirmResult {
policies: string[];
postedTransactions: number;
}
/* ------------------------------------------------ EFE cash receipt book */
export interface CashReceipt {
id: string;
folio: number;
receivedAt: string;
amount: string;
currency: Currency;
exchangeRate: string | null;
concept: string;
depositedAt: string | null;
createdAt: string;
customer: { id: string; name: string; folderNumber: number | null };
preparedBy: { name: string };
/** The C<folio> credit. Voiding it voids the receipt. */
transaction: { id: string; voidedAt: string | null; amount: string; currency: Currency };
}
/** Office details the printed receipt is headed with. */
export interface CompanyHeader {
name: string;
addressLine1: string;
addressLine2: string;
cityState: string;
mailingAddress: string;
phone: string;
email: string;
taxId: string;
website: string;
}
export interface CashReceiptInput {
customerId: string;
folio?: number;
receivedAt: string;
amount: number;
currency: Currency;
exchangeRate?: number;
concept: string;
depositedAt?: string;
}
/** One-screen account opening: cliente + propiedad + servicios + saldo inicial. */
export interface OnboardInput {
customer: CustomerInput;
property?: { addressLine1?: string; addressLine2?: string; phone1?: string };
services?: { kind: ServiceKind; accountNumber?: string; meterNumber?: string }[];
openingPayment?: {
method: "CASH" | "CHECK";
amount: number;
currency: Currency;
receivedAt: string;
checkNumber?: string;
exchangeRate?: number;
concept?: string;
};
}
export interface OnboardResult {
customer: { id: string; name: string; folderNumber: number | null };
property: { id: string } | null;
receipt: { folio: number } | null;
payment: { id: string } | null;
}
+3 -1
View File
@@ -148,7 +148,9 @@ carries tables nothing writes teaches the next reader the wrong thing.
| 3.1 | **Liquidación batch workflow** | ~70% of the fields already wired end to end. **226 policies pending.** Needs the ramo-parameterized pending report + `POST /policies/liquidate-batch` under a new `policy:liquidate` (MANAGER). Smallest remaining piece of step 12 | INSURANCE §2 |
| 3.2 | **Certificate rendering** | The report half is buildable now off the same `format: "letter"` machinery as `aviso-renovacion`. Portal delivery waits on steps 8/9. Whole section waits on 1.2 | INSURANCE §3 |
| 3.3 | **Carrier API integration** | Blocked on 1.3. ANA's SOAP service is real with a known operation list; GMX publishes nothing machine-readable and writes the larger half of this book | INSURANCE §4 |
| 3.4 | **Customer-number recycling** | Not started. `Customer.customerNumber` **does not exist in the schema**. Backfill needs care: ~140 utilities rows and all insurance-only customers carry synthetic `rownum_N`/`insrow_N` placeholders, not real `NUM id`s. Last unbuilt piece of step 11 | RECEIPT §4 |
| 3.4 | ~~**Customer-number recycling**~~ | **BUILT 2026-10-12** (`b016e53`) as `customers.folderNumber` ("Carpeta"): a label, not a key, unique among non-archived customers, lowest free suggested. Backfilled from the utilities NUMid; insurance-only customers start blank | RECEIPT §4 |
| 3.5 | **Portal login by email for every customer** | Not started (deferred 2026-10-12). Every customer with an email gets access automatically: email → verification code → session. Token carries the email and resolves the customer set per request, so it survives the cutover REIMPORT. 30 emails are shared by 68 customers → account picker after verify. 107 active customers have no email → no access until the office adds one. Unlocks the 284 customers with no NUMid. In `my-jorgecuadros-web`: `verification_codes` is keyed `(email, numid)`, ~68 files authorize on `$tokenData['numid']` — route them through one helper | meeting 2026-10-08 |
| 3.6 | **Remove NUMid + password portal login** | Not started (deferred 2026-10-12, lands with or after 3.5). Delete `login.php`'s NUMid/password path and the legacy `PASSWD` reads; the email code becomes the only way in. Assumes no store-installed mobile app still calls `login.php` | meeting 2026-10-08 |
Note 3.3 partly overlaps what [`POLICY_OCR.md`](POLICY_OCR.md) already does —
an OCR path that turns a carrier PDF into a `Policy` row covers some of what
+17 -3
View File
@@ -166,12 +166,17 @@ def resolve_name(raw, nid, index) -> tuple[str, str | None]:
# ------------------------------- record builders ---------------------------- #
def customer_from_utilities(row, name_index) -> dict:
name, name_source = resolve_name(row["nombre"], norm_id(row["num_id"]), name_index)
nid = norm_id(row["num_id"])
name, name_source = resolve_name(row["nombre"], nid, name_index)
return dict(
id=str(uuid.uuid4()),
name=name,
nameSource=name_source,
nameMissing=int(name == NO_NAME),
# The NUMid is the number on the customer's shelf folder. It is copied
# into a plain label so the office can reuse it without it ever being
# an identity; customer_legacy_refs keeps the NUMid itself.
folderNumber=int(nid) if nid and nid.isdigit() else None,
addressLine1=s(row["direccion"]),
addressLine2=s(row["colonia"]),
city=s(row["ciudad"]),
@@ -205,6 +210,7 @@ def customer_from_insurance(row, name_index) -> dict:
name=name,
nameSource=name_source,
nameMissing=int(name == NO_NAME),
folderNumber=None, # insurance ids are a separate space, not a shelf folder
addressLine1=s(row["direccion_1"]),
addressLine2=s(row["direccion_2"]),
city=s(row["ciudad"]),
@@ -228,7 +234,7 @@ def customer_from_insurance(row, name_index) -> dict:
_CUST_COLS = [
"id", "name", "nameSource", "nameMissing", "addressLine1", "addressLine2", "city", "state", "zipCode",
"id", "name", "nameSource", "nameMissing", "folderNumber", "addressLine1", "addressLine2", "city", "state", "zipCode",
"country", "phone", "mobile", "fax", "email", "notes", "identificationType",
"identificationNumber", "identificationExpiration", "customerSince",
"status", "feeAmount", "minimumBalance", "updatedAt",
@@ -322,7 +328,7 @@ def main() -> None:
remap[rec["id"]] = stable or rec["id"]
for rec in customers:
rec["id"] = remap[rec["id"]]
cur.execute(f"INSERT INTO customers ({','.join(f'`{c}`' for c in _CUST_COLS)}) VALUES ({placeholders}) ON DUPLICATE KEY UPDATE name=VALUES(name),nameSource=VALUES(nameSource),nameMissing=VALUES(nameMissing),addressLine1=VALUES(addressLine1),addressLine2=VALUES(addressLine2),city=VALUES(city),state=VALUES(state),zipCode=VALUES(zipCode),country=VALUES(country),phone=VALUES(phone),mobile=VALUES(mobile),fax=VALUES(fax),email=VALUES(email),notes=VALUES(notes),identificationType=VALUES(identificationType),identificationNumber=VALUES(identificationNumber),identificationExpiration=VALUES(identificationExpiration),customerSince=VALUES(customerSince),status=VALUES(status),feeAmount=VALUES(feeAmount),minimumBalance=VALUES(minimumBalance),updatedAt=VALUES(updatedAt)", tuple(rec[c] for c in _CUST_COLS))
cur.execute(f"INSERT INTO customers ({','.join(f'`{c}`' for c in _CUST_COLS)}) VALUES ({placeholders}) ON DUPLICATE KEY UPDATE name=VALUES(name),nameSource=VALUES(nameSource),nameMissing=VALUES(nameMissing),folderNumber=VALUES(folderNumber),addressLine1=VALUES(addressLine1),addressLine2=VALUES(addressLine2),city=VALUES(city),state=VALUES(state),zipCode=VALUES(zipCode),country=VALUES(country),phone=VALUES(phone),mobile=VALUES(mobile),fax=VALUES(fax),email=VALUES(email),notes=VALUES(notes),identificationType=VALUES(identificationType),identificationNumber=VALUES(identificationNumber),identificationExpiration=VALUES(identificationExpiration),customerSince=VALUES(customerSince),status=VALUES(status),feeAmount=VALUES(feeAmount),minimumBalance=VALUES(minimumBalance),updatedAt=VALUES(updatedAt)", tuple(rec[c] for c in _CUST_COLS))
for ref in refs:
cur.execute("INSERT INTO customer_legacy_refs (id,customerId,sourceSystem,sourceTable,legacyId) VALUES (%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE customerId=VALUES(customerId)",
(ref[0], remap[ref[1]], ref[2], ref[3], ref[4]))
@@ -374,6 +380,12 @@ def main() -> None:
recovered = cur.fetchall()
cur.execute("SELECT COUNT(*) FROM customers WHERE name = %s", (NO_NAME,))
still_unnamed = cur.fetchone()[0]
cur.execute("SELECT COUNT(folderNumber) FROM customers")
with_folder = cur.fetchone()[0]
cur.execute("SELECT COUNT(*) FROM (SELECT folderNumber FROM customers "
"WHERE archivedAt IS NULL AND folderNumber IS NOT NULL "
"GROUP BY folderNumber HAVING COUNT(*) > 1) d")
shared_folders = cur.fetchone()[0]
print("=== Customer load complete ===")
print(f" utilities DATGRAL rows : {len(util)}")
@@ -390,6 +402,8 @@ def main() -> None:
print(f" from {src:16} : {n}")
print(f" of which via the linked insurance record : {from_ins_side}")
print(f" still {NO_NAME} : {still_unnamed}")
print(f" folder numbers : {with_folder} (one per numeric utilities NUMid)")
print(f" held by >1 open customer (must be 0) : {shared_folders}")
if not sync_mode:
# Full-load invariants only: sync upserts into an already-loaded (and
# pruned) table, so these exact counts don't hold.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jorgecuadros-platform",
"version": "1.0.26",
"version": "1.1.0",
"private": true,
"workspaces": [
"apps/*",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@jorgecuadros/database",
"version": "1.0.26",
"version": "1.1.0",
"private": true,
"main": "generated/client/index.js",
"types": "generated/client/index.d.ts",
@@ -0,0 +1,20 @@
-- The office files every customer in a numbered paper folder and hands the
-- number back out once an account is closed. That number has been the legacy
-- utilities NUMid; it becomes a plain label here so reusing it never touches
-- an identity. Unique among non-archived customers only, enforced by the API.
ALTER TABLE `customers` ADD COLUMN `folderNumber` INTEGER NULL;
CREATE INDEX `customers_folderNumber_idx` ON `customers`(`folderNumber`);
-- Existing customers start with their utilities NUMid. Insurance-only
-- customers have none and get one from the office.
UPDATE `customers` c
JOIN (
SELECT `customerId`, MIN(CAST(`legacyId` AS UNSIGNED)) AS n
FROM `customer_legacy_refs`
WHERE `sourceSystem` = 'utilities'
AND `sourceTable` = 'DATGRAL'
AND `legacyId` REGEXP '^[0-9]+$'
GROUP BY `customerId`
) r ON r.`customerId` = c.`id`
SET c.`folderNumber` = r.n;
@@ -0,0 +1,30 @@
-- Two asks from the 2026-10-08 meeting, both on the OCR receipt queue:
-- * sin fondos per receipt, not only per check (`outstanding`);
-- * one receipt paid by several customers (411 + 523 sharing a water bill),
-- posted as one charge per part.
-- AlterTable
ALTER TABLE `statement_documents` ADD COLUMN `outstanding` BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE `statement_document_splits` (
`id` VARCHAR(191) NOT NULL,
`documentId` VARCHAR(191) NOT NULL,
`sequence` INTEGER NOT NULL,
`customerId` VARCHAR(191) NOT NULL,
`amount` DECIMAL(12, 2) NOT NULL,
`postedTransactionId` VARCHAR(191) NULL,
UNIQUE INDEX `statement_document_splits_postedTransactionId_key`(`postedTransactionId`),
UNIQUE INDEX `statement_document_splits_documentId_sequence_key`(`documentId`, `sequence`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `statement_document_splits` ADD CONSTRAINT `statement_document_splits_documentId_fkey` FOREIGN KEY (`documentId`) REFERENCES `statement_documents`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `statement_document_splits` ADD CONSTRAINT `statement_document_splits_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `statement_document_splits` ADD CONSTRAINT `statement_document_splits_postedTransactionId_fkey` FOREIGN KEY (`postedTransactionId`) REFERENCES `transactions`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
@@ -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;
+74
View File
@@ -89,6 +89,13 @@ model Customer {
// can sort nameless records last — ordering by `name` alone puts them first,
// since "(" sorts before every letter.
nameMissing Boolean @default(false)
// The number on the customer's paper folder on the office shelf. A label,
// not an identity: nothing joins on it. The office hands a number back out
// once an account is closed, so it is unique only among customers that are
// not archived — enforced by CustomersService, not by an index, because
// Prisma cannot model a partial/functional unique and would drop one.
// Imported customers start with their utilities NUMid.
folderNumber Int?
addressLine1 String?
addressLine2 String?
city String?
@@ -123,11 +130,14 @@ model Customer {
transactions Transaction[]
statementDocuments StatementDocument[]
statementSplits StatementDocumentSplit[]
cashReceipts CashReceipt[]
policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer")
emailNotificationLogs EmailNotificationLog[]
accountStatusHistory AccountStatusHistory[]
@@index([folderNumber])
@@map("customers")
}
@@ -722,6 +732,26 @@ model StatementBatch {
}
/// One statement — one customer, one period — after splitting the batch.
/// One customer's share of a split receipt — a water bill two neighbours
/// share, an HOA fee spread across a building. Free amounts, no stored
/// percentages: the office splits unevenly as often as not.
model StatementDocumentSplit {
id String @id @default(uuid())
documentId String
document StatementDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
/// 1-based; also the `#n` in the posted row's captureRef.
sequence Int
customerId String
customer Customer @relation(fields: [customerId], references: [id])
amount Decimal @db.Decimal(12, 2)
postedTransactionId String? @unique
postedTransaction Transaction? @relation("StatementDocumentSplitTransaction", fields: [postedTransactionId], references: [id])
@@unique([documentId, sequence])
@@map("statement_document_splits")
}
model StatementDocument {
id String @id @default(uuid())
batchId String
@@ -767,6 +797,15 @@ model StatementDocument {
postedTransactionId String? @unique
postedTransaction Transaction? @relation(fields: [postedTransactionId], references: [id])
/// Sin fondos for this receipt alone: posted as outstanding even when the
/// rest of the check is funded.
outstanding Boolean @default(false)
/// When set, the receipt is paid for by several customers and posts one
/// charge per part instead of one to `matchedCustomer`. The parts always sum
/// to `extractedAmount`.
splits StatementDocumentSplit[]
createdAt DateTime @default(now())
@@unique([batchId, pageNumber])
@@ -845,6 +884,8 @@ 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])
@@ -966,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")
@@ -1248,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<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")
}