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.
- {quints-0.3.0 → quints-0.4.1}/PKG-INFO +3 -1
- {quints-0.3.0 → quints-0.4.1}/README.md +2 -0
- quints-0.4.1/examples/.gitignore +8 -0
- quints-0.4.1/examples/AGENTS.md +120 -0
- quints-0.4.1/examples/CLAUDE.md +1 -0
- {quints-0.3.0 → quints-0.4.1}/examples/invoicing/acme-2026-07.yaml +1 -0
- {quints-0.3.0 → quints-0.4.1}/examples/invoicing/customers.yaml +1 -0
- {quints-0.3.0 → quints-0.4.1}/examples/invoicing/globex-2026-08.yaml +1 -0
- {quints-0.3.0 → quints-0.4.1}/examples/invoicing/issuer.yaml +1 -0
- {quints-0.3.0 → quints-0.4.1}/pyproject.toml +1 -1
- quints-0.4.1/src/quints/__init__.py +7 -0
- {quints-0.3.0 → quints-0.4.1}/src/quints/cli.py +68 -13
- {quints-0.3.0 → quints-0.4.1}/src/quints/config.py +56 -38
- {quints-0.3.0 → quints-0.4.1}/src/quints/fava/__init__.py +28 -3
- {quints-0.3.0 → quints-0.4.1}/src/quints/fx.py +6 -3
- {quints-0.3.0 → quints-0.4.1}/src/quints/importing.py +59 -28
- {quints-0.3.0 → quints-0.4.1}/src/quints/inbox.py +3 -3
- {quints-0.3.0 → quints-0.4.1}/src/quints/init.py +183 -16
- {quints-0.3.0 → quints-0.4.1}/src/quints/invoice/draft.py +3 -2
- {quints-0.3.0 → quints-0.4.1}/src/quints/invoice/labels.py +1 -1
- {quints-0.3.0 → quints-0.4.1}/src/quints/invoice/model.py +30 -1
- {quints-0.3.0 → quints-0.4.1}/src/quints/invoice/qr.py +8 -3
- {quints-0.3.0 → quints-0.4.1}/src/quints/invoice/render.py +46 -15
- {quints-0.3.0 → quints-0.4.1}/src/quints/kmu.py +17 -7
- {quints-0.3.0 → quints-0.4.1}/src/quints/ledger.py +1 -1
- {quints-0.3.0 → quints-0.4.1}/src/quints/match.py +16 -11
- {quints-0.3.0 → quints-0.4.1}/src/quints/mwst.py +27 -11
- {quints-0.3.0 → quints-0.4.1}/src/quints/plugins/kmu.py +1 -1
- {quints-0.3.0 → quints-0.4.1}/src/quints/prices.py +13 -4
- {quints-0.3.0 → quints-0.4.1}/src/quints/receivables.py +28 -16
- {quints-0.3.0 → quints-0.4.1}/src/quints/report_pdf.py +19 -7
- {quints-0.3.0 → quints-0.4.1}/src/quints/settlement.py +30 -14
- {quints-0.3.0 → quints-0.4.1}/src/quints/vat.py +3 -1
- {quints-0.3.0 → quints-0.4.1}/tests/test_cli_json.py +4 -3
- {quints-0.3.0 → quints-0.4.1}/tests/test_config.py +16 -12
- {quints-0.3.0 → quints-0.4.1}/tests/test_docs.py +26 -1
- {quints-0.3.0 → quints-0.4.1}/tests/test_fava_ext.py +3 -1
- {quints-0.3.0 → quints-0.4.1}/tests/test_fx.py +5 -4
- {quints-0.3.0 → quints-0.4.1}/tests/test_importing.py +18 -11
- {quints-0.3.0 → quints-0.4.1}/tests/test_inbox_match.py +19 -3
- {quints-0.3.0 → quints-0.4.1}/tests/test_init.py +150 -0
- {quints-0.3.0 → quints-0.4.1}/tests/test_invoice.py +17 -15
- {quints-0.3.0 → quints-0.4.1}/tests/test_kmu_report.py +7 -6
- {quints-0.3.0 → quints-0.4.1}/tests/test_mwst.py +5 -4
- {quints-0.3.0 → quints-0.4.1}/tests/test_prices_sync.py +19 -12
- {quints-0.3.0 → quints-0.4.1}/tests/test_receivables.py +4 -3
- quints-0.4.1/tests/test_report_pdf.py +87 -0
- {quints-0.3.0 → quints-0.4.1}/tests/test_settlement.py +22 -21
- 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/tests/test_report_pdf.py +0 -46
- {quints-0.3.0 → quints-0.4.1}/.gitignore +0 -0
- {quints-0.3.0 → quints-0.4.1}/LICENSE +0 -0
- {quints-0.3.0 → quints-0.4.1}/examples/accounts.bean +0 -0
- {quints-0.3.0 → quints-0.4.1}/examples/answers.toml +0 -0
- {quints-0.3.0 → quints-0.4.1}/examples/books/2026.bean +0 -0
- {quints-0.3.0 → quints-0.4.1}/examples/commodities.bean +0 -0
- {quints-0.3.0 → quints-0.4.1}/examples/documents/.gitkeep +0 -0
- {quints-0.3.0 → quints-0.4.1}/examples/inbox/.gitkeep +0 -0
- {quints-0.3.0 → quints-0.4.1}/examples/main.bean +0 -0
- {quints-0.3.0 → quints-0.4.1}/examples/prices.bean +0 -0
- {quints-0.3.0 → quints-0.4.1}/examples/pyproject.toml +0 -0
- {quints-0.3.0 → quints-0.4.1}/examples/quints.toml +0 -0
- {quints-0.3.0 → quints-0.4.1}/examples/staging/.gitkeep +0 -0
- {quints-0.3.0 → quints-0.4.1}/examples/statements/ubs-2026.mt940 +0 -0
- {quints-0.3.0 → quints-0.4.1}/src/quints/fava/templates/QuintDashboard.html +0 -0
- {quints-0.3.0 → quints-0.4.1}/src/quints/invoice/__init__.py +0 -0
- {quints-0.3.0 → quints-0.4.1}/src/quints/invoice/template.typ +0 -0
- {quints-0.3.0 → quints-0.4.1}/src/quints/invoice/vatid.py +0 -0
- {quints-0.3.0 → quints-0.4.1}/src/quints/invoice/verify.py +0 -0
- {quints-0.3.0 → quints-0.4.1}/src/quints/plugins/__init__.py +0 -0
- {quints-0.3.0 → quints-0.4.1}/src/quints/report.typ +0 -0
- {quints-0.3.0 → quints-0.4.1}/src/quints/ui.py +0 -0
- {quints-0.3.0 → quints-0.4.1}/tests/fixtures/transactions.mt940 +0 -0
- {quints-0.3.0 → quints-0.4.1}/tests/test_kmu_plugin.py +0 -0
- {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
|
+
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
|
+

|
|
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.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
|
|
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(
|
|
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
|
|
549
|
+
except WiseError as e:
|
|
526
550
|
typer.secho(f"ERROR: {e}", fg="red", err=True)
|
|
527
|
-
if isinstance(e,
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
123
|
-
|
|
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(
|
|
136
|
+
return tuple(sorted(accounts.items()))
|
|
126
137
|
|
|
127
138
|
|
|
128
|
-
def _import_sections(raw: dict) -> dict:
|
|
129
|
-
imports = raw.get("import")
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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=
|
|
135
|
-
iban=
|
|
136
|
-
currency=
|
|
137
|
-
rules=_rules(
|
|
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
|
-
|
|
140
|
-
|
|
152
|
+
wise = imports.get("wise")
|
|
153
|
+
if isinstance(wise, dict):
|
|
141
154
|
updates["import_wise"] = WiseImport(
|
|
142
|
-
accounts=_accounts_map(
|
|
143
|
-
fees_account=
|
|
144
|
-
holder=
|
|
145
|
-
rules=_rules(
|
|
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
|
-
|
|
148
|
-
|
|
160
|
+
stripe = imports.get("stripe")
|
|
161
|
+
if isinstance(stripe, dict):
|
|
149
162
|
updates["import_stripe"] = StripeImport(
|
|
150
|
-
accounts=_accounts_map(
|
|
151
|
-
fees_account=
|
|
152
|
-
tax_account=
|
|
153
|
-
account_id=
|
|
154
|
-
rules=_rules(
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
184
|
+
def take(section: dict[str, object], key: str, field: str) -> None:
|
|
168
185
|
if key in section:
|
|
169
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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(
|
|
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
|
-
|
|
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")))
|