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 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)."""
@@ -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)
@@ -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"])