stackin-python-sdk 0.7.0__tar.gz → 0.9.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.7.0
3
+ Version: 0.9.0
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,73 @@ 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()`/`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.
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` plus either `unit_price` or `amount` are universal, everything else (`ncm`/`cfop`/`cest`/tax groups...) is Brazil-specific and only required for NFE; NFSE ignores it.
51
+
52
+ ## What a line item is worth
53
+
54
+ `unit_price` is the price of **one unit**. `amount` is the **gross total of
55
+ the line's products**, before discount, freight, insurance and other
56
+ expenses. Send either; sending both asserts that they agree.
57
+
58
+ ```python
59
+ from decimal import Decimal
60
+
61
+ # One unit
62
+ Product(description="Consultoria", quantity=Decimal("1"), unit_price=Decimal("1500.00"))
63
+
64
+ # More than one unit — amount is computed as 2 x 120.00 = 240.00
65
+ Product(description="Teclado", quantity=Decimal("2"), unit_price=Decimal("120.00"))
66
+
67
+ # A fractional quantity — 1.5 x 30.00 = 45.00
68
+ Product(description="Cabo", quantity=Decimal("1.5"), unit_price=Decimal("30.00"))
69
+
70
+ # Legacy: amount alone still means the line's gross total
71
+ Product(description="Servico", quantity=Decimal("3"), amount=Decimal("150.00"))
72
+
73
+ # Both, checked against each other before the document is transmitted
74
+ Product(
75
+ description="Teclado",
76
+ quantity=Decimal("2"),
77
+ unit_price=Decimal("120.00"),
78
+ amount=Decimal("240.00"),
79
+ )
80
+
81
+ # With charges: 5000.00 - 100.50 + 40.00 + 10.00 = 4949.50 on the note
82
+ Product(
83
+ description="Servidor",
84
+ quantity=Decimal("2"),
85
+ unit_price=Decimal("2500.00"),
86
+ discount=100.50,
87
+ freight=40.00,
88
+ insurance=10.00,
89
+ )
90
+ ```
91
+
92
+ Amounts that do not add up are refused before the authorizer sees them,
93
+ with a `422` naming the line and both numbers:
94
+
95
+ ```json
96
+ {
97
+ "detail": {
98
+ "code": "ITEM_TOTAL_MISMATCH",
99
+ "field": "items[0].amount",
100
+ "message": "amount is 50.00, but quantity x unit_price is 150.00.",
101
+ "expected": "150.00",
102
+ "received": "50.00"
103
+ }
104
+ }
105
+ ```
106
+
107
+ ### Migrating
108
+
109
+ ```text
110
+ Before: quantity = 3, amount = 150.00
111
+ After: quantity = 3, unit_price = 50.00
112
+ ```
113
+
114
+ Nothing has to migrate. `amount` keeps the meaning it always had and is
115
+ not deprecated in this release; a line sent with `amount` alone is built
116
+ exactly as it was before.
51
117
 
52
118
  ## Install
53
119
 
@@ -60,6 +126,8 @@ pip install stackin-python-sdk
60
126
  Get an `api_key` from the [stackin dashboard](https://app.stackin.io) — select the issuing company, then Settings → API key (context `sdk`). One key per issuing company, shown once at creation. The API resolves the issuer (CNPJ, state, address, certificate, environment) entirely from it; nothing about the issuer is ever passed on a call.
61
127
 
62
128
  ```python
129
+ from decimal import Decimal
130
+
63
131
  from stackin import Invoice, DocumentType, Address
64
132
  from stackin.br import Product # Brazil-specific line item — NCM/CFOP
65
133
 
@@ -69,7 +137,7 @@ invoice = client.issue(
69
137
  document_type=DocumentType.NFSE,
70
138
  client_name="John Doe",
71
139
  tax_id="00000000000",
72
- items=[Product(description="Software development", amount=5000.00)],
140
+ items=[Product(description="Software development", unit_price=Decimal("5000.00"))],
73
141
  )
74
142
 
75
143
  status = client.consult("ACCESS_KEY...", document_type=DocumentType.NFSE)
@@ -90,7 +158,16 @@ client.issue(
90
158
  document_type=DocumentType.NFE,
91
159
  client_name="Buyer Company Ltd",
92
160
  tax_id="11111111111111",
93
- items=[Product(description="Test product", amount=100.00, ncm="84713012", cfop="5102")],
161
+ items=[
162
+ Product(
163
+ description="Teclado",
164
+ quantity=Decimal("2"),
165
+ unit_price=Decimal("120.00"),
166
+ unit="UN",
167
+ ncm="84716052",
168
+ cfop="5102",
169
+ )
170
+ ],
94
171
  recipient_address=Address(
95
172
  street="Avenida Atlantica",
96
173
  number="500",
@@ -117,13 +194,15 @@ burned, and undoing it means cancelling, which has a deadline.
117
194
  Pass `idempotency_key` to make the retry safe:
118
195
 
119
196
  ```python
197
+ from decimal import Decimal
198
+
120
199
  key = str(uuid.uuid4())
121
200
 
122
201
  result = invoice.issue(
123
202
  document_type=DocumentType.NFSE,
124
203
  client_name="Maria Silva",
125
204
  tax_id="12345678909",
126
- items=[Product(description="Consultoria", amount=1500.00)],
205
+ items=[Product(description="Consultoria", unit_price=Decimal("1500.00"))],
127
206
  idempotency_key=key,
128
207
  )
129
208
  ```
@@ -18,7 +18,73 @@
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()`/`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.
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` plus either `unit_price` or `amount` are universal, everything else (`ncm`/`cfop`/`cest`/tax groups...) is Brazil-specific and only required for NFE; NFSE ignores it.
22
+
23
+ ## What a line item is worth
24
+
25
+ `unit_price` is the price of **one unit**. `amount` is the **gross total of
26
+ the line's products**, before discount, freight, insurance and other
27
+ expenses. Send either; sending both asserts that they agree.
28
+
29
+ ```python
30
+ from decimal import Decimal
31
+
32
+ # One unit
33
+ Product(description="Consultoria", quantity=Decimal("1"), unit_price=Decimal("1500.00"))
34
+
35
+ # More than one unit — amount is computed as 2 x 120.00 = 240.00
36
+ Product(description="Teclado", quantity=Decimal("2"), unit_price=Decimal("120.00"))
37
+
38
+ # A fractional quantity — 1.5 x 30.00 = 45.00
39
+ Product(description="Cabo", quantity=Decimal("1.5"), unit_price=Decimal("30.00"))
40
+
41
+ # Legacy: amount alone still means the line's gross total
42
+ Product(description="Servico", quantity=Decimal("3"), amount=Decimal("150.00"))
43
+
44
+ # Both, checked against each other before the document is transmitted
45
+ Product(
46
+ description="Teclado",
47
+ quantity=Decimal("2"),
48
+ unit_price=Decimal("120.00"),
49
+ amount=Decimal("240.00"),
50
+ )
51
+
52
+ # With charges: 5000.00 - 100.50 + 40.00 + 10.00 = 4949.50 on the note
53
+ Product(
54
+ description="Servidor",
55
+ quantity=Decimal("2"),
56
+ unit_price=Decimal("2500.00"),
57
+ discount=100.50,
58
+ freight=40.00,
59
+ insurance=10.00,
60
+ )
61
+ ```
62
+
63
+ Amounts that do not add up are refused before the authorizer sees them,
64
+ with a `422` naming the line and both numbers:
65
+
66
+ ```json
67
+ {
68
+ "detail": {
69
+ "code": "ITEM_TOTAL_MISMATCH",
70
+ "field": "items[0].amount",
71
+ "message": "amount is 50.00, but quantity x unit_price is 150.00.",
72
+ "expected": "150.00",
73
+ "received": "50.00"
74
+ }
75
+ }
76
+ ```
77
+
78
+ ### Migrating
79
+
80
+ ```text
81
+ Before: quantity = 3, amount = 150.00
82
+ After: quantity = 3, unit_price = 50.00
83
+ ```
84
+
85
+ Nothing has to migrate. `amount` keeps the meaning it always had and is
86
+ not deprecated in this release; a line sent with `amount` alone is built
87
+ exactly as it was before.
22
88
 
23
89
  ## Install
24
90
 
@@ -31,6 +97,8 @@ pip install stackin-python-sdk
31
97
  Get an `api_key` from the [stackin dashboard](https://app.stackin.io) — select the issuing company, then Settings → API key (context `sdk`). One key per issuing company, shown once at creation. The API resolves the issuer (CNPJ, state, address, certificate, environment) entirely from it; nothing about the issuer is ever passed on a call.
32
98
 
33
99
  ```python
100
+ from decimal import Decimal
101
+
34
102
  from stackin import Invoice, DocumentType, Address
35
103
  from stackin.br import Product # Brazil-specific line item — NCM/CFOP
36
104
 
@@ -40,7 +108,7 @@ invoice = client.issue(
40
108
  document_type=DocumentType.NFSE,
41
109
  client_name="John Doe",
42
110
  tax_id="00000000000",
43
- items=[Product(description="Software development", amount=5000.00)],
111
+ items=[Product(description="Software development", unit_price=Decimal("5000.00"))],
44
112
  )
45
113
 
46
114
  status = client.consult("ACCESS_KEY...", document_type=DocumentType.NFSE)
@@ -61,7 +129,16 @@ client.issue(
61
129
  document_type=DocumentType.NFE,
62
130
  client_name="Buyer Company Ltd",
63
131
  tax_id="11111111111111",
64
- items=[Product(description="Test product", amount=100.00, ncm="84713012", cfop="5102")],
132
+ items=[
133
+ Product(
134
+ description="Teclado",
135
+ quantity=Decimal("2"),
136
+ unit_price=Decimal("120.00"),
137
+ unit="UN",
138
+ ncm="84716052",
139
+ cfop="5102",
140
+ )
141
+ ],
65
142
  recipient_address=Address(
66
143
  street="Avenida Atlantica",
67
144
  number="500",
@@ -88,13 +165,15 @@ burned, and undoing it means cancelling, which has a deadline.
88
165
  Pass `idempotency_key` to make the retry safe:
89
166
 
90
167
  ```python
168
+ from decimal import Decimal
169
+
91
170
  key = str(uuid.uuid4())
92
171
 
93
172
  result = invoice.issue(
94
173
  document_type=DocumentType.NFSE,
95
174
  client_name="Maria Silva",
96
175
  tax_id="12345678909",
97
- items=[Product(description="Consultoria", amount=1500.00)],
176
+ items=[Product(description="Consultoria", unit_price=Decimal("1500.00"))],
98
177
  idempotency_key=key,
99
178
  )
100
179
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stackin-python-sdk"
3
- version = "0.7.0"
3
+ version = "0.9.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.7.0"
48
+ version = "0.9.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"
@@ -1,6 +1,6 @@
1
1
  """Invoice __init__ module."""
2
2
 
3
- __version__ = "0.7.0"
3
+ __version__ = "0.9.0"
4
4
  __description__ = (
5
5
  "Python SDK for issuing, consulting and cancelling electronic invoices."
6
6
  )
@@ -1,6 +1,6 @@
1
1
  """Brazil-specific document fields."""
2
2
 
3
- from stackin.br.product import PresumedCredit, Product
3
+ from stackin.br.product import IbsCbs, PresumedCredit, Product
4
4
  from stackin.br.tax import (
5
5
  CofinsAliq,
6
6
  CofinsNt,
@@ -42,6 +42,7 @@ from stackin.br.tax import (
42
42
 
43
43
  __all__ = [
44
44
  "Product",
45
+ "IbsCbs",
45
46
  "PresumedCredit",
46
47
  "Tax",
47
48
  "Icms00",
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from decimal import Decimal
5
6
  from typing import Annotated, Any
6
7
 
7
8
  from pydantic import BaseModel, Field
@@ -9,6 +10,17 @@ from pydantic import BaseModel, Field
9
10
  from stackin.br.tax import Tax
10
11
 
11
12
 
13
+ class IbsCbs(BaseModel):
14
+ """The Reforma Tributária group for one item."""
15
+
16
+ cst: str = Field(pattern=r"^\d{3}$")
17
+ classification: str = Field(pattern=r"^\d{6}$")
18
+ base: float | None = Field(default=None, gt=0)
19
+ rate_state: float = Field(ge=0)
20
+ rate_city: float = Field(ge=0)
21
+ rate_federal: float = Field(ge=0)
22
+
23
+
12
24
  class PresumedCredit(BaseModel):
13
25
  """A presumed tax credit applied to this item."""
14
26
 
@@ -32,15 +44,22 @@ _BR_FIELDS = {
32
44
  "import_content_control_number",
33
45
  "recopi_number",
34
46
  "extra_groups",
47
+ "ibs_cbs",
35
48
  "tax",
36
49
  }
37
50
 
38
51
 
52
+ def _decimal_str(value: Decimal | None) -> str | None:
53
+ """JSON has no decimal; a float would lose the tenth place."""
54
+ return None if value is None else format(value, "f")
55
+
56
+
39
57
  class Product(BaseModel):
40
58
  """One product or service line item on an invoice."""
41
59
 
42
60
  description: str = Field(..., min_length=1)
43
- amount: float = Field(..., gt=0)
61
+ amount: Decimal | None = Field(default=None, gt=0)
62
+ unit_price: Decimal | None = Field(default=None, gt=0)
44
63
  unit: str = Field(default="UN")
45
64
  quantity: float = Field(default=1.0, gt=0)
46
65
  barcode: str | None = Field(default=None)
@@ -73,6 +92,7 @@ class Product(BaseModel):
73
92
  ),
74
93
  )
75
94
  recopi_number: str | None = Field(default=None, pattern=r"^\d{20}$")
95
+ ibs_cbs: IbsCbs | None = Field(default=None)
76
96
  extra_groups: dict[str, Any] | None = Field(default=None)
77
97
  tax: Tax | dict[str, Any] | None = Field(default=None)
78
98
 
@@ -102,6 +122,7 @@ class Product(BaseModel):
102
122
  exclude={
103
123
  "description",
104
124
  "amount",
125
+ "unit_price",
105
126
  "tax",
106
127
  *_BR_FIELDS,
107
128
  *_NFSE_FIELDS,
@@ -116,7 +137,8 @@ class Product(BaseModel):
116
137
  data["br"] = br
117
138
  return {
118
139
  "description": self.description,
119
- "amount": self.amount,
140
+ "amount": _decimal_str(self.amount),
141
+ "unit_price": _decimal_str(self.unit_price),
120
142
  "product": data,
121
143
  "service_code": self.service_code,
122
144
  "discount": self.service_discount,