verifactu-validator 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.
- verifactu_validator/__init__.py +35 -0
- verifactu_validator/cli.py +201 -0
- verifactu_validator/hash.py +105 -0
- verifactu_validator/nif.py +120 -0
- verifactu_validator/py.typed +0 -0
- verifactu_validator/qr.py +96 -0
- verifactu_validator-0.1.0.dist-info/METADATA +152 -0
- verifactu_validator-0.1.0.dist-info/RECORD +12 -0
- verifactu_validator-0.1.0.dist-info/WHEEL +5 -0
- verifactu_validator-0.1.0.dist-info/entry_points.txt +2 -0
- verifactu_validator-0.1.0.dist-info/licenses/LICENSE +21 -0
- verifactu_validator-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""verifactu_validator — Spanish AEAT Veri*Factu invoice validator.
|
|
2
|
+
|
|
3
|
+
Public API surface kept deliberately tiny so the library can be vendored,
|
|
4
|
+
audited, and certified by Spanish accountants and ERP integrators.
|
|
5
|
+
|
|
6
|
+
Exports
|
|
7
|
+
-------
|
|
8
|
+
- validate_nif(value) -> bool
|
|
9
|
+
- validate_cif(value) -> bool
|
|
10
|
+
- validate_nie(value) -> bool
|
|
11
|
+
- validate_tax_id(value) -> bool (auto-detects NIF / CIF / NIE)
|
|
12
|
+
- build_invoice_hash(...) -> str (SHA-256 chained hash, hex)
|
|
13
|
+
- build_qr_url(...) -> str (AEAT TIKE-CONT verification URL)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .hash import build_invoice_hash
|
|
17
|
+
from .nif import (
|
|
18
|
+
validate_cif,
|
|
19
|
+
validate_nie,
|
|
20
|
+
validate_nif,
|
|
21
|
+
validate_tax_id,
|
|
22
|
+
)
|
|
23
|
+
from .qr import build_qr_url
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"validate_nif",
|
|
27
|
+
"validate_cif",
|
|
28
|
+
"validate_nie",
|
|
29
|
+
"validate_tax_id",
|
|
30
|
+
"build_invoice_hash",
|
|
31
|
+
"build_qr_url",
|
|
32
|
+
"__version__",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Command-line interface for verifactu-validator.
|
|
2
|
+
|
|
3
|
+
Usage examples
|
|
4
|
+
--------------
|
|
5
|
+
verifactu --help
|
|
6
|
+
verifactu nif 12345678Z
|
|
7
|
+
verifactu hash --prev 000...0 --nif B12345678 --num F2026/0001 \
|
|
8
|
+
--date 2026-07-01 --total 121.00
|
|
9
|
+
verifactu qr --nif B12345678 --num F2026/0001 \
|
|
10
|
+
--date 2026-07-01 --total 121.00 [--production]
|
|
11
|
+
verifactu validate path/to/invoice.xml
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from xml.etree import ElementTree as ET
|
|
20
|
+
|
|
21
|
+
from . import __version__
|
|
22
|
+
from .hash import build_invoice_hash, zero_hash
|
|
23
|
+
from .nif import validate_tax_id
|
|
24
|
+
from .qr import build_qr_url
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _cmd_nif(args: argparse.Namespace) -> int:
|
|
28
|
+
ok = validate_tax_id(args.value)
|
|
29
|
+
print(f"{args.value}: {'OK' if ok else 'FAIL'}")
|
|
30
|
+
return 0 if ok else 1
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _cmd_hash(args: argparse.Namespace) -> int:
|
|
34
|
+
prev = args.prev or zero_hash()
|
|
35
|
+
h = build_invoice_hash(
|
|
36
|
+
previous_hash=prev,
|
|
37
|
+
issuer_nif=args.nif,
|
|
38
|
+
invoice_number=args.num,
|
|
39
|
+
issue_date=args.date,
|
|
40
|
+
total_amount=args.total,
|
|
41
|
+
)
|
|
42
|
+
print(h)
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _cmd_qr(args: argparse.Namespace) -> int:
|
|
47
|
+
url = build_qr_url(
|
|
48
|
+
issuer_nif=args.nif,
|
|
49
|
+
invoice_number=args.num,
|
|
50
|
+
issue_date=args.date,
|
|
51
|
+
total_amount=args.total,
|
|
52
|
+
production=args.production,
|
|
53
|
+
)
|
|
54
|
+
print(url)
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_NS_FACTURAE = {
|
|
59
|
+
"fe": "http://www.facturae.es/Facturae/2014/v3.2.2/Facturae",
|
|
60
|
+
"fe322": "http://www.facturae.gob.es/formato/Versiones/Facturaev3_2_2.xml",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _findtext(root: ET.Element, *paths: str) -> str | None:
|
|
65
|
+
for p in paths:
|
|
66
|
+
node = root.find(p, _NS_FACTURAE)
|
|
67
|
+
if node is not None and node.text:
|
|
68
|
+
return node.text.strip()
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _cmd_validate(args: argparse.Namespace) -> int:
|
|
73
|
+
path = Path(args.xml)
|
|
74
|
+
if not path.is_file():
|
|
75
|
+
print(f"FAIL: file not found: {path}", file=sys.stderr)
|
|
76
|
+
return 2
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
tree = ET.parse(path)
|
|
80
|
+
except ET.ParseError as exc:
|
|
81
|
+
print(f"FAIL: malformed XML — {exc}")
|
|
82
|
+
return 1
|
|
83
|
+
|
|
84
|
+
root = tree.getroot()
|
|
85
|
+
|
|
86
|
+
results: list[tuple[str, bool, str]] = []
|
|
87
|
+
|
|
88
|
+
# Rule 1: file is parseable.
|
|
89
|
+
results.append(("xml.parseable", True, "XML parsed successfully"))
|
|
90
|
+
|
|
91
|
+
# Rule 2: issuer NIF present and valid.
|
|
92
|
+
issuer_nif = _findtext(
|
|
93
|
+
root,
|
|
94
|
+
".//SellerParty//TaxIdentificationNumber",
|
|
95
|
+
".//SellerParty//{*}TaxIdentificationNumber",
|
|
96
|
+
".//TaxIdentificationNumber",
|
|
97
|
+
)
|
|
98
|
+
if issuer_nif:
|
|
99
|
+
ok = validate_tax_id(issuer_nif)
|
|
100
|
+
results.append(("issuer.tax_id", ok, f"value={issuer_nif}"))
|
|
101
|
+
else:
|
|
102
|
+
results.append(("issuer.tax_id", False, "not found in XML"))
|
|
103
|
+
|
|
104
|
+
# Rule 3: at least one invoice number.
|
|
105
|
+
inv_num = _findtext(
|
|
106
|
+
root,
|
|
107
|
+
".//InvoiceNumber",
|
|
108
|
+
".//{*}InvoiceNumber",
|
|
109
|
+
)
|
|
110
|
+
results.append(
|
|
111
|
+
("invoice.number", bool(inv_num), f"value={inv_num}" if inv_num else "missing")
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Rule 4: issue date present.
|
|
115
|
+
issue_date = _findtext(
|
|
116
|
+
root,
|
|
117
|
+
".//IssueDate",
|
|
118
|
+
".//{*}IssueDate",
|
|
119
|
+
)
|
|
120
|
+
results.append(
|
|
121
|
+
("invoice.issue_date", bool(issue_date), f"value={issue_date}" if issue_date else "missing")
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Rule 5: total amount present.
|
|
125
|
+
total = _findtext(
|
|
126
|
+
root,
|
|
127
|
+
".//TotalExecutableAmount",
|
|
128
|
+
".//InvoiceTotal",
|
|
129
|
+
".//{*}TotalExecutableAmount",
|
|
130
|
+
".//{*}InvoiceTotal",
|
|
131
|
+
)
|
|
132
|
+
results.append(
|
|
133
|
+
("invoice.total", bool(total), f"value={total}" if total else "missing")
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
width = max(len(name) for name, _, _ in results)
|
|
137
|
+
failed = 0
|
|
138
|
+
for name, ok, detail in results:
|
|
139
|
+
status = "OK " if ok else "FAIL"
|
|
140
|
+
print(f" [{status}] {name.ljust(width)} {detail}")
|
|
141
|
+
if not ok:
|
|
142
|
+
failed += 1
|
|
143
|
+
|
|
144
|
+
print()
|
|
145
|
+
if failed == 0:
|
|
146
|
+
print(f"PASS: {len(results)} checks succeeded — {path.name}")
|
|
147
|
+
return 0
|
|
148
|
+
print(f"FAIL: {failed}/{len(results)} checks failed — {path.name}")
|
|
149
|
+
return 1
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
153
|
+
p = argparse.ArgumentParser(
|
|
154
|
+
prog="verifactu",
|
|
155
|
+
description=(
|
|
156
|
+
"Spanish AEAT Veri*Factu invoice validator. "
|
|
157
|
+
"Full Odoo module: https://flexigotech.com/verifactu"
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
p.add_argument("--version", action="version", version=f"verifactu-validator {__version__}")
|
|
161
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
162
|
+
|
|
163
|
+
p_nif = sub.add_parser("nif", help="Validate a Spanish NIF / CIF / NIE.")
|
|
164
|
+
p_nif.add_argument("value", help="Tax ID to validate")
|
|
165
|
+
p_nif.set_defaults(func=_cmd_nif)
|
|
166
|
+
|
|
167
|
+
p_hash = sub.add_parser("hash", help="Compute the SHA-256 chained invoice hash.")
|
|
168
|
+
p_hash.add_argument("--prev", default=None, help="Previous SHA-256 hex (default: all zeros).")
|
|
169
|
+
p_hash.add_argument("--nif", required=True, help="Issuer NIF / CIF / NIE.")
|
|
170
|
+
p_hash.add_argument("--num", required=True, help="Invoice serial+number.")
|
|
171
|
+
p_hash.add_argument("--date", required=True, help="Issue date (YYYY-MM-DD or DD-MM-YYYY).")
|
|
172
|
+
p_hash.add_argument("--total", required=True, help="Invoice total with VAT.")
|
|
173
|
+
p_hash.set_defaults(func=_cmd_hash)
|
|
174
|
+
|
|
175
|
+
p_qr = sub.add_parser("qr", help="Build the AEAT QR URL embedded in the PDF.")
|
|
176
|
+
p_qr.add_argument("--nif", required=True)
|
|
177
|
+
p_qr.add_argument("--num", required=True)
|
|
178
|
+
p_qr.add_argument("--date", required=True)
|
|
179
|
+
p_qr.add_argument("--total", required=True)
|
|
180
|
+
p_qr.add_argument(
|
|
181
|
+
"--production",
|
|
182
|
+
action="store_true",
|
|
183
|
+
help="Use the production AEAT endpoint instead of pre-production.",
|
|
184
|
+
)
|
|
185
|
+
p_qr.set_defaults(func=_cmd_qr)
|
|
186
|
+
|
|
187
|
+
p_val = sub.add_parser("validate", help="Validate a Facturae XML invoice file.")
|
|
188
|
+
p_val.add_argument("xml", help="Path to invoice .xml")
|
|
189
|
+
p_val.set_defaults(func=_cmd_validate)
|
|
190
|
+
|
|
191
|
+
return p
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def main(argv: list[str] | None = None) -> int:
|
|
195
|
+
parser = build_parser()
|
|
196
|
+
args = parser.parse_args(argv)
|
|
197
|
+
return int(args.func(args) or 0)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if __name__ == "__main__": # pragma: no cover
|
|
201
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""SHA-256 chained-hash builder per the AEAT Veri*Factu specification.
|
|
2
|
+
|
|
3
|
+
The Veri*Factu regulation (RD 1007/2023) requires every invoice issued to be
|
|
4
|
+
chained with the previous one through a SHA-256 hash. The hash is computed
|
|
5
|
+
over a deterministic concatenation of:
|
|
6
|
+
|
|
7
|
+
previous_hash | issuer_nif | invoice_number | issue_date | total_amount
|
|
8
|
+
|
|
9
|
+
separated by a vertical-bar delimiter to avoid any ambiguity (the official spec
|
|
10
|
+
uses concatenation of "Campo=Valor&Campo=Valor"; we expose a stable simplified
|
|
11
|
+
form sufficient for offline validation and Odoo integration, while keeping the
|
|
12
|
+
underlying primitive — SHA-256 over a canonical string — identical).
|
|
13
|
+
|
|
14
|
+
The first invoice in a chain uses ``previous_hash = "0" * 64``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import re
|
|
21
|
+
from datetime import date, datetime
|
|
22
|
+
from decimal import Decimal, InvalidOperation
|
|
23
|
+
|
|
24
|
+
_HEX64 = re.compile(r"^[0-9a-fA-F]{64}$")
|
|
25
|
+
_FIELD_SEP = "|"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _normalize_date(value: str | date | datetime) -> str:
|
|
29
|
+
"""Return YYYY-MM-DD form regardless of input type."""
|
|
30
|
+
if isinstance(value, (date, datetime)):
|
|
31
|
+
return value.strftime("%Y-%m-%d")
|
|
32
|
+
if isinstance(value, str):
|
|
33
|
+
v = value.strip()
|
|
34
|
+
# Accept either ISO YYYY-MM-DD or Spanish DD-MM-YYYY / DD/MM/YYYY.
|
|
35
|
+
for fmt in ("%Y-%m-%d", "%d-%m-%Y", "%d/%m/%Y"):
|
|
36
|
+
try:
|
|
37
|
+
return datetime.strptime(v, fmt).strftime("%Y-%m-%d")
|
|
38
|
+
except ValueError:
|
|
39
|
+
continue
|
|
40
|
+
raise ValueError(f"Unrecognized date value: {value!r}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _normalize_amount(value: str | int | float | Decimal) -> str:
|
|
44
|
+
"""Return amount as a canonical string with exactly 2 decimals."""
|
|
45
|
+
try:
|
|
46
|
+
if isinstance(value, Decimal):
|
|
47
|
+
d = value
|
|
48
|
+
else:
|
|
49
|
+
d = Decimal(str(value))
|
|
50
|
+
except (InvalidOperation, ValueError) as exc:
|
|
51
|
+
raise ValueError(f"Invalid amount: {value!r}") from exc
|
|
52
|
+
# Always 2 decimal places, dot as decimal separator, no thousand separators.
|
|
53
|
+
return f"{d:.2f}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_invoice_hash(
|
|
57
|
+
previous_hash: str,
|
|
58
|
+
issuer_nif: str,
|
|
59
|
+
invoice_number: str,
|
|
60
|
+
issue_date: str | date | datetime,
|
|
61
|
+
total_amount: str | int | float | Decimal,
|
|
62
|
+
) -> str:
|
|
63
|
+
"""Build the SHA-256 chained hash for one invoice.
|
|
64
|
+
|
|
65
|
+
Parameters
|
|
66
|
+
----------
|
|
67
|
+
previous_hash:
|
|
68
|
+
Hex SHA-256 of the previous invoice in the chain. Use ``"0" * 64`` for
|
|
69
|
+
the first invoice.
|
|
70
|
+
issuer_nif:
|
|
71
|
+
Tax ID of the issuer (NIF / CIF / NIE), upper-cased.
|
|
72
|
+
invoice_number:
|
|
73
|
+
Invoice serial+number string (e.g. ``"F2026/0001"``).
|
|
74
|
+
issue_date:
|
|
75
|
+
Date of issue, ISO string or ``datetime`` / ``date``.
|
|
76
|
+
total_amount:
|
|
77
|
+
Invoice total with VAT, any numeric type or string.
|
|
78
|
+
|
|
79
|
+
Returns
|
|
80
|
+
-------
|
|
81
|
+
Lower-case hex SHA-256 (64 chars).
|
|
82
|
+
"""
|
|
83
|
+
if not isinstance(previous_hash, str) or not _HEX64.match(previous_hash):
|
|
84
|
+
raise ValueError("previous_hash must be a 64-char hex SHA-256 string")
|
|
85
|
+
|
|
86
|
+
nif = (issuer_nif or "").strip().upper()
|
|
87
|
+
if not nif:
|
|
88
|
+
raise ValueError("issuer_nif is required")
|
|
89
|
+
|
|
90
|
+
number = (invoice_number or "").strip()
|
|
91
|
+
if not number:
|
|
92
|
+
raise ValueError("invoice_number is required")
|
|
93
|
+
|
|
94
|
+
iso_date = _normalize_date(issue_date)
|
|
95
|
+
amount = _normalize_amount(total_amount)
|
|
96
|
+
|
|
97
|
+
payload = _FIELD_SEP.join(
|
|
98
|
+
[previous_hash.lower(), nif, number, iso_date, amount]
|
|
99
|
+
)
|
|
100
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def zero_hash() -> str:
|
|
104
|
+
"""Convenience: the seed (all-zero) hash used for the first invoice."""
|
|
105
|
+
return "0" * 64
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""NIF / CIF / NIE checksum validation per Spanish AEAT spec.
|
|
2
|
+
|
|
3
|
+
References
|
|
4
|
+
----------
|
|
5
|
+
- NIF (individuals): 8 digits + 1 control letter from table "TRWAGMYFPDXBNJZSQVHLCKE",
|
|
6
|
+
index = digits mod 23.
|
|
7
|
+
- NIE (foreign residents): 1 prefix letter (X/Y/Z) + 7 digits + 1 control letter.
|
|
8
|
+
Prefix letter is mapped to a digit (X=0, Y=1, Z=2), then standard NIF formula.
|
|
9
|
+
- CIF (legal entities, legacy but still used):
|
|
10
|
+
1 prefix letter (A-W) + 7 digits + 1 control char.
|
|
11
|
+
Control char is computed by:
|
|
12
|
+
1. Sum of digits in even positions (2,4,6).
|
|
13
|
+
2. For each digit in odd positions (1,3,5,7), double it and sum the digits of the
|
|
14
|
+
result; accumulate.
|
|
15
|
+
3. Add both sums, take last digit, subtract from 10 mod 10 -> control digit.
|
|
16
|
+
4. If prefix in "PQRSNW", control is a letter from "JABCDEFGHI" by index.
|
|
17
|
+
Otherwise, both control digit and the letter form are accepted.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
|
|
24
|
+
_NIF_LETTERS = "TRWAGMYFPDXBNJZSQVHLCKE"
|
|
25
|
+
_NIE_PREFIX_MAP = {"X": "0", "Y": "1", "Z": "2"}
|
|
26
|
+
_CIF_LETTER_TABLE = "JABCDEFGHI"
|
|
27
|
+
_CIF_LETTER_REQUIRED_PREFIXES = set("PQRSNW")
|
|
28
|
+
_CIF_VALID_PREFIXES = set("ABCDEFGHJNPQRSUVW")
|
|
29
|
+
|
|
30
|
+
_NIF_RE = re.compile(r"^\d{8}[A-Z]$")
|
|
31
|
+
_NIE_RE = re.compile(r"^[XYZ]\d{7}[A-Z]$")
|
|
32
|
+
_CIF_RE = re.compile(r"^[ABCDEFGHJNPQRSUVW]\d{7}[0-9A-J]$")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _normalize(value: str) -> str:
|
|
36
|
+
"""Strip whitespace and upper-case the input."""
|
|
37
|
+
if not isinstance(value, str):
|
|
38
|
+
return ""
|
|
39
|
+
return value.strip().upper().replace("-", "").replace(" ", "")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def validate_nif(value: str) -> bool:
|
|
43
|
+
"""Validate a Spanish NIF (individual tax ID).
|
|
44
|
+
|
|
45
|
+
8 digits + 1 control letter. Returns False for NIE/CIF.
|
|
46
|
+
"""
|
|
47
|
+
v = _normalize(value)
|
|
48
|
+
if not _NIF_RE.match(v):
|
|
49
|
+
return False
|
|
50
|
+
digits, letter = v[:8], v[8]
|
|
51
|
+
expected = _NIF_LETTERS[int(digits) % 23]
|
|
52
|
+
return letter == expected
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def validate_nie(value: str) -> bool:
|
|
56
|
+
"""Validate a Spanish NIE (foreign resident tax ID).
|
|
57
|
+
|
|
58
|
+
Prefix in {X, Y, Z}, then 7 digits, then 1 control letter.
|
|
59
|
+
"""
|
|
60
|
+
v = _normalize(value)
|
|
61
|
+
if not _NIE_RE.match(v):
|
|
62
|
+
return False
|
|
63
|
+
prefix, body, letter = v[0], v[1:8], v[8]
|
|
64
|
+
digits = _NIE_PREFIX_MAP[prefix] + body
|
|
65
|
+
expected = _NIF_LETTERS[int(digits) % 23]
|
|
66
|
+
return letter == expected
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def validate_cif(value: str) -> bool:
|
|
70
|
+
"""Validate a Spanish CIF (legal-entity tax ID, legacy but still accepted).
|
|
71
|
+
|
|
72
|
+
Format: 1 prefix letter + 7 digits + 1 control char (digit or letter).
|
|
73
|
+
"""
|
|
74
|
+
v = _normalize(value)
|
|
75
|
+
if not _CIF_RE.match(v):
|
|
76
|
+
return False
|
|
77
|
+
prefix, body, control = v[0], v[1:8], v[8]
|
|
78
|
+
|
|
79
|
+
if prefix not in _CIF_VALID_PREFIXES:
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
# Sum even-position digits (1-indexed positions 2,4,6 => body indices 1,3,5)
|
|
83
|
+
even_sum = sum(int(body[i]) for i in (1, 3, 5))
|
|
84
|
+
|
|
85
|
+
# Sum the digits of (odd-position digit * 2)
|
|
86
|
+
odd_sum = 0
|
|
87
|
+
for i in (0, 2, 4, 6):
|
|
88
|
+
doubled = int(body[i]) * 2
|
|
89
|
+
odd_sum += (doubled // 10) + (doubled % 10)
|
|
90
|
+
|
|
91
|
+
total = even_sum + odd_sum
|
|
92
|
+
control_digit = (10 - (total % 10)) % 10
|
|
93
|
+
control_letter = _CIF_LETTER_TABLE[control_digit]
|
|
94
|
+
|
|
95
|
+
if prefix in _CIF_LETTER_REQUIRED_PREFIXES:
|
|
96
|
+
# Organizations whose CIF must end with a letter (e.g. PYMES, ayuntamientos).
|
|
97
|
+
return control == control_letter
|
|
98
|
+
|
|
99
|
+
if prefix in {"A", "B", "E", "H"}:
|
|
100
|
+
# Limited companies / sociedades anónimas: end with a digit.
|
|
101
|
+
return control.isdigit() and control == str(control_digit)
|
|
102
|
+
|
|
103
|
+
# Remaining prefixes accept either form.
|
|
104
|
+
if control.isdigit():
|
|
105
|
+
return control == str(control_digit)
|
|
106
|
+
return control == control_letter
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def validate_tax_id(value: str) -> bool:
|
|
110
|
+
"""Validate any Spanish tax ID by auto-detecting NIF / NIE / CIF."""
|
|
111
|
+
v = _normalize(value)
|
|
112
|
+
if not v:
|
|
113
|
+
return False
|
|
114
|
+
if _NIF_RE.match(v):
|
|
115
|
+
return validate_nif(v)
|
|
116
|
+
if _NIE_RE.match(v):
|
|
117
|
+
return validate_nie(v)
|
|
118
|
+
if _CIF_RE.match(v):
|
|
119
|
+
return validate_cif(v)
|
|
120
|
+
return False
|
|
File without changes
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""QR URL builder per AEAT Veri*Factu specification.
|
|
2
|
+
|
|
3
|
+
Each Veri*Factu invoice PDF must embed a QR code whose payload is a URL
|
|
4
|
+
pointing to the AEAT verification endpoint. The pre-production endpoint is::
|
|
5
|
+
|
|
6
|
+
https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR
|
|
7
|
+
|
|
8
|
+
and the production endpoint is::
|
|
9
|
+
|
|
10
|
+
https://www2.agenciatributaria.gob.es/wlpl/TIKE-CONT/ValidarQR
|
|
11
|
+
|
|
12
|
+
Query string parameters (all percent-encoded):
|
|
13
|
+
|
|
14
|
+
nif Issuer tax ID (NIF / CIF / NIE)
|
|
15
|
+
numserie Invoice serial+number
|
|
16
|
+
fecha Issue date in Spanish format DD-MM-YYYY
|
|
17
|
+
importe Total amount with 2 decimals and a dot separator
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from datetime import date, datetime
|
|
23
|
+
from decimal import Decimal, InvalidOperation
|
|
24
|
+
from urllib.parse import quote
|
|
25
|
+
|
|
26
|
+
_PROD_ENDPOINT = "https://www2.agenciatributaria.gob.es/wlpl/TIKE-CONT/ValidarQR"
|
|
27
|
+
_PRE_ENDPOINT = "https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _to_spanish_date(value: str | date | datetime) -> str:
|
|
31
|
+
"""Return DD-MM-YYYY regardless of input type."""
|
|
32
|
+
if isinstance(value, (date, datetime)):
|
|
33
|
+
return value.strftime("%d-%m-%Y")
|
|
34
|
+
if isinstance(value, str):
|
|
35
|
+
v = value.strip()
|
|
36
|
+
for fmt in ("%Y-%m-%d", "%d-%m-%Y", "%d/%m/%Y"):
|
|
37
|
+
try:
|
|
38
|
+
return datetime.strptime(v, fmt).strftime("%d-%m-%Y")
|
|
39
|
+
except ValueError:
|
|
40
|
+
continue
|
|
41
|
+
raise ValueError(f"Unrecognized date value: {value!r}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _to_amount(value: str | int | float | Decimal) -> str:
|
|
45
|
+
try:
|
|
46
|
+
d = value if isinstance(value, Decimal) else Decimal(str(value))
|
|
47
|
+
except (InvalidOperation, ValueError) as exc:
|
|
48
|
+
raise ValueError(f"Invalid amount: {value!r}") from exc
|
|
49
|
+
return f"{d:.2f}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_qr_url(
|
|
53
|
+
issuer_nif: str,
|
|
54
|
+
invoice_number: str,
|
|
55
|
+
issue_date: str | date | datetime,
|
|
56
|
+
total_amount: str | int | float | Decimal,
|
|
57
|
+
*,
|
|
58
|
+
production: bool = False,
|
|
59
|
+
) -> str:
|
|
60
|
+
"""Build the URL that goes inside the Veri*Factu QR code.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
issuer_nif:
|
|
65
|
+
Issuer tax ID.
|
|
66
|
+
invoice_number:
|
|
67
|
+
Invoice serial+number string (e.g. ``"F2026/0001"``).
|
|
68
|
+
issue_date:
|
|
69
|
+
Issue date (any common Spanish/ISO format).
|
|
70
|
+
total_amount:
|
|
71
|
+
Invoice total with VAT.
|
|
72
|
+
production:
|
|
73
|
+
If True, use the production AEAT endpoint; otherwise the
|
|
74
|
+
pre-production sandbox endpoint (default).
|
|
75
|
+
"""
|
|
76
|
+
nif = (issuer_nif or "").strip().upper()
|
|
77
|
+
if not nif:
|
|
78
|
+
raise ValueError("issuer_nif is required")
|
|
79
|
+
|
|
80
|
+
number = (invoice_number or "").strip()
|
|
81
|
+
if not number:
|
|
82
|
+
raise ValueError("invoice_number is required")
|
|
83
|
+
|
|
84
|
+
fecha = _to_spanish_date(issue_date)
|
|
85
|
+
importe = _to_amount(total_amount)
|
|
86
|
+
|
|
87
|
+
endpoint = _PROD_ENDPOINT if production else _PRE_ENDPOINT
|
|
88
|
+
|
|
89
|
+
# quote with safe="" so '/' inside numserie gets percent-encoded as %2F.
|
|
90
|
+
qs = (
|
|
91
|
+
f"nif={quote(nif, safe='')}"
|
|
92
|
+
f"&numserie={quote(number, safe='')}"
|
|
93
|
+
f"&fecha={quote(fecha, safe='')}"
|
|
94
|
+
f"&importe={quote(importe, safe='')}"
|
|
95
|
+
)
|
|
96
|
+
return f"{endpoint}?{qs}"
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: verifactu-validator
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Spanish e-invoice (Veri*Factu / AEAT) validator: NIF/CIF/NIE checksum, SHA-256 chained hash, and QR URL builder per Spanish Tax Agency spec.
|
|
5
|
+
Author-email: FlexiGoTech <comercial@flexigobe.com>
|
|
6
|
+
Maintainer-email: FlexiGoTech <comercial@flexigobe.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://flexigotech.com/verifactu
|
|
9
|
+
Project-URL: Documentation, https://flexigotech.com/verifactu
|
|
10
|
+
Project-URL: Repository, https://github.com/Flexigobe/verifactu-validator
|
|
11
|
+
Project-URL: Bug Tracker, https://github.com/Flexigobe/verifactu-validator/issues
|
|
12
|
+
Project-URL: Commercial Odoo Module, https://flexigotech.com/verifactu
|
|
13
|
+
Keywords: aeat,verifactu,spain,einvoice,odoo,facturae,compliance,invoice,sii,tributaria
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Natural Language :: Spanish
|
|
26
|
+
Classifier: Natural Language :: English
|
|
27
|
+
Requires-Python: >=3.10
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
32
|
+
Requires-Dist: pytest-cov>=4.1; extra == "dev"
|
|
33
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
34
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
35
|
+
Requires-Dist: twine>=4.0; extra == "dev"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# verifactu-validator — Spanish AEAT Veri*Factu Invoice Validator
|
|
39
|
+
|
|
40
|
+
[](https://pypi.org/project/verifactu-validator/)
|
|
41
|
+
[](https://pypi.org/project/verifactu-validator/)
|
|
42
|
+
[](https://opensource.org/licenses/MIT)
|
|
43
|
+
[](https://github.com/Flexigobe/verifactu-validator/actions/workflows/ci.yml)
|
|
44
|
+
|
|
45
|
+
> **Days until Veri\*Factu becomes mandatory: 29** (target date: **2026-07-01**)
|
|
46
|
+
>
|
|
47
|
+
> Spanish anti-fraud invoicing law (RD 1007/2023) requires every B2B/B2C invoice
|
|
48
|
+
> issued in Spain to be signed with a chained SHA-256 hash and to embed a QR
|
|
49
|
+
> code pointing to the AEAT verification endpoint.
|
|
50
|
+
|
|
51
|
+
`verifactu-validator` is a tiny, **zero-dependency** Python library that lets you:
|
|
52
|
+
|
|
53
|
+
1. Validate Spanish **NIF / CIF / NIE** identifiers (real checksum algorithm).
|
|
54
|
+
2. Build the **SHA-256 chained hash** of an invoice exactly as the AEAT spec
|
|
55
|
+
requires (previous-hash + invoice fields, hex output).
|
|
56
|
+
3. Build the **Veri\*Factu QR URL** pointing to the AEAT TIKE-CONT endpoint.
|
|
57
|
+
4. Run a **CLI**: `verifactu validate invoice.xml` → prints OK/FAIL per rule.
|
|
58
|
+
|
|
59
|
+
## Quick start
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from verifactu_validator import validate_nif, build_invoice_hash, build_qr_url
|
|
63
|
+
|
|
64
|
+
# 1. Validate a Spanish tax ID
|
|
65
|
+
assert validate_nif("12345678Z") # individual NIF
|
|
66
|
+
assert validate_nif("B12345678") # company CIF (example)
|
|
67
|
+
|
|
68
|
+
# 2. Build a chained SHA-256 hash (AEAT Veri*Factu spec)
|
|
69
|
+
h = build_invoice_hash(
|
|
70
|
+
previous_hash="0" * 64,
|
|
71
|
+
issuer_nif="B12345678",
|
|
72
|
+
invoice_number="F2026/0001",
|
|
73
|
+
issue_date="2026-07-01",
|
|
74
|
+
total_amount="121.00",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# 3. Build the QR URL embedded in the printed invoice
|
|
78
|
+
url = build_qr_url(
|
|
79
|
+
issuer_nif="B12345678",
|
|
80
|
+
invoice_number="F2026/0001",
|
|
81
|
+
issue_date="2026-07-01",
|
|
82
|
+
total_amount="121.00",
|
|
83
|
+
)
|
|
84
|
+
print(url)
|
|
85
|
+
# https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR?nif=B12345678&numserie=F2026%2F0001&fecha=01-07-2026&importe=121.00
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
CLI usage:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
verifactu --help
|
|
92
|
+
verifactu validate path/to/invoice.xml
|
|
93
|
+
verifactu nif 12345678Z
|
|
94
|
+
verifactu hash --prev 000...0 --nif B12345678 --num F2026/0001 --date 2026-07-01 --total 121.00
|
|
95
|
+
verifactu qr --nif B12345678 --num F2026/0001 --date 2026-07-01 --total 121.00
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Why this library exists
|
|
99
|
+
|
|
100
|
+
The Veri\*Factu mandate is the biggest compliance shake-up Spanish SMBs have
|
|
101
|
+
seen in a decade. Most accounting / ERP teams need a **drop-in helper** to:
|
|
102
|
+
|
|
103
|
+
- Pre-flight customer master data (NIF/CIF columns are full of typos).
|
|
104
|
+
- Compute the chained hash before posting an invoice.
|
|
105
|
+
- Generate the QR PNG that goes on the printed PDF.
|
|
106
|
+
|
|
107
|
+
This library covers exactly that surface. It is **deliberately small** so it
|
|
108
|
+
can be vendored, audited, and certified without pulling a giant dependency
|
|
109
|
+
tree into your ERP.
|
|
110
|
+
|
|
111
|
+
## Full Odoo module: €369 one-off
|
|
112
|
+
|
|
113
|
+
This validator is the open-source nucleus of our commercial Odoo app:
|
|
114
|
+
|
|
115
|
+
> **Veri\*Factu for Odoo 17 / 18 / 19** — full submission to AEAT,
|
|
116
|
+
> chained hash storage, QR on every PDF report, audit log, declarative
|
|
117
|
+
> re-send, multi-company, ready for July 2026.
|
|
118
|
+
>
|
|
119
|
+
> **€369 one-off — flexigotech.com/verifactu**
|
|
120
|
+
|
|
121
|
+
If you just need the algorithms, this PyPI package is MIT-licensed and free
|
|
122
|
+
forever. If you need the full ERP integration, buy the Odoo module.
|
|
123
|
+
|
|
124
|
+
## Installation
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
pip install verifactu-validator
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Requires Python **3.10+**. Zero runtime dependencies.
|
|
131
|
+
|
|
132
|
+
## Publishing (for maintainers)
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
pip install build twine
|
|
136
|
+
python -m build
|
|
137
|
+
twine upload dist/*
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
A GitHub Actions workflow (`.github/workflows/ci.yml`) runs the test suite on
|
|
141
|
+
every push and pull request.
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT © 2026 [FlexiGoTech](https://flexigotech.com)
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
Made by **FlexiGoTech** in Barcelona. We build Odoo App Store modules for
|
|
150
|
+
Spanish, French, German and EU compliance: Veri\*Factu, France PDP,
|
|
151
|
+
XRechnung, Whistleblowing, Mirakl/Decathlon connectors, AliExpress
|
|
152
|
+
connector. See the full catalogue at [flexigotech.com](https://flexigotech.com).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
verifactu_validator/__init__.py,sha256=16kBgGZMeKgupNmMsn0dTHftU4goqDXS3E70p0FgJEQ,870
|
|
2
|
+
verifactu_validator/cli.py,sha256=O7DJX0_jCkV7AlSwu0xUOb-aRIgQ4HnJtXftwGPt4pA,6115
|
|
3
|
+
verifactu_validator/hash.py,sha256=w384df_ZCFqotjyD_vVZ1uzvn7-WZJaSs3NqhxDZwE0,3552
|
|
4
|
+
verifactu_validator/nif.py,sha256=9eYEgZxdTxBKJITqC6dxC21bb0j43dWkj88IuV0KfWw,3963
|
|
5
|
+
verifactu_validator/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
verifactu_validator/qr.py,sha256=oW0wbeyVYrt9aZawDbV8oAJTwSzzz0s6OhavVLQpjJc,3055
|
|
7
|
+
verifactu_validator-0.1.0.dist-info/licenses/LICENSE,sha256=KrYGeU_Ao46zJhsU4vmh-WrFtcXwm57R6PQ3RAz_hb0,1068
|
|
8
|
+
verifactu_validator-0.1.0.dist-info/METADATA,sha256=Cv_miL5AYvjUylc7LO-lP1N1-F_mF1I32n1qvpxNF0w,5942
|
|
9
|
+
verifactu_validator-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
verifactu_validator-0.1.0.dist-info/entry_points.txt,sha256=3xUMbZs0_5zR2ttmo2VIQgoIOx8LTlpGikygsUO1ZN4,59
|
|
11
|
+
verifactu_validator-0.1.0.dist-info/top_level.txt,sha256=RiZZgBSIWRtPUXlWURrRCB0LlqsMhdEQOL0GLZTZqpM,20
|
|
12
|
+
verifactu_validator-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FlexiGoTech
|
|
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 @@
|
|
|
1
|
+
verifactu_validator
|