Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36158ae761 | ||
|
|
fa38ff581e | ||
|
|
c4213aa697 | ||
|
|
f7507f2370 | ||
|
|
2f9a9afc0d | ||
|
|
7e71a993d0 | ||
|
|
aa5867c8ea |
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@jorgecuadros/api",
|
"name": "@jorgecuadros/api",
|
||||||
"version": "1.0.24",
|
"version": "1.0.26",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
|
|||||||
@@ -149,26 +149,40 @@ describe("balance floor", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the source-table exclusion alongside the floor", async () => {
|
it("keeps the source-table exclusion alongside the floor", async () => {
|
||||||
// The two guards answer different questions — one reproduces legacy's
|
// The three guards answer different questions — one windows imported
|
||||||
// DATOS2-only materialization, the other drops superseded history — and
|
// periods, one drops the cash receipt book the ledger already posts, the
|
||||||
// dropping either one changes the customer's balance.
|
// floor drops superseded history — and dropping any one of them changes
|
||||||
|
// the customer's balance.
|
||||||
const { service, findMany } = serviceWith(new Date("2026-01-01T00:00:00Z"));
|
const { service, findMany } = serviceWith(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
|
||||||
await service.statement("c1");
|
await service.statement("c1");
|
||||||
|
|
||||||
const where = rowsQuery(findMany).where;
|
const where = rowsQuery(findMany).where;
|
||||||
expect(where.OR).toEqual([
|
// Imported periods are windowed rather than excluded: history below the
|
||||||
|
// year start (the only carry a floored-by-archive customer has), never
|
||||||
|
// at or above it (those rows sit inside the next BALANCE FORWARD).
|
||||||
|
expect(where.AND).toEqual([
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
||||||
|
{ transactionDate: { lt: expect.any(Date) } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// The cash journal, qualified by the database it came from — the
|
||||||
|
// insurance line has its own EFECTIVO and that one is a real ledger.
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ legacySourceDb: null },
|
||||||
|
{ legacySourceDb: { not: "UTILITIES" } },
|
||||||
{ legacySourceTable: null },
|
{ legacySourceTable: null },
|
||||||
{
|
{
|
||||||
legacySourceTable: {
|
legacySourceTable: {
|
||||||
notIn: expect.arrayContaining(["EFECTIVO"]),
|
notIn: expect.arrayContaining(["EFECTIVO"]),
|
||||||
// Imported prior periods are excluded here too. The floor does not
|
|
||||||
// cover them: a customer whose newest BALANCE FORWARD sits inside
|
|
||||||
// an archive floors at that archive's own Jan 1, and every archive
|
|
||||||
// spills a row or two into the following January.
|
|
||||||
not: { startsWith: "datos2@" },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -209,18 +209,6 @@ export const BALANCE_FLOOR_JOIN = Prisma.sql`
|
|||||||
*/
|
*/
|
||||||
export const NOT_SUPERSEDED = Prisma.sql`(bfloor.floorDate IS NULL OR t.transactionDate >= bfloor.floorDate)`;
|
export const NOT_SUPERSEDED = Prisma.sql`(bfloor.floorDate IS NULL OR t.transactionDate >= bfloor.floorDate)`;
|
||||||
|
|
||||||
/**
|
|
||||||
* Source tables excluded from the customer-facing statement.
|
|
||||||
*
|
|
||||||
* The legacy portal's `datosfreak` table was materialized from DATOS2 only
|
|
||||||
* (`objects.json:1358`), so the customer's "current balance" never saw
|
|
||||||
* EFECTIVO / EFECTIVO FM3 / CHEQUE FM3 / EFECTIVO_BACKUP cash receipts, nor
|
|
||||||
* the IVA 2015 snapshot. The unified `transactions` table has all of them, so
|
|
||||||
* the statement must drop them to match the legacy number the customer has
|
|
||||||
* been quoted for years. The staff-facing balances worklist and movement
|
|
||||||
* browser keep them — they're real money, just tracked separately
|
|
||||||
* (FM3 = visa fee stream, EFECTIVO = cash receipt stream).
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* `legacySourceTable` of an imported prior period.
|
* `legacySourceTable` of an imported prior period.
|
||||||
*
|
*
|
||||||
@@ -235,7 +223,34 @@ export const periodSourceTable = (year: number) => `datos2@${year}`;
|
|||||||
/** Matches any imported period tag, for discovering which years a customer has. */
|
/** Matches any imported period tag, for discovering which years a customer has. */
|
||||||
export const PERIOD_TABLE_PREFIX = "datos2@";
|
export const PERIOD_TABLE_PREFIX = "datos2@";
|
||||||
|
|
||||||
export const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [
|
/**
|
||||||
|
* The legacy cash receipt book — a journal, not a ledger.
|
||||||
|
*
|
||||||
|
* `EFECTIVO` is the office's numbered receipt pad: money is handed over the
|
||||||
|
* counter, a folio is written, and the same receipt is then *posted* to the
|
||||||
|
* utilities ledger (`DATOS2`) as reference `C<folio>`. Legacy summed the ledger
|
||||||
|
* alone — `ws/v2/lib/ledger_repository.php` reads `datosfreak`, which is
|
||||||
|
* materialized from DATOS2 only (`objects.json:1358`). The migration flattened
|
||||||
|
* both tables into one `transactions` table, so anything summing a customer's
|
||||||
|
* rows counts every cash receipt twice.
|
||||||
|
*
|
||||||
|
* Verified against the live legacy database on 2026-08-20: of the 297 receipts
|
||||||
|
* written in 2026, 296 carry a matching DATOS2 posting. Only folio 13536 (CL
|
||||||
|
* 717, $400 USD) has no posting anywhere, and it wants a human's eyes rather
|
||||||
|
* than a code change. Six receipts post converted to pesos under a mistyped
|
||||||
|
* folio, which is why matching on folio and amount found fewer duplicate pairs
|
||||||
|
* than actually exist — a reason to exclude the whole journal rather than to
|
||||||
|
* exclude a list of confirmed pairs.
|
||||||
|
*
|
||||||
|
* THE DATABASE QUALIFIER IS LOAD-BEARING. `SEGUROS 16_be` keeps its own table
|
||||||
|
* also called `EFECTIVO`, and that one is the insurance line's *only* ledger —
|
||||||
|
* nothing posts it anywhere else. Excluding by table name alone erases the
|
||||||
|
* whole insurance balance: 55,444.95 USD and 63,957.78 MXN across 102
|
||||||
|
* customers, 99 of whom have no other rows at all.
|
||||||
|
*/
|
||||||
|
export const CASH_JOURNAL_SOURCE_DB = "UTILITIES";
|
||||||
|
|
||||||
|
export const CASH_JOURNAL_SOURCE_TABLES: readonly string[] = [
|
||||||
"EFECTIVO",
|
"EFECTIVO",
|
||||||
"EFECTIVO_BACKUP",
|
"EFECTIVO_BACKUP",
|
||||||
"EFECTIVO FM3",
|
"EFECTIVO FM3",
|
||||||
@@ -243,6 +258,53 @@ export const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [
|
|||||||
"IVA 2015",
|
"IVA 2015",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prisma form of the cash-journal exclusion.
|
||||||
|
*
|
||||||
|
* Spelled as a positive OR on purpose. `notIn` alone compiles to SQL `NOT IN`,
|
||||||
|
* and `NULL NOT IN (...)` is NULL rather than true, so every app-captured row
|
||||||
|
* (no `legacySourceTable`) would silently vanish. Same for the database test.
|
||||||
|
*/
|
||||||
|
export const notCashJournal = (): Prisma.TransactionWhereInput => ({
|
||||||
|
OR: [
|
||||||
|
{ legacySourceDb: null },
|
||||||
|
{ legacySourceDb: { not: CASH_JOURNAL_SOURCE_DB } },
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{ legacySourceTable: { notIn: [...CASH_JOURNAL_SOURCE_TABLES] } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Raw-SQL form, for the aggregate queries that cannot use Prisma's builder. */
|
||||||
|
export const NOT_CASH_JOURNAL = Prisma.sql`(
|
||||||
|
t.legacySourceDb IS NULL
|
||||||
|
OR t.legacySourceDb <> ${CASH_JOURNAL_SOURCE_DB}
|
||||||
|
OR t.legacySourceTable IS NULL
|
||||||
|
OR t.legacySourceTable NOT IN (${Prisma.join([
|
||||||
|
...CASH_JOURNAL_SOURCE_TABLES,
|
||||||
|
])}))`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps an imported prior period out of the *current* period, NULL-safely.
|
||||||
|
*
|
||||||
|
* The balance floor does not settle the archives on its own, in both
|
||||||
|
* directions. A customer whose newest BALANCE FORWARD lives *inside* an archive
|
||||||
|
* floors at that archive's own January 1st, so every row of it clears the floor
|
||||||
|
* — and that is correct, because below the year start the archive is the only
|
||||||
|
* carry there is. At or above the year start it must go: the archives spill a
|
||||||
|
* couple of rows into the following January and those already sit inside the
|
||||||
|
* next year's BALANCE FORWARD, which is the sum of the whole archive.
|
||||||
|
*
|
||||||
|
* Spelled as a positive OR for the same NULL reason as above.
|
||||||
|
*/
|
||||||
|
export const archiveIsHistorySql = (yearStart: Date) => Prisma.sql`(
|
||||||
|
t.legacySourceTable IS NULL
|
||||||
|
OR t.legacySourceTable NOT LIKE ${`${PERIOD_TABLE_PREFIX}%`}
|
||||||
|
OR t.transactionDate < ${yearStart})`;
|
||||||
|
|
||||||
|
/** January 1st of the running year, UTC — the current period's lower bound. */
|
||||||
|
export const currentYearStart = () =>
|
||||||
|
new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1));
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BillingService {
|
export class BillingService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@@ -412,6 +474,16 @@ export class BillingService {
|
|||||||
async balances(params: BalanceParams) {
|
async balances(params: BalanceParams) {
|
||||||
const { query, page, pageSize, currency, balance, domain, sort } = params;
|
const { query, page, pageSize, currency, balance, domain, sort } = params;
|
||||||
|
|
||||||
|
// A balance is what the customer owes, so it takes the same rules the
|
||||||
|
// statement takes: the floor, the cash journal, and the archive window.
|
||||||
|
// Without the last two the worklist quoted a different number than the
|
||||||
|
// customer's own statement — NUMid 295 read 14,377.46 against a statement
|
||||||
|
// of 4,377.46, and NUMid 10 read 2,362.20 against -1,137.80, the gap in
|
||||||
|
// each case being a cash receipt already posted to the ledger.
|
||||||
|
const scope = Prisma.sql`AND ${NOT_CASH_JOURNAL} AND ${archiveIsHistorySql(
|
||||||
|
currentYearStart(),
|
||||||
|
)}`;
|
||||||
|
|
||||||
const filters: Prisma.Sql[] = [];
|
const filters: Prisma.Sql[] = [];
|
||||||
if (domain) filters.push(Prisma.sql`t.domain = ${domain}`);
|
if (domain) filters.push(Prisma.sql`t.domain = ${domain}`);
|
||||||
const txFilter = filters.length
|
const txFilter = filters.length
|
||||||
@@ -474,7 +546,7 @@ export class BillingService {
|
|||||||
FROM customers c
|
FROM customers c
|
||||||
JOIN transactions t ON t.customerId = c.id
|
JOIN transactions t ON t.customerId = c.id
|
||||||
${BALANCE_FLOOR_JOIN}
|
${BALANCE_FLOOR_JOIN}
|
||||||
WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${nameFilter} ${txFilter}
|
WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${scope} ${nameFilter} ${txFilter}
|
||||||
GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state
|
GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state
|
||||||
${having}
|
${having}
|
||||||
${orderBy}
|
${orderBy}
|
||||||
@@ -490,7 +562,7 @@ export class BillingService {
|
|||||||
-- Must match the page query's filters exactly, or the total disagrees
|
-- Must match the page query's filters exactly, or the total disagrees
|
||||||
-- with the rows. (The void exclusion was missing here before the
|
-- with the rows. (The void exclusion was missing here before the
|
||||||
-- outstanding work; a voided-only customer inflated the count.)
|
-- outstanding work; a voided-only customer inflated the count.)
|
||||||
WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${nameFilter} ${txFilter}
|
WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${scope} ${nameFilter} ${txFilter}
|
||||||
GROUP BY c.id
|
GROUP BY c.id
|
||||||
${having}
|
${having}
|
||||||
) x
|
) x
|
||||||
@@ -537,11 +609,22 @@ export class BillingService {
|
|||||||
* Two different questions live here and they use different row sets.
|
* Two different questions live here and they use different row sets.
|
||||||
* `movements`, `ledgerCustomers`, `crossLineCustomers` and the date range are
|
* `movements`, `ledgerCustomers`, `crossLineCustomers` and the date range are
|
||||||
* INVENTORY — what is stored — and count everything not voided. Everything
|
* INVENTORY — what is stored — and count everything not voided. Everything
|
||||||
* under `byCurrency` / `byDomain` is a BALANCE, so it applies NOT_SUPERSEDED
|
* under `byCurrency` / `byDomain` is a BALANCE, so it takes the same scope
|
||||||
* and drops rows an opening balance already accounts for. The four aggregates
|
* `balances()` takes — the opening-balance floor, the cash journal and the
|
||||||
* moved from Prisma groupBy to raw SQL to express that join; groupBy cannot.
|
* archive window — and the book has to agree with the worklist that sits
|
||||||
|
* under it. The four aggregates moved from Prisma groupBy to raw SQL to
|
||||||
|
* express that join; groupBy cannot.
|
||||||
|
*
|
||||||
|
* KNOWN DIVERGENCE, left deliberately: these three do not drop outstanding
|
||||||
|
* rows, while `balances()` does. Reconciling them moves the book by about
|
||||||
|
* 1.95M MXN and turns on whether an unfunded charge is owed by the customer,
|
||||||
|
* which is the client's call and not settled yet.
|
||||||
*/
|
*/
|
||||||
async stats() {
|
async stats() {
|
||||||
|
const scope = Prisma.sql`AND ${NOT_CASH_JOURNAL} AND ${archiveIsHistorySql(
|
||||||
|
currentYearStart(),
|
||||||
|
)}`;
|
||||||
|
|
||||||
const [movements, ledgerCustomers] = await Promise.all([
|
const [movements, ledgerCustomers] = await Promise.all([
|
||||||
this.prisma.transaction.count({ where: NOT_VOIDED }),
|
this.prisma.transaction.count({ where: NOT_VOIDED }),
|
||||||
this.prisma.transaction
|
this.prisma.transaction
|
||||||
@@ -573,7 +656,7 @@ export class BillingService {
|
|||||||
SUM(t.amount > 0) AS creditCount
|
SUM(t.amount > 0) AS creditCount
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
${BALANCE_FLOOR_JOIN}
|
${BALANCE_FLOOR_JOIN}
|
||||||
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED}
|
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} ${scope}
|
||||||
GROUP BY t.currency
|
GROUP BY t.currency
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -589,7 +672,7 @@ export class BillingService {
|
|||||||
SUM(t.amount) AS net, COUNT(*) AS count
|
SUM(t.amount) AS net, COUNT(*) AS count
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
${BALANCE_FLOOR_JOIN}
|
${BALANCE_FLOOR_JOIN}
|
||||||
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED}
|
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} ${scope}
|
||||||
GROUP BY t.domain, t.currency
|
GROUP BY t.domain, t.currency
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -610,7 +693,7 @@ export class BillingService {
|
|||||||
SELECT t.customerId, t.currency, SUM(t.amount) AS bal
|
SELECT t.customerId, t.currency, SUM(t.amount) AS bal
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
${BALANCE_FLOOR_JOIN}
|
${BALANCE_FLOOR_JOIN}
|
||||||
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED}
|
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} ${scope}
|
||||||
GROUP BY t.customerId, t.currency
|
GROUP BY t.customerId, t.currency
|
||||||
) x
|
) x
|
||||||
GROUP BY currency
|
GROUP BY currency
|
||||||
@@ -784,6 +867,19 @@ export class BillingService {
|
|||||||
}
|
}
|
||||||
const isArchive = requested !== thisYear;
|
const isArchive = requested !== thisYear;
|
||||||
|
|
||||||
|
//
|
||||||
|
// Deliberately open-ended at the top. A period is a table in legacy, not a
|
||||||
|
// date range, so whatever the office filed in it belongs to it — including
|
||||||
|
// the future-dated rows the current ledger carries (it runs to 2028). An
|
||||||
|
// upper bound would hide them from every view, which is not what legacy did
|
||||||
|
// and not what the office has been reading.
|
||||||
|
//
|
||||||
|
// An archive needs no fold at all: it *is* the period, and its own Jan-1
|
||||||
|
// BALANCE FORWARD row is the carry, listed exactly as legacy listed it.
|
||||||
|
const yearStart = isArchive
|
||||||
|
? new Date(0)
|
||||||
|
: new Date(Date.UTC(requested, 0, 1));
|
||||||
|
|
||||||
// One customer, so the balance floor is a single date rather than the
|
// One customer, so the balance floor is a single date rather than the
|
||||||
// derived table the aggregate queries join. See NOT_SUPERSEDED: rows before
|
// derived table the aggregate queries join. See NOT_SUPERSEDED: rows before
|
||||||
// the opening balance are already inside it, and showing them would both
|
// the opening balance are already inside it, and showing them would both
|
||||||
@@ -819,26 +915,36 @@ export class BillingService {
|
|||||||
{ legacySourceTable: periodSourceTable(requested) }
|
{ legacySourceTable: periodSourceTable(requested) }
|
||||||
: {
|
: {
|
||||||
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||||
// NULL-safe exclusion. `notIn` alone compiles to SQL `NOT IN`, and
|
// An archive row belongs to this period only as history. Below
|
||||||
// `NULL NOT IN (...)` is NULL, not true — so every app-captured row
|
// the year start it is exactly what `opening` is for, and for the
|
||||||
// (which has no legacySourceTable) silently vanished from the
|
// one customer whose newest BALANCE FORWARD lives *inside* an
|
||||||
// statement while still showing in the movement browser. Rows the app
|
// archive it is the only carry there is — dropping it outright
|
||||||
// books must appear on the customer's statement, so the null case is
|
// understated NUMid 295 by his whole 2025 closing balance, 785.46.
|
||||||
// spelled out.
|
//
|
||||||
|
// At or above the year start it must go. The archives spill a
|
||||||
|
// couple of rows into the following January, and those are
|
||||||
|
// already inside the next year's BALANCE FORWARD (which is the
|
||||||
|
// sum of the whole archive), so listing them here would both
|
||||||
|
// double-count and file a closed year's row as current.
|
||||||
|
//
|
||||||
|
// Spelled as a positive OR because `NOT (col LIKE ... AND ...)`
|
||||||
|
// is NULL for an app-captured row, which would drop every one.
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
OR: [
|
OR: [
|
||||||
{ legacySourceTable: null },
|
{ legacySourceTable: null },
|
||||||
{
|
{
|
||||||
legacySourceTable: {
|
legacySourceTable: {
|
||||||
notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[],
|
|
||||||
// The archives have to go too, and the floor will not do
|
|
||||||
// it. A customer whose newest BALANCE FORWARD lives inside
|
|
||||||
// an archive floors at that archive's own Jan 1, so all 28
|
|
||||||
// of its rows clear it; and the archives spill rows into
|
|
||||||
// the following January, which clears any floor. Both put a
|
|
||||||
// closed year back into the current one.
|
|
||||||
not: { startsWith: PERIOD_TABLE_PREFIX },
|
not: { startsWith: PERIOD_TABLE_PREFIX },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{ transactionDate: { lt: yearStart } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// The cash receipt book is the ledger's own postings written a
|
||||||
|
// second time, so listing it here would show every counter
|
||||||
|
// payment twice and double the credit side.
|
||||||
|
notCashJournal(),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -867,19 +973,6 @@ export class BillingService {
|
|||||||
// opening balance), the earlier rows still have to be *counted* or every
|
// opening balance), the earlier rows still have to be *counted* or every
|
||||||
// balance below is wrong, so they are folded into `opening` rather than
|
// balance below is wrong, so they are folded into `opening` rather than
|
||||||
// listed. That is the same thing a BALANCE FORWARD row does, just computed.
|
// listed. That is the same thing a BALANCE FORWARD row does, just computed.
|
||||||
//
|
|
||||||
// Deliberately open-ended at the top. A period is a table in legacy, not a
|
|
||||||
// date range, so whatever the office filed in it belongs to it — including
|
|
||||||
// the future-dated rows the current ledger carries (it runs to 2028). An
|
|
||||||
// upper bound would hide them from every view, which is not what legacy did
|
|
||||||
// and not what the office has been reading.
|
|
||||||
//
|
|
||||||
// An archive needs no fold at all: it *is* the period, and its own Jan-1
|
|
||||||
// BALANCE FORWARD row is the carry, listed exactly as legacy listed it.
|
|
||||||
const yearStart = isArchive
|
|
||||||
? new Date(0)
|
|
||||||
: new Date(Date.UTC(requested, 0, 1));
|
|
||||||
|
|
||||||
const running = new Map<string, Prisma.Decimal>();
|
const running = new Map<string, Prisma.Decimal>();
|
||||||
/** Balance carried into `yearStart`, per currency. */
|
/** Balance carried into `yearStart`, per currency. */
|
||||||
const opening = new Map<string, Prisma.Decimal>();
|
const opening = new Map<string, Prisma.Decimal>();
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
|
import {
|
||||||
|
CASH_JOURNAL_SOURCE_DB,
|
||||||
|
CASH_JOURNAL_SOURCE_TABLES,
|
||||||
|
NOT_CASH_JOURNAL,
|
||||||
|
notCashJournal,
|
||||||
|
} from "./billing.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `EFECTIVO` is the office's paper receipt book, and every receipt in it is
|
||||||
|
* also posted to the utilities ledger as `C<folio>`. Both copies were imported
|
||||||
|
* into one `transactions` table, so a balance that reads the journal counts
|
||||||
|
* each counter payment twice — 1,094,347.78 MXN of phantom credit book-wide,
|
||||||
|
* and 3,500.00 of it on NUMid 10 alone.
|
||||||
|
*
|
||||||
|
* These fail silently in the worst way: the numbers stay plausible, they are
|
||||||
|
* just too generous to the customer. Two shapes of mistake are easy to make
|
||||||
|
* here and both are covered below — dropping the database qualifier (which
|
||||||
|
* erases the insurance line's only ledger) and writing the exclusion as a bare
|
||||||
|
* `NOT IN` (which erases every app-captured row).
|
||||||
|
*/
|
||||||
|
describe("cash journal exclusion", () => {
|
||||||
|
describe("raw SQL form", () => {
|
||||||
|
it("binds the source database rather than interpolating it", () => {
|
||||||
|
expect(NOT_CASH_JOURNAL.values).toContain(CASH_JOURNAL_SOURCE_DB);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("qualifies the table names with the database they came from", () => {
|
||||||
|
// `SEGUROS 16_be` has its own EFECTIVO and it is the insurance line's
|
||||||
|
// ONLY ledger — nothing posts it anywhere else. Matching on the table
|
||||||
|
// name alone erases 55,444.95 USD and 63,957.78 MXN across 102 customers.
|
||||||
|
expect(NOT_CASH_JOURNAL.sql).toContain("t.legacySourceDb <>");
|
||||||
|
expect(NOT_CASH_JOURNAL.values).toContain(CASH_JOURNAL_SOURCE_DB);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spells both null cases out instead of relying on NOT IN", () => {
|
||||||
|
// `NULL NOT IN (...)` is NULL, not true. Without these branches every
|
||||||
|
// app-captured row — the ones staff key in by hand — drops out of the
|
||||||
|
// balance while still showing in the movement browser.
|
||||||
|
expect(NOT_CASH_JOURNAL.sql).toContain("t.legacySourceDb IS NULL");
|
||||||
|
expect(NOT_CASH_JOURNAL.sql).toContain("t.legacySourceTable IS NULL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers the whole cash family, not just EFECTIVO", () => {
|
||||||
|
for (const table of CASH_JOURNAL_SOURCE_TABLES) {
|
||||||
|
expect(NOT_CASH_JOURNAL.values).toContain(table);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a single parenthesised term, safe to AND into a WHERE clause", () => {
|
||||||
|
// It is composed as `... AND ${NOT_CASH_JOURNAL} AND ...`. An unbracketed
|
||||||
|
// OR chain would swallow every condition after it and silently widen the
|
||||||
|
// whole query to the entire table.
|
||||||
|
const sql = NOT_CASH_JOURNAL.sql.trim();
|
||||||
|
expect(sql.startsWith("(")).toBe(true);
|
||||||
|
expect(sql.endsWith(")")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Prisma form", () => {
|
||||||
|
it("matches the raw form's terms so the two cannot drift apart", () => {
|
||||||
|
const branches = notCashJournal().OR as Prisma.TransactionWhereInput[];
|
||||||
|
expect(branches).toEqual([
|
||||||
|
{ legacySourceDb: null },
|
||||||
|
{ legacySourceDb: { not: CASH_JOURNAL_SOURCE_DB } },
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{ legacySourceTable: { notIn: [...CASH_JOURNAL_SOURCE_TABLES] } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a fresh object each call", () => {
|
||||||
|
// It is spread into `AND: [...]` arrays that Prisma may mutate; a shared
|
||||||
|
// singleton would leak one query's filters into the next.
|
||||||
|
expect(notCashJournal()).not.toBe(notCashJournal());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -60,24 +60,25 @@ describe("customer file ledger card", () => {
|
|||||||
expect(groupBy.mock.calls[0][0].where).not.toHaveProperty("transactionDate");
|
expect(groupBy.mock.calls[0][0].where).not.toHaveProperty("transactionDate");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("excludes imported periods from the totals", async () => {
|
it("counts an archive as history but never as current", async () => {
|
||||||
// The floor alone is not enough: it does not exist for the floorless, and
|
// The floor alone is not enough: a customer floored by an archive clears
|
||||||
// archives carry rows dated past their own period that clear it.
|
// it with every row of that archive, and the rows archives spill into the
|
||||||
|
// following January clear any floor. But excluding archives outright is
|
||||||
|
// wrong too — below the year start they are the only carry a
|
||||||
|
// floored-by-archive customer has (NUMid 295, 785.46).
|
||||||
const { service, groupBy } = serviceWith(new Date("2026-01-01T00:00:00Z"));
|
const { service, groupBy } = serviceWith(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
|
||||||
await service.detail("c1");
|
await service.detail("c1");
|
||||||
|
|
||||||
const and = groupBy.mock.calls[0][0].where.AND;
|
const and = groupBy.mock.calls[0][0].where.AND;
|
||||||
expect(and).toEqual(
|
const rule = and.find((c: { OR?: unknown[] }) =>
|
||||||
expect.arrayContaining([
|
JSON.stringify(c).includes("datos2@"),
|
||||||
{
|
);
|
||||||
OR: [
|
expect(rule.OR).toEqual([
|
||||||
{ legacySourceTable: null },
|
{ legacySourceTable: null },
|
||||||
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
||||||
],
|
{ transactionDate: { lt: expect.any(Date) } },
|
||||||
},
|
]);
|
||||||
]),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the cash-source exclusion so it reads like the statement", async () => {
|
it("keeps the cash-source exclusion so it reads like the statement", async () => {
|
||||||
@@ -110,11 +111,12 @@ describe("customer file ledger card", () => {
|
|||||||
await service.detail("c1");
|
await service.detail("c1");
|
||||||
|
|
||||||
const include = prisma.customer.findUnique.mock.calls[0][0].include;
|
const include = prisma.customer.findUnique.mock.calls[0][0].include;
|
||||||
expect(include.transactions.where).toMatchObject({
|
expect(include.transactions.where.OR).toEqual([
|
||||||
OR: [
|
|
||||||
{ legacySourceTable: null },
|
{ legacySourceTable: null },
|
||||||
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
||||||
],
|
// Nothing below yearStart reaches this list, so the third branch never
|
||||||
});
|
// admits an archive row here — it is carried for one shared rule.
|
||||||
|
{ transactionDate: { lt: expect.any(Date) } },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,27 +5,33 @@ import { CreateCustomerDto } from "./create-customer.dto";
|
|||||||
import { UpdateCustomerDto } from "./update-customer.dto";
|
import { UpdateCustomerDto } from "./update-customer.dto";
|
||||||
import {
|
import {
|
||||||
BALANCE_FORWARD_TYPE,
|
BALANCE_FORWARD_TYPE,
|
||||||
|
notCashJournal,
|
||||||
PERIOD_TABLE_PREFIX,
|
PERIOD_TABLE_PREFIX,
|
||||||
STATEMENT_EXCLUDED_SOURCE_TABLES,
|
|
||||||
} from "../billing/billing.service";
|
} from "../billing/billing.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keeps imported prior periods out of a query, NULL-safely.
|
* Keeps an imported prior period out of the *current* period, NULL-safely.
|
||||||
*
|
*
|
||||||
* A closed year is imported as its own tagged copy of that year's ledger
|
* A closed year is imported as its own tagged copy (`datos2@2025`). Below the
|
||||||
* (`datos2@2025`) and the BALANCE FORWARD rows above it already contain every
|
* year start it is history and counts — for the one customer whose newest
|
||||||
* peso of it. Anything summing a customer's history has to leave the archives
|
* BALANCE FORWARD lives inside an archive it is the only carry there is, and
|
||||||
* out or it counts each closed year twice — see the floor comment below.
|
* dropping it understated NUMid 295 by his entire 2025 closing balance. At or
|
||||||
|
* above the year start it must go: the archives spill a couple of rows into the
|
||||||
|
* following January, and those already sit inside the next year's BALANCE
|
||||||
|
* FORWARD, which is the sum of the whole archive.
|
||||||
*
|
*
|
||||||
* `NULL NOT LIKE '...'` is NULL rather than true, so app-captured rows (which
|
* Spelled as a positive OR because `NOT (col LIKE ... AND ...)` evaluates to
|
||||||
* carry no legacySourceTable) need the null branch spelled out or they vanish.
|
* NULL for an app-captured row (no legacySourceTable), dropping every one.
|
||||||
*/
|
*/
|
||||||
const EXCLUDE_ARCHIVES: Prisma.TransactionWhereInput = {
|
const archiveIsHistory = (
|
||||||
|
yearStart: Date,
|
||||||
|
): Prisma.TransactionWhereInput => ({
|
||||||
OR: [
|
OR: [
|
||||||
{ legacySourceTable: null },
|
{ legacySourceTable: null },
|
||||||
{ legacySourceTable: { not: { startsWith: PERIOD_TABLE_PREFIX } } },
|
{ legacySourceTable: { not: { startsWith: PERIOD_TABLE_PREFIX } } },
|
||||||
|
{ transactionDate: { lt: yearStart } },
|
||||||
],
|
],
|
||||||
};
|
});
|
||||||
|
|
||||||
export interface ListParams {
|
export interface ListParams {
|
||||||
query?: string;
|
query?: string;
|
||||||
@@ -152,7 +158,15 @@ export class CustomersService {
|
|||||||
// in 2026, datos2@2025 two more — so a date test alone would surface
|
// in 2026, datos2@2025 two more — so a date test alone would surface
|
||||||
// a closed year's rows in the current year's list, duplicating the
|
// a closed year's rows in the current year's list, duplicating the
|
||||||
// live ledger's own copy of them for three customers.
|
// live ledger's own copy of them for three customers.
|
||||||
where: { transactionDate: { gte: yearStart }, ...EXCLUDE_ARCHIVES },
|
// Archives are kept out by tag, not by date. They are not cleanly
|
||||||
|
// bounded — datos2@2025 carries rows dated into 2026 — so a date test
|
||||||
|
// alone would surface a closed year's rows in the current year's
|
||||||
|
// list. Nothing below yearStart reaches this list anyway, so the
|
||||||
|
// window rule reduces to a plain exclusion here.
|
||||||
|
where: {
|
||||||
|
transactionDate: { gte: yearStart },
|
||||||
|
...archiveIsHistory(yearStart),
|
||||||
|
},
|
||||||
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||||
include: { type: true },
|
include: { type: true },
|
||||||
},
|
},
|
||||||
@@ -197,23 +211,10 @@ export class CustomersService {
|
|||||||
voidedAt: null,
|
voidedAt: null,
|
||||||
outstanding: false,
|
outstanding: false,
|
||||||
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||||
// The floor alone would leave the archives out for anyone who has an
|
// The floor alone does not settle the archives: a customer floored by
|
||||||
// opening balance, but 102 customers have none — for them there is no
|
// an archive clears it with every row of that archive, and the rows
|
||||||
// floor at all, and the archives' rows dated past their own period
|
// archives spill into the following January clear any floor.
|
||||||
// clear it even for the rest.
|
AND: [archiveIsHistory(yearStart), notCashJournal()],
|
||||||
AND: [
|
|
||||||
EXCLUDE_ARCHIVES,
|
|
||||||
{
|
|
||||||
OR: [
|
|
||||||
{ legacySourceTable: null },
|
|
||||||
{
|
|
||||||
legacySourceTable: {
|
|
||||||
notIn: [...STATEMENT_EXCLUDED_SOURCE_TABLES],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
|
|||||||
@@ -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 }));
|
||||||
|
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
import { Prisma } from "@jorgecuadros/database";
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
import {
|
import {
|
||||||
BALANCE_FORWARD_TYPE,
|
BALANCE_FORWARD_TYPE,
|
||||||
|
notCashJournal,
|
||||||
periodSourceTable,
|
periodSourceTable,
|
||||||
} from "../billing/billing.service";
|
} from "../billing/billing.service";
|
||||||
import {
|
import {
|
||||||
@@ -835,25 +836,28 @@ const edoCuentaDatos: ReportDef = {
|
|||||||
{ legacySourceTable: periodSourceTable(requestedYear) }
|
{ legacySourceTable: periodSourceTable(requestedYear) }
|
||||||
: {
|
: {
|
||||||
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||||
// NULL-safe: `NULL NOT IN (...)` is NULL, not true, so a bare `notIn`
|
// Archive rows count as history below the year start (that is
|
||||||
// drops every app-captured row (they have no legacySourceTable) — the
|
// what `opening` is for, and for a customer floored by an archive
|
||||||
// same defect this report's on-screen twin was fixed for.
|
// it is the only carry there is) and are dropped at or above it.
|
||||||
|
// Same rule as the on-screen twin — see BillingService.statement.
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
OR: [
|
OR: [
|
||||||
{ legacySourceTable: null },
|
{ legacySourceTable: null },
|
||||||
|
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
||||||
{
|
{
|
||||||
legacySourceTable: {
|
transactionDate: {
|
||||||
notIn: [
|
lt: new Date(Date.UTC(requestedYear, 0, 1)),
|
||||||
"EFECTIVO",
|
},
|
||||||
"EFECTIVO_BACKUP",
|
},
|
||||||
"EFECTIVO FM3",
|
|
||||||
"CHEQUE FM3",
|
|
||||||
"IVA 2015",
|
|
||||||
],
|
],
|
||||||
// Archives out of the current period too — the balance
|
|
||||||
// floor does not exclude them (see the on-screen twin).
|
|
||||||
not: { startsWith: "datos2@" },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
// The cash receipt book, which the ledger already carries as
|
||||||
|
// its own `C<folio>` postings. Taken from the shared helper
|
||||||
|
// rather than restated, so the printed statement and the screen
|
||||||
|
// cannot drift apart — and so this keeps the database
|
||||||
|
// qualifier that spares the insurance line's own EFECTIVO.
|
||||||
|
notCashJournal(),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@jorgecuadros/web",
|
"name": "@jorgecuadros/web",
|
||||||
"version": "1.0.24",
|
"version": "1.0.26",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 4500",
|
"dev": "next dev -p 4500",
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Delete unused images from the target host after a successful deploy.
|
||||||
|
*
|
||||||
|
* This exists because nothing else reclaims them. Every build.yml run pushes a
|
||||||
|
* new api + web image, every deploy pulls both onto the host, and the previous
|
||||||
|
* pair is left behind untagged-but-present forever. On galactus that reached
|
||||||
|
* 63 images / 83.85GB (79.26GB of it unused) and filled the 98GB root
|
||||||
|
* filesystem to 100% on 2026-08-20 — which surfaced as "re-import is broken",
|
||||||
|
* because the Operaciones REIMPORT job leads with a mysqldump that could no
|
||||||
|
* longer write its safety backup.
|
||||||
|
*
|
||||||
|
* Two things keep this from eating a live deployment:
|
||||||
|
*
|
||||||
|
* - Docker never prunes an image that a container references, running or
|
||||||
|
* stopped. The five images the prod stacks use are therefore untouchable
|
||||||
|
* for as long as their containers exist.
|
||||||
|
* - `until` gives a grace window on top of that, so a rollback target stays
|
||||||
|
* on disk instead of forcing a re-pull from the registry.
|
||||||
|
*
|
||||||
|
* TRAP: `until` filters on the image's CREATION time, not when the host pulled
|
||||||
|
* it. Rolling back to an old tag pulls an image that is already older than the
|
||||||
|
* window, so the grace period does NOT protect it — the running-container rule
|
||||||
|
* is what does. That is why this step must run AFTER the app stack is deployed
|
||||||
|
* and verified, never before.
|
||||||
|
*
|
||||||
|
* Required env:
|
||||||
|
* PORTAINER_URL, PORTAINER_API_KEY, PORTAINER_ENDPOINT_ID
|
||||||
|
* Optional:
|
||||||
|
* KEEP_HOURS grace window in hours (default 168 = 7 days)
|
||||||
|
*
|
||||||
|
* TLS: Portainer here is self-signed; the caller sets
|
||||||
|
* NODE_TLS_REJECT_UNAUTHORIZED=0 for this step.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function required(name) {
|
||||||
|
const v = process.env[name];
|
||||||
|
if (!v) {
|
||||||
|
console.error(`missing required env: ${name}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PORTAINER_URL = required("PORTAINER_URL").replace(/\/+$/, "");
|
||||||
|
const API_KEY = required("PORTAINER_API_KEY");
|
||||||
|
const ENDPOINT_ID = required("PORTAINER_ENDPOINT_ID");
|
||||||
|
const KEEP_HOURS = process.env.KEEP_HOURS || "168";
|
||||||
|
|
||||||
|
const DOCKER = `${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker`;
|
||||||
|
|
||||||
|
// `dangling: ["false"]` is what makes this `docker image prune -a` rather than
|
||||||
|
// the default, which only collects untagged layers. The tagged-but-superseded
|
||||||
|
// api/web images are the whole problem, and the default filter walks straight
|
||||||
|
// past them.
|
||||||
|
const FILTERS = JSON.stringify({
|
||||||
|
dangling: ["false"],
|
||||||
|
until: [`${KEEP_HOURS}h`],
|
||||||
|
});
|
||||||
|
|
||||||
|
function human(bytes) {
|
||||||
|
if (!bytes) return "0B";
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let i = 0;
|
||||||
|
let n = bytes;
|
||||||
|
while (n >= 1024 && i < units.length - 1) {
|
||||||
|
n /= 1024;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
return `${n.toFixed(i === 0 ? 0 : 2)}${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const url = `${DOCKER}/images/prune?filters=${encodeURIComponent(FILTERS)}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "X-API-Key": API_KEY },
|
||||||
|
});
|
||||||
|
const body = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`prune -> HTTP ${res.status} ${body.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let report;
|
||||||
|
try {
|
||||||
|
report = JSON.parse(body);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`prune returned non-JSON: ${body.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = report.ImagesDeleted ?? [];
|
||||||
|
const reclaimed = report.SpaceReclaimed ?? 0;
|
||||||
|
console.log(
|
||||||
|
`pruned images older than ${KEEP_HOURS}h and unused by any container`,
|
||||||
|
);
|
||||||
|
console.log(` entries removed : ${deleted.length}`);
|
||||||
|
console.log(` space reclaimed : ${human(reclaimed)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
// Non-fatal by contract: the step that calls this sets continue-on-error, so
|
||||||
|
// housekeeping never turns a good deploy red. Exit non-zero anyway so the
|
||||||
|
// failure is visible in the run rather than swallowed.
|
||||||
|
console.error(`::warning::image prune FAILED: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jorgecuadros-platform",
|
"name": "jorgecuadros-platform",
|
||||||
"version": "1.0.24",
|
"version": "1.0.26",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@jorgecuadros/database",
|
"name": "@jorgecuadros/database",
|
||||||
"version": "1.0.24",
|
"version": "1.0.26",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "generated/client/index.js",
|
"main": "generated/client/index.js",
|
||||||
"types": "generated/client/index.d.ts",
|
"types": "generated/client/index.d.ts",
|
||||||
|
|||||||
+157
-12
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user