quints 0.18.0__tar.gz → 0.19.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 (114) hide show
  1. {quints-0.18.0 → quints-0.19.0}/PKG-INFO +1 -1
  2. {quints-0.18.0 → quints-0.19.0}/examples/AGENTS.md +8 -0
  3. {quints-0.18.0 → quints-0.19.0}/examples/accounts.bean +4 -0
  4. {quints-0.18.0 → quints-0.19.0}/examples/quints.toml +2 -0
  5. {quints-0.18.0 → quints-0.19.0}/pyproject.toml +1 -1
  6. {quints-0.18.0 → quints-0.19.0}/src/quints/cli.py +59 -26
  7. {quints-0.18.0 → quints-0.19.0}/src/quints/config.py +21 -0
  8. {quints-0.18.0 → quints-0.19.0}/src/quints/fx.py +2 -0
  9. {quints-0.18.0 → quints-0.19.0}/src/quints/init.py +14 -0
  10. quints-0.19.0/src/quints/invoice/draft.py +151 -0
  11. {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/labels.py +31 -2
  12. {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/model.py +58 -0
  13. {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/qr.py +16 -4
  14. {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/render.py +61 -19
  15. {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/template.typ +20 -4
  16. quints-0.19.0/src/quints/invoice/verify.py +198 -0
  17. {quints-0.18.0 → quints-0.19.0}/src/quints/kmu.py +15 -3
  18. {quints-0.18.0 → quints-0.19.0}/src/quints/mwst.py +21 -2
  19. {quints-0.18.0 → quints-0.19.0}/src/quints/report.typ +32 -9
  20. {quints-0.18.0 → quints-0.19.0}/src/quints/report_pdf.py +80 -13
  21. {quints-0.18.0 → quints-0.19.0}/tests/test_invoice.py +12 -0
  22. {quints-0.18.0 → quints-0.19.0}/tests/test_kmu_report.py +19 -2
  23. quints-0.19.0/tests/test_proforma.py +485 -0
  24. {quints-0.18.0 → quints-0.19.0}/tests/test_report_pdf.py +32 -0
  25. quints-0.18.0/src/quints/invoice/draft.py +0 -60
  26. quints-0.18.0/src/quints/invoice/verify.py +0 -66
  27. {quints-0.18.0 → quints-0.19.0}/.gitignore +0 -0
  28. {quints-0.18.0 → quints-0.19.0}/LICENSE +0 -0
  29. {quints-0.18.0 → quints-0.19.0}/README.md +0 -0
  30. {quints-0.18.0 → quints-0.19.0}/examples/.gitignore +0 -0
  31. {quints-0.18.0 → quints-0.19.0}/examples/CLAUDE.md +0 -0
  32. {quints-0.18.0 → quints-0.19.0}/examples/answers.toml +0 -0
  33. {quints-0.18.0 → quints-0.19.0}/examples/books/2026.bean +0 -0
  34. {quints-0.18.0 → quints-0.19.0}/examples/commodities.bean +0 -0
  35. {quints-0.18.0 → quints-0.19.0}/examples/documents/.gitkeep +0 -0
  36. {quints-0.18.0 → quints-0.19.0}/examples/inbox/.gitkeep +0 -0
  37. {quints-0.18.0 → quints-0.19.0}/examples/invoicing/acme-2026-07.yaml +0 -0
  38. {quints-0.18.0 → quints-0.19.0}/examples/invoicing/customers.yaml +0 -0
  39. {quints-0.18.0 → quints-0.19.0}/examples/invoicing/globex-2026-08.yaml +0 -0
  40. {quints-0.18.0 → quints-0.19.0}/examples/invoicing/issuer.yaml +0 -0
  41. {quints-0.18.0 → quints-0.19.0}/examples/invoicing/wordmark.svg +0 -0
  42. {quints-0.18.0 → quints-0.19.0}/examples/main.bean +0 -0
  43. {quints-0.18.0 → quints-0.19.0}/examples/prices.bean +0 -0
  44. {quints-0.18.0 → quints-0.19.0}/examples/pyproject.toml +0 -0
  45. {quints-0.18.0 → quints-0.19.0}/examples/staging/.gitkeep +0 -0
  46. {quints-0.18.0 → quints-0.19.0}/examples/statements/ubs-2026.mt940 +0 -0
  47. {quints-0.18.0 → quints-0.19.0}/examples/statements/yapeal.csv +0 -0
  48. {quints-0.18.0 → quints-0.19.0}/src/quints/__init__.py +0 -0
  49. {quints-0.18.0 → quints-0.19.0}/src/quints/closing.py +0 -0
  50. {quints-0.18.0 → quints-0.19.0}/src/quints/fava/__init__.py +0 -0
  51. {quints-0.18.0 → quints-0.19.0}/src/quints/fava/templates/QuintDashboard.html +0 -0
  52. {quints-0.18.0 → quints-0.19.0}/src/quints/importing.py +0 -0
  53. {quints-0.18.0 → quints-0.19.0}/src/quints/inbox.py +0 -0
  54. {quints-0.18.0 → quints-0.19.0}/src/quints/init_assets/wordmark.svg +0 -0
  55. {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/__init__.py +0 -0
  56. {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/bank.py +0 -0
  57. {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/README.md +0 -0
  58. {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/geist/Geist-Bold.otf +0 -0
  59. {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/geist/Geist-Medium.otf +0 -0
  60. {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/geist/Geist-Regular.otf +0 -0
  61. {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/geist/Geist-SemiBold.otf +0 -0
  62. {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/geist/OFL.txt +0 -0
  63. {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/geist-mono/GeistMono-Medium.otf +0 -0
  64. {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/geist-mono/GeistMono-Regular.otf +0 -0
  65. {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/geist-mono/GeistMono-SemiBold.otf +0 -0
  66. {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/geist-mono/OFL.txt +0 -0
  67. {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/liberation/LICENSE +0 -0
  68. {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/liberation/LiberationSans-Bold.ttf +0 -0
  69. {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/liberation/LiberationSans-Regular.ttf +0 -0
  70. {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/newsreader/Newsreader-Medium.ttf +0 -0
  71. {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/newsreader/Newsreader-Regular.ttf +0 -0
  72. {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/newsreader/Newsreader-SemiBold.ttf +0 -0
  73. {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/newsreader/OFL.txt +0 -0
  74. {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/reference.py +0 -0
  75. {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/swico.py +0 -0
  76. {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/vatid.py +0 -0
  77. {quints-0.18.0 → quints-0.19.0}/src/quints/ledger.py +0 -0
  78. {quints-0.18.0 → quints-0.19.0}/src/quints/liability.py +0 -0
  79. {quints-0.18.0 → quints-0.19.0}/src/quints/match.py +0 -0
  80. {quints-0.18.0 → quints-0.19.0}/src/quints/payables.py +0 -0
  81. {quints-0.18.0 → quints-0.19.0}/src/quints/plugins/__init__.py +0 -0
  82. {quints-0.18.0 → quints-0.19.0}/src/quints/plugins/kmu.py +0 -0
  83. {quints-0.18.0 → quints-0.19.0}/src/quints/prices.py +0 -0
  84. {quints-0.18.0 → quints-0.19.0}/src/quints/receivables.py +0 -0
  85. {quints-0.18.0 → quints-0.19.0}/src/quints/settlement.py +0 -0
  86. {quints-0.18.0 → quints-0.19.0}/src/quints/ui.py +0 -0
  87. {quints-0.18.0 → quints-0.19.0}/src/quints/vat.py +0 -0
  88. {quints-0.18.0 → quints-0.19.0}/tests/fixtures/transactions.mt940 +0 -0
  89. {quints-0.18.0 → quints-0.19.0}/tests/test_cli_errors.py +0 -0
  90. {quints-0.18.0 → quints-0.19.0}/tests/test_cli_json.py +0 -0
  91. {quints-0.18.0 → quints-0.19.0}/tests/test_closing.py +0 -0
  92. {quints-0.18.0 → quints-0.19.0}/tests/test_config.py +0 -0
  93. {quints-0.18.0 → quints-0.19.0}/tests/test_config_strict.py +0 -0
  94. {quints-0.18.0 → quints-0.19.0}/tests/test_docs.py +0 -0
  95. {quints-0.18.0 → quints-0.19.0}/tests/test_durable_writes.py +0 -0
  96. {quints-0.18.0 → quints-0.19.0}/tests/test_fava_ext.py +0 -0
  97. {quints-0.18.0 → quints-0.19.0}/tests/test_fx.py +0 -0
  98. {quints-0.18.0 → quints-0.19.0}/tests/test_importing.py +0 -0
  99. {quints-0.18.0 → quints-0.19.0}/tests/test_importing_stripe_invoices.py +0 -0
  100. {quints-0.18.0 → quints-0.19.0}/tests/test_inbox_match.py +0 -0
  101. {quints-0.18.0 → quints-0.19.0}/tests/test_init.py +0 -0
  102. {quints-0.18.0 → quints-0.19.0}/tests/test_kmu_plugin.py +0 -0
  103. {quints-0.18.0 → quints-0.19.0}/tests/test_ledger.py +0 -0
  104. {quints-0.18.0 → quints-0.19.0}/tests/test_loader_errors.py +0 -0
  105. {quints-0.18.0 → quints-0.19.0}/tests/test_money_defaults.py +0 -0
  106. {quints-0.18.0 → quints-0.19.0}/tests/test_mwst.py +0 -0
  107. {quints-0.18.0 → quints-0.19.0}/tests/test_payables.py +0 -0
  108. {quints-0.18.0 → quints-0.19.0}/tests/test_prices_ecosystem.py +0 -0
  109. {quints-0.18.0 → quints-0.19.0}/tests/test_prices_sync.py +0 -0
  110. {quints-0.18.0 → quints-0.19.0}/tests/test_receivables.py +0 -0
  111. {quints-0.18.0 → quints-0.19.0}/tests/test_saldo.py +0 -0
  112. {quints-0.18.0 → quints-0.19.0}/tests/test_settlement.py +0 -0
  113. {quints-0.18.0 → quints-0.19.0}/tests/test_statements_crosscheck.py +0 -0
  114. {quints-0.18.0 → quints-0.19.0}/tests/test_vat_lifecycle.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: quints
3
- Version: 0.18.0
3
+ Version: 0.19.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, Yapeal, Wise, Stripe) that draft into a staging area
5
5
  License: GPL-2.0-only
6
6
  License-File: LICENSE
@@ -15,6 +15,14 @@ This project is a git repository; `quints init` committed the pristine
15
15
  scaffold. Work in reviewable steps: `git diff` before moving drafts into
16
16
  `books/`, commit once `quints check` passes — the history is the audit trail.
17
17
 
18
+ ## Confidential data
19
+
20
+ These are the company's books: its customers, suppliers, bank balances and
21
+ their documents, some of them personal data. Whatever you read here goes to
22
+ your model provider. Read what the task needs, never open `.env` or `.wise/`,
23
+ and never send the books to a service the company hasn't approved. Keep the
24
+ git remote private.
25
+
18
26
  ## Layout
19
27
 
20
28
  - `main.bean` — options, plugins, includes; the entry point every tool loads.
@@ -11,6 +11,10 @@
11
11
  kmu: "1170" ; Input VAT
12
12
  2026-01-01 open Liabilities:CH:GmbH:Payable:Trade
13
13
  kmu: "2000" ; Trade payables
14
+ 2026-01-01 open Liabilities:CH:GmbH:Advances:Domestic
15
+ kmu: "2030" ; Advances received
16
+ 2026-01-01 open Liabilities:CH:GmbH:Advances:Export
17
+ kmu: "2030" ; Advances received
14
18
  ; Fixed assets: `quints close depreciation --year <year>` prints the
15
19
  ; year's write-down from the metadata below, and `quints close check`
16
20
  ; verifies it was booked. Rates are the ESTV Merkblatt A/1995 maxima
@@ -36,6 +36,8 @@ reduced_marker = ":Reduced" # 2.6 % rate class → Ziffer 313
36
36
  lodging_marker = ":Lodging" # 3.8 % Beherbergung → Ziffer 343
37
37
  income_domestic = "Income:CH:GmbH:Consulting:External:Domestic"
38
38
  income_export = "Income:CH:GmbH:Consulting:External:Export"
39
+ advances_domestic = "Liabilities:CH:GmbH:Advances:Domestic" # pro forma payments, until invoiced
40
+ advances_export = "Liabilities:CH:GmbH:Advances:Export"
39
41
  fx_gain = "Income:CH:GmbH:FX:CurrencyGain"
40
42
  fx_loss = "Expenses:CH:GmbH:FX:CurrencyLoss"
41
43
  rounding_income = "Income:CH:GmbH:Rounding"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "quints"
3
- version = "0.18.0"
3
+ version = "0.19.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, Yapeal, Wise, Stripe) that draft into a staging area"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -245,23 +245,15 @@ def _problems(violations: list[ledger.Violation], *, err: bool = False) -> None:
245
245
  console.print(ui.violations_table(violations, "Problems in the books"))
246
246
 
247
247
 
248
- def _vat_chf(file: Path, totals: Totals, inv: Invoice) -> Decimal | None:
249
- """An invoice's VAT in CHF at its issue date, for the ledger draft — None
250
- when it is CHF already or the books hold no rate (the draft says so)."""
251
- from beancount.core.amount import Amount
252
-
253
- if inv.currency == "CHF" or not totals.vat_amount:
248
+ def _chf_rate(file: Path, inv: Invoice) -> Decimal | None:
249
+ """CHF per unit of a foreign invoice's currency on its issue date, for the
250
+ ledger draft's VAT — None when it is CHF already, a pro forma (owed at the
251
+ day received), or the books hold no rate (the draft says so)."""
252
+ if inv.currency == "CHF" or inv.proforma:
254
253
  return None
255
254
  entries, _ = ledger.load_entries(file) # its errors were listed by the cross-check
256
- try:
257
- chf = ledger.to_chf(
258
- Amount(totals.vat_amount, inv.currency),
259
- inv.issue_date,
260
- bc_prices.build_price_map(entries),
261
- )
262
- except ledger.RateUnavailable:
263
- return None
264
- return ledger.rappen(chf)
255
+ _, rate = ledger.rate(bc_prices.build_price_map(entries), inv.currency, inv.issue_date)
256
+ return rate
265
257
 
266
258
 
267
259
  def _require_ledger(file: Path) -> None:
@@ -844,10 +836,9 @@ def invoice(
844
836
  # an invoice from before registration (or after it ended) carries no VAT.
845
837
  registered = cfg.liable_on(inv.issue_date)
846
838
  if out is None:
847
- # File the PDF the way beancount documents are filed: under the income
848
- # account's folder, date-prefixed, next to the ledger's other evidence.
849
- account = cfg.income_export if inv.kind == "export" else cfg.income_domestic
850
- out = m.document_path(inv, account)
839
+ # File the PDF the way beancount documents are filed: under the folder
840
+ # of the account it books against, date-prefixed, next to the evidence.
841
+ out = m.document_path(inv, dr.filing_account(inv, cfg))
851
842
  path, totals, payload = r.render(inv, iss, out, vat_registered=registered)
852
843
  ref = ref_mod.payment_reference(inv, iss.account(inv.currency))
853
844
 
@@ -859,7 +850,9 @@ def invoice(
859
850
  if verify and file.exists():
860
851
  cc = v.cross_check(file, inv, totals)
861
852
  if not cc.found:
862
- ledger_draft = dr.build_draft(inv, totals, vat_chf=_vat_chf(file, totals, inv))
853
+ ledger_draft = dr.build_draft(
854
+ inv, totals, chf_rate=_chf_rate(file, inv), received=cc.received
855
+ )
863
856
  if not as_json:
864
857
  _print_cross_check(cc, inv, ledger_draft)
865
858
  elif verify and not as_json:
@@ -892,6 +885,7 @@ def _invoice_payload(
892
885
  return {
893
886
  "number": inv.number,
894
887
  "kind": inv.kind,
888
+ "proforma": inv.proforma,
895
889
  "currency": inv.currency,
896
890
  "issue_date": inv.issue_date,
897
891
  "customer": inv.resolved_customer.name,
@@ -918,8 +912,9 @@ def _print_rendered(
918
912
  """What was written: the PDF, its reference, VAT status and QR payload."""
919
913
  from .invoice import model as m
920
914
 
915
+ kind = f"pro forma {inv.kind}" if inv.proforma else inv.kind
921
916
  ui.console.print(
922
- f"[ok]Wrote[/] {path} · {inv.kind} · {inv.currency} {m.money(totals.grand_total)}"
917
+ f"[ok]Wrote[/] {path} · {kind} · {inv.currency} {m.money(totals.amount_due)}"
923
918
  )
924
919
  ui.console.print(f"[muted]ref {ref.kind} {ref.formatted}[/]")
925
920
  if not registered:
@@ -938,7 +933,18 @@ def _print_cross_check(cc: CrossCheck, inv: Invoice, ledger_draft: str | None) -
938
933
  from .invoice import model as m
939
934
 
940
935
  _problems(cc.violations)
941
- if not cc.found:
936
+ for problem in cc.advance_problems:
937
+ ui.console.print(
938
+ f"[err]{problem}[/] — fix the invoice's `advances` or the receipt booking."
939
+ )
940
+ if not cc.found and inv.proforma:
941
+ ui.console.print(
942
+ f"[warn]No payment booked for pro forma {inv.number} yet[/] — when it arrives, "
943
+ f"book it with these legs into books/<year>.bean:\n"
944
+ )
945
+ print(ledger_draft)
946
+ print()
947
+ elif not cc.found:
942
948
  ui.console.print(
943
949
  f"[warn]No ledger txn for {inv.number}[/] — paste this draft "
944
950
  f"into books/{inv.issue_date.year}.bean:\n"
@@ -948,9 +954,16 @@ def _print_cross_check(cc: CrossCheck, inv: Invoice, ledger_draft: str | None) -
948
954
  elif cc.ok:
949
955
  if cc.ledger_total is None: # unreachable: found implies totals are set
950
956
  raise ValueError(f"cross-check for {inv.number} lost its ledger total")
951
- ui.console.print(
952
- f"[ok]Ledger match[/] ({cc.date}): {inv.currency} {m.money(cc.ledger_total)}"
953
- )
957
+ if inv.proforma and cc.invoice_total is not None and cc.ledger_total < cc.invoice_total:
958
+ ui.console.print(
959
+ f"[ok]Advance received[/] ({cc.date}): {inv.currency} "
960
+ f"{m.money(cc.ledger_total)} of {m.money(cc.invoice_total)}"
961
+ )
962
+ else:
963
+ what = "Advance received" if inv.proforma else "Ledger match"
964
+ ui.console.print(
965
+ f"[ok]{what}[/] ({cc.date}): {inv.currency} {m.money(cc.ledger_total)}"
966
+ )
954
967
  if not cc.date_ok:
955
968
  ui.console.print(f"[warn]booking date {cc.date} ≠ invoice date {inv.issue_date}[/]")
956
969
  else:
@@ -1546,9 +1559,29 @@ def statements(
1546
1559
  lang = lang or config_mod.get().report_language
1547
1560
  bilanz_report = kmu_mod.compute_bilanz(file, balance_date, date_from=date_from)
1548
1561
  erfolg_report = kmu_mod.compute_erfolg(file, date_from, balance_date)
1562
+ # The prior year's figures sit next to this year's (Art. 958d Abs. 2 OR).
1563
+ # An extended first year (`--from`) and a year with empty books before it
1564
+ # have none.
1565
+ # shortcut: the prior year is a calendar year, so after an extended first
1566
+ # year its column covers only that year's calendar part; add --prior-from if asked.
1567
+ prior = None
1568
+ if from_ is None:
1569
+ prior_bilanz = kmu_mod.compute_bilanz(
1570
+ file, f"{year - 1}-12-31", date_from=f"{year - 1}-01-01"
1571
+ )
1572
+ prior_erfolg = kmu_mod.compute_erfolg(file, f"{year - 1}-01-01", f"{year - 1}-12-31")
1573
+ if prior_bilanz.total_assets or prior_erfolg.result:
1574
+ prior = (prior_bilanz, prior_erfolg)
1549
1575
  out = out or Path(f"statements-{year}-{lang}.pdf")
1550
- path = report_pdf.render_pdf(bilanz_report, erfolg_report, lang, out)
1576
+ path = report_pdf.render_pdf(bilanz_report, erfolg_report, lang, out, prior=prior)
1551
1577
  typer.secho(f"Wrote {path}", fg="green")
1578
+ if prior is None and from_ is None:
1579
+ typer.secho(
1580
+ f"No figures for {year - 1} in the books, so the statements have no "
1581
+ "prior-year column (Art. 958d Abs. 2 OR). If the company existed then, "
1582
+ f"date the opening balances {year - 1}-12-31 instead of {year}-01-01.",
1583
+ fg="yellow",
1584
+ )
1552
1585
  if bilanz_report.violations:
1553
1586
  typer.secho("The statements list these problems in the books at the top:", fg="yellow")
1554
1587
  _problems(bilanz_report.violations)
@@ -243,6 +243,12 @@ class Config:
243
243
  rounding_income: str = "Income:CH:GmbH:Rounding"
244
244
  income_domestic: str = "Income:CH:GmbH:Consulting:External:Domestic"
245
245
  income_export: str = "Income:CH:GmbH:Consulting:External:Export"
246
+ # Customer advances (KMU 2030 Erhaltene Anzahlungen): a payment against a
247
+ # pro forma, held until the invoice credits it. The return counts it as
248
+ # turnover on receipt (Art. 40 Abs. 1 Bst. c MWSTG), so `mwst` routes these
249
+ # like the income account they mirror — the export one carries the marker.
250
+ advances_domestic: str = "Liabilities:CH:GmbH:Advances:Domestic"
251
+ advances_export: str = "Liabilities:CH:GmbH:Advances:Export"
246
252
  # Saldosteuersatz method only. The difference between the statutory VAT
247
253
  # invoiced and the SSS owed lands in `saldo_difference` at settlement;
248
254
  # Bezugsteuer is a cost, not a deduction, so it lands in
@@ -320,6 +326,19 @@ class Config:
320
326
  """Whether the entity is VAT-registered on ``day``."""
321
327
  return self.phase_at(day) is not None
322
328
 
329
+ @property
330
+ def advances(self) -> tuple[str, str]:
331
+ """The customer-advance accounts: held at the CHF received, turnover to `mwst`."""
332
+ return (self.advances_domestic, self.advances_export)
333
+
334
+ def advances_for(self, kind: str) -> str:
335
+ """The advances account of an invoice `kind` (domestic or export)."""
336
+ return self.advances_export if kind == "export" else self.advances_domestic
337
+
338
+ def mirrored_income(self, account: str) -> str:
339
+ """The income account an advances account stands in for until invoiced."""
340
+ return self.income_export if account == self.advances_export else self.income_domestic
341
+
323
342
  def for_period(self, d0: Date, d1: Date) -> tuple[Config, Date, Date]:
324
343
  """The config one return is computed under, and the liable stretch of it.
325
344
 
@@ -445,6 +464,8 @@ _ACCOUNT_KEYS = (
445
464
  "rounding_income",
446
465
  "income_domestic",
447
466
  "income_export",
467
+ "advances_domestic",
468
+ "advances_export",
448
469
  "saldo_difference",
449
470
  "bezugsteuer_expense",
450
471
  )
@@ -78,6 +78,8 @@ def compute(
78
78
  root = p.account.split(":", 1)[0]
79
79
  if cfg.entity_marker not in p.account or root not in ("Assets", "Liabilities"):
80
80
  continue
81
+ if p.account in cfg.advances:
82
+ continue # settled by the supply, not in money: stays at the rate received
81
83
  if p.units is None or p.units.currency == "CHF":
82
84
  continue
83
85
  inventories.setdefault(p.account, Inventory()).add_position(p)
@@ -201,6 +201,8 @@ def _cfg(answers: Answers) -> config.Config:
201
201
  rounding_income=_sub(base.rounding_income, c),
202
202
  income_domestic=_sub(base.income_domestic, c),
203
203
  income_export=_sub(base.income_export, c),
204
+ advances_domestic=_sub(base.advances_domestic, c),
205
+ advances_export=_sub(base.advances_export, c),
204
206
  saldo_difference=_sub(base.saldo_difference, c),
205
207
  bezugsteuer_expense=_sub(base.bezugsteuer_expense, c),
206
208
  saldo=tuple(config.SaldoRate(Decimal(spec) / 100) for spec in sorted(answers.saldo_rates)),
@@ -241,6 +243,8 @@ def _backbone(answers: Answers) -> list[_Account]:
241
243
  _Account(cfg.receivable, "1100"),
242
244
  _Account(cfg.input_vat, "1170", (oc,)),
243
245
  _Account(cfg.payable, "2000"),
246
+ _Account(cfg.advances_domestic, "2030"),
247
+ _Account(cfg.advances_export, "2030"),
244
248
  _Account(
245
249
  _sub(_FIXED_ASSETS, c),
246
250
  "1510",
@@ -816,6 +820,8 @@ def _quints_toml(answers: Answers) -> str:
816
820
  *_marker_lines(cfg),
817
821
  f'income_domestic = "{cfg.income_domestic}"',
818
822
  f'income_export = "{cfg.income_export}"',
823
+ f'advances_domestic = "{cfg.advances_domestic}" # pro forma payments, until invoiced',
824
+ f'advances_export = "{cfg.advances_export}"',
819
825
  *_saldo_account_lines(answers, cfg),
820
826
  f'fx_gain = "{cfg.fx_gain}"',
821
827
  f'fx_loss = "{cfg.fx_loss}"',
@@ -1145,6 +1151,14 @@ This project is a git repository; `quints init` committed the pristine
1145
1151
  scaffold. Work in reviewable steps: `git diff` before moving drafts into
1146
1152
  `books/`, commit once `quints check` passes — the history is the audit trail.
1147
1153
 
1154
+ ## Confidential data
1155
+
1156
+ These are the company's books: its customers, suppliers, bank balances and
1157
+ their documents, some of them personal data. Whatever you read here goes to
1158
+ your model provider. Read what the task needs, never open `.env` or `.wise/`,
1159
+ and never send the books to a service the company hasn't approved. Keep the
1160
+ git remote private.
1161
+
1148
1162
  ## Layout
1149
1163
 
1150
1164
  - `main.bean` — options, plugins, includes; the entry point every tool loads.
@@ -0,0 +1,151 @@
1
+ """Ready-to-paste Beancount draft for an invoice with no ledger entry yet."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from decimal import Decimal
7
+
8
+ from .. import config, ledger
9
+ from .model import Invoice, Totals, document_path, vat_included
10
+ from .verify import Received
11
+
12
+
13
+ def _quoted(value: str) -> str:
14
+ """A beancount string literal — free text from the customer must not be
15
+ able to break the draft out of its quotes."""
16
+ return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
17
+
18
+
19
+ def filing_account(inv: Invoice, cfg: config.Config) -> str:
20
+ """The account the invoice books against, and its PDF is filed under: the
21
+ income account — or, for a pro forma, the customer advances it becomes."""
22
+ if inv.proforma:
23
+ return cfg.advances_for(inv.kind)
24
+ return cfg.income_export if inv.kind == "export" else cfg.income_domestic
25
+
26
+
27
+ def _vat_leg(vat: Decimal, ccy: str, chf_rate: Decimal | None, todo: str = "") -> str:
28
+ """The OutputVAT amount: CHF as the return is, priced ``@@`` in `ccy`."""
29
+ if ccy == "CHF":
30
+ return f"{-vat:>10.2f} {ccy}"
31
+ if chf_rate is not None:
32
+ return f"{-ledger.rappen(vat * chf_rate):>10.2f} CHF @@ {vat:.2f} {ccy}"
33
+ return f"{-vat:>10.2f} {ccy} ; TODO in CHF{todo}: quints vat convert"
34
+
35
+
36
+ def _lines(header: str, meta: list[str], legs: list[tuple[str, str, str]]) -> str:
37
+ width = max(len(a) for a, _, _ in legs) + 4
38
+ out = [header, *meta]
39
+ for account, amount, posting_meta in legs:
40
+ out.append(f" {account:<{width}}{amount}")
41
+ if posting_meta:
42
+ out.append(f" {posting_meta}")
43
+ return "\n".join(out)
44
+
45
+
46
+ def receipt_draft(inv: Invoice, totals: Totals, cfg: config.Config | None = None) -> str:
47
+ """The booking for a pro forma's payment, when it arrives.
48
+
49
+ Not income yet: the advance is a liability until the invoice credits it.
50
+ The return declares it on receipt all the same — with its VAT when
51
+ domestic (Art. 40 Abs. 1 Bst. c MWSTG), so the VAT is split out of the
52
+ gross received the way the invoice will credit it back (`vat_included`).
53
+ """
54
+ cfg = cfg or config.get()
55
+ ccy = inv.currency
56
+ gross = totals.amount_due
57
+ vat = vat_included(gross, totals.vat_rate)
58
+ doc = document_path(inv, filing_account(inv, cfg))
59
+ legs = [(filing_account(inv, cfg), f"{-(gross - vat):>10.2f} {ccy}", "")]
60
+ if vat:
61
+ legs.append((cfg.output_vat, _vat_leg(vat, ccy, None, " at the day received"), ""))
62
+ header = (
63
+ f"{inv.issue_date} * {_quoted(inv.resolved_customer.name)} "
64
+ f"{_quoted(f'Advance on pro forma {inv.number}')} ^{inv.number}"
65
+ " ; TODO the day it was received"
66
+ )
67
+ meta = [
68
+ f" proforma: {_quoted(inv.number)}",
69
+ f" document: {_quoted(doc.name)} ; TODO file the PDF under {doc.parent}/",
70
+ f" ; TODO the bank leg: {gross:.2f} {ccy} into the account it was paid to.",
71
+ ]
72
+ if vat:
73
+ meta.append(
74
+ " ; A different amount arrived? Split that: VAT = amount × rate / (100 + rate)."
75
+ )
76
+ return _lines(header, meta, legs)
77
+
78
+
79
+ def build_draft(
80
+ inv: Invoice,
81
+ totals: Totals,
82
+ cfg: config.Config | None = None,
83
+ chf_rate: Decimal | None = None,
84
+ received: Mapping[str, Received] | None = None,
85
+ ) -> str:
86
+ """A balanced receivable booking matching what `verify.cross_check` expects.
87
+
88
+ VAT is owed in CHF, so on a foreign-currency invoice the OutputVAT leg is
89
+ CHF at ``chf_rate`` (the issue date's), priced ``@@`` at the invoice's
90
+ VAT. Without a rate it stays in the invoice currency, marked to convert.
91
+
92
+ Credited advances are released as their receipts booked them
93
+ (``received``, from `verify.cross_check`): the VAT they declared is not
94
+ owed again, and a foreign advance leaves at the CHF it came in at — the
95
+ income it becomes is valued at that too, so the advances account clears
96
+ in CHF as well as in units. A pro forma missing from ``received`` is
97
+ released as the invoice credits it.
98
+ """
99
+ cfg = cfg or config.get()
100
+ if inv.proforma:
101
+ return receipt_draft(inv, totals, cfg)
102
+ customer = inv.resolved_customer
103
+ ccy = inv.currency
104
+ income = filing_account(inv, cfg)
105
+ advances = cfg.advances_for(inv.kind)
106
+ narration = f"{inv.supply.text(inv.locale)} invoiced"
107
+ doc = document_path(inv, income) # same name the rendered PDF is filed under
108
+
109
+ vat = totals.vat_amount
110
+ rest = totals.subtotal # the income not advanced, valued at the issue date
111
+ slices: list[tuple[str, str, str]] = []
112
+ released: list[tuple[str, str, str]] = []
113
+ for number in dict.fromkeys(a.proforma for a in inv.advances):
114
+ r = (received or {}).get(number) or _as_credited(inv, number, totals.vat_rate)
115
+ vat -= r.vat
116
+ # Posting metadata, not a second ^link: receivables trust a lone invoice link.
117
+ tag = f"proforma: {_quoted(number)}"
118
+ if ccy != "CHF" and r.net_chf is not None:
119
+ rest -= r.net
120
+ slices.append((income, f"{-r.net:>10.2f} {ccy} @@ {r.net_chf:.2f} CHF", ""))
121
+ released.append((advances, f"{r.net:>10.2f} {ccy} @@ {r.net_chf:.2f} CHF", tag))
122
+ elif ccy != "CHF":
123
+ todo = " ; TODO @@ the CHF it was received at"
124
+ released.append((advances, f"{r.net:>10.2f} {ccy}{todo}", tag))
125
+ else:
126
+ released.append((advances, f"{r.net:>10.2f} {ccy}", tag))
127
+
128
+ legs = [*([(income, f"{-rest:>10.2f} {ccy}", "")] if rest else []), *slices]
129
+ if totals.amount_due:
130
+ legs.append((cfg.receivable, f"{totals.amount_due:>10.2f} {ccy}", ""))
131
+ legs += released
132
+ if vat:
133
+ legs.append((cfg.output_vat, _vat_leg(vat, ccy, chf_rate), ""))
134
+ if totals.rounding:
135
+ legs.append((cfg.rounding_income, f"{-totals.rounding:>10.2f} {ccy}", ""))
136
+
137
+ meta = [f" invoice: {_quoted(inv.number)}"]
138
+ if inv.customer_reference:
139
+ # The payer quotes their own reference, not ours, when they ask about
140
+ # this invoice — so the ledger can be searched by it too.
141
+ meta.append(f" customer_reference: {_quoted(inv.customer_reference)}")
142
+ meta.append(f" document: {_quoted(doc.name)} ; TODO file the PDF under {doc.parent}/")
143
+ header = f"{inv.issue_date} * {_quoted(customer.name)} {_quoted(narration)} ^{inv.number}"
144
+ return _lines(header, meta, legs)
145
+
146
+
147
+ def _as_credited(inv: Invoice, number: str, rate: Decimal) -> Received:
148
+ """An advance not (yet) in the books, split the way its receipt would be."""
149
+ gross = sum((a.amount for a in inv.advances if a.proforma == number), Decimal("0"))
150
+ vat = vat_included(gross, rate)
151
+ return Received(net=gross - vat, vat=vat, net_chf=None)
@@ -6,9 +6,11 @@ these keys, so a new language is accepted the moment it is defined here — no
6
6
  other file to touch; the matching CLDR locale, e.g. `es_ES`, gives Babel the
7
7
  number/date formatting for free).
8
8
 
9
- The legally load-bearing strings — `terms`, `export_note`, `reverse_charge` —
9
+ The legally load-bearing strings — `terms`, `export_note`, `reverse_charge`,
10
+ `proforma_note`, `advance_vat` —
10
11
  should be reviewed by a native/legal speaker before an entry ships.
11
- `terms` MUST keep the literal `{days}` placeholder (`render` calls `.format`)."""
12
+ `terms` MUST keep the literal `{days}` placeholder, `advance` its `{number}`
13
+ and `{date}`, and `advance_vat` its `{vat}` (`render` calls `.format`)."""
12
14
 
13
15
  LABELS = {
14
16
  "de": {
@@ -34,6 +36,15 @@ LABELS = {
34
36
  "export_note": "Nicht der schweizerischen MWST unterliegend: Ort der "
35
37
  "Leistung im Ausland (Art. 8 Abs. 1 MWSTG).",
36
38
  "reverse_charge": "Reverse Charge — die Steuer ist vom Leistungsempfänger abzurechnen.",
39
+ "proforma": "Proforma-Rechnung",
40
+ "proforma_no": "Proforma-Nr.",
41
+ "proforma_date": "Datum",
42
+ "amount_due": "Zahlbar",
43
+ "invoice_total": "Rechnungstotal",
44
+ "advance": "Anzahlung {number} vom {date}",
45
+ "advance_vat": "inkl. MWST {vat}",
46
+ "proforma_note": "Zahlungsaufforderung für eine Anzahlung. Die Rechnung folgt mit "
47
+ "der Leistung und rechnet diese Zahlung samt der darin enthaltenen MWST an.",
37
48
  },
38
49
  "en": {
39
50
  "invoice": "Invoice",
@@ -58,6 +69,15 @@ LABELS = {
58
69
  "export_note": "Not subject to Swiss VAT: place of supply abroad "
59
70
  "(Art. 8 para. 1 Swiss VAT Act).",
60
71
  "reverse_charge": "Reverse charge — VAT to be accounted for by the recipient.",
72
+ "proforma": "Pro forma invoice",
73
+ "proforma_no": "Pro forma no.",
74
+ "proforma_date": "Date",
75
+ "amount_due": "Amount due",
76
+ "invoice_total": "Invoice total",
77
+ "advance": "Advance {number} received {date}",
78
+ "advance_vat": "incl. VAT {vat}",
79
+ "proforma_note": "Request for an advance payment. The invoice follows with the "
80
+ "supply and credits this payment, including the VAT it contains.",
61
81
  },
62
82
  "es": {
63
83
  "invoice": "Factura",
@@ -83,6 +103,15 @@ LABELS = {
83
103
  "extranjero (art. 8 apdo. 1 de la Ley suiza del IVA).",
84
104
  "reverse_charge": "Inversión del sujeto pasivo — el IVA debe ser "
85
105
  "liquidado por el destinatario.",
106
+ "proforma": "Factura proforma",
107
+ "proforma_no": "N.º de proforma",
108
+ "proforma_date": "Fecha",
109
+ "amount_due": "Importe a pagar",
110
+ "invoice_total": "Total factura",
111
+ "advance": "Anticipo {number} recibido el {date}",
112
+ "advance_vat": "IVA incl. {vat}",
113
+ "proforma_note": "Solicitud de pago anticipado. La factura se emitirá con la "
114
+ "prestación y descontará este pago, incluido el IVA que contiene.",
86
115
  },
87
116
  }
88
117
 
@@ -279,6 +279,22 @@ class LineItem(BaseModel):
279
279
  return (self.quantity * self.unit_price).quantize(Decimal("0.01"), ROUND_HALF_UP)
280
280
 
281
281
 
282
+ class Advance(BaseModel):
283
+ """A payment received against a pro forma, credited on the final invoice."""
284
+
285
+ model_config = ConfigDict(extra="forbid")
286
+
287
+ proforma: str = Field(min_length=1, description="The pro forma's number, e.g. PF2026001.")
288
+ received: date = Field(description="The day the payment arrived.")
289
+ amount: Decimal = Field(
290
+ gt=0,
291
+ description=(
292
+ "What was received, in the invoice currency, VAT included — the "
293
+ "amount the advance was booked at."
294
+ ),
295
+ )
296
+
297
+
282
298
  class VatBlock(BaseModel):
283
299
  rate: Decimal | None = Field(
284
300
  default=None,
@@ -336,6 +352,23 @@ class Invoice(BaseModel):
336
352
  ),
337
353
  )
338
354
  notes: list[str] = Field(default_factory=list)
355
+ proforma: bool = Field(
356
+ default=False,
357
+ description=(
358
+ "A pro forma: a request for an advance payment, not the invoice. It "
359
+ "is titled and marked as such, carries no Swico billing information "
360
+ "(the payer's software would book it as an invoice), and is booked "
361
+ "only when paid, as a customer advance. Give it its own number "
362
+ "series (PF2026001)."
363
+ ),
364
+ )
365
+ advances: list[Advance] = Field(
366
+ default_factory=list,
367
+ description=(
368
+ "Payments received against pro formas, credited on this invoice: it "
369
+ "shows VAT on the full supply, subtracts them, and asks for the rest."
370
+ ),
371
+ )
339
372
  round_5: bool | None = None # None → 0.05 rounding iff currency is CHF
340
373
  terms_days: int | None = 30 # None → no payment-terms line
341
374
  # Export only. None → reverse charge expected (EU B2B default); requires the
@@ -369,6 +402,14 @@ class Invoice(BaseModel):
369
402
  )
370
403
  return self
371
404
 
405
+ @model_validator(mode="after")
406
+ def _proforma_has_no_advances(self) -> Invoice:
407
+ if self.proforma and self.advances:
408
+ raise ValueError(
409
+ "a pro forma cannot credit advances — list them on the invoice that follows it"
410
+ )
411
+ return self
412
+
372
413
  @model_validator(mode="before")
373
414
  @classmethod
374
415
  def _reject_legacy_language(cls, data: Any) -> Any:
@@ -611,6 +652,10 @@ class Totals(BaseModel):
611
652
  unrounded: Decimal
612
653
  rounding: Decimal
613
654
  grand_total: Decimal
655
+ # Advances received against pro formas, VAT included: their VAT was
656
+ # declared when received (see `draft.build_draft`).
657
+ advances: Decimal = Decimal("0")
658
+ amount_due: Decimal # grand_total − advances: what the payment part asks for
614
659
  # Whether the issuer was VAT-registered on the issue date. When not, the
615
660
  # invoice must not mention the tax at all (Art. 27 Abs. 1 MWSTG).
616
661
  vat_registered: bool = True
@@ -696,6 +741,11 @@ def load_invoice(path: Path, customers: CustomerRegistry | None = None) -> Invoi
696
741
  # ── computation ───────────────────────────────────────────────────────────────
697
742
 
698
743
 
744
+ def vat_included(gross: Decimal, rate: Decimal) -> Decimal:
745
+ """The VAT within a gross amount at `rate` per cent (8.1 → 81 of 1081)."""
746
+ return (gross * rate / (100 + rate)).quantize(Decimal("0.01"), ROUND_HALF_UP)
747
+
748
+
699
749
  def compute(inv: Invoice, vat_registered: bool = True) -> Totals:
700
750
  """The invoice's figures. ``vat_registered`` is the issuer's status on the
701
751
  issue date: a business that is not registered charges no VAT."""
@@ -708,6 +758,12 @@ def compute(inv: Invoice, vat_registered: bool = True) -> Totals:
708
758
  vat = (subtotal * rate / 100).quantize(Decimal("0.01"), ROUND_HALF_UP)
709
759
  unrounded = subtotal + vat
710
760
  grand = round_step(unrounded, Decimal("0.05") if inv.rounds_to_5 else Decimal("0"))
761
+ advances = sum((a.amount for a in inv.advances), Decimal("0"))
762
+ if advances > grand:
763
+ raise ValueError(
764
+ f"invoice {inv.number}: advances of {advances} exceed its total of {grand} "
765
+ f"{inv.currency} — refund the difference or issue a credit note"
766
+ )
711
767
  return Totals(
712
768
  subtotal=subtotal,
713
769
  vat_rate=rate,
@@ -715,5 +771,7 @@ def compute(inv: Invoice, vat_registered: bool = True) -> Totals:
715
771
  unrounded=unrounded,
716
772
  rounding=grand - unrounded,
717
773
  grand_total=grand,
774
+ advances=advances,
775
+ amount_due=grand - advances,
718
776
  vat_registered=vat_registered,
719
777
  )
@@ -31,6 +31,14 @@ def _structured(name: str, lines: Sequence[str], country: str) -> dict[str, str
31
31
  f"like '8000 Zürich' (got {lines})"
32
32
  )
33
33
  street_lines = [ln for i, ln in enumerate(lines) if i != postal_idx]
34
+ if len(street_lines) > 1:
35
+ # Structured addresses carry one street line; c/o or Postfach can't follow.
36
+ warnings.warn(
37
+ f"{name}: the QR-bill's structured address takes one street line, so "
38
+ f"{street_lines[1:]} is left off the payment part (the invoice still "
39
+ f"prints it) — put the line the post delivers to first",
40
+ stacklevel=3,
41
+ )
34
42
  if street_lines:
35
43
  m = re.match(r"^(.*?)[\s,]+(\d+\w*)$", street_lines[0]) # split trailing house no
36
44
  street, house = (m.group(1), m.group(2)) if m else (street_lines[0], "")
@@ -54,14 +62,18 @@ def build_bill(inv: Invoice, issuer: Issuer, account: BankAccount, totals: Total
54
62
  # statement, so it carries the invoice number; the structured billing
55
63
  # information is for the payer's software and carries everything else.
56
64
  message = inv.number
57
- billing = swico.billing_information(inv, issuer, totals, message) or ""
65
+ # A pro forma carries none: its /10/ invoice number, date and VAT rate are
66
+ # what the payer's software books an invoice — and input tax — from.
67
+ billing = "" if inv.proforma else swico.billing_information(inv, issuer, totals, message) or ""
68
+ creditor = _structured(issuer.name, issuer.address, issuer.country)
69
+ debtor = _structured(customer.name, customer.address, customer.country)
58
70
  with warnings.catch_warnings():
59
71
  warnings.simplefilter("ignore")
60
72
  bill = QRBill(
61
73
  account=ref_mod.creditor_iban(account, payment_ref.kind, currency),
62
- creditor=_structured(issuer.name, issuer.address, issuer.country),
63
- debtor=_structured(customer.name, customer.address, customer.country),
64
- amount=f"{totals.grand_total:.2f}",
74
+ creditor=creditor,
75
+ debtor=debtor,
76
+ amount=f"{totals.amount_due:.2f}",
65
77
  currency=currency,
66
78
  reference_number=payment_ref.value,
67
79
  additional_information=message,