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,294 @@
1
+ """Books Closed Through: read it, read who moved it and when, and move it forward only.
2
+
3
+ Frappe records every field change to a document as a `Version`
4
+ (`frappe/core/doctype/version/version.py`): its `data` field is a JSON string
5
+ whose `"changed"` key lists `[fieldname, old, new]` triples for every field
6
+ that save touched. `lock_history` filters that down to `accounts_frozen_till_date`
7
+ alone -- the field the fence in `runningtab.period.fence` protects -- and ignores
8
+ every other change a Company save might carry, `role_allowed_for_frozen_entries`
9
+ included.
10
+
11
+ `get_diff` (same file) writes a changed Date field's old/new through
12
+ `get_formatted()` -> `formatdate()` (`frappe/utils/data.py`) before it ever
13
+ reaches `data` -- i.e. in the *site's* `date_format` system setting, not ISO.
14
+ A live site can be, and by default is, set to `yyyy-mm-dd`, but nothing
15
+ guarantees it stays that way, so `_parse_version_date` tries ISO first and
16
+ falls back to every other format System Settings' `date_format` select
17
+ offers (`frappe/core/doctype/system_settings/system_settings.json`), reading
18
+ the site's actual setting only if two of those formats disagree on the same
19
+ text. `Company` itself is not affected: an ordinary document read returns
20
+ the raw stored field value, never a formatted one, so `read_lock` still
21
+ parses it as plain ISO.
22
+
23
+ The Version doctype ships permission to `System Manager` and `Administrator`
24
+ only (`frappe/core/doctype/version/version.json`); neither is a role the books
25
+ reader inherits, so `runningtab.access.READER_GRANTS` carries an explicit read
26
+ grant on `Version` to let `status`/`health` show who reopened a quarter.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import dataclasses
32
+ import datetime as dt
33
+ import json
34
+ from typing import Any, Protocol
35
+
36
+ from runningtab.period import PeriodError
37
+ from runningtab.period.fence import LOCK_FIELDS
38
+ from runningtab.site import Profile
39
+
40
+ COMPANY = "Company"
41
+ _SYSTEM_SETTINGS = "System Settings"
42
+
43
+ # System Settings' own `date_format` options (system_settings.json), less
44
+ # `yyyy-mm-dd` -- that one is ISO, and `_iso_date` already covers it.
45
+ _OTHER_DATE_FORMATS: dict[str, str] = {
46
+ "dd-mm-yyyy": "%d-%m-%Y",
47
+ "mm-dd-yyyy": "%m-%d-%Y",
48
+ "dd/mm/yyyy": "%d/%m/%Y",
49
+ "mm/dd/yyyy": "%m/%d/%Y",
50
+ "dd.mm.yyyy": "%d.%m.%Y",
51
+ }
52
+
53
+
54
+ class _Reader(Protocol):
55
+ """What `read_lock` and `lock_history` need: read Company, read its Version history."""
56
+
57
+ def get(self, doctype: str, name: str) -> Any: ...
58
+ def iter_all(self, doctype: str, **kwargs: Any) -> Any: ...
59
+
60
+
61
+ class _Admin(Protocol):
62
+ """What `set_closed_through` needs: the reader's slice, plus writing Company."""
63
+
64
+ def get(self, doctype: str, name: str) -> Any: ...
65
+ def update(self, doctype: str, name: str, doc: dict[str, Any]) -> Any: ...
66
+ def iter_all(self, doctype: str, **kwargs: Any) -> Any: ...
67
+
68
+
69
+ @dataclasses.dataclass(frozen=True)
70
+ class LockState:
71
+ """Company's close lock, as it stands right now."""
72
+
73
+ closed_through: dt.date | None
74
+ bypass_role: str | None
75
+
76
+
77
+ @dataclasses.dataclass(frozen=True)
78
+ class LockChange:
79
+ """One Version's edit to `accounts_frozen_till_date`, decoded.
80
+
81
+ `old`/`new` are the decoded dates; `old_text`/`new_text` are exactly what
82
+ Frappe recorded, unparsed -- so a person can always see the raw text
83
+ behind a decode, whether or not it parsed cleanly.
84
+ """
85
+
86
+ version: str
87
+ at: dt.datetime
88
+ by: str
89
+ old: dt.date | None
90
+ new: dt.date | None
91
+ old_text: str
92
+ new_text: str
93
+
94
+
95
+ def _parse_date(value: Any) -> dt.date | None:
96
+ """A Company field value as Frappe's JSON client hands it back: a date string, empty string, or null.
97
+
98
+ An ordinary document read is never passed through `get_formatted()` --
99
+ that only happens inside `Version.get_diff` -- so this is plain ISO,
100
+ unlike `_parse_version_date` below.
101
+ """
102
+ if not value:
103
+ return None
104
+ return dt.date.fromisoformat(str(value))
105
+
106
+
107
+ def read_lock(client: _Reader, company: str) -> LockState:
108
+ """Company's current `accounts_frozen_till_date` and `role_allowed_for_frozen_entries`."""
109
+ doc = client.get(COMPANY, company)
110
+ return LockState(closed_through=_parse_date(doc.get(LOCK_FIELDS[0])), bypass_role=doc.get(LOCK_FIELDS[1]) or None)
111
+
112
+
113
+ _LOCK_FIELD_LABELS: dict[str, str] = {
114
+ LOCK_FIELDS[0]: "Books Closed Through",
115
+ LOCK_FIELDS[1]: "the frozen-entries role",
116
+ }
117
+
118
+
119
+ def read_visible_lock(client: _Reader, company: str, profile: Profile) -> LockState:
120
+ """`read_lock`, but refuses by name instead of reporting a false "unset" when this credential cannot see a
121
+ lock field at all.
122
+
123
+ Both `LOCK_FIELDS` sit at Company permlevel 1 (`runningtab.period.fence.install_fence`). Frappe's
124
+ `apply_fieldlevel_read_permissions` *deletes* a permlevel-1 field from a document response the
125
+ credential cannot read rather than returning it as null, so a credential never (re-)provisioned since
126
+ its grant was added -- or one whose `access provision` installed the fence and then failed -- gets a
127
+ doc with no `accounts_frozen_till_date`/`role_allowed_for_frozen_entries` key at all, indistinguishable,
128
+ to plain `dict.get`, from Company legitimately having neither set yet. `missing_grants_report` cannot
129
+ catch this either: it explicitly skips every grant above permlevel 0 (`runningtab.access.missing_grants_report`'s
130
+ own docstring), since `frappe.client.has_permission` has no permlevel of its own to ask about. Presence of
131
+ the key is the only thing that tells the two cases apart.
132
+
133
+ `profile` is the identity the caller reads as -- the reader for `period status`/`health`, the bot for the
134
+ Mercury ingest -- and names whose re-provision fixes it.
135
+ """
136
+ doc = client.get(COMPANY, company)
137
+ missing = [field for field in LOCK_FIELDS if field not in doc]
138
+ if missing:
139
+ labels = " and ".join(_LOCK_FIELD_LABELS[field] for field in missing)
140
+ raise PeriodError(
141
+ f"the {profile.value} credential cannot see {labels} on {company} (Company, permlevel 1); run "
142
+ f"`runningtab access provision --profile {profile.value}` with RUNNINGTAB_ADMIN_TOKEN set"
143
+ )
144
+ return LockState(closed_through=_parse_date(doc.get(LOCK_FIELDS[0])), bypass_role=doc.get(LOCK_FIELDS[1]) or None)
145
+
146
+
147
+ def _raw_text(value: Any) -> str:
148
+ return str(value) if value else ""
149
+
150
+
151
+ def _iso_date(text: str) -> dt.date | None:
152
+ try:
153
+ return dt.date.fromisoformat(text)
154
+ except ValueError:
155
+ return None
156
+
157
+
158
+ def _formatted_dates(text: str) -> dict[str, dt.date]:
159
+ """{format name: parsed date} for every one of Frappe's other date_format options that matches `text`."""
160
+ matches: dict[str, dt.date] = {}
161
+ for name, pattern in _OTHER_DATE_FORMATS.items():
162
+ try:
163
+ matches[name] = dt.datetime.strptime(text, pattern).date()
164
+ except ValueError:
165
+ continue
166
+ return matches
167
+
168
+
169
+ def _site_date_format(client: _Reader) -> str | None:
170
+ """The site's configured `date_format`, or None if it cannot be read.
171
+
172
+ Best-effort only, and only consulted to disambiguate a Version's
173
+ formatted text when more than one of Frappe's date formats parses it to
174
+ a different date. A reader credential may hold no grant on System
175
+ Settings at all; that failure (or any other) just means the format
176
+ could not be determined, not that reading history fails outright.
177
+ """
178
+ try:
179
+ settings = client.get(_SYSTEM_SETTINGS, _SYSTEM_SETTINGS)
180
+ except Exception:
181
+ return None
182
+ value = settings.get("date_format") if isinstance(settings, dict) else None
183
+ return str(value) if value else None
184
+
185
+
186
+ def _parse_version_date(client: _Reader, version: str, label: str, text: str) -> dt.date | None:
187
+ """One raw old/new value from a Version's `data`, parsed defensively.
188
+
189
+ ISO is tried first -- the common case, and a live site's default. When
190
+ that fails, every other format System Settings' `date_format` offers is
191
+ tried; if exactly one produces a date, or several agree, that date wins.
192
+ If more than one disagrees, the site's actual `date_format` (read fresh,
193
+ not cached) breaks the tie; if that cannot be read either, or the text
194
+ matches nothing at all, this refuses by name rather than guess.
195
+ """
196
+ if not text:
197
+ return None
198
+ iso = _iso_date(text)
199
+ if iso is not None:
200
+ return iso
201
+ matches = _formatted_dates(text)
202
+ distinct = set(matches.values())
203
+ if len(distinct) == 1:
204
+ return distinct.pop()
205
+ if not distinct:
206
+ raise PeriodError(f"Version {version}: {label} {text!r} could not be parsed as a date")
207
+ site_format = _site_date_format(client)
208
+ if site_format in matches:
209
+ return matches[site_format]
210
+ raise PeriodError(
211
+ f"Version {version}: {label} {text!r} is ambiguous ({', '.join(sorted(matches))}) "
212
+ "and the site's date_format could not be determined"
213
+ )
214
+
215
+
216
+ def _date_change(row: dict[str, Any]) -> tuple[Any, Any] | None:
217
+ """The `[old, new]` pair for the lock date field in one Version row's `data`, or None if it was not touched."""
218
+ data = json.loads(row["data"])
219
+ for fieldname, old, new in data.get("changed", []):
220
+ if fieldname == LOCK_FIELDS[0]:
221
+ return old, new
222
+ return None
223
+
224
+
225
+ def lock_history(client: _Reader, company: str) -> list[LockChange]:
226
+ """Every Version of `company` that touched `accounts_frozen_till_date`, newest first.
227
+
228
+ Ordering is the server's: requested via `order_by`, not re-sorted here.
229
+ """
230
+ rows = client.iter_all(
231
+ "Version",
232
+ fields=["name", "owner", "creation", "data"],
233
+ filters=[["ref_doctype", "=", COMPANY], ["docname", "=", company]],
234
+ order_by="creation desc, name desc",
235
+ )
236
+ history: list[LockChange] = []
237
+ for row in rows:
238
+ change = _date_change(row)
239
+ if change is None:
240
+ continue
241
+ old_raw, new_raw = change
242
+ version = str(row["name"])
243
+ old_text, new_text = _raw_text(old_raw), _raw_text(new_raw)
244
+ history.append(
245
+ LockChange(
246
+ version=version,
247
+ at=dt.datetime.fromisoformat(str(row["creation"])),
248
+ by=str(row["owner"]),
249
+ old=_parse_version_date(client, version, "old value", old_text),
250
+ new=_parse_version_date(client, version, "new value", new_text),
251
+ old_text=old_text,
252
+ new_text=new_text,
253
+ )
254
+ )
255
+ return history
256
+
257
+
258
+ def set_closed_through(admin: _Admin, company: str, date: dt.date) -> LockChange:
259
+ """Move Books Closed Through forward to `date`, refusing to move it backwards.
260
+
261
+ Reads the current value first, so a backwards request is refused before
262
+ anything is written. Confirmation never depends on parsing a Version's
263
+ formatted text back to a date -- that text is in the site's
264
+ `date_format`, not necessarily ISO, and comparing it to `date` is exactly
265
+ the bug this guards against. Instead: `read_lock` afterwards must show
266
+ `date` in Company's own (always-ISO) field, and a Version that did not
267
+ exist before this call must now touch `accounts_frozen_till_date` --
268
+ that Version, identified by name, is the one returned. Either check
269
+ failing refuses rather than report a change that cannot be shown to have
270
+ happened.
271
+ """
272
+ current = read_lock(admin, company)
273
+ if current.closed_through is not None and date < current.closed_through:
274
+ raise PeriodError(
275
+ f"Books Closed Through cannot move backwards: {company} is closed through "
276
+ f"{current.closed_through.isoformat()}, refusing {date.isoformat()}"
277
+ )
278
+ before = {change.version for change in lock_history(admin, company)}
279
+ admin.update(COMPANY, company, {LOCK_FIELDS[0]: date.isoformat()})
280
+
281
+ after = read_lock(admin, company)
282
+ if after.closed_through != date:
283
+ shown = after.closed_through.isoformat() if after.closed_through else "unset"
284
+ raise PeriodError(
285
+ f"{company}'s Books Closed Through was set to {date.isoformat()} but reading it back shows "
286
+ f"{shown} -- refusing to report success"
287
+ )
288
+
289
+ for change in lock_history(admin, company):
290
+ if change.version not in before:
291
+ return change
292
+ raise PeriodError(
293
+ f"{company}'s Books Closed Through was updated to {date.isoformat()} but left no Version audit trail"
294
+ )
@@ -0,0 +1,259 @@
1
+ """`MANIFEST.md`: the one file in the package written for a person, not a program.
2
+
3
+ Nothing parses it (`SHA256SUMS` and `period status` carry the machine-readable
4
+ facts). The reader is an outside accountant or auditor who has never touched
5
+ these books, so every section says what it means in plain sentences rather than
6
+ pointing back at our own vocabulary.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import dataclasses
12
+ import datetime as dt
13
+ from collections.abc import Sequence
14
+
15
+ from runningtab.period import PeriodError
16
+ from runningtab.period.bank import BankLine
17
+ from runningtab.period.csvfmt import money
18
+ from runningtab.period.qbo_map import QboMap
19
+ from runningtab.period.quarter import FiscalYear, Quarter
20
+
21
+ # One sentence per file, keyed by its exact path, or by a prefix for a family
22
+ # of files (`qbo/journal-entries-NN.csv`, `documents/...`). Keeping this a
23
+ # plain dict rather than a rule on the path means a new file with no entry
24
+ # here raises `KeyError` in `_describe` instead of shipping undescribed.
25
+ FILE_DESCRIPTIONS: dict[str, str] = {
26
+ "chart-of-accounts.csv": (
27
+ "Every account in the chart, with its number, name, parent, and type, as of this export."
28
+ ),
29
+ "trial-balance.csv": "Each account's opening balance, this quarter's debits and credits, and its closing balance.",
30
+ "general-ledger.csv": "Every transaction posted in the quarter, one line per debit or credit.",
31
+ "profit-and-loss.csv": "Income and expenses for the quarter and for the year to date.",
32
+ "balance-sheet.csv": "Assets, liabilities, and equity as of the quarter's end.",
33
+ "bank-reconciliation.csv": (
34
+ "Each bank account's balance next to its matching chart account's balance, and the difference between them."
35
+ ),
36
+ "open-receivables.csv": "What each customer owed as of the quarter's end.",
37
+ "open-payables.csv": "What we owed each vendor as of the quarter's end.",
38
+ "qbo/accounts.csv": (
39
+ "The chart accounts to create in QuickBooks Online, each with the Type and Detail Type QuickBooks requires."
40
+ ),
41
+ "qbo/customers.csv": "Every customer QuickBooks needs before the opening balances and journal entries import.",
42
+ "qbo/vendors.csv": "Every vendor QuickBooks needs before the opening balances and journal entries import.",
43
+ "qbo/opening-balances.csv": (
44
+ "Every account's balance the day before the quarter began, written as journal entries. "
45
+ "Import this file once only."
46
+ ),
47
+ "qbo/journal-entries-": (
48
+ "One QuickBooks journal entry per transaction posted in the quarter, split into files of at most "
49
+ "1,000 rows apiece."
50
+ ),
51
+ "documents/": "A receipt, invoice, or other file attached to a transaction or bank record dated in the quarter.",
52
+ }
53
+
54
+
55
+ # The final manifest's line naming the Company Version that locked the
56
+ # quarter. `lock_version_of` reads it back; one constant keeps the two from
57
+ # drifting apart.
58
+ LOCK_RECORD_PREFIX = "- **Lock record:** Company Version `"
59
+
60
+
61
+ def lock_version_of(manifest: str) -> str | None:
62
+ """The Company Version a final manifest says locked its quarter, or None when it records none (a draft)."""
63
+ for line in manifest.splitlines():
64
+ if line.startswith(LOCK_RECORD_PREFIX) and line.endswith("`"):
65
+ return line[len(LOCK_RECORD_PREFIX) : -1]
66
+ return None
67
+
68
+
69
+ def _describe(path: str) -> str:
70
+ if path in FILE_DESCRIPTIONS:
71
+ return FILE_DESCRIPTIONS[path]
72
+ for prefix in ("qbo/journal-entries-", "documents/"):
73
+ if path.startswith(prefix):
74
+ return FILE_DESCRIPTIONS[prefix]
75
+ raise KeyError(path)
76
+
77
+
78
+ @dataclasses.dataclass(frozen=True)
79
+ class ManifestFacts:
80
+ quarter: Quarter
81
+ fiscal_year: FiscalYear
82
+ company: str
83
+ # `books.toml`'s name: the title says whose books these are.
84
+ books_name: str
85
+ site_url: str
86
+ chart_sha256: str
87
+ qmap: QboMap
88
+ generated_at: dt.datetime
89
+ generated_by: str
90
+ draft: bool
91
+ today: dt.date
92
+ bank: tuple[BankLine, ...]
93
+ split_vouchers: tuple[str, ...]
94
+ has_journals: bool
95
+ closed_by: str | None = None
96
+ lock_version: str | None = None
97
+ revision: int = 1
98
+
99
+
100
+ def _title(facts: ManifestFacts) -> str:
101
+ suffix = " — DRAFT" if facts.draft else ""
102
+ return f"# {facts.books_name} books — {facts.quarter.label}{suffix}"
103
+
104
+
105
+ def _facts_section(facts: ManifestFacts) -> str:
106
+ lines = [
107
+ f"- **Quarter:** {facts.quarter.label}, {facts.quarter.start.isoformat()} through "
108
+ f"{facts.quarter.end.isoformat()}",
109
+ f"- **Fiscal year:** {facts.fiscal_year.name}, {facts.fiscal_year.start.isoformat()} through "
110
+ f"{facts.fiscal_year.end.isoformat()}",
111
+ f"- **Company:** {facts.company}",
112
+ f"- **Site:** {facts.site_url}",
113
+ f"- **Chart of accounts:** `chart.coa`, SHA-256 `{facts.chart_sha256}`",
114
+ f"- **Generated:** {facts.generated_at.astimezone(dt.UTC).strftime('%Y-%m-%d %H:%M UTC')} "
115
+ f"by {facts.generated_by}",
116
+ ]
117
+ if not facts.draft:
118
+ lines.append(f"- **Closed by:** {facts.closed_by}")
119
+ lines.append(f"{LOCK_RECORD_PREFIX}{facts.lock_version}`")
120
+ lines.append(f"- **Revision:** {facts.revision}")
121
+ if facts.today <= facts.quarter.end:
122
+ lines.append("- **Note:** this quarter not yet ended; these figures will change before the quarter closes.")
123
+ return "\n".join(lines)
124
+
125
+
126
+ def _files_section(files: dict[str, bytes]) -> str:
127
+ lines = [
128
+ "- **`MANIFEST.md`** — this document.",
129
+ "- **`SHA256SUMS`** — a checksum for every other file, to prove nothing in the package changed.",
130
+ ]
131
+ lines.extend(f"- **`{path}`** — {_describe(path)}" for path in sorted(files))
132
+ return "## Files\n\n" + "\n".join(lines)
133
+
134
+
135
+ _SIGN_CONVENTIONS = (
136
+ "## Sign conventions\n\n"
137
+ "The trial balance and the general ledger split each account's net balance into separate debit and credit "
138
+ "columns: a debit balance appears in the debit column, a credit balance in the credit column. The profit and "
139
+ "loss statement shows income and expenses both as positive numbers. The balance sheet shows assets, "
140
+ "liabilities, and equity each as a positive number. A text cell in these CSVs that begins with a formula "
141
+ "character (`=`, `+`, `@`, or `-` unless it is a plain negative number) is prefixed with an apostrophe so a "
142
+ "spreadsheet shows it as text instead of running it as a formula. In the `qbo/` files, description text is "
143
+ "escaped the same way; an account or party name that begins with a formula character is refused instead, "
144
+ "since QuickBooks matches those by exact text and an added apostrophe would break the match."
145
+ )
146
+
147
+
148
+ def _bank_section(bank: Sequence[BankLine]) -> str:
149
+ if not bank:
150
+ return "## Bank reconciliation\n\nNo Mercury account was reconciled this quarter."
151
+ if all(line.difference == 0 for line in bank):
152
+ summary = "All accounts reconcile: the difference is zero for every Mercury account."
153
+ else:
154
+ summary = "Not every account reconciles. The differences below that are not zero appear in **bold**."
155
+ rows = [
156
+ "| Account | Mercury account | Bank balance | Ledger balance | Difference |",
157
+ "| --- | --- | --- | --- | --- |",
158
+ ]
159
+ for line in bank:
160
+ diff = money(line.difference)
161
+ cell = f"**{diff}**" if line.difference != 0 else diff
162
+ rows.append(f"| {line.number} | {line.label} | {money(line.bank)} | {money(line.ledger)} | {cell} |")
163
+ return "## Bank reconciliation\n\n" + summary + "\n\n" + "\n".join(rows)
164
+
165
+
166
+ def _qbo_section(facts: ManifestFacts) -> str:
167
+ qmap = facts.qmap
168
+ builtin_lines = "\n".join(
169
+ f"- Our account `{number}` maps onto QuickBooks's built-in `{entry.name}`."
170
+ for number, entry in sorted(qmap.builtin.items(), key=lambda pair: int(pair[0]))
171
+ )
172
+ account_count = len(qmap.accounts)
173
+ if facts.has_journals:
174
+ journal_note = "Every `qbo/journal-entries-NN.csv` file listed above belongs in this step."
175
+ else:
176
+ journal_note = "There is nothing to import here: no journal entries this quarter."
177
+ if facts.split_vouchers:
178
+ split_note = (
179
+ "The following vouchers touched more than one customer or vendor and were split into more than one "
180
+ "journal entry, balanced through the `QBO Import Clearing` account so the split nets to zero: "
181
+ + ", ".join(facts.split_vouchers)
182
+ + "."
183
+ )
184
+ else:
185
+ split_note = "No voucher needed splitting this quarter."
186
+ return (
187
+ "## Importing into QuickBooks Online\n\n"
188
+ "Import the files in this order:\n\n"
189
+ "1. `qbo/accounts.csv`\n"
190
+ "2. `qbo/customers.csv` and `qbo/vendors.csv`\n"
191
+ "3. Turn account numbers off in QuickBooks. Both `qbo/opening-balances.csv` and "
192
+ "`qbo/journal-entries-NN.csv` use the same journal import, so numbers must stay off through both steps "
193
+ "below; turn them back on only once step 5 finishes, as Intuit's own instructions say to.\n"
194
+ "4. `qbo/opening-balances.csv` — **first import only: importing it twice doubles every balance.**\n"
195
+ "5. `qbo/journal-entries-NN.csv`\n\n"
196
+ f"{journal_note}\n\n"
197
+ f"{split_note}\n\n"
198
+ f"This kit does not create new QuickBooks accounts for {len(qmap.builtin)} of ours. It posts to the "
199
+ "accounts QuickBooks already provides on every company:\n\n"
200
+ f"{builtin_lines}\n\n"
201
+ "Several details of this format are unverified against a real QuickBooks import. Check each on the "
202
+ "first import:\n\n"
203
+ "- The journal date is written as `YYYY-MM-DD` (ISO 8601).\n"
204
+ "- The unused side of each journal line — the Debits or Credits cell that does not apply to that "
205
+ "line — is left blank rather than filled with a zero.\n"
206
+ "- A split voucher's Journal No. can run to 21 characters (for example `ACC-PINV-2026-00001/2`); "
207
+ "check that QuickBooks accepts a Journal No. that long.\n"
208
+ "- Whether a party that is both a customer and a vendor imports under one name or two.\n\n"
209
+ f"`qbo/accounts.csv` assigns each account a QuickBooks Type and Detail Type chosen account by account "
210
+ f"in `qbo.toml`; nobody has yet checked the {account_count} choices against a live QuickBooks company. "
211
+ "Check them on the first import, and correct `qbo.toml` if any are wrong."
212
+ )
213
+
214
+
215
+ _WHAT_THE_KIT_LEAVES_OUT = (
216
+ "## What the QBO kit leaves out\n\n"
217
+ "It carries every amount, account, date and party, and reproduces our trial balance. It does not carry: "
218
+ "the chart's hierarchy (group accounts and their nesting), ERPNext's voucher types (every voucher becomes "
219
+ "a QBO journal entry, not an invoice or bill), cost centres, the invoice-to-payment links, the split "
220
+ "vouchers' original single-entry shape, or attachments. Those stay in ERPNext and in the rest of the "
221
+ "package, which remain the record."
222
+ )
223
+
224
+
225
+ _VERIFYING_THIS_PACKAGE = (
226
+ "## Verifying this package\n\n"
227
+ "From inside this directory, run `sha256sum -c SHA256SUMS`. Every file it lists should print `OK`. "
228
+ "`SHA256SUMS` covers every file in the package except this one: `MANIFEST.md` records the moment it was "
229
+ "generated, so a checksum that covered it would never match between two otherwise identical builds. "
230
+ "After a close, `runningtab period status` rebuilds this quarter's package from the ledger and compares it "
231
+ "against this one; a bank transaction that settles later but is dated inside this quarter will show up "
232
+ "there as a change to `bank-reconciliation.csv`."
233
+ )
234
+
235
+
236
+ def render_manifest(facts: ManifestFacts, files: dict[str, bytes]) -> bytes:
237
+ """`MANIFEST.md`, as UTF-8 Markdown ending in a newline.
238
+
239
+ A final manifest (`draft=False`) with `closed_by` or `lock_version`
240
+ still `None` would print "Closed by: None" or a backtick-quoted `None`
241
+ as the lock's own Version -- a confident wrong answer about who closed
242
+ the quarter, printed into the one record an outside accountant is
243
+ handed. Refusing here means that can never reach a package.
244
+ """
245
+ if not facts.draft and (facts.closed_by is None or facts.lock_version is None):
246
+ raise PeriodError(
247
+ "a final manifest needs closed_by and lock_version; render_manifest was given draft=False without them"
248
+ )
249
+ sections = [
250
+ _title(facts),
251
+ _facts_section(facts),
252
+ _files_section(files),
253
+ _SIGN_CONVENTIONS,
254
+ _bank_section(facts.bank),
255
+ _qbo_section(facts),
256
+ _WHAT_THE_KIT_LEAVES_OUT,
257
+ _VERIFYING_THIS_PACKAGE,
258
+ ]
259
+ return ("\n\n".join(sections) + "\n").encode("utf-8")
@@ -0,0 +1,84 @@
1
+ """Open receivables and payables at a date, by party and by the document each entry settles.
2
+
3
+ A trial balance carries one balance per account; a person working from it --
4
+ or QBO, which demands a customer or vendor on every AR and AP line -- needs
5
+ that balance split by who owes it and for what.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+ import datetime as dt
12
+ from collections import defaultdict
13
+ from collections.abc import Sequence
14
+ from decimal import Decimal
15
+ from typing import Literal
16
+
17
+ from runningtab.period import PeriodError
18
+ from runningtab.period.balances import TrialBalance
19
+ from runningtab.period.csvfmt import money, render_csv
20
+ from runningtab.period.ledger import AccountIndex, GLRow
21
+
22
+ Kind = Literal["Receivable", "Payable"]
23
+
24
+
25
+ @dataclasses.dataclass(frozen=True)
26
+ class OpenItem:
27
+ party_type: str
28
+ party: str
29
+ document_type: str
30
+ document: str
31
+ document_date: dt.date
32
+ due_date: dt.date | None
33
+ amount: Decimal
34
+ advance: bool
35
+
36
+
37
+ def open_items(rows: Sequence[GLRow], index: AccountIndex, kind: Kind, as_of: dt.date) -> list[OpenItem]:
38
+ relevant = [r for r in rows if r.posting_date <= as_of and index[r.account].account_type == kind]
39
+ orphans = sorted(r.name for r in relevant if not r.party)
40
+ if orphans:
41
+ raise PeriodError(f"{kind} entries with no party cannot be placed: {', '.join(orphans)}")
42
+ groups: dict[tuple[str, str, str, str], list[GLRow]] = defaultdict(list)
43
+ for r in relevant:
44
+ doc_type = r.against_voucher_type or r.voucher_type
45
+ doc = r.against_voucher or r.voucher_no
46
+ groups[(r.party_type or "", r.party or "", doc_type, doc)].append(r)
47
+ sign = 1 if kind == "Receivable" else -1
48
+ items: list[OpenItem] = []
49
+ for (party_type, party, doc_type, doc), members in groups.items():
50
+ amount = sign * sum((m.net for m in members), Decimal(0))
51
+ if amount == 0:
52
+ continue
53
+ own = [m for m in members if m.voucher_no == doc] or members
54
+ first = min(own, key=lambda m: (m.posting_date, m.name))
55
+ due = next((m.due_date for m in own if m.due_date), None)
56
+ items.append(OpenItem(party_type, party, doc_type, doc, first.posting_date, due, amount, amount < 0))
57
+ return sorted(items, key=lambda i: (i.party, i.document_date, i.document))
58
+
59
+
60
+ def open_items_csv(items: Sequence[OpenItem]) -> bytes:
61
+ header = ["party_type", "party", "document_type", "document", "document_date", "due_date", "amount", "advance"]
62
+ rows = [
63
+ [
64
+ i.party_type,
65
+ i.party,
66
+ i.document_type,
67
+ i.document,
68
+ i.document_date.isoformat(),
69
+ i.due_date.isoformat() if i.due_date else "",
70
+ money(i.amount),
71
+ "yes" if i.advance else "",
72
+ ]
73
+ for i in items
74
+ ]
75
+ return render_csv(header, rows)
76
+
77
+
78
+ def check_open_items_against(items: Sequence[OpenItem], tb: TrialBalance, index: AccountIndex, kind: Kind) -> None:
79
+ """The open items must add up to the closing balance of every account of this kind."""
80
+ sign = 1 if kind == "Receivable" else -1
81
+ ledger = sign * sum((line.closing for line in tb.lines if line.account.account_type == kind), Decimal(0))
82
+ listed = sum((i.amount for i in items), Decimal(0))
83
+ if ledger != listed:
84
+ raise PeriodError(f"open {kind.lower()}s total {listed} but the {kind} accounts close at {ledger}")