runningtab 0.1.0.dev3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. runningtab/__init__.py +3 -0
  2. runningtab/access.py +419 -0
  3. runningtab/books.py +225 -0
  4. runningtab/chart.py +408 -0
  5. runningtab/cli.py +2216 -0
  6. runningtab/coa.py +212 -0
  7. runningtab/doc.py +185 -0
  8. runningtab/forecast/__init__.py +1 -0
  9. runningtab/forecast/compute.py +224 -0
  10. runningtab/forecast/doctype.py +228 -0
  11. runningtab/forecast/model.py +251 -0
  12. runningtab/forecast/names.py +8 -0
  13. runningtab/forecast/sources.py +85 -0
  14. runningtab/gl_codes.py +50 -0
  15. runningtab/health.py +661 -0
  16. runningtab/mercury/__init__.py +7 -0
  17. runningtab/mercury/categories.py +500 -0
  18. runningtab/mercury/classify.py +101 -0
  19. runningtab/mercury/client.py +360 -0
  20. runningtab/mercury/ingest.py +733 -0
  21. runningtab/mercury/mapping.py +130 -0
  22. runningtab/mercury/models.py +544 -0
  23. runningtab/mercury/posting.py +757 -0
  24. runningtab/mercury/receipts.py +304 -0
  25. runningtab/mercury/session.py +65 -0
  26. runningtab/mercury/state.py +285 -0
  27. runningtab/mercury/vouchers.py +951 -0
  28. runningtab/period/__init__.py +9 -0
  29. runningtab/period/balances.py +283 -0
  30. runningtab/period/bank.py +154 -0
  31. runningtab/period/close.py +510 -0
  32. runningtab/period/crosscheck.py +257 -0
  33. runningtab/period/csvfmt.py +86 -0
  34. runningtab/period/documents.py +260 -0
  35. runningtab/period/fence.py +277 -0
  36. runningtab/period/ledger.py +205 -0
  37. runningtab/period/lock.py +294 -0
  38. runningtab/period/manifest.py +259 -0
  39. runningtab/period/open_items.py +84 -0
  40. runningtab/period/package.py +140 -0
  41. runningtab/period/qbo.py +521 -0
  42. runningtab/period/qbo_map.py +174 -0
  43. runningtab/period/quarter.py +90 -0
  44. runningtab/period/readiness.py +146 -0
  45. runningtab/period/record.py +247 -0
  46. runningtab/period/status.py +312 -0
  47. runningtab/site.py +409 -0
  48. runningtab-0.1.0.dev3.dist-info/METADATA +726 -0
  49. runningtab-0.1.0.dev3.dist-info/RECORD +52 -0
  50. runningtab-0.1.0.dev3.dist-info/WHEEL +4 -0
  51. runningtab-0.1.0.dev3.dist-info/entry_points.txt +2 -0
  52. runningtab-0.1.0.dev3.dist-info/licenses/LICENSE +202 -0
runningtab/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
+ )