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.
Files changed (75) hide show
  1. {quints-0.2.0 → quints-0.3.0}/.gitignore +1 -0
  2. {quints-0.2.0 → quints-0.3.0}/PKG-INFO +3 -3
  3. {quints-0.2.0 → quints-0.3.0}/README.md +1 -1
  4. quints-0.3.0/examples/invoicing/acme-2026-07.yaml +15 -0
  5. quints-0.3.0/examples/invoicing/customers.yaml +15 -0
  6. quints-0.3.0/examples/invoicing/globex-2026-08.yaml +13 -0
  7. quints-0.3.0/examples/invoicing/issuer.yaml +16 -0
  8. {quints-0.2.0 → quints-0.3.0}/pyproject.toml +2 -2
  9. {quints-0.2.0 → quints-0.3.0}/src/quints/__init__.py +1 -1
  10. {quints-0.2.0 → quints-0.3.0}/src/quints/cli.py +30 -1
  11. {quints-0.2.0 → quints-0.3.0}/src/quints/init.py +116 -1
  12. {quints-0.2.0 → quints-0.3.0}/src/quints/prices.py +50 -3
  13. quints-0.3.0/tests/test_docs.py +112 -0
  14. {quints-0.2.0 → quints-0.3.0}/tests/test_init.py +32 -0
  15. {quints-0.2.0 → quints-0.3.0}/tests/test_prices_sync.py +21 -1
  16. quints-0.2.0/examples/invoicing/acme-2026-07.yaml +0 -19
  17. quints-0.2.0/examples/invoicing/issuer.yaml +0 -12
  18. quints-0.2.0/tests/test_docs.py +0 -48
  19. {quints-0.2.0 → quints-0.3.0}/LICENSE +0 -0
  20. {quints-0.2.0 → quints-0.3.0}/examples/.gitignore +0 -0
  21. {quints-0.2.0 → quints-0.3.0}/examples/AGENTS.md +0 -0
  22. {quints-0.2.0 → quints-0.3.0}/examples/accounts.bean +0 -0
  23. {quints-0.2.0 → quints-0.3.0}/examples/answers.toml +0 -0
  24. {quints-0.2.0 → quints-0.3.0}/examples/books/2026.bean +0 -0
  25. {quints-0.2.0 → quints-0.3.0}/examples/commodities.bean +0 -0
  26. {quints-0.2.0 → quints-0.3.0}/examples/documents/.gitkeep +0 -0
  27. {quints-0.2.0 → quints-0.3.0}/examples/inbox/.gitkeep +0 -0
  28. {quints-0.2.0 → quints-0.3.0}/examples/main.bean +0 -0
  29. {quints-0.2.0 → quints-0.3.0}/examples/prices.bean +0 -0
  30. {quints-0.2.0 → quints-0.3.0}/examples/pyproject.toml +0 -0
  31. {quints-0.2.0 → quints-0.3.0}/examples/quints.toml +0 -0
  32. {quints-0.2.0 → quints-0.3.0}/examples/staging/.gitkeep +0 -0
  33. {quints-0.2.0 → quints-0.3.0}/examples/statements/ubs-2026.mt940 +0 -0
  34. {quints-0.2.0 → quints-0.3.0}/src/quints/config.py +0 -0
  35. {quints-0.2.0 → quints-0.3.0}/src/quints/fava/__init__.py +0 -0
  36. {quints-0.2.0 → quints-0.3.0}/src/quints/fava/templates/QuintDashboard.html +0 -0
  37. {quints-0.2.0 → quints-0.3.0}/src/quints/fx.py +0 -0
  38. {quints-0.2.0 → quints-0.3.0}/src/quints/importing.py +0 -0
  39. {quints-0.2.0 → quints-0.3.0}/src/quints/inbox.py +0 -0
  40. {quints-0.2.0 → quints-0.3.0}/src/quints/invoice/__init__.py +0 -0
  41. {quints-0.2.0 → quints-0.3.0}/src/quints/invoice/draft.py +0 -0
  42. {quints-0.2.0 → quints-0.3.0}/src/quints/invoice/labels.py +0 -0
  43. {quints-0.2.0 → quints-0.3.0}/src/quints/invoice/model.py +0 -0
  44. {quints-0.2.0 → quints-0.3.0}/src/quints/invoice/qr.py +0 -0
  45. {quints-0.2.0 → quints-0.3.0}/src/quints/invoice/render.py +0 -0
  46. {quints-0.2.0 → quints-0.3.0}/src/quints/invoice/template.typ +0 -0
  47. {quints-0.2.0 → quints-0.3.0}/src/quints/invoice/vatid.py +0 -0
  48. {quints-0.2.0 → quints-0.3.0}/src/quints/invoice/verify.py +0 -0
  49. {quints-0.2.0 → quints-0.3.0}/src/quints/kmu.py +0 -0
  50. {quints-0.2.0 → quints-0.3.0}/src/quints/ledger.py +0 -0
  51. {quints-0.2.0 → quints-0.3.0}/src/quints/match.py +0 -0
  52. {quints-0.2.0 → quints-0.3.0}/src/quints/mwst.py +0 -0
  53. {quints-0.2.0 → quints-0.3.0}/src/quints/plugins/__init__.py +0 -0
  54. {quints-0.2.0 → quints-0.3.0}/src/quints/plugins/kmu.py +0 -0
  55. {quints-0.2.0 → quints-0.3.0}/src/quints/receivables.py +0 -0
  56. {quints-0.2.0 → quints-0.3.0}/src/quints/report.typ +0 -0
  57. {quints-0.2.0 → quints-0.3.0}/src/quints/report_pdf.py +0 -0
  58. {quints-0.2.0 → quints-0.3.0}/src/quints/settlement.py +0 -0
  59. {quints-0.2.0 → quints-0.3.0}/src/quints/ui.py +0 -0
  60. {quints-0.2.0 → quints-0.3.0}/src/quints/vat.py +0 -0
  61. {quints-0.2.0 → quints-0.3.0}/tests/fixtures/transactions.mt940 +0 -0
  62. {quints-0.2.0 → quints-0.3.0}/tests/test_cli_json.py +0 -0
  63. {quints-0.2.0 → quints-0.3.0}/tests/test_config.py +0 -0
  64. {quints-0.2.0 → quints-0.3.0}/tests/test_fava_ext.py +0 -0
  65. {quints-0.2.0 → quints-0.3.0}/tests/test_fx.py +0 -0
  66. {quints-0.2.0 → quints-0.3.0}/tests/test_importing.py +0 -0
  67. {quints-0.2.0 → quints-0.3.0}/tests/test_inbox_match.py +0 -0
  68. {quints-0.2.0 → quints-0.3.0}/tests/test_invoice.py +0 -0
  69. {quints-0.2.0 → quints-0.3.0}/tests/test_kmu_plugin.py +0 -0
  70. {quints-0.2.0 → quints-0.3.0}/tests/test_kmu_report.py +0 -0
  71. {quints-0.2.0 → quints-0.3.0}/tests/test_ledger.py +0 -0
  72. {quints-0.2.0 → quints-0.3.0}/tests/test_mwst.py +0 -0
  73. {quints-0.2.0 → quints-0.3.0}/tests/test_receivables.py +0 -0
  74. {quints-0.2.0 → quints-0.3.0}/tests/test_report_pdf.py +0 -0
  75. {quints-0.2.0 → quints-0.3.0}/tests/test_settlement.py +0 -0
@@ -6,6 +6,7 @@ build/
6
6
  *.egg-info/
7
7
  .pytest_cache/
8
8
  .ruff_cache/
9
+ site/
9
10
  .env
10
11
  .DS_Store
11
12
  .claude/settings.local.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quints
3
- Version: 0.2.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.1.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/AG) needs on top of beancount + Fava:
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/AG) needs on top of beancount + Fava:
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.2.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.1.0",
18
+ "beanprice-bazg>=0.2.0",
19
19
  "fava>=1.30",
20
20
  "pydantic>=2.7",
21
21
  "python-stdnum>=1.20",
@@ -1,3 +1,3 @@
1
1
  """quints — Swiss VAT & accounting for plain-text (beancount) books."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.3.0"
@@ -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
- result = prices_mod.sync(out, repair_from=repair)
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
- for sp in src.get_prices_series(ccy, _utc(start), _utc(today)):
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
@@ -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