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/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()
|