chore: catálogo obligaciones, cierre automático, fixes SAT y facturación
- Catálogo de obligaciones fiscales expandido a 30 entradas con campo requierePago. - Soporte de frecuencia cuatrimestral en obligaciones y declaraciones. - Automatización de cierre de obligaciones fiscales desde Documentos › Declaraciones. - Nuevas tablas obligacion_evidencias, obligacion_periodos estados y declaracion_obligaciones. - Nuevo servicio obligacion-evidencias.service.ts y endpoints REST. - Refactor de declaraciones.service.ts para vincular obligaciones y crear evidencias. - Notificaciones por email para evidencias de obligaciones. - Adjuntar PDFs en correo de declaración subida. - Fix drill-down de CFDIs: carga completa al visualizar. - Fix sincronización SAT: tipos P/N, UUID case-insensitive, no reutilizar requestId. - Fix suscripciones pending en /configuracion/planes-despacho. - Fix sugerencias de Clave Producto SAT: importar catálogo y robustecer autocomplete. - Quitar toggle manual de completado en Configuración › Obligaciones fiscales › Tareas. - Scripts de soporte para Demo Ventas y utilerías (change-user-email, resend-welcome, import-clave-prod-serv). - Documentación de cambios en docs/CAMBIOS-2026-05-04.md.
This commit is contained in:
@@ -189,6 +189,7 @@ export default function ObligacionesPage() {
|
||||
mensual: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
||||
bimestral: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
||||
trimestral: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
|
||||
cuatrimestral: 'bg-pink-100 text-pink-700 dark:bg-pink-900 dark:text-pink-300',
|
||||
anual: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
||||
eventual: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { apiClient } from '@/lib/api/client';
|
||||
import { subscribeMe, changeMyPlan, cancelMySubscription, upgradeMe, generatePaymentLink } from '@/lib/api/subscription';
|
||||
import { getPendingInvitation, acceptInvitation } from '@/lib/api/trial-invitations';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { getSubscriptionState } from '@horux/shared';
|
||||
|
||||
type Despachoplan = 'trial' | 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus' | 'custom';
|
||||
type PaidPlan = 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus';
|
||||
@@ -89,15 +90,14 @@ export default function PlanesDespachoPage() {
|
||||
// El usuario puede cancelar si tiene una suscripción que aún corre (paid, trial,
|
||||
// custom). Si ya está cancelada o expirada, no hay nada que cancelar.
|
||||
const subStatus = planInfo?.subscription?.status ?? null;
|
||||
const hasActiveSub = subStatus != null
|
||||
&& subStatus !== 'cancelled'
|
||||
&& subStatus !== 'trial_expired';
|
||||
// Estados en los que se puede generar un link de pago (incluye trial y vencido).
|
||||
const subState = planInfo?.subscription ? getSubscriptionState(planInfo.subscription) : null;
|
||||
const hasActiveSub = subState?.isActive || subState?.isTrial || subState?.isCancelledInPeriod || false;
|
||||
// Estados en los que se puede generar un link de pago (incluye trial, vencido y pending).
|
||||
const isPayableStatus = subStatus === 'trial'
|
||||
|| subStatus === 'trial_expired'
|
||||
|| subStatus === 'pending'
|
||||
|| hasActiveSub;
|
||||
const isCurrentPlanPaid = currentPlan === planInfo?.subscription?.plan
|
||||
&& (subStatus === 'authorized' || subStatus === 'pending');
|
||||
const isCurrentPlanPaid = currentPlan === planInfo?.subscription?.plan && subState?.isActive === true;
|
||||
|
||||
/** Resuelve la frecuencia para un plan. Mi Empresa y Mi Empresa+ leen su
|
||||
* propio toggle; el resto (business_*) siempre annual. */
|
||||
@@ -112,6 +112,15 @@ export default function PlanesDespachoPage() {
|
||||
setBusy(plan);
|
||||
setMessage(null);
|
||||
try {
|
||||
// Si el plan actual está pendiente de pago, solo regeneramos el link de pago.
|
||||
if (currentPlan === plan && subState?.isPending) {
|
||||
return await handlePagarAhora();
|
||||
}
|
||||
// Si tiene una sub pendiente en otro plan, no permitir cambiar hasta pagar.
|
||||
if (subState?.isPending) {
|
||||
setMessage({ kind: 'err', text: 'Completa el pago del plan actual antes de cambiar de plan.' });
|
||||
return;
|
||||
}
|
||||
// Sin sub activa: subscribe directo → MP (preapproval del plan completo).
|
||||
const result = await subscribeMe({ plan, frequency });
|
||||
window.open(result.paymentUrl, '_blank');
|
||||
@@ -197,10 +206,10 @@ export default function PlanesDespachoPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function ActiveBadge() {
|
||||
function CurrentPlanBadge({ pending }: { pending?: boolean }) {
|
||||
return (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-green-600 text-white text-xs px-3 py-1 rounded-full font-medium whitespace-nowrap">
|
||||
Plan actual
|
||||
<div className={`absolute -top-3 left-1/2 -translate-x-1/2 text-white text-xs px-3 py-1 rounded-full font-medium whitespace-nowrap ${pending ? 'bg-yellow-600' : 'bg-green-600'}`}>
|
||||
{pending ? 'Plan actual — pendiente' : 'Plan actual'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -325,7 +334,7 @@ export default function PlanesDespachoPage() {
|
||||
)}
|
||||
|
||||
{/* Banner de suscripción activa */}
|
||||
{!loading && planInfo?.subscription && hasPaidPlan && (subStatus === 'authorized' || subStatus === 'pending') && (() => {
|
||||
{!loading && planInfo?.subscription && hasPaidPlan && subState?.isActive && (() => {
|
||||
const sub = planInfo.subscription;
|
||||
const periodEndDate = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null;
|
||||
const fechaFormato = periodEndDate
|
||||
@@ -352,6 +361,21 @@ export default function PlanesDespachoPage() {
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Banner de suscripción pendiente */}
|
||||
{!loading && planInfo?.subscription && hasPaidPlan && subState?.isPending && (
|
||||
<div className="flex items-start gap-3 bg-yellow-50 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 rounded-lg px-4 py-3 max-w-3xl mx-auto">
|
||||
<Clock className="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm space-y-0.5">
|
||||
<div className="font-semibold text-yellow-800 dark:text-yellow-300">
|
||||
Suscripción pendiente de pago
|
||||
</div>
|
||||
<div className="text-yellow-700 dark:text-yellow-400">
|
||||
Tu suscripción aún no está activa. Completa el pago para evitar la suspensión del servicio.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Banner de trial vencido */}
|
||||
{!loading && subStatus === 'trial_expired' && hasPaidPlan && (
|
||||
<div className="flex items-start gap-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg px-4 py-3 max-w-3xl mx-auto">
|
||||
@@ -423,7 +447,7 @@ export default function PlanesDespachoPage() {
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 max-w-7xl mx-auto">
|
||||
{/* Mi Empresa */}
|
||||
<Card className={`relative flex flex-col${currentPlan === 'mi_empresa' ? ' ring-2 ring-green-500' : ''}`}>
|
||||
{currentPlan === 'mi_empresa' && <ActiveBadge />}
|
||||
{currentPlan === 'mi_empresa' && <CurrentPlanBadge pending={subState?.isPending} />}
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto bg-emerald-100 dark:bg-emerald-900 rounded-full p-3 w-fit mb-2">
|
||||
<Cloud className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
|
||||
@@ -457,7 +481,7 @@ export default function PlanesDespachoPage() {
|
||||
|
||||
{/* Mi Empresa + */}
|
||||
<Card className={`relative flex flex-col${currentPlan === 'mi_empresa_plus' ? ' ring-2 ring-green-500' : ''}`}>
|
||||
{currentPlan === 'mi_empresa_plus' && <ActiveBadge />}
|
||||
{currentPlan === 'mi_empresa_plus' && <CurrentPlanBadge pending={subState?.isPending} />}
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto bg-teal-100 dark:bg-teal-900 rounded-full p-3 w-fit mb-2">
|
||||
<Cloud className="h-6 w-6 text-teal-600 dark:text-teal-400" />
|
||||
@@ -494,7 +518,7 @@ export default function PlanesDespachoPage() {
|
||||
{/* Business Control */}
|
||||
<Card className={`relative flex flex-col${currentPlan === 'business_control' ? ' ring-2 ring-green-500' : ' border-primary ring-2 ring-primary/20'}`}>
|
||||
{currentPlan === 'business_control'
|
||||
? <ActiveBadge />
|
||||
? <CurrentPlanBadge pending={subState?.isPending} />
|
||||
: (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground text-xs px-3 py-1 rounded-full">
|
||||
Más popular
|
||||
@@ -529,7 +553,7 @@ export default function PlanesDespachoPage() {
|
||||
|
||||
{/* Enterprise (key interna: business_cloud) */}
|
||||
<Card className={`relative flex flex-col${currentPlan === 'business_cloud' ? ' ring-2 ring-green-500' : ''}`}>
|
||||
{currentPlan === 'business_cloud' && <ActiveBadge />}
|
||||
{currentPlan === 'business_cloud' && <CurrentPlanBadge pending={subState?.isPending} />}
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto bg-purple-100 dark:bg-purple-900 rounded-full p-3 w-fit mb-2">
|
||||
<Cloud className="h-6 w-6 text-purple-600 dark:text-purple-400" />
|
||||
|
||||
@@ -23,9 +23,11 @@ import {
|
||||
import { PapeleriaTab } from '@/components/documentos/papeleria-tab';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import * as docsApi from '@/lib/api/documentos';
|
||||
import { getObligacionesPorPeriodo, type ObligacionPeriodo } from '@/lib/api/obligaciones';
|
||||
|
||||
const MESES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
|
||||
const IMPUESTOS: Impuesto[] = ['IVA', 'ISR', 'IEPS', 'ISN', 'DIOT', 'OTRO', 'ISH'];
|
||||
const OBLIGACIONES_ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'];
|
||||
const PERIODICIDADES: { value: Periodicidad; label: string }[] = [
|
||||
{ value: 'mensual', label: 'Mensual' },
|
||||
{ value: 'bimestral', label: 'Bimestral' },
|
||||
@@ -504,7 +506,7 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
const [tipo, setTipo] = useState<'normal' | 'complementaria'>('normal');
|
||||
const [periodicidad, setPeriodicidad] = useState<Periodicidad>('mensual');
|
||||
const yearsOptions = Array.from({ length: 6 }, (_, i) => currentYear - i);
|
||||
const [impuestos, setImpuestos] = useState<Impuesto[]>([]);
|
||||
const [obligacionesIds, setObligacionesIds] = useState<string[]>([]);
|
||||
const [montoPago, setMontoPago] = useState('');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [ligaFile, setLigaFile] = useState<File | null>(null);
|
||||
@@ -512,6 +514,15 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const periodOptions = getPeriodOptions(periodicidad);
|
||||
const periodo = `${año}-${String(mes).padStart(2, '0')}`;
|
||||
|
||||
const obligacionesQ = useQuery({
|
||||
queryKey: ['obligaciones-periodo-declaracion', selectedContribuyenteId, periodo],
|
||||
queryFn: () => selectedContribuyenteId
|
||||
? getObligacionesPorPeriodo(selectedContribuyenteId, periodo, false)
|
||||
: Promise.resolve({ data: [], periodo }),
|
||||
enabled: !!selectedContribuyenteId,
|
||||
});
|
||||
|
||||
const handlePeriodicidadChange = (p: Periodicidad) => {
|
||||
setPeriodicidad(p);
|
||||
@@ -522,21 +533,21 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleImpuesto = (i: Impuesto) => {
|
||||
setImpuestos(prev => prev.includes(i) ? prev.filter(x => x !== i) : [...prev, i]);
|
||||
const toggleObligacion = (id: string) => {
|
||||
setObligacionesIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
|
||||
};
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
if (!file) return setErr('Selecciona el PDF de la declaración');
|
||||
if (impuestos.length === 0) return setErr('Selecciona al menos un impuesto');
|
||||
if (obligacionesIds.length === 0) return setErr('Selecciona al menos una obligación fiscal');
|
||||
try {
|
||||
const pdfBase64 = await fileToBase64(file);
|
||||
const ligaPagoBase64 = ligaFile ? await fileToBase64(ligaFile) : undefined;
|
||||
const montoNum = montoPago.trim() !== '' ? parseFloat(montoPago) : undefined;
|
||||
await create.mutateAsync({
|
||||
año, mes, tipo, periodicidad, impuestos,
|
||||
año, mes, tipo, periodicidad, obligacionesIds,
|
||||
montoPago: montoNum,
|
||||
pdfBase64, pdfFilename: file.name,
|
||||
ligaPagoBase64,
|
||||
@@ -606,16 +617,51 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Impuestos cubiertos</Label>
|
||||
<div className="grid grid-cols-3 gap-2 mt-1">
|
||||
{IMPUESTOS.map(i => (
|
||||
<label key={i} className={`flex items-center gap-2 px-3 py-2 rounded-md border cursor-pointer text-sm ${impuestos.includes(i) ? 'bg-primary/10 border-primary' : 'hover:bg-muted'}`}>
|
||||
<input type="checkbox" checked={impuestos.includes(i)} onChange={() => toggleImpuesto(i)} className="accent-primary" />
|
||||
{i}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">Selecciona todos los impuestos que incluye esta declaración — definen qué recordatorios se desactivan.</p>
|
||||
<Label>Obligaciones fiscales cubiertas</Label>
|
||||
{!selectedContribuyenteId ? (
|
||||
<p className="text-sm text-muted-foreground mt-1">Selecciona un contribuyente para ver sus obligaciones.</p>
|
||||
) : obligacionesQ.isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Cargando obligaciones...
|
||||
</div>
|
||||
) : obligacionesQ.error ? (
|
||||
<p className="text-sm text-red-600 mt-1">Error al cargar obligaciones.</p>
|
||||
) : obligacionesQ.data?.data.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground mt-1">No hay obligaciones fiscales configuradas para este periodo.</p>
|
||||
) : (
|
||||
<div className="space-y-3 mt-2 max-h-60 overflow-y-auto rounded-md border p-3">
|
||||
{Array.from(new Set((obligacionesQ.data?.data || []).map(o => o.categoria || 'Sin categoría'))).map((categoria) => (
|
||||
<div key={categoria}>
|
||||
<p className="text-xs font-semibold uppercase text-muted-foreground mb-1.5">{categoria}</p>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{(obligacionesQ.data?.data || [])
|
||||
.filter(o => (o.categoria || 'Sin categoría') === categoria)
|
||||
.map((o) => (
|
||||
<label
|
||||
key={o.id}
|
||||
className={`flex items-start gap-2 px-3 py-2 rounded-md border cursor-pointer text-sm ${obligacionesIds.includes(o.id) ? 'bg-primary/10 border-primary' : 'hover:bg-muted'}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={obligacionesIds.includes(o.id)}
|
||||
onChange={() => toggleObligacion(o.id)}
|
||||
className="accent-primary mt-0.5"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="font-medium">{o.nombre}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2 capitalize">({o.frecuencia || '—'})</span>
|
||||
{o.requierePago && (
|
||||
<span className="block text-[10px] text-muted-foreground">Requiere comprobante de pago</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">Selecciona las obligaciones fiscales que cubre esta declaración. Al guardar se marcarán como presentadas y, si aplica, quedarán a la espera de su comprobante de pago.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { formatCurrency, toCfdiDate } from '@/lib/utils';
|
||||
import { exportToExcel } from '@/lib/export-excel';
|
||||
import { useTableSort } from '@horux/shared-ui';
|
||||
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||
import { getCfdiById } from '@/lib/api/cfdi';
|
||||
import { Eye, Download } from 'lucide-react';
|
||||
import type { Cfdi } from '@horux/shared';
|
||||
|
||||
@@ -44,6 +45,7 @@ export default function DrillDownPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const titulo = searchParams.get('titulo') || 'Detalle de CFDIs';
|
||||
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
|
||||
const [loadingCfdiId, setLoadingCfdiId] = useState<number | null>(null);
|
||||
const { selectedContribuyenteId } = useContribuyenteStore();
|
||||
|
||||
const params = new URLSearchParams();
|
||||
@@ -154,7 +156,23 @@ export default function DrillDownPage() {
|
||||
<td className="py-2 text-xs font-mono">{cfdi.regimenEmisor || '-'}</td>
|
||||
<td className="py-2 text-xs font-mono">{cfdi.regimenReceptor || '-'}</td>
|
||||
<td className="py-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedCfdi(cfdi)} title="Ver factura">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={loadingCfdiId === cfdi.id}
|
||||
onClick={async () => {
|
||||
setLoadingCfdiId(cfdi.id);
|
||||
try {
|
||||
const fullCfdi = await getCfdiById(String(cfdi.id));
|
||||
setSelectedCfdi(fullCfdi);
|
||||
} catch (err) {
|
||||
console.error('Error cargando CFDI completo:', err);
|
||||
} finally {
|
||||
setLoadingCfdiId(null);
|
||||
}
|
||||
}}
|
||||
title="Ver factura"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
|
||||
@@ -554,12 +554,26 @@ export default function FacturacionPage() {
|
||||
? clavesUnidad?.filter(u => !SERVICE_UNITS.includes(u.clave))
|
||||
: clavesUnidad;
|
||||
|
||||
const prodSearchAbort = useRef<AbortController | null>(null);
|
||||
|
||||
const handleSearchProduct = async (q: string, idx: number) => {
|
||||
setProdSearch(q);
|
||||
setSearchingIdx(idx);
|
||||
if (q.length < 2) { setProdResults([]); return; }
|
||||
const results = await searchClaveProdServ(q);
|
||||
setProdResults(results);
|
||||
setProdResults([]);
|
||||
if (q.length < 2) return;
|
||||
|
||||
prodSearchAbort.current?.abort();
|
||||
prodSearchAbort.current = new AbortController();
|
||||
|
||||
try {
|
||||
const results = await searchClaveProdServ(q, prodSearchAbort.current.signal);
|
||||
setProdResults(results ?? []);
|
||||
} catch (err: any) {
|
||||
if (err.name !== 'AbortError' && err.code !== 'ERR_CANCELED') {
|
||||
console.error('Error buscando clave SAT:', err);
|
||||
}
|
||||
setProdResults([]);
|
||||
}
|
||||
};
|
||||
|
||||
const selectProduct = (idx: number, clave: string, descripcion: string) => {
|
||||
@@ -1418,6 +1432,7 @@ export default function FacturacionPage() {
|
||||
onChange={e => handleSearchProduct(e.target.value, idx)}
|
||||
onFocus={() => { setSearchingIdx(idx); setProdSearch(c.productKey); }}
|
||||
placeholder="Buscar clave SAT..."
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
<Search className="absolute right-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
|
||||
@@ -147,6 +147,7 @@ export default function PendientesPage() {
|
||||
mensual: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
||||
bimestral: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
||||
trimestral: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
|
||||
cuatrimestral: 'bg-pink-100 text-pink-700 dark:bg-pink-900 dark:text-pink-300',
|
||||
anual: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
||||
};
|
||||
return f ? (
|
||||
|
||||
Reference in New Issue
Block a user