stackin-python-sdk 0.10.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.10.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
 
@@ -348,7 +355,9 @@ Three things worth knowing before you loop:
348
355
 
349
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.
350
357
  - `stackin.ConnectionFailedError` — the API didn't respond (network/DNS/timeout).
351
- - `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.
352
361
 
353
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.
354
363
 
@@ -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
 
@@ -319,7 +326,9 @@ Three things worth knowing before you loop:
319
326
 
320
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.
321
328
  - `stackin.ConnectionFailedError` — the API didn't respond (network/DNS/timeout).
322
- - `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.
323
332
 
324
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.
325
334
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stackin-python-sdk"
3
- version = "0.10.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.10.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.10.0"
3
+ __version__ = "0.11.0"
4
4
  __description__ = (
5
5
  "Python SDK for issuing, consulting and cancelling electronic invoices."
6
6
  )
@@ -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,6 +68,77 @@ def _validate_nfe_address(address: Address | None) -> None:
63
68
  )
64
69
 
65
70
 
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
+
66
142
  class _Client:
67
143
  """Where an api_key becomes an HTTP call, for every entry point.
68
144
 
@@ -172,15 +248,21 @@ class Invoice(_Client):
172
248
 
173
249
  if document_type is DocumentType.NFE:
174
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
+ )
175
256
  if not item.ncm:
176
257
  raise ValueError(f"items[{index}].ncm is required for NFE")
177
258
  if not item.cfop:
178
- raise ValueError(
179
- f"items[{index}].cfop is required for NFE"
180
- )
259
+ raise ValueError(f"items[{index}].cfop is required for NFE")
181
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)
182
264
 
183
- payload = {
265
+ payload: dict[str, Any] = {
184
266
  "document_type": document_type.value,
185
267
  "client_name": client_name,
186
268
  "tax_id": tax_id,
@@ -192,12 +274,17 @@ class Invoice(_Client):
192
274
  payload["series"] = series
193
275
  if number:
194
276
  payload["number"] = number
195
-
196
- return self._request(
197
- "POST",
198
- "/invoices",
199
- json=payload,
200
- 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
+ ),
201
288
  )
202
289
 
203
290
  def consult(
@@ -209,7 +296,9 @@ class Invoice(_Client):
209
296
  """Consults a fiscal document by its access key."""
210
297
  params = {"document_type": document_type.value}
211
298
 
212
- 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
+ )
213
302
 
214
303
  def cancel(
215
304
  self,
@@ -231,11 +320,14 @@ class Invoice(_Client):
231
320
  "reason": reason,
232
321
  }
233
322
 
234
- return self._request(
235
- "POST",
236
- f"/invoices/{access_key}/cancel",
237
- json=payload,
238
- 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
+ ),
239
331
  )
240
332
 
241
333
  def invalidate(
@@ -259,7 +351,9 @@ class Invoice(_Client):
259
351
  "reason": reason,
260
352
  }
261
353
 
262
- return self._request("POST", "/invoices/invalidations", json=payload)
354
+ return cast(
355
+ dict, self._request("POST", "/invoices/invalidations", json=payload)
356
+ )
263
357
 
264
358
  def correct(
265
359
  self,
@@ -268,7 +362,20 @@ class Invoice(_Client):
268
362
  document_type: DocumentType,
269
363
  correction: str,
270
364
  ) -> dict:
271
- """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
+ )
272
379
  if not 15 <= len(correction) <= 1000:
273
380
  raise ValueError("correction must be 15 to 1000 characters")
274
381
 
@@ -277,18 +384,20 @@ class Invoice(_Client):
277
384
  "correction": correction,
278
385
  }
279
386
 
280
- return self._request(
281
- "POST", f"/invoices/{access_key}/correction", json=payload
387
+ return cast(
388
+ dict,
389
+ self._request("POST", f"/invoices/{access_key}/correction", json=payload),
282
390
  )
283
391
 
284
- def reissue(
285
- self, invoice_id: str, *, idempotency_key: str | None = None
286
- ) -> dict:
392
+ def reissue(self, invoice_id: str, *, idempotency_key: str | None = None) -> dict:
287
393
  """Retries a previous invoice submission by its local id."""
288
- return self._request(
289
- "POST",
290
- f"/invoices/{invoice_id}/reissue",
291
- 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
+ ),
292
401
  )
293
402
 
294
403
  def pdf(
@@ -306,9 +415,7 @@ class Invoice(_Client):
306
415
  unavailable, not that the invoice is wrong. NFS-e only.
307
416
  """
308
417
  params = {"document_type": document_type.value}
309
- response = self._send(
310
- "GET", f"/invoices/{access_key}/pdf", params=params
311
- )
418
+ response = self._send("GET", f"/invoices/{access_key}/pdf", params=params)
312
419
  return response.content
313
420
 
314
421
  def received(
@@ -329,7 +436,7 @@ class Invoice(_Client):
329
436
  if offset is not None:
330
437
  params["offset"] = offset
331
438
 
332
- return self._request("GET", "/received-invoices", params=params)
439
+ return cast(dict, self._request("GET", "/received-invoices", params=params))
333
440
 
334
441
  def history(
335
442
  self,
@@ -360,7 +467,7 @@ class Invoice(_Client):
360
467
  if order_by is not None:
361
468
  params["order_by"] = order_by
362
469
 
363
- return self._request("GET", "/invoices", params=params)
470
+ return cast(dict, self._request("GET", "/invoices", params=params))
364
471
 
365
472
  def submissions(self, invoice_id: str) -> list:
366
473
  """Every attempt made for one invoice, and what came back.
@@ -368,7 +475,7 @@ class Invoice(_Client):
368
475
  consult() gives the status; this gives the reason. Takes the
369
476
  invoice_id because a rejected document has no access key.
370
477
  """
371
- return self._request("GET", f"/invoices/{invoice_id}/submissions")
478
+ return cast(list, self._request("GET", f"/invoices/{invoice_id}/submissions"))
372
479
 
373
480
  def manifest(
374
481
  self,
@@ -386,21 +493,22 @@ class Invoice(_Client):
386
493
  needs_reason = manifestation is Manifestation.OPERACAO_NAO_REALIZADA
387
494
  if needs_reason and not reason:
388
495
  raise ValueError(
389
- "manifestation 210240 (operação não realizada) requires "
390
- "a reason"
496
+ "manifestation 210240 (operação não realizada) requires " "a reason"
391
497
  )
392
498
  if not needs_reason and reason:
393
499
  raise ValueError(
394
- f"manifestation {manifestation.value} does not take a "
395
- "reason"
500
+ f"manifestation {manifestation.value} does not take a " "reason"
396
501
  )
397
502
 
398
503
  payload = {"manifestation": manifestation.value}
399
504
  if reason:
400
505
  payload["reason"] = reason
401
506
 
402
- return self._request(
403
- "POST",
404
- f"/received-invoices/{access_key}/manifestation",
405
- json=payload,
507
+ return cast(
508
+ dict,
509
+ self._request(
510
+ "POST",
511
+ f"/received-invoices/{access_key}/manifestation",
512
+ json=payload,
513
+ ),
406
514
  )
@@ -53,8 +53,7 @@ class Kind:
53
53
  dict,
54
54
  self._client._request(
55
55
  "GET",
56
- f"/fiscal-references/{_segment(self.name)}"
57
- f"/{_segment(code)}",
56
+ f"/fiscal-references/{_segment(self.name)}" f"/{_segment(code)}",
58
57
  params={"country": country or self.country},
59
58
  ),
60
59
  )