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.
Files changed (76) hide show
  1. {quints-0.3.0 → quints-0.4.0}/PKG-INFO +3 -1
  2. {quints-0.3.0 → quints-0.4.0}/README.md +2 -0
  3. quints-0.4.0/examples/.gitignore +8 -0
  4. quints-0.4.0/examples/AGENTS.md +120 -0
  5. quints-0.4.0/examples/CLAUDE.md +1 -0
  6. {quints-0.3.0 → quints-0.4.0}/examples/invoicing/acme-2026-07.yaml +1 -0
  7. {quints-0.3.0 → quints-0.4.0}/examples/invoicing/customers.yaml +1 -0
  8. {quints-0.3.0 → quints-0.4.0}/examples/invoicing/globex-2026-08.yaml +1 -0
  9. {quints-0.3.0 → quints-0.4.0}/examples/invoicing/issuer.yaml +1 -0
  10. {quints-0.3.0 → quints-0.4.0}/pyproject.toml +1 -1
  11. quints-0.4.0/src/quints/__init__.py +7 -0
  12. {quints-0.3.0 → quints-0.4.0}/src/quints/cli.py +43 -3
  13. {quints-0.3.0 → quints-0.4.0}/src/quints/config.py +5 -0
  14. {quints-0.3.0 → quints-0.4.0}/src/quints/init.py +183 -16
  15. {quints-0.3.0 → quints-0.4.0}/src/quints/invoice/model.py +15 -0
  16. {quints-0.3.0 → quints-0.4.0}/tests/test_docs.py +26 -1
  17. {quints-0.3.0 → quints-0.4.0}/tests/test_init.py +150 -0
  18. quints-0.3.0/examples/.gitignore +0 -8
  19. quints-0.3.0/examples/AGENTS.md +0 -56
  20. quints-0.3.0/src/quints/__init__.py +0 -3
  21. {quints-0.3.0 → quints-0.4.0}/.gitignore +0 -0
  22. {quints-0.3.0 → quints-0.4.0}/LICENSE +0 -0
  23. {quints-0.3.0 → quints-0.4.0}/examples/accounts.bean +0 -0
  24. {quints-0.3.0 → quints-0.4.0}/examples/answers.toml +0 -0
  25. {quints-0.3.0 → quints-0.4.0}/examples/books/2026.bean +0 -0
  26. {quints-0.3.0 → quints-0.4.0}/examples/commodities.bean +0 -0
  27. {quints-0.3.0 → quints-0.4.0}/examples/documents/.gitkeep +0 -0
  28. {quints-0.3.0 → quints-0.4.0}/examples/inbox/.gitkeep +0 -0
  29. {quints-0.3.0 → quints-0.4.0}/examples/main.bean +0 -0
  30. {quints-0.3.0 → quints-0.4.0}/examples/prices.bean +0 -0
  31. {quints-0.3.0 → quints-0.4.0}/examples/pyproject.toml +0 -0
  32. {quints-0.3.0 → quints-0.4.0}/examples/quints.toml +0 -0
  33. {quints-0.3.0 → quints-0.4.0}/examples/staging/.gitkeep +0 -0
  34. {quints-0.3.0 → quints-0.4.0}/examples/statements/ubs-2026.mt940 +0 -0
  35. {quints-0.3.0 → quints-0.4.0}/src/quints/fava/__init__.py +0 -0
  36. {quints-0.3.0 → quints-0.4.0}/src/quints/fava/templates/QuintDashboard.html +0 -0
  37. {quints-0.3.0 → quints-0.4.0}/src/quints/fx.py +0 -0
  38. {quints-0.3.0 → quints-0.4.0}/src/quints/importing.py +0 -0
  39. {quints-0.3.0 → quints-0.4.0}/src/quints/inbox.py +0 -0
  40. {quints-0.3.0 → quints-0.4.0}/src/quints/invoice/__init__.py +0 -0
  41. {quints-0.3.0 → quints-0.4.0}/src/quints/invoice/draft.py +0 -0
  42. {quints-0.3.0 → quints-0.4.0}/src/quints/invoice/labels.py +0 -0
  43. {quints-0.3.0 → quints-0.4.0}/src/quints/invoice/qr.py +0 -0
  44. {quints-0.3.0 → quints-0.4.0}/src/quints/invoice/render.py +0 -0
  45. {quints-0.3.0 → quints-0.4.0}/src/quints/invoice/template.typ +0 -0
  46. {quints-0.3.0 → quints-0.4.0}/src/quints/invoice/vatid.py +0 -0
  47. {quints-0.3.0 → quints-0.4.0}/src/quints/invoice/verify.py +0 -0
  48. {quints-0.3.0 → quints-0.4.0}/src/quints/kmu.py +0 -0
  49. {quints-0.3.0 → quints-0.4.0}/src/quints/ledger.py +0 -0
  50. {quints-0.3.0 → quints-0.4.0}/src/quints/match.py +0 -0
  51. {quints-0.3.0 → quints-0.4.0}/src/quints/mwst.py +0 -0
  52. {quints-0.3.0 → quints-0.4.0}/src/quints/plugins/__init__.py +0 -0
  53. {quints-0.3.0 → quints-0.4.0}/src/quints/plugins/kmu.py +0 -0
  54. {quints-0.3.0 → quints-0.4.0}/src/quints/prices.py +0 -0
  55. {quints-0.3.0 → quints-0.4.0}/src/quints/receivables.py +0 -0
  56. {quints-0.3.0 → quints-0.4.0}/src/quints/report.typ +0 -0
  57. {quints-0.3.0 → quints-0.4.0}/src/quints/report_pdf.py +0 -0
  58. {quints-0.3.0 → quints-0.4.0}/src/quints/settlement.py +0 -0
  59. {quints-0.3.0 → quints-0.4.0}/src/quints/ui.py +0 -0
  60. {quints-0.3.0 → quints-0.4.0}/src/quints/vat.py +0 -0
  61. {quints-0.3.0 → quints-0.4.0}/tests/fixtures/transactions.mt940 +0 -0
  62. {quints-0.3.0 → quints-0.4.0}/tests/test_cli_json.py +0 -0
  63. {quints-0.3.0 → quints-0.4.0}/tests/test_config.py +0 -0
  64. {quints-0.3.0 → quints-0.4.0}/tests/test_fava_ext.py +0 -0
  65. {quints-0.3.0 → quints-0.4.0}/tests/test_fx.py +0 -0
  66. {quints-0.3.0 → quints-0.4.0}/tests/test_importing.py +0 -0
  67. {quints-0.3.0 → quints-0.4.0}/tests/test_inbox_match.py +0 -0
  68. {quints-0.3.0 → quints-0.4.0}/tests/test_invoice.py +0 -0
  69. {quints-0.3.0 → quints-0.4.0}/tests/test_kmu_plugin.py +0 -0
  70. {quints-0.3.0 → quints-0.4.0}/tests/test_kmu_report.py +0 -0
  71. {quints-0.3.0 → quints-0.4.0}/tests/test_ledger.py +0 -0
  72. {quints-0.3.0 → quints-0.4.0}/tests/test_mwst.py +0 -0
  73. {quints-0.3.0 → quints-0.4.0}/tests/test_prices_sync.py +0 -0
  74. {quints-0.3.0 → quints-0.4.0}/tests/test_receivables.py +0 -0
  75. {quints-0.3.0 → quints-0.4.0}/tests/test_report_pdf.py +0 -0
  76. {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.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
+ ![quints init scaffolds sample books, quints check validates them, and quints mwst prints the Form-310 VAT return](https://raw.githubusercontent.com/sealambda/quints/main/docs/assets/quickstart.gif)
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
+ ![quints init scaffolds sample books, quints check validates them, and quints mwst prints the Form-310 VAT return](https://raw.githubusercontent.com/sealambda/quints/main/docs/assets/quickstart.gif)
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,8 @@
1
+ # quints working directories. documents/ (filed sources and rendered
2
+ # invoices) is deliberately NOT ignored — the ledger links to it.
3
+ /staging/
4
+ .env
5
+ __pycache__/
6
+ .venv/
7
+ .DS_Store
8
+ .wise/
@@ -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/customers.schema.json
1
2
  # Customer registry — invoices reference these entries by key. A customer
2
3
  # is a flat entry, or a dated `versions` history for address changes.
3
4
  acme:
@@ -1,3 +1,4 @@
1
+ # yaml-language-server: $schema=https://sealambda.github.io/quints/schema/invoice.schema.json
1
2
  # Sample export invoice — foreign currency, reverse charge, no QR part.
2
3
  # Ties to the ^INV2026015 booking in books/2026.bean (500.00 EUR).
3
4
  number: INV2026015
@@ -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.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, "--out", "-o", help="Output PDF (default: <number>.pdf)."
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
- out = out or Path(f"{inv.number}.pdf")
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
- marker = _cfg(answers).entity_marker
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; `inbox/` — incoming source
676
- documents; `documents/` — filed documents, mirroring the account tree.
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
- Run `quints report konten` to see the codes already in use. Pick codes from the
689
- KMU Kontenrahmen; `quints check` fails on a `{marker}` account with no valid
690
- `kmu:` code.
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
- ## The loop
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
- 1. Draft bank/PSP activity: `quints import ubs <file>` → `staging/`.
695
- 2. Review, add the VAT decision (InputVAT / Bezugsteuer / none) and a linked
696
- document, then move drafts into `books/{year}.bean`.
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
- - Every reporting command takes `--json`: `quints mwst -q 2026-Q3 --json`,
702
- `quints status --json`, `quints report bilanz --json`.
703
- - `quints schema` writes JSON Schemas for the invoice/issuer/customer files.
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
- PAGES = [Path("README.md"), *DOC_PAGES]
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.
@@ -1,8 +0,0 @@
1
- # quints working directories
2
- /staging/
3
- .env
4
- __pycache__/
5
- *.pdf
6
- .venv/
7
- .DS_Store
8
- .wise/
@@ -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`.
@@ -1,3 +0,0 @@
1
- """quints — Swiss VAT & accounting for plain-text (beancount) books."""
2
-
3
- __version__ = "0.3.0"
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