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.
- runningtab/__init__.py +3 -0
- runningtab/access.py +419 -0
- runningtab/books.py +225 -0
- runningtab/chart.py +408 -0
- runningtab/cli.py +2216 -0
- runningtab/coa.py +212 -0
- runningtab/doc.py +185 -0
- runningtab/forecast/__init__.py +1 -0
- runningtab/forecast/compute.py +224 -0
- runningtab/forecast/doctype.py +228 -0
- runningtab/forecast/model.py +251 -0
- runningtab/forecast/names.py +8 -0
- runningtab/forecast/sources.py +85 -0
- runningtab/gl_codes.py +50 -0
- runningtab/health.py +661 -0
- runningtab/mercury/__init__.py +7 -0
- runningtab/mercury/categories.py +500 -0
- runningtab/mercury/classify.py +101 -0
- runningtab/mercury/client.py +360 -0
- runningtab/mercury/ingest.py +733 -0
- runningtab/mercury/mapping.py +130 -0
- runningtab/mercury/models.py +544 -0
- runningtab/mercury/posting.py +757 -0
- runningtab/mercury/receipts.py +304 -0
- runningtab/mercury/session.py +65 -0
- runningtab/mercury/state.py +285 -0
- runningtab/mercury/vouchers.py +951 -0
- runningtab/period/__init__.py +9 -0
- runningtab/period/balances.py +283 -0
- runningtab/period/bank.py +154 -0
- runningtab/period/close.py +510 -0
- runningtab/period/crosscheck.py +257 -0
- runningtab/period/csvfmt.py +86 -0
- runningtab/period/documents.py +260 -0
- runningtab/period/fence.py +277 -0
- runningtab/period/ledger.py +205 -0
- runningtab/period/lock.py +294 -0
- runningtab/period/manifest.py +259 -0
- runningtab/period/open_items.py +84 -0
- runningtab/period/package.py +140 -0
- runningtab/period/qbo.py +521 -0
- runningtab/period/qbo_map.py +174 -0
- runningtab/period/quarter.py +90 -0
- runningtab/period/readiness.py +146 -0
- runningtab/period/record.py +247 -0
- runningtab/period/status.py +312 -0
- runningtab/site.py +409 -0
- runningtab-0.1.0.dev3.dist-info/METADATA +726 -0
- runningtab-0.1.0.dev3.dist-info/RECORD +52 -0
- runningtab-0.1.0.dev3.dist-info/WHEEL +4 -0
- runningtab-0.1.0.dev3.dist-info/entry_points.txt +2 -0
- 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")
|