Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7507f2370 | ||
|
|
2f9a9afc0d | ||
|
|
7e71a993d0 | ||
|
|
aa5867c8ea |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/api",
|
||||
"version": "1.0.24",
|
||||
"version": "1.0.26",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
|
||||
@@ -149,26 +149,40 @@ describe("balance floor", () => {
|
||||
});
|
||||
|
||||
it("keeps the source-table exclusion alongside the floor", async () => {
|
||||
// The two guards answer different questions — one reproduces legacy's
|
||||
// DATOS2-only materialization, the other drops superseded history — and
|
||||
// dropping either one changes the customer's balance.
|
||||
// The three guards answer different questions — one windows imported
|
||||
// periods, one drops the cash receipt book the ledger already posts, the
|
||||
// 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"));
|
||||
|
||||
await service.statement("c1");
|
||||
|
||||
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: {
|
||||
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@" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)`;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -235,7 +223,34 @@ export const periodSourceTable = (year: number) => `datos2@${year}`;
|
||||
/** Matches any imported period tag, for discovering which years a customer has. */
|
||||
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_BACKUP",
|
||||
"EFECTIVO FM3",
|
||||
@@ -243,6 +258,53 @@ export const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [
|
||||
"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()
|
||||
export class BillingService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -412,6 +474,16 @@ export class BillingService {
|
||||
async balances(params: BalanceParams) {
|
||||
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[] = [];
|
||||
if (domain) filters.push(Prisma.sql`t.domain = ${domain}`);
|
||||
const txFilter = filters.length
|
||||
@@ -474,7 +546,7 @@ export class BillingService {
|
||||
FROM customers c
|
||||
JOIN transactions t ON t.customerId = c.id
|
||||
${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
|
||||
${having}
|
||||
${orderBy}
|
||||
@@ -490,7 +562,7 @@ export class BillingService {
|
||||
-- Must match the page query's filters exactly, or the total disagrees
|
||||
-- with the rows. (The void exclusion was missing here before the
|
||||
-- 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
|
||||
${having}
|
||||
) x
|
||||
@@ -537,11 +609,22 @@ export class BillingService {
|
||||
* Two different questions live here and they use different row sets.
|
||||
* `movements`, `ledgerCustomers`, `crossLineCustomers` and the date range are
|
||||
* INVENTORY — what is stored — and count everything not voided. Everything
|
||||
* under `byCurrency` / `byDomain` is a BALANCE, so it applies NOT_SUPERSEDED
|
||||
* and drops rows an opening balance already accounts for. The four aggregates
|
||||
* moved from Prisma groupBy to raw SQL to express that join; groupBy cannot.
|
||||
* under `byCurrency` / `byDomain` is a BALANCE, so it takes the same scope
|
||||
* `balances()` takes — the opening-balance floor, the cash journal and the
|
||||
* 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() {
|
||||
const scope = Prisma.sql`AND ${NOT_CASH_JOURNAL} AND ${archiveIsHistorySql(
|
||||
currentYearStart(),
|
||||
)}`;
|
||||
|
||||
const [movements, ledgerCustomers] = await Promise.all([
|
||||
this.prisma.transaction.count({ where: NOT_VOIDED }),
|
||||
this.prisma.transaction
|
||||
@@ -573,7 +656,7 @@ export class BillingService {
|
||||
SUM(t.amount > 0) AS creditCount
|
||||
FROM transactions t
|
||||
${BALANCE_FLOOR_JOIN}
|
||||
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED}
|
||||
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} ${scope}
|
||||
GROUP BY t.currency
|
||||
`;
|
||||
|
||||
@@ -589,7 +672,7 @@ export class BillingService {
|
||||
SUM(t.amount) AS net, COUNT(*) AS count
|
||||
FROM transactions t
|
||||
${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
|
||||
`;
|
||||
|
||||
@@ -610,7 +693,7 @@ export class BillingService {
|
||||
SELECT t.customerId, t.currency, SUM(t.amount) AS bal
|
||||
FROM transactions t
|
||||
${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
|
||||
) x
|
||||
GROUP BY currency
|
||||
@@ -784,6 +867,19 @@ export class BillingService {
|
||||
}
|
||||
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
|
||||
// derived table the aggregate queries join. See NOT_SUPERSEDED: rows before
|
||||
// the opening balance are already inside it, and showing them would both
|
||||
@@ -819,26 +915,36 @@ export class BillingService {
|
||||
{ legacySourceTable: periodSourceTable(requested) }
|
||||
: {
|
||||
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||
// NULL-safe exclusion. `notIn` alone compiles to SQL `NOT IN`, and
|
||||
// `NULL NOT IN (...)` is NULL, not true — so every app-captured row
|
||||
// (which has no legacySourceTable) silently vanished from the
|
||||
// statement while still showing in the movement browser. Rows the app
|
||||
// books must appear on the customer's statement, so the null case is
|
||||
// spelled out.
|
||||
// An archive row belongs to this period only as history. Below
|
||||
// the year start it is exactly what `opening` is for, and for the
|
||||
// one customer whose newest BALANCE FORWARD lives *inside* an
|
||||
// archive it is the only carry there is — dropping it outright
|
||||
// understated NUMid 295 by his whole 2025 closing balance, 785.46.
|
||||
//
|
||||
// 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: [
|
||||
{ legacySourceTable: null },
|
||||
{
|
||||
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 },
|
||||
},
|
||||
},
|
||||
{ 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
|
||||
// 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.
|
||||
//
|
||||
// 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>();
|
||||
/** Balance carried into `yearStart`, per currency. */
|
||||
const opening = new Map<string, Prisma.Decimal>();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
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.
|
||||
it("counts an archive as history but never as current", async () => {
|
||||
// The floor alone is not enough: a customer floored by an archive clears
|
||||
// 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"));
|
||||
|
||||
await service.detail("c1");
|
||||
|
||||
const and = groupBy.mock.calls[0][0].where.AND;
|
||||
expect(and).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
OR: [
|
||||
const rule = and.find((c: { OR?: unknown[] }) =>
|
||||
JSON.stringify(c).includes("datos2@"),
|
||||
);
|
||||
expect(rule.OR).toEqual([
|
||||
{ legacySourceTable: null },
|
||||
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
||||
],
|
||||
},
|
||||
]),
|
||||
);
|
||||
{ transactionDate: { lt: expect.any(Date) } },
|
||||
]);
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
const include = prisma.customer.findUnique.mock.calls[0][0].include;
|
||||
expect(include.transactions.where).toMatchObject({
|
||||
OR: [
|
||||
expect(include.transactions.where.OR).toEqual([
|
||||
{ legacySourceTable: null },
|
||||
{ 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) } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,27 +5,33 @@ import { CreateCustomerDto } from "./create-customer.dto";
|
||||
import { UpdateCustomerDto } from "./update-customer.dto";
|
||||
import {
|
||||
BALANCE_FORWARD_TYPE,
|
||||
notCashJournal,
|
||||
PERIOD_TABLE_PREFIX,
|
||||
STATEMENT_EXCLUDED_SOURCE_TABLES,
|
||||
} 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
|
||||
* (`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.
|
||||
* A closed year is imported as its own tagged copy (`datos2@2025`). Below the
|
||||
* year start it is history and counts — for the one customer whose newest
|
||||
* BALANCE FORWARD lives inside an archive it is the only carry there is, and
|
||||
* 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
|
||||
* carry no legacySourceTable) need the null branch spelled out or they vanish.
|
||||
* Spelled as a positive OR because `NOT (col LIKE ... AND ...)` evaluates to
|
||||
* NULL for an app-captured row (no legacySourceTable), dropping every one.
|
||||
*/
|
||||
const EXCLUDE_ARCHIVES: Prisma.TransactionWhereInput = {
|
||||
const archiveIsHistory = (
|
||||
yearStart: Date,
|
||||
): Prisma.TransactionWhereInput => ({
|
||||
OR: [
|
||||
{ legacySourceTable: null },
|
||||
{ legacySourceTable: { not: { startsWith: PERIOD_TABLE_PREFIX } } },
|
||||
{ transactionDate: { lt: yearStart } },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
export interface ListParams {
|
||||
query?: string;
|
||||
@@ -152,7 +158,15 @@ export class CustomersService {
|
||||
// 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 },
|
||||
// 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" }],
|
||||
include: { type: true },
|
||||
},
|
||||
@@ -197,23 +211,10 @@ export class CustomersService {
|
||||
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],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
// The floor alone does not settle the archives: a customer floored by
|
||||
// an archive clears it with every row of that archive, and the rows
|
||||
// archives spill into the following January clear any floor.
|
||||
AND: [archiveIsHistory(yearStart), notCashJournal()],
|
||||
},
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import {
|
||||
BALANCE_FORWARD_TYPE,
|
||||
notCashJournal,
|
||||
periodSourceTable,
|
||||
} from "../billing/billing.service";
|
||||
import {
|
||||
@@ -835,25 +836,28 @@ const edoCuentaDatos: ReportDef = {
|
||||
{ legacySourceTable: periodSourceTable(requestedYear) }
|
||||
: {
|
||||
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||
// NULL-safe: `NULL NOT IN (...)` is NULL, not true, so a bare `notIn`
|
||||
// drops every app-captured row (they have no legacySourceTable) — the
|
||||
// same defect this report's on-screen twin was fixed for.
|
||||
// Archive rows count as history below the year start (that is
|
||||
// what `opening` is for, and for a customer floored by an archive
|
||||
// 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: [
|
||||
{ legacySourceTable: null },
|
||||
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
||||
{
|
||||
legacySourceTable: {
|
||||
notIn: [
|
||||
"EFECTIVO",
|
||||
"EFECTIVO_BACKUP",
|
||||
"EFECTIVO FM3",
|
||||
"CHEQUE FM3",
|
||||
"IVA 2015",
|
||||
transactionDate: {
|
||||
lt: new Date(Date.UTC(requestedYear, 0, 1)),
|
||||
},
|
||||
},
|
||||
],
|
||||
// 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,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/web",
|
||||
"version": "1.0.24",
|
||||
"version": "1.0.26",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 4500",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jorgecuadros-platform",
|
||||
"version": "1.0.24",
|
||||
"version": "1.0.26",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/database",
|
||||
"version": "1.0.24",
|
||||
"version": "1.0.26",
|
||||
"private": true,
|
||||
"main": "generated/client/index.js",
|
||||
"types": "generated/client/index.d.ts",
|
||||
|
||||
Reference in New Issue
Block a user