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
|
@@ -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
|
+
}
|
quints/invoice/vatid.py
ADDED
|
@@ -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
|
quints/invoice/verify.py
ADDED
|
@@ -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
|
+
)
|