contabridge-mcp 0.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.
- contabridge_mcp-0.1.0/LICENSE +70 -0
- contabridge_mcp-0.1.0/PKG-INFO +90 -0
- contabridge_mcp-0.1.0/README.md +71 -0
- contabridge_mcp-0.1.0/pyproject.toml +36 -0
- contabridge_mcp-0.1.0/setup.cfg +4 -0
- contabridge_mcp-0.1.0/src/contabridge_mcp/__init__.py +3 -0
- contabridge_mcp-0.1.0/src/contabridge_mcp/__main__.py +3 -0
- contabridge_mcp-0.1.0/src/contabridge_mcp/bank.py +151 -0
- contabridge_mcp-0.1.0/src/contabridge_mcp/billing.py +167 -0
- contabridge_mcp-0.1.0/src/contabridge_mcp/formats.py +460 -0
- contabridge_mcp-0.1.0/src/contabridge_mcp/ids.py +236 -0
- contabridge_mcp-0.1.0/src/contabridge_mcp/journal.py +243 -0
- contabridge_mcp-0.1.0/src/contabridge_mcp/n43.py +510 -0
- contabridge_mcp-0.1.0/src/contabridge_mcp/server.py +315 -0
- contabridge_mcp-0.1.0/src/contabridge_mcp.egg-info/PKG-INFO +90 -0
- contabridge_mcp-0.1.0/src/contabridge_mcp.egg-info/SOURCES.txt +22 -0
- contabridge_mcp-0.1.0/src/contabridge_mcp.egg-info/dependency_links.txt +1 -0
- contabridge_mcp-0.1.0/src/contabridge_mcp.egg-info/entry_points.txt +2 -0
- contabridge_mcp-0.1.0/src/contabridge_mcp.egg-info/requires.txt +6 -0
- contabridge_mcp-0.1.0/src/contabridge_mcp.egg-info/top_level.txt +1 -0
- contabridge_mcp-0.1.0/tests/test_core.py +226 -0
- contabridge_mcp-0.1.0/tests/test_defaults.py +64 -0
- contabridge_mcp-0.1.0/tests/test_e2e.py +170 -0
- contabridge_mcp-0.1.0/tests/test_limits.py +42 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved.
|
|
2
|
+
"Business Source License" is a trademark of MariaDB Corporation Ab.
|
|
3
|
+
|
|
4
|
+
Parameters
|
|
5
|
+
|
|
6
|
+
Licensor: ArceeDev (KeyFlint, https://keyflint.dev, legal@keyflint.dev)
|
|
7
|
+
Licensed Work: ContaBridge MCP (contabridge-mcp), version 0.1.0 or later.
|
|
8
|
+
The Licensed Work is (c) 2026 ArceeDev.
|
|
9
|
+
Additional Use Grant: You may make production use of the Licensed Work:
|
|
10
|
+
(a) with a valid KeyFlint license for the Licensed Work
|
|
11
|
+
obtained from the Licensor (https://mcp.keyflint.dev),
|
|
12
|
+
with tool calls metered through the Licensor's
|
|
13
|
+
KeyFlint gateway; or
|
|
14
|
+
(b) for personal, educational, research or evaluation
|
|
15
|
+
purposes that are not commercial.
|
|
16
|
+
This grant does not cover removing, disabling or
|
|
17
|
+
bypassing the license verification or credit metering,
|
|
18
|
+
nor offering the Licensed Work, or a modified version of
|
|
19
|
+
it, to third parties on a hosted, embedded or resold
|
|
20
|
+
basis.
|
|
21
|
+
Change Date: 2030-11-05
|
|
22
|
+
Change License: Apache License, Version 2.0
|
|
23
|
+
|
|
24
|
+
For information about alternative licensing arrangements for the Licensed Work,
|
|
25
|
+
please contact the Licensor at legal@keyflint.dev or https://keyflint.dev.
|
|
26
|
+
|
|
27
|
+
Notice
|
|
28
|
+
|
|
29
|
+
Business Source License 1.1
|
|
30
|
+
|
|
31
|
+
Terms
|
|
32
|
+
|
|
33
|
+
The Licensor hereby grants you the right to copy, modify, create derivative
|
|
34
|
+
works, redistribute, and make non-production use of the Licensed Work. The
|
|
35
|
+
Licensor may make an Additional Use Grant, above, permitting limited production use.
|
|
36
|
+
|
|
37
|
+
Effective on the Change Date, or the fourth anniversary of the first publicly
|
|
38
|
+
available distribution of a specific version of the Licensed Work under this
|
|
39
|
+
License, whichever comes first, the Licensor hereby grants you rights under
|
|
40
|
+
the terms of the Change License, and the rights granted in the paragraph
|
|
41
|
+
above terminate.
|
|
42
|
+
|
|
43
|
+
If your use of the Licensed Work does not comply with the requirements
|
|
44
|
+
currently in effect as described in this License, you must purchase a
|
|
45
|
+
commercial license from the Licensor, its affiliated entities, or authorized
|
|
46
|
+
resellers, or you must refrain from using the Licensed Work.
|
|
47
|
+
|
|
48
|
+
All copies of the original and modified Licensed Work, and derivative works
|
|
49
|
+
of the Licensed Work, are subject to this License. This License applies
|
|
50
|
+
separately for each version of the Licensed Work and the Change Date may vary
|
|
51
|
+
for each version of the Licensed Work released by Licensor.
|
|
52
|
+
|
|
53
|
+
You must conspicuously display this License on each original or modified copy
|
|
54
|
+
of the Licensed Work. If you receive the Licensed Work in original or
|
|
55
|
+
modified form from a third party, the terms and conditions set forth in this
|
|
56
|
+
License apply to your use of that work.
|
|
57
|
+
|
|
58
|
+
Any use of the Licensed Work in violation of this License will automatically
|
|
59
|
+
terminate your rights under this License for the current and all other
|
|
60
|
+
versions of the Licensed Work.
|
|
61
|
+
|
|
62
|
+
This License does not grant you any right in any trademark or logo of
|
|
63
|
+
Licensor or its affiliates (provided that you may use a trademark or logo of
|
|
64
|
+
Licensor as expressly required by this License).
|
|
65
|
+
|
|
66
|
+
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
|
|
67
|
+
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
|
|
68
|
+
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
|
|
69
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
|
|
70
|
+
TITLE.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: contabridge-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for Spanish accounting import files: convert journals between A3 (SUENLACE.DAT), ContaPlus, Sage 50 and CSV, and turn Norma 43 bank statements into journal entries. Billed per call with KeyFlint credits.
|
|
5
|
+
Author: ArceeDev
|
|
6
|
+
License-Expression: BUSL-1.1
|
|
7
|
+
Project-URL: Homepage, https://github.com/ArceeDev/contabridge-mcp
|
|
8
|
+
Project-URL: Source, https://github.com/ArceeDev/contabridge-mcp
|
|
9
|
+
Keywords: mcp,contabilidad,a3,a3asesor,suenlace,contaplus,sage,libro-diario,asientos,norma43,gestoria,spain
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: mcp>=2.0
|
|
14
|
+
Requires-Dist: keyflint-verify[fast]>=1.2.0
|
|
15
|
+
Provides-Extra: test
|
|
16
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
17
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "test"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# contabridge-mcp
|
|
21
|
+
|
|
22
|
+
An MCP server that moves bookkeeping between Spanish accounting programs, built for AI agents that work for gestorías and accounting firms with many clients. It does not store any data between calls, and every result is deterministic.
|
|
23
|
+
|
|
24
|
+
| Tool | Credits | What it does |
|
|
25
|
+
| --- | --- | --- |
|
|
26
|
+
| `convert_journal` | 2 | Converts a libro diario between **a3ASESOR/a3ECO/a3CON** (`SUENLACE.DAT`), **Sage ContaPlus / Sage 50** (Diario CSV of 142 fields, or XDiario ASCII) and **spreadsheet CSV** (Holded, Excel, any ERP). It resizes subaccounts (`4300001` → `43000001`, `430.1` → `43000001`), renames accounts and renumbers entries. |
|
|
27
|
+
| `bank_to_journal` | 3 | Turns a **Norma 43** bank statement into journal entries against the 572 subaccount. Ordered rules map movements to counterpart accounts (by text, regex, AEB concept or direction), and movements that match no rule go to 555 and are listed. |
|
|
28
|
+
| `build_journal` | 2 | Writes entries the agent composed (invoices, payroll, accruals) in any of the formats. |
|
|
29
|
+
| `validate_journal` | 1 | Runs the checks A3 and ContaPlus run on import: balanced entries, debit or credit on each line (never both), PGC groups, fiscal year, mixed account lengths. |
|
|
30
|
+
| `resize_account`, `detect_format`, `pricing`, `credits_balance` | 0 | Free helpers. |
|
|
31
|
+
|
|
32
|
+
Output files are returned as `content` and as `content_base64`. The base64 version is Windows-1252 with CRLF line endings (UTF-8 with BOM for CSV), which is what these programs import.
|
|
33
|
+
|
|
34
|
+
Charging works like this:
|
|
35
|
+
- A file that would be rejected (unbalanced, unreadable, missing columns) is refused **before** any credits are charged.
|
|
36
|
+
- A failure after charging is refunded.
|
|
37
|
+
|
|
38
|
+
## Formats and sources
|
|
39
|
+
|
|
40
|
+
- **A3**: "Enlace contable de entrada. Descripción de registros" (Wolters Kluwer). Record type 0, format 5 (512 bytes) and format 4 (256 bytes). When reading, invoice records (types 1, 2 and 9) are skipped and reported in a warning.
|
|
41
|
+
- **ContaPlus**: "Protocolo de comunicación de programas de gestión con ContaPlus" (Sage, 2023). Writes Asien, Fecha, SubCta, Contra, Concepto, Documento, MonedaUso=2, EuroDebe and EuroHaber. The fixed-width ASCII variant (32 fields) uses the same layout as the open-source BulmaGés exporter.
|
|
42
|
+
- **CSV**: columns are detected in Spanish and English (`fecha`, `cuenta`, `debe`, `haber`, `importe`+`d/h`…), or set with `column_map`. A file without an entry number is grouped into entries by accumulating lines until they balance.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
All you need is your KeyFlint license (`LIC-...`). The gateway URL, its public key and the checkout link have defaults.
|
|
47
|
+
|
|
48
|
+
**Claude Desktop, one click:** download `contabridge-mcp-X.Y.Z.mcpb` from the [latest release](https://github.com/ArceeDev/contabridge-mcp/releases/latest), double-click it and paste your license. No terminal, no JSON.
|
|
49
|
+
|
|
50
|
+
**Claude Code:**
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
claude mcp add contabridge -e KEYFLINT_LICENSE=LIC-... -- uvx contabridge-mcp
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Cursor, Windsurf, Claude Desktop (JSON):**
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"mcpServers": {
|
|
61
|
+
"contabridge": {
|
|
62
|
+
"command": "uvx",
|
|
63
|
+
"args": ["contabridge-mcp"],
|
|
64
|
+
"env": { "KEYFLINT_LICENSE": "LIC-..." }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Optional: `KEYFLINT_URL`, `KEYFLINT_PUBLIC_KEYS` (fetched from the gateway and cached in `~/.keyflint/keyring.json` when unset), `KEYFLINT_CHECKOUT_URL`.
|
|
71
|
+
|
|
72
|
+
Hosted mode: `CONTABRIDGE_TRANSPORT=http contabridge-mcp --http` (license per request, `Authorization: Bearer LIC-...`).
|
|
73
|
+
|
|
74
|
+
Tuning: `CONTABRIDGE_PRICES` (`convert_journal=3,bank_to_journal=5`), `CONTABRIDGE_MAX_BYTES`.
|
|
75
|
+
|
|
76
|
+
## Claude skill
|
|
77
|
+
|
|
78
|
+
`skills/contabridge/SKILL.md` (zipped in `distribution/contabridge-skill.zip`) tells the agent which tool to use, which account length to ask for, and how to save the file.
|
|
79
|
+
|
|
80
|
+
## Tests
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pip install -e '.[test]' && pytest
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The tests use a fake KeyFlint gateway and locally signed licenses, so they need no network. A3 and ContaPlus layouts are checked position by position against the official specifications.
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
Business Source License 1.1. Source available; production use needs a KeyFlint license (or must be non-commercial). Each version becomes Apache 2.0 four years after release. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# contabridge-mcp
|
|
2
|
+
|
|
3
|
+
An MCP server that moves bookkeeping between Spanish accounting programs, built for AI agents that work for gestorías and accounting firms with many clients. It does not store any data between calls, and every result is deterministic.
|
|
4
|
+
|
|
5
|
+
| Tool | Credits | What it does |
|
|
6
|
+
| --- | --- | --- |
|
|
7
|
+
| `convert_journal` | 2 | Converts a libro diario between **a3ASESOR/a3ECO/a3CON** (`SUENLACE.DAT`), **Sage ContaPlus / Sage 50** (Diario CSV of 142 fields, or XDiario ASCII) and **spreadsheet CSV** (Holded, Excel, any ERP). It resizes subaccounts (`4300001` → `43000001`, `430.1` → `43000001`), renames accounts and renumbers entries. |
|
|
8
|
+
| `bank_to_journal` | 3 | Turns a **Norma 43** bank statement into journal entries against the 572 subaccount. Ordered rules map movements to counterpart accounts (by text, regex, AEB concept or direction), and movements that match no rule go to 555 and are listed. |
|
|
9
|
+
| `build_journal` | 2 | Writes entries the agent composed (invoices, payroll, accruals) in any of the formats. |
|
|
10
|
+
| `validate_journal` | 1 | Runs the checks A3 and ContaPlus run on import: balanced entries, debit or credit on each line (never both), PGC groups, fiscal year, mixed account lengths. |
|
|
11
|
+
| `resize_account`, `detect_format`, `pricing`, `credits_balance` | 0 | Free helpers. |
|
|
12
|
+
|
|
13
|
+
Output files are returned as `content` and as `content_base64`. The base64 version is Windows-1252 with CRLF line endings (UTF-8 with BOM for CSV), which is what these programs import.
|
|
14
|
+
|
|
15
|
+
Charging works like this:
|
|
16
|
+
- A file that would be rejected (unbalanced, unreadable, missing columns) is refused **before** any credits are charged.
|
|
17
|
+
- A failure after charging is refunded.
|
|
18
|
+
|
|
19
|
+
## Formats and sources
|
|
20
|
+
|
|
21
|
+
- **A3**: "Enlace contable de entrada. Descripción de registros" (Wolters Kluwer). Record type 0, format 5 (512 bytes) and format 4 (256 bytes). When reading, invoice records (types 1, 2 and 9) are skipped and reported in a warning.
|
|
22
|
+
- **ContaPlus**: "Protocolo de comunicación de programas de gestión con ContaPlus" (Sage, 2023). Writes Asien, Fecha, SubCta, Contra, Concepto, Documento, MonedaUso=2, EuroDebe and EuroHaber. The fixed-width ASCII variant (32 fields) uses the same layout as the open-source BulmaGés exporter.
|
|
23
|
+
- **CSV**: columns are detected in Spanish and English (`fecha`, `cuenta`, `debe`, `haber`, `importe`+`d/h`…), or set with `column_map`. A file without an entry number is grouped into entries by accumulating lines until they balance.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
All you need is your KeyFlint license (`LIC-...`). The gateway URL, its public key and the checkout link have defaults.
|
|
28
|
+
|
|
29
|
+
**Claude Desktop, one click:** download `contabridge-mcp-X.Y.Z.mcpb` from the [latest release](https://github.com/ArceeDev/contabridge-mcp/releases/latest), double-click it and paste your license. No terminal, no JSON.
|
|
30
|
+
|
|
31
|
+
**Claude Code:**
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
claude mcp add contabridge -e KEYFLINT_LICENSE=LIC-... -- uvx contabridge-mcp
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**Cursor, Windsurf, Claude Desktop (JSON):**
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"mcpServers": {
|
|
42
|
+
"contabridge": {
|
|
43
|
+
"command": "uvx",
|
|
44
|
+
"args": ["contabridge-mcp"],
|
|
45
|
+
"env": { "KEYFLINT_LICENSE": "LIC-..." }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Optional: `KEYFLINT_URL`, `KEYFLINT_PUBLIC_KEYS` (fetched from the gateway and cached in `~/.keyflint/keyring.json` when unset), `KEYFLINT_CHECKOUT_URL`.
|
|
52
|
+
|
|
53
|
+
Hosted mode: `CONTABRIDGE_TRANSPORT=http contabridge-mcp --http` (license per request, `Authorization: Bearer LIC-...`).
|
|
54
|
+
|
|
55
|
+
Tuning: `CONTABRIDGE_PRICES` (`convert_journal=3,bank_to_journal=5`), `CONTABRIDGE_MAX_BYTES`.
|
|
56
|
+
|
|
57
|
+
## Claude skill
|
|
58
|
+
|
|
59
|
+
`skills/contabridge/SKILL.md` (zipped in `distribution/contabridge-skill.zip`) tells the agent which tool to use, which account length to ask for, and how to save the file.
|
|
60
|
+
|
|
61
|
+
## Tests
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install -e '.[test]' && pytest
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The tests use a fake KeyFlint gateway and locally signed licenses, so they need no network. A3 and ContaPlus layouts are checked position by position against the official specifications.
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
Business Source License 1.1. Source available; production use needs a KeyFlint license (or must be non-commercial). Each version becomes Apache 2.0 four years after release. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "contabridge-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP server for Spanish accounting import files: convert journals between A3 (SUENLACE.DAT), ContaPlus, Sage 50 and CSV, and turn Norma 43 bank statements into journal entries. Billed per call with KeyFlint credits."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "BUSL-1.1"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "ArceeDev" }]
|
|
14
|
+
keywords = ["mcp", "contabilidad", "a3", "a3asesor", "suenlace", "contaplus", "sage", "libro-diario", "asientos", "norma43", "gestoria", "spain"]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"mcp>=2.0",
|
|
17
|
+
"keyflint-verify[fast]>=1.2.0",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
test = ["pytest>=8", "pytest-asyncio>=0.23"]
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
contabridge-mcp = "contabridge_mcp.server:main"
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/ArceeDev/contabridge-mcp"
|
|
28
|
+
Source = "https://github.com/ArceeDev/contabridge-mcp"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["src"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
asyncio_mode = "auto"
|
|
35
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
36
|
+
pythonpath = ["src", "tests"]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Norma 43 bank movements -> journal entries, with deterministic first-match rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from . import ids, n43
|
|
10
|
+
from .journal import Entry, JournalError, Line, parse_date
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Rule:
|
|
15
|
+
account: str
|
|
16
|
+
contains: list[str]
|
|
17
|
+
regex: re.Pattern[str] | None
|
|
18
|
+
common_concept: str
|
|
19
|
+
direction: str
|
|
20
|
+
concept: str
|
|
21
|
+
index: int
|
|
22
|
+
|
|
23
|
+
def matches(self, text: str, m: n43.Movement) -> bool:
|
|
24
|
+
if self.direction and self.direction != ("cargo" if m.cents < 0 else "abono"):
|
|
25
|
+
return False
|
|
26
|
+
if self.common_concept and self.common_concept != m.common_concept:
|
|
27
|
+
return False
|
|
28
|
+
low = text.lower()
|
|
29
|
+
if self.contains and not any(c in low for c in self.contains):
|
|
30
|
+
return False
|
|
31
|
+
if self.regex and not self.regex.search(text):
|
|
32
|
+
return False
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
try: # Python 3.11+
|
|
37
|
+
from re import _constants as _sre_c, _parser as _sre_p # type: ignore[attr-defined]
|
|
38
|
+
except ImportError: # pragma: no cover - Python 3.10
|
|
39
|
+
import sre_constants as _sre_c # type: ignore[no-redef]
|
|
40
|
+
import sre_parse as _sre_p # type: ignore[no-redef]
|
|
41
|
+
|
|
42
|
+
MAX_REGEX_LEN = 300
|
|
43
|
+
_REPEATS = (_sre_c.MAX_REPEAT, _sre_c.MIN_REPEAT, getattr(_sre_c, "POSSESSIVE_REPEAT", None))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _risky(items: Any, in_repeat: bool = False) -> bool:
|
|
47
|
+
"""True for backreferences or a repeat nested in another repeat: the shapes behind catastrophic
|
|
48
|
+
backtracking, e.g. (a+)+ or (a*)*b, which would hold the server's CPU for minutes."""
|
|
49
|
+
for op, av in items:
|
|
50
|
+
if op in (_sre_c.GROUPREF, getattr(_sre_c, "GROUPREF_EXISTS", None)):
|
|
51
|
+
return True
|
|
52
|
+
if op in _REPEATS and op is not None:
|
|
53
|
+
lo, hi, sub = av
|
|
54
|
+
many = hi is _sre_c.MAXREPEAT or hi > 1
|
|
55
|
+
if many and in_repeat:
|
|
56
|
+
return True
|
|
57
|
+
if _risky(sub, in_repeat or many):
|
|
58
|
+
return True
|
|
59
|
+
elif op is _sre_c.SUBPATTERN:
|
|
60
|
+
if _risky(av[-1], in_repeat):
|
|
61
|
+
return True
|
|
62
|
+
elif op is _sre_c.BRANCH:
|
|
63
|
+
if any(_risky(b, in_repeat) for b in av[1]):
|
|
64
|
+
return True
|
|
65
|
+
elif op in (_sre_c.ASSERT, _sre_c.ASSERT_NOT):
|
|
66
|
+
if _risky(av[1], in_repeat):
|
|
67
|
+
return True
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def compile_rule_regex(rx: str, where: str) -> re.Pattern[str]:
|
|
72
|
+
if len(rx) > MAX_REGEX_LEN:
|
|
73
|
+
raise JournalError(f"{where}: regex demasiado larga (máx. {MAX_REGEX_LEN} caracteres)")
|
|
74
|
+
try:
|
|
75
|
+
pattern = re.compile(rx, re.IGNORECASE)
|
|
76
|
+
except re.error as exc:
|
|
77
|
+
raise JournalError(f"{where}: regex no válida ({exc})") from exc
|
|
78
|
+
if _risky(_sre_p.parse(rx)):
|
|
79
|
+
raise JournalError(f"{where}: regex con cuantificadores anidados o referencias (p. ej. (a+)+); "
|
|
80
|
+
"simplifícala o usa 'contains'")
|
|
81
|
+
return pattern
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def build_rules(items: list[dict[str, Any]]) -> list[Rule]:
|
|
85
|
+
out = []
|
|
86
|
+
for i, it in enumerate(items or [], 1):
|
|
87
|
+
if not isinstance(it, dict) or not str(it.get("account", "")).strip():
|
|
88
|
+
raise JournalError(f"regla {i}: falta 'account'")
|
|
89
|
+
contains = it.get("contains") or []
|
|
90
|
+
if isinstance(contains, str):
|
|
91
|
+
contains = [contains]
|
|
92
|
+
rx = it.get("regex") or ""
|
|
93
|
+
pattern = compile_rule_regex(str(rx), f"regla {i}") if rx else None
|
|
94
|
+
direction = str(it.get("direction", "") or "").lower()
|
|
95
|
+
if direction not in ("", "cargo", "abono"):
|
|
96
|
+
raise JournalError(f"regla {i}: direction debe ser 'cargo' o 'abono'")
|
|
97
|
+
cc = str(it.get("common_concept", "") or "").strip()
|
|
98
|
+
if not (contains or pattern or cc or direction):
|
|
99
|
+
raise JournalError(f"regla {i}: indica al menos contains, regex, common_concept o direction")
|
|
100
|
+
out.append(Rule(account=str(it["account"]).strip(), contains=[str(c).lower() for c in contains],
|
|
101
|
+
regex=pattern, common_concept=cc.zfill(2) if cc else "", direction=direction,
|
|
102
|
+
concept=str(it.get("concept", "") or ""), index=i))
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _bank_account(acc: n43.Account, bank_account: str, bank_accounts: dict[str, str]) -> str:
|
|
107
|
+
ccc = acc.ccc or ""
|
|
108
|
+
iban = (ids.iban_from_ccc(ccc) or "") if ccc else ""
|
|
109
|
+
for key, value in (bank_accounts or {}).items():
|
|
110
|
+
k = re.sub(r"\s", "", key).upper()
|
|
111
|
+
if k and (k == iban or k == ccc or iban.endswith(k) or ccc.endswith(k)):
|
|
112
|
+
return value.strip()
|
|
113
|
+
if not bank_account:
|
|
114
|
+
raise JournalError(f"no hay subcuenta 572 para la cuenta bancaria {iban or ccc}: indica bank_account")
|
|
115
|
+
return bank_account.strip()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def movements_to_entries(res: n43.ParseResult, bank_account: str, rules: list[Rule],
|
|
119
|
+
suspense_account: str, bank_accounts: dict[str, str] | None = None,
|
|
120
|
+
use_value_date: bool = False) -> tuple[list[Entry], dict[str, Any]]:
|
|
121
|
+
entries: list[Entry] = []
|
|
122
|
+
unmatched: list[dict[str, Any]] = []
|
|
123
|
+
hits: dict[int, int] = {}
|
|
124
|
+
for acc in res.accounts:
|
|
125
|
+
bank = _bank_account(acc, bank_account, bank_accounts or {})
|
|
126
|
+
for m in acc.movements:
|
|
127
|
+
text = m.description()
|
|
128
|
+
when = (m.value_date if use_value_date else m.operation_date) or m.operation_date or m.value_date
|
|
129
|
+
if not when:
|
|
130
|
+
raise JournalError(f"movimiento {m.id} sin fecha")
|
|
131
|
+
rule = next((r for r in rules if r.matches(text, m)), None)
|
|
132
|
+
if rule:
|
|
133
|
+
hits[rule.index] = hits.get(rule.index, 0) + 1
|
|
134
|
+
other = rule.account
|
|
135
|
+
else:
|
|
136
|
+
other = suspense_account
|
|
137
|
+
unmatched.append({"movement": m.id, "date": when, "amount": n43.fmt_cents(m.cents),
|
|
138
|
+
"description": text})
|
|
139
|
+
concept = (rule.concept if rule and rule.concept else text)[:60]
|
|
140
|
+
doc = (m.document_number or "").lstrip("0") or (m.reference1 or "").lstrip("0")
|
|
141
|
+
amount = abs(m.cents)
|
|
142
|
+
bank_line = Line(account=bank, debit=amount if m.cents > 0 else 0, credit=amount if m.cents < 0 else 0,
|
|
143
|
+
concept=concept, document=doc)
|
|
144
|
+
other_line = Line(account=other, debit=amount if m.cents < 0 else 0, credit=amount if m.cents > 0 else 0,
|
|
145
|
+
concept=concept, document=doc)
|
|
146
|
+
lines = [other_line, bank_line] if m.cents < 0 else [bank_line, other_line]
|
|
147
|
+
entries.append(Entry(number=None, date=parse_date(when), lines=lines))
|
|
148
|
+
stats = {"movements": len(entries), "matched": len(entries) - len(unmatched), "unmatched": unmatched,
|
|
149
|
+
"rule_hits": {f"regla {k}": v for k, v in sorted(hits.items())},
|
|
150
|
+
"suspense_account": suspense_account}
|
|
151
|
+
return entries, stats
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Per-call credit billing on top of keyflint_verify.mcp.KeyFlint.
|
|
2
|
+
|
|
3
|
+
stdio: the license comes from KEYFLINT_LICENSE (one buyer per process).
|
|
4
|
+
HTTP: the license comes from each request (``Authorization: Bearer LIC-...`` or
|
|
5
|
+
``X-KeyFlint-License``), so one hosted server bills many buyers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import os
|
|
12
|
+
import pathlib
|
|
13
|
+
import threading
|
|
14
|
+
import uuid
|
|
15
|
+
from collections import OrderedDict
|
|
16
|
+
from contextlib import asynccontextmanager
|
|
17
|
+
from typing import Any, AsyncIterator, Mapping, Optional
|
|
18
|
+
|
|
19
|
+
from keyflint_verify.mcp import LICENSE_INVALID, KeyFlint, LicenseError
|
|
20
|
+
|
|
21
|
+
LICENSE_HEADERS = ("x-license-key", "authorization")
|
|
22
|
+
_MAX_CLIENTS = 1024
|
|
23
|
+
|
|
24
|
+
DEFAULT_GATEWAY = "https://licenses.keyflint.dev"
|
|
25
|
+
DEFAULT_CHECKOUT = "https://mcp.keyflint.dev/precios/"
|
|
26
|
+
_KEYRING_TTL = 6 * 3600
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _env(name: str) -> str:
|
|
30
|
+
return (os.environ.get("KEYFLINT_" + name) or os.environ.get("KEYFORGE_" + name) or "").strip()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _fetch_keyring(gateway: str) -> str:
|
|
34
|
+
"""Gateway public keyring, cached in ~/.keyflint (stale copy used when offline)."""
|
|
35
|
+
import hashlib
|
|
36
|
+
import json
|
|
37
|
+
import time
|
|
38
|
+
import urllib.request
|
|
39
|
+
|
|
40
|
+
cache = pathlib.Path(os.environ.get("KEYFLINT_KEYRING_CACHE")
|
|
41
|
+
or pathlib.Path.home() / ".keyflint" / "keyring.json")
|
|
42
|
+
slot = hashlib.sha256(gateway.encode()).hexdigest()[:16]
|
|
43
|
+
try:
|
|
44
|
+
stored = json.loads(cache.read_text("utf-8"))
|
|
45
|
+
except (OSError, ValueError):
|
|
46
|
+
stored = {}
|
|
47
|
+
entry = stored.get(slot) or {}
|
|
48
|
+
if entry.get("keyring") and time.time() - float(entry.get("at", 0)) < _KEYRING_TTL:
|
|
49
|
+
return str(entry["keyring"])
|
|
50
|
+
try:
|
|
51
|
+
req = urllib.request.Request(gateway + "/api/v1/licenses/public-key",
|
|
52
|
+
headers={"Accept": "application/json"})
|
|
53
|
+
with urllib.request.urlopen(req, timeout=4) as resp: # noqa: S310 - https gateway
|
|
54
|
+
keyring = str(json.loads(resp.read().decode("utf-8"))["data"]["keyring"]).strip()
|
|
55
|
+
except Exception: # noqa: BLE001 - offline: fall back to the cached keyring
|
|
56
|
+
return str(entry.get("keyring") or "")
|
|
57
|
+
if keyring:
|
|
58
|
+
try:
|
|
59
|
+
stored[slot] = {"keyring": keyring, "at": time.time()}
|
|
60
|
+
cache.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
cache.write_text(json.dumps(stored), "utf-8")
|
|
62
|
+
except OSError:
|
|
63
|
+
pass
|
|
64
|
+
return keyring
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def apply_defaults() -> None:
|
|
68
|
+
"""Only KEYFLINT_LICENSE is required: gateway, keyring and checkout default to KeyFlint's."""
|
|
69
|
+
if not _env("URL"):
|
|
70
|
+
os.environ["KEYFLINT_URL"] = DEFAULT_GATEWAY
|
|
71
|
+
if not _env("CHECKOUT_URL"):
|
|
72
|
+
os.environ["KEYFLINT_CHECKOUT_URL"] = DEFAULT_CHECKOUT
|
|
73
|
+
if not (_env("PUBLIC_KEYS") or _env("PUBLIC_KEY")):
|
|
74
|
+
keyring = _fetch_keyring(_env("URL").rstrip("/"))
|
|
75
|
+
if keyring:
|
|
76
|
+
os.environ["KEYFLINT_PUBLIC_KEYS"] = keyring
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def license_from_headers(headers: Optional[Mapping[str, str]]) -> Optional[str]:
|
|
80
|
+
if not headers:
|
|
81
|
+
return None
|
|
82
|
+
lower = {k.lower(): v for k, v in headers.items()}
|
|
83
|
+
raw = lower.get("x-license-key") or ""
|
|
84
|
+
if not raw:
|
|
85
|
+
auth = lower.get("authorization") or ""
|
|
86
|
+
if auth[:7].lower() == "bearer ":
|
|
87
|
+
raw = auth[7:]
|
|
88
|
+
raw = raw.strip()
|
|
89
|
+
return raw or None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Billing:
|
|
93
|
+
"""Resolves the buyer's license and debits credits around a tool body."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, *, hosted: bool = False, **kf_kwargs: Any) -> None:
|
|
96
|
+
self.hosted = hosted
|
|
97
|
+
if "public_keys" not in kf_kwargs:
|
|
98
|
+
apply_defaults()
|
|
99
|
+
self._kf_kwargs = kf_kwargs
|
|
100
|
+
self._env_kf = KeyFlint(**kf_kwargs)
|
|
101
|
+
self._clients: "OrderedDict[str, KeyFlint]" = OrderedDict()
|
|
102
|
+
self._lock = threading.Lock()
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def checkout_url(self) -> Optional[str]:
|
|
106
|
+
return self._env_kf.checkout_url
|
|
107
|
+
|
|
108
|
+
def client(self, headers: Optional[Mapping[str, str]] = None) -> KeyFlint:
|
|
109
|
+
key = license_from_headers(headers)
|
|
110
|
+
if key is None:
|
|
111
|
+
if self.hosted:
|
|
112
|
+
text = "Send your KeyFlint license as 'Authorization: Bearer LIC-...'."
|
|
113
|
+
if self.checkout_url:
|
|
114
|
+
text += f" Get one: {self.checkout_url}"
|
|
115
|
+
raise LicenseError(LICENSE_INVALID, text, {"reason": "ERR_EMPTY_TOKEN",
|
|
116
|
+
"checkout_url": self.checkout_url})
|
|
117
|
+
return self._env_kf
|
|
118
|
+
with self._lock:
|
|
119
|
+
kf = self._clients.get(key)
|
|
120
|
+
if kf is None:
|
|
121
|
+
# Hosted: server clock is trusted, no node lock (the buyer's machine is not ours).
|
|
122
|
+
kf = KeyFlint(license_key=key, machine_id=None, clock_guard=False,
|
|
123
|
+
public_keys=self._env_kf.public_keys, gateway_url=self._env_kf.gateway_url,
|
|
124
|
+
checkout_url=self._env_kf.checkout_url, timeout=self._env_kf.timeout)
|
|
125
|
+
self._clients[key] = kf
|
|
126
|
+
if len(self._clients) > _MAX_CLIENTS:
|
|
127
|
+
self._clients.popitem(last=False)
|
|
128
|
+
else:
|
|
129
|
+
self._clients.move_to_end(key)
|
|
130
|
+
return kf
|
|
131
|
+
|
|
132
|
+
@asynccontextmanager
|
|
133
|
+
async def charge(self, credits: int, tool: str, headers: Optional[Mapping[str, str]] = None,
|
|
134
|
+
features: Optional[list[str]] = None) -> AsyncIterator[dict[str, Any]]:
|
|
135
|
+
"""Offline license check, debit, run body; refund if the body raises."""
|
|
136
|
+
kf = self.client(headers)
|
|
137
|
+
kf.check(features)
|
|
138
|
+
idem = f"n43-{uuid.uuid4().hex}"
|
|
139
|
+
receipt = await asyncio.to_thread(kf.deduct, credits, idempotency_key=idem, event_type=f"mcp:{tool}")
|
|
140
|
+
try:
|
|
141
|
+
yield receipt
|
|
142
|
+
except BaseException:
|
|
143
|
+
await asyncio.to_thread(kf.refund, idem, "tool_error")
|
|
144
|
+
raise
|
|
145
|
+
|
|
146
|
+
async def balance(self, headers: Optional[Mapping[str, str]] = None) -> dict[str, Any]:
|
|
147
|
+
import json
|
|
148
|
+
import urllib.parse
|
|
149
|
+
import urllib.request
|
|
150
|
+
|
|
151
|
+
kf = self.client(headers)
|
|
152
|
+
kf.check()
|
|
153
|
+
if not kf.gateway_url:
|
|
154
|
+
raise LicenseError(-32063, "KEYFLINT_URL is not set.", {"reason": "GATEWAY_NOT_CONFIGURED"})
|
|
155
|
+
url = f"{kf.gateway_url}/api/v1/quota/balance?" + urllib.parse.urlencode({"license_key": kf.license_key})
|
|
156
|
+
|
|
157
|
+
def _get() -> dict[str, Any]:
|
|
158
|
+
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
|
159
|
+
with urllib.request.urlopen(req, timeout=kf.timeout) as resp: # noqa: S310 - configured URL
|
|
160
|
+
data = json.loads(resp.read().decode("utf-8") or "{}")
|
|
161
|
+
return dict(data.get("data", data))
|
|
162
|
+
|
|
163
|
+
return await asyncio.to_thread(_get)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def from_env() -> Billing:
|
|
167
|
+
return Billing(hosted=os.environ.get("CONTABRIDGE_TRANSPORT", "stdio") != "stdio")
|