quints 0.4.0__tar.gz → 0.4.1__tar.gz
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-0.4.0 → quints-0.4.1}/PKG-INFO +1 -1
- {quints-0.4.0 → quints-0.4.1}/pyproject.toml +1 -1
- {quints-0.4.0 → quints-0.4.1}/src/quints/cli.py +25 -10
- {quints-0.4.0 → quints-0.4.1}/src/quints/config.py +53 -40
- {quints-0.4.0 → quints-0.4.1}/src/quints/fava/__init__.py +28 -3
- {quints-0.4.0 → quints-0.4.1}/src/quints/fx.py +6 -3
- {quints-0.4.0 → quints-0.4.1}/src/quints/importing.py +59 -28
- {quints-0.4.0 → quints-0.4.1}/src/quints/inbox.py +3 -3
- {quints-0.4.0 → quints-0.4.1}/src/quints/invoice/draft.py +3 -2
- {quints-0.4.0 → quints-0.4.1}/src/quints/invoice/labels.py +1 -1
- {quints-0.4.0 → quints-0.4.1}/src/quints/invoice/model.py +15 -1
- {quints-0.4.0 → quints-0.4.1}/src/quints/invoice/qr.py +8 -3
- {quints-0.4.0 → quints-0.4.1}/src/quints/invoice/render.py +46 -15
- {quints-0.4.0 → quints-0.4.1}/src/quints/kmu.py +17 -7
- {quints-0.4.0 → quints-0.4.1}/src/quints/ledger.py +1 -1
- {quints-0.4.0 → quints-0.4.1}/src/quints/match.py +16 -11
- {quints-0.4.0 → quints-0.4.1}/src/quints/mwst.py +27 -11
- {quints-0.4.0 → quints-0.4.1}/src/quints/plugins/kmu.py +1 -1
- {quints-0.4.0 → quints-0.4.1}/src/quints/prices.py +13 -4
- {quints-0.4.0 → quints-0.4.1}/src/quints/receivables.py +28 -16
- {quints-0.4.0 → quints-0.4.1}/src/quints/report_pdf.py +19 -7
- {quints-0.4.0 → quints-0.4.1}/src/quints/settlement.py +30 -14
- {quints-0.4.0 → quints-0.4.1}/src/quints/vat.py +3 -1
- {quints-0.4.0 → quints-0.4.1}/tests/test_cli_json.py +4 -3
- {quints-0.4.0 → quints-0.4.1}/tests/test_config.py +16 -12
- {quints-0.4.0 → quints-0.4.1}/tests/test_fava_ext.py +3 -1
- {quints-0.4.0 → quints-0.4.1}/tests/test_fx.py +5 -4
- {quints-0.4.0 → quints-0.4.1}/tests/test_importing.py +18 -11
- {quints-0.4.0 → quints-0.4.1}/tests/test_inbox_match.py +19 -3
- {quints-0.4.0 → quints-0.4.1}/tests/test_invoice.py +17 -15
- {quints-0.4.0 → quints-0.4.1}/tests/test_kmu_report.py +7 -6
- {quints-0.4.0 → quints-0.4.1}/tests/test_mwst.py +5 -4
- {quints-0.4.0 → quints-0.4.1}/tests/test_prices_sync.py +19 -12
- {quints-0.4.0 → quints-0.4.1}/tests/test_receivables.py +4 -3
- quints-0.4.1/tests/test_report_pdf.py +87 -0
- {quints-0.4.0 → quints-0.4.1}/tests/test_settlement.py +22 -21
- quints-0.4.0/tests/test_report_pdf.py +0 -46
- {quints-0.4.0 → quints-0.4.1}/.gitignore +0 -0
- {quints-0.4.0 → quints-0.4.1}/LICENSE +0 -0
- {quints-0.4.0 → quints-0.4.1}/README.md +0 -0
- {quints-0.4.0 → quints-0.4.1}/examples/.gitignore +0 -0
- {quints-0.4.0 → quints-0.4.1}/examples/AGENTS.md +0 -0
- {quints-0.4.0 → quints-0.4.1}/examples/CLAUDE.md +0 -0
- {quints-0.4.0 → quints-0.4.1}/examples/accounts.bean +0 -0
- {quints-0.4.0 → quints-0.4.1}/examples/answers.toml +0 -0
- {quints-0.4.0 → quints-0.4.1}/examples/books/2026.bean +0 -0
- {quints-0.4.0 → quints-0.4.1}/examples/commodities.bean +0 -0
- {quints-0.4.0 → quints-0.4.1}/examples/documents/.gitkeep +0 -0
- {quints-0.4.0 → quints-0.4.1}/examples/inbox/.gitkeep +0 -0
- {quints-0.4.0 → quints-0.4.1}/examples/invoicing/acme-2026-07.yaml +0 -0
- {quints-0.4.0 → quints-0.4.1}/examples/invoicing/customers.yaml +0 -0
- {quints-0.4.0 → quints-0.4.1}/examples/invoicing/globex-2026-08.yaml +0 -0
- {quints-0.4.0 → quints-0.4.1}/examples/invoicing/issuer.yaml +0 -0
- {quints-0.4.0 → quints-0.4.1}/examples/main.bean +0 -0
- {quints-0.4.0 → quints-0.4.1}/examples/prices.bean +0 -0
- {quints-0.4.0 → quints-0.4.1}/examples/pyproject.toml +0 -0
- {quints-0.4.0 → quints-0.4.1}/examples/quints.toml +0 -0
- {quints-0.4.0 → quints-0.4.1}/examples/staging/.gitkeep +0 -0
- {quints-0.4.0 → quints-0.4.1}/examples/statements/ubs-2026.mt940 +0 -0
- {quints-0.4.0 → quints-0.4.1}/src/quints/__init__.py +0 -0
- {quints-0.4.0 → quints-0.4.1}/src/quints/fava/templates/QuintDashboard.html +0 -0
- {quints-0.4.0 → quints-0.4.1}/src/quints/init.py +0 -0
- {quints-0.4.0 → quints-0.4.1}/src/quints/invoice/__init__.py +0 -0
- {quints-0.4.0 → quints-0.4.1}/src/quints/invoice/template.typ +0 -0
- {quints-0.4.0 → quints-0.4.1}/src/quints/invoice/vatid.py +0 -0
- {quints-0.4.0 → quints-0.4.1}/src/quints/invoice/verify.py +0 -0
- {quints-0.4.0 → quints-0.4.1}/src/quints/plugins/__init__.py +0 -0
- {quints-0.4.0 → quints-0.4.1}/src/quints/report.typ +0 -0
- {quints-0.4.0 → quints-0.4.1}/src/quints/ui.py +0 -0
- {quints-0.4.0 → quints-0.4.1}/tests/fixtures/transactions.mt940 +0 -0
- {quints-0.4.0 → quints-0.4.1}/tests/test_docs.py +0 -0
- {quints-0.4.0 → quints-0.4.1}/tests/test_init.py +0 -0
- {quints-0.4.0 → quints-0.4.1}/tests/test_kmu_plugin.py +0 -0
- {quints-0.4.0 → quints-0.4.1}/tests/test_ledger.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: quints
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Swiss VAT & accounting toolkit for plain-text (beancount) books: MWST reports and quarterly settlement, Bezugsteuer helpers, BAZG daily FX rates, QR-bill invoicing, KMU statutory statements, and statement importers (UBS MT940, Wise, Stripe) that draft into a staging area
|
|
5
5
|
License: GPL-2.0-only
|
|
6
6
|
License-File: LICENSE
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "quints"
|
|
3
|
-
version = "0.4.
|
|
3
|
+
version = "0.4.1"
|
|
4
4
|
description = "Swiss VAT & accounting toolkit for plain-text (beancount) books: MWST reports and quarterly settlement, Bezugsteuer helpers, BAZG daily FX rates, QR-bill invoicing, KMU statutory statements, and statement importers (UBS MT940, Wise, Stripe) that draft into a staging area"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from collections.abc import Callable
|
|
5
6
|
from dataclasses import replace
|
|
6
7
|
from datetime import date as Date
|
|
7
8
|
from decimal import Decimal, InvalidOperation
|
|
8
9
|
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
9
11
|
|
|
10
12
|
import typer
|
|
11
13
|
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from _typeshed import DataclassInstance
|
|
16
|
+
|
|
12
17
|
from . import (
|
|
13
18
|
config as config_mod,
|
|
14
19
|
)
|
|
@@ -66,7 +71,7 @@ def _print_version(value: bool) -> None:
|
|
|
66
71
|
|
|
67
72
|
|
|
68
73
|
@app.callback()
|
|
69
|
-
def
|
|
74
|
+
def root(
|
|
70
75
|
config: Path | None = typer.Option(
|
|
71
76
|
None, "--config", help="quints.toml path (default: ./quints.toml, else built-in defaults)."
|
|
72
77
|
),
|
|
@@ -103,7 +108,9 @@ def _lang_option() -> str:
|
|
|
103
108
|
)
|
|
104
109
|
|
|
105
110
|
|
|
106
|
-
def _emit(
|
|
111
|
+
def _emit(
|
|
112
|
+
report: DataclassInstance, render: Callable[..., object], lang: str | None, as_json: bool
|
|
113
|
+
) -> None:
|
|
107
114
|
lang = lang or config_mod.get().report_language
|
|
108
115
|
if as_json:
|
|
109
116
|
import dataclasses
|
|
@@ -126,7 +133,7 @@ def _parse_date(text: str) -> Date:
|
|
|
126
133
|
raise typer.Exit(1) from None
|
|
127
134
|
|
|
128
135
|
|
|
129
|
-
def _json_out(payload) -> None:
|
|
136
|
+
def _json_out(payload: object) -> None:
|
|
130
137
|
"""Emit a machine-readable payload (Decimals/dates as strings)."""
|
|
131
138
|
import json
|
|
132
139
|
|
|
@@ -535,11 +542,13 @@ def import_wise(
|
|
|
535
542
|
raise typer.Exit(1)
|
|
536
543
|
_parse_date(from_)
|
|
537
544
|
_parse_date(to)
|
|
545
|
+
from beangulp_wise import ScaChallenge, WiseError
|
|
546
|
+
|
|
538
547
|
try:
|
|
539
548
|
statements = importing_mod.fetch_wise(from_, to, out)
|
|
540
|
-
except
|
|
549
|
+
except WiseError as e:
|
|
541
550
|
typer.secho(f"ERROR: {e}", fg="red", err=True)
|
|
542
|
-
if isinstance(e,
|
|
551
|
+
if isinstance(e, ScaChallenge):
|
|
543
552
|
typer.secho(
|
|
544
553
|
"Statements are SCA-protected: upload .wise/public.pem in Wise "
|
|
545
554
|
"(Settings → API tokens → Manage public keys) and set "
|
|
@@ -608,18 +617,19 @@ def import_stripe(
|
|
|
608
617
|
_report_import(result, as_json)
|
|
609
618
|
|
|
610
619
|
|
|
611
|
-
def _report_import(result, as_json: bool = False) -> None:
|
|
620
|
+
def _report_import(result: importing_mod.ImportResult, as_json: bool = False) -> None:
|
|
612
621
|
if as_json:
|
|
622
|
+
from beancount.core import data
|
|
613
623
|
|
|
614
|
-
def txn(t):
|
|
624
|
+
def txn(t: data.Transaction) -> dict[str, object]:
|
|
615
625
|
u = t.postings[0].units
|
|
616
626
|
return {
|
|
617
627
|
"date": str(t.date),
|
|
618
628
|
"flag": t.flag,
|
|
619
629
|
"payee": t.payee,
|
|
620
630
|
"narration": t.narration,
|
|
621
|
-
"amount": u.number,
|
|
622
|
-
"currency": u.currency,
|
|
631
|
+
"amount": u.number if u else None,
|
|
632
|
+
"currency": u.currency if u else None,
|
|
623
633
|
}
|
|
624
634
|
|
|
625
635
|
_json_out(
|
|
@@ -789,6 +799,8 @@ def invoice(
|
|
|
789
799
|
print(ledger_draft)
|
|
790
800
|
print()
|
|
791
801
|
elif cc.ok:
|
|
802
|
+
if cc.ledger_total is None: # unreachable: found implies totals are set
|
|
803
|
+
raise ValueError(f"cross-check for {inv.number} lost its ledger total")
|
|
792
804
|
ui.console.print(
|
|
793
805
|
f"[ok]Ledger match[/] ({cc.date}): {inv.currency} {m.money(cc.ledger_total)}"
|
|
794
806
|
)
|
|
@@ -797,6 +809,9 @@ def invoice(
|
|
|
797
809
|
f"[warn]booking date {cc.date} ≠ invoice date {inv.issue_date}[/]"
|
|
798
810
|
)
|
|
799
811
|
else:
|
|
812
|
+
if cc.ledger_total is None or cc.invoice_total is None:
|
|
813
|
+
# unreachable: found implies totals are set, but narrows the Optionals
|
|
814
|
+
raise ValueError(f"cross-check for {inv.number} lost its totals")
|
|
800
815
|
ui.console.print(
|
|
801
816
|
f"[err]Ledger CONFLICT[/] ({cc.date}): {inv.number} is already booked "
|
|
802
817
|
f"at {m.money(cc.ledger_total)} but the invoice says "
|
|
@@ -812,7 +827,7 @@ def invoice(
|
|
|
812
827
|
"kind": inv.kind,
|
|
813
828
|
"currency": inv.currency,
|
|
814
829
|
"issue_date": inv.issue_date,
|
|
815
|
-
"customer": inv.
|
|
830
|
+
"customer": inv.resolved_customer.name,
|
|
816
831
|
"pdf": str(path),
|
|
817
832
|
"totals": totals.model_dump(),
|
|
818
833
|
"qr_payload_ok": qr_ok,
|
|
@@ -120,67 +120,80 @@ class Config:
|
|
|
120
120
|
import_stripe: StripeImport | None = None
|
|
121
121
|
|
|
122
122
|
|
|
123
|
-
def _rules(section: dict) -> tuple[tuple[str, str, str], ...]:
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
123
|
+
def _rules(section: dict[str, object]) -> tuple[tuple[str, str, str], ...]:
|
|
124
|
+
rules = section.get("rules")
|
|
125
|
+
if not isinstance(rules, list):
|
|
126
|
+
return ()
|
|
127
|
+
return tuple((r[0], r[1], r[2]) for r in rules)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _accounts_map(
|
|
131
|
+
section: dict[str, object], default: tuple[tuple[str, str], ...]
|
|
132
|
+
) -> tuple[tuple[str, str], ...]:
|
|
133
|
+
accounts = section.get("accounts")
|
|
134
|
+
if not isinstance(accounts, dict):
|
|
129
135
|
return default
|
|
130
|
-
return tuple(sorted(
|
|
136
|
+
return tuple(sorted(accounts.items()))
|
|
131
137
|
|
|
132
138
|
|
|
133
|
-
def _import_sections(raw: dict) -> dict:
|
|
134
|
-
imports = raw.get("import")
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
139
|
+
def _import_sections(raw: dict[str, object]) -> dict[str, object]:
|
|
140
|
+
imports = raw.get("import")
|
|
141
|
+
if not isinstance(imports, dict):
|
|
142
|
+
return {}
|
|
143
|
+
updates: dict[str, object] = {}
|
|
144
|
+
ubs = imports.get("ubs")
|
|
145
|
+
if isinstance(ubs, dict):
|
|
138
146
|
updates["import_ubs"] = UbsImport(
|
|
139
|
-
account=
|
|
140
|
-
iban=
|
|
141
|
-
currency=
|
|
142
|
-
rules=_rules(
|
|
147
|
+
account=ubs.get("account", UbsImport.account),
|
|
148
|
+
iban=ubs.get("iban"),
|
|
149
|
+
currency=ubs.get("currency", UbsImport.currency),
|
|
150
|
+
rules=_rules(ubs),
|
|
143
151
|
)
|
|
144
|
-
|
|
145
|
-
|
|
152
|
+
wise = imports.get("wise")
|
|
153
|
+
if isinstance(wise, dict):
|
|
146
154
|
updates["import_wise"] = WiseImport(
|
|
147
|
-
accounts=_accounts_map(
|
|
148
|
-
fees_account=
|
|
149
|
-
holder=
|
|
150
|
-
rules=_rules(
|
|
155
|
+
accounts=_accounts_map(wise, WiseImport.accounts),
|
|
156
|
+
fees_account=wise.get("fees_account", WiseImport.fees_account),
|
|
157
|
+
holder=wise.get("holder"),
|
|
158
|
+
rules=_rules(wise),
|
|
151
159
|
)
|
|
152
|
-
|
|
153
|
-
|
|
160
|
+
stripe = imports.get("stripe")
|
|
161
|
+
if isinstance(stripe, dict):
|
|
154
162
|
updates["import_stripe"] = StripeImport(
|
|
155
|
-
accounts=_accounts_map(
|
|
156
|
-
fees_account=
|
|
157
|
-
tax_account=
|
|
158
|
-
account_id=
|
|
159
|
-
rules=_rules(
|
|
163
|
+
accounts=_accounts_map(stripe, StripeImport.accounts),
|
|
164
|
+
fees_account=stripe.get("fees_account", StripeImport.fees_account),
|
|
165
|
+
tax_account=stripe.get("tax_account", StripeImport.tax_account),
|
|
166
|
+
account_id=stripe.get("account_id"),
|
|
167
|
+
rules=_rules(stripe),
|
|
160
168
|
)
|
|
161
169
|
return updates
|
|
162
170
|
|
|
163
171
|
|
|
164
|
-
def _from_mapping(raw: dict) -> Config:
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
172
|
+
def _from_mapping(raw: dict[str, object]) -> Config:
|
|
173
|
+
def section(key: str) -> dict[str, object]:
|
|
174
|
+
value = raw.get(key)
|
|
175
|
+
return value if isinstance(value, dict) else {}
|
|
176
|
+
|
|
177
|
+
entity = section("entity")
|
|
178
|
+
ledger_ = section("ledger")
|
|
179
|
+
accounts = section("accounts")
|
|
180
|
+
report = section("report")
|
|
169
181
|
cfg = Config()
|
|
170
|
-
updates: dict = {}
|
|
182
|
+
updates: dict[str, object] = {}
|
|
171
183
|
|
|
172
|
-
def take(section: dict, key: str, field: str
|
|
184
|
+
def take(section: dict[str, object], key: str, field: str) -> None:
|
|
173
185
|
if key in section:
|
|
174
|
-
|
|
175
|
-
updates[field] = cast(value) if cast else value
|
|
186
|
+
updates[field] = section[key]
|
|
176
187
|
|
|
177
188
|
take(entity, "name", "entity_name")
|
|
178
189
|
take(entity, "legal_form", "legal_form")
|
|
179
190
|
take(entity, "vat_method", "vat_method")
|
|
180
191
|
take(entity, "vat_registered_since", "vat_registered_since")
|
|
181
192
|
take(entity, "operating_currency", "operating_currency")
|
|
182
|
-
|
|
183
|
-
|
|
193
|
+
if "main" in ledger_:
|
|
194
|
+
updates["ledger_main"] = Path(str(ledger_["main"]))
|
|
195
|
+
if "prices" in ledger_:
|
|
196
|
+
updates["ledger_prices"] = Path(str(ledger_["prices"]))
|
|
184
197
|
for key in (
|
|
185
198
|
"input_vat",
|
|
186
199
|
"output_vat",
|
|
@@ -12,9 +12,12 @@ Enable in the beancount file:
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
14
|
import re
|
|
15
|
+
from collections.abc import Sequence
|
|
15
16
|
from datetime import datetime, timezone
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
|
|
19
|
+
from beancount.core import data
|
|
20
|
+
from fava.beans import abc as fava_abc
|
|
18
21
|
from fava.ext import FavaExtensionBase
|
|
19
22
|
|
|
20
23
|
from .. import config, receivables, settlement
|
|
@@ -22,6 +25,26 @@ from .. import inbox as inbox_mod
|
|
|
22
25
|
|
|
23
26
|
_DRAFT_LINE = re.compile(r"^\d{4}-\d{2}-\d{2} +([*!]) ", re.M)
|
|
24
27
|
|
|
28
|
+
_BEANCOUNT_DIRECTIVES = (
|
|
29
|
+
data.Open,
|
|
30
|
+
data.Close,
|
|
31
|
+
data.Commodity,
|
|
32
|
+
data.Pad,
|
|
33
|
+
data.Balance,
|
|
34
|
+
data.Transaction,
|
|
35
|
+
data.Note,
|
|
36
|
+
data.Event,
|
|
37
|
+
data.Query,
|
|
38
|
+
data.Price,
|
|
39
|
+
data.Document,
|
|
40
|
+
data.Custom,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _entries(entries: Sequence[fava_abc.Directive]) -> data.Directives:
|
|
45
|
+
"""Re-type fava's ``abc.Directive`` view of the same beancount objects."""
|
|
46
|
+
return [e for e in entries if isinstance(e, _BEANCOUNT_DIRECTIVES)]
|
|
47
|
+
|
|
25
48
|
|
|
26
49
|
class QuintDashboard(FavaExtensionBase):
|
|
27
50
|
"""VAT, receivables, and review-queue panel."""
|
|
@@ -46,11 +69,13 @@ class QuintDashboard(FavaExtensionBase):
|
|
|
46
69
|
return settlement.outstanding(
|
|
47
70
|
Path(self.ledger.beancount_file_path),
|
|
48
71
|
cfg=self._cfg,
|
|
49
|
-
entries=self.ledger.all_entries,
|
|
72
|
+
entries=_entries(self.ledger.all_entries),
|
|
50
73
|
)
|
|
51
74
|
|
|
52
75
|
def open_receivables(self):
|
|
53
|
-
return receivables.compute_from_entries(
|
|
76
|
+
return receivables.compute_from_entries(
|
|
77
|
+
_entries(self.ledger.all_entries), self.today(), self._cfg
|
|
78
|
+
)
|
|
54
79
|
|
|
55
80
|
def staging(self):
|
|
56
81
|
"""Pending staging drafts: (file name, total drafts, flagged drafts)."""
|
|
@@ -62,4 +87,4 @@ class QuintDashboard(FavaExtensionBase):
|
|
|
62
87
|
|
|
63
88
|
def inbox(self):
|
|
64
89
|
"""Inbox inventory (hints + duplicate/linked status) — see quints.inbox."""
|
|
65
|
-
return inbox_mod.scan(self._root, self.ledger.all_entries)
|
|
90
|
+
return inbox_mod.scan(self._root, _entries(self.ledger.all_entries))
|
|
@@ -73,7 +73,7 @@ def compute(ledger_path: Path, at: str, cfg: config.Config | None = None) -> lis
|
|
|
73
73
|
root = p.account.split(":", 1)[0]
|
|
74
74
|
if cfg.entity_marker not in p.account or root not in ("Assets", "Liabilities"):
|
|
75
75
|
continue
|
|
76
|
-
if p.units.currency == "CHF":
|
|
76
|
+
if p.units is None or p.units.currency == "CHF":
|
|
77
77
|
continue
|
|
78
78
|
inventories.setdefault(p.account, Inventory()).add_position(p)
|
|
79
79
|
weight = bc_convert.get_weight(p)
|
|
@@ -90,11 +90,14 @@ def compute(ledger_path: Path, at: str, cfg: config.Config | None = None) -> lis
|
|
|
90
90
|
revaluations: list[Revaluation] = []
|
|
91
91
|
for account, inv in sorted(inventories.items()):
|
|
92
92
|
for pos in inv:
|
|
93
|
-
|
|
93
|
+
number = pos.units.number
|
|
94
|
+
if number is None:
|
|
95
|
+
continue
|
|
96
|
+
units = number.quantize(Decimal("0.01"))
|
|
94
97
|
if not units:
|
|
95
98
|
continue
|
|
96
99
|
rate_date, rate = ledger.rate(price_map, pos.units.currency, on)
|
|
97
|
-
if rate is None:
|
|
100
|
+
if rate is None or rate_date is None:
|
|
98
101
|
raise RateUnavailable(pos.units.currency, on)
|
|
99
102
|
market = ledger.rappen(units * Decimal(rate))
|
|
100
103
|
book_value = ledger.rappen(book.get((account, pos.units.currency), Decimal("0")))
|
|
@@ -25,9 +25,12 @@ from __future__ import annotations
|
|
|
25
25
|
import json
|
|
26
26
|
import os
|
|
27
27
|
import re
|
|
28
|
+
from collections.abc import Sequence
|
|
28
29
|
from dataclasses import dataclass, field
|
|
29
30
|
from datetime import date as TodayDate
|
|
31
|
+
from decimal import Decimal
|
|
30
32
|
from pathlib import Path
|
|
33
|
+
from typing import TypeVar
|
|
31
34
|
|
|
32
35
|
from beancount.core import data
|
|
33
36
|
from beancount.parser import printer
|
|
@@ -45,7 +48,10 @@ LEGACY_WINDOW_DAYS = 3
|
|
|
45
48
|
DEFAULT_STAGING = Path("staging")
|
|
46
49
|
|
|
47
50
|
|
|
48
|
-
|
|
51
|
+
_Section = TypeVar("_Section", config.UbsImport, config.WiseImport, config.StripeImport)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _require(section: _Section | None, name: str, *keys: str) -> _Section:
|
|
49
55
|
"""The importer's entity data ([import.<name>] in quints.toml) is config."""
|
|
50
56
|
if section is None:
|
|
51
57
|
raise ValueError(f"[import.{name}] is not configured — add it to quints.toml")
|
|
@@ -100,38 +106,54 @@ def stripe_importer(cfg: config.Config | None = None) -> StripeImporter:
|
|
|
100
106
|
class ImportResult:
|
|
101
107
|
source: str
|
|
102
108
|
out_path: Path | None = None
|
|
103
|
-
drafts: list = field(default_factory=list) #
|
|
104
|
-
balances: list = field(default_factory=list) # closing-balance assertions
|
|
109
|
+
drafts: list[data.Transaction] = field(default_factory=list) # written to staging
|
|
110
|
+
balances: list[data.Balance] = field(default_factory=list) # closing-balance assertions
|
|
105
111
|
skipped_ref: int = 0 # deduped by reference metadata
|
|
106
|
-
legacy_matches: list = field(
|
|
107
|
-
|
|
112
|
+
legacy_matches: list[tuple[data.Transaction, TodayDate]] = field(
|
|
113
|
+
default_factory=list
|
|
114
|
+
) # (draft, booked date) pairs
|
|
115
|
+
receivable_matches: list[tuple[str, data.Transaction]] = field(
|
|
116
|
+
default_factory=list
|
|
117
|
+
) # (invoice number, draft)
|
|
108
118
|
|
|
109
119
|
|
|
110
|
-
def _cash_pool(
|
|
120
|
+
def _cash_pool(
|
|
121
|
+
entries: Sequence[data.Directive], accounts: set[str]
|
|
122
|
+
) -> list[tuple[TodayDate, Decimal, str]]:
|
|
111
123
|
"""Mutable pool of (date, amount, account) cash postings for legacy matching."""
|
|
112
|
-
pool = []
|
|
124
|
+
pool: list[tuple[TodayDate, Decimal, str]] = []
|
|
113
125
|
for e in entries:
|
|
114
126
|
if not isinstance(e, data.Transaction):
|
|
115
127
|
continue
|
|
116
128
|
for p in e.postings:
|
|
117
|
-
if p.account in accounts:
|
|
129
|
+
if p.account in accounts and p.units is not None and p.units.number is not None:
|
|
118
130
|
pool.append((e.date, p.units.number, p.account))
|
|
119
131
|
return pool
|
|
120
132
|
|
|
121
133
|
|
|
122
|
-
def _split(
|
|
134
|
+
def _split(
|
|
135
|
+
result: ImportResult,
|
|
136
|
+
extracted: Sequence[data.Directive],
|
|
137
|
+
pool: list[tuple[TodayDate, Decimal, str]],
|
|
138
|
+
) -> None:
|
|
123
139
|
"""Sort extracted entries into drafts / legacy matches / balance assertions."""
|
|
124
140
|
for entry in extracted:
|
|
125
141
|
if isinstance(entry, data.Balance):
|
|
126
142
|
result.balances.append(entry)
|
|
127
143
|
continue
|
|
144
|
+
if not isinstance(entry, data.Transaction):
|
|
145
|
+
continue
|
|
128
146
|
cash = entry.postings[0]
|
|
147
|
+
if cash.units is None or cash.units.number is None:
|
|
148
|
+
result.drafts.append(entry) # no amount to match on — review by hand
|
|
149
|
+
continue
|
|
150
|
+
cash_number = cash.units.number
|
|
129
151
|
match = next(
|
|
130
152
|
(
|
|
131
153
|
(d, n, a)
|
|
132
154
|
for d, n, a in pool
|
|
133
155
|
if a == cash.account
|
|
134
|
-
and n ==
|
|
156
|
+
and n == cash_number
|
|
135
157
|
and abs((d - entry.date).days) <= LEGACY_WINDOW_DAYS
|
|
136
158
|
),
|
|
137
159
|
None,
|
|
@@ -143,7 +165,9 @@ def _split(result: ImportResult, extracted, pool) -> None:
|
|
|
143
165
|
result.drafts.append(entry)
|
|
144
166
|
|
|
145
167
|
|
|
146
|
-
def
|
|
168
|
+
def match_receivables(
|
|
169
|
+
result: ImportResult, existing: Sequence[data.Directive], cfg: config.Config
|
|
170
|
+
) -> None:
|
|
147
171
|
"""Link incoming drafts to open invoices (docs/plans/02 §2.1).
|
|
148
172
|
|
|
149
173
|
`quints invoice` derives the QRR (QR-bill) and SCOR/RF (SEPA) references
|
|
@@ -160,7 +184,7 @@ def _match_receivables(result: ImportResult, existing, cfg: config.Config) -> No
|
|
|
160
184
|
|
|
161
185
|
for i, draft in enumerate(result.drafts):
|
|
162
186
|
cash = draft.postings[0]
|
|
163
|
-
if cash.units is None or cash.units.number <= 0:
|
|
187
|
+
if cash.units is None or cash.units.number is None or cash.units.number <= 0:
|
|
164
188
|
continue # only incoming payments clear receivables
|
|
165
189
|
blob = " ".join(
|
|
166
190
|
[draft.payee or "", draft.narration or ""]
|
|
@@ -208,8 +232,9 @@ def run_ubs(
|
|
|
208
232
|
) -> ImportResult:
|
|
209
233
|
cfg = cfg or config.get()
|
|
210
234
|
importer = ubs_importer(cfg)
|
|
235
|
+
ubs = _require(cfg.import_ubs, "ubs", "iban")
|
|
211
236
|
if not importer.identify(str(statement)):
|
|
212
|
-
raise ValueError(f"{statement} is not an MT940 statement for {
|
|
237
|
+
raise ValueError(f"{statement} is not an MT940 statement for {ubs.iban}")
|
|
213
238
|
|
|
214
239
|
existing, _ = ledger.load_entries(ledger_path)
|
|
215
240
|
total = len(importer.extract(str(statement), existing=[]))
|
|
@@ -217,8 +242,8 @@ def run_ubs(
|
|
|
217
242
|
|
|
218
243
|
result = ImportResult(source=str(statement))
|
|
219
244
|
result.skipped_ref = total - len(extracted)
|
|
220
|
-
_split(result, extracted, _cash_pool(existing, {
|
|
221
|
-
|
|
245
|
+
_split(result, extracted, _cash_pool(existing, {ubs.account}))
|
|
246
|
+
match_receivables(result, existing, cfg)
|
|
222
247
|
_write_staging(result, out_dir, "ubs")
|
|
223
248
|
return result
|
|
224
249
|
|
|
@@ -231,7 +256,7 @@ def run_wise(
|
|
|
231
256
|
) -> ImportResult:
|
|
232
257
|
cfg = cfg or config.get()
|
|
233
258
|
importer = wise_importer(cfg)
|
|
234
|
-
wise = cfg.import_wise
|
|
259
|
+
wise = _require(cfg.import_wise, "wise")
|
|
235
260
|
for statement in statements:
|
|
236
261
|
if not importer.identify(str(statement)):
|
|
237
262
|
raise ValueError(
|
|
@@ -240,8 +265,8 @@ def run_wise(
|
|
|
240
265
|
)
|
|
241
266
|
|
|
242
267
|
existing, _ = ledger.load_entries(ledger_path)
|
|
243
|
-
raw:
|
|
244
|
-
deduped:
|
|
268
|
+
raw: data.Directives = [] # without ledger dedup, to count reference skips
|
|
269
|
+
deduped: data.Directives = []
|
|
245
270
|
for statement in statements:
|
|
246
271
|
raw += importer.extract(str(statement), existing=[])
|
|
247
272
|
deduped += importer.extract(str(statement), existing=existing)
|
|
@@ -249,7 +274,7 @@ def run_wise(
|
|
|
249
274
|
result = ImportResult(source=", ".join(str(s) for s in statements))
|
|
250
275
|
result.skipped_ref = _txn_count(raw) - _txn_count(deduped)
|
|
251
276
|
_split(result, merge_conversions(deduped), _cash_pool(existing, set(wise.account_map.values())))
|
|
252
|
-
|
|
277
|
+
match_receivables(result, existing, cfg)
|
|
253
278
|
_write_staging(result, out_dir, "wise")
|
|
254
279
|
return result
|
|
255
280
|
|
|
@@ -262,7 +287,7 @@ def run_stripe(
|
|
|
262
287
|
) -> ImportResult:
|
|
263
288
|
cfg = cfg or config.get()
|
|
264
289
|
importer = stripe_importer(cfg)
|
|
265
|
-
stripe = cfg.import_stripe
|
|
290
|
+
stripe = _require(cfg.import_stripe, "stripe", "account_id")
|
|
266
291
|
for statement in statements:
|
|
267
292
|
if not importer.identify(str(statement)):
|
|
268
293
|
raise ValueError(
|
|
@@ -271,8 +296,8 @@ def run_stripe(
|
|
|
271
296
|
)
|
|
272
297
|
|
|
273
298
|
existing, _ = ledger.load_entries(ledger_path)
|
|
274
|
-
raw:
|
|
275
|
-
deduped:
|
|
299
|
+
raw: data.Directives = [] # without ledger dedup, to count reference skips
|
|
300
|
+
deduped: data.Directives = []
|
|
276
301
|
for statement in statements:
|
|
277
302
|
raw += importer.extract(str(statement), existing=[])
|
|
278
303
|
deduped += importer.extract(str(statement), existing=existing)
|
|
@@ -280,12 +305,12 @@ def run_stripe(
|
|
|
280
305
|
result = ImportResult(source=", ".join(str(s) for s in statements))
|
|
281
306
|
result.skipped_ref = _txn_count(raw) - _txn_count(deduped)
|
|
282
307
|
_split(result, deduped, _cash_pool(existing, set(stripe.account_map.values())))
|
|
283
|
-
|
|
308
|
+
match_receivables(result, existing, cfg)
|
|
284
309
|
_write_staging(result, out_dir, "stripe")
|
|
285
310
|
return result
|
|
286
311
|
|
|
287
312
|
|
|
288
|
-
def _txn_count(entries) -> int:
|
|
313
|
+
def _txn_count(entries: Sequence[data.Directive]) -> int:
|
|
289
314
|
return sum(1 for e in entries if isinstance(e, data.Transaction))
|
|
290
315
|
|
|
291
316
|
|
|
@@ -297,6 +322,9 @@ def fetch_wise(
|
|
|
297
322
|
) -> list[Path]:
|
|
298
323
|
"""Download one statement.json per currency balance into ``out_dir``."""
|
|
299
324
|
wise = _require((cfg or config.get()).import_wise, "wise", "holder")
|
|
325
|
+
holder = wise.holder
|
|
326
|
+
if not holder: # unreachable: _require checked, but narrows the Optional
|
|
327
|
+
raise ValueError("[import.wise] needs 'holder' in quints.toml")
|
|
300
328
|
_load_env()
|
|
301
329
|
token = os.environ.get("QUINTS_WISE_API_TOKEN")
|
|
302
330
|
if not token:
|
|
@@ -307,16 +335,19 @@ def fetch_wise(
|
|
|
307
335
|
pem = Path(key_path).read_bytes()
|
|
308
336
|
|
|
309
337
|
client = WiseClient(token, private_key_pem=pem)
|
|
310
|
-
profile = client.profile_id(
|
|
338
|
+
profile = client.profile_id(holder)
|
|
311
339
|
paths: list[Path] = []
|
|
312
340
|
out_dir.mkdir(exist_ok=True)
|
|
313
341
|
for balance in client.balances(profile):
|
|
314
342
|
currency = balance["currency"]
|
|
315
|
-
if currency not in wise.account_map:
|
|
343
|
+
if not isinstance(currency, str) or currency not in wise.account_map:
|
|
316
344
|
continue
|
|
345
|
+
balance_id = balance["id"]
|
|
346
|
+
if not isinstance(balance_id, int):
|
|
347
|
+
raise ValueError(f"Wise balance for {currency} has a non-integer id: {balance_id!r}")
|
|
317
348
|
statement = client.balance_statement(
|
|
318
349
|
profile,
|
|
319
|
-
|
|
350
|
+
balance_id,
|
|
320
351
|
currency,
|
|
321
352
|
f"{interval_start}T00:00:00.000Z",
|
|
322
353
|
f"{interval_end}T23:59:59.999Z",
|
|
@@ -362,7 +393,7 @@ def fetch_stripe(
|
|
|
362
393
|
moment += dt.timedelta(days=1)
|
|
363
394
|
return int(moment.timestamp()) - (1 if end else 0)
|
|
364
395
|
|
|
365
|
-
wrapper: dict = {
|
|
396
|
+
wrapper: dict[str, object] = {
|
|
366
397
|
"account": {"id": account.get("id")},
|
|
367
398
|
"data": client.balance_transactions(_ts(interval_start, False), _ts(interval_end, True)),
|
|
368
399
|
}
|
|
@@ -41,7 +41,7 @@ def _sha(path: Path) -> str:
|
|
|
41
41
|
return hashlib.sha256(path.read_bytes()).hexdigest()[:12]
|
|
42
42
|
|
|
43
43
|
|
|
44
|
-
def _linked_basenames(entries) -> set[str]:
|
|
44
|
+
def _linked_basenames(entries: data.Directives) -> set[str]:
|
|
45
45
|
names: set[str] = set()
|
|
46
46
|
for e in entries:
|
|
47
47
|
if not isinstance(e, data.Transaction) or not e.meta:
|
|
@@ -52,7 +52,7 @@ def _linked_basenames(entries) -> set[str]:
|
|
|
52
52
|
return names
|
|
53
53
|
|
|
54
54
|
|
|
55
|
-
def scan(root: Path, entries) -> list[InboxDoc]:
|
|
55
|
+
def scan(root: Path, entries: data.Directives) -> list[InboxDoc]:
|
|
56
56
|
box_dir = root / "inbox"
|
|
57
57
|
if not box_dir.is_dir():
|
|
58
58
|
return []
|
|
@@ -75,7 +75,7 @@ def scan(root: Path, entries) -> list[InboxDoc]:
|
|
|
75
75
|
filed_by_hash.setdefault(_sha(p), p)
|
|
76
76
|
|
|
77
77
|
linked = _linked_basenames(entries)
|
|
78
|
-
out = []
|
|
78
|
+
out: list[InboxDoc] = []
|
|
79
79
|
for p in files:
|
|
80
80
|
m = _NAMED.match(p.name)
|
|
81
81
|
dup = filed_by_hash.get(_sha(p))
|
|
@@ -16,10 +16,11 @@ def _slug(text: str) -> str:
|
|
|
16
16
|
def build_draft(inv: Invoice, totals: Totals, cfg: config.Config | None = None) -> str:
|
|
17
17
|
"""A balanced receivable booking matching what `verify.cross_check` expects."""
|
|
18
18
|
cfg = cfg or config.get()
|
|
19
|
+
customer = inv.resolved_customer
|
|
19
20
|
ccy = inv.currency
|
|
20
21
|
income = cfg.income_export if inv.kind == "export" else cfg.income_domestic
|
|
21
22
|
narration = f"{inv.supply} invoiced".strip() if inv.supply else f"Invoice {inv.number}"
|
|
22
|
-
doc = f"{inv.issue_date}.{_slug(
|
|
23
|
+
doc = f"{inv.issue_date}.{_slug(customer.name)}.{_slug(inv.supply or inv.number)}.pdf"
|
|
23
24
|
|
|
24
25
|
legs: list[tuple[str, str]] = [(cfg.receivable, f"{totals.grand_total:>10.2f} {ccy}")]
|
|
25
26
|
legs.append((income, f"{-totals.subtotal:>10.2f} {ccy}"))
|
|
@@ -30,7 +31,7 @@ def build_draft(inv: Invoice, totals: Totals, cfg: config.Config | None = None)
|
|
|
30
31
|
|
|
31
32
|
width = max(len(a) for a, _ in legs) + 4
|
|
32
33
|
lines = [
|
|
33
|
-
f'{inv.issue_date} * "{
|
|
34
|
+
f'{inv.issue_date} * "{customer.name}" "{narration}" ^{inv.number}',
|
|
34
35
|
f' invoice: "{inv.number}"',
|
|
35
36
|
f' document: "{doc}" ; TODO file the PDF under documents/{income.replace(":", "/")}/',
|
|
36
37
|
]
|
|
@@ -78,7 +78,7 @@ LABELS = {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
|
|
81
|
-
def labels(language: str) -> dict:
|
|
81
|
+
def labels(language: str) -> dict[str, str]:
|
|
82
82
|
"""Label set for `language` (a locale's language subtag). `Invoice.locale`
|
|
83
83
|
is validated at load, so the fallback only guards programmatic callers."""
|
|
84
84
|
return LABELS.get(language, LABELS["de"])
|