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 +344 -0
- billwright/__init__.py +12 -0
- billwright/__main__.py +4 -0
- billwright/assets/fonts/Inter-Medium.otf +0 -0
- billwright/assets/fonts/Inter-Regular.otf +0 -0
- billwright/assets/fonts/Inter-SemiBold.otf +0 -0
- billwright/assets/fonts/OFL.txt +92 -0
- billwright/audit.py +101 -0
- billwright/doctor.py +248 -0
- billwright/fonts.py +51 -0
- billwright/i18n.py +187 -0
- billwright/load.py +385 -0
- billwright/main.py +341 -0
- billwright/mcp_server.py +441 -0
- billwright/model.py +262 -0
- billwright/money.py +64 -0
- billwright/native.py +90 -0
- billwright/numbering.py +62 -0
- billwright/paths.py +164 -0
- billwright/qr.py +64 -0
- billwright/render.py +270 -0
- billwright/scaffold.py +206 -0
- billwright/scan.py +334 -0
- billwright/statement.py +122 -0
- billwright/styles/bill.css +260 -0
- billwright/styles/print.css +67 -0
- billwright/styles/statement.css +167 -0
- billwright/styles/tokens.css +103 -0
- billwright/templates/_wordmark.html.j2 +16 -0
- billwright/templates/base.html.j2 +17 -0
- billwright/templates/bill.html.j2 +156 -0
- billwright/templates/figures.html.j2 +81 -0
- billwright/templates/statement.html.j2 +110 -0
- billwright-0.2.0.dist-info/METADATA +514 -0
- billwright-0.2.0.dist-info/RECORD +39 -0
- billwright-0.2.0.dist-info/WHEEL +4 -0
- billwright-0.2.0.dist-info/entry_points.txt +3 -0
- billwright-0.2.0.dist-info/licenses/LICENSE +201 -0
- billwright-0.2.0.dist-info/licenses/NOTICE +31 -0
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
|
Binary file
|
|
Binary file
|
|
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
|
+
)
|