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