Compare commits

...
5 Commits
Author SHA1 Message Date
rmancinasandClaude Opus 5 36158ae761 feat(notificaciones): sweep one aseguradora at a time, and stop the robot quoting a premium
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m59s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m27s
The office works GMX and ANA as separate batches, so the manual barrido now
takes a "Compañía" selection. It filters the pending list as well as the
sweep, so what is on screen is exactly what "Ejecutar barrido" will mail, and
the confirmation names the carrier — running GMX when ANA was meant is the
mistake the filter exists to prevent, and it is not reversible once the mail
is out.

The carrier is chosen by InsuranceProvider id, from the same /lookups the
policy form reads, so a renamed or newly added aseguradora needs no code
change here.

A carrier-scoped run deliberately does NOT advance `lastSuccessfulAt`. The
sweep's catch-up window is computed from it, so advancing after a run that
looked at every day but mailed only one carrier would push every OTHER
carrier's letters out of tomorrow's window and they would never be sent.
Same reasoning that already keeps a debug run from advancing it.

Separately, the letter's "Prima" row is now dropped from the unattended
scheduled sweep only. A premium can still be re-rated at renewal, and an
amount a robot mailed out is one the office has to walk back; every send a
person triggers — the manual barrido and the per-row "Enviar aviso" — still
quotes it. `recordLog` renders with the same flag, so `bodySnapshot` cannot
show the office a letter the customer never received.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:24:28 -07:00
rmancinasandClaude Opus 5 fa38ff581e fix(deploy): reclaim superseded images so the host stops filling up
Every build pushes a new api + web image and every deploy pulls both onto
galactus, but nothing ever removed the pair they replaced. That reached 63
images / 83.85GB, of which 79.26GB was unused, and filled the 98GB root
filesystem to 100%.

The symptom was not a disk alert. It was "re-import is broken": the
Operaciones REIMPORT job leads with a mysqldump safety backup, that write
had nowhere to go, and PIPEFAIL took the job down before it touched the
database. Nothing in the ops_jobs log pointed at the disk.

Prune runs last, after the verify step, because Docker refuses to prune an
image that a container references — the running stack is what protects the
release just shipped. `until` adds a grace window on top so a rollback
dispatch stays a stack swap instead of a re-pull, but note it filters on
image creation time rather than pull time, so it does NOT cover rolling
back to an old tag; the running-container rule is what does.

continue-on-error: housekeeping that fails leaves a fat host, not a broken
release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:32:52 -07:00
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
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
18 changed files with 734 additions and 133 deletions
+25
View File
@@ -25,6 +25,10 @@
# through this workflow at all.
# 4. app (api + web) the new images.
# 5. verify ask the running API what it actually is.
# 6. prune images reclaim the superseded api/web images. LAST, and
# after verify: Docker will not prune an image a
# container references, so the running stack is what
# protects the release we just shipped.
#
# Rollback = re-dispatch with an older `tag`. That rolls back CODE only; the
# schema stays forward. This is exactly why every schema change must be
@@ -386,3 +390,24 @@ jobs:
echo "dispatched '$WANT'; tiers report '$API_VER' (not directly comparable)"
;;
esac
# --- housekeeping ------------------------------------------------------
# Runs LAST, and only after the verify step proved the new containers are
# up. See deploy/scripts/prune-images.mjs: Docker refuses to prune an
# image a container references, so "the stack is running" is what makes
# the current images safe. Pruning earlier would have nothing holding
# them.
#
# continue-on-error: reclaiming disk is not what the deploy is for. A
# prune that fails leaves a fat host, not a broken release.
- name: Prune unused images
continue-on-error: true
env:
PORTAINER_URL: ${{ secrets.PORTAINER_URL_GALACTUS }}
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
# Grace window. Keeps the previous few releases on disk so a rollback
# dispatch is a stack swap instead of a re-pull.
KEEP_HOURS: "168"
NODE_TLS_REJECT_UNAUTHORIZED: "0"
run: node deploy/scripts/prune-images.mjs
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@jorgecuadros/api",
"version": "1.0.25",
"version": "1.0.26",
"private": true,
"scripts": {
"build": "nest build",
+18 -7
View File
@@ -149,18 +149,15 @@ describe("balance floor", () => {
});
it("keeps the source-table exclusion alongside the floor", async () => {
// The two guards answer different questions — one reproduces legacy's
// DATOS2-only materialization, the other drops superseded history — and
// dropping either one changes the customer's balance.
// The three guards answer different questions — one windows imported
// periods, one drops the cash receipt book the ledger already posts, the
// 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"));
await service.statement("c1");
const where = rowsQuery(findMany).where;
expect(where.OR).toEqual([
{ legacySourceTable: null },
{ legacySourceTable: { notIn: expect.arrayContaining(["EFECTIVO"]) } },
]);
// Imported periods are windowed rather than excluded: history below the
// year start (the only carry a floored-by-archive customer has), never
// at or above it (those rows sit inside the next BALANCE FORWARD).
@@ -172,6 +169,20 @@ describe("balance floor", () => {
{ 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"]),
},
},
],
},
]);
});
});
+108 -35
View File
@@ -209,18 +209,6 @@ export const BALANCE_FLOOR_JOIN = Prisma.sql`
*/
export const NOT_SUPERSEDED = Prisma.sql`(bfloor.floorDate IS NULL OR t.transactionDate >= bfloor.floorDate)`;
/**
* Source tables excluded from the customer-facing statement.
*
* The legacy portal's `datosfreak` table was materialized from DATOS2 only
* (`objects.json:1358`), so the customer's "current balance" never saw
* EFECTIVO / EFECTIVO FM3 / CHEQUE FM3 / EFECTIVO_BACKUP cash receipts, nor
* the IVA 2015 snapshot. The unified `transactions` table has all of them, so
* the statement must drop them to match the legacy number the customer has
* been quoted for years. The staff-facing balances worklist and movement
* browser keep them — they're real money, just tracked separately
* (FM3 = visa fee stream, EFECTIVO = cash receipt stream).
*/
/**
* `legacySourceTable` of an imported prior period.
*
@@ -235,7 +223,34 @@ 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@";
export const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [
/**
* The legacy cash receipt book — a journal, not a ledger.
*
* `EFECTIVO` is the office's numbered receipt pad: money is handed over the
* counter, a folio is written, and the same receipt is then *posted* to the
* utilities ledger (`DATOS2`) as reference `C<folio>`. Legacy summed the ledger
* alone — `ws/v2/lib/ledger_repository.php` reads `datosfreak`, which is
* materialized from DATOS2 only (`objects.json:1358`). The migration flattened
* both tables into one `transactions` table, so anything summing a customer's
* rows counts every cash receipt twice.
*
* Verified against the live legacy database on 2026-08-20: of the 297 receipts
* written in 2026, 296 carry a matching DATOS2 posting. Only folio 13536 (CL
* 717, $400 USD) has no posting anywhere, and it wants a human's eyes rather
* than a code change. Six receipts post converted to pesos under a mistyped
* folio, which is why matching on folio and amount found fewer duplicate pairs
* than actually exist — a reason to exclude the whole journal rather than to
* exclude a list of confirmed pairs.
*
* THE DATABASE QUALIFIER IS LOAD-BEARING. `SEGUROS 16_be` keeps its own table
* also called `EFECTIVO`, and that one is the insurance line's *only* ledger —
* nothing posts it anywhere else. Excluding by table name alone erases the
* whole insurance balance: 55,444.95 USD and 63,957.78 MXN across 102
* customers, 99 of whom have no other rows at all.
*/
export const CASH_JOURNAL_SOURCE_DB = "UTILITIES";
export const CASH_JOURNAL_SOURCE_TABLES: readonly string[] = [
"EFECTIVO",
"EFECTIVO_BACKUP",
"EFECTIVO FM3",
@@ -243,6 +258,53 @@ export const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [
"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()
export class BillingService {
constructor(private readonly prisma: PrismaService) {}
@@ -412,6 +474,16 @@ export class BillingService {
async balances(params: BalanceParams) {
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[] = [];
if (domain) filters.push(Prisma.sql`t.domain = ${domain}`);
const txFilter = filters.length
@@ -474,7 +546,7 @@ export class BillingService {
FROM customers c
JOIN transactions t ON t.customerId = c.id
${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
${having}
${orderBy}
@@ -490,7 +562,7 @@ export class BillingService {
-- Must match the page query's filters exactly, or the total disagrees
-- with the rows. (The void exclusion was missing here before the
-- 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
${having}
) x
@@ -537,11 +609,22 @@ export class BillingService {
* Two different questions live here and they use different row sets.
* `movements`, `ledgerCustomers`, `crossLineCustomers` and the date range are
* INVENTORY — what is stored — and count everything not voided. Everything
* under `byCurrency` / `byDomain` is a BALANCE, so it applies NOT_SUPERSEDED
* and drops rows an opening balance already accounts for. The four aggregates
* moved from Prisma groupBy to raw SQL to express that join; groupBy cannot.
* under `byCurrency` / `byDomain` is a BALANCE, so it takes the same scope
* `balances()` takes — the opening-balance floor, the cash journal and the
* 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() {
const scope = Prisma.sql`AND ${NOT_CASH_JOURNAL} AND ${archiveIsHistorySql(
currentYearStart(),
)}`;
const [movements, ledgerCustomers] = await Promise.all([
this.prisma.transaction.count({ where: NOT_VOIDED }),
this.prisma.transaction
@@ -573,7 +656,7 @@ export class BillingService {
SUM(t.amount > 0) AS creditCount
FROM transactions t
${BALANCE_FLOOR_JOIN}
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED}
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} ${scope}
GROUP BY t.currency
`;
@@ -589,7 +672,7 @@ export class BillingService {
SUM(t.amount) AS net, COUNT(*) AS count
FROM transactions t
${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
`;
@@ -610,7 +693,7 @@ export class BillingService {
SELECT t.customerId, t.currency, SUM(t.amount) AS bal
FROM transactions t
${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
) x
GROUP BY currency
@@ -832,20 +915,6 @@ export class BillingService {
{ legacySourceTable: periodSourceTable(requested) }
: {
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
// NULL-safe exclusion. `notIn` alone compiles to SQL `NOT IN`, and
// `NULL NOT IN (...)` is NULL, not true — so every app-captured row
// (which has no legacySourceTable) silently vanished from the
// statement while still showing in the movement browser. Rows the app
// books must appear on the customer's statement, so the null case is
// spelled out.
OR: [
{ legacySourceTable: null },
{
legacySourceTable: {
notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[],
},
},
],
// An archive row belongs to this period only as history. Below
// the year start it is exactly what `opening` is for, and for the
// one customer whose newest BALANCE FORWARD lives *inside* an
@@ -872,6 +941,10 @@ export class BillingService {
{ 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(),
],
}),
},
+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());
});
});
});
+2 -25
View File
@@ -5,21 +5,10 @@ import { CreateCustomerDto } from "./create-customer.dto";
import { UpdateCustomerDto } from "./update-customer.dto";
import {
BALANCE_FORWARD_TYPE,
notCashJournal,
PERIOD_TABLE_PREFIX,
STATEMENT_EXCLUDED_SOURCE_TABLES,
} from "../billing/billing.service";
/**
* Keeps imported prior periods out of a query, NULL-safely.
*
* A closed year is imported as its own tagged copy of that year's ledger
* (`datos2@2025`) and the BALANCE FORWARD rows above it already contain every
* peso of it. Anything summing a customer's history has to leave the archives
* out or it counts each closed year twice — see the floor comment below.
*
* `NULL NOT LIKE '...'` is NULL rather than true, so app-captured rows (which
* carry no legacySourceTable) need the null branch spelled out or they vanish.
*/
/**
* Keeps an imported prior period out of the *current* period, NULL-safely.
*
@@ -225,19 +214,7 @@ export class CustomersService {
// 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),
{
OR: [
{ legacySourceTable: null },
{
legacySourceTable: {
notIn: [...STATEMENT_EXCLUDED_SOURCE_TABLES],
},
},
],
},
],
AND: [archiveIsHistory(yearStart), notCashJournal()],
},
_sum: { amount: true },
_count: { _all: true },
@@ -46,6 +46,20 @@ describe("renderRenewalEmail", () => {
expect(result.html).toContain("Calle Uno 123");
});
it("omits the premium when the sender did not ask for it", () => {
// The unattended sweep quotes no amount: the premium can still be
// re-rated at renewal, and a number a robot mailed out is one the office
// has to walk back.
const result = renderRenewalEmail(letter(), { includePremium: false });
expect(result.html).not.toContain("Prima");
expect(result.html).not.toContain("1,392.00");
// Everything else the customer needs is still there.
expect(result.html).toContain("POL-123");
expect(result.html).toContain("01/09/2026");
expect(result.html).toContain("Ana Pérez");
});
it("uses overdue wording for generation three", () => {
const result = renderRenewalEmail(letter({ generation: 3 }));
+15 -2
View File
@@ -34,10 +34,23 @@ function row(label: string, value: string): string {
return `<tr><th style="padding:8px 12px;text-align:left;background:#f4f4f4;border:1px solid #ddd">${escapeHtml(label)}</th><td style="padding:8px 12px;border:1px solid #ddd">${escapeHtml(value)}</td></tr>`;
}
export function renderRenewalEmail(letter: RenewalLetterRow): {
/**
* Render one renewal letter.
*
* `includePremium` decides whether the "Prima" row appears. The unattended
* sweep sends without it — an amount quoted by a robot, on a premium that may
* still be re-rated at renewal, is a number the office has to walk back — and
* every staff-triggered send (the manual barrido and the per-row "Enviar
* aviso") keeps it, because a person chose to quote it.
*/
export function renderRenewalEmail(
letter: RenewalLetterRow,
options: { includePremium?: boolean } = {},
): {
subject: string;
html: string;
} {
const includePremium = options.includePremium !== false;
const expired = letter.generation === 3;
const subject = expired
? `Póliza vencida: ${letter.policyNumber}`
@@ -51,7 +64,7 @@ export function renderRenewalEmail(letter: RenewalLetterRow): {
row("Tipo de póliza", letter.policyType),
row("Aseguradora", letter.provider),
row("Fecha de vencimiento", displayDate(letter.policyTo)),
row("Prima", money(premium, letter.currency)),
...(includePremium ? [row("Prima", money(premium, letter.currency))] : []),
row("Cliente", letter.customerName),
row("Correo", letter.customerEmail ?? "No disponible"),
row("Teléfono", phone),
@@ -183,6 +183,50 @@ describe("renewal notices write the shared notification log", () => {
expect(prisma.renewalNotice.upsert).not.toHaveBeenCalled();
});
it("quotes the premium on a staff-triggered sweep but not the scheduled one", async () => {
const manual = build({});
await manual.service.sweep("user-1");
expect(manual.record.mock.calls[0][0].bodySnapshot).toContain("Prima");
const automatic = build({});
await automatic.service.scheduledSweep();
const body = automatic.record.mock.calls[0][0].bodySnapshot;
// The snapshot has to match the mail that actually went out, or the
// office reads a letter the customer never received.
expect(body).not.toContain("Prima");
expect(body).toContain("700442181");
});
it("scopes a sweep to one aseguradora without advancing the catch-up window", async () => {
const { service, prisma, send } = build({});
const result = await service.sweep("user-1", { providerId: "gmx-id" });
expect(result.sent).toBe(1);
expect(result.providerId).toBe("gmx-id");
expect(prisma.policy.findMany.mock.calls[0][0].where).toMatchObject({
insuranceProviderId: "gmx-id",
});
expect(send).toHaveBeenCalledTimes(1);
// Only one carrier was mailed, so the days this run covered are still owed
// to every other carrier: advancing `lastSuccessfulAt` would move them out
// of tomorrow's window and they would never be sent.
const release = prisma.scheduledJobState.update.mock.calls.at(-1)?.[0];
expect(release.data.lastSuccessfulAt).toBeUndefined();
});
it("advances the catch-up window on a clean unfiltered sweep", async () => {
const { service, prisma } = build({});
await service.sweep("user-1");
expect(prisma.policy.findMany.mock.calls[0][0].where).not.toHaveProperty(
"insuranceProviderId",
);
const release = prisma.scheduledJobState.update.mock.calls.at(-1)?.[0];
expect(release.data.lastSuccessfulAt).toBeInstanceOf(Date);
});
it("does not fail a delivered notice when the log write throws", async () => {
const { service, record } = build({});
record.mockRejectedValue(new Error("log table gone"));
+14 -2
View File
@@ -25,6 +25,13 @@ class RenewalFlagsDto {
debug?: boolean;
}
class SweepRenewalsDto extends RenewalFlagsDto {
/** Sweep one aseguradora only (GMX, ANA, …). Omitted = todas. */
@IsOptional()
@IsString()
providerId?: string;
}
class SendRenewalDto extends RenewalFlagsDto {
@IsString()
policyId!: string;
@@ -43,17 +50,22 @@ export class RenewalsController {
constructor(private readonly renewals: RenewalsService) {}
@Get("pending")
pending(@Query("days") days?: string) {
pending(
@Query("days") days?: string,
@Query("providerId") providerId?: string,
) {
return this.renewals.pending(
Math.min(365, Math.max(1, Number(days) || 30)),
providerId?.trim() || undefined,
);
}
@Post("sweep")
@RequireAbility("renewal:send")
sweep(@Body() dto: RenewalFlagsDto, @Req() req: Request) {
sweep(@Body() dto: SweepRenewalsDto, @Req() req: Request) {
return this.renewals.sweep((req.user as { id: string }).id, {
debug: dto?.debug,
providerId: dto?.providerId,
});
}
+55 -10
View File
@@ -84,10 +84,14 @@ export class RenewalsService implements OnModuleInit {
/** The unattended run always sends for real: `debug` is a per-click switch
* in the UI, never persisted, so the schedule cannot inherit a forgotten
* test toggle and silently stop mailing customers. */
* test toggle and silently stop mailing customers.
*
* `automatic` is what drops the premium from the letter — see
* `renderRenewalEmail`. It is set here and nowhere else, so every sweep a
* person clicks still quotes the amount. */
async scheduledSweep(): Promise<void> {
try {
await this.sweep();
await this.sweep(undefined, { automatic: true });
} catch (error) {
this.logger.error(
`Falló el barrido de renovaciones: ${(error as Error).message}`,
@@ -95,7 +99,10 @@ export class RenewalsService implements OnModuleInit {
}
}
async pending(days = 30) {
/** @param providerId Restrict to one aseguradora. The list has to agree
* with what a sweep would send, or the carrier-scoped barrido shows rows it
* will not mail. */
async pending(days = 30, providerId?: string) {
const today = dateInTimeZone(new Date());
const state = await this.prisma.scheduledJobState.findUnique({
where: { name: JOB_NAME },
@@ -111,6 +118,7 @@ export class RenewalsService implements OnModuleInit {
item,
today,
state?.lastSuccessfulAt ?? null,
providerId,
),
})),
);
@@ -122,8 +130,20 @@ export class RenewalsService implements OnModuleInit {
);
}
async sweep(userId?: string, flags: { debug?: boolean } = {}) {
/**
* @param flags.providerId Sweep only one aseguradora. GMX and ANA are worked
* as separate batches by the office, so mixing them in one run is what this
* exists to prevent.
* @param flags.automatic Set only by the scheduler. Drops the premium from
* the letter.
*/
async sweep(
userId?: string,
flags: { debug?: boolean; providerId?: string; automatic?: boolean } = {},
) {
const debug = !!flags.debug;
const providerId = flags.providerId?.trim() || undefined;
const includePremium = !flags.automatic;
const now = new Date();
const state = await this.acquireLock(now);
@@ -145,6 +165,7 @@ export class RenewalsService implements OnModuleInit {
cadence,
today,
state.lastSuccessfulAt,
providerId,
);
eligible += policies.length;
@@ -157,13 +178,17 @@ export class RenewalsService implements OnModuleInit {
await this.recordLog(policy, cadence.generation, "", {
status: "SKIPPED_NO_EMAIL",
debug,
includePremium,
});
skipped++;
continue;
}
try {
await this.deliver(policy, cadence.generation, to, userId, debug);
await this.deliver(policy, cadence.generation, to, userId, {
debug,
includePremium,
});
sent++;
} catch (error) {
failures.push({
@@ -182,11 +207,18 @@ export class RenewalsService implements OnModuleInit {
failed: failures.length,
failures,
debug,
providerId: providerId ?? null,
};
// A debug run must not advance `lastSuccessfulAt`: it wrote no
// RenewalNotice rows, so the days it "covered" are still owed, and
// narrowing tomorrow's window back to a single day would drop them.
await this.releaseLock(!debug && failures.length === 0 ? now : null);
//
// A carrier-scoped run must not advance it either, for the same reason
// one step out: it looked at the whole window but only mailed one
// aseguradora, so every other carrier's letters in those days would fall
// outside tomorrow's window and never be sent at all.
const complete = !debug && !providerId && failures.length === 0;
await this.releaseLock(complete ? now : null);
void this.audit.log(userId, "renewalNotice.sweep", result);
return result;
} catch (error) {
@@ -233,12 +265,14 @@ export class RenewalsService implements OnModuleInit {
throw new BadRequestException("El cliente no tiene correo registrado.");
}
// A person clicked this, so the premium stays in the letter — only the
// scheduler's unattended run omits it.
const { sentAt, providerMessageId, addressedTo } = await this.deliver(
policy,
generation,
to,
userId,
debug,
{ debug, includePremium: true },
);
return {
policyId,
@@ -270,10 +304,12 @@ export class RenewalsService implements OnModuleInit {
generation: number,
to: string,
userId?: string,
debug = false,
options: { debug?: boolean; includePremium?: boolean } = {},
) {
const debug = !!options.debug;
const includePremium = options.includePremium !== false;
const letter = toRenewalLetterRow(policy, generation);
const message = renderRenewalEmail(letter);
const message = renderRenewalEmail(letter, { includePremium });
const addressedTo = debug ? DEBUG_RECIPIENT : to;
let result: Awaited<ReturnType<MailService["send"]>>;
@@ -291,6 +327,7 @@ export class RenewalsService implements OnModuleInit {
status: "FAILED",
error: detail,
debug,
includePremium,
});
throw error;
}
@@ -324,6 +361,7 @@ export class RenewalsService implements OnModuleInit {
providerResponse: result.response || undefined,
sendDate: sentAt,
debug,
includePremium,
});
void this.audit.log(userId, "renewalNotice.send", {
policyId: policy.id,
@@ -355,10 +393,15 @@ export class RenewalsService implements OnModuleInit {
error?: string;
sendDate?: Date;
debug?: boolean;
/** Must match what `deliver` rendered, or `bodySnapshot` shows the
* office a letter the customer never received. */
includePremium?: boolean;
},
): Promise<void> {
const letter = toRenewalLetterRow(policy, generation);
const message = renderRenewalEmail(letter);
const message = renderRenewalEmail(letter, {
includePremium: outcome.includePremium,
});
try {
await this.notificationLog.record({
notificationType: "RENEWAL_NOTICE",
@@ -391,11 +434,13 @@ export class RenewalsService implements OnModuleInit {
cadence: (typeof RENEWAL_CADENCE)[number],
today: Date,
lastSuccessfulAt: Date | null,
providerId?: string,
) {
const window = renewalWindow(today, cadence.offsetDays, lastSuccessfulAt);
return this.prisma.policy.findMany({
where: {
archivedAt: null,
...(providerId && { insuranceProviderId: providerId }),
policyTo: { gte: window.from, lte: window.to },
customer: {
archivedAt: null,
+7 -17
View File
@@ -17,6 +17,7 @@
import { Prisma } from "@jorgecuadros/database";
import {
BALANCE_FORWARD_TYPE,
notCashJournal,
periodSourceTable,
} from "../billing/billing.service";
import {
@@ -835,23 +836,6 @@ const edoCuentaDatos: ReportDef = {
{ legacySourceTable: periodSourceTable(requestedYear) }
: {
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
// NULL-safe: `NULL NOT IN (...)` is NULL, not true, so a bare `notIn`
// drops every app-captured row (they have no legacySourceTable) — the
// same defect this report's on-screen twin was fixed for.
OR: [
{ legacySourceTable: null },
{
legacySourceTable: {
notIn: [
"EFECTIVO",
"EFECTIVO_BACKUP",
"EFECTIVO FM3",
"CHEQUE FM3",
"IVA 2015",
],
},
},
],
// Archive rows count as history below the year start (that is
// what `opening` is for, and for a customer floored by an archive
// it is the only carry there is) and are dropped at or above it.
@@ -868,6 +852,12 @@ const edoCuentaDatos: ReportDef = {
},
],
},
// The cash receipt book, which the ledger already carries as
// its own `C<folio>` postings. Taken from the shared helper
// rather than restated, so the printed statement and the screen
// cannot drift apart — and so this keeps the database
// qualifier that spares the insurance line's own EFECTIVO.
notCashJournal(),
],
}),
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@jorgecuadros/web",
"version": "1.0.25",
"version": "1.0.26",
"private": true,
"scripts": {
"dev": "next dev -p 4500",
@@ -4,7 +4,13 @@ import { useCallback, useEffect, useState } from "react";
import { useCan } from "@/lib/abilities";
import { formatDate, formatMoney } from "@/lib/labels";
import { NotificationLogPanel } from "@/components/NotificationLogPanel";
import { apiFetch, POLIZAS_LOG_SCOPE, type NotificationFlags } from "@/lib/api";
import {
apiFetch,
getLookups,
POLIZAS_LOG_SCOPE,
type NotificationFlags,
} from "@/lib/api";
import type { ProviderRow } from "@/lib/types";
/**
* Renewal notices — the "Pólizas" half of /notificaciones. Shows which
@@ -22,6 +28,12 @@ import { apiFetch, POLIZAS_LOG_SCOPE, type NotificationFlags } from "@/lib/api";
* thing here as it does for servicios: the mail is diverted to the override
* inbox. It additionally does NOT mark the notice as sent, so a test send
* leaves the row exactly where it was — pending.
*
* The barrido manual is scoped by aseguradora because the office works GMX and
* ANA as separate batches. The selection filters the pending list too, so what
* is on screen is exactly what "Ejecutar barrido" will mail. A carrier-scoped
* run deliberately does not advance the sweep's catch-up window — it only
* covered one carrier — so the other carriers' letters stay pending.
*/
export interface RenewalLetter {
@@ -46,6 +58,8 @@ export interface RenewalSweepResult {
failed: number;
failures: { policyId: string; generation: number; error: string }[];
debug: boolean;
/** Echoed back so the confirmation says which carrier actually ran. */
providerId: string | null;
}
export interface RenewalSendResult {
@@ -68,6 +82,10 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
const allowed = useCan("renewal:send");
const debug = !!flags.debug;
const [days, setDays] = useState(30);
/** "" = ambas/todas. Holds an InsuranceProvider id, never a name — carriers
* are renamed in the lookups screen and the filter must survive that. */
const [providerId, setProviderId] = useState("");
const [providers, setProviders] = useState<ProviderRow[]>([]);
const [pending, setPending] = useState<RenewalLetter[] | null>(null);
const [pendingError, setPendingError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
@@ -81,8 +99,10 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
const refresh = useCallback(async () => {
setPendingError(null);
try {
const params = new URLSearchParams({ days: String(days) });
if (providerId) params.set("providerId", providerId);
const data = await apiFetch<RenewalLetter[]>(
`/renewals/pending?days=${days}`,
`/renewals/pending?${params.toString()}`,
);
setPending(data);
} catch (e) {
@@ -91,18 +111,44 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
);
setPending([]);
}
}, [days]);
}, [days, providerId]);
useEffect(() => {
if (allowed) refresh();
}, [allowed, refresh]);
// Carriers come from the same lookups the policy form uses, so a new
// aseguradora shows up here without a code change.
useEffect(() => {
if (!allowed) return;
let cancelled = false;
getLookups()
.then((data) => {
if (!cancelled) setProviders(data.providers);
})
.catch(() => {
// A failed lookup only costs the filter; the unfiltered sweep still
// works, so this must not blank the screen.
if (!cancelled) setProviders([]);
});
return () => {
cancelled = true;
};
}, [allowed]);
const providerLabel =
providers.find((item) => item.id === providerId)?.name ?? "todas las compañías";
async function handleSweep() {
// Only worth confirming when debug is off — that is the case where real
// customers receive mail. Mirrors "Ejecutar todos" on the servicios tab.
// The carrier is named in the prompt: running GMX when ANA was meant is
// exactly the mistake this filter exists to prevent, and it is not
// reversible once the mail is out.
if (!debug) {
const ok = window.confirm(
"debug está desactivado: los avisos irán a los correos reales de los clientes. ¿Ejecutar el barrido?",
`debug está desactivado: los avisos irán a los correos reales de los clientes. ` +
`¿Ejecutar el barrido de ${providerLabel}?`,
);
if (!ok) return;
}
@@ -112,10 +158,11 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
try {
const result = await apiFetch<RenewalSweepResult>("/renewals/sweep", {
method: "POST",
body: JSON.stringify({ debug }),
body: JSON.stringify({ debug, providerId: providerId || undefined }),
});
setNotice(
`Enviados ${result.sent} avisos (${result.failed} con error).` +
`Enviados ${result.sent} avisos de ${providerLabel} ` +
`(${result.failed} con error).` +
(result.debug
? " Modo debug: fueron al buzón de pruebas y siguen pendientes."
: ""),
@@ -199,7 +246,9 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
<h2 className="section-title">Barrido manual</h2>
<p className="muted small" style={{ marginTop: 4 }}>
Usa la fecha actual del servidor como referencia para seleccionar
avisos vencidos a 30 y 15 días, y vencidos hace 7 días.
avisos vencidos a 30 y 15 días, y vencidos hace 7 días. La
compañía elegida filtra también la lista de abajo: se envía
exactamente lo que está en pantalla.
</p>
</div>
<button
@@ -211,7 +260,11 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
{sweeping ? "Enviando…" : "Ejecutar barrido"}
</button>
</div>
<div className="field" style={{ maxWidth: 180, marginTop: 12, marginBottom: 0 }}>
<div
className="row-actions"
style={{ marginTop: 12, alignItems: "flex-end", gap: 16 }}
>
<div className="field" style={{ maxWidth: 180, marginBottom: 0 }}>
<span className="field-label">Ventana (días)</span>
<input
className="input"
@@ -224,6 +277,22 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
}
/>
</div>
<div className="field" style={{ maxWidth: 260, marginBottom: 0 }}>
<span className="field-label">Compañía</span>
<select
className="input"
value={providerId}
onChange={(e) => setProviderId(e.target.value)}
>
<option value="">Todas las compañías</option>
{providers.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
</div>
</section>
{pendingError && <div className="state-box state-error">{pendingError}</div>}
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env node
/**
* Delete unused images from the target host after a successful deploy.
*
* This exists because nothing else reclaims them. Every build.yml run pushes a
* new api + web image, every deploy pulls both onto the host, and the previous
* pair is left behind untagged-but-present forever. On galactus that reached
* 63 images / 83.85GB (79.26GB of it unused) and filled the 98GB root
* filesystem to 100% on 2026-08-20 — which surfaced as "re-import is broken",
* because the Operaciones REIMPORT job leads with a mysqldump that could no
* longer write its safety backup.
*
* Two things keep this from eating a live deployment:
*
* - Docker never prunes an image that a container references, running or
* stopped. The five images the prod stacks use are therefore untouchable
* for as long as their containers exist.
* - `until` gives a grace window on top of that, so a rollback target stays
* on disk instead of forcing a re-pull from the registry.
*
* TRAP: `until` filters on the image's CREATION time, not when the host pulled
* it. Rolling back to an old tag pulls an image that is already older than the
* window, so the grace period does NOT protect it — the running-container rule
* is what does. That is why this step must run AFTER the app stack is deployed
* and verified, never before.
*
* Required env:
* PORTAINER_URL, PORTAINER_API_KEY, PORTAINER_ENDPOINT_ID
* Optional:
* KEEP_HOURS grace window in hours (default 168 = 7 days)
*
* TLS: Portainer here is self-signed; the caller sets
* NODE_TLS_REJECT_UNAUTHORIZED=0 for this step.
*/
function required(name) {
const v = process.env[name];
if (!v) {
console.error(`missing required env: ${name}`);
process.exit(1);
}
return v;
}
const PORTAINER_URL = required("PORTAINER_URL").replace(/\/+$/, "");
const API_KEY = required("PORTAINER_API_KEY");
const ENDPOINT_ID = required("PORTAINER_ENDPOINT_ID");
const KEEP_HOURS = process.env.KEEP_HOURS || "168";
const DOCKER = `${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker`;
// `dangling: ["false"]` is what makes this `docker image prune -a` rather than
// the default, which only collects untagged layers. The tagged-but-superseded
// api/web images are the whole problem, and the default filter walks straight
// past them.
const FILTERS = JSON.stringify({
dangling: ["false"],
until: [`${KEEP_HOURS}h`],
});
function human(bytes) {
if (!bytes) return "0B";
const units = ["B", "KB", "MB", "GB", "TB"];
let i = 0;
let n = bytes;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i += 1;
}
return `${n.toFixed(i === 0 ? 0 : 2)}${units[i]}`;
}
async function main() {
const url = `${DOCKER}/images/prune?filters=${encodeURIComponent(FILTERS)}`;
const res = await fetch(url, {
method: "POST",
headers: { "X-API-Key": API_KEY },
});
const body = await res.text();
if (!res.ok) {
throw new Error(`prune -> HTTP ${res.status} ${body.slice(0, 300)}`);
}
let report;
try {
report = JSON.parse(body);
} catch {
throw new Error(`prune returned non-JSON: ${body.slice(0, 300)}`);
}
const deleted = report.ImagesDeleted ?? [];
const reclaimed = report.SpaceReclaimed ?? 0;
console.log(
`pruned images older than ${KEEP_HOURS}h and unused by any container`,
);
console.log(` entries removed : ${deleted.length}`);
console.log(` space reclaimed : ${human(reclaimed)}`);
}
main().catch((err) => {
// Non-fatal by contract: the step that calls this sets continue-on-error, so
// housekeeping never turns a good deploy red. Exit non-zero anyway so the
// failure is visible in the run rather than swallowed.
console.error(`::warning::image prune FAILED: ${err.message}`);
process.exit(1);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jorgecuadros-platform",
"version": "1.0.25",
"version": "1.0.26",
"private": true,
"workspaces": [
"apps/*",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@jorgecuadros/database",
"version": "1.0.25",
"version": "1.0.26",
"private": true,
"main": "generated/client/index.js",
"types": "generated/client/index.d.ts",
+157 -12
View File
@@ -15,14 +15,27 @@
* 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.
* a 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.
* READ THE COMPOSITION LINE BEFORE ACTING ON THIS. After the prior-period
* import the group is mostly insurance-only customers whose rows come from
* the seguros database's own EFECTIVO, which is that line's ONLY ledger.
* Flooring those deletes receipts instead of removing a double count. The
* "floor them, never carry" argument holds for the utilities rows alone.
*
* B. DOUBLE-BOOKED 2026 RECEIPTS — one cash receipt appearing twice, once in
* EFECTIVO with folio `N` and once in datos2 with reference `CN`.
*
* This is NOT an office data-entry defect, which is what it looked like
* while the pair count kept growing at ~40/month. EFECTIVO is the paper
* receipt book and every receipt in it is POSTED to the datos2 ledger by
* design — verified against the live legacy database, 296 of the 297
* receipts written in 2026 carry a matching posting. Legacy summed the
* ledger alone. The duplication was the migration flattening a journal and
* its postings into one table, and since 1.0.26 the application drops the
* journal from every balance. Section B now reports what the journal holds
* and asserts that none of it still reaches a balance.
*
* 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
@@ -82,6 +95,44 @@ const BF_PREDICATE = `(
AND t.legacySourceTable = 'datos2')
)`;
/**
* The rest of what a balance query drops, over and above the floor. Mirrors
* NOT_CASH_JOURNAL and archiveIsHistorySql in billing.service.ts — an audit
* that computes a different book than the application is worse than no audit,
* because its numbers look authoritative and diff cleanly against yesterday's.
*
* The database qualifier is not decoration. `SEGUROS 16_be` keeps its own table
* called EFECTIVO and that one is the insurance line's only ledger; matching on
* the table name alone would report 55,444.95 USD of real receivables as
* duplicate cash. See efectivo-is-a-journal-not-a-ledger.
*/
const NOT_CASH_JOURNAL = `(
t.legacySourceDb IS NULL
OR t.legacySourceDb <> 'UTILITIES'
OR t.legacySourceTable IS NULL
OR t.legacySourceTable NOT IN
('EFECTIVO', 'EFECTIVO_BACKUP', 'EFECTIVO FM3', 'CHEQUE FM3', 'IVA 2015')
)`;
/**
* An imported period counts as history below the year start and is dropped at
* or above it. Spelled as a positive OR: `NOT (col LIKE ... AND ...)` is NULL
* for an app-captured row, which would silently drop every one.
*
* The bound is the running calendar year, matching currentYearStart() in the
* application rather than the cutover — the app's current period is "this
* year", whatever cut the data happens to reflect.
*/
const yearStart = `${new Date().getUTCFullYear()}-01-01`;
const ARCHIVE_IS_HISTORY = `(
t.legacySourceTable IS NULL
OR t.legacySourceTable NOT LIKE 'datos2@%'
OR t.transactionDate < '${yearStart}'
)`;
/** Everything a balance drops apart from the floor itself. */
const READ_SCOPE = `(${NOT_CASH_JOURNAL} AND ${ARCHIVE_IS_HISTORY})`;
async function main() {
const args = process.argv.slice(2);
const limit = Number(arg(args, "--limit", "25"));
@@ -127,9 +178,13 @@ async function main() {
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,
THEN t.amount ELSE 0 END), 2) AS floorOnlyMxn,
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,
THEN t.amount ELSE 0 END), 2) AS floorOnlyUsd,
ROUND(SUM(CASE WHEN t.currency='MXN' AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate)
AND ${READ_SCOPE} 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)
AND ${READ_SCOPE} 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 >= ?
@@ -197,6 +252,39 @@ async function main() {
cutover,
);
// What the floorless population's balance is actually MADE OF.
//
// "Floor them, never carry" was written when this group looked like
// utilities cash receipts whose charges were never migrated. It is not that
// any more. After the prior-period import the group is 99 customers, and
// almost all of them are insurance-only — their rows come from the seguros
// database's own EFECTIVO, which is that line's ONLY ledger. Nothing posts
// it a second time, so flooring it does not remove a double count, it
// deletes receipts. Split the two so the remedy is chosen per population
// rather than for the group.
const [floorlessMix] = 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
COUNT(DISTINCT CASE WHEN t.legacySourceDb = 'SEGUROS 16_be' THEN t.customerId END) AS insCusts,
ROUND(SUM(CASE WHEN t.legacySourceDb = 'SEGUROS 16_be' AND t.currency='MXN'
THEN t.amount ELSE 0 END), 2) AS insMxn,
ROUND(SUM(CASE WHEN t.legacySourceDb = 'SEGUROS 16_be' AND t.currency='USD'
THEN t.amount ELSE 0 END), 2) AS insUsd,
COUNT(DISTINCT CASE WHEN t.legacySourceDb <> 'SEGUROS 16_be' THEN t.customerId END) AS utilCusts
FROM transactions t JOIN nobf n ON n.id = t.customerId
WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND t.transactionDate < ?
`,
cutover,
);
// ---- section B: double-booked receipts ---------------------------------
const pairs = await prisma.$queryRawUnsafe(
`
@@ -269,6 +357,34 @@ async function main() {
`,
);
// REGRESSION GUARD. Since 1.0.26 the application drops the whole cash
// journal from every balance, so none of section B's rows should reach one
// any more. This counts the ones that still do: it is 0 while the exclusion
// holds, and goes non-zero the moment someone reintroduces a balance query
// that forgets it. A defect list that cannot tell you whether the defect is
// still live is just history.
const [stillCounted] = 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 COUNT(*) AS n,
ROUND(SUM(CASE WHEN t.currency='MXN' THEN t.amount ELSE 0 END), 2) AS mxn,
ROUND(SUM(CASE WHEN t.currency='USD' THEN t.amount ELSE 0 END), 2) AS usd
FROM transactions t
LEFT JOIN bfloor b ON b.customerId = t.customerId
WHERE t.voidedAt IS NULL AND t.outstanding = 0
AND t.legacySourceDb = 'UTILITIES'
AND t.legacySourceTable IN
('EFECTIVO', 'EFECTIVO_BACKUP', 'EFECTIVO FM3', 'CHEQUE FM3', 'IVA 2015')
AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate)
AND ${READ_SCOPE}
`,
);
// ---- CSV escapes -------------------------------------------------------
if (args.includes("--csv-a")) return dumpCsv(floorless);
if (args.includes("--csv-b")) return dumpCsv([...pairs, ...nearby]);
@@ -276,8 +392,15 @@ async function main() {
// ---- 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(` BF floor only (pre-1.0.26) ${money(book.floorOnlyMxn)} MXN ${money(book.floorOnlyUsd)} USD`);
console.log(` TODAY, as the app computes it ${money(book.todayMxn)} MXN ${money(book.todayUsd)} USD`);
console.log(` flat floor at ${cutover} ${money(book.flooredMxn)} MXN ${money(book.flooredUsd)} USD`);
console.log(
" (the middle line is the floor alone, kept only so older runs of this\n" +
" script still diff against something. The app has dropped the cash\n" +
" journal and windowed the archives since 1.0.26; USD going to zero on\n" +
" the utilities side is correct, that ledger is peso-denominated.)",
);
const preRowsTotal = floorless.reduce((s, r) => s + d(r.preRows), 0);
const wouldZero = floorless.filter((r) => d(r.postRows) === 0);
@@ -291,6 +414,17 @@ async function main() {
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(
` of the ${floorless.length}: ${d(floorlessMix.insCusts)} carry insurance-line cash` +
` (${d(floorlessMix.insMxn).toFixed(2)} MXN / ${d(floorlessMix.insUsd).toFixed(2)} USD)` +
`, ${d(floorlessMix.utilCusts)} carry utilities rows`,
);
console.log(
` READ THAT LINE BEFORE FLOORING ANYONE. The seguros EFECTIVO is that\n` +
` line's only ledger — nothing posts it twice — so flooring those\n` +
` customers deletes receipts rather than removing a double count.\n` +
` The argument for flooring holds for the utilities rows alone.`,
);
console.log(
`\n ${"customer".padEnd(30)} ${"pre".padStart(4)} ${"post".padStart(4)}` +
` ${"today MXN".padStart(13)} ${"after MXN".padStart(13)} ${"first tx".padStart(10)}`,
@@ -347,6 +481,16 @@ async function main() {
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(
` still reaching a balance after the 1.0.26 exclusion: ${d(stillCounted.n)} rows` +
` (${d(stillCounted.mxn).toFixed(2)} MXN / ${d(stillCounted.usd).toFixed(2)} USD)` +
`${d(stillCounted.n) === 0 ? " <- 0 is the passing value" : " <- REGRESSION"}`,
);
console.log(
` The rows below still exist and always will; the ledger's own C<folio>\n` +
` posting is the copy that counts. This section is now a record of what\n` +
` the journal holds, not a list of money being double-counted.`,
);
console.log(
`\n ${"customer".padEnd(28)} ${"datos2".padStart(10)} ${"efectivo".padStart(10)}` +
` ${"datos2 amt".padStart(13)} ${"efectivo amt".padStart(13)} ref`,
@@ -382,8 +526,9 @@ async function main() {
"\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.",
"database. It exists in the DreamHost per-year tables. Section B is no\n" +
"longer an open defect — it was fixed read-side in 1.0.26 — and the line\n" +
"that matters there is the regression count, which must stay at 0.",
);
} finally {
await prisma.$disconnect();