From 6a97242fc3750440f6cf8461738c899f3ff7e0fd Mon Sep 17 00:00:00 2001 From: Ricardo Mancinas Date: Thu, 6 Aug 2026 20:04:03 -0700 Subject: [PATCH] feat(customers): allocate portal NUMids, with an audit for reusable ones Customers created in the staff UI had no NUMid and so could not log in to my.jorgecuadros.com at all: the id is a CustomerLegacyRef row, not a column, and create() deliberately writes none. Allocation is a staff action (POST /customers/:id/portal-access, MANAGER) rather than part of create, because insurance is expected to move to the platform before utilities and an insurance-only customer has no reason to spend a utilities id. The audit that decides which ids are reusable took three passes. "Owns no rows" matches nobody -- migration gave all 1,171 NUMids a property and a transaction. "No transaction in N years" also matches nobody -- every customer carries a synthetic Jan-1 opening-balance row, so everyone looks active this year. Subtracting that row is what makes dormancy measurable, and it leaves 4 never-used ids and 10 dormant ones on dev. Two further traps are encoded in the queries: insurance/DATGRAL is a separate id space that reuses the sourceTable name and runs past 4,000, and ACCOUNT CANCELED is a transaction line type, not an account state -- all 8 customers carrying it have current-year activity. Recycling ships switched off (numid.recycleEmpty, default false). Every reusable id still exists in Access DATGRAL, and a --sync run reassigns refs with ON DUPLICATE KEY UPDATE customerId, so an id recycled before the utilities cutover is silently handed back to its Access owner. Co-Authored-By: Claude Opus 5 --- apps/api/src/auth/abilities.ts | 7 + .../api/src/customers/customers.controller.ts | 32 +++ apps/api/src/customers/customers.module.ts | 6 +- apps/api/src/customers/numid.service.spec.ts | 206 ++++++++++++++ apps/api/src/customers/numid.service.ts | 253 ++++++++++++++++++ apps/api/src/settings/settings.service.ts | 35 +++ apps/web/src/app/clientes/[id]/page.tsx | 49 +++- apps/web/src/lib/api.ts | 14 + apps/web/src/lib/types.ts | 1 + docs/BACKLOG.md | 28 +- scripts/numid-audit.mjs | 149 +++++++++++ scripts/numid-audit.sql | 103 +++++++ 12 files changed, 880 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/customers/numid.service.spec.ts create mode 100644 apps/api/src/customers/numid.service.ts create mode 100644 scripts/numid-audit.mjs create mode 100644 scripts/numid-audit.sql diff --git a/apps/api/src/auth/abilities.ts b/apps/api/src/auth/abilities.ts index a1e0671..8075f7c 100644 --- a/apps/api/src/auth/abilities.ts +++ b/apps/api/src/auth/abilities.ts @@ -21,6 +21,7 @@ export type Ability = | "customer:create" | "customer:update" | "customer:delete" + | "customer:portal-access" | "policy:create" | "policy:update" | "policy:delete" @@ -48,6 +49,12 @@ export const ABILITY_MIN: Record = { "customer:create": "STAFF", "customer:update": "STAFF", "customer:delete": "ADMIN", + // Assigning a portal NUMid is granting someone the ability to log in to + // my.jorgecuadros.com and read an account, so it sits above customer:update: + // editing a phone number is the day job, handing out portal identity is not. + // It is also close to irreversible in practice — the id is what the customer + // then types at every login. + "customer:portal-access": "MANAGER", "policy:create": "STAFF", "policy:update": "STAFF", "policy:delete": "MANAGER", diff --git a/apps/api/src/customers/customers.controller.ts b/apps/api/src/customers/customers.controller.ts index a23530e..a169f69 100644 --- a/apps/api/src/customers/customers.controller.ts +++ b/apps/api/src/customers/customers.controller.ts @@ -16,6 +16,7 @@ import { AbilityGuard } from "../auth/ability.guard"; import { RequireAbility } from "../auth/require-ability.decorator"; import { AuditService } from "../common/audit.service"; import { CustomersService } from "./customers.service"; +import { NumidService } from "./numid.service"; import { CreateCustomerDto } from "./create-customer.dto"; import { UpdateCustomerDto } from "./update-customer.dto"; @@ -24,6 +25,7 @@ import { UpdateCustomerDto } from "./update-customer.dto"; export class CustomersController { constructor( private readonly customers: CustomersService, + private readonly numids: NumidService, private readonly audit: AuditService, ) {} @@ -36,6 +38,13 @@ export class CustomersController { return this.customers.stats(); } + /** Reusable portal ids, lowest first. Declared above `:id` so the literal + * path is not swallowed by the wildcard route. */ + @Get("numid/candidates") + async numidCandidates() { + return { candidates: await this.numids.emptyCandidates() }; + } + @Get() list( @Query("query") query?: string, @@ -95,4 +104,27 @@ export class CustomersController { void this.audit.log(this.actingId(req), "customer.restore", { customerId: id }); return c; } + + /** + * Give this customer a portal NUMid so they can log in to + * my.jorgecuadros.com. Idempotent — a customer who already has one gets it + * back rather than a second identity. + */ + @Post(":id/portal-access") + @RequireAbility("customer:portal-access") + async portalAccess(@Param("id") id: string, @Req() req: Request) { + const allocation = await this.numids.allocate(id); + if (allocation.origin !== "existing") { + // Logged with the origin and the previous holder: a recycled id is the one + // case where reading this record later has to answer "whose number was + // this before, and was it taken or minted". + void this.audit.log(this.actingId(req), "customer.portal-access", { + customerId: id, + numid: allocation.numid, + origin: allocation.origin, + previousCustomerId: allocation.previousCustomerId, + }); + } + return allocation; + } } diff --git a/apps/api/src/customers/customers.module.ts b/apps/api/src/customers/customers.module.ts index e99cbc9..3729e47 100644 --- a/apps/api/src/customers/customers.module.ts +++ b/apps/api/src/customers/customers.module.ts @@ -1,9 +1,13 @@ import { Module } from "@nestjs/common"; +import { SettingsModule } from "../settings/settings.module"; import { CustomersController } from "./customers.controller"; import { CustomersService } from "./customers.service"; +import { NumidService } from "./numid.service"; @Module({ + imports: [SettingsModule], controllers: [CustomersController], - providers: [CustomersService], + providers: [CustomersService, NumidService], + exports: [NumidService], }) export class CustomersModule {} diff --git a/apps/api/src/customers/numid.service.spec.ts b/apps/api/src/customers/numid.service.spec.ts new file mode 100644 index 0000000..bf93189 --- /dev/null +++ b/apps/api/src/customers/numid.service.spec.ts @@ -0,0 +1,206 @@ +import { ConflictException, NotFoundException } from "@nestjs/common"; +import { Prisma } from "@jorgecuadros/database"; +import { NumidService } from "./numid.service"; + +/** + * What matters about the allocator is the two things it must never do: hand the + * same id to two customers, and hand out a recycled id while Access can still + * take it back. Both are tested here; the emptiness SQL itself is exercised + * against real data by scripts/numid-audit.mjs. + */ + +interface Options { + existingRef?: { legacyId: string } | null; + archived?: boolean; + missing?: boolean; + recycle?: boolean; + empty?: { numid: string; refId: string; customerId: string }[]; + max?: number | null; + /** Make the first N create() calls fail the unique key, as a race would. */ + createConflicts?: number; + /** Make updateMany report "nothing matched", as a lost recycle race would. */ + recycleMisses?: number; +} + +function build(opts: Options = {}) { + const created: { legacyId: string }[] = []; + let conflictsLeft = opts.createConflicts ?? 0; + let missesLeft = opts.recycleMisses ?? 0; + + const prisma = { + customer: { + findUnique: jest.fn().mockResolvedValue( + opts.missing ? null : { id: "cust-new", archivedAt: opts.archived ? new Date() : null }, + ), + }, + customerLegacyRef: { + findFirst: jest.fn().mockResolvedValue(opts.existingRef ?? null), + updateMany: jest.fn().mockImplementation(() => { + if (missesLeft > 0) { + missesLeft -= 1; + return Promise.resolve({ count: 0 }); + } + return Promise.resolve({ count: 1 }); + }), + create: jest.fn().mockImplementation(({ data }: { data: { legacyId: string } }) => { + if (conflictsLeft > 0) { + conflictsLeft -= 1; + return Promise.reject( + new Prisma.PrismaClientKnownRequestError("dup", { + code: "P2002", + clientVersion: "5", + }), + ); + } + created.push(data); + return Promise.resolve(data); + }), + }, + // Two different raw queries share one mock: the MAX lookup returns a single + // {max} row, everything else is the empty-candidate list. + $queryRaw: jest.fn().mockImplementation((sql: { strings?: string[]; sql?: string }) => { + const text = String((sql as unknown as { sql?: string }).sql ?? ""); + if (text.includes("MAX(")) return Promise.resolve([{ max: opts.max ?? null }]); + return Promise.resolve(opts.empty ?? []); + }), + }; + + const settings = { + numidRecycleEmpty: jest + .fn() + .mockResolvedValue({ value: opts.recycle ?? false, source: "default" }), + }; + + return { + service: new NumidService(prisma as never, settings as never), + prisma, + created, + }; +} + +describe("NUMid allocation", () => { + it("returns the id a customer already holds instead of minting a second one", async () => { + // A double-clicked button must not fork the customer's portal identity. + const { service, prisma } = build({ existingRef: { legacyId: "501" } }); + + await expect(service.allocate("cust-new")).resolves.toEqual({ + numid: "501", + origin: "existing", + }); + expect(prisma.customerLegacyRef.create).not.toHaveBeenCalled(); + }); + + it("allocates one past the highest id in the pool", async () => { + const { service, created } = build({ max: 1171 }); + + await expect(service.allocate("cust-new")).resolves.toEqual({ + numid: "1172", + origin: "new", + }); + expect(created[0]).toMatchObject({ + sourceSystem: "utilities", + sourceTable: "DATGRAL", + legacyId: "1172", + }); + }); + + it("starts at 1 when the pool is empty", async () => { + const { service } = build({ max: null }); + + await expect(service.allocate("cust-new")).resolves.toMatchObject({ numid: "1" }); + }); + + it("does NOT recycle while the setting is off, even with candidates free", async () => { + // The default has to be the safe one: every reusable id still exists in + // Access, and a --sync run reassigns it back to its Access owner. + const { service, prisma } = build({ + max: 1171, + empty: [{ numid: "1089", refId: "ref-1089", customerId: "cust-old" }], + }); + + await expect(service.allocate("cust-new")).resolves.toMatchObject({ + numid: "1172", + origin: "new", + }); + expect(prisma.customerLegacyRef.updateMany).not.toHaveBeenCalled(); + }); + + it("takes the lowest empty id once recycling is switched on", async () => { + const { service, prisma } = build({ + recycle: true, + max: 1171, + empty: [ + { numid: "1089", refId: "ref-1089", customerId: "cust-old" }, + { numid: "1094", refId: "ref-1094", customerId: "cust-other" }, + ], + }); + + await expect(service.allocate("cust-new")).resolves.toEqual({ + numid: "1089", + origin: "recycled", + previousCustomerId: "cust-old", + }); + // Guarded on the owner read a moment ago, so a ref that moved underneath us + // matches nothing rather than being stolen. + expect(prisma.customerLegacyRef.updateMany).toHaveBeenCalledWith({ + where: { id: "ref-1089", customerId: "cust-old" }, + data: { customerId: "cust-new" }, + }); + }); + + it("skips a candidate that someone else took first", async () => { + const { service } = build({ + recycle: true, + max: 1171, + recycleMisses: 1, + empty: [ + { numid: "1089", refId: "ref-1089", customerId: "cust-old" }, + { numid: "1094", refId: "ref-1094", customerId: "cust-other" }, + ], + }); + + await expect(service.allocate("cust-new")).resolves.toMatchObject({ + numid: "1094", + origin: "recycled", + }); + }); + + it("falls back to a new id when recycling is on but nothing is free", async () => { + const { service } = build({ recycle: true, max: 1171, empty: [] }); + + await expect(service.allocate("cust-new")).resolves.toMatchObject({ + numid: "1172", + origin: "new", + }); + }); + + it("retries when two writers pick the same id", async () => { + // The unique key on (sourceSystem, sourceTable, legacyId) is what decides + // the winner; the loser must retry, never overwrite. + const { service, prisma } = build({ max: 1171, createConflicts: 1 }); + + await expect(service.allocate("cust-new")).resolves.toMatchObject({ + numid: "1172", + origin: "new", + }); + expect(prisma.customerLegacyRef.create).toHaveBeenCalledTimes(2); + }); + + it("gives up loudly rather than looping forever", async () => { + const { service } = build({ max: 1171, createConflicts: 99 }); + + await expect(service.allocate("cust-new")).rejects.toBeInstanceOf(ConflictException); + }); + + it("refuses an archived customer", async () => { + const { service } = build({ archived: true }); + + await expect(service.allocate("cust-new")).rejects.toBeInstanceOf(ConflictException); + }); + + it("refuses a customer that does not exist", async () => { + const { service } = build({ missing: true }); + + await expect(service.allocate("nope")).rejects.toBeInstanceOf(NotFoundException); + }); +}); diff --git a/apps/api/src/customers/numid.service.ts b/apps/api/src/customers/numid.service.ts new file mode 100644 index 0000000..9e53d76 --- /dev/null +++ b/apps/api/src/customers/numid.service.ts @@ -0,0 +1,253 @@ +import { + ConflictException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { Prisma } from "@jorgecuadros/database"; +import { PrismaService } from "../prisma/prisma.service"; +import { SettingsService } from "../settings/settings.service"; + +/** + * Allocation of the portal NUMid — the "Security Number" my.jorgecuadros.com + * asks for at login. + * + * The NUMid is not a column on `Customer`. It is a `CustomerLegacyRef` row with + * (sourceSystem='utilities', sourceTable='DATGRAL'), and `CustomersService.create` + * deliberately writes none: a natively created customer has no legacy provenance. + * The consequence is that every customer created in the staff UI is invisible to + * the portal until this service gives them an id. + * + * WHY THIS IS NOT DONE AT CREATE TIME. Insurance is expected to move to the + * platform before utilities, and an insurance-only customer has no reason to hold + * a portal identity. Allocating on every create would spend utilities ids — and + * the handful of reusable ones — on people who will never log in. So this is an + * explicit staff action instead. + */ + +/** The pair that identifies a portal NUMid. */ +export const UTILITIES_SYSTEM = "utilities"; +export const UTILITIES_TABLE = "DATGRAL"; + +/** + * insurance/DATGRAL is a SEPARATE id space that reuses the same sourceTable name + * and runs past 4,000. It must never be read as a NUMid, and never allocated + * from: the portal cannot resolve those ids. Every query here filters on BOTH + * columns for that reason, never on sourceTable alone. + */ +const POOL = { + sourceSystem: UTILITIES_SYSTEM, + sourceTable: UTILITIES_TABLE, +} as const; + +export type AllocationOrigin = "existing" | "new" | "recycled"; + +export interface Allocation { + numid: string; + origin: AllocationOrigin; + /** Set only on a recycle — the customer the id was taken from. */ + previousCustomerId?: string; +} + +/** + * NUMids that were created and never used, safe for an allocator to take. + * + * THE TWO OBVIOUS RULES BOTH FIND NOTHING, which is why this one looks the way + * it does. "Owns no rows" matches nobody: migration gave all 1,171 NUMids a + * property and a transaction. "No transaction in N years" also matches nobody: + * every customer carries a synthetic Jan-1 opening-balance row, so everyone + * looks active in the current year. That row has to be subtracted before any + * activity test means anything, which is what `bf` does below. + * + * The balance-forward row is matched in two shapes on purpose. + * transform_transactions.py:120 mints a type literally named 'BALANCE FORWARD'; + * databases loaded before that change carry the same rows with typeId NULL, + * dated Jan 1, legacySourceTable='datos2'. Matching only the type name floors + * nothing on such a database and turns the balance test into a raw lifetime sum + * — the double-count that read the whole book as +20.6M MXN in credit before + * d173c9e, and which here would mark live customers as empty. + * + * Services are tested as "any service" rather than "any ACTIVE service": a + * deactivated water account is still a record of somebody having lived behind + * this id. + * + * Kept in step with scripts/numid-audit.sql, which reports the same tier for a + * human. That script is the reporting copy of this rule; change both together. + */ +const EMPTY_NUMID_SQL = Prisma.sql` + WITH bf AS ( + SELECT t.id, t.customerId + FROM transactions t + LEFT JOIN type_transactions tt ON tt.id = t.typeId + WHERE t.voidedAt IS NULL + AND ( + tt.nameEn = 'BALANCE FORWARD' + OR (t.typeId IS NULL AND MONTH(t.transactionDate) = 1 AND DAY(t.transactionDate) = 1 + AND t.legacySourceTable = 'datos2') + ) + ) + SELECT r.legacyId AS numid, r.id AS refId, r.customerId AS customerId + FROM customer_legacy_refs r + JOIN customers c ON c.id = r.customerId + WHERE r.sourceSystem = ${UTILITIES_SYSTEM} AND r.sourceTable = ${UTILITIES_TABLE} + AND (c.email IS NULL OR c.email = '') + AND NOT EXISTS (SELECT 1 FROM transactions t + WHERE t.customerId = c.id AND t.voidedAt IS NULL + AND t.id NOT IN (SELECT id FROM bf)) + AND NOT EXISTS (SELECT 1 FROM transactions t + WHERE t.customerId = c.id AND t.voidedAt IS NULL AND t.outstanding = 1) + AND NOT EXISTS (SELECT 1 FROM property_services ps + JOIN properties p ON p.id = ps.propertyId WHERE p.customerId = c.id) + AND NOT EXISTS (SELECT 1 FROM policies p WHERE p.customerId = c.id) + AND NOT EXISTS (SELECT 1 FROM vehicles v WHERE v.customerId = c.id) + AND NOT EXISTS (SELECT 1 FROM trust_accounts ta + JOIN properties p ON p.id = ta.propertyId WHERE p.customerId = c.id) + AND NOT EXISTS (SELECT 1 FROM statement_documents s WHERE s.matchedCustomerId = c.id) + AND NOT EXISTS (SELECT 1 FROM policy_ocr_documents o WHERE o.matchedCustomerId = c.id) + AND NOT EXISTS (SELECT 1 FROM email_notification_log e WHERE e.customerId = c.id) + AND NOT EXISTS (SELECT 1 FROM email_log e WHERE e.customerId = c.id) + AND NOT EXISTS (SELECT 1 FROM account_status_history a WHERE a.customerId = c.id) + AND NOT EXISTS (SELECT 1 FROM customer_legacy_refs i + WHERE i.customerId = c.id AND i.sourceSystem = 'insurance') + ORDER BY CAST(r.legacyId AS UNSIGNED)`; + +interface EmptyRow { + numid: string; + refId: string; + customerId: string; +} + +@Injectable() +export class NumidService { + private readonly logger = new Logger(NumidService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly settings: SettingsService, + ) {} + + /** The customer's portal id, or null if they have none. */ + async current(customerId: string): Promise { + const ref = await this.prisma.customerLegacyRef.findFirst({ + where: { customerId, ...POOL }, + select: { legacyId: true }, + }); + return ref?.legacyId ?? null; + } + + /** Reusable ids, lowest first. Empty unless recycling is switched on. */ + async emptyCandidates(): Promise { + const rows = await this.prisma.$queryRaw(EMPTY_NUMID_SQL); + return rows.map((r) => r.numid); + } + + /** + * Give a customer a portal NUMid. + * + * Idempotent: a customer who already holds one gets it back rather than a + * second id, so a double-clicked button cannot fork an identity. + */ + async allocate(customerId: string): Promise { + const customer = await this.prisma.customer.findUnique({ + where: { id: customerId }, + select: { id: true, archivedAt: true }, + }); + if (!customer) throw new NotFoundException(`Customer ${customerId} not found`); + if (customer.archivedAt) { + throw new ConflictException( + "No se puede asignar un número de portal a un cliente archivado", + ); + } + + const existing = await this.current(customerId); + if (existing) return { numid: existing, origin: "existing" }; + + const recycle = await this.recycleEnabled(); + + // Two writers can pick the same id between the read and the write. The + // unique key on (sourceSystem, sourceTable, legacyId) is what actually + // decides the winner; the loser retries and takes the next id rather than + // silently overwriting. Bounded so a genuinely wedged pool fails loudly. + for (let attempt = 0; attempt < 5; attempt++) { + try { + if (recycle) { + const recycled = await this.tryRecycle(customerId); + if (recycled) return recycled; + } + return await this.allocateNext(customerId); + } catch (error) { + if (!isUniqueViolation(error)) throw error; + this.logger.warn( + `NUMid allocation for ${customerId} lost a race (attempt ${attempt + 1}), retrying`, + ); + } + } + throw new ConflictException( + "No se pudo asignar un número de portal; intente de nuevo", + ); + } + + /** + * Whether the recycle tier is live. + * + * Off by default, and that default is the safe one while Access is still the + * utilities master. Every id in the pool ALSO exists in Access DATGRAL, and a + * `--sync` migration run upserts refs with ON DUPLICATE KEY UPDATE customerId + * (transform_customers.py:327) — so an id recycled today is silently handed + * back to its Access owner on the next sync, and the customer who was given it + * loses their portal identity. Turn this on once utilities has cut over, or + * for ids that have been deleted at the source. + */ + private async recycleEnabled(): Promise { + const { value } = await this.settings.numidRecycleEmpty(); + return value; + } + + /** Re-point the lowest empty id at this customer. Null when none is free. */ + private async tryRecycle(customerId: string): Promise { + const rows = await this.prisma.$queryRaw(EMPTY_NUMID_SQL); + for (const row of rows) { + // Guarded by the owner we just read: if anything moved the ref in the + // meantime the update matches nothing and we fall through to the next + // candidate rather than stealing an id that is no longer empty. + const moved = await this.prisma.customerLegacyRef.updateMany({ + where: { id: row.refId, customerId: row.customerId }, + data: { customerId }, + }); + if (moved.count === 1) { + this.logger.log( + `NUMid ${row.numid} recycled from ${row.customerId} to ${customerId}`, + ); + return { + numid: row.numid, + origin: "recycled", + previousCustomerId: row.customerId, + }; + } + } + return null; + } + + /** One past the highest id in the pool. */ + private async allocateNext(customerId: string): Promise { + const [{ max }] = await this.prisma.$queryRaw<{ max: number | null }[]>( + // MAX over a CAST, not over the string: legacyId is VARCHAR, so a plain + // MAX returns '999' as the highest of 1,171 rows and the allocator hands + // out an id that is already taken. + Prisma.sql`SELECT MAX(CAST(legacyId AS UNSIGNED)) AS max + FROM customer_legacy_refs + WHERE sourceSystem = ${UTILITIES_SYSTEM} AND sourceTable = ${UTILITIES_TABLE}`, + ); + const numid = String(Number(max ?? 0) + 1); + await this.prisma.customerLegacyRef.create({ + data: { customerId, ...POOL, legacyId: numid }, + }); + return { numid, origin: "new" }; + } +} + +function isUniqueViolation(error: unknown): boolean { + return ( + error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002" + ); +} diff --git a/apps/api/src/settings/settings.service.ts b/apps/api/src/settings/settings.service.ts index 18956f6..cfb8277 100644 --- a/apps/api/src/settings/settings.service.ts +++ b/apps/api/src/settings/settings.service.ts @@ -22,6 +22,8 @@ export const SETTING_KEYS = { scheduleServicios: "notification.schedule.servicios", /** JSON cadence of the automatic pólizas renewal sweep. */ schedulePolizas: "notification.schedule.polizas", + /** Whether the NUMid allocator may reuse empty portal ids. */ + numidRecycleEmpty: "numid.recycleEmpty", } as const; /** Where a resolved value came from. Shown in the UI. */ @@ -169,6 +171,39 @@ export class SettingsService { ); } + /** + * Whether the NUMid allocator may reuse empty portal ids instead of only + * issuing new ones. + * + * Defaults to OFF, and the default is the safety property rather than a + * preference: while Access remains the utilities master, every reusable id + * still exists in DATGRAL, and a `--sync` migration run reassigns the ref back + * to its Access owner (transform_customers.py:327). Recycling before utilities + * cuts over therefore hands out ids that quietly stop working. No env rung — + * this has never been an environment variable and should be flipped + * deliberately, in the UI, by someone who knows the cutover happened. + */ + async numidRecycleEmpty(): Promise> { + const row = await this.read(SETTING_KEYS.numidRecycleEmpty); + if (row) { + return { + value: row.value === "true", + source: "db", + updatedAt: row.updatedAt, + updatedById: row.updatedById, + }; + } + return { value: false, source: "default", updatedAt: null, updatedById: null }; + } + + async setNumidRecycleEmpty( + enabled: boolean, + userId: string, + ): Promise> { + await this.write(SETTING_KEYS.numidRecycleEmpty, String(enabled), userId); + return this.numidRecycleEmpty(); + } + private read(key: string) { return this.prisma.appSetting.findUnique({ where: { key } }); } diff --git a/apps/web/src/app/clientes/[id]/page.tsx b/apps/web/src/app/clientes/[id]/page.tsx index d9c4900..df8e249 100644 --- a/apps/web/src/app/clientes/[id]/page.tsx +++ b/apps/web/src/app/clientes/[id]/page.tsx @@ -7,6 +7,7 @@ import { ContextReports } from "@/components/ContextReports"; import { archiveCustomer, getCustomer, + grantPortalAccess, policyDocumentDownloadUrl, propertyDocumentDownloadUrl, restoreCustomer, @@ -151,9 +152,43 @@ function CustomerActions({ }) { const canEdit = useCan("customer:update"); const canDelete = useCan("customer:delete"); + const canGrantPortal = useCan("customer:portal-access"); const [busy, setBusy] = useState(false); const archived = customer.archivedAt != null; + // The portal NUMid is a legacy ref, not a column: (utilities, DATGRAL) is the + // "Security Number" my.jorgecuadros.com asks for. An insurance ref is a + // different id space entirely and does not let anyone log in, so both columns + // are checked — matching on sourceTable alone would hide the button from + // customers who cannot actually reach the portal. + const hasPortalId = customer.legacyRefs.some( + (r) => r.sourceSystem === "utilities" && r.sourceTable === "DATGRAL", + ); + + async function grantPortal() { + if ( + !window.confirm( + "¿Asignar un número de portal a este cliente? Con él podrá entrar a " + + "my.jorgecuadros.com.", + ) + ) + return; + setBusy(true); + try { + const { numid, origin } = await grantPortalAccess(customer.id); + window.alert( + origin === "existing" + ? `Este cliente ya tenía el número de portal ${numid}.` + : `Número de portal asignado: ${numid}.`, + ); + onChange(); + } catch (e) { + window.alert((e as Error)?.message ?? "No se pudo completar la acción."); + } finally { + setBusy(false); + } + } + async function toggleArchive() { const verb = archived ? "restaurar" : "archivar"; if (!window.confirm(`¿Seguro que desea ${verb} este cliente?`)) return; @@ -169,11 +204,23 @@ function CustomerActions({ } } - if (!canEdit && !canDelete) return null; + const showPortal = canGrantPortal && !hasPortalId && !archived; + if (!canEdit && !canDelete && !showPortal) return null; return (
{archived && Archivado} + {showPortal && ( + + )} {canEdit && ( Editar diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 7d8ee25..e6edb2a 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -232,6 +232,20 @@ export function restoreCustomer(id: string): Promise { return apiFetch(`/customers/${id}/restore`, { method: "POST" }); } +export interface NumidAllocation { + numid: string; + /** "existing" when the customer already had one — the call is idempotent. */ + origin: "existing" | "new" | "recycled"; + previousCustomerId?: string; +} + +/** Give a customer the portal NUMid they log in to my.jorgecuadros.com with. */ +export function grantPortalAccess(id: string): Promise { + return apiFetch(`/customers/${id}/portal-access`, { + method: "POST", + }); +} + /* ------------------------------------------------------ Policies module */ /** Renewal horizon in days, shared by the list, stats and detail calls so the diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index ac793e5..1389890 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -9,6 +9,7 @@ export type Ability = | "customer:create" | "customer:update" | "customer:delete" + | "customer:portal-access" | "policy:create" | "policy:update" | "policy:delete" diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 7b97c5e..e4dbea3 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -83,7 +83,7 @@ guess. | 1.3 | Carrier API **direction**: outbound quote/issue (ANA supports today) or inbound portfolio sync (no evidence either carrier offers it) | whether §4 is buildable at all | INSURANCE §4 | | 1.4 | CFE amount: the rounded barcode figure (`$268`, what is paid at the window) or the exact breakdown total (`$268.88`) | the parser currently takes the barcode | STATEMENT_OCR / RECEIPT §2 | | 1.5 | The Seguros USD bank's name, currency and details | multi-bank is built; that account does not exist yet | RECEIPT §3 | -| 1.6 | Recycling triggers — exact "1 year inactive" / "cancelled" definitions, and whether recycling ever means true data purge | §4 recycling | RECEIPT §4 | +| 1.6 | Whether recycling ever means a true data purge. The *triggers* are now settled and built (see §5 "NUMid allocation"); what is still open is whether a recycled id's old rows are ever deleted rather than left attached to the previous customer | nothing — the allocator ships without a purge | RECEIPT §4 | | 1.7 | Notice body in Spanish or English | `Customer` carries no language preference | INSURANCE §1 | | 1.8 | How to model `TRASPASOS PAYPAL` — a clearing account, not a customer, carrying −7.03M MXN over 309 movements and therefore topping the adeudo worklist | deliberately not special-cased in code | RESUME §6 | | 1.9 | The 78 policyholders with no email — skip silently or produce a print worklist | recommendation is the worklist | INSURANCE §1 | @@ -184,6 +184,32 @@ Each of these is a known, deliberate stopping point rather than a bug. - Handwritten folder numbers are deliberately not an input to matching (Tesseract read `405` as `205`). +**NUMid allocation** — `POST /customers/:id/portal-access` assigns the portal +"Security Number", on a staff action rather than at create time, because an +insurance-only customer has no reason to hold a utilities id. + +- **Recycling is built but switched off.** `numid.recycleEmpty` in `app_settings` + defaults to false, and that default is a safety property, not a preference: + every reusable id still exists in Access DATGRAL, and a `--sync` migration run + upserts refs with `ON DUPLICATE KEY UPDATE customerId` + (`transform_customers.py:327`), so an id recycled today is handed back to its + Access owner on the next sync and the customer given it loses portal access. + **Flip it on after utilities cuts over**, or for ids deleted at the source. +- **A full re-import destroys every natively allocated id.** + `transform_customers.py:246` truncates `customers` and `customer_legacy_refs`, + then rebuilds the pool from Access alone. Until that is fixed — either by + mandating `--sync` for all future utilities loads, or by teaching the transform + to preserve refs with no Access counterpart — a NUMid issued here survives only + until the next full load. This is a prerequisite for the cutover, not a + nice-to-have. +- **The empty-id rule exists twice**: enforced in `numid.service.ts` + (`EMPTY_NUMID_SQL`) and reported by `scripts/numid-audit.sql`. They agree today + (both return 1089, 1094, 1134, 1143 on dev); they are not mechanically kept in + step, so change them together. +- **No un-assign.** Nothing removes a NUMid once given, and nothing reports which + ids were recycled from whom beyond the `customer.portal-access` activity-log + entry. + **Bank** — the concept→ramo classifier is **won't-build**, not pending. `concepto` is a payee name (0 of 22,354 match a category) and TABLA RAMODOS is a property-management expense chart, not the business-line split it was assumed diff --git a/scripts/numid-audit.mjs b/scripts/numid-audit.mjs new file mode 100644 index 0000000..6f48a0c --- /dev/null +++ b/scripts/numid-audit.mjs @@ -0,0 +1,149 @@ +#!/usr/bin/env node +/** + * NUMid recycle audit. + * + * Prints which portal NUMids (customer_legacy_refs, utilities/DATGRAL) are dead + * enough to hand to a new customer, and which are merely quiet. Read-only: it + * writes nothing and reassigns nothing. + * + * node scripts/numid-audit.mjs # summary + both candidate tiers + * node scripts/numid-audit.mjs --csv # full per-NUMid table on stdout + * node scripts/numid-audit.mjs --numid 501 + * + * Needs DATABASE_URL. Run it against PROD before acting on anything: the tiers + * describe whatever database it is pointed at, and a stale copy will happily + * report a NUMid as empty that prod has been billing all year. + */ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +// Relative, not "@jorgecuadros/database": the workspace link is not always +// present at the repo root, and this script has to run from a bare checkout and +// from inside the API container alike. +import pkg from "../packages/database/generated/client/index.js"; + +const { PrismaClient } = pkg; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +/** + * Anything here means the id carries history a new owner would inherit. + * Counted, not sampled: a single row in any of them disqualifies. + */ +const HISTORY_COLUMNS = [ + "veh", + "trust", + "stmt", + "ocr", + "enl", + "elog", + "ash", + "nopago", +]; + +const n = (v) => (v == null ? 0 : Number(v)); +const hasHistory = (r) => HISTORY_COLUMNS.some((c) => n(r[c]) > 0); +const zeroBalance = (r) => n(r.balMxn) === 0 && n(r.balUsd) === 0; + +/** + * EMPTY — the id was created and never used. Safe for an allocator to take + * without a human looking, subject to the legacy check below. + * + * "Never transacted" means zero movements once the synthetic opening-balance row + * is removed; that row exists for all 1,171 NUMids and is not evidence of use. + * Services are checked as anySvc rather than activeSvc, because a deactivated + * water account still says a person once lived behind this id. + */ +const isEmpty = (r) => + n(r.realTx) === 0 && + zeroBalance(r) && + n(r.anySvc) === 0 && + n(r.anyPol) === 0 && + n(r.insRef) === 0 && + n(r.hasEmail) === 0 && + !hasHistory(r); + +/** + * DORMANT — used once, quiet for years, owes nothing. NOT auto-allocatable: + * a returning snowbird is indistinguishable from an abandoned account here. + */ +const isDormant = (r) => + !isEmpty(r) && + n(r.realTx36m) === 0 && + zeroBalance(r) && + n(r.activeSvc) === 0 && + n(r.activePol) === 0 && + !hasHistory(r); + +function line(r) { + return ( + ` ${String(r.numid).padStart(5)} ${(r.name || "(sin nombre)").slice(0, 30).padEnd(30)}` + + ` last=${(r.lastRealTx ? new Date(r.lastRealTx).toISOString().slice(0, 10) : "never").padStart(10)}` + + ` tx=${String(n(r.realTx)).padStart(3)}` + + ` bal=${n(r.balMxn).toFixed(2).padStart(10)}` + + ` svc=${n(r.anySvc)}` + + ` pol=${n(r.anyPol)}` + ); +} + +async function main() { + const args = process.argv.slice(2); + const prisma = new PrismaClient(); + try { + const sql = readFileSync(join(HERE, "numid-audit.sql"), "utf8"); + const rows = await prisma.$queryRawUnsafe(sql); + + const one = args.indexOf("--numid"); + if (one !== -1) { + const want = Number(args[one + 1]); + const r = rows.find((x) => Number(x.numid) === want); + if (!r) { + console.log(`NUMid ${want} is not in the utilities/DATGRAL pool.`); + return; + } + console.log(JSON.stringify(r, (_k, v) => (typeof v === "bigint" ? Number(v) : v), 2)); + console.log( + `\nverdict: ${isEmpty(r) ? "EMPTY" : isDormant(r) ? "DORMANT" : "IN USE"}`, + ); + return; + } + + if (args.includes("--csv")) { + const cols = Object.keys(rows[0]); + console.log(cols.join(",")); + for (const r of rows) { + console.log(cols.map((c) => JSON.stringify(r[c] ?? "")).join(",")); + } + return; + } + + const empty = rows.filter(isEmpty); + const dormant = rows.filter(isDormant); + const max = rows.reduce((m, r) => Math.max(m, Number(r.numid)), 0); + + console.log(`pool: ${rows.length} NUMids, max ${max}`); + console.log(` EMPTY (never used, auto-allocatable): ${empty.length}`); + console.log(` DORMANT (quiet, needs a human): ${dormant.length}`); + console.log(` IN USE: ${rows.length - empty.length - dormant.length}`); + + console.log("\nEMPTY"); + empty.forEach((r) => console.log(line(r))); + console.log("\nDORMANT"); + dormant.forEach((r) => console.log(line(r))); + + console.log( + "\nNOTE: this audit sees the platform only. Every NUMid here also exists in\n" + + "Access, and freakma republishes DATGRAL in full on each export, so an id\n" + + "reassigned here comes back under its old owner unless it is removed at the\n" + + "source or the NUMid is routed to the platform. Confirm against prod\n" + + "datosfreak before reassigning.", + ); + } finally { + await prisma.$disconnect(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/numid-audit.sql b/scripts/numid-audit.sql new file mode 100644 index 0000000..cc6dd64 --- /dev/null +++ b/scripts/numid-audit.sql @@ -0,0 +1,103 @@ +-- One row per portal NUMid, with every signal that says whether the id is in use. +-- Consumed by scripts/numid-audit.mjs, which applies the tier rules. +-- +-- POOL. customer_legacy_refs where sourceSystem='utilities' AND sourceTable='DATGRAL'. +-- That pair IS the portal "Security Number" the login screen asks for. +-- insurance/DATGRAL is a DIFFERENT id space running to 4000 and sharing the same +-- sourceTable name; drawing from it would hand out an id the portal cannot resolve. +-- +-- WHY THE OBVIOUS RULES FIND NOTHING. +-- "every owned row count is zero" -> 0 of 1,171. Migration gave every NUMid +-- at least one property and one transaction. +-- "no transaction in the last N years" -> 0 of 1,171. Every customer carries a +-- synthetic Jan-1 opening-balance row, so +-- everyone looks active in the current year. +-- The opening-balance row has to be subtracted before any of this means anything, +-- which is what `bf` below does and why `real_tx` exists. +-- +-- BALANCE-FORWARD DETECTION IS TWO-SHAPED ON PURPOSE. +-- transform_transactions.py:120 mints a transaction type literally named +-- 'BALANCE FORWARD'. Databases loaded before that change carry the same rows with +-- typeId NULL, dated Jan 1, legacySourceTable='datos2' -- 1,170 of them, exactly one +-- per customer. Matching the type name alone floors nothing on such a database, and +-- every balance below silently becomes a raw lifetime sum: the same double-count that +-- read the whole book as +20.6M MXN in credit before d173c9e. Match both shapes. +-- +-- Balances otherwise follow BillingService exactly -- voided out, outstanding out, +-- superseded rows out (BALANCE_FLOOR_JOIN / NOT_SUPERSEDED, billing.service.ts:179-210). + +WITH bf AS ( + SELECT t.id, t.customerId, t.transactionDate + FROM transactions t + LEFT JOIN type_transactions tt ON tt.id = t.typeId + WHERE t.voidedAt IS NULL + AND ( + tt.nameEn = 'BALANCE FORWARD' + OR (t.typeId IS NULL AND MONTH(t.transactionDate) = 1 AND DAY(t.transactionDate) = 1 + AND t.legacySourceTable = 'datos2') + ) +), +bfloor AS ( + SELECT customerId, MAX(transactionDate) AS floorDate FROM bf GROUP BY customerId +), +real_tx AS ( + SELECT t.* FROM transactions t + WHERE t.voidedAt IS NULL AND t.id NOT IN (SELECT id FROM bf) +), +pool AS ( + SELECT CAST(r.legacyId AS UNSIGNED) AS numid, + c.id AS cid, + REPLACE(REPLACE(COALESCE(c.name,''),'\n',' '),'\t',' ') AS name, + IF(c.archivedAt IS NULL,0,1) AS archived, + IF(c.email IS NULL OR c.email='',0,1) AS hasEmail + FROM customer_legacy_refs r + JOIN customers c ON c.id = r.customerId + WHERE r.sourceSystem='utilities' AND r.sourceTable='DATGRAL' +) +SELECT + p.numid, p.cid AS customerUuid, p.name, p.archived, p.hasEmail, + -- EXISTS, not a join: one customer can hold several insurance refs (DATGRAL and + -- COBRO3 both), and joining them fans this result out past one row per NUMid. + EXISTS(SELECT 1 FROM customer_legacy_refs i + WHERE i.customerId=p.cid AND i.sourceSystem='insurance') AS insRef, + + COALESCE((SELECT ROUND(SUM(t.amount),2) FROM transactions t + LEFT JOIN bfloor f ON f.customerId=t.customerId + WHERE t.customerId=p.cid AND t.voidedAt IS NULL AND t.outstanding=0 + AND t.currency='MXN' + AND (f.floorDate IS NULL OR t.transactionDate>=f.floorDate)),0) AS balMxn, + COALESCE((SELECT ROUND(SUM(t.amount),2) FROM transactions t + LEFT JOIN bfloor f ON f.customerId=t.customerId + WHERE t.customerId=p.cid AND t.voidedAt IS NULL AND t.outstanding=0 + AND t.currency='USD' + AND (f.floorDate IS NULL OR t.transactionDate>=f.floorDate)),0) AS balUsd, + + (SELECT COUNT(*) FROM transactions t + WHERE t.customerId=p.cid AND t.voidedAt IS NULL AND t.outstanding=1) AS nopago, + (SELECT COUNT(*) FROM real_tx t WHERE t.customerId=p.cid) AS realTx, + (SELECT COUNT(*) FROM real_tx t WHERE t.customerId=p.cid + AND t.transactionDate >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)) AS realTx12m, + (SELECT COUNT(*) FROM real_tx t WHERE t.customerId=p.cid + AND t.transactionDate >= DATE_SUB(CURDATE(), INTERVAL 36 MONTH)) AS realTx36m, + (SELECT DATE(MAX(t.transactionDate)) FROM real_tx t WHERE t.customerId=p.cid) AS lastRealTx, + + (SELECT COUNT(*) FROM properties pr WHERE pr.customerId=p.cid AND pr.archivedAt IS NULL) AS props, + -- services are counted BOTH ways: an inactive service is still a record of the id + -- having been used, so the auto tier requires zero of any kind. + (SELECT COUNT(*) FROM property_services ps JOIN properties pr ON pr.id=ps.propertyId + WHERE pr.customerId=p.cid AND pr.archivedAt IS NULL) AS anySvc, + (SELECT COUNT(*) FROM property_services ps JOIN properties pr ON pr.id=ps.propertyId + WHERE pr.customerId=p.cid AND pr.archivedAt IS NULL AND ps.active=1) AS activeSvc, + (SELECT COUNT(*) FROM policies po WHERE po.customerId=p.cid AND po.archivedAt IS NULL + AND (po.policyTo IS NULL OR po.policyTo >= CURDATE())) AS activePol, + (SELECT COUNT(*) FROM policies po WHERE po.customerId=p.cid AND po.archivedAt IS NULL) AS anyPol, + (SELECT COUNT(*) FROM vehicles v WHERE v.customerId=p.cid) AS veh, + (SELECT COUNT(*) FROM trust_accounts ta JOIN properties pr ON pr.id=ta.propertyId + WHERE pr.customerId=p.cid) AS trust, + (SELECT COUNT(*) FROM statement_documents s WHERE s.matchedCustomerId=p.cid) AS stmt, + (SELECT COUNT(*) FROM policy_ocr_documents o WHERE o.matchedCustomerId=p.cid) AS ocr, + (SELECT COUNT(*) FROM email_notification_log e WHERE e.customerId=p.cid) AS enl, + (SELECT COUNT(*) FROM email_log e WHERE e.customerId=p.cid) AS elog, + (SELECT COUNT(*) FROM account_status_history a WHERE a.customerId=p.cid) AS ash +FROM pool p +ORDER BY p.numid;