fix: personalización logo/color por contribuyente en vez de tenant
- Agrega getCustomizationContribuyente, uploadLogoContribuyente, updateColorContribuyente en contribuyente-facturapi.service.ts - Agrega controllers per-contribuyente en facturacion.controller.ts - Agrega rutas GET/POST/PUT /contribuyentes/:id/facturapi/customization|logo|color - Modifica CustomizationSection para recibir contribuyenteId, usar endpoints per-contribuyente, y corrige useState mal aplicado a useEffect - Backend y frontend buildeados y deployados
This commit is contained in:
@@ -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`)}`;
|
) ${NO_IGNORADO_EMISOR.replace('regimen_fiscal_emisor', `CASE WHEN ${esEmisor} THEN regimen_fiscal_emisor ELSE regimen_fiscal_receptor END`)}`;
|
||||||
} else if (bucketStr === 'gastos') {
|
} else if (bucketStr === 'gastos') {
|
||||||
// Las E PUE se exhiben en su propia card "NCs Recibidas" — no entran aquí.
|
// 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 (
|
where += ` AND (
|
||||||
|
(
|
||||||
${esReceptor} AND (
|
${esReceptor} AND (
|
||||||
(tipo_comprobante = 'I' AND metodo_pago = 'PUE')
|
(tipo_comprobante = 'I' AND metodo_pago = 'PUE')
|
||||||
OR (tipo_comprobante = 'P')
|
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') {
|
} else if (bucketStr === 'causado') {
|
||||||
where += ` AND (
|
where += ` AND (
|
||||||
${esEmisor} AND (
|
${esEmisor} AND (
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
downloadPdfContribuyente,
|
downloadPdfContribuyente,
|
||||||
downloadXmlContribuyente,
|
downloadXmlContribuyente,
|
||||||
sendInvoiceByEmailContribuyente,
|
sendInvoiceByEmailContribuyente,
|
||||||
|
getCustomizationContribuyente,
|
||||||
|
uploadLogoContribuyente,
|
||||||
|
updateColorContribuyente,
|
||||||
} from '../services/contribuyente-facturapi.service.js';
|
} from '../services/contribuyente-facturapi.service.js';
|
||||||
import { parseXml } from '../services/sat/sat-parser.service.js';
|
import { parseXml } from '../services/sat/sat-parser.service.js';
|
||||||
import * as tenantsService from '../services/tenants.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); }
|
} 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 ──
|
// ── Datos fiscales del tenant ──
|
||||||
|
|
||||||
// Schema Zod para preferencias de auto-facturación
|
// Schema Zod para preferencias de auto-facturación
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { authenticate, authorize } from '../middlewares/auth.middleware.js';
|
|||||||
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||||
import * as ctrl from '../controllers/contribuyente.controller.js';
|
import * as ctrl from '../controllers/contribuyente.controller.js';
|
||||||
import * as configCtrl from '../controllers/contribuyente-config.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';
|
import * as obligacionesCtrl from '../controllers/obligaciones.controller.js';
|
||||||
|
|
||||||
const router: IRouter = Router();
|
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.get('/:id/facturapi/status', configCtrl.orgStatus);
|
||||||
router.post('/:id/facturapi/csd', authorize('owner', 'cfo'), configCtrl.uploadCsd);
|
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
|
// Obligaciones fiscales per contribuyente
|
||||||
router.get('/:id/obligaciones/periodo', obligacionesCtrl.getObligacionesPorPeriodo);
|
router.get('/:id/obligaciones/periodo', obligacionesCtrl.getObligacionesPorPeriodo);
|
||||||
router.get('/:id/obligaciones', obligacionesCtrl.getObligaciones);
|
router.get('/:id/obligaciones', obligacionesCtrl.getObligaciones);
|
||||||
|
|||||||
@@ -542,3 +542,66 @@ async function ensureOrgLegalForEmit(
|
|||||||
const payload = await buildLegalPayload(contrib, chosenTaxSystem, currentLegal);
|
const payload = await buildLegalPayload(contrib, chosenTaxSystem, currentLegal);
|
||||||
await putOrgLegal(orgId, payload);
|
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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Header } from '@/components/layouts/header';
|
import { Header } from '@/components/layouts/header';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Button, Input, Label } from '@horux/shared-ui';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Button, Input, Label } from '@horux/shared-ui';
|
||||||
import { useTimbres } from '@/lib/hooks/use-facturacion';
|
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 { useContribuyenteStore } from '@/stores/contribuyente-store';
|
||||||
import { Shield, Upload, Check, AlertCircle, Receipt, Palette, Image, Building2, FileText } from 'lucide-react';
|
import { Shield, Upload, Check, AlertCircle, Receipt, Palette, Image, Building2, FileText } from 'lucide-react';
|
||||||
|
|
||||||
function CustomizationSection() {
|
function CustomizationSection({ contribuyenteId }: { contribuyenteId: string }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [logoUploading, setLogoUploading] = useState(false);
|
const [logoUploading, setLogoUploading] = useState(false);
|
||||||
const [colorSaving, setColorSaving] = useState(false);
|
const [colorSaving, setColorSaving] = useState(false);
|
||||||
const [color, setColor] = useState('#75A4FF');
|
const [color, setColor] = useState('#75A4FF');
|
||||||
const [msg, setMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
const [msg, setMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
const queryKey = ['facturapi-customization', contribuyenteId];
|
||||||
|
|
||||||
const { data: customization } = useQuery({
|
const { data: customization } = useQuery({
|
||||||
queryKey: ['facturapi-customization'],
|
queryKey,
|
||||||
queryFn: () => apiClient.get('/facturacion/customization').then(r => r.data),
|
queryFn: () => apiClient.get(`/contribuyentes/${contribuyenteId}/facturapi/customization`).then(r => r.data),
|
||||||
|
enabled: !!contribuyenteId,
|
||||||
});
|
});
|
||||||
|
|
||||||
useState(() => {
|
useEffect(() => {
|
||||||
if (customization?.color) setColor(`#${customization.color}`);
|
if (customization?.color) setColor(`#${customization.color}`);
|
||||||
});
|
}, [customization?.color]);
|
||||||
|
|
||||||
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -45,8 +48,8 @@ function CustomizationSection() {
|
|||||||
reader.onload = async () => {
|
reader.onload = async () => {
|
||||||
const base64 = (reader.result as string).split(',')[1];
|
const base64 = (reader.result as string).split(',')[1];
|
||||||
try {
|
try {
|
||||||
await apiClient.post('/facturacion/logo', { logo: base64 });
|
await apiClient.post(`/contribuyentes/${contribuyenteId}/facturapi/logo`, { logo: base64 });
|
||||||
queryClient.invalidateQueries({ queryKey: ['facturapi-customization'] });
|
queryClient.invalidateQueries({ queryKey });
|
||||||
setMsg({ type: 'success', text: 'Logo subido correctamente' });
|
setMsg({ type: 'success', text: 'Logo subido correctamente' });
|
||||||
} catch {
|
} catch {
|
||||||
setMsg({ type: 'error', text: 'Error al subir logo' });
|
setMsg({ type: 'error', text: 'Error al subir logo' });
|
||||||
@@ -61,8 +64,8 @@ function CustomizationSection() {
|
|||||||
setColorSaving(true);
|
setColorSaving(true);
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
try {
|
try {
|
||||||
await apiClient.put('/facturacion/color', { color: color.replace('#', '') });
|
await apiClient.put(`/contribuyentes/${contribuyenteId}/facturapi/color`, { color: color.replace('#', '') });
|
||||||
queryClient.invalidateQueries({ queryKey: ['facturapi-customization'] });
|
queryClient.invalidateQueries({ queryKey });
|
||||||
setMsg({ type: 'success', text: 'Color actualizado' });
|
setMsg({ type: 'success', text: 'Color actualizado' });
|
||||||
} catch {
|
} catch {
|
||||||
setMsg({ type: 'error', text: 'Error al actualizar color' });
|
setMsg({ type: 'error', text: 'Error al actualizar color' });
|
||||||
@@ -357,7 +360,7 @@ export default function CsdConfigPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Personalización de factura */}
|
{/* Personalización de factura */}
|
||||||
{selectedContribuyenteId && orgStatus?.configured && <CustomizationSection />}
|
{selectedContribuyenteId && orgStatus?.configured && <CustomizationSection contribuyenteId={selectedContribuyenteId} />}
|
||||||
|
|
||||||
{/* Timbres */}
|
{/* Timbres */}
|
||||||
{selectedContribuyenteId && <Card>
|
{selectedContribuyenteId && <Card>
|
||||||
|
|||||||
Reference in New Issue
Block a user