quints 0.6.0__tar.gz → 0.7.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.6.0 → quints-0.7.0}/PKG-INFO +1 -1
- {quints-0.6.0 → quints-0.7.0}/pyproject.toml +1 -1
- quints-0.7.0/src/quints/invoice/fonts/LICENSE +102 -0
- quints-0.7.0/src/quints/invoice/fonts/LiberationSans-Bold.ttf +0 -0
- quints-0.7.0/src/quints/invoice/fonts/LiberationSans-Regular.ttf +0 -0
- quints-0.7.0/src/quints/invoice/fonts/README.md +15 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/invoice/model.py +37 -3
- {quints-0.6.0 → quints-0.7.0}/src/quints/invoice/qr.py +7 -1
- {quints-0.6.0 → quints-0.7.0}/src/quints/invoice/render.py +43 -11
- quints-0.7.0/src/quints/invoice/template.typ +184 -0
- {quints-0.6.0 → quints-0.7.0}/tests/test_invoice.py +116 -0
- quints-0.6.0/src/quints/invoice/template.typ +0 -140
- {quints-0.6.0 → quints-0.7.0}/.gitignore +0 -0
- {quints-0.6.0 → quints-0.7.0}/LICENSE +0 -0
- {quints-0.6.0 → quints-0.7.0}/README.md +0 -0
- {quints-0.6.0 → quints-0.7.0}/examples/.gitignore +0 -0
- {quints-0.6.0 → quints-0.7.0}/examples/AGENTS.md +0 -0
- {quints-0.6.0 → quints-0.7.0}/examples/CLAUDE.md +0 -0
- {quints-0.6.0 → quints-0.7.0}/examples/accounts.bean +0 -0
- {quints-0.6.0 → quints-0.7.0}/examples/answers.toml +0 -0
- {quints-0.6.0 → quints-0.7.0}/examples/books/2026.bean +0 -0
- {quints-0.6.0 → quints-0.7.0}/examples/commodities.bean +0 -0
- {quints-0.6.0 → quints-0.7.0}/examples/documents/.gitkeep +0 -0
- {quints-0.6.0 → quints-0.7.0}/examples/inbox/.gitkeep +0 -0
- {quints-0.6.0 → quints-0.7.0}/examples/invoicing/acme-2026-07.yaml +0 -0
- {quints-0.6.0 → quints-0.7.0}/examples/invoicing/customers.yaml +0 -0
- {quints-0.6.0 → quints-0.7.0}/examples/invoicing/globex-2026-08.yaml +0 -0
- {quints-0.6.0 → quints-0.7.0}/examples/invoicing/issuer.yaml +0 -0
- {quints-0.6.0 → quints-0.7.0}/examples/main.bean +0 -0
- {quints-0.6.0 → quints-0.7.0}/examples/prices.bean +0 -0
- {quints-0.6.0 → quints-0.7.0}/examples/pyproject.toml +0 -0
- {quints-0.6.0 → quints-0.7.0}/examples/quints.toml +0 -0
- {quints-0.6.0 → quints-0.7.0}/examples/staging/.gitkeep +0 -0
- {quints-0.6.0 → quints-0.7.0}/examples/statements/ubs-2026.mt940 +0 -0
- {quints-0.6.0 → quints-0.7.0}/examples/statements/yapeal.csv +0 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/__init__.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/cli.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/config.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/fava/__init__.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/fava/templates/QuintDashboard.html +0 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/fx.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/importing.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/inbox.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/init.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/invoice/__init__.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/invoice/draft.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/invoice/labels.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/invoice/vatid.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/invoice/verify.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/kmu.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/ledger.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/match.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/mwst.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/plugins/__init__.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/plugins/kmu.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/prices.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/receivables.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/report.typ +0 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/report_pdf.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/settlement.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/ui.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/src/quints/vat.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/tests/fixtures/transactions.mt940 +0 -0
- {quints-0.6.0 → quints-0.7.0}/tests/test_cli_json.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/tests/test_config.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/tests/test_docs.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/tests/test_fava_ext.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/tests/test_fx.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/tests/test_importing.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/tests/test_inbox_match.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/tests/test_init.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/tests/test_kmu_plugin.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/tests/test_kmu_report.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/tests/test_ledger.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/tests/test_mwst.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/tests/test_prices_ecosystem.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/tests/test_prices_sync.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/tests/test_receivables.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/tests/test_report_pdf.py +0 -0
- {quints-0.6.0 → quints-0.7.0}/tests/test_settlement.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: quints
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "quints"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.7.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"
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Digitized data copyright (c) 2010 Google Corporation
|
|
2
|
+
with Reserved Font Arimo, Tinos and Cousine.
|
|
3
|
+
Copyright (c) 2012 Red Hat, Inc.
|
|
4
|
+
with Reserved Font Name Liberation.
|
|
5
|
+
|
|
6
|
+
This Font Software is licensed under the SIL Open Font License,
|
|
7
|
+
Version 1.1.
|
|
8
|
+
|
|
9
|
+
This license is copied below, and is also available with a FAQ at:
|
|
10
|
+
http://scripts.sil.org/OFL
|
|
11
|
+
|
|
12
|
+
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
13
|
+
|
|
14
|
+
PREAMBLE The goals of the Open Font License (OFL) are to stimulate
|
|
15
|
+
worldwide development of collaborative font projects, to support the font
|
|
16
|
+
creation efforts of academic and linguistic communities, and to provide
|
|
17
|
+
a free and open framework in which fonts may be shared and improved in
|
|
18
|
+
partnership with others.
|
|
19
|
+
|
|
20
|
+
The OFL allows the licensed fonts to be used, studied, modified and
|
|
21
|
+
redistributed freely as long as they are not sold by themselves.
|
|
22
|
+
The fonts, including any derivative works, can be bundled, embedded,
|
|
23
|
+
redistributed and/or sold with any software provided that any reserved
|
|
24
|
+
names are not used by derivative works. The fonts and derivatives,
|
|
25
|
+
however, cannot be released under any other type of license. The
|
|
26
|
+
requirement for fonts to remain under this license does not apply to
|
|
27
|
+
any document created using the fonts or their derivatives.
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
DEFINITIONS
|
|
32
|
+
"Font Software" refers to the set of files released by the Copyright
|
|
33
|
+
Holder(s) under this license and clearly marked as such.
|
|
34
|
+
This may include source files, build scripts and documentation.
|
|
35
|
+
|
|
36
|
+
"Reserved Font Name" refers to any names specified as such after the
|
|
37
|
+
copyright statement(s).
|
|
38
|
+
|
|
39
|
+
"Original Version" refers to the collection of Font Software components
|
|
40
|
+
as distributed by the Copyright Holder(s).
|
|
41
|
+
|
|
42
|
+
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
43
|
+
or substituting ? in part or in whole ?
|
|
44
|
+
any of the components of the Original Version, by changing formats or
|
|
45
|
+
by porting the Font Software to a new environment.
|
|
46
|
+
|
|
47
|
+
"Author" refers to any designer, engineer, programmer, technical writer
|
|
48
|
+
or other person who contributed to the Font Software.
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
PERMISSION & CONDITIONS
|
|
52
|
+
|
|
53
|
+
Permission is hereby granted, free of charge, to any person obtaining a
|
|
54
|
+
copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
55
|
+
redistribute, and sell modified and unmodified copies of the Font
|
|
56
|
+
Software, subject to the following conditions:
|
|
57
|
+
|
|
58
|
+
1) Neither the Font Software nor any of its individual components,in
|
|
59
|
+
Original or Modified Versions, may be sold by itself.
|
|
60
|
+
|
|
61
|
+
2) Original or Modified Versions of the Font Software may be bundled,
|
|
62
|
+
redistributed and/or sold with any software, provided that each copy
|
|
63
|
+
contains the above copyright notice and this license. These can be
|
|
64
|
+
included either as stand-alone text files, human-readable headers or
|
|
65
|
+
in the appropriate machine-readable metadata fields within text or
|
|
66
|
+
binary files as long as those fields can be easily viewed by the user.
|
|
67
|
+
|
|
68
|
+
3) No Modified Version of the Font Software may use the Reserved Font
|
|
69
|
+
Name(s) unless explicit written permission is granted by the
|
|
70
|
+
corresponding Copyright Holder. This restriction only applies to the
|
|
71
|
+
primary font name as presented to the users.
|
|
72
|
+
|
|
73
|
+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
74
|
+
Software shall not be used to promote, endorse or advertise any
|
|
75
|
+
Modified Version, except to acknowledge the contribution(s) of the
|
|
76
|
+
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
77
|
+
permission.
|
|
78
|
+
|
|
79
|
+
5) The Font Software, modified or unmodified, in part or in whole, must
|
|
80
|
+
be distributed entirely under this license, and must not be distributed
|
|
81
|
+
under any other license. The requirement for fonts to remain under
|
|
82
|
+
this license does not apply to any document created using the Font
|
|
83
|
+
Software.
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
TERMINATION
|
|
88
|
+
This license becomes null and void if any of the above conditions are not met.
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
DISCLAIMER
|
|
93
|
+
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
94
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
95
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
96
|
+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
97
|
+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
98
|
+
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
99
|
+
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
100
|
+
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
|
101
|
+
DEALINGS IN THE FONT SOFTWARE.
|
|
102
|
+
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Bundled fallback fonts
|
|
2
|
+
|
|
3
|
+
Liberation Sans, always added to the Typst font search path.
|
|
4
|
+
|
|
5
|
+
It carries two guarantees the invoice template would otherwise only get by
|
|
6
|
+
luck, from whatever the rendering machine happens to have installed:
|
|
7
|
+
|
|
8
|
+
- `Brand.font` defaults to Liberation Sans, and the template names it as the
|
|
9
|
+
fallback behind every issuer-configured family.
|
|
10
|
+
- The Swiss QR-bill Implementation Guidelines permit only Arial, Frutiger,
|
|
11
|
+
Helvetica and Liberation Sans in the payment part. Liberation Sans is the
|
|
12
|
+
one of the four that can be licensed and shipped, and it is metric-
|
|
13
|
+
compatible with Arial, which the `qrbill` library sizes its text boxes for.
|
|
14
|
+
|
|
15
|
+
Liberation Fonts 2.1.5, SIL Open Font License 1.1 — see `LICENSE`.
|
|
@@ -14,10 +14,17 @@ import sys
|
|
|
14
14
|
from datetime import date
|
|
15
15
|
from decimal import ROUND_HALF_UP, Decimal
|
|
16
16
|
from pathlib import Path
|
|
17
|
-
from typing import Any, Literal
|
|
17
|
+
from typing import Annotated, Any, Literal
|
|
18
18
|
|
|
19
19
|
import yaml
|
|
20
|
-
from pydantic import
|
|
20
|
+
from pydantic import (
|
|
21
|
+
BaseModel,
|
|
22
|
+
Field,
|
|
23
|
+
RootModel,
|
|
24
|
+
StringConstraints,
|
|
25
|
+
field_validator,
|
|
26
|
+
model_validator,
|
|
27
|
+
)
|
|
21
28
|
|
|
22
29
|
if sys.version_info >= (3, 11):
|
|
23
30
|
import tomllib
|
|
@@ -249,13 +256,40 @@ class BankAccount(BaseModel):
|
|
|
249
256
|
return v.replace(" ", "")
|
|
250
257
|
|
|
251
258
|
|
|
259
|
+
# Every hex form Typst's `rgb()` takes: RGB, RGBA, RRGGBB, RRGGBBAA. `accent`
|
|
260
|
+
# used to be a bare `str` handed straight to `rgb()`, so anything shorthand that
|
|
261
|
+
# rendered before still loads; only strings Typst would have rejected anyway are
|
|
262
|
+
# caught here, and now at config-load time instead of mid-render.
|
|
263
|
+
HexColor = Annotated[
|
|
264
|
+
str, StringConstraints(pattern=r"^#([0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$")
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
|
|
252
268
|
class Brand(BaseModel):
|
|
253
|
-
|
|
269
|
+
"""Typography and colour tokens the template renders with.
|
|
270
|
+
|
|
271
|
+
The colour defaults reproduce the neutral greyscale the template used
|
|
272
|
+
before tokens existed, so an issuer config that sets none of them keeps
|
|
273
|
+
its previous look. An issuer with a palette overrides them all.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
accent: HexColor = "#123c3a"
|
|
254
277
|
font: str = "Liberation Sans"
|
|
255
278
|
font_display: str | None = None # title/wordmark family; defaults to `font`
|
|
256
279
|
font_display_stretch: int = 100 # CSS-style font-stretch % (125 → Expanded cut)
|
|
280
|
+
# Title weight. Not every display family ships every cut — Mona Sans
|
|
281
|
+
# Expanded starts at Medium, so asking for "regular" silently drops back to
|
|
282
|
+
# the normal-width face; a serif title usually wants "regular".
|
|
283
|
+
font_display_weight: Literal["regular", "medium", "semibold", "bold"] = "semibold"
|
|
284
|
+
font_mono: str | None = None # figures, IBAN, reference; defaults to `font`
|
|
257
285
|
font_dir: str | None = None # bundled fonts dir passed to typst (repo-relative)
|
|
258
286
|
logo: str | None = None
|
|
287
|
+
logo_height: float = 12.0 # mm, as placed in the header
|
|
288
|
+
|
|
289
|
+
ink: HexColor = "#141414" # body copy
|
|
290
|
+
subtle: HexColor = "#696969" # labels and secondary text
|
|
291
|
+
rule: HexColor = "#d2d2d2" # hairlines
|
|
292
|
+
panel: HexColor = "#f6f6f6" # fill behind bounded blocks
|
|
259
293
|
|
|
260
294
|
|
|
261
295
|
class Issuer(BaseModel):
|
|
@@ -52,7 +52,7 @@ def build_bill(inv: Invoice, issuer: Issuer, account: BankAccount, grand: Decima
|
|
|
52
52
|
raise ValueError(f"Swiss QR-bill supports only CHF or EUR, not {currency!r}")
|
|
53
53
|
with warnings.catch_warnings():
|
|
54
54
|
warnings.simplefilter("ignore")
|
|
55
|
-
|
|
55
|
+
bill = QRBill(
|
|
56
56
|
account=account.qr_iban or account.iban,
|
|
57
57
|
creditor=_structured(issuer.name, issuer.address, issuer.country),
|
|
58
58
|
debtor=_structured(customer.name, customer.address, customer.country),
|
|
@@ -61,6 +61,12 @@ def build_bill(inv: Invoice, issuer: Issuer, account: BankAccount, grand: Decima
|
|
|
61
61
|
reference_number=reference_for(inv, account),
|
|
62
62
|
additional_information=f"{inv.number}",
|
|
63
63
|
)
|
|
64
|
+
# The Implementation Guidelines allow Arial, Frutiger, Helvetica and
|
|
65
|
+
# Liberation Sans in the payment part, and nothing else. qrbill asks for
|
|
66
|
+
# Arial/Helvetica only, which are system faces — an issuer that bundles
|
|
67
|
+
# fonts has system faces switched off, so name the OFL one it can ship.
|
|
68
|
+
bill.font_family = "Arial,Helvetica,Liberation Sans"
|
|
69
|
+
return bill
|
|
64
70
|
|
|
65
71
|
|
|
66
72
|
def write_svg(bill: QRBill, path: Path) -> None:
|
|
@@ -18,6 +18,10 @@ from .labels import labels as get_labels
|
|
|
18
18
|
from .model import Invoice, Issuer, Totals, compute, make_scor, money, number
|
|
19
19
|
|
|
20
20
|
TEMPLATE = Path(__file__).parent / "template.typ"
|
|
21
|
+
# Liberation Sans, always on the search path: the default `Brand.font`, the
|
|
22
|
+
# template's last-resort fallback, and the only face the QR-bill payment part
|
|
23
|
+
# is allowed to use that we can ship. See fonts/README.md.
|
|
24
|
+
BUNDLED_FONTS = Path(__file__).parent / "fonts"
|
|
21
25
|
|
|
22
26
|
|
|
23
27
|
class InvoiceContext(TypedDict):
|
|
@@ -36,7 +40,7 @@ class InvoiceContext(TypedDict):
|
|
|
36
40
|
totals: dict[str, str | bool]
|
|
37
41
|
payment: Mapping[str, str | None]
|
|
38
42
|
notes: list[str]
|
|
39
|
-
brand: dict[str, str |
|
|
43
|
+
brand: dict[str, str | float | None]
|
|
40
44
|
|
|
41
45
|
|
|
42
46
|
def _fmt_iban(iban: str) -> str:
|
|
@@ -113,20 +117,45 @@ def build_context(
|
|
|
113
117
|
"font": issuer.brand.font,
|
|
114
118
|
"font_display": issuer.brand.font_display or issuer.brand.font,
|
|
115
119
|
"display_stretch": issuer.brand.font_display_stretch,
|
|
120
|
+
"display_weight": issuer.brand.font_display_weight,
|
|
121
|
+
"font_mono": issuer.brand.font_mono or issuer.brand.font,
|
|
122
|
+
"ink": issuer.brand.ink,
|
|
123
|
+
"subtle": issuer.brand.subtle,
|
|
124
|
+
"rule": issuer.brand.rule,
|
|
125
|
+
"panel": issuer.brand.panel,
|
|
116
126
|
"logo": logo,
|
|
127
|
+
"logo_height": issuer.brand.logo_height,
|
|
117
128
|
},
|
|
118
129
|
}
|
|
119
130
|
|
|
120
131
|
|
|
121
|
-
def _compile(
|
|
122
|
-
|
|
132
|
+
def _compile(
|
|
133
|
+
main: Path, output: Path, root: Path, font_paths: list[Path], only_bundled: bool
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Compile to PDF/A-2b (archival) when supported, else a plain PDF.
|
|
136
|
+
|
|
137
|
+
`only_bundled` switches machine-installed families off, and is set when the
|
|
138
|
+
issuer ships their own font directory. Without it, a variable font installed
|
|
139
|
+
on the designer's machine shadows the bundled static cuts of the same family
|
|
140
|
+
and every weight silently collapses to the variable font's default instance,
|
|
141
|
+
so the same invoice renders differently on a colleague's machine. Issuers on
|
|
142
|
+
the default font keep system fonts, since their families may live there.
|
|
143
|
+
"""
|
|
123
144
|
fonts = [str(p) for p in font_paths]
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
145
|
+
base: dict[str, object] = {"output": str(output), "root": str(root), "font_paths": fonts}
|
|
146
|
+
# Most capable call first; drop the options an older typst-py may not know.
|
|
147
|
+
attempts = [
|
|
148
|
+
{**base, "ignore_system_fonts": only_bundled, "pdf_standards": "a-2b"},
|
|
149
|
+
{**base, "pdf_standards": "a-2b"},
|
|
150
|
+
base,
|
|
151
|
+
]
|
|
152
|
+
for i, opts in enumerate(attempts):
|
|
153
|
+
try:
|
|
154
|
+
typst.compile(str(main), **opts) # type: ignore[arg-type]
|
|
155
|
+
return
|
|
156
|
+
except (TypeError, ValueError):
|
|
157
|
+
if i == len(attempts) - 1:
|
|
158
|
+
raise
|
|
130
159
|
|
|
131
160
|
|
|
132
161
|
def render(inv: Invoice, issuer: Issuer, out_path: Path) -> tuple[Path, Totals, str | None]:
|
|
@@ -184,11 +213,14 @@ def render(inv: Invoice, issuer: Issuer, out_path: Path) -> tuple[Path, Totals,
|
|
|
184
213
|
shutil.copy(TEMPLATE, work / "template.typ")
|
|
185
214
|
|
|
186
215
|
font_dir = issuer.brand.font_dir
|
|
187
|
-
|
|
216
|
+
issuer_fonts = [Path(font_dir)] if font_dir and Path(font_dir).is_dir() else []
|
|
217
|
+
# Ours first so the guaranteed fallback is always resolvable; the
|
|
218
|
+
# issuer's own families win on name, not on search order.
|
|
219
|
+
font_paths = [BUNDLED_FONTS, *issuer_fonts]
|
|
188
220
|
|
|
189
221
|
out_path = Path(out_path)
|
|
190
222
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
191
|
-
_compile(work / "template.typ", out_path, work, font_paths)
|
|
223
|
+
_compile(work / "template.typ", out_path, work, font_paths, bool(issuer_fonts))
|
|
192
224
|
return out_path, totals, qr_payload
|
|
193
225
|
finally:
|
|
194
226
|
shutil.rmtree(work, ignore_errors=True)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// quints invoice template — data-driven (reads data.json), two modes.
|
|
2
|
+
//
|
|
3
|
+
// Everything issuer-specific arrives through `data.json`: no family name, hex
|
|
4
|
+
// colour or asset path is hard-coded here. The brand tokens are:
|
|
5
|
+
// accent — the one saturated colour: title, rules that lead, the amount due
|
|
6
|
+
// ink — body copy subtle — labels and secondary text
|
|
7
|
+
// rule — hairlines panel — fill behind bounded blocks
|
|
8
|
+
// and three families: `font` (body), `font_display` (title), `font_mono` —
|
|
9
|
+
// every figure, IBAN and reference. Set `font_mono` to a real monospace and the
|
|
10
|
+
// amount columns align on the digit; left unset it follows the body face.
|
|
11
|
+
#let d = json("data.json")
|
|
12
|
+
#let L = d.labels
|
|
13
|
+
#let accent = rgb(d.brand.accent)
|
|
14
|
+
#let ink = rgb(d.brand.ink)
|
|
15
|
+
#let subtle = rgb(d.brand.subtle)
|
|
16
|
+
#let rule = rgb(d.brand.rule)
|
|
17
|
+
#let panel = rgb(d.brand.panel)
|
|
18
|
+
#let body = (d.brand.font, "Liberation Sans", "Arial")
|
|
19
|
+
#let display = (d.brand.font_display, d.brand.font, "Liberation Sans", "Arial")
|
|
20
|
+
#let mono = (d.brand.font_mono, d.brand.font, "Liberation Mono", "Courier New")
|
|
21
|
+
#let lines(a) = a.join(linebreak())
|
|
22
|
+
#let qrbill = d.payment.type == "qrbill"
|
|
23
|
+
|
|
24
|
+
// Display face. `stretch` is only passed when the issuer actually asks for a
|
|
25
|
+
// width other than normal: a font with no wdth axis fails to match against an
|
|
26
|
+
// explicit stretch and silently falls back to the body face.
|
|
27
|
+
#let disp(it, size: 10pt, fill: ink) = text(
|
|
28
|
+
font: display, size: size, weight: d.brand.display_weight, fill: fill,
|
|
29
|
+
..(if d.brand.display_stretch != 100 { (stretch: d.brand.display_stretch * 1%) } else { (:) }),
|
|
30
|
+
)[#it]
|
|
31
|
+
|
|
32
|
+
// Small tracked capitals: every label in the document, at one size.
|
|
33
|
+
#let label(it) = text(size: 7pt, weight: "medium", tracking: 0.1em, fill: subtle)[#upper(it)]
|
|
34
|
+
// Figures. Tabular by construction — the mono family keeps columns on the digit.
|
|
35
|
+
#let fig(it, weight: "regular", fill: ink, size: 9.5pt) = text(
|
|
36
|
+
font: mono, size: size, weight: weight, fill: fill,
|
|
37
|
+
)[#it]
|
|
38
|
+
|
|
39
|
+
#set document(title: L.invoice + " " + d.invoice.number, author: d.issuer.name)
|
|
40
|
+
#set page(
|
|
41
|
+
paper: "a4",
|
|
42
|
+
margin: (x: 20mm, top: 16mm, bottom: if qrbill { 108mm } else { 24mm }),
|
|
43
|
+
)
|
|
44
|
+
#set text(font: body, size: 9.5pt, fill: ink)
|
|
45
|
+
#set par(justify: false, leading: 0.65em)
|
|
46
|
+
|
|
47
|
+
// ── Header: wordmark left, issuer identity right (Art. 26 Abs. 2 lit. a MWSTG) ─
|
|
48
|
+
#grid(columns: (1fr, auto), column-gutter: 10mm,
|
|
49
|
+
align(left + top,
|
|
50
|
+
if d.brand.logo != none {
|
|
51
|
+
image(d.brand.logo, height: d.brand.logo_height * 1mm)
|
|
52
|
+
} else {
|
|
53
|
+
disp(d.issuer.name, size: 17pt)
|
|
54
|
+
}
|
|
55
|
+
),
|
|
56
|
+
align(right + top)[
|
|
57
|
+
#set text(size: 7.5pt, fill: subtle)
|
|
58
|
+
#set par(leading: 0.55em)
|
|
59
|
+
#text(fill: ink, weight: "medium")[#d.issuer.name] \
|
|
60
|
+
#lines(d.issuer.address) \
|
|
61
|
+
#d.issuer.vat_id
|
|
62
|
+
#if d.issuer.email != none [ \ #link("mailto:" + d.issuer.email)[#d.issuer.email] ]
|
|
63
|
+
#if d.issuer.phone != none [ \ #d.issuer.phone ]
|
|
64
|
+
],
|
|
65
|
+
)
|
|
66
|
+
#v(4mm)
|
|
67
|
+
#line(length: 100%, stroke: 0.5pt + rule)
|
|
68
|
+
|
|
69
|
+
// ── Recipient (right, window-envelope position) ───────────────────────
|
|
70
|
+
#place(top + left, dx: 112mm, dy: 30mm, block(width: 78mm)[
|
|
71
|
+
#set par(leading: 0.6em)
|
|
72
|
+
#text(weight: "medium")[#d.customer.name] \
|
|
73
|
+
#lines(d.customer.address)
|
|
74
|
+
#if d.customer.country != d.issuer.country [ \ #d.customer.country ]
|
|
75
|
+
#if d.customer.vat_id != none [ \ #d.customer.vat_id ]
|
|
76
|
+
])
|
|
77
|
+
|
|
78
|
+
// Clears the recipient block placed above; the QR-bill leaves only ~173mm of
|
|
79
|
+
// body height, so the rhythm below stays deliberately tight.
|
|
80
|
+
#v(17mm)
|
|
81
|
+
|
|
82
|
+
// ── Title + meta ──────────────────────────────────────────────────────
|
|
83
|
+
#disp(L.invoice, size: 25pt, fill: accent)
|
|
84
|
+
#v(4mm)
|
|
85
|
+
#grid(columns: (auto, auto), column-gutter: 7mm, row-gutter: 4pt,
|
|
86
|
+
align(horizon, label(L.invoice_no)), fig(d.invoice.number, weight: "medium"),
|
|
87
|
+
align(horizon, label(L.date)), fig(d.invoice.issue_date),
|
|
88
|
+
..(if d.invoice.supply != "" {
|
|
89
|
+
(align(horizon, label(L.supply)), text(size: 9.5pt)[#d.invoice.supply])
|
|
90
|
+
} else { () }),
|
|
91
|
+
)
|
|
92
|
+
#v(8mm)
|
|
93
|
+
|
|
94
|
+
// ── Line items ────────────────────────────────────────────────────────
|
|
95
|
+
#table(
|
|
96
|
+
columns: (auto, 1fr, auto, auto, auto),
|
|
97
|
+
inset: (x: 0pt, y: 7pt),
|
|
98
|
+
column-gutter: 6mm,
|
|
99
|
+
align: (x, y) => if x >= 2 { right } else { left },
|
|
100
|
+
stroke: none,
|
|
101
|
+
table.hline(stroke: 1pt + accent),
|
|
102
|
+
table.header(
|
|
103
|
+
label(L.pos), label(L.description), label(L.qty), label(L.unit_price), label(L.line_total),
|
|
104
|
+
),
|
|
105
|
+
table.hline(stroke: 0.5pt + rule),
|
|
106
|
+
..d.items.map(it => (
|
|
107
|
+
fig(it.pos, fill: subtle, size: 9pt),
|
|
108
|
+
[#it.description],
|
|
109
|
+
fig[#it.quantity#if it.unit != "" [ #it.unit]],
|
|
110
|
+
fig(it.unit_price),
|
|
111
|
+
fig(it.total),
|
|
112
|
+
)).flatten(),
|
|
113
|
+
table.hline(stroke: 0.5pt + rule),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
// ── Totals ────────────────────────────────────────────────────────────
|
|
117
|
+
#v(4mm)
|
|
118
|
+
#align(right, block(width: 76mm)[
|
|
119
|
+
#let row(l, v) = grid(
|
|
120
|
+
columns: (1fr, auto), column-gutter: 6mm,
|
|
121
|
+
align(horizon, label(l)), v,
|
|
122
|
+
)
|
|
123
|
+
#row(L.subtotal, fig(d.currency + " " + d.totals.subtotal))
|
|
124
|
+
#if not d.totals.export {
|
|
125
|
+
v(3pt)
|
|
126
|
+
row(L.vat + " " + d.totals.vat_rate + "%", fig(d.currency + " " + d.totals.vat_amount))
|
|
127
|
+
}
|
|
128
|
+
#if d.totals.show_rounding {
|
|
129
|
+
v(3pt)
|
|
130
|
+
row(L.rounding, fig(d.currency + " " + d.totals.rounding))
|
|
131
|
+
}
|
|
132
|
+
#v(5pt)
|
|
133
|
+
// The amount due is the one thing a reader looks for: accent on a tinted band.
|
|
134
|
+
#block(fill: panel, inset: (x: 8pt, y: 7pt), width: 100%,
|
|
135
|
+
grid(columns: (1fr, auto), column-gutter: 6mm,
|
|
136
|
+
align(horizon, text(size: 8pt, weight: "medium", tracking: 0.1em, fill: accent)[
|
|
137
|
+
#upper(L.grand_total)
|
|
138
|
+
]),
|
|
139
|
+
fig(d.currency + " " + d.totals.grand_total, weight: "medium", fill: accent, size: 12pt),
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
])
|
|
143
|
+
|
|
144
|
+
// ── Export legal notes ────────────────────────────────────────────────
|
|
145
|
+
#if d.totals.export {
|
|
146
|
+
v(6mm)
|
|
147
|
+
block(
|
|
148
|
+
fill: panel, width: 100%,
|
|
149
|
+
inset: (left: 9pt, right: 9pt, y: 8pt), stroke: (left: 1.5pt + accent),
|
|
150
|
+
)[
|
|
151
|
+
#set text(size: 8.5pt)
|
|
152
|
+
#L.export_note
|
|
153
|
+
#if d.reverse_charge [ \ #text(fill: subtle)[#L.reverse_charge] ]
|
|
154
|
+
]
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Payment terms + notes ─────────────────────────────────────────────
|
|
158
|
+
#if d.terms != none or d.notes.len() > 0 {
|
|
159
|
+
v(5mm)
|
|
160
|
+
set text(size: 8.5pt, fill: subtle)
|
|
161
|
+
if d.terms != none [ #d.terms \ ]
|
|
162
|
+
for n in d.notes [ #n \ ]
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Payment ───────────────────────────────────────────────────────────
|
|
166
|
+
#if qrbill {
|
|
167
|
+
// Full-width Swiss QR-bill payment part, flush at the page bottom. Its
|
|
168
|
+
// layout and typography are prescribed by the Implementation Guidelines —
|
|
169
|
+
// it is rendered by qr.py and deliberately left unstyled here.
|
|
170
|
+
place(bottom + left, dx: -20mm, dy: 108mm, image("qrbill.svg", width: 210mm))
|
|
171
|
+
} else {
|
|
172
|
+
v(8mm)
|
|
173
|
+
block(fill: panel, inset: (x: 10pt, y: 9pt), width: 100%)[
|
|
174
|
+
#text(size: 8pt, weight: "medium", tracking: 0.1em, fill: accent)[#upper(L.payment_to)]
|
|
175
|
+
#v(4pt)
|
|
176
|
+
#grid(columns: (auto, 1fr), column-gutter: 7mm, row-gutter: 4pt,
|
|
177
|
+
align(horizon, label("IBAN")), fig(d.payment.iban),
|
|
178
|
+
..(if d.payment.bic != none {
|
|
179
|
+
(align(horizon, label("BIC")), fig(d.payment.bic))
|
|
180
|
+
} else { () }),
|
|
181
|
+
align(horizon, label(L.reference)), fig(d.payment.reference),
|
|
182
|
+
)
|
|
183
|
+
]
|
|
184
|
+
}
|
|
@@ -304,6 +304,61 @@ def test_reverse_charge_flag_in_context():
|
|
|
304
304
|
assert ctx["reverse_charge"] is True
|
|
305
305
|
|
|
306
306
|
|
|
307
|
+
# ── brand tokens ──────────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_brand_tokens_reach_the_template_context():
|
|
311
|
+
from quints.invoice.model import Brand
|
|
312
|
+
from quints.invoice.render import build_context
|
|
313
|
+
|
|
314
|
+
brand_in = Brand(accent="#6b1f4a", ink="#1c1618", font="Geist", font_mono="Geist Mono")
|
|
315
|
+
inv = _export()
|
|
316
|
+
brand = build_context(inv, ISSUER.model_copy(update={"brand": brand_in}), compute(inv), {})[
|
|
317
|
+
"brand"
|
|
318
|
+
]
|
|
319
|
+
assert brand["accent"] == "#6b1f4a"
|
|
320
|
+
assert brand["ink"] == "#1c1618"
|
|
321
|
+
assert brand["font_mono"] == "Geist Mono"
|
|
322
|
+
# Every token the template dereferences must be present: Typst fails on a
|
|
323
|
+
# missing dictionary key, and only when that branch of the layout renders.
|
|
324
|
+
assert {
|
|
325
|
+
"subtle",
|
|
326
|
+
"rule",
|
|
327
|
+
"panel",
|
|
328
|
+
"font_display",
|
|
329
|
+
"display_stretch",
|
|
330
|
+
"logo_height",
|
|
331
|
+
} <= set(brand)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_brand_font_families_fall_back_to_the_body_face():
|
|
335
|
+
from quints.invoice.model import Brand
|
|
336
|
+
from quints.invoice.render import build_context
|
|
337
|
+
|
|
338
|
+
issuer = ISSUER.model_copy(update={"brand": Brand(font="Mona Sans")})
|
|
339
|
+
inv = _export()
|
|
340
|
+
brand = build_context(inv, issuer, compute(inv), {})["brand"]
|
|
341
|
+
assert brand["font_display"] == "Mona Sans"
|
|
342
|
+
assert brand["font_mono"] == "Mona Sans"
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def test_brand_rejects_a_non_hex_colour():
|
|
346
|
+
from quints.invoice.model import Brand
|
|
347
|
+
|
|
348
|
+
for bad in ("tyrian", "#6b1f4", "6b1f4a", "rgb(107,31,74)"):
|
|
349
|
+
with pytest.raises(ValueError):
|
|
350
|
+
Brand(accent=bad)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def test_brand_accepts_every_hex_form_typst_takes():
|
|
354
|
+
"""`accent` was an unconstrained `str` handed straight to Typst's `rgb()`.
|
|
355
|
+
Validating it must not reject a shorthand palette that rendered before."""
|
|
356
|
+
from quints.invoice.model import Brand
|
|
357
|
+
|
|
358
|
+
for good in ("#fff", "#fff8", "#6b1f4a", "#6b1f4aff"):
|
|
359
|
+
assert Brand(accent=good).accent == good
|
|
360
|
+
|
|
361
|
+
|
|
307
362
|
# ── schema ────────────────────────────────────────────────────────────────────
|
|
308
363
|
|
|
309
364
|
|
|
@@ -433,3 +488,64 @@ def test_render_embeds_bundled_font(tmp_path: Path):
|
|
|
433
488
|
# body font and the wide title cut must be embedded, not system fallbacks
|
|
434
489
|
assert b"MonaSans-Regular" in pdf
|
|
435
490
|
assert b"MonaSansExpanded-SemiBold" in pdf
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def test_bundled_fallback_font_is_always_on_the_search_path(tmp_path: Path):
|
|
494
|
+
"""Liberation Sans ships with the package: it is the default `Brand.font`,
|
|
495
|
+
the template's last-resort fallback, and the only face the QR-bill payment
|
|
496
|
+
part may use that we can distribute (its guidelines allow four)."""
|
|
497
|
+
from quints.invoice import render
|
|
498
|
+
|
|
499
|
+
out = tmp_path / "inv.pdf"
|
|
500
|
+
render.render(_domestic(), ISSUER, out) # ISSUER sets no font_dir
|
|
501
|
+
assert b"LiberationSans" in out.read_bytes()
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def test_qr_bill_keeps_a_permitted_face_without_system_fonts(tmp_path: Path):
|
|
505
|
+
"""With system fonts off, Arial and Helvetica are unreachable, and the QR-bill
|
|
506
|
+
payment part may use only those two, Frutiger, or Liberation Sans. It must
|
|
507
|
+
land on the bundled Liberation Sans and not fall through to a Typst default.
|
|
508
|
+
|
|
509
|
+
The bold cut doubles as the regression test for weight shadowing: the
|
|
510
|
+
payment part sets headings bold, so a real `-Bold` face has to be embedded.
|
|
511
|
+
"""
|
|
512
|
+
from quints.invoice import render
|
|
513
|
+
from quints.invoice.model import Brand
|
|
514
|
+
|
|
515
|
+
# Pointing font_dir at our own bundle is the smallest issuer that ships
|
|
516
|
+
# fonts, which is what switches machine-installed families off.
|
|
517
|
+
issuer = ISSUER.model_copy(update={"brand": Brand(font_dir=str(render.BUNDLED_FONTS))})
|
|
518
|
+
out = tmp_path / "inv.pdf"
|
|
519
|
+
render.render(_domestic(), issuer, out)
|
|
520
|
+
pdf = out.read_bytes()
|
|
521
|
+
assert b"LiberationSans-Bold" in pdf
|
|
522
|
+
assert b"DejaVu" not in pdf and b"Libertinus" not in pdf
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def test_issuer_bundled_fonts_switch_off_machine_fonts(
|
|
526
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
527
|
+
):
|
|
528
|
+
"""Regression: a variable font installed on the rendering machine shadows
|
|
529
|
+
the bundled static cuts of the same family, collapsing every weight to the
|
|
530
|
+
variable default. Only an issuer that ships fonts opts out of system ones —
|
|
531
|
+
issuers on the default font may legitimately keep a family installed."""
|
|
532
|
+
import typst
|
|
533
|
+
|
|
534
|
+
from quints.invoice import render
|
|
535
|
+
from quints.invoice.model import Brand
|
|
536
|
+
|
|
537
|
+
seen: list[object] = []
|
|
538
|
+
|
|
539
|
+
def spy(_main: str, **kwargs: object) -> None:
|
|
540
|
+
seen.append(kwargs.get("ignore_system_fonts"))
|
|
541
|
+
raise ValueError("stop after capturing the first attempt")
|
|
542
|
+
|
|
543
|
+
monkeypatch.setattr(typst, "compile", spy)
|
|
544
|
+
|
|
545
|
+
fonts = render.BUNDLED_FONTS # any real directory will do
|
|
546
|
+
for brand, expected in [(Brand(font_dir=str(fonts)), True), (Brand(), False)]:
|
|
547
|
+
seen.clear()
|
|
548
|
+
issuer = ISSUER.model_copy(update={"brand": brand})
|
|
549
|
+
with pytest.raises(ValueError):
|
|
550
|
+
render.render(_domestic(), issuer, tmp_path / "inv.pdf")
|
|
551
|
+
assert seen[0] is expected
|
|
@@ -1,140 +0,0 @@
|
|
|
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
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|