Skip to content

Commit 911d520

Browse files
committed
feat: live-validate FR + credit notes; payments, NC redesign, cancel guard
Functional testing against the real Vendus API (test mode, plus one real FR+NC cycle) surfaced several more wire-shape bugs the mocks hid, and the SDK now matches the verified behaviour. Fatura-Recibo (FR): - create_invoice_receipt now requires `payments` — an FR records payment on issue and Vendus rejects it otherwise ("o pagamento deve ser realizado no ato"). - New `Payment` input model and `list_payment_methods()` (+ `PaymentMethod`), since payment-method ids are account-specific (GET /v1.0/payments). Credit note (NC): - create_credit_note(reference_document_id, reason) redesigned: it GETs the original and credits its full set of lines, referencing each via `reference_document` (number + row) + the original line id. The previous top-level `reference_document_id` was rejected by the API (P001); the method had never worked. No more inline items/client/register_id. Cancel: - cancel() fetches the document and refuses FT/FR/NC — fiscal documents cannot be cancelled ("Não é permitido cancelar este tipo de documentos"); the SDK raises ValidationError pointing to create_credit_note. Robustness: - _parse_document tolerates unknown type codes (the live account returned `RG`, absent from documents/types) via a `DocumentType.UNKNOWN` sentinel; the raw code is preserved in raw_response. Docs/examples updated across EN + PT (FR payments, NC signature, "FT cannot be cancelled"), README validation matrix refreshed, CLAUDE.md R13 reworked and R16 added with the live-verified wire facts. 79 unit tests + 4 live integration tests; ruff/mypy/pytest/mkdocs --strict all green. BREAKING CHANGE: create_invoice_receipt requires payments; create_credit_note drops register_id/items/client; cancel refuses FT/FR/NC.
1 parent 8645691 commit 911d520

24 files changed

Lines changed: 646 additions & 243 deletions

CHANGELOG.md

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
1313
- `Document.tax_authority_id` — the AT-generated id, empty until Vendus reports the
1414
document to the AT (lets you confirm a test document was not reported)
1515
- `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
16+
- `Payment` model and `list_payment_methods()` (sync + async) — a Fatura-Recibo now
17+
takes `payments=[Payment(method_id=..., amount=...)]`; method ids are account-specific
18+
- `PaymentMethod` model returned by `list_payment_methods()`
19+
- `DocumentType.UNKNOWN` — forward-compat sentinel so the SDK does not crash on type
20+
codes the API returns but the enum does not model (e.g. `RG`); the raw code stays in
21+
`raw_response`
22+
- Live integration tests (`tests/integration/`, excluded by default): FT and FR in test
23+
mode, plus read-only `list_payment_methods`/`list`/`get` against real documents
1924
- Initial project skeleton
2025
- `VendusClient` with HTTP Basic Auth
2126
- `DocumentsService.create_invoice` (sync + async)
@@ -27,7 +32,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
2732
- Portuguese NIF validation
2833
- PII redaction filter for logging
2934
- HttpTransport with conditional POST retries (R3)
30-
- 72 unit tests with `respx` mocks (94% coverage), plus live integration tests
35+
- 79 unit tests with `respx` mocks (94% coverage), plus live integration tests
3136
- Bilingual documentation (PT/EN) with mkdocs-material + i18n
3237
- 10 runnable examples, including an all-scenarios reference
3338
- CI workflow (ruff, mypy --strict, pytest on Python 3.9–3.13)
@@ -40,7 +45,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4045
NOR/INT/RED/ISE/OUT), not by a numeric rate — the rate is defined by the category in
4146
the merchant's Vendus configuration.
4247
- **Breaking:** `cancel(document_id)` no longer takes a `reason` — the Vendus document
43-
PATCH endpoint has no field for a cancellation reason.
48+
PATCH endpoint has no field for a cancellation reason. It now also fetches the document
49+
and **refuses to cancel FT/FR/NC** (fiscal documents cannot be cancelled; reverse them
50+
with a credit note), raising `ValidationError` with that guidance.
51+
- **Breaking:** `create_credit_note(reference_document_id, reason, ...)` no longer takes
52+
`register_id`/`items`/`client`. It fetches the original and credits its full set of
53+
lines (the original must be a real, retrievable document). The previous signature never
54+
worked: Vendus rejected the old top-level `reference_document_id` field.
55+
- **Breaking:** `create_invoice_receipt(...)` now requires `payments` — an FR records
56+
payment on issue and Vendus rejects it otherwise.
4457
- Docs: replaced the "Sandbox" section with a sourced "Testing" section. Vendus has
4558
no separate sandbox host; testing is done via document-level test mode. Corrects a
4659
prior unsourced claim that every call creates real fiscal documents.
@@ -52,6 +65,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5265
never succeeded against the live API before this.
5366
- `cancel` no longer sends `notes` (rejected by the document PATCH endpoint, which
5467
accepts only `status`/`mode`).
68+
- Credit notes now build the wire body Vendus accepts — found live: each credit line
69+
references the original via `reference_document` (number + row) and the original line
70+
id, instead of the rejected top-level `reference_document_id`.
71+
- `_parse_document` tolerates unknown document type codes instead of raising
72+
(the live account returned `RG`, which is absent from the documents/types reference).
5573

5674
### Quality
5775
- `mypy --strict` passes with zero errors

CLAUDE.md

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -294,16 +294,14 @@ client.documents.create_quote(...) # OR
294294

295295
Reason: each type has different mandatory/forbidden fields. A credit note **requires** `reference_document_id`; an invoice doesn't. Type-checking and autocomplete are better with dedicated methods.
296296

297-
### R13: Credit Notes Always Reference an Original Document
297+
### R13: Credit Notes Credit a Real Original Document
298298

299-
Validated locally before any API call:
299+
`create_credit_note(reference_document_id, reason)` credits the **full** original. Vendus requires each credit line to reference an existing line of the original (`reference_document` = document number + 1-based row) and to carry that line's id, so the SDK **GETs the original** (FT/FR) and replicates its lines. Consequences:
300300

301-
```python
302-
if reference_document_id is None:
303-
raise ValidationError("Credit note requires reference_document_id")
304-
```
305-
306-
The original document must be a previously-issued invoice (FT) or invoice-receipt (FR). Vendus validates the rest server-side.
301+
- The original must be **retrievable** → a real document, not a test-mode one (test docs aren't addressable).
302+
- No inline `items`/`client` — they come from the original.
303+
- Partial credits are not supported in v0.1.
304+
- There is **no** top-level `reference_document_id` on the wire — Vendus rejects it (`P001`); the link lives per item.
307305

308306
### R14: Inline Client Only (v0.1.0)
309307

@@ -318,6 +316,17 @@ If the app needs to track Vendus client IDs locally, that is its responsibility.
318316

319317
In Portuguese invoicing, "consumidor final" (final consumer) is represented by omitting the client entirely, NOT by passing `fiscal_id=999999990`. R7 enforces this at validation time.
320318

319+
### R16: Live-verified Wire Facts (do not regress)
320+
321+
These were confirmed against the **real** Vendus API; changing them silently re-breaks the SDK. Each was a bug the respx mocks hid until live-validation caught it:
322+
323+
- **Line items** send `tax_id` (a `TaxCategory` code — `NOR`/`INT`/`RED`/`ISE`/`OUT`), not `tax_rate`; `discount_percentage`, not `discount`; `id` for a product line, not `product_id`. Wrong names → `P001`.
324+
- **FR (Fatura-Recibo) requires `payments`**`[{"id": <method_id>, "amount": <gross>}]`. `method_id` is **account-specific**; list it via `list_payment_methods()` (`GET /v1.0/payments`). Missing → "o pagamento deve ser realizado no ato".
325+
- **Cancel** = `PATCH /documents/{id}` with `{"status":"A"}` only (no reason field). **FT/FR/NC cannot be cancelled** ("Não é permitido cancelar este tipo de documentos") — the SDK fetches the type and refuses, pointing to a credit note.
326+
- **Test-mode documents** live in a separate space: not retrievable/cancellable via `/documents/{id}` (404 "não existe").
327+
- **Unknown type codes** (e.g. `RG`, seen live, absent from documents/types) must not crash parsing → `DocumentType.UNKNOWN`, raw code kept in `raw_response`.
328+
- `tax_authority_id` is **empty in the POST response even for real fiscal documents** (ATCUD/hash mark a doc fiscal), so it is not a reliable test-vs-real discriminator at create time — the series prefix is (`FT T01P…` test vs `FT 01P…` real).
329+
321330
---
322331

323332
## How to Add a New Document Type (Future Versions)

README.md

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -88,21 +88,14 @@ Do NOT pass `fiscal_id="999999990"` — the SDK rejects it. For final consumer,
8888

8989
## Credit Notes
9090

91-
A credit note (NC) always references a previously issued document:
91+
A credit note (NC) credits a previously issued invoice. It is also the only way
92+
to reverse a fiscal invoice — FT/FR **cannot be cancelled**. The SDK fetches the
93+
original and credits its full set of lines, so you pass only the id and a reason:
9294

9395
```python
9496
credit_note = client.documents.create_credit_note(
95-
register_id=1,
9697
reference_document_id=invoice.id,
9798
reason="Customer return",
98-
items=[
99-
DocumentItem(
100-
description="Consulting hours (credited)",
101-
quantity=Decimal("2"),
102-
unit_price=Decimal("75.00"),
103-
tax_category=TaxCategory.NORMAL,
104-
),
105-
],
10699
external_reference="REFUND-2026-001",
107100
)
108101
```
@@ -170,17 +163,17 @@ client = VendusClient.from_env()
170163

171164
### Validation status
172165

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:
166+
The wire format of every operation is asserted by unit tests (respx mocks), and validated against the real Vendus API in **test mode** (`mode=tests`, non-fiscal) where possible, and once in real mode for the operations that test mode can't reach:
174167

175168
| Operation | Unit | Live |
176169
|---|:-:|---|
177170
| `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) |
171+
| `create_invoice_receipt` (FR) || ✅ test mode — requires `payments` |
172+
| `create_credit_note` (NC) || ✅ real mode once (credited a real FR); test-mode originals aren't retrievable |
173+
| `list_payment_methods` / `list` / `get` || ✅ read-only |
174+
| `cancel` || ⚠️ FT/FR/NC can't be cancelled — the SDK refuses them (reverse with a credit note) |
182175

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.
176+
> Test-mode documents ("Modo de Formação") are non-fiscal and never reported to the AT, but Vendus stores them in a separate space they can't be retrieved or cancelled via `/documents/{id}`. So credit notes (which must read the original) are validated in real mode, and `cancel` is not live-validated (FT/FR/NC are not cancellable; other types would require voiding a real document).
184177
185178
## Why This SDK
186179

docs/documents/credit-note.md

Lines changed: 17 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,11 @@
22

33
## What it is
44

5-
A Credit Note (NC) cancels or partially credits a previously issued document (FT or FS). It is the legal mechanism for returns, retroactive discounts, or correcting wrong invoices.
5+
A Credit Note (NC) credits a previously issued invoice (FT or FR). It is the legal mechanism for returns, refunds, and corrections — and the **only** way to reverse a fiscal invoice, which cannot be cancelled.
66

77
- **Always references** an original document (`reference_document_id`)
88
- **Reason is mandatory** (`reason`) — required by AT
9-
- Client should match the original document's client
10-
- Can be partial (refunding only some items)
9+
- Credits the **full** original document: the SDK fetches the original and replicates its lines, so the client and amounts come from it (partial credits are not supported in v0.1)
1110

1211
## Flow
1312

@@ -18,10 +17,10 @@ sequenceDiagram
1817
participant API as Vendus API
1918
participant AT
2019
21-
Note over App: Original: client bought 10 hours
22-
App->>SDK: create_credit_note(<br/>reference_document_id=12345,<br/>reason="Refund 2h",<br/>items=[2 hours])
23-
SDK->>SDK: validate reason + reference_id
24-
SDK->>API: POST /v1.1/documents (NC)
20+
App->>SDK: create_credit_note(reference_document_id=12345, reason="Refund")
21+
SDK->>API: GET /v1.1/documents/12345 (read the original's lines)
22+
API-->>SDK: original document
23+
SDK->>API: POST /v1.1/documents (NC, crediting each line)
2524
API->>AT: report NC
2625
AT-->>API: hash + ATCUD
2726
API-->>SDK: Document JSON
@@ -31,58 +30,45 @@ sequenceDiagram
3130
## Full example
3231

3332
```python
34-
from decimal import Decimal
35-
from vendus import ClientData, DocumentItem, TaxCategory, VendusClient
33+
from vendus import VendusClient
3634

3735
client = VendusClient.from_env()
3836

39-
# Assume invoice.id was stored from the original issue
37+
# invoice.id was stored when you issued the original
4038
original_invoice_id = 12345
4139

4240
nc = client.documents.create_credit_note(
43-
register_id=1,
4441
reference_document_id=original_invoice_id,
45-
reason="Client returned 2 consulting hours",
46-
client=ClientData(name="Acme Lda", fiscal_id="123456789"),
47-
items=[
48-
DocumentItem(
49-
description="Consulting (credited)",
50-
quantity=Decimal("2"),
51-
unit_price=Decimal("75.00"),
52-
tax_category=TaxCategory.NORMAL,
53-
),
54-
],
42+
reason="Client returned the service",
5543
external_reference="REFUND-2026-001",
5644
)
5745

5846
print(nc.number) # "NC 2026/4"
59-
print(nc.gross_amount) # Decimal("184.50") — 2 × 75 × 1.23
47+
print(nc.gross_amount) # the credited amount
6048
```
6149

6250
## Parameters
6351

6452
| Parameter | Type | Required | Description |
6553
|---|---|---|---|
66-
| `register_id` | `int` | Yes | POS register ID configured in Vendus |
67-
| `reference_document_id` | `int` | Yes | ID of the original FT/FS being credited |
54+
| `reference_document_id` | `int` | Yes | id of the original FT/FR being credited |
6855
| `reason` | `str` | Yes | Reason for the credit note (required by AT) |
69-
| `items` | `list[DocumentItem]` | Yes | Items to credit (can be a subset of the original) |
70-
| `client` | `ClientData \| None` | No | Should match the original document's client |
7156
| `external_reference` | `str` | No | Enables safe POST retries |
57+
| `mode` | `DocumentMode \| None` | No | `TESTS` for a non-fiscal test NC |
58+
59+
The client, items and amounts are read from the original document — you do not pass them.
7260

7361
## Async variant
7462

7563
```python
7664
nc = await client.documents.create_credit_note_async(
77-
register_id=1,
7865
reference_document_id=12345,
7966
reason="...",
80-
items=[...],
8167
)
8268
```
8369

8470
## Notes
8571

86-
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)` instead.
88-
3. **Consistent client:** if the original was to Final Consumer, the NC should also omit `client`.
72+
1. **Full credit only (v0.1):** the SDK credits every line of the original. Partial credits (some lines or quantities) are a future addition.
73+
2. **NC is how you reverse an invoice:** fiscal invoices (FT/FR) **cannot be cancelled** `cancel()` rejects them. Issue an NC to credit the original instead.
74+
3. **Real documents only:** the original must be retrievable, so credit notes work on **real** documents, not test-mode ones (which are not addressable via `/documents/{id}`).

docs/documents/credit-note.pt.md

Lines changed: 20 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,11 @@
22

33
## O que é
44

5-
A Nota de Crédito (NC) anula ou credita parcialmente um documento já emitido (FT ou FS). É o mecanismo legal para devoluções, descontos retroativos, ou correções de faturas erradas.
5+
Uma Nota de Crédito (NC) credita uma fatura emitida anteriormente (FT ou FR). É o mecanismo legal para devoluções, reembolsos e correções — e a **única** forma de reverter uma fatura fiscal, que não pode ser cancelada.
66

77
- **Referencia sempre** um documento original (`reference_document_id`)
88
- **Motivo obrigatório** (`reason`) — exigido pela AT
9-
- O cliente deve coincidir com o do documento original
10-
- Pode ser parcial (devolução de só alguns itens)
9+
- Credita o documento original **por inteiro**: o SDK vai buscar o original e replica as suas linhas, por isso o cliente e os valores vêm dele (créditos parciais não são suportados no v0.1)
1110

1211
## Fluxo
1312

@@ -18,11 +17,11 @@ sequenceDiagram
1817
participant API as Vendus API
1918
participant AT
2019
21-
Note over App: Originalmente: cliente comprou 10 horas
22-
App->>SDK: create_credit_note(<br/>reference_document_id=12345,<br/>reason="Devolução 2h",<br/>items=[2 horas])
23-
SDK->>SDK: validar reason + reference_id
24-
SDK->>API: POST /v1.1/documents (NC)
25-
API->>AT: comunicar NC
20+
App->>SDK: create_credit_note(reference_document_id=12345, reason="Devolução")
21+
SDK->>API: GET /v1.1/documents/12345 (lê as linhas do original)
22+
API-->>SDK: documento original
23+
SDK->>API: POST /v1.1/documents (NC, a creditar cada linha)
24+
API->>AT: comunica NC
2625
AT-->>API: hash + ATCUD
2726
API-->>SDK: Document JSON
2827
SDK-->>App: Document(NC)
@@ -31,58 +30,45 @@ sequenceDiagram
3130
## Exemplo completo
3231

3332
```python
34-
from decimal import Decimal
35-
from vendus import ClientData, DocumentItem, TaxCategory, VendusClient
33+
from vendus import VendusClient
3634

3735
client = VendusClient.from_env()
3836

39-
# Assume que invoice.id foi guardado da emissão original
37+
# invoice.id foi guardado quando emitiste o original
4038
original_invoice_id = 12345
4139

4240
nc = client.documents.create_credit_note(
43-
register_id=1,
4441
reference_document_id=original_invoice_id,
45-
reason="Cliente devolveu 2 horas de consultoria",
46-
client=ClientData(name="Acme Lda", fiscal_id="123456789"),
47-
items=[
48-
DocumentItem(
49-
description="Consultoria (creditada)",
50-
quantity=Decimal("2"),
51-
unit_price=Decimal("75.00"),
52-
tax_category=TaxCategory.NORMAL,
53-
),
54-
],
42+
reason="Cliente devolveu o serviço",
5543
external_reference="REFUND-2026-001",
5644
)
5745

5846
print(nc.number) # "NC 2026/4"
59-
print(nc.gross_amount) # Decimal("184.50") — 2 × 75 × 1.23
47+
print(nc.gross_amount) # o valor creditado
6048
```
6149

6250
## Parâmetros
6351

6452
| Parâmetro | Tipo | Obrigatório | Descrição |
6553
|---|---|---|---|
66-
| `register_id` | `int` | Sim | ID do POS configurado na Vendus |
67-
| `reference_document_id` | `int` | Sim | ID da FT/FS original a creditar |
68-
| `reason` | `str` | Sim | Motivo da NC (exigido pela AT) |
69-
| `items` | `list[DocumentItem]` | Sim | Itens a creditar (podem ser subset do original) |
70-
| `client` | `ClientData \| None` | Não | Deve coincidir com o cliente do original |
71-
| `external_reference` | `str` | Não | Habilita retry seguro do POST |
54+
| `reference_document_id` | `int` | Sim | id da FT/FR original a creditar |
55+
| `reason` | `str` | Sim | Motivo da nota de crédito (exigido pela AT) |
56+
| `external_reference` | `str` | Não | Permite retries seguros do POST |
57+
| `mode` | `DocumentMode \| None` | Não | `TESTS` para uma NC de teste (não-fiscal) |
58+
59+
O cliente, os itens e os valores são lidos do documento original — não os passas.
7260

7361
## Variante async
7462

7563
```python
7664
nc = await client.documents.create_credit_note_async(
77-
register_id=1,
7865
reference_document_id=12345,
7966
reason="...",
80-
items=[...],
8167
)
8268
```
8369

8470
## Notas
8571

86-
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)` em alternativa.
88-
3. **Cliente coerente:** se o original foi a Consumidor Final, a NC também deve omitir `client`.
72+
1. **Só crédito total (v0.1):** o SDK credita todas as linhas do original. Créditos parciais (algumas linhas/quantidades) ficam para o futuro.
73+
2. **A NC é como se reverte uma fatura:** faturas fiscais (FT/FR) **não podem ser canceladas** o `cancel()` rejeita-as. Emite uma NC para creditar o original.
74+
3. **Apenas documentos reais:** o original tem de ser consultável, por isso as notas de crédito funcionam sobre documentos **reais**, não sobre os de modo teste (que não são endereçáveis via `/documents/{id}`).

0 commit comments

Comments
 (0)