The --sync path had never been run and was broken in several ways. Fixed and verified against the dev DB (two consecutive syncs, both exit 0, 32/32 assertions: stable PKs, manual-row preservation, changed-row updates, legacy-delete, no child duplication, zero FK orphans; idempotent). - policies/properties: reuse each legacy row's existing id (by provenance) BEFORE building child rows, so children no longer point at a discarded fresh uuid; rebuild legacy-owned children via scoped delete + reinsert. - customers: replace zip(customers, refs) (mispaired almost every row) with a ref-grouped id remap; names now restore and no spurious customers appear. - drop the invalid Vehicle @@unique(legacySourceTable, legacyId) — one legacy policy row carries up to 3 vehicles sharing a legacyId; handle via delete+reinsert. - upsert lookup tables (policy_types, insurance_providers, type_transactions, adjusters) by natural name and remap child FKs instead of inserting fresh uuids that nothing points at. - transactions: drop updatedAt=NOW() (no such column); guard report formatting on NULL legacySourceTable (manual rows). Same report guard in bank. - add manual-safe prune (prune_empty_customers.py --sync, in SYNC_STEPS): prune only legacy-owned empties, never manually-added customers. web: customer-detail mini tx list now strikes voided rows with an "(anulado)" tag (was the last void-UI rendering gap; /estado-cuenta already handled it). docs: RESUME.md updated — Phase B sync marked verified end-to-end, void-UI browser pass recorded. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
36 KiB
Resume Notes — Jorge Cuadros & Assoc. Unified Platform
Comprehensive state-of-the-world doc for picking this project back up. Read this before doing anything else in a fresh session — it front-loads everything that took multiple rounds of investigation to establish.
Companion doc: the full architecture/migration plan is PLAN.md in
this repo — that is the source of truth for the design. (It began as
~/.claude/plans/logical-yawning-tome.md on the old Windows machine; that copy is
gone and no longer authoritative.) This file is the "what happened and what's next"
companion, not a replacement. Read both.
1. The goal
Jorge Cuadros & Assoc. runs two lines of business — property/utility management and insurance brokerage — out of separate, decades-old MS Access databases, plus a third Access file that's the office's own bank checking register. The same people are customers of both lines but there's no shared customer record between systems. Goal: one platform with a single unified customer record, from which staff see and manage that customer's utility services and insurance policies and shared billing/transaction history — replacing the Access files and the old, insecure PHP internal app.
There is also a separate, pre-existing customer-facing portal (PHP + MySQL, with a companion mobile app) that customers use to view statements, make payments, and order propane. That portal is out of scope to rebuild — it stays exactly as-is — but the new platform has to keep it supplied with live data. That constraint is what drove the database-engine and infrastructure decisions below.
2. Where everything lives (file paths)
Paths below are the current macOS machine. The project moved Windows → macOS on 2026-07-22; anything still written as
C:\Users\ricar\...in older notes is stale.
Source data (do not modify — read-only references), all in ~/Downloads/JorgeCuadros-Legacy/:
UTILITIES.accdb— utilities business, 52 tables, ~538MBSEGUROS 16.mdb— insurance frontend shell, no data tables, but holds all of the insurance line's Reports/Forms/QueriesSEGUROS 16_be.mdb— insurance backend, 64 tables, ~882MBSCOTHIA.mdb— office's own Scotiabank checking register ("chequera"), 7 tables, ~3MButility_dbo.sql— customer portal's live DB dump (1.3 GB, 55 tables)jorgecuadros.sql— older/partial export (38 MB, 11 tables), not the portal live DB- Full structural reference for all three, usable without Windows or the original files:
docs/LEGACY_DATABASES.md— every table, every column with type/nullability, the cross-reference keys between the three databases, and every known data-quality quirk (the UTF-16 decode bug, the corruptedMULTrow, near-duplicate snapshot tables, etc.), all generated from a live read of the real files viamigration/catalog_schema.py. Regenerate it if the source files change; the raw JSON it's built from is checked in atmigration/catalog.json. - Queries/Forms/Reports reference:
docs/LEGACY_DATABASES_OBJECTS.md— none of this is visible via ODBC/pyodbc; it required DAO COM automation (migration/catalog_objects.py, needspywin32) instead. Found 311 Reports, 271 Forms, and 1,274 Queries (751 "real," the rest Access-internal hidden subquery caches) across the three populated files — importantly,SEGUROS 16.mdb(which has zero data tables) turned out to hold all of the insurance line's Reports/Forms/Queries;SEGUROS 16_be.mdbis confirmed pure data storage. The real queries' full SQL text is the best available record of actual business logic (billing math, renewal batching) — worth reading before reimplementing any given feature from scratch. Raw JSON checked in atmigration/objects.json. jorgecuadros_app.sql/jorgecuadros_app (1).sql(on the old machine) — MySQL dumps of the portal's tracking/analytics sidecar DB (browse_tracking,devicespush-tokens,task_tracking). Not the portal's real data DB; superseded byutility_dbo.sqlabove.
Customer-facing portal (out of scope to rebuild, but the sync target):
~/PhpstormProjects/my-jorgecuadros-web— PHP/mysqli, ~397 files, core inscripts/functions.php. Reads/writesutility_dbo.
Old internal app (reference-only, not being built on):
jorgecuadros-intra-webapp(on the old machine) — PHP, MySQL (webapp_jorgecuadros). Itsdb/webapp_jorgecuadros.sqlis a useful reference for field mappings/business logic. Code itself is not reused — see §4.
New platform (the actual deliverable):
~/WebstormProjects/jorgecuadros-platform— the repo. Is a git repo, branchmaster, 21 commits, remotegit.mancinas.io/rmancinas/jorgecuadros-platform.
The plan document:
PLAN.mdin this repo — full architecture, source-data inventory per table, target data model, migration strategy, infrastructure/sync design, locked decisions, build sequencing. This is now the source of truth for the design (the original~/.claude/plans/logical-yawning-tome.mdlived on the old Windows machine). This RESUME.md is the "what happened / what's next" companion.
Staged data (gitignored, regenerable):
migration/output/stg_utilities|stg_seguros|stg_scothia/*.parquet— regenerate in ~2 min withload_staging.py --output-dir ./output. Every transform step reads from here.
3. Key decisions (locked — see PLAN.md → "Decisions (locked)")
| Decision | Answer | Why |
|---|---|---|
| Stack | Next.js (React/TS) + NestJS (TS) + Prisma | Type safety, parameterized queries by default (kills the SQL-injection class of bug the old app had everywhere) |
| Database engine | MySQL (not Postgres — reversed mid-session) | The customer-facing portal's PHP code (mysqli) isn't being rewritten, and shared hosting can't run Postgres. Using MySQL everywhere avoids a cross-engine sync layer. |
| Repo | New repo, not built on jorgecuadros-intra-webapp |
That repo has SQL injection in every query (src/core/db.php string-concatenates $_POST) and plaintext password comparison (src/core/auth.php) — not worth patching |
| Historical data | Migrate everything, no cutoff | Source tables are small (largest ~16k rows); completeness is cheap |
| Customer portal | Stays as-is, not rebuilt | Explicit user decision |
| Infrastructure | Internal server (private) + new VPS (Tailscale-linked) running a MySQL replica | Internal server has no inbound internet exposure; shared hosting can't be a replication target; a VPS you control can be both a real replication node and internet-reachable for the portal |
| Auth mechanism | Session-based (Passport + express-session), Argon2 password hashing |
Implemented already — see §5 |
4. What is built and verified
Everything below was run and confirmed working, not just written. §8 carries the per-module detail and the running status; this section is the structural tour.
4.1 Repo scaffold
jorgecuadros-platform/— npm workspaces (apps/*,packages/*)apps/api— NestJS. Builds clean under strict TypeScript (npx nest buildinapps/api, zero errors).src/main.ts— globalValidationPipe(whitelist + forbid unknown fields — structural replacement for the old app's total lack of input validation), session middleware, CORS.src/auth/—AuthService.validateUser()verifies passwords withargon2.verify()(replacespasswd = '$password'plaintext SQL comparison in the old app),LocalStrategy,SessionSerializer,AuthenticatedGuard(replaces manually-calledvalidate_session()),AuthController(/auth/login,/auth/me,/auth/logout).src/users/,src/prisma/(globalPrismaModule/PrismaService).
apps/web— Next.js App Router shell. Builds clean (npx next build).packages/database— Prisma schema + generated client.
4.2 Prisma schema (packages/database/prisma/schema.prisma)
Full target data model implementing the plan's design, validated and generating a working client against MySQL:
- Identity:
Customer,CustomerLegacyRef(generalizes the oldcustomer_mappingbridge table — one row per legacy record folded into a unified customer, with provenance) - Insurance:
InsuranceProvider,PolicyType,Policy(consolidatesINCENDIO/MULT/M EMPR/6 auto-table variants/LICENCIASinto one table with a type discriminator),PolicyPaymentInstallment(unpivots the 4 hardcoded payment-installment columns found on every legacy policy table),Vehicle(unpivotsMCA2's 3 hardcoded vehicle slots),InsuredDriver,PolicyBeneficiary,Claim,Adjuster,PolicyDocument - Utilities:
Property(shared with insurance — the actual unification point),PropertyService,ServiceDocument,TrustAccount - Shared ledger:
Transaction(unifies all theEFECTIVO*/billing-period snapshot tables),ExchangeRate,TypeTransaction - Bank register (SCOTHIA):
BankTransaction,BusinessLineCategory - Admin:
User(hashed passwords, roles),ActivityLog,EmailTemplate,EmailCampaign,EmailLog
Every model sourced from a legacy Access table carries legacySourceDb/legacySourceTable/legacyId provenance columns for traceability and idempotent re-runs. Long-text fields (notes, observations, description, etc.) are explicitly @db.Text — MySQL's default String is VARCHAR(191) and would silently truncate them otherwise (this was caught and fixed during the Postgres→MySQL swap).
Regenerate the client any time with:
cd jorgecuadros-platform
DATABASE_URL="mysql://user:pass@localhost:3306/placeholder" npx prisma generate --schema=packages/database/prisma/schema.prisma
(A real DATABASE_URL isn't needed for generate/validate, just a syntactically valid one. A live dev DB is available now — see §7 — so prisma db push works too.)
4.3 Docker Compose / Dockerfiles
docker-compose.yml—mysql:8.4+api+webservices, healthchecked.docker/api.Dockerfile,docker/web.Dockerfile— multi-stage builds..env.example—DATABASE_URL,SESSION_SECRET,WEB_ORIGIN,NEXT_PUBLIC_API_ORIGIN.- Not run — this environment has no Docker installed (
docker --versionfails). Untested beyond visual review; verify on a machine with Docker before relying on it.
4.4 Migration pipeline (migration/) — run end-to-end against real data
config.py— manifest of the Access source files (SOURCE_ROOT+ per-source exclude lists for confirmed-scratch tables, with reasoning in comments)extract.py— shells out to mdbtools (mdb-tables/mdb-export, Homebrew). Rewritten from the originalpyodbc+ Windows Access ODBC version during the macOS move; public interface (connect/list_tables/read_table) unchanged. mdbtools also sidesteps both bugs the pyodbc path needed workarounds for: it reads accented-column tables (PROPANO,FALTANTES AGUA,TIT) cleanly instead of hitting a UTF-16 decode error, and it doesn't abort a whole table onMULT's corrupted row.- What mdbtools cannot do is read Forms/Reports/Queries. Those were already captured on Windows via DAO COM and are frozen in
migration/objects.json+docs/LEGACY_DATABASES_OBJECTS.md— nothing is lost, but they can't be re-extracted on this machine.
- What mdbtools cannot do is read Forms/Reports/Queries. Those were already captured on Windows via DAO COM and are frozen in
load_staging.py— dumps every non-excluded table into either Parquet (--output-dir, no DB needed) or MySQL (--database-url, one database per source:stg_utilities/stg_seguros/stg_scothia). 82 tables staged, zero unhandled errors.reconcile.py→RECONCILIATION.md— the duplicate/distinct pass (step 2). See §8 step 3.transform_*.py,prune_empty_customers.py,blob_extract.py— steps 3–4, all idempotent (truncate + rebuild).run_all.py— the entry point. Runs every step in dependency order. See the ⚠️ in §7 for why you should never run a single transform on its own.dbenv.py—--env <name>readsdeploy/.env.<name>for the target DB.requirements.txt—pandas,pyarrow,sqlalchemy,pymysql,boto3(nopyodbc— that was the Windows path).
To rerun (from migration/, venv at migration/.venv):
./.venv/bin/python load_staging.py --output-dir ./output # re-extract from Access (needs mdbtools + the source files)
./.venv/bin/python run_all.py --env dev # full transform+load; add --stage to re-extract first
./.venv/bin/python run_all.py --env dev --sync # additive sync: upsert legacy by provenance, keep manual rows, prune legacy empties
--sync mode (Phase B) upserts legacy-owned rows by their provenance keys and preserves
manual rows (legacyId IS NULL); every transform reuses each row's existing PK, rebuilds
legacy-owned children by scoped delete + reinsert, and drops legacy rows gone from source.
Verified end-to-end against dev 2026-07-24 — see §6 item 6.
5. Infrastructure & sync architecture (designed, not yet built)
- Internal server — on-prem, private IP
192.168.1.xx, no inbound internet exposure. Runs the platform + canonical MySQL (source of truth). - New VPS (Hetzner or DigitalOcean — not yet provisioned) — becomes what
mysql.freakma.comresolves to. Runs a MySQL replica. Because it's infrastructure the user controls (unlike shared hosting), it can be a real MySQL replication target. - Tailscale — mesh VPN joining the internal server and the VPS, so they can reach each other without opening inbound ports anywhere.
- Internal → VPS: one-way native MySQL replication (binlog/GTID) for the subset of data the portal needs to read (statements, balances, customer profile). Internal-only tables (staff notes, adjuster info, activity logs) are deliberately excluded from what replicates.
- VPS → Internal: the portal also writes (payment submissions, propane orders) — one-way replication can't carry that back, and multi-master MySQL replication was deliberately ruled out as too fragile for this system's size. Instead: unreplicated "inbox" tables on the VPS (
payment_submissions,propane_order_requests) that the portal writes to directly, polled every 1–5 min by a worker on the internal server (over Tailscale) that turns new rows into real records.
6. Open items
Resolved since this section was first written (kept as a pointer, not a to-do):
utility_dbo schema (full dump on disk), CI/CD (Gitea Actions), i18n (Spanish-first), and
the reconciliation pass (done, then corrected) are all closed. See §3 and §8.
Still open:
-
VPS not yet provisioned — provider (Hetzner vs DigitalOcean), size, Tailscale + MySQL replica setup. Pure ops task; the design is settled (§5). This is the only genuinely blocking item left on the roadmap.
-
Sync worker not built — unblocked now that
utility_dboand the portal code are on disk, but depends on the VPS existing. Portal write points to poll:peticion_gas, PayPal payments,notifications_settings,verification_codes. -
Old external-DB credential — the old repo's
dbConnection.phphas a hardcoded plaintext MySQL password committed to git history. Not carried into the new platform, but rotate it regardless; it is already exposed. -
bank_transactions.categoryIdis null on all 22354 rows — RESOLVED as won't-build (plan step 7). The concept→ramo classifier was investigated and dropped:conceptois a payee name (0 of 22354 match a category), and TABLA RAMODOS is a property-management expense chart of accounts + owner names, not the insurance/servicios/fideicomiso split it was assumed to be — so a classifier would invent data rather than produce a business-line view. The/bancomodule intentionally has no category dimension. See §8 step 7(b). -
TRASPASOS PAYPALis a clearing account, not a customer — carries -7.03M MXN over 309 movements and therefore tops the adeudo worklist. Deliberately not special-cased in code; needs a business decision on how to model it. -
DB Operations — Phase B (additive sync) — VERIFIED END-TO-END against dev DB 2026-07-24. Phase A provides the admin-only
/operacionespage +opsAPI module (abilitydb:manage, ADMIN), ingest folder, backup, restore, and destructive re-import. Phase B enablesSYNC:OpsServicecreates a safety backup and runsrun_all.py --sync; transforms upsert legacy-owned rows by provenance keys while preserving existing PKs and rows whoselegacyId IS NULL(manual). Prisma enforces provenance uniqueness for properties, policies, transactions, and bank transactions (the vehicle unique was removed — one legacy policy row carries up to 3 vehicles that share alegacyId, so provenance is not unique per vehicle; vehicles are rebuilt by scoped delete + reinsert). Sync skips blob extraction, and runs a manual-safe prune (prune_empty_customers.py --sync— only prunes empties that carry a legacy ref, never manually-added customers) because the customer upsert otherwise re-creates every previously-pruned empty from Parquet.The as-written sync was broken and had never been run; a batch of bugs were fixed on 2026-07-24 before it passed (fresh-uuid child FKs in policies/properties, unconditional child inserts, a
zip(customers, refs)mispairing in transform_customers, invalid vehicle unique, lookup tables built with fresh uuids but never upserted, aupdatedAt=NOW()on a table with no such column, and report crashes on NULLlegacySourceTablefor manual rows). Verified withmigration/verify_sync.py-style harness: two consecutiverun_all.py --syncruns both exit 0 and pass 32/32 assertions (stable PKs, manual-row preservation, changed-row updates, legacy-delete, no child duplication, zero FK orphans), idempotent (customers stable at 1537). Schema pushed to dev, Prisma client regenerated, API build clean. Migration/web changes uncommitted as of this update. Still open before production: run the same sync from the/operacionesUI (OpsService path) and against a prod-shaped DB.
7. Environment notes (current macOS machine)
- macOS (Darwin 25.5.0), zsh. Node v22.23.0. Python 3.14.6 in
migration/.venv. Homebrew, Docker, MySQL/MariaDB client all present. - mdbtools installed via Homebrew — the extraction toolchain. No Access ODBC driver (and none needed).
npmis pnpm-aliased, and pnpm ignores theworkspacesfield. Consequences:- there is no root
node_modules/.bin. Binaries live per-app:apps/api/node_modules/.bin/nest,apps/web/node_modules/.bin/next. - Prisma CLI is run as
npx prisma@5.
- there is no root
- Dev servers (both must be up to use the UI):
- API
cd apps/api && ./node_modules/.bin/nest start --watch→:3001 - Web
cd apps/web && ./node_modules/.bin/next dev→:3000 - Dev login:
admin@jorgecuadros.local, password fromapps/api/scripts/seed-user.mjs(SEED_PASSWORDenv overrides the default).
- API
- Dev DB:
192.168.4.212:3307(cubex Swarm stackjorgecuadros-dev-db). Credentials in gitignoreddeploy/.env.dev. MinIO for documents:192.168.4.212:9100, bucketjorgecuadros-documents.
Traps worth knowing before you lose an hour to one:
- ⚠️ Never run a single
transform_*.pyon its own — userun_all.py. Each step truncates what it owns, so a lone run orphans everything downstream.prune_empty_customers.pymust re-run after any ledger change, andblob_extract.pymust follow properties + policies or the uploaded MinIO objects end up with no rows pointing at them. - ⚠️ Never run
next buildwhilenext devis running — they share.nextand the dev server starts serving blank white pages. Recovery: kill the dev server,rm -rf apps/web/.next, restart. - ⚠️
mdb-exportformats numerics per Access column type (5000from one table,27000.0000from another). Never string-compare staged Parquet numerics across two tables — canonicalize first. This exact trap produced a wrong, locked migration decision that shipped 12386 duplicate rows into the ledger (§8 step 4). - Shell on this machine:
headis aliased to an HTTP HEAD tool — use/usr/bin/head.grep --include=*.mdtrips zsh globbing — quote the pattern.
8. Plan locked — next actions
All four previously-open decisions are now locked (2026-07-22). See PLAN.md
→ "Decisions (locked)" for the authoritative record:
- Extraction toolchain (macOS): mdbtools (installed + verified against the real files).
- i18n: Spanish-first.
- CI/CD: Gitea Actions on
git.mancinas.io→ registry → Portainer. utility_dbo: resolved — full dump (utility_dbo.sql, 1.3 GB, 55 tables) and the portal codebase (~/PhpstormProjects/my-jorgecuadros-web) are both on disk.
Environment: moved Windows → macOS (2026-07-22). See §7 for the current machine.
Execution queue. Steps 1–6 below are done; they are kept because each carries the data findings and corrections that came out of doing it. Skip to the ⏭ marker at the end for what's actually next.
-
Port the extraction layer to mdbtools.DONE. Rewrotemigration/extract.pyto shell out tomdb-tables/mdb-exportinstead ofpyodbc. Keep the same public interface (connect/list_tables/read_table) soload_staging.pyandconfig.pyare unchanged beyond the already-fixedSOURCE_ROOT. Carry over the two hard-won fixes conceptually: accented-column tables (mdbtools readsPROPANOcleanly — verified) and the corruptedMULTrow (mdb-export's-b/ error handling; confirm the bad row is skipped, not fatal). -
Re-run stagingDONE — staged Parquet regenerated on this machine (load_staging.py --output-dir ./output), 82 tables. -
Reconciliation pass (plan step 2) — DONE (
migration/reconcile.py→RECONCILIATION.md), and corrected 2026-07-22. Current verdicts:EFECTIVO_BACKUPis a stale backup copy ofEFECTIVO— 12386 of its 12387 rows are verbatim duplicates (customer + timestamp-to-the-second + amount + concept text), leaving 1 new row. Load EFECTIVO in full, de-dup BACKUP on the business key. Never de-dup onfolio— it is per-table sequential and collides (12363 shared numbers, 12204 of them on different payments).- The billing tables (
datos2/FEE ANUAL/fee15) are disjoint period runs — union all, no de-dup. COBRO3is a charge batch, not a customer snapshot —DATGRALis the sole master.
⚠️ This file and
PLAN.mdpreviously said the opposite about EFECTIVO ("near-disjoint ledgers, migrate both"). That was a bug, not a finding — see the Shared ledger entry in step 4 below for the root cause and the fix. If you read a doc, comment, or commit message from before 2026-07-22 that says "migrate both, no folio de-dup", it is stale. The authoritative rules live inPLAN.mdmigration step 2. -
Transform + load (plan step 3) — IN PROGRESS.
- Customers — DONE (
migration/transform_customers.py). Loaded into the dev DB: 1682 customers (1172 utilities master + 510 insurance-only), 2242 legacy refs (all traceable), 560 insurance rows linked vianum_utilwith 0 broken refs, 542 merged identities spanning both business lines; linked customers enriched with insurance-only ID-doc fields. COBRO3 excluded. Re-runnable (truncate+rebuild); needs staged Parquet present (load_staging.py --output-dir ./outputfirst). - Properties — DONE (
migration/transform_properties.py): 1519 properties (0 orphans), 3486 services, 553 trust accounts from DATMEX/PROFILE; PROFILE flags matched 1519/1519. - Policies — DONE (
migration/transform_policies.py): config-driven consolidation of all insurance lines intopolicies(2378: AUTO 1307 / MULT 760 / LICENCIAS 306 / M_EMPR 5; 10 skipped for unresolved customer, 0 orphans) + 4678 installments, 1110 vehicles, 513 insured_drivers, 126 beneficiaries, 1 claim, 5 policy_types, 15 insurance_providers, 17 adjusters. Unmodeled coverage columns preserved verbatim incoveragesJson. Verified a unified customer (EARWOOD, DAVID) carrying both a utility property+services and 2 MULT policies — the cross-line customer view works at the data layer. - Shared ledger — DONE (
migration/transform_transactions.py): 33475 transactions (UTILITY 33180 / INSURANCE 295, 0 orphans) unioning EFECTIVO (13695) plus only the 1 business-key-new row from EFECTIVO_BACKUP, all three billing tables (datos2/FEE ANUAL/fee15), the FM3 fee stream (amount=fee+tax+multa), IVA 2015 (nominal date), and insurance EFECTIVO; plus 79type_transactionsand 2301exchange_rates. Skipped 22 no-customer + 272 no-date + 12417 EFECTIVO_BACKUP duplicates. Corrected 2026-07-22 — this used to load 45861 rows.reconcile.pyhad string-comparedmonto, which mdb-export serializes at a different precision per Access column type (5000vs27000.0000), so it saw 2 overlapping rows instead of 12386 and ruled EFECTIVO_BACKUP an independent ledger. It is a stale backup copy: 12386 of its 12387 rows match an EFECTIVO row on customer + timestamp-to-the-second + amount + concept text. The ledger was double-counting those payments, roughly doubling every customer's historical receipt total. Bothreconcile.py(canonicalizes numeric key columns now) andtransform_transactions.py(de-dups on the business key, never onfolio— folio collides) are fixed, andrun_all.py --env devhas been re-run end to end. Lesson for any future reconciliation: never compare mdb-export output as raw strings across two tables — canonicalize numerics first. - Bank register — DONE (
migration/transform_bank.py): 22354bank_transactionsfrom SCOTHIA DATOS I/E as signed amounts (income +, expense -; net +899,375.77), 66business_line_categories. No customer FK; categoryId left null (concept->ramo classifier is a later enhancement). - Documents — DONE (
migration/blob_extract.py, migration step 4): carves embedded files out of the Access OLE wrapper (magic-byte detection) and uploads to MinIO on cubex (stackjorgecuadros-dev-minio, S3 at 192.168.4.212:9100, bucket jorgecuadros-documents), writing service_documents/policy_documents pointer rows. Loaded 70 documents (3 service bills + 67 policy foto/docs, 0 orphans, ~290 MB). Data finding: the LONGBINARY columns are almost entirely empty — DATMEX's real bill-scan columns are ILUZ/IAGUA/IPREDIAL/ITEL (not doc_1/doc_2), but only 3 cells populated across 1520 rows; the *_MENS tables are mail-merge templates (correctly excluded). The 538MB/882MB source files are mostly Access bloat, not documents. Migration steps 1-4 COMPLETE. - Customer module (plan step 3) — DONE:
apps/api/src/customers/(list/search/detail/stats)apps/web/clientesand/clientes/[id], Spanish-first, verified against real data.
- Insurance module (plan step 4) — DONE:
apps/api/src/policies/(GET /policieswith search over policy number / customer / agent / plate / driver name, vigencia buckets active|expiring|expired|undated, ramo + aseguradora + liquidada filters, 5 sorts;/policies/stats,/policies/facets,/policies/:id) + web/polizas(renewals-first browser, clickable stat cells) and/polizas/[id]. Cross-links both ways with the customer view. Data finding: thepolicies.totalcolumn is dead — only 2 of 2378 rows are non-zero (1585 are literally 0, 791 null) and one of those two is lower than its own net premium, so every premium headline and the premium sort usenetPremium(populated on 2377/2378). This also fixed a live bug on the customer detail page, which was showing "$0.00 Total" for 1585 policies. - Utilities module (plan step 5) — DONE:
apps/api/src/properties/(GET /propertieswith search over address / customer / service account number / meter / trust number / phones, filters for service kind, municipality, trust bank, trust bucket (with|without|active|expiring|expired|undated) andhasServices, 5 sorts;/properties/stats,/properties/facets,/properties/:id) + web/servicios(renewals-first property browser with clickable stat cells and a clickable service-mix strip) and/servicios/[id](services, fideicomiso, linked policy, owner + sibling properties, owner-level utility ledger, documents). Cross-links both ways with the customer and policy views. Data findings: (a) the trust renewal date staff chase istrust_accounts.dueDate2— DATMEX'svence2, one year aftervence1on 531 of 541 dated trusts (18 due within 30 days, 119 already overdue); (b)properties.zoneis effectively dead (1444 of 1519 null, the rest near-unique), so it is not a facet; (c) the municipality that bills a property lives in the predial service'snotes(ROSARITO 566 / TIJUANA 221 / ENSENADA 152, 939/939 populated) — that is the real geographic filter.PropertyService.notesmeans something different per kind (municipality / CFE PAR-IMPAR cycle / gas supply type), so the UI labels it per kind. 240 of 1519 properties have no service rows at all — surfaced as its own bucket. - Billing / statements module (plan step 6) — DONE:
apps/api/src/billing/(GET /billingmovement browser with search over customer / referencia / cheque / concepto / periodo, filters for línea, moneda, cargo-vs-abono, concepto (typeId), origin table and a from/to date range, 5 sorts, and totals for the whole filtered set;GET /billing/balancesper-customer balances with owing/credit/settled buckets and 4 sorts;/billing/stats,/billing/facets,/billing/customers/:id) + web/estado-cuenta(two tabs: "Saldos por cliente" worklist and "Movimientos" ledger) and/estado-cuenta/[id](the statement: balance per currency, the same balance split by business line, cargos por concepto with proportional bars, and the full movement table with a running balance). Cross-links from the customer and property detail pages. Data findings: (a)transactions.amountis a signed ledger — every charge type is negative without exception (WATER 3115/3117, ELECTRIC 2191/2191, PROPERTY TAXES 926/926, TRUST FEE 188/188) and every deposit type positive (CHECK/CASH DEPOSIT, PAYPAL, all of EFECTIVO), soSUM(amount)is the balance and negative = the customer owes. (b) Currency is not summable. 912 of the 1269 customers with a ledger move in both MXN and USD; the charge side (datos2/FEE ANUAL/fee15) is MXN-only while receipts arrive in both, and no per-movement exchange rate was ever stored. Every figure in the module is per currency; the balance filter/sort takes a currency argument rather than collapsing. (c)type_transactions.nameEsis entirely null — the legacyTYPE OF TRXtable has anESPAÑOLcolumn but all 79 rows are empty, so the API can only return English names.labels.ts:TX_TYPE_LABELSsupplies Spanish for the real service/payment categories; the rest of the 79 "types" are payee names (LORETO GONZALEZ, ALBERCAS VALLARTA…) that fall through untranslated, which is correct. (d) The biggest debtor by far is "TRASPASOS PAYPAL" (-7.03M MXN over 309 movements) — a house/clearing account, not a person. Left in rather than special-cased, but it will head the adeudo worklist until someone decides how to model it. - Bank register module (plan step 7) — DONE:
apps/api/src/bank/(GET /bankregister browser with search over concepto/beneficiario, chequereference,notesandamountInWords; filters for direction (income|expense|void), cleared status and a from/to date range, 5 sorts, and income/expense/net totals for the whole filtered set;/bank/stats,/bank/facets(year list),/bank/summaryyear/month rollup with a running net figure) + web/banco(two tabs: "Movimientos" register and "Resumen por periodo" with clickable year→month drill-down). Added to the AppShell nav as "Chequera". Verified end-to-end in the browser: totals reconcile (6948 income + 14615 expense + 791 void = 22354; net +899,375.77 matches stats; 2013 months open at $0 and close at the year's net $794,295.78). Design decisions / data findings: (a) Kept separate from/estado-cuentaon purpose — this is the office's own chequera, not customer money; the two ledgers are never summed or shown together. Distinct nav entry, distinct page, distinct API module. (b) No category/ramo dimension, and the concept→ramo classifier was NOT built (closes open item §6.4): the data cannot support it.DATOS E/Ihave no ramo column to migrate;conceptois a payee name (PAYPAL, CFE, ~1,900 individuals), and 0 of 22354 concepts match abusiness_line_categoriesname; and the 66 TABLA RAMODOS rows are a property-management expense chart of accounts (Payroll, Pool Labor, Gardening) + owner names, not the insurance/servicios/fideicomiso split the migration comment implied. A classifier would invent data, socategoryIdstays null and the module does not filter on it. Register is browsable by date/payee/amount/cheque instead. (c) Single currency (MXN).bank_transactionshas no currency column and everyamountInWordsis spelled out in PESOS — so, unlike the customer ledger, everything here is one currency and not split per-currency. (d) The "acumulado" is net movement since the register opened, not a bank balance — SCOTHIA carries no opening balance (itsbantable holds only the bank's name), so the running total starts at 0 in 2013. Labelled as such in the UI so it is never read as a statement balance. (e) Sign convention (fromtransform_bank.py): positive = ingreso, negative = egreso, exactly zero = a cancelled/void cheque (787 of 791 say CANCELADO/VOID) — voids are excluded from both the income and expense sides. - Full pipeline reproducible in one command:
run_all.py --env <env>runs customers → properties → policies → transactions → prune → bank → blobs in order (all idempotent); add--stageto re-extract from the Access files first. Verified end-to-end against dev.
- Customers — DONE (
-
Infra — DONE. Dev MySQL deployed to the cubex Swarm via the Portainer API as stack
jorgecuadros-dev-db(MySQL 8.4,192.168.4.212:3307, nodecubexlabeledjorgecuadros_db=true); Prisma schema pushed (26 tables). Stack filedeploy/jorgecuadros-db.stack.ymldeploys prod from the same file asjorgecuadros-prod-dbon :3306. MinIO for documents deployed asjorgecuadros-dev-minio. -
Staff web UI — DONE for all five modules (§8 step 4 + step 7: clientes, polizas, servicios, estado-cuenta, banco). Spanish-first, session-cookie auth against the API, verified against real migrated data. All app-layer feature work is complete.
- Sync implementation — DONE + VALIDATED end-to-end against dev 2026-07-24.
run_all.py --syncperforms the non-destructive legacy upsert path for customers, properties, policies, transactions, and bank rows, plus a manual-safe empty-customer prune. It preserves manual rows and stable legacy-owned primary keys; the admin SYNC job auto-creates a pre-sync backup. The as-written code was broken and had never been run — a batch of bugs was fixed before it passed (see §6 item 6). Two consecutive syncs both exit 0 and pass 32/32 assertions (added, changed, removed, and manually-created rows), idempotent. Remaining: exercise the same path from the/operacionesadmin UI and against a prod-shaped DB. - Plan step 9: portal sync worker remains separate and blocked on VPS provisioning. This
Phase B feature synchronizes Access source files into the internal platform; it does not yet
poll
utility_dboinbox tables or replicate portal-facing data to a VPS. - Small / open: (a)
TRASPASOS PAYPALclearing account still tops the adeudo worklist (§6.4d) — a business modelling call, not code. (b) Credential rotation on the old repo's exposed MySQL password. (c)TheDONE 2026-07-24 — verified vs dev: Anular buttons admin-gated, voided rows struck + excluded from totals, clicking Anular voids end-to-end (note: it uses a blocking/estado-cuentabrowser visual pass.window.confirm). Customer-detail mini tx list now also strikes voided rows ("(anulado)" tag) — was the last void-UI gap.