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.
- {quints-0.18.0 → quints-0.19.0}/PKG-INFO +1 -1
- {quints-0.18.0 → quints-0.19.0}/examples/AGENTS.md +8 -0
- {quints-0.18.0 → quints-0.19.0}/examples/accounts.bean +4 -0
- {quints-0.18.0 → quints-0.19.0}/examples/quints.toml +2 -0
- {quints-0.18.0 → quints-0.19.0}/pyproject.toml +1 -1
- {quints-0.18.0 → quints-0.19.0}/src/quints/cli.py +59 -26
- {quints-0.18.0 → quints-0.19.0}/src/quints/config.py +21 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/fx.py +2 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/init.py +14 -0
- quints-0.19.0/src/quints/invoice/draft.py +151 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/labels.py +31 -2
- {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/model.py +58 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/qr.py +16 -4
- {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/render.py +61 -19
- {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/template.typ +20 -4
- quints-0.19.0/src/quints/invoice/verify.py +198 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/kmu.py +15 -3
- {quints-0.18.0 → quints-0.19.0}/src/quints/mwst.py +21 -2
- {quints-0.18.0 → quints-0.19.0}/src/quints/report.typ +32 -9
- {quints-0.18.0 → quints-0.19.0}/src/quints/report_pdf.py +80 -13
- {quints-0.18.0 → quints-0.19.0}/tests/test_invoice.py +12 -0
- {quints-0.18.0 → quints-0.19.0}/tests/test_kmu_report.py +19 -2
- quints-0.19.0/tests/test_proforma.py +485 -0
- {quints-0.18.0 → quints-0.19.0}/tests/test_report_pdf.py +32 -0
- quints-0.18.0/src/quints/invoice/draft.py +0 -60
- quints-0.18.0/src/quints/invoice/verify.py +0 -66
- {quints-0.18.0 → quints-0.19.0}/.gitignore +0 -0
- {quints-0.18.0 → quints-0.19.0}/LICENSE +0 -0
- {quints-0.18.0 → quints-0.19.0}/README.md +0 -0
- {quints-0.18.0 → quints-0.19.0}/examples/.gitignore +0 -0
- {quints-0.18.0 → quints-0.19.0}/examples/CLAUDE.md +0 -0
- {quints-0.18.0 → quints-0.19.0}/examples/answers.toml +0 -0
- {quints-0.18.0 → quints-0.19.0}/examples/books/2026.bean +0 -0
- {quints-0.18.0 → quints-0.19.0}/examples/commodities.bean +0 -0
- {quints-0.18.0 → quints-0.19.0}/examples/documents/.gitkeep +0 -0
- {quints-0.18.0 → quints-0.19.0}/examples/inbox/.gitkeep +0 -0
- {quints-0.18.0 → quints-0.19.0}/examples/invoicing/acme-2026-07.yaml +0 -0
- {quints-0.18.0 → quints-0.19.0}/examples/invoicing/customers.yaml +0 -0
- {quints-0.18.0 → quints-0.19.0}/examples/invoicing/globex-2026-08.yaml +0 -0
- {quints-0.18.0 → quints-0.19.0}/examples/invoicing/issuer.yaml +0 -0
- {quints-0.18.0 → quints-0.19.0}/examples/invoicing/wordmark.svg +0 -0
- {quints-0.18.0 → quints-0.19.0}/examples/main.bean +0 -0
- {quints-0.18.0 → quints-0.19.0}/examples/prices.bean +0 -0
- {quints-0.18.0 → quints-0.19.0}/examples/pyproject.toml +0 -0
- {quints-0.18.0 → quints-0.19.0}/examples/staging/.gitkeep +0 -0
- {quints-0.18.0 → quints-0.19.0}/examples/statements/ubs-2026.mt940 +0 -0
- {quints-0.18.0 → quints-0.19.0}/examples/statements/yapeal.csv +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/__init__.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/closing.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/fava/__init__.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/fava/templates/QuintDashboard.html +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/importing.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/inbox.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/init_assets/wordmark.svg +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/__init__.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/bank.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/README.md +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/geist/Geist-Bold.otf +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/geist/Geist-Medium.otf +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/geist/Geist-Regular.otf +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/geist/Geist-SemiBold.otf +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/geist/OFL.txt +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/geist-mono/GeistMono-Medium.otf +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/geist-mono/GeistMono-Regular.otf +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/geist-mono/GeistMono-SemiBold.otf +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/geist-mono/OFL.txt +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/liberation/LICENSE +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/liberation/LiberationSans-Bold.ttf +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/liberation/LiberationSans-Regular.ttf +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/newsreader/Newsreader-Medium.ttf +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/newsreader/Newsreader-Regular.ttf +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/newsreader/Newsreader-SemiBold.ttf +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/fonts/newsreader/OFL.txt +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/reference.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/swico.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/invoice/vatid.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/ledger.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/liability.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/match.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/payables.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/plugins/__init__.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/plugins/kmu.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/prices.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/receivables.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/settlement.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/ui.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/src/quints/vat.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/tests/fixtures/transactions.mt940 +0 -0
- {quints-0.18.0 → quints-0.19.0}/tests/test_cli_errors.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/tests/test_cli_json.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/tests/test_closing.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/tests/test_config.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/tests/test_config_strict.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/tests/test_docs.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/tests/test_durable_writes.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/tests/test_fava_ext.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/tests/test_fx.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/tests/test_importing.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/tests/test_importing_stripe_invoices.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/tests/test_inbox_match.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/tests/test_init.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/tests/test_kmu_plugin.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/tests/test_ledger.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/tests/test_loader_errors.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/tests/test_money_defaults.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/tests/test_mwst.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/tests/test_payables.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/tests/test_prices_ecosystem.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/tests/test_prices_sync.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/tests/test_receivables.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/tests/test_saldo.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/tests/test_settlement.py +0 -0
- {quints-0.18.0 → quints-0.19.0}/tests/test_statements_crosscheck.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
249
|
-
"""
|
|
250
|
-
when it is CHF already
|
|
251
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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
|
|
848
|
-
# account
|
|
849
|
-
|
|
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(
|
|
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} · {
|
|
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
|
-
|
|
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
|
-
|
|
952
|
-
|
|
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
|
|
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
|
-
|
|
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=
|
|
63
|
-
debtor=
|
|
64
|
-
amount=f"{totals.
|
|
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,
|