runningtab 0.1.0.dev3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- runningtab/__init__.py +3 -0
- runningtab/access.py +419 -0
- runningtab/books.py +225 -0
- runningtab/chart.py +408 -0
- runningtab/cli.py +2216 -0
- runningtab/coa.py +212 -0
- runningtab/doc.py +185 -0
- runningtab/forecast/__init__.py +1 -0
- runningtab/forecast/compute.py +224 -0
- runningtab/forecast/doctype.py +228 -0
- runningtab/forecast/model.py +251 -0
- runningtab/forecast/names.py +8 -0
- runningtab/forecast/sources.py +85 -0
- runningtab/gl_codes.py +50 -0
- runningtab/health.py +661 -0
- runningtab/mercury/__init__.py +7 -0
- runningtab/mercury/categories.py +500 -0
- runningtab/mercury/classify.py +101 -0
- runningtab/mercury/client.py +360 -0
- runningtab/mercury/ingest.py +733 -0
- runningtab/mercury/mapping.py +130 -0
- runningtab/mercury/models.py +544 -0
- runningtab/mercury/posting.py +757 -0
- runningtab/mercury/receipts.py +304 -0
- runningtab/mercury/session.py +65 -0
- runningtab/mercury/state.py +285 -0
- runningtab/mercury/vouchers.py +951 -0
- runningtab/period/__init__.py +9 -0
- runningtab/period/balances.py +283 -0
- runningtab/period/bank.py +154 -0
- runningtab/period/close.py +510 -0
- runningtab/period/crosscheck.py +257 -0
- runningtab/period/csvfmt.py +86 -0
- runningtab/period/documents.py +260 -0
- runningtab/period/fence.py +277 -0
- runningtab/period/ledger.py +205 -0
- runningtab/period/lock.py +294 -0
- runningtab/period/manifest.py +259 -0
- runningtab/period/open_items.py +84 -0
- runningtab/period/package.py +140 -0
- runningtab/period/qbo.py +521 -0
- runningtab/period/qbo_map.py +174 -0
- runningtab/period/quarter.py +90 -0
- runningtab/period/readiness.py +146 -0
- runningtab/period/record.py +247 -0
- runningtab/period/status.py +312 -0
- runningtab/site.py +409 -0
- runningtab-0.1.0.dev3.dist-info/METADATA +726 -0
- runningtab-0.1.0.dev3.dist-info/RECORD +52 -0
- runningtab-0.1.0.dev3.dist-info/WHEEL +4 -0
- runningtab-0.1.0.dev3.dist-info/entry_points.txt +2 -0
- runningtab-0.1.0.dev3.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""A quarter's books as a package an accountant or auditor can work from.
|
|
2
|
+
|
|
3
|
+
Built from one read of the ledger by pure functions, so every file agrees with
|
|
4
|
+
every other and a rebuild of the same quarter reproduces the same bytes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PeriodError(ValueError):
|
|
9
|
+
"""The package cannot be built as asked. The message names every offending item."""
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""The trial balance, and the statements and listings derived from the same rows.
|
|
2
|
+
|
|
3
|
+
Opening balances follow ERPNext's own Trial Balance report so the two can be
|
|
4
|
+
compared account by account: a balance-sheet account opens with everything
|
|
5
|
+
before the quarter, a P&L account with everything since the fiscal year
|
|
6
|
+
began. P&L from earlier years that no Period Closing Voucher closed is carried
|
|
7
|
+
as one line, so the trial balance and balance sheet balance without one.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import dataclasses
|
|
13
|
+
import datetime as dt
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from decimal import Decimal
|
|
17
|
+
|
|
18
|
+
from icedcoffee_sync.apply.model import DesiredAccount
|
|
19
|
+
|
|
20
|
+
from runningtab.period import PeriodError
|
|
21
|
+
from runningtab.period.csvfmt import money, render_csv
|
|
22
|
+
from runningtab.period.ledger import AccountIndex, GLRow, require_known_accounts
|
|
23
|
+
from runningtab.period.quarter import FiscalYear, Quarter
|
|
24
|
+
|
|
25
|
+
PRIOR_YEAR_LABEL = "Prior-year earnings, unclosed"
|
|
26
|
+
CURRENT_YEAR_LABEL = "Current-year earnings"
|
|
27
|
+
_ZERO = Decimal(0)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_profit_and_loss(account: DesiredAccount) -> bool:
|
|
31
|
+
return account.root_type in ("Income", "Expense")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclasses.dataclass(frozen=True)
|
|
35
|
+
class AccountBalance:
|
|
36
|
+
"""One postable account's figures for the quarter. Every figure is debit minus credit."""
|
|
37
|
+
|
|
38
|
+
account: DesiredAccount
|
|
39
|
+
opening: Decimal
|
|
40
|
+
period_debit: Decimal
|
|
41
|
+
period_credit: Decimal
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def closing(self) -> Decimal:
|
|
45
|
+
return self.opening + self.period_debit - self.period_credit
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclasses.dataclass(frozen=True)
|
|
49
|
+
class TrialBalance:
|
|
50
|
+
quarter: Quarter
|
|
51
|
+
fiscal_year: FiscalYear
|
|
52
|
+
lines: tuple[AccountBalance, ...]
|
|
53
|
+
prior_year_unclosed: Decimal
|
|
54
|
+
|
|
55
|
+
def _line(self, number: str) -> AccountBalance:
|
|
56
|
+
for line in self.lines:
|
|
57
|
+
if line.account.number == number:
|
|
58
|
+
return line
|
|
59
|
+
raise KeyError(number)
|
|
60
|
+
|
|
61
|
+
def closing(self, number: str) -> Decimal:
|
|
62
|
+
return self._line(number).closing
|
|
63
|
+
|
|
64
|
+
def opening(self, number: str) -> Decimal:
|
|
65
|
+
return self._line(number).opening
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _refuse_out_of_scope(rows: Sequence[GLRow], index: AccountIndex, quarter: Quarter, fy: FiscalYear) -> None:
|
|
69
|
+
"""Refuse rows this trial balance cannot place without double-counting or reading past the quarter."""
|
|
70
|
+
pcv = sorted({r.voucher_no for r in rows if r.voucher_type == "Period Closing Voucher"})
|
|
71
|
+
if pcv:
|
|
72
|
+
raise PeriodError(f"Period Closing Vouchers are not handled yet: {', '.join(pcv)}")
|
|
73
|
+
odd = sorted(
|
|
74
|
+
r.name for r in rows if r.is_opening and is_profit_and_loss(index[r.account]) and r.posting_date >= fy.start
|
|
75
|
+
)
|
|
76
|
+
if odd:
|
|
77
|
+
raise PeriodError(f"P&L entries marked as opening inside the fiscal year are not handled: {', '.join(odd)}")
|
|
78
|
+
# A balance-sheet account's `is_opening` row dated inside the quarter is
|
|
79
|
+
# ERPNext's own opening-balance convention (see the module docstring),
|
|
80
|
+
# but `journal_entries` and `general_ledger_csv` both take every voucher
|
|
81
|
+
# dated in the quarter as period activity regardless of `is_opening` --
|
|
82
|
+
# so this row would land in both the opening balance here and the
|
|
83
|
+
# period's general ledger and QBO kit. Refuse rather than silently
|
|
84
|
+
# double-count it.
|
|
85
|
+
double_counted = sorted(
|
|
86
|
+
(
|
|
87
|
+
r
|
|
88
|
+
for r in rows
|
|
89
|
+
if r.is_opening
|
|
90
|
+
and not is_profit_and_loss(index[r.account])
|
|
91
|
+
and quarter.start <= r.posting_date <= quarter.end
|
|
92
|
+
),
|
|
93
|
+
key=lambda r: r.name,
|
|
94
|
+
)
|
|
95
|
+
if double_counted:
|
|
96
|
+
names = ", ".join(r.name for r in double_counted)
|
|
97
|
+
vouchers = ", ".join(sorted({r.voucher_no for r in double_counted}))
|
|
98
|
+
day_before = quarter.start - dt.timedelta(days=1)
|
|
99
|
+
raise PeriodError(
|
|
100
|
+
f"opening entries dated inside {quarter.label} would be counted both as an opening balance and "
|
|
101
|
+
f"as period activity: GL Entries {names} (voucher(s) {vouchers}); opening entries must be dated "
|
|
102
|
+
f"on or before {day_before.isoformat()}, the day before {quarter.label} starts"
|
|
103
|
+
)
|
|
104
|
+
future = [r for r in rows if r.posting_date > quarter.end]
|
|
105
|
+
if future:
|
|
106
|
+
latest = max(r.posting_date for r in future)
|
|
107
|
+
raise PeriodError(
|
|
108
|
+
f"{len(future)} GL Entries are dated after {quarter.label} ends ({quarter.end.isoformat()}); "
|
|
109
|
+
f"the latest is dated {latest.isoformat()}. A caller must filter its read to the quarter's end; "
|
|
110
|
+
"this refusal does not depend on it."
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def trial_balance(
|
|
115
|
+
rows: Sequence[GLRow], index: AccountIndex, quarter: Quarter, fiscal_year: FiscalYear
|
|
116
|
+
) -> TrialBalance:
|
|
117
|
+
require_known_accounts(rows, index)
|
|
118
|
+
_refuse_out_of_scope(rows, index, quarter, fiscal_year)
|
|
119
|
+
opening: dict[str, Decimal] = defaultdict(Decimal)
|
|
120
|
+
debit: dict[str, Decimal] = defaultdict(Decimal)
|
|
121
|
+
credit: dict[str, Decimal] = defaultdict(Decimal)
|
|
122
|
+
prior = _ZERO
|
|
123
|
+
for row in rows:
|
|
124
|
+
account = index[row.account]
|
|
125
|
+
in_quarter = quarter.start <= row.posting_date <= quarter.end
|
|
126
|
+
if is_profit_and_loss(account):
|
|
127
|
+
if row.posting_date < fiscal_year.start:
|
|
128
|
+
prior += row.net
|
|
129
|
+
elif in_quarter:
|
|
130
|
+
debit[account.number] += row.debit
|
|
131
|
+
credit[account.number] += row.credit
|
|
132
|
+
else:
|
|
133
|
+
opening[account.number] += row.net
|
|
134
|
+
elif in_quarter and not row.is_opening:
|
|
135
|
+
debit[account.number] += row.debit
|
|
136
|
+
credit[account.number] += row.credit
|
|
137
|
+
else:
|
|
138
|
+
opening[account.number] += row.net
|
|
139
|
+
postable = sorted((a for a in index.values() if not a.is_group), key=lambda a: a.number)
|
|
140
|
+
lines = tuple(AccountBalance(a, opening[a.number], debit[a.number], credit[a.number]) for a in postable)
|
|
141
|
+
total = sum((line.closing for line in lines), _ZERO) + prior
|
|
142
|
+
if total != 0:
|
|
143
|
+
raise PeriodError(
|
|
144
|
+
f"the ledger read does not balance: debits exceed credits by {total}; "
|
|
145
|
+
"the read was short or a voucher is one-sided"
|
|
146
|
+
)
|
|
147
|
+
return TrialBalance(quarter, fiscal_year, lines, prior)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _split(net: Decimal) -> tuple[str, str]:
|
|
151
|
+
return (money(net), money(_ZERO)) if net >= 0 else (money(_ZERO), money(-net))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def trial_balance_csv(tb: TrialBalance) -> bytes:
|
|
155
|
+
"""One row per postable account by number, then the prior-year line. Net presentation."""
|
|
156
|
+
header = [
|
|
157
|
+
"number",
|
|
158
|
+
"name",
|
|
159
|
+
"root_type",
|
|
160
|
+
"opening_debit",
|
|
161
|
+
"opening_credit",
|
|
162
|
+
"period_debit",
|
|
163
|
+
"period_credit",
|
|
164
|
+
"closing_debit",
|
|
165
|
+
"closing_credit",
|
|
166
|
+
]
|
|
167
|
+
rows = [
|
|
168
|
+
[
|
|
169
|
+
line.account.number,
|
|
170
|
+
line.account.name,
|
|
171
|
+
line.account.root_type,
|
|
172
|
+
*_split(line.opening),
|
|
173
|
+
money(line.period_debit),
|
|
174
|
+
money(line.period_credit),
|
|
175
|
+
*_split(line.closing),
|
|
176
|
+
]
|
|
177
|
+
for line in tb.lines
|
|
178
|
+
]
|
|
179
|
+
rows.append([
|
|
180
|
+
"",
|
|
181
|
+
PRIOR_YEAR_LABEL,
|
|
182
|
+
"Equity",
|
|
183
|
+
*_split(tb.prior_year_unclosed),
|
|
184
|
+
money(_ZERO),
|
|
185
|
+
money(_ZERO),
|
|
186
|
+
*_split(tb.prior_year_unclosed),
|
|
187
|
+
])
|
|
188
|
+
return render_csv(header, rows)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def profit_and_loss_csv(tb: TrialBalance) -> bytes:
|
|
192
|
+
"""Income then expense accounts by number; amounts in their natural sign (income and expense both positive)."""
|
|
193
|
+
header = ["number", "name", "root_type", "quarter", "year_to_date"]
|
|
194
|
+
rows: list[list[str]] = []
|
|
195
|
+
totals = {"Income": [_ZERO, _ZERO], "Expense": [_ZERO, _ZERO]}
|
|
196
|
+
for root in ("Income", "Expense"):
|
|
197
|
+
sign = -1 if root == "Income" else 1
|
|
198
|
+
for line in tb.lines:
|
|
199
|
+
if line.account.root_type != root:
|
|
200
|
+
continue
|
|
201
|
+
quarter = sign * (line.period_debit - line.period_credit)
|
|
202
|
+
ytd = sign * line.closing
|
|
203
|
+
totals[root][0] += quarter
|
|
204
|
+
totals[root][1] += ytd
|
|
205
|
+
rows.append([line.account.number, line.account.name, root, money(quarter), money(ytd)])
|
|
206
|
+
rows.append(["", f"Total {root.lower()}", root, money(totals[root][0]), money(totals[root][1])])
|
|
207
|
+
net_q = totals["Income"][0] - totals["Expense"][0]
|
|
208
|
+
net_y = totals["Income"][1] - totals["Expense"][1]
|
|
209
|
+
rows.append(["", "Net income", "", money(net_q), money(net_y)])
|
|
210
|
+
return render_csv(header, rows)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def balance_sheet_csv(tb: TrialBalance) -> bytes:
|
|
214
|
+
"""As of the quarter's end. Assets debit-positive; liabilities and equity credit-positive."""
|
|
215
|
+
header = ["number", "name", "root_type", "amount"]
|
|
216
|
+
rows: list[list[str]] = []
|
|
217
|
+
assets = sum((line.closing for line in tb.lines if line.account.root_type == "Asset"), _ZERO)
|
|
218
|
+
for line in tb.lines:
|
|
219
|
+
if line.account.root_type == "Asset":
|
|
220
|
+
rows.append([line.account.number, line.account.name, "Asset", money(line.closing)])
|
|
221
|
+
rows.append(["", "Total assets", "Asset", money(assets)])
|
|
222
|
+
other = _ZERO
|
|
223
|
+
for root in ("Liability", "Equity"):
|
|
224
|
+
for line in tb.lines:
|
|
225
|
+
if line.account.root_type == root:
|
|
226
|
+
other += -line.closing
|
|
227
|
+
rows.append([line.account.number, line.account.name, root, money(-line.closing)])
|
|
228
|
+
current = -sum((line.closing for line in tb.lines if is_profit_and_loss(line.account)), _ZERO)
|
|
229
|
+
prior = -tb.prior_year_unclosed
|
|
230
|
+
rows.append(["", CURRENT_YEAR_LABEL, "Equity", money(current)])
|
|
231
|
+
rows.append(["", PRIOR_YEAR_LABEL, "Equity", money(prior)])
|
|
232
|
+
other += current + prior
|
|
233
|
+
rows.append(["", "Total liabilities and equity", "", money(other)])
|
|
234
|
+
if assets != other:
|
|
235
|
+
raise PeriodError(f"the balance sheet does not balance: assets {assets}, liabilities and equity {other}")
|
|
236
|
+
return render_csv(header, rows)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def chart_of_accounts_csv(chart: Sequence[DesiredAccount]) -> bytes:
|
|
240
|
+
"""Every account in chart.coa file order: number, name, parent, root type, account type, group."""
|
|
241
|
+
header = ["number", "name", "parent", "root_type", "account_type", "is_group"]
|
|
242
|
+
rows = [
|
|
243
|
+
[a.number, a.name, a.parent_number or "", a.root_type, a.account_type or "", "1" if a.is_group else "0"]
|
|
244
|
+
for a in chart
|
|
245
|
+
]
|
|
246
|
+
return render_csv(header, rows)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def general_ledger_csv(rows: Sequence[GLRow], index: AccountIndex, quarter: Quarter) -> bytes:
|
|
250
|
+
"""Every GL Entry dated in the quarter, in ledger read order."""
|
|
251
|
+
header = [
|
|
252
|
+
"posting_date",
|
|
253
|
+
"voucher_type",
|
|
254
|
+
"voucher_no",
|
|
255
|
+
"number",
|
|
256
|
+
"account",
|
|
257
|
+
"party_type",
|
|
258
|
+
"party",
|
|
259
|
+
"debit",
|
|
260
|
+
"credit",
|
|
261
|
+
"against_voucher",
|
|
262
|
+
"remarks",
|
|
263
|
+
"gl_entry",
|
|
264
|
+
]
|
|
265
|
+
out = [
|
|
266
|
+
[
|
|
267
|
+
r.posting_date.isoformat(),
|
|
268
|
+
r.voucher_type,
|
|
269
|
+
r.voucher_no,
|
|
270
|
+
index[r.account].number,
|
|
271
|
+
index[r.account].name,
|
|
272
|
+
r.party_type or "",
|
|
273
|
+
r.party or "",
|
|
274
|
+
money(r.debit),
|
|
275
|
+
money(r.credit),
|
|
276
|
+
r.against_voucher or "",
|
|
277
|
+
r.remarks,
|
|
278
|
+
r.name,
|
|
279
|
+
]
|
|
280
|
+
for r in rows
|
|
281
|
+
if quarter.start <= r.posting_date <= quarter.end
|
|
282
|
+
]
|
|
283
|
+
return render_csv(header, out)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""The bank's balance at the quarter's end, next to the ledger's, per Mercury account."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import datetime as dt
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
|
|
11
|
+
from runningtab import health
|
|
12
|
+
from runningtab.health import AccountLedger, MercuryReader, reconcile, unclassified_statuses
|
|
13
|
+
from runningtab.mercury.mapping import Mapping
|
|
14
|
+
from runningtab.period import PeriodError
|
|
15
|
+
from runningtab.period.csvfmt import money, render_csv
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _Closings(Protocol):
|
|
19
|
+
def closing(self, number: str) -> Decimal: ...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclasses.dataclass(frozen=True)
|
|
23
|
+
class BankLine:
|
|
24
|
+
label: str
|
|
25
|
+
account_id: str
|
|
26
|
+
number: str
|
|
27
|
+
bank: Decimal
|
|
28
|
+
ledger: Decimal
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def difference(self) -> Decimal:
|
|
32
|
+
return self.bank - self.ledger
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def read_ledgers(client: MercuryReader) -> list[AccountLedger]:
|
|
36
|
+
"""Every Mercury ledger, refusing an account with no inception date on record.
|
|
37
|
+
|
|
38
|
+
`health.read_ledgers` passes `start=account.created_at` to the per-account
|
|
39
|
+
transaction read; when that is `None`, Mercury's own endpoint default --
|
|
40
|
+
the last 30 days -- silently stands in, so an account open longer than
|
|
41
|
+
that reads short. `runningtab.health`'s balance invariant does not catch it:
|
|
42
|
+
it only proves the settled sum equals `currentBalance`, never that every
|
|
43
|
+
transaction was actually read, and a short read whose missing rows net to
|
|
44
|
+
zero passes it clean. The quarter-end balance `bank_reconciliation` builds
|
|
45
|
+
from that short ledger would then be wrong with nothing to say so.
|
|
46
|
+
|
|
47
|
+
A period-side wrapper around `health.read_ledgers` rather than a change to
|
|
48
|
+
it: `runningtab health`'s own report is unaffected by this refusal on purpose
|
|
49
|
+
-- there is nothing wrong with running health against an account still
|
|
50
|
+
missing its inception date, only with building a quarter's historical
|
|
51
|
+
balance from one.
|
|
52
|
+
|
|
53
|
+
`client.accounts()`/`client.credit_accounts()` are fetched once, here,
|
|
54
|
+
and passed straight into `health.read_ledgers` rather than read again --
|
|
55
|
+
the inception-date check and the ledger build need the same two lists.
|
|
56
|
+
"""
|
|
57
|
+
accounts = client.accounts()
|
|
58
|
+
credit_accounts = client.credit_accounts()
|
|
59
|
+
missing = sorted(
|
|
60
|
+
f"{account.label} ({account.id})" for account in (*accounts, *credit_accounts) if account.created_at is None
|
|
61
|
+
)
|
|
62
|
+
if missing:
|
|
63
|
+
raise PeriodError(
|
|
64
|
+
"Mercury account(s) with no inception date on record: "
|
|
65
|
+
+ ", ".join(missing)
|
|
66
|
+
+ "; the transaction read would silently default to the last 30 days"
|
|
67
|
+
)
|
|
68
|
+
return health.read_ledgers(client, accounts=accounts, credit_accounts=credit_accounts)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def bank_balance_through(ledger: AccountLedger, end: dt.date) -> Decimal:
|
|
72
|
+
"""Settled transactions dated on or before `end`, by the date the ingest books them."""
|
|
73
|
+
return sum((t.amount for t in ledger.transactions if t.is_postable and t.accounting_date <= end), Decimal(0))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def bank_reconciliation(
|
|
77
|
+
ledgers: Sequence[AccountLedger], mapping: Mapping, tb: _Closings, end: dt.date
|
|
78
|
+
) -> list[BankLine]:
|
|
79
|
+
"""Each Mercury account's balance at `end` next to the chart account's closing balance.
|
|
80
|
+
|
|
81
|
+
Refuses unless the balance invariant holds for every account: a balance
|
|
82
|
+
at a past date built from a short read cannot be trusted, and an account
|
|
83
|
+
`mercury.toml` does not map has nowhere in the chart to compare against.
|
|
84
|
+
Also refuses when a transaction carries a status nobody has classified
|
|
85
|
+
(`runningtab.health.unclassified`): such a row would be silently excluded from
|
|
86
|
+
`bank_balance_through` with no established accounting treatment, and a
|
|
87
|
+
quiet exclusion is a wrong balance nobody can see. Also refuses when two
|
|
88
|
+
Mercury accounts map to the same chart account: each would be compared
|
|
89
|
+
against that one account's whole closing balance rather than its own
|
|
90
|
+
share of it, producing a false difference for at least one of them. Also
|
|
91
|
+
refuses when `mercury.toml` maps an account to a number that is not a
|
|
92
|
+
postable account in `tb` (a group account, say): `TrialBalance.closing`
|
|
93
|
+
has no line for it and would otherwise fail with a bare `KeyError`. Every
|
|
94
|
+
refusal names the offending account rather than failing silently.
|
|
95
|
+
"""
|
|
96
|
+
if not ledgers:
|
|
97
|
+
raise PeriodError("Mercury returned no accounts; a short read, not an empty bank")
|
|
98
|
+
failing = [r.label for r in reconcile(ledgers) if not r.reconciles]
|
|
99
|
+
if failing:
|
|
100
|
+
raise PeriodError(
|
|
101
|
+
"the balance invariant fails for "
|
|
102
|
+
+ ", ".join(sorted(failing))
|
|
103
|
+
+ "; a balance at a past date cannot be trusted until `runningtab health` passes"
|
|
104
|
+
)
|
|
105
|
+
unclassified_by_account = {
|
|
106
|
+
ledger.label: statuses for ledger in ledgers if (statuses := unclassified_statuses([ledger]))
|
|
107
|
+
}
|
|
108
|
+
if unclassified_by_account:
|
|
109
|
+
named = ", ".join(
|
|
110
|
+
f"{label} ({', '.join(f'{count} {status}' for status, count in statuses.items())})"
|
|
111
|
+
for label, statuses in sorted(unclassified_by_account.items())
|
|
112
|
+
)
|
|
113
|
+
raise PeriodError(
|
|
114
|
+
"transaction status(es) nobody has classified would be silently excluded from the historical "
|
|
115
|
+
f"balance with unknown accounting treatment: {named}"
|
|
116
|
+
)
|
|
117
|
+
maybe_numbers = {
|
|
118
|
+
ledger.account_id: mapping.account_for_mercury_account(ledger.account_id, ledger.label) for ledger in ledgers
|
|
119
|
+
}
|
|
120
|
+
unmapped = sorted(account_id for account_id, number in maybe_numbers.items() if number is None)
|
|
121
|
+
if unmapped:
|
|
122
|
+
raise PeriodError(f"mercury.toml maps no chart account for Mercury account(s): {', '.join(unmapped)}")
|
|
123
|
+
numbers: dict[str, str] = {account_id: number for account_id, number in maybe_numbers.items() if number is not None}
|
|
124
|
+
accounts_by_number: dict[str, list[AccountLedger]] = {}
|
|
125
|
+
for ledger in ledgers:
|
|
126
|
+
accounts_by_number.setdefault(numbers[ledger.account_id], []).append(ledger)
|
|
127
|
+
duplicated = sorted(number for number, accounts in accounts_by_number.items() if len(accounts) > 1)
|
|
128
|
+
if duplicated:
|
|
129
|
+
named = "; ".join(
|
|
130
|
+
f"{number} ({', '.join(f'{a.label} ({a.account_id})' for a in accounts_by_number[number])})"
|
|
131
|
+
for number in duplicated
|
|
132
|
+
)
|
|
133
|
+
raise PeriodError(f"mercury.toml maps more than one Mercury account to chart account: {named}")
|
|
134
|
+
lines = []
|
|
135
|
+
for ledger in ledgers:
|
|
136
|
+
number = numbers[ledger.account_id]
|
|
137
|
+
try:
|
|
138
|
+
closing = tb.closing(number)
|
|
139
|
+
except KeyError:
|
|
140
|
+
raise PeriodError(
|
|
141
|
+
f"mercury.toml maps Mercury account {ledger.label} ({ledger.account_id}) to {number}, "
|
|
142
|
+
"which is not a postable account in the trial balance"
|
|
143
|
+
) from None
|
|
144
|
+
lines.append(BankLine(ledger.label, ledger.account_id, number, bank_balance_through(ledger, end), closing))
|
|
145
|
+
return sorted(lines, key=lambda line: line.number)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def bank_reconciliation_csv(lines: Sequence[BankLine]) -> bytes:
|
|
149
|
+
header = ["number", "account", "mercury_account", "bank_balance", "ledger_balance", "difference"]
|
|
150
|
+
rows = [
|
|
151
|
+
[line.number, line.label, line.account_id, money(line.bank), money(line.ledger), money(line.difference)]
|
|
152
|
+
for line in lines
|
|
153
|
+
]
|
|
154
|
+
return render_csv(header, rows)
|