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,277 @@
1
+ """Fence Company's two close-lock fields at permission level 1, and prove the bot cannot move them.
2
+
3
+ `accounts_frozen_till_date` and `role_allowed_for_frozen_entries` are the two
4
+ fields that make a quarter's close mean something: once set, they are supposed
5
+ to stop *anyone* below the fence from post-dating an entry into a closed
6
+ period. Company's standard permissions are all permlevel 0
7
+ (`erpnext/setup/doctype/company/company.json`), so the bot's ordinary
8
+ `Company: read, write` grant (`runningtab.access.GRANTS`) would otherwise reach
9
+ them like any other field.
10
+
11
+ Frappe does not refuse a write to a field above the writer's permlevel -- it
12
+ silently resets it to what is already stored (`validate_higher_perm_levels` in
13
+ `frappe/model/document.py`) -- so a doctype-level permission check proves
14
+ nothing here. The only proof is a real write attempted with the bot's key,
15
+ read back by an administrator: `prove_fence`. Its probe only ever pushes
16
+ `accounts_frozen_till_date` later than whatever is there (never earlier), and
17
+ restores whatever it finds changed -- verifying the restore actually took --
18
+ so a freeze a person actually set stays in force whether or not this proof
19
+ runs.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import dataclasses
25
+ import datetime as dt
26
+ from typing import Any, Literal, Protocol
27
+
28
+ from icedcoffee_sync.frappe.access import PermissionGrant, ensure_role, grant
29
+ from icedcoffee_sync.frappe.errors import PermissionError_
30
+
31
+ from runningtab.period import PeriodError
32
+
33
+ LOCK_FIELDS: tuple[str, str] = ("accounts_frozen_till_date", "role_allowed_for_frozen_entries")
34
+
35
+ COMPANY = "Company"
36
+ FENCE_ROLE = "System Manager"
37
+ FENCE_PERMLEVEL = 1
38
+
39
+ # The probe role -- the books directory's `fence.probe_role`, passed in as
40
+ # `probe_role` -- is what the probe writes into
41
+ # `role_allowed_for_frozen_entries`. It must be a role held by nobody -- specifically, it must NOT be "Guest" or "All".
42
+ # Frappe's `get_roles()` (frappe/permissions.py) adds both to every logged-in
43
+ # non-Administrator user's role list unconditionally (`roles +=
44
+ # [ALL_USER_ROLE, GUEST_ROLE]`), and ERPNext's `check_freezing_date`
45
+ # (erpnext/accounts/services/gl_validator.py) lets a posting through once the
46
+ # configured role is in `frappe.get_roles()`. A probe that ever landed
47
+ # "Guest" as the bypass role would not just fail to prove the fence -- it
48
+ # would open the closed quarter to every user on the site. This role exists
49
+ # for exactly one purpose (a value to write that nobody can ever satisfy) and
50
+ # `install_fence` creates it with desk access off; nothing ever grants it to
51
+ # a user. `runningtab.books` refuses a probe role of "Guest", "All", or the
52
+ # bot's or reader's own role.
53
+
54
+ _PLACEHOLDER_DATE = "1900-01-01"
55
+
56
+ HowMoved = Literal["raised", "reset", "took"]
57
+
58
+
59
+ def property_setter_documents() -> list[dict[str, Any]]:
60
+ """The two Property Setters that push `LOCK_FIELDS` to permission level 1 on Company."""
61
+ return [
62
+ {
63
+ "doctype": "Property Setter",
64
+ "doctype_or_field": "DocField",
65
+ "doc_type": COMPANY,
66
+ "field_name": field,
67
+ "property": "permlevel",
68
+ "value": "1",
69
+ "property_type": "Int",
70
+ }
71
+ for field in LOCK_FIELDS
72
+ ]
73
+
74
+
75
+ class _AdminClient(Protocol):
76
+ """The administrator's slice: create/repair the fence, and read/restore Company."""
77
+
78
+ def list(self, doctype: str, **kwargs: Any) -> Any: ...
79
+ def insert(self, doctype: str, doc: dict[str, Any]) -> Any: ...
80
+ def update(self, doctype: str, name: str, doc: dict[str, Any]) -> Any: ...
81
+ def get(self, doctype: str, name: str) -> Any: ...
82
+ def call(self, dotted_path: str, params: dict[str, Any] | None = None) -> Any: ...
83
+
84
+
85
+ class _BotClient(Protocol):
86
+ """The bot's slice: the one write `prove_fence` attempts."""
87
+
88
+ def update(self, doctype: str, name: str, doc: dict[str, Any]) -> Any: ...
89
+
90
+
91
+ def install_fence(admin: _AdminClient, probe_role: str) -> list[tuple[str, str]]:
92
+ """Create or repair the two Property Setters, grant System Manager permlevel-1 Company, and ensure the probe role.
93
+
94
+ Follows `runningtab.mercury.posting.install_custom_fields`: list each Property
95
+ Setter by its `(doc_type, field_name, property)`, insert when absent,
96
+ update `value` when it differs. Needs an administrator -- the books bot's
97
+ role carries no Property Setter permission, deliberately, the same reason
98
+ it carries no Custom Field permission.
99
+
100
+ Does **not** call `revoke_grants_except` for System Manager: that is a
101
+ standard role this code does not own, and reconciling it down to exactly
102
+ one doctype's permissions would strip whatever else a person granted it.
103
+
104
+ Also ensures `probe_role` exists (desk access off), so `prove_fence` has
105
+ a role to probe with that is guaranteed to be held by nobody. Nothing
106
+ here, or anywhere else in this codebase, ever grants it to a user.
107
+ """
108
+ results: list[tuple[str, str]] = []
109
+ for doc in property_setter_documents():
110
+ field = doc["field_name"]
111
+ label = f"{COMPANY}.{field}"
112
+ body = {k: v for k, v in doc.items() if k != "doctype"}
113
+ existing = admin.list(
114
+ "Property Setter",
115
+ fields=["name", "value"],
116
+ filters=[["doc_type", "=", COMPANY], ["field_name", "=", field], ["property", "=", "permlevel"]],
117
+ limit=1,
118
+ )
119
+ if not existing:
120
+ admin.insert("Property Setter", body)
121
+ results.append((label, "created"))
122
+ continue
123
+ row = existing[0]
124
+ if row.get("value") != body["value"]:
125
+ admin.update("Property Setter", str(row["name"]), {"value": body["value"]})
126
+ results.append((label, "updated"))
127
+ else:
128
+ results.append((label, "unchanged"))
129
+
130
+ grant(admin, FENCE_ROLE, PermissionGrant(COMPANY, frozenset({"read", "write"}), permlevel=FENCE_PERMLEVEL))
131
+
132
+ role_change = ensure_role(admin, probe_role)
133
+ if role_change.created:
134
+ results.append((probe_role, "created"))
135
+ elif role_change.desk_access_repaired or role_change.disabled_repaired:
136
+ results.append((probe_role, "updated"))
137
+ else:
138
+ results.append((probe_role, "unchanged"))
139
+ return results
140
+
141
+
142
+ @dataclasses.dataclass(frozen=True)
143
+ class FenceProof:
144
+ """Whether the server actually refused the bot's write to each lock field, and how."""
145
+
146
+ date_refused: bool
147
+ role_refused: bool
148
+ # "raised": the bot's write itself raised PermissionError_.
149
+ # "reset": the write reached the server but the stored value never moved
150
+ # (Frappe's own `validate_higher_perm_levels`).
151
+ # "took": the value actually moved, and was restored (verified).
152
+ date_how: HowMoved
153
+ role_how: HowMoved
154
+ restored: tuple[str, ...]
155
+
156
+
157
+ def _probe_date(current: Any) -> str:
158
+ """A later `accounts_frozen_till_date` than whatever is stored -- freezes more, never less."""
159
+ if not current:
160
+ return _PLACEHOLDER_DATE
161
+ parsed = dt.date.fromisoformat(str(current))
162
+ return (parsed + dt.timedelta(days=1)).isoformat()
163
+
164
+
165
+ def _probe_value(field: str, current: Any, probe_role: str) -> Any:
166
+ if field == LOCK_FIELDS[0]:
167
+ return _probe_date(current)
168
+ return probe_role
169
+
170
+
171
+ def _probe_field(
172
+ admin: _AdminClient, bot: _BotClient, company: str, field: str, original: Any, probe_role: str
173
+ ) -> tuple[bool, bool, HowMoved]:
174
+ """Attempt the bot's write, read it back as admin, and verify the restore actually took.
175
+
176
+ The bot's write sits inside the same `try` the restore's `finally`
177
+ guards: if it raises `PermissionError_`, nothing landed and the probe is
178
+ refused outright ("raised"); if it raises anything else, the write's
179
+ effect is unknown, so the restore below still runs -- a restore after a
180
+ write that never landed is a harmless no-op. Whatever happens, the
181
+ restore is attempted and then verified by reading Company back a second
182
+ time: a restore that does not read back as `original` raises
183
+ `PeriodError` naming the field and both values, because a probe that
184
+ cannot put a lock field back is not something to let return quietly.
185
+ """
186
+ try:
187
+ try:
188
+ bot.update(COMPANY, company, {field: _probe_value(field, original, probe_role)})
189
+ except PermissionError_:
190
+ return True, False, "raised"
191
+
192
+ after = admin.get(COMPANY, company)
193
+ changed = after.get(field) != original
194
+ return not changed, changed, ("took" if changed else "reset")
195
+ finally:
196
+ admin.update(COMPANY, company, {field: original})
197
+ confirmed = admin.get(COMPANY, company)
198
+ if confirmed.get(field) != original:
199
+ raise PeriodError(
200
+ f"the fence probe could not restore {field}: it is stuck at {confirmed.get(field)!r}, "
201
+ f"not its original {original!r}. Set it back in ERPNext now."
202
+ )
203
+
204
+
205
+ def probe_role_holders(admin: _AdminClient, probe_role: str) -> list[str]:
206
+ """Every user who holds `probe_role`. Nothing in this codebase grants it, so this should always be empty."""
207
+ rows = admin.list("User", fields=["name"], filters=[["Has Role", "role", "=", probe_role]], limit=50)
208
+ return sorted(str(row["name"]) for row in rows)
209
+
210
+
211
+ def _refuse_an_unprovable_probe(admin: _AdminClient, original: dict[str, Any], probe_role: str) -> None:
212
+ """Refuse, before the bot writes anything, a probe whose result could not mean what it says.
213
+
214
+ A probe value equal to what is stored reads back unchanged whether or
215
+ not the bot could write it, so it would report a fence that was never
216
+ tested. And while a failing fence lets the probe role sit in
217
+ `role_allowed_for_frozen_entries`, anyone holding that role could post
218
+ into a closed quarter -- so the role being held by nobody is checked
219
+ here, not assumed.
220
+ """
221
+ for field in LOCK_FIELDS:
222
+ current = original.get(field)
223
+ if _probe_value(field, current, probe_role) == current:
224
+ raise PeriodError(
225
+ f"{field} is already {current!r}, the fence probe's own value, so the probe could not tell a "
226
+ "refused write from one that landed; clear it in ERPNext and run provision again"
227
+ )
228
+ holders = probe_role_holders(admin, probe_role)
229
+ if holders:
230
+ raise PeriodError(
231
+ f"{probe_role!r} is held by {', '.join(holders)}; it must be held by nobody, because the fence probe "
232
+ "writes it into role_allowed_for_frozen_entries and a holder could post into a closed quarter while "
233
+ "it sits there. Remove the role from those users and run provision again"
234
+ )
235
+
236
+
237
+ def check_fence_probe(admin: _AdminClient, company: str, probe_role: str) -> None:
238
+ """Refuse now, as the administrator, a fence probe that could not prove anything -- before any key is minted.
239
+
240
+ `access provision` rotates the bot's key before it can run `prove_fence`,
241
+ so a refusal found only inside the proof would leave the stored key
242
+ already dead. Running the same checks here first keeps that refusal an
243
+ ordinary one; `prove_fence` still repeats them immediately before it
244
+ writes, since the site can change between the two.
245
+ """
246
+ _refuse_an_unprovable_probe(admin, admin.get(COMPANY, company), probe_role)
247
+
248
+
249
+ def prove_fence(admin: _AdminClient, bot: _BotClient, company: str, probe_role: str) -> FenceProof:
250
+ """Prove the bot's key cannot move either lock field, restoring (and verifying) anything that did move.
251
+
252
+ Reads the Company record as `admin` first, refuses a probe that could
253
+ not prove anything (`_refuse_an_unprovable_probe`), then attempts one
254
+ write per field with `bot`'s key. A field whose stored value did not
255
+ change is refused; the bot's update raising `PermissionError_` counts as
256
+ refused too. Anything that did change is restored by `admin` to its
257
+ original value, the restore is verified, and only then is the field
258
+ named in `restored`.
259
+ """
260
+ original = admin.get(COMPANY, company)
261
+ _refuse_an_unprovable_probe(admin, original, probe_role)
262
+ date_refused, date_changed, date_how = _probe_field(
263
+ admin, bot, company, LOCK_FIELDS[0], original.get(LOCK_FIELDS[0]), probe_role
264
+ )
265
+ role_refused, role_changed, role_how = _probe_field(
266
+ admin, bot, company, LOCK_FIELDS[1], original.get(LOCK_FIELDS[1]), probe_role
267
+ )
268
+ restored = tuple(
269
+ field for field, changed in ((LOCK_FIELDS[0], date_changed), (LOCK_FIELDS[1], role_changed)) if changed
270
+ )
271
+ return FenceProof(
272
+ date_refused=date_refused,
273
+ role_refused=role_refused,
274
+ date_how=date_how,
275
+ role_how=role_how,
276
+ restored=restored,
277
+ )
@@ -0,0 +1,205 @@
1
+ """One read of the ledger, parsed exactly.
2
+
3
+ Every submitted, non-cancelled GL Entry for the company through the quarter's
4
+ end. Paged with `iter_all`, never a capped `list`: a short read would build a
5
+ package that balances and is wrong.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
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.apply.model import DesiredAccount
17
+ from icedcoffee_sync.erpnext.accounts import document_name
18
+
19
+ from runningtab.period import PeriodError
20
+ from runningtab.period.quarter import FiscalYear
21
+
22
+ GL_FIELDS = [
23
+ "name",
24
+ "posting_date",
25
+ "account",
26
+ "party_type",
27
+ "party",
28
+ "debit",
29
+ "credit",
30
+ "voucher_type",
31
+ "voucher_no",
32
+ "against_voucher_type",
33
+ "against_voucher",
34
+ "due_date",
35
+ "remarks",
36
+ "is_opening",
37
+ ]
38
+
39
+ AccountIndex = dict[str, DesiredAccount]
40
+
41
+
42
+ class _Reader(Protocol):
43
+ def iter_all(self, doctype: str, **kwargs: Any) -> Any: ...
44
+ def get(self, doctype: str, name: str) -> Any: ...
45
+
46
+
47
+ @dataclasses.dataclass(frozen=True)
48
+ class GLRow:
49
+ """One GL Entry, with money as exact decimals."""
50
+
51
+ name: str
52
+ posting_date: dt.date
53
+ account: str
54
+ party_type: str | None
55
+ party: str | None
56
+ debit: Decimal
57
+ credit: Decimal
58
+ voucher_type: str
59
+ voucher_no: str
60
+ against_voucher_type: str | None
61
+ against_voucher: str | None
62
+ due_date: dt.date | None
63
+ remarks: str
64
+ is_opening: bool
65
+
66
+ @property
67
+ def net(self) -> Decimal:
68
+ return self.debit - self.credit
69
+
70
+
71
+ def _money(value: object) -> Decimal:
72
+ # The client parses JSON with the standard library, so amounts arrive as
73
+ # floats; `str()` first so the binary tail never reaches the arithmetic.
74
+ if value in (None, ""):
75
+ return Decimal(0)
76
+ return Decimal(str(value))
77
+
78
+
79
+ def _text(value: object) -> str | None:
80
+ return str(value) if value not in (None, "") else None
81
+
82
+
83
+ def _date(value: object) -> dt.date | None:
84
+ return dt.date.fromisoformat(str(value)[:10]) if value not in (None, "") else None
85
+
86
+
87
+ def parse_gl_row(row: dict[str, Any]) -> GLRow:
88
+ posting = _date(row["posting_date"])
89
+ if posting is None:
90
+ raise PeriodError(f"GL Entry {row.get('name')} has no posting date")
91
+ return GLRow(
92
+ name=str(row["name"]),
93
+ posting_date=posting,
94
+ account=str(row["account"]),
95
+ party_type=_text(row.get("party_type")),
96
+ party=_text(row.get("party")),
97
+ debit=_money(row.get("debit")),
98
+ credit=_money(row.get("credit")),
99
+ voucher_type=str(row.get("voucher_type") or ""),
100
+ voucher_no=str(row.get("voucher_no") or ""),
101
+ against_voucher_type=_text(row.get("against_voucher_type")),
102
+ against_voucher=_text(row.get("against_voucher")),
103
+ due_date=_date(row.get("due_date")),
104
+ remarks=str(row.get("remarks") or ""),
105
+ is_opening=str(row.get("is_opening") or "No") == "Yes",
106
+ )
107
+
108
+
109
+ def _order(row: GLRow) -> tuple[dt.date, str, str, str]:
110
+ return (row.posting_date, row.voucher_type, row.voucher_no, row.name)
111
+
112
+
113
+ def _refuse_malformed_rows(rows: Sequence[GLRow]) -> None:
114
+ """Refuse what offset paging over a non-unique sort key could still hand back.
115
+
116
+ `read_gl` pages on `posting_date asc, name asc`, a unique order, but a
117
+ reader depending on that guarantee should not trust it silently: refuse a
118
+ duplicate GL Entry `name` (paging that returned the same row twice, or
119
+ skipped one and duplicated another across a page boundary) and any row
120
+ with no `voucher_type` or `voucher_no`, which nothing downstream can
121
+ group into a voucher.
122
+ """
123
+ seen: dict[str, int] = {}
124
+ for row in rows:
125
+ seen[row.name] = seen.get(row.name, 0) + 1
126
+ duplicates = sorted(name for name, count in seen.items() if count > 1)
127
+ if duplicates:
128
+ raise PeriodError(f"the GL Entry read returned duplicate rows: {', '.join(duplicates)}")
129
+ unvouchered = sorted(row.name for row in rows if not row.voucher_type or not row.voucher_no)
130
+ if unvouchered:
131
+ raise PeriodError(
132
+ f"GL Entries with no voucher_type or voucher_no cannot be grouped into a voucher: {', '.join(unvouchered)}"
133
+ )
134
+
135
+
136
+ def read_gl(client: _Reader, company: str, through: dt.date) -> list[GLRow]:
137
+ """Every live GL Entry for `company` dated on or before `through`, in package order.
138
+
139
+ Pages on `order_by="posting_date asc, name asc"`: `name` breaks ties on a
140
+ date, since offset paging over `posting_date asc` alone is a non-unique
141
+ sort key that can silently duplicate or skip a row across a page
142
+ boundary. `_refuse_malformed_rows` is the backstop for what a bad page
143
+ could still let through.
144
+ """
145
+ rows = client.iter_all(
146
+ "GL Entry",
147
+ fields=GL_FIELDS,
148
+ filters=[["company", "=", company], ["is_cancelled", "=", 0], ["posting_date", "<=", through.isoformat()]],
149
+ order_by="posting_date asc, name asc",
150
+ )
151
+ parsed = sorted((parse_gl_row(row) for row in rows), key=_order)
152
+ _refuse_malformed_rows(parsed)
153
+ return parsed
154
+
155
+
156
+ def parse_fiscal_year(row: dict[str, Any]) -> FiscalYear:
157
+ return FiscalYear(
158
+ name=str(row["name"]),
159
+ start=dt.date.fromisoformat(str(row["year_start_date"])[:10]),
160
+ end=dt.date.fromisoformat(str(row["year_end_date"])[:10]),
161
+ )
162
+
163
+
164
+ def read_fiscal_years(client: _Reader, company: str) -> list[FiscalYear]:
165
+ """Every enabled Fiscal Year that applies to `company`.
166
+
167
+ Fiscal Year's `companies` child table (doctype `Fiscal Year Company`,
168
+ field `company`) restricts which companies a year applies to -- an
169
+ empty table means every company, so a year with rows naming other
170
+ companies but not this one is excluded, and a year with no rows at all
171
+ is kept. `iter_all`'s listing does not expand child tables, so each
172
+ year is read individually with `client.get`; there are few Fiscal
173
+ Years, so this stays cheap.
174
+ """
175
+ rows = client.iter_all(
176
+ "Fiscal Year", fields=["name", "year_start_date", "year_end_date"], filters=[["disabled", "=", 0]]
177
+ )
178
+ years = []
179
+ for row in rows:
180
+ doc = client.get("Fiscal Year", str(row["name"]))
181
+ companies = [str(c["company"]) for c in (doc.get("companies") or [])]
182
+ if companies and company not in companies:
183
+ continue
184
+ years.append(parse_fiscal_year(row))
185
+ return years
186
+
187
+
188
+ def index_accounts(chart: Sequence[DesiredAccount], abbr: str) -> AccountIndex:
189
+ """Chart accounts by the document name ERPNext gives them on this company."""
190
+ return {document_name(a.name, abbr, a.number): a for a in chart}
191
+
192
+
193
+ def require_known_accounts(rows: Sequence[GLRow], index: AccountIndex) -> None:
194
+ """Refuse when any GL Entry sits on an account `chart.coa` does not describe.
195
+
196
+ Such rows would otherwise have nowhere to go in the package, and dropping
197
+ them would hand over books that balance and are wrong.
198
+ """
199
+ unknown = sorted({r.account for r in rows if r.account not in index})
200
+ if unknown:
201
+ raise PeriodError(
202
+ "GL Entries sit on accounts chart.coa does not describe: "
203
+ + "; ".join(unknown)
204
+ + ". Add them to the chart (and apply it) or move the entries."
205
+ )