pycurenergia 0.1.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.
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ .pytest_cache/
6
+ .coverage
7
+ htmlcov/
8
+ node_modules/
9
+ .superpowers/
10
+ *.pdf
11
+ !tests/fixtures/**/*.pdf
12
+ !lib/tests/fixtures/**/*.pdf
13
+ private/
14
+ dist/
15
+ .ruff_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 yuuues
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.
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.5
2
+ Name: pycurenergia
3
+ Version: 0.1.0
4
+ Summary: Parser de facturas en PDF de CurEnergía (proyecto no oficial).
5
+ Project-URL: Source, https://github.com/yuuues/hacs-curenergia
6
+ Project-URL: Issues, https://github.com/yuuues/hacs-curenergia/issues
7
+ Author: yuuues
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.13
17
+ Requires-Dist: pypdf>=6.0
18
+ Description-Content-Type: text/markdown
19
+
20
+ # pycurenergia
21
+
22
+ Librería Python que lee las facturas en PDF de CurEnergía (electricidad y gas)
23
+ y devuelve sus datos como objetos tipados. Todos los importes, kWh, m³ y
24
+ porcentajes son `decimal.Decimal`.
25
+
26
+ > Proyecto no oficial: no está afiliado, respaldado ni patrocinado por
27
+ > CurEnergía.
28
+
29
+ Licencia MIT.
30
+
31
+ ## Uso
32
+
33
+ ```python
34
+ from pycurenergia import check_consistency, parse_invoice_pdf
35
+
36
+ with open("factura.pdf", "rb") as f:
37
+ invoice = parse_invoice_pdf(f.read())
38
+
39
+ print(invoice.supply, invoice.total)
40
+ print(check_consistency(invoice)) # [] si los importes cuadran
41
+ ```
42
+
43
+ - `parse_invoice_pdf(data: bytes)` lee un PDF; `parse_invoice_text(text: str)`
44
+ parsea el texto ya extraído.
45
+ - Errores (todos derivan de `Exception`; ninguno incluye texto de la factura en
46
+ su mensaje):
47
+ - `InvoiceReadError`: el PDF no se puede leer (dañado, vacío, cifrado con
48
+ contraseña) o es demasiado grande (más de 10 MiB o de 20 páginas). Solo lo
49
+ lanza `parse_invoice_pdf`; el error original de pypdf queda en
50
+ `__cause__`.
51
+ - `UnsupportedInvoiceError`: el documento no es una factura de luz o de gas,
52
+ o es una factura rectificativa o de abono (no soportadas).
53
+ - `InvoiceParseError` (con el atributo `field`): falta algún dato obligatorio
54
+ o no tiene el formato esperado.
55
+ - `check_consistency(invoice)` devuelve una lista de `Problem`; vacía si todo
56
+ cuadra.
57
+ - `invoice.energy_price_kwh` es el precio de la energía en €/kWh, o `None` si
58
+ la factura no lo indica.
59
+
60
+ ## Publicar
61
+
62
+ El workflow `.github/workflows/lib-release.yml` construye y publica en PyPI al
63
+ subir una etiqueta `pycurenergia-v*` (por ejemplo `pycurenergia-v0.1.0`),
64
+ usando *trusted publishing* (sin tokens).
65
+
66
+ Paso manual previo, una sola vez:
67
+
68
+ 1. Crear la cuenta en [PyPI](https://pypi.org).
69
+ 2. En PyPI, añadir un *pending trusted publisher* con estos datos:
70
+ - Proyecto: `pycurenergia`
71
+ - Owner: `yuuues`
72
+ - Repositorio: `hacs-curenergia`
73
+ - Workflow: `lib-release.yml`
74
+ - Entorno: `pypi`
75
+ 3. Crear el entorno `pypi` en los ajustes del repositorio de GitHub y
76
+ configurar en él *required reviewers* (revisores obligatorios), de modo que
77
+ subir una etiqueta por sí solo no publique nada sin una aprobación manual.
78
+ 4. Etiquetar y subir: `git tag pycurenergia-v0.1.0 && git push origin pycurenergia-v0.1.0`.
@@ -0,0 +1,59 @@
1
+ # pycurenergia
2
+
3
+ Librería Python que lee las facturas en PDF de CurEnergía (electricidad y gas)
4
+ y devuelve sus datos como objetos tipados. Todos los importes, kWh, m³ y
5
+ porcentajes son `decimal.Decimal`.
6
+
7
+ > Proyecto no oficial: no está afiliado, respaldado ni patrocinado por
8
+ > CurEnergía.
9
+
10
+ Licencia MIT.
11
+
12
+ ## Uso
13
+
14
+ ```python
15
+ from pycurenergia import check_consistency, parse_invoice_pdf
16
+
17
+ with open("factura.pdf", "rb") as f:
18
+ invoice = parse_invoice_pdf(f.read())
19
+
20
+ print(invoice.supply, invoice.total)
21
+ print(check_consistency(invoice)) # [] si los importes cuadran
22
+ ```
23
+
24
+ - `parse_invoice_pdf(data: bytes)` lee un PDF; `parse_invoice_text(text: str)`
25
+ parsea el texto ya extraído.
26
+ - Errores (todos derivan de `Exception`; ninguno incluye texto de la factura en
27
+ su mensaje):
28
+ - `InvoiceReadError`: el PDF no se puede leer (dañado, vacío, cifrado con
29
+ contraseña) o es demasiado grande (más de 10 MiB o de 20 páginas). Solo lo
30
+ lanza `parse_invoice_pdf`; el error original de pypdf queda en
31
+ `__cause__`.
32
+ - `UnsupportedInvoiceError`: el documento no es una factura de luz o de gas,
33
+ o es una factura rectificativa o de abono (no soportadas).
34
+ - `InvoiceParseError` (con el atributo `field`): falta algún dato obligatorio
35
+ o no tiene el formato esperado.
36
+ - `check_consistency(invoice)` devuelve una lista de `Problem`; vacía si todo
37
+ cuadra.
38
+ - `invoice.energy_price_kwh` es el precio de la energía en €/kWh, o `None` si
39
+ la factura no lo indica.
40
+
41
+ ## Publicar
42
+
43
+ El workflow `.github/workflows/lib-release.yml` construye y publica en PyPI al
44
+ subir una etiqueta `pycurenergia-v*` (por ejemplo `pycurenergia-v0.1.0`),
45
+ usando *trusted publishing* (sin tokens).
46
+
47
+ Paso manual previo, una sola vez:
48
+
49
+ 1. Crear la cuenta en [PyPI](https://pypi.org).
50
+ 2. En PyPI, añadir un *pending trusted publisher* con estos datos:
51
+ - Proyecto: `pycurenergia`
52
+ - Owner: `yuuues`
53
+ - Repositorio: `hacs-curenergia`
54
+ - Workflow: `lib-release.yml`
55
+ - Entorno: `pypi`
56
+ 3. Crear el entorno `pypi` en los ajustes del repositorio de GitHub y
57
+ configurar en él *required reviewers* (revisores obligatorios), de modo que
58
+ subir una etiqueta por sí solo no publique nada sin una aprobación manual.
59
+ 4. Etiquetar y subir: `git tag pycurenergia-v0.1.0 && git push origin pycurenergia-v0.1.0`.
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pycurenergia"
7
+ version = "0.1.0"
8
+ description = "Parser de facturas en PDF de CurEnergía (proyecto no oficial)."
9
+ readme = "README.md"
10
+ authors = [{ name = "yuuues" }]
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ requires-python = ">=3.13"
14
+ dependencies = ["pypdf>=6.0"]
15
+ # Sin clasificador «License :: …»: PEP 639 lo sustituye por ``license`` (MIT) y
16
+ # PyPI rechaza combinarlo con una expresión de licencia.
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
23
+ "Typing :: Typed",
24
+ ]
25
+
26
+ [project.urls]
27
+ Source = "https://github.com/yuuues/hacs-curenergia"
28
+ Issues = "https://github.com/yuuues/hacs-curenergia/issues"
29
+
30
+ [dependency-groups]
31
+ dev = ["pytest>=8", "ruff>=0.6", "reportlab>=4"]
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/pycurenergia"]
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
38
+
39
+ [tool.ruff]
40
+ target-version = "py313"
41
+
42
+ [tool.ruff.lint]
43
+ select = ["E", "F", "I", "UP", "B"]
@@ -0,0 +1,226 @@
1
+ """Anonimiza el texto de una factura de CurEnergía para usarlo como fixture.
2
+
3
+ Uso:
4
+ anonymize.py PDF --out TXT [--secrets SECRETS]
5
+ anonymize.py --check TXT... [--secrets SECRETS]
6
+
7
+ Los valores reales y sus sustitutos se leen de ``private/secrets.toml``
8
+ (excluido de git)::
9
+
10
+ [replace]
11
+ "valor real" = "valor falso"
12
+
13
+ [drop]
14
+ patterns = ["regex que elimina la línea entera"]
15
+
16
+ El script no se empaqueta con la librería.
17
+ """
18
+
19
+ import argparse
20
+ import re
21
+ import sys
22
+ import tomllib
23
+ from pathlib import Path
24
+
25
+ from pycurenergia.text import extract_text
26
+
27
+ DEFAULT_SECRETS = Path(__file__).parents[2] / "private" / "secrets.toml"
28
+ FAKE_NIF = "00000000T"
29
+
30
+ _NIF_RE = re.compile(r"\b\d{8}[A-Z]\b")
31
+ _LONG_DIGITS_RE = re.compile(r"\d{20,}")
32
+ _WORD_RE = re.compile(r"[^\W\d_]{4,}")
33
+ _SYMBOL_CHARS = frozenset("\t[]{}<>@_=^~|\\")
34
+ _ALLOWED_CHARS = frozenset(
35
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
36
+ "áéíóúüñÁÉÍÓÚÜÑçǪº"
37
+ ".,:;()/%€-'\"*+&!?¿¡"
38
+ )
39
+
40
+
41
+ def _is_noise(line: str) -> bool:
42
+ """Indica si la línea es un código de barras o texto de una fuente de glifos."""
43
+ if _LONG_DIGITS_RE.search(line):
44
+ return True
45
+ visible = [c for c in line if not c.isspace()]
46
+ outside = sum(1 for c in visible if c not in _ALLOWED_CHARS)
47
+ if outside * 2 > len(visible):
48
+ return True
49
+ return not _WORD_RE.search(line) and any(c in _SYMBOL_CHARS for c in line)
50
+
51
+
52
+ def drop_noise_lines(text: str, patterns: list[str] | None = None) -> str:
53
+ """Elimina las líneas de códigos de barras y glifos sin sentido.
54
+
55
+ ``patterns`` son expresiones regulares adicionales: se elimina toda línea
56
+ en la que alguna de ellas encuentre coincidencia. Cada página (separada por
57
+ ``"\\f"``) se trata por separado para no perder los saltos de página, que
58
+ pypdf deja a veces en mitad de una línea.
59
+ """
60
+ extra = [re.compile(p) for p in patterns or []]
61
+
62
+ def keep(line: str) -> bool:
63
+ return not _is_noise(line) and not any(p.search(line) for p in extra)
64
+
65
+ pages = text.split("\f")
66
+ return "\f".join("\n".join(filter(keep, page.split("\n"))) for page in pages)
67
+
68
+
69
+ def apply_replacements(text: str, mapping: dict[str, str]) -> str:
70
+ """Sustituye cada valor real por su valor falso.
71
+
72
+ Las claves se prueban de la más larga a la más corta, en una sola pasada,
73
+ así que un valor falso nunca se vuelve a sustituir.
74
+ """
75
+ if not mapping:
76
+ return text
77
+ keys = sorted(mapping, key=len, reverse=True)
78
+ pattern = re.compile("|".join(re.escape(k) for k in keys))
79
+ return pattern.sub(lambda m: mapping[m.group(0)], text)
80
+
81
+
82
+ def mask_nif(text: str) -> str:
83
+ """Sustituye cualquier NIF (8 dígitos y una letra) por ``00000000T``."""
84
+ return _NIF_RE.sub(FAKE_NIF, text)
85
+
86
+
87
+ def find_leaks(text: str, mapping: dict[str, str]) -> list[tuple[int, str]]:
88
+ """Busca valores reales que hayan quedado en el texto.
89
+
90
+ Devuelve ``(nº de línea, valor real)``, ordenado por línea. Cada valor se
91
+ busca sin distinguir mayúsculas, tal cual y sin espacios en cada línea, y
92
+ además en todo el texto sin ningún espacio en blanco (saltos de línea y de
93
+ página, tabuladores y espacios duros incluidos), para detectar valores
94
+ partidos entre líneas; en ese caso la línea es la de su comienzo. También se
95
+ informa de cualquier NIF distinto de ``00000000T``.
96
+ """
97
+ needles = []
98
+ for real in mapping:
99
+ forms = {real.casefold(), real.replace(" ", "").casefold()}
100
+ needles.append((real, {f for f in forms if f}))
101
+ leaks = []
102
+ reported = set()
103
+ lines = text.split("\n")
104
+ for number, line in enumerate(lines, start=1):
105
+ folded = line.casefold()
106
+ for real, forms in needles:
107
+ if any(f in folded for f in forms):
108
+ leaks.append((number, real))
109
+ reported.add(real)
110
+ for nif in _NIF_RE.findall(line):
111
+ if nif != FAKE_NIF:
112
+ leaks.append((number, nif))
113
+
114
+ # Texto sin espacios en blanco, con la línea de origen de cada carácter.
115
+ chars = [
116
+ (folded, number)
117
+ for number, line in enumerate(lines, start=1)
118
+ for c in line
119
+ if not c.isspace()
120
+ for folded in c.casefold()
121
+ ]
122
+ squeezed = "".join(c for c, _ in chars)
123
+ for real in mapping:
124
+ needle = "".join(c for c in real if not c.isspace()).casefold()
125
+ if real in reported or not needle:
126
+ continue
127
+ index = squeezed.find(needle)
128
+ if index >= 0:
129
+ leaks.append((chars[index][1], real))
130
+ leaks.sort(key=lambda leak: leak[0])
131
+ return leaks
132
+
133
+
134
+ def load_secrets(path: Path) -> tuple[dict[str, str], list[str]]:
135
+ """Lee ``[replace]`` y ``[drop] patterns`` del fichero de secretos.
136
+
137
+ Lanza ``ValueError`` si el formato no es válido: ``[replace]`` debe ser una
138
+ tabla de cadenas no vacías y ``[drop] patterns`` una lista de cadenas no
139
+ vacías. Los mensajes nunca incluyen los valores del fichero.
140
+ """
141
+ with path.open("rb") as fh:
142
+ data = tomllib.load(fh)
143
+ error = f"{path.name} no válido: "
144
+ replace = data.get("replace", {})
145
+ if not isinstance(replace, dict):
146
+ raise ValueError(error + "[replace] debe ser una tabla")
147
+ for index, (real, fake) in enumerate(replace.items(), start=1):
148
+ if not real.strip() or not isinstance(fake, str) or not fake.strip():
149
+ raise ValueError(
150
+ error + f"la clave nº {index} de [replace] o su valor no es "
151
+ "una cadena no vacía"
152
+ )
153
+ drop = data.get("drop", {})
154
+ patterns = drop.get("patterns", []) if isinstance(drop, dict) else None
155
+ if not isinstance(patterns, list) or not all(
156
+ isinstance(p, str) and p for p in patterns
157
+ ):
158
+ raise ValueError(error + "[drop] patterns debe ser una lista de cadenas")
159
+ return dict(replace), list(patterns)
160
+
161
+
162
+ def anonymize(text: str, mapping: dict[str, str], patterns: list[str]) -> str:
163
+ """Aplica en orden: limpieza de ruido, sustituciones y máscara de NIF."""
164
+ return mask_nif(apply_replacements(drop_noise_lines(text, patterns), mapping))
165
+
166
+
167
+ def _report(name: str, leaks: list[tuple[int, str]], mapping: dict[str, str]) -> None:
168
+ """Informa de cada fuga sin mostrar nunca el valor real.
169
+
170
+ Un valor de ``[replace]`` se identifica por su posición en el fichero de
171
+ secretos; un NIF, solo por su longitud.
172
+ """
173
+ keys = list(mapping)
174
+ for number, value in leaks:
175
+ if value in mapping:
176
+ what = f"clave nº {keys.index(value) + 1} de [replace] sin anonimizar"
177
+ else:
178
+ what = f"NIF sin enmascarar ({len(value)} caracteres)"
179
+ print(f"{name}:{number}: {what}", file=sys.stderr)
180
+
181
+
182
+ def main(argv: list[str] | None = None) -> int:
183
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
184
+ parser.add_argument(
185
+ "inputs", nargs="+", type=Path, help="PDF a anonimizar, o TXT con --check"
186
+ )
187
+ parser.add_argument("--out", type=Path, help="fichero de texto de salida")
188
+ parser.add_argument(
189
+ "--check", action="store_true", help="solo comprueba fixtures ya generadas"
190
+ )
191
+ parser.add_argument("--secrets", type=Path, default=DEFAULT_SECRETS)
192
+ args = parser.parse_args(argv)
193
+ # La consola de Windows puede no ser UTF-8; mejor escapar que fallar al informar.
194
+ if hasattr(sys.stderr, "reconfigure"):
195
+ sys.stderr.reconfigure(errors="backslashreplace")
196
+ if not args.secrets.is_file():
197
+ parser.error(f"no existe el fichero de secretos {args.secrets}")
198
+
199
+ try:
200
+ mapping, patterns = load_secrets(args.secrets)
201
+ except ValueError as exc:
202
+ parser.error(str(exc))
203
+
204
+ if args.check:
205
+ failed = False
206
+ for path in args.inputs:
207
+ leaks = find_leaks(path.read_text(encoding="utf-8"), mapping)
208
+ _report(str(path), leaks, mapping)
209
+ failed = failed or bool(leaks)
210
+ return 1 if failed else 0
211
+
212
+ if len(args.inputs) != 1 or args.out is None:
213
+ parser.error("hay que indicar un único PDF y --out")
214
+ text = anonymize(extract_text(args.inputs[0].read_bytes()), mapping, patterns)
215
+ leaks = find_leaks(text, mapping)
216
+ if leaks:
217
+ _report(str(args.inputs[0]), leaks, mapping)
218
+ return 1
219
+ args.out.parent.mkdir(parents=True, exist_ok=True)
220
+ with args.out.open("w", encoding="utf-8", newline="\n") as fh:
221
+ fh.write(text)
222
+ return 0
223
+
224
+
225
+ if __name__ == "__main__":
226
+ sys.exit(main())
@@ -0,0 +1,25 @@
1
+ """Lector de facturas en PDF de CurEnergía."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ from .errors import InvoiceParseError, InvoiceReadError, UnsupportedInvoiceError
6
+ from .models import Concept, ConceptKey, Invoice, SupplyType
7
+ from .parser import parse_invoice_pdf, parse_invoice_text
8
+ from .validation import Problem, check_consistency
9
+
10
+ __version__ = version("pycurenergia")
11
+
12
+ __all__ = [
13
+ "Concept",
14
+ "ConceptKey",
15
+ "Invoice",
16
+ "InvoiceParseError",
17
+ "InvoiceReadError",
18
+ "Problem",
19
+ "SupplyType",
20
+ "UnsupportedInvoiceError",
21
+ "__version__",
22
+ "check_consistency",
23
+ "parse_invoice_pdf",
24
+ "parse_invoice_text",
25
+ ]
@@ -0,0 +1,52 @@
1
+ """Utilidades comunes a los lectores de facturas."""
2
+
3
+ import re
4
+ from collections.abc import Callable
5
+ from decimal import Decimal
6
+
7
+ from pycurenergia.errors import InvoiceParseError
8
+ from pycurenergia.numbers import parse_decimal
9
+
10
+ NUM = r"-?\d[\d.]*(?:,\d+)?"
11
+ """Patrón de un número en formato español (se interpreta con ``parse_decimal``)."""
12
+
13
+ BOL = r"(?:^|(?<=\f))"
14
+ """Inicio de línea (con ``re.MULTILINE``) o justo tras un ``\\f``.
15
+
16
+ Las páginas se unen con ``\\f``, que puede quedar en mitad de una línea.
17
+ """
18
+
19
+ CUPS = r"ES(?:[ \t]?\d){16}[ \t]?[A-Z]{2}(?:[ \t]?\d[A-Z])?\b"
20
+ """CUPS: ``ES``, 16 dígitos, 2 letras de control y sufijo opcional (dígito y
21
+ letra), con espacios o tabuladores sueltos entre grupos."""
22
+
23
+ LINE_BREAK_RE = re.compile(r"[\n\f]")
24
+ """Separa líneas tanto por ``\\n`` como por ``\\f`` (salto de página)."""
25
+
26
+
27
+ def compact(value: str) -> str:
28
+ """Quita todo el espacio en blanco de ``value`` (p. ej. el CUPS)."""
29
+ return re.sub(r"\s+", "", value)
30
+
31
+
32
+ def search(pattern: re.Pattern[str], text: str, field: str) -> re.Match[str]:
33
+ """Busca ``pattern`` o lanza ``InvoiceParseError`` indicando ``field``."""
34
+ match = pattern.search(text)
35
+ if match is None:
36
+ raise InvoiceParseError(field)
37
+ return match
38
+
39
+
40
+ def convert[T](parse: Callable[[str], T], value: str, field: str) -> T:
41
+ """Aplica ``parse`` a ``value``; un ``ValueError`` pasa a ``InvoiceParseError``."""
42
+ try:
43
+ return parse(value)
44
+ except ValueError as exc:
45
+ raise InvoiceParseError(
46
+ field, f"Valor no válido en {field!r}: {value!r}"
47
+ ) from exc
48
+
49
+
50
+ def to_decimal(value: str, field: str) -> Decimal:
51
+ """Convierte un número español a ``Decimal`` o lanza ``InvoiceParseError``."""
52
+ return convert(parse_decimal, value, field)