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/kmu.py ADDED
@@ -0,0 +1,712 @@
1
+ """Statutory financial statements grouped by the Swiss KMU chart of accounts.
2
+
3
+ Bilanz (balance sheet, OR Art. 959a) and Erfolgsrechnung (income statement,
4
+ OR Art. 959b) are produced by aggregating ledger accounts on the ``kmu:``
5
+ metadata of their ``open`` directives (enforced by ``quints.plugins.kmu``).
6
+ The statutory structure and all labels are static tables in this module;
7
+ language is purely a rendering concern (``lang="en"`` or ``"de"``).
8
+
9
+ Valuation:
10
+
11
+ - Balance-sheet positions: non-CHF balances are converted at the report-date
12
+ rate (latest BAZG rate on or before ``--at``) — the same method Fava uses
13
+ for market value, so totals tie out.
14
+ - Income-statement flows: converted at each transaction's date.
15
+ - The difference between the balance sheet's balancing result and the income
16
+ statement's result is therefore the **unrealized currency translation** not
17
+ yet booked (Plan 1.2's year-end revaluation makes it explicit); compute
18
+ exposes it instead of hiding it.
19
+
20
+ Computation (`compute_bilanz` / `compute_erfolg` / `compute_konten`) is
21
+ separated from presentation (`render_*`) so web/TUI/MCP front-ends can consume
22
+ the dataclasses directly.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass, field
28
+ from datetime import date as Date
29
+ from decimal import Decimal
30
+ from pathlib import Path
31
+
32
+ from beancount.core import convert as bc_convert
33
+ from beancount.core import data
34
+ from beancount.core import prices as bc_prices
35
+ from beancount.core.amount import Amount
36
+ from beancount.core.inventory import Inventory
37
+ from rich import box
38
+ from rich.console import Console
39
+ from rich.table import Table
40
+
41
+ from . import config, ledger, ui
42
+
43
+ # ── static structure tables ──────────────────────────────────────────────────
44
+ # KMU code → name, per language. Only codes in use (or expected) are listed;
45
+ # an unknown code renders as the code itself, never an error.
46
+ KMU_NAMES: dict[str, dict[str, str]] = {
47
+ "1020": {"en": "Bank deposits", "de": "Bankguthaben"},
48
+ "1100": {"en": "Trade receivables", "de": "Forderungen aus Lieferungen und Leistungen"},
49
+ "1140": {"en": "Receivables from shareholders", "de": "Forderungen gegenüber Beteiligten"},
50
+ "1170": {"en": "Input VAT", "de": "Vorsteuer"},
51
+ "1510": {"en": "Furniture and equipment", "de": "Mobiliar und Einrichtungen"},
52
+ "2000": {"en": "Trade payables", "de": "Verbindlichkeiten aus Lieferungen und Leistungen"},
53
+ "2200": {"en": "VAT due", "de": "Geschuldete Mehrwertsteuer"},
54
+ "2270": {"en": "Social security payable", "de": "Geschuldete Sozialversicherungsbeiträge"},
55
+ "2300": {"en": "Accrued expenses", "de": "Passive Rechnungsabgrenzung"},
56
+ "2500": {
57
+ "en": "Liabilities to shareholders",
58
+ "de": "Verbindlichkeiten gegenüber Gesellschaftern",
59
+ },
60
+ "2800": {"en": "Share capital", "de": "Stammkapital"},
61
+ "3400": {"en": "Revenue from services", "de": "Dienstleistungserlöse"},
62
+ "4400": {"en": "Purchased services", "de": "Aufwand für bezogene Dienstleistungen"},
63
+ "5000": {"en": "Wages and salaries", "de": "Lohnaufwand"},
64
+ "5700": {"en": "Social security expenses", "de": "Sozialversicherungsaufwand"},
65
+ "6500": {"en": "Administrative expenses", "de": "Verwaltungsaufwand"},
66
+ "6530": {"en": "Accounting and consulting fees", "de": "Buchführungs- und Beratungsaufwand"},
67
+ "6570": {"en": "IT expenses", "de": "Informatikaufwand"},
68
+ "6600": {"en": "Advertising", "de": "Werbeaufwand"},
69
+ "6620": {"en": "Commissions", "de": "Provisionen"},
70
+ "6640": {"en": "Travel expenses", "de": "Reisespesen"},
71
+ "6800": {"en": "Depreciation", "de": "Abschreibungen"},
72
+ "6900": {"en": "Financial expenses", "de": "Finanzaufwand"},
73
+ "6940": {"en": "Bank charges", "de": "Bankspesen"},
74
+ "6950": {"en": "Financial income", "de": "Finanzertrag"},
75
+ }
76
+
77
+ # Statutory rows: (row key, code range lo..hi inclusive). A KMU code belongs to
78
+ # the first row whose range contains it. Section membership below.
79
+ BILANZ_ROWS: tuple[tuple[str, str, str], ...] = (
80
+ ("cash", "1000", "1099"),
81
+ ("trade_receivables", "1100", "1139"),
82
+ ("other_receivables", "1140", "1199"),
83
+ ("inventories", "1200", "1299"),
84
+ ("prepaid_expenses", "1300", "1399"),
85
+ ("financial_assets", "1400", "1499"),
86
+ ("tangible_assets", "1500", "1699"),
87
+ ("intangible_assets", "1700", "1799"),
88
+ ("trade_payables", "2000", "2099"),
89
+ ("short_term_debt", "2100", "2199"),
90
+ ("other_short_term", "2200", "2299"),
91
+ ("accrued_liabilities", "2300", "2399"),
92
+ ("long_term_debt", "2400", "2499"),
93
+ ("other_long_term", "2500", "2599"),
94
+ ("provisions", "2600", "2699"),
95
+ ("share_capital", "2800", "2899"),
96
+ ("reserves", "2900", "2969"),
97
+ ("retained_earnings", "2970", "2979"),
98
+ )
99
+
100
+ BILANZ_SECTIONS: dict[str, tuple[str, ...]] = {
101
+ "current_assets": (
102
+ "cash",
103
+ "trade_receivables",
104
+ "other_receivables",
105
+ "inventories",
106
+ "prepaid_expenses",
107
+ ),
108
+ "noncurrent_assets": ("financial_assets", "tangible_assets", "intangible_assets"),
109
+ "short_term_liabilities": (
110
+ "trade_payables",
111
+ "short_term_debt",
112
+ "other_short_term",
113
+ "accrued_liabilities",
114
+ ),
115
+ "long_term_liabilities": ("long_term_debt", "other_long_term", "provisions"),
116
+ "equity": ("share_capital", "reserves", "retained_earnings"),
117
+ }
118
+
119
+ ERFOLG_ROWS: tuple[tuple[str, str, str], ...] = (
120
+ ("revenue", "3000", "3999"),
121
+ ("materials_services", "4000", "4999"),
122
+ ("personnel", "5000", "5999"),
123
+ ("other_operating", "6000", "6799"),
124
+ ("depreciation", "6800", "6899"),
125
+ ("financial_expenses", "6900", "6949"),
126
+ ("financial_income", "6950", "6999"),
127
+ )
128
+
129
+ # Rows whose natural beancount sign is credit (display flipped).
130
+ CREDIT_ROWS = frozenset(
131
+ {
132
+ "trade_payables",
133
+ "short_term_debt",
134
+ "other_short_term",
135
+ "accrued_liabilities",
136
+ "long_term_debt",
137
+ "other_long_term",
138
+ "provisions",
139
+ "share_capital",
140
+ "reserves",
141
+ "retained_earnings",
142
+ "revenue",
143
+ "financial_income",
144
+ }
145
+ )
146
+
147
+ LABELS: dict[str, dict[str, str]] = {
148
+ "bilanz_title": {"en": "Balance sheet", "de": "Bilanz"},
149
+ "erfolg_title": {"en": "Income statement", "de": "Erfolgsrechnung"},
150
+ "konten_title": {"en": "Account statements", "de": "Kontoblätter"},
151
+ "as_at": {"en": "as at", "de": "per"},
152
+ "assets": {"en": "ASSETS", "de": "AKTIVEN"},
153
+ "liabilities_equity": {"en": "LIABILITIES AND EQUITY", "de": "PASSIVEN"},
154
+ "current_assets": {"en": "Current assets", "de": "Umlaufvermögen"},
155
+ "noncurrent_assets": {"en": "Non-current assets", "de": "Anlagevermögen"},
156
+ "short_term_liabilities": {"en": "Short-term liabilities", "de": "Kurzfristiges Fremdkapital"},
157
+ "long_term_liabilities": {"en": "Long-term liabilities", "de": "Langfristiges Fremdkapital"},
158
+ "equity": {"en": "Equity", "de": "Eigenkapital"},
159
+ "cash": {"en": "Cash and cash equivalents", "de": "Flüssige Mittel"},
160
+ "trade_receivables": {
161
+ "en": "Trade receivables",
162
+ "de": "Forderungen aus Lieferungen und Leistungen",
163
+ },
164
+ "other_receivables": {
165
+ "en": "Other current receivables",
166
+ "de": "Übrige kurzfristige Forderungen",
167
+ },
168
+ "inventories": {"en": "Inventories", "de": "Vorräte"},
169
+ "prepaid_expenses": {"en": "Prepaid expenses", "de": "Aktive Rechnungsabgrenzung"},
170
+ "financial_assets": {"en": "Financial assets", "de": "Finanzanlagen"},
171
+ "tangible_assets": {"en": "Tangible fixed assets", "de": "Sachanlagen"},
172
+ "intangible_assets": {"en": "Intangible assets", "de": "Immaterielle Werte"},
173
+ "trade_payables": {
174
+ "en": "Trade payables",
175
+ "de": "Verbindlichkeiten aus Lieferungen und Leistungen",
176
+ },
177
+ "short_term_debt": {
178
+ "en": "Short-term interest-bearing debt",
179
+ "de": "Kurzfristige verzinsliche Verbindlichkeiten",
180
+ },
181
+ "other_short_term": {
182
+ "en": "Other short-term liabilities",
183
+ "de": "Übrige kurzfristige Verbindlichkeiten",
184
+ },
185
+ "accrued_liabilities": {"en": "Accrued liabilities", "de": "Passive Rechnungsabgrenzung"},
186
+ "long_term_debt": {
187
+ "en": "Long-term interest-bearing debt",
188
+ "de": "Langfristige verzinsliche Verbindlichkeiten",
189
+ },
190
+ "other_long_term": {
191
+ "en": "Other long-term liabilities",
192
+ "de": "Übrige langfristige Verbindlichkeiten",
193
+ },
194
+ "provisions": {"en": "Provisions", "de": "Rückstellungen"},
195
+ "share_capital": {"en": "Share capital", "de": "Stammkapital"},
196
+ "reserves": {"en": "Reserves", "de": "Reserven"},
197
+ "retained_earnings": {"en": "Retained earnings", "de": "Gewinnvortrag"},
198
+ "retained_prior": {"en": "Profit/loss brought forward", "de": "Gewinn-/Verlustvortrag"},
199
+ "result": {"en": "Profit/loss for the year", "de": "Jahresgewinn/-verlust"},
200
+ "total_assets": {"en": "Total assets", "de": "Total Aktiven"},
201
+ "total_liabilities_equity": {"en": "Total liabilities and equity", "de": "Total Passiven"},
202
+ "revenue": {
203
+ "en": "Net revenue from services",
204
+ "de": "Nettoerlöse aus Lieferungen und Leistungen",
205
+ },
206
+ "materials_services": {
207
+ "en": "Cost of materials and purchased services",
208
+ "de": "Material- und Dienstleistungsaufwand",
209
+ },
210
+ "personnel": {"en": "Personnel expenses", "de": "Personalaufwand"},
211
+ "other_operating": {"en": "Other operating expenses", "de": "Übriger betrieblicher Aufwand"},
212
+ "depreciation": {
213
+ "en": "Depreciation and value adjustments",
214
+ "de": "Abschreibungen und Wertberichtigungen",
215
+ },
216
+ "ebit": {"en": "Operating result (EBIT)", "de": "Betriebsergebnis (EBIT)"},
217
+ "financial_expenses": {"en": "Financial expenses", "de": "Finanzaufwand"},
218
+ "financial_income": {"en": "Financial income", "de": "Finanzertrag"},
219
+ "unrealized_fx": {
220
+ "en": "Unrealized currency translation (not booked)",
221
+ "de": "Nicht verbuchte Kursdifferenzen (unrealisiert)",
222
+ },
223
+ "period": {"en": "Period", "de": "Periode"},
224
+ }
225
+
226
+
227
+ def label(key: str, lang: str) -> str:
228
+ entry = LABELS.get(key)
229
+ if entry:
230
+ return entry.get(lang) or entry["en"]
231
+ return key
232
+
233
+
234
+ def kmu_name(code: str, lang: str) -> str:
235
+ entry = KMU_NAMES.get(code)
236
+ if entry:
237
+ return entry.get(lang) or entry["en"]
238
+ return code
239
+
240
+
241
+ def _row_for(code: str, rows: tuple[tuple[str, str, str], ...]) -> str | None:
242
+ for key, lo, hi in rows:
243
+ if lo <= code <= hi:
244
+ return key
245
+ return None
246
+
247
+
248
+ # ── data ─────────────────────────────────────────────────────────────────────
249
+
250
+
251
+ @dataclass
252
+ class CodeLine:
253
+ code: str
254
+ amount: Decimal # display sign (positive in its natural direction)
255
+ accounts: list[str]
256
+
257
+
258
+ @dataclass
259
+ class RowLine:
260
+ key: str
261
+ amount: Decimal
262
+ codes: list[CodeLine] = field(default_factory=list)
263
+
264
+
265
+ @dataclass
266
+ class BilanzReport:
267
+ at: str
268
+ current_assets: list[RowLine]
269
+ noncurrent_assets: list[RowLine]
270
+ short_term_liabilities: list[RowLine]
271
+ long_term_liabilities: list[RowLine]
272
+ equity: list[RowLine]
273
+ retained_prior: Decimal # unbooked P&L of years before the report year (Gewinnvortrag)
274
+ result: Decimal # report-year share of the balancing figure (Jahresgewinn)
275
+ total_assets: Decimal
276
+ total_liabilities_equity: Decimal
277
+ converted: dict[str, Decimal] = field(
278
+ default_factory=dict
279
+ ) # ccy → units valued at report-date rate
280
+
281
+
282
+ @dataclass
283
+ class ErfolgReport:
284
+ date_from: str
285
+ date_to: str
286
+ revenue: list[RowLine]
287
+ expenses: list[RowLine] # materials..depreciation, statutory order
288
+ ebit: Decimal
289
+ financial_expenses: list[RowLine]
290
+ financial_income: list[RowLine]
291
+ result: Decimal
292
+
293
+
294
+ @dataclass
295
+ class KontoLine:
296
+ date: str
297
+ payee: str
298
+ narration: str
299
+ account: str
300
+ original: Decimal
301
+ currency: str
302
+ chf: Decimal
303
+
304
+
305
+ @dataclass
306
+ class Konto:
307
+ code: str
308
+ lines: list[KontoLine]
309
+ flow: Decimal # CHF, natural display sign
310
+
311
+
312
+ @dataclass
313
+ class KontenReport:
314
+ date_from: str
315
+ date_to: str
316
+ konten: list[Konto]
317
+
318
+
319
+ # ── compute ──────────────────────────────────────────────────────────────────
320
+
321
+
322
+ def kmu_map(entries, marker: str) -> dict[str, str]:
323
+ """account → kmu code, from the entity's open directives."""
324
+ mapping: dict[str, str] = {}
325
+ for e in entries:
326
+ if isinstance(e, data.Open) and marker in e.account:
327
+ code = (e.meta or {}).get("kmu")
328
+ if isinstance(code, str):
329
+ mapping[e.account] = code
330
+ return mapping
331
+
332
+
333
+ def _to_chf(units: Amount, on: Date, price_map) -> Decimal:
334
+ if units.currency == "CHF":
335
+ return units.number
336
+ conv = bc_convert.convert_amount(units, "CHF", price_map, date=on)
337
+ return conv.number if conv.currency == "CHF" else Decimal("0")
338
+
339
+
340
+ def _posting_chf(p: data.Posting, on: Date, fallback: Date, price_map) -> Decimal:
341
+ """CHF value of a P&L posting at its transaction date.
342
+
343
+ The posting's own ``@``/``@@`` annotation wins (it is the booked rate);
344
+ otherwise the price map at ``on``, then at ``fallback`` — old entries may
345
+ predate the BAZG price coverage, and silently valuing them at 0 would
346
+ corrupt totals.
347
+ """
348
+ weight = bc_convert.get_weight(p)
349
+ if weight.currency == "CHF":
350
+ return weight.number
351
+ conv = bc_convert.convert_amount(weight, "CHF", price_map, date=on)
352
+ if conv.currency == "CHF":
353
+ return conv.number
354
+ conv = bc_convert.convert_amount(weight, "CHF", price_map, date=fallback)
355
+ return conv.number if conv.currency == "CHF" else Decimal("0")
356
+
357
+
358
+ def _rows_from(
359
+ per_code: dict[str, tuple[Decimal, set[str]]],
360
+ structure: tuple[tuple[str, str, str], ...],
361
+ keys: tuple[str, ...],
362
+ ) -> list[RowLine]:
363
+ """Group per-code totals into statutory rows (only ``keys``), display signs."""
364
+ rows: dict[str, RowLine] = {}
365
+ for code in sorted(per_code):
366
+ key = _row_for(code, structure)
367
+ if key not in keys:
368
+ continue
369
+ amount, accounts = per_code[code]
370
+ display = ledger.rappen(-amount if key in CREDIT_ROWS else amount)
371
+ row = rows.setdefault(key, RowLine(key, Decimal("0")))
372
+ row.amount += display
373
+ row.codes.append(CodeLine(code, display, sorted(accounts)))
374
+ return [rows[k] for k in keys if k in rows]
375
+
376
+
377
+ def compute_bilanz(ledger_path: Path, at: str, cfg: config.Config | None = None) -> BilanzReport:
378
+ cfg = cfg or config.get()
379
+ on = Date.fromisoformat(at)
380
+ entries, _ = ledger.load_entries(ledger_path)
381
+ price_map = bc_prices.build_price_map(entries)
382
+ mapping = kmu_map(entries, cfg.entity_marker)
383
+
384
+ fy_start = Date(on.year, 1, 1)
385
+ inventories: dict[str, Inventory] = {}
386
+ prior_flows = Decimal("0") # P&L of years before the report year, at txn-date rates
387
+ for e in entries:
388
+ if not isinstance(e, data.Transaction) or e.date > on:
389
+ continue
390
+ for p in e.postings:
391
+ if p.account not in mapping:
392
+ continue
393
+ root = p.account.split(":", 1)[0]
394
+ if root in ("Assets", "Liabilities", "Equity"):
395
+ inventories.setdefault(p.account, Inventory()).add_position(p)
396
+ elif e.date < fy_start:
397
+ prior_flows += _posting_chf(p, e.date, on, price_map)
398
+
399
+ per_code: dict[str, tuple[Decimal, set[str]]] = {}
400
+ converted: dict[str, Decimal] = {}
401
+ for account, inv in inventories.items():
402
+ total = Decimal("0")
403
+ for pos in inv:
404
+ chf = _to_chf(pos.units, on, price_map)
405
+ total += chf
406
+ if pos.units.currency != "CHF" and pos.units.number:
407
+ converted[pos.units.currency] = (
408
+ converted.get(pos.units.currency, Decimal("0")) + pos.units.number
409
+ )
410
+ code = mapping[account]
411
+ amount, accounts = per_code.get(code, (Decimal("0"), set()))
412
+ per_code[code] = (amount + total, accounts | {account})
413
+
414
+ assets_rows = _rows_from(
415
+ per_code,
416
+ BILANZ_ROWS,
417
+ BILANZ_SECTIONS["current_assets"] + BILANZ_SECTIONS["noncurrent_assets"],
418
+ )
419
+ current = [r for r in assets_rows if r.key in BILANZ_SECTIONS["current_assets"]]
420
+ noncurrent = [r for r in assets_rows if r.key in BILANZ_SECTIONS["noncurrent_assets"]]
421
+ short = _rows_from(per_code, BILANZ_ROWS, BILANZ_SECTIONS["short_term_liabilities"])
422
+ long_ = _rows_from(per_code, BILANZ_ROWS, BILANZ_SECTIONS["long_term_liabilities"])
423
+ equity = _rows_from(per_code, BILANZ_ROWS, BILANZ_SECTIONS["equity"])
424
+
425
+ total_assets = sum((r.amount for r in current + noncurrent), Decimal("0"))
426
+ liabilities_and_equity = sum((r.amount for r in short + long_ + equity), Decimal("0"))
427
+ # Balancing figure = all not-yet-closed P&L since inception. Split it into
428
+ # prior years (Gewinnvortrag) and the report year (Jahresgewinn); the report
429
+ # year's share absorbs the unrealized FX of report-date valuation.
430
+ balancing = total_assets - liabilities_and_equity
431
+ retained_prior = ledger.rappen(-prior_flows)
432
+ result = ledger.rappen(balancing) - retained_prior
433
+
434
+ return BilanzReport(
435
+ at=at,
436
+ current_assets=current,
437
+ noncurrent_assets=noncurrent,
438
+ short_term_liabilities=short,
439
+ long_term_liabilities=long_,
440
+ equity=equity,
441
+ retained_prior=retained_prior,
442
+ result=result,
443
+ total_assets=ledger.rappen(total_assets),
444
+ total_liabilities_equity=ledger.rappen(liabilities_and_equity) + retained_prior + result,
445
+ converted={c: ledger.rappen(v) for c, v in converted.items()},
446
+ )
447
+
448
+
449
+ def compute_erfolg(
450
+ ledger_path: Path, date_from: str, date_to: str, cfg: config.Config | None = None
451
+ ) -> ErfolgReport:
452
+ cfg = cfg or config.get()
453
+ d0, d1 = Date.fromisoformat(date_from), Date.fromisoformat(date_to)
454
+ entries, _ = ledger.load_entries(ledger_path)
455
+ price_map = bc_prices.build_price_map(entries)
456
+ mapping = kmu_map(entries, cfg.entity_marker)
457
+
458
+ per_code: dict[str, tuple[Decimal, set[str]]] = {}
459
+ for e in entries:
460
+ if not isinstance(e, data.Transaction) or not (d0 <= e.date <= d1):
461
+ continue
462
+ for p in e.postings:
463
+ if p.account not in mapping:
464
+ continue
465
+ if p.account.split(":", 1)[0] not in ("Income", "Expenses"):
466
+ continue
467
+ chf = _posting_chf(p, e.date, d1, price_map)
468
+ code = mapping[p.account]
469
+ amount, accounts = per_code.get(code, (Decimal("0"), set()))
470
+ per_code[code] = (amount + chf, accounts | {p.account})
471
+
472
+ revenue = _rows_from(per_code, ERFOLG_ROWS, ("revenue",))
473
+ expenses = _rows_from(
474
+ per_code,
475
+ ERFOLG_ROWS,
476
+ ("materials_services", "personnel", "other_operating", "depreciation"),
477
+ )
478
+ fin_exp = _rows_from(per_code, ERFOLG_ROWS, ("financial_expenses",))
479
+ fin_inc = _rows_from(per_code, ERFOLG_ROWS, ("financial_income",))
480
+
481
+ revenue_total = sum((r.amount for r in revenue), Decimal("0"))
482
+ expense_total = sum((r.amount for r in expenses), Decimal("0"))
483
+ ebit = ledger.rappen(revenue_total - expense_total)
484
+ result = ledger.rappen(
485
+ ebit
486
+ - sum((r.amount for r in fin_exp), Decimal("0"))
487
+ + sum((r.amount for r in fin_inc), Decimal("0"))
488
+ )
489
+ return ErfolgReport(
490
+ date_from=date_from,
491
+ date_to=date_to,
492
+ revenue=revenue,
493
+ expenses=expenses,
494
+ ebit=ebit,
495
+ financial_expenses=fin_exp,
496
+ financial_income=fin_inc,
497
+ result=result,
498
+ )
499
+
500
+
501
+ def compute_konten(
502
+ ledger_path: Path, date_from: str, date_to: str, cfg: config.Config | None = None
503
+ ) -> KontenReport:
504
+ cfg = cfg or config.get()
505
+ d0, d1 = Date.fromisoformat(date_from), Date.fromisoformat(date_to)
506
+ entries, _ = ledger.load_entries(ledger_path)
507
+ price_map = bc_prices.build_price_map(entries)
508
+ mapping = kmu_map(entries, cfg.entity_marker)
509
+
510
+ konten: dict[str, Konto] = {}
511
+ for e in entries:
512
+ if not isinstance(e, data.Transaction) or not (d0 <= e.date <= d1):
513
+ continue
514
+ for p in e.postings:
515
+ code = mapping.get(p.account)
516
+ if code is None:
517
+ continue
518
+ chf = _posting_chf(p, e.date, d1, price_map)
519
+ konto = konten.setdefault(code, Konto(code, [], Decimal("0")))
520
+ konto.lines.append(
521
+ KontoLine(
522
+ str(e.date),
523
+ e.payee or "",
524
+ e.narration or "",
525
+ p.account,
526
+ p.units.number,
527
+ p.units.currency,
528
+ chf,
529
+ )
530
+ )
531
+ konto.flow += chf
532
+ return KontenReport(date_from, date_to, [konten[c] for c in sorted(konten)])
533
+
534
+
535
+ # ── render ───────────────────────────────────────────────────────────────────
536
+
537
+
538
+ def _statement_table(lang: str) -> Table:
539
+ t = Table(box=box.SIMPLE_HEAVY, pad_edge=False, expand=False, show_header=False)
540
+ t.add_column("KMU", justify="right", style="ziffer", no_wrap=True)
541
+ t.add_column("Position", min_width=44)
542
+ t.add_column("CHF", justify="right", no_wrap=True)
543
+ return t
544
+
545
+
546
+ def _add_rows(t: Table, rows: list[RowLine], lang: str) -> None:
547
+ for row in rows:
548
+ t.add_row("", label(row.key, lang), ui.money(row.amount))
549
+ for cl in row.codes:
550
+ t.add_row(
551
+ cl.code, f"[muted]{kmu_name(cl.code, lang)}[/]", f"[muted]{ui.money(cl.amount)}[/]"
552
+ )
553
+
554
+
555
+ def render_bilanz(
556
+ report: BilanzReport,
557
+ lang: str = "en",
558
+ console: Console | None = None,
559
+ cfg: config.Config | None = None,
560
+ ) -> None:
561
+ console = console or ui.console
562
+ console.print()
563
+ console.rule(f"[bold]{label('bilanz_title', lang)}[/] {label('as_at', lang)} {report.at}")
564
+ console.print(
565
+ f"{(cfg or config.get()).entity_name} · OR Art. 959a · "
566
+ f"{(cfg or config.get()).operating_currency}",
567
+ style="muted",
568
+ justify="center",
569
+ )
570
+ console.print()
571
+
572
+ t = _statement_table(lang)
573
+ t.add_row("", f"[bold]{label('assets', lang)}[/]", "")
574
+ for section in ("current_assets", "noncurrent_assets"):
575
+ rows = getattr(report, section)
576
+ if not rows:
577
+ continue
578
+ t.add_section()
579
+ _add_rows(t, rows, lang)
580
+ t.add_row(
581
+ "",
582
+ f"[bold]{label(section, lang)}[/]",
583
+ f"[bold]{ui.money(sum((r.amount for r in rows), Decimal('0')))}[/]",
584
+ )
585
+ t.add_section()
586
+ t.add_row(
587
+ "", f"[bold]{label('total_assets', lang)}[/]", f"[bold]{ui.money(report.total_assets)}[/]"
588
+ )
589
+
590
+ t.add_section()
591
+ t.add_row("", f"[bold]{label('liabilities_equity', lang)}[/]", "")
592
+ for section in ("short_term_liabilities", "long_term_liabilities", "equity"):
593
+ rows = getattr(report, section)
594
+ if not rows and section != "equity":
595
+ continue
596
+ t.add_section()
597
+ _add_rows(t, rows, lang)
598
+ total = sum((r.amount for r in rows), Decimal("0"))
599
+ if section == "equity":
600
+ if report.retained_prior:
601
+ t.add_row("", label("retained_prior", lang), ui.money(report.retained_prior))
602
+ total += report.retained_prior
603
+ t.add_row("", label("result", lang), ui.money(report.result))
604
+ total += report.result
605
+ t.add_row("", f"[bold]{label(section, lang)}[/]", f"[bold]{ui.money(total)}[/]")
606
+ t.add_section()
607
+ t.add_row(
608
+ "",
609
+ f"[bold]{label('total_liabilities_equity', lang)}[/]",
610
+ f"[bold]{ui.money(report.total_liabilities_equity)}[/]",
611
+ )
612
+ console.print(t)
613
+
614
+ if report.converted:
615
+ parts = ", ".join(f"{ui.money(v)} {c}" for c, v in sorted(report.converted.items()))
616
+ note = {
617
+ "en": f"Non-CHF balances valued at the {report.at} rate: {parts}.",
618
+ "de": f"Fremdwährungsbestände zum Kurs per {report.at} bewertet: {parts}.",
619
+ }
620
+ console.print(note.get(lang, note["en"]), style="muted")
621
+ console.print()
622
+
623
+
624
+ def render_erfolg(
625
+ report: ErfolgReport,
626
+ lang: str = "en",
627
+ console: Console | None = None,
628
+ cfg: config.Config | None = None,
629
+ ) -> None:
630
+ console = console or ui.console
631
+ console.print()
632
+ console.rule(f"[bold]{label('erfolg_title', lang)}[/] {report.date_from} – {report.date_to}")
633
+ console.print(
634
+ f"{(cfg or config.get()).entity_name} · OR Art. 959b · "
635
+ f"{(cfg or config.get()).operating_currency}",
636
+ style="muted",
637
+ justify="center",
638
+ )
639
+ console.print()
640
+
641
+ t = _statement_table(lang)
642
+ _add_rows(t, report.revenue, lang)
643
+ t.add_section()
644
+ for row in report.expenses:
645
+ t.add_row("", label(row.key, lang), ui.money(-row.amount))
646
+ for cl in row.codes:
647
+ t.add_row(
648
+ cl.code, f"[muted]{kmu_name(cl.code, lang)}[/]", f"[muted]{ui.money(-cl.amount)}[/]"
649
+ )
650
+ t.add_section()
651
+ t.add_row("", f"[bold]{label('ebit', lang)}[/]", f"[bold]{ui.money(report.ebit)}[/]")
652
+ t.add_section()
653
+ for row in report.financial_expenses:
654
+ _add_rows(
655
+ t,
656
+ [
657
+ RowLine(
658
+ row.key,
659
+ -row.amount,
660
+ [CodeLine(c.code, -c.amount, c.accounts) for c in row.codes],
661
+ )
662
+ ],
663
+ lang,
664
+ )
665
+ for row in report.financial_income:
666
+ _add_rows(t, [row], lang)
667
+ t.add_section()
668
+ style = "refund" if report.result >= 0 else "owe"
669
+ t.add_row(
670
+ "",
671
+ f"[bold {style}]{label('result', lang)}[/]",
672
+ f"[bold {style}]{ui.money(report.result)}[/]",
673
+ )
674
+ console.print(t)
675
+ console.print()
676
+
677
+
678
+ def render_konten(report: KontenReport, lang: str = "en", console: Console | None = None) -> None:
679
+ console = console or ui.console
680
+ console.print()
681
+ console.rule(f"[bold]{label('konten_title', lang)}[/] {report.date_from} – {report.date_to}")
682
+ for konto in report.konten:
683
+ console.print()
684
+ t = Table(
685
+ box=box.SIMPLE,
686
+ title=f"{konto.code} — {kmu_name(konto.code, lang)}",
687
+ title_justify="left",
688
+ title_style="bold",
689
+ )
690
+ t.add_column("Datum" if lang == "de" else "Date", style="muted", no_wrap=True)
691
+ t.add_column("Payee")
692
+ t.add_column("Narration", max_width=40)
693
+ t.add_column("Konto" if lang == "de" else "Account", style="muted")
694
+ t.add_column("Original", justify="right", no_wrap=True)
695
+ t.add_column("CHF", justify="right", no_wrap=True)
696
+ for line in konto.lines:
697
+ t.add_row(
698
+ line.date,
699
+ line.payee,
700
+ line.narration,
701
+ line.account.replace("Assets:CH:GmbH:", "")
702
+ .replace("Liabilities:CH:GmbH:", "")
703
+ .replace("Income:CH:GmbH:", "")
704
+ .replace("Expenses:CH:GmbH:", "")
705
+ .replace("Equity:CH:GmbH:", ""),
706
+ f"{line.original:,.2f} {line.currency}",
707
+ ui.money(line.chf),
708
+ )
709
+ t.add_section()
710
+ t.add_row("", "", "[bold]Total[/]", "", "", f"[bold]{ui.money(konto.flow)}[/]")
711
+ console.print(t)
712
+ console.print()