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,500 @@
1
+ """Mercury's custom categories, mirrored from the chart of accounts.
2
+
3
+ Mercury's rule engine derives a GL code from a transaction's category, and its
4
+ GL-code list is ours (`runningtab.gl_codes`). So the shortest path from "somebody
5
+ picked something in Mercury" to "the Bank Transaction arrives carrying the
6
+ right account" is to make the two lists the same list, string for string, and
7
+ let a person write one rule per category. Neither the GL-code list nor the
8
+ rules have an API; categories do.
9
+
10
+ Reconciliation is by **name**, because a name is the only thing the two sides
11
+ share -- nothing here keeps state between runs, so Mercury's category UUIDs are
12
+ not ours to remember. Three rules follow from that, and all three are about not
13
+ destroying somebody's coding:
14
+
15
+ - A Mercury category the chart does not name is **unmanaged**: reported, and
16
+ never renamed. It is what transactions may already be coded with, and no
17
+ re-run reproduces the judgement behind that.
18
+ - An edit only ever sets visibility flags. The name is the match key, so
19
+ changing it would change which account the edit was about.
20
+ - Two Mercury categories with one desired name is **refused** with both UUIDs
21
+ named. One of them carries the coding and this module cannot tell which.
22
+
23
+ Pruning is the one deletion this module can do, and it is guarded by that same
24
+ sentence. `--prune` deletes an unmanaged category **only when no transaction on
25
+ any account carries it**, measured by UUID against an exhaustive read of every
26
+ transaction -- settled or not. One that is carried is reported with its count
27
+ and left standing: a person re-codes those rows in Mercury, or leaves them, and
28
+ until they do the two lists disagree and the exit code says so. A delete strips
29
+ somebody's coding off rows that nothing reproduces, so the guard is not an
30
+ optimisation and the read behind it is not allowed to be short.
31
+
32
+ `MercuryCategoryWriter` at the bottom holds the only three writes this repo can
33
+ make to Mercury. It is a separate class from `MercuryClient` deliberately:
34
+ `runningtab.mercury.ingest` never imports this module, so the ingest's client keeps
35
+ its "no verb but GET" guarantee intact. That three-method surface is a
36
+ courtesy, not a boundary -- Mercury offers no category-only scope, so the token
37
+ behind it is a full Read and Write token that could also move money. What
38
+ bounds it is its lifetime; see `runningtab.site.mercury_admin_token`.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import dataclasses
44
+ import json
45
+ from collections.abc import Iterable, Iterator, Mapping
46
+ from typing import Any
47
+
48
+ import httpx2
49
+
50
+ from runningtab.chart import ChartEntry
51
+ from runningtab.gl_codes import gl_code_name, postable_accounts
52
+ from runningtab.mercury.client import MalformedResponseError, MercuryConfig, MercuryError
53
+ from runningtab.mercury.models import Category, Transaction, parse_category
54
+
55
+ # Expenses. Spend arrives on a card, as a reimbursement, and as a wire or ACH,
56
+ # so every picker offers these.
57
+ EXPENSE_PREFIX = "5"
58
+ # Member capital contributions (31xx) and revenue (4xxx): money coming in. It
59
+ # arrives as a wire or an ACH credit, never as card spend and never as a
60
+ # reimbursement, so offering them in those two pickers would only lengthen the
61
+ # list somebody scrolls at a card reader with answers that cannot be right.
62
+ #
63
+ # `31` rather than the three member accounts by name: a fourth member is a
64
+ # chart edit, and naming 3110/3120/3130 here would leave their contributions
65
+ # with nowhere to go.
66
+ INCOMING_PREFIXES = ("31", "4")
67
+
68
+ # The API's own field names, which are also what the plan prints. A person
69
+ # reading an `edit` line is looking for one of these three checkboxes in the
70
+ # dashboard, so the report says exactly what the dashboard says.
71
+ CARD_SPEND = "visibleForCardSpend"
72
+ REIMBURSEMENTS = "visibleForReimbursements"
73
+ OTHER = "visibleForOther"
74
+
75
+
76
+ @dataclasses.dataclass(frozen=True)
77
+ class DesiredCategory:
78
+ """One chart account as Mercury should hold it: the GL code's own label, and three flags."""
79
+
80
+ number: str
81
+ name: str
82
+ visible_for_card_spend: bool
83
+ visible_for_reimbursements: bool
84
+ visible_for_other: bool
85
+
86
+ @property
87
+ def flags(self) -> dict[str, bool]:
88
+ """The three visibility flags under the API's field names, in the order the plan prints them."""
89
+ return {
90
+ CARD_SPEND: self.visible_for_card_spend,
91
+ REIMBURSEMENTS: self.visible_for_reimbursements,
92
+ OTHER: self.visible_for_other,
93
+ }
94
+
95
+ @property
96
+ def flag_label(self) -> str:
97
+ """`CRO`, with `-` for each flag that is off -- the plan's compact column."""
98
+ return "".join(letter if value else "-" for letter, value in zip("CRO", self.flags.values(), strict=True))
99
+
100
+
101
+ def live_flags(category: Category) -> dict[str, bool]:
102
+ """A Mercury category's flags under the same field names, so the two sides compare directly."""
103
+ return {
104
+ CARD_SPEND: category.visible_for_card_spend,
105
+ REIMBURSEMENTS: category.visible_for_reimbursements,
106
+ OTHER: category.visible_for_other,
107
+ }
108
+
109
+
110
+ def desired_categories(entries: list[ChartEntry]) -> list[DesiredCategory]:
111
+ """One category per account a person can code a transaction to, in chart order.
112
+
113
+ Derived from the chart rather than from a list kept beside it: a hand-kept
114
+ list goes stale the first time an account moves, and its staleness is
115
+ invisible. Groups are excluded for the reason `gl_codes` excludes them --
116
+ nothing posts to a group, so offering one offers a mistake.
117
+
118
+ So are accounts the chart tags `no-bank-line`. Depreciation, round-off, FX,
119
+ write-offs and a disposal gain reach the ledger as a period-end or
120
+ adjustment journal and never as a statement line, so a category for one is
121
+ an answer that cannot be right in the only place the list is read -- the
122
+ picker somebody scrolls at a card reader. They keep their GL codes:
123
+ `coa export-gl-codes` still emits every postable account, because a person
124
+ reconciling in ERPNext does reach them. What they lose is the category.
125
+ """
126
+ by_number = {entry.account.number: entry for entry in entries}
127
+ wanted: list[DesiredCategory] = []
128
+ for account in postable_accounts([entry.account for entry in entries]):
129
+ if by_number[account.number].no_bank_line:
130
+ continue
131
+ if account.number.startswith(EXPENSE_PREFIX):
132
+ card = reimbursements = other = True
133
+ elif account.number.startswith(INCOMING_PREFIXES):
134
+ card = reimbursements = False
135
+ other = True
136
+ else:
137
+ continue
138
+ wanted.append(
139
+ DesiredCategory(
140
+ number=account.number,
141
+ name=gl_code_name(account),
142
+ visible_for_card_spend=card,
143
+ visible_for_reimbursements=reimbursements,
144
+ visible_for_other=other,
145
+ )
146
+ )
147
+ return wanted
148
+
149
+
150
+ @dataclasses.dataclass(frozen=True)
151
+ class CreateCategory:
152
+ """A category the chart wants and Mercury does not have."""
153
+
154
+ desired: DesiredCategory
155
+
156
+
157
+ @dataclasses.dataclass(frozen=True)
158
+ class FlagChange:
159
+ """One visibility flag that disagrees, named as the dashboard names it."""
160
+
161
+ field: str
162
+ old: bool
163
+ new: bool
164
+
165
+ def __str__(self) -> str:
166
+ return f"{self.field}: {str(self.old).lower()} -> {str(self.new).lower()}"
167
+
168
+
169
+ @dataclasses.dataclass(frozen=True)
170
+ class EditCategory:
171
+ """A category Mercury has under the right name, with at least one flag wrong."""
172
+
173
+ desired: DesiredCategory
174
+ category_id: str
175
+ changes: tuple[FlagChange, ...]
176
+
177
+
178
+ @dataclasses.dataclass(frozen=True)
179
+ class UnmanagedCategory:
180
+ """A Mercury category the chart does not name. Reported, and without `--prune` left entirely alone."""
181
+
182
+ category: Category
183
+
184
+
185
+ @dataclasses.dataclass(frozen=True)
186
+ class DeleteCategory:
187
+ """An unmanaged category no transaction carries, so deleting it takes nobody's coding with it."""
188
+
189
+ category: Category
190
+
191
+
192
+ @dataclasses.dataclass(frozen=True)
193
+ class InUseCategory:
194
+ """An unmanaged category transactions carry. Counted, reported, and never deleted."""
195
+
196
+ category: Category
197
+ transactions: int
198
+
199
+ def __str__(self) -> str:
200
+ return f"{self.transactions} transaction(s) {self.category.name}"
201
+
202
+
203
+ class CategoryUseUnreadableError(MercuryError):
204
+ """Transactions carry custom categories whose UUID this read cannot see, so no deletion is safe.
205
+
206
+ The one failure that would make `--prune` delete somebody's coding: if the
207
+ id never parses, every category looks uncarried and the whole unmanaged
208
+ list becomes a delete list. `categoryData`'s exact shape is recorded as an
209
+ assumption rather than a measurement (see the fixture README), so the
210
+ contradiction -- rows naming a custom category while none carries an id --
211
+ is checked for and refused rather than trusted.
212
+ """
213
+
214
+
215
+ def carried_categories(transactions: Iterable[Transaction]) -> dict[str, int]:
216
+ """How many transactions carry each custom category, by UUID.
217
+
218
+ Every transaction the read returned, whatever its status: a pending charge
219
+ is somebody's coding too, and it settles next week onto a category this
220
+ would otherwise have deleted underneath it.
221
+
222
+ Raises:
223
+ CategoryUseUnreadableError: if any transaction names a custom category
224
+ without an id this can read. One such row means the read cannot
225
+ answer "does anything carry this category", and a prune that ran
226
+ anyway would delete on the strength of a field it failed to parse.
227
+ """
228
+ counts: dict[str, int] = {}
229
+ unreadable: list[str] = []
230
+ for transaction in transactions:
231
+ if transaction.custom_category_id:
232
+ counts[transaction.custom_category_id] = counts.get(transaction.custom_category_id, 0) + 1
233
+ elif transaction.custom_category:
234
+ unreadable.append(transaction.id)
235
+ if unreadable:
236
+ raise CategoryUseUnreadableError(
237
+ f"{len(unreadable)} transaction(s) carry a custom category with no id this client can read "
238
+ f"(first: {unreadable[0]}); which categories are in use cannot be answered, so nothing is deleted"
239
+ )
240
+ return counts
241
+
242
+
243
+ @dataclasses.dataclass(frozen=True)
244
+ class DuplicateName:
245
+ """Two Mercury categories wearing one desired name, which this module will not choose between."""
246
+
247
+ name: str
248
+ category_ids: tuple[str, ...]
249
+
250
+ def __str__(self) -> str:
251
+ return (
252
+ f"{len(self.category_ids)} Mercury categories are named {self.name!r} "
253
+ f"({', '.join(self.category_ids)}); transactions carry one of them and this cannot tell which. "
254
+ f"Merge or rename them in the dashboard."
255
+ )
256
+
257
+
258
+ @dataclasses.dataclass(frozen=True)
259
+ class CategoryPlan:
260
+ """What the chart wants against what Mercury has, as work rather than as a diff."""
261
+
262
+ creates: tuple[CreateCategory, ...]
263
+ edits: tuple[EditCategory, ...]
264
+ matches: tuple[DesiredCategory, ...]
265
+ unmanaged: tuple[UnmanagedCategory, ...]
266
+ refusals: tuple[DuplicateName, ...]
267
+ # Both empty unless the caller asked to prune, which is the only way an
268
+ # unmanaged category is ever classified rather than merely listed.
269
+ deletes: tuple[DeleteCategory, ...] = ()
270
+ in_use: tuple[InUseCategory, ...] = ()
271
+
272
+ @property
273
+ def has_work(self) -> bool:
274
+ """Whether there is anything for `apply` to send."""
275
+ return bool(self.creates or self.edits or self.deletes)
276
+
277
+ @property
278
+ def has_drift(self) -> bool:
279
+ """Whether the two lists disagree at all.
280
+
281
+ Unmanaged categories on their own are the steady state, not drift. An
282
+ unmanaged category a prune could not take because transactions carry it
283
+ *is* drift: the dropdown still holds it, and only a person re-coding
284
+ those rows in Mercury resolves that.
285
+ """
286
+ return self.has_work or bool(self.refusals or self.in_use)
287
+
288
+
289
+ def plan_categories(
290
+ desired: list[DesiredCategory], live: list[Category], *, carried: Mapping[str, int] | None = None
291
+ ) -> CategoryPlan:
292
+ """Reconcile the chart's categories against Mercury's, by name.
293
+
294
+ Args:
295
+ desired: What the chart wants, in chart order, from `desired_categories`.
296
+ live: Every category Mercury holds, read to the end of the list.
297
+ carried: Transaction counts per category UUID, from `carried_categories`,
298
+ when the caller asked to prune. `None` -- the default -- plans no
299
+ deletion at all, and every unmanaged category is simply reported.
300
+ An empty mapping is not the same thing: it says the bank was read
301
+ and nothing carries any category.
302
+
303
+ Returns:
304
+ The plan. Creates and edits keep chart order so the report reads as a
305
+ checklist; unmanaged categories keep Mercury's own order.
306
+ """
307
+ by_name: dict[str, list[Category]] = {}
308
+ for category in live:
309
+ by_name.setdefault(category.name, []).append(category)
310
+
311
+ creates: list[CreateCategory] = []
312
+ edits: list[EditCategory] = []
313
+ matches: list[DesiredCategory] = []
314
+ refusals: list[DuplicateName] = []
315
+ claimed: set[str] = set()
316
+
317
+ for want in desired:
318
+ found = by_name.get(want.name, [])
319
+ if len(found) > 1:
320
+ # Named, not chosen between. Both are claimed so neither is also
321
+ # reported as unmanaged -- one report line per category, and this
322
+ # one already says what is wrong with them.
323
+ refusals.append(DuplicateName(name=want.name, category_ids=tuple(c.id for c in found)))
324
+ claimed.update(c.id for c in found)
325
+ continue
326
+ if not found:
327
+ creates.append(CreateCategory(desired=want))
328
+ continue
329
+ current = found[0]
330
+ claimed.add(current.id)
331
+ held = live_flags(current)
332
+ changes = tuple(
333
+ FlagChange(field=field, old=held[field], new=value)
334
+ for field, value in want.flags.items()
335
+ if held[field] != value
336
+ )
337
+ if changes:
338
+ edits.append(EditCategory(desired=want, category_id=current.id, changes=changes))
339
+ else:
340
+ matches.append(want)
341
+
342
+ unmanaged = tuple(UnmanagedCategory(category=c) for c in live if c.id not in claimed)
343
+ deletes: list[DeleteCategory] = []
344
+ in_use: list[InUseCategory] = []
345
+ if carried is not None:
346
+ for one in unmanaged:
347
+ count = carried.get(one.category.id, 0)
348
+ if count:
349
+ in_use.append(InUseCategory(category=one.category, transactions=count))
350
+ else:
351
+ deletes.append(DeleteCategory(category=one.category))
352
+
353
+ return CategoryPlan(
354
+ creates=tuple(creates),
355
+ edits=tuple(edits),
356
+ matches=tuple(matches),
357
+ unmanaged=unmanaged,
358
+ refusals=tuple(refusals),
359
+ deletes=tuple(deletes),
360
+ in_use=tuple(in_use),
361
+ )
362
+
363
+
364
+ @dataclasses.dataclass(frozen=True)
365
+ class AppliedCategory:
366
+ """One write, with the UUID it concerned -- the only record of it this repo sees.
367
+
368
+ `error` is set only on a delete Mercury refused. A refused create or edit
369
+ stops the run, because everything after it was planned against a state that
370
+ did not happen; a refused delete stops nothing, because each delete stands
371
+ alone and Mercury may simply protect some of its own defaults.
372
+ """
373
+
374
+ action: str
375
+ name: str
376
+ category_id: str
377
+ error: str | None = None
378
+
379
+
380
+ class MercuryCategoryWriter:
381
+ """The only three writes this repo can make to Mercury: create a category, set one's flags, delete one.
382
+
383
+ Separate from `MercuryClient` on purpose. That class has no verb but GET
384
+ and is what the ingest and the health report hold; this one exists for a
385
+ single command and holds a different token.
386
+
387
+ The three-method surface stops a stray call at the type level, which is
388
+ worth having, but it is not the safety property. Mercury issues no
389
+ category-only write scope, so the token here is a full Read and Write token
390
+ that can also send money -- `runningtab.site.mercury_admin_token` reads it from
391
+ the environment and never from Secret Manager, and it is revoked after the
392
+ run.
393
+
394
+ `delete_category` is the one irreversible verb here, and nothing in this
395
+ class guards it: the guard is in the plan. `plan_categories` proposes a
396
+ delete only for a category an exhaustive transaction read found nobody
397
+ carrying, and `runningtab mercury categories apply` sends one only under
398
+ `--prune`. Calling this method on a category transactions carry would strip
399
+ their coding, and no re-run reproduces it.
400
+ """
401
+
402
+ def __init__(self, config: MercuryConfig, *, transport: httpx2.BaseTransport | None = None) -> None:
403
+ self._config = config
404
+ self._http = httpx2.Client(timeout=config.timeout, transport=transport)
405
+
406
+ def __enter__(self) -> MercuryCategoryWriter:
407
+ return self
408
+
409
+ def __exit__(self, *exc: object) -> None:
410
+ self.close()
411
+
412
+ def close(self) -> None:
413
+ """Release the underlying connection pool."""
414
+ self._http.close()
415
+
416
+ def _post(self, path: str, body: dict[str, Any]) -> Category:
417
+ response = self._http.request(
418
+ "POST",
419
+ f"{self._config.base_url.rstrip('/')}{path}",
420
+ json=body,
421
+ headers={"Authorization": f"Bearer {self._config.token}", "Accept": "application/json"},
422
+ )
423
+ if response.status_code >= 400:
424
+ raise MercuryError(
425
+ f"POST {path} -> {response.status_code}: {response.text[:300]}", status=response.status_code
426
+ )
427
+ try:
428
+ return parse_category(json.loads(response.text))
429
+ except (KeyError, TypeError, ValueError) as exc:
430
+ raise MalformedResponseError(
431
+ f"{path} answered {response.status_code} with a body this client cannot read: {exc!r}"
432
+ ) from exc
433
+
434
+ def create_category(self, desired: DesiredCategory) -> Category:
435
+ """Create one category. Mercury answers with it, including the UUID it assigned."""
436
+ return self._post("/categories", {"name": desired.name, **desired.flags})
437
+
438
+ def edit_category(self, category_id: str, desired: DesiredCategory) -> Category:
439
+ """Set one category's three visibility flags.
440
+
441
+ Sends the flags and never `name`. The name is how this module found the
442
+ category in the first place, so renaming it here would be editing a
443
+ different account than the one the plan was about -- and would silently
444
+ re-label every transaction already coded with it.
445
+ """
446
+ return self._post(f"/categories/{category_id}", dict(desired.flags))
447
+
448
+ def delete_category(self, category_id: str) -> None:
449
+ """Delete one category. Irreversible, and it takes the coding off every transaction carrying it.
450
+
451
+ Only ever called for a `DeleteCategory` the plan produced, which means
452
+ an exhaustive read of every transaction on every account found none
453
+ carrying this UUID.
454
+ """
455
+ response = self._http.request(
456
+ "DELETE",
457
+ f"{self._config.base_url.rstrip('/')}/categories/{category_id}",
458
+ headers={"Authorization": f"Bearer {self._config.token}", "Accept": "application/json"},
459
+ )
460
+ if response.status_code >= 400:
461
+ raise MercuryError(
462
+ f"DELETE /categories/{category_id} -> {response.status_code}: {response.text[:300]}",
463
+ status=response.status_code,
464
+ )
465
+
466
+
467
+ def apply_plan(writer: MercuryCategoryWriter, plan: CategoryPlan) -> Iterator[AppliedCategory]:
468
+ """Send the plan's creates, then its edits, then its deletes, yielding each result.
469
+
470
+ Creates first so that a run Mercury cuts short has added categories rather
471
+ than half-edited them: a missing category is the state the plan already
472
+ describes, while a partly-edited one is a new state nobody asked for.
473
+ Deletes last for the sharper version of the same reason -- everything
474
+ before them is recoverable by running again, and they are not.
475
+
476
+ A generator rather than a list so the caller prints each write as it lands.
477
+ A refused create or edit raises `MercuryError`, and everything before it
478
+ has already been reported -- a run that wrote four things and then failed
479
+ must not look like a run that wrote nothing.
480
+
481
+ A refused **delete** is yielded as a report line carrying the error and the
482
+ run continues. Mercury may protect some of its own defaults, and one
483
+ category it will not part with says nothing about the next; stopping there
484
+ would make the rest of the list a mystery instead of a report. The caller
485
+ exits 1 on one.
486
+ """
487
+ for create in plan.creates:
488
+ written = writer.create_category(create.desired)
489
+ yield AppliedCategory(action="created", name=written.name, category_id=written.id)
490
+ for edit in plan.edits:
491
+ written = writer.edit_category(edit.category_id, edit.desired)
492
+ yield AppliedCategory(action="edited", name=written.name, category_id=written.id)
493
+ for delete in plan.deletes:
494
+ category = delete.category
495
+ try:
496
+ writer.delete_category(category.id)
497
+ except MercuryError as exc:
498
+ yield AppliedCategory(action="refused", name=category.name, category_id=category.id, error=str(exc))
499
+ continue
500
+ yield AppliedCategory(action="deleted", name=category.name, category_id=category.id)
@@ -0,0 +1,101 @@
1
+ """What each transaction is, and whether it becomes a Bank Transaction.
2
+
3
+ Pure functions over parsed transactions. Nothing here performs I/O, which is
4
+ what lets the whole routing table be exercised against captured payloads.
5
+
6
+ The routing:
7
+
8
+ | Signal | Destination |
9
+ | ----------------------------------------------------- | ------------------------------------------------- |
10
+ | a status nobody has assigned a meaning to | a person decides; nothing is posted |
11
+ | status not settled -- pending, failed, cancelled, ... | post nothing |
12
+ | status reversed | a person decides; nothing is posted or undone |
13
+ | everything else | a Bank Transaction on the account it moved through |
14
+
15
+ There is no category branch. The books run through ERPNext's Banking workspace
16
+ -- Bank Statement Import and the Bank Reconciliation Tool -- which works on
17
+ submitted Bank Transactions, and a person picks the account when they reconcile
18
+ each one to a voucher. Mercury's GL code and note ride along on the row as
19
+ hints for that person; they route nothing. An internal transfer is two
20
+ statement lines on two accounts and becomes two Bank Transactions, one per
21
+ account, which the tool matches to one transfer voucher. A category with no
22
+ mapping, or no category at all, is just a Bank Transaction with whatever hint
23
+ Mercury has.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import dataclasses
29
+ import enum
30
+ from collections.abc import Iterable
31
+
32
+ from runningtab.mercury.models import Status, Transaction
33
+
34
+
35
+ class Route(enum.StrEnum):
36
+ """Where a transaction goes."""
37
+
38
+ SKIP = "skip"
39
+ BANK_TRANSACTION = "bank-transaction"
40
+ ATTENTION = "attention"
41
+
42
+
43
+ @dataclasses.dataclass(frozen=True)
44
+ class Decision:
45
+ """The routing for one transaction, why, and whether a later run must look again.
46
+
47
+ `retryable` separates the two things that arrive as `ATTENTION`. A reversal
48
+ is settled with: there is a bookkeeping judgement to make and no amount of
49
+ re-running makes it, so the cursor may move past it. A status nobody has
50
+ classified is not: the row is fine and our configuration is short, and once
51
+ somebody says what the status means the transaction has to post. Retiring
52
+ it would need a re-snapshot to reach again, because it emits no new events.
53
+ """
54
+
55
+ route: Route
56
+ reason: str
57
+ retryable: bool = False
58
+
59
+ @property
60
+ def posts(self) -> bool:
61
+ """Whether this decision produces a ledger document at all."""
62
+ return self.route is Route.BANK_TRANSACTION
63
+
64
+
65
+ def is_internal_transfer(transaction: Transaction, own_account_ids: Iterable[str]) -> bool:
66
+ """Whether the counterparty is one of our own Mercury accounts.
67
+
68
+ Routing does not depend on the answer -- both legs post -- but the uncoded
69
+ report does: nobody codes a movement between our own accounts, so counting
70
+ one would put a floor under that number and hide the signal.
71
+
72
+ Keyed on `counterpartyId` against the account list rather than on `kind`:
73
+ the verified sandbox pair between checking and the credit account came
74
+ through as `kind: other`, not `internalTransfer`, so trusting `kind` alone
75
+ would have missed exactly the pair the rule is for.
76
+ """
77
+ return transaction.counterparty_id is not None and transaction.counterparty_id in set(own_account_ids)
78
+
79
+
80
+ def classify(transaction: Transaction) -> Decision:
81
+ """Route one transaction. Pure; the caller decides what to do with the answer."""
82
+ if not transaction.status_recognised:
83
+ # Before any other reading of the status, because every other branch
84
+ # here acts on a meaning somebody assigned and this one has none. A
85
+ # skip would claim there is nothing to post, and skips raise no
86
+ # attention -- so a settled status Mercury added after this was written
87
+ # would be left out of the ledger while the run exited clean.
88
+ return Decision(
89
+ Route.ATTENTION,
90
+ f"status {transaction.raw_status!r} is not one this ingest has semantics for; "
91
+ "decide what it means for the ledger before these rows are posted or dismissed",
92
+ retryable=True,
93
+ )
94
+ if transaction.status is Status.REVERSED:
95
+ return Decision(
96
+ Route.ATTENTION,
97
+ "reversed by the bank; a person decides what happens to any Bank Transaction already posted",
98
+ )
99
+ if not transaction.is_postable:
100
+ return Decision(Route.SKIP, f"status {transaction.raw_status} is not settled")
101
+ return Decision(Route.BANK_TRANSACTION, "settled; a statement line for the person reconciling")