runningtab 0.1.0.dev3__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 (52) hide show
  1. runningtab/__init__.py +3 -0
  2. runningtab/access.py +419 -0
  3. runningtab/books.py +225 -0
  4. runningtab/chart.py +408 -0
  5. runningtab/cli.py +2216 -0
  6. runningtab/coa.py +212 -0
  7. runningtab/doc.py +185 -0
  8. runningtab/forecast/__init__.py +1 -0
  9. runningtab/forecast/compute.py +224 -0
  10. runningtab/forecast/doctype.py +228 -0
  11. runningtab/forecast/model.py +251 -0
  12. runningtab/forecast/names.py +8 -0
  13. runningtab/forecast/sources.py +85 -0
  14. runningtab/gl_codes.py +50 -0
  15. runningtab/health.py +661 -0
  16. runningtab/mercury/__init__.py +7 -0
  17. runningtab/mercury/categories.py +500 -0
  18. runningtab/mercury/classify.py +101 -0
  19. runningtab/mercury/client.py +360 -0
  20. runningtab/mercury/ingest.py +733 -0
  21. runningtab/mercury/mapping.py +130 -0
  22. runningtab/mercury/models.py +544 -0
  23. runningtab/mercury/posting.py +757 -0
  24. runningtab/mercury/receipts.py +304 -0
  25. runningtab/mercury/session.py +65 -0
  26. runningtab/mercury/state.py +285 -0
  27. runningtab/mercury/vouchers.py +951 -0
  28. runningtab/period/__init__.py +9 -0
  29. runningtab/period/balances.py +283 -0
  30. runningtab/period/bank.py +154 -0
  31. runningtab/period/close.py +510 -0
  32. runningtab/period/crosscheck.py +257 -0
  33. runningtab/period/csvfmt.py +86 -0
  34. runningtab/period/documents.py +260 -0
  35. runningtab/period/fence.py +277 -0
  36. runningtab/period/ledger.py +205 -0
  37. runningtab/period/lock.py +294 -0
  38. runningtab/period/manifest.py +259 -0
  39. runningtab/period/open_items.py +84 -0
  40. runningtab/period/package.py +140 -0
  41. runningtab/period/qbo.py +521 -0
  42. runningtab/period/qbo_map.py +174 -0
  43. runningtab/period/quarter.py +90 -0
  44. runningtab/period/readiness.py +146 -0
  45. runningtab/period/record.py +247 -0
  46. runningtab/period/status.py +312 -0
  47. runningtab/site.py +409 -0
  48. runningtab-0.1.0.dev3.dist-info/METADATA +726 -0
  49. runningtab-0.1.0.dev3.dist-info/RECORD +52 -0
  50. runningtab-0.1.0.dev3.dist-info/WHEEL +4 -0
  51. runningtab-0.1.0.dev3.dist-info/entry_points.txt +2 -0
  52. runningtab-0.1.0.dev3.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,130 @@
1
+ """What each Mercury account is in our books.
2
+
3
+ Checked in beside the chart, as `mercury.toml`, because it is our data and not
4
+ the client's. Two tables:
5
+
6
+ - `[accounts]` -- a Mercury account, by the id or the name Mercury reports for
7
+ it, to the chart account holding its balance. Read by
8
+ `runningtab mercury install-bank-accounts`, which links each Bank Account to it.
9
+ - `[bank_accounts]` -- a Mercury account to the ERPNext Bank Account document
10
+ its Bank Transactions attach to. Keyed the same way `[accounts]` is.
11
+
12
+ There is no category table. Every settled transaction becomes a Bank
13
+ Transaction on its own account, and the person reconciling it picks the
14
+ account; Mercury's GL code rides along as a hint. A file that still carries
15
+ `[categories]` or `[posting]` is refused rather than read past, because a table
16
+ that reads as configured while doing nothing is exactly the silence a person
17
+ editing it cannot see.
18
+
19
+ Account entries may be keyed on the name Mercury reports as well as the id, and
20
+ the id wins when both are present. Names stood in while the real ids were
21
+ unknown; the ids are the durable key.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import dataclasses
27
+ import pathlib
28
+ import tomllib
29
+
30
+
31
+ class MappingError(ValueError):
32
+ """`mercury.toml` is not a mapping we can post with."""
33
+
34
+
35
+ def _normalise(name: str) -> str:
36
+ """Account names are compared case-insensitively with runs of whitespace collapsed."""
37
+ return " ".join(name.split()).casefold()
38
+
39
+
40
+ def _by_id_or_name(table: dict[str, str], account_id: str, account_name: str | None) -> str | None:
41
+ """A Mercury account's entry in a table keyed on either its id or its name.
42
+
43
+ The id is looked up first, so filling real ids in later takes precedence
44
+ over the names standing in for them today.
45
+ """
46
+ by_id = table.get(account_id)
47
+ if by_id is not None:
48
+ return by_id
49
+ if account_name:
50
+ return table.get(_normalise(account_name))
51
+ return None
52
+
53
+
54
+ @dataclasses.dataclass(frozen=True)
55
+ class Mapping:
56
+ """What each Mercury account means in our chart and in ERPNext."""
57
+
58
+ accounts: dict[str, str]
59
+ bank_accounts: dict[str, str] = dataclasses.field(default_factory=dict)
60
+
61
+ def account_for_mercury_account(self, account_id: str, account_name: str | None = None) -> str | None:
62
+ """The chart account holding a Mercury account's balance."""
63
+ return _by_id_or_name(self.accounts, account_id, account_name)
64
+
65
+ def bank_account_for(self, account_id: str, account_name: str | None = None) -> str | None:
66
+ """The ERPNext Bank Account document a Mercury account's activity attaches to.
67
+
68
+ One per Mercury account, never one for all of them: the ingest reads
69
+ checking, savings and the card, and a Bank Transaction posted against
70
+ another account's Bank Account is both wrong in the ledger and invisible
71
+ to the reconciliation the doctype exists for.
72
+ """
73
+ return _by_id_or_name(self.bank_accounts, account_id, account_name)
74
+
75
+ def knows_account(self, account_id: str, account_name: str | None = None) -> bool:
76
+ """Whether this Mercury account has a chart account. An unknown one is never guessed at."""
77
+ return self.account_for_mercury_account(account_id, account_name) is not None
78
+
79
+
80
+ def _table(data: dict[str, object], key: str) -> dict[str, str]:
81
+ value = data.get(key, {})
82
+ if not isinstance(value, dict):
83
+ raise MappingError(f"[{key}] must be a table of string values")
84
+ out: dict[str, str] = {}
85
+ for name, number in value.items():
86
+ if not isinstance(number, str):
87
+ raise MappingError(f"[{key}] {name!r} must map to an account number as a string, got {number!r}")
88
+ out[str(name)] = number
89
+ return out
90
+
91
+
92
+ def _account_keys(table: dict[str, str]) -> dict[str, str]:
93
+ """A Mercury-account-keyed table, readable by both id and normalised name.
94
+
95
+ Keys are left as written as well as normalised, because one of them is a
96
+ Mercury account id -- opaque, and it must match exactly.
97
+ """
98
+ return {**table, **{_normalise(name): value for name, value in table.items()}}
99
+
100
+
101
+ def parse_mapping(text: str) -> Mapping:
102
+ """Parse `mercury.toml` text. Account numbers are strings, matching the chart."""
103
+ data = tomllib.loads(text)
104
+ if "categories" in data:
105
+ raise MappingError(
106
+ "[categories] is no longer read: every settled transaction becomes a Bank Transaction on its "
107
+ "own account, with Mercury's GL code carried as a hint, and the person reconciling picks the "
108
+ "account; remove the table"
109
+ )
110
+ if "posting" in data:
111
+ raise MappingError("[posting] is no longer read: nothing posts to suspense; remove the table")
112
+ return Mapping(
113
+ accounts=_account_keys(_table(data, "accounts")),
114
+ bank_accounts=_account_keys(_table(data, "bank_accounts")),
115
+ )
116
+
117
+
118
+ def load_mapping(path: pathlib.Path) -> Mapping:
119
+ """Load the mapping at `path`: the books directory's `mercury.toml`, or a `--mercury-mapping` file."""
120
+ return parse_mapping(path.read_text())
121
+
122
+
123
+ def validate_against_chart(mapping: Mapping, chart_numbers: set[str]) -> list[str]:
124
+ """Every account number the mapping names that the chart does not have.
125
+
126
+ Cheap and worth doing at load: a typo'd account number would otherwise
127
+ surface as an ERPNext link error when the Bank Account is installed, naming
128
+ a document rather than the line of `mercury.toml` that is wrong.
129
+ """
130
+ return sorted(set(mapping.accounts.values()) - chart_numbers)
@@ -0,0 +1,544 @@
1
+ """Mercury payloads as typed records.
2
+
3
+ Modelled from observed payloads rather than from the published reference: the
4
+ reference describes `transactionCategoryData` while responses carry
5
+ `categoryData` and `mercuryCategory`. Where a field name here is an assumption
6
+ rather than something the investigation recorded, it says so in a comment and
7
+ the parser accepts every spelling it might have -- `tests/capture_mercury_fixtures.py`
8
+ settles which is real.
9
+
10
+ Money is inconsistent at the source: transactions carry floats in dollars,
11
+ card budgets carry integers in cents. Everything becomes `Decimal` here, at the
12
+ parse boundary, and via `str()` -- `Decimal(0.1)` is not `0.1`. No float reaches
13
+ the ledger.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import dataclasses
19
+ import datetime as dt
20
+ import enum
21
+ import re
22
+ import urllib.parse
23
+ from decimal import Decimal
24
+ from typing import Any
25
+
26
+
27
+ class MercuryPayloadError(ValueError):
28
+ """A payload is missing something the ingest cannot proceed without."""
29
+
30
+
31
+ class Status(enum.StrEnum):
32
+ """Mercury's transaction status enum.
33
+
34
+ Wider than the four the sandbox exhibited; `REVERSED` is the one that needs
35
+ handling rather than a skip.
36
+ """
37
+
38
+ PENDING = "pending"
39
+ SENT = "sent"
40
+ CANCELLED = "cancelled"
41
+ FAILED = "failed"
42
+ REVERSED = "reversed"
43
+ BLOCKED = "blocked"
44
+
45
+
46
+ # Statuses `currentBalance` leaves out. Measured exactly on two sandbox
47
+ # accounts: one off by 80.00 with a failed -70.00 and a pending -10.00, another
48
+ # off by 3.50 with a pending -3.50. `cancelled`, `reversed`, and `blocked` never
49
+ # occurred there, so which side of this line they fall on is unverified -- they
50
+ # are counted, which is what "the balance is the bank's number" implies, but a
51
+ # real capture is what settles it.
52
+ UNBALANCED_STATUSES: frozenset[Status] = frozenset({Status.PENDING, Status.FAILED})
53
+
54
+ # The only status that posts. Everything unsettled would put the ledger out of
55
+ # step with `currentBalance` by construction.
56
+ POSTABLE_STATUSES: frozenset[Status] = frozenset({Status.SENT})
57
+
58
+ # Every status string somebody has decided the accounting meaning of. A status
59
+ # outside this set is not merely unpostable: which side of the balance line it
60
+ # falls on and whether it should reach the ledger are both undecided, so it
61
+ # needs a person rather than a skip.
62
+ KNOWN_STATUSES: frozenset[str] = frozenset(status.value for status in Status)
63
+
64
+
65
+ class CategorySource(enum.StrEnum):
66
+ """Which of Mercury's two category fields supplied the category.
67
+
68
+ The precedence is a person's choice first, then Mercury's automatic
69
+ assignment. A draft records which one it came from, so a reviewer can see at
70
+ a glance which entries had no human judgement behind them.
71
+ """
72
+
73
+ CUSTOM = "custom"
74
+ MERCURY = "mercury"
75
+
76
+
77
+ @dataclasses.dataclass(frozen=True)
78
+ class Allocation:
79
+ """One `glAllocations` row: what Mercury's own rule engine derived.
80
+
81
+ Never the classification key -- GL fields emit no event -- but worth reading
82
+ as corroboration once the GL code list is our account names.
83
+ """
84
+
85
+ amount: Decimal
86
+ gl_code_name: str | None
87
+
88
+
89
+ # The only stable name an attachment has. Mercury's presigned URL ends
90
+ # `attachment/<uuid>.<ext>`; the query string carries the signature and changes
91
+ # on every read, and the row carries no id field of its own. Matched on the
92
+ # UUID shape rather than on the `attachment/` segment before it, because the
93
+ # shape is what makes the name a name -- a path that reorganises around it
94
+ # still identifies the same file.
95
+ _ATTACHMENT_ID = re.compile(
96
+ r"^(?P<id>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\.[^.]+)?$",
97
+ re.IGNORECASE,
98
+ )
99
+
100
+
101
+ @dataclasses.dataclass(frozen=True)
102
+ class Attachment:
103
+ """One receipt a person uploaded to Mercury for this transaction.
104
+
105
+ The URL is presigned and expires twelve hours after Mercury minted it, so
106
+ it is a thing to use inside the run that read it and never a thing to
107
+ store. It is kept out of the repr for the reason the bank token is: this
108
+ record turns up in log lines and pytest assertion output, and the signature
109
+ in that query string is a credential for the file.
110
+ """
111
+
112
+ file_name: str
113
+ url: str = dataclasses.field(repr=False)
114
+
115
+ @property
116
+ def attachment_id(self) -> str | None:
117
+ """The UUID naming this attachment, or None when the URL path carries none.
118
+
119
+ The identity a run compares against what is already attached in the
120
+ ledger. None is not an error here -- it is a fact about the URL that
121
+ the caller reports, because an attachment with no identity would be
122
+ uploaded again by every later run.
123
+ """
124
+ last = urllib.parse.urlsplit(self.url).path.rsplit("/", 1)[-1]
125
+ match = _ATTACHMENT_ID.match(urllib.parse.unquote(last))
126
+ return match["id"].lower() if match else None
127
+
128
+
129
+ @dataclasses.dataclass(frozen=True)
130
+ class Category:
131
+ """One row of `GET /categories`: a UUID that survives renames, a name, three visibility flags.
132
+
133
+ The flags decide which pickers Mercury offers the category in -- a card
134
+ charge, an expense reimbursement, and everything else (wires, ACH,
135
+ transfers). All three are required by the API's own schema and all three
136
+ are required here: a flag guessed rather than read would make
137
+ `runningtab.mercury.categories` propose an edit that changes nothing, or miss
138
+ one that matters.
139
+ """
140
+
141
+ id: str
142
+ name: str
143
+ visible_for_card_spend: bool
144
+ visible_for_reimbursements: bool
145
+ visible_for_other: bool
146
+
147
+
148
+ @dataclasses.dataclass(frozen=True)
149
+ class Account:
150
+ """A deposit account from `GET /accounts`.
151
+
152
+ Credit accounts are not here: `/accounts` omits them entirely and `GET
153
+ /credit` returns them with a different, much smaller schema. Two account
154
+ models, not one with optional fields -- `CreditAccount` is the other.
155
+ """
156
+
157
+ id: str
158
+ name: str
159
+ current_balance: Decimal
160
+ kind: str | None = None
161
+ # What a full transaction read starts from. The endpoint's own default is
162
+ # the last 30 days, so "every transaction on this account" is only sayable
163
+ # with a date, and this is the only one the account itself supplies.
164
+ created_at: dt.datetime | None = None
165
+
166
+ @property
167
+ def label(self) -> str:
168
+ """What a person reading a report calls this account."""
169
+ return self.name or self.id
170
+
171
+
172
+ @dataclasses.dataclass(frozen=True)
173
+ class CreditAccount:
174
+ """A credit account from `GET /credit`. Has no name; the IO card is one of these."""
175
+
176
+ id: str
177
+ status: str | None
178
+ current_balance: Decimal
179
+ available_balance: Decimal | None = None
180
+ created_at: dt.datetime | None = None
181
+
182
+ @property
183
+ def label(self) -> str:
184
+ """What a person reading a report calls this account. It has no name to use."""
185
+ return f"credit account {self.id[:8]}"
186
+
187
+
188
+ @dataclasses.dataclass(frozen=True)
189
+ class Transaction:
190
+ """One transaction, with the account it was read from attached.
191
+
192
+ `account_id` is not part of the payload. It comes from the request that
193
+ fetched the row, because pairing the two halves of an internal transfer
194
+ needs to know which side is which and `counterpartyId` alone cannot say.
195
+ """
196
+
197
+ id: str
198
+ account_id: str
199
+ amount: Decimal
200
+ status: Status
201
+ raw_status: str
202
+ kind: str
203
+ created_at: dt.datetime
204
+ posted_at: dt.datetime | None = None
205
+ counterparty_id: str | None = None
206
+ counterparty_name: str | None = None
207
+ bank_description: str | None = None
208
+ external_memo: str | None = None
209
+ note: str | None = None
210
+ dashboard_link: str | None = None
211
+ custom_category: str | None = None
212
+ # The custom category's UUID, which is what `GET /categories` keys on. The
213
+ # name would be the obvious handle and is the wrong one: two categories can
214
+ # wear one name, and a rename moves the name without moving the row. Only
215
+ # this answers "does any transaction carry that category", which is the
216
+ # question `runningtab mercury categories apply --prune` must get right before
217
+ # it deletes anything.
218
+ custom_category_id: str | None = None
219
+ mercury_category: str | None = None
220
+ gl_code_name: str | None = None
221
+ allocations: tuple[Allocation, ...] = ()
222
+ # The receipts a person uploaded against this transaction. Read on every
223
+ # run rather than in response to an event: the attachment list emits none,
224
+ # for the same reason the GL code and the note do not.
225
+ attachments: tuple[Attachment, ...] = ()
226
+ # The ACH trace number, which Mercury's statement export carries as
227
+ # `Tracking ID`. Absent on anything that never went through the ACH
228
+ # network -- a card charge has none. Observed as a string on a real
229
+ # production row; never seen on a sandbox one.
230
+ tracking_number: str | None = None
231
+
232
+ @property
233
+ def category(self) -> str | None:
234
+ """A person's choice, else Mercury's automatic one. Parsed as a fact about the row; nothing routes on it."""
235
+ return self.custom_category or self.mercury_category
236
+
237
+ @property
238
+ def gl_code(self) -> str | None:
239
+ """The GL code a person chose for this row: Mercury's GL code, else their custom category.
240
+
241
+ The custom categories are the chart's GL-code labels word for word
242
+ (`runningtab mercury categories apply` mirrors them), and Mercury's dropdown
243
+ offers nothing else, so picking a category is coding the row. Coding at
244
+ the card reader sets the category and leaves `generalLedgerCodeName`
245
+ empty. Mercury's automatic category is not a GL code: nobody chose it,
246
+ and it names Mercury's taxonomy rather than the chart.
247
+ """
248
+ return self.gl_code_name or self.custom_category
249
+
250
+ @property
251
+ def category_source(self) -> CategorySource | None:
252
+ """Which field the category came from, or None when the transaction carries neither."""
253
+ if self.custom_category:
254
+ return CategorySource.CUSTOM
255
+ if self.mercury_category:
256
+ return CategorySource.MERCURY
257
+ return None
258
+
259
+ @property
260
+ def status_recognised(self) -> bool:
261
+ """Whether the status is one this ingest has accounting semantics for.
262
+
263
+ Asked of `raw_status` rather than of `status`: an unrecognised status
264
+ parses to `BLOCKED`, which makes it indistinguishable from a real one
265
+ at the enum and would let it be handled as though somebody had decided
266
+ what it means.
267
+ """
268
+ return self.raw_status in KNOWN_STATUSES
269
+
270
+ @property
271
+ def counts_toward_balance(self) -> bool:
272
+ """Whether `currentBalance` includes this row."""
273
+ return self.status not in UNBALANCED_STATUSES
274
+
275
+ @property
276
+ def is_postable(self) -> bool:
277
+ """Whether this row is settled enough to reach the ledger at all."""
278
+ return self.status in POSTABLE_STATUSES
279
+
280
+ @property
281
+ def accounting_date(self) -> dt.date:
282
+ """The date a posting carries.
283
+
284
+ `postedAt` when present, else `createdAt`. The design left the choice
285
+ open -- the two differ by a month on some sandbox rows -- and this picks
286
+ `postedAt` because the ledger is describing settled activity, which is
287
+ what `currentBalance` counts.
288
+ """
289
+ return (self.posted_at or self.created_at).date()
290
+
291
+ @property
292
+ def allocated_total(self) -> Decimal:
293
+ """The sum of the GL allocations. Compared against `amount`, never assumed equal to it."""
294
+ return sum((a.amount for a in self.allocations), Decimal(0))
295
+
296
+
297
+ class Operation(enum.StrEnum):
298
+ """What an event says happened to its resource."""
299
+
300
+ CREATE = "create"
301
+ UPDATE = "update"
302
+ DELETE = "delete"
303
+
304
+
305
+ @dataclasses.dataclass(frozen=True)
306
+ class Event:
307
+ """One row of the event log, which is also exactly what a webhook delivers.
308
+
309
+ The applier reads only the envelope -- which resource changed, at which
310
+ version -- and re-reads the resource itself from the transactions endpoint.
311
+ That is deliberate: the event body is a JSON Merge Patch whose exact
312
+ envelope key the investigation never recorded, and re-reading is both
313
+ cheaper to be sure of and correct for a pending charge that settled.
314
+ """
315
+
316
+ id: str
317
+ resource_type: str
318
+ resource_id: str
319
+ operation: Operation
320
+ resource_version: int | None = None
321
+ changed_paths: tuple[str, ...] = ()
322
+
323
+ @property
324
+ def is_transaction(self) -> bool:
325
+ """Whether this event is about a transaction rather than an account balance."""
326
+ return self.resource_type.lower() == "transaction"
327
+
328
+
329
+ def _require(payload: dict[str, Any], *names: str) -> Any:
330
+ """The first present, non-null value among `names`, or a loud failure.
331
+
332
+ Several field names in this file are recorded observations and several are
333
+ assumptions. Rather than guess silently and produce a wrong record, an
334
+ absent required field raises and names both what was wanted and what the
335
+ payload actually carried.
336
+ """
337
+ for name in names:
338
+ value = payload.get(name)
339
+ if value is not None:
340
+ return value
341
+ wanted = " / ".join(names)
342
+ raise MercuryPayloadError(f"payload has no {wanted}; keys present: {sorted(payload)}")
343
+
344
+
345
+ def _optional(payload: dict[str, Any], *names: str) -> Any:
346
+ for name in names:
347
+ value = payload.get(name)
348
+ if value is not None:
349
+ return value
350
+ return None
351
+
352
+
353
+ def money(value: Any) -> Decimal:
354
+ """A Mercury amount as an exact `Decimal`.
355
+
356
+ Goes through `str()` because the wire value is a JSON float: `Decimal(0.1)`
357
+ carries the binary approximation and would drift a cent at a time through a
358
+ ledger.
359
+ """
360
+ if isinstance(value, Decimal):
361
+ return value
362
+ return Decimal(str(value))
363
+
364
+
365
+ def timestamp(value: Any) -> dt.datetime:
366
+ """An ISO 8601 instant as an aware UTC datetime.
367
+
368
+ Mercury sends `Z`; `datetime.fromisoformat` accepts it from 3.11 on. A naive
369
+ value is read as UTC rather than as local time, because a bank timestamp is
370
+ never local.
371
+ """
372
+ parsed = value if isinstance(value, dt.datetime) else dt.datetime.fromisoformat(str(value))
373
+ if parsed.tzinfo is None:
374
+ return parsed.replace(tzinfo=dt.UTC)
375
+ return parsed.astimezone(dt.UTC)
376
+
377
+
378
+ def _optional_timestamp(payload: dict[str, Any], *names: str) -> dt.datetime | None:
379
+ value = _optional(payload, *names)
380
+ return None if value is None else timestamp(value)
381
+
382
+
383
+ def parse_status(value: Any) -> tuple[Status, str]:
384
+ """A status string as `(Status, raw)`.
385
+
386
+ An unrecognised status is not an error -- Mercury may add one -- but it is
387
+ never postable: it falls back to `BLOCKED`, whose handling is "post
388
+ nothing", and the raw string is kept so a report can name it.
389
+ """
390
+ raw = str(value)
391
+ try:
392
+ return Status(raw), raw
393
+ except ValueError:
394
+ return Status.BLOCKED, raw
395
+
396
+
397
+ def parse_allocations(payload: dict[str, Any]) -> tuple[Allocation, ...]:
398
+ rows = payload.get("glAllocations") or ()
399
+ return tuple(Allocation(amount=money(row.get("amount", 0)), gl_code_name=row.get("glCodeName")) for row in rows)
400
+
401
+
402
+ def parse_attachments(payload: dict[str, Any]) -> tuple[Attachment, ...]:
403
+ """The `attachments` rows as records.
404
+
405
+ `url` goes through `_require`: an attachment row without one names a
406
+ receipt nothing can fetch, and reading past it would drop a person's
407
+ document without saying so. Raising here surfaces as a
408
+ `MalformedResponseError` naming the endpoint, which is the loud failure
409
+ this would otherwise be the silent version of.
410
+
411
+ `fileName` falls back to the last path segment, which is the file's own
412
+ `<uuid>.<ext>` -- a poor name for a person to read and a real one.
413
+ """
414
+ rows = payload.get("attachments") or ()
415
+ attachments = []
416
+ for row in rows:
417
+ url = str(_require(row, "url"))
418
+ name = _optional(row, "fileName") or urllib.parse.urlsplit(url).path.rsplit("/", 1)[-1]
419
+ attachments.append(Attachment(file_name=str(name), url=url))
420
+ return tuple(attachments)
421
+
422
+
423
+ def _parse_categories(payload: dict[str, Any]) -> tuple[str | None, str | None, str | None]:
424
+ """`(customCategory name, customCategory id, mercuryCategory name)`.
425
+
426
+ Two shapes are accepted for the automatic category because the record is
427
+ ambiguous: the sandbox survey counted `mercuryCategory` as a top-level field
428
+ while the Events field set names it `categoryData.mercuryCategory`. Both are
429
+ read; a live capture settles which one a real account sends.
430
+
431
+ The custom category is **flat**: `categoryData` is the category object
432
+ itself, carrying `id`, `name` and the three visibility flags -- measured
433
+ against the real organisation on 2026-09-21, and the reason the nested
434
+ `categoryData.customCategory` the ingest fixtures were built around is read
435
+ as a fallback rather than as the shape. Reading the id wrongly would be
436
+ worse than reading it not at all: `--prune` deletes a category no
437
+ transaction carries, so an id that never parses makes every category look
438
+ uncarried. `runningtab.mercury.categories` guards that separately, by refusing to
439
+ prune at all when transactions name a category and none yields an id.
440
+ """
441
+ category_data = payload.get("categoryData") or {}
442
+ nested = category_data.get("customCategory") or {}
443
+ custom = nested.get("name") or category_data.get("name")
444
+ custom_id = nested.get("id") or category_data.get("id")
445
+ mercury = category_data.get("mercuryCategory") or payload.get("mercuryCategory")
446
+ if isinstance(mercury, dict):
447
+ mercury = mercury.get("name")
448
+ return custom, None if custom_id is None else str(custom_id), mercury
449
+
450
+
451
+ def parse_transaction(payload: dict[str, Any], account_id: str) -> Transaction:
452
+ """One transaction payload, with the account it was read from."""
453
+ status, raw_status = parse_status(_require(payload, "status"))
454
+ custom_category, custom_category_id, mercury_category = _parse_categories(payload)
455
+ return Transaction(
456
+ id=str(_require(payload, "id")),
457
+ account_id=account_id,
458
+ amount=money(_require(payload, "amount")),
459
+ status=status,
460
+ raw_status=raw_status,
461
+ kind=str(_optional(payload, "kind") or "unknown"),
462
+ created_at=timestamp(_require(payload, "createdAt")),
463
+ posted_at=_optional_timestamp(payload, "postedAt"),
464
+ counterparty_id=_optional(payload, "counterpartyId"),
465
+ counterparty_name=_optional(payload, "counterpartyName"),
466
+ bank_description=_optional(payload, "bankDescription"),
467
+ external_memo=_optional(payload, "externalMemo"),
468
+ note=_optional(payload, "note"),
469
+ dashboard_link=_optional(payload, "dashboardLink"),
470
+ custom_category=custom_category,
471
+ custom_category_id=custom_category_id,
472
+ mercury_category=mercury_category,
473
+ gl_code_name=_optional(payload, "generalLedgerCodeName"),
474
+ allocations=parse_allocations(payload),
475
+ attachments=parse_attachments(payload),
476
+ tracking_number=_optional_string(payload, "trackingNumber"),
477
+ )
478
+
479
+
480
+ def _optional_string(payload: dict[str, Any], *names: str) -> str | None:
481
+ """An optional field as a string, or None. Kept as text: a trace number with a leading zero is not a number."""
482
+ value = _optional(payload, *names)
483
+ return None if value is None else str(value)
484
+
485
+
486
+ def parse_account(payload: dict[str, Any]) -> Account:
487
+ return Account(
488
+ id=str(_require(payload, "id")),
489
+ name=str(_optional(payload, "name", "nickname") or ""),
490
+ current_balance=money(_require(payload, "currentBalance")),
491
+ kind=_optional(payload, "kind", "type"),
492
+ created_at=_optional_timestamp(payload, "createdAt"),
493
+ )
494
+
495
+
496
+ def parse_credit_account(payload: dict[str, Any]) -> CreditAccount:
497
+ available = _optional(payload, "availableBalance")
498
+ return CreditAccount(
499
+ id=str(_require(payload, "id")),
500
+ status=_optional(payload, "status"),
501
+ current_balance=money(_require(payload, "currentBalance")),
502
+ available_balance=None if available is None else money(available),
503
+ created_at=_optional_timestamp(payload, "createdAt"),
504
+ )
505
+
506
+
507
+ def parse_category(payload: dict[str, Any]) -> Category:
508
+ """One category payload. Every visibility flag is required, never defaulted.
509
+
510
+ `_require` rejects a missing or null flag but accepts `False`, which is a
511
+ real answer. Defaulting an absent flag would silently decide something the
512
+ bank was never asked.
513
+ """
514
+ return Category(
515
+ id=str(_require(payload, "id")),
516
+ name=str(_require(payload, "name")),
517
+ visible_for_card_spend=bool(_require(payload, "visibleForCardSpend")),
518
+ visible_for_reimbursements=bool(_require(payload, "visibleForReimbursements")),
519
+ visible_for_other=bool(_require(payload, "visibleForOther")),
520
+ )
521
+
522
+
523
+ def parse_event(payload: dict[str, Any]) -> Event:
524
+ """One event envelope.
525
+
526
+ `resourceVersion` is optional here, not because Mercury omits it -- it is
527
+ documented as an int64 starting at 1 -- but because an event that arrives
528
+ without one must still be applied exactly once rather than dropped. Dedup on
529
+ the event id is what makes that safe.
530
+ """
531
+ version = _optional(payload, "resourceVersion")
532
+ operation = str(_require(payload, "operationType")).lower()
533
+ try:
534
+ parsed_operation = Operation(operation)
535
+ except ValueError as exc:
536
+ raise MercuryPayloadError(f"unknown operationType {operation!r} on event {payload.get('id')!r}") from exc
537
+ return Event(
538
+ id=str(_require(payload, "id")),
539
+ resource_type=str(_require(payload, "resourceType")),
540
+ resource_id=str(_require(payload, "resourceId")),
541
+ operation=parsed_operation,
542
+ resource_version=None if version is None else int(version),
543
+ changed_paths=tuple(payload.get("changedPaths") or ()),
544
+ )