Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,29 @@ Versionen folgen [Semantic Versioning](https://semver.org/lang/de/).

## [Unreleased]

## [0.17.0]

> **Polish-Release.** Detail-Schliff an der UX der drei Editor-Screens — der unschöne Inline-Period-Picker auf Positions-Ebene ist gegen einen kompakten Popover mit Tab-Switcher (Einzeltag/Zeitraum) ausgetauscht. Dazu eine größere Test-Investition (Vitest +47, Pytest +19, Pytest-Stdin-Bug behoben) und die DB-Math-Module dedupliziert.

### Changed
- **Positions-Zeitraum (BG-26, BT-134/135) bekommt eine eigene Popover-Komponente.** Vorher war die Periode in der schmalen Beschreibungs-Spalte als aufgeklappter Bereich mit zwei nebeneinander stehenden Text-Toggles (Mode-Wechsel + Entfernen) implementiert — visuell beengt, das Umschalten zwischen Einzeltag und Zeitraum war ein zweiter Klick nach dem Öffnen. Neu: ein kompakter Chip-Button in der Description-Cell. Klick öffnet ein Popover (300 px breit) mit einem segmented Tab-Switcher „Einzeltag · Zeitraum" oben, dem passenden DatePicker(s) darunter (Range-Modus mit Min-Date-Constraint auf das Start-Datum), und einem Footer mit „Entfernen" / „Fertig". Befüllt zeigt der Trigger den Wert als Chip mit Akzent-Border an („15.05.2026" oder „15.05.2026 – 22.05.2026"). InvoiceEdit, OfferEdit und RecurringEdit teilen sich die neue Komponente; das Detail-Beschreibungs-Toggle steht als zweiter Chip daneben.
- **Math-Module dedupliziert.** `computeLineTotal` / `computeTotals` lebten dreimal nahezu identisch in `src/lib/db/{invoices,offers,expenses}.ts`. Neu in `src/lib/utils/totals.ts` als pure Funktion mit einheitlichem `TotalsItem`-Interface; die drei DB-Module re-exportieren sie. Tests in `totals.test.ts` (12 Cases) decken jetzt die Produktions-Math direkt ab. Expenses passt sich semantisch an, indem es `isKleinunternehmer: false` an die generische Variante übergibt — die Rechen-Logik ist identisch.

### Fixed
- **PeriodSwitcher: 6 svelte-check-Warnings** zu `state_referenced_locally`. Das Anfangs-Snapshot der Period-Props (Year/Quarter/Month/Custom-From/To) wird jetzt via `untrack()` aus `svelte` gelesen — Intent ist „Initial-Wert übernehmen, dann lokal weiter editieren", was die Warnings korrekt adressiert (statt sie nur zu verstecken).
- **NotFound-Screen poliert.** Raw `text-neutral-500` ersetzt durch das semantische `text-muted-foreground`-Token, Lucide-Icon und animierter Back-Link konsistent mit der „Unified hover"-Konvention aus v0.13.
- **Pytest-Suite auf Windows: 15 zuvor stille Failures behoben.** Drei Test-Module (`test_validator_golden.py`, `test_end_to_end.py`, `test_missing_fields.py`) riefen `subprocess.run(["java", "-jar", validator.jar, …])` ohne explizite Stdin-Umleitung auf. Auf Windows erbt der Subprozess das Console-Handle, KoSIT prüft via `FileInputStream.available()` ob seine stdin gepiped ist — und crasht beim Character-Device. Symptom: kein Report wird geschrieben, der Test-Assert „report file exists" failt. Same root cause wie der App-seitige v0.16.1-Hotfix (`validator.rs`). Fix: alle drei Module füttern jetzt eine leere reguläre Datei als stdin — disk-type-Handle, `available()` liefert sauber 0.

### Tests
- **Vitest:** +47 Cases (134 total, vorher 87). Neue Suites für `lib/utils/currency.ts` (24), `lib/utils/date.ts` (11), `lib/utils/totals.ts` (12). Roundtrip-Edge-Cases für Multi-Currency-Konvertierung, DST-Boundary für Datums-Arithmetik, Storno-Vorzeichen-Propagation durch `computeTotals`.
- **Pytest-Sidecar:** +19 Cases (71 total, vorher 52 effektiv). Neue `test_i18n.py` (14) mit Key-Parität DE/EN, Placeholder-Drift-Check, Fallback-Verhalten. Neue `test_line_period.py` (5) für BillingSpecifiedPeriod-Emission im ZUGFeRD-XML (single-day, range, missing, partial).

### Migration
- **Keine DB-Migration** — Schema bleibt auf `user_version = 26`. v0.17 ist reine UI-/Code-Polish.

### Notes
- **`pdf-language`-String im Footer** (Seite N von M) bleibt sprach-fest auf Deutsch in v0.17 — der CSS-Counter-Workaround (`@page` + `string-set`) ist in v0.18 vorgesehen.

## [0.16.1]

### Fixed
Expand Down
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
Offline-first invoice generator for German freelancers / Kleinunternehmer. **Tauri 2 + Svelte 5 + Python sidecar.** ZUGFeRD/Factur-X PDF/A-3 output. Release-Historie in `CHANGELOG.md` — hier nicht duplizieren.

## Current state
- **Released:** v0.13.0 on `main` (2026-05-19). Auto-Update aktiv seit v0.4.3.
- **DB schema:** `user_version = 20` (Migration `0019_v0.12_service_periods.sql`).
- **Dev-Linie:** neue Arbeit auf `release/vX.Y`-Branches (siehe Git-Workflow).
- **Released:** v0.16.1 on `main`. Auto-Update aktiv seit v0.4.3.
- **DB schema:** `user_version = 26` (letzte Migration `0025_v0.16_expense_categories.sql`).
- **Dev-Linie:** neue Arbeit auf `release/vX.Y`-Branches (aktuell `release/v0.17`).
- **Test-Suite:** 134 Vitest-Cases (`pnpm test`) + 71 Pytest-Cases (`sidecar/tests/`). Pure-Math (Totals/Tax/Skonto/Currency/Date/DATEV) ist voll abgedeckt, Sidecar deckt ZUGFeRD-XML-Goldens + KoSIT-Validator-Roundtrip + i18n ab.

## Architecture

Expand Down
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,23 @@ Zettel ist das, was dabei rausgekommen ist: ein kleines Desktop-Tool, das **loka
- **Stornorechnungen** als first-class Credit-Notes mit korrektem CII-Schema
- Mehrere USt-Sätze pro Rechnung (0 %, 7 %, 19 %), Multi-Currency mit eingefrorenem EUR-Wert
- Drei PDF-Themes (classic / modern / minimal) per CSS-Variablen
- **PDF auf Deutsch oder Englisch** pro Rechnung wählbar (XML bleibt sprach-neutral nach Norm)
- **Leistungszeiträume** (BG-14 / BG-26) auf Header- und Positions-Ebene, lange Positions-Beschreibungen (BT-154)
- **Artikel-/Leistungs-Katalog** für wiederverwendbare Positionen (DE + EN-Beschreibungen)
- **Skonto** (Frühzahler-Rabatt) strukturiert als `ApplicableTradePaymentDiscountTerms` im XML
- **EPC-QR-Code (Girocode)** automatisch auf EUR-Rechnungen mit IBAN — Empfänger scannt mit Banking-App
- **Angebote** als eigener Dokumententyp mit One-Click-Konvertierung zur Rechnung

### Buchhaltungs-Light
- **Eingangsrechnungen** mit Drop-Zone für ZUGFeRD-PDFs (automatisches Parsing) und OCR-Light-Fallback (Text-Layer-Extraktion, kein Tesseract)
- **Lieferanten-Verwaltung** mit USt-IdNr.-Matching beim Drop
- **Kuratierte Ausgaben-Kategorien** mit SKR03- und SKR04-Konten-Mapping (Software, Hardware, Reise, …)
- **Teilzahlungen** mit eigener Tabelle und automatischem `partial`-Status für teil-bezahlte Rechnungen
- **Bank-Import** für CAMT.053 (XML) und MT940 (Text) mit Auto-Match auf Rechnungsnummer / Betrag / Kunde
- **DATEV-Export** (Format 700, SKR03/SKR04) — Erlöse, Aufwände und Stornos in einem Buchungsstapel
- **UStVA-Vorbereitung** (Kennzahlen 81/86/41/21/66 pro Quartal, zum Abtippen)
- **Mahnungen** als eigene Dokumentenklasse mit Nummernkreis `MA-…`
- **Mahnungen** als eigene Dokumentenklasse mit Nummernkreis `MA-…`, Eskalations-Strip pro Rechnung
- **Bulk-Aktionen** in Rechnungen-, Angebote- und Eingangsrechnungen-Listen

### Steuer-Rücklage
- § 32a EStG-Tarif für **VZ 2024, 2025 und 2026** hartcodiert aus amtlicher BMF-Bekanntmachung, 38 Testfälle
Expand All @@ -86,12 +95,15 @@ Zettel ist das, was dabei rausgekommen ist: ein kleines Desktop-Tool, das **loka
### Dashboard & Workflow
- Zeitraum-Switcher (Jahr / Quartal / Monat / Custom) mit YoY-Kontext
- **Globale Suche** (`Cmd/Ctrl+K`) über alle Entities inkl. Item-Beschreibungen
- **Liquiditäts-Vorschau** über die nächsten 30 Tage (offene Posten + überfällige + wiederkehrend)
- **Wiedervorlage**-Liste pro Kunde/Rechnung mit `follow_up_date`
- **Onboarding-Wizard** beim ersten Start, sortierbare Tabellen in allen Listen
- Interne Notizen (nicht auf der PDF)
- **Wiederkehrende Rechnungen** (monatlich / quartalsweise / jährlich) — explizit per Klick erzeugen, kein stilles Background-Cron

### Daten gehören dir
- **Backup** als verschlüsseltes ZIP (AES-256-GCM + Argon2id) oder unverschlüsselt
- **Auto-Backup** mit konfigurierbarem Intervall (rotierender Wochen-Snapshot)
- **Granularer Restore** (Kunden / Rechnungen+PDFs / Settings unabhängig)
- **Sandbox-Modus** mit separater DB zum Ausprobieren
- **Auto-Update** Ed25519-signiert via GitHub Releases
Expand Down Expand Up @@ -121,6 +133,8 @@ Auto-Update ist ab v0.4.3 aktiv. Ältere Installationen einmal manuell auf die a

## Quickstart

Beim ersten Start führt der **Onboarding-Wizard** durch Firma, Steuer, Bank — kann mit „Später erinnern" übersprungen werden. Wer manuell loslegen will:

1. **Einstellungen → Unternehmen** öffnen, Firmendaten + Steuernummer (bzw. USt-IdNr.) eintragen
2. **Einstellungen → Steuerprofil** ausfüllen, damit das Dashboard die Steuer-Rücklage berechnen kann
3. **Kunde** anlegen (`/customers/new`)
Expand All @@ -134,7 +148,7 @@ Für eingehende Rechnungen: `/expenses` öffnen, PDF in die Drop-Zone ziehen —
Bewusst ausgeschlossen — diese Dinge sind im Scope anderer Tools besser aufgehoben:

- **Keine vollständige Buchhaltung** (kein doppelter Eintrag, kein Konten-Plan, kein Jahresabschluss)
- **Kein Banking / Kontoabgleich** — Zahlungen werden manuell gemarkt
- **Keine Online-Banking-Anbindung** — der Bank-Import liest CAMT.053-/MT940-Dateien lokal ein, keine Live-Verbindung zur Bank
- **Kein ELSTER-Upload** und kein UStVA-Versand — Zettel bereitet vor, du tippst ab oder gibst es dem Steuerberater
- **Keine Cloud, kein Sync, keine Mobile-App** — bewusst nicht in Scope
- **Kein SMTP-Versand** — Rechnungen landen als PDF, du schickst sie wie du willst
Expand Down Expand Up @@ -203,10 +217,11 @@ Vollständige Plattform-Voraussetzungen (GTK3-Runtime auf Windows, Tauri-System-

Geplant — siehe [Issues](https://github.com/jonax1337/zettel/issues) und [Discussions](https://github.com/jonax1337/zettel/discussions). Größere Themen, die in Diskussion sind:

- I18n (englische UI als zweite Sprache)
- Englische UI (PDF auf Englisch existiert seit v0.16, die App-UI ist noch nur Deutsch)
- AfA / Anlageverzeichnis
- EÜR-Export
- Mandanten-Profile (mehrere Firmen in einer Installation)
- Custom Ausgaben-Kategorien-Verwaltung in den Settings (Schema steht, UI fehlt)

Was bereits drin ist: [`CHANGELOG.md`](./CHANGELOG.md).

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zettel",
"version": "0.16.1",
"version": "0.17.0",
"description": "Offline-first invoice generator with ZUGFeRD/Factur-X support",
"type": "module",
"scripts": {
Expand Down
25 changes: 15 additions & 10 deletions sidecar/tests/test_end_to_end.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,21 @@ def _run_sidecar(request: dict) -> dict:


def _validate_xml(xml_path: Path, out_dir: Path) -> tuple[bool, list[str]]:
subprocess.run(
[
"java", "-jar", str(VALIDATOR_DIR / "validator.jar"),
"-s", str(VALIDATOR_DIR / "scenarios.xml"),
"-r", str(VALIDATOR_DIR),
"-o", str(out_dir),
str(xml_path),
],
capture_output=True, timeout=60,
)
# Real empty file as stdin — KoSIT's available() check explodes on NUL on
# Windows (same bug fixed in validator.rs / v0.16.1).
stdin_path = out_dir / ".stdin-empty"
stdin_path.write_bytes(b"")
with stdin_path.open("rb") as stdin_f:
subprocess.run(
[
"java", "-jar", str(VALIDATOR_DIR / "validator.jar"),
"-s", str(VALIDATOR_DIR / "scenarios.xml"),
"-r", str(VALIDATOR_DIR),
"-o", str(out_dir),
str(xml_path),
],
capture_output=True, timeout=60, stdin=stdin_f,
)
report = out_dir / f"{xml_path.stem}-report.xml"
if not report.exists():
return False, ["no report produced"]
Expand Down
72 changes: 72 additions & 0 deletions sidecar/tests/test_i18n.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Tests for the PDF translation table (invoice/i18n.py).

Solo-tool, flat key dict — these tests are an inventory check: every key
present in DE must also be present in EN, no orphan keys, no missing
placeholders in interpolated strings.
"""
from __future__ import annotations

import re

import pytest

from invoice.i18n import _DE, _EN, t # type: ignore[attr-defined]


def test_de_and_en_have_identical_key_sets():
de_keys = set(_DE.keys())
en_keys = set(_EN.keys())
only_de = de_keys - en_keys
only_en = en_keys - de_keys
assert not only_de, f"keys missing in EN: {sorted(only_de)}"
assert not only_en, f"keys missing in DE: {sorted(only_en)}"


@pytest.mark.parametrize("lang", ["de", "en"])
def test_no_empty_translations(lang: str):
table = _DE if lang == "de" else _EN
blanks = [k for k, v in table.items() if not v.strip()]
assert not blanks, f"empty translations in {lang}: {blanks}"


def test_t_returns_german_by_default():
assert t("doctype_invoice") == "Rechnung"


def test_t_returns_english_when_requested():
assert t("doctype_invoice", language="en") == "Invoice"


def test_t_falls_back_to_de_for_unknown_language():
assert t("doctype_invoice", language="zz") == "Rechnung"


def test_t_falls_back_to_key_for_unknown_key():
assert t("unknown_key_xyz", language="de") == "unknown_key_xyz"
assert t("unknown_key_xyz", language="en") == "unknown_key_xyz"


def test_t_interpolates_kwargs():
out = t("skonto_inline", language="de", date="22.05.2026", percent=2, amount="3,57 €")
assert "22.05.2026" in out
assert "2 %" in out
assert "3,57 €" in out


def test_t_interpolation_safe_against_missing_kwargs():
# missing kwargs return the raw template instead of crashing — caller's bug,
# not the user's.
out = t("skonto_inline", language="de", date="22.05.2026")
assert "{percent}" in out or "Skonto" in out


_PLACEHOLDER_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")


@pytest.mark.parametrize("key", ["skonto_inline", "skonto_offer_text", "credit_note_ref", "offer_disclaimer", "page_n_of_m"])
def test_de_and_en_share_the_same_placeholders(key: str):
de_holders = set(_PLACEHOLDER_RE.findall(_DE[key]))
en_holders = set(_PLACEHOLDER_RE.findall(_EN[key]))
assert de_holders == en_holders, (
f"placeholder drift in '{key}': DE={de_holders} EN={en_holders}"
)
105 changes: 105 additions & 0 deletions sidecar/tests/test_line_period.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""Line-item service period (BG-26, BT-134/135) — XML emission and edge cases.

v0.12 added the schema. v0.17 polished the input UX. These tests pin the XML
contract:

- single-day period (start == end) emits both date elements with the same
`format="102"` (YYYYMMDD) string
- range emits Start and End in document order
- missing fields suppress the entire BillingSpecifiedPeriod block
"""
from __future__ import annotations

from xml.etree import ElementTree as ET

import pytest

from invoice.zugferd import render_zugferd_xml

NS = {
"rsm": "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100",
"ram": "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100",
"udt": "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100",
}

# 2025-05-15 00:00:00 UTC
DAY = 1747267200
# 2025-05-22 00:00:00 UTC
DAY_PLUS_7 = DAY + 7 * 86400


def _line_period_dates(xml: str, item_index: int = 0) -> tuple[str | None, str | None]:
root = ET.fromstring(xml)
lines = root.findall(
".//ram:IncludedSupplyChainTradeLineItem", NS,
)
line = lines[item_index]
period = line.find(".//ram:BillingSpecifiedPeriod", NS)
if period is None:
return None, None
start = period.find("./ram:StartDateTime/udt:DateTimeString", NS)
end = period.find("./ram:EndDateTime/udt:DateTimeString", NS)
return (
start.text if start is not None else None,
end.text if end is not None else None,
)


def test_single_day_period_emits_same_start_and_end(payloads):
payload = payloads("01-standard-19.json")
payload["items"][0]["linePeriodStart"] = DAY
payload["items"][0]["linePeriodEnd"] = DAY
xml = render_zugferd_xml(payload)
start, end = _line_period_dates(xml)
assert start == "20250515"
assert end == "20250515"


def test_range_period_emits_distinct_start_and_end(payloads):
payload = payloads("01-standard-19.json")
payload["items"][0]["linePeriodStart"] = DAY
payload["items"][0]["linePeriodEnd"] = DAY_PLUS_7
xml = render_zugferd_xml(payload)
start, end = _line_period_dates(xml)
assert start == "20250515"
assert end == "20250522"


def test_missing_line_period_suppresses_block(payloads):
payload = payloads("01-standard-19.json")
# neither set
payload["items"][0].pop("linePeriodStart", None)
payload["items"][0].pop("linePeriodEnd", None)
xml = render_zugferd_xml(payload)
start, end = _line_period_dates(xml)
assert start is None
assert end is None
assert "BillingSpecifiedPeriod" not in xml or "BillingSpecifiedPeriod" in xml.split("<ram:ApplicableHeaderTradeSettlement")[0] == False or True
# Practical check: only the header-level period block should be eligible
# (and the standard payload doesn't set one), so no occurrence at all.
assert xml.count("BillingSpecifiedPeriod") == 0


def test_partial_period_suppresses_block(payloads):
# start without end → block suppressed (no half-period allowed in XML)
payload = payloads("01-standard-19.json")
payload["items"][0]["linePeriodStart"] = DAY
payload["items"][0]["linePeriodEnd"] = None
xml = render_zugferd_xml(payload)
start, end = _line_period_dates(xml)
assert start is None and end is None


def test_period_per_item_only(payloads):
# First item carries period, second doesn't — only line 1 gets the block.
payload = payloads("02-mixed-vat.json")
payload["items"][0]["linePeriodStart"] = DAY
payload["items"][0]["linePeriodEnd"] = DAY_PLUS_7
if len(payload["items"]) > 1:
payload["items"][1].pop("linePeriodStart", None)
payload["items"][1].pop("linePeriodEnd", None)
xml = render_zugferd_xml(payload)
s0, e0 = _line_period_dates(xml, 0)
s1, e1 = _line_period_dates(xml, 1)
assert s0 and e0
assert s1 is None and e1 is None
16 changes: 10 additions & 6 deletions sidecar/tests/test_missing_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,16 @@ def _validate(xml: str, tmp_path: Path) -> dict:
xf.write_text(xml, encoding="utf-8")
out = tmp_path / "out"
out.mkdir(exist_ok=True)
subprocess.run(
["java", "-jar", str(VAL / "validator.jar"),
"-s", str(VAL / "scenarios.xml"),
"-r", str(VAL), "-o", str(out), str(xf)],
capture_output=True, timeout=60,
)
# Empty real file as stdin — see test_end_to_end.py / v0.16.1 for the why.
stdin_path = out / ".stdin-empty"
stdin_path.write_bytes(b"")
with stdin_path.open("rb") as stdin_f:
subprocess.run(
["java", "-jar", str(VAL / "validator.jar"),
"-s", str(VAL / "scenarios.xml"),
"-r", str(VAL), "-o", str(out), str(xf)],
capture_output=True, timeout=60, stdin=stdin_f,
)
rep = next(out.glob("*-report.xml"), None)
if not rep:
return {"valid": False, "errors": ["no report"], "step_xsd": None, "step_sch": None}
Expand Down
31 changes: 19 additions & 12 deletions sidecar/tests/test_validator_golden.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,25 @@ def _validator_available() -> bool:


def _run_validator(xml_path: Path, out_dir: Path) -> Path:
subprocess.run(
[
"java", "-jar", str(VALIDATOR_JAR),
"-s", str(SCENARIOS),
"-r", str(VALIDATOR_DIR),
"-o", str(out_dir),
str(xml_path),
],
check=False,
capture_output=True,
timeout=60,
)
# Feed an empty real file as stdin (not subprocess.DEVNULL → NUL on Windows,
# which KoSIT misreads via FileInputStream.available() and crashes). Same
# fix shipped in validator.rs / v0.16.1.
stdin_path = out_dir / ".stdin-empty"
stdin_path.write_bytes(b"")
with stdin_path.open("rb") as stdin_f:
subprocess.run(
[
"java", "-jar", str(VALIDATOR_JAR),
"-s", str(SCENARIOS),
"-r", str(VALIDATOR_DIR),
"-o", str(out_dir),
str(xml_path),
],
check=False,
capture_output=True,
timeout=60,
stdin=stdin_f,
)
return out_dir / f"{xml_path.stem}-report.xml"


Expand Down
Loading
Loading