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.
Files changed (44) hide show
  1. xippios/cashctrl/__init__.py +62 -0
  2. xippios/cashctrl/_cli.py +57 -0
  3. xippios/cashctrl/_client.py +316 -0
  4. xippios/cashctrl/_version.py +10 -0
  5. xippios/cashctrl/api/__init__.py +75 -0
  6. xippios/cashctrl/api/_filter.py +25 -0
  7. xippios/cashctrl/api/_form.py +263 -0
  8. xippios/cashctrl/api/_params.py +20 -0
  9. xippios/cashctrl/api/account_list.py +93 -0
  10. xippios/cashctrl/api/account_read.py +28 -0
  11. xippios/cashctrl/api/currency_list.py +22 -0
  12. xippios/cashctrl/api/fiscalperiod_list.py +22 -0
  13. xippios/cashctrl/api/journal_create.py +116 -0
  14. xippios/cashctrl/api/journal_delete.py +31 -0
  15. xippios/cashctrl/api/journal_list.py +143 -0
  16. xippios/cashctrl/api/journal_read.py +31 -0
  17. xippios/cashctrl/api/journal_update.py +33 -0
  18. xippios/cashctrl/api/order_category_list.py +44 -0
  19. xippios/cashctrl/api/order_create.py +127 -0
  20. xippios/cashctrl/api/order_delete.py +30 -0
  21. xippios/cashctrl/api/order_list.py +136 -0
  22. xippios/cashctrl/api/order_read.py +30 -0
  23. xippios/cashctrl/api/person_create.py +158 -0
  24. xippios/cashctrl/api/person_delete.py +30 -0
  25. xippios/cashctrl/api/person_list.py +136 -0
  26. xippios/cashctrl/api/person_read.py +30 -0
  27. xippios/cashctrl/api/person_update.py +32 -0
  28. xippios/cashctrl/api/tax_list.py +22 -0
  29. xippios/cashctrl/enums.py +7 -0
  30. xippios/cashctrl/errors.py +55 -0
  31. xippios/cashctrl/http.py +17 -0
  32. xippios/cashctrl/mcp.py +42 -0
  33. xippios/cashctrl/mcp_write.py +45 -0
  34. xippios/cashctrl/models.py +1695 -0
  35. xippios/cashctrl/params.py +42 -0
  36. xippios/cashctrl/py.typed +0 -0
  37. xippios/cashctrl/reference/README.md +29 -0
  38. xippios/cashctrl/tasks.py +831 -0
  39. xippios/cashctrl/tasks_write.py +1627 -0
  40. xippios_cashctrl-0.1.0.dist-info/METADATA +317 -0
  41. xippios_cashctrl-0.1.0.dist-info/RECORD +44 -0
  42. xippios_cashctrl-0.1.0.dist-info/WHEEL +4 -0
  43. xippios_cashctrl-0.1.0.dist-info/entry_points.txt +3 -0
  44. 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
+ ]
@@ -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
+ )