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.
- {stackin_python_sdk-0.9.0 → stackin_python_sdk-0.11.0}/PKG-INFO +47 -7
- {stackin_python_sdk-0.9.0 → stackin_python_sdk-0.11.0}/README.md +46 -6
- {stackin_python_sdk-0.9.0 → stackin_python_sdk-0.11.0}/pyproject.toml +4 -2
- {stackin_python_sdk-0.9.0 → stackin_python_sdk-0.11.0}/stackin/__init__.py +7 -1
- {stackin_python_sdk-0.9.0 → stackin_python_sdk-0.11.0}/stackin/br/product.py +38 -13
- {stackin_python_sdk-0.9.0 → stackin_python_sdk-0.11.0}/stackin/core/address.py +9 -1
- {stackin_python_sdk-0.9.0 → stackin_python_sdk-0.11.0}/stackin/core/client.py +231 -114
- stackin_python_sdk-0.11.0/stackin/core/reference.py +172 -0
- stackin_python_sdk-0.11.0/stackin/core/taxpayer.py +49 -0
- {stackin_python_sdk-0.9.0 → stackin_python_sdk-0.11.0}/LICENSE +0 -0
- {stackin_python_sdk-0.9.0 → stackin_python_sdk-0.11.0}/stackin/br/__init__.py +0 -0
- {stackin_python_sdk-0.9.0 → stackin_python_sdk-0.11.0}/stackin/br/tax.py +0 -0
- {stackin_python_sdk-0.9.0 → stackin_python_sdk-0.11.0}/stackin/core/__init__.py +0 -0
- {stackin_python_sdk-0.9.0 → stackin_python_sdk-0.11.0}/stackin/core/exceptions.py +0 -0
- {stackin_python_sdk-0.9.0 → stackin_python_sdk-0.11.0}/stackin/core/types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackin-python-sdk
|
|
3
|
-
Version: 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
|
|
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
|
|
255
|
-
|
|
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
|
|
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
|
|
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
|
|
226
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
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
|
|
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
|
|
205
|
-
|
|
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
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|