Agrego carpeta del source sql de la Base de Datos

This commit is contained in:
Pablo
2026-08-22 19:10:49 -03:00
parent 11e36bd6c2
commit 88d724fbcf
94 changed files with 7820 additions and 0 deletions
+179
View File
@@ -0,0 +1,179 @@
-- ============================================================================
-- fc_anular_pago
-- ============================================================================
-- PROPÓSITO
-- Anula un pago previamente registrado (soft-delete). El pago sigue
-- existiendo y consultable, marcado con quién y cuándo lo anuló, y un
-- motivo opcional. Genera un evento auditable.
--
-- DOMINIO
-- Implementa la operación "anular" descrita en
-- Documentation/DominioPagos.md §5.4 y §8. La anulación es terminal:
-- un pago anulado no se desanula ni se vuelve a corregir; si la
-- situación cambia se registra un pago nuevo (§5.4). Por ser
-- transparente (visible + auditable), el rol con autoridad plena puede
-- anular incluso fuera de la ventana de corrección (§9).
--
-- ARCHIVADO (US-R11)
-- Un pago cuyo mes ya fue archivado al XLSX histórico es inmutable
-- (DominioPagos §14): se rechaza la anulación antes de cualquier otra
-- regla, incluso para la autoridad plena. El criterio lo resuelve
-- internal.pagos_mes_archivado (consulta internal.archivo_exports).
--
-- PARÁMETROS
-- p_id UUID del pago a anular.
-- p_motivo TEXT motivo libre. NULL, cadena vacía o sólo whitespace se
-- persisten como NULL para no contaminar el campo.
-- p_token UUID sesión del actor.
--
-- AUTORIZACIÓN
-- Requiere permiso 'agregar_pagos'.
-- - Sin 'gestionar_cualquier_pago': debe ser el creador del pago y
-- estar dentro de la ventana de corrección.
-- - Con 'gestionar_cualquier_pago': sin restricción de ownership ni
-- ventana.
--
-- ERRORES (RAISE EXCEPTION)
-- - 'Sesión inválida o usuario no encontrado.'
-- - 'No se encontró el pago.'
-- - 'Pago archivado. No se puede modificar.' (US-R11)
-- - 'Este pago ya estaba anulado.'
-- - 'No tenés permiso para anular este pago.'
-- - 'La ventana de anulación de este pago ya venció.'
--
-- RETORNA
-- JSONB con el pago ya anulado, mismo shape que un ítem de
-- fc_obtener_pagos.
--
-- EFECTOS SECUNDARIOS
-- - Setea anulado_at, anulado_por y motivo_anulacion del pago.
-- - Registra evento 'pago_anulado' en public.eventos con un snapshot
-- mínimo del pago (tipo, monto, mes, método, plan) como valor_anterior
-- y el motivo como descripción.
-- ============================================================================
CREATE OR REPLACE FUNCTION public.fc_anular_pago(
p_id UUID,
p_motivo TEXT,
p_token UUID
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
VOLATILE
AS $function$
DECLARE
v_actor_id UUID;
v_actor_rol TEXT;
v_has_gestion BOOLEAN;
v_ventana_minutos INT;
v_pago public.pagos%ROWTYPE;
v_motivo_limpio TEXT;
v_snapshot_min JSONB;
BEGIN
PERFORM internal.validate_permission(p_token, 'agregar_pagos');
SELECT s.usuario_id, u.rol
INTO v_actor_id, v_actor_rol
FROM internal.sesiones s
JOIN usuarios u ON u.id = s.usuario_id
WHERE s.token = p_token;
IF v_actor_id IS NULL THEN
RAISE EXCEPTION 'Sesión inválida o usuario no encontrado.';
END IF;
SELECT EXISTS (
SELECT 1 FROM internal.permisos
WHERE accion = 'gestionar_cualquier_pago'
AND v_actor_rol = ANY(roles_permitidos)
) INTO v_has_gestion;
SELECT * INTO v_pago FROM public.pagos WHERE id = p_id FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'No se encontró el pago.';
END IF;
-- Pago archivado: inmutable (US-R11, DominioPagos §14). Bloquea incluso a
-- la autoridad plena, que normalmente puede anular fuera de ventana: una
-- vez en el archivo, anular descoordinaría base y XLSX. Se chequea antes
-- que el resto porque es la condición de inmutabilidad más fuerte.
IF internal.pagos_mes_archivado(v_pago.anio_mes_pagado) THEN
RAISE EXCEPTION 'Pago archivado. No se puede modificar.';
END IF;
IF v_pago.anulado_at IS NOT NULL THEN
RAISE EXCEPTION 'Este pago ya estaba anulado.';
END IF;
IF NOT v_has_gestion THEN
IF v_pago.created_by IS DISTINCT FROM v_actor_id THEN
RAISE EXCEPTION 'No tenés permiso para anular este pago.';
END IF;
v_ventana_minutos := internal.get_config_int('pagos.ventana_edicion_minutos', 30);
IF now() > v_pago.created_at + (v_ventana_minutos || ' minutes')::interval THEN
RAISE EXCEPTION 'La ventana de anulación de este pago ya venció.';
END IF;
END IF;
v_motivo_limpio := NULLIF(trim(p_motivo), '');
UPDATE public.pagos
SET anulado_at = now(),
anulado_por = v_actor_id,
motivo_anulacion = v_motivo_limpio
WHERE id = p_id;
v_snapshot_min := jsonb_build_object(
'tipo', v_pago.tipo,
'monto_total', v_pago.monto_total,
'anio_mes_pagado', v_pago.anio_mes_pagado,
'metodo_id', v_pago.metodo_id,
'tipo_cuota_id', v_pago.tipo_cuota_id
);
PERFORM internal.log_evento(
p_tipo => 'pago_anulado',
p_tabla => 'pagos',
p_referencia_id => p_id,
p_cliente_id => v_pago.cliente_id,
p_actor_id => v_actor_id,
p_valor_anterior => v_snapshot_min,
p_valor_actual => NULL,
p_descripcion => v_motivo_limpio
);
RETURN (
SELECT jsonb_build_object(
'id', p.id,
'tipo', p.tipo,
'anio_mes_pagado', p.anio_mes_pagado,
'fecha_pago', p.fecha_pago,
'monto_total', p.monto_total,
'detalle', p.detalle,
'metodo', m.descripcion,
'created_at', p.created_at,
'created_by', p.created_by,
'created_by_nombre', cu.nombre,
'updated_at', p.updated_at,
'updated_by_nombre', uu.nombre,
'anulado_at', p.anulado_at,
'anulado_por_nombre', au.nombre,
'motivo_anulacion', p.motivo_anulacion,
'cliente', jsonb_build_object(
'nombre', cl.nombre,
'apellido', cl.apellido,
'dni', cl.dni
)
)
FROM public.pagos p
JOIN public.usuarios cl ON p.cliente_id = cl.id
LEFT JOIN public.metodos_pago m ON p.metodo_id = m.id
LEFT JOIN public.usuarios cu ON p.created_by = cu.id
LEFT JOIN public.usuarios uu ON p.updated_by = uu.id
LEFT JOIN public.usuarios au ON p.anulado_por = au.id
WHERE p.id = p_id
);
END;
$function$;
+272
View File
@@ -0,0 +1,272 @@
-- ============================================================================
-- fc_editar_pago
-- ============================================================================
-- PROPÓSITO
-- Corrige un pago previamente registrado, dentro de la ventana de
-- corrección. Genera un evento auditable con los cambios reales producidos.
--
-- DOMINIO
-- Implementa la operación "corregir" descrita en
-- Documentation/DominioPagos.md §5.3 y §8. La ventana aplica a TODOS los
-- actores (incluso al rol con autoridad plena): pasada la ventana, la
-- única vía para enmendar un pago es anular y registrar uno nuevo (§9).
--
-- ARCHIVADO (US-R11)
-- Un pago cuyo mes ya fue archivado al XLSX histórico es inmutable
-- (DominioPagos §14): se rechaza la edición antes de cualquier otra
-- regla. El criterio de "archivado" lo resuelve
-- internal.pagos_mes_archivado (consulta internal.archivo_exports).
--
-- PARÁMETROS
-- p_id UUID del pago a editar.
-- p_datos JSONB con los campos a actualizar. Semántica PATCH: sólo se
-- modifican las claves presentes en el JSON. Editables:
-- metodo_id, anio_mes_pagado, fecha_pago, monto_total,
-- detalle, tipo_cuota_id. cliente_id y tipo NO son editables.
-- p_token UUID sesión del actor.
--
-- AUTORIZACIÓN
-- Requiere permiso 'agregar_pagos'.
-- - Sin 'gestionar_cualquier_pago': debe ser el creador del pago y estar
-- dentro de la ventana de corrección.
-- - Con 'gestionar_cualquier_pago': sin restricción de ownership, pero
-- igualmente dentro de la ventana.
--
-- ERRORES (RAISE EXCEPTION)
-- - 'Sesión inválida o usuario no encontrado.'
-- - 'No se encontró el pago.'
-- - 'Pago archivado. No se puede modificar.' (US-R11)
-- - 'No se puede editar un pago anulado.'
-- - 'Sólo se pueden editar pagos de tipo cuota_mensual'
-- - 'No tenés permiso para editar este pago.'
-- - 'La ventana de edición de este pago ya venció.'
-- - 'cliente_id no es editable. Anulá el pago y cargá uno nuevo.'
-- - 'tipo no es editable. Anulá el pago y cargá uno nuevo.'
-- - 'metodo_id no puede ser nulo en un pago de cuota_mensual.'
-- - 'El método de pago ID X no existe o no está activo.'
-- - 'anio_mes_pagado no puede ser nulo en un pago de cuota_mensual.'
-- - 'El monto total es inválido para un pago de cuota_mensual.'
-- - 'El tipo de cuota ID X no existe.'
--
-- RETORNA
-- JSONB con el pago actualizado, mismo shape que un ítem de
-- fc_obtener_pagos.
--
-- EFECTOS SECUNDARIOS
-- - UPDATE de la fila en public.pagos, setea updated_at = now() y
-- updated_by = actor.
-- - Registra evento 'pago_editado' en public.eventos con el diff campo
-- a campo, SÓLO si hubo cambios reales. Las ediciones no-op no generan
-- evento.
-- ============================================================================
CREATE OR REPLACE FUNCTION public.fc_editar_pago(
p_id UUID,
p_datos JSONB,
p_token UUID
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
VOLATILE
AS $function$
DECLARE
v_actor_id UUID;
v_actor_rol TEXT;
v_has_gestion BOOLEAN;
v_ventana_minutos INT;
v_pago public.pagos%ROWTYPE;
v_after public.pagos%ROWTYPE;
v_diff_anterior JSONB := '{}'::jsonb;
v_diff_actual JSONB := '{}'::jsonb;
v_new_metodo_id SMALLINT;
v_new_anio_mes DATE;
v_new_tipo_cuota UUID;
v_new_monto NUMERIC;
BEGIN
PERFORM internal.validate_permission(p_token, 'agregar_pagos');
SELECT s.usuario_id, u.rol
INTO v_actor_id, v_actor_rol
FROM internal.sesiones s
JOIN usuarios u ON u.id = s.usuario_id
WHERE s.token = p_token;
IF v_actor_id IS NULL THEN
RAISE EXCEPTION 'Sesión inválida o usuario no encontrado.';
END IF;
SELECT EXISTS (
SELECT 1 FROM internal.permisos
WHERE accion = 'gestionar_cualquier_pago'
AND v_actor_rol = ANY(roles_permitidos)
) INTO v_has_gestion;
SELECT * INTO v_pago FROM public.pagos WHERE id = p_id FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'No se encontró el pago.';
END IF;
-- Pago archivado: inmutable (US-R11, DominioPagos §14). Se chequea antes
-- que cualquier otra regla porque es la condición de inmutabilidad más
-- fuerte: si el mes ya viajó al archivo, no hay edición posible para
-- nadie. (Normalmente el pipeline ya borró la fila, pero el guard cubre
-- la ventana entre marcar 'ok' y el DELETE, y deja la regla explícita.)
IF internal.pagos_mes_archivado(v_pago.anio_mes_pagado) THEN
RAISE EXCEPTION 'Pago archivado. No se puede modificar.';
END IF;
IF v_pago.anulado_at IS NOT NULL THEN
RAISE EXCEPTION 'No se puede editar un pago anulado.';
END IF;
IF v_pago.tipo <> 'cuota_mensual' THEN
RAISE EXCEPTION 'Sólo se pueden editar pagos de tipo cuota_mensual';
END IF;
IF NOT v_has_gestion THEN
IF v_pago.created_by IS DISTINCT FROM v_actor_id THEN
RAISE EXCEPTION 'No tenés permiso para editar este pago.';
END IF;
END IF;
v_ventana_minutos := internal.get_config_int('pagos.ventana_edicion_minutos', 30);
IF now() > v_pago.created_at + (v_ventana_minutos || ' minutes')::interval THEN
RAISE EXCEPTION 'La ventana de edición de este pago ya venció.';
END IF;
IF p_datos ? 'cliente_id' THEN
RAISE EXCEPTION 'cliente_id no es editable. Anulá el pago y cargá uno nuevo.';
END IF;
IF p_datos ? 'tipo' THEN
RAISE EXCEPTION 'tipo no es editable. Anulá el pago y cargá uno nuevo.';
END IF;
IF p_datos ? 'metodo_id' THEN
v_new_metodo_id := (p_datos->>'metodo_id')::SMALLINT;
IF v_new_metodo_id IS NULL THEN
RAISE EXCEPTION 'metodo_id no puede ser nulo en un pago de cuota_mensual.';
END IF;
PERFORM 1 FROM metodos_pago WHERE id = v_new_metodo_id AND activo = true;
IF NOT FOUND THEN
RAISE EXCEPTION 'El método de pago ID % no existe o no está activo.', v_new_metodo_id;
END IF;
END IF;
IF p_datos ? 'anio_mes_pagado' THEN
v_new_anio_mes := date_trunc('month', (p_datos->>'anio_mes_pagado')::DATE)::DATE;
IF v_new_anio_mes IS NULL THEN
RAISE EXCEPTION 'anio_mes_pagado no puede ser nulo en un pago de cuota_mensual.';
END IF;
END IF;
IF p_datos ? 'monto_total' THEN
v_new_monto := (p_datos->>'monto_total')::NUMERIC;
IF v_new_monto IS NULL OR v_new_monto < 0 THEN
RAISE EXCEPTION 'El monto total es inválido para un pago de cuota_mensual.';
END IF;
END IF;
IF p_datos ? 'tipo_cuota_id' THEN
IF (p_datos->>'tipo_cuota_id') IS NOT NULL THEN
v_new_tipo_cuota := (p_datos->>'tipo_cuota_id')::UUID;
PERFORM 1 FROM tipos_cuota WHERE id = v_new_tipo_cuota;
IF NOT FOUND THEN
RAISE EXCEPTION 'El tipo de cuota ID % no existe.', v_new_tipo_cuota;
END IF;
ELSE
v_new_tipo_cuota := NULL;
END IF;
END IF;
UPDATE public.pagos p
SET metodo_id = CASE WHEN p_datos ? 'metodo_id'
THEN v_new_metodo_id ELSE p.metodo_id END,
anio_mes_pagado = CASE WHEN p_datos ? 'anio_mes_pagado'
THEN v_new_anio_mes ELSE p.anio_mes_pagado END,
fecha_pago = CASE WHEN p_datos ? 'fecha_pago'
THEN (p_datos->>'fecha_pago')::TIMESTAMPTZ
ELSE p.fecha_pago END,
monto_total = CASE WHEN p_datos ? 'monto_total'
THEN v_new_monto ELSE p.monto_total END,
detalle = CASE WHEN p_datos ? 'detalle'
THEN p_datos->'detalle' ELSE p.detalle END,
tipo_cuota_id = CASE WHEN p_datos ? 'tipo_cuota_id'
THEN v_new_tipo_cuota ELSE p.tipo_cuota_id END,
updated_at = now(),
updated_by = v_actor_id
WHERE p.id = p_id
RETURNING * INTO v_after;
IF v_pago.metodo_id IS DISTINCT FROM v_after.metodo_id THEN
v_diff_anterior := v_diff_anterior || jsonb_build_object('metodo_id', v_pago.metodo_id);
v_diff_actual := v_diff_actual || jsonb_build_object('metodo_id', v_after.metodo_id);
END IF;
IF v_pago.anio_mes_pagado IS DISTINCT FROM v_after.anio_mes_pagado THEN
v_diff_anterior := v_diff_anterior || jsonb_build_object('anio_mes_pagado', v_pago.anio_mes_pagado);
v_diff_actual := v_diff_actual || jsonb_build_object('anio_mes_pagado', v_after.anio_mes_pagado);
END IF;
IF v_pago.fecha_pago IS DISTINCT FROM v_after.fecha_pago THEN
v_diff_anterior := v_diff_anterior || jsonb_build_object('fecha_pago', v_pago.fecha_pago);
v_diff_actual := v_diff_actual || jsonb_build_object('fecha_pago', v_after.fecha_pago);
END IF;
IF v_pago.monto_total IS DISTINCT FROM v_after.monto_total THEN
v_diff_anterior := v_diff_anterior || jsonb_build_object('monto_total', v_pago.monto_total);
v_diff_actual := v_diff_actual || jsonb_build_object('monto_total', v_after.monto_total);
END IF;
IF v_pago.detalle IS DISTINCT FROM v_after.detalle THEN
v_diff_anterior := v_diff_anterior || jsonb_build_object('detalle', v_pago.detalle);
v_diff_actual := v_diff_actual || jsonb_build_object('detalle', v_after.detalle);
END IF;
IF v_pago.tipo_cuota_id IS DISTINCT FROM v_after.tipo_cuota_id THEN
v_diff_anterior := v_diff_anterior || jsonb_build_object('tipo_cuota_id', v_pago.tipo_cuota_id);
v_diff_actual := v_diff_actual || jsonb_build_object('tipo_cuota_id', v_after.tipo_cuota_id);
END IF;
IF v_diff_anterior <> '{}'::jsonb THEN
PERFORM internal.log_evento(
p_tipo => 'pago_editado',
p_tabla => 'pagos',
p_referencia_id => p_id,
p_cliente_id => v_after.cliente_id,
p_actor_id => v_actor_id,
p_valor_anterior => v_diff_anterior,
p_valor_actual => v_diff_actual,
p_descripcion => NULL
);
END IF;
RETURN (
SELECT jsonb_build_object(
'id', p.id,
'tipo', p.tipo,
'anio_mes_pagado', p.anio_mes_pagado,
'fecha_pago', p.fecha_pago,
'monto_total', p.monto_total,
'detalle', p.detalle,
'metodo', m.descripcion,
'created_at', p.created_at,
'created_by', p.created_by,
'created_by_nombre', cu.nombre,
'updated_at', p.updated_at,
'updated_by_nombre', uu.nombre,
'anulado_at', p.anulado_at,
'anulado_por_nombre', au.nombre,
'motivo_anulacion', p.motivo_anulacion,
'cliente', jsonb_build_object(
'nombre', cl.nombre,
'apellido', cl.apellido,
'dni', cl.dni
)
)
FROM public.pagos p
JOIN public.usuarios cl ON p.cliente_id = cl.id
LEFT JOIN public.metodos_pago m ON p.metodo_id = m.id
LEFT JOIN public.usuarios cu ON p.created_by = cu.id
LEFT JOIN public.usuarios uu ON p.updated_by = uu.id
LEFT JOIN public.usuarios au ON p.anulado_por = au.id
WHERE p.id = p_id
);
END;
$function$;
@@ -0,0 +1,210 @@
-- ============================================================================
-- fc_insertar_pago
-- ============================================================================
-- PROPÓSITO
-- Registra un pago nuevo de tipo cuota_mensual para un cliente identificado
-- por DNI. Opcionalmente actualiza el plan por defecto del cliente. Como
-- efecto colateral, activa al cliente.
--
-- DOMINIO
-- Implementa la operación "registrar" descrita en
-- Documentation/DominioPagos.md §7. Hoy sólo soporta el concepto
-- cuota_mensual (§5.1); el dominio admite otros conceptos en el futuro,
-- pero la función los rechaza explícitamente por ahora.
--
-- RETENCIÓN (US-R10)
-- Rechaza pagos cuyo `anio_mes_pagado` cae por debajo de la ventana
-- operativa de pagos (`retencion.pagos_meses`, default 12). Sin esto
-- se podrían crear pagos que el pipeline mensual archivaría y
-- purgaría a los pocos días (Documentation/PoliticaRetencion.md
-- §4, §7). La validación es sólo del borde inferior; los meses
-- futuros no se restringen acá.
--
-- PARÁMETROS
-- p_datos JSONB con el payload del pago. Claves:
-- dni TEXT (obligatorio) DNI del cliente.
-- metodo_id SMALLINT (obligatorio) método de pago activo.
-- anio_mes_pagado DATE (obligatorio) mes que cubre el pago.
-- Se trunca al día 1 del mes.
-- fecha_pago TIMESTAMPTZ (opcional) fecha declarada del
-- cobro. Default: now() en zona
-- America/Argentina/Buenos_Aires.
-- monto_total NUMERIC (obligatorio) ≥ 0.
-- tipo_cuota_id UUID (opcional) plan que paga el cliente.
-- set_default_tipo_cuota BOOLEAN (opcional, default false). Si true y
-- tipo_cuota_id no nulo, actualiza el
-- plan por defecto del cliente.
-- detalle JSONB (opcional, default '{}'). Si se pasa
-- tipo_cuota_id, se enriquece con
-- cuota_nombre, cuota_precio,
-- cuota_dias_semana (snapshot del plan
-- al momento del cobro).
-- tipo TEXT (opcional) debe ser 'cuota_mensual'
-- si se pasa; otros valores se rechazan.
-- p_token UUID de sesión del actor.
--
-- AUTORIZACIÓN
-- Requiere permiso 'agregar_pagos'.
--
-- ERRORES (RAISE EXCEPTION)
-- - 'Tipo de pago no soportado'
-- - 'Sesión inválida o usuario no encontrado.'
-- - 'No existe un usuario con el DNI X'
-- - 'El campo metodo_id es obligatorio'
-- - 'El método de pago ID X no existe o no está activo'
-- - 'El tipo de cuota ID X no existe'
-- - 'El monto total es inválido.'
-- - 'No se puede registrar un pago para el mes X: está fuera de la
-- ventana de retención de pagos (N meses). ...' (US-R10)
--
-- RETORNA
-- JSONB con {id, cliente_id, anio_mes_pagado, fecha_pago, monto_total,
-- status: 'success'}.
--
-- EFECTOS SECUNDARIOS
-- - INSERT en public.pagos con created_by = actor, tipo = 'cuota_mensual'.
-- - SIEMPRE activa al cliente (usuarios.isactive = true), independiente
-- de set_default_tipo_cuota.
-- - Si set_default_tipo_cuota = true Y tipo_cuota_id no nulo: actualiza
-- usuarios.tipo_cuota del cliente.
-- - NO genera evento en public.eventos. La trazabilidad de creación está
-- cubierta por created_at + created_by en la propia fila.
-- ============================================================================
CREATE OR REPLACE FUNCTION public.fc_insertar_pago(
p_datos JSONB,
p_token UUID
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
SET timezone = 'America/Argentina/Buenos_Aires'
VOLATILE
AS $function$
DECLARE
v_actor_id UUID;
v_cliente_id UUID;
v_metodo_id SMALLINT;
v_anio_mes DATE;
v_fecha_pago TIMESTAMPTZ;
v_monto NUMERIC;
v_detalle JSONB;
v_tipo_cuota_id UUID;
v_set_default BOOLEAN;
v_info_cuota JSONB;
v_pago_insertado RECORD;
v_meses_retencion INT;
v_mes_limite DATE;
BEGIN
PERFORM internal.validate_permission(p_token, 'agregar_pagos');
-- Actualmente la app sólo crea pagos de tipo 'cuota_mensual'.
-- En un futuro, se podrían registrar pagos de otras cosas (nutricionista, pago anualizado, o cosas así)
-- y las tablas ya lo soportan, sólo se cambiaría esta función.
IF p_datos ? 'tipo' AND (p_datos->>'tipo') IS DISTINCT FROM 'cuota_mensual' THEN
RAISE EXCEPTION 'Tipo de pago no soportado';
END IF;
-- Autor del registro de pago, lo queremos para auditoría (created_by).
SELECT usuario_id INTO v_actor_id FROM internal.sesiones WHERE token = p_token;
IF v_actor_id IS NULL THEN
RAISE EXCEPTION 'Sesión inválida o usuario no encontrado.';
END IF;
SELECT id INTO v_cliente_id FROM usuarios WHERE dni = (p_datos->>'dni');
IF NOT FOUND THEN
RAISE EXCEPTION 'No existe un usuario con el DNI %', p_datos->>'dni';
END IF;
v_metodo_id := (p_datos->>'metodo_id')::SMALLINT;
IF v_metodo_id IS NULL THEN
RAISE EXCEPTION 'El campo metodo_id es obligatorio';
END IF;
PERFORM 1 FROM metodos_pago WHERE id = v_metodo_id AND activo = true;
IF NOT FOUND THEN
RAISE EXCEPTION 'El método de pago ID % no existe o no está activo', v_metodo_id;
END IF;
v_tipo_cuota_id := (p_datos->>'tipo_cuota_id')::UUID;
v_set_default := COALESCE((p_datos->>'set_default_tipo_cuota')::BOOLEAN, false);
v_detalle := COALESCE(p_datos->'detalle', '{}'::jsonb);
IF v_tipo_cuota_id IS NOT NULL THEN
SELECT jsonb_build_object(
'cuota_nombre', nombre,
'cuota_precio', precio,
'cuota_dias_semana', dias_semana
) INTO v_info_cuota
FROM tipos_cuota
WHERE id = v_tipo_cuota_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'El tipo de cuota ID % no existe', v_tipo_cuota_id;
END IF;
v_detalle := v_detalle || v_info_cuota;
END IF;
v_anio_mes := date_trunc('month', (p_datos->>'anio_mes_pagado')::DATE)::DATE;
v_fecha_pago := COALESCE((p_datos->>'fecha_pago')::TIMESTAMPTZ, NOW());
v_monto := (p_datos->>'monto_total')::NUMERIC;
-- cuota_mensual: monto positivo o cero. Los correctivos (que aún no se
-- pueden crear acá) podrán llevar negativo cuando tengan sus funciones.
IF v_monto IS NULL OR v_monto < 0 THEN
RAISE EXCEPTION 'El monto total es inválido.';
END IF;
-- Ventana de retención (US-R10): no permitir registrar un pago cuyo mes
-- objetivo ya quedó fuera de la ventana operativa de pagos. De aceptarse,
-- el pipeline mensual lo archivaría y purgaría casi de inmediato (ver
-- Documentation/PoliticaRetencion.md §4, §7). Sólo se valida el borde
-- inferior: los meses futuros (pago adelantado) no son un problema de
-- retención y quedan deliberadamente fuera de esta validación.
v_meses_retencion := internal.get_config_int('retencion.pagos_meses', 12);
v_mes_limite := date_trunc('month', CURRENT_DATE)::DATE
- (v_meses_retencion * INTERVAL '1 month');
IF v_anio_mes < v_mes_limite THEN
RAISE EXCEPTION
'No se puede registrar un pago para el mes %: está fuera de la ventana de retención de pagos (% meses). El mes más antiguo admitido es %.',
to_char(v_anio_mes, 'YYYY-MM'), v_meses_retencion, to_char(v_mes_limite, 'YYYY-MM');
END IF;
INSERT INTO public.pagos (
id, cliente_id, anio_mes_pagado, fecha_pago, monto_total,
metodo_id, detalle, tipo_cuota_id, tipo, created_by
) VALUES (
gen_random_uuid(), v_cliente_id, v_anio_mes, v_fecha_pago, v_monto,
v_metodo_id, v_detalle, v_tipo_cuota_id, 'cuota_mensual', v_actor_id
)
RETURNING id, anio_mes_pagado, fecha_pago, monto_total, detalle
INTO v_pago_insertado;
IF v_set_default AND v_tipo_cuota_id IS NOT NULL THEN
UPDATE usuarios
SET tipo_cuota = v_tipo_cuota_id,
isactive = true
WHERE id = v_cliente_id;
ELSE
UPDATE usuarios
SET isactive = true
WHERE id = v_cliente_id;
END IF;
-- No loggeamos creación en eventos (decisión de scope): created_at/created_by
-- ya cubren la trazabilidad de la inserción.
RETURN jsonb_build_object(
'id', v_pago_insertado.id,
'cliente_id', v_cliente_id,
'anio_mes_pagado', v_pago_insertado.anio_mes_pagado,
'fecha_pago', v_pago_insertado.fecha_pago,
'monto_total', v_pago_insertado.monto_total,
'status', 'success'
);
END;
$function$;
@@ -0,0 +1,90 @@
-- ============================================================================
-- fc_obtener_mis_pagos
-- ============================================================================
-- PROPÓSITO
-- Devuelve los pagos del cliente autenticado, paginados.
--
-- DOMINIO
-- Versión "self-service" de la consulta descrita en
-- Documentation/DominioPagos.md §7. Incluye los pagos anulados del propio
-- cliente: él pudo haberlos visto cuando estaban vigentes, así que también
-- debe poder consultar el rastro de la anulación
-- (Documentation/DominioPagos.md §12).
--
-- PARÁMETROS
-- p_token UUID sesión del cliente.
-- p_pagina INT (default 1) página, base 1.
-- p_cantidad INT (default 20) ítems por página.
--
-- AUTORIZACIÓN
-- Requiere permiso 'ver_mis_pagos'.
--
-- ERRORES (RAISE EXCEPTION)
-- - 'Sesión inválida o usuario no encontrado.'
--
-- RETORNA
-- JSONB array (puede ser vacío). Cada ítem: id, tipo, anio_mes_pagado,
-- fecha_pago, monto_total, detalle, metodo (descripción), metadata de
-- auditoría reducida (created_at, created_by, updated_at,
-- updated_by_nombre, anulado_at, anulado_por_nombre) y motivo_anulacion.
-- NO incluye el objeto cliente porque es siempre el del token.
--
-- EFECTOS SECUNDARIOS
-- Ninguno (STABLE).
-- ============================================================================
CREATE OR REPLACE FUNCTION public.fc_obtener_mis_pagos(
p_token UUID,
p_pagina INT DEFAULT 1,
p_cantidad INT DEFAULT 20
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
STABLE
AS $function$
DECLARE
v_user_id UUID;
v_offset INT;
BEGIN
PERFORM internal.validate_permission(p_token, 'ver_mis_pagos');
SELECT usuario_id INTO v_user_id FROM internal.sesiones WHERE token = p_token;
IF v_user_id IS NULL THEN
RAISE EXCEPTION 'Sesión inválida o usuario no encontrado.';
END IF;
v_offset := (p_pagina - 1) * p_cantidad;
RETURN (
SELECT COALESCE(jsonb_agg(pago_json), '[]'::jsonb)
FROM (
SELECT jsonb_build_object(
'id', p.id,
'tipo', p.tipo,
'anio_mes_pagado', p.anio_mes_pagado,
'fecha_pago', p.fecha_pago,
'monto_total', p.monto_total,
'detalle', p.detalle,
'metodo', m.descripcion,
'created_at', p.created_at,
'created_by', p.created_by,
'updated_at', p.updated_at,
'updated_by_nombre', uu.nombre,
'anulado_at', p.anulado_at,
'anulado_por_nombre', au.nombre,
'motivo_anulacion', p.motivo_anulacion
) AS pago_json
FROM public.pagos p
LEFT JOIN public.metodos_pago m ON p.metodo_id = m.id
LEFT JOIN public.usuarios uu ON p.updated_by = uu.id
LEFT JOIN public.usuarios au ON p.anulado_por = au.id
WHERE p.cliente_id = v_user_id
ORDER BY p.fecha_pago DESC
LIMIT p_cantidad
OFFSET v_offset
) t
);
END;
$function$;
@@ -0,0 +1,106 @@
-- ============================================================================
-- fc_obtener_pagos
-- ============================================================================
-- PROPÓSITO
-- Devuelve un lote paginado de pagos, filtrable por cliente.
-- Opcionalmente incluye los pagos anulados.
--
-- DOMINIO
-- Sirve a la operación "consultar pagos" descrita en
-- Documentation/DominioPagos.md §7. Cada ítem trae la metadata necesaria
-- para que la UI pueda distinguir pagos vigentes de anulados, identificar
-- autoría de registro / corrección / anulación y mostrar motivo (§12).
--
-- PARÁMETROS
-- p_token UUID sesión del actor.
-- p_dni TEXT (opcional) DNI del cliente para filtrar.
-- NULL = todos los clientes.
-- p_pagina INT (default 1) página, base 1.
-- p_cantidad INT (default 50) ítems por página.
-- p_incluir_anulados BOOLEAN (default FALSE) si TRUE, incluye pagos
-- anulados en el resultado.
--
-- AUTORIZACIÓN
-- Requiere permiso 'ver_pagos'.
--
-- ERRORES (RAISE EXCEPTION)
-- - 'No existe un usuario con el DNI X' cuando p_dni se pasa y no
-- coincide con ningún cliente.
--
-- RETORNA
-- JSONB array (puede ser vacío). Cada ítem: id, tipo, anio_mes_pagado,
-- fecha_pago, monto_total, detalle, metodo (descripción), metadata de
-- auditoría (created/updated/anulado: at + by_nombre), motivo_anulacion
-- y un objeto cliente {nombre, apellido, dni}.
--
-- EFECTOS SECUNDARIOS
-- Ninguno (STABLE).
-- ============================================================================
CREATE OR REPLACE FUNCTION public.fc_obtener_pagos(
p_token UUID,
p_dni TEXT DEFAULT NULL,
p_pagina INT DEFAULT 1,
p_cantidad INT DEFAULT 50,
p_incluir_anulados BOOLEAN DEFAULT FALSE
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
STABLE
AS $function$
DECLARE
v_cliente_id UUID := NULL;
v_offset INT;
BEGIN
PERFORM internal.validate_permission(p_token, 'ver_pagos');
v_offset := (p_pagina - 1) * p_cantidad;
IF p_dni IS NOT NULL THEN
SELECT id INTO v_cliente_id FROM usuarios WHERE dni = p_dni;
IF NOT FOUND THEN
RAISE EXCEPTION 'No existe un usuario con el DNI %', p_dni;
END IF;
END IF;
RETURN (
SELECT COALESCE(jsonb_agg(pago_json), '[]'::jsonb)
FROM (
SELECT jsonb_build_object(
'id', p.id,
'tipo', p.tipo,
'anio_mes_pagado', p.anio_mes_pagado,
'fecha_pago', p.fecha_pago,
'monto_total', p.monto_total,
'detalle', p.detalle,
'metodo', m.descripcion,
'created_at', p.created_at,
'created_by', p.created_by,
'created_by_nombre', cu.nombre,
'updated_at', p.updated_at,
'updated_by_nombre', uu.nombre,
'anulado_at', p.anulado_at,
'anulado_por_nombre', au.nombre,
'motivo_anulacion', p.motivo_anulacion,
'cliente', jsonb_build_object(
'nombre', cl.nombre,
'apellido', cl.apellido,
'dni', cl.dni
)
) AS pago_json
FROM public.pagos p
JOIN public.usuarios cl ON p.cliente_id = cl.id
LEFT JOIN public.metodos_pago m ON p.metodo_id = m.id
LEFT JOIN public.usuarios cu ON p.created_by = cu.id
LEFT JOIN public.usuarios uu ON p.updated_by = uu.id
LEFT JOIN public.usuarios au ON p.anulado_por = au.id
WHERE (v_cliente_id IS NULL OR p.cliente_id = v_cliente_id)
AND (p_incluir_anulados OR p.anulado_at IS NULL)
ORDER BY p.fecha_pago DESC
LIMIT p_cantidad
OFFSET v_offset
) t
);
END;
$function$;
@@ -0,0 +1,96 @@
-- ============================================================================
-- fc_modificar_metodos_pago
-- ============================================================================
-- PROPÓSITO
-- Modifica los campos editables de un método de pago existente. Semántica
-- PATCH: sólo se actualizan las claves presentes en p_datos.
--
-- DOMINIO
-- Mantenimiento de los métodos de pago como configuración del módulo.
-- No crea ni elimina; el alta lógica/baja lógica se maneja con el flag
-- `activo` del propio método.
--
-- PARÁMETROS
-- p_datos JSONB con:
-- id INTEGER (obligatorio) ID del método a modificar.
-- descripcion TEXT (opcional) nuevo nombre / etiqueta.
-- activo BOOLEAN (opcional) baja lógica (false) o reactivación
-- (true).
-- icono TEXT (opcional) identificador del ícono asociado.
-- p_token UUID sesión del actor.
--
-- AUTORIZACIÓN
-- Requiere permiso 'modificar_metodos_pago'.
--
-- ERRORES (RAISE EXCEPTION)
-- - 'Debe indicar el ID del tipo de cuota a modificar.'
-- (NOTA: el mensaje refiere a "tipo de cuota" por un error histórico;
-- en realidad valida que se pase el ID del método de pago.)
-- - 'El método de pago con ID X no existe.'
--
-- RETORNA
-- JSONB con la fila completa del método de pago tras la modificación.
--
-- EFECTOS SECUNDARIOS
-- UPDATE sobre metodos_pago. No genera evento.
-- ============================================================================
CREATE OR REPLACE FUNCTION fc_modificar_metodos_pago(
p_datos JSONB,
p_token UUID
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
VOLATILE -- Checklist #2: Modifica datos
AS $function$
DECLARE
v_resultado JSONB;
v_id INTEGER;
BEGIN
-- -----------------------------------------------------------
-- Checklist #3: Validar permisos
-- -----------------------------------------------------------
PERFORM internal.validate_permission(p_token, 'modificar_metodos_pago');
-- 3. Validar que venga el ID para saber qué modificar
IF NOT p_datos ? 'id' OR p_datos->>'id' IS NULL THEN
RAISE EXCEPTION 'Debe indicar el ID del tipo de cuota a modificar.';
END IF;
v_id := (p_datos->>'id')::INTEGER;
-- -----------------------------------------------------------
-- 1. UPDATE Dinámico y Seguro
-- -----------------------------------------------------------
UPDATE metodos_pago
SET
descripcion = CASE
WHEN p_datos ? 'descripcion' THEN p_datos->>'descripcion'
ELSE descripcion
END,
activo = CASE
WHEN p_datos ? 'activo' THEN (p_datos->>'activo')::BOOL
ELSE activo
END,
-- BUG CORREGIDO: Ahora si no viene el icono, no lo tocamos.
icono = CASE
WHEN p_datos ? 'icono' THEN p_datos->>'icono'
ELSE icono
END
WHERE id = v_id
RETURNING to_jsonb(metodos_pago.*) INTO v_resultado; -- Retorno atómico
-- -----------------------------------------------------------
-- 2. Verificar si se actualizó algo
-- -----------------------------------------------------------
IF v_resultado IS NULL THEN
RAISE EXCEPTION 'El método de pago con ID % no existe.', v_id;
END IF;
RETURN v_resultado;
END;
$function$;
@@ -0,0 +1,55 @@
-- ============================================================================
-- fc_obtener_metodos_pago
-- ============================================================================
-- PROPÓSITO
-- Devuelve todos los métodos de pago configurados (activos e inactivos).
--
-- DOMINIO
-- Los métodos de pago (efectivo, transferencia, billetera digital, etc.)
-- son entidades de configuración del módulo de pagos. Se consultan al
-- registrar o corregir un pago para que el responsable elija cómo se cobró
-- (Documentation/DominioPagos.md §5.1).
--
-- PARÁMETROS
-- p_token UUID sesión del actor.
--
-- AUTORIZACIÓN
-- Requiere permiso 'ver_metodos_pago'.
--
-- ERRORES (RAISE EXCEPTION)
-- Ninguno propio (sólo los del validador de permiso).
--
-- RETORNA
-- JSONB array de filas completas de metodos_pago, ordenadas por id.
-- Cada ítem: id, descripcion, activo, icono. Array vacío si no hay
-- registros.
--
-- EFECTOS SECUNDARIOS
-- Ninguno (STABLE).
-- ============================================================================
CREATE OR REPLACE FUNCTION fc_obtener_metodos_pago(p_token UUID)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
STABLE
AS $function$
DECLARE
-- No necesitamos variables extra si usamos internal.validate_permission
BEGIN
-- 1. Validar permisos (Checklist #3)
-- Asumimos acción 'ver_configuracion' o 'ver_metodos_pago'
-- Esto te permite mañana darle permiso al 'cliente' sin tocar este código.
PERFORM internal.validate_permission(p_token, 'ver_metodos_pago');
-- 2. Retorno directo y sin vueltas
RETURN (
SELECT COALESCE(
jsonb_agg(to_jsonb(mp) ORDER BY mp.id),
'[]'::jsonb
)
FROM public.metodos_pago mp
);
END;
$function$;