feat(recibos): split a receipt, sin fondos per receipt, keep a batch open
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>
This commit is contained in:
1 parent
f655fcbb82
commit
fd5df240bc
9 files changed
+756
-49
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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)}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+25
-4
@@ -31,6 +31,7 @@ import type {
|
||||
UpdateBankAccountInput,
|
||||
ResolveOutstandingInput,
|
||||
ReviewDocumentInput,
|
||||
SplitPartInput,
|
||||
StatementBatch,
|
||||
StatementBatchDetail,
|
||||
StatementDocument,
|
||||
@@ -1380,17 +1381,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,
|
||||
@@ -1418,6 +1428,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" });
|
||||
}
|
||||
|
||||
@@ -1415,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 {
|
||||
@@ -1438,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 {
|
||||
@@ -1448,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. */
|
||||
@@ -1463,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;
|
||||
}
|
||||
|
||||
|
||||
+30
@@ -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;
|
||||
|
||||
@@ -130,7 +130,8 @@ model Customer {
|
||||
transactions Transaction[]
|
||||
|
||||
statementDocuments StatementDocument[]
|
||||
policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer")
|
||||
statementSplits StatementDocumentSplit[]
|
||||
policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer")
|
||||
|
||||
emailNotificationLogs EmailNotificationLog[]
|
||||
accountStatusHistory AccountStatusHistory[]
|
||||
@@ -730,6 +731,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
|
||||
@@ -775,6 +796,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])
|
||||
@@ -853,7 +883,8 @@ model Transaction {
|
||||
|
||||
/// Set only on OCR-posted rows — the statement page this came from.
|
||||
statementDocument StatementDocument?
|
||||
policyOcrDocument PolicyOcrDocument? @relation("PolicyOcrDocumentTransaction")
|
||||
statementSplit StatementDocumentSplit? @relation("StatementDocumentSplitTransaction")
|
||||
policyOcrDocument PolicyOcrDocument? @relation("PolicyOcrDocumentTransaction")
|
||||
|
||||
@@unique([legacySourceDb, legacySourceTable, legacyId])
|
||||
@@index([customerId, transactionDate])
|
||||
|
||||
Reference in new issue
Block a user