Compare commits

..
3 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-api (push) Successful in 2m27s
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m59s
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
9 changed files with 518 additions and 45 deletions
+25
View File
@@ -25,6 +25,10 @@
# through this workflow at all. # through this workflow at all.
# 4. app (api + web) the new images. # 4. app (api + web) the new images.
# 5. verify ask the running API what it actually is. # 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 # 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 # 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)" echo "dispatched '$WANT'; tiers report '$API_VER' (not directly comparable)"
;; ;;
esac 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
@@ -46,6 +46,20 @@ describe("renderRenewalEmail", () => {
expect(result.html).toContain("Calle Uno 123"); 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", () => { it("uses overdue wording for generation three", () => {
const result = renderRenewalEmail(letter({ generation: 3 })); 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>`; 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; subject: string;
html: string; html: string;
} { } {
const includePremium = options.includePremium !== false;
const expired = letter.generation === 3; const expired = letter.generation === 3;
const subject = expired const subject = expired
? `Póliza vencida: ${letter.policyNumber}` ? `Póliza vencida: ${letter.policyNumber}`
@@ -51,7 +64,7 @@ export function renderRenewalEmail(letter: RenewalLetterRow): {
row("Tipo de póliza", letter.policyType), row("Tipo de póliza", letter.policyType),
row("Aseguradora", letter.provider), row("Aseguradora", letter.provider),
row("Fecha de vencimiento", displayDate(letter.policyTo)), 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("Cliente", letter.customerName),
row("Correo", letter.customerEmail ?? "No disponible"), row("Correo", letter.customerEmail ?? "No disponible"),
row("Teléfono", phone), row("Teléfono", phone),
@@ -183,6 +183,50 @@ describe("renewal notices write the shared notification log", () => {
expect(prisma.renewalNotice.upsert).not.toHaveBeenCalled(); 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 () => { it("does not fail a delivered notice when the log write throws", async () => {
const { service, record } = build({}); const { service, record } = build({});
record.mockRejectedValue(new Error("log table gone")); record.mockRejectedValue(new Error("log table gone"));
+14 -2
View File
@@ -25,6 +25,13 @@ class RenewalFlagsDto {
debug?: boolean; debug?: boolean;
} }
class SweepRenewalsDto extends RenewalFlagsDto {
/** Sweep one aseguradora only (GMX, ANA, …). Omitted = todas. */
@IsOptional()
@IsString()
providerId?: string;
}
class SendRenewalDto extends RenewalFlagsDto { class SendRenewalDto extends RenewalFlagsDto {
@IsString() @IsString()
policyId!: string; policyId!: string;
@@ -43,17 +50,22 @@ export class RenewalsController {
constructor(private readonly renewals: RenewalsService) {} constructor(private readonly renewals: RenewalsService) {}
@Get("pending") @Get("pending")
pending(@Query("days") days?: string) { pending(
@Query("days") days?: string,
@Query("providerId") providerId?: string,
) {
return this.renewals.pending( return this.renewals.pending(
Math.min(365, Math.max(1, Number(days) || 30)), Math.min(365, Math.max(1, Number(days) || 30)),
providerId?.trim() || undefined,
); );
} }
@Post("sweep") @Post("sweep")
@RequireAbility("renewal:send") @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, { return this.renewals.sweep((req.user as { id: string }).id, {
debug: dto?.debug, 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 /** 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 * 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> { async scheduledSweep(): Promise<void> {
try { try {
await this.sweep(); await this.sweep(undefined, { automatic: true });
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`Falló el barrido de renovaciones: ${(error as Error).message}`, `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 today = dateInTimeZone(new Date());
const state = await this.prisma.scheduledJobState.findUnique({ const state = await this.prisma.scheduledJobState.findUnique({
where: { name: JOB_NAME }, where: { name: JOB_NAME },
@@ -111,6 +118,7 @@ export class RenewalsService implements OnModuleInit {
item, item,
today, today,
state?.lastSuccessfulAt ?? null, 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 debug = !!flags.debug;
const providerId = flags.providerId?.trim() || undefined;
const includePremium = !flags.automatic;
const now = new Date(); const now = new Date();
const state = await this.acquireLock(now); const state = await this.acquireLock(now);
@@ -145,6 +165,7 @@ export class RenewalsService implements OnModuleInit {
cadence, cadence,
today, today,
state.lastSuccessfulAt, state.lastSuccessfulAt,
providerId,
); );
eligible += policies.length; eligible += policies.length;
@@ -157,13 +178,17 @@ export class RenewalsService implements OnModuleInit {
await this.recordLog(policy, cadence.generation, "", { await this.recordLog(policy, cadence.generation, "", {
status: "SKIPPED_NO_EMAIL", status: "SKIPPED_NO_EMAIL",
debug, debug,
includePremium,
}); });
skipped++; skipped++;
continue; continue;
} }
try { try {
await this.deliver(policy, cadence.generation, to, userId, debug); await this.deliver(policy, cadence.generation, to, userId, {
debug,
includePremium,
});
sent++; sent++;
} catch (error) { } catch (error) {
failures.push({ failures.push({
@@ -182,11 +207,18 @@ export class RenewalsService implements OnModuleInit {
failed: failures.length, failed: failures.length,
failures, failures,
debug, debug,
providerId: providerId ?? null,
}; };
// A debug run must not advance `lastSuccessfulAt`: it wrote no // A debug run must not advance `lastSuccessfulAt`: it wrote no
// RenewalNotice rows, so the days it "covered" are still owed, and // RenewalNotice rows, so the days it "covered" are still owed, and
// narrowing tomorrow's window back to a single day would drop them. // 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); void this.audit.log(userId, "renewalNotice.sweep", result);
return result; return result;
} catch (error) { } catch (error) {
@@ -233,12 +265,14 @@ export class RenewalsService implements OnModuleInit {
throw new BadRequestException("El cliente no tiene correo registrado."); 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( const { sentAt, providerMessageId, addressedTo } = await this.deliver(
policy, policy,
generation, generation,
to, to,
userId, userId,
debug, { debug, includePremium: true },
); );
return { return {
policyId, policyId,
@@ -270,10 +304,12 @@ export class RenewalsService implements OnModuleInit {
generation: number, generation: number,
to: string, to: string,
userId?: 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 letter = toRenewalLetterRow(policy, generation);
const message = renderRenewalEmail(letter); const message = renderRenewalEmail(letter, { includePremium });
const addressedTo = debug ? DEBUG_RECIPIENT : to; const addressedTo = debug ? DEBUG_RECIPIENT : to;
let result: Awaited<ReturnType<MailService["send"]>>; let result: Awaited<ReturnType<MailService["send"]>>;
@@ -291,6 +327,7 @@ export class RenewalsService implements OnModuleInit {
status: "FAILED", status: "FAILED",
error: detail, error: detail,
debug, debug,
includePremium,
}); });
throw error; throw error;
} }
@@ -324,6 +361,7 @@ export class RenewalsService implements OnModuleInit {
providerResponse: result.response || undefined, providerResponse: result.response || undefined,
sendDate: sentAt, sendDate: sentAt,
debug, debug,
includePremium,
}); });
void this.audit.log(userId, "renewalNotice.send", { void this.audit.log(userId, "renewalNotice.send", {
policyId: policy.id, policyId: policy.id,
@@ -355,10 +393,15 @@ export class RenewalsService implements OnModuleInit {
error?: string; error?: string;
sendDate?: Date; sendDate?: Date;
debug?: boolean; debug?: boolean;
/** Must match what `deliver` rendered, or `bodySnapshot` shows the
* office a letter the customer never received. */
includePremium?: boolean;
}, },
): Promise<void> { ): Promise<void> {
const letter = toRenewalLetterRow(policy, generation); const letter = toRenewalLetterRow(policy, generation);
const message = renderRenewalEmail(letter); const message = renderRenewalEmail(letter, {
includePremium: outcome.includePremium,
});
try { try {
await this.notificationLog.record({ await this.notificationLog.record({
notificationType: "RENEWAL_NOTICE", notificationType: "RENEWAL_NOTICE",
@@ -391,11 +434,13 @@ export class RenewalsService implements OnModuleInit {
cadence: (typeof RENEWAL_CADENCE)[number], cadence: (typeof RENEWAL_CADENCE)[number],
today: Date, today: Date,
lastSuccessfulAt: Date | null, lastSuccessfulAt: Date | null,
providerId?: string,
) { ) {
const window = renewalWindow(today, cadence.offsetDays, lastSuccessfulAt); const window = renewalWindow(today, cadence.offsetDays, lastSuccessfulAt);
return this.prisma.policy.findMany({ return this.prisma.policy.findMany({
where: { where: {
archivedAt: null, archivedAt: null,
...(providerId && { insuranceProviderId: providerId }),
policyTo: { gte: window.from, lte: window.to }, policyTo: { gte: window.from, lte: window.to },
customer: { customer: {
archivedAt: null, archivedAt: null,
@@ -4,7 +4,13 @@ import { useCallback, useEffect, useState } from "react";
import { useCan } from "@/lib/abilities"; import { useCan } from "@/lib/abilities";
import { formatDate, formatMoney } from "@/lib/labels"; import { formatDate, formatMoney } from "@/lib/labels";
import { NotificationLogPanel } from "@/components/NotificationLogPanel"; 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 * 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 * 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 * inbox. It additionally does NOT mark the notice as sent, so a test send
* leaves the row exactly where it was — pending. * 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 { export interface RenewalLetter {
@@ -46,6 +58,8 @@ export interface RenewalSweepResult {
failed: number; failed: number;
failures: { policyId: string; generation: number; error: string }[]; failures: { policyId: string; generation: number; error: string }[];
debug: boolean; debug: boolean;
/** Echoed back so the confirmation says which carrier actually ran. */
providerId: string | null;
} }
export interface RenewalSendResult { export interface RenewalSendResult {
@@ -68,6 +82,10 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
const allowed = useCan("renewal:send"); const allowed = useCan("renewal:send");
const debug = !!flags.debug; const debug = !!flags.debug;
const [days, setDays] = useState(30); 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 [pending, setPending] = useState<RenewalLetter[] | null>(null);
const [pendingError, setPendingError] = useState<string | null>(null); const [pendingError, setPendingError] = useState<string | null>(null);
const [actionError, setActionError] = 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 () => { const refresh = useCallback(async () => {
setPendingError(null); setPendingError(null);
try { try {
const params = new URLSearchParams({ days: String(days) });
if (providerId) params.set("providerId", providerId);
const data = await apiFetch<RenewalLetter[]>( const data = await apiFetch<RenewalLetter[]>(
`/renewals/pending?days=${days}`, `/renewals/pending?${params.toString()}`,
); );
setPending(data); setPending(data);
} catch (e) { } catch (e) {
@@ -91,18 +111,44 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
); );
setPending([]); setPending([]);
} }
}, [days]); }, [days, providerId]);
useEffect(() => { useEffect(() => {
if (allowed) refresh(); if (allowed) refresh();
}, [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() { async function handleSweep() {
// Only worth confirming when debug is off — that is the case where real // Only worth confirming when debug is off — that is the case where real
// customers receive mail. Mirrors "Ejecutar todos" on the servicios tab. // 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) { if (!debug) {
const ok = window.confirm( 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; if (!ok) return;
} }
@@ -112,10 +158,11 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
try { try {
const result = await apiFetch<RenewalSweepResult>("/renewals/sweep", { const result = await apiFetch<RenewalSweepResult>("/renewals/sweep", {
method: "POST", method: "POST",
body: JSON.stringify({ debug }), body: JSON.stringify({ debug, providerId: providerId || undefined }),
}); });
setNotice( setNotice(
`Enviados ${result.sent} avisos (${result.failed} con error).` + `Enviados ${result.sent} avisos de ${providerLabel} ` +
`(${result.failed} con error).` +
(result.debug (result.debug
? " Modo debug: fueron al buzón de pruebas y siguen pendientes." ? " 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> <h2 className="section-title">Barrido manual</h2>
<p className="muted small" style={{ marginTop: 4 }}> <p className="muted small" style={{ marginTop: 4 }}>
Usa la fecha actual del servidor como referencia para seleccionar 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> </p>
</div> </div>
<button <button
@@ -211,7 +260,11 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
{sweeping ? "Enviando…" : "Ejecutar barrido"} {sweeping ? "Enviando…" : "Ejecutar barrido"}
</button> </button>
</div> </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> <span className="field-label">Ventana (días)</span>
<input <input
className="input" className="input"
@@ -224,6 +277,22 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
} }
/> />
</div> </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> </section>
{pendingError && <div className="state-box state-error">{pendingError}</div>} {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);
});
+157 -12
View File
@@ -15,14 +15,27 @@
* is a raw lifetime sum. The platform only migrated the CURRENT-year * is a raw lifetime sum. The platform only migrated the CURRENT-year
* charge ledger (datos2); the per-year charge tables live in DreamHost * charge ledger (datos2); the per-year charge tables live in DreamHost
* and were never staged. What survives before the cutover is therefore * and were never staged. What survives before the cutover is therefore
* the EFECTIVO cash journal — receipts with no matching charges — so * a cash journal — receipts with no matching charges — so those sums read
* those sums read as the office owing money it does not owe. * as the office owing money it does not owe.
* *
* B. DOUBLE-BOOKED 2026 RECEIPTS — one cash receipt recorded twice, once in * READ THE COMPOSITION LINE BEFORE ACTING ON THIS. After the prior-period
* EFECTIVO with folio `N` and once in datos2 with reference `CN`. Both * import the group is mostly insurance-only customers whose rows come from
* rows are after the 2026-01-01 floor, so both count. The statement hides * the seguros database's own EFECTIVO, which is that line's ONLY ledger.
* them (STATEMENT_EXCLUDED_SOURCE_TABLES drops EFECTIVO); the balances * Flooring those deletes receipts instead of removing a double count. The
* worklist, the movement browser and the /clientes/:id card do not. * "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 * 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 * lead and never as the verdict. Folios are reused, so `C13483` can collide
@@ -82,6 +95,44 @@ const BF_PREDICATE = `(
AND t.legacySourceTable = 'datos2') 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() { async function main() {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const limit = Number(arg(args, "--limit", "25")); 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='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='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) 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) 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 >= ? ROUND(SUM(CASE WHEN t.currency='MXN' AND t.transactionDate >= ?
THEN t.amount ELSE 0 END), 2) AS flooredMxn, THEN t.amount ELSE 0 END), 2) AS flooredMxn,
ROUND(SUM(CASE WHEN t.currency='USD' AND t.transactionDate >= ? ROUND(SUM(CASE WHEN t.currency='USD' AND t.transactionDate >= ?
@@ -197,6 +252,39 @@ async function main() {
cutover, 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 --------------------------------- // ---- section B: double-booked receipts ---------------------------------
const pairs = await prisma.$queryRawUnsafe( 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 ------------------------------------------------------- // ---- CSV escapes -------------------------------------------------------
if (args.includes("--csv-a")) return dumpCsv(floorless); if (args.includes("--csv-a")) return dumpCsv(floorless);
if (args.includes("--csv-b")) return dumpCsv([...pairs, ...nearby]); if (args.includes("--csv-b")) return dumpCsv([...pairs, ...nearby]);
@@ -276,8 +392,15 @@ async function main() {
// ---- report ------------------------------------------------------------ // ---- report ------------------------------------------------------------
console.log("\nBOOK (voided and outstanding rows excluded)"); 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(` 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(` 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 preRowsTotal = floorless.reduce((s, r) => s + d(r.preRows), 0);
const wouldZero = floorless.filter((r) => d(r.postRows) === 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(` balance moved by flooring: ${money(-deltaMxn)} MXN ${money(-deltaUsd)} USD`);
console.log(` customers left with NO rows at all after the cut: ${wouldZero.length}` + console.log(` customers left with NO rows at all after the cut: ${wouldZero.length}` +
` (their balance becomes 0 — an assertion, not a migrated figure)`); ` (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( console.log(
`\n ${"customer".padEnd(30)} ${"pre".padStart(4)} ${"post".padStart(4)}` + `\n ${"customer".padEnd(30)} ${"pre".padStart(4)} ${"post".padStart(4)}` +
` ${"today MXN".padStart(13)} ${"after MXN".padStart(13)} ${"first tx".padStart(10)}`, ` ${"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(` 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(` 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(` 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( console.log(
`\n ${"customer".padEnd(28)} ${"datos2".padStart(10)} ${"efectivo".padStart(10)}` + `\n ${"customer".padEnd(28)} ${"datos2".padStart(10)} ${"efectivo".padStart(10)}` +
` ${"datos2 amt".padStart(13)} ${"efectivo amt".padStart(13)} ref`, ` ${"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" + "\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" + "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" + "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" + "database. It exists in the DreamHost per-year tables. Section B is no\n" +
"independent defect and does not need a corte to fix.", "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 { } finally {
await prisma.$disconnect(); await prisma.$disconnect();