Compare commits

...
11 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
gitea-actions 5549a1e0cf chore(release): v1.0.24
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m52s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m4s
Deploy on tag / Deploy to galactus (push) Successful in 22s
Cut by rmancinas via the "Cut release" workflow. Pushing the tag triggers build.yml; deploy separately with tag=1.0.24.
2026-08-19 23:58:27 +00:00
rmancinasandClaude Opus 5 c6feae9522 fix(customers): the customer file's balances are the statement's balances
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m41s
Build and Push Images / Build jorgecuadros-api (push) Successful in 3m7s
The /clientes/:id ledger card is titled "Estado de cuenta" and links straight to
the statement, but its per-line tiles came from a groupBy with `voidedAt: null`
and nothing else — no balance floor, no source exclusion, no outstanding rule.
It was a raw lifetime sum, double-counting the pre-cutover history that each
BALANCE FORWARD row already absorbs, and the card said so in its own footnote
rather than being fixed.

Importing prior periods turned that from wrong into badly wrong. Every closed
year is now also held as its own tagged copy, so an unfloored sum adds each one
a second time on top of the opening balance that contains it. NUMid 501 read
-7,119.29 before the archives landed and -15,270.59 after, against a true
-10,715.29 — the gap being exactly the 2024 and 2025 closing balances.

The tiles now take the same three rules the statement takes, and agree with it
for all 600 customers sampled.

Two places needed the archives excluded explicitly, because the balance floor
does not do it:

  - A customer whose newest BALANCE FORWARD lives inside an archive floors at
    that archive's own January 1, so every row of it clears the floor. One
    customer, 28 rows.
  - The archives are not cleanly bounded. datos2@2024 carries rows dated 2022,
    2023, 2025 and one in 2026; datos2@2025 two more. Those clear any floor and
    land in the current year next to the live ledger's own copy of them.

That second point is a defect in 93f8171, not only in this card: statement()
and the edo-cuenta-datos report were both filtering the current period by
source and date without excluding the period tags, so four customers on the
dev book would have read a closed year's rows as current. Both are fixed here.
The portal already had it right.

The year's movement list on the card keeps showing every non-archive row, as
before — it is a list of what happened, not a balance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:19:06 -07:00
rmancinasandClaude Opus 5 93f817158e feat(statements): a year selector, reading each closed year from its archive
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m49s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m4s
The statement has been pinned to the calendar year in progress since bc74905.
Now that prior years are imported, the year becomes a choice: the current one
still reads the live ledger, and any earlier one reads that year's archive.

A period is selected by its `datos2@YYYY` tag, not by a date range. That is how
legacy addressed it — one table per closed year, `SELECT ... FROM `2025`` — and
the distinction is load-bearing: the archives carry rows dated a day or two into
the following January, so a date window would file them under the wrong year in
one direction and drop them in the other.

Two things the archive branch must not inherit:

  - The balance floor. It exists to stop a later opening balance double-counting
    the history it summarizes; for a period view that history is precisely what
    is being asked for, so applying it would return nothing at all.
  - The cash-source exclusion. It reproduces legacy's DATOS2-only datosfreak,
    and an archive is DATOS2 rows already.

No fold into an opening balance either — the archive holds its own Jan-1 BALANCE
FORWARD row, which is the carry, listed exactly as legacy listed it.

The current period stays 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 live ledger carries out to 2028. Bounding it
would hide them from every view.

`availableYears` reports the periods a customer actually has, so the picker never
offers a year that would render empty — "you had no activity in 2019" is a
different claim from "2019 was never imported", and only one of them is true.
The selector hides itself entirely for a customer with a single period, and a
year outside the list is a 404 rather than a silent fall back to the current one.

The same period rule lands on the printable twin (edo-cuenta-datos gains a
"Periodo (año)" parameter) and on the portal, where fetchLedgerRowsPlatform was
also filtering by date with no source exclusion at all — so period=2025 would
have returned the archive rows on top of that year's EFECTIVO receipts, counting
every prior-year payment twice. The portal's allowlist is now built per data
source and validated at the point of use: DreamHost holds the current year plus
one archive table, the platform holds however many were imported, and
fetchLedgerRowsLegacy interpolates the period as a table name, so a
platform-only year must not reach it — including on the fallback path when the
platform is unreachable.

Left alone: the /clientes/:id ledger card still shows the current year. It reads
transactionYear off the customers endpoint rather than the statement, it is a
summary that links to the full statement, and giving it its own year state would
duplicate the page it links to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:48:25 -07:00
rmancinasandClaude Opus 5 29ae9fa5bc feat(migration): import prior periods so a closed year can be shown on its own
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m43s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m19s
Legacy ran a year-end corte: it summed the closing year, wrote that total back
as each customer's Jan-1 BALANCE FORWARD, and started the next year clean. The
platform inherited those opening rows but never the years behind them — only
the current year's charges were ever staged, so every prior year held receipts
and no bills. Rendering one would have shown a customer credits with nothing
owed against them, which is worse than showing nothing.

The archives are whole-database Access snapshots named for the period they
hold, so `2025.accdb` is discovered by filename, staged, and loaded through the
existing DATOS2 branch — a snapshot's `datos2` is the identical shape, one year
older. Only the ledger and DATGRAL come out of a snapshot; everything else in
it is a year-stale copy of a live table. The cash side is deliberately left
behind: EFECTIVO is a lifetime journal, so the snapshot's copy is a subset of
the live one and importing it would double-book every prior-year receipt.

Period travels with the row, in legacySourceTable as `datos2@2025`. That tag,
not the date, is what a year view should filter on: the archives are not
cleanly bounded (2025 carries ten undated rows and two dated into 2026) and
legacy never filtered by date either — its reader is `SELECT ... FROM \`2025\``.
The tag also keeps legacyId safe, since it is a positional ordinal that
restarts at 0 in every archive and would otherwise collide row-for-row.

Two guards, because attaching a prior year by NUMid is the one thing here that
can go quietly wrong:

  - Reissued numbers are skipped, not imported. Comparing each archive's
    DATGRAL against the live one, 13 names moved since 2025 and 40 since 2024;
    most are the same customer re-described, but a few are a different
    household holding a recycled number, and filing their ledger under the new
    owner would show a stranger's charges. Sharing any word of three or more
    characters separates a rename from a reissue. Names are compared
    legacy-to-legacy: `customers.name` has been through blank-name recovery,
    and comparing to it reported 121 drifts where there are 13.
  - Every period is checked against the corte identity it must satisfy —
    SUM(year N) == BALANCE FORWARD(N+1) — and the result is reported per year.
    A truncated export, a file dropped under the wrong year, or a botched
    customer match all fail loudly here. 2025 reconciles 1,159/1,167 (99.3%)
    and 2024 1,144/1,156 (99.0%); the recycle guard raised 2024 from 98.2%.

Balances are untouched: BALANCE_FLOOR_JOIN floors on the newest BALANCE FORWARD
per customer, so rows behind it are already excluded from every balance read.

Uploads go through the existing ingest endpoint, allowlisted by an anchored
`AAAA.accdb` pattern that also keeps a caller-supplied name inside the ingest
directory. The Operaciones page grows an entry point for an archive that has no
row yet, reading the period off the chosen file's own name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:26:58 -07:00
rmancinasandClaude Opus 5 9973488330 fix(billing): folio typos hid two double-booked receipts from the audit
Matching the datos2 `CN` reference against EFECTIVO folio `N` finds pairs
only where both were keyed correctly. Jorge Cuadros Jr's account carries
`C13647` against EFECTIVO folio `13649` — same day, same 3,500.00 MXN, one
receipt — and POWERS carries `C135808` against `13508`. Folio matching alone
calls both accounts clean, which is exactly backwards: an account used as a
validator reporting a false negative is worse than no audit.

A second pass now sweeps the C-refs the first pass left orphaned, on
proximity alone (same customer, same three-day window), and both passes are
judged by the same money rules. The folio is demoted to a lead: it can be
wrong in either direction, so it never decides anything on its own.

278 confirmed pairs, up from 276 — 989,740.00 MXN and 88,392.00 USD on the
EFECTIVO side. Of the 97 orphaned C-refs only 3 had any EFECTIVO row nearby,
so the remaining 94 are datos2-only postings rather than misses.

Rejections rise to 3. RAMIREZ, SUSANA pairs 3,320.00 MXN against 18,000.00
the next day; that is a partial application, not a duplicate, and it needs a
human rather than a rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 23:35:53 -07:00
rmancinasandClaude Opus 5 75dcbc11b8 feat(billing): audit the two populations the balance floor misses
Read-only. Reports what a corte would touch without touching it.

The platform inherited legacy's BALANCE FORWARD rows (1,170, all dated
2026-01-01) but not the yearly process that produces them, and
BillingService floors balances per customer on those rows. Two populations
fall outside that floor, and they are unrelated defects that happen to
surface as the same symptom — a customer whose balance reads as a credit
the office does not owe.

A. 102 customers have no BALANCE FORWARD row, so their balance is a raw
   lifetime sum. Only the current-year charge ledger (datos2) was migrated;
   the per-year charge tables stayed in DreamHost. What survives before the
   cutover is the EFECTIVO cash journal, and it shows: 199 of their 201
   pre-cutover rows are credits. Flooring them at 2026-01-01 moves the book
   by -82,297.78 MXN and -52,020.20 USD. 91 of the 102 are left with no rows
   at all, so their balance becomes zero — an assertion, not a figure
   recovered from anywhere, which is why this script proposes and does not
   apply.

B. 276 cash receipts booked twice in 2026 — once in EFECTIVO under folio N,
   once in datos2 as reference CN — across 120 customers, 986,240.00 MXN and
   88,252.00 USD on the EFECTIVO side. Both rows sit after the floor so both
   count. The statement already hides them via
   STATEMENT_EXCLUDED_SOURCE_TABLES; the balances worklist, the movement
   browser and the /clientes/:id card do not.

The folio alone does not establish a pair — folios are reused. Every pair is
corroborated on money too: equal amounts when both legs share a currency, or
an implied USD->MXN rate inside the band exchange_rates observed that year.
Two of 278 folio matches fail that test and are reported apart rather than
counted, both same-day partial applications that need a human.

Matches the balance-forward row in both shapes, as numid.service.ts does.
Databases imported before the type was minted carry those rows with typeId
NULL, and name-only matching reports every customer as floorless on such a
copy — including the dev database, which the API itself currently reads as
+20,653,109.15 MXN against a floored -9,194.61.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 23:18:48 -07:00
rmancinasandClaude Opus 5 9929a9a3ac feat(customers): the customer file's estado de cuenta follows the same year rule
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m48s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m5s
`/clientes/:id` carries its own "Estado de cuenta" card, fed by
CustomersService.detail rather than by BillingService.statement, so the
previous commit left it reading the old way: the last 100 movements of all
time, newest first. Two pages, one label, two orders.

It now covers the current calendar year oldest-first, like the statement and
like the sheet the office prints. The `take: 100` is gone with it — capping a
descending list hid the oldest rows, but capping an ascending one would hide
the newest, and a single year is small (365 rows for the heaviest customer in
the book).

The per-domain totals above the list are untouched and still historical: they
are an unfloored groupBy, so they double-count every customer's opening
balance. That is a separate bug from this one; the card now says out loud that
those figures are lifetime, not this year's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:21:51 -07:00
23 changed files with 1530 additions and 115 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@jorgecuadros/api", "name": "@jorgecuadros/api",
"version": "1.0.23", "version": "1.0.26",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
+44 -10
View File
@@ -115,13 +115,26 @@ describe("balance floor", () => {
); );
}); });
/**
* statement() issues two findMany calls: first the period discovery (which
* years this customer has an archive for), then the statement rows. Select
* the rows query by its shape so adding another lookup later moves nothing
* here — the previous version indexed call 0 and broke the moment period
* support landed.
*/
function rowsQuery(findMany: jest.Mock) {
const call = findMany.mock.calls.find((c) => c[0]?.orderBy);
if (!call) throw new Error("statement() issued no ordered rows query");
return call[0];
}
it("bounds the statement at the floor, inclusive", async () => { it("bounds the statement at the floor, inclusive", async () => {
const floor = new Date("2026-01-01T00:00:00Z"); const floor = new Date("2026-01-01T00:00:00Z");
const { service, findMany } = serviceWith(floor); const { service, findMany } = serviceWith(floor);
await service.statement("c1"); await service.statement("c1");
expect(findMany.mock.calls[0][0].where).toMatchObject({ expect(rowsQuery(findMany).where).toMatchObject({
customerId: "c1", customerId: "c1",
transactionDate: { gte: floor }, transactionDate: { gte: floor },
}); });
@@ -132,23 +145,44 @@ describe("balance floor", () => {
await service.statement("c1"); await service.statement("c1");
expect(findMany.mock.calls[0][0].where).not.toHaveProperty( expect(rowsQuery(findMany).where).not.toHaveProperty("transactionDate");
"transactionDate",
);
}); });
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 = findMany.mock.calls[0][0].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: null },
{ legacySourceTable: { notIn: expect.arrayContaining(["EFECTIVO"]) } }, { 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"]),
},
},
],
},
]); ]);
}); });
}); });
+16 -3
View File
@@ -119,10 +119,23 @@ export class BillingController {
return this.billing.byCheck(n); return this.billing.byCheck(n);
} }
/** One customer's full statement across both business lines. */ /**
* One customer's statement across both business lines, for one period.
*
* `year` omitted means the current one. Any earlier year is served from its
* imported archive; the response carries `availableYears` so the caller can
* offer only the periods this customer actually has.
*/
@Get("customers/:id") @Get("customers/:id")
statement(@Param("id") id: string) { statement(@Param("id") id: string, @Query("year") year?: string) {
return this.billing.statement(id); let parsed: number | undefined;
if (year !== undefined && year !== "") {
parsed = Number(year);
if (!Number.isInteger(parsed)) {
throw new BadRequestException("year debe ser un año de cuatro dígitos");
}
}
return this.billing.statement(id, parsed);
} }
/** Cross-customer movement browser. */ /** Cross-customer movement browser. */
+204 -32
View File
@@ -210,18 +210,47 @@ 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. * `legacySourceTable` of an imported prior period.
* *
* The legacy portal's `datosfreak` table was materialized from DATOS2 only * A closed year arrives as its own Access snapshot and is tagged rather than
* (`objects.json:1358`), so the customer's "current balance" never saw * dated (see migration/transform_transactions.py). The tag is what a period
* EFECTIVO / EFECTIVO FM3 / CHEQUE FM3 / EFECTIVO_BACKUP cash receipts, nor * view filters on: the archives are not cleanly date-bounded — 2025 carries
* the IVA 2015 snapshot. The unified `transactions` table has all of them, so * rows dated into 2026 — and legacy did not filter by date either, it selected
* the statement must drop them to match the legacy number the customer has * `FROM \`2025\``. Filtering on provenance reproduces the legacy period exactly.
* 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).
*/ */
const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [ 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@";
/**
* 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",
@@ -229,6 +258,53 @@ 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) {}
@@ -398,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
@@ -460,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}
@@ -476,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
@@ -523,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
@@ -559,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
`; `;
@@ -575,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
`; `;
@@ -596,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
@@ -701,16 +798,22 @@ export class BillingService {
/** /**
* One customer's statement across both business lines. * One customer's statement across both business lines.
* *
* Scoped to the current calendar year and listed oldest-first, matching the * Scoped to one calendar year and listed oldest-first, matching the legacy
* legacy EDO CUENTA report the office has printed for years: an opening * EDO CUENTA report the office has printed for years: an opening balance at
* balance at the top, then the year's movements in the order they happened. * the top, then the year's movements in the order they happened.
*
* `year` selects the period. The current year is read from the live tables;
* any earlier year is read from its imported archive, which legacy kept as a
* separate table and this reads by its `datos2@YYYY` tag. `availableYears`
* reports which periods this customer actually has, so a caller never offers
* a year that would render empty.
* *
* Returns the *whole* year rather than a page of it: the heaviest customer * Returns the *whole* year rather than a page of it: the heaviest customer
* carries 365 movements (mean 26), and a running balance is meaningless if * carries 365 movements (mean 26), and a running balance is meaningless if
* the client only holds a slice. The running balance is accumulated per * the client only holds a slice. The running balance is accumulated per
* currency in chronological order, with each row's balance-after attached. * currency in chronological order, with each row's balance-after attached.
*/ */
async statement(customerId: string) { async statement(customerId: string, year?: number) {
const customer = await this.prisma.customer.findUnique({ const customer = await this.prisma.customer.findUnique({
where: { id: customerId }, where: { id: customerId },
select: { select: {
@@ -734,6 +837,49 @@ export class BillingService {
throw new NotFoundException(`Customer ${customerId} not found`); throw new NotFoundException(`Customer ${customerId} not found`);
} }
// Which periods this customer has. The current year is always offered —
// it is the live ledger even when empty — and each imported archive adds
// the year it holds.
const archives = await this.prisma.transaction.findMany({
where: {
customerId,
voidedAt: null,
legacySourceTable: { startsWith: PERIOD_TABLE_PREFIX },
},
distinct: ["legacySourceTable"],
select: { legacySourceTable: true },
});
const thisYear = new Date().getUTCFullYear();
const archiveYears = archives
.map((a) => Number(a.legacySourceTable?.slice(PERIOD_TABLE_PREFIX.length)))
.filter((y) => Number.isInteger(y) && y < thisYear);
const availableYears = [...new Set([thisYear, ...archiveYears])].sort(
(a, b) => b - a,
);
// An unknown year would silently render as the current one, which reads as
// "this customer had no activity in 2019" rather than "there is no 2019".
const requested = year ?? thisYear;
if (!availableYears.includes(requested)) {
throw new NotFoundException(
`El cliente no tiene movimientos del periodo ${requested}.`,
);
}
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
@@ -759,22 +905,49 @@ export class BillingService {
const rows = await this.prisma.transaction.findMany({ const rows = await this.prisma.transaction.findMany({
where: { where: {
customerId, customerId,
...(isArchive
? // An archive is already exactly one period's ledger, so the tag is
// the whole filter. The balance floor is deliberately NOT applied:
// it exists to stop a later opening balance double-counting the
// history it summarizes, and here that history is the thing being
// asked for. The exclusion list is moot too — an archive holds only
// DATOS2 rows, which is what legacy's year table held.
{ 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[], 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(),
],
}),
},
orderBy: [{ transactionDate: "asc" }, { id: "asc" }], orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
select: { select: {
id: true, id: true,
@@ -800,8 +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.
const yearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 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>();
@@ -976,7 +1147,8 @@ export class BillingService {
propertyCount: customer._count.properties, propertyCount: customer._count.properties,
policyCount: customer._count.policies, policyCount: customer._count.policies,
}, },
year: yearStart.getUTCFullYear(), year: requested,
availableYears,
summary: [...perCurrency.values()].map((c) => { summary: [...perCurrency.values()].map((c) => {
const open = opening.get(c.currency) ?? new Prisma.Decimal(0); const open = opening.get(c.currency) ?? new Prisma.Decimal(0);
return { return {
+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());
});
});
});
@@ -0,0 +1,36 @@
import { periodSourceTable } from "./billing.service";
/**
* A closed year is imported as its own tagged set of rows rather than being
* identified by date. The tag is written by migration/transform_transactions.py
* and read by BillingService.statement, the edo-cuenta-datos report, and the
* PHP portal — three places that must agree on the exact string.
*/
describe("periodSourceTable", () => {
it("names the archive the migration writes", () => {
expect(periodSourceTable(2025)).toBe("datos2@2025");
expect(periodSourceTable(2024)).toBe("datos2@2024");
});
it("stays distinct from the live ledger's own table", () => {
// The live table is plain `datos2`. legacyId is a positional ordinal that
// restarts at 0 in every archive, so a shared name would collide with the
// current year row-for-row on the unique key.
expect(periodSourceTable(2025)).not.toBe("datos2");
expect(periodSourceTable(2025).startsWith("datos2@")).toBe(true);
});
it("is not matched by the statement's cash-source exclusion list", () => {
// STATEMENT_EXCLUDED_SOURCE_TABLES drops the EFECTIVO family to reproduce
// legacy's DATOS2-only datosfreak. An archive holds DATOS2 rows, so it must
// survive that filter or a prior year renders empty.
const excluded = [
"EFECTIVO",
"EFECTIVO_BACKUP",
"EFECTIVO FM3",
"CHEQUE FM3",
"IVA 2015",
];
expect(excluded).not.toContain(periodSourceTable(2025));
});
});
@@ -0,0 +1,122 @@
import { CustomersService } from "./customers.service";
import { BALANCE_FORWARD_TYPE } from "../billing/billing.service";
/**
* The /clientes/:id ledger card is titled "Estado de cuenta" and links straight
* to the statement, so its per-line totals must be the statement's numbers.
*
* They were a raw lifetime sum — no floor, no source exclusion — which
* double-counted the pre-cutover history each BALANCE FORWARD row absorbs.
* Importing prior periods made it visibly worse: every closed year is now held
* a second time as its own tagged copy, so an unfloored sum adds each one on
* top of the opening balance that already contains it.
*/
describe("customer file ledger card", () => {
function serviceWith(floor: Date | null) {
const groupBy = jest.fn().mockResolvedValue([]);
const prisma = {
customer: {
findUnique: jest.fn().mockResolvedValue({ id: "c1", transactions: [] }),
},
transaction: {
findFirst: jest
.fn()
.mockResolvedValue(floor ? { transactionDate: floor } : null),
groupBy,
},
};
return {
service: new CustomersService(prisma as never),
prisma,
groupBy,
};
}
it("takes the same balance floor the statement takes", async () => {
const floor = new Date("2026-01-01T00:00:00Z");
const { service, prisma, groupBy } = serviceWith(floor);
await service.detail("c1");
expect(prisma.transaction.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
type: { nameEn: BALANCE_FORWARD_TYPE },
}),
}),
);
expect(groupBy.mock.calls[0][0].where).toMatchObject({
transactionDate: { gte: floor },
});
});
it("applies no floor when the customer never had an opening balance", async () => {
// 102 customers have no BALANCE FORWARD row at all. Inventing a floor for
// them would hide their whole ledger.
const { service, groupBy } = serviceWith(null);
await service.detail("c1");
expect(groupBy.mock.calls[0][0].where).not.toHaveProperty("transactionDate");
});
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;
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 () => {
const { service, groupBy } = serviceWith(new Date("2026-01-01T00:00:00Z"));
await service.detail("c1");
const and = groupBy.mock.calls[0][0].where.AND;
const sourceRule = and.find((c: { OR?: unknown[] }) =>
JSON.stringify(c).includes("EFECTIVO"),
);
expect(sourceRule).toBeDefined();
});
it("drops outstanding rows, as every balance does", async () => {
const { service, groupBy } = serviceWith(null);
await service.detail("c1");
expect(groupBy.mock.calls[0][0].where).toMatchObject({
outstanding: false,
});
});
it("keeps archives out of the year's movement list too", async () => {
// datos2@2024 carries rows dated into 2026; a date test alone would show
// them as current-year movements next to the live ledger's own copy.
const { service, prisma } = serviceWith(null);
await service.detail("c1");
const include = prisma.customer.findUnique.mock.calls[0][0].include;
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) } },
]);
});
});
+88 -4
View File
@@ -3,6 +3,35 @@ import { Prisma } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { CreateCustomerDto } from "./create-customer.dto"; import { CreateCustomerDto } from "./create-customer.dto";
import { UpdateCustomerDto } from "./update-customer.dto"; import { UpdateCustomerDto } from "./update-customer.dto";
import {
BALANCE_FORWARD_TYPE,
notCashJournal,
PERIOD_TABLE_PREFIX,
} from "../billing/billing.service";
/**
* 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 { export interface ListParams {
query?: string; query?: string;
@@ -96,6 +125,13 @@ export class CustomersService {
/** Full unified customer view: identity + both business lines + ledger. */ /** Full unified customer view: identity + both business lines + ledger. */
async detail(id: string) { async detail(id: string) {
// The movement list on the customer file is the same statement the office
// prints, so it follows the same rule as BillingService.statement: this
// calendar year, oldest-first. No `take` any more — the cap used to hide
// the end of a busy customer's year once the order flipped, and a single
// year is small (365 rows for the heaviest customer in the book).
const yearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1));
const customer = await this.prisma.customer.findUnique({ const customer = await this.prisma.customer.findUnique({
where: { id }, where: { id },
include: { include: {
@@ -117,8 +153,21 @@ export class CustomersService {
}, },
}, },
transactions: { transactions: {
orderBy: { transactionDate: "desc" }, // Archives are excluded by tag, not by date. They are not cleanly
take: 100, // bounded — datos2@2024 carries rows dated 2022, 2023, 2025 and one
// 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.
// 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 }, include: { type: true },
}, },
}, },
@@ -130,16 +179,51 @@ export class CustomersService {
// Ledger totals per domain + currency (the "one statement across both // Ledger totals per domain + currency (the "one statement across both
// business lines" payoff), computed in the DB rather than in JS. // business lines" payoff), computed in the DB rather than in JS.
//
// These have to answer the same question BillingService.statement answers,
// because this card is titled "Estado de cuenta" and links straight to it —
// two screens quoting one customer two different balances is worse than
// either number alone. So it takes the same three rules the statement uses:
// the balance floor, the cash-source exclusion, and dropping outstanding
// rows the office has not paid yet.
//
// Without the floor these were a raw lifetime sum, double-counting the
// pre-cutover history each BALANCE FORWARD row already absorbs. Importing
// prior periods made that visibly worse: for NUMid 501 the tiles read
// -7,119.29 before the archives landed and -15,270.59 after, against a true
// -10,715.29 — the difference being exactly the 2024 and 2025 closing
// balances, added a second time on top of the opening row that contains
// them.
const floor = await this.prisma.transaction.findFirst({
where: {
customerId: id,
voidedAt: null,
type: { nameEn: BALANCE_FORWARD_TYPE },
},
orderBy: { transactionDate: "desc" },
select: { transactionDate: true },
});
const summary = await this.prisma.transaction.groupBy({ const summary = await this.prisma.transaction.groupBy({
by: ["domain", "currency"], by: ["domain", "currency"],
// Exclude voided rows so the per-domain balance matches the statement. where: {
where: { customerId: id, voidedAt: null }, customerId: id,
voidedAt: null,
outstanding: false,
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
// 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 }, _sum: { amount: true },
_count: { _all: true }, _count: { _all: true },
}); });
return { return {
...customer, ...customer,
/** Calendar year the movement list covers. */
transactionYear: yearStart.getUTCFullYear(),
transactionSummary: summary.map((s) => ({ transactionSummary: summary.map((s) => ({
domain: s.domain, domain: s.domain,
currency: s.currency, currency: s.currency,
+76 -8
View File
@@ -31,6 +31,29 @@ export const INGEST_FILES = [
] as const; ] as const;
export type IngestName = (typeof INGEST_FILES)[number]; export type IngestName = (typeof INGEST_FILES)[number];
/**
* A prior-period archive: one Access snapshot per closed year, named for the
* period it holds. `2025.accdb` is UTILITIES as it stood when 2025 was cut.
*
* The filename is the entire declaration of the period — nothing inside the
* file names its year, because a snapshot's `datos2` is indistinguishable from
* the live one — so this pattern is both the allowlist and the contract. It is
* anchored and allows no separator, which is what keeps an upload from
* escaping the ingest directory.
*/
const PERIOD_FILE_RE = /^(\d{4})\.accdb$/i;
/** Earliest period we will accept, so a typo'd year cannot mint a bogus one. */
const PERIOD_MIN_YEAR = 1990;
export function periodYearOf(name: string): number | null {
const m = PERIOD_FILE_RE.exec(name);
if (!m) return null;
const year = Number(m[1]);
if (year < PERIOD_MIN_YEAR || year > new Date().getUTCFullYear()) return null;
return year;
}
/** /**
* Prefix for every command containing a pipe. Without it the exit status of * Prefix for every command containing a pipe. Without it the exit status of
* `mysqldump | gzip` is gzip's, so a dump that failed immediately still looks * `mysqldump | gzip` is gzip's, so a dump that failed immediately still looks
@@ -120,19 +143,30 @@ export class OpsService implements OnModuleInit {
/* -------------------------------------------------------------- ingest */ /* -------------------------------------------------------------- ingest */
private assertIngestName(name: string): IngestName { private assertIngestName(name: string): string {
if (!INGEST_FILES.includes(name as IngestName)) { if (INGEST_FILES.includes(name as IngestName)) return name;
if (periodYearOf(name) !== null) return name;
throw new BadRequestException( throw new BadRequestException(
`Archivo no permitido. Debe ser uno de: ${INGEST_FILES.join(", ")}`, `Archivo no permitido. Debe ser uno de: ${INGEST_FILES.join(", ")}` +
`, o un archivo de periodo anterior con nombre AAAA.accdb (por ejemplo 2025.accdb).`,
); );
} }
return name as IngestName;
}
async listIngest(): Promise< async listIngest(): Promise<
{ name: string; present: boolean; size: number | null; modifiedAt: string | null }[] {
name: string;
present: boolean;
size: number | null;
modifiedAt: string | null;
/** Set only on a prior-period archive; null on the four fixed sources. */
periodYear: number | null;
}[]
> { > {
return Promise.all( // The four fixed sources are listed whether present or not — they are
// required, so "missing" is the useful state to show. Period archives are
// optional and unbounded, so they are listed only once uploaded, newest
// year first.
const fixed = await Promise.all(
INGEST_FILES.map(async (name) => { INGEST_FILES.map(async (name) => {
try { try {
const st = await fs.stat(path.join(this.ingestDir, name)); const st = await fs.stat(path.join(this.ingestDir, name));
@@ -141,12 +175,46 @@ export class OpsService implements OnModuleInit {
present: true, present: true,
size: st.size, size: st.size,
modifiedAt: st.mtime.toISOString(), modifiedAt: st.mtime.toISOString(),
periodYear: null as number | null,
}; };
} catch { } catch {
return { name, present: false, size: null, modifiedAt: null }; return {
name,
present: false,
size: null,
modifiedAt: null,
periodYear: null as number | null,
};
} }
}), }),
); );
let entries: string[] = [];
try {
entries = await fs.readdir(this.ingestDir);
} catch {
entries = [];
}
const periods = (
await Promise.all(
entries
.map((name) => ({ name, year: periodYearOf(name) }))
.filter((e): e is { name: string; year: number } => e.year !== null)
.sort((a, b) => b.year - a.year)
.map(async ({ name, year }) => {
const st = await fs.stat(path.join(this.ingestDir, name));
return {
name,
present: true,
size: st.size,
modifiedAt: st.mtime.toISOString(),
periodYear: year,
};
}),
)
).filter(Boolean);
return [...fixed, ...periods];
} }
async saveIngest(name: string, data: Buffer): Promise<void> { async saveIngest(name: string, data: Buffer): Promise<void> {
+48
View File
@@ -0,0 +1,48 @@
import { periodYearOf } from "./ops.service";
/**
* `periodYearOf` is the upload allowlist for prior-period archives, so it is
* doing two jobs at once: deciding what counts as a period file, and keeping a
* caller-supplied name from escaping the ingest directory. Both are pinned here.
*/
describe("periodYearOf", () => {
it("accepts a four-digit year archive", () => {
expect(periodYearOf("2025.accdb")).toBe(2025);
expect(periodYearOf("1999.accdb")).toBe(1999);
});
it("is case-insensitive on the extension", () => {
expect(periodYearOf("2025.ACCDB")).toBe(2025);
});
it("rejects a path that would escape the ingest directory", () => {
// The name is joined onto the ingest path, so anything with a separator or
// a parent reference has to fail before it reaches the filesystem.
expect(periodYearOf("../2025.accdb")).toBeNull();
expect(periodYearOf("../../etc/passwd")).toBeNull();
expect(periodYearOf("sub/2025.accdb")).toBeNull();
expect(periodYearOf("2025.accdb/../../x")).toBeNull();
});
it("rejects names that only look like a period", () => {
expect(periodYearOf("202.accdb")).toBeNull();
expect(periodYearOf("20255.accdb")).toBeNull();
expect(periodYearOf("2025.mdb")).toBeNull();
expect(periodYearOf("copia 2025.accdb")).toBeNull();
expect(periodYearOf("2025.accdb.bak")).toBeNull();
expect(periodYearOf("UTILITIES.accdb")).toBeNull();
});
it("rejects years outside the plausible range", () => {
// A typo'd year would otherwise mint a period nobody can ever reconcile:
// there is no BALANCE FORWARD for the year after it to check against.
expect(periodYearOf("1889.accdb")).toBeNull();
expect(periodYearOf(`${new Date().getUTCFullYear() + 1}.accdb`)).toBeNull();
});
it("accepts the current year, which is the earliest a period can be cut", () => {
expect(periodYearOf(`${new Date().getUTCFullYear()}.accdb`)).toBe(
new Date().getUTCFullYear(),
);
});
});
+52 -14
View File
@@ -15,7 +15,11 @@
*/ */
import { Prisma } from "@jorgecuadros/database"; import { Prisma } from "@jorgecuadros/database";
import { BALANCE_FORWARD_TYPE } from "../billing/billing.service"; import {
BALANCE_FORWARD_TYPE,
notCashJournal,
periodSourceTable,
} from "../billing/billing.service";
import { import {
intParam, intParam,
NOT_VOIDED, NOT_VOIDED,
@@ -759,6 +763,15 @@ const edoCuentaDatos: ReportDef = {
format: "statement", format: "statement",
params: [ params: [
{ key: "customerId", label: "Cliente", kind: "customer-picker" }, { key: "customerId", label: "Cliente", kind: "customer-picker" },
// Which period to print. Blank means the year in progress; an earlier year
// prints from its imported archive, the same source the on-screen
// statement reads.
{
key: "year",
label: "Periodo (año)",
kind: "number",
placeholder: "año en curso",
},
], ],
columns: [ columns: [
// Statement rows carry synthetic `__kind` discriminators instead of // Statement rows carry synthetic `__kind` discriminators instead of
@@ -804,29 +817,50 @@ const edoCuentaDatos: ReportDef = {
select: { transactionDate: true }, select: { transactionDate: true },
}); });
// Which period to print. An earlier year comes from its imported archive,
// tagged rather than dated, exactly as the on-screen statement reads it.
const thisYear = new Date().getUTCFullYear();
const askedYear = Number(p.year);
const requestedYear =
Number.isInteger(askedYear) && askedYear > 0 ? askedYear : thisYear;
const isArchive = requestedYear !== thisYear;
const rows = await prisma.transaction.findMany({ const rows = await prisma.transaction.findMany({
where: { where: {
customerId, customerId,
voidedAt: null, voidedAt: null,
...(isArchive
? // The archive is one period's ledger already, so the tag is the
// whole filter and the balance floor must not apply — the floor
// hides exactly the history this period is asking for.
{ 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",
],
}, },
}, },
], ],
}, },
// 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(),
],
}),
},
orderBy: [{ transactionDate: "asc" }, { id: "asc" }], orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
select: { select: {
id: true, id: true,
@@ -847,8 +881,12 @@ const edoCuentaDatos: ReportDef = {
// EDO CUENTA sheet reads. Rows from earlier years still move the running // EDO CUENTA sheet reads. Rows from earlier years still move the running
// balance — they are folded into `opening` and printed as a single "saldo // balance — they are folded into `opening` and printed as a single "saldo
// anterior" line, which is what a BALANCE FORWARD row is. // anterior" line, which is what a BALANCE FORWARD row is.
const yearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1)); // An archive needs no fold: it *is* the period, and its own Jan-1 BALANCE
const year = yearStart.getUTCFullYear(); // FORWARD row is the carry, printed like legacy printed it.
const yearStart = isArchive
? new Date(0)
: new Date(Date.UTC(requestedYear, 0, 1));
const year = requestedYear;
const running = new Map<string, Prisma.Decimal>(); const running = new Map<string, Prisma.Decimal>();
const opening = new Map<string, Prisma.Decimal>(); const opening = new Map<string, Prisma.Decimal>();
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@jorgecuadros/web", "name": "@jorgecuadros/web",
"version": "1.0.23", "version": "1.0.26",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 4500", "dev": "next dev -p 4500",
+8 -5
View File
@@ -128,6 +128,7 @@ function Detail({ id }: { id: string }) {
customerId={data.id} customerId={data.id}
summary={data.transactionSummary} summary={data.transactionSummary}
transactions={data.transactions} transactions={data.transactions}
year={data.transactionYear}
/> />
<DocumentosSection data={data} /> <DocumentosSection data={data} />
</div> </div>
@@ -746,16 +747,18 @@ function EstadoCuentaSection({
customerId, customerId,
summary, summary,
transactions, transactions,
year,
}: { }: {
customerId: string; customerId: string;
summary: TransactionSummaryRow[]; summary: TransactionSummaryRow[];
transactions: Transaction[]; transactions: Transaction[];
year: number;
}) { }) {
return ( return (
<section className="section"> <section className="section">
<SectionHead <SectionHead
rule="cuenta" rule="cuenta"
title="Estado de cuenta" title={`Estado de cuenta ${year}`}
count={transactions.length} count={transactions.length}
countSuffix="movimientos" countSuffix="movimientos"
/> />
@@ -782,7 +785,7 @@ function EstadoCuentaSection({
<div className="card"> <div className="card">
{transactions.length === 0 ? ( {transactions.length === 0 ? (
<div className="empty-inline">Sin movimientos registrados.</div> <div className="empty-inline">Sin movimientos en {year}.</div>
) : ( ) : (
<div className="tx-scroll"> <div className="tx-scroll">
<table className="tx-table"> <table className="tx-table">
@@ -804,11 +807,11 @@ function EstadoCuentaSection({
</table> </table>
</div> </div>
)} )}
{transactions.length >= 100 && (
<div className="section-note" style={{ padding: "0 16px 14px" }}> <div className="section-note" style={{ padding: "0 16px 14px" }}>
Mostrando los 100 movimientos más recientes. Movimientos de {year}, del más antiguo al más reciente. Los saldos de
arriba son el saldo actual por línea de negocio los mismos del
estado de cuenta, no la suma del año.
</div> </div>
)}
</div> </div>
{transactions.length > 0 && ( {transactions.length > 0 && (
<p className="section-note"> <p className="section-note">
+36 -6
View File
@@ -40,8 +40,10 @@ import type {
* currency at a time — a column that alternated between pesos and dollars would * currency at a time — a column that alternated between pesos and dollars would
* be a meaningless number. * be a meaningless number.
* *
* Like the legacy EDO CUENTA report, the table covers the current year only and * Like the legacy EDO CUENTA report, the table covers one calendar year and runs
* runs oldest-first, opening on the balance carried in from before it. * oldest-first, opening on the balance carried in from before it. The period
* selector switches years; earlier ones are served from the imported archive of
* that year, which is how legacy kept them — one table per closed year.
*/ */
export default function EstadoCuentaDetailPage({ export default function EstadoCuentaDetailPage({
params, params,
@@ -67,19 +69,27 @@ function StatementView({ id }: { id: string }) {
const [currency, setCurrency] = useState<LedgerCurrency | null>(null); const [currency, setCurrency] = useState<LedgerCurrency | null>(null);
const [domain, setDomain] = useState<TransactionDomain | "">(""); const [domain, setDomain] = useState<TransactionDomain | "">("");
/** null = the current period; the API decides what that is. */
const [year, setYear] = useState<number | null>(null);
function reload() { function reload() {
let alive = true; let alive = true;
setLoading(true); setLoading(true);
setError(null); setError(null);
getStatement(id) getStatement(id, year ?? undefined)
.then((d) => { .then((d) => {
if (!alive) return; if (!alive) return;
setData(d); setData(d);
// Default to the currency the customer actually moves the most in; // Default to the currency the customer actually moves the most in;
// preserve a previously-chosen currency across reloads. // preserve a previously-chosen currency across reloads — but only if
// the loaded period still has it. Switching to a year the customer
// never moved dollars in would otherwise leave the picker on USD with
// no matching option, showing an empty table for a year that has rows.
const busiest = [...d.summary].sort((a, b) => b.count - a.count)[0]; const busiest = [...d.summary].sort((a, b) => b.count - a.count)[0];
setCurrency((prev) => prev ?? busiest?.currency ?? "MXN"); const fallback = busiest?.currency ?? "MXN";
setCurrency((prev) =>
prev && d.summary.some((s) => s.currency === prev) ? prev : fallback,
);
setLoading(false); setLoading(false);
}) })
.catch((e) => { .catch((e) => {
@@ -101,7 +111,7 @@ function StatementView({ id }: { id: string }) {
getBillingFacets().then(setFacets).catch(() => setFacets(null)); getBillingFacets().then(setFacets).catch(() => setFacets(null));
return cleanup; return cleanup;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]); }, [id, year]);
const movements = useMemo(() => { const movements = useMemo(() => {
if (!data || !currency) return []; if (!data || !currency) return [];
@@ -229,6 +239,26 @@ function StatementView({ id }: { id: string }) {
)} )}
<div className="filter-row"> <div className="filter-row">
{/* Only the periods this customer has. A year with no archive would
render an empty table that reads as "no hubo movimientos" when the
truth is that the year was never imported. */}
{data.availableYears.length > 1 && (
<label className="filter-field">
<span className="filter-label">Periodo</span>
<select
className="input select"
value={data.year}
onChange={(e) => setYear(Number(e.target.value))}
>
{data.availableYears.map((y) => (
<option key={y} value={y}>
{y}
{y === data.availableYears[0] ? " (en curso)" : ""}
</option>
))}
</select>
</label>
)}
<label className="filter-field"> <label className="filter-field">
<span className="filter-label">Moneda</span> <span className="filter-label">Moneda</span>
<select <select
+58 -4
View File
@@ -68,6 +68,7 @@ function Operaciones() {
const [starting, setStarting] = useState(false); const [starting, setStarting] = useState(false);
const fileInputs = useRef<Record<string, HTMLInputElement | null>>({}); const fileInputs = useRef<Record<string, HTMLInputElement | null>>({});
const periodInput = useRef<HTMLInputElement | null>(null);
const refreshLists = useCallback(() => { const refreshLists = useCallback(() => {
listIngest().then(setIngest).catch(() => setIngest([])); listIngest().then(setIngest).catch(() => setIngest([]));
@@ -143,6 +144,20 @@ function Operaciones() {
} }
} }
/** Upload a prior-period archive under its own filename. */
async function handleUploadPeriod(file: File | undefined) {
if (!file) return;
if (!/^\d{4}\.accdb$/i.test(file.name)) {
setError(
`"${file.name}" no es un archivo de periodo. Debe llamarse AAAA.accdb, por ejemplo 2025.accdb.`,
);
if (periodInput.current) periodInput.current.value = "";
return;
}
await handleUpload(file.name, file);
if (periodInput.current) periodInput.current.value = "";
}
async function handleDeleteIngest(name: string) { async function handleDeleteIngest(name: string) {
setError(null); setError(null);
try { try {
@@ -197,7 +212,11 @@ function Operaciones() {
else await start("RESTORE", c.file); else await start("RESTORE", c.file);
} }
const ingestReady = (ingest ?? []).every((f) => f.present); // Only the four fixed Access sources gate a run. Period archives are
// optional extras — having none simply means no prior years are available.
const ingestReady = (ingest ?? [])
.filter((f) => f.periodYear === null)
.every((f) => f.present);
return ( return (
<> <>
@@ -244,8 +263,9 @@ function Operaciones() {
<div className="card" style={{ padding: 20, marginBottom: 20 }}> <div className="card" style={{ padding: 20, marginBottom: 20 }}>
<h2 className="section-title">Carpeta de ingesta</h2> <h2 className="section-title">Carpeta de ingesta</h2>
<p className="inline-form-note"> <p className="inline-form-note">
Los cuatro archivos originales de Access. La reimportación y la Los cuatro archivos originales de Access, más los archivos de periodos
sincronización leen de aquí. Tamaño máximo por archivo: {formatBytes(INGEST_MAX_BYTES)}. anteriores. La reimportación y la sincronización leen de aquí. Tamaño
máximo por archivo: {formatBytes(INGEST_MAX_BYTES)}.
</p> </p>
<div className="tx-scroll"> <div className="tx-scroll">
<table className="tx-table"> <table className="tx-table">
@@ -262,7 +282,14 @@ function Operaciones() {
{(ingest ?? []).map((f) => ( {(ingest ?? []).map((f) => (
<Fragment key={f.name}> <Fragment key={f.name}>
<tr> <tr>
<td className="mono">{f.name}</td> <td className="mono">
{f.name}
{f.periodYear !== null && (
<span className="badge" style={{ marginLeft: 8 }}>
periodo {f.periodYear}
</span>
)}
</td>
<td> <td>
<span className={`badge ${f.present ? "badge-positive" : "badge-negative"}`}> <span className={`badge ${f.present ? "badge-positive" : "badge-negative"}`}>
{f.present ? "Presente" : "Falta"} {f.present ? "Presente" : "Falta"}
@@ -313,6 +340,33 @@ function Operaciones() {
</tbody> </tbody>
</table> </table>
</div> </div>
{/* A period archive that has never been uploaded has no row to click,
so it needs its own entry point. The file names itself: the archive
IS `2025.accdb`, and that name is what declares the period, so the
control reads it off the chosen file rather than asking twice. */}
<div className="row-actions" style={{ marginTop: 16 }}>
<input
ref={periodInput}
type="file"
accept=".accdb"
style={{ display: "none" }}
onChange={(e) => handleUploadPeriod(e.target.files?.[0])}
/>
<button
className="btn btn-outline"
type="button"
disabled={uploading !== null}
onClick={() => periodInput.current?.click()}
>
Agregar periodo anterior
</button>
<span className="inline-form-note">
Un archivo de Access por año cerrado, nombrado con su periodo:{" "}
<span className="mono">2025.accdb</span>. Aporta el año anterior al
estado de cuenta; no altera el saldo actual.
</span>
</div>
</div> </div>
{/* Operations */} {/* Operations */}
+7 -2
View File
@@ -615,8 +615,13 @@ export function getBillingFacets(): Promise<BillingFacets> {
return apiFetch<BillingFacets>("/billing/facets"); return apiFetch<BillingFacets>("/billing/facets");
} }
export function getStatement(customerId: string): Promise<Statement> { /** `year` omitted reads the current period; earlier years come from an archive. */
return apiFetch<Statement>(`/billing/customers/${customerId}`); export function getStatement(
customerId: string,
year?: number,
): Promise<Statement> {
const q = year === undefined ? "" : `?year=${year}`;
return apiFetch<Statement>(`/billing/customers/${customerId}${q}`);
} }
/** Append a new ledger movement. Booked movements are never edited — fix /** Append a new ledger movement. Booked movements are never edited — fix
+11
View File
@@ -192,6 +192,8 @@ export interface IngestFile {
present: boolean; present: boolean;
size: number | null; size: number | null;
modifiedAt: string | null; modifiedAt: string | null;
/** Year of a prior-period archive (`2025.accdb`); null on the four fixed sources. */
periodYear: number | null;
} }
export interface BackupFile { export interface BackupFile {
@@ -1094,6 +1096,13 @@ export interface Statement {
}; };
/** Calendar year the statement covers; movements are scoped to it. */ /** Calendar year the statement covers; movements are scoped to it. */
year: number; year: number;
/**
* Periods this customer actually has, newest first. The current year is
* always present; each earlier year comes from an imported archive. Offering
* anything outside this list would render an empty statement that reads as
* "no activity" rather than "not imported".
*/
availableYears: number[];
summary: StatementSummary[]; summary: StatementSummary[];
byDomain: StatementDomainRow[]; byDomain: StatementDomainRow[];
byType: StatementTypeRow[]; byType: StatementTypeRow[];
@@ -1129,6 +1138,8 @@ export interface CustomerDetail {
properties: Property[]; properties: Property[];
policies: Policy[]; policies: Policy[];
transactions: Transaction[]; transactions: Transaction[];
/** Calendar year `transactions` covers. */
transactionYear: number;
transactionSummary: TransactionSummaryRow[]; transactionSummary: TransactionSummaryRow[];
} }
+57
View File
@@ -18,6 +18,7 @@ the four Access source files.
""" """
import os import os
import re
from pathlib import Path from pathlib import Path
# The folder holding the four Access source files. Overridable via INGEST_DIR so # The folder holding the four Access source files. Overridable via INGEST_DIR so
@@ -95,3 +96,59 @@ SOURCES = {
}, },
}, },
} }
# --- prior-period archives ----------------------------------------------
#
# Legacy ran a year-end *corte*: it summed the closing year, wrote that total
# back as each customer's Jan-1 BALANCE FORWARD, and started the next year
# clean. Access keeps the closed year as a whole-database snapshot named for
# the period it holds — `2025.accdb` is UTILITIES as it stood when 2025 was
# cut — and the office archives one per year.
#
# Only the ledger is staged out of a snapshot. Everything else in it (DATMEX,
# PROFILE, EFECTIVO, ...) is a year-stale copy of a table the live
# UTILITIES.accdb already provides, and staging all ~50 of them would triple
# the extract time to import data we would then have to ignore. DATGRAL comes
# along solely to check that a NUMid still means the same customer it did that
# year; see the recycle guard in transform_transactions.py.
#
# The cash side is deliberately NOT taken from the snapshot: `EFECTIVO` is a
# lifetime journal, so the snapshot's copy is a subset of the live one and
# importing it would double-book every prior-year receipt.
PERIOD_FILE_RE = re.compile(r"^(\d{4})\.accdb$", re.IGNORECASE)
PERIOD_TABLES = {"datos2", "DATGRAL"}
def period_schema(year: int) -> str:
return f"stg_period_{year}"
def discover_periods(root: Path) -> dict[str, dict]:
"""Find every `YYYY.accdb` archive sitting in the ingest folder.
Discovery is by filename because that is the whole upload contract: the
operator drops `2025.accdb` on the Operaciones page and the period is 2025.
Nothing inside the file names the year — a snapshot's `datos2` looks
identical to the live one — so the name is the only declaration of intent
we get, and it is what the allowlist on the upload endpoint enforces.
"""
found: dict[str, dict] = {}
if not root.is_dir():
return found
for path in sorted(root.iterdir()):
m = PERIOD_FILE_RE.match(path.name)
if not m:
continue
year = int(m.group(1))
found[f"period_{year}"] = {
"path": path,
"schema": period_schema(year),
"exclude": set(),
"include": set(PERIOD_TABLES),
"period_year": year,
}
return found
SOURCES.update(discover_periods(SOURCE_ROOT))
+11
View File
@@ -40,6 +40,17 @@ def stage_source(source_name: str, source_cfg: dict, sink) -> None:
tables = extract.list_tables(cnxn) tables = extract.list_tables(cnxn)
excluded = source_cfg["exclude"] excluded = source_cfg["exclude"]
# A source may name the only tables it is worth staging. Prior-period
# archives do: they are whole-database snapshots, but everything in them
# except the ledger is a year-stale copy of a live table, so staging the
# rest costs minutes per file to produce data nothing reads.
include = source_cfg.get("include")
if include is not None:
missing = include - set(tables)
if missing:
print(f" [WARN] {source_name}: missing expected table(s) {sorted(missing)}", file=sys.stderr)
tables = [t for t in tables if t in include]
for table_name in tables: for table_name in tables:
if table_name in excluded: if table_name in excluded:
print(f" [exclude] {table_name}") print(f" [exclude] {table_name}")
+151 -4
View File
@@ -90,6 +90,62 @@ def load(src, name):
return df return df
def verify_corte(c, year: int) -> None:
"""Assert legacy's corte identity: SUM(period Y) == BALANCE FORWARD(Y+1).
This is the whole reason a year can be shown on its own. Legacy closed each
year by summing it and writing that total back as every customer's Jan-1
opening row for the next one, so if an archive is the right file, complete,
and attached to the right customers, its per-customer total lands exactly on
the next year's BALANCE FORWARD. A truncated export, a file dropped under
the wrong year, or a botched customer match all break the identity loudly
here instead of quietly six months from now.
Reported, never fatal. Legacy publishes on its own schedule, so a handful of
customers legitimately drift between the snapshot and the cut — the run that
established this reconciled 1,160 of 1,170.
"""
c.execute(
"""
SELECT sums.customerId, sums.total, bf.amount
FROM (
SELECT customerId, ROUND(SUM(amount), 2) AS total
FROM transactions
WHERE legacySourceTable = %s AND voidedAt IS NULL
GROUP BY customerId
) sums
LEFT JOIN (
SELECT t.customerId, ROUND(SUM(t.amount), 2) AS amount
FROM transactions t
JOIN type_transactions tt ON tt.id = t.typeId
WHERE tt.nameEn = 'BALANCE FORWARD' AND t.voidedAt IS NULL
AND t.transactionDate >= %s AND t.transactionDate < %s
GROUP BY t.customerId
) bf ON bf.customerId = sums.customerId
""",
(f"datos2@{year}", f"{year + 1}-01-01", f"{year + 1}-01-02"),
)
rows = c.fetchall()
matched = mismatched = 0
missing = 0
drift = Decimal(0)
for _cid, total, amount in rows:
if amount is None:
missing += 1
continue
if abs(Decimal(str(total)) - Decimal(str(amount))) < Decimal("0.02"):
matched += 1
else:
mismatched += 1
drift += abs(Decimal(str(total)) - Decimal(str(amount)))
checked = matched + mismatched
pct = (100 * matched / checked) if checked else 0
print(
f" corte {year} -> BF {year + 1}: {matched}/{checked} match ({pct:.1f}%)"
f", {mismatched} off by {drift:,.2f}, {missing} with no BF row"
)
def main(): def main():
env, sync_mode = parse_mode() env, sync_mode = parse_mode()
conn = connect(env) conn = connect(env)
@@ -230,9 +286,13 @@ def main():
message=s(r["conepto"]), check=s(r[check_col]) if check_col else None, message=s(r["conepto"]), check=s(r[check_col]) if check_col else None,
src_db="UTILITIES", src_tbl=legacy_tbl, legacy=str(int(r["_row_num"]))) src_db="UTILITIES", src_tbl=legacy_tbl, legacy=str(int(r["_row_num"])))
def billing(name, legacy_tbl): def billing(name, legacy_tbl, *, src="stg_utilities", skip_numids=None):
"""Load a DATOS2-shaped billing ledger. """Load a DATOS2-shaped billing ledger.
`src` names the staging schema, so a prior-period archive
(stg_period_2025) loads through this same path: the snapshot's `datos2`
is the identical eleven-column shape, one year older.
NOPAGO is the legacy "still owed" flag. The website reads it directly — NOPAGO is the legacy "still owed" flag. The website reads it directly —
`account.statement.php` splits the statement on `NOPAGO = 0` vs `account.statement.php` splits the statement on `NOPAGO = 0` vs
`NOPAGO = 1` and renders the latter as the "Outstanding Bills Requiring `NOPAGO = 1` and renders the latter as the "Outstanding Bills Requiring
@@ -241,10 +301,13 @@ def main():
Only these three tables carry it (76 rows set in DATOS2 today); the Only these three tables carry it (76 rows set in DATOS2 today); the
EFECTIVO/FM3 cash streams have no such column and stay 0. EFECTIVO/FM3 cash streams have no such column and stay 0.
""" """
nonlocal skip_cust, skip_date nonlocal skip_cust, skip_date, skip_recycled
df = load("stg_utilities", name) df = load(src, name)
for _, r in df.iterrows(): for _, r in df.iterrows():
cid = util_cust.get(norm_id(r["numid"])) numid = norm_id(r["numid"])
if skip_numids and numid in skip_numids:
skip_recycled += 1; continue
cid = util_cust.get(numid)
if not cid: if not cid:
skip_cust += 1; continue skip_cust += 1; continue
td = dt(r["date"]) td = dt(r["date"])
@@ -257,6 +320,53 @@ def main():
legacy=str(int(r["_row_num"])), legacy=str(int(r["_row_num"])),
outstanding=1 if s(r["nopago"]) == "1" else 0) outstanding=1 if s(r["nopago"]) == "1" else 0)
skip_recycled = 0
recycle_report: list[tuple[int, str, str, str]] = []
def period_numid_guard(year: int) -> set[str]:
"""NUMids whose prior-period owner is not today's customer.
Prior-period rows attach by NUMid and nothing else, so a number the
office retired and reissued would file one customer's ledger under
another's name — the one error this feature must never make, because it
shows a stranger's charges to whoever holds the number now.
Reuse is real but rare: comparing each archive's DATGRAL against the
live one, 13 names moved since 2025 and 40 since 2024. Most are the same
customer re-described — a typo fixed (VIKIE -> VICKIE), a spouse added
or dropped (STEWART, ALAN R. -> STEWART, ALAN & JENNIFER). A few are
genuinely a different household (STRONKS, BOB -> SWEET, DONALD E.).
Sharing any word of three or more characters separates the two cleanly:
a rename keeps the surname, a reissue keeps nothing. Names are compared
legacy-to-legacy, archive DATGRAL against live DATGRAL, deliberately not
against `customers.name` — that column has been through the blank-name
recovery pass, and comparing to it reported 121 drifts where there are
13, every extra one a false positive that would have discarded good
history.
"""
try:
arch = load(f"stg_period_{year}", "datgral")
live = load("stg_utilities", "datgral")
except (FileNotFoundError, OSError):
return set()
def toks(v) -> set[str]:
return {w for w in "".join(ch if ch.isalnum() else " " for ch in (s(v) or "").upper()).split() if len(w) >= 3}
live_names = {norm_id(r["num_id"]): s(r["nombre"]) for _, r in live.iterrows()}
blocked: set[str] = set()
for _, r in arch.iterrows():
numid = norm_id(r["num_id"])
was, now = s(r["nombre"]), live_names.get(numid)
if not numid or not was or not now:
continue
if toks(was) & toks(now):
continue
blocked.add(numid)
recycle_report.append((year, numid, was, now))
return blocked
def iva(): def iva():
nonlocal skip_cust nonlocal skip_cust
df = load("stg_utilities", "iva_2015") df = load("stg_utilities", "iva_2015")
@@ -286,6 +396,31 @@ def main():
billing("fee_anual", "FEE ANUAL") billing("fee_anual", "FEE ANUAL")
billing("fee15", "fee15") billing("fee15", "fee15")
iva() iva()
# --- prior periods -----------------------------------------------------
#
# Legacy kept each closed year in its own table and opened the next one with
# a Jan-1 BALANCE FORWARD carrying the closing total. The platform has one
# `transactions` table, so the period a row belongs to has to travel with
# the row: it rides in legacySourceTable as `datos2@2025`.
#
# That tag, not the date, is what a year view should filter on. The archives
# are not cleanly bounded — 2025's ledger carries ten undated rows and two
# dated into 2026 — and legacy itself never filtered by date either: its
# reader is `SELECT ... FROM \`2025\``. Keying on provenance reproduces the
# legacy period exactly and strands nothing.
#
# The tag also keeps the unique key safe. legacyId is a positional row
# ordinal, so every archive restarts it at 0 and would collide with the live
# `datos2` row-for-row if they shared a source-table name.
periods = sorted(
int(d.name.rsplit("_", 1)[1])
for d in STG.glob("stg_period_*")
if d.is_dir() and d.name.rsplit("_", 1)[1].isdigit()
)
for year in periods:
billing("datos2", f"datos2@{year}", src=f"stg_period_{year}",
skip_numids=period_numid_guard(year))
# Same record shape in the seguros DB. Labelled for consistency in the # Same record shape in the seguros DB. Labelled for consistency in the
# platform's own UI; unverifiable against the site, which only ever reads # platform's own UI; unverifiable against the site, which only ever reads
# domain='UTILITY', so no customer-facing behaviour depends on it. # domain='UTILITY', so no customer-facing behaviour depends on it.
@@ -334,6 +469,7 @@ def main():
print(f" skipped (unresolved customer): {skip_cust}") print(f" skipped (unresolved customer): {skip_cust}")
print(f" skipped (unparseable date) : {skip_date}") print(f" skipped (unparseable date) : {skip_date}")
print(f" skipped (EFECTIVO_BACKUP dup): {skip_dupe}") print(f" skipped (EFECTIVO_BACKUP dup): {skip_dupe}")
print(f" skipped (reissued NUMid) : {skip_recycled}")
print(f" -> transactions : {count('transactions')}") print(f" -> transactions : {count('transactions')}")
print(f" by domain : {dict(by_dom)}") print(f" by domain : {dict(by_dom)}")
for src, n in by_src: for src, n in by_src:
@@ -342,6 +478,17 @@ def main():
print(f" -> exchange_rates : {count('exchange_rates')}") print(f" -> exchange_rates : {count('exchange_rates')}")
print(f" orphan transactions (bad customer FK): {orphans}") print(f" orphan transactions (bad customer FK): {orphans}")
assert orphans == 0, "transaction customer FK invariant failed" assert orphans == 0, "transaction customer FK invariant failed"
if recycle_report:
print(f" ! reissued NUMids, prior-period rows NOT imported: {len(recycle_report)}")
for year, numid, was, now in recycle_report[:8]:
print(f" {year} NUMid {numid}: '{was}' -> '{now}'")
if len(recycle_report) > 8:
print(f" ... {len(recycle_report) - 8} more")
for year in periods:
verify_corte(c, year)
print(" validation: OK") print(" validation: OK")
conn.close() conn.close()
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jorgecuadros-platform", "name": "jorgecuadros-platform",
"version": "1.0.23", "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.23", "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",
+405
View File
@@ -0,0 +1,405 @@
#!/usr/bin/env node
/**
* Corte (year-end cut) audit — READ ONLY. Writes nothing, voids nothing.
*
* Legacy Access ran a corte every year: it moved the year's utility movements
* into a per-year table and stamped one BALANCE FORWARD row per customer,
* dated Jan 1, carrying the closing balance. The platform inherited the ROWS
* (1,170 of them, dated 2026-01-01 — legacy's last cut before the extract) but
* not the PROCESS, and BillingService uses those rows as a per-customer floor
* (BALANCE_FLOOR_JOIN / NOT_SUPERSEDED in apps/api/src/billing/billing.service.ts).
*
* This script reports the two populations that floor does not cover:
*
* A. FLOORLESS customers — no BALANCE FORWARD row at all, so their balance
* is a raw lifetime sum. The platform only migrated the CURRENT-year
* charge ledger (datos2); the per-year charge tables live in DreamHost
* and were never staged. What survives before the cutover is therefore
* the EFECTIVO cash journal — receipts with no matching charges — so
* those sums read as the office owing money it does not owe.
*
* B. DOUBLE-BOOKED 2026 RECEIPTS — one cash receipt recorded twice, once in
* EFECTIVO with folio `N` and once in datos2 with reference `CN`. Both
* rows are after the 2026-01-01 floor, so both count. The statement hides
* them (STATEMENT_EXCLUDED_SOURCE_TABLES drops EFECTIVO); the balances
* worklist, the movement browser and the /clientes/:id card do not.
*
* The folio alone neither proves nor disproves a pair, so it is used as a
* lead and never as the verdict. Folios are reused, so `C13483` can collide
* with an unrelated receipt; folios are also mistyped, so a genuine pair can
* carry two different numbers. Detection therefore runs twice — once on the
* `CN` cross-reference, once over the C-refs that pass left orphaned, this
* time on proximity alone (same customer, within three days) — and BOTH
* passes are then judged on the money: identical amount when the two legs
* share a currency, or an implied USD->MXN rate inside the band the
* exchange_rates table actually observed that year. Anything that fails is
* reported apart and must not be counted as duplicated money.
*
* The second pass is not a refinement. Jorge Jr's own account carries
* `C13647` against EFECTIVO folio `13649` — same day, same 3,500.00 — and
* POWERS carries `C135808` against `13508`. Folio matching alone reports
* both accounts as clean.
*
* node scripts/corte-audit.mjs # summary + both sections
* node scripts/corte-audit.mjs --cutover 2026-01-01
* node scripts/corte-audit.mjs --csv-a # per-customer table, section A
* node scripts/corte-audit.mjs --csv-b # per-pair table, section B
* node scripts/corte-audit.mjs --limit 40 # rows printed per section
*
* Needs DATABASE_URL. Point it at PROD — a stale copy answers about itself.
* On a database imported before the BALANCE FORWARD type was minted those rows
* carry typeId NULL instead (see numid.service.ts:80), so the floor is matched
* in BOTH shapes here; matching only the type name reports every customer as
* floorless on such a copy.
*/
import pkg from "../packages/database/generated/client/index.js";
const { PrismaClient } = pkg;
/** Prisma hands raw DECIMAL back as Decimal|string|null; counts as BigInt. */
const d = (v) => (v == null ? 0 : Number(v));
const money = (v) => d(v).toFixed(2).padStart(13);
/**
* Raw DATE/DATETIME columns arrive as JS Date objects. String() would render
* them in the host's local zone, which turns a row stored at 2026-01-01 00:00
* UTC into "Dec 31" on a US Pacific laptop — the ledger is keyed on UTC dates
* everywhere else, so format in UTC and nowhere else.
*/
const day = (v) => (v == null ? "—" : new Date(v).toISOString().slice(0, 10));
function arg(args, name, fallback = null) {
const i = args.indexOf(name);
return i === -1 ? fallback : args[i + 1];
}
/**
* A row is a balance-forward marker in either of two shapes. Keep in step with
* EMPTY_NUMID_SQL in apps/api/src/customers/numid.service.ts.
*/
const BF_PREDICATE = `(
tt.nameEn = 'BALANCE FORWARD'
OR (t.typeId IS NULL AND MONTH(t.transactionDate) = 1 AND DAY(t.transactionDate) = 1
AND t.legacySourceTable = 'datos2')
)`;
async function main() {
const args = process.argv.slice(2);
const limit = Number(arg(args, "--limit", "25"));
const prisma = new PrismaClient();
try {
// ---- cutover -----------------------------------------------------------
// Default to the newest balance-forward date actually in the book rather
// than to the current year: the cut the data reflects is a fact, not a
// preference, and hardcoding 2026 would silently lie on any other copy.
const [bfDates] = await prisma.$queryRawUnsafe(`
SELECT MAX(t.transactionDate) AS newest, MIN(t.transactionDate) AS oldest,
COUNT(*) AS rows_, COUNT(DISTINCT t.customerId) AS custs
FROM transactions t LEFT JOIN type_transactions tt ON tt.id = t.typeId
WHERE t.voidedAt IS NULL AND ${BF_PREDICATE}
`);
const cutover =
arg(args, "--cutover") ??
(bfDates.newest ? new Date(bfDates.newest).toISOString().slice(0, 10) : null);
if (!cutover) {
console.log("No BALANCE FORWARD rows in this database and no --cutover given.");
return;
}
console.log(`corte audit — cutover ${cutover}`);
console.log(
` balance-forward rows: ${d(bfDates.rows_)} across ${d(bfDates.custs)} customers` +
`, dated ${day(bfDates.oldest)}..${day(bfDates.newest)}`,
);
// ---- book totals -------------------------------------------------------
const [book] = await prisma.$queryRawUnsafe(
`
WITH bfloor AS (
SELECT t.customerId, MAX(t.transactionDate) AS floorDate
FROM transactions t LEFT JOIN type_transactions tt ON tt.id = t.typeId
WHERE t.voidedAt IS NULL AND ${BF_PREDICATE}
GROUP BY t.customerId
)
SELECT
ROUND(SUM(CASE WHEN t.currency='MXN' THEN t.amount ELSE 0 END), 2) AS rawMxn,
ROUND(SUM(CASE WHEN t.currency='USD' THEN t.amount ELSE 0 END), 2) AS rawUsd,
ROUND(SUM(CASE WHEN t.currency='MXN' AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate)
THEN t.amount ELSE 0 END), 2) AS todayMxn,
ROUND(SUM(CASE WHEN t.currency='USD' AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate)
THEN t.amount ELSE 0 END), 2) AS todayUsd,
ROUND(SUM(CASE WHEN t.currency='MXN' AND t.transactionDate >= ?
THEN t.amount ELSE 0 END), 2) AS flooredMxn,
ROUND(SUM(CASE WHEN t.currency='USD' AND t.transactionDate >= ?
THEN t.amount ELSE 0 END), 2) AS flooredUsd
FROM transactions t
LEFT JOIN bfloor b ON b.customerId = t.customerId
WHERE t.voidedAt IS NULL AND t.outstanding = 0
`,
cutover,
cutover,
);
// ---- section A: floorless customers ------------------------------------
const floorless = await prisma.$queryRawUnsafe(
`
WITH nobf AS (
SELECT c.id, c.name
FROM customers c
WHERE EXISTS (SELECT 1 FROM transactions t WHERE t.customerId = c.id AND t.voidedAt IS NULL)
AND NOT EXISTS (
SELECT 1 FROM transactions t LEFT JOIN type_transactions tt ON tt.id = t.typeId
WHERE t.customerId = c.id AND t.voidedAt IS NULL AND ${BF_PREDICATE}
)
)
SELECT n.id, n.name,
COUNT(*) AS rows_,
SUM(t.transactionDate < ?) AS preRows,
SUM(t.transactionDate >= ?) AS postRows,
MIN(t.transactionDate) AS firstTx,
MAX(t.transactionDate) AS lastTx,
SUM(t.domain = 'UTILITY') AS utilRows,
SUM(t.domain = 'INSURANCE') AS insRows,
ROUND(SUM(CASE WHEN t.currency='MXN' AND t.outstanding=0 THEN t.amount ELSE 0 END), 2) AS todayMxn,
ROUND(SUM(CASE WHEN t.currency='USD' AND t.outstanding=0 THEN t.amount ELSE 0 END), 2) AS todayUsd,
ROUND(SUM(CASE WHEN t.currency='MXN' AND t.outstanding=0 AND t.transactionDate >= ?
THEN t.amount ELSE 0 END), 2) AS afterMxn,
ROUND(SUM(CASE WHEN t.currency='USD' AND t.outstanding=0 AND t.transactionDate >= ?
THEN t.amount ELSE 0 END), 2) AS afterUsd
FROM nobf n
JOIN transactions t ON t.customerId = n.id AND t.voidedAt IS NULL
GROUP BY n.id, n.name
ORDER BY ABS(SUM(CASE WHEN t.currency='MXN' AND t.transactionDate < ? THEN t.amount ELSE 0 END)) DESC
`,
cutover, cutover, cutover, cutover, cutover,
);
// Direction of the pre-cutover history, which is the whole argument for
// flooring rather than carrying it: a corte carries a NET, and a net built
// from receipts whose charges were never migrated is not one.
const [split] = await prisma.$queryRawUnsafe(
`
WITH nobf AS (
SELECT c.id FROM customers c
WHERE EXISTS (SELECT 1 FROM transactions t WHERE t.customerId = c.id AND t.voidedAt IS NULL)
AND NOT EXISTS (
SELECT 1 FROM transactions t LEFT JOIN type_transactions tt ON tt.id = t.typeId
WHERE t.customerId = c.id AND t.voidedAt IS NULL AND ${BF_PREDICATE}
)
)
SELECT SUM(t.amount < 0) AS charges, ROUND(SUM(CASE WHEN t.amount < 0 THEN t.amount ELSE 0 END), 2) AS chargeMxn,
SUM(t.amount > 0) AS credits, ROUND(SUM(CASE WHEN t.amount > 0 THEN t.amount ELSE 0 END), 2) AS creditMxn
FROM transactions t JOIN nobf n ON n.id = t.customerId
WHERE t.voidedAt IS NULL AND t.transactionDate < ?
`,
cutover,
);
// ---- section B: double-booked receipts ---------------------------------
const pairs = await prisma.$queryRawUnsafe(
`
SELECT d.id AS datos2Id, e.id AS efectivoId, c.name,
DATE(d.transactionDate) AS datos2Date, DATE(e.transactionDate) AS efectivoDate,
d.amount AS datos2Amount, d.currency AS datos2Currency,
e.amount AS efectivoAmount, e.currency AS efectivoCurrency,
d.reference AS datos2Ref, e.reference AS efectivoRef,
fx.lo AS rateLo, fx.hi AS rateHi
FROM transactions d
JOIN transactions e
ON e.customerId = d.customerId
AND e.legacySourceTable = 'EFECTIVO'
AND e.voidedAt IS NULL
AND e.reference = SUBSTRING(d.reference, 2)
JOIN customers c ON c.id = d.customerId
LEFT JOIN (
SELECT YEAR(effectiveDate) AS y, MIN(rate) AS lo, MAX(rate) AS hi
FROM exchange_rates GROUP BY YEAR(effectiveDate)
) fx ON fx.y = YEAR(d.transactionDate)
WHERE d.voidedAt IS NULL
AND d.legacySourceTable = 'datos2'
AND d.reference REGEXP '^C[0-9]+$'
ORDER BY d.transactionDate
`,
);
// Pass two — the leads pass one could not follow. Same shape of row, judged
// by the same money rules below, so a folio typo costs nothing.
const nearby = await prisma.$queryRawUnsafe(
`
SELECT d.id AS datos2Id, e.id AS efectivoId, c.name,
DATE(d.transactionDate) AS datos2Date, DATE(e.transactionDate) AS efectivoDate,
d.amount AS datos2Amount, d.currency AS datos2Currency,
e.amount AS efectivoAmount, e.currency AS efectivoCurrency,
d.reference AS datos2Ref, e.reference AS efectivoRef,
fx.lo AS rateLo, fx.hi AS rateHi
FROM transactions d
JOIN transactions e
ON e.customerId = d.customerId AND e.legacySourceTable = 'EFECTIVO'
AND e.voidedAt IS NULL AND e.amount > 0
AND ABS(DATEDIFF(e.transactionDate, d.transactionDate)) <= 3
JOIN customers c ON c.id = d.customerId
LEFT JOIN (
SELECT YEAR(effectiveDate) AS y, MIN(rate) AS lo, MAX(rate) AS hi
FROM exchange_rates GROUP BY YEAR(effectiveDate)
) fx ON fx.y = YEAR(d.transactionDate)
WHERE d.voidedAt IS NULL AND d.legacySourceTable = 'datos2'
AND d.reference REGEXP '^C[0-9]+$'
AND NOT EXISTS (
SELECT 1 FROM transactions x
WHERE x.customerId = d.customerId AND x.legacySourceTable = 'EFECTIVO'
AND x.voidedAt IS NULL AND x.reference = SUBSTRING(d.reference, 2)
)
ORDER BY d.transactionDate
`,
);
const [unpaired] = await prisma.$queryRawUnsafe(
`
SELECT COUNT(*) AS n
FROM transactions d
WHERE d.voidedAt IS NULL AND d.legacySourceTable = 'datos2'
AND d.reference REGEXP '^C[0-9]+$'
AND NOT EXISTS (
SELECT 1 FROM transactions e
WHERE e.customerId = d.customerId AND e.legacySourceTable = 'EFECTIVO'
AND e.voidedAt IS NULL AND e.reference = SUBSTRING(d.reference, 2)
)
`,
);
// ---- CSV escapes -------------------------------------------------------
if (args.includes("--csv-a")) return dumpCsv(floorless);
if (args.includes("--csv-b")) return dumpCsv([...pairs, ...nearby]);
// ---- report ------------------------------------------------------------
console.log("\nBOOK (voided and outstanding rows excluded)");
console.log(` raw lifetime sum, no floor ${money(book.rawMxn)} MXN ${money(book.rawUsd)} USD`);
console.log(` today (per-customer BF floor) ${money(book.todayMxn)} MXN ${money(book.todayUsd)} USD`);
console.log(` flat floor at ${cutover} ${money(book.flooredMxn)} MXN ${money(book.flooredUsd)} USD`);
const preRowsTotal = floorless.reduce((s, r) => s + d(r.preRows), 0);
const wouldZero = floorless.filter((r) => d(r.postRows) === 0);
const deltaMxn = floorless.reduce((s, r) => s + (d(r.todayMxn) - d(r.afterMxn)), 0);
const deltaUsd = floorless.reduce((s, r) => s + (d(r.todayUsd) - d(r.afterUsd)), 0);
console.log(`\nA. FLOORLESS CUSTOMERS — ${floorless.length}`);
console.log(` pre-cutover rows they still count: ${preRowsTotal}`);
console.log(` of those rows: ${d(split.charges)} charges (${d(split.chargeMxn).toFixed(2)})` +
` vs ${d(split.credits)} credits (${d(split.creditMxn).toFixed(2)})`);
console.log(` balance moved by flooring: ${money(-deltaMxn)} MXN ${money(-deltaUsd)} USD`);
console.log(` customers left with NO rows at all after the cut: ${wouldZero.length}` +
` (their balance becomes 0 — an assertion, not a migrated figure)`);
console.log(
`\n ${"customer".padEnd(30)} ${"pre".padStart(4)} ${"post".padStart(4)}` +
` ${"today MXN".padStart(13)} ${"after MXN".padStart(13)} ${"first tx".padStart(10)}`,
);
for (const r of floorless.slice(0, limit)) {
console.log(
` ${(r.name || "(sin nombre)").slice(0, 30).padEnd(30)}` +
` ${String(d(r.preRows)).padStart(4)} ${String(d(r.postRows)).padStart(4)}` +
` ${money(r.todayMxn)} ${money(r.afterMxn)} ${day(r.firstTx).padStart(10)}`,
);
}
if (floorless.length > limit) console.log(` ... ${floorless.length - limit} more (--csv-a)`);
// A folio match is a hypothesis; the money is the evidence. The band is
// widened by 10% either side of what exchange_rates observed that year,
// because the office keys receipts at its own counter rate, not at a
// published one, and a pair should not be called false over a few centavos.
const classify = (p) => {
const dAmt = d(p.datos2Amount);
const eAmt = d(p.efectivoAmount);
if (p.datos2Currency === p.efectivoCurrency) {
return Math.abs(dAmt - eAmt) < 0.005 ? "confirmed" : "suspect";
}
if (p.efectivoCurrency !== "USD" || p.datos2Currency !== "MXN") return "suspect";
if (!eAmt || !p.rateLo) return "suspect";
const implied = dAmt / eAmt;
return implied >= d(p.rateLo) * 0.9 && implied <= d(p.rateHi) * 1.1
? "confirmed"
: "suspect";
};
for (const p of pairs) {
p.pass = "folio";
p.verdict = classify(p);
}
for (const p of nearby) {
p.pass = "proximity";
p.verdict = classify(p);
}
pairs.push(...nearby);
const confirmed = pairs.filter((p) => p.verdict === "confirmed");
const suspect = pairs.filter((p) => p.verdict === "suspect");
const byCust = new Set(confirmed.map((p) => p.name));
const sameCur = confirmed.filter((p) => p.datos2Currency === p.efectivoCurrency);
const converted = confirmed.filter((p) => p.efectivoCurrency === "USD" && p.datos2Currency === "MXN");
const efecMxn = confirmed.reduce((s, p) => s + (p.efectivoCurrency === "MXN" ? d(p.efectivoAmount) : 0), 0);
const efecUsd = confirmed.reduce((s, p) => s + (p.efectivoCurrency === "USD" ? d(p.efectivoAmount) : 0), 0);
console.log(`\nB. DOUBLE-BOOKED RECEIPTS — ${confirmed.length} confirmed pairs across ${byCust.size} customers`);
const byFolio = confirmed.filter((p) => p.pass === "folio").length;
console.log(` candidates examined: ${pairs.length} (confirmed ${confirmed.length}, rejected on money ${suspect.length})`);
console.log(` found by folio cross-reference: ${byFolio}, by proximity after a folio miss: ${confirmed.length - byFolio}`);
console.log(` confirmed same-currency, amount equal to the cent: ${sameCur.length}`);
console.log(` confirmed USD receipt posted to datos2 in MXN: ${converted.length}`);
console.log(` datos2 C-refs with no EFECTIVO partner at all: ${d(unpaired.n)}`);
console.log(` EFECTIVO side of the confirmed pairs: ${money(efecMxn)} MXN ${money(efecUsd)} USD`);
console.log(
`\n ${"customer".padEnd(28)} ${"datos2".padStart(10)} ${"efectivo".padStart(10)}` +
` ${"datos2 amt".padStart(13)} ${"efectivo amt".padStart(13)} ref`,
);
for (const p of confirmed.slice(0, limit)) {
console.log(
` ${(p.name || "(sin nombre)").slice(0, 28).padEnd(28)}` +
` ${day(p.datos2Date).padStart(10)} ${day(p.efectivoDate).padStart(10)}` +
` ${money(p.datos2Amount)} ${p.datos2Currency}` +
` ${money(p.efectivoAmount)} ${p.efectivoCurrency} ${p.datos2Ref}/${p.efectivoRef}`,
);
}
if (confirmed.length > limit) console.log(` ... ${confirmed.length - limit} more (--csv-b)`);
if (suspect.length) {
console.log(`\n REJECTED — folio matched, money did not. Not duplicates on this evidence:`);
for (const p of suspect.slice(0, limit)) {
const implied =
d(p.efectivoAmount) && p.datos2Currency !== p.efectivoCurrency
? ` implied ${(d(p.datos2Amount) / d(p.efectivoAmount)).toFixed(2)}`
: "";
console.log(
` ${(p.name || "(sin nombre)").slice(0, 28).padEnd(28)}` +
` ${day(p.datos2Date).padStart(10)} ${day(p.efectivoDate).padStart(10)}` +
` ${money(p.datos2Amount)} ${p.datos2Currency}` +
` ${money(p.efectivoAmount)} ${p.efectivoCurrency} ${p.datos2Ref}${implied}`,
);
}
if (suspect.length > limit) console.log(` ... ${suspect.length - limit} more (--csv-b)`);
}
console.log(
"\nNOTE: nothing above has been changed. Section A is a proposal to move the\n" +
"floor, not a carried-forward balance: the pre-cutover charge ledger was\n" +
"never migrated, so no true opening balance can be computed from this\n" +
"database. It exists in the DreamHost per-year tables. Section B is an\n" +
"independent defect and does not need a corte to fix.",
);
} finally {
await prisma.$disconnect();
}
}
function dumpCsv(rows) {
if (!rows.length) return;
const cols = Object.keys(rows[0]);
console.log(cols.join(","));
for (const r of rows) {
console.log(cols.map((c) => JSON.stringify(r[c] == null ? "" : String(r[c]))).join(","));
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});