quints 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quints/__init__.py +3 -0
- quints/cli.py +887 -0
- quints/config.py +214 -0
- quints/fava/__init__.py +65 -0
- quints/fava/templates/QuintDashboard.html +85 -0
- quints/fx.py +164 -0
- quints/importing.py +393 -0
- quints/inbox.py +127 -0
- quints/init.py +462 -0
- quints/invoice/__init__.py +1 -0
- quints/invoice/draft.py +38 -0
- quints/invoice/labels.py +84 -0
- quints/invoice/model.py +333 -0
- quints/invoice/qr.py +67 -0
- quints/invoice/render.py +163 -0
- quints/invoice/template.typ +140 -0
- quints/invoice/vatid.py +48 -0
- quints/invoice/verify.py +60 -0
- quints/kmu.py +712 -0
- quints/ledger.py +59 -0
- quints/match.py +245 -0
- quints/mwst.py +353 -0
- quints/plugins/__init__.py +1 -0
- quints/plugins/kmu.py +58 -0
- quints/prices.py +114 -0
- quints/receivables.py +132 -0
- quints/report.typ +55 -0
- quints/report_pdf.py +183 -0
- quints/settlement.py +225 -0
- quints/ui.py +34 -0
- quints/vat.py +88 -0
- quints-0.1.0.dist-info/METADATA +60 -0
- quints-0.1.0.dist-info/RECORD +36 -0
- quints-0.1.0.dist-info/WHEEL +4 -0
- quints-0.1.0.dist-info/entry_points.txt +2 -0
- quints-0.1.0.dist-info/licenses/LICENSE +339 -0
quints/init.py
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""Scaffold a new quints project: questionnaire answers → a runnable ledger.
|
|
2
|
+
|
|
3
|
+
This is deterministic on purpose. ``quints`` is a tool; the AI is always a
|
|
4
|
+
*caller* of it, never a callee. So ``init`` emits a **backbone** — the parts
|
|
5
|
+
of a Swiss GmbH's books that are a published standard, not a judgement call:
|
|
6
|
+
the directory layout, ``quints.toml``, the statutory accounts (InputVAT /
|
|
7
|
+
OutputVAT / Bezugsteuer / VAT-due, FX gain/loss) and a KMU Kontenrahmen
|
|
8
|
+
skeleton. Every ``*:CH:GmbH:*`` account is opened with a four-digit ``kmu:``
|
|
9
|
+
code (seeded from :data:`quints.kmu.KMU_NAMES`) so ``quints.plugins.kmu`` and
|
|
10
|
+
the statutory statements work out of the box.
|
|
11
|
+
|
|
12
|
+
The **leaves** — business-specific income/expense sub-trees, importer rules —
|
|
13
|
+
are left for an agent (Claude Code, Codex, the Agent SDK) to tailor by
|
|
14
|
+
conversation, guided by the ``AGENTS.md`` this emits into the project.
|
|
15
|
+
|
|
16
|
+
The compute layer (:func:`plan`) is presentation-free: answers in, an ordered
|
|
17
|
+
list of :class:`ScaffoldFile` out, no I/O. :func:`write` does the I/O. Running
|
|
18
|
+
:func:`plan` with ``include_samples=True`` produces the repo's ``examples/``
|
|
19
|
+
project, which doubles as the CI smoke test — so the example is regenerated,
|
|
20
|
+
never hand-maintained.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import sys
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from datetime import date as Date
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
if sys.version_info >= (3, 11):
|
|
32
|
+
import tomllib
|
|
33
|
+
else: # pragma: no cover
|
|
34
|
+
import tomli as tomllib
|
|
35
|
+
|
|
36
|
+
from . import config, kmu
|
|
37
|
+
|
|
38
|
+
# Accounts the backbone adds beyond the configurable set in `config.Config`.
|
|
39
|
+
_SHARE_CAPITAL = "Equity:CH:GmbH:Capital:Share"
|
|
40
|
+
_IT_HOSTING = "Expenses:CH:GmbH:IT:Hosting"
|
|
41
|
+
_PRIMARY_BANK = "Assets:CH:GmbH:Current:UBS:CHF"
|
|
42
|
+
_WISE_EUR = "Assets:CH:GmbH:Current:Wise:EUR"
|
|
43
|
+
|
|
44
|
+
_KNOWN_IMPORTERS = ("ubs", "wise", "stripe")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Answers:
|
|
49
|
+
"""The questionnaire result — everything ``plan`` needs, nothing more."""
|
|
50
|
+
|
|
51
|
+
entity_name: str = "Example GmbH"
|
|
52
|
+
vat_method: str = "effective" # "saldo" is not supported yet
|
|
53
|
+
vat_registered_since: Date | None = Date(2026, 1, 1)
|
|
54
|
+
operating_currency: str = "CHF"
|
|
55
|
+
report_language: str = "en"
|
|
56
|
+
importers: tuple[str, ...] = () # subset of _KNOWN_IMPORTERS
|
|
57
|
+
include_samples: bool = False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def answers_from_mapping(raw: dict[str, Any]) -> Answers:
|
|
61
|
+
"""Build :class:`Answers` from a parsed ``answers.toml`` mapping.
|
|
62
|
+
|
|
63
|
+
Keys mirror the field names; unknown keys are ignored, missing keys keep
|
|
64
|
+
their default. ``vat_registered_since`` accepts a ``datetime.date`` (TOML
|
|
65
|
+
date literal) or an ISO string; ``importers`` is a list of names.
|
|
66
|
+
"""
|
|
67
|
+
d = Answers()
|
|
68
|
+
since = raw.get("vat_registered_since", d.vat_registered_since)
|
|
69
|
+
if isinstance(since, str):
|
|
70
|
+
since = Date.fromisoformat(since)
|
|
71
|
+
importers = tuple(raw.get("importers", d.importers))
|
|
72
|
+
return Answers(
|
|
73
|
+
entity_name=raw.get("entity_name", d.entity_name),
|
|
74
|
+
vat_method=raw.get("vat_method", d.vat_method),
|
|
75
|
+
vat_registered_since=since,
|
|
76
|
+
operating_currency=raw.get("operating_currency", d.operating_currency),
|
|
77
|
+
report_language=raw.get("report_language", d.report_language),
|
|
78
|
+
importers=importers,
|
|
79
|
+
include_samples=raw.get("include_samples", d.include_samples),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def load_answers(path: Path) -> Answers:
|
|
84
|
+
"""Parse a TOML answer-file into :class:`Answers` (3.10-safe TOML)."""
|
|
85
|
+
with open(path, "rb") as fh:
|
|
86
|
+
return answers_from_mapping(tomllib.load(fh))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class ScaffoldFile:
|
|
91
|
+
"""One file to materialise, path relative to the project root."""
|
|
92
|
+
|
|
93
|
+
path: Path
|
|
94
|
+
content: str
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class _Account:
|
|
99
|
+
name: str
|
|
100
|
+
code: str # four-digit KMU Kontenrahmen code
|
|
101
|
+
currencies: tuple[str, ...] = () # () = no constraint (multi-currency)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class InitError(ValueError):
|
|
105
|
+
"""Answers that cannot produce a valid project."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ── the backbone ─────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _cfg(answers: Answers) -> config.Config:
|
|
112
|
+
"""The `config.Config` the emitted `quints.toml` resolves to."""
|
|
113
|
+
return config.Config(
|
|
114
|
+
entity_name=answers.entity_name,
|
|
115
|
+
vat_method=answers.vat_method,
|
|
116
|
+
vat_registered_since=answers.vat_registered_since,
|
|
117
|
+
operating_currency=answers.operating_currency,
|
|
118
|
+
report_language=answers.report_language,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _backbone(answers: Answers) -> list[_Account]:
|
|
123
|
+
"""The KMU account skeleton, deduped, in a stable order.
|
|
124
|
+
|
|
125
|
+
Every account carries a ``kmu:`` code that exists in
|
|
126
|
+
:data:`quints.kmu.KMU_NAMES`, so the statutory statements name it and the
|
|
127
|
+
plugin accepts it.
|
|
128
|
+
"""
|
|
129
|
+
cfg = _cfg(answers)
|
|
130
|
+
oc = answers.operating_currency
|
|
131
|
+
accounts: list[_Account] = [
|
|
132
|
+
_Account(_PRIMARY_BANK, "1020", ("CHF",)),
|
|
133
|
+
_Account(cfg.receivable, "1100"),
|
|
134
|
+
_Account(cfg.input_vat, "1170", (oc,)),
|
|
135
|
+
_Account(cfg.output_vat, "2200", (oc,)),
|
|
136
|
+
_Account(cfg.bezugsteuer, "2200", (oc,)),
|
|
137
|
+
_Account(cfg.payable_vat, "2200", (oc,)),
|
|
138
|
+
_Account(_SHARE_CAPITAL, "2800", (oc,)),
|
|
139
|
+
_Account(cfg.income_domestic, "3400"),
|
|
140
|
+
_Account(cfg.income_export, "3400"),
|
|
141
|
+
_Account(_IT_HOSTING, "6570"),
|
|
142
|
+
_Account(cfg.fx_loss, "6900", (oc,)),
|
|
143
|
+
_Account(cfg.rounding_income, "6950", (oc,)),
|
|
144
|
+
_Account(cfg.fx_gain, "6950", (oc,)),
|
|
145
|
+
]
|
|
146
|
+
if "wise" in answers.importers:
|
|
147
|
+
for ccy, acct in config.WiseImport().accounts:
|
|
148
|
+
accounts.append(_Account(acct, "1020", (ccy,)))
|
|
149
|
+
accounts.append(_Account(config.WiseImport().fees_account, "6940"))
|
|
150
|
+
if "stripe" in answers.importers:
|
|
151
|
+
for ccy, acct in config.StripeImport().accounts:
|
|
152
|
+
accounts.append(_Account(acct, "1020", (ccy,)))
|
|
153
|
+
accounts.append(_Account(config.StripeImport().fees_account, "6940"))
|
|
154
|
+
# The demo quarter settles a EUR reverse-charge purchase, so it needs a
|
|
155
|
+
# EUR bank even when the Wise importer is off.
|
|
156
|
+
if answers.include_samples:
|
|
157
|
+
accounts.append(_Account(_WISE_EUR, "1020", ("EUR",)))
|
|
158
|
+
|
|
159
|
+
seen: set[str] = set()
|
|
160
|
+
unique: list[_Account] = []
|
|
161
|
+
for a in accounts:
|
|
162
|
+
if a.name not in seen:
|
|
163
|
+
seen.add(a.name)
|
|
164
|
+
unique.append(a)
|
|
165
|
+
return unique
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ── .bean rendering ──────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _open_directive(open_date: Date, account: _Account) -> str:
|
|
172
|
+
constraint = f" {','.join(account.currencies)}" if account.currencies else ""
|
|
173
|
+
name = kmu.kmu_name(account.code, "en")
|
|
174
|
+
return f'{open_date} open {account.name}{constraint}\n kmu: "{account.code}" ; {name}\n'
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _main_bean(answers: Answers) -> str:
|
|
178
|
+
cfg = _cfg(answers)
|
|
179
|
+
open_date = answers.vat_registered_since or Date(2026, 1, 1)
|
|
180
|
+
lines: list[str] = [
|
|
181
|
+
f"; {answers.entity_name} — books managed with quints (https://github.com/sealambda/quints)",
|
|
182
|
+
";",
|
|
183
|
+
"; Backbone generated by `quints init`. Extend the chart by adding more",
|
|
184
|
+
"; `open` directives — every :CH:GmbH: account needs a four-digit kmu: code",
|
|
185
|
+
"; (see `quints report konten` and AGENTS.md). Validate with `quints check`.",
|
|
186
|
+
"",
|
|
187
|
+
f'option "title" "{answers.entity_name}"',
|
|
188
|
+
f'option "operating_currency" "{answers.operating_currency}"',
|
|
189
|
+
"",
|
|
190
|
+
'plugin "quints.plugins.kmu"',
|
|
191
|
+
"",
|
|
192
|
+
f'include "{cfg.ledger_prices}"',
|
|
193
|
+
"",
|
|
194
|
+
"; ── chart of accounts (KMU Kontenrahmen) ──────────────────────────────",
|
|
195
|
+
"",
|
|
196
|
+
]
|
|
197
|
+
for account in _backbone(answers):
|
|
198
|
+
lines.append(_open_directive(open_date, account).rstrip("\n"))
|
|
199
|
+
if answers.include_samples:
|
|
200
|
+
lines.append("")
|
|
201
|
+
lines.append(_sample_quarter(open_date))
|
|
202
|
+
return "\n".join(lines).rstrip("\n") + "\n"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _sample_quarter(open_date: Date) -> str:
|
|
206
|
+
"""A small, balancing quarter of activity so every command has data.
|
|
207
|
+
|
|
208
|
+
Mirrors the conventions in test_mwst: a domestic sale with 8.1% output
|
|
209
|
+
VAT, a zero-rated EUR export, and a EUR reverse-charge purchase
|
|
210
|
+
(Bezugsteuer). Amounts are literals — deterministic, no rate lookups.
|
|
211
|
+
"""
|
|
212
|
+
year = open_date.year
|
|
213
|
+
return "\n".join(
|
|
214
|
+
[
|
|
215
|
+
"; ── sample activity (remove once you book your own) ───────────────────",
|
|
216
|
+
"",
|
|
217
|
+
f'{year}-01-02 * "Founders" "Share capital paid in"',
|
|
218
|
+
" Assets:CH:GmbH:Current:UBS:CHF 20000.00 CHF",
|
|
219
|
+
" Equity:CH:GmbH:Capital:Share -20000.00 CHF",
|
|
220
|
+
"",
|
|
221
|
+
f'{year}-07-02 * "Acme AG" "Consulting — July" ^INV{year}014',
|
|
222
|
+
" Assets:CH:GmbH:Receivable:Trade 1081.00 CHF",
|
|
223
|
+
" Income:CH:GmbH:Consulting:External:Domestic -1000.00 CHF",
|
|
224
|
+
" Liabilities:CH:GmbH:Tax:OutputVAT -81.00 CHF",
|
|
225
|
+
"",
|
|
226
|
+
f'{year}-07-20 * "Acme AG" "Payment INV{year}014" ^INV{year}014',
|
|
227
|
+
" Assets:CH:GmbH:Current:UBS:CHF 1081.00 CHF",
|
|
228
|
+
" Assets:CH:GmbH:Receivable:Trade -1081.00 CHF",
|
|
229
|
+
"",
|
|
230
|
+
f'{year}-08-05 * "Globex Ltd" "Export consulting" ^INV{year}015',
|
|
231
|
+
" Assets:CH:GmbH:Receivable:Trade 500.00 EUR",
|
|
232
|
+
" Income:CH:GmbH:Consulting:External:Export -500.00 EUR",
|
|
233
|
+
"",
|
|
234
|
+
f'{year}-08-12 * "Foreign SaaS" "Cloud hosting (reverse charge)"',
|
|
235
|
+
" Expenses:CH:GmbH:IT:Hosting 100.00 EUR",
|
|
236
|
+
" Assets:CH:GmbH:Tax:InputVAT 7.53 CHF @@ 8.10 EUR",
|
|
237
|
+
" Liabilities:CH:GmbH:Tax:Bezugsteuer -7.53 CHF @@ 8.10 EUR",
|
|
238
|
+
" Assets:CH:GmbH:Current:Wise:EUR -100.00 EUR",
|
|
239
|
+
]
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _prices_bean(answers: Answers) -> str:
|
|
244
|
+
header = (
|
|
245
|
+
"; FX rates (CHF per unit). Populate with `quints prices sync`\n"
|
|
246
|
+
"; (official BAZG/EZV daily rates).\n"
|
|
247
|
+
)
|
|
248
|
+
if not answers.include_samples:
|
|
249
|
+
return header
|
|
250
|
+
year = (answers.vat_registered_since or Date(2026, 1, 1)).year
|
|
251
|
+
return header + "\n".join(
|
|
252
|
+
[
|
|
253
|
+
"",
|
|
254
|
+
f"{year}-07-01 price EUR 0.93 CHF",
|
|
255
|
+
f"{year}-08-01 price EUR 0.94 CHF",
|
|
256
|
+
"",
|
|
257
|
+
]
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ── quints.toml rendering ────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _quints_toml(answers: Answers) -> str:
|
|
265
|
+
since = answers.vat_registered_since
|
|
266
|
+
lines: list[str] = [
|
|
267
|
+
"# quints.toml — everything entity-specific lives here, next to main.bean.",
|
|
268
|
+
"# Account names below are the built-in defaults (CH:GmbH namespacing);",
|
|
269
|
+
"# uncomment and edit in quints.toml + main.bean together to change them.",
|
|
270
|
+
"",
|
|
271
|
+
"[entity]",
|
|
272
|
+
f'name = "{answers.entity_name}"',
|
|
273
|
+
f'vat_method = "{answers.vat_method}" # "saldo" is not supported yet',
|
|
274
|
+
]
|
|
275
|
+
if since is not None:
|
|
276
|
+
lines.append(
|
|
277
|
+
f"vat_registered_since = {since.isoformat()} # earlier periods are pre-liability"
|
|
278
|
+
)
|
|
279
|
+
lines += [
|
|
280
|
+
f'operating_currency = "{answers.operating_currency}"',
|
|
281
|
+
"",
|
|
282
|
+
"[ledger]",
|
|
283
|
+
'main = "main.bean"',
|
|
284
|
+
'prices = "prices.bean"',
|
|
285
|
+
"",
|
|
286
|
+
"[report]",
|
|
287
|
+
f'language = "{answers.report_language}" # or "de"; --lang overrides',
|
|
288
|
+
]
|
|
289
|
+
for importer in answers.importers:
|
|
290
|
+
lines.append("")
|
|
291
|
+
lines.append(_import_section(importer))
|
|
292
|
+
return "\n".join(lines).rstrip("\n") + "\n"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _import_section(importer: str) -> str:
|
|
296
|
+
if importer == "ubs":
|
|
297
|
+
d = config.UbsImport()
|
|
298
|
+
return "\n".join(
|
|
299
|
+
[
|
|
300
|
+
"# MT940 statements → staging/. '*' = booked as drafted, '!' = still",
|
|
301
|
+
"# needs a VAT decision + linked document before it reaches your books.",
|
|
302
|
+
"[import.ubs]",
|
|
303
|
+
f'account = "{d.account}"',
|
|
304
|
+
'iban = "CH9300762011623852957" # your IBAN, as in the MT940 :25: field',
|
|
305
|
+
"rules = [",
|
|
306
|
+
' [\'\\bacme\\b\', "Assets:CH:GmbH:Receivable:Trade", "*"],',
|
|
307
|
+
"]",
|
|
308
|
+
]
|
|
309
|
+
)
|
|
310
|
+
if importer == "wise":
|
|
311
|
+
d = config.WiseImport()
|
|
312
|
+
rows = "\n".join(f'{ccy} = "{acct}"' for ccy, acct in d.accounts)
|
|
313
|
+
return "\n".join(
|
|
314
|
+
[
|
|
315
|
+
"# Fetching (`quints import wise --fetch`) needs QUINTS_WISE_API_TOKEN",
|
|
316
|
+
"# (and QUINTS_WISE_PRIVATE_KEY for SCA-protected profiles) in .env.",
|
|
317
|
+
"[import.wise]",
|
|
318
|
+
f'fees_account = "{d.fees_account}"',
|
|
319
|
+
"rules = [",
|
|
320
|
+
' ["cloudflare", "Expenses:CH:GmbH:IT:Hosting", "!"],',
|
|
321
|
+
"]",
|
|
322
|
+
"",
|
|
323
|
+
"[import.wise.accounts]",
|
|
324
|
+
rows,
|
|
325
|
+
]
|
|
326
|
+
)
|
|
327
|
+
if importer == "stripe":
|
|
328
|
+
d = config.StripeImport()
|
|
329
|
+
rows = "\n".join(f'{ccy} = "{acct}"' for ccy, acct in d.accounts)
|
|
330
|
+
return "\n".join(
|
|
331
|
+
[
|
|
332
|
+
"# Fetching needs QUINTS_STRIPE_API_KEY in .env (a restricted key with",
|
|
333
|
+
"# Balance transaction sources: Read and Charges: Read).",
|
|
334
|
+
"[import.stripe]",
|
|
335
|
+
'account_id = "acct_XXXXXXXXXXXX" # guard: refuse a key for another account',
|
|
336
|
+
f'fees_account = "{d.fees_account}"',
|
|
337
|
+
f'tax_account = "{d.tax_account}" # VAT inside Stripe\'s monthly fee debits',
|
|
338
|
+
"rules = [",
|
|
339
|
+
' [\'\\bpayout\\b\', "Assets:CH:GmbH:Current:Wise:EUR", "*"],',
|
|
340
|
+
"]",
|
|
341
|
+
"",
|
|
342
|
+
"[import.stripe.accounts]",
|
|
343
|
+
rows,
|
|
344
|
+
]
|
|
345
|
+
)
|
|
346
|
+
raise InitError(f"unknown importer {importer!r} (known: {', '.join(_KNOWN_IMPORTERS)})")
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# ── AGENTS.md — the AI-first payload ─────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _agents_md(answers: Answers) -> str:
|
|
353
|
+
return f"""# Working on {answers.entity_name}'s books with an AI agent
|
|
354
|
+
|
|
355
|
+
These are plain-text ([beancount](https://beancount.github.io)) books managed
|
|
356
|
+
with [`quints`](https://github.com/sealambda/quints). **`quints` is a
|
|
357
|
+
deterministic tool — you drive it, it never calls a model.** Your job is to
|
|
358
|
+
extend and maintain the ledger; `quints` validates and reports on it.
|
|
359
|
+
|
|
360
|
+
## Layout
|
|
361
|
+
|
|
362
|
+
- `main.bean` — the ledger (accounts + transactions). `plugin
|
|
363
|
+
"quints.plugins.kmu"` is enabled: every `*:CH:GmbH:*` account **must** be
|
|
364
|
+
opened with a four-digit `kmu:` code (Swiss KMU Kontenrahmen).
|
|
365
|
+
- `prices.bean` — FX rates; refresh with `quints prices sync`.
|
|
366
|
+
- `quints.toml` — entity config (name, VAT, importer rules). VAT *rates* are
|
|
367
|
+
law and live in code, not here.
|
|
368
|
+
- `staging/` — importer drafts land here; `inbox/` — source documents.
|
|
369
|
+
|
|
370
|
+
## Extending the chart of accounts (the part that needs judgement)
|
|
371
|
+
|
|
372
|
+
Add income/expense sub-trees for this business as `open` directives, each with
|
|
373
|
+
the KMU code it rolls up to, e.g.:
|
|
374
|
+
|
|
375
|
+
```beancount
|
|
376
|
+
2026-01-01 open Expenses:CH:GmbH:Marketing:Ads CHF
|
|
377
|
+
kmu: "6600" ; Advertising
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Run `quints report konten` to see the codes already in use. Pick codes from the
|
|
381
|
+
KMU Kontenrahmen; `quints check` fails on a `:CH:GmbH:` account with no valid
|
|
382
|
+
`kmu:` code.
|
|
383
|
+
|
|
384
|
+
## The loop
|
|
385
|
+
|
|
386
|
+
1. Draft bank/PSP activity: `quints import ubs <file>` → `staging/`.
|
|
387
|
+
2. Review, add the VAT decision (InputVAT / Bezugsteuer / none) and a linked
|
|
388
|
+
document, then move drafts into `main.bean`.
|
|
389
|
+
3. **Always** `quints check` before you consider the books consistent.
|
|
390
|
+
|
|
391
|
+
## Machine-readable surfaces (prefer these over scraping text)
|
|
392
|
+
|
|
393
|
+
- Every reporting command takes `--json`: `quints mwst -q 2026-Q3 --json`,
|
|
394
|
+
`quints status --json`, `quints report bilanz --json`.
|
|
395
|
+
- `quints schema` writes JSON Schemas for the invoice/issuer/customer files.
|
|
396
|
+
|
|
397
|
+
Never invent VAT numbers or rates — compute them with `quints mwst`.
|
|
398
|
+
"""
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _gitignore() -> str:
|
|
402
|
+
return "\n".join(
|
|
403
|
+
[
|
|
404
|
+
"# quints working directories",
|
|
405
|
+
"/staging/",
|
|
406
|
+
".env",
|
|
407
|
+
"__pycache__/",
|
|
408
|
+
"*.pdf", # rendered invoices/statements — regenerate from source
|
|
409
|
+
"",
|
|
410
|
+
]
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# ── plan / write ─────────────────────────────────────────────────────────────
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def plan(answers: Answers) -> list[ScaffoldFile]:
|
|
418
|
+
"""Answers → the ordered set of files to materialise. Pure, no I/O."""
|
|
419
|
+
_validate(answers)
|
|
420
|
+
files = [
|
|
421
|
+
ScaffoldFile(Path("quints.toml"), _quints_toml(answers)),
|
|
422
|
+
ScaffoldFile(Path("main.bean"), _main_bean(answers)),
|
|
423
|
+
ScaffoldFile(Path("prices.bean"), _prices_bean(answers)),
|
|
424
|
+
ScaffoldFile(Path("AGENTS.md"), _agents_md(answers)),
|
|
425
|
+
ScaffoldFile(Path(".gitignore"), _gitignore()),
|
|
426
|
+
ScaffoldFile(Path("inbox/.gitkeep"), ""),
|
|
427
|
+
ScaffoldFile(Path("staging/.gitkeep"), ""),
|
|
428
|
+
ScaffoldFile(Path("documents/.gitkeep"), ""),
|
|
429
|
+
]
|
|
430
|
+
return files
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _validate(answers: Answers) -> None:
|
|
434
|
+
if answers.vat_method != "effective":
|
|
435
|
+
raise InitError(
|
|
436
|
+
f"vat_method {answers.vat_method!r} is not supported yet (only 'effective')"
|
|
437
|
+
)
|
|
438
|
+
unknown = [i for i in answers.importers if i not in _KNOWN_IMPORTERS]
|
|
439
|
+
if unknown:
|
|
440
|
+
raise InitError(
|
|
441
|
+
f"unknown importer(s) {', '.join(unknown)} (known: {', '.join(_KNOWN_IMPORTERS)})"
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
@dataclass
|
|
446
|
+
class WriteResult:
|
|
447
|
+
written: list[Path] = field(default_factory=list)
|
|
448
|
+
skipped: list[Path] = field(default_factory=list)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def write(target: Path, files: list[ScaffoldFile], *, force: bool = False) -> WriteResult:
|
|
452
|
+
"""Materialise ``files`` under ``target``. Never overwrites unless ``force``."""
|
|
453
|
+
result = WriteResult()
|
|
454
|
+
for f in files:
|
|
455
|
+
dest = target / f.path
|
|
456
|
+
if dest.exists() and not force:
|
|
457
|
+
result.skipped.append(dest)
|
|
458
|
+
continue
|
|
459
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
460
|
+
dest.write_text(f.content)
|
|
461
|
+
result.written.append(dest)
|
|
462
|
+
return result
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Swiss QR-bill invoice generation (Typst + qrbill)."""
|
quints/invoice/draft.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Ready-to-paste Beancount draft for an invoice with no ledger entry yet."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from .. import config
|
|
8
|
+
from .model import Invoice, Totals
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _slug(text: str) -> str:
|
|
12
|
+
s = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
|
13
|
+
return s or "invoice"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_draft(inv: Invoice, totals: Totals, cfg: config.Config | None = None) -> str:
|
|
17
|
+
"""A balanced receivable booking matching what `verify.cross_check` expects."""
|
|
18
|
+
cfg = cfg or config.get()
|
|
19
|
+
ccy = inv.currency
|
|
20
|
+
income = cfg.income_export if inv.kind == "export" else cfg.income_domestic
|
|
21
|
+
narration = f"{inv.supply} invoiced".strip() if inv.supply else f"Invoice {inv.number}"
|
|
22
|
+
doc = f"{inv.issue_date}.{_slug(inv.customer.name)}.{_slug(inv.supply or inv.number)}.pdf"
|
|
23
|
+
|
|
24
|
+
legs: list[tuple[str, str]] = [(cfg.receivable, f"{totals.grand_total:>10.2f} {ccy}")]
|
|
25
|
+
legs.append((income, f"{-totals.subtotal:>10.2f} {ccy}"))
|
|
26
|
+
if totals.vat_amount:
|
|
27
|
+
legs.append((cfg.output_vat, f"{-totals.vat_amount:>10.2f} {ccy}"))
|
|
28
|
+
if totals.rounding:
|
|
29
|
+
legs.append((cfg.rounding_income, f"{-totals.rounding:>10.2f} {ccy}"))
|
|
30
|
+
|
|
31
|
+
width = max(len(a) for a, _ in legs) + 4
|
|
32
|
+
lines = [
|
|
33
|
+
f'{inv.issue_date} * "{inv.customer.name}" "{narration}" ^{inv.number}',
|
|
34
|
+
f' invoice: "{inv.number}"',
|
|
35
|
+
f' document: "{doc}" ; TODO file the PDF under documents/{income.replace(":", "/")}/',
|
|
36
|
+
]
|
|
37
|
+
lines += [f" {a:<{width}}{amt}" for a, amt in legs]
|
|
38
|
+
return "\n".join(lines)
|
quints/invoice/labels.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Localized labels for the invoice template.
|
|
2
|
+
|
|
3
|
+
Adding a language = add one entry to `LABELS` with the full key set (a test
|
|
4
|
+
enforces key parity, and `Invoice.locale` validates its language subtag against
|
|
5
|
+
these keys, so a new language is accepted the moment it is defined here — no
|
|
6
|
+
other file to touch; the matching CLDR locale, e.g. `es_ES`, gives Babel the
|
|
7
|
+
number/date formatting for free).
|
|
8
|
+
|
|
9
|
+
The legally load-bearing strings — `terms`, `export_note`, `reverse_charge` —
|
|
10
|
+
should be reviewed by a native/legal speaker before an entry ships.
|
|
11
|
+
`terms` MUST keep the literal `{days}` placeholder (`render` calls `.format`)."""
|
|
12
|
+
|
|
13
|
+
LABELS = {
|
|
14
|
+
"de": {
|
|
15
|
+
"invoice": "Rechnung",
|
|
16
|
+
"invoice_no": "Rechnungs-Nr.",
|
|
17
|
+
"date": "Rechnungsdatum",
|
|
18
|
+
"supply": "Leistungsperiode",
|
|
19
|
+
"pos": "Pos.",
|
|
20
|
+
"description": "Bezeichnung",
|
|
21
|
+
"qty": "Menge",
|
|
22
|
+
"unit_price": "Preis",
|
|
23
|
+
"line_total": "Total",
|
|
24
|
+
"subtotal": "Zwischentotal",
|
|
25
|
+
"vat": "MWST",
|
|
26
|
+
"rounding": "Rundung",
|
|
27
|
+
"grand_total": "Rechnungstotal",
|
|
28
|
+
"payment_to": "Zahlbar an",
|
|
29
|
+
"reference": "Referenz",
|
|
30
|
+
"terms": "Zahlbar innert {days} Tagen netto.",
|
|
31
|
+
"export_note": "Nicht der schweizerischen MWST unterliegend: Ort der "
|
|
32
|
+
"Leistung im Ausland (Art. 8 Abs. 1 MWSTG).",
|
|
33
|
+
"reverse_charge": "Reverse Charge — die Steuer ist vom Leistungsempfänger abzurechnen.",
|
|
34
|
+
},
|
|
35
|
+
"en": {
|
|
36
|
+
"invoice": "Invoice",
|
|
37
|
+
"invoice_no": "Invoice no.",
|
|
38
|
+
"date": "Invoice date",
|
|
39
|
+
"supply": "Service period",
|
|
40
|
+
"pos": "No.",
|
|
41
|
+
"description": "Description",
|
|
42
|
+
"qty": "Qty",
|
|
43
|
+
"unit_price": "Price",
|
|
44
|
+
"line_total": "Total",
|
|
45
|
+
"subtotal": "Subtotal",
|
|
46
|
+
"vat": "VAT",
|
|
47
|
+
"rounding": "Rounding",
|
|
48
|
+
"grand_total": "Total due",
|
|
49
|
+
"payment_to": "Payable to",
|
|
50
|
+
"reference": "Reference",
|
|
51
|
+
"terms": "Payable within {days} days net.",
|
|
52
|
+
"export_note": "Not subject to Swiss VAT: place of supply abroad "
|
|
53
|
+
"(Art. 8 para. 1 Swiss VAT Act).",
|
|
54
|
+
"reverse_charge": "Reverse charge — VAT to be accounted for by the recipient.",
|
|
55
|
+
},
|
|
56
|
+
"es": {
|
|
57
|
+
"invoice": "Factura",
|
|
58
|
+
"invoice_no": "N.º de factura",
|
|
59
|
+
"date": "Fecha de factura",
|
|
60
|
+
"supply": "Periodo de servicio",
|
|
61
|
+
"pos": "N.º",
|
|
62
|
+
"description": "Descripción",
|
|
63
|
+
"qty": "Cantidad",
|
|
64
|
+
"unit_price": "Precio",
|
|
65
|
+
"line_total": "Total",
|
|
66
|
+
"subtotal": "Subtotal",
|
|
67
|
+
"vat": "IVA",
|
|
68
|
+
"rounding": "Redondeo",
|
|
69
|
+
"grand_total": "Total a pagar",
|
|
70
|
+
"payment_to": "A pagar a",
|
|
71
|
+
"reference": "Referencia",
|
|
72
|
+
"terms": "A pagar en un plazo de {days} días netos.",
|
|
73
|
+
"export_note": "No sujeto al IVA suizo: lugar de la prestación en el "
|
|
74
|
+
"extranjero (art. 8 apdo. 1 de la Ley suiza del IVA).",
|
|
75
|
+
"reverse_charge": "Inversión del sujeto pasivo — el IVA debe ser "
|
|
76
|
+
"liquidado por el destinatario.",
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def labels(language: str) -> dict:
|
|
82
|
+
"""Label set for `language` (a locale's language subtag). `Invoice.locale`
|
|
83
|
+
is validated at load, so the fallback only guards programmatic callers."""
|
|
84
|
+
return LABELS.get(language, LABELS["de"])
|