stackin-python-sdk 0.9.0__tar.gz → 0.11.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackin-python-sdk
3
- Version: 0.9.0
3
+ Version: 0.11.0
4
4
  Summary: Python SDK for issuing, consulting and cancelling electronic invoices.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -180,9 +180,9 @@ client.issue(
180
180
  )
181
181
  ```
182
182
 
183
- `recipient_address` is an `Address` — the buyer's address, **required for NFE** and ignored for NFSE. Every field is required, `city_code` (the 7-digit IBGE municipality code) included: it becomes `enderDest` on the wire and the SEFAZ rejects a partial one. `state` is also what resolves `idDest` — a buyer in another state is emitted as an interstate operation automatically. A missing or incomplete address raises a `ValueError` locally, before the request goes out.
183
+ `recipient_address` is an `Address` — the buyer's address, **required for NFE**. On NFE every field is required, `city_code` (the 7-digit IBGE municipality code) included: it becomes `enderDest` on the wire and the SEFAZ rejects a partial one. `state` is also what resolves `idDest` — a buyer in another state is emitted as an interstate operation automatically. On NFSE it is the tomador's address: optional, except when any item has `tax_retained=True` (the ISSQN retained by the tomador), where the city authorizer refuses the document without it (`E0237`) — then every field except `city_code` is required. In both cases a missing or incomplete address raises a `ValueError` locally, before the request goes out.
184
184
 
185
- `items` is a list of `Product` (`stackin.br`) — `description`/`amount` apply to any document type; `ncm`/`cfop` (plus everything else on `Product`: `cest`, tax groups, presumed credits...) are Brazil-specific and required per item for NFE, ignored for NFSE (a service isn't a physical good).
185
+ `items` is a list of `Product` (`stackin.br`) — `description`/`amount` apply to any document type; `ncm`/`cfop` (plus everything else on `Product`: `cest`, tax groups, presumed credits...) are Brazil-specific and required per item for NFE, ignored for NFSE (a service isn't a physical good). `ncm` is the full 8 digits, or `00` for a service line — the SEFAZ rejects any other short code (rejection `777`), so one fails at construction.
186
186
 
187
187
  ## Retrying safely
188
188
 
@@ -251,8 +251,15 @@ The original document does not change — the CC-e is an event attached to it, a
251
251
  the authorized XML stays as it was. A document accepts at most 20 of them, and
252
252
  they are numbered for you.
253
253
 
254
- **NF-e only.** NFS-e has no correction letter, and asking for one returns
255
- a `409`.
254
+ **NF-e only.** NFS-e has no correction letter — cancel and reissue it instead.
255
+ The access key says which one it is: an NF-e access key is 44 characters, an
256
+ NFS-e key 50. A key that is not an NF-e access key (44 characters: digits, with
257
+ uppercase letters allowed in the first 12 positions of the issuer CNPJ, which
258
+ can be alphanumeric from July 2026) raises a `ValueError` locally, before the call:
259
+ `correct is NFe only. NFSe has no correction letter — cancel and reissue instead.`
260
+ What only the API can know stays an `APIError` with status `409`: an NF-e key
261
+ sent with `document_type=DocumentType.NFSE`, or a key for a document the API
262
+ does not hold.
256
263
 
257
264
  ## Invalidating unused numbers
258
265
 
@@ -313,15 +320,48 @@ Realizada. Only the last one takes a reason, and it requires one — both rules
313
320
  checked locally, before the request goes out, because a round trip to be told a
314
321
  fixed rule is a round trip wasted.
315
322
 
323
+ ## Looking up a code, or who a CNPJ belongs to
324
+
325
+ Two more clients, for the tables an issuer reads while filling a document. Neither writes anything.
326
+
327
+ ```python
328
+ from stackin import FiscalReference, Taxpayer
329
+
330
+ ref = FiscalReference(api_key="...") # country="BR" by default
331
+
332
+ ref.ncm.get("84716052") # one code
333
+ ref.ncm.search("teclado", limit=5) # a page of matches
334
+ ref.cfop.get("5102")
335
+ ref.kinds() # what this country has
336
+ ref.kind("ibs_cbs_class").get("000001") # any kind, named or not
337
+ ref.search("teclado") # every kind at once
338
+
339
+ Taxpayer(api_key="...").get("00000000000191")
340
+ ```
341
+
342
+ `cfop`, `ncm`, `cest`, `cst`, `csosn`, `iss_service`, `icms_fuel` and `ibs_cbs_class` have accessors. **`kind(name)` reaches anything else**, including a classification published after this release — ask `kinds()` rather than trusting this list.
343
+
344
+ `metadata` is passed through as the API sends it and differs per kind: `utrib` on an NCM, `ncm_code` on a CEST, `tax_type` on a CST, `None` on an ISS service.
345
+
346
+ Three things worth knowing before you loop:
347
+
348
+ - **These share the invoice read allowance** — 600 calls a minute per key, the same bucket `consult()`, `history()`, `received()` and `pdf()` draw from. One `search()` page beats N `get()` calls.
349
+ - **Ordering is fixed** (kind, then code, ascending). Unlike `history()`, these take no `sort_by`/`order_by`.
350
+ - **A 404 from `Taxpayer` does not mean the company does not exist.** That registry reloads monthly, so a recently registered CNPJ is simply not in it yet. Do not build a validation rule on it.
351
+
352
+ `Taxpayer` has one method and keeps one: the registry holds the names and addresses of real people, so there is no search over it, by design.
353
+
316
354
  ## Errors
317
355
 
318
356
  - `stackin.APIError` — the API responded with a non-2xx status (`status_code`, `detail`) — a 401 here means `api_key` is missing, wrong, or was rotated.
319
357
  - `stackin.ConnectionFailedError` — the API didn't respond (network/DNS/timeout).
320
- - `ValueError` — `issue()`'s `items` is empty, missing `ncm`/`cfop` on an item for NFE, or a missing/incomplete `recipient_address` on NFE.
358
+ - `ValueError` — `issue()`'s `items` is empty, missing `ncm`/`cfop` on an item for NFE, an item `description` over 120 characters on NFE (the `xProd` limit; NFSE has no such limit), any string in an NFE request with a character the NF-e cannot carry (outside ISO-8859-1, latin1: an emoji, CJK, Cyrillic…; the error names the field — typographic quotes, dashes, ellipsis, bullet, `€` and `™` are not refused, the API converts them and folds tabs and line breaks into a space), a missing/incomplete `recipient_address` on NFE, or a missing/incomplete `recipient_address` (every field except `city_code`) on NFSE when any item has `tax_retained=True` (the ISSQN retained by the tomador).
359
+ - `ValueError` — `correct()` with a key that is not an NF-e access key (44 characters): a correction letter exists only on NF-e; an NFS-e is cancelled and reissued instead. An NF-e key sent with `DocumentType.NFSE`, or one the API does not hold, is an `APIError` 409, not a `ValueError`.
360
+ - `pydantic.ValidationError` (a `ValueError`) — a model built with a value the NF-e 4.00 XSD refuses: an `ncm` that is not 8 digits (or `00` for a service line), a `barcode` that is not `SEM GTIN` or an 8/12/13/14-digit GTIN with a valid GS1 check digit, or an `Address` `street`/`neighborhood` under 2 characters once trimmed.
321
361
 
322
362
  Building the full fiscal document (issuer data, service code, tax groups, schema-accurate XML) is the API's job — configured once per company, not passed on every call.
323
363
 
324
364
  ## Examples
325
365
 
326
- Runnable end-to-end scripts in [`examples/nfe/`](examples/nfe/) and [`examples/nfse/`](examples/nfse/) — one file per field/variant, from the bare minimum to every field filled. `examples/consult_invoice.py`, `examples/cancel_invoice.py`, and `examples/reissue_invoice.py` cover the operations that act on an already-issued document.
366
+ Runnable end-to-end scripts in [`examples/nfe/`](examples/nfe/) and [`examples/nfse/`](examples/nfse/) — one file per field/variant, from the bare minimum to every field filled. `examples/consult_invoice.py`, `examples/cancel_invoice.py`, and `examples/reissue_invoice.py` cover the operations that act on an already-issued document. `examples/lookup_fiscal_reference.py` and `examples/lookup_taxpayer.py` cover the two read-only clients.
327
367
 
@@ -151,9 +151,9 @@ client.issue(
151
151
  )
152
152
  ```
153
153
 
154
- `recipient_address` is an `Address` — the buyer's address, **required for NFE** and ignored for NFSE. Every field is required, `city_code` (the 7-digit IBGE municipality code) included: it becomes `enderDest` on the wire and the SEFAZ rejects a partial one. `state` is also what resolves `idDest` — a buyer in another state is emitted as an interstate operation automatically. A missing or incomplete address raises a `ValueError` locally, before the request goes out.
154
+ `recipient_address` is an `Address` — the buyer's address, **required for NFE**. On NFE every field is required, `city_code` (the 7-digit IBGE municipality code) included: it becomes `enderDest` on the wire and the SEFAZ rejects a partial one. `state` is also what resolves `idDest` — a buyer in another state is emitted as an interstate operation automatically. On NFSE it is the tomador's address: optional, except when any item has `tax_retained=True` (the ISSQN retained by the tomador), where the city authorizer refuses the document without it (`E0237`) — then every field except `city_code` is required. In both cases a missing or incomplete address raises a `ValueError` locally, before the request goes out.
155
155
 
156
- `items` is a list of `Product` (`stackin.br`) — `description`/`amount` apply to any document type; `ncm`/`cfop` (plus everything else on `Product`: `cest`, tax groups, presumed credits...) are Brazil-specific and required per item for NFE, ignored for NFSE (a service isn't a physical good).
156
+ `items` is a list of `Product` (`stackin.br`) — `description`/`amount` apply to any document type; `ncm`/`cfop` (plus everything else on `Product`: `cest`, tax groups, presumed credits...) are Brazil-specific and required per item for NFE, ignored for NFSE (a service isn't a physical good). `ncm` is the full 8 digits, or `00` for a service line — the SEFAZ rejects any other short code (rejection `777`), so one fails at construction.
157
157
 
158
158
  ## Retrying safely
159
159
 
@@ -222,8 +222,15 @@ The original document does not change — the CC-e is an event attached to it, a
222
222
  the authorized XML stays as it was. A document accepts at most 20 of them, and
223
223
  they are numbered for you.
224
224
 
225
- **NF-e only.** NFS-e has no correction letter, and asking for one returns
226
- a `409`.
225
+ **NF-e only.** NFS-e has no correction letter — cancel and reissue it instead.
226
+ The access key says which one it is: an NF-e access key is 44 characters, an
227
+ NFS-e key 50. A key that is not an NF-e access key (44 characters: digits, with
228
+ uppercase letters allowed in the first 12 positions of the issuer CNPJ, which
229
+ can be alphanumeric from July 2026) raises a `ValueError` locally, before the call:
230
+ `correct is NFe only. NFSe has no correction letter — cancel and reissue instead.`
231
+ What only the API can know stays an `APIError` with status `409`: an NF-e key
232
+ sent with `document_type=DocumentType.NFSE`, or a key for a document the API
233
+ does not hold.
227
234
 
228
235
  ## Invalidating unused numbers
229
236
 
@@ -284,14 +291,47 @@ Realizada. Only the last one takes a reason, and it requires one — both rules
284
291
  checked locally, before the request goes out, because a round trip to be told a
285
292
  fixed rule is a round trip wasted.
286
293
 
294
+ ## Looking up a code, or who a CNPJ belongs to
295
+
296
+ Two more clients, for the tables an issuer reads while filling a document. Neither writes anything.
297
+
298
+ ```python
299
+ from stackin import FiscalReference, Taxpayer
300
+
301
+ ref = FiscalReference(api_key="...") # country="BR" by default
302
+
303
+ ref.ncm.get("84716052") # one code
304
+ ref.ncm.search("teclado", limit=5) # a page of matches
305
+ ref.cfop.get("5102")
306
+ ref.kinds() # what this country has
307
+ ref.kind("ibs_cbs_class").get("000001") # any kind, named or not
308
+ ref.search("teclado") # every kind at once
309
+
310
+ Taxpayer(api_key="...").get("00000000000191")
311
+ ```
312
+
313
+ `cfop`, `ncm`, `cest`, `cst`, `csosn`, `iss_service`, `icms_fuel` and `ibs_cbs_class` have accessors. **`kind(name)` reaches anything else**, including a classification published after this release — ask `kinds()` rather than trusting this list.
314
+
315
+ `metadata` is passed through as the API sends it and differs per kind: `utrib` on an NCM, `ncm_code` on a CEST, `tax_type` on a CST, `None` on an ISS service.
316
+
317
+ Three things worth knowing before you loop:
318
+
319
+ - **These share the invoice read allowance** — 600 calls a minute per key, the same bucket `consult()`, `history()`, `received()` and `pdf()` draw from. One `search()` page beats N `get()` calls.
320
+ - **Ordering is fixed** (kind, then code, ascending). Unlike `history()`, these take no `sort_by`/`order_by`.
321
+ - **A 404 from `Taxpayer` does not mean the company does not exist.** That registry reloads monthly, so a recently registered CNPJ is simply not in it yet. Do not build a validation rule on it.
322
+
323
+ `Taxpayer` has one method and keeps one: the registry holds the names and addresses of real people, so there is no search over it, by design.
324
+
287
325
  ## Errors
288
326
 
289
327
  - `stackin.APIError` — the API responded with a non-2xx status (`status_code`, `detail`) — a 401 here means `api_key` is missing, wrong, or was rotated.
290
328
  - `stackin.ConnectionFailedError` — the API didn't respond (network/DNS/timeout).
291
- - `ValueError` — `issue()`'s `items` is empty, missing `ncm`/`cfop` on an item for NFE, or a missing/incomplete `recipient_address` on NFE.
329
+ - `ValueError` — `issue()`'s `items` is empty, missing `ncm`/`cfop` on an item for NFE, an item `description` over 120 characters on NFE (the `xProd` limit; NFSE has no such limit), any string in an NFE request with a character the NF-e cannot carry (outside ISO-8859-1, latin1: an emoji, CJK, Cyrillic…; the error names the field — typographic quotes, dashes, ellipsis, bullet, `€` and `™` are not refused, the API converts them and folds tabs and line breaks into a space), a missing/incomplete `recipient_address` on NFE, or a missing/incomplete `recipient_address` (every field except `city_code`) on NFSE when any item has `tax_retained=True` (the ISSQN retained by the tomador).
330
+ - `ValueError` — `correct()` with a key that is not an NF-e access key (44 characters): a correction letter exists only on NF-e; an NFS-e is cancelled and reissued instead. An NF-e key sent with `DocumentType.NFSE`, or one the API does not hold, is an `APIError` 409, not a `ValueError`.
331
+ - `pydantic.ValidationError` (a `ValueError`) — a model built with a value the NF-e 4.00 XSD refuses: an `ncm` that is not 8 digits (or `00` for a service line), a `barcode` that is not `SEM GTIN` or an 8/12/13/14-digit GTIN with a valid GS1 check digit, or an `Address` `street`/`neighborhood` under 2 characters once trimmed.
292
332
 
293
333
  Building the full fiscal document (issuer data, service code, tax groups, schema-accurate XML) is the API's job — configured once per company, not passed on every call.
294
334
 
295
335
  ## Examples
296
336
 
297
- Runnable end-to-end scripts in [`examples/nfe/`](examples/nfe/) and [`examples/nfse/`](examples/nfse/) — one file per field/variant, from the bare minimum to every field filled. `examples/consult_invoice.py`, `examples/cancel_invoice.py`, and `examples/reissue_invoice.py` cover the operations that act on an already-issued document.
337
+ Runnable end-to-end scripts in [`examples/nfe/`](examples/nfe/) and [`examples/nfse/`](examples/nfse/) — one file per field/variant, from the bare minimum to every field filled. `examples/consult_invoice.py`, `examples/cancel_invoice.py`, and `examples/reissue_invoice.py` cover the operations that act on an already-issued document. `examples/lookup_fiscal_reference.py` and `examples/lookup_taxpayer.py` cover the two read-only clients.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stackin-python-sdk"
3
- version = "0.9.0"
3
+ version = "0.11.0"
4
4
  authors = [
5
5
  { name = "Fernando Celmer", email = "email@fernandocelmer.com" },
6
6
  ]
@@ -45,7 +45,7 @@ Changelog = "https://github.com/stackin-io/stackin-python-sdk/releases"
45
45
 
46
46
  [tool.poetry]
47
47
  name = "stackin-python-sdk"
48
- version = "0.9.0"
48
+ version = "0.11.0"
49
49
  description = "Python SDK for issuing, consulting and cancelling electronic invoices."
50
50
  authors = ["Fernando Celmer <email@fernandocelmer.com>"]
51
51
  readme = "README.md"
@@ -65,6 +65,8 @@ python-dotenv = "^1.0.1"
65
65
 
66
66
  [tool.poetry.group.code-quality.dependencies]
67
67
  ruff = "^0.8.0"
68
+ mypy = "^2.4.1"
69
+ types-requests = "^2.33.0.20261030"
68
70
 
69
71
  [tool.pytest.ini_options]
70
72
  testpaths = ["tests"]
@@ -1,6 +1,6 @@
1
1
  """Invoice __init__ module."""
2
2
 
3
- __version__ = "0.9.0"
3
+ __version__ = "0.11.0"
4
4
  __description__ = (
5
5
  "Python SDK for issuing, consulting and cancelling electronic invoices."
6
6
  )
@@ -8,10 +8,16 @@ __description__ = (
8
8
  from .core.address import Address
9
9
  from .core.client import Invoice
10
10
  from .core.exceptions import APIError, ConnectionFailedError, InvoiceError
11
+ from .core.reference import KINDS, FiscalReference, Kind
12
+ from .core.taxpayer import Taxpayer
11
13
  from .core.types import DocumentType, Environment, Manifestation
12
14
 
13
15
  __all__ = [
14
16
  "Invoice",
17
+ "FiscalReference",
18
+ "Kind",
19
+ "KINDS",
20
+ "Taxpayer",
15
21
  "DocumentType",
16
22
  "Environment",
17
23
  "Manifestation",
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from decimal import Decimal
6
6
  from typing import Annotated, Any
7
7
 
8
- from pydantic import BaseModel, Field
8
+ from pydantic import BaseModel, Field, field_validator
9
9
 
10
10
  from stackin.br.tax import Tax
11
11
 
@@ -49,6 +49,15 @@ _BR_FIELDS = {
49
49
  }
50
50
 
51
51
 
52
+ def _gtin_check_digit_ok(gtin: str) -> bool:
53
+ """GS1 modulo 10: weights 3,1,3,1... from the right, check excluded."""
54
+ body = gtin[-2::-1]
55
+ total = sum(
56
+ int(digit) * (3 if index % 2 == 0 else 1) for index, digit in enumerate(body)
57
+ )
58
+ return (10 - total % 10) % 10 == int(gtin[-1])
59
+
60
+
52
61
  def _decimal_str(value: Decimal | None) -> str | None:
53
62
  """JSON has no decimal; a float would lose the tenth place."""
54
63
  return None if value is None else format(value, "f")
@@ -62,7 +71,11 @@ class Product(BaseModel):
62
71
  unit_price: Decimal | None = Field(default=None, gt=0)
63
72
  unit: str = Field(default="UN")
64
73
  quantity: float = Field(default=1.0, gt=0)
65
- barcode: str | None = Field(default=None)
74
+ barcode: str | None = Field(
75
+ default=None,
76
+ pattern=r"^(SEM GTIN|\d{8}|\d{12,14})$",
77
+ description="cEAN: a GTIN-8/12/13/14, or SEM GTIN.",
78
+ )
66
79
  freight: float | None = Field(default=None)
67
80
  insurance: float | None = Field(default=None)
68
81
  discount: float | None = Field(default=None)
@@ -71,25 +84,27 @@ class Product(BaseModel):
71
84
  purchase_order: str | None = Field(default=None)
72
85
  purchase_order_item: str | None = Field(default=None, pattern=r"^\d{1,6}$")
73
86
 
74
- ncm: str | None = Field(default=None, pattern=r"^\d{2}$|^\d{8}$")
87
+ ncm: str | None = Field(
88
+ default=None,
89
+ pattern=r"^(00|\d{8})$",
90
+ description=(
91
+ "The full 8 digits, or 00 for a service line. Any other "
92
+ "short code the SEFAZ rejects with 777."
93
+ ),
94
+ )
75
95
  cfop: str | None = Field(default=None, pattern=r"^[123567]\d{3}$")
76
96
  cest: str | None = Field(default=None, pattern=r"^\d{7}$")
77
- nve_codes: (
78
- list[Annotated[str, Field(pattern=r"^[A-Z]{2}\d{4}$")]] | None
79
- ) = Field(default=None)
80
- ind_escala: str | None = Field(default=None)
81
- manufacturer_cnpj: str | None = Field(
82
- default=None, pattern=r"^[0-9A-Z]{12}\d{2}$"
97
+ nve_codes: list[Annotated[str, Field(pattern=r"^[A-Z]{2}\d{4}$")]] | None = Field(
98
+ default=None
83
99
  )
100
+ ind_escala: str | None = Field(default=None)
101
+ manufacturer_cnpj: str | None = Field(default=None, pattern=r"^[0-9A-Z]{12}\d{2}$")
84
102
  tax_benefit_code: str | None = Field(default=None, pattern=_CBENEF_PATTERN)
85
103
  presumed_credits: list[PresumedCredit] | None = Field(default=None)
86
104
  ex_tipi: str | None = Field(default=None, pattern=r"^\d{2,3}$")
87
105
  import_content_control_number: str | None = Field(
88
106
  default=None,
89
- pattern=(
90
- r"^[A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}"
91
- r"-[A-F0-9]{4}-[A-F0-9]{12}$"
92
- ),
107
+ pattern=(r"^[A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}" r"-[A-F0-9]{4}-[A-F0-9]{12}$"),
93
108
  )
94
109
  recopi_number: str | None = Field(default=None, pattern=r"^\d{20}$")
95
110
  ibs_cbs: IbsCbs | None = Field(default=None)
@@ -109,6 +124,16 @@ class Product(BaseModel):
109
124
  default=None, max_length=2000, description="nfse only."
110
125
  )
111
126
 
127
+ @field_validator("barcode")
128
+ @classmethod
129
+ def _valid_gtin_check_digit(cls, value: str | None) -> str | None:
130
+ """Runs after the pattern: SEM GTIN or digits by now."""
131
+ if value is None or not value.isdigit():
132
+ return value
133
+ if not _gtin_check_digit_ok(value):
134
+ raise ValueError("barcode has an invalid GTIN check digit")
135
+ return value
136
+
112
137
  def to_dict(self) -> dict:
113
138
  """Returns the item as a plain dict, ready for the request body."""
114
139
  _NFSE_FIELDS = {
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from pydantic import BaseModel, Field
5
+ from pydantic import BaseModel, Field, field_validator
6
6
 
7
7
 
8
8
  class Address(BaseModel):
@@ -16,6 +16,14 @@ class Address(BaseModel):
16
16
  city: str | None = Field(default=None)
17
17
  zip_code: str | None = Field(default=None)
18
18
 
19
+ @field_validator("street", "neighborhood")
20
+ @classmethod
21
+ def _at_least_two_characters(cls, value: str | None, info) -> str | None:
22
+ """The NF-e XSD sets xLgr and xBairro at 2 characters minimum."""
23
+ if value is not None and len(value.strip()) < 2:
24
+ raise ValueError(f"{info.field_name} must have at least 2 characters")
25
+ return value
26
+
19
27
  def to_dict(self) -> dict:
20
28
  """Returns the address as a plain dict."""
21
29
  return self.model_dump(exclude_none=True)
@@ -3,7 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
- from typing import Any
6
+ import re
7
+ from typing import Any, cast
7
8
 
8
9
  import requests
9
10
 
@@ -37,6 +38,12 @@ def _resolve_base_url(
37
38
  return DEFAULT_BASE_URL
38
39
 
39
40
 
41
+ _NFE_DESCRIPTION_MAX = 120
42
+
43
+ # cUF+AAMM, then the issuer CNPJ (alphanumeric from July 2026: letters
44
+ # in its first 12 positions), then the rest. Same check as the API's.
45
+ _NFE_ACCESS_KEY = re.compile(r"[0-9]{6}[0-9A-Z]{12}[0-9]{26}")
46
+
40
47
  _NFE_ADDRESS_FIELDS = (
41
48
  "street",
42
49
  "number",
@@ -53,9 +60,7 @@ def _validate_nfe_address(address: Address | None) -> None:
53
60
  if address is None:
54
61
  raise ValueError("recipient_address is required for NFE")
55
62
 
56
- missing = [
57
- field for field in _NFE_ADDRESS_FIELDS if not getattr(address, field)
58
- ]
63
+ missing = [field for field in _NFE_ADDRESS_FIELDS if not getattr(address, field)]
59
64
  if missing:
60
65
  raise ValueError(
61
66
  "recipient_address is missing required fields for NFE: "
@@ -63,8 +68,84 @@ def _validate_nfe_address(address: Address | None) -> None:
63
68
  )
64
69
 
65
70
 
66
- class Invoice:
67
- """Client for issuing, consulting, and cancelling fiscal documents."""
71
+ # What the API converts before its own latin1 check (stackin-api#228):
72
+ # typographic quotes, dashes, ellipsis, bullet, euro and trademark.
73
+ # Those are not refused here; the SDK does not translate them either.
74
+ _API_CONVERTED = frozenset(
75
+ "\u2018\u2019\u201a\u201c\u201d\u201e\u2013\u2014\u2212\u2026\u2022\u20ac\u2122"
76
+ )
77
+
78
+
79
+ def _first_non_latin1(value: Any, path: str) -> tuple[str, str] | None:
80
+ """The first character in `value` the NF-e cannot carry, with its path.
81
+
82
+ Anything up to U+00FF passes (tab, newline and CR included: the API
83
+ folds them into a space), and so does what the API converts.
84
+ """
85
+ if isinstance(value, str):
86
+ for char in value:
87
+ if char > "\u00ff" and char not in _API_CONVERTED:
88
+ return path, char
89
+ return None
90
+ if isinstance(value, dict):
91
+ for key, nested in value.items():
92
+ child = f"{path}.{key}" if path else str(key)
93
+ if found := _first_non_latin1(nested, child):
94
+ return found
95
+ return None
96
+ if isinstance(value, (list, tuple)):
97
+ for index, nested in enumerate(value):
98
+ if found := _first_non_latin1(nested, f"{path}[{index}]"):
99
+ return found
100
+ return None
101
+
102
+
103
+ def _validate_nfe_latin1(payload: dict) -> None:
104
+ """NF-e is encoded in ISO-8859-1; the SEFAZ refuses anything else.
105
+
106
+ Walks every string the request would carry, so a free-text field
107
+ added later is covered without being listed here. Refuses only what
108
+ the API would refuse after its own normalisation, never more.
109
+ """
110
+ if found := _first_non_latin1(payload, ""):
111
+ path, char = found
112
+ raise ValueError(
113
+ f"{path} contains a character outside ISO-8859-1 (latin1), "
114
+ f"which NF-e does not accept: '{char}'"
115
+ )
116
+
117
+
118
+ _NFSE_RETAINED_ADDRESS_FIELDS = tuple(
119
+ field for field in _NFE_ADDRESS_FIELDS if field != "city_code"
120
+ )
121
+
122
+
123
+ def _validate_nfse_retained_address(address: Address | None) -> None:
124
+ """Retained ISSQN needs the tomador's address — the authorizer refuses
125
+ the document without it (E0237). Every field but city_code."""
126
+ if address is None:
127
+ raise ValueError(
128
+ "recipient_address is required on an NFS-e when any item "
129
+ "has tax_retained=true (ISSQN retained by the tomador)"
130
+ )
131
+
132
+ missing = [
133
+ field for field in _NFSE_RETAINED_ADDRESS_FIELDS if not getattr(address, field)
134
+ ]
135
+ if missing:
136
+ raise ValueError(
137
+ "recipient_address is missing required fields for NFSE with "
138
+ "tax_retained=true: " + ", ".join(missing)
139
+ )
140
+
141
+
142
+ class _Client:
143
+ """Where an api_key becomes an HTTP call, for every entry point.
144
+
145
+ Lives here rather than in a module of its own because the tests
146
+ patch `stackin.core.client.requests`, and so every request the SDK
147
+ makes has to be issued from this module.
148
+ """
68
149
 
69
150
  def __init__(
70
151
  self,
@@ -78,6 +159,77 @@ class Invoice:
78
159
  self.api_key = api_key or os.environ.get("STACKIN_API_KEY")
79
160
  self.timeout = timeout
80
161
 
162
+ def _headers(self, idempotency_key: str | None = None) -> dict:
163
+ headers = {}
164
+ if self.api_key:
165
+ headers["Authorization"] = f"Bearer {self.api_key}"
166
+ if idempotency_key:
167
+ headers["Idempotency-Key"] = idempotency_key
168
+ return headers
169
+
170
+ def _request(
171
+ self,
172
+ method: str,
173
+ path: str,
174
+ *,
175
+ json: dict | None = None,
176
+ params: dict | None = None,
177
+ idempotency_key: str | None = None,
178
+ ) -> Any:
179
+ response = self._send(
180
+ method,
181
+ path,
182
+ json=json,
183
+ params=params,
184
+ idempotency_key=idempotency_key,
185
+ )
186
+ try:
187
+ body = response.json() if response.content else {}
188
+ except ValueError:
189
+ body = {}
190
+ if isinstance(body, dict):
191
+ return body.get("result", body)
192
+ return body
193
+
194
+ def _send(
195
+ self,
196
+ method: str,
197
+ path: str,
198
+ *,
199
+ json: dict | None = None,
200
+ params: dict | None = None,
201
+ idempotency_key: str | None = None,
202
+ ) -> requests.Response:
203
+ url = f"{self.base_url}/api/v1{path}"
204
+
205
+ try:
206
+ response = requests.request(
207
+ method,
208
+ url,
209
+ json=json,
210
+ params=params,
211
+ headers=self._headers(idempotency_key),
212
+ timeout=self.timeout,
213
+ )
214
+ except requests.RequestException as error:
215
+ raise ConnectionFailedError(str(error)) from error
216
+
217
+ if not response.ok:
218
+ try:
219
+ body = response.json() if response.content else {}
220
+ except ValueError:
221
+ body = {}
222
+ raise APIError(
223
+ status_code=response.status_code,
224
+ detail=body.get("detail", response.text),
225
+ )
226
+
227
+ return response
228
+
229
+
230
+ class Invoice(_Client):
231
+ """Client for issuing, consulting, and cancelling fiscal documents."""
232
+
81
233
  def issue(
82
234
  self,
83
235
  *,
@@ -96,15 +248,21 @@ class Invoice:
96
248
 
97
249
  if document_type is DocumentType.NFE:
98
250
  for index, item in enumerate(items):
251
+ if len(item.description) > _NFE_DESCRIPTION_MAX:
252
+ raise ValueError(
253
+ f"items[{index}].description exceeds "
254
+ f"{_NFE_DESCRIPTION_MAX} characters (NF-e xProd limit)"
255
+ )
99
256
  if not item.ncm:
100
257
  raise ValueError(f"items[{index}].ncm is required for NFE")
101
258
  if not item.cfop:
102
- raise ValueError(
103
- f"items[{index}].cfop is required for NFE"
104
- )
259
+ raise ValueError(f"items[{index}].cfop is required for NFE")
105
260
  _validate_nfe_address(recipient_address)
261
+ retained = any(item.tax_retained for item in items)
262
+ if document_type is DocumentType.NFSE and retained:
263
+ _validate_nfse_retained_address(recipient_address)
106
264
 
107
- payload = {
265
+ payload: dict[str, Any] = {
108
266
  "document_type": document_type.value,
109
267
  "client_name": client_name,
110
268
  "tax_id": tax_id,
@@ -116,12 +274,17 @@ class Invoice:
116
274
  payload["series"] = series
117
275
  if number:
118
276
  payload["number"] = number
119
-
120
- return self._request(
121
- "POST",
122
- "/invoices",
123
- json=payload,
124
- idempotency_key=idempotency_key,
277
+ if document_type is DocumentType.NFE:
278
+ _validate_nfe_latin1(payload)
279
+
280
+ return cast(
281
+ dict,
282
+ self._request(
283
+ "POST",
284
+ "/invoices",
285
+ json=payload,
286
+ idempotency_key=idempotency_key,
287
+ ),
125
288
  )
126
289
 
127
290
  def consult(
@@ -133,7 +296,9 @@ class Invoice:
133
296
  """Consults a fiscal document by its access key."""
134
297
  params = {"document_type": document_type.value}
135
298
 
136
- return self._request("GET", f"/invoices/{access_key}", params=params)
299
+ return cast(
300
+ dict, self._request("GET", f"/invoices/{access_key}", params=params)
301
+ )
137
302
 
138
303
  def cancel(
139
304
  self,
@@ -155,11 +320,14 @@ class Invoice:
155
320
  "reason": reason,
156
321
  }
157
322
 
158
- return self._request(
159
- "POST",
160
- f"/invoices/{access_key}/cancel",
161
- json=payload,
162
- idempotency_key=idempotency_key,
323
+ return cast(
324
+ dict,
325
+ self._request(
326
+ "POST",
327
+ f"/invoices/{access_key}/cancel",
328
+ json=payload,
329
+ idempotency_key=idempotency_key,
330
+ ),
163
331
  )
164
332
 
165
333
  def invalidate(
@@ -183,7 +351,9 @@ class Invoice:
183
351
  "reason": reason,
184
352
  }
185
353
 
186
- return self._request("POST", "/invoices/invalidations", json=payload)
354
+ return cast(
355
+ dict, self._request("POST", "/invoices/invalidations", json=payload)
356
+ )
187
357
 
188
358
  def correct(
189
359
  self,
@@ -192,7 +362,20 @@ class Invoice:
192
362
  document_type: DocumentType,
193
363
  correction: str,
194
364
  ) -> dict:
195
- """Files a correction letter (CC-e) against an issued document."""
365
+ """Files a correction letter (CC-e) against an issued NF-e.
366
+
367
+ Only an NF-e has one, so the access key must be an NF-e access
368
+ key (44 characters; the CNPJ slot may carry letters); an NFS-e is
369
+ cancelled and issued again instead. That is checked here. An NF-e
370
+ key sent with DocumentType.NFSE, or one
371
+ for a document the API does not hold, is still answered by the
372
+ API as an APIError with status 409.
373
+ """
374
+ if not _NFE_ACCESS_KEY.fullmatch(access_key):
375
+ raise ValueError(
376
+ "correct is NFe only. NFSe has no correction letter — "
377
+ "cancel and reissue instead."
378
+ )
196
379
  if not 15 <= len(correction) <= 1000:
197
380
  raise ValueError("correction must be 15 to 1000 characters")
198
381
 
@@ -201,18 +384,20 @@ class Invoice:
201
384
  "correction": correction,
202
385
  }
203
386
 
204
- return self._request(
205
- "POST", f"/invoices/{access_key}/correction", json=payload
387
+ return cast(
388
+ dict,
389
+ self._request("POST", f"/invoices/{access_key}/correction", json=payload),
206
390
  )
207
391
 
208
- def reissue(
209
- self, invoice_id: str, *, idempotency_key: str | None = None
210
- ) -> dict:
392
+ def reissue(self, invoice_id: str, *, idempotency_key: str | None = None) -> dict:
211
393
  """Retries a previous invoice submission by its local id."""
212
- return self._request(
213
- "POST",
214
- f"/invoices/{invoice_id}/reissue",
215
- idempotency_key=idempotency_key,
394
+ return cast(
395
+ dict,
396
+ self._request(
397
+ "POST",
398
+ f"/invoices/{invoice_id}/reissue",
399
+ idempotency_key=idempotency_key,
400
+ ),
216
401
  )
217
402
 
218
403
  def pdf(
@@ -230,9 +415,7 @@ class Invoice:
230
415
  unavailable, not that the invoice is wrong. NFS-e only.
231
416
  """
232
417
  params = {"document_type": document_type.value}
233
- response = self._send(
234
- "GET", f"/invoices/{access_key}/pdf", params=params
235
- )
418
+ response = self._send("GET", f"/invoices/{access_key}/pdf", params=params)
236
419
  return response.content
237
420
 
238
421
  def received(
@@ -253,7 +436,7 @@ class Invoice:
253
436
  if offset is not None:
254
437
  params["offset"] = offset
255
438
 
256
- return self._request("GET", "/received-invoices", params=params)
439
+ return cast(dict, self._request("GET", "/received-invoices", params=params))
257
440
 
258
441
  def history(
259
442
  self,
@@ -284,7 +467,7 @@ class Invoice:
284
467
  if order_by is not None:
285
468
  params["order_by"] = order_by
286
469
 
287
- return self._request("GET", "/invoices", params=params)
470
+ return cast(dict, self._request("GET", "/invoices", params=params))
288
471
 
289
472
  def submissions(self, invoice_id: str) -> list:
290
473
  """Every attempt made for one invoice, and what came back.
@@ -292,7 +475,7 @@ class Invoice:
292
475
  consult() gives the status; this gives the reason. Takes the
293
476
  invoice_id because a rejected document has no access key.
294
477
  """
295
- return self._request("GET", f"/invoices/{invoice_id}/submissions")
478
+ return cast(list, self._request("GET", f"/invoices/{invoice_id}/submissions"))
296
479
 
297
480
  def manifest(
298
481
  self,
@@ -310,88 +493,22 @@ class Invoice:
310
493
  needs_reason = manifestation is Manifestation.OPERACAO_NAO_REALIZADA
311
494
  if needs_reason and not reason:
312
495
  raise ValueError(
313
- "manifestation 210240 (operação não realizada) requires "
314
- "a reason"
496
+ "manifestation 210240 (operação não realizada) requires " "a reason"
315
497
  )
316
498
  if not needs_reason and reason:
317
499
  raise ValueError(
318
- f"manifestation {manifestation.value} does not take a "
319
- "reason"
500
+ f"manifestation {manifestation.value} does not take a " "reason"
320
501
  )
321
502
 
322
503
  payload = {"manifestation": manifestation.value}
323
504
  if reason:
324
505
  payload["reason"] = reason
325
506
 
326
- return self._request(
327
- "POST",
328
- f"/received-invoices/{access_key}/manifestation",
329
- json=payload,
507
+ return cast(
508
+ dict,
509
+ self._request(
510
+ "POST",
511
+ f"/received-invoices/{access_key}/manifestation",
512
+ json=payload,
513
+ ),
330
514
  )
331
-
332
- def _headers(self, idempotency_key: str | None = None) -> dict:
333
- headers = {}
334
- if self.api_key:
335
- headers["Authorization"] = f"Bearer {self.api_key}"
336
- if idempotency_key:
337
- headers["Idempotency-Key"] = idempotency_key
338
- return headers
339
-
340
- def _request(
341
- self,
342
- method: str,
343
- path: str,
344
- *,
345
- json: dict | None = None,
346
- params: dict | None = None,
347
- idempotency_key: str | None = None,
348
- ) -> Any:
349
- response = self._send(
350
- method,
351
- path,
352
- json=json,
353
- params=params,
354
- idempotency_key=idempotency_key,
355
- )
356
- try:
357
- body = response.json() if response.content else {}
358
- except ValueError:
359
- body = {}
360
- if isinstance(body, dict):
361
- return body.get("result", body)
362
- return body
363
-
364
- def _send(
365
- self,
366
- method: str,
367
- path: str,
368
- *,
369
- json: dict | None = None,
370
- params: dict | None = None,
371
- idempotency_key: str | None = None,
372
- ) -> requests.Response:
373
- url = f"{self.base_url}/api/v1{path}"
374
-
375
- try:
376
- response = requests.request(
377
- method,
378
- url,
379
- json=json,
380
- params=params,
381
- headers=self._headers(idempotency_key),
382
- timeout=self.timeout,
383
- )
384
- except requests.RequestException as error:
385
- raise ConnectionFailedError(str(error)) from error
386
-
387
- if not response.ok:
388
- try:
389
- body = response.json() if response.content else {}
390
- except ValueError:
391
- body = {}
392
- raise APIError(
393
- status_code=response.status_code,
394
- detail=body.get("detail", response.text),
395
- )
396
-
397
- return response
@@ -0,0 +1,172 @@
1
+ """The published classification tables — CFOP, NCM, CEST and the rest.
2
+
3
+ None of this is the company's own data and none of it is writable. The
4
+ eight named accessors are ergonomics; `kind()` is the contract, and it
5
+ reaches a classification published after this release without one.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, cast
11
+ from urllib.parse import quote
12
+
13
+ from stackin.core.client import _Client
14
+ from stackin.core.types import Environment
15
+
16
+
17
+ def _segment(value: str) -> str:
18
+ """One path segment, escaped, refusing what would leave it.
19
+
20
+ `safe=""` because a code the caller typed may hold a `/`. The dot
21
+ segments need refusing rather than escaping: RFC 3986 calls `.`
22
+ unreserved, so `quote` leaves it alone and the HTTP client then
23
+ collapses `..` into a different endpoint.
24
+ """
25
+ if value in ("", ".", ".."):
26
+ raise ValueError(f"{value!r} is not a usable path segment")
27
+ return quote(value, safe="")
28
+
29
+
30
+ KINDS = (
31
+ "cfop",
32
+ "ncm",
33
+ "cest",
34
+ "cst",
35
+ "csosn",
36
+ "iss_service",
37
+ "icms_fuel",
38
+ "ibs_cbs_class",
39
+ )
40
+
41
+
42
+ class Kind:
43
+ """One classification, bound to a country."""
44
+
45
+ def __init__(self, client: _Client, name: str, country: str) -> None:
46
+ self._client = client
47
+ self.name = name
48
+ self.country = country
49
+
50
+ def get(self, code: str, *, country: str | None = None) -> dict:
51
+ """One code. A code that does not exist raises APIError (404)."""
52
+ return cast(
53
+ dict,
54
+ self._client._request(
55
+ "GET",
56
+ f"/fiscal-references/{_segment(self.name)}" f"/{_segment(code)}",
57
+ params={"country": country or self.country},
58
+ ),
59
+ )
60
+
61
+ def search(
62
+ self,
63
+ term: str | None = None,
64
+ *,
65
+ country: str | None = None,
66
+ limit: int | None = None,
67
+ offset: int | None = None,
68
+ ) -> dict:
69
+ """This classification, searched or simply paged through.
70
+
71
+ Rows come back ordered by kind then code; the API takes no
72
+ sort_by or order_by here, unlike Invoice.history().
73
+ """
74
+ return _search(
75
+ self._client,
76
+ country=country or self.country,
77
+ kind=self.name,
78
+ term=term,
79
+ limit=limit,
80
+ offset=offset,
81
+ )
82
+
83
+
84
+ def _search(
85
+ client: _Client,
86
+ *,
87
+ country: str,
88
+ kind: str | None,
89
+ term: str | None,
90
+ limit: int | None,
91
+ offset: int | None,
92
+ ) -> dict:
93
+ """Shared by Kind.search and FiscalReference.search."""
94
+ params: dict[str, Any] = {"country": country}
95
+ if kind is not None:
96
+ params["kind"] = kind
97
+ if term:
98
+ params["search"] = term
99
+ if limit is not None:
100
+ params["limit"] = limit
101
+ if offset is not None:
102
+ params["offset"] = offset
103
+
104
+ return cast(
105
+ dict,
106
+ client._request("GET", "/fiscal-references", params=params),
107
+ )
108
+
109
+
110
+ class FiscalReference(_Client):
111
+ """Client for the published fiscal classification tables."""
112
+
113
+ def __init__(
114
+ self,
115
+ base_url: str | None = None,
116
+ environment: Environment | str | None = None,
117
+ api_key: str | None = None,
118
+ timeout: int = 30,
119
+ country: str = "BR",
120
+ ) -> None:
121
+ super().__init__(base_url, environment, api_key, timeout)
122
+ self.country = country
123
+
124
+ self.cfop = Kind(self, "cfop", country)
125
+ self.ncm = Kind(self, "ncm", country)
126
+ self.cest = Kind(self, "cest", country)
127
+ self.cst = Kind(self, "cst", country)
128
+ self.csosn = Kind(self, "csosn", country)
129
+ self.iss_service = Kind(self, "iss_service", country)
130
+ self.icms_fuel = Kind(self, "icms_fuel", country)
131
+ self.ibs_cbs_class = Kind(self, "ibs_cbs_class", country)
132
+
133
+ def kinds(self, *, country: str | None = None) -> list[str]:
134
+ """Which classifications this country has data for.
135
+
136
+ The only honest answer to "what else is there" — a hard-coded
137
+ list in a README goes stale the next time the ETL grows one.
138
+ """
139
+ return cast(
140
+ list[str],
141
+ self._request(
142
+ "GET",
143
+ "/fiscal-references/kinds",
144
+ params={"country": country or self.country},
145
+ ),
146
+ )
147
+
148
+ def kind(self, name: str, *, country: str | None = None) -> Kind:
149
+ """Any classification by name, including one with no accessor."""
150
+ return Kind(self, name, country or self.country)
151
+
152
+ def search(
153
+ self,
154
+ term: str | None = None,
155
+ *,
156
+ country: str | None = None,
157
+ limit: int | None = None,
158
+ offset: int | None = None,
159
+ ) -> dict:
160
+ """Every classification at once, which no accessor can express.
161
+
162
+ The most expensive call the endpoint accepts: it is the whole
163
+ country's tables, not one of them.
164
+ """
165
+ return _search(
166
+ self,
167
+ country=country or self.country,
168
+ kind=None,
169
+ term=term,
170
+ limit=limit,
171
+ offset=offset,
172
+ )
@@ -0,0 +1,49 @@
1
+ """The taxpayer registry, looked up one tax id at a time.
2
+
3
+ One method, and it stays one method. The registry names and addresses
4
+ real people and companies: an exact lookup answers the question an
5
+ issuer has — who is this CNPJ I am about to invoice — and answers
6
+ nothing else. A prefix or wildcard search over the same table is a bulk
7
+ export wearing the costume of a query. Do not add one, not for parity
8
+ with FiscalReference, not "just by name", not "just within one state".
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import cast
14
+
15
+ from stackin.core.client import _Client
16
+ from stackin.core.reference import _segment
17
+ from stackin.core.types import Environment
18
+
19
+
20
+ class Taxpayer(_Client):
21
+ """Client for the taxpayer registry."""
22
+
23
+ def __init__(
24
+ self,
25
+ base_url: str | None = None,
26
+ environment: Environment | str | None = None,
27
+ api_key: str | None = None,
28
+ timeout: int = 30,
29
+ country: str = "BR",
30
+ ) -> None:
31
+ super().__init__(base_url, environment, api_key, timeout)
32
+ self.country = country
33
+
34
+ def get(self, tax_id: str, *, country: str | None = None) -> dict:
35
+ """One taxpayer, by exact tax id.
36
+
37
+ A 404 raises APIError and does not mean the taxpayer does not
38
+ exist: the registry reloads monthly, so a company registered in
39
+ the last few weeks is simply not in it yet. Never build a
40
+ validation rule on top of it.
41
+ """
42
+ return cast(
43
+ dict,
44
+ self._request(
45
+ "GET",
46
+ f"/taxpayers/{_segment(tax_id)}",
47
+ params={"country": country or self.country},
48
+ ),
49
+ )