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.
Files changed (62) hide show
  1. xippios_cashctrl-0.1.0/.gitignore +23 -0
  2. xippios_cashctrl-0.1.0/CHANGELOG.md +461 -0
  3. xippios_cashctrl-0.1.0/LICENSE +201 -0
  4. xippios_cashctrl-0.1.0/PKG-INFO +317 -0
  5. xippios_cashctrl-0.1.0/README.md +288 -0
  6. xippios_cashctrl-0.1.0/docs/demo-org.md +490 -0
  7. xippios_cashctrl-0.1.0/docs/upstream/FETCHED.md +67 -0
  8. xippios_cashctrl-0.1.0/docs/upstream/account.md +974 -0
  9. xippios_cashctrl-0.1.0/docs/upstream/authentication.md +25 -0
  10. xippios_cashctrl-0.1.0/docs/upstream/common.md +797 -0
  11. xippios_cashctrl-0.1.0/docs/upstream/error-handling.md +44 -0
  12. xippios_cashctrl-0.1.0/docs/upstream/examples.md +161 -0
  13. xippios_cashctrl-0.1.0/docs/upstream/file.md +411 -0
  14. xippios_cashctrl-0.1.0/docs/upstream/introduction.md +17 -0
  15. xippios_cashctrl-0.1.0/docs/upstream/inventory.md +970 -0
  16. xippios_cashctrl-0.1.0/docs/upstream/journal.md +724 -0
  17. xippios_cashctrl-0.1.0/docs/upstream/language.md +17 -0
  18. xippios_cashctrl-0.1.0/docs/upstream/meta.md +480 -0
  19. xippios_cashctrl-0.1.0/docs/upstream/order.md +1056 -0
  20. xippios_cashctrl-0.1.0/docs/upstream/person.md +752 -0
  21. xippios_cashctrl-0.1.0/docs/upstream/report.md +494 -0
  22. xippios_cashctrl-0.1.0/docs/upstream/salary.md +2167 -0
  23. xippios_cashctrl-0.1.0/pyproject.toml +107 -0
  24. xippios_cashctrl-0.1.0/src/xippios/cashctrl/__init__.py +62 -0
  25. xippios_cashctrl-0.1.0/src/xippios/cashctrl/_cli.py +57 -0
  26. xippios_cashctrl-0.1.0/src/xippios/cashctrl/_client.py +316 -0
  27. xippios_cashctrl-0.1.0/src/xippios/cashctrl/_version.py +10 -0
  28. xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/__init__.py +75 -0
  29. xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/_filter.py +25 -0
  30. xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/_form.py +263 -0
  31. xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/_params.py +20 -0
  32. xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/account_list.py +93 -0
  33. xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/account_read.py +28 -0
  34. xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/currency_list.py +22 -0
  35. xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/fiscalperiod_list.py +22 -0
  36. xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/journal_create.py +116 -0
  37. xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/journal_delete.py +31 -0
  38. xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/journal_list.py +143 -0
  39. xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/journal_read.py +31 -0
  40. xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/journal_update.py +33 -0
  41. xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/order_category_list.py +44 -0
  42. xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/order_create.py +127 -0
  43. xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/order_delete.py +30 -0
  44. xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/order_list.py +136 -0
  45. xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/order_read.py +30 -0
  46. xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/person_create.py +158 -0
  47. xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/person_delete.py +30 -0
  48. xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/person_list.py +136 -0
  49. xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/person_read.py +30 -0
  50. xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/person_update.py +32 -0
  51. xippios_cashctrl-0.1.0/src/xippios/cashctrl/api/tax_list.py +22 -0
  52. xippios_cashctrl-0.1.0/src/xippios/cashctrl/enums.py +7 -0
  53. xippios_cashctrl-0.1.0/src/xippios/cashctrl/errors.py +55 -0
  54. xippios_cashctrl-0.1.0/src/xippios/cashctrl/http.py +17 -0
  55. xippios_cashctrl-0.1.0/src/xippios/cashctrl/mcp.py +42 -0
  56. xippios_cashctrl-0.1.0/src/xippios/cashctrl/mcp_write.py +45 -0
  57. xippios_cashctrl-0.1.0/src/xippios/cashctrl/models.py +1695 -0
  58. xippios_cashctrl-0.1.0/src/xippios/cashctrl/params.py +42 -0
  59. xippios_cashctrl-0.1.0/src/xippios/cashctrl/py.typed +0 -0
  60. xippios_cashctrl-0.1.0/src/xippios/cashctrl/reference/README.md +29 -0
  61. xippios_cashctrl-0.1.0/src/xippios/cashctrl/tasks.py +831 -0
  62. 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 `&#64;`,
77
+ `&#x40;`, `&commat;`) 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`.