Compare commits

..
5 Commits
Author SHA1 Message Date
rmancinasandClaude Opus 5.5 031b13f8c8 fix(reports): balances the statement agrees with, NUMid on every report
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m44s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m16s
From the 2026-10-08 meeting.

- Clientes en rojo summed every row a customer ever had, so the opening
  balance's history counted twice, cash receipts twice and the 2025
  archive on top: 195 customers in the red against 22 real ones. It now
  takes the balance scope balances() uses (extracted as balanceScopeSql),
  sorts by most recent real movement, and filters by ramo, date, minimum
  owed and inactive customers (status = 0, the internal accounts).
- Pagos no efectuados asked "no charge of any kind in N days"; legacy asks
  "no row of THAT service's concept in the period". Rewritten to legacy's
  question, per customer, defaulting to one billing cycle back. It also
  passed ELECTRICITY/PHONE, which are not ServiceKind values, so Luz and
  Telefono never returned a row. Sin fondos rows show as their own status.
- Faltantes demanded account, meter AND route of every kind; water and
  phone have no route. Now the legacy fields per kind.
- Reporte de efectivo counted every credit BALANCE FORWARD as cash
  (958 of 1093 rows on dev).
- Every report carries the legacy NUMid; policy reports add the
  insurance file number. The statement header shows it too.
- Printed headers showed placeholder company details: prod sets no
  COMPANY_* variables. Defaults are now the office's real locator, plus a
  US mailing address line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:20:44 -07:00
rmancinasandClaude Opus 5.5 2cf07ed5ee fix(migration): the CFE meter and route belong to the electric service
DATMEX.MEDIDOR/RUTA were unpivoted onto the WATER row, so every electric
service arrived without a meter and water carried CFE's. They are filled
on exactly the 1113 rows that carry an RPU (100% overlap), the meters are
CFE's alphanumeric form (53MT58), and every legacy query that reads them
is a LUZ one. medidor2/ruta2 pair with rpu2 the same way.

Takes effect on the next reimport.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:20:44 -07:00
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
17 changed files with 900 additions and 236 deletions

No files matched your search

+2 -1
View File
@@ -22,12 +22,13 @@ OPS_DB_ADMIN_USER=
OPS_DB_ADMIN_PASSWORD=
# Company info — printed in the header of every report (PDF + browser
# print). Leave blank to use the placeholders. COMPANY_LOGO_PATH is
# print). Leave blank to use the defaults in company.ts. COMPANY_LOGO_PATH is
# optional; when unset the API falls back to apps/api/assets/company_logo.png.
COMPANY_NAME=Jorge Cuadros & Asociados
COMPANY_ADDRESS_LINE1=
COMPANY_ADDRESS_LINE2=
COMPANY_CITY_STATE=
COMPANY_MAILING_ADDRESS=
COMPANY_PHONE=
COMPANY_EMAIL=
COMPANY_TAX_ID=
+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
+11 -6
View File
@@ -305,6 +305,15 @@ export const archiveIsHistorySql = (yearStart: Date) => Prisma.sql`(
export const currentYearStart = () =>
new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1));
/**
* What a balance sums over beyond "not voided": the cash journal out, the
* archives kept to history. Shared by `balances()`, `stats()` and the reports,
* so a customer's balance reads the same on every screen that quotes one.
* Pair it with BALANCE_FLOOR_JOIN + NOT_SUPERSEDED.
*/
export const balanceScopeSql = () =>
Prisma.sql`AND ${NOT_CASH_JOURNAL} AND ${archiveIsHistorySql(currentYearStart())}`;
@Injectable()
export class BillingService {
constructor(private readonly prisma: PrismaService) {}
@@ -480,9 +489,7 @@ export class BillingService {
// 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 scope = balanceScopeSql();
const filters: Prisma.Sql[] = [];
if (domain) filters.push(Prisma.sql`t.domain = ${domain}`);
@@ -621,9 +628,7 @@ export class BillingService {
* which is the client's call and not settled yet.
*/
async stats() {
const scope = Prisma.sql`AND ${NOT_CASH_JOURNAL} AND ${archiveIsHistorySql(
currentYearStart(),
)}`;
const scope = balanceScopeSql();
const [movements, ledgerCustomers] = await Promise.all([
this.prisma.transaction.count({ where: NOT_VOIDED }),
@@ -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,
+14 -8
View File
@@ -21,6 +21,8 @@ export interface CompanyInfo {
addressLine2: string;
/** "City, State, ZIP, Country" — single line. */
cityState: string;
/** US postal address, printed after the office address. Optional. */
mailingAddress: string;
phone: string;
email: string;
/** Mexican tax ID ("RFC"). Optional. */
@@ -83,19 +85,23 @@ export function getCompanyInfo(): CompanyInfo {
}
cached = {
name: envOr("COMPANY_NAME", "Jorge Cuadros & Asociados"),
addressLine1: envOr(
"COMPANY_ADDRESS_LINE1",
"Av. Revolución 1234, Int. 5",
),
// The office's real locator, as published on jorgecuadros.com. Prod sets
// no COMPANY_* variables, so these defaults ARE what every printed
// statement shows — they were placeholders until 2026-10.
addressLine1: envOr("COMPANY_ADDRESS_LINE1", "Quinta Plaza Ste 3-B"),
addressLine2: envOr("COMPANY_ADDRESS_LINE2", ""),
cityState: envOr(
"COMPANY_CITY_STATE",
"Tijuana, Baja California 22000, México",
"Rosarito, Baja California, México",
),
phone: envOr("COMPANY_PHONE", "(664) 000-0000"),
email: envOr("COMPANY_EMAIL", "contacto@jorgecuadros.local"),
mailingAddress: envOr(
"COMPANY_MAILING_ADDRESS",
"US: 416 W. San Ysidro Blvd L #3108, San Ysidro, CA 92173",
),
phone: envOr("COMPANY_PHONE", "011-52 (661) 612-1295"),
email: envOr("COMPANY_EMAIL", "info@jorgecuadros.com"),
taxId: envOr("COMPANY_TAX_ID", ""),
website: envOr("COMPANY_WEBSITE", "jorgecuadros.local"),
website: envOr("COMPANY_WEBSITE", "jorgecuadros.com"),
logoPath,
logo,
};
+2
View File
@@ -199,6 +199,7 @@ export function renderPdf(
company.addressLine1,
company.addressLine2,
[company.cityState].filter(Boolean).join(" · "),
company.mailingAddress,
[company.phone, company.email].filter(Boolean).join(" · "),
company.taxId ? `RFC: ${company.taxId}` : "",
].filter(Boolean);
@@ -363,6 +364,7 @@ export function renderPrintHtml(
company.addressLine1,
company.addressLine2,
company.cityState,
company.mailingAddress,
[company.phone, company.email].filter(Boolean).join(" · "),
company.taxId ? `RFC: ${company.taxId}` : "",
].filter(Boolean);
+335 -164
View File
@@ -14,13 +14,23 @@
* the EFECTIVO* set OR `checkNumber` null + amount > 0 (true cash).
*/
import { Prisma } from "@jorgecuadros/database";
import { Prisma, type ServiceKind } from "@jorgecuadros/database";
import {
BALANCE_FLOOR_JOIN,
BALANCE_FORWARD_TYPE,
balanceScopeSql,
NOT_SUPERSEDED,
notCashJournal,
periodSourceTable,
} from "../billing/billing.service";
import {
UTILITIES_SYSTEM,
UTILITIES_TABLE,
} from "../customers/numid.service";
import type { PrismaService } from "../prisma/prisma.service";
import { CONCEPT_BY_KIND } from "../statements/statements.service";
import {
type ColumnDef,
intParam,
NOT_VOIDED,
parseDate,
@@ -37,19 +47,80 @@ function nameOf(c: { name: string; nameMissing: boolean }): string {
return c.nameMissing ? "(sin nombre)" : c.name;
}
const isoDay = (d: Date | null | undefined) =>
d ? d.toISOString().slice(0, 10) : "—";
/** Every report leads with the legacy customer number (2026-10 meeting). */
const NUMID_COLUMN: ColumnDef = { key: "numid", label: "NUMid", type: "text" };
/** Policy reports add the insurance file number beside it. */
const INSURANCE_NO_COLUMN: ColumnDef = {
key: "insuranceNo",
label: "No. seguros",
type: "text",
};
/**
* Legacy customer numbers for a page of report rows. The office files,
* captures and talks in NUMids, not platform ids.
*
* The utilities NUMid is the portal number, one per customer. insurance/DATGRAL
* is a separate id space under the same table name, so it is read on its own
* and never shown as a NUMid; 16 customers hold more than one.
*/
async function legacyNumbers(prisma: PrismaService, customerIds: string[]) {
const ids = [...new Set(customerIds)];
const refs = ids.length
? await prisma.customerLegacyRef.findMany({
where: {
customerId: { in: ids },
sourceTable: UTILITIES_TABLE,
sourceSystem: { in: [UTILITIES_SYSTEM, "insurance"] },
},
select: { customerId: true, sourceSystem: true, legacyId: true },
})
: [];
const byCustomer = new Map<string, { numid?: string; insurance: string[] }>();
for (const r of refs) {
const e = byCustomer.get(r.customerId) ?? { insurance: [] };
if (r.sourceSystem === UTILITIES_SYSTEM) e.numid = r.legacyId;
else e.insurance.push(r.legacyId);
byCustomer.set(r.customerId, e);
}
return {
numid: (id: string) => byCustomer.get(id)?.numid ?? "—",
insurance: (id: string) =>
byCustomer
.get(id)
?.insurance.sort((a, b) => Number(a) - Number(b))
.join(", ") || "—",
};
}
/* ------------------------------------------------------------------ reports */
/**
* LISTADO EN ROJO — overdue customers worklist.
* Same data as the receivables worklist with balance=owing, but presented
* as a printable report rather than a paginated browser.
*
* Legacy printed it from `ALFABETICO EN ROJO`: `SUM(CHARGECREDIT) < 0` over
* DATOS2, by name. That sum was only honest because DATOS2 held one year plus
* its BALANCE FORWARD. The platform keeps every year, so the balance here is
* the statement's own — the floor, the cash journal and the archive window via
* the same fragments `BillingService.balances()` uses. Summed raw, as this
* report used to, 195 customers read in the red against 22 real ones: every
* closed account whose history the opening balance had already settled.
*
* Ordered by the most recent real movement (Jorge, 2026-10): the oldest
* debtors are the accounts that closed, the ones worth chasing are recent.
* Inactive customers (`status = 0` — the office's own and internal accounts,
* e.g. TRASPASOS PAYPAL at -12M) stay out unless asked for.
*/
const listadoEnRojo: ReportDef = {
slug: "listado-en-rojo",
title: "Clientes en rojo",
description:
"Cartera vencida: clientes con saldo deudor en la moneda seleccionada, " +
"ordenados del más antiguo al más reciente.",
"Cartera vencida: clientes con saldo deudor en la moneda seleccionada — " +
"el mismo saldo de su estado de cuenta — ordenados por el movimiento " +
"más reciente. Los clientes inactivos quedan fuera salvo que se pidan.",
domain: "estado-cuenta",
legacyName: "LISTADO EN ROJO",
format: "tabular",
@@ -64,6 +135,45 @@ const listadoEnRojo: ReportDef = {
],
defaultValue: "MXN",
},
{
key: "domain",
label: "Ramo",
kind: "select",
options: [
{ value: "ALL", label: "Todos" },
{ value: "UTILITY", label: "Servicios" },
{ value: "INSURANCE", label: "Seguros" },
],
defaultValue: "ALL",
},
{ key: "since", label: "Con movimiento desde", kind: "date" },
{
key: "minOwed",
label: "Adeudo mínimo",
kind: "number",
placeholder: "0",
},
{
key: "inactive",
label: "Clientes inactivos",
kind: "select",
options: [
{ value: "no", label: "Excluir" },
{ value: "yes", label: "Incluir" },
],
defaultValue: "no",
},
{
key: "sort",
label: "Orden",
kind: "select",
options: [
{ value: "recent", label: "Movimiento más reciente" },
{ value: "debt", label: "Mayor adeudo" },
{ value: "name", label: "Nombre" },
],
defaultValue: "recent",
},
{
key: "query",
label: "Buscar (nombre o ciudad)",
@@ -72,24 +182,48 @@ const listadoEnRojo: ReportDef = {
},
],
columns: [
{ key: "id", label: "#", type: "text" },
NUMID_COLUMN,
{ key: "name", label: "Cliente", type: "text" },
{ key: "city", label: "Ciudad", type: "text" },
{ key: "movements", label: "Movs.", type: "number", align: "right" },
{ key: "balance", label: "Saldo", type: "money", align: "right" },
{ key: "lastMovement", label: "Último movimiento", type: "date" },
{ key: "lastPayment", label: "Último abono", type: "date" },
{ key: "balance", label: "Saldo", type: "money", align: "right" },
],
async run(prisma, p) {
const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD";
const q = p.query?.trim();
const nameFilter = q
? Prisma.sql`AND (c.name LIKE ${`%${q}%`} OR c.city LIKE ${`%${q}%`})`
const since = parseDate(p.since);
const minOwed = Math.max(Number(p.minOwed) || 0, 0.005);
const filters: Prisma.Sql[] = [];
if (q) {
filters.push(Prisma.sql`(c.name LIKE ${`%${q}%`} OR c.city LIKE ${`%${q}%`})`);
}
if (p.domain === "UTILITY" || p.domain === "INSURANCE") {
filters.push(Prisma.sql`t.domain = ${p.domain}`);
}
if (p.inactive !== "yes") {
filters.push(Prisma.sql`c.status = 1 AND c.archivedAt IS NULL`);
}
const where = filters.length
? Prisma.sql`AND ${Prisma.join(filters, " AND ")}`
: Prisma.empty;
const bal =
currency === "USD"
? Prisma.sql`SUM(CASE WHEN t.currency = 'USD' THEN t.amount ELSE 0 END)`
: Prisma.sql`SUM(CASE WHEN t.currency = 'MXN' THEN t.amount ELSE 0 END)`;
// The Jan 1 BALANCE FORWARD is not a movement — every customer has one, so
// counting it would date every closed account to this year.
const real = Prisma.sql`(tt.nameEn IS NULL OR tt.nameEn <> ${BALANCE_FORWARD_TYPE})`;
const lastMovement = Prisma.sql`MAX(CASE WHEN ${real} THEN t.transactionDate END)`;
const orderBy =
p.sort === "debt"
? Prisma.sql`ORDER BY balance ASC`
: p.sort === "name"
? Prisma.sql`ORDER BY c.nameMissing ASC, c.name ASC`
: Prisma.sql`ORDER BY lastMovement DESC, balance ASC`;
const rows = await prisma.$queryRaw<
Array<{
@@ -97,38 +231,39 @@ const listadoEnRojo: ReportDef = {
name: string;
nameMissing: boolean;
city: string | null;
movements: bigint | number | string;
balance: Prisma.Decimal | null;
lastMovement: Date | null;
lastPayment: Date | null;
}>
>`
SELECT c.id, c.name, c.nameMissing, c.city,
COUNT(*) AS movements,
${bal} AS balance,
MAX(t.transactionDate) AS lastMovement
${lastMovement} AS lastMovement,
MAX(CASE WHEN ${real} AND t.amount > 0 THEN t.transactionDate END) AS lastPayment
FROM customers c
JOIN transactions t ON t.customerId = c.id
WHERE t.voidedAt IS NULL ${nameFilter}
LEFT JOIN type_transactions tt ON tt.id = t.typeId
${BALANCE_FLOOR_JOIN}
WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED}
${balanceScopeSql()} ${where}
GROUP BY c.id, c.name, c.nameMissing, c.city
HAVING ${bal} < -0.005
ORDER BY MAX(t.transactionDate) ASC, c.nameMissing ASC, c.name ASC
HAVING ${bal} < ${-minOwed}
${since ? Prisma.sql`AND ${lastMovement} >= ${since}` : Prisma.empty}
${orderBy}
`;
const nums = await legacyNumbers(prisma, rows.map((r) => r.id));
let totalBalance = new Prisma.Decimal(0);
let totalMovs = 0;
const out = rows.map((r) => {
const b = r.balance ?? new Prisma.Decimal(0);
totalBalance = totalBalance.plus(b);
totalMovs += Number(r.movements);
return {
id: r.id.slice(0, 8),
numid: nums.numid(r.id),
name: nameOf(r),
city: r.city ?? "—",
movements: Number(r.movements),
lastMovement: isoDay(r.lastMovement),
lastPayment: isoDay(r.lastPayment),
balance: b.toFixed(2),
lastMovement: r.lastMovement
? r.lastMovement.toISOString().slice(0, 10)
: "—",
};
});
@@ -136,31 +271,58 @@ const listadoEnRojo: ReportDef = {
rows: out,
totals: {
customers: out.length,
movements: totalMovs,
balance: totalBalance.toFixed(2),
currency,
},
subtitle: `Moneda: ${currency} · ${out.length} clientes con saldo deudor`,
subtitle: `Moneda: ${currency} · ${out.length} clientes con saldo deudor${
p.inactive === "yes" ? " · incluye inactivos" : ""
}`,
};
},
};
/** The services whose bill the office pays and books as a ledger concept. */
const SERVICE_OPTIONS: { value: ServiceKind; label: string }[] = [
{ value: "WATER", label: "Agua" },
{ value: "ELECTRIC", label: "Luz" },
{ value: "TELEPHONE", label: "Teléfono" },
{ value: "GAS", label: "Gas" },
{ value: "PROPERTY_TAX", label: "Predial" },
{ value: "FEDERAL_ZONE", label: "Zona federal" },
{ value: "CABLE", label: "Cable" },
];
const serviceLabel = (kind: string) =>
SERVICE_OPTIONS.find((o) => o.value === kind)?.label ?? kind;
/**
* PAGOS NO EFECTUADOS (AGUA / LUZ / TEL).
* Customers enrolled in a service (by PropertyService.kind) with no
* related ledger charge in the last N days. Heuristic: any non-voided
* charge-type transaction in the window counts as "they paid". The
* report groups by property so the same customer with two water meters
* appears once per property.
* PAGOS NO EFECTUADOS (AGUA / LUZ / TEL / …).
*
* Legacy `PAGO AGUA/LUZ/TEL NO`: every customer with the service, LEFT JOIN
* `LOCALIZA PAGO *` — the rows of THAT service's concept dated in the period
* the operator typed — where nothing was found. So: "whose water did we not
* pay this month". The first port asked a different question ("no charge of
* any kind in N days"), which is why customers who were up to date kept
* showing: one IZZI or annual-fee row hid a missing water bill, and the
* reverse. It also passed `ELECTRICITY`/`PHONE`, which are not ServiceKind
* values, so Luz and Teléfono never returned a row at all.
*
* Matched per customer, as legacy did. A charge row carries no link to the
* service it paid, and its reference only matches the stored account for
* water and phone (813/822, 501/521) — CFE bills post under a reference that
* is not the RPU (5 of 1109) — so matching on the reference would list every
* electric customer as unpaid.
*
* A row captured "sin fondos" in the window is not a payment: the customer is
* listed with that status, which is the follow-up the office actually owes.
*/
const pagosNoEfectuados: ReportDef = {
slug: "pagos-no-efectuados",
title: "Pagos no efectuados",
description:
"Clientes con un servicio contratado (agua, luz o teléfono) sin " +
"movimientos de cargo en los últimos N días. Heurística basada en el " +
"servicio registrado en la propiedad y la ausencia de cargos en el " +
"periodo seleccionado.",
"Clientes con el servicio contratado sin un pago de ese servicio " +
"registrado en el periodo. Sin fechas, revisa un ciclo hacia atrás " +
"desde hoy: 31 días, o 62 para luz (bimestral).",
domain: "servicios",
legacyName: "PAGOS NO EFECTUADOS AGUA/LUZ/TEL",
format: "tabular",
@@ -169,139 +331,143 @@ const pagosNoEfectuados: ReportDef = {
key: "serviceKind",
label: "Servicio",
kind: "select",
options: [
{ value: "WATER", label: "Agua" },
{ value: "ELECTRICITY", label: "Luz" },
{ value: "PHONE", label: "Teléfono" },
],
options: SERVICE_OPTIONS,
defaultValue: "WATER",
},
{ key: "from", label: "Desde (por defecto, un ciclo atrás)", kind: "date" },
{ key: "to", label: "Hasta (por defecto, hoy)", kind: "date", endOfDay: true },
{
key: "days",
label: "Días sin movimiento",
kind: "number",
defaultValue: "60",
},
{
key: "currency",
label: "Moneda",
key: "inactive",
label: "Clientes inactivos",
kind: "select",
options: [
{ value: "MXN", label: "MXN" },
{ value: "USD", label: "USD" },
{ value: "no", label: "Excluir" },
{ value: "yes", label: "Incluir" },
],
defaultValue: "MXN",
defaultValue: "no",
},
],
columns: [
{ key: "customerId", label: "Cliente #", type: "text" },
NUMID_COLUMN,
{ key: "customerName", label: "Cliente", type: "text" },
{ key: "propertyAddress", label: "Dirección", type: "text" },
{ key: "accountNumber", label: "Cuenta / Medidor", type: "text" },
{ key: "lastCharge", label: "Último cargo", type: "date" },
{ key: "balance", label: "Saldo", type: "money", align: "right" },
{ key: "accountNumber", label: "Cuenta", type: "text" },
{ key: "status", label: "Estado", type: "text" },
{ key: "lastPaid", label: "Último pago del servicio", type: "date" },
],
async run(prisma, p) {
const kind = (p.serviceKind ?? "WATER") as
| "WATER"
| "ELECTRICITY"
| "PHONE";
const days = intParam(p, "days", 60, 1, 365);
const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD";
const cutoff = new Date(Date.now() - days * 86400000);
const kind = (
SERVICE_OPTIONS.some((o) => o.value === p.serviceKind)
? p.serviceKind
: "WATER"
) as ServiceKind;
const concept = CONCEPT_BY_KIND[kind]!;
// No period given: one billing cycle back from the end date, so the
// report means the same on the 2nd as on the 28th. "This month" listed
// nearly everyone early in the month — the bills are captured ~20th.
const to = parseDate(p.to, true) ?? new Date();
const cycleDays = kind === "ELECTRIC" ? 62 : 31;
const from =
parseDate(p.from) ?? new Date(to.getTime() - cycleDays * 86400000);
const inactive =
p.inactive === "yes"
? Prisma.empty
: Prisma.sql`AND c.status = 1`;
// One concept row for the customer, not voided, in the window.
const conceptRow = (outstanding: 0 | 1) => Prisma.sql`
SELECT 1 FROM transactions t
JOIN type_transactions tt ON tt.id = t.typeId
WHERE t.customerId = c.id AND t.voidedAt IS NULL
AND t.outstanding = ${outstanding} AND tt.nameEn = ${concept}
AND t.transactionDate >= ${from} AND t.transactionDate <= ${to}`;
// Customers enrolled in the service on a non-archived property, with no
// charge-type transaction in the window. The subquery picks up
// *anything* the customer paid (any domain, any type) — close enough
// for the staff's "who's overdue" view; the precise per-service match
// would need a per-service typeId taxonomy that doesn't exist in the
// legacy data.
const rows = await prisma.$queryRaw<
Array<{
customerId: string;
customerName: string;
nameMissing: boolean;
propertyId: string;
propertyAddress: string | null;
accountNumber: string | null;
lastCharge: Date | null;
balance: Prisma.Decimal | null;
addresses: string | null;
accounts: string | null;
unfunded: bigint | number;
lastPaid: Date | null;
}>
>`
SELECT
c.id AS customerId,
c.name AS customerName,
c.nameMissing AS nameMissing,
pr.id AS propertyId,
pr.addressLine1 AS propertyAddress,
ps.accountNumber AS accountNumber,
GROUP_CONCAT(DISTINCT pr.addressLine1 ORDER BY pr.addressLine1 SEPARATOR ' / ') AS addresses,
GROUP_CONCAT(DISTINCT NULLIF(ps.accountNumber, '') SEPARATOR ', ') AS accounts,
EXISTS(${conceptRow(1)}) AS unfunded,
(SELECT MAX(t.transactionDate) FROM transactions t
WHERE t.customerId = c.id AND t.voidedAt IS NULL
AND t.amount < 0
AND t.transactionDate >= ${cutoff}) AS lastCharge,
(SELECT SUM(t.amount) FROM transactions t
WHERE t.customerId = c.id AND t.voidedAt IS NULL
AND t.currency = ${currency}) AS balance
JOIN type_transactions tt ON tt.id = t.typeId
WHERE t.customerId = c.id AND t.voidedAt IS NULL
AND t.outstanding = 0 AND tt.nameEn = ${concept}) AS lastPaid
FROM property_services ps
JOIN properties pr ON pr.id = ps.propertyId AND pr.archivedAt IS NULL
JOIN customers c ON c.id = pr.customerId AND c.archivedAt IS NULL
WHERE ps.kind = ${kind} AND ps.active = 1
AND NOT EXISTS (
SELECT 1 FROM transactions t
WHERE t.customerId = c.id AND t.voidedAt IS NULL
AND t.amount < 0 AND t.transactionDate >= ${cutoff}
)
ORDER BY c.nameMissing ASC, c.name ASC, pr.addressLine1 ASC
WHERE ps.kind = ${kind} AND ps.active = 1 ${inactive}
AND NOT EXISTS (${conceptRow(0)})
GROUP BY c.id, c.name, c.nameMissing
ORDER BY c.nameMissing ASC, c.name ASC
`;
let totalBalance = new Prisma.Decimal(0);
const nums = await legacyNumbers(prisma, rows.map((r) => r.customerId));
let unfunded = 0;
const out = rows.map((r) => {
const b = r.balance ?? new Prisma.Decimal(0);
totalBalance = totalBalance.plus(b);
if (Number(r.unfunded)) unfunded++;
return {
customerId: r.customerId.slice(0, 8),
customerName: nameOf({
name: r.customerName,
nameMissing: r.nameMissing,
}),
propertyAddress: r.propertyAddress ?? "—",
accountNumber: r.accountNumber ?? "—",
lastCharge: r.lastCharge
? r.lastCharge.toISOString().slice(0, 10)
: "—",
balance: b.toFixed(2),
numid: nums.numid(r.customerId),
customerName: nameOf({ name: r.customerName, nameMissing: r.nameMissing }),
propertyAddress: r.addresses ?? "—",
accountNumber: r.accounts ?? "—",
status: Number(r.unfunded) ? "Sin fondos" : "Sin pago",
lastPaid: isoDay(r.lastPaid),
};
});
return {
rows: out,
totals: {
rows: out.length,
balance: totalBalance.toFixed(2),
currency,
clientes: out.length,
"sin pago": out.length - unfunded,
"sin fondos": unfunded,
},
subtitle: `Servicio: ${
kind === "WATER" ? "Agua" : kind === "ELECTRICITY" ? "Luz" : "Teléfono"
} · ${days} días · ${out.length} propiedades sin cargo reciente`,
subtitle: `Servicio: ${serviceLabel(kind)} · ${isoDay(from)} a ${isoDay(to)} · ${out.length} clientes sin pago registrado`,
};
},
};
/**
* FALTANTES DE (AGUA / LUZ / TEL).
* Data-quality report: properties enrolled in a service that are missing
* the key identifier the legacy system required (account/meter/route).
* Different `faltante` per service kind in the legacy because the
* service's identifier fields differ; here we flag any of the three
* common identifiers being blank.
* What each service must have on file to be paid and matched — the columns
* legacy's FALTANTES DE AGUA/LUZ/TEL read (DATMEX.AGUA; MEDIDOR + RPU;
* TELEFONO), in the field `scopedRefField` stores each one in. The first port
* demanded account AND meter AND route of every kind; water and phone have no
* route, so every one of them was flagged.
*/
const REQUIRED_FIELDS: Partial<
Record<ServiceKind, Array<["accountNumber" | "meterNumber", string]>>
> = {
WATER: [["accountNumber", "Sin número de cuenta"]],
ELECTRIC: [
["accountNumber", "Sin RPU"],
["meterNumber", "Sin medidor"],
],
TELEPHONE: [["accountNumber", "Sin número de teléfono"]],
};
/**
* FALTANTES DE (AGUA / LUZ / TEL) — data quality: active services missing the
* identifier the office needs to pay and match the bill.
*/
const faltantes: ReportDef = {
slug: "faltantes",
title: "Faltantes de datos por servicio",
description:
"Calidad de datos: propiedades con un servicio contratado que no " +
"tienen número de cuenta, medidor o ruta registrado. El reporte que " +
"en la legacy corría como FALTANTES DE AGUA / LUZ / TEL.",
"Calidad de datos: servicios activos sin el dato que se necesita para " +
"pagar el recibo — agua: número de cuenta; luz: RPU y medidor; " +
"teléfono: número.",
domain: "servicios",
legacyName: "FALTANTES DE AGUA/LUZ/TEL",
format: "tabular",
@@ -310,82 +476,65 @@ const faltantes: ReportDef = {
key: "serviceKind",
label: "Servicio",
kind: "select",
options: [
{ value: "WATER", label: "Agua" },
{ value: "ELECTRICITY", label: "Luz" },
{ value: "PHONE", label: "Teléfono" },
],
options: SERVICE_OPTIONS.filter((o) => REQUIRED_FIELDS[o.value]),
defaultValue: "WATER",
},
],
columns: [
{ key: "customerId", label: "Cliente #", type: "text" },
NUMID_COLUMN,
{ key: "customerName", label: "Cliente", type: "text" },
{ key: "propertyAddress", label: "Dirección", type: "text" },
{ key: "missing", label: "Faltante", type: "text" },
{ key: "dueDay", label: "Día de vencimiento", type: "text" },
],
async run(prisma, p) {
const kind = (p.serviceKind ?? "WATER") as
| "WATER"
| "ELECTRICITY"
| "PHONE";
const kind = (
p.serviceKind && REQUIRED_FIELDS[p.serviceKind as ServiceKind]
? p.serviceKind
: "WATER"
) as ServiceKind;
const required = REQUIRED_FIELDS[kind]!;
// Column names come from the constant above, never from the request.
const blank = (field: string) =>
Prisma.sql`(ps.${Prisma.raw(field)} IS NULL OR TRIM(ps.${Prisma.raw(field)}) = '')`;
// A row per (property, missing field). The "missing" string describes
// what's blank so the report is self-explanatory when printed.
const rows = await prisma.$queryRaw<
Array<{
customerId: string;
customerName: string;
nameMissing: boolean;
propertyId: string;
propertyAddress: string | null;
dueDay: string | null;
missing: string;
}>
>`
SELECT
c.id AS customerId,
c.name AS customerName,
c.nameMissing AS nameMissing,
pr.id AS propertyId,
c.id AS customerId,
c.name AS customerName,
c.nameMissing AS nameMissing,
pr.addressLine1 AS propertyAddress,
ps.dueDay AS dueDay,
CASE
WHEN ps.accountNumber IS NULL OR ps.accountNumber = '' THEN 'Sin número de cuenta'
WHEN ps.meterNumber IS NULL OR ps.meterNumber = '' THEN 'Sin número de medidor'
WHEN ps.route IS NULL OR ps.route = '' THEN 'Sin ruta'
ELSE ''
END AS missing
CONCAT_WS(', ', ${Prisma.join(
required.map(([field, label]) => Prisma.sql`IF(${blank(field)}, ${label}, NULL)`),
)}) AS missing
FROM property_services ps
JOIN properties pr ON pr.id = ps.propertyId AND pr.archivedAt IS NULL
JOIN customers c ON c.id = pr.customerId AND c.archivedAt IS NULL
WHERE ps.kind = ${kind} AND ps.active = 1
AND (
ps.accountNumber IS NULL OR ps.accountNumber = ''
OR ps.meterNumber IS NULL OR ps.meterNumber = ''
OR ps.route IS NULL OR ps.route = ''
)
WHERE ps.kind = ${kind} AND ps.active = 1 AND c.status = 1
AND (${Prisma.join(required.map(([field]) => blank(field)), " OR ")})
ORDER BY c.nameMissing ASC, c.name ASC, pr.addressLine1 ASC
`;
const nums = await legacyNumbers(prisma, rows.map((r) => r.customerId));
const out = rows.map((r) => ({
customerId: r.customerId.slice(0, 8),
customerName: nameOf({
name: r.customerName,
nameMissing: r.nameMissing,
}),
numid: nums.numid(r.customerId),
customerName: nameOf({ name: r.customerName, nameMissing: r.nameMissing }),
propertyAddress: r.propertyAddress ?? "—",
missing: r.missing,
dueDay: r.dueDay ?? "—",
}));
return {
rows: out,
totals: { rows: out.length },
subtitle: `Servicio: ${
kind === "WATER" ? "Agua" : kind === "ELECTRICITY" ? "Luz" : "Teléfono"
} · ${out.length} propiedades con datos faltantes`,
subtitle: `Servicio: ${serviceLabel(kind)} · ${out.length} servicios con datos faltantes`,
};
},
};
@@ -422,6 +571,7 @@ const reporteDeEfectivo: ReportDef = {
],
columns: [
{ key: "date", label: "Fecha", type: "date" },
NUMID_COLUMN,
{ key: "customerName", label: "Cliente", type: "text" },
{ key: "concept", label: "Concepto", type: "text" },
{ key: "source", label: "Origen", type: "text" },
@@ -436,6 +586,8 @@ const reporteDeEfectivo: ReportDef = {
NOT_VOIDED,
{ amount: { gt: 0 } },
{ currency },
// A credit opening balance has no check number but is not cash.
{ OR: [{ typeId: null }, { type: { nameEn: { not: BALANCE_FORWARD_TYPE } } }] },
{
OR: [
{ legacySourceTable: { in: ["EFECTIVO", "EFECTIVO_BACKUP"] } },
@@ -468,15 +620,17 @@ const reporteDeEfectivo: ReportDef = {
message: true,
legacySourceTable: true,
type: { select: { nameEs: true, nameEn: true } },
customer: { select: { name: true, nameMissing: true } },
customer: { select: { id: true, name: true, nameMissing: true } },
},
});
const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
let total = new Prisma.Decimal(0);
const out = rows.map((r) => {
total = total.plus(r.amount);
return {
date: r.transactionDate.toISOString().slice(0, 10),
numid: nums.numid(r.customer.id),
customerName: nameOf(r.customer),
concept: r.message ?? r.type?.nameEs ?? r.type?.nameEn ?? "—",
source: r.legacySourceTable ?? "—",
@@ -541,6 +695,8 @@ const vigente: ReportDef = {
],
columns: [
{ key: "policyNumber", label: "Póliza", type: "text" },
NUMID_COLUMN,
INSURANCE_NO_COLUMN,
{ key: "customerName", label: "Cliente", type: "text" },
{ key: "provider", label: "Aseguradora", type: "text" },
{ key: "agent", label: "Agente", type: "text" },
@@ -571,11 +727,12 @@ const vigente: ReportDef = {
policyTo: true,
netPremium: true,
agentName: true,
customer: { select: { name: true, nameMissing: true } },
customer: { select: { id: true, name: true, nameMissing: true } },
insuranceProvider: { select: { name: true } },
},
});
const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
let totalPremium = new Prisma.Decimal(0);
const out = rows.map((r) => {
const daysTo = r.policyTo
@@ -586,6 +743,8 @@ const vigente: ReportDef = {
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
return {
policyNumber: r.policyNumber,
numid: nums.numid(r.customer.id),
insuranceNo: nums.insurance(r.customer.id),
customerName: nameOf(r.customer),
provider: r.insuranceProvider?.name ?? "—",
agent: r.agentName ?? "—",
@@ -689,6 +848,8 @@ const avisoRenovacion: ReportDef = {
// ReportRunner.tsx) — same trade-off edoCuentaDatos makes for "statement".
columns: [
{ key: "policyNumber", label: "Póliza", type: "text" },
NUMID_COLUMN,
INSURANCE_NO_COLUMN,
{ key: "customerName", label: "Cliente", type: "text" },
{ key: "provider", label: "Aseguradora", type: "text" },
{ key: "policyTo", label: "Vence", type: "date" },
@@ -720,11 +881,16 @@ const avisoRenovacion: ReportDef = {
select: renewalLetterSelect(generation),
});
const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
let totalPremium = new Prisma.Decimal(0);
let sentCount = 0;
const out = rows.map((r) => {
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
const letter = toRenewalLetterRow(r, generation);
const letter = {
...toRenewalLetterRow(r, generation),
numid: nums.numid(r.customer.id),
insuranceNo: nums.insurance(r.customer.id),
};
if (letter.sentAt) sentCount++;
return letter;
});
@@ -802,6 +968,7 @@ const edoCuentaDatos: ReportDef = {
},
});
if (!customer) return { rows: [], subtitle: "Cliente no encontrado" };
const numid = (await legacyNumbers(prisma, [customer.id])).numid(customer.id);
// The source-table exclusion, the balance floor and the year scope below
// are BillingService.statement's, because this report and
@@ -966,6 +1133,7 @@ const edoCuentaDatos: ReportDef = {
{
__kind: "header",
name: nameOf(customer),
numid,
address: customer.addressLine1 ?? "",
city: [customer.city, customer.state].filter(Boolean).join(", "),
phone: customer.phone ?? "",
@@ -983,7 +1151,7 @@ const edoCuentaDatos: ReportDef = {
...carried,
...movements,
],
subtitle: `${nameOf(customer)} · ${year} · ${visible.length} movimientos`,
subtitle: `NUMid ${numid} · ${nameOf(customer)} · ${year} · ${visible.length} movimientos`,
};
},
};
@@ -1021,6 +1189,7 @@ const chequeCount: ReportDef = {
},
],
columns: [
NUMID_COLUMN,
{ key: "customerName", label: "Cliente", type: "text" },
{ key: "reference", label: "Referencia", type: "text" },
{ key: "period", label: "Periodo", type: "text" },
@@ -1050,7 +1219,7 @@ const chequeCount: ReportDef = {
period: true,
outstanding: true,
type: { select: { nameEn: true, nameEs: true } },
customer: { select: { name: true, nameMissing: true } },
customer: { select: { id: true, name: true, nameMissing: true } },
},
});
@@ -1076,8 +1245,10 @@ const chequeCount: ReportDef = {
}
if (outstandingCount) totalsOut["sin fondos"] = outstandingCount;
const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
return {
rows: rows.map((r) => ({
numid: nums.numid(r.customer.id),
customerName: nameOf(r.customer),
reference: r.reference ?? "—",
period: r.period ?? "—",
@@ -24,7 +24,7 @@ import type { ConfirmBatchDto, ReviewDocumentDto } from "./statement.dto";
* `TYPE OF TRX` values already in `type_transactions`, resolved by name once
* per confirm rather than hard-coded as ids, which differ per environment.
*/
const CONCEPT_BY_KIND: Partial<Record<ServiceKind, string>> = {
export const CONCEPT_BY_KIND: Partial<Record<ServiceKind, string>> = {
ELECTRIC: "ELECTRIC",
WATER: "WATER",
TELEPHONE: "TELEPHONE",
@@ -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,18 +260,38 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
{sweeping ? "Enviando…" : "Ejecutar barrido"}
</button>
</div>
<div className="field" style={{ maxWidth: 180, marginTop: 12, marginBottom: 0 }}>
<span className="field-label">Ventana (días)</span>
<input
className="input"
type="number"
min={1}
max={365}
value={days}
onChange={(e) =>
setDays(Math.min(365, Math.max(1, Number(e.target.value) || 30)))
}
/>
<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"
type="number"
min={1}
max={365}
value={days}
onChange={(e) =>
setDays(Math.min(365, Math.max(1, Number(e.target.value) || 30)))
}
/>
</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>
+3
View File
@@ -390,6 +390,9 @@ function StatementLayout({ result }: { result: ReportRunResult }) {
<div className="statement">
<header className="statement-head">
<h2 className="statement-name">{String(header.name ?? "—")}</h2>
{header.numid && header.numid !== "—" ? (
<p className="muted">NUMid {String(header.numid)}</p>
) : null}
{Boolean(header.address) && (
<p className="muted">{String(header.address)}</p>
)}
+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);
});
+14 -11
View File
@@ -164,18 +164,21 @@ def main():
services.append((str(uuid.uuid4()), pid, kind, account, meter, route,
due, 1 if active else 0, notes))
# WATER (primary + extra meters)
# WATER — the CESPT account only. DATMEX has no water meter column.
if s(row["agua"]) or flag("water1", False):
svc("WATER", account=s(row["agua"]), meter=s(row["medidor"]),
route=s(row["ruta"]), due=s(row["diagua"]), active=flag("water1"))
for mcol, rcol in (("medidor2", "ruta2"), ("medidor3", "ruta3")):
if s(row[mcol]) or s(row[rcol]):
svc("WATER", meter=s(row[mcol]), route=s(row[rcol]),
notes="secondary meter", active=flag("water1"))
# ELECTRIC (rpu account + extras); luz_tipo as note
for rc in ("rpu", "rpu2", "rpu3"):
if s(row[rc]):
svc("ELECTRIC", account=s(row[rc]),
svc("WATER", account=s(row["agua"]), due=s(row["diagua"]),
active=flag("water1"))
# ELECTRIC — rpu account + the CFE meter and route beside it. MEDIDOR/
# RUTA are the electric meter, not water: they are filled on exactly
# the 1113 rows that carry an RPU (100% overlap), the meters are CFE's
# alphanumeric `53MT58` form, and every legacy query that reads them is
# a LUZ one (LUZ, FALTANTES DE LUZ, "luz ruta 325 y 329"). They used to
# land on the WATER row, leaving every electric service without a meter.
# Paired by suffix: medidor2/ruta2 belong to rpu2. luz_tipo as note.
for rc, mc, rtc in (("rpu", "medidor", "ruta"), ("rpu2", "medidor2", "ruta2"),
("rpu3", "medidor3", "ruta3")):
if s(row[rc]) or s(row[mc]):
svc("ELECTRIC", account=s(row[rc]), meter=s(row[mc]), route=s(row[rtc]),
notes=s(row["luz_tipo"]) if rc == "rpu" else None,
active=flag("electric"))
# GAS — the column mixes two things: an account/meter number for 160 of
+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();