faturapt 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- faturapt-0.1.0/.github/workflows/ci.yml +28 -0
- faturapt-0.1.0/.github/workflows/release.yml +40 -0
- faturapt-0.1.0/.gitignore +9 -0
- faturapt-0.1.0/CHANGELOG.md +21 -0
- faturapt-0.1.0/CLAUDE.md +22 -0
- faturapt-0.1.0/LICENSE +21 -0
- faturapt-0.1.0/PKG-INFO +102 -0
- faturapt-0.1.0/README.md +73 -0
- faturapt-0.1.0/pyproject.toml +76 -0
- faturapt-0.1.0/src/faturapt/__init__.py +22 -0
- faturapt-0.1.0/src/faturapt/__main__.py +53 -0
- faturapt-0.1.0/src/faturapt/image.py +61 -0
- faturapt-0.1.0/src/faturapt/nif.py +91 -0
- faturapt-0.1.0/src/faturapt/py.typed +0 -0
- faturapt-0.1.0/src/faturapt/qr.py +341 -0
- faturapt-0.1.0/tests/test_build.py +92 -0
- faturapt-0.1.0/tests/test_image.py +87 -0
- faturapt-0.1.0/tests/test_nif.py +48 -0
- faturapt-0.1.0/tests/test_qr.py +175 -0
- faturapt-0.1.0/uv.lock +1092 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
strategy:
|
|
15
|
+
fail-fast: false
|
|
16
|
+
matrix:
|
|
17
|
+
python: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v5
|
|
20
|
+
- uses: astral-sh/setup-uv@v6
|
|
21
|
+
with:
|
|
22
|
+
python-version: ${{ matrix.python }}
|
|
23
|
+
- run: uv sync --locked --all-extras
|
|
24
|
+
- run: uv run ruff check .
|
|
25
|
+
- run: uv run ruff format --check .
|
|
26
|
+
- run: uv run mypy
|
|
27
|
+
- run: uv run pytest
|
|
28
|
+
- run: uv build
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# Publica no PyPI quando se cria uma tag vX.Y.Z. Usa trusted publishing: no PyPI,
|
|
4
|
+
# registar este repositório, o workflow release.yml e o ambiente "pypi".
|
|
5
|
+
on:
|
|
6
|
+
push:
|
|
7
|
+
tags: ["v*"]
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
build:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v5
|
|
17
|
+
- uses: astral-sh/setup-uv@v6
|
|
18
|
+
- name: A tag tem de ser igual à versão
|
|
19
|
+
run: |
|
|
20
|
+
version=$(uv run --no-project python -c "import re;print(re.search(r'__version__ = \"(.+)\"', open('src/faturapt/__init__.py').read())[1])")
|
|
21
|
+
test "v$version" = "$GITHUB_REF_NAME"
|
|
22
|
+
- run: uv sync --locked --all-extras && uv run pytest
|
|
23
|
+
- run: uv build
|
|
24
|
+
- uses: actions/upload-artifact@v4
|
|
25
|
+
with:
|
|
26
|
+
name: dist
|
|
27
|
+
path: dist/
|
|
28
|
+
|
|
29
|
+
publish:
|
|
30
|
+
needs: build
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
environment: pypi
|
|
33
|
+
permissions:
|
|
34
|
+
id-token: write
|
|
35
|
+
steps:
|
|
36
|
+
- uses: actions/download-artifact@v4
|
|
37
|
+
with:
|
|
38
|
+
name: dist
|
|
39
|
+
path: dist/
|
|
40
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Alterações
|
|
2
|
+
|
|
3
|
+
O formato segue o [Keep a Changelog](https://keepachangelog.com/pt-PT/1.1.0/) e as versões o [Semantic Versioning](https://semver.org/lang/pt-PT/).
|
|
4
|
+
|
|
5
|
+
## [Por publicar]
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-10-21
|
|
8
|
+
|
|
9
|
+
Primeira versão.
|
|
10
|
+
|
|
11
|
+
### Adicionado
|
|
12
|
+
|
|
13
|
+
- `parse`, para ler o texto do código QR das faturas (Portaria n.º 195/2020), e `check`, para confirmar o conteúdo: ordem dos campos, duas casas decimais, NIFs, ATCUD (o número no fim tem de ser o do documento, e o documento em G tem de ser do tipo D) e contas.
|
|
14
|
+
- `InvoiceQR.to_text()`, para gerar o texto do QR, com testes de ida e volta (Hypothesis).
|
|
15
|
+
- `read_image`, para encontrar e ler o QR numa fotografia ou digitalização, com as dependências opcionais `faturapt[image]` (Pillow e zxing-cpp).
|
|
16
|
+
- `is_valid_nif`, `nif_type` e `normalize_nif`.
|
|
17
|
+
- Linha de comandos `faturapt`, a partir do texto ou de uma imagem (`-i`/`--imagem`).
|
|
18
|
+
- Regras conferidas com as especificações técnicas da AT (versão 1.0, agosto de 2020), com os quatro exemplos do documento nos testes.
|
|
19
|
+
|
|
20
|
+
[Por publicar]: https://github.com/JoseFerrao20/faturapt/compare/v0.1.0...HEAD
|
|
21
|
+
[0.1.0]: https://github.com/JoseFerrao20/faturapt/releases/tag/v0.1.0
|
faturapt-0.1.0/CLAUDE.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# faturapt
|
|
2
|
+
|
|
3
|
+
Biblioteca Python (≥ 3.10, sem dependências) que lê e valida o código QR das faturas portuguesas (`src/faturapt/qr.py`) e NIFs (`src/faturapt/nif.py`), com a linha de comandos em `__main__.py`. A leitura de imagens (`image.py`) usa as dependências opcionais `faturapt[image]` (Pillow e zxing-cpp), importadas só dentro do `read_image`: `import faturapt` tem de funcionar sem elas. Projeto de portfólio de José Ferrão.
|
|
4
|
+
|
|
5
|
+
## Comandos
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv sync --all-extras
|
|
9
|
+
uv run pytest # inclui os exemplos do README (doctest) e cobertura ≥ 95 %
|
|
10
|
+
uv run ruff check . && uv run ruff format --check . && uv run mypy
|
|
11
|
+
uv build
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Convenções
|
|
15
|
+
|
|
16
|
+
- README, CHANGELOG, mensagens de erro e descrições de PR em **português de Portugal**; nomes no código e mensagens de commit em inglês.
|
|
17
|
+
- API pública só pelo que está em `faturapt.__all__`; mudar algo que já lá está é uma versão nova *major* (ou *minor* antes da 1.0) e entra no `CHANGELOG.md`.
|
|
18
|
+
- `parse` só recusa o que está mal formado; problemas de conteúdo (NIFs, contas) vão para `check`, que devolve uma lista e nunca lança.
|
|
19
|
+
- Valores em `Decimal`, nunca `float`.
|
|
20
|
+
- Nos testes de imagem, os QR são gerados com o `segno` e não com o escritor do zxing, para o leitor não ser testado contra si próprio.
|
|
21
|
+
- `to_text` e `parse` são inversos: `parse(fatura.to_text()) == fatura` (teste com o Hypothesis em `tests/test_build.py`). Um campo novo entra nos dois.
|
|
22
|
+
- Cada alteração num PR a partir de um ramo, com o CI verde.
|
faturapt-0.1.0/LICENSE
ADDED
|
@@ -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.
|
faturapt-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
faturapt-0.1.0/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# faturapt
|
|
2
|
+
|
|
3
|
+
[](https://github.com/JoseFerrao20/faturapt/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/faturapt/)
|
|
5
|
+
[](https://pypi.org/project/faturapt/)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
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.
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
>>> import faturapt
|
|
12
|
+
>>> inv = faturapt.parse("A:500000000*B:999999990*C:PT*D:FS*E:N*F:20261015*G:FS 01/1234"
|
|
13
|
+
... "*H:JJ3XY2KD-1234*I1:PT*I7:10.00*I8:2.30*N:2.30*O:12.30*Q:Ab1z*R:1234")
|
|
14
|
+
>>> inv.document_type_name, inv.document_date, inv.total
|
|
15
|
+
('Fatura simplificada', datetime.date(2026, 10, 15), Decimal('12.30'))
|
|
16
|
+
>>> inv.is_final_consumer
|
|
17
|
+
True
|
|
18
|
+
>>> faturapt.check(inv) # NIFs e contas; lista vazia = tudo certo
|
|
19
|
+
[]
|
|
20
|
+
>>> faturapt.nif_type("PT 123 456 789")
|
|
21
|
+
<NifType.INDIVIDUAL: 'pessoa singular'>
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Instalar
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install faturapt # só texto: sem dependências
|
|
29
|
+
pip install 'faturapt[image]' # também lê fotografias (Pillow e zxing-cpp)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
>>> fatura = faturapt.read_image("talao.jpg") # doctest: +SKIP
|
|
34
|
+
>>> fatura.total, faturapt.check(fatura) # doctest: +SKIP
|
|
35
|
+
(Decimal('12.30'), [])
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## O que faz
|
|
39
|
+
|
|
40
|
+
- **`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.
|
|
41
|
+
- **`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.
|
|
42
|
+
- **`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).
|
|
43
|
+
- **`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`.
|
|
44
|
+
- **`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]`.
|
|
45
|
+
- **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.
|
|
46
|
+
|
|
47
|
+
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).
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
## Campos do QR
|
|
51
|
+
|
|
52
|
+
| Chave | Campo | `InvoiceQR` |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| A, B, C | NIF do emitente, NIF e país do adquirente | `issuer_nif`, `buyer_nif`, `buyer_country` |
|
|
55
|
+
| D, E, F, G | Tipo, estado, data e identificação do documento | `document_type`, `document_status`, `document_date`, `document_id` |
|
|
56
|
+
| H | ATCUD | `atcud` |
|
|
57
|
+
| I1–I8, J1–J8, K1–K8 | IVA por espaço fiscal (isento, reduzida, intermédia, normal) | `taxes` (`TaxBreakdown`) |
|
|
58
|
+
| 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` |
|
|
59
|
+
| Q, R, S | 4 caracteres do hash, n.º do certificado, outras informações | `hash_chars`, `certificate`, `other_info` |
|
|
60
|
+
|
|
61
|
+
## Desenvolvimento
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
uv sync --all-extras # ambiente com as dependências de desenvolvimento e de imagem
|
|
65
|
+
uv run pytest # testes, com cobertura mínima de 95 %
|
|
66
|
+
uv run ruff check . && uv run ruff format --check . && uv run mypy
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
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).
|
|
70
|
+
|
|
71
|
+
## Licença
|
|
72
|
+
|
|
73
|
+
MIT.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "faturapt"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Leitura e validação do código QR das faturas portuguesas (AT) e de NIFs."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "José Ferrão" }]
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
dependencies = []
|
|
15
|
+
keywords = ["portugal", "fatura", "invoice", "qr code", "nif", "atcud", "autoridade tributária"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Natural Language :: Portuguese",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Programming Language :: Python :: 3.14",
|
|
28
|
+
"Topic :: Office/Business :: Financial :: Accounting",
|
|
29
|
+
"Typing :: Typed",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Repository = "https://github.com/JoseFerrao20/faturapt"
|
|
34
|
+
Changelog = "https://github.com/JoseFerrao20/faturapt/blob/main/CHANGELOG.md"
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
faturapt = "faturapt.__main__:main"
|
|
38
|
+
|
|
39
|
+
[project.optional-dependencies]
|
|
40
|
+
image = [
|
|
41
|
+
"pillow>=10",
|
|
42
|
+
"zxing-cpp>=3.0",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[dependency-groups]
|
|
46
|
+
dev = [
|
|
47
|
+
"pytest>=8",
|
|
48
|
+
"pytest-cov>=5",
|
|
49
|
+
"ruff>=0.6",
|
|
50
|
+
"mypy>=1.11",
|
|
51
|
+
"hypothesis>=6.100",
|
|
52
|
+
"segno>=1.6.6",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[tool.hatch.version]
|
|
56
|
+
path = "src/faturapt/__init__.py"
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
line-length = 100
|
|
60
|
+
target-version = "py310"
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
select = ["E", "F", "W", "I", "B", "UP", "S", "SIM", "RUF"]
|
|
64
|
+
|
|
65
|
+
[tool.ruff.lint.isort]
|
|
66
|
+
known-local-folder = ["test_qr"] # os testes importam os exemplos uns dos outros
|
|
67
|
+
|
|
68
|
+
[tool.ruff.lint.per-file-ignores]
|
|
69
|
+
"tests/**" = ["S101", "E402"] # assert nos testes; imports depois do importorskip
|
|
70
|
+
|
|
71
|
+
[tool.mypy]
|
|
72
|
+
strict = true
|
|
73
|
+
files = ["src", "tests"]
|
|
74
|
+
|
|
75
|
+
[tool.pytest.ini_options]
|
|
76
|
+
addopts = "--doctest-glob=README.md --cov=faturapt --cov-report=term-missing --cov-fail-under=95"
|
|
@@ -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"
|
|
@@ -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())
|
|
@@ -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
|
+
)
|
|
@@ -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
|
|
File without changes
|