Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e71a993d0 | ||
|
|
aa5867c8ea |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/api",
|
||||
"version": "1.0.24",
|
||||
"version": "1.0.25",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
|
||||
@@ -159,15 +159,18 @@ describe("balance floor", () => {
|
||||
const where = rowsQuery(findMany).where;
|
||||
expect(where.OR).toEqual([
|
||||
{ legacySourceTable: null },
|
||||
{ legacySourceTable: { notIn: expect.arrayContaining(["EFECTIVO"]) } },
|
||||
]);
|
||||
// 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([
|
||||
{
|
||||
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@" },
|
||||
},
|
||||
OR: [
|
||||
{ legacySourceTable: null },
|
||||
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
||||
{ transactionDate: { lt: expect.any(Date) } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -784,6 +784,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
|
||||
@@ -830,15 +843,35 @@ 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.
|
||||
},
|
||||
},
|
||||
],
|
||||
// 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: {
|
||||
not: { startsWith: PERIOD_TABLE_PREFIX },
|
||||
},
|
||||
},
|
||||
{ transactionDate: { lt: yearStart } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
@@ -867,19 +900,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>();
|
||||
|
||||
@@ -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) } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,12 +20,29 @@ import {
|
||||
* `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 = {
|
||||
/**
|
||||
* Keeps an imported prior period out of the *current* period, NULL-safely.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Spelled as a positive OR because `NOT (col LIKE ... AND ...)` evaluates to
|
||||
* NULL for an app-captured row (no legacySourceTable), dropping every one.
|
||||
*/
|
||||
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 +169,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,12 +222,11 @@ 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.
|
||||
// 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: [
|
||||
EXCLUDE_ARCHIVES,
|
||||
archiveIsHistory(yearStart),
|
||||
{
|
||||
OR: [
|
||||
{ legacySourceTable: null },
|
||||
|
||||
@@ -849,12 +849,26 @@ 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@" },
|
||||
},
|
||||
},
|
||||
],
|
||||
// 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@" } } },
|
||||
{
|
||||
transactionDate: {
|
||||
lt: new Date(Date.UTC(requestedYear, 0, 1)),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/web",
|
||||
"version": "1.0.24",
|
||||
"version": "1.0.25",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 4500",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jorgecuadros-platform",
|
||||
"version": "1.0.24",
|
||||
"version": "1.0.25",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/database",
|
||||
"version": "1.0.24",
|
||||
"version": "1.0.25",
|
||||
"private": true,
|
||||
"main": "generated/client/index.js",
|
||||
"types": "generated/client/index.d.ts",
|
||||
|
||||
Reference in New Issue
Block a user