Commit Graph
9 Commits
Author SHA1 Message Date
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
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
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 bc749055e7 feat(statements): scope the estado de cuenta to the current year, oldest-first
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m11s
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m50s
The office's EDO CUENTA sheet has always been a *year* statement: a balance
forward line dated January 1st, then that year's movements in the order they
happened. Both of ours read the other way — every year the customer ever had,
newest first — so staff comparing the screen against the printed sheet were
reading two different documents.

Movements are now bounded to the calendar year and returned ascending, on the
screen (/estado-cuenta/[id]) and in the printable `edo-cuenta-datos` report
alike.

Earlier rows are dropped from the *list*, not from the arithmetic. The balance
floor normally lands on January 1st already, so for most customers nothing
extra is dropped at all; when it doesn't — a customer the last legacy publish
skipped, or one that never had an opening balance — the earlier rows are
folded into a carried balance and shown as a single "saldo anterior" line.
Discarding them instead would restart every balance at zero on January 1st and
nothing would throw; the numbers would just be wrong, which is how the
double-counting bug survived for years. `opening` is exposed per currency and
per business line so the totals still reconcile against the last running
balance printed.

Two things the report was missing on its own are fixed while it is being
touched, since it must agree with the screen to the peso:

  - it never applied the balance floor, so every pre-cutover row was counted
    twice — once inside the opening balance and once as itself;
  - its source-table exclusion used a bare `notIn`, and `NULL NOT IN (...)` is
    NULL rather than true, so every app-captured row (which has no
    legacySourceTable) silently vanished from the printout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 08:45:15 -07:00
rmancinas 87d8743251 feat(renovaciones): renewal notification emails over SES
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m48s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m4s
INSURANCE_FEATURES_SPEC §1. The office printed and mailed renewal letters
from the legacy CONTROL <ramo> RENEW[2/3] paper log; 91% of policyholders
have an email on file, so send the notice instead and keep the paper log
as the fallback.

A daily cron (06:00 America/Tijuana) sweeps three generations off
policyTo — 30 and 15 days before expiry, 7 days after — sends each
through SES, and upserts RenewalNotice by [policyId, generation] so a
policy is never notified twice for the same milestone. RenewalNotice now
records providerMessageId, so a later bounce or complaint webhook can be
traced back to the row that sent it.

- customers.emailOptOut excludes a customer from every sweep; editable
  from the customer form
- scheduled_job_states holds the sweep's lock and last successful run;
  the window is widened to cover days the job did not run, so a weekend
  outage does not silently drop a generation
- SES unconfigured is not an error outside production — messages are
  logged and skipped, so dev and CI never send
- /renovaciones (renewal:send, MANAGER+) lists what is pending per
  generation, runs the sweep by hand, and marks a notice sent by mail
  for the customers with no email
- POST /policies/:id/renewal-notices records that manual mark
- the aviso-renovacion report and the emails now share one projection
  (reports/renewal-letter.ts) instead of two copies of the mapping
2026-08-02 02:00:02 -07:00
rmancinasandClaude Opus 5 7df928c3ab feat(billing): receipt capture — outstanding workflow, batch by check, reconciliation
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m1s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m3s
Implements docs/RECEIPT_CAPTURE_SPEC.md §1, the legacy "Editor"
replacement, on top of the single-movement capture from plan step 6.
No new abilities: batching and resolving are both capturing.

- outstanding (legacy NOPAGO): capture flag, ?outstanding= filter, and
  POST /billing/:id/resolve-outstanding (gated ledger:create, not
  ledger:void — resolving completes a capture rather than reversing
  one). Outstanding rows are excluded from every balance aggregate,
  matching the legacy SALDOS ULTIMO 0 query's HAVING NOPAGO = 0, but
  still count in the movement browser's filtered totals.
- POST /billing/batch: many customers' receipts against one check, in
  one $transaction. Deliberately not a persisted batch entity —
  checkNumber is already a column and grouping by it answers every
  legacy by-check query.
- GET /billing/by-check + a cheque-count report, replacing REPORTE
  CHEQUE COUNT / REPORTE POR CHEQUE / EDITA CHEQUE ALF|COUNT|NUM. Print,
  PDF, CSV and XLSX come free from the existing /reportes/:slug machinery.
- Web: /estado-cuenta/lote (the Editor screen, with live reconciliation
  against the physical check amount), an "Estado de pago" filter, a
  "sin fondos" row tag and a Resolver dialog, plus a top-level "Captura"
  nav entry.

Integration seam for the OCR auto-capture module (spec §2), which is
required to post through createBatch rather than writing Transaction
rows itself: items[i] maps to lines[i] so postedTransactionId can be
zipped back on; opts.refs[i] stamps captureRef with a duplicate-post
guard that a voided row deliberately does not block; opts.source is
service-level only, so an HTTP client cannot label hand-keyed rows as
machine-captured. captureSource/captureRef are nullable so the 40,136
migrated rows stay NULL rather than being mislabelled.

Fixes two pre-existing bugs found while building this:

- statement() filtered legacySourceTable with `notIn`, which compiles to
  SQL NOT IN — and `NULL NOT IN (...)` is NULL, so every app-captured
  movement was invisible on the customer statement (438 rows in the
  movement browser vs 392 on the statement) while showing everywhere
  else. This would have made the whole capture feature look broken.
- The balances count query omitted the void filter its own page query
  applied, so the total disagreed with the rows.

Nav highlighting now resolves by longest match; the previous
first-startsWith logic lit up both the parent and any nested entry.

Verified end-to-end against the dev DB, API and browser; all test rows
removed afterwards. Also corrects RESUME.md, which documented the dev
ports as :3001/:3000 — they are :4501/:4500, from the env files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 21:54:41 -07:00
rmancinasandClaude Sonnet 5 f7ae0d5342 feat(reports): parameterized renewal-notice report + legacy report reference
Build and Push Images / Build jorgecuadros-web (push) Failing after 59s
Build and Push Images / Build jorgecuadros-api (push) Successful in 1m59s
Replaces ~40 legacy Access renewal-notice report clones (one per carrier
per coverage tier, e.g. AMPL/RC/LIC RENEW X MES/VENCE ATLAS 13/2013) with
one parameterized aviso-renovacion report driven by real Policy/Vehicle/
coveragesJson data instead of hand-typed label text per clone.

- schema.prisma: add RenewalNotice, replacing the legacy CONTROL <ramo>
  RENEW[2/3] X MES paper log of which notice generation was sent
- reports: new "letter" ReportFormat + aviso-renovacion registry entry +
  LetterLayout renderer in ReportRunner.tsx
- docs/RENEWAL_NOTICES.md + migration/legacy_report_defs/: extracted (via
  Application.SaveAsText, since the VBA project wouldn't load) and
  documented the legacy report/query chain this replaces

Coveragesjson key names and a mark-as-sent mutation are still unverified/
unbuilt — see caveats in docs/RENEWAL_NOTICES.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 23:05:21 -07:00
rmancinas 8802f08d4f feat(reports): reports module + /inicio + edo-cuenta-datos prefill
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m35s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m11s
- New reports backend (registry, service, controller, outputs, types)
  with catalog endpoint + slug/CSV/XLSX/PDF/print outputs.
- /reportes catalog + /reportes/[slug] runner; ReportRunner + ContextReports
  components wire pre-filtered links from domain pages.
- Fix: /reportes/[slug] now reads searchParams and forwards initialParams to
  ReportRunner so /reportes/edo-cuenta-datos?customerId=... auto-runs
  instead of dropping the id and forcing a manual customer search.
- /inicio landing page; root + login redirect to /inicio.
- Company header env vars + logo asset for PDF/print rendering.
- exceljs + pdfkit deps.
2026-07-23 23:20:41 -07:00