xippios-cashctrl 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.
- xippios_cashctrl-0.1.0/.gitignore +23 -0
- xippios_cashctrl-0.1.0/CHANGELOG.md +461 -0
- xippios_cashctrl-0.1.0/LICENSE +201 -0
- xippios_cashctrl-0.1.0/PKG-INFO +317 -0
- xippios_cashctrl-0.1.0/README.md +288 -0
- xippios_cashctrl-0.1.0/docs/demo-org.md +490 -0
- xippios_cashctrl-0.1.0/docs/upstream/FETCHED.md +67 -0
- xippios_cashctrl-0.1.0/docs/upstream/account.md +974 -0
- xippios_cashctrl-0.1.0/docs/upstream/authentication.md +25 -0
- xippios_cashctrl-0.1.0/docs/upstream/common.md +797 -0
- xippios_cashctrl-0.1.0/docs/upstream/error-handling.md +44 -0
- xippios_cashctrl-0.1.0/docs/upstream/examples.md +161 -0
- xippios_cashctrl-0.1.0/docs/upstream/file.md +411 -0
- xippios_cashctrl-0.1.0/docs/upstream/introduction.md +17 -0
- xippios_cashctrl-0.1.0/docs/upstream/inventory.md +970 -0
- xippios_cashctrl-0.1.0/docs/upstream/journal.md +724 -0
- xippios_cashctrl-0.1.0/docs/upstream/language.md +17 -0
- xippios_cashctrl-0.1.0/docs/upstream/meta.md +480 -0
- xippios_cashctrl-0.1.0/docs/upstream/order.md +1056 -0
- xippios_cashctrl-0.1.0/docs/upstream/person.md +752 -0
- xippios_cashctrl-0.1.0/docs/upstream/report.md +494 -0
- xippios_cashctrl-0.1.0/docs/upstream/salary.md +2167 -0
- xippios_cashctrl-0.1.0/pyproject.toml +107 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/__init__.py +62 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/_cli.py +57 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/_client.py +316 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/_version.py +10 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/__init__.py +75 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/_filter.py +25 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/_form.py +263 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/_params.py +20 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/account_list.py +93 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/account_read.py +28 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/currency_list.py +22 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/fiscalperiod_list.py +22 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/journal_create.py +116 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/journal_delete.py +31 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/journal_list.py +143 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/journal_read.py +31 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/journal_update.py +33 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/order_category_list.py +44 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/order_create.py +127 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/order_delete.py +30 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/order_list.py +136 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/order_read.py +30 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/person_create.py +158 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/person_delete.py +30 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/person_list.py +136 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/person_read.py +30 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/person_update.py +32 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/tax_list.py +22 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/enums.py +7 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/errors.py +55 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/http.py +17 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/mcp.py +42 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/mcp_write.py +45 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/models.py +1695 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/params.py +42 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/py.typed +0 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/reference/README.md +29 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/tasks.py +831 -0
- xippios_cashctrl-0.1.0/src/xippios/cashctrl/tasks_write.py +1627 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# environments and caches
|
|
2
|
+
.venv/
|
|
3
|
+
__pycache__/
|
|
4
|
+
*.py[cod]
|
|
5
|
+
.mypy_cache/
|
|
6
|
+
.ruff_cache/
|
|
7
|
+
.pytest_cache/
|
|
8
|
+
.hypothesis/
|
|
9
|
+
|
|
10
|
+
# build artefacts
|
|
11
|
+
dist/
|
|
12
|
+
build/
|
|
13
|
+
*.egg-info/
|
|
14
|
+
|
|
15
|
+
# local secrets are never files, but belt and braces
|
|
16
|
+
.env
|
|
17
|
+
.env.*
|
|
18
|
+
.envrc
|
|
19
|
+
.envrc.*
|
|
20
|
+
|
|
21
|
+
# editors
|
|
22
|
+
.idea/
|
|
23
|
+
.vscode/
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/) (pre-1.0: breaking changes bump the minor).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-10-26
|
|
10
|
+
|
|
11
|
+
First release. Named organisations declared in the environment, each with its own
|
|
12
|
+
client, key and rate limit, on core 0.2.0's `Tenants` (ADR-0014); twelve read tasks
|
|
13
|
+
over CashCtrl's ledger basics, journal, persons and orders, plus `list_organisations`,
|
|
14
|
+
as MCP tools and HTTP routes; eight confirm-gated write tools for journal entries,
|
|
15
|
+
persons and orders on a separate write server; fixtures and live tests against a
|
|
16
|
+
synthetic demo organisation only. It depends on `xippios-core~=0.2.0` from PyPI and is
|
|
17
|
+
built on sdk-template v0.2.0 and conventions 1.13. The last changes before the release
|
|
18
|
+
enforce ADR-0017 by the projection type (`PersonDetailProjection` drops each bank
|
|
19
|
+
account's `iban` itself), accept the `null` `attachmentCount` CashCtrl sends for a
|
|
20
|
+
journal entry an order booked, and run the live tests with short tracebacks, which keep
|
|
21
|
+
each frame's arguments, the upstream payload among them, out of the public log. Nothing
|
|
22
|
+
was published before, so the entries under Changed, Removed and Fixed record steps on
|
|
23
|
+
the way to this release; a note marked superseded names what replaced one.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- Scaffold from [sdk-template](https://github.com/xippios/sdk-template): configured
|
|
28
|
+
client for CashCtrl, empty task layer, MCP server and FastAPI router
|
|
29
|
+
wired to it, test harness, CI and publishing.
|
|
30
|
+
- Upstream documentation snapshot in `docs/upstream/`, fetched 2026-10-23: the whole
|
|
31
|
+
CashCtrl API documentation page as fifteen Markdown files, one per top-level entry of
|
|
32
|
+
its table of contents (five general sections, ten modules), listed in `FETCHED.md`.
|
|
33
|
+
- `scripts/refresh_upstream.py`: regenerates the snapshot reproducibly from the one
|
|
34
|
+
documentation page (`uv run scripts/refresh_upstream.py`).
|
|
35
|
+
- `scripts/seed_demo.py`: seeds a fresh, empty CashCtrl organisation with small
|
|
36
|
+
synthetic books (Muster Handel AG: 10 fictional companies whose names start with a
|
|
37
|
+
placeholder marker such as Muster, Beispiel or Fiktiv, 48 manual journal entries in
|
|
38
|
+
2025 and 2026, 4 sales invoices and 2 purchase orders, EUR and USD if missing), so
|
|
39
|
+
fixtures and live tests never touch real data. `--plan` (the default) prints the plan
|
|
40
|
+
without a request; `--apply --yes` first refuses, before any write, an organisation
|
|
41
|
+
whose name lacks `demo`, one with a journal entry or order in any fiscal period or a
|
|
42
|
+
person other than the insurance records CashCtrl creates itself (`createdBy` SYSTEM,
|
|
43
|
+
`isInsurance` true; a new organisation has two), and a chart of accounts without the
|
|
44
|
+
account numbers it uses, and prints the `calcType` of the tax codes it uses. It then
|
|
45
|
+
writes form-encoded with `charset=UTF-8` declared (`--multipart` for a manual rerun,
|
|
46
|
+
never an automatic fallback), persons first, reads the first one (Muster Büro AG) back
|
|
47
|
+
and stops unless its umlaut arrived byte for byte, starts requests 1.2 seconds apart
|
|
48
|
+
with the family User-Agent, and stops at the first refusal or transport error; the
|
|
49
|
+
summary, printed after a stop as well, includes the balance of 1100 at 2025-12-31 and
|
|
50
|
+
2026-09-30. It reads only `CASHCTRL_DEMO_SUBDOMAIN` and `CASHCTRL_DEMO_API_KEY`, never
|
|
51
|
+
prints either, and scrubs every upstream text it shows.
|
|
52
|
+
- `docs/demo-org.md`: why the demo organisation is synthetic and the naming rule, how to
|
|
53
|
+
create it with fictional data only (the main location included, which a new
|
|
54
|
+
organisation lacks) and seed it, the books in full, the assumed account
|
|
55
|
+
numbers and VAT codes, the balances of 1100 the books imply under a gross and a net
|
|
56
|
+
reading, and the write semantics the first seeding run observed on 2026-10-23.
|
|
57
|
+
- Organisations: `_client.py` is a registry of named organisations instead of one
|
|
58
|
+
client. `CASHCTRL_ORGS` lists them, each with `CASHCTRL_<NAME>_SUBDOMAIN` and
|
|
59
|
+
`CASHCTRL_<NAME>_API_KEY`; the reserved `demo` is declared by its own pair.
|
|
60
|
+
`client_for(org)` gives every organisation its own client (base URL, key, 60 requests
|
|
61
|
+
per minute, no shared cache); an undeclared name is an `InvalidRequestError`.
|
|
62
|
+
- `--org <name>` on `xippios-cashctrl-mcp` and `xippios-cashctrl-mcp-write` locks the
|
|
63
|
+
server to one organisation: an undeclared name or a missing or malformed subdomain
|
|
64
|
+
stops it at startup (exit status 2), and its instructions name the organisation. Both
|
|
65
|
+
servers keep httpx's request lines, which carry the subdomain, out of their log.
|
|
66
|
+
- `RequestRejected` (an `InvalidRequestError`, HTTP 422) for CashCtrl's HTTP 200
|
|
67
|
+
`success: false` refusals, with the field errors or upstream's single message
|
|
68
|
+
(`errorMessage`, else `message`). An unknown id on a `read.json` read by id (a
|
|
69
|
+
non-empty `id`, at most `lang` besides) raises `NotFoundError`, as observed against the
|
|
70
|
+
demo organisation; a 404, which upstream documents as an unknown endpoint, raises
|
|
71
|
+
`UpstreamError`. Neither status error messages nor `UpstreamError.body` name the
|
|
72
|
+
organisation's subdomain, also where an error page echoes the host: the body names
|
|
73
|
+
`cashctrl.com` instead.
|
|
74
|
+
- `scripts/record_fixture.py`: records fixtures from the demo organisation only. It
|
|
75
|
+
scrubs credentials, rewrites the demo host to `demo.cashctrl.com`, every e-mail
|
|
76
|
+
address to `user@example.com` (also written `%40` or as the HTML entities `@`,
|
|
77
|
+
`@`, `@`) and CashCtrl's API user tag in `createdBy` and `lastUpdatedBy`
|
|
78
|
+
(`API:` and the first four characters of the API user's key, observed on 2026-10-23)
|
|
79
|
+
to `API:REDACTED`, and the text value of every token-like body field (`hash`, `token`,
|
|
80
|
+
`secret`, `apiKey`, by name in any case; `order/read.json` sends `hash`, an
|
|
81
|
+
undocumented 32-character text that may be the token of a public document link) to
|
|
82
|
+
`REDACTED`, all in memory; checks the serialised fixture and the decoded strings of its
|
|
83
|
+
body for the subdomain, the key, any other e-mail address, any other API user tag in
|
|
84
|
+
any case and position, the key's first four characters on their own (a possible key
|
|
85
|
+
prefix) and any value but `null` or `REDACTED` under a token-like name (a compound
|
|
86
|
+
name such as `publicHash` goes to review, `hash` being key-like) before the one
|
|
87
|
+
write; and refuses bodies that are not UTF-8 text
|
|
88
|
+
(exports), any method but GET and any endpoint but `.json`. The demo organisation is
|
|
89
|
+
synthetic (`scripts/seed_demo.py`, `docs/demo-org.md`), so any module and any amount
|
|
90
|
+
may be recorded.
|
|
91
|
+
- Ledger basics, five read-only tasks, each an MCP tool `cashctrl_<task>` and an HTTP
|
|
92
|
+
route `/cashctrl/<task>`, all taking `org` first:
|
|
93
|
+
- `list_fiscal_periods` (`fiscalperiod/list.json`), `list_currencies`
|
|
94
|
+
(`currency/list.json`) and `list_tax_codes` (`tax/list.json`), which document no
|
|
95
|
+
parameters;
|
|
96
|
+
- `list_accounts` (`account/list.json`) with its nine documented parameters
|
|
97
|
+
(`query`, `filter`, `category_id`, `fiscal_period_id`, `only_active`,
|
|
98
|
+
`only_cost_centers`, `only_notes`, `sort`, `dir`); its HTTP route is a `POST`;
|
|
99
|
+
- `get_account` (`account/read.json`); an unknown id raises `NotFoundError`.
|
|
100
|
+
- Models mirroring the payloads: `FiscalPeriod`, `Currency`, `TaxCode` with
|
|
101
|
+
`TaxComponent` and `TaxRate`, `Account` (a `list.json` entry, with balances),
|
|
102
|
+
`AccountDetail` (the `read.json` record), the envelopes `ListResponse` and
|
|
103
|
+
`ReadResponse`, and `Filter` for the `filter` parameter. Amounts and rates are
|
|
104
|
+
`Decimal`, dates `date`, timestamps naive `datetime` (`lastEntryDate`, undocumented
|
|
105
|
+
and seen with a time of day, stays a timestamp). Upstream's camelCase spellings are
|
|
106
|
+
read with `Field(validation_alias=...)`, so every result, in Python, over MCP and
|
|
107
|
+
over HTTP, carries the snake_case names the tool arguments use (`is_current`,
|
|
108
|
+
`end_amount`), checked for every key of every tool and route result; `meta.request`
|
|
109
|
+
keeps the parameters as sent, under upstream's names.
|
|
110
|
+
- `dir` and `Filter.comparison` are typed as their documented value sets, `Direction`
|
|
111
|
+
(`ASC`, `DESC`) and `Comparison` (`eq`, `like`, `gt`, `lt`), so the tool schema lists
|
|
112
|
+
them and the adapters refuse another value before the task runs.
|
|
113
|
+
- `list_accounts` takes each filter as a `Filter` or as a mapping with the same keys,
|
|
114
|
+
and refuses, with `InvalidRequestError` and before any request, a `dir` other than
|
|
115
|
+
`ASC` or `DESC`, a filter `comparison` other than `eq`, `like`, `gt` or `lt`, a
|
|
116
|
+
`filter` that is not a list, an item that is neither a `Filter` nor a mapping that
|
|
117
|
+
validates as one, and a `query` or `sort` that is not a string: upstream would
|
|
118
|
+
silently ignore them.
|
|
119
|
+
- Nine fixtures recorded on 2026-10-23 from the synthetic demo organisation
|
|
120
|
+
(`scripts/seed_demo.py`, `docs/demo-org.md`), with e-mail addresses and API user tags
|
|
121
|
+
scrubbed: the fiscal periods, currencies and tax codes whole; the accounts narrowed
|
|
122
|
+
twice from CashCtrl's default chart (number `1020`; active accounts matching
|
|
123
|
+
`Finanz`), since the 139 accounts are too many for one fixture; account 1020 read by
|
|
124
|
+
the id the filtered list gave; an unknown id, a missing id and a wrong key. Every
|
|
125
|
+
recorded list holds its whole `total`.
|
|
126
|
+
- Journal, persons and orders, six read-only tasks, each an MCP tool `cashctrl_<task>`
|
|
127
|
+
and an HTTP route `/cashctrl/<task>`, all taking `org` first:
|
|
128
|
+
- `list_journal_entries` (`journal/list.json`) with its eighteen documented
|
|
129
|
+
parameters (`account_id`, `associate_id`, `dir`, `filter`, `fiscal_period_id`,
|
|
130
|
+
`limit`, the nine `only_*` flags, `query`, `sort`, `start`), and
|
|
131
|
+
`get_journal_entry` (`journal/read.json`) with the lines of a collective entry, the
|
|
132
|
+
bookings per account, tax rows apart, and the persons the entry names;
|
|
133
|
+
- `list_persons` (`person/list.json`) with its seventeen documented parameters
|
|
134
|
+
(`category_id`, `dir`, `filter`, `limit`, the ten `only_*` flags, `query`, `sort`,
|
|
135
|
+
`start`), and `get_person` (`person/read.json`) with addresses and contacts;
|
|
136
|
+
- `list_orders` (`order/list.json`) with its sixteen documented parameters
|
|
137
|
+
(`category_id`, `dir`, `filter`, `fiscal_period_id`, `group_id`, `limit`,
|
|
138
|
+
`only_cost_centers`, `only_notes`, `only_open`, `only_overdue`, `person_id`,
|
|
139
|
+
`query`, `sort`, `start`, `status_id`, `type`), `type` typed as `SALES` or
|
|
140
|
+
`PURCHASE` and optional (both types without it, observed), and `get_order`
|
|
141
|
+
(`order/read.json`) with its items.
|
|
142
|
+
The three lists are `POST` routes (they take `filter`); an unknown id on a read raises
|
|
143
|
+
`NotFoundError` (observed for the journal: "This book entry does not exist.").
|
|
144
|
+
- Offset pagination for the three lists (conventions 1.7 §4, ADR-0012), in one helper:
|
|
145
|
+
with `limit`, one request for the page at `start` (default 0); without it, every page
|
|
146
|
+
from `start` on through core's `paginate_offset`, 100 records a request (upstream's
|
|
147
|
+
documented default, sent explicitly), ending after a short page or, without a further
|
|
148
|
+
request, once the next offset reaches upstream's `total`. `meta.total` is upstream's
|
|
149
|
+
`total`, the page's or the last page's, never computed from `len(data)`. `meta.request`
|
|
150
|
+
holds the parameters as sent, `limit` left out when every page is collected. A
|
|
151
|
+
`start` below 0, a `limit` below 1, either not a whole number, and an unknown `type`
|
|
152
|
+
are refused with `InvalidRequestError` before any request.
|
|
153
|
+
- `meta.total` on the four other list tasks as well: upstream's `total`, which equals
|
|
154
|
+
the records of their one answer.
|
|
155
|
+
- Models: `JournalEntry` and `JournalEntryDetail` with `JournalItem` (the lines of a
|
|
156
|
+
collective entry) and `JournalRow` (the bookings per account), `Person` and
|
|
157
|
+
`PersonDetail` with `PersonAddress` and `PersonContact`, `Order` and `OrderDetail`
|
|
158
|
+
with `OrderItem`, and `OrderType`. Every observed field, typed from the create and
|
|
159
|
+
update tables where they describe it and from the recorded answers otherwise
|
|
160
|
+
(each model's docstring names which); amounts `Decimal`, documented dates `date`,
|
|
161
|
+
undocumented timestamps `datetime`, fields only ever seen empty kept as sent. A read
|
|
162
|
+
differs from its list entry, and `person/read.json` leaves out null fields.
|
|
163
|
+
- Fourteen fixtures recorded on 2026-10-23 from the synthetic demo organisation: the
|
|
164
|
+
2025 journal in pages of five (`total` 12: 5, 5 and 2 records) and the page past the
|
|
165
|
+
end (empty, `total` 12), the first five 2026 entries (`total` 38), a taxed and a
|
|
166
|
+
collective entry read and an unknown id; all twelve persons (the ten seeded and
|
|
167
|
+
CashCtrl's two insurance records) and the four vendors; the four 2026 sales and two
|
|
168
|
+
purchase orders and one order read, re-recorded after the token field scrub so its
|
|
169
|
+
`hash` is `REDACTED`.
|
|
170
|
+
- `list_organisations`, the MCP tool `cashctrl_list_organisations` and the route
|
|
171
|
+
`GET /cashctrl/list_organisations` (ADR-0014): the declared organisations' names,
|
|
172
|
+
nothing else, without a request; core's `list_task` over the registry. It stays off the
|
|
173
|
+
write server; on a server locked with `--org` it lists that organisation only.
|
|
174
|
+
- `meta.tenant` on every task's result: the organisation the data came from, by name
|
|
175
|
+
(`ResultMeta.tenant`, ADR-0014); `org` stays in `meta.request` too. `None` for
|
|
176
|
+
`list_organisations`.
|
|
177
|
+
- Write tools, eight confirm-gated tasks in `tasks_write.py`, re-exported from
|
|
178
|
+
`xippios.cashctrl` and served by `xippios-cashctrl-mcp-write` only (never by the read
|
|
179
|
+
server, never as an HTTP route): `create_journal_entry`, `update_journal_entry`,
|
|
180
|
+
`delete_journal_entry` (`journal/create.json`, `update.json`, `delete.json`),
|
|
181
|
+
`create_person`, `update_person`, `delete_person` (`person/...`), `create_order` and
|
|
182
|
+
`delete_order` (`order/create.json`, `delete.json`). Decided by Johannes in the wave 2
|
|
183
|
+
interview on 2026-10-23, and in the follow-up on 2026-10-24:
|
|
184
|
+
- every create and update takes exactly the parameters its create table documents,
|
|
185
|
+
snake_cased, `custom` among them, and `confirm: bool = False` last;
|
|
186
|
+
- while `confirm` is false nothing is written: the result is a `WritePreview`
|
|
187
|
+
(`performed` false) with the exact form `payload` upstream would receive and, for
|
|
188
|
+
an update or a delete, the record as it is (`before`), for an update next to the
|
|
189
|
+
`changes`; a create's preview sends no request, an update's or a delete's one read.
|
|
190
|
+
With `confirm` the result is a `WriteResult` (`performed` true, `insert_id`,
|
|
191
|
+
`message`). Results are `Result[WritePreview[<record>] | WriteResult]`;
|
|
192
|
+
`meta.request` holds `org` and, for a performed write, the fields as sent;
|
|
193
|
+
- updates fail closed: they read the record, apply the arguments given and send every
|
|
194
|
+
parameter `update.json` documents, derived from the record, and refuse with
|
|
195
|
+
`InvalidRequestError` before any write when a mandatory parameter cannot be derived
|
|
196
|
+
or a stored list of a shape never observed (attachments, allocations, bank accounts,
|
|
197
|
+
children, service periods, insurance contracts, certificate values) would be lost;
|
|
198
|
+
- `custom` maps `customField<ID>` names to text, sent as upstream's
|
|
199
|
+
`<values>...</values>` XML with every value escaped; an update keeps the stored
|
|
200
|
+
values it is not given;
|
|
201
|
+
- deletes take one id and preview the record they would delete;
|
|
202
|
+
- the gate is `confirm is True`: from Python anything else (`"false"`, `1`, `[0]`)
|
|
203
|
+
previews; over MCP the schema turns `"true"`, `"yes"`, `"1"`, `1` into true and
|
|
204
|
+
`"false"`, `"no"`, `"0"`, `0` into false and refuses anything else;
|
|
205
|
+
- `update_journal_entry` and `update_person` take `clear`, the parameters to send
|
|
206
|
+
empty, checked against the documented update parameters (an unknown name, or one
|
|
207
|
+
both given and cleared, is refused before any request) and shown in `changes`,
|
|
208
|
+
where a list given empty shows as `[]`;
|
|
209
|
+
- journal `associate_id` (entry and lines) is a person's id; a name, which upstream
|
|
210
|
+
would turn into a new person, is refused;
|
|
211
|
+
- `update_journal_entry` refuses an entry an order or a salary run booked
|
|
212
|
+
(`orderId`, `orderBookEntryId` or `salaryBookEntryId` set);
|
|
213
|
+
- `update_person` follows what a live read showed on 2026-10-24: every stored
|
|
214
|
+
documented field comes back under its own name, empty ones left out; an
|
|
215
|
+
insurance's `insuranceNr` and `insuranceMemberNr` and an employee's `userId`, never
|
|
216
|
+
seen come back, must be given or cleared; a stored address `titleId` or contact
|
|
217
|
+
`purpose`, which the tables do not document, refuses the update unless the list is
|
|
218
|
+
given or cleared;
|
|
219
|
+
- `update_order` is left for a later wave; a delete of an unknown id stays a
|
|
220
|
+
`RequestRejected` (for a journal entry the read now comes first, see Changed);
|
|
221
|
+
custom field names stay `customField<ID>`.
|
|
222
|
+
- `list_order_categories` (`order/category/list.json`, with `type` and
|
|
223
|
+
`fiscal_period_id`), a read tool `cashctrl_list_order_categories` and route
|
|
224
|
+
`GET /cashctrl/list_order_categories`: the categories and their statuses, the ids
|
|
225
|
+
`create_order` takes. Models `OrderCategory`, `OrderCategoryStatus`,
|
|
226
|
+
`OrderBookTemplate`, typed from the category tables and the recorded answer.
|
|
227
|
+
- `api/_form.py`: the one form encoding (booleans `true`/`false`, `Decimal` as plain
|
|
228
|
+
digits, dates `YYYY-MM-DD`, lists as compact JSON under upstream's keys, `None` and
|
|
229
|
+
empty lists left out), the custom field XML, and `post_form`, which posts through
|
|
230
|
+
core's `data=` with `charset=UTF-8` declared, as the seeder did. Write wrappers
|
|
231
|
+
`journal_create`, `journal_update`, `journal_delete`, `person_create`,
|
|
232
|
+
`person_update`, `person_delete`, `order_create`, `order_delete` with the field
|
|
233
|
+
builders `journal_fields`, `person_fields`, `order_fields` and `delete_fields`, and
|
|
234
|
+
`order_category_list`.
|
|
235
|
+
- Models for the write payloads, snake_case with upstream's camelCase accepted and
|
|
236
|
+
written to the form (`JournalItemInput`, `OrderItemInput`, `PersonAddressInput`,
|
|
237
|
+
`PersonContactInput`, `AllocationInput`, `AttachmentInput`, `BankAccountInput`,
|
|
238
|
+
`ChildInput`, `InsuranceContractInput`, `ServicePeriodInput`; unknown keys refused),
|
|
239
|
+
the documented value sets as `Literal` types (`NotifyType`, `Recurrence`,
|
|
240
|
+
`Language`, `Color`, `AddressType`, `Canton`, `ContactType`, `BankAccountType`,
|
|
241
|
+
`OrderItemType`), `WriteAnswer` for upstream's `{success, insertId, message}`, and
|
|
242
|
+
`WritePreview` and `WriteResult`. `PersonDetail.certificate_values`.
|
|
243
|
+
- The recorder records writes against the demo organisation: `--method POST` for the
|
|
244
|
+
eight write paths the write tools call only (`journal/create|update|delete.json`,
|
|
245
|
+
`person/create|update|delete.json`, `order/create|delete.json`; every other POST and
|
|
246
|
+
method refused before a request), and a `create.json` only when one of its values
|
|
247
|
+
carries `xippios-live`; the `key=value` pairs are sent as the form body in their
|
|
248
|
+
order; it then says that the organisation changed and asks for the matching delete.
|
|
249
|
+
Its rewrite and final check cover the request body as well (raw, each form field
|
|
250
|
+
decoded, each JSON value's strings).
|
|
251
|
+
- Thirteen fixtures recorded on 2026-10-24 from the synthetic demo organisation, every
|
|
252
|
+
record marked `xippios-live` and deleted again: the sales order categories; a journal
|
|
253
|
+
entry created (1020/3200, 100.00 with `UN1`), a create refused without `debitId`
|
|
254
|
+
(the first recorded field-error list), the entry read back, updated (title changed,
|
|
255
|
+
the whole record sent), deleted, and deleted again; a person created (an address in
|
|
256
|
+
Zürich, one contact), read back, updated and deleted; a sales invoice with one item
|
|
257
|
+
created and deleted.
|
|
258
|
+
- Live tests for the write tools: one per resource that creates a marked record,
|
|
259
|
+
updates it (journal, person; previewed first, read back after, every other derived
|
|
260
|
+
field unchanged) and deletes it with the tools, after sweeping marked leftovers (by
|
|
261
|
+
marked text or `nr`); and one for `list_order_categories`.
|
|
262
|
+
- Two fixtures recorded on 2026-10-26 from the demo organisation, GET only: the 2026
|
|
263
|
+
journal with `onlyPurchase` (`journal_list/order_booked`, the two entries the seeded
|
|
264
|
+
purchase orders booked) and the read of one of them (`journal_read/order_booked`).
|
|
265
|
+
- A read-only live check: the delete of a journal entry an order booked, found with
|
|
266
|
+
`only_purchase`, is asked for without `confirm` and refused before any preview; a
|
|
267
|
+
delete is never attempted. Two calls more, 42 a run.
|
|
268
|
+
|
|
269
|
+
### Changed
|
|
270
|
+
|
|
271
|
+
- Pre-release leftovers (decided by Johannes on 2026-10-26):
|
|
272
|
+
- `PersonDetailProjection` drops the `iban` of each of `bank_accounts` itself, the
|
|
273
|
+
key in any case, with a field validator, however the projection is built:
|
|
274
|
+
`PersonDetailProjection.project(person)` carries no IBAN without help from the
|
|
275
|
+
task, and the task's override in `tasks.py` is gone (ADR-0017, enforced by the
|
|
276
|
+
type). The read model `PersonDetail` is unchanged.
|
|
277
|
+
- `PersonAddress.title_id` is `int | None`, the id of a title, where it was kept as
|
|
278
|
+
sent: `titleId` is "optional, NUMBER", a person title, whose `id` is "mandatory,
|
|
279
|
+
NUMBER" (`docs/upstream/person.md`, lines 314 and 683).
|
|
280
|
+
- The dev group pins beautifulsoup4 (`==4.15.0`) and markdownify (`==1.2.3`) to the
|
|
281
|
+
versions `scripts/refresh_upstream.py` pins in its PEP 723 block, where it took
|
|
282
|
+
them with `>=`.
|
|
283
|
+
- The nightly `live.yml` and the live command in CLAUDE.md run pytest with
|
|
284
|
+
`--tb=short`: the long traceback prints each frame's arguments, so a failed parse
|
|
285
|
+
would print the upstream payload, API user tags included, into the public log.
|
|
286
|
+
- Adopted xippios-core 0.2.0, sdk-template v0.2.0 and conventions 1.13 (conventions §15).
|
|
287
|
+
- `xippios-core~=0.2.0` from PyPI, and its `mcp`, `http` and `testing` extras
|
|
288
|
+
likewise: one core minor (conventions §12, ADR-0018). The git pin of sdk-core
|
|
289
|
+
`863a443` is gone, `core_git_ref` is empty, and the pin's
|
|
290
|
+
`# pragma: allowlist secret` with it.
|
|
291
|
+
- Personal identifiers stay out of what the tasks return (ADR-0017, decided by
|
|
292
|
+
Johannes on 2026-10-25: `ssn` and `iban`). `list_persons`, `get_person` and
|
|
293
|
+
`get_journal_entry` return `PersonProjection`, `PersonDetailProjection` and
|
|
294
|
+
`JournalEntryDetailProjection`: no `ssn`, no `iban`, and each bank account without
|
|
295
|
+
its `iban`, its other keys kept. The endpoint wrappers' `Person`, `PersonDetail`
|
|
296
|
+
and `JournalEntryDetail` keep both. A write preview's `before` is the projection;
|
|
297
|
+
`create_person` and `update_person` send `ssn` and an `iban` as given, but a
|
|
298
|
+
preview's `payload` and `changes` and a performed write's `meta.request` show
|
|
299
|
+
`REDACTED` (a cleared `ssn` shows empty). The recorder rewrites both to `REDACTED`
|
|
300
|
+
by name and refuses a write that sends either, inside a JSON form value too; the
|
|
301
|
+
fixture grep counts their values.
|
|
302
|
+
- The pace keeps 60 requests per minute and gets a burst of 1, where it allowed 60 at
|
|
303
|
+
once (ADR-0010 decision 1). No `QuotaPolicy` is installed: CashCtrl documents no
|
|
304
|
+
quota and no window (CLAUDE.md, Rate limit). A malformed
|
|
305
|
+
`CASHCTRL_<NAME>_SUBDOMAIN` is refused with what it should hold (`Tenants(hints=)`).
|
|
306
|
+
- `test_replay_tests_send_the_placeholder_key` and its helpers are gone: core's plugin
|
|
307
|
+
pins the key on every auth object and proves it (ADR-0013). The key-resolution test
|
|
308
|
+
moved to `tests/test_key_resolution.py`, whose module-level `placeholder_api_key`
|
|
309
|
+
pins nothing.
|
|
310
|
+
- From the template: `/adopt-release`; the nightly `.github/workflows/live.yml`, its
|
|
311
|
+
pytest step given `CASHCTRL_DEMO_SUBDOMAIN` and `CASHCTRL_DEMO_API_KEY` instead of
|
|
312
|
+
the rendered `CASHCTRL_API_KEY`; pre-commit in CI; `publish.yml` refusing a
|
|
313
|
+
git-pinned core; an sdist without tests; `MCP_CAPS` passed through by both servers
|
|
314
|
+
(none set); `mypy --strict` over `scripts/`, with beautifulsoup4 and markdownify in
|
|
315
|
+
the dev group for `refresh_upstream.py`; `scripts/refresh_reference.py
|
|
316
|
+
--render-only`, and `enums.py` re-rendered with it (its first line); the command
|
|
317
|
+
texts and CLAUDE.md rows that go with them.
|
|
318
|
+
- `scripts/refresh_upstream.py` writes `Fetched` as the UTC time with `Z` and a note
|
|
319
|
+
per file kept with HTML tables (conventions §3); the committed `FETCHED.md` keeps
|
|
320
|
+
the date of the first fetch until the next refresh.
|
|
321
|
+
- Write tools, after the wave 2 review (decided by Johannes on 2026-10-24):
|
|
322
|
+
- `clear` on a flag (`is_customer`, `is_inactive` and the other boolean update
|
|
323
|
+
parameters of `update_person`) sends `false`, and the preview's `changes` shows
|
|
324
|
+
`"false"` instead of an empty string: clearing a yes/no value means false.
|
|
325
|
+
`clear=["sequence_number_id"]`, a parameter that is not stored and sent only when
|
|
326
|
+
given, was a silent no-op; it is refused before any request with a hint to drop it.
|
|
327
|
+
- `delete_journal_entry` reads the entry with `confirm=true` as well (one more
|
|
328
|
+
request), and both the preview and the confirmed delete refuse, with
|
|
329
|
+
`InvalidRequestError` before any write, an entry whose record carries `orderId`,
|
|
330
|
+
`orderBookEntryId` or `salaryBookEntryId`, as `update_journal_entry` does: upstream
|
|
331
|
+
documents that such entries cannot be deleted here, and its answer to such a
|
|
332
|
+
delete was never observed. A confirmed delete of an unknown id is now
|
|
333
|
+
`NotFoundError` from the read; `RequestRejected` remains for an entry deleted
|
|
334
|
+
between the read and the delete. The live journal test makes one call more (40 a
|
|
335
|
+
run).
|
|
336
|
+
- The recorder's e-mail scrub no longer takes percent-escapes into an address's local
|
|
337
|
+
part: in the form body `contacts=%5B%7B%22address%22%3A%22user%40example.com...` it
|
|
338
|
+
read `%5B%7B%22address%22%3A%22user` as the local part and wrote a body other than
|
|
339
|
+
the one sent; now only the address is rewritten (found recording
|
|
340
|
+
`person_create/ok`, whose first recording was replaced).
|
|
341
|
+
- `classify` documents the refusal shapes the writes showed on 2026-10-24: the
|
|
342
|
+
documented `errors` list with `"message": null`, and a single `message` (not
|
|
343
|
+
`errorMessage`) for a delete of an id that no longer exists; both stay
|
|
344
|
+
`RequestRejected`.
|
|
345
|
+
- `xippios-core` is pinned to sdk-core commit `863a443` (`core_git_ref` in
|
|
346
|
+
`.copier-answers.yml`, `[tool.uv.sources]` in `pyproject.toml`), the unreleased merge
|
|
347
|
+
of sdk-core#17, for the tenant registry, the tenant lock and the option hooks
|
|
348
|
+
(ADR-0014), the plugin's offline guarantees (ADR-0013), `Scrub`, `ResultMeta.total`
|
|
349
|
+
(ADR-0012) and `ResultMeta.tenant`; before, `dbe2eba`. It is re-pinned at core 0.2.0.
|
|
350
|
+
This package releases only after core 0.2.0 is on PyPI. Removing the pin alone would
|
|
351
|
+
let core 0.1.0 back in, which has none of these, names the tenant host in status error
|
|
352
|
+
messages and ignores `total`, so the same change raises `xippios-core` and its three
|
|
353
|
+
extras to `>=0.2.0` and deletes the pin's `# pragma: allowlist secret` comment by hand.
|
|
354
|
+
The bound cannot be raised yet: core main still reports 0.1.0 (superseded: core 0.2.0
|
|
355
|
+
from PyPI, `~=0.2.0`, see the adoption above).
|
|
356
|
+
- Adopted from that core: status error messages name the method and the path, never the
|
|
357
|
+
host (`404 from GET /api/v1/account/nope.json: ...`, sdk-core#14), so `classify` no
|
|
358
|
+
longer rewrites the request URL; it still hands core a copy of the response whose body
|
|
359
|
+
names `cashctrl.com`, because core quotes the body in the message and keeps it as
|
|
360
|
+
`UpstreamError.body`. Core's `build_server` and `main` set the `httpx` and `httpcore`
|
|
361
|
+
loggers to WARNING (sdk-core#12), so the local `quiet_request_logging` is gone.
|
|
362
|
+
- `api/_params.py` encodes booleans and drops unset parameters once, for every list
|
|
363
|
+
wrapper.
|
|
364
|
+
- chore: update to sdk-template v0.1.5.
|
|
365
|
+
- Hygiene files: `.gitattributes` keeps `docs/upstream/` byte-exact, `.gitignore`
|
|
366
|
+
covers `.env.*` and `.envrc.*`, the secrets baseline skips `.copier-answers.yml`.
|
|
367
|
+
- Command texts: `/add-endpoint`, `/refresh-reference-data` and `/refresh-upstream`
|
|
368
|
+
(which now runs `scripts/refresh_upstream.py` where it exists), the MCP server
|
|
369
|
+
instructions and the `reference/README.md` wording follow the template.
|
|
370
|
+
- Placeholder-key test: every test but a live one sends the placeholder key, pinned on
|
|
371
|
+
`CASHCTRL_API_KEY` and on `auth.key` (now in `_client.__all__`), and
|
|
372
|
+
`test_replay_tests_send_the_placeholder_key` checks the basic auth user name
|
|
373
|
+
(superseded by the registry, see Removed below).
|
|
374
|
+
- `validation_alias` rule for the models to come: upstream spellings are accepted with
|
|
375
|
+
`Field(validation_alias=...)`, never `alias=` (`models.py` docstring, `/add-endpoint`).
|
|
376
|
+
- No reference data is packaged: CashCtrl's lists (accounts, currencies, tax codes,
|
|
377
|
+
fiscal periods) are per-organisation data, served live by the list tasks. Only value
|
|
378
|
+
sets the documentation fixes may ship, generated from `docs/upstream/`; none is needed
|
|
379
|
+
yet, so `scripts/refresh_reference.py` is a stub that exits non-zero.
|
|
380
|
+
- The placeholder-key test follows the registry:
|
|
381
|
+
`test_replay_tests_send_the_placeholder_key` sends a request through
|
|
382
|
+
`client_for("demo")` and checks that the basic auth user name is the placeholder and
|
|
383
|
+
that the shell's `CASHCTRL_DEMO_API_KEY` is nowhere in the request. Instead of pinning
|
|
384
|
+
a module-level `auth.key`, `tests/conftest.py` drops every cached client before and
|
|
385
|
+
after each test, and a `pytester` run of the conftest proves that a key a live test
|
|
386
|
+
cached never reaches the next offline test (and that it would without the reset)
|
|
387
|
+
(superseded by core's class-level pin, see Removed below).
|
|
388
|
+
- Rate limit, ADR-0010 decisions 1, 2, 3 and 5 adopted (CLAUDE.md, Rate limit): 60
|
|
389
|
+
requests per minute per organisation, a self-imposed pace since CashCtrl documents no
|
|
390
|
+
limit; no longer-window quota to ration; a 429 is `RateLimitError` with `retry_after`
|
|
391
|
+
from `Retry-After` through core's mapping in `classify`, now tested through the client;
|
|
392
|
+
pauses are stated by core's adapters. `x-cashctrl-requests-left` is not read (unknown
|
|
393
|
+
window and scope); decision 4 waits for core's quota helper (superseded: no
|
|
394
|
+
QuotaPolicy, decided 2026-10-25, see the core 0.2.0 adoption entry).
|
|
395
|
+
- Adopted from core (ADR-0014): the organisation registry is `xippios.core.Tenants`,
|
|
396
|
+
`TENANTS` in `_client.py`, with the same variables (`CASHCTRL_ORGS`,
|
|
397
|
+
`CASHCTRL_<NAME>_SUBDOMAIN`, `CASHCTRL_<NAME>_API_KEY`, the reserved `demo`) and the
|
|
398
|
+
one-label subdomain check, lower-cased, as `check_subdomain`; `client_for(org)` is
|
|
399
|
+
`TENANTS.client_for(org, ...)`. Messages read "unknown tenant" and "declared tenants"
|
|
400
|
+
for an undeclared name; a malformed `CASHCTRL_ORGS` entry is named by its position,
|
|
401
|
+
never quoted; a refused subdomain is "does not hold a valid SUBDOMAIN". A changed key
|
|
402
|
+
now builds a new client, as a changed subdomain did.
|
|
403
|
+
- Adopted from core: `--org` on both servers is core's `TenantLock`, reached through
|
|
404
|
+
`main`'s `add_options` and `apply_options` (`_cli.py`), the command line parsed once.
|
|
405
|
+
Three behaviours follow core: a call that leaves `org` out of a tool that takes it is
|
|
406
|
+
refused by the lock (`... the call did not name the tenant`) instead of by the tool's
|
|
407
|
+
argument validation; a refused call reads `error: InvalidRequestError: this server is
|
|
408
|
+
locked to org=<name>; the call named another tenant`; the instructions end with "This
|
|
409
|
+
server is locked to org <name>; every tool call that takes org must pass org=<name>."
|
|
410
|
+
Core also serves the write server over `--http` on a loopback `--host` only, unless
|
|
411
|
+
`--allow-remote` is passed.
|
|
412
|
+
- Adopted from core (conventions §10): the recorder scrubs with
|
|
413
|
+
`Scrub(host="demo.cashctrl.com", secrets=[key, its basic-auth token],
|
|
414
|
+
rewrite=scrub_text, allow_binary=False)` through `fixture_from_response`. A key in a
|
|
415
|
+
response is now written as `REDACTED` instead of refusing the fixture, and the fixture
|
|
416
|
+
goes to review (exit status 1, a note naming the variable); core's verify refuses a key
|
|
417
|
+
the scrub could not reach (behind a JSON escape); core refuses a body that is not UTF-8
|
|
418
|
+
text with its own message, which the recorder prefixes with "exports are out of scope
|
|
419
|
+
for fixtures;". The e-mail, API user tag and
|
|
420
|
+
token-field scrubs stay local, as `scrub_text`, which the recorder also applies to the
|
|
421
|
+
request URL, since core's rewrite does not reach it; the final check keeps the bare
|
|
422
|
+
subdomain, e-mail addresses, API user tags, the key prefix and token-like values, and
|
|
423
|
+
runs before the one write, which stays the recorder's own.
|
|
424
|
+
- Adopted from core (ADR-0013): the plugin keeps the run offline and sends the
|
|
425
|
+
placeholder key from every auth object; `CASHCTRL_DEMO_API_KEY` is named in
|
|
426
|
+
`xippios_placeholder_key_envs`. `tests/conftest.py` keeps the demo-only handling: the
|
|
427
|
+
`CASHCTRL_*` strip at import, `CASHCTRL_ORGS` removed per test, the placeholder
|
|
428
|
+
subdomain and `TENANTS.reset()` around each test. Tests that resolve keys themselves
|
|
429
|
+
override `placeholder_api_key`. `mcp_call` goes through an in-process `mcp.Client`, so
|
|
430
|
+
a schema refusal fails it with `AssertionError` instead of `ToolError`.
|
|
431
|
+
|
|
432
|
+
### Removed
|
|
433
|
+
|
|
434
|
+
- `_org_lock.py` (`org_lock`, `announce_lock`, `parse_org` and its `main`), replaced by
|
|
435
|
+
core's `TenantLock` and option hooks; and from `_client.py` `orgs()`, `require_org()`,
|
|
436
|
+
`reset_clients()`, `subdomain_env()`, `api_key_env()`, `ORGS_ENV` and the local client
|
|
437
|
+
cache, replaced by `TENANTS.names()`, `require()`, `reset()`, `var()` and `list_var`.
|
|
438
|
+
Nothing was released, so no compatibility names remain.
|
|
439
|
+
- The `pytester` proof that a key a live test cached never reaches the next offline
|
|
440
|
+
test, and its `pytester` plugin: core's class-level placeholder pin guarantees it and
|
|
441
|
+
core's own tests prove it.
|
|
442
|
+
- The scaffold's single client (`client`, `auth`, `config`, `BASE_URL` and `API_KEY_ENV`
|
|
443
|
+
in `_client.py`) and its `CASHCTRL_API_KEY` variable, replaced by the organisation
|
|
444
|
+
registry above.
|
|
445
|
+
|
|
446
|
+
### Fixed
|
|
447
|
+
|
|
448
|
+
- The `PersonAddress` docstring said `title_id` was only seen as `null`; an address
|
|
449
|
+
`titleId` was stored and read back in the live verification of 2026-10-24
|
|
450
|
+
(`docs/demo-org.md`, Person reads). It is in neither `addresses` table of
|
|
451
|
+
`person/create.json` or `person/update.json`, but reads return it.
|
|
452
|
+
- The write tasks showed the `iban` of an item of `bankAccounts` as `REDACTED` only
|
|
453
|
+
under the key `iban` exactly; the key is now matched in any case, as the projection
|
|
454
|
+
and the recorder match it (ADR-0017).
|
|
455
|
+
- A journal entry an order booked (`type` `ORDER`, `orderId` set) sends
|
|
456
|
+
`attachmentCount` `null` in the list and the read (seen in the demo organisation on
|
|
457
|
+
2026-10-26), where `JournalEntry` and `JournalEntryDetail` required a number:
|
|
458
|
+
`list_journal_entries` failed with `ValidationError` on any page holding one (the
|
|
459
|
+
whole 2026 journal of the demo organisation, for one), and `get_journal_entry`,
|
|
460
|
+
`update_journal_entry` and `delete_journal_entry` failed at their read, before the
|
|
461
|
+
booked-entry refusal. `attachment_count` is now `int | None`.
|