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/config.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""quints configuration — the entity lives in ``quints.toml``, not in code.
|
|
2
|
+
|
|
3
|
+
Defaults are a neutral Swiss GmbH chart of accounts, so any repo following
|
|
4
|
+
the ``CH:GmbH`` namespacing works out of the box; everything entity-specific
|
|
5
|
+
(name, VAT registration, importer credentials/rules) comes from
|
|
6
|
+
``quints.toml`` — a stranger with their own beancount file and a
|
|
7
|
+
``quints.toml`` gets correct reports without touching this package.
|
|
8
|
+
|
|
9
|
+
Precedence: an explicit ``--config`` path > ``./quints.toml`` > built-in
|
|
10
|
+
defaults. VAT *rates* are deliberately not configurable — they are law and
|
|
11
|
+
live in :data:`quints.ledger.VAT_RATES`.
|
|
12
|
+
|
|
13
|
+
Modules take a ``cfg`` parameter (tests construct :class:`Config` inline) and
|
|
14
|
+
fall back to :func:`get`, the process-wide config the CLI resolves once.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import sys
|
|
20
|
+
from dataclasses import dataclass, replace
|
|
21
|
+
from datetime import date as Date
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
if sys.version_info >= (3, 11):
|
|
25
|
+
import tomllib
|
|
26
|
+
else: # pragma: no cover
|
|
27
|
+
import tomli as tomllib
|
|
28
|
+
|
|
29
|
+
DEFAULT_PATH = Path("quints.toml")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class UbsImport:
|
|
34
|
+
"""[import.ubs] — MT940 statements for one CHF bank account."""
|
|
35
|
+
|
|
36
|
+
account: str = "Assets:CH:GmbH:Current:UBS:CHF"
|
|
37
|
+
iban: str | None = None # required to identify statements
|
|
38
|
+
currency: str = "CHF"
|
|
39
|
+
rules: tuple[tuple[str, str, str], ...] = () # (payee regex, account, flag)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class WiseImport:
|
|
44
|
+
"""[import.wise] — Wise balance statements, one account per currency."""
|
|
45
|
+
|
|
46
|
+
accounts: tuple[tuple[str, str], ...] = ( # (currency, account)
|
|
47
|
+
("CHF", "Assets:CH:GmbH:Current:Wise:CHF"),
|
|
48
|
+
("EUR", "Assets:CH:GmbH:Current:Wise:EUR"),
|
|
49
|
+
("USD", "Assets:CH:GmbH:Current:Wise:USD"),
|
|
50
|
+
)
|
|
51
|
+
fees_account: str = "Expenses:CH:GmbH:BankFees:Wise"
|
|
52
|
+
holder: str | None = None # businessName filter (multi-profile tokens)
|
|
53
|
+
rules: tuple[tuple[str, str, str], ...] = ()
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def account_map(self) -> dict[str, str]:
|
|
57
|
+
return dict(self.accounts)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class StripeImport:
|
|
62
|
+
"""[import.stripe] — Stripe balance transactions for one account."""
|
|
63
|
+
|
|
64
|
+
accounts: tuple[tuple[str, str], ...] = (("EUR", "Assets:CH:GmbH:Current:Stripe:EUR"),)
|
|
65
|
+
fees_account: str = "Expenses:CH:GmbH:BankFees:Stripe"
|
|
66
|
+
tax_account: str = "Assets:CH:GmbH:Tax:InputVAT" # VAT within Stripe's own fees
|
|
67
|
+
account_id: str | None = None # required: acct_… this key must belong to
|
|
68
|
+
rules: tuple[tuple[str, str, str], ...] = ()
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def account_map(self) -> dict[str, str]:
|
|
72
|
+
return dict(self.accounts)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class Config:
|
|
77
|
+
# [entity]
|
|
78
|
+
entity_name: str = "Example GmbH"
|
|
79
|
+
vat_method: str = "effective" # "saldo" would need a different Form-310 mapping
|
|
80
|
+
vat_registered_since: Date | None = None
|
|
81
|
+
operating_currency: str = "CHF"
|
|
82
|
+
# [ledger]
|
|
83
|
+
ledger_main: Path = Path("main.bean")
|
|
84
|
+
ledger_prices: Path = Path("prices.bean")
|
|
85
|
+
# [accounts]
|
|
86
|
+
input_vat: str = "Assets:CH:GmbH:Tax:InputVAT"
|
|
87
|
+
output_vat: str = "Liabilities:CH:GmbH:Tax:OutputVAT"
|
|
88
|
+
bezugsteuer: str = "Liabilities:CH:GmbH:Tax:Bezugsteuer"
|
|
89
|
+
payable_vat: str = "Liabilities:CH:GmbH:Tax:PayableVAT"
|
|
90
|
+
income_prefix: str = "Income:CH:GmbH"
|
|
91
|
+
export_marker: str = ":Export"
|
|
92
|
+
entity_marker: str = ":CH:GmbH:"
|
|
93
|
+
fx_gain: str = "Income:CH:GmbH:FX:CurrencyGain"
|
|
94
|
+
fx_loss: str = "Expenses:CH:GmbH:FX:CurrencyLoss"
|
|
95
|
+
receivable: str = "Assets:CH:GmbH:Receivable:Trade"
|
|
96
|
+
rounding_income: str = "Income:CH:GmbH:Rounding"
|
|
97
|
+
income_domestic: str = "Income:CH:GmbH:Consulting:External:Domestic"
|
|
98
|
+
income_export: str = "Income:CH:GmbH:Consulting:External:Export"
|
|
99
|
+
# [report]
|
|
100
|
+
report_language: str = "en"
|
|
101
|
+
# [import.*] — statement importers (plan 2); None = not configured
|
|
102
|
+
import_ubs: UbsImport | None = None
|
|
103
|
+
import_wise: WiseImport | None = None
|
|
104
|
+
import_stripe: StripeImport | None = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _rules(section: dict) -> tuple[tuple[str, str, str], ...]:
|
|
108
|
+
return tuple((r[0], r[1], r[2]) for r in section.get("rules", ()))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _accounts_map(section: dict, default) -> tuple[tuple[str, str], ...]:
|
|
112
|
+
if "accounts" not in section:
|
|
113
|
+
return default
|
|
114
|
+
return tuple(sorted(section["accounts"].items()))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _import_sections(raw: dict) -> dict:
|
|
118
|
+
imports = raw.get("import") or {}
|
|
119
|
+
updates: dict = {}
|
|
120
|
+
if "ubs" in imports:
|
|
121
|
+
s = imports["ubs"]
|
|
122
|
+
updates["import_ubs"] = UbsImport(
|
|
123
|
+
account=s.get("account", UbsImport.account),
|
|
124
|
+
iban=s.get("iban"),
|
|
125
|
+
currency=s.get("currency", UbsImport.currency),
|
|
126
|
+
rules=_rules(s),
|
|
127
|
+
)
|
|
128
|
+
if "wise" in imports:
|
|
129
|
+
s = imports["wise"]
|
|
130
|
+
updates["import_wise"] = WiseImport(
|
|
131
|
+
accounts=_accounts_map(s, WiseImport.accounts),
|
|
132
|
+
fees_account=s.get("fees_account", WiseImport.fees_account),
|
|
133
|
+
holder=s.get("holder"),
|
|
134
|
+
rules=_rules(s),
|
|
135
|
+
)
|
|
136
|
+
if "stripe" in imports:
|
|
137
|
+
s = imports["stripe"]
|
|
138
|
+
updates["import_stripe"] = StripeImport(
|
|
139
|
+
accounts=_accounts_map(s, StripeImport.accounts),
|
|
140
|
+
fees_account=s.get("fees_account", StripeImport.fees_account),
|
|
141
|
+
tax_account=s.get("tax_account", StripeImport.tax_account),
|
|
142
|
+
account_id=s.get("account_id"),
|
|
143
|
+
rules=_rules(s),
|
|
144
|
+
)
|
|
145
|
+
return updates
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _from_mapping(raw: dict) -> Config:
|
|
149
|
+
entity = raw.get("entity") or {}
|
|
150
|
+
ledger_ = raw.get("ledger") or {}
|
|
151
|
+
accounts = raw.get("accounts") or {}
|
|
152
|
+
report = raw.get("report") or {}
|
|
153
|
+
cfg = Config()
|
|
154
|
+
updates: dict = {}
|
|
155
|
+
|
|
156
|
+
def take(section: dict, key: str, field: str, cast=None):
|
|
157
|
+
if key in section:
|
|
158
|
+
value = section[key]
|
|
159
|
+
updates[field] = cast(value) if cast else value
|
|
160
|
+
|
|
161
|
+
take(entity, "name", "entity_name")
|
|
162
|
+
take(entity, "vat_method", "vat_method")
|
|
163
|
+
take(entity, "vat_registered_since", "vat_registered_since")
|
|
164
|
+
take(entity, "operating_currency", "operating_currency")
|
|
165
|
+
take(ledger_, "main", "ledger_main", Path)
|
|
166
|
+
take(ledger_, "prices", "ledger_prices", Path)
|
|
167
|
+
for key in (
|
|
168
|
+
"input_vat",
|
|
169
|
+
"output_vat",
|
|
170
|
+
"bezugsteuer",
|
|
171
|
+
"payable_vat",
|
|
172
|
+
"income_prefix",
|
|
173
|
+
"export_marker",
|
|
174
|
+
"entity_marker",
|
|
175
|
+
"fx_gain",
|
|
176
|
+
"fx_loss",
|
|
177
|
+
"receivable",
|
|
178
|
+
"rounding_income",
|
|
179
|
+
"income_domestic",
|
|
180
|
+
"income_export",
|
|
181
|
+
):
|
|
182
|
+
take(accounts, key, key)
|
|
183
|
+
take(report, "language", "report_language")
|
|
184
|
+
updates.update(_import_sections(raw))
|
|
185
|
+
return replace(cfg, **updates)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def load(path: Path | None = None) -> Config:
|
|
189
|
+
"""Load ``path``, or ``./quints.toml`` if present, else defaults."""
|
|
190
|
+
if path is None:
|
|
191
|
+
path = DEFAULT_PATH if DEFAULT_PATH.exists() else None
|
|
192
|
+
if path is None:
|
|
193
|
+
return Config()
|
|
194
|
+
with open(path, "rb") as f:
|
|
195
|
+
return _from_mapping(tomllib.load(f))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
_current: Config | None = None
|
|
199
|
+
_path: Path | None = None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def set_path(path: Path | None) -> None:
|
|
203
|
+
"""CLI hook: remember --config and invalidate the cached config."""
|
|
204
|
+
global _path, _current
|
|
205
|
+
_path = path
|
|
206
|
+
_current = None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def get() -> Config:
|
|
210
|
+
"""The process-wide config (cached; honors a previous :func:`set_path`)."""
|
|
211
|
+
global _current
|
|
212
|
+
if _current is None:
|
|
213
|
+
_current = load(_path)
|
|
214
|
+
return _current
|
quints/fava/__init__.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Fava extension: the quints review panel (docs/plans/06, plan 5.4.1).
|
|
2
|
+
|
|
3
|
+
The read-only half of the approval-queue surface: VAT status, open
|
|
4
|
+
receivables, staging drafts, and inbox backlog — the same compute layer
|
|
5
|
+
the CLI's ``--json`` exposes, rendered inside the UI users already run.
|
|
6
|
+
|
|
7
|
+
Enable in the beancount file:
|
|
8
|
+
|
|
9
|
+
2024-01-01 custom "fava-extension" "quints.fava"
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from fava.ext import FavaExtensionBase
|
|
19
|
+
|
|
20
|
+
from .. import config, receivables, settlement
|
|
21
|
+
from .. import inbox as inbox_mod
|
|
22
|
+
|
|
23
|
+
_DRAFT_LINE = re.compile(r"^\d{4}-\d{2}-\d{2} +([*!]) ", re.M)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class QuintDashboard(FavaExtensionBase):
|
|
27
|
+
"""VAT, receivables, and review-queue panel."""
|
|
28
|
+
|
|
29
|
+
report_title = "Quint"
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def _root(self) -> Path:
|
|
33
|
+
return Path(self.ledger.beancount_file_path).parent
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def _cfg(self) -> config.Config:
|
|
37
|
+
return config.load(
|
|
38
|
+
self._root / "quints.toml" if (self._root / "quints.toml").exists() else None
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def today(self):
|
|
42
|
+
return datetime.now(timezone.utc).date()
|
|
43
|
+
|
|
44
|
+
def vat_status(self):
|
|
45
|
+
"""(liabilities, unlinked_owed, total_owed, today) — see settlement."""
|
|
46
|
+
return settlement.outstanding(
|
|
47
|
+
Path(self.ledger.beancount_file_path),
|
|
48
|
+
cfg=self._cfg,
|
|
49
|
+
entries=self.ledger.all_entries,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def open_receivables(self):
|
|
53
|
+
return receivables.compute_from_entries(self.ledger.all_entries, self.today(), self._cfg)
|
|
54
|
+
|
|
55
|
+
def staging(self):
|
|
56
|
+
"""Pending staging drafts: (file name, total drafts, flagged drafts)."""
|
|
57
|
+
out = []
|
|
58
|
+
for f in sorted((self._root / "staging").glob("*.bean")):
|
|
59
|
+
flags = _DRAFT_LINE.findall(f.read_text(encoding="utf-8"))
|
|
60
|
+
out.append((f.name, len(flags), flags.count("!")))
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
def inbox(self):
|
|
64
|
+
"""Inbox inventory (hints + duplicate/linked status) — see quints.inbox."""
|
|
65
|
+
return inbox_mod.scan(self._root, self.ledger.all_entries)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
{% set liabilities, unlinked, vat_total, today = extension.vat_status() %}
|
|
2
|
+
{% set open_invoices = extension.open_receivables() %}
|
|
3
|
+
{% set staging = extension.staging() %}
|
|
4
|
+
{% set inbox = extension.inbox() %}
|
|
5
|
+
|
|
6
|
+
<div class="left">
|
|
7
|
+
|
|
8
|
+
<h3>VAT outstanding · {{ today }}</h3>
|
|
9
|
+
{% if not liabilities and not unlinked %}
|
|
10
|
+
<p>Nothing outstanding — all filed VAT is paid. ✓</p>
|
|
11
|
+
{% else %}
|
|
12
|
+
<table is="sortable-table">
|
|
13
|
+
<thead>
|
|
14
|
+
<tr><th data-sort="string">Period</th><th data-sort="num">Owed CHF</th>
|
|
15
|
+
<th data-sort="string">Due</th><th data-sort="num">Days left</th></tr>
|
|
16
|
+
</thead>
|
|
17
|
+
<tbody>
|
|
18
|
+
{% for l in liabilities %}
|
|
19
|
+
<tr>
|
|
20
|
+
<td>{{ l.period }}</td>
|
|
21
|
+
<td class="num">{{ l.owed }}</td>
|
|
22
|
+
<td>{{ l.due or "—" }}</td>
|
|
23
|
+
<td class="num">{{ l.days_left if l.days_left is not none else "—" }}</td>
|
|
24
|
+
</tr>
|
|
25
|
+
{% endfor %}
|
|
26
|
+
{% if unlinked %}
|
|
27
|
+
<tr><td>(unlinked)</td><td class="num">{{ unlinked }}</td><td>—</td><td></td></tr>
|
|
28
|
+
{% endif %}
|
|
29
|
+
</tbody>
|
|
30
|
+
<tfoot><tr><td>Total</td><td class="num">{{ vat_total }}</td><td></td><td></td></tr></tfoot>
|
|
31
|
+
</table>
|
|
32
|
+
{% endif %}
|
|
33
|
+
|
|
34
|
+
<h3>Open receivables</h3>
|
|
35
|
+
{% if not open_invoices %}
|
|
36
|
+
<p>Nothing open — every invoice is settled. ✓</p>
|
|
37
|
+
{% else %}
|
|
38
|
+
<table is="sortable-table">
|
|
39
|
+
<thead>
|
|
40
|
+
<tr><th data-sort="string">Invoice</th><th data-sort="string">Payee</th>
|
|
41
|
+
<th data-sort="string">Date</th><th data-sort="num">Age (d)</th>
|
|
42
|
+
<th data-sort="num">Open</th></tr>
|
|
43
|
+
</thead>
|
|
44
|
+
<tbody>
|
|
45
|
+
{% for o in open_invoices %}
|
|
46
|
+
<tr>
|
|
47
|
+
<td>{{ o.number }}</td>
|
|
48
|
+
<td>{{ o.payee }}</td>
|
|
49
|
+
<td>{{ o.invoice_date }}</td>
|
|
50
|
+
<td class="num"{% if o.age_days > 30 %} style="color: var(--budget-negative)"{% endif %}>{{ o.age_days }}</td>
|
|
51
|
+
<td class="num">{{ o.open_amount }} {{ o.currency }}</td>
|
|
52
|
+
</tr>
|
|
53
|
+
{% endfor %}
|
|
54
|
+
</tbody>
|
|
55
|
+
</table>
|
|
56
|
+
{% endif %}
|
|
57
|
+
|
|
58
|
+
<h3>Review queue</h3>
|
|
59
|
+
{% if not staging and not inbox %}
|
|
60
|
+
<p>Inbox and staging are empty. ✓</p>
|
|
61
|
+
{% else %}
|
|
62
|
+
{% if staging %}
|
|
63
|
+
<table>
|
|
64
|
+
<thead><tr><th>Staging file</th><th>Drafts</th><th>Needs decision (!)</th></tr></thead>
|
|
65
|
+
<tbody>
|
|
66
|
+
{% for name, total, flagged in staging %}
|
|
67
|
+
<tr><td>{{ name }}</td><td class="num">{{ total }}</td><td class="num">{{ flagged }}</td></tr>
|
|
68
|
+
{% endfor %}
|
|
69
|
+
</tbody>
|
|
70
|
+
</table>
|
|
71
|
+
{% endif %}
|
|
72
|
+
{% if inbox %}
|
|
73
|
+
<p>Inbox — {{ inbox | length }} document(s):</p>
|
|
74
|
+
<ul>
|
|
75
|
+
{% for d in inbox %}
|
|
76
|
+
<li>{{ d.name }}
|
|
77
|
+
{%- if d.duplicate_of %} — duplicate of {{ d.duplicate_of }}
|
|
78
|
+
{%- elif d.linked %} — already linked in ledger
|
|
79
|
+
{%- endif %}</li>
|
|
80
|
+
{% endfor %}
|
|
81
|
+
</ul>
|
|
82
|
+
{% endif %}
|
|
83
|
+
{% endif %}
|
|
84
|
+
|
|
85
|
+
</div>
|
quints/fx.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Year-end FX revaluation of non-CHF GmbH balances (OR Art. 960 ff.).
|
|
2
|
+
|
|
3
|
+
Beancount accounts hold plain units, so a currency account's **book value in
|
|
4
|
+
CHF** is implicit: the sum of each posting's CHF weight (its booked ``@``/
|
|
5
|
+
``@@`` rate, or the BAZG rate of the transaction date) — the same valuation
|
|
6
|
+
the Erfolgsrechnung uses. The revaluation books, per currency, the difference
|
|
7
|
+
between that book value and the balance valued at the report-date BAZG rate:
|
|
8
|
+
|
|
9
|
+
2026-12-31 * "Wise" "Year-end FX revaluation EUR (Art. 960 OR)"
|
|
10
|
+
Assets:…:Wise:EUR -6395.74 EUR @@ 5952.86 CHF ; out at book value
|
|
11
|
+
Assets:…:Wise:EUR 6395.74 EUR @@ 5900.11 CHF ; back at year-end rate
|
|
12
|
+
Expenses:CH:GmbH:FX:CurrencyLoss 52.75 CHF
|
|
13
|
+
|
|
14
|
+
Units net to zero; the CHF delta lands in CurrencyGain/CurrencyLoss. After
|
|
15
|
+
booking, the account's implicit book value equals the year-end market value,
|
|
16
|
+
so the KMU Bilanz's balancing result and the Erfolgsrechnung agree — the
|
|
17
|
+
unrealized FX becomes explicit instead of a reconciling difference.
|
|
18
|
+
|
|
19
|
+
Like the settlement, this prints ledger text to review and paste — it never
|
|
20
|
+
writes the books.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from datetime import date as Date
|
|
27
|
+
from decimal import Decimal
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from beancount.core import convert as bc_convert
|
|
31
|
+
from beancount.core import data
|
|
32
|
+
from beancount.core import prices as bc_prices
|
|
33
|
+
from beancount.core.inventory import Inventory
|
|
34
|
+
from rich.console import Console
|
|
35
|
+
|
|
36
|
+
from . import config, ledger, ui
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Revaluation:
|
|
41
|
+
currency: str
|
|
42
|
+
account: str
|
|
43
|
+
units: Decimal
|
|
44
|
+
book_chf: Decimal # implicit book value (booked rates / txn-date BAZG)
|
|
45
|
+
market_chf: Decimal # units at the report-date BAZG rate
|
|
46
|
+
rate: Decimal # report-date rate used
|
|
47
|
+
rate_date: Date # BAZG date of that rate
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def delta(self) -> Decimal:
|
|
51
|
+
"""Positive = unrealized gain, negative = loss."""
|
|
52
|
+
return self.market_chf - self.book_chf
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RateUnavailable(RuntimeError):
|
|
56
|
+
def __init__(self, ccy: str, on: Date):
|
|
57
|
+
super().__init__(f"no {ccy}→CHF rate on or before {on}")
|
|
58
|
+
self.ccy, self.on = ccy, on
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def compute(ledger_path: Path, at: str, cfg: config.Config | None = None) -> list[Revaluation]:
|
|
62
|
+
cfg = cfg or config.get()
|
|
63
|
+
on = Date.fromisoformat(at)
|
|
64
|
+
entries, _ = ledger.load_entries(ledger_path)
|
|
65
|
+
price_map = bc_prices.build_price_map(entries)
|
|
66
|
+
|
|
67
|
+
inventories: dict[str, Inventory] = {}
|
|
68
|
+
book: dict[tuple[str, str], Decimal] = {} # (account, ccy) → CHF book value
|
|
69
|
+
for e in entries:
|
|
70
|
+
if not isinstance(e, data.Transaction) or e.date > on:
|
|
71
|
+
continue
|
|
72
|
+
for p in e.postings:
|
|
73
|
+
root = p.account.split(":", 1)[0]
|
|
74
|
+
if cfg.entity_marker not in p.account or root not in ("Assets", "Liabilities"):
|
|
75
|
+
continue
|
|
76
|
+
if p.units.currency == "CHF":
|
|
77
|
+
continue
|
|
78
|
+
inventories.setdefault(p.account, Inventory()).add_position(p)
|
|
79
|
+
weight = bc_convert.get_weight(p)
|
|
80
|
+
if weight.currency == "CHF":
|
|
81
|
+
chf = weight.number
|
|
82
|
+
else:
|
|
83
|
+
conv = bc_convert.convert_amount(weight, "CHF", price_map, date=e.date)
|
|
84
|
+
chf = conv.number if conv.currency == "CHF" else None
|
|
85
|
+
if chf is None:
|
|
86
|
+
raise RateUnavailable(p.units.currency, e.date)
|
|
87
|
+
key = (p.account, p.units.currency)
|
|
88
|
+
book[key] = book.get(key, Decimal("0")) + chf
|
|
89
|
+
|
|
90
|
+
revaluations: list[Revaluation] = []
|
|
91
|
+
for account, inv in sorted(inventories.items()):
|
|
92
|
+
for pos in inv:
|
|
93
|
+
units = pos.units.number.quantize(Decimal("0.01"))
|
|
94
|
+
if not units:
|
|
95
|
+
continue
|
|
96
|
+
rate_date, rate = ledger.rate(price_map, pos.units.currency, on)
|
|
97
|
+
if rate is None:
|
|
98
|
+
raise RateUnavailable(pos.units.currency, on)
|
|
99
|
+
market = ledger.rappen(units * Decimal(rate))
|
|
100
|
+
book_value = ledger.rappen(book.get((account, pos.units.currency), Decimal("0")))
|
|
101
|
+
revaluations.append(
|
|
102
|
+
Revaluation(
|
|
103
|
+
currency=pos.units.currency,
|
|
104
|
+
account=account,
|
|
105
|
+
units=units,
|
|
106
|
+
book_chf=book_value,
|
|
107
|
+
market_chf=market,
|
|
108
|
+
rate=Decimal(rate),
|
|
109
|
+
rate_date=rate_date,
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
return revaluations
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def revaluation_text(
|
|
116
|
+
revaluations: list[Revaluation], at: str, cfg: config.Config | None = None
|
|
117
|
+
) -> str:
|
|
118
|
+
"""Paste-ready ledger text, one transaction per currency."""
|
|
119
|
+
cfg = cfg or config.get()
|
|
120
|
+
by_ccy: dict[str, list[Revaluation]] = {}
|
|
121
|
+
for r in revaluations:
|
|
122
|
+
if r.delta:
|
|
123
|
+
by_ccy.setdefault(r.currency, []).append(r)
|
|
124
|
+
|
|
125
|
+
chunks: list[str] = []
|
|
126
|
+
for ccy, items in sorted(by_ccy.items()):
|
|
127
|
+
lines = [
|
|
128
|
+
f'{at} * "Wise" "Year-end FX revaluation {ccy} (Art. 960 OR)"',
|
|
129
|
+
]
|
|
130
|
+
total_delta = Decimal("0")
|
|
131
|
+
for r in items:
|
|
132
|
+
lines.append(
|
|
133
|
+
f" ; {r.units} {ccy} @ {r.rate:.5f} CHF/{ccy} (BAZG {r.rate_date}) "
|
|
134
|
+
f"= {r.market_chf} CHF vs {r.book_chf} CHF book"
|
|
135
|
+
)
|
|
136
|
+
lines.append(f" {r.account:<48} {-r.units:>12} {ccy} @@ {r.book_chf} CHF")
|
|
137
|
+
lines.append(f" {r.account:<48} {r.units:>12} {ccy} @@ {r.market_chf} CHF")
|
|
138
|
+
total_delta += r.delta
|
|
139
|
+
if total_delta > 0:
|
|
140
|
+
lines.append(f" {cfg.fx_gain:<48} {-total_delta:>12} CHF")
|
|
141
|
+
else:
|
|
142
|
+
lines.append(f" {cfg.fx_loss:<48} {-total_delta:>12} CHF")
|
|
143
|
+
chunks.append("\n".join(lines))
|
|
144
|
+
return "\n\n".join(chunks)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def render(revaluations: list[Revaluation], at: str, console: Console | None = None) -> None:
|
|
148
|
+
console = console or ui.console
|
|
149
|
+
if not any(r.delta for r in revaluations):
|
|
150
|
+
console.print(f"No unrealized FX at {at} — nothing to revalue.", style="ok")
|
|
151
|
+
return
|
|
152
|
+
console.print()
|
|
153
|
+
console.rule(f"[bold]FX revaluation[/] per {at}")
|
|
154
|
+
for r in revaluations:
|
|
155
|
+
style = "refund" if r.delta > 0 else "owe" if r.delta < 0 else "muted"
|
|
156
|
+
console.print(
|
|
157
|
+
f" {r.account} {r.units} {r.currency}: book {ui.money(r.book_chf)} → "
|
|
158
|
+
f"market {ui.money(r.market_chf)} CHF [{style}]{ui.money(r.delta)}[/]"
|
|
159
|
+
)
|
|
160
|
+
console.print()
|
|
161
|
+
console.print("Paste into books/<year>.bean (review first):", style="muted")
|
|
162
|
+
console.print()
|
|
163
|
+
console.print(revaluation_text(revaluations, at), highlight=False)
|
|
164
|
+
console.print()
|