- 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.
532 lines
19 KiB
TypeScript
532 lines
19 KiB
TypeScript
import type { Pool } from 'pg';
|
|
import { OBLIGACIONES_CATALOGO, getRecomendaciones, type ObligacionFiscal } from '../constants/obligaciones-fiscales.js';
|
|
|
|
function requierePagoPorCatalogo(catalogoId: string | null): boolean {
|
|
if (!catalogoId) return true;
|
|
return OBLIGACIONES_CATALOGO.find((o) => o.id === catalogoId)?.requierePago ?? true;
|
|
}
|
|
|
|
/**
|
|
* Keyword-based matching: each catalog entry has discriminant keywords
|
|
* that must ALL appear in the SAT description (normalized, lowercase, no accents).
|
|
* Multiple keyword sets per entry allow for variant phrasings.
|
|
*/
|
|
const CATALOG_MATCH_RULES: Array<{ id: string; keywords: string[][] }> = [
|
|
// ISR provisionales
|
|
{ id: 'isr-provisional', keywords: [
|
|
['pago provisional', 'isr', 'actividades empresariales'],
|
|
['pago provisional', 'isr personas morales', 'general'],
|
|
['pago provisional mensual de isr personas morales'],
|
|
]},
|
|
{ id: 'isr-resico-pm', keywords: [
|
|
['isr', 'simplificado de confianza', 'pago provisional'],
|
|
['isr', 'simplificado de confianza', 'pago provisional mensual'],
|
|
]},
|
|
{ id: 'isr-resico-pf', keywords: [
|
|
['isr', 'simplificado de confianza', 'ajuste anual'],
|
|
// Note: PF RESICO "pago provisional" is same id as PM; differentiate by RFC length at runtime
|
|
]},
|
|
|
|
// IVA
|
|
{ id: 'iva-mensual', keywords: [
|
|
['pago definitivo', 'iva', 'mensual'],
|
|
['pago definitivo mensual de iva'],
|
|
]},
|
|
|
|
// DIOT
|
|
{ id: 'diot', keywords: [
|
|
['proveedores', 'iva'],
|
|
['diot'],
|
|
]},
|
|
|
|
// Anuales
|
|
{ id: 'anual-isr-pm', keywords: [
|
|
['declaracion anual', 'isr', 'personas morales'],
|
|
['anual de isr del regimen', 'simplificado', 'personas morales'],
|
|
]},
|
|
{ id: 'anual-isr-pf', keywords: [
|
|
['declaracion anual', 'isr', 'personas fisicas'],
|
|
['ajuste anual', 'isr', 'declaracion anual', 'simplificado'],
|
|
]},
|
|
|
|
// Retenciones ISR
|
|
{ id: 'ret-isr-sueldos', keywords: [
|
|
['retenciones', 'isr', 'sueldos y salarios'],
|
|
['retenciones mensuales de isr por sueldos'],
|
|
]},
|
|
{ id: 'ret-isr-asimilados', keywords: [
|
|
['retenciones', 'isr', 'asimilados a salarios'],
|
|
['retenciones mensuales de isr por ingresos asimilados'],
|
|
]},
|
|
{ id: 'ret-isr-honorarios', keywords: [
|
|
['retencion', 'isr', 'servicios profesionales'],
|
|
['retenciones de isr por servicios profesionales'], // TPR variant (missing accent)
|
|
]},
|
|
|
|
// Retenciones IVA
|
|
{ id: 'ret-iva', keywords: [
|
|
['retenciones de iva'],
|
|
['retenciones', 'iva', 'mensual'],
|
|
]},
|
|
|
|
// IEPS
|
|
{ id: 'ieps', keywords: [
|
|
['ieps'],
|
|
]},
|
|
|
|
// RIF bimestral
|
|
{ id: 'isr-provisional', keywords: [
|
|
['bimestral del rif'],
|
|
['pago definitivo bimestral del rif'],
|
|
]},
|
|
|
|
// Arrendamiento
|
|
{ id: 'isr-provisional', keywords: [
|
|
['isr', 'arrendamiento de inmuebles', 'pago provisional'],
|
|
['isr por arrendamiento de inmuebles pf'],
|
|
]},
|
|
|
|
// Informativas (no tienen match directo en catálogo pero agrupar con DIM)
|
|
{ id: 'dim', keywords: [
|
|
['declaracion informativa anual', 'pagos y retenciones'],
|
|
['declaracion informativa anual de clientes y proveedores'],
|
|
['declaracion informativa anual de retenciones'],
|
|
['declaracion informativa de iva con la anual'],
|
|
]},
|
|
];
|
|
|
|
function normalizeForMatch(s: string): string {
|
|
return s
|
|
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
|
.toLowerCase()
|
|
.replace(/[.,;:()]/g, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function matchCsfToCatalog(descripcion: string, rfc: string): ObligacionFiscal | undefined {
|
|
const norm = normalizeForMatch(descripcion);
|
|
const esPM = rfc.length === 12;
|
|
|
|
for (const rule of CATALOG_MATCH_RULES) {
|
|
for (const kwSet of rule.keywords) {
|
|
const allMatch = kwSet.every(kw => norm.includes(normalizeForMatch(kw)));
|
|
if (allMatch) {
|
|
// Special case: RESICO ISR provisional — PM vs PF
|
|
if (rule.id === 'isr-resico-pm' && !esPM) {
|
|
return OBLIGACIONES_CATALOGO.find(c => c.id === 'isr-resico-pf');
|
|
}
|
|
if (rule.id === 'isr-resico-pf' && esPM) {
|
|
return OBLIGACIONES_CATALOGO.find(c => c.id === 'isr-resico-pm');
|
|
}
|
|
return OBLIGACIONES_CATALOGO.find(c => c.id === rule.id);
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export interface ObligacionContribuyente {
|
|
id: string;
|
|
contribuyenteId: string;
|
|
catalogoId: string | null;
|
|
nombre: string;
|
|
fundamento: string | null;
|
|
frecuencia: string | null;
|
|
fechaLimite: string | null;
|
|
categoria: string | null;
|
|
activa: boolean;
|
|
esRecomendada: boolean;
|
|
esCustom: boolean;
|
|
completada: boolean;
|
|
completadaAt: string | null;
|
|
completadaPor: string | null;
|
|
periodoCompletado: string | null;
|
|
createdAt?: string;
|
|
auxiliarAsignadoId?: string | null;
|
|
auxiliarAsignadoNombre?: string | null;
|
|
}
|
|
|
|
export function getCatalogo(): ObligacionFiscal[] {
|
|
return OBLIGACIONES_CATALOGO;
|
|
}
|
|
|
|
export async function getObligaciones(pool: Pool, contribuyenteId: string): Promise<ObligacionContribuyente[]> {
|
|
const { rows } = await pool.query(`
|
|
SELECT
|
|
oc.id, oc.contribuyente_id AS "contribuyenteId", oc.catalogo_id AS "catalogoId",
|
|
oc.nombre, oc.fundamento, oc.frecuencia, oc.fecha_limite AS "fechaLimite", oc.categoria,
|
|
oc.activa, oc.es_recomendada AS "esRecomendada", oc.es_custom AS "esCustom",
|
|
oc.completada, oc.completada_at AS "completadaAt", oc.completada_por AS "completadaPor",
|
|
oc.periodo_completado AS "periodoCompletado",
|
|
oc.created_at AS "createdAt",
|
|
oa.auxiliar_user_id AS "auxiliarAsignadoId"
|
|
FROM obligaciones_contribuyente oc
|
|
LEFT JOIN obligacion_asignaciones oa ON oa.obligacion_id = oc.id
|
|
WHERE oc.contribuyente_id = $1
|
|
ORDER BY oc.categoria, oc.nombre
|
|
`, [contribuyenteId]);
|
|
return rows;
|
|
}
|
|
|
|
/**
|
|
* Reads obligations from the latest Constancia de Situación Fiscal (CSF)
|
|
* and populates obligaciones_contribuyente. Falls back to catalog-based
|
|
* recommendations if no CSF exists.
|
|
*/
|
|
export async function initRecomendaciones(
|
|
pool: Pool,
|
|
contribuyenteId: string,
|
|
rfc: string,
|
|
regimenes: string[],
|
|
tieneNomina: boolean
|
|
): Promise<number> {
|
|
// Clean up alerts and periodos for existing recommended obligations before replacing
|
|
await pool.query(
|
|
`DELETE FROM alertas WHERE tipo LIKE 'ob-%' AND SUBSTRING(tipo FROM 4 FOR 36) IN (
|
|
SELECT id::text FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND es_recomendada = true
|
|
)`,
|
|
[contribuyenteId],
|
|
);
|
|
await pool.query(
|
|
`DELETE FROM obligacion_periodos WHERE obligacion_id IN (
|
|
SELECT id FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND es_recomendada = true
|
|
)`,
|
|
[contribuyenteId],
|
|
);
|
|
// Clear previous recommended obligations (re-init replaces them)
|
|
await pool.query(
|
|
`DELETE FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND es_recomendada = true`,
|
|
[contribuyenteId],
|
|
);
|
|
|
|
// Try to get obligations from the latest CSF
|
|
const { rows: csfRows } = await pool.query(`
|
|
SELECT datos->'obligaciones' as obligaciones
|
|
FROM constancias_situacion_fiscal
|
|
WHERE rfc = $1
|
|
ORDER BY created_at DESC LIMIT 1
|
|
`, [rfc]);
|
|
|
|
const csfObligaciones = csfRows[0]?.obligaciones as Array<{
|
|
descripcion: string;
|
|
fechaInicio: string;
|
|
fechaFin?: string;
|
|
descripcionVencimiento: string;
|
|
}> | null;
|
|
|
|
let count = 0;
|
|
|
|
if (csfObligaciones && csfObligaciones.length > 0) {
|
|
// Use CSF obligations directly — these are the official SAT obligations
|
|
// Only import ACTIVE obligations (no fechaFin = still in effect)
|
|
const activeCsf = csfObligaciones.filter(ob => !ob.fechaFin);
|
|
for (const ob of activeCsf) {
|
|
// Keyword-based matching against catalog for enrichment (fundamento, categoria)
|
|
const catalogMatch = matchCsfToCatalog(ob.descripcion, rfc);
|
|
|
|
const { rowCount } = await pool.query(`
|
|
INSERT INTO obligaciones_contribuyente (
|
|
contribuyente_id, catalogo_id, nombre, fundamento, frecuencia, fecha_limite, categoria, es_recomendada
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, true)
|
|
ON CONFLICT DO NOTHING
|
|
`, [
|
|
contribuyenteId,
|
|
catalogMatch?.id || null,
|
|
ob.descripcion,
|
|
catalogMatch?.fundamento || null,
|
|
catalogMatch?.frecuencia || inferirFrecuencia(ob.descripcionVencimiento),
|
|
ob.descripcionVencimiento,
|
|
catalogMatch?.categoria || 'SAT',
|
|
]);
|
|
count += rowCount ?? 0;
|
|
}
|
|
} else {
|
|
// Fallback: use catalog-based recommendations
|
|
const recomendadas = getRecomendaciones(rfc, regimenes, tieneNomina);
|
|
for (const ob of recomendadas) {
|
|
const { rowCount } = await pool.query(`
|
|
INSERT INTO obligaciones_contribuyente (contribuyente_id, catalogo_id, nombre, fundamento, frecuencia, fecha_limite, categoria, es_recomendada)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, true)
|
|
ON CONFLICT DO NOTHING
|
|
`, [contribuyenteId, ob.id, ob.nombre, ob.fundamento, ob.frecuencia, ob.fechaLimite, ob.categoria]);
|
|
count += rowCount ?? 0;
|
|
}
|
|
}
|
|
|
|
return count;
|
|
}
|
|
|
|
function inferirFrecuencia(vencimiento: string): string {
|
|
const lower = vencimiento.toLowerCase();
|
|
if (lower.includes('mensual') || lower.includes('mes')) return 'mensual';
|
|
if (lower.includes('cuatrimest')) return 'cuatrimestral';
|
|
if (lower.includes('bimest')) return 'bimestral';
|
|
if (lower.includes('trimest')) return 'trimestral';
|
|
if (lower.includes('anual') || lower.includes('ejercicio') || lower.includes('tres meses siguientes')) return 'anual';
|
|
return 'mensual';
|
|
}
|
|
|
|
export async function completeObligacion(pool: Pool, obligacionId: string, userId: string, periodo: string): Promise<boolean> {
|
|
const { rowCount } = await pool.query(
|
|
'UPDATE obligaciones_contribuyente SET completada = true, completada_at = now(), completada_por = $2, periodo_completado = $3 WHERE id = $1',
|
|
[obligacionId, userId, periodo]
|
|
);
|
|
return (rowCount ?? 0) > 0;
|
|
}
|
|
|
|
export async function uncompleteObligacion(pool: Pool, obligacionId: string): Promise<boolean> {
|
|
const { rowCount } = await pool.query(
|
|
'UPDATE obligaciones_contribuyente SET completada = false, completada_at = null, completada_por = null, periodo_completado = null WHERE id = $1',
|
|
[obligacionId]
|
|
);
|
|
return (rowCount ?? 0) > 0;
|
|
}
|
|
|
|
export async function addObligacion(pool: Pool, contribuyenteId: string, data: {
|
|
catalogoId?: string;
|
|
nombre: string;
|
|
fundamento?: string;
|
|
frecuencia?: string;
|
|
fechaLimite?: string;
|
|
categoria?: string;
|
|
}): Promise<ObligacionContribuyente> {
|
|
const isFromCatalog = !!data.catalogoId;
|
|
const { rows: [row] } = await pool.query(`
|
|
INSERT INTO obligaciones_contribuyente (contribuyente_id, catalogo_id, nombre, fundamento, frecuencia, fecha_limite, categoria, es_custom)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
RETURNING id, contribuyente_id AS "contribuyenteId", catalogo_id AS "catalogoId",
|
|
nombre, fundamento, frecuencia, fecha_limite AS "fechaLimite", categoria,
|
|
activa, es_recomendada AS "esRecomendada", es_custom AS "esCustom"
|
|
`, [contribuyenteId, data.catalogoId || null, data.nombre, data.fundamento || null,
|
|
data.frecuencia || null, data.fechaLimite || null, data.categoria || 'Custom',
|
|
!isFromCatalog]);
|
|
return row;
|
|
}
|
|
|
|
export async function removeObligacion(pool: Pool, obligacionId: string): Promise<boolean> {
|
|
const { rowCount } = await pool.query(
|
|
'UPDATE obligaciones_contribuyente SET activa = false WHERE id = $1',
|
|
[obligacionId]
|
|
);
|
|
// Clean up alerts generated for this obligation (tipo format: 'ob-{obligacionId}-{periodo}')
|
|
await pool.query(
|
|
`DELETE FROM alertas WHERE tipo LIKE $1`,
|
|
[`ob-${obligacionId}-%`],
|
|
);
|
|
// Clean up completion records
|
|
await pool.query(
|
|
'DELETE FROM obligacion_periodos WHERE obligacion_id = $1',
|
|
[obligacionId],
|
|
);
|
|
return (rowCount ?? 0) > 0;
|
|
}
|
|
|
|
export async function restoreObligacion(pool: Pool, obligacionId: string): Promise<boolean> {
|
|
const { rowCount } = await pool.query(
|
|
'UPDATE obligaciones_contribuyente SET activa = true WHERE id = $1',
|
|
[obligacionId]
|
|
);
|
|
return (rowCount ?? 0) > 0;
|
|
}
|
|
|
|
/**
|
|
* Returns obligations for a specific period (YYYY-MM) for a contribuyente.
|
|
* Includes:
|
|
* - All active obligations that apply to this period (based on frequency)
|
|
* - Completion status from obligacion_periodos table
|
|
* - Past-due obligations from previous periods that were NOT completed
|
|
*/
|
|
export interface DeclaracionLink {
|
|
id: number;
|
|
año: number;
|
|
mes: number;
|
|
tipo: 'normal' | 'complementaria';
|
|
pdfFilename: string | null;
|
|
}
|
|
|
|
export async function getObligacionesPorPeriodo(
|
|
pool: Pool,
|
|
contribuyenteId: string,
|
|
periodo: string, // "2026-04"
|
|
incluirAtrasados: boolean = true
|
|
): Promise<Array<ObligacionContribuyente & { periodStatus: 'pendiente' | 'completada' | 'atrasada'; periodoAplica: string; declaracion: DeclaracionLink | null }>> {
|
|
// Get all active obligations for this contribuyente
|
|
const obligaciones = await getObligaciones(pool, contribuyenteId);
|
|
const activas = obligaciones.filter(o => o.activa);
|
|
|
|
const [year, month] = periodo.split('-').map(Number);
|
|
const currentPeriodo = new Date().toISOString().substring(0, 7);
|
|
const results: Array<ObligacionContribuyente & {
|
|
periodStatus: string;
|
|
periodoAplica: string;
|
|
declaracion: DeclaracionLink | null;
|
|
declaracionPresentada: boolean;
|
|
pagoPresentado: boolean;
|
|
requierePago: boolean;
|
|
}> = [];
|
|
|
|
// Get all completion records + associated declaration info for this contribuyente
|
|
const { rows: completions } = await pool.query<{
|
|
obligacion_id: string;
|
|
periodo: string;
|
|
completada: boolean;
|
|
declaracion_presentada: boolean;
|
|
pago_presentado: boolean;
|
|
declaracion_id: number | null;
|
|
decl_año: number | null;
|
|
decl_mes: number | null;
|
|
decl_tipo: 'normal' | 'complementaria' | null;
|
|
decl_pdf_filename: string | null;
|
|
}>(`
|
|
SELECT op.obligacion_id, op.periodo, op.completada,
|
|
op.declaracion_presentada, op.pago_presentado,
|
|
op.declaracion_id,
|
|
dp.año AS decl_año,
|
|
dp.mes AS decl_mes,
|
|
dp.tipo AS decl_tipo,
|
|
dp.pdf_filename AS decl_pdf_filename
|
|
FROM obligacion_periodos op
|
|
JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id
|
|
LEFT JOIN declaraciones_provisionales dp ON dp.id = op.declaracion_id
|
|
WHERE oc.contribuyente_id = $1
|
|
`, [contribuyenteId]);
|
|
|
|
const completionMap = new Map<string, boolean>();
|
|
const declaracionPresentadaMap = new Map<string, boolean>();
|
|
const pagoPresentadoMap = new Map<string, boolean>();
|
|
const declaracionMap = new Map<string, DeclaracionLink | null>();
|
|
for (const c of completions) {
|
|
const key = `${c.obligacion_id}:${c.periodo}`;
|
|
completionMap.set(key, c.completada);
|
|
declaracionPresentadaMap.set(key, c.declaracion_presentada);
|
|
pagoPresentadoMap.set(key, c.pago_presentado);
|
|
if (c.declaracion_id && c.decl_año != null && c.decl_mes != null && c.decl_tipo) {
|
|
declaracionMap.set(key, {
|
|
id: c.declaracion_id,
|
|
año: c.decl_año,
|
|
mes: c.decl_mes,
|
|
tipo: c.decl_tipo,
|
|
pdfFilename: c.decl_pdf_filename,
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const ob of activas) {
|
|
// Obligations only apply from the month they were created forward
|
|
const obStartPeriodo = ob.createdAt
|
|
? new Date(ob.createdAt).toISOString().substring(0, 7)
|
|
: '2000-01';
|
|
|
|
// Check if this obligation applies to the requested period
|
|
if (periodo >= obStartPeriodo && appliesTo(ob.frecuencia, periodo)) {
|
|
const key = `${ob.id}:${periodo}`;
|
|
const isCompleted = completionMap.get(key) === true;
|
|
results.push({
|
|
...ob,
|
|
periodStatus: isCompleted ? 'completada' : 'pendiente',
|
|
periodoAplica: periodo,
|
|
declaracion: declaracionMap.get(key) ?? null,
|
|
declaracionPresentada: declaracionPresentadaMap.get(key) === true,
|
|
pagoPresentado: pagoPresentadoMap.get(key) === true,
|
|
requierePago: requierePagoPorCatalogo(ob.catalogoId),
|
|
});
|
|
}
|
|
|
|
// Check past-due (previous periods not completed) — only if requested
|
|
if (incluirAtrasados && periodo >= currentPeriodo) {
|
|
// Look back up to 12 months for overdue items
|
|
for (let i = 1; i <= 12; i++) {
|
|
let pm = month - i;
|
|
let py = year;
|
|
while (pm < 1) { pm += 12; py--; }
|
|
const pastPeriodo = `${py}-${String(pm).padStart(2, '0')}`;
|
|
|
|
if (pastPeriodo >= currentPeriodo) continue; // only past periods
|
|
if (pastPeriodo < obStartPeriodo) continue; // don't go before obligation was created
|
|
if (!appliesTo(ob.frecuencia, pastPeriodo)) continue;
|
|
|
|
const pastKey = `${ob.id}:${pastPeriodo}`;
|
|
const pastCompleted = completionMap.get(pastKey) === true;
|
|
|
|
if (!pastCompleted) {
|
|
// Don't add duplicates
|
|
if (!results.find(r => r.id === ob.id && r.periodoAplica === pastPeriodo)) {
|
|
results.push({
|
|
...ob,
|
|
periodStatus: 'atrasada',
|
|
periodoAplica: pastPeriodo,
|
|
declaracion: null,
|
|
declaracionPresentada: declaracionPresentadaMap.get(pastKey) === true,
|
|
pagoPresentado: pagoPresentadoMap.get(pastKey) === true,
|
|
requierePago: requierePagoPorCatalogo(ob.catalogoId),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort: atrasadas first, then by name
|
|
results.sort((a, b) => {
|
|
if (a.periodStatus === 'atrasada' && b.periodStatus !== 'atrasada') return -1;
|
|
if (b.periodStatus === 'atrasada' && a.periodStatus !== 'atrasada') return 1;
|
|
return a.nombre.localeCompare(b.nombre);
|
|
});
|
|
|
|
return results as Array<ObligacionContribuyente & {
|
|
periodStatus: 'pendiente' | 'completada' | 'atrasada';
|
|
periodoAplica: string;
|
|
declaracion: DeclaracionLink | null;
|
|
declaracionPresentada: boolean;
|
|
pagoPresentado: boolean;
|
|
requierePago: boolean;
|
|
}>;
|
|
}
|
|
|
|
function appliesTo(frecuencia: string | null, periodo: string): boolean {
|
|
const [, month] = periodo.split('-').map(Number);
|
|
switch (frecuencia) {
|
|
case 'mensual': return true;
|
|
case 'bimestral': return month % 2 === 1; // Jan, Mar, May...
|
|
case 'trimestral': return [1, 4, 7, 10].includes(month);
|
|
case 'cuatrimestral': return [1, 5, 9].includes(month);
|
|
case 'anual': return month === 3 || month === 4; // March (PM) or April (PF) — show in both
|
|
case 'eventual': return false; // Don't auto-show
|
|
default: return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mark an obligation as completed for a specific period
|
|
*/
|
|
export async function completePeriodo(
|
|
pool: Pool,
|
|
obligacionId: string,
|
|
periodo: string,
|
|
userId: string,
|
|
notas?: string
|
|
): Promise<boolean> {
|
|
await pool.query(`
|
|
INSERT INTO obligacion_periodos (obligacion_id, periodo, completada, completada_at, completada_por, notas)
|
|
VALUES ($1, $2, true, now(), $3, $4)
|
|
ON CONFLICT (obligacion_id, periodo)
|
|
DO UPDATE SET completada = true, completada_at = now(), completada_por = $3, notas = COALESCE($4, obligacion_periodos.notas)
|
|
`, [obligacionId, periodo, userId, notas || null]);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Unmark an obligation completion for a specific period
|
|
*/
|
|
export async function uncompletePeriodo(
|
|
pool: Pool,
|
|
obligacionId: string,
|
|
periodo: string
|
|
): Promise<boolean> {
|
|
await pool.query(`
|
|
DELETE FROM obligacion_periodos WHERE obligacion_id = $1 AND periodo = $2
|
|
`, [obligacionId, periodo]);
|
|
return true;
|
|
}
|