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/health.py ADDED
@@ -0,0 +1,661 @@
1
+ """Whether the books are actually current: six checks and one exit code.
2
+
3
+ The books integration's likeliest failure is not a crash, it is silence -- a
4
+ webhook Mercury disabled after repeated delivery failures, a cursor that
5
+ stopped advancing, entries a person parked in suspense and never came back
6
+ for, a quarter reopened after its books were handed off. Each leaves a ledger
7
+ that looks fine and is wrong, and none of them raises anything. This module is
8
+ the thing that looks.
9
+
10
+ Everything here returns data; `runningtab.cli` prints it and picks the exit code.
11
+ Three of the six checks run today. The other three need the Mercury ingest and
12
+ a token for a real Mercury account, and they say so rather than passing
13
+ vacuously -- a check that reports success because it did not run is worse than
14
+ no check at all.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import dataclasses
20
+ import datetime as dt
21
+ import enum
22
+ import textwrap
23
+ import tomllib
24
+ from collections.abc import Callable, Iterable, Sequence
25
+ from decimal import Decimal
26
+ from typing import Any, Protocol
27
+
28
+ import httpx2
29
+ from google.api_core.exceptions import GoogleAPIError
30
+ from google.auth.exceptions import GoogleAuthError
31
+ from icedcoffee_sync.erpnext.accounts import document_name
32
+ from icedcoffee_sync.frappe.errors import FrappeError
33
+
34
+ from runningtab.chart import ChartError
35
+ from runningtab.coa import NoSoleCompanyError
36
+ from runningtab.mercury.client import MercuryError
37
+ from runningtab.mercury.ingest import BalanceCheck
38
+ from runningtab.mercury.ingest import reconcile as account_balance
39
+ from runningtab.mercury.mapping import MappingError
40
+ from runningtab.mercury.models import Account, CreditAccount, Status, Transaction
41
+ from runningtab.period import PeriodError
42
+ from runningtab.site import SiteCredentialError
43
+
44
+ # The statuses whose effect on `currentBalance` has actually been measured:
45
+ # `sent` counts toward it, `pending` and `failed` do not, on two sandbox
46
+ # accounts that then reconciled exactly. `cancelled`, `reversed`, and `blocked`
47
+ # are in the bank's enum and occurred in none of those reads, so the arithmetic
48
+ # treats them the way `models.UNBALANCED_STATUSES` says -- as counting -- and
49
+ # this report says out loud that it saw one. Absorbing an unmeasured status
50
+ # silently is how a real discrepancy gets explained away.
51
+ MEASURED_STATUSES: frozenset[Status] = frozenset({Status.SENT, Status.PENDING, Status.FAILED})
52
+
53
+
54
+ class Outcome(enum.StrEnum):
55
+ """How a check came out. Only `PASS` and `SKIP` are ever acceptable."""
56
+
57
+ PASS = "pass" # noqa: S105 -- an outcome, not a credential
58
+ FAIL = "fail"
59
+ # The check is not built yet. The only outcome `--strict` promotes, and
60
+ # the only one that means "nothing here has gone wrong".
61
+ SKIP = "n/a"
62
+ # A check that exists could not run: a credential unset or refused, an
63
+ # unreachable site, a missing account, a short read. Never gated behind a
64
+ # flag -- an unanswerable check is out of bounds by definition, because
65
+ # the report cannot say whether the books are current without it.
66
+ ERROR = "error"
67
+
68
+
69
+ @dataclasses.dataclass(frozen=True)
70
+ class Check:
71
+ """One named section of the report."""
72
+
73
+ key: str
74
+ title: str
75
+ outcome: Outcome
76
+ summary: str
77
+ detail: tuple[str, ...] = ()
78
+
79
+
80
+ def exit_code(checks: Sequence[Check], *, strict: bool) -> int:
81
+ """0 only when nothing is out of bounds.
82
+
83
+ A skipped check is not a failure by default: three of the five cannot run
84
+ until the ingest exists, and a command that always exits 1 gets wired to
85
+ nothing. `--strict` is for the day the ingest lands, when a skip means a
86
+ regression rather than a known gap.
87
+ """
88
+ if any(check.outcome in (Outcome.FAIL, Outcome.ERROR) for check in checks):
89
+ return 1
90
+ if strict and any(check.outcome is Outcome.SKIP for check in checks):
91
+ return 1
92
+ return 0
93
+
94
+
95
+ def wrapped(text: str) -> tuple[str, ...]:
96
+ """One explanatory sentence as indented report lines."""
97
+ return tuple(f" {line}" for line in textwrap.wrap(text, width=94))
98
+
99
+
100
+ def skipped(key: str, title: str, needs: str) -> Check:
101
+ """A check that has nothing to run against yet, and says what it is waiting for.
102
+
103
+ The summary is the same sentence for all three, so the detail line carries
104
+ the part that differs: what specifically has to exist before this one can
105
+ run. Three identical "not available" lines would tell a reader nothing
106
+ about which is closest to being real.
107
+ """
108
+ return Check(
109
+ key=key,
110
+ title=title,
111
+ outcome=Outcome.SKIP,
112
+ summary="not available: needs the Mercury ingest",
113
+ detail=wrapped(needs),
114
+ )
115
+
116
+
117
+ # --- the balance invariant ------------------------------------------------
118
+
119
+
120
+ @dataclasses.dataclass(frozen=True)
121
+ class AccountLedger:
122
+ """One account's reported balance next to the transactions behind it."""
123
+
124
+ account_id: str
125
+ label: str
126
+ current_balance: Decimal
127
+ transactions: tuple[Transaction, ...]
128
+
129
+
130
+ @dataclasses.dataclass(frozen=True)
131
+ class Reconciliation:
132
+ """What one account's arithmetic came to."""
133
+
134
+ # The arithmetic itself is the ingest's, not a second copy: one client
135
+ # reads the bank and one function sums it, so the number this report
136
+ # prints is the number the ingest acts on.
137
+ balance: BalanceCheck
138
+ settled_count: int
139
+ # The three counts are disjoint and cover the account: `currentBalance`
140
+ # counts the settled ones, is known not to count the unsettled ones, and
141
+ # nothing has established what it does with the unclassified ones. That
142
+ # last group is the one the check exists to surface, so it is counted on
143
+ # its own rather than folded into either measured side.
144
+ unsettled_count: int
145
+ unclassified_count: int
146
+
147
+ @property
148
+ def label(self) -> str:
149
+ """What a person reading the report calls this account."""
150
+ return self.balance.label
151
+
152
+ @property
153
+ def account_id(self) -> str:
154
+ return self.balance.account_id
155
+
156
+ @property
157
+ def current_balance(self) -> Decimal:
158
+ """The balance the bank reports."""
159
+ return self.balance.reported
160
+
161
+ @property
162
+ def settled_total(self) -> Decimal:
163
+ """The sum of the rows `currentBalance` counts."""
164
+ return self.balance.computed
165
+
166
+ @property
167
+ def delta(self) -> Decimal:
168
+ """Settled sum minus the balance the bank reports. Zero, or something is wrong."""
169
+ return self.balance.difference
170
+
171
+ @property
172
+ def reconciles(self) -> bool:
173
+ """Exact equality. There is no tolerance to set: both sides are exact decimals."""
174
+ return self.balance.ok
175
+
176
+
177
+ def unclassified(transaction: Transaction) -> bool:
178
+ """Whether nobody has measured what `currentBalance` does with this row's status."""
179
+ return transaction.status not in MEASURED_STATUSES
180
+
181
+
182
+ def settled_total(transactions: Iterable[Transaction]) -> Decimal:
183
+ """Sum of the transactions `currentBalance` counts -- everything but pending and failed."""
184
+ return account_balance("", "", Decimal(0), transactions).computed
185
+
186
+
187
+ def reconcile(ledgers: Iterable[AccountLedger]) -> list[Reconciliation]:
188
+ """The invariant, per account. Exact and free, and it catches a short read."""
189
+ results = []
190
+ for ledger in ledgers:
191
+ settled = [t for t in ledger.transactions if t.is_postable]
192
+ unsettled = [t for t in ledger.transactions if not t.counts_toward_balance]
193
+ unmeasured = [t for t in ledger.transactions if unclassified(t)]
194
+ results.append(
195
+ Reconciliation(
196
+ balance=account_balance(ledger.account_id, ledger.label, ledger.current_balance, ledger.transactions),
197
+ settled_count=len(settled),
198
+ unsettled_count=len(unsettled),
199
+ unclassified_count=len(unmeasured),
200
+ )
201
+ )
202
+ return results
203
+
204
+
205
+ def _amount(value: Decimal) -> str:
206
+ return f"{value:,.2f}"
207
+
208
+
209
+ def unclassified_statuses(ledgers: Iterable[AccountLedger]) -> dict[str, int]:
210
+ """Transaction statuses whose effect on `currentBalance` has never been established, and how many carry them.
211
+
212
+ The sum counts them, which is a guess, so the report says so rather than
213
+ letting the guess ride. Whichever way one of these actually behaves,
214
+ absorbing it silently is how a real discrepancy gets explained away.
215
+
216
+ Counted by `raw_status`, not by the parsed enum: a status Mercury adds
217
+ later parses to `blocked`, and reporting it under that name would name the
218
+ wrong thing to go and decide about.
219
+ """
220
+ counts: dict[str, int] = {}
221
+ for ledger in ledgers:
222
+ for transaction in ledger.transactions:
223
+ if unclassified(transaction):
224
+ counts[transaction.raw_status] = counts.get(transaction.raw_status, 0) + 1
225
+ return dict(sorted(counts.items()))
226
+
227
+
228
+ def check_balance_invariant(ledgers: Sequence[AccountLedger]) -> Check:
229
+ """For each account, the settled transactions must sum to `currentBalance`."""
230
+ title = "balance invariant"
231
+ if not ledgers:
232
+ return Check(
233
+ key="balance-invariant",
234
+ title=title,
235
+ outcome=Outcome.ERROR,
236
+ summary="no accounts read",
237
+ detail=wrapped("Mercury returned no accounts at all, which is not a state this organisation can be in"),
238
+ )
239
+ results = reconcile(ledgers)
240
+ detail = []
241
+ for result in results:
242
+ mark = "ok " if result.reconciles else "OFF"
243
+ counts = f"{result.settled_count} settled, {result.unsettled_count} pending/failed"
244
+ if result.unclassified_count:
245
+ counts += f", {result.unclassified_count} unclassified"
246
+ line = (
247
+ f" {mark} {result.label}: settled {_amount(result.settled_total)} "
248
+ f"vs currentBalance {_amount(result.current_balance)} "
249
+ f"({counts})"
250
+ )
251
+ if not result.reconciles:
252
+ line += f" -- off by {_amount(result.delta)}"
253
+ detail.append(line)
254
+
255
+ unclassified = unclassified_statuses(ledgers)
256
+ if unclassified:
257
+ detail.extend(
258
+ wrapped(
259
+ "counted in every sum above, because nothing has established whether currentBalance counts "
260
+ "them: " + ", ".join(f"{count} {status}" for status, count in unclassified.items()) + ". "
261
+ "Decide from a real account and put the status on the right side of "
262
+ "runningtab.mercury.models.UNBALANCED_STATUSES."
263
+ )
264
+ )
265
+
266
+ off = [r for r in results if not r.reconciles]
267
+ if off or unclassified:
268
+ problems = []
269
+ if off:
270
+ problems.append(f"{len(off)} of {len(results)} accounts do not reconcile")
271
+ if unclassified:
272
+ problems.append(f"{sum(unclassified.values())} transactions carry an unclassified status")
273
+ return Check(
274
+ key="balance-invariant",
275
+ title=title,
276
+ outcome=Outcome.FAIL,
277
+ summary="; ".join(problems),
278
+ detail=tuple(detail),
279
+ )
280
+ return Check(
281
+ key="balance-invariant",
282
+ title=title,
283
+ outcome=Outcome.PASS,
284
+ summary=f"{len(results)} accounts reconcile exactly",
285
+ detail=tuple(detail),
286
+ )
287
+
288
+
289
+ # --- suspense contents ----------------------------------------------------
290
+
291
+ # The ledger holds submitted documents only: ERPNext writes GL Entries in
292
+ # `on_submit`, so a draft against the suspense account has none of them and
293
+ # this check does not see it. That is fine now that nothing machine-made lands
294
+ # there: the Mercury ingest posts Bank Transactions, which reach the general
295
+ # ledger only when a person reconciles them, so an entry against 5299 is one a
296
+ # person made -- a voucher they reconciled a statement line to because they
297
+ # could not yet say where it belongs -- and a person's voucher is submitted.
298
+ SUSPENSE_DOCTYPE = "GL Entry"
299
+ _SUSPENSE_FIELDS = ["posting_date", "debit", "credit", "voucher_type", "voucher_no", "remarks", "creation"]
300
+
301
+
302
+ @dataclasses.dataclass(frozen=True)
303
+ class SuspenseEntry:
304
+ """One ledger entry against the suspense account."""
305
+
306
+ posting_date: dt.date
307
+ creation: str
308
+ amount: Decimal
309
+ voucher_type: str
310
+ voucher_no: str
311
+ remarks: str
312
+
313
+
314
+ @dataclasses.dataclass(frozen=True)
315
+ class SuspenseState:
316
+ """What sits in suspense, and since when."""
317
+
318
+ balance: Decimal
319
+ entry_count: int
320
+ # The posting date on which the balance last left zero -- None when the
321
+ # account is empty. Not simply the oldest entry: after a clear-out the
322
+ # oldest entry is ancient and the account is fine.
323
+ open_since: dt.date | None
324
+ age_days: int
325
+ # The entries that make up the current balance, in ledger order. Carried
326
+ # here rather than recovered afterwards by date: a clear-out and a refill
327
+ # on the same day are two runs that a date cannot separate, and the walk
328
+ # below already knows where one ended.
329
+ outstanding: tuple[SuspenseEntry, ...]
330
+
331
+ @property
332
+ def is_empty(self) -> bool:
333
+ """Whether the account nets to nothing. A balance of zero is the only acceptable state."""
334
+ return self.balance == 0
335
+
336
+
337
+ def _entry_order(entry: SuspenseEntry) -> tuple[dt.date, str]:
338
+ return (entry.posting_date, entry.creation)
339
+
340
+
341
+ def suspense_state(entries: Sequence[SuspenseEntry], as_of: dt.date) -> SuspenseState:
342
+ """Aggregate the suspense account, ageing from the last time it was empty.
343
+
344
+ Walking a running balance rather than taking the oldest entry: a
345
+ reclassification leaves the original entry in place with an opposite one
346
+ beside it, so an account that was emptied last week would otherwise report
347
+ the age of the first thing that ever landed in it.
348
+
349
+ The balance returning to zero is the only evidence available that a run
350
+ ended -- no ledger field links a reclassification back to what it
351
+ reverses -- so two unrelated entries of opposite sign read as a clear-out.
352
+ That costs the age and the listing, never the outcome: pass or fail is the
353
+ closing balance, which the walk does not change. Separating the two needs
354
+ provenance nothing writes yet.
355
+ """
356
+ balance = Decimal(0)
357
+ open_since: dt.date | None = None
358
+ outstanding: list[SuspenseEntry] = []
359
+ for entry in sorted(entries, key=_entry_order):
360
+ was_empty = balance == 0
361
+ balance += entry.amount
362
+ outstanding.append(entry)
363
+ if balance == 0:
364
+ open_since = None
365
+ outstanding.clear()
366
+ elif was_empty:
367
+ open_since = entry.posting_date
368
+ age = (as_of - open_since).days if open_since is not None else 0
369
+ return SuspenseState(
370
+ balance=balance,
371
+ entry_count=len(entries),
372
+ open_since=open_since,
373
+ age_days=age,
374
+ outstanding=tuple(outstanding),
375
+ )
376
+
377
+
378
+ def check_suspense(state: SuspenseState, account: str) -> Check:
379
+ """Anything left in suspense is unfinished bookkeeping, so a balance fails."""
380
+ title = "suspense contents"
381
+ if state.is_empty:
382
+ return Check(
383
+ key="suspense-contents",
384
+ title=title,
385
+ outcome=Outcome.PASS,
386
+ summary=f"{account} is empty ({state.entry_count} historical entries, all cleared)",
387
+ )
388
+ detail = [f" open since {state.open_since} ({state.age_days} days)"]
389
+ for entry in state.outstanding:
390
+ remarks = entry.remarks.strip().replace("\n", " ")
391
+ if len(remarks) > 80:
392
+ remarks = remarks[:77] + "..."
393
+ detail.append(
394
+ f" {entry.posting_date} {_amount(entry.amount):>14} {entry.voucher_type} {entry.voucher_no}"
395
+ + (f" {remarks}" if remarks else "")
396
+ )
397
+ return Check(
398
+ key="suspense-contents",
399
+ title=title,
400
+ outcome=Outcome.FAIL,
401
+ summary=f"{_amount(state.balance)} sitting in {account}, oldest {state.age_days} days",
402
+ detail=tuple(detail),
403
+ )
404
+
405
+
406
+ class _Reader(Protocol):
407
+ """The slice of the Frappe client the suspense read needs."""
408
+
409
+ def iter_all(self, doctype: str, **kwargs: Any) -> Any: ...
410
+
411
+
412
+ class MercuryReader(Protocol):
413
+ """The slice of the Mercury client the balance invariant needs."""
414
+
415
+ def accounts(self) -> list[Account]: ...
416
+ def credit_accounts(self) -> list[CreditAccount]: ...
417
+ def account_transactions(self, account_id: str, *, start: dt.datetime | None = None) -> list[Transaction]: ...
418
+
419
+
420
+ def _gl_money(value: object) -> Decimal:
421
+ """A GL Entry amount as an exact decimal.
422
+
423
+ The Frappe client parses responses with the standard library's defaults,
424
+ so debit and credit arrive as floats. `str()` first: `Decimal(float)`
425
+ would carry the binary representation's tail into the ledger arithmetic.
426
+ """
427
+ if value in (None, ""):
428
+ return Decimal(0)
429
+ return Decimal(str(value))
430
+
431
+
432
+ def read_suspense_entries(client: _Reader, company: str, account: str) -> list[SuspenseEntry]:
433
+ """Every live ledger entry against the suspense account.
434
+
435
+ Paged through `iter_all`, never a capped `list`: a short read here would
436
+ report a full suspense account as empty, which is precisely the silence
437
+ this command exists to break. Cancelled entries are excluded -- they no
438
+ longer affect the balance.
439
+ """
440
+ rows = client.iter_all(
441
+ SUSPENSE_DOCTYPE,
442
+ fields=_SUSPENSE_FIELDS,
443
+ filters=[["account", "=", account], ["company", "=", company], ["is_cancelled", "=", 0]],
444
+ order_by="posting_date asc",
445
+ )
446
+ return [
447
+ SuspenseEntry(
448
+ posting_date=dt.date.fromisoformat(str(row["posting_date"])),
449
+ creation=str(row.get("creation") or ""),
450
+ amount=_gl_money(row.get("debit")) - _gl_money(row.get("credit")),
451
+ voucher_type=str(row.get("voucher_type") or ""),
452
+ voucher_no=str(row.get("voucher_no") or ""),
453
+ remarks=str(row.get("remarks") or ""),
454
+ )
455
+ for row in rows
456
+ ]
457
+
458
+
459
+ def suspense_account_name(number: str, name: str, abbr: str) -> str:
460
+ """The suspense account's ERPNext document name, derived the way ERPNext derives it."""
461
+ return document_name(name, abbr, number)
462
+
463
+
464
+ # --- the report -----------------------------------------------------------
465
+
466
+
467
+ def uncoded_spend_placeholder() -> Check:
468
+ """Settled, non-transfer transactions carrying no GL code at Mercury, aged by days since posting.
469
+
470
+ The GL code is the one hint the person reconciling a Bank Transaction gets
471
+ about which account it belongs in; a row without one has to be coded
472
+ somewhere. `runningtab mercury snapshot`/`sync` report these per run as
473
+ `uncoded`; this section has nothing persisted to read them from between
474
+ runs.
475
+ """
476
+ return skipped(
477
+ "uncoded-spend",
478
+ "uncoded spend, aged",
479
+ "needs the ingest's read of the bank: `runningtab mercury sync` reports these rows per run as `uncoded`, "
480
+ "and this section has nothing persisted to read them from between runs",
481
+ )
482
+
483
+
484
+ def webhook_subscription_placeholder() -> Check:
485
+ """Mercury disables a webhook after consecutive delivery failures, invisibly from our side."""
486
+ return skipped(
487
+ "webhook-subscriptions",
488
+ "webhook subscription health",
489
+ "needs the ingest's webhook subscription to exist; there is nothing registered to report the status of",
490
+ )
491
+
492
+
493
+ def event_cursor_placeholder() -> Check:
494
+ """How long since the event log was last advanced."""
495
+ return skipped(
496
+ "event-cursor-age",
497
+ "event cursor age",
498
+ "needs the ingest's persisted last-processed event id; nothing writes a cursor yet",
499
+ )
500
+
501
+
502
+ def guarded(key: str, title: str, run: Callable[[], Check]) -> Check:
503
+ """Run one check, turning the ways it can fail to answer into the report itself.
504
+
505
+ Every one of these is an `ERROR`, including a credential that is simply
506
+ not configured. `SKIP` is reserved for the three checks that are not built
507
+ yet; a check that exists and did not run leaves the report unable to say
508
+ whether the books are current, and reporting success on that is the
509
+ failure this command exists to prevent. The messages say what to set or
510
+ create, so an `ERROR` is actionable rather than merely loud.
511
+
512
+ The closed-periods check reaches further than the other two that run
513
+ today -- the chart, the record bucket, a Mercury mapping file -- so its
514
+ own refusals (`PeriodError`, `ChartError`, `MappingError`, a bad TOML
515
+ file, and the two Google exceptions a missing or expired ADC credential
516
+ can raise) are caught here too, the same list `runningtab.cli`'s `period
517
+ close` and `period export` already refuse by name.
518
+
519
+ `LookupError` and `OSError` are caught in a second clause, with the
520
+ exception's own type name prefixed to the summary. Both are generic
521
+ enough to come from a genuine bug -- `company_abbr`'s deliberate
522
+ `LookupError` writes a full sentence, but its sibling `KeyError` from a
523
+ typo'd dict key anywhere in the rebuild carries only the repr of the
524
+ missing key, with no context at all -- and every other caught type here
525
+ already writes a hand-composed, self-explaining message, so prefixing
526
+ those would only be noise.
527
+ """
528
+ try:
529
+ return run()
530
+ except (
531
+ # Every Mercury credential this command uses is fetched by
532
+ # `runningtab.site`, so an unset token and a refused secret both arrive as
533
+ # a `SiteCredentialError`. `MercuryError` covers the reads themselves:
534
+ # an error status, a body the client cannot read, and a short read are
535
+ # all subclasses, and all mean the same thing here -- no answer.
536
+ SiteCredentialError,
537
+ MercuryError,
538
+ FrappeError,
539
+ NoSoleCompanyError,
540
+ httpx2.HTTPError,
541
+ PeriodError,
542
+ ChartError,
543
+ MappingError,
544
+ tomllib.TOMLDecodeError,
545
+ GoogleAPIError,
546
+ # ADC expiring or missing (constructing the record bucket client, or a
547
+ # revoked/expired token discovered on a read) raises from google.auth,
548
+ # not google.api_core -- GoogleAuthError is not a GoogleAPIError
549
+ # subclass, so it needs naming here too or it crashes instead of
550
+ # refusing.
551
+ GoogleAuthError,
552
+ ) as exc:
553
+ return Check(key=key, title=title, outcome=Outcome.ERROR, summary=f"could not run: {exc}")
554
+ except (
555
+ # `company_abbr` raises LookupError for a company the site does not
556
+ # have, or one with no abbreviation -- an operator naming the wrong
557
+ # `--company` should read a line, not a stack. But `LookupError`'s
558
+ # own subclass `KeyError` (and `IndexError`) is just as likely to be
559
+ # a programming bug in the rebuild path, whose bare text alone is
560
+ # useless -- the type name is what a reader needs to tell the two
561
+ # apart.
562
+ LookupError,
563
+ OSError,
564
+ ) as exc:
565
+ return Check(key=key, title=title, outcome=Outcome.ERROR, summary=f"could not run: {type(exc).__name__}: {exc}")
566
+
567
+
568
+ def read_ledgers(
569
+ client: MercuryReader,
570
+ *,
571
+ accounts: Sequence[Account] | None = None,
572
+ credit_accounts: Sequence[CreditAccount] | None = None,
573
+ ) -> list[AccountLedger]:
574
+ """Every account, deposit and credit, with every transaction since it opened.
575
+
576
+ Read per account rather than through the org-wide `/transactions`
577
+ endpoint: only the per-account response carries a `total`, and without one
578
+ a short read is undetectable. `/accounts` omits credit accounts entirely,
579
+ so both endpoints are read or the card account is silently skipped.
580
+
581
+ `accounts`/`credit_accounts` are optional so a caller that already has
582
+ the account lists -- `runningtab.period.bank.read_ledgers`, checking
583
+ `created_at` before this runs -- passes them straight through instead
584
+ of this function fetching them a second time. `runningtab health` passes
585
+ neither, so its own behaviour (one fetch, done here) is unchanged.
586
+ """
587
+ ledgers: list[AccountLedger] = []
588
+ for account in accounts if accounts is not None else client.accounts():
589
+ ledgers.append(
590
+ AccountLedger(
591
+ account_id=account.id,
592
+ label=account.label,
593
+ current_balance=account.current_balance,
594
+ transactions=tuple(client.account_transactions(account.id, start=account.created_at)),
595
+ )
596
+ )
597
+ for credit in credit_accounts if credit_accounts is not None else client.credit_accounts():
598
+ ledgers.append(
599
+ AccountLedger(
600
+ account_id=credit.id,
601
+ label=credit.label,
602
+ current_balance=credit.current_balance,
603
+ transactions=tuple(client.account_transactions(credit.id, start=credit.created_at)),
604
+ )
605
+ )
606
+ return ledgers
607
+
608
+
609
+ def balance_invariant(client: MercuryReader) -> Check:
610
+ """Read every account and check each one's arithmetic."""
611
+ return check_balance_invariant(read_ledgers(client))
612
+
613
+
614
+ def suspense_contents(client: Any, company: str, account: str, as_of: dt.date) -> Check:
615
+ """What is in the suspense account, and how long it has been there."""
616
+ if not client.list("Account", fields=["name"], filters=[["name", "=", account]], limit=1):
617
+ return Check(
618
+ key="suspense-contents",
619
+ title="suspense contents",
620
+ outcome=Outcome.ERROR,
621
+ summary=f"{account} is not on this site",
622
+ detail=wrapped(
623
+ "the chart declares the suspense account; run `runningtab coa apply` so entries have somewhere to land"
624
+ ),
625
+ )
626
+ entries = read_suspense_entries(client, company, account)
627
+ return check_suspense(suspense_state(entries, as_of), account)
628
+
629
+
630
+ def report(balance: Check, suspense: Check, closed_periods: Check) -> list[Check]:
631
+ """The six sections, in the one order the report ever prints them.
632
+
633
+ Assembled here rather than in the CLI so that "all six, always, in this
634
+ order" is a property of the report and not of one call site. Arithmetic
635
+ first, then what the arithmetic cannot see, then whether a closed
636
+ quarter is still closed.
637
+ """
638
+ return [
639
+ balance,
640
+ uncoded_spend_placeholder(),
641
+ suspense,
642
+ webhook_subscription_placeholder(),
643
+ event_cursor_placeholder(),
644
+ closed_periods,
645
+ ]
646
+
647
+
648
+ def render(checks: Sequence[Check]) -> list[str]:
649
+ """The report as lines. Human-readable first; the exit code is the contract."""
650
+ width = max(len(check.title) for check in checks)
651
+ lines = []
652
+ for check in checks:
653
+ lines.append(f"{check.title.ljust(width)} {check.outcome.value.upper():<5} {check.summary}")
654
+ lines.extend(check.detail)
655
+ counts = {outcome: sum(1 for c in checks if c.outcome is outcome) for outcome in Outcome}
656
+ lines.append("")
657
+ lines.append(
658
+ f"{counts[Outcome.PASS]} passed, {counts[Outcome.FAIL]} failed, "
659
+ f"{counts[Outcome.ERROR]} could not run, {counts[Outcome.SKIP]} not available"
660
+ )
661
+ return lines
@@ -0,0 +1,7 @@
1
+ """The Mercury half of the books integration: read the bank, post its statement lines into the ledger.
2
+
3
+ Read-only against Mercury: `client.py` performs no verb but GET. Against ERPNext
4
+ every settled transaction becomes a submitted Bank Transaction, the one document
5
+ this package submits; a Bank Transaction reaches the general ledger only when a
6
+ person reconciles it.
7
+ """