sunat-ubl 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .venv/
7
+ venv/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ salida/
12
+ .coverage
13
+ htmlcov/
14
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fabricio Miranda Vila
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,183 @@
1
+ Metadata-Version: 2.5
2
+ Name: sunat-ubl
3
+ Version: 0.2.0
4
+ Summary: Genera y firma XML UBL 2.1 de facturas y boletas electrónicas para SUNAT (Perú), y lee CDR. Núcleo sin dependencias.
5
+ Author: Fabricio Miranda Vila
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Keywords: boleta electronica,cdr,cpe,factura electronica,firma digital,peru,sunat,ubl,xmldsig
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.10
18
+ Provides-Extra: dev
19
+ Requires-Dist: cryptography>=42; extra == 'dev'
20
+ Requires-Dist: lxml>=5; extra == 'dev'
21
+ Requires-Dist: pytest>=8; extra == 'dev'
22
+ Requires-Dist: signxml>=4; extra == 'dev'
23
+ Provides-Extra: firma
24
+ Requires-Dist: lxml>=5; extra == 'firma'
25
+ Requires-Dist: signxml>=4; extra == 'firma'
26
+ Description-Content-Type: text/markdown
27
+
28
+ <div align="center">
29
+ <img src="assets/social-preview.png" alt="sunat-ubl: facturas y boletas electrónicas de SUNAT en Python, sin dependencias" width="860">
30
+ </div>
31
+
32
+ # sunat-ubl
33
+
34
+ [![CI](https://github.com/Giovanniedu/sunat-ubl/actions/workflows/ci.yml/badge.svg)](https://github.com/Giovanniedu/sunat-ubl/actions)
35
+ [![Python](https://img.shields.io/badge/Python-3.10%2B-blue.svg)](https://pypi.org/project/sunat-ubl/)
36
+ [![Licencia](https://img.shields.io/badge/licencia-MIT-green.svg)](LICENSE)
37
+ [![Dependencias](https://img.shields.io/badge/dependencias-0-green.svg)](pyproject.toml)
38
+
39
+ Genera el **XML UBL 2.1** de facturas y boletas electrónicas del Perú, lo **firma digitalmente**
40
+ y lee el **CDR** de SUNAT. El núcleo no tiene dependencias: solo la librería estándar de Python.
41
+
42
+ <img src="assets/flujo.png" alt="Del dato al comprobante aceptado: JSON → sunat_ubl → XML UBL 2.1 firmado → SUNAT SEE-SOL → CDR" width="860">
43
+
44
+ ## Por qué existe
45
+
46
+ Integrar con el SEE-SOL de SUNAT obliga a armar a mano un XML UBL 2.1 con namespaces, catálogos
47
+ de afectación del IGV y reglas de redondeo que, si se equivocan por un céntimo, terminan en un
48
+ rechazo con un código y sin explicación útil. Esta librería resuelve esa capa:
49
+
50
+ - **Cuadratura exacta.** Si partes de un precio con IGV (100.00), la base queda en 84.75 y el IGV
51
+ en 15.25, no en 15.26. Base + IGV siempre suma lo que paga el cliente.
52
+ - **Firma digital XMLDSig** (RSA-SHA256, SHA-256, exc-c14n) insertada en `ext:UBLExtensions`,
53
+ que es donde la exige SUNAT.
54
+ - **Catálogos oficiales**: afectación del IGV (10, 20, 30, 40), tributos (1000, 9995, 9997, 9998),
55
+ tipos de documento de identidad y unidades de medida.
56
+ - **Nombre de archivo correcto**: `RUC-TIPO-SERIE-NUMERO.xml`.
57
+ - **Validación temprana** de RUC (módulo 11), DNI, series y montos, con mensajes en español.
58
+ - **CDR**: lee el ZIP o el XML que devuelve SUNAT y expone código, descripción y observaciones.
59
+
60
+ ## Instalación
61
+
62
+ ```bash
63
+ pip install sunat-ubl # núcleo, sin dependencias
64
+ pip install "sunat-ubl[firma]" # + firma digital (signxml, lxml)
65
+ ```
66
+
67
+ ## Uso rápido
68
+
69
+ ```python
70
+ from datetime import date
71
+ from sunat_ubl import Cliente, Comprobante, Emisor, Linea, guardar
72
+
73
+ factura = Comprobante(
74
+ tipo="01", serie="F001", numero=1, fecha=date(2026, 10, 30),
75
+ emisor=Emisor(ruc="20999999990", razon_social="SERVICIOS DEMO PERU SAC"),
76
+ cliente=Cliente(tipo_doc="6", numero="20512345671", razon_social="CLIENTE SAC"),
77
+ lineas=[
78
+ Linea("Consultoría", cantidad=2, valor_unitario="100.00", unidad="ZZ"),
79
+ Linea.desde_precio_con_igv("Soporte", cantidad=1, precio_con_igv=118),
80
+ Linea("Servicio exonerado", cantidad=1, valor_unitario="50.00", tipo_afectacion="20"),
81
+ ],
82
+ )
83
+ guardar(factura, "salida") # salida/20999999990-01-F001-1.xml
84
+ ```
85
+
86
+ ## Firma digital
87
+
88
+ El XML sale sin firmar por defecto. Para firmarlo necesitas el certificado digital de tu empresa
89
+ (PEM) y su clave privada:
90
+
91
+ ```python
92
+ from sunat_ubl import firmar, firmar_xml, verificar_firma
93
+
94
+ xml_firmado = firmar(factura, "certificado.pem", "clave.pem")
95
+ # o firma un XML ya generado:
96
+ xml_firmado = firmar_xml(open("20999999990-01-F001-1.xml", "rb").read(), "certificado.pem", "clave.pem")
97
+
98
+ verificar_firma(xml_firmado, "certificado.pem") # True si nadie lo alteró
99
+ ```
100
+
101
+ - La firma se inserta en `ext:UBLExtensions/ext:ExtensionContent` (posición que exige SUNAT).
102
+ - Algoritmos: `rsa-sha256`, digest `sha256`, canonicalización exclusiva C14N.
103
+ - El documento firmado no se reindenta: cualquier cambio de espacios invalidaría la firma.
104
+
105
+ ## CLI
106
+
107
+ ```bash
108
+ sunat-ubl generar examples/factura.json -o salida
109
+ sunat-ubl generar examples/factura.json -o salida --cert cert.pem --clave key.pem
110
+ sunat-ubl firmar factura.xml --cert cert.pem --clave key.pem [-o salida.xml]
111
+ sunat-ubl cdr R-20999999990-01-F001-1.zip
112
+ sunat-ubl ruc 20100070970
113
+ ```
114
+
115
+ ```
116
+ $ sunat-ubl cdr R-20999999990-01-F001-1.zip
117
+ ACEPTADO CON OBSERVACIONES (0): El comprobante fue registrado exitosamente
118
+ comprobante: F001-1
119
+ observación: 2335 - El comprobante fue aceptado
120
+ ```
121
+
122
+ ## Formato JSON
123
+
124
+ `examples/factura.json` es un ejemplo completo. Las líneas aceptan `valor_unitario` (sin IGV) o
125
+ `precio_con_igv` (lo que paga el cliente):
126
+
127
+ ```json
128
+ {
129
+ "tipo": "01", "serie": "F001", "numero": 1,
130
+ "fecha": "2026-10-30", "hora": "15:42:10", "moneda": "PEN",
131
+ "emisor": { "ruc": "20999999990", "razon_social": "SERVICIOS DEMO PERU SAC" },
132
+ "cliente": { "tipo_doc": "6", "numero": "20512345671", "razon_social": "CLIENTE SAC" },
133
+ "lineas": [
134
+ { "descripcion": "Consultoría", "cantidad": 2, "valor_unitario": 100.0, "unidad": "ZZ" },
135
+ { "descripcion": "Soporte", "cantidad": 1, "precio_con_igv": 118.0, "unidad": "ZZ" }
136
+ ]
137
+ }
138
+ ```
139
+
140
+ ## Qué hace y qué no
141
+
142
+ | Sí | Todavía no |
143
+ | --- | --- |
144
+ | XML UBL 2.1 de factura (01) y boleta (03) | Notas de crédito (07) y débito (08) |
145
+ | IGV, exonerado, inafecto y exportación | ISC e ICBPER |
146
+ | Firma digital XMLDSig | Envío al SEE-SOL (SUNAT solo acepta SOAP con certificado) |
147
+ | Lectura de CDR | Validación contra el XSD oficial de SUNAT |
148
+ | Totales, catálogos y validaciones | |
149
+
150
+ > **Honestidad primero:** la estructura del XML sigue el estándar UBL 2.1 y la firma verifica
151
+ > criptográficamente, pero este proyecto todavía **no ha sido validado contra el XSD oficial de
152
+ > SUNAT ni probado en el SEE beta**. Si vas a emitir comprobantes reales, pruébalo primero en el
153
+ > entorno beta del contribuyente con tu certificado.
154
+
155
+ ## API
156
+
157
+ ```python
158
+ from sunat_ubl import a_xml, construir, guardar, leer_cdr, validar_ruc
159
+
160
+ xml_bytes = a_xml(factura) # bytes UTF-8 sin firmar
161
+ raiz = construir(factura) # xml.etree.ElementTree.Element
162
+ cdr = leer_cdr("R-...-CDR.zip")
163
+ cdr.aceptado, cdr.codigo, cdr.notas, cdr.referencia
164
+ ```
165
+
166
+ ## English summary
167
+
168
+ `sunat-ubl` generates the UBL 2.1 XML required by SUNAT (Peru's tax authority) for electronic
169
+ invoices and sales receipts, signs it with XMLDSig, and reads the CDR response. The core has
170
+ **zero dependencies**; signing is an optional extra. It handles IGV rounding edge cases (the
171
+ "1 céntimo" mismatch), validates RUC/DNI/series, and produces the exact filename SUNAT expects.
172
+
173
+ ## Desarrollo
174
+
175
+ ```bash
176
+ python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
177
+ pip install -e ".[dev]"
178
+ pytest # 62 tests
179
+ ```
180
+
181
+ ## Licencia
182
+
183
+ MIT. Ver [LICENSE](LICENSE).
@@ -0,0 +1,156 @@
1
+ <div align="center">
2
+ <img src="assets/social-preview.png" alt="sunat-ubl: facturas y boletas electrónicas de SUNAT en Python, sin dependencias" width="860">
3
+ </div>
4
+
5
+ # sunat-ubl
6
+
7
+ [![CI](https://github.com/Giovanniedu/sunat-ubl/actions/workflows/ci.yml/badge.svg)](https://github.com/Giovanniedu/sunat-ubl/actions)
8
+ [![Python](https://img.shields.io/badge/Python-3.10%2B-blue.svg)](https://pypi.org/project/sunat-ubl/)
9
+ [![Licencia](https://img.shields.io/badge/licencia-MIT-green.svg)](LICENSE)
10
+ [![Dependencias](https://img.shields.io/badge/dependencias-0-green.svg)](pyproject.toml)
11
+
12
+ Genera el **XML UBL 2.1** de facturas y boletas electrónicas del Perú, lo **firma digitalmente**
13
+ y lee el **CDR** de SUNAT. El núcleo no tiene dependencias: solo la librería estándar de Python.
14
+
15
+ <img src="assets/flujo.png" alt="Del dato al comprobante aceptado: JSON → sunat_ubl → XML UBL 2.1 firmado → SUNAT SEE-SOL → CDR" width="860">
16
+
17
+ ## Por qué existe
18
+
19
+ Integrar con el SEE-SOL de SUNAT obliga a armar a mano un XML UBL 2.1 con namespaces, catálogos
20
+ de afectación del IGV y reglas de redondeo que, si se equivocan por un céntimo, terminan en un
21
+ rechazo con un código y sin explicación útil. Esta librería resuelve esa capa:
22
+
23
+ - **Cuadratura exacta.** Si partes de un precio con IGV (100.00), la base queda en 84.75 y el IGV
24
+ en 15.25, no en 15.26. Base + IGV siempre suma lo que paga el cliente.
25
+ - **Firma digital XMLDSig** (RSA-SHA256, SHA-256, exc-c14n) insertada en `ext:UBLExtensions`,
26
+ que es donde la exige SUNAT.
27
+ - **Catálogos oficiales**: afectación del IGV (10, 20, 30, 40), tributos (1000, 9995, 9997, 9998),
28
+ tipos de documento de identidad y unidades de medida.
29
+ - **Nombre de archivo correcto**: `RUC-TIPO-SERIE-NUMERO.xml`.
30
+ - **Validación temprana** de RUC (módulo 11), DNI, series y montos, con mensajes en español.
31
+ - **CDR**: lee el ZIP o el XML que devuelve SUNAT y expone código, descripción y observaciones.
32
+
33
+ ## Instalación
34
+
35
+ ```bash
36
+ pip install sunat-ubl # núcleo, sin dependencias
37
+ pip install "sunat-ubl[firma]" # + firma digital (signxml, lxml)
38
+ ```
39
+
40
+ ## Uso rápido
41
+
42
+ ```python
43
+ from datetime import date
44
+ from sunat_ubl import Cliente, Comprobante, Emisor, Linea, guardar
45
+
46
+ factura = Comprobante(
47
+ tipo="01", serie="F001", numero=1, fecha=date(2026, 10, 30),
48
+ emisor=Emisor(ruc="20999999990", razon_social="SERVICIOS DEMO PERU SAC"),
49
+ cliente=Cliente(tipo_doc="6", numero="20512345671", razon_social="CLIENTE SAC"),
50
+ lineas=[
51
+ Linea("Consultoría", cantidad=2, valor_unitario="100.00", unidad="ZZ"),
52
+ Linea.desde_precio_con_igv("Soporte", cantidad=1, precio_con_igv=118),
53
+ Linea("Servicio exonerado", cantidad=1, valor_unitario="50.00", tipo_afectacion="20"),
54
+ ],
55
+ )
56
+ guardar(factura, "salida") # salida/20999999990-01-F001-1.xml
57
+ ```
58
+
59
+ ## Firma digital
60
+
61
+ El XML sale sin firmar por defecto. Para firmarlo necesitas el certificado digital de tu empresa
62
+ (PEM) y su clave privada:
63
+
64
+ ```python
65
+ from sunat_ubl import firmar, firmar_xml, verificar_firma
66
+
67
+ xml_firmado = firmar(factura, "certificado.pem", "clave.pem")
68
+ # o firma un XML ya generado:
69
+ xml_firmado = firmar_xml(open("20999999990-01-F001-1.xml", "rb").read(), "certificado.pem", "clave.pem")
70
+
71
+ verificar_firma(xml_firmado, "certificado.pem") # True si nadie lo alteró
72
+ ```
73
+
74
+ - La firma se inserta en `ext:UBLExtensions/ext:ExtensionContent` (posición que exige SUNAT).
75
+ - Algoritmos: `rsa-sha256`, digest `sha256`, canonicalización exclusiva C14N.
76
+ - El documento firmado no se reindenta: cualquier cambio de espacios invalidaría la firma.
77
+
78
+ ## CLI
79
+
80
+ ```bash
81
+ sunat-ubl generar examples/factura.json -o salida
82
+ sunat-ubl generar examples/factura.json -o salida --cert cert.pem --clave key.pem
83
+ sunat-ubl firmar factura.xml --cert cert.pem --clave key.pem [-o salida.xml]
84
+ sunat-ubl cdr R-20999999990-01-F001-1.zip
85
+ sunat-ubl ruc 20100070970
86
+ ```
87
+
88
+ ```
89
+ $ sunat-ubl cdr R-20999999990-01-F001-1.zip
90
+ ACEPTADO CON OBSERVACIONES (0): El comprobante fue registrado exitosamente
91
+ comprobante: F001-1
92
+ observación: 2335 - El comprobante fue aceptado
93
+ ```
94
+
95
+ ## Formato JSON
96
+
97
+ `examples/factura.json` es un ejemplo completo. Las líneas aceptan `valor_unitario` (sin IGV) o
98
+ `precio_con_igv` (lo que paga el cliente):
99
+
100
+ ```json
101
+ {
102
+ "tipo": "01", "serie": "F001", "numero": 1,
103
+ "fecha": "2026-10-30", "hora": "15:42:10", "moneda": "PEN",
104
+ "emisor": { "ruc": "20999999990", "razon_social": "SERVICIOS DEMO PERU SAC" },
105
+ "cliente": { "tipo_doc": "6", "numero": "20512345671", "razon_social": "CLIENTE SAC" },
106
+ "lineas": [
107
+ { "descripcion": "Consultoría", "cantidad": 2, "valor_unitario": 100.0, "unidad": "ZZ" },
108
+ { "descripcion": "Soporte", "cantidad": 1, "precio_con_igv": 118.0, "unidad": "ZZ" }
109
+ ]
110
+ }
111
+ ```
112
+
113
+ ## Qué hace y qué no
114
+
115
+ | Sí | Todavía no |
116
+ | --- | --- |
117
+ | XML UBL 2.1 de factura (01) y boleta (03) | Notas de crédito (07) y débito (08) |
118
+ | IGV, exonerado, inafecto y exportación | ISC e ICBPER |
119
+ | Firma digital XMLDSig | Envío al SEE-SOL (SUNAT solo acepta SOAP con certificado) |
120
+ | Lectura de CDR | Validación contra el XSD oficial de SUNAT |
121
+ | Totales, catálogos y validaciones | |
122
+
123
+ > **Honestidad primero:** la estructura del XML sigue el estándar UBL 2.1 y la firma verifica
124
+ > criptográficamente, pero este proyecto todavía **no ha sido validado contra el XSD oficial de
125
+ > SUNAT ni probado en el SEE beta**. Si vas a emitir comprobantes reales, pruébalo primero en el
126
+ > entorno beta del contribuyente con tu certificado.
127
+
128
+ ## API
129
+
130
+ ```python
131
+ from sunat_ubl import a_xml, construir, guardar, leer_cdr, validar_ruc
132
+
133
+ xml_bytes = a_xml(factura) # bytes UTF-8 sin firmar
134
+ raiz = construir(factura) # xml.etree.ElementTree.Element
135
+ cdr = leer_cdr("R-...-CDR.zip")
136
+ cdr.aceptado, cdr.codigo, cdr.notas, cdr.referencia
137
+ ```
138
+
139
+ ## English summary
140
+
141
+ `sunat-ubl` generates the UBL 2.1 XML required by SUNAT (Peru's tax authority) for electronic
142
+ invoices and sales receipts, signs it with XMLDSig, and reads the CDR response. The core has
143
+ **zero dependencies**; signing is an optional extra. It handles IGV rounding edge cases (the
144
+ "1 céntimo" mismatch), validates RUC/DNI/series, and produces the exact filename SUNAT expects.
145
+
146
+ ## Desarrollo
147
+
148
+ ```bash
149
+ python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
150
+ pip install -e ".[dev]"
151
+ pytest # 62 tests
152
+ ```
153
+
154
+ ## Licencia
155
+
156
+ MIT. Ver [LICENSE](LICENSE).
@@ -0,0 +1,50 @@
1
+ {
2
+ "tipo": "01",
3
+ "serie": "F001",
4
+ "numero": 1,
5
+ "fecha": "2026-10-30",
6
+ "hora": "15:42:10",
7
+ "moneda": "PEN",
8
+ "orden_compra": "OC-2026-0042",
9
+ "observacion": "Gracias por su compra",
10
+ "emisor": {
11
+ "ruc": "20999999990",
12
+ "razon_social": "SERVICIOS DEMO PERU SAC",
13
+ "nombre_comercial": "Demo Perú",
14
+ "direccion": "AV. SIEMPRE VIVA 123",
15
+ "ubigeo": "150101"
16
+ },
17
+ "cliente": {
18
+ "tipo_doc": "6",
19
+ "numero": "20512345671",
20
+ "razon_social": "CLIENTE DE PRUEBA SAC",
21
+ "direccion": "JR. LAMBAYEQUE 456",
22
+ "email": "compras@cliente.pe"
23
+ },
24
+ "lineas": [
25
+ {
26
+ "codigo": "SRV-001",
27
+ "descripcion": "SERVICIO DE CONSULTORIA EN CRECIMIENTO (10 HORAS)",
28
+ "cantidad": 2,
29
+ "valor_unitario": 100.0,
30
+ "tipo_afectacion": "10",
31
+ "unidad": "ZZ"
32
+ },
33
+ {
34
+ "codigo": "SRV-002",
35
+ "descripcion": "SOPORTE TECNICO MENSUAL",
36
+ "cantidad": 1,
37
+ "precio_con_igv": 118.0,
38
+ "tipo_afectacion": "10",
39
+ "unidad": "ZZ"
40
+ },
41
+ {
42
+ "codigo": "ADM-001",
43
+ "descripcion": "SERVICIO EXONERADO DEL IGV (ART. 2 LEY 27037)",
44
+ "cantidad": 1,
45
+ "valor_unitario": 50.0,
46
+ "tipo_afectacion": "20",
47
+ "unidad": "ZZ"
48
+ }
49
+ ]
50
+ }
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.21"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sunat-ubl"
7
+ version = "0.2.0"
8
+ description = "Genera y firma XML UBL 2.1 de facturas y boletas electrónicas para SUNAT (Perú), y lee CDR. Núcleo sin dependencias."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Fabricio Miranda Vila" }]
13
+ keywords = ["sunat", "factura electronica", "boleta electronica", "ubl", "peru", "cpe", "cdr", "firma digital", "xmldsig"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Topic :: Office/Business :: Financial :: Accounting",
22
+ "Typing :: Typed",
23
+ ]
24
+ dependencies = []
25
+
26
+ [project.optional-dependencies]
27
+ firma = ["signxml>=4", "lxml>=5"]
28
+ dev = ["pytest>=8", "signxml>=4", "lxml>=5", "cryptography>=42"]
29
+
30
+ [project.scripts]
31
+ sunat-ubl = "sunat_ubl.cli:main"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/sunat_ubl"]
35
+
36
+ [tool.hatch.build.targets.sdist]
37
+ include = ["src", "tests", "examples", "README.md", "LICENSE"]
38
+
39
+ [tool.pytest.ini_options]
40
+ pythonpath = ["src"]
41
+ testpaths = ["tests"]
42
+ addopts = "-q"
@@ -0,0 +1,39 @@
1
+ """sunat-ubl: facturas y boletas electrónicas del Perú sin dependencias."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .catalogs import TASA_IGV, TIPOS_COMPROBANTE, TIPOS_DOC_IDENTIDAD, afectacion
6
+ from .cdr import Cdr, leer_cdr
7
+ from .firma import firmar, firmar_xml, verificar_firma
8
+ from .generator import a_xml, construir, guardar
9
+ from .json_input import comprobante_desde_archivo, comprobante_desde_dict
10
+ from .models import Cliente, Comprobante, Emisor, Linea, dinero
11
+ from .validation import validar_dni, validar_ruc, validar_serie
12
+
13
+ __version__ = "0.2.0"
14
+
15
+ __all__ = [
16
+ "TASA_IGV",
17
+ "TIPOS_COMPROBANTE",
18
+ "TIPOS_DOC_IDENTIDAD",
19
+ "Cdr",
20
+ "Cliente",
21
+ "Comprobante",
22
+ "Emisor",
23
+ "Linea",
24
+ "a_xml",
25
+ "afectacion",
26
+ "comprobante_desde_archivo",
27
+ "comprobante_desde_dict",
28
+ "construir",
29
+ "firmar",
30
+ "firmar_xml",
31
+ "verificar_firma",
32
+ "dinero",
33
+ "guardar",
34
+ "leer_cdr",
35
+ "validar_dni",
36
+ "validar_ruc",
37
+ "validar_serie",
38
+ "__version__",
39
+ ]
@@ -0,0 +1,87 @@
1
+ """Catálogos oficiales de SUNAT usados en comprobantes electrónicos."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from decimal import Decimal
7
+
8
+ TASA_IGV = Decimal("0.18")
9
+
10
+ TIPOS_COMPROBANTE = {
11
+ "01": "Factura electrónica",
12
+ "03": "Boleta de venta electrónica",
13
+ }
14
+
15
+ PREFIJO_SERIE = {
16
+ "01": "F",
17
+ "03": "B",
18
+ }
19
+
20
+ TIPOS_DOC_IDENTIDAD = {
21
+ "0": "Doc. tributario no domiciliado sin RUC",
22
+ "1": "DNI",
23
+ "4": "Carné de extranjería",
24
+ "6": "RUC",
25
+ "7": "Pasaporte",
26
+ "A": "Cédula diplomática",
27
+ }
28
+
29
+ # Catálogo 05: tributos
30
+ TRIBUTOS = {
31
+ "1000": ("IGV", "VAT"),
32
+ "9995": ("EXP", "FRE"),
33
+ "9997": ("EXO", "VAT"),
34
+ "9998": ("INAF", "FRE"),
35
+ }
36
+
37
+ # Catálogo 07: tipo de afectación del IGV
38
+ AFECTACIONES = {
39
+ "10": ("Gravado - operación onerosa", "S", "1000"),
40
+ "20": ("Exonerado - operación onerosa", "E", "9997"),
41
+ "30": ("Inafecto - operación onerosa", "O", "9998"),
42
+ "40": ("Exportación de bienes", "G", "9995"),
43
+ }
44
+
45
+ UNIDADES = {
46
+ "NIU": "Unidad",
47
+ "ZZ": "Servicio",
48
+ "KGM": "Kilogramo",
49
+ "LTR": "Litro",
50
+ "MTR": "Metro",
51
+ "BX": "Caja",
52
+ "PK": "Paquete",
53
+ }
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Afectacion:
58
+ codigo: str
59
+ descripcion: str
60
+ categoria: str
61
+ tributo: str
62
+ tasa: Decimal
63
+
64
+ @property
65
+ def gravada(self) -> bool:
66
+ return self.codigo == "10"
67
+
68
+ @property
69
+ def exenta(self) -> bool:
70
+ return not self.gravada
71
+
72
+
73
+ def afectacion(codigo: str) -> Afectacion:
74
+ try:
75
+ descripcion, categoria, tributo = AFECTACIONES[codigo]
76
+ except KeyError:
77
+ raise ValueError(
78
+ f"Tipo de afectación '{codigo}' no soportado. "
79
+ f"Usa uno de: {', '.join(sorted(AFECTACIONES))}"
80
+ ) from None
81
+ return Afectacion(
82
+ codigo=codigo,
83
+ descripcion=descripcion,
84
+ categoria=categoria,
85
+ tributo=tributo,
86
+ tasa=TASA_IGV if codigo == "10" else Decimal("0"),
87
+ )
@@ -0,0 +1,90 @@
1
+ """Lectura del CDR (Constancia de Recepción) que devuelve SUNAT."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import zipfile
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from xml.etree import ElementTree as ET
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Cdr:
14
+ codigo: str
15
+ descripcion: str
16
+ notas: tuple[str, ...] = ()
17
+ referencia: str | None = None
18
+ fecha: str | None = None
19
+ hash: str | None = None
20
+ identificador: str | None = None
21
+
22
+ @property
23
+ def aceptado(self) -> bool:
24
+ return self.codigo == "0"
25
+
26
+ @property
27
+ def observado(self) -> bool:
28
+ """Aceptado, pero con observaciones que conviene revisar."""
29
+ return self.aceptado and bool(self.notas)
30
+
31
+ @property
32
+ def rechazado(self) -> bool:
33
+ return not self.aceptado
34
+
35
+
36
+ def _local(etiqueta: str) -> str:
37
+ return etiqueta.rsplit("}", 1)[-1]
38
+
39
+
40
+ def _texto(raiz: ET.Element, nombre: str) -> str | None:
41
+ for elemento in raiz.iter():
42
+ if _local(elemento.tag) == nombre and elemento.text:
43
+ return elemento.text.strip()
44
+ return None
45
+
46
+
47
+ def _hijo_directo(raiz: ET.Element, nombre: str) -> str | None:
48
+ for elemento in list(raiz):
49
+ if _local(elemento.tag) == nombre and elemento.text:
50
+ return elemento.text.strip()
51
+ return None
52
+
53
+
54
+ def _textos(raiz: ET.Element, nombre: str) -> tuple[str, ...]:
55
+ return tuple(
56
+ elemento.text.strip()
57
+ for elemento in raiz.iter()
58
+ if _local(elemento.tag) == nombre and elemento.text and elemento.text.strip()
59
+ )
60
+
61
+
62
+ def _extraer_xml(datos: bytes) -> bytes:
63
+ if datos[:2] != b"PK":
64
+ return datos
65
+ with zipfile.ZipFile(io.BytesIO(datos)) as archivo:
66
+ xmls = [n for n in archivo.namelist() if n.lower().endswith(".xml")]
67
+ if not xmls:
68
+ raise ValueError("El ZIP no contiene ningún archivo XML")
69
+ return archivo.read(xmls[0])
70
+
71
+
72
+ def leer_cdr(origen: bytes | str | Path) -> Cdr:
73
+ """Parsea el CDR desde bytes, un ZIP o un XML suelto."""
74
+ if isinstance(origen, (str, Path)):
75
+ datos = Path(origen).read_bytes()
76
+ else:
77
+ datos = origen
78
+ raiz = ET.fromstring(_extraer_xml(datos))
79
+ codigo = _texto(raiz, "ResponseCode")
80
+ if codigo is None:
81
+ raise ValueError("El XML no parece un CDR: falta cbc:ResponseCode")
82
+ return Cdr(
83
+ codigo=codigo,
84
+ descripcion=_texto(raiz, "Description") or "",
85
+ notas=_textos(raiz, "Note"),
86
+ referencia=_texto(raiz, "ReferenceID"),
87
+ fecha=_hijo_directo(raiz, "IssueDate"),
88
+ hash=_texto(raiz, "DigestValue"),
89
+ identificador=_hijo_directo(raiz, "ID"),
90
+ )