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
@@ -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)