Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e71a993d0 | ||
|
|
aa5867c8ea |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@jorgecuadros/api",
|
"name": "@jorgecuadros/api",
|
||||||
"version": "1.0.24",
|
"version": "1.0.25",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
|
|||||||
@@ -159,15 +159,18 @@ describe("balance floor", () => {
|
|||||||
const where = rowsQuery(findMany).where;
|
const where = rowsQuery(findMany).where;
|
||||||
expect(where.OR).toEqual([
|
expect(where.OR).toEqual([
|
||||||
{ legacySourceTable: null },
|
{ 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: {
|
OR: [
|
||||||
notIn: expect.arrayContaining(["EFECTIVO"]),
|
{ legacySourceTable: null },
|
||||||
// Imported prior periods are excluded here too. The floor does not
|
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
||||||
// cover them: a customer whose newest BALANCE FORWARD sits inside
|
{ transactionDate: { lt: expect.any(Date) } },
|
||||||
// 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@" },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -784,6 +784,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
|
||||||
@@ -830,16 +843,36 @@ export class BillingService {
|
|||||||
{
|
{
|
||||||
legacySourceTable: {
|
legacySourceTable: {
|
||||||
notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[],
|
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 },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
// 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 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||||
@@ -867,19 +900,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>();
|
||||||
|
|||||||
@@ -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: [
|
|
||||||
{ legacySourceTable: null },
|
|
||||||
{ legacySourceTable: { not: { startsWith: "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 () => {
|
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) } },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,12 +20,29 @@ import {
|
|||||||
* `NULL NOT LIKE '...'` is NULL rather than true, so app-captured rows (which
|
* `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.
|
* 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: [
|
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 +169,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,12 +222,11 @@ 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: [
|
AND: [
|
||||||
EXCLUDE_ARCHIVES,
|
archiveIsHistory(yearStart),
|
||||||
{
|
{
|
||||||
OR: [
|
OR: [
|
||||||
{ legacySourceTable: null },
|
{ legacySourceTable: null },
|
||||||
|
|||||||
@@ -849,12 +849,26 @@ const edoCuentaDatos: ReportDef = {
|
|||||||
"CHEQUE FM3",
|
"CHEQUE FM3",
|
||||||
"IVA 2015",
|
"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" }],
|
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@jorgecuadros/web",
|
"name": "@jorgecuadros/web",
|
||||||
"version": "1.0.24",
|
"version": "1.0.25",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 4500",
|
"dev": "next dev -p 4500",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jorgecuadros-platform",
|
"name": "jorgecuadros-platform",
|
||||||
"version": "1.0.24",
|
"version": "1.0.25",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@jorgecuadros/database",
|
"name": "@jorgecuadros/database",
|
||||||
"version": "1.0.24",
|
"version": "1.0.25",
|
||||||
"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",
|
||||||
|
|||||||
Reference in New Issue
Block a user