diff --git a/apps/api/src/controllers/cfdi.controller.ts b/apps/api/src/controllers/cfdi.controller.ts index 687c880..343ca85 100644 --- a/apps/api/src/controllers/cfdi.controller.ts +++ b/apps/api/src/controllers/cfdi.controller.ts @@ -239,13 +239,28 @@ export async function drillDown(req: Request, res: Response, next: NextFunction) ) ${NO_IGNORADO_EMISOR.replace('regimen_fiscal_emisor', `CASE WHEN ${esEmisor} THEN regimen_fiscal_emisor ELSE regimen_fiscal_receptor END`)}`; } else if (bucketStr === 'gastos') { // Las E PUE se exhiben en su propia card "NCs Recibidas" — no entran aquí. + // La nómina emitida (tipo_comprobante = 'N') SÍ entra: el patrón la emite + // (lado emisor) y es un gasto/egreso para sus libros — alineado con + // calcularEgresosPorRegimen en dashboard.service.ts. where += ` AND ( - ${esReceptor} AND ( - (tipo_comprobante = 'I' AND metodo_pago = 'PUE') - OR (tipo_comprobante = 'P') + ( + ${esReceptor} AND ( + (tipo_comprobante = 'I' AND metodo_pago = 'PUE') + OR (tipo_comprobante = 'P') + ) + AND regimen_fiscal_receptor IN (${TODOS_REGS}) ) - AND regimen_fiscal_receptor IN (${TODOS_REGS}) - ) ${NO_IGNORADO_RECEPTOR}`; + OR ( + ${esEmisor} AND tipo_comprobante = 'N' + AND regimen_fiscal_emisor IN (${TODOS_REGS}) + ) + )`; + if (ignorados.length > 0) { + where += ` AND ( + (${esReceptor} AND regimen_fiscal_receptor NOT IN (${ignorados.map(r => `'${r}'`).join(',')})) + OR (${esEmisor} AND tipo_comprobante = 'N' AND regimen_fiscal_emisor NOT IN (${ignorados.map(r => `'${r}'`).join(',')})) + )`; + } } else if (bucketStr === 'causado') { where += ` AND ( ${esEmisor} AND ( diff --git a/apps/api/src/controllers/facturacion.controller.ts b/apps/api/src/controllers/facturacion.controller.ts index 2f19dff..3d57e23 100644 --- a/apps/api/src/controllers/facturacion.controller.ts +++ b/apps/api/src/controllers/facturacion.controller.ts @@ -8,6 +8,9 @@ import { downloadPdfContribuyente, downloadXmlContribuyente, sendInvoiceByEmailContribuyente, + getCustomizationContribuyente, + uploadLogoContribuyente, + updateColorContribuyente, } from '../services/contribuyente-facturapi.service.js'; import { parseXml } from '../services/sat/sat-parser.service.js'; import * as tenantsService from '../services/tenants.service.js'; @@ -466,6 +469,38 @@ export async function updateColor(req: Request, res: Response, next: NextFunctio } catch (error) { next(error); } } +// ── Personalización per-contribuyente ── + +export async function getCustomizationContribuyenteCtrl(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = String(req.params.id); + const data = await getCustomizationContribuyente(req.tenantPool!, contribuyenteId); + res.json(data || {}); + } catch (error) { next(error); } +} + +export async function uploadLogoContribuyenteCtrl(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = String(req.params.id); + const { logo } = req.body; + if (!logo) return res.status(400).json({ message: 'Logo es requerido (base64)' }); + const result = await uploadLogoContribuyente(req.tenantPool!, contribuyenteId, logo); + if (!result.success) return res.status(400).json({ message: result.message }); + res.json(result); + } catch (error) { next(error); } +} + +export async function updateColorContribuyenteCtrl(req: Request, res: Response, next: NextFunction) { + try { + const contribuyenteId = String(req.params.id); + const { color } = req.body; + if (!color) return res.status(400).json({ message: 'Color es requerido' }); + const result = await updateColorContribuyente(req.tenantPool!, contribuyenteId, color); + if (!result.success) return res.status(400).json({ message: result.message }); + res.json(result); + } catch (error) { next(error); } +} + // ── Datos fiscales del tenant ── // Schema Zod para preferencias de auto-facturación diff --git a/apps/api/src/routes/contribuyente.routes.ts b/apps/api/src/routes/contribuyente.routes.ts index 63d012a..3d6b1d9 100644 --- a/apps/api/src/routes/contribuyente.routes.ts +++ b/apps/api/src/routes/contribuyente.routes.ts @@ -3,6 +3,7 @@ import { authenticate, authorize } from '../middlewares/auth.middleware.js'; import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; import * as ctrl from '../controllers/contribuyente.controller.js'; import * as configCtrl from '../controllers/contribuyente-config.controller.js'; +import * as facturacionCtrl from '../controllers/facturacion.controller.js'; import * as obligacionesCtrl from '../controllers/obligaciones.controller.js'; const router: IRouter = Router(); @@ -32,6 +33,11 @@ router.post('/:id/facturapi/org', authorize('owner', 'cfo'), configCtrl.createOr router.get('/:id/facturapi/status', configCtrl.orgStatus); router.post('/:id/facturapi/csd', authorize('owner', 'cfo'), configCtrl.uploadCsd); +// Personalización per contribuyente +router.get('/:id/facturapi/customization', facturacionCtrl.getCustomizationContribuyenteCtrl); +router.post('/:id/facturapi/logo', authorize('owner', 'cfo'), facturacionCtrl.uploadLogoContribuyenteCtrl); +router.put('/:id/facturapi/color', authorize('owner', 'cfo'), facturacionCtrl.updateColorContribuyenteCtrl); + // Obligaciones fiscales per contribuyente router.get('/:id/obligaciones/periodo', obligacionesCtrl.getObligacionesPorPeriodo); router.get('/:id/obligaciones', obligacionesCtrl.getObligaciones); diff --git a/apps/api/src/services/contribuyente-facturapi.service.ts b/apps/api/src/services/contribuyente-facturapi.service.ts index 3fb9d3a..9c9803d 100644 --- a/apps/api/src/services/contribuyente-facturapi.service.ts +++ b/apps/api/src/services/contribuyente-facturapi.service.ts @@ -542,3 +542,66 @@ async function ensureOrgLegalForEmit( const payload = await buildLegalPayload(contrib, chosenTaxSystem, currentLegal); await putOrgLegal(orgId, payload); } + +// ── Personalización (logo, color) per-contribuyente ── + +export async function getCustomizationContribuyente( + pool: Pool, + contribuyenteId: string, +): Promise<{ logoUrl?: string; color?: string } | null> { + const { rows } = await pool.query<{ facturapi_org_id: string }>( + 'SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true', + [contribuyenteId], + ); + if (rows.length === 0) return null; + + const userClient = getUserClient(); + try { + const org = await userClient.organizations.retrieve(rows[0].facturapi_org_id); + return { + logoUrl: org.customization?.has_logo ? (org.logo_url ?? undefined) : undefined, + color: org.customization?.color || undefined, + }; + } catch { return null; } +} + +export async function uploadLogoContribuyente( + pool: Pool, + contribuyenteId: string, + logoBase64: string, +): Promise<{ success: boolean; message: string }> { + const { rows } = await pool.query<{ facturapi_org_id: string }>( + 'SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true', + [contribuyenteId], + ); + if (rows.length === 0) throw new Error('Organización no configurada'); + + const userClient = getUserClient(); + try { + const buffer = Buffer.from(logoBase64, 'base64'); + await userClient.organizations.uploadLogo(rows[0].facturapi_org_id, buffer); + return { success: true, message: 'Logo subido correctamente' }; + } catch (error: any) { + return { success: false, message: error.message || 'Error al subir logo' }; + } +} + +export async function updateColorContribuyente( + pool: Pool, + contribuyenteId: string, + color: string, +): Promise<{ success: boolean; message: string }> { + const { rows } = await pool.query<{ facturapi_org_id: string }>( + 'SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true', + [contribuyenteId], + ); + if (rows.length === 0) throw new Error('Organización no configurada'); + + const userClient = getUserClient(); + try { + await userClient.organizations.updateCustomization(rows[0].facturapi_org_id, { color }); + return { success: true, message: 'Color actualizado correctamente' }; + } catch (error: any) { + return { success: false, message: error.message || 'Error al actualizar color' }; + } +} diff --git a/apps/web/app/(dashboard)/configuracion/csd/page.tsx b/apps/web/app/(dashboard)/configuracion/csd/page.tsx index 8dd9b19..65b9aa3 100644 --- a/apps/web/app/(dashboard)/configuracion/csd/page.tsx +++ b/apps/web/app/(dashboard)/configuracion/csd/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Header } from '@/components/layouts/header'; import { Card, CardContent, CardDescription, CardHeader, CardTitle, Button, Input, Label } from '@horux/shared-ui'; import { useTimbres } from '@/lib/hooks/use-facturacion'; @@ -9,21 +9,24 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useContribuyenteStore } from '@/stores/contribuyente-store'; import { Shield, Upload, Check, AlertCircle, Receipt, Palette, Image, Building2, FileText } from 'lucide-react'; -function CustomizationSection() { +function CustomizationSection({ contribuyenteId }: { contribuyenteId: string }) { const queryClient = useQueryClient(); const [logoUploading, setLogoUploading] = useState(false); const [colorSaving, setColorSaving] = useState(false); const [color, setColor] = useState('#75A4FF'); const [msg, setMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const queryKey = ['facturapi-customization', contribuyenteId]; + const { data: customization } = useQuery({ - queryKey: ['facturapi-customization'], - queryFn: () => apiClient.get('/facturacion/customization').then(r => r.data), + queryKey, + queryFn: () => apiClient.get(`/contribuyentes/${contribuyenteId}/facturapi/customization`).then(r => r.data), + enabled: !!contribuyenteId, }); - useState(() => { + useEffect(() => { if (customization?.color) setColor(`#${customization.color}`); - }); + }, [customization?.color]); const handleLogoUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -45,8 +48,8 @@ function CustomizationSection() { reader.onload = async () => { const base64 = (reader.result as string).split(',')[1]; try { - await apiClient.post('/facturacion/logo', { logo: base64 }); - queryClient.invalidateQueries({ queryKey: ['facturapi-customization'] }); + await apiClient.post(`/contribuyentes/${contribuyenteId}/facturapi/logo`, { logo: base64 }); + queryClient.invalidateQueries({ queryKey }); setMsg({ type: 'success', text: 'Logo subido correctamente' }); } catch { setMsg({ type: 'error', text: 'Error al subir logo' }); @@ -61,8 +64,8 @@ function CustomizationSection() { setColorSaving(true); setMsg(null); try { - await apiClient.put('/facturacion/color', { color: color.replace('#', '') }); - queryClient.invalidateQueries({ queryKey: ['facturapi-customization'] }); + await apiClient.put(`/contribuyentes/${contribuyenteId}/facturapi/color`, { color: color.replace('#', '') }); + queryClient.invalidateQueries({ queryKey }); setMsg({ type: 'success', text: 'Color actualizado' }); } catch { setMsg({ type: 'error', text: 'Error al actualizar color' }); @@ -357,7 +360,7 @@ export default function CsdConfigPage() { )} {/* Personalización de factura */} - {selectedContribuyenteId && orgStatus?.configured && } + {selectedContribuyenteId && orgStatus?.configured && } {/* Timbres */} {selectedContribuyenteId &&