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 ADDED
@@ -0,0 +1,3 @@
1
+ """openUBL - Peruvian SUNAT Electronic Documents Library."""
2
+
3
+ __version__ = "0.1.0"
@@ -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,8 @@
1
+ """
2
+ FastAPI application for openUBL.
3
+ """
4
+ from fastapi import FastAPI
5
+ from .api.router import router
6
+
7
+ app = FastAPI(title="openUBL", version="0.1.0")
8
+ app.include_router(router, prefix="/api/v1")
@@ -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"
@@ -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]