quints 0.5.0__tar.gz → 0.7.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.5.0 → quints-0.7.0}/PKG-INFO +7 -5
- {quints-0.5.0 → quints-0.7.0}/README.md +4 -3
- quints-0.7.0/examples/statements/yapeal.csv +4 -0
- {quints-0.5.0 → quints-0.7.0}/pyproject.toml +3 -2
- {quints-0.5.0 → quints-0.7.0}/src/quints/cli.py +22 -1
- {quints-0.5.0 → quints-0.7.0}/src/quints/config.py +19 -0
- {quints-0.5.0 → quints-0.7.0}/src/quints/importing.py +46 -2
- {quints-0.5.0 → quints-0.7.0}/src/quints/init.py +25 -2
- quints-0.7.0/src/quints/invoice/fonts/LICENSE +102 -0
- quints-0.7.0/src/quints/invoice/fonts/LiberationSans-Bold.ttf +0 -0
- quints-0.7.0/src/quints/invoice/fonts/LiberationSans-Regular.ttf +0 -0
- quints-0.7.0/src/quints/invoice/fonts/README.md +15 -0
- {quints-0.5.0 → quints-0.7.0}/src/quints/invoice/model.py +37 -3
- {quints-0.5.0 → quints-0.7.0}/src/quints/invoice/qr.py +7 -1
- {quints-0.5.0 → quints-0.7.0}/src/quints/invoice/render.py +43 -11
- quints-0.7.0/src/quints/invoice/template.typ +184 -0
- {quints-0.5.0 → quints-0.7.0}/tests/test_importing.py +49 -0
- {quints-0.5.0 → quints-0.7.0}/tests/test_init.py +18 -1
- {quints-0.5.0 → quints-0.7.0}/tests/test_invoice.py +116 -0
- quints-0.5.0/src/quints/invoice/template.typ +0 -140
- {quints-0.5.0 → quints-0.7.0}/.gitignore +0 -0
- {quints-0.5.0 → quints-0.7.0}/LICENSE +0 -0
- {quints-0.5.0 → quints-0.7.0}/examples/.gitignore +0 -0
- {quints-0.5.0 → quints-0.7.0}/examples/AGENTS.md +0 -0
- {quints-0.5.0 → quints-0.7.0}/examples/CLAUDE.md +0 -0
- {quints-0.5.0 → quints-0.7.0}/examples/accounts.bean +0 -0
- {quints-0.5.0 → quints-0.7.0}/examples/answers.toml +0 -0
- {quints-0.5.0 → quints-0.7.0}/examples/books/2026.bean +0 -0
- {quints-0.5.0 → quints-0.7.0}/examples/commodities.bean +0 -0
- {quints-0.5.0 → quints-0.7.0}/examples/documents/.gitkeep +0 -0
- {quints-0.5.0 → quints-0.7.0}/examples/inbox/.gitkeep +0 -0
- {quints-0.5.0 → quints-0.7.0}/examples/invoicing/acme-2026-07.yaml +0 -0
- {quints-0.5.0 → quints-0.7.0}/examples/invoicing/customers.yaml +0 -0
- {quints-0.5.0 → quints-0.7.0}/examples/invoicing/globex-2026-08.yaml +0 -0
- {quints-0.5.0 → quints-0.7.0}/examples/invoicing/issuer.yaml +0 -0
- {quints-0.5.0 → quints-0.7.0}/examples/main.bean +0 -0
- {quints-0.5.0 → quints-0.7.0}/examples/prices.bean +0 -0
- {quints-0.5.0 → quints-0.7.0}/examples/pyproject.toml +0 -0
- {quints-0.5.0 → quints-0.7.0}/examples/quints.toml +0 -0
- {quints-0.5.0 → quints-0.7.0}/examples/staging/.gitkeep +0 -0
- {quints-0.5.0 → quints-0.7.0}/examples/statements/ubs-2026.mt940 +0 -0
- {quints-0.5.0 → quints-0.7.0}/src/quints/__init__.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/src/quints/fava/__init__.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/src/quints/fava/templates/QuintDashboard.html +0 -0
- {quints-0.5.0 → quints-0.7.0}/src/quints/fx.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/src/quints/inbox.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/src/quints/invoice/__init__.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/src/quints/invoice/draft.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/src/quints/invoice/labels.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/src/quints/invoice/vatid.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/src/quints/invoice/verify.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/src/quints/kmu.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/src/quints/ledger.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/src/quints/match.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/src/quints/mwst.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/src/quints/plugins/__init__.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/src/quints/plugins/kmu.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/src/quints/prices.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/src/quints/receivables.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/src/quints/report.typ +0 -0
- {quints-0.5.0 → quints-0.7.0}/src/quints/report_pdf.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/src/quints/settlement.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/src/quints/ui.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/src/quints/vat.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/tests/fixtures/transactions.mt940 +0 -0
- {quints-0.5.0 → quints-0.7.0}/tests/test_cli_json.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/tests/test_config.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/tests/test_docs.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/tests/test_fava_ext.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/tests/test_fx.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/tests/test_inbox_match.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/tests/test_kmu_plugin.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/tests/test_kmu_report.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/tests/test_ledger.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/tests/test_mwst.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/tests/test_prices_ecosystem.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/tests/test_prices_sync.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/tests/test_receivables.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/tests/test_report_pdf.py +0 -0
- {quints-0.5.0 → quints-0.7.0}/tests/test_settlement.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: quints
|
|
3
|
-
Version: 0.
|
|
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
|
|
3
|
+
Version: 0.7.0
|
|
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, Yapeal, Wise, Stripe) that draft into a staging area
|
|
5
5
|
License: GPL-2.0-only
|
|
6
6
|
License-File: LICENSE
|
|
7
7
|
Keywords: accounting,beancount,fava,invoice,kmu,mwst,plain-text-accounting,qr-bill,switzerland,vat
|
|
@@ -11,6 +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: beangulp-yapeal>=0.1.0
|
|
14
15
|
Requires-Dist: beanprice-bazg>=0.2.0
|
|
15
16
|
Requires-Dist: fava>=1.30
|
|
16
17
|
Requires-Dist: pydantic>=2.7
|
|
@@ -39,9 +40,10 @@ Everything a Swiss micro-company (GmbH, AG, or Einzelfirma) needs on top of bean
|
|
|
39
40
|
(Art. 960 OR).
|
|
40
41
|
- **QR-bill invoicing**: Swiss QR-bill (QRR) and SEPA (SCOR/RF) invoices as
|
|
41
42
|
PDF/A via Typst, drafted straight into the ledger.
|
|
42
|
-
- **Statement importers**: UBS (MT940), Wise, and Stripe statements
|
|
43
|
-
a `staging/` area — idempotent re-imports, conversion merging,
|
|
44
|
-
open-invoice matching by QR reference — never directly into
|
|
43
|
+
- **Statement importers**: UBS (MT940), Yapeal, Wise, and Stripe statements
|
|
44
|
+
draft into a `staging/` area — idempotent re-imports, conversion merging,
|
|
45
|
+
fee splitting, open-invoice matching by QR reference — never directly into
|
|
46
|
+
your books.
|
|
45
47
|
- **KMU statements**: statutory Bilanz/Erfolgsrechnung (OR Art. 959a/959b) by
|
|
46
48
|
KMU Kontenrahmen code, English or German, terminal or PDF.
|
|
47
49
|
- **Fava extension**: review panel inside the UI you already run.
|
|
@@ -14,9 +14,10 @@ Everything a Swiss micro-company (GmbH, AG, or Einzelfirma) needs on top of bean
|
|
|
14
14
|
(Art. 960 OR).
|
|
15
15
|
- **QR-bill invoicing**: Swiss QR-bill (QRR) and SEPA (SCOR/RF) invoices as
|
|
16
16
|
PDF/A via Typst, drafted straight into the ledger.
|
|
17
|
-
- **Statement importers**: UBS (MT940), Wise, and Stripe statements
|
|
18
|
-
a `staging/` area — idempotent re-imports, conversion merging,
|
|
19
|
-
open-invoice matching by QR reference — never directly into
|
|
17
|
+
- **Statement importers**: UBS (MT940), Yapeal, Wise, and Stripe statements
|
|
18
|
+
draft into a `staging/` area — idempotent re-imports, conversion merging,
|
|
19
|
+
fee splitting, open-invoice matching by QR reference — never directly into
|
|
20
|
+
your books.
|
|
20
21
|
- **KMU statements**: statutory Bilanz/Erfolgsrechnung (OR Art. 959a/959b) by
|
|
21
22
|
KMU Kontenrahmen code, English or German, terminal or PDF.
|
|
22
23
|
- **Fava extension**: review panel inside the UI you already run.
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
Transaktions-ID,Belastung oder Gutschrift,Buchungs-Datum,Transaktions-Datum,Transaktions-Info,Betrag,Belastung (CHF),Gutschrift (CHF),Kontowährung,Saldo (CHF),Gegenpartei,IBAN Gegenpartei,Original Transaktionsbetrag,Transaktionswährung,Devisenkurs,Gebühr,Zahlungstyp,Letzte 4 der Kartennummer,Karteninhaber,Status der Spesen,Zahlungsreferenz,Eigene Notiz,Zahlungs-Notiz,Zahlung erfasst am,Zahlung genehmigt von,Zahlung genehmigt am,Kategorie,Karten Programm,usedTags,mentions,ultimateCreditorName,ultimateDebitorName
|
|
2
|
+
e9839ff4-e419-432a-b553-801e13c7e8f1,DEBIT,05.01.2026,05.01.2026,Debit card payment,25.50,25.50,,CHF,-25.50,ACME Hosting Inc,CH9300762011623852957,25.50,CHF,,,,,,,,Subject Test,,,,general,,,,,,,
|
|
3
|
+
f68eb96a-1776-4c4d-b099-12ef65be3b63,CREDIT,10.01.2026,10.01.2026,Invoice payment,1000.00,,1000.00,CHF,1000.00,Client AG,CH9300762011623852957,1000.00,CHF,,,,,,,,Subject Test,,,,general,,,,,,,
|
|
4
|
+
a1b2c3d4-5678-90ab-cdef-1234567890ab,DEBIT,15.01.2026,15.01.2026,Monthly subscription,45.00,45.00,,CHF,-45.00,Swisscom,CH9300762011623852957,45.00,CHF,,,,,,,,Subject Test,,,,general,,,,,,,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "quints"
|
|
3
|
-
version = "0.
|
|
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"
|
|
3
|
+
version = "0.7.0"
|
|
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, Yapeal, Wise, Stripe) that draft into a staging area"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
7
7
|
license = { text = "GPL-2.0-only" }
|
|
@@ -15,6 +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
|
+
"beangulp-yapeal>=0.1.0",
|
|
18
19
|
"beanprice-bazg>=0.2.0",
|
|
19
20
|
"fava>=1.30",
|
|
20
21
|
"pydantic>=2.7",
|
|
@@ -552,6 +552,27 @@ def fx_revalue(
|
|
|
552
552
|
fx_mod.render(revaluations, at)
|
|
553
553
|
|
|
554
554
|
|
|
555
|
+
@import_app.command("yapeal")
|
|
556
|
+
def import_yapeal(
|
|
557
|
+
statement: Path = typer.Argument(..., help="Yapeal CSV statement file."),
|
|
558
|
+
out: Path = typer.Option(importing_mod.DEFAULT_STAGING, "--out", help="Staging directory."),
|
|
559
|
+
as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
560
|
+
file: Path = _file_option(),
|
|
561
|
+
):
|
|
562
|
+
"""Draft the Yapeal bank account's activity from a CSV statement."""
|
|
563
|
+
_require_ledger(file)
|
|
564
|
+
if not statement.exists():
|
|
565
|
+
typer.secho(f"ERROR: statement not found: {statement}", fg="red", err=True)
|
|
566
|
+
raise typer.Exit(1)
|
|
567
|
+
try:
|
|
568
|
+
result = importing_mod.run_yapeal(statement, file, out)
|
|
569
|
+
except ValueError as e:
|
|
570
|
+
typer.secho(f"ERROR: {e}", fg="red", err=True)
|
|
571
|
+
raise typer.Exit(1) from None
|
|
572
|
+
|
|
573
|
+
_report_import(result, as_json)
|
|
574
|
+
|
|
575
|
+
|
|
555
576
|
@import_app.command("ubs")
|
|
556
577
|
def import_ubs(
|
|
557
578
|
statement: Path = typer.Argument(..., help="UBS MT940 statement file."),
|
|
@@ -927,7 +948,7 @@ def init(
|
|
|
927
948
|
),
|
|
928
949
|
lang: str | None = typer.Option(None, "--lang", "-l", help="Report language: en or de."),
|
|
929
950
|
importers: str | None = typer.Option(
|
|
930
|
-
None, "--importers", help="Comma-separated: ubs, wise, stripe (default: none)."
|
|
951
|
+
None, "--importers", help="Comma-separated: ubs, yapeal, wise, stripe (default: none)."
|
|
931
952
|
),
|
|
932
953
|
samples: bool = typer.Option(
|
|
933
954
|
False, "--samples", help="Include a demo quarter of transactions."
|
|
@@ -87,6 +87,16 @@ class StripeImport:
|
|
|
87
87
|
return dict(self.accounts)
|
|
88
88
|
|
|
89
89
|
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class YapealImport:
|
|
92
|
+
"""[import.yapeal] — Yapeal CSV statements for one CHF bank account."""
|
|
93
|
+
|
|
94
|
+
account: str = "Assets:CH:GmbH:Current:Bank:CHF"
|
|
95
|
+
iban: str | None = None # optional — matched against file content
|
|
96
|
+
currency: str = "CHF"
|
|
97
|
+
rules: tuple[tuple[str, str, str], ...] = ()
|
|
98
|
+
|
|
99
|
+
|
|
90
100
|
@dataclass(frozen=True)
|
|
91
101
|
class Config:
|
|
92
102
|
# [entity]
|
|
@@ -120,6 +130,7 @@ class Config:
|
|
|
120
130
|
report_language: str = "en"
|
|
121
131
|
# [import.*] — statement importers (plan 2); None = not configured
|
|
122
132
|
import_ubs: UbsImport | None = None
|
|
133
|
+
import_yapeal: YapealImport | None = None
|
|
123
134
|
import_wise: WiseImport | None = None
|
|
124
135
|
import_stripe: StripeImport | None = None
|
|
125
136
|
|
|
@@ -153,6 +164,14 @@ def _import_sections(raw: dict[str, object]) -> dict[str, object]:
|
|
|
153
164
|
currency=ubs.get("currency", UbsImport.currency),
|
|
154
165
|
rules=_rules(ubs),
|
|
155
166
|
)
|
|
167
|
+
yapeal = imports.get("yapeal")
|
|
168
|
+
if isinstance(yapeal, dict):
|
|
169
|
+
updates["import_yapeal"] = YapealImport(
|
|
170
|
+
account=yapeal.get("account", YapealImport.account),
|
|
171
|
+
iban=yapeal.get("iban"),
|
|
172
|
+
currency=yapeal.get("currency", YapealImport.currency),
|
|
173
|
+
rules=_rules(yapeal),
|
|
174
|
+
)
|
|
156
175
|
wise = imports.get("wise")
|
|
157
176
|
if isinstance(wise, dict):
|
|
158
177
|
updates["import_wise"] = WiseImport(
|
|
@@ -40,6 +40,7 @@ from beangulp_stripe import Importer as StripeImporter
|
|
|
40
40
|
from beangulp_stripe import StripeClient, StripeError
|
|
41
41
|
from beangulp_wise import Importer as WiseImporter
|
|
42
42
|
from beangulp_wise import ScaChallenge, WiseClient, merge_conversions
|
|
43
|
+
from beangulp_yapeal import Importer as YapealImporter
|
|
43
44
|
|
|
44
45
|
from . import config, ledger
|
|
45
46
|
from . import receivables as recv_mod
|
|
@@ -48,7 +49,9 @@ LEGACY_WINDOW_DAYS = 3
|
|
|
48
49
|
DEFAULT_STAGING = Path("staging")
|
|
49
50
|
|
|
50
51
|
|
|
51
|
-
_Section = TypeVar(
|
|
52
|
+
_Section = TypeVar(
|
|
53
|
+
"_Section", config.UbsImport, config.YapealImport, config.WiseImport, config.StripeImport
|
|
54
|
+
)
|
|
52
55
|
|
|
53
56
|
|
|
54
57
|
def _require(section: _Section | None, name: str, *keys: str) -> _Section:
|
|
@@ -76,6 +79,17 @@ def ubs_importer(cfg: config.Config | None = None) -> Mt940Importer:
|
|
|
76
79
|
)
|
|
77
80
|
|
|
78
81
|
|
|
82
|
+
def yapeal_importer(cfg: config.Config | None = None) -> YapealImporter:
|
|
83
|
+
s = _require((cfg or config.get()).import_yapeal, "yapeal")
|
|
84
|
+
return YapealImporter(
|
|
85
|
+
s.account,
|
|
86
|
+
iban=s.iban,
|
|
87
|
+
currency=s.currency,
|
|
88
|
+
meta_key="bank_ref",
|
|
89
|
+
payee_rules=s.rules,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
79
93
|
def wise_importer(cfg: config.Config | None = None) -> WiseImporter:
|
|
80
94
|
s = _require((cfg or config.get()).import_wise, "wise")
|
|
81
95
|
return WiseImporter(
|
|
@@ -195,8 +209,14 @@ def match_receivables(
|
|
|
195
209
|
if number is None:
|
|
196
210
|
continue
|
|
197
211
|
postings = list(draft.postings)
|
|
198
|
-
if len(postings) ==
|
|
212
|
+
if len(postings) == 1:
|
|
213
|
+
# Only the cash leg is known — add the elided receivable clearing
|
|
214
|
+
# posting so the matched draft still balances.
|
|
215
|
+
postings.append(data.Posting(cfg.receivable, None, None, None, None, None))
|
|
216
|
+
elif len(postings) == 2:
|
|
199
217
|
postings[1] = postings[1]._replace(account=cfg.receivable)
|
|
218
|
+
else:
|
|
219
|
+
continue # ambiguous shape — leave the draft unmatched, still flagged for review
|
|
200
220
|
meta = dict(draft.meta or {})
|
|
201
221
|
meta["invoice"] = number
|
|
202
222
|
matched = draft._replace(
|
|
@@ -224,6 +244,30 @@ def _write_staging(result: ImportResult, out_dir: Path, source: str) -> None:
|
|
|
224
244
|
result.out_path = out
|
|
225
245
|
|
|
226
246
|
|
|
247
|
+
def run_yapeal(
|
|
248
|
+
statement: Path,
|
|
249
|
+
ledger_path: Path,
|
|
250
|
+
out_dir: Path = DEFAULT_STAGING,
|
|
251
|
+
cfg: config.Config | None = None,
|
|
252
|
+
) -> ImportResult:
|
|
253
|
+
cfg = cfg or config.get()
|
|
254
|
+
importer = yapeal_importer(cfg)
|
|
255
|
+
yapeal = _require(cfg.import_yapeal, "yapeal")
|
|
256
|
+
if not importer.identify(str(statement)):
|
|
257
|
+
raise ValueError(f"{statement} is not a Yapeal CSV statement for {yapeal.account}")
|
|
258
|
+
|
|
259
|
+
existing, _ = ledger.load_entries(ledger_path)
|
|
260
|
+
total = len(importer.extract(str(statement), existing=[]))
|
|
261
|
+
extracted = importer.extract(str(statement), existing=existing)
|
|
262
|
+
|
|
263
|
+
result = ImportResult(source=str(statement))
|
|
264
|
+
result.skipped_ref = total - len(extracted)
|
|
265
|
+
_split(result, extracted, _cash_pool(existing, {yapeal.account}))
|
|
266
|
+
match_receivables(result, existing, cfg)
|
|
267
|
+
_write_staging(result, out_dir, "yapeal")
|
|
268
|
+
return result
|
|
269
|
+
|
|
270
|
+
|
|
227
271
|
def run_ubs(
|
|
228
272
|
statement: Path,
|
|
229
273
|
ledger_path: Path,
|
|
@@ -55,7 +55,7 @@ _IT_HOSTING = "Expenses:CH:GmbH:IT:Hosting"
|
|
|
55
55
|
_PRIMARY_BANK = "Assets:CH:GmbH:Current:UBS:CHF"
|
|
56
56
|
_WISE_EUR = "Assets:CH:GmbH:Current:Wise:EUR"
|
|
57
57
|
|
|
58
|
-
_KNOWN_IMPORTERS = ("ubs", "wise", "stripe")
|
|
58
|
+
_KNOWN_IMPORTERS = ("ubs", "yapeal", "wise", "stripe")
|
|
59
59
|
|
|
60
60
|
|
|
61
61
|
@dataclass(frozen=True)
|
|
@@ -207,6 +207,9 @@ def _backbone(answers: Answers) -> list[_Account]:
|
|
|
207
207
|
_Account(cfg.rounding_income, "6950", (oc,)),
|
|
208
208
|
_Account(cfg.fx_gain, "6950", (oc,)),
|
|
209
209
|
]
|
|
210
|
+
if "yapeal" in answers.importers:
|
|
211
|
+
d = config.YapealImport()
|
|
212
|
+
accounts.append(_Account(_sub(d.account, c), "1020", (d.currency,)))
|
|
210
213
|
if "wise" in answers.importers:
|
|
211
214
|
for ccy, acct in config.WiseImport().accounts:
|
|
212
215
|
accounts.append(_Account(_sub(acct, c), "1020", (ccy,)))
|
|
@@ -617,6 +620,20 @@ def _import_section(importer: str, answers: Answers) -> str:
|
|
|
617
620
|
"]",
|
|
618
621
|
]
|
|
619
622
|
)
|
|
623
|
+
if importer == "yapeal":
|
|
624
|
+
d = config.YapealImport()
|
|
625
|
+
return "\n".join(
|
|
626
|
+
[
|
|
627
|
+
"# Yapeal CSV statements → staging/. '*' = booked as drafted, '!' = still",
|
|
628
|
+
"# needs a VAT decision + linked document before it reaches your books.",
|
|
629
|
+
"[import.yapeal]",
|
|
630
|
+
f'account = "{_sub(d.account, c)}"',
|
|
631
|
+
'iban = "CH9300762011623852957" # optional — matched against file content',
|
|
632
|
+
"rules = [",
|
|
633
|
+
f' [\'\\bacme\\b\', "{_sub("Assets:CH:GmbH:Receivable:Trade", c)}", "*"],',
|
|
634
|
+
"]",
|
|
635
|
+
]
|
|
636
|
+
)
|
|
620
637
|
if importer == "wise":
|
|
621
638
|
d = config.WiseImport()
|
|
622
639
|
rows = "\n".join(f'{ccy} = "{_sub(acct, c)}"' for ccy, acct in d.accounts)
|
|
@@ -665,6 +682,10 @@ _IMPORTER_USAGE = {
|
|
|
665
682
|
"`quints import ubs <statement.mt940>` — the MT940 export from UBS "
|
|
666
683
|
"e-banking; no credentials."
|
|
667
684
|
),
|
|
685
|
+
"yapeal": (
|
|
686
|
+
"`quints import yapeal <statement.csv>` — the CSV export from Yapeal "
|
|
687
|
+
"e-banking; no credentials."
|
|
688
|
+
),
|
|
668
689
|
"wise": (
|
|
669
690
|
"`quints import wise --fetch --from <date> --to <date>` — needs "
|
|
670
691
|
"`QUINTS_WISE_API_TOKEN` in `.env` (plus `QUINTS_WISE_PRIVATE_KEY` for "
|
|
@@ -683,7 +704,7 @@ def _agents_import_step(answers: Answers) -> str:
|
|
|
683
704
|
return (
|
|
684
705
|
"1. Draft bank/PSP activity into `staging/` with `quints import`. No\n"
|
|
685
706
|
" importer is configured yet — add an `[import.<name>]` section to\n"
|
|
686
|
-
" `quints.toml` (supported: ubs, wise, stripe)."
|
|
707
|
+
" `quints.toml` (supported: ubs, yapeal, wise, stripe)."
|
|
687
708
|
)
|
|
688
709
|
lines = ["1. Draft bank/PSP activity into `staging/`. Configured importers:"]
|
|
689
710
|
lines += [f" - {_IMPORTER_USAGE[name]}" for name in answers.importers]
|
|
@@ -711,6 +732,8 @@ def _agents_sample_section(answers: Answers) -> str:
|
|
|
711
732
|
]
|
|
712
733
|
if "ubs" in answers.importers:
|
|
713
734
|
lines.append("- [ ] `quints.toml` — the placeholder IBAN under `[import.ubs]`.")
|
|
735
|
+
if "yapeal" in answers.importers:
|
|
736
|
+
lines.append("- [ ] `quints.toml` — the placeholder IBAN under `[import.yapeal]`.")
|
|
714
737
|
return "\n".join(lines) + "\n"
|
|
715
738
|
|
|
716
739
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Digitized data copyright (c) 2010 Google Corporation
|
|
2
|
+
with Reserved Font Arimo, Tinos and Cousine.
|
|
3
|
+
Copyright (c) 2012 Red Hat, Inc.
|
|
4
|
+
with Reserved Font Name Liberation.
|
|
5
|
+
|
|
6
|
+
This Font Software is licensed under the SIL Open Font License,
|
|
7
|
+
Version 1.1.
|
|
8
|
+
|
|
9
|
+
This license is copied below, and is also available with a FAQ at:
|
|
10
|
+
http://scripts.sil.org/OFL
|
|
11
|
+
|
|
12
|
+
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
13
|
+
|
|
14
|
+
PREAMBLE The goals of the Open Font License (OFL) are to stimulate
|
|
15
|
+
worldwide development of collaborative font projects, to support the font
|
|
16
|
+
creation efforts of academic and linguistic communities, and to provide
|
|
17
|
+
a free and open framework in which fonts may be shared and improved in
|
|
18
|
+
partnership with others.
|
|
19
|
+
|
|
20
|
+
The OFL allows the licensed fonts to be used, studied, modified and
|
|
21
|
+
redistributed freely as long as they are not sold by themselves.
|
|
22
|
+
The fonts, including any derivative works, can be bundled, embedded,
|
|
23
|
+
redistributed and/or sold with any software provided that any reserved
|
|
24
|
+
names are not used by derivative works. The fonts and derivatives,
|
|
25
|
+
however, cannot be released under any other type of license. The
|
|
26
|
+
requirement for fonts to remain under this license does not apply to
|
|
27
|
+
any document created using the fonts or their derivatives.
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
DEFINITIONS
|
|
32
|
+
"Font Software" refers to the set of files released by the Copyright
|
|
33
|
+
Holder(s) under this license and clearly marked as such.
|
|
34
|
+
This may include source files, build scripts and documentation.
|
|
35
|
+
|
|
36
|
+
"Reserved Font Name" refers to any names specified as such after the
|
|
37
|
+
copyright statement(s).
|
|
38
|
+
|
|
39
|
+
"Original Version" refers to the collection of Font Software components
|
|
40
|
+
as distributed by the Copyright Holder(s).
|
|
41
|
+
|
|
42
|
+
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
43
|
+
or substituting ? in part or in whole ?
|
|
44
|
+
any of the components of the Original Version, by changing formats or
|
|
45
|
+
by porting the Font Software to a new environment.
|
|
46
|
+
|
|
47
|
+
"Author" refers to any designer, engineer, programmer, technical writer
|
|
48
|
+
or other person who contributed to the Font Software.
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
PERMISSION & CONDITIONS
|
|
52
|
+
|
|
53
|
+
Permission is hereby granted, free of charge, to any person obtaining a
|
|
54
|
+
copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
55
|
+
redistribute, and sell modified and unmodified copies of the Font
|
|
56
|
+
Software, subject to the following conditions:
|
|
57
|
+
|
|
58
|
+
1) Neither the Font Software nor any of its individual components,in
|
|
59
|
+
Original or Modified Versions, may be sold by itself.
|
|
60
|
+
|
|
61
|
+
2) Original or Modified Versions of the Font Software may be bundled,
|
|
62
|
+
redistributed and/or sold with any software, provided that each copy
|
|
63
|
+
contains the above copyright notice and this license. These can be
|
|
64
|
+
included either as stand-alone text files, human-readable headers or
|
|
65
|
+
in the appropriate machine-readable metadata fields within text or
|
|
66
|
+
binary files as long as those fields can be easily viewed by the user.
|
|
67
|
+
|
|
68
|
+
3) No Modified Version of the Font Software may use the Reserved Font
|
|
69
|
+
Name(s) unless explicit written permission is granted by the
|
|
70
|
+
corresponding Copyright Holder. This restriction only applies to the
|
|
71
|
+
primary font name as presented to the users.
|
|
72
|
+
|
|
73
|
+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
74
|
+
Software shall not be used to promote, endorse or advertise any
|
|
75
|
+
Modified Version, except to acknowledge the contribution(s) of the
|
|
76
|
+
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
77
|
+
permission.
|
|
78
|
+
|
|
79
|
+
5) The Font Software, modified or unmodified, in part or in whole, must
|
|
80
|
+
be distributed entirely under this license, and must not be distributed
|
|
81
|
+
under any other license. The requirement for fonts to remain under
|
|
82
|
+
this license does not apply to any document created using the Font
|
|
83
|
+
Software.
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
TERMINATION
|
|
88
|
+
This license becomes null and void if any of the above conditions are not met.
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
DISCLAIMER
|
|
93
|
+
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
94
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
95
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
96
|
+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
97
|
+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
98
|
+
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
99
|
+
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
100
|
+
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
|
101
|
+
DEALINGS IN THE FONT SOFTWARE.
|
|
102
|
+
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Bundled fallback fonts
|
|
2
|
+
|
|
3
|
+
Liberation Sans, always added to the Typst font search path.
|
|
4
|
+
|
|
5
|
+
It carries two guarantees the invoice template would otherwise only get by
|
|
6
|
+
luck, from whatever the rendering machine happens to have installed:
|
|
7
|
+
|
|
8
|
+
- `Brand.font` defaults to Liberation Sans, and the template names it as the
|
|
9
|
+
fallback behind every issuer-configured family.
|
|
10
|
+
- The Swiss QR-bill Implementation Guidelines permit only Arial, Frutiger,
|
|
11
|
+
Helvetica and Liberation Sans in the payment part. Liberation Sans is the
|
|
12
|
+
one of the four that can be licensed and shipped, and it is metric-
|
|
13
|
+
compatible with Arial, which the `qrbill` library sizes its text boxes for.
|
|
14
|
+
|
|
15
|
+
Liberation Fonts 2.1.5, SIL Open Font License 1.1 — see `LICENSE`.
|
|
@@ -14,10 +14,17 @@ import sys
|
|
|
14
14
|
from datetime import date
|
|
15
15
|
from decimal import ROUND_HALF_UP, Decimal
|
|
16
16
|
from pathlib import Path
|
|
17
|
-
from typing import Any, Literal
|
|
17
|
+
from typing import Annotated, Any, Literal
|
|
18
18
|
|
|
19
19
|
import yaml
|
|
20
|
-
from pydantic import
|
|
20
|
+
from pydantic import (
|
|
21
|
+
BaseModel,
|
|
22
|
+
Field,
|
|
23
|
+
RootModel,
|
|
24
|
+
StringConstraints,
|
|
25
|
+
field_validator,
|
|
26
|
+
model_validator,
|
|
27
|
+
)
|
|
21
28
|
|
|
22
29
|
if sys.version_info >= (3, 11):
|
|
23
30
|
import tomllib
|
|
@@ -249,13 +256,40 @@ class BankAccount(BaseModel):
|
|
|
249
256
|
return v.replace(" ", "")
|
|
250
257
|
|
|
251
258
|
|
|
259
|
+
# Every hex form Typst's `rgb()` takes: RGB, RGBA, RRGGBB, RRGGBBAA. `accent`
|
|
260
|
+
# used to be a bare `str` handed straight to `rgb()`, so anything shorthand that
|
|
261
|
+
# rendered before still loads; only strings Typst would have rejected anyway are
|
|
262
|
+
# caught here, and now at config-load time instead of mid-render.
|
|
263
|
+
HexColor = Annotated[
|
|
264
|
+
str, StringConstraints(pattern=r"^#([0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$")
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
|
|
252
268
|
class Brand(BaseModel):
|
|
253
|
-
|
|
269
|
+
"""Typography and colour tokens the template renders with.
|
|
270
|
+
|
|
271
|
+
The colour defaults reproduce the neutral greyscale the template used
|
|
272
|
+
before tokens existed, so an issuer config that sets none of them keeps
|
|
273
|
+
its previous look. An issuer with a palette overrides them all.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
accent: HexColor = "#123c3a"
|
|
254
277
|
font: str = "Liberation Sans"
|
|
255
278
|
font_display: str | None = None # title/wordmark family; defaults to `font`
|
|
256
279
|
font_display_stretch: int = 100 # CSS-style font-stretch % (125 → Expanded cut)
|
|
280
|
+
# Title weight. Not every display family ships every cut — Mona Sans
|
|
281
|
+
# Expanded starts at Medium, so asking for "regular" silently drops back to
|
|
282
|
+
# the normal-width face; a serif title usually wants "regular".
|
|
283
|
+
font_display_weight: Literal["regular", "medium", "semibold", "bold"] = "semibold"
|
|
284
|
+
font_mono: str | None = None # figures, IBAN, reference; defaults to `font`
|
|
257
285
|
font_dir: str | None = None # bundled fonts dir passed to typst (repo-relative)
|
|
258
286
|
logo: str | None = None
|
|
287
|
+
logo_height: float = 12.0 # mm, as placed in the header
|
|
288
|
+
|
|
289
|
+
ink: HexColor = "#141414" # body copy
|
|
290
|
+
subtle: HexColor = "#696969" # labels and secondary text
|
|
291
|
+
rule: HexColor = "#d2d2d2" # hairlines
|
|
292
|
+
panel: HexColor = "#f6f6f6" # fill behind bounded blocks
|
|
259
293
|
|
|
260
294
|
|
|
261
295
|
class Issuer(BaseModel):
|
|
@@ -52,7 +52,7 @@ def build_bill(inv: Invoice, issuer: Issuer, account: BankAccount, grand: Decima
|
|
|
52
52
|
raise ValueError(f"Swiss QR-bill supports only CHF or EUR, not {currency!r}")
|
|
53
53
|
with warnings.catch_warnings():
|
|
54
54
|
warnings.simplefilter("ignore")
|
|
55
|
-
|
|
55
|
+
bill = QRBill(
|
|
56
56
|
account=account.qr_iban or account.iban,
|
|
57
57
|
creditor=_structured(issuer.name, issuer.address, issuer.country),
|
|
58
58
|
debtor=_structured(customer.name, customer.address, customer.country),
|
|
@@ -61,6 +61,12 @@ def build_bill(inv: Invoice, issuer: Issuer, account: BankAccount, grand: Decima
|
|
|
61
61
|
reference_number=reference_for(inv, account),
|
|
62
62
|
additional_information=f"{inv.number}",
|
|
63
63
|
)
|
|
64
|
+
# The Implementation Guidelines allow Arial, Frutiger, Helvetica and
|
|
65
|
+
# Liberation Sans in the payment part, and nothing else. qrbill asks for
|
|
66
|
+
# Arial/Helvetica only, which are system faces — an issuer that bundles
|
|
67
|
+
# fonts has system faces switched off, so name the OFL one it can ship.
|
|
68
|
+
bill.font_family = "Arial,Helvetica,Liberation Sans"
|
|
69
|
+
return bill
|
|
64
70
|
|
|
65
71
|
|
|
66
72
|
def write_svg(bill: QRBill, path: Path) -> None:
|
|
@@ -18,6 +18,10 @@ from .labels import labels as get_labels
|
|
|
18
18
|
from .model import Invoice, Issuer, Totals, compute, make_scor, money, number
|
|
19
19
|
|
|
20
20
|
TEMPLATE = Path(__file__).parent / "template.typ"
|
|
21
|
+
# Liberation Sans, always on the search path: the default `Brand.font`, the
|
|
22
|
+
# template's last-resort fallback, and the only face the QR-bill payment part
|
|
23
|
+
# is allowed to use that we can ship. See fonts/README.md.
|
|
24
|
+
BUNDLED_FONTS = Path(__file__).parent / "fonts"
|
|
21
25
|
|
|
22
26
|
|
|
23
27
|
class InvoiceContext(TypedDict):
|
|
@@ -36,7 +40,7 @@ class InvoiceContext(TypedDict):
|
|
|
36
40
|
totals: dict[str, str | bool]
|
|
37
41
|
payment: Mapping[str, str | None]
|
|
38
42
|
notes: list[str]
|
|
39
|
-
brand: dict[str, str |
|
|
43
|
+
brand: dict[str, str | float | None]
|
|
40
44
|
|
|
41
45
|
|
|
42
46
|
def _fmt_iban(iban: str) -> str:
|
|
@@ -113,20 +117,45 @@ def build_context(
|
|
|
113
117
|
"font": issuer.brand.font,
|
|
114
118
|
"font_display": issuer.brand.font_display or issuer.brand.font,
|
|
115
119
|
"display_stretch": issuer.brand.font_display_stretch,
|
|
120
|
+
"display_weight": issuer.brand.font_display_weight,
|
|
121
|
+
"font_mono": issuer.brand.font_mono or issuer.brand.font,
|
|
122
|
+
"ink": issuer.brand.ink,
|
|
123
|
+
"subtle": issuer.brand.subtle,
|
|
124
|
+
"rule": issuer.brand.rule,
|
|
125
|
+
"panel": issuer.brand.panel,
|
|
116
126
|
"logo": logo,
|
|
127
|
+
"logo_height": issuer.brand.logo_height,
|
|
117
128
|
},
|
|
118
129
|
}
|
|
119
130
|
|
|
120
131
|
|
|
121
|
-
def _compile(
|
|
122
|
-
|
|
132
|
+
def _compile(
|
|
133
|
+
main: Path, output: Path, root: Path, font_paths: list[Path], only_bundled: bool
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Compile to PDF/A-2b (archival) when supported, else a plain PDF.
|
|
136
|
+
|
|
137
|
+
`only_bundled` switches machine-installed families off, and is set when the
|
|
138
|
+
issuer ships their own font directory. Without it, a variable font installed
|
|
139
|
+
on the designer's machine shadows the bundled static cuts of the same family
|
|
140
|
+
and every weight silently collapses to the variable font's default instance,
|
|
141
|
+
so the same invoice renders differently on a colleague's machine. Issuers on
|
|
142
|
+
the default font keep system fonts, since their families may live there.
|
|
143
|
+
"""
|
|
123
144
|
fonts = [str(p) for p in font_paths]
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
145
|
+
base: dict[str, object] = {"output": str(output), "root": str(root), "font_paths": fonts}
|
|
146
|
+
# Most capable call first; drop the options an older typst-py may not know.
|
|
147
|
+
attempts = [
|
|
148
|
+
{**base, "ignore_system_fonts": only_bundled, "pdf_standards": "a-2b"},
|
|
149
|
+
{**base, "pdf_standards": "a-2b"},
|
|
150
|
+
base,
|
|
151
|
+
]
|
|
152
|
+
for i, opts in enumerate(attempts):
|
|
153
|
+
try:
|
|
154
|
+
typst.compile(str(main), **opts) # type: ignore[arg-type]
|
|
155
|
+
return
|
|
156
|
+
except (TypeError, ValueError):
|
|
157
|
+
if i == len(attempts) - 1:
|
|
158
|
+
raise
|
|
130
159
|
|
|
131
160
|
|
|
132
161
|
def render(inv: Invoice, issuer: Issuer, out_path: Path) -> tuple[Path, Totals, str | None]:
|
|
@@ -184,11 +213,14 @@ def render(inv: Invoice, issuer: Issuer, out_path: Path) -> tuple[Path, Totals,
|
|
|
184
213
|
shutil.copy(TEMPLATE, work / "template.typ")
|
|
185
214
|
|
|
186
215
|
font_dir = issuer.brand.font_dir
|
|
187
|
-
|
|
216
|
+
issuer_fonts = [Path(font_dir)] if font_dir and Path(font_dir).is_dir() else []
|
|
217
|
+
# Ours first so the guaranteed fallback is always resolvable; the
|
|
218
|
+
# issuer's own families win on name, not on search order.
|
|
219
|
+
font_paths = [BUNDLED_FONTS, *issuer_fonts]
|
|
188
220
|
|
|
189
221
|
out_path = Path(out_path)
|
|
190
222
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
191
|
-
_compile(work / "template.typ", out_path, work, font_paths)
|
|
223
|
+
_compile(work / "template.typ", out_path, work, font_paths, bool(issuer_fonts))
|
|
192
224
|
return out_path, totals, qr_payload
|
|
193
225
|
finally:
|
|
194
226
|
shutil.rmtree(work, ignore_errors=True)
|