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 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
+ [![CI](https://github.com/JoseFerrao20/faturapt/actions/workflows/ci.yml/badge.svg)](https://github.com/JoseFerrao20/faturapt/actions/workflows/ci.yml)
33
+ [![PyPI](https://img.shields.io/pypi/v/faturapt)](https://pypi.org/project/faturapt/)
34
+ [![Versões de Python](https://img.shields.io/pypi/pyversions/faturapt)](https://pypi.org/project/faturapt/)
35
+ [![Licença: MIT](https://img.shields.io/badge/licen%C3%A7a-MIT-blue)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ faturapt = faturapt.__main__:main
@@ -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.