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,140 @@
1
+ """The package: every content file from one set of inputs, plus how it is checked and written."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import hashlib
7
+ import os
8
+ import pathlib
9
+ import shutil
10
+ import tempfile
11
+
12
+ from icedcoffee_sync.apply.model import DesiredAccount
13
+
14
+ from runningtab.health import AccountLedger
15
+ from runningtab.mercury.mapping import Mapping
16
+ from runningtab.period import PeriodError
17
+ from runningtab.period.balances import (
18
+ balance_sheet_csv,
19
+ chart_of_accounts_csv,
20
+ general_ledger_csv,
21
+ profit_and_loss_csv,
22
+ trial_balance,
23
+ trial_balance_csv,
24
+ )
25
+ from runningtab.period.bank import BankLine, bank_reconciliation, bank_reconciliation_csv
26
+ from runningtab.period.ledger import GLRow, index_accounts
27
+ from runningtab.period.open_items import check_open_items_against, open_items, open_items_csv
28
+ from runningtab.period.qbo import build_qbo_kit
29
+ from runningtab.period.qbo_map import QboMap
30
+ from runningtab.period.quarter import FiscalYear, Quarter
31
+
32
+
33
+ @dataclasses.dataclass(frozen=True)
34
+ class PackageInputs:
35
+ quarter: Quarter
36
+ fiscal_year: FiscalYear
37
+ company: str
38
+ site_url: str
39
+ chart: tuple[DesiredAccount, ...]
40
+ abbr: str
41
+ rows: tuple[GLRow, ...]
42
+ ledgers: tuple[AccountLedger, ...]
43
+ mapping: Mapping
44
+ qmap: QboMap
45
+ documents: dict[str, bytes]
46
+
47
+
48
+ @dataclasses.dataclass(frozen=True)
49
+ class Package:
50
+ files: dict[str, bytes]
51
+ bank: tuple[BankLine, ...]
52
+ split_vouchers: tuple[str, ...]
53
+
54
+
55
+ def build_package(inputs: PackageInputs) -> Package:
56
+ """Every content file for the quarter, plus the bank reconciliation and split vouchers `MANIFEST.md` reports on.
57
+
58
+ A bank difference is reported, not refused -- a draft package is for
59
+ investigating one, and the CLI decides what a non-zero difference means
60
+ for the exit code.
61
+ """
62
+ index = index_accounts(inputs.chart, inputs.abbr)
63
+ tb = trial_balance(inputs.rows, index, inputs.quarter, inputs.fiscal_year)
64
+ receivables = open_items(inputs.rows, index, "Receivable", inputs.quarter.end)
65
+ payables = open_items(inputs.rows, index, "Payable", inputs.quarter.end)
66
+ check_open_items_against(receivables, tb, index, "Receivable")
67
+ check_open_items_against(payables, tb, index, "Payable")
68
+ bank = bank_reconciliation(inputs.ledgers, inputs.mapping, tb, inputs.quarter.end)
69
+ kit = build_qbo_kit(tb, inputs.rows, index, inputs.chart, inputs.qmap, inputs.quarter)
70
+ files = {
71
+ "chart-of-accounts.csv": chart_of_accounts_csv(inputs.chart),
72
+ "trial-balance.csv": trial_balance_csv(tb),
73
+ "general-ledger.csv": general_ledger_csv(inputs.rows, index, inputs.quarter),
74
+ "profit-and-loss.csv": profit_and_loss_csv(tb),
75
+ "balance-sheet.csv": balance_sheet_csv(tb),
76
+ "bank-reconciliation.csv": bank_reconciliation_csv(bank),
77
+ "open-receivables.csv": open_items_csv(receivables),
78
+ "open-payables.csv": open_items_csv(payables),
79
+ **{f"qbo/{name}": data for name, data in kit.files.items()},
80
+ **inputs.documents,
81
+ }
82
+ return Package(dict(sorted(files.items())), tuple(bank), kit.split_vouchers)
83
+
84
+
85
+ def sha256sums(files: dict[str, bytes]) -> bytes:
86
+ """The standard `sha256sum` format, sorted by path, over exactly the files given.
87
+
88
+ `MANIFEST.md` is never in `files`: it carries a generation timestamp, so
89
+ including it here would make every rebuild's checksums differ even when
90
+ the content is byte-identical. `write_package` relies on that.
91
+ """
92
+ return "".join(f"{hashlib.sha256(data).hexdigest()} {path}\n" for path, data in sorted(files.items())).encode()
93
+
94
+
95
+ def write_package(directory: pathlib.Path, package: Package, manifest: bytes) -> None:
96
+ """Write a new package directory. Never over an existing one: a person may be working in it.
97
+
98
+ `directory` is reserved with a plain `mkdir` before anything else --
99
+ that is what raises the "already exists" refusal, atomically. An
100
+ existence check followed later by a rename onto `directory` is two
101
+ separate operations with a window between them: something else (another
102
+ run, a person) could create an empty `directory` in that window, and the
103
+ rename at the end would then silently replace it instead of refusing.
104
+ Reserving the name first closes that window.
105
+
106
+ The package itself is still written into a sibling temporary directory,
107
+ then renamed onto the now-reserved `directory` as the last step: a
108
+ straight write that failed part-way (disk full, the process killed)
109
+ would leave a partial package sitting at `directory`, which a retry
110
+ refuses because it exists and a person could mistake for a real export.
111
+ The rename only succeeds onto an empty directory, so anything that has
112
+ since put a file into the reserved `directory` makes it fail loudly
113
+ instead of silently discarding that file. On any failure the temporary
114
+ directory is removed, and the reserved `directory` too, but only if it
115
+ is still the empty one this call created -- never one something else
116
+ has since written into. `tempfile.mkdtemp` itself runs inside that same
117
+ cleanup block: failing outside it would leave the reserved `directory`
118
+ behind with nothing to build into it, blocking every retry the same way
119
+ a straight write's partial package used to.
120
+ """
121
+ try:
122
+ directory.mkdir()
123
+ except FileExistsError:
124
+ raise PeriodError(f"{directory} already exists; move it aside or choose another --output") from None
125
+ staging: pathlib.Path | None = None
126
+ try:
127
+ staging = pathlib.Path(tempfile.mkdtemp(dir=directory.parent, prefix=f".{directory.name}.partial-"))
128
+ for path, data in package.files.items():
129
+ target = staging / path
130
+ target.parent.mkdir(parents=True, exist_ok=True)
131
+ target.write_bytes(data)
132
+ (staging / "MANIFEST.md").write_bytes(manifest)
133
+ (staging / "SHA256SUMS").write_bytes(sha256sums(package.files))
134
+ os.replace(staging, directory)
135
+ except Exception:
136
+ if staging is not None:
137
+ shutil.rmtree(staging, ignore_errors=True)
138
+ if directory.exists() and not any(directory.iterdir()):
139
+ directory.rmdir()
140
+ raise
@@ -0,0 +1,521 @@
1
+ """The whole QuickBooks Online import kit: accounts, parties, opening balances, journal entries.
2
+
3
+ One QBO journal entry per ERPNext voucher. QBO allows one A/R or A/P line per
4
+ transaction, so a voucher with more is split through `QBO Import Clearing`,
5
+ whose lines net to zero across the voucher. Files stay under QBO's 1,000-row
6
+ import cap and never divide an entry. `check_replay` proves the kit's opening
7
+ balances plus its journal entries reproduce our trial balance account by
8
+ account, so a kit that would silently misstate the books is refused before
9
+ it ships.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import dataclasses
15
+ import datetime as dt
16
+ from collections.abc import Iterable, Sequence
17
+ from decimal import Decimal
18
+
19
+ from icedcoffee_sync.apply.model import DesiredAccount
20
+
21
+ from runningtab.period import PeriodError
22
+ from runningtab.period.balances import TrialBalance
23
+ from runningtab.period.csvfmt import is_formula_like, money, render_csv, spreadsheet_safe_cell
24
+ from runningtab.period.ledger import AccountIndex, GLRow
25
+ from runningtab.period.open_items import open_items
26
+ from runningtab.period.qbo_map import CLEARING_ACCOUNT_NAME, QboMap, validate_qbo_map
27
+ from runningtab.period.quarter import Quarter
28
+
29
+ # Intuit's journal-import article confirms the CSV's columns but not the
30
+ # Journal Date format or the unused Debits/Credits cell's spelling -- neither
31
+ # is stated in its text, its UK/CA counterparts, or any community thread
32
+ # found, and both appear only inside the in-app "Download a sample file"
33
+ # link, unreachable without a signed-in QBO session (see qbo.toml's header).
34
+ # Controller's ruling (2026-09-22), UNVERIFIED pending a manual trial-company
35
+ # import: ISO `%Y-%m-%d`, because ISO cannot be misread, whereas MM/DD vs
36
+ # DD/MM would silently swap day and month; the empty side stays blank.
37
+ QBO_DATE_FORMAT = "%Y-%m-%d"
38
+ _AR_AP = ("Receivable", "Payable")
39
+
40
+
41
+ @dataclasses.dataclass(frozen=True)
42
+ class QboLine:
43
+ account: str
44
+ net: Decimal
45
+ description: str
46
+ party: str | None
47
+ is_ar_ap: bool
48
+
49
+
50
+ @dataclasses.dataclass(frozen=True)
51
+ class QboEntry:
52
+ journal_no: str
53
+ date: dt.date
54
+ lines: tuple[QboLine, ...]
55
+ source: str
56
+
57
+
58
+ def qbo_account_name(account: DesiredAccount, qmap: QboMap) -> str:
59
+ entry = qmap.builtin.get(account.number)
60
+ return entry.name if entry is not None else account.name
61
+
62
+
63
+ def _clearing(net: Decimal) -> QboLine:
64
+ return QboLine(CLEARING_ACCOUNT_NAME, net, "", None, False)
65
+
66
+
67
+ def split_entry(journal_no: str, date: dt.date, lines: Sequence[QboLine], source: str) -> list[QboEntry]:
68
+ ar = [line for line in lines if line.is_ar_ap]
69
+ rest = [line for line in lines if not line.is_ar_ap]
70
+ if len(ar) <= 1:
71
+ return [QboEntry(journal_no, date, (*ar, *rest), source)]
72
+ first_lines = [ar[0], *rest]
73
+ entries = [
74
+ QboEntry(
75
+ f"{journal_no}/1",
76
+ date,
77
+ (*first_lines, _clearing(-sum((line.net for line in first_lines), Decimal(0)))),
78
+ source,
79
+ )
80
+ ]
81
+ for n, line in enumerate(ar[1:], start=2):
82
+ entries.append(QboEntry(f"{journal_no}/{n}", date, (line, _clearing(-line.net)), source))
83
+ return entries
84
+
85
+
86
+ def journal_entries(rows: Sequence[GLRow], index: AccountIndex, qmap: QboMap, quarter: Quarter) -> list[QboEntry]:
87
+ """One `QboEntry` per voucher in `quarter`, split where QBO's one-AR/AP-line rule demands.
88
+
89
+ Vouchers come out in ledger read order (a voucher's position is where its
90
+ first row appears in `rows`); within a voucher, lines follow GL order,
91
+ with the AR/AP line moved first when a split has to isolate it; a split
92
+ voucher's pieces come out in `/1`, `/2`, ... order.
93
+
94
+ Every row of a voucher that has at least one row in `quarter` is grouped
95
+ and date-checked before anything else runs: such a voucher straddling
96
+ the quarter boundary (one row in the prior quarter, one row in this one)
97
+ must be refused by name, not silently admitted as an unbalanced entry
98
+ built from only its in-quarter rows. A voucher with no row in the
99
+ quarter at all is ignored here even if its own history spans more than
100
+ one date -- an old voucher that only feeds `opening_entries`'s running
101
+ balance is not a reason to block this quarter's export; that date
102
+ confusion is `opening_entries`'s to catch, if anything's.
103
+
104
+ Grouped by `(voucher_type, voucher_no)`, not `voucher_no` alone: two
105
+ vouchers of different types that happen to share a number (a Journal
106
+ Entry and a Purchase Invoice, say) must stay separate QBO entries rather
107
+ than having their GL rows merged into one. But the QBO CSV's Journal No.
108
+ column is `voucher_no` alone, with no room for the type, so two such
109
+ vouchers landing in the same quarter would collide there even though they
110
+ never merged here; refused by name rather than shipped as a kit QBO would
111
+ read back as one entry.
112
+ """
113
+ vouchers: dict[tuple[str, str], list[GLRow]] = {}
114
+ for row in rows:
115
+ vouchers.setdefault((row.voucher_type, row.voucher_no), []).append(row)
116
+ in_quarter = {
117
+ key: members
118
+ for key, members in vouchers.items()
119
+ if any(quarter.start <= m.posting_date <= quarter.end for m in members)
120
+ }
121
+ spanning = sorted(
122
+ voucher_no
123
+ for (_voucher_type, voucher_no), members in in_quarter.items()
124
+ if len({m.posting_date for m in members}) > 1
125
+ )
126
+ if spanning:
127
+ raise PeriodError(
128
+ f"vouchers with GL entries on more than one date cannot become one QBO entry: {', '.join(spanning)}"
129
+ )
130
+ entries: list[QboEntry] = []
131
+ types_by_number: dict[str, set[str]] = {}
132
+ for (voucher_type, voucher_no), members in in_quarter.items():
133
+ # `members` is single-date and in-quarter by construction: `spanning`
134
+ # above already refused any surviving voucher with more than one
135
+ # date, and `in_quarter` kept only vouchers with a row in range.
136
+ date = members[0].posting_date
137
+ types_by_number.setdefault(voucher_no, set()).add(voucher_type)
138
+ lines = []
139
+ for m in members:
140
+ account = index[m.account]
141
+ is_ar_ap = account.account_type in _AR_AP
142
+ lines.append(
143
+ QboLine(qbo_account_name(account, qmap), m.net, m.remarks, m.party if is_ar_ap else None, is_ar_ap)
144
+ )
145
+ entries.extend(split_entry(voucher_no, date, lines, voucher_no))
146
+ colliding = sorted(no for no, types in types_by_number.items() if len(types) > 1)
147
+ if colliding:
148
+ detail = "; ".join(f"{no} ({', '.join(sorted(types_by_number[no]))})" for no in colliding)
149
+ raise PeriodError(
150
+ "Journal No. is voucher_no alone, so vouchers of different types sharing a number would collide "
151
+ f"in the QBO import: {detail}"
152
+ )
153
+ unbalanced = sorted(e.journal_no for e in entries if sum((line.net for line in e.lines), Decimal(0)) != 0)
154
+ if unbalanced:
155
+ raise PeriodError(f"QBO entries do not balance to zero: {', '.join(unbalanced)}")
156
+ return entries
157
+
158
+
159
+ def check_opening_size(entries: Sequence[QboEntry], max_rows: int = 999, max_bytes: int = 2_000_000) -> None:
160
+ """Refuse, naming the line count or byte count, an opening kit too big for the one file it ships as.
161
+
162
+ Unlike the quarter's journal entries, `opening-balances.csv` is never
163
+ split across files (`build_qbo_kit` writes it as a single file, and
164
+ importing it twice doubles every balance, which splitting would make
165
+ easy to do by accident) -- so it must fit under both of QBO's import
166
+ caps on its own: 1,000 rows AND 2 MB (`max_bytes` is conservative).
167
+ Splitting is not needed today; this only refuses the kit that would
168
+ need it.
169
+ """
170
+ total_rows = sum(len(e.lines) for e in entries)
171
+ if total_rows > max_rows:
172
+ raise PeriodError(
173
+ f"the opening balances need {total_rows} rows, more than the {max_rows} a single QBO import file "
174
+ "allows; opening-balances.csv is not split across files"
175
+ )
176
+ total_bytes = len(journal_csv(entries))
177
+ if total_bytes > max_bytes:
178
+ raise PeriodError(
179
+ f"the opening balances render to {total_bytes:,} bytes, more than the {max_bytes:,} a single QBO "
180
+ "import file allows; opening-balances.csv is not split across files"
181
+ )
182
+
183
+
184
+ def chunk(entries: Sequence[QboEntry], max_rows: int = 999, max_bytes: int = 2_000_000) -> list[list[QboEntry]]:
185
+ """Group `entries` into files of at most `max_rows` rows and `max_bytes` rendered bytes, splitting only at entry boundaries.
186
+
187
+ Intuit's limit is 1,000 rows AND 2 MB per file, not rows alone; `max_bytes`
188
+ is a conservative reading of the 2 MB figure. An entry's byte cost is
189
+ measured by rendering it alone through `journal_csv` -- the same CSV
190
+ writer `journal_csv` uses for the file itself -- and subtracting the
191
+ header's own cost, so the estimate matches what actually lands in the
192
+ file rather than a hand-rolled guess at CSV overhead.
193
+ """
194
+ too_long = [e.journal_no for e in entries if len(e.lines) > max_rows]
195
+ if too_long:
196
+ raise PeriodError(f"QBO entries longer than one import file allows ({max_rows} rows): {', '.join(too_long)}")
197
+ header_bytes = len(journal_csv([]))
198
+ costs = [len(journal_csv([entry])) - header_bytes for entry in entries]
199
+ too_big = sorted({
200
+ entry.journal_no for entry, cost in zip(entries, costs, strict=True) if header_bytes + cost > max_bytes
201
+ })
202
+ if too_big:
203
+ raise PeriodError(
204
+ f"QBO entries whose rendered size alone, plus header, exceeds one import file's {max_bytes:,}-byte "
205
+ f"limit: {', '.join(too_big)}"
206
+ )
207
+ files: list[list[QboEntry]] = []
208
+ current: list[QboEntry] = []
209
+ rows = 0
210
+ size = header_bytes
211
+ for entry, cost in zip(entries, costs, strict=True):
212
+ if current and (rows + len(entry.lines) > max_rows or size + cost > max_bytes):
213
+ files.append(current)
214
+ current, rows, size = [], 0, header_bytes
215
+ current.append(entry)
216
+ rows += len(entry.lines)
217
+ size += cost
218
+ if current:
219
+ files.append(current)
220
+ return files
221
+
222
+
223
+ def _sides(net: Decimal) -> tuple[str, str]:
224
+ # The empty side's spelling is the controller's ruling above (blank, not
225
+ # "0"): UNVERIFIED against a real QBO import.
226
+ return (money(net), "") if net >= 0 else ("", money(-net))
227
+
228
+
229
+ def _refuse_formula_like_names(column: str, location: str, values: Iterable[str]) -> None:
230
+ """Refuse, naming each, a `column` value in `location` a spreadsheet would read as a formula.
231
+
232
+ QuickBooks matches Name and Account Name values by exact text, so the
233
+ leading-apostrophe escape the human-facing CSVs use would itself break
234
+ the match -- there is no way to make a formula-like value safe here,
235
+ only to refuse it before it ships.
236
+ """
237
+ bad = sorted({v for v in values if is_formula_like(v)})
238
+ if bad:
239
+ raise PeriodError(
240
+ f"{location} would write a {column} value a spreadsheet reads as a formula, which cannot be escaped "
241
+ f"without breaking QuickBooks's exact-text match: {', '.join(bad)}"
242
+ )
243
+
244
+
245
+ def journal_csv(entries: Sequence[QboEntry]) -> bytes:
246
+ """Intuit's journal-import columns, one CSV row per line.
247
+
248
+ Rows follow `entries` in the order given, and within each entry follow
249
+ `entry.lines` in the order given -- this function reorders nothing, so
250
+ `journal_entries`'s ordering (ledger read order, AR/AP line first, split
251
+ pieces in `/1`, `/2`, ... order) is what lands in the file.
252
+
253
+ Journal No., Account Name, and Name are keys QuickBooks matches by exact
254
+ text, so a formula-like value there is refused, naming each, rather than
255
+ escaped -- unlike the human-facing CSVs, an apostrophe here would itself
256
+ break the match. That includes an `OPENING-...` number and a split
257
+ piece's `/1`, `/2` suffix: both are written to Journal No. exactly like
258
+ a voucher number is. Journal/Description is free text, not a key, so it
259
+ is escaped the same way the human-facing CSVs are.
260
+ """
261
+ bad_journal_nos = sorted({e.journal_no for e in entries if is_formula_like(e.journal_no)})
262
+ bad_names = sorted(
263
+ {(line.account, e.journal_no) for e in entries for line in e.lines if is_formula_like(line.account)}
264
+ | {
265
+ (line.party, e.journal_no)
266
+ for e in entries
267
+ for line in e.lines
268
+ if line.party and is_formula_like(line.party)
269
+ }
270
+ )
271
+ if bad_journal_nos or bad_names:
272
+ parts = []
273
+ if bad_journal_nos:
274
+ parts.append(f"Journal No. {', '.join(bad_journal_nos)}")
275
+ if bad_names:
276
+ parts.append(
277
+ "Account Name/Name " + ", ".join(f"{value!r} in {journal_no}" for value, journal_no in bad_names)
278
+ )
279
+ raise PeriodError(
280
+ "QuickBooks matches Journal No., Account Name, and Name by exact text; a value that would open "
281
+ f"as a formula in a spreadsheet cannot be escaped without breaking that match: {'; '.join(parts)}"
282
+ )
283
+ header = ["Journal No.", "Journal Date", "Account Name", "Debits", "Credits", "Journal/Description", "Name"]
284
+ rows = [
285
+ [
286
+ e.journal_no,
287
+ e.date.strftime(QBO_DATE_FORMAT),
288
+ line.account,
289
+ *_sides(line.net),
290
+ spreadsheet_safe_cell(line.description),
291
+ line.party or "",
292
+ ]
293
+ for e in entries
294
+ for line in e.lines
295
+ ]
296
+ return render_csv(header, rows, spreadsheet_safe=False)
297
+
298
+
299
+ def parties_csv(parties: Sequence[str], location: str) -> bytes:
300
+ """One party per row, sorted, deduplicated: QBO refuses a contact it already has."""
301
+ names = sorted(set(parties))
302
+ _refuse_formula_like_names("Name", location, names)
303
+ return render_csv(["Name"], [[p] for p in names], spreadsheet_safe=False)
304
+
305
+
306
+ def accounts_csv(chart: Sequence[DesiredAccount], qmap: QboMap) -> bytes:
307
+ """Postable, non-builtin accounts in number order, then the clearing account.
308
+
309
+ QBO already carries its own Accounts Receivable, Accounts Payable, and
310
+ Retained Earnings -- the builtins in `qmap` -- so those are never
311
+ imported as accounts; `opening_entries` and `journal_entries` still post
312
+ to them by name.
313
+ """
314
+ rows = [
315
+ [a.name, *qmap.accounts[a.number], a.number]
316
+ for a in sorted(chart, key=lambda a: a.number)
317
+ if not a.is_group and a.number not in qmap.builtin
318
+ ]
319
+ rows.append([CLEARING_ACCOUNT_NAME, *qmap.clearing, ""])
320
+ _refuse_formula_like_names("Account Name", "accounts.csv", (row[0] for row in rows))
321
+ return render_csv(["Account Name", "Type", "Detail Type", "Number"], rows, spreadsheet_safe=False)
322
+
323
+
324
+ def _retained_earnings_builtin(qmap: QboMap, index: AccountIndex) -> str:
325
+ equity = [n for n in qmap.builtin if any(a.number == n and a.root_type == "Equity" for a in index.values())]
326
+ if len(equity) != 1:
327
+ raise PeriodError(f"qbo.toml [builtin] must map exactly one Equity account (Retained Earnings); found {equity}")
328
+ return qmap.builtin[equity[0]].name
329
+
330
+
331
+ def _sole_account(index: AccountIndex, kind: str) -> DesiredAccount:
332
+ """The one postable account of `kind` (Receivable or Payable) that QBO's single A/R or A/P builtin stands in for.
333
+
334
+ QBO has exactly one Accounts Receivable and one Accounts Payable, so a
335
+ second postable account of the same kind in `chart.coa` can't be told
336
+ apart once its lines land there; refuse by name -- naming every
337
+ candidate found -- rather than picking one with `next(...)` and quietly
338
+ dropping the other's AR/AP lines, or crashing with a bare
339
+ `StopIteration` when there is none.
340
+ """
341
+ candidates = sorted(
342
+ (a for a in index.values() if a.account_type == kind and not a.is_group), key=lambda a: a.number
343
+ )
344
+ if len(candidates) != 1:
345
+ numbers = ", ".join(a.number for a in candidates) or "none"
346
+ raise PeriodError(f"expected exactly one postable {kind} account in chart.coa; found {numbers}")
347
+ return candidates[0]
348
+
349
+
350
+ def opening_entries(tb: TrialBalance, rows: Sequence[GLRow], index: AccountIndex, qmap: QboMap) -> list[QboEntry]:
351
+ """One entry dated the day before the quarter, opening every account QBO does not already carry from history.
352
+
353
+ A non-AR/AP postable account with a non-zero opening balance gets one
354
+ line; the A/R and A/P builtins are opened by party instead, one line per
355
+ open item as of that day, because QBO demands a customer or vendor on
356
+ every A/R or A/P line; and any prior-year P&L never closed by a Period
357
+ Closing Voucher lands on the Retained Earnings builtin, the same figure
358
+ `check_replay` expects it to carry.
359
+ """
360
+ day_before = tb.quarter.start - dt.timedelta(days=1)
361
+ lines: list[QboLine] = []
362
+ for line in tb.lines:
363
+ if line.account.account_type in _AR_AP or line.opening == 0:
364
+ continue
365
+ lines.append(QboLine(qbo_account_name(line.account, qmap), line.opening, "Opening balance", None, False))
366
+ for kind in _AR_AP:
367
+ account = _sole_account(index, kind)
368
+ sign = 1 if kind == "Receivable" else -1
369
+ for item in open_items(rows, index, kind, day_before):
370
+ lines.append(
371
+ QboLine(qbo_account_name(account, qmap), sign * item.amount, f"Open {item.document}", item.party, True)
372
+ )
373
+ if tb.prior_year_unclosed:
374
+ lines.append(
375
+ QboLine(
376
+ _retained_earnings_builtin(qmap, index),
377
+ tb.prior_year_unclosed,
378
+ "Prior-year earnings, unclosed",
379
+ None,
380
+ False,
381
+ )
382
+ )
383
+ if not lines:
384
+ return []
385
+ return split_entry(f"OPENING-{tb.quarter.label}", day_before, lines, f"OPENING-{tb.quarter.label}")
386
+
387
+
388
+ def replay(entries: Sequence[QboEntry]) -> dict[str, Decimal]:
389
+ """Every QBO account's net across `entries`, by QBO account name."""
390
+ totals: dict[str, Decimal] = {}
391
+ for entry in entries:
392
+ for line in entry.lines:
393
+ totals[line.account] = totals.get(line.account, Decimal(0)) + line.net
394
+ return totals
395
+
396
+
397
+ def check_replay(entries: Sequence[QboEntry], tb: TrialBalance, qmap: QboMap) -> None:
398
+ """Refuse unless every QBO account's replayed net equals what our books expect it to close at.
399
+
400
+ The Retained Earnings builtin carries the ordinary account's closing
401
+ balance plus the prior-year figure `opening_entries` posted to it, and
402
+ the clearing account must always net to zero -- its lines exist only to
403
+ balance a split voucher.
404
+ """
405
+ expected: dict[str, Decimal] = {CLEARING_ACCOUNT_NAME: Decimal(0)}
406
+ for line in tb.lines:
407
+ name = qbo_account_name(line.account, qmap)
408
+ expected[name] = expected.get(name, Decimal(0)) + line.closing
409
+ retained = next(
410
+ (
411
+ qmap.builtin[n].name
412
+ for n in qmap.builtin
413
+ if any(tbline.account.number == n and tbline.account.root_type == "Equity" for tbline in tb.lines)
414
+ ),
415
+ None,
416
+ )
417
+ if retained is not None:
418
+ expected[retained] = expected.get(retained, Decimal(0)) + tb.prior_year_unclosed
419
+ actual = replay(entries)
420
+ wrong = sorted(
421
+ f"{name}: kit {actual.get(name, Decimal(0))}, books {expected.get(name, Decimal(0))}"
422
+ for name in expected.keys() | actual.keys()
423
+ if actual.get(name, Decimal(0)) != expected.get(name, Decimal(0))
424
+ )
425
+ if wrong:
426
+ raise PeriodError("the QBO kit would not reproduce our trial balance:\n " + "\n ".join(wrong))
427
+
428
+
429
+ def refuse_duplicate_journal_numbers(entries: Sequence[QboEntry]) -> None:
430
+ """Refuse, naming each, any finished Journal No. shared by more than one entry.
431
+
432
+ `journal_entries`'s own collision check compares voucher numbers before
433
+ splitting adds `/1`, `/2` -- so a voucher literally named `JV-1/1` passes
434
+ that check even though it collides with the first half of a split
435
+ `JV-1`. This runs after opening and journal entries are both built, once
436
+ every entry (split or not, opening or quarter) carries the Journal No.
437
+ that will actually land in a file, and catches what the earlier check
438
+ cannot see.
439
+ """
440
+ counts: dict[str, int] = {}
441
+ for entry in entries:
442
+ counts[entry.journal_no] = counts.get(entry.journal_no, 0) + 1
443
+ duplicated = sorted(no for no, count in counts.items() if count > 1)
444
+ if duplicated:
445
+ raise PeriodError(f"QBO Journal No. is not unique across the kit: {', '.join(duplicated)}")
446
+
447
+
448
+ def check_file_size(name: str, row_count: int, data: bytes, max_rows: int = 999, max_bytes: int = 2_000_000) -> None:
449
+ """Refuse, naming `name`, a QBO import file over either of Intuit's caps: 1,000 rows and 2 MB.
450
+
451
+ `check_opening_size` and `chunk` cover the entries-shaped files
452
+ (`opening-balances.csv`, `journal-entries-NN.csv`) by measuring an entry
453
+ at a time; `customers.csv` and `vendors.csv` are a plain party list with
454
+ no entry to measure that way, so this checks the finished file's own row
455
+ count and rendered size directly instead -- the same two caps, reused
456
+ rather than re-derived. Splitting is not needed today: this only refuses
457
+ the file that would need it.
458
+ """
459
+ if row_count > max_rows:
460
+ raise PeriodError(f"{name} needs {row_count} rows, more than the {max_rows} a single QBO import file allows")
461
+ if len(data) > max_bytes:
462
+ raise PeriodError(
463
+ f"{name} renders to {len(data):,} bytes, more than the {max_bytes:,} a single QBO import file allows"
464
+ )
465
+
466
+
467
+ @dataclasses.dataclass(frozen=True)
468
+ class QboKit:
469
+ files: dict[str, bytes]
470
+ split_vouchers: tuple[str, ...]
471
+
472
+
473
+ def build_qbo_kit(
474
+ tb: TrialBalance,
475
+ rows: Sequence[GLRow],
476
+ index: AccountIndex,
477
+ chart: Sequence[DesiredAccount],
478
+ qmap: QboMap,
479
+ quarter: Quarter,
480
+ ) -> QboKit:
481
+ """Assemble the whole `qbo/` import directory and prove it replays to our books before returning it."""
482
+ validate_qbo_map(qmap, chart)
483
+ opening = opening_entries(tb, rows, index, qmap)
484
+ check_opening_size(opening)
485
+ journals = journal_entries(rows, index, qmap, quarter)
486
+ refuse_duplicate_journal_numbers([*opening, *journals])
487
+ check_replay([*opening, *journals], tb, qmap)
488
+ receivable_name = qbo_account_name(_sole_account(index, "Receivable"), qmap)
489
+ payable_name = qbo_account_name(_sole_account(index, "Payable"), qmap)
490
+ customer_parties = [
491
+ line.party for e in [*opening, *journals] for line in e.lines if line.party and line.account == receivable_name
492
+ ]
493
+ vendor_parties = [
494
+ line.party for e in [*opening, *journals] for line in e.lines if line.party and line.account == payable_name
495
+ ]
496
+ customers_csv = parties_csv(customer_parties, "customers.csv")
497
+ vendors_csv = parties_csv(vendor_parties, "vendors.csv")
498
+ check_file_size("customers.csv", len(set(customer_parties)), customers_csv)
499
+ check_file_size("vendors.csv", len(set(vendor_parties)), vendors_csv)
500
+ accounts_data = accounts_csv(chart, qmap)
501
+ # +1 for the clearing account row accounts_csv always appends.
502
+ accounts_row_count = sum(1 for a in chart if not a.is_group and a.number not in qmap.builtin) + 1
503
+ check_file_size("accounts.csv", accounts_row_count, accounts_data)
504
+ files = {
505
+ "accounts.csv": accounts_data,
506
+ "customers.csv": customers_csv,
507
+ "vendors.csv": vendors_csv,
508
+ "opening-balances.csv": journal_csv(opening),
509
+ }
510
+ for n, group in enumerate(chunk(journals), start=1):
511
+ files[f"journal-entries-{n:02d}.csv"] = journal_csv(group)
512
+ # A voucher is split when it produced more than one QboEntry -- counted
513
+ # by source voucher, not by "/" in journal_no: a voucher whose own
514
+ # number happens to contain "/" is not split, and checking the
515
+ # character would misreport it as one to the accountant reading
516
+ # MANIFEST.md.
517
+ source_counts: dict[str, int] = {}
518
+ for e in journals:
519
+ source_counts[e.source] = source_counts.get(e.source, 0) + 1
520
+ split = tuple(sorted(source for source, count in source_counts.items() if count > 1))
521
+ return QboKit(files, split)