runningtab 0.1.0.dev3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- runningtab/__init__.py +3 -0
- runningtab/access.py +419 -0
- runningtab/books.py +225 -0
- runningtab/chart.py +408 -0
- runningtab/cli.py +2216 -0
- runningtab/coa.py +212 -0
- runningtab/doc.py +185 -0
- runningtab/forecast/__init__.py +1 -0
- runningtab/forecast/compute.py +224 -0
- runningtab/forecast/doctype.py +228 -0
- runningtab/forecast/model.py +251 -0
- runningtab/forecast/names.py +8 -0
- runningtab/forecast/sources.py +85 -0
- runningtab/gl_codes.py +50 -0
- runningtab/health.py +661 -0
- runningtab/mercury/__init__.py +7 -0
- runningtab/mercury/categories.py +500 -0
- runningtab/mercury/classify.py +101 -0
- runningtab/mercury/client.py +360 -0
- runningtab/mercury/ingest.py +733 -0
- runningtab/mercury/mapping.py +130 -0
- runningtab/mercury/models.py +544 -0
- runningtab/mercury/posting.py +757 -0
- runningtab/mercury/receipts.py +304 -0
- runningtab/mercury/session.py +65 -0
- runningtab/mercury/state.py +285 -0
- runningtab/mercury/vouchers.py +951 -0
- runningtab/period/__init__.py +9 -0
- runningtab/period/balances.py +283 -0
- runningtab/period/bank.py +154 -0
- runningtab/period/close.py +510 -0
- runningtab/period/crosscheck.py +257 -0
- runningtab/period/csvfmt.py +86 -0
- runningtab/period/documents.py +260 -0
- runningtab/period/fence.py +277 -0
- runningtab/period/ledger.py +205 -0
- runningtab/period/lock.py +294 -0
- runningtab/period/manifest.py +259 -0
- runningtab/period/open_items.py +84 -0
- runningtab/period/package.py +140 -0
- runningtab/period/qbo.py +521 -0
- runningtab/period/qbo_map.py +174 -0
- runningtab/period/quarter.py +90 -0
- runningtab/period/readiness.py +146 -0
- runningtab/period/record.py +247 -0
- runningtab/period/status.py +312 -0
- runningtab/site.py +409 -0
- runningtab-0.1.0.dev3.dist-info/METADATA +726 -0
- runningtab-0.1.0.dev3.dist-info/RECORD +52 -0
- runningtab-0.1.0.dev3.dist-info/WHEEL +4 -0
- runningtab-0.1.0.dev3.dist-info/entry_points.txt +2 -0
- runningtab-0.1.0.dev3.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,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
|
runningtab/period/qbo.py
ADDED
|
@@ -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)
|