diff --git a/apps/api/src/billing/balance-floor.spec.ts b/apps/api/src/billing/balance-floor.spec.ts index 447a2e1..5113dd6 100644 --- a/apps/api/src/billing/balance-floor.spec.ts +++ b/apps/api/src/billing/balance-floor.spec.ts @@ -159,7 +159,16 @@ describe("balance floor", () => { const where = rowsQuery(findMany).where; expect(where.OR).toEqual([ { legacySourceTable: null }, - { legacySourceTable: { notIn: expect.arrayContaining(["EFECTIVO"]) } }, + { + legacySourceTable: { + notIn: expect.arrayContaining(["EFECTIVO"]), + // Imported prior periods are excluded here too. The floor does not + // cover them: a customer whose newest BALANCE FORWARD sits inside + // an archive floors at that archive's own Jan 1, and every archive + // spills a row or two into the following January. + not: { startsWith: "datos2@" }, + }, + }, ]); }); }); diff --git a/apps/api/src/billing/billing.service.ts b/apps/api/src/billing/billing.service.ts index 77a0f6a..e7aab71 100644 --- a/apps/api/src/billing/billing.service.ts +++ b/apps/api/src/billing/billing.service.ts @@ -233,9 +233,9 @@ export const NOT_SUPERSEDED = Prisma.sql`(bfloor.floorDate IS NULL OR t.transact export const periodSourceTable = (year: number) => `datos2@${year}`; /** Matches any imported period tag, for discovering which years a customer has. */ -const PERIOD_TABLE_PREFIX = "datos2@"; +export const PERIOD_TABLE_PREFIX = "datos2@"; -const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [ +export const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [ "EFECTIVO", "EFECTIVO_BACKUP", "EFECTIVO FM3", @@ -830,6 +830,13 @@ export class BillingService { { legacySourceTable: { notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[], + // The archives have to go too, and the floor will not do + // it. A customer whose newest BALANCE FORWARD lives inside + // an archive floors at that archive's own Jan 1, so all 28 + // of its rows clear it; and the archives spill rows into + // the following January, which clears any floor. Both put a + // closed year back into the current one. + not: { startsWith: PERIOD_TABLE_PREFIX }, }, }, ], diff --git a/apps/api/src/customers/customer-ledger-card.spec.ts b/apps/api/src/customers/customer-ledger-card.spec.ts new file mode 100644 index 0000000..06f451a --- /dev/null +++ b/apps/api/src/customers/customer-ledger-card.spec.ts @@ -0,0 +1,120 @@ +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("excludes imported periods from the totals", async () => { + // The floor alone is not enough: it does not exist for the floorless, and + // archives carry rows dated past their own period that clear it. + const { service, groupBy } = serviceWith(new Date("2026-01-01T00:00:00Z")); + + await service.detail("c1"); + + const and = groupBy.mock.calls[0][0].where.AND; + expect(and).toEqual( + expect.arrayContaining([ + { + OR: [ + { legacySourceTable: null }, + { legacySourceTable: { not: { startsWith: "datos2@" } } }, + ], + }, + ]), + ); + }); + + 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).toMatchObject({ + OR: [ + { legacySourceTable: null }, + { legacySourceTable: { not: { startsWith: "datos2@" } } }, + ], + }); + }); +}); diff --git a/apps/api/src/customers/customers.service.ts b/apps/api/src/customers/customers.service.ts index 22af4b3..50d0d20 100644 --- a/apps/api/src/customers/customers.service.ts +++ b/apps/api/src/customers/customers.service.ts @@ -3,6 +3,29 @@ import { Prisma } from "@jorgecuadros/database"; import { PrismaService } from "../prisma/prisma.service"; import { CreateCustomerDto } from "./create-customer.dto"; import { UpdateCustomerDto } from "./update-customer.dto"; +import { + BALANCE_FORWARD_TYPE, + PERIOD_TABLE_PREFIX, + STATEMENT_EXCLUDED_SOURCE_TABLES, +} from "../billing/billing.service"; + +/** + * Keeps imported prior periods out of a query, NULL-safely. + * + * A closed year is imported as its own tagged copy of that year's ledger + * (`datos2@2025`) and the BALANCE FORWARD rows above it already contain every + * peso of it. Anything summing a customer's history has to leave the archives + * out or it counts each closed year twice — see the floor comment below. + * + * `NULL NOT LIKE '...'` is NULL rather than true, so app-captured rows (which + * carry no legacySourceTable) need the null branch spelled out or they vanish. + */ +const EXCLUDE_ARCHIVES: Prisma.TransactionWhereInput = { + OR: [ + { legacySourceTable: null }, + { legacySourceTable: { not: { startsWith: PERIOD_TABLE_PREFIX } } }, + ], +}; export interface ListParams { query?: string; @@ -124,7 +147,12 @@ export class CustomersService { }, }, transactions: { - where: { transactionDate: { gte: yearStart } }, + // Archives are excluded by tag, not by date. They are not cleanly + // 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. + where: { transactionDate: { gte: yearStart }, ...EXCLUDE_ARCHIVES }, orderBy: [{ transactionDate: "asc" }, { id: "asc" }], include: { type: true }, }, @@ -137,10 +165,56 @@ export class CustomersService { // Ledger totals per domain + currency (the "one statement across both // 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({ by: ["domain", "currency"], - // Exclude voided rows so the per-domain balance matches the statement. - where: { customerId: id, voidedAt: null }, + where: { + customerId: id, + voidedAt: null, + outstanding: false, + ...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}), + // The floor alone would leave the archives out for anyone who has an + // opening balance, but 102 customers have none — for them there is no + // floor at all, and the archives' rows dated past their own period + // clear it even for the rest. + AND: [ + EXCLUDE_ARCHIVES, + { + OR: [ + { legacySourceTable: null }, + { + legacySourceTable: { + notIn: [...STATEMENT_EXCLUDED_SOURCE_TABLES], + }, + }, + ], + }, + ], + }, _sum: { amount: true }, _count: { _all: true }, }); diff --git a/apps/api/src/reports/reports.registry.ts b/apps/api/src/reports/reports.registry.ts index df7af4a..7398874 100644 --- a/apps/api/src/reports/reports.registry.ts +++ b/apps/api/src/reports/reports.registry.ts @@ -849,6 +849,9 @@ const edoCuentaDatos: ReportDef = { "CHEQUE FM3", "IVA 2015", ], + // Archives out of the current period too — the balance + // floor does not exclude them (see the on-screen twin). + not: { startsWith: "datos2@" }, }, }, ], diff --git a/apps/web/src/app/clientes/[id]/page.tsx b/apps/web/src/app/clientes/[id]/page.tsx index 8d7c3dd..00d8f31 100644 --- a/apps/web/src/app/clientes/[id]/page.tsx +++ b/apps/web/src/app/clientes/[id]/page.tsx @@ -809,7 +809,8 @@ function EstadoCuentaSection({ )}