From 7dac2637173bd5e0ccc33b78ae7fe04ddca18e98 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 07:03:40 +0000 Subject: [PATCH] feat: gestionar cobros falleros idempotentes @CertezaAbsoluta @lo+erestu PCT/EP2025/067317 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GestorFallaV9 con comisión 8 %, cuota mínima 50 EUR, memoria anti-duplicados y asientos contables. Bajo Protocolo de Soberanía V10 - Founder: Rubén Co-authored-by: Tryonme --- .env.example | 14 ++ .gitignore | 3 + logic/GESTION_FALLA.md | 56 +++++ logic/__init__.py | 1 + logic/gestion_falla.py | 425 ++++++++++++++++++++++++++++++++++++ tests/test_gestion_falla.py | 190 ++++++++++++++++ 6 files changed, 689 insertions(+) create mode 100644 logic/GESTION_FALLA.md create mode 100644 logic/__init__.py create mode 100644 logic/gestion_falla.py create mode 100644 tests/test_gestion_falla.py diff --git a/.env.example b/.env.example index 79cd2ddd..a39e91df 100644 --- a/.env.example +++ b/.env.example @@ -46,6 +46,13 @@ QONTO_INVOICE_VAT_CATEGORY= # FINANCE_BRIDGE_SKIP_AUDIT_LOG= # FINANCE_BRIDGE_SKIP_TREASURY_CHECK= +# --- Gestor de cobros Falla (logic/gestion_falla.py) --- +# Comisión decimal: 0.08 = 8 %. El histórico evita duplicados por referencia. +FALLA_COMISION_PCT=0.08 +FALLA_CUOTA_MINIMA_EUR=50.00 +FALLA_MEMORIES_PATH=data/falla_memories.json +FALLA_LEDGER_PATH=logs/falla_cobros.jsonl + # Sincronización búnker Stripe ↔ Supabase (IDs LIVE; no usar po_/pi_ de test en Live) BUNKER_SYNC_STRIPE_PAYOUT_ID= BUNKER_SYNC_PAYMENT_INTENT_IDS= @@ -334,6 +341,13 @@ QONTO_INVOICE_VAT_CATEGORY= # FINANCE_BRIDGE_SKIP_AUDIT_LOG= # FINANCE_BRIDGE_SKIP_TREASURY_CHECK= +# --- Gestor de cobros Falla (logic/gestion_falla.py) --- +# Comisión decimal: 0.08 = 8 %. El histórico evita duplicados por referencia. +FALLA_COMISION_PCT=0.08 +FALLA_CUOTA_MINIMA_EUR=50.00 +FALLA_MEMORIES_PATH=data/falla_memories.json +FALLA_LEDGER_PATH=logs/falla_cobros.jsonl + # Sincronización búnker Stripe ↔ Supabase (IDs LIVE; no usar po_/pi_ de test en Live) BUNKER_SYNC_STRIPE_PAYOUT_ID= BUNKER_SYNC_PAYMENT_INTENT_IDS= diff --git a/.gitignore b/.gitignore index 8a37d960..96c70cde 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,9 @@ logs/ip_access.jsonl logs/IP_WATCH.md logs/LAFAYETTE_TTC_MONITOR.md logs/SISTEMA_SUSPENDIDO.jsonlupdate_stripe.py +logs/falla_cobros.jsonl +data/falla_memories.json +data/falla_memories.json.lock update_stripe_v10.py activate_royalties_v10.py monetizacion_trace_demo.log diff --git a/logic/GESTION_FALLA.md b/logic/GESTION_FALLA.md new file mode 100644 index 00000000..2302a332 --- /dev/null +++ b/logic/GESTION_FALLA.md @@ -0,0 +1,56 @@ +# Gestor de cobros de Falla + +`logic.gestion_falla` procesa registros provenientes de un webhook, Make.com, +una exportación de Google Sheets o una tarea programada. Aplica una comisión +del 8 % por defecto, actualiza el saldo pendiente y evita duplicados. + +## Activador + +- Webhook: enviar el registro de la pasarela con una `referencia` estable. +- Schedule: para las 09:00 cada día, usar `FREQ=DAILY;BYHOUR=9;BYMINUTE=0;BYSECOND=0` + o su equivalente cron `0 9 * * *` con zona `Europe/Madrid`. Un cron UTC fijo + `0 7 * * *` coincide con las 09:00 CEST en verano, pero se desfasa en horario + de invierno; preferir zona explícita en el orquestador. + +Payload compatible: + +```json +{ + "nombre": "Pau", + "fallero_id": "F-001", + "bruto": 100, + "concepto": "Cuota Abril", + "referencia": "pi_123", + "fecha": "2026-04-10" +} +``` + +También admite una lista o `{"registros": [...]}`. Para Make.com se conservan +los alias `monto`/`importe`, `tipo` y `payment_id`. + +## Ejecución + +```bash +python3 -m logic.gestion_falla --input cobros.json +``` + +Para recibir el JSON por entrada estándar: + +```bash +printf '%s' '{"nombre":"Pau","bruto":100,"concepto":"Cuota Abril","referencia":"pi_123"}' \ + | python3 -m logic.gestion_falla +``` + +Configuración: + +- `FALLA_COMISION_PCT=0.08` +- `FALLA_CUOTA_MINIMA_EUR=50.00` +- `FALLA_MEMORIES_PATH=data/falla_memories.json` +- `FALLA_LEDGER_PATH=logs/falla_cobros.jsonl` + +La memoria JSON es la fuente de verdad. El libro JSONL se sincroniza desde ella +en cada operación. Un reintento con la misma `referencia` devuelve +`"duplicado": true` y no genera un segundo asiento. + +Patente: PCT/EP2025/067317 +Bajo Protocolo de Soberanía V10 - Founder: Rubén diff --git a/logic/__init__.py b/logic/__init__.py new file mode 100644 index 00000000..c81a3cd5 --- /dev/null +++ b/logic/__init__.py @@ -0,0 +1 @@ +# Paquete logic (gestión Falla y finanzas operativas). diff --git a/logic/gestion_falla.py b/logic/gestion_falla.py new file mode 100644 index 00000000..a2d7a9ad --- /dev/null +++ b/logic/gestion_falla.py @@ -0,0 +1,425 @@ +"""Gestión idempotente de cobros y comisiones para comisiones falleras. + +El histórico JSON actúa como la memoria operativa y el libro JSONL se regenera +desde esa fuente de verdad. Esto permite que los reintentos de un webhook no +dupliquen cobros ni asientos contables. + +Patente: PCT/EP2025/067317 +Bajo Protocolo de Soberanía V10 - Founder: Rubén +""" +from __future__ import annotations + +import argparse +import datetime as dt +import fcntl +import hashlib +import json +import os +import tempfile +import unicodedata +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP +from pathlib import Path +from typing import Any, Iterable, Mapping + + +COMISION_PCT_PREDETERMINADA = Decimal("0.08") +CUOTA_MINIMA_PREDETERMINADA = Decimal("50.00") +CONCEPTOS_PERMITIDOS = frozenset({"cuota", "loteria", "evento"}) +ESTADO_PAGADO = "PAGADO" +ESTADO_PENDIENTE = "PENDIENTE DE REGULARIZAR" +_CENTIMOS = Decimal("0.01") +_PRECISION_TASA = Decimal("0.0001") + + +def _texto_normalizado(value: str) -> str: + text = unicodedata.normalize("NFKD", value.strip().casefold()) + return "".join(char for char in text if not unicodedata.combining(char)) + + +def _decimal_monetario(value: Any, *, campo: str) -> Decimal: + if isinstance(value, bool): + raise ValueError(f"{campo} debe ser un importe numérico") + try: + amount = Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError) as exc: + raise ValueError(f"{campo} debe ser un importe numérico") from exc + if not amount.is_finite(): + raise ValueError(f"{campo} debe ser un importe finito") + return amount.quantize(_CENTIMOS, rounding=ROUND_HALF_UP) + + +def _importe_json(value: Decimal) -> float: + return float(value.quantize(_CENTIMOS, rounding=ROUND_HALF_UP)) + + +def _tasa_json(value: Decimal) -> float: + return float(value.quantize(_PRECISION_TASA, rounding=ROUND_HALF_UP)) + + +class GestorFallaV9: + """Procesa cobros con comisión configurable y memoria local durable.""" + + def __init__( + self, + comision_pct: float | Decimal = COMISION_PCT_PREDETERMINADA, + cuota_base: float | Decimal = CUOTA_MINIMA_PREDETERMINADA, + *, + memoria_path: str | os.PathLike[str] | None = None, + libro_path: str | os.PathLike[str] | None = None, + ) -> None: + try: + self.comision_pct = Decimal(str(comision_pct)).quantize( + _PRECISION_TASA, rounding=ROUND_HALF_UP + ) + except (InvalidOperation, TypeError, ValueError) as exc: + raise ValueError("comision_pct debe ser un número decimal") from exc + if not self.comision_pct.is_finite(): + raise ValueError("comision_pct debe ser un número decimal finito") + self.cuota_base = _decimal_monetario(cuota_base, campo="cuota_base") + if not Decimal("0") <= self.comision_pct <= Decimal("1"): + raise ValueError("comision_pct debe estar entre 0 y 1") + if self.cuota_base <= 0: + raise ValueError("cuota_base debe ser mayor que cero") + + self.memoria_path = Path( + memoria_path + or os.getenv("FALLA_MEMORIES_PATH", "data/falla_memories.json") + ) + self.libro_path = Path( + libro_path + or os.getenv("FALLA_LEDGER_PATH", "logs/falla_cobros.jsonl") + ) + + def ejecutar_cobro( + self, + nombre: str, + bruto: Any, + concepto: str, + *, + referencia: str | None = None, + fallero_id: str | None = None, + fecha: str | None = None, + ) -> dict[str, Any]: + """Valida, calcula, concilia y registra un único cobro.""" + nombre_limpio = self._validar_nombre(nombre) + concepto_limpio, categoria = self._validar_concepto(concepto) + importe_bruto = _decimal_monetario(bruto, campo="bruto") + if importe_bruto <= 0: + raise ValueError("bruto debe ser mayor que cero") + + fecha_iso = self._validar_fecha(fecha) + resolved_fallero_id = self._resolver_fallero_id(nombre_limpio, fallero_id) + resolved_reference = self._resolver_referencia( + referencia, + fecha_iso, + resolved_fallero_id, + concepto_limpio, + importe_bruto, + ) + + self.memoria_path.parent.mkdir(parents=True, exist_ok=True) + lock_path = self.memoria_path.with_suffix(self.memoria_path.suffix + ".lock") + with lock_path.open("a+", encoding="utf-8") as lock: + fcntl.flock(lock.fileno(), fcntl.LOCK_EX) + try: + memoria = self._cargar_memoria() + existente = memoria["cobros"].get(resolved_reference) + if existente is not None: + self._sincronizar_libro(memoria) + return {**existente, "duplicado": True} + + fallero = memoria["falleros"].get(resolved_fallero_id) + if fallero is None: + saldo_inicial = ( + self.cuota_base if categoria == "cuota" else Decimal("0") + ) + fallero = { + "nombre": nombre_limpio, + "saldo_pendiente": _importe_json(saldo_inicial), + } + memoria["falleros"][resolved_fallero_id] = fallero + elif _texto_normalizado(fallero["nombre"]) != _texto_normalizado( + nombre_limpio + ): + raise ValueError( + f"fallero_id {resolved_fallero_id!r} pertenece a otro nombre" + ) + + saldo_anterior = _decimal_monetario( + fallero.get("saldo_pendiente", 0), + campo="saldo_pendiente", + ) + saldo_pendiente = max(Decimal("0"), saldo_anterior - importe_bruto) + comision = (importe_bruto * self.comision_pct).quantize( + _CENTIMOS, rounding=ROUND_HALF_UP + ) + neto = importe_bruto - comision + estado = ( + ESTADO_PAGADO + if importe_bruto >= self.cuota_base + else ESTADO_PENDIENTE + ) + + asiento = { + "fecha": fecha_iso, + "referencia": resolved_reference, + "fallero_id": resolved_fallero_id, + "fallero": nombre_limpio.upper(), + "concepto": concepto_limpio, + "importe_bruto": _importe_json(importe_bruto), + "comision_pct": _tasa_json(self.comision_pct), + "comision_aplicada": _importe_json(comision), + "neto_resultante": _importe_json(neto), + "saldo_pendiente": _importe_json(saldo_pendiente), + "estado": estado, + } + fallero["saldo_pendiente"] = asiento["saldo_pendiente"] + memoria["cobros"][resolved_reference] = asiento + memoria["actualizado_en"] = dt.datetime.now(dt.timezone.utc).isoformat() + + self._guardar_memoria(memoria) + self._sincronizar_libro(memoria) + return {**asiento, "duplicado": False} + finally: + fcntl.flock(lock.fileno(), fcntl.LOCK_UN) + + def procesar_pago( + self, + fallero_nombre: str, + importe_bruto: Any, + concepto: str, + **kwargs: Any, + ) -> dict[str, Any]: + """Alias compatible con el prototipo histórico guardado en Memories.""" + return self.ejecutar_cobro( + fallero_nombre, importe_bruto, concepto, **kwargs + ) + + def procesar_registros( + self, registros: Iterable[Mapping[str, Any]] + ) -> list[dict[str, Any]]: + """Normaliza payloads previsibles de webhook, Make.com o exportaciones.""" + resultados: list[dict[str, Any]] = [] + for registro in registros: + nombre = registro.get("nombre", registro.get("fallero")) + bruto = registro.get( + "bruto", registro.get("importe", registro.get("monto")) + ) + concepto = registro.get("concepto", registro.get("tipo")) + if nombre is None or bruto is None or concepto is None: + raise ValueError( + "cada registro requiere nombre/fallero, bruto/importe/monto " + "y concepto/tipo" + ) + resultados.append( + self.ejecutar_cobro( + str(nombre), + bruto, + str(concepto), + referencia=self._valor_opcional( + registro, "referencia", "payment_id", "id" + ), + fallero_id=self._valor_opcional(registro, "fallero_id"), + fecha=self._valor_opcional(registro, "fecha"), + ) + ) + return resultados + + def obtener_resumen(self) -> list[dict[str, Any]]: + """Devuelve el histórico contable en orden de inserción.""" + memoria = self._cargar_memoria() + return list(memoria["cobros"].values()) + + @staticmethod + def _valor_opcional(registro: Mapping[str, Any], *keys: str) -> str | None: + for key in keys: + value = registro.get(key) + if value is not None and str(value).strip(): + return str(value) + return None + + @staticmethod + def _validar_nombre(nombre: str) -> str: + if not isinstance(nombre, str): + raise ValueError("nombre debe ser texto") + limpio = " ".join(nombre.strip().split()) + if len(limpio) < 2 or not any(char.isalpha() for char in limpio): + raise ValueError("nombre de fallero no válido") + return limpio + + @staticmethod + def _validar_concepto(concepto: str) -> tuple[str, str]: + if not isinstance(concepto, str): + raise ValueError("concepto debe ser texto") + limpio = " ".join(concepto.strip().split()) + normalizado = _texto_normalizado(limpio) + categoria = next( + ( + permitida + for permitida in CONCEPTOS_PERMITIDOS + if normalizado == permitida or normalizado.startswith(f"{permitida} ") + ), + None, + ) + if categoria is None: + permitidos = ", ".join(sorted(CONCEPTOS_PERMITIDOS)) + raise ValueError(f"concepto no válido; categorías permitidas: {permitidos}") + return limpio, categoria + + @staticmethod + def _validar_fecha(fecha: str | None) -> str: + if fecha is None: + return dt.datetime.now(dt.timezone.utc).date().isoformat() + try: + return dt.date.fromisoformat(fecha).isoformat() + except (TypeError, ValueError) as exc: + raise ValueError("fecha debe usar el formato ISO AAAA-MM-DD") from exc + + @staticmethod + def _resolver_fallero_id(nombre: str, fallero_id: str | None) -> str: + if fallero_id is not None: + limpio = fallero_id.strip() + if not limpio or len(limpio) > 80: + raise ValueError("fallero_id no válido") + return limpio + digest = hashlib.sha256(_texto_normalizado(nombre).encode("utf-8")).hexdigest() + return f"FAL-{digest[:12].upper()}" + + @staticmethod + def _resolver_referencia( + referencia: str | None, + fecha: str, + fallero_id: str, + concepto: str, + bruto: Decimal, + ) -> str: + if referencia is not None: + limpia = referencia.strip() + if not limpia or len(limpia) > 120: + raise ValueError("referencia no válida") + return limpia + fingerprint = "|".join( + (fecha, fallero_id, _texto_normalizado(concepto), format(bruto, ".2f")) + ) + digest = hashlib.sha256(fingerprint.encode("utf-8")).hexdigest() + return f"AUTO-{digest[:24].upper()}" + + @staticmethod + def _memoria_vacia() -> dict[str, Any]: + return { + "version": 1, + "falleros": {}, + "cobros": {}, + "actualizado_en": None, + } + + def _cargar_memoria(self) -> dict[str, Any]: + if not self.memoria_path.exists(): + return self._memoria_vacia() + try: + data = json.loads(self.memoria_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + raise RuntimeError("no se puede leer la memoria de cobros") from exc + if ( + not isinstance(data, dict) + or data.get("version") != 1 + or not isinstance(data.get("falleros"), dict) + or not isinstance(data.get("cobros"), dict) + ): + raise RuntimeError("formato de memoria de cobros no compatible") + return data + + def _guardar_memoria(self, memoria: Mapping[str, Any]) -> None: + self.memoria_path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporal = tempfile.mkstemp( + prefix=f".{self.memoria_path.name}.", + suffix=".tmp", + dir=self.memoria_path.parent, + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as file: + json.dump(memoria, file, ensure_ascii=False, indent=2, sort_keys=True) + file.write("\n") + file.flush() + os.fsync(file.fileno()) + os.replace(temporal, self.memoria_path) + finally: + if os.path.exists(temporal): + os.unlink(temporal) + + def _sincronizar_libro(self, memoria: Mapping[str, Any]) -> None: + self.libro_path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporal = tempfile.mkstemp( + prefix=f".{self.libro_path.name}.", + suffix=".tmp", + dir=self.libro_path.parent, + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as file: + for asiento in memoria["cobros"].values(): + file.write( + json.dumps(asiento, ensure_ascii=False, sort_keys=True) + "\n" + ) + file.flush() + os.fsync(file.fileno()) + os.replace(temporal, self.libro_path) + finally: + if os.path.exists(temporal): + os.unlink(temporal) + + +GestionFalla = GestorFallaV9 + + +def _cargar_payload(path: str) -> list[Mapping[str, Any]]: + if path == "-": + raw = __import__("sys").stdin.read() + else: + raw = Path(path).read_text(encoding="utf-8") + payload = json.loads(raw) + if isinstance(payload, Mapping): + payload = payload.get("registros", [payload]) + if not isinstance(payload, list) or not all( + isinstance(item, Mapping) for item in payload + ): + raise ValueError("el JSON debe ser un registro, una lista o contener registros") + return payload + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Procesa cobros de una Falla") + parser.add_argument( + "--input", + default="-", + help="Archivo JSON de entrada; '-' lee stdin (predeterminado)", + ) + parser.add_argument("--memory", help="Ruta del histórico Memories") + parser.add_argument("--ledger", help="Ruta del libro contable JSONL") + parser.add_argument( + "--commission", + default=os.getenv("FALLA_COMISION_PCT", "0.08"), + help="Comisión decimal; 0.08 equivale al 8%%", + ) + parser.add_argument( + "--minimum", + default=os.getenv("FALLA_CUOTA_MINIMA_EUR", "50.00"), + help="Cuota mínima en euros", + ) + args = parser.parse_args(argv) + + try: + gestor = GestorFallaV9( + args.commission, + args.minimum, + memoria_path=args.memory, + libro_path=args.ledger, + ) + resultados = gestor.procesar_registros(_cargar_payload(args.input)) + except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as exc: + parser.error(str(exc)) + print(json.dumps({"cobros": resultados}, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_gestion_falla.py b/tests/test_gestion_falla.py new file mode 100644 index 00000000..b68bc674 --- /dev/null +++ b/tests/test_gestion_falla.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path + +_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +if _ROOT not in sys.path: + sys.path.insert(0, _ROOT) + +from logic.gestion_falla import ESTADO_PAGADO, ESTADO_PENDIENTE, GestorFallaV9 + + +class TestGestorFallaV9(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + base = Path(self.temp_dir.name) + self.memory = base / "data" / "memories.json" + self.ledger = base / "logs" / "cobros.jsonl" + self.gestor = GestorFallaV9( + memoria_path=self.memory, + libro_path=self.ledger, + ) + + def tearDown(self) -> None: + self.temp_dir.cleanup() + + def test_calcula_comision_neto_y_asiento_al_ocho_por_ciento(self) -> None: + asiento = self.gestor.ejecutar_cobro( + "Pau", + 100, + "Cuota Abril", + referencia="pay_001", + fallero_id="F-001", + fecha="2026-04-10", + ) + + self.assertEqual(asiento["fecha"], "2026-04-10") + self.assertEqual(asiento["fallero_id"], "F-001") + self.assertEqual(asiento["fallero"], "PAU") + self.assertEqual(asiento["importe_bruto"], 100.0) + self.assertEqual(asiento["comision_pct"], 0.08) + self.assertEqual(asiento["comision_aplicada"], 8.0) + self.assertEqual(asiento["neto_resultante"], 92.0) + self.assertEqual(asiento["saldo_pendiente"], 0.0) + self.assertEqual(asiento["estado"], ESTADO_PAGADO) + self.assertFalse(asiento["duplicado"]) + + def test_marca_cobro_inferior_y_actualiza_saldo_pendiente(self) -> None: + asiento = self.gestor.ejecutar_cobro( + "Maria M.", + "40.00", + "Cuota Anual", + referencia="pay_bajo", + fecha="2026-07-17", + ) + + self.assertEqual(asiento["estado"], ESTADO_PENDIENTE) + self.assertEqual(asiento["saldo_pendiente"], 10.0) + self.assertEqual(asiento["comision_aplicada"], 3.2) + self.assertEqual(asiento["neto_resultante"], 36.8) + + def test_conserva_precision_de_una_comision_personalizada(self) -> None: + gestor = GestorFallaV9( + comision_pct="0.075", + memoria_path=self.memory, + libro_path=self.ledger, + ) + + asiento = gestor.ejecutar_cobro( + "Pau", + 100, + "Evento Presentación", + referencia="fee_075", + ) + + self.assertEqual(asiento["comision_pct"], 0.075) + self.assertEqual(asiento["comision_aplicada"], 7.5) + self.assertEqual(asiento["neto_resultante"], 92.5) + + def test_reintento_no_duplica_memoria_ni_libro(self) -> None: + first = self.gestor.ejecutar_cobro( + "José S.", + 200, + "Lotería", + referencia="stripe_pi_123", + fecha="2026-07-17", + ) + second = self.gestor.ejecutar_cobro( + "José S.", + 200, + "Lotería", + referencia="stripe_pi_123", + fecha="2026-07-17", + ) + + self.assertFalse(first["duplicado"]) + self.assertTrue(second["duplicado"]) + self.assertEqual(len(self.gestor.obtener_resumen()), 1) + ledger_lines = self.ledger.read_text(encoding="utf-8").splitlines() + self.assertEqual(len(ledger_lines), 1) + self.assertEqual(json.loads(ledger_lines[0])["referencia"], "stripe_pi_123") + + def test_persistencia_sobrevive_a_una_nueva_instancia(self) -> None: + self.gestor.ejecutar_cobro( + "Pau", + 100, + "Evento Presentación", + referencia="evt_001", + ) + reloaded = GestorFallaV9( + memoria_path=self.memory, + libro_path=self.ledger, + ) + + self.assertEqual(len(reloaded.obtener_resumen()), 1) + self.assertEqual(reloaded.obtener_resumen()[0]["referencia"], "evt_001") + + def test_payload_make_admite_aliases_estables(self) -> None: + resultados = self.gestor.procesar_registros( + [ + { + "nombre": "Paco G.", + "monto": 75, + "tipo": "Cuota Mayo", + "payment_id": "make_001", + "fallero_id": "F-075", + "fecha": "2026-05-01", + } + ] + ) + + self.assertEqual(resultados[0]["referencia"], "make_001") + self.assertEqual(resultados[0]["fallero_id"], "F-075") + self.assertEqual(resultados[0]["neto_resultante"], 69.0) + + def test_referencia_automatica_es_idempotente(self) -> None: + first = self.gestor.ejecutar_cobro( + "Ana Pérez", + 50, + "Cuota Junio", + fecha="2026-06-01", + ) + second = self.gestor.ejecutar_cobro( + " Ana Pérez ", + "50.0", + "cuota junio", + fecha="2026-06-01", + ) + + self.assertEqual(first["referencia"], second["referencia"]) + self.assertTrue(second["duplicado"]) + + def test_rechaza_datos_financieros_invalidos(self) -> None: + casos = [ + ("", 50, "Cuota"), + ("Pau", 0, "Cuota"), + ("Pau", -1, "Cuota"), + ("Pau", True, "Cuota"), + ("Pau", 50, "Donativo"), + ] + for nombre, bruto, concepto in casos: + with self.subTest(nombre=nombre, bruto=bruto, concepto=concepto): + with self.assertRaises(ValueError): + self.gestor.ejecutar_cobro(nombre, bruto, concepto) + + def test_rechaza_reutilizar_id_para_otro_fallero(self) -> None: + self.gestor.ejecutar_cobro( + "Pau", + 50, + "Cuota Abril", + fallero_id="F-001", + referencia="pay_001", + ) + + with self.assertRaisesRegex(ValueError, "pertenece a otro nombre"): + self.gestor.ejecutar_cobro( + "María", + 50, + "Cuota Abril", + fallero_id="F-001", + referencia="pay_002", + ) + + +if __name__ == "__main__": + unittest.main()