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.
Files changed (80) hide show
  1. {quints-0.5.0 → quints-0.7.0}/PKG-INFO +7 -5
  2. {quints-0.5.0 → quints-0.7.0}/README.md +4 -3
  3. quints-0.7.0/examples/statements/yapeal.csv +4 -0
  4. {quints-0.5.0 → quints-0.7.0}/pyproject.toml +3 -2
  5. {quints-0.5.0 → quints-0.7.0}/src/quints/cli.py +22 -1
  6. {quints-0.5.0 → quints-0.7.0}/src/quints/config.py +19 -0
  7. {quints-0.5.0 → quints-0.7.0}/src/quints/importing.py +46 -2
  8. {quints-0.5.0 → quints-0.7.0}/src/quints/init.py +25 -2
  9. quints-0.7.0/src/quints/invoice/fonts/LICENSE +102 -0
  10. quints-0.7.0/src/quints/invoice/fonts/LiberationSans-Bold.ttf +0 -0
  11. quints-0.7.0/src/quints/invoice/fonts/LiberationSans-Regular.ttf +0 -0
  12. quints-0.7.0/src/quints/invoice/fonts/README.md +15 -0
  13. {quints-0.5.0 → quints-0.7.0}/src/quints/invoice/model.py +37 -3
  14. {quints-0.5.0 → quints-0.7.0}/src/quints/invoice/qr.py +7 -1
  15. {quints-0.5.0 → quints-0.7.0}/src/quints/invoice/render.py +43 -11
  16. quints-0.7.0/src/quints/invoice/template.typ +184 -0
  17. {quints-0.5.0 → quints-0.7.0}/tests/test_importing.py +49 -0
  18. {quints-0.5.0 → quints-0.7.0}/tests/test_init.py +18 -1
  19. {quints-0.5.0 → quints-0.7.0}/tests/test_invoice.py +116 -0
  20. quints-0.5.0/src/quints/invoice/template.typ +0 -140
  21. {quints-0.5.0 → quints-0.7.0}/.gitignore +0 -0
  22. {quints-0.5.0 → quints-0.7.0}/LICENSE +0 -0
  23. {quints-0.5.0 → quints-0.7.0}/examples/.gitignore +0 -0
  24. {quints-0.5.0 → quints-0.7.0}/examples/AGENTS.md +0 -0
  25. {quints-0.5.0 → quints-0.7.0}/examples/CLAUDE.md +0 -0
  26. {quints-0.5.0 → quints-0.7.0}/examples/accounts.bean +0 -0
  27. {quints-0.5.0 → quints-0.7.0}/examples/answers.toml +0 -0
  28. {quints-0.5.0 → quints-0.7.0}/examples/books/2026.bean +0 -0
  29. {quints-0.5.0 → quints-0.7.0}/examples/commodities.bean +0 -0
  30. {quints-0.5.0 → quints-0.7.0}/examples/documents/.gitkeep +0 -0
  31. {quints-0.5.0 → quints-0.7.0}/examples/inbox/.gitkeep +0 -0
  32. {quints-0.5.0 → quints-0.7.0}/examples/invoicing/acme-2026-07.yaml +0 -0
  33. {quints-0.5.0 → quints-0.7.0}/examples/invoicing/customers.yaml +0 -0
  34. {quints-0.5.0 → quints-0.7.0}/examples/invoicing/globex-2026-08.yaml +0 -0
  35. {quints-0.5.0 → quints-0.7.0}/examples/invoicing/issuer.yaml +0 -0
  36. {quints-0.5.0 → quints-0.7.0}/examples/main.bean +0 -0
  37. {quints-0.5.0 → quints-0.7.0}/examples/prices.bean +0 -0
  38. {quints-0.5.0 → quints-0.7.0}/examples/pyproject.toml +0 -0
  39. {quints-0.5.0 → quints-0.7.0}/examples/quints.toml +0 -0
  40. {quints-0.5.0 → quints-0.7.0}/examples/staging/.gitkeep +0 -0
  41. {quints-0.5.0 → quints-0.7.0}/examples/statements/ubs-2026.mt940 +0 -0
  42. {quints-0.5.0 → quints-0.7.0}/src/quints/__init__.py +0 -0
  43. {quints-0.5.0 → quints-0.7.0}/src/quints/fava/__init__.py +0 -0
  44. {quints-0.5.0 → quints-0.7.0}/src/quints/fava/templates/QuintDashboard.html +0 -0
  45. {quints-0.5.0 → quints-0.7.0}/src/quints/fx.py +0 -0
  46. {quints-0.5.0 → quints-0.7.0}/src/quints/inbox.py +0 -0
  47. {quints-0.5.0 → quints-0.7.0}/src/quints/invoice/__init__.py +0 -0
  48. {quints-0.5.0 → quints-0.7.0}/src/quints/invoice/draft.py +0 -0
  49. {quints-0.5.0 → quints-0.7.0}/src/quints/invoice/labels.py +0 -0
  50. {quints-0.5.0 → quints-0.7.0}/src/quints/invoice/vatid.py +0 -0
  51. {quints-0.5.0 → quints-0.7.0}/src/quints/invoice/verify.py +0 -0
  52. {quints-0.5.0 → quints-0.7.0}/src/quints/kmu.py +0 -0
  53. {quints-0.5.0 → quints-0.7.0}/src/quints/ledger.py +0 -0
  54. {quints-0.5.0 → quints-0.7.0}/src/quints/match.py +0 -0
  55. {quints-0.5.0 → quints-0.7.0}/src/quints/mwst.py +0 -0
  56. {quints-0.5.0 → quints-0.7.0}/src/quints/plugins/__init__.py +0 -0
  57. {quints-0.5.0 → quints-0.7.0}/src/quints/plugins/kmu.py +0 -0
  58. {quints-0.5.0 → quints-0.7.0}/src/quints/prices.py +0 -0
  59. {quints-0.5.0 → quints-0.7.0}/src/quints/receivables.py +0 -0
  60. {quints-0.5.0 → quints-0.7.0}/src/quints/report.typ +0 -0
  61. {quints-0.5.0 → quints-0.7.0}/src/quints/report_pdf.py +0 -0
  62. {quints-0.5.0 → quints-0.7.0}/src/quints/settlement.py +0 -0
  63. {quints-0.5.0 → quints-0.7.0}/src/quints/ui.py +0 -0
  64. {quints-0.5.0 → quints-0.7.0}/src/quints/vat.py +0 -0
  65. {quints-0.5.0 → quints-0.7.0}/tests/fixtures/transactions.mt940 +0 -0
  66. {quints-0.5.0 → quints-0.7.0}/tests/test_cli_json.py +0 -0
  67. {quints-0.5.0 → quints-0.7.0}/tests/test_config.py +0 -0
  68. {quints-0.5.0 → quints-0.7.0}/tests/test_docs.py +0 -0
  69. {quints-0.5.0 → quints-0.7.0}/tests/test_fava_ext.py +0 -0
  70. {quints-0.5.0 → quints-0.7.0}/tests/test_fx.py +0 -0
  71. {quints-0.5.0 → quints-0.7.0}/tests/test_inbox_match.py +0 -0
  72. {quints-0.5.0 → quints-0.7.0}/tests/test_kmu_plugin.py +0 -0
  73. {quints-0.5.0 → quints-0.7.0}/tests/test_kmu_report.py +0 -0
  74. {quints-0.5.0 → quints-0.7.0}/tests/test_ledger.py +0 -0
  75. {quints-0.5.0 → quints-0.7.0}/tests/test_mwst.py +0 -0
  76. {quints-0.5.0 → quints-0.7.0}/tests/test_prices_ecosystem.py +0 -0
  77. {quints-0.5.0 → quints-0.7.0}/tests/test_prices_sync.py +0 -0
  78. {quints-0.5.0 → quints-0.7.0}/tests/test_receivables.py +0 -0
  79. {quints-0.5.0 → quints-0.7.0}/tests/test_report_pdf.py +0 -0
  80. {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.5.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 draft into
43
- a `staging/` area — idempotent re-imports, conversion merging, fee splitting,
44
- open-invoice matching by QR reference — never directly into your books.
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 draft into
18
- a `staging/` area — idempotent re-imports, conversion merging, fee splitting,
19
- open-invoice matching by QR reference — never directly into your books.
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.5.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("_Section", config.UbsImport, config.WiseImport, config.StripeImport)
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) == 2:
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
+
@@ -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 BaseModel, Field, RootModel, field_validator, model_validator
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
- accent: str = "#123c3a"
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
- return QRBill(
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 | int | None]
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(main: Path, output: Path, root: Path, font_paths: list[Path]) -> None:
122
- """Compile to PDF/A-2b (archival) when supported, else a plain PDF."""
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
- try:
125
- typst.compile(
126
- str(main), output=str(output), root=str(root), font_paths=fonts, pdf_standards="a-2b"
127
- )
128
- except (TypeError, ValueError): # older typst-py, or standard unsupported
129
- typst.compile(str(main), output=str(output), root=str(root), font_paths=fonts)
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
- font_paths = [Path(font_dir)] if font_dir and Path(font_dir).is_dir() else []
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)