Compare commits

..
4 Commits
Author SHA1 Message Date
gitea-actions f7507f2370 chore(release): v1.0.26
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m50s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m26s
Deploy on tag / Deploy to galactus (push) Successful in 38s
Cut by rmancinas via the "Cut release" workflow. Pushing the tag triggers build.yml; deploy separately with tag=1.0.26.
2026-08-20 02:44:12 +00:00
rmancinasandClaude Opus 5 2f9a9afc0d fix(billing): the cash receipt book is not a second ledger
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m49s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m28s
EFECTIVO is a journal, not a ledger. The office writes a numbered paper
receipt for money handed over the counter and then posts that same receipt
to the utilities ledger as reference `C<folio>`. Legacy summed the ledger
alone — ledger_repository.php reads `datosfreak`, materialized from DATOS2
only — but the migration flattened both tables into one `transactions`
table, so every balance counted each counter payment twice.

Confirmed against the live legacy database rather than inferred: of the 297
receipts written in 2026, 296 carry a matching DATOS2 posting. Six of them
post converted to pesos under a mistyped folio, which is why matching pairs
on folio and amount found fewer duplicates than exist — and why this
excludes the whole journal instead of a list of confirmed pairs. Only folio
13536 (CL 717, $400 USD) has no posting anywhere; that one wants a human.

The database qualifier is load-bearing. `SEGUROS 16_be` keeps its own table
also called EFECTIVO, and that one is the insurance line's only ledger —
nothing posts it anywhere else. Excluding by table name alone would erase
55,444.95 USD and 63,957.78 MXN across 102 customers, 99 of whom have no
other rows at all. Extending the qualified rule to the statement and the
customer file also gives those 99 back a statement that is not empty.

The same queries were missing the archive window the statement already had,
so the worklist and the book also counted a closed year twice for customers
floored inside an archive.

Measured on production, utilities MXN: NUMid 6 and 173 unchanged to the
cent, 501 unchanged at -10,874.33 (still the portal's number), 10 drops
2,362.20 -> -1,137.80 (exactly the 3,500.00 duplicate), 295 drops
14,377.46 -> 4,377.46 — which is what his statement already said. The
worklist and the statement now agree, which is the point.

Left alone deliberately: the movement browser, which is inventory rather
than balance and should still show what was captured; and stats()'s
outstanding rows, which turn on a client decision that is still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:36:49 -07:00
gitea-actions 7e71a993d0 chore(release): v1.0.25
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m0s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m40s
Deploy on tag / Deploy to galactus (push) Successful in 1m9s
Cut by rmancinas via the "Cut release" workflow. Pushing the tag triggers build.yml; deploy separately with tag=1.0.25.
2026-08-20 00:31:07 +00:00
rmancinasandClaude Opus 5 aa5867c8ea fix(statements): an archive is history below the year start, not nothing
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m49s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m20s
c6feae9 excluded imported periods from the current period outright. That is
right for the rows an archive spills into the following January — those already
sit inside the next year's BALANCE FORWARD, which is the sum of the whole
archive, so counting them again would double-book them and file a closed year's
row as current.

It is wrong for everything below the year start. Those rows are what `opening`
exists for, and for a customer whose newest BALANCE FORWARD lives *inside* an
archive they are the only carry there is: the corte skipped NUMid 295 in 2026,
so his floor is the archive's own January 1 and excluding it dropped his entire
2025 closing balance. His statement read 3,592.00 against a true 4,377.46.

So the rule is a window, not an exclusion: an archive row counts below the year
start and never at or above it. Applied identically to statement(), the
edo-cuenta-datos report and the customer-file card, which have to agree.

Spelled as a positive OR rather than NOT(tag AND date). `NOT (col LIKE '...'
AND ...)` is NULL for a row with no legacySourceTable, so the negated form would
have silently dropped every app-captured movement — the same NULL trap the
source-table exclusion was already fixed for.

Verified against prod, which now carries datos2@2025: exactly one customer is
affected and the book moves by his 785.46. NUMid 501 is unchanged at -10,874.33
and still matches the portal; 6 and 173 are unchanged to the cent; the two
customers whose archives spill into January 2026 still keep those rows out of
the current period.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:29:08 -07:00
10 changed files with 323 additions and 132 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@jorgecuadros/api", "name": "@jorgecuadros/api",
"version": "1.0.24", "version": "1.0.26",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
+23 -9
View File
@@ -149,26 +149,40 @@ describe("balance floor", () => {
}); });
it("keeps the source-table exclusion alongside the floor", async () => { it("keeps the source-table exclusion alongside the floor", async () => {
// The two guards answer different questions — one reproduces legacy's // The three guards answer different questions — one windows imported
// DATOS2-only materialization, the other drops superseded history — and // periods, one drops the cash receipt book the ledger already posts, the
// dropping either one changes the customer's balance. // floor drops superseded history — and dropping any one of them changes
// the customer's balance.
const { service, findMany } = serviceWith(new Date("2026-01-01T00:00:00Z")); const { service, findMany } = serviceWith(new Date("2026-01-01T00:00:00Z"));
await service.statement("c1"); await service.statement("c1");
const where = rowsQuery(findMany).where; const where = rowsQuery(findMany).where;
expect(where.OR).toEqual([ // Imported periods are windowed rather than excluded: history below the
// year start (the only carry a floored-by-archive customer has), never
// at or above it (those rows sit inside the next BALANCE FORWARD).
expect(where.AND).toEqual([
{
OR: [
{ legacySourceTable: null },
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
{ transactionDate: { lt: expect.any(Date) } },
],
},
// The cash journal, qualified by the database it came from — the
// insurance line has its own EFECTIVO and that one is a real ledger.
{
OR: [
{ legacySourceDb: null },
{ legacySourceDb: { not: "UTILITIES" } },
{ legacySourceTable: null }, { legacySourceTable: null },
{ {
legacySourceTable: { legacySourceTable: {
notIn: expect.arrayContaining(["EFECTIVO"]), 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@" },
}, },
}, },
],
},
]); ]);
}); });
}); });
+140 -47
View File
@@ -209,18 +209,6 @@ export const BALANCE_FLOOR_JOIN = Prisma.sql`
*/ */
export const NOT_SUPERSEDED = Prisma.sql`(bfloor.floorDate IS NULL OR t.transactionDate >= bfloor.floorDate)`; export const NOT_SUPERSEDED = Prisma.sql`(bfloor.floorDate IS NULL OR t.transactionDate >= bfloor.floorDate)`;
/**
* Source tables excluded from the customer-facing statement.
*
* The legacy portal's `datosfreak` table was materialized from DATOS2 only
* (`objects.json:1358`), so the customer's "current balance" never saw
* EFECTIVO / EFECTIVO FM3 / CHEQUE FM3 / EFECTIVO_BACKUP cash receipts, nor
* the IVA 2015 snapshot. The unified `transactions` table has all of them, so
* the statement must drop them to match the legacy number the customer has
* been quoted for years. The staff-facing balances worklist and movement
* browser keep them — they're real money, just tracked separately
* (FM3 = visa fee stream, EFECTIVO = cash receipt stream).
*/
/** /**
* `legacySourceTable` of an imported prior period. * `legacySourceTable` of an imported prior period.
* *
@@ -235,7 +223,34 @@ export const periodSourceTable = (year: number) => `datos2@${year}`;
/** Matches any imported period tag, for discovering which years a customer has. */ /** Matches any imported period tag, for discovering which years a customer has. */
export const PERIOD_TABLE_PREFIX = "datos2@"; export const PERIOD_TABLE_PREFIX = "datos2@";
export const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [ /**
* The legacy cash receipt book — a journal, not a ledger.
*
* `EFECTIVO` is the office's numbered receipt pad: money is handed over the
* counter, a folio is written, and the same receipt is then *posted* to the
* utilities ledger (`DATOS2`) as reference `C<folio>`. Legacy summed the ledger
* alone — `ws/v2/lib/ledger_repository.php` reads `datosfreak`, which is
* materialized from DATOS2 only (`objects.json:1358`). The migration flattened
* both tables into one `transactions` table, so anything summing a customer's
* rows counts every cash receipt twice.
*
* Verified against the live legacy database on 2026-08-20: of the 297 receipts
* written in 2026, 296 carry a matching DATOS2 posting. Only folio 13536 (CL
* 717, $400 USD) has no posting anywhere, and it wants a human's eyes rather
* than a code change. Six receipts post converted to pesos under a mistyped
* folio, which is why matching on folio and amount found fewer duplicate pairs
* than actually exist — a reason to exclude the whole journal rather than to
* exclude a list of confirmed pairs.
*
* THE DATABASE QUALIFIER IS LOAD-BEARING. `SEGUROS 16_be` keeps its own table
* also called `EFECTIVO`, and that one is the insurance line's *only* ledger —
* nothing posts it anywhere else. Excluding by table name alone erases the
* whole insurance balance: 55,444.95 USD and 63,957.78 MXN across 102
* customers, 99 of whom have no other rows at all.
*/
export const CASH_JOURNAL_SOURCE_DB = "UTILITIES";
export const CASH_JOURNAL_SOURCE_TABLES: readonly string[] = [
"EFECTIVO", "EFECTIVO",
"EFECTIVO_BACKUP", "EFECTIVO_BACKUP",
"EFECTIVO FM3", "EFECTIVO FM3",
@@ -243,6 +258,53 @@ export const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [
"IVA 2015", "IVA 2015",
]; ];
/**
* Prisma form of the cash-journal exclusion.
*
* Spelled as a positive OR on purpose. `notIn` alone compiles to SQL `NOT IN`,
* and `NULL NOT IN (...)` is NULL rather than true, so every app-captured row
* (no `legacySourceTable`) would silently vanish. Same for the database test.
*/
export const notCashJournal = (): Prisma.TransactionWhereInput => ({
OR: [
{ legacySourceDb: null },
{ legacySourceDb: { not: CASH_JOURNAL_SOURCE_DB } },
{ legacySourceTable: null },
{ legacySourceTable: { notIn: [...CASH_JOURNAL_SOURCE_TABLES] } },
],
});
/** Raw-SQL form, for the aggregate queries that cannot use Prisma's builder. */
export const NOT_CASH_JOURNAL = Prisma.sql`(
t.legacySourceDb IS NULL
OR t.legacySourceDb <> ${CASH_JOURNAL_SOURCE_DB}
OR t.legacySourceTable IS NULL
OR t.legacySourceTable NOT IN (${Prisma.join([
...CASH_JOURNAL_SOURCE_TABLES,
])}))`;
/**
* Keeps an imported prior period out of the *current* period, NULL-safely.
*
* The balance floor does not settle the archives on its own, in both
* directions. A customer whose newest BALANCE FORWARD lives *inside* an archive
* floors at that archive's own January 1st, so every row of it clears the floor
* — and that is correct, because below the year start the archive is the only
* carry there is. At or above the year start it must go: the archives spill a
* couple of rows into the following January and those already sit inside the
* next year's BALANCE FORWARD, which is the sum of the whole archive.
*
* Spelled as a positive OR for the same NULL reason as above.
*/
export const archiveIsHistorySql = (yearStart: Date) => Prisma.sql`(
t.legacySourceTable IS NULL
OR t.legacySourceTable NOT LIKE ${`${PERIOD_TABLE_PREFIX}%`}
OR t.transactionDate < ${yearStart})`;
/** January 1st of the running year, UTC — the current period's lower bound. */
export const currentYearStart = () =>
new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1));
@Injectable() @Injectable()
export class BillingService { export class BillingService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
@@ -412,6 +474,16 @@ export class BillingService {
async balances(params: BalanceParams) { async balances(params: BalanceParams) {
const { query, page, pageSize, currency, balance, domain, sort } = params; const { query, page, pageSize, currency, balance, domain, sort } = params;
// A balance is what the customer owes, so it takes the same rules the
// statement takes: the floor, the cash journal, and the archive window.
// Without the last two the worklist quoted a different number than the
// customer's own statement — NUMid 295 read 14,377.46 against a statement
// of 4,377.46, and NUMid 10 read 2,362.20 against -1,137.80, the gap in
// each case being a cash receipt already posted to the ledger.
const scope = Prisma.sql`AND ${NOT_CASH_JOURNAL} AND ${archiveIsHistorySql(
currentYearStart(),
)}`;
const filters: Prisma.Sql[] = []; const filters: Prisma.Sql[] = [];
if (domain) filters.push(Prisma.sql`t.domain = ${domain}`); if (domain) filters.push(Prisma.sql`t.domain = ${domain}`);
const txFilter = filters.length const txFilter = filters.length
@@ -474,7 +546,7 @@ export class BillingService {
FROM customers c FROM customers c
JOIN transactions t ON t.customerId = c.id JOIN transactions t ON t.customerId = c.id
${BALANCE_FLOOR_JOIN} ${BALANCE_FLOOR_JOIN}
WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${nameFilter} ${txFilter} WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${scope} ${nameFilter} ${txFilter}
GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state
${having} ${having}
${orderBy} ${orderBy}
@@ -490,7 +562,7 @@ export class BillingService {
-- Must match the page query's filters exactly, or the total disagrees -- Must match the page query's filters exactly, or the total disagrees
-- with the rows. (The void exclusion was missing here before the -- with the rows. (The void exclusion was missing here before the
-- outstanding work; a voided-only customer inflated the count.) -- outstanding work; a voided-only customer inflated the count.)
WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${nameFilter} ${txFilter} WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${scope} ${nameFilter} ${txFilter}
GROUP BY c.id GROUP BY c.id
${having} ${having}
) x ) x
@@ -537,11 +609,22 @@ export class BillingService {
* Two different questions live here and they use different row sets. * Two different questions live here and they use different row sets.
* `movements`, `ledgerCustomers`, `crossLineCustomers` and the date range are * `movements`, `ledgerCustomers`, `crossLineCustomers` and the date range are
* INVENTORY — what is stored — and count everything not voided. Everything * INVENTORY — what is stored — and count everything not voided. Everything
* under `byCurrency` / `byDomain` is a BALANCE, so it applies NOT_SUPERSEDED * under `byCurrency` / `byDomain` is a BALANCE, so it takes the same scope
* and drops rows an opening balance already accounts for. The four aggregates * `balances()` takes — the opening-balance floor, the cash journal and the
* moved from Prisma groupBy to raw SQL to express that join; groupBy cannot. * archive window — and the book has to agree with the worklist that sits
* under it. The four aggregates moved from Prisma groupBy to raw SQL to
* express that join; groupBy cannot.
*
* KNOWN DIVERGENCE, left deliberately: these three do not drop outstanding
* rows, while `balances()` does. Reconciling them moves the book by about
* 1.95M MXN and turns on whether an unfunded charge is owed by the customer,
* which is the client's call and not settled yet.
*/ */
async stats() { async stats() {
const scope = Prisma.sql`AND ${NOT_CASH_JOURNAL} AND ${archiveIsHistorySql(
currentYearStart(),
)}`;
const [movements, ledgerCustomers] = await Promise.all([ const [movements, ledgerCustomers] = await Promise.all([
this.prisma.transaction.count({ where: NOT_VOIDED }), this.prisma.transaction.count({ where: NOT_VOIDED }),
this.prisma.transaction this.prisma.transaction
@@ -573,7 +656,7 @@ export class BillingService {
SUM(t.amount > 0) AS creditCount SUM(t.amount > 0) AS creditCount
FROM transactions t FROM transactions t
${BALANCE_FLOOR_JOIN} ${BALANCE_FLOOR_JOIN}
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} ${scope}
GROUP BY t.currency GROUP BY t.currency
`; `;
@@ -589,7 +672,7 @@ export class BillingService {
SUM(t.amount) AS net, COUNT(*) AS count SUM(t.amount) AS net, COUNT(*) AS count
FROM transactions t FROM transactions t
${BALANCE_FLOOR_JOIN} ${BALANCE_FLOOR_JOIN}
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} ${scope}
GROUP BY t.domain, t.currency GROUP BY t.domain, t.currency
`; `;
@@ -610,7 +693,7 @@ export class BillingService {
SELECT t.customerId, t.currency, SUM(t.amount) AS bal SELECT t.customerId, t.currency, SUM(t.amount) AS bal
FROM transactions t FROM transactions t
${BALANCE_FLOOR_JOIN} ${BALANCE_FLOOR_JOIN}
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} ${scope}
GROUP BY t.customerId, t.currency GROUP BY t.customerId, t.currency
) x ) x
GROUP BY currency GROUP BY currency
@@ -784,6 +867,19 @@ export class BillingService {
} }
const isArchive = requested !== thisYear; const isArchive = requested !== thisYear;
//
// Deliberately open-ended at the top. A period is a table in legacy, not a
// date range, so whatever the office filed in it belongs to it — including
// the future-dated rows the current ledger carries (it runs to 2028). An
// upper bound would hide them from every view, which is not what legacy did
// and not what the office has been reading.
//
// An archive needs no fold at all: it *is* the period, and its own Jan-1
// BALANCE FORWARD row is the carry, listed exactly as legacy listed it.
const yearStart = isArchive
? new Date(0)
: new Date(Date.UTC(requested, 0, 1));
// One customer, so the balance floor is a single date rather than the // One customer, so the balance floor is a single date rather than the
// derived table the aggregate queries join. See NOT_SUPERSEDED: rows before // derived table the aggregate queries join. See NOT_SUPERSEDED: rows before
// the opening balance are already inside it, and showing them would both // the opening balance are already inside it, and showing them would both
@@ -819,26 +915,36 @@ export class BillingService {
{ legacySourceTable: periodSourceTable(requested) } { legacySourceTable: periodSourceTable(requested) }
: { : {
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}), ...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
// NULL-safe exclusion. `notIn` alone compiles to SQL `NOT IN`, and // An archive row belongs to this period only as history. Below
// `NULL NOT IN (...)` is NULL, not true — so every app-captured row // the year start it is exactly what `opening` is for, and for the
// (which has no legacySourceTable) silently vanished from the // one customer whose newest BALANCE FORWARD lives *inside* an
// statement while still showing in the movement browser. Rows the app // archive it is the only carry there is — dropping it outright
// books must appear on the customer's statement, so the null case is // understated NUMid 295 by his whole 2025 closing balance, 785.46.
// spelled out. //
// At or above the year start it must go. The archives spill a
// couple of rows into the following January, and those are
// already inside the next year's BALANCE FORWARD (which is the
// sum of the whole archive), so listing them here would both
// double-count and file a closed year's row as current.
//
// Spelled as a positive OR because `NOT (col LIKE ... AND ...)`
// is NULL for an app-captured row, which would drop every one.
AND: [
{
OR: [ OR: [
{ legacySourceTable: null }, { legacySourceTable: null },
{ {
legacySourceTable: { 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 }, not: { startsWith: PERIOD_TABLE_PREFIX },
}, },
}, },
{ transactionDate: { lt: yearStart } },
],
},
// The cash receipt book is the ledger's own postings written a
// second time, so listing it here would show every counter
// payment twice and double the credit side.
notCashJournal(),
], ],
}), }),
}, },
@@ -867,19 +973,6 @@ export class BillingService {
// opening balance), the earlier rows still have to be *counted* or every // opening balance), the earlier rows still have to be *counted* or every
// balance below is wrong, so they are folded into `opening` rather than // balance below is wrong, so they are folded into `opening` rather than
// listed. That is the same thing a BALANCE FORWARD row does, just computed. // listed. That is the same thing a BALANCE FORWARD row does, just computed.
//
// Deliberately open-ended at the top. A period is a table in legacy, not a
// date range, so whatever the office filed in it belongs to it — including
// the future-dated rows the current ledger carries (it runs to 2028). An
// upper bound would hide them from every view, which is not what legacy did
// and not what the office has been reading.
//
// An archive needs no fold at all: it *is* the period, and its own Jan-1
// BALANCE FORWARD row is the carry, listed exactly as legacy listed it.
const yearStart = isArchive
? new Date(0)
: new Date(Date.UTC(requested, 0, 1));
const running = new Map<string, Prisma.Decimal>(); const running = new Map<string, Prisma.Decimal>();
/** Balance carried into `yearStart`, per currency. */ /** Balance carried into `yearStart`, per currency. */
const opening = new Map<string, Prisma.Decimal>(); const opening = new Map<string, Prisma.Decimal>();
+77
View File
@@ -0,0 +1,77 @@
import { Prisma } from "@jorgecuadros/database";
import {
CASH_JOURNAL_SOURCE_DB,
CASH_JOURNAL_SOURCE_TABLES,
NOT_CASH_JOURNAL,
notCashJournal,
} from "./billing.service";
/**
* `EFECTIVO` is the office's paper receipt book, and every receipt in it is
* also posted to the utilities ledger as `C<folio>`. Both copies were imported
* into one `transactions` table, so a balance that reads the journal counts
* each counter payment twice — 1,094,347.78 MXN of phantom credit book-wide,
* and 3,500.00 of it on NUMid 10 alone.
*
* These fail silently in the worst way: the numbers stay plausible, they are
* just too generous to the customer. Two shapes of mistake are easy to make
* here and both are covered below — dropping the database qualifier (which
* erases the insurance line's only ledger) and writing the exclusion as a bare
* `NOT IN` (which erases every app-captured row).
*/
describe("cash journal exclusion", () => {
describe("raw SQL form", () => {
it("binds the source database rather than interpolating it", () => {
expect(NOT_CASH_JOURNAL.values).toContain(CASH_JOURNAL_SOURCE_DB);
});
it("qualifies the table names with the database they came from", () => {
// `SEGUROS 16_be` has its own EFECTIVO and it is the insurance line's
// ONLY ledger — nothing posts it anywhere else. Matching on the table
// name alone erases 55,444.95 USD and 63,957.78 MXN across 102 customers.
expect(NOT_CASH_JOURNAL.sql).toContain("t.legacySourceDb <>");
expect(NOT_CASH_JOURNAL.values).toContain(CASH_JOURNAL_SOURCE_DB);
});
it("spells both null cases out instead of relying on NOT IN", () => {
// `NULL NOT IN (...)` is NULL, not true. Without these branches every
// app-captured row — the ones staff key in by hand — drops out of the
// balance while still showing in the movement browser.
expect(NOT_CASH_JOURNAL.sql).toContain("t.legacySourceDb IS NULL");
expect(NOT_CASH_JOURNAL.sql).toContain("t.legacySourceTable IS NULL");
});
it("covers the whole cash family, not just EFECTIVO", () => {
for (const table of CASH_JOURNAL_SOURCE_TABLES) {
expect(NOT_CASH_JOURNAL.values).toContain(table);
}
});
it("is a single parenthesised term, safe to AND into a WHERE clause", () => {
// It is composed as `... AND ${NOT_CASH_JOURNAL} AND ...`. An unbracketed
// OR chain would swallow every condition after it and silently widen the
// whole query to the entire table.
const sql = NOT_CASH_JOURNAL.sql.trim();
expect(sql.startsWith("(")).toBe(true);
expect(sql.endsWith(")")).toBe(true);
});
});
describe("Prisma form", () => {
it("matches the raw form's terms so the two cannot drift apart", () => {
const branches = notCashJournal().OR as Prisma.TransactionWhereInput[];
expect(branches).toEqual([
{ legacySourceDb: null },
{ legacySourceDb: { not: CASH_JOURNAL_SOURCE_DB } },
{ legacySourceTable: null },
{ legacySourceTable: { notIn: [...CASH_JOURNAL_SOURCE_TABLES] } },
]);
});
it("returns a fresh object each call", () => {
// It is spread into `AND: [...]` arrays that Prisma may mutate; a shared
// singleton would leak one query's filters into the next.
expect(notCashJournal()).not.toBe(notCashJournal());
});
});
});
@@ -60,24 +60,25 @@ describe("customer file ledger card", () => {
expect(groupBy.mock.calls[0][0].where).not.toHaveProperty("transactionDate"); expect(groupBy.mock.calls[0][0].where).not.toHaveProperty("transactionDate");
}); });
it("excludes imported periods from the totals", async () => { it("counts an archive as history but never as current", async () => {
// The floor alone is not enough: it does not exist for the floorless, and // The floor alone is not enough: a customer floored by an archive clears
// archives carry rows dated past their own period that clear it. // it with every row of that archive, and the rows archives spill into the
// following January clear any floor. But excluding archives outright is
// wrong too — below the year start they are the only carry a
// floored-by-archive customer has (NUMid 295, 785.46).
const { service, groupBy } = serviceWith(new Date("2026-01-01T00:00:00Z")); const { service, groupBy } = serviceWith(new Date("2026-01-01T00:00:00Z"));
await service.detail("c1"); await service.detail("c1");
const and = groupBy.mock.calls[0][0].where.AND; const and = groupBy.mock.calls[0][0].where.AND;
expect(and).toEqual( const rule = and.find((c: { OR?: unknown[] }) =>
expect.arrayContaining([ JSON.stringify(c).includes("datos2@"),
{ );
OR: [ expect(rule.OR).toEqual([
{ legacySourceTable: null }, { legacySourceTable: null },
{ legacySourceTable: { not: { startsWith: "datos2@" } } }, { legacySourceTable: { not: { startsWith: "datos2@" } } },
], { transactionDate: { lt: expect.any(Date) } },
}, ]);
]),
);
}); });
it("keeps the cash-source exclusion so it reads like the statement", async () => { it("keeps the cash-source exclusion so it reads like the statement", async () => {
@@ -110,11 +111,12 @@ describe("customer file ledger card", () => {
await service.detail("c1"); await service.detail("c1");
const include = prisma.customer.findUnique.mock.calls[0][0].include; const include = prisma.customer.findUnique.mock.calls[0][0].include;
expect(include.transactions.where).toMatchObject({ expect(include.transactions.where.OR).toEqual([
OR: [
{ legacySourceTable: null }, { legacySourceTable: null },
{ legacySourceTable: { not: { startsWith: "datos2@" } } }, { legacySourceTable: { not: { startsWith: "datos2@" } } },
], // Nothing below yearStart reaches this list, so the third branch never
}); // admits an archive row here — it is carried for one shared rule.
{ transactionDate: { lt: expect.any(Date) } },
]);
}); });
}); });
+29 -28
View File
@@ -5,27 +5,33 @@ import { CreateCustomerDto } from "./create-customer.dto";
import { UpdateCustomerDto } from "./update-customer.dto"; import { UpdateCustomerDto } from "./update-customer.dto";
import { import {
BALANCE_FORWARD_TYPE, BALANCE_FORWARD_TYPE,
notCashJournal,
PERIOD_TABLE_PREFIX, PERIOD_TABLE_PREFIX,
STATEMENT_EXCLUDED_SOURCE_TABLES,
} from "../billing/billing.service"; } from "../billing/billing.service";
/** /**
* Keeps imported prior periods out of a query, NULL-safely. * Keeps an imported prior period out of the *current* period, NULL-safely.
* *
* A closed year is imported as its own tagged copy of that year's ledger * A closed year is imported as its own tagged copy (`datos2@2025`). Below the
* (`datos2@2025`) and the BALANCE FORWARD rows above it already contain every * year start it is history and counts — for the one customer whose newest
* peso of it. Anything summing a customer's history has to leave the archives * BALANCE FORWARD lives inside an archive it is the only carry there is, and
* out or it counts each closed year twice — see the floor comment below. * dropping it understated NUMid 295 by his entire 2025 closing balance. At or
* above the year start it must go: the archives spill a couple of rows into the
* following January, and those already sit inside the next year's BALANCE
* FORWARD, which is the sum of the whole archive.
* *
* `NULL NOT LIKE '...'` is NULL rather than true, so app-captured rows (which * Spelled as a positive OR because `NOT (col LIKE ... AND ...)` evaluates to
* carry no legacySourceTable) need the null branch spelled out or they vanish. * NULL for an app-captured row (no legacySourceTable), dropping every one.
*/ */
const EXCLUDE_ARCHIVES: Prisma.TransactionWhereInput = { const archiveIsHistory = (
yearStart: Date,
): Prisma.TransactionWhereInput => ({
OR: [ OR: [
{ legacySourceTable: null }, { legacySourceTable: null },
{ legacySourceTable: { not: { startsWith: PERIOD_TABLE_PREFIX } } }, { legacySourceTable: { not: { startsWith: PERIOD_TABLE_PREFIX } } },
{ transactionDate: { lt: yearStart } },
], ],
}; });
export interface ListParams { export interface ListParams {
query?: string; query?: string;
@@ -152,7 +158,15 @@ export class CustomersService {
// in 2026, datos2@2025 two more — so a date test alone would surface // 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 // a closed year's rows in the current year's list, duplicating the
// live ledger's own copy of them for three customers. // live ledger's own copy of them for three customers.
where: { transactionDate: { gte: yearStart }, ...EXCLUDE_ARCHIVES }, // Archives are kept out by tag, not by date. They are not cleanly
// bounded — datos2@2025 carries rows dated into 2026 — so a date test
// alone would surface a closed year's rows in the current year's
// list. Nothing below yearStart reaches this list anyway, so the
// window rule reduces to a plain exclusion here.
where: {
transactionDate: { gte: yearStart },
...archiveIsHistory(yearStart),
},
orderBy: [{ transactionDate: "asc" }, { id: "asc" }], orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
include: { type: true }, include: { type: true },
}, },
@@ -197,23 +211,10 @@ export class CustomersService {
voidedAt: null, voidedAt: null,
outstanding: false, outstanding: false,
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}), ...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
// The floor alone would leave the archives out for anyone who has an // The floor alone does not settle the archives: a customer floored by
// opening balance, but 102 customers have none — for them there is no // an archive clears it with every row of that archive, and the rows
// floor at all, and the archives' rows dated past their own period // archives spill into the following January clear any floor.
// clear it even for the rest. AND: [archiveIsHistory(yearStart), notCashJournal()],
AND: [
EXCLUDE_ARCHIVES,
{
OR: [
{ legacySourceTable: null },
{
legacySourceTable: {
notIn: [...STATEMENT_EXCLUDED_SOURCE_TABLES],
},
},
],
},
],
}, },
_sum: { amount: true }, _sum: { amount: true },
_count: { _all: true }, _count: { _all: true },
+18 -14
View File
@@ -17,6 +17,7 @@
import { Prisma } from "@jorgecuadros/database"; import { Prisma } from "@jorgecuadros/database";
import { import {
BALANCE_FORWARD_TYPE, BALANCE_FORWARD_TYPE,
notCashJournal,
periodSourceTable, periodSourceTable,
} from "../billing/billing.service"; } from "../billing/billing.service";
import { import {
@@ -835,25 +836,28 @@ const edoCuentaDatos: ReportDef = {
{ legacySourceTable: periodSourceTable(requestedYear) } { legacySourceTable: periodSourceTable(requestedYear) }
: { : {
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}), ...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
// NULL-safe: `NULL NOT IN (...)` is NULL, not true, so a bare `notIn` // Archive rows count as history below the year start (that is
// drops every app-captured row (they have no legacySourceTable) — the // what `opening` is for, and for a customer floored by an archive
// same defect this report's on-screen twin was fixed for. // it is the only carry there is) and are dropped at or above it.
// Same rule as the on-screen twin — see BillingService.statement.
AND: [
{
OR: [ OR: [
{ legacySourceTable: null }, { legacySourceTable: null },
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
{ {
legacySourceTable: { transactionDate: {
notIn: [ lt: new Date(Date.UTC(requestedYear, 0, 1)),
"EFECTIVO", },
"EFECTIVO_BACKUP", },
"EFECTIVO FM3",
"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@" },
},
}, },
// The cash receipt book, which the ledger already carries as
// its own `C<folio>` postings. Taken from the shared helper
// rather than restated, so the printed statement and the screen
// cannot drift apart — and so this keeps the database
// qualifier that spares the insurance line's own EFECTIVO.
notCashJournal(),
], ],
}), }),
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@jorgecuadros/web", "name": "@jorgecuadros/web",
"version": "1.0.24", "version": "1.0.26",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 4500", "dev": "next dev -p 4500",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jorgecuadros-platform", "name": "jorgecuadros-platform",
"version": "1.0.24", "version": "1.0.26",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@jorgecuadros/database", "name": "@jorgecuadros/database",
"version": "1.0.24", "version": "1.0.26",
"private": true, "private": true,
"main": "generated/client/index.js", "main": "generated/client/index.js",
"types": "generated/client/index.d.ts", "types": "generated/client/index.d.ts",