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 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
+ ]
@@ -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
@@ -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
+ )