diff --git a/CHANGELOG.md b/CHANGELOG.md index 94d88d1..f9a29c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed +- `inutilizar` ahora acepta el rango real de SIFEN (2 a 1000 números), rechaza + localmente rangos unitarios y devuelve exit code `2` cuando el evento queda + `RECHAZADO` o no retorna el código de aprobación `0600`. - La validación local ahora depende del `tipoDocumento`: la factura sigue requiriendo `condicionOperacion`; las NC/ND requieren `motivoEmision` (valor válido del enum SIFEN) y un `documentoAsociado` `ELECTRONICO` con CDC de 44 diff --git a/README.md b/README.md index 57c9a22..83d361e 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,10 @@ Variables soportadas en `.env`: Flags globales disponibles en todos los comandos: `--quiet`, `--json`, `--debug`, `--api-key`, `--base-url`. Con Make se pasan como `EXTRA="--json"`. +La inutilización admite rangos de 2 a 1000 números por evento, conforme a las +restricciones de SIFEN. Una respuesta HTTP exitosa cuyo evento termine en +`RECHAZADO` o con un código distinto de `0600` devuelve exit code `2`. + Al aprobar un documento, la CLI guarda automáticamente `documentos/{cdc}/` con `payload.json`, `response.json` y `kude.pdf`. ### Notas de crédito y débito diff --git a/sifende/commands/inutilizar.py b/sifende/commands/inutilizar.py index 005293c..27b9dfa 100644 --- a/sifende/commands/inutilizar.py +++ b/sifende/commands/inutilizar.py @@ -3,12 +3,17 @@ from __future__ import annotations import json +import sys from ..client import SifendeClient -from ..errors import ValidationError +from ..errors import RejectedError, ValidationError from ..utils.validation import validate_motivo +MIN_RANGO = 2 +MAX_RANGO = 1000 + + def register(subparsers, *, parents=()) -> None: p = subparsers.add_parser( "inutilizar", @@ -24,19 +29,27 @@ def register(subparsers, *, parents=()) -> None: help="Código de punto de expedición (3 dígitos, ej: 001).") p.add_argument("--numero-timbrado", required=True, help="Número de timbrado electrónico.") - p.add_argument("--desde", required=True, type=int, help="Número inicial del rango.") - p.add_argument("--hasta", required=True, type=int, help="Número final del rango.") + p.add_argument("--desde", required=True, type=int, help="Número inicial del rango (mínimo 2 números).") + p.add_argument("--hasta", required=True, type=int, help="Número final del rango (máximo 1000 números).") p.add_argument("--motivo", "-m", required=True, help="Motivo de inutilización (mín. 5 caracteres).") def run(args, client: SifendeClient) -> int: - MAX_RANGO = 4 - motivo = validate_motivo(args.motivo) if args.desde < 1 or args.hasta < args.desde: raise ValidationError("rango", "se requiere 1 ≤ desde ≤ hasta") - if (args.hasta - args.desde + 1) > MAX_RANGO: - raise ValidationError("rango", f"máximo {MAX_RANGO} números por inutilización (recibido: {args.hasta - args.desde + 1})") + + cantidad = args.hasta - args.desde + 1 + if cantidad < MIN_RANGO: + raise ValidationError( + "rango", + f"mínimo {MIN_RANGO} números por inutilización (recibido: {cantidad})", + ) + if cantidad > MAX_RANGO: + raise ValidationError( + "rango", + f"máximo {MAX_RANGO} números por inutilización (recibido: {cantidad})", + ) est = str(args.establecimiento).zfill(3) pe = str(args.punto_expedicion).zfill(3) @@ -52,6 +65,21 @@ def run(args, client: SifendeClient) -> int: } payload = client.inutilizar(body) + estado = str(payload.get("estadoEvento") or "RESPUESTA_INVALIDA").upper() + codigo = str(payload.get("codigoRespuesta") or "") + aprobado = estado == "APROBADO" and codigo == "0600" + + if not aprobado: + mensaje = str(payload.get("mensajeRespuesta") or "respuesta de evento no aprobada") + if codigo and f"[{codigo}]" not in mensaje: + mensaje = f"[{codigo}] {mensaje}" + + if args.json: + print(json.dumps(payload, ensure_ascii=False)) + elif args.quiet: + print(f"{estado}: {mensaje}", file=sys.stderr) + raise RejectedError(None, estado, mensaje) + if args.json: print(json.dumps(payload, ensure_ascii=False)) elif args.quiet: diff --git a/sifende/errors.py b/sifende/errors.py index ede1c35..fd6107c 100644 --- a/sifende/errors.py +++ b/sifende/errors.py @@ -74,15 +74,17 @@ class NotFoundError(HttpError): class RejectedError(SifendeError): """SIFEN returned a terminal RECHAZADO or ERROR state. - `cdc` is always preserved so the user can re-check via `estado `. + `cdc` is preserved for document operations. Event operations such as + inutilizacion do not have a CDC, so they pass ``None``. `mensaje_rechazo` is verbatim from SIFEN and printed unchanged. """ - def __init__(self, cdc: str, estado: str, mensaje_rechazo: Optional[str]) -> None: + def __init__(self, cdc: Optional[str], estado: str, mensaje_rechazo: Optional[str]) -> None: self.cdc = cdc self.estado = estado self.mensaje_rechazo = mensaje_rechazo or "" - super().__init__(f"{estado} (CDC={cdc}): {self.mensaje_rechazo or ''}") + reference = f" (CDC={cdc})" if cdc else "" + super().__init__(f"{estado}{reference}: {self.mensaje_rechazo or ''}") class TimeoutError(SifendeError): # noqa: A001 — intentional shadow, scoped to this module's namespace diff --git a/tests/test_errors.py b/tests/test_errors.py index 49033fe..7dc3ba6 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -40,3 +40,9 @@ def test_rejected_error_stores_cdc_estado_mensaje(): assert err.estado == "RECHAZADO" assert err.mensaje_rechazo == "factura rechazada por SIFEN" assert "RECHAZADO" in str(err) + + +def test_rejected_event_error_does_not_render_empty_cdc(): + err = RejectedError(None, "RECHAZADO", "evento rechazado") + assert "CDC" not in str(err) + assert str(err) == "RECHAZADO: evento rechazado" diff --git a/tests/test_inutilizar_guard.py b/tests/test_inutilizar_guard.py index 53325c4..a1b8c5b 100644 --- a/tests/test_inutilizar_guard.py +++ b/tests/test_inutilizar_guard.py @@ -1,10 +1,19 @@ from types import SimpleNamespace -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest from sifende.commands.inutilizar import run -from sifende.errors import ValidationError +from sifende.config import Config +from sifende.cli import main +from sifende.errors import RejectedError, ValidationError + + +APPROVED_RESPONSE = { + "estadoEvento": "APROBADO", + "codigoRespuesta": "0600", + "mensajeRespuesta": "[0600] Evento registrado correctamente", +} def _make_args(*, desde, hasta): @@ -21,30 +30,39 @@ def _make_args(*, desde, hasta): ) -def test_range_of_4_passes_guard(): +def test_range_of_2_passes_guard(): + client = MagicMock() + client.inutilizar.return_value = APPROVED_RESPONSE + args = _make_args(desde=1, hasta=2) + rc = run(args, client) + assert rc == 0 + assert client.inutilizar.called + + +def test_range_of_1000_passes_guard(): client = MagicMock() - client.inutilizar.return_value = {} - args = _make_args(desde=1, hasta=4) + client.inutilizar.return_value = APPROVED_RESPONSE + args = _make_args(desde=1, hasta=1000) rc = run(args, client) assert rc == 0 assert client.inutilizar.called -def test_range_of_5_raises(): +def test_range_of_1001_raises(): client = MagicMock() - args = _make_args(desde=1, hasta=5) + args = _make_args(desde=1, hasta=1001) with pytest.raises(ValidationError) as exc: run(args, client) - assert "máximo 4" in str(exc.value) + assert "máximo 1000" in str(exc.value) assert not client.inutilizar.called -def test_range_of_100_raises(): +def test_single_number_raises(): client = MagicMock() - args = _make_args(desde=1, hasta=100) + args = _make_args(desde=4, hasta=4) with pytest.raises(ValidationError) as exc: run(args, client) - assert "máximo 4" in str(exc.value) + assert "mínimo 2" in str(exc.value) assert not client.inutilizar.called @@ -62,3 +80,62 @@ def test_desde_greater_than_hasta_raises(): with pytest.raises(ValidationError) as exc: run(args, client) assert "desde ≤ hasta" in str(exc.value) + + +def test_rejected_event_raises_rejected_error(): + client = MagicMock() + client.inutilizar.return_value = { + "estadoEvento": "RECHAZADO", + "codigoRespuesta": "4065", + "mensajeRespuesta": "[4065] Existe DTE en el rango informado", + } + + with pytest.raises(RejectedError) as exc: + run(_make_args(desde=1008, hasta=1011), client) + + assert exc.value.cdc is None + assert exc.value.estado == "RECHAZADO" + assert "4065" in str(exc.value) + + +def test_approved_state_with_non_success_code_is_rejected(): + client = MagicMock() + client.inutilizar.return_value = { + "estadoEvento": "APROBADO", + "codigoRespuesta": "9999", + "mensajeRespuesta": "respuesta inconsistente", + } + + with pytest.raises(RejectedError): + run(_make_args(desde=1, hasta=2), client) + + +def test_cli_returns_exit_code_2_for_rejected_event(capsys): + client = MagicMock() + client.inutilizar.return_value = { + "estadoEvento": "RECHAZADO", + "codigoRespuesta": "4065", + "mensajeRespuesta": "[4065] Existe DTE en el rango informado", + } + client_context = MagicMock() + client_context.__enter__.return_value = client + + argv = [ + "inutilizar", + "--tipo-documento", "1", + "--establecimiento", "001", + "--punto-expedicion", "001", + "--numero-timbrado", "19046779", + "--desde", "1008", + "--hasta", "1011", + "--motivo", "Error de sistema", + ] + + with ( + patch("sifende.cli.load_config", return_value=Config("test-key", "https://example.test/")), + patch("sifende.cli.SifendeClient", return_value=client_context), + ): + exit_code = main(argv) + + assert exit_code == 2 + assert "RECHAZADO" in capsys.readouterr().err