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/coa.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""What `runningtab coa` does between the prompts: plan, apply, prune, park.
|
|
2
|
+
|
|
3
|
+
Everything here returns data or raises a typed error; `runningtab.cli` prints and
|
|
4
|
+
picks exit codes. The rules that matter: a refused chart plan is never
|
|
5
|
+
executed; a plan is re-made after the operator confirms and refused if it
|
|
6
|
+
changed; prune only ever touches extras; parking (a renumber) is confined
|
|
7
|
+
to `--allow-unapplied`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import dataclasses
|
|
13
|
+
|
|
14
|
+
from icedcoffee_sync.apply.diff import ChartPlan, Refusal, RefusalReason, diff
|
|
15
|
+
from icedcoffee_sync.apply.execute import ApplyReport, apply_chart
|
|
16
|
+
from icedcoffee_sync.apply.model import DesiredAccount, LiveAccount
|
|
17
|
+
from icedcoffee_sync.apply.park import ParkedNumber, park_colliding_numbers, parking_candidates, unresolved_collisions
|
|
18
|
+
from icedcoffee_sync.apply.prune import PrunePlan, PruneRefusal, PruneReport, execute_prune, plan_prune
|
|
19
|
+
from icedcoffee_sync.erpnext.accounts import accounts_with_entries, company_abbr, document_name, read_chart
|
|
20
|
+
from icedcoffee_sync.erpnext.company import clear_defaults_pointing_at, require_default_fields, set_default_accounts
|
|
21
|
+
from icedcoffee_sync.frappe.client import Client, SiteConfig
|
|
22
|
+
|
|
23
|
+
# Prune only touches extras, so a chart refusal blocks it only when it could
|
|
24
|
+
# make the extras set wrong: ROOT_NOT_UNIQUE breaks the tree walk and
|
|
25
|
+
# AMBIGUOUS_MATCH marks every candidate matched. The rest attach to a create
|
|
26
|
+
# or a matched account. An explicit allow-list, so a new reason is classified
|
|
27
|
+
# on purpose.
|
|
28
|
+
PRUNE_BLOCKING_REFUSAL_REASONS = frozenset({RefusalReason.ROOT_NOT_UNIQUE, RefusalReason.AMBIGUOUS_MATCH})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NoSoleCompanyError(ValueError):
|
|
32
|
+
"""The site has no company, or more than one, so `--company` is required."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PlanChangedError(RuntimeError):
|
|
36
|
+
"""The chart changed between the plan the operator confirmed and now."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclasses.dataclass
|
|
40
|
+
class Context:
|
|
41
|
+
"""One planning pass against a site: the client, the chart, the live tree, the diff."""
|
|
42
|
+
|
|
43
|
+
client: Client
|
|
44
|
+
company: str
|
|
45
|
+
abbr: str
|
|
46
|
+
chart: list[DesiredAccount]
|
|
47
|
+
by_number: dict[str, DesiredAccount]
|
|
48
|
+
plan: ChartPlan
|
|
49
|
+
live: list[LiveAccount]
|
|
50
|
+
with_entries: set[str]
|
|
51
|
+
# The chart's Company default-account fields, as account numbers.
|
|
52
|
+
defaults: dict[str, str]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def sole_company(client: Client) -> str:
|
|
56
|
+
"""The site's only company; refuses to guess when there are none or several."""
|
|
57
|
+
# Two rows through iter_all, never one capped list call: a server-side
|
|
58
|
+
# page cap could make several companies look like one.
|
|
59
|
+
companies = client.iter_all("Company", fields=["name"])
|
|
60
|
+
first = next(companies, None)
|
|
61
|
+
if first is None:
|
|
62
|
+
raise NoSoleCompanyError("--company is required: this site has no companies")
|
|
63
|
+
second = next(companies, None)
|
|
64
|
+
if second is None:
|
|
65
|
+
return str(first["name"])
|
|
66
|
+
raise NoSoleCompanyError(
|
|
67
|
+
f"--company is required: this site has more than one company (at least {first['name']!r} and {second['name']!r})"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def plan_against(
|
|
72
|
+
config: SiteConfig, company: str | None, chart: list[DesiredAccount], defaults: dict[str, str]
|
|
73
|
+
) -> Context:
|
|
74
|
+
"""Read the site and diff the chart against it.
|
|
75
|
+
|
|
76
|
+
`chart` and `defaults` are the caller's parse of `chart.coa`, never read
|
|
77
|
+
here: `period export` hands in its single snapshot, used for the
|
|
78
|
+
live-chart check, the package, and the manifest hash, and every other
|
|
79
|
+
caller reads the books directory's chart once and passes both halves.
|
|
80
|
+
"""
|
|
81
|
+
client = Client(config)
|
|
82
|
+
resolved = company or sole_company(client)
|
|
83
|
+
live = read_chart(client, resolved)
|
|
84
|
+
with_entries = accounts_with_entries(client, [a.document_name for a in live])
|
|
85
|
+
return Context(
|
|
86
|
+
client=client,
|
|
87
|
+
company=resolved,
|
|
88
|
+
abbr=company_abbr(client, resolved),
|
|
89
|
+
chart=chart,
|
|
90
|
+
by_number={a.number: a for a in chart},
|
|
91
|
+
plan=diff(chart, live, with_entries),
|
|
92
|
+
live=live,
|
|
93
|
+
with_entries=with_entries,
|
|
94
|
+
defaults=dict(defaults),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def blocking_prune_refusals(refusals: list[Refusal]) -> list[Refusal]:
|
|
99
|
+
"""The chart refusals that make the extras set untrustworthy."""
|
|
100
|
+
return [refusal for refusal in refusals if refusal.reason in PRUNE_BLOCKING_REFUSAL_REASONS]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def prune_plan_for(context: Context) -> PrunePlan:
|
|
104
|
+
"""Which extras prune may delete, reusing the ledger probe the plan already paid for."""
|
|
105
|
+
extras = [extra.live for extra in context.plan.extras]
|
|
106
|
+
return plan_prune(extras, context.live, context.with_entries)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def company_defaults(context: Context) -> dict[str, str]:
|
|
110
|
+
"""Company default-account fields mapped to the chart accounts' document names."""
|
|
111
|
+
return {
|
|
112
|
+
field: document_name(context.by_number[number].name, context.abbr, number)
|
|
113
|
+
for field, number in context.defaults.items()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclasses.dataclass(frozen=True)
|
|
118
|
+
class ApplyOutcome:
|
|
119
|
+
"""What an apply did: the account changes (None when the plan was empty) and the defaults written."""
|
|
120
|
+
|
|
121
|
+
report: ApplyReport | None
|
|
122
|
+
defaults_written: list[str]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def apply_confirmed(context: Context, fresh_plan: ChartPlan) -> ApplyOutcome:
|
|
126
|
+
"""Execute a confirmed plan, after checking it still holds and the defaults can be set.
|
|
127
|
+
|
|
128
|
+
Raises `PlanChangedError` if `fresh_plan` differs from the confirmed one,
|
|
129
|
+
and `UnknownCompanyFieldError` before any account is touched if a
|
|
130
|
+
default field is not on this build's Company.
|
|
131
|
+
"""
|
|
132
|
+
if fresh_plan != context.plan:
|
|
133
|
+
raise PlanChangedError("the chart plan changed since planning")
|
|
134
|
+
require_default_fields(context.client, context.company, context.defaults.keys())
|
|
135
|
+
report: ApplyReport | None = None
|
|
136
|
+
if not context.plan.is_empty:
|
|
137
|
+
report = apply_chart(
|
|
138
|
+
context.client, context.plan, context.live, context.by_number, context.abbr, context.company
|
|
139
|
+
)
|
|
140
|
+
# Always set the defaults: an applied chart with unset defaults is a
|
|
141
|
+
# half-configured ledger, and a rerun is how a failed default gets fixed.
|
|
142
|
+
written = set_default_accounts(context.client, context.company, company_defaults(context))
|
|
143
|
+
return ApplyOutcome(report=report, defaults_written=written)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclasses.dataclass(frozen=True)
|
|
147
|
+
class ParkOutcome:
|
|
148
|
+
"""Survivors renumbered, those skipped for gaining entries, and collisions left standing."""
|
|
149
|
+
|
|
150
|
+
gained_entries: list[str]
|
|
151
|
+
parked: list[ParkedNumber]
|
|
152
|
+
unresolved: list[str]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def park_survivors(
|
|
156
|
+
client: Client,
|
|
157
|
+
extras: list[LiveAccount],
|
|
158
|
+
report: PruneReport,
|
|
159
|
+
refusals: list[PruneRefusal],
|
|
160
|
+
live: list[LiveAccount],
|
|
161
|
+
chart_numbers: frozenset[str],
|
|
162
|
+
) -> ParkOutcome:
|
|
163
|
+
"""Renumber surviving extras off the chart's numbers, never one carrying ledger entries."""
|
|
164
|
+
candidates, excluded = parking_candidates(extras, report, refusals)
|
|
165
|
+
gained: set[str] = set()
|
|
166
|
+
if candidates:
|
|
167
|
+
# Plan-time knowledge is stale by now: the prompt and the deletes
|
|
168
|
+
# both left time for an account to gain its first entry.
|
|
169
|
+
gained = accounts_with_entries(client, [a.document_name for a in candidates])
|
|
170
|
+
excluded = excluded + [a for a in candidates if a.document_name in gained]
|
|
171
|
+
candidates = [a for a in candidates if a.document_name not in gained]
|
|
172
|
+
taken = frozenset(a.number for a in live if a.number) | chart_numbers
|
|
173
|
+
parked = park_colliding_numbers(client, candidates, chart_numbers, taken)
|
|
174
|
+
return ParkOutcome(
|
|
175
|
+
gained_entries=sorted(gained),
|
|
176
|
+
parked=parked,
|
|
177
|
+
unresolved=unresolved_collisions(excluded, chart_numbers),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclasses.dataclass(frozen=True)
|
|
182
|
+
class PruneOutcome:
|
|
183
|
+
"""What a prune did, in the order it happened."""
|
|
184
|
+
|
|
185
|
+
cleared_defaults: list[str]
|
|
186
|
+
report: PruneReport
|
|
187
|
+
parking: ParkOutcome | None
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def failed(self) -> bool:
|
|
191
|
+
"""True when a delete failed without being parked, or a collision is left standing."""
|
|
192
|
+
parked_names = {p.old_document_name for p in self.parking.parked} if self.parking else set()
|
|
193
|
+
unresolved = bool(self.parking and self.parking.unresolved)
|
|
194
|
+
return unresolved or any(name not in parked_names for name, _ in self.report.failed)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def prune_confirmed(context: Context, prune_plan: PrunePlan, *, allow_unapplied: bool) -> PruneOutcome:
|
|
198
|
+
"""Execute a confirmed prune plan; under `--allow-unapplied`, clear defaults first and park afterwards."""
|
|
199
|
+
cleared: list[str] = []
|
|
200
|
+
if allow_unapplied:
|
|
201
|
+
# ERPNext refuses to retire an account the Company record names as a
|
|
202
|
+
# default; apply repoints the defaults afterwards.
|
|
203
|
+
deleting = frozenset(d.live.document_name for d in prune_plan.deletes)
|
|
204
|
+
cleared = clear_defaults_pointing_at(context.client, context.company, deleting)
|
|
205
|
+
report = execute_prune(context.client, prune_plan)
|
|
206
|
+
parking: ParkOutcome | None = None
|
|
207
|
+
if allow_unapplied:
|
|
208
|
+
extras = [extra.live for extra in context.plan.extras]
|
|
209
|
+
parking = park_survivors(
|
|
210
|
+
context.client, extras, report, prune_plan.refusals, context.live, frozenset(context.by_number)
|
|
211
|
+
)
|
|
212
|
+
return PruneOutcome(cleared_defaults=cleared, report=report, parking=parking)
|
runningtab/doc.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Render a chart-of-accounts page's account tables from `chart.coa`.
|
|
2
|
+
|
|
3
|
+
Two marker pairs are rewritten in place, nothing else is touched:
|
|
4
|
+
`<!-- chart:table NUMBER -->` … `<!-- chart:end -->` (the subtree rooted at
|
|
5
|
+
NUMBER, in file order) and `<!-- chart:defaults -->` … `<!-- chart:end -->`
|
|
6
|
+
(the Company defaults). Tables are padded the way prettier pads them, so the
|
|
7
|
+
pre-commit hook is a no-op on the output; if prettier disagrees, prettier wins.
|
|
8
|
+
|
|
9
|
+
Which constrains the markdown a chart note may use: prettier rewrites
|
|
10
|
+
`*italic*` to `_italic_`, so a note containing one renders, gets rewritten by
|
|
11
|
+
the hook, and then fails `test_docs_chart_tables_are_generated` forever -- the
|
|
12
|
+
generator keeps emitting what `chart.coa` says. Bold (`**`) and code spans
|
|
13
|
+
survive untouched; use those.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
from runningtab.chart import ChartDocument, ChartEntry, ChartError
|
|
21
|
+
|
|
22
|
+
# Mostly a literal transcription of the [defaults] field name: replace `_`
|
|
23
|
+
# with a space, capitalize the first letter. Three rows are not, because the
|
|
24
|
+
# ERPNext field name does not describe the setting the way a person reading
|
|
25
|
+
# the doc does:
|
|
26
|
+
#
|
|
27
|
+
# - default_expense_account: ERPNext's own field is named as a general
|
|
28
|
+
# "expense account" default, but the doc already calls out (in "Default
|
|
29
|
+
# accounts") that nothing else falls back to it -- it *is* the
|
|
30
|
+
# cost-of-goods-sold default, so the label says that instead.
|
|
31
|
+
# - exchange_gain_loss_account: a literal transform gives "Exchange gain
|
|
32
|
+
# loss account"; the doc reads it as "gain / loss", matching how the pair
|
|
33
|
+
# is written everywhere else on the page (e.g. "Gain / Loss on Asset
|
|
34
|
+
# Disposal").
|
|
35
|
+
# - disposal_account: the field name alone ("disposal account") does not say
|
|
36
|
+
# what kind of disposal event or what gets posted; the label spells out
|
|
37
|
+
# that this is the gain-or-loss side of an asset disposal.
|
|
38
|
+
_DEFAULT_LABELS: dict[str, str] = {
|
|
39
|
+
"default_bank_account": "Default bank account",
|
|
40
|
+
"default_cash_account": "Default cash account",
|
|
41
|
+
"default_receivable_account": "Default receivable account",
|
|
42
|
+
"default_payable_account": "Default payable account",
|
|
43
|
+
"default_expense_account": "Default cost of goods sold account",
|
|
44
|
+
"default_income_account": "Default income account",
|
|
45
|
+
"round_off_account": "Round off account",
|
|
46
|
+
"exchange_gain_loss_account": "Exchange gain / loss account",
|
|
47
|
+
"write_off_account": "Write off account",
|
|
48
|
+
"default_deferred_revenue_account": "Default deferred revenue account",
|
|
49
|
+
"default_deferred_expense_account": "Default deferred expense account",
|
|
50
|
+
"accumulated_depreciation_account": "Accumulated depreciation account",
|
|
51
|
+
"depreciation_expense_account": "Depreciation expense account",
|
|
52
|
+
"disposal_account": "Gain / loss account on asset disposal",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_ACCOUNT_HEADERS = ["Number", "Account", "Type", "What goes here"]
|
|
56
|
+
_DEFAULTS_HEADERS = ["Company setting", "Account"]
|
|
57
|
+
|
|
58
|
+
_TABLE_RE = re.compile(r"chart:table\s+(\d+)")
|
|
59
|
+
_DEFAULTS_RE = re.compile(r"chart:defaults")
|
|
60
|
+
_END_RE = re.compile(r"chart:end")
|
|
61
|
+
_ANY_MARKER_RE = re.compile(r"<!--\s*(chart:[a-z:0-9 ]*?)\s*-->")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _escape(cell: str) -> str:
|
|
65
|
+
"""Escape a literal `|` so it cannot be read as a table column break."""
|
|
66
|
+
return cell.replace("|", "\\|")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _pad_table(headers: list[str], rows: list[list[str]]) -> str:
|
|
70
|
+
"""A GFM pipe table, each column padded to its widest cell -- prettier's own rule.
|
|
71
|
+
|
|
72
|
+
Escapes every row cell before measuring or emitting it: `chart.coa`
|
|
73
|
+
permits a literal `|` in an account name just as freely as in a note,
|
|
74
|
+
and an unescaped one reads as a column break -- silently producing a
|
|
75
|
+
table row with an extra column instead of the cell text it was meant
|
|
76
|
+
to hold. Escaping here, once, centrally, means no future column ever
|
|
77
|
+
has to remember to call `_escape` itself.
|
|
78
|
+
"""
|
|
79
|
+
escaped_rows = [[_escape(cell) for cell in row] for row in rows]
|
|
80
|
+
widths = [len(h) for h in headers]
|
|
81
|
+
for row in escaped_rows:
|
|
82
|
+
for i, cell in enumerate(row):
|
|
83
|
+
widths[i] = max(widths[i], len(cell))
|
|
84
|
+
|
|
85
|
+
def format_row(cells: list[str]) -> str:
|
|
86
|
+
return "| " + " | ".join(cell.ljust(width) for cell, width in zip(cells, widths, strict=True)) + " |"
|
|
87
|
+
|
|
88
|
+
lines = [format_row(headers), format_row(["-" * width for width in widths])]
|
|
89
|
+
lines.extend(format_row(row) for row in escaped_rows)
|
|
90
|
+
return "\n".join(lines) + "\n"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _children_by_parent(entries: list[ChartEntry]) -> dict[str | None, list[ChartEntry]]:
|
|
94
|
+
children: dict[str | None, list[ChartEntry]] = {}
|
|
95
|
+
for entry in entries:
|
|
96
|
+
children.setdefault(entry.account.parent_number, []).append(entry)
|
|
97
|
+
return children
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _subtree(
|
|
101
|
+
number: str, by_number: dict[str, ChartEntry], children: dict[str | None, list[ChartEntry]]
|
|
102
|
+
) -> list[ChartEntry]:
|
|
103
|
+
if number not in by_number:
|
|
104
|
+
raise ChartError(f"chart:table {number}: no account {number} in the chart")
|
|
105
|
+
ordered: list[ChartEntry] = []
|
|
106
|
+
|
|
107
|
+
def walk(current: str) -> None:
|
|
108
|
+
ordered.append(by_number[current])
|
|
109
|
+
for child in children.get(current, []):
|
|
110
|
+
walk(child.account.number)
|
|
111
|
+
|
|
112
|
+
walk(number)
|
|
113
|
+
return ordered
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _render_account_table(entries: list[ChartEntry]) -> str:
|
|
117
|
+
rows = []
|
|
118
|
+
for entry in entries:
|
|
119
|
+
account = entry.account
|
|
120
|
+
type_cell = "Group" if account.is_group else (account.account_type or "")
|
|
121
|
+
rows.append([account.number, account.name, type_cell, entry.note])
|
|
122
|
+
return _pad_table(_ACCOUNT_HEADERS, rows)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _render_defaults_table(defaults: dict[str, str]) -> str:
|
|
126
|
+
rows = []
|
|
127
|
+
for field, number in defaults.items():
|
|
128
|
+
label = _DEFAULT_LABELS.get(field)
|
|
129
|
+
if label is None:
|
|
130
|
+
raise ChartError(f"chart:defaults: no doc label mapped for default field {field!r}")
|
|
131
|
+
rows.append([label, number])
|
|
132
|
+
return _pad_table(_DEFAULTS_HEADERS, rows)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def render_doc(doc_text: str, chart: ChartDocument) -> str:
|
|
136
|
+
"""Rewrite every marked table in `doc_text`, leaving everything else untouched.
|
|
137
|
+
|
|
138
|
+
Pure: takes the chart already parsed and the doc's current text, returns
|
|
139
|
+
the doc's new text. Raises `ChartError` if a marker is unknown,
|
|
140
|
+
unbalanced (a start with no `chart:end`, or a stray `chart:end`), or a
|
|
141
|
+
`chart:table` names a number absent from the chart.
|
|
142
|
+
"""
|
|
143
|
+
by_number = {entry.account.number: entry for entry in chart.entries}
|
|
144
|
+
children = _children_by_parent(chart.entries)
|
|
145
|
+
|
|
146
|
+
lines = doc_text.splitlines(keepends=True)
|
|
147
|
+
out: list[str] = []
|
|
148
|
+
i = 0
|
|
149
|
+
while i < len(lines):
|
|
150
|
+
line = lines[i]
|
|
151
|
+
stripped = line.strip()
|
|
152
|
+
|
|
153
|
+
if _END_RE.search(stripped):
|
|
154
|
+
raise ChartError(f"line {i + 1}: 'chart:end' with no matching start marker")
|
|
155
|
+
|
|
156
|
+
table_match = _TABLE_RE.search(stripped)
|
|
157
|
+
is_defaults = _DEFAULTS_RE.search(stripped) is not None
|
|
158
|
+
if not table_match and not is_defaults:
|
|
159
|
+
unknown = _ANY_MARKER_RE.search(stripped)
|
|
160
|
+
if unknown:
|
|
161
|
+
raise ChartError(f"line {i + 1}: unknown marker {unknown.group(1)!r}")
|
|
162
|
+
out.append(line)
|
|
163
|
+
i += 1
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
out.append(line) # the start marker itself, verbatim
|
|
167
|
+
start_line = i
|
|
168
|
+
i += 1
|
|
169
|
+
while i < len(lines) and not _END_RE.search(lines[i].strip()):
|
|
170
|
+
i += 1
|
|
171
|
+
if i >= len(lines):
|
|
172
|
+
raise ChartError(f"line {start_line + 1}: {stripped!r} has no matching 'chart:end'")
|
|
173
|
+
|
|
174
|
+
rendered = (
|
|
175
|
+
_render_account_table(_subtree(table_match.group(1), by_number, children))
|
|
176
|
+
if table_match
|
|
177
|
+
else _render_defaults_table(chart.defaults)
|
|
178
|
+
)
|
|
179
|
+
out.append("\n")
|
|
180
|
+
out.append(rendered)
|
|
181
|
+
out.append("\n")
|
|
182
|
+
out.append(lines[i]) # the end marker itself, verbatim
|
|
183
|
+
i += 1
|
|
184
|
+
|
|
185
|
+
return "".join(out)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The capital forecast: what the members must put in over the coming months."""
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""The capital call: how much each member must put in over the next three months.
|
|
2
|
+
|
|
3
|
+
Pure arithmetic over the forecast file, the bank's opening balance and this
|
|
4
|
+
month's Mercury transactions. Nothing here reads a clock or a network, so the
|
|
5
|
+
same inputs always produce the same answer.
|
|
6
|
+
|
|
7
|
+
The answer is money people will pay, so every choice errs toward asking for
|
|
8
|
+
enough: a line costs its `high` amount, a charge not yet seen in the bank is
|
|
9
|
+
still owed, and each member's share rounds up to the cent.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import calendar
|
|
15
|
+
import dataclasses
|
|
16
|
+
import datetime as dt
|
|
17
|
+
from collections.abc import Iterable
|
|
18
|
+
from decimal import ROUND_CEILING, Decimal
|
|
19
|
+
from typing import Literal
|
|
20
|
+
|
|
21
|
+
from runningtab.forecast.model import ForecastFile, Line
|
|
22
|
+
from runningtab.mercury.models import Transaction
|
|
23
|
+
|
|
24
|
+
SCENARIO = "conservative"
|
|
25
|
+
FIXED_FLOOR_SOURCE = "[call] floor"
|
|
26
|
+
NO_FLOOR_SOURCE = "no owed charges"
|
|
27
|
+
_CENT = Decimal("0.01")
|
|
28
|
+
_FULL_MONTHS_AHEAD = 3
|
|
29
|
+
# A charge settles a due only within this band around the line's range. Usage
|
|
30
|
+
# bills drift a little from the estimate, but a charge far outside it is a
|
|
31
|
+
# different bill from the same vendor, and counting it as this one would leave
|
|
32
|
+
# the real bill uncounted and the call understated.
|
|
33
|
+
_SETTLE_BELOW = Decimal("0.9")
|
|
34
|
+
_SETTLE_ABOVE = Decimal("1.1")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclasses.dataclass(frozen=True)
|
|
38
|
+
class Due:
|
|
39
|
+
"""One occurrence of a line, at the amount we plan for (its `high`)."""
|
|
40
|
+
|
|
41
|
+
line: Line
|
|
42
|
+
due: dt.date
|
|
43
|
+
amount: Decimal
|
|
44
|
+
status: Literal["owed", "seen paid"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclasses.dataclass(frozen=True)
|
|
48
|
+
class Month:
|
|
49
|
+
"""One month of the cash path. `month` is its first day."""
|
|
50
|
+
|
|
51
|
+
month: dt.date
|
|
52
|
+
opening: Decimal
|
|
53
|
+
outflows: Decimal
|
|
54
|
+
inflows: Decimal
|
|
55
|
+
closing: Decimal
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclasses.dataclass(frozen=True)
|
|
59
|
+
class Forecast:
|
|
60
|
+
"""The whole answer, with what it was computed from."""
|
|
61
|
+
|
|
62
|
+
computed_at: dt.datetime
|
|
63
|
+
ledger_through: dt.date
|
|
64
|
+
window_start: dt.date
|
|
65
|
+
window_end: dt.date
|
|
66
|
+
scenario: str
|
|
67
|
+
opening_cash: Decimal
|
|
68
|
+
floor: Decimal
|
|
69
|
+
floor_from: str
|
|
70
|
+
months: tuple[Month, ...]
|
|
71
|
+
dues: tuple[Due, ...]
|
|
72
|
+
members: tuple[tuple[str, Decimal], ...]
|
|
73
|
+
total_call: Decimal
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _month_end(year: int, month: int) -> dt.date:
|
|
77
|
+
return dt.date(year, month, calendar.monthrange(year, month)[1])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _add_months(first: dt.date, n: int) -> dt.date:
|
|
81
|
+
index = first.year * 12 + first.month - 1 + n
|
|
82
|
+
return dt.date(index // 12, index % 12 + 1, 1)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def window(today: dt.date) -> tuple[dt.date, dt.date]:
|
|
86
|
+
"""From today through the end of the third full month after this one."""
|
|
87
|
+
last = _add_months(today.replace(day=1), _FULL_MONTHS_AHEAD)
|
|
88
|
+
return today, _month_end(last.year, last.month)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _occurrences(line: Line, first: dt.date, end: dt.date) -> Iterable[dt.date]:
|
|
92
|
+
"""Every date the line falls due between the first of the current month and `end`."""
|
|
93
|
+
if line.cadence == "monthly":
|
|
94
|
+
if line.day is None:
|
|
95
|
+
raise ValueError(f"monthly line {line.name!r} has no day")
|
|
96
|
+
month = first
|
|
97
|
+
while month <= end:
|
|
98
|
+
# Day 31 falls on the last day of a shorter month.
|
|
99
|
+
day = min(line.day, calendar.monthrange(month.year, month.month)[1])
|
|
100
|
+
yield month.replace(day=day)
|
|
101
|
+
month = _add_months(month, 1)
|
|
102
|
+
elif line.cadence == "yearly":
|
|
103
|
+
if line.on is None:
|
|
104
|
+
raise ValueError(f"yearly line {line.name!r} has no date")
|
|
105
|
+
for year in range(first.year, end.year + 1):
|
|
106
|
+
yield line.on.replace(year=year)
|
|
107
|
+
else:
|
|
108
|
+
raise ValueError(f"line {line.name!r} has unknown cadence {line.cadence!r}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _payments(transactions: Iterable[Transaction], today: dt.date) -> list[Transaction]:
|
|
112
|
+
"""Charges we paid this month, up to today, in money the opening balance already reflects."""
|
|
113
|
+
return [
|
|
114
|
+
t
|
|
115
|
+
for t in transactions
|
|
116
|
+
if t.counterparty_name
|
|
117
|
+
and t.amount < 0
|
|
118
|
+
and t.counts_toward_balance
|
|
119
|
+
and (t.accounting_date.year, t.accounting_date.month) == (today.year, today.month)
|
|
120
|
+
and t.accounting_date <= today
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _dues(file: ForecastFile, today: dt.date, end: dt.date, payments: list[Transaction]) -> tuple[Due, ...]:
|
|
125
|
+
"""Every due in the window, each marked `seen paid` only if a payment of its own settles it.
|
|
126
|
+
|
|
127
|
+
One bank charge pays at most one due: two lines can share a counterparty
|
|
128
|
+
("Google"), and a single charge must not mark both paid. Dues are settled
|
|
129
|
+
by pairing every eligible payment with the due it is nearest in price to
|
|
130
|
+
(earlier due date, then file order, break ties); each payment and each due
|
|
131
|
+
is used once. A payment is eligible for a due when its counterparty matches
|
|
132
|
+
and its amount is within the tolerance band of the line's range. A due left
|
|
133
|
+
without a payment stays owed.
|
|
134
|
+
"""
|
|
135
|
+
first = today.replace(day=1)
|
|
136
|
+
found: list[tuple[dt.date, int, Line]] = []
|
|
137
|
+
for index, line in enumerate(file.lines):
|
|
138
|
+
for due in _occurrences(line, first, end):
|
|
139
|
+
if not first <= due <= end:
|
|
140
|
+
continue
|
|
141
|
+
if (line.starts and due < line.starts) or (line.ends and due > line.ends):
|
|
142
|
+
continue
|
|
143
|
+
found.append((due, index, line))
|
|
144
|
+
found.sort(key=lambda f: (f[0], f[1]))
|
|
145
|
+
|
|
146
|
+
# Every way a payment could settle a due, nearest-priced first.
|
|
147
|
+
pairs = sorted(
|
|
148
|
+
(abs(abs(t.amount) - line.amount.high), due, index, p, f)
|
|
149
|
+
for f, (due, index, line) in enumerate(found)
|
|
150
|
+
if due < today
|
|
151
|
+
for p, t in enumerate(payments)
|
|
152
|
+
if (t.counterparty_name or "").casefold() in {m.casefold() for m in line.match}
|
|
153
|
+
and line.amount.low * _SETTLE_BELOW <= abs(t.amount) <= line.amount.high * _SETTLE_ABOVE
|
|
154
|
+
)
|
|
155
|
+
settled: set[int] = set()
|
|
156
|
+
used: set[int] = set()
|
|
157
|
+
for _, _, _, p, f in pairs:
|
|
158
|
+
if p not in used and f not in settled:
|
|
159
|
+
used.add(p)
|
|
160
|
+
settled.add(f)
|
|
161
|
+
|
|
162
|
+
dues = [
|
|
163
|
+
Due(line, due, line.amount.high, "seen paid" if f in settled else "owed")
|
|
164
|
+
for f, (due, _, line) in enumerate(found)
|
|
165
|
+
]
|
|
166
|
+
return tuple(sorted(dues, key=lambda d: (d.due, d.line.name)))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def compute(
|
|
170
|
+
file: ForecastFile,
|
|
171
|
+
*,
|
|
172
|
+
now: dt.datetime,
|
|
173
|
+
opening_cash: Decimal,
|
|
174
|
+
ledger_through: dt.date,
|
|
175
|
+
transactions: Iterable[Transaction],
|
|
176
|
+
) -> Forecast:
|
|
177
|
+
"""The capital each member must contribute to keep cash at or above the floor.
|
|
178
|
+
|
|
179
|
+
The floor is the largest single charge, so a one-off bill is always
|
|
180
|
+
coverable. The call is whatever lifts the cash path's lowest closing
|
|
181
|
+
balance to the floor, `max(0, floor - lowest closing)`, split evenly and
|
|
182
|
+
rounded up per member.
|
|
183
|
+
"""
|
|
184
|
+
today = now.date()
|
|
185
|
+
start, end = window(today)
|
|
186
|
+
dues = _dues(file, today, end, _payments(transactions, today))
|
|
187
|
+
owed = [d for d in dues if d.status == "owed"]
|
|
188
|
+
|
|
189
|
+
months: list[Month] = []
|
|
190
|
+
opening = opening_cash
|
|
191
|
+
month = start.replace(day=1)
|
|
192
|
+
while month <= end:
|
|
193
|
+
outflows = sum((d.amount for d in owed if (d.due.year, d.due.month) == (month.year, month.month)), Decimal(0))
|
|
194
|
+
inflows = Decimal(0)
|
|
195
|
+
closing = opening + inflows - outflows
|
|
196
|
+
months.append(Month(month, opening, outflows, inflows, closing))
|
|
197
|
+
opening = closing
|
|
198
|
+
month = _add_months(month, 1)
|
|
199
|
+
|
|
200
|
+
if file.call.floor is not None:
|
|
201
|
+
floor, floor_from = file.call.floor, FIXED_FLOOR_SOURCE
|
|
202
|
+
elif owed:
|
|
203
|
+
biggest = max(owed, key=lambda d: d.amount)
|
|
204
|
+
floor, floor_from = biggest.amount, biggest.line.name
|
|
205
|
+
else:
|
|
206
|
+
floor, floor_from = Decimal(0), NO_FLOOR_SOURCE
|
|
207
|
+
|
|
208
|
+
call = max(Decimal(0), floor - min(m.closing for m in months))
|
|
209
|
+
share = (call / len(file.call.members)).quantize(_CENT, rounding=ROUND_CEILING)
|
|
210
|
+
members = tuple((m, share) for m in file.call.members)
|
|
211
|
+
return Forecast(
|
|
212
|
+
computed_at=now,
|
|
213
|
+
ledger_through=ledger_through,
|
|
214
|
+
window_start=start,
|
|
215
|
+
window_end=end,
|
|
216
|
+
scenario=SCENARIO,
|
|
217
|
+
opening_cash=opening_cash,
|
|
218
|
+
floor=floor,
|
|
219
|
+
floor_from=floor_from,
|
|
220
|
+
months=tuple(months),
|
|
221
|
+
dues=dues,
|
|
222
|
+
members=members,
|
|
223
|
+
total_call=sum((s for _, s in members), Decimal(0)),
|
|
224
|
+
)
|