From a79bb6e7f613d3445e8b6f695c9e6d028d55a6c2 Mon Sep 17 00:00:00 2001 From: Sebastian Alvarez Date: Mon, 27 Jul 2026 13:02:47 -0300 Subject: [PATCH] =?UTF-8?q?feat(emitir):=20emitir=20notas=20de=20cr=C3=A9d?= =?UTF-8?q?ito=20y=20d=C3=A9bito=20electr=C3=B3nicas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El comando `emitir` ahora soporta NOTA_DE_CREDITO_ELECTRONICA y NOTA_DE_DEBITO_ELECTRONICA, tanto por archivo como en modo interactivo. - Validación local dependiente de tipoDocumento: la factura sigue requiriendo condicionOperacion; las NC/ND requieren motivoEmision (enum SIFEN TiMotEmi) y un documentoAsociado ELECTRONICO con CDC de 44 dígitos (espejo del DocumentoAsociadoValidator del backend). - El wizard interactivo pide motivo y CDC del documento asociado al elegir NC (2) o ND (3), y omite los campos exclusivos de factura. - Nuevos samples sample_nota_credito.json y sample_nota_debito.json. - Targets make nota-credito / nota-debito (vars FILE_NC / FILE_ND). - validate_factura_payload / build_factura_interactive quedan como alias de las nuevas validate_documento_payload / build_documento_interactive. - Tests de validación e interactivo para NC/ND. --- CHANGELOG.md | 21 ++++++++ Makefile | 13 ++++- README.md | 22 +++++++++ sample_nota_credito.json | 31 ++++++++++++ sample_nota_debito.json | 31 ++++++++++++ sifende/commands/emitir.py | 8 ++-- sifende/interactive.py | 90 +++++++++++++++++++++++++---------- sifende/utils/validation.py | 62 +++++++++++++++++++++++- tests/conftest.py | 34 +++++++++++++ tests/test_interactive.py | 86 +++++++++++++++++++++++++++++++++ tests/test_nota_validation.py | 77 ++++++++++++++++++++++++++++++ 11 files changed, 443 insertions(+), 32 deletions(-) create mode 100644 sample_nota_credito.json create mode 100644 sample_nota_debito.json create mode 100644 tests/test_interactive.py create mode 100644 tests/test_nota_validation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index af4559b..94d88d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Emisión de **notas de crédito** (`NOTA_DE_CREDITO_ELECTRONICA`) y **notas de + débito** (`NOTA_DE_DEBITO_ELECTRONICA`) vía el comando `emitir`, tanto por + archivo como en modo interactivo. El wizard pide `motivoEmision` y el CDC del + `documentoAsociado` (la factura original) cuando se elige NC/ND. +- Samples `sample_nota_credito.json` y `sample_nota_debito.json`. +- Targets `make nota-credito` y `make nota-debito` (con variables `FILE_NC` / + `FILE_ND`). + +### Changed + +- 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 + dígitos. `validate_factura_payload` / `build_factura_interactive` se mantienen + como alias de las nuevas `validate_documento_payload` / + `build_documento_interactive`. + ## [0.1.0] - 2026-05-09 Initial release. diff --git a/Makefile b/Makefile index 6ae6bb3..91febdb 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,8 @@ SIFENDE := $(PY) sifende.py # Variables por comando (se pueden pisar desde la CLI). Los defaults permiten # que `make emitir` funcione directo después de `make setup` con el sample incluido. FILE ?= sample_factura.json +FILE_NC ?= sample_nota_credito.json +FILE_ND ?= sample_nota_debito.json CDC ?= OUT ?= MOTIVO ?= @@ -26,8 +28,8 @@ EXTRA ?= .DEFAULT_GOAL := help -.PHONY: help setup install env emitir emitir-i estado kude cancelar inutilizar \ - emit-and-pdf clean +.PHONY: help setup install env emitir emitir-i nota-credito nota-debito estado \ + kude cancelar inutilizar emit-and-pdf clean help: ## Mostrar esta ayuda @echo "Sifende CLI — comandos disponibles:" @@ -37,6 +39,7 @@ help: ## Mostrar esta ayuda @echo "" @echo "Variables (pisá con VAR=valor):" @echo " FILE=$(FILE)" + @echo " FILE_NC=$(FILE_NC) FILE_ND=$(FILE_ND)" @echo " CDC, OUT, MOTIVO, TIPO, EST, PE, DESDE, HASTA, EXTRA" # Guard de variable requerida. Los targets listan `guard-FOO` como prerequisito @@ -69,6 +72,12 @@ emitir: ## Emitir documento (FILE=sample_factura.json por defecto) emitir-i: ## Emitir en modo interactivo (sin --file) $(SIFENDE) emitir $(EXTRA) +nota-credito: ## Emitir nota de crédito (FILE_NC=sample_nota_credito.json por defecto) + $(SIFENDE) emitir --file $(FILE_NC) $(EXTRA) + +nota-debito: ## Emitir nota de débito (FILE_ND=sample_nota_debito.json por defecto) + $(SIFENDE) emitir --file $(FILE_ND) $(EXTRA) + estado: guard-CDC ## Consultar estado — make estado CDC=xxxx $(SIFENDE) estado $(CDC) $(EXTRA) diff --git a/README.md b/README.md index 7b41f4f..57c9a22 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ Variables soportadas en `.env`: |-----------|------|--------| | Emitir (archivo) | `make emitir FILE=factura.json` | `python sifende.py emitir --file factura.json` | | Emitir (interactivo) | `make emitir-i` | `python sifende.py emitir` | +| Nota de crédito | `make nota-credito FILE_NC=nc.json` | `python sifende.py emitir --file nc.json` | +| Nota de débito | `make nota-debito FILE_ND=nd.json` | `python sifende.py emitir --file nd.json` | | Consultar estado | `make estado CDC=` | `python sifende.py estado ` | | Descargar KuDE | `make kude CDC= OUT=factura.pdf` | `python sifende.py kude --out factura.pdf` | | Cancelar | `make cancelar CDC= MOTIVO="..."` | `python sifende.py cancelar --motivo "..."` | @@ -56,6 +58,24 @@ Con Make se pasan como `EXTRA="--json"`. 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 + +Las notas de crédito (`NOTA_DE_CREDITO_ELECTRONICA`) y débito +(`NOTA_DE_DEBITO_ELECTRONICA`) usan el mismo comando `emitir`. Respecto a una +factura, requieren dos campos extra y **no** llevan `condicionOperacion`/`condicionPago`: + +- `motivoEmision`: uno de `DEVOLUCION`, `DEVOLUCION_Y_AJUSTES_DE_PRECIOS`, + `DESCUENTO`, `BONIFICACION`, `CREDITO_INCOBRABLE`, `RECUPERO_DE_COSTO`, + `RECUPERO_DE_GASTO`, `AJUSTE_DE_PRECIO`. +- `documentoAsociado`: la referencia al DE original. Hoy sólo se admite + `{"tipoDocumento": "ELECTRONICO", "cdc": "<44 dígitos>"}`, donde el CDC es el + de una factura ya aprobada. + +Hay ejemplos en `sample_nota_credito.json` y `sample_nota_debito.json` — reemplazá +el `cdc` del `documentoAsociado` por el de una factura real antes de emitir. El +modo interactivo (`make emitir-i`) también guía la carga de estos campos cuando +elegís tipo 2 (nota de crédito) o 3 (nota de débito). + ## Estructura del proyecto @@ -67,6 +87,8 @@ sifende_cli/ ├── requirements.txt ├── .env.example ├── sample_factura.json +├── sample_nota_credito.json +├── sample_nota_debito.json ├── sifende.py └── sifende/ ├── __main__.py diff --git a/sample_nota_credito.json b/sample_nota_credito.json new file mode 100644 index 0000000..09e1f22 --- /dev/null +++ b/sample_nota_credito.json @@ -0,0 +1,31 @@ +{ + "tipoDocumento": "NOTA_DE_CREDITO_ELECTRONICA", + "tipoEmision": "NORMAL", + "fechaEmision": "2026-05-09T11:00:00", + "numeroEstablecimiento": 1, + "puntoExpedicion": 1, + "monedaOperacion": "PYG", + "motivoEmision": "DEVOLUCION", + "documentoAsociado": { + "tipoDocumento": "ELECTRONICO", + "cdc": "01800000000001001000000012026050912345678901" + }, + "receptor": { + "tipoContribuyente": "NO_CONTRIBUYENTE", + "tipoOperacion": "B2C", + "tipoDocumento": "CEDULA_PARAGUAYA", + "numeroDocumento": "1234567", + "nombreRazonSocial": "JUAN PEREZ" + }, + "items": [ + { + "codigo": "PROD-001", + "descripcion": "Servicio de consultoría (devolución)", + "unidadMedida": "UNI", + "cantidad": 1, + "precioUnitario": 100000, + "tasaIVA": 10, + "afectacionTributaria": "GRAVADO" + } + ] +} diff --git a/sample_nota_debito.json b/sample_nota_debito.json new file mode 100644 index 0000000..08c641c --- /dev/null +++ b/sample_nota_debito.json @@ -0,0 +1,31 @@ +{ + "tipoDocumento": "NOTA_DE_DEBITO_ELECTRONICA", + "tipoEmision": "NORMAL", + "fechaEmision": "2026-05-09T11:30:00", + "numeroEstablecimiento": 1, + "puntoExpedicion": 1, + "monedaOperacion": "PYG", + "motivoEmision": "AJUSTE_DE_PRECIO", + "documentoAsociado": { + "tipoDocumento": "ELECTRONICO", + "cdc": "01800000000001001000000012026050912345678901" + }, + "receptor": { + "tipoContribuyente": "NO_CONTRIBUYENTE", + "tipoOperacion": "B2C", + "tipoDocumento": "CEDULA_PARAGUAYA", + "numeroDocumento": "1234567", + "nombreRazonSocial": "JUAN PEREZ" + }, + "items": [ + { + "codigo": "PROD-001", + "descripcion": "Ajuste de precio - Servicio de consultoría", + "unidadMedida": "UNI", + "cantidad": 1, + "precioUnitario": 20000, + "tasaIVA": 10, + "afectacionTributaria": "GRAVADO" + } + ] +} diff --git a/sifende/commands/emitir.py b/sifende/commands/emitir.py index 683a183..2e1a23c 100644 --- a/sifende/commands/emitir.py +++ b/sifende/commands/emitir.py @@ -7,11 +7,11 @@ from ..client import SifendeClient from ..errors import RejectedError, TimeoutError as PollTimeoutError -from ..interactive import build_factura_interactive +from ..interactive import build_documento_interactive from ..models import Estado, EmitirResponse from ..polling import DEFAULT_TIMEOUT_S, poll_until_terminal from ..utils.io import load_json_payload, save_document_folder, save_pdf -from ..utils.validation import validate_factura_payload +from ..utils.validation import validate_documento_payload def register(subparsers, *, parents=()) -> None: @@ -38,12 +38,12 @@ def run(args, client: SifendeClient) -> int: payload = load_json_payload(args.file) else: try: - payload = build_factura_interactive() + payload = build_documento_interactive() except KeyboardInterrupt: print("\nemisión cancelada", file=sys.stderr) return 130 - validate_factura_payload(payload) + validate_documento_payload(payload) response_payload = client.emitir(payload) initial = EmitirResponse.from_api(response_payload) diff --git a/sifende/interactive.py b/sifende/interactive.py index e34803c..274c379 100644 --- a/sifende/interactive.py +++ b/sifende/interactive.py @@ -8,7 +8,7 @@ from .errors import ValidationError from .utils.formatting import fmt_pyg from .utils.io import prompt -from .utils.validation import validate_pyg +from .utils.validation import NOTAS, validate_cdc, validate_pyg _TIPO_DOC_CHOICES = { @@ -16,6 +16,17 @@ "2": "NOTA_DE_CREDITO_ELECTRONICA", "3": "NOTA_DE_DEBITO_ELECTRONICA", } +# SIFEN TiMotEmi — keys mirror the enum numeric value. +_MOTIVO_CHOICES = { + "1": "DEVOLUCION_Y_AJUSTES_DE_PRECIOS", + "2": "DEVOLUCION", + "3": "DESCUENTO", + "4": "BONIFICACION", + "5": "CREDITO_INCOBRABLE", + "6": "RECUPERO_DE_COSTO", + "7": "RECUPERO_DE_GASTO", + "8": "AJUSTE_DE_PRECIO", +} _MONEDA_CHOICES = {"1": "PYG", "2": "USD", "3": "BRL", "4": "ARS", "5": "EUR"} _OP_CHOICES = {"1": "B2C", "2": "B2B", "3": "B2G"} _IVA_CHOICES = { @@ -69,8 +80,12 @@ def _yesno(value: str) -> str: raise ValidationError("respuesta", "responda 's' o 'n'") -def build_factura_interactive() -> dict: - """Run the interactive wizard. Raises KeyboardInterrupt on Ctrl+C.""" +def build_documento_interactive() -> dict: + """Run the interactive wizard. Raises KeyboardInterrupt on Ctrl+C. + + Builds a FACTURA, NOTA_DE_CREDITO or NOTA_DE_DEBITO payload depending on + the document type chosen up front. + """ print("=== Sifende — Nuevo Documento Electrónico ===") tipo_key = prompt( @@ -155,35 +170,62 @@ def build_factura_interactive() -> dict: "afectacionTributaria": afectacion, }) - print("\n— Pago —") - pago_key = prompt( - "Tipo de pago: 1)Efectivo 2)T.Crédito 3)T.Débito 4)Transferencia 5)Cheque 6)Billetera 7)Otro", - default="1", - validator=lambda s: _choice(s, _TIPO_PAGO_CHOICES, field="tipoPago"), - ) total_general = sum(i["precioUnitario"] * i["cantidad"] for i in items) - condicion_pago = { - "tipo": "CONTADO", - "tipoPago": _TIPO_PAGO_CHOICES[pago_key], - "monedaPago": moneda, - "montoPago": total_general, - } - - print(f"\nResumen: {len(items)} ítem(s) — Total: {fmt_pyg(total_general)} {moneda}") - confirm = prompt("Confirmar emisión (s/n)", default="s", validator=_yesno) - if confirm != "s": - raise KeyboardInterrupt - return { + payload: dict = { "tipoDocumento": tipo_doc, "tipoEmision": "NORMAL", - "tipoTransaccion": "VENTA_MERCADERIA", "fechaEmision": fecha, "numeroEstablecimiento": establecimiento, "puntoExpedicion": punto_exp, "monedaOperacion": moneda, - "condicionOperacion": "CONTADO", - "condicionPago": condicion_pago, "receptor": receptor, "items": items, } + + if tipo_doc in NOTAS: + # Nota de crédito / débito: motivo + referencia al DE original. + etiqueta = "crédito" if tipo_doc == "NOTA_DE_CREDITO_ELECTRONICA" else "débito" + print(f"\n— Nota de {etiqueta} —") + motivo_default = "2" if tipo_doc == "NOTA_DE_CREDITO_ELECTRONICA" else "8" + motivo_key = prompt( + "Motivo de emisión: 1)Devol.+ajuste 2)Devolución 3)Descuento 4)Bonificación " + "5)Crédito incobrable 6)Recupero costo 7)Recupero gasto 8)Ajuste de precio", + default=motivo_default, + validator=lambda s: _choice(s, _MOTIVO_CHOICES, field="motivoEmision"), + ) + cdc_asociado = prompt( + "CDC del documento asociado (factura original, 44 dígitos)", + validator=lambda s: validate_cdc(s, field="documentoAsociado.cdc"), + ) + payload["motivoEmision"] = _MOTIVO_CHOICES[motivo_key] + payload["documentoAsociado"] = {"tipoDocumento": "ELECTRONICO", "cdc": cdc_asociado} + resumen_extra = f" — Motivo: {_MOTIVO_CHOICES[motivo_key]}" + else: + # Factura: condición de operación + detalle de pago. + print("\n— Pago —") + pago_key = prompt( + "Tipo de pago: 1)Efectivo 2)T.Crédito 3)T.Débito 4)Transferencia 5)Cheque 6)Billetera 7)Otro", + default="1", + validator=lambda s: _choice(s, _TIPO_PAGO_CHOICES, field="tipoPago"), + ) + payload["tipoTransaccion"] = "VENTA_MERCADERIA" + payload["condicionOperacion"] = "CONTADO" + payload["condicionPago"] = { + "tipo": "CONTADO", + "tipoPago": _TIPO_PAGO_CHOICES[pago_key], + "monedaPago": moneda, + "montoPago": total_general, + } + resumen_extra = "" + + print(f"\nResumen: {len(items)} ítem(s) — Total: {fmt_pyg(total_general)} {moneda}{resumen_extra}") + confirm = prompt("Confirmar emisión (s/n)", default="s", validator=_yesno) + if confirm != "s": + raise KeyboardInterrupt + + return payload + + +# Backwards-compatible alias — the wizard now covers FE + NC/ND. +build_factura_interactive = build_documento_interactive diff --git a/sifende/utils/validation.py b/sifende/utils/validation.py index f63d70c..08f9fa6 100644 --- a/sifende/utils/validation.py +++ b/sifende/utils/validation.py @@ -11,6 +11,24 @@ _RUC_RE = re.compile(r"^\d{1,8}-\d$") _CDC_RE = re.compile(r"^\d{44}$") +# tipoDocumento discriminators understood by the polymorphic API endpoint. +FACTURA = "FACTURA_ELECTRONICA" +NOTA_CREDITO = "NOTA_DE_CREDITO_ELECTRONICA" +NOTA_DEBITO = "NOTA_DE_DEBITO_ELECTRONICA" +NOTAS = frozenset({NOTA_CREDITO, NOTA_DEBITO}) + +# Valid `motivoEmision` values (SIFEN TiMotEmi enum) for NC/ND. +VALID_MOTIVOS = frozenset({ + "DEVOLUCION_Y_AJUSTES_DE_PRECIOS", + "DEVOLUCION", + "DESCUENTO", + "BONIFICACION", + "CREDITO_INCOBRABLE", + "RECUPERO_DE_COSTO", + "RECUPERO_DE_GASTO", + "AJUSTE_DE_PRECIO", +}) + def validate_ruc(value: str, *, field: str = "ruc") -> str: if not isinstance(value, str) or not _RUC_RE.match(value): @@ -50,9 +68,30 @@ def validate_motivo(value: str, *, field: str = "motivo", min_len: int = 5) -> s return value.strip() -def validate_factura_payload(payload: dict) -> dict: +def validate_documento_asociado(asoc, *, field: str = "documentoAsociado") -> dict: + """Validate the reference to the original DE that a NC/ND modifies. + + Mirrors the backend `DocumentoAsociadoValidator`: only ELECTRONICO is + accepted, and it must carry a 44-digit CDC. + """ + if not isinstance(asoc, dict): + raise ValidationError(field, "se esperaba un objeto") + tipo = asoc.get("tipoDocumento") + if tipo != "ELECTRONICO": + raise ValidationError( + f"{field}.tipoDocumento", + "solo se admite documento asociado ELECTRONICO", + ) + validate_cdc(asoc.get("cdc") or "", field=f"{field}.cdc") + return asoc + + +def validate_documento_payload(payload: dict) -> dict: """Defense-in-depth check before posting. SIFEN is the source of truth; this only catches obvious shape errors that would make the call wasted. + + Branches on `tipoDocumento`: FACTURA_ELECTRONICA requires + `condicionOperacion`; NC/ND require `motivoEmision` + `documentoAsociado`. """ if not isinstance(payload, dict): raise ValidationError("payload", "se esperaba un objeto JSON") @@ -65,7 +104,6 @@ def validate_factura_payload(payload: dict) -> dict: "puntoExpedicion", "monedaOperacion", "receptor", - "condicionOperacion", "items", ) missing = [k for k in required if k not in payload] @@ -74,6 +112,22 @@ def validate_factura_payload(payload: dict) -> dict: validate_iso_no_tz(payload["fechaEmision"], field="fechaEmision") + tipo_doc = payload["tipoDocumento"] + if tipo_doc == FACTURA: + if "condicionOperacion" not in payload: + raise ValidationError("condicionOperacion", "campo requerido para factura") + elif tipo_doc in NOTAS: + motivo = payload.get("motivoEmision") + if motivo is None: + raise ValidationError("motivoEmision", "campo requerido para notas de crédito/débito") + if motivo not in VALID_MOTIVOS: + raise ValidationError("motivoEmision", f"motivo inválido: {motivo!r}") + if "documentoAsociado" not in payload: + raise ValidationError( + "documentoAsociado", "campo requerido para notas de crédito/débito" + ) + validate_documento_asociado(payload["documentoAsociado"]) + items = payload["items"] if not isinstance(items, list) or not items: raise ValidationError("items", "debe haber al menos un ítem") @@ -93,3 +147,7 @@ def validate_factura_payload(payload: dict) -> dict: raise ValidationError(f"receptor.{k}", "campo requerido") return payload + + +# Backwards-compatible alias — the validator now covers FE + NC/ND. +validate_factura_payload = validate_documento_payload diff --git a/tests/conftest.py b/tests/conftest.py index 1e26c3e..2a8b7f4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,3 +28,37 @@ def minimal_valid_payload(): } ], } + + +@pytest.fixture +def minimal_valid_nota_payload(): + """A NC/ND payload: no condicionOperacion, but motivoEmision + documentoAsociado.""" + return { + "tipoDocumento": "NOTA_DE_CREDITO_ELECTRONICA", + "tipoEmision": "NORMAL", + "fechaEmision": "2026-05-09T11:00:00", + "numeroEstablecimiento": "001", + "puntoExpedicion": "001", + "monedaOperacion": "PYG", + "motivoEmision": "DEVOLUCION", + "documentoAsociado": { + "tipoDocumento": "ELECTRONICO", + "cdc": "0" * 44, + }, + "receptor": { + "tipoContribuyente": "NO_CONTRIBUYENTE", + "tipoOperacion": "B2C", + "tipoDocumento": "CEDULA_PARAGUAYA", + "numeroDocumento": "1234567", + "nombreRazonSocial": "Cliente Final", + }, + "items": [ + { + "descripcion": "Servicio de prueba", + "cantidad": 1, + "precioUnitario": 100000, + "tasaIVA": 10, + "afectacionTributaria": "GRAVADO_IVA", + } + ], + } diff --git a/tests/test_interactive.py b/tests/test_interactive.py new file mode 100644 index 0000000..940aa03 --- /dev/null +++ b/tests/test_interactive.py @@ -0,0 +1,86 @@ +import builtins + +import pytest + +from sifende.interactive import ( + build_documento_interactive, + build_factura_interactive, +) + + +def _scripted_input(monkeypatch, answers): + it = iter(answers) + + def fake_input(_prompt=""): + try: + return next(it) + except StopIteration: # pragma: no cover - signals a wrong script + raise AssertionError("interactive wizard asked for more input than scripted") + + monkeypatch.setattr(builtins, "input", fake_input) + + +# Ordered exactly as the wizard prompts (see build_documento_interactive). +_COMMON_HEAD = [ + "1", # establecimiento + "1", # punto de expedición + "2026-05-09T10:00:00", # fecha + "1", # moneda PYG + "1", # operación B2C + "1", # tipo doc receptor (cédula PY) + "1234567", # número documento receptor + "JUAN PEREZ", # nombre receptor + "s", # agregar ítem + "Devolución producto", # descripción + "1", # cantidad + "50000", # precio unitario + "1", # IVA 10% + "n", # no agregar otro ítem +] + + +def test_interactive_builds_nota_credito(monkeypatch): + cdc = "0" * 44 + answers = ["2"] + _COMMON_HEAD + ["2", cdc, "s"] + _scripted_input(monkeypatch, answers) + + payload = build_documento_interactive() + + assert payload["tipoDocumento"] == "NOTA_DE_CREDITO_ELECTRONICA" + assert payload["motivoEmision"] == "DEVOLUCION" + assert payload["documentoAsociado"] == {"tipoDocumento": "ELECTRONICO", "cdc": cdc} + # NC/ND must not carry factura-only fields. + assert "condicionOperacion" not in payload + assert "condicionPago" not in payload + assert len(payload["items"]) == 1 + + +def test_interactive_builds_nota_debito(monkeypatch): + cdc = "1" * 44 + # motivo "8" = AJUSTE_DE_PRECIO + answers = ["3"] + _COMMON_HEAD + ["8", cdc, "s"] + _scripted_input(monkeypatch, answers) + + payload = build_documento_interactive() + + assert payload["tipoDocumento"] == "NOTA_DE_DEBITO_ELECTRONICA" + assert payload["motivoEmision"] == "AJUSTE_DE_PRECIO" + assert payload["documentoAsociado"]["cdc"] == cdc + + +def test_interactive_builds_factura(monkeypatch): + # motivo/CDC prompts are replaced by the pago prompt for a factura. + answers = ["1"] + _COMMON_HEAD + ["1", "s"] # tipo pago EFECTIVO, confirmar + _scripted_input(monkeypatch, answers) + + payload = build_documento_interactive() + + assert payload["tipoDocumento"] == "FACTURA_ELECTRONICA" + assert payload["condicionOperacion"] == "CONTADO" + assert payload["condicionPago"]["tipoPago"] == "EFECTIVO" + assert "motivoEmision" not in payload + assert "documentoAsociado" not in payload + + +def test_build_factura_interactive_is_alias(): + assert build_factura_interactive is build_documento_interactive diff --git a/tests/test_nota_validation.py b/tests/test_nota_validation.py new file mode 100644 index 0000000..b7139c0 --- /dev/null +++ b/tests/test_nota_validation.py @@ -0,0 +1,77 @@ +import copy + +import pytest + +from sifende.errors import ValidationError +from sifende.utils.validation import ( + validate_documento_asociado, + validate_documento_payload, + validate_factura_payload, +) + + +def test_accepts_valid_nota_credito(minimal_valid_nota_payload): + assert validate_documento_payload(minimal_valid_nota_payload) is minimal_valid_nota_payload + + +def test_nota_debito_also_accepted(minimal_valid_nota_payload): + payload = copy.deepcopy(minimal_valid_nota_payload) + payload["tipoDocumento"] = "NOTA_DE_DEBITO_ELECTRONICA" + payload["motivoEmision"] = "AJUSTE_DE_PRECIO" + assert validate_documento_payload(payload) is payload + + +def test_nota_does_not_require_condicion_operacion(minimal_valid_nota_payload): + # A NC/ND must validate even though it has no condicionOperacion. + assert "condicionOperacion" not in minimal_valid_nota_payload + validate_documento_payload(minimal_valid_nota_payload) + + +def test_nota_missing_motivo_raises(minimal_valid_nota_payload): + payload = copy.deepcopy(minimal_valid_nota_payload) + payload.pop("motivoEmision") + with pytest.raises(ValidationError) as exc: + validate_documento_payload(payload) + assert "motivoEmision" in str(exc.value) + + +def test_nota_invalid_motivo_raises(minimal_valid_nota_payload): + payload = copy.deepcopy(minimal_valid_nota_payload) + payload["motivoEmision"] = "NO_EXISTE" + with pytest.raises(ValidationError) as exc: + validate_documento_payload(payload) + assert "motivoEmision" in str(exc.value) + + +def test_nota_missing_documento_asociado_raises(minimal_valid_nota_payload): + payload = copy.deepcopy(minimal_valid_nota_payload) + payload.pop("documentoAsociado") + with pytest.raises(ValidationError) as exc: + validate_documento_payload(payload) + assert "documentoAsociado" in str(exc.value) + + +def test_nota_documento_asociado_bad_cdc_raises(minimal_valid_nota_payload): + payload = copy.deepcopy(minimal_valid_nota_payload) + payload["documentoAsociado"]["cdc"] = "123" + with pytest.raises(ValidationError) as exc: + validate_documento_payload(payload) + assert "cdc" in str(exc.value) + + +def test_documento_asociado_non_electronico_rejected(): + with pytest.raises(ValidationError) as exc: + validate_documento_asociado({"tipoDocumento": "IMPRESO", "cdc": "0" * 44}) + assert "ELECTRONICO" in str(exc.value) + + +def test_factura_still_requires_condicion_operacion(minimal_valid_payload): + payload = copy.deepcopy(minimal_valid_payload) + payload.pop("condicionOperacion") + with pytest.raises(ValidationError) as exc: + validate_documento_payload(payload) + assert "condicionOperacion" in str(exc.value) + + +def test_validate_factura_payload_is_alias(): + assert validate_factura_payload is validate_documento_payload