billwright 0.2.0__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.
billwright/AGENTS.md ADDED
@@ -0,0 +1,344 @@
1
+ # AGENTS.md
2
+
3
+ Instructions for a coding agent working on billwright: invoices and yearly
4
+ statements. Read this before touching anything. This is the single source of
5
+ truth for agent rules in this repo; `CLAUDE.md` and `GEMINI.md` only point here.
6
+
7
+ Everything here is **client-facing or tax-facing**. A broken invoice is
8
+ discovered by a client who is being asked for money; a broken statement is
9
+ discovered by the Steueramt. Bias hard towards verifying rather than assuming.
10
+
11
+ Humans: see [README.md](README.md).
12
+
13
+ ---
14
+
15
+ ## If you are setting up a new company, start here
16
+
17
+ Someone has cloned this and wants to bill with it. **Do the setup for them** —
18
+ they should not have to install anything by hand before talking to you.
19
+
20
+ **Step 1: get the environment working.** Check what is missing and install it,
21
+ asking before you run anything that needs `sudo` or touches their system:
22
+
23
+ ```bash
24
+ uv --version # https://astral.sh/uv if absent
25
+ python3 -c "import ctypes.util as u; print(u.find_library('gobject-2.0'))"
26
+ ```
27
+
28
+ WeasyPrint binds Pango and Cairo through cffi, and a missing one produces
29
+ `cannot load library 'libgobject-2.0-0'` rather than anything about fonts:
30
+
31
+ | Platform | Command |
32
+ |---|---|
33
+ | macOS | `brew install pango` (and `poppler`, to turn a PDF into a PNG you can look at) |
34
+ | Debian/Ubuntu | `sudo apt install libpango-1.0-0 libpangoft2-1.0-0 poppler-utils` |
35
+ | Fedora | `sudo dnf install pango poppler-utils` |
36
+
37
+ **Step 2: prove the renderer works before touching their data.** This separates
38
+ a missing system library from a mistake you are about to make:
39
+
40
+ ```bash
41
+ make setup # uv sync
42
+ make bill BILL=RE-26001 # renders the bundled example into out/
43
+ ```
44
+
45
+ **Step 3: give them their own profile.** Either way, `data/` is gitignored and
46
+ `example/` stays exactly as it is:
47
+
48
+ ```bash
49
+ uv run billwright init-profile --into data # empty form, every field marked
50
+ # required or optional
51
+ ```
52
+
53
+ or, if you would rather start from a filled-in example and edit it down:
54
+
55
+ ```bash
56
+ cp -r example data
57
+ rm -rf data/bills/* data/years/* # their profile, not the sample's numbers
58
+ ```
59
+
60
+ **Step 4: take whatever they have and write the TOML yourself.**
61
+
62
+ They do not know this file format and should never be asked to learn it. Ask
63
+ what they already have and read it:
64
+
65
+ - **an invoice they sent before** — PDF, Word, Pages, a photo of a printout.
66
+ This is the best source: it has the issuer block, the footer, the payment
67
+ details and a real line item, all in one place.
68
+ - **a letterhead, an email signature, a business card, their website**
69
+ - **a bank statement or e-banking screenshot**, for the IBAN and the bank name
70
+ - **nothing at all** — then just ask, in plain questions, one topic at a time.
71
+
72
+ Read the source, extract the fields, and write the files. Where a document is
73
+ ambiguous, ask about that one field rather than guessing the lot.
74
+
75
+ | Where it usually is on an old invoice | Goes to |
76
+ |---|---|
77
+ | Sender block, top left or in the letterhead | `company.toml`: `name`, `person`, `address` |
78
+ | Footer: email, website, CHE number, legal form | `company.toml`: `email`, `website`, `uid`, `legal_form` |
79
+ | Payment slip or "Zahlbar an" | `company.toml`: `iban`, `bank` |
80
+ | "MwSt/TVA" line, or its absence | `company.toml`: `vat_registered`, `vat_rate` |
81
+ | "Zahlbar innert 30 Tagen" | `company.toml`: `default_terms_days` |
82
+ | Recipient block | `clients/<key>.toml` |
83
+ | Line items and their unit prices | `rates.toml` service categories |
84
+ | Logo colours and typeface | `brand.toml`, or leave the neutral default |
85
+
86
+ **Never invent a financial or legal value, and never trust your own reading of
87
+ one.** An invented or misread IBAN sends money to a stranger; an invented VAT
88
+ rate under-invoices and the difference is still owed.
89
+
90
+ - **Read every extracted financial value back to them for confirmation** — the
91
+ IBAN in full, the VAT rate, the rates. OCR and PDF text layers transpose
92
+ digits, and an invoice is the last place that gets noticed.
93
+ - If they do not know, stop and let them find out. `vat_registered` being absent
94
+ means *unknown*, not `false`.
95
+ - `billwright doctor` checks the IBAN's mod-97 checksum, so a transposed digit
96
+ usually fails there — but a checksum-valid wrong IBAN exists, which is why
97
+ the human confirms it too.
98
+
99
+ Then:
100
+
101
+ ```bash
102
+ uv run billwright doctor # is the profile complete and the IBAN valid?
103
+ uv run billwright check # numbering, gaps, empty bills
104
+ make new CLIENT=<their-key> # scaffolds the next number
105
+ # fill in the line items, then:
106
+ make bill BILL=<number> # renders into out/ — LOOK AT IT (rule 6 below)
107
+ make scan # nothing private reached a tracked file
108
+ ```
109
+
110
+ Tell them plainly, once, before their first real invoice: **scan the QR code
111
+ with a banking app.** The payload is unit-tested; a live scan is the only thing
112
+ that proves the money would arrive.
113
+
114
+ ---
115
+
116
+ ## First, orient
117
+
118
+ ```bash
119
+ make setup # uv sync
120
+ make bill BILL=RE-26001 # render one bill into out/
121
+ make statement YEAR=2026 # yearly accounts + figures sheet into out/
122
+ make scan # fail if a tracked file holds a private value
123
+ make check # lint + scan + tests + numbering checks (what CI runs)
124
+ uv run billwright --help
125
+ ```
126
+
127
+ The entry point is `billwright.main:main`, exposed as `billwright` and as
128
+ `python -m billwright`.
129
+
130
+ There is a second, optional entry point: `billwright.mcp_server:main`, exposed
131
+ as `billwright-mcp` and installed only with the `mcp` extra. **It is not the way
132
+ in, and it is not a place to put behaviour.** It serves this file verbatim as a
133
+ resource and calls the same loaders the CLI calls; anything it did that the CLI
134
+ does not is by definition drift. If you are about to add logic there, add it to
135
+ the engine and let both call it.
136
+
137
+ **How the repo is split** — this is the thing to understand first:
138
+
139
+ - `src/billwright/` is a **generic engine**. It contains no company name, address,
140
+ IBAN, colour, font or wordmark.
141
+ - `data/` is the **profile**: all company facts. Gitignored. `example/` is a
142
+ fictional profile that is committed, and what the tests run against.
143
+ - `src/billwright/assets/fonts/` holds the vendored typeface. It is inside the
144
+ package so that an installed copy renders correctly with no flags; a face the
145
+ renderer cannot find is now an error rather than a substitution.
146
+ - `assets/reference/` (gitignored) is for source documents from clients.
147
+ - `skills/` documents how to rebuild the system, split generic vs. specific, so
148
+ it can be reused for another company. Read
149
+ [`skills/README.md`](skills/README.md) before making structural changes.
150
+
151
+ ---
152
+
153
+ ## Non-negotiables
154
+
155
+ **1. Never put a company value in `src/`.** No name, address, IBAN, hex colour,
156
+ font choice or wordmark string. If you need one, it belongs in `data/`. A value
157
+ appearing in `src/` means the engine/profile seam has leaked — fix the seam
158
+ rather than special-casing.
159
+
160
+ `make scan` enforces this across every **tracked** file, not just `src/`: it
161
+ matches the active profile's own values, anything shaped like an IBAN, a Swiss
162
+ UID, an email address or a phone number, and the literals in the gitignored
163
+ `notes/denylist.txt`. It runs inside `make check`. It never prints what it
164
+ matched — a guard that echoes the value into a log has moved the leak rather
165
+ than caught it, so findings name the file and line and you look yourself.
166
+
167
+ **2. Totals are computed, never stored.** No data file may carry a `total`
168
+ field. This is not a style preference: the Word original this replaces states
169
+ a total its own lines did not sum to, and computing the total is
170
+ what makes that impossible. `tests/test_load.py` pins it.
171
+
172
+ **3. Money is `Decimal`.** Never `float`. Format Swiss: `1’234.50 CHF`, with
173
+ U+2019 for thousands and a period for decimals. `’` and `'` look alike and only
174
+ one is correct — `tests/test_money.py` asserts on it.
175
+
176
+ **4. Swiss German uses `ss`, never `ß`.** `Grüssen`, `vereinbarungsgemäss`. The
177
+ Word originals got this wrong; do not reintroduce it.
178
+
179
+ **5. Never invent a colour, font or spacing value.** Everything comes from
180
+ `data/brand.toml` (colours, wordmark) and the scale in
181
+ `src/billwright/styles/tokens.css` (type, spacing). The palette mirrors
182
+ the profile's `brand.toml`. If something looks like it
183
+ needs a new value, it almost certainly needs an existing one.
184
+
185
+ **But the accent is an accent.** One 1 pt rule under the title is the entire
186
+ brand presence on an invoice. Filling boxes with the tint is how this gets
187
+ wrecked.
188
+
189
+ **6. Render it and actually look at it.** This is the rule that matters most.
190
+ Structural checks pass happily while a payment part sits on top of the totals.
191
+
192
+ ```bash
193
+ make bill BILL=RE-26001
194
+ cd out && pdftoppm -png -r 100 *RE-26001.pdf page # then open page-1.png
195
+ ```
196
+
197
+ If you cannot view images in your environment, **say so explicitly in your
198
+ report** and describe the check as unverified. Never imply visual confirmation
199
+ you did not perform.
200
+
201
+ **7. Never edit a PDF, and never edit an archived one at all.** The archived PDF
202
+ is the record of what a client was sent — `OR Art. 958f` requires keeping it ten
203
+ years. Change the data and re-render. `tests/test_reproducible.py` proves a
204
+ re-render is byte-identical.
205
+
206
+ **8. The invoice body has ~165 mm.** A4 minus the 105 mm Swiss QR payment part
207
+ and the top margin. Every spacing decision lives under that budget. When a bill
208
+ overflows, measure before adjusting:
209
+
210
+ ```python
211
+ # see skills/swiss-billing-generator/references/weasyprint.md
212
+ box.position_y / 96 * 25.4 # mm
213
+ ```
214
+
215
+ **9. Every change to a *tracked* file gets a [CHANGELOG.md](CHANGELOG.md)
216
+ entry** under an `[Unreleased]` heading, added above the current release.
217
+
218
+ **Using the tool is not a change to it.** Creating a profile under `data/`,
219
+ adding a client, issuing a bill, closing a year — none of these get a changelog
220
+ entry, and none of them get a commit. `data/` is gitignored: nothing there is
221
+ part of this repository, and a public changelog saying that the maintainer set
222
+ up a profile tells its readers nothing while inviting a detail that should not
223
+ be public. The changelog is written for someone who installed this software,
224
+ about the software.
225
+
226
+ An earlier version of this rule said "data changes and new bills included",
227
+ which was correct when this repository *was* one company's billing system.
228
+ It is now a generic tool with the company outside it. If you find yourself
229
+ writing a changelog line about a bill, you are recording your own bookkeeping
230
+ in someone else's release notes.
231
+
232
+ **The changelog records what changed in this repository. Nothing else.** It must
233
+ never carry:
234
+
235
+ - **Client or commercial matters** — a disputed amount, an under- or
236
+ over-billing, who owes what, why an invoice was reissued.
237
+ - **Open decisions** — anything phrased as "whether to X is still open". A
238
+ changelog is a record of what happened, not a place to park a question.
239
+ - **Specific money amounts, client names, or account details.** A rule of thumb:
240
+ if the line would embarrass you in front of the client it names, it is in the
241
+ wrong file. Describe the *change* ("totals are now computed from line items"),
242
+ never the *incident* that motivated it.
243
+
244
+ Those things have homes already, and the changelog is not a shortcut to them:
245
+
246
+ | Kind of thing | Where it goes |
247
+ |---|---|
248
+ | An open decision needing Daniel | `TODOS.md` (untracked) |
249
+ | A durable fact about the business you bill for | the private company profile skill (gitignored) |
250
+ | A defect that must never recur | a regression test, with a docstring saying why |
251
+ | A fact about one bill | a comment in that bill's TOML |
252
+
253
+ Before adding a changelog line, ask whether it describes a change to code, data
254
+ or documentation. If it describes a business event, it belongs somewhere above.
255
+
256
+ **10. No co-authored commits.** No `Co-Authored-By` trailer, no tool attribution
257
+ in commit messages.
258
+
259
+ ---
260
+
261
+ ## What this tool does not do
262
+
263
+ Stated because the obvious fix for a storage problem is the wrong one here, and
264
+ the next agent to read "the archive has to outlive a laptop" will otherwise
265
+ propose it in good faith.
266
+
267
+ **No storage backend.** No Drive, no S3, no GCS, no upload of any kind. "It
268
+ reads and writes local files and talks to nothing" is a property people rely on,
269
+ and it is most of why this tool is allowed near a company's bank details at all.
270
+ Making an archive survive a disk is a sync problem, solved outside this tool.
271
+
272
+ **Nothing that sends a document anywhere.** No mail, no client portal, no
273
+ webhook. An invoice leaving the company is a human decision, and a tool that
274
+ could send one makes that unenforceable.
275
+
276
+ If a request seems to need either, the answer is a path on disk and a sentence
277
+ explaining who copies it where.
278
+
279
+ ---
280
+
281
+ ## Things that will bite you
282
+
283
+ **WeasyPrint on macOS.** It binds Pango/GLib/Cairo through cffi and Homebrew
284
+ puts them where dyld does not look. `billwright` repairs this itself by re-execing
285
+ (`src/billwright/native.py`); the `Makefile` exports the variable for pytest.
286
+ Running `uv run python` directly needs it set by hand:
287
+
288
+ ```bash
289
+ DYLD_FALLBACK_LIBRARY_PATH=/opt/homebrew/lib uv run python …
290
+ ```
291
+
292
+ **`@page :last` does not exist.** Placing the payment part on the final page is
293
+ a two-pass render: count the body's pages, choose inline or a separate sheet,
294
+ then verify the choice did not itself cause an overflow. `render.py` does this;
295
+ do not simplify it away.
296
+
297
+ **`position: fixed` repeats on every page.** It is only safe for the
298
+ single-page case, which is why the fallback exists.
299
+
300
+ **oklch() is not reliable in WeasyPrint.** Tokens are hex, with the oklch source
301
+ in a comment in `data/brand.toml`.
302
+
303
+ **Fonts are vendored static weights**, embedded as data URIs. Do not switch to a
304
+ variable font or a system install — a substituted face on a client document is
305
+ not something you find out about in time.
306
+
307
+ ---
308
+
309
+ ## Adding things
310
+
311
+ **A new bill**: `make new CLIENT=<client-key>`, edit the items, `make bill BILL=…`,
312
+ look at it, then `make archive BILL=…`.
313
+
314
+ **A new client**: a TOML in `data/clients/`. Write the salutation out in full.
315
+
316
+ **A new language**: add the block to `src/billwright/i18n.py`. Both `de` and `en`
317
+ must stay complete — `StrictUndefined` turns a missing key into a render error,
318
+ which is the intent.
319
+
320
+ **A different look for one company**: not a change to `src/`. Put CSS in
321
+ `<profile>/styles/` (`overrides.css` for everything, `bill.css` or
322
+ `statement.css` for one document) — it is appended after the packaged rules — or
323
+ replace a template outright by putting a file of the same name in
324
+ `<profile>/templates/`. If you are editing `src/billwright/styles/` to make one
325
+ company's invoice look right, the seam has leaked.
326
+
327
+ **A new document type**: a template in `templates/`, a stylesheet in `styles/`, a
328
+ subcommand in `main.py`. Reuse `tokens.css` and `print.css`.
329
+
330
+ **A new command**: a subcommand in `main.py`, and — if an agent without a shell
331
+ would need it — a tool in `mcp_server.py` that calls the same function. Put the
332
+ logic in a module both can import, never in either entry point. `audit.py` and
333
+ `paths.py` exist because that rule was applied to `check` and to filenames.
334
+
335
+ ---
336
+
337
+ ## Definition of done
338
+
339
+ - [ ] `make check` passes
340
+ - [ ] The document was rendered **and looked at**
341
+ - [ ] No company value landed in `src/`
342
+ - [ ] No hardcoded colour or spacing outside the token files
343
+ - [ ] CHANGELOG entry added
344
+ - [ ] Anything unverified is stated as unverified
billwright/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Bills and yearly statements, generated from data."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("billwright")
7
+ except PackageNotFoundError: # pragma: no cover - a source tree with nothing installed
8
+ # Not a silent default: it is a version string that cannot be mistaken for
9
+ # a release, which is what an un-installed source tree actually is.
10
+ __version__ = "0+unknown"
11
+
12
+ __all__ = ["__version__"]
billwright/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .main import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
Binary file
@@ -0,0 +1,92 @@
1
+ Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ https://openfontlicense.org
6
+
7
+ -----------------------------------------------------------
8
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
9
+ -----------------------------------------------------------
10
+
11
+ PREAMBLE
12
+ The goals of the Open Font License (OFL) are to stimulate worldwide
13
+ development of collaborative font projects, to support the font
14
+ creation efforts of academic and linguistic communities, and to
15
+ provide a free and open framework in which fonts may be shared and
16
+ improved in partnership with others.
17
+
18
+ The OFL allows the licensed fonts to be used, studied, modified and
19
+ redistributed freely as long as they are not sold by themselves. The
20
+ fonts, including any derivative works, can be bundled, embedded,
21
+ redistributed and/or sold with any software provided that any reserved
22
+ names are not used by derivative works. The fonts and derivatives,
23
+ however, cannot be released under any other type of license. The
24
+ requirement for fonts to remain under this license does not apply to
25
+ any document created using the fonts or their derivatives.
26
+
27
+ DEFINITIONS
28
+ "Font Software" refers to the set of files released by the Copyright
29
+ Holder(s) under this license and clearly marked as such. This may
30
+ include source files, build scripts and documentation.
31
+
32
+ "Reserved Font Name" refers to any names specified as such after the
33
+ copyright statement(s).
34
+
35
+ "Original Version" refers to the collection of Font Software
36
+ components as distributed by the Copyright Holder(s).
37
+
38
+ "Modified Version" refers to any derivative made by adding to,
39
+ deleting, or substituting -- in part or in whole -- any of the
40
+ components of the Original Version, by changing formats or by porting
41
+ the Font Software to a new environment.
42
+
43
+ "Author" refers to any designer, engineer, programmer, technical
44
+ writer or other person who contributed to the Font Software.
45
+
46
+ PERMISSION & CONDITIONS
47
+ Permission is hereby granted, free of charge, to any person obtaining
48
+ a copy of the Font Software, to use, study, copy, merge, embed,
49
+ modify, redistribute, and sell modified and unmodified copies of the
50
+ Font Software, subject to the following conditions:
51
+
52
+ 1) Neither the Font Software nor any of its individual components, in
53
+ Original or Modified Versions, may be sold by itself.
54
+
55
+ 2) Original or Modified Versions of the Font Software may be bundled,
56
+ redistributed and/or sold with any software, provided that each copy
57
+ contains the above copyright notice and this license. These can be
58
+ included either as stand-alone text files, human-readable headers or
59
+ in the appropriate machine-readable metadata fields within text or
60
+ binary files as long as those fields can be easily viewed by the user.
61
+
62
+ 3) No Modified Version of the Font Software may use the Reserved Font
63
+ Name(s) unless explicit written permission is granted by the
64
+ corresponding Copyright Holder. This restriction only applies to the
65
+ primary font name as presented to the users.
66
+
67
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
68
+ Software shall not be used to promote, endorse or advertise any
69
+ Modified Version, except to acknowledge the contribution(s) of the
70
+ Copyright Holder(s) and the Author(s) or with their explicit written
71
+ permission.
72
+
73
+ 5) The Font Software, modified or unmodified, in part or in whole,
74
+ must be distributed entirely under this license, and must not be
75
+ distributed under any other license. The requirement for fonts to
76
+ remain under this license does not apply to any document created using
77
+ the Font Software.
78
+
79
+ TERMINATION
80
+ This license becomes null and void if any of the above conditions are
81
+ not met.
82
+
83
+ DISCLAIMER
84
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
85
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
86
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
87
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
88
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
89
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
90
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
91
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
92
+ OTHER DEALINGS IN THE FONT SOFTWARE.
billwright/audit.py ADDED
@@ -0,0 +1,101 @@
1
+ """Whether a profile's bills hold together as a numbered series.
2
+
3
+ An auditor reading ``RE-26001, RE-26003`` asks what became of ``RE-26002``, and
4
+ "nothing" is not an answer anyone accepts after the fact. This makes the
5
+ question answerable before it is asked.
6
+
7
+ Kept out of ``main.py`` because the command line is no longer the only caller:
8
+ the MCP server reports the same audit, and two implementations of "is the
9
+ numbering sound" would eventually disagree about a real invoice.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ from .load import bill_paths, load_bills, load_expenses
18
+ from .numbering import find_duplicates, find_gaps
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class YearAudit:
23
+ """One year of the series.
24
+
25
+ ``migrated`` counts numbers issued before this tool existed — Word files in
26
+ a folder somewhere. They are not gaps, but they must be *declared* in
27
+ ``years/<year>.toml`` so that a genuinely missing number can never hide
28
+ among them.
29
+ """
30
+
31
+ year: int
32
+ gaps: tuple[str, ...]
33
+ migrated: int
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Audit:
38
+ """What one profile's bills look like read end to end."""
39
+
40
+ bills: int
41
+ duplicates: tuple[str, ...]
42
+ years: tuple[YearAudit, ...]
43
+ empty: tuple[str, ...]
44
+ nonpositive: tuple[str, ...]
45
+
46
+ @property
47
+ def ok(self) -> bool:
48
+ return not self.problems
49
+
50
+ @property
51
+ def problems(self) -> tuple[str, ...]:
52
+ """Every finding, as lines. Empty means the series is sound."""
53
+ lines = []
54
+ if self.duplicates:
55
+ lines.append(f"duplicate bill numbers: {', '.join(self.duplicates)}")
56
+ for year in self.years:
57
+ if year.gaps:
58
+ lines.append(f"{year.year}: gaps in the numbering: {', '.join(year.gaps)}")
59
+ lines.extend(f"{number}: no line items" for number in self.empty)
60
+ lines.extend(f"{number}: net amount is not positive" for number in self.nonpositive)
61
+ return tuple(lines)
62
+
63
+ @property
64
+ def notes(self) -> tuple[str, ...]:
65
+ """True but not wrong — stated so the numbers below them add up."""
66
+ return tuple(
67
+ f"{year.year}: {year.migrated} bill(s) issued before migration, not in this repo"
68
+ for year in self.years
69
+ if year.migrated
70
+ )
71
+
72
+
73
+ def audit(profile: Path) -> Audit:
74
+ """Read every bill in ``profile`` and report what an auditor would ask about."""
75
+ numbers = [path.stem for path in bill_paths(profile)]
76
+
77
+ years = []
78
+ for year in sorted({int(path.parent.name) for path in bill_paths(profile)}):
79
+ _, year_data = load_expenses(profile, year)
80
+ elsewhere = set(year_data.get("bills_issued_elsewhere", []))
81
+ gaps = [
82
+ str(gap)
83
+ for gap in find_gaps(numbers + sorted(elsewhere), year)
84
+ if str(gap) not in elsewhere
85
+ ]
86
+ years.append(YearAudit(year=year, gaps=tuple(gaps), migrated=len(elsewhere)))
87
+
88
+ bills = load_bills(profile)
89
+ # An empty bill is reported as empty and not *also* as non-positive: one
90
+ # fault, one line. `nonpositive` is for a bill that has lines and still does
91
+ # not come to anything, which is a different mistake.
92
+ empty = tuple(bill.number for bill in bills if not bill.items)
93
+ nonpositive = tuple(bill.number for bill in bills if bill.items and bill.net <= 0)
94
+
95
+ return Audit(
96
+ bills=len(numbers),
97
+ duplicates=tuple(find_duplicates(numbers)),
98
+ years=tuple(years),
99
+ empty=empty,
100
+ nonpositive=nonpositive,
101
+ )