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/cli.py
ADDED
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
"""`quints` — Swiss VAT & accounting CLI for plain-text (beancount) books."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from datetime import date as Date
|
|
7
|
+
from decimal import Decimal, InvalidOperation
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from . import (
|
|
13
|
+
config as config_mod,
|
|
14
|
+
)
|
|
15
|
+
from . import (
|
|
16
|
+
fx as fx_mod,
|
|
17
|
+
)
|
|
18
|
+
from . import (
|
|
19
|
+
importing as importing_mod,
|
|
20
|
+
)
|
|
21
|
+
from . import (
|
|
22
|
+
inbox as inbox_mod,
|
|
23
|
+
)
|
|
24
|
+
from . import (
|
|
25
|
+
init as init_mod,
|
|
26
|
+
)
|
|
27
|
+
from . import (
|
|
28
|
+
kmu as kmu_mod,
|
|
29
|
+
)
|
|
30
|
+
from . import (
|
|
31
|
+
ledger,
|
|
32
|
+
ui,
|
|
33
|
+
)
|
|
34
|
+
from . import (
|
|
35
|
+
match as match_mod,
|
|
36
|
+
)
|
|
37
|
+
from . import (
|
|
38
|
+
mwst as mwst_mod,
|
|
39
|
+
)
|
|
40
|
+
from . import (
|
|
41
|
+
prices as prices_mod,
|
|
42
|
+
)
|
|
43
|
+
from . import (
|
|
44
|
+
receivables as recv_mod,
|
|
45
|
+
)
|
|
46
|
+
from . import (
|
|
47
|
+
settlement as settle_mod,
|
|
48
|
+
)
|
|
49
|
+
from . import (
|
|
50
|
+
vat as vat_mod,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
app = typer.Typer(
|
|
54
|
+
no_args_is_help=True,
|
|
55
|
+
add_completion=True,
|
|
56
|
+
help="quints — Swiss VAT & accounting for plain-text books (MWST, statements, imports).",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.callback()
|
|
61
|
+
def _main(
|
|
62
|
+
config: Path | None = typer.Option(
|
|
63
|
+
None, "--config", help="quints.toml path (default: ./quints.toml, else built-in defaults)."
|
|
64
|
+
),
|
|
65
|
+
):
|
|
66
|
+
config_mod.set_path(config)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
prices_app = typer.Typer(no_args_is_help=True, help="Price database (BAZG daily CHF rates).")
|
|
70
|
+
app.add_typer(prices_app, name="prices")
|
|
71
|
+
report_app = typer.Typer(
|
|
72
|
+
no_args_is_help=True,
|
|
73
|
+
help="Statutory statements grouped by the Swiss KMU chart of accounts (OR Art. 959a/959b).",
|
|
74
|
+
)
|
|
75
|
+
app.add_typer(report_app, name="report")
|
|
76
|
+
import_app = typer.Typer(
|
|
77
|
+
no_args_is_help=True,
|
|
78
|
+
help="Draft transactions from bank/PSP statements into staging/ (never books/).",
|
|
79
|
+
)
|
|
80
|
+
app.add_typer(import_app, name="import")
|
|
81
|
+
fx_app = typer.Typer(no_args_is_help=True, help="FX helpers (year-end revaluation).")
|
|
82
|
+
app.add_typer(fx_app, name="fx")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _lang_option() -> str:
|
|
86
|
+
return typer.Option(
|
|
87
|
+
None, "--lang", "-l", help="Report language: en or de (default from quints.toml)."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _emit(report, render, lang: str | None, as_json: bool) -> None:
|
|
92
|
+
lang = lang or config_mod.get().report_language
|
|
93
|
+
if as_json:
|
|
94
|
+
import dataclasses
|
|
95
|
+
import json
|
|
96
|
+
|
|
97
|
+
typer.echo(json.dumps(dataclasses.asdict(report), indent=2, default=str))
|
|
98
|
+
else:
|
|
99
|
+
render(report, lang=lang)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _file_option() -> Path:
|
|
103
|
+
return typer.Option(ledger.DEFAULT_LEDGER, "--file", "-f", help="Ledger file.")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _parse_date(text: str) -> Date:
|
|
107
|
+
try:
|
|
108
|
+
return Date.fromisoformat(text)
|
|
109
|
+
except ValueError:
|
|
110
|
+
typer.secho(f"ERROR: invalid date {text!r} (use YYYY-MM-DD)", fg="red", err=True)
|
|
111
|
+
raise typer.Exit(1) from None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _json_out(payload) -> None:
|
|
115
|
+
"""Emit a machine-readable payload (Decimals/dates as strings)."""
|
|
116
|
+
import json
|
|
117
|
+
|
|
118
|
+
typer.echo(json.dumps(payload, indent=2, default=str))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _require_ledger(file: Path) -> None:
|
|
122
|
+
if not file.exists():
|
|
123
|
+
typer.secho(f"ERROR: ledger not found: {file}", fg="red", err=True)
|
|
124
|
+
raise typer.Exit(1)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@app.command()
|
|
128
|
+
def vat(
|
|
129
|
+
amount: str = typer.Argument(
|
|
130
|
+
..., help="VAT amount in the invoice currency (or net price with --net)."
|
|
131
|
+
),
|
|
132
|
+
currency: str = typer.Argument(..., help="Invoice currency, e.g. USD or EUR."),
|
|
133
|
+
date: str = typer.Argument(
|
|
134
|
+
..., metavar="YYYY-MM-DD", help="Invoice date (picks the BAZG rate)."
|
|
135
|
+
),
|
|
136
|
+
net: bool = typer.Option(
|
|
137
|
+
False, "--net", help="Treat amount as the net price; VAT = 8.1% first."
|
|
138
|
+
),
|
|
139
|
+
bezugsteuer: bool = typer.Option(
|
|
140
|
+
False,
|
|
141
|
+
"--bezugsteuer",
|
|
142
|
+
"-b",
|
|
143
|
+
help="Reverse charge (Art. 45 MWSTG): amount is the net foreign invoice; "
|
|
144
|
+
"emit the InputVAT + Bezugsteuer posting pair (implies --net).",
|
|
145
|
+
),
|
|
146
|
+
as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
147
|
+
file: Path = _file_option(),
|
|
148
|
+
):
|
|
149
|
+
"""Convert a foreign-currency VAT amount to a CHF InputVAT posting."""
|
|
150
|
+
on = _parse_date(date)
|
|
151
|
+
net = net or bezugsteuer
|
|
152
|
+
try:
|
|
153
|
+
amt = Decimal(amount)
|
|
154
|
+
except InvalidOperation:
|
|
155
|
+
typer.secho(f"ERROR: invalid amount {amount!r}", fg="red", err=True)
|
|
156
|
+
raise typer.Exit(1) from None
|
|
157
|
+
_require_ledger(file)
|
|
158
|
+
|
|
159
|
+
price_map, errors = ledger.build_price_map(file)
|
|
160
|
+
if errors:
|
|
161
|
+
typer.secho(f"WARNING: {len(errors)} loader error(s) in {file.name}", fg="yellow", err=True)
|
|
162
|
+
try:
|
|
163
|
+
posting = vat_mod.convert(amt, currency, on, price_map, net=net)
|
|
164
|
+
except vat_mod.RateUnavailable as e:
|
|
165
|
+
typer.secho(
|
|
166
|
+
f"ERROR: no {e.ccy}→CHF rate on or before {e.on} in {file.name}.\n"
|
|
167
|
+
f" Fetch it: uv run quints prices sync",
|
|
168
|
+
fg="red",
|
|
169
|
+
err=True,
|
|
170
|
+
)
|
|
171
|
+
raise typer.Exit(1) from None
|
|
172
|
+
text = posting.render_bezugsteuer() if bezugsteuer else posting.render()
|
|
173
|
+
if as_json:
|
|
174
|
+
import dataclasses
|
|
175
|
+
|
|
176
|
+
_json_out({**dataclasses.asdict(posting), "bezugsteuer": bezugsteuer, "posting_text": text})
|
|
177
|
+
return
|
|
178
|
+
typer.echo(text)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@app.command()
|
|
182
|
+
def mwst(
|
|
183
|
+
quarter: str | None = typer.Option(
|
|
184
|
+
None, "--quarter", "-q", help="e.g. 2026-Q2 (instead of --from/--to)."
|
|
185
|
+
),
|
|
186
|
+
from_: str | None = typer.Option(None, "--from", help="Period start YYYY-MM-DD."),
|
|
187
|
+
to: str | None = typer.Option(None, "--to", help="Period end YYYY-MM-DD."),
|
|
188
|
+
settle: bool = typer.Option(
|
|
189
|
+
False, "--settle", help="Also print the settlement transaction to paste (period close)."
|
|
190
|
+
),
|
|
191
|
+
as_json: bool = typer.Option(
|
|
192
|
+
False, "--json", help="Machine-readable output (not with --settle)."
|
|
193
|
+
),
|
|
194
|
+
file: Path = _file_option(),
|
|
195
|
+
):
|
|
196
|
+
"""Swiss MWST (VAT) report for a reporting period."""
|
|
197
|
+
if as_json and settle:
|
|
198
|
+
typer.secho("ERROR: --json and --settle are mutually exclusive.", fg="red", err=True)
|
|
199
|
+
raise typer.Exit(1)
|
|
200
|
+
if quarter:
|
|
201
|
+
try:
|
|
202
|
+
date_from, date_to = mwst_mod.quarter_range(quarter)
|
|
203
|
+
except ValueError as e:
|
|
204
|
+
typer.secho(f"ERROR: {e}", fg="red", err=True)
|
|
205
|
+
raise typer.Exit(1) from None
|
|
206
|
+
label = quarter.upper().replace(" ", "")
|
|
207
|
+
elif from_ and to:
|
|
208
|
+
date_from = _parse_date(from_).isoformat()
|
|
209
|
+
date_to = _parse_date(to).isoformat()
|
|
210
|
+
label = None
|
|
211
|
+
else:
|
|
212
|
+
typer.secho("ERROR: provide --quarter, or both --from and --to.", fg="red", err=True)
|
|
213
|
+
raise typer.Exit(1)
|
|
214
|
+
_require_ledger(file)
|
|
215
|
+
report = mwst_mod.compute(file, date_from, date_to)
|
|
216
|
+
if as_json:
|
|
217
|
+
import dataclasses
|
|
218
|
+
import json
|
|
219
|
+
|
|
220
|
+
typer.echo(json.dumps(dataclasses.asdict(report), indent=2, default=str))
|
|
221
|
+
return
|
|
222
|
+
mwst_mod.render(report)
|
|
223
|
+
if settle:
|
|
224
|
+
settle_mod.render_settlement(settle_mod.build_settlement(file, report, label))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@app.command()
|
|
228
|
+
def status(
|
|
229
|
+
as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
230
|
+
file: Path = _file_option(),
|
|
231
|
+
):
|
|
232
|
+
"""Outstanding VAT owed to the ESTV (filed but unpaid), with due dates."""
|
|
233
|
+
_require_ledger(file)
|
|
234
|
+
liabilities, unlinked, total, today = settle_mod.outstanding(file)
|
|
235
|
+
if as_json:
|
|
236
|
+
import dataclasses
|
|
237
|
+
import json
|
|
238
|
+
|
|
239
|
+
typer.echo(
|
|
240
|
+
json.dumps(
|
|
241
|
+
{
|
|
242
|
+
"today": str(today),
|
|
243
|
+
"liabilities": [dataclasses.asdict(liab) for liab in liabilities],
|
|
244
|
+
"unlinked_owed": str(unlinked),
|
|
245
|
+
"total_owed": str(total),
|
|
246
|
+
},
|
|
247
|
+
indent=2,
|
|
248
|
+
default=str,
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
return
|
|
252
|
+
settle_mod.render_status(liabilities, unlinked, total, today)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@app.command()
|
|
256
|
+
def receivables(
|
|
257
|
+
at: str | None = typer.Option(
|
|
258
|
+
None, "--at", metavar="YYYY-MM-DD", help="Aging as of this date (default: today)."
|
|
259
|
+
),
|
|
260
|
+
as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
261
|
+
file: Path = _file_option(),
|
|
262
|
+
):
|
|
263
|
+
"""Open invoices (aging), grouped by invoice id against Receivable:Trade."""
|
|
264
|
+
_require_ledger(file)
|
|
265
|
+
open_invoices, ref = recv_mod.compute(file, _parse_date(at) if at else None)
|
|
266
|
+
if as_json:
|
|
267
|
+
import dataclasses
|
|
268
|
+
import json
|
|
269
|
+
|
|
270
|
+
typer.echo(
|
|
271
|
+
json.dumps(
|
|
272
|
+
{"at": str(ref), "open": [dataclasses.asdict(o) for o in open_invoices]},
|
|
273
|
+
indent=2,
|
|
274
|
+
default=str,
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
return
|
|
278
|
+
recv_mod.render(open_invoices, ref)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@app.command()
|
|
282
|
+
def inbox(
|
|
283
|
+
as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
284
|
+
file: Path = _file_option(),
|
|
285
|
+
):
|
|
286
|
+
"""Inventory inbox/ documents: filename hints, duplicates, already-linked."""
|
|
287
|
+
_require_ledger(file)
|
|
288
|
+
docs = inbox_mod.compute(file)
|
|
289
|
+
if as_json:
|
|
290
|
+
import dataclasses
|
|
291
|
+
|
|
292
|
+
_json_out({"inbox": [dataclasses.asdict(d) for d in docs]})
|
|
293
|
+
return
|
|
294
|
+
inbox_mod.render(docs)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@app.command()
|
|
298
|
+
def match(
|
|
299
|
+
staging: Path | None = typer.Option(
|
|
300
|
+
None, "--staging", help="Staging directory (default: ./staging)."
|
|
301
|
+
),
|
|
302
|
+
as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
303
|
+
file: Path = _file_option(),
|
|
304
|
+
):
|
|
305
|
+
"""Match staging drafts and inbox documents to invoices and bookings (scored)."""
|
|
306
|
+
_require_ledger(file)
|
|
307
|
+
results = match_mod.compute(file, staging_dir=staging)
|
|
308
|
+
if as_json:
|
|
309
|
+
import dataclasses
|
|
310
|
+
|
|
311
|
+
_json_out({"matches": [dataclasses.asdict(m) for m in results]})
|
|
312
|
+
return
|
|
313
|
+
match_mod.render(results)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@prices_app.command("sync")
|
|
317
|
+
def prices_sync(
|
|
318
|
+
out: Path = typer.Option(
|
|
319
|
+
ledger.DEFAULT_PRICES, "--out", help="Price file (default: prices.bean)."
|
|
320
|
+
),
|
|
321
|
+
from_: str | None = typer.Option(
|
|
322
|
+
None,
|
|
323
|
+
"--from",
|
|
324
|
+
help="Repair: re-scan from this date and fill ANY missing days (heals interior gaps).",
|
|
325
|
+
),
|
|
326
|
+
as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
327
|
+
):
|
|
328
|
+
"""Fetch new BAZG daily CHF rates into the price file (full precision, gap-aware).
|
|
329
|
+
|
|
330
|
+
Without --from: extend each currency forward to today (fast, daily use).
|
|
331
|
+
With --from DATE: re-scan the whole range and fill any missing days.
|
|
332
|
+
"""
|
|
333
|
+
repair = _parse_date(from_) if from_ else None
|
|
334
|
+
result = prices_mod.sync(out, repair_from=repair)
|
|
335
|
+
if as_json:
|
|
336
|
+
_json_out(
|
|
337
|
+
{
|
|
338
|
+
"file": str(out),
|
|
339
|
+
"wrote": result.wrote,
|
|
340
|
+
"added": result.added,
|
|
341
|
+
"per_currency": {
|
|
342
|
+
ccy: {"added": added, "had_through": last}
|
|
343
|
+
for ccy, (added, last) in result.per_currency.items()
|
|
344
|
+
},
|
|
345
|
+
}
|
|
346
|
+
)
|
|
347
|
+
return
|
|
348
|
+
for ccy, (added, last) in result.per_currency.items():
|
|
349
|
+
where = f"had through {last}" if last else "was empty"
|
|
350
|
+
typer.echo(f"{ccy}: +{added} rate(s) ({where}).")
|
|
351
|
+
if result.wrote:
|
|
352
|
+
typer.secho(f"Wrote {result.added} new price(s) to {out.name} (sorted).", fg="green")
|
|
353
|
+
else:
|
|
354
|
+
typer.echo(f"{out.name} already current.")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@report_app.command()
|
|
358
|
+
def bilanz(
|
|
359
|
+
at: str = typer.Option(..., "--at", metavar="YYYY-MM-DD", help="Report date."),
|
|
360
|
+
lang: str = _lang_option(),
|
|
361
|
+
as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
362
|
+
file: Path = _file_option(),
|
|
363
|
+
):
|
|
364
|
+
"""Balance sheet (Bilanz, OR Art. 959a) grouped by KMU codes."""
|
|
365
|
+
_parse_date(at)
|
|
366
|
+
_require_ledger(file)
|
|
367
|
+
_emit(kmu_mod.compute_bilanz(file, at), kmu_mod.render_bilanz, lang, as_json)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@report_app.command()
|
|
371
|
+
def erfolg(
|
|
372
|
+
from_: str | None = typer.Option(None, "--from", metavar="YYYY-MM-DD"),
|
|
373
|
+
to: str | None = typer.Option(None, "--to", metavar="YYYY-MM-DD"),
|
|
374
|
+
year: int | None = typer.Option(None, "--year", help="Shortcut for a calendar year."),
|
|
375
|
+
lang: str = _lang_option(),
|
|
376
|
+
as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
377
|
+
file: Path = _file_option(),
|
|
378
|
+
):
|
|
379
|
+
"""Income statement (Erfolgsrechnung, OR Art. 959b) grouped by KMU codes."""
|
|
380
|
+
date_from, date_to = _period(from_, to, year)
|
|
381
|
+
_require_ledger(file)
|
|
382
|
+
_emit(kmu_mod.compute_erfolg(file, date_from, date_to), kmu_mod.render_erfolg, lang, as_json)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@report_app.command()
|
|
386
|
+
def konten(
|
|
387
|
+
from_: str | None = typer.Option(None, "--from", metavar="YYYY-MM-DD"),
|
|
388
|
+
to: str | None = typer.Option(None, "--to", metavar="YYYY-MM-DD"),
|
|
389
|
+
year: int | None = typer.Option(None, "--year", help="Shortcut for a calendar year."),
|
|
390
|
+
lang: str = _lang_option(),
|
|
391
|
+
as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
392
|
+
file: Path = _file_option(),
|
|
393
|
+
):
|
|
394
|
+
"""Per-KMU-code transaction listings (Kontoblätter) — auditor detail."""
|
|
395
|
+
date_from, date_to = _period(from_, to, year)
|
|
396
|
+
_require_ledger(file)
|
|
397
|
+
_emit(kmu_mod.compute_konten(file, date_from, date_to), kmu_mod.render_konten, lang, as_json)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@report_app.command()
|
|
401
|
+
def statements(
|
|
402
|
+
year: int = typer.Option(..., "--year", help="Fiscal year."),
|
|
403
|
+
at: str | None = typer.Option(
|
|
404
|
+
None, "--at", metavar="YYYY-MM-DD", help="Balance-sheet date (default: <year>-12-31)."
|
|
405
|
+
),
|
|
406
|
+
lang: str = _lang_option(),
|
|
407
|
+
out: Path | None = typer.Option(None, "--out", "-o", help="Output PDF path."),
|
|
408
|
+
file: Path = _file_option(),
|
|
409
|
+
):
|
|
410
|
+
"""Bilanz + Erfolgsrechnung as one PDF for the Treuhänder/auditor."""
|
|
411
|
+
from . import report_pdf
|
|
412
|
+
|
|
413
|
+
_require_ledger(file)
|
|
414
|
+
balance_date = at or f"{year}-12-31"
|
|
415
|
+
_parse_date(balance_date)
|
|
416
|
+
lang = lang or config_mod.get().report_language
|
|
417
|
+
bilanz_report = kmu_mod.compute_bilanz(file, balance_date)
|
|
418
|
+
erfolg_report = kmu_mod.compute_erfolg(file, f"{year}-01-01", f"{year}-12-31")
|
|
419
|
+
out = out or Path(f"statements-{year}-{lang}.pdf")
|
|
420
|
+
path = report_pdf.render_pdf(bilanz_report, erfolg_report, lang, out)
|
|
421
|
+
typer.secho(f"Wrote {path}", fg="green")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
@fx_app.command("revalue")
|
|
425
|
+
def fx_revalue(
|
|
426
|
+
at: str = typer.Option(
|
|
427
|
+
..., "--at", metavar="YYYY-MM-DD", help="Revaluation date (usually 12-31)."
|
|
428
|
+
),
|
|
429
|
+
as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
430
|
+
file: Path = _file_option(),
|
|
431
|
+
):
|
|
432
|
+
"""Print the year-end FX revaluation transaction(s) to paste (Art. 960 OR)."""
|
|
433
|
+
_parse_date(at)
|
|
434
|
+
_require_ledger(file)
|
|
435
|
+
try:
|
|
436
|
+
revaluations = fx_mod.compute(file, at)
|
|
437
|
+
except fx_mod.RateUnavailable as e:
|
|
438
|
+
typer.secho(
|
|
439
|
+
f"ERROR: {e}.\n Fetch rates: uv run quints prices sync", fg="red", err=True
|
|
440
|
+
)
|
|
441
|
+
raise typer.Exit(1) from None
|
|
442
|
+
if as_json:
|
|
443
|
+
import dataclasses
|
|
444
|
+
|
|
445
|
+
_json_out(
|
|
446
|
+
{
|
|
447
|
+
"at": at,
|
|
448
|
+
"revaluations": [{**dataclasses.asdict(r), "delta": r.delta} for r in revaluations],
|
|
449
|
+
}
|
|
450
|
+
)
|
|
451
|
+
return
|
|
452
|
+
fx_mod.render(revaluations, at)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@import_app.command("ubs")
|
|
456
|
+
def import_ubs(
|
|
457
|
+
statement: Path = typer.Argument(..., help="UBS MT940 statement file."),
|
|
458
|
+
out: Path = typer.Option(importing_mod.DEFAULT_STAGING, "--out", help="Staging directory."),
|
|
459
|
+
as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
460
|
+
file: Path = _file_option(),
|
|
461
|
+
):
|
|
462
|
+
"""Draft the UBS CHF account's activity from an MT940 statement."""
|
|
463
|
+
_require_ledger(file)
|
|
464
|
+
if not statement.exists():
|
|
465
|
+
typer.secho(f"ERROR: statement not found: {statement}", fg="red", err=True)
|
|
466
|
+
raise typer.Exit(1)
|
|
467
|
+
try:
|
|
468
|
+
result = importing_mod.run_ubs(statement, file, out)
|
|
469
|
+
except ValueError as e:
|
|
470
|
+
typer.secho(f"ERROR: {e}", fg="red", err=True)
|
|
471
|
+
raise typer.Exit(1) from None
|
|
472
|
+
|
|
473
|
+
_report_import(result, as_json)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@import_app.command("wise")
|
|
477
|
+
def import_wise(
|
|
478
|
+
statements: list[Path] | None = typer.Argument(None, help="Wise statement.json file(s)."),
|
|
479
|
+
fetch: bool = typer.Option(False, "--fetch", help="Fetch statements from the Wise API first."),
|
|
480
|
+
from_: str | None = typer.Option(None, "--from", metavar="YYYY-MM-DD"),
|
|
481
|
+
to: str | None = typer.Option(None, "--to", metavar="YYYY-MM-DD"),
|
|
482
|
+
out: Path = typer.Option(importing_mod.DEFAULT_STAGING, "--out", help="Staging directory."),
|
|
483
|
+
as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
484
|
+
file: Path = _file_option(),
|
|
485
|
+
):
|
|
486
|
+
"""Draft the Wise balances' activity (all currencies, conversions merged)."""
|
|
487
|
+
_require_ledger(file)
|
|
488
|
+
if fetch:
|
|
489
|
+
if not (from_ and to):
|
|
490
|
+
typer.secho("ERROR: --fetch needs --from and --to.", fg="red", err=True)
|
|
491
|
+
raise typer.Exit(1)
|
|
492
|
+
_parse_date(from_)
|
|
493
|
+
_parse_date(to)
|
|
494
|
+
try:
|
|
495
|
+
statements = importing_mod.fetch_wise(from_, to, out)
|
|
496
|
+
except importing_mod.WiseError as e:
|
|
497
|
+
typer.secho(f"ERROR: {e}", fg="red", err=True)
|
|
498
|
+
if isinstance(e, importing_mod.ScaChallenge):
|
|
499
|
+
typer.secho(
|
|
500
|
+
"Statements are SCA-protected: upload .wise/public.pem in Wise "
|
|
501
|
+
"(Settings → API tokens → Manage public keys) and set "
|
|
502
|
+
"QUINTS_WISE_PRIVATE_KEY in .env.",
|
|
503
|
+
fg="yellow",
|
|
504
|
+
err=True,
|
|
505
|
+
)
|
|
506
|
+
raise typer.Exit(1) from None
|
|
507
|
+
for path in statements:
|
|
508
|
+
typer.echo(f"fetched {path}")
|
|
509
|
+
if not statements:
|
|
510
|
+
typer.secho("ERROR: pass statement.json file(s) or --fetch.", fg="red", err=True)
|
|
511
|
+
raise typer.Exit(1)
|
|
512
|
+
try:
|
|
513
|
+
result = importing_mod.run_wise(list(statements), file, out)
|
|
514
|
+
except ValueError as e:
|
|
515
|
+
typer.secho(f"ERROR: {e}", fg="red", err=True)
|
|
516
|
+
raise typer.Exit(1) from None
|
|
517
|
+
_report_import(result, as_json)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
@import_app.command("stripe")
|
|
521
|
+
def import_stripe(
|
|
522
|
+
statements: list[Path] | None = typer.Argument(
|
|
523
|
+
None, help="Stripe balance-transactions JSON file(s)."
|
|
524
|
+
),
|
|
525
|
+
fetch: bool = typer.Option(
|
|
526
|
+
False, "--fetch", help="Fetch balance transactions from the Stripe API first."
|
|
527
|
+
),
|
|
528
|
+
from_: str | None = typer.Option(None, "--from", metavar="YYYY-MM-DD"),
|
|
529
|
+
to: str | None = typer.Option(None, "--to", metavar="YYYY-MM-DD"),
|
|
530
|
+
out: Path = typer.Option(importing_mod.DEFAULT_STAGING, "--out", help="Staging directory."),
|
|
531
|
+
as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
532
|
+
file: Path = _file_option(),
|
|
533
|
+
):
|
|
534
|
+
"""Draft the Stripe balance's activity (charges, monthly fees, payouts)."""
|
|
535
|
+
_require_ledger(file)
|
|
536
|
+
if fetch:
|
|
537
|
+
if not (from_ and to):
|
|
538
|
+
typer.secho("ERROR: --fetch needs --from and --to.", fg="red", err=True)
|
|
539
|
+
raise typer.Exit(1)
|
|
540
|
+
_parse_date(from_)
|
|
541
|
+
_parse_date(to)
|
|
542
|
+
try:
|
|
543
|
+
statements = importing_mod.fetch_stripe(from_, to, out)
|
|
544
|
+
except importing_mod.StripeError as e:
|
|
545
|
+
typer.secho(f"ERROR: {e}", fg="red", err=True)
|
|
546
|
+
typer.secho(
|
|
547
|
+
"Fetching needs QUINTS_STRIPE_API_KEY in .env — a restricted key "
|
|
548
|
+
"(Balance transaction sources: Read + Charges: Read) for the "
|
|
549
|
+
"account configured as [import.stripe] account_id.",
|
|
550
|
+
fg="yellow",
|
|
551
|
+
err=True,
|
|
552
|
+
)
|
|
553
|
+
raise typer.Exit(1) from None
|
|
554
|
+
for path in statements:
|
|
555
|
+
typer.echo(f"fetched {path}")
|
|
556
|
+
if not statements:
|
|
557
|
+
typer.secho("ERROR: pass balance-transactions JSON file(s) or --fetch.", fg="red", err=True)
|
|
558
|
+
raise typer.Exit(1)
|
|
559
|
+
try:
|
|
560
|
+
result = importing_mod.run_stripe(list(statements), file, out)
|
|
561
|
+
except ValueError as e:
|
|
562
|
+
typer.secho(f"ERROR: {e}", fg="red", err=True)
|
|
563
|
+
raise typer.Exit(1) from None
|
|
564
|
+
_report_import(result, as_json)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _report_import(result, as_json: bool = False) -> None:
|
|
568
|
+
if as_json:
|
|
569
|
+
|
|
570
|
+
def txn(t):
|
|
571
|
+
u = t.postings[0].units
|
|
572
|
+
return {
|
|
573
|
+
"date": str(t.date),
|
|
574
|
+
"flag": t.flag,
|
|
575
|
+
"payee": t.payee,
|
|
576
|
+
"narration": t.narration,
|
|
577
|
+
"amount": u.number,
|
|
578
|
+
"currency": u.currency,
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
_json_out(
|
|
582
|
+
{
|
|
583
|
+
"source": result.source,
|
|
584
|
+
"staging_file": str(result.out_path) if result.out_path else None,
|
|
585
|
+
"skipped_ref": result.skipped_ref,
|
|
586
|
+
"drafts": [txn(t) for t in result.drafts],
|
|
587
|
+
"legacy_matches": [{**txn(t), "booked": str(d)} for t, d in result.legacy_matches],
|
|
588
|
+
"receivable_matches": [
|
|
589
|
+
{**txn(t), "invoice": n} for n, t in result.receivable_matches
|
|
590
|
+
],
|
|
591
|
+
"balances": [
|
|
592
|
+
{
|
|
593
|
+
"date": str(b.date),
|
|
594
|
+
"account": b.account,
|
|
595
|
+
"amount": b.amount.number,
|
|
596
|
+
"currency": b.amount.currency,
|
|
597
|
+
}
|
|
598
|
+
for b in result.balances
|
|
599
|
+
],
|
|
600
|
+
}
|
|
601
|
+
)
|
|
602
|
+
return
|
|
603
|
+
if result.skipped_ref:
|
|
604
|
+
typer.echo(f"{result.skipped_ref} entr(ies) already imported (reference match) — skipped.")
|
|
605
|
+
if result.legacy_matches:
|
|
606
|
+
typer.echo(
|
|
607
|
+
f"{len(result.legacy_matches)} entr(ies) matched already-booked postings "
|
|
608
|
+
f"(amount within ±{importing_mod.LEGACY_WINDOW_DAYS}d):"
|
|
609
|
+
)
|
|
610
|
+
for draft, booked in result.legacy_matches:
|
|
611
|
+
units = draft.postings[0].units
|
|
612
|
+
typer.echo(f" {draft.date} {units} {draft.payee} → booked {booked}")
|
|
613
|
+
if result.receivable_matches:
|
|
614
|
+
typer.secho(
|
|
615
|
+
f"{len(result.receivable_matches)} payment(s) matched open invoices "
|
|
616
|
+
f"(receivable clearing drafted):",
|
|
617
|
+
fg="green",
|
|
618
|
+
)
|
|
619
|
+
for number, draft in result.receivable_matches:
|
|
620
|
+
typer.echo(f" {draft.date} {draft.postings[0].units} ^{number}")
|
|
621
|
+
if result.drafts:
|
|
622
|
+
typer.secho(f"{len(result.drafts)} draft(s) → {result.out_path}", fg="green")
|
|
623
|
+
for draft in result.drafts:
|
|
624
|
+
typer.echo(f" {draft.flag} {draft.date} {draft.postings[0].units} {draft.payee}")
|
|
625
|
+
else:
|
|
626
|
+
typer.echo("No new drafts.")
|
|
627
|
+
for balance in result.balances:
|
|
628
|
+
typer.echo(f"Closing balance assertion: {balance.date} {balance.amount} (in staging file).")
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _period(from_: str | None, to: str | None, year: int | None) -> tuple[str, str]:
|
|
632
|
+
if year is not None:
|
|
633
|
+
return f"{year}-01-01", f"{year}-12-31"
|
|
634
|
+
if from_ and to:
|
|
635
|
+
return _parse_date(from_).isoformat(), _parse_date(to).isoformat()
|
|
636
|
+
typer.secho("ERROR: provide --year, or both --from and --to.", fg="red", err=True)
|
|
637
|
+
raise typer.Exit(1)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
@app.command()
|
|
641
|
+
def check(
|
|
642
|
+
as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
643
|
+
file: Path = _file_option(),
|
|
644
|
+
):
|
|
645
|
+
"""Validate the ledger (bean-check equivalent)."""
|
|
646
|
+
_require_ledger(file)
|
|
647
|
+
_entries, errors = ledger.load_entries(file)
|
|
648
|
+
if as_json:
|
|
649
|
+
_json_out(
|
|
650
|
+
{
|
|
651
|
+
"ok": not errors,
|
|
652
|
+
"errors": [
|
|
653
|
+
{
|
|
654
|
+
"file": (e.source or {}).get("filename"),
|
|
655
|
+
"line": (e.source or {}).get("lineno"),
|
|
656
|
+
"message": e.message,
|
|
657
|
+
}
|
|
658
|
+
for e in errors
|
|
659
|
+
],
|
|
660
|
+
}
|
|
661
|
+
)
|
|
662
|
+
raise typer.Exit(1 if errors else 0)
|
|
663
|
+
if errors:
|
|
664
|
+
from beancount.parser import printer
|
|
665
|
+
|
|
666
|
+
printer.print_errors(errors)
|
|
667
|
+
typer.secho(f"{len(errors)} error(s).", fg="red", err=True)
|
|
668
|
+
raise typer.Exit(1)
|
|
669
|
+
typer.secho("OK — no errors.", fg="green")
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
@app.command()
|
|
673
|
+
def invoice(
|
|
674
|
+
data: Path = typer.Argument(..., help="Invoice file (.yaml/.toml/.json)."),
|
|
675
|
+
out: Path | None = typer.Option(
|
|
676
|
+
None, "--out", "-o", help="Output PDF (default: <number>.pdf)."
|
|
677
|
+
),
|
|
678
|
+
issuer: Path = typer.Option(
|
|
679
|
+
Path("invoicing/issuer.yaml"), "--issuer", help="Issuer config (.yaml/.toml/.json)."
|
|
680
|
+
),
|
|
681
|
+
customers: Path = typer.Option(
|
|
682
|
+
Path("invoicing/customers.yaml"),
|
|
683
|
+
"--customers",
|
|
684
|
+
help="Customer registry (.yaml/.toml/.json).",
|
|
685
|
+
),
|
|
686
|
+
file: Path = _file_option(),
|
|
687
|
+
verify: bool = typer.Option(
|
|
688
|
+
True, "--verify/--no-verify", help="Cross-check total against the ledger."
|
|
689
|
+
),
|
|
690
|
+
as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
691
|
+
):
|
|
692
|
+
"""Render a Swiss QR-bill invoice PDF (domestic or export)."""
|
|
693
|
+
import dataclasses
|
|
694
|
+
|
|
695
|
+
from .invoice import draft as dr
|
|
696
|
+
from .invoice import model as m
|
|
697
|
+
from .invoice import render as r
|
|
698
|
+
from .invoice import verify as v
|
|
699
|
+
|
|
700
|
+
for p, what in [(data, "invoice file"), (issuer, "issuer config")]:
|
|
701
|
+
if not p.exists():
|
|
702
|
+
typer.secho(f"ERROR: {what} not found: {p}", fg="red", err=True)
|
|
703
|
+
raise typer.Exit(1)
|
|
704
|
+
|
|
705
|
+
registry = m.load_customers(customers) if customers.exists() else None
|
|
706
|
+
inv = m.load_invoice(data, registry)
|
|
707
|
+
iss = m.load_issuer(issuer)
|
|
708
|
+
out = out or Path(f"{inv.number}.pdf")
|
|
709
|
+
path, totals, payload = r.render(inv, iss, out)
|
|
710
|
+
|
|
711
|
+
qr_ok = None
|
|
712
|
+
if payload:
|
|
713
|
+
lines = payload.splitlines()
|
|
714
|
+
qr_ok = lines[:1] == ["SPC"] and lines[-1] == "EPD"
|
|
715
|
+
if not as_json:
|
|
716
|
+
ui.console.print(
|
|
717
|
+
f"[ok]Wrote[/] {path} · {inv.kind} · {inv.currency} {m.money(totals.grand_total)}"
|
|
718
|
+
)
|
|
719
|
+
if payload:
|
|
720
|
+
ui.console.print(
|
|
721
|
+
f"[muted]QR-bill payload: {'SPC…EPD ✓' if qr_ok else 'CHECK!'} "
|
|
722
|
+
f"({len(payload.splitlines())} lines, ref {inv.reference or 'auto-QRR'})[/]"
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
cc = None
|
|
726
|
+
ledger_draft = None
|
|
727
|
+
if verify and file.exists():
|
|
728
|
+
cc = v.cross_check(file, inv, totals)
|
|
729
|
+
if not cc.found:
|
|
730
|
+
ledger_draft = dr.build_draft(inv, totals)
|
|
731
|
+
if not as_json:
|
|
732
|
+
if not cc.found:
|
|
733
|
+
ui.console.print(
|
|
734
|
+
f"[warn]No ledger txn for {inv.number}[/] — paste this draft "
|
|
735
|
+
f"into books/{inv.issue_date.year}.bean:\n"
|
|
736
|
+
)
|
|
737
|
+
print(ledger_draft)
|
|
738
|
+
print()
|
|
739
|
+
elif cc.ok:
|
|
740
|
+
ui.console.print(
|
|
741
|
+
f"[ok]Ledger match[/] ({cc.date}): {inv.currency} {m.money(cc.ledger_total)}"
|
|
742
|
+
)
|
|
743
|
+
if not cc.date_ok:
|
|
744
|
+
ui.console.print(
|
|
745
|
+
f"[warn]booking date {cc.date} ≠ invoice date {inv.issue_date}[/]"
|
|
746
|
+
)
|
|
747
|
+
else:
|
|
748
|
+
ui.console.print(
|
|
749
|
+
f"[err]Ledger CONFLICT[/] ({cc.date}): {inv.number} is already booked "
|
|
750
|
+
f"at {m.money(cc.ledger_total)} but the invoice says "
|
|
751
|
+
f"{m.money(cc.invoice_total)} {inv.currency} — fix one side before issuing."
|
|
752
|
+
)
|
|
753
|
+
elif verify and not as_json:
|
|
754
|
+
ui.console.print("[warn]ledger not found — cross-check skipped.[/]")
|
|
755
|
+
|
|
756
|
+
if as_json:
|
|
757
|
+
_json_out(
|
|
758
|
+
{
|
|
759
|
+
"number": inv.number,
|
|
760
|
+
"kind": inv.kind,
|
|
761
|
+
"currency": inv.currency,
|
|
762
|
+
"issue_date": inv.issue_date,
|
|
763
|
+
"customer": inv.customer.name,
|
|
764
|
+
"pdf": str(path),
|
|
765
|
+
"totals": totals.model_dump(),
|
|
766
|
+
"qr_payload_ok": qr_ok,
|
|
767
|
+
"cross_check": dataclasses.asdict(cc) if cc else None,
|
|
768
|
+
"ledger_draft": ledger_draft,
|
|
769
|
+
}
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
@app.command()
|
|
774
|
+
def schema(
|
|
775
|
+
out: Path = typer.Option(
|
|
776
|
+
Path("invoicing/schema"), "--out", "-o", help="Directory for the generated JSON Schemas."
|
|
777
|
+
),
|
|
778
|
+
):
|
|
779
|
+
"""Write JSON Schemas for the invoice, issuer, and customers files.
|
|
780
|
+
|
|
781
|
+
Point an editor at them (yaml-language-server modeline) for completion
|
|
782
|
+
and validation; any future UI can consume the same contract."""
|
|
783
|
+
import json as _json
|
|
784
|
+
|
|
785
|
+
from .invoice import model as m
|
|
786
|
+
|
|
787
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
788
|
+
for name, mdl in [
|
|
789
|
+
("invoice", m.Invoice),
|
|
790
|
+
("issuer", m.Issuer),
|
|
791
|
+
("customers", m.CustomerRegistry),
|
|
792
|
+
]:
|
|
793
|
+
path = out / f"{name}.schema.json"
|
|
794
|
+
path.write_text(_json.dumps(mdl.model_json_schema(), indent=2) + "\n")
|
|
795
|
+
ui.console.print(f"[ok]Wrote[/] {path}")
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
@app.command()
|
|
799
|
+
def init(
|
|
800
|
+
directory: Path = typer.Argument(
|
|
801
|
+
Path("."), help="Target project directory (created if missing)."
|
|
802
|
+
),
|
|
803
|
+
name: str | None = typer.Option(None, "--name", help="Entity name, e.g. 'Acme GmbH'."),
|
|
804
|
+
lang: str | None = typer.Option(None, "--lang", "-l", help="Report language: en or de."),
|
|
805
|
+
importers: str | None = typer.Option(
|
|
806
|
+
None, "--importers", help="Comma-separated: ubs, wise, stripe (default: none)."
|
|
807
|
+
),
|
|
808
|
+
samples: bool = typer.Option(
|
|
809
|
+
False, "--samples", help="Include a demo quarter of transactions."
|
|
810
|
+
),
|
|
811
|
+
answers_file: Path | None = typer.Option(
|
|
812
|
+
None, "--answers", help="TOML answer-file for non-interactive scaffolding."
|
|
813
|
+
),
|
|
814
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing files."),
|
|
815
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip prompts; accept defaults."),
|
|
816
|
+
as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
817
|
+
):
|
|
818
|
+
"""Scaffold a new quints project (chart of accounts, quints.toml, AGENTS.md).
|
|
819
|
+
|
|
820
|
+
Deterministic: the same answers always produce the same files. Run
|
|
821
|
+
interactively, or feed a TOML answer-file with --answers for CI/repeatable
|
|
822
|
+
setups."""
|
|
823
|
+
if answers_file is not None:
|
|
824
|
+
if not answers_file.exists():
|
|
825
|
+
typer.secho(f"ERROR: answer-file not found: {answers_file}", fg="red", err=True)
|
|
826
|
+
raise typer.Exit(1)
|
|
827
|
+
answers = init_mod.load_answers(answers_file)
|
|
828
|
+
else:
|
|
829
|
+
answers = init_mod.Answers()
|
|
830
|
+
|
|
831
|
+
interactive = answers_file is None and not yes
|
|
832
|
+
if name is not None:
|
|
833
|
+
answers = replace(answers, entity_name=name)
|
|
834
|
+
elif interactive:
|
|
835
|
+
answers = replace(
|
|
836
|
+
answers, entity_name=typer.prompt("Entity name", default=answers.entity_name)
|
|
837
|
+
)
|
|
838
|
+
if lang is not None:
|
|
839
|
+
answers = replace(answers, report_language=lang)
|
|
840
|
+
elif interactive:
|
|
841
|
+
answers = replace(
|
|
842
|
+
answers,
|
|
843
|
+
report_language=typer.prompt(
|
|
844
|
+
"Report language (en/de)", default=answers.report_language
|
|
845
|
+
),
|
|
846
|
+
)
|
|
847
|
+
if importers is not None:
|
|
848
|
+
answers = replace(
|
|
849
|
+
answers, importers=tuple(i.strip() for i in importers.split(",") if i.strip())
|
|
850
|
+
)
|
|
851
|
+
if samples:
|
|
852
|
+
answers = replace(answers, include_samples=True)
|
|
853
|
+
|
|
854
|
+
try:
|
|
855
|
+
files = init_mod.plan(answers)
|
|
856
|
+
except init_mod.InitError as e:
|
|
857
|
+
typer.secho(f"ERROR: {e}", fg="red", err=True)
|
|
858
|
+
raise typer.Exit(1) from None
|
|
859
|
+
result = init_mod.write(directory, files, force=force)
|
|
860
|
+
|
|
861
|
+
if as_json:
|
|
862
|
+
_json_out(
|
|
863
|
+
{
|
|
864
|
+
"directory": str(directory),
|
|
865
|
+
"entity": answers.entity_name,
|
|
866
|
+
"written": [str(p) for p in result.written],
|
|
867
|
+
"skipped": [str(p) for p in result.skipped],
|
|
868
|
+
}
|
|
869
|
+
)
|
|
870
|
+
return
|
|
871
|
+
for path in result.written:
|
|
872
|
+
ui.console.print(f"[ok]created[/] {path}")
|
|
873
|
+
for path in result.skipped:
|
|
874
|
+
ui.console.print(f"[warn]exists, skipped[/] {path} (use --force to overwrite)")
|
|
875
|
+
if result.written and not result.skipped:
|
|
876
|
+
ui.console.print(
|
|
877
|
+
f"\nScaffolded [b]{answers.entity_name}[/] in {directory}. "
|
|
878
|
+
"Next: [b]quints check[/] then [b]quints mwst -q 2026-Q3[/]."
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def main() -> None:
|
|
883
|
+
app()
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
if __name__ == "__main__":
|
|
887
|
+
main()
|