From 2afa8f9cc5666c66cb11f0ae00b5d1afe727ac04 Mon Sep 17 00:00:00 2001 From: jonax1337 Date: Fri, 22 May 2026 13:09:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(v0.17):=20Positions-Period-Popover=20?= =?UTF-8?q?=C2=B7=20Test-Suite=20+66=20=C2=B7=20Math-Dedupe=20=C2=B7=20Doc?= =?UTF-8?q?-Refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polish-Release. Drei Detail-Fixes plus eine ordentliche Test-Investition: UI - Neue LinePeriodPopover-Komponente (src/lib/ui/) — kompakter Chip-Trigger öffnet ein 300-px-Popover mit Tab-Switcher Einzeltag/Zeitraum, DatePicker(s) und Footer (Entfernen/Fertig). Befüllt zeigt der Chip den Wert mit Akzent-Border. Ersetzt den beengten Inline-Block in InvoiceEdit, OfferEdit und RecurringEdit. Detail-Beschreibungs-Toggle steht jetzt als zweiter Chip daneben — Beschreibungs-Cell deutlich aufgeräumter. - NotFound polish: text-muted-foreground statt raw text-neutral-500, Lucide- Icon, animierter Back-Link konsistent zur Unified-Hover-Konvention (v0.13). - PeriodSwitcher: 6 svelte-check-Warnings zu state_referenced_locally behoben — untrack() für Initial-Snapshot der Period-Props. Code-Health - computeLineTotal/computeTotals dedupliziert: pure Math jetzt in src/lib/utils/totals.ts, die drei db-Module (invoices, offers, expenses) re-exportieren. expenses passt sich semantisch an mit isKleinunternehmer: false. Tests - Vitest +47 (134 total, vorher 87): neue Suites currency.test.ts (24), date.test.ts (11), totals.test.ts (12). Coverage für Multi-Currency- Konvertierung, DST-Boundary, Storno-Vorzeichen-Propagation. - Pytest-Sidecar +19 (71 total, vorher 52): test_i18n.py (14) mit DE/EN- Key-Parität + Placeholder-Drift-Check, test_line_period.py (5) für BillingSpecifiedPeriod-Emission im ZUGFeRD-XML. - 15 zuvor stille Pytest-Failures auf Windows behoben — drei Test-Module riefen java/KoSIT ohne Stdin-Umleitung auf, KoSIT crashte beim FileInputStream.available()-Check auf Console-Handle. Same root cause wie v0.16.1 (validator.rs). Fix: leere reguläre Datei als stdin. Docs - README: Buchhaltungs-Light + Dashboard-Sektionen um v0.14-v0.16-Features ergänzt (Katalog, Skonto, EPC-QR, Teilzahlungen, Bank-Import, Bulk- Aktionen, Auto-Backup, Onboarding-Wizard, Liquiditäts-Vorschau, PDF-EN). Roadmap aktualisiert (EN-PDF shipped, EN-UI noch offen). "Banking"-Claim geschärft (kein Live-Banking, aber lokaler CAMT/MT940-Import). - CLAUDE.md: Current-State auf v0.16.1 / schema 26 / release/v0.17 + Test-Suite-Übersicht. - Version-Bump in package.json, tauri.conf.json, Cargo.toml, Cargo.lock. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 23 +++ CLAUDE.md | 7 +- README.md | 21 ++- package.json | 2 +- sidecar/tests/test_end_to_end.py | 25 +-- sidecar/tests/test_i18n.py | 72 ++++++++ sidecar/tests/test_line_period.py | 105 +++++++++++ sidecar/tests/test_missing_fields.py | 16 +- sidecar/tests/test_validator_golden.py | 31 ++-- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- src/lib/components/PeriodSwitcher.svelte | 14 +- src/lib/db/expenses.ts | 26 ++- src/lib/db/invoices.ts | 24 +-- src/lib/db/offers.ts | 22 +-- src/lib/ui/LinePeriodPopover.svelte | 186 ++++++++++++++++++++ src/lib/ui/index.ts | 1 + src/lib/utils/currency.test.ts | 123 +++++++++++++ src/lib/utils/date.test.ts | 63 +++++++ src/lib/utils/totals.test.ts | 115 ++++++++++++ src/lib/utils/totals.ts | 42 +++++ src/routes/InvoiceEdit.svelte | 187 ++++++-------------- src/routes/NotFound.svelte | 19 +- src/routes/OfferEdit.svelte | 193 ++++++--------------- src/routes/RecurringEdit.svelte | 212 +++++++---------------- 26 files changed, 1016 insertions(+), 519 deletions(-) create mode 100644 sidecar/tests/test_i18n.py create mode 100644 sidecar/tests/test_line_period.py create mode 100644 src/lib/ui/LinePeriodPopover.svelte create mode 100644 src/lib/utils/currency.test.ts create mode 100644 src/lib/utils/date.test.ts create mode 100644 src/lib/utils/totals.test.ts create mode 100644 src/lib/utils/totals.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3045d75..71ac02b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 7160887..57c7ecb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index f6cc1f7..3fb3cfd 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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`) @@ -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 @@ -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). diff --git a/package.json b/package.json index 76b97c1..2f0a014 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/sidecar/tests/test_end_to_end.py b/sidecar/tests/test_end_to_end.py index c062349..f94c3c7 100644 --- a/sidecar/tests/test_end_to_end.py +++ b/sidecar/tests/test_end_to_end.py @@ -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"] diff --git a/sidecar/tests/test_i18n.py b/sidecar/tests/test_i18n.py new file mode 100644 index 0000000..9c19ebe --- /dev/null +++ b/sidecar/tests/test_i18n.py @@ -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}" + ) diff --git a/sidecar/tests/test_line_period.py b/sidecar/tests/test_line_period.py new file mode 100644 index 0000000..f72699d --- /dev/null +++ b/sidecar/tests/test_line_period.py @@ -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(" 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 diff --git a/sidecar/tests/test_missing_fields.py b/sidecar/tests/test_missing_fields.py index 7cc767d..dfc618d 100644 --- a/sidecar/tests/test_missing_fields.py +++ b/sidecar/tests/test_missing_fields.py @@ -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} diff --git a/sidecar/tests/test_validator_golden.py b/sidecar/tests/test_validator_golden.py index 119a3e1..21b8a4f 100644 --- a/sidecar/tests/test_validator_golden.py +++ b/sidecar/tests/test_validator_golden.py @@ -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" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 49eb6a0..2faf280 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6876,7 +6876,7 @@ dependencies = [ [[package]] name = "zettel" -version = "0.16.1" +version = "0.17.0" dependencies = [ "aes-gcm", "argon2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 882662d..ff040b3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zettel" -version = "0.16.1" +version = "0.17.0" description = "Zettel — Offline-first invoice generator" authors = ["Jonas Laux"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 12617e7..6cac172 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Zettel", - "version": "0.16.1", + "version": "0.17.0", "identifier": "digital.laux.zettel", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/lib/components/PeriodSwitcher.svelte b/src/lib/components/PeriodSwitcher.svelte index 8b8bf8c..9793b99 100644 --- a/src/lib/components/PeriodSwitcher.svelte +++ b/src/lib/components/PeriodSwitcher.svelte @@ -1,4 +1,5 @@ + + + + + {#if hasValue} + {chipLabel(startIso, endIso)} + {:else} + Leistungsdatum + {/if} + + + + +
+ + +
+ + {#if mode === "single"} + startIso, + (v: string) => { + startIso = v; + endIso = v; + } + } + /> +

+ Tag der Leistungserbringung (BT-134). +

+ {:else} +
+
+ Von + +
+
+ Bis + +
+
+ {#if invalid} +

+ Enddatum liegt vor Startdatum. +

+ {:else} +

+ Zeitraum der Leistungserbringung (BT-134/135). +

+ {/if} + {/if} + +
+ {#if hasValue} + + {:else} + + {/if} + +
+
+
+
diff --git a/src/lib/ui/index.ts b/src/lib/ui/index.ts index 72702a2..fe150c7 100644 --- a/src/lib/ui/index.ts +++ b/src/lib/ui/index.ts @@ -12,6 +12,7 @@ export { default as Select } from "./Select.svelte"; export { default as Checkbox } from "./Checkbox.svelte"; export { default as Slider } from "./Slider.svelte"; export { default as DatePicker } from "./DatePicker.svelte"; +export { default as LinePeriodPopover } from "./LinePeriodPopover.svelte"; export { default as Dialog } from "./Dialog.svelte"; export { default as ConfirmDialog } from "./ConfirmDialog.svelte"; export { default as DropdownMenu } from "./DropdownMenu.svelte"; diff --git a/src/lib/utils/currency.test.ts b/src/lib/utils/currency.test.ts new file mode 100644 index 0000000..df7ef6c --- /dev/null +++ b/src/lib/utils/currency.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { + SUPPORTED_CURRENCIES, + computeEurTotalCent, + formatMoney, + isValidCurrencyCode, + parseExchangeRateScaled, + toEurCents, +} from "./currency"; + +describe("isValidCurrencyCode", () => { + it("accepts every supported code", () => { + for (const c of SUPPORTED_CURRENCIES) { + expect(isValidCurrencyCode(c.code)).toBe(true); + } + }); + it("rejects unknown codes", () => { + expect(isValidCurrencyCode("ZZZ")).toBe(false); + expect(isValidCurrencyCode("eur")).toBe(false); // case-sensitive + expect(isValidCurrencyCode("")).toBe(false); + }); +}); + +describe("parseExchangeRateScaled", () => { + it("parses dot decimal", () => { + expect(parseExchangeRateScaled("1.0832")).toBe(108320000n); + }); + it("parses comma decimal", () => { + expect(parseExchangeRateScaled("1,0832")).toBe(108320000n); + }); + it("parses integer rate", () => { + expect(parseExchangeRateScaled("110")).toBe(11000000000n); + }); + it("pads to 8 fractional digits", () => { + expect(parseExchangeRateScaled("1.5")).toBe(150000000n); + }); + it("truncates excess fractional digits", () => { + expect(parseExchangeRateScaled("1.123456789012")).toBe(112345678n); + }); + it("rejects negative", () => { + expect(parseExchangeRateScaled("-1.5")).toBeNull(); + }); + it("rejects zero", () => { + expect(parseExchangeRateScaled("0")).toBeNull(); + expect(parseExchangeRateScaled("0.0")).toBeNull(); + }); + it("rejects garbage", () => { + expect(parseExchangeRateScaled("abc")).toBeNull(); + expect(parseExchangeRateScaled("1.2.3")).toBeNull(); + expect(parseExchangeRateScaled("")).toBeNull(); + }); + it("trims whitespace", () => { + expect(parseExchangeRateScaled(" 1.5 ")).toBe(150000000n); + }); +}); + +describe("toEurCents", () => { + it("converts USD at 1.08 to EUR", () => { + // 108 USD cents @ rate 1.08 = 100 EUR cents + const rate = parseExchangeRateScaled("1.08")!; + expect(toEurCents(108, rate)).toBe(100); + }); + it("rounds half-up", () => { + // 1 cent at rate 2.00 = 0.5 cents → 1 (half-up) + const rate = parseExchangeRateScaled("2.00")!; + expect(toEurCents(1, rate)).toBe(1); + }); + it("preserves sign on negative totals (storno)", () => { + const rate = parseExchangeRateScaled("1.08")!; + expect(toEurCents(-108, rate)).toBe(-100); + }); + it("handles large totals without precision loss", () => { + // 1 mio cents = 10000 EUR at rate 1.0 + const rate = parseExchangeRateScaled("1.0")!; + expect(toEurCents(1_000_000, rate)).toBe(1_000_000); + }); + it("rate < 1 inflates the foreign-currency total", () => { + // 100 GBP cents at rate 0.85 (1 EUR buys 0.85 GBP) ≈ 117.65 EUR cents → 118 + const rate = parseExchangeRateScaled("0.85")!; + expect(toEurCents(100, rate)).toBe(118); + }); +}); + +describe("computeEurTotalCent", () => { + it("returns total unchanged for EUR", () => { + expect(computeEurTotalCent("EUR", 12345, "1.0")).toBe(12345); + expect(computeEurTotalCent("EUR", 12345, null)).toBe(12345); + expect(computeEurTotalCent("EUR", 12345, "")).toBe(12345); + }); + it("returns null when foreign + no rate", () => { + expect(computeEurTotalCent("USD", 12345, null)).toBeNull(); + expect(computeEurTotalCent("USD", 12345, "")).toBeNull(); + }); + it("returns null when rate unparseable", () => { + expect(computeEurTotalCent("USD", 12345, "abc")).toBeNull(); + expect(computeEurTotalCent("USD", 12345, "0")).toBeNull(); + }); + it("converts foreign currency with valid rate", () => { + expect(computeEurTotalCent("USD", 108, "1.08")).toBe(100); + }); +}); + +describe("formatMoney", () => { + it("formats EUR with 2 decimals", () => { + expect(formatMoney(12345, "EUR")).toMatch(/123,45/); + }); + it("formats JPY with 0 decimals (zero-decimal currency)", () => { + const out = formatMoney(12345, "JPY"); + expect(out).not.toMatch(/[,.]/); + expect(out).toContain("12"); + }); + it("falls back gracefully for unknown ISO code", () => { + // Intl.NumberFormat accepts unknown 3-letter codes on modern V8; both + // the Intl path and the catch fallback should at least surface the code + // and the numeric value. + const out = formatMoney(12345, "XYZ"); + expect(out).toContain("XYZ"); + expect(out).toMatch(/123[,.]45/); + }); + it("defaults to EUR when currency is empty", () => { + expect(formatMoney(100, "")).toMatch(/1,00/); + }); +}); diff --git a/src/lib/utils/date.test.ts b/src/lib/utils/date.test.ts new file mode 100644 index 0000000..0c584ca --- /dev/null +++ b/src/lib/utils/date.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { addDaysUnix, formatDate, fromIsoDate, nowUnix, toIsoDate } from "./date"; + +describe("toIsoDate / fromIsoDate", () => { + it("roundtrips at local midnight", () => { + const iso = "2026-05-22"; + expect(toIsoDate(fromIsoDate(iso))).toBe(iso); + }); + it("zero-pads month and day", () => { + const iso = toIsoDate(fromIsoDate("2026-01-05")); + expect(iso).toBe("2026-01-05"); + }); + it("handles end-of-year boundary", () => { + expect(toIsoDate(fromIsoDate("2025-12-31"))).toBe("2025-12-31"); + }); + it("handles leap day", () => { + expect(toIsoDate(fromIsoDate("2024-02-29"))).toBe("2024-02-29"); + }); +}); + +describe("fromIsoDate", () => { + it("falls back to month/day 1 on partial input", () => { + expect(fromIsoDate("2026")).toBe(fromIsoDate("2026-01-01")); + }); +}); + +describe("formatDate", () => { + it("formats as DD.MM.YYYY (de-DE)", () => { + const u = fromIsoDate("2026-05-22"); + expect(formatDate(u)).toBe("22.5.2026"); + }); +}); + +describe("addDaysUnix", () => { + it("adds positive days", () => { + const base = fromIsoDate("2026-05-20"); + expect(toIsoDate(addDaysUnix(base, 7))).toBe("2026-05-27"); + }); + it("subtracts when negative", () => { + const base = fromIsoDate("2026-05-01"); + expect(toIsoDate(addDaysUnix(base, -1))).toBe("2026-04-30"); + }); + it("crosses DST boundary cleanly (Berlin)", () => { + // 2026-03-29 02:00 → 03:00 CEST. addDaysUnix is pure-second math, so the + // ISO output can shift by 1h, but the date string at local midnight stays + // stable thanks to fromIsoDate using local-time constructor. + const base = fromIsoDate("2026-03-28"); + const plus2 = addDaysUnix(base, 2); + expect(toIsoDate(plus2)).toBe("2026-03-30"); + }); +}); + +describe("nowUnix", () => { + it("returns a sane current epoch (seconds since 1970)", () => { + const n = nowUnix(); + // some safe lower bound (year 2026) and reasonable upper bound (year 2100) + expect(n).toBeGreaterThan(1_750_000_000); + expect(n).toBeLessThan(4_000_000_000); + }); + it("returns integer seconds", () => { + expect(Number.isInteger(nowUnix())).toBe(true); + }); +}); diff --git a/src/lib/utils/totals.test.ts b/src/lib/utils/totals.test.ts new file mode 100644 index 0000000..c1982b5 --- /dev/null +++ b/src/lib/utils/totals.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { computeLineTotal, computeTotals } from "./totals"; + +describe("computeLineTotal", () => { + it("multiplies quantity × unitPrice (cent integer)", () => { + expect(computeLineTotal({ quantity: 1, unitPrice: 10000, vatRate: 19 })).toBe(10000); + expect(computeLineTotal({ quantity: 3, unitPrice: 999, vatRate: 19 })).toBe(2997); + }); + it("rounds fractional quantities half-up", () => { + // 0.5h × 5000 cents = 2500 — exact + expect(computeLineTotal({ quantity: 0.5, unitPrice: 5000, vatRate: 19 })).toBe(2500); + // 1.5h × 999 cents = 1498.5 → 1499 (Math.round half-up for positives) + expect(computeLineTotal({ quantity: 1.5, unitPrice: 999, vatRate: 19 })).toBe(1499); + }); + it("handles zero", () => { + expect(computeLineTotal({ quantity: 0, unitPrice: 12345, vatRate: 19 })).toBe(0); + expect(computeLineTotal({ quantity: 5, unitPrice: 0, vatRate: 19 })).toBe(0); + }); + it("supports negative quantities (storno)", () => { + expect(computeLineTotal({ quantity: -1, unitPrice: 10000, vatRate: 19 })).toBe(-10000); + }); +}); + +describe("computeTotals — regular (no exemption)", () => { + const opts = { isKleinunternehmer: false, isReverseCharge: false }; + + it("computes a single 19 % line", () => { + const t = computeTotals( + [{ quantity: 1, unitPrice: 10000, vatRate: 19 }], + opts, + ); + expect(t).toEqual({ subtotal: 10000, vatAmount: 1900, total: 11900 }); + }); + + it("sums multiple positions with mixed VAT rates", () => { + const t = computeTotals( + [ + { quantity: 2, unitPrice: 5000, vatRate: 19 }, // 100,00 € net, 19,00 € VAT + { quantity: 1, unitPrice: 1000, vatRate: 7 }, // 10,00 € net, 0,70 € VAT + ], + opts, + ); + expect(t.subtotal).toBe(11000); + expect(t.vatAmount).toBe(1970); + expect(t.total).toBe(12970); + }); + + it("rounds VAT per line (not on the cumulative subtotal)", () => { + // 3 × 333 = 999 cents net, 19 % → 189.81 → rounded to 190 + const t = computeTotals( + [{ quantity: 3, unitPrice: 333, vatRate: 19 }], + opts, + ); + expect(t.subtotal).toBe(999); + expect(t.vatAmount).toBe(190); + expect(t.total).toBe(1189); + }); + + it("handles 0 % VAT positions (passes through subtotal)", () => { + const t = computeTotals( + [{ quantity: 1, unitPrice: 5000, vatRate: 0 }], + opts, + ); + expect(t).toEqual({ subtotal: 5000, vatAmount: 0, total: 5000 }); + }); + + it("empty items list yields zeros", () => { + expect(computeTotals([], opts)).toEqual({ subtotal: 0, vatAmount: 0, total: 0 }); + }); +}); + +describe("computeTotals — Kleinunternehmer", () => { + const opts = { isKleinunternehmer: true, isReverseCharge: false }; + + it("never adds VAT, regardless of per-line rate", () => { + const t = computeTotals( + [ + { quantity: 1, unitPrice: 10000, vatRate: 19 }, + { quantity: 1, unitPrice: 5000, vatRate: 7 }, + ], + opts, + ); + expect(t.subtotal).toBe(15000); + expect(t.vatAmount).toBe(0); + expect(t.total).toBe(15000); + }); +}); + +describe("computeTotals — Reverse-Charge", () => { + const opts = { isKleinunternehmer: false, isReverseCharge: true }; + + it("zeros VAT even if positions still carry a rate", () => { + // Reverse-charge invoices may still hold a vatRate in the input (UI keeps + // it for switching back), but the math must report 0. + const t = computeTotals( + [{ quantity: 1, unitPrice: 12345, vatRate: 19 }], + opts, + ); + expect(t).toEqual({ subtotal: 12345, vatAmount: 0, total: 12345 }); + }); +}); + +describe("computeTotals — Storno (Credit-Note)", () => { + const opts = { isKleinunternehmer: false, isReverseCharge: false }; + + it("propagates negative signs through subtotal, VAT and total", () => { + const t = computeTotals( + [{ quantity: -1, unitPrice: 10000, vatRate: 19 }], + opts, + ); + expect(t.subtotal).toBe(-10000); + expect(t.vatAmount).toBe(-1900); + expect(t.total).toBe(-11900); + }); +}); diff --git a/src/lib/utils/totals.ts b/src/lib/utils/totals.ts new file mode 100644 index 0000000..49786ad --- /dev/null +++ b/src/lib/utils/totals.ts @@ -0,0 +1,42 @@ +/** + * Pure line-total / VAT math, shared across invoices, offers and expenses. + * + * Cent-integer all the way. Per-position rounding (Math.round) matches the + * Finanzamt convention: each line is rounded individually before the VAT sum, + * so the totals shown to the customer reconcile down to single cents. + */ + +export type TotalsItem = { + quantity: number; + unitPrice: number; // cents + vatRate: number; // percent (integer) +}; + +export type Totals = { + subtotal: number; + vatAmount: number; + total: number; +}; + +export type TotalsOpts = { + isKleinunternehmer: boolean; + isReverseCharge: boolean; +}; + +export function computeLineTotal(item: TotalsItem): number { + return Math.round(item.quantity * item.unitPrice); +} + +export function computeTotals(items: TotalsItem[], opts: TotalsOpts): Totals { + const vatExempt = opts.isKleinunternehmer || opts.isReverseCharge; + let subtotal = 0; + let vatAmount = 0; + for (const item of items) { + const line = computeLineTotal(item); + subtotal += line; + if (!vatExempt) { + vatAmount += Math.round((line * item.vatRate) / 100); + } + } + return { subtotal, vatAmount, total: subtotal + vatAmount }; +} diff --git a/src/routes/InvoiceEdit.svelte b/src/routes/InvoiceEdit.svelte index 5b9698f..3059d7a 100644 --- a/src/routes/InvoiceEdit.svelte +++ b/src/routes/InvoiceEdit.svelte @@ -36,6 +36,7 @@ CatalogPicker, Checkbox, DatePicker, + LinePeriodPopover, Select, toast, } from "$lib/ui"; @@ -75,12 +76,9 @@ type ItemUI = InvoiceItemInput & { priceText: string; longDescription: string; - linePeriodMode: "single" | "range"; - linePeriodSingleIso: string; - linePeriodStartIso: string; - linePeriodEndIso: string; + periodStartIso: string; + periodEndIso: string; showDetail: boolean; - showPeriod: boolean; }; function emptyItem(vatRate = 0): ItemUI { return { @@ -91,12 +89,9 @@ vatRate, priceText: "", longDescription: "", - linePeriodMode: "single", - linePeriodSingleIso: "", - linePeriodStartIso: "", - linePeriodEndIso: "", + periodStartIso: "", + periodEndIso: "", showDetail: false, - showPeriod: false, }; } let items = $state([emptyItem()]); @@ -244,27 +239,18 @@ skontoDays = res.invoice.skontoDays; } pdfLanguage = (res.invoice.pdfLanguage ?? "de") as "de" | "en"; - items = res.items.map((it) => { - const isSingle = - !!it.linePeriodStart && - !!it.linePeriodEnd && - it.linePeriodStart === it.linePeriodEnd; - return { - description: it.description, - quantity: it.quantity, - unit: it.unit, - unitPrice: it.unitPrice, - vatRate: it.vatRate, - priceText: (it.unitPrice / 100).toFixed(2).replace(".", ","), - longDescription: it.longDescription ?? "", - linePeriodMode: (isSingle ? "single" : "range") as "single" | "range", - linePeriodSingleIso: isSingle ? toIsoDate(it.linePeriodStart!) : "", - linePeriodStartIso: it.linePeriodStart ? toIsoDate(it.linePeriodStart) : "", - linePeriodEndIso: it.linePeriodEnd ? toIsoDate(it.linePeriodEnd) : "", - showDetail: !!it.longDescription, - showPeriod: !!(it.linePeriodStart && it.linePeriodEnd), - }; - }); + items = res.items.map((it) => ({ + description: it.description, + quantity: it.quantity, + unit: it.unit, + unitPrice: it.unitPrice, + vatRate: it.vatRate, + priceText: (it.unitPrice / 100).toFixed(2).replace(".", ","), + longDescription: it.longDescription ?? "", + periodStartIso: it.linePeriodStart ? toIsoDate(it.linePeriodStart) : "", + periodEndIso: it.linePeriodEnd ? toIsoDate(it.linePeriodEnd) : "", + showDetail: !!it.longDescription, + })); }) .catch((e) => (error = String(e))) .finally(() => (loaded = true)); @@ -378,12 +364,10 @@ } } for (const it of items) { - if (it.showPeriod && it.linePeriodMode === "range") { - if (it.linePeriodStartIso && it.linePeriodEndIso) { - if (fromIsoDate(it.linePeriodEndIso) < fromIsoDate(it.linePeriodStartIso)) { - error = `Positions-Zeitraum: Enddatum vor Startdatum (${it.description || "ohne Beschreibung"}).`; - return; - } + if (it.periodStartIso && it.periodEndIso) { + if (fromIsoDate(it.periodEndIso) < fromIsoDate(it.periodStartIso)) { + error = `Positions-Zeitraum: Enddatum vor Startdatum (${it.description || "ohne Beschreibung"}).`; + return; } } } @@ -407,31 +391,17 @@ notes: notes.trim() || null, paymentTerms: paymentTerms.trim() || null, reverseChargeType, - items: items.map((it) => { - let lpStart: number | null = null; - let lpEnd: number | null = null; - if (it.showPeriod) { - if (it.linePeriodMode === "single" && it.linePeriodSingleIso) { - const d = fromIsoDate(it.linePeriodSingleIso); - lpStart = d; - lpEnd = d; - } else if (it.linePeriodMode === "range" && it.linePeriodStartIso && it.linePeriodEndIso) { - lpStart = fromIsoDate(it.linePeriodStartIso); - lpEnd = fromIsoDate(it.linePeriodEndIso); - } - } - return { - description: it.description, - quantity: it.quantity, - unit: it.unit, - unitPrice: it.unitPrice, - vatRate: it.vatRate, - longDescription: - it.showDetail && it.longDescription.trim() ? it.longDescription.trim() : null, - linePeriodStart: lpStart, - linePeriodEnd: lpEnd, - }; - }), + items: items.map((it) => ({ + description: it.description, + quantity: it.quantity, + unit: it.unit, + unitPrice: it.unitPrice, + vatRate: it.vatRate, + longDescription: + it.showDetail && it.longDescription.trim() ? it.longDescription.trim() : null, + linePeriodStart: it.periodStartIso ? fromIsoDate(it.periodStartIso) : null, + linePeriodEnd: it.periodEndIso ? fromIsoDate(it.periodEndIso) : null, + })), currency, exchangeRate: currency === "EUR" ? null : exchangeRate.trim(), eurTotalCent, @@ -667,15 +637,23 @@ {#if it.showDetail} -
+