Utilities module: property browser (list/search/detail) + trust renewals

Plan step 5. Properties, services and trust accounts become a first-class
browser the way /polizas is for insurance.

API (apps/api/src/properties):
  GET /properties         search over address, customer, service account
                          number, meter, trust number and phones; filters for
                          service kind, municipality, trust bank, trust bucket
                          (with|without|active|expiring|expired|undated) and
                          hasServices; 5 sorts
  GET /properties/stats   properties/owners/services/trusts, renewal counts,
                          service mix per kind
  GET /properties/facets  kinds, municipalities, banks — all with counts
  GET /properties/:id     services, fideicomiso, linked policy, owner and
                          sibling properties, owner-level utility ledger

Web: /servicios (renewals-first browser, clickable stat cells and service-mix
strip) and /servicios/[id]. Property cards on /clientes/[id] and linked
properties on /polizas/[id] now navigate into it.

Data findings baked into the design:
  - The trust deadline staff chase is trust_accounts.dueDate2 (DATMEX vence2),
    one year after vence1 on 531 of 541 dated trusts: 18 due within 30 days,
    119 already overdue. Every renewal bucket keys off dueDate2 alone.
  - properties.zone is dead (1444 of 1519 null, the rest near-unique), so the
    geographic filter is the municipality carried in the predial service's
    notes (ROSARITO 566 / TIJUANA 221 / ENSENADA 152, 939/939 populated).
  - PropertyService.notes means a different thing per kind (municipality, CFE
    PAR/IMPAR cycle, gas supply type, cable provider) and is labelled as such.
  - 240 of 1519 properties have no service rows at all — its own bucket.

Sorting by trust due date scopes to properties that have a trust, since MySQL
would otherwise float the ~966 trust-less NULLs above every real due date;
the sort label and the result meta both say so.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 22:04:07 -07:00
co-authored by Claude Opus 4.8
parent c291bc8d4c
commit 61193586a5
14 changed files with 2086 additions and 7 deletions
+2
View File
@@ -5,6 +5,7 @@ import { UsersModule } from "./users/users.module";
import { AuthModule } from "./auth/auth.module";
import { CustomersModule } from "./customers/customers.module";
import { PoliciesModule } from "./policies/policies.module";
import { PropertiesModule } from "./properties/properties.module";
import { AppController } from "./app.controller";
@Module({
@@ -15,6 +16,7 @@ import { AppController } from "./app.controller";
AuthModule,
CustomersModule,
PoliciesModule,
PropertiesModule,
],
controllers: [AppController],
})
@@ -0,0 +1,98 @@
import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common";
import { ServiceKind } from "@jorgecuadros/database";
import { AuthenticatedGuard } from "../auth/authenticated.guard";
import {
PropertiesService,
type PropertySort,
type TrustFilter,
} from "./properties.service";
const KINDS: ServiceKind[] = [
"WATER",
"ELECTRIC",
"GAS",
"CABLE",
"PROPERTY_TAX",
"FEDERAL_ZONE",
"ALARM",
"OTHER",
];
const TRUST_FILTERS: TrustFilter[] = [
"with",
"without",
"active",
"expiring",
"expired",
"undated",
];
const SORTS: PropertySort[] = [
"customer",
"address",
"services_desc",
"trust_due_asc",
"trust_due_desc",
];
/** Clamped trust-renewal window; 30 days matches the policies module. */
function parseDays(days?: string): number {
return Math.min(365, Math.max(1, Number(days) || 30));
}
@UseGuards(AuthenticatedGuard)
@Controller("properties")
export class PropertiesController {
constructor(private readonly properties: PropertiesService) {}
@Get("stats")
stats(@Query("days") days?: string) {
return this.properties.stats(parseDays(days));
}
@Get("facets")
facets() {
return this.properties.facets();
}
@Get()
list(
@Query("query") query?: string,
@Query("page") page?: string,
@Query("pageSize") pageSize?: string,
@Query("serviceKind") serviceKind?: string,
@Query("municipality") municipality?: string,
@Query("bank") bank?: string,
@Query("trust") trust?: string,
@Query("hasServices") hasServices?: string,
@Query("customerId") customerId?: string,
@Query("days") days?: string,
@Query("sort") sort?: string,
) {
return this.properties.list({
query,
page: Math.max(1, Number(page) || 1),
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
serviceKind: KINDS.includes(serviceKind as ServiceKind)
? (serviceKind as ServiceKind)
: undefined,
municipality: municipality || undefined,
bank: bank || undefined,
trust: TRUST_FILTERS.includes(trust as TrustFilter)
? (trust as TrustFilter)
: undefined,
hasServices:
hasServices === "true" ? true : hasServices === "false" ? false : undefined,
customerId: customerId || undefined,
days: parseDays(days),
sort: SORTS.includes(sort as PropertySort)
? (sort as PropertySort)
: "customer",
});
}
@Get(":id")
detail(@Param("id") id: string, @Query("days") days?: string) {
return this.properties.detail(id, parseDays(days));
}
}
@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { PropertiesController } from "./properties.controller";
import { PropertiesService } from "./properties.service";
@Module({
controllers: [PropertiesController],
providers: [PropertiesService],
})
export class PropertiesModule {}
@@ -0,0 +1,446 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma, ServiceKind } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
/**
* Trust (fideicomiso) renewal buckets, derived from `trustAccount.dueDate2`
* against today. The migration loaded DATMEX's `vence1`/`vence2` pair as
* `dueDate1`/`dueDate2`; on 531 of the 541 dated trusts `dueDate2` is exactly
* one year after `dueDate1`, so `dueDate2` is the *next* annual due date — the
* one staff chase — and `dueDate1` is the period it renewed from.
*
* `undated` is a real bucket, not an error: 12 trusts carry no dates at all.
*/
export type TrustStatus = "active" | "expiring" | "expired" | "undated";
/** `with`/`without` filter on the whole property set; the rest are trust buckets. */
export type TrustFilter = "with" | "without" | TrustStatus;
export type PropertySort =
| "customer"
| "address"
| "services_desc"
| "trust_due_asc"
| "trust_due_desc";
export interface ListParams {
query?: string;
page: number;
pageSize: number;
serviceKind?: ServiceKind;
/** Municipality from the predial service's notes — see `facets()`. */
municipality?: string;
bank?: string;
trust?: TrustFilter;
/** false = properties with no service rows at all (240 of 1519). */
hasServices?: boolean;
customerId?: string;
/** Window in days for the `expiring` trust bucket. */
days: number;
sort: PropertySort;
}
/** Municipality lives in the predial service's `notes` (939/939 populated,
* exactly three values). FEDERAL_ZONE's notes hold the same idea but also
* carry non-municipality values like "SUSPENDIDO", so predial is the source. */
const MUNICIPALITY_KIND: ServiceKind = "PROPERTY_TAX";
/** Midnight today, UTC — trust dates are stored date-only at 00:00 UTC. */
function today(): Date {
const now = new Date();
return new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
);
}
function addDays(d: Date, days: number): Date {
return new Date(d.getTime() + days * 86400000);
}
function trustStatusOf(
dueDate: Date | null | undefined,
from: Date,
soon: Date,
): TrustStatus {
if (!dueDate) return "undated";
if (dueDate < from) return "expired";
return dueDate <= soon ? "expiring" : "active";
}
function daysUntil(dueDate: Date | null | undefined, from: Date): number | null {
if (!dueDate) return null;
return Math.round((dueDate.getTime() - from.getTime()) / 86400000);
}
@Injectable()
export class PropertiesService {
constructor(private readonly prisma: PrismaService) {}
private trustWhere(
trust: TrustFilter | undefined,
days: number,
): Prisma.PropertyWhereInput {
const from = today();
switch (trust) {
case "with":
return { trustAccount: { isNot: null } };
case "without":
return { trustAccount: { is: null } };
case "active":
return { trustAccount: { dueDate2: { gte: from } } };
case "expiring":
return {
trustAccount: { dueDate2: { gte: from, lte: addDays(from, days) } },
};
case "expired":
return { trustAccount: { dueDate2: { lt: from } } };
case "undated":
return { trustAccount: { is: { dueDate2: null } } };
default:
return {};
}
}
private orderBy(sort: PropertySort): Prisma.PropertyOrderByWithRelationInput[] {
switch (sort) {
case "address":
return [{ addressLine1: "asc" }, { addressLine2: "asc" }];
case "services_desc":
return [{ services: { _count: "desc" } }, { customer: { name: "asc" } }];
case "trust_due_asc":
return [{ trustAccount: { dueDate2: "asc" } }];
case "trust_due_desc":
return [{ trustAccount: { dueDate2: "desc" } }];
default:
// Nameless customers last, same rule the customer list uses.
return [
{ customer: { nameMissing: "asc" } },
{ customer: { name: "asc" } },
{ addressLine1: "asc" },
];
}
}
/** Property list with search, service/trust/municipality filters, paginated. */
async list(params: ListParams) {
const {
query,
page,
pageSize,
serviceKind,
municipality,
bank,
trust,
hasServices,
customerId,
days,
sort,
} = params;
const and: Prisma.PropertyWhereInput[] = [this.trustWhere(trust, days)];
// Sorting by trust due date is only meaningful for properties that have a
// trust; MySQL would otherwise float the ~966 trust-less rows (NULL first
// on ASC) above every real due date. Scoping is explicit in the UI label.
if (sort === "trust_due_asc" || sort === "trust_due_desc") {
and.push({ trustAccount: { isNot: null } });
}
if (query && query.trim()) {
const q = query.trim();
and.push({
OR: [
{ addressLine1: { contains: q } },
{ addressLine2: { contains: q } },
{ phone1: { contains: q } },
{ phone2: { contains: q } },
{ phone3: { contains: q } },
{ zone: { contains: q } },
{ legacyId: { contains: q } },
{ customer: { name: { contains: q } } },
{ services: { some: { accountNumber: { contains: q } } } },
{ services: { some: { meterNumber: { contains: q } } } },
{ trustAccount: { trustNumber: { contains: q } } },
],
});
}
if (serviceKind) and.push({ services: { some: { kind: serviceKind } } });
if (municipality)
and.push({
services: { some: { kind: MUNICIPALITY_KIND, notes: municipality } },
});
if (bank) and.push({ trustAccount: { bankName: bank } });
if (hasServices !== undefined)
and.push(hasServices ? { services: { some: {} } } : { services: { none: {} } });
if (customerId) and.push({ customerId });
const where: Prisma.PropertyWhereInput = { AND: and };
const [total, rows] = await this.prisma.$transaction([
this.prisma.property.count({ where }),
this.prisma.property.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: this.orderBy(sort),
select: {
id: true,
addressLine1: true,
addressLine2: true,
phone1: true,
phone2: true,
phone3: true,
zone: true,
customer: {
select: { id: true, name: true, city: true, state: true },
},
services: {
select: { id: true, kind: true, active: true, notes: true },
},
trustAccount: {
select: {
bankName: true,
trustNumber: true,
bankFee: true,
dueDate1: true,
dueDate2: true,
},
},
_count: { select: { services: true, documents: true } },
},
}),
]);
const from = today();
const soon = addDays(from, days);
const items = rows.map((r) => {
const predial = r.services.find((s) => s.kind === MUNICIPALITY_KIND);
return {
id: r.id,
addressLine1: r.addressLine1,
addressLine2: r.addressLine2,
zone: r.zone,
phones: [r.phone1, r.phone2, r.phone3].filter(Boolean) as string[],
customerId: r.customer.id,
customerName: r.customer.name,
customerCity: r.customer.city,
customerState: r.customer.state,
municipality: predial?.notes ?? null,
services: r.services.map((s) => ({
id: s.id,
kind: s.kind,
active: s.active,
})),
serviceCount: r._count.services,
activeServiceCount: r.services.filter((s) => s.active).length,
documentCount: r._count.documents,
trust: r.trustAccount
? {
bankName: r.trustAccount.bankName,
trustNumber: r.trustAccount.trustNumber,
bankFee: r.trustAccount.bankFee,
dueDate1: r.trustAccount.dueDate1,
dueDate2: r.trustAccount.dueDate2,
status: trustStatusOf(r.trustAccount.dueDate2, from, soon),
daysToDue: daysUntil(r.trustAccount.dueDate2, from),
}
: null,
};
});
return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) };
}
/** Top-line counts for the utilities page header. */
async stats(days: number) {
const from = today();
const soon = addDays(from, days);
const [
properties,
owners,
services,
withoutServices,
trusts,
trustExpiring,
trustExpired,
documents,
] = await this.prisma.$transaction([
this.prisma.property.count(),
this.prisma.customer.count({ where: { properties: { some: {} } } }),
this.prisma.propertyService.count(),
this.prisma.property.count({ where: { services: { none: {} } } }),
this.prisma.property.count({ where: { trustAccount: { isNot: null } } }),
this.prisma.property.count({
where: { trustAccount: { dueDate2: { gte: from, lte: soon } } },
}),
this.prisma.property.count({
where: { trustAccount: { dueDate2: { lt: from } } },
}),
this.prisma.serviceDocument.count(),
]);
// Service mix, per kind — the operational headline for this line of
// business (how many bills of each type the office pays every month).
const byKind = await this.prisma.propertyService.groupBy({
by: ["kind"],
_count: { _all: true },
orderBy: { _count: { kind: "desc" } },
});
const activeByKind = await this.prisma.propertyService.groupBy({
by: ["kind"],
where: { active: true },
_count: { _all: true },
});
const activeMap = new Map(activeByKind.map((r) => [r.kind, r._count._all]));
return {
properties,
owners,
services,
withoutServices,
trusts,
trustExpiring,
trustExpired,
documents,
days,
byKind: byKind.map((r) => ({
kind: r.kind,
count: r._count._all,
active: activeMap.get(r.kind) ?? 0,
})),
};
}
/** Filter dropdown options, with counts so empty choices are visible. */
async facets() {
// Kept as separate awaits rather than one $transaction: Prisma's groupBy
// result type is lost when the calls are widened into a promise array.
const kinds = await this.prisma.propertyService.groupBy({
by: ["kind"],
_count: { _all: true },
orderBy: { _count: { kind: "desc" } },
});
const municipalities = await this.prisma.propertyService.groupBy({
by: ["notes"],
where: { kind: MUNICIPALITY_KIND, notes: { not: null } },
_count: { _all: true },
orderBy: { _count: { notes: "desc" } },
});
const banks = await this.prisma.trustAccount.groupBy({
by: ["bankName"],
where: { bankName: { not: null } },
_count: { _all: true },
orderBy: { _count: { bankName: "desc" } },
});
return {
kinds: kinds.map((k) => ({ kind: k.kind, count: k._count._all })),
municipalities: municipalities.map((m) => ({
name: m.notes as string,
count: m._count._all,
})),
banks: banks.map((b) => ({
name: b.bankName as string,
count: b._count._all,
})),
};
}
/** Full property view: services, trust, documents, owner and siblings. */
async detail(id: string, days: number) {
const property = await this.prisma.property.findUnique({
where: { id },
include: {
customer: {
select: {
id: true,
name: true,
nameSource: true,
addressLine1: true,
city: true,
state: true,
phone: true,
mobile: true,
email: true,
_count: { select: { properties: true, policies: true } },
},
},
services: { orderBy: { kind: "asc" } },
trustAccount: true,
documents: true,
policy: {
select: {
id: true,
policyNumber: true,
policyTo: true,
policyType: { select: { name: true } },
},
},
},
});
if (!property) {
throw new NotFoundException(`Property ${id} not found`);
}
// Other properties of the same owner, so staff can hop between them
// without going back through the customer file.
const siblings = await this.prisma.property.findMany({
where: { customerId: property.customerId, id: { not: id } },
orderBy: [{ addressLine1: "asc" }],
select: {
id: true,
addressLine1: true,
addressLine2: true,
zone: true,
_count: { select: { services: true } },
},
});
// Utility-domain ledger for the OWNER, not for this property: the legacy
// data ties payments to the customer, never to a specific property, so
// these are shown as the customer's service movements.
const transactions = await this.prisma.transaction.findMany({
where: { customerId: property.customerId, domain: "UTILITY" },
orderBy: { transactionDate: "desc" },
take: 12,
include: { type: true },
});
const ledger = await this.prisma.transaction.groupBy({
by: ["currency"],
where: { customerId: property.customerId, domain: "UTILITY" },
_sum: { amount: true },
_count: { _all: true },
});
const from = today();
const predial = property.services.find((s) => s.kind === MUNICIPALITY_KIND);
return {
...property,
municipality: predial?.notes ?? null,
trustStatus: trustStatusOf(
property.trustAccount?.dueDate2,
from,
addDays(from, days),
),
daysToTrustDue: daysUntil(property.trustAccount?.dueDate2, from),
siblings: siblings.map((s) => ({
id: s.id,
addressLine1: s.addressLine1,
addressLine2: s.addressLine2,
zone: s.zone,
serviceCount: s._count.services,
})),
customerTransactions: transactions,
customerLedger: ledger.map((l) => ({
currency: l.currency,
total: l._sum.amount,
count: l._count._all,
})),
};
}
}