Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 35 additions & 7 deletions sifende/commands/inutilizar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)
Expand All @@ -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:
Expand Down
8 changes: 5 additions & 3 deletions sifende/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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>`.
`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 '<sin mensaje>'}")
reference = f" (CDC={cdc})" if cdc else ""
super().__init__(f"{estado}{reference}: {self.mensaje_rechazo or '<sin mensaje>'}")


class TimeoutError(SifendeError): # noqa: A001 — intentional shadow, scoped to this module's namespace
Expand Down
6 changes: 6 additions & 0 deletions tests/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
99 changes: 88 additions & 11 deletions tests/test_inutilizar_guard.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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


Expand All @@ -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
Loading