Skip to content
Draft
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Added

- `kude --formato {a4,ticket,ticket-resumen}` y `--ancho {80,58}` para descargar
el KuDE en cinta de papel de impresoras térmicas. Sin `--out`, el ticket se
guarda como `<CDC>-ticket.pdf` o `<CDC>-ticket-resumen.pdf`. `make kude` acepta
`FORMATO` y `ANCHO`.
- 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
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@ nota-debito: ## Emitir nota de débito (FILE_ND=sample_nota_debito.json por defe
estado: guard-CDC ## Consultar estado — make estado CDC=xxxx
$(SIFENDE) estado $(CDC) $(EXTRA)

kude: guard-CDC ## Descargar KuDE PDF — make kude CDC=xxxx [OUT=ruta.pdf]
$(SIFENDE) kude $(CDC) $(if $(OUT),--out $(OUT)) $(EXTRA)
kude: guard-CDC ## Descargar KuDE PDF — make kude CDC=xxxx [FORMATO=ticket ANCHO=58] [OUT=ruta.pdf]
$(SIFENDE) kude $(CDC) $(if $(FORMATO),--formato $(FORMATO)) $(if $(ANCHO),--ancho $(ANCHO)) $(if $(OUT),--out $(OUT)) $(EXTRA)

cancelar: guard-CDC guard-MOTIVO ## Cancelar documento — make cancelar CDC=xxxx MOTIVO="..."
$(SIFENDE) cancelar $(CDC) --motivo "$(MOTIVO)" $(EXTRA)
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Variables soportadas en `.env`:
| Nota de débito | `make nota-debito FILE_ND=nd.json` | `python sifende.py emitir --file nd.json` |
| Consultar estado | `make estado CDC=<cdc>` | `python sifende.py estado <cdc>` |
| Descargar KuDE | `make kude CDC=<cdc> OUT=factura.pdf` | `python sifende.py kude <cdc> --out factura.pdf` |
| KuDE en ticket | `make kude CDC=<cdc> FORMATO=ticket ANCHO=58` | `python sifende.py kude <cdc> --formato ticket --ancho 58` |
| Cancelar | `make cancelar CDC=<cdc> MOTIVO="..."` | `python sifende.py cancelar <cdc> --motivo "..."` |
| Inutilizar | `make inutilizar TIPO=1 EST=001 PE=001 TIMBRADO=<t> DESDE=1 HASTA=3 MOTIVO="..."` | `python sifende.py inutilizar --tipo-documento 1 --establecimiento 001 --punto-expedicion 001 --numero-timbrado <t> --desde 1 --hasta 3 --motivo "..."` |
| Debug HTTP | `make debug FILE=factura.json` | `python sifende.py --debug emitir --file factura.json` |
Expand All @@ -62,6 +63,14 @@ restricciones de SIFEN. Una respuesta HTTP exitosa cuyo evento termine en

Al aprobar un documento, la CLI guarda automáticamente `documentos/{cdc}/` con `payload.json`, `response.json` y `kude.pdf`.

### KuDE en cinta de papel

`kude --formato ticket` descarga el KuDE en cinta de papel para impresoras
térmicas, con `--ancho 80` (default) o `--ancho 58`. `--formato ticket-resumen`
omite el detalle de ítems e impuestos, para cuando el cliente pide el resumen. Sólo
está disponible para facturas y notas de crédito o débito; el KuDE A4 sigue siendo
el que se guarda y se envía por email.

### Notas de crédito y débito

Las notas de crédito (`NOTA_DE_CREDITO_ELECTRONICA`) y débito
Expand Down
8 changes: 8 additions & 0 deletions sifende/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,14 @@ def kude(self, cdc: str) -> bytes:
resp = self._request("GET", f"documento-electronico/{cdc}/kude", accept="application/pdf")
return resp.content

def kude_ticket(self, cdc: str, *, ancho: int = 80, resumen: bool = False) -> bytes:
resp = self._request(
"GET",
f"documento-electronico/{cdc}/kude/ticket?ancho={ancho}&resumen={'true' if resumen else 'false'}",
accept="application/pdf",
)
return resp.content

def cancelar(self, cdc: str, motivo: str) -> dict:
resp = self._request(
"POST",
Expand Down
38 changes: 32 additions & 6 deletions sifende/commands/kude.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,61 @@
"""`kude`: download the KuDE PDF for a given CDC."""
"""`kude`: download the KuDE PDF for a given CDC, in A4 or as a paper-roll ticket."""

from __future__ import annotations

import json
import sys

from ..client import SifendeClient
from ..errors import ValidationError
from ..utils.io import save_pdf
from ..utils.validation import validate_cdc

FORMATOS = ("a4", "ticket", "ticket-resumen")


def register(subparsers, *, parents=()) -> None:
p = subparsers.add_parser(
"kude",
parents=list(parents),
help="Descargar el KuDE (PDF) de un documento.",
description="Descarga el PDF representativo (KuDE) generado por SIFEN.",
description=(
"Descarga el PDF representativo (KuDE). Con --formato ticket se genera en cinta de papel "
"para impresoras térmicas (sólo facturas y notas de crédito o débito)."
),
)
p.add_argument("cdc", help="CDC de 44 dígitos.")
p.add_argument("--out", "-o", help="Ruta destino. Default: <CDC>.pdf en el directorio actual.")
p.add_argument(
"--formato",
choices=FORMATOS,
default="a4",
help="a4 (default), ticket o ticket-resumen (sin detalle de ítems).",
)
p.add_argument(
"--ancho",
type=int,
choices=(80, 58),
help="Ancho del papel del ticket en mm. Default: 80.",
)
p.add_argument("--out", "-o", help="Ruta destino. Default: <CDC>.pdf o <CDC>-<formato>.pdf.")


def run(args, client: SifendeClient) -> int:
cdc = validate_cdc(args.cdc)
out_path = args.out or f"{cdc}.pdf"
pdf = client.kude(cdc)
formato = args.formato
if formato == "a4":
if args.ancho is not None:
raise ValidationError("ancho", "sólo aplica a --formato ticket o ticket-resumen")
out_path = args.out or f"{cdc}.pdf"
pdf = client.kude(cdc)
else:
ancho = args.ancho or 80
out_path = args.out or f"{cdc}-{formato}.pdf"
pdf = client.kude_ticket(cdc, ancho=ancho, resumen=formato == "ticket-resumen")
saved = save_pdf(pdf, out_path)
bytes_written = len(pdf)

if args.json:
print(json.dumps({"cdc": cdc, "path": saved, "bytes": bytes_written}, ensure_ascii=False))
print(json.dumps({"cdc": cdc, "formato": formato, "path": saved, "bytes": bytes_written}, ensure_ascii=False))
elif args.quiet:
print(saved)
else:
Expand Down
65 changes: 65 additions & 0 deletions tests/test_kude.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from types import SimpleNamespace
from unittest.mock import MagicMock

import pytest

from sifende.client import SifendeClient
from sifende.commands.kude import run
from sifende.config import Config
from sifende.errors import ValidationError

CDC = "01800123451001001000000122026042710000000006"
PDF = b"%PDF-1.4 synthetic"


def _make_args(tmp_path, *, formato="a4", ancho=None, out=None):
return SimpleNamespace(
cdc=CDC,
formato=formato,
ancho=ancho,
out=str(tmp_path / out) if out else None,
json=False,
quiet=True,
)


def test_a4_uses_existing_endpoint(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
client = MagicMock()
client.kude.return_value = PDF
assert run(_make_args(tmp_path), client) == 0
client.kude.assert_called_once_with(CDC)
assert not client.kude_ticket.called
assert (tmp_path / f"{CDC}.pdf").read_bytes() == PDF


@pytest.mark.parametrize(
"formato,ancho,resumen,ancho_esperado",
[("ticket", None, False, 80), ("ticket", 58, False, 58), ("ticket-resumen", 80, True, 80)],
)
def test_ticket_formats(tmp_path, monkeypatch, formato, ancho, resumen, ancho_esperado):
monkeypatch.chdir(tmp_path)
client = MagicMock()
client.kude_ticket.return_value = PDF
assert run(_make_args(tmp_path, formato=formato, ancho=ancho), client) == 0
client.kude_ticket.assert_called_once_with(CDC, ancho=ancho_esperado, resumen=resumen)
assert (tmp_path / f"{CDC}-{formato}.pdf").read_bytes() == PDF


def test_ancho_with_a4_is_rejected_locally(tmp_path):
client = MagicMock()
with pytest.raises(ValidationError) as exc:
run(_make_args(tmp_path, ancho=58), client)
assert exc.value.field == "ancho"
assert not client.kude.called


def test_client_builds_ticket_url():
client = SifendeClient(Config(api_key="k", base_url="https://api.example.com/api/v1/", timeout_s=5))
response = MagicMock(status_code=200, content=PDF, ok=True)
client._session.request = MagicMock(return_value=response)
assert client.kude_ticket(CDC, ancho=58, resumen=True) == PDF
method, url = client._session.request.call_args.args
assert method == "GET"
assert url == f"https://api.example.com/api/v1/documento-electronico/{CDC}/kude/ticket?ancho=58&resumen=true"
assert client._session.request.call_args.kwargs["headers"]["Accept"] == "application/pdf"
Loading