stackin-python-sdk 0.2.0__tar.gz → 0.3.1__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.2.0
3
+ Version: 0.3.1
4
4
  Summary: Python SDK for issuing, consulting and cancelling electronic invoices.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -47,7 +47,7 @@ Description-Content-Type: text/markdown
47
47
 
48
48
  Python SDK for fiscal document issuance — a handful of business fields, nothing about certificates, XML, XSD, signing or SOAP. The API resolves all of that from the issuer's own configuration, identified by `api_key`.
49
49
 
50
- **One class, `Invoice`** — `issue()`/`consult()`/`cancel()`/`reissue()`, nothing else to instantiate. Each line item is a `Product` (`stackin.br`) — `description`/`amount` are universal, everything else (`ncm`/`cfop`/`cest`/tax groups...) is Brazil-specific and only required for NFE; NFSE ignores it.
50
+ **One class, `Invoice`** — `issue()`/`consult()`/`cancel()`/`reissue()`/`correct()`/`invalidate()`/`pdf()`/`received()`/`manifest()`, nothing else to instantiate. Each line item is a `Product` (`stackin.br`) — `description`/`amount` are universal, everything else (`ncm`/`cfop`/`cest`/tax groups...) is Brazil-specific and only required for NFE; NFSE ignores it.
51
51
 
52
52
  ## Install
53
53
 
@@ -80,6 +80,11 @@ client.cancel(
80
80
  )
81
81
  client.reissue(invoice["id"]) # retries a rejected/failed submission
82
82
 
83
+ # The authorizer's PDF, as raw bytes. NFS-e only; the XML stays the
84
+ # legally valid document, and a 502 here means the authorizer is down.
85
+ with open("nota.pdf", "wb") as file:
86
+ file.write(client.pdf(access_key, document_type=DocumentType.NFSE))
87
+
83
88
  # NFE requires ncm/cfop on every item, plus the buyer's full recipient_address:
84
89
  client.issue(
85
90
  document_type=DocumentType.NFE,
@@ -195,6 +200,40 @@ round trip — and the authorizer checks again for what we can't see from here.
195
200
 
196
201
  **NF-e only**, and it takes no access key: there is no document to point at.
197
202
 
203
+ ## Documents issued against you
204
+
205
+ Everything above serves the **issuer**. These two serve the **recipient**: what
206
+ suppliers billed to this CNPJ, and the formal answer to it.
207
+
208
+ Reading the list never calls the SEFAZ. The authorizer caps how many times a CNPJ
209
+ may ask for its distribution per day, so collecting runs on a schedule on the API
210
+ side and a page refresh cannot spend that allowance.
211
+
212
+ ```python
213
+ from stackin import Manifestation
214
+
215
+ page = client.received(limit=20)
216
+ for document in page["data"]:
217
+ print(document["access_key"], document["issuer_name"], document["amount"])
218
+
219
+ client.manifest(access_key, manifestation=Manifestation.CIENCIA)
220
+ client.manifest(
221
+ access_key,
222
+ manifestation=Manifestation.OPERACAO_NAO_REALIZADA,
223
+ reason="Mercadoria nunca chegou ao endereco",
224
+ )
225
+ ```
226
+
227
+ Before you answer a document the SEFAZ sends only a **summary** (`resNFe`): access
228
+ key, issuer, amount, date. The **full document** (`nfeProc`) arrives after a
229
+ manifestation, and the `schema` field on each row says which one you hold.
230
+
231
+ The four answers are `210200` Confirmação da Operação, `210210` Ciência da
232
+ Operação, `210220` Desconhecimento da Operação and `210240` Operação não
233
+ Realizada. Only the last one takes a reason, and it requires one — both rules are
234
+ checked locally, before the request goes out, because a round trip to be told a
235
+ fixed rule is a round trip wasted.
236
+
198
237
  ## Errors
199
238
 
200
239
  - `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.
@@ -18,7 +18,7 @@
18
18
 
19
19
  Python SDK for fiscal document issuance — a handful of business fields, nothing about certificates, XML, XSD, signing or SOAP. The API resolves all of that from the issuer's own configuration, identified by `api_key`.
20
20
 
21
- **One class, `Invoice`** — `issue()`/`consult()`/`cancel()`/`reissue()`, nothing else to instantiate. Each line item is a `Product` (`stackin.br`) — `description`/`amount` are universal, everything else (`ncm`/`cfop`/`cest`/tax groups...) is Brazil-specific and only required for NFE; NFSE ignores it.
21
+ **One class, `Invoice`** — `issue()`/`consult()`/`cancel()`/`reissue()`/`correct()`/`invalidate()`/`pdf()`/`received()`/`manifest()`, nothing else to instantiate. Each line item is a `Product` (`stackin.br`) — `description`/`amount` are universal, everything else (`ncm`/`cfop`/`cest`/tax groups...) is Brazil-specific and only required for NFE; NFSE ignores it.
22
22
 
23
23
  ## Install
24
24
 
@@ -51,6 +51,11 @@ client.cancel(
51
51
  )
52
52
  client.reissue(invoice["id"]) # retries a rejected/failed submission
53
53
 
54
+ # The authorizer's PDF, as raw bytes. NFS-e only; the XML stays the
55
+ # legally valid document, and a 502 here means the authorizer is down.
56
+ with open("nota.pdf", "wb") as file:
57
+ file.write(client.pdf(access_key, document_type=DocumentType.NFSE))
58
+
54
59
  # NFE requires ncm/cfop on every item, plus the buyer's full recipient_address:
55
60
  client.issue(
56
61
  document_type=DocumentType.NFE,
@@ -166,6 +171,40 @@ round trip — and the authorizer checks again for what we can't see from here.
166
171
 
167
172
  **NF-e only**, and it takes no access key: there is no document to point at.
168
173
 
174
+ ## Documents issued against you
175
+
176
+ Everything above serves the **issuer**. These two serve the **recipient**: what
177
+ suppliers billed to this CNPJ, and the formal answer to it.
178
+
179
+ Reading the list never calls the SEFAZ. The authorizer caps how many times a CNPJ
180
+ may ask for its distribution per day, so collecting runs on a schedule on the API
181
+ side and a page refresh cannot spend that allowance.
182
+
183
+ ```python
184
+ from stackin import Manifestation
185
+
186
+ page = client.received(limit=20)
187
+ for document in page["data"]:
188
+ print(document["access_key"], document["issuer_name"], document["amount"])
189
+
190
+ client.manifest(access_key, manifestation=Manifestation.CIENCIA)
191
+ client.manifest(
192
+ access_key,
193
+ manifestation=Manifestation.OPERACAO_NAO_REALIZADA,
194
+ reason="Mercadoria nunca chegou ao endereco",
195
+ )
196
+ ```
197
+
198
+ Before you answer a document the SEFAZ sends only a **summary** (`resNFe`): access
199
+ key, issuer, amount, date. The **full document** (`nfeProc`) arrives after a
200
+ manifestation, and the `schema` field on each row says which one you hold.
201
+
202
+ The four answers are `210200` Confirmação da Operação, `210210` Ciência da
203
+ Operação, `210220` Desconhecimento da Operação and `210240` Operação não
204
+ Realizada. Only the last one takes a reason, and it requires one — both rules are
205
+ checked locally, before the request goes out, because a round trip to be told a
206
+ fixed rule is a round trip wasted.
207
+
169
208
  ## Errors
170
209
 
171
210
  - `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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stackin-python-sdk"
3
- version = "0.2.0"
3
+ version = "0.3.1"
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.2.0"
48
+ version = "0.3.1"
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"
@@ -1,6 +1,6 @@
1
1
  """Invoice __init__ module."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.1"
4
4
  __description__ = (
5
5
  "Python SDK for issuing, consulting and cancelling electronic invoices."
6
6
  )
@@ -8,12 +8,13 @@ __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.types import DocumentType, Environment
11
+ from .core.types import DocumentType, Environment, Manifestation
12
12
 
13
13
  __all__ = [
14
14
  "Invoice",
15
15
  "DocumentType",
16
16
  "Environment",
17
+ "Manifestation",
17
18
  "Address",
18
19
  "InvoiceError",
19
20
  "APIError",
@@ -67,7 +67,10 @@ class Product(BaseModel):
67
67
  ex_tipi: str | None = Field(default=None, pattern=r"^\d{2,3}$")
68
68
  import_content_control_number: str | None = Field(
69
69
  default=None,
70
- pattern=r"^[A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{12}$",
70
+ pattern=(
71
+ r"^[A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}"
72
+ r"-[A-F0-9]{4}-[A-F0-9]{12}$"
73
+ ),
71
74
  )
72
75
  recopi_number: str | None = Field(default=None, pattern=r"^\d{20}$")
73
76
  extra_groups: dict[str, Any] | None = Field(default=None)
@@ -9,7 +9,7 @@ import requests
9
9
  from stackin.br.product import Product
10
10
  from stackin.core.address import Address
11
11
  from stackin.core.exceptions import APIError, ConnectionFailedError
12
- from stackin.core.types import DocumentType, Environment
12
+ from stackin.core.types import DocumentType, Environment, Manifestation
13
13
 
14
14
  DEFAULT_BASE_URL = "https://sdk.stackin.io"
15
15
 
@@ -23,7 +23,7 @@ _ENVIRONMENT_URLS = {
23
23
  def _resolve_base_url(
24
24
  base_url: str | None, environment: Environment | str | None
25
25
  ) -> str:
26
- """Resolution order: explicit param, then env var, then environment's default."""
26
+ """Explicit param, then env var, then the environment's default."""
27
27
  if base_url:
28
28
  return base_url
29
29
  if url := os.environ.get("STACKIN_BASE_URL"):
@@ -48,7 +48,7 @@ _NFE_ADDRESS_FIELDS = (
48
48
 
49
49
 
50
50
  def _validate_nfe_address(address: Address | None) -> None:
51
- """NFE needs the buyer's full address — a partial one is a SEFAZ rejection."""
51
+ """NFE needs the full address — a partial one the SEFAZ rejects."""
52
52
  if address is None:
53
53
  raise ValueError("recipient_address is required for NFE")
54
54
 
@@ -204,6 +204,81 @@ class Invoice:
204
204
  idempotency_key=idempotency_key,
205
205
  )
206
206
 
207
+ def pdf(
208
+ self,
209
+ access_key: str,
210
+ *,
211
+ document_type: DocumentType,
212
+ ) -> bytes:
213
+ """The authorizer's rendering of an authorized document.
214
+
215
+ Returns raw bytes — the only method that does not return a
216
+ parsed object. The XML is the legally valid document; this is a
217
+ convenience, and the authorizer's endpoint for it is unstable,
218
+ so an APIError with status 502 means the authorizer is
219
+ unavailable, not that the invoice is wrong. NFS-e only.
220
+ """
221
+ params = {"document_type": document_type.value}
222
+ response = self._send(
223
+ "GET", f"/invoices/{access_key}/pdf", params=params
224
+ )
225
+ return response.content
226
+
227
+ def received(
228
+ self,
229
+ *,
230
+ limit: int | None = None,
231
+ offset: int | None = None,
232
+ ) -> dict:
233
+ """Documents other companies issued against this one.
234
+
235
+ Reads what the API already collected; it does not call the
236
+ authorizer. Collecting runs on a schedule there, because the
237
+ SEFAZ caps how many times a CNPJ may ask per day.
238
+ """
239
+ params = {}
240
+ if limit is not None:
241
+ params["limit"] = limit
242
+ if offset is not None:
243
+ params["offset"] = offset
244
+
245
+ return self._request("GET", "/received-invoices", params=params)
246
+
247
+ def manifest(
248
+ self,
249
+ access_key: str,
250
+ *,
251
+ manifestation: Manifestation,
252
+ reason: str | None = None,
253
+ ) -> dict:
254
+ """The recipient's formal answer to a received document.
255
+
256
+ Only `OPERACAO_NAO_REALIZADA` takes a reason, and it requires
257
+ one. Both are fixed rules, so they are checked here rather than
258
+ spending a round trip to be told.
259
+ """
260
+ needs_reason = manifestation is Manifestation.OPERACAO_NAO_REALIZADA
261
+ if needs_reason and not reason:
262
+ raise ValueError(
263
+ "manifestation 210240 (operação não realizada) requires "
264
+ "a reason"
265
+ )
266
+ if not needs_reason and reason:
267
+ raise ValueError(
268
+ f"manifestation {manifestation.value} does not take a "
269
+ "reason"
270
+ )
271
+
272
+ payload = {"manifestation": manifestation.value}
273
+ if reason:
274
+ payload["reason"] = reason
275
+
276
+ return self._request(
277
+ "POST",
278
+ f"/received-invoices/{access_key}/manifestation",
279
+ json=payload,
280
+ )
281
+
207
282
  def _headers(self, idempotency_key: str | None = None) -> dict:
208
283
  headers = {}
209
284
  if self.api_key:
@@ -221,6 +296,28 @@ class Invoice:
221
296
  params: dict | None = None,
222
297
  idempotency_key: str | None = None,
223
298
  ) -> dict:
299
+ response = self._send(
300
+ method,
301
+ path,
302
+ json=json,
303
+ params=params,
304
+ idempotency_key=idempotency_key,
305
+ )
306
+ try:
307
+ body = response.json() if response.content else {}
308
+ except ValueError:
309
+ body = {}
310
+ return body.get("result", body)
311
+
312
+ def _send(
313
+ self,
314
+ method: str,
315
+ path: str,
316
+ *,
317
+ json: dict | None = None,
318
+ params: dict | None = None,
319
+ idempotency_key: str | None = None,
320
+ ) -> requests.Response:
224
321
  url = f"{self.base_url}/api/v1{path}"
225
322
 
226
323
  try:
@@ -235,15 +332,14 @@ class Invoice:
235
332
  except requests.RequestException as error:
236
333
  raise ConnectionFailedError(str(error)) from error
237
334
 
238
- try:
239
- body = response.json() if response.content else {}
240
- except ValueError:
241
- body = {}
242
-
243
335
  if not response.ok:
336
+ try:
337
+ body = response.json() if response.content else {}
338
+ except ValueError:
339
+ body = {}
244
340
  raise APIError(
245
341
  status_code=response.status_code,
246
342
  detail=body.get("detail", response.text),
247
343
  )
248
344
 
249
- return body.get("result", body)
345
+ return response
@@ -15,4 +15,4 @@ class APIError(InvoiceError):
15
15
 
16
16
 
17
17
  class ConnectionFailedError(InvoiceError):
18
- """Raised when the invoice API can't be reached at all (network/DNS/timeout)."""
18
+ """The API could not be reached at all (network/DNS/timeout)."""
@@ -13,8 +13,17 @@ class DocumentType(str, Enum):
13
13
 
14
14
 
15
15
  class Environment(str, Enum):
16
- """Which host to talk to — pass to `Invoice(environment=...)` instead of a raw `base_url`."""
16
+ """Which host to talk to, instead of a raw `base_url`."""
17
17
 
18
18
  LOCAL = "local"
19
19
  TEST = "test"
20
20
  PRODUCTION = "production"
21
+
22
+
23
+ class Manifestation(str, Enum):
24
+ """The recipient's four possible answers to a received document."""
25
+
26
+ CONFIRMACAO = "210200"
27
+ CIENCIA = "210210"
28
+ DESCONHECIMENTO = "210220"
29
+ OPERACAO_NAO_REALIZADA = "210240"