import { Injectable, NotFoundException } from "@nestjs/common"; import { randomUUID } from "node:crypto"; import { Prisma, ServiceKind } from "@jorgecuadros/database"; import { PrismaService } from "../prisma/prisma.service"; import { StorageService } from "../storage/storage.service"; import { extForUpload } from "../storage/upload-file"; import { toDate } from "../common/coerce"; import { CreatePropertyDto, ServiceDto, TrustDto, UpdatePropertyDto, UpdateServiceDto, } from "./property.dto"; /** * 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; includeArchived?: boolean; 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 readonly storage: StorageService, ) {} 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, includeArchived, sort, } = params; const and: Prisma.PropertyWhereInput[] = [this.trustWhere(trust, days)]; if (!includeArchived) and.push({ archivedAt: null }); // 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, archivedAt: 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, archived: r.archivedAt != null, 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", voidedAt: null }, _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, })), }; } // --- property header writes ----------------------------------------------- async create(dto: CreatePropertyDto) { const customer = await this.prisma.customer.findUnique({ where: { id: dto.customerId }, select: { id: true }, }); if (!customer) throw new NotFoundException(`Customer ${dto.customerId} not found`); return this.prisma.property.create({ data: { ...dto } }); } async update(id: string, dto: UpdatePropertyDto) { await this.ensureProperty(id); return this.prisma.property.update({ where: { id }, data: { ...dto } }); } async archive(id: string) { await this.ensureProperty(id); return this.prisma.property.update({ where: { id }, data: { archivedAt: new Date() } }); } async restore(id: string) { await this.ensureProperty(id); return this.prisma.property.update({ where: { id }, data: { archivedAt: null } }); } private async ensureProperty(id: string) { const found = await this.prisma.property.findUnique({ where: { id }, select: { id: true }, }); if (!found) throw new NotFoundException(`Property ${id} not found`); } private async ensureService(propertyId: string, serviceId: string) { await this.ensureProperty(propertyId); const row = await this.prisma.propertyService.findFirst({ where: { id: serviceId, propertyId }, select: { id: true }, }); if (!row) throw new NotFoundException(`Service ${serviceId} not found on property ${propertyId}`); } // --- services ------------------------------------------------------------- async addService(propertyId: string, dto: ServiceDto) { await this.ensureProperty(propertyId); return this.prisma.propertyService.create({ data: { propertyId, ...dto } }); } async updateService(propertyId: string, id: string, dto: UpdateServiceDto) { await this.ensureService(propertyId, id); return this.prisma.propertyService.update({ where: { id }, data: { ...dto } }); } async removeService(propertyId: string, id: string) { await this.ensureService(propertyId, id); return this.prisma.propertyService.delete({ where: { id } }); } // --- trust account (1:1 upsert) ------------------------------------------- async upsertTrust(propertyId: string, dto: TrustDto) { await this.ensureProperty(propertyId); const data = { bankName: dto.bankName, trustNumber: dto.trustNumber, bankFee: dto.bankFee, ...(dto.dueDate1 !== undefined && { dueDate1: toDate(dto.dueDate1) }), ...(dto.dueDate2 !== undefined && { dueDate2: toDate(dto.dueDate2) }), }; return this.prisma.trustAccount.upsert({ where: { propertyId }, create: { propertyId, ...data }, update: data, }); } async removeTrust(propertyId: string) { await this.ensureProperty(propertyId); const existing = await this.prisma.trustAccount.findUnique({ where: { propertyId }, select: { id: true }, }); if (!existing) throw new NotFoundException(`No trust account on property ${propertyId}`); return this.prisma.trustAccount.delete({ where: { propertyId } }); } // --- documents ------------------------------------------------------------ // The blob lives in object storage (MinIO); the row is just the pointer. Keys // stay under the `service//…` prefix the migration established. async addDocument( propertyId: string, file: { buffer: Buffer; originalname?: string; mimetype?: string }, documentType?: string, ) { await this.ensureProperty(propertyId); const ext = extForUpload(file); const key = `service/${propertyId}/${randomUUID()}${ext}`; await this.storage.put(key, file.buffer, file.mimetype); return this.prisma.serviceDocument.create({ data: { propertyId, documentType: documentType?.trim() || "DOCUMENT", storageKey: key, }, }); } async getDocument(propertyId: string, id: string) { const row = await this.prisma.serviceDocument.findFirst({ where: { id, propertyId }, }); if (!row) throw new NotFoundException(`Document ${id} not found on property ${propertyId}`); const blob = await this.storage.getStream(row.storageKey); return { row, ...blob }; } async removeDocument(propertyId: string, id: string) { await this.ensureProperty(propertyId); const row = await this.prisma.serviceDocument.findFirst({ where: { id, propertyId }, select: { id: true, storageKey: true }, }); if (!row) throw new NotFoundException(`Document ${id} not found on property ${propertyId}`); const deleted = await this.prisma.serviceDocument.delete({ where: { id } }); await this.storage.delete(row.storageKey); return deleted; } }