Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7507f2370 | ||
|
|
2f9a9afc0d | ||
|
|
7e71a993d0 | ||
|
|
aa5867c8ea | ||
|
|
5549a1e0cf | ||
|
|
c6feae9522 | ||
|
|
93f817158e | ||
|
|
29ae9fa5bc | ||
|
|
9973488330 | ||
|
|
75dcbc11b8 | ||
|
|
9929a9a3ac |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@jorgecuadros/api",
|
"name": "@jorgecuadros/api",
|
||||||
"version": "1.0.23",
|
"version": "1.0.26",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
|
|||||||
@@ -115,13 +115,26 @@ describe("balance floor", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* statement() issues two findMany calls: first the period discovery (which
|
||||||
|
* years this customer has an archive for), then the statement rows. Select
|
||||||
|
* the rows query by its shape so adding another lookup later moves nothing
|
||||||
|
* here — the previous version indexed call 0 and broke the moment period
|
||||||
|
* support landed.
|
||||||
|
*/
|
||||||
|
function rowsQuery(findMany: jest.Mock) {
|
||||||
|
const call = findMany.mock.calls.find((c) => c[0]?.orderBy);
|
||||||
|
if (!call) throw new Error("statement() issued no ordered rows query");
|
||||||
|
return call[0];
|
||||||
|
}
|
||||||
|
|
||||||
it("bounds the statement at the floor, inclusive", async () => {
|
it("bounds the statement at the floor, inclusive", async () => {
|
||||||
const floor = new Date("2026-01-01T00:00:00Z");
|
const floor = new Date("2026-01-01T00:00:00Z");
|
||||||
const { service, findMany } = serviceWith(floor);
|
const { service, findMany } = serviceWith(floor);
|
||||||
|
|
||||||
await service.statement("c1");
|
await service.statement("c1");
|
||||||
|
|
||||||
expect(findMany.mock.calls[0][0].where).toMatchObject({
|
expect(rowsQuery(findMany).where).toMatchObject({
|
||||||
customerId: "c1",
|
customerId: "c1",
|
||||||
transactionDate: { gte: floor },
|
transactionDate: { gte: floor },
|
||||||
});
|
});
|
||||||
@@ -132,23 +145,44 @@ describe("balance floor", () => {
|
|||||||
|
|
||||||
await service.statement("c1");
|
await service.statement("c1");
|
||||||
|
|
||||||
expect(findMany.mock.calls[0][0].where).not.toHaveProperty(
|
expect(rowsQuery(findMany).where).not.toHaveProperty("transactionDate");
|
||||||
"transactionDate",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 = findMany.mock.calls[0][0].where;
|
const where = rowsQuery(findMany).where;
|
||||||
expect(where.OR).toEqual([
|
// Imported periods are windowed rather than excluded: history below the
|
||||||
{ legacySourceTable: null },
|
// year start (the only carry a floored-by-archive customer has), never
|
||||||
{ legacySourceTable: { notIn: expect.arrayContaining(["EFECTIVO"]) } },
|
// 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: {
|
||||||
|
notIn: expect.arrayContaining(["EFECTIVO"]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -119,10 +119,23 @@ export class BillingController {
|
|||||||
return this.billing.byCheck(n);
|
return this.billing.byCheck(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One customer's full statement across both business lines. */
|
/**
|
||||||
|
* One customer's statement across both business lines, for one period.
|
||||||
|
*
|
||||||
|
* `year` omitted means the current one. Any earlier year is served from its
|
||||||
|
* imported archive; the response carries `availableYears` so the caller can
|
||||||
|
* offer only the periods this customer actually has.
|
||||||
|
*/
|
||||||
@Get("customers/:id")
|
@Get("customers/:id")
|
||||||
statement(@Param("id") id: string) {
|
statement(@Param("id") id: string, @Query("year") year?: string) {
|
||||||
return this.billing.statement(id);
|
let parsed: number | undefined;
|
||||||
|
if (year !== undefined && year !== "") {
|
||||||
|
parsed = Number(year);
|
||||||
|
if (!Number.isInteger(parsed)) {
|
||||||
|
throw new BadRequestException("year debe ser un año de cuatro dígitos");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.billing.statement(id, parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cross-customer movement browser. */
|
/** Cross-customer movement browser. */
|
||||||
|
|||||||
@@ -210,18 +210,47 @@ 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.
|
* `legacySourceTable` of an imported prior period.
|
||||||
*
|
*
|
||||||
* The legacy portal's `datosfreak` table was materialized from DATOS2 only
|
* A closed year arrives as its own Access snapshot and is tagged rather than
|
||||||
* (`objects.json:1358`), so the customer's "current balance" never saw
|
* dated (see migration/transform_transactions.py). The tag is what a period
|
||||||
* EFECTIVO / EFECTIVO FM3 / CHEQUE FM3 / EFECTIVO_BACKUP cash receipts, nor
|
* view filters on: the archives are not cleanly date-bounded — 2025 carries
|
||||||
* the IVA 2015 snapshot. The unified `transactions` table has all of them, so
|
* rows dated into 2026 — and legacy did not filter by date either, it selected
|
||||||
* the statement must drop them to match the legacy number the customer has
|
* `FROM \`2025\``. Filtering on provenance reproduces the legacy period exactly.
|
||||||
* 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).
|
|
||||||
*/
|
*/
|
||||||
const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [
|
export const periodSourceTable = (year: number) => `datos2@${year}`;
|
||||||
|
|
||||||
|
/** Matches any imported period tag, for discovering which years a customer has. */
|
||||||
|
export const PERIOD_TABLE_PREFIX = "datos2@";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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",
|
||||||
@@ -229,6 +258,53 @@ 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) {}
|
||||||
@@ -398,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
|
||||||
@@ -460,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}
|
||||||
@@ -476,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
|
||||||
@@ -523,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
|
||||||
@@ -559,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
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -575,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
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -596,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
|
||||||
@@ -701,16 +798,22 @@ export class BillingService {
|
|||||||
/**
|
/**
|
||||||
* One customer's statement across both business lines.
|
* One customer's statement across both business lines.
|
||||||
*
|
*
|
||||||
* Scoped to the current calendar year and listed oldest-first, matching the
|
* Scoped to one calendar year and listed oldest-first, matching the legacy
|
||||||
* legacy EDO CUENTA report the office has printed for years: an opening
|
* EDO CUENTA report the office has printed for years: an opening balance at
|
||||||
* balance at the top, then the year's movements in the order they happened.
|
* the top, then the year's movements in the order they happened.
|
||||||
|
*
|
||||||
|
* `year` selects the period. The current year is read from the live tables;
|
||||||
|
* any earlier year is read from its imported archive, which legacy kept as a
|
||||||
|
* separate table and this reads by its `datos2@YYYY` tag. `availableYears`
|
||||||
|
* reports which periods this customer actually has, so a caller never offers
|
||||||
|
* a year that would render empty.
|
||||||
*
|
*
|
||||||
* Returns the *whole* year rather than a page of it: the heaviest customer
|
* Returns the *whole* year rather than a page of it: the heaviest customer
|
||||||
* carries 365 movements (mean 26), and a running balance is meaningless if
|
* carries 365 movements (mean 26), and a running balance is meaningless if
|
||||||
* the client only holds a slice. The running balance is accumulated per
|
* the client only holds a slice. The running balance is accumulated per
|
||||||
* currency in chronological order, with each row's balance-after attached.
|
* currency in chronological order, with each row's balance-after attached.
|
||||||
*/
|
*/
|
||||||
async statement(customerId: string) {
|
async statement(customerId: string, year?: number) {
|
||||||
const customer = await this.prisma.customer.findUnique({
|
const customer = await this.prisma.customer.findUnique({
|
||||||
where: { id: customerId },
|
where: { id: customerId },
|
||||||
select: {
|
select: {
|
||||||
@@ -734,6 +837,49 @@ export class BillingService {
|
|||||||
throw new NotFoundException(`Customer ${customerId} not found`);
|
throw new NotFoundException(`Customer ${customerId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Which periods this customer has. The current year is always offered —
|
||||||
|
// it is the live ledger even when empty — and each imported archive adds
|
||||||
|
// the year it holds.
|
||||||
|
const archives = await this.prisma.transaction.findMany({
|
||||||
|
where: {
|
||||||
|
customerId,
|
||||||
|
voidedAt: null,
|
||||||
|
legacySourceTable: { startsWith: PERIOD_TABLE_PREFIX },
|
||||||
|
},
|
||||||
|
distinct: ["legacySourceTable"],
|
||||||
|
select: { legacySourceTable: true },
|
||||||
|
});
|
||||||
|
const thisYear = new Date().getUTCFullYear();
|
||||||
|
const archiveYears = archives
|
||||||
|
.map((a) => Number(a.legacySourceTable?.slice(PERIOD_TABLE_PREFIX.length)))
|
||||||
|
.filter((y) => Number.isInteger(y) && y < thisYear);
|
||||||
|
const availableYears = [...new Set([thisYear, ...archiveYears])].sort(
|
||||||
|
(a, b) => b - a,
|
||||||
|
);
|
||||||
|
|
||||||
|
// An unknown year would silently render as the current one, which reads as
|
||||||
|
// "this customer had no activity in 2019" rather than "there is no 2019".
|
||||||
|
const requested = year ?? thisYear;
|
||||||
|
if (!availableYears.includes(requested)) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`El cliente no tiene movimientos del periodo ${requested}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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
|
||||||
@@ -759,21 +905,48 @@ export class BillingService {
|
|||||||
const rows = await this.prisma.transaction.findMany({
|
const rows = await this.prisma.transaction.findMany({
|
||||||
where: {
|
where: {
|
||||||
customerId,
|
customerId,
|
||||||
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
...(isArchive
|
||||||
// NULL-safe exclusion. `notIn` alone compiles to SQL `NOT IN`, and
|
? // An archive is already exactly one period's ledger, so the tag is
|
||||||
// `NULL NOT IN (...)` is NULL, not true — so every app-captured row
|
// the whole filter. The balance floor is deliberately NOT applied:
|
||||||
// (which has no legacySourceTable) silently vanished from the
|
// it exists to stop a later opening balance double-counting the
|
||||||
// statement while still showing in the movement browser. Rows the app
|
// history it summarizes, and here that history is the thing being
|
||||||
// books must appear on the customer's statement, so the null case is
|
// asked for. The exclusion list is moot too — an archive holds only
|
||||||
// spelled out.
|
// DATOS2 rows, which is what legacy's year table held.
|
||||||
OR: [
|
{ legacySourceTable: periodSourceTable(requested) }
|
||||||
{ legacySourceTable: null },
|
: {
|
||||||
{
|
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||||
legacySourceTable: {
|
// An archive row belongs to this period only as history. Below
|
||||||
notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[],
|
// the year start it is exactly what `opening` is for, and for the
|
||||||
},
|
// one customer whose newest BALANCE FORWARD lives *inside* an
|
||||||
},
|
// archive it is the only carry there is — dropping it outright
|
||||||
],
|
// understated NUMid 295 by his whole 2025 closing balance, 785.46.
|
||||||
|
//
|
||||||
|
// 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: [
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{
|
||||||
|
legacySourceTable: {
|
||||||
|
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(),
|
||||||
|
],
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||||
select: {
|
select: {
|
||||||
@@ -800,8 +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.
|
||||||
const yearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 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>();
|
||||||
@@ -976,7 +1147,8 @@ export class BillingService {
|
|||||||
propertyCount: customer._count.properties,
|
propertyCount: customer._count.properties,
|
||||||
policyCount: customer._count.policies,
|
policyCount: customer._count.policies,
|
||||||
},
|
},
|
||||||
year: yearStart.getUTCFullYear(),
|
year: requested,
|
||||||
|
availableYears,
|
||||||
summary: [...perCurrency.values()].map((c) => {
|
summary: [...perCurrency.values()].map((c) => {
|
||||||
const open = opening.get(c.currency) ?? new Prisma.Decimal(0);
|
const open = opening.get(c.currency) ?? new Prisma.Decimal(0);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { periodSourceTable } from "./billing.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A closed year is imported as its own tagged set of rows rather than being
|
||||||
|
* identified by date. The tag is written by migration/transform_transactions.py
|
||||||
|
* and read by BillingService.statement, the edo-cuenta-datos report, and the
|
||||||
|
* PHP portal — three places that must agree on the exact string.
|
||||||
|
*/
|
||||||
|
describe("periodSourceTable", () => {
|
||||||
|
it("names the archive the migration writes", () => {
|
||||||
|
expect(periodSourceTable(2025)).toBe("datos2@2025");
|
||||||
|
expect(periodSourceTable(2024)).toBe("datos2@2024");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays distinct from the live ledger's own table", () => {
|
||||||
|
// The live table is plain `datos2`. legacyId is a positional ordinal that
|
||||||
|
// restarts at 0 in every archive, so a shared name would collide with the
|
||||||
|
// current year row-for-row on the unique key.
|
||||||
|
expect(periodSourceTable(2025)).not.toBe("datos2");
|
||||||
|
expect(periodSourceTable(2025).startsWith("datos2@")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not matched by the statement's cash-source exclusion list", () => {
|
||||||
|
// STATEMENT_EXCLUDED_SOURCE_TABLES drops the EFECTIVO family to reproduce
|
||||||
|
// legacy's DATOS2-only datosfreak. An archive holds DATOS2 rows, so it must
|
||||||
|
// survive that filter or a prior year renders empty.
|
||||||
|
const excluded = [
|
||||||
|
"EFECTIVO",
|
||||||
|
"EFECTIVO_BACKUP",
|
||||||
|
"EFECTIVO FM3",
|
||||||
|
"CHEQUE FM3",
|
||||||
|
"IVA 2015",
|
||||||
|
];
|
||||||
|
expect(excluded).not.toContain(periodSourceTable(2025));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { CustomersService } from "./customers.service";
|
||||||
|
import { BALANCE_FORWARD_TYPE } from "../billing/billing.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The /clientes/:id ledger card is titled "Estado de cuenta" and links straight
|
||||||
|
* to the statement, so its per-line totals must be the statement's numbers.
|
||||||
|
*
|
||||||
|
* They were a raw lifetime sum — no floor, no source exclusion — which
|
||||||
|
* double-counted the pre-cutover history each BALANCE FORWARD row absorbs.
|
||||||
|
* Importing prior periods made it visibly worse: every closed year is now held
|
||||||
|
* a second time as its own tagged copy, so an unfloored sum adds each one on
|
||||||
|
* top of the opening balance that already contains it.
|
||||||
|
*/
|
||||||
|
describe("customer file ledger card", () => {
|
||||||
|
function serviceWith(floor: Date | null) {
|
||||||
|
const groupBy = jest.fn().mockResolvedValue([]);
|
||||||
|
const prisma = {
|
||||||
|
customer: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue({ id: "c1", transactions: [] }),
|
||||||
|
},
|
||||||
|
transaction: {
|
||||||
|
findFirst: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(floor ? { transactionDate: floor } : null),
|
||||||
|
groupBy,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
service: new CustomersService(prisma as never),
|
||||||
|
prisma,
|
||||||
|
groupBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("takes the same balance floor the statement takes", async () => {
|
||||||
|
const floor = new Date("2026-01-01T00:00:00Z");
|
||||||
|
const { service, prisma, groupBy } = serviceWith(floor);
|
||||||
|
|
||||||
|
await service.detail("c1");
|
||||||
|
|
||||||
|
expect(prisma.transaction.findFirst).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
type: { nameEn: BALANCE_FORWARD_TYPE },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(groupBy.mock.calls[0][0].where).toMatchObject({
|
||||||
|
transactionDate: { gte: floor },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies no floor when the customer never had an opening balance", async () => {
|
||||||
|
// 102 customers have no BALANCE FORWARD row at all. Inventing a floor for
|
||||||
|
// them would hide their whole ledger.
|
||||||
|
const { service, groupBy } = serviceWith(null);
|
||||||
|
|
||||||
|
await service.detail("c1");
|
||||||
|
|
||||||
|
expect(groupBy.mock.calls[0][0].where).not.toHaveProperty("transactionDate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts an archive as history but never as current", async () => {
|
||||||
|
// The floor alone is not enough: a customer floored by an archive clears
|
||||||
|
// it with every row of that archive, and the rows archives spill into the
|
||||||
|
// following January clear any floor. 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"));
|
||||||
|
|
||||||
|
await service.detail("c1");
|
||||||
|
|
||||||
|
const and = groupBy.mock.calls[0][0].where.AND;
|
||||||
|
const rule = and.find((c: { OR?: unknown[] }) =>
|
||||||
|
JSON.stringify(c).includes("datos2@"),
|
||||||
|
);
|
||||||
|
expect(rule.OR).toEqual([
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
||||||
|
{ transactionDate: { lt: expect.any(Date) } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the cash-source exclusion so it reads like the statement", async () => {
|
||||||
|
const { service, groupBy } = serviceWith(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
|
||||||
|
await service.detail("c1");
|
||||||
|
|
||||||
|
const and = groupBy.mock.calls[0][0].where.AND;
|
||||||
|
const sourceRule = and.find((c: { OR?: unknown[] }) =>
|
||||||
|
JSON.stringify(c).includes("EFECTIVO"),
|
||||||
|
);
|
||||||
|
expect(sourceRule).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops outstanding rows, as every balance does", async () => {
|
||||||
|
const { service, groupBy } = serviceWith(null);
|
||||||
|
|
||||||
|
await service.detail("c1");
|
||||||
|
|
||||||
|
expect(groupBy.mock.calls[0][0].where).toMatchObject({
|
||||||
|
outstanding: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps archives out of the year's movement list too", async () => {
|
||||||
|
// datos2@2024 carries rows dated into 2026; a date test alone would show
|
||||||
|
// them as current-year movements next to the live ledger's own copy.
|
||||||
|
const { service, prisma } = serviceWith(null);
|
||||||
|
|
||||||
|
await service.detail("c1");
|
||||||
|
|
||||||
|
const include = prisma.customer.findUnique.mock.calls[0][0].include;
|
||||||
|
expect(include.transactions.where.OR).toEqual([
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{ 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) } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,35 @@ import { Prisma } from "@jorgecuadros/database";
|
|||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { CreateCustomerDto } from "./create-customer.dto";
|
import { CreateCustomerDto } from "./create-customer.dto";
|
||||||
import { UpdateCustomerDto } from "./update-customer.dto";
|
import { UpdateCustomerDto } from "./update-customer.dto";
|
||||||
|
import {
|
||||||
|
BALANCE_FORWARD_TYPE,
|
||||||
|
notCashJournal,
|
||||||
|
PERIOD_TABLE_PREFIX,
|
||||||
|
} from "../billing/billing.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps an imported prior period out of the *current* period, NULL-safely.
|
||||||
|
*
|
||||||
|
* A closed year is imported as its own tagged copy (`datos2@2025`). Below the
|
||||||
|
* year start it is history and counts — for the one customer whose newest
|
||||||
|
* BALANCE FORWARD lives inside an archive it is the only carry there is, and
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* Spelled as a positive OR because `NOT (col LIKE ... AND ...)` evaluates to
|
||||||
|
* NULL for an app-captured row (no legacySourceTable), dropping every one.
|
||||||
|
*/
|
||||||
|
const archiveIsHistory = (
|
||||||
|
yearStart: Date,
|
||||||
|
): Prisma.TransactionWhereInput => ({
|
||||||
|
OR: [
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{ legacySourceTable: { not: { startsWith: PERIOD_TABLE_PREFIX } } },
|
||||||
|
{ transactionDate: { lt: yearStart } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
export interface ListParams {
|
export interface ListParams {
|
||||||
query?: string;
|
query?: string;
|
||||||
@@ -96,6 +125,13 @@ export class CustomersService {
|
|||||||
|
|
||||||
/** Full unified customer view: identity + both business lines + ledger. */
|
/** Full unified customer view: identity + both business lines + ledger. */
|
||||||
async detail(id: string) {
|
async detail(id: string) {
|
||||||
|
// The movement list on the customer file is the same statement the office
|
||||||
|
// prints, so it follows the same rule as BillingService.statement: this
|
||||||
|
// calendar year, oldest-first. No `take` any more — the cap used to hide
|
||||||
|
// the end of a busy customer's year once the order flipped, and a single
|
||||||
|
// year is small (365 rows for the heaviest customer in the book).
|
||||||
|
const yearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1));
|
||||||
|
|
||||||
const customer = await this.prisma.customer.findUnique({
|
const customer = await this.prisma.customer.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
@@ -117,8 +153,21 @@ export class CustomersService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
transactions: {
|
transactions: {
|
||||||
orderBy: { transactionDate: "desc" },
|
// Archives are excluded by tag, not by date. They are not cleanly
|
||||||
take: 100,
|
// bounded — datos2@2024 carries rows dated 2022, 2023, 2025 and one
|
||||||
|
// 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
|
||||||
|
// live ledger's own copy of them for three customers.
|
||||||
|
// 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" }],
|
||||||
include: { type: true },
|
include: { type: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -130,16 +179,51 @@ export class CustomersService {
|
|||||||
|
|
||||||
// Ledger totals per domain + currency (the "one statement across both
|
// Ledger totals per domain + currency (the "one statement across both
|
||||||
// business lines" payoff), computed in the DB rather than in JS.
|
// business lines" payoff), computed in the DB rather than in JS.
|
||||||
|
//
|
||||||
|
// These have to answer the same question BillingService.statement answers,
|
||||||
|
// because this card is titled "Estado de cuenta" and links straight to it —
|
||||||
|
// two screens quoting one customer two different balances is worse than
|
||||||
|
// either number alone. So it takes the same three rules the statement uses:
|
||||||
|
// the balance floor, the cash-source exclusion, and dropping outstanding
|
||||||
|
// rows the office has not paid yet.
|
||||||
|
//
|
||||||
|
// Without the floor these were a raw lifetime sum, double-counting the
|
||||||
|
// pre-cutover history each BALANCE FORWARD row already absorbs. Importing
|
||||||
|
// prior periods made that visibly worse: for NUMid 501 the tiles read
|
||||||
|
// -7,119.29 before the archives landed and -15,270.59 after, against a true
|
||||||
|
// -10,715.29 — the difference being exactly the 2024 and 2025 closing
|
||||||
|
// balances, added a second time on top of the opening row that contains
|
||||||
|
// them.
|
||||||
|
const floor = await this.prisma.transaction.findFirst({
|
||||||
|
where: {
|
||||||
|
customerId: id,
|
||||||
|
voidedAt: null,
|
||||||
|
type: { nameEn: BALANCE_FORWARD_TYPE },
|
||||||
|
},
|
||||||
|
orderBy: { transactionDate: "desc" },
|
||||||
|
select: { transactionDate: true },
|
||||||
|
});
|
||||||
|
|
||||||
const summary = await this.prisma.transaction.groupBy({
|
const summary = await this.prisma.transaction.groupBy({
|
||||||
by: ["domain", "currency"],
|
by: ["domain", "currency"],
|
||||||
// Exclude voided rows so the per-domain balance matches the statement.
|
where: {
|
||||||
where: { customerId: id, voidedAt: null },
|
customerId: id,
|
||||||
|
voidedAt: null,
|
||||||
|
outstanding: false,
|
||||||
|
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||||
|
// The floor alone does not settle the archives: a customer floored by
|
||||||
|
// an archive clears it with every row of that archive, and the rows
|
||||||
|
// archives spill into the following January clear any floor.
|
||||||
|
AND: [archiveIsHistory(yearStart), notCashJournal()],
|
||||||
|
},
|
||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...customer,
|
...customer,
|
||||||
|
/** Calendar year the movement list covers. */
|
||||||
|
transactionYear: yearStart.getUTCFullYear(),
|
||||||
transactionSummary: summary.map((s) => ({
|
transactionSummary: summary.map((s) => ({
|
||||||
domain: s.domain,
|
domain: s.domain,
|
||||||
currency: s.currency,
|
currency: s.currency,
|
||||||
|
|||||||
@@ -31,6 +31,29 @@ export const INGEST_FILES = [
|
|||||||
] as const;
|
] as const;
|
||||||
export type IngestName = (typeof INGEST_FILES)[number];
|
export type IngestName = (typeof INGEST_FILES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A prior-period archive: one Access snapshot per closed year, named for the
|
||||||
|
* period it holds. `2025.accdb` is UTILITIES as it stood when 2025 was cut.
|
||||||
|
*
|
||||||
|
* The filename is the entire declaration of the period — nothing inside the
|
||||||
|
* file names its year, because a snapshot's `datos2` is indistinguishable from
|
||||||
|
* the live one — so this pattern is both the allowlist and the contract. It is
|
||||||
|
* anchored and allows no separator, which is what keeps an upload from
|
||||||
|
* escaping the ingest directory.
|
||||||
|
*/
|
||||||
|
const PERIOD_FILE_RE = /^(\d{4})\.accdb$/i;
|
||||||
|
|
||||||
|
/** Earliest period we will accept, so a typo'd year cannot mint a bogus one. */
|
||||||
|
const PERIOD_MIN_YEAR = 1990;
|
||||||
|
|
||||||
|
export function periodYearOf(name: string): number | null {
|
||||||
|
const m = PERIOD_FILE_RE.exec(name);
|
||||||
|
if (!m) return null;
|
||||||
|
const year = Number(m[1]);
|
||||||
|
if (year < PERIOD_MIN_YEAR || year > new Date().getUTCFullYear()) return null;
|
||||||
|
return year;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prefix for every command containing a pipe. Without it the exit status of
|
* Prefix for every command containing a pipe. Without it the exit status of
|
||||||
* `mysqldump | gzip` is gzip's, so a dump that failed immediately still looks
|
* `mysqldump | gzip` is gzip's, so a dump that failed immediately still looks
|
||||||
@@ -120,19 +143,30 @@ export class OpsService implements OnModuleInit {
|
|||||||
|
|
||||||
/* -------------------------------------------------------------- ingest */
|
/* -------------------------------------------------------------- ingest */
|
||||||
|
|
||||||
private assertIngestName(name: string): IngestName {
|
private assertIngestName(name: string): string {
|
||||||
if (!INGEST_FILES.includes(name as IngestName)) {
|
if (INGEST_FILES.includes(name as IngestName)) return name;
|
||||||
throw new BadRequestException(
|
if (periodYearOf(name) !== null) return name;
|
||||||
`Archivo no permitido. Debe ser uno de: ${INGEST_FILES.join(", ")}`,
|
throw new BadRequestException(
|
||||||
);
|
`Archivo no permitido. Debe ser uno de: ${INGEST_FILES.join(", ")}` +
|
||||||
}
|
`, o un archivo de periodo anterior con nombre AAAA.accdb (por ejemplo 2025.accdb).`,
|
||||||
return name as IngestName;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listIngest(): Promise<
|
async listIngest(): Promise<
|
||||||
{ name: string; present: boolean; size: number | null; modifiedAt: string | null }[]
|
{
|
||||||
|
name: string;
|
||||||
|
present: boolean;
|
||||||
|
size: number | null;
|
||||||
|
modifiedAt: string | null;
|
||||||
|
/** Set only on a prior-period archive; null on the four fixed sources. */
|
||||||
|
periodYear: number | null;
|
||||||
|
}[]
|
||||||
> {
|
> {
|
||||||
return Promise.all(
|
// The four fixed sources are listed whether present or not — they are
|
||||||
|
// required, so "missing" is the useful state to show. Period archives are
|
||||||
|
// optional and unbounded, so they are listed only once uploaded, newest
|
||||||
|
// year first.
|
||||||
|
const fixed = await Promise.all(
|
||||||
INGEST_FILES.map(async (name) => {
|
INGEST_FILES.map(async (name) => {
|
||||||
try {
|
try {
|
||||||
const st = await fs.stat(path.join(this.ingestDir, name));
|
const st = await fs.stat(path.join(this.ingestDir, name));
|
||||||
@@ -141,12 +175,46 @@ export class OpsService implements OnModuleInit {
|
|||||||
present: true,
|
present: true,
|
||||||
size: st.size,
|
size: st.size,
|
||||||
modifiedAt: st.mtime.toISOString(),
|
modifiedAt: st.mtime.toISOString(),
|
||||||
|
periodYear: null as number | null,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { name, present: false, size: null, modifiedAt: null };
|
return {
|
||||||
|
name,
|
||||||
|
present: false,
|
||||||
|
size: null,
|
||||||
|
modifiedAt: null,
|
||||||
|
periodYear: null as number | null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let entries: string[] = [];
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(this.ingestDir);
|
||||||
|
} catch {
|
||||||
|
entries = [];
|
||||||
|
}
|
||||||
|
const periods = (
|
||||||
|
await Promise.all(
|
||||||
|
entries
|
||||||
|
.map((name) => ({ name, year: periodYearOf(name) }))
|
||||||
|
.filter((e): e is { name: string; year: number } => e.year !== null)
|
||||||
|
.sort((a, b) => b.year - a.year)
|
||||||
|
.map(async ({ name, year }) => {
|
||||||
|
const st = await fs.stat(path.join(this.ingestDir, name));
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
present: true,
|
||||||
|
size: st.size,
|
||||||
|
modifiedAt: st.mtime.toISOString(),
|
||||||
|
periodYear: year,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).filter(Boolean);
|
||||||
|
|
||||||
|
return [...fixed, ...periods];
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveIngest(name: string, data: Buffer): Promise<void> {
|
async saveIngest(name: string, data: Buffer): Promise<void> {
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { periodYearOf } from "./ops.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `periodYearOf` is the upload allowlist for prior-period archives, so it is
|
||||||
|
* doing two jobs at once: deciding what counts as a period file, and keeping a
|
||||||
|
* caller-supplied name from escaping the ingest directory. Both are pinned here.
|
||||||
|
*/
|
||||||
|
describe("periodYearOf", () => {
|
||||||
|
it("accepts a four-digit year archive", () => {
|
||||||
|
expect(periodYearOf("2025.accdb")).toBe(2025);
|
||||||
|
expect(periodYearOf("1999.accdb")).toBe(1999);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-insensitive on the extension", () => {
|
||||||
|
expect(periodYearOf("2025.ACCDB")).toBe(2025);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a path that would escape the ingest directory", () => {
|
||||||
|
// The name is joined onto the ingest path, so anything with a separator or
|
||||||
|
// a parent reference has to fail before it reaches the filesystem.
|
||||||
|
expect(periodYearOf("../2025.accdb")).toBeNull();
|
||||||
|
expect(periodYearOf("../../etc/passwd")).toBeNull();
|
||||||
|
expect(periodYearOf("sub/2025.accdb")).toBeNull();
|
||||||
|
expect(periodYearOf("2025.accdb/../../x")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects names that only look like a period", () => {
|
||||||
|
expect(periodYearOf("202.accdb")).toBeNull();
|
||||||
|
expect(periodYearOf("20255.accdb")).toBeNull();
|
||||||
|
expect(periodYearOf("2025.mdb")).toBeNull();
|
||||||
|
expect(periodYearOf("copia 2025.accdb")).toBeNull();
|
||||||
|
expect(periodYearOf("2025.accdb.bak")).toBeNull();
|
||||||
|
expect(periodYearOf("UTILITIES.accdb")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects years outside the plausible range", () => {
|
||||||
|
// A typo'd year would otherwise mint a period nobody can ever reconcile:
|
||||||
|
// there is no BALANCE FORWARD for the year after it to check against.
|
||||||
|
expect(periodYearOf("1889.accdb")).toBeNull();
|
||||||
|
expect(periodYearOf(`${new Date().getUTCFullYear() + 1}.accdb`)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts the current year, which is the earliest a period can be cut", () => {
|
||||||
|
expect(periodYearOf(`${new Date().getUTCFullYear()}.accdb`)).toBe(
|
||||||
|
new Date().getUTCFullYear(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Prisma } from "@jorgecuadros/database";
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
import { BALANCE_FORWARD_TYPE } from "../billing/billing.service";
|
import {
|
||||||
|
BALANCE_FORWARD_TYPE,
|
||||||
|
notCashJournal,
|
||||||
|
periodSourceTable,
|
||||||
|
} from "../billing/billing.service";
|
||||||
import {
|
import {
|
||||||
intParam,
|
intParam,
|
||||||
NOT_VOIDED,
|
NOT_VOIDED,
|
||||||
@@ -759,6 +763,15 @@ const edoCuentaDatos: ReportDef = {
|
|||||||
format: "statement",
|
format: "statement",
|
||||||
params: [
|
params: [
|
||||||
{ key: "customerId", label: "Cliente", kind: "customer-picker" },
|
{ key: "customerId", label: "Cliente", kind: "customer-picker" },
|
||||||
|
// Which period to print. Blank means the year in progress; an earlier year
|
||||||
|
// prints from its imported archive, the same source the on-screen
|
||||||
|
// statement reads.
|
||||||
|
{
|
||||||
|
key: "year",
|
||||||
|
label: "Periodo (año)",
|
||||||
|
kind: "number",
|
||||||
|
placeholder: "año en curso",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
columns: [
|
columns: [
|
||||||
// Statement rows carry synthetic `__kind` discriminators instead of
|
// Statement rows carry synthetic `__kind` discriminators instead of
|
||||||
@@ -804,28 +817,49 @@ const edoCuentaDatos: ReportDef = {
|
|||||||
select: { transactionDate: true },
|
select: { transactionDate: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Which period to print. An earlier year comes from its imported archive,
|
||||||
|
// tagged rather than dated, exactly as the on-screen statement reads it.
|
||||||
|
const thisYear = new Date().getUTCFullYear();
|
||||||
|
const askedYear = Number(p.year);
|
||||||
|
const requestedYear =
|
||||||
|
Number.isInteger(askedYear) && askedYear > 0 ? askedYear : thisYear;
|
||||||
|
const isArchive = requestedYear !== thisYear;
|
||||||
|
|
||||||
const rows = await prisma.transaction.findMany({
|
const rows = await prisma.transaction.findMany({
|
||||||
where: {
|
where: {
|
||||||
customerId,
|
customerId,
|
||||||
voidedAt: null,
|
voidedAt: null,
|
||||||
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
...(isArchive
|
||||||
// NULL-safe: `NULL NOT IN (...)` is NULL, not true, so a bare `notIn`
|
? // The archive is one period's ledger already, so the tag is the
|
||||||
// drops every app-captured row (they have no legacySourceTable) — the
|
// whole filter and the balance floor must not apply — the floor
|
||||||
// same defect this report's on-screen twin was fixed for.
|
// hides exactly the history this period is asking for.
|
||||||
OR: [
|
{ legacySourceTable: periodSourceTable(requestedYear) }
|
||||||
{ legacySourceTable: null },
|
: {
|
||||||
{
|
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||||
legacySourceTable: {
|
// Archive rows count as history below the year start (that is
|
||||||
notIn: [
|
// what `opening` is for, and for a customer floored by an archive
|
||||||
"EFECTIVO",
|
// it is the only carry there is) and are dropped at or above it.
|
||||||
"EFECTIVO_BACKUP",
|
// Same rule as the on-screen twin — see BillingService.statement.
|
||||||
"EFECTIVO FM3",
|
AND: [
|
||||||
"CHEQUE FM3",
|
{
|
||||||
"IVA 2015",
|
OR: [
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
||||||
|
{
|
||||||
|
transactionDate: {
|
||||||
|
lt: new Date(Date.UTC(requestedYear, 0, 1)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// 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(),
|
||||||
],
|
],
|
||||||
},
|
}),
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||||
select: {
|
select: {
|
||||||
@@ -847,8 +881,12 @@ const edoCuentaDatos: ReportDef = {
|
|||||||
// EDO CUENTA sheet reads. Rows from earlier years still move the running
|
// EDO CUENTA sheet reads. Rows from earlier years still move the running
|
||||||
// balance — they are folded into `opening` and printed as a single "saldo
|
// balance — they are folded into `opening` and printed as a single "saldo
|
||||||
// anterior" line, which is what a BALANCE FORWARD row is.
|
// anterior" line, which is what a BALANCE FORWARD row is.
|
||||||
const yearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1));
|
// An archive needs no fold: it *is* the period, and its own Jan-1 BALANCE
|
||||||
const year = yearStart.getUTCFullYear();
|
// FORWARD row is the carry, printed like legacy printed it.
|
||||||
|
const yearStart = isArchive
|
||||||
|
? new Date(0)
|
||||||
|
: new Date(Date.UTC(requestedYear, 0, 1));
|
||||||
|
const year = requestedYear;
|
||||||
|
|
||||||
const running = new Map<string, Prisma.Decimal>();
|
const running = new Map<string, Prisma.Decimal>();
|
||||||
const opening = new Map<string, Prisma.Decimal>();
|
const opening = new Map<string, Prisma.Decimal>();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@jorgecuadros/web",
|
"name": "@jorgecuadros/web",
|
||||||
"version": "1.0.23",
|
"version": "1.0.26",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 4500",
|
"dev": "next dev -p 4500",
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ function Detail({ id }: { id: string }) {
|
|||||||
customerId={data.id}
|
customerId={data.id}
|
||||||
summary={data.transactionSummary}
|
summary={data.transactionSummary}
|
||||||
transactions={data.transactions}
|
transactions={data.transactions}
|
||||||
|
year={data.transactionYear}
|
||||||
/>
|
/>
|
||||||
<DocumentosSection data={data} />
|
<DocumentosSection data={data} />
|
||||||
</div>
|
</div>
|
||||||
@@ -746,16 +747,18 @@ function EstadoCuentaSection({
|
|||||||
customerId,
|
customerId,
|
||||||
summary,
|
summary,
|
||||||
transactions,
|
transactions,
|
||||||
|
year,
|
||||||
}: {
|
}: {
|
||||||
customerId: string;
|
customerId: string;
|
||||||
summary: TransactionSummaryRow[];
|
summary: TransactionSummaryRow[];
|
||||||
transactions: Transaction[];
|
transactions: Transaction[];
|
||||||
|
year: number;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<SectionHead
|
<SectionHead
|
||||||
rule="cuenta"
|
rule="cuenta"
|
||||||
title="Estado de cuenta"
|
title={`Estado de cuenta ${year}`}
|
||||||
count={transactions.length}
|
count={transactions.length}
|
||||||
countSuffix="movimientos"
|
countSuffix="movimientos"
|
||||||
/>
|
/>
|
||||||
@@ -782,7 +785,7 @@ function EstadoCuentaSection({
|
|||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
{transactions.length === 0 ? (
|
{transactions.length === 0 ? (
|
||||||
<div className="empty-inline">Sin movimientos registrados.</div>
|
<div className="empty-inline">Sin movimientos en {year}.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="tx-scroll">
|
<div className="tx-scroll">
|
||||||
<table className="tx-table">
|
<table className="tx-table">
|
||||||
@@ -804,11 +807,11 @@ function EstadoCuentaSection({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{transactions.length >= 100 && (
|
<div className="section-note" style={{ padding: "0 16px 14px" }}>
|
||||||
<div className="section-note" style={{ padding: "0 16px 14px" }}>
|
Movimientos de {year}, del más antiguo al más reciente. Los saldos de
|
||||||
Mostrando los 100 movimientos más recientes.
|
arriba son el saldo actual por línea de negocio — los mismos del
|
||||||
</div>
|
estado de cuenta, no la suma del año.
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{transactions.length > 0 && (
|
{transactions.length > 0 && (
|
||||||
<p className="section-note">
|
<p className="section-note">
|
||||||
|
|||||||
@@ -40,8 +40,10 @@ import type {
|
|||||||
* currency at a time — a column that alternated between pesos and dollars would
|
* currency at a time — a column that alternated between pesos and dollars would
|
||||||
* be a meaningless number.
|
* be a meaningless number.
|
||||||
*
|
*
|
||||||
* Like the legacy EDO CUENTA report, the table covers the current year only and
|
* Like the legacy EDO CUENTA report, the table covers one calendar year and runs
|
||||||
* runs oldest-first, opening on the balance carried in from before it.
|
* oldest-first, opening on the balance carried in from before it. The period
|
||||||
|
* selector switches years; earlier ones are served from the imported archive of
|
||||||
|
* that year, which is how legacy kept them — one table per closed year.
|
||||||
*/
|
*/
|
||||||
export default function EstadoCuentaDetailPage({
|
export default function EstadoCuentaDetailPage({
|
||||||
params,
|
params,
|
||||||
@@ -67,19 +69,27 @@ function StatementView({ id }: { id: string }) {
|
|||||||
|
|
||||||
const [currency, setCurrency] = useState<LedgerCurrency | null>(null);
|
const [currency, setCurrency] = useState<LedgerCurrency | null>(null);
|
||||||
const [domain, setDomain] = useState<TransactionDomain | "">("");
|
const [domain, setDomain] = useState<TransactionDomain | "">("");
|
||||||
|
/** null = the current period; the API decides what that is. */
|
||||||
|
const [year, setYear] = useState<number | null>(null);
|
||||||
|
|
||||||
function reload() {
|
function reload() {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
getStatement(id)
|
getStatement(id, year ?? undefined)
|
||||||
.then((d) => {
|
.then((d) => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
setData(d);
|
setData(d);
|
||||||
// Default to the currency the customer actually moves the most in;
|
// Default to the currency the customer actually moves the most in;
|
||||||
// preserve a previously-chosen currency across reloads.
|
// preserve a previously-chosen currency across reloads — but only if
|
||||||
|
// the loaded period still has it. Switching to a year the customer
|
||||||
|
// never moved dollars in would otherwise leave the picker on USD with
|
||||||
|
// no matching option, showing an empty table for a year that has rows.
|
||||||
const busiest = [...d.summary].sort((a, b) => b.count - a.count)[0];
|
const busiest = [...d.summary].sort((a, b) => b.count - a.count)[0];
|
||||||
setCurrency((prev) => prev ?? busiest?.currency ?? "MXN");
|
const fallback = busiest?.currency ?? "MXN";
|
||||||
|
setCurrency((prev) =>
|
||||||
|
prev && d.summary.some((s) => s.currency === prev) ? prev : fallback,
|
||||||
|
);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
@@ -101,7 +111,7 @@ function StatementView({ id }: { id: string }) {
|
|||||||
getBillingFacets().then(setFacets).catch(() => setFacets(null));
|
getBillingFacets().then(setFacets).catch(() => setFacets(null));
|
||||||
return cleanup;
|
return cleanup;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [id]);
|
}, [id, year]);
|
||||||
|
|
||||||
const movements = useMemo(() => {
|
const movements = useMemo(() => {
|
||||||
if (!data || !currency) return [];
|
if (!data || !currency) return [];
|
||||||
@@ -229,6 +239,26 @@ function StatementView({ id }: { id: string }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="filter-row">
|
<div className="filter-row">
|
||||||
|
{/* Only the periods this customer has. A year with no archive would
|
||||||
|
render an empty table that reads as "no hubo movimientos" when the
|
||||||
|
truth is that the year was never imported. */}
|
||||||
|
{data.availableYears.length > 1 && (
|
||||||
|
<label className="filter-field">
|
||||||
|
<span className="filter-label">Periodo</span>
|
||||||
|
<select
|
||||||
|
className="input select"
|
||||||
|
value={data.year}
|
||||||
|
onChange={(e) => setYear(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{data.availableYears.map((y) => (
|
||||||
|
<option key={y} value={y}>
|
||||||
|
{y}
|
||||||
|
{y === data.availableYears[0] ? " (en curso)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<label className="filter-field">
|
<label className="filter-field">
|
||||||
<span className="filter-label">Moneda</span>
|
<span className="filter-label">Moneda</span>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ function Operaciones() {
|
|||||||
const [starting, setStarting] = useState(false);
|
const [starting, setStarting] = useState(false);
|
||||||
|
|
||||||
const fileInputs = useRef<Record<string, HTMLInputElement | null>>({});
|
const fileInputs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||||
|
const periodInput = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const refreshLists = useCallback(() => {
|
const refreshLists = useCallback(() => {
|
||||||
listIngest().then(setIngest).catch(() => setIngest([]));
|
listIngest().then(setIngest).catch(() => setIngest([]));
|
||||||
@@ -143,6 +144,20 @@ function Operaciones() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Upload a prior-period archive under its own filename. */
|
||||||
|
async function handleUploadPeriod(file: File | undefined) {
|
||||||
|
if (!file) return;
|
||||||
|
if (!/^\d{4}\.accdb$/i.test(file.name)) {
|
||||||
|
setError(
|
||||||
|
`"${file.name}" no es un archivo de periodo. Debe llamarse AAAA.accdb, por ejemplo 2025.accdb.`,
|
||||||
|
);
|
||||||
|
if (periodInput.current) periodInput.current.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handleUpload(file.name, file);
|
||||||
|
if (periodInput.current) periodInput.current.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDeleteIngest(name: string) {
|
async function handleDeleteIngest(name: string) {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -197,7 +212,11 @@ function Operaciones() {
|
|||||||
else await start("RESTORE", c.file);
|
else await start("RESTORE", c.file);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ingestReady = (ingest ?? []).every((f) => f.present);
|
// Only the four fixed Access sources gate a run. Period archives are
|
||||||
|
// optional extras — having none simply means no prior years are available.
|
||||||
|
const ingestReady = (ingest ?? [])
|
||||||
|
.filter((f) => f.periodYear === null)
|
||||||
|
.every((f) => f.present);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -244,8 +263,9 @@ function Operaciones() {
|
|||||||
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||||
<h2 className="section-title">Carpeta de ingesta</h2>
|
<h2 className="section-title">Carpeta de ingesta</h2>
|
||||||
<p className="inline-form-note">
|
<p className="inline-form-note">
|
||||||
Los cuatro archivos originales de Access. La reimportación y la
|
Los cuatro archivos originales de Access, más los archivos de periodos
|
||||||
sincronización leen de aquí. Tamaño máximo por archivo: {formatBytes(INGEST_MAX_BYTES)}.
|
anteriores. La reimportación y la sincronización leen de aquí. Tamaño
|
||||||
|
máximo por archivo: {formatBytes(INGEST_MAX_BYTES)}.
|
||||||
</p>
|
</p>
|
||||||
<div className="tx-scroll">
|
<div className="tx-scroll">
|
||||||
<table className="tx-table">
|
<table className="tx-table">
|
||||||
@@ -262,7 +282,14 @@ function Operaciones() {
|
|||||||
{(ingest ?? []).map((f) => (
|
{(ingest ?? []).map((f) => (
|
||||||
<Fragment key={f.name}>
|
<Fragment key={f.name}>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="mono">{f.name}</td>
|
<td className="mono">
|
||||||
|
{f.name}
|
||||||
|
{f.periodYear !== null && (
|
||||||
|
<span className="badge" style={{ marginLeft: 8 }}>
|
||||||
|
periodo {f.periodYear}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`badge ${f.present ? "badge-positive" : "badge-negative"}`}>
|
<span className={`badge ${f.present ? "badge-positive" : "badge-negative"}`}>
|
||||||
{f.present ? "Presente" : "Falta"}
|
{f.present ? "Presente" : "Falta"}
|
||||||
@@ -313,6 +340,33 @@ function Operaciones() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* A period archive that has never been uploaded has no row to click,
|
||||||
|
so it needs its own entry point. The file names itself: the archive
|
||||||
|
IS `2025.accdb`, and that name is what declares the period, so the
|
||||||
|
control reads it off the chosen file rather than asking twice. */}
|
||||||
|
<div className="row-actions" style={{ marginTop: 16 }}>
|
||||||
|
<input
|
||||||
|
ref={periodInput}
|
||||||
|
type="file"
|
||||||
|
accept=".accdb"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={(e) => handleUploadPeriod(e.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline"
|
||||||
|
type="button"
|
||||||
|
disabled={uploading !== null}
|
||||||
|
onClick={() => periodInput.current?.click()}
|
||||||
|
>
|
||||||
|
Agregar periodo anterior…
|
||||||
|
</button>
|
||||||
|
<span className="inline-form-note">
|
||||||
|
Un archivo de Access por año cerrado, nombrado con su periodo:{" "}
|
||||||
|
<span className="mono">2025.accdb</span>. Aporta el año anterior al
|
||||||
|
estado de cuenta; no altera el saldo actual.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Operations */}
|
{/* Operations */}
|
||||||
|
|||||||
@@ -615,8 +615,13 @@ export function getBillingFacets(): Promise<BillingFacets> {
|
|||||||
return apiFetch<BillingFacets>("/billing/facets");
|
return apiFetch<BillingFacets>("/billing/facets");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStatement(customerId: string): Promise<Statement> {
|
/** `year` omitted reads the current period; earlier years come from an archive. */
|
||||||
return apiFetch<Statement>(`/billing/customers/${customerId}`);
|
export function getStatement(
|
||||||
|
customerId: string,
|
||||||
|
year?: number,
|
||||||
|
): Promise<Statement> {
|
||||||
|
const q = year === undefined ? "" : `?year=${year}`;
|
||||||
|
return apiFetch<Statement>(`/billing/customers/${customerId}${q}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Append a new ledger movement. Booked movements are never edited — fix
|
/** Append a new ledger movement. Booked movements are never edited — fix
|
||||||
|
|||||||
@@ -192,6 +192,8 @@ export interface IngestFile {
|
|||||||
present: boolean;
|
present: boolean;
|
||||||
size: number | null;
|
size: number | null;
|
||||||
modifiedAt: string | null;
|
modifiedAt: string | null;
|
||||||
|
/** Year of a prior-period archive (`2025.accdb`); null on the four fixed sources. */
|
||||||
|
periodYear: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackupFile {
|
export interface BackupFile {
|
||||||
@@ -1094,6 +1096,13 @@ export interface Statement {
|
|||||||
};
|
};
|
||||||
/** Calendar year the statement covers; movements are scoped to it. */
|
/** Calendar year the statement covers; movements are scoped to it. */
|
||||||
year: number;
|
year: number;
|
||||||
|
/**
|
||||||
|
* Periods this customer actually has, newest first. The current year is
|
||||||
|
* always present; each earlier year comes from an imported archive. Offering
|
||||||
|
* anything outside this list would render an empty statement that reads as
|
||||||
|
* "no activity" rather than "not imported".
|
||||||
|
*/
|
||||||
|
availableYears: number[];
|
||||||
summary: StatementSummary[];
|
summary: StatementSummary[];
|
||||||
byDomain: StatementDomainRow[];
|
byDomain: StatementDomainRow[];
|
||||||
byType: StatementTypeRow[];
|
byType: StatementTypeRow[];
|
||||||
@@ -1129,6 +1138,8 @@ export interface CustomerDetail {
|
|||||||
properties: Property[];
|
properties: Property[];
|
||||||
policies: Policy[];
|
policies: Policy[];
|
||||||
transactions: Transaction[];
|
transactions: Transaction[];
|
||||||
|
/** Calendar year `transactions` covers. */
|
||||||
|
transactionYear: number;
|
||||||
transactionSummary: TransactionSummaryRow[];
|
transactionSummary: TransactionSummaryRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ the four Access source files.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# The folder holding the four Access source files. Overridable via INGEST_DIR so
|
# The folder holding the four Access source files. Overridable via INGEST_DIR so
|
||||||
@@ -95,3 +96,59 @@ SOURCES = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- prior-period archives ----------------------------------------------
|
||||||
|
#
|
||||||
|
# Legacy ran a year-end *corte*: it summed the closing year, wrote that total
|
||||||
|
# back as each customer's Jan-1 BALANCE FORWARD, and started the next year
|
||||||
|
# clean. Access keeps the closed year as a whole-database snapshot named for
|
||||||
|
# the period it holds — `2025.accdb` is UTILITIES as it stood when 2025 was
|
||||||
|
# cut — and the office archives one per year.
|
||||||
|
#
|
||||||
|
# Only the ledger is staged out of a snapshot. Everything else in it (DATMEX,
|
||||||
|
# PROFILE, EFECTIVO, ...) is a year-stale copy of a table the live
|
||||||
|
# UTILITIES.accdb already provides, and staging all ~50 of them would triple
|
||||||
|
# the extract time to import data we would then have to ignore. DATGRAL comes
|
||||||
|
# along solely to check that a NUMid still means the same customer it did that
|
||||||
|
# year; see the recycle guard in transform_transactions.py.
|
||||||
|
#
|
||||||
|
# The cash side is deliberately NOT taken from the snapshot: `EFECTIVO` is a
|
||||||
|
# lifetime journal, so the snapshot's copy is a subset of the live one and
|
||||||
|
# importing it would double-book every prior-year receipt.
|
||||||
|
PERIOD_FILE_RE = re.compile(r"^(\d{4})\.accdb$", re.IGNORECASE)
|
||||||
|
PERIOD_TABLES = {"datos2", "DATGRAL"}
|
||||||
|
|
||||||
|
|
||||||
|
def period_schema(year: int) -> str:
|
||||||
|
return f"stg_period_{year}"
|
||||||
|
|
||||||
|
|
||||||
|
def discover_periods(root: Path) -> dict[str, dict]:
|
||||||
|
"""Find every `YYYY.accdb` archive sitting in the ingest folder.
|
||||||
|
|
||||||
|
Discovery is by filename because that is the whole upload contract: the
|
||||||
|
operator drops `2025.accdb` on the Operaciones page and the period is 2025.
|
||||||
|
Nothing inside the file names the year — a snapshot's `datos2` looks
|
||||||
|
identical to the live one — so the name is the only declaration of intent
|
||||||
|
we get, and it is what the allowlist on the upload endpoint enforces.
|
||||||
|
"""
|
||||||
|
found: dict[str, dict] = {}
|
||||||
|
if not root.is_dir():
|
||||||
|
return found
|
||||||
|
for path in sorted(root.iterdir()):
|
||||||
|
m = PERIOD_FILE_RE.match(path.name)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
year = int(m.group(1))
|
||||||
|
found[f"period_{year}"] = {
|
||||||
|
"path": path,
|
||||||
|
"schema": period_schema(year),
|
||||||
|
"exclude": set(),
|
||||||
|
"include": set(PERIOD_TABLES),
|
||||||
|
"period_year": year,
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
SOURCES.update(discover_periods(SOURCE_ROOT))
|
||||||
|
|||||||
@@ -40,6 +40,17 @@ def stage_source(source_name: str, source_cfg: dict, sink) -> None:
|
|||||||
tables = extract.list_tables(cnxn)
|
tables = extract.list_tables(cnxn)
|
||||||
excluded = source_cfg["exclude"]
|
excluded = source_cfg["exclude"]
|
||||||
|
|
||||||
|
# A source may name the only tables it is worth staging. Prior-period
|
||||||
|
# archives do: they are whole-database snapshots, but everything in them
|
||||||
|
# except the ledger is a year-stale copy of a live table, so staging the
|
||||||
|
# rest costs minutes per file to produce data nothing reads.
|
||||||
|
include = source_cfg.get("include")
|
||||||
|
if include is not None:
|
||||||
|
missing = include - set(tables)
|
||||||
|
if missing:
|
||||||
|
print(f" [WARN] {source_name}: missing expected table(s) {sorted(missing)}", file=sys.stderr)
|
||||||
|
tables = [t for t in tables if t in include]
|
||||||
|
|
||||||
for table_name in tables:
|
for table_name in tables:
|
||||||
if table_name in excluded:
|
if table_name in excluded:
|
||||||
print(f" [exclude] {table_name}")
|
print(f" [exclude] {table_name}")
|
||||||
|
|||||||
@@ -90,6 +90,62 @@ def load(src, name):
|
|||||||
return df
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def verify_corte(c, year: int) -> None:
|
||||||
|
"""Assert legacy's corte identity: SUM(period Y) == BALANCE FORWARD(Y+1).
|
||||||
|
|
||||||
|
This is the whole reason a year can be shown on its own. Legacy closed each
|
||||||
|
year by summing it and writing that total back as every customer's Jan-1
|
||||||
|
opening row for the next one, so if an archive is the right file, complete,
|
||||||
|
and attached to the right customers, its per-customer total lands exactly on
|
||||||
|
the next year's BALANCE FORWARD. A truncated export, a file dropped under
|
||||||
|
the wrong year, or a botched customer match all break the identity loudly
|
||||||
|
here instead of quietly six months from now.
|
||||||
|
|
||||||
|
Reported, never fatal. Legacy publishes on its own schedule, so a handful of
|
||||||
|
customers legitimately drift between the snapshot and the cut — the run that
|
||||||
|
established this reconciled 1,160 of 1,170.
|
||||||
|
"""
|
||||||
|
c.execute(
|
||||||
|
"""
|
||||||
|
SELECT sums.customerId, sums.total, bf.amount
|
||||||
|
FROM (
|
||||||
|
SELECT customerId, ROUND(SUM(amount), 2) AS total
|
||||||
|
FROM transactions
|
||||||
|
WHERE legacySourceTable = %s AND voidedAt IS NULL
|
||||||
|
GROUP BY customerId
|
||||||
|
) sums
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT t.customerId, ROUND(SUM(t.amount), 2) AS amount
|
||||||
|
FROM transactions t
|
||||||
|
JOIN type_transactions tt ON tt.id = t.typeId
|
||||||
|
WHERE tt.nameEn = 'BALANCE FORWARD' AND t.voidedAt IS NULL
|
||||||
|
AND t.transactionDate >= %s AND t.transactionDate < %s
|
||||||
|
GROUP BY t.customerId
|
||||||
|
) bf ON bf.customerId = sums.customerId
|
||||||
|
""",
|
||||||
|
(f"datos2@{year}", f"{year + 1}-01-01", f"{year + 1}-01-02"),
|
||||||
|
)
|
||||||
|
rows = c.fetchall()
|
||||||
|
matched = mismatched = 0
|
||||||
|
missing = 0
|
||||||
|
drift = Decimal(0)
|
||||||
|
for _cid, total, amount in rows:
|
||||||
|
if amount is None:
|
||||||
|
missing += 1
|
||||||
|
continue
|
||||||
|
if abs(Decimal(str(total)) - Decimal(str(amount))) < Decimal("0.02"):
|
||||||
|
matched += 1
|
||||||
|
else:
|
||||||
|
mismatched += 1
|
||||||
|
drift += abs(Decimal(str(total)) - Decimal(str(amount)))
|
||||||
|
checked = matched + mismatched
|
||||||
|
pct = (100 * matched / checked) if checked else 0
|
||||||
|
print(
|
||||||
|
f" corte {year} -> BF {year + 1}: {matched}/{checked} match ({pct:.1f}%)"
|
||||||
|
f", {mismatched} off by {drift:,.2f}, {missing} with no BF row"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
env, sync_mode = parse_mode()
|
env, sync_mode = parse_mode()
|
||||||
conn = connect(env)
|
conn = connect(env)
|
||||||
@@ -230,9 +286,13 @@ def main():
|
|||||||
message=s(r["conepto"]), check=s(r[check_col]) if check_col else None,
|
message=s(r["conepto"]), check=s(r[check_col]) if check_col else None,
|
||||||
src_db="UTILITIES", src_tbl=legacy_tbl, legacy=str(int(r["_row_num"])))
|
src_db="UTILITIES", src_tbl=legacy_tbl, legacy=str(int(r["_row_num"])))
|
||||||
|
|
||||||
def billing(name, legacy_tbl):
|
def billing(name, legacy_tbl, *, src="stg_utilities", skip_numids=None):
|
||||||
"""Load a DATOS2-shaped billing ledger.
|
"""Load a DATOS2-shaped billing ledger.
|
||||||
|
|
||||||
|
`src` names the staging schema, so a prior-period archive
|
||||||
|
(stg_period_2025) loads through this same path: the snapshot's `datos2`
|
||||||
|
is the identical eleven-column shape, one year older.
|
||||||
|
|
||||||
NOPAGO is the legacy "still owed" flag. The website reads it directly —
|
NOPAGO is the legacy "still owed" flag. The website reads it directly —
|
||||||
`account.statement.php` splits the statement on `NOPAGO = 0` vs
|
`account.statement.php` splits the statement on `NOPAGO = 0` vs
|
||||||
`NOPAGO = 1` and renders the latter as the "Outstanding Bills Requiring
|
`NOPAGO = 1` and renders the latter as the "Outstanding Bills Requiring
|
||||||
@@ -241,10 +301,13 @@ def main():
|
|||||||
Only these three tables carry it (76 rows set in DATOS2 today); the
|
Only these three tables carry it (76 rows set in DATOS2 today); the
|
||||||
EFECTIVO/FM3 cash streams have no such column and stay 0.
|
EFECTIVO/FM3 cash streams have no such column and stay 0.
|
||||||
"""
|
"""
|
||||||
nonlocal skip_cust, skip_date
|
nonlocal skip_cust, skip_date, skip_recycled
|
||||||
df = load("stg_utilities", name)
|
df = load(src, name)
|
||||||
for _, r in df.iterrows():
|
for _, r in df.iterrows():
|
||||||
cid = util_cust.get(norm_id(r["numid"]))
|
numid = norm_id(r["numid"])
|
||||||
|
if skip_numids and numid in skip_numids:
|
||||||
|
skip_recycled += 1; continue
|
||||||
|
cid = util_cust.get(numid)
|
||||||
if not cid:
|
if not cid:
|
||||||
skip_cust += 1; continue
|
skip_cust += 1; continue
|
||||||
td = dt(r["date"])
|
td = dt(r["date"])
|
||||||
@@ -257,6 +320,53 @@ def main():
|
|||||||
legacy=str(int(r["_row_num"])),
|
legacy=str(int(r["_row_num"])),
|
||||||
outstanding=1 if s(r["nopago"]) == "1" else 0)
|
outstanding=1 if s(r["nopago"]) == "1" else 0)
|
||||||
|
|
||||||
|
skip_recycled = 0
|
||||||
|
recycle_report: list[tuple[int, str, str, str]] = []
|
||||||
|
|
||||||
|
def period_numid_guard(year: int) -> set[str]:
|
||||||
|
"""NUMids whose prior-period owner is not today's customer.
|
||||||
|
|
||||||
|
Prior-period rows attach by NUMid and nothing else, so a number the
|
||||||
|
office retired and reissued would file one customer's ledger under
|
||||||
|
another's name — the one error this feature must never make, because it
|
||||||
|
shows a stranger's charges to whoever holds the number now.
|
||||||
|
|
||||||
|
Reuse is real but rare: comparing each archive's DATGRAL against the
|
||||||
|
live one, 13 names moved since 2025 and 40 since 2024. Most are the same
|
||||||
|
customer re-described — a typo fixed (VIKIE -> VICKIE), a spouse added
|
||||||
|
or dropped (STEWART, ALAN R. -> STEWART, ALAN & JENNIFER). A few are
|
||||||
|
genuinely a different household (STRONKS, BOB -> SWEET, DONALD E.).
|
||||||
|
|
||||||
|
Sharing any word of three or more characters separates the two cleanly:
|
||||||
|
a rename keeps the surname, a reissue keeps nothing. Names are compared
|
||||||
|
legacy-to-legacy, archive DATGRAL against live DATGRAL, deliberately not
|
||||||
|
against `customers.name` — that column has been through the blank-name
|
||||||
|
recovery pass, and comparing to it reported 121 drifts where there are
|
||||||
|
13, every extra one a false positive that would have discarded good
|
||||||
|
history.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
arch = load(f"stg_period_{year}", "datgral")
|
||||||
|
live = load("stg_utilities", "datgral")
|
||||||
|
except (FileNotFoundError, OSError):
|
||||||
|
return set()
|
||||||
|
|
||||||
|
def toks(v) -> set[str]:
|
||||||
|
return {w for w in "".join(ch if ch.isalnum() else " " for ch in (s(v) or "").upper()).split() if len(w) >= 3}
|
||||||
|
|
||||||
|
live_names = {norm_id(r["num_id"]): s(r["nombre"]) for _, r in live.iterrows()}
|
||||||
|
blocked: set[str] = set()
|
||||||
|
for _, r in arch.iterrows():
|
||||||
|
numid = norm_id(r["num_id"])
|
||||||
|
was, now = s(r["nombre"]), live_names.get(numid)
|
||||||
|
if not numid or not was or not now:
|
||||||
|
continue
|
||||||
|
if toks(was) & toks(now):
|
||||||
|
continue
|
||||||
|
blocked.add(numid)
|
||||||
|
recycle_report.append((year, numid, was, now))
|
||||||
|
return blocked
|
||||||
|
|
||||||
def iva():
|
def iva():
|
||||||
nonlocal skip_cust
|
nonlocal skip_cust
|
||||||
df = load("stg_utilities", "iva_2015")
|
df = load("stg_utilities", "iva_2015")
|
||||||
@@ -286,6 +396,31 @@ def main():
|
|||||||
billing("fee_anual", "FEE ANUAL")
|
billing("fee_anual", "FEE ANUAL")
|
||||||
billing("fee15", "fee15")
|
billing("fee15", "fee15")
|
||||||
iva()
|
iva()
|
||||||
|
|
||||||
|
# --- prior periods -----------------------------------------------------
|
||||||
|
#
|
||||||
|
# Legacy kept each closed year in its own table and opened the next one with
|
||||||
|
# a Jan-1 BALANCE FORWARD carrying the closing total. The platform has one
|
||||||
|
# `transactions` table, so the period a row belongs to has to travel with
|
||||||
|
# the row: it rides in legacySourceTable as `datos2@2025`.
|
||||||
|
#
|
||||||
|
# That tag, not the date, is what a year view should filter on. The archives
|
||||||
|
# are not cleanly bounded — 2025's ledger carries ten undated rows and two
|
||||||
|
# dated into 2026 — and legacy itself never filtered by date either: its
|
||||||
|
# reader is `SELECT ... FROM \`2025\``. Keying on provenance reproduces the
|
||||||
|
# legacy period exactly and strands nothing.
|
||||||
|
#
|
||||||
|
# The tag also keeps the unique key safe. legacyId is a positional row
|
||||||
|
# ordinal, so every archive restarts it at 0 and would collide with the live
|
||||||
|
# `datos2` row-for-row if they shared a source-table name.
|
||||||
|
periods = sorted(
|
||||||
|
int(d.name.rsplit("_", 1)[1])
|
||||||
|
for d in STG.glob("stg_period_*")
|
||||||
|
if d.is_dir() and d.name.rsplit("_", 1)[1].isdigit()
|
||||||
|
)
|
||||||
|
for year in periods:
|
||||||
|
billing("datos2", f"datos2@{year}", src=f"stg_period_{year}",
|
||||||
|
skip_numids=period_numid_guard(year))
|
||||||
# Same record shape in the seguros DB. Labelled for consistency in the
|
# Same record shape in the seguros DB. Labelled for consistency in the
|
||||||
# platform's own UI; unverifiable against the site, which only ever reads
|
# platform's own UI; unverifiable against the site, which only ever reads
|
||||||
# domain='UTILITY', so no customer-facing behaviour depends on it.
|
# domain='UTILITY', so no customer-facing behaviour depends on it.
|
||||||
@@ -334,6 +469,7 @@ def main():
|
|||||||
print(f" skipped (unresolved customer): {skip_cust}")
|
print(f" skipped (unresolved customer): {skip_cust}")
|
||||||
print(f" skipped (unparseable date) : {skip_date}")
|
print(f" skipped (unparseable date) : {skip_date}")
|
||||||
print(f" skipped (EFECTIVO_BACKUP dup): {skip_dupe}")
|
print(f" skipped (EFECTIVO_BACKUP dup): {skip_dupe}")
|
||||||
|
print(f" skipped (reissued NUMid) : {skip_recycled}")
|
||||||
print(f" -> transactions : {count('transactions')}")
|
print(f" -> transactions : {count('transactions')}")
|
||||||
print(f" by domain : {dict(by_dom)}")
|
print(f" by domain : {dict(by_dom)}")
|
||||||
for src, n in by_src:
|
for src, n in by_src:
|
||||||
@@ -342,6 +478,17 @@ def main():
|
|||||||
print(f" -> exchange_rates : {count('exchange_rates')}")
|
print(f" -> exchange_rates : {count('exchange_rates')}")
|
||||||
print(f" orphan transactions (bad customer FK): {orphans}")
|
print(f" orphan transactions (bad customer FK): {orphans}")
|
||||||
assert orphans == 0, "transaction customer FK invariant failed"
|
assert orphans == 0, "transaction customer FK invariant failed"
|
||||||
|
|
||||||
|
if recycle_report:
|
||||||
|
print(f" ! reissued NUMids, prior-period rows NOT imported: {len(recycle_report)}")
|
||||||
|
for year, numid, was, now in recycle_report[:8]:
|
||||||
|
print(f" {year} NUMid {numid}: '{was}' -> '{now}'")
|
||||||
|
if len(recycle_report) > 8:
|
||||||
|
print(f" ... {len(recycle_report) - 8} more")
|
||||||
|
|
||||||
|
for year in periods:
|
||||||
|
verify_corte(c, year)
|
||||||
|
|
||||||
print(" validation: OK")
|
print(" validation: OK")
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jorgecuadros-platform",
|
"name": "jorgecuadros-platform",
|
||||||
"version": "1.0.23",
|
"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.23",
|
"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",
|
||||||
|
|||||||
@@ -0,0 +1,405 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Corte (year-end cut) audit — READ ONLY. Writes nothing, voids nothing.
|
||||||
|
*
|
||||||
|
* Legacy Access ran a corte every year: it moved the year's utility movements
|
||||||
|
* into a per-year table and stamped one BALANCE FORWARD row per customer,
|
||||||
|
* dated Jan 1, carrying the closing balance. The platform inherited the ROWS
|
||||||
|
* (1,170 of them, dated 2026-01-01 — legacy's last cut before the extract) but
|
||||||
|
* not the PROCESS, and BillingService uses those rows as a per-customer floor
|
||||||
|
* (BALANCE_FLOOR_JOIN / NOT_SUPERSEDED in apps/api/src/billing/billing.service.ts).
|
||||||
|
*
|
||||||
|
* This script reports the two populations that floor does not cover:
|
||||||
|
*
|
||||||
|
* A. FLOORLESS customers — no BALANCE FORWARD row at all, so their balance
|
||||||
|
* is a raw lifetime sum. The platform only migrated the CURRENT-year
|
||||||
|
* charge ledger (datos2); the per-year charge tables live in DreamHost
|
||||||
|
* and were never staged. What survives before the cutover is therefore
|
||||||
|
* the EFECTIVO cash journal — receipts with no matching charges — so
|
||||||
|
* those sums read as the office owing money it does not owe.
|
||||||
|
*
|
||||||
|
* B. DOUBLE-BOOKED 2026 RECEIPTS — one cash receipt recorded twice, once in
|
||||||
|
* EFECTIVO with folio `N` and once in datos2 with reference `CN`. Both
|
||||||
|
* rows are after the 2026-01-01 floor, so both count. The statement hides
|
||||||
|
* them (STATEMENT_EXCLUDED_SOURCE_TABLES drops EFECTIVO); the balances
|
||||||
|
* worklist, the movement browser and the /clientes/:id card do not.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* with an unrelated receipt; folios are also mistyped, so a genuine pair can
|
||||||
|
* carry two different numbers. Detection therefore runs twice — once on the
|
||||||
|
* `CN` cross-reference, once over the C-refs that pass left orphaned, this
|
||||||
|
* time on proximity alone (same customer, within three days) — and BOTH
|
||||||
|
* passes are then judged on the money: identical amount when the two legs
|
||||||
|
* share a currency, or an implied USD->MXN rate inside the band the
|
||||||
|
* exchange_rates table actually observed that year. Anything that fails is
|
||||||
|
* reported apart and must not be counted as duplicated money.
|
||||||
|
*
|
||||||
|
* The second pass is not a refinement. Jorge Jr's own account carries
|
||||||
|
* `C13647` against EFECTIVO folio `13649` — same day, same 3,500.00 — and
|
||||||
|
* POWERS carries `C135808` against `13508`. Folio matching alone reports
|
||||||
|
* both accounts as clean.
|
||||||
|
*
|
||||||
|
* node scripts/corte-audit.mjs # summary + both sections
|
||||||
|
* node scripts/corte-audit.mjs --cutover 2026-01-01
|
||||||
|
* node scripts/corte-audit.mjs --csv-a # per-customer table, section A
|
||||||
|
* node scripts/corte-audit.mjs --csv-b # per-pair table, section B
|
||||||
|
* node scripts/corte-audit.mjs --limit 40 # rows printed per section
|
||||||
|
*
|
||||||
|
* Needs DATABASE_URL. Point it at PROD — a stale copy answers about itself.
|
||||||
|
* On a database imported before the BALANCE FORWARD type was minted those rows
|
||||||
|
* carry typeId NULL instead (see numid.service.ts:80), so the floor is matched
|
||||||
|
* in BOTH shapes here; matching only the type name reports every customer as
|
||||||
|
* floorless on such a copy.
|
||||||
|
*/
|
||||||
|
import pkg from "../packages/database/generated/client/index.js";
|
||||||
|
|
||||||
|
const { PrismaClient } = pkg;
|
||||||
|
|
||||||
|
/** Prisma hands raw DECIMAL back as Decimal|string|null; counts as BigInt. */
|
||||||
|
const d = (v) => (v == null ? 0 : Number(v));
|
||||||
|
const money = (v) => d(v).toFixed(2).padStart(13);
|
||||||
|
/**
|
||||||
|
* Raw DATE/DATETIME columns arrive as JS Date objects. String() would render
|
||||||
|
* them in the host's local zone, which turns a row stored at 2026-01-01 00:00
|
||||||
|
* UTC into "Dec 31" on a US Pacific laptop — the ledger is keyed on UTC dates
|
||||||
|
* everywhere else, so format in UTC and nowhere else.
|
||||||
|
*/
|
||||||
|
const day = (v) => (v == null ? "—" : new Date(v).toISOString().slice(0, 10));
|
||||||
|
|
||||||
|
function arg(args, name, fallback = null) {
|
||||||
|
const i = args.indexOf(name);
|
||||||
|
return i === -1 ? fallback : args[i + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A row is a balance-forward marker in either of two shapes. Keep in step with
|
||||||
|
* EMPTY_NUMID_SQL in apps/api/src/customers/numid.service.ts.
|
||||||
|
*/
|
||||||
|
const BF_PREDICATE = `(
|
||||||
|
tt.nameEn = 'BALANCE FORWARD'
|
||||||
|
OR (t.typeId IS NULL AND MONTH(t.transactionDate) = 1 AND DAY(t.transactionDate) = 1
|
||||||
|
AND t.legacySourceTable = 'datos2')
|
||||||
|
)`;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const limit = Number(arg(args, "--limit", "25"));
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ---- cutover -----------------------------------------------------------
|
||||||
|
// Default to the newest balance-forward date actually in the book rather
|
||||||
|
// than to the current year: the cut the data reflects is a fact, not a
|
||||||
|
// preference, and hardcoding 2026 would silently lie on any other copy.
|
||||||
|
const [bfDates] = await prisma.$queryRawUnsafe(`
|
||||||
|
SELECT MAX(t.transactionDate) AS newest, MIN(t.transactionDate) AS oldest,
|
||||||
|
COUNT(*) AS rows_, COUNT(DISTINCT t.customerId) AS custs
|
||||||
|
FROM transactions t LEFT JOIN type_transactions tt ON tt.id = t.typeId
|
||||||
|
WHERE t.voidedAt IS NULL AND ${BF_PREDICATE}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cutover =
|
||||||
|
arg(args, "--cutover") ??
|
||||||
|
(bfDates.newest ? new Date(bfDates.newest).toISOString().slice(0, 10) : null);
|
||||||
|
|
||||||
|
if (!cutover) {
|
||||||
|
console.log("No BALANCE FORWARD rows in this database and no --cutover given.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`corte audit — cutover ${cutover}`);
|
||||||
|
console.log(
|
||||||
|
` balance-forward rows: ${d(bfDates.rows_)} across ${d(bfDates.custs)} customers` +
|
||||||
|
`, dated ${day(bfDates.oldest)}..${day(bfDates.newest)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- book totals -------------------------------------------------------
|
||||||
|
const [book] = 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
|
||||||
|
ROUND(SUM(CASE WHEN t.currency='MXN' THEN t.amount ELSE 0 END), 2) AS rawMxn,
|
||||||
|
ROUND(SUM(CASE WHEN t.currency='USD' THEN t.amount ELSE 0 END), 2) AS rawUsd,
|
||||||
|
ROUND(SUM(CASE WHEN t.currency='MXN' AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate)
|
||||||
|
THEN t.amount ELSE 0 END), 2) AS todayMxn,
|
||||||
|
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,
|
||||||
|
ROUND(SUM(CASE WHEN t.currency='MXN' AND t.transactionDate >= ?
|
||||||
|
THEN t.amount ELSE 0 END), 2) AS flooredMxn,
|
||||||
|
ROUND(SUM(CASE WHEN t.currency='USD' AND t.transactionDate >= ?
|
||||||
|
THEN t.amount ELSE 0 END), 2) AS flooredUsd
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN bfloor b ON b.customerId = t.customerId
|
||||||
|
WHERE t.voidedAt IS NULL AND t.outstanding = 0
|
||||||
|
`,
|
||||||
|
cutover,
|
||||||
|
cutover,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- section A: floorless customers ------------------------------------
|
||||||
|
const floorless = await prisma.$queryRawUnsafe(
|
||||||
|
`
|
||||||
|
WITH nobf AS (
|
||||||
|
SELECT c.id, c.name
|
||||||
|
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 n.id, n.name,
|
||||||
|
COUNT(*) AS rows_,
|
||||||
|
SUM(t.transactionDate < ?) AS preRows,
|
||||||
|
SUM(t.transactionDate >= ?) AS postRows,
|
||||||
|
MIN(t.transactionDate) AS firstTx,
|
||||||
|
MAX(t.transactionDate) AS lastTx,
|
||||||
|
SUM(t.domain = 'UTILITY') AS utilRows,
|
||||||
|
SUM(t.domain = 'INSURANCE') AS insRows,
|
||||||
|
ROUND(SUM(CASE WHEN t.currency='MXN' AND t.outstanding=0 THEN t.amount ELSE 0 END), 2) AS todayMxn,
|
||||||
|
ROUND(SUM(CASE WHEN t.currency='USD' AND t.outstanding=0 THEN t.amount ELSE 0 END), 2) AS todayUsd,
|
||||||
|
ROUND(SUM(CASE WHEN t.currency='MXN' AND t.outstanding=0 AND t.transactionDate >= ?
|
||||||
|
THEN t.amount ELSE 0 END), 2) AS afterMxn,
|
||||||
|
ROUND(SUM(CASE WHEN t.currency='USD' AND t.outstanding=0 AND t.transactionDate >= ?
|
||||||
|
THEN t.amount ELSE 0 END), 2) AS afterUsd
|
||||||
|
FROM nobf n
|
||||||
|
JOIN transactions t ON t.customerId = n.id AND t.voidedAt IS NULL
|
||||||
|
GROUP BY n.id, n.name
|
||||||
|
ORDER BY ABS(SUM(CASE WHEN t.currency='MXN' AND t.transactionDate < ? THEN t.amount ELSE 0 END)) DESC
|
||||||
|
`,
|
||||||
|
cutover, cutover, cutover, cutover, cutover,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Direction of the pre-cutover history, which is the whole argument for
|
||||||
|
// flooring rather than carrying it: a corte carries a NET, and a net built
|
||||||
|
// from receipts whose charges were never migrated is not one.
|
||||||
|
const [split] = 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 SUM(t.amount < 0) AS charges, ROUND(SUM(CASE WHEN t.amount < 0 THEN t.amount ELSE 0 END), 2) AS chargeMxn,
|
||||||
|
SUM(t.amount > 0) AS credits, ROUND(SUM(CASE WHEN t.amount > 0 THEN t.amount ELSE 0 END), 2) AS creditMxn
|
||||||
|
FROM transactions t JOIN nobf n ON n.id = t.customerId
|
||||||
|
WHERE t.voidedAt IS NULL AND t.transactionDate < ?
|
||||||
|
`,
|
||||||
|
cutover,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- section B: double-booked receipts ---------------------------------
|
||||||
|
const pairs = await prisma.$queryRawUnsafe(
|
||||||
|
`
|
||||||
|
SELECT d.id AS datos2Id, e.id AS efectivoId, c.name,
|
||||||
|
DATE(d.transactionDate) AS datos2Date, DATE(e.transactionDate) AS efectivoDate,
|
||||||
|
d.amount AS datos2Amount, d.currency AS datos2Currency,
|
||||||
|
e.amount AS efectivoAmount, e.currency AS efectivoCurrency,
|
||||||
|
d.reference AS datos2Ref, e.reference AS efectivoRef,
|
||||||
|
fx.lo AS rateLo, fx.hi AS rateHi
|
||||||
|
FROM transactions d
|
||||||
|
JOIN transactions e
|
||||||
|
ON e.customerId = d.customerId
|
||||||
|
AND e.legacySourceTable = 'EFECTIVO'
|
||||||
|
AND e.voidedAt IS NULL
|
||||||
|
AND e.reference = SUBSTRING(d.reference, 2)
|
||||||
|
JOIN customers c ON c.id = d.customerId
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT YEAR(effectiveDate) AS y, MIN(rate) AS lo, MAX(rate) AS hi
|
||||||
|
FROM exchange_rates GROUP BY YEAR(effectiveDate)
|
||||||
|
) fx ON fx.y = YEAR(d.transactionDate)
|
||||||
|
WHERE d.voidedAt IS NULL
|
||||||
|
AND d.legacySourceTable = 'datos2'
|
||||||
|
AND d.reference REGEXP '^C[0-9]+$'
|
||||||
|
ORDER BY d.transactionDate
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pass two — the leads pass one could not follow. Same shape of row, judged
|
||||||
|
// by the same money rules below, so a folio typo costs nothing.
|
||||||
|
const nearby = await prisma.$queryRawUnsafe(
|
||||||
|
`
|
||||||
|
SELECT d.id AS datos2Id, e.id AS efectivoId, c.name,
|
||||||
|
DATE(d.transactionDate) AS datos2Date, DATE(e.transactionDate) AS efectivoDate,
|
||||||
|
d.amount AS datos2Amount, d.currency AS datos2Currency,
|
||||||
|
e.amount AS efectivoAmount, e.currency AS efectivoCurrency,
|
||||||
|
d.reference AS datos2Ref, e.reference AS efectivoRef,
|
||||||
|
fx.lo AS rateLo, fx.hi AS rateHi
|
||||||
|
FROM transactions d
|
||||||
|
JOIN transactions e
|
||||||
|
ON e.customerId = d.customerId AND e.legacySourceTable = 'EFECTIVO'
|
||||||
|
AND e.voidedAt IS NULL AND e.amount > 0
|
||||||
|
AND ABS(DATEDIFF(e.transactionDate, d.transactionDate)) <= 3
|
||||||
|
JOIN customers c ON c.id = d.customerId
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT YEAR(effectiveDate) AS y, MIN(rate) AS lo, MAX(rate) AS hi
|
||||||
|
FROM exchange_rates GROUP BY YEAR(effectiveDate)
|
||||||
|
) fx ON fx.y = YEAR(d.transactionDate)
|
||||||
|
WHERE d.voidedAt IS NULL AND d.legacySourceTable = 'datos2'
|
||||||
|
AND d.reference REGEXP '^C[0-9]+$'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM transactions x
|
||||||
|
WHERE x.customerId = d.customerId AND x.legacySourceTable = 'EFECTIVO'
|
||||||
|
AND x.voidedAt IS NULL AND x.reference = SUBSTRING(d.reference, 2)
|
||||||
|
)
|
||||||
|
ORDER BY d.transactionDate
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [unpaired] = await prisma.$queryRawUnsafe(
|
||||||
|
`
|
||||||
|
SELECT COUNT(*) AS n
|
||||||
|
FROM transactions d
|
||||||
|
WHERE d.voidedAt IS NULL AND d.legacySourceTable = 'datos2'
|
||||||
|
AND d.reference REGEXP '^C[0-9]+$'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM transactions e
|
||||||
|
WHERE e.customerId = d.customerId AND e.legacySourceTable = 'EFECTIVO'
|
||||||
|
AND e.voidedAt IS NULL AND e.reference = SUBSTRING(d.reference, 2)
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- CSV escapes -------------------------------------------------------
|
||||||
|
if (args.includes("--csv-a")) return dumpCsv(floorless);
|
||||||
|
if (args.includes("--csv-b")) return dumpCsv([...pairs, ...nearby]);
|
||||||
|
|
||||||
|
// ---- report ------------------------------------------------------------
|
||||||
|
console.log("\nBOOK (voided and outstanding rows excluded)");
|
||||||
|
console.log(` raw lifetime sum, no floor ${money(book.rawMxn)} MXN ${money(book.rawUsd)} USD`);
|
||||||
|
console.log(` today (per-customer BF floor) ${money(book.todayMxn)} MXN ${money(book.todayUsd)} USD`);
|
||||||
|
console.log(` flat floor at ${cutover} ${money(book.flooredMxn)} MXN ${money(book.flooredUsd)} USD`);
|
||||||
|
|
||||||
|
const preRowsTotal = floorless.reduce((s, r) => s + d(r.preRows), 0);
|
||||||
|
const wouldZero = floorless.filter((r) => d(r.postRows) === 0);
|
||||||
|
const deltaMxn = floorless.reduce((s, r) => s + (d(r.todayMxn) - d(r.afterMxn)), 0);
|
||||||
|
const deltaUsd = floorless.reduce((s, r) => s + (d(r.todayUsd) - d(r.afterUsd)), 0);
|
||||||
|
|
||||||
|
console.log(`\nA. FLOORLESS CUSTOMERS — ${floorless.length}`);
|
||||||
|
console.log(` pre-cutover rows they still count: ${preRowsTotal}`);
|
||||||
|
console.log(` of those rows: ${d(split.charges)} charges (${d(split.chargeMxn).toFixed(2)})` +
|
||||||
|
` vs ${d(split.credits)} credits (${d(split.creditMxn).toFixed(2)})`);
|
||||||
|
console.log(` balance moved by flooring: ${money(-deltaMxn)} MXN ${money(-deltaUsd)} USD`);
|
||||||
|
console.log(` customers left with NO rows at all after the cut: ${wouldZero.length}` +
|
||||||
|
` (their balance becomes 0 — an assertion, not a migrated figure)`);
|
||||||
|
console.log(
|
||||||
|
`\n ${"customer".padEnd(30)} ${"pre".padStart(4)} ${"post".padStart(4)}` +
|
||||||
|
` ${"today MXN".padStart(13)} ${"after MXN".padStart(13)} ${"first tx".padStart(10)}`,
|
||||||
|
);
|
||||||
|
for (const r of floorless.slice(0, limit)) {
|
||||||
|
console.log(
|
||||||
|
` ${(r.name || "(sin nombre)").slice(0, 30).padEnd(30)}` +
|
||||||
|
` ${String(d(r.preRows)).padStart(4)} ${String(d(r.postRows)).padStart(4)}` +
|
||||||
|
` ${money(r.todayMxn)} ${money(r.afterMxn)} ${day(r.firstTx).padStart(10)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (floorless.length > limit) console.log(` ... ${floorless.length - limit} more (--csv-a)`);
|
||||||
|
|
||||||
|
// A folio match is a hypothesis; the money is the evidence. The band is
|
||||||
|
// widened by 10% either side of what exchange_rates observed that year,
|
||||||
|
// because the office keys receipts at its own counter rate, not at a
|
||||||
|
// published one, and a pair should not be called false over a few centavos.
|
||||||
|
const classify = (p) => {
|
||||||
|
const dAmt = d(p.datos2Amount);
|
||||||
|
const eAmt = d(p.efectivoAmount);
|
||||||
|
if (p.datos2Currency === p.efectivoCurrency) {
|
||||||
|
return Math.abs(dAmt - eAmt) < 0.005 ? "confirmed" : "suspect";
|
||||||
|
}
|
||||||
|
if (p.efectivoCurrency !== "USD" || p.datos2Currency !== "MXN") return "suspect";
|
||||||
|
if (!eAmt || !p.rateLo) return "suspect";
|
||||||
|
const implied = dAmt / eAmt;
|
||||||
|
return implied >= d(p.rateLo) * 0.9 && implied <= d(p.rateHi) * 1.1
|
||||||
|
? "confirmed"
|
||||||
|
: "suspect";
|
||||||
|
};
|
||||||
|
for (const p of pairs) {
|
||||||
|
p.pass = "folio";
|
||||||
|
p.verdict = classify(p);
|
||||||
|
}
|
||||||
|
for (const p of nearby) {
|
||||||
|
p.pass = "proximity";
|
||||||
|
p.verdict = classify(p);
|
||||||
|
}
|
||||||
|
pairs.push(...nearby);
|
||||||
|
|
||||||
|
const confirmed = pairs.filter((p) => p.verdict === "confirmed");
|
||||||
|
const suspect = pairs.filter((p) => p.verdict === "suspect");
|
||||||
|
const byCust = new Set(confirmed.map((p) => p.name));
|
||||||
|
const sameCur = confirmed.filter((p) => p.datos2Currency === p.efectivoCurrency);
|
||||||
|
const converted = confirmed.filter((p) => p.efectivoCurrency === "USD" && p.datos2Currency === "MXN");
|
||||||
|
const efecMxn = confirmed.reduce((s, p) => s + (p.efectivoCurrency === "MXN" ? d(p.efectivoAmount) : 0), 0);
|
||||||
|
const efecUsd = confirmed.reduce((s, p) => s + (p.efectivoCurrency === "USD" ? d(p.efectivoAmount) : 0), 0);
|
||||||
|
|
||||||
|
console.log(`\nB. DOUBLE-BOOKED RECEIPTS — ${confirmed.length} confirmed pairs across ${byCust.size} customers`);
|
||||||
|
const byFolio = confirmed.filter((p) => p.pass === "folio").length;
|
||||||
|
console.log(` candidates examined: ${pairs.length} (confirmed ${confirmed.length}, rejected on money ${suspect.length})`);
|
||||||
|
console.log(` found by folio cross-reference: ${byFolio}, by proximity after a folio miss: ${confirmed.length - byFolio}`);
|
||||||
|
console.log(` confirmed same-currency, amount equal to the cent: ${sameCur.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(` EFECTIVO side of the confirmed pairs: ${money(efecMxn)} MXN ${money(efecUsd)} USD`);
|
||||||
|
console.log(
|
||||||
|
`\n ${"customer".padEnd(28)} ${"datos2".padStart(10)} ${"efectivo".padStart(10)}` +
|
||||||
|
` ${"datos2 amt".padStart(13)} ${"efectivo amt".padStart(13)} ref`,
|
||||||
|
);
|
||||||
|
for (const p of confirmed.slice(0, limit)) {
|
||||||
|
console.log(
|
||||||
|
` ${(p.name || "(sin nombre)").slice(0, 28).padEnd(28)}` +
|
||||||
|
` ${day(p.datos2Date).padStart(10)} ${day(p.efectivoDate).padStart(10)}` +
|
||||||
|
` ${money(p.datos2Amount)} ${p.datos2Currency}` +
|
||||||
|
` ${money(p.efectivoAmount)} ${p.efectivoCurrency} ${p.datos2Ref}/${p.efectivoRef}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (confirmed.length > limit) console.log(` ... ${confirmed.length - limit} more (--csv-b)`);
|
||||||
|
|
||||||
|
if (suspect.length) {
|
||||||
|
console.log(`\n REJECTED — folio matched, money did not. Not duplicates on this evidence:`);
|
||||||
|
for (const p of suspect.slice(0, limit)) {
|
||||||
|
const implied =
|
||||||
|
d(p.efectivoAmount) && p.datos2Currency !== p.efectivoCurrency
|
||||||
|
? ` implied ${(d(p.datos2Amount) / d(p.efectivoAmount)).toFixed(2)}`
|
||||||
|
: "";
|
||||||
|
console.log(
|
||||||
|
` ${(p.name || "(sin nombre)").slice(0, 28).padEnd(28)}` +
|
||||||
|
` ${day(p.datos2Date).padStart(10)} ${day(p.efectivoDate).padStart(10)}` +
|
||||||
|
` ${money(p.datos2Amount)} ${p.datos2Currency}` +
|
||||||
|
` ${money(p.efectivoAmount)} ${p.efectivoCurrency} ${p.datos2Ref}${implied}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (suspect.length > limit) console.log(` ... ${suspect.length - limit} more (--csv-b)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"\nNOTE: nothing above has been changed. Section A is a proposal to move the\n" +
|
||||||
|
"floor, not a carried-forward balance: the pre-cutover charge ledger was\n" +
|
||||||
|
"never migrated, so no true opening balance can be computed from this\n" +
|
||||||
|
"database. It exists in the DreamHost per-year tables. Section B is an\n" +
|
||||||
|
"independent defect and does not need a corte to fix.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dumpCsv(rows) {
|
||||||
|
if (!rows.length) return;
|
||||||
|
const cols = Object.keys(rows[0]);
|
||||||
|
console.log(cols.join(","));
|
||||||
|
for (const r of rows) {
|
||||||
|
console.log(cols.map((c) => JSON.stringify(r[c] == null ? "" : String(r[c]))).join(","));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user