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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 11 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?=
Expand All @@ -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:"
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<cdc>` | `python sifende.py estado <cdc>` |
| Descargar KuDE | `make kude CDC=<cdc> OUT=factura.pdf` | `python sifende.py kude <cdc> --out factura.pdf` |
| Cancelar | `make cancelar CDC=<cdc> MOTIVO="..."` | `python sifende.py cancelar <cdc> --motivo "..."` |
Expand All @@ -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

Expand All @@ -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
Expand Down
31 changes: 31 additions & 0 deletions sample_nota_credito.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
31 changes: 31 additions & 0 deletions sample_nota_debito.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
8 changes: 4 additions & 4 deletions sifende/commands/emitir.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down
90 changes: 66 additions & 24 deletions sifende/interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,25 @@
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 = {
"1": "FACTURA_ELECTRONICA",
"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 = {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Loading
Loading