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.
Files changed (74) hide show
  1. {quints-0.4.0 → quints-0.4.1}/PKG-INFO +1 -1
  2. {quints-0.4.0 → quints-0.4.1}/pyproject.toml +1 -1
  3. {quints-0.4.0 → quints-0.4.1}/src/quints/cli.py +25 -10
  4. {quints-0.4.0 → quints-0.4.1}/src/quints/config.py +53 -40
  5. {quints-0.4.0 → quints-0.4.1}/src/quints/fava/__init__.py +28 -3
  6. {quints-0.4.0 → quints-0.4.1}/src/quints/fx.py +6 -3
  7. {quints-0.4.0 → quints-0.4.1}/src/quints/importing.py +59 -28
  8. {quints-0.4.0 → quints-0.4.1}/src/quints/inbox.py +3 -3
  9. {quints-0.4.0 → quints-0.4.1}/src/quints/invoice/draft.py +3 -2
  10. {quints-0.4.0 → quints-0.4.1}/src/quints/invoice/labels.py +1 -1
  11. {quints-0.4.0 → quints-0.4.1}/src/quints/invoice/model.py +15 -1
  12. {quints-0.4.0 → quints-0.4.1}/src/quints/invoice/qr.py +8 -3
  13. {quints-0.4.0 → quints-0.4.1}/src/quints/invoice/render.py +46 -15
  14. {quints-0.4.0 → quints-0.4.1}/src/quints/kmu.py +17 -7
  15. {quints-0.4.0 → quints-0.4.1}/src/quints/ledger.py +1 -1
  16. {quints-0.4.0 → quints-0.4.1}/src/quints/match.py +16 -11
  17. {quints-0.4.0 → quints-0.4.1}/src/quints/mwst.py +27 -11
  18. {quints-0.4.0 → quints-0.4.1}/src/quints/plugins/kmu.py +1 -1
  19. {quints-0.4.0 → quints-0.4.1}/src/quints/prices.py +13 -4
  20. {quints-0.4.0 → quints-0.4.1}/src/quints/receivables.py +28 -16
  21. {quints-0.4.0 → quints-0.4.1}/src/quints/report_pdf.py +19 -7
  22. {quints-0.4.0 → quints-0.4.1}/src/quints/settlement.py +30 -14
  23. {quints-0.4.0 → quints-0.4.1}/src/quints/vat.py +3 -1
  24. {quints-0.4.0 → quints-0.4.1}/tests/test_cli_json.py +4 -3
  25. {quints-0.4.0 → quints-0.4.1}/tests/test_config.py +16 -12
  26. {quints-0.4.0 → quints-0.4.1}/tests/test_fava_ext.py +3 -1
  27. {quints-0.4.0 → quints-0.4.1}/tests/test_fx.py +5 -4
  28. {quints-0.4.0 → quints-0.4.1}/tests/test_importing.py +18 -11
  29. {quints-0.4.0 → quints-0.4.1}/tests/test_inbox_match.py +19 -3
  30. {quints-0.4.0 → quints-0.4.1}/tests/test_invoice.py +17 -15
  31. {quints-0.4.0 → quints-0.4.1}/tests/test_kmu_report.py +7 -6
  32. {quints-0.4.0 → quints-0.4.1}/tests/test_mwst.py +5 -4
  33. {quints-0.4.0 → quints-0.4.1}/tests/test_prices_sync.py +19 -12
  34. {quints-0.4.0 → quints-0.4.1}/tests/test_receivables.py +4 -3
  35. quints-0.4.1/tests/test_report_pdf.py +87 -0
  36. {quints-0.4.0 → quints-0.4.1}/tests/test_settlement.py +22 -21
  37. quints-0.4.0/tests/test_report_pdf.py +0 -46
  38. {quints-0.4.0 → quints-0.4.1}/.gitignore +0 -0
  39. {quints-0.4.0 → quints-0.4.1}/LICENSE +0 -0
  40. {quints-0.4.0 → quints-0.4.1}/README.md +0 -0
  41. {quints-0.4.0 → quints-0.4.1}/examples/.gitignore +0 -0
  42. {quints-0.4.0 → quints-0.4.1}/examples/AGENTS.md +0 -0
  43. {quints-0.4.0 → quints-0.4.1}/examples/CLAUDE.md +0 -0
  44. {quints-0.4.0 → quints-0.4.1}/examples/accounts.bean +0 -0
  45. {quints-0.4.0 → quints-0.4.1}/examples/answers.toml +0 -0
  46. {quints-0.4.0 → quints-0.4.1}/examples/books/2026.bean +0 -0
  47. {quints-0.4.0 → quints-0.4.1}/examples/commodities.bean +0 -0
  48. {quints-0.4.0 → quints-0.4.1}/examples/documents/.gitkeep +0 -0
  49. {quints-0.4.0 → quints-0.4.1}/examples/inbox/.gitkeep +0 -0
  50. {quints-0.4.0 → quints-0.4.1}/examples/invoicing/acme-2026-07.yaml +0 -0
  51. {quints-0.4.0 → quints-0.4.1}/examples/invoicing/customers.yaml +0 -0
  52. {quints-0.4.0 → quints-0.4.1}/examples/invoicing/globex-2026-08.yaml +0 -0
  53. {quints-0.4.0 → quints-0.4.1}/examples/invoicing/issuer.yaml +0 -0
  54. {quints-0.4.0 → quints-0.4.1}/examples/main.bean +0 -0
  55. {quints-0.4.0 → quints-0.4.1}/examples/prices.bean +0 -0
  56. {quints-0.4.0 → quints-0.4.1}/examples/pyproject.toml +0 -0
  57. {quints-0.4.0 → quints-0.4.1}/examples/quints.toml +0 -0
  58. {quints-0.4.0 → quints-0.4.1}/examples/staging/.gitkeep +0 -0
  59. {quints-0.4.0 → quints-0.4.1}/examples/statements/ubs-2026.mt940 +0 -0
  60. {quints-0.4.0 → quints-0.4.1}/src/quints/__init__.py +0 -0
  61. {quints-0.4.0 → quints-0.4.1}/src/quints/fava/templates/QuintDashboard.html +0 -0
  62. {quints-0.4.0 → quints-0.4.1}/src/quints/init.py +0 -0
  63. {quints-0.4.0 → quints-0.4.1}/src/quints/invoice/__init__.py +0 -0
  64. {quints-0.4.0 → quints-0.4.1}/src/quints/invoice/template.typ +0 -0
  65. {quints-0.4.0 → quints-0.4.1}/src/quints/invoice/vatid.py +0 -0
  66. {quints-0.4.0 → quints-0.4.1}/src/quints/invoice/verify.py +0 -0
  67. {quints-0.4.0 → quints-0.4.1}/src/quints/plugins/__init__.py +0 -0
  68. {quints-0.4.0 → quints-0.4.1}/src/quints/report.typ +0 -0
  69. {quints-0.4.0 → quints-0.4.1}/src/quints/ui.py +0 -0
  70. {quints-0.4.0 → quints-0.4.1}/tests/fixtures/transactions.mt940 +0 -0
  71. {quints-0.4.0 → quints-0.4.1}/tests/test_docs.py +0 -0
  72. {quints-0.4.0 → quints-0.4.1}/tests/test_init.py +0 -0
  73. {quints-0.4.0 → quints-0.4.1}/tests/test_kmu_plugin.py +0 -0
  74. {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.0
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.0"
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 _main(
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(report, render, lang: str | None, as_json: bool) -> None:
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 importing_mod.WiseError as e:
549
+ except WiseError as e:
541
550
  typer.secho(f"ERROR: {e}", fg="red", err=True)
542
- if isinstance(e, importing_mod.ScaChallenge):
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.customer.name,
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
- return tuple((r[0], r[1], r[2]) for r in section.get("rules", ()))
125
-
126
-
127
- def _accounts_map(section: dict, default) -> tuple[tuple[str, str], ...]:
128
- if "accounts" not in section:
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(section["accounts"].items()))
136
+ return tuple(sorted(accounts.items()))
131
137
 
132
138
 
133
- def _import_sections(raw: dict) -> dict:
134
- imports = raw.get("import") or {}
135
- updates: dict = {}
136
- if "ubs" in imports:
137
- s = imports["ubs"]
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=s.get("account", UbsImport.account),
140
- iban=s.get("iban"),
141
- currency=s.get("currency", UbsImport.currency),
142
- rules=_rules(s),
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
- if "wise" in imports:
145
- s = imports["wise"]
152
+ wise = imports.get("wise")
153
+ if isinstance(wise, dict):
146
154
  updates["import_wise"] = WiseImport(
147
- accounts=_accounts_map(s, WiseImport.accounts),
148
- fees_account=s.get("fees_account", WiseImport.fees_account),
149
- holder=s.get("holder"),
150
- rules=_rules(s),
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
- if "stripe" in imports:
153
- s = imports["stripe"]
160
+ stripe = imports.get("stripe")
161
+ if isinstance(stripe, dict):
154
162
  updates["import_stripe"] = StripeImport(
155
- accounts=_accounts_map(s, StripeImport.accounts),
156
- fees_account=s.get("fees_account", StripeImport.fees_account),
157
- tax_account=s.get("tax_account", StripeImport.tax_account),
158
- account_id=s.get("account_id"),
159
- rules=_rules(s),
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
- entity = raw.get("entity") or {}
166
- ledger_ = raw.get("ledger") or {}
167
- accounts = raw.get("accounts") or {}
168
- report = raw.get("report") or {}
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, cast=None):
184
+ def take(section: dict[str, object], key: str, field: str) -> None:
173
185
  if key in section:
174
- value = section[key]
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
- take(ledger_, "main", "ledger_main", Path)
183
- take(ledger_, "prices", "ledger_prices", Path)
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(self.ledger.all_entries, self.today(), self._cfg)
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
- units = pos.units.number.quantize(Decimal("0.01"))
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
- def _require(section, name: str, *keys: str):
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) # Transactions written to staging
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(default_factory=list) # (draft, booked date) pairs
107
- receivable_matches: list = field(default_factory=list) # (invoice number, draft)
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(entries, accounts: set[str]) -> list[tuple]:
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(result: ImportResult, extracted, pool) -> None:
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 == cash.units.number
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 _match_receivables(result: ImportResult, existing, cfg: config.Config) -> None:
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 {cfg.import_ubs.iban}")
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, {cfg.import_ubs.account}))
221
- _match_receivables(result, existing, cfg)
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: list = [] # without ledger dedup, to count reference skips
244
- deduped: list = []
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
- _match_receivables(result, existing, cfg)
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: list = [] # without ledger dedup, to count reference skips
275
- deduped: list = []
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
- _match_receivables(result, existing, cfg)
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(wise.holder)
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
- balance["id"],
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(inv.customer.name)}.{_slug(inv.supply or inv.number)}.pdf"
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} * "{inv.customer.name}" "{narration}" ^{inv.number}',
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"])