eqinvoice 1.1.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.
Files changed (50) hide show
  1. eqinvoice-1.1.0/LICENSE +21 -0
  2. eqinvoice-1.1.0/PKG-INFO +303 -0
  3. eqinvoice-1.1.0/README.md +266 -0
  4. eqinvoice-1.1.0/pyproject.toml +85 -0
  5. eqinvoice-1.1.0/setup.cfg +4 -0
  6. eqinvoice-1.1.0/src/eqinvoice/__init__.py +97 -0
  7. eqinvoice-1.1.0/src/eqinvoice/access_key.py +46 -0
  8. eqinvoice-1.1.0/src/eqinvoice/document.py +203 -0
  9. eqinvoice-1.1.0/src/eqinvoice/errors.py +23 -0
  10. eqinvoice-1.1.0/src/eqinvoice/labels/es.json +65 -0
  11. eqinvoice-1.1.0/src/eqinvoice/model.py +155 -0
  12. eqinvoice-1.1.0/src/eqinvoice/pricing.py +39 -0
  13. eqinvoice-1.1.0/src/eqinvoice/protocol.py +108 -0
  14. eqinvoice-1.1.0/src/eqinvoice/py.typed +0 -0
  15. eqinvoice-1.1.0/src/eqinvoice/record.py +184 -0
  16. eqinvoice-1.1.0/src/eqinvoice/registry.py +149 -0
  17. eqinvoice-1.1.0/src/eqinvoice/ride/__init__.py +14 -0
  18. eqinvoice-1.1.0/src/eqinvoice/ride/labels.py +28 -0
  19. eqinvoice-1.1.0/src/eqinvoice/ride/logo.py +243 -0
  20. eqinvoice-1.1.0/src/eqinvoice/ride/pdf.py +548 -0
  21. eqinvoice-1.1.0/src/eqinvoice/schemas/NotaCredito_V1.1.0.xsd +584 -0
  22. eqinvoice-1.1.0/src/eqinvoice/schemas/factura_V1.1.0.xsd +961 -0
  23. eqinvoice-1.1.0/src/eqinvoice/schemas/xmldsig-core-schema.xsd +318 -0
  24. eqinvoice-1.1.0/src/eqinvoice/service.py +221 -0
  25. eqinvoice-1.1.0/src/eqinvoice/signature.py +276 -0
  26. eqinvoice-1.1.0/src/eqinvoice.egg-info/PKG-INFO +303 -0
  27. eqinvoice-1.1.0/src/eqinvoice.egg-info/SOURCES.txt +48 -0
  28. eqinvoice-1.1.0/src/eqinvoice.egg-info/dependency_links.txt +1 -0
  29. eqinvoice-1.1.0/src/eqinvoice.egg-info/entry_points.txt +2 -0
  30. eqinvoice-1.1.0/src/eqinvoice.egg-info/requires.txt +14 -0
  31. eqinvoice-1.1.0/src/eqinvoice.egg-info/top_level.txt +2 -0
  32. eqinvoice-1.1.0/src/eqinvoice_mcp/__init__.py +0 -0
  33. eqinvoice-1.1.0/src/eqinvoice_mcp/book.py +205 -0
  34. eqinvoice-1.1.0/src/eqinvoice_mcp/desk.py +272 -0
  35. eqinvoice-1.1.0/src/eqinvoice_mcp/icon.svg +1 -0
  36. eqinvoice-1.1.0/src/eqinvoice_mcp/model.py +68 -0
  37. eqinvoice-1.1.0/src/eqinvoice_mcp/server.py +267 -0
  38. eqinvoice-1.1.0/src/eqinvoice_mcp/settings.py +28 -0
  39. eqinvoice-1.1.0/tests/test_access_key.py +33 -0
  40. eqinvoice-1.1.0/tests/test_book.py +52 -0
  41. eqinvoice-1.1.0/tests/test_desk.py +181 -0
  42. eqinvoice-1.1.0/tests/test_document.py +80 -0
  43. eqinvoice-1.1.0/tests/test_plugin.py +48 -0
  44. eqinvoice-1.1.0/tests/test_pricing.py +36 -0
  45. eqinvoice-1.1.0/tests/test_record.py +44 -0
  46. eqinvoice-1.1.0/tests/test_registry.py +98 -0
  47. eqinvoice-1.1.0/tests/test_ride.py +66 -0
  48. eqinvoice-1.1.0/tests/test_service.py +115 -0
  49. eqinvoice-1.1.0/tests/test_settings.py +24 -0
  50. eqinvoice-1.1.0/tests/test_signature.py +104 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jahirtrap
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,303 @@
1
+ Metadata-Version: 2.4
2
+ Name: eqinvoice
3
+ Version: 1.1.0
4
+ Summary: Electronic invoices and credit notes for Ecuador's SRI: build, sign, send, authorize and print, as a library or an MCP server.
5
+ Author: jahirtrap
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/jahirxtrap/eqinvoice
8
+ Project-URL: Repository, https://github.com/jahirxtrap/eqinvoice
9
+ Project-URL: Issues, https://github.com/jahirxtrap/eqinvoice/issues
10
+ Keywords: ecuador,sri,electronic invoice,factura electronica,xades,mcp
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Natural Language :: Spanish
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: cryptography>=50.0
25
+ Requires-Dist: fonttools>=4.60
26
+ Requires-Dist: httpx>=0.28
27
+ Requires-Dist: lxml>=6.1
28
+ Requires-Dist: reportlab>=5.1
29
+ Provides-Extra: mcp
30
+ Requires-Dist: mcp>=2.3; extra == "mcp"
31
+ Provides-Extra: dev
32
+ Requires-Dist: mcp>=2.3; extra == "dev"
33
+ Requires-Dist: mypy>=2.4; extra == "dev"
34
+ Requires-Dist: pytest>=9.1; extra == "dev"
35
+ Requires-Dist: ruff>=0.17; extra == "dev"
36
+ Dynamic: license-file
37
+
38
+ <p align="center">
39
+ <img src="https://raw.githubusercontent.com/jahirxtrap/eqinvoice/main/.github/assets/banner.png" alt="EQinvoice" width="760">
40
+ </p>
41
+
42
+ Electronic invoices and credit notes for **Ecuador's SRI** (offline scheme 1.1.0): build the
43
+ voucher, sign it XAdES-BES with the issuer's .p12, send it, read the authorization back and print
44
+ the RIDE. Use it as a **library** inside your own system, or as an **MCP server** that lets an
45
+ assistant draft, preview, issue and follow one issuer's vouchers.
46
+
47
+ <p align="center">
48
+ <a href="https://pypi.org/project/eqinvoice/">PyPI</a> ·
49
+ <a href="#library">Library</a> ·
50
+ <a href="#claude-code-plugin">Claude Code plugin</a> ·
51
+ <a href="#mcp-server">MCP server</a>
52
+ </p>
53
+
54
+ ```
55
+ [your system] ──values──> [eqinvoice] ──signed XML──> [SRI reception] ──> [SRI authorization]
56
+ [assistant] ──MCP────> [eqinvoice-mcp] ──> the book (clients, drafts, vouchers)
57
+ ```
58
+
59
+ ---
60
+
61
+ ## Install
62
+
63
+ ```bash
64
+ pip install eqinvoice # the library
65
+ pip install "eqinvoice[mcp]" # with the MCP server
66
+ ```
67
+
68
+ Python 3.11 or newer. In Claude Code, the [plugin](#claude-code-plugin) installs everything in one step.
69
+
70
+ ## Capabilities
71
+
72
+ - **Vouchers**: invoices (factura) and credit notes (nota de crédito), checked against the SRI's own XSD before they leave.
73
+ - **Access key**: the 49-digit clave de acceso with its modulo 11 check digit, and the issue date on the SRI's clock.
74
+ - **Signature**: XAdES-BES with the issuer's .p12 and its password, refused with a reason when it is missing, unreadable or out of date.
75
+ - **SRI web services**: reception and authorization, in the test environment (celcer) or in production (cel).
76
+ - **Taxpayer registry**: a RUC looked up in the SRI's public registry (legal name, standing, regime, accounting duty, establishments), and the issuer its vouchers name.
77
+ - **Priced lines**: a line's subtotal and IVA worked out to the cent from its quantity, unit price, discount and the SRI's rate code.
78
+ - **Printed copy (RIDE)**: an A4 page or an 80 mm strip, with the SRI's full block of totals and the access key as a barcode (page) or a QR code (strip).
79
+ - **Logo**: a picture, or a mark and a name drawn as vectors, set beside each other as a lockup.
80
+ - **Labels**: the printed words are data. Spanish by default, as the SRI prints them; any file of the same shape replaces them.
81
+ - **Simulation**: a simulated signature, SRI and registry that answer on the spot, for development and tests.
82
+
83
+ ---
84
+
85
+ ## Library
86
+
87
+ ```python
88
+ from datetime import UTC, datetime
89
+ from decimal import Decimal
90
+ from pathlib import Path
91
+
92
+ from eqinvoice import (
93
+ Buyer,
94
+ BuyerKind,
95
+ Environment,
96
+ Issuer,
97
+ Payment,
98
+ SriWebServices,
99
+ Voucher,
100
+ VoucherKind,
101
+ access_key,
102
+ issue_date,
103
+ price_line,
104
+ read,
105
+ read_credential,
106
+ sign_voucher,
107
+ )
108
+ from eqinvoice.ride import ImageLogo, Layout, render
109
+
110
+ issuer = Issuer(
111
+ legal_name="ACME COMERCIAL S.A.S.",
112
+ ruc="1790011223001",
113
+ head_office_address="AV. AMAZONAS N10-20, QUITO",
114
+ keeps_accounts=True,
115
+ )
116
+ credential = read_credential(Path("signature.p12"), "p12 password")
117
+ now = datetime.now(UTC)
118
+ issued_on = issue_date(now)
119
+ line = price_line("SKU-1", "Cafe molido (500 g)", Decimal("1"), Decimal("10.00"), "4")
120
+ voucher = Voucher(
121
+ kind=VoucherKind.INVOICE,
122
+ environment=Environment.TEST,
123
+ access_key=access_key(
124
+ issued_on, VoucherKind.INVOICE, issuer.ruc, Environment.TEST, "001", "001", 1
125
+ ),
126
+ establishment="001",
127
+ point="001",
128
+ sequential=1,
129
+ establishment_address="AV. AMAZONAS N10-20, QUITO",
130
+ issued_on=issued_on,
131
+ buyer=Buyer(BuyerKind.CEDULA, "1710034065", "Ana Perez", "ana@example.com"),
132
+ lines=(line,),
133
+ payments=(Payment("01", Decimal("11.50")),),
134
+ )
135
+
136
+ document = sign_voucher(voucher, issuer, credential, now)
137
+ sri = SriWebServices(Environment.TEST)
138
+ reception = sri.send(document)
139
+ answer = sri.authorize(voucher.access_key)
140
+
141
+ if answer is not None and answer.authorized:
142
+ issued = read(answer.record)
143
+ page = render(issued, Layout.PAGE, logo=ImageLogo(Path("logo.png")))
144
+ strip = render(issued, Layout.TICKET, logo=ImageLogo(Path("logo.png")))
145
+ ```
146
+
147
+ - **What to keep**: `answer.record` is the authorized XML, the voucher with tax validity. Store it: `read(record)` gives the voucher back and `render` prints it whenever it is needed.
148
+ - **Still processing**: `authorize` gives `None` while the SRI has not answered yet; ask again later with the same access key.
149
+ - **Sent twice**: `reception.already_there` is true when the SRI already holds that access key, so its answer waits at the authorization service.
150
+ - **Numbering**: the establishment, the emission point and the sequential belong to the caller's system. A sequential is never reused, and a voucher sent again keeps its access key.
151
+ - **Signed documents**: `read_voucher(document)` gives back the voucher a signed or unsigned document carries.
152
+
153
+ <details>
154
+ <summary><b>Amounts and codes</b></summary>
155
+
156
+ - **Lines**: `subtotal` is the line before IVA (quantity times unit price minus discount) and `vat` its tax, both to two decimals. `price_line(code, description, quantity, unit_price, rate_code, discount)` works them out, rounding halves up. The voucher adds them up per rate.
157
+ - **IVA rate codes** (`rate_code`): the SRI's table, for example `"4"` for 15 %, `"0"` for 0 %, `"6"` not subject to IVA, `"7"` exempt.
158
+ - **Payment forms** (`Payment.form`): the SRI's table, for example `"01"` without the financial system (cash), `"16"` debit card, `"19"` credit card, `"20"` other with the financial system (transfer). Payments of the same form are added up.
159
+ - **Credit notes**: `modified=Modified(number, issued_on, reason)` names the invoice they correct; credit notes carry no payments.
160
+ - **Final consumer**: `Buyer(BuyerKind.FINAL_CONSUMER)`. The voucher writes the SRI's own name and number for it.
161
+
162
+ </details>
163
+
164
+ <details>
165
+ <summary><b>When something fails</b></summary>
166
+
167
+ - **`SriUnavailable`**: the SRI could not be reached or failed on its side. Nothing is lost: send the same document again later.
168
+ - **`VoucherInvalid`**: the voucher breaks the SRI's schema (the message names every reason) or the record holds no voucher. Fix the data; sending it would only be returned.
169
+ - **`CredentialRefused`**: the signature cannot sign. `problem` says why: `missing`, `unreadable` (wrong password or not a .p12) or `out_of_date`.
170
+ - **Turned down**: `answer.authorized` is false and `answer.messages` carry the SRI's codes and texts, as it wrote them.
171
+
172
+ </details>
173
+
174
+ ### Taxpayer registry
175
+
176
+ ```python
177
+ from eqinvoice import SriRegistry
178
+
179
+ taxpayer = SriRegistry().find("1791234567001")
180
+ if taxpayer is not None and taxpayer.active:
181
+ issuer = taxpayer.issuer(trade_name="Acme Shop")
182
+ ```
183
+
184
+ - **Nobody holds it**: `find` gives `None`. A registry that cannot be reached raises `SriUnavailable`.
185
+ - **The issuer**: `taxpayer.issuer()` names the head office address the registry lists. A withholding agent or a special taxpayer passes its resolution or number (`withholding_agent=`, `special_taxpayer=`), which the registry does not publish.
186
+
187
+ ### Logo
188
+
189
+ - **A mark and a name**: `MarkLogo(mark=Path("mark.svg"), name=Path("name.svg"), font=Path("Brand.ttf"))`. The mark is an SVG of rectangles and straight paths; the name is an SVG `<text>` whose runs carry their colours (a `<tspan>` for a second colour), set in the font given. They sit as a lockup: for a mark 28 high the name is 17 and the gap 8, the proportion `name_size` and `gap` change, and the name's own glyphs (ascenders and descenders included) are centred on the mark.
190
+ - **A picture**: `ImageLogo(Path("logo.png"))`, drawn at the logo's height keeping its proportions. `ImageLogo(path, one_ink_path=Path("logo-black.png"))` gives the thermal strip its own black version.
191
+ - **No logo**: leave `logo` out and the issuer's box takes the space.
192
+
193
+ On the A4 page the logo goes above the issuer's box, in colour. On the strip it goes on top, in one ink.
194
+
195
+ ### Labels
196
+
197
+ `default_labels()` holds the Spanish words the SRI prints. To change them, copy `src/eqinvoice/labels/es.json`, edit the values and pass `labels=read_labels(Path("labels.json"))` to `render`. The keys stay as they are.
198
+
199
+ ---
200
+
201
+ ## Claude Code plugin
202
+
203
+ ```
204
+ /plugin install eqinvoice --marketplace jahirxtrap/eqinvoice
205
+ ```
206
+
207
+ Or add the marketplace once and install it from there:
208
+
209
+ ```bash
210
+ claude plugin marketplace add jahirxtrap/eqinvoice
211
+ claude plugin install eqinvoice@eqinvoice
212
+ ```
213
+
214
+ On enabling it, Claude Code asks for the book folder, the .p12 signature and its password; the password goes to the system's secure storage. The plugin starts the [MCP server](#mcp-server) with `uvx` from PyPI (it needs [uv](https://docs.astral.sh/uv/)): the first start installs it, later ones take a couple of seconds. Its tools show up as `mcp__plugin_eqinvoice_eqinvoice__*`.
215
+
216
+ ## MCP server
217
+
218
+ `eqinvoice-mcp` serves one issuer over stdio. It keeps everything in a folder, the book, and signs with the issuer's .p12. Without the plugin, register it by hand:
219
+
220
+ ```bash
221
+ claude mcp add --scope user eqinvoice \
222
+ -e EQINVOICE_BOOK=/path/to/book \
223
+ -e EQINVOICE_P12=/path/to/signature.p12 \
224
+ -e EQINVOICE_P12_PASSWORD=... \
225
+ -- uvx --from "eqinvoice[mcp]" eqinvoice-mcp
226
+ ```
227
+
228
+ - **Issuer**: `configure_issuer` takes a RUC, the environment (`test` or `production`), the establishment and the emission point. The legal name, the address and the accounting duty come from the SRI's registry.
229
+ - **Clients**: `register_client` keeps a buyer and its email. A RUC is named as the registry holds it; a cédula or a passport brings its name.
230
+ - **Drafts first**: every voucher starts as a draft with the voucher it would become and a printed preview (a PDF). Nothing reaches the SRI until the draft is issued.
231
+ - **Issuing**: `issue_draft` numbers the draft, signs it, sends it and files it as the SRI leaves it: `authorized` (with its record and its printed copy), `refused` (with the SRI's messages) or `pending`, which `check_voucher` asks for again.
232
+ - **Numbering**: each environment, kind, establishment and point numbers its own series, and a number is taken for good once a voucher is signed with it.
233
+
234
+ <details>
235
+ <summary><b>Tools</b></summary>
236
+
237
+ | Tool | What it does |
238
+ |---|---|
239
+ | `look_up_taxpayer` | A RUC as the SRI's registry holds it |
240
+ | `configure_issuer` | Who issues, from which establishment and point, in which environment |
241
+ | `show_issuer` | The issuer as it is configured |
242
+ | `register_client`, `list_clients`, `forget_client` | The clients in the book |
243
+ | `draft_invoice` | An invoice for a client, with its printed preview |
244
+ | `draft_credit_note` | A credit note against an authorized invoice, whole or by lines |
245
+ | `list_drafts`, `discard_draft` | The drafts waiting to be issued |
246
+ | `issue_draft` | The approved draft numbered, signed and sent to the SRI |
247
+ | `list_vouchers` | Every voucher numbered in the current environment |
248
+ | `check_voucher` | A pending voucher sent again and asked for |
249
+
250
+ </details>
251
+
252
+ <details>
253
+ <summary><b>The book</b></summary>
254
+
255
+ | Path | What it holds |
256
+ |---|---|
257
+ | `profile.json` | The issuer, its establishment and point, and the environment |
258
+ | `clients/<identification>.json` | One client each |
259
+ | `drafts/<id>.json`, `drafts/<id>.pdf` | A draft and its printed preview |
260
+ | `vouchers/<environment>/<kind>/<number>/` | `signed.xml`, then `authorized.xml` and `printed.pdf`, or `refused.json` |
261
+ | `brand/` | The logo of the printed copies: `mark.svg`, `name.svg` and a `.ttf`, or `logo.png`. An optional `lockup.json` (`{"name_size": 0.76}`) sizes the name or the gap for this brand |
262
+
263
+ </details>
264
+
265
+ <details>
266
+ <summary><b>Environment variables</b></summary>
267
+
268
+ | Variable | What it does |
269
+ |---|---|
270
+ | `EQINVOICE_BOOK` | The folder the server keeps the book in |
271
+ | `EQINVOICE_P12` | The issuer's .p12 signature |
272
+ | `EQINVOICE_P12_PASSWORD` | Its password |
273
+
274
+ </details>
275
+
276
+ ---
277
+
278
+ ## Development
279
+
280
+ ```bash
281
+ python3.11 -m venv .venv
282
+ .venv/bin/pip install -e ".[dev]"
283
+ .venv/bin/ruff check src tests scripts
284
+ .venv/bin/mypy
285
+ .venv/bin/pytest
286
+ ```
287
+
288
+ Two scripts drive the live services:
289
+
290
+ ```bash
291
+ EQINVOICE_P12=signature.p12 EQINVOICE_P12_PASSWORD=... \
292
+ .venv/bin/python scripts/smoke_sri.py setup.json out
293
+ .venv/bin/python scripts/smoke_registry.py 1791234567001
294
+ ```
295
+
296
+ - **`smoke_sri.py`**: a real round trip against the SRI's test environment. It signs a $1.00 invoice to a final consumer, sends it, waits for the authorization, reads it back and prints both layouts. `setup.json` holds `issuer` (the `Issuer` fields), `establishment`, `point` and `establishment_address`; use an emission point no other system numbers with.
297
+ - **`smoke_registry.py`**: a RUC looked up in the SRI's live registry, with its establishments and the issuer it would be.
298
+
299
+ A tag (`1.2.0`) publishes that version to PyPI and opens its release with `CHANGELOG.md`. The version lives in `pyproject.toml` and, the same, in the plugin's manifest, its marketplace entry and the package its `.mcp.json` launches; `tests/test_plugin.py` keeps them in agreement.
300
+
301
+ ## License
302
+
303
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,266 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/jahirxtrap/eqinvoice/main/.github/assets/banner.png" alt="EQinvoice" width="760">
3
+ </p>
4
+
5
+ Electronic invoices and credit notes for **Ecuador's SRI** (offline scheme 1.1.0): build the
6
+ voucher, sign it XAdES-BES with the issuer's .p12, send it, read the authorization back and print
7
+ the RIDE. Use it as a **library** inside your own system, or as an **MCP server** that lets an
8
+ assistant draft, preview, issue and follow one issuer's vouchers.
9
+
10
+ <p align="center">
11
+ <a href="https://pypi.org/project/eqinvoice/">PyPI</a> ·
12
+ <a href="#library">Library</a> ·
13
+ <a href="#claude-code-plugin">Claude Code plugin</a> ·
14
+ <a href="#mcp-server">MCP server</a>
15
+ </p>
16
+
17
+ ```
18
+ [your system] ──values──> [eqinvoice] ──signed XML──> [SRI reception] ──> [SRI authorization]
19
+ [assistant] ──MCP────> [eqinvoice-mcp] ──> the book (clients, drafts, vouchers)
20
+ ```
21
+
22
+ ---
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ pip install eqinvoice # the library
28
+ pip install "eqinvoice[mcp]" # with the MCP server
29
+ ```
30
+
31
+ Python 3.11 or newer. In Claude Code, the [plugin](#claude-code-plugin) installs everything in one step.
32
+
33
+ ## Capabilities
34
+
35
+ - **Vouchers**: invoices (factura) and credit notes (nota de crédito), checked against the SRI's own XSD before they leave.
36
+ - **Access key**: the 49-digit clave de acceso with its modulo 11 check digit, and the issue date on the SRI's clock.
37
+ - **Signature**: XAdES-BES with the issuer's .p12 and its password, refused with a reason when it is missing, unreadable or out of date.
38
+ - **SRI web services**: reception and authorization, in the test environment (celcer) or in production (cel).
39
+ - **Taxpayer registry**: a RUC looked up in the SRI's public registry (legal name, standing, regime, accounting duty, establishments), and the issuer its vouchers name.
40
+ - **Priced lines**: a line's subtotal and IVA worked out to the cent from its quantity, unit price, discount and the SRI's rate code.
41
+ - **Printed copy (RIDE)**: an A4 page or an 80 mm strip, with the SRI's full block of totals and the access key as a barcode (page) or a QR code (strip).
42
+ - **Logo**: a picture, or a mark and a name drawn as vectors, set beside each other as a lockup.
43
+ - **Labels**: the printed words are data. Spanish by default, as the SRI prints them; any file of the same shape replaces them.
44
+ - **Simulation**: a simulated signature, SRI and registry that answer on the spot, for development and tests.
45
+
46
+ ---
47
+
48
+ ## Library
49
+
50
+ ```python
51
+ from datetime import UTC, datetime
52
+ from decimal import Decimal
53
+ from pathlib import Path
54
+
55
+ from eqinvoice import (
56
+ Buyer,
57
+ BuyerKind,
58
+ Environment,
59
+ Issuer,
60
+ Payment,
61
+ SriWebServices,
62
+ Voucher,
63
+ VoucherKind,
64
+ access_key,
65
+ issue_date,
66
+ price_line,
67
+ read,
68
+ read_credential,
69
+ sign_voucher,
70
+ )
71
+ from eqinvoice.ride import ImageLogo, Layout, render
72
+
73
+ issuer = Issuer(
74
+ legal_name="ACME COMERCIAL S.A.S.",
75
+ ruc="1790011223001",
76
+ head_office_address="AV. AMAZONAS N10-20, QUITO",
77
+ keeps_accounts=True,
78
+ )
79
+ credential = read_credential(Path("signature.p12"), "p12 password")
80
+ now = datetime.now(UTC)
81
+ issued_on = issue_date(now)
82
+ line = price_line("SKU-1", "Cafe molido (500 g)", Decimal("1"), Decimal("10.00"), "4")
83
+ voucher = Voucher(
84
+ kind=VoucherKind.INVOICE,
85
+ environment=Environment.TEST,
86
+ access_key=access_key(
87
+ issued_on, VoucherKind.INVOICE, issuer.ruc, Environment.TEST, "001", "001", 1
88
+ ),
89
+ establishment="001",
90
+ point="001",
91
+ sequential=1,
92
+ establishment_address="AV. AMAZONAS N10-20, QUITO",
93
+ issued_on=issued_on,
94
+ buyer=Buyer(BuyerKind.CEDULA, "1710034065", "Ana Perez", "ana@example.com"),
95
+ lines=(line,),
96
+ payments=(Payment("01", Decimal("11.50")),),
97
+ )
98
+
99
+ document = sign_voucher(voucher, issuer, credential, now)
100
+ sri = SriWebServices(Environment.TEST)
101
+ reception = sri.send(document)
102
+ answer = sri.authorize(voucher.access_key)
103
+
104
+ if answer is not None and answer.authorized:
105
+ issued = read(answer.record)
106
+ page = render(issued, Layout.PAGE, logo=ImageLogo(Path("logo.png")))
107
+ strip = render(issued, Layout.TICKET, logo=ImageLogo(Path("logo.png")))
108
+ ```
109
+
110
+ - **What to keep**: `answer.record` is the authorized XML, the voucher with tax validity. Store it: `read(record)` gives the voucher back and `render` prints it whenever it is needed.
111
+ - **Still processing**: `authorize` gives `None` while the SRI has not answered yet; ask again later with the same access key.
112
+ - **Sent twice**: `reception.already_there` is true when the SRI already holds that access key, so its answer waits at the authorization service.
113
+ - **Numbering**: the establishment, the emission point and the sequential belong to the caller's system. A sequential is never reused, and a voucher sent again keeps its access key.
114
+ - **Signed documents**: `read_voucher(document)` gives back the voucher a signed or unsigned document carries.
115
+
116
+ <details>
117
+ <summary><b>Amounts and codes</b></summary>
118
+
119
+ - **Lines**: `subtotal` is the line before IVA (quantity times unit price minus discount) and `vat` its tax, both to two decimals. `price_line(code, description, quantity, unit_price, rate_code, discount)` works them out, rounding halves up. The voucher adds them up per rate.
120
+ - **IVA rate codes** (`rate_code`): the SRI's table, for example `"4"` for 15 %, `"0"` for 0 %, `"6"` not subject to IVA, `"7"` exempt.
121
+ - **Payment forms** (`Payment.form`): the SRI's table, for example `"01"` without the financial system (cash), `"16"` debit card, `"19"` credit card, `"20"` other with the financial system (transfer). Payments of the same form are added up.
122
+ - **Credit notes**: `modified=Modified(number, issued_on, reason)` names the invoice they correct; credit notes carry no payments.
123
+ - **Final consumer**: `Buyer(BuyerKind.FINAL_CONSUMER)`. The voucher writes the SRI's own name and number for it.
124
+
125
+ </details>
126
+
127
+ <details>
128
+ <summary><b>When something fails</b></summary>
129
+
130
+ - **`SriUnavailable`**: the SRI could not be reached or failed on its side. Nothing is lost: send the same document again later.
131
+ - **`VoucherInvalid`**: the voucher breaks the SRI's schema (the message names every reason) or the record holds no voucher. Fix the data; sending it would only be returned.
132
+ - **`CredentialRefused`**: the signature cannot sign. `problem` says why: `missing`, `unreadable` (wrong password or not a .p12) or `out_of_date`.
133
+ - **Turned down**: `answer.authorized` is false and `answer.messages` carry the SRI's codes and texts, as it wrote them.
134
+
135
+ </details>
136
+
137
+ ### Taxpayer registry
138
+
139
+ ```python
140
+ from eqinvoice import SriRegistry
141
+
142
+ taxpayer = SriRegistry().find("1791234567001")
143
+ if taxpayer is not None and taxpayer.active:
144
+ issuer = taxpayer.issuer(trade_name="Acme Shop")
145
+ ```
146
+
147
+ - **Nobody holds it**: `find` gives `None`. A registry that cannot be reached raises `SriUnavailable`.
148
+ - **The issuer**: `taxpayer.issuer()` names the head office address the registry lists. A withholding agent or a special taxpayer passes its resolution or number (`withholding_agent=`, `special_taxpayer=`), which the registry does not publish.
149
+
150
+ ### Logo
151
+
152
+ - **A mark and a name**: `MarkLogo(mark=Path("mark.svg"), name=Path("name.svg"), font=Path("Brand.ttf"))`. The mark is an SVG of rectangles and straight paths; the name is an SVG `<text>` whose runs carry their colours (a `<tspan>` for a second colour), set in the font given. They sit as a lockup: for a mark 28 high the name is 17 and the gap 8, the proportion `name_size` and `gap` change, and the name's own glyphs (ascenders and descenders included) are centred on the mark.
153
+ - **A picture**: `ImageLogo(Path("logo.png"))`, drawn at the logo's height keeping its proportions. `ImageLogo(path, one_ink_path=Path("logo-black.png"))` gives the thermal strip its own black version.
154
+ - **No logo**: leave `logo` out and the issuer's box takes the space.
155
+
156
+ On the A4 page the logo goes above the issuer's box, in colour. On the strip it goes on top, in one ink.
157
+
158
+ ### Labels
159
+
160
+ `default_labels()` holds the Spanish words the SRI prints. To change them, copy `src/eqinvoice/labels/es.json`, edit the values and pass `labels=read_labels(Path("labels.json"))` to `render`. The keys stay as they are.
161
+
162
+ ---
163
+
164
+ ## Claude Code plugin
165
+
166
+ ```
167
+ /plugin install eqinvoice --marketplace jahirxtrap/eqinvoice
168
+ ```
169
+
170
+ Or add the marketplace once and install it from there:
171
+
172
+ ```bash
173
+ claude plugin marketplace add jahirxtrap/eqinvoice
174
+ claude plugin install eqinvoice@eqinvoice
175
+ ```
176
+
177
+ On enabling it, Claude Code asks for the book folder, the .p12 signature and its password; the password goes to the system's secure storage. The plugin starts the [MCP server](#mcp-server) with `uvx` from PyPI (it needs [uv](https://docs.astral.sh/uv/)): the first start installs it, later ones take a couple of seconds. Its tools show up as `mcp__plugin_eqinvoice_eqinvoice__*`.
178
+
179
+ ## MCP server
180
+
181
+ `eqinvoice-mcp` serves one issuer over stdio. It keeps everything in a folder, the book, and signs with the issuer's .p12. Without the plugin, register it by hand:
182
+
183
+ ```bash
184
+ claude mcp add --scope user eqinvoice \
185
+ -e EQINVOICE_BOOK=/path/to/book \
186
+ -e EQINVOICE_P12=/path/to/signature.p12 \
187
+ -e EQINVOICE_P12_PASSWORD=... \
188
+ -- uvx --from "eqinvoice[mcp]" eqinvoice-mcp
189
+ ```
190
+
191
+ - **Issuer**: `configure_issuer` takes a RUC, the environment (`test` or `production`), the establishment and the emission point. The legal name, the address and the accounting duty come from the SRI's registry.
192
+ - **Clients**: `register_client` keeps a buyer and its email. A RUC is named as the registry holds it; a cédula or a passport brings its name.
193
+ - **Drafts first**: every voucher starts as a draft with the voucher it would become and a printed preview (a PDF). Nothing reaches the SRI until the draft is issued.
194
+ - **Issuing**: `issue_draft` numbers the draft, signs it, sends it and files it as the SRI leaves it: `authorized` (with its record and its printed copy), `refused` (with the SRI's messages) or `pending`, which `check_voucher` asks for again.
195
+ - **Numbering**: each environment, kind, establishment and point numbers its own series, and a number is taken for good once a voucher is signed with it.
196
+
197
+ <details>
198
+ <summary><b>Tools</b></summary>
199
+
200
+ | Tool | What it does |
201
+ |---|---|
202
+ | `look_up_taxpayer` | A RUC as the SRI's registry holds it |
203
+ | `configure_issuer` | Who issues, from which establishment and point, in which environment |
204
+ | `show_issuer` | The issuer as it is configured |
205
+ | `register_client`, `list_clients`, `forget_client` | The clients in the book |
206
+ | `draft_invoice` | An invoice for a client, with its printed preview |
207
+ | `draft_credit_note` | A credit note against an authorized invoice, whole or by lines |
208
+ | `list_drafts`, `discard_draft` | The drafts waiting to be issued |
209
+ | `issue_draft` | The approved draft numbered, signed and sent to the SRI |
210
+ | `list_vouchers` | Every voucher numbered in the current environment |
211
+ | `check_voucher` | A pending voucher sent again and asked for |
212
+
213
+ </details>
214
+
215
+ <details>
216
+ <summary><b>The book</b></summary>
217
+
218
+ | Path | What it holds |
219
+ |---|---|
220
+ | `profile.json` | The issuer, its establishment and point, and the environment |
221
+ | `clients/<identification>.json` | One client each |
222
+ | `drafts/<id>.json`, `drafts/<id>.pdf` | A draft and its printed preview |
223
+ | `vouchers/<environment>/<kind>/<number>/` | `signed.xml`, then `authorized.xml` and `printed.pdf`, or `refused.json` |
224
+ | `brand/` | The logo of the printed copies: `mark.svg`, `name.svg` and a `.ttf`, or `logo.png`. An optional `lockup.json` (`{"name_size": 0.76}`) sizes the name or the gap for this brand |
225
+
226
+ </details>
227
+
228
+ <details>
229
+ <summary><b>Environment variables</b></summary>
230
+
231
+ | Variable | What it does |
232
+ |---|---|
233
+ | `EQINVOICE_BOOK` | The folder the server keeps the book in |
234
+ | `EQINVOICE_P12` | The issuer's .p12 signature |
235
+ | `EQINVOICE_P12_PASSWORD` | Its password |
236
+
237
+ </details>
238
+
239
+ ---
240
+
241
+ ## Development
242
+
243
+ ```bash
244
+ python3.11 -m venv .venv
245
+ .venv/bin/pip install -e ".[dev]"
246
+ .venv/bin/ruff check src tests scripts
247
+ .venv/bin/mypy
248
+ .venv/bin/pytest
249
+ ```
250
+
251
+ Two scripts drive the live services:
252
+
253
+ ```bash
254
+ EQINVOICE_P12=signature.p12 EQINVOICE_P12_PASSWORD=... \
255
+ .venv/bin/python scripts/smoke_sri.py setup.json out
256
+ .venv/bin/python scripts/smoke_registry.py 1791234567001
257
+ ```
258
+
259
+ - **`smoke_sri.py`**: a real round trip against the SRI's test environment. It signs a $1.00 invoice to a final consumer, sends it, waits for the authorization, reads it back and prints both layouts. `setup.json` holds `issuer` (the `Issuer` fields), `establishment`, `point` and `establishment_address`; use an emission point no other system numbers with.
260
+ - **`smoke_registry.py`**: a RUC looked up in the SRI's live registry, with its establishments and the issuer it would be.
261
+
262
+ A tag (`1.2.0`) publishes that version to PyPI and opens its release with `CHANGELOG.md`. The version lives in `pyproject.toml` and, the same, in the plugin's manifest, its marketplace entry and the package its `.mcp.json` launches; `tests/test_plugin.py` keeps them in agreement.
263
+
264
+ ## License
265
+
266
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,85 @@
1
+ [project]
2
+ name = "eqinvoice"
3
+ version = "1.1.0"
4
+ description = "Electronic invoices and credit notes for Ecuador's SRI: build, sign, send, authorize and print, as a library or an MCP server."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "jahirtrap" }]
10
+ keywords = ["ecuador", "sri", "electronic invoice", "factura electronica", "xades", "mcp"]
11
+ classifiers = [
12
+ "Development Status :: 5 - Production/Stable",
13
+ "Intended Audience :: Developers",
14
+ "Natural Language :: Spanish",
15
+ "Operating System :: OS Independent",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Topic :: Office/Business :: Financial :: Accounting",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = [
24
+ "cryptography>=50.0",
25
+ "fonttools>=4.60",
26
+ "httpx>=0.28",
27
+ "lxml>=6.1",
28
+ "reportlab>=5.1",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ mcp = [
33
+ "mcp>=2.3",
34
+ ]
35
+ dev = [
36
+ "mcp>=2.3",
37
+ "mypy>=2.4",
38
+ "pytest>=9.1",
39
+ "ruff>=0.17",
40
+ ]
41
+
42
+ [project.urls]
43
+ Homepage = "https://github.com/jahirxtrap/eqinvoice"
44
+ Repository = "https://github.com/jahirxtrap/eqinvoice"
45
+ Issues = "https://github.com/jahirxtrap/eqinvoice/issues"
46
+
47
+ [project.scripts]
48
+ eqinvoice-mcp = "eqinvoice_mcp.server:main"
49
+
50
+ [build-system]
51
+ requires = ["setuptools>=80.0"]
52
+ build-backend = "setuptools.build_meta"
53
+
54
+ [tool.setuptools.packages.find]
55
+ where = ["src"]
56
+
57
+ [tool.setuptools.package-data]
58
+ eqinvoice = ["py.typed", "schemas/*.xsd", "labels/*.json"]
59
+ eqinvoice_mcp = ["icon.svg"]
60
+
61
+ [tool.pytest.ini_options]
62
+ testpaths = ["tests"]
63
+ addopts = "--strict-markers -q"
64
+
65
+ [tool.mypy]
66
+ python_version = "3.11"
67
+ files = ["src", "tests", "scripts"]
68
+ ignore_missing_imports = true
69
+ no_implicit_optional = true
70
+ warn_redundant_casts = true
71
+ warn_unused_ignores = true
72
+ warn_unreachable = true
73
+ check_untyped_defs = true
74
+ disallow_untyped_defs = true
75
+
76
+ [[tool.mypy.overrides]]
77
+ module = ["tests.*"]
78
+ disallow_untyped_defs = false
79
+
80
+ [tool.ruff]
81
+ line-length = 100
82
+ target-version = "py311"
83
+
84
+ [tool.ruff.lint]
85
+ select = ["E", "F", "I", "UP", "B"]