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.
- sunat_ubl-0.2.0/.gitignore +14 -0
- sunat_ubl-0.2.0/LICENSE +21 -0
- sunat_ubl-0.2.0/PKG-INFO +183 -0
- sunat_ubl-0.2.0/README.md +156 -0
- sunat_ubl-0.2.0/examples/factura.json +50 -0
- sunat_ubl-0.2.0/pyproject.toml +42 -0
- sunat_ubl-0.2.0/src/sunat_ubl/__init__.py +39 -0
- sunat_ubl-0.2.0/src/sunat_ubl/catalogs.py +87 -0
- sunat_ubl-0.2.0/src/sunat_ubl/cdr.py +90 -0
- sunat_ubl-0.2.0/src/sunat_ubl/cli.py +114 -0
- sunat_ubl-0.2.0/src/sunat_ubl/firma.py +115 -0
- sunat_ubl-0.2.0/src/sunat_ubl/generator.py +276 -0
- sunat_ubl-0.2.0/src/sunat_ubl/json_input.py +82 -0
- sunat_ubl-0.2.0/src/sunat_ubl/models.py +250 -0
- sunat_ubl-0.2.0/src/sunat_ubl/py.typed +0 -0
- sunat_ubl-0.2.0/src/sunat_ubl/validation.py +31 -0
- sunat_ubl-0.2.0/tests/test_cdr.py +84 -0
- sunat_ubl-0.2.0/tests/test_cli.py +46 -0
- sunat_ubl-0.2.0/tests/test_comprobante.py +116 -0
- sunat_ubl-0.2.0/tests/test_firma.py +166 -0
- sunat_ubl-0.2.0/tests/test_generator.py +159 -0
- sunat_ubl-0.2.0/tests/test_validation.py +46 -0
sunat_ubl-0.2.0/LICENSE
ADDED
|
@@ -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.
|
sunat_ubl-0.2.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://github.com/Giovanniedu/sunat-ubl/actions)
|
|
35
|
+
[](https://pypi.org/project/sunat-ubl/)
|
|
36
|
+
[](LICENSE)
|
|
37
|
+
[](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
|
+
[](https://github.com/Giovanniedu/sunat-ubl/actions)
|
|
8
|
+
[](https://pypi.org/project/sunat-ubl/)
|
|
9
|
+
[](LICENSE)
|
|
10
|
+
[](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
|
+
)
|