Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7507f2370 | ||
|
|
2f9a9afc0d |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@jorgecuadros/api",
|
"name": "@jorgecuadros/api",
|
||||||
"version": "1.0.25",
|
"version": "1.0.26",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
|
|||||||
@@ -149,18 +149,15 @@ 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([
|
|
||||||
{ legacySourceTable: null },
|
|
||||||
{ legacySourceTable: { notIn: expect.arrayContaining(["EFECTIVO"]) } },
|
|
||||||
]);
|
|
||||||
// Imported periods are windowed rather than excluded: history below the
|
// Imported periods are windowed rather than excluded: history below the
|
||||||
// year start (the only carry a floored-by-archive customer has), never
|
// year start (the only carry a floored-by-archive customer has), never
|
||||||
// at or above it (those rows sit inside the next BALANCE FORWARD).
|
// at or above it (those rows sit inside the next BALANCE FORWARD).
|
||||||
@@ -172,6 +169,20 @@ describe("balance floor", () => {
|
|||||||
{ transactionDate: { lt: expect.any(Date) } },
|
{ 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"]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -832,20 +915,6 @@ 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
|
|
||||||
// `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.
|
|
||||||
OR: [
|
|
||||||
{ legacySourceTable: null },
|
|
||||||
{
|
|
||||||
legacySourceTable: {
|
|
||||||
notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
// An archive row belongs to this period only as history. Below
|
// An archive row belongs to this period only as history. Below
|
||||||
// the year start it is exactly what `opening` is for, and for the
|
// the year start it is exactly what `opening` is for, and for the
|
||||||
// one customer whose newest BALANCE FORWARD lives *inside* an
|
// one customer whose newest BALANCE FORWARD lives *inside* an
|
||||||
@@ -872,6 +941,10 @@ export class BillingService {
|
|||||||
{ transactionDate: { lt: yearStart } },
|
{ 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(),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,21 +5,10 @@ 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.
|
|
||||||
*
|
|
||||||
* A closed year is imported as its own tagged copy of that year's ledger
|
|
||||||
* (`datos2@2025`) and the BALANCE FORWARD rows above it already contain every
|
|
||||||
* peso of it. Anything summing a customer's history has to leave the archives
|
|
||||||
* out or it counts each closed year twice — see the floor comment below.
|
|
||||||
*
|
|
||||||
* `NULL NOT LIKE '...'` is NULL rather than true, so app-captured rows (which
|
|
||||||
* carry no legacySourceTable) need the null branch spelled out or they vanish.
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* Keeps an imported prior period out of the *current* period, NULL-safely.
|
* Keeps an imported prior period out of the *current* period, NULL-safely.
|
||||||
*
|
*
|
||||||
@@ -225,19 +214,7 @@ export class CustomersService {
|
|||||||
// The floor alone does not settle the archives: a customer floored by
|
// 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
|
// an archive clears it with every row of that archive, and the rows
|
||||||
// archives spill into the following January clear any floor.
|
// archives spill into the following January clear any floor.
|
||||||
AND: [
|
AND: [archiveIsHistory(yearStart), notCashJournal()],
|
||||||
archiveIsHistory(yearStart),
|
|
||||||
{
|
|
||||||
OR: [
|
|
||||||
{ legacySourceTable: null },
|
|
||||||
{
|
|
||||||
legacySourceTable: {
|
|
||||||
notIn: [...STATEMENT_EXCLUDED_SOURCE_TABLES],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
|
|||||||
@@ -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,23 +836,6 @@ 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`
|
|
||||||
// drops every app-captured row (they have no legacySourceTable) — the
|
|
||||||
// same defect this report's on-screen twin was fixed for.
|
|
||||||
OR: [
|
|
||||||
{ legacySourceTable: null },
|
|
||||||
{
|
|
||||||
legacySourceTable: {
|
|
||||||
notIn: [
|
|
||||||
"EFECTIVO",
|
|
||||||
"EFECTIVO_BACKUP",
|
|
||||||
"EFECTIVO FM3",
|
|
||||||
"CHEQUE FM3",
|
|
||||||
"IVA 2015",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
// Archive rows count as history below the year start (that is
|
// Archive rows count as history below the year start (that is
|
||||||
// what `opening` is for, and for a customer floored by an archive
|
// 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.
|
// it is the only carry there is) and are dropped at or above it.
|
||||||
@@ -868,6 +852,12 @@ const edoCuentaDatos: ReportDef = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// 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",
|
"name": "@jorgecuadros/web",
|
||||||
"version": "1.0.25",
|
"version": "1.0.26",
|
||||||
"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.25",
|
"version": "1.0.26",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@jorgecuadros/database",
|
"name": "@jorgecuadros/database",
|
||||||
"version": "1.0.25",
|
"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",
|
||||||
|
|||||||
Reference in New Issue
Block a user