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