quints 0.3.0__tar.gz → 0.4.0__tar.gz
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.
- {quints-0.3.0 → quints-0.4.0}/PKG-INFO +3 -1
- {quints-0.3.0 → quints-0.4.0}/README.md +2 -0
- quints-0.4.0/examples/.gitignore +8 -0
- quints-0.4.0/examples/AGENTS.md +120 -0
- quints-0.4.0/examples/CLAUDE.md +1 -0
- {quints-0.3.0 → quints-0.4.0}/examples/invoicing/acme-2026-07.yaml +1 -0
- {quints-0.3.0 → quints-0.4.0}/examples/invoicing/customers.yaml +1 -0
- {quints-0.3.0 → quints-0.4.0}/examples/invoicing/globex-2026-08.yaml +1 -0
- {quints-0.3.0 → quints-0.4.0}/examples/invoicing/issuer.yaml +1 -0
- {quints-0.3.0 → quints-0.4.0}/pyproject.toml +1 -1
- quints-0.4.0/src/quints/__init__.py +7 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/cli.py +43 -3
- {quints-0.3.0 → quints-0.4.0}/src/quints/config.py +5 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/init.py +183 -16
- {quints-0.3.0 → quints-0.4.0}/src/quints/invoice/model.py +15 -0
- {quints-0.3.0 → quints-0.4.0}/tests/test_docs.py +26 -1
- {quints-0.3.0 → quints-0.4.0}/tests/test_init.py +150 -0
- quints-0.3.0/examples/.gitignore +0 -8
- quints-0.3.0/examples/AGENTS.md +0 -56
- quints-0.3.0/src/quints/__init__.py +0 -3
- {quints-0.3.0 → quints-0.4.0}/.gitignore +0 -0
- {quints-0.3.0 → quints-0.4.0}/LICENSE +0 -0
- {quints-0.3.0 → quints-0.4.0}/examples/accounts.bean +0 -0
- {quints-0.3.0 → quints-0.4.0}/examples/answers.toml +0 -0
- {quints-0.3.0 → quints-0.4.0}/examples/books/2026.bean +0 -0
- {quints-0.3.0 → quints-0.4.0}/examples/commodities.bean +0 -0
- {quints-0.3.0 → quints-0.4.0}/examples/documents/.gitkeep +0 -0
- {quints-0.3.0 → quints-0.4.0}/examples/inbox/.gitkeep +0 -0
- {quints-0.3.0 → quints-0.4.0}/examples/main.bean +0 -0
- {quints-0.3.0 → quints-0.4.0}/examples/prices.bean +0 -0
- {quints-0.3.0 → quints-0.4.0}/examples/pyproject.toml +0 -0
- {quints-0.3.0 → quints-0.4.0}/examples/quints.toml +0 -0
- {quints-0.3.0 → quints-0.4.0}/examples/staging/.gitkeep +0 -0
- {quints-0.3.0 → quints-0.4.0}/examples/statements/ubs-2026.mt940 +0 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/fava/__init__.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/fava/templates/QuintDashboard.html +0 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/fx.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/importing.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/inbox.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/invoice/__init__.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/invoice/draft.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/invoice/labels.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/invoice/qr.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/invoice/render.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/invoice/template.typ +0 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/invoice/vatid.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/invoice/verify.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/kmu.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/ledger.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/match.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/mwst.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/plugins/__init__.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/plugins/kmu.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/prices.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/receivables.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/report.typ +0 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/report_pdf.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/settlement.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/ui.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/src/quints/vat.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/tests/fixtures/transactions.mt940 +0 -0
- {quints-0.3.0 → quints-0.4.0}/tests/test_cli_json.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/tests/test_config.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/tests/test_fava_ext.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/tests/test_fx.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/tests/test_importing.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/tests/test_inbox_match.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/tests/test_invoice.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/tests/test_kmu_plugin.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/tests/test_kmu_report.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/tests/test_ledger.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/tests/test_mwst.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/tests/test_prices_sync.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/tests/test_receivables.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/tests/test_report_pdf.py +0 -0
- {quints-0.3.0 → quints-0.4.0}/tests/test_settlement.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: quints
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Swiss VAT & accounting toolkit for plain-text (beancount) books: MWST reports and quarterly settlement, Bezugsteuer helpers, BAZG daily FX rates, QR-bill invoicing, KMU statutory statements, and statement importers (UBS MT940, Wise, Stripe) that draft into a staging area
|
|
5
5
|
License: GPL-2.0-only
|
|
6
6
|
License-File: LICENSE
|
|
@@ -27,6 +27,8 @@ Description-Content-Type: text/markdown
|
|
|
27
27
|
|
|
28
28
|
Swiss VAT & accounting toolkit for plain-text ([beancount](https://github.com/beancount/beancount)) books.
|
|
29
29
|
|
|
30
|
+

|
|
31
|
+
|
|
30
32
|
Everything a Swiss micro-company (GmbH, AG, or Einzelfirma) needs on top of beancount + Fava:
|
|
31
33
|
|
|
32
34
|
- **MWST**: quarterly VAT report mapped to the ESTV form Ziffern, settlement
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Swiss VAT & accounting toolkit for plain-text ([beancount](https://github.com/beancount/beancount)) books.
|
|
4
4
|
|
|
5
|
+

|
|
6
|
+
|
|
5
7
|
Everything a Swiss micro-company (GmbH, AG, or Einzelfirma) needs on top of beancount + Fava:
|
|
6
8
|
|
|
7
9
|
- **MWST**: quarterly VAT report mapped to the ESTV form Ziffern, settlement
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Working on Example GmbH's books with an AI agent
|
|
2
|
+
|
|
3
|
+
These are plain-text ([beancount](https://beancount.github.io)) books managed
|
|
4
|
+
with [`quints`](https://github.com/sealambda/quints). **`quints` is a
|
|
5
|
+
deterministic tool — you drive it, it never calls a model.** Your job is to
|
|
6
|
+
extend and maintain the ledger; `quints` validates and reports on it.
|
|
7
|
+
|
|
8
|
+
## Setup
|
|
9
|
+
|
|
10
|
+
`uv sync` once — it installs quints, which brings beancount and fava along.
|
|
11
|
+
Then `uv run quints check` (or activate the venv and call `quints` and the
|
|
12
|
+
standard beancount tools directly).
|
|
13
|
+
|
|
14
|
+
This project is a git repository; `quints init` committed the pristine
|
|
15
|
+
scaffold. Work in reviewable steps: `git diff` before moving drafts into
|
|
16
|
+
`books/`, commit once `quints check` passes — the history is the audit trail.
|
|
17
|
+
|
|
18
|
+
## Layout
|
|
19
|
+
|
|
20
|
+
- `main.bean` — options, plugins, includes; the entry point every tool loads.
|
|
21
|
+
`plugin "quints.plugins.kmu"` is enabled: every `*:CH:GmbH:*` account
|
|
22
|
+
**must** be opened with a four-digit `kmu:` code (Swiss KMU Kontenrahmen).
|
|
23
|
+
- `accounts.bean` — the chart of accounts (all `open` directives).
|
|
24
|
+
- `books/2026.bean` — transactions, one file per fiscal year. `main.bean`
|
|
25
|
+
includes `books/*.bean`, so a new year just needs a new file.
|
|
26
|
+
- `commodities.bean` — currencies; `prices.bean` — FX rates, refresh with
|
|
27
|
+
`quints prices sync`.
|
|
28
|
+
- `quints.toml` — entity config (name, legal form, VAT, importer rules). VAT
|
|
29
|
+
*rates* are law and live in code, not here.
|
|
30
|
+
- `staging/` — importer drafts land here (git-ignored, transient).
|
|
31
|
+
- `inbox/` — incoming source documents, not yet filed.
|
|
32
|
+
- `documents/` — filed documents, mirroring the account tree as
|
|
33
|
+
`documents/<Account/Tree>/YYYY-MM-DD.payee.description.pdf`. Committed:
|
|
34
|
+
the ledger links to these files (`fava.plugins.link_documents`).
|
|
35
|
+
- `invoicing/` — issuer identity (`issuer.yaml`), customer registry
|
|
36
|
+
(`customers.yaml`), one YAML per issued invoice.
|
|
37
|
+
|
|
38
|
+
## Extending the chart of accounts (the part that needs judgement)
|
|
39
|
+
|
|
40
|
+
Add income/expense sub-trees for this business as `open` directives in
|
|
41
|
+
`accounts.bean`, each with the KMU code it rolls up to, e.g.:
|
|
42
|
+
|
|
43
|
+
```beancount
|
|
44
|
+
2026-01-01 open Expenses:CH:GmbH:Marketing:Ads CHF
|
|
45
|
+
kmu: "6600" ; Advertising
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
See the codes already in use:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
quints report konten --year 2026
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Pick codes from the KMU Kontenrahmen; `quints check` fails on a `:CH:GmbH:`
|
|
55
|
+
account with no valid `kmu:` code.
|
|
56
|
+
|
|
57
|
+
## The loop — money out (statements → books)
|
|
58
|
+
|
|
59
|
+
1. Draft bank/PSP activity into `staging/`. Configured importers:
|
|
60
|
+
- `quints import ubs <statement.mt940>` — the MT940 export from UBS e-banking; no credentials.
|
|
61
|
+
- `quints import wise --fetch --from <date> --to <date>` — needs `QUINTS_WISE_API_TOKEN` in `.env` (plus `QUINTS_WISE_PRIVATE_KEY` for SCA-protected profiles; the key pair lives in `.wise/`, git-ignored).
|
|
62
|
+
- `quints import stripe --fetch --from <date> --to <date>` — needs `QUINTS_STRIPE_API_KEY` in `.env` (a restricted read-only key for the `[import.stripe]` account).
|
|
63
|
+
2. Review each draft in `staging/`. A draft is a flagged (`!`) transaction
|
|
64
|
+
with only the cash leg known:
|
|
65
|
+
|
|
66
|
+
```beancount
|
|
67
|
+
2026-07-20 ! "ACME AG" "Payment order"
|
|
68
|
+
Assets:CH:GmbH:Current:UBS:CHF -250.00 CHF
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Complete the counter leg, decide the VAT treatment (InputVAT /
|
|
72
|
+
Bezugsteuer / none), link the source document, flip `!` to `*`, and move
|
|
73
|
+
it into `books/2026.bean`. `quints match` scores staging drafts and
|
|
74
|
+
inbox documents against invoices and bookings.
|
|
75
|
+
3. **Always** `quints check` before you consider the books consistent.
|
|
76
|
+
|
|
77
|
+
## The loop — money in (invoice → receivable → payment)
|
|
78
|
+
|
|
79
|
+
1. Describe the invoice as a YAML file in `invoicing/` (each file carries a
|
|
80
|
+
`$schema` modeline, so schema-aware editors validate it as you type).
|
|
81
|
+
2. `quints invoice invoicing/<file>.yaml` renders the PDF into `documents/`
|
|
82
|
+
under the income account and cross-checks the total against the ledger.
|
|
83
|
+
Not booked yet? It prints the receivable draft to paste into
|
|
84
|
+
`books/2026.bean`.
|
|
85
|
+
3. The payment arrives with the next bank import; the draft is matched to
|
|
86
|
+
the open invoice by its QR/SCOR reference. `quints receivables` shows
|
|
87
|
+
what is still open.
|
|
88
|
+
|
|
89
|
+
## Machine-readable surfaces (prefer these over scraping text)
|
|
90
|
+
|
|
91
|
+
Every reporting command takes `--json` — stable keys, ISO dates, decimal
|
|
92
|
+
strings:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
quints check --json
|
|
96
|
+
quints mwst -q 2026-Q3 --json
|
|
97
|
+
quints status --json
|
|
98
|
+
quints report bilanz --at 2026-12-31 --json
|
|
99
|
+
quints receivables --json
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
JSON Schemas for the invoicing files are hosted at
|
|
103
|
+
https://sealambda.github.io/quints/schema/ (`quints schema` writes them
|
|
104
|
+
locally to `invoicing/schema/`).
|
|
105
|
+
|
|
106
|
+
Never invent VAT numbers or rates — compute them with `quints mwst`.
|
|
107
|
+
|
|
108
|
+
## Sample data — replace before the books are real
|
|
109
|
+
|
|
110
|
+
The scaffold seeded a demo quarter so every command has data. Before
|
|
111
|
+
booking real activity:
|
|
112
|
+
|
|
113
|
+
- [ ] `invoicing/issuer.yaml` — the VAT ID (CHE-267.359.056 MWST) and both
|
|
114
|
+
IBANs are checksum-valid fakes; put the real ones in.
|
|
115
|
+
- [ ] `invoicing/customers.yaml` — replace the demo customers (acme, globex).
|
|
116
|
+
- [ ] `invoicing/acme-2026-07.yaml` and `invoicing/globex-2026-08.yaml`
|
|
117
|
+
— delete the demo invoices.
|
|
118
|
+
- [ ] `books/2026.bean` — delete the block marked *sample activity*.
|
|
119
|
+
- [ ] `prices.bean` — drop the demo EUR rates, then `quints prices sync`.
|
|
120
|
+
- [ ] `quints.toml` — the placeholder IBAN under `[import.ubs]`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# yaml-language-server: $schema=https://sealambda.github.io/quints/schema/invoice.schema.json
|
|
1
2
|
# Sample domestic invoice — a Swiss QR-bill. It ties to the ^INV2026014
|
|
2
3
|
# booking in books/2026.bean (net 1'000.00 + 8.1% VAT = 1'081.00), so
|
|
3
4
|
# `quints invoice` cross-checks it clean against the ledger.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# yaml-language-server: $schema=https://sealambda.github.io/quints/schema/issuer.schema.json
|
|
1
2
|
# Issuer identity for `quints invoice` — name, address, VAT ID, and one
|
|
2
3
|
# bank account per invoicing currency. Sample data: replace the VAT ID
|
|
3
4
|
# and IBANs with your own before issuing a real invoice.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "quints"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "Swiss VAT & accounting toolkit for plain-text (beancount) books: MWST reports and quarterly settlement, Bezugsteuer helpers, BAZG daily FX rates, QR-bill invoicing, KMU statutory statements, and statement importers (UBS MT940, Wise, Stripe) that draft into a staging area"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""quints — Swiss VAT & accounting for plain-text (beancount) books."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
# Derived from the installed distribution (pyproject.toml is the single
|
|
6
|
+
# source) — a hardcoded string here drifted from the released version once.
|
|
7
|
+
__version__ = version("quints")
|
|
@@ -57,11 +57,26 @@ app = typer.Typer(
|
|
|
57
57
|
)
|
|
58
58
|
|
|
59
59
|
|
|
60
|
+
def _print_version(value: bool) -> None:
|
|
61
|
+
if value:
|
|
62
|
+
from . import __version__
|
|
63
|
+
|
|
64
|
+
typer.echo(__version__)
|
|
65
|
+
raise typer.Exit()
|
|
66
|
+
|
|
67
|
+
|
|
60
68
|
@app.callback()
|
|
61
69
|
def _main(
|
|
62
70
|
config: Path | None = typer.Option(
|
|
63
71
|
None, "--config", help="quints.toml path (default: ./quints.toml, else built-in defaults)."
|
|
64
72
|
),
|
|
73
|
+
version: bool = typer.Option(
|
|
74
|
+
False,
|
|
75
|
+
"--version",
|
|
76
|
+
help="Print the quints version and exit.",
|
|
77
|
+
callback=_print_version,
|
|
78
|
+
is_eager=True,
|
|
79
|
+
),
|
|
65
80
|
):
|
|
66
81
|
config_mod.set_path(config)
|
|
67
82
|
|
|
@@ -702,7 +717,10 @@ def check(
|
|
|
702
717
|
def invoice(
|
|
703
718
|
data: Path = typer.Argument(..., help="Invoice file (.yaml/.toml/.json)."),
|
|
704
719
|
out: Path | None = typer.Option(
|
|
705
|
-
None,
|
|
720
|
+
None,
|
|
721
|
+
"--out",
|
|
722
|
+
"-o",
|
|
723
|
+
help="Output PDF (default: filed under documents/<income account>/).",
|
|
706
724
|
),
|
|
707
725
|
issuer: Path = typer.Option(
|
|
708
726
|
Path("invoicing/issuer.yaml"), "--issuer", help="Issuer config (.yaml/.toml/.json)."
|
|
@@ -734,7 +752,12 @@ def invoice(
|
|
|
734
752
|
registry = m.load_customers(customers) if customers.exists() else None
|
|
735
753
|
inv = m.load_invoice(data, registry)
|
|
736
754
|
iss = m.load_issuer(issuer)
|
|
737
|
-
|
|
755
|
+
if out is None:
|
|
756
|
+
# File the PDF the way beancount documents are filed: under the income
|
|
757
|
+
# account's folder, date-prefixed, next to the ledger's other evidence.
|
|
758
|
+
cfg = config_mod.get()
|
|
759
|
+
account = cfg.income_export if inv.kind == "export" else cfg.income_domestic
|
|
760
|
+
out = m.document_path(inv, account)
|
|
738
761
|
path, totals, payload = r.render(inv, iss, out)
|
|
739
762
|
|
|
740
763
|
qr_ok = None
|
|
@@ -808,7 +831,9 @@ def schema(
|
|
|
808
831
|
"""Write JSON Schemas for the invoice, issuer, and customers files.
|
|
809
832
|
|
|
810
833
|
Point an editor at them (yaml-language-server modeline) for completion
|
|
811
|
-
and validation; any future UI can consume the same contract.
|
|
834
|
+
and validation; any future UI can consume the same contract. The same
|
|
835
|
+
schemas are published on the docs site, so scaffolded YAMLs reference
|
|
836
|
+
them without running this command."""
|
|
812
837
|
import json as _json
|
|
813
838
|
|
|
814
839
|
from .invoice import model as m
|
|
@@ -822,6 +847,7 @@ def schema(
|
|
|
822
847
|
path = out / f"{name}.schema.json"
|
|
823
848
|
path.write_text(_json.dumps(mdl.model_json_schema(), indent=2) + "\n")
|
|
824
849
|
ui.console.print(f"[ok]Wrote[/] {path}")
|
|
850
|
+
ui.console.print(f"[muted]Also hosted at {config_mod.DOCS_URL}/schema/[/]")
|
|
825
851
|
|
|
826
852
|
|
|
827
853
|
@app.command()
|
|
@@ -845,6 +871,11 @@ def init(
|
|
|
845
871
|
answers_file: Path | None = typer.Option(
|
|
846
872
|
None, "--answers", help="TOML answer-file for non-interactive scaffolding."
|
|
847
873
|
),
|
|
874
|
+
use_git: bool = typer.Option(
|
|
875
|
+
True,
|
|
876
|
+
"--git/--no-git",
|
|
877
|
+
help="git init + commit the pristine scaffold (skipped inside an existing repo).",
|
|
878
|
+
),
|
|
848
879
|
force: bool = typer.Option(False, "--force", help="Overwrite existing files."),
|
|
849
880
|
yes: bool = typer.Option(False, "--yes", "-y", help="Skip prompts; accept defaults."),
|
|
850
881
|
as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
@@ -908,14 +939,18 @@ def init(
|
|
|
908
939
|
typer.secho(f"ERROR: {e}", fg="red", err=True)
|
|
909
940
|
raise typer.Exit(1) from None
|
|
910
941
|
result = init_mod.write(directory, files, force=force)
|
|
942
|
+
git_result = init_mod.init_git(directory) if use_git and result.written else None
|
|
911
943
|
|
|
912
944
|
if as_json:
|
|
945
|
+
import dataclasses
|
|
946
|
+
|
|
913
947
|
_json_out(
|
|
914
948
|
{
|
|
915
949
|
"directory": str(directory),
|
|
916
950
|
"entity": answers.entity_name,
|
|
917
951
|
"written": [str(p) for p in result.written],
|
|
918
952
|
"skipped": [str(p) for p in result.skipped],
|
|
953
|
+
"git": dataclasses.asdict(git_result) if git_result else None,
|
|
919
954
|
}
|
|
920
955
|
)
|
|
921
956
|
return
|
|
@@ -923,6 +958,11 @@ def init(
|
|
|
923
958
|
ui.console.print(f"[ok]created[/] {path}")
|
|
924
959
|
for path in result.skipped:
|
|
925
960
|
ui.console.print(f"[warn]exists, skipped[/] {path} (use --force to overwrite)")
|
|
961
|
+
if git_result:
|
|
962
|
+
if git_result.committed:
|
|
963
|
+
ui.console.print("[ok]git[/] initialised repository, committed the scaffold")
|
|
964
|
+
else:
|
|
965
|
+
ui.console.print(f"[warn]git[/] {git_result.detail}")
|
|
926
966
|
if result.written and not result.skipped:
|
|
927
967
|
ui.console.print(
|
|
928
968
|
f"\nScaffolded [b]{answers.entity_name}[/] in {directory}. "
|
|
@@ -28,6 +28,11 @@ else: # pragma: no cover
|
|
|
28
28
|
|
|
29
29
|
DEFAULT_PATH = Path("quints.toml")
|
|
30
30
|
|
|
31
|
+
# The published documentation site. The invoice/issuer/customers JSON Schemas
|
|
32
|
+
# are hosted under <DOCS_URL>/schema/ (regenerated by `make docs`, sync-tested
|
|
33
|
+
# in CI); `quints init` writes yaml-language-server modelines pointing there.
|
|
34
|
+
DOCS_URL = "https://sealambda.github.io/quints"
|
|
35
|
+
|
|
31
36
|
# Legal form → account-name component (Assets:CH:<component>:…). The keys are
|
|
32
37
|
# the entity families the official KMU Kontenrahmen (veb.ch) prints Klasse-28
|
|
33
38
|
# variants for: juristische Personen (gmbh, ag) and Einzelunternehmen;
|
|
@@ -416,9 +416,17 @@ _SAMPLE_IBAN = "CH93 0076 2011 6238 5295 7"
|
|
|
416
416
|
_SAMPLE_CUSTOMER_VAT_ID = "IE1234567T"
|
|
417
417
|
|
|
418
418
|
|
|
419
|
+
def _modeline(schema: str) -> str:
|
|
420
|
+
"""A yaml-language-server modeline pointing at the hosted JSON Schema, so
|
|
421
|
+
editors and agents get field-level validation without running `quints
|
|
422
|
+
schema` first. The schemas are published by the docs build."""
|
|
423
|
+
return f"# yaml-language-server: $schema={config.DOCS_URL}/schema/{schema}.schema.json"
|
|
424
|
+
|
|
425
|
+
|
|
419
426
|
def _issuer_yaml(answers: Answers) -> str:
|
|
420
427
|
return "\n".join(
|
|
421
428
|
[
|
|
429
|
+
_modeline("issuer"),
|
|
422
430
|
"# Issuer identity for `quints invoice` — name, address, VAT ID, and one",
|
|
423
431
|
"# bank account per invoicing currency. Sample data: replace the VAT ID",
|
|
424
432
|
"# and IBANs with your own before issuing a real invoice.",
|
|
@@ -443,6 +451,7 @@ def _issuer_yaml(answers: Answers) -> str:
|
|
|
443
451
|
def _customers_yaml(_answers: Answers) -> str:
|
|
444
452
|
return "\n".join(
|
|
445
453
|
[
|
|
454
|
+
_modeline("customers"),
|
|
446
455
|
"# Customer registry — invoices reference these entries by key. A customer",
|
|
447
456
|
"# is a flat entry, or a dated `versions` history for address changes.",
|
|
448
457
|
"acme:",
|
|
@@ -467,6 +476,7 @@ def _invoice_acme_yaml(answers: Answers) -> str:
|
|
|
467
476
|
year = _open_date(answers).year
|
|
468
477
|
return "\n".join(
|
|
469
478
|
[
|
|
479
|
+
_modeline("invoice"),
|
|
470
480
|
f"# Sample domestic invoice — a Swiss QR-bill. It ties to the ^INV{year}014",
|
|
471
481
|
f"# booking in books/{year}.bean (net 1'000.00 + 8.1% VAT = 1'081.00), so",
|
|
472
482
|
"# `quints invoice` cross-checks it clean against the ledger.",
|
|
@@ -491,6 +501,7 @@ def _invoice_globex_yaml(answers: Answers) -> str:
|
|
|
491
501
|
year = _open_date(answers).year
|
|
492
502
|
return "\n".join(
|
|
493
503
|
[
|
|
504
|
+
_modeline("invoice"),
|
|
494
505
|
"# Sample export invoice — foreign currency, reverse charge, no QR part.",
|
|
495
506
|
f"# Ties to the ^INV{year}015 booking in books/{year}.bean (500.00 EUR).",
|
|
496
507
|
f"number: INV{year}015",
|
|
@@ -643,10 +654,67 @@ def _import_section(importer: str, answers: Answers) -> str:
|
|
|
643
654
|
|
|
644
655
|
# ── AGENTS.md — the AI-first payload ─────────────────────────────────────────
|
|
645
656
|
|
|
657
|
+
# Per-importer usage lines for AGENTS.md — command first, credentials after,
|
|
658
|
+
# so an agent can run the roster without probing quints.toml or .env.
|
|
659
|
+
_IMPORTER_USAGE = {
|
|
660
|
+
"ubs": (
|
|
661
|
+
"`quints import ubs <statement.mt940>` — the MT940 export from UBS "
|
|
662
|
+
"e-banking; no credentials."
|
|
663
|
+
),
|
|
664
|
+
"wise": (
|
|
665
|
+
"`quints import wise --fetch --from <date> --to <date>` — needs "
|
|
666
|
+
"`QUINTS_WISE_API_TOKEN` in `.env` (plus `QUINTS_WISE_PRIVATE_KEY` for "
|
|
667
|
+
"SCA-protected profiles; the key pair lives in `.wise/`, git-ignored)."
|
|
668
|
+
),
|
|
669
|
+
"stripe": (
|
|
670
|
+
"`quints import stripe --fetch --from <date> --to <date>` — needs "
|
|
671
|
+
"`QUINTS_STRIPE_API_KEY` in `.env` (a restricted read-only key for the "
|
|
672
|
+
"`[import.stripe]` account)."
|
|
673
|
+
),
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _agents_import_step(answers: Answers) -> str:
|
|
678
|
+
if not answers.importers:
|
|
679
|
+
return (
|
|
680
|
+
"1. Draft bank/PSP activity into `staging/` with `quints import`. No\n"
|
|
681
|
+
" importer is configured yet — add an `[import.<name>]` section to\n"
|
|
682
|
+
" `quints.toml` (supported: ubs, wise, stripe)."
|
|
683
|
+
)
|
|
684
|
+
lines = ["1. Draft bank/PSP activity into `staging/`. Configured importers:"]
|
|
685
|
+
lines += [f" - {_IMPORTER_USAGE[name]}" for name in answers.importers]
|
|
686
|
+
return "\n".join(lines)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _agents_sample_section(answers: Answers) -> str:
|
|
690
|
+
if not answers.include_samples:
|
|
691
|
+
return ""
|
|
692
|
+
year = _open_date(answers).year
|
|
693
|
+
lines = [
|
|
694
|
+
"",
|
|
695
|
+
"## Sample data — replace before the books are real",
|
|
696
|
+
"",
|
|
697
|
+
"The scaffold seeded a demo quarter so every command has data. Before",
|
|
698
|
+
"booking real activity:",
|
|
699
|
+
"",
|
|
700
|
+
f"- [ ] `invoicing/issuer.yaml` — the VAT ID ({_SAMPLE_VAT_ID}) and both",
|
|
701
|
+
" IBANs are checksum-valid fakes; put the real ones in.",
|
|
702
|
+
"- [ ] `invoicing/customers.yaml` — replace the demo customers (acme, globex).",
|
|
703
|
+
f"- [ ] `invoicing/acme-{year}-07.yaml` and `invoicing/globex-{year}-08.yaml`",
|
|
704
|
+
" — delete the demo invoices.",
|
|
705
|
+
f"- [ ] `books/{year}.bean` — delete the block marked *sample activity*.",
|
|
706
|
+
"- [ ] `prices.bean` — drop the demo EUR rates, then `quints prices sync`.",
|
|
707
|
+
]
|
|
708
|
+
if "ubs" in answers.importers:
|
|
709
|
+
lines.append("- [ ] `quints.toml` — the placeholder IBAN under `[import.ubs]`.")
|
|
710
|
+
return "\n".join(lines) + "\n"
|
|
711
|
+
|
|
646
712
|
|
|
647
713
|
def _agents_md(answers: Answers) -> str:
|
|
648
|
-
|
|
714
|
+
cfg = _cfg(answers)
|
|
715
|
+
marker = cfg.entity_marker
|
|
649
716
|
year = _open_date(answers).year
|
|
717
|
+
bank = _sub(_PRIMARY_BANK, _component(answers))
|
|
650
718
|
return f"""# Working on {answers.entity_name}'s books with an AI agent
|
|
651
719
|
|
|
652
720
|
These are plain-text ([beancount](https://beancount.github.io)) books managed
|
|
@@ -660,6 +728,10 @@ extend and maintain the ledger; `quints` validates and reports on it.
|
|
|
660
728
|
Then `uv run quints check` (or activate the venv and call `quints` and the
|
|
661
729
|
standard beancount tools directly).
|
|
662
730
|
|
|
731
|
+
This project is a git repository; `quints init` committed the pristine
|
|
732
|
+
scaffold. Work in reviewable steps: `git diff` before moving drafts into
|
|
733
|
+
`books/`, commit once `quints check` passes — the history is the audit trail.
|
|
734
|
+
|
|
663
735
|
## Layout
|
|
664
736
|
|
|
665
737
|
- `main.bean` — options, plugins, includes; the entry point every tool loads.
|
|
@@ -672,8 +744,13 @@ standard beancount tools directly).
|
|
|
672
744
|
`quints prices sync`.
|
|
673
745
|
- `quints.toml` — entity config (name, legal form, VAT, importer rules). VAT
|
|
674
746
|
*rates* are law and live in code, not here.
|
|
675
|
-
- `staging/` — importer drafts land here
|
|
676
|
-
|
|
747
|
+
- `staging/` — importer drafts land here (git-ignored, transient).
|
|
748
|
+
- `inbox/` — incoming source documents, not yet filed.
|
|
749
|
+
- `documents/` — filed documents, mirroring the account tree as
|
|
750
|
+
`documents/<Account/Tree>/YYYY-MM-DD.payee.description.pdf`. Committed:
|
|
751
|
+
the ledger links to these files (`fava.plugins.link_documents`).
|
|
752
|
+
- `invoicing/` — issuer identity (`issuer.yaml`), customer registry
|
|
753
|
+
(`customers.yaml`), one YAML per issued invoice.
|
|
677
754
|
|
|
678
755
|
## Extending the chart of accounts (the part that needs judgement)
|
|
679
756
|
|
|
@@ -685,35 +762,79 @@ Add income/expense sub-trees for this business as `open` directives in
|
|
|
685
762
|
kmu: "6600" ; Advertising
|
|
686
763
|
```
|
|
687
764
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
765
|
+
See the codes already in use:
|
|
766
|
+
|
|
767
|
+
```bash
|
|
768
|
+
quints report konten --year {year}
|
|
769
|
+
```
|
|
770
|
+
|
|
771
|
+
Pick codes from the KMU Kontenrahmen; `quints check` fails on a `{marker}`
|
|
772
|
+
account with no valid `kmu:` code.
|
|
773
|
+
|
|
774
|
+
## The loop — money out (statements → books)
|
|
691
775
|
|
|
692
|
-
|
|
776
|
+
{_agents_import_step(answers)}
|
|
777
|
+
2. Review each draft in `staging/`. A draft is a flagged (`!`) transaction
|
|
778
|
+
with only the cash leg known:
|
|
693
779
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
780
|
+
```beancount
|
|
781
|
+
{year}-07-20 ! "ACME AG" "Payment order"
|
|
782
|
+
{bank} -250.00 CHF
|
|
783
|
+
```
|
|
784
|
+
|
|
785
|
+
Complete the counter leg, decide the VAT treatment (InputVAT /
|
|
786
|
+
Bezugsteuer / none), link the source document, flip `!` to `*`, and move
|
|
787
|
+
it into `books/{year}.bean`. `quints match` scores staging drafts and
|
|
788
|
+
inbox documents against invoices and bookings.
|
|
697
789
|
3. **Always** `quints check` before you consider the books consistent.
|
|
698
790
|
|
|
791
|
+
## The loop — money in (invoice → receivable → payment)
|
|
792
|
+
|
|
793
|
+
1. Describe the invoice as a YAML file in `invoicing/` (each file carries a
|
|
794
|
+
`$schema` modeline, so schema-aware editors validate it as you type).
|
|
795
|
+
2. `quints invoice invoicing/<file>.yaml` renders the PDF into `documents/`
|
|
796
|
+
under the income account and cross-checks the total against the ledger.
|
|
797
|
+
Not booked yet? It prints the receivable draft to paste into
|
|
798
|
+
`books/{year}.bean`.
|
|
799
|
+
3. The payment arrives with the next bank import; the draft is matched to
|
|
800
|
+
the open invoice by its QR/SCOR reference. `quints receivables` shows
|
|
801
|
+
what is still open.
|
|
802
|
+
|
|
699
803
|
## Machine-readable surfaces (prefer these over scraping text)
|
|
700
804
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
805
|
+
Every reporting command takes `--json` — stable keys, ISO dates, decimal
|
|
806
|
+
strings:
|
|
807
|
+
|
|
808
|
+
```bash
|
|
809
|
+
quints check --json
|
|
810
|
+
quints mwst -q {year}-Q3 --json
|
|
811
|
+
quints status --json
|
|
812
|
+
quints report bilanz --at {year}-12-31 --json
|
|
813
|
+
quints receivables --json
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
JSON Schemas for the invoicing files are hosted at
|
|
817
|
+
{config.DOCS_URL}/schema/ (`quints schema` writes them
|
|
818
|
+
locally to `invoicing/schema/`).
|
|
704
819
|
|
|
705
820
|
Never invent VAT numbers or rates — compute them with `quints mwst`.
|
|
706
|
-
"""
|
|
821
|
+
{_agents_sample_section(answers)}"""
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _claude_md() -> str:
|
|
825
|
+
# Claude Code auto-loads CLAUDE.md; the @-include pulls AGENTS.md into
|
|
826
|
+
# context so the instructions work without the agent going looking.
|
|
827
|
+
return "@AGENTS.md\n"
|
|
707
828
|
|
|
708
829
|
|
|
709
830
|
def _gitignore() -> str:
|
|
710
831
|
return "\n".join(
|
|
711
832
|
[
|
|
712
|
-
"# quints working directories",
|
|
833
|
+
"# quints working directories. documents/ (filed sources and rendered",
|
|
834
|
+
"# invoices) is deliberately NOT ignored — the ledger links to it.",
|
|
713
835
|
"/staging/",
|
|
714
836
|
".env",
|
|
715
837
|
"__pycache__/",
|
|
716
|
-
"*.pdf", # rendered invoices/statements — regenerate from source
|
|
717
838
|
".venv/",
|
|
718
839
|
".DS_Store",
|
|
719
840
|
".wise/", # Wise SCA signing keys
|
|
@@ -749,6 +870,7 @@ def plan(answers: Answers) -> list[ScaffoldFile]:
|
|
|
749
870
|
ScaffoldFile(Path(f"books/{year}.bean"), _books_bean(answers)),
|
|
750
871
|
*invoicing,
|
|
751
872
|
ScaffoldFile(Path("AGENTS.md"), _agents_md(answers)),
|
|
873
|
+
ScaffoldFile(Path("CLAUDE.md"), _claude_md()),
|
|
752
874
|
ScaffoldFile(Path(".gitignore"), _gitignore()),
|
|
753
875
|
ScaffoldFile(Path("inbox/.gitkeep"), ""),
|
|
754
876
|
ScaffoldFile(Path("staging/.gitkeep"), ""),
|
|
@@ -793,3 +915,48 @@ def write(target: Path, files: list[ScaffoldFile], *, force: bool = False) -> Wr
|
|
|
793
915
|
dest.write_text(f.content)
|
|
794
916
|
result.written.append(dest)
|
|
795
917
|
return result
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
@dataclass(frozen=True)
|
|
921
|
+
class GitResult:
|
|
922
|
+
initialized: bool # a fresh repository was created
|
|
923
|
+
committed: bool # the pristine scaffold is the initial commit
|
|
924
|
+
detail: str = "" # why a step was skipped or failed, for the caller to show
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def init_git(target: Path) -> GitResult:
|
|
928
|
+
"""``git init`` + an initial commit of the pristine scaffold.
|
|
929
|
+
|
|
930
|
+
The repository is what makes agent edits reviewable — diff before drafts
|
|
931
|
+
move into ``books/``, revert when a change was wrong — and the initial
|
|
932
|
+
commit makes "what did the agent change" answerable from day one. A
|
|
933
|
+
``target`` already inside a work tree (e.g. scaffolding a subdirectory of
|
|
934
|
+
an existing repo) is left alone.
|
|
935
|
+
"""
|
|
936
|
+
import shutil
|
|
937
|
+
import subprocess
|
|
938
|
+
|
|
939
|
+
git = shutil.which("git")
|
|
940
|
+
if git is None:
|
|
941
|
+
return GitResult(False, False, "git not installed")
|
|
942
|
+
inside = subprocess.run( # noqa: S603 — fixed argv, git resolved via shutil.which
|
|
943
|
+
[git, "-C", str(target), "rev-parse", "--is-inside-work-tree"],
|
|
944
|
+
capture_output=True,
|
|
945
|
+
text=True,
|
|
946
|
+
)
|
|
947
|
+
if inside.returncode == 0 and inside.stdout.strip() == "true":
|
|
948
|
+
return GitResult(False, False, "already inside a git repository")
|
|
949
|
+
for argv in ([git, "init", "--quiet"], [git, "add", "--all"]):
|
|
950
|
+
step = subprocess.run(argv, cwd=target, capture_output=True, text=True) # noqa: S603 — fixed argv
|
|
951
|
+
if step.returncode != 0:
|
|
952
|
+
return GitResult(True, False, step.stderr.strip() or f"git {argv[1]} failed")
|
|
953
|
+
commit = subprocess.run( # noqa: S603 — fixed argv
|
|
954
|
+
[git, "commit", "--quiet", "-m", "Scaffold books with quints init"],
|
|
955
|
+
cwd=target,
|
|
956
|
+
capture_output=True,
|
|
957
|
+
text=True,
|
|
958
|
+
)
|
|
959
|
+
if commit.returncode != 0:
|
|
960
|
+
# Most likely no git identity — leave the repo with everything staged.
|
|
961
|
+
return GitResult(True, False, commit.stderr.strip() or "git commit failed")
|
|
962
|
+
return GitResult(True, True)
|
|
@@ -9,6 +9,7 @@ one contract.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import json
|
|
12
|
+
import re
|
|
12
13
|
import sys
|
|
13
14
|
from datetime import date
|
|
14
15
|
from decimal import ROUND_HALF_UP, Decimal
|
|
@@ -288,6 +289,20 @@ def load_mapping(path: Path) -> dict:
|
|
|
288
289
|
raise ValueError(f"unsupported invoice file format {suffix!r} (use .yaml/.toml/.json)")
|
|
289
290
|
|
|
290
291
|
|
|
292
|
+
def document_path(inv: Invoice, income_account: str, root: Path = Path("documents")) -> Path:
|
|
293
|
+
"""Where the rendered PDF is filed, per the beancount documents convention.
|
|
294
|
+
|
|
295
|
+
`option "documents"` discovery wants `<root>/<Account/Tree>/YYYY-MM-DD.…`;
|
|
296
|
+
the date prefix is what links the file to the account, and the
|
|
297
|
+
`<payee>.<number>` tail keeps the folder scannable:
|
|
298
|
+
`documents/Income/…/Domestic/2026-07-02.acme-ag.INV2026014.pdf`.
|
|
299
|
+
"""
|
|
300
|
+
customer = inv.customer.name if isinstance(inv.customer, Party) else inv.customer
|
|
301
|
+
slug = re.sub(r"[^a-z0-9]+", "-", customer.lower()).strip("-") or "customer"
|
|
302
|
+
name = f"{inv.issue_date.isoformat()}.{slug}.{inv.number}.pdf"
|
|
303
|
+
return root.joinpath(*income_account.split(":")) / name
|
|
304
|
+
|
|
305
|
+
|
|
291
306
|
def load_issuer(path: Path) -> Issuer:
|
|
292
307
|
return Issuer.model_validate(load_mapping(path))
|
|
293
308
|
|
|
@@ -41,7 +41,10 @@ REPO = Path(__file__).resolve().parents[3]
|
|
|
41
41
|
EXAMPLES = Path(__file__).resolve().parents[1] / "examples"
|
|
42
42
|
|
|
43
43
|
DOC_PAGES = sorted(p.relative_to(REPO) for p in (REPO / "docs").rglob("*.md"))
|
|
44
|
-
|
|
44
|
+
# The generated AGENTS.md is a doc page too — the one an agent actually reads.
|
|
45
|
+
# Its fenced commands run against the example project like every other page.
|
|
46
|
+
AGENTS_MD = EXAMPLES.relative_to(REPO) / "AGENTS.md"
|
|
47
|
+
PAGES = [Path("README.md"), AGENTS_MD, *DOC_PAGES]
|
|
45
48
|
|
|
46
49
|
_FENCE = re.compile(r"^```(\w*)")
|
|
47
50
|
_NO_TEST = re.compile(r"<!--.*no-test.*-->")
|
|
@@ -110,3 +113,25 @@ def test_extractor_sees_the_docs() -> None:
|
|
|
110
113
|
assert len(DOC_PAGES) >= 8, f"docs/ pages missing: found only {DOC_PAGES}"
|
|
111
114
|
total = sum(len(_commands((REPO / p).read_text())) for p in PAGES)
|
|
112
115
|
assert total >= 25, f"only {total} documented commands extracted — extractor broken?"
|
|
116
|
+
agents = len(_commands((REPO / AGENTS_MD).read_text()))
|
|
117
|
+
assert agents >= 5, f"AGENTS.md yields only {agents} runnable commands"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_hosted_schemas_are_in_sync() -> None:
|
|
121
|
+
# The scaffold's yaml-language-server modelines point at
|
|
122
|
+
# <site>/schema/<name>.schema.json; the committed docs/schema/ files that
|
|
123
|
+
# publish there must match the Pydantic models. Regenerate: `make docs`.
|
|
124
|
+
import json
|
|
125
|
+
|
|
126
|
+
from quints.invoice import model as m
|
|
127
|
+
|
|
128
|
+
for name, mdl in (
|
|
129
|
+
("invoice", m.Invoice),
|
|
130
|
+
("issuer", m.Issuer),
|
|
131
|
+
("customers", m.CustomerRegistry),
|
|
132
|
+
):
|
|
133
|
+
path = REPO / "docs" / "schema" / f"{name}.schema.json"
|
|
134
|
+
assert path.exists(), f"{path} missing — run `make docs`"
|
|
135
|
+
assert json.loads(path.read_text()) == mdl.model_json_schema(), (
|
|
136
|
+
f"docs/schema/{name}.schema.json is stale — run `make docs`"
|
|
137
|
+
)
|
|
@@ -8,6 +8,7 @@ against it, asserting known figures. That is the end-to-end smoke test the CI
|
|
|
8
8
|
|
|
9
9
|
import json
|
|
10
10
|
import re
|
|
11
|
+
import subprocess
|
|
11
12
|
import sys
|
|
12
13
|
from datetime import date as Date
|
|
13
14
|
|
|
@@ -210,10 +211,159 @@ def test_sample_invoices_render_and_reconcile(tmp_path: Path):
|
|
|
210
211
|
assert cc.found and cc.ok and cc.date_ok
|
|
211
212
|
|
|
212
213
|
|
|
214
|
+
def test_cli_scaffold_to_invoice_end_to_end(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
215
|
+
# The exact steps a new user types, through the CLI: `quints init …
|
|
216
|
+
# --samples`, then `quints invoice` on each sample invoice inside the
|
|
217
|
+
# project. Guards the whole chain — invoicing/ lands on disk, quints.toml
|
|
218
|
+
# resolves from the cwd, the QR-bill renders, the ledger cross-check
|
|
219
|
+
# matches. Einzelfirma on purpose: its account namespace differs from the
|
|
220
|
+
# built-in defaults, so a config-resolution regression can't hide.
|
|
221
|
+
proj = tmp_path / "jane-books"
|
|
222
|
+
res = runner.invoke(
|
|
223
|
+
app,
|
|
224
|
+
[
|
|
225
|
+
"init",
|
|
226
|
+
str(proj),
|
|
227
|
+
"--name",
|
|
228
|
+
"Jane Doe",
|
|
229
|
+
"--legal-form",
|
|
230
|
+
"einzelfirma",
|
|
231
|
+
"--lang",
|
|
232
|
+
"en",
|
|
233
|
+
"--samples",
|
|
234
|
+
],
|
|
235
|
+
)
|
|
236
|
+
assert res.exit_code == 0, res.output
|
|
237
|
+
for rel in (
|
|
238
|
+
"invoicing/issuer.yaml",
|
|
239
|
+
"invoicing/customers.yaml",
|
|
240
|
+
"invoicing/acme-2026-07.yaml",
|
|
241
|
+
"invoicing/globex-2026-08.yaml",
|
|
242
|
+
):
|
|
243
|
+
assert (proj / rel).exists(), f"scaffold did not create {rel}:\n{res.output}"
|
|
244
|
+
|
|
245
|
+
monkeypatch.chdir(proj)
|
|
246
|
+
# Rendered invoices are filed per the beancount documents convention:
|
|
247
|
+
# documents/<income account tree>/<date>.<customer>.<number>.pdf.
|
|
248
|
+
income = proj / "documents/Income/CH/Einzelfirma/Consulting/External"
|
|
249
|
+
for invoice_file, filed in (
|
|
250
|
+
("acme-2026-07", income / "Domestic/2026-07-02.acme-ag.INV2026014.pdf"),
|
|
251
|
+
("globex-2026-08", income / "Export/2026-08-05.globex-ltd.INV2026015.pdf"),
|
|
252
|
+
):
|
|
253
|
+
res = runner.invoke(app, ["invoice", f"invoicing/{invoice_file}.yaml"])
|
|
254
|
+
assert res.exit_code == 0, res.output
|
|
255
|
+
assert "Ledger match" in res.output, res.output
|
|
256
|
+
assert filed.exists(), res.output
|
|
257
|
+
|
|
258
|
+
|
|
213
259
|
def test_no_invoicing_files_without_samples():
|
|
214
260
|
assert not any(f.path.parts[0] == "invoicing" for f in init.plan(init.Answers()))
|
|
215
261
|
|
|
216
262
|
|
|
263
|
+
def test_agent_payload_is_loaded_and_accurate():
|
|
264
|
+
# CLAUDE.md @-includes AGENTS.md so Claude Code sessions start with the
|
|
265
|
+
# instructions in context; the commands AGENTS.md quotes must be complete
|
|
266
|
+
# (konten needs a period), and the sample checklist only appears when
|
|
267
|
+
# there is sample data to replace.
|
|
268
|
+
files = _files(init.Answers(include_samples=True, importers=("ubs", "wise")))
|
|
269
|
+
assert files["CLAUDE.md"] == "@AGENTS.md\n"
|
|
270
|
+
agents = files["AGENTS.md"]
|
|
271
|
+
assert "quints report konten --year 2026" in agents
|
|
272
|
+
assert "Sample data — replace before the books are real" in agents
|
|
273
|
+
assert "QUINTS_WISE_API_TOKEN" in agents
|
|
274
|
+
assert "quints import stripe" not in agents # only the configured roster
|
|
275
|
+
bare = _files(init.Answers())["AGENTS.md"]
|
|
276
|
+
assert "Sample data" not in bare
|
|
277
|
+
assert "quints.toml` (supported: ubs, wise, stripe)" in bare
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def test_invoicing_yaml_carries_hosted_schema_modeline():
|
|
281
|
+
files = _files(init.Answers(include_samples=True))
|
|
282
|
+
for name, schema in [
|
|
283
|
+
("issuer.yaml", "issuer"),
|
|
284
|
+
("customers.yaml", "customers"),
|
|
285
|
+
("acme-2026-07.yaml", "invoice"),
|
|
286
|
+
("globex-2026-08.yaml", "invoice"),
|
|
287
|
+
]:
|
|
288
|
+
first = files[name].splitlines()[0]
|
|
289
|
+
assert first == (
|
|
290
|
+
f"# yaml-language-server: $schema={config.DOCS_URL}/schema/{schema}.schema.json"
|
|
291
|
+
), f"{name} lacks the schema modeline: {first}"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_gitignore_keeps_documents_tree_committed():
|
|
295
|
+
# A blanket *.pdf ignore would silently exclude documents/ — the filed
|
|
296
|
+
# sources and rendered invoices the ledger links to must be committable.
|
|
297
|
+
gitignore = _files(init.Answers())[".gitignore"]
|
|
298
|
+
assert "*.pdf" not in gitignore
|
|
299
|
+
assert "/staging/" in gitignore
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def test_cli_init_commits_pristine_scaffold(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
303
|
+
# No git identity is configured in CI — provide one via the environment.
|
|
304
|
+
for var in ("GIT_AUTHOR_NAME", "GIT_COMMITTER_NAME"):
|
|
305
|
+
monkeypatch.setenv(var, "quints test")
|
|
306
|
+
for var in ("GIT_AUTHOR_EMAIL", "GIT_COMMITTER_EMAIL"):
|
|
307
|
+
monkeypatch.setenv(var, "test@example.invalid")
|
|
308
|
+
proj = tmp_path / "books"
|
|
309
|
+
res = runner.invoke(app, ["init", str(proj), "--yes", "--json"])
|
|
310
|
+
assert res.exit_code == 0, res.output
|
|
311
|
+
payload = json.loads(res.output)
|
|
312
|
+
assert payload["git"] == {"initialized": True, "committed": True, "detail": ""}
|
|
313
|
+
assert (proj / ".git").is_dir()
|
|
314
|
+
log = subprocess.run( # noqa: S603 — fixed git argv in a test
|
|
315
|
+
["git", "-C", str(proj), "log", "--format=%s"], # noqa: S607
|
|
316
|
+
capture_output=True,
|
|
317
|
+
text=True,
|
|
318
|
+
)
|
|
319
|
+
assert log.stdout.strip() == "Scaffold books with quints init"
|
|
320
|
+
status = subprocess.run( # noqa: S603 — fixed git argv in a test
|
|
321
|
+
["git", "-C", str(proj), "status", "--porcelain"], # noqa: S607
|
|
322
|
+
capture_output=True,
|
|
323
|
+
text=True,
|
|
324
|
+
)
|
|
325
|
+
assert status.stdout == "" # the initial commit captured the whole scaffold
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def test_cli_init_never_nests_a_repo_inside_one(tmp_path: Path):
|
|
329
|
+
subprocess.run(["git", "init", "--quiet", str(tmp_path)], check=True) # noqa: S603, S607
|
|
330
|
+
proj = tmp_path / "books"
|
|
331
|
+
res = runner.invoke(app, ["init", str(proj), "--yes", "--json"])
|
|
332
|
+
assert res.exit_code == 0, res.output
|
|
333
|
+
git = json.loads(res.output)["git"]
|
|
334
|
+
assert git["initialized"] is False and "already inside" in git["detail"]
|
|
335
|
+
assert not (proj / ".git").exists()
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def test_cli_init_no_git_opts_out(tmp_path: Path):
|
|
339
|
+
proj = tmp_path / "books"
|
|
340
|
+
res = runner.invoke(app, ["init", str(proj), "--yes", "--no-git", "--json"])
|
|
341
|
+
assert res.exit_code == 0, res.output
|
|
342
|
+
assert json.loads(res.output)["git"] is None
|
|
343
|
+
assert not (proj / ".git").exists()
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def test_cli_init_without_tty_aborts_cleanly_before_writing(tmp_path: Path):
|
|
347
|
+
# `init` prompts for unanswered questions; with no stdin (agent, script,
|
|
348
|
+
# CI) the prompt aborts — that must happen BEFORE anything is written, so
|
|
349
|
+
# a failed run never leaves a half-scaffolded project. Non-interactive
|
|
350
|
+
# callers pass the full flag set, --yes, or --answers.
|
|
351
|
+
proj = tmp_path / "books"
|
|
352
|
+
res = runner.invoke(app, ["init", str(proj), "--samples"])
|
|
353
|
+
assert res.exit_code != 0
|
|
354
|
+
assert not proj.exists()
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def test_cli_version_flag():
|
|
358
|
+
# `quints --version` must report the installed distribution's version —
|
|
359
|
+
# the first thing to check when a scaffold is missing newer files.
|
|
360
|
+
from importlib.metadata import version
|
|
361
|
+
|
|
362
|
+
res = runner.invoke(app, ["--version"])
|
|
363
|
+
assert res.exit_code == 0
|
|
364
|
+
assert res.output.strip() == version("quints")
|
|
365
|
+
|
|
366
|
+
|
|
217
367
|
def test_cli_init_answers_file_and_force(tmp_path: Path):
|
|
218
368
|
# Guards the CLI wiring: --answers is non-interactive, --json is stable,
|
|
219
369
|
# and a second run without --force skips rather than clobbers.
|
quints-0.3.0/examples/.gitignore
DELETED
quints-0.3.0/examples/AGENTS.md
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
# Working on Example GmbH's books with an AI agent
|
|
2
|
-
|
|
3
|
-
These are plain-text ([beancount](https://beancount.github.io)) books managed
|
|
4
|
-
with [`quints`](https://github.com/sealambda/quints). **`quints` is a
|
|
5
|
-
deterministic tool — you drive it, it never calls a model.** Your job is to
|
|
6
|
-
extend and maintain the ledger; `quints` validates and reports on it.
|
|
7
|
-
|
|
8
|
-
## Setup
|
|
9
|
-
|
|
10
|
-
`uv sync` once — it installs quints, which brings beancount and fava along.
|
|
11
|
-
Then `uv run quints check` (or activate the venv and call `quints` and the
|
|
12
|
-
standard beancount tools directly).
|
|
13
|
-
|
|
14
|
-
## Layout
|
|
15
|
-
|
|
16
|
-
- `main.bean` — options, plugins, includes; the entry point every tool loads.
|
|
17
|
-
`plugin "quints.plugins.kmu"` is enabled: every `*:CH:GmbH:*` account
|
|
18
|
-
**must** be opened with a four-digit `kmu:` code (Swiss KMU Kontenrahmen).
|
|
19
|
-
- `accounts.bean` — the chart of accounts (all `open` directives).
|
|
20
|
-
- `books/2026.bean` — transactions, one file per fiscal year. `main.bean`
|
|
21
|
-
includes `books/*.bean`, so a new year just needs a new file.
|
|
22
|
-
- `commodities.bean` — currencies; `prices.bean` — FX rates, refresh with
|
|
23
|
-
`quints prices sync`.
|
|
24
|
-
- `quints.toml` — entity config (name, legal form, VAT, importer rules). VAT
|
|
25
|
-
*rates* are law and live in code, not here.
|
|
26
|
-
- `staging/` — importer drafts land here; `inbox/` — incoming source
|
|
27
|
-
documents; `documents/` — filed documents, mirroring the account tree.
|
|
28
|
-
|
|
29
|
-
## Extending the chart of accounts (the part that needs judgement)
|
|
30
|
-
|
|
31
|
-
Add income/expense sub-trees for this business as `open` directives in
|
|
32
|
-
`accounts.bean`, each with the KMU code it rolls up to, e.g.:
|
|
33
|
-
|
|
34
|
-
```beancount
|
|
35
|
-
2026-01-01 open Expenses:CH:GmbH:Marketing:Ads CHF
|
|
36
|
-
kmu: "6600" ; Advertising
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
Run `quints report konten` to see the codes already in use. Pick codes from the
|
|
40
|
-
KMU Kontenrahmen; `quints check` fails on a `:CH:GmbH:` account with no valid
|
|
41
|
-
`kmu:` code.
|
|
42
|
-
|
|
43
|
-
## The loop
|
|
44
|
-
|
|
45
|
-
1. Draft bank/PSP activity: `quints import ubs <file>` → `staging/`.
|
|
46
|
-
2. Review, add the VAT decision (InputVAT / Bezugsteuer / none) and a linked
|
|
47
|
-
document, then move drafts into `books/2026.bean`.
|
|
48
|
-
3. **Always** `quints check` before you consider the books consistent.
|
|
49
|
-
|
|
50
|
-
## Machine-readable surfaces (prefer these over scraping text)
|
|
51
|
-
|
|
52
|
-
- Every reporting command takes `--json`: `quints mwst -q 2026-Q3 --json`,
|
|
53
|
-
`quints status --json`, `quints report bilanz --json`.
|
|
54
|
-
- `quints schema` writes JSON Schemas for the invoice/issuer/customer files.
|
|
55
|
-
|
|
56
|
-
Never invent VAT numbers or rates — compute them with `quints mwst`.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|