import type { Pool } from 'pg'; import { prisma } from '../config/database.js'; export interface CreateContribuyenteData { rfc: string; razonSocial: string; regimenFiscal?: string; codigoPostal?: string; domicilio?: Record; supervisorUserId?: string; } export interface ContribuyenteRow { id: string; tipo: string; nombre: string; identificador: string; supervisorUserId: string | null; active: boolean; createdAt: string; rfc: string; regimenFiscal: string | null; codigoPostal: string | null; domicilio: Record | null; } async function fetchTenantFiscalData(tenantId: string) { const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { rfc: true, codigoPostal: true, calle: true, numExterior: true, numInterior: true, colonia: true, ciudad: true, municipio: true, estado: true, telefono: true, }, }); if (!tenant) return null; const regimenes = await prisma.tenantRegimenActivo.findMany({ where: { tenantId }, select: { regimen: { select: { clave: true } } }, }); const regimenFiscal = regimenes.map(r => r.regimen.clave).join(',') || null; const hasAnyAddress = tenant.calle || tenant.colonia || tenant.ciudad || tenant.municipio || tenant.estado || tenant.codigoPostal; const domicilio = hasAnyAddress ? { calle: tenant.calle || '', numExterior: tenant.numExterior || '', numInterior: tenant.numInterior || '', colonia: tenant.colonia || '', ciudad: tenant.ciudad || '', municipio: tenant.municipio || '', estado: tenant.estado || '', codigoPostal: tenant.codigoPostal || '', telefono: tenant.telefono || '', } : null; return { tenantRfc: tenant.rfc, regimenFiscal, codigoPostal: tenant.codigoPostal, domicilio }; } function mergeContribuyenteWithTenant( row: ContribuyenteRow, tenantData: NonNullable>> ): ContribuyenteRow { return { ...row, regimenFiscal: row.regimenFiscal || tenantData.regimenFiscal, codigoPostal: row.codigoPostal || tenantData.codigoPostal, domicilio: row.domicilio || tenantData.domicilio, }; } export async function listContribuyentes(pool: Pool, entidadIds?: string[], tenantId?: string): Promise { let query = ` SELECT e.id, e.tipo, e.nombre, e.identificador, e.supervisor_user_id AS "supervisorUserId", e.active, e.created_at AS "createdAt", c.rfc, c.regimen_fiscal AS "regimenFiscal", c.codigo_postal AS "codigoPostal", c.domicilio FROM entidades_gestionadas e JOIN contribuyentes c ON c.entidad_id = e.id WHERE e.active = true `; const params: unknown[] = []; if (entidadIds !== undefined) { if (entidadIds.length === 0) return []; // No access = empty list query += ` AND e.id = ANY($1)`; params.push(entidadIds); } query += ' ORDER BY e.created_at DESC'; const { rows } = await pool.query(query, params); if (!tenantId) return rows; const tenantData = await fetchTenantFiscalData(tenantId); if (!tenantData) return rows; return rows.map((r: ContribuyenteRow) => { if (r.rfc !== tenantData.tenantRfc) return r; if (r.regimenFiscal && r.codigoPostal && r.domicilio) return r; return mergeContribuyenteWithTenant(r, tenantData); }); } export async function getContribuyenteById(pool: Pool, id: string, tenantId?: string): Promise { const { rows } = await pool.query(` SELECT e.id, e.tipo, e.nombre, e.identificador, e.supervisor_user_id AS "supervisorUserId", e.active, e.created_at AS "createdAt", c.rfc, c.regimen_fiscal AS "regimenFiscal", c.codigo_postal AS "codigoPostal", c.domicilio FROM entidades_gestionadas e JOIN contribuyentes c ON c.entidad_id = e.id WHERE e.id = $1 `, [id]); const row = rows[0] ?? null; if (!row || !tenantId) return row; const tenantData = await fetchTenantFiscalData(tenantId); if (!tenantData || row.rfc !== tenantData.tenantRfc) return row; if (row.regimenFiscal && row.codigoPostal && row.domicilio) return row; return mergeContribuyenteWithTenant(row, tenantData); } export async function createContribuyente(pool: Pool, data: CreateContribuyenteData): Promise { const client = await pool.connect(); try { await client.query('BEGIN'); const { rows: [entidad] } = await client.query(` INSERT INTO entidades_gestionadas (tipo, nombre, identificador, supervisor_user_id) VALUES ('CONTRIBUYENTE', $1, $2, $3) RETURNING id `, [data.razonSocial, data.rfc.toUpperCase(), data.supervisorUserId ?? null]); await client.query(` INSERT INTO contribuyentes (entidad_id, rfc, regimen_fiscal, codigo_postal, domicilio) VALUES ($1, $2, $3, $4, $5) `, [entidad.id, data.rfc.toUpperCase(), data.regimenFiscal ?? null, data.codigoPostal ?? null, data.domicilio ? JSON.stringify(data.domicilio) : null]); await client.query('COMMIT'); // Backfill: claim existing CFDIs that match this RFC await backfillCfdiContribuyente(pool, entidad.id, data.rfc.toUpperCase()).catch( (err) => console.error('[Contribuyente] Backfill CFDIs failed (non-blocking):', err) ); return (await getContribuyenteById(pool, entidad.id))!; } catch (err) { await client.query('ROLLBACK'); throw err; } finally { client.release(); } } export async function updateContribuyente(pool: Pool, id: string, data: Partial): Promise { const existing = await getContribuyenteById(pool, id); if (!existing) return null; const client = await pool.connect(); try { await client.query('BEGIN'); // Update entidades_gestionadas if needed const entidadSets: string[] = []; const entidadVals: unknown[] = []; let idx = 1; if (data.razonSocial) { entidadSets.push(`nombre = $${idx}`, `identificador = $${idx}`); entidadVals.push(data.razonSocial); idx++; } if (data.supervisorUserId !== undefined) { entidadSets.push(`supervisor_user_id = $${idx}`); entidadVals.push(data.supervisorUserId); idx++; } if (entidadSets.length > 0) { entidadSets.push('updated_at = now()'); entidadVals.push(id); await client.query(`UPDATE entidades_gestionadas SET ${entidadSets.join(', ')} WHERE id = $${idx}`, entidadVals); } // Update contribuyentes if needed const contribSets: string[] = []; const contribVals: unknown[] = []; idx = 1; if (data.regimenFiscal !== undefined) { contribSets.push(`regimen_fiscal = $${idx}`); contribVals.push(data.regimenFiscal); idx++; } if (data.codigoPostal !== undefined) { contribSets.push(`codigo_postal = $${idx}`); contribVals.push(data.codigoPostal); idx++; } if (data.domicilio !== undefined) { contribSets.push(`domicilio = $${idx}`); contribVals.push(JSON.stringify(data.domicilio)); idx++; } if (contribSets.length > 0) { contribVals.push(id); await client.query(`UPDATE contribuyentes SET ${contribSets.join(', ')} WHERE entidad_id = $${idx}`, contribVals); } await client.query('COMMIT'); return (await getContribuyenteById(pool, id))!; } catch (err) { await client.query('ROLLBACK'); throw err; } finally { client.release(); } } export async function deactivateContribuyente(pool: Pool, id: string): Promise { const { rowCount } = await pool.query( 'UPDATE entidades_gestionadas SET active = false, updated_at = now() WHERE id = $1', [id] ); return (rowCount ?? 0) > 0; } /** * Assigns contribuyente_id to CFDIs that match the RFC (emisor or receptor). * Runs after contribuyente creation and can be called manually for backfill. * Only updates CFDIs where contribuyente_id IS NULL (doesn't override). */ export async function backfillCfdiContribuyente(pool: Pool, contribuyenteId: string, rfc: string): Promise { const { rowCount } = await pool.query(` UPDATE cfdis SET contribuyente_id = $1 WHERE contribuyente_id IS NULL AND (rfc_emisor = $2 OR rfc_receptor = $2) `, [contribuyenteId, rfc]); const count = rowCount ?? 0; if (count > 0) { console.log(`[Backfill] Assigned ${count} CFDIs to contribuyente ${rfc} (${contribuyenteId})`); } return count; } /** * Backfills ALL contribuyentes in the tenant BD. Useful after initial SAT sync. */ export async function backfillAllContribuyentes(pool: Pool): Promise { const { rows } = await pool.query('SELECT entidad_id, rfc FROM contribuyentes'); let total = 0; for (const { entidad_id, rfc } of rows) { total += await backfillCfdiContribuyente(pool, entidad_id, rfc); } return total; }