quints 0.4.0__tar.gz → 0.5.0__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 (78) hide show
  1. {quints-0.4.0 → quints-0.5.0}/PKG-INFO +1 -1
  2. {quints-0.4.0 → quints-0.5.0}/examples/quints.toml +4 -0
  3. {quints-0.4.0 → quints-0.5.0}/pyproject.toml +6 -1
  4. {quints-0.4.0 → quints-0.5.0}/src/quints/cli.py +91 -27
  5. {quints-0.4.0 → quints-0.5.0}/src/quints/config.py +64 -38
  6. {quints-0.4.0 → quints-0.5.0}/src/quints/fava/__init__.py +28 -3
  7. {quints-0.4.0 → quints-0.5.0}/src/quints/fx.py +6 -3
  8. {quints-0.4.0 → quints-0.5.0}/src/quints/importing.py +59 -28
  9. {quints-0.4.0 → quints-0.5.0}/src/quints/inbox.py +3 -3
  10. {quints-0.4.0 → quints-0.5.0}/src/quints/init.py +4 -0
  11. {quints-0.4.0 → quints-0.5.0}/src/quints/invoice/draft.py +3 -2
  12. {quints-0.4.0 → quints-0.5.0}/src/quints/invoice/labels.py +1 -1
  13. {quints-0.4.0 → quints-0.5.0}/src/quints/invoice/model.py +15 -1
  14. {quints-0.4.0 → quints-0.5.0}/src/quints/invoice/qr.py +8 -3
  15. {quints-0.4.0 → quints-0.5.0}/src/quints/invoice/render.py +46 -15
  16. {quints-0.4.0 → quints-0.5.0}/src/quints/kmu.py +17 -7
  17. {quints-0.4.0 → quints-0.5.0}/src/quints/ledger.py +1 -2
  18. {quints-0.4.0 → quints-0.5.0}/src/quints/match.py +16 -11
  19. {quints-0.4.0 → quints-0.5.0}/src/quints/mwst.py +27 -11
  20. {quints-0.4.0 → quints-0.5.0}/src/quints/plugins/kmu.py +1 -1
  21. quints-0.5.0/src/quints/prices.py +514 -0
  22. {quints-0.4.0 → quints-0.5.0}/src/quints/receivables.py +28 -16
  23. {quints-0.4.0 → quints-0.5.0}/src/quints/report_pdf.py +19 -7
  24. {quints-0.4.0 → quints-0.5.0}/src/quints/settlement.py +30 -14
  25. {quints-0.4.0 → quints-0.5.0}/src/quints/vat.py +3 -1
  26. quints-0.5.0/tests/test_cli_json.py +139 -0
  27. {quints-0.4.0 → quints-0.5.0}/tests/test_config.py +32 -12
  28. {quints-0.4.0 → quints-0.5.0}/tests/test_fava_ext.py +3 -1
  29. {quints-0.4.0 → quints-0.5.0}/tests/test_fx.py +5 -4
  30. {quints-0.4.0 → quints-0.5.0}/tests/test_importing.py +18 -11
  31. {quints-0.4.0 → quints-0.5.0}/tests/test_inbox_match.py +19 -3
  32. {quints-0.4.0 → quints-0.5.0}/tests/test_invoice.py +17 -15
  33. {quints-0.4.0 → quints-0.5.0}/tests/test_kmu_report.py +7 -6
  34. {quints-0.4.0 → quints-0.5.0}/tests/test_mwst.py +5 -4
  35. quints-0.5.0/tests/test_prices_ecosystem.py +95 -0
  36. quints-0.5.0/tests/test_prices_sync.py +419 -0
  37. {quints-0.4.0 → quints-0.5.0}/tests/test_receivables.py +4 -3
  38. quints-0.5.0/tests/test_report_pdf.py +87 -0
  39. {quints-0.4.0 → quints-0.5.0}/tests/test_settlement.py +22 -21
  40. quints-0.4.0/src/quints/prices.py +0 -161
  41. quints-0.4.0/tests/test_cli_json.py +0 -56
  42. quints-0.4.0/tests/test_prices_sync.py +0 -98
  43. quints-0.4.0/tests/test_report_pdf.py +0 -46
  44. {quints-0.4.0 → quints-0.5.0}/.gitignore +0 -0
  45. {quints-0.4.0 → quints-0.5.0}/LICENSE +0 -0
  46. {quints-0.4.0 → quints-0.5.0}/README.md +0 -0
  47. {quints-0.4.0 → quints-0.5.0}/examples/.gitignore +0 -0
  48. {quints-0.4.0 → quints-0.5.0}/examples/AGENTS.md +0 -0
  49. {quints-0.4.0 → quints-0.5.0}/examples/CLAUDE.md +0 -0
  50. {quints-0.4.0 → quints-0.5.0}/examples/accounts.bean +0 -0
  51. {quints-0.4.0 → quints-0.5.0}/examples/answers.toml +0 -0
  52. {quints-0.4.0 → quints-0.5.0}/examples/books/2026.bean +0 -0
  53. {quints-0.4.0 → quints-0.5.0}/examples/commodities.bean +0 -0
  54. {quints-0.4.0 → quints-0.5.0}/examples/documents/.gitkeep +0 -0
  55. {quints-0.4.0 → quints-0.5.0}/examples/inbox/.gitkeep +0 -0
  56. {quints-0.4.0 → quints-0.5.0}/examples/invoicing/acme-2026-07.yaml +0 -0
  57. {quints-0.4.0 → quints-0.5.0}/examples/invoicing/customers.yaml +0 -0
  58. {quints-0.4.0 → quints-0.5.0}/examples/invoicing/globex-2026-08.yaml +0 -0
  59. {quints-0.4.0 → quints-0.5.0}/examples/invoicing/issuer.yaml +0 -0
  60. {quints-0.4.0 → quints-0.5.0}/examples/main.bean +0 -0
  61. {quints-0.4.0 → quints-0.5.0}/examples/prices.bean +0 -0
  62. {quints-0.4.0 → quints-0.5.0}/examples/pyproject.toml +0 -0
  63. {quints-0.4.0 → quints-0.5.0}/examples/staging/.gitkeep +0 -0
  64. {quints-0.4.0 → quints-0.5.0}/examples/statements/ubs-2026.mt940 +0 -0
  65. {quints-0.4.0 → quints-0.5.0}/src/quints/__init__.py +0 -0
  66. {quints-0.4.0 → quints-0.5.0}/src/quints/fava/templates/QuintDashboard.html +0 -0
  67. {quints-0.4.0 → quints-0.5.0}/src/quints/invoice/__init__.py +0 -0
  68. {quints-0.4.0 → quints-0.5.0}/src/quints/invoice/template.typ +0 -0
  69. {quints-0.4.0 → quints-0.5.0}/src/quints/invoice/vatid.py +0 -0
  70. {quints-0.4.0 → quints-0.5.0}/src/quints/invoice/verify.py +0 -0
  71. {quints-0.4.0 → quints-0.5.0}/src/quints/plugins/__init__.py +0 -0
  72. {quints-0.4.0 → quints-0.5.0}/src/quints/report.typ +0 -0
  73. {quints-0.4.0 → quints-0.5.0}/src/quints/ui.py +0 -0
  74. {quints-0.4.0 → quints-0.5.0}/tests/fixtures/transactions.mt940 +0 -0
  75. {quints-0.4.0 → quints-0.5.0}/tests/test_docs.py +0 -0
  76. {quints-0.4.0 → quints-0.5.0}/tests/test_init.py +0 -0
  77. {quints-0.4.0 → quints-0.5.0}/tests/test_kmu_plugin.py +0 -0
  78. {quints-0.4.0 → quints-0.5.0}/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.5.0
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
@@ -12,6 +12,10 @@ operating_currency = "CHF"
12
12
  main = "main.bean"
13
13
  prices = "prices.bean"
14
14
 
15
+ # `quints prices sync` reads the commodity price metadata in
16
+ # commodities.bean (same as bean-price). A [prices] section here only
17
+ # matters for ledgers that declare none — see the FX guide.
18
+
15
19
  [accounts]
16
20
  entity_marker = ":CH:GmbH:" # scopes KMU statements and the plugin
17
21
  input_vat = "Assets:CH:GmbH:Tax:InputVAT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "quints"
3
- version = "0.4.0"
3
+ version = "0.5.0"
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"
@@ -30,6 +30,11 @@ dependencies = [
30
30
  [project.scripts]
31
31
  quints = "quints.cli:main"
32
32
 
33
+ [tool.deptry.per_rule_ignores]
34
+ # The default price source: `prices sync` resolves it by module name at
35
+ # runtime (importlib), so deptry sees no static import.
36
+ DEP002 = ["beanprice-bazg"]
37
+
33
38
  [dependency-groups]
34
39
  dev = ["pytest>=8"]
35
40
 
@@ -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
  ),
@@ -81,7 +86,10 @@ def _main(
81
86
  config_mod.set_path(config)
82
87
 
83
88
 
84
- prices_app = typer.Typer(no_args_is_help=True, help="Price database (BAZG daily CHF rates).")
89
+ prices_app = typer.Typer(
90
+ no_args_is_help=True,
91
+ help="Price database (daily FX rates from any beanprice source; BAZG by default).",
92
+ )
85
93
  app.add_typer(prices_app, name="prices")
86
94
  report_app = typer.Typer(
87
95
  no_args_is_help=True,
@@ -103,7 +111,9 @@ def _lang_option() -> str:
103
111
  )
104
112
 
105
113
 
106
- def _emit(report, render, lang: str | None, as_json: bool) -> None:
114
+ def _emit(
115
+ report: DataclassInstance, render: Callable[..., object], lang: str | None, as_json: bool
116
+ ) -> None:
107
117
  lang = lang or config_mod.get().report_language
108
118
  if as_json:
109
119
  import dataclasses
@@ -126,7 +136,7 @@ def _parse_date(text: str) -> Date:
126
136
  raise typer.Exit(1) from None
127
137
 
128
138
 
129
- def _json_out(payload) -> None:
139
+ def _json_out(payload: object) -> None:
130
140
  """Emit a machine-readable payload (Decimals/dates as strings)."""
131
141
  import json
132
142
 
@@ -330,20 +340,31 @@ def match(
330
340
 
331
341
  @prices_app.command("sync")
332
342
  def prices_sync(
333
- out: Path = typer.Option(
334
- ledger.DEFAULT_PRICES, "--out", help="Price file (default: prices.bean)."
343
+ out: Path | None = typer.Option(
344
+ None, "--out", help="Price file (default: [ledger].prices from quints.toml, prices.bean)."
335
345
  ),
336
346
  from_: str | None = typer.Option(
337
347
  None,
338
348
  "--from",
339
- help="Repair: re-scan from this date and fill ANY missing days (heals interior gaps).",
349
+ help="Repair: re-check every day from this date, even days already verified.",
350
+ ),
351
+ source: str | None = typer.Option(
352
+ None,
353
+ "--source",
354
+ help="beanprice source module (default: [prices].source from quints.toml, beanprice_bazg).",
340
355
  ),
341
356
  as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
342
357
  ):
343
- """Fetch new BAZG daily CHF rates into the price file (full precision, gap-aware).
344
-
345
- Without --from: extend each currency forward to today (fast, daily use).
346
- With --from DATE: re-scan the whole range and fill any missing days.
358
+ """Fetch daily rates into the price file (full precision, resumable, gap-aware).
359
+
360
+ What to fetch comes from the ledger's commodity `price:` metadata — the
361
+ same declarations bean-price reads — or, when the ledger declares none,
362
+ from [prices] in quints.toml. Every run extends each currency forward to
363
+ today AND fills interior gaps; days the source has no rate for (weekends,
364
+ holidays) are checked once and recorded as verified in the file header.
365
+ The file is written as rates arrive, so an interrupted sync resumes where
366
+ it left off.
367
+ With --from DATE: re-check the whole range, ignoring the verified record.
347
368
  """
348
369
  from rich.progress import (
349
370
  BarColumn,
@@ -354,10 +375,24 @@ def prices_sync(
354
375
  TimeRemainingColumn,
355
376
  )
356
377
 
378
+ cfg = config_mod.get()
379
+ out = out if out is not None else cfg.ledger_prices
357
380
  repair = _parse_date(from_) if from_ else None
358
- # BAZG has no bulk endpoint — the fetch is one request per calendar day per
359
- # currency, so a backfill takes a while: show a live per-currency bar
360
- # (stderr, transient) unless the caller asked for machine-readable output.
381
+ # bean-price mode: commodity `price:` metadata declares what to fetch.
382
+ # --source forces config mode; a metadata-less ledger falls back to it too.
383
+ jobs = None
384
+ if source is None and cfg.ledger_main.exists():
385
+ entries, _errors = ledger.load_entries(cfg.ledger_main)
386
+
387
+ def bad_meta(ccy: str, message: str) -> None:
388
+ typer.secho(
389
+ f"{ccy}: skipping unusable price metadata — {message}", fg="yellow", err=True
390
+ )
391
+
392
+ jobs = prices_mod.jobs_from_ledger(entries, on_error=bad_meta) or None
393
+ # Sources without a bulk endpoint fetch one request per day per currency,
394
+ # so a backfill takes a while: show a live per-currency bar (stderr,
395
+ # transient) unless the caller asked for machine-readable output.
361
396
  with Progress(
362
397
  TextColumn("[progress.description]{task.description}"),
363
398
  BarColumn(),
@@ -375,7 +410,16 @@ def prices_sync(
375
410
  tasks[ccy] = bars.add_task(ccy, total=total)
376
411
  bars.update(tasks[ccy], completed=done)
377
412
 
378
- result = prices_mod.sync(out, repair_from=repair, progress=on_progress)
413
+ result = prices_mod.sync(
414
+ out,
415
+ repair_from=repair,
416
+ currencies=cfg.prices_currencies,
417
+ source=source if source is not None else cfg.prices_source,
418
+ quote=cfg.operating_currency,
419
+ tickers=dict(cfg.prices_tickers),
420
+ progress=on_progress,
421
+ jobs=jobs,
422
+ )
379
423
  if as_json:
380
424
  _json_out(
381
425
  {
@@ -383,15 +427,27 @@ def prices_sync(
383
427
  "wrote": result.wrote,
384
428
  "added": result.added,
385
429
  "per_currency": {
386
- ccy: {"added": added, "had_through": last}
387
- for ccy, (added, last) in result.per_currency.items()
430
+ ccy: {
431
+ "added": s.added,
432
+ "healed": s.healed,
433
+ "unavailable": s.unavailable,
434
+ "had_through": s.last,
435
+ }
436
+ for ccy, s in result.per_currency.items()
388
437
  },
389
438
  }
390
439
  )
391
440
  return
392
- for ccy, (added, last) in result.per_currency.items():
393
- where = f"had through {last}" if last else "was empty"
394
- typer.echo(f"{ccy}: +{added} rate(s) ({where}).")
441
+ for ccy, s in result.per_currency.items():
442
+ where = f"had through {s.last}" if s.last else "was empty"
443
+ healed = f", {s.healed} gap day(s) healed" if s.healed else ""
444
+ typer.echo(f"{ccy}: +{s.added} rate(s){healed} ({where}).")
445
+ if s.unavailable:
446
+ typer.secho(
447
+ f"{ccy}: {s.unavailable} day(s) unavailable from the source — retried next sync.",
448
+ fg="yellow",
449
+ err=True,
450
+ )
395
451
  if result.wrote:
396
452
  typer.secho(f"Wrote {result.added} new price(s) to {out.name} (sorted).", fg="green")
397
453
  else:
@@ -535,11 +591,13 @@ def import_wise(
535
591
  raise typer.Exit(1)
536
592
  _parse_date(from_)
537
593
  _parse_date(to)
594
+ from beangulp_wise import ScaChallenge, WiseError
595
+
538
596
  try:
539
597
  statements = importing_mod.fetch_wise(from_, to, out)
540
- except importing_mod.WiseError as e:
598
+ except WiseError as e:
541
599
  typer.secho(f"ERROR: {e}", fg="red", err=True)
542
- if isinstance(e, importing_mod.ScaChallenge):
600
+ if isinstance(e, ScaChallenge):
543
601
  typer.secho(
544
602
  "Statements are SCA-protected: upload .wise/public.pem in Wise "
545
603
  "(Settings → API tokens → Manage public keys) and set "
@@ -608,18 +666,19 @@ def import_stripe(
608
666
  _report_import(result, as_json)
609
667
 
610
668
 
611
- def _report_import(result, as_json: bool = False) -> None:
669
+ def _report_import(result: importing_mod.ImportResult, as_json: bool = False) -> None:
612
670
  if as_json:
671
+ from beancount.core import data
613
672
 
614
- def txn(t):
673
+ def txn(t: data.Transaction) -> dict[str, object]:
615
674
  u = t.postings[0].units
616
675
  return {
617
676
  "date": str(t.date),
618
677
  "flag": t.flag,
619
678
  "payee": t.payee,
620
679
  "narration": t.narration,
621
- "amount": u.number,
622
- "currency": u.currency,
680
+ "amount": u.number if u else None,
681
+ "currency": u.currency if u else None,
623
682
  }
624
683
 
625
684
  _json_out(
@@ -789,6 +848,8 @@ def invoice(
789
848
  print(ledger_draft)
790
849
  print()
791
850
  elif cc.ok:
851
+ if cc.ledger_total is None: # unreachable: found implies totals are set
852
+ raise ValueError(f"cross-check for {inv.number} lost its ledger total")
792
853
  ui.console.print(
793
854
  f"[ok]Ledger match[/] ({cc.date}): {inv.currency} {m.money(cc.ledger_total)}"
794
855
  )
@@ -797,6 +858,9 @@ def invoice(
797
858
  f"[warn]booking date {cc.date} ≠ invoice date {inv.issue_date}[/]"
798
859
  )
799
860
  else:
861
+ if cc.ledger_total is None or cc.invoice_total is None:
862
+ # unreachable: found implies totals are set, but narrows the Optionals
863
+ raise ValueError(f"cross-check for {inv.number} lost its totals")
800
864
  ui.console.print(
801
865
  f"[err]Ledger CONFLICT[/] ({cc.date}): {inv.number} is already booked "
802
866
  f"at {m.money(cc.ledger_total)} but the invoice says "
@@ -812,7 +876,7 @@ def invoice(
812
876
  "kind": inv.kind,
813
877
  "currency": inv.currency,
814
878
  "issue_date": inv.issue_date,
815
- "customer": inv.customer.name,
879
+ "customer": inv.resolved_customer.name,
816
880
  "pdf": str(path),
817
881
  "totals": totals.model_dump(),
818
882
  "qr_payload_ok": qr_ok,
@@ -112,6 +112,10 @@ class Config:
112
112
  rounding_income: str = "Income:CH:GmbH:Rounding"
113
113
  income_domestic: str = "Income:CH:GmbH:Consulting:External:Domestic"
114
114
  income_export: str = "Income:CH:GmbH:Consulting:External:Export"
115
+ # [prices]
116
+ prices_source: str = "beanprice_bazg" # beanprice source module for `prices sync`
117
+ prices_currencies: tuple[str, ...] = ("USD", "EUR") # priced against operating_currency
118
+ prices_tickers: tuple[tuple[str, str], ...] = () # currency -> source ticker, when they differ
115
119
  # [report]
116
120
  report_language: str = "en"
117
121
  # [import.*] — statement importers (plan 2); None = not configured
@@ -120,67 +124,80 @@ class Config:
120
124
  import_stripe: StripeImport | None = None
121
125
 
122
126
 
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", ()))
127
+ def _rules(section: dict[str, object]) -> tuple[tuple[str, str, str], ...]:
128
+ rules = section.get("rules")
129
+ if not isinstance(rules, list):
130
+ return ()
131
+ return tuple((r[0], r[1], r[2]) for r in rules)
125
132
 
126
133
 
127
- def _accounts_map(section: dict, default) -> tuple[tuple[str, str], ...]:
128
- if "accounts" not in section:
134
+ def _accounts_map(
135
+ section: dict[str, object], default: tuple[tuple[str, str], ...]
136
+ ) -> tuple[tuple[str, str], ...]:
137
+ accounts = section.get("accounts")
138
+ if not isinstance(accounts, dict):
129
139
  return default
130
- return tuple(sorted(section["accounts"].items()))
140
+ return tuple(sorted(accounts.items()))
131
141
 
132
142
 
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"]
143
+ def _import_sections(raw: dict[str, object]) -> dict[str, object]:
144
+ imports = raw.get("import")
145
+ if not isinstance(imports, dict):
146
+ return {}
147
+ updates: dict[str, object] = {}
148
+ ubs = imports.get("ubs")
149
+ if isinstance(ubs, dict):
138
150
  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),
151
+ account=ubs.get("account", UbsImport.account),
152
+ iban=ubs.get("iban"),
153
+ currency=ubs.get("currency", UbsImport.currency),
154
+ rules=_rules(ubs),
143
155
  )
144
- if "wise" in imports:
145
- s = imports["wise"]
156
+ wise = imports.get("wise")
157
+ if isinstance(wise, dict):
146
158
  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),
159
+ accounts=_accounts_map(wise, WiseImport.accounts),
160
+ fees_account=wise.get("fees_account", WiseImport.fees_account),
161
+ holder=wise.get("holder"),
162
+ rules=_rules(wise),
151
163
  )
152
- if "stripe" in imports:
153
- s = imports["stripe"]
164
+ stripe = imports.get("stripe")
165
+ if isinstance(stripe, dict):
154
166
  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),
167
+ accounts=_accounts_map(stripe, StripeImport.accounts),
168
+ fees_account=stripe.get("fees_account", StripeImport.fees_account),
169
+ tax_account=stripe.get("tax_account", StripeImport.tax_account),
170
+ account_id=stripe.get("account_id"),
171
+ rules=_rules(stripe),
160
172
  )
161
173
  return updates
162
174
 
163
175
 
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 {}
176
+ def _from_mapping(raw: dict[str, object]) -> Config:
177
+ def section(key: str) -> dict[str, object]:
178
+ value = raw.get(key)
179
+ return value if isinstance(value, dict) else {}
180
+
181
+ entity = section("entity")
182
+ ledger_ = section("ledger")
183
+ accounts = section("accounts")
184
+ report = section("report")
169
185
  cfg = Config()
170
- updates: dict = {}
186
+ updates: dict[str, object] = {}
171
187
 
172
- def take(section: dict, key: str, field: str, cast=None):
188
+ def take(section: dict[str, object], key: str, field: str) -> None:
173
189
  if key in section:
174
- value = section[key]
175
- updates[field] = cast(value) if cast else value
190
+ updates[field] = section[key]
176
191
 
177
192
  take(entity, "name", "entity_name")
178
193
  take(entity, "legal_form", "legal_form")
179
194
  take(entity, "vat_method", "vat_method")
180
195
  take(entity, "vat_registered_since", "vat_registered_since")
181
196
  take(entity, "operating_currency", "operating_currency")
182
- take(ledger_, "main", "ledger_main", Path)
183
- take(ledger_, "prices", "ledger_prices", Path)
197
+ if "main" in ledger_:
198
+ updates["ledger_main"] = Path(str(ledger_["main"]))
199
+ if "prices" in ledger_:
200
+ updates["ledger_prices"] = Path(str(ledger_["prices"]))
184
201
  for key in (
185
202
  "input_vat",
186
203
  "output_vat",
@@ -197,6 +214,15 @@ def _from_mapping(raw: dict) -> Config:
197
214
  "income_export",
198
215
  ):
199
216
  take(accounts, key, key)
217
+ prices_ = section("prices")
218
+ take(prices_, "source", "prices_source")
219
+ if "currencies" in prices_:
220
+ currencies = prices_["currencies"]
221
+ if isinstance(currencies, list):
222
+ updates["prices_currencies"] = tuple(str(c) for c in currencies)
223
+ tickers = prices_.get("tickers")
224
+ if isinstance(tickers, dict):
225
+ updates["prices_tickers"] = tuple(sorted((str(k), str(v)) for k, v in tickers.items()))
200
226
  take(report, "language", "report_language")
201
227
  updates.update(_import_sections(raw))
202
228
  return replace(cfg, **updates)
@@ -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")))