fix(customers): the customer file's balances are the statement's balances
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:
@@ -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 },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user