import type { Db } from "./db.ts"; export type Company = { id: number; code: string; name: string; parent_id: number | null; kind: "principal" | "sub"; status: "activo" | "inactivo"; tenant_id?: number | null; registro_patronal?: string; razon_social?: string; nombre_comercial?: string; rfc?: string; regimen_fiscal?: string; clase_riesgo?: string; domicilio_fiscal?: string; codigo_postal?: string; ciudad?: string; estado?: string; telefono?: string; email?: string; representante_legal?: string; giro?: string; created_at?: string; parent_code?: string | null; parent_name?: string | null; worker_count?: number; }; export type CompanyProfileInput = { name?: string; code?: string; status?: string; parent_id?: number | null; registro_patronal?: string; razon_social?: string; nombre_comercial?: string; rfc?: string; regimen_fiscal?: string; clase_riesgo?: string; domicilio_fiscal?: string; codigo_postal?: string; ciudad?: string; estado?: string; telefono?: string; email?: string; representante_legal?: string; giro?: string; }; const CODE_RE = /^[A-Z0-9][A-Z0-9_-]{1,15}$/; const RFC_RE = /^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/; function trimText(value: unknown): string { return (value ?? "").toString().trim(); } function normRfc(value: unknown): string { return trimText(value).toUpperCase().replace(/\s+/g, ""); } export async function listCompanies(database: Db, tenantId?: number | null): Promise { // El filtro tenant_id aquí es defensa adicional/legibilidad -- el // aislamiento real ya lo garantiza Row Level Security sobre la conexión // acotada por withCoreScope (ver db/core/changesets/005-rls.sql). const where = tenantId != null ? "WHERE c.tenant_id = ?" : ""; const params = tenantId != null ? [tenantId] : []; return await database.prepare( `SELECT c.*, p.code AS parent_code, p.name AS parent_name, (SELECT COUNT(*) FROM workers w WHERE w.company_id = c.id) AS worker_count FROM companies c LEFT JOIN companies p ON p.id = c.parent_id ${where} ORDER BY CASE c.kind WHEN 'principal' THEN 0 ELSE 1 END, LOWER(c.name)`, ).all(...params) as Company[]; } export async function companyById(database: Db, id: number): Promise { return await database.prepare("SELECT * FROM companies WHERE id = ?").get(id) as | Company | undefined; } export async function companyByCode(database: Db, code: string): Promise { return await database.prepare("SELECT * FROM companies WHERE code = ?").get( normCompanyCode(code), ) as Company | undefined; } export async function principalCompany(database: Db, tenantId?: number | null): Promise { if (tenantId != null) { return await database.prepare( "SELECT * FROM companies WHERE kind = 'principal' AND tenant_id = ? ORDER BY id LIMIT 1", ).get(tenantId) as Company | undefined; } return await database.prepare( "SELECT * FROM companies WHERE kind = 'principal' ORDER BY id LIMIT 1", ).get() as Company | undefined; } export function normCompanyCode(value: string | null | undefined): string { return (value ?? "").toString().trim().toUpperCase().replace(/\s+/g, ""); } export function companyCodeFromName(name: string): string { const slug = name .normalize("NFD") .replace(/\p{M}/gu, "") .toUpperCase() .replace(/[^A-Z0-9]+/g, "") .slice(0, 12); return slug || "EMP"; } export async function nextCompanyCode(database: Db, name: string): Promise { const base = companyCodeFromName(name); if (!await companyByCode(database, base)) return base; const row = await database.prepare( `SELECT COALESCE(MAX(substring(code from 5)::integer), 0) + 1 AS n FROM companies WHERE code ~ '^EMP-[0-9]{4}'`, ).get() as { n: number }; return `EMP-${String(row.n).padStart(4, "0")}`; } export async function resolveCompany( database: Db, body: { company_id?: number | null; hire_type?: string | null }, ): Promise { if (body.company_id) { const byId = await companyById(database, Number(body.company_id)); if (byId) return byId; } const code = normCompanyCode(body.hire_type); if (code) return await companyByCode(database, code); return undefined; } export function validateCompanyCode(code: string): string | null { if (!CODE_RE.test(code)) { return "Código de 2 a 16 caracteres (letras, números, _ o -)"; } return null; } export function validateCompanyProfile( input: CompanyProfileInput, opts: { requireLegal?: boolean } = {}, ): string | null { const rfc = normRfc(input.rfc); if (rfc && !RFC_RE.test(rfc)) { return "RFC inválido (formato mexicano de 12 o 13 caracteres)"; } if (opts.requireLegal) { if (!trimText(input.razon_social) && !trimText(input.name) && !trimText(input.nombre_comercial)) { return "Indique razón social o nombre comercial"; } } const clase = trimText(input.clase_riesgo).toUpperCase(); if (clase && !["I", "II", "III", "IV", "V"].includes(clase)) { return "Clase de riesgo IMSS debe ser I, II, III, IV o V"; } return null; } export function normalizeCompanyProfile(input: CompanyProfileInput, fallbackName = "") { const nombreComercial = trimText(input.nombre_comercial) || trimText(input.name) || fallbackName; const razonSocial = trimText(input.razon_social) || nombreComercial || fallbackName; const name = trimText(input.name) || nombreComercial || razonSocial || fallbackName; return { name, nombre_comercial: nombreComercial, razon_social: razonSocial, rfc: normRfc(input.rfc), regimen_fiscal: trimText(input.regimen_fiscal), registro_patronal: trimText(input.registro_patronal).toUpperCase(), clase_riesgo: trimText(input.clase_riesgo).toUpperCase(), domicilio_fiscal: trimText(input.domicilio_fiscal), codigo_postal: trimText(input.codigo_postal), ciudad: trimText(input.ciudad), estado: trimText(input.estado), telefono: trimText(input.telefono), email: trimText(input.email).toLowerCase(), representante_legal: trimText(input.representante_legal), giro: trimText(input.giro), }; } function validateProfile(input: CompanyProfileInput, opts: { requireLegal?: boolean } = {}): string | null { return validateCompanyProfile(input, opts); } function profileFromInput(input: CompanyProfileInput, fallbackName = "") { return normalizeCompanyProfile(input, fallbackName); } export async function createSubcompany( database: Db, input: CompanyProfileInput, tenantId?: number | null, ): Promise<{ company?: Company; error?: string }> { const profileErr = validateProfile(input, { requireLegal: true }); if (profileErr) return { error: profileErr }; const profile = profileFromInput(input); if (!profile.name) return { error: "Nombre de empresa obligatorio" }; const principal = await principalCompany(database, tenantId); if (!principal) return { error: "No hay empresa principal" }; const parentId = input.parent_id ? Number(input.parent_id) : principal.id; const parent = await companyById(database, parentId); if (!parent) return { error: "Empresa padre no encontrada" }; if (tenantId != null && parent.tenant_id != null && parent.tenant_id !== tenantId) { return { error: "Empresa padre de otro tenant" }; } let code = normCompanyCode(input.code); if (!code) code = await nextCompanyCode(database, profile.name); const codeErr = validateCompanyCode(code); if (codeErr) return { error: codeErr }; if (await companyByCode(database, code)) return { error: "Ya existe una empresa con ese código" }; const tid = tenantId ?? principal.tenant_id ?? null; try { await database.prepare( `INSERT INTO companies ( code, name, parent_id, kind, status, tenant_id, registro_patronal, razon_social, nombre_comercial, rfc, regimen_fiscal, clase_riesgo, domicilio_fiscal, codigo_postal, ciudad, estado, telefono, email, representante_legal, giro ) VALUES (?, ?, ?, 'sub', 'activo', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( code, profile.name, parent.id, tid, profile.registro_patronal, profile.razon_social, profile.nombre_comercial, profile.rfc, profile.regimen_fiscal, profile.clase_riesgo, profile.domicilio_fiscal, profile.codigo_postal, profile.ciudad, profile.estado, profile.telefono, profile.email, profile.representante_legal, profile.giro, ); } catch (e) { if (isUniqueViolation(e)) return { error: "Ya existe una empresa con ese código" }; throw e; } return { company: await companyById(database, await database.lastInsertId()) }; } export async function updateCompany( database: Db, id: number, input: CompanyProfileInput, tenantId?: number | null, ): Promise<{ company?: Company; error?: string; status?: 400 | 404 }> { const current = await companyById(database, id); // Con RLS activo, una fila de otro tenant ya no aparece aquí (la conexión // solo ve app.tenant_id); este chequeo explícito es defensa adicional y // un mensaje de error más claro que un 404 "silencioso". if (!current || (tenantId != null && current.tenant_id != null && current.tenant_id !== tenantId)) { return { error: "Empresa no encontrada", status: 404 }; } const profileErr = validateProfile(input); if (profileErr) return { error: profileErr, status: 400 }; const merged: CompanyProfileInput = { name: input.name !== undefined ? input.name : current.name, code: input.code !== undefined ? input.code : current.code, status: input.status !== undefined ? input.status : current.status, registro_patronal: input.registro_patronal !== undefined ? input.registro_patronal : current.registro_patronal, razon_social: input.razon_social !== undefined ? input.razon_social : current.razon_social, nombre_comercial: input.nombre_comercial !== undefined ? input.nombre_comercial : current.nombre_comercial, rfc: input.rfc !== undefined ? input.rfc : current.rfc, regimen_fiscal: input.regimen_fiscal !== undefined ? input.regimen_fiscal : current.regimen_fiscal, clase_riesgo: input.clase_riesgo !== undefined ? input.clase_riesgo : current.clase_riesgo, domicilio_fiscal: input.domicilio_fiscal !== undefined ? input.domicilio_fiscal : current.domicilio_fiscal, codigo_postal: input.codigo_postal !== undefined ? input.codigo_postal : current.codigo_postal, ciudad: input.ciudad !== undefined ? input.ciudad : current.ciudad, estado: input.estado !== undefined ? input.estado : current.estado, telefono: input.telefono !== undefined ? input.telefono : current.telefono, email: input.email !== undefined ? input.email : current.email, representante_legal: input.representante_legal !== undefined ? input.representante_legal : current.representante_legal, giro: input.giro !== undefined ? input.giro : current.giro, }; const profile = profileFromInput(merged, current.name); if (!profile.name) return { error: "Nombre de empresa obligatorio", status: 400 }; let code = current.code; if (input.code !== undefined) { code = normCompanyCode(input.code); const codeErr = validateCompanyCode(code); if (codeErr) return { error: codeErr, status: 400 }; const clash = await companyByCode(database, code); if (clash && clash.id !== id) return { error: "Ya existe una empresa con ese código", status: 400 }; } let status = current.status; if (input.status !== undefined) { if (!["activo", "inactivo"].includes(input.status)) { return { error: "Estatus debe ser activo o inactivo", status: 400 }; } if (current.kind === "principal" && input.status === "inactivo") { return { error: "La empresa principal no se puede inactivar", status: 400 }; } status = input.status as Company["status"]; } try { await database.prepare( `UPDATE companies SET name = ?, code = ?, status = ?, registro_patronal = ?, razon_social = ?, nombre_comercial = ?, rfc = ?, regimen_fiscal = ?, clase_riesgo = ?, domicilio_fiscal = ?, codigo_postal = ?, ciudad = ?, estado = ?, telefono = ?, email = ?, representante_legal = ?, giro = ? WHERE id = ?`, ).run( profile.name, code, status, profile.registro_patronal, profile.razon_social, profile.nombre_comercial, profile.rfc, profile.regimen_fiscal, profile.clase_riesgo, profile.domicilio_fiscal, profile.codigo_postal, profile.ciudad, profile.estado, profile.telefono, profile.email, profile.representante_legal, profile.giro, id, ); } catch (e) { if (isUniqueViolation(e)) return { error: "Ya existe una empresa con ese código", status: 400 }; throw e; } if (code !== current.code) { await database.prepare("UPDATE workers SET hire_type = ? WHERE company_id = ?").run(code, id); } return { company: await companyById(database, id) }; } /** Postgres error code 23505 = unique_violation. */ function isUniqueViolation(e: unknown): boolean { return !!e && typeof e === "object" && (e as { code?: string }).code === "23505"; }