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/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()