fix(customers): the customer file's balances are the statement's balances
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m41s
Build and Push Images / Build jorgecuadros-api (push) Successful in 3m7s

The /clientes/:id ledger card is titled "Estado de cuenta" and links straight to
the statement, but its per-line tiles came from a groupBy with `voidedAt: null`
and nothing else — no balance floor, no source exclusion, no outstanding rule.
It was a raw lifetime sum, double-counting the pre-cutover history that each
BALANCE FORWARD row already absorbs, and the card said so in its own footnote
rather than being fixed.

Importing prior periods turned that from wrong into badly wrong. Every closed
year is now also held as its own tagged copy, so an unfloored sum adds each one
a second time on top of the opening balance that contains it. NUMid 501 read
-7,119.29 before the archives landed and -15,270.59 after, against a true
-10,715.29 — the gap being exactly the 2024 and 2025 closing balances.

The tiles now take the same three rules the statement takes, and agree with it
for all 600 customers sampled.

Two places needed the archives excluded explicitly, because the balance floor
does not do it:

  - A customer whose newest BALANCE FORWARD lives inside an archive floors at
    that archive's own January 1, so every row of it clears the floor. One
    customer, 28 rows.
  - The archives are not cleanly bounded. datos2@2024 carries rows dated 2022,
    2023, 2025 and one in 2026; datos2@2025 two more. Those clear any floor and
    land in the current year next to the live ledger's own copy of them.

That second point is a defect in 93f8171, not only in this card: statement()
and the edo-cuenta-datos report were both filtering the current period by
source and date without excluding the period tags, so four customers on the
dev book would have read a closed year's rows as current. Both are fixed here.
The portal already had it right.

The year's movement list on the card keeps showing every non-archive row, as
before — it is a list of what happened, not a balance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 16:19:06 -07:00
co-authored by Claude Opus 5
parent 93f817158e
commit c6feae9522
6 changed files with 221 additions and 7 deletions
+10 -1
View File
@@ -159,7 +159,16 @@ describe("balance floor", () => {
const where = rowsQuery(findMany).where;
expect(where.OR).toEqual([
{ legacySourceTable: null },
{ legacySourceTable: { notIn: expect.arrayContaining(["EFECTIVO"]) } },
{
legacySourceTable: {
notIn: expect.arrayContaining(["EFECTIVO"]),
// Imported prior periods are excluded here too. The floor does not
// cover them: a customer whose newest BALANCE FORWARD sits inside
// an archive floors at that archive's own Jan 1, and every archive
// spills a row or two into the following January.
not: { startsWith: "datos2@" },
},
},
]);
});
});
+9 -2
View File
@@ -233,9 +233,9 @@ export const NOT_SUPERSEDED = Prisma.sql`(bfloor.floorDate IS NULL OR t.transact
export const periodSourceTable = (year: number) => `datos2@${year}`;
/** Matches any imported period tag, for discovering which years a customer has. */
const PERIOD_TABLE_PREFIX = "datos2@";
export const PERIOD_TABLE_PREFIX = "datos2@";
const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [
export const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [
"EFECTIVO",
"EFECTIVO_BACKUP",
"EFECTIVO FM3",
@@ -830,6 +830,13 @@ export class BillingService {
{
legacySourceTable: {
notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[],
// The archives have to go too, and the floor will not do
// it. A customer whose newest BALANCE FORWARD lives inside
// an archive floors at that archive's own Jan 1, so all 28
// of its rows clear it; and the archives spill rows into
// the following January, which clears any floor. Both put a
// closed year back into the current one.
not: { startsWith: PERIOD_TABLE_PREFIX },
},
},
],
@@ -0,0 +1,120 @@
import { CustomersService } from "./customers.service";
import { BALANCE_FORWARD_TYPE } from "../billing/billing.service";
/**
* The /clientes/:id ledger card is titled "Estado de cuenta" and links straight
* to the statement, so its per-line totals must be the statement's numbers.
*
* They were a raw lifetime sum — no floor, no source exclusion — which
* double-counted the pre-cutover history each BALANCE FORWARD row absorbs.
* Importing prior periods made it visibly worse: every closed year is now held
* a second time as its own tagged copy, so an unfloored sum adds each one on
* top of the opening balance that already contains it.
*/
describe("customer file ledger card", () => {
function serviceWith(floor: Date | null) {
const groupBy = jest.fn().mockResolvedValue([]);
const prisma = {
customer: {
findUnique: jest.fn().mockResolvedValue({ id: "c1", transactions: [] }),
},
transaction: {
findFirst: jest
.fn()
.mockResolvedValue(floor ? { transactionDate: floor } : null),
groupBy,
},
};
return {
service: new CustomersService(prisma as never),
prisma,
groupBy,
};
}
it("takes the same balance floor the statement takes", async () => {
const floor = new Date("2026-01-01T00:00:00Z");
const { service, prisma, groupBy } = serviceWith(floor);
await service.detail("c1");
expect(prisma.transaction.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
type: { nameEn: BALANCE_FORWARD_TYPE },
}),
}),
);
expect(groupBy.mock.calls[0][0].where).toMatchObject({
transactionDate: { gte: floor },
});
});
it("applies no floor when the customer never had an opening balance", async () => {
// 102 customers have no BALANCE FORWARD row at all. Inventing a floor for
// them would hide their whole ledger.
const { service, groupBy } = serviceWith(null);
await service.detail("c1");
expect(groupBy.mock.calls[0][0].where).not.toHaveProperty("transactionDate");
});
it("excludes imported periods from the totals", async () => {
// The floor alone is not enough: it does not exist for the floorless, and
// archives carry rows dated past their own period that clear it.
const { service, groupBy } = serviceWith(new Date("2026-01-01T00:00:00Z"));
await service.detail("c1");
const and = groupBy.mock.calls[0][0].where.AND;
expect(and).toEqual(
expect.arrayContaining([
{
OR: [
{ legacySourceTable: null },
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
],
},
]),
);
});
it("keeps the cash-source exclusion so it reads like the statement", async () => {
const { service, groupBy } = serviceWith(new Date("2026-01-01T00:00:00Z"));
await service.detail("c1");
const and = groupBy.mock.calls[0][0].where.AND;
const sourceRule = and.find((c: { OR?: unknown[] }) =>
JSON.stringify(c).includes("EFECTIVO"),
);
expect(sourceRule).toBeDefined();
});
it("drops outstanding rows, as every balance does", async () => {
const { service, groupBy } = serviceWith(null);
await service.detail("c1");
expect(groupBy.mock.calls[0][0].where).toMatchObject({
outstanding: false,
});
});
it("keeps archives out of the year's movement list too", async () => {
// datos2@2024 carries rows dated into 2026; a date test alone would show
// them as current-year movements next to the live ledger's own copy.
const { service, prisma } = serviceWith(null);
await service.detail("c1");
const include = prisma.customer.findUnique.mock.calls[0][0].include;
expect(include.transactions.where).toMatchObject({
OR: [
{ legacySourceTable: null },
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
],
});
});
});
+77 -3
View File
@@ -3,6 +3,29 @@ import { Prisma } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
import { CreateCustomerDto } from "./create-customer.dto";
import { UpdateCustomerDto } from "./update-customer.dto";
import {
BALANCE_FORWARD_TYPE,
PERIOD_TABLE_PREFIX,
STATEMENT_EXCLUDED_SOURCE_TABLES,
} from "../billing/billing.service";
/**
* Keeps imported prior periods out of a query, NULL-safely.
*
* A closed year is imported as its own tagged copy of that year's ledger
* (`datos2@2025`) and the BALANCE FORWARD rows above it already contain every
* peso of it. Anything summing a customer's history has to leave the archives
* out or it counts each closed year twice — see the floor comment below.
*
* `NULL NOT LIKE '...'` is NULL rather than true, so app-captured rows (which
* carry no legacySourceTable) need the null branch spelled out or they vanish.
*/
const EXCLUDE_ARCHIVES: Prisma.TransactionWhereInput = {
OR: [
{ legacySourceTable: null },
{ legacySourceTable: { not: { startsWith: PERIOD_TABLE_PREFIX } } },
],
};
export interface ListParams {
query?: string;
@@ -124,7 +147,12 @@ export class CustomersService {
},
},
transactions: {
where: { transactionDate: { gte: yearStart } },
// Archives are excluded by tag, not by date. They are not cleanly
// bounded — datos2@2024 carries rows dated 2022, 2023, 2025 and one
// in 2026, datos2@2025 two more — so a date test alone would surface
// a closed year's rows in the current year's list, duplicating the
// live ledger's own copy of them for three customers.
where: { transactionDate: { gte: yearStart }, ...EXCLUDE_ARCHIVES },
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
include: { type: true },
},
@@ -137,10 +165,56 @@ export class CustomersService {
// Ledger totals per domain + currency (the "one statement across both
// business lines" payoff), computed in the DB rather than in JS.
//
// These have to answer the same question BillingService.statement answers,
// because this card is titled "Estado de cuenta" and links straight to it —
// two screens quoting one customer two different balances is worse than
// either number alone. So it takes the same three rules the statement uses:
// the balance floor, the cash-source exclusion, and dropping outstanding
// rows the office has not paid yet.
//
// Without the floor these were a raw lifetime sum, double-counting the
// pre-cutover history each BALANCE FORWARD row already absorbs. Importing
// prior periods made that visibly worse: for NUMid 501 the tiles read
// -7,119.29 before the archives landed and -15,270.59 after, against a true
// -10,715.29 — the difference being exactly the 2024 and 2025 closing
// balances, added a second time on top of the opening row that contains
// them.
const floor = await this.prisma.transaction.findFirst({
where: {
customerId: id,
voidedAt: null,
type: { nameEn: BALANCE_FORWARD_TYPE },
},
orderBy: { transactionDate: "desc" },
select: { transactionDate: true },
});
const summary = await this.prisma.transaction.groupBy({
by: ["domain", "currency"],
// Exclude voided rows so the per-domain balance matches the statement.
where: { customerId: id, voidedAt: null },
where: {
customerId: id,
voidedAt: null,
outstanding: false,
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
// The floor alone would leave the archives out for anyone who has an
// opening balance, but 102 customers have none — for them there is no
// floor at all, and the archives' rows dated past their own period
// clear it even for the rest.
AND: [
EXCLUDE_ARCHIVES,
{
OR: [
{ legacySourceTable: null },
{
legacySourceTable: {
notIn: [...STATEMENT_EXCLUDED_SOURCE_TABLES],
},
},
],
},
],
},
_sum: { amount: true },
_count: { _all: true },
});
+3
View File
@@ -849,6 +849,9 @@ const edoCuentaDatos: ReportDef = {
"CHEQUE FM3",
"IVA 2015",
],
// Archives out of the current period too — the balance
// floor does not exclude them (see the on-screen twin).
not: { startsWith: "datos2@" },
},
},
],