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.
@@ -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()
@@ -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)