xippios-cashctrl 0.1.0__py3-none-any.whl
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.
- xippios/cashctrl/__init__.py +62 -0
- xippios/cashctrl/_cli.py +57 -0
- xippios/cashctrl/_client.py +316 -0
- xippios/cashctrl/_version.py +10 -0
- xippios/cashctrl/api/__init__.py +75 -0
- xippios/cashctrl/api/_filter.py +25 -0
- xippios/cashctrl/api/_form.py +263 -0
- xippios/cashctrl/api/_params.py +20 -0
- xippios/cashctrl/api/account_list.py +93 -0
- xippios/cashctrl/api/account_read.py +28 -0
- xippios/cashctrl/api/currency_list.py +22 -0
- xippios/cashctrl/api/fiscalperiod_list.py +22 -0
- xippios/cashctrl/api/journal_create.py +116 -0
- xippios/cashctrl/api/journal_delete.py +31 -0
- xippios/cashctrl/api/journal_list.py +143 -0
- xippios/cashctrl/api/journal_read.py +31 -0
- xippios/cashctrl/api/journal_update.py +33 -0
- xippios/cashctrl/api/order_category_list.py +44 -0
- xippios/cashctrl/api/order_create.py +127 -0
- xippios/cashctrl/api/order_delete.py +30 -0
- xippios/cashctrl/api/order_list.py +136 -0
- xippios/cashctrl/api/order_read.py +30 -0
- xippios/cashctrl/api/person_create.py +158 -0
- xippios/cashctrl/api/person_delete.py +30 -0
- xippios/cashctrl/api/person_list.py +136 -0
- xippios/cashctrl/api/person_read.py +30 -0
- xippios/cashctrl/api/person_update.py +32 -0
- xippios/cashctrl/api/tax_list.py +22 -0
- xippios/cashctrl/enums.py +7 -0
- xippios/cashctrl/errors.py +55 -0
- xippios/cashctrl/http.py +17 -0
- xippios/cashctrl/mcp.py +42 -0
- xippios/cashctrl/mcp_write.py +45 -0
- xippios/cashctrl/models.py +1695 -0
- xippios/cashctrl/params.py +42 -0
- xippios/cashctrl/py.typed +0 -0
- xippios/cashctrl/reference/README.md +29 -0
- xippios/cashctrl/tasks.py +831 -0
- xippios/cashctrl/tasks_write.py +1627 -0
- xippios_cashctrl-0.1.0.dist-info/METADATA +317 -0
- xippios_cashctrl-0.1.0.dist-info/RECORD +44 -0
- xippios_cashctrl-0.1.0.dist-info/WHEEL +4 -0
- xippios_cashctrl-0.1.0.dist-info/entry_points.txt +3 -0
- xippios_cashctrl-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Typed client, MCP tools and HTTP routes for CashCtrl.
|
|
2
|
+
|
|
3
|
+
Distribution ``xippios-cashctrl``. Public API: ``__version__`` and the task
|
|
4
|
+
functions re-exported here. Every task takes ``org``, a declared organisation, first,
|
|
5
|
+
except ``list_organisations``, which takes no argument and lists the declared
|
|
6
|
+
organisations' names. The write tasks (``create_*``, ``update_*``, ``delete_*``, from
|
|
7
|
+
``tasks_write``) preview unless called with ``confirm=True``.
|
|
8
|
+
Endpoint wrappers under ``api/`` and the organisation registry in ``_client`` are
|
|
9
|
+
internal.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from xippios.cashctrl._version import __version__
|
|
13
|
+
from xippios.cashctrl.tasks import (
|
|
14
|
+
get_account,
|
|
15
|
+
get_journal_entry,
|
|
16
|
+
get_order,
|
|
17
|
+
get_person,
|
|
18
|
+
list_accounts,
|
|
19
|
+
list_currencies,
|
|
20
|
+
list_fiscal_periods,
|
|
21
|
+
list_journal_entries,
|
|
22
|
+
list_order_categories,
|
|
23
|
+
list_orders,
|
|
24
|
+
list_organisations,
|
|
25
|
+
list_persons,
|
|
26
|
+
list_tax_codes,
|
|
27
|
+
)
|
|
28
|
+
from xippios.cashctrl.tasks_write import (
|
|
29
|
+
create_journal_entry,
|
|
30
|
+
create_order,
|
|
31
|
+
create_person,
|
|
32
|
+
delete_journal_entry,
|
|
33
|
+
delete_order,
|
|
34
|
+
delete_person,
|
|
35
|
+
update_journal_entry,
|
|
36
|
+
update_person,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"__version__",
|
|
41
|
+
"create_journal_entry",
|
|
42
|
+
"create_order",
|
|
43
|
+
"create_person",
|
|
44
|
+
"delete_journal_entry",
|
|
45
|
+
"delete_order",
|
|
46
|
+
"delete_person",
|
|
47
|
+
"get_account",
|
|
48
|
+
"get_journal_entry",
|
|
49
|
+
"get_order",
|
|
50
|
+
"get_person",
|
|
51
|
+
"list_accounts",
|
|
52
|
+
"list_currencies",
|
|
53
|
+
"list_fiscal_periods",
|
|
54
|
+
"list_journal_entries",
|
|
55
|
+
"list_order_categories",
|
|
56
|
+
"list_orders",
|
|
57
|
+
"list_organisations",
|
|
58
|
+
"list_persons",
|
|
59
|
+
"list_tax_codes",
|
|
60
|
+
"update_journal_entry",
|
|
61
|
+
"update_person",
|
|
62
|
+
]
|
xippios/cashctrl/_cli.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""The ``--org`` option both MCP servers take, through core's option hooks (ADR-0014).
|
|
2
|
+
|
|
3
|
+
Core's ``main`` parses the command line once, with ``--org`` added by ``add_org``. Given
|
|
4
|
+
``--org <name>``, ``locked`` checks the organisation as ``client_for`` would, declared
|
|
5
|
+
(``TENANTS.require``) and with a well-formed subdomain (``TENANTS.field``), and returns
|
|
6
|
+
the server built anew with ``tenant_lock=TenantLock("org", name)``: core refuses every
|
|
7
|
+
tool call that names another organisation or leaves ``org`` out, and adds a sentence
|
|
8
|
+
naming the organisation to the instructions. An undeclared name or a missing or
|
|
9
|
+
malformed subdomain stops the process at startup, exit status 2, the message on stderr,
|
|
10
|
+
nothing served; the key is still read on the first request. Without ``--org`` the
|
|
11
|
+
server is served as built, for every organisation its environment declares.
|
|
12
|
+
|
|
13
|
+
Core's ``build_server`` and ``main`` keep httpx's request lines, which carry the
|
|
14
|
+
organisation's host, out of the log (``xippios.core.mcp.quiet_request_logging``), and
|
|
15
|
+
``main`` serves a write server over ``--http`` on a loopback ``--host`` only, unless
|
|
16
|
+
``--allow-remote`` is passed. The HTTP router has no lock: a deployment that needs one
|
|
17
|
+
declares only that organisation.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
from collections.abc import Callable, Sequence
|
|
22
|
+
|
|
23
|
+
from mcp.server.mcpserver import MCPServer
|
|
24
|
+
from xippios.core.mcp import TenantLock, main
|
|
25
|
+
|
|
26
|
+
from xippios.cashctrl._client import TENANTS
|
|
27
|
+
|
|
28
|
+
__all__ = ["ORG_HELP", "add_org", "serve"]
|
|
29
|
+
|
|
30
|
+
ORG_HELP = "serve only this organisation, e.g. demo; tool calls naming another are refused"
|
|
31
|
+
|
|
32
|
+
Build = Callable[[TenantLock | None], MCPServer]
|
|
33
|
+
"""Builds an adapter's server, locked to one organisation or, given ``None``, not."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def add_org(parser: argparse.ArgumentParser) -> None:
|
|
37
|
+
"""Put ``--org`` on core's parser, so ``--help`` lists it and a typo fails as usual."""
|
|
38
|
+
parser.add_argument("--org", help=ORG_HELP)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def serve(server: MCPServer, build: Build, argv: Sequence[str] | None = None) -> None:
|
|
42
|
+
"""Run ``server``, or the one ``build`` locks to ``--org``, through core's ``main``.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
server: The adapter's unlocked server, built by ``build(None)``.
|
|
46
|
+
build: Builds the adapter's server; called with the lock when ``--org`` is given.
|
|
47
|
+
argv: Arguments without the program name; defaults to ``sys.argv[1:]``.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def locked(namespace: argparse.Namespace, unlocked: MCPServer) -> MCPServer:
|
|
51
|
+
if namespace.org is None:
|
|
52
|
+
return unlocked
|
|
53
|
+
org = TENANTS.require(namespace.org)
|
|
54
|
+
TENANTS.field(org, "SUBDOMAIN")
|
|
55
|
+
return build(TenantLock("org", org))
|
|
56
|
+
|
|
57
|
+
main(server, argv, add_options=add_org, apply_options=locked)
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""The organisation registry for CashCtrl: one configured ``AsyncClient`` per organisation.
|
|
2
|
+
|
|
3
|
+
CashCtrl serves every organisation from its own subdomain, and an API user belongs to
|
|
4
|
+
exactly one organisation (``docs/upstream/introduction.md``, ``authentication.md``). So
|
|
5
|
+
there is no single client here: every task takes ``org``, the name of an organisation
|
|
6
|
+
declared in the environment, and ``client_for(org)`` returns that organisation's own
|
|
7
|
+
client, with its own base URL, its own key, its own token bucket and no shared cache.
|
|
8
|
+
|
|
9
|
+
The registry is core's ``xippios.core.Tenants`` (ADR-0014), ``TENANTS`` below; the
|
|
10
|
+
environment is the contract (conventions §7):
|
|
11
|
+
|
|
12
|
+
- ``CASHCTRL_ORGS``: comma-separated names matching ``^[a-z0-9_]+$``, e.g. ``acme,acme_test``;
|
|
13
|
+
- per name, ``CASHCTRL_<NAME>_SUBDOMAIN`` and ``CASHCTRL_<NAME>_API_KEY``, the name
|
|
14
|
+
upper-cased (``CASHCTRL_ACME_TEST_SUBDOMAIN``); ``TENANTS.var(name, field)`` names them;
|
|
15
|
+
- the reserved organisation ``demo`` is never listed in ``CASHCTRL_ORGS``: it exists when
|
|
16
|
+
both ``CASHCTRL_DEMO_SUBDOMAIN`` and ``CASHCTRL_DEMO_API_KEY`` are set. Fixtures, live
|
|
17
|
+
tests and the fixture recorder use ``demo`` and nothing else.
|
|
18
|
+
|
|
19
|
+
``TENANTS.names()`` lists the declared organisations, ``TENANTS.require(org)`` refuses an
|
|
20
|
+
undeclared one with ``InvalidRequestError`` (the caller's mistake), and a malformed list
|
|
21
|
+
or a missing or malformed subdomain is a ``ConfigError`` naming the variable, never its
|
|
22
|
+
value (the operator's). Isolation is by configuration: a deployment reaches exactly the
|
|
23
|
+
organisations its environment declares, and a deployment that does not set the demo pair
|
|
24
|
+
has no ``demo``. Nothing here spans organisations: ``TENANTS.names()`` returns names
|
|
25
|
+
only, never values.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
import httpx
|
|
32
|
+
from xippios.core import (
|
|
33
|
+
AsyncClient,
|
|
34
|
+
BasicKeyAuth,
|
|
35
|
+
ClientConfig,
|
|
36
|
+
NotFoundError,
|
|
37
|
+
RateLimit,
|
|
38
|
+
Tenants,
|
|
39
|
+
UpstreamError,
|
|
40
|
+
XippiosError,
|
|
41
|
+
)
|
|
42
|
+
from xippios.core.errors import BODY_LIMIT, error_from_response
|
|
43
|
+
|
|
44
|
+
from xippios.cashctrl._version import __version__
|
|
45
|
+
from xippios.cashctrl.errors import FieldError, RequestRejected
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"BASE_URL_TEMPLATE",
|
|
49
|
+
"DEMO",
|
|
50
|
+
"NEUTRAL_HOST",
|
|
51
|
+
"RATE_LIMIT",
|
|
52
|
+
"SOURCE",
|
|
53
|
+
"TENANTS",
|
|
54
|
+
"check_subdomain",
|
|
55
|
+
"classify",
|
|
56
|
+
"client_for",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
SOURCE = "cashctrl"
|
|
60
|
+
BASE_URL_TEMPLATE = "https://{subdomain}.cashctrl.com/api/v1"
|
|
61
|
+
"""Base URL of one organisation; ``subdomain`` is the organisation's (introduction.md)."""
|
|
62
|
+
# A self-imposed pace: upstream documents no limit (ADR-0010 decision 1).
|
|
63
|
+
RATE_LIMIT = RateLimit(per_minute=60, burst=1)
|
|
64
|
+
"""Per organisation and self-imposed: upstream documents only a 429 and "add delays".
|
|
65
|
+
|
|
66
|
+
One request at a time, then one a second: CashCtrl documents no burst either. Every
|
|
67
|
+
response carries ``x-cashctrl-requests-left`` (299 observed on 2026-10-23, a 401
|
|
68
|
+
included); its window and scope are not documented, so nothing here reads it, and no
|
|
69
|
+
``QuotaPolicy`` is installed (ADR-0015): with no documented window, its default of an
|
|
70
|
+
hour would turn one 429 without ``Retry-After`` into an hour of refusals.
|
|
71
|
+
"""
|
|
72
|
+
DEMO = "demo"
|
|
73
|
+
"""The reserved organisation, declared by its own two variables and never listed."""
|
|
74
|
+
|
|
75
|
+
_SUBDOMAIN = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def check_subdomain(value: str) -> str:
|
|
79
|
+
"""The subdomain in ``value``, lower-cased, when it is one DNS label.
|
|
80
|
+
|
|
81
|
+
The label before ``.cashctrl.com``: letters, digits and inner hyphens, at most 63
|
|
82
|
+
characters. CashCtrl's hosts are lower-case, so the value is lower-cased first, as
|
|
83
|
+
the upstream would read it (ADR-0014).
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
ValueError: ``value`` is not one label; the message never quotes it, and core's
|
|
87
|
+
``ConfigError`` names the variable instead.
|
|
88
|
+
"""
|
|
89
|
+
subdomain = value.lower()
|
|
90
|
+
if not _SUBDOMAIN.fullmatch(subdomain):
|
|
91
|
+
raise ValueError("not one DNS label")
|
|
92
|
+
return subdomain
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
TENANTS = Tenants(
|
|
96
|
+
SOURCE,
|
|
97
|
+
list_var="CASHCTRL_ORGS",
|
|
98
|
+
fields=("SUBDOMAIN", "API_KEY"),
|
|
99
|
+
reserved=(DEMO,),
|
|
100
|
+
validate={"SUBDOMAIN": check_subdomain},
|
|
101
|
+
hints={"SUBDOMAIN": "set the subdomain alone, one DNS label: acme for acme.cashctrl.com"},
|
|
102
|
+
)
|
|
103
|
+
"""CashCtrl's organisations, declared in the environment (ADR-0014)."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
NEUTRAL_HOST = "cashctrl.com"
|
|
107
|
+
"""The host a quoted error body names instead of an organisation's own.
|
|
108
|
+
|
|
109
|
+
See ``_without_tenant_host``.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
_REJECTED = re.compile(rb'"success"\s*:\s*false')
|
|
113
|
+
_BODY_HEADERS = frozenset(
|
|
114
|
+
{"content-encoding", "content-length", "content-type", "transfer-encoding"}
|
|
115
|
+
)
|
|
116
|
+
_READ_ONE_PARAMS = frozenset({"id", "lang"})
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _field_errors(items: Any) -> list[FieldError]:
|
|
120
|
+
if not isinstance(items, list):
|
|
121
|
+
return []
|
|
122
|
+
return [
|
|
123
|
+
FieldError(
|
|
124
|
+
field=item["field"] if isinstance(item.get("field"), str) else None,
|
|
125
|
+
message=str(item.get("message", "")),
|
|
126
|
+
)
|
|
127
|
+
for item in items
|
|
128
|
+
if isinstance(item, dict)
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _text(value: Any) -> str | None:
|
|
133
|
+
return value if isinstance(value, str) and value.strip() else None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _reads_one_record(url: httpx.URL) -> bool:
|
|
137
|
+
"""``<...>/read.json`` asked for one record: a non-empty ``id``, at most ``lang`` besides."""
|
|
138
|
+
params = url.params
|
|
139
|
+
return (
|
|
140
|
+
url.path.endswith("/read.json")
|
|
141
|
+
and set(params.keys()) <= _READ_ONE_PARAMS
|
|
142
|
+
and _text(params.get("id")) is not None
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _full_text(response: httpx.Response) -> str:
|
|
147
|
+
try:
|
|
148
|
+
return response.text
|
|
149
|
+
except (httpx.ResponseNotRead, UnicodeDecodeError):
|
|
150
|
+
return ""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _without_tenant_host(response: httpx.Response) -> httpx.Response:
|
|
154
|
+
"""A copy of ``response`` whose body names ``cashctrl.com`` wherever it named the tenant's host.
|
|
155
|
+
|
|
156
|
+
Every occurrence of the request's host in the body text, in any letter case, becomes
|
|
157
|
+
``cashctrl.com``: an error page that echoes the host cannot leak it through the
|
|
158
|
+
message snippet or ``UpstreamError.body``, both of which core quotes from the body.
|
|
159
|
+
The request is kept as it is: core's messages name the method and the path, never
|
|
160
|
+
the host (conventions §5, sdk-core#14). Status, reason phrase and the other headers
|
|
161
|
+
are kept, so core's mapping (status, ``Retry-After``, body truncation) is unchanged.
|
|
162
|
+
The copy's body is that text encoded as UTF-8, so the headers that describe the
|
|
163
|
+
original bytes (``Content-Type``, ``Content-Encoding``, ``Content-Length``,
|
|
164
|
+
``Transfer-Encoding``) are left out.
|
|
165
|
+
"""
|
|
166
|
+
request = response.request
|
|
167
|
+
host = request.url.host
|
|
168
|
+
text = _full_text(response)
|
|
169
|
+
if host and host != NEUTRAL_HOST:
|
|
170
|
+
text = re.sub(re.escape(host), NEUTRAL_HOST, text, flags=re.IGNORECASE)
|
|
171
|
+
headers = [
|
|
172
|
+
(name, value)
|
|
173
|
+
for name, value in response.headers.multi_items()
|
|
174
|
+
if name.lower() not in _BODY_HEADERS
|
|
175
|
+
]
|
|
176
|
+
reason = response.extensions.get("reason_phrase")
|
|
177
|
+
return httpx.Response(
|
|
178
|
+
response.status_code,
|
|
179
|
+
headers=headers,
|
|
180
|
+
content=text.encode("utf-8"),
|
|
181
|
+
request=request,
|
|
182
|
+
extensions={"reason_phrase": reason} if reason is not None else None,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _body_text(response: httpx.Response) -> str:
|
|
187
|
+
return _full_text(response)[:BODY_LIMIT]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _status_error(response: httpx.Response) -> XippiosError | None:
|
|
191
|
+
# Core's error_from_response names the method and the path, never the host, but it
|
|
192
|
+
# quotes the body in the message and keeps it as UpstreamError.body, and an error
|
|
193
|
+
# page may echo the organisation's host, its subdomain, which is treated like a key.
|
|
194
|
+
# Core maps a copy whose body names cashctrl.com instead.
|
|
195
|
+
neutral = _without_tenant_host(response)
|
|
196
|
+
error = error_from_response(neutral, source=SOURCE)
|
|
197
|
+
if isinstance(error, NotFoundError):
|
|
198
|
+
# error-handling.md: "404 - Not Found: The requested endpoint doesn't exist." An
|
|
199
|
+
# absent record is a 200 refusal (classify), so NotFoundError keeps that one meaning.
|
|
200
|
+
return UpstreamError(
|
|
201
|
+
f"{error.message} (the endpoint does not exist upstream)",
|
|
202
|
+
status=404,
|
|
203
|
+
body=_body_text(neutral),
|
|
204
|
+
source=SOURCE,
|
|
205
|
+
)
|
|
206
|
+
return error
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def classify(response: httpx.Response) -> XippiosError | None:
|
|
210
|
+
"""CashCtrl's error shapes, mapped once (ADR-0008); the client calls it on every response.
|
|
211
|
+
|
|
212
|
+
HTTP status codes (``docs/upstream/error-handling.md``): every 4xx and 5xx is mapped
|
|
213
|
+
by core's ``error_from_response`` (401/403 ``AuthError``, 429 ``RateLimitError``,
|
|
214
|
+
anything else ``UpstreamError``), whose message names the method and the path but
|
|
215
|
+
never the host. It is applied to a copy of the response whose body names
|
|
216
|
+
``cashctrl.com`` instead of the organisation's host, so neither the quoted snippet
|
|
217
|
+
nor ``UpstreamError.body`` carries an organisation's subdomain. A 404
|
|
218
|
+
means "The requested endpoint doesn't exist" and raises ``UpstreamError`` with
|
|
219
|
+
``status=404``: ``NotFoundError`` only ever means an absent record (ADR-0003).
|
|
220
|
+
|
|
221
|
+
Documented refusals: validation errors come back as HTTP 200 with
|
|
222
|
+
``{"success": false, "errors": [{"field": ..., "message": ...}]}``. They raise
|
|
223
|
+
``RequestRejected`` naming the fields. Other operations are documented to return
|
|
224
|
+
"either a success or error message", one message instead of a list (e.g. "Delete
|
|
225
|
+
accounts" in ``account.md``).
|
|
226
|
+
|
|
227
|
+
On 2026-10-23, observed against the demo organisation, not stated in docs/upstream/:
|
|
228
|
+
that one message is a top-level ``errorMessage``, HTTP 200
|
|
229
|
+
``{"success": false, "errorMessage": "...", "data": null}``. A top-level ``message``
|
|
230
|
+
is read when ``errorMessage`` is missing, null or empty. On 2026-10-24 the writes
|
|
231
|
+
showed both other shapes: ``journal/create.json`` without ``debitId`` answers
|
|
232
|
+
``{"success": false, "message": null, "errors": [{"field": "debitId", "message":
|
|
233
|
+
"This field cannot be empty."}]}``, the documented list; ``journal/delete.json`` for
|
|
234
|
+
an id that no longer exists answers ``{"success": false, "message": "This book entry
|
|
235
|
+
does not exist."}``, the message under ``message``. Both raise ``RequestRejected``;
|
|
236
|
+
a delete is not a read by id, so the second is no ``NotFoundError``.
|
|
237
|
+
|
|
238
|
+
- ``account/read.json`` without ``id`` answers ``"errorMessage": "ID missing."``:
|
|
239
|
+
``RequestRejected`` with that message as ``upstream_message``.
|
|
240
|
+
- ``account/read.json`` and ``currency/read.json`` with an unknown ``id`` answer
|
|
241
|
+
``"errorMessage": "This account does not exist."`` (``"This currency ..."``). A
|
|
242
|
+
refusal with a message and no field list raises ``NotFoundError`` when the request
|
|
243
|
+
reads one record by id: the path ends in ``/read.json`` and the query is a
|
|
244
|
+
non-empty ``id`` plus at most ``lang``. The test is the request, not the text,
|
|
245
|
+
because the text follows ``lang``. In docs/upstream/ that covers every
|
|
246
|
+
``<module>/read.json`` whose one parameter is ``id`` (``account``, ``journal``,
|
|
247
|
+
``order``, ``person`` and the rest), ``fiscalperiod/read.json`` with an ``id`` and
|
|
248
|
+
``sequencenumber/read.json`` called by ``id`` alone; never
|
|
249
|
+
``sequencenumber/read.json`` with ``type``, ``categoryId`` or ``date``, nor
|
|
250
|
+
``setting/read.json``, which takes no ``id``. Known gaps:
|
|
251
|
+
``order/category/read_status.json``, ``account/balance`` and
|
|
252
|
+
``account/costcenter/balance`` also read by ``id`` under other paths, so an unknown
|
|
253
|
+
id there stays a ``RequestRejected``. ``account``, ``currency`` and ``journal``
|
|
254
|
+
(``"This book entry does not exist."``) were observed; the other operations are
|
|
255
|
+
assumed to refuse alike.
|
|
256
|
+
|
|
257
|
+
Everything else falls through (``None``): a 200 that is not a JSON object with
|
|
258
|
+
``"success": false`` (data, the CSV, Excel and PDF exports, malformed JSON) and every
|
|
259
|
+
other 2xx or 3xx. There is no content-type check: a byte pre-check for
|
|
260
|
+
``"success": false`` and a tolerant JSON parse decide, as ``get_json`` does.
|
|
261
|
+
"""
|
|
262
|
+
if response.status_code >= 400:
|
|
263
|
+
return _status_error(response)
|
|
264
|
+
if response.status_code != 200 or _REJECTED.search(response.content) is None:
|
|
265
|
+
return None
|
|
266
|
+
try:
|
|
267
|
+
payload = response.json()
|
|
268
|
+
except ValueError:
|
|
269
|
+
return None
|
|
270
|
+
if not isinstance(payload, dict) or payload.get("success") is not False:
|
|
271
|
+
return None
|
|
272
|
+
errors = _field_errors(payload.get("errors"))
|
|
273
|
+
message = _text(payload.get("errorMessage")) or _text(payload.get("message"))
|
|
274
|
+
url = response.request.url
|
|
275
|
+
if not errors and message and _reads_one_record(url):
|
|
276
|
+
return NotFoundError(
|
|
277
|
+
f"CashCtrl: {message} ({url.path.rsplit('/api/v1', 1)[-1]})", source=SOURCE
|
|
278
|
+
)
|
|
279
|
+
return RequestRejected(errors, upstream_message=message, source=SOURCE)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _build(org: str) -> AsyncClient:
|
|
283
|
+
"""A new client for organisation ``org``: its base URL, its key, its own token bucket.
|
|
284
|
+
|
|
285
|
+
Called by ``TENANTS.client_for`` on first use and whenever one of the organisation's
|
|
286
|
+
variables changed. The subdomain is read and checked here (``ConfigError`` when it is
|
|
287
|
+
missing or malformed); the key is read on the first request.
|
|
288
|
+
"""
|
|
289
|
+
subdomain = TENANTS.field(org, "SUBDOMAIN")
|
|
290
|
+
config = ClientConfig(
|
|
291
|
+
source=SOURCE,
|
|
292
|
+
version=__version__,
|
|
293
|
+
base_url=BASE_URL_TEMPLATE.format(subdomain=subdomain),
|
|
294
|
+
rate_limit=RATE_LIMIT,
|
|
295
|
+
cache=None,
|
|
296
|
+
classify=classify,
|
|
297
|
+
)
|
|
298
|
+
return AsyncClient(config, auth=BasicKeyAuth(TENANTS.var(org, "API_KEY"), source=SOURCE))
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def client_for(org: str) -> AsyncClient:
|
|
302
|
+
"""The configured client of organisation ``org``, built on first use and then reused.
|
|
303
|
+
|
|
304
|
+
Each organisation has its own client: its own base URL, key, token bucket and no
|
|
305
|
+
response cache. The key is read from ``CASHCTRL_<NAME>_API_KEY`` on the first request,
|
|
306
|
+
so a declared organisation without a key fails then, with ``ConfigError``. Core keeps
|
|
307
|
+
the client under a digest of the organisation's variables: when the environment
|
|
308
|
+
changes the subdomain or the key, the next call builds a new client.
|
|
309
|
+
``TENANTS.reset()`` drops every cached client.
|
|
310
|
+
|
|
311
|
+
Raises:
|
|
312
|
+
InvalidRequestError: ``org`` is not declared.
|
|
313
|
+
ConfigError: ``CASHCTRL_ORGS`` is malformed, or the organisation's subdomain
|
|
314
|
+
variable is missing or malformed.
|
|
315
|
+
"""
|
|
316
|
+
return TENANTS.client_for(org, _build)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Package version, read from the installed distribution metadata."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("xippios-cashctrl")
|
|
7
|
+
except PackageNotFoundError: # pragma: no cover - only in an unbuilt checkout
|
|
8
|
+
__version__ = "0.0.0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Endpoint wrappers for CashCtrl.
|
|
2
|
+
|
|
3
|
+
Layer 1 of ADR-0001: one module per upstream operation, named after it. A wrapper
|
|
4
|
+
takes ``org`` first, then the parameters as upstream names them (snake_cased), calls
|
|
5
|
+
``xippios.cashctrl._client.client_for(org)`` and returns models from ``models.py`` that
|
|
6
|
+
mirror the payload. No composition and no defaults beyond upstream's own: a paginated
|
|
7
|
+
list wrapper fetches one page, and the task collects pages. The reference is the
|
|
8
|
+
snapshot in ``docs/upstream/``.
|
|
9
|
+
|
|
10
|
+
A write wrapper (``*_create``, ``*_update``, ``*_delete``) posts form fields through
|
|
11
|
+
``_form.post_form``; ``journal_fields``, ``person_fields`` and ``order_fields`` encode
|
|
12
|
+
them from the parameters as upstream names them, so a task can preview exactly what it
|
|
13
|
+
would send (``tasks_write``).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from xippios.cashctrl.api._filter import encode_filter
|
|
17
|
+
from xippios.cashctrl.api._form import custom_xml, delete_fields, merge_custom
|
|
18
|
+
from xippios.cashctrl.api.account_list import account_list, account_list_params
|
|
19
|
+
from xippios.cashctrl.api.account_read import account_read
|
|
20
|
+
from xippios.cashctrl.api.currency_list import currency_list
|
|
21
|
+
from xippios.cashctrl.api.fiscalperiod_list import fiscalperiod_list
|
|
22
|
+
from xippios.cashctrl.api.journal_create import journal_create, journal_fields
|
|
23
|
+
from xippios.cashctrl.api.journal_delete import journal_delete
|
|
24
|
+
from xippios.cashctrl.api.journal_list import journal_list, journal_list_params
|
|
25
|
+
from xippios.cashctrl.api.journal_read import journal_read
|
|
26
|
+
from xippios.cashctrl.api.journal_update import journal_update
|
|
27
|
+
from xippios.cashctrl.api.order_category_list import (
|
|
28
|
+
order_category_list,
|
|
29
|
+
order_category_list_params,
|
|
30
|
+
)
|
|
31
|
+
from xippios.cashctrl.api.order_create import order_create, order_fields
|
|
32
|
+
from xippios.cashctrl.api.order_delete import order_delete
|
|
33
|
+
from xippios.cashctrl.api.order_list import order_list, order_list_params
|
|
34
|
+
from xippios.cashctrl.api.order_read import order_read
|
|
35
|
+
from xippios.cashctrl.api.person_create import person_create, person_fields
|
|
36
|
+
from xippios.cashctrl.api.person_delete import person_delete
|
|
37
|
+
from xippios.cashctrl.api.person_list import person_list, person_list_params
|
|
38
|
+
from xippios.cashctrl.api.person_read import person_read
|
|
39
|
+
from xippios.cashctrl.api.person_update import person_update
|
|
40
|
+
from xippios.cashctrl.api.tax_list import tax_list
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"account_list",
|
|
44
|
+
"account_list_params",
|
|
45
|
+
"account_read",
|
|
46
|
+
"currency_list",
|
|
47
|
+
"custom_xml",
|
|
48
|
+
"delete_fields",
|
|
49
|
+
"encode_filter",
|
|
50
|
+
"fiscalperiod_list",
|
|
51
|
+
"journal_create",
|
|
52
|
+
"journal_delete",
|
|
53
|
+
"journal_fields",
|
|
54
|
+
"journal_list",
|
|
55
|
+
"journal_list_params",
|
|
56
|
+
"journal_read",
|
|
57
|
+
"journal_update",
|
|
58
|
+
"merge_custom",
|
|
59
|
+
"order_category_list",
|
|
60
|
+
"order_category_list_params",
|
|
61
|
+
"order_create",
|
|
62
|
+
"order_delete",
|
|
63
|
+
"order_fields",
|
|
64
|
+
"order_list",
|
|
65
|
+
"order_list_params",
|
|
66
|
+
"order_read",
|
|
67
|
+
"person_create",
|
|
68
|
+
"person_delete",
|
|
69
|
+
"person_fields",
|
|
70
|
+
"person_list",
|
|
71
|
+
"person_list_params",
|
|
72
|
+
"person_read",
|
|
73
|
+
"person_update",
|
|
74
|
+
"tax_list",
|
|
75
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""The ``filter`` parameter of CashCtrl's ``list.json`` operations, encoded once.
|
|
2
|
+
|
|
3
|
+
Upstream takes ``filter`` as "a JSON array ``[{...},{...},...]``" of
|
|
4
|
+
``{comparison, field, value}`` (``docs/upstream/account.md``, "List accounts"; the same
|
|
5
|
+
table recurs on the other lists that document ``filter``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
|
|
11
|
+
from xippios.cashctrl.models import Filter
|
|
12
|
+
|
|
13
|
+
__all__ = ["encode_filter"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def encode_filter(filters: Sequence[Filter]) -> str:
|
|
17
|
+
"""``filters`` as the compact JSON array upstream reads, keys in the documented order.
|
|
18
|
+
|
|
19
|
+
Example: ``[{"comparison":"eq","field":"number","value":"1020"}]``.
|
|
20
|
+
"""
|
|
21
|
+
return json.dumps(
|
|
22
|
+
[item.model_dump(mode="json") for item in filters],
|
|
23
|
+
separators=(",", ":"),
|
|
24
|
+
ensure_ascii=False,
|
|
25
|
+
)
|