quints 0.3.0__tar.gz → 0.4.1__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 (77) hide show
  1. {quints-0.3.0 → quints-0.4.1}/PKG-INFO +3 -1
  2. {quints-0.3.0 → quints-0.4.1}/README.md +2 -0
  3. quints-0.4.1/examples/.gitignore +8 -0
  4. quints-0.4.1/examples/AGENTS.md +120 -0
  5. quints-0.4.1/examples/CLAUDE.md +1 -0
  6. {quints-0.3.0 → quints-0.4.1}/examples/invoicing/acme-2026-07.yaml +1 -0
  7. {quints-0.3.0 → quints-0.4.1}/examples/invoicing/customers.yaml +1 -0
  8. {quints-0.3.0 → quints-0.4.1}/examples/invoicing/globex-2026-08.yaml +1 -0
  9. {quints-0.3.0 → quints-0.4.1}/examples/invoicing/issuer.yaml +1 -0
  10. {quints-0.3.0 → quints-0.4.1}/pyproject.toml +1 -1
  11. quints-0.4.1/src/quints/__init__.py +7 -0
  12. {quints-0.3.0 → quints-0.4.1}/src/quints/cli.py +68 -13
  13. {quints-0.3.0 → quints-0.4.1}/src/quints/config.py +56 -38
  14. {quints-0.3.0 → quints-0.4.1}/src/quints/fava/__init__.py +28 -3
  15. {quints-0.3.0 → quints-0.4.1}/src/quints/fx.py +6 -3
  16. {quints-0.3.0 → quints-0.4.1}/src/quints/importing.py +59 -28
  17. {quints-0.3.0 → quints-0.4.1}/src/quints/inbox.py +3 -3
  18. {quints-0.3.0 → quints-0.4.1}/src/quints/init.py +183 -16
  19. {quints-0.3.0 → quints-0.4.1}/src/quints/invoice/draft.py +3 -2
  20. {quints-0.3.0 → quints-0.4.1}/src/quints/invoice/labels.py +1 -1
  21. {quints-0.3.0 → quints-0.4.1}/src/quints/invoice/model.py +30 -1
  22. {quints-0.3.0 → quints-0.4.1}/src/quints/invoice/qr.py +8 -3
  23. {quints-0.3.0 → quints-0.4.1}/src/quints/invoice/render.py +46 -15
  24. {quints-0.3.0 → quints-0.4.1}/src/quints/kmu.py +17 -7
  25. {quints-0.3.0 → quints-0.4.1}/src/quints/ledger.py +1 -1
  26. {quints-0.3.0 → quints-0.4.1}/src/quints/match.py +16 -11
  27. {quints-0.3.0 → quints-0.4.1}/src/quints/mwst.py +27 -11
  28. {quints-0.3.0 → quints-0.4.1}/src/quints/plugins/kmu.py +1 -1
  29. {quints-0.3.0 → quints-0.4.1}/src/quints/prices.py +13 -4
  30. {quints-0.3.0 → quints-0.4.1}/src/quints/receivables.py +28 -16
  31. {quints-0.3.0 → quints-0.4.1}/src/quints/report_pdf.py +19 -7
  32. {quints-0.3.0 → quints-0.4.1}/src/quints/settlement.py +30 -14
  33. {quints-0.3.0 → quints-0.4.1}/src/quints/vat.py +3 -1
  34. {quints-0.3.0 → quints-0.4.1}/tests/test_cli_json.py +4 -3
  35. {quints-0.3.0 → quints-0.4.1}/tests/test_config.py +16 -12
  36. {quints-0.3.0 → quints-0.4.1}/tests/test_docs.py +26 -1
  37. {quints-0.3.0 → quints-0.4.1}/tests/test_fava_ext.py +3 -1
  38. {quints-0.3.0 → quints-0.4.1}/tests/test_fx.py +5 -4
  39. {quints-0.3.0 → quints-0.4.1}/tests/test_importing.py +18 -11
  40. {quints-0.3.0 → quints-0.4.1}/tests/test_inbox_match.py +19 -3
  41. {quints-0.3.0 → quints-0.4.1}/tests/test_init.py +150 -0
  42. {quints-0.3.0 → quints-0.4.1}/tests/test_invoice.py +17 -15
  43. {quints-0.3.0 → quints-0.4.1}/tests/test_kmu_report.py +7 -6
  44. {quints-0.3.0 → quints-0.4.1}/tests/test_mwst.py +5 -4
  45. {quints-0.3.0 → quints-0.4.1}/tests/test_prices_sync.py +19 -12
  46. {quints-0.3.0 → quints-0.4.1}/tests/test_receivables.py +4 -3
  47. quints-0.4.1/tests/test_report_pdf.py +87 -0
  48. {quints-0.3.0 → quints-0.4.1}/tests/test_settlement.py +22 -21
  49. quints-0.3.0/examples/.gitignore +0 -8
  50. quints-0.3.0/examples/AGENTS.md +0 -56
  51. quints-0.3.0/src/quints/__init__.py +0 -3
  52. quints-0.3.0/tests/test_report_pdf.py +0 -46
  53. {quints-0.3.0 → quints-0.4.1}/.gitignore +0 -0
  54. {quints-0.3.0 → quints-0.4.1}/LICENSE +0 -0
  55. {quints-0.3.0 → quints-0.4.1}/examples/accounts.bean +0 -0
  56. {quints-0.3.0 → quints-0.4.1}/examples/answers.toml +0 -0
  57. {quints-0.3.0 → quints-0.4.1}/examples/books/2026.bean +0 -0
  58. {quints-0.3.0 → quints-0.4.1}/examples/commodities.bean +0 -0
  59. {quints-0.3.0 → quints-0.4.1}/examples/documents/.gitkeep +0 -0
  60. {quints-0.3.0 → quints-0.4.1}/examples/inbox/.gitkeep +0 -0
  61. {quints-0.3.0 → quints-0.4.1}/examples/main.bean +0 -0
  62. {quints-0.3.0 → quints-0.4.1}/examples/prices.bean +0 -0
  63. {quints-0.3.0 → quints-0.4.1}/examples/pyproject.toml +0 -0
  64. {quints-0.3.0 → quints-0.4.1}/examples/quints.toml +0 -0
  65. {quints-0.3.0 → quints-0.4.1}/examples/staging/.gitkeep +0 -0
  66. {quints-0.3.0 → quints-0.4.1}/examples/statements/ubs-2026.mt940 +0 -0
  67. {quints-0.3.0 → quints-0.4.1}/src/quints/fava/templates/QuintDashboard.html +0 -0
  68. {quints-0.3.0 → quints-0.4.1}/src/quints/invoice/__init__.py +0 -0
  69. {quints-0.3.0 → quints-0.4.1}/src/quints/invoice/template.typ +0 -0
  70. {quints-0.3.0 → quints-0.4.1}/src/quints/invoice/vatid.py +0 -0
  71. {quints-0.3.0 → quints-0.4.1}/src/quints/invoice/verify.py +0 -0
  72. {quints-0.3.0 → quints-0.4.1}/src/quints/plugins/__init__.py +0 -0
  73. {quints-0.3.0 → quints-0.4.1}/src/quints/report.typ +0 -0
  74. {quints-0.3.0 → quints-0.4.1}/src/quints/ui.py +0 -0
  75. {quints-0.3.0 → quints-0.4.1}/tests/fixtures/transactions.mt940 +0 -0
  76. {quints-0.3.0 → quints-0.4.1}/tests/test_kmu_plugin.py +0 -0
  77. {quints-0.3.0 → quints-0.4.1}/tests/test_ledger.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.1
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.1"
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")
@@ -2,13 +2,18 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from collections.abc import Callable
5
6
  from dataclasses import replace
6
7
  from datetime import date as Date
7
8
  from decimal import Decimal, InvalidOperation
8
9
  from pathlib import Path
10
+ from typing import TYPE_CHECKING
9
11
 
10
12
  import typer
11
13
 
14
+ if TYPE_CHECKING:
15
+ from _typeshed import DataclassInstance
16
+
12
17
  from . import (
13
18
  config as config_mod,
14
19
  )
@@ -57,11 +62,26 @@ app = typer.Typer(
57
62
  )
58
63
 
59
64
 
65
+ def _print_version(value: bool) -> None:
66
+ if value:
67
+ from . import __version__
68
+
69
+ typer.echo(__version__)
70
+ raise typer.Exit()
71
+
72
+
60
73
  @app.callback()
61
- def _main(
74
+ def root(
62
75
  config: Path | None = typer.Option(
63
76
  None, "--config", help="quints.toml path (default: ./quints.toml, else built-in defaults)."
64
77
  ),
78
+ version: bool = typer.Option(
79
+ False,
80
+ "--version",
81
+ help="Print the quints version and exit.",
82
+ callback=_print_version,
83
+ is_eager=True,
84
+ ),
65
85
  ):
66
86
  config_mod.set_path(config)
67
87
 
@@ -88,7 +108,9 @@ def _lang_option() -> str:
88
108
  )
89
109
 
90
110
 
91
- def _emit(report, render, lang: str | None, as_json: bool) -> None:
111
+ def _emit(
112
+ report: DataclassInstance, render: Callable[..., object], lang: str | None, as_json: bool
113
+ ) -> None:
92
114
  lang = lang or config_mod.get().report_language
93
115
  if as_json:
94
116
  import dataclasses
@@ -111,7 +133,7 @@ def _parse_date(text: str) -> Date:
111
133
  raise typer.Exit(1) from None
112
134
 
113
135
 
114
- def _json_out(payload) -> None:
136
+ def _json_out(payload: object) -> None:
115
137
  """Emit a machine-readable payload (Decimals/dates as strings)."""
116
138
  import json
117
139
 
@@ -520,11 +542,13 @@ def import_wise(
520
542
  raise typer.Exit(1)
521
543
  _parse_date(from_)
522
544
  _parse_date(to)
545
+ from beangulp_wise import ScaChallenge, WiseError
546
+
523
547
  try:
524
548
  statements = importing_mod.fetch_wise(from_, to, out)
525
- except importing_mod.WiseError as e:
549
+ except WiseError as e:
526
550
  typer.secho(f"ERROR: {e}", fg="red", err=True)
527
- if isinstance(e, importing_mod.ScaChallenge):
551
+ if isinstance(e, ScaChallenge):
528
552
  typer.secho(
529
553
  "Statements are SCA-protected: upload .wise/public.pem in Wise "
530
554
  "(Settings → API tokens → Manage public keys) and set "
@@ -593,18 +617,19 @@ def import_stripe(
593
617
  _report_import(result, as_json)
594
618
 
595
619
 
596
- def _report_import(result, as_json: bool = False) -> None:
620
+ def _report_import(result: importing_mod.ImportResult, as_json: bool = False) -> None:
597
621
  if as_json:
622
+ from beancount.core import data
598
623
 
599
- def txn(t):
624
+ def txn(t: data.Transaction) -> dict[str, object]:
600
625
  u = t.postings[0].units
601
626
  return {
602
627
  "date": str(t.date),
603
628
  "flag": t.flag,
604
629
  "payee": t.payee,
605
630
  "narration": t.narration,
606
- "amount": u.number,
607
- "currency": u.currency,
631
+ "amount": u.number if u else None,
632
+ "currency": u.currency if u else None,
608
633
  }
609
634
 
610
635
  _json_out(
@@ -702,7 +727,10 @@ def check(
702
727
  def invoice(
703
728
  data: Path = typer.Argument(..., help="Invoice file (.yaml/.toml/.json)."),
704
729
  out: Path | None = typer.Option(
705
- None, "--out", "-o", help="Output PDF (default: <number>.pdf)."
730
+ None,
731
+ "--out",
732
+ "-o",
733
+ help="Output PDF (default: filed under documents/<income account>/).",
706
734
  ),
707
735
  issuer: Path = typer.Option(
708
736
  Path("invoicing/issuer.yaml"), "--issuer", help="Issuer config (.yaml/.toml/.json)."
@@ -734,7 +762,12 @@ def invoice(
734
762
  registry = m.load_customers(customers) if customers.exists() else None
735
763
  inv = m.load_invoice(data, registry)
736
764
  iss = m.load_issuer(issuer)
737
- out = out or Path(f"{inv.number}.pdf")
765
+ if out is None:
766
+ # File the PDF the way beancount documents are filed: under the income
767
+ # account's folder, date-prefixed, next to the ledger's other evidence.
768
+ cfg = config_mod.get()
769
+ account = cfg.income_export if inv.kind == "export" else cfg.income_domestic
770
+ out = m.document_path(inv, account)
738
771
  path, totals, payload = r.render(inv, iss, out)
739
772
 
740
773
  qr_ok = None
@@ -766,6 +799,8 @@ def invoice(
766
799
  print(ledger_draft)
767
800
  print()
768
801
  elif cc.ok:
802
+ if cc.ledger_total is None: # unreachable: found implies totals are set
803
+ raise ValueError(f"cross-check for {inv.number} lost its ledger total")
769
804
  ui.console.print(
770
805
  f"[ok]Ledger match[/] ({cc.date}): {inv.currency} {m.money(cc.ledger_total)}"
771
806
  )
@@ -774,6 +809,9 @@ def invoice(
774
809
  f"[warn]booking date {cc.date} ≠ invoice date {inv.issue_date}[/]"
775
810
  )
776
811
  else:
812
+ if cc.ledger_total is None or cc.invoice_total is None:
813
+ # unreachable: found implies totals are set, but narrows the Optionals
814
+ raise ValueError(f"cross-check for {inv.number} lost its totals")
777
815
  ui.console.print(
778
816
  f"[err]Ledger CONFLICT[/] ({cc.date}): {inv.number} is already booked "
779
817
  f"at {m.money(cc.ledger_total)} but the invoice says "
@@ -789,7 +827,7 @@ def invoice(
789
827
  "kind": inv.kind,
790
828
  "currency": inv.currency,
791
829
  "issue_date": inv.issue_date,
792
- "customer": inv.customer.name,
830
+ "customer": inv.resolved_customer.name,
793
831
  "pdf": str(path),
794
832
  "totals": totals.model_dump(),
795
833
  "qr_payload_ok": qr_ok,
@@ -808,7 +846,9 @@ def schema(
808
846
  """Write JSON Schemas for the invoice, issuer, and customers files.
809
847
 
810
848
  Point an editor at them (yaml-language-server modeline) for completion
811
- and validation; any future UI can consume the same contract."""
849
+ and validation; any future UI can consume the same contract. The same
850
+ schemas are published on the docs site, so scaffolded YAMLs reference
851
+ them without running this command."""
812
852
  import json as _json
813
853
 
814
854
  from .invoice import model as m
@@ -822,6 +862,7 @@ def schema(
822
862
  path = out / f"{name}.schema.json"
823
863
  path.write_text(_json.dumps(mdl.model_json_schema(), indent=2) + "\n")
824
864
  ui.console.print(f"[ok]Wrote[/] {path}")
865
+ ui.console.print(f"[muted]Also hosted at {config_mod.DOCS_URL}/schema/[/]")
825
866
 
826
867
 
827
868
  @app.command()
@@ -845,6 +886,11 @@ def init(
845
886
  answers_file: Path | None = typer.Option(
846
887
  None, "--answers", help="TOML answer-file for non-interactive scaffolding."
847
888
  ),
889
+ use_git: bool = typer.Option(
890
+ True,
891
+ "--git/--no-git",
892
+ help="git init + commit the pristine scaffold (skipped inside an existing repo).",
893
+ ),
848
894
  force: bool = typer.Option(False, "--force", help="Overwrite existing files."),
849
895
  yes: bool = typer.Option(False, "--yes", "-y", help="Skip prompts; accept defaults."),
850
896
  as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
@@ -908,14 +954,18 @@ def init(
908
954
  typer.secho(f"ERROR: {e}", fg="red", err=True)
909
955
  raise typer.Exit(1) from None
910
956
  result = init_mod.write(directory, files, force=force)
957
+ git_result = init_mod.init_git(directory) if use_git and result.written else None
911
958
 
912
959
  if as_json:
960
+ import dataclasses
961
+
913
962
  _json_out(
914
963
  {
915
964
  "directory": str(directory),
916
965
  "entity": answers.entity_name,
917
966
  "written": [str(p) for p in result.written],
918
967
  "skipped": [str(p) for p in result.skipped],
968
+ "git": dataclasses.asdict(git_result) if git_result else None,
919
969
  }
920
970
  )
921
971
  return
@@ -923,6 +973,11 @@ def init(
923
973
  ui.console.print(f"[ok]created[/] {path}")
924
974
  for path in result.skipped:
925
975
  ui.console.print(f"[warn]exists, skipped[/] {path} (use --force to overwrite)")
976
+ if git_result:
977
+ if git_result.committed:
978
+ ui.console.print("[ok]git[/] initialised repository, committed the scaffold")
979
+ else:
980
+ ui.console.print(f"[warn]git[/] {git_result.detail}")
926
981
  if result.written and not result.skipped:
927
982
  ui.console.print(
928
983
  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;
@@ -115,67 +120,80 @@ class Config:
115
120
  import_stripe: StripeImport | None = None
116
121
 
117
122
 
118
- def _rules(section: dict) -> tuple[tuple[str, str, str], ...]:
119
- return tuple((r[0], r[1], r[2]) for r in section.get("rules", ()))
123
+ def _rules(section: dict[str, object]) -> tuple[tuple[str, str, str], ...]:
124
+ rules = section.get("rules")
125
+ if not isinstance(rules, list):
126
+ return ()
127
+ return tuple((r[0], r[1], r[2]) for r in rules)
120
128
 
121
129
 
122
- def _accounts_map(section: dict, default) -> tuple[tuple[str, str], ...]:
123
- if "accounts" not in section:
130
+ def _accounts_map(
131
+ section: dict[str, object], default: tuple[tuple[str, str], ...]
132
+ ) -> tuple[tuple[str, str], ...]:
133
+ accounts = section.get("accounts")
134
+ if not isinstance(accounts, dict):
124
135
  return default
125
- return tuple(sorted(section["accounts"].items()))
136
+ return tuple(sorted(accounts.items()))
126
137
 
127
138
 
128
- def _import_sections(raw: dict) -> dict:
129
- imports = raw.get("import") or {}
130
- updates: dict = {}
131
- if "ubs" in imports:
132
- s = imports["ubs"]
139
+ def _import_sections(raw: dict[str, object]) -> dict[str, object]:
140
+ imports = raw.get("import")
141
+ if not isinstance(imports, dict):
142
+ return {}
143
+ updates: dict[str, object] = {}
144
+ ubs = imports.get("ubs")
145
+ if isinstance(ubs, dict):
133
146
  updates["import_ubs"] = UbsImport(
134
- account=s.get("account", UbsImport.account),
135
- iban=s.get("iban"),
136
- currency=s.get("currency", UbsImport.currency),
137
- rules=_rules(s),
147
+ account=ubs.get("account", UbsImport.account),
148
+ iban=ubs.get("iban"),
149
+ currency=ubs.get("currency", UbsImport.currency),
150
+ rules=_rules(ubs),
138
151
  )
139
- if "wise" in imports:
140
- s = imports["wise"]
152
+ wise = imports.get("wise")
153
+ if isinstance(wise, dict):
141
154
  updates["import_wise"] = WiseImport(
142
- accounts=_accounts_map(s, WiseImport.accounts),
143
- fees_account=s.get("fees_account", WiseImport.fees_account),
144
- holder=s.get("holder"),
145
- rules=_rules(s),
155
+ accounts=_accounts_map(wise, WiseImport.accounts),
156
+ fees_account=wise.get("fees_account", WiseImport.fees_account),
157
+ holder=wise.get("holder"),
158
+ rules=_rules(wise),
146
159
  )
147
- if "stripe" in imports:
148
- s = imports["stripe"]
160
+ stripe = imports.get("stripe")
161
+ if isinstance(stripe, dict):
149
162
  updates["import_stripe"] = StripeImport(
150
- accounts=_accounts_map(s, StripeImport.accounts),
151
- fees_account=s.get("fees_account", StripeImport.fees_account),
152
- tax_account=s.get("tax_account", StripeImport.tax_account),
153
- account_id=s.get("account_id"),
154
- rules=_rules(s),
163
+ accounts=_accounts_map(stripe, StripeImport.accounts),
164
+ fees_account=stripe.get("fees_account", StripeImport.fees_account),
165
+ tax_account=stripe.get("tax_account", StripeImport.tax_account),
166
+ account_id=stripe.get("account_id"),
167
+ rules=_rules(stripe),
155
168
  )
156
169
  return updates
157
170
 
158
171
 
159
- def _from_mapping(raw: dict) -> Config:
160
- entity = raw.get("entity") or {}
161
- ledger_ = raw.get("ledger") or {}
162
- accounts = raw.get("accounts") or {}
163
- report = raw.get("report") or {}
172
+ def _from_mapping(raw: dict[str, object]) -> Config:
173
+ def section(key: str) -> dict[str, object]:
174
+ value = raw.get(key)
175
+ return value if isinstance(value, dict) else {}
176
+
177
+ entity = section("entity")
178
+ ledger_ = section("ledger")
179
+ accounts = section("accounts")
180
+ report = section("report")
164
181
  cfg = Config()
165
- updates: dict = {}
182
+ updates: dict[str, object] = {}
166
183
 
167
- def take(section: dict, key: str, field: str, cast=None):
184
+ def take(section: dict[str, object], key: str, field: str) -> None:
168
185
  if key in section:
169
- value = section[key]
170
- updates[field] = cast(value) if cast else value
186
+ updates[field] = section[key]
171
187
 
172
188
  take(entity, "name", "entity_name")
173
189
  take(entity, "legal_form", "legal_form")
174
190
  take(entity, "vat_method", "vat_method")
175
191
  take(entity, "vat_registered_since", "vat_registered_since")
176
192
  take(entity, "operating_currency", "operating_currency")
177
- take(ledger_, "main", "ledger_main", Path)
178
- take(ledger_, "prices", "ledger_prices", Path)
193
+ if "main" in ledger_:
194
+ updates["ledger_main"] = Path(str(ledger_["main"]))
195
+ if "prices" in ledger_:
196
+ updates["ledger_prices"] = Path(str(ledger_["prices"]))
179
197
  for key in (
180
198
  "input_vat",
181
199
  "output_vat",
@@ -12,9 +12,12 @@ Enable in the beancount file:
12
12
  from __future__ import annotations
13
13
 
14
14
  import re
15
+ from collections.abc import Sequence
15
16
  from datetime import datetime, timezone
16
17
  from pathlib import Path
17
18
 
19
+ from beancount.core import data
20
+ from fava.beans import abc as fava_abc
18
21
  from fava.ext import FavaExtensionBase
19
22
 
20
23
  from .. import config, receivables, settlement
@@ -22,6 +25,26 @@ from .. import inbox as inbox_mod
22
25
 
23
26
  _DRAFT_LINE = re.compile(r"^\d{4}-\d{2}-\d{2} +([*!]) ", re.M)
24
27
 
28
+ _BEANCOUNT_DIRECTIVES = (
29
+ data.Open,
30
+ data.Close,
31
+ data.Commodity,
32
+ data.Pad,
33
+ data.Balance,
34
+ data.Transaction,
35
+ data.Note,
36
+ data.Event,
37
+ data.Query,
38
+ data.Price,
39
+ data.Document,
40
+ data.Custom,
41
+ )
42
+
43
+
44
+ def _entries(entries: Sequence[fava_abc.Directive]) -> data.Directives:
45
+ """Re-type fava's ``abc.Directive`` view of the same beancount objects."""
46
+ return [e for e in entries if isinstance(e, _BEANCOUNT_DIRECTIVES)]
47
+
25
48
 
26
49
  class QuintDashboard(FavaExtensionBase):
27
50
  """VAT, receivables, and review-queue panel."""
@@ -46,11 +69,13 @@ class QuintDashboard(FavaExtensionBase):
46
69
  return settlement.outstanding(
47
70
  Path(self.ledger.beancount_file_path),
48
71
  cfg=self._cfg,
49
- entries=self.ledger.all_entries,
72
+ entries=_entries(self.ledger.all_entries),
50
73
  )
51
74
 
52
75
  def open_receivables(self):
53
- return receivables.compute_from_entries(self.ledger.all_entries, self.today(), self._cfg)
76
+ return receivables.compute_from_entries(
77
+ _entries(self.ledger.all_entries), self.today(), self._cfg
78
+ )
54
79
 
55
80
  def staging(self):
56
81
  """Pending staging drafts: (file name, total drafts, flagged drafts)."""
@@ -62,4 +87,4 @@ class QuintDashboard(FavaExtensionBase):
62
87
 
63
88
  def inbox(self):
64
89
  """Inbox inventory (hints + duplicate/linked status) — see quints.inbox."""
65
- return inbox_mod.scan(self._root, self.ledger.all_entries)
90
+ return inbox_mod.scan(self._root, _entries(self.ledger.all_entries))
@@ -73,7 +73,7 @@ def compute(ledger_path: Path, at: str, cfg: config.Config | None = None) -> lis
73
73
  root = p.account.split(":", 1)[0]
74
74
  if cfg.entity_marker not in p.account or root not in ("Assets", "Liabilities"):
75
75
  continue
76
- if p.units.currency == "CHF":
76
+ if p.units is None or p.units.currency == "CHF":
77
77
  continue
78
78
  inventories.setdefault(p.account, Inventory()).add_position(p)
79
79
  weight = bc_convert.get_weight(p)
@@ -90,11 +90,14 @@ def compute(ledger_path: Path, at: str, cfg: config.Config | None = None) -> lis
90
90
  revaluations: list[Revaluation] = []
91
91
  for account, inv in sorted(inventories.items()):
92
92
  for pos in inv:
93
- units = pos.units.number.quantize(Decimal("0.01"))
93
+ number = pos.units.number
94
+ if number is None:
95
+ continue
96
+ units = number.quantize(Decimal("0.01"))
94
97
  if not units:
95
98
  continue
96
99
  rate_date, rate = ledger.rate(price_map, pos.units.currency, on)
97
- if rate is None:
100
+ if rate is None or rate_date is None:
98
101
  raise RateUnavailable(pos.units.currency, on)
99
102
  market = ledger.rappen(units * Decimal(rate))
100
103
  book_value = ledger.rappen(book.get((account, pos.units.currency), Decimal("0")))