Skip to content

Commit 8645691

Browse files
committed
fix: align document wire format with the live Vendus API; add test mode
Live validation against the real Vendus API surfaced wire-shape bugs that the respx mocks hid (create_invoice had never actually worked end-to-end): - Line items now send `tax_id` (a VAT category — new `TaxCategory` enum) instead of the rejected `tax_rate`; `discount_percentage` instead of `discount`; `id` instead of `product_id`. The real API returned P001 for the old field names. - `cancel(document_id)` no longer sends `notes` and drops the `reason` argument — the document PATCH endpoint accepts only status/mode (no reason field exists). Added: - `mode` parameter (`DocumentMode` enum) on every create_* method to issue non-fiscal test-mode documents that Vendus does not report to the AT. - `Document.tax_authority_id` — empty until the AT is notified. - Live integration tests (excluded by default): create in test mode and read-only list/get against real documents. Docs: - Replaced the unsourced "Sandbox" section with a sourced "Testing" section. - README gains an honest per-operation unit/live validation matrix. - CLAUDE.md: "Always Honest, Never Assume" rule, eupago-reference.md adopted as the build playbook, R1 vocabulary updated, Sandbox TBD resolved. BREAKING CHANGE: DocumentItem.tax_rate -> tax_category; cancel() no longer takes reason.
1 parent 7e71ab7 commit 8645691

46 files changed

Lines changed: 1097 additions & 171 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
77
## [Unreleased]
88

99
### Added
10+
- `mode` parameter (`DocumentMode` enum: `NORMAL` / `TESTS`) on `create_invoice`,
11+
`create_invoice_receipt`, `create_credit_note` (sync + async) — issue non-fiscal
12+
test-mode documents that Vendus does not communicate to the AT
13+
- `Document.tax_authority_id` — the AT-generated id, empty until Vendus reports the
14+
document to the AT (lets you confirm a test document was not reported)
15+
- `TaxCategory` enum (`NORMAL`/`INTERMEDIATE`/`REDUCED`/`EXEMPT`/`OTHER`) for line-item VAT
16+
- Live integration tests (`tests/integration/`, excluded by default): issue an FT in
17+
test mode (asserting `tax_authority_id` is empty) and read-only `list`/`get` against
18+
real documents
1019
- Initial project skeleton
1120
- `VendusClient` with HTTP Basic Auth
1221
- `DocumentsService.create_invoice` (sync + async)
@@ -18,13 +27,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
1827
- Portuguese NIF validation
1928
- PII redaction filter for logging
2029
- HttpTransport with conditional POST retries (R3)
21-
- 69 tests with `respx` mocks (93% coverage)
30+
- 72 unit tests with `respx` mocks (94% coverage), plus live integration tests
2231
- Bilingual documentation (PT/EN) with mkdocs-material + i18n
2332
- 10 runnable examples, including an all-scenarios reference
2433
- CI workflow (ruff, mypy --strict, pytest on Python 3.9–3.13)
2534
- Docs auto-deploy to GitHub Pages
2635
- Issue/PR templates and Dependabot config
2736

37+
### Changed
38+
- **Breaking:** `DocumentItem.tax_rate` (a `Decimal` percentage) is now `tax_category`
39+
(a `TaxCategory` enum). Vendus classifies line-item VAT by category (`tax_id`:
40+
NOR/INT/RED/ISE/OUT), not by a numeric rate — the rate is defined by the category in
41+
the merchant's Vendus configuration.
42+
- **Breaking:** `cancel(document_id)` no longer takes a `reason` — the Vendus document
43+
PATCH endpoint has no field for a cancellation reason.
44+
- Docs: replaced the "Sandbox" section with a sourced "Testing" section. Vendus has
45+
no separate sandbox host; testing is done via document-level test mode. Corrects a
46+
prior unsourced claim that every call creates real fiscal documents.
47+
48+
### Fixed
49+
- Line items now send the field names the real Vendus API actually accepts — found by
50+
live validation, which returned `P001` for the old names: `tax_id` (was `tax_rate`),
51+
`discount_percentage` (was `discount`), `id` (was `product_id`). `create_invoice` had
52+
never succeeded against the live API before this.
53+
- `cancel` no longer sends `notes` (rejected by the document PATCH endpoint, which
54+
accepts only `status`/`mode`).
55+
2856
### Quality
2957
- `mypy --strict` passes with zero errors
3058
- `ruff check` / `ruff format` clean

CLAUDE.md

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,47 @@ Unofficial Python SDK for the [Vendus](https://www.vendus.pt) invoicing API (Por
88

99
Vendus is an AT-certified (Autoridade Tributária) invoicing/POS SaaS. The Vendus backend handles all communication with the AT (SAF-T, ATCUD, QR code, document hash). This SDK talks to Vendus; Vendus talks to AT. The SDK never communicates with the AT directly.
1010

11+
## Working Principle: Always Honest, Never Assume (non-negotiable)
12+
13+
This rule overrides convenience. It applies to code, comments, docs, commit messages, and every reply to the user.
14+
15+
- **State only what is verified.** If something is not confirmed, say so explicitly and label it as an assumption or unknown — never present a guess or general knowledge as established fact.
16+
- **Cite the source for any claim about the Vendus API or AT behavior.** No source → mark it `TBD` in the code/docs and flag it for verification. Do not write unsourced assertions into user-facing docs.
17+
- **When asked "how do we know X?"**, if there is no verified source, say so plainly instead of rationalizing after the fact.
18+
- **When uncertain, verify or ask before acting.** Don't fill gaps with plausible-sounding details.
19+
- **No contradictions between artifacts.** If the docs claim something the code/CLAUDE.md still lists as `TBD`, that is a bug — resolve it, don't paper over it.
20+
21+
> Origin: the docs once stated "Vendus has no public sandbox" as fact, with no cited source, while CLAUDE.md still listed sandbox as `TBD — investigate`. That unsourced assertion is exactly what this rule forbids.
22+
23+
## Build Playbook — `eupago-reference.md` is the foundation
24+
25+
The canonical playbook for building this SDK is [`eupago-reference.md`](eupago-reference.md) at the repo root — the distilled engineering discipline from the sibling `eupago-python` SDK. **Read it before any substantial work.** It is the *why*; the rules in this file are the Vendus-specific *how*.
26+
27+
- **R1–R15 below are the Vendus application of that playbook.** Adapt, don't copy: where the Vendus domain demands a different choice, diverge **deliberately and document it** — e.g. R3's conditional POST retries vs. the playbook's blanket no-retry, because Vendus accepts `external_reference` as a dedup anchor.
28+
- The playbook's identity, architecture, naming, money/PII/validation and quality rules are already encoded as R1–R15 and the Architecture section. The two disciplines below are imported here explicitly because they are **not** yet encoded elsewhere in this file and this project has already been bitten by their absence.
29+
30+
### Live-validation discipline (`eupago-reference.md` §4, §8) — non-negotiable
31+
32+
Two test layers, both required:
33+
34+
| Layer | Tool | Runs | Purpose |
35+
|---|---|---|---|
36+
| Unit | `pytest` + `respx` | every commit / CI | guard the **exact wire body**, validation, sync/async parity |
37+
| Live | `pytest -m integration` | on demand | prove the SDK works against the **real Vendus API** end-to-end |
38+
39+
- Unit tests **assert the exact JSON sent on the wire**, not just the return value — that is how latent field-name/shape bugs are caught (`body = json.loads(route.calls[0].request.content); assert body == {...}`).
40+
- Live tests live in `tests/integration/`, are marked `@pytest.mark.integration` (excluded from the default `pytest` run), and **auto-skip** when `VENDUS_API_KEY` is absent — no false failures on machines without creds.
41+
- **One live test per operation**, exercising the full loop (SDK → Vendus → parse). Run them against the **test-mode register configured in `.env`** (`VENDUS_REGISTER_ID`, a register whose `mode` is `tests`) so live tests issue **non-fiscal** documents that are never reported to the AT. Concretely: assert a created test document comes back with an empty `tax_authority_id` (that field is only set once Vendus has communicated the document to the AT).
42+
- **"If you didn't run it against the real Vendus API, it isn't done."** The Vendus `.doc` reference pages describe what *should* happen; verify the actual wire shape live before claiming an operation works. This is the operational form of the *Always Honest, Never Assume* rule above.
43+
44+
### Honesty in status reporting (`eupago-reference.md` §4, §8.1, §10.3)
45+
46+
- README / CHANGELOG / roadmap use a **per-operation matrix** (Unit ✅ / Live ✅), never a blanket "service done".
47+
- Never mark a row **Done** without a live test — or a live test that **skips with a documented reason**. A skipped-with-reason test is honest; a green test that never hit the API is a trap.
48+
- When the upstream docs turn out wrong or incomplete: fix the SDK, add a unit test asserting the **corrected** wire body, then the live test passes — and record the divergence (with the Vendus error it fixed) in the CHANGELOG.
49+
50+
For situations not covered here — webhooks, multiple identifiers for one resource, form-vs-JSON bodies, operations that don't return a field the docs promise — consult `eupago-reference.md` §7–§8 when they arise.
51+
1152
## Scope (v0.1.0 — MVP)
1253

1354
- Issue invoices (FT)
@@ -102,6 +143,7 @@ The Vendus API uses Portuguese-influenced naming (`fiscal_id`, `amount_gross`).
102143
| Document number | `number` | `number` | `str` |
103144
| Document type | `type` | `type` | `DocumentType` enum |
104145
| Subtype | `subtype` | `subtype` | `str` |
146+
| Working mode | `mode` | `mode` | `DocumentMode` enum |
105147
| Date issued | `date` | `date` | `datetime` |
106148
| Local time | `local_time` | `local_time` | `datetime` |
107149
| System time | `system_time` | `system_time` | `datetime` |
@@ -110,13 +152,16 @@ The Vendus API uses Portuguese-influenced naming (`fiscal_id`, `amount_gross`).
110152
| Tax amount | `tax_amount` | (derived) | `Decimal` |
111153
| AT hash | `hash` | `hash` | `str` |
112154
| ATCUD | `atcud` | `atcud` | `str` |
155+
| AT document ID | `tax_authority_id` | `tax_authority_id` | `str` |
113156
| QR code data | `qrcode` | `qrcode` | `str` |
114157
| External reference | `external_reference` | `external_reference` | `str` |
115158
| Reference document | `reference_document_id` | (credit note ref) | `int` |
116159
| Item description | `description` | `title` | `str` |
117160
| Item quantity | `quantity` | `qty` | `Decimal` |
118161
| Item unit price | `unit_price` | `gross_price` | `Decimal` |
119-
| Tax rate | `tax_rate` | `tax_rate` | `Decimal` |
162+
| Item discount % | `discount` | `discount_percentage` | `Decimal` |
163+
| Product id | `product_id` | `id` | `int` |
164+
| Tax category | `tax_category` | `tax_id` | `TaxCategory` enum |
120165
| Tax exemption | `tax_exemption` | `tax_exemption` | `TaxExemption` enum |
121166

122167
**When adding a new resource:** look at its API fields. Use the table. If a new concept appears, add it here and use it consistently.
@@ -308,7 +353,7 @@ async def create_receipt_async(self, ...) -> Document: ...
308353
### Base URLs
309354
- Production: `https://www.vendus.pt/ws/`
310355
- Spain: `https://www.vendus.es/ws/`
311-
- Sandbox: TBD — investigate during v0.1 dev
356+
- Sandbox: **none.** Vendus has no separate sandbox host. Testing is done via a document-level test mode — pass `mode=tests` on a create call, or use a register configured in `tests` mode (new accounts default to this). Test documents are non-fiscal and not communicated to the AT (their `tax_authority_id` stays empty). Sources: [documents.doc](https://www.vendus.pt/ws/v1.1/documents.doc), [registers.doc](https://www.vendus.pt/ws/v1.1/registers.doc), [Modo de Formação/Testes](https://www.vendus.cv/ajuda/modo-formacao-testes/). Not yet live-verified: whether a per-request `mode=tests` overrides a `normal` register.
312357

313358
### Endpoint Versions
314359
- Documents: `v1.1` (`/ws/v1.1/documents/`)

README.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Requires Python 3.9+. No additional dependencies beyond [httpx](https://www.pyth
2727

2828
```python
2929
from decimal import Decimal
30-
from vendus import VendusClient, ClientData, DocumentItem
30+
from vendus import ClientData, DocumentItem, TaxCategory, VendusClient
3131

3232
client = VendusClient(api_key="your-api-key")
3333

@@ -40,7 +40,7 @@ invoice = client.documents.create_invoice(
4040
description="Consulting hours",
4141
quantity=Decimal("10"),
4242
unit_price=Decimal("75.00"), # gross (includes tax)
43-
tax_rate=Decimal("23"),
43+
tax_category=TaxCategory.NORMAL,
4444
),
4545
],
4646
external_reference="ORD-2026-001", # enables safe POST retries
@@ -100,7 +100,7 @@ credit_note = client.documents.create_credit_note(
100100
description="Consulting hours (credited)",
101101
quantity=Decimal("2"),
102102
unit_price=Decimal("75.00"),
103-
tax_rate=Decimal("23"),
103+
tax_category=TaxCategory.NORMAL,
104104
),
105105
],
106106
external_reference="REFUND-2026-001",
@@ -168,6 +168,20 @@ client = VendusClient.from_env()
168168
| Guia de Transporte | GT || v0.2 |
169169
| Nota de Débito | ND || v0.2 |
170170

171+
### Validation status
172+
173+
The wire format of every operation is asserted by unit tests (respx mocks). Live validation runs against the real Vendus API in **test mode** (`mode=tests`) — non-fiscal documents that are never reported to the AT:
174+
175+
| Operation | Unit | Live |
176+
|---|:-:|---|
177+
| `create_invoice` (FT) || ✅ test mode — non-fiscal, `tax_authority_id` empty |
178+
| `create_invoice_receipt` (FR) || ⚠️ not yet run live |
179+
| `create_credit_note` (NC) || ⚠️ not yet run live |
180+
| `get` / `list` || ✅ read-only, against real documents |
181+
| `cancel` || ⚠️ not live-validated (see note) |
182+
183+
> Test-mode documents ("Modo de Formação") are non-fiscal and never reported to the AT. Vendus stores them in a separate space, so they can't be retrieved or cancelled via `/documents/{id}`. Live `get`/`list` are validated read-only against real documents; `cancel` is not live-validated because voiding a real fiscal document is destructive.
184+
171185
## Why This SDK
172186

173187
- **Fully typed**`mypy --strict` passes, `py.typed` marker included. Full autocomplete in VS Code and PyCharm.

docs/documents/credit-note.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ sequenceDiagram
3232

3333
```python
3434
from decimal import Decimal
35-
from vendus import VendusClient, ClientData, DocumentItem
35+
from vendus import ClientData, DocumentItem, TaxCategory, VendusClient
3636

3737
client = VendusClient.from_env()
3838

@@ -49,7 +49,7 @@ nc = client.documents.create_credit_note(
4949
description="Consulting (credited)",
5050
quantity=Decimal("2"),
5151
unit_price=Decimal("75.00"),
52-
tax_rate=Decimal("23"),
52+
tax_category=TaxCategory.NORMAL,
5353
),
5454
],
5555
external_reference="REFUND-2026-001",
@@ -84,5 +84,5 @@ nc = await client.documents.create_credit_note_async(
8484
## Notes
8585

8686
1. **Full vs partial NC:** if refunding the whole amount, replicate all items. For partial refunds, include only the items/quantities to credit.
87-
2. **NC is not cancellation:** an NC **credits** the value but keeps the original document. To fully void, use `client.documents.cancel(id, reason)` instead.
87+
2. **NC is not cancellation:** an NC **credits** the value but keeps the original document. To fully void, use `client.documents.cancel(id)` instead.
8888
3. **Consistent client:** if the original was to Final Consumer, the NC should also omit `client`.

docs/documents/credit-note.pt.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ sequenceDiagram
3232

3333
```python
3434
from decimal import Decimal
35-
from vendus import VendusClient, ClientData, DocumentItem
35+
from vendus import ClientData, DocumentItem, TaxCategory, VendusClient
3636

3737
client = VendusClient.from_env()
3838

@@ -49,7 +49,7 @@ nc = client.documents.create_credit_note(
4949
description="Consultoria (creditada)",
5050
quantity=Decimal("2"),
5151
unit_price=Decimal("75.00"),
52-
tax_rate=Decimal("23"),
52+
tax_category=TaxCategory.NORMAL,
5353
),
5454
],
5555
external_reference="REFUND-2026-001",
@@ -84,5 +84,5 @@ nc = await client.documents.create_credit_note_async(
8484
## Notas
8585

8686
1. **NC total vs parcial:** se devolves o total, replica todos os itens. Se devolves uma parte, inclui só os itens/quantidades a creditar.
87-
2. **Não é cancelamento:** uma NC **credita** o valor mas mantém o documento original. Para cancelar completamente, usa `client.documents.cancel(id, reason)` em alternativa.
87+
2. **Não é cancelamento:** uma NC **credita** o valor mas mantém o documento original. Para cancelar completamente, usa `client.documents.cancel(id)` em alternativa.
8888
3. **Cliente coerente:** se o original foi a Consumidor Final, a NC também deve omitir `client`.

docs/documents/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ The SDK validates locally **before** hitting the API:
4545

4646
- **Portuguese NIF:** mod 11 algorithm (rejects bad check digits)
4747
- **NIF 999999990:** explicitly rejected
48-
- **Items:** at least one, `quantity > 0`, `0 ≤ tax_rate ≤ 100`
48+
- **Items:** at least one, `quantity > 0`, a `tax_category` (NORMAL/INTERMEDIATE/REDUCED/EXEMPT/OTHER)
4949
- **Credit Note:** requires `reference_document_id` and `reason`
5050
- **Cancellation:** requires `reason`
5151

docs/documents/index.pt.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ O SDK valida localmente **antes** de tocar na API:
4545

4646
- **NIF português:** algoritmo mod 11 (rejeita check digits errados)
4747
- **NIF 999999990:** explicitamente rejeitado
48-
- **Items:** pelo menos um, `quantity > 0`, `0 ≤ tax_rate ≤ 100`
48+
- **Items:** pelo menos um, `quantity > 0`, uma `tax_category` (NORMAL/INTERMEDIATE/REDUCED/EXEMPT/OTHER)
4949
- **Nota de Crédito:** exige `reference_document_id` e `reason`
5050
- **Cancelamento:** exige `reason`
5151

docs/documents/invoice-receipt.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ The Invoice-Receipt (FR) is **an invoice and a receipt in one document**: it bil
1212

1313
```python
1414
from decimal import Decimal
15-
from vendus import VendusClient, ClientData, DocumentItem
15+
from vendus import ClientData, DocumentItem, TaxCategory, VendusClient
1616

1717
client = VendusClient.from_env()
1818

@@ -24,7 +24,7 @@ fr = client.documents.create_invoice_receipt(
2424
description="Consulting session (paid on the spot)",
2525
quantity=Decimal("1"),
2626
unit_price=Decimal("90.00"),
27-
tax_rate=Decimal("23"),
27+
tax_category=TaxCategory.NORMAL,
2828
),
2929
],
3030
external_reference="FR-2026-001",
@@ -73,5 +73,5 @@ fr = await client.documents.create_invoice_receipt_async(
7373
## Notes
7474

7575
1. **Immediate payment:** FR assumes payment happens at issue time. If you bill on credit, use `create_invoice` (FT) and issue the receipt (RC) when the client pays — RC comes in a future version.
76-
2. **Cancellation:** same API (`client.documents.cancel(id, reason)`).
76+
2. **Cancellation:** same API (`client.documents.cancel(id)`).
7777
3. **Credit note:** an FR can be credited via `create_credit_note` referencing the FR's `id`.

docs/documents/invoice-receipt.pt.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ A Fatura-Recibo (FR) é **fatura e recibo num só documento**: factura a venda *
1212

1313
```python
1414
from decimal import Decimal
15-
from vendus import VendusClient, ClientData, DocumentItem
15+
from vendus import ClientData, DocumentItem, TaxCategory, VendusClient
1616

1717
client = VendusClient.from_env()
1818

@@ -24,7 +24,7 @@ fr = client.documents.create_invoice_receipt(
2424
description="Sessão de consultoria (paga na hora)",
2525
quantity=Decimal("1"),
2626
unit_price=Decimal("90.00"),
27-
tax_rate=Decimal("23"),
27+
tax_category=TaxCategory.NORMAL,
2828
),
2929
],
3030
external_reference="FR-2026-001",
@@ -73,5 +73,5 @@ fr = await client.documents.create_invoice_receipt_async(
7373
## Notas
7474

7575
1. **Pagamento imediato:** a FR pressupõe que o pagamento ocorre no momento da emissão. Se faturas a crédito, usa `create_invoice` (FT) e emite o recibo (RC) quando o cliente pagar — RC chega numa versão futura.
76-
2. **Cancelamento:** mesma API (`client.documents.cancel(id, reason)`).
76+
2. **Cancelamento:** mesma API (`client.documents.cancel(id)`).
7777
3. **Nota de crédito:** uma FR pode ser creditada via `create_credit_note` referenciando o `id` da FR.

docs/documents/invoice.md

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ sequenceDiagram
3232

3333
```python
3434
from decimal import Decimal
35-
from vendus import VendusClient, ClientData, DocumentItem
35+
from vendus import ClientData, DocumentItem, TaxCategory, VendusClient
3636

3737
client = VendusClient.from_env()
3838

@@ -52,7 +52,7 @@ invoice = client.documents.create_invoice(
5252
description="Consulting",
5353
quantity=Decimal("10"),
5454
unit_price=Decimal("75.00"),
55-
tax_rate=Decimal("23"),
55+
tax_category=TaxCategory.NORMAL,
5656
),
5757
],
5858
external_reference="ORD-2026-001",
@@ -105,7 +105,7 @@ invoice = await client.documents.create_invoice_async(
105105

106106
## VAT-exempt items
107107

108-
When `tax_rate=0`, AT requires an exemption code (M01-M99):
108+
When `tax_category=TaxCategory.EXEMPT`, AT requires an exemption code (M01-M99):
109109

110110
```python
111111
from vendus import DocumentItem, TaxExemption
@@ -114,7 +114,7 @@ DocumentItem(
114114
description="Education service",
115115
quantity=Decimal("1"),
116116
unit_price=Decimal("100.00"),
117-
tax_rate=Decimal("0"),
117+
tax_category=TaxCategory.EXEMPT,
118118
tax_exemption=TaxExemption.M07, # Exempt Article 9.º CIVA
119119
)
120120
```
@@ -132,13 +132,10 @@ Common codes:
132132
## Cancel an invoice
133133

134134
```python
135-
cancelled = client.documents.cancel(
136-
document_id=invoice.id,
137-
reason="Wrong client",
138-
)
135+
cancelled = client.documents.cancel(document_id=invoice.id)
139136
```
140137

141-
AT requires `reason`. Cancellation is non-idempotent — do not pass max_retries > 0.
138+
Vendus has no API field for a cancellation reason, so none is sent. Cancellation is non-idempotent — do not pass max_retries > 0.
142139

143140
## Notes
144141

0 commit comments

Comments
 (0)