quints 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- quints/cli.py +887 -0
- quints/config.py +214 -0
- quints/fava/__init__.py +65 -0
- quints/fava/templates/QuintDashboard.html +85 -0
- quints/fx.py +164 -0
- quints/importing.py +393 -0
- quints/inbox.py +127 -0
- quints/init.py +462 -0
- quints/invoice/__init__.py +1 -0
- quints/invoice/draft.py +38 -0
- quints/invoice/labels.py +84 -0
- quints/invoice/model.py +333 -0
- quints/invoice/qr.py +67 -0
- quints/invoice/render.py +163 -0
- quints/invoice/template.typ +140 -0
- quints/invoice/vatid.py +48 -0
- quints/invoice/verify.py +60 -0
- quints/kmu.py +712 -0
- quints/ledger.py +59 -0
- quints/match.py +245 -0
- quints/mwst.py +353 -0
- quints/plugins/__init__.py +1 -0
- quints/plugins/kmu.py +58 -0
- quints/prices.py +114 -0
- quints/receivables.py +132 -0
- quints/report.typ +55 -0
- quints/report_pdf.py +183 -0
- quints/settlement.py +225 -0
- quints/ui.py +34 -0
- quints/vat.py +88 -0
- quints-0.1.0.dist-info/METADATA +60 -0
- quints-0.1.0.dist-info/RECORD +36 -0
- quints-0.1.0.dist-info/WHEEL +4 -0
- quints-0.1.0.dist-info/entry_points.txt +2 -0
- quints-0.1.0.dist-info/licenses/LICENSE +339 -0
quints/invoice/model.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Invoice data model (Pydantic), multi-format loading, and computation.
|
|
2
|
+
|
|
3
|
+
Authoring files (invoice, issuer, customer registry) may be YAML, TOML, or
|
|
4
|
+
JSON — picked by file extension. The Pydantic models double as the JSON
|
|
5
|
+
Schema source (`quints schema`), so the CLI, editors, and any future UI share
|
|
6
|
+
one contract.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from datetime import date
|
|
14
|
+
from decimal import ROUND_HALF_UP, Decimal
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Literal
|
|
17
|
+
|
|
18
|
+
import yaml
|
|
19
|
+
from pydantic import BaseModel, Field, RootModel, field_validator, model_validator
|
|
20
|
+
|
|
21
|
+
if sys.version_info >= (3, 11):
|
|
22
|
+
import tomllib
|
|
23
|
+
else: # pragma: no cover
|
|
24
|
+
import tomli as tomllib
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def money(v: Decimal, locale: str = "de_CH") -> str:
|
|
28
|
+
"""Amount formatted for `locale`, always two decimals.
|
|
29
|
+
|
|
30
|
+
de_CH → 5'059.10, en → 5,059.10, es_ES → 5.059,10. CLDR types the Swiss
|
|
31
|
+
group separator as U+2019/U+02BC; we normalise it to a plain ASCII
|
|
32
|
+
apostrophe — how Swiss invoices are typeset, and a stable copy-paste."""
|
|
33
|
+
from babel.numbers import format_decimal
|
|
34
|
+
|
|
35
|
+
s = format_decimal(v, format="#,##0.00", locale=locale)
|
|
36
|
+
return s.replace("’", "'").replace("ʼ", "'")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def number(v: Decimal, locale: str = "de_CH") -> str:
|
|
40
|
+
"""A bare decimal for `locale`, trailing zeros trimmed (quantities, rates).
|
|
41
|
+
|
|
42
|
+
es_ES → 2,5 / 8,1; de_CH → 2.5 / 8.1."""
|
|
43
|
+
from babel.numbers import format_decimal
|
|
44
|
+
|
|
45
|
+
return format_decimal(v, locale=locale)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def round_step(v: Decimal, step: Decimal) -> Decimal:
|
|
49
|
+
"""Round to the nearest `step` (e.g. 0.05 cash rounding); 0 → 2 decimals."""
|
|
50
|
+
if step == 0:
|
|
51
|
+
return v.quantize(Decimal("0.01"), ROUND_HALF_UP)
|
|
52
|
+
return (v / step).quantize(Decimal("1"), ROUND_HALF_UP) * step
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── Swiss QRR reference (ESR mod-10 recursive check digit) ─────────────────────
|
|
56
|
+
|
|
57
|
+
_MOD10 = [0, 9, 4, 6, 8, 2, 7, 1, 3, 5]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def qrr_check_digit(number: str) -> str:
|
|
61
|
+
carry = 0
|
|
62
|
+
for ch in number:
|
|
63
|
+
carry = _MOD10[(carry + int(ch)) % 10]
|
|
64
|
+
return str((10 - carry) % 10)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def make_qrr(base: str) -> str:
|
|
68
|
+
"""A valid 27-digit QR-reference from an arbitrary string's digits."""
|
|
69
|
+
digits = "".join(c for c in base if c.isdigit())[:26].rjust(26, "0")
|
|
70
|
+
return digits + qrr_check_digit(digits)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def make_scor(base: str) -> str:
|
|
74
|
+
"""ISO 11649 Creditor Reference (SCOR) from an alphanumeric string.
|
|
75
|
+
|
|
76
|
+
Valid with a regular IBAN on QR-bills and in SEPA credit transfers
|
|
77
|
+
(check digits per modulo 97-10, like an IBAN)."""
|
|
78
|
+
ref = "".join(c for c in base if c.isalnum()).upper()[:21]
|
|
79
|
+
if not ref:
|
|
80
|
+
raise ValueError("SCOR reference needs at least one alphanumeric character")
|
|
81
|
+
num = "".join(str(int(c, 36)) for c in ref + "RF00")
|
|
82
|
+
return f"RF{98 - int(num) % 97:02d}{ref}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── model ─────────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Party(BaseModel):
|
|
89
|
+
name: str
|
|
90
|
+
address: list[str] = Field(min_length=1)
|
|
91
|
+
country: str = "CH"
|
|
92
|
+
vat_id: str | None = None
|
|
93
|
+
|
|
94
|
+
@model_validator(mode="after")
|
|
95
|
+
def _vat_id_checksum(self):
|
|
96
|
+
if self.vat_id:
|
|
97
|
+
from . import vatid
|
|
98
|
+
|
|
99
|
+
vatid.validate(self.vat_id, self.country)
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class CustomerVersion(Party):
|
|
104
|
+
valid_from: date | None = None # None → valid since forever
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Customer(BaseModel):
|
|
108
|
+
"""Registry entry: a flat party, or a dated `versions` history."""
|
|
109
|
+
|
|
110
|
+
name: str | None = None
|
|
111
|
+
address: list[str] | None = None
|
|
112
|
+
country: str = "CH"
|
|
113
|
+
vat_id: str | None = None
|
|
114
|
+
versions: list[CustomerVersion] = Field(default_factory=list)
|
|
115
|
+
|
|
116
|
+
@model_validator(mode="after")
|
|
117
|
+
def _flat_or_versioned(self):
|
|
118
|
+
if bool(self.versions) == bool(self.name):
|
|
119
|
+
raise ValueError("customer needs either flat fields or `versions`, not both")
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
def at(self, on: date) -> Party:
|
|
123
|
+
"""The party data in force on `on` (for re-rendering old invoices)."""
|
|
124
|
+
if not self.versions:
|
|
125
|
+
return Party(
|
|
126
|
+
name=self.name, address=self.address, country=self.country, vat_id=self.vat_id
|
|
127
|
+
)
|
|
128
|
+
live = [
|
|
129
|
+
v
|
|
130
|
+
for v in sorted(self.versions, key=lambda v: v.valid_from or date.min)
|
|
131
|
+
if (v.valid_from or date.min) <= on
|
|
132
|
+
]
|
|
133
|
+
if not live:
|
|
134
|
+
raise ValueError(f"no customer version valid on {on}")
|
|
135
|
+
v = live[-1]
|
|
136
|
+
return Party(name=v.name, address=v.address, country=v.country, vat_id=v.vat_id)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class CustomerRegistry(RootModel[dict[str, Customer]]):
|
|
140
|
+
def resolve(self, ref: str, on: date) -> Party:
|
|
141
|
+
try:
|
|
142
|
+
return self.root[ref].at(on)
|
|
143
|
+
except KeyError:
|
|
144
|
+
raise ValueError(
|
|
145
|
+
f"unknown customer {ref!r} (registry has: {', '.join(sorted(self.root)) or 'none'})"
|
|
146
|
+
) from None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class LineItem(BaseModel):
|
|
150
|
+
description: str
|
|
151
|
+
quantity: Decimal
|
|
152
|
+
unit_price: Decimal
|
|
153
|
+
unit: str = ""
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def total(self) -> Decimal:
|
|
157
|
+
return (self.quantity * self.unit_price).quantize(Decimal("0.01"), ROUND_HALF_UP)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class VatBlock(BaseModel):
|
|
161
|
+
rate: Decimal = Decimal("8.1")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class Invoice(BaseModel):
|
|
165
|
+
number: str
|
|
166
|
+
kind: Literal["domestic", "export"]
|
|
167
|
+
currency: str = Field(pattern=r"^[A-Z]{3}$")
|
|
168
|
+
issue_date: date
|
|
169
|
+
customer: str | Party # str → key into the customer registry
|
|
170
|
+
items: list[LineItem] = Field(min_length=1)
|
|
171
|
+
supply: str = ""
|
|
172
|
+
locale: str = "de_CH" # CLDR locale for labels + number/date formatting
|
|
173
|
+
vat: VatBlock = Field(default_factory=VatBlock)
|
|
174
|
+
reference: str | None = None
|
|
175
|
+
notes: list[str] = Field(default_factory=list)
|
|
176
|
+
round_5: bool | None = None # None → 0.05 rounding iff currency is CHF
|
|
177
|
+
terms_days: int | None = 30 # None → no payment-terms line
|
|
178
|
+
# Export only. None → reverse charge expected (EU B2B default); requires the
|
|
179
|
+
# customer's VAT number (Art. 196, 226(4)+(11a) EU VAT Directive). Set false
|
|
180
|
+
# for customers outside a reverse-charge regime (e.g. US) to drop the note.
|
|
181
|
+
reverse_charge: bool | None = None
|
|
182
|
+
|
|
183
|
+
@model_validator(mode="before")
|
|
184
|
+
@classmethod
|
|
185
|
+
def _reject_legacy_language(cls, data: Any) -> Any:
|
|
186
|
+
if isinstance(data, dict) and "language" in data:
|
|
187
|
+
raise ValueError(
|
|
188
|
+
"`language` is replaced by `locale` — use a full CLDR locale, "
|
|
189
|
+
"e.g. locale: es_ES (Spanish/Spain), de_CH, or en"
|
|
190
|
+
)
|
|
191
|
+
return data
|
|
192
|
+
|
|
193
|
+
@field_validator("locale")
|
|
194
|
+
@classmethod
|
|
195
|
+
def _known_locale(cls, v: str) -> str:
|
|
196
|
+
from babel import Locale, UnknownLocaleError
|
|
197
|
+
|
|
198
|
+
from .labels import LABELS
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
Locale.parse(v)
|
|
202
|
+
except (ValueError, UnknownLocaleError) as e:
|
|
203
|
+
raise ValueError(f"unknown locale {v!r} (e.g. de_CH, en, es_ES)") from e
|
|
204
|
+
lang = v.split("_")[0]
|
|
205
|
+
if lang not in LABELS:
|
|
206
|
+
raise ValueError(
|
|
207
|
+
f"no invoice labels for language {lang!r} of locale {v!r} "
|
|
208
|
+
f"(available: {', '.join(sorted(LABELS))})"
|
|
209
|
+
)
|
|
210
|
+
return v
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def language(self) -> str:
|
|
214
|
+
"""The label language: the locale's language subtag (es_ES → es)."""
|
|
215
|
+
return self.locale.split("_")[0]
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def vat_rate(self) -> Decimal:
|
|
219
|
+
return self.vat.rate
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def rounds_to_5(self) -> bool:
|
|
223
|
+
return self.round_5 if self.round_5 is not None else self.currency == "CHF"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class BankAccount(BaseModel):
|
|
227
|
+
iban: str = "" # regular IBAN — SEPA/international credit transfers
|
|
228
|
+
qr_iban: str = "" # QR-IID variant — Swiss QR-bill with QRR reference ONLY
|
|
229
|
+
bic: str | None = None
|
|
230
|
+
|
|
231
|
+
@field_validator("iban", "qr_iban")
|
|
232
|
+
@classmethod
|
|
233
|
+
def _strip(cls, v: str) -> str:
|
|
234
|
+
return v.replace(" ", "")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class Brand(BaseModel):
|
|
238
|
+
accent: str = "#123c3a"
|
|
239
|
+
font: str = "Liberation Sans"
|
|
240
|
+
font_display: str | None = None # title/wordmark family; defaults to `font`
|
|
241
|
+
font_display_stretch: int = 100 # CSS-style font-stretch % (125 → Expanded cut)
|
|
242
|
+
font_dir: str | None = None # bundled fonts dir passed to typst (repo-relative)
|
|
243
|
+
logo: str | None = None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class Issuer(BaseModel):
|
|
247
|
+
name: str
|
|
248
|
+
address: list[str] = Field(min_length=1)
|
|
249
|
+
vat_id: str
|
|
250
|
+
country: str = "CH"
|
|
251
|
+
email: str | None = None
|
|
252
|
+
phone: str | None = None
|
|
253
|
+
bank: dict[str, BankAccount] = Field(default_factory=dict)
|
|
254
|
+
brand: Brand = Field(default_factory=Brand)
|
|
255
|
+
|
|
256
|
+
def account(self, currency: str) -> BankAccount:
|
|
257
|
+
try:
|
|
258
|
+
return self.bank[currency]
|
|
259
|
+
except KeyError:
|
|
260
|
+
raise ValueError(
|
|
261
|
+
f"no bank account configured for {currency} "
|
|
262
|
+
f"(issuer has: {', '.join(sorted(self.bank)) or 'none'})"
|
|
263
|
+
) from None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class Totals(BaseModel):
|
|
267
|
+
subtotal: Decimal
|
|
268
|
+
vat_rate: Decimal
|
|
269
|
+
vat_amount: Decimal
|
|
270
|
+
unrounded: Decimal
|
|
271
|
+
rounding: Decimal
|
|
272
|
+
grand_total: Decimal
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ── loading ───────────────────────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def load_mapping(path: Path) -> dict:
|
|
279
|
+
"""Parse a .yaml/.yml/.toml/.json file into a plain mapping."""
|
|
280
|
+
suffix = path.suffix.lower()
|
|
281
|
+
if suffix in (".yaml", ".yml"):
|
|
282
|
+
return yaml.safe_load(path.read_text()) or {}
|
|
283
|
+
if suffix == ".toml":
|
|
284
|
+
with open(path, "rb") as f:
|
|
285
|
+
return tomllib.load(f)
|
|
286
|
+
if suffix == ".json":
|
|
287
|
+
return json.loads(path.read_text())
|
|
288
|
+
raise ValueError(f"unsupported invoice file format {suffix!r} (use .yaml/.toml/.json)")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def load_issuer(path: Path) -> Issuer:
|
|
292
|
+
return Issuer.model_validate(load_mapping(path))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def load_customers(path: Path) -> CustomerRegistry:
|
|
296
|
+
return CustomerRegistry.model_validate(load_mapping(path))
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def load_invoice(path: Path, customers: CustomerRegistry | None = None) -> Invoice:
|
|
300
|
+
"""Load an invoice; a string `customer` is resolved through the registry
|
|
301
|
+
as of the issue date (old invoices keep the address in force back then)."""
|
|
302
|
+
inv = Invoice.model_validate(load_mapping(path))
|
|
303
|
+
if isinstance(inv.customer, str):
|
|
304
|
+
if customers is None:
|
|
305
|
+
raise ValueError(
|
|
306
|
+
f"invoice references customer {inv.customer!r} but no customer "
|
|
307
|
+
f"registry was found (invoicing/customers.yaml)"
|
|
308
|
+
)
|
|
309
|
+
inv.customer = customers.resolve(inv.customer, inv.issue_date)
|
|
310
|
+
return inv
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ── computation ───────────────────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def compute(inv: Invoice) -> Totals:
|
|
317
|
+
subtotal = sum((it.total for it in inv.items), Decimal("0"))
|
|
318
|
+
if inv.kind == "export":
|
|
319
|
+
rate = Decimal("0")
|
|
320
|
+
vat = Decimal("0")
|
|
321
|
+
else:
|
|
322
|
+
rate = inv.vat_rate
|
|
323
|
+
vat = (subtotal * rate / 100).quantize(Decimal("0.01"), ROUND_HALF_UP)
|
|
324
|
+
unrounded = subtotal + vat
|
|
325
|
+
grand = round_step(unrounded, Decimal("0.05") if inv.rounds_to_5 else Decimal("0"))
|
|
326
|
+
return Totals(
|
|
327
|
+
subtotal=subtotal,
|
|
328
|
+
vat_rate=rate,
|
|
329
|
+
vat_amount=vat,
|
|
330
|
+
unrounded=unrounded,
|
|
331
|
+
rounding=grand - unrounded,
|
|
332
|
+
grand_total=grand,
|
|
333
|
+
)
|
quints/invoice/qr.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Swiss QR-bill construction via the qrbill library (domestic CHF invoices)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import warnings
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from qrbill import QRBill
|
|
11
|
+
|
|
12
|
+
from .model import BankAccount, Invoice, Issuer, make_qrr, make_scor
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _structured(name: str, lines, country: str) -> dict:
|
|
16
|
+
"""Parse free-form address lines into qrbill's structured address dict."""
|
|
17
|
+
lines = [ln.strip() for ln in lines if ln and ln.strip()]
|
|
18
|
+
street = house = pcode = city = ""
|
|
19
|
+
postal_idx = None
|
|
20
|
+
for i, ln in enumerate(lines):
|
|
21
|
+
m = re.match(r"^([A-Za-z]{0,3}[- ]?\d{3,6})\s+(.+)$", ln) # e.g. "3000 Bern"
|
|
22
|
+
if m:
|
|
23
|
+
pcode, city, postal_idx = m.group(1), m.group(2), i
|
|
24
|
+
break
|
|
25
|
+
street_lines = [ln for i, ln in enumerate(lines) if i != postal_idx]
|
|
26
|
+
if street_lines:
|
|
27
|
+
m = re.match(r"^(.*?)[\s,]+(\d+\w*)$", street_lines[0]) # split trailing house no
|
|
28
|
+
street, house = (m.group(1), m.group(2)) if m else (street_lines[0], "")
|
|
29
|
+
return {
|
|
30
|
+
"name": name,
|
|
31
|
+
"street": street or None,
|
|
32
|
+
"house_num": house or None,
|
|
33
|
+
"pcode": pcode or "0",
|
|
34
|
+
"city": city or name,
|
|
35
|
+
"country": country,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def reference_for(inv: Invoice, account: BankAccount) -> str | None:
|
|
40
|
+
if inv.reference:
|
|
41
|
+
return inv.reference
|
|
42
|
+
if account.qr_iban:
|
|
43
|
+
return make_qrr(inv.number) # QRR — mandatory with a QR-IBAN
|
|
44
|
+
return make_scor(inv.number) # SCOR (ISO 11649) with a regular IBAN
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_bill(inv: Invoice, issuer: Issuer, account: BankAccount, grand: Decimal) -> QRBill:
|
|
48
|
+
with warnings.catch_warnings():
|
|
49
|
+
warnings.simplefilter("ignore")
|
|
50
|
+
return QRBill(
|
|
51
|
+
account=account.qr_iban or account.iban,
|
|
52
|
+
creditor=_structured(issuer.name, issuer.address, issuer.country),
|
|
53
|
+
debtor=_structured(inv.customer.name, inv.customer.address, inv.customer.country),
|
|
54
|
+
amount=f"{grand:.2f}",
|
|
55
|
+
currency=inv.currency,
|
|
56
|
+
reference_number=reference_for(inv, account),
|
|
57
|
+
additional_information=f"{inv.number}",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def write_svg(bill: QRBill, path: Path) -> None:
|
|
62
|
+
with open(path, "w") as f:
|
|
63
|
+
bill.as_svg(f)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def payload(bill: QRBill) -> str:
|
|
67
|
+
return bill.qr_data()
|
quints/invoice/render.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Render an invoice to PDF: build a data context, run Typst via typst-py."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
from datetime import date
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import typst
|
|
12
|
+
from babel.dates import format_date
|
|
13
|
+
|
|
14
|
+
from . import qr
|
|
15
|
+
from .labels import labels as get_labels
|
|
16
|
+
from .model import Invoice, Issuer, Totals, compute, make_scor, money, number
|
|
17
|
+
|
|
18
|
+
TEMPLATE = Path(__file__).parent / "template.typ"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _fmt_iban(iban: str) -> str:
|
|
22
|
+
s = iban.replace(" ", "")
|
|
23
|
+
return " ".join(s[i : i + 4] for i in range(0, len(s), 4))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _fmt_date(d: date, locale: str) -> str:
|
|
27
|
+
"""CLDR medium date for the invoice locale (de_CH → 02.07.2026,
|
|
28
|
+
es_ES → 2 jul 2026). The locale is validated on the model, so it resolves."""
|
|
29
|
+
return format_date(d, format="medium", locale=locale)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_context(
|
|
33
|
+
inv: Invoice, issuer: Issuer, totals: Totals, payment: dict, reverse_charge: bool = False
|
|
34
|
+
) -> dict:
|
|
35
|
+
lbl = get_labels(inv.language)
|
|
36
|
+
return {
|
|
37
|
+
"reverse_charge": reverse_charge,
|
|
38
|
+
"lang": inv.language,
|
|
39
|
+
"kind": inv.kind,
|
|
40
|
+
"currency": inv.currency,
|
|
41
|
+
"labels": lbl,
|
|
42
|
+
"issuer": {
|
|
43
|
+
"name": issuer.name,
|
|
44
|
+
"address": issuer.address,
|
|
45
|
+
"vat_id": issuer.vat_id,
|
|
46
|
+
"country": issuer.country,
|
|
47
|
+
"email": issuer.email,
|
|
48
|
+
"phone": issuer.phone,
|
|
49
|
+
},
|
|
50
|
+
"customer": {
|
|
51
|
+
"name": inv.customer.name,
|
|
52
|
+
"address": inv.customer.address,
|
|
53
|
+
"country": inv.customer.country,
|
|
54
|
+
"vat_id": inv.customer.vat_id,
|
|
55
|
+
},
|
|
56
|
+
"invoice": {
|
|
57
|
+
"number": inv.number,
|
|
58
|
+
"supply": inv.supply,
|
|
59
|
+
"issue_date": _fmt_date(inv.issue_date, inv.locale),
|
|
60
|
+
},
|
|
61
|
+
"terms": (lbl["terms"].format(days=inv.terms_days) if inv.terms_days is not None else None),
|
|
62
|
+
"items": [
|
|
63
|
+
{
|
|
64
|
+
"pos": i + 1,
|
|
65
|
+
"description": it.description,
|
|
66
|
+
"quantity": number(it.quantity, inv.locale),
|
|
67
|
+
"unit": it.unit,
|
|
68
|
+
"unit_price": money(it.unit_price, inv.locale),
|
|
69
|
+
"total": money(it.total, inv.locale),
|
|
70
|
+
}
|
|
71
|
+
for i, it in enumerate(inv.items)
|
|
72
|
+
],
|
|
73
|
+
"totals": {
|
|
74
|
+
"subtotal": money(totals.subtotal, inv.locale),
|
|
75
|
+
"vat_rate": number(totals.vat_rate, inv.locale),
|
|
76
|
+
"vat_amount": money(totals.vat_amount, inv.locale),
|
|
77
|
+
"rounding": money(totals.rounding, inv.locale),
|
|
78
|
+
"show_rounding": totals.rounding != 0,
|
|
79
|
+
"grand_total": money(totals.grand_total, inv.locale),
|
|
80
|
+
"export": inv.kind == "export",
|
|
81
|
+
},
|
|
82
|
+
"payment": payment,
|
|
83
|
+
"notes": list(inv.notes),
|
|
84
|
+
"brand": {
|
|
85
|
+
"accent": issuer.brand.accent,
|
|
86
|
+
"font": issuer.brand.font,
|
|
87
|
+
"font_display": issuer.brand.font_display or issuer.brand.font,
|
|
88
|
+
"display_stretch": issuer.brand.font_display_stretch,
|
|
89
|
+
"logo": None,
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _compile(main: Path, output: Path, root: Path, font_paths: list) -> None:
|
|
95
|
+
"""Compile to PDF/A-2b (archival) when supported, else a plain PDF."""
|
|
96
|
+
kwargs = {"output": str(output), "root": str(root), "font_paths": [str(p) for p in font_paths]}
|
|
97
|
+
try:
|
|
98
|
+
typst.compile(str(main), pdf_standards="a-2b", **kwargs)
|
|
99
|
+
except (TypeError, ValueError): # older typst-py, or standard unsupported
|
|
100
|
+
typst.compile(str(main), **kwargs)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def render(inv: Invoice, issuer: Issuer, out_path: Path) -> tuple[Path, Totals, str | None]:
|
|
104
|
+
"""Render to `out_path`. Returns (path, totals, qr_payload|None)."""
|
|
105
|
+
totals = compute(inv)
|
|
106
|
+
account = issuer.account(inv.currency)
|
|
107
|
+
|
|
108
|
+
# Reverse charge needs a VAT-identified recipient: Art. 196 EU VAT Directive,
|
|
109
|
+
# and Art. 226(4)+(11a) require the customer's VAT number + mention on the
|
|
110
|
+
# invoice. Default for exports is reverse charge ON; opt out explicitly.
|
|
111
|
+
reverse_charge = False
|
|
112
|
+
if inv.kind == "export":
|
|
113
|
+
reverse_charge = True if inv.reverse_charge is None else inv.reverse_charge
|
|
114
|
+
if reverse_charge and not inv.customer.vat_id:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f"export invoice {inv.number} claims reverse charge but customer "
|
|
117
|
+
f"{inv.customer.name!r} has no vat_id — add their VAT number, or "
|
|
118
|
+
f"set `reverse_charge: false` if they are outside a reverse-charge "
|
|
119
|
+
f"regime (Art. 196 / Art. 226(4) EU VAT Directive)"
|
|
120
|
+
)
|
|
121
|
+
work = Path(tempfile.mkdtemp(prefix="quints-inv-"))
|
|
122
|
+
try:
|
|
123
|
+
qr_payload = None
|
|
124
|
+
if inv.kind == "domestic":
|
|
125
|
+
# Swiss QR-bill: QR-IBAN + QRR if configured, else IBAN + SCOR.
|
|
126
|
+
bill = qr.build_bill(inv, issuer, account, totals.grand_total)
|
|
127
|
+
qr.write_svg(bill, work / "qrbill.svg")
|
|
128
|
+
qr_payload = qr.payload(bill)
|
|
129
|
+
payment = {"type": "qrbill"}
|
|
130
|
+
else:
|
|
131
|
+
# Cross-border: a QR-IBAN only works inside the QR-bill scheme —
|
|
132
|
+
# foreign transfers need the regular IBAN.
|
|
133
|
+
if not account.iban:
|
|
134
|
+
raise ValueError(
|
|
135
|
+
f"export invoice in {inv.currency} needs a regular `iban` "
|
|
136
|
+
f"(a QR-IBAN cannot receive normal credit transfers)"
|
|
137
|
+
)
|
|
138
|
+
payment = {
|
|
139
|
+
"type": "sepa",
|
|
140
|
+
"iban": _fmt_iban(account.iban),
|
|
141
|
+
"bic": account.bic,
|
|
142
|
+
"reference": _fmt_iban(inv.reference or make_scor(inv.number)),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
ctx = build_context(inv, issuer, totals, payment, reverse_charge)
|
|
146
|
+
logo = issuer.brand.logo
|
|
147
|
+
if logo and Path(logo).exists():
|
|
148
|
+
dest = work / ("logo" + Path(logo).suffix)
|
|
149
|
+
shutil.copy(logo, dest)
|
|
150
|
+
ctx["brand"]["logo"] = dest.name
|
|
151
|
+
|
|
152
|
+
(work / "data.json").write_text(json.dumps(ctx, ensure_ascii=False, indent=2))
|
|
153
|
+
shutil.copy(TEMPLATE, work / "template.typ")
|
|
154
|
+
|
|
155
|
+
font_dir = issuer.brand.font_dir
|
|
156
|
+
font_paths = [Path(font_dir)] if font_dir and Path(font_dir).is_dir() else []
|
|
157
|
+
|
|
158
|
+
out_path = Path(out_path)
|
|
159
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
_compile(work / "template.typ", out_path, work, font_paths)
|
|
161
|
+
return out_path, totals, qr_payload
|
|
162
|
+
finally:
|
|
163
|
+
shutil.rmtree(work, ignore_errors=True)
|