openubl 0.1.0__py3-none-any.whl
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.
- openubl/__init__.py +3 -0
- openubl/api/__init__.py +1 -0
- openubl/api/router.py +186 -0
- openubl/enricher.py +68 -0
- openubl/main.py +8 -0
- openubl/models/__init__.py +61 -0
- openubl/models/catalog.py +93 -0
- openubl/models/common.py +46 -0
- openubl/models/credit_note.py +35 -0
- openubl/models/debit_note.py +35 -0
- openubl/models/defaults.py +25 -0
- openubl/models/invoice.py +53 -0
- openubl/models/perception.py +48 -0
- openubl/models/retention.py +29 -0
- openubl/models/summary.py +66 -0
- openubl/models/voided.py +36 -0
- openubl/packager.py +62 -0
- openubl/renderer.py +52 -0
- openubl/signer.py +78 -0
- openubl/templates/credit_note.xml.j2 +120 -0
- openubl/templates/debit_note.xml.j2 +118 -0
- openubl/templates/invoice.xml.j2 +110 -0
- openubl/templates/perception.xml.j2 +68 -0
- openubl/templates/retention.xml.j2 +68 -0
- openubl/templates/summary_documents.xml.j2 +82 -0
- openubl/templates/voided_documents.xml.j2 +53 -0
- openubl/validator.py +218 -0
- openubl/version.py +19 -0
- openubl-0.1.0.dist-info/METADATA +184 -0
- openubl-0.1.0.dist-info/RECORD +37 -0
- openubl-0.1.0.dist-info/WHEEL +4 -0
- openubl-0.1.0.dist-info/entry_points.txt +3 -0
- scripts/__init__.py +0 -0
- scripts/bump_version.py +117 -0
- scripts/check_sdk_sync.py +114 -0
- scripts/create_release.py +91 -0
- scripts/export_openapi.py +25 -0
openubl/__init__.py
ADDED
openubl/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""openUBL API package."""
|
openubl/api/router.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI router for openUBL REST API.
|
|
3
|
+
"""
|
|
4
|
+
from fastapi import APIRouter, Query, HTTPException
|
|
5
|
+
from openubl import __version__
|
|
6
|
+
|
|
7
|
+
from ..models import (
|
|
8
|
+
Invoice, CreditNote, DebitNote, VoidedDocuments,
|
|
9
|
+
SummaryDocuments, Perception, Retention,
|
|
10
|
+
)
|
|
11
|
+
from ..enricher import ContentEnricher
|
|
12
|
+
from ..renderer import (
|
|
13
|
+
render_invoice, render_credit_note, render_debit_note,
|
|
14
|
+
render_voided_documents, render_summary_documents,
|
|
15
|
+
render_perception, render_retention,
|
|
16
|
+
)
|
|
17
|
+
from ..signer import sign_ubl_xml
|
|
18
|
+
from ..validator import SunatValidator
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
router = APIRouter()
|
|
22
|
+
validator = SunatValidator()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@router.get("/version")
|
|
26
|
+
def get_version():
|
|
27
|
+
"""Return the current API version.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
200: `{"version": "..."}` with the current API version.
|
|
31
|
+
"""
|
|
32
|
+
return {"version": __version__}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _validate_xml(xml_string: str, doc_type: str) -> list[str]:
|
|
36
|
+
"""Run SUNAT validation on rendered XML."""
|
|
37
|
+
if doc_type == "invoice":
|
|
38
|
+
return validator.validate_invoice(xml_string)
|
|
39
|
+
elif doc_type == "credit_note":
|
|
40
|
+
return validator.validate_credit_note(xml_string)
|
|
41
|
+
elif doc_type == "voided":
|
|
42
|
+
return validator.validate_voided_documents(xml_string)
|
|
43
|
+
return []
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@router.post("/invoice/create")
|
|
47
|
+
def create_invoice(doc: Invoice, validate: bool = Query(default=True)):
|
|
48
|
+
"""Generate an Invoice XML document.
|
|
49
|
+
|
|
50
|
+
The `validate` query parameter controls SUNAT validation and defaults to `true`.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
200: `{"xml": "..."}` with the generated XML.
|
|
54
|
+
422: Validation failed or the request body is invalid.
|
|
55
|
+
"""
|
|
56
|
+
enricher = ContentEnricher()
|
|
57
|
+
enricher.enrich(doc)
|
|
58
|
+
xml = render_invoice(doc)
|
|
59
|
+
if validate:
|
|
60
|
+
errors = _validate_xml(xml, "invoice")
|
|
61
|
+
if errors:
|
|
62
|
+
raise HTTPException(status_code=422, detail=errors)
|
|
63
|
+
return {"xml": xml}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@router.post("/credit-note/create")
|
|
67
|
+
def create_credit_note(doc: CreditNote, validate: bool = Query(default=True)):
|
|
68
|
+
"""Generate a CreditNote XML document.
|
|
69
|
+
|
|
70
|
+
The `validate` query parameter controls SUNAT validation and defaults to `true`.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
200: `{"xml": "..."}` with the generated XML.
|
|
74
|
+
422: Validation failed or the request body is invalid.
|
|
75
|
+
"""
|
|
76
|
+
enricher = ContentEnricher()
|
|
77
|
+
enricher.enrich(doc)
|
|
78
|
+
xml = render_credit_note(doc)
|
|
79
|
+
if validate:
|
|
80
|
+
errors = _validate_xml(xml, "credit_note")
|
|
81
|
+
if errors:
|
|
82
|
+
raise HTTPException(status_code=422, detail=errors)
|
|
83
|
+
return {"xml": xml}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@router.post("/debit-note/create")
|
|
87
|
+
def create_debit_note(doc: DebitNote, validate: bool = Query(default=True)):
|
|
88
|
+
"""Generate a DebitNote XML document.
|
|
89
|
+
|
|
90
|
+
The `validate` query parameter controls SUNAT validation and defaults to `true`.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
200: `{"xml": "..."}` with the generated XML.
|
|
94
|
+
422: Validation failed or the request body is invalid.
|
|
95
|
+
"""
|
|
96
|
+
enricher = ContentEnricher()
|
|
97
|
+
enricher.enrich(doc)
|
|
98
|
+
xml = render_debit_note(doc)
|
|
99
|
+
if validate:
|
|
100
|
+
errors = _validate_xml(xml, "credit_note")
|
|
101
|
+
if errors:
|
|
102
|
+
raise HTTPException(status_code=422, detail=errors)
|
|
103
|
+
return {"xml": xml}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@router.post("/voided-documents/create")
|
|
107
|
+
def create_voided_documents(doc: VoidedDocuments, validate: bool = Query(default=True)):
|
|
108
|
+
"""Generate a VoidedDocuments XML document.
|
|
109
|
+
|
|
110
|
+
The `validate` query parameter controls SUNAT validation and defaults to `true`.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
200: `{"xml": "..."}` with the generated XML.
|
|
114
|
+
422: Validation failed or the request body is invalid.
|
|
115
|
+
"""
|
|
116
|
+
enricher = ContentEnricher()
|
|
117
|
+
enricher.enrich(doc)
|
|
118
|
+
xml = render_voided_documents(doc)
|
|
119
|
+
if validate:
|
|
120
|
+
errors = _validate_xml(xml, "voided")
|
|
121
|
+
if errors:
|
|
122
|
+
raise HTTPException(status_code=422, detail=errors)
|
|
123
|
+
return {"xml": xml}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@router.post("/summary-documents/create")
|
|
127
|
+
def create_summary_documents(doc: SummaryDocuments, validate: bool = Query(default=True)):
|
|
128
|
+
"""Generate a SummaryDocuments XML document.
|
|
129
|
+
|
|
130
|
+
The `validate` query parameter controls SUNAT validation and defaults to `true`.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
200: `{"xml": "..."}` with the generated XML.
|
|
134
|
+
422: Validation failed or the request body is invalid.
|
|
135
|
+
"""
|
|
136
|
+
xml = render_summary_documents(doc)
|
|
137
|
+
return {"xml": xml}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@router.post("/perception/create")
|
|
141
|
+
def create_perception(doc: Perception, validate: bool = Query(default=True)):
|
|
142
|
+
"""Generate a Perception XML document.
|
|
143
|
+
|
|
144
|
+
The `validate` query parameter controls SUNAT validation and defaults to `true`.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
200: `{"xml": "..."}` with the generated XML.
|
|
148
|
+
422: Validation failed or the request body is invalid.
|
|
149
|
+
"""
|
|
150
|
+
xml = render_perception(doc)
|
|
151
|
+
return {"xml": xml}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@router.post("/retention/create")
|
|
155
|
+
def create_retention(doc: Retention, validate: bool = Query(default=True)):
|
|
156
|
+
"""Generate a Retention XML document.
|
|
157
|
+
|
|
158
|
+
The `validate` query parameter controls SUNAT validation and defaults to `true`.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
200: `{"xml": "..."}` with the generated XML.
|
|
162
|
+
422: Validation failed or the request body is invalid.
|
|
163
|
+
"""
|
|
164
|
+
xml = render_retention(doc)
|
|
165
|
+
return {"xml": xml}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@router.post("/sign")
|
|
169
|
+
def sign_xml(payload: dict):
|
|
170
|
+
"""Sign an arbitrary UBL XML document with a PEM certificate and key.
|
|
171
|
+
|
|
172
|
+
Required body fields:
|
|
173
|
+
- `xml`: The XML string to sign.
|
|
174
|
+
- `cert_pem`: The PEM-encoded certificate.
|
|
175
|
+
- `key_pem`: The PEM-encoded private key.
|
|
176
|
+
- `signature_id`: The signature ID (defaults to `SignSUNAT`).
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
200: `{"signed_xml": "..."}` with the signed XML.
|
|
180
|
+
"""
|
|
181
|
+
xml = payload.get("xml", "")
|
|
182
|
+
cert_pem = payload.get("cert_pem", "")
|
|
183
|
+
key_pem = payload.get("key_pem", "")
|
|
184
|
+
signature_id = payload.get("signature_id", "SignSUNAT")
|
|
185
|
+
signed = sign_ubl_xml(xml, cert_pem, key_pem, signature_id)
|
|
186
|
+
return {"signed_xml": signed}
|
openubl/enricher.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ContentEnricher - Auto-calculates tax fields and totals.
|
|
3
|
+
Based on XBuilder Java ContentEnricher pattern.
|
|
4
|
+
|
|
5
|
+
RS N° 300-2014/SUNAT, Anexo 1:
|
|
6
|
+
- IGV: 18% (Ley N° 30296)
|
|
7
|
+
- ICBPER: S/ 0.20 (Ley N° 30830)
|
|
8
|
+
"""
|
|
9
|
+
from datetime import date
|
|
10
|
+
from decimal import Decimal, ROUND_HALF_UP
|
|
11
|
+
|
|
12
|
+
from .models.defaults import DateProvider, Defaults
|
|
13
|
+
from .models.invoice import DocumentoVentaDetalle, Invoice
|
|
14
|
+
from .models.credit_note import CreditNote
|
|
15
|
+
from .models.debit_note import DebitNote
|
|
16
|
+
from .models.voided import VoidedDocuments
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _round(value: Decimal) -> Decimal:
|
|
20
|
+
"""Round to 2 decimal places using HALF_UP."""
|
|
21
|
+
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ContentEnricher:
|
|
25
|
+
"""Enriches documents with auto-calculated tax fields."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, defaults: Defaults | None = None, date_provider: DateProvider | None = None):
|
|
28
|
+
self.defaults = defaults or Defaults()
|
|
29
|
+
self.date_provider = date_provider or DateProvider()
|
|
30
|
+
|
|
31
|
+
def enrich(self, doc):
|
|
32
|
+
"""Enrich a document in-place."""
|
|
33
|
+
if isinstance(doc, (Invoice, CreditNote, DebitNote)):
|
|
34
|
+
self._enrich_invoice_like(doc)
|
|
35
|
+
elif isinstance(doc, VoidedDocuments):
|
|
36
|
+
self._enrich_voided(doc)
|
|
37
|
+
# Summary, Perception, Retention: no enrichment needed
|
|
38
|
+
|
|
39
|
+
def _enrich_invoice_like(self, doc: Invoice | CreditNote | DebitNote):
|
|
40
|
+
"""Enrich invoice, credit note, or debit note."""
|
|
41
|
+
if doc.fechaEmision is None:
|
|
42
|
+
doc.fechaEmision = self.date_provider.now()
|
|
43
|
+
|
|
44
|
+
for detalle in doc.detalles:
|
|
45
|
+
self._enrich_detalle(detalle)
|
|
46
|
+
|
|
47
|
+
doc.valorVentaTotal = _round(sum(d.valorVenta or Decimal("0") for d in doc.detalles))
|
|
48
|
+
doc.igvTotal = _round(sum(d.igv or Decimal("0") for d in doc.detalles))
|
|
49
|
+
doc.importeTotal = _round(doc.valorVentaTotal + doc.igvTotal)
|
|
50
|
+
|
|
51
|
+
def _enrich_detalle(self, detalle: DocumentoVentaDetalle):
|
|
52
|
+
"""Enrich a single line item."""
|
|
53
|
+
if detalle.valorVenta is None:
|
|
54
|
+
detalle.valorVenta = _round(detalle.cantidad * detalle.precio)
|
|
55
|
+
|
|
56
|
+
if detalle.igv is None:
|
|
57
|
+
if detalle.tipoAfectacionIGV == "10": # Gravado
|
|
58
|
+
detalle.igv = _round(detalle.valorVenta * self.defaults.igvTasa)
|
|
59
|
+
else:
|
|
60
|
+
detalle.igv = Decimal("0")
|
|
61
|
+
|
|
62
|
+
if detalle.precioVenta is None:
|
|
63
|
+
detalle.precioVenta = _round(detalle.valorVenta + detalle.igv)
|
|
64
|
+
|
|
65
|
+
def _enrich_voided(self, doc: VoidedDocuments):
|
|
66
|
+
"""Enrich voided documents."""
|
|
67
|
+
if doc.fechaEmision is None:
|
|
68
|
+
doc.fechaEmision = self.date_provider.now()
|
openubl/main.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""openUBL models package."""
|
|
2
|
+
|
|
3
|
+
from .catalog import (
|
|
4
|
+
Catalog1,
|
|
5
|
+
Catalog2,
|
|
6
|
+
Catalog5,
|
|
7
|
+
Catalog6,
|
|
8
|
+
Catalog7,
|
|
9
|
+
Catalog16,
|
|
10
|
+
Catalog19,
|
|
11
|
+
Catalog22,
|
|
12
|
+
Catalog23,
|
|
13
|
+
)
|
|
14
|
+
from .common import Address, Cliente, Proveedor
|
|
15
|
+
from .defaults import DateProvider, Defaults
|
|
16
|
+
from .invoice import DocumentoVentaDetalle, Invoice
|
|
17
|
+
from .credit_note import CreditNote
|
|
18
|
+
from .debit_note import DebitNote
|
|
19
|
+
from .voided import VoidedDocuments, VoidedDocumentsItem
|
|
20
|
+
from .summary import (
|
|
21
|
+
Comprobante,
|
|
22
|
+
ComprobanteAfectado,
|
|
23
|
+
ComprobanteImpuestos,
|
|
24
|
+
ComprobanteValorVenta,
|
|
25
|
+
SummaryDocuments,
|
|
26
|
+
SummaryDocumentsItem,
|
|
27
|
+
)
|
|
28
|
+
from .perception import Perception, PercepcionRetencionOperacion
|
|
29
|
+
from .retention import Retention
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"Catalog1",
|
|
33
|
+
"Catalog2",
|
|
34
|
+
"Catalog5",
|
|
35
|
+
"Catalog6",
|
|
36
|
+
"Catalog7",
|
|
37
|
+
"Catalog16",
|
|
38
|
+
"Catalog19",
|
|
39
|
+
"Catalog22",
|
|
40
|
+
"Catalog23",
|
|
41
|
+
"Address",
|
|
42
|
+
"Cliente",
|
|
43
|
+
"Proveedor",
|
|
44
|
+
"DateProvider",
|
|
45
|
+
"Defaults",
|
|
46
|
+
"DocumentoVentaDetalle",
|
|
47
|
+
"Invoice",
|
|
48
|
+
"CreditNote",
|
|
49
|
+
"DebitNote",
|
|
50
|
+
"VoidedDocuments",
|
|
51
|
+
"VoidedDocumentsItem",
|
|
52
|
+
"Comprobante",
|
|
53
|
+
"ComprobanteAfectado",
|
|
54
|
+
"ComprobanteImpuestos",
|
|
55
|
+
"ComprobanteValorVenta",
|
|
56
|
+
"SummaryDocuments",
|
|
57
|
+
"SummaryDocumentsItem",
|
|
58
|
+
"Perception",
|
|
59
|
+
"PercepcionRetencionOperacion",
|
|
60
|
+
"Retention",
|
|
61
|
+
]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SUNAT Catalog constants for electronic documents.
|
|
3
|
+
Based on RS N° 300-2014/SUNAT and its annexes.
|
|
4
|
+
"""
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Catalog1(str, Enum):
|
|
9
|
+
"""Tipo de Comprobante - Catálogo N.° 01"""
|
|
10
|
+
FACTURA = "01"
|
|
11
|
+
BOLETA = "03"
|
|
12
|
+
NOTA_CREDITO = "07"
|
|
13
|
+
NOTA_DEBITO = "08"
|
|
14
|
+
GUIA_REMISION = "09"
|
|
15
|
+
COMPROBANTE_RETENCION = "20"
|
|
16
|
+
COMPROBANTE_PERCEPCION = "40"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Catalog6(str, Enum):
|
|
20
|
+
"""Tipo de Documento de Identidad - Catálogo N.° 06"""
|
|
21
|
+
DOC_NO_DOMICILIADO = "0"
|
|
22
|
+
DNI = "1"
|
|
23
|
+
CE = "4"
|
|
24
|
+
RUC = "6"
|
|
25
|
+
PASAPORTE = "7"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Catalog7(str, Enum):
|
|
29
|
+
"""Tipo de Afectación del IGV - Catálogo N.° 07"""
|
|
30
|
+
GRAVADO_OPERACION_ONEROSA = "10"
|
|
31
|
+
EXONERADO_OPERACION_ONEROSA = "20"
|
|
32
|
+
INAFECTO_OPERACION_ONEROSA = "30"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Catalog16(str, Enum):
|
|
36
|
+
"""Tipo de Precio - Catálogo N.° 16"""
|
|
37
|
+
PRECIO_UNITARIO_INCLUYE_IGV = "01"
|
|
38
|
+
VALOR_REFERENCIAL_UNITARIO_EN_OPERACIONES_NO_ONEROSAS = "02"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Catalog19(str, Enum):
|
|
42
|
+
"""Tipo de Operación - Resumen Diario - Catálogo N.° 19"""
|
|
43
|
+
ADICIONAR = "1"
|
|
44
|
+
MODIFICAR = "2"
|
|
45
|
+
ANULADO = "3"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Catalog22(str, Enum):
|
|
49
|
+
"""Régimen de Percepción - Catálogo N.° 22"""
|
|
50
|
+
VENTA_INTERNA = "01"
|
|
51
|
+
ADQUISICION_COMBUSTIBLE = "02"
|
|
52
|
+
TASA_TRES = "03"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Catalog23(str, Enum):
|
|
56
|
+
"""Régimen de Retención - Catálogo N.° 23"""
|
|
57
|
+
TASA_TRES = "01"
|
|
58
|
+
TASA_SEIS = "02"
|
|
59
|
+
TASA_MIXTA = "03"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Catalog2(str, Enum):
|
|
63
|
+
"""Tipo de Moneda - Catálogo N.° 02"""
|
|
64
|
+
PEN = "PEN"
|
|
65
|
+
USD = "USD"
|
|
66
|
+
EUR = "EUR"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Catalog5(str, Enum):
|
|
70
|
+
"""Tipo de Tributo - Catálogo N.° 05"""
|
|
71
|
+
IGV = "1000"
|
|
72
|
+
ISC = "2000"
|
|
73
|
+
EXPORTACION = "9995"
|
|
74
|
+
GRATUITAS = "9996"
|
|
75
|
+
EXONERADO = "9997"
|
|
76
|
+
INAFECTO = "9998"
|
|
77
|
+
OTROS_TRIBUTOS = "9999"
|
|
78
|
+
ICBPER = "7152"
|
|
79
|
+
IVAP = "1016"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Catalog20(str, Enum):
|
|
83
|
+
"""Motivo de Traslado - Catálogo N.° 20"""
|
|
84
|
+
VENTA = "01"
|
|
85
|
+
COMPRA = "02"
|
|
86
|
+
VENTA_SUJETA_A_CONFIRMAR = "03"
|
|
87
|
+
TRASLADO_ENTRE_ESTABLECIMIENTOS = "04"
|
|
88
|
+
CONSIGNACION = "05"
|
|
89
|
+
DEVOLUCION = "06"
|
|
90
|
+
IMPORTACION = "08"
|
|
91
|
+
EXPORTACION = "09"
|
|
92
|
+
TRASLADO_EMISOR_ITINERANTE = "13"
|
|
93
|
+
OTROS = "14"
|
openubl/models/common.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Common models for electronic documents.
|
|
3
|
+
Based on RS N° 300-2014/SUNAT - Sistema de Emisión Electrónica.
|
|
4
|
+
"""
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Address(BaseModel):
|
|
9
|
+
"""Dirección según esquema UBL 2.1 de SUNAT."""
|
|
10
|
+
ubigeo: str | None = None
|
|
11
|
+
direccion: str | None = None
|
|
12
|
+
urbanizacion: str | None = None
|
|
13
|
+
provincia: str | None = None
|
|
14
|
+
departamento: str | None = None
|
|
15
|
+
distrito: str | None = None
|
|
16
|
+
codigoPais: str = "PE"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Proveedor(BaseModel):
|
|
20
|
+
"""Datos del emisor del comprobante.
|
|
21
|
+
|
|
22
|
+
RS N° 300-2014/SUNAT, Anexo 1:
|
|
23
|
+
- RUC del emisor: 11 dígitos numéricos (Catálogo N.° 06, valor 6)
|
|
24
|
+
- Razón social: obligatoria
|
|
25
|
+
"""
|
|
26
|
+
ruc: str = Field(
|
|
27
|
+
min_length=11,
|
|
28
|
+
max_length=11,
|
|
29
|
+
pattern=r"^\d{11}$",
|
|
30
|
+
description="RUC del emisor - 11 dígitos numéricos",
|
|
31
|
+
)
|
|
32
|
+
razonSocial: str
|
|
33
|
+
nombreComercial: str | None = None
|
|
34
|
+
address: Address | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Cliente(BaseModel):
|
|
38
|
+
"""Datos del adquirente o usuario.
|
|
39
|
+
|
|
40
|
+
RS N° 300-2014/SUNAT, Anexo 1:
|
|
41
|
+
- Tipo de documento: Catálogo N.° 06
|
|
42
|
+
- Número de documento: según tipo
|
|
43
|
+
"""
|
|
44
|
+
nombre: str
|
|
45
|
+
numeroDocumentoIdentidad: str
|
|
46
|
+
tipoDocumentoIdentidad: str # Catalog6 code
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Credit Note model for SUNAT electronic invoicing.
|
|
3
|
+
RS N° 300-2014/SUNAT - Nota de Crédito Electrónica (07).
|
|
4
|
+
"""
|
|
5
|
+
from datetime import date
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from .common import Cliente, Proveedor
|
|
11
|
+
from .invoice import DocumentoVentaDetalle
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CreditNote(BaseModel):
|
|
15
|
+
"""Nota de Crédito Electrónica - Tipo 07.
|
|
16
|
+
|
|
17
|
+
RS N° 300-2014/SUNAT, Anexo 1:
|
|
18
|
+
- Serie: debe iniciar con B/C o F/E según tipo de documento afectado
|
|
19
|
+
- Debe referenciar el comprobante afectado
|
|
20
|
+
"""
|
|
21
|
+
serie: str = Field(
|
|
22
|
+
pattern=r"^[BCbc][A-Za-z0-9]{2,3}$",
|
|
23
|
+
description="Serie de nota de crédito",
|
|
24
|
+
)
|
|
25
|
+
numero: int
|
|
26
|
+
comprobanteAfectadoSerieNumero: str
|
|
27
|
+
sustentoDescripcion: str
|
|
28
|
+
proveedor: Proveedor
|
|
29
|
+
cliente: Cliente
|
|
30
|
+
detalles: list[DocumentoVentaDetalle]
|
|
31
|
+
moneda: str = "PEN"
|
|
32
|
+
fechaEmision: date | None = None
|
|
33
|
+
igvTotal: Decimal | None = None
|
|
34
|
+
valorVentaTotal: Decimal | None = None
|
|
35
|
+
importeTotal: Decimal | None = None
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Debit Note model for SUNAT electronic invoicing.
|
|
3
|
+
RS N° 300-2014/SUNAT - Nota de Débito Electrónica (08).
|
|
4
|
+
"""
|
|
5
|
+
from datetime import date
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from .common import Cliente, Proveedor
|
|
11
|
+
from .invoice import DocumentoVentaDetalle
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DebitNote(BaseModel):
|
|
15
|
+
"""Nota de Débito Electrónica - Tipo 08.
|
|
16
|
+
|
|
17
|
+
RS N° 300-2014/SUNAT, Anexo 1:
|
|
18
|
+
- Serie: debe iniciar con B/C o F/E según tipo de documento afectado
|
|
19
|
+
- Debe referenciar el comprobante afectado
|
|
20
|
+
"""
|
|
21
|
+
serie: str = Field(
|
|
22
|
+
pattern=r"^[BCbc][A-Za-z0-9]{2,3}$",
|
|
23
|
+
description="Serie de nota de débito",
|
|
24
|
+
)
|
|
25
|
+
numero: int
|
|
26
|
+
comprobanteAfectadoSerieNumero: str
|
|
27
|
+
sustentoDescripcion: str
|
|
28
|
+
proveedor: Proveedor
|
|
29
|
+
cliente: Cliente
|
|
30
|
+
detalles: list[DocumentoVentaDetalle]
|
|
31
|
+
moneda: str = "PEN"
|
|
32
|
+
fechaEmision: date | None = None
|
|
33
|
+
igvTotal: Decimal | None = None
|
|
34
|
+
valorVentaTotal: Decimal | None = None
|
|
35
|
+
importeTotal: Decimal | None = None
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Default values for document generation.
|
|
3
|
+
"""
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Defaults(BaseModel):
|
|
10
|
+
"""Valores por defecto para cálculos de impuestos.
|
|
11
|
+
|
|
12
|
+
Ley N° 30296 - IGV tasa 18%
|
|
13
|
+
Ley N° 30830 - ICBPER tasa S/ 0.20
|
|
14
|
+
"""
|
|
15
|
+
igvTasa: Decimal = Decimal("0.18")
|
|
16
|
+
icbTasa: Decimal = Decimal("0.2")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DateProvider:
|
|
20
|
+
"""Proveedor de fechas para facilitar testing."""
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def now():
|
|
24
|
+
from datetime import date
|
|
25
|
+
return date.today()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Invoice model for SUNAT electronic invoicing.
|
|
3
|
+
RS N° 300-2014/SUNAT - Factura Electrónica (01).
|
|
4
|
+
"""
|
|
5
|
+
from datetime import date
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from .common import Cliente, Proveedor
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DocumentoVentaDetalle(BaseModel):
|
|
14
|
+
"""Línea de detalle de un documento de venta.
|
|
15
|
+
|
|
16
|
+
RS N° 300-2014/SUNAT, Anexo 1:
|
|
17
|
+
- Descripción del bien o servicio
|
|
18
|
+
- Cantidad y unidad de medida (Catálogo N.° 03)
|
|
19
|
+
- Valor unitario (sin IGV)
|
|
20
|
+
- Tipo de afectación del IGV (Catálogo N.° 07)
|
|
21
|
+
"""
|
|
22
|
+
descripcion: str
|
|
23
|
+
cantidad: Decimal
|
|
24
|
+
precio: Decimal
|
|
25
|
+
unidadMedida: str = "NIU"
|
|
26
|
+
tipoAfectacionIGV: str = "10" # Catalog7 default: GRAVADO_OPERACION_ONEROSA
|
|
27
|
+
igv: Decimal | None = None
|
|
28
|
+
valorVenta: Decimal | None = None
|
|
29
|
+
precioVenta: Decimal | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Invoice(BaseModel):
|
|
33
|
+
"""Factura Electrónica - Tipo 01.
|
|
34
|
+
|
|
35
|
+
RS N° 300-2014/SUNAT, Anexo 1:
|
|
36
|
+
- Serie: debe iniciar con F (factura) o B (boleta)
|
|
37
|
+
- Número: correlativo
|
|
38
|
+
- Tipo de operación: Catálogo N.° 51, default 0101 (Venta interna)
|
|
39
|
+
"""
|
|
40
|
+
serie: str = Field(
|
|
41
|
+
pattern=r"^[FBfb][A-Za-z0-9]{2,3}$",
|
|
42
|
+
description="Serie de factura (F001) o boleta (B001)",
|
|
43
|
+
)
|
|
44
|
+
numero: int
|
|
45
|
+
proveedor: Proveedor
|
|
46
|
+
cliente: Cliente
|
|
47
|
+
detalles: list[DocumentoVentaDetalle]
|
|
48
|
+
moneda: str = "PEN"
|
|
49
|
+
fechaEmision: date | None = None
|
|
50
|
+
igvTotal: Decimal | None = None
|
|
51
|
+
valorVentaTotal: Decimal | None = None
|
|
52
|
+
importeTotal: Decimal | None = None
|
|
53
|
+
tipoOperacion: str = "0101" # Default: Venta interna
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Perception model for SUNAT electronic invoicing.
|
|
3
|
+
RS N° 274-2015/SUNAT - Comprobante de Percepción Electrónico (40).
|
|
4
|
+
"""
|
|
5
|
+
from datetime import date
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from .common import Cliente, Proveedor
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ComprobanteAfectado(BaseModel):
|
|
14
|
+
"""Comprobante afectado por la percepción."""
|
|
15
|
+
tipoComprobante: str
|
|
16
|
+
serieNumero: str
|
|
17
|
+
fechaEmision: date
|
|
18
|
+
importeTotal: Decimal
|
|
19
|
+
moneda: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PercepcionRetencionOperacion(BaseModel):
|
|
23
|
+
"""Operación de percepción."""
|
|
24
|
+
numeroOperacion: int
|
|
25
|
+
fechaOperacion: date
|
|
26
|
+
importeOperacion: Decimal
|
|
27
|
+
comprobante: ComprobanteAfectado
|
|
28
|
+
|
|
29
|
+
class Perception(BaseModel):
|
|
30
|
+
"""Comprobante de Percepción Electrónico - Tipo 40.
|
|
31
|
+
|
|
32
|
+
RS N° 274-2015/SUNAT, Anexo 1:
|
|
33
|
+
- Serie: P### (P001, P002, etc.)
|
|
34
|
+
- Régimen: Catálogo N.° 22
|
|
35
|
+
"""
|
|
36
|
+
serie: str = Field(
|
|
37
|
+
pattern=r"^P\d{3}$",
|
|
38
|
+
description="Serie de percepción (P###)",
|
|
39
|
+
)
|
|
40
|
+
numero: int
|
|
41
|
+
fechaEmision: date
|
|
42
|
+
proveedor: Proveedor
|
|
43
|
+
cliente: Cliente
|
|
44
|
+
importeTotalPercibido: Decimal
|
|
45
|
+
importeTotalCobrado: Decimal
|
|
46
|
+
tipoRegimen: str # Catalog22
|
|
47
|
+
tipoRegimenPorcentaje: Decimal
|
|
48
|
+
operaciones: list[PercepcionRetencionOperacion]
|