eqinvoice 1.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.
- eqinvoice/__init__.py +97 -0
- eqinvoice/access_key.py +46 -0
- eqinvoice/document.py +203 -0
- eqinvoice/errors.py +23 -0
- eqinvoice/labels/es.json +65 -0
- eqinvoice/model.py +155 -0
- eqinvoice/pricing.py +39 -0
- eqinvoice/protocol.py +108 -0
- eqinvoice/py.typed +0 -0
- eqinvoice/record.py +184 -0
- eqinvoice/registry.py +149 -0
- eqinvoice/ride/__init__.py +14 -0
- eqinvoice/ride/labels.py +28 -0
- eqinvoice/ride/logo.py +243 -0
- eqinvoice/ride/pdf.py +548 -0
- eqinvoice/schemas/NotaCredito_V1.1.0.xsd +584 -0
- eqinvoice/schemas/factura_V1.1.0.xsd +961 -0
- eqinvoice/schemas/xmldsig-core-schema.xsd +318 -0
- eqinvoice/service.py +221 -0
- eqinvoice/signature.py +276 -0
- eqinvoice-1.1.0.dist-info/METADATA +303 -0
- eqinvoice-1.1.0.dist-info/RECORD +33 -0
- eqinvoice-1.1.0.dist-info/WHEEL +5 -0
- eqinvoice-1.1.0.dist-info/entry_points.txt +2 -0
- eqinvoice-1.1.0.dist-info/licenses/LICENSE +21 -0
- eqinvoice-1.1.0.dist-info/top_level.txt +2 -0
- eqinvoice_mcp/__init__.py +0 -0
- eqinvoice_mcp/book.py +205 -0
- eqinvoice_mcp/desk.py +272 -0
- eqinvoice_mcp/icon.svg +1 -0
- eqinvoice_mcp/model.py +68 -0
- eqinvoice_mcp/server.py +267 -0
- eqinvoice_mcp/settings.py +28 -0
eqinvoice/__init__.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from eqinvoice.access_key import access_key, check_digit, issue_date
|
|
2
|
+
from eqinvoice.document import build, rimpe_legend, validate
|
|
3
|
+
from eqinvoice.errors import (
|
|
4
|
+
CredentialProblem,
|
|
5
|
+
CredentialRefused,
|
|
6
|
+
SriUnavailable,
|
|
7
|
+
VoucherInvalid,
|
|
8
|
+
)
|
|
9
|
+
from eqinvoice.model import (
|
|
10
|
+
Buyer,
|
|
11
|
+
BuyerKind,
|
|
12
|
+
Environment,
|
|
13
|
+
Issued,
|
|
14
|
+
Issuer,
|
|
15
|
+
Line,
|
|
16
|
+
Modified,
|
|
17
|
+
Payment,
|
|
18
|
+
TaxGroup,
|
|
19
|
+
Voucher,
|
|
20
|
+
VoucherKind,
|
|
21
|
+
)
|
|
22
|
+
from eqinvoice.pricing import cents, price_line
|
|
23
|
+
from eqinvoice.record import read, read_voucher
|
|
24
|
+
from eqinvoice.registry import (
|
|
25
|
+
Establishment,
|
|
26
|
+
Registry,
|
|
27
|
+
SimulatedRegistry,
|
|
28
|
+
SriRegistry,
|
|
29
|
+
Taxpayer,
|
|
30
|
+
)
|
|
31
|
+
from eqinvoice.service import (
|
|
32
|
+
Authorization,
|
|
33
|
+
Message,
|
|
34
|
+
Reception,
|
|
35
|
+
SimulatedSri,
|
|
36
|
+
Sri,
|
|
37
|
+
SriWebServices,
|
|
38
|
+
)
|
|
39
|
+
from eqinvoice.signature import (
|
|
40
|
+
Credential,
|
|
41
|
+
SignatureDetails,
|
|
42
|
+
details,
|
|
43
|
+
load_credential,
|
|
44
|
+
read_credential,
|
|
45
|
+
sign,
|
|
46
|
+
sign_voucher,
|
|
47
|
+
simulated_credential,
|
|
48
|
+
usable,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"Authorization",
|
|
53
|
+
"Buyer",
|
|
54
|
+
"BuyerKind",
|
|
55
|
+
"Credential",
|
|
56
|
+
"CredentialProblem",
|
|
57
|
+
"CredentialRefused",
|
|
58
|
+
"Environment",
|
|
59
|
+
"Establishment",
|
|
60
|
+
"Issued",
|
|
61
|
+
"Issuer",
|
|
62
|
+
"Line",
|
|
63
|
+
"Message",
|
|
64
|
+
"Modified",
|
|
65
|
+
"Payment",
|
|
66
|
+
"Reception",
|
|
67
|
+
"Registry",
|
|
68
|
+
"SignatureDetails",
|
|
69
|
+
"SimulatedRegistry",
|
|
70
|
+
"SimulatedSri",
|
|
71
|
+
"Sri",
|
|
72
|
+
"SriRegistry",
|
|
73
|
+
"SriUnavailable",
|
|
74
|
+
"SriWebServices",
|
|
75
|
+
"TaxGroup",
|
|
76
|
+
"Taxpayer",
|
|
77
|
+
"Voucher",
|
|
78
|
+
"VoucherInvalid",
|
|
79
|
+
"VoucherKind",
|
|
80
|
+
"access_key",
|
|
81
|
+
"build",
|
|
82
|
+
"cents",
|
|
83
|
+
"check_digit",
|
|
84
|
+
"details",
|
|
85
|
+
"issue_date",
|
|
86
|
+
"load_credential",
|
|
87
|
+
"price_line",
|
|
88
|
+
"read",
|
|
89
|
+
"read_credential",
|
|
90
|
+
"read_voucher",
|
|
91
|
+
"rimpe_legend",
|
|
92
|
+
"sign",
|
|
93
|
+
"sign_voucher",
|
|
94
|
+
"simulated_credential",
|
|
95
|
+
"usable",
|
|
96
|
+
"validate",
|
|
97
|
+
]
|
eqinvoice/access_key.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from datetime import date, datetime
|
|
5
|
+
|
|
6
|
+
from eqinvoice.model import Environment, VoucherKind
|
|
7
|
+
from eqinvoice.protocol import (
|
|
8
|
+
CLOCK,
|
|
9
|
+
DOCUMENT_CODES,
|
|
10
|
+
ENVIRONMENT_CODES,
|
|
11
|
+
NORMAL_EMISSION,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
WEIGHTS = (2, 3, 4, 5, 6, 7)
|
|
15
|
+
MODULUS = 11
|
|
16
|
+
NUMERIC_CODE_DIGITS = 8
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def check_digit(digits: str) -> str:
|
|
20
|
+
total = sum(
|
|
21
|
+
int(digit) * WEIGHTS[index % len(WEIGHTS)] for index, digit in enumerate(reversed(digits))
|
|
22
|
+
)
|
|
23
|
+
remainder = MODULUS - total % MODULUS
|
|
24
|
+
return {MODULUS: "0", MODULUS - 1: "1"}.get(remainder, str(remainder))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def access_key(
|
|
28
|
+
issued_on: date,
|
|
29
|
+
kind: VoucherKind,
|
|
30
|
+
ruc: str,
|
|
31
|
+
environment: Environment,
|
|
32
|
+
establishment: str,
|
|
33
|
+
point: str,
|
|
34
|
+
sequential: int,
|
|
35
|
+
) -> str:
|
|
36
|
+
"""The clave de acceso, with a fresh numeric code and its check digit."""
|
|
37
|
+
numeric = f"{secrets.randbelow(10**NUMERIC_CODE_DIGITS):0{NUMERIC_CODE_DIGITS}d}"
|
|
38
|
+
body = (
|
|
39
|
+
f"{issued_on:%d%m%Y}{DOCUMENT_CODES[kind]}{ruc}{ENVIRONMENT_CODES[environment]}"
|
|
40
|
+
f"{establishment}{point}{sequential:09d}{numeric}{NORMAL_EMISSION}"
|
|
41
|
+
)
|
|
42
|
+
return body + check_digit(body)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def issue_date(now: datetime) -> date:
|
|
46
|
+
return now.astimezone(CLOCK).date()
|
eqinvoice/document.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from functools import cache
|
|
5
|
+
|
|
6
|
+
from lxml import etree
|
|
7
|
+
|
|
8
|
+
from eqinvoice.errors import VoucherInvalid
|
|
9
|
+
from eqinvoice.model import ZERO, Issuer, Voucher, VoucherKind
|
|
10
|
+
from eqinvoice.protocol import (
|
|
11
|
+
BUYER_CODES,
|
|
12
|
+
CODE_TAGS,
|
|
13
|
+
CURRENCY_NAME,
|
|
14
|
+
DATE_FORMAT,
|
|
15
|
+
DOCUMENT_CODES,
|
|
16
|
+
EMAIL_FIELD,
|
|
17
|
+
ENVIRONMENT_CODES,
|
|
18
|
+
INFO,
|
|
19
|
+
MOST_EXTRA_FIELDS,
|
|
20
|
+
NO,
|
|
21
|
+
NORMAL_EMISSION,
|
|
22
|
+
PARSER,
|
|
23
|
+
RIMPE_TYPE,
|
|
24
|
+
ROOTS,
|
|
25
|
+
SCHEMA_DIR,
|
|
26
|
+
SCHEMAS,
|
|
27
|
+
SYSTEM_PROVIDER_FIELD,
|
|
28
|
+
VAT_TAX,
|
|
29
|
+
VERSION,
|
|
30
|
+
VOUCHER_ID,
|
|
31
|
+
XSD_NS,
|
|
32
|
+
YES,
|
|
33
|
+
buyer_shown,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def money(amount: Decimal) -> str:
|
|
38
|
+
return f"{amount:.2f}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _six(amount: Decimal) -> str:
|
|
42
|
+
return f"{amount:.6f}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _add(parent: etree._Element, tag: str, text: str | None = None) -> etree._Element:
|
|
46
|
+
child = etree.SubElement(parent, tag)
|
|
47
|
+
if text is not None:
|
|
48
|
+
child.text = text
|
|
49
|
+
return child
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _optional(parent: etree._Element, tag: str, text: str | None) -> None:
|
|
53
|
+
if text:
|
|
54
|
+
_add(parent, tag, text)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _info_tributaria(root: etree._Element, voucher: Voucher, issuer: Issuer) -> None:
|
|
58
|
+
info = _add(root, "infoTributaria")
|
|
59
|
+
_add(info, "ambiente", ENVIRONMENT_CODES[voucher.environment])
|
|
60
|
+
_add(info, "tipoEmision", NORMAL_EMISSION)
|
|
61
|
+
_add(info, "razonSocial", issuer.legal_name)
|
|
62
|
+
_optional(info, "nombreComercial", issuer.trade_name)
|
|
63
|
+
_add(info, "ruc", issuer.ruc)
|
|
64
|
+
_add(info, "claveAcceso", voucher.access_key)
|
|
65
|
+
_add(info, "codDoc", DOCUMENT_CODES[voucher.kind])
|
|
66
|
+
_add(info, "estab", voucher.establishment)
|
|
67
|
+
_add(info, "ptoEmi", voucher.point)
|
|
68
|
+
_add(info, "secuencial", f"{voucher.sequential:09d}")
|
|
69
|
+
_add(info, "dirMatriz", issuer.head_office_address)
|
|
70
|
+
_optional(info, "agenteRetencion", issuer.withholding_agent)
|
|
71
|
+
if issuer.rimpe:
|
|
72
|
+
_add(info, RIMPE_TYPE, rimpe_legend())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _buyer(info: etree._Element, voucher: Voucher) -> None:
|
|
76
|
+
buyer = voucher.buyer
|
|
77
|
+
name, identification = buyer_shown(buyer.kind, buyer.name, buyer.identification)
|
|
78
|
+
_add(info, "tipoIdentificacionComprador", BUYER_CODES[buyer.kind])
|
|
79
|
+
_add(info, "razonSocialComprador", name)
|
|
80
|
+
_add(info, "identificacionComprador", identification)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _taxes(info: etree._Element, voucher: Voucher) -> None:
|
|
84
|
+
totals = _add(info, "totalConImpuestos")
|
|
85
|
+
for group in voucher.tax_groups:
|
|
86
|
+
tax = _add(totals, "totalImpuesto")
|
|
87
|
+
_add(tax, "codigo", VAT_TAX)
|
|
88
|
+
_add(tax, "codigoPorcentaje", group.code)
|
|
89
|
+
_add(tax, "baseImponible", money(group.base))
|
|
90
|
+
_add(tax, "valor", money(group.vat))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _accounts(info: etree._Element, issuer: Issuer) -> None:
|
|
94
|
+
_add(info, "obligadoContabilidad", YES if issuer.keeps_accounts else NO)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _invoice_info(root: etree._Element, voucher: Voucher, issuer: Issuer) -> None:
|
|
98
|
+
info = _add(root, INFO[VoucherKind.INVOICE])
|
|
99
|
+
_add(info, "fechaEmision", voucher.issued_on.strftime(DATE_FORMAT))
|
|
100
|
+
_add(info, "dirEstablecimiento", voucher.establishment_address)
|
|
101
|
+
_optional(info, "contribuyenteEspecial", issuer.special_taxpayer)
|
|
102
|
+
_accounts(info, issuer)
|
|
103
|
+
_buyer(info, voucher)
|
|
104
|
+
_add(info, "totalSinImpuestos", money(voucher.subtotal))
|
|
105
|
+
_add(info, "totalDescuento", money(voucher.discount))
|
|
106
|
+
_taxes(info, voucher)
|
|
107
|
+
_add(info, "propina", money(ZERO))
|
|
108
|
+
_add(info, "importeTotal", money(voucher.total))
|
|
109
|
+
_add(info, "moneda", CURRENCY_NAME)
|
|
110
|
+
payments = _add(info, "pagos")
|
|
111
|
+
for payment in voucher.payment_forms:
|
|
112
|
+
paid = _add(payments, "pago")
|
|
113
|
+
_add(paid, "formaPago", payment.form)
|
|
114
|
+
_add(paid, "total", money(payment.amount))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _credit_info(root: etree._Element, voucher: Voucher, issuer: Issuer) -> None:
|
|
118
|
+
modified = voucher.modified
|
|
119
|
+
if modified is None:
|
|
120
|
+
raise VoucherInvalid("A credit note names the invoice it modifies")
|
|
121
|
+
info = _add(root, INFO[VoucherKind.CREDIT_NOTE])
|
|
122
|
+
_add(info, "fechaEmision", voucher.issued_on.strftime(DATE_FORMAT))
|
|
123
|
+
_add(info, "dirEstablecimiento", voucher.establishment_address)
|
|
124
|
+
_buyer(info, voucher)
|
|
125
|
+
_optional(info, "contribuyenteEspecial", issuer.special_taxpayer)
|
|
126
|
+
_accounts(info, issuer)
|
|
127
|
+
_add(info, "codDocModificado", DOCUMENT_CODES[VoucherKind.INVOICE])
|
|
128
|
+
_add(info, "numDocModificado", modified.number)
|
|
129
|
+
_add(info, "fechaEmisionDocSustento", modified.issued_on.strftime(DATE_FORMAT))
|
|
130
|
+
_add(info, "totalSinImpuestos", money(voucher.subtotal))
|
|
131
|
+
_add(info, "valorModificacion", money(voucher.total))
|
|
132
|
+
_add(info, "moneda", CURRENCY_NAME)
|
|
133
|
+
_taxes(info, voucher)
|
|
134
|
+
_add(info, "motivo", modified.reason)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _details(root: etree._Element, voucher: Voucher) -> None:
|
|
138
|
+
details = _add(root, "detalles")
|
|
139
|
+
for line in voucher.lines:
|
|
140
|
+
detail = _add(details, "detalle")
|
|
141
|
+
_add(detail, CODE_TAGS[voucher.kind], line.code)
|
|
142
|
+
_add(detail, "descripcion", line.description)
|
|
143
|
+
_add(detail, "cantidad", _six(line.quantity))
|
|
144
|
+
_add(detail, "precioUnitario", _six(line.unit_price))
|
|
145
|
+
_add(detail, "descuento", money(line.discount))
|
|
146
|
+
_add(detail, "precioTotalSinImpuesto", money(line.subtotal))
|
|
147
|
+
tax = _add(_add(detail, "impuestos"), "impuesto")
|
|
148
|
+
_add(tax, "codigo", VAT_TAX)
|
|
149
|
+
_add(tax, "codigoPorcentaje", line.rate_code)
|
|
150
|
+
_add(tax, "tarifa", money(line.rate_percentage))
|
|
151
|
+
_add(tax, "baseImponible", money(line.subtotal))
|
|
152
|
+
_add(tax, "valor", money(line.vat))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _extra(root: etree._Element, voucher: Voucher, issuer: Issuer) -> None:
|
|
156
|
+
fields = list(voucher.extra)
|
|
157
|
+
if voucher.buyer.email:
|
|
158
|
+
fields.insert(0, (EMAIL_FIELD, voucher.buyer.email))
|
|
159
|
+
if issuer.system_provider_ruc:
|
|
160
|
+
fields.append((SYSTEM_PROVIDER_FIELD, issuer.system_provider_ruc))
|
|
161
|
+
if not fields:
|
|
162
|
+
return
|
|
163
|
+
extra = _add(root, "infoAdicional")
|
|
164
|
+
for name, value in fields[:MOST_EXTRA_FIELDS]:
|
|
165
|
+
_add(extra, "campoAdicional", value).set("nombre", name)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def build(voucher: Voucher, issuer: Issuer) -> etree._Element:
|
|
169
|
+
root = etree.Element(ROOTS[voucher.kind], id=VOUCHER_ID, version=VERSION)
|
|
170
|
+
_info_tributaria(root, voucher, issuer)
|
|
171
|
+
if voucher.kind == VoucherKind.INVOICE:
|
|
172
|
+
_invoice_info(root, voucher, issuer)
|
|
173
|
+
else:
|
|
174
|
+
_credit_info(root, voucher, issuer)
|
|
175
|
+
_details(root, voucher)
|
|
176
|
+
_extra(root, voucher, issuer)
|
|
177
|
+
return root
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@cache
|
|
181
|
+
def _schema_document(kind: VoucherKind) -> etree._ElementTree:
|
|
182
|
+
return etree.parse(str(SCHEMA_DIR / SCHEMAS[kind]), PARSER)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@cache
|
|
186
|
+
def _schema(kind: VoucherKind) -> etree.XMLSchema:
|
|
187
|
+
return etree.XMLSchema(_schema_document(kind))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@cache
|
|
191
|
+
def rimpe_legend() -> str:
|
|
192
|
+
pattern = _schema_document(VoucherKind.INVOICE).find(
|
|
193
|
+
f"{{{XSD_NS}}}simpleType[@name='{RIMPE_TYPE}']/{{{XSD_NS}}}restriction/{{{XSD_NS}}}pattern"
|
|
194
|
+
)
|
|
195
|
+
if pattern is None:
|
|
196
|
+
raise VoucherInvalid("The SRI's schema no longer fixes the RIMPE legend")
|
|
197
|
+
return str(pattern.get("value"))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def validate(kind: VoucherKind, document: bytes) -> None:
|
|
201
|
+
schema = _schema(kind)
|
|
202
|
+
if not schema.validate(etree.fromstring(document, PARSER)):
|
|
203
|
+
raise VoucherInvalid("; ".join(str(error.message) for error in schema.error_log))
|
eqinvoice/errors.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SriUnavailable(RuntimeError):
|
|
7
|
+
"""The SRI could not be reached or failed on its side; the same voucher can be sent again."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class VoucherInvalid(ValueError):
|
|
11
|
+
"""The voucher does not satisfy the SRI's schema, so sending it would only be returned."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CredentialProblem(StrEnum):
|
|
15
|
+
MISSING = "missing"
|
|
16
|
+
UNREADABLE = "unreadable"
|
|
17
|
+
OUT_OF_DATE = "out_of_date"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CredentialRefused(RuntimeError):
|
|
21
|
+
def __init__(self, problem: CredentialProblem, reason: str) -> None:
|
|
22
|
+
super().__init__(reason)
|
|
23
|
+
self.problem = problem
|
eqinvoice/labels/es.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"documents": {
|
|
3
|
+
"invoice": "FACTURA",
|
|
4
|
+
"credit_note": "NOTA DE CRÉDITO"
|
|
5
|
+
},
|
|
6
|
+
"ruc": "R.U.C.",
|
|
7
|
+
"number": "No.",
|
|
8
|
+
"authorizationNumber": "NÚMERO DE AUTORIZACIÓN",
|
|
9
|
+
"authorizedAt": "FECHA Y HORA DE AUTORIZACIÓN",
|
|
10
|
+
"environment": "AMBIENTE",
|
|
11
|
+
"environments": {
|
|
12
|
+
"test": "PRUEBAS",
|
|
13
|
+
"production": "PRODUCCIÓN"
|
|
14
|
+
},
|
|
15
|
+
"emission": "EMISIÓN",
|
|
16
|
+
"normalEmission": "NORMAL",
|
|
17
|
+
"accessKey": "CLAVE DE ACCESO",
|
|
18
|
+
"headOffice": "Dirección Matriz",
|
|
19
|
+
"branch": "Dirección Sucursal",
|
|
20
|
+
"specialTaxpayer": "Contribuyente Especial Nro.",
|
|
21
|
+
"keepsAccounts": "OBLIGADO A LLEVAR CONTABILIDAD",
|
|
22
|
+
"yes": "SI",
|
|
23
|
+
"no": "NO",
|
|
24
|
+
"withholdingAgent": "Agente de Retención Resolución No.",
|
|
25
|
+
"buyerName": "Razón Social / Nombres y Apellidos",
|
|
26
|
+
"buyerId": "Identificación",
|
|
27
|
+
"issuedOn": "Fecha Emisión",
|
|
28
|
+
"modified": "Comprobante que se modifica",
|
|
29
|
+
"modifiedIssuedOn": "Fecha Emisión (Comprobante a modificar)",
|
|
30
|
+
"reason": "Razón de Modificación",
|
|
31
|
+
"columns": {
|
|
32
|
+
"code": "Cod. Principal",
|
|
33
|
+
"quantity": "Cantidad",
|
|
34
|
+
"description": "Descripción",
|
|
35
|
+
"unitPrice": "Precio Unitario",
|
|
36
|
+
"discount": "Descuento",
|
|
37
|
+
"total": "Precio Total"
|
|
38
|
+
},
|
|
39
|
+
"extra": "Información Adicional",
|
|
40
|
+
"paymentForm": "Forma de pago",
|
|
41
|
+
"value": "Valor",
|
|
42
|
+
"paymentForms": {
|
|
43
|
+
"01": "SIN UTILIZACIÓN DEL SISTEMA FINANCIERO",
|
|
44
|
+
"15": "COMPENSACIÓN DE DEUDAS",
|
|
45
|
+
"16": "TARJETA DE DÉBITO",
|
|
46
|
+
"17": "DINERO ELECTRÓNICO",
|
|
47
|
+
"18": "TARJETA PREPAGO",
|
|
48
|
+
"19": "TARJETA DE CRÉDITO",
|
|
49
|
+
"20": "OTROS CON UTILIZACIÓN DEL SISTEMA FINANCIERO",
|
|
50
|
+
"21": "ENDOSO DE TÍTULOS"
|
|
51
|
+
},
|
|
52
|
+
"subtotalRated": "SUBTOTAL {{rate}}%",
|
|
53
|
+
"subtotalUnrated": {
|
|
54
|
+
"0": "SUBTOTAL 0%",
|
|
55
|
+
"6": "SUBTOTAL NO OBJETO DE IVA",
|
|
56
|
+
"7": "SUBTOTAL EXENTO DE IVA"
|
|
57
|
+
},
|
|
58
|
+
"subtotal": "SUBTOTAL SIN IMPUESTOS",
|
|
59
|
+
"ice": "ICE",
|
|
60
|
+
"irbpnr": "IRBPNR",
|
|
61
|
+
"tip": "PROPINA",
|
|
62
|
+
"discount": "TOTAL DESCUENTO",
|
|
63
|
+
"vatRated": "IVA {{rate}}%",
|
|
64
|
+
"total": "VALOR TOTAL"
|
|
65
|
+
}
|
eqinvoice/model.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import date, datetime
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
|
|
8
|
+
ZERO = Decimal("0")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class VoucherKind(StrEnum):
|
|
12
|
+
INVOICE = "invoice"
|
|
13
|
+
CREDIT_NOTE = "credit_note"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Environment(StrEnum):
|
|
17
|
+
TEST = "test"
|
|
18
|
+
PRODUCTION = "production"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BuyerKind(StrEnum):
|
|
22
|
+
RUC = "ruc"
|
|
23
|
+
CEDULA = "cedula"
|
|
24
|
+
PASSPORT = "passport"
|
|
25
|
+
FINAL_CONSUMER = "final_consumer"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Issuer:
|
|
30
|
+
legal_name: str
|
|
31
|
+
ruc: str
|
|
32
|
+
head_office_address: str
|
|
33
|
+
keeps_accounts: bool
|
|
34
|
+
trade_name: str | None = None
|
|
35
|
+
special_taxpayer: str | None = None
|
|
36
|
+
withholding_agent: str | None = None
|
|
37
|
+
rimpe: bool = False
|
|
38
|
+
system_provider_ruc: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Buyer:
|
|
43
|
+
"""Whom the voucher is for; a final consumer carries neither identification nor name."""
|
|
44
|
+
|
|
45
|
+
kind: BuyerKind
|
|
46
|
+
identification: str | None = None
|
|
47
|
+
name: str | None = None
|
|
48
|
+
email: str | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Line:
|
|
53
|
+
"""One line, already split: every amount before IVA, then the IVA itself."""
|
|
54
|
+
|
|
55
|
+
code: str
|
|
56
|
+
description: str
|
|
57
|
+
quantity: Decimal
|
|
58
|
+
unit_price: Decimal
|
|
59
|
+
discount: Decimal
|
|
60
|
+
subtotal: Decimal
|
|
61
|
+
vat: Decimal
|
|
62
|
+
rate_code: str
|
|
63
|
+
rate_percentage: Decimal
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class Payment:
|
|
68
|
+
"""Part of what an invoice was paid with: the SRI's payment form code and the amount."""
|
|
69
|
+
|
|
70
|
+
form: str
|
|
71
|
+
amount: Decimal
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class Modified:
|
|
76
|
+
number: str
|
|
77
|
+
issued_on: date
|
|
78
|
+
reason: str
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class TaxGroup:
|
|
83
|
+
code: str
|
|
84
|
+
base: Decimal
|
|
85
|
+
vat: Decimal
|
|
86
|
+
rate: Decimal
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class Voucher:
|
|
91
|
+
kind: VoucherKind
|
|
92
|
+
environment: Environment
|
|
93
|
+
access_key: str
|
|
94
|
+
establishment: str
|
|
95
|
+
point: str
|
|
96
|
+
sequential: int
|
|
97
|
+
establishment_address: str
|
|
98
|
+
issued_on: date
|
|
99
|
+
buyer: Buyer
|
|
100
|
+
lines: tuple[Line, ...]
|
|
101
|
+
payments: tuple[Payment, ...] = ()
|
|
102
|
+
modified: Modified | None = None
|
|
103
|
+
extra: tuple[tuple[str, str], ...] = ()
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def number(self) -> str:
|
|
107
|
+
return f"{self.establishment}-{self.point}-{self.sequential:09d}"
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def subtotal(self) -> Decimal:
|
|
111
|
+
return sum((line.subtotal for line in self.lines), ZERO)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def discount(self) -> Decimal:
|
|
115
|
+
return sum((line.discount for line in self.lines), ZERO)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def vat(self) -> Decimal:
|
|
119
|
+
return sum((line.vat for line in self.lines), ZERO)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def total(self) -> Decimal:
|
|
123
|
+
return self.subtotal + self.vat
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def tax_groups(self) -> tuple[TaxGroup, ...]:
|
|
127
|
+
"""The lines added up per IVA rate, in the order the rates first appear."""
|
|
128
|
+
groups: dict[str, TaxGroup] = {}
|
|
129
|
+
for line in self.lines:
|
|
130
|
+
held = groups.get(line.rate_code)
|
|
131
|
+
base, vat = (held.base, held.vat) if held else (ZERO, ZERO)
|
|
132
|
+
groups[line.rate_code] = TaxGroup(
|
|
133
|
+
line.rate_code, base + line.subtotal, vat + line.vat, line.rate_percentage
|
|
134
|
+
)
|
|
135
|
+
return tuple(groups.values())
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def payment_forms(self) -> tuple[Payment, ...]:
|
|
139
|
+
"""The payments added up per form, in the order the forms first appear."""
|
|
140
|
+
forms: dict[str, Decimal] = {}
|
|
141
|
+
for payment in self.payments:
|
|
142
|
+
forms[payment.form] = forms.get(payment.form, ZERO) + payment.amount
|
|
143
|
+
return tuple(Payment(form, amount) for form, amount in forms.items())
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class Issued:
|
|
148
|
+
voucher: Voucher
|
|
149
|
+
issuer: Issuer
|
|
150
|
+
authorized_at: datetime
|
|
151
|
+
authorization_number: str
|
|
152
|
+
total: Decimal
|
|
153
|
+
ice: Decimal = ZERO
|
|
154
|
+
irbpnr: Decimal = ZERO
|
|
155
|
+
tip: Decimal | None = None
|
eqinvoice/pricing.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from decimal import ROUND_HALF_UP, Decimal
|
|
4
|
+
|
|
5
|
+
from eqinvoice.errors import VoucherInvalid
|
|
6
|
+
from eqinvoice.model import ZERO, Line
|
|
7
|
+
from eqinvoice.protocol import VAT_RATES
|
|
8
|
+
|
|
9
|
+
CENT = Decimal("0.01")
|
|
10
|
+
HUNDRED = Decimal("100")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def cents(amount: Decimal) -> Decimal:
|
|
14
|
+
return amount.quantize(CENT, ROUND_HALF_UP)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def price_line(
|
|
18
|
+
code: str,
|
|
19
|
+
description: str,
|
|
20
|
+
quantity: Decimal,
|
|
21
|
+
unit_price: Decimal,
|
|
22
|
+
rate_code: str,
|
|
23
|
+
discount: Decimal = ZERO,
|
|
24
|
+
) -> Line:
|
|
25
|
+
rate = VAT_RATES.get(rate_code)
|
|
26
|
+
if rate is None:
|
|
27
|
+
raise VoucherInvalid(f"The SRI has no IVA rate with the code {rate_code!r}")
|
|
28
|
+
subtotal = cents(quantity * unit_price - discount)
|
|
29
|
+
return Line(
|
|
30
|
+
code=code,
|
|
31
|
+
description=description,
|
|
32
|
+
quantity=quantity,
|
|
33
|
+
unit_price=unit_price,
|
|
34
|
+
discount=discount,
|
|
35
|
+
subtotal=subtotal,
|
|
36
|
+
vat=cents(subtotal * rate / HUNDRED),
|
|
37
|
+
rate_code=rate_code,
|
|
38
|
+
rate_percentage=rate,
|
|
39
|
+
)
|