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,140 @@
1
+ // quints invoice template — data-driven (reads data.json), two modes.
2
+ #let d = json("data.json")
3
+ #let L = d.labels
4
+ #let accent = rgb(d.brand.accent)
5
+ #let display = (d.brand.font_display, d.brand.font, "Liberation Sans", "Arial")
6
+ #let dstretch = d.brand.display_stretch * 1%
7
+ #let muted = luma(105)
8
+ #let lines(a) = a.join(linebreak())
9
+ #let qrbill = d.payment.type == "qrbill"
10
+
11
+ #set document(title: L.invoice + " " + d.invoice.number, author: d.issuer.name)
12
+ #set page(
13
+ paper: "a4",
14
+ margin: (x: 20mm, top: 18mm, bottom: if qrbill { 108mm } else { 24mm }),
15
+ )
16
+ #set text(font: (d.brand.font, "Liberation Sans", "Arial"), size: 10pt, fill: luma(20))
17
+ #set par(justify: false, leading: 0.62em)
18
+
19
+ // ── Header: wordmark left, issuer identity right (Art. 26 Abs. 2 lit. a MWSTG) ─
20
+ #grid(columns: (1fr, auto),
21
+ align(left + top,
22
+ if d.brand.logo != none {
23
+ image(d.brand.logo, height: 12mm)
24
+ } else {
25
+ text(font: display, stretch: dstretch, size: 15pt, weight: "bold", fill: accent)[#d.issuer.name]
26
+ }
27
+ ),
28
+ align(right + top)[
29
+ #set text(size: 8pt, fill: muted)
30
+ #set par(leading: 0.5em)
31
+ #text(weight: "semibold", fill: luma(60))[#d.issuer.name] \
32
+ #lines(d.issuer.address) \
33
+ #d.issuer.vat_id
34
+ #if d.issuer.email != none [ \ #link("mailto:" + d.issuer.email)[#d.issuer.email] ]
35
+ #if d.issuer.phone != none [ \ #d.issuer.phone ]
36
+ ],
37
+ )
38
+
39
+ // ── Recipient (right, window-envelope position) ───────────────────────
40
+ #place(top + left, dx: 112mm, dy: 34mm, block(width: 78mm)[
41
+ #set text(size: 10pt)
42
+ #strong(d.customer.name) \
43
+ #lines(d.customer.address)
44
+ #if d.customer.country != d.issuer.country [ \ #d.customer.country ]
45
+ #if d.customer.vat_id != none [ \ #d.customer.vat_id ]
46
+ ])
47
+
48
+ #v(38mm)
49
+
50
+ // ── Title + meta ──────────────────────────────────────────────────────
51
+ #text(font: display, stretch: dstretch, size: 20pt, weight: "semibold", fill: accent)[#L.invoice]
52
+ #v(3mm)
53
+ #grid(columns: (auto, auto), column-gutter: 8mm, row-gutter: 3pt,
54
+ text(fill: muted)[#L.invoice_no], text(weight: "bold")[#d.invoice.number],
55
+ text(fill: muted)[#L.date], [#d.invoice.issue_date],
56
+ ..(if d.invoice.supply != "" { (text(fill: muted)[#L.supply], [#d.invoice.supply]) } else { () }),
57
+ )
58
+ #v(7mm)
59
+
60
+ // ── Line items ────────────────────────────────────────────────────────
61
+ #table(
62
+ columns: (auto, 1fr, auto, auto, auto),
63
+ inset: (x: 4pt, y: 6.5pt),
64
+ align: (x, y) => if x >= 2 { right } else { left },
65
+ stroke: none,
66
+ table.hline(stroke: 0.7pt + accent),
67
+ table.header(
68
+ text(size: 9pt, fill: accent, weight: "bold")[#L.pos],
69
+ text(size: 9pt, fill: accent, weight: "bold")[#L.description],
70
+ text(size: 9pt, fill: accent, weight: "bold")[#L.qty],
71
+ text(size: 9pt, fill: accent, weight: "bold")[#L.unit_price],
72
+ text(size: 9pt, fill: accent, weight: "bold")[#L.line_total],
73
+ ),
74
+ table.hline(stroke: 0.7pt + accent),
75
+ ..d.items.map(it => (
76
+ [#it.pos],
77
+ [#it.description],
78
+ [#it.quantity#if it.unit != "" [ #it.unit]],
79
+ [#it.unit_price],
80
+ [#it.total],
81
+ )).flatten(),
82
+ table.hline(stroke: 0.4pt + luma(210)),
83
+ )
84
+
85
+ // ── Totals ────────────────────────────────────────────────────────────
86
+ #v(3mm)
87
+ #align(right, block(width: 72mm)[
88
+ #let row(l, v, strong: false) = grid(
89
+ columns: (1fr, auto), column-gutter: 6mm,
90
+ if strong { text(weight: "bold")[#l] } else { text(fill: muted)[#l] },
91
+ if strong { text(weight: "bold")[#v] } else [#v],
92
+ )
93
+ #row(L.subtotal, [#d.currency #d.totals.subtotal])
94
+ #if not d.totals.export {
95
+ v(2.2pt)
96
+ row(L.vat + " " + d.totals.vat_rate + "%", [#d.currency #d.totals.vat_amount])
97
+ }
98
+ #if d.totals.show_rounding {
99
+ v(2.2pt)
100
+ row(L.rounding, [#d.currency #d.totals.rounding])
101
+ }
102
+ #v(4pt)
103
+ #line(length: 100%, stroke: 0.5pt + luma(180))
104
+ #v(4pt)
105
+ #row(L.grand_total, text(fill: accent)[#d.currency #d.totals.grand_total], strong: true)
106
+ ])
107
+
108
+ // ── Export legal notes ────────────────────────────────────────────────
109
+ #if d.totals.export {
110
+ v(6mm)
111
+ block(fill: luma(246), inset: 8pt, radius: 3pt, width: 100%)[
112
+ #text(size: 9pt)[#L.export_note]
113
+ #if d.reverse_charge [ \ #text(size: 9pt, fill: muted)[#L.reverse_charge] ]
114
+ ]
115
+ }
116
+
117
+ // ── Payment terms + notes ─────────────────────────────────────────────
118
+ #if d.terms != none or d.notes.len() > 0 {
119
+ v(6mm)
120
+ set text(size: 9pt, fill: muted)
121
+ if d.terms != none [ #d.terms \ ]
122
+ for n in d.notes [ #n \ ]
123
+ }
124
+
125
+ // ── Payment ───────────────────────────────────────────────────────────
126
+ #if qrbill {
127
+ // Full-width Swiss QR-bill payment part, flush at the page bottom.
128
+ place(bottom + left, dx: -20mm, dy: 108mm, image("qrbill.svg", width: 210mm))
129
+ } else {
130
+ v(8mm)
131
+ block(stroke: 0.6pt + accent, inset: 10pt, radius: 3pt, width: 100%)[
132
+ #text(fill: accent, weight: "bold")[#L.payment_to] \
133
+ #v(2pt)
134
+ #grid(columns: (auto, 1fr), column-gutter: 6mm, row-gutter: 3pt,
135
+ text(fill: muted)[IBAN], [#d.payment.iban],
136
+ ..(if d.payment.bic != none { (text(fill: muted)[BIC], [#d.payment.bic]) } else { () }),
137
+ text(fill: muted)[#L.reference], [#d.payment.reference],
138
+ )
139
+ ]
140
+ }
@@ -0,0 +1,48 @@
1
+ """Offline VAT-number validation via python-stdnum (per-country checksums).
2
+
3
+ Strategy: try the country's national module (stdnum.<cc>.vat or its alias,
4
+ e.g. ee.kmkr) and, for alpha-prefixed numbers, the EU-wide dispatcher. The
5
+ number is accepted if any applicable validator passes; countries without a
6
+ VAT module (e.g. US — no VAT regime) are accepted as-is. Online checks
7
+ (VIES/UID register) are deliberately out of scope here.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from stdnum.eu import vat as euvat
13
+ from stdnum.exceptions import ValidationError
14
+ from stdnum.util import get_cc_module
15
+
16
+ _EU_PREFIX = {"GR": "EL"} # country code → VAT prefix that differs
17
+
18
+
19
+ def validate(vat_id: str, country: str) -> None:
20
+ """Raise ValueError if `vat_id` fails every applicable checksum for `country`."""
21
+ cc = country.upper()
22
+ compact = "".join(vat_id.split()).upper()
23
+ errors: list[str] = []
24
+
25
+ national = get_cc_module(cc.lower(), "vat")
26
+ if national is not None:
27
+ for candidate in (vat_id, _strip_prefix(compact, cc)):
28
+ try:
29
+ national.validate(candidate)
30
+ return
31
+ except ValidationError as e:
32
+ errors.append(str(e))
33
+
34
+ if compact[:2] == _EU_PREFIX.get(cc, cc) and compact[:2].isalpha():
35
+ try:
36
+ euvat.validate(compact)
37
+ return
38
+ except ValidationError as e:
39
+ errors.append(str(e))
40
+
41
+ if not errors: # no validator knows this country → accept
42
+ return
43
+ raise ValueError(f"invalid VAT number {vat_id!r} for country {country}: {errors[0]}")
44
+
45
+
46
+ def _strip_prefix(compact: str, cc: str) -> str:
47
+ prefix = _EU_PREFIX.get(cc, cc)
48
+ return compact[2:] if compact.startswith(prefix) else compact
@@ -0,0 +1,60 @@
1
+ """Cross-check a rendered invoice against its ledger transaction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from decimal import Decimal
7
+ from pathlib import Path
8
+
9
+ from beancount.core import data
10
+
11
+ from .. import config, ledger
12
+ from .model import Invoice, Totals
13
+
14
+
15
+ @dataclass
16
+ class CrossCheck:
17
+ found: bool
18
+ date: str | None = None
19
+ ledger_total: Decimal | None = None
20
+ invoice_total: Decimal | None = None
21
+ ok: bool = False
22
+ date_ok: bool = True # booking date equals the invoice issue date
23
+
24
+
25
+ def cross_check(
26
+ ledger_path: Path, inv: Invoice, totals: Totals, tol: Decimal = Decimal("0.005")
27
+ ) -> CrossCheck:
28
+ """Match by invoice id (metadata `invoice:` or a `^<number>` link); compare
29
+ the invoice grand total to the income + output VAT across ALL matching
30
+ transactions (the payment-clearing entry may carry the same link — its
31
+ lack of income legs makes it contribute zero)."""
32
+ cfg = config.get()
33
+ entries, _ = ledger.load_entries(ledger_path)
34
+ matches = []
35
+ for e in entries:
36
+ if not isinstance(e, data.Transaction):
37
+ continue
38
+ if inv.number == (e.meta or {}).get("invoice") or inv.number in (e.links or set()):
39
+ matches.append(e)
40
+ if not matches:
41
+ return CrossCheck(found=False)
42
+
43
+ expected = Decimal("0")
44
+ booking = None # the transaction that carries the income legs
45
+ for e in matches:
46
+ for p in e.postings:
47
+ if p.units.currency != inv.currency:
48
+ continue
49
+ if p.account.startswith(cfg.income_prefix) or p.account == cfg.output_vat:
50
+ expected += -p.units.number
51
+ booking = booking or e
52
+ booking = booking or matches[0]
53
+ return CrossCheck(
54
+ found=True,
55
+ date=str(booking.date),
56
+ ledger_total=expected,
57
+ invoice_total=totals.grand_total,
58
+ ok=abs(expected - totals.grand_total) <= tol,
59
+ date_ok=booking.date == inv.issue_date,
60
+ )