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
runningtab/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Keeping a company's books in ERPNext: the tooling that applies a books directory's chart, bank feed and close."""
2
+
3
+ __version__ = "0.0.1"
runningtab/access.py ADDED
@@ -0,0 +1,419 @@
1
+ """Policy for the books bot: exactly what it may touch, and the one thing it may submit.
2
+
3
+ The bot's role holds create/write for drafting and the chart of accounts, and
4
+ submit on Bank Transaction alone; cancel and amend are written as "0"
5
+ everywhere, and so is submit on every other doctype. The server is asked to
6
+ confirm all of it before a key is stored.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import dataclasses
12
+ from typing import Any, Protocol
13
+
14
+ from icedcoffee_sync.frappe.access import (
15
+ ApiKeyPair,
16
+ PermissionGrant,
17
+ RoleChange,
18
+ SubmitProbe,
19
+ UserChange,
20
+ ensure_role,
21
+ ensure_user,
22
+ generate_keys,
23
+ grant,
24
+ granted_flags_effective,
25
+ probe_submit,
26
+ prohibited_flags_allowed,
27
+ revoke_grants_except,
28
+ )
29
+ from icedcoffee_sync.frappe.client import SiteConfig
30
+
31
+ from runningtab.forecast.names import CASH_FORECAST
32
+
33
+ # Which role and user each identity is comes from the books directory's
34
+ # `books.toml` (`runningtab.books`); what each may touch is fixed here, and
35
+ # `bot_policy`/`reader_policy` put the two together.
36
+
37
+ # The one doctype the bot may submit. A Bank Transaction posts nothing to the
38
+ # general ledger on submit -- ERPNext writes the GL entries when a person
39
+ # reconciles it to a voucher -- and the Bank Reconciliation Tool only sees
40
+ # submitted rows, so the Mercury ingest submits the rows it creates and
41
+ # "machines draft, humans post" still holds in substance. Every other
42
+ # submittable doctype commits money on submit and stays draft-only.
43
+ BOT_SUBMITS: frozenset[str] = frozenset({"Bank Transaction"})
44
+
45
+ # Never granted: cancel or amend on any doctype, and submit outside
46
+ # BOT_SUBMITS. No DocType grant either: a role permission on DocType does not
47
+ # take effect (measured live), and nothing here reads meta.
48
+ GRANTS: tuple[PermissionGrant, ...] = (
49
+ PermissionGrant("Account", frozenset({"read", "write", "create", "delete"})),
50
+ PermissionGrant("Company", frozenset({"read", "write"})),
51
+ # `runningtab.period.fence` pushes Books Closed Through and its bypass role to
52
+ # permission level 1, so the ordinary permlevel-0 Company read above no
53
+ # longer reaches them. The Mercury ingest reads `accounts_frozen_till_date`
54
+ # every run to leave a closed quarter's rows to a person, so the bot is
55
+ # granted read at that level too -- read only, so the fence still proves
56
+ # the bot cannot write it.
57
+ PermissionGrant("Company", frozenset({"read"}), permlevel=1),
58
+ PermissionGrant("GL Entry", frozenset({"read"})),
59
+ PermissionGrant("Journal Entry", frozenset({"read", "write", "create"})),
60
+ PermissionGrant("Purchase Invoice", frozenset({"read", "write", "create"})),
61
+ PermissionGrant("Bank Transaction", frozenset({"read", "write", "create", "submit"})),
62
+ PermissionGrant("Supplier", frozenset({"read", "write", "create"})),
63
+ PermissionGrant("File", frozenset({"read", "write", "create"})),
64
+ # A forecast is written once and never changed: create without write or
65
+ # delete. Its child tables inherit the parent's permissions.
66
+ PermissionGrant(CASH_FORECAST, frozenset({"read", "create"})),
67
+ )
68
+
69
+ # Exactly what `coa plan` reads and nothing else: the accounts, the company
70
+ # they hang off, and which accounts carry ledger entries.
71
+ #
72
+ # GL Entry read is a deliberate decision, not an oversight, so that reviews
73
+ # stop re-raising it: plan needs only to know WHETHER an account has entries,
74
+ # and asking that cheaply (`list("GL Entry", filters=[["account","=",...]],
75
+ # limit=1)`) means read on the ledger. Narrowing it would take a whitelisted
76
+ # method on the site and a custom Frappe app to hold it. Reading the whole
77
+ # ledger is accepted on purpose: the reader is the tooling that keeps these
78
+ # books, reading the books it keeps.
79
+ READER_GRANTS: tuple[PermissionGrant, ...] = (
80
+ PermissionGrant("Account", frozenset({"read"})),
81
+ PermissionGrant("Company", frozenset({"read"})),
82
+ # `runningtab.period.fence` pushes Books Closed Through and its bypass role to
83
+ # permission level 1, so the ordinary permlevel-0 Company read above no
84
+ # longer reaches them. `status` and `health` still need to read
85
+ # `accounts_frozen_till_date`, so the reader is granted read at that level
86
+ # too -- as the bot is in `GRANTS`, for the Mercury ingest. Removing
87
+ # either grant makes that credential's lock read refuse by name
88
+ # (`runningtab.period.lock.read_visible_lock`).
89
+ PermissionGrant("Company", frozenset({"read"}), permlevel=1),
90
+ PermissionGrant("GL Entry", frozenset({"read"})),
91
+ # Read by `runningtab period export`: the ledger's parties, the fiscal years,
92
+ # and the vouchers whose attachments a private-file download is checked
93
+ # against.
94
+ PermissionGrant("Fiscal Year", frozenset({"read"})),
95
+ PermissionGrant("Customer", frozenset({"read"})),
96
+ PermissionGrant("Journal Entry", frozenset({"read"})),
97
+ PermissionGrant("Purchase Invoice", frozenset({"read"})),
98
+ PermissionGrant("Payment Entry", frozenset({"read"})),
99
+ PermissionGrant("Sales Invoice", frozenset({"read"})),
100
+ PermissionGrant("Bank Transaction", frozenset({"read"})),
101
+ PermissionGrant("File", frozenset({"read"})),
102
+ PermissionGrant("Supplier", frozenset({"read"})),
103
+ # Version ships read to System Manager and Administrator only
104
+ # (frappe/core/doctype/version/version.json); the reader inherits
105
+ # neither, so this is explicit. `runningtab.period.lock.lock_history` reads
106
+ # it to name who moved Books Closed Through and when -- status names who
107
+ # reopened a quarter.
108
+ PermissionGrant("Version", frozenset({"read"})),
109
+ )
110
+
111
+ # Every mutating flag, on every doctype the writing bot can reach. Read access
112
+ # is worthless as a boundary unless the absence of write access is proven
113
+ # rather than assumed: a role inherited from somewhere else, or a stray grant
114
+ # left on the user, would otherwise pass unnoticed.
115
+ #
116
+ # The list is deliberately wider than the reader's own grants. Checking only
117
+ # the doctypes we granted would prove nothing about an inherited permission on
118
+ # one we did not, and an inherited permission is the whole thing this check
119
+ # exists to catch. Every flag that changes a document is listed -- cancel and
120
+ # amend included, which a "can it write" check reads past too easily.
121
+ MUTATING_PTYPES: frozenset[str] = frozenset({"write", "create", "delete", "submit", "cancel", "amend"})
122
+ READER_PROHIBITED: dict[str, frozenset[str]] = dict.fromkeys(
123
+ (
124
+ "Account",
125
+ "Company",
126
+ "GL Entry",
127
+ "Journal Entry",
128
+ "Purchase Invoice",
129
+ "Bank Transaction",
130
+ "Supplier",
131
+ "File",
132
+ "Fiscal Year",
133
+ "Customer",
134
+ "Payment Entry",
135
+ "Sales Invoice",
136
+ "Version",
137
+ ),
138
+ MUTATING_PTYPES,
139
+ )
140
+
141
+ # What the reader can do that we did not grant and cannot take away here.
142
+ #
143
+ # MEASURED on the live site 2026-08-27, not inferred: the reader's role grants
144
+ # File nothing, the server reports create/write/delete on File as allowed, and
145
+ # a real insert SUCCEEDED (then was deleted again). Frappe hands this to
146
+ # authenticated users so attachments work; taking it away means editing a
147
+ # role's permissions for the whole site, and `DocPerm` is not even readable
148
+ # over REST to find out which role supplies it.
149
+ #
150
+ # Recorded here rather than dropped from the check, so that verification keeps
151
+ # reporting it every run instead of quietly narrowing what "read-only" claims.
152
+ # It reaches no account, no ledger entry and no company record -- but it is
153
+ # write access, and calling the reader "unable to write anything" would be a
154
+ # lie.
155
+ READER_INHERITED_WRITES: dict[str, frozenset[str]] = {"File": frozenset({"create", "write", "delete"})}
156
+
157
+
158
+ @dataclasses.dataclass(frozen=True)
159
+ class Policy:
160
+ """One identity on the books site: its role, its user, and what that role may touch."""
161
+
162
+ role: str
163
+ user: str
164
+ first_name: str
165
+ grants: tuple[PermissionGrant, ...]
166
+
167
+
168
+ def bot_policy(role: str, user: str, first_name: str) -> Policy:
169
+ """The writing bot: `GRANTS`, held by the role and user the books name."""
170
+ return Policy(role, user, first_name, GRANTS)
171
+
172
+
173
+ def reader_policy(role: str, user: str, first_name: str) -> Policy:
174
+ """The reader: `READER_GRANTS`, held by the role and user the books name."""
175
+ return Policy(role, user, first_name, READER_GRANTS)
176
+
177
+
178
+ # Frappe permissions are per doctype and per flag: the insert-based probe
179
+ # proves submit on Journal Entry only, so every prohibited flag is checked on
180
+ # every submittable doctype the bot is granted.
181
+ SUBMITTABLE_DOCTYPES: frozenset[str] = frozenset({"Journal Entry", "Purchase Invoice", "Bank Transaction"})
182
+ PROHIBITED_PTYPES: frozenset[str] = frozenset({"submit", "cancel", "amend"})
183
+
184
+ # Cash Forecast is not submittable; what must be refused is changing or
185
+ # removing a forecast once the bot has created it.
186
+ FORECAST_PROHIBITED: frozenset[str] = frozenset({"write", "delete"})
187
+
188
+
189
+ def prohibited_ptypes_for(doctype: str) -> frozenset[str]:
190
+ """The committing flags the bot must be denied on `doctype`: all three, less submit where it is granted."""
191
+ if doctype in BOT_SUBMITS:
192
+ return PROHIBITED_PTYPES - {"submit"}
193
+ return PROHIBITED_PTYPES
194
+
195
+
196
+ class KeyRotationFailedError(RuntimeError):
197
+ """`generate_keys` failed after it may already have rotated the live secret.
198
+
199
+ The call has no dry-run and no read-back, so a timeout looks the same as
200
+ a request that never arrived; assume the rotation landed.
201
+ """
202
+
203
+
204
+ @dataclasses.dataclass(frozen=True)
205
+ class ProvisionReport:
206
+ """What `provision` changed, and the bot's freshly minted credential."""
207
+
208
+ role_created: bool
209
+ role_desk_access_repaired: bool
210
+ role_disabled_repaired: bool
211
+ grants: dict[str, list[tuple[str, str]]]
212
+ revoked: list[str]
213
+ user: UserChange
214
+ keys: ApiKeyPair
215
+
216
+
217
+ class _ProvisionClient(Protocol):
218
+ """The slice of `Client` provisioning needs."""
219
+
220
+ def get(self, doctype: str, name: str) -> Any: ...
221
+ def insert(self, doctype: str, doc: dict[str, Any]) -> Any: ...
222
+ def update(self, doctype: str, name: str, doc: dict[str, Any]) -> Any: ...
223
+ def call(self, dotted_path: str, params: dict[str, Any] | None = None) -> Any: ...
224
+
225
+
226
+ def _grant_label(doctype: str, permlevel: int) -> str:
227
+ """`"<doctype>"` at permlevel 0, `"<doctype> (permlevel N)"` otherwise.
228
+
229
+ The spelling `revoke_grants_except` already uses for a revoked row. Every
230
+ dict this module keys per grant uses it too: two grants on the same
231
+ doctype at different permlevels -- the bot's and the reader's Company
232
+ grants both are, now -- must never collapse onto one key and silently
233
+ drop one of the two findings.
234
+ """
235
+ return doctype if permlevel == 0 else f"{doctype} (permlevel {permlevel})"
236
+
237
+
238
+ def provision(client: _ProvisionClient, policy: Policy) -> ProvisionReport:
239
+ """Create or repair `policy`'s role and user to exactly what it describes, and mint a fresh key.
240
+
241
+ Idempotent up to the key: grants are rewritten in full, stray grants and
242
+ roles revoked, a disabled role or user re-enabled. A role without desk
243
+ access makes the user a Website User, which the REST API serves fine.
244
+ """
245
+ role: RoleChange = ensure_role(client, policy.role)
246
+ sent = {_grant_label(g.doctype, g.permlevel): grant(client, policy.role, g) for g in policy.grants}
247
+ revoked = revoke_grants_except(client, policy.role, keep=frozenset((g.doctype, g.permlevel) for g in policy.grants))
248
+ user = ensure_user(client, policy.user, first_name=policy.first_name, roles=frozenset({policy.role}))
249
+ try:
250
+ keys = generate_keys(client, policy.user)
251
+ except Exception as exc:
252
+ raise KeyRotationFailedError(str(exc)) from exc
253
+ return ProvisionReport(
254
+ role_created=role.created,
255
+ role_desk_access_repaired=role.desk_access_repaired,
256
+ role_disabled_repaired=role.disabled_repaired,
257
+ grants=sent,
258
+ revoked=revoked,
259
+ user=user,
260
+ keys=keys,
261
+ )
262
+
263
+
264
+ def prohibited_flags_report(config: SiteConfig) -> dict[str, list[str]]:
265
+ """{doctype: [flag, ...]} for every prohibited flag the server still allows this credential.
266
+
267
+ Submit on Bank Transaction is granted, not prohibited, and `missing_grants_report`
268
+ is what proves the server honours it. Cash Forecast is checked for write and
269
+ delete, which the bot must never hold.
270
+ """
271
+ leaked: dict[str, list[str]] = {}
272
+ for doctype in sorted(SUBMITTABLE_DOCTYPES):
273
+ flags = prohibited_flags_allowed(config, doctype=doctype, ptypes=prohibited_ptypes_for(doctype))
274
+ allowed = sorted(ptype for ptype, value in flags.items() if value)
275
+ if allowed:
276
+ leaked[doctype] = allowed
277
+ flags = prohibited_flags_allowed(config, doctype=CASH_FORECAST, ptypes=FORECAST_PROHIBITED)
278
+ if allowed := sorted(ptype for ptype, value in flags.items() if value):
279
+ leaked[CASH_FORECAST] = allowed
280
+ return leaked
281
+
282
+
283
+ def missing_grants_report(config: SiteConfig, grants: tuple[PermissionGrant, ...] = GRANTS) -> dict[str, list[str]]:
284
+ """{label: [flag, ...]} for every permlevel-0 granted flag the server does not honour.
285
+
286
+ A grant above permlevel 0 is left out entirely here -- never asked about,
287
+ never reported missing. `granted_flags_effective` asks
288
+ `frappe.client.has_permission`, which has no permlevel of its own: it
289
+ cannot tell a permlevel-1 DocPerm row's presence from a permlevel-0 grant
290
+ already on the same doctype, so treating its answer as proof for a
291
+ permlevel-1 grant would be a check that always reads "effective" whether
292
+ or not the row exists -- a false green. `skipped_grants` names these
293
+ instead, and what actually proves them is a live round trip of the
294
+ field's value through the credential (`tests/test_live_fence.py`), not a
295
+ doctype-level yes/no.
296
+ """
297
+ missing: dict[str, list[str]] = {}
298
+ for g in grants:
299
+ if g.permlevel > 0:
300
+ continue
301
+ flags = granted_flags_effective(config, doctype=g.doctype, ptypes=g.ptypes)
302
+ not_effective = sorted(ptype for ptype, value in flags.items() if not value)
303
+ if not_effective:
304
+ missing[_grant_label(g.doctype, g.permlevel)] = not_effective
305
+ return missing
306
+
307
+
308
+ def skipped_grants(grants: tuple[PermissionGrant, ...] = GRANTS) -> list[str]:
309
+ """Labels of the grants `missing_grants_report` cannot check -- see its docstring.
310
+
311
+ Static over `grants` alone: which grants sit above permlevel 0 is known
312
+ before anything is asked of the server, so this makes no network call.
313
+ """
314
+ return [_grant_label(g.doctype, g.permlevel) for g in grants if g.permlevel > 0]
315
+
316
+
317
+ def reader_prohibited_report(config: SiteConfig) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
318
+ """Which mutating flags the server still allows a reader: (unexpected, known-inherited).
319
+
320
+ Split rather than merged so the known ones stay visible on every run
321
+ without failing it, and anything new fails immediately instead of hiding
322
+ among them.
323
+ """
324
+ leaked: dict[str, list[str]] = {}
325
+ inherited: dict[str, list[str]] = {}
326
+ for doctype, ptypes in sorted(READER_PROHIBITED.items()):
327
+ flags = prohibited_flags_allowed(config, doctype=doctype, ptypes=ptypes)
328
+ allowed = {ptype for ptype, value in flags.items() if value}
329
+ known = READER_INHERITED_WRITES.get(doctype, frozenset())
330
+ if expected := sorted(allowed & known):
331
+ inherited[doctype] = expected
332
+ if unexpected := sorted(allowed - known):
333
+ leaked[doctype] = unexpected
334
+ return leaked, inherited
335
+
336
+
337
+ def check_reader_credential(config: SiteConfig) -> CredentialCheck:
338
+ """Prove a reader credential can read what plan needs and cannot write anything.
339
+
340
+ No submit probe: the probe works by inserting a Journal Entry, which a
341
+ reader may not create at all, so its refusal would prove nothing about
342
+ submit. Asking the server which flags it honours is the whole check here,
343
+ and `probe` is recorded as refused because a credential that cannot create
344
+ cannot submit either.
345
+
346
+ Raises `CredentialCheckError` (naming the stage) when a check cannot run.
347
+ """
348
+ try:
349
+ leaked, inherited = reader_prohibited_report(config)
350
+ except Exception as exc:
351
+ raise CredentialCheckError("prohibited", exc) from exc
352
+ probe = SubmitProbe(refused=True, can_create=False, submit_flag=False)
353
+ if leaked:
354
+ return CredentialCheck(probe=probe, leaked=leaked, inherited=inherited)
355
+ try:
356
+ missing = missing_grants_report(config, READER_GRANTS)
357
+ except Exception as exc:
358
+ raise CredentialCheckError("grants", exc) from exc
359
+ return CredentialCheck(probe=probe, leaked=leaked, missing=missing, inherited=inherited)
360
+
361
+
362
+ class CredentialCheckError(RuntimeError):
363
+ """A credential check could not run; `stage` says which one."""
364
+
365
+ def __init__(self, stage: str, cause: Exception) -> None:
366
+ super().__init__(f"{stage}: {cause}")
367
+ self.stage = stage
368
+ self.cause = cause
369
+
370
+
371
+ @dataclasses.dataclass(frozen=True)
372
+ class CredentialCheck:
373
+ """The server's verdict on a credential, stage by stage; later stages are None once one fails."""
374
+
375
+ probe: SubmitProbe
376
+ leaked: dict[str, list[str]] | None = None
377
+ missing: dict[str, list[str]] | None = None
378
+ # Allowed, expected, and not a failure -- see READER_INHERITED_WRITES.
379
+ inherited: dict[str, list[str]] | None = None
380
+
381
+ @property
382
+ def passed(self) -> bool:
383
+ """True only when the probe's submit was refused, no prohibited flag leaked, and every grant is effective.
384
+
385
+ For the bot that means submit denied on Journal Entry and Purchase
386
+ Invoice, cancel and amend denied everywhere, and submit on Bank
387
+ Transaction actually honoured -- a grant on paper the server ignores
388
+ would fail the ingest on its first row.
389
+ """
390
+ return self.probe.refused and self.leaked == {} and self.missing == {}
391
+
392
+
393
+ def check_credential(config: SiteConfig, company: str) -> CredentialCheck:
394
+ """Prove a credential is scoped as policy says, stopping at the first stage that fails.
395
+
396
+ Raises `CredentialCheckError` (naming the stage) when a check cannot run;
397
+ an `OrphanProbeDocumentError` from the probe propagates as itself.
398
+ """
399
+ from icedcoffee_sync.frappe.access import OrphanProbeDocumentError
400
+
401
+ try:
402
+ probe = probe_submit(config, company=company)
403
+ except OrphanProbeDocumentError:
404
+ raise
405
+ except Exception as exc:
406
+ raise CredentialCheckError("probe", exc) from exc
407
+ if not probe.refused:
408
+ return CredentialCheck(probe=probe)
409
+ try:
410
+ leaked = prohibited_flags_report(config)
411
+ except Exception as exc:
412
+ raise CredentialCheckError("prohibited", exc) from exc
413
+ if leaked:
414
+ return CredentialCheck(probe=probe, leaked=leaked)
415
+ try:
416
+ missing = missing_grants_report(config)
417
+ except Exception as exc:
418
+ raise CredentialCheckError("grants", exc) from exc
419
+ return CredentialCheck(probe=probe, leaked=leaked, missing=missing)
runningtab/books.py ADDED
@@ -0,0 +1,225 @@
1
+ """The books directory: whose books runningtab is keeping, and everything it reads about them.
2
+
3
+ runningtab is the tool; the books are data it is pointed at. A books
4
+ directory holds the chart (`chart.coa`), the Mercury account map
5
+ (`mercury.toml`), the forecast (`forecast.toml`), the QuickBooks import map
6
+ (`qbo.toml`), and `books.toml`, which says where the ledger is and which
7
+ identities and secrets reach it.
8
+
9
+ There is no default directory and no fallback to one inside the package:
10
+ `--books DIR` or `RUNNINGTAB_BOOKS` names it, or the command refuses. A
11
+ silent fallback would plan against books nobody meant, and every difference
12
+ it reported would look real.
13
+
14
+ `books.toml` is read strictly. An unknown key is refused by its dotted name,
15
+ and so is a missing one, because a typo that quietly became a default is the
16
+ kind of mistake nobody notices until the ledger does.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import dataclasses
22
+ import os
23
+ import pathlib
24
+ import re
25
+ import tomllib
26
+ from collections.abc import Mapping
27
+ from typing import Any
28
+
29
+ from runningtab.access import Policy, bot_policy, reader_policy
30
+ from runningtab.site import Site, Target, normalised_host
31
+
32
+ BOOKS_ENV = "RUNNINGTAB_BOOKS"
33
+ BOOKS_FILE = "books.toml"
34
+ CHART_FILE = "chart.coa"
35
+ MERCURY_FILE = "mercury.toml"
36
+ FORECAST_FILE = "forecast.toml"
37
+ QBO_FILE = "qbo.toml"
38
+ _DATA_FILES = (CHART_FILE, MERCURY_FILE, FORECAST_FILE, QBO_FILE)
39
+
40
+ # One path segment: the name namespaces the Mercury ingest's local state.
41
+ _NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
42
+
43
+ # Every key `books.toml` may hold, and its type. All of them are required.
44
+ _STR = "a string"
45
+ _STR_LIST = "a list of strings"
46
+ _SCHEMA: dict[str, Any] = {
47
+ "name": _STR,
48
+ "chart_doc": _STR,
49
+ "site": {"host": _STR, "test_hosts": _STR_LIST},
50
+ "secrets": {
51
+ "project": _STR,
52
+ "books": _STR,
53
+ "books_readonly": _STR,
54
+ "mercury": _STR,
55
+ "test_admin": _STR,
56
+ },
57
+ "record_buckets": {"books": _STR, "test": _STR},
58
+ "bot": {"role": _STR, "user": _STR, "first_name": _STR},
59
+ "reader": {"role": _STR, "user": _STR, "first_name": _STR},
60
+ "fence": {"probe_role": _STR},
61
+ }
62
+
63
+
64
+ class BooksError(ValueError):
65
+ """No books directory was named, or the one named is not a set of books runningtab can read."""
66
+
67
+
68
+ @dataclasses.dataclass(frozen=True)
69
+ class Books:
70
+ """One loaded books directory."""
71
+
72
+ directory: pathlib.Path
73
+ # Names the books in what runningtab writes for a person (the period
74
+ # package's manifest) and namespaces its local state.
75
+ name: str
76
+ # `docs/chart-of-accounts.md`-style page whose account tables
77
+ # `runningtab chart render-doc` regenerates from the chart.
78
+ chart_doc: pathlib.Path
79
+ site: Site
80
+ # The record buckets a closed quarter's package is written to, one per
81
+ # target, so a caller never spells a bucket name itself.
82
+ record_buckets: Mapping[Target, str]
83
+ bot: Policy
84
+ reader: Policy
85
+ # The role `runningtab.period.fence` writes as Company's bypass role while
86
+ # proving the fence holds. Held by nobody; see that module.
87
+ fence_probe_role: str
88
+
89
+ @property
90
+ def chart_path(self) -> pathlib.Path:
91
+ """The chart of accounts."""
92
+ return self.directory / CHART_FILE
93
+
94
+ @property
95
+ def mercury_path(self) -> pathlib.Path:
96
+ """Which chart account and Bank Account each Mercury account is."""
97
+ return self.directory / MERCURY_FILE
98
+
99
+ @property
100
+ def forecast_path(self) -> pathlib.Path:
101
+ """The expected cash movements `runningtab forecast` reads."""
102
+ return self.directory / FORECAST_FILE
103
+
104
+ @property
105
+ def qbo_path(self) -> pathlib.Path:
106
+ """What QuickBooks Online needs to know about the chart."""
107
+ return self.directory / QBO_FILE
108
+
109
+
110
+ def resolve_books(flag: pathlib.Path | None, environ: Mapping[str, str] = os.environ) -> Books:
111
+ """Load the books directory `--books` names, else the one `RUNNINGTAB_BOOKS` names, else refuse.
112
+
113
+ Raises:
114
+ BooksError: Neither names a directory, or the one named does not load.
115
+ """
116
+ if flag is not None:
117
+ return load_books(flag)
118
+ named = environ.get(BOOKS_ENV, "")
119
+ if not named:
120
+ raise BooksError(
121
+ f"no books directory named: pass --books DIR or set {BOOKS_ENV}. "
122
+ "runningtab has no default books, so that it never plans against books nobody meant."
123
+ )
124
+ return load_books(pathlib.Path(named))
125
+
126
+
127
+ def load_books(directory: pathlib.Path) -> Books:
128
+ """Read `directory/books.toml` and check the directory holds every file runningtab reads.
129
+
130
+ Raises:
131
+ BooksError: Naming the file, key or host that is wrong.
132
+ """
133
+ directory = directory.resolve()
134
+ books_file = directory / BOOKS_FILE
135
+ try:
136
+ text = books_file.read_text(encoding="utf-8")
137
+ except OSError as exc:
138
+ raise BooksError(f"{books_file}: cannot read the books directory's {BOOKS_FILE}: {exc}") from exc
139
+ missing = [name for name in _DATA_FILES if not (directory / name).is_file()]
140
+ if missing:
141
+ raise BooksError(f"books directory {directory} is missing: {', '.join(missing)}")
142
+ try:
143
+ data = tomllib.loads(text)
144
+ except tomllib.TOMLDecodeError as exc:
145
+ raise BooksError(f"{books_file} is not valid TOML: {exc}") from exc
146
+ problems = _schema_problems(data, _SCHEMA, prefix="")
147
+ if problems:
148
+ raise BooksError(f"{books_file}:\n " + "\n ".join(problems))
149
+ return _books(directory, books_file, data)
150
+
151
+
152
+ def _schema_problems(data: Mapping[str, Any], schema: Mapping[str, Any], *, prefix: str) -> list[str]:
153
+ """Every unknown, missing or wrongly typed key under `prefix`, by its dotted name."""
154
+ problems: list[str] = []
155
+ for key in sorted(data.keys() - schema.keys()):
156
+ problems.append(f"{prefix}{key}: unknown key")
157
+ for key, expected in schema.items():
158
+ where = f"{prefix}{key}"
159
+ if key not in data:
160
+ problems.append(f"{where}: missing")
161
+ continue
162
+ value = data[key]
163
+ if isinstance(expected, dict):
164
+ if isinstance(value, dict):
165
+ problems.extend(_schema_problems(value, expected, prefix=f"{where}."))
166
+ else:
167
+ problems.append(f"{where}: must be a table")
168
+ elif expected == _STR and not (isinstance(value, str) and value.strip()):
169
+ problems.append(f"{where}: must be {_STR}, not empty")
170
+ elif expected == _STR_LIST and not (
171
+ isinstance(value, list) and all(isinstance(item, str) and item.strip() for item in value)
172
+ ):
173
+ problems.append(f"{where}: must be {_STR_LIST}")
174
+ return problems
175
+
176
+
177
+ def _books(directory: pathlib.Path, books_file: pathlib.Path, data: Mapping[str, Any]) -> Books:
178
+ """Build `Books` from data `_schema_problems` already passed, refusing what only makes sense together."""
179
+ name = data["name"]
180
+ if not _NAME.match(name):
181
+ raise BooksError(f"{books_file}: name {name!r} must be letters, digits, '-' and '_' only; it names a directory")
182
+ site_data, secrets = data["site"], data["secrets"]
183
+ site = Site(
184
+ host=site_data["host"],
185
+ test_hosts=frozenset(site_data["test_hosts"]),
186
+ secret_project=secrets["project"],
187
+ books_secret=secrets["books"],
188
+ books_readonly_secret=secrets["books_readonly"],
189
+ mercury_secret=secrets["mercury"],
190
+ test_admin_secret=secrets["test_admin"],
191
+ )
192
+ # The allowlist exists so that an alias of the ledger is refused. A list
193
+ # that names the ledger itself would let `--target test` reach it.
194
+ ledger = normalised_host(site.host)
195
+ if any(normalised_host(host) == ledger for host in site.test_hosts):
196
+ raise BooksError(
197
+ f"{books_file}: site.test_hosts names the books host {site.host}; "
198
+ "the test target must never be able to reach the ledger"
199
+ )
200
+ bot, reader = data["bot"], data["reader"]
201
+ if bot["user"] == reader["user"] or bot["role"] == reader["role"]:
202
+ raise BooksError(
203
+ f"{books_file}: bot and reader must be different users with different roles; "
204
+ "the reader's whole point is a credential that cannot write"
205
+ )
206
+ # `runningtab.period.fence` writes this role as the one allowed past a
207
+ # closed quarter, so it must be held by nobody. Frappe gives every user
208
+ # "Guest" and "All", and the bot and reader hold their own roles.
209
+ probe_role = data["fence"]["probe_role"]
210
+ if probe_role in {"Guest", "All", bot["role"], reader["role"]}:
211
+ raise BooksError(
212
+ f"{books_file}: fence.probe_role {probe_role!r} is a role somebody holds; "
213
+ "it must be one nobody is ever granted"
214
+ )
215
+ buckets = data["record_buckets"]
216
+ return Books(
217
+ directory=directory,
218
+ name=name,
219
+ chart_doc=(directory / data["chart_doc"]).resolve(),
220
+ site=site,
221
+ record_buckets={Target.BOOKS: buckets["books"], Target.TEST: buckets["test"]},
222
+ bot=bot_policy(bot["role"], bot["user"], bot["first_name"]),
223
+ reader=reader_policy(reader["role"], reader["user"], reader["first_name"]),
224
+ fence_probe_role=probe_role,
225
+ )