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,228 @@
|
|
|
1
|
+
"""The Cash Forecast doctype, and writing a computed forecast into it.
|
|
2
|
+
|
|
3
|
+
Each run writes a new document and never edits an earlier one: the capital call
|
|
4
|
+
is money people wired against, so the record of what a past run said must not
|
|
5
|
+
change underneath them. A custom doctype (`custom: 1`) needs no developer mode
|
|
6
|
+
on the site, which is why every definition here carries it.
|
|
7
|
+
|
|
8
|
+
Installing needs an administrator, for the same reason `install_custom_fields`
|
|
9
|
+
does: the books bot may write forecasts, not reshape doctypes.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from decimal import Decimal
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from icedcoffee_sync.frappe.errors import DoesNotExistError, PermissionError_
|
|
19
|
+
|
|
20
|
+
from runningtab.forecast.compute import Forecast
|
|
21
|
+
from runningtab.forecast.names import CASH_FORECAST
|
|
22
|
+
from runningtab.mercury.posting import Ledger, Outcome
|
|
23
|
+
|
|
24
|
+
MONTH_TABLE = "Cash Forecast Month"
|
|
25
|
+
MEMBER_TABLE = "Cash Forecast Member"
|
|
26
|
+
LINE_TABLE = "Cash Forecast Line"
|
|
27
|
+
|
|
28
|
+
_DOCTYPE = "DocType"
|
|
29
|
+
_STATUS_OPTIONS = "owed\nseen paid"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _field(fieldname: str, fieldtype: str, options: str | None = None, *, in_list: bool = False) -> dict[str, Any]:
|
|
33
|
+
field: dict[str, Any] = {
|
|
34
|
+
"fieldname": fieldname,
|
|
35
|
+
"label": fieldname.replace("_", " ").title(),
|
|
36
|
+
"fieldtype": fieldtype,
|
|
37
|
+
"in_list_view": int(in_list),
|
|
38
|
+
}
|
|
39
|
+
if options is not None:
|
|
40
|
+
field["options"] = options
|
|
41
|
+
return field
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _table(name: str, fields: list[dict[str, Any]]) -> dict[str, Any]:
|
|
45
|
+
return {
|
|
46
|
+
"doctype": _DOCTYPE,
|
|
47
|
+
"name": name,
|
|
48
|
+
"module": "Accounts",
|
|
49
|
+
"custom": 1,
|
|
50
|
+
"istable": 1,
|
|
51
|
+
"fields": fields,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def doctype_documents() -> list[dict[str, Any]]:
|
|
56
|
+
"""The four DocType definitions, child tables first so the parent can name them."""
|
|
57
|
+
month = _table(
|
|
58
|
+
MONTH_TABLE,
|
|
59
|
+
[
|
|
60
|
+
_field("month", "Date", in_list=True),
|
|
61
|
+
_field("opening", "Currency", in_list=True),
|
|
62
|
+
_field("outflows", "Currency", in_list=True),
|
|
63
|
+
_field("inflows", "Currency", in_list=True),
|
|
64
|
+
_field("closing", "Currency", in_list=True),
|
|
65
|
+
],
|
|
66
|
+
)
|
|
67
|
+
member = _table(
|
|
68
|
+
MEMBER_TABLE,
|
|
69
|
+
[
|
|
70
|
+
_field("account", "Link", "Account", in_list=True),
|
|
71
|
+
_field("amount", "Currency", in_list=True),
|
|
72
|
+
],
|
|
73
|
+
)
|
|
74
|
+
line = _table(
|
|
75
|
+
LINE_TABLE,
|
|
76
|
+
[
|
|
77
|
+
_field("month", "Date", in_list=True),
|
|
78
|
+
_field("due", "Date", in_list=True),
|
|
79
|
+
_field("line", "Data", in_list=True),
|
|
80
|
+
_field("account", "Link", "Account"),
|
|
81
|
+
_field("amount", "Currency", in_list=True),
|
|
82
|
+
_field("status", "Select", _STATUS_OPTIONS, in_list=True),
|
|
83
|
+
],
|
|
84
|
+
)
|
|
85
|
+
parent = {
|
|
86
|
+
"doctype": _DOCTYPE,
|
|
87
|
+
"name": CASH_FORECAST,
|
|
88
|
+
"module": "Accounts",
|
|
89
|
+
"custom": 1,
|
|
90
|
+
"istable": 0,
|
|
91
|
+
"is_submittable": 0,
|
|
92
|
+
"track_changes": 0,
|
|
93
|
+
"autoname": "CF-.#####",
|
|
94
|
+
"sort_field": "creation",
|
|
95
|
+
"sort_order": "DESC",
|
|
96
|
+
"fields": [
|
|
97
|
+
# Data, not Datetime: Frappe reads a Datetime as site-local and would
|
|
98
|
+
# silently shift an instant that carries its own UTC offset.
|
|
99
|
+
_field("computed_at", "Data", in_list=True),
|
|
100
|
+
_field("ledger_through", "Date"),
|
|
101
|
+
_field("window_start", "Date"),
|
|
102
|
+
_field("window_end", "Date"),
|
|
103
|
+
_field("scenario", "Data"),
|
|
104
|
+
_field("opening_cash", "Currency"),
|
|
105
|
+
_field("floor", "Currency"),
|
|
106
|
+
_field("floor_from", "Data"),
|
|
107
|
+
_field("total_call", "Currency", in_list=True),
|
|
108
|
+
_field("months", "Table", MONTH_TABLE),
|
|
109
|
+
_field("members", "Table", MEMBER_TABLE),
|
|
110
|
+
_field("lines", "Table", LINE_TABLE),
|
|
111
|
+
],
|
|
112
|
+
"permissions": [
|
|
113
|
+
{"role": "System Manager", "read": 1, "write": 1, "create": 1, "delete": 1, "report": 1, "export": 1},
|
|
114
|
+
{"role": "Accounts Manager", "read": 1},
|
|
115
|
+
],
|
|
116
|
+
}
|
|
117
|
+
return [month, member, line, parent]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _shape(fields: list[dict[str, Any]]) -> dict[str, tuple[str, str]]:
|
|
121
|
+
"""What makes a field the right field, by name: Frappe fills the rest in itself.
|
|
122
|
+
|
|
123
|
+
Keyed by fieldname so a different field order from Frappe is not a
|
|
124
|
+
difference.
|
|
125
|
+
"""
|
|
126
|
+
return {str(f["fieldname"]): (str(f["fieldtype"]), str(f.get("options") or "")) for f in fields}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def install_doctypes(admin: Ledger) -> list[tuple[str, Outcome]]:
|
|
130
|
+
"""Create each doctype if absent; replace its field list if it differs.
|
|
131
|
+
|
|
132
|
+
Repair covers fields only. It does not touch permissions or `is_submittable`
|
|
133
|
+
on an existing doctype.
|
|
134
|
+
|
|
135
|
+
A doctype that exists with a field of the wrong type would accept the
|
|
136
|
+
forecast write and then store the wrong thing, so presence is not enough.
|
|
137
|
+
Repair replaces the whole field list with the desired one, so a field added
|
|
138
|
+
by hand to the doctype is dropped from the definition (its column stays).
|
|
139
|
+
It never deletes the doctype, which would take every stored forecast with
|
|
140
|
+
it.
|
|
141
|
+
"""
|
|
142
|
+
results: list[tuple[str, Outcome]] = []
|
|
143
|
+
for document in doctype_documents():
|
|
144
|
+
name = document["name"]
|
|
145
|
+
body = {k: v for k, v in document.items() if k != "doctype"}
|
|
146
|
+
if not admin.list(_DOCTYPE, fields=["name"], filters=[["name", "=", name]], limit=1):
|
|
147
|
+
admin.insert(_DOCTYPE, body)
|
|
148
|
+
results.append((name, Outcome.CREATED))
|
|
149
|
+
continue
|
|
150
|
+
existing = admin.get(_DOCTYPE, name)
|
|
151
|
+
if _shape(existing.get("fields") or []) == _shape(body["fields"]):
|
|
152
|
+
results.append((name, Outcome.UNCHANGED))
|
|
153
|
+
continue
|
|
154
|
+
admin.update(_DOCTYPE, name, {"fields": body["fields"]})
|
|
155
|
+
results.append((name, Outcome.UPDATED))
|
|
156
|
+
return results
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def is_installed(ledger: Ledger) -> bool:
|
|
160
|
+
"""Whether the site has the Cash Forecast doctype, asked with the caller's own credential.
|
|
161
|
+
|
|
162
|
+
Listing the doctype needs no permission on `DocType` itself, which the bot
|
|
163
|
+
lacks. A site without the doctype answers DoesNotExist; a credential not
|
|
164
|
+
yet granted read answers PermissionError, which only a site that has the
|
|
165
|
+
doctype can say.
|
|
166
|
+
"""
|
|
167
|
+
try:
|
|
168
|
+
ledger.list(CASH_FORECAST, fields=["name"], limit=1)
|
|
169
|
+
except DoesNotExistError:
|
|
170
|
+
return False
|
|
171
|
+
except PermissionError_:
|
|
172
|
+
return True
|
|
173
|
+
return True
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _money(value: Decimal) -> float:
|
|
177
|
+
"""A currency amount as JSON, converted once after all arithmetic is done in `Decimal`."""
|
|
178
|
+
return float(value)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def forecast_document(forecast: Forecast, *, link: Callable[[str], str]) -> dict[str, Any]:
|
|
182
|
+
"""The Cash Forecast document for a computed forecast.
|
|
183
|
+
|
|
184
|
+
`link` maps a chart account number to the company-suffixed name ERPNext
|
|
185
|
+
links by.
|
|
186
|
+
"""
|
|
187
|
+
if forecast.computed_at.utcoffset() is None:
|
|
188
|
+
raise ValueError("computed_at must carry a time zone; a naive time is ambiguous once stored")
|
|
189
|
+
return {
|
|
190
|
+
"doctype": CASH_FORECAST,
|
|
191
|
+
"computed_at": forecast.computed_at.isoformat(timespec="seconds"),
|
|
192
|
+
"ledger_through": forecast.ledger_through.isoformat(),
|
|
193
|
+
"window_start": forecast.window_start.isoformat(),
|
|
194
|
+
"window_end": forecast.window_end.isoformat(),
|
|
195
|
+
"scenario": forecast.scenario,
|
|
196
|
+
"opening_cash": _money(forecast.opening_cash),
|
|
197
|
+
"floor": _money(forecast.floor),
|
|
198
|
+
"floor_from": forecast.floor_from,
|
|
199
|
+
"total_call": _money(forecast.total_call),
|
|
200
|
+
"months": [
|
|
201
|
+
{
|
|
202
|
+
"month": m.month.isoformat(),
|
|
203
|
+
"opening": _money(m.opening),
|
|
204
|
+
"outflows": _money(m.outflows),
|
|
205
|
+
"inflows": _money(m.inflows),
|
|
206
|
+
"closing": _money(m.closing),
|
|
207
|
+
}
|
|
208
|
+
for m in forecast.months
|
|
209
|
+
],
|
|
210
|
+
"members": [{"account": link(number), "amount": _money(amount)} for number, amount in forecast.members],
|
|
211
|
+
"lines": [
|
|
212
|
+
{
|
|
213
|
+
"month": d.due.replace(day=1).isoformat(),
|
|
214
|
+
"due": d.due.isoformat(),
|
|
215
|
+
"line": d.line.name,
|
|
216
|
+
"account": link(d.line.account),
|
|
217
|
+
"amount": _money(d.amount),
|
|
218
|
+
"status": d.status,
|
|
219
|
+
}
|
|
220
|
+
for d in forecast.dues
|
|
221
|
+
],
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def write_forecast(ledger: Ledger, forecast: Forecast, *, link: Callable[[str], str]) -> str:
|
|
226
|
+
"""Insert the forecast as a new document and return its name."""
|
|
227
|
+
created = ledger.insert(CASH_FORECAST, forecast_document(forecast, link=link))
|
|
228
|
+
return str(created["name"])
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""The forecast file: expected cash movements, as checked-in data.
|
|
2
|
+
|
|
3
|
+
`forecast.toml` sits beside the chart in the books directory. A `[call]` table
|
|
4
|
+
names the member capital accounts and an optional floor; each `[[line]]` table
|
|
5
|
+
is one recurring cost. The file is validated against the chart on load so a
|
|
6
|
+
typo in an account number is refused here rather than surfacing as a wrong
|
|
7
|
+
capital call later.
|
|
8
|
+
|
|
9
|
+
`lag_days` and `confidence` are reserved for income and payroll lines. They are
|
|
10
|
+
accepted and ignored so that adding them later does not invalidate the file.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import dataclasses
|
|
16
|
+
import datetime as dt
|
|
17
|
+
import pathlib
|
|
18
|
+
import tomllib
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from decimal import Decimal
|
|
21
|
+
from typing import Any, Literal, cast
|
|
22
|
+
|
|
23
|
+
from runningtab.mercury.vouchers import EXPENSE_PREFIX, MEMBER_CAPITAL_PREFIX, Chart
|
|
24
|
+
|
|
25
|
+
_RESERVED = frozenset({"lag_days", "confidence"})
|
|
26
|
+
_KNOWN = (
|
|
27
|
+
frozenset({
|
|
28
|
+
"name",
|
|
29
|
+
"kind",
|
|
30
|
+
"account",
|
|
31
|
+
"match",
|
|
32
|
+
"cadence",
|
|
33
|
+
"day",
|
|
34
|
+
"on",
|
|
35
|
+
"amount",
|
|
36
|
+
"low",
|
|
37
|
+
"high",
|
|
38
|
+
"paid",
|
|
39
|
+
"starts",
|
|
40
|
+
"ends",
|
|
41
|
+
})
|
|
42
|
+
| _RESERVED
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ForecastFileError(ValueError):
|
|
47
|
+
"""`forecast.toml` is not a forecast we can compute from."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclasses.dataclass(frozen=True)
|
|
51
|
+
class Amount:
|
|
52
|
+
"""What a line costs in a month. A fixed cost has `low == high`."""
|
|
53
|
+
|
|
54
|
+
low: Decimal
|
|
55
|
+
high: Decimal
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclasses.dataclass(frozen=True)
|
|
59
|
+
class Line:
|
|
60
|
+
"""One recurring expense.
|
|
61
|
+
|
|
62
|
+
`day` is set for monthly lines; `on` for yearly ones, where only its month
|
|
63
|
+
and day recur. `match` names the Mercury counterparties whose charge is this
|
|
64
|
+
line; for a reimbursed line that is the member Mercury pays back, one
|
|
65
|
+
transfer per charge. A line without `match` is always counted owed.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
name: str
|
|
69
|
+
account: str
|
|
70
|
+
match: tuple[str, ...]
|
|
71
|
+
cadence: Literal["monthly", "yearly"]
|
|
72
|
+
day: int | None
|
|
73
|
+
on: dt.date | None
|
|
74
|
+
amount: Amount
|
|
75
|
+
paid: Literal["mercury", "reimbursed"]
|
|
76
|
+
starts: dt.date | None
|
|
77
|
+
ends: dt.date | None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclasses.dataclass(frozen=True)
|
|
81
|
+
class CallPolicy:
|
|
82
|
+
"""Who is called for capital, and an optional fixed floor overriding the computed one."""
|
|
83
|
+
|
|
84
|
+
members: tuple[str, ...]
|
|
85
|
+
floor: Decimal | None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclasses.dataclass(frozen=True)
|
|
89
|
+
class ForecastFile:
|
|
90
|
+
"""The whole parsed file."""
|
|
91
|
+
|
|
92
|
+
lines: tuple[Line, ...]
|
|
93
|
+
call: CallPolicy
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _money(value: Any, where: str) -> Decimal:
|
|
97
|
+
"""A TOML number as Decimal, via its text so 29.16 stays 29.16."""
|
|
98
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
99
|
+
raise ForecastFileError(f"{where} must be a number, got {value!r}")
|
|
100
|
+
return Decimal(str(value))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _date(value: Any, where: str) -> dt.date:
|
|
104
|
+
if not isinstance(value, dt.date) or isinstance(value, dt.datetime):
|
|
105
|
+
raise ForecastFileError(f"{where} must be a TOML date (YYYY-MM-DD), got {value!r}")
|
|
106
|
+
return value
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _optional_date(table: Mapping[str, Any], key: str, where: str) -> dt.date | None:
|
|
110
|
+
return _date(table[key], f"{where}: {key}") if key in table else None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _parse_account(table: Mapping[str, Any], where: str, postable: Mapping[str, str]) -> str:
|
|
114
|
+
account = table.get("account")
|
|
115
|
+
if not isinstance(account, str) or account not in postable:
|
|
116
|
+
raise ForecastFileError(f"{where}: account {account!r} is not a postable account in chart.coa")
|
|
117
|
+
if not account.startswith(EXPENSE_PREFIX):
|
|
118
|
+
raise ForecastFileError(f"{where}: account {account} is not an expense account")
|
|
119
|
+
return account
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _parse_match(table: Mapping[str, Any], where: str, paid: str) -> tuple[str, ...]:
|
|
123
|
+
match = table.get("match", [])
|
|
124
|
+
if not isinstance(match, list) or not all(isinstance(m, str) and m for m in match):
|
|
125
|
+
raise ForecastFileError(f"{where}: match must be a list of counterparty names")
|
|
126
|
+
if paid == "mercury" and not match:
|
|
127
|
+
raise ForecastFileError(f"{where}: match is required for a mercury line; the paid-this-month check needs it")
|
|
128
|
+
return tuple(match)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _parse_when(
|
|
132
|
+
table: Mapping[str, Any], where: str
|
|
133
|
+
) -> tuple[Literal["monthly", "yearly"], int | None, dt.date | None]:
|
|
134
|
+
"""The cadence with its `day` (monthly) or `on` (yearly)."""
|
|
135
|
+
cadence = table.get("cadence")
|
|
136
|
+
if cadence == "monthly":
|
|
137
|
+
day = table.get("day")
|
|
138
|
+
if isinstance(day, bool) or not isinstance(day, int) or not 1 <= day <= 31:
|
|
139
|
+
raise ForecastFileError(f"{where}: a monthly line needs day = 1..31, got {day!r}")
|
|
140
|
+
if "on" in table:
|
|
141
|
+
raise ForecastFileError(f"{where}: a monthly line takes day, not on")
|
|
142
|
+
return "monthly", day, None
|
|
143
|
+
if cadence == "yearly":
|
|
144
|
+
if "on" not in table:
|
|
145
|
+
raise ForecastFileError(f"{where}: a yearly line needs on = a date; its month and day recur")
|
|
146
|
+
if "day" in table:
|
|
147
|
+
raise ForecastFileError(f"{where}: a yearly line takes on, not day")
|
|
148
|
+
on = _date(table["on"], f"{where}: on")
|
|
149
|
+
if (on.month, on.day) == (2, 29):
|
|
150
|
+
raise ForecastFileError(f"{where}: on {on} (Feb 29) does not recur every year; use Feb 28 or Mar 1")
|
|
151
|
+
return "yearly", None, on
|
|
152
|
+
raise ForecastFileError(f"{where}: cadence must be 'monthly' or 'yearly', got {cadence!r}")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _parse_amount(table: Mapping[str, Any], where: str) -> Amount:
|
|
156
|
+
if "amount" in table:
|
|
157
|
+
if "low" in table or "high" in table:
|
|
158
|
+
raise ForecastFileError(f"{where}: give amount or low and high, not both")
|
|
159
|
+
fixed = _money(table["amount"], f"{where}: amount")
|
|
160
|
+
amount = Amount(fixed, fixed)
|
|
161
|
+
elif "low" in table and "high" in table:
|
|
162
|
+
amount = Amount(_money(table["low"], f"{where}: low"), _money(table["high"], f"{where}: high"))
|
|
163
|
+
if amount.low > amount.high:
|
|
164
|
+
raise ForecastFileError(f"{where}: low {amount.low} is above high {amount.high}")
|
|
165
|
+
else:
|
|
166
|
+
raise ForecastFileError(f"{where}: needs amount, or both low and high")
|
|
167
|
+
if amount.low < 0:
|
|
168
|
+
raise ForecastFileError(f"{where}: amounts must not be negative")
|
|
169
|
+
return amount
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _parse_line(table: Mapping[str, Any], index: int, postable: Mapping[str, str]) -> Line:
|
|
173
|
+
name = table.get("name")
|
|
174
|
+
if not isinstance(name, str) or not name:
|
|
175
|
+
raise ForecastFileError(f"line #{index + 1}: name is required")
|
|
176
|
+
where = f"line {name!r}"
|
|
177
|
+
unknown = sorted(set(table) - _KNOWN)
|
|
178
|
+
if unknown:
|
|
179
|
+
raise ForecastFileError(f"{where}: unknown field(s) {', '.join(unknown)}")
|
|
180
|
+
kind = table.get("kind")
|
|
181
|
+
if kind != "expense":
|
|
182
|
+
raise ForecastFileError(f"{where}: kind {kind!r} is not supported yet; only 'expense' is")
|
|
183
|
+
paid = table.get("paid")
|
|
184
|
+
if paid not in ("mercury", "reimbursed"):
|
|
185
|
+
raise ForecastFileError(f"{where}: paid must be 'mercury' or 'reimbursed', got {paid!r}")
|
|
186
|
+
cadence, day, on = _parse_when(table, where)
|
|
187
|
+
starts = _optional_date(table, "starts", where)
|
|
188
|
+
ends = _optional_date(table, "ends", where)
|
|
189
|
+
if starts and ends and starts > ends:
|
|
190
|
+
raise ForecastFileError(f"{where}: starts {starts} is later than ends {ends}")
|
|
191
|
+
return Line(
|
|
192
|
+
name=name,
|
|
193
|
+
account=_parse_account(table, where, postable),
|
|
194
|
+
match=_parse_match(table, where, paid),
|
|
195
|
+
cadence=cadence,
|
|
196
|
+
day=day,
|
|
197
|
+
on=on,
|
|
198
|
+
amount=_parse_amount(table, where),
|
|
199
|
+
paid=paid,
|
|
200
|
+
starts=starts,
|
|
201
|
+
ends=ends,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _parse_call(table: Mapping[str, Any], postable: Mapping[str, str]) -> CallPolicy:
|
|
206
|
+
members = table.get("members")
|
|
207
|
+
if not isinstance(members, list) or not members:
|
|
208
|
+
raise ForecastFileError("call: members must be a non-empty list of member capital accounts")
|
|
209
|
+
for member in members:
|
|
210
|
+
if not isinstance(member, str) or member not in postable or not member.startswith(MEMBER_CAPITAL_PREFIX):
|
|
211
|
+
raise ForecastFileError(
|
|
212
|
+
f"call: member {member!r} is not a member capital account ({MEMBER_CAPITAL_PREFIX}xx)"
|
|
213
|
+
)
|
|
214
|
+
duplicates = sorted({m for m in members if members.count(m) > 1})
|
|
215
|
+
if duplicates:
|
|
216
|
+
raise ForecastFileError(f"call: member(s) {', '.join(duplicates)} listed more than once; the split would skew")
|
|
217
|
+
floor = _money(table["floor"], "call: floor") if "floor" in table else None
|
|
218
|
+
if floor is not None and floor < 0:
|
|
219
|
+
raise ForecastFileError(f"call: floor {floor} must not be negative")
|
|
220
|
+
return CallPolicy(members=tuple(members), floor=floor)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def parse_forecast(text: str, *, postable: Mapping[str, str]) -> ForecastFile:
|
|
224
|
+
"""Parse and validate forecast TOML against the chart's postable accounts.
|
|
225
|
+
|
|
226
|
+
Raises:
|
|
227
|
+
ForecastFileError: naming the line and field that is wrong.
|
|
228
|
+
"""
|
|
229
|
+
try:
|
|
230
|
+
data = tomllib.loads(text)
|
|
231
|
+
except tomllib.TOMLDecodeError as error:
|
|
232
|
+
raise ForecastFileError(f"forecast file is not valid TOML: {error}") from error
|
|
233
|
+
call = data.get("call")
|
|
234
|
+
if not isinstance(call, dict):
|
|
235
|
+
raise ForecastFileError("forecast file needs a [call] table")
|
|
236
|
+
raw_lines = data.get("line", [])
|
|
237
|
+
if not isinstance(raw_lines, list) or not all(isinstance(t, dict) for t in raw_lines):
|
|
238
|
+
raise ForecastFileError("forecast file: line must be an array of [[line]] tables")
|
|
239
|
+
lines = tuple(
|
|
240
|
+
_parse_line(table, index, postable) for index, table in enumerate(cast("list[Mapping[str, Any]]", raw_lines))
|
|
241
|
+
)
|
|
242
|
+
names = [line.name for line in lines]
|
|
243
|
+
duplicate_names = sorted({n for n in names if names.count(n) > 1})
|
|
244
|
+
if duplicate_names:
|
|
245
|
+
raise ForecastFileError(f"forecast file: duplicate line name(s) {', '.join(duplicate_names)}")
|
|
246
|
+
return ForecastFile(lines=lines, call=_parse_call(call, postable))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def load_forecast(path: pathlib.Path, chart: Chart) -> ForecastFile:
|
|
250
|
+
"""Read `forecast.toml` and validate it against the chart beside it."""
|
|
251
|
+
return parse_forecast(path.read_text(encoding="utf-8"), postable=chart.postable)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Doctype names the access policy needs, kept import-free.
|
|
2
|
+
|
|
3
|
+
`runningtab.access` must name the Cash Forecast doctype, but `runningtab.forecast.doctype`
|
|
4
|
+
imports the ledger code, which imports `runningtab.access`. Holding the name here
|
|
5
|
+
breaks that cycle; `runningtab.forecast.doctype` re-exports it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
CASH_FORECAST = "Cash Forecast"
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Where the forecast's opening numbers come from.
|
|
2
|
+
|
|
3
|
+
Opening cash is Mercury's own balance, and the ledger's reconciliation frontier
|
|
4
|
+
is the last day ERPNext agrees with the bank. Both refuse plainly when they
|
|
5
|
+
cannot be trusted: a capital call computed from a balance that does not
|
|
6
|
+
reconcile is a confident wrong number that members then wire money against.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import datetime as dt
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from decimal import Decimal
|
|
14
|
+
from typing import Any, Protocol
|
|
15
|
+
|
|
16
|
+
from icedcoffee_sync.frappe.errors import FrappeError
|
|
17
|
+
|
|
18
|
+
from runningtab.mercury.ingest import BalanceCheck
|
|
19
|
+
|
|
20
|
+
MAINTENANCE = "SessionStopped"
|
|
21
|
+
"""The `error_type` a Frappe site answers with while an update is running."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SourceError(RuntimeError):
|
|
25
|
+
"""An input the forecast cannot trust. The message is the sentence a person reads."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _Reader(Protocol):
|
|
29
|
+
def iter_all(self, doctype: str, **kwargs: Any) -> Any: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def opening_cash(balances: Sequence[BalanceCheck]) -> Decimal:
|
|
33
|
+
"""Cash on hand: the sum of every account's reported balance, net of cards.
|
|
34
|
+
|
|
35
|
+
A credit account reports a negative balance when it owes money, so a plain
|
|
36
|
+
sum is already net of what the card owes.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
SourceError: Naming every account whose reported balance differs from
|
|
40
|
+
the sum of the transactions read for it, since that means the read
|
|
41
|
+
was incomplete and the balance cannot anchor a forecast.
|
|
42
|
+
"""
|
|
43
|
+
if not balances:
|
|
44
|
+
raise SourceError("Mercury returned no accounts, so there is no balance to forecast from")
|
|
45
|
+
bad = [check.label for check in balances if not check.ok]
|
|
46
|
+
if bad:
|
|
47
|
+
raise SourceError(
|
|
48
|
+
"Mercury's balance does not match its transactions for "
|
|
49
|
+
+ ", ".join(bad)
|
|
50
|
+
+ "; the read was incomplete, so no forecast"
|
|
51
|
+
)
|
|
52
|
+
return sum((check.reported for check in balances), Decimal(0))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def ledger_through(ledger: _Reader, today: dt.date) -> dt.date:
|
|
56
|
+
"""The last day the ledger is known to agree with the bank.
|
|
57
|
+
|
|
58
|
+
That is the day before the oldest submitted Bank Transaction still
|
|
59
|
+
unreconciled, or `today` when none is.
|
|
60
|
+
"""
|
|
61
|
+
rows = ledger.iter_all(
|
|
62
|
+
"Bank Transaction",
|
|
63
|
+
fields=["name", "date"],
|
|
64
|
+
filters=[["status", "=", "Unreconciled"], ["docstatus", "=", 1]],
|
|
65
|
+
)
|
|
66
|
+
dates = [_row_date(row) for row in rows]
|
|
67
|
+
return min(dates) - dt.timedelta(days=1) if dates else today
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _row_date(row: Any) -> dt.date:
|
|
71
|
+
"""A Bank Transaction's date, or a SourceError naming the row when it has none we can read."""
|
|
72
|
+
raw = row.get("date")
|
|
73
|
+
try:
|
|
74
|
+
return dt.date.fromisoformat(str(raw))
|
|
75
|
+
except ValueError:
|
|
76
|
+
raise SourceError(
|
|
77
|
+
f"Bank Transaction {row.get('name')} has no readable date ({raw!r}), so the reconciliation frontier is unknown"
|
|
78
|
+
) from None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def maintenance_message(exc: FrappeError) -> str | None:
|
|
82
|
+
"""The sentence for a site mid-update, or None when the error is something else."""
|
|
83
|
+
if exc.error_type == MAINTENANCE:
|
|
84
|
+
return "the site is in maintenance mode (an update is running); run this again when it finishes"
|
|
85
|
+
return None
|
runningtab/gl_codes.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""The GL code list Mercury shows on every transaction: our postable accounts, as a CSV.
|
|
2
|
+
|
|
3
|
+
The code a person picks in the bank rides along on the Bank Transaction as a
|
|
4
|
+
hint for whoever reconciles it, so the vocabulary they pick from has to be the
|
|
5
|
+
ledger's. There is no upload API; a person uploads it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import csv
|
|
11
|
+
import io
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from icedcoffee_sync.apply.model import DesiredAccount
|
|
15
|
+
|
|
16
|
+
_THOUSANDS_SEPARATOR = re.compile(r"(?<=\d),(?=\d)")
|
|
17
|
+
_LIST_SEPARATOR = re.compile(r",\s*")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def postable_accounts(chart: list[DesiredAccount]) -> list[DesiredAccount]:
|
|
21
|
+
"""Accounts a transaction can actually post to. Groups cannot."""
|
|
22
|
+
return [a for a in chart if not a.is_group]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def gl_code_name(account: DesiredAccount) -> str:
|
|
26
|
+
"""One entry of the list: `{number} - {name}` with every comma spent.
|
|
27
|
+
|
|
28
|
+
Mercury's GL-code importer splits each line at the first comma no matter how
|
|
29
|
+
the CSV quotes it, so a label carrying one reaches the bank truncated and
|
|
30
|
+
every transaction coded with it names an account that does not exist. A
|
|
31
|
+
comma between two digits is a thousands separator and simply goes; any other
|
|
32
|
+
comma separates list items and becomes ` / `. The ERPNext account name is
|
|
33
|
+
left alone, so a label can differ from the account it stands for.
|
|
34
|
+
"""
|
|
35
|
+
name = _LIST_SEPARATOR.sub(" / ", _THOUSANDS_SEPARATOR.sub("", account.name))
|
|
36
|
+
return f"{account.number} - {name}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def gl_code_csv(chart: list[DesiredAccount]) -> str:
|
|
40
|
+
"""A single column of `gl_code_name`, no header, and no comma or quote on any line.
|
|
41
|
+
|
|
42
|
+
Quoting would be the RFC 4180 answer to a comma in a field, and Mercury's
|
|
43
|
+
importer does not honour it — so the labels carry no commas to quote and no
|
|
44
|
+
line here is ever quoted.
|
|
45
|
+
"""
|
|
46
|
+
out = io.StringIO()
|
|
47
|
+
writer = csv.writer(out, lineterminator="\n")
|
|
48
|
+
for account in postable_accounts(chart):
|
|
49
|
+
writer.writerow([gl_code_name(account)])
|
|
50
|
+
return out.getvalue()
|