Phase 9: Kassenverwaltung (#14)

This commit was merged in pull request #14.
This commit is contained in:
2026-09-05 02:30:51 +00:00
parent d70acad4cd
commit 2b53556175
78 changed files with 12314 additions and 42 deletions
+179 -4
View File
@@ -2,7 +2,7 @@
import * as React from "react";
import Image from "next/image";
import { Camera, Check, Trash2 } from "lucide-react";
import { Camera, Check, Trash2, Wallet } from "lucide-react";
import { KiStand } from "@prisma/client";
import type { inferRouterOutputs } from "@trpc/server";
import { trpc } from "@/lib/trpc/client";
@@ -12,6 +12,21 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { KiAbzeichen, KiFehltHinweis } from "@/components/ki/kennzeichnung";
import { dateiHochladen } from "@/lib/hochladen";
@@ -33,9 +48,11 @@ import { alsDatum, alsEuro } from "@/lib/utils";
* Feld ist eine Aufforderung; ein geratener Wert wäre eine falsche Buchung,
* die niemand bemerkt.
*/
export function BelegListe() {
export function BelegListe({ darfBuchen }: { darfBuchen: boolean }) {
const werkzeuge = trpc.useUtils();
const [buchen, setBuchen] = React.useState<Beleg | null>(null);
const [laedtHoch, setLaedtHoch] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(null);
@@ -141,7 +158,8 @@ export function BelegListe() {
<th className="py-2 pr-4">Kategorie</th>
<th className="py-2 pr-4 text-right">Betrag</th>
<th className="py-2 pr-4">Stand</th>
<th className="py-2">Gegengezeichnet</th>
<th className="py-2 pr-4">Gegengezeichnet</th>
<th className="py-2">Kasse</th>
</tr>
</thead>
<tbody>
@@ -158,11 +176,23 @@ export function BelegListe() {
{KI_STAND_TEXT[beleg.stand]}
</Badge>
</td>
<td className="py-2">
<td className="py-2 pr-4">
{beleg.bestaetigtVon
? `${beleg.bestaetigtVon.vorname} ${beleg.bestaetigtVon.nachname}`
: "—"}
</td>
<td className="py-2">
{beleg.buchungId ? (
<Badge variant="einsatzbereit">gebucht</Badge>
) : beleg.stand === KiStand.UEBERNOMMEN && darfBuchen ? (
<Button size="sm" variant="outline" onClick={() => setBuchen(beleg)}>
<Wallet aria-hidden />
Buchen
</Button>
) : (
"—"
)}
</td>
</tr>
))}
</tbody>
@@ -170,6 +200,19 @@ export function BelegListe() {
</div>
</section>
)}
<BelegBuchenDialog
beleg={buchen}
schliessen={() => setBuchen(null)}
fehler={setMeldung}
fertig={(betrag) => {
setBuchen(null);
setHinweis(
`Beleg über ${betrag} gebucht. Die Buchung steht als Entwurf im Kassenbuch.`,
);
void werkzeuge.assistent.invalidate();
}}
/>
</div>
);
}
@@ -327,3 +370,135 @@ function BelegKarte({ beleg, fehler }: { beleg: Beleg; fehler: (nachricht: strin
</Card>
);
}
/**
* Aus einem gegengezeichneten Beleg eine Buchung machen.
*
* Konto und Kategorie fehlen dem Beleg — das kann keine Maschine entscheiden,
* und der Kassier soll es auch nicht raten müssen. Betrag, Datum und Händler
* stehen bereits fest; sie stammen aus dem gegengezeichneten Beleg und werden
* hier nicht noch einmal zur Bearbeitung gestellt.
*
* Das Foto hängt danach an der Buchung. Genau das verlangt eine
* Kassenprüfung: zu jeder Ausgabe ein Beleg, und zwar an der Buchung und
* nicht in einem Ordner daneben.
*/
function BelegBuchenDialog({
beleg,
schliessen,
fertig,
fehler,
}: {
beleg: Beleg | null;
schliessen: () => void;
fertig: (betrag: string) => void;
fehler: (nachricht: string) => void;
}) {
const [kontoId, setKontoId] = React.useState("");
const [kategorieId, setKategorieId] = React.useState("");
const konten = trpc.kassenkonto.liste.useQuery(undefined, { enabled: beleg !== null });
const kategorien = trpc.buchungskategorie.liste.useQuery(
{ art: "AUSGABE" },
{ enabled: beleg !== null },
);
const vorschlag = trpc.buchung.vorschlag.useQuery(
{
verwendungszweck: `${beleg?.haendler ?? ""} ${beleg?.kategorie ?? ""}`.trim(),
art: "AUSGABE",
},
{ enabled: beleg !== null && Boolean(beleg.haendler ?? beleg.kategorie) },
);
const buchen = trpc.buchung.belegBuchen.useMutation({
onSuccess: (buchung) => fertig(alsEuro(Number(buchung.betrag))),
onError: (aufruf) => fehler(aufruf.message),
});
React.useEffect(() => {
if (!kontoId && konten.data?.[0]) setKontoId(konten.data[0].id);
}, [kontoId, konten.data]);
// Derselbe Vorschlag wie in der Buchungsmaske — sonst landet derselbe
// Händler einmal unter Kraftstoff und einmal unter Fahrzeuge, und die
// Jahresauswertung ist dahin.
React.useEffect(() => {
if (!kategorieId && vorschlag.data) setKategorieId(vorschlag.data.kategorieId);
}, [kategorieId, vorschlag.data]);
React.useEffect(() => {
if (!beleg) setKategorieId("");
}, [beleg]);
return (
<Dialog open={beleg !== null} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Beleg buchen</DialogTitle>
<DialogDescription>
{beleg?.haendler ?? "Beleg"}
{beleg?.betrag !== null &&
beleg?.betrag !== undefined &&
` · ${alsEuro(Number(beleg.betrag))}`}
{beleg?.belegdatum && ` · ${alsDatum(beleg.belegdatum)}`}. Es entsteht eine Ausgabe im
Kassenbuch, mit dem Foto als Beleg. Sie ist zunächst ein Entwurf.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="beleg-konto">Von welchem Konto</Label>
<Select value={kontoId} onValueChange={setKontoId}>
<SelectTrigger id="beleg-konto">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{konten.data?.map((konto) => (
<SelectItem key={konto.id} value={konto.id}>
{konto.bezeichnung}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="beleg-kategorie">Kategorie</Label>
<Select value={kategorieId} onValueChange={setKategorieId}>
<SelectTrigger id="beleg-kategorie">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{kategorien.data?.map((kategorie) => (
<SelectItem key={kategorie.id} value={kategorie.id}>
{kategorie.oberkategorie
? `${kategorie.oberkategorie.name} › ${kategorie.name}`
: kategorie.name}
</SelectItem>
))}
</SelectContent>
</Select>
{vorschlag.data && (
<p className="text-xs text-[var(--muted-foreground)]">
Vorgeschlagen: {vorschlag.data.name} — {vorschlag.data.grund}
</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() => beleg && buchen.mutate({ belegId: beleg.id, kontoId, kategorieId })}
disabled={!kontoId || !kategorieId || buchen.isPending}
>
{buchen.isPending ? "Wird gebucht …" : "Buchen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+5 -5
View File
@@ -8,9 +8,9 @@ export const metadata = { title: "Belege" };
/**
* Belegerfassung.
*
* Quittung fotografieren, Angaben prüfen, gegenzeichnen. Die Buchung daraus
* kommt mit der Kasse in Phase 9 — hier entsteht erst einmal ein sauber
* erfasster Beleg mit Foto.
* Quittung fotografieren, Angaben prüfen, gegenzeichnen — und seit Phase 9
* buchen. Der letzte Schritt braucht ein eigenes Recht: wer Belege erfasst,
* führt deshalb noch lange nicht die Kasse.
*/
export default async function BelegeSeite() {
const sitzung = await aktuelleSitzung();
@@ -22,11 +22,11 @@ export default async function BelegeSeite() {
<div>
<h1 className="text-2xl font-semibold tracking-tight">Belege</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Quittung fotografieren, Angaben prüfen, gegenzeichnen.
Quittung fotografieren, Angaben prüfen, gegenzeichnen — und ins Kassenbuch buchen.
</p>
</div>
<BelegListe />
<BelegListe darfBuchen={hatRecht(sitzung, "kasse.bearbeiten")} />
</div>
);
}
+780
View File
@@ -0,0 +1,780 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { ArrowLeft, Download, FileText, Lock, Plus } from "lucide-react";
import { BuchungsArt } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { alsBetrag } from "@/lib/kasse/texte";
import { alsDatum } from "@/lib/utils";
const MONATE = [
"Januar",
"Februar",
"März",
"April",
"Mai",
"Juni",
"Juli",
"August",
"September",
"Oktober",
"November",
"Dezember",
];
/**
* Abschluss und Auswertung.
*
* Zwei Dinge, die zusammengehören: der Kassenprüfer will die Jahreszahlen
* sehen, bevor er unterschreibt, und die Jahreshauptversammlung will beides.
*/
export function Abschlussansicht({ darfAbschliessen }: { darfAbschliessen: boolean }) {
const [reiter, setReiter] = React.useState<"pruefung" | "auswertung">("pruefung");
return (
<div className="space-y-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/kasse">
<ArrowLeft aria-hidden />
Zurück zur Kasse
</Link>
</Button>
<div className="flex gap-2 border-b border-[var(--border)]">
<Reiter aktiv={reiter === "pruefung"} beim={() => setReiter("pruefung")}>
Kassenprüfung
</Reiter>
<Reiter aktiv={reiter === "auswertung"} beim={() => setReiter("auswertung")}>
Auswertung
</Reiter>
</div>
{reiter === "pruefung" ? (
<Pruefungen darfAbschliessen={darfAbschliessen} />
) : (
<Auswertung />
)}
</div>
);
}
function Reiter({
aktiv,
beim,
children,
}: {
aktiv: boolean;
beim: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={beim}
className={`-mb-px border-b-2 px-3 py-2 text-sm ${
aktiv
? "border-[var(--primary)] font-medium"
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
}`}
>
{children}
</button>
);
}
/** Die Liste der Prüfungen — und der Weg zu einer neuen. */
function Pruefungen({ darfAbschliessen }: { darfAbschliessen: boolean }) {
const werkzeuge = trpc.useUtils();
const [anlegenOffen, setAnlegenOffen] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(null);
const abschluesse = trpc.kassenabschluss.liste.useQuery();
const festschreiben = trpc.kassenabschluss.festschreiben.useMutation({
onSuccess: () => {
setHinweis(
"Festgeschrieben. Der Zeitraum ist zu, und der Prüfbericht entsteht im Hintergrund.",
);
void werkzeuge.kassenabschluss.invalidate();
void werkzeuge.buchung.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
return (
<div className="space-y-4">
{meldung && <Fehlermeldung meldung={meldung} kennung="abschlussfehler" />}
{hinweis && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
{hinweis}
</div>
)}
{darfAbschliessen && (
<div className="flex justify-end">
<Button onClick={() => setAnlegenOffen(true)}>
<Plus aria-hidden />
Kassenprüfung anlegen
</Button>
</div>
)}
{!abschluesse.isLoading && (abschluesse.data?.length ?? 0) === 0 && (
<NichtsGefunden
titel="Noch keine Kassenprüfung"
hinweis="Ein Abschluss hält fest, was gezählt wurde und was das Buch sagt — und schließt den Zeitraum danach."
/>
)}
<div className="space-y-3">
{abschluesse.data?.map((abschluss) => {
const differenz = Number(abschluss.differenz);
return (
<Card key={abschluss.id}>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<CardTitle className="text-base">
{alsDatum(abschluss.vonAm)} bis {alsDatum(abschluss.bisAm)}
</CardTitle>
<CardDescription>
{abschluss.pruefer
? `Geprüft von ${abschluss.pruefer.vorname} ${abschluss.pruefer.nachname}`
: "Noch kein Prüfer eingetragen"}
{abschluss.zweiterPruefer &&
` und ${abschluss.zweiterPruefer.vorname} ${abschluss.zweiterPruefer.nachname}`}
{abschluss.geprueftAm && ` am ${alsDatum(abschluss.geprueftAm)}`}
{` · ${abschluss._count.posten} Konten`}
</CardDescription>
</div>
{abschluss.festgeschriebenAm ? (
<Badge variant="einsatzbereit">
<Lock className="size-3" aria-hidden />
Festgeschrieben
</Badge>
) : (
<Badge variant="outline">Entwurf</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-3 text-sm sm:grid-cols-3">
<Wert titel="Laut Buch" wert={abschluss.bestandLautBuch} />
<Wert titel="Gezählt" wert={abschluss.gezaehlterBestand} />
<Wert
titel="Differenz"
wert={abschluss.differenz}
warnen={Math.abs(differenz) > 0.005}
/>
</div>
{abschluss.erlaeuterung && (
<p className="rounded-md bg-[var(--muted)] p-3 text-sm">
{abschluss.erlaeuterung}
</p>
)}
<div className="flex flex-wrap gap-2">
{abschluss.berichtDatei && (
<Button size="sm" variant="outline" asChild>
<a href={`/api/dateien/${abschluss.berichtDatei.id}`} target="_blank">
<Download aria-hidden />
Prüfbericht
</a>
</Button>
)}
{darfAbschliessen && !abschluss.festgeschriebenAm && (
<Button
size="sm"
onClick={() => festschreiben.mutate({ id: abschluss.id })}
disabled={festschreiben.isPending}
>
<Lock aria-hidden />
Festschreiben
</Button>
)}
</div>
{darfAbschliessen && !abschluss.festgeschriebenAm && (
<p className="text-xs text-[var(--muted-foreground)]">
Festschreiben schließt den Zeitraum: die Buchungen darin lassen sich danach
nicht mehr ändern, und ein neues Datum in diesem Zeitraum wird abgelehnt.
</p>
)}
</CardContent>
</Card>
);
})}
</div>
{darfAbschliessen && (
<AbschlussAnlegen
offen={anlegenOffen}
schliessen={() => setAnlegenOffen(false)}
fehler={setMeldung}
fertig={() => {
setAnlegenOffen(false);
setHinweis(
"Angelegt — noch als Entwurf. Die Prüfer können ihre Zahlen korrigieren, solange sie beisammensitzen.",
);
void werkzeuge.kassenabschluss.invalidate();
}}
/>
)}
</div>
);
}
function Wert({
titel,
wert,
warnen = false,
}: {
titel: string;
wert: string | { toString(): string };
warnen?: boolean;
}) {
return (
<div>
<p className="text-xs text-[var(--muted-foreground)]">{titel}</p>
<p
className={`text-lg font-semibold tabular-nums ${
warnen ? "text-[var(--destructive)]" : ""
}`}
>
{alsBetrag(wert)}
</p>
</div>
);
}
/**
* Die Prüfungsmaske.
*
* Links steht, was das Buch sagt; rechts wird eingetragen, was gezählt wurde.
* In dieser Reihenfolge — wer erst das Buch sieht und dann zählt, zählt, bis
* es passt.
*
* Das ist der Grund, warum die Buchbestände hier zwar zu sehen sind (der
* Prüfer braucht sie für die Differenz), die gezählten Werte aber leer
* anfangen und nicht vorbelegt sind.
*/
function AbschlussAnlegen({
offen,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const jahr = new Date().getFullYear() - 1;
const [vonAm, setVonAm] = React.useState(`${jahr}-01-01`);
const [bisAm, setBisAm] = React.useState(`${jahr}-12-31`);
const [prueferId, setPrueferId] = React.useState("");
const [zweiterId, setZweiterId] = React.useState("");
const [geprueftAm, setGeprueftAm] = React.useState(new Date().toISOString().slice(0, 10));
const [erlaeuterung, setErlaeuterung] = React.useState("");
const [gezaehlt, setGezaehlt] = React.useState<Record<string, string>>({});
const vorbereitung = trpc.kassenabschluss.vorbereiten.useQuery(
{ vonAm: new Date(vonAm), bisAm: new Date(bisAm) },
{ enabled: offen && Boolean(vonAm) && Boolean(bisAm) },
);
const mitglieder = trpc.person.liste.useQuery(
{ seite: 1, seitengroesse: 200, richtung: "asc" },
{ enabled: offen },
);
const anlegen = trpc.kassenabschluss.anlegen.useMutation({
onSuccess: () => {
setGezaehlt({});
setErlaeuterung("");
fertig();
},
onError: (aufruf) => fehler(aufruf.message),
});
const posten = vorbereitung.data?.posten ?? [];
const alleGezaehlt = posten.every((eintrag) => gezaehlt[eintrag.kontoId]?.trim());
const summeGezaehlt = posten.reduce(
(gesamt, eintrag) => gesamt + Number(gezaehlt[eintrag.kontoId] || 0),
0,
);
const differenz = summeGezaehlt - Number(vorbereitung.data?.gesamt ?? 0);
const abweichung = Math.abs(differenz) > 0.005;
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Kassenprüfung</DialogTitle>
<DialogDescription>
Erst zählen, dann eintragen. Der Buchbestand steht daneben, damit die Differenz
sichtbar wird — und nicht, damit die gezählte Zahl daran angepasst wird.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="abschluss-von">Zeitraum von</Label>
<Input
id="abschluss-von"
type="date"
value={vonAm}
onChange={(ereignis) => setVonAm(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="abschluss-bis">bis</Label>
<Input
id="abschluss-bis"
type="date"
value={bisAm}
onChange={(ereignis) => setBisAm(ereignis.target.value)}
/>
</div>
</div>
{(vorbereitung.data?.offeneBuchungen ?? 0) > 0 && (
<div className="rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-3 text-sm">
{vorbereitung.data?.offeneBuchungen} Buchungen im Zeitraum sind noch nicht
festgeschrieben. Das Festschreiben des Abschlusses nimmt sie mit — wer sie vorher
durchsieht, prüft einen Stand, der sich danach nicht mehr ändert.
</div>
)}
{posten.length > 0 && (
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
<th className="px-3 py-2">Konto</th>
<th className="px-3 py-2 text-right">Laut Buch</th>
<th className="px-3 py-2 text-right">Gezählt</th>
</tr>
</thead>
<tbody>
{posten.map((eintrag) => {
const wert = gezaehlt[eintrag.kontoId] ?? "";
const einzelDifferenz = wert
? Number(wert) - Number(eintrag.bestandLautBuch)
: 0;
return (
<tr
key={eintrag.kontoId}
className="border-b border-[var(--border)] last:border-0"
>
<td className="px-3 py-2">
{eintrag.bezeichnung}
<span className="block text-xs text-[var(--muted-foreground)]">
im Zeitraum +{alsBetrag(eintrag.einnahmen)} / −
{alsBetrag(eintrag.ausgaben)}
</span>
</td>
<td className="px-3 py-2 text-right tabular-nums">
{alsBetrag(eintrag.bestandLautBuch)}
</td>
<td className="px-3 py-2 text-right">
<Input
type="number"
step="0.01"
className="w-32 text-right"
value={wert}
onChange={(ereignis) =>
setGezaehlt((vorher) => ({
...vorher,
[eintrag.kontoId]: ereignis.target.value,
}))
}
aria-label={`Gezählter Bestand ${eintrag.bezeichnung}`}
/>
{wert && Math.abs(einzelDifferenz) > 0.005 && (
<span className="block text-xs text-[var(--destructive)]">
{einzelDifferenz > 0 ? "+" : ""}
{alsBetrag(einzelDifferenz.toFixed(2))}
</span>
)}
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t border-[var(--border)] font-medium">
<td className="px-3 py-2">Summe</td>
<td className="px-3 py-2 text-right tabular-nums">
{alsBetrag(vorbereitung.data?.gesamt)}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{alsBetrag(summeGezaehlt.toFixed(2))}
</td>
</tr>
</tfoot>
</table>
</div>
)}
{abweichung && (
<div className="rounded-md border border-[var(--destructive)]/40 bg-[var(--destructive)]/10 p-3 text-sm">
Differenz von <strong>{alsBetrag(differenz.toFixed(2))}</strong>. Sie muss erklärt
werden, sonst lässt sich der Abschluss nicht festschreiben — in fünf Jahren weiß
sonst niemand mehr, woher sie kam.
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="abschluss-pruefer">Kassenprüfer</Label>
<Select value={prueferId} onValueChange={setPrueferId}>
<SelectTrigger id="abschluss-pruefer">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{mitglieder.data?.eintraege.map((person) => (
<SelectItem key={person.id} value={person.id}>
{person.nachname}, {person.vorname}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="abschluss-zweiter">Zweiter Prüfer</Label>
<Select value={zweiterId} onValueChange={setZweiterId}>
<SelectTrigger id="abschluss-zweiter">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{mitglieder.data?.eintraege.map((person) => (
<SelectItem key={person.id} value={person.id}>
{person.nachname}, {person.vorname}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="abschluss-geprueft">Geprüft am</Label>
<Input
id="abschluss-geprueft"
type="date"
value={geprueftAm}
onChange={(ereignis) => setGeprueftAm(ereignis.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="abschluss-erlaeuterung">
Erläuterung {abweichung && <span className="text-[var(--destructive)]">*</span>}
</Label>
<Textarea
id="abschluss-erlaeuterung"
rows={3}
value={erlaeuterung}
onChange={(ereignis) => setErlaeuterung(ereignis.target.value)}
placeholder="Bar 3,20 € weniger — Wechselgeld vom Maifest, Beleg fehlt."
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
anlegen.mutate({
vonAm: new Date(vonAm),
bisAm: new Date(bisAm),
prueferId: prueferId || null,
zweiterPrueferId: zweiterId || null,
geprueftAm: geprueftAm ? new Date(geprueftAm) : null,
erlaeuterung: erlaeuterung.trim() || null,
posten: posten.map((eintrag) => ({
kontoId: eintrag.kontoId,
gezaehlterBestand: Number(gezaehlt[eintrag.kontoId] ?? 0),
})),
})
}
disabled={posten.length === 0 || !alleGezaehlt || anlegen.isPending}
>
{anlegen.isPending ? "Wird angelegt …" : "Als Entwurf anlegen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Jahreszahlen für die Jahreshauptversammlung. */
function Auswertung() {
const [jahr, setJahr] = React.useState(new Date().getFullYear());
const [exportLaeuft, setExportLaeuft] = React.useState(false);
const werkzeuge = trpc.useUtils();
const auswertung = trpc.kassenabschluss.auswertung.useQuery({ jahr });
const jahre = Array.from({ length: 6 }, (_, stelle) => new Date().getFullYear() - stelle);
async function herunterladen() {
setExportLaeuft(true);
try {
const datei = await werkzeuge.kassenabschluss.export.fetch({ jahr });
// Der Umweg über einen Blob statt eines Links auf eine API-Route: die
// Daten liegen bereits vor, und so entsteht kein zweiter Weg an der
// Rechteprüfung vorbei.
const ziel = URL.createObjectURL(
new Blob([datei.inhalt], { type: "text/csv;charset=utf-8" }),
);
const verweis = document.createElement("a");
verweis.href = ziel;
verweis.download = datei.dateiname;
verweis.click();
URL.revokeObjectURL(ziel);
} finally {
setExportLaeuft(false);
}
}
const einnahmen = auswertung.data?.kategorien.filter(
(eintrag) => eintrag.art === BuchungsArt.EINNAHME,
);
const ausgaben = auswertung.data?.kategorien.filter(
(eintrag) => eintrag.art === BuchungsArt.AUSGABE,
);
const hoechster = Math.max(
1,
...(auswertung.data?.monate.flatMap((monat) => [
Number(monat.einnahmen),
Number(monat.ausgaben),
]) ?? [1]),
);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Select value={String(jahr)} onValueChange={(wert) => setJahr(Number(wert))}>
<SelectTrigger className="w-32" aria-label="Jahr">
<SelectValue />
</SelectTrigger>
<SelectContent>
{jahre.map((wert) => (
<SelectItem key={wert} value={String(wert)}>
{wert}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
className="ml-auto"
onClick={() => void herunterladen()}
disabled={exportLaeuft}
>
<FileText aria-hidden />
{exportLaeuft ? "Wird erzeugt …" : "Kassenbuch als CSV"}
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<Vergleich
titel="Einnahmen"
wert={auswertung.data?.gesamt.einnahmen}
vorjahr={auswertung.data?.vorjahr.einnahmen}
jahr={jahr}
/>
<Vergleich
titel="Ausgaben"
wert={auswertung.data?.gesamt.ausgaben}
vorjahr={auswertung.data?.vorjahr.ausgaben}
jahr={jahr}
/>
<Vergleich
titel="Saldo"
wert={auswertung.data?.gesamt.saldo}
vorjahr={auswertung.data?.vorjahr.saldo}
jahr={jahr}
/>
</div>
{auswertung.data?.anzahl === 0 && (
<NichtsGefunden
titel={`Für ${jahr} steht nichts im Kassenbuch`}
hinweis="Ohne Buchungen gibt es nichts auszuwerten."
/>
)}
{(auswertung.data?.anzahl ?? 0) > 0 && (
<>
{/* Der Jahresverlauf. Zwölf Balkenpaare, kein Diagrammpaket —
das reicht, um zu sehen, wo das Fest war. */}
<Card>
<CardHeader>
<CardTitle className="text-base">Verlauf {jahr}</CardTitle>
<CardDescription>
Einnahmen und Ausgaben je Monat, gemessen am größten Monatswert.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-1.5">
{auswertung.data?.monate.map((monat) => (
<div key={monat.monat} className="flex items-center gap-3 text-xs">
<span className="w-20 shrink-0 text-[var(--muted-foreground)]">
{MONATE[monat.monat - 1]}
</span>
<div className="flex flex-1 flex-col gap-0.5">
<div
className="h-2 rounded-sm bg-[var(--einsatzbereit)]"
style={{ width: `${(Number(monat.einnahmen) / hoechster) * 100}%` }}
/>
<div
className="h-2 rounded-sm bg-[var(--destructive)]"
style={{ width: `${(Number(monat.ausgaben) / hoechster) * 100}%` }}
/>
</div>
<span className="w-28 shrink-0 text-right tabular-nums">
{alsBetrag(monat.saldo)}
</span>
</div>
))}
</div>
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
<Kategorieliste titel="Einnahmen nach Kategorie" zeilen={einnahmen} />
<Kategorieliste titel="Ausgaben nach Kategorie" zeilen={ausgaben} />
</div>
</>
)}
</div>
);
}
function Vergleich({
titel,
wert,
vorjahr,
jahr,
}: {
titel: string;
wert: string | undefined;
vorjahr: string | undefined;
jahr: number;
}) {
const jetzt = Number(wert ?? 0);
const davor = Number(vorjahr ?? 0);
const unterschied = jetzt - davor;
return (
<Card>
<CardHeader className="pb-1">
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
{titel}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xl font-semibold tabular-nums">{alsBetrag(wert)}</p>
<p className="text-xs text-[var(--muted-foreground)]">
{jahr - 1}: {alsBetrag(vorjahr)}
{davor !== 0 && (
<>
{" · "}
{unterschied >= 0 ? "+" : "−"}
{alsBetrag(Math.abs(unterschied).toFixed(2))}
</>
)}
</p>
</CardContent>
</Card>
);
}
function Kategorieliste({
titel,
zeilen,
}: {
titel: string;
zeilen: Array<{ id: string; name: string; oberkategorie: string | null; summe: string }> | undefined;
}) {
const gesamt = zeilen?.reduce((wert, zeile) => wert + Number(zeile.summe), 0) ?? 0;
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{titel}</CardTitle>
</CardHeader>
<CardContent>
{(zeilen?.length ?? 0) === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">Nichts gebucht.</p>
) : (
<ul className="space-y-1.5 text-sm">
{zeilen?.map((zeile) => (
<li key={zeile.id}>
<div className="flex justify-between gap-2">
<span>
{zeile.name}
{zeile.oberkategorie && (
<span className="text-[var(--muted-foreground)]">
{" "}
· {zeile.oberkategorie}
</span>
)}
</span>
<span className="tabular-nums">{alsBetrag(zeile.summe)}</span>
</div>
<div
className="mt-0.5 h-1 rounded-sm bg-[var(--primary)]/40"
style={{ width: `${gesamt > 0 ? (Number(zeile.summe) / gesamt) * 100 : 0}%` }}
/>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Abschlussansicht } from "@/app/(app)/kasse/abschluss/ansicht";
export const metadata = { title: "Kassenabschluss" };
export default async function AbschlussSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "kasse.lesen")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Abschluss und Auswertung</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Kassenprüfung, Jahreszahlen und der Export für Steuerberater oder Kämmerei.
</p>
</div>
<Abschlussansicht darfAbschliessen={hatRecht(sitzung, "kasse.abschluss")} />
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Beitragsverwaltung } from "@/app/(app)/kasse/beitraege/verwaltung";
export const metadata = { title: "Beiträge" };
export default async function BeitraegeSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "kasse.lesen")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Mitgliedsbeiträge</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Sätze, Beitragslauf und wer noch nicht gezahlt hat.
</p>
</div>
<Beitragsverwaltung darfBearbeiten={hatRecht(sitzung, "kasse.bearbeiten")} />
</div>
);
}
@@ -0,0 +1,735 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { AlertTriangle, ArrowLeft, Check, Mail, PlayCircle, Plus } from "lucide-react";
import { BeitragStand } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { BEITRAG_STAND_TEXT, BEITRAG_STAND_VARIANTE, alsBetrag } from "@/lib/kasse/texte";
import { alsDatum } from "@/lib/utils";
/**
* Die Beitragsverwaltung.
*
* Der Ablauf, wie er im Januar wirklich stattfindet:
*
* 1. Sätze prüfen — sind sie noch aktuell?
* 2. Beitragslauf starten. Vorher steht da, wie viele Beiträge entstehen und
* wer keinen Satz hat.
* 3. Zahlungen abhaken. Jede erzeugt eine Buchung.
* 4. Im Herbst mahnen, was offen ist.
*
* Die Liste ohne Satz ist der interessante Teil der Vorschau: das sind die
* Leute, für die niemand entschieden hat, was sie zahlen.
*/
export function Beitragsverwaltung({ darfBearbeiten }: { darfBearbeiten: boolean }) {
const werkzeuge = trpc.useUtils();
const [jahr, setJahr] = React.useState(new Date().getFullYear());
const [stand, setStand] = React.useState<BeitragStand | "alle">("alle");
const [suche, setSuche] = React.useState("");
const [laufOffen, setLaufOffen] = React.useState(false);
const [satzOffen, setSatzOffen] = React.useState(false);
const [bezahlen, setBezahlen] = React.useState<{ id: string; name: string; betrag: string } | null>(
null,
);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(null);
const [gewaehlt, setGewaehlt] = React.useState<Set<string>>(new Set());
const saetze = trpc.beitrag.saetze.useQuery();
const beitraege = trpc.beitrag.liste.useQuery({
jahr,
stand: stand === "alle" ? undefined : stand,
suche: suche.trim() || undefined,
});
const rueckstaende = trpc.beitrag.rueckstaende.useQuery();
const mahnen = trpc.beitrag.mahnen.useMutation({
onSuccess: (ergebnis) => {
setHinweis(
`${ergebnis.gemahnt} Beiträge gemahnt.` +
(ergebnis.uebersprungen > 0
? ` ${ergebnis.uebersprungen} übersprungen — dort ist die fünfte Mahnstufe erreicht, und was danach kommt, entscheidet die Kommandantschaft.`
: ""),
);
setGewaehlt(new Set());
void werkzeuge.beitrag.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const erlassen = trpc.beitrag.erlassen.useMutation({
onSuccess: () => void werkzeuge.beitrag.invalidate(),
onError: (fehler) => setMeldung(fehler.message),
});
const jahre = Array.from({ length: 6 }, (_, stelle) => new Date().getFullYear() - stelle);
const zeilen = beitraege.data?.beitraege ?? [];
return (
<div className="space-y-6">
<Button variant="ghost" size="sm" asChild>
<Link href="/kasse">
<ArrowLeft aria-hidden />
Zurück zur Kasse
</Link>
</Button>
{meldung && <Fehlermeldung meldung={meldung} kennung="beitragsfehler" />}
{hinweis && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
{hinweis}
</div>
)}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Kennzahl titel="Sollstellung" wert={beitraege.data?.kennzahlen.gesamt} />
<Kennzahl titel="Bezahlt" wert={beitraege.data?.kennzahlen.bezahlt} />
<Kennzahl
titel="Offen"
wert={beitraege.data?.kennzahlen.offen}
hinweis={`${beitraege.data?.kennzahlen.anzahlOffen ?? 0} Mitglieder`}
hervorheben={(beitraege.data?.kennzahlen.anzahlOffen ?? 0) > 0}
/>
<Kennzahl titel="Erlassen" wert={beitraege.data?.kennzahlen.erlassen} />
</div>
{/* Rückstände über alle Jahre — die Liste für die Jahreshauptversammlung */}
{(rueckstaende.data?.zeilen.length ?? 0) > 0 && (
<Card className="border-[var(--pruefung-faellig)]">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="size-4" aria-hidden />
Rückstände über alle Jahre
</CardTitle>
<CardDescription>
{rueckstaende.data?.anzahl} offene Beiträge über{" "}
{alsBetrag(rueckstaende.data?.gesamt)}. Wer über mehrere Jahre offen steht, fällt
hier auf — und sonst nirgends.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-1 text-sm">
{rueckstaende.data?.zeilen.slice(0, 10).map((zeile) => (
<li key={zeile.person.id} className="flex flex-wrap justify-between gap-2">
<span>
{zeile.person.nachname}, {zeile.person.vorname}
<span className="text-[var(--muted-foreground)]">
{" "}
· {zeile.jahre.join(", ")}
{zeile.hoechsteMahnstufe > 0 && ` · ${zeile.hoechsteMahnstufe}. Mahnung`}
</span>
</span>
<span className="tabular-nums font-medium">{alsBetrag(zeile.summe)}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* Sätze */}
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold">Beitragssätze</h2>
{darfBearbeiten && (
<Button variant="outline" size="sm" onClick={() => setSatzOffen(true)}>
<Plus aria-hidden />
Satz anlegen
</Button>
)}
</div>
{(saetze.data?.length ?? 0) === 0 ? (
<NichtsGefunden
titel="Noch keine Sätze"
hinweis="Ohne Satz weiß FLORIAN nicht, was ein Mitglied zahlt — der Beitragslauf legt dann nichts an."
/>
) : (
<div className="flex flex-wrap gap-2">
{saetze.data?.map((satz) => (
<div
key={satz.id}
className="rounded-lg border border-[var(--border)] bg-[var(--card)] px-3 py-2 text-sm"
>
<span className="font-medium">{satz.bezeichnung}</span>
<span className="text-[var(--muted-foreground)]">
{" "}
· {alsBetrag(satz.betrag)} ab {satz.abJahr}
{satz.abteilung ? ` · ${satz.abteilung}` : " · alle Abteilungen"}
</span>
</div>
))}
</div>
)}
</section>
{/* Liste */}
<section className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<Select value={String(jahr)} onValueChange={(wert) => setJahr(Number(wert))}>
<SelectTrigger className="w-32" aria-label="Jahr">
<SelectValue />
</SelectTrigger>
<SelectContent>
{jahre.map((wert) => (
<SelectItem key={wert} value={String(wert)}>
{wert}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={stand} onValueChange={(wert) => setStand(wert as BeitragStand | "alle")}>
<SelectTrigger className="w-44" aria-label="Stand">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Stände</SelectItem>
{Object.values(BeitragStand).map((wert) => (
<SelectItem key={wert} value={wert}>
{BEITRAG_STAND_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Name oder Mitgliedsnummer"
className="min-w-48 flex-1"
aria-label="Mitglieder durchsuchen"
/>
{darfBearbeiten && (
<div className="ml-auto flex gap-2">
{gewaehlt.size > 0 && (
<Button
variant="outline"
onClick={() => mahnen.mutate({ ids: [...gewaehlt] })}
disabled={mahnen.isPending}
>
<Mail aria-hidden />
{gewaehlt.size} mahnen
</Button>
)}
<Button onClick={() => setLaufOffen(true)}>
<PlayCircle aria-hidden />
Beitragslauf {jahr}
</Button>
</div>
)}
</div>
{!beitraege.isLoading && zeilen.length === 0 && (
<NichtsGefunden
titel={`Keine Beiträge für ${jahr}`}
hinweis={
darfBearbeiten
? "Der Beitragslauf legt sie für alle aktiven Mitglieder an."
: "Für dieses Jahr wurde noch nicht abgerechnet."
}
/>
)}
{zeilen.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
{darfBearbeiten && <th className="px-3 py-2" />}
<th className="px-3 py-2">Mitglied</th>
<th className="px-3 py-2">Abteilung</th>
<th className="px-3 py-2 text-right">Betrag</th>
<th className="px-3 py-2">Stand</th>
<th className="px-3 py-2">Bezahlt</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody>
{zeilen.map((beitrag) => {
const offen =
beitrag.stand === BeitragStand.OFFEN || beitrag.stand === BeitragStand.GEMAHNT;
return (
<tr key={beitrag.id} className="border-b border-[var(--border)] last:border-0">
{darfBearbeiten && (
<td className="px-3 py-2">
{offen && (
<input
type="checkbox"
checked={gewaehlt.has(beitrag.id)}
onChange={(ereignis) => {
const neu = new Set(gewaehlt);
if (ereignis.target.checked) neu.add(beitrag.id);
else neu.delete(beitrag.id);
setGewaehlt(neu);
}}
aria-label={`${beitrag.person.nachname} auswählen`}
/>
)}
</td>
)}
<td className="px-3 py-2">
{beitrag.person.nachname}, {beitrag.person.vorname}
<span className="block text-xs text-[var(--muted-foreground)]">
{beitrag.person.mitgliedsnummer}
</span>
</td>
<td className="px-3 py-2">{beitrag.person.abteilung ?? "—"}</td>
<td className="px-3 py-2 text-right tabular-nums">
{alsBetrag(beitrag.betrag)}
</td>
<td className="px-3 py-2">
<Badge variant={BEITRAG_STAND_VARIANTE[beitrag.stand]}>
{BEITRAG_STAND_TEXT[beitrag.stand]}
</Badge>
{beitrag.mahnstufe > 0 && (
<span className="ml-1 text-xs text-[var(--muted-foreground)]">
{beitrag.mahnstufe}.
</span>
)}
</td>
<td className="px-3 py-2 whitespace-nowrap">
{beitrag.bezahltAm ? alsDatum(beitrag.bezahltAm) : "—"}
</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
{darfBearbeiten && offen && (
<>
<Button
size="sm"
variant="ghost"
onClick={() =>
setBezahlen({
id: beitrag.id,
name: `${beitrag.person.nachname}, ${beitrag.person.vorname}`,
betrag: String(beitrag.betrag),
})
}
>
<Check aria-hidden />
Bezahlt
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
const grund = window.prompt(
"Warum wird der Beitrag erlassen? Ohne Begründung ist er in der Kassenprüfung eine offene Frage.",
);
if (grund?.trim()) {
erlassen.mutate({ id: beitrag.id, grund: grund.trim() });
}
}}
>
Erlassen
</Button>
</>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
{darfBearbeiten && (
<>
<BeitragslaufDialog
offen={laufOffen}
jahr={jahr}
schliessen={() => setLaufOffen(false)}
fehler={setMeldung}
fertig={(angelegt) => {
setLaufOffen(false);
setHinweis(`${angelegt} Beiträge für ${jahr} angelegt.`);
void werkzeuge.beitrag.invalidate();
}}
/>
<SatzDialog
offen={satzOffen}
schliessen={() => setSatzOffen(false)}
fehler={setMeldung}
fertig={() => {
setSatzOffen(false);
void werkzeuge.beitrag.invalidate();
}}
/>
<BezahlenDialog
eintrag={bezahlen}
schliessen={() => setBezahlen(null)}
fehler={setMeldung}
fertig={() => {
setBezahlen(null);
void werkzeuge.beitrag.invalidate();
void werkzeuge.kassenkonto.invalidate();
}}
/>
</>
)}
</div>
);
}
function Kennzahl({
titel,
wert,
hinweis,
hervorheben = false,
}: {
titel: string;
wert: string | undefined;
hinweis?: string;
hervorheben?: boolean;
}) {
return (
<Card className={hervorheben ? "border-[var(--pruefung-faellig)]" : undefined}>
<CardHeader className="pb-1">
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
{titel}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xl font-semibold tabular-nums">{alsBetrag(wert)}</p>
{hinweis && <p className="text-xs text-[var(--muted-foreground)]">{hinweis}</p>}
</CardContent>
</Card>
);
}
/** Der Beitragslauf mit Vorschau. */
function BeitragslaufDialog({
offen,
jahr,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
jahr: number;
schliessen: () => void;
fertig: (angelegt: number) => void;
fehler: (nachricht: string) => void;
}) {
const vorschau = trpc.beitrag.laufVorschau.useQuery({ jahr }, { enabled: offen });
const lauf = trpc.beitrag.lauf.useMutation({
onSuccess: (ergebnis) => fertig(ergebnis.angelegt),
onError: (aufruf) => fehler(aufruf.message),
});
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Beitragslauf {jahr}</DialogTitle>
<DialogDescription>
Legt an, was fehlt. Wiederholt ausgeführt entsteht nichts doppelt — der Lauf lässt
sich also im Februar noch einmal starten, wenn Neue dazugekommen sind.
</DialogDescription>
</DialogHeader>
{vorschau.isLoading && <p className="text-sm">Wird gerechnet …</p>}
{vorschau.data && (
<div className="space-y-4 text-sm">
<div className="rounded-md bg-[var(--muted)] p-3">
<p>
<strong>{vorschau.data.anzulegen.length} Beiträge</strong> werden angelegt, Summe{" "}
<strong>{alsBetrag(vorschau.data.summe)}</strong>.
</p>
{vorschau.data.bereitsVorhanden > 0 && (
<p className="text-[var(--muted-foreground)]">
{vorschau.data.bereitsVorhanden} Mitglieder haben für {jahr} bereits einen
Beitrag — die bleiben unberührt.
</p>
)}
</div>
{vorschau.data.ohneSatz.length > 0 && (
<div className="rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-3">
<p className="font-medium">
{vorschau.data.ohneSatz.length} Mitglieder ohne passenden Satz
</p>
<p className="mt-1 text-[var(--muted-foreground)]">
Für sie entsteht kein Beitrag. Entweder fehlt ein Satz für ihre Abteilung, oder
es gibt keinen allgemeinen.
</p>
<ul className="mt-2 list-disc pl-5">
{vorschau.data.ohneSatz.slice(0, 8).map((eintrag) => (
<li key={eintrag.personId}>
{eintrag.name}
{eintrag.abteilung && ` (${eintrag.abteilung})`}
</li>
))}
</ul>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() => lauf.mutate({ jahr })}
disabled={!vorschau.data?.anzulegen.length || lauf.isPending}
>
{lauf.isPending ? "Läuft …" : `${vorschau.data?.anzulegen.length ?? 0} anlegen`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function SatzDialog({
offen,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [bezeichnung, setBezeichnung] = React.useState("");
const [betrag, setBetrag] = React.useState("");
const [abteilung, setAbteilung] = React.useState("");
const [abJahr, setAbJahr] = React.useState(String(new Date().getFullYear()));
const anlegen = trpc.beitrag.satzAnlegen.useMutation({
onSuccess: () => {
setBezeichnung("");
setBetrag("");
setAbteilung("");
fertig();
},
onError: (aufruf) => fehler(aufruf.message),
});
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Beitragssatz anlegen</DialogTitle>
<DialogDescription>
Ein Satz gilt ab einem Jahr und wird nicht rückwirkend geändert — sonst stimmen die
Beiträge vergangener Jahre nicht mehr, und die sind bereits geprüft.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="satz-bezeichnung">Bezeichnung</Label>
<Input
id="satz-bezeichnung"
value={bezeichnung}
onChange={(ereignis) => setBezeichnung(ereignis.target.value)}
placeholder="Aktive Mitglieder"
/>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="satz-betrag">Betrag je Jahr</Label>
<Input
id="satz-betrag"
type="number"
step="0.01"
min="0"
value={betrag}
onChange={(ereignis) => setBetrag(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="satz-abteilung">Abteilung</Label>
<Input
id="satz-abteilung"
value={abteilung}
onChange={(ereignis) => setAbteilung(ereignis.target.value)}
placeholder="leer = alle"
/>
</div>
<div className="space-y-2">
<Label htmlFor="satz-jahr">Ab Jahr</Label>
<Input
id="satz-jahr"
type="number"
value={abJahr}
onChange={(ereignis) => setAbJahr(ereignis.target.value)}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
anlegen.mutate({
bezeichnung,
betrag: Number(betrag),
abteilung: abteilung.trim() || null,
abJahr: Number(abJahr),
aktiv: true,
})
}
disabled={!bezeichnung.trim() || !betrag || anlegen.isPending}
>
{anlegen.isPending ? "Wird angelegt …" : "Anlegen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Zahlungseingang — erzeugt zugleich die Buchung. */
function BezahlenDialog({
eintrag,
schliessen,
fertig,
fehler,
}: {
eintrag: { id: string; name: string; betrag: string } | null;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [kontoId, setKontoId] = React.useState("");
const [kategorieId, setKategorieId] = React.useState("");
const [datum, setDatum] = React.useState(new Date().toISOString().slice(0, 10));
const konten = trpc.kassenkonto.liste.useQuery();
const kategorien = trpc.buchungskategorie.liste.useQuery({ art: "EINNAHME" });
const bezahlen = trpc.beitrag.bezahlen.useMutation({
onSuccess: fertig,
onError: (aufruf) => fehler(aufruf.message),
});
React.useEffect(() => {
if (!kontoId && konten.data?.[0]) setKontoId(konten.data[0].id);
// Die Kategorie „Mitgliedsbeiträge" vorbelegen — sie ist fast immer die
// richtige und kommt aus dem Standardkontenrahmen.
if (!kategorieId && kategorien.data) {
const treffer = kategorien.data.find((eintrag) =>
eintrag.name.toLowerCase().includes("beitr"),
);
if (treffer) setKategorieId(treffer.id);
}
}, [kontoId, kategorieId, konten.data, kategorien.data]);
return (
<Dialog open={eintrag !== null} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Beitrag als bezahlt vermerken</DialogTitle>
<DialogDescription>
{eintrag?.name} · {alsBetrag(eintrag?.betrag)} — es entsteht zugleich die Buchung.
Ein Häkchen ohne Geld in der Kasse wäre eine Behauptung.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="bezahlt-datum">Bezahlt am</Label>
<Input
id="bezahlt-datum"
type="date"
value={datum}
onChange={(ereignis) => setDatum(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="bezahlt-konto">Auf welches Konto</Label>
<Select value={kontoId} onValueChange={setKontoId}>
<SelectTrigger id="bezahlt-konto">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{konten.data?.map((konto) => (
<SelectItem key={konto.id} value={konto.id}>
{konto.bezeichnung}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="bezahlt-kategorie">Kategorie</Label>
<Select value={kategorieId} onValueChange={setKategorieId}>
<SelectTrigger id="bezahlt-kategorie">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{kategorien.data?.map((kategorie) => (
<SelectItem key={kategorie.id} value={kategorie.id}>
{kategorie.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
eintrag &&
bezahlen.mutate({
id: eintrag.id,
kontoId,
kategorieId,
bezahltAm: new Date(datum),
})
}
disabled={!kontoId || !kategorieId || bezahlen.isPending}
>
{bezahlen.isPending ? "Wird gebucht …" : "Bezahlt und buchen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,929 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { ArrowLeft, Lock, Plus, Search, Sparkles, Undo2, Upload } from "lucide-react";
import { BuchungsArt } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { BUCHUNGSART_TEXT, alsBetrag } from "@/lib/kasse/texte";
import { alsDatum } from "@/lib/utils";
/**
* Das Kassenbuch.
*
* Oben die Summen über **alle** Treffer, nicht nur über die angezeigten —
* eine Summe unter einer auf hundert Zeilen begrenzten Liste bedeutet sonst
* nichts.
*
* Festgeschriebene Buchungen tragen ein Schloss. Sie lassen sich weder ändern
* noch löschen; wer sich vertan hat, bucht dagegen. Beide bleiben stehen, und
* im Kassenbuch ist zu sehen, dass etwas berichtigt wurde — genau das will ein
* Kassenprüfer sehen.
*/
export function BuchungsListe({ darfBearbeiten }: { darfBearbeiten: boolean }) {
const werkzeuge = trpc.useUtils();
const [suche, setSuche] = React.useState("");
const [kontoId, setKontoId] = React.useState<string | "alle">("alle");
const [art, setArt] = React.useState<BuchungsArt | "alle">("alle");
const [nurOffene, setNurOffene] = React.useState(false);
const [erfassenOffen, setErfassenOffen] = React.useState(false);
const [importOffen, setImportOffen] = React.useState(false);
const [gegenbuchen, setGegenbuchen] = React.useState<{ id: string; zweck: string } | null>(null);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(null);
const konten = trpc.kassenkonto.liste.useQuery();
const buchungen = trpc.buchung.liste.useQuery({
suche: suche.trim() || undefined,
kontoId: kontoId === "alle" ? undefined : kontoId,
art: art === "alle" ? undefined : art,
nurOffene,
anzahl: 200,
});
const festschreiben = trpc.buchung.festschreiben.useMutation({
onSuccess: (ergebnis) => {
setHinweis(
ergebnis.festgeschrieben === 0
? "Es war nichts festzuschreiben."
: `${ergebnis.festgeschrieben} Buchungen festgeschrieben. Sie sind ab jetzt unveränderbar.`,
);
void werkzeuge.buchung.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const loeschen = trpc.buchung.loeschen.useMutation({
onSuccess: () => void werkzeuge.buchung.invalidate(),
onError: (fehler) => setMeldung(fehler.message),
});
const eintraege = buchungen.data?.buchungen ?? [];
const offene = eintraege.filter((eintrag) => !eintrag.festgeschriebenAm).length;
return (
<div className="space-y-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/kasse">
<ArrowLeft aria-hidden />
Zurück zur Kasse
</Link>
</Button>
{meldung && <Fehlermeldung meldung={meldung} kennung="buchungsfehler" />}
{hinweis && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
{hinweis}
</div>
)}
{/* Summen über alle Treffer */}
<div className="grid gap-3 sm:grid-cols-3">
<Summe titel="Einnahmen" wert={buchungen.data?.summe.einnahmen} />
<Summe titel="Ausgaben" wert={buchungen.data?.summe.ausgaben} />
<Summe titel="Saldo" wert={buchungen.data?.summe.saldo} hervorheben />
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-56 flex-1">
<Search
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--muted-foreground)]"
aria-hidden
/>
<Input
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Verwendungszweck, Lieferant, Belegnummer …"
className="pl-9"
aria-label="Buchungen durchsuchen"
/>
</div>
<Select value={kontoId} onValueChange={setKontoId}>
<SelectTrigger className="w-52" aria-label="Konto">
<SelectValue placeholder="Alle Konten" />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Konten</SelectItem>
{konten.data?.map((konto) => (
<SelectItem key={konto.id} value={konto.id}>
{konto.bezeichnung}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={art} onValueChange={(wert) => setArt(wert as BuchungsArt | "alle")}>
<SelectTrigger className="w-40" aria-label="Art">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle</SelectItem>
{Object.values(BuchungsArt).map((wert) => (
<SelectItem key={wert} value={wert}>
{BUCHUNGSART_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant={nurOffene ? "default" : "outline"}
size="sm"
onClick={() => setNurOffene((wert) => !wert)}
>
Nur Entwürfe
</Button>
{darfBearbeiten && (
<div className="ml-auto flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setImportOffen(true)}>
<Upload aria-hidden />
Kontoauszug
</Button>
<Button onClick={() => setErfassenOffen(true)}>
<Plus aria-hidden />
Buchung erfassen
</Button>
</div>
)}
</div>
{darfBearbeiten && offene > 0 && (
<div className="flex flex-wrap items-center gap-3 rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-3 text-sm">
<span className="flex-1">
{offene} {offene === 1 ? "Buchung ist" : "Buchungen sind"} noch nicht festgeschrieben.
Festgeschriebene lassen sich nicht mehr ändern — das ist der Zustand, den eine
Kassenprüfung erwartet.
</span>
<Button
size="sm"
variant="outline"
onClick={() => festschreiben.mutate({ bisAm: new Date() })}
disabled={festschreiben.isPending}
>
<Lock aria-hidden />
Alles bis heute festschreiben
</Button>
</div>
)}
{!buchungen.isLoading && eintraege.length === 0 && (
<NichtsGefunden
titel="Keine Buchungen"
hinweis={
suche || kontoId !== "alle" || art !== "alle" || nurOffene
? "Zu dieser Auswahl steht nichts im Kassenbuch."
: "Die erste Buchung entsteht über den Knopf oben rechts."
}
/>
)}
{eintraege.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
<th className="px-3 py-2">Datum</th>
<th className="px-3 py-2">Verwendungszweck</th>
<th className="px-3 py-2">Kategorie</th>
<th className="px-3 py-2">Konto</th>
<th className="px-3 py-2 text-right">Betrag</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody>
{eintraege.map((buchung) => (
<tr key={buchung.id} className="border-b border-[var(--border)] last:border-0">
<td className="px-3 py-2 whitespace-nowrap">{alsDatum(buchung.datum)}</td>
<td className="px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span>{buchung.verwendungszweck}</span>
{buchung.festgeschriebenAm && (
<Lock
className="size-3.5 text-[var(--muted-foreground)]"
aria-label="festgeschrieben"
/>
)}
{buchung.storniertVonId && (
<Badge variant="secondary">gegengebucht</Badge>
)}
</div>
{(buchung.lieferant || buchung.person) && (
<span className="text-xs text-[var(--muted-foreground)]">
{buchung.person
? `${buchung.person.nachname}, ${buchung.person.vorname}`
: buchung.lieferant}
</span>
)}
</td>
<td className="px-3 py-2">{buchung.kategorie.name}</td>
<td className="px-3 py-2">{buchung.konto.bezeichnung}</td>
<td
className={`px-3 py-2 text-right tabular-nums whitespace-nowrap ${
buchung.art === BuchungsArt.AUSGABE ? "text-[var(--destructive)]" : ""
}`}
>
{buchung.art === BuchungsArt.AUSGABE ? "−" : "+"}
{alsBetrag(buchung.betrag)}
</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
{darfBearbeiten && !buchung.storniertVonId && (
<>
{buchung.festgeschriebenAm ? (
<Button
size="sm"
variant="ghost"
onClick={() =>
setGegenbuchen({
id: buchung.id,
zweck: buchung.verwendungszweck,
})
}
>
<Undo2 aria-hidden />
Gegenbuchen
</Button>
) : (
<Button
size="sm"
variant="ghost"
onClick={() => loeschen.mutate({ id: buchung.id })}
>
Verwerfen
</Button>
)}
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{darfBearbeiten && (
<>
<BuchungErfassen
offen={erfassenOffen}
schliessen={() => setErfassenOffen(false)}
fehler={setMeldung}
fertig={() => {
setErfassenOffen(false);
void werkzeuge.buchung.invalidate();
void werkzeuge.kassenkonto.invalidate();
}}
/>
<Kontoauszug
offen={importOffen}
schliessen={() => setImportOffen(false)}
fehler={setMeldung}
fertig={(anzahl) => {
setImportOffen(false);
setHinweis(`${anzahl} Buchungen als Entwurf übernommen. Bitte durchsehen.`);
void werkzeuge.buchung.invalidate();
}}
/>
<GegenbuchenDialog
eintrag={gegenbuchen}
schliessen={() => setGegenbuchen(null)}
fehler={setMeldung}
fertig={() => {
setGegenbuchen(null);
void werkzeuge.buchung.invalidate();
void werkzeuge.kassenkonto.invalidate();
}}
/>
</>
)}
</div>
);
}
function Summe({
titel,
wert,
hervorheben = false,
}: {
titel: string;
wert: string | undefined;
hervorheben?: boolean;
}) {
const zahl = Number(wert ?? 0);
return (
<Card className={hervorheben ? "border-[var(--primary)]" : undefined}>
<CardHeader className="pb-1">
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
{titel}
</CardTitle>
</CardHeader>
<CardContent>
<p
className={`text-xl font-semibold tabular-nums ${
hervorheben && zahl < 0 ? "text-[var(--destructive)]" : ""
}`}
>
{alsBetrag(wert)}
</p>
</CardContent>
</Card>
);
}
/** Datum von heute im Format, das ein date-Feld erwartet. */
function heute(): string {
return new Date().toISOString().slice(0, 10);
}
/**
* Die Buchungsmaske.
*
* Der Kategorievorschlag ist die eine Bequemlichkeit, die hier wirklich etwas
* bringt: nicht wegen der gesparten Sekunden, sondern weil dieselbe Sache
* dadurch immer unter derselben Kategorie landet — und erst das macht die
* Jahresauswertung brauchbar.
*/
function BuchungErfassen({
offen,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [art, setArt] = React.useState<BuchungsArt>(BuchungsArt.AUSGABE);
const [kontoId, setKontoId] = React.useState("");
const [kategorieId, setKategorieId] = React.useState("");
const [datum, setDatum] = React.useState(heute());
const [betrag, setBetrag] = React.useState("");
const [zweck, setZweck] = React.useState("");
const [lieferant, setLieferant] = React.useState("");
const [belegnummer, setBelegnummer] = React.useState("");
const [vorschlagUebernommen, setVorschlagUebernommen] = React.useState(false);
const konten = trpc.kassenkonto.liste.useQuery();
const kategorien = trpc.buchungskategorie.liste.useQuery({ art });
const vorschlag = trpc.buchung.vorschlag.useQuery(
{ verwendungszweck: zweck, art },
{ enabled: offen && zweck.trim().length >= 3 },
);
const anlegen = trpc.buchung.anlegen.useMutation({
onSuccess: () => {
setBetrag("");
setZweck("");
setLieferant("");
setBelegnummer("");
setVorschlagUebernommen(false);
fertig();
},
onError: (aufruf) => fehler(aufruf.message),
});
// Das erste Konto vorbelegen — die meisten Wehren buchen fast alles auf
// dasselbe.
React.useEffect(() => {
if (!kontoId && konten.data?.[0]) setKontoId(konten.data[0].id);
}, [kontoId, konten.data]);
// Beim Wechsel der Richtung passt die gewählte Kategorie nicht mehr.
React.useEffect(() => {
setKategorieId("");
setVorschlagUebernommen(false);
}, [art]);
const passenderVorschlag =
vorschlag.data && !kategorieId && !vorschlagUebernommen ? vorschlag.data : null;
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Buchung erfassen</DialogTitle>
<DialogDescription>
Der Betrag ist immer positiv — die Richtung steht daneben. Die Buchung ist zunächst
ein Entwurf und lässt sich noch ändern.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex gap-2">
{Object.values(BuchungsArt).map((wert) => (
<Button
key={wert}
type="button"
variant={art === wert ? "default" : "outline"}
className="flex-1"
onClick={() => setArt(wert)}
>
{BUCHUNGSART_TEXT[wert]}
</Button>
))}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="buchung-datum">Buchungsdatum</Label>
<Input
id="buchung-datum"
type="date"
value={datum}
onChange={(ereignis) => setDatum(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="buchung-betrag">Betrag</Label>
<Input
id="buchung-betrag"
type="number"
step="0.01"
min="0.01"
value={betrag}
onChange={(ereignis) => setBetrag(ereignis.target.value)}
placeholder="0,00"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="buchung-zweck">Verwendungszweck</Label>
<Textarea
id="buchung-zweck"
rows={2}
value={zweck}
onChange={(ereignis) => setZweck(ereignis.target.value)}
placeholder="Tankstelle Musterhausen, Diesel LF 20"
/>
</div>
{passenderVorschlag && (
<button
type="button"
onClick={() => {
setKategorieId(passenderVorschlag.kategorieId);
setVorschlagUebernommen(true);
}}
className="flex w-full items-center gap-2 rounded-md border border-[var(--primary)] bg-[var(--primary)]/5 p-3 text-left text-sm hover:bg-[var(--primary)]/10"
>
<Sparkles className="size-4 shrink-0" aria-hidden />
<span>
Vorschlag: <strong>{passenderVorschlag.name}</strong>
<span className="block text-xs text-[var(--muted-foreground)]">
{passenderVorschlag.grund}
</span>
</span>
</button>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="buchung-kategorie">Kategorie</Label>
<Select value={kategorieId} onValueChange={setKategorieId}>
<SelectTrigger id="buchung-kategorie">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{kategorien.data?.map((kategorie) => (
<SelectItem key={kategorie.id} value={kategorie.id}>
{kategorie.oberkategorie
? `${kategorie.oberkategorie.name} › ${kategorie.name}`
: kategorie.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="buchung-konto">Konto</Label>
<Select value={kontoId} onValueChange={setKontoId}>
<SelectTrigger id="buchung-konto">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{konten.data?.map((konto) => (
<SelectItem key={konto.id} value={konto.id}>
{konto.bezeichnung}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="buchung-lieferant">
{art === BuchungsArt.AUSGABE ? "Empfänger" : "Einzahler"}
</Label>
<Input
id="buchung-lieferant"
value={lieferant}
onChange={(ereignis) => setLieferant(ereignis.target.value)}
placeholder="Werkstatt Müller GmbH"
/>
</div>
<div className="space-y-2">
<Label htmlFor="buchung-beleg">Belegnummer</Label>
<Input
id="buchung-beleg"
value={belegnummer}
onChange={(ereignis) => setBelegnummer(ereignis.target.value)}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
anlegen.mutate({
kontoId,
kategorieId,
datum: new Date(datum),
art,
betrag: Number(betrag),
verwendungszweck: zweck,
lieferant: lieferant || null,
belegnummer: belegnummer || null,
})
}
disabled={
!kontoId || !kategorieId || !zweck.trim() || !(Number(betrag) > 0) || anlegen.isPending
}
>
{anlegen.isPending ? "Wird gebucht …" : "Buchen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/**
* Kontoauszug einlesen.
*
* Der eigentliche Wert ist die Spalte „schon da": ein zweimal eingelesener
* Auszug wäre sonst ein doppelt gebuchter Monat, und das fällt erst beim
* Kassenabschluss auf.
*/
function Kontoauszug({
offen,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
schliessen: () => void;
fertig: (anzahl: number) => void;
fehler: (nachricht: string) => void;
}) {
const [kontoId, setKontoId] = React.useState("");
const [gelesen, setGelesen] = React.useState<{
format: string;
konto: string | null;
fehler: Array<{ stelle: string; grund: string }>;
posten: Array<{
datum: Date;
art: BuchungsArt;
betrag: string;
verwendungszweck: string;
gegenseite: string | null;
schonDa: boolean;
}>;
bekannt: number;
} | null>(null);
const [gewaehlt, setGewaehlt] = React.useState<Set<number>>(new Set());
const [kategorieId, setKategorieId] = React.useState("");
const dateiRef = React.useRef<HTMLInputElement>(null);
const konten = trpc.kassenkonto.liste.useQuery();
const kategorien = trpc.buchungskategorie.liste.useQuery();
const lesen = trpc.buchung.auszugLesen.useMutation({
onSuccess: (ergebnis) => {
setGelesen(ergebnis);
// Vorausgewählt ist, was noch nicht da ist — das ist fast immer das,
// was jemand übernehmen will.
setGewaehlt(
new Set(
ergebnis.posten
.map((posten, stelle) => (posten.schonDa ? -1 : stelle))
.filter((stelle) => stelle >= 0),
),
);
},
onError: (aufruf) => fehler(aufruf.message),
});
const uebernehmen = trpc.buchung.auszugUebernehmen.useMutation({
onSuccess: (ergebnis) => {
setGelesen(null);
setGewaehlt(new Set());
fertig(ergebnis.uebernommen);
},
onError: (aufruf) => fehler(aufruf.message),
});
React.useEffect(() => {
if (!kontoId && konten.data?.[0]) setKontoId(konten.data[0].id);
}, [kontoId, konten.data]);
async function dateiWaehlen(datei: File) {
const inhalt = await datei.text();
lesen.mutate({ kontoId, inhalt });
}
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Kontoauszug einlesen</DialogTitle>
<DialogDescription>
CAMT.053 (XML) oder MT940 — beides bietet das Online-Banking unter „Umsatzexport“ an.
Es entstehen <strong>Entwürfe</strong>, keine fertigen Buchungen.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-52 flex-1 space-y-2">
<Label htmlFor="auszug-konto">Auf welches Konto</Label>
<Select value={kontoId} onValueChange={setKontoId}>
<SelectTrigger id="auszug-konto">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{konten.data?.map((konto) => (
<SelectItem key={konto.id} value={konto.id}>
{konto.bezeichnung}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<input
ref={dateiRef}
type="file"
accept=".xml,.camt,.txt,.sta,.mt940,text/xml,text/plain"
className="hidden"
onChange={(ereignis) => {
const datei = ereignis.target.files?.[0];
if (datei) void dateiWaehlen(datei);
ereignis.target.value = "";
}}
/>
<Button
onClick={() => dateiRef.current?.click()}
disabled={!kontoId || lesen.isPending}
>
<Upload aria-hidden />
{lesen.isPending ? "Wird gelesen …" : "Datei wählen"}
</Button>
</div>
{gelesen && (
<>
<p className="text-sm">
Format <strong>{gelesen.format}</strong>
{gelesen.konto && ` · Konto ${gelesen.konto}`} · {gelesen.posten.length} Umsätze
{gelesen.bekannt > 0 && ` · ${gelesen.bekannt} davon stehen schon in der Kasse`}
</p>
{gelesen.fehler.length > 0 && (
<div className="rounded-md border border-[var(--destructive)]/40 bg-[var(--destructive)]/10 p-3 text-sm">
<p className="font-medium">Nicht gelesen werden konnte:</p>
<ul className="mt-1 list-disc pl-5">
{gelesen.fehler.slice(0, 8).map((eintrag, stelle) => (
<li key={stelle}>
{eintrag.stelle}: {eintrag.grund}
</li>
))}
</ul>
</div>
)}
{gelesen.posten.length > 0 && (
<>
<div className="space-y-2">
<Label htmlFor="auszug-kategorie">Kategorie für alle übernommenen</Label>
<Select value={kategorieId} onValueChange={setKategorieId}>
<SelectTrigger id="auszug-kategorie">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{kategorien.data?.map((kategorie) => (
<SelectItem key={kategorie.id} value={kategorie.id}>
{BUCHUNGSART_TEXT[kategorie.art]} · {kategorie.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-[var(--muted-foreground)]">
Sie lässt sich danach je Buchung ändern — die Entwürfe sind dafür da.
</p>
</div>
<div className="max-h-72 overflow-y-auto rounded-md border border-[var(--border)]">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-[var(--card)]">
<tr className="border-b border-[var(--border)] text-left">
<th className="px-2 py-1.5" />
<th className="px-2 py-1.5">Datum</th>
<th className="px-2 py-1.5">Verwendungszweck</th>
<th className="px-2 py-1.5 text-right">Betrag</th>
</tr>
</thead>
<tbody>
{gelesen.posten.map((posten, stelle) => (
<tr
key={stelle}
className={`border-b border-[var(--border)] last:border-0 ${
posten.schonDa ? "opacity-50" : ""
}`}
>
<td className="px-2 py-1.5">
<input
type="checkbox"
checked={gewaehlt.has(stelle)}
onChange={(ereignis) => {
const neu = new Set(gewaehlt);
if (ereignis.target.checked) neu.add(stelle);
else neu.delete(stelle);
setGewaehlt(neu);
}}
aria-label={`Umsatz ${stelle + 1} übernehmen`}
/>
</td>
<td className="px-2 py-1.5 whitespace-nowrap">
{alsDatum(posten.datum)}
</td>
<td className="px-2 py-1.5">
{posten.verwendungszweck.slice(0, 80)}
{posten.gegenseite && (
<span className="block text-xs text-[var(--muted-foreground)]">
{posten.gegenseite}
</span>
)}
{posten.schonDa && (
<Badge variant="secondary" className="mt-1">
steht schon in der Kasse
</Badge>
)}
</td>
<td
className={`px-2 py-1.5 text-right tabular-nums whitespace-nowrap ${
posten.art === BuchungsArt.AUSGABE
? "text-[var(--destructive)]"
: ""
}`}
>
{posten.art === BuchungsArt.AUSGABE ? "−" : "+"}
{alsBetrag(posten.betrag)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
uebernehmen.mutate({
kontoId,
posten: [...gewaehlt].map((stelle) => {
const posten = gelesen!.posten[stelle]!;
return {
datum: posten.datum,
art: posten.art,
betrag: Number(posten.betrag),
verwendungszweck: posten.verwendungszweck.slice(0, 300),
gegenseite: posten.gegenseite,
kategorieId,
};
}),
})
}
disabled={gewaehlt.size === 0 || !kategorieId || uebernehmen.isPending}
>
{uebernehmen.isPending
? "Wird übernommen …"
: `${gewaehlt.size} übernehmen`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Gegenbuchen — mit Grund, denn er steht später im Verwendungszweck. */
function GegenbuchenDialog({
eintrag,
schliessen,
fertig,
fehler,
}: {
eintrag: { id: string; zweck: string } | null;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [grund, setGrund] = React.useState("");
const gegenbuchen = trpc.buchung.gegenbuchen.useMutation({
onSuccess: () => {
setGrund("");
fertig();
},
onError: (aufruf) => fehler(aufruf.message),
});
return (
<Dialog open={eintrag !== null} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Gegenbuchen</DialogTitle>
<DialogDescription>
Es entsteht eine zweite Buchung mit derselben Summe in die andere Richtung, mit
Verweis auf die erste. Beide bleiben stehen — genau das will ein Kassenprüfer sehen.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md bg-[var(--muted)] p-3 text-sm">{eintrag?.zweck}</div>
<div className="space-y-2">
<Label htmlFor="gegenbuchung-grund">Warum?</Label>
<Input
id="gegenbuchung-grund"
value={grund}
onChange={(ereignis) => setGrund(ereignis.target.value)}
placeholder="Betrag falsch erfasst"
/>
<p className="text-xs text-[var(--muted-foreground)]">
Der Grund steht im Verwendungszweck der Gegenbuchung. Das Buchungsdatum ist heute —
ein geprüfter Zeitraum bleibt zu.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() => eintrag && gegenbuchen.mutate({ id: eintrag.id, grund })}
disabled={grund.trim().length < 3 || gegenbuchen.isPending}
>
{gegenbuchen.isPending ? "Wird gebucht …" : "Gegenbuchen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { BuchungsListe } from "@/app/(app)/kasse/buchungen/buchungs-liste";
export const metadata = { title: "Buchungen" };
export default async function BuchungenSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "kasse.lesen")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Buchungen</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Das Kassenbuch. Festgeschriebene Buchungen sind unveränderbar — Korrekturen laufen über
eine Gegenbuchung.
</p>
</div>
<BuchungsListe darfBearbeiten={hatRecht(sitzung, "kasse.bearbeiten")} />
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { KassenUebersicht } from "@/app/(app)/kasse/uebersicht";
export const metadata = { title: "Kasse" };
/**
* Die Kasse der Wehr.
*
* Oben die Konten mit ihrem Bestand — die Zahl, die der Kassier zuerst sehen
* will. Darunter, was zu tun ist: Entwürfe, offene Beiträge, überfällige
* Rechnungen.
*/
export default async function KasseSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "kasse.lesen")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Kasse</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Konten, Buchungen, Beiträge und der Kassenabschluss.
</p>
</div>
<KassenUebersicht
darfBearbeiten={hatRecht(sitzung, "kasse.bearbeiten")}
darfAbschluss={hatRecht(sitzung, "kasse.abschluss")}
/>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Rechnungsverwaltung } from "@/app/(app)/kasse/rechnungen/verwaltung";
export const metadata = { title: "Rechnungen" };
export default async function RechnungenSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "kasse.lesen")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Rechnungen</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Kostenersatz für Fehlalarme und Öleinsätze, Vermietungen, Veranstaltungen.
</p>
</div>
<Rechnungsverwaltung darfBearbeiten={hatRecht(sitzung, "kasse.bearbeiten")} />
</div>
);
}
@@ -0,0 +1,824 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { ArrowLeft, Check, Download, FileText, Mail, Plus, Send, Trash2, X } from "lucide-react";
import { RechnungsAnlass, RechnungStand } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import {
ANLASS_TEXT,
RECHNUNG_STAND_TEXT,
RECHNUNG_STAND_VARIANTE,
alsBetrag,
} from "@/lib/kasse/texte";
import { alsDatum } from "@/lib/utils";
type Position = {
bezeichnung: string;
menge: string;
einheit: string;
einzelpreis: string;
};
const LEERE_POSITION: Position = { bezeichnung: "", menge: "1", einheit: "", einzelpreis: "" };
/**
* Rechnungen.
*
* Eine Rechnung ist bis zum Versenden ein Entwurf und danach nicht mehr
* änderbar. Der Grund ist derselbe wie beim Kassenbuch: was beim Empfänger
* liegt, muss dem entsprechen, was hier steht. Korrigiert wird über Storno
* und eine neue Rechnung — mit eigener Nummer, damit keine Lücke entsteht.
*/
export function Rechnungsverwaltung({ darfBearbeiten }: { darfBearbeiten: boolean }) {
const werkzeuge = trpc.useUtils();
const [stand, setStand] = React.useState<RechnungStand | "alle">("alle");
const [jahr, setJahr] = React.useState<number | "alle">(new Date().getFullYear());
const [suche, setSuche] = React.useState("");
const [anlegenOffen, setAnlegenOffen] = React.useState(false);
const [bezahlen, setBezahlen] = React.useState<{
id: string;
nummer: string;
betrag: string;
} | null>(null);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(null);
const rechnungen = trpc.rechnung.liste.useQuery({
stand: stand === "alle" ? undefined : stand,
jahr: jahr === "alle" ? undefined : jahr,
suche: suche.trim() || undefined,
});
const versenden = trpc.rechnung.versenden.useMutation({
onSuccess: () => {
setHinweis("Versendet. Das PDF entsteht im Hintergrund und steht gleich zum Herunterladen.");
void werkzeuge.rechnung.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const mahnen = trpc.rechnung.mahnen.useMutation({
onSuccess: (ergebnis) => {
setHinweis(
`${ergebnis.gemahnt} Rechnungen gemahnt.` +
(ergebnis.uebersprungen > 0
? ` ${ergebnis.uebersprungen} übersprungen — dort steht die dritte Mahnung, danach ist es eine Frage für die Kommandantschaft.`
: ""),
);
void werkzeuge.rechnung.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const stornieren = trpc.rechnung.stornieren.useMutation({
onSuccess: () => void werkzeuge.rechnung.invalidate(),
onError: (fehler) => setMeldung(fehler.message),
});
const pdfErzeugen = trpc.rechnung.pdfErzeugen.useMutation({
onSuccess: (ergebnis) => setHinweis(ergebnis.hinweis),
onError: (fehler) => setMeldung(fehler.message),
});
const jahre = Array.from({ length: 6 }, (_, stelle) => new Date().getFullYear() - stelle);
const zeilen = rechnungen.data?.rechnungen ?? [];
return (
<div className="space-y-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/kasse">
<ArrowLeft aria-hidden />
Zurück zur Kasse
</Link>
</Button>
{meldung && <Fehlermeldung meldung={meldung} kennung="rechnungsfehler" />}
{hinweis && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
{hinweis}
</div>
)}
<div className="grid gap-3 sm:grid-cols-3">
<Kennzahl titel="Gestellt" wert={rechnungen.data?.kennzahlen.gesamt} />
<Kennzahl
titel="Offen"
wert={rechnungen.data?.kennzahlen.offen}
hinweis={`${rechnungen.data?.kennzahlen.anzahlOffen ?? 0} Rechnungen`}
/>
<Card
className={
(rechnungen.data?.kennzahlen.ueberfaellig ?? 0) > 0
? "border-[var(--gesperrt)]"
: undefined
}
>
<CardHeader className="pb-1">
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
Überfällig
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xl font-semibold tabular-nums">
{rechnungen.data?.kennzahlen.ueberfaellig ?? 0}
</p>
<p className="text-xs text-[var(--muted-foreground)]">Zahlungsziel verstrichen</p>
</CardContent>
</Card>
</div>
<div className="flex flex-wrap items-center gap-3">
<Input
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Empfänger oder Rechnungsnummer"
className="min-w-52 flex-1"
aria-label="Rechnungen durchsuchen"
/>
<Select
value={String(jahr)}
onValueChange={(wert) => setJahr(wert === "alle" ? "alle" : Number(wert))}
>
<SelectTrigger className="w-32" aria-label="Jahr">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Jahre</SelectItem>
{jahre.map((wert) => (
<SelectItem key={wert} value={String(wert)}>
{wert}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={stand} onValueChange={(wert) => setStand(wert as RechnungStand | "alle")}>
<SelectTrigger className="w-44" aria-label="Stand">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Stände</SelectItem>
{Object.values(RechnungStand).map((wert) => (
<SelectItem key={wert} value={wert}>
{RECHNUNG_STAND_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
{darfBearbeiten && (
<Button className="ml-auto" onClick={() => setAnlegenOffen(true)}>
<Plus aria-hidden />
Rechnung schreiben
</Button>
)}
</div>
{!rechnungen.isLoading && zeilen.length === 0 && (
<NichtsGefunden
titel="Keine Rechnungen"
hinweis="Kostenersatz nach Art. 28 BayFwG wird hier geschrieben — Fehlalarme, Öleinsätze, Vermietungen."
/>
)}
{zeilen.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
<th className="px-3 py-2">Nummer</th>
<th className="px-3 py-2">Empfänger</th>
<th className="px-3 py-2">Anlass</th>
<th className="px-3 py-2">Fällig</th>
<th className="px-3 py-2 text-right">Betrag</th>
<th className="px-3 py-2">Stand</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody>
{zeilen.map((rechnung) => {
const offen =
rechnung.stand === RechnungStand.VERSENDET ||
rechnung.stand === RechnungStand.GEMAHNT;
const ueberfaellig = offen && new Date(rechnung.faelligAm) < new Date();
return (
<tr key={rechnung.id} className="border-b border-[var(--border)] last:border-0">
<td className="px-3 py-2 whitespace-nowrap font-medium">{rechnung.nummer}</td>
<td className="px-3 py-2">
{rechnung.empfaenger}
{rechnung.ort && (
<span className="block text-xs text-[var(--muted-foreground)]">
{rechnung.plz} {rechnung.ort}
</span>
)}
</td>
<td className="px-3 py-2">{ANLASS_TEXT[rechnung.anlass]}</td>
<td
className={`px-3 py-2 whitespace-nowrap ${
ueberfaellig ? "text-[var(--destructive)]" : ""
}`}
>
{alsDatum(rechnung.faelligAm)}
</td>
<td className="px-3 py-2 text-right tabular-nums whitespace-nowrap">
{alsBetrag(rechnung.betrag)}
</td>
<td className="px-3 py-2">
<Badge variant={RECHNUNG_STAND_VARIANTE[rechnung.stand]}>
{RECHNUNG_STAND_TEXT[rechnung.stand]}
</Badge>
{rechnung.mahnstufe > 0 && (
<span className="ml-1 text-xs text-[var(--muted-foreground)]">
{rechnung.mahnstufe}. Mahnung
</span>
)}
</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
{rechnung.pdfDateiId && (
<Button size="sm" variant="ghost" asChild>
<a href={`/api/dateien/${rechnung.pdfDateiId}`} target="_blank">
<Download aria-hidden />
<span className="sr-only">PDF herunterladen</span>
</a>
</Button>
)}
{darfBearbeiten && rechnung.stand === RechnungStand.ENTWURF && (
<Button
size="sm"
variant="ghost"
onClick={() => versenden.mutate({ id: rechnung.id })}
disabled={versenden.isPending}
>
<Send aria-hidden />
Versenden
</Button>
)}
{darfBearbeiten && offen && (
<>
<Button
size="sm"
variant="ghost"
onClick={() =>
setBezahlen({
id: rechnung.id,
nummer: rechnung.nummer,
betrag: String(rechnung.betrag),
})
}
>
<Check aria-hidden />
Bezahlt
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => mahnen.mutate({ ids: [rechnung.id] })}
disabled={mahnen.isPending}
>
<Mail aria-hidden />
Mahnen
</Button>
</>
)}
{darfBearbeiten && rechnung.stand !== RechnungStand.STORNIERT && (
<>
{rechnung.stand !== RechnungStand.ENTWURF && !rechnung.pdfDateiId && (
<Button
size="sm"
variant="ghost"
onClick={() => pdfErzeugen.mutate({ id: rechnung.id })}
>
<FileText aria-hidden />
<span className="sr-only">PDF erzeugen</span>
</Button>
)}
{rechnung.stand !== RechnungStand.BEZAHLT && (
<Button
size="sm"
variant="ghost"
onClick={() => {
const grund = window.prompt(
"Warum wird storniert? Der Grund steht später in der Bemerkung — die Rechnungsnummer bleibt vergeben.",
);
if (grund?.trim()) {
stornieren.mutate({ id: rechnung.id, grund: grund.trim() });
}
}}
>
<X aria-hidden />
<span className="sr-only">Stornieren</span>
</Button>
)}
</>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{darfBearbeiten && (
<>
<RechnungAnlegen
offen={anlegenOffen}
schliessen={() => setAnlegenOffen(false)}
fehler={setMeldung}
fertig={(nummer) => {
setAnlegenOffen(false);
setHinweis(`Rechnung ${nummer} als Entwurf angelegt. Sie ist noch änderbar.`);
void werkzeuge.rechnung.invalidate();
}}
/>
<BezahltDialog
eintrag={bezahlen}
schliessen={() => setBezahlen(null)}
fehler={setMeldung}
fertig={() => {
setBezahlen(null);
void werkzeuge.rechnung.invalidate();
void werkzeuge.kassenkonto.invalidate();
}}
/>
</>
)}
</div>
);
}
function Kennzahl({
titel,
wert,
hinweis,
}: {
titel: string;
wert: string | undefined;
hinweis?: string;
}) {
return (
<Card>
<CardHeader className="pb-1">
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
{titel}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xl font-semibold tabular-nums">{alsBetrag(wert)}</p>
{hinweis && <p className="text-xs text-[var(--muted-foreground)]">{hinweis}</p>}
</CardContent>
</Card>
);
}
/**
* Die Rechnungsmaske.
*
* Der Gesamtbetrag wird nicht eingegeben, sondern aus den Positionen
* gerechnet — hier zur Anzeige, auf dem Server noch einmal verbindlich. Ein
* eingetippter Betrag, der nicht zur Summe der Positionen passt, ist der
* Fehler, den niemand bemerkt, bis der Empfänger anruft.
*/
function RechnungAnlegen({
offen,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
schliessen: () => void;
fertig: (nummer: string) => void;
fehler: (nachricht: string) => void;
}) {
const heute = new Date();
const inVierWochen = new Date(heute.getTime() + 28 * 24 * 60 * 60 * 1000);
const [anlass, setAnlass] = React.useState<RechnungsAnlass>(RechnungsAnlass.FEHLALARM);
const [empfaenger, setEmpfaenger] = React.useState("");
const [zusatz, setZusatz] = React.useState("");
const [strasse, setStrasse] = React.useState("");
const [plz, setPlz] = React.useState("");
const [ort, setOrt] = React.useState("");
const [datum, setDatum] = React.useState(heute.toISOString().slice(0, 10));
const [faellig, setFaellig] = React.useState(inVierWochen.toISOString().slice(0, 10));
const [bemerkung, setBemerkung] = React.useState("");
const [positionen, setPositionen] = React.useState<Position[]>([{ ...LEERE_POSITION }]);
const anlegen = trpc.rechnung.anlegen.useMutation({
onSuccess: (rechnung) => {
setEmpfaenger("");
setZusatz("");
setStrasse("");
setPlz("");
setOrt("");
setBemerkung("");
setPositionen([{ ...LEERE_POSITION }]);
fertig(rechnung.nummer);
},
onError: (aufruf) => fehler(aufruf.message),
});
function aendern(stelle: number, feld: keyof Position, wert: string) {
setPositionen((vorher) =>
vorher.map((position, index) => (index === stelle ? { ...position, [feld]: wert } : position)),
);
}
const summe = positionen.reduce(
(gesamt, position) => gesamt + Number(position.menge || 0) * Number(position.einzelpreis || 0),
0,
);
const vollstaendig =
empfaenger.trim().length > 0 &&
positionen.every(
(position) => position.bezeichnung.trim().length > 0 && Number(position.einzelpreis) >= 0,
);
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Rechnung schreiben</DialogTitle>
<DialogDescription>
Die Rechnungsnummer vergibt FLORIAN fortlaufend je Jahr — lückenlos, weil eine Lücke
in der Kassenprüfung erklärt werden muss.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="rechnung-anlass">Anlass</Label>
<Select value={anlass} onValueChange={(wert) => setAnlass(wert as RechnungsAnlass)}>
<SelectTrigger id="rechnung-anlass">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(RechnungsAnlass).map((wert) => (
<SelectItem key={wert} value={wert}>
{ANLASS_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-empfaenger">Empfänger</Label>
<Input
id="rechnung-empfaenger"
value={empfaenger}
onChange={(ereignis) => setEmpfaenger(ereignis.target.value)}
placeholder="Firma Muster GmbH"
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="rechnung-zusatz">Zusatz</Label>
<Input
id="rechnung-zusatz"
value={zusatz}
onChange={(ereignis) => setZusatz(ereignis.target.value)}
placeholder="z. Hd. Herrn Meier"
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="rechnung-strasse">Straße</Label>
<Input
id="rechnung-strasse"
value={strasse}
onChange={(ereignis) => setStrasse(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-plz">PLZ</Label>
<Input
id="rechnung-plz"
value={plz}
onChange={(ereignis) => setPlz(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-ort">Ort</Label>
<Input
id="rechnung-ort"
value={ort}
onChange={(ereignis) => setOrt(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-datum">Rechnungsdatum</Label>
<Input
id="rechnung-datum"
type="date"
value={datum}
onChange={(ereignis) => setDatum(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-faellig">Zahlbar bis</Label>
<Input
id="rechnung-faellig"
type="date"
value={faellig}
onChange={(ereignis) => setFaellig(ereignis.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Positionen</Label>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setPositionen((vorher) => [...vorher, { ...LEERE_POSITION }])}
>
<Plus aria-hidden />
Position
</Button>
</div>
<div className="space-y-2">
{positionen.map((position, stelle) => (
<div key={stelle} className="flex flex-wrap items-end gap-2">
<div className="min-w-40 flex-1 space-y-1">
<Label className="text-xs" htmlFor={`position-${stelle}-bezeichnung`}>
Bezeichnung
</Label>
<Input
id={`position-${stelle}-bezeichnung`}
value={position.bezeichnung}
onChange={(ereignis) => aendern(stelle, "bezeichnung", ereignis.target.value)}
placeholder="Einsatz LF 20, angefangene Stunde"
/>
</div>
<div className="w-20 space-y-1">
<Label className="text-xs" htmlFor={`position-${stelle}-menge`}>
Menge
</Label>
<Input
id={`position-${stelle}-menge`}
type="number"
step="0.01"
min="0.01"
value={position.menge}
onChange={(ereignis) => aendern(stelle, "menge", ereignis.target.value)}
/>
</div>
<div className="w-20 space-y-1">
<Label className="text-xs" htmlFor={`position-${stelle}-einheit`}>
Einheit
</Label>
<Input
id={`position-${stelle}-einheit`}
value={position.einheit}
onChange={(ereignis) => aendern(stelle, "einheit", ereignis.target.value)}
placeholder="Std."
/>
</div>
<div className="w-28 space-y-1">
<Label className="text-xs" htmlFor={`position-${stelle}-preis`}>
Einzelpreis
</Label>
<Input
id={`position-${stelle}-preis`}
type="number"
step="0.01"
min="0"
value={position.einzelpreis}
onChange={(ereignis) => aendern(stelle, "einzelpreis", ereignis.target.value)}
/>
</div>
<Button
type="button"
size="icon"
variant="ghost"
disabled={positionen.length === 1}
onClick={() =>
setPositionen((vorher) => vorher.filter((_, index) => index !== stelle))
}
>
<Trash2 aria-hidden />
<span className="sr-only">Position entfernen</span>
</Button>
</div>
))}
</div>
<p className="text-right text-sm">
Summe <strong className="tabular-nums">{alsBetrag(summe.toFixed(2))}</strong>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-bemerkung">Bemerkung</Label>
<Textarea
id="rechnung-bemerkung"
rows={2}
value={bemerkung}
onChange={(ereignis) => setBemerkung(ereignis.target.value)}
placeholder="Einsatz vom 14.03. — Fehlalarm durch Brandmeldeanlage"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
anlegen.mutate({
anlass,
empfaenger,
empfaengerZusatz: zusatz || null,
strasse: strasse || null,
plz: plz || null,
ort: ort || null,
datum: new Date(datum),
faelligAm: new Date(faellig),
bemerkung: bemerkung || null,
positionen: positionen.map((position) => ({
bezeichnung: position.bezeichnung,
menge: Number(position.menge || 1),
einheit: position.einheit || null,
einzelpreis: Number(position.einzelpreis || 0),
})),
})
}
disabled={!vollstaendig || anlegen.isPending}
>
{anlegen.isPending ? "Wird angelegt …" : "Als Entwurf anlegen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Zahlungseingang zu einer Rechnung — erzeugt die Buchung. */
function BezahltDialog({
eintrag,
schliessen,
fertig,
fehler,
}: {
eintrag: { id: string; nummer: string; betrag: string } | null;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [kontoId, setKontoId] = React.useState("");
const [kategorieId, setKategorieId] = React.useState("");
const [datum, setDatum] = React.useState(new Date().toISOString().slice(0, 10));
const [teilbetrag, setTeilbetrag] = React.useState("");
const konten = trpc.kassenkonto.liste.useQuery();
const kategorien = trpc.buchungskategorie.liste.useQuery({ art: "EINNAHME" });
const bezahlen = trpc.rechnung.bezahlen.useMutation({
onSuccess: () => {
setTeilbetrag("");
fertig();
},
onError: (aufruf) => fehler(aufruf.message),
});
React.useEffect(() => {
if (!kontoId && konten.data?.[0]) setKontoId(konten.data[0].id);
}, [kontoId, konten.data]);
return (
<Dialog open={eintrag !== null} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rechnung {eintrag?.nummer} bezahlt</DialogTitle>
<DialogDescription>
Über {alsBetrag(eintrag?.betrag)}. Es entsteht zugleich die Buchung im Kassenbuch.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="rechnung-bezahlt-datum">Eingegangen am</Label>
<Input
id="rechnung-bezahlt-datum"
type="date"
value={datum}
onChange={(ereignis) => setDatum(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-bezahlt-betrag">Abweichender Betrag</Label>
<Input
id="rechnung-bezahlt-betrag"
type="number"
step="0.01"
min="0.01"
value={teilbetrag}
onChange={(ereignis) => setTeilbetrag(ereignis.target.value)}
placeholder={eintrag?.betrag}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-bezahlt-konto">Auf welches Konto</Label>
<Select value={kontoId} onValueChange={setKontoId}>
<SelectTrigger id="rechnung-bezahlt-konto">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{konten.data?.map((konto) => (
<SelectItem key={konto.id} value={konto.id}>
{konto.bezeichnung}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-bezahlt-kategorie">Kategorie</Label>
<Select value={kategorieId} onValueChange={setKategorieId}>
<SelectTrigger id="rechnung-bezahlt-kategorie">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{kategorien.data?.map((kategorie) => (
<SelectItem key={kategorie.id} value={kategorie.id}>
{kategorie.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
eintrag &&
bezahlen.mutate({
id: eintrag.id,
kontoId,
kategorieId,
bezahltAm: new Date(datum),
betrag: teilbetrag ? Number(teilbetrag) : undefined,
})
}
disabled={!kontoId || !kategorieId || bezahlen.isPending}
>
{bezahlen.isPending ? "Wird gebucht …" : "Bezahlt und buchen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+457
View File
@@ -0,0 +1,457 @@
"use client";
import * as React from "react";
import Link from "next/link";
import {
BanknoteArrowUp,
ClipboardCheck,
FileText,
Landmark,
Plus,
Users,
Wallet,
} from "lucide-react";
import { KontoArt } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { KONTOART_TEXT, alsBetrag, ibanKurz } from "@/lib/kasse/texte";
import { alsDatum } from "@/lib/utils";
/**
* Die Kassenübersicht.
*
* Zuerst die Konten mit ihrem Bestand — die Zahl, wegen der jemand hierher
* kommt. Der Bestand ist **gerechnet**, nicht gespeichert: Anfangsbestand plus
* die Wirkung aller Buchungen. Ein gespeicherter Bestand neben einer
* nachgetragenen Buchung wäre falsch, ohne falsch auszusehen.
*
* Darunter die Arbeit, die ansteht. Nicht als Zahlenwand, sondern als vier
* Wege in die vier Bereiche — wer die Kasse führt, hat drei bis fünf Dinge zu
* tun, und die sollen mit einem Klick erreichbar sein.
*/
export function KassenUebersicht({
darfBearbeiten,
darfAbschluss,
}: {
darfBearbeiten: boolean;
darfAbschluss: boolean;
}) {
const werkzeuge = trpc.useUtils();
const jahr = new Date().getFullYear();
const [kontoOffen, setKontoOffen] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(null);
const konten = trpc.kassenkonto.liste.useQuery({ mitInaktiven: false });
const kategorien = trpc.buchungskategorie.liste.useQuery();
const offeneBuchungen = trpc.buchung.liste.useQuery({ nurOffene: true, anzahl: 1 });
const beitraege = trpc.beitrag.liste.useQuery({ jahr });
const rechnungen = trpc.rechnung.liste.useQuery({ jahr });
const letzte = trpc.buchung.liste.useQuery({ anzahl: 8 });
const standard = trpc.buchungskategorie.standardAnlegen.useMutation({
onSuccess: () => void werkzeuge.buchungskategorie.invalidate(),
onError: (fehler) => setMeldung(fehler.message),
});
const gesamt = (konten.data ?? []).reduce(
(bisher, konto) => bisher + Number(konto.bestand),
0,
);
const keineKategorien = (kategorien.data?.length ?? 0) === 0;
return (
<div className="space-y-6">
{meldung && <Fehlermeldung meldung={meldung} kennung="kassenfehler" />}
{/* Der Einstieg für eine Wehr, die gerade anfängt. */}
{keineKategorien && darfBearbeiten && !kategorien.isLoading && (
<Card className="border-[var(--pruefung-faellig)]">
<CardHeader>
<CardTitle className="text-base">Die Kasse ist noch leer</CardTitle>
<CardDescription>
Es fehlen die Kategorien, unter denen gebucht wird. FLORIAN bringt einen
Kontenrahmen mit, wie ihn eine Feuerwehr braucht — er lässt sich danach
erweitern und umbenennen.
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => standard.mutate()} disabled={standard.isPending}>
{standard.isPending ? "Wird angelegt …" : "Kontenrahmen anlegen"}
</Button>
</CardContent>
</Card>
)}
{/* Konten */}
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold">Konten</h2>
{darfBearbeiten && (
<Button variant="outline" size="sm" onClick={() => setKontoOffen(true)}>
<Plus aria-hidden />
Konto anlegen
</Button>
)}
</div>
{konten.isLoading && (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 3 }).map((_, zeile) => (
<div
key={zeile}
className="h-28 animate-pulse rounded-lg border border-[var(--border)] bg-[var(--muted)]"
aria-hidden
/>
))}
</div>
)}
{!konten.isLoading && (konten.data?.length ?? 0) === 0 && (
<NichtsGefunden
titel="Noch keine Konten"
hinweis="Eine Wehr führt meistens drei: die Barkasse im Gerätehaus, ein Bankkonto und eine Rücklage."
/>
)}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{konten.data?.map((konto) => (
<Card key={konto.id}>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
{konto.art === KontoArt.BAR ? (
<Wallet className="size-4 text-[var(--muted-foreground)]" aria-hidden />
) : (
<Landmark className="size-4 text-[var(--muted-foreground)]" aria-hidden />
)}
<CardTitle className="text-sm font-medium">{konto.bezeichnung}</CardTitle>
</div>
<CardDescription>
{KONTOART_TEXT[konto.art]}
{konto.iban && ` · ${ibanKurz(konto.iban)}`}
</CardDescription>
</CardHeader>
<CardContent>
<p
className={`text-2xl font-semibold tabular-nums ${
Number(konto.bestand) < 0 ? "text-[var(--destructive)]" : ""
}`}
>
{alsBetrag(konto.bestand)}
</p>
</CardContent>
</Card>
))}
{(konten.data?.length ?? 0) > 1 && (
<Card className="border-[var(--primary)]">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Gesamt</CardTitle>
<CardDescription>über alle Konten</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold tabular-nums">
{alsBetrag(gesamt.toFixed(2))}
</p>
</CardContent>
</Card>
)}
</div>
</section>
{/* Was ansteht */}
<section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Aufgabe
titel="Buchungen"
symbol={<BanknoteArrowUp className="size-4" aria-hidden />}
wert={offeneBuchungen.data?.buchungen.length ? "Entwürfe offen" : "alles erfasst"}
hinweis={`Saldo ${alsBetrag(letzte.data?.summe.saldo)}`}
pfad="/kasse/buchungen"
hervorheben={Boolean(offeneBuchungen.data?.buchungen.length)}
/>
<Aufgabe
titel={`Beiträge ${jahr}`}
symbol={<Users className="size-4" aria-hidden />}
wert={
beitraege.data
? `${beitraege.data.kennzahlen.anzahlOffen} offen`
: "—"
}
hinweis={beitraege.data ? `${alsBetrag(beitraege.data.kennzahlen.offen)} ausstehend` : ""}
pfad="/kasse/beitraege"
hervorheben={(beitraege.data?.kennzahlen.anzahlOffen ?? 0) > 0}
/>
<Aufgabe
titel="Rechnungen"
symbol={<FileText className="size-4" aria-hidden />}
wert={rechnungen.data ? `${rechnungen.data.kennzahlen.anzahlOffen} offen` : "—"}
hinweis={
rechnungen.data?.kennzahlen.ueberfaellig
? `${rechnungen.data.kennzahlen.ueberfaellig} überfällig`
: rechnungen.data
? `${alsBetrag(rechnungen.data.kennzahlen.offen)} ausstehend`
: ""
}
pfad="/kasse/rechnungen"
hervorheben={(rechnungen.data?.kennzahlen.ueberfaellig ?? 0) > 0}
/>
<Aufgabe
titel="Abschluss"
symbol={<ClipboardCheck className="size-4" aria-hidden />}
wert={darfAbschluss ? "Kassenprüfung" : "nur ansehen"}
hinweis="Bestände zählen und festschreiben"
pfad="/kasse/abschluss"
/>
</section>
{/* Die letzten Buchungen */}
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Zuletzt gebucht</h2>
<Button variant="ghost" size="sm" asChild>
<Link href="/kasse/buchungen">Alle Buchungen</Link>
</Button>
</div>
{(letzte.data?.buchungen.length ?? 0) === 0 && !letzte.isLoading ? (
<NichtsGefunden
titel="Noch nichts gebucht"
hinweis="Die erste Buchung entsteht unter „Buchungen“."
/>
) : (
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
<th className="px-3 py-2">Datum</th>
<th className="px-3 py-2">Verwendungszweck</th>
<th className="px-3 py-2">Kategorie</th>
<th className="px-3 py-2">Konto</th>
<th className="px-3 py-2 text-right">Betrag</th>
</tr>
</thead>
<tbody>
{letzte.data?.buchungen.map((buchung) => (
<tr key={buchung.id} className="border-b border-[var(--border)] last:border-0">
<td className="px-3 py-2 whitespace-nowrap">{alsDatum(buchung.datum)}</td>
<td className="px-3 py-2">
{buchung.verwendungszweck}
{buchung.storniertVonId && (
<Badge variant="secondary" className="ml-2">
gegengebucht
</Badge>
)}
</td>
<td className="px-3 py-2">{buchung.kategorie.name}</td>
<td className="px-3 py-2">{buchung.konto.bezeichnung}</td>
<td
className={`px-3 py-2 text-right tabular-nums whitespace-nowrap ${
buchung.art === "AUSGABE" ? "text-[var(--destructive)]" : ""
}`}
>
{buchung.art === "AUSGABE" ? "−" : "+"}
{alsBetrag(buchung.betrag)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{darfBearbeiten && (
<KontoAnlegen
offen={kontoOffen}
schliessen={() => setKontoOffen(false)}
fehler={setMeldung}
fertig={() => {
setKontoOffen(false);
void werkzeuge.kassenkonto.invalidate();
}}
/>
)}
</div>
);
}
function Aufgabe({
titel,
symbol,
wert,
hinweis,
pfad,
hervorheben = false,
}: {
titel: string;
symbol: React.ReactNode;
wert: string;
hinweis: string;
pfad: string;
hervorheben?: boolean;
}) {
return (
<Link href={pfad} className="block">
<Card
className={`h-full transition-colors hover:bg-[var(--accent)] ${
hervorheben ? "border-[var(--pruefung-faellig)]" : ""
}`}
>
<CardHeader className="pb-2">
<div className="flex items-center gap-2 text-[var(--muted-foreground)]">
{symbol}
<CardTitle className="text-sm font-medium">{titel}</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="font-medium">{wert}</p>
<p className="text-sm text-[var(--muted-foreground)]">{hinweis}</p>
</CardContent>
</Card>
</Link>
);
}
/** Ein Konto anlegen. */
function KontoAnlegen({
offen,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [bezeichnung, setBezeichnung] = React.useState("");
const [art, setArt] = React.useState<KontoArt>(KontoArt.BAR);
const [iban, setIban] = React.useState("");
const [anfangsbestand, setAnfangsbestand] = React.useState("0");
const anlegen = trpc.kassenkonto.anlegen.useMutation({
onSuccess: () => {
setBezeichnung("");
setIban("");
setAnfangsbestand("0");
fertig();
},
onError: (aufruf) => fehler(aufruf.message),
});
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Konto anlegen</DialogTitle>
<DialogDescription>
Der Anfangsbestand ist der Ausgangspunkt aller Bestände. Er lässt sich später nicht
mehr ändern, sobald darauf gebucht wurde — eine Korrektur läuft dann über eine
Buchung.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="konto-bezeichnung">Bezeichnung</Label>
<Input
id="konto-bezeichnung"
value={bezeichnung}
onChange={(ereignis) => setBezeichnung(ereignis.target.value)}
placeholder="Barkasse Gerätehaus"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="konto-art">Art</Label>
<Select value={art} onValueChange={(wert) => setArt(wert as KontoArt)}>
<SelectTrigger id="konto-art">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(KontoArt).map((wert) => (
<SelectItem key={wert} value={wert}>
{KONTOART_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="konto-bestand">Anfangsbestand</Label>
<Input
id="konto-bestand"
type="number"
step="0.01"
value={anfangsbestand}
onChange={(ereignis) => setAnfangsbestand(ereignis.target.value)}
/>
</div>
</div>
{art !== KontoArt.BAR && (
<div className="space-y-2">
<Label htmlFor="konto-iban">IBAN</Label>
<Input
id="konto-iban"
value={iban}
onChange={(ereignis) => setIban(ereignis.target.value.toUpperCase())}
placeholder="DE00 0000 0000 0000 0000 00"
className="font-mono"
/>
<p className="text-xs text-[var(--muted-foreground)]">
Steht auf der Rechnung im Zahlungshinweis. In Listen wird sie gekürzt angezeigt.
</p>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
anlegen.mutate({
bezeichnung,
art,
iban: iban.replace(/\s/g, "") || null,
anfangsbestand: Number(anfangsbestand) || 0,
aktiv: true,
})
}
disabled={!bezeichnung.trim() || anlegen.isPending}
>
{anlegen.isPending ? "Wird angelegt …" : "Anlegen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+1 -1
View File
@@ -178,7 +178,7 @@ const EINTRAEGE: Eintrag[] = [
/** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist
* sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */
const AKTUELLE_PHASE = 8;
const AKTUELLE_PHASE = 9;
export interface SeitenleisteProps {
rechte: Recht[];
+405
View File
@@ -0,0 +1,405 @@
import { Prisma } from "@prisma/client";
import { BuchungsArt } from "@prisma/client";
import { runden } from "@/lib/kasse/betrag";
/**
* Kontoauszüge einlesen: CAMT.053 und MT940.
*
* Zwei Formate, weil jede Bank ein anderes für „das übliche" hält. CAMT.053
* ist XML nach ISO 20022 und der neuere Standard; MT940 ist eine
* Zeilenkodierung aus den Achtzigern, die Sparkassen und Volksbanken bis heute
* ausliefern. Eine Wehr bekommt, was ihre Bank hergibt, und beides muss gehen.
*
* **Was hier NICHT passiert: buchen.** Herauskommen Vorschläge, die jemand
* ansieht. Der Grund ist derselbe wie bei der Belegerfassung in Phase 8: ein
* Betrag, den eine Maschine aus einer Datei gelesen hat, ist keine Buchung,
* sondern ein ausgefülltes Formular. Und ein doppelt eingelesener Auszug wäre
* sonst ein doppelt gebuchter Monat.
*
* Selbst geschrieben statt über eine Bibliothek: für MT940 gibt es kaum
* gepflegte, und CAMT.053 braucht von seinen zweihundert Feldern genau sechs.
* Der Parser ist damit kürzer als die Einbindung — und in einer Anwendung ohne
* Netzzugang ist jede Abhängigkeit weniger eine Sorge weniger.
*
* Reines Auswerten ohne Datenbank — damit es sich prüfen lässt.
*/
export interface Auszugsposten {
/** Buchungstag. Bei MT940 das Valutadatum, wenn kein anderes dabeisteht. */
datum: Date;
art: BuchungsArt;
betrag: Prisma.Decimal;
/** Der Verwendungszweck, zusammengesetzt und von Umbrüchen befreit. */
verwendungszweck: string;
/** Wer überwiesen hat oder wem überwiesen wurde. */
gegenseite: string | null;
gegenIban: string | null;
/**
* Die Kennung der Bank für diesen Umsatz.
*
* Das Feld, an dem eine Doppelbuchung erkannt wird: derselbe Auszug zweimal
* eingelesen liefert dieselben Kennungen. Nicht jede Bank liefert eine —
* dann wird aus Datum, Betrag und Zweck eine gebildet.
*/
kennung: string;
}
export interface Auszug {
posten: Auszugsposten[];
/** Was nicht gelesen werden konnte, mit Grund. */
fehler: Array<{ stelle: string; grund: string }>;
/** IBAN des Kontos, wenn die Datei sie nennt. */
konto: string | null;
format: "CAMT.053" | "MT940";
}
// -----------------------------------------------------------------------------
// Gemeinsames
// -----------------------------------------------------------------------------
/** Aus Datum, Betrag und Zweck eine Kennung bilden, wo die Bank keine liefert. */
function ersatzkennung(datum: Date, betrag: string, zweck: string): string {
const tag = datum.toISOString().slice(0, 10);
// Der Zweck normalisiert, damit unterschiedliche Umbrüche derselben Zeile
// nicht zu zwei Kennungen führen.
const kern = zweck.replace(/\s+/g, " ").trim().slice(0, 60);
return `${tag}|${betrag}|${kern}`;
}
function alsDatum(jahr: number, monat: number, tag: number): Date | null {
if (monat < 1 || monat > 12 || tag < 1 || tag > 31) return null;
const wert = new Date(Date.UTC(jahr, monat - 1, tag));
if (wert.getUTCMonth() !== monat - 1 || wert.getUTCDate() !== tag) return null;
return wert;
}
// -----------------------------------------------------------------------------
// CAMT.053
// -----------------------------------------------------------------------------
/**
* Ein Element aus einem XML-Ausschnitt holen.
*
* Bewusst mit regulären Ausdrücken und ohne XML-Parser: gebraucht werden sechs
* Felder aus einer flachen Struktur, die Datei kommt von der eigenen Bank, und
* ein Parser wäre eine Abhängigkeit für dreißig Zeilen. Namensräume werden
* dabei ignoriert (`<Ntry>` und `<ns:Ntry>` gleichermaßen) — genau das, was
* ein Parser hier auch täte.
*/
function feld(xml: string, name: string): string | null {
const treffer = new RegExp(`<(?:\\w+:)?${name}\\b[^>]*>([\\s\\S]*?)</(?:\\w+:)?${name}>`).exec(
xml,
);
return treffer?.[1]?.trim() ?? null;
}
function alleBloecke(xml: string, name: string): string[] {
const muster = new RegExp(`<(?:\\w+:)?${name}\\b[^>]*>([\\s\\S]*?)</(?:\\w+:)?${name}>`, "g");
return [...xml.matchAll(muster)].map((treffer) => treffer[1] ?? "");
}
export function camtLesen(inhalt: string): Auszug {
const posten: Auszugsposten[] = [];
const fehler: Array<{ stelle: string; grund: string }> = [];
const konto = feld(inhalt, "IBAN");
const eintraege = alleBloecke(inhalt, "Ntry");
if (eintraege.length === 0) {
fehler.push({
stelle: "Datei",
grund:
"Es wurde kein einziger Umsatz (<Ntry>) gefunden. Ist das wirklich eine " +
"CAMT.053-Datei?",
});
}
for (const [nummer, eintrag] of eintraege.entries()) {
const stelle = `Umsatz ${nummer + 1}`;
const rohBetrag = feld(eintrag, "Amt");
const richtung = feld(eintrag, "CdtDbtInd");
if (!rohBetrag || !richtung) {
fehler.push({ stelle, grund: "Betrag oder Richtung (CdtDbtInd) fehlt." });
continue;
}
// CRDT = Gutschrift, DBIT = Lastschrift. Andere Werte gibt es nicht.
const art =
richtung.toUpperCase() === "CRDT"
? BuchungsArt.EINNAHME
: richtung.toUpperCase() === "DBIT"
? BuchungsArt.AUSGABE
: null;
if (!art) {
fehler.push({ stelle, grund: `Unbekannte Richtung „${richtung}".` });
continue;
}
// Buchungstag bevorzugt, sonst Valuta.
const rohDatum =
feld(feld(eintrag, "BookgDt") ?? "", "Dt") ?? feld(feld(eintrag, "ValDt") ?? "", "Dt");
const teile = /^(\d{4})-(\d{2})-(\d{2})/.exec(rohDatum ?? "");
const datum = teile
? alsDatum(Number(teile[1]), Number(teile[2]), Number(teile[3]))
: null;
if (!datum) {
fehler.push({ stelle, grund: "Kein lesbares Buchungsdatum." });
continue;
}
let wert: Prisma.Decimal;
try {
wert = runden(new Prisma.Decimal(rohBetrag));
} catch {
fehler.push({ stelle, grund: `Betrag „${rohBetrag}" ist keine Zahl.` });
continue;
}
if (wert.lessThanOrEqualTo(0)) {
// Der Betrag ist in CAMT immer positiv; die Richtung steht daneben. Ein
// negativer Wert wäre ein Widerspruch in der Datei selbst.
fehler.push({ stelle, grund: "Betrag ist nicht positiv." });
continue;
}
// Der Verwendungszweck kann auf mehrere <Ustrd> verteilt sein.
const zweckteile = alleBloecke(eintrag, "Ustrd");
const verwendungszweck =
zweckteile.join(" ").replace(/\s+/g, " ").trim() ||
feld(eintrag, "AddtlNtryInf")?.replace(/\s+/g, " ").trim() ||
"(kein Verwendungszweck)";
// Die Gegenseite steht je nach Richtung unter Debtor oder Creditor.
const partei = art === BuchungsArt.EINNAHME ? "Dbtr" : "Cdtr";
const gegenseite = feld(feld(eintrag, partei) ?? "", "Nm");
const gegenIban = feld(feld(eintrag, `${partei}Acct`) ?? "", "IBAN");
const kennung =
feld(eintrag, "AcctSvcrRef") ??
feld(eintrag, "EndToEndId") ??
ersatzkennung(datum, wert.toFixed(2), verwendungszweck);
posten.push({ datum, art, betrag: wert, verwendungszweck, gegenseite, gegenIban, kennung });
}
return { posten, fehler, konto, format: "CAMT.053" };
}
// -----------------------------------------------------------------------------
// MT940
// -----------------------------------------------------------------------------
/**
* MT940 einlesen.
*
* Das Format ist eine Folge von Feldern `:NN:Inhalt`, jedes auf einer eigenen
* Zeile, Fortsetzungen ohne Doppelpunkt. Gebraucht werden zwei:
*
* :61: der Umsatz — Datum, Richtung, Betrag
* :86: der Verwendungszweck, in Unterfeldern `?NN`
*
* Die Zeile :61: ist eine Zeichenkette ohne Trennzeichen, was sie zum
* unangenehmsten Teil macht:
*
* 2605 2605 C 1234,56 NTRF ...
* └─┬┘ └─┬┘ │ └──┬──┘
* Valuta Buchung │ Betrag mit Komma
* (optional)
* Richtung: C, D, RC, RD
*/
export function mt940Lesen(inhalt: string): Auszug {
const posten: Auszugsposten[] = [];
const fehler: Array<{ stelle: string; grund: string }> = [];
let konto: string | null = null;
// Fortsetzungszeilen anhängen: eine neue Angabe beginnt immer mit ":NN:".
const zeilen: string[] = [];
for (const roh of inhalt.replace(/\r\n/g, "\n").split("\n")) {
if (/^:\d{2}[A-Z]?:/.test(roh)) zeilen.push(roh);
else if (zeilen.length > 0 && roh.trim()) zeilen[zeilen.length - 1] += roh;
}
let offen: Auszugsposten | null = null;
let nummer = 0;
const abschliessen = () => {
if (offen) {
posten.push(offen);
offen = null;
}
};
for (const zeile of zeilen) {
const kennsatz = /^:(\d{2}[A-Z]?):(.*)$/.exec(zeile);
if (!kennsatz) continue;
const [, art, rest = ""] = kennsatz;
// :25: Kontobezeichnung — enthält je nach Bank die IBAN.
if (art === "25") {
const iban = /[A-Z]{2}\d{2}[A-Z0-9]{10,30}/.exec(rest.replace(/\s/g, ""));
konto = iban?.[0] ?? konto;
continue;
}
if (art === "61") {
abschliessen();
nummer += 1;
const stelle = `Umsatz ${nummer}`;
// Valutadatum JJMMTT, dann optional Buchungsdatum MMTT, dann Richtung.
const treffer = /^(\d{6})(\d{4})?(RC|RD|C|D)([A-Z])?([\d.,]+)/.exec(rest);
if (!treffer) {
fehler.push({ stelle, grund: `Die Umsatzzeile ist nicht lesbar: „${rest.slice(0, 40)}"` });
continue;
}
const [, valuta, buchung, richtung, , rohBetrag = ""] = treffer;
const jahr = 2000 + Number(valuta!.slice(0, 2));
const valutaMonat = Number(valuta!.slice(2, 4));
const valutaTag = Number(valuta!.slice(4, 6));
// Das Buchungsdatum trägt kein Jahr. Liegt es weit vor dem Valutadatum,
// stammt es aus dem Vorjahr — das ist der Jahreswechsel im Auszug.
let datum: Date | null;
if (buchung) {
const monat = Number(buchung.slice(0, 2));
const tag = Number(buchung.slice(2, 4));
const jahrDerBuchung = monat > valutaMonat + 6 ? jahr - 1 : jahr;
datum = alsDatum(jahrDerBuchung, monat, tag);
} else {
datum = alsDatum(jahr, valutaMonat, valutaTag);
}
if (!datum) {
fehler.push({ stelle, grund: "Kein lesbares Datum in der Umsatzzeile." });
continue;
}
// C = Gutschrift, D = Lastschrift. RC und RD sind Rückbuchungen und
// kehren die Richtung um.
const buchungsart =
richtung === "C" || richtung === "RD" ? BuchungsArt.EINNAHME : BuchungsArt.AUSGABE;
let wert: Prisma.Decimal;
try {
wert = runden(new Prisma.Decimal(rohBetrag.replace(/\./g, "").replace(",", ".")));
} catch {
fehler.push({ stelle, grund: `Betrag „${rohBetrag}" ist keine Zahl.` });
continue;
}
if (wert.lessThanOrEqualTo(0)) {
fehler.push({ stelle, grund: "Betrag ist nicht positiv." });
continue;
}
offen = {
datum,
art: buchungsart,
betrag: wert,
verwendungszweck: "",
gegenseite: null,
gegenIban: null,
kennung: "",
};
continue;
}
if (art === "86" && offen) {
// Unterfelder ?20 bis ?29 sind der Verwendungszweck, ?32/?33 der Name
// der Gegenseite, ?31 ihre Kontonummer oder IBAN.
const felder = new Map<string, string>();
for (const teil of rest.split("?").slice(1)) {
const schluessel = teil.slice(0, 2);
const inhaltTeil = teil.slice(2);
felder.set(schluessel, (felder.get(schluessel) ?? "") + inhaltTeil);
}
const zweck = Array.from({ length: 10 }, (_, i) => felder.get(String(20 + i)) ?? "")
.join(" ")
.replace(/\s+/g, " ")
.trim();
const name = [felder.get("32"), felder.get("33")]
.filter(Boolean)
.join(" ")
.replace(/\s+/g, " ")
.trim();
offen.verwendungszweck = zweck || rest.replace(/\s+/g, " ").trim() || "(kein Verwendungszweck)";
offen.gegenseite = name || null;
const iban = /[A-Z]{2}\d{2}[A-Z0-9]{10,30}/.exec((felder.get("31") ?? "").replace(/\s/g, ""));
offen.gegenIban = iban?.[0] ?? null;
offen.kennung = ersatzkennung(offen.datum, offen.betrag.toFixed(2), offen.verwendungszweck);
continue;
}
}
abschliessen();
// Ein Umsatz ohne :86: hat noch keine Kennung bekommen.
for (const eintrag of posten) {
if (!eintrag.verwendungszweck) eintrag.verwendungszweck = "(kein Verwendungszweck)";
if (!eintrag.kennung) {
eintrag.kennung = ersatzkennung(
eintrag.datum,
eintrag.betrag.toFixed(2),
eintrag.verwendungszweck,
);
}
}
if (posten.length === 0 && fehler.length === 0) {
fehler.push({
stelle: "Datei",
grund: "Es wurde kein einziger Umsatz (:61:) gefunden. Ist das wirklich eine MT940-Datei?",
});
}
return { posten, fehler, konto, format: "MT940" };
}
/**
* Das Format erkennen und einlesen.
*
* Erkannt wird am Inhalt und nicht an der Dateiendung: `.txt` und `.sta`
* kommen für MT940 vor, `.xml` und `.camt` für CAMT — und mancher Browser
* hängt beim Herunterladen noch etwas an.
*/
export function auszugLesen(inhalt: string): Auszug {
const anfang = inhalt.trimStart().slice(0, 400);
if (anfang.startsWith("<") || /<(?:\w+:)?Document\b/.test(anfang)) {
return camtLesen(inhalt);
}
if (/^:\d{2}[A-Z]?:/m.test(inhalt)) {
return mt940Lesen(inhalt);
}
return {
posten: [],
fehler: [
{
stelle: "Datei",
grund:
"Das Format wurde nicht erkannt. Erwartet wird CAMT.053 (XML) oder MT940 " +
"(Zeilen mit :20:, :61:, :86:). Beides bietet das Online-Banking zum " +
"Herunterladen an — oft unter „Umsatzexport“.",
},
],
konto: null,
format: "MT940",
};
}
+161
View File
@@ -0,0 +1,161 @@
import { Prisma } from "@prisma/client";
import { BuchungsArt } from "@prisma/client";
/**
* Rechnen mit Geld.
*
* **Niemals Fließkomma.** 0,1 + 0,2 ergibt in binärer Darstellung nicht 0,3,
* und eine Kasse, die sich um Cent verrechnet, ist keine Kasse — der
* Kassenprüfer findet die Abweichung, niemand kann sie erklären, und am Ende
* steht das Wort „Differenz" im Bericht.
*
* Gerechnet wird deshalb durchgängig mit `Prisma.Decimal` (decimal.js), das
* dieselbe Genauigkeit hat wie die Spalte `DECIMAL(12,2)` in der Datenbank.
*
* Reines Rechnen ohne Datenbank — damit es sich prüfen lässt.
*/
export const NULL_EURO = new Prisma.Decimal(0);
/** Ein Betrag aus einer Zahl, einem Text oder einem Decimal. */
export function betrag(wert: Prisma.Decimal | number | string | null | undefined): Prisma.Decimal {
if (wert === null || wert === undefined || wert === "") return NULL_EURO;
return new Prisma.Decimal(wert);
}
/**
* Auf zwei Stellen runden — kaufmännisch.
*
* Ein Zwischenergebnis mit drei Nachkommastellen entsteht bei jeder
* Multiplikation mit einer Menge (2,5 Stunden × 47,50 €). Gerundet wird erst
* am Ende, und zwar so, wie es eine Rechnung tut: ab der halben Stelle nach
* oben.
*/
export function runden(wert: Prisma.Decimal): Prisma.Decimal {
return wert.toDecimalPlaces(2, Prisma.Decimal.ROUND_HALF_UP);
}
/** Die Summe einer Liste. Leere Liste ergibt null Euro, nicht undefined. */
export function summe(werte: Array<Prisma.Decimal | number | string>): Prisma.Decimal {
return werte.reduce<Prisma.Decimal>((bisher, wert) => bisher.plus(betrag(wert)), NULL_EURO);
}
/**
* Was eine Buchung mit dem Bestand macht.
*
* Der Betrag ist immer positiv, die Richtung steht in `art`. Diese Funktion
* ist die eine Stelle, an der aus beidem ein Vorzeichen wird — damit es nicht
* an fünf Stellen unterschiedlich gemacht wird.
*/
export function alsWirkung(art: BuchungsArt, wert: Prisma.Decimal | number | string): Prisma.Decimal {
const zahl = betrag(wert);
return art === BuchungsArt.EINNAHME ? zahl : zahl.negated();
}
/**
* Der Bestand eines Kontos.
*
* Anfangsbestand plus die Wirkung aller Buchungen. Bewusst gerechnet und nicht
* gespeichert: ein gespeicherter Bestand neben einer nachgetragenen Buchung
* wäre falsch, ohne falsch auszusehen — und genau danach sucht ein
* Kassenprüfer.
*/
export function bestand(
anfangsbestand: Prisma.Decimal | number | string,
buchungen: Array<{ art: BuchungsArt; betrag: Prisma.Decimal | number | string }>,
): Prisma.Decimal {
return buchungen.reduce<Prisma.Decimal>(
(bisher, buchung) => bisher.plus(alsWirkung(buchung.art, buchung.betrag)),
betrag(anfangsbestand),
);
}
/**
* Einnahmen, Ausgaben und Saldo einer Liste.
*
* Die drei Zahlen, die in jeder Auswertung stehen. An einer Stelle gerechnet,
* damit sie zusammenpassen: Saldo ist immer Einnahmen minus Ausgaben und nie
* eine unabhängig gebildete Summe.
*/
export function bilanz(
buchungen: Array<{ art: BuchungsArt; betrag: Prisma.Decimal | number | string }>,
): { einnahmen: Prisma.Decimal; ausgaben: Prisma.Decimal; saldo: Prisma.Decimal } {
const einnahmen = summe(
buchungen.filter((b) => b.art === BuchungsArt.EINNAHME).map((b) => b.betrag),
);
const ausgaben = summe(
buchungen.filter((b) => b.art === BuchungsArt.AUSGABE).map((b) => b.betrag),
);
return { einnahmen, ausgaben, saldo: einnahmen.minus(ausgaben) };
}
/**
* Eine Position einer Rechnung.
*
* Menge mal Einzelpreis, auf zwei Stellen gerundet. Einsatzstunden werden
* viertelstündlich abgerechnet — 0,25 × 47,50 muss auf den Cent stimmen.
*/
export function positionssumme(
menge: Prisma.Decimal | number | string,
einzelpreis: Prisma.Decimal | number | string,
): Prisma.Decimal {
return runden(betrag(menge).times(betrag(einzelpreis)));
}
/**
* Einen Betrag aus einer Eingabe lesen.
*
* Deutsche und englische Schreibweise, mit und ohne Währungszeichen. Was sich
* nicht eindeutig lesen lässt, ergibt null — nicht null Euro, sondern
* „unlesbar". Der Unterschied ist wichtig: ein Betrag von null ist eine
* Angabe, ein unlesbarer ist eine Rückfrage.
*/
export function betragLesen(text: string): Prisma.Decimal | null {
const bereinigt = text
.replace(/[€$£]/g, "")
.replace(/\b(?:EUR|USD|CHF)\b/gi, "")
.replace(/\s/g, "")
.trim();
if (!bereinigt) return null;
// Das letzte Trennzeichen entscheidet: "1.234,56" ist tausendzweihundert,
// "1,234.56" ebenso — nur andersherum geschrieben.
const letztesKomma = bereinigt.lastIndexOf(",");
const letzterPunkt = bereinigt.lastIndexOf(".");
const normalisiert =
letztesKomma > letzterPunkt
? bereinigt.replace(/\./g, "").replace(",", ".")
: bereinigt.replace(/,/g, "");
if (!/^-?\d+(\.\d+)?$/.test(normalisiert)) return null;
try {
return runden(new Prisma.Decimal(normalisiert));
} catch {
return null;
}
}
/**
* Ein Betrag, wie er auf Papier steht.
*
* Eigene Funktion statt `alsEuro` aus lib/utils: die arbeitet mit `number`,
* und ein Decimal dorthin zu geben, wäre genau der Umweg über Fließkomma, den
* diese Datei vermeidet.
*/
export function alsText(wert: Prisma.Decimal | number | string | null | undefined): string {
if (wert === null || wert === undefined) return "—";
const zahl = betrag(wert);
const negativ = zahl.isNegative();
const [ganz, teil = "00"] = zahl.abs().toFixed(2).split(".");
// Tausenderpunkte von hinten setzen — die Formatierung von Intl würde den
// Decimal erst in eine Fließkommazahl verwandeln.
const mitPunkten = (ganz ?? "0").replace(/\B(?=(\d{3})+(?!\d))/g, ".");
return `${negativ ? "−" : ""}${mitPunkten},${teil} €`;
}
+359
View File
@@ -0,0 +1,359 @@
import "server-only";
import { html, htmlZuPdf } from "@/lib/pdf";
import { alsDatum } from "@/lib/utils";
import { alsText } from "@/lib/kasse/betrag";
import { ANLASS_TEXT, KONTOART_TEXT } from "@/lib/kasse/texte";
import type { KontoArt, RechnungsAnlass } from "@prisma/client";
/**
* Die beiden Papiere der Kasse: Rechnung und Kassenprüfbericht.
*
* Beide gehen aus dem Haus und werden von Leuten gelesen, die FLORIAN nicht
* kennen — der eine von einem Rechnungsempfänger, der andere von der
* Kassenprüfung der Gemeinde. Danach richtet sich alles:
*
* * **Alles ist eingebettet.** Keine externen Schriften, keine nachgeladenen
* Bilder. Ein Kassenprüfbericht muss in zehn Jahren noch genauso aussehen,
* auch wenn es dann keinen Server mehr gibt, der etwas nachliefert.
* * **Unterschriftsfelder gehören aufs Papier.** Ein Kassenprüfbericht ohne
* Platz für zwei Unterschriften ist kein Bericht, sondern ein Ausdruck.
* * **Beträge rechtsbündig und mit Tabellenziffern.** Eine Spalte mit
* Beträgen, die nicht untereinander stehen, prüft niemand gern.
*/
/**
* Die Fußzeile.
*
* Gotenberg erwartet dafür ein vollständiges HTML-Dokument mit eigenem Stil —
* die Regeln aus dem Hauptdokument gelten dort nicht. `pageNumber` und
* `totalPages` füllt es selbst.
*/
function fusszeile(text: string): string {
return `<!doctype html><html><head><meta charset="utf-8"><style>
body { font-family: Helvetica, Arial, sans-serif; font-size: 7pt; color: #555;
width: 100%; margin: 0; padding: 0 18mm; }
</style></head><body>
<div>${html(text)} · Seite <span class="pageNumber"></span> von <span class="totalPages"></span></div>
</body></html>`;
}
/** Der gemeinsame Rahmen. Schriften nur aus dem, was jedes System hat. */
function rahmen(titel: string, inhalt: string): string {
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>${html(titel)}</title>
<style>
@page { size: A4; margin: 20mm 18mm 22mm 18mm; }
* { box-sizing: border-box; }
body {
font-family: "Segoe UI", "DejaVu Sans", Arial, sans-serif;
font-size: 10.5pt;
line-height: 1.45;
color: #111;
margin: 0;
}
h1 { font-size: 16pt; margin: 0 0 2mm; }
h2 { font-size: 12pt; margin: 8mm 0 2mm; border-bottom: 0.4mm solid #111; padding-bottom: 1mm; }
table { width: 100%; border-collapse: collapse; margin-top: 2mm; }
th, td { padding: 1.6mm 2mm; text-align: left; vertical-align: top; }
thead th { border-bottom: 0.4mm solid #111; font-size: 9.5pt; }
tbody tr { border-bottom: 0.15mm solid #bbb; }
.zahl { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.summe td { border-top: 0.4mm solid #111; border-bottom: none; font-weight: 600; padding-top: 2.5mm; }
.klein { font-size: 9pt; color: #444; }
.kopf { display: flex; justify-content: space-between; gap: 10mm; margin-bottom: 8mm; }
.anschrift { margin: 10mm 0 8mm; line-height: 1.5; }
.hinweis { border: 0.3mm solid #111; padding: 3mm; margin-top: 6mm; }
.unterschriften { display: flex; gap: 12mm; margin-top: 18mm; }
.unterschrift { flex: 1; }
.linie { border-top: 0.3mm solid #111; margin-top: 14mm; padding-top: 1.5mm; font-size: 9pt; }
.differenz-ok { color: #15803d; font-weight: 600; }
.differenz-abweichung { color: #b91c1c; font-weight: 600; }
</style>
</head>
<body>${inhalt}</body>
</html>`;
}
// -----------------------------------------------------------------------------
// Rechnung
// -----------------------------------------------------------------------------
export interface RechnungsDaten {
wehr: {
name: string;
strasse: string | null;
plz: string | null;
ort: string | null;
telefon: string | null;
email: string | null;
};
nummer: string;
anlass: RechnungsAnlass;
datum: Date;
faelligAm: Date;
empfaenger: string;
empfaengerZusatz: string | null;
strasse: string | null;
plz: string | null;
ort: string | null;
bemerkung: string | null;
betrag: string;
positionen: Array<{
bezeichnung: string;
menge: string;
einheit: string | null;
einzelpreis: string;
gesamtpreis: string;
}>;
/** Bankverbindung für den Zahlungshinweis. */
bankkonto: { bezeichnung: string; iban: string | null } | null;
}
export async function rechnungPdf(daten: RechnungsDaten): Promise<Buffer> {
const absender = [
daten.wehr.name,
daten.wehr.strasse,
[daten.wehr.plz, daten.wehr.ort].filter(Boolean).join(" "),
]
.filter(Boolean)
.join(" · ");
const positionen = daten.positionen
.map(
(position, stelle) => `
<tr>
<td class="zahl">${stelle + 1}</td>
<td>${html(position.bezeichnung)}</td>
<td class="zahl">${html(position.menge)}${position.einheit ? ` ${html(position.einheit)}` : ""}</td>
<td class="zahl">${html(alsText(position.einzelpreis))}</td>
<td class="zahl">${html(alsText(position.gesamtpreis))}</td>
</tr>`,
)
.join("");
const inhalt = `
<div class="kopf">
<div>
<h1>${html(daten.wehr.name)}</h1>
<div class="klein">
${html(daten.wehr.strasse ?? "")}<br>
${html([daten.wehr.plz, daten.wehr.ort].filter(Boolean).join(" "))}<br>
${daten.wehr.telefon ? `Telefon ${html(daten.wehr.telefon)}<br>` : ""}
${daten.wehr.email ? html(daten.wehr.email) : ""}
</div>
</div>
<div class="klein" style="text-align:right">
<strong>Rechnung ${html(daten.nummer)}</strong><br>
Datum: ${html(alsDatum(daten.datum))}<br>
Fällig: ${html(alsDatum(daten.faelligAm))}<br>
Anlass: ${html(ANLASS_TEXT[daten.anlass])}
</div>
</div>
<div class="klein" style="font-size:7.5pt; border-bottom:0.2mm solid #999; padding-bottom:1mm">
${html(absender)}
</div>
<div class="anschrift">
${html(daten.empfaenger)}<br>
${daten.empfaengerZusatz ? `${html(daten.empfaengerZusatz)}<br>` : ""}
${daten.strasse ? `${html(daten.strasse)}<br>` : ""}
${html([daten.plz, daten.ort].filter(Boolean).join(" "))}
</div>
<h2>Rechnung ${html(daten.nummer)}</h2>
${daten.bemerkung ? `<p>${html(daten.bemerkung)}</p>` : ""}
<table>
<thead>
<tr>
<th class="zahl">Pos.</th>
<th>Bezeichnung</th>
<th class="zahl">Menge</th>
<th class="zahl">Einzelpreis</th>
<th class="zahl">Gesamt</th>
</tr>
</thead>
<tbody>
${positionen}
<tr class="summe">
<td colspan="4">Rechnungsbetrag</td>
<td class="zahl">${html(alsText(daten.betrag))}</td>
</tr>
</tbody>
</table>
<div class="hinweis">
<strong>Zahlungshinweis</strong><br>
Bitte überweisen Sie den Betrag bis zum ${html(alsDatum(daten.faelligAm))} unter Angabe
der Rechnungsnummer ${html(daten.nummer)}.
${
daten.bankkonto?.iban
? `<br><br>${html(daten.bankkonto.bezeichnung)}<br>IBAN ${html(
daten.bankkonto.iban.replace(/(.{4})/g, "$1 ").trim(),
)}`
: ""
}
</div>
<p class="klein" style="margin-top:8mm">
Diese Rechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.
</p>
`;
return htmlZuPdf(rahmen(`Rechnung ${daten.nummer}`, inhalt), {
hintergrundDrucken: true,
fusszeileHtml: fusszeile(`${daten.wehr.name} · Rechnung ${daten.nummer}`),
});
}
// -----------------------------------------------------------------------------
// Kassenprüfbericht
// -----------------------------------------------------------------------------
export interface PruefberichtDaten {
wehr: { name: string; ort: string | null };
vonAm: Date;
bisAm: Date;
geprueftAm: Date | null;
pruefer: string | null;
zweiterPruefer: string | null;
bestandLautBuch: string;
gezaehlterBestand: string;
differenz: string;
erlaeuterung: string | null;
posten: Array<{
bezeichnung: string;
art: KontoArt;
bestandLautBuch: string;
gezaehlterBestand: string;
differenz: string;
bemerkung: string | null;
}>;
/** Bewegung im Zeitraum, über alle Konten. */
einnahmen: string;
ausgaben: string;
anzahlBuchungen: number;
}
export async function pruefberichtPdf(daten: PruefberichtDaten): Promise<Buffer> {
const stimmt = Number(daten.differenz) === 0;
const posten = daten.posten
.map(
(posten) => `
<tr>
<td>${html(posten.bezeichnung)}<div class="klein">${html(KONTOART_TEXT[posten.art])}</div></td>
<td class="zahl">${html(alsText(posten.bestandLautBuch))}</td>
<td class="zahl">${html(alsText(posten.gezaehlterBestand))}</td>
<td class="zahl ${Number(posten.differenz) === 0 ? "differenz-ok" : "differenz-abweichung"}">
${html(alsText(posten.differenz))}
</td>
<td>${html(posten.bemerkung ?? "")}</td>
</tr>`,
)
.join("");
const inhalt = `
<h1>Kassenprüfbericht</h1>
<div class="klein">
${html(daten.wehr.name)}${daten.wehr.ort ? `, ${html(daten.wehr.ort)}` : ""}<br>
Prüfzeitraum ${html(alsDatum(daten.vonAm))} bis ${html(alsDatum(daten.bisAm))}
${daten.geprueftAm ? ` · geprüft am ${html(alsDatum(daten.geprueftAm))}` : ""}
</div>
<h2>Bestände je Konto</h2>
<table>
<thead>
<tr>
<th>Konto</th>
<th class="zahl">laut Buch</th>
<th class="zahl">gezählt</th>
<th class="zahl">Differenz</th>
<th>Bemerkung</th>
</tr>
</thead>
<tbody>
${posten}
<tr class="summe">
<td>Gesamt</td>
<td class="zahl">${html(alsText(daten.bestandLautBuch))}</td>
<td class="zahl">${html(alsText(daten.gezaehlterBestand))}</td>
<td class="zahl ${stimmt ? "differenz-ok" : "differenz-abweichung"}">
${html(alsText(daten.differenz))}
</td>
<td></td>
</tr>
</tbody>
</table>
<h2>Bewegung im Prüfzeitraum</h2>
<table>
<tbody>
<tr><td>Einnahmen</td><td class="zahl">${html(alsText(daten.einnahmen))}</td></tr>
<tr><td>Ausgaben</td><td class="zahl">${html(alsText(daten.ausgaben))}</td></tr>
<tr class="summe">
<td>Saldo</td>
<td class="zahl">${html(alsText((Number(daten.einnahmen) - Number(daten.ausgaben)).toFixed(2)))}</td>
</tr>
</tbody>
</table>
<p class="klein">${daten.anzahlBuchungen} Buchungen im Prüfzeitraum.</p>
<h2>Feststellung</h2>
<div class="hinweis">
${
stimmt
? "Der gezählte Bestand stimmt mit dem Bestand laut Buch überein."
: `<strong>Es besteht eine Differenz von ${html(alsText(daten.differenz))}.</strong>`
}
${daten.erlaeuterung ? `<br><br>${html(daten.erlaeuterung).replace(/\n/g, "<br>")}` : ""}
</div>
<p style="margin-top:6mm">
Die Kassenprüfer bestätigen mit ihrer Unterschrift, dass sie die Kassenführung geprüft,
die Bestände gezählt und die Belege stichprobenweise eingesehen haben.
</p>
<div class="unterschriften">
<div class="unterschrift">
<div class="linie">
${html(daten.pruefer ?? "Kassenprüfer")}<br>
<span class="klein">Ort, Datum, Unterschrift</span>
</div>
</div>
<div class="unterschrift">
<div class="linie">
${html(daten.zweiterPruefer ?? "Kassenprüfer")}<br>
<span class="klein">Ort, Datum, Unterschrift</span>
</div>
</div>
</div>
<div class="unterschriften">
<div class="unterschrift">
<div class="linie">
Kassier<br>
<span class="klein">Ort, Datum, Unterschrift</span>
</div>
</div>
<div class="unterschrift">
<div class="linie">
Kommandant<br>
<span class="klein">Ort, Datum, Unterschrift</span>
</div>
</div>
</div>
`;
return htmlZuPdf(rahmen("Kassenprüfbericht", inhalt), {
hintergrundDrucken: true,
fusszeileHtml: fusszeile(
`${daten.wehr.name} · Kassenprüfbericht ${alsDatum(daten.vonAm)} – ${alsDatum(daten.bisAm)}`,
),
});
}
+107
View File
@@ -0,0 +1,107 @@
import type {
BeitragStand,
BuchungsArt,
KontoArt,
RechnungsAnlass,
RechnungStand,
} from "@prisma/client";
/**
* Klartext für die Kassenansichten.
*
* Eigene Datei, weil die Oberfläche sie im Browser braucht. Dieselbe Trennung
* wie bei den Texten der Phasen 4, 7 und 8.
*/
export const KONTOART_TEXT: Record<KontoArt, string> = {
BAR: "Barkasse",
BANK: "Bankkonto",
RUECKLAGE: "Rücklage",
};
export const BUCHUNGSART_TEXT: Record<BuchungsArt, string> = {
EINNAHME: "Einnahme",
AUSGABE: "Ausgabe",
};
export const BEITRAG_STAND_TEXT: Record<BeitragStand, string> = {
OFFEN: "Offen",
BEZAHLT: "Bezahlt",
ERLASSEN: "Erlassen",
GEMAHNT: "Gemahnt",
};
export const BEITRAG_STAND_VARIANTE: Record<
BeitragStand,
"einsatzbereit" | "pruefungFaellig" | "gesperrt" | "secondary"
> = {
OFFEN: "pruefungFaellig",
BEZAHLT: "einsatzbereit",
ERLASSEN: "secondary",
GEMAHNT: "gesperrt",
};
export const RECHNUNG_STAND_TEXT: Record<RechnungStand, string> = {
ENTWURF: "Entwurf",
VERSENDET: "Versendet",
BEZAHLT: "Bezahlt",
GEMAHNT: "Gemahnt",
STORNIERT: "Storniert",
};
export const RECHNUNG_STAND_VARIANTE: Record<
RechnungStand,
"einsatzbereit" | "pruefungFaellig" | "gesperrt" | "secondary" | "outline"
> = {
ENTWURF: "outline",
VERSENDET: "pruefungFaellig",
BEZAHLT: "einsatzbereit",
GEMAHNT: "gesperrt",
STORNIERT: "secondary",
};
export const ANLASS_TEXT: Record<RechnungsAnlass, string> = {
FEHLALARM: "Fehlalarm",
OELEINSATZ: "Öleinsatz",
VERANSTALTUNG: "Veranstaltung",
VERMIETUNG: "Vermietung",
SONSTIGES: "Sonstiges",
};
/**
* Ein Betrag, wie er auf Papier steht — für den Browser.
*
* Nimmt auch ein `Decimal` entgegen: Prisma reicht Beträge als solche durch,
* und `String()` darauf ergibt „12.5". Hier wird nur formatiert, nicht
* gerechnet — gerechnet wird auf dem Server, wo `Prisma.Decimal` mit seiner
* vollen Genauigkeit zur Verfügung steht. Ein Umweg über `Number` wäre genau
* der Schritt über Fließkomma, den die ganze Kasse vermeidet.
*/
export function alsBetrag(
wert: string | number | { toString(): string } | null | undefined,
): string {
if (wert === null || wert === undefined) return "—";
const text = String(wert);
const negativ = text.startsWith("-");
const [ganz = "0", teil = "00"] = text.replace("-", "").split(".");
const mitPunkten = ganz.replace(/\B(?=(\d{3})+(?!\d))/g, ".");
const nachkomma = (teil + "00").slice(0, 2);
return `${negativ ? "−" : ""}${mitPunkten},${nachkomma} €`;
}
/** Eine IBAN gekürzt: DE89 …3000. Vollständig steht sie nur in den Stammdaten. */
export function ibanKurz(iban: string | null | undefined): string {
if (!iban) return "—";
const roh = iban.replace(/\s/g, "");
if (roh.length < 8) return roh;
return `${roh.slice(0, 4)} … ${roh.slice(-4)}`;
}
/** Eine IBAN in Vierergruppen, wie sie auf Papier steht. */
export function ibanFormatieren(iban: string | null | undefined): string {
if (!iban) return "—";
return iban.replace(/\s/g, "").replace(/(.{4})/g, "$1 ").trim();
}
+247
View File
@@ -0,0 +1,247 @@
import { BuchungsArt } from "@prisma/client";
/**
* Aus dem Verwendungszweck eine Kategorie vorschlagen.
*
* Der Kassier tippt „Tankstelle Musterhausen, Diesel LF" und bekommt
* „Kraftstoff" vorgeschlagen. Das spart bei zweihundert Buchungen im Jahr
* keine Stunde — aber es sorgt dafür, dass dieselbe Sache immer unter
* derselben Kategorie landet, und **das** ist es, was die Auswertung am Ende
* brauchbar macht.
*
* Zwei Wege, in dieser Reihenfolge:
*
* 1. **Was die Wehr schon gebucht hat.** Der beste Vorschlag kommt aus dem
* eigenen Bestand: wer „Stadtwerke" bisher immer auf „Wasser und Strom"
* gebucht hat, will es wieder.
* 2. **Stichwörter.** Für die erste Buchung einer Wehr, wenn es noch keinen
* Bestand gibt.
*
* Kein Sprachmodell. Das wäre für eine Kategoriezuordnung mit Kanonen auf
* Spatzen geschossen, es liefe nur mit eingerichtetem Ollama, und der Vergleich
* mit dem eigenen Bestand ist ohnehin besser: er kennt die Gepflogenheiten
* dieser Wehr.
*
* Reines Rechnen ohne Datenbank — damit es sich prüfen lässt.
*/
export interface Vorschlag {
kategorieId: string;
name: string;
/** 0 bis 1. Woher die Sicherheit kommt, steht in `grund`. */
sicherheit: number;
grund: string;
}
/** Eine frühere Buchung, aus der gelernt wird. */
export interface Vorbild {
kategorieId: string;
kategorieName: string;
verwendungszweck: string;
art: BuchungsArt;
}
/**
* Stichwörter für den Fall ohne Bestand.
*
* Bewusst kurz gehalten und auf das beschränkt, was in einer Feuerwehrkasse
* wirklich vorkommt. Eine lange Liste sähe gründlicher aus und träfe häufiger
* daneben — jeder Eintrag hier ist eine Behauptung darüber, wie diese Wehr
* ihre Kategorien nennt.
*/
const STICHWOERTER: Array<{ woerter: string[]; kategorie: string; art: BuchungsArt }> = [
{ woerter: ["diesel", "benzin", "tankstelle", "kraftstoff", "aral", "shell"], kategorie: "Kraftstoff", art: BuchungsArt.AUSGABE },
{ woerter: ["werkstatt", "reparatur", "instandsetzung", "ersatzteil"], kategorie: "Reparatur", art: BuchungsArt.AUSGABE },
{ woerter: ["tüv", "tuev", "hauptuntersuchung", "dekra"], kategorie: "Prüfungen", art: BuchungsArt.AUSGABE },
{ woerter: ["versicherung", "kfz-versicherung", "haftpflicht"], kategorie: "Versicherungen", art: BuchungsArt.AUSGABE },
{ woerter: ["strom", "wasser", "abwasser", "stadtwerke", "gas", "heizung"], kategorie: "Wasser und Strom", art: BuchungsArt.AUSGABE },
{ woerter: ["lehrgang", "ausbildung", "seminar", "feuerwehrschule"], kategorie: "Ausbildung", art: BuchungsArt.AUSGABE },
{ woerter: ["schutzkleidung", "einsatzjacke", "helm", "stiefel", "handschuh"], kategorie: "Schutzkleidung", art: BuchungsArt.AUSGABE },
{ woerter: ["atemschutz", "pressluftatmer", "maske", "filter"], kategorie: "Atemschutz", art: BuchungsArt.AUSGABE },
{ woerter: ["verpflegung", "getränke", "getraenke", "brotzeit", "metzger", "bäckerei", "baeckerei"], kategorie: "Verpflegung", art: BuchungsArt.AUSGABE },
{ woerter: ["porto", "büromaterial", "bueromaterial", "druckerpapier", "toner"], kategorie: "Verwaltung", art: BuchungsArt.AUSGABE },
{ woerter: ["kontoführung", "kontofuehrung", "gebühren", "gebuehren", "entgelt"], kategorie: "Bankgebühren", art: BuchungsArt.AUSGABE },
{ woerter: ["mitgliedsbeitrag", "beitrag", "jahresbeitrag"], kategorie: "Mitgliedsbeiträge", art: BuchungsArt.EINNAHME },
{ woerter: ["zuschuss", "zuwendung", "gemeinde", "kommune", "landkreis"], kategorie: "Zuschüsse", art: BuchungsArt.EINNAHME },
{ woerter: ["spende", "spendenaktion"], kategorie: "Spenden", art: BuchungsArt.EINNAHME },
{ woerter: ["fest", "waldfest", "ausschank", "losverkauf", "tombola"], kategorie: "Veranstaltungen", art: BuchungsArt.EINNAHME },
{ woerter: ["fehlalarm", "kostenersatz", "verursacher", "ölspur", "oelspur"], kategorie: "Kostenersatz", art: BuchungsArt.EINNAHME },
];
/** Wörter, die in fast jedem Verwendungszweck stehen und nichts unterscheiden. */
const FUELLWOERTER = new Set([
"rechnung",
"rechnungsnummer",
"nr",
"vom",
"für",
"fuer",
"und",
"der",
"die",
"das",
"den",
"dem",
"gmbh",
"kg",
"ohg",
"ag",
"ev",
"feuerwehr",
"ff",
"kundennummer",
"kdnr",
"datum",
"betrag",
"eur",
]);
/** Einen Text in vergleichbare Wörter zerlegen. */
export function woerter(text: string): string[] {
return text
.toLowerCase()
.replace(/[^a-zäöüß0-9\s-]/g, " ")
.split(/\s+/)
.map((wort) => wort.replace(/^-+|-+$/g, ""))
// Zwei Zeichen sagen nichts, reine Zahlen sind Rechnungsnummern.
.filter((wort) => wort.length >= 3 && !/^\d+$/.test(wort) && !FUELLWOERTER.has(wort));
}
/**
* Wie ähnlich sich zwei Verwendungszwecke sind.
*
* Anteil der gemeinsamen Wörter an der kürzeren der beiden Mengen. Damit
* schlägt „Stadtwerke Musterhausen Abschlag Strom" auf „Stadtwerke
* Musterhausen Jahresabrechnung" an, ohne dass eine lange Buchung eine kurze
* verdrängt.
*/
export function aehnlichkeit(a: string, b: string): number {
const mengeA = new Set(woerter(a));
const mengeB = new Set(woerter(b));
if (mengeA.size === 0 || mengeB.size === 0) return 0;
let gemeinsam = 0;
for (const wort of mengeA) if (mengeB.has(wort)) gemeinsam += 1;
return gemeinsam / Math.min(mengeA.size, mengeB.size);
}
/**
* Den besten Vorschlag finden.
*
* @param zweck was der Kassier eingetippt hat
* @param art Einnahme oder Ausgabe — schränkt die Kategorien ein
* @param vorbilder frühere Buchungen der Wehr
* @param kategorien was es überhaupt gibt, für den Weg über Stichwörter
*/
export function kategorieVorschlagen(
zweck: string,
art: BuchungsArt,
vorbilder: Vorbild[],
kategorien: Array<{ id: string; name: string; art: BuchungsArt }>,
): Vorschlag | null {
if (!zweck.trim()) return null;
// --- Weg 1: aus dem eigenen Bestand ---------------------------------------
//
// Je Kategorie den ähnlichsten Vorgänger nehmen, nicht den Durchschnitt: eine
// Kategorie mit fünfzig gemischten Buchungen wäre sonst gegenüber einer mit
// zwei sehr passenden im Nachteil.
const beste = new Map<string, { wert: number; name: string; zweck: string }>();
for (const vorbild of vorbilder) {
if (vorbild.art !== art) continue;
const wert = aehnlichkeit(zweck, vorbild.verwendungszweck);
const bisher = beste.get(vorbild.kategorieId);
if (!bisher || wert > bisher.wert) {
beste.set(vorbild.kategorieId, {
wert,
name: vorbild.kategorieName,
zweck: vorbild.verwendungszweck,
});
}
}
let bester: { id: string; wert: number; name: string; zweck: string } | null = null;
for (const [id, eintrag] of beste) {
if (!bester || eintrag.wert > bester.wert) bester = { id, ...eintrag };
}
// Unter der Hälfte gemeinsamer Wörter ist es Zufall. Ein falscher Vorschlag
// ist schlechter als keiner: er wird übernommen, ohne gelesen zu werden.
if (bester && bester.wert >= 0.5) {
return {
kategorieId: bester.id,
name: bester.name,
sicherheit: Math.min(1, bester.wert),
grund: `zuletzt so gebucht: „${bester.zweck.slice(0, 60)}"`,
};
}
// --- Weg 2: Stichwörter ---------------------------------------------------
const gefunden = woerter(zweck);
for (const eintrag of STICHWOERTER) {
if (eintrag.art !== art) continue;
if (!eintrag.woerter.some((wort) => gefunden.includes(wort))) continue;
const kategorie = kategorien.find(
(k) => k.art === art && k.name.toLowerCase() === eintrag.kategorie.toLowerCase(),
);
if (kategorie) {
return {
kategorieId: kategorie.id,
name: kategorie.name,
// Niedriger als ein Treffer aus dem Bestand: ein Stichwort weiß
// nichts über die Gepflogenheiten dieser Wehr.
sicherheit: 0.5,
grund: "aus dem Verwendungszweck geschlossen",
};
}
}
return null;
}
/**
* Die Kategorien, die eine Wehr am Anfang bekommt.
*
* Sie stehen hier und nicht nur im Seed, weil eine neu angelegte Wehr sie
* ebenso braucht — und weil die Stichwortliste oben sich auf genau diese Namen
* bezieht. Beides auseinanderlaufen zu lassen wäre die sichere Art, den
* Vorschlag stillschweigend kaputtzumachen.
*/
export const STANDARDKATEGORIEN: Array<{
name: string;
art: BuchungsArt;
oberkategorie?: string;
}> = [
{ name: "Fahrzeuge", art: BuchungsArt.AUSGABE },
{ name: "Kraftstoff", art: BuchungsArt.AUSGABE, oberkategorie: "Fahrzeuge" },
{ name: "Reparatur", art: BuchungsArt.AUSGABE, oberkategorie: "Fahrzeuge" },
{ name: "Prüfungen", art: BuchungsArt.AUSGABE, oberkategorie: "Fahrzeuge" },
{ name: "Versicherungen", art: BuchungsArt.AUSGABE },
{ name: "Ausrüstung", art: BuchungsArt.AUSGABE },
{ name: "Schutzkleidung", art: BuchungsArt.AUSGABE, oberkategorie: "Ausrüstung" },
{ name: "Atemschutz", art: BuchungsArt.AUSGABE, oberkategorie: "Ausrüstung" },
{ name: "Gerätehaus", art: BuchungsArt.AUSGABE },
{ name: "Wasser und Strom", art: BuchungsArt.AUSGABE, oberkategorie: "Gerätehaus" },
{ name: "Ausbildung", art: BuchungsArt.AUSGABE },
{ name: "Verpflegung", art: BuchungsArt.AUSGABE },
{ name: "Verwaltung", art: BuchungsArt.AUSGABE },
{ name: "Bankgebühren", art: BuchungsArt.AUSGABE, oberkategorie: "Verwaltung" },
{ name: "Mitgliedsbeiträge", art: BuchungsArt.EINNAHME },
{ name: "Zuschüsse", art: BuchungsArt.EINNAHME },
{ name: "Spenden", art: BuchungsArt.EINNAHME },
{ name: "Veranstaltungen", art: BuchungsArt.EINNAHME },
{ name: "Kostenersatz", art: BuchungsArt.EINNAHME },
];
+4
View File
@@ -30,6 +30,10 @@ export const AUFTRAG = {
JAHRESEXPORT_ERZEUGEN: "jahresexport.erzeugen",
/** Erzeugt das Atemschutzprotokoll als PDF (Phase 5). */
ATEMSCHUTZPROTOKOLL_ERZEUGEN: "atemschutzprotokoll.erzeugen",
/** Erzeugt eine Rechnung als PDF (Phase 9). */
RECHNUNG_ERZEUGEN: "rechnung.erzeugen",
/** Erzeugt den Kassenprüfbericht als PDF (Phase 9). */
KASSENPRUEFBERICHT_ERZEUGEN: "kassenpruefbericht.erzeugen",
/** Bettet eine Wiki-Seite für die semantische Suche ein (Phase 8). */
WIKI_EINBETTEN: "wiki.einbetten",
/** Wandelt eine Diktataufnahme in Text (Phase 8). */
+13
View File
@@ -29,6 +29,11 @@ import { benachrichtigungRouter } from "@/server/routers/termin/benachrichtigung
import { dienstausweisRouter } from "@/server/routers/stammdaten/dienstausweis";
import { wikiRouter } from "@/server/routers/ki/wiki";
import { assistentRouter } from "@/server/routers/ki/assistent";
import { kassenkontoRouter, buchungskategorieRouter } from "@/server/routers/kasse/konto";
import { buchungRouter } from "@/server/routers/kasse/buchung";
import { beitragRouter } from "@/server/routers/kasse/beitrag";
import { rechnungRouter } from "@/server/routers/kasse/rechnung";
import { abschlussRouter } from "@/server/routers/kasse/abschluss";
/**
* Wurzel-Router.
@@ -195,6 +200,14 @@ export const appRouter = router({
// Phase 8 — Lokale KI
wiki: wikiRouter,
assistent: assistentRouter,
// Phase 9 — Kassenverwaltung
kassenkonto: kassenkontoRouter,
buchungskategorie: buchungskategorieRouter,
buchung: buchungRouter,
beitrag: beitragRouter,
rechnung: rechnungRouter,
kassenabschluss: abschlussRouter,
});
export type AppRouter = typeof appRouter;
+554
View File
@@ -0,0 +1,554 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { BuchungsArt, Prisma } from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
import { alsText, bestand, bilanz, summe } from "@/lib/kasse/betrag";
import { AUFTRAG, beauftrage } from "@/lib/warteschlange";
/**
* Kassenabschluss und Auswertungen.
*
* Der Kassenabschluss ist der Vorgang, für den es die ganze Kasse gibt: zwei
* Kassenprüfer setzen sich hin, zählen das Bargeld, vergleichen die
* Kontoauszüge und unterschreiben. Danach ist der Zeitraum zu.
*
* Drei Festlegungen:
*
* * **Der Bestand laut Buch wird gerechnet, nicht eingegeben.** Er ist
* Anfangsbestand plus die Wirkung aller Buchungen bis zum Stichtag. Eine
* Eingabe wäre die Stelle, an der eine Differenz verschwindet.
* * **Die Differenz ist das Ergebnis, nicht die Eingabe.** Gezählt minus
* laut Buch. Auch das erzwingt die Datenbank.
* * **Festschreiben schließt den Zeitraum.** Danach nimmt die Buchungsmaske
* kein Datum daraus mehr an, und der Abschluss selbst ist unveränderbar.
*
* Je Konto eine Zeile. Eine einzelne Summe über alles würde verbergen, auf
* welchem Konto die Differenz sitzt — und genau das ist die Frage, die ein
* Kassenprüfer stellt.
*/
export const abschlussRouter = router({
/**
* Was ein Abschluss für einen Zeitraum ergäbe.
*
* Wird vor dem Anlegen angezeigt: je Konto der Bestand laut Buch, damit die
* Prüfer wissen, wogegen sie zählen. Die gezählten Werte tragen sie danach
* ein.
*/
vorbereiten: mitRecht("kasse.abschluss")
.input(z.object({ vonAm: z.date(), bisAm: z.date() }))
.query(async ({ ctx, input }) => {
if (input.bisAm < input.vonAm) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Der Zeitraum endet vor seinem Beginn." });
}
return ctx.db(async (db) => {
const konten = await db.kassenkonto.findMany({
where: { geloeschtAm: null },
orderBy: [{ art: "asc" }, { bezeichnung: "asc" }],
});
const posten = [];
for (const konto of konten) {
// Alles bis zum Stichtag, nicht nur der Zeitraum: der Bestand ist
// eine Momentaufnahme und keine Periodensumme.
const summen = await db.buchung.groupBy({
by: ["art"],
where: { kontoId: konto.id, datum: { lte: input.bisAm } },
_sum: { betrag: true },
});
// Zusätzlich die Bewegung im Zeitraum — das ist die Zahl, die im
// Bericht neben dem Bestand steht.
const imZeitraum = await db.buchung.groupBy({
by: ["art"],
where: { kontoId: konto.id, datum: { gte: input.vonAm, lte: input.bisAm } },
_sum: { betrag: true },
});
const zeitraum = bilanz(
imZeitraum.map((zeile) => ({ art: zeile.art, betrag: zeile._sum.betrag ?? 0 })),
);
posten.push({
kontoId: konto.id,
bezeichnung: konto.bezeichnung,
art: konto.art,
bestandLautBuch: bestand(
konto.anfangsbestand,
summen.map((zeile) => ({ art: zeile.art, betrag: zeile._sum.betrag ?? 0 })),
).toFixed(2),
einnahmen: zeitraum.einnahmen.toFixed(2),
ausgaben: zeitraum.ausgaben.toFixed(2),
});
}
const offene = await db.buchung.count({
where: { festgeschriebenAm: null, datum: { lte: input.bisAm } },
});
return {
posten,
gesamt: summe(posten.map((eintrag) => eintrag.bestandLautBuch)).toFixed(2),
/**
* Noch nicht festgeschriebene Buchungen im Zeitraum.
*
* Der wichtigste Hinweis dieser Ansicht: wer abschließt, während
* noch Entwürfe herumliegen, prüft einen Stand, der sich morgen
* ändert.
*/
offeneBuchungen: offene,
};
});
}),
liste: mitRecht("kasse.lesen").query(async ({ ctx }) =>
ctx.db((db) =>
db.kassenabschluss.findMany({
orderBy: { bisAm: "desc" },
take: 50,
include: {
pruefer: { select: { vorname: true, nachname: true } },
zweiterPruefer: { select: { vorname: true, nachname: true } },
berichtDatei: { select: { id: true, dateiname: true } },
_count: { select: { posten: true } },
},
}),
),
),
einzeln: mitRecht("kasse.lesen")
.input(idEingabe)
.query(async ({ ctx, input }) => {
const abschluss = await ctx.db((db) =>
db.kassenabschluss.findUnique({
where: { id: input.id },
include: {
pruefer: { select: { vorname: true, nachname: true, dienstgrad: true } },
zweiterPruefer: { select: { vorname: true, nachname: true, dienstgrad: true } },
berichtDatei: { select: { id: true, dateiname: true } },
posten: {
orderBy: { konto: { bezeichnung: "asc" } },
include: { konto: { select: { bezeichnung: true, art: true } } },
},
},
}),
);
if (!abschluss) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Abschluss gibt es nicht." });
}
return abschluss;
}),
/**
* Einen Abschluss anlegen.
*
* Noch nicht festgeschrieben: die Prüfer können ihre Zahlen korrigieren,
* solange sie beisammensitzen. Erst das Festschreiben schließt den Zeitraum.
*/
anlegen: mitRecht("kasse.abschluss")
.input(
z.object({
vonAm: z.date(),
bisAm: z.date(),
prueferId: z.string().uuid().nullish(),
zweiterPrueferId: z.string().uuid().nullish(),
geprueftAm: z.date().nullish(),
erlaeuterung: z.string().trim().max(2000).nullish(),
posten: z
.array(
z.object({
kontoId: z.string().uuid(),
gezaehlterBestand: z.number().min(-1_000_000).max(10_000_000),
bemerkung: z.string().trim().max(500).nullish(),
}),
)
.min(1)
.max(50),
}),
)
.mutation(async ({ ctx, input }) => {
if (input.bisAm < input.vonAm) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Der Zeitraum endet vor seinem Beginn." });
}
return ctx.db(async (db) => {
const ueberschneidung = await db.kassenabschluss.findFirst({
where: {
festgeschriebenAm: { not: null },
vonAm: { lte: input.bisAm },
bisAm: { gte: input.vonAm },
},
select: { vonAm: true, bisAm: true },
});
if (ueberschneidung) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
`Der Zeitraum überschneidet sich mit einem bereits geprüften ` +
`(${ueberschneidung.vonAm.toISOString().slice(0, 10)} bis ` +
`${ueberschneidung.bisAm.toISOString().slice(0, 10)}). ` +
"Ein Zeitraum wird genau einmal geprüft.",
});
}
// Der Bestand laut Buch wird hier gerechnet und nicht übernommen. Eine
// Eingabe wäre die Stelle, an der eine Differenz verschwindet.
const posten = [];
let summeLautBuch = new Prisma.Decimal(0);
let summeGezaehlt = new Prisma.Decimal(0);
for (const eingabe of input.posten) {
const konto = await db.kassenkonto.findUnique({
where: { id: eingabe.kontoId },
select: { anfangsbestand: true, bezeichnung: true },
});
if (!konto) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Eines der angegebenen Konten gibt es nicht.",
});
}
const summen = await db.buchung.groupBy({
by: ["art"],
where: { kontoId: eingabe.kontoId, datum: { lte: input.bisAm } },
_sum: { betrag: true },
});
const lautBuch = bestand(
konto.anfangsbestand,
summen.map((zeile) => ({ art: zeile.art, betrag: zeile._sum.betrag ?? 0 })),
);
const gezaehlt = new Prisma.Decimal(eingabe.gezaehlterBestand);
posten.push({
organisationId: ctx.sitzung.organisationId,
kontoId: eingabe.kontoId,
bestandLautBuch: lautBuch,
gezaehlterBestand: gezaehlt,
differenz: gezaehlt.minus(lautBuch),
bemerkung: eingabe.bemerkung ?? null,
});
summeLautBuch = summeLautBuch.plus(lautBuch);
summeGezaehlt = summeGezaehlt.plus(gezaehlt);
}
const abschluss = await db.kassenabschluss.create({
data: {
organisationId: ctx.sitzung.organisationId,
vonAm: input.vonAm,
bisAm: input.bisAm,
bestandLautBuch: summeLautBuch,
gezaehlterBestand: summeGezaehlt,
differenz: summeGezaehlt.minus(summeLautBuch),
erlaeuterung: input.erlaeuterung ?? null,
prueferId: input.prueferId ?? null,
zweiterPrueferId: input.zweiterPrueferId ?? null,
geprueftAm: input.geprueftAm ?? null,
posten: { create: posten },
},
include: { posten: true },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.ERSTELLT,
entitaet: "Kassenabschluss",
entitaetId: abschluss.id,
beschreibung:
`Kassenabschluss ${input.vonAm.toISOString().slice(0, 10)} bis ` +
`${input.bisAm.toISOString().slice(0, 10)}: laut Buch ${alsText(summeLautBuch)}, ` +
`gezählt ${alsText(summeGezaehlt)}`,
nachher: {
bestandLautBuch: summeLautBuch.toFixed(2),
gezaehlterBestand: summeGezaehlt.toFixed(2),
differenz: summeGezaehlt.minus(summeLautBuch).toFixed(2),
},
});
return abschluss;
});
}),
/**
* Festschreiben.
*
* Der Zeitraum ist danach zu — die Buchungsmaske nimmt kein Datum daraus
* mehr an, und der Abschluss selbst lässt sich nicht mehr ändern. Beides
* setzt die Datenbank durch.
*
* Eine Differenz ohne Erklärung wird abgewiesen. Das ist der Punkt, an dem
* eine Kassenprüfung sonst hängen bleibt: „stimmt nicht" steht im Bericht,
* und in fünf Jahren weiß niemand mehr, warum.
*/
festschreiben: mitRecht("kasse.abschluss")
.input(idEingabe)
.mutation(async ({ ctx, input }) => {
const abschluss = await ctx.db(async (db) => {
const vorher = await db.kassenabschluss.findUnique({ where: { id: input.id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Abschluss gibt es nicht." });
}
if (vorher.festgeschriebenAm) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Dieser Abschluss ist bereits festgeschrieben.",
});
}
if (!vorher.prueferId || !vorher.geprueftAm) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Ohne Kassenprüfer und Prüfdatum ist es kein Abschluss, sondern eine Notiz.",
});
}
if (!vorher.differenz.isZero() && !vorher.erlaeuterung?.trim()) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
`Es besteht eine Differenz von ${alsText(vorher.differenz)}. Bitte erklären, ` +
"woher sie kommt — sonst steht in fünf Jahren „stimmt nicht“ im Bericht und " +
"niemand weiß mehr, warum.",
});
}
// Noch offene Buchungen im Zeitraum mit festschreiben: sonst ließe
// sich eine davon danach nicht mehr anfassen, weil der Zeitraum zu
// ist — und sie stünde für immer als Entwurf da.
const offene = await db.buchung.updateMany({
where: {
festgeschriebenAm: null,
datum: { gte: vorher.vonAm, lte: vorher.bisAm },
},
data: { festgeschriebenAm: new Date() },
});
const nachher = await db.kassenabschluss.update({
where: { id: input.id },
data: { festgeschriebenAm: new Date() },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Kassenabschluss",
entitaetId: input.id,
beschreibung:
`Kassenabschluss festgeschrieben — der Zeitraum ${vorher.vonAm.toISOString().slice(0, 10)} ` +
`bis ${vorher.bisAm.toISOString().slice(0, 10)} ist geschlossen ` +
`(${offene.count} Buchungen mit festgeschrieben)`,
vorher: { festgeschriebenAm: null },
nachher: { festgeschriebenAm: nachher.festgeschriebenAm, mitFestgeschrieben: offene.count },
});
return nachher;
});
await beauftrage(AUFTRAG.KASSENPRUEFBERICHT_ERZEUGEN, {
organisationId: ctx.sitzung.organisationId,
abschlussId: abschluss.id,
}).catch((fehler) => {
console.error("[kasse] Berichtsauftrag ließ sich nicht einstellen:", fehler);
});
return abschluss;
}),
// ---------------------------------------------------------------------------
// Auswertungen
// ---------------------------------------------------------------------------
/**
* Einnahmen und Ausgaben nach Kategorie und Monat.
*
* Die Zahlen für die Jahreshauptversammlung — und die einzige Ansicht, in
* der auffällt, dass die Kraftstoffkosten sich verdoppelt haben.
*/
auswertung: mitRecht("kasse.lesen")
.input(z.object({ jahr: z.number().int().min(1900).max(2200) }))
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const von = new Date(Date.UTC(input.jahr, 0, 1));
const bis = new Date(Date.UTC(input.jahr, 11, 31));
const buchungen = await db.buchung.findMany({
where: { datum: { gte: von, lte: bis } },
select: {
datum: true,
art: true,
betrag: true,
kategorie: {
select: { id: true, name: true, oberkategorie: { select: { name: true } } },
},
},
});
// Nach Kategorie.
const jeKategorie = new Map<
string,
{ name: string; ober: string | null; art: BuchungsArt; summe: Prisma.Decimal }
>();
for (const buchung of buchungen) {
const bisher = jeKategorie.get(buchung.kategorie.id);
if (bisher) {
bisher.summe = bisher.summe.plus(buchung.betrag);
} else {
jeKategorie.set(buchung.kategorie.id, {
name: buchung.kategorie.name,
ober: buchung.kategorie.oberkategorie?.name ?? null,
art: buchung.art,
summe: new Prisma.Decimal(buchung.betrag),
});
}
}
// Nach Monat. Zwölf Zeilen, auch wenn in einem Monat nichts war —
// eine Lücke in der Reihe sähe aus wie ein Fehler.
const monate = Array.from({ length: 12 }, (_, monat) => {
const imMonat = buchungen.filter((buchung) => buchung.datum.getUTCMonth() === monat);
const werte = bilanz(imMonat);
return {
monat: monat + 1,
einnahmen: werte.einnahmen.toFixed(2),
ausgaben: werte.ausgaben.toFixed(2),
saldo: werte.saldo.toFixed(2),
};
});
const gesamt = bilanz(buchungen);
// Das Vorjahr daneben: eine Zahl ohne Vergleich sagt nichts.
const vorjahr = await db.buchung.groupBy({
by: ["art"],
where: {
datum: {
gte: new Date(Date.UTC(input.jahr - 1, 0, 1)),
lte: new Date(Date.UTC(input.jahr - 1, 11, 31)),
},
},
_sum: { betrag: true },
});
const vorjahrWerte = bilanz(
vorjahr.map((zeile) => ({ art: zeile.art, betrag: zeile._sum.betrag ?? 0 })),
);
return {
jahr: input.jahr,
kategorien: [...jeKategorie.entries()]
.map(([id, eintrag]) => ({
id,
name: eintrag.name,
oberkategorie: eintrag.ober,
art: eintrag.art,
summe: eintrag.summe.toFixed(2),
}))
.sort((a, b) => Number(b.summe) - Number(a.summe)),
monate,
gesamt: {
einnahmen: gesamt.einnahmen.toFixed(2),
ausgaben: gesamt.ausgaben.toFixed(2),
saldo: gesamt.saldo.toFixed(2),
},
vorjahr: {
einnahmen: vorjahrWerte.einnahmen.toFixed(2),
ausgaben: vorjahrWerte.ausgaben.toFixed(2),
saldo: vorjahrWerte.saldo.toFixed(2),
},
anzahl: buchungen.length,
};
}),
),
/**
* Der Export für Steuerberater und Kämmerei.
*
* CSV mit Semikolon und BOM — das ist, was Excel in Deutschland ohne
* Rückfrage richtig öffnet. Eine Datei, die beim Doppelklick als eine Spalte
* voller Semikolons erscheint, wird zurückgeschickt.
*/
export: mitRecht("kasse.lesen")
.input(z.object({ jahr: z.number().int().min(1900).max(2200) }))
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const buchungen = await db.buchung.findMany({
where: {
datum: {
gte: new Date(Date.UTC(input.jahr, 0, 1)),
lte: new Date(Date.UTC(input.jahr, 11, 31)),
},
},
orderBy: [{ datum: "asc" }, { erstelltAm: "asc" }],
include: {
konto: { select: { bezeichnung: true } },
kategorie: {
select: {
name: true,
kontonummer: true,
oberkategorie: { select: { name: true } },
},
},
person: { select: { vorname: true, nachname: true } },
},
});
const kopf = [
"Datum",
"Konto",
"Art",
"Betrag",
"Oberkategorie",
"Kategorie",
"Kontonummer",
"Verwendungszweck",
"Belegnummer",
"Gegenüber",
"Festgeschrieben",
];
const zeilen = buchungen.map((buchung) => [
buchung.datum.toISOString().slice(0, 10),
buchung.konto.bezeichnung,
buchung.art === BuchungsArt.EINNAHME ? "Einnahme" : "Ausgabe",
// Komma als Dezimaltrennzeichen — so erwartet es Excel hierzulande.
buchung.betrag.toFixed(2).replace(".", ","),
buchung.kategorie.oberkategorie?.name ?? "",
buchung.kategorie.name,
buchung.kategorie.kontonummer ?? "",
buchung.verwendungszweck,
buchung.belegnummer ?? "",
buchung.person
? `${buchung.person.nachname}, ${buchung.person.vorname}`
: (buchung.lieferant ?? ""),
buchung.festgeschriebenAm ? "ja" : "nein",
]);
/** Semikolon trennt, Anführungszeichen werden verdoppelt. */
const zeile = (felder: string[]) =>
felder
.map((feld) => (/[";\n]/.test(feld) ? `"${feld.replace(/"/g, '""')}"` : feld))
.join(";");
return {
dateiname: `kassenbuch-${input.jahr}.csv`,
// Das BOM sagt Excel, dass es UTF-8 ist. Ohne es werden aus Umlauten
// Fragezeichen, und die Datei kommt zurück.
inhalt: `${[zeile(kopf), ...zeilen.map(zeile)].join("\r\n")}\r\n`,
zeilen: zeilen.length,
};
}),
),
});
+625
View File
@@ -0,0 +1,625 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { BeitragStand, BuchungsArt, Prisma } from "@prisma/client";
import { router, mitRecht, type MandantenAbfrage } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
import { alsText, summe } from "@/lib/kasse/betrag";
/**
* Mitgliedsbeiträge.
*
* Der Vorgang, der dem Kassier im Januar den meisten Ärger macht: hundert
* Mitglieder, drei Abteilungen mit verschiedenen Sätzen, und am Ende die Frage,
* wer noch nicht gezahlt hat.
*
* Drei Festlegungen:
*
* * **Der Beitragslauf legt an, was fehlt.** Zweimal ausgeführt entsteht
* nichts doppelt (Eindeutigkeit über Person und Jahr). Damit lässt er sich
* im Februar wiederholen, wenn drei Neue dazugekommen sind.
* * **Ein Satz gilt ab einem Jahr und wird nicht rückwirkend geändert.**
* Sonst stimmen die Beiträge vergangener Jahre nicht mehr, und die sind
* bereits geprüft.
* * **„Bezahlt" entsteht durch eine Buchung.** Ein Häkchen ohne Geld in der
* Kasse wäre eine Behauptung; die Buchung ist der Beleg.
*/
const satzFelder = z.object({
bezeichnung: z.string().trim().min(1).max(120),
betrag: z.number().min(0).max(100_000),
abteilung: z.string().trim().max(60).nullish(),
abJahr: z.number().int().min(1900).max(2200),
aktiv: z.boolean().default(true),
});
export const beitragRouter = router({
// ---------------------------------------------------------------------------
// Sätze
// ---------------------------------------------------------------------------
saetze: mitRecht("kasse.lesen")
.input(z.object({ jahr: z.number().int().optional() }).optional())
.query(async ({ ctx, input }) =>
ctx.db((db) =>
db.beitragssatz.findMany({
where: { ...(input?.jahr ? { abJahr: { lte: input.jahr } } : {}) },
orderBy: [{ abJahr: "desc" }, { abteilung: "asc" }],
include: { _count: { select: { beitraege: true } } },
}),
),
),
satzAnlegen: mitRecht("kasse.bearbeiten")
.input(satzFelder)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorhanden = await db.beitragssatz.findFirst({
where: { abteilung: input.abteilung ?? null, abJahr: input.abJahr },
select: { id: true },
});
if (vorhanden) {
throw new TRPCError({
code: "CONFLICT",
message: `Für ${input.abteilung ?? "alle Abteilungen"} gibt es ab ${input.abJahr} bereits einen Satz.`,
});
}
const satz = await db.beitragssatz.create({
data: { ...input, organisationId: ctx.sitzung.organisationId },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.ERSTELLT,
entitaet: "Beitragssatz",
entitaetId: satz.id,
beschreibung:
`Beitragssatz „${input.bezeichnung}": ${alsText(input.betrag)} ab ${input.abJahr}` +
(input.abteilung ? ` (${input.abteilung})` : ""),
nachher: { ...input },
});
return satz;
}),
),
satzAendern: mitRecht("kasse.bearbeiten")
.input(satzFelder.partial().extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const { id, ...felder } = input;
return ctx.db(async (db) => {
const vorher = await db.beitragssatz.findUnique({
where: { id },
include: { _count: { select: { beitraege: true } } },
});
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Satz gibt es nicht." });
}
// Ein Satz, nach dem schon abgerechnet wurde, ändert seinen Betrag
// nicht mehr: sonst stimmen die Beiträge vergangener Jahre nicht mehr,
// und die sind längst geprüft.
if (
felder.betrag !== undefined &&
!vorher.betrag.equals(felder.betrag) &&
vorher._count.beitraege > 0
) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
`Nach diesem Satz sind bereits ${vorher._count.beitraege} Beiträge abgerechnet. ` +
"Für eine Änderung bitte einen neuen Satz ab dem kommenden Jahr anlegen — " +
"so bleiben die bisherigen Jahre nachvollziehbar.",
});
}
const nachher = await db.beitragssatz.update({ where: { id }, data: felder });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Beitragssatz",
entitaetId: id,
beschreibung: `Beitragssatz „${nachher.bezeichnung}" geändert`,
vorher: vorher as unknown as Record<string, unknown>,
nachher: nachher as unknown as Record<string, unknown>,
});
return nachher;
});
}),
// ---------------------------------------------------------------------------
// Der Lauf
// ---------------------------------------------------------------------------
/**
* Wie ein Beitragslauf ausginge.
*
* Wird vor dem Anlegen angezeigt. Wer hundert Beiträge erzeugt, soll vorher
* sehen, wie viele es werden und wer keinen Satz hat — die Liste ohne Satz
* ist der interessante Teil.
*/
laufVorschau: mitRecht("kasse.bearbeiten")
.input(z.object({ jahr: z.number().int().min(1900).max(2200) }))
.query(async ({ ctx, input }) => vorschauRechnen(ctx.db, input.jahr)),
/**
* Den Beitragslauf ausführen.
*
* Legt an, was fehlt. Wiederholt ausgeführt entsteht nichts doppelt — die
* Eindeutigkeit über Person und Jahr sorgt dafür. Genau deshalb lässt er
* sich im Februar noch einmal starten, wenn drei Neue dazugekommen sind.
*/
lauf: mitRecht("kasse.bearbeiten")
.input(z.object({ jahr: z.number().int().min(1900).max(2200) }))
.mutation(async ({ ctx, input }) => {
const vorschau = await vorschauRechnen(ctx.db, input.jahr);
if (vorschau.anzulegen.length === 0) {
return { angelegt: 0, ohneSatz: vorschau.ohneSatz.length };
}
return ctx.db(async (db) => {
const ergebnis = await db.beitrag.createMany({
data: vorschau.anzulegen.map((eintrag) => ({
organisationId: ctx.sitzung.organisationId,
personId: eintrag.personId,
jahr: input.jahr,
satzId: eintrag.satzId,
betrag: eintrag.betrag,
})),
// Wer schon einen hat, behält ihn.
skipDuplicates: true,
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.ERSTELLT,
entitaet: "Beitrag",
beschreibung: `Beitragslauf ${input.jahr}: ${ergebnis.count} Beiträge angelegt`,
nachher: {
jahr: input.jahr,
angelegt: ergebnis.count,
ohneSatz: vorschau.ohneSatz.length,
},
});
return { angelegt: ergebnis.count, ohneSatz: vorschau.ohneSatz.length };
});
}),
// ---------------------------------------------------------------------------
// Die Liste
// ---------------------------------------------------------------------------
liste: mitRecht("kasse.lesen")
.input(
z.object({
jahr: z.number().int().min(1900).max(2200),
stand: z.nativeEnum(BeitragStand).optional(),
suche: z.string().trim().max(120).optional(),
}),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const bedingung: Prisma.BeitragWhereInput = {
jahr: input.jahr,
...(input.stand ? { stand: input.stand } : {}),
...(input.suche
? {
person: {
OR: [
{ vorname: { contains: input.suche, mode: "insensitive" } },
{ nachname: { contains: input.suche, mode: "insensitive" } },
{ mitgliedsnummer: { contains: input.suche, mode: "insensitive" } },
],
},
}
: {}),
};
const [beitraege, alle] = await Promise.all([
db.beitrag.findMany({
where: bedingung,
orderBy: [{ person: { nachname: "asc" } }, { person: { vorname: "asc" } }],
take: 1000,
include: {
person: {
select: {
id: true,
vorname: true,
nachname: true,
mitgliedsnummer: true,
abteilung: true,
},
},
satz: { select: { bezeichnung: true } },
},
}),
db.beitrag.findMany({
where: { jahr: input.jahr },
select: { betrag: true, stand: true },
}),
]);
const nach = (stand: BeitragStand) => alle.filter((eintrag) => eintrag.stand === stand);
return {
beitraege,
kennzahlen: {
gesamt: summe(alle.map((eintrag) => eintrag.betrag)).toFixed(2),
bezahlt: summe(nach(BeitragStand.BEZAHLT).map((e) => e.betrag)).toFixed(2),
offen: summe(
[...nach(BeitragStand.OFFEN), ...nach(BeitragStand.GEMAHNT)].map((e) => e.betrag),
).toFixed(2),
erlassen: summe(nach(BeitragStand.ERLASSEN).map((e) => e.betrag)).toFixed(2),
anzahlOffen: nach(BeitragStand.OFFEN).length + nach(BeitragStand.GEMAHNT).length,
},
};
}),
),
/**
* Einen Beitrag als bezahlt vermerken.
*
* Erzeugt zugleich die Buchung. Ein Häkchen ohne Geld in der Kasse wäre eine
* Behauptung — die Buchung ist der Beleg, und beim Kassenabschluss stimmt
* die Summe nur, wenn beides zusammenhängt.
*/
bezahlen: mitRecht("kasse.bearbeiten")
.input(
z.object({
id: z.string().uuid(),
kontoId: z.string().uuid(),
kategorieId: z.string().uuid(),
bezahltAm: z.date(),
/** Abweichender Betrag, etwa bei einer Teilzahlung oder Aufrundung. */
betrag: z.number().positive().max(100_000).optional(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const beitrag = await db.beitrag.findUnique({
where: { id: input.id },
include: { person: { select: { vorname: true, nachname: true, mitgliedsnummer: true } } },
});
if (!beitrag) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Beitrag gibt es nicht." });
}
if (beitrag.stand === BeitragStand.BEZAHLT) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Dieser Beitrag ist bezahlt." });
}
if (beitrag.stand === BeitragStand.ERLASSEN) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Dieser Beitrag wurde erlassen. Für eine Zahlung bitte zuerst zurücknehmen.",
});
}
const betragWert = input.betrag ?? Number(beitrag.betrag);
const buchung = await db.buchung.create({
data: {
organisationId: ctx.sitzung.organisationId,
kontoId: input.kontoId,
kategorieId: input.kategorieId,
datum: input.bezahltAm,
art: BuchungsArt.EINNAHME,
betrag: betragWert,
verwendungszweck: `Mitgliedsbeitrag ${beitrag.jahr} — ${beitrag.person.nachname}, ${beitrag.person.vorname}`,
personId: beitrag.personId,
erfasstVonId: ctx.sitzung.personId,
},
select: { id: true },
});
const nachher = await db.beitrag.update({
where: { id: input.id },
data: {
stand: BeitragStand.BEZAHLT,
bezahltAm: input.bezahltAm,
buchungId: buchung.id,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Beitrag",
entitaetId: input.id,
beschreibung:
`Beitrag ${beitrag.jahr} von ${beitrag.person.nachname} als bezahlt vermerkt ` +
`(${alsText(betragWert)})`,
vorher: { stand: beitrag.stand },
nachher: { stand: BeitragStand.BEZAHLT, buchungId: buchung.id },
});
return nachher;
}),
),
/** Einen Beitrag erlassen — mit Begründung, die Datenbank verlangt sie. */
erlassen: mitRecht("kasse.bearbeiten")
.input(z.object({ id: z.string().uuid(), grund: z.string().trim().min(3).max(300) }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const beitrag = await db.beitrag.findUnique({
where: { id: input.id },
include: { person: { select: { nachname: true, vorname: true } } },
});
if (!beitrag) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Beitrag gibt es nicht." });
}
if (beitrag.stand === BeitragStand.BEZAHLT) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Ein bezahlter Beitrag lässt sich nicht erlassen — das Geld ist in der Kasse.",
});
}
const nachher = await db.beitrag.update({
where: { id: input.id },
data: { stand: BeitragStand.ERLASSEN, erlassGrund: input.grund },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Beitrag",
entitaetId: input.id,
beschreibung: `Beitrag ${beitrag.jahr} von ${beitrag.person.nachname} erlassen: ${input.grund}`,
vorher: { stand: beitrag.stand },
nachher: { stand: BeitragStand.ERLASSEN, grund: input.grund },
});
return nachher;
}),
),
/**
* Mahnen.
*
* Als Sammelvorgang über eine Auswahl: einzeln zu mahnen ist bei zwanzig
* Rückständen eine Arbeit, die liegen bleibt — und ein nie gemahnter
* Rückstand steht in fünf Jahren immer noch da.
*/
mahnen: mitRecht("kasse.bearbeiten")
.input(z.object({ ids: z.array(z.string().uuid()).min(1).max(500) }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const betroffen = await db.beitrag.findMany({
where: { id: { in: input.ids }, stand: { in: [BeitragStand.OFFEN, BeitragStand.GEMAHNT] } },
select: { id: true, mahnstufe: true },
});
const jetzt = new Date();
let gemahnt = 0;
for (const eintrag of betroffen) {
// Bei fünf ist Schluss: was danach kommt, ist keine Mahnung mehr,
// sondern eine Entscheidung der Kommandantschaft.
if (eintrag.mahnstufe >= 5) continue;
await db.beitrag.update({
where: { id: eintrag.id },
data: {
stand: BeitragStand.GEMAHNT,
mahnstufe: eintrag.mahnstufe + 1,
gemahntAm: jetzt,
},
});
gemahnt += 1;
}
if (gemahnt > 0) {
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Beitrag",
beschreibung: `${gemahnt} Beiträge gemahnt`,
nachher: { anzahl: gemahnt, gemahntAm: jetzt },
});
}
return { gemahnt, uebersprungen: betroffen.length - gemahnt };
}),
),
/**
* Die Rückstände über alle Jahre.
*
* Die Liste für die Jahreshauptversammlung — und die einzige Stelle, an der
* auffällt, dass jemand seit drei Jahren nicht gezahlt hat.
*/
rueckstaende: mitRecht("kasse.lesen").query(async ({ ctx }) =>
ctx.db(async (db) => {
const offene = await db.beitrag.findMany({
where: { stand: { in: [BeitragStand.OFFEN, BeitragStand.GEMAHNT] } },
orderBy: [{ person: { nachname: "asc" } }, { jahr: "asc" }],
include: {
person: {
select: { id: true, vorname: true, nachname: true, mitgliedsnummer: true, aktiv: true },
},
},
});
// Nach Person bündeln: „Huber, Andreas — 3 Jahre, 135 €" sagt mehr als
// drei einzelne Zeilen.
const jePerson = new Map<
string,
{
person: (typeof offene)[number]["person"];
jahre: number[];
summe: Prisma.Decimal;
hoechsteMahnstufe: number;
}
>();
for (const eintrag of offene) {
const bisher = jePerson.get(eintrag.personId);
if (bisher) {
bisher.jahre.push(eintrag.jahr);
bisher.summe = bisher.summe.plus(eintrag.betrag);
bisher.hoechsteMahnstufe = Math.max(bisher.hoechsteMahnstufe, eintrag.mahnstufe);
} else {
jePerson.set(eintrag.personId, {
person: eintrag.person,
jahre: [eintrag.jahr],
summe: new Prisma.Decimal(eintrag.betrag),
hoechsteMahnstufe: eintrag.mahnstufe,
});
}
}
const zeilen = [...jePerson.values()]
.map((eintrag) => ({
person: eintrag.person,
jahre: eintrag.jahre,
summe: eintrag.summe.toFixed(2),
hoechsteMahnstufe: eintrag.hoechsteMahnstufe,
}))
.sort((a, b) => Number(b.summe) - Number(a.summe));
return {
zeilen,
gesamt: summe(offene.map((eintrag) => eintrag.betrag)).toFixed(2),
anzahl: offene.length,
};
}),
),
/** Eine Zahlung zurücknehmen — sie war ein Irrtum. */
zuruecknehmen: mitRecht("kasse.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const beitrag = await db.beitrag.findUnique({ where: { id: input.id } });
if (!beitrag) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Beitrag gibt es nicht." });
}
if (beitrag.stand === BeitragStand.OFFEN) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Dieser Beitrag ist bereits offen." });
}
// Die Buchung bleibt, wo sie ist. Wenn sie falsch war, wird sie
// gegengebucht — hier steht nur der Beitrag wieder auf offen.
const nachher = await db.beitrag.update({
where: { id: input.id },
data: {
stand: BeitragStand.OFFEN,
bezahltAm: null,
buchungId: null,
erlassGrund: null,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Beitrag",
entitaetId: input.id,
beschreibung: `Beitrag ${beitrag.jahr} wieder auf offen gesetzt`,
vorher: { stand: beitrag.stand, buchungId: beitrag.buchungId },
nachher: { stand: BeitragStand.OFFEN },
kontext: beitrag.buchungId
? { hinweis: "Die zugehörige Buchung bleibt bestehen und ist ggf. gegenzubuchen." }
: null,
});
return nachher;
}),
),
});
/**
* Was ein Beitragslauf für ein Jahr ergäbe.
*
* An einer Stelle, weil Vorschau und Ausführung dieselbe Rechnung brauchen —
* zwei Umsetzungen davon liefen früher oder später auseinander, und dann zeigte
* die Vorschau etwas anderes an, als hinterher entsteht.
*/
async function vorschauRechnen(abfrage: MandantenAbfrage, jahr: number) {
return abfrage(async (db) => {
const [personen, saetze, vorhandene] = await Promise.all([
db.person.findMany({
where: { aktiv: true, geloeschtAm: null },
select: {
id: true,
vorname: true,
nachname: true,
mitgliedsnummer: true,
abteilung: true,
},
orderBy: [{ nachname: "asc" }, { vorname: "asc" }],
}),
db.beitragssatz.findMany({
where: { aktiv: true, abJahr: { lte: jahr } },
orderBy: { abJahr: "desc" },
}),
db.beitrag.findMany({ where: { jahr }, select: { personId: true } }),
]);
const schonDa = new Set(vorhandene.map((eintrag) => eintrag.personId));
/** Der jüngste Satz, der für diese Abteilung schon gilt. */
const satzFuer = (abteilung: string | null) => {
const passend = saetze.find((satz) => satz.abteilung === abteilung);
// Kein eigener Satz? Dann der allgemeine ohne Abteilung.
return passend ?? saetze.find((satz) => satz.abteilung === null) ?? null;
};
const anzulegen: Array<{
personId: string;
name: string;
abteilung: string | null;
satzId: string;
betrag: number;
}> = [];
const ohneSatz: Array<{ personId: string; name: string; abteilung: string | null }> = [];
for (const person of personen) {
if (schonDa.has(person.id)) continue;
const satz = satzFuer(person.abteilung);
const name = `${person.nachname}, ${person.vorname}`;
if (!satz) {
ohneSatz.push({ personId: person.id, name, abteilung: person.abteilung });
continue;
}
anzulegen.push({
personId: person.id,
name,
abteilung: person.abteilung,
satzId: satz.id,
betrag: Number(satz.betrag),
});
}
return {
jahr,
anzulegen,
ohneSatz,
bereitsVorhanden: schonDa.size,
summe: anzulegen.reduce((bisher, eintrag) => bisher + eintrag.betrag, 0).toFixed(2),
};
});
}
+717
View File
@@ -0,0 +1,717 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { BuchungsArt, KiStand, Prisma } from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
import { alsText, bilanz } from "@/lib/kasse/betrag";
import { auszugLesen } from "@/lib/kasse/bankimport";
import { kategorieVorschlagen } from "@/lib/kasse/vorschlag";
/**
* Buchungen — der Kern der Kasse.
*
* Drei Regeln bestimmen alles hier, und alle drei stehen zusätzlich in der
* Datenbank:
*
* 1. **Eine festgeschriebene Buchung ist unveränderbar.** Korrekturen laufen
* über eine Gegenbuchung mit Verweis. Ein Kassenbuch, in dem man
* rückwirkend ändern kann, ist vor der Kassenprüfung wertlos.
* 2. **Ein geprüfter Zeitraum ist zu.** Nach dem Kassenabschluss nimmt die
* Maske kein Datum daraus mehr an — sonst hätte der geprüfte Bestand am
* Tag danach eine andere Zahl.
* 3. **Der Betrag ist positiv, die Richtung steht daneben.** Ein
* vorzeichenbehafteter Betrag wäre kürzer und beim ersten Tippfehler
* falsch.
*
* Der Bankimport liefert **Vorschläge**, keine Buchungen. Ein Betrag, den eine
* Maschine aus einer Datei gelesen hat, ist ein ausgefülltes Formular — und
* ein doppelt eingelesener Auszug wäre sonst ein doppelt gebuchter Monat.
*/
const buchungFelder = z.object({
kontoId: z.string().uuid(),
kategorieId: z.string().uuid(),
datum: z.date(),
art: z.nativeEnum(BuchungsArt),
betrag: z.number().positive().max(10_000_000),
verwendungszweck: z.string().trim().min(1).max(300),
belegnummer: z.string().trim().max(60).nullish(),
personId: z.string().uuid().nullish(),
lieferant: z.string().trim().max(200).nullish(),
gegenkontoId: z.string().uuid().nullish(),
belegDateiId: z.string().uuid().nullish(),
});
const listenauswahl = {
id: true,
datum: true,
art: true,
betrag: true,
verwendungszweck: true,
belegnummer: true,
lieferant: true,
festgeschriebenAm: true,
storniertVonId: true,
belegDateiId: true,
konto: { select: { id: true, bezeichnung: true, art: true } },
kategorie: { select: { id: true, name: true, art: true } },
person: { select: { id: true, vorname: true, nachname: true } },
} as const;
/** Was an einer Buchung fachlich nicht zusammenpasst. */
function widersprueche(daten: {
personId?: string | null;
lieferant?: string | null;
kontoId?: string;
gegenkontoId?: string | null;
}): string | null {
if (daten.personId && daten.lieferant) {
return "Entweder ein Mitglied oder ein Dritter — beides zusammen lässt sich nicht auswerten.";
}
if (daten.gegenkontoId && daten.gegenkontoId === daten.kontoId) {
return "Eine Umbuchung auf dasselbe Konto ist keine.";
}
return null;
}
export const buchungRouter = router({
liste: mitRecht("kasse.lesen")
.input(
z
.object({
suche: z.string().trim().max(200).optional(),
kontoId: z.string().uuid().optional(),
kategorieId: z.string().uuid().optional(),
art: z.nativeEnum(BuchungsArt).optional(),
von: z.date().optional(),
bis: z.date().optional(),
nurOffene: z.boolean().default(false),
anzahl: z.number().int().min(1).max(500).default(100),
})
.optional(),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const bedingung: Prisma.BuchungWhereInput = {
...(input?.kontoId ? { kontoId: input.kontoId } : {}),
...(input?.kategorieId ? { kategorieId: input.kategorieId } : {}),
...(input?.art ? { art: input.art } : {}),
...(input?.nurOffene ? { festgeschriebenAm: null } : {}),
...(input?.von || input?.bis
? {
datum: {
...(input.von ? { gte: input.von } : {}),
...(input.bis ? { lte: input.bis } : {}),
},
}
: {}),
...(input?.suche
? {
OR: [
{ verwendungszweck: { contains: input.suche, mode: "insensitive" } },
{ lieferant: { contains: input.suche, mode: "insensitive" } },
{ belegnummer: { contains: input.suche, mode: "insensitive" } },
],
}
: {}),
};
const [buchungen, summen] = await Promise.all([
db.buchung.findMany({
where: bedingung,
orderBy: [{ datum: "desc" }, { erstelltAm: "desc" }],
take: input?.anzahl ?? 100,
select: listenauswahl,
}),
// Die Summe über ALLE Treffer, nicht nur über die angezeigten. Sonst
// stünde unter einer auf hundert Zeilen begrenzten Liste eine
// Summe, die nichts bedeutet.
db.buchung.groupBy({ by: ["art"], where: bedingung, _sum: { betrag: true } }),
]);
const gesamt = bilanz(
summen.map((zeile) => ({ art: zeile.art, betrag: zeile._sum.betrag ?? 0 })),
);
return {
buchungen,
summe: {
einnahmen: gesamt.einnahmen.toFixed(2),
ausgaben: gesamt.ausgaben.toFixed(2),
saldo: gesamt.saldo.toFixed(2),
},
};
}),
),
einzeln: mitRecht("kasse.lesen")
.input(idEingabe)
.query(async ({ ctx, input }) => {
const buchung = await ctx.db((db) =>
db.buchung.findUnique({
where: { id: input.id },
include: {
konto: true,
kategorie: { include: { oberkategorie: { select: { name: true } } } },
person: { select: { id: true, vorname: true, nachname: true } },
erfasstVon: { select: { vorname: true, nachname: true } },
belegDatei: { select: { id: true, dateiname: true, mimeTyp: true } },
storniertVon: { select: { id: true, datum: true, verwendungszweck: true } },
storniert: { select: { id: true, datum: true, verwendungszweck: true } },
},
}),
);
if (!buchung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Buchung gibt es nicht." });
}
return buchung;
}),
/**
* Eine Kategorie vorschlagen.
*
* Wird beim Tippen aufgerufen. Der beste Vorschlag kommt aus dem eigenen
* Bestand — wer „Stadtwerke" bisher immer auf „Wasser und Strom" gebucht
* hat, will es wieder.
*/
vorschlag: mitRecht("kasse.bearbeiten")
.input(
z.object({
verwendungszweck: z.string().trim().min(3).max(300),
art: z.nativeEnum(BuchungsArt),
}),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const [vorbilder, kategorien] = await Promise.all([
db.buchung.findMany({
where: { art: input.art },
orderBy: { datum: "desc" },
take: 400,
select: {
kategorieId: true,
verwendungszweck: true,
art: true,
kategorie: { select: { name: true } },
},
}),
db.buchungskategorie.findMany({
where: { aktiv: true, geloeschtAm: null, art: input.art },
select: { id: true, name: true, art: true },
}),
]);
return kategorieVorschlagen(
input.verwendungszweck,
input.art,
vorbilder.map((zeile) => ({
kategorieId: zeile.kategorieId,
kategorieName: zeile.kategorie.name,
verwendungszweck: zeile.verwendungszweck,
art: zeile.art,
})),
kategorien,
);
}),
),
anlegen: mitRecht("kasse.bearbeiten")
.input(buchungFelder)
.mutation(async ({ ctx, input }) => {
const problem = widersprueche(input);
if (problem) throw new TRPCError({ code: "BAD_REQUEST", message: problem });
return ctx.db(async (db) => {
const kategorie = await db.buchungskategorie.findUnique({
where: { id: input.kategorieId },
select: { art: true, name: true, aktiv: true },
});
if (!kategorie) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Kategorie gibt es nicht." });
}
if (kategorie.art !== input.art) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `„${kategorie.name}" ist eine Kategorie für ${
kategorie.art === BuchungsArt.EINNAHME ? "Einnahmen" : "Ausgaben"
}.`,
});
}
const buchung = await db.buchung.create({
data: {
...input,
organisationId: ctx.sitzung.organisationId,
erfasstVonId: ctx.sitzung.personId,
},
select: listenauswahl,
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.ERSTELLT,
entitaet: "Buchung",
entitaetId: buchung.id,
beschreibung:
`${input.art === BuchungsArt.EINNAHME ? "Einnahme" : "Ausgabe"} über ` +
`${alsText(input.betrag)}: ${input.verwendungszweck}`,
nachher: { ...input, betrag: input.betrag },
});
return buchung;
});
}),
aendern: mitRecht("kasse.bearbeiten")
.input(buchungFelder.partial().extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const { id, ...felder } = input;
return ctx.db(async (db) => {
const vorher = await db.buchung.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Buchung gibt es nicht." });
}
// Die Datenbank würde es ohnehin abweisen — die Meldung hier ist die,
// die ein Mensch liest.
if (vorher.festgeschriebenAm) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Diese Buchung ist festgeschrieben und lässt sich nicht mehr ändern. " +
"Für eine Korrektur bitte gegenbuchen — dann bleiben beide Vorgänge " +
"nachvollziehbar.",
});
}
const zusammen = { ...vorher, ...felder };
const problem = widersprueche(zusammen);
if (problem) throw new TRPCError({ code: "BAD_REQUEST", message: problem });
const nachher = await db.buchung.update({ where: { id }, data: felder });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Buchung",
entitaetId: id,
beschreibung: `Buchung geändert: ${nachher.verwendungszweck}`,
vorher: vorher as unknown as Record<string, unknown>,
nachher: nachher as unknown as Record<string, unknown>,
});
return nachher;
});
}),
/**
* Eine noch nicht festgeschriebene Buchung entfernen.
*
* Solange nichts festgeschrieben ist, ist eine Buchung ein Entwurf, und ein
* Entwurf darf verschwinden. Danach nicht mehr — dafür gibt es die
* Gegenbuchung.
*/
loeschen: mitRecht("kasse.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const buchung = await db.buchung.findUnique({
where: { id: input.id },
select: { festgeschriebenAm: true, verwendungszweck: true, betrag: true, art: true },
});
if (!buchung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Buchung gibt es nicht." });
}
if (buchung.festgeschriebenAm) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Diese Buchung ist festgeschrieben. Bitte gegenbuchen statt löschen — ein " +
"Kassenbuch mit Lücken ist keines.",
});
}
await db.buchung.delete({ where: { id: input.id } });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GELOESCHT,
entitaet: "Buchung",
entitaetId: input.id,
beschreibung: `Entwurf verworfen: ${buchung.verwendungszweck}`,
vorher: {
verwendungszweck: buchung.verwendungszweck,
betrag: buchung.betrag.toFixed(2),
art: buchung.art,
},
});
return { erfolgreich: true };
}),
),
/**
* Buchungen festschreiben.
*
* Der Schritt, nach dem nichts mehr geändert wird. Üblicherweise am Ende
* eines Monats für alles, was bis dahin erfasst wurde.
*
* Bewusst als Sammelvorgang: einzeln festzuschreiben ist bei zweihundert
* Buchungen im Jahr eine Arbeit, die niemand macht — und was nie
* festgeschrieben wird, ist nie geprüft.
*/
festschreiben: mitRecht("kasse.bearbeiten")
.input(
z.object({
ids: z.array(z.string().uuid()).min(1).max(1000).optional(),
/** Oder pauschal alles bis zu diesem Tag. */
bisAm: z.date().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
if (!input.ids && !input.bisAm) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Bitte angeben, was festgeschrieben werden soll — einzelne oder bis zu einem Tag.",
});
}
return ctx.db(async (db) => {
const jetzt = new Date();
const ergebnis = await db.buchung.updateMany({
where: {
festgeschriebenAm: null,
...(input.ids ? { id: { in: input.ids } } : {}),
...(input.bisAm ? { datum: { lte: input.bisAm } } : {}),
},
data: { festgeschriebenAm: jetzt },
});
if (ergebnis.count > 0) {
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Buchung",
beschreibung:
`${ergebnis.count} Buchungen festgeschrieben` +
(input.bisAm ? ` (bis ${input.bisAm.toISOString().slice(0, 10)})` : ""),
nachher: { anzahl: ergebnis.count, festgeschriebenAm: jetzt },
});
}
return { festgeschrieben: ergebnis.count };
});
}),
/**
* Gegenbuchen.
*
* Der einzige Weg, eine festgeschriebene Buchung aufzuheben: eine zweite mit
* derselben Summe in die andere Richtung, mit Verweis auf die erste. Beide
* bleiben stehen, die Summe stimmt wieder, und im Kassenbuch ist zu sehen,
* dass etwas berichtigt wurde — genau das will ein Kassenprüfer sehen.
*
* Das Datum der Gegenbuchung ist **heute**, nicht das der ursprünglichen:
* ein geprüfter Zeitraum bleibt zu.
*/
gegenbuchen: mitRecht("kasse.bearbeiten")
.input(
z.object({
id: z.string().uuid(),
grund: z.string().trim().min(3).max(200),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const original = await db.buchung.findUnique({
where: { id: input.id },
include: { konto: { select: { bezeichnung: true } } },
});
if (!original) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Buchung gibt es nicht." });
}
if (original.storniertVonId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Diese Buchung ist bereits gegengebucht.",
});
}
const gegenrichtung =
original.art === BuchungsArt.EINNAHME ? BuchungsArt.AUSGABE : BuchungsArt.EINNAHME;
const gegenbuchung = await db.buchung.create({
data: {
organisationId: ctx.sitzung.organisationId,
kontoId: original.kontoId,
kategorieId: original.kategorieId,
// Heute, nicht das Datum des Originals: ein geprüfter Zeitraum
// bleibt zu, und die Berichtigung gehört in den Monat, in dem sie
// aufgefallen ist.
datum: new Date(),
art: gegenrichtung,
betrag: original.betrag,
verwendungszweck: `Gegenbuchung zu „${original.verwendungszweck.slice(0, 200)}": ${input.grund}`,
belegnummer: original.belegnummer,
personId: original.personId,
lieferant: original.lieferant,
storniertVonId: original.id,
erfasstVonId: ctx.sitzung.personId,
},
select: listenauswahl,
});
// Auch am Original vermerken. Das ist die einzige Änderung, die der
// Trigger an einer festgeschriebenen Buchung zulässt — sie ist keine
// Änderung des Vorgangs, sondern seine Fortschreibung.
await db.buchung.update({
where: { id: original.id },
data: { storniertVonId: gegenbuchung.id },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.ERSTELLT,
entitaet: "Buchung",
entitaetId: gegenbuchung.id,
beschreibung:
`Gegenbuchung über ${alsText(original.betrag)} zu „${original.verwendungszweck}": ` +
input.grund,
nachher: { original: original.id, grund: input.grund },
});
return gegenbuchung;
}),
),
// ---------------------------------------------------------------------------
// Bankimport
// ---------------------------------------------------------------------------
/**
* Einen Kontoauszug einlesen.
*
* Herauskommen **Vorschläge**, keine Buchungen — mitsamt dem Hinweis,
* welche davon schon in der Kasse stehen. Diese Erkennung ist der eigentliche
* Wert der Funktion: ein zweimal eingelesener Auszug wäre sonst ein doppelt
* gebuchter Monat, und das fällt erst beim Kassenabschluss auf.
*/
auszugLesen: mitRecht("kasse.bearbeiten")
.input(
z.object({
kontoId: z.string().uuid(),
inhalt: z.string().min(10).max(10_000_000),
}),
)
.mutation(async ({ ctx, input }) => {
const auszug = auszugLesen(input.inhalt);
if (auszug.posten.length === 0) {
return { ...auszug, posten: [], bekannt: 0 };
}
// Was davon steht schon in der Kasse? Verglichen wird über Datum und
// Betrag im Fenster des Auszugs — die Kennung der Bank steht an einer
// vorhandenen Buchung nicht, weil sie auch von Hand erfasst worden sein
// kann.
const daten = auszug.posten.map((posten) => posten.datum.getTime());
const von = new Date(Math.min(...daten));
const bis = new Date(Math.max(...daten));
const vorhandene = await ctx.db((db) =>
db.buchung.findMany({
where: { kontoId: input.kontoId, datum: { gte: von, lte: bis } },
select: { datum: true, betrag: true, art: true, verwendungszweck: true },
}),
);
const posten = auszug.posten.map((posten) => {
const schonDa = vorhandene.some(
(buchung) =>
buchung.datum.toISOString().slice(0, 10) ===
posten.datum.toISOString().slice(0, 10) &&
buchung.art === posten.art &&
buchung.betrag.equals(posten.betrag),
);
return {
datum: posten.datum,
art: posten.art,
betrag: posten.betrag.toFixed(2),
verwendungszweck: posten.verwendungszweck,
gegenseite: posten.gegenseite,
gegenIban: posten.gegenIban,
kennung: posten.kennung,
schonDa,
};
});
return {
format: auszug.format,
konto: auszug.konto,
fehler: auszug.fehler,
posten,
bekannt: posten.filter((eintrag) => eintrag.schonDa).length,
};
}),
/**
* Ausgewählte Posten eines Auszugs übernehmen.
*
* Als Entwürfe: nicht festgeschrieben, damit jemand sie noch einordnen kann.
* Die Kategorie kommt aus dem Vorschlag — falsch zugeordnet ist besser als
* gar nicht zugeordnet, solange es jemand sieht und ändern kann.
*/
auszugUebernehmen: mitRecht("kasse.bearbeiten")
.input(
z.object({
kontoId: z.string().uuid(),
posten: z
.array(
z.object({
datum: z.date(),
art: z.nativeEnum(BuchungsArt),
betrag: z.number().positive(),
verwendungszweck: z.string().trim().min(1).max(300),
gegenseite: z.string().trim().max(200).nullish(),
kategorieId: z.string().uuid(),
}),
)
.min(1)
.max(500),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const ergebnis = await db.buchung.createMany({
data: input.posten.map((posten) => ({
organisationId: ctx.sitzung.organisationId,
kontoId: input.kontoId,
kategorieId: posten.kategorieId,
datum: posten.datum,
art: posten.art,
betrag: posten.betrag,
verwendungszweck: posten.verwendungszweck,
lieferant: posten.gegenseite ?? null,
erfasstVonId: ctx.sitzung.personId,
})),
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.ERSTELLT,
entitaet: "Buchung",
beschreibung: `${ergebnis.count} Buchungen aus einem Kontoauszug übernommen`,
nachher: { anzahl: ergebnis.count, kontoId: input.kontoId },
});
return { uebernommen: ergebnis.count };
}),
),
/**
* Aus einem gegengezeichneten Beleg eine Buchung machen.
*
* Die Bruecke zwischen Phase 8 und Phase 9. Fotografiert und ausgefuellt
* wird der Beleg am Terminal oder am Handy — gebucht wird er hier, mit
* Konto und Kategorie, die eine Maschine nicht entscheiden kann.
*
* Das Foto haengt an der Buchung. Genau das verlangt eine Kassenpruefung:
* zu jeder Ausgabe ein Beleg, und zwar an der Buchung und nicht in einem
* Ordner daneben.
*
* Nur gegengezeichnete Belege: ein Entwurf ist ein von einem Modell
* ausgefuelltes Formular, und daraus wird keine Buchung, bevor ein Mensch
* ihn angesehen hat.
*/
belegBuchen: mitRecht("kasse.bearbeiten")
.input(
z.object({
belegId: z.string().uuid(),
kontoId: z.string().uuid(),
kategorieId: z.string().uuid(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const beleg = await db.belegentwurf.findUnique({
where: { id: input.belegId },
include: { fotoDatei: { select: { id: true } } },
});
if (!beleg || beleg.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Beleg gibt es nicht." });
}
if (beleg.stand !== KiStand.UEBERNOMMEN) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Dieser Beleg ist noch nicht gegengezeichnet. Erst ansehen, dann buchen — " +
"ein Entwurf ist ein von einer Maschine ausgefülltes Formular.",
});
}
if (beleg.buchungId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Dieser Beleg ist bereits gebucht.",
});
}
if (!beleg.betrag || beleg.betrag.lessThanOrEqualTo(0)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Ohne Betrag lässt sich der Beleg nicht buchen.",
});
}
const buchung = await db.buchung.create({
data: {
organisationId: ctx.sitzung.organisationId,
kontoId: input.kontoId,
kategorieId: input.kategorieId,
// Das Belegdatum, nicht der Tag des Fotografierens. Danach
// richtet sich, in welchen Abschluss die Buchung fällt.
datum: beleg.belegdatum ?? new Date(),
art: BuchungsArt.AUSGABE,
betrag: beleg.betrag,
verwendungszweck: beleg.haendler
? `${beleg.haendler}${beleg.kategorie ? `, ${beleg.kategorie}` : ""}`
: (beleg.kategorie ?? "Beleg"),
lieferant: beleg.haendler ?? null,
belegDateiId: beleg.fotoDateiId,
erfasstVonId: ctx.sitzung.personId,
},
});
await db.belegentwurf.update({
where: { id: input.belegId },
data: { buchungId: buchung.id },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.ERSTELLT,
entitaet: "Buchung",
entitaetId: buchung.id,
beschreibung: `Beleg über ${alsText(beleg.betrag)} gebucht`,
nachher: { belegId: input.belegId, betrag: beleg.betrag.toFixed(2) },
});
return buchung;
}),
),
});
+399
View File
@@ -0,0 +1,399 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { BuchungsArt, KontoArt } from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
import { bestand } from "@/lib/kasse/betrag";
import { STANDARDKATEGORIEN } from "@/lib/kasse/vorschlag";
/**
* Konten und Kategorien — das Gerüst der Kasse.
*
* Eine Wehr führt in aller Regel drei Konten: die Barkasse im Gerätehaus, ein
* Bankkonto und eine Rücklage für die nächste größere Anschaffung. Die
* Trennung der Rücklage ist keine Buchhaltungsspielerei — sie ist der Grund,
* warum das Geld für das neue Fahrzeug in fünf Jahren noch da ist.
*
* **Der Bestand wird gerechnet, nicht gespeichert.** Anfangsbestand plus die
* Wirkung aller Buchungen. Ein gespeicherter Bestand neben einer nachgetragenen
* Buchung wäre falsch, ohne falsch auszusehen — und genau danach sucht ein
* Kassenprüfer.
*/
const kontoFelder = z.object({
bezeichnung: z.string().trim().min(1).max(120),
art: z.nativeEnum(KontoArt).default(KontoArt.BAR),
iban: z.string().trim().max(34).nullish(),
bic: z.string().trim().max(11).nullish(),
anfangsbestand: z.number().min(-1_000_000).max(1_000_000).default(0),
bemerkung: z.string().trim().max(500).nullish(),
aktiv: z.boolean().default(true),
});
const kategorieFelder = z.object({
name: z.string().trim().min(1).max(120),
art: z.nativeEnum(BuchungsArt),
oberkategorieId: z.string().uuid().nullish(),
kontonummer: z.string().trim().max(20).nullish(),
bemerkung: z.string().trim().max(500).nullish(),
aktiv: z.boolean().default(true),
});
export const kassenkontoRouter = router({
/**
* Die Konten mit ihrem Bestand.
*
* Der Bestand kommt aus den Buchungen. Bei einer Wehr mit zweihundert
* Buchungen im Jahr ist das eine Summenbildung über ein paar tausend Zeilen —
* das rechnet die Datenbank schneller, als sie einen gespeicherten Wert
* suchen würde.
*/
liste: mitRecht("kasse.lesen")
.input(z.object({ mitInaktiven: z.boolean().default(false) }).optional())
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const konten = await db.kassenkonto.findMany({
where: { geloeschtAm: null, ...(input?.mitInaktiven ? {} : { aktiv: true }) },
orderBy: [{ art: "asc" }, { bezeichnung: "asc" }],
});
const summen = await db.buchung.groupBy({
by: ["kontoId", "art"],
_sum: { betrag: true },
});
return konten.map((konto) => {
const eigene = summen
.filter((zeile) => zeile.kontoId === konto.id)
.map((zeile) => ({ art: zeile.art, betrag: zeile._sum.betrag ?? 0 }));
return {
...konto,
bestand: bestand(konto.anfangsbestand, eigene).toFixed(2),
};
});
}),
),
einzeln: mitRecht("kasse.lesen")
.input(idEingabe)
.query(async ({ ctx, input }) => {
const konto = await ctx.db(async (db) => {
const eintrag = await db.kassenkonto.findUnique({
where: { id: input.id },
include: { _count: { select: { buchungen: true } } },
});
if (!eintrag || eintrag.geloeschtAm) return null;
const summen = await db.buchung.groupBy({
by: ["art"],
where: { kontoId: input.id },
_sum: { betrag: true },
});
return {
...eintrag,
bestand: bestand(
eintrag.anfangsbestand,
summen.map((zeile) => ({ art: zeile.art, betrag: zeile._sum.betrag ?? 0 })),
).toFixed(2),
};
});
if (!konto) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Konto gibt es nicht." });
}
return konto;
}),
anlegen: mitRecht("kasse.bearbeiten")
.input(kontoFelder)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const konto = await db.kassenkonto.create({
data: { ...input, organisationId: ctx.sitzung.organisationId },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.ERSTELLT,
entitaet: "Kassenkonto",
entitaetId: konto.id,
beschreibung: `Konto „${input.bezeichnung}" angelegt`,
nachher: { ...input },
});
return konto;
}),
),
aendern: mitRecht("kasse.bearbeiten")
.input(kontoFelder.partial().extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const { id, ...felder } = input;
return ctx.db(async (db) => {
const vorher = await db.kassenkonto.findUnique({
where: { id },
include: { _count: { select: { buchungen: true } } },
});
if (!vorher || vorher.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Konto gibt es nicht." });
}
// Der Anfangsbestand ist der Ausgangspunkt aller Bestände. Ihn zu
// ändern, wenn schon gebucht wurde, verschöbe jeden Kontostand
// rückwirkend — auch die bereits geprüften.
if (
felder.anfangsbestand !== undefined &&
!vorher.anfangsbestand.equals(felder.anfangsbestand) &&
vorher._count.buchungen > 0
) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
`Auf diesem Konto stehen bereits ${vorher._count.buchungen} Buchungen. ` +
"Der Anfangsbestand lässt sich dann nicht mehr ändern — eine Korrektur " +
"läuft über eine Buchung, damit sie nachvollziehbar bleibt.",
});
}
const nachher = await db.kassenkonto.update({ where: { id }, data: felder });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Kassenkonto",
entitaetId: id,
beschreibung: `Konto „${nachher.bezeichnung}" geändert`,
vorher: vorher as unknown as Record<string, unknown>,
nachher: nachher as unknown as Record<string, unknown>,
});
return nachher;
});
}),
/**
* Ein Konto stilllegen.
*
* Nie löschen: an ihm hängen Buchungen, und ein Kassenbuch mit Zeilen ohne
* Konto ist keines. Stillgelegt heißt: taucht in der Buchungsmaske nicht
* mehr auf, in Auswertungen und Abschlüssen weiterhin.
*/
stilllegen: mitRecht("kasse.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const konto = await db.kassenkonto.findUnique({
where: { id: input.id },
select: { bezeichnung: true },
});
if (!konto) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Konto gibt es nicht." });
}
await db.kassenkonto.update({ where: { id: input.id }, data: { aktiv: false } });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Kassenkonto",
entitaetId: input.id,
beschreibung: `Konto „${konto.bezeichnung}" stillgelegt`,
vorher: { aktiv: true },
nachher: { aktiv: false },
});
return { erfolgreich: true };
}),
),
});
export const buchungskategorieRouter = router({
liste: mitRecht("kasse.lesen")
.input(
z
.object({
art: z.nativeEnum(BuchungsArt).optional(),
mitInaktiven: z.boolean().default(false),
})
.optional(),
)
.query(async ({ ctx, input }) =>
ctx.db((db) =>
db.buchungskategorie.findMany({
where: {
geloeschtAm: null,
...(input?.art ? { art: input.art } : {}),
...(input?.mitInaktiven ? {} : { aktiv: true }),
},
orderBy: [{ art: "asc" }, { name: "asc" }],
include: {
oberkategorie: { select: { id: true, name: true } },
_count: { select: { buchungen: true } },
},
}),
),
),
anlegen: mitRecht("kasse.bearbeiten")
.input(kategorieFelder)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
if (input.oberkategorieId) {
const ober = await db.buchungskategorie.findUnique({
where: { id: input.oberkategorieId },
select: { art: true, oberkategorieId: true },
});
if (!ober) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Oberkategorie gibt es nicht." });
}
if (ober.art !== input.art) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Eine Ausgabe kann nicht unter einer Einnahmekategorie stehen.",
});
}
// Zwei Ebenen reichen. Jede weitere macht die Auswertung unlesbar,
// und niemand findet mehr, wo eine Buchung eingeordnet ist.
if (ober.oberkategorieId) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Es gibt genau zwei Ebenen: Oberkategorie und Kategorie. Eine dritte " +
"würde die Auswertung unübersichtlich machen.",
});
}
}
const kategorie = await db.buchungskategorie.create({
data: { ...input, organisationId: ctx.sitzung.organisationId },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.ERSTELLT,
entitaet: "Buchungskategorie",
entitaetId: kategorie.id,
beschreibung: `Kategorie „${input.name}" angelegt`,
nachher: { ...input },
});
return kategorie;
}),
),
aendern: mitRecht("kasse.bearbeiten")
.input(kategorieFelder.partial().extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const { id, ...felder } = input;
return ctx.db(async (db) => {
const vorher = await db.buchungskategorie.findUnique({
where: { id },
include: { _count: { select: { buchungen: true } } },
});
if (!vorher || vorher.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Kategorie gibt es nicht." });
}
// Die Richtung einer Kategorie zu drehen, würde jede vorhandene
// Buchung umdeuten — aus einer Ausgabe würde eine Einnahme, und die
// Jahresauswertung stimmte rückwirkend nicht mehr.
if (felder.art && felder.art !== vorher.art && vorher._count.buchungen > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
`Auf diese Kategorie sind ${vorher._count.buchungen} Buchungen gebucht. ` +
"Einnahme und Ausgabe lassen sich dann nicht mehr tauschen — bitte eine " +
"neue Kategorie anlegen und die alte stilllegen.",
});
}
const nachher = await db.buchungskategorie.update({ where: { id }, data: felder });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Buchungskategorie",
entitaetId: id,
beschreibung: `Kategorie „${nachher.name}" geändert`,
vorher: vorher as unknown as Record<string, unknown>,
nachher: nachher as unknown as Record<string, unknown>,
});
return nachher;
});
}),
/**
* Den Standardkontenrahmen anlegen.
*
* Eine Wehr, die mit der Kasse anfängt, soll nicht erst zwanzig Kategorien
* eintippen. Die Namen entsprechen denen, auf die sich der Kategorievorschlag
* bezieht — deshalb stehen beide in derselben Datei.
*/
standardAnlegen: mitRecht("kasse.bearbeiten").mutation(async ({ ctx }) =>
ctx.db(async (db) => {
const vorhanden = await db.buchungskategorie.count({ where: { geloeschtAm: null } });
if (vorhanden > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
`Es gibt bereits ${vorhanden} Kategorien. Der Kontenrahmen wird nur in eine ` +
"leere Kasse gelegt — sonst stünde alles doppelt da.",
});
}
// Erst die Oberkategorien, damit die Unterkategorien sie finden.
const nachName = new Map<string, string>();
for (const eintrag of STANDARDKATEGORIEN.filter((k) => !k.oberkategorie)) {
const angelegt = await db.buchungskategorie.create({
data: {
organisationId: ctx.sitzung.organisationId,
name: eintrag.name,
art: eintrag.art,
},
select: { id: true, name: true },
});
nachName.set(angelegt.name, angelegt.id);
}
for (const eintrag of STANDARDKATEGORIEN.filter((k) => k.oberkategorie)) {
await db.buchungskategorie.create({
data: {
organisationId: ctx.sitzung.organisationId,
name: eintrag.name,
art: eintrag.art,
oberkategorieId: nachName.get(eintrag.oberkategorie!) ?? null,
},
});
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.ERSTELLT,
entitaet: "Buchungskategorie",
beschreibung: `Standardkontenrahmen mit ${STANDARDKATEGORIEN.length} Kategorien angelegt`,
});
return { angelegt: STANDARDKATEGORIEN.length };
}),
),
});
+538
View File
@@ -0,0 +1,538 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { BuchungsArt, Prisma, RechnungStand, RechnungsAnlass } from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
import { naechsteNummer } from "@/lib/nummernkreis";
import { alsText, positionssumme, summe } from "@/lib/kasse/betrag";
import { AUFTRAG, beauftrage } from "@/lib/warteschlange";
/**
* Rechnungen an Dritte.
*
* Fehlalarm, Ölspur, Absicherung einer Veranstaltung, Vermietung. Also das,
* was eine Wehr nach dem Verursacherprinzip oder aus einer Vereinbarung heraus
* abrechnet.
*
* Zwei Festlegungen:
*
* * **Der Betrag wird gerechnet, nie eingegeben.** Summe der Positionen,
* jede davon Menge mal Einzelpreis. Ein von Hand eingetragener Gesamtbetrag
* neben Positionen, die etwas anderes ergeben, ist der Fehler, den niemand
* bemerkt.
* * **Eine versendete Rechnung wird nicht mehr geändert.** Was beim
* Empfänger liegt, gilt. Korrekturen laufen über eine Stornierung und eine
* neue Rechnung — genau wie bei den Buchungen.
*
* Die Nummer kommt aus demselben Nummernkreis wie Einsätze und Prüfungen:
* `JJJJ-NNNN`, fortlaufend im Jahr, je Wehr getrennt.
*/
const positionFelder = z.object({
bezeichnung: z.string().trim().min(1).max(300),
menge: z.number().positive().max(100_000).default(1),
einheit: z.string().trim().max(20).nullish(),
einzelpreis: z.number().min(0).max(1_000_000),
});
const rechnungFelder = z.object({
anlass: z.nativeEnum(RechnungsAnlass).default(RechnungsAnlass.SONSTIGES),
empfaenger: z.string().trim().min(1).max(200),
empfaengerZusatz: z.string().trim().max(200).nullish(),
strasse: z.string().trim().max(200).nullish(),
plz: z.string().trim().max(10).nullish(),
ort: z.string().trim().max(120).nullish(),
einsatzId: z.string().uuid().nullish(),
datum: z.date(),
faelligAm: z.date(),
bemerkung: z.string().trim().max(1000).nullish(),
});
export const rechnungRouter = router({
liste: mitRecht("kasse.lesen")
.input(
z
.object({
stand: z.nativeEnum(RechnungStand).optional(),
jahr: z.number().int().optional(),
suche: z.string().trim().max(200).optional(),
})
.optional(),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const jahresgrenzen = input?.jahr
? {
gte: new Date(Date.UTC(input.jahr, 0, 1)),
lte: new Date(Date.UTC(input.jahr, 11, 31)),
}
: undefined;
const rechnungen = await db.rechnung.findMany({
where: {
...(input?.stand ? { stand: input.stand } : {}),
...(jahresgrenzen ? { datum: jahresgrenzen } : {}),
...(input?.suche
? {
OR: [
{ empfaenger: { contains: input.suche, mode: "insensitive" } },
{ nummer: { contains: input.suche, mode: "insensitive" } },
],
}
: {}),
},
orderBy: { datum: "desc" },
take: 300,
include: { _count: { select: { positionen: true } } },
});
const offen = rechnungen.filter(
(eintrag) =>
eintrag.stand === RechnungStand.VERSENDET || eintrag.stand === RechnungStand.GEMAHNT,
);
const heute = new Date();
return {
rechnungen,
kennzahlen: {
gesamt: summe(rechnungen.map((eintrag) => eintrag.betrag)).toFixed(2),
offen: summe(offen.map((eintrag) => eintrag.betrag)).toFixed(2),
anzahlOffen: offen.length,
ueberfaellig: offen.filter((eintrag) => eintrag.faelligAm < heute).length,
},
};
}),
),
einzeln: mitRecht("kasse.lesen")
.input(idEingabe)
.query(async ({ ctx, input }) => {
const rechnung = await ctx.db((db) =>
db.rechnung.findUnique({
where: { id: input.id },
include: {
positionen: { orderBy: { reihenfolge: "asc" } },
buchung: { select: { id: true, datum: true, betrag: true } },
pdfDatei: { select: { id: true, dateiname: true } },
erstelltVon: { select: { vorname: true, nachname: true } },
},
}),
);
if (!rechnung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Rechnung gibt es nicht." });
}
return rechnung;
}),
anlegen: mitRecht("kasse.bearbeiten")
.input(rechnungFelder.extend({ positionen: z.array(positionFelder).min(1).max(100) }))
.mutation(async ({ ctx, input }) => {
const { positionen, ...felder } = input;
if (felder.faelligAm < felder.datum) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Eine Rechnung kann nicht fällig werden, bevor sie geschrieben wurde.",
});
}
return ctx.db(async (db) => {
const jahr = felder.datum.getFullYear();
// Der Gesamtbetrag ist die Summe der Positionen — gerechnet, nicht
// eingegeben. Ein eingetragener Betrag neben Positionen, die etwas
// anderes ergeben, ist der Fehler, den niemand bemerkt.
const gerechnet = positionen.map((position, stelle) => ({
reihenfolge: stelle,
bezeichnung: position.bezeichnung,
menge: position.menge,
einheit: position.einheit ?? null,
einzelpreis: position.einzelpreis,
gesamtpreis: positionssumme(position.menge, position.einzelpreis),
}));
const betrag = summe(gerechnet.map((position) => position.gesamtpreis));
/**
* Die Nummer vergeben und schreiben — mit Wiederholung.
*
* Höchste lesen und dann schreiben ist ein Wettlauf: schreiben zwei
* Leute gleichzeitig eine Rechnung, lesen beide dieselbe höchste
* Nummer, und die zweite läuft in den Eindeutigkeitsfehler. Statt
* die Vergabe zu sperren — was jede Rechnung hinter jede andere
* stellte — wird der seltene Zusammenstoß erkannt und die nächste
* Nummer genommen. Lückenlos bleibt sie dabei, und genau darauf
* kommt es in der Kassenprüfung an.
*/
let rechnung = null as Awaited<ReturnType<typeof anlegenMit>> | null;
let nummer = "";
async function anlegenMit(vergebene: string) {
return db.rechnung.create({
data: {
...felder,
organisationId: ctx.sitzung.organisationId,
nummer: vergebene,
betrag,
erstelltVonId: ctx.sitzung.personId,
positionen: {
create: gerechnet.map((position) => ({
...position,
organisationId: ctx.sitzung.organisationId,
})),
},
},
include: { positionen: { orderBy: { reihenfolge: "asc" } } },
});
}
for (let versuch = 0; versuch < 5 && !rechnung; versuch += 1) {
const hoechste = await db.rechnung.findFirst({
where: { nummer: { startsWith: `${jahr}-` } },
orderBy: { nummer: "desc" },
select: { nummer: true },
});
nummer = naechsteNummer(jahr, hoechste?.nummer);
try {
rechnung = await anlegenMit(nummer);
} catch (fehler) {
const belegt =
fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2002";
if (!belegt || versuch === 4) throw fehler;
}
}
if (!rechnung) {
throw new TRPCError({
code: "CONFLICT",
message:
"Die Rechnungsnummer war mehrfach hintereinander schon vergeben. " +
"Bitte noch einmal versuchen.",
});
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.ERSTELLT,
entitaet: "Rechnung",
entitaetId: rechnung.id,
beschreibung: `Rechnung ${nummer} an ${felder.empfaenger} über ${alsText(betrag)}`,
nachher: { nummer, empfaenger: felder.empfaenger, betrag: betrag.toFixed(2) },
});
return rechnung;
});
}),
aendern: mitRecht("kasse.bearbeiten")
.input(
rechnungFelder
.partial()
.extend({
id: z.string().uuid(),
positionen: z.array(positionFelder).min(1).max(100).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { id, positionen, ...felder } = input;
return ctx.db(async (db) => {
const vorher = await db.rechnung.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Rechnung gibt es nicht." });
}
if (vorher.stand !== RechnungStand.ENTWURF) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Diese Rechnung ist bereits versendet. Was beim Empfänger liegt, gilt — für " +
"eine Korrektur bitte stornieren und neu schreiben.",
});
}
let betrag = vorher.betrag;
if (positionen) {
const gerechnet = positionen.map((position, stelle) => ({
reihenfolge: stelle,
bezeichnung: position.bezeichnung,
menge: position.menge,
einheit: position.einheit ?? null,
einzelpreis: position.einzelpreis,
gesamtpreis: positionssumme(position.menge, position.einzelpreis),
organisationId: ctx.sitzung.organisationId,
}));
betrag = summe(gerechnet.map((position) => position.gesamtpreis));
await db.rechnungsposition.deleteMany({ where: { rechnungId: id } });
await db.rechnungsposition.createMany({
data: gerechnet.map((position) => ({ ...position, rechnungId: id })),
});
}
const nachher = await db.rechnung.update({
where: { id },
data: { ...felder, betrag },
include: { positionen: { orderBy: { reihenfolge: "asc" } } },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Rechnung",
entitaetId: id,
beschreibung: `Rechnung ${nachher.nummer} geändert`,
vorher: { betrag: vorher.betrag.toFixed(2), empfaenger: vorher.empfaenger },
nachher: { betrag: nachher.betrag.toFixed(2), empfaenger: nachher.empfaenger },
});
return nachher;
});
}),
/**
* Versenden.
*
* Der Schritt, nach dem nichts mehr geändert wird. Das PDF entsteht im
* Hintergrund — es ist der Stand, der beim Empfänger liegt.
*/
versenden: mitRecht("kasse.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) => {
const rechnung = await ctx.db(async (db) => {
const vorher = await db.rechnung.findUnique({
where: { id: input.id },
include: { _count: { select: { positionen: true } } },
});
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Rechnung gibt es nicht." });
}
if (vorher.stand !== RechnungStand.ENTWURF) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Diese Rechnung ist bereits versendet." });
}
if (vorher._count.positionen === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Eine Rechnung ohne Positionen ist keine.",
});
}
const nachher = await db.rechnung.update({
where: { id: input.id },
data: { stand: RechnungStand.VERSENDET },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Rechnung",
entitaetId: input.id,
beschreibung: `Rechnung ${nachher.nummer} versendet`,
vorher: { stand: RechnungStand.ENTWURF },
nachher: { stand: RechnungStand.VERSENDET },
});
return nachher;
});
await beauftrage(AUFTRAG.RECHNUNG_ERZEUGEN, {
organisationId: ctx.sitzung.organisationId,
rechnungId: rechnung.id,
}).catch((fehler) => {
console.error("[rechnung] PDF-Auftrag ließ sich nicht einstellen:", fehler);
});
return rechnung;
}),
/**
* Zahlungseingang.
*
* Erzeugt die Buchung — genau wie beim Beitrag. „Bezahlt" ohne Geld in der
* Kasse wäre eine Behauptung.
*/
bezahlen: mitRecht("kasse.bearbeiten")
.input(
z.object({
id: z.string().uuid(),
kontoId: z.string().uuid(),
kategorieId: z.string().uuid(),
bezahltAm: z.date(),
betrag: z.number().positive().max(1_000_000).optional(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const rechnung = await db.rechnung.findUnique({ where: { id: input.id } });
if (!rechnung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Rechnung gibt es nicht." });
}
if (rechnung.stand === RechnungStand.BEZAHLT) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Diese Rechnung ist bezahlt." });
}
if (rechnung.stand === RechnungStand.STORNIERT) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Diese Rechnung ist storniert — eine Zahlung darauf gehört gegengebucht.",
});
}
const betragWert = input.betrag ?? Number(rechnung.betrag);
const buchung = await db.buchung.create({
data: {
organisationId: ctx.sitzung.organisationId,
kontoId: input.kontoId,
kategorieId: input.kategorieId,
datum: input.bezahltAm,
art: BuchungsArt.EINNAHME,
betrag: betragWert,
verwendungszweck: `Rechnung ${rechnung.nummer} — ${rechnung.empfaenger}`,
belegnummer: rechnung.nummer,
lieferant: rechnung.empfaenger,
erfasstVonId: ctx.sitzung.personId,
},
select: { id: true },
});
const nachher = await db.rechnung.update({
where: { id: input.id },
data: { stand: RechnungStand.BEZAHLT, bezahltAm: input.bezahltAm, buchungId: buchung.id },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Rechnung",
entitaetId: input.id,
beschreibung: `Rechnung ${rechnung.nummer} bezahlt (${alsText(betragWert)})`,
vorher: { stand: rechnung.stand },
nachher: { stand: RechnungStand.BEZAHLT, buchungId: buchung.id },
});
return nachher;
}),
),
mahnen: mitRecht("kasse.bearbeiten")
.input(z.object({ ids: z.array(z.string().uuid()).min(1).max(200) }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const betroffen = await db.rechnung.findMany({
where: {
id: { in: input.ids },
stand: { in: [RechnungStand.VERSENDET, RechnungStand.GEMAHNT] },
},
select: { id: true, mahnstufe: true, nummer: true },
});
const jetzt = new Date();
let gemahnt = 0;
for (const eintrag of betroffen) {
if (eintrag.mahnstufe >= 3) continue;
await db.rechnung.update({
where: { id: eintrag.id },
data: {
stand: RechnungStand.GEMAHNT,
mahnstufe: eintrag.mahnstufe + 1,
gemahntAm: jetzt,
},
});
gemahnt += 1;
}
if (gemahnt > 0) {
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Rechnung",
beschreibung: `${gemahnt} Rechnungen gemahnt`,
nachher: { anzahl: gemahnt, gemahntAm: jetzt },
});
}
return { gemahnt, uebersprungen: betroffen.length - gemahnt };
}),
),
/**
* Stornieren.
*
* Die Rechnung bleibt stehen — durchgestrichen, mit Grund. Eine
* verschwundene Rechnungsnummer ist in der Kassenprüfung eine Lücke, die
* erklärt werden muss.
*/
stornieren: mitRecht("kasse.bearbeiten")
.input(z.object({ id: z.string().uuid(), grund: z.string().trim().min(3).max(500) }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const rechnung = await db.rechnung.findUnique({ where: { id: input.id } });
if (!rechnung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Rechnung gibt es nicht." });
}
if (rechnung.stand === RechnungStand.BEZAHLT) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Diese Rechnung ist bezahlt. Für eine Rückzahlung bitte die Buchung gegenbuchen " +
"und die Rechnung danach stornieren.",
});
}
const nachher = await db.rechnung.update({
where: { id: input.id },
data: {
stand: RechnungStand.STORNIERT,
bemerkung: `${rechnung.bemerkung ? `${rechnung.bemerkung}\n\n` : ""}Storniert: ${input.grund}`,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Rechnung",
entitaetId: input.id,
beschreibung: `Rechnung ${rechnung.nummer} storniert: ${input.grund}`,
vorher: { stand: rechnung.stand },
nachher: { stand: RechnungStand.STORNIERT, grund: input.grund },
});
return nachher;
}),
),
/** Das PDF neu erzeugen — nach einer Änderung an den Wehrdaten etwa. */
pdfErzeugen: mitRecht("kasse.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) => {
await beauftrage(AUFTRAG.RECHNUNG_ERZEUGEN, {
organisationId: ctx.sitzung.organisationId,
rechnungId: input.id,
});
return {
erfolgreich: true,
hinweis: "Die Rechnung wird im Hintergrund erzeugt und steht gleich zum Herunterladen.",
};
}),
});
+45
View File
@@ -22,6 +22,7 @@ import {
zustellen,
} from "@/worker/benachrichtigung";
import { diktatErkennen, veralteteSeitenEinbetten, wikiEinbetten } from "@/worker/ki";
import { pruefberichtErzeugen, rechnungErzeugen, ueberfaelligeMelden } from "@/worker/kasse";
/**
* Hintergrundprozess.
@@ -195,6 +196,40 @@ async function diktatErkennenAuftrag(auftrag: Job<AuftragsDaten>) {
return diktatErkennen(organisationId, diktatId);
}
/** Eine Rechnung als PDF erzeugen (Phase 9). */
async function rechnungErzeugenAuftrag(auftrag: Job<AuftragsDaten>) {
const { organisationId, rechnungId } = auftrag.data as AuftragsDaten & { rechnungId?: string };
if (!rechnungId) {
throw new Error("Rechnungsauftrag ohne Angabe, wofür — rechnungId fehlt.");
}
const ergebnis = await rechnungErzeugen(organisationId, rechnungId);
if (!ergebnis.erzeugt) {
protokoll(`Keine Rechnung für ${rechnungId} — unbekannt oder ohne Wehrdaten.`);
}
return ergebnis;
}
/** Den Kassenprüfbericht als PDF erzeugen (Phase 9). */
async function kassenpruefberichtAuftrag(auftrag: Job<AuftragsDaten>) {
const { organisationId, abschlussId } = auftrag.data as AuftragsDaten & { abschlussId?: string };
if (!abschlussId) {
throw new Error("Berichtsauftrag ohne Angabe, wofür — abschlussId fehlt.");
}
const ergebnis = await pruefberichtErzeugen(organisationId, abschlussId);
if (!ergebnis.erzeugt) {
protokoll(`Kein Kassenprüfbericht für ${abschlussId} — unbekannt.`);
}
return ergebnis;
}
async function aufbewahrungPruefen(auftrag: Job<AuftragsDaten>): Promise<{ entfernt: number }> {
protokoll(`Aufbewahrungsfristen prüfen für Organisation ${auftrag.data.organisationId}`);
@@ -209,6 +244,14 @@ async function aufbewahrungPruefen(auftrag: Job<AuftragsDaten>): Promise<{ entfe
console.error("[worker] Nachträgliches Einbetten fehlgeschlagen:", fehler);
});
// Seit Phase 9: überfällige Rechnungen zählen. Bewusst nur zählen und ins
// Protokoll schreiben — eine Software, die ungefragt mahnt, verschickt
// irgendwann eine Mahnung an den Bürgermeister, weil ein Zahlungseingang
// zwei Tage später verbucht wurde.
await ueberfaelligeMelden(auftrag.data.organisationId).catch((fehler) => {
console.error("[worker] Fälligkeitsprüfung fehlgeschlagen:", fehler);
});
// Ab Phase 8: Audit-Log exportieren und danach abräumen,
// ausgetretene Mitglieder nach PERSON_ANONYMISIERUNG_TAGE anonymisieren.
return { entfernt: benachrichtigungen.entfernt };
@@ -224,6 +267,8 @@ const bearbeiter: Record<string, (auftrag: Job<AuftragsDaten>) => Promise<unknow
[AUFTRAG.BENACHRICHTIGUNG_ERINNERN]: benachrichtigungErinnern,
[AUFTRAG.WIKI_EINBETTEN]: wikiEinbettenAuftrag,
[AUFTRAG.DIKTAT_ERKENNEN]: diktatErkennenAuftrag,
[AUFTRAG.RECHNUNG_ERZEUGEN]: rechnungErzeugenAuftrag,
[AUFTRAG.KASSENPRUEFBERICHT_ERZEUGEN]: kassenpruefberichtAuftrag,
[AUFTRAG.AUFBEWAHRUNG_PRUEFEN]: aufbewahrungPruefen,
};
+305
View File
@@ -0,0 +1,305 @@
import { BuchungsArt, KontoArt } from "@prisma/client";
import { fuerOrganisation } from "@/lib/db";
import { AkteurTyp, AuditAktion, protokolliereIn } from "@/lib/audit";
import { hochladen, objektSchluessel } from "@/lib/storage";
import { bilanz } from "@/lib/kasse/betrag";
import { pruefberichtPdf, rechnungPdf } from "@/lib/kasse/dokumente";
/**
* Die beiden Papiere der Kasse erzeugen.
*
* Im Hintergrund, weil Gotenberg ein paar Sekunden braucht — wer gerade eine
* Rechnung versendet hat, soll dabei nicht warten.
*
* Der Ablauf ist wie bei den Prüfprotokollen aus Phase 2 dreigeteilt: lesen,
* rendern, schreiben. Zwischen Lesen und Schreiben liegt ein Netzaufruf, und
* eine offene Datenbanktransaktion dürfte darauf nicht warten — sie liefe in
* ihr Zeitlimit und gäbe die Verbindung nicht frei.
*
* Beide Vorgänge sind wiederholbar. Ein zweiter Lauf erzeugt das PDF neu und
* ersetzt den Verweis; das ist gewollt, denn nach einer Änderung an den
* Wehrdaten soll sich eine Rechnung neu erzeugen lassen.
*/
function protokoll(nachricht: string, ...weiteres: unknown[]): void {
console.info(`[kasse] ${nachricht}`, ...weiteres);
}
/** Eine Rechnung als PDF. */
export async function rechnungErzeugen(
organisationId: string,
rechnungId: string,
): Promise<{ erzeugt: boolean; dateiId?: string }> {
// --- lesen ----------------------------------------------------------------
const daten = await fuerOrganisation(organisationId, async (db) => {
const rechnung = await db.rechnung.findUnique({
where: { id: rechnungId },
include: { positionen: { orderBy: { reihenfolge: "asc" } } },
});
if (!rechnung) return null;
const organisation = await db.organisation.findUnique({
where: { id: organisationId },
select: {
slug: true,
name: true,
strasse: true,
plz: true,
ort: true,
telefon: true,
email: true,
},
});
// Das erste aktive Bankkonto für den Zahlungshinweis. Eine Rechnung ohne
// Kontoverbindung wird nicht bezahlt — sie wird zurückgerufen.
const bankkonto = await db.kassenkonto.findFirst({
where: { art: KontoArt.BANK, aktiv: true, geloeschtAm: null, iban: { not: null } },
select: { bezeichnung: true, iban: true },
orderBy: { erstelltAm: "asc" },
});
return { rechnung, organisation, bankkonto };
});
if (!daten?.rechnung || !daten.organisation) {
return { erzeugt: false };
}
const { rechnung, organisation, bankkonto } = daten;
// --- rendern --------------------------------------------------------------
const pdf = await rechnungPdf({
wehr: organisation,
nummer: rechnung.nummer,
anlass: rechnung.anlass,
datum: rechnung.datum,
faelligAm: rechnung.faelligAm,
empfaenger: rechnung.empfaenger,
empfaengerZusatz: rechnung.empfaengerZusatz,
strasse: rechnung.strasse,
plz: rechnung.plz,
ort: rechnung.ort,
bemerkung: rechnung.bemerkung,
betrag: rechnung.betrag.toFixed(2),
positionen: rechnung.positionen.map((position) => ({
bezeichnung: position.bezeichnung,
menge: position.menge.toFixed(3).replace(/\.?0+$/, "").replace(".", ","),
einheit: position.einheit,
einzelpreis: position.einzelpreis.toFixed(2),
gesamtpreis: position.gesamtpreis.toFixed(2),
})),
bankkonto,
});
// --- schreiben ------------------------------------------------------------
const dateiname = `rechnung-${rechnung.nummer}.pdf`;
const schluessel = objektSchluessel({
organisationSlug: organisation.slug,
entitaet: "Rechnung",
entitaetId: rechnung.id,
dateiname,
});
const abgelegt = await hochladen(schluessel, pdf, "application/pdf", {
rechnung: rechnung.nummer,
});
const dateiId = await fuerOrganisation(organisationId, async (db) => {
const datei = await db.datei.create({
data: {
organisationId,
bucket: abgelegt.bucket,
objektSchluessel: schluessel,
dateiname,
mimeTyp: "application/pdf",
groesseBytes: abgelegt.groesseBytes,
pruefsumme: abgelegt.pruefsumme,
entitaet: "Rechnung",
entitaetId: rechnung.id,
kategorie: "rechnung",
},
select: { id: true },
});
await db.rechnung.update({ where: { id: rechnung.id }, data: { pdfDateiId: datei.id } });
await protokolliereIn(db, {
organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Rechnung",
entitaetId: rechnung.id,
beschreibung: `Rechnung ${rechnung.nummer} als PDF erzeugt`,
akteurTyp: AkteurTyp.WORKER,
akteurName: "Hintergrundprozess",
});
return datei.id;
});
protokoll(`Rechnung ${rechnung.nummer} erzeugt (${abgelegt.groesseBytes} Bytes)`);
return { erzeugt: true, dateiId };
}
/** Der Kassenprüfbericht als PDF. */
export async function pruefberichtErzeugen(
organisationId: string,
abschlussId: string,
): Promise<{ erzeugt: boolean; dateiId?: string }> {
const daten = await fuerOrganisation(organisationId, async (db) => {
const abschluss = await db.kassenabschluss.findUnique({
where: { id: abschlussId },
include: {
pruefer: { select: { vorname: true, nachname: true } },
zweiterPruefer: { select: { vorname: true, nachname: true } },
posten: {
orderBy: { konto: { bezeichnung: "asc" } },
include: { konto: { select: { bezeichnung: true, art: true } } },
},
},
});
if (!abschluss) return null;
const organisation = await db.organisation.findUnique({
where: { id: organisationId },
select: { slug: true, name: true, ort: true },
});
const buchungen = await db.buchung.findMany({
where: { datum: { gte: abschluss.vonAm, lte: abschluss.bisAm } },
select: { art: true, betrag: true },
});
return { abschluss, organisation, buchungen };
});
if (!daten?.abschluss || !daten.organisation) {
return { erzeugt: false };
}
const { abschluss, organisation, buchungen } = daten;
const bewegung = bilanz(buchungen);
const name = (person: { vorname: string; nachname: string } | null) =>
person ? `${person.vorname} ${person.nachname}` : null;
const pdf = await pruefberichtPdf({
wehr: organisation,
vonAm: abschluss.vonAm,
bisAm: abschluss.bisAm,
geprueftAm: abschluss.geprueftAm,
pruefer: name(abschluss.pruefer),
zweiterPruefer: name(abschluss.zweiterPruefer),
bestandLautBuch: abschluss.bestandLautBuch.toFixed(2),
gezaehlterBestand: abschluss.gezaehlterBestand.toFixed(2),
differenz: abschluss.differenz.toFixed(2),
erlaeuterung: abschluss.erlaeuterung,
posten: abschluss.posten.map((posten) => ({
bezeichnung: posten.konto.bezeichnung,
art: posten.konto.art,
bestandLautBuch: posten.bestandLautBuch.toFixed(2),
gezaehlterBestand: posten.gezaehlterBestand.toFixed(2),
differenz: posten.differenz.toFixed(2),
bemerkung: posten.bemerkung,
})),
einnahmen: bewegung.einnahmen.toFixed(2),
ausgaben: bewegung.ausgaben.toFixed(2),
anzahlBuchungen: buchungen.length,
});
const zeitraum = `${abschluss.vonAm.toISOString().slice(0, 10)}-bis-${abschluss.bisAm
.toISOString()
.slice(0, 10)}`;
const dateiname = `kassenpruefbericht-${zeitraum}.pdf`;
const schluessel = objektSchluessel({
organisationSlug: organisation.slug,
entitaet: "Kassenabschluss",
entitaetId: abschluss.id,
dateiname,
});
const abgelegt = await hochladen(schluessel, pdf, "application/pdf", {
zeitraum,
});
const dateiId = await fuerOrganisation(organisationId, async (db) => {
const datei = await db.datei.create({
data: {
organisationId,
bucket: abgelegt.bucket,
objektSchluessel: schluessel,
dateiname,
mimeTyp: "application/pdf",
groesseBytes: abgelegt.groesseBytes,
pruefsumme: abgelegt.pruefsumme,
entitaet: "Kassenabschluss",
entitaetId: abschluss.id,
kategorie: "kassenpruefbericht",
},
select: { id: true },
});
// Der Verweis auf den Bericht ist die einzige Änderung, die der Trigger an
// einem festgeschriebenen Abschluss zulässt: das PDF entsteht im
// Hintergrund und damit erst danach — es nachzutragen ist keine Änderung
// des Abschlusses, sondern seine Fertigstellung.
await db.kassenabschluss.update({
where: { id: abschluss.id },
data: { berichtDateiId: datei.id },
});
await protokolliereIn(db, {
organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Kassenabschluss",
entitaetId: abschluss.id,
beschreibung: "Kassenprüfbericht als PDF erzeugt",
akteurTyp: AkteurTyp.WORKER,
akteurName: "Hintergrundprozess",
kontext: { dateiId: datei.id },
});
return datei.id;
});
protokoll(`Kassenprüfbericht ${zeitraum} erzeugt (${abgelegt.groesseBytes} Bytes)`);
return { erzeugt: true, dateiId };
}
/**
* Rechnungen mahnen, die überfällig sind.
*
* Läuft im nächtlichen Lauf mit und **mahnt nicht selbst** — es wird nur
* vermerkt, dass etwas fällig ist. Eine Software, die ungefragt Mahnungen
* verschickt, verschickt irgendwann eine an den Bürgermeister, weil ein
* Zahlungseingang zwei Tage später verbucht wurde.
*/
export async function ueberfaelligeMelden(
organisationId: string,
heute = new Date(),
): Promise<{ rechnungen: number; betrag: string }> {
return fuerOrganisation(organisationId, async (db) => {
const offen = await db.rechnung.findMany({
where: { stand: { in: ["VERSENDET", "GEMAHNT"] }, faelligAm: { lt: heute } },
select: { nummer: true, betrag: true, faelligAm: true, empfaenger: true },
});
if (offen.length === 0) return { rechnungen: 0, betrag: "0.00" };
const summe = offen.reduce(
(bisher, rechnung) => bisher.plus(rechnung.betrag),
offen[0]!.betrag.minus(offen[0]!.betrag),
);
protokoll(`${offen.length} überfällige Rechnungen über ${summe.toFixed(2)} €`);
return { rechnungen: offen.length, betrag: summe.toFixed(2) };
});
}
/** Die Buchungsarten, damit der Import sie nicht selbst herleiten muss. */
export const BUCHUNGSARTEN = [BuchungsArt.EINNAHME, BuchungsArt.AUSGABE] as const;