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.
- {quints-0.4.0 → quints-0.5.0}/PKG-INFO +1 -1
- {quints-0.4.0 → quints-0.5.0}/examples/quints.toml +4 -0
- {quints-0.4.0 → quints-0.5.0}/pyproject.toml +6 -1
- {quints-0.4.0 → quints-0.5.0}/src/quints/cli.py +91 -27
- {quints-0.4.0 → quints-0.5.0}/src/quints/config.py +64 -38
- {quints-0.4.0 → quints-0.5.0}/src/quints/fava/__init__.py +28 -3
- {quints-0.4.0 → quints-0.5.0}/src/quints/fx.py +6 -3
- {quints-0.4.0 → quints-0.5.0}/src/quints/importing.py +59 -28
- {quints-0.4.0 → quints-0.5.0}/src/quints/inbox.py +3 -3
- {quints-0.4.0 → quints-0.5.0}/src/quints/init.py +4 -0
- {quints-0.4.0 → quints-0.5.0}/src/quints/invoice/draft.py +3 -2
- {quints-0.4.0 → quints-0.5.0}/src/quints/invoice/labels.py +1 -1
- {quints-0.4.0 → quints-0.5.0}/src/quints/invoice/model.py +15 -1
- {quints-0.4.0 → quints-0.5.0}/src/quints/invoice/qr.py +8 -3
- {quints-0.4.0 → quints-0.5.0}/src/quints/invoice/render.py +46 -15
- {quints-0.4.0 → quints-0.5.0}/src/quints/kmu.py +17 -7
- {quints-0.4.0 → quints-0.5.0}/src/quints/ledger.py +1 -2
- {quints-0.4.0 → quints-0.5.0}/src/quints/match.py +16 -11
- {quints-0.4.0 → quints-0.5.0}/src/quints/mwst.py +27 -11
- {quints-0.4.0 → quints-0.5.0}/src/quints/plugins/kmu.py +1 -1
- quints-0.5.0/src/quints/prices.py +514 -0
- {quints-0.4.0 → quints-0.5.0}/src/quints/receivables.py +28 -16
- {quints-0.4.0 → quints-0.5.0}/src/quints/report_pdf.py +19 -7
- {quints-0.4.0 → quints-0.5.0}/src/quints/settlement.py +30 -14
- {quints-0.4.0 → quints-0.5.0}/src/quints/vat.py +3 -1
- quints-0.5.0/tests/test_cli_json.py +139 -0
- {quints-0.4.0 → quints-0.5.0}/tests/test_config.py +32 -12
- {quints-0.4.0 → quints-0.5.0}/tests/test_fava_ext.py +3 -1
- {quints-0.4.0 → quints-0.5.0}/tests/test_fx.py +5 -4
- {quints-0.4.0 → quints-0.5.0}/tests/test_importing.py +18 -11
- {quints-0.4.0 → quints-0.5.0}/tests/test_inbox_match.py +19 -3
- {quints-0.4.0 → quints-0.5.0}/tests/test_invoice.py +17 -15
- {quints-0.4.0 → quints-0.5.0}/tests/test_kmu_report.py +7 -6
- {quints-0.4.0 → quints-0.5.0}/tests/test_mwst.py +5 -4
- quints-0.5.0/tests/test_prices_ecosystem.py +95 -0
- quints-0.5.0/tests/test_prices_sync.py +419 -0
- {quints-0.4.0 → quints-0.5.0}/tests/test_receivables.py +4 -3
- quints-0.5.0/tests/test_report_pdf.py +87 -0
- {quints-0.4.0 → quints-0.5.0}/tests/test_settlement.py +22 -21
- quints-0.4.0/src/quints/prices.py +0 -161
- quints-0.4.0/tests/test_cli_json.py +0 -56
- quints-0.4.0/tests/test_prices_sync.py +0 -98
- quints-0.4.0/tests/test_report_pdf.py +0 -46
- {quints-0.4.0 → quints-0.5.0}/.gitignore +0 -0
- {quints-0.4.0 → quints-0.5.0}/LICENSE +0 -0
- {quints-0.4.0 → quints-0.5.0}/README.md +0 -0
- {quints-0.4.0 → quints-0.5.0}/examples/.gitignore +0 -0
- {quints-0.4.0 → quints-0.5.0}/examples/AGENTS.md +0 -0
- {quints-0.4.0 → quints-0.5.0}/examples/CLAUDE.md +0 -0
- {quints-0.4.0 → quints-0.5.0}/examples/accounts.bean +0 -0
- {quints-0.4.0 → quints-0.5.0}/examples/answers.toml +0 -0
- {quints-0.4.0 → quints-0.5.0}/examples/books/2026.bean +0 -0
- {quints-0.4.0 → quints-0.5.0}/examples/commodities.bean +0 -0
- {quints-0.4.0 → quints-0.5.0}/examples/documents/.gitkeep +0 -0
- {quints-0.4.0 → quints-0.5.0}/examples/inbox/.gitkeep +0 -0
- {quints-0.4.0 → quints-0.5.0}/examples/invoicing/acme-2026-07.yaml +0 -0
- {quints-0.4.0 → quints-0.5.0}/examples/invoicing/customers.yaml +0 -0
- {quints-0.4.0 → quints-0.5.0}/examples/invoicing/globex-2026-08.yaml +0 -0
- {quints-0.4.0 → quints-0.5.0}/examples/invoicing/issuer.yaml +0 -0
- {quints-0.4.0 → quints-0.5.0}/examples/main.bean +0 -0
- {quints-0.4.0 → quints-0.5.0}/examples/prices.bean +0 -0
- {quints-0.4.0 → quints-0.5.0}/examples/pyproject.toml +0 -0
- {quints-0.4.0 → quints-0.5.0}/examples/staging/.gitkeep +0 -0
- {quints-0.4.0 → quints-0.5.0}/examples/statements/ubs-2026.mt940 +0 -0
- {quints-0.4.0 → quints-0.5.0}/src/quints/__init__.py +0 -0
- {quints-0.4.0 → quints-0.5.0}/src/quints/fava/templates/QuintDashboard.html +0 -0
- {quints-0.4.0 → quints-0.5.0}/src/quints/invoice/__init__.py +0 -0
- {quints-0.4.0 → quints-0.5.0}/src/quints/invoice/template.typ +0 -0
- {quints-0.4.0 → quints-0.5.0}/src/quints/invoice/vatid.py +0 -0
- {quints-0.4.0 → quints-0.5.0}/src/quints/invoice/verify.py +0 -0
- {quints-0.4.0 → quints-0.5.0}/src/quints/plugins/__init__.py +0 -0
- {quints-0.4.0 → quints-0.5.0}/src/quints/report.typ +0 -0
- {quints-0.4.0 → quints-0.5.0}/src/quints/ui.py +0 -0
- {quints-0.4.0 → quints-0.5.0}/tests/fixtures/transactions.mt940 +0 -0
- {quints-0.4.0 → quints-0.5.0}/tests/test_docs.py +0 -0
- {quints-0.4.0 → quints-0.5.0}/tests/test_init.py +0 -0
- {quints-0.4.0 → quints-0.5.0}/tests/test_kmu_plugin.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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-
|
|
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
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
#
|
|
359
|
-
#
|
|
360
|
-
|
|
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(
|
|
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: {
|
|
387
|
-
|
|
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,
|
|
393
|
-
where = f"had through {last}" if last else "was empty"
|
|
394
|
-
|
|
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
|
|
598
|
+
except WiseError as e:
|
|
541
599
|
typer.secho(f"ERROR: {e}", fg="red", err=True)
|
|
542
|
-
if isinstance(e,
|
|
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.
|
|
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
|
-
|
|
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(
|
|
128
|
-
|
|
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(
|
|
140
|
+
return tuple(sorted(accounts.items()))
|
|
131
141
|
|
|
132
142
|
|
|
133
|
-
def _import_sections(raw: dict) -> dict:
|
|
134
|
-
imports = raw.get("import")
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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=
|
|
140
|
-
iban=
|
|
141
|
-
currency=
|
|
142
|
-
rules=_rules(
|
|
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
|
-
|
|
145
|
-
|
|
156
|
+
wise = imports.get("wise")
|
|
157
|
+
if isinstance(wise, dict):
|
|
146
158
|
updates["import_wise"] = WiseImport(
|
|
147
|
-
accounts=_accounts_map(
|
|
148
|
-
fees_account=
|
|
149
|
-
holder=
|
|
150
|
-
rules=_rules(
|
|
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
|
-
|
|
153
|
-
|
|
164
|
+
stripe = imports.get("stripe")
|
|
165
|
+
if isinstance(stripe, dict):
|
|
154
166
|
updates["import_stripe"] = StripeImport(
|
|
155
|
-
accounts=_accounts_map(
|
|
156
|
-
fees_account=
|
|
157
|
-
tax_account=
|
|
158
|
-
account_id=
|
|
159
|
-
rules=_rules(
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
188
|
+
def take(section: dict[str, object], key: str, field: str) -> None:
|
|
173
189
|
if key in section:
|
|
174
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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(
|
|
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")))
|