faturapt 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.
- faturapt/__init__.py +22 -0
- faturapt/__main__.py +53 -0
- faturapt/image.py +61 -0
- faturapt/nif.py +91 -0
- faturapt/py.typed +0 -0
- faturapt/qr.py +341 -0
- faturapt-0.1.0.dist-info/METADATA +102 -0
- faturapt-0.1.0.dist-info/RECORD +11 -0
- faturapt-0.1.0.dist-info/WHEEL +4 -0
- faturapt-0.1.0.dist-info/entry_points.txt +2 -0
- faturapt-0.1.0.dist-info/licenses/LICENSE +21 -0
faturapt/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Leitura e validação do código QR das faturas portuguesas e de NIFs."""
|
|
2
|
+
|
|
3
|
+
from .image import read_image
|
|
4
|
+
from .nif import InvalidNIF, NifType, is_valid_nif, nif_type, normalize_nif
|
|
5
|
+
from .qr import DOCUMENT_TYPES, InvoiceQR, QRCodeError, TaxBreakdown, check, parse
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"DOCUMENT_TYPES",
|
|
9
|
+
"InvalidNIF",
|
|
10
|
+
"InvoiceQR",
|
|
11
|
+
"NifType",
|
|
12
|
+
"QRCodeError",
|
|
13
|
+
"TaxBreakdown",
|
|
14
|
+
"check",
|
|
15
|
+
"is_valid_nif",
|
|
16
|
+
"nif_type",
|
|
17
|
+
"normalize_nif",
|
|
18
|
+
"parse",
|
|
19
|
+
"read_image",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
faturapt/__main__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""``faturapt "A:...*B:..."`` ou ``faturapt -i talao.jpg``: mostra a fatura em JSON e os
|
|
2
|
+
problemas encontrados."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import asdict
|
|
10
|
+
from datetime import date
|
|
11
|
+
from decimal import Decimal
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .image import read_image
|
|
15
|
+
from .qr import QRCodeError, check, parse
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _json(value: Any) -> Any:
|
|
19
|
+
if isinstance(value, Decimal):
|
|
20
|
+
return str(value)
|
|
21
|
+
if isinstance(value, date):
|
|
22
|
+
return value.isoformat()
|
|
23
|
+
raise TypeError(type(value).__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main(argv: list[str] | None = None) -> int:
|
|
27
|
+
parser = argparse.ArgumentParser(prog="faturapt", description=__doc__)
|
|
28
|
+
source = parser.add_mutually_exclusive_group()
|
|
29
|
+
source.add_argument("qr", nargs="?", help="texto do código QR (por omissão, lido do stdin)")
|
|
30
|
+
source.add_argument(
|
|
31
|
+
"-i", "--imagem", help="fotografia ou digitalização da fatura (precisa de faturapt[image])"
|
|
32
|
+
)
|
|
33
|
+
args = parser.parse_args(argv)
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
if args.imagem is not None:
|
|
37
|
+
invoice = read_image(args.imagem)
|
|
38
|
+
else:
|
|
39
|
+
invoice = parse(args.qr if args.qr is not None else sys.stdin.read())
|
|
40
|
+
except (QRCodeError, ImportError, OSError) as exc:
|
|
41
|
+
print(f"erro: {exc}", file=sys.stderr)
|
|
42
|
+
return 2
|
|
43
|
+
|
|
44
|
+
data = asdict(invoice)
|
|
45
|
+
data.pop("fields")
|
|
46
|
+
data["document_type_name"] = invoice.document_type_name
|
|
47
|
+
data["problems"] = check(invoice)
|
|
48
|
+
print(json.dumps(data, default=_json, ensure_ascii=False, indent=2))
|
|
49
|
+
return 1 if data["problems"] else 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
raise SystemExit(main())
|
faturapt/image.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Leitura do código QR a partir de uma imagem (fotografia ou digitalização da fatura).
|
|
2
|
+
|
|
3
|
+
Precisa das dependências opcionais: ``pip install 'faturapt[image]'``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import io
|
|
9
|
+
import os
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from .qr import InvoiceQR, QRCodeError, parse
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from PIL.Image import Image
|
|
16
|
+
|
|
17
|
+
__all__ = ["read_image"]
|
|
18
|
+
|
|
19
|
+
_MISSING = "Para ler imagens, instalar com: pip install 'faturapt[image]'"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def read_image(image: str | os.PathLike[str] | bytes | Image) -> InvoiceQR:
|
|
23
|
+
"""Encontra o código QR da fatura numa imagem e lê-o com :func:`parse`.
|
|
24
|
+
|
|
25
|
+
Aceita o caminho de um ficheiro, o conteúdo em ``bytes`` ou uma imagem do Pillow.
|
|
26
|
+
Respeita a orientação EXIF das fotografias do telemóvel. Se houver vários códigos
|
|
27
|
+
(por exemplo, o QR de um site ao lado), devolve o primeiro que seja de uma fatura.
|
|
28
|
+
|
|
29
|
+
Lança :class:`QRCodeError` se não houver nenhum QR de fatura na imagem, e
|
|
30
|
+
``ImportError`` se faltarem as dependências opcionais.
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
import zxingcpp
|
|
34
|
+
from PIL import Image as PILImage
|
|
35
|
+
from PIL import ImageOps
|
|
36
|
+
except ImportError as exc:
|
|
37
|
+
raise ImportError(_MISSING) from exc
|
|
38
|
+
|
|
39
|
+
picture: Image
|
|
40
|
+
if isinstance(image, bytes):
|
|
41
|
+
picture = PILImage.open(io.BytesIO(image))
|
|
42
|
+
elif isinstance(image, (str, os.PathLike)):
|
|
43
|
+
picture = PILImage.open(image)
|
|
44
|
+
else:
|
|
45
|
+
picture = image
|
|
46
|
+
picture = ImageOps.exif_transpose(picture).convert("L")
|
|
47
|
+
|
|
48
|
+
codes = zxingcpp.read_barcodes(
|
|
49
|
+
picture, formats=zxingcpp.BarcodeFormats(zxingcpp.BarcodeFormat.QRCode)
|
|
50
|
+
)
|
|
51
|
+
if not codes:
|
|
52
|
+
raise QRCodeError("Não encontrei nenhum código QR na imagem")
|
|
53
|
+
errors = []
|
|
54
|
+
for code in codes:
|
|
55
|
+
try:
|
|
56
|
+
return parse(code.text)
|
|
57
|
+
except QRCodeError as exc:
|
|
58
|
+
errors.append(str(exc))
|
|
59
|
+
raise QRCodeError(
|
|
60
|
+
f"Há {len(codes)} código(s) QR na imagem, mas nenhum é de uma fatura ({errors[0]})"
|
|
61
|
+
)
|
faturapt/nif.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Validação do NIF (Número de Identificação Fiscal) português."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
__all__ = ["InvalidNIF", "NifType", "is_valid_nif", "nif_type", "normalize_nif"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InvalidNIF(ValueError):
|
|
11
|
+
"""O valor não é um NIF português válido."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NifType(str, Enum):
|
|
15
|
+
"""Tipo de contribuinte, pelo prefixo do NIF."""
|
|
16
|
+
|
|
17
|
+
INDIVIDUAL = "pessoa singular"
|
|
18
|
+
NON_RESIDENT_INDIVIDUAL = "pessoa singular não residente"
|
|
19
|
+
COMPANY = "pessoa coletiva"
|
|
20
|
+
PUBLIC_ADMINISTRATION = "administração pública"
|
|
21
|
+
UNDIVIDED_INHERITANCE = "herança indivisa"
|
|
22
|
+
NON_RESIDENT_COMPANY = "pessoa coletiva não residente"
|
|
23
|
+
INVESTMENT_FUND = "fundo de investimento"
|
|
24
|
+
OFFICIAL_ASSIGNMENT = "atribuição oficiosa"
|
|
25
|
+
EXCEPTIONAL_REGIME = "regime excecional"
|
|
26
|
+
SOLE_TRADER = "empresário em nome individual"
|
|
27
|
+
CONDOMINIUM_OR_IRREGULAR = "condomínio ou sociedade irregular"
|
|
28
|
+
NON_RESIDENT_NO_ESTABLISHMENT = "não residente sem estabelecimento estável"
|
|
29
|
+
CIVIL_PARTNERSHIP = "sociedade civil sem personalidade jurídica"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Prefixos de dois dígitos primeiro: "45" tem de ganhar a "4" (que não existe sozinho).
|
|
33
|
+
_PREFIXES: tuple[tuple[str, NifType], ...] = (
|
|
34
|
+
("45", NifType.NON_RESIDENT_INDIVIDUAL),
|
|
35
|
+
("70", NifType.UNDIVIDED_INHERITANCE),
|
|
36
|
+
("71", NifType.NON_RESIDENT_COMPANY),
|
|
37
|
+
("72", NifType.INVESTMENT_FUND),
|
|
38
|
+
("74", NifType.UNDIVIDED_INHERITANCE),
|
|
39
|
+
("75", NifType.UNDIVIDED_INHERITANCE),
|
|
40
|
+
("77", NifType.OFFICIAL_ASSIGNMENT),
|
|
41
|
+
("79", NifType.EXCEPTIONAL_REGIME),
|
|
42
|
+
("90", NifType.CONDOMINIUM_OR_IRREGULAR),
|
|
43
|
+
("91", NifType.CONDOMINIUM_OR_IRREGULAR),
|
|
44
|
+
("98", NifType.NON_RESIDENT_NO_ESTABLISHMENT),
|
|
45
|
+
("99", NifType.CIVIL_PARTNERSHIP),
|
|
46
|
+
("1", NifType.INDIVIDUAL),
|
|
47
|
+
("2", NifType.INDIVIDUAL),
|
|
48
|
+
("3", NifType.INDIVIDUAL),
|
|
49
|
+
("5", NifType.COMPANY),
|
|
50
|
+
("6", NifType.PUBLIC_ADMINISTRATION),
|
|
51
|
+
("8", NifType.SOLE_TRADER),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def normalize_nif(value: str | int) -> str:
|
|
56
|
+
"""Tira espaços, pontos, hífenes e o prefixo ``PT``.
|
|
57
|
+
|
|
58
|
+
Não valida: ``normalize_nif(" PT 123 456 789 ")`` dá ``"123456789"``.
|
|
59
|
+
"""
|
|
60
|
+
text = str(value).strip().upper()
|
|
61
|
+
if text.startswith("PT"):
|
|
62
|
+
text = text[2:]
|
|
63
|
+
return "".join(ch for ch in text if ch not in " .-")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _check_digit(first_eight: str) -> int:
|
|
67
|
+
total = sum(int(d) * w for d, w in zip(first_eight, range(9, 1, -1), strict=True))
|
|
68
|
+
remainder = total % 11
|
|
69
|
+
return 0 if remainder < 2 else 11 - remainder
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def nif_type(value: str | int) -> NifType:
|
|
73
|
+
"""Devolve o tipo de contribuinte, ou lança :class:`InvalidNIF`."""
|
|
74
|
+
nif = normalize_nif(value)
|
|
75
|
+
if len(nif) != 9 or not nif.isdigit():
|
|
76
|
+
raise InvalidNIF(f"O NIF tem de ter 9 dígitos: {value!r}")
|
|
77
|
+
kind = next((t for prefix, t in _PREFIXES if nif.startswith(prefix)), None)
|
|
78
|
+
if kind is None:
|
|
79
|
+
raise InvalidNIF(f"O NIF começa por um prefixo que não existe: {value!r}")
|
|
80
|
+
if int(nif[8]) != _check_digit(nif[:8]):
|
|
81
|
+
raise InvalidNIF(f"O dígito de controlo do NIF não confere: {value!r}")
|
|
82
|
+
return kind
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def is_valid_nif(value: str | int) -> bool:
|
|
86
|
+
"""``True`` se o valor (depois de :func:`normalize_nif`) for um NIF válido."""
|
|
87
|
+
try:
|
|
88
|
+
nif_type(value)
|
|
89
|
+
except InvalidNIF:
|
|
90
|
+
return False
|
|
91
|
+
return True
|
faturapt/py.typed
ADDED
|
File without changes
|
faturapt/qr.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Leitura do código QR das faturas portuguesas.
|
|
2
|
+
|
|
3
|
+
O formato vem da Portaria n.º 195/2020 e das especificações técnicas da AT
|
|
4
|
+
("Código de Barras Bidimensional - Código QR", versão 1.0, agosto de 2020):
|
|
5
|
+
campos ``CHAVE:valor`` separados por ``*``, por exemplo
|
|
6
|
+
``A:123456789*B:999999990*C:PT*D:FT*...``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import date, datetime
|
|
14
|
+
from decimal import Decimal, InvalidOperation
|
|
15
|
+
|
|
16
|
+
from .nif import is_valid_nif
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"DOCUMENT_TYPES",
|
|
20
|
+
"InvoiceQR",
|
|
21
|
+
"QRCodeError",
|
|
22
|
+
"TaxBreakdown",
|
|
23
|
+
"check",
|
|
24
|
+
"parse",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class QRCodeError(ValueError):
|
|
29
|
+
"""O texto não é um código QR de fatura bem formado."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
DOCUMENT_TYPES: dict[str, str] = {
|
|
33
|
+
"FT": "Fatura",
|
|
34
|
+
"FS": "Fatura simplificada",
|
|
35
|
+
"FR": "Fatura-recibo",
|
|
36
|
+
"ND": "Nota de débito",
|
|
37
|
+
"NC": "Nota de crédito",
|
|
38
|
+
"GR": "Guia de remessa",
|
|
39
|
+
"GT": "Guia de transporte",
|
|
40
|
+
"GA": "Guia de movimentação de ativos próprios",
|
|
41
|
+
"GC": "Guia de consignação",
|
|
42
|
+
"GD": "Guia ou nota de devolução",
|
|
43
|
+
"CM": "Consulta de mesa",
|
|
44
|
+
"CC": "Crédito de consignação",
|
|
45
|
+
"FC": "Fatura de consignação",
|
|
46
|
+
"FO": "Folha de obra",
|
|
47
|
+
"NE": "Nota de encomenda",
|
|
48
|
+
"OU": "Outros",
|
|
49
|
+
"OR": "Orçamento",
|
|
50
|
+
"PF": "Fatura pró-forma",
|
|
51
|
+
"RC": "Recibo",
|
|
52
|
+
"RG": "Outro recibo",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_REQUIRED = ("A", "B", "C", "D", "E", "F", "G", "H", "I1", "N", "O", "Q", "R")
|
|
56
|
+
_TAX_KEYS = [f"{letter}{i}" for letter in "IJK" for i in range(1, 9)]
|
|
57
|
+
_MONETARY = {k for k in _TAX_KEYS if not k.endswith("1")} | {"L", "M", "N", "O", "P"}
|
|
58
|
+
# Tamanho máximo de cada campo, pela ordem em que têm de aparecer (ponto 4 da especificação).
|
|
59
|
+
_MAX_LENGTH: dict[str, int] = {
|
|
60
|
+
**{"A": 9, "B": 30, "C": 12, "D": 2, "E": 1, "F": 8, "G": 60, "H": 70},
|
|
61
|
+
**{k: 16 if k in _MONETARY else 5 for k in _TAX_KEYS},
|
|
62
|
+
**{"L": 16, "M": 16, "N": 16, "O": 16, "P": 16, "Q": 4, "R": 4, "S": 65},
|
|
63
|
+
}
|
|
64
|
+
_ORDER = {key: i for i, key in enumerate(_MAX_LENGTH)}
|
|
65
|
+
_AMOUNT = re.compile(r"^-?\d+(\.\d{1,2})?$")
|
|
66
|
+
# ATCUD: código de validação da série (8 ou mais caracteres), hífen e número do documento.
|
|
67
|
+
_ATCUD = re.compile(r"^[A-Z0-9]{8,}-(\d+)$")
|
|
68
|
+
# Identificação do documento no SAF-T(PT): tipo, espaço, série, barra e número.
|
|
69
|
+
_DOCUMENT_ID = re.compile(r"^(\S+) \S+/(\d+)$")
|
|
70
|
+
_TAX_FIELDS = (
|
|
71
|
+
"exempt_base",
|
|
72
|
+
"reduced_base",
|
|
73
|
+
"reduced_vat",
|
|
74
|
+
"intermediate_base",
|
|
75
|
+
"intermediate_vat",
|
|
76
|
+
"normal_base",
|
|
77
|
+
"normal_vat",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, slots=True)
|
|
82
|
+
class TaxBreakdown:
|
|
83
|
+
"""IVA de um espaço fiscal (campos I, J ou K)."""
|
|
84
|
+
|
|
85
|
+
fiscal_space: str
|
|
86
|
+
"""``PT``, ``PT-AC`` (Açores) ou ``PT-MA`` (Madeira)."""
|
|
87
|
+
exempt_base: Decimal | None = None
|
|
88
|
+
reduced_base: Decimal | None = None
|
|
89
|
+
reduced_vat: Decimal | None = None
|
|
90
|
+
intermediate_base: Decimal | None = None
|
|
91
|
+
intermediate_vat: Decimal | None = None
|
|
92
|
+
normal_base: Decimal | None = None
|
|
93
|
+
normal_vat: Decimal | None = None
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def total_base(self) -> Decimal:
|
|
97
|
+
parts = (self.exempt_base, self.reduced_base, self.intermediate_base, self.normal_base)
|
|
98
|
+
return sum((p for p in parts if p is not None), Decimal(0))
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def total_vat(self) -> Decimal:
|
|
102
|
+
parts = (self.reduced_vat, self.intermediate_vat, self.normal_vat)
|
|
103
|
+
return sum((p for p in parts if p is not None), Decimal(0))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True, slots=True)
|
|
107
|
+
class InvoiceQR:
|
|
108
|
+
"""Os campos de um código QR de fatura, já convertidos."""
|
|
109
|
+
|
|
110
|
+
issuer_nif: str
|
|
111
|
+
buyer_nif: str
|
|
112
|
+
buyer_country: str
|
|
113
|
+
document_type: str
|
|
114
|
+
document_status: str
|
|
115
|
+
document_date: date
|
|
116
|
+
document_id: str
|
|
117
|
+
atcud: str
|
|
118
|
+
taxes: tuple[TaxBreakdown, ...]
|
|
119
|
+
total_taxes: Decimal
|
|
120
|
+
total: Decimal
|
|
121
|
+
hash_chars: str
|
|
122
|
+
certificate: str
|
|
123
|
+
non_taxable: Decimal | None = None
|
|
124
|
+
stamp_duty: Decimal | None = None
|
|
125
|
+
withholding: Decimal | None = None
|
|
126
|
+
other_info: str | None = None
|
|
127
|
+
fields: dict[str, str] = field(default_factory=dict, compare=False, repr=False)
|
|
128
|
+
"""Os campos tal como vieram, por chave."""
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def document_type_name(self) -> str | None:
|
|
132
|
+
"""Nome do tipo de documento (``"Fatura"``), ou ``None`` se for desconhecido."""
|
|
133
|
+
return DOCUMENT_TYPES.get(self.document_type)
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def is_final_consumer(self) -> bool:
|
|
137
|
+
"""``True`` se a fatura não tem NIF do adquirente (consumidor final)."""
|
|
138
|
+
return self.buyer_nif == "999999990"
|
|
139
|
+
|
|
140
|
+
def to_text(self) -> str:
|
|
141
|
+
"""O texto do código QR, pela ordem e com o formato da especificação.
|
|
142
|
+
|
|
143
|
+
Valores ``None`` não entram (os campos opcionais sem informação não se criam), e
|
|
144
|
+
sem espaços fiscais escreve ``I1:0``. Lança :class:`QRCodeError` se algum valor
|
|
145
|
+
passar o tamanho máximo ou tiver um ``*``, ou se o último acabar em espaço.
|
|
146
|
+
``parse(fatura.to_text())`` devolve uma fatura igual a esta.
|
|
147
|
+
"""
|
|
148
|
+
if len(self.taxes) > 3:
|
|
149
|
+
raise QRCodeError("Só há três espaços fiscais (campos I, J e K)")
|
|
150
|
+
fields: dict[str, str | Decimal | None] = {
|
|
151
|
+
"A": self.issuer_nif,
|
|
152
|
+
"B": self.buyer_nif,
|
|
153
|
+
"C": self.buyer_country,
|
|
154
|
+
"D": self.document_type,
|
|
155
|
+
"E": self.document_status,
|
|
156
|
+
"F": self.document_date.strftime("%Y%m%d"),
|
|
157
|
+
"G": self.document_id,
|
|
158
|
+
"H": self.atcud,
|
|
159
|
+
}
|
|
160
|
+
if not self.taxes:
|
|
161
|
+
fields["I1"] = "0"
|
|
162
|
+
for letter, tax in zip("IJK", self.taxes, strict=False):
|
|
163
|
+
fields[f"{letter}1"] = tax.fiscal_space
|
|
164
|
+
for i, name in enumerate(_TAX_FIELDS, start=2):
|
|
165
|
+
fields[f"{letter}{i}"] = getattr(tax, name)
|
|
166
|
+
fields |= {
|
|
167
|
+
"L": self.non_taxable,
|
|
168
|
+
"M": self.stamp_duty,
|
|
169
|
+
"N": self.total_taxes,
|
|
170
|
+
"O": self.total,
|
|
171
|
+
"P": self.withholding,
|
|
172
|
+
"Q": self.hash_chars,
|
|
173
|
+
"R": self.certificate,
|
|
174
|
+
"S": self.other_info,
|
|
175
|
+
}
|
|
176
|
+
text = "*".join(
|
|
177
|
+
f"{key}:{value:.2f}" if isinstance(value, Decimal) else f"{key}:{value}"
|
|
178
|
+
for key, value in fields.items()
|
|
179
|
+
if value is not None
|
|
180
|
+
)
|
|
181
|
+
if text != text.strip(): # a leitura tira os espaços das pontas
|
|
182
|
+
raise QRCodeError("O último campo não pode acabar em espaço")
|
|
183
|
+
_split(text) # mesmas regras de tamanho e formato que a leitura
|
|
184
|
+
return text
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _split(text: str) -> dict[str, str]:
|
|
188
|
+
fields: dict[str, str] = {}
|
|
189
|
+
for part in text.strip().split("*"):
|
|
190
|
+
key, sep, value = part.partition(":")
|
|
191
|
+
if not sep or not key:
|
|
192
|
+
raise QRCodeError(f"Campo sem o formato CHAVE:valor: {part!r}")
|
|
193
|
+
if key in fields:
|
|
194
|
+
raise QRCodeError(f"Campo repetido: {key}")
|
|
195
|
+
if key not in _MAX_LENGTH:
|
|
196
|
+
raise QRCodeError(f"Campo desconhecido: {key}")
|
|
197
|
+
if len(value) > _MAX_LENGTH[key]:
|
|
198
|
+
raise QRCodeError(
|
|
199
|
+
f"O campo {key} passa o tamanho máximo ({_MAX_LENGTH[key]} caracteres): {value!r}"
|
|
200
|
+
)
|
|
201
|
+
fields[key] = value
|
|
202
|
+
return fields
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _amount(fields: dict[str, str], key: str) -> Decimal | None:
|
|
206
|
+
raw = fields.get(key)
|
|
207
|
+
if raw is None:
|
|
208
|
+
return None
|
|
209
|
+
if not _AMOUNT.match(raw):
|
|
210
|
+
raise QRCodeError(f"Valor inválido no campo {key} (usar ponto e até 2 casas): {raw!r}")
|
|
211
|
+
try:
|
|
212
|
+
return Decimal(raw)
|
|
213
|
+
except InvalidOperation as exc: # pragma: no cover - o regex já o impede
|
|
214
|
+
raise QRCodeError(f"Valor inválido no campo {key}: {raw!r}") from exc
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _taxes(fields: dict[str, str]) -> tuple[TaxBreakdown, ...]:
|
|
218
|
+
result = []
|
|
219
|
+
for letter in "IJK":
|
|
220
|
+
space = fields.get(f"{letter}1")
|
|
221
|
+
others = [k for k in fields if k.startswith(letter) and k != f"{letter}1"]
|
|
222
|
+
if space is None:
|
|
223
|
+
if others:
|
|
224
|
+
raise QRCodeError(f"Campos {letter}2 a {letter}8 sem o espaço fiscal {letter}1")
|
|
225
|
+
continue
|
|
226
|
+
if space == "0": # documento sem IVA
|
|
227
|
+
if others:
|
|
228
|
+
raise QRCodeError(f"{letter}1 é 0 (sem IVA), mas há outros campos {letter}")
|
|
229
|
+
continue
|
|
230
|
+
values = {
|
|
231
|
+
name: _amount(fields, f"{letter}{i}") for i, name in enumerate(_TAX_FIELDS, start=2)
|
|
232
|
+
}
|
|
233
|
+
result.append(TaxBreakdown(fiscal_space=space, **values))
|
|
234
|
+
return tuple(result)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def parse(text: str) -> InvoiceQR:
|
|
238
|
+
"""Lê o texto de um código QR de fatura.
|
|
239
|
+
|
|
240
|
+
Lança :class:`QRCodeError` se o texto estiver mal formado (campos desconhecidos,
|
|
241
|
+
repetidos ou acima do tamanho máximo) ou faltarem campos obrigatórios. Aceita
|
|
242
|
+
valores com menos de duas casas decimais e campos fora de ordem, e não confirma
|
|
243
|
+
NIFs nem contas: isso fica para :func:`check`.
|
|
244
|
+
"""
|
|
245
|
+
fields = _split(text)
|
|
246
|
+
missing = [k for k in _REQUIRED if k not in fields]
|
|
247
|
+
if missing:
|
|
248
|
+
raise QRCodeError(f"Faltam campos obrigatórios: {', '.join(missing)}")
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
document_date = datetime.strptime(fields["F"], "%Y%m%d").date()
|
|
252
|
+
except ValueError as exc:
|
|
253
|
+
raise QRCodeError(f"Data inválida no campo F (AAAAMMDD): {fields['F']!r}") from exc
|
|
254
|
+
|
|
255
|
+
if len(fields["Q"]) != 4:
|
|
256
|
+
raise QRCodeError(f"O campo Q tem de ter 4 caracteres do hash: {fields['Q']!r}")
|
|
257
|
+
|
|
258
|
+
total_taxes = _amount(fields, "N") or Decimal(0) # N e O são obrigatórios (vistos acima)
|
|
259
|
+
total = _amount(fields, "O") or Decimal(0)
|
|
260
|
+
|
|
261
|
+
return InvoiceQR(
|
|
262
|
+
issuer_nif=fields["A"],
|
|
263
|
+
buyer_nif=fields["B"],
|
|
264
|
+
buyer_country=fields["C"],
|
|
265
|
+
document_type=fields["D"],
|
|
266
|
+
document_status=fields["E"],
|
|
267
|
+
document_date=document_date,
|
|
268
|
+
document_id=fields["G"],
|
|
269
|
+
atcud=fields["H"],
|
|
270
|
+
taxes=_taxes(fields),
|
|
271
|
+
non_taxable=_amount(fields, "L"),
|
|
272
|
+
stamp_duty=_amount(fields, "M"),
|
|
273
|
+
total_taxes=total_taxes,
|
|
274
|
+
total=total,
|
|
275
|
+
withholding=_amount(fields, "P"),
|
|
276
|
+
hash_chars=fields["Q"],
|
|
277
|
+
certificate=fields["R"],
|
|
278
|
+
other_info=fields.get("S"),
|
|
279
|
+
fields=fields,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def check(invoice: InvoiceQR) -> list[str]:
|
|
284
|
+
"""Problemas de conteúdo numa fatura já lida; lista vazia se estiver tudo certo.
|
|
285
|
+
|
|
286
|
+
Confirma a ordem dos campos, as duas casas decimais dos valores, os NIFs, o ATCUD
|
|
287
|
+
(o número no fim tem de ser o do documento, em G) e as contas: o total de
|
|
288
|
+
impostos (N) é o IVA mais o selo, e o total (O) é a soma das bases, do não
|
|
289
|
+
sujeito (L) e dos impostos (N).
|
|
290
|
+
"""
|
|
291
|
+
problems = []
|
|
292
|
+
keys = list(invoice.fields)
|
|
293
|
+
if keys != sorted(keys, key=_ORDER.__getitem__):
|
|
294
|
+
problems.append("Os campos não estão pela ordem da especificação")
|
|
295
|
+
problems += [
|
|
296
|
+
f"O campo {key} devia ter duas casas decimais: {value}"
|
|
297
|
+
for key, value in invoice.fields.items()
|
|
298
|
+
if key in _MONETARY and not re.fullmatch(r"-?\d+\.\d\d", value)
|
|
299
|
+
]
|
|
300
|
+
if not is_valid_nif(invoice.issuer_nif):
|
|
301
|
+
problems.append(f"NIF do emitente inválido: {invoice.issuer_nif}")
|
|
302
|
+
if invoice.buyer_country == "PT" and not is_valid_nif(invoice.buyer_nif):
|
|
303
|
+
problems.append(f"NIF do adquirente inválido: {invoice.buyer_nif}")
|
|
304
|
+
|
|
305
|
+
problems += _check_atcud(invoice)
|
|
306
|
+
|
|
307
|
+
vat = sum((t.total_vat for t in invoice.taxes), Decimal(0))
|
|
308
|
+
expected_taxes = vat + (invoice.stamp_duty or 0)
|
|
309
|
+
if expected_taxes != invoice.total_taxes:
|
|
310
|
+
problems.append(
|
|
311
|
+
f"Total de impostos (N) é {invoice.total_taxes}, mas IVA + selo dá {expected_taxes}"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
bases = sum((t.total_base for t in invoice.taxes), Decimal(0))
|
|
315
|
+
expected_total = bases + (invoice.non_taxable or 0) + invoice.total_taxes
|
|
316
|
+
if expected_total != invoice.total:
|
|
317
|
+
problems.append(
|
|
318
|
+
f"Total (O) é {invoice.total}, mas bases + não sujeito + impostos dá {expected_total}"
|
|
319
|
+
)
|
|
320
|
+
return problems
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _check_atcud(invoice: InvoiceQR) -> list[str]:
|
|
324
|
+
document = _DOCUMENT_ID.match(invoice.document_id)
|
|
325
|
+
if document is None:
|
|
326
|
+
return [f"Documento (G) sem o formato 'TIPO SÉRIE/NÚMERO': {invoice.document_id}"]
|
|
327
|
+
problems = []
|
|
328
|
+
if document[1] != invoice.document_type:
|
|
329
|
+
problems.append(
|
|
330
|
+
f"O documento (G) é do tipo {document[1]}, mas o tipo (D) é {invoice.document_type}"
|
|
331
|
+
)
|
|
332
|
+
if invoice.atcud == "0": # sem ATCUD, como era permitido antes de ser obrigatório
|
|
333
|
+
return problems
|
|
334
|
+
atcud = _ATCUD.match(invoice.atcud)
|
|
335
|
+
if atcud is None:
|
|
336
|
+
problems.append(f"ATCUD (H) sem o formato 'CÓDIGO-NÚMERO': {invoice.atcud}")
|
|
337
|
+
elif int(atcud[1]) != int(document[2]):
|
|
338
|
+
problems.append(
|
|
339
|
+
f"O ATCUD (H) acaba em {atcud[1]}, mas o documento (G) é o n.º {document[2]}"
|
|
340
|
+
)
|
|
341
|
+
return problems
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: faturapt
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Leitura e validação do código QR das faturas portuguesas (AT) e de NIFs.
|
|
5
|
+
Project-URL: Repository, https://github.com/JoseFerrao20/faturapt
|
|
6
|
+
Project-URL: Changelog, https://github.com/JoseFerrao20/faturapt/blob/main/CHANGELOG.md
|
|
7
|
+
Author: José Ferrão
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: atcud,autoridade tributária,fatura,invoice,nif,portugal,qr code
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Natural Language :: Portuguese
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Provides-Extra: image
|
|
26
|
+
Requires-Dist: pillow>=10; extra == 'image'
|
|
27
|
+
Requires-Dist: zxing-cpp>=3.0; extra == 'image'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# faturapt
|
|
31
|
+
|
|
32
|
+
[](https://github.com/JoseFerrao20/faturapt/actions/workflows/ci.yml)
|
|
33
|
+
[](https://pypi.org/project/faturapt/)
|
|
34
|
+
[](https://pypi.org/project/faturapt/)
|
|
35
|
+
[](LICENSE)
|
|
36
|
+
|
|
37
|
+
Lê e valida o **código QR das faturas portuguesas** (o que a AT exige desde 2022, Portaria n.º 195/2020) e **NIFs**. Python puro, sem dependências obrigatórias, com tipos.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
>>> import faturapt
|
|
41
|
+
>>> inv = faturapt.parse("A:500000000*B:999999990*C:PT*D:FS*E:N*F:20261015*G:FS 01/1234"
|
|
42
|
+
... "*H:JJ3XY2KD-1234*I1:PT*I7:10.00*I8:2.30*N:2.30*O:12.30*Q:Ab1z*R:1234")
|
|
43
|
+
>>> inv.document_type_name, inv.document_date, inv.total
|
|
44
|
+
('Fatura simplificada', datetime.date(2026, 10, 15), Decimal('12.30'))
|
|
45
|
+
>>> inv.is_final_consumer
|
|
46
|
+
True
|
|
47
|
+
>>> faturapt.check(inv) # NIFs e contas; lista vazia = tudo certo
|
|
48
|
+
[]
|
|
49
|
+
>>> faturapt.nif_type("PT 123 456 789")
|
|
50
|
+
<NifType.INDIVIDUAL: 'pessoa singular'>
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Instalar
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install faturapt # só texto: sem dependências
|
|
58
|
+
pip install 'faturapt[image]' # também lê fotografias (Pillow e zxing-cpp)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
>>> fatura = faturapt.read_image("talao.jpg") # doctest: +SKIP
|
|
63
|
+
>>> fatura.total, faturapt.check(fatura) # doctest: +SKIP
|
|
64
|
+
(Decimal('12.30'), [])
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## O que faz
|
|
68
|
+
|
|
69
|
+
- **`parse(texto)`**: converte o texto do QR num `InvoiceQR` (datas em `date`, valores em `Decimal`, IVA por espaço fiscal: continente, Açores e Madeira). Lança `QRCodeError`, com a mensagem em português, se faltarem campos obrigatórios, houver campos repetidos, desconhecidos ou acima do tamanho máximo, ou valores mal formados.
|
|
70
|
+
- **`check(fatura)`**: devolve os problemas de conteúdo: campos fora de ordem, valores sem duas casas decimais, NIFs inválidos, ATCUD que não acaba no número do documento (G) ou documento (G) de outro tipo que não o D, total de impostos (N) diferente de IVA + selo, ou total (O) diferente de bases + não sujeito + impostos.
|
|
71
|
+
- **`fatura.to_text()`**: o caminho inverso, para quem gera faturas: escreve o texto do QR pela ordem da especificação, com duas casas decimais, e recusa valores acima do tamanho máximo. `parse(fatura.to_text())` devolve sempre a mesma fatura (os testes confirmam-no com o Hypothesis, em faturas geradas ao acaso).
|
|
72
|
+
- **`is_valid_nif`, `nif_type`, `normalize_nif`**: dígito de controlo, prefixos e tipo de contribuinte (pessoa singular, coletiva, condomínio, …). Aceita espaços, pontos e o prefixo `PT`.
|
|
73
|
+
- **`read_image(imagem)`**: encontra o QR da fatura numa fotografia ou digitalização (caminho, `bytes` ou imagem do Pillow) e lê-o com o `parse`. Respeita a orientação EXIF do telemóvel e, se houver outros QR na imagem (um site, uma promoção), escolhe o da fatura. Precisa de `faturapt[image]`.
|
|
74
|
+
- **Linha de comandos**: `faturapt "A:...*B:..."` (ou pelo stdin) ou `faturapt -i talao.jpg` mostra a fatura em JSON. Sai com 0 se estiver tudo certo, com 1 se houver problemas e com 2 se o texto ou a imagem não tiverem um QR válido.
|
|
75
|
+
|
|
76
|
+
As regras seguem as [especificações técnicas da AT](https://info.portaldasfinancas.gov.pt/pt/apoio_contribuinte/Novas_regras_faturacao/Documents/Especificacoes_Tecnicas_Codigo_QR.pdf) (versão 1.0, agosto de 2020), e os quatro exemplos do documento fazem parte dos testes. O `parse` recusa o que não se consegue ler (campos desconhecidos, repetidos ou acima do tamanho máximo, obrigatórios em falta); o `check` aponta o resto (campos fora de ordem, valores sem duas casas decimais, NIFs e contas).
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
## Campos do QR
|
|
80
|
+
|
|
81
|
+
| Chave | Campo | `InvoiceQR` |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| A, B, C | NIF do emitente, NIF e país do adquirente | `issuer_nif`, `buyer_nif`, `buyer_country` |
|
|
84
|
+
| D, E, F, G | Tipo, estado, data e identificação do documento | `document_type`, `document_status`, `document_date`, `document_id` |
|
|
85
|
+
| H | ATCUD | `atcud` |
|
|
86
|
+
| I1–I8, J1–J8, K1–K8 | IVA por espaço fiscal (isento, reduzida, intermédia, normal) | `taxes` (`TaxBreakdown`) |
|
|
87
|
+
| L, M, N, O, P | Não sujeito, selo, total de impostos, total, retenção na fonte | `non_taxable`, `stamp_duty`, `total_taxes`, `total`, `withholding` |
|
|
88
|
+
| Q, R, S | 4 caracteres do hash, n.º do certificado, outras informações | `hash_chars`, `certificate`, `other_info` |
|
|
89
|
+
|
|
90
|
+
## Desenvolvimento
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
uv sync --all-extras # ambiente com as dependências de desenvolvimento e de imagem
|
|
94
|
+
uv run pytest # testes, com cobertura mínima de 95 %
|
|
95
|
+
uv run ruff check . && uv run ruff format --check . && uv run mypy
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
O CI corre o mesmo em Python 3.10 a 3.14. Para publicar uma versão: atualizar `__version__`, passar as entradas de "Por publicar" do `CHANGELOG.md` para a versão nova, com a data, e criar uma tag `vX.Y.Z`. O workflow `release.yml` publica no PyPI por *trusted publishing* (sem tokens guardados no GitHub).
|
|
99
|
+
|
|
100
|
+
## Licença
|
|
101
|
+
|
|
102
|
+
MIT.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
faturapt/__init__.py,sha256=6uF8sGCOsehA0b5NlSJRpTkhC1TO8ypNFT1kbIOxk4s,517
|
|
2
|
+
faturapt/__main__.py,sha256=CaYzSGGYhUSNfWDTYkFP0ni4FMmCMDaf7RLOLAx4C58,1642
|
|
3
|
+
faturapt/image.py,sha256=vGwzvbOYu_IpcJwJ2kuXByLlm26Nn_mGrY5jhSiJoJY,1994
|
|
4
|
+
faturapt/nif.py,sha256=pq6q6aXKgzc1MPb9zPuFMa15VpHl2ZCYougvSfXdmVQ,3298
|
|
5
|
+
faturapt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
faturapt/qr.py,sha256=cBTjs0GdrGoBdN5-TS_pQsk24gSgoAcsgqFEn1DtZUo,12714
|
|
7
|
+
faturapt-0.1.0.dist-info/METADATA,sha256=vyFPtRF6C33oI3Dqjh1BipMniGe18ApImXc6iEjeM04,6312
|
|
8
|
+
faturapt-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
9
|
+
faturapt-0.1.0.dist-info/entry_points.txt,sha256=RmtRQQ-dciS3SxJ9hl_kQGYIcuSsnvf5p3w4bL3hKyU,52
|
|
10
|
+
faturapt-0.1.0.dist-info/licenses/LICENSE,sha256=FD_G0Zs3jAvgOjJgyxQC0kbhv0X2LseaHAySa0LOy40,1070
|
|
11
|
+
faturapt-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 José Ferrão
|
|
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.
|