6 Commits
Author SHA1 Message Date
rmancinasandClaude Opus 5 c4213aa697 fix(audit): the corte audit computed a book the app no longer shows
The script carried its own copy of the balance rules, and since 1.0.26 that
copy was stale: it reported 631,078.98 MXN where the application reports
-416,403.42. An audit that disagrees with production is worse than no audit,
because its numbers look authoritative and diff cleanly against yesterday's
run. It now mirrors NOT_CASH_JOURNAL and archiveIsHistorySql, keeps the
floor-only figure as a labelled line so older runs still diff, and says
plainly which line is the app's.

Section B stops being an open defect list. EFECTIVO is a receipt book whose
receipts are posted to the datos2 ledger by design, so those rows exist and
always will; what matters is whether any of them still reaches a balance.
That is now a counted assertion which must stay at 0, and it fails loudly if
someone writes a balance query that forgets the exclusion.

Section A grew the line that changes its recommendation. "Floor them, never
carry" was written when the floorless group looked like utilities receipts
whose charges were never migrated. Measured on production today: of the 99,
ninety-five carry insurance-line cash and NONE carry utilities rows. The
seguros EFECTIVO is that line's only ledger, so flooring them deletes
receipts rather than removing a double count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:05:47 -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 17d83291c3 feat(migration): refuse a full re-import that would delete native rows
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m51s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m42s
A full run_all.py pass truncates and rebuilds every table it owns from the
Access extract. That was harmless while the platform was a read-only mirror --
every row came from the extract, so wiping and rebuilding lost nothing. It
stopped being harmless once the platform started minting rows Access has never
heard of: allocated portal NUMids, customers created in the staff UI,
OCR-captured policies, app-booked ledger rows, uploaded documents.

REIMPORT is a button in /operaciones, so that was one click away.

native_guard.py counts what only exists here and exits 3; run_all.py runs it
before the first truncate and stops. Detecting an allocated NUMid needs the
staged Parquet -- the customer holds an ordinary-looking (utilities, DATGRAL,
'1172') ref, so "customer has no refs" cannot see it and only comparing against
the extract can. Missing staging is therefore treated as blocking rather than
as "nothing to protect".

The guard does not teach full mode to preserve anything: --sync already upserts
legacy rows against the existing refs and leaves the rest alone, and rebuilding
that inside full mode would re-implement it. --force-full (checkbox in the
REIMPORT confirm, recorded in the audit log) deletes them deliberately.

Verified against dev: clean before, exit 3 listing utilities/1172 with a
synthetic ref present, clean again after removing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 21:01:01 -07:00
rmancinasandClaude Opus 5 6a97242fc3 feat(customers): allocate portal NUMids, with an audit for reusable ones
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m59s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m13s
Customers created in the staff UI had no NUMid and so could not log in to
my.jorgecuadros.com at all: the id is a CustomerLegacyRef row, not a column,
and create() deliberately writes none.

Allocation is a staff action (POST /customers/:id/portal-access, MANAGER)
rather than part of create, because insurance is expected to move to the
platform before utilities and an insurance-only customer has no reason to
spend a utilities id.

The audit that decides which ids are reusable took three passes. "Owns no
rows" matches nobody -- migration gave all 1,171 NUMids a property and a
transaction. "No transaction in N years" also matches nobody -- every customer
carries a synthetic Jan-1 opening-balance row, so everyone looks active this
year. Subtracting that row is what makes dormancy measurable, and it leaves 4
never-used ids and 10 dormant ones on dev. Two further traps are encoded in the
queries: insurance/DATGRAL is a separate id space that reuses the sourceTable
name and runs past 4,000, and ACCOUNT CANCELED is a transaction line type, not
an account state -- all 8 customers carrying it have current-year activity.

Recycling ships switched off (numid.recycleEmpty, default false). Every
reusable id still exists in Access DATGRAL, and a --sync run reassigns refs
with ON DUPLICATE KEY UPDATE customerId, so an id recycled before the utilities
cutover is silently handed back to its Access owner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:04:13 -07:00
rmancinasandClaude Opus 5 4ee7ec71f0 feat(deploy): prisma migration history, /version, galactus standalone deploy
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m49s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m2s
Closes the gap between "what tag did I deploy" and "what is actually running",
and gives the schema a history that can be reasoned about across releases.

Migrations
- Baseline the existing schema as 0000_init (migrate diff --from-empty). The
  schema had only ever been applied with `prisma db push`, so no history
  existed and schema state was disconnected from app version. Existing
  databases must be baselined once with `migrate resolve --applied 0000_init`;
  the workflows print this remedy on P3005.
- Run `prisma migrate deploy` as a deploy STEP, not the container CMD — as a
  CMD, N replicas would race each other applying the same migration.

Version reporting
- GET /version on the API reports the APP_VERSION / GIT_SHA / BUILD_DATE that
  build.yml already baked into both images but nothing ever read.
- The web footer shows the web build and flags an api/web mismatch. The two
  cannot drift at build time (one matrix run) but can at deploy time.
- Both deploy workflows now fail if the running API does not report the tag
  that was dispatched — a stack naming a tag is not proof of what is running.
- scripts/set-version.mjs stamps every package.json, which had all sat at
  0.1.0 while real releases shipped as v1.x.

Pre-migrate backup
- deploy/scripts/pre-migrate-backup.mjs dumps the database from INSIDE the
  still-running old API container over Portainer's Docker API, so the file
  lands in the volume the Operaciones restore screen reads. A dump taken on
  the CI runner would be unreachable by the only restore path we have.
  Verifies the artefact with `gzip -t` before letting the migration proceed.

galactus
- deploy/galactus/*.compose.yml: standalone-Docker ports of the Swarm stacks.
  Plain compose silently ignores `deploy:`, so restart_policy becomes
  `restart: unless-stopped` — without it nothing returns after a host reboot.
- .gitea/workflows/deploy-galactus.yml drives endpoint 3 with its own secrets.

Fixes
- deploy.yml passed `endpoint_id` and `pull_image` to
  cssnr/portainer-stack-deploy-action, which has no such inputs (they are
  `endpoint` and `pull`). The endpoint was silently never set.

docs/DEPLOY_AND_MIGRATIONS.md documents expand/contract as the rule for schema
changes: Prisma has no down-migrations, so a code rollback never rolls the
schema back, and restoring the replication master from a dump diverges every
replica.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:41:12 -07:00