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/chart.py ADDED
@@ -0,0 +1,408 @@
1
+ """Load and validate a chart of accounts from a books directory's `chart.coa`.
2
+
3
+ The file is an indented outline: one account per line (`<number> <name>
4
+ [tags]`), two spaces per level, `>` lines carrying the account's note, and a
5
+ trailing `[defaults]` block. A group is any account with children -- an
6
+ empty group cannot be expressed. Every check here fails at load time so
7
+ that nothing fails halfway through an apply.
8
+
9
+ A tag block holds at most one `root:` tag, at most one ERPNext account type,
10
+ and the reserved word `no-bank-line`, which says the account only ever
11
+ receives a period-end or adjustment journal. The chart is the one place that
12
+ fact is written down; `runningtab.mercury.categories` reads it rather than keeping
13
+ a list of numbers beside the chart, because such a list goes stale invisibly.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import dataclasses
19
+ import pathlib
20
+ import re
21
+
22
+ from icedcoffee_sync.apply.model import DesiredAccount
23
+ from icedcoffee_sync.erpnext.accounts import ROOT_TYPES, UnknownAccountTypeError, validate_account_type
24
+
25
+ # Every Company default field the chart must declare. Kept outside the file
26
+ # so a truncated [defaults] block is a load error, not a silently
27
+ # half-configured ledger; change it and chart.coa together.
28
+ REQUIRED_DEFAULT_FIELDS: frozenset[str] = frozenset({
29
+ "default_bank_account",
30
+ "default_cash_account",
31
+ "default_receivable_account",
32
+ "default_payable_account",
33
+ "default_expense_account",
34
+ "default_income_account",
35
+ "round_off_account",
36
+ "exchange_gain_loss_account",
37
+ "write_off_account",
38
+ "default_deferred_revenue_account",
39
+ "default_deferred_expense_account",
40
+ "accumulated_depreciation_account",
41
+ "depreciation_expense_account",
42
+ "disposal_account",
43
+ })
44
+
45
+ # Where the ingest puts a transaction it could not classify, and what
46
+ # `runningtab health` ages. Named here rather than only in `chart.coa` so that
47
+ # deleting it from the chart is a load error: the alternative is a health
48
+ # report that quietly stops looking at the one account nobody watches.
49
+ SUSPENSE_ACCOUNT_NUMBER = "5299"
50
+
51
+ _ACCOUNT_LINE = re.compile(r"^(?P<number>\d+)(?:\s+(?P<rest>.*))?$")
52
+ _ROOT_TAG_PREFIX = "root:"
53
+ # A reserved tag word, recognised before the account-type slot so it never
54
+ # reaches `validate_account_type`. It marks an account that only ever receives
55
+ # a period-end or adjustment journal -- depreciation, rounding, FX, write-offs,
56
+ # a disposal gain -- and so can never be what somebody picks against a bank
57
+ # line. `runningtab.mercury.categories` reads it and offers no category for those.
58
+ NO_BANK_LINE_TAG = "no-bank-line"
59
+
60
+
61
+ class ChartError(ValueError):
62
+ """The chart file is not a chart we can apply."""
63
+
64
+
65
+ @dataclasses.dataclass(frozen=True)
66
+ class ChartEntry:
67
+ """One account as the outline describes it, plus its note."""
68
+
69
+ account: DesiredAccount
70
+ note: str
71
+ # Whether the line carried `no-bank-line`. Lives here and not on
72
+ # `DesiredAccount` because it says nothing to ERPNext: the account is
73
+ # ordinary there, and only the bank-facing pickers care.
74
+ no_bank_line: bool = False
75
+
76
+
77
+ @dataclasses.dataclass(frozen=True)
78
+ class ChartDocument:
79
+ """The parsed `chart.coa` file: accounts in file order, plus defaults."""
80
+
81
+ entries: list[ChartEntry]
82
+ defaults: dict[str, str]
83
+
84
+
85
+ def _split_name_and_tags(rest: str, lineno: int) -> tuple[str, list[str]]:
86
+ """Split `<name> [tag, tag]` into the name and the tag list.
87
+
88
+ Names may contain `&`, `/`, `(`, `)`, en dashes, `$`, and `,` -- only `[`
89
+ is reserved -- so the first `[` unambiguously starts the tag block.
90
+ """
91
+ if "[" not in rest:
92
+ name = rest.rstrip()
93
+ return name, []
94
+ name_part, _, tag_part = rest.partition("[")
95
+ name = name_part.rstrip()
96
+ if not tag_part.endswith("]"):
97
+ raise ChartError(f"line {lineno}: tag block is missing its closing ']'")
98
+ tag_content = tag_part[:-1].strip()
99
+ tags = [t.strip() for t in tag_content.split(",") if t.strip()]
100
+ if not tags:
101
+ raise ChartError(f"line {lineno}: empty tag block '[]'")
102
+ return name, tags
103
+
104
+
105
+ def _parse_tags(tags: list[str], lineno: int) -> tuple[str | None, str | None, bool]:
106
+ """Split tags into (root type or None, account type or None, no-bank-line)."""
107
+ root_declared: str | None = None
108
+ account_type: str | None = None
109
+ no_bank_line = False
110
+ for tag in tags:
111
+ if tag.startswith(_ROOT_TAG_PREFIX):
112
+ if root_declared is not None:
113
+ raise ChartError(f"line {lineno}: more than one 'root:' tag")
114
+ root_declared = tag[len(_ROOT_TAG_PREFIX) :].strip()
115
+ elif tag == NO_BANK_LINE_TAG:
116
+ # Matched before the account-type slot: it is a word of our own,
117
+ # not one of ERPNext's account types, and it shares the tag block
118
+ # with one (`[Depreciation, no-bank-line]`).
119
+ if no_bank_line:
120
+ raise ChartError(f"line {lineno}: more than one {NO_BANK_LINE_TAG!r} tag")
121
+ no_bank_line = True
122
+ else:
123
+ if account_type is not None:
124
+ raise ChartError(f"line {lineno}: more than one account-type tag ({account_type!r}, {tag!r})")
125
+ account_type = tag
126
+ return root_declared, account_type, no_bank_line
127
+
128
+
129
+ @dataclasses.dataclass
130
+ class _ParseState:
131
+ """Mutable state threaded through one pass over the file."""
132
+
133
+ entries: list[ChartEntry] = dataclasses.field(default_factory=list)
134
+ root_types_by_number: dict[str, str] = dataclasses.field(default_factory=dict)
135
+ numbers_seen: set[str] = dataclasses.field(default_factory=set)
136
+ ancestors: list[str] = dataclasses.field(default_factory=list) # ancestors[level] = number at that level
137
+ last_entry_index: int | None = None
138
+ defaults: dict[str, str] = dataclasses.field(default_factory=dict)
139
+
140
+
141
+ def _parse_default_line(stripped: str, lineno: int, defaults: dict[str, str]) -> None:
142
+ if "=" not in stripped:
143
+ raise ChartError(f"line {lineno}: expected 'key = number' inside [defaults]")
144
+ key, _, value = stripped.partition("=")
145
+ key, value = key.strip(), value.strip()
146
+ if not value.isdigit():
147
+ raise ChartError(f"line {lineno}: default {key!r} must be an account number, got {value!r}")
148
+ if key in defaults:
149
+ raise ChartError(f"line {lineno}: duplicate default {key!r}")
150
+ defaults[key] = value
151
+
152
+
153
+ def _append_note(state: _ParseState, stripped: str, lineno: int) -> None:
154
+ if state.last_entry_index is None:
155
+ raise ChartError(f"line {lineno}: note has no preceding account")
156
+ note_text = stripped[1:].strip()
157
+ previous = state.entries[state.last_entry_index]
158
+ state.entries[state.last_entry_index] = dataclasses.replace(previous, note=f"{previous.note} {note_text}".strip())
159
+
160
+
161
+ def _account_level(raw: str, lineno: int) -> tuple[int, str]:
162
+ indent = len(raw) - len(raw.lstrip(" "))
163
+ if indent % 2 != 0:
164
+ raise ChartError(f"line {lineno}: indent must be a whole number of two-space levels, got {indent} spaces")
165
+ return indent // 2, raw[indent:]
166
+
167
+
168
+ def _account_root_type(
169
+ level: int, parent_number: str | None, root_declared: str | None, number: str, lineno: int, state: _ParseState
170
+ ) -> str:
171
+ if level > 0:
172
+ if root_declared is not None:
173
+ raise ChartError(f"line {lineno}: account {number} is not a root; it must not carry 'root:'")
174
+ if parent_number is None:
175
+ # Unreachable in practice: _parse_account_line only calls this at
176
+ # level > 0 once it has confirmed a parent slot exists. Guarded
177
+ # explicitly anyway, so a future refactor that breaks that
178
+ # invariant fails loudly instead of crashing on a bad dict key.
179
+ raise ChartError(f"line {lineno}: account {number} has no parent to inherit a root type from")
180
+ return state.root_types_by_number[parent_number]
181
+ if root_declared is None:
182
+ raise ChartError(f"line {lineno}: root account {number} must carry a 'root: <RootType>' tag")
183
+ if root_declared not in ROOT_TYPES:
184
+ raise ChartError(
185
+ f"line {lineno}: root account {number} has root_type {root_declared!r}; expected one of {sorted(ROOT_TYPES)}"
186
+ )
187
+ return root_declared
188
+
189
+
190
+ def _parse_account_line(raw: str, lineno: int, state: _ParseState) -> None:
191
+ level, body = _account_level(raw, lineno)
192
+
193
+ match = _ACCOUNT_LINE.match(body)
194
+ if not match:
195
+ raise ChartError(f"line {lineno}: not a valid account line: {raw!r}")
196
+ number = match.group("number")
197
+ name, tags = _split_name_and_tags(match.group("rest") or "", lineno)
198
+ if not name:
199
+ raise ChartError(f"line {lineno}: account {number} has no name")
200
+
201
+ if number in state.numbers_seen:
202
+ raise ChartError(f"line {lineno}: duplicate account number {number}")
203
+ state.numbers_seen.add(number)
204
+
205
+ if level > len(state.ancestors):
206
+ raise ChartError(f"line {lineno}: account {number} is indented more than one level past the previous account")
207
+ del state.ancestors[level:]
208
+ parent_number = state.ancestors[-1] if state.ancestors else None
209
+
210
+ root_declared, account_type, no_bank_line = _parse_tags(tags, lineno)
211
+ root_type = _account_root_type(level, parent_number, root_declared, number, lineno, state)
212
+
213
+ try:
214
+ validate_account_type(account_type)
215
+ except UnknownAccountTypeError as exc:
216
+ raise ChartError(f"line {lineno}: account {number}: {exc}") from exc
217
+
218
+ state.ancestors.append(number)
219
+ state.root_types_by_number[number] = root_type
220
+ state.entries.append(
221
+ ChartEntry(
222
+ account=DesiredAccount(
223
+ number=number,
224
+ name=name,
225
+ parent_number=parent_number,
226
+ is_group=False, # derived below, once every child is known
227
+ root_type=root_type,
228
+ account_type=account_type,
229
+ ),
230
+ note="",
231
+ no_bank_line=no_bank_line,
232
+ )
233
+ )
234
+ state.last_entry_index = len(state.entries) - 1
235
+
236
+
237
+ def _derive_is_group(entries: list[ChartEntry]) -> list[ChartEntry]:
238
+ parents = {entry.account.parent_number for entry in entries if entry.account.parent_number}
239
+ return [
240
+ dataclasses.replace(entry, account=dataclasses.replace(entry.account, is_group=entry.account.number in parents))
241
+ for entry in entries
242
+ ]
243
+
244
+
245
+ def _parse(text: str) -> ChartDocument:
246
+ state = _ParseState()
247
+ in_defaults = False
248
+
249
+ for lineno, raw in enumerate(text.splitlines(), start=1):
250
+ stripped = raw.strip()
251
+ if not stripped or stripped.startswith("#"):
252
+ continue
253
+
254
+ if stripped == "[defaults]":
255
+ if in_defaults:
256
+ raise ChartError(f"line {lineno}: a second '[defaults]' section; it must appear once, last")
257
+ in_defaults = True
258
+ continue
259
+
260
+ if in_defaults:
261
+ _parse_default_line(stripped, lineno, state.defaults)
262
+ elif stripped.startswith(">"):
263
+ _append_note(state, stripped, lineno)
264
+ else:
265
+ _parse_account_line(raw, lineno, state)
266
+
267
+ return ChartDocument(entries=_derive_is_group(state.entries), defaults=state.defaults)
268
+
269
+
270
+ def load_chart_document(path: pathlib.Path) -> ChartDocument:
271
+ """Parse `chart.coa`, or raise `ChartError` naming the offending line."""
272
+ return _parse(path.read_text(encoding="utf-8"))
273
+
274
+
275
+ def parse_chart_entries(text: str) -> list[ChartEntry]:
276
+ """`load_chart_entries`, from `chart.coa` text already in hand rather than read from `path` here.
277
+
278
+ For a caller that must use one single read of the file across more than
279
+ one step -- `period export` validates the live chart against it, builds
280
+ the package from it, and hashes it for the manifest -- so that an edit
281
+ landing between those reads cannot make one step see different content
282
+ than another.
283
+ """
284
+ document = _parse(text)
285
+ accounts = [entry.account for entry in document.entries]
286
+ _check_match_keys(accounts)
287
+ _check_roots(accounts)
288
+ _check_defaults(accounts, document.defaults)
289
+ _check_suspense(accounts)
290
+ return document.entries
291
+
292
+
293
+ def load_chart_entries(path: pathlib.Path) -> list[ChartEntry]:
294
+ """Read the chart and run every check, keeping each account's note and tags.
295
+
296
+ `load_chart` is this with the notes and tags dropped. Anything that needs
297
+ `no_bank_line` -- Mercury's category selection -- comes through here, so
298
+ the flag reaches its reader from the same parse that validated the file.
299
+ """
300
+ return parse_chart_entries(path.read_text(encoding="utf-8"))
301
+
302
+
303
+ def parse_chart(text: str) -> list[DesiredAccount]:
304
+ """`load_chart`, from `chart.coa` text already in hand -- see `parse_chart_entries`."""
305
+ return [entry.account for entry in parse_chart_entries(text)]
306
+
307
+
308
+ def load_chart(path: pathlib.Path) -> list[DesiredAccount]:
309
+ """Read the chart, or raise ChartError describing exactly what is wrong."""
310
+ return [entry.account for entry in load_chart_entries(path)]
311
+
312
+
313
+ def suspense_account(accounts: list[DesiredAccount]) -> DesiredAccount:
314
+ """The account a person parks a statement line in when they cannot yet say where it belongs.
315
+
316
+ Present because `load_chart` insists: `runningtab health` ages what sits here.
317
+ """
318
+ return next(a for a in accounts if a.number == SUSPENSE_ACCOUNT_NUMBER)
319
+
320
+
321
+ def _check_suspense(accounts: list[DesiredAccount]) -> None:
322
+ """The suspense account exists, is postable, and is an expense."""
323
+ account = next((a for a in accounts if a.number == SUSPENSE_ACCOUNT_NUMBER), None)
324
+ if account is None:
325
+ raise ChartError(
326
+ f"chart has no suspense account {SUSPENSE_ACCOUNT_NUMBER}; a person reconciling a statement line "
327
+ f"they cannot yet code has nowhere to park it, and `runningtab health` has nothing to age"
328
+ )
329
+ if account.is_group:
330
+ raise ChartError(f"suspense account {SUSPENSE_ACCOUNT_NUMBER} is a group and cannot be posted to")
331
+ if account.root_type != "Expense":
332
+ raise ChartError(
333
+ f"suspense account {SUSPENSE_ACCOUNT_NUMBER} has root_type {account.root_type!r}; expected 'Expense'"
334
+ )
335
+
336
+
337
+ def _check_roots(accounts: list[DesiredAccount]) -> None:
338
+ """Exactly one root per ERPNext root type.
339
+
340
+ A missing root type would make the site's whole subtree for it extras,
341
+ which prune would delete after nothing but the usual prompt.
342
+ """
343
+ root_types = [a.root_type for a in accounts if a.parent_number is None]
344
+ missing = sorted(ROOT_TYPES - set(root_types))
345
+ if missing:
346
+ raise ChartError(f"chart has no root account for root_type(s): {', '.join(missing)}")
347
+ duplicated = sorted({rt for rt in root_types if root_types.count(rt) > 1})
348
+ if duplicated:
349
+ raise ChartError(f"chart declares more than one root for root_type(s): {', '.join(duplicated)}")
350
+
351
+
352
+ def _check_match_keys(accounts: list[DesiredAccount]) -> None:
353
+ """Refuse two accounts `diff` cannot tell apart: same name under the same parent.
354
+
355
+ Keyed on the parent's number, not its name, because the matching walk
356
+ descends by parent identity; roots are identified by root type instead.
357
+ """
358
+ seen: dict[tuple[str, str], str] = {}
359
+ for account in accounts:
360
+ if account.parent_number is None:
361
+ continue
362
+ key = (account.name, account.parent_number)
363
+ if key in seen:
364
+ raise ChartError(
365
+ f"accounts {seen[key]} and {account.number} share name {account.name!r} "
366
+ f"under the same parent; the diff cannot tell them apart"
367
+ )
368
+ seen[key] = account.number
369
+
370
+
371
+ def _check_defaults(accounts: list[DesiredAccount], defaults: dict[str, str]) -> None:
372
+ """Every default names a postable chart account, and the set is exactly `REQUIRED_DEFAULT_FIELDS`.
373
+
374
+ Values are checked before the key set so a bad value is reported as
375
+ itself rather than as a completeness complaint. ERPNext cannot post to
376
+ a group, so a default naming one is refused.
377
+ """
378
+ by_number = {a.number: a for a in accounts}
379
+ for field, number in defaults.items():
380
+ account = by_number.get(number)
381
+ if account is None:
382
+ raise ChartError(f"default {field!r} names account {number}, which is not in the chart")
383
+ if account.is_group:
384
+ raise ChartError(
385
+ f"default {field!r} names account {number} ({account.name!r}), which is a group and cannot be posted to"
386
+ )
387
+
388
+ missing_fields = sorted(REQUIRED_DEFAULT_FIELDS - defaults.keys())
389
+ if missing_fields:
390
+ raise ChartError(f"chart's [defaults] section is missing required field(s): {', '.join(missing_fields)}")
391
+ unexpected_fields = sorted(defaults.keys() - REQUIRED_DEFAULT_FIELDS)
392
+ if unexpected_fields:
393
+ raise ChartError(
394
+ f"chart's [defaults] section names field(s) not in REQUIRED_DEFAULT_FIELDS: {', '.join(unexpected_fields)}"
395
+ )
396
+
397
+
398
+ def parse_defaults(text: str) -> dict[str, str]:
399
+ """`load_defaults`, from `chart.coa` text already in hand -- see `parse_chart_entries`."""
400
+ document = _parse(text)
401
+ accounts = [entry.account for entry in document.entries]
402
+ _check_defaults(accounts, document.defaults)
403
+ return dict(document.defaults)
404
+
405
+
406
+ def load_defaults(path: pathlib.Path) -> dict[str, str]:
407
+ """Company default-account fields, as account numbers."""
408
+ return parse_defaults(path.read_text(encoding="utf-8"))