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
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
|
+
"""
|