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,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()