EFECTIVO is a journal, not a ledger. The office writes a numbered paper receipt for money handed over the counter and then posts that same receipt to the utilities ledger as reference `C<folio>`. Legacy summed the ledger alone — ledger_repository.php reads `datosfreak`, materialized from DATOS2 only — but the migration flattened both tables into one `transactions` table, so every balance counted each counter payment twice. Confirmed against the live legacy database rather than inferred: of the 297 receipts written in 2026, 296 carry a matching DATOS2 posting. Six of them post converted to pesos under a mistyped folio, which is why matching pairs on folio and amount found fewer duplicates than exist — and why this excludes the whole journal instead of a list of confirmed pairs. Only folio 13536 (CL 717, $400 USD) has no posting anywhere; that one wants a human. 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 would erase 55,444.95 USD and 63,957.78 MXN across 102 customers, 99 of whom have no other rows at all. Extending the qualified rule to the statement and the customer file also gives those 99 back a statement that is not empty. The same queries were missing the archive window the statement already had, so the worklist and the book also counted a closed year twice for customers floored inside an archive. Measured on production, utilities MXN: NUMid 6 and 173 unchanged to the cent, 501 unchanged at -10,874.33 (still the portal's number), 10 drops 2,362.20 -> -1,137.80 (exactly the 3,500.00 duplicate), 295 drops 14,377.46 -> 4,377.46 — which is what his statement already said. The worklist and the statement now agree, which is the point. Left alone deliberately: the movement browser, which is inventory rather than balance and should still show what was captured; and stats()'s outstanding rows, which turn on a client decision that is still open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
import { Injectable, NotFoundException } from "@nestjs/common";
|
|
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,
|
|
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 {
|
|
query?: string;
|
|
page: number;
|
|
pageSize: number;
|
|
line?: "utility" | "insurance" | "both";
|
|
includeArchived?: boolean;
|
|
}
|
|
|
|
/** Parse an optional ISO date string to a Date (or null to clear it). */
|
|
function toDate(v?: string): Date | null | undefined {
|
|
if (v === undefined) return undefined;
|
|
if (v === "" || v === null) return null;
|
|
const d = new Date(v);
|
|
return isNaN(d.getTime()) ? undefined : d;
|
|
}
|
|
|
|
@Injectable()
|
|
export class CustomersService {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
/** Unified customer list with search + business-line filter, paginated. */
|
|
async list({ query, page, pageSize, line, includeArchived }: ListParams) {
|
|
const where: Prisma.CustomerWhereInput = {};
|
|
|
|
if (!includeArchived) where.archivedAt = null;
|
|
|
|
if (query && query.trim()) {
|
|
const q = query.trim();
|
|
where.OR = [
|
|
{ name: { contains: q } },
|
|
{ email: { contains: q } },
|
|
{ phone: { contains: q } },
|
|
{ mobile: { contains: q } },
|
|
{ city: { contains: q } },
|
|
{ legacyRefs: { some: { legacyId: { contains: q } } } },
|
|
];
|
|
}
|
|
if (line === "utility") where.properties = { some: {} };
|
|
if (line === "insurance") where.policies = { some: {} };
|
|
if (line === "both") {
|
|
where.properties = { some: {} };
|
|
where.policies = { some: {} };
|
|
}
|
|
|
|
const [total, rows] = await this.prisma.$transaction([
|
|
this.prisma.customer.count({ where }),
|
|
this.prisma.customer.findMany({
|
|
where,
|
|
skip: (page - 1) * pageSize,
|
|
take: pageSize,
|
|
// Nameless records last: ordering by name alone floats every
|
|
// "(SIN NOMBRE)" to the top, since "(" sorts before every letter.
|
|
orderBy: [{ nameMissing: "asc" }, { name: "asc" }],
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
nameSource: true,
|
|
city: true,
|
|
state: true,
|
|
email: true,
|
|
phone: true,
|
|
mobile: true,
|
|
status: true,
|
|
archivedAt: true,
|
|
_count: { select: { properties: true, policies: true, transactions: true } },
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const items = rows.map((r) => ({
|
|
id: r.id,
|
|
name: r.name,
|
|
nameSource: r.nameSource,
|
|
city: r.city,
|
|
state: r.state,
|
|
email: r.email,
|
|
phone: r.phone,
|
|
mobile: r.mobile,
|
|
status: r.status,
|
|
archived: r.archivedAt != null,
|
|
propertyCount: r._count.properties,
|
|
policyCount: r._count.policies,
|
|
transactionCount: r._count.transactions,
|
|
hasUtilities: r._count.properties > 0,
|
|
hasInsurance: r._count.policies > 0,
|
|
}));
|
|
|
|
return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) };
|
|
}
|
|
|
|
/** Full unified customer view: identity + both business lines + ledger. */
|
|
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({
|
|
where: { id },
|
|
include: {
|
|
legacyRefs: true,
|
|
properties: {
|
|
include: { services: true, trustAccount: true, documents: true },
|
|
},
|
|
policies: {
|
|
orderBy: { policyFrom: "desc" },
|
|
include: {
|
|
policyType: true,
|
|
insuranceProvider: true,
|
|
installments: { orderBy: { sequence: "asc" } },
|
|
vehicles: true,
|
|
insuredDrivers: true,
|
|
beneficiaries: true,
|
|
claims: true,
|
|
documents: true,
|
|
},
|
|
},
|
|
transactions: {
|
|
// 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.
|
|
// 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 },
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!customer) {
|
|
throw new NotFoundException(`Customer ${id} not found`);
|
|
}
|
|
|
|
// 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"],
|
|
where: {
|
|
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 },
|
|
_count: { _all: true },
|
|
});
|
|
|
|
return {
|
|
...customer,
|
|
/** Calendar year the movement list covers. */
|
|
transactionYear: yearStart.getUTCFullYear(),
|
|
transactionSummary: summary.map((s) => ({
|
|
domain: s.domain,
|
|
currency: s.currency,
|
|
total: s._sum.amount,
|
|
count: s._count._all,
|
|
})),
|
|
};
|
|
}
|
|
|
|
// --- writes ---------------------------------------------------------------
|
|
|
|
private toData(dto: CreateCustomerDto | UpdateCustomerDto) {
|
|
// Whitelisted by the DTO already; map the date strings to Date objects.
|
|
const { identificationExpiration, customerSince, ...rest } = dto;
|
|
return {
|
|
...rest,
|
|
...(identificationExpiration !== undefined && {
|
|
identificationExpiration: toDate(identificationExpiration),
|
|
}),
|
|
...(customerSince !== undefined && { customerSince: toDate(customerSince) }),
|
|
};
|
|
}
|
|
|
|
async create(dto: CreateCustomerDto) {
|
|
return this.prisma.customer.create({
|
|
// App-created rows: nameMissing false (name is required), no legacy
|
|
// provenance — those columns stay null, marking a native record.
|
|
data: { ...this.toData(dto), name: dto.name, nameMissing: false },
|
|
});
|
|
}
|
|
|
|
async update(id: string, dto: UpdateCustomerDto) {
|
|
await this.ensureExists(id);
|
|
return this.prisma.customer.update({ where: { id }, data: this.toData(dto) });
|
|
}
|
|
|
|
/** Soft-delete: hide from default lists, keep the row + provenance. */
|
|
async archive(id: string) {
|
|
await this.ensureExists(id);
|
|
return this.prisma.customer.update({
|
|
where: { id },
|
|
data: { archivedAt: new Date() },
|
|
});
|
|
}
|
|
|
|
async restore(id: string) {
|
|
await this.ensureExists(id);
|
|
return this.prisma.customer.update({
|
|
where: { id },
|
|
data: { archivedAt: null },
|
|
});
|
|
}
|
|
|
|
private async ensureExists(id: string) {
|
|
const found = await this.prisma.customer.findUnique({
|
|
where: { id },
|
|
select: { id: true },
|
|
});
|
|
if (!found) throw new NotFoundException(`Customer ${id} not found`);
|
|
}
|
|
|
|
/** Top-line counts for a dashboard header. */
|
|
async stats() {
|
|
const [customers, withUtilities, withInsurance, policies, properties, transactions] =
|
|
await this.prisma.$transaction([
|
|
this.prisma.customer.count(),
|
|
this.prisma.customer.count({ where: { properties: { some: {} } } }),
|
|
this.prisma.customer.count({ where: { policies: { some: {} } } }),
|
|
this.prisma.policy.count(),
|
|
this.prisma.property.count(),
|
|
this.prisma.transaction.count(),
|
|
]);
|
|
const bothLines = await this.prisma.customer.count({
|
|
where: { properties: { some: {} }, policies: { some: {} } },
|
|
});
|
|
return { customers, withUtilities, withInsurance, bothLines, policies, properties, transactions };
|
|
}
|
|
}
|