Insurance module: policy browser (list/search/detail) + renewals view

Plan step 4. Adds the policies API and the Spanish-first /polizas pages on
top of the customer records the customer module already exposes.

API (apps/api/src/policies):
- GET /policies — search over policy number, customer name, agent, vehicle
  license plate, insured-driver name and legacy id; filters for vigencia
  bucket, ramo, aseguradora and liquidation state; five sort orders.
- GET /policies/stats — bucket counts plus premium in force split by
  currency (MXN and USD can't be summed).
- GET /policies/facets — ramos/aseguradoras with counts for the dropdowns.
- GET /policies/:id — full policy plus the owning customer.

Vigencia is derived from policyTo as active/expiring/expired/undated.
"undated" is a real bucket rather than an error case: 528 of the 2378
migrated policies carry no end date at all.

Web:
- /polizas — renewals-first browser; the stat cells double as vigencia
  filters, with a secondary row for ramo, aseguradora and sort order.
- /polizas/[id] — vigencia hero, condiciones y primas, pagos, vehículos,
  asegurados/beneficiarios, siniestros, the verbatim legacy coverage
  columns, and documents.
- Nav gains Clientes | Pólizas with a real active state, and the two
  modules cross-link in both directions.

Also fixes a display bug on the customer detail page: it headlined
policies.total, which is dead data — only 2 of 2378 rows are non-zero
(1585 are literally 0, 791 null), and one of those two is lower than its
own net premium. That rendered "$0.00 Total" on 1585 policies. Premium
headlines and the premium sort now use netPremium (2377/2378 populated);
total is shown only where it is non-zero, as raw source data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 21:27:38 -07:00
co-authored by Claude Opus 4.8
parent fa9b696752
commit e2aba8bd17
13 changed files with 1957 additions and 16 deletions
+2
View File
@@ -4,6 +4,7 @@ import { PrismaModule } from "./prisma/prisma.module";
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 { AppController } from "./app.controller";
@Module({
@@ -13,6 +14,7 @@ import { AppController } from "./app.controller";
UsersModule,
AuthModule,
CustomersModule,
PoliciesModule,
],
controllers: [AppController],
})
@@ -0,0 +1,72 @@
import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common";
import { AuthenticatedGuard } from "../auth/authenticated.guard";
import {
PoliciesService,
type PolicySort,
type PolicyStatus,
} from "./policies.service";
const STATUSES: PolicyStatus[] = ["active", "expiring", "expired", "undated"];
const SORTS: PolicySort[] = [
"expiry_desc",
"expiry_asc",
"customer",
"number",
"premium_desc",
];
/** Clamped expiry window; 30 days is the default renewal horizon. */
function parseDays(days?: string): number {
return Math.min(365, Math.max(1, Number(days) || 30));
}
@UseGuards(AuthenticatedGuard)
@Controller("policies")
export class PoliciesController {
constructor(private readonly policies: PoliciesService) {}
@Get("stats")
stats(@Query("days") days?: string) {
return this.policies.stats(parseDays(days));
}
@Get("facets")
facets() {
return this.policies.facets();
}
@Get()
list(
@Query("query") query?: string,
@Query("page") page?: string,
@Query("pageSize") pageSize?: string,
@Query("status") status?: string,
@Query("days") days?: string,
@Query("typeId") typeId?: string,
@Query("providerId") providerId?: string,
@Query("liquidated") liquidated?: string,
@Query("sort") sort?: string,
) {
return this.policies.list({
query,
page: Math.max(1, Number(page) || 1),
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
status: STATUSES.includes(status as PolicyStatus)
? (status as PolicyStatus)
: undefined,
days: parseDays(days),
typeId: typeId || undefined,
providerId: providerId || undefined,
liquidated:
liquidated === "true" ? true : liquidated === "false" ? false : undefined,
sort: SORTS.includes(sort as PolicySort)
? (sort as PolicySort)
: "expiry_desc",
});
}
@Get(":id")
detail(@Param("id") id: string, @Query("days") days?: string) {
return this.policies.detail(id, parseDays(days));
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { PoliciesController } from "./policies.controller";
import { PoliciesService } from "./policies.service";
@Module({
controllers: [PoliciesController],
providers: [PoliciesService],
})
export class PoliciesModule {}
+281
View File
@@ -0,0 +1,281 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
/**
* Vigencia buckets, derived from `policyTo` against today. `undated` is a real
* bucket rather than an error case: 528 of the migrated policies carry no end
* date at all (the legacy Access tables left it blank), so they can neither be
* called current nor expired.
*/
export type PolicyStatus = "active" | "expiring" | "expired" | "undated";
export type PolicySort =
| "expiry_desc"
| "expiry_asc"
| "customer"
| "number"
| "premium_desc";
export interface ListParams {
query?: string;
page: number;
pageSize: number;
status?: PolicyStatus;
/** Window in days for the `expiring` bucket. */
days: number;
typeId?: string;
providerId?: string;
liquidated?: boolean;
sort: PolicySort;
}
/** Midnight today, UTC — policy 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 statusOf(policyTo: Date | null, from: Date, soon: Date): PolicyStatus {
if (!policyTo) return "undated";
if (policyTo < from) return "expired";
return policyTo <= soon ? "expiring" : "active";
}
function daysUntil(policyTo: Date | null, from: Date): number | null {
if (!policyTo) return null;
return Math.round((policyTo.getTime() - from.getTime()) / 86400000);
}
@Injectable()
export class PoliciesService {
constructor(private readonly prisma: PrismaService) {}
private statusWhere(
status: PolicyStatus | undefined,
days: number,
): Prisma.PolicyWhereInput {
const from = today();
switch (status) {
case "active":
return { policyTo: { gte: from } };
case "expiring":
return { policyTo: { gte: from, lte: addDays(from, days) } };
case "expired":
return { policyTo: { lt: from } };
case "undated":
return { policyTo: null };
default:
return {};
}
}
private orderBy(sort: PolicySort): Prisma.PolicyOrderByWithRelationInput[] {
switch (sort) {
case "expiry_asc":
return [{ policyTo: "asc" }];
case "customer":
return [{ customer: { name: "asc" } }, { policyTo: "desc" }];
case "number":
return [{ policyNumber: "asc" }];
case "premium_desc":
// Sorts on netPremium, not total: `total` is 0 or null on all but 2 of
// the 2378 migrated policies, so ordering by it is meaningless.
return [{ netPremium: "desc" }];
default:
// MySQL sorts NULLs last on DESC, which puts the 528 undated policies
// at the end instead of the top — the behaviour we want by default.
return [{ policyTo: "desc" }];
}
}
/** Policy list with search, vigencia/type/provider filters, paginated. */
async list(params: ListParams) {
const { query, page, pageSize, status, days, typeId, providerId, liquidated, sort } =
params;
const where: Prisma.PolicyWhereInput = { ...this.statusWhere(status, days) };
if (query && query.trim()) {
const q = query.trim();
where.OR = [
{ policyNumber: { contains: q } },
{ customer: { name: { contains: q } } },
{ agentName: { contains: q } },
{ vehicles: { some: { licensePlate: { contains: q } } } },
{ insuredDrivers: { some: { fullName: { contains: q } } } },
{ legacyId: { contains: q } },
];
}
if (typeId) where.policyTypeId = typeId;
if (providerId) where.insuranceProviderId = providerId;
if (liquidated !== undefined) where.liquidated = liquidated;
const [total, rows] = await this.prisma.$transaction([
this.prisma.policy.count({ where }),
this.prisma.policy.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: this.orderBy(sort),
select: {
id: true,
policyNumber: true,
agentName: true,
policyFrom: true,
policyTo: true,
netPremium: true,
total: true,
currency: true,
liquidated: true,
customer: { select: { id: true, name: true, city: true } },
policyType: { select: { id: true, name: true } },
insuranceProvider: { select: { id: true, name: true } },
_count: { select: { vehicles: true, installments: true, documents: true } },
},
}),
]);
const from = today();
const soon = addDays(from, days);
const items = rows.map((r) => ({
id: r.id,
policyNumber: r.policyNumber,
agentName: r.agentName,
policyFrom: r.policyFrom,
policyTo: r.policyTo,
netPremium: r.netPremium,
total: r.total,
currency: r.currency,
liquidated: r.liquidated,
customerId: r.customer.id,
customerName: r.customer.name,
customerCity: r.customer.city,
policyType: r.policyType,
insuranceProvider: r.insuranceProvider,
status: statusOf(r.policyTo, from, soon),
daysToExpiry: daysUntil(r.policyTo, from),
vehicleCount: r._count.vehicles,
installmentCount: r._count.installments,
documentCount: r._count.documents,
}));
return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) };
}
/** Top-line counts for the policies page header. */
async stats(days: number) {
const from = today();
const soon = addDays(from, days);
const [total, active, expiring, expired, undated, liquidated] =
await this.prisma.$transaction([
this.prisma.policy.count(),
this.prisma.policy.count({ where: { policyTo: { gte: from } } }),
this.prisma.policy.count({
where: { policyTo: { gte: from, lte: soon } },
}),
this.prisma.policy.count({ where: { policyTo: { lt: from } } }),
this.prisma.policy.count({ where: { policyTo: null } }),
this.prisma.policy.count({ where: { liquidated: true } }),
]);
// Premium in force, per currency — the two currencies can't be summed.
const inForce = await this.prisma.policy.groupBy({
by: ["currency"],
where: { policyTo: { gte: from } },
_sum: { total: true, netPremium: true },
_count: { _all: true },
});
return {
total,
active,
expiring,
expired,
undated,
liquidated,
pending: total - liquidated,
days,
premiumInForce: inForce.map((r) => ({
currency: r.currency,
total: r._sum.total,
netPremium: r._sum.netPremium,
count: r._count._all,
})),
};
}
/** Filter dropdown options, with counts so empty choices are visible. */
async facets() {
const [types, providers] = await this.prisma.$transaction([
this.prisma.policyType.findMany({
orderBy: { name: "asc" },
select: { id: true, name: true, _count: { select: { policies: true } } },
}),
this.prisma.insuranceProvider.findMany({
orderBy: { name: "asc" },
select: { id: true, name: true, _count: { select: { policies: true } } },
}),
]);
return {
types: types.map((t) => ({ id: t.id, name: t.name, count: t._count.policies })),
providers: providers.map((p) => ({
id: p.id,
name: p.name,
count: p._count.policies,
})),
};
}
/** Full policy view, including the owning customer. */
async detail(id: string, days: number) {
const policy = await this.prisma.policy.findUnique({
where: { id },
include: {
customer: {
select: {
id: true,
name: true,
nameSource: true,
city: true,
state: true,
phone: true,
mobile: true,
email: true,
},
},
policyType: true,
insuranceProvider: true,
installments: { orderBy: { sequence: "asc" } },
vehicles: true,
insuredDrivers: true,
beneficiaries: true,
claims: { include: { adjuster: true } },
documents: true,
properties: {
select: { id: true, addressLine1: true, addressLine2: true, zone: true },
},
},
});
if (!policy) {
throw new NotFoundException(`Policy ${id} not found`);
}
const from = today();
return {
...policy,
status: statusOf(policy.policyTo, from, addDays(from, days)),
daysToExpiry: daysUntil(policy.policyTo, from),
};
}
}