diff --git a/.env.example b/.env.example index fe081f7..aa9aac6 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,9 @@ ADMIN_PASSWORD="bitte-aendern" # Öffentlich erreichbare Adresse des Dashboards (für install.sh und E-Mails) APP_URL="http://localhost:3000" +# Ablage für hochgeladene Rechnungs-PDFs +UPLOAD_DIR="./data/uploads" + # Demo-Daten beim Seed anlegen (1 = ja) SEED_DEMO="0" diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a400aa..f4c3c18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,19 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Sicherheit +## [0.5.0] – 2026-09-17 + +### Hinzugefügt +- Laufzeit-Vorschläge der Agents bestätigen oder verwerfen (#008) +- Proxmox- und Kundenrechnungen erfassen; gleiche Rechnungsnummer fasst mehrere Laufzeiten zu einer Sammelrechnung zusammen (#008) +- PDF-Upload für beide Rechnungsarten mit Prüfung des Dateiinhalts und Ablage unter der Prüfsumme (#008) +- Rechnungsübersicht mit Häkchen „bezahlt“ und „erstellt“ direkt in der Zeile, Filter nach Status, Kunde, Jahr und Suchbegriff (#008) +- Detailseite je Laufzeit mit Beträgen, Marge und Übersicht der Sammelrechnung (#008) +- Laufzeiten lassen sich von Hand anlegen (#008) + +### Geändert +- Die Aufgabenliste auf dem Dashboard setzt beim Abhaken den Rechnungsstatus in der Datenbank (#008) + ## [0.4.2] – 2026-09-17 ### Geändert diff --git a/DEVLOG.md b/DEVLOG.md index bbda1e1..c72571b 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,7 +2,7 @@ ## Zeitübersicht -**Gesamt:** 2 h 58 min +**Gesamt:** 3 h 23 min | Issue | Zeit | |---|---| @@ -14,6 +14,7 @@ | #006 Kunden und Hostzuweisung | 0 h 35 min | | #007 Host-Tabelle und Detailansichten | 0 h 25 min | | #020 Issues nach Gitea übertragen | 0 h 20 min | +| #008 Laufzeiten und Rechnungen | 0 h 35 min | | Meilenstein | Zeit | |---|---| @@ -23,12 +24,13 @@ | v0.4.0 | 0 h 25 min | **abgeschlossen** | v0.4.1 | 0 h 10 min | **abgeschlossen** | v0.4.2 | 0 h 10 min | **abgeschlossen** +| v0.5.0 | 0 h 35 min | **abgeschlossen** --- ## 2026-09-17 · Sitzung 1 · 19:36–laufend **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) -**Version:** 0.4.2 +**Version:** 0.5.0 ### Erledigt - Auftrag gelesen, Rückfragen gestellt @@ -52,8 +54,12 @@ - #020 abgeschlossen: Abgleichskript für den Gitea-Issue-Tracker (Missverständnis geklärt – die Issues lagen bisher nur als Dateien im Repo, nicht im Tracker) - Token kommt aus den Git-Zugangsdaten statt aus einer zweiten Stelle; 20 Issues, 15 Labels und 13 Meilensteine in Gitea angelegt - Alles nach Gitea gepusht: main bei 51243c2, Tags v0.1.0 bis v0.4.1 +- #008 abgeschlossen: Laufzeit-Vorschläge bestätigen oder verwerfen, Rechnungen mit Positionen erfassen, PDF-Upload, Häkchen direkt in der Tabelle, Filter nach Status, Kunde und Jahr; Aufgabenliste auf dem Dashboard setzt jetzt echte Status +- Meilenstein v0.5.0 abgeschlossen; 78 Tests ### Entscheidungen +- **Rechnungsnummer als Schlüssel für Sammelrechnungen:** Dieselbe Nummer bei einer zweiten Laufzeit hängt eine Position an – kein eigener Dialog für „Sammelrechnung anlegen“ nötig. +- **PDF-Prüfung am Dateianfang** statt am gemeldeten Typ; abgelegt wird unter der Prüfsumme, gleiche Datei also nur einmal. - **Screenshots mit Firefox headless** statt Playwright: Firefox ist vorhanden, es muss kein Browser samt Systembibliotheken nachinstalliert werden (dafür wäre root nötig). - **Kennzahlen werden berechnet, nicht hinterlegt:** auch im Prototyp kommen alle Zahlen aus den Beispieldaten, damit Tabellen und Kacheln nie widersprüchlich sind. - **AdminLTE 4.9.1 nur als CSS:** stabil, Bootstrap 5.3.8, kein jQuery; die JS-Plugins manipulieren das DOM und werden daher als React-Komponenten nachgebaut. @@ -78,4 +84,4 @@ - Offen: Rolle „readonly“ zusätzlich zu Admin? (vorläufig vorgesehen) ### Nächster Schritt -- #008 Laufzeiten und Rechnungen: Vorschläge bestätigen, Rechnungen erfassen, Häkchen direkt in der Tabelle +- #009 Kundenübersicht, Ablauf-Kalender und Jahresbahn auf Datenbankbasis diff --git a/docs/anleitung/bilder/laufzeit-detail.png b/docs/anleitung/bilder/laufzeit-detail.png new file mode 100644 index 0000000..eefee6d Binary files /dev/null and b/docs/anleitung/bilder/laufzeit-detail.png differ diff --git a/docs/anleitung/bilder/laufzeit-vorschlag.png b/docs/anleitung/bilder/laufzeit-vorschlag.png new file mode 100644 index 0000000..a9f2d97 Binary files /dev/null and b/docs/anleitung/bilder/laufzeit-vorschlag.png differ diff --git a/docs/anleitung/bilder/rechnungen.png b/docs/anleitung/bilder/rechnungen.png index 1bf88f9..dad0a21 100644 Binary files a/docs/anleitung/bilder/rechnungen.png and b/docs/anleitung/bilder/rechnungen.png differ diff --git a/docs/issues/008-laufzeiten-rechnungen.md b/docs/issues/008-laufzeiten-rechnungen.md index ac816ff..ab0f40c 100644 --- a/docs/issues/008-laufzeiten-rechnungen.md +++ b/docs/issues/008-laufzeiten-rechnungen.md @@ -1,28 +1,37 @@ # #008 Laufzeiten und Rechnungen -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.5.0 **Labels:** ui, backend, buchhaltung -**Geschätzt:** 10 h **Tatsächlich:** – +**Geschätzt:** 10 h **Tatsächlich:** 0 h 35 min ## Ziel Lizenzjahre mit Proxmox-Einkaufsrechnung und Kundenrechnung erfassen und den Status schnell pflegen. ## Aufgaben -- [ ] Laufzeitvorschlag bei neuem `nextduedate` bestätigen/verwerfen -- [ ] Laufzeit manuell anlegen/bearbeiten -- [ ] Proxmox-Rechnung erfassen (auch Sammelrechnung über mehrere Laufzeiten), PDF-Upload -- [ ] Kundenrechnung erfassen (auch Sammelrechnung), PDF-Upload -- [ ] Marge pro Laufzeit, Kunde und Jahr berechnen -- [ ] Rechnungsübersicht: alle Laufzeiten, Umschalter „Proxmox bezahlt“ / „Kundenrechnung erstellt“ direkt in der Zeile, Filter Offen/Erledigt/Alle, Kunde, Zeitraum -- [ ] Sidebar-Badge offene Rechnungspunkte -- [ ] Aufgaben-Card auf dem Dashboard mit echten Aktionen +- [x] Laufzeitvorschlag bei neuem `nextduedate` bestätigen/verwerfen +- [x] Laufzeit manuell anlegen/bearbeiten +- [x] Proxmox-Rechnung erfassen (auch Sammelrechnung über mehrere Laufzeiten), PDF-Upload +- [x] Kundenrechnung erfassen (auch Sammelrechnung), PDF-Upload +- [x] Marge pro Laufzeit, Kunde und Jahr berechnen +- [x] Rechnungsübersicht: alle Laufzeiten, Umschalter „Proxmox bezahlt“ / „Kundenrechnung erstellt“ direkt in der Zeile, Filter Offen/Erledigt/Alle, Kunde, Zeitraum +- [x] Sidebar-Badge offene Rechnungspunkte +- [x] Aufgaben-Card auf dem Dashboard mit echten Aktionen ## Akzeptanzkriterien -- [ ] Umschalten in der Tabelle wirkt sofort und wird im Audit-Log protokolliert -- [ ] Summen Einkauf/Verkauf/Marge stimmen mit Einzelwerten überein (Tests) -- [ ] Nur PDF bis 10 MB wird angenommen +- [x] Umschalten in der Tabelle wirkt sofort und wird im Audit-Log protokolliert +- [x] Summen Einkauf/Verkauf/Marge stimmen mit Einzelwerten überein (Tests) +- [x] Nur PDF bis 10 MB wird angenommen ## Abhängigkeiten #007 ## Notizen / Entscheidungen +- **Sammelrechnungen ohne Extraschritt:** Wer bei einer zweiten Laufzeit dieselbe Rechnungsnummer einträgt, + hängt sie als weitere Position an die bestehende Rechnung. Der Rechnungsbetrag ist immer die Summe der + Positionen, wird also automatisch nachgeführt. +- Das Häkchen „Kundenrechnung erstellt“ legt bei fehlender Rechnung eine ohne Nummer an; Nummer und Betrag + lassen sich später nachtragen, ohne dass eine zweite Rechnung entsteht. +- Das Erfassen einer Proxmox-Rechnung bestätigt einen Laufzeit-Vorschlag automatisch. +- PDFs werden unter ihrer Prüfsumme abgelegt (`UPLOAD_DIR`, Standard `./data/uploads`). Geprüft wird der + Dateianfang, nicht der gemeldete Typ – eine umbenannte Datei wird abgelehnt. Dieselbe Datei belegt nur + einmal Platz. diff --git a/docs/issues/README.md b/docs/issues/README.md index 84cd613..22f321e 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -11,8 +11,8 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#005](005-agent-skript.md) | Agent-Skript | erledigt | v0.2.0 | 8 h | 0 h 25 min | | [#006](006-kunden-hostzuweisung.md) | Kunden, Standorte und Hostzuweisung | erledigt | v0.3.0 | 6 h | 0 h 35 min | | [#007](007-hosts-detailansichten.md) | Host-Tabelle, Suche und Detailansichten | erledigt | v0.4.0 | 8 h | 0 h 25 min | -| [#008](008-laufzeiten-rechnungen.md) | Laufzeiten und Rechnungen | in Arbeit | v0.5.0 | 10 h | – | -| [#009](009-kundenuebersicht-dashboard-jahresbahn.md) | Kundenübersicht, Dashboard und Jahresbahn | offen | v0.6.0 | 8 h | – | +| [#008](008-laufzeiten-rechnungen.md) | Laufzeiten und Rechnungen | erledigt | v0.5.0 | 10 h | 0 h 35 min | +| [#009](009-kundenuebersicht-dashboard-jahresbahn.md) | Kundenübersicht, Dashboard und Jahresbahn | in Arbeit | v0.6.0 | 8 h | – | | [#010](010-export.md) | Export CSV und PDF | offen | v0.6.0 | 5 h | – | | [#011](011-warnregeln.md) | Warnregeln | offen | v0.7.0 | 8 h | – | | [#012](012-benachrichtigungen.md) | Benachrichtigungen | offen | v0.8.0 | 6 h | – | @@ -24,4 +24,4 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#018](018-agent-feldtest.md) | Agent auf echten Proxmox-Hosts prüfen | offen | v0.2.1 | 2 h | – | | [#019](019-ci-pipeline.md) | CI-Pipeline | offen | v0.10.0 | 3 h | – | | [#020](020-gitea-issue-abgleich.md) | Issues nach Gitea übertragen | erledigt | v0.4.1 | 2 h | 0 h 10 min | -| | **Summe** | | | **114 h** | **2 h 48 min** | +| | **Summe** | | | **114 h** | **3 h 23 min** | diff --git a/package.json b/package.json index c8b1703..dae45b8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proxledger", - "version": "0.4.2", + "version": "0.5.0", "private": true, "scripts": { "dev": "next dev", diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx index 60d383e..ff79ef5 100644 --- a/src/app/(dashboard)/page.tsx +++ b/src/app/(dashboard)/page.tsx @@ -39,6 +39,7 @@ export default async function DashboardSeite() { dringlichkeit: aufgabe.dringlichkeit, badgeText: aufgabe.badgeText, href: aufgabe.href, + aktion: aufgabe.aktion, })), ...ohneKunde.map((host) => ({ id: `zuweisen-${host.id}`, diff --git a/src/app/(dashboard)/rechnungen/[id]/page.tsx b/src/app/(dashboard)/rechnungen/[id]/page.tsx new file mode 100644 index 0000000..347df8b --- /dev/null +++ b/src/app/(dashboard)/rechnungen/[id]/page.tsx @@ -0,0 +1,197 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ContentHeader } from "@/components/layout/ContentHeader"; +import { KundenrechnungFormular, ProxmoxRechnungFormular } from "@/components/rechnungen/RechnungsFormulare"; +import { Badge, EinkaufBadge, VerkaufBadge } from "@/components/ui/Badges"; +import { Callout } from "@/components/ui/Callout"; +import { Card } from "@/components/ui/Card"; +import { CopyableCode } from "@/components/ui/CopyableCode"; +import { PRODUKT_TEXT } from "@/lib/anzeige"; +import { formatDatum, formatEuro } from "@/lib/format"; +import { laufzeitBestaetigenAktion, laufzeitVerwerfenAktion } from "@/server/actions/rechnungen"; +import { laufzeitDetail } from "@/server/queries/rechnungen"; + +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const laufzeit = await laufzeitDetail(id); + return { title: laufzeit ? `Laufzeit ${laufzeit.host.hostname}` : "Laufzeit" }; +} + +export default async function LaufzeitSeite({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const laufzeit = await laufzeitDetail(id); + if (!laufzeit) notFound(); + + const einkauf = laufzeit.proxmoxPosition?.rechnung ?? null; + const verkauf = laufzeit.kundenPosition?.rechnung ?? null; + const einkaufBetrag = laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null; + const verkaufBetrag = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null; + const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null; + + const zeitraum = `${formatDatum(laufzeit.start)} – ${formatDatum(laufzeit.ende)}`; + + return ( + <> + Vorschlag : null} + /> + +
+
+ {laufzeit.status === "VORSCHLAG" ? ( + +
+ + + +
+
+ + + +
+
+ } + > + Der Host hat ein neues Ablaufdatum gemeldet. Mit dem Erfassen einer Proxmox-Rechnung gilt die + Laufzeit automatisch als bestätigt. + + ) : null} + +
+
+ +
+
Host
+
+ {laufzeit.host.hostname} +
+
Kunde
+
+ {laufzeit.host.kunde ? ( + {laufzeit.host.kunde.name} + ) : ( + Nicht zugeordnet + )} +
+
Produkt
+
{PRODUKT_TEXT[laufzeit.host.produkt]}
+
Key
+
+ +
+
Beginn
+
{formatDatum(laufzeit.start)}
+
Ende
+
{formatDatum(laufzeit.ende)}
+
+
+
+
Einkauf netto
+
{formatEuro(einkaufBetrag)}
+
Verkauf netto
+
{formatEuro(verkaufBetrag)}
+
Marge
+
= 0 ? "pl-text-success" : "pl-text-danger"}`}> + {marge === null ? "–" : formatEuro(marge)} +
+
+
+ + {einkauf && einkauf.positionen.length > 1 ? ( + +

+ Die Proxmox-Rechnung {einkauf.nummer} deckt mehrere + Laufzeiten ab: +

+
    + {einkauf.positionen.map((position) => ( +
  • + {position.laufzeit ? ( + + {position.laufzeit.host.hostname} + + ) : ( + position.bezeichnung + )}{" "} + · {formatEuro(Number(position.nettoBetrag))} +
  • + ))} +
+
+ ) : null} +
+ +
+ } + > + + + + + } + > + + +
+
+
+ + + ); +} diff --git a/src/app/(dashboard)/rechnungen/page.tsx b/src/app/(dashboard)/rechnungen/page.tsx index 7da1ec9..c8fb043 100644 --- a/src/app/(dashboard)/rechnungen/page.tsx +++ b/src/app/(dashboard)/rechnungen/page.tsx @@ -1,26 +1,55 @@ import Link from "next/link"; import { ContentHeader } from "@/components/layout/ContentHeader"; +import { StatusHaken } from "@/components/rechnungen/StatusHaken"; +import { Badge } from "@/components/ui/Badges"; +import { Callout } from "@/components/ui/Callout"; import { Card } from "@/components/ui/Card"; import { CopyableCode } from "@/components/ui/CopyableCode"; -import { formatDatum, formatEuro } from "@/lib/format"; -import { hosts, laufzeiten } from "@/mock/data"; -import { getKunde, istAbgeschlossen } from "@/mock/queries"; +import { InfoBox } from "@/components/ui/SmallBox"; +import { prisma } from "@/lib/db"; +import { formatDatum, formatEuro, tageBis } from "@/lib/format"; +import { + anzahlVorschlaege, + laufzeitJahre, + rechnungsListe, + type RechnungsFilter, +} from "@/server/queries/rechnungen"; export const metadata = { title: "Rechnungen" }; -export default async function RechnungenSeite({ - searchParams, -}: { - searchParams: Promise<{ filter?: string }>; -}) { - const { filter = "offen" } = await searchParams; - const alle = laufzeiten.filter((l) => !l.vorschlag); - const liste = - filter === "erledigt" - ? alle.filter(istAbgeschlossen) - : filter === "alle" - ? alle - : alle.filter((l) => !istAbgeschlossen(l)); +type SuchParameter = Promise>; + +function einzeln(wert: string | string[] | undefined): string | undefined { + const text = Array.isArray(wert) ? wert[0] : wert; + return text && text.length > 0 ? text : undefined; +} + +export default async function RechnungenSeite({ searchParams }: { searchParams: SuchParameter }) { + const roh = await searchParams; + const filter: RechnungsFilter = { + status: (einzeln(roh.status) as RechnungsFilter["status"]) ?? "offen", + kunde: einzeln(roh.kunde), + jahr: einzeln(roh.jahr), + q: einzeln(roh.q), + }; + + const [{ zeilen, summen }, jahre, kunden, vorschlaege] = await Promise.all([ + rechnungsListe(filter), + laufzeitJahre(), + prisma.kunde.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }), + anzahlVorschlaege(), + ]); + + const heute = new Date(); + + function adresse(aenderung: Record) { + const params = new URLSearchParams(); + for (const [schluessel, wert] of Object.entries({ ...filter, ...aenderung })) { + if (wert) params.set(schluessel, String(wert)); + } + const text = params.toString(); + return text ? `/rechnungen?${text}` : "/rechnungen"; + } return ( <> @@ -28,21 +57,73 @@ export default async function RechnungenSeite({
+ {vorschlaege > 0 && filter.status !== "vorschlaege" ? ( + + Ansehen + + } + > + Die Agents haben neue Ablaufdaten gemeldet. Bestätige die Laufzeit und trage die Rechnungsdaten + ein. + + ) : null} + +

+ Summen beziehen sich auf die aktuelle Auswahl ({zeilen.length} Laufzeiten). +

+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ - {[ - ["offen", "Offen"], - ["erledigt", "Erledigt"], - ["alle", "Alle"], - ].map(([wert, text]) => ( + {( + [ + ["offen", "Offen"], + ["erledigt", "Erledigt"], + ["alle", "Alle"], + ["vorschlaege", `Vorschläge${vorschlaege ? ` (${vorschlaege})` : ""}`], + ] as const + ).map(([wert, text]) => ( {text} @@ -50,6 +131,55 @@ export default async function RechnungenSeite({
} > +
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+
@@ -60,76 +190,106 @@ export default async function RechnungenSeite({ + - {liste.map((laufzeit) => { - const host = hosts.find((h) => h.id === laufzeit.hostId); - const kunde = getKunde(host?.kundeId ?? null); - const marge = (laufzeit.verkauf?.nettoBetrag ?? 0) - (laufzeit.einkauf?.nettoBetrag ?? 0); + {zeilen.map((zeile) => { + const faelligIn = tageBis(zeile.einkaufFaelligAm, heute); return ( - + - + - + - + ); })} + {zeilen.length === 0 ? ( + + + + ) : null}
Proxmox-Rechnung Netto - Proxmox bezahlt + Bezahlt Kundenrechnung Netto - Kundenrechnung erstellt + Erstellt + + Bezahlt Marge
- {kunde ? ( - - {kunde.name} + {zeile.kunde ? ( + + {zeile.kunde.name} ) : ( Nicht zugeordnet )} - - {host?.hostname} + + {zeile.hostname} - {formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)} + + {formatDatum(zeile.start)} – {formatDatum(zeile.ende)} + + {zeile.vorschlag ? Vorschlag : null} - + + {zeile.einkaufBezahlt === false && faelligIn !== null && faelligIn <= 7 ? ( +
+ fällig {formatDatum(zeile.einkaufFaelligAm)} +
+ ) : null}
{formatEuro(laufzeit.einkauf?.nettoBetrag)}{formatEuro(zeile.einkaufBetrag)} - - + {formatEuro(laufzeit.verkauf?.nettoBetrag)}{formatEuro(zeile.verkaufBetrag)} - = 0 ? "pl-text-success" : "pl-text-danger"}> - {laufzeit.verkauf?.nettoBetrag ? formatEuro(marge) : "–"} + + + = 0 ? "pl-text-success" : "pl-text-danger"}> + {zeile.marge === null ? "–" : formatEuro(zeile.marge)}
+ Nichts gefunden. Alle anzeigen +
+

- Im Prototyp sind die Häkchen noch ohne Wirkung. Ab #008 setzen sie den Status direkt. + Die Häkchen wirken sofort. Für Rechnungsnummer, Beträge und PDF die Laufzeit öffnen.

diff --git a/src/app/anhang/[id]/route.ts b/src/app/anhang/[id]/route.ts new file mode 100644 index 0000000..6ab9d47 --- /dev/null +++ b/src/app/anhang/[id]/route.ts @@ -0,0 +1,26 @@ +import { readFile } from "node:fs/promises"; +import { prisma } from "@/lib/db"; +import { anhangPfad } from "@/lib/uploads"; + +export const dynamic = "force-dynamic"; + +/** Liefert ein hinterlegtes Rechnungs-PDF aus. */ +export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const anhang = await prisma.anhang.findUnique({ where: { id } }); + if (!anhang) return new Response("Nicht gefunden", { status: 404 }); + + try { + const inhalt = await readFile(anhangPfad(anhang.pfad)); + return new Response(new Uint8Array(inhalt), { + headers: { + "Content-Type": anhang.mimeTyp, + "Content-Disposition": `inline; filename="${encodeURIComponent(anhang.dateiname)}"`, + "Content-Length": String(inhalt.length), + "Cache-Control": "private, no-store", + }, + }); + } catch { + return new Response("Datei fehlt auf der Platte", { status: 410 }); + } +} diff --git a/src/components/rechnungen/RechnungsFormulare.tsx b/src/components/rechnungen/RechnungsFormulare.tsx new file mode 100644 index 0000000..fc01bd3 --- /dev/null +++ b/src/components/rechnungen/RechnungsFormulare.tsx @@ -0,0 +1,345 @@ +"use client"; + +import { useActionState } from "react"; +import { kundenrechnungSpeichern, proxmoxRechnungSpeichern } from "@/server/actions/rechnungen"; +import type { AktionsErgebnis } from "@/server/actions/kunden"; + +const LEER: AktionsErgebnis = {}; + +function isoDatum(wert: string | null | undefined) { + return wert ? wert.slice(0, 10) : ""; +} + +function Meldung({ ergebnis }: { ergebnis: AktionsErgebnis }) { + if (ergebnis.fehler) return
{ergebnis.fehler}
; + if (ergebnis.erfolg) return
{ergebnis.erfolg}
; + return null; +} + +export interface ProxmoxWerte { + nummer: string | null; + datum: string | null; + faelligAm: string | null; + nettoBetrag: number | null; + ustSatz: number; + rabatt: number | null; + zahlungsart: string | null; + bezahlt: boolean; + bezahltAm: string | null; + anhangId: string | null; + anhangName: string | null; +} + +/** Proxmox-Rechnung (Einkauf). Gleiche Rechnungsnummer heißt: Position an derselben Rechnung. */ +export function ProxmoxRechnungFormular({ + laufzeitId, + hostId, + werte, +}: { + laufzeitId: string; + hostId: string; + werte: ProxmoxWerte; +}) { + const [ergebnis, aktion, laeuft] = useActionState(proxmoxRechnungSpeichern, LEER); + + return ( +
+ + + + +
+
+ + +
+ Eine bereits vorhandene Nummer ergänzt die Sammelrechnung um diese Laufzeit. +
+
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+
+
+ + +
+
+ + + {werte.anhangId ? ( + + ) : null} +
+
+ + + + ); +} + +export interface KundenWerte { + erstellt: boolean; + nummer: string | null; + datum: string | null; + nettoBetrag: number | null; + ustSatz: number; + bezahlt: boolean; + bezahltAm: string | null; + anhangId: string | null; + anhangName: string | null; +} + +/** Kundenrechnung (Verkauf). */ +export function KundenrechnungFormular({ + laufzeitId, + hostId, + werte, + kundeVorhanden, +}: { + laufzeitId: string; + hostId: string; + werte: KundenWerte; + kundeVorhanden: boolean; +}) { + const [ergebnis, aktion, laeuft] = useActionState(kundenrechnungSpeichern, LEER); + + if (!kundeVorhanden) { + return ( +

+ Dieser Host ist keinem Kunden zugewiesen. Ohne Kunde lässt sich keine Kundenrechnung erfassen. +

+ ); + } + + return ( +
+ + + + +
+
+
+ + +
+
+
+ + +
Gleiche Nummer = eine Rechnung über mehrere Laufzeiten.
+
+
+ + +
+ +
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + + {werte.anhangId ? ( + + ) : null} +
+
+ + + + ); +} diff --git a/src/components/rechnungen/StatusHaken.tsx b/src/components/rechnungen/StatusHaken.tsx new file mode 100644 index 0000000..d0eb717 --- /dev/null +++ b/src/components/rechnungen/StatusHaken.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useTransition } from "react"; +import { statusUmschalten } from "@/server/actions/rechnungen"; + +/** + * Häkchen, das den Rechnungsstatus direkt in der Tabelle setzt. + * Ohne erfasste Rechnung bleibt es deaktiviert – außer bei „Kundenrechnung erstellt“, + * denn dort legt das Häkchen eine Rechnung ohne Nummer an, die später ergänzt wird. + */ +export function StatusHaken({ + laufzeitId, + hostId, + feld, + wert, + deaktiviert = false, + titel, +}: { + laufzeitId: string; + hostId: string; + feld: "einkaufBezahlt" | "verkaufErstellt" | "verkaufBezahlt"; + wert: boolean; + deaktiviert?: boolean; + titel: string; +}) { + const [laeuft, starte] = useTransition(); + + function umschalten(neu: boolean) { + const daten = new FormData(); + daten.set("laufzeitId", laufzeitId); + daten.set("hostId", hostId); + daten.set("feld", feld); + daten.set("wert", String(neu)); + starte(async () => { + await statusUmschalten(daten); + }); + } + + return ( + + {laeuft ? ( + + ); +} diff --git a/src/components/ui/TodoList.tsx b/src/components/ui/TodoList.tsx index 37314c0..f735301 100644 --- a/src/components/ui/TodoList.tsx +++ b/src/components/ui/TodoList.tsx @@ -1,7 +1,8 @@ "use client"; import Link from "next/link"; -import { useState } from "react"; +import { useState, useTransition } from "react"; +import { statusUmschalten } from "@/server/actions/rechnungen"; import { Badge } from "./Badges"; import type { Farbton } from "./SmallBox"; @@ -12,6 +13,12 @@ export interface Aufgabe { dringlichkeit: "gefahr" | "warnung" | "info"; badgeText: string; href: string; + /** Gesetzt, wenn das Abhaken direkt einen Rechnungsstatus setzt. */ + aktion?: { + laufzeitId: string; + hostId: string; + feld: "einkaufBezahlt" | "verkaufErstellt"; + }; } const BADGE_FARBE: Record = { @@ -21,14 +28,30 @@ const BADGE_FARBE: Record = { }; /** - * Aufgabenliste im AdminLTE-Todo-Stil. Im Prototyp merkt sich das Abhaken den Zustand nur lokal; - * ab #008 setzt es den Rechnungsstatus in der Datenbank. + * Aufgabenliste im AdminLTE-Todo-Stil. Aufgaben mit `aktion` setzen beim Abhaken den + * Rechnungsstatus in der Datenbank; die übrigen sind Merkposten und verschwinden erst, + * wenn die Ursache behoben ist (z. B. ein Host einem Kunden zugewiesen wurde). */ export function TodoList({ aufgaben }: { aufgaben: Aufgabe[] }) { - const [erledigt, setErledigt] = useState([]); + const [oertlichErledigt, setOertlichErledigt] = useState([]); + const [laeuft, starte] = useTransition(); - function umschalten(id: string) { - setErledigt((alt) => (alt.includes(id) ? alt.filter((x) => x !== id) : [...alt, id])); + function umschalten(aufgabe: Aufgabe) { + if (!aufgabe.aktion) { + setOertlichErledigt((alt) => + alt.includes(aufgabe.id) ? alt.filter((x) => x !== aufgabe.id) : [...alt, aufgabe.id], + ); + return; + } + + const daten = new FormData(); + daten.set("laufzeitId", aufgabe.aktion.laufzeitId); + daten.set("hostId", aufgabe.aktion.hostId); + daten.set("feld", aufgabe.aktion.feld); + daten.set("wert", "true"); + starte(async () => { + await statusUmschalten(daten); + }); } if (aufgaben.length === 0) { @@ -38,7 +61,7 @@ export function TodoList({ aufgaben }: { aufgaben: Aufgabe[] }) { return (
    {aufgaben.map((aufgabe) => { - const istErledigt = erledigt.includes(aufgabe.id); + const istErledigt = oertlichErledigt.includes(aufgabe.id); return (
  • umschalten(aufgabe.id)} - aria-label={`${aufgabe.titel} erledigt`} + disabled={laeuft} + onChange={() => umschalten(aufgabe)} + aria-label={ + aufgabe.aktion ? `${aufgabe.titel} als erledigt eintragen` : `${aufgabe.titel} abhaken` + } + title={aufgabe.aktion ? "Setzt den Rechnungsstatus" : "Nur zur eigenen Übersicht"} />
    diff --git a/src/lib/uploads.ts b/src/lib/uploads.ts new file mode 100644 index 0000000..555f4a3 --- /dev/null +++ b/src/lib/uploads.ts @@ -0,0 +1,53 @@ +import { createHash } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { prisma } from "./db"; + +/** Rechnungs-PDFs liegen auf der Platte, nur die Metadaten in der Datenbank. */ +export const UPLOAD_VERZEICHNIS = process.env.UPLOAD_DIR ?? path.join(process.cwd(), "data", "uploads"); +export const MAX_GROESSE = 10 * 1024 * 1024; + +export class UploadFehler extends Error {} + +/** + * Nimmt ein PDF entgegen und legt es unter seiner Prüfsumme ab. + * Gleiche Datei zweimal hochgeladen heißt: derselbe Anhang, kein zweites Exemplar auf der Platte. + */ +export async function anhangSpeichern(datei: File): Promise { + if (datei.size === 0) throw new UploadFehler("Die Datei ist leer."); + if (datei.size > MAX_GROESSE) { + throw new UploadFehler(`Die Datei ist größer als ${MAX_GROESSE / 1024 / 1024} MB.`); + } + + const inhalt = Buffer.from(await datei.arrayBuffer()); + + // Nicht auf den gemeldeten Typ verlassen, sondern in die Datei sehen. + if (inhalt.subarray(0, 5).toString("latin1") !== "%PDF-") { + throw new UploadFehler("Nur PDF-Dateien sind erlaubt."); + } + + const sha256 = createHash("sha256").update(inhalt).digest("hex"); + const vorhanden = await prisma.anhang.findFirst({ where: { sha256 } }); + if (vorhanden) return vorhanden.id; + + await mkdir(UPLOAD_VERZEICHNIS, { recursive: true }); + const dateiname = `${sha256}.pdf`; + await writeFile(path.join(UPLOAD_VERZEICHNIS, dateiname), inhalt, { mode: 0o640 }); + + const anhang = await prisma.anhang.create({ + data: { + dateiname: datei.name || dateiname, + pfad: dateiname, + mimeTyp: "application/pdf", + groesse: inhalt.length, + sha256, + }, + }); + + return anhang.id; +} + +export function anhangPfad(pfad: string): string { + // Nur der Dateiname aus der Datenbank wird verwendet; Pfadangaben darin werden verworfen. + return path.join(UPLOAD_VERZEICHNIS, path.basename(pfad)); +} diff --git a/src/server/actions/rechnungen.ts b/src/server/actions/rechnungen.ts new file mode 100644 index 0000000..644cf33 --- /dev/null +++ b/src/server/actions/rechnungen.ts @@ -0,0 +1,168 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { anhangSpeichern, UploadFehler } from "@/lib/uploads"; +import { + einkaufBezahltSetzen, + kundenrechnungErfassen, + laufzeitAnlegen, + laufzeitBestaetigen, + laufzeitVerwerfen, + proxmoxRechnungErfassen, + RechnungsFehler, + verkaufBezahltSetzen, + verkaufErstelltSetzen, +} from "@/server/services/rechnungen"; +import type { AktionsErgebnis } from "./kunden"; + +function datum(wert: FormDataEntryValue | null): Date | null { + const text = wert?.toString().trim(); + return text ? new Date(`${text}T00:00:00Z`) : null; +} + +function betrag(wert: FormDataEntryValue | null): number | null { + const text = wert?.toString().trim().replace(",", "."); + if (!text) return null; + const zahl = Number(text); + return Number.isFinite(zahl) ? zahl : null; +} + +function seitenNeuLaden(hostId?: string | null) { + revalidatePath("/rechnungen"); + revalidatePath("/"); + revalidatePath("/kunden"); + if (hostId) revalidatePath(`/hosts/${hostId}`); +} + +/** Wandelt Fehler aus der Fachlogik in eine Rückmeldung für das Formular. */ +async function ausfuehren(arbeit: () => Promise): Promise { + try { + const meldung = await arbeit(); + return { erfolg: meldung || "Gespeichert." }; + } catch (fehler) { + if (fehler instanceof RechnungsFehler || fehler instanceof UploadFehler) { + return { fehler: fehler.message }; + } + throw fehler; + } +} + +export async function laufzeitBestaetigenAktion(formular: FormData) { + const id = formular.get("laufzeitId")?.toString(); + if (!id) return; + await laufzeitBestaetigen(id); + seitenNeuLaden(formular.get("hostId")?.toString()); +} + +export async function laufzeitVerwerfenAktion(formular: FormData) { + const id = formular.get("laufzeitId")?.toString(); + if (!id) return; + await laufzeitVerwerfen(id); + seitenNeuLaden(formular.get("hostId")?.toString()); +} + +/** Häkchen aus der Rechnungstabelle. */ +export async function statusUmschalten(formular: FormData): Promise { + const laufzeitId = formular.get("laufzeitId")?.toString(); + const feld = formular.get("feld")?.toString(); + const wert = formular.get("wert")?.toString() === "true"; + if (!laufzeitId || !feld) return; + + try { + if (feld === "einkaufBezahlt") await einkaufBezahltSetzen(laufzeitId, wert); + if (feld === "verkaufErstellt") await verkaufErstelltSetzen(laufzeitId, wert); + if (feld === "verkaufBezahlt") await verkaufBezahltSetzen(laufzeitId, wert); + } catch (fehler) { + if (!(fehler instanceof RechnungsFehler)) throw fehler; + // Fehlt die Rechnung, bleibt der Status unverändert; die Tabelle zeigt weiterhin „Fehlt“. + } + + seitenNeuLaden(formular.get("hostId")?.toString()); +} + +export async function proxmoxRechnungSpeichern( + _vorher: AktionsErgebnis, + formular: FormData, +): Promise { + const laufzeitId = formular.get("laufzeitId")?.toString(); + if (!laufzeitId) return { fehler: "Laufzeit fehlt." }; + + return ausfuehren(async () => { + const pdf = formular.get("pdf"); + const anhangId = pdf instanceof File && pdf.size > 0 ? await anhangSpeichern(pdf) : null; + const rechnungsdatum = datum(formular.get("datum")); + + if (!rechnungsdatum) throw new RechnungsFehler("Bitte ein Rechnungsdatum angeben."); + const netto = betrag(formular.get("nettoBetrag")); + if (netto === null) throw new RechnungsFehler("Bitte einen Nettobetrag angeben."); + + const ergebnis = await proxmoxRechnungErfassen(laufzeitId, { + nummer: formular.get("nummer")?.toString() ?? "", + datum: rechnungsdatum, + faelligAm: datum(formular.get("faelligAm")), + nettoBetrag: netto, + ustSatz: betrag(formular.get("ustSatz")) ?? 19, + rabatt: betrag(formular.get("rabatt")), + zahlungsart: formular.get("zahlungsart")?.toString().trim() || null, + bezahlt: formular.get("bezahlt") === "on", + bezahltAm: datum(formular.get("bezahltAm")), + anhangId, + notizen: formular.get("notizen")?.toString().trim() || null, + }); + + seitenNeuLaden(formular.get("hostId")?.toString()); + return ergebnis.sammelrechnung + ? "Gespeichert – die Laufzeit hängt jetzt als weitere Position an dieser Sammelrechnung." + : "Proxmox-Rechnung gespeichert."; + }); +} + +export async function kundenrechnungSpeichern( + _vorher: AktionsErgebnis, + formular: FormData, +): Promise { + const laufzeitId = formular.get("laufzeitId")?.toString(); + if (!laufzeitId) return { fehler: "Laufzeit fehlt." }; + + return ausfuehren(async () => { + const pdf = formular.get("pdf"); + const anhangId = pdf instanceof File && pdf.size > 0 ? await anhangSpeichern(pdf) : null; + + const ergebnis = await kundenrechnungErfassen(laufzeitId, { + erstellt: formular.get("erstellt") === "on", + nummer: formular.get("nummer")?.toString().trim() || null, + datum: datum(formular.get("datum")), + nettoBetrag: betrag(formular.get("nettoBetrag")), + ustSatz: betrag(formular.get("ustSatz")) ?? 19, + bezahlt: formular.get("bezahlt") === "on", + bezahltAm: datum(formular.get("bezahltAm")), + anhangId, + notizen: formular.get("notizen")?.toString().trim() || null, + }); + + seitenNeuLaden(formular.get("hostId")?.toString()); + return ergebnis.sammelrechnung + ? "Gespeichert – die Laufzeit hängt jetzt als weitere Position an dieser Sammelrechnung." + : "Kundenrechnung gespeichert."; + }); +} + +export async function laufzeitAnlegenAktion( + _vorher: AktionsErgebnis, + formular: FormData, +): Promise { + const hostId = formular.get("hostId")?.toString(); + const key = formular.get("key")?.toString().trim(); + const start = datum(formular.get("start")); + const ende = datum(formular.get("ende")); + + if (!hostId || !key || !start || !ende) { + return { fehler: "Bitte Host, Key, Beginn und Ende angeben." }; + } + + return ausfuehren(async () => { + await laufzeitAnlegen({ hostId, key, start, ende, notizen: null }); + seitenNeuLaden(hostId); + return "Laufzeit angelegt."; + }); +} diff --git a/src/server/queries/dashboard.ts b/src/server/queries/dashboard.ts index 6d7cb2e..877dbc1 100644 --- a/src/server/queries/dashboard.ts +++ b/src/server/queries/dashboard.ts @@ -66,7 +66,19 @@ export async function offeneAufgaben() { }, }); - const aufgaben = []; + const aufgaben: { + id: string; + titel: string; + detail: string; + betrag: number | null; + faelligAm: Date | null; + dringlichkeit: "gefahr" | "warnung"; + badgeText: string; + href: string; + start: Date; + ende: Date; + aktion: { laufzeitId: string; hostId: string; feld: "einkaufBezahlt" | "verkaufErstellt" }; + }[] = []; for (const laufzeit of laufzeiten) { const basis = `${laufzeit.host.hostname} · ${laufzeit.host.kunde?.name ?? "Nicht zugeordnet"}`; const einkauf = laufzeit.proxmoxPosition?.rechnung; @@ -80,9 +92,10 @@ export async function offeneAufgaben() { faelligAm: einkauf.faelligAm, dringlichkeit: "gefahr" as const, badgeText: "Einkauf", - href: `/hosts/${laufzeit.hostId}`, + href: `/rechnungen/${laufzeit.id}`, start: laufzeit.start, ende: laufzeit.ende, + aktion: { laufzeitId: laufzeit.id, hostId: laufzeit.hostId, feld: "einkaufBezahlt" as const }, }); } @@ -95,9 +108,10 @@ export async function offeneAufgaben() { faelligAm: null, dringlichkeit: "warnung" as const, badgeText: "Verkauf", - href: "/rechnungen", + href: `/rechnungen/${laufzeit.id}`, start: laufzeit.start, ende: laufzeit.ende, + aktion: { laufzeitId: laufzeit.id, hostId: laufzeit.hostId, feld: "verkaufErstellt" as const }, }); } } diff --git a/src/server/queries/rechnungen.ts b/src/server/queries/rechnungen.ts new file mode 100644 index 0000000..9a98a39 --- /dev/null +++ b/src/server/queries/rechnungen.ts @@ -0,0 +1,152 @@ +import type { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; + +export interface RechnungsFilter { + status?: "offen" | "erledigt" | "alle" | "vorschlaege"; + kunde?: string; + jahr?: string; + q?: string; +} + +function bedingung(filter: RechnungsFilter): Prisma.LaufzeitWhereInput { + const und: Prisma.LaufzeitWhereInput[] = []; + + if (filter.status === "vorschlaege") { + und.push({ status: "VORSCHLAG" }); + } else { + und.push({ status: "BESTAETIGT" }); + } + + if (filter.status === "offen") { + und.push({ + OR: [ + { proxmoxPosition: null }, + { proxmoxPosition: { rechnung: { bezahlt: false } } }, + { kundenPosition: null }, + { kundenPosition: { rechnung: { erstellt: false } } }, + ], + }); + } + + if (filter.status === "erledigt") { + und.push({ proxmoxPosition: { rechnung: { bezahlt: true } } }); + und.push({ kundenPosition: { rechnung: { erstellt: true } } }); + } + + if (filter.kunde) und.push({ host: { kundeId: filter.kunde } }); + + if (filter.jahr) { + const jahr = Number(filter.jahr); + und.push({ + start: { gte: new Date(Date.UTC(jahr, 0, 1)), lt: new Date(Date.UTC(jahr + 1, 0, 1)) }, + }); + } + + if (filter.q?.trim()) { + const suche = filter.q.trim(); + und.push({ + OR: [ + { key: { contains: suche, mode: "insensitive" } }, + { host: { hostname: { contains: suche, mode: "insensitive" } } }, + { proxmoxPosition: { rechnung: { nummer: { contains: suche, mode: "insensitive" } } } }, + { kundenPosition: { rechnung: { nummer: { contains: suche, mode: "insensitive" } } } }, + ], + }); + } + + return { AND: und }; +} + +export async function rechnungsListe(filter: RechnungsFilter) { + const laufzeiten = await prisma.laufzeit.findMany({ + where: bedingung(filter), + orderBy: [{ start: "desc" }], + include: { + host: { select: { id: true, hostname: true, kunde: { select: { id: true, name: true } } } }, + proxmoxPosition: { include: { rechnung: true } }, + kundenPosition: { include: { rechnung: true } }, + }, + }); + + const zeilen = laufzeiten.map((laufzeit) => { + const einkauf = laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null; + const verkauf = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null; + + return { + id: laufzeit.id, + hostId: laufzeit.host.id, + hostname: laufzeit.host.hostname, + kunde: laufzeit.host.kunde, + key: laufzeit.key, + start: laufzeit.start, + ende: laufzeit.ende, + vorschlag: laufzeit.status === "VORSCHLAG", + einkaufNummer: laufzeit.proxmoxPosition?.rechnung.nummer ?? null, + einkaufBetrag: einkauf, + einkaufBezahlt: laufzeit.proxmoxPosition ? laufzeit.proxmoxPosition.rechnung.bezahlt : null, + einkaufFaelligAm: laufzeit.proxmoxPosition?.rechnung.faelligAm ?? null, + verkaufNummer: laufzeit.kundenPosition?.rechnung.nummer ?? null, + verkaufBetrag: verkauf, + verkaufErstellt: laufzeit.kundenPosition?.rechnung.erstellt ?? false, + verkaufBezahlt: laufzeit.kundenPosition?.rechnung.bezahlt ?? false, + marge: einkauf !== null && verkauf !== null ? verkauf - einkauf : null, + }; + }); + + const summen = zeilen.reduce( + (summe, zeile) => ({ + einkauf: summe.einkauf + (zeile.einkaufBetrag ?? 0), + verkauf: summe.verkauf + (zeile.verkaufBetrag ?? 0), + }), + { einkauf: 0, verkauf: 0 }, + ); + + return { + zeilen, + summen: { ...summen, marge: summen.verkauf - summen.einkauf }, + }; +} + +export type RechnungsZeile = Awaited>["zeilen"][number]; + +/** Jahre, für die Laufzeiten vorliegen – für den Zeitraum-Filter. */ +export async function laufzeitJahre(): Promise { + const laufzeiten = await prisma.laufzeit.findMany({ select: { start: true } }); + return [...new Set(laufzeiten.map((l) => String(l.start.getUTCFullYear())))].sort((a, b) => + b.localeCompare(a), + ); +} + +export async function laufzeitDetail(id: string) { + return prisma.laufzeit.findUnique({ + where: { id }, + include: { + host: { + select: { + id: true, + hostname: true, + produkt: true, + kunde: { select: { id: true, name: true } }, + }, + }, + proxmoxPosition: { + include: { + rechnung: { + include: { anhang: true, positionen: { include: { laufzeit: { include: { host: true } } } } }, + }, + }, + }, + kundenPosition: { + include: { + rechnung: { + include: { anhang: true, positionen: { include: { laufzeit: { include: { host: true } } } } }, + }, + }, + }, + }, + }); +} + +export async function anzahlVorschlaege() { + return prisma.laufzeit.count({ where: { status: "VORSCHLAG" } }); +} diff --git a/src/server/services/rechnungen.ts b/src/server/services/rechnungen.ts new file mode 100644 index 0000000..9541597 --- /dev/null +++ b/src/server/services/rechnungen.ts @@ -0,0 +1,312 @@ +import { prisma } from "@/lib/db"; +import { protokolliere } from "@/server/audit"; + +export class RechnungsFehler extends Error {} + +export interface ProxmoxRechnungEingabe { + nummer: string; + datum: Date; + faelligAm: Date | null; + nettoBetrag: number; + ustSatz?: number; + rabatt?: number | null; + zahlungsart?: string | null; + bezahlt: boolean; + bezahltAm?: Date | null; + anhangId?: string | null; + notizen?: string | null; +} + +export interface KundenrechnungEingabe { + erstellt: boolean; + nummer?: string | null; + datum?: Date | null; + nettoBetrag?: number | null; + ustSatz?: number; + bezahlt: boolean; + bezahltAm?: Date | null; + anhangId?: string | null; + notizen?: string | null; +} + +/** Bestätigt einen vom Agent vorgeschlagenen Lizenzzeitraum. */ +export async function laufzeitBestaetigen(id: string) { + const laufzeit = await prisma.laufzeit.update({ + where: { id }, + data: { status: "BESTAETIGT" }, + }); + await protokolliere("laufzeit.bestaetigt", "Laufzeit", id, { + start: laufzeit.start, + ende: laufzeit.ende, + }); + return laufzeit; +} + +/** Verwirft einen Vorschlag; bereits bestätigte Laufzeiten bleiben unangetastet. */ +export async function laufzeitVerwerfen(id: string) { + const laufzeit = await prisma.laufzeit.findUnique({ where: { id } }); + if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden."); + if (laufzeit.status !== "VORSCHLAG") { + throw new RechnungsFehler("Nur Vorschläge können verworfen werden."); + } + + await prisma.laufzeit.delete({ where: { id } }); + await protokolliere("laufzeit.verworfen", "Laufzeit", id, { start: laufzeit.start }); +} + +/** + * Erfasst die Proxmox-Rechnung zu einer Laufzeit. + * Gibt es die Rechnungsnummer schon, wird eine weitere Position angehängt – so entsteht aus mehreren + * Laufzeiten eine Sammelrechnung, ohne dass die Nummer doppelt vergeben wird. + */ +export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: ProxmoxRechnungEingabe) { + const laufzeit = await prisma.laufzeit.findUnique({ + where: { id: laufzeitId }, + include: { proxmoxPosition: true, host: { select: { hostname: true } } }, + }); + if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden."); + if (!eingabe.nummer.trim()) throw new RechnungsFehler("Bitte eine Rechnungsnummer angeben."); + + const nummer = eingabe.nummer.trim(); + const bezeichnung = `${laufzeit.key} · ${laufzeit.host.hostname} · ${laufzeit.start.toISOString().slice(0, 10)} bis ${laufzeit.ende.toISOString().slice(0, 10)}`; + + return prisma.$transaction(async (tx) => { + const vorhanden = await tx.proxmoxRechnung.findUnique({ where: { nummer } }); + + const rechnung = vorhanden + ? await tx.proxmoxRechnung.update({ + where: { id: vorhanden.id }, + data: { + datum: eingabe.datum, + faelligAm: eingabe.faelligAm, + ustSatz: eingabe.ustSatz ?? 19, + rabatt: eingabe.rabatt ?? null, + zahlungsart: eingabe.zahlungsart ?? null, + bezahlt: eingabe.bezahlt, + bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null, + anhangId: eingabe.anhangId ?? vorhanden.anhangId, + notizen: eingabe.notizen ?? vorhanden.notizen, + }, + }) + : await tx.proxmoxRechnung.create({ + data: { + nummer, + datum: eingabe.datum, + faelligAm: eingabe.faelligAm, + nettoBetrag: eingabe.nettoBetrag, + ustSatz: eingabe.ustSatz ?? 19, + rabatt: eingabe.rabatt ?? null, + zahlungsart: eingabe.zahlungsart ?? null, + bezahlt: eingabe.bezahlt, + bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null, + anhangId: eingabe.anhangId ?? null, + notizen: eingabe.notizen ?? null, + }, + }); + + const position = laufzeit.proxmoxPosition + ? await tx.proxmoxRechnungPosition.update({ + where: { id: laufzeit.proxmoxPosition.id }, + data: { rechnungId: rechnung.id, bezeichnung, nettoBetrag: eingabe.nettoBetrag }, + }) + : await tx.proxmoxRechnungPosition.create({ + data: { rechnungId: rechnung.id, bezeichnung, nettoBetrag: eingabe.nettoBetrag }, + }); + + await tx.laufzeit.update({ + where: { id: laufzeitId }, + data: { proxmoxPositionId: position.id, status: "BESTAETIGT" }, + }); + + // Der Rechnungsbetrag ist immer die Summe der Positionen. + const positionen = await tx.proxmoxRechnungPosition.findMany({ where: { rechnungId: rechnung.id } }); + const summe = positionen.reduce((s, p) => s + Number(p.nettoBetrag), 0); + await tx.proxmoxRechnung.update({ where: { id: rechnung.id }, data: { nettoBetrag: summe } }); + + await protokolliere("proxmox_rechnung.erfasst", "ProxmoxRechnung", rechnung.id, { + nummer, + laufzeitId, + nettoBetrag: eingabe.nettoBetrag, + positionen: positionen.length, + }); + + return { rechnungId: rechnung.id, positionId: position.id, sammelrechnung: positionen.length > 1 }; + }); +} + +/** Erfasst die Kundenrechnung zu einer Laufzeit; gleiche Nummer bedeutet auch hier Sammelrechnung. */ +export async function kundenrechnungErfassen(laufzeitId: string, eingabe: KundenrechnungEingabe) { + const laufzeit = await prisma.laufzeit.findUnique({ + where: { id: laufzeitId }, + include: { kundenPosition: true, host: { select: { hostname: true, kundeId: true } } }, + }); + if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden."); + const kundeId = laufzeit.host.kundeId; + if (!kundeId) { + throw new RechnungsFehler("Der Host ist keinem Kunden zugewiesen – ohne Kunde keine Kundenrechnung."); + } + + const nummer = eingabe.nummer?.trim() || null; + const bezeichnung = `${laufzeit.key} · ${laufzeit.host.hostname} · ${laufzeit.start.toISOString().slice(0, 10)} bis ${laufzeit.ende.toISOString().slice(0, 10)}`; + const betrag = eingabe.nettoBetrag ?? 0; + + return prisma.$transaction(async (tx) => { + const vorhanden = nummer ? await tx.kundenrechnung.findUnique({ where: { nummer } }) : null; + if (vorhanden && vorhanden.kundeId !== kundeId) { + throw new RechnungsFehler("Diese Rechnungsnummer gehört bereits zu einem anderen Kunden."); + } + + const daten = { + erstellt: eingabe.erstellt, + nummer, + datum: eingabe.datum ?? null, + ustSatz: eingabe.ustSatz ?? 19, + bezahlt: eingabe.bezahlt, + bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null, + anhangId: eingabe.anhangId ?? null, + notizen: eingabe.notizen ?? null, + }; + + // Welche Rechnung ist gemeint? Zuerst die über die Nummer gefundene, sonst die bisher verknüpfte. + const zielRechnungId = vorhanden?.id ?? laufzeit.kundenPosition?.rechnungId ?? null; + + const rechnung = zielRechnungId + ? await tx.kundenrechnung.update({ where: { id: zielRechnungId }, data: daten }) + : await tx.kundenrechnung.create({ + data: { ...daten, kundeId, nettoBetrag: betrag }, + }); + + const position = laufzeit.kundenPosition + ? await tx.kundenrechnungPosition.update({ + where: { id: laufzeit.kundenPosition.id }, + data: { rechnungId: rechnung.id, bezeichnung, nettoBetrag: betrag }, + }) + : await tx.kundenrechnungPosition.create({ + data: { rechnungId: rechnung.id, bezeichnung, nettoBetrag: betrag }, + }); + + await tx.laufzeit.update({ where: { id: laufzeitId }, data: { kundenPositionId: position.id } }); + + const positionen = await tx.kundenrechnungPosition.findMany({ where: { rechnungId: rechnung.id } }); + const summe = positionen.reduce((s, p) => s + Number(p.nettoBetrag), 0); + await tx.kundenrechnung.update({ where: { id: rechnung.id }, data: { nettoBetrag: summe } }); + + await protokolliere("kundenrechnung.erfasst", "Kundenrechnung", rechnung.id, { + nummer, + laufzeitId, + nettoBetrag: betrag, + positionen: positionen.length, + }); + + return { rechnungId: rechnung.id, positionId: position.id, sammelrechnung: positionen.length > 1 }; + }); +} + +/** Häkchen „Proxmox bezahlt“ direkt aus der Tabelle. */ +export async function einkaufBezahltSetzen(laufzeitId: string, bezahlt: boolean) { + const laufzeit = await prisma.laufzeit.findUnique({ + where: { id: laufzeitId }, + include: { proxmoxPosition: true }, + }); + if (!laufzeit?.proxmoxPosition) { + throw new RechnungsFehler("Für diese Laufzeit ist noch keine Proxmox-Rechnung erfasst."); + } + + await prisma.proxmoxRechnung.update({ + where: { id: laufzeit.proxmoxPosition.rechnungId }, + data: { bezahlt, bezahltAm: bezahlt ? new Date() : null }, + }); + + await protokolliere( + "proxmox_rechnung.bezahlt_gesetzt", + "ProxmoxRechnung", + laufzeit.proxmoxPosition.rechnungId, + { + bezahlt, + laufzeitId, + }, + ); +} + +/** + * Häkchen „Kundenrechnung erstellt“ direkt aus der Tabelle. + * Fehlt die Rechnung noch, entsteht eine ohne Nummer – sie lässt sich später vervollständigen. + */ +export async function verkaufErstelltSetzen(laufzeitId: string, erstellt: boolean) { + const laufzeit = await prisma.laufzeit.findUnique({ + where: { id: laufzeitId }, + include: { kundenPosition: true, host: { select: { hostname: true, kundeId: true } } }, + }); + if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden."); + + if (laufzeit.kundenPosition) { + await prisma.kundenrechnung.update({ + where: { id: laufzeit.kundenPosition.rechnungId }, + data: { erstellt }, + }); + await protokolliere( + "kundenrechnung.erstellt_gesetzt", + "Kundenrechnung", + laufzeit.kundenPosition.rechnungId, + { + erstellt, + laufzeitId, + }, + ); + return; + } + + if (!erstellt) return; + await kundenrechnungErfassen(laufzeitId, { erstellt: true, bezahlt: false, nettoBetrag: 0 }); +} + +/** Häkchen „Kundenrechnung bezahlt“. */ +export async function verkaufBezahltSetzen(laufzeitId: string, bezahlt: boolean) { + const laufzeit = await prisma.laufzeit.findUnique({ + where: { id: laufzeitId }, + include: { kundenPosition: true }, + }); + if (!laufzeit?.kundenPosition) { + throw new RechnungsFehler("Für diese Laufzeit ist noch keine Kundenrechnung erfasst."); + } + + await prisma.kundenrechnung.update({ + where: { id: laufzeit.kundenPosition.rechnungId }, + data: { bezahlt, bezahltAm: bezahlt ? new Date() : null }, + }); + + await protokolliere( + "kundenrechnung.bezahlt_gesetzt", + "Kundenrechnung", + laufzeit.kundenPosition.rechnungId, + { + bezahlt, + laufzeitId, + }, + ); +} + +/** Legt eine Laufzeit von Hand an – etwa für Käufe, die der Agent noch nicht gemeldet hat. */ +export async function laufzeitAnlegen(daten: { + hostId: string; + key: string; + start: Date; + ende: Date; + notizen?: string | null; +}) { + const vorhanden = await prisma.laufzeit.findUnique({ + where: { hostId_start: { hostId: daten.hostId, start: daten.start } }, + }); + if (vorhanden) { + throw new RechnungsFehler("Für dieses Startdatum gibt es bereits eine Laufzeit."); + } + if (daten.ende <= daten.start) { + throw new RechnungsFehler("Das Ende muss nach dem Beginn liegen."); + } + + const laufzeit = await prisma.laufzeit.create({ + data: { ...daten, status: "BESTAETIGT" }, + }); + await protokolliere("laufzeit.angelegt", "Laufzeit", laufzeit.id, daten); + return laufzeit; +} diff --git a/tests/rechnungen.test.ts b/tests/rechnungen.test.ts new file mode 100644 index 0000000..5c3bbe8 --- /dev/null +++ b/tests/rechnungen.test.ts @@ -0,0 +1,316 @@ +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { + einkaufBezahltSetzen, + kundenrechnungErfassen, + laufzeitAnlegen, + laufzeitBestaetigen, + laufzeitVerwerfen, + proxmoxRechnungErfassen, + RechnungsFehler, + verkaufBezahltSetzen, + verkaufErstelltSetzen, +} from "@/server/services/rechnungen"; +import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; + +async function aufbau() { + const kunde = await testPrisma.kunde.create({ + data: { name: "Brandl Elektrotechnik GmbH", kundennummer: "K-1001" }, + }); + + const hostA = await testPrisma.host.create({ + data: { agentId: "a-1", hostname: "pve-01", produkt: "PVE", kundeId: kunde.id }, + }); + const hostB = await testPrisma.host.create({ + data: { agentId: "a-2", hostname: "pve-02", produkt: "PVE", kundeId: kunde.id }, + }); + + const laufzeitA = await testPrisma.laufzeit.create({ + data: { + hostId: hostA.id, + key: "pve1c-aaaa", + start: new Date("2026-09-24"), + ende: new Date("2027-09-23"), + status: "VORSCHLAG", + }, + }); + const laufzeitB = await testPrisma.laufzeit.create({ + data: { + hostId: hostB.id, + key: "pve2c-bbbb", + start: new Date("2026-10-08"), + ende: new Date("2027-10-07"), + status: "VORSCHLAG", + }, + }); + + return { kunde, hostA, hostB, laufzeitA, laufzeitB }; +} + +const EINKAUF = { + nummer: "PX-2026-14790", + datum: new Date("2026-09-10"), + faelligAm: new Date("2026-09-24"), + nettoBetrag: 115, + bezahlt: false, +}; + +describe.runIf(datenbankVerfuegbar)("Laufzeiten und Rechnungen", () => { + beforeEach(async () => { + await datenbankLeeren(); + }); + + afterAll(async () => { + await testPrisma.$disconnect(); + }); + + describe("Laufzeiten", () => { + it("bestätigt einen Vorschlag", async () => { + const { laufzeitA } = await aufbau(); + await laufzeitBestaetigen(laufzeitA.id); + + const danach = await testPrisma.laufzeit.findUniqueOrThrow({ where: { id: laufzeitA.id } }); + expect(danach.status).toBe("BESTAETIGT"); + }); + + it("verwirft einen Vorschlag, aber keine bestätigte Laufzeit", async () => { + const { laufzeitA, laufzeitB } = await aufbau(); + + await laufzeitVerwerfen(laufzeitA.id); + expect(await testPrisma.laufzeit.findUnique({ where: { id: laufzeitA.id } })).toBeNull(); + + await laufzeitBestaetigen(laufzeitB.id); + await expect(laufzeitVerwerfen(laufzeitB.id)).rejects.toThrow(RechnungsFehler); + }); + + it("legt eine Laufzeit von Hand an und verhindert doppelte Startdaten", async () => { + const { hostA } = await aufbau(); + + const daten = { + hostId: hostA.id, + key: "pve1c-manuell", + start: new Date("2028-01-01"), + ende: new Date("2028-12-31"), + }; + await laufzeitAnlegen(daten); + await expect(laufzeitAnlegen(daten)).rejects.toThrow(RechnungsFehler); + await expect( + laufzeitAnlegen({ ...daten, start: new Date("2029-01-01"), ende: new Date("2028-12-31") }), + ).rejects.toThrow(RechnungsFehler); + }); + }); + + describe("Proxmox-Rechnung", () => { + it("erfasst eine Rechnung und bestätigt die Laufzeit dabei", async () => { + const { laufzeitA } = await aufbau(); + const ergebnis = await proxmoxRechnungErfassen(laufzeitA.id, EINKAUF); + + expect(ergebnis.sammelrechnung).toBe(false); + + const laufzeit = await testPrisma.laufzeit.findUniqueOrThrow({ + where: { id: laufzeitA.id }, + include: { proxmoxPosition: { include: { rechnung: true } } }, + }); + expect(laufzeit.status).toBe("BESTAETIGT"); + expect(laufzeit.proxmoxPosition?.rechnung.nummer).toBe("PX-2026-14790"); + expect(Number(laufzeit.proxmoxPosition?.nettoBetrag)).toBe(115); + expect(laufzeit.proxmoxPosition?.rechnung.faelligAm?.toISOString().slice(0, 10)).toBe("2026-09-24"); + }); + + it("hängt eine zweite Laufzeit an dieselbe Rechnungsnummer an (Sammelrechnung)", async () => { + const { laufzeitA, laufzeitB } = await aufbau(); + + await proxmoxRechnungErfassen(laufzeitA.id, EINKAUF); + const zweite = await proxmoxRechnungErfassen(laufzeitB.id, { ...EINKAUF, nettoBetrag: 220 }); + + expect(zweite.sammelrechnung).toBe(true); + expect(await testPrisma.proxmoxRechnung.count()).toBe(1); + + const rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({ + where: { nummer: "PX-2026-14790" }, + include: { positionen: true }, + }); + expect(rechnung.positionen).toHaveLength(2); + // Der Rechnungsbetrag ist die Summe der Positionen. + expect(Number(rechnung.nettoBetrag)).toBe(335); + }); + + it("ändert eine bereits erfasste Rechnung, statt eine zweite Position anzulegen", async () => { + const { laufzeitA } = await aufbau(); + await proxmoxRechnungErfassen(laufzeitA.id, EINKAUF); + await proxmoxRechnungErfassen(laufzeitA.id, { ...EINKAUF, nettoBetrag: 130, bezahlt: true }); + + const rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({ + where: { nummer: "PX-2026-14790" }, + include: { positionen: true }, + }); + expect(rechnung.positionen).toHaveLength(1); + expect(Number(rechnung.nettoBetrag)).toBe(130); + expect(rechnung.bezahlt).toBe(true); + expect(rechnung.bezahltAm).not.toBeNull(); + }); + + it("verlangt eine Rechnungsnummer", async () => { + const { laufzeitA } = await aufbau(); + await expect(proxmoxRechnungErfassen(laufzeitA.id, { ...EINKAUF, nummer: " " })).rejects.toThrow( + RechnungsFehler, + ); + }); + }); + + describe("Kundenrechnung", () => { + it("erfasst eine Kundenrechnung mit Position", async () => { + const { laufzeitA, kunde } = await aufbau(); + await kundenrechnungErfassen(laufzeitA.id, { + erstellt: true, + nummer: "RE-2026-0001", + datum: new Date("2026-10-01"), + nettoBetrag: 165, + bezahlt: false, + }); + + const rechnung = await testPrisma.kundenrechnung.findUniqueOrThrow({ + where: { nummer: "RE-2026-0001" }, + include: { positionen: true }, + }); + expect(rechnung.kundeId).toBe(kunde.id); + expect(rechnung.erstellt).toBe(true); + expect(Number(rechnung.nettoBetrag)).toBe(165); + expect(rechnung.positionen).toHaveLength(1); + }); + + it("fasst mehrere Laufzeiten desselben Kunden zu einer Rechnung zusammen", async () => { + const { laufzeitA, laufzeitB } = await aufbau(); + + await kundenrechnungErfassen(laufzeitA.id, { + erstellt: true, + nummer: "RE-2026-0002", + nettoBetrag: 165, + bezahlt: false, + }); + const zweite = await kundenrechnungErfassen(laufzeitB.id, { + erstellt: true, + nummer: "RE-2026-0002", + nettoBetrag: 320, + bezahlt: false, + }); + + expect(zweite.sammelrechnung).toBe(true); + const rechnung = await testPrisma.kundenrechnung.findUniqueOrThrow({ + where: { nummer: "RE-2026-0002" }, + include: { positionen: true }, + }); + expect(rechnung.positionen).toHaveLength(2); + expect(Number(rechnung.nettoBetrag)).toBe(485); + }); + + it("weist eine Rechnungsnummer ab, die einem anderen Kunden gehört", async () => { + const { laufzeitA } = await aufbau(); + const fremderKunde = await testPrisma.kunde.create({ + data: { name: "Huber Logistik AG", kundennummer: "K-1002" }, + }); + const fremderHost = await testPrisma.host.create({ + data: { agentId: "a-3", hostname: "pve-huber", produkt: "PVE", kundeId: fremderKunde.id }, + }); + const fremdeLaufzeit = await testPrisma.laufzeit.create({ + data: { + hostId: fremderHost.id, + key: "pve1c-cccc", + start: new Date("2026-11-01"), + ende: new Date("2027-10-31"), + status: "BESTAETIGT", + }, + }); + + await kundenrechnungErfassen(fremdeLaufzeit.id, { + erstellt: true, + nummer: "RE-2026-0003", + nettoBetrag: 100, + bezahlt: false, + }); + + await expect( + kundenrechnungErfassen(laufzeitA.id, { + erstellt: true, + nummer: "RE-2026-0003", + nettoBetrag: 165, + bezahlt: false, + }), + ).rejects.toThrow(RechnungsFehler); + }); + + it("lehnt eine Kundenrechnung für einen Host ohne Kunde ab", async () => { + const host = await testPrisma.host.create({ + data: { agentId: "a-ohne", hostname: "pve-ohne-kunde", produkt: "PVE" }, + }); + const laufzeit = await testPrisma.laufzeit.create({ + data: { + hostId: host.id, + key: "pve1c-dddd", + start: new Date("2026-09-01"), + ende: new Date("2027-08-31"), + status: "BESTAETIGT", + }, + }); + + await expect( + kundenrechnungErfassen(laufzeit.id, { erstellt: true, bezahlt: false, nettoBetrag: 10 }), + ).rejects.toThrow(RechnungsFehler); + }); + }); + + describe("Häkchen in der Tabelle", () => { + it("setzt „Proxmox bezahlt“ samt Bezahldatum und nimmt es zurück", async () => { + const { laufzeitA } = await aufbau(); + await proxmoxRechnungErfassen(laufzeitA.id, EINKAUF); + + await einkaufBezahltSetzen(laufzeitA.id, true); + let rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({ where: { nummer: EINKAUF.nummer } }); + expect(rechnung.bezahlt).toBe(true); + expect(rechnung.bezahltAm).not.toBeNull(); + + await einkaufBezahltSetzen(laufzeitA.id, false); + rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({ where: { nummer: EINKAUF.nummer } }); + expect(rechnung.bezahlt).toBe(false); + expect(rechnung.bezahltAm).toBeNull(); + }); + + it("meldet sich, wenn noch keine Proxmox-Rechnung erfasst ist", async () => { + const { laufzeitA } = await aufbau(); + await expect(einkaufBezahltSetzen(laufzeitA.id, true)).rejects.toThrow(RechnungsFehler); + }); + + it("legt beim Häkchen „Kundenrechnung erstellt“ eine Rechnung ohne Nummer an", async () => { + const { laufzeitA } = await aufbau(); + await verkaufErstelltSetzen(laufzeitA.id, true); + + const laufzeit = await testPrisma.laufzeit.findUniqueOrThrow({ + where: { id: laufzeitA.id }, + include: { kundenPosition: { include: { rechnung: true } } }, + }); + expect(laufzeit.kundenPosition?.rechnung.erstellt).toBe(true); + expect(laufzeit.kundenPosition?.rechnung.nummer).toBeNull(); + + // Die Nummer lässt sich später nachtragen, ohne dass eine zweite Rechnung entsteht. + await kundenrechnungErfassen(laufzeitA.id, { + erstellt: true, + nummer: "RE-2026-0009", + nettoBetrag: 165, + bezahlt: false, + }); + expect(await testPrisma.kundenrechnung.count()).toBe(1); + const nachgetragen = await testPrisma.kundenrechnung.findFirstOrThrow(); + expect(nachgetragen.nummer).toBe("RE-2026-0009"); + expect(Number(nachgetragen.nettoBetrag)).toBe(165); + }); + + it("setzt „Kundenrechnung bezahlt“", async () => { + const { laufzeitA } = await aufbau(); + await verkaufErstelltSetzen(laufzeitA.id, true); + await verkaufBezahltSetzen(laufzeitA.id, true); + + const rechnung = await testPrisma.kundenrechnung.findFirstOrThrow(); + expect(rechnung.bezahlt).toBe(true); + expect(rechnung.bezahltAm).not.toBeNull(); + }); + }); +}); diff --git a/tests/uploads.test.ts b/tests/uploads.test.ts new file mode 100644 index 0000000..1f6495e --- /dev/null +++ b/tests/uploads.test.ts @@ -0,0 +1,67 @@ +import { mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; + +let verzeichnis: string; + +beforeAll(() => { + verzeichnis = mkdtempSync(path.join(tmpdir(), "proxledger-uploads-")); + process.env.UPLOAD_DIR = verzeichnis; +}); + +afterAll(() => { + rmSync(verzeichnis, { recursive: true, force: true }); +}); + +/** Kleinstmögliche gültige PDF-Datei. */ +function pdf(inhalt = "Testrechnung"): File { + return new File([`%PDF-1.4\n${inhalt}\n%%EOF\n`], "rechnung.pdf", { type: "application/pdf" }); +} + +describe.runIf(datenbankVerfuegbar)("PDF-Upload", () => { + beforeEach(async () => { + await datenbankLeeren(); + }); + + afterAll(async () => { + await testPrisma.$disconnect(); + }); + + it("nimmt ein PDF an und legt Datei samt Datensatz an", async () => { + const { anhangSpeichern } = await import("@/lib/uploads"); + const id = await anhangSpeichern(pdf()); + + const anhang = await testPrisma.anhang.findUniqueOrThrow({ where: { id } }); + expect(anhang.mimeTyp).toBe("application/pdf"); + expect(anhang.dateiname).toBe("rechnung.pdf"); + expect(anhang.sha256).toHaveLength(64); + expect(readdirSync(verzeichnis)).toContain(`${anhang.sha256}.pdf`); + }); + + it("speichert dieselbe Datei nicht zweimal", async () => { + const { anhangSpeichern } = await import("@/lib/uploads"); + const erste = await anhangSpeichern(pdf()); + const zweite = await anhangSpeichern(pdf()); + + expect(zweite).toBe(erste); + expect(await testPrisma.anhang.count()).toBe(1); + }); + + it("lehnt Dateien ab, die kein PDF sind – auch bei passendem Dateinamen", async () => { + const { anhangSpeichern, UploadFehler } = await import("@/lib/uploads"); + const getarnt = new File([""], "rechnung.pdf", { type: "application/pdf" }); + + await expect(anhangSpeichern(getarnt)).rejects.toThrow(UploadFehler); + expect(await testPrisma.anhang.count()).toBe(0); + }); + + it("lehnt leere und zu große Dateien ab", async () => { + const { anhangSpeichern, MAX_GROESSE, UploadFehler } = await import("@/lib/uploads"); + + await expect(anhangSpeichern(new File([], "leer.pdf"))).rejects.toThrow(UploadFehler); + const zuGross = new File(["%PDF-1.4\n" + "x".repeat(MAX_GROESSE)], "gross.pdf"); + await expect(anhangSpeichern(zuGross)).rejects.toThrow(UploadFehler); + }); +});