quints 0.2.0__tar.gz → 0.3.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.2.0 → quints-0.3.0}/.gitignore +1 -0
- {quints-0.2.0 → quints-0.3.0}/PKG-INFO +3 -3
- {quints-0.2.0 → quints-0.3.0}/README.md +1 -1
- quints-0.3.0/examples/invoicing/acme-2026-07.yaml +15 -0
- quints-0.3.0/examples/invoicing/customers.yaml +15 -0
- quints-0.3.0/examples/invoicing/globex-2026-08.yaml +13 -0
- quints-0.3.0/examples/invoicing/issuer.yaml +16 -0
- {quints-0.2.0 → quints-0.3.0}/pyproject.toml +2 -2
- {quints-0.2.0 → quints-0.3.0}/src/quints/__init__.py +1 -1
- {quints-0.2.0 → quints-0.3.0}/src/quints/cli.py +30 -1
- {quints-0.2.0 → quints-0.3.0}/src/quints/init.py +116 -1
- {quints-0.2.0 → quints-0.3.0}/src/quints/prices.py +50 -3
- quints-0.3.0/tests/test_docs.py +112 -0
- {quints-0.2.0 → quints-0.3.0}/tests/test_init.py +32 -0
- {quints-0.2.0 → quints-0.3.0}/tests/test_prices_sync.py +21 -1
- quints-0.2.0/examples/invoicing/acme-2026-07.yaml +0 -19
- quints-0.2.0/examples/invoicing/issuer.yaml +0 -12
- quints-0.2.0/tests/test_docs.py +0 -48
- {quints-0.2.0 → quints-0.3.0}/LICENSE +0 -0
- {quints-0.2.0 → quints-0.3.0}/examples/.gitignore +0 -0
- {quints-0.2.0 → quints-0.3.0}/examples/AGENTS.md +0 -0
- {quints-0.2.0 → quints-0.3.0}/examples/accounts.bean +0 -0
- {quints-0.2.0 → quints-0.3.0}/examples/answers.toml +0 -0
- {quints-0.2.0 → quints-0.3.0}/examples/books/2026.bean +0 -0
- {quints-0.2.0 → quints-0.3.0}/examples/commodities.bean +0 -0
- {quints-0.2.0 → quints-0.3.0}/examples/documents/.gitkeep +0 -0
- {quints-0.2.0 → quints-0.3.0}/examples/inbox/.gitkeep +0 -0
- {quints-0.2.0 → quints-0.3.0}/examples/main.bean +0 -0
- {quints-0.2.0 → quints-0.3.0}/examples/prices.bean +0 -0
- {quints-0.2.0 → quints-0.3.0}/examples/pyproject.toml +0 -0
- {quints-0.2.0 → quints-0.3.0}/examples/quints.toml +0 -0
- {quints-0.2.0 → quints-0.3.0}/examples/staging/.gitkeep +0 -0
- {quints-0.2.0 → quints-0.3.0}/examples/statements/ubs-2026.mt940 +0 -0
- {quints-0.2.0 → quints-0.3.0}/src/quints/config.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/src/quints/fava/__init__.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/src/quints/fava/templates/QuintDashboard.html +0 -0
- {quints-0.2.0 → quints-0.3.0}/src/quints/fx.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/src/quints/importing.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/src/quints/inbox.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/src/quints/invoice/__init__.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/src/quints/invoice/draft.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/src/quints/invoice/labels.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/src/quints/invoice/model.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/src/quints/invoice/qr.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/src/quints/invoice/render.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/src/quints/invoice/template.typ +0 -0
- {quints-0.2.0 → quints-0.3.0}/src/quints/invoice/vatid.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/src/quints/invoice/verify.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/src/quints/kmu.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/src/quints/ledger.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/src/quints/match.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/src/quints/mwst.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/src/quints/plugins/__init__.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/src/quints/plugins/kmu.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/src/quints/receivables.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/src/quints/report.typ +0 -0
- {quints-0.2.0 → quints-0.3.0}/src/quints/report_pdf.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/src/quints/settlement.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/src/quints/ui.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/src/quints/vat.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/tests/fixtures/transactions.mt940 +0 -0
- {quints-0.2.0 → quints-0.3.0}/tests/test_cli_json.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/tests/test_config.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/tests/test_fava_ext.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/tests/test_fx.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/tests/test_importing.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/tests/test_inbox_match.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/tests/test_invoice.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/tests/test_kmu_plugin.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/tests/test_kmu_report.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/tests/test_ledger.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/tests/test_mwst.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/tests/test_receivables.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/tests/test_report_pdf.py +0 -0
- {quints-0.2.0 → quints-0.3.0}/tests/test_settlement.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: quints
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -11,7 +11,7 @@ Requires-Dist: beancount>=3.2.0
|
|
|
11
11
|
Requires-Dist: beangulp-mt940>=0.1.0
|
|
12
12
|
Requires-Dist: beangulp-stripe>=0.1.0
|
|
13
13
|
Requires-Dist: beangulp-wise[sca]>=0.1.0
|
|
14
|
-
Requires-Dist: beanprice-bazg>=0.
|
|
14
|
+
Requires-Dist: beanprice-bazg>=0.2.0
|
|
15
15
|
Requires-Dist: fava>=1.30
|
|
16
16
|
Requires-Dist: pydantic>=2.7
|
|
17
17
|
Requires-Dist: python-stdnum>=1.20
|
|
@@ -27,7 +27,7 @@ Description-Content-Type: text/markdown
|
|
|
27
27
|
|
|
28
28
|
Swiss VAT & accounting toolkit for plain-text ([beancount](https://github.com/beancount/beancount)) books.
|
|
29
29
|
|
|
30
|
-
Everything a Swiss micro-company (GmbH
|
|
30
|
+
Everything a Swiss micro-company (GmbH, AG, or Einzelfirma) needs on top of beancount + Fava:
|
|
31
31
|
|
|
32
32
|
- **MWST**: quarterly VAT report mapped to the ESTV form Ziffern, settlement
|
|
33
33
|
transactions, VAT status, and Bezugsteuer (reverse-charge, Art. 45 ff. MWSTG)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Swiss VAT & accounting toolkit for plain-text ([beancount](https://github.com/beancount/beancount)) books.
|
|
4
4
|
|
|
5
|
-
Everything a Swiss micro-company (GmbH
|
|
5
|
+
Everything a Swiss micro-company (GmbH, AG, or Einzelfirma) needs on top of beancount + Fava:
|
|
6
6
|
|
|
7
7
|
- **MWST**: quarterly VAT report mapped to the ESTV form Ziffern, settlement
|
|
8
8
|
transactions, VAT status, and Bezugsteuer (reverse-charge, Art. 45 ff. MWSTG)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Sample domestic invoice — a Swiss QR-bill. It ties to the ^INV2026014
|
|
2
|
+
# booking in books/2026.bean (net 1'000.00 + 8.1% VAT = 1'081.00), so
|
|
3
|
+
# `quints invoice` cross-checks it clean against the ledger.
|
|
4
|
+
number: INV2026014
|
|
5
|
+
kind: domestic
|
|
6
|
+
currency: CHF
|
|
7
|
+
issue_date: 2026-07-02
|
|
8
|
+
supply: Juli 2026
|
|
9
|
+
customer: acme
|
|
10
|
+
items:
|
|
11
|
+
- description: Consulting — July
|
|
12
|
+
quantity: 1
|
|
13
|
+
unit_price: 1000.00
|
|
14
|
+
unit: Pauschal
|
|
15
|
+
locale: de_CH
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Customer registry — invoices reference these entries by key. A customer
|
|
2
|
+
# is a flat entry, or a dated `versions` history for address changes.
|
|
3
|
+
acme:
|
|
4
|
+
name: Acme AG
|
|
5
|
+
address:
|
|
6
|
+
- Bahnhofstrasse 1
|
|
7
|
+
- 8001 Zürich
|
|
8
|
+
globex:
|
|
9
|
+
name: Globex Ltd
|
|
10
|
+
country: IE
|
|
11
|
+
# Reverse-charge exports must carry the customer's VAT number.
|
|
12
|
+
vat_id: IE1234567T
|
|
13
|
+
address:
|
|
14
|
+
- 1 Liffey Street
|
|
15
|
+
- Dublin 1
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Sample export invoice — foreign currency, reverse charge, no QR part.
|
|
2
|
+
# Ties to the ^INV2026015 booking in books/2026.bean (500.00 EUR).
|
|
3
|
+
number: INV2026015
|
|
4
|
+
kind: export
|
|
5
|
+
currency: EUR
|
|
6
|
+
issue_date: 2026-08-05
|
|
7
|
+
supply: August 2026
|
|
8
|
+
customer: globex
|
|
9
|
+
items:
|
|
10
|
+
- description: Export consulting
|
|
11
|
+
quantity: 1
|
|
12
|
+
unit_price: 500.00
|
|
13
|
+
locale: en
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Issuer identity for `quints invoice` — name, address, VAT ID, and one
|
|
2
|
+
# bank account per invoicing currency. Sample data: replace the VAT ID
|
|
3
|
+
# and IBANs with your own before issuing a real invoice.
|
|
4
|
+
name: Example GmbH
|
|
5
|
+
address:
|
|
6
|
+
- Beispielstrasse 1
|
|
7
|
+
- 8000 Zürich
|
|
8
|
+
vat_id: CHE-267.359.056 MWST
|
|
9
|
+
email: billing@example.ch
|
|
10
|
+
bank:
|
|
11
|
+
CHF:
|
|
12
|
+
# QR-IBAN (QR-IID variant) — a Swiss QR-bill with a QRR reference.
|
|
13
|
+
qr_iban: CH44 3199 9123 0008 8901 2
|
|
14
|
+
EUR:
|
|
15
|
+
# Regular IBAN — foreign transfers can't use the QR-bill scheme.
|
|
16
|
+
iban: CH93 0076 2011 6238 5295 7
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "quints"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.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"
|
|
@@ -15,7 +15,7 @@ dependencies = [
|
|
|
15
15
|
"beangulp-mt940>=0.1.0",
|
|
16
16
|
"beangulp-stripe>=0.1.0",
|
|
17
17
|
"beangulp-wise[sca]>=0.1.0",
|
|
18
|
-
"beanprice-bazg>=0.
|
|
18
|
+
"beanprice-bazg>=0.2.0",
|
|
19
19
|
"fava>=1.30",
|
|
20
20
|
"pydantic>=2.7",
|
|
21
21
|
"python-stdnum>=1.20",
|
|
@@ -330,8 +330,37 @@ def prices_sync(
|
|
|
330
330
|
Without --from: extend each currency forward to today (fast, daily use).
|
|
331
331
|
With --from DATE: re-scan the whole range and fill any missing days.
|
|
332
332
|
"""
|
|
333
|
+
from rich.progress import (
|
|
334
|
+
BarColumn,
|
|
335
|
+
MofNCompleteColumn,
|
|
336
|
+
Progress,
|
|
337
|
+
TaskID,
|
|
338
|
+
TextColumn,
|
|
339
|
+
TimeRemainingColumn,
|
|
340
|
+
)
|
|
341
|
+
|
|
333
342
|
repair = _parse_date(from_) if from_ else None
|
|
334
|
-
|
|
343
|
+
# BAZG has no bulk endpoint — the fetch is one request per calendar day per
|
|
344
|
+
# currency, so a backfill takes a while: show a live per-currency bar
|
|
345
|
+
# (stderr, transient) unless the caller asked for machine-readable output.
|
|
346
|
+
with Progress(
|
|
347
|
+
TextColumn("[progress.description]{task.description}"),
|
|
348
|
+
BarColumn(),
|
|
349
|
+
MofNCompleteColumn(),
|
|
350
|
+
TextColumn("days"),
|
|
351
|
+
TimeRemainingColumn(),
|
|
352
|
+
console=ui.err_console,
|
|
353
|
+
transient=True,
|
|
354
|
+
disable=as_json,
|
|
355
|
+
) as bars:
|
|
356
|
+
tasks: dict[str, TaskID] = {}
|
|
357
|
+
|
|
358
|
+
def on_progress(ccy: str, done: int, total: int) -> None:
|
|
359
|
+
if ccy not in tasks:
|
|
360
|
+
tasks[ccy] = bars.add_task(ccy, total=total)
|
|
361
|
+
bars.update(tasks[ccy], completed=done)
|
|
362
|
+
|
|
363
|
+
result = prices_mod.sync(out, repair_from=repair, progress=on_progress)
|
|
335
364
|
if as_json:
|
|
336
365
|
_json_out(
|
|
337
366
|
{
|
|
@@ -28,7 +28,9 @@ The compute layer (:func:`plan`) is presentation-free: answers in, an ordered
|
|
|
28
28
|
list of :class:`ScaffoldFile` out, no I/O. :func:`write` does the I/O. Running
|
|
29
29
|
:func:`plan` with ``include_samples=True`` produces the repo's ``examples/``
|
|
30
30
|
project, which doubles as the CI smoke test — so the example is regenerated,
|
|
31
|
-
never hand-maintained.
|
|
31
|
+
never hand-maintained. The samples include an ``invoicing/`` set (issuer,
|
|
32
|
+
customer registry, a domestic QR-bill and a reverse-charge export invoice)
|
|
33
|
+
tied to the demo quarter's bookings, so ``quints invoice`` reconciles clean.
|
|
32
34
|
"""
|
|
33
35
|
|
|
34
36
|
from __future__ import annotations
|
|
@@ -405,6 +407,108 @@ def _prices_bean(answers: Answers) -> str:
|
|
|
405
407
|
)
|
|
406
408
|
|
|
407
409
|
|
|
410
|
+
# ── invoicing samples ────────────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
# Checksum-valid demo identifiers (stdnum-verified) — obviously not real.
|
|
413
|
+
_SAMPLE_VAT_ID = "CHE-267.359.056 MWST"
|
|
414
|
+
_SAMPLE_QR_IBAN = "CH44 3199 9123 0008 8901 2" # QR-IID range 30000–31999
|
|
415
|
+
_SAMPLE_IBAN = "CH93 0076 2011 6238 5295 7"
|
|
416
|
+
_SAMPLE_CUSTOMER_VAT_ID = "IE1234567T"
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _issuer_yaml(answers: Answers) -> str:
|
|
420
|
+
return "\n".join(
|
|
421
|
+
[
|
|
422
|
+
"# Issuer identity for `quints invoice` — name, address, VAT ID, and one",
|
|
423
|
+
"# bank account per invoicing currency. Sample data: replace the VAT ID",
|
|
424
|
+
"# and IBANs with your own before issuing a real invoice.",
|
|
425
|
+
f"name: {answers.entity_name}",
|
|
426
|
+
"address:",
|
|
427
|
+
" - Beispielstrasse 1",
|
|
428
|
+
" - 8000 Zürich",
|
|
429
|
+
f"vat_id: {_SAMPLE_VAT_ID}",
|
|
430
|
+
"email: billing@example.ch",
|
|
431
|
+
"bank:",
|
|
432
|
+
" CHF:",
|
|
433
|
+
" # QR-IBAN (QR-IID variant) — a Swiss QR-bill with a QRR reference.",
|
|
434
|
+
f" qr_iban: {_SAMPLE_QR_IBAN}",
|
|
435
|
+
" EUR:",
|
|
436
|
+
" # Regular IBAN — foreign transfers can't use the QR-bill scheme.",
|
|
437
|
+
f" iban: {_SAMPLE_IBAN}",
|
|
438
|
+
"",
|
|
439
|
+
]
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _customers_yaml(_answers: Answers) -> str:
|
|
444
|
+
return "\n".join(
|
|
445
|
+
[
|
|
446
|
+
"# Customer registry — invoices reference these entries by key. A customer",
|
|
447
|
+
"# is a flat entry, or a dated `versions` history for address changes.",
|
|
448
|
+
"acme:",
|
|
449
|
+
" name: Acme AG",
|
|
450
|
+
" address:",
|
|
451
|
+
" - Bahnhofstrasse 1",
|
|
452
|
+
" - 8001 Zürich",
|
|
453
|
+
"globex:",
|
|
454
|
+
" name: Globex Ltd",
|
|
455
|
+
" country: IE",
|
|
456
|
+
" # Reverse-charge exports must carry the customer's VAT number.",
|
|
457
|
+
f" vat_id: {_SAMPLE_CUSTOMER_VAT_ID}",
|
|
458
|
+
" address:",
|
|
459
|
+
" - 1 Liffey Street",
|
|
460
|
+
" - Dublin 1",
|
|
461
|
+
"",
|
|
462
|
+
]
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _invoice_acme_yaml(answers: Answers) -> str:
|
|
467
|
+
year = _open_date(answers).year
|
|
468
|
+
return "\n".join(
|
|
469
|
+
[
|
|
470
|
+
f"# Sample domestic invoice — a Swiss QR-bill. It ties to the ^INV{year}014",
|
|
471
|
+
f"# booking in books/{year}.bean (net 1'000.00 + 8.1% VAT = 1'081.00), so",
|
|
472
|
+
"# `quints invoice` cross-checks it clean against the ledger.",
|
|
473
|
+
f"number: INV{year}014",
|
|
474
|
+
"kind: domestic",
|
|
475
|
+
"currency: CHF",
|
|
476
|
+
f"issue_date: {year}-07-02",
|
|
477
|
+
f"supply: Juli {year}",
|
|
478
|
+
"customer: acme",
|
|
479
|
+
"items:",
|
|
480
|
+
" - description: Consulting — July",
|
|
481
|
+
" quantity: 1",
|
|
482
|
+
" unit_price: 1000.00",
|
|
483
|
+
" unit: Pauschal",
|
|
484
|
+
"locale: de_CH",
|
|
485
|
+
"",
|
|
486
|
+
]
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _invoice_globex_yaml(answers: Answers) -> str:
|
|
491
|
+
year = _open_date(answers).year
|
|
492
|
+
return "\n".join(
|
|
493
|
+
[
|
|
494
|
+
"# Sample export invoice — foreign currency, reverse charge, no QR part.",
|
|
495
|
+
f"# Ties to the ^INV{year}015 booking in books/{year}.bean (500.00 EUR).",
|
|
496
|
+
f"number: INV{year}015",
|
|
497
|
+
"kind: export",
|
|
498
|
+
"currency: EUR",
|
|
499
|
+
f"issue_date: {year}-08-05",
|
|
500
|
+
f"supply: August {year}",
|
|
501
|
+
"customer: globex",
|
|
502
|
+
"items:",
|
|
503
|
+
" - description: Export consulting",
|
|
504
|
+
" quantity: 1",
|
|
505
|
+
" unit_price: 500.00",
|
|
506
|
+
"locale: en",
|
|
507
|
+
"",
|
|
508
|
+
]
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
|
|
408
512
|
# ── project metadata rendering ───────────────────────────────────────────────
|
|
409
513
|
|
|
410
514
|
|
|
@@ -625,6 +729,16 @@ def plan(answers: Answers) -> list[ScaffoldFile]:
|
|
|
625
729
|
"""Answers → the ordered set of files to materialise. Pure, no I/O."""
|
|
626
730
|
_validate(answers)
|
|
627
731
|
year = _open_date(answers).year
|
|
732
|
+
invoicing: list[ScaffoldFile] = []
|
|
733
|
+
if answers.include_samples:
|
|
734
|
+
# One QR-bill and one export invoice, reconciling against the sample
|
|
735
|
+
# quarter — so `quints invoice` is testable out of the box.
|
|
736
|
+
invoicing = [
|
|
737
|
+
ScaffoldFile(Path("invoicing/issuer.yaml"), _issuer_yaml(answers)),
|
|
738
|
+
ScaffoldFile(Path("invoicing/customers.yaml"), _customers_yaml(answers)),
|
|
739
|
+
ScaffoldFile(Path(f"invoicing/acme-{year}-07.yaml"), _invoice_acme_yaml(answers)),
|
|
740
|
+
ScaffoldFile(Path(f"invoicing/globex-{year}-08.yaml"), _invoice_globex_yaml(answers)),
|
|
741
|
+
]
|
|
628
742
|
files = [
|
|
629
743
|
ScaffoldFile(Path("quints.toml"), _quints_toml(answers)),
|
|
630
744
|
ScaffoldFile(Path("pyproject.toml"), _pyproject_toml(answers)),
|
|
@@ -633,6 +747,7 @@ def plan(answers: Answers) -> list[ScaffoldFile]:
|
|
|
633
747
|
ScaffoldFile(Path("commodities.bean"), _commodities_bean(answers)),
|
|
634
748
|
ScaffoldFile(Path("prices.bean"), _prices_bean(answers)),
|
|
635
749
|
ScaffoldFile(Path(f"books/{year}.bean"), _books_bean(answers)),
|
|
750
|
+
*invoicing,
|
|
636
751
|
ScaffoldFile(Path("AGENTS.md"), _agents_md(answers)),
|
|
637
752
|
ScaffoldFile(Path(".gitignore"), _gitignore()),
|
|
638
753
|
ScaffoldFile(Path("inbox/.gitkeep"), ""),
|
|
@@ -17,15 +17,45 @@ Unlike ``bean-price --update main.bean`` — which rounds through the ledger's
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
19
|
import re
|
|
20
|
+
from collections.abc import Callable, Sequence
|
|
20
21
|
from dataclasses import dataclass, field
|
|
21
22
|
from datetime import date as Date
|
|
22
23
|
from datetime import datetime, timedelta, timezone
|
|
24
|
+
from decimal import Decimal
|
|
23
25
|
from pathlib import Path
|
|
26
|
+
from typing import Protocol
|
|
24
27
|
|
|
25
28
|
from beanprice_bazg.bazg import Source
|
|
26
29
|
|
|
27
30
|
from . import ledger
|
|
28
31
|
|
|
32
|
+
ProgressFn = Callable[[str, int, int], None]
|
|
33
|
+
"""(currency, days_fetched, days_total) — called once up front and per day."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PricePoint(Protocol):
|
|
37
|
+
"""One day's rate — the shape of ``beanprice`` ``SourcePrice``."""
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def price(self) -> Decimal: ...
|
|
41
|
+
@property
|
|
42
|
+
def time(self) -> datetime: ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SeriesSource(Protocol):
|
|
46
|
+
"""The slice of ``beanprice_bazg.Source`` that :func:`sync` needs."""
|
|
47
|
+
|
|
48
|
+
def get_prices_series(
|
|
49
|
+
self,
|
|
50
|
+
ticker: str,
|
|
51
|
+
time_begin: datetime,
|
|
52
|
+
time_end: datetime,
|
|
53
|
+
/,
|
|
54
|
+
*,
|
|
55
|
+
progress: Callable[[Date], None] | None = None,
|
|
56
|
+
) -> Sequence[PricePoint]: ...
|
|
57
|
+
|
|
58
|
+
|
|
29
59
|
DEFAULT_BACKFILL_START = Date(2024, 1, 1)
|
|
30
60
|
_PRICE_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2}) price (\w+)\s+(\S+) CHF\s*$")
|
|
31
61
|
|
|
@@ -42,6 +72,21 @@ def _utc(d: Date) -> datetime:
|
|
|
42
72
|
return datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
|
|
43
73
|
|
|
44
74
|
|
|
75
|
+
def _ticker(progress: ProgressFn | None, ccy: str, total: int) -> Callable[[Date], None] | None:
|
|
76
|
+
"""Adapt the (ccy, done, total) callback to the source's per-day one."""
|
|
77
|
+
if progress is None:
|
|
78
|
+
return None
|
|
79
|
+
progress(ccy, 0, total) # announce the currency before the first request
|
|
80
|
+
done = 0
|
|
81
|
+
|
|
82
|
+
def tick(_day: Date) -> None:
|
|
83
|
+
nonlocal done
|
|
84
|
+
done += 1
|
|
85
|
+
progress(ccy, done, total)
|
|
86
|
+
|
|
87
|
+
return tick
|
|
88
|
+
|
|
89
|
+
|
|
45
90
|
def _read(out: Path):
|
|
46
91
|
"""Return (header_lines, {(ccy, date): price_str}) from an existing file."""
|
|
47
92
|
header: list[str] = []
|
|
@@ -77,9 +122,10 @@ def sync(
|
|
|
77
122
|
out: Path,
|
|
78
123
|
repair_from: Date | None = None,
|
|
79
124
|
today: Date | None = None,
|
|
80
|
-
currencies=ledger.PRICE_CURRENCIES,
|
|
125
|
+
currencies: Sequence[str] = ledger.PRICE_CURRENCIES,
|
|
81
126
|
backfill_start: Date = DEFAULT_BACKFILL_START,
|
|
82
|
-
source=None,
|
|
127
|
+
source: SeriesSource | None = None,
|
|
128
|
+
progress: ProgressFn | None = None,
|
|
83
129
|
) -> SyncResult:
|
|
84
130
|
if today is None:
|
|
85
131
|
today = datetime.now(timezone.utc).date()
|
|
@@ -99,7 +145,8 @@ def sync(
|
|
|
99
145
|
|
|
100
146
|
added = 0
|
|
101
147
|
if start <= today:
|
|
102
|
-
|
|
148
|
+
tick = _ticker(progress, ccy, total=(today - start).days + 1)
|
|
149
|
+
for sp in src.get_prices_series(ccy, _utc(start), _utc(today), progress=tick):
|
|
103
150
|
d = sp.time.date()
|
|
104
151
|
if (ccy, d) in entries: # skip present (dedups weekend echoes + repair)
|
|
105
152
|
continue
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Every quints command shown in the docs must actually run.
|
|
2
|
+
|
|
3
|
+
The docs site (docs/**/*.md) and the README promise commands; this extracts
|
|
4
|
+
them from the fenced code blocks and runs each page as a session — a fresh
|
|
5
|
+
copy of the example project, commands executed in document order through the
|
|
6
|
+
CLI (no --config/--file overrides — the same resolution a copy-paste gets),
|
|
7
|
+
asserting a clean exit. If a documented command breaks — a renamed flag, a
|
|
8
|
+
changed default — this fails, so the docs can't silently rot.
|
|
9
|
+
|
|
10
|
+
Extraction rules (documented here because docs authors rely on them):
|
|
11
|
+
|
|
12
|
+
- Only ``bash`` and ``console`` fences are scanned. In console blocks just
|
|
13
|
+
the ``$ ``-prefixed lines are commands (the rest is output); in bash
|
|
14
|
+
blocks every non-comment line is considered.
|
|
15
|
+
- Only ``quints …`` and ``cd …`` lines execute. Anything else (uv, git,
|
|
16
|
+
pipx, …) is skipped — this suite runs offline and in-process.
|
|
17
|
+
- A line containing a ``<placeholder>`` is skipped: it documents a pattern,
|
|
18
|
+
not a runnable invocation. Prefer concrete commands where possible.
|
|
19
|
+
- An HTML comment containing ``no-test`` on the line above a fence skips the
|
|
20
|
+
whole block (network commands, credential-dependent fetches).
|
|
21
|
+
- ``cd`` into a directory that doesn't exist (e.g. after a skipped
|
|
22
|
+
``git clone``) is skipped too.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import shlex
|
|
30
|
+
import shutil
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
import pytest
|
|
35
|
+
from typer.testing import CliRunner
|
|
36
|
+
|
|
37
|
+
from quints.cli import app
|
|
38
|
+
|
|
39
|
+
runner = CliRunner()
|
|
40
|
+
REPO = Path(__file__).resolve().parents[3]
|
|
41
|
+
EXAMPLES = Path(__file__).resolve().parents[1] / "examples"
|
|
42
|
+
|
|
43
|
+
DOC_PAGES = sorted(p.relative_to(REPO) for p in (REPO / "docs").rglob("*.md"))
|
|
44
|
+
PAGES = [Path("README.md"), *DOC_PAGES]
|
|
45
|
+
|
|
46
|
+
_FENCE = re.compile(r"^```(\w*)")
|
|
47
|
+
_NO_TEST = re.compile(r"<!--.*no-test.*-->")
|
|
48
|
+
_PLACEHOLDER = re.compile(r"<[^>]+>")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class _Command:
|
|
53
|
+
line: str # verbatim doc line, for failure messages
|
|
54
|
+
argv: list[str]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _commands(text: str) -> list[_Command]:
|
|
58
|
+
"""The quints/cd command sequence a reader would run for one page."""
|
|
59
|
+
commands: list[_Command] = []
|
|
60
|
+
lang: str | None = None # inside a fence when not None
|
|
61
|
+
skip_block = False
|
|
62
|
+
prev = ""
|
|
63
|
+
for raw in text.splitlines():
|
|
64
|
+
line = raw.rstrip()
|
|
65
|
+
fence = _FENCE.match(line)
|
|
66
|
+
if fence and lang is None:
|
|
67
|
+
lang = fence.group(1)
|
|
68
|
+
skip_block = bool(_NO_TEST.search(prev))
|
|
69
|
+
elif line.startswith("```"):
|
|
70
|
+
lang = None
|
|
71
|
+
elif lang in ("bash", "console") and not skip_block:
|
|
72
|
+
cmd = line.strip()
|
|
73
|
+
if lang == "console":
|
|
74
|
+
if not cmd.startswith("$ "):
|
|
75
|
+
continue # output line
|
|
76
|
+
cmd = cmd[2:]
|
|
77
|
+
elif cmd.startswith("$ "):
|
|
78
|
+
cmd = cmd[2:]
|
|
79
|
+
if _PLACEHOLDER.search(cmd):
|
|
80
|
+
continue
|
|
81
|
+
argv = shlex.split(cmd, comments=True)
|
|
82
|
+
if argv and argv[0] in ("quints", "cd"):
|
|
83
|
+
commands.append(_Command(line=cmd, argv=argv))
|
|
84
|
+
prev = line
|
|
85
|
+
return commands
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.mark.parametrize("page", PAGES, ids=str)
|
|
89
|
+
def test_documented_commands_run(
|
|
90
|
+
page: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
91
|
+
) -> None:
|
|
92
|
+
commands = _commands((REPO / page).read_text())
|
|
93
|
+
proj = tmp_path / "example"
|
|
94
|
+
shutil.copytree(EXAMPLES, proj)
|
|
95
|
+
monkeypatch.chdir(proj) # restores the original cwd on teardown
|
|
96
|
+
for cmd in commands:
|
|
97
|
+
if cmd.argv[0] == "cd":
|
|
98
|
+
if Path(cmd.argv[1]).is_dir():
|
|
99
|
+
os.chdir(cmd.argv[1])
|
|
100
|
+
continue
|
|
101
|
+
result = runner.invoke(app, cmd.argv[1:])
|
|
102
|
+
assert result.exit_code == 0, (
|
|
103
|
+
f"{page}: `{cmd.line}` exited {result.exit_code}:\n{result.output}"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_extractor_sees_the_docs() -> None:
|
|
108
|
+
# If the docs move or the extractor breaks, the suite above would pass
|
|
109
|
+
# vacuously. Pin a floor: the site exists and yields real commands.
|
|
110
|
+
assert len(DOC_PAGES) >= 8, f"docs/ pages missing: found only {DOC_PAGES}"
|
|
111
|
+
total = sum(len(_commands((REPO / p).read_text())) for p in PAGES)
|
|
112
|
+
assert total >= 25, f"only {total} documented commands extracted — extractor broken?"
|
|
@@ -182,6 +182,38 @@ def test_generated_ledger_reports_expected_numbers(tmp_path: Path):
|
|
|
182
182
|
assert open_inv[0].currency == "EUR"
|
|
183
183
|
|
|
184
184
|
|
|
185
|
+
def test_sample_invoices_render_and_reconcile(tmp_path: Path):
|
|
186
|
+
# --samples must make the invoice generator testable out of the box: a
|
|
187
|
+
# domestic QR-bill and a reverse-charge export, both reconciling against
|
|
188
|
+
# the sample quarter's ^INV… bookings.
|
|
189
|
+
from quints.invoice import model as im
|
|
190
|
+
from quints.invoice import render as ir
|
|
191
|
+
from quints.invoice import verify as iv
|
|
192
|
+
|
|
193
|
+
answers = init.Answers(entity_name="Smoke GmbH", include_samples=True)
|
|
194
|
+
init.write(tmp_path, init.plan(answers))
|
|
195
|
+
main = tmp_path / "main.bean"
|
|
196
|
+
registry = im.load_customers(tmp_path / "invoicing/customers.yaml")
|
|
197
|
+
issuer = im.load_issuer(tmp_path / "invoicing/issuer.yaml")
|
|
198
|
+
assert issuer.name == "Smoke GmbH"
|
|
199
|
+
|
|
200
|
+
domestic = im.load_invoice(tmp_path / "invoicing/acme-2026-07.yaml", registry)
|
|
201
|
+
_path, totals, payload = ir.render(domestic, issuer, tmp_path / "acme.pdf")
|
|
202
|
+
assert payload is not None and payload.splitlines()[0] == "SPC" # QR part present
|
|
203
|
+
cc = iv.cross_check(main, domestic, totals)
|
|
204
|
+
assert cc.found and cc.ok and cc.date_ok
|
|
205
|
+
|
|
206
|
+
export = im.load_invoice(tmp_path / "invoicing/globex-2026-08.yaml", registry)
|
|
207
|
+
_path, totals, payload = ir.render(export, issuer, tmp_path / "globex.pdf")
|
|
208
|
+
assert payload is None # no QR-bill on a foreign invoice
|
|
209
|
+
cc = iv.cross_check(main, export, totals)
|
|
210
|
+
assert cc.found and cc.ok and cc.date_ok
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_no_invoicing_files_without_samples():
|
|
214
|
+
assert not any(f.path.parts[0] == "invoicing" for f in init.plan(init.Answers()))
|
|
215
|
+
|
|
216
|
+
|
|
185
217
|
def test_cli_init_answers_file_and_force(tmp_path: Path):
|
|
186
218
|
# Guards the CLI wiring: --answers is non-interactive, --json is stable,
|
|
187
219
|
# and a second run without --force skips rather than clobbers.
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""Tests for the gap-aware price sync (no network — a fake source is injected)."""
|
|
2
2
|
|
|
3
|
+
from collections.abc import Callable
|
|
3
4
|
from datetime import date, timedelta
|
|
4
5
|
from decimal import Decimal
|
|
6
|
+
from pathlib import Path
|
|
5
7
|
|
|
6
8
|
from beanprice_bazg.bazg import SourcePrice
|
|
7
9
|
from quints import prices
|
|
@@ -13,11 +15,13 @@ class FakeSource:
|
|
|
13
15
|
def __init__(self):
|
|
14
16
|
self.calls = []
|
|
15
17
|
|
|
16
|
-
def get_prices_series(self, ccy, begin, end):
|
|
18
|
+
def get_prices_series(self, ccy, begin, end, progress: Callable[[date], None] | None = None):
|
|
17
19
|
self.calls.append((ccy, begin.date(), end.date()))
|
|
18
20
|
out, d = [], begin
|
|
19
21
|
while d <= end:
|
|
20
22
|
out.append(SourcePrice(Decimal("0.9"), d, "CHF"))
|
|
23
|
+
if progress is not None:
|
|
24
|
+
progress(d.date())
|
|
21
25
|
d += timedelta(days=1)
|
|
22
26
|
return out
|
|
23
27
|
|
|
@@ -71,6 +75,22 @@ def test_repair_heals_interior_gap_and_sorts(tmp_path):
|
|
|
71
75
|
assert dates.count("2026-01-06") == 1 # no duplicates
|
|
72
76
|
|
|
73
77
|
|
|
78
|
+
def test_progress_reports_each_day_per_currency(tmp_path: Path):
|
|
79
|
+
out = tmp_path / "prices.bean"
|
|
80
|
+
out.write_text(";; header\n")
|
|
81
|
+
seen = []
|
|
82
|
+
prices.sync(
|
|
83
|
+
out,
|
|
84
|
+
today=date(2026, 1, 3),
|
|
85
|
+
backfill_start=date(2026, 1, 1),
|
|
86
|
+
currencies=("EUR", "USD"),
|
|
87
|
+
source=FakeSource(),
|
|
88
|
+
progress=lambda ccy, done, total: seen.append((ccy, done, total)),
|
|
89
|
+
)
|
|
90
|
+
# Announced at 0, then one tick per fetched day, for each currency in turn.
|
|
91
|
+
assert seen == [(ccy, n, 3) for ccy in ("EUR", "USD") for n in range(4)]
|
|
92
|
+
|
|
93
|
+
|
|
74
94
|
def test_header_preserved(tmp_path):
|
|
75
95
|
out = tmp_path / "prices.bean"
|
|
76
96
|
out.write_text(";; keep me\n;; and me\n\n2026-01-01 price EUR 0.9 CHF\n")
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
# Illustrative invoice for the example project — NOT emitted by `quints init`.
|
|
2
|
-
# It ties to the ^INV2026014 booking in main.bean (net 1000 + 8.1% VAT = 1081),
|
|
3
|
-
# so `quints invoice … --verify` reconciles the PDF against the ledger.
|
|
4
|
-
number: INV2026014
|
|
5
|
-
kind: domestic
|
|
6
|
-
currency: CHF
|
|
7
|
-
issue_date: 2026-07-02
|
|
8
|
-
supply: Juli 2026
|
|
9
|
-
customer:
|
|
10
|
-
name: Acme AG
|
|
11
|
-
address:
|
|
12
|
-
- Bahnhofstrasse 1
|
|
13
|
-
- 8001 Zürich
|
|
14
|
-
items:
|
|
15
|
-
- description: Consulting — July
|
|
16
|
-
quantity: 1
|
|
17
|
-
unit_price: 1000.00
|
|
18
|
-
unit: Pauschal
|
|
19
|
-
locale: de_CH
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
# Illustrative issuer for the example project — NOT emitted by `quints init`.
|
|
2
|
-
# It's demo input for `quints invoice`; edit for your own entity.
|
|
3
|
-
name: Example GmbH
|
|
4
|
-
address:
|
|
5
|
-
- Beispielstrasse 1
|
|
6
|
-
- 8000 Zürich
|
|
7
|
-
vat_id: CHE-267.359.056 MWST
|
|
8
|
-
email: billing@example.ch
|
|
9
|
-
bank:
|
|
10
|
-
CHF:
|
|
11
|
-
# QR-IBAN (QR-IID variant) — required for a Swiss QR-bill with a QRR reference.
|
|
12
|
-
qr_iban: CH44 3199 9123 0008 8901 2
|
quints-0.2.0/tests/test_docs.py
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
"""Every command shown in the README must actually run.
|
|
2
|
-
|
|
3
|
-
The README tells a newcomer to `cd packages/quints/examples` and run these
|
|
4
|
-
commands. This copies that example to a scratch dir, changes into it, and runs
|
|
5
|
-
each *literal* command through the CLI (no `--config`/`--file` overrides — the
|
|
6
|
-
same resolution a copy-paste gets), asserting a clean exit. If a documented
|
|
7
|
-
command breaks — a renamed flag, a changed default — this fails, so the docs
|
|
8
|
-
can't silently rot. `prices sync` is excluded by design (it needs network);
|
|
9
|
-
`init` is covered by test_init.
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
import shutil
|
|
13
|
-
from pathlib import Path
|
|
14
|
-
|
|
15
|
-
import pytest
|
|
16
|
-
from typer.testing import CliRunner
|
|
17
|
-
|
|
18
|
-
from quints.cli import app
|
|
19
|
-
|
|
20
|
-
runner = CliRunner()
|
|
21
|
-
EXAMPLES = Path(__file__).resolve().parents[1] / "examples"
|
|
22
|
-
|
|
23
|
-
# Exactly the runnable commands in README.md's "by the job" sections.
|
|
24
|
-
README_COMMANDS: list[list[str]] = [
|
|
25
|
-
["check"],
|
|
26
|
-
["mwst", "-q", "2026-Q3"],
|
|
27
|
-
["status"],
|
|
28
|
-
["receivables"],
|
|
29
|
-
["report", "bilanz", "--at", "2026-12-31"],
|
|
30
|
-
["report", "erfolg", "--year", "2026"],
|
|
31
|
-
["report", "statements", "--year", "2026", "--lang", "de"],
|
|
32
|
-
["import", "ubs", "statements/ubs-2026.mt940"],
|
|
33
|
-
["invoice", "invoicing/acme-2026-07.yaml"],
|
|
34
|
-
["fx", "revalue", "--at", "2026-12-31"],
|
|
35
|
-
]
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
@pytest.mark.parametrize("cmd", README_COMMANDS, ids=lambda c: " ".join(c))
|
|
39
|
-
def test_readme_command_runs(
|
|
40
|
-
cmd: list[str], tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
41
|
-
) -> None:
|
|
42
|
-
proj = tmp_path / "example"
|
|
43
|
-
shutil.copytree(EXAMPLES, proj)
|
|
44
|
-
monkeypatch.chdir(proj)
|
|
45
|
-
result = runner.invoke(app, cmd)
|
|
46
|
-
assert result.exit_code == 0, (
|
|
47
|
-
f"`quints {' '.join(cmd)}` exited {result.exit_code}:\n{result.output}"
|
|
48
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|