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
+653
View File
@@ -21,6 +21,9 @@
* Zustellkanäle: niemand wird ungefragt benachrichtigt, auch nicht im Seed.
* Ab Phase 8 dazu: vier Wiki-Seiten mit eigenem Text — Prüffristen,
* Atemschutz-Merkblatt, Schlauchpflege und Alarmierungsordnung.
* Ab Phase 9 dazu: das Kassenjahr — drei Konten, der Standardkontenrahmen,
* gut dreißig Buchungen über zwei Jahre, die Mitgliedsbeiträge mit Rückständen,
* zwei Rechnungen und die festgeschriebene Kassenprüfung des Vorjahres.
*
* Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang,
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
@@ -36,11 +39,14 @@ import {
Alarmierungsart,
AnwesenheitAnlass,
AusweisTokenTyp,
BeitragStand,
BuchungsArt,
ChecklistenpunktTyp,
EinsatzFunktion,
EinsatzKategorie,
EinsatzStatus,
Dienstgrad,
KontoArt,
DruckArt,
EreignisArt,
FahrtZweck,
@@ -58,6 +64,8 @@ import {
PruefungStatus,
QualifikationsArt,
RolleSchluessel,
RechnungsAnlass,
RechnungStand,
SignaturRolle,
HydrantStatus,
HydrantTyp,
@@ -3247,6 +3255,648 @@ async function wikiAnlegen(organisationId: string): Promise<void> {
);
}
// -----------------------------------------------------------------------------
// Phase 9 — Kassenverwaltung
//
// Ein Kassenjahr, wie es eine Wehr wirklich hat: drei Konten, der
// Standardkontenrahmen, gut dreißig Buchungen über zwei Jahre, die
// Mitgliedsbeiträge, zwei Rechnungen und die abgeschlossene Kassenprüfung
// des Vorjahres.
//
// Die Reihenfolge ist hier nicht beliebig, und das ist kein Zufall des
// Skripts, sondern die Absicherung selbst: sobald der Abschluss für das
// Vorjahr festgeschrieben ist, lehnt ein Trigger jede weitere Buchung mit
// einem Datum in diesem Zeitraum ab. Erst die Buchungen, dann der Abschluss —
// wer es umdreht, bekommt vom Seed dieselbe Fehlermeldung wie der Kassier,
// der drei Wochen nach der Prüfung noch eine Quittung findet.
// -----------------------------------------------------------------------------
async function kassendatenAnlegen(organisationId: string): Promise<void> {
const vorhanden = await prisma.kassenkonto.count({ where: { organisationId } });
if (vorhanden > 0) {
log("Kassendaten bereits vorhanden — übersprungen.");
return;
}
const { STANDARDKATEGORIEN } = await import("../src/lib/kasse/vorschlag");
const jahr = new Date().getUTCFullYear();
const vorjahr = jahr - 1;
// --- Konten ---------------------------------------------------------------
const bar = await prisma.kassenkonto.create({
data: {
organisationId,
bezeichnung: "Barkasse",
art: KontoArt.BAR,
anfangsbestand: "250.00",
bemerkung: "Kassette im Kommandantenzimmer. Wechselgeld für Feste.",
},
});
const giro = await prisma.kassenkonto.create({
data: {
organisationId,
bezeichnung: "Girokonto Raiffeisenbank",
art: KontoArt.BANK,
iban: "DE02701500000000594937",
bic: "SSKMDEMM",
anfangsbestand: "4800.00",
},
});
const ruecklage = await prisma.kassenkonto.create({
data: {
organisationId,
bezeichnung: "Rücklage Fahrzeugbeschaffung",
art: KontoArt.RUECKLAGE,
iban: "DE02120300000000202051",
anfangsbestand: "12000.00",
bemerkung: "Angespart für den Ersatz des MTW. Wird nicht laufend bewegt.",
},
});
// --- Kontenrahmen ---------------------------------------------------------
//
// Zwei Durchgänge: erst die Oberkategorien, dann die Unterkategorien mit
// ihrem Verweis. Anders ginge es nicht, der Baum verweist auf sich selbst.
const kategorien = new Map<string, string>();
for (const eintrag of STANDARDKATEGORIEN.filter((k) => !k.oberkategorie)) {
const angelegt = await prisma.buchungskategorie.create({
data: { organisationId, name: eintrag.name, art: eintrag.art },
});
kategorien.set(eintrag.name, angelegt.id);
}
for (const eintrag of STANDARDKATEGORIEN.filter((k) => k.oberkategorie)) {
const angelegt = await prisma.buchungskategorie.create({
data: {
organisationId,
name: eintrag.name,
art: eintrag.art,
oberkategorieId: kategorien.get(eintrag.oberkategorie!) ?? null,
},
});
kategorien.set(eintrag.name, angelegt.id);
}
const kategorie = (name: string): string => {
const id = kategorien.get(name);
if (!id) throw new Error(`Kategorie fehlt im Standardkontenrahmen: ${name}`);
return id;
};
const kassier = await prisma.person.findFirst({
where: {
organisationId,
rollen: { some: { rolle: { schluessel: RolleSchluessel.KASSIER } } },
},
select: { id: true },
});
// --- Buchungen ------------------------------------------------------------
//
// Das Vorjahr vollständig und festgeschrieben, das laufende Jahr bis in den
// Sommer und noch offen. So zeigt die Ansicht beide Zustände: das
// geschlossene Buch und das, in dem noch gearbeitet wird.
type Zeile = {
konto: string;
monat: number;
tag: number;
art: BuchungsArt;
betrag: string;
kategorie: string;
zweck: string;
gegenueber?: string;
beleg?: string;
};
const vorjahrszeilen: Zeile[] = [
{
konto: giro.id,
monat: 1,
tag: 15,
art: BuchungsArt.EINNAHME,
betrag: "6500.00",
kategorie: "Zuschüsse",
zweck: "Gemeindezuschuss laufender Betrieb",
gegenueber: "Gemeinde Musterhausen",
beleg: "Z-2024-01",
},
{
konto: giro.id,
monat: 2,
tag: 3,
art: BuchungsArt.AUSGABE,
betrag: "1284.50",
kategorie: "Versicherungen",
zweck: "Jahresprämie Fahrzeug- und Unfallversicherung",
gegenueber: "Bayerische Versicherungskammer",
},
{
konto: giro.id,
monat: 3,
tag: 12,
art: BuchungsArt.AUSGABE,
betrag: "412.90",
kategorie: "Kraftstoff",
zweck: "Diesel LF 20, Sammelrechnung Februar",
gegenueber: "Tankstelle Musterhausen",
},
{
konto: giro.id,
monat: 4,
tag: 8,
art: BuchungsArt.AUSGABE,
betrag: "2340.00",
kategorie: "Atemschutz",
zweck: "Zwei Pressluftatmer, Ersatzbeschaffung",
gegenueber: "Dräger Safety",
beleg: "RE-2024-114",
},
{
konto: giro.id,
monat: 5,
tag: 22,
art: BuchungsArt.AUSGABE,
betrag: "868.00",
kategorie: "Prüfungen",
zweck: "Wiederkehrende Prüfung Drehleiter",
gegenueber: "TÜV Süd",
},
{
konto: bar.id,
monat: 6,
tag: 15,
art: BuchungsArt.EINNAHME,
betrag: "3860.00",
kategorie: "Veranstaltungen",
zweck: "Maifest, Getränke- und Speisenverkauf",
},
{
konto: bar.id,
monat: 6,
tag: 15,
art: BuchungsArt.AUSGABE,
betrag: "1420.60",
kategorie: "Verpflegung",
zweck: "Maifest, Einkauf Getränke und Fleisch",
gegenueber: "Getränke Hofmann",
},
{
konto: giro.id,
monat: 7,
tag: 2,
art: BuchungsArt.AUSGABE,
betrag: "1150.00",
kategorie: "Schutzkleidung",
zweck: "Vier Einsatzjacken, Ersatz nach Verschleiß",
gegenueber: "Feuerwehrbedarf Barth",
},
{
konto: giro.id,
monat: 9,
tag: 18,
art: BuchungsArt.AUSGABE,
betrag: "624.00",
kategorie: "Ausbildung",
zweck: "Lehrgangsgebühren Maschinistenlehrgang",
gegenueber: "Landkreis Musterkreis",
},
{
konto: giro.id,
monat: 10,
tag: 30,
art: BuchungsArt.AUSGABE,
betrag: "398.20",
kategorie: "Wasser und Strom",
zweck: "Abschlagszahlung Strom Gerätehaus",
gegenueber: "Stadtwerke Musterhausen",
},
{
konto: giro.id,
monat: 11,
tag: 14,
art: BuchungsArt.EINNAHME,
betrag: "780.00",
kategorie: "Spenden",
zweck: "Spenden aus der Haussammlung",
},
{
konto: giro.id,
monat: 12,
tag: 20,
art: BuchungsArt.AUSGABE,
betrag: "36.00",
kategorie: "Bankgebühren",
zweck: "Kontoführung viertes Quartal",
},
];
const laufendeZeilen: Zeile[] = [
{
konto: giro.id,
monat: 1,
tag: 20,
art: BuchungsArt.EINNAHME,
betrag: "6500.00",
kategorie: "Zuschüsse",
zweck: "Gemeindezuschuss laufender Betrieb",
gegenueber: "Gemeinde Musterhausen",
},
{
konto: giro.id,
monat: 2,
tag: 5,
art: BuchungsArt.AUSGABE,
betrag: "1341.80",
kategorie: "Versicherungen",
zweck: "Jahresprämie Fahrzeug- und Unfallversicherung",
gegenueber: "Bayerische Versicherungskammer",
},
{
konto: giro.id,
monat: 2,
tag: 28,
art: BuchungsArt.AUSGABE,
betrag: "487.40",
kategorie: "Kraftstoff",
zweck: "Diesel LF 20 und MTW, Sammelrechnung Februar",
gegenueber: "Tankstelle Musterhausen",
},
{
konto: giro.id,
monat: 3,
tag: 14,
art: BuchungsArt.AUSGABE,
betrag: "1890.00",
kategorie: "Reparatur",
zweck: "Kupplung MTW, Werkstattrechnung",
gegenueber: "Werkstatt Müller GmbH",
beleg: "RE-4471",
},
{
konto: giro.id,
monat: 4,
tag: 3,
art: BuchungsArt.AUSGABE,
betrag: "212.00",
kategorie: "Verwaltung",
zweck: "Büromaterial und Druckerpatronen",
gegenueber: "Schreibwaren Ludwig",
},
{
konto: bar.id,
monat: 5,
tag: 1,
art: BuchungsArt.EINNAHME,
betrag: "4120.00",
kategorie: "Veranstaltungen",
zweck: "Maifest, Getränke- und Speisenverkauf",
},
{
konto: bar.id,
monat: 5,
tag: 1,
art: BuchungsArt.AUSGABE,
betrag: "1588.30",
kategorie: "Verpflegung",
zweck: "Maifest, Einkauf Getränke und Fleisch",
gegenueber: "Getränke Hofmann",
},
{
konto: bar.id,
monat: 5,
tag: 6,
art: BuchungsArt.AUSGABE,
betrag: "2500.00",
kategorie: "Verwaltung",
zweck: "Einzahlung Barkasse auf das Girokonto nach dem Maifest",
},
{
konto: giro.id,
monat: 5,
tag: 6,
art: BuchungsArt.EINNAHME,
betrag: "2500.00",
kategorie: "Veranstaltungen",
zweck: "Einzahlung aus der Barkasse nach dem Maifest",
},
{
konto: giro.id,
monat: 6,
tag: 11,
art: BuchungsArt.AUSGABE,
betrag: "742.00",
kategorie: "Prüfungen",
zweck: "Schlauchprüfung, 40 Stück B und C",
gegenueber: "Schlauchpflege Nordbayern",
},
{
konto: giro.id,
monat: 6,
tag: 25,
art: BuchungsArt.AUSGABE,
betrag: "36.00",
kategorie: "Bankgebühren",
zweck: "Kontoführung zweites Quartal",
},
{
konto: ruecklage.id,
monat: 7,
tag: 1,
art: BuchungsArt.EINNAHME,
betrag: "3000.00",
kategorie: "Zuschüsse",
zweck: "Jährliche Zuführung zur Fahrzeugrücklage",
gegenueber: "Gemeinde Musterhausen",
},
];
async function buchungAnlegen(zeile: Zeile, festgeschrieben: boolean, buchungsjahr: number) {
return prisma.buchung.create({
data: {
organisationId,
kontoId: zeile.konto,
kategorieId: kategorie(zeile.kategorie),
datum: datum(buchungsjahr, zeile.monat, zeile.tag),
art: zeile.art,
betrag: zeile.betrag,
verwendungszweck: zeile.zweck,
lieferant: zeile.gegenueber ?? null,
belegnummer: zeile.beleg ?? null,
erfasstVonId: kassier?.id ?? null,
festgeschriebenAm: festgeschrieben ? datum(vorjahr + 1, 2, 10) : null,
},
});
}
for (const zeile of vorjahrszeilen) {
await buchungAnlegen(zeile, true, vorjahr);
}
for (const zeile of laufendeZeilen) {
await buchungAnlegen(zeile, false, jahr);
}
// --- Beiträge -------------------------------------------------------------
const satzAktive = await prisma.beitragssatz.create({
data: {
organisationId,
bezeichnung: "Aktive und Passive",
betrag: "25.00",
abteilung: null,
abJahr: vorjahr,
},
});
const satzJugend = await prisma.beitragssatz.create({
data: {
organisationId,
bezeichnung: "Jugendfeuerwehr",
betrag: "10.00",
abteilung: "Jugendfeuerwehr",
abJahr: vorjahr,
},
});
const mitglieder = await prisma.person.findMany({
where: { organisationId, aktiv: true, austrittsdatum: null },
orderBy: { mitgliedsnummer: "asc" },
select: { id: true, abteilung: true },
});
const beitragsKategorie = kategorie("Mitgliedsbeiträge");
// Die meisten haben bezahlt, zwei stehen offen, einer ist gemahnt, einem
// wurde erlassen. Das ist die Verteilung, die eine Wehr im Herbst wirklich
// vor sich hat — und der Grund, warum es die Rückstandsliste gibt.
for (const [stelle, person] of mitglieder.entries()) {
const jugend = person.abteilung === "Jugendfeuerwehr";
const satz = jugend ? satzJugend : satzAktive;
const betrag = jugend ? "10.00" : "25.00";
let buchungId: string | null = null;
let stand: BeitragStand = BeitragStand.BEZAHLT;
let bezahltAm: Date | null = datum(jahr, 2, 12 + (stelle % 10));
let mahnstufe = 0;
let gemahntAm: Date | null = null;
let erlassGrund: string | null = null;
if (stelle % 7 === 3) {
stand = BeitragStand.OFFEN;
bezahltAm = null;
} else if (stelle % 11 === 5) {
stand = BeitragStand.GEMAHNT;
bezahltAm = null;
mahnstufe = 1;
gemahntAm = datum(jahr, 6, 30);
} else if (stelle % 13 === 9) {
stand = BeitragStand.ERLASSEN;
bezahltAm = null;
erlassGrund = "Längere Erkrankung, Beschluss der Kommandantschaft vom 12.03.";
}
if (stand === BeitragStand.BEZAHLT && bezahltAm) {
const buchung = await prisma.buchung.create({
data: {
organisationId,
kontoId: giro.id,
kategorieId: beitragsKategorie,
datum: bezahltAm,
art: BuchungsArt.EINNAHME,
betrag,
verwendungszweck: `Mitgliedsbeitrag ${jahr}`,
personId: person.id,
erfasstVonId: kassier?.id ?? null,
},
});
buchungId = buchung.id;
}
await prisma.beitrag.create({
data: {
organisationId,
personId: person.id,
jahr,
satzId: satz.id,
betrag,
stand,
buchungId,
bezahltAm,
mahnstufe,
gemahntAm,
erlassGrund,
},
});
}
// --- Rechnungen -----------------------------------------------------------
const kostenersatz = kategorie("Kostenersatz");
const fehlalarmBuchung = await prisma.buchung.create({
data: {
organisationId,
kontoId: giro.id,
kategorieId: kostenersatz,
datum: datum(jahr, 4, 28),
art: BuchungsArt.EINNAHME,
betrag: "612.00",
verwendungszweck: `Zahlungseingang Rechnung ${jahr}-0001`,
lieferant: "Möbelhaus Sonnleitner GmbH",
erfasstVonId: kassier?.id ?? null,
},
});
await prisma.rechnung.create({
data: {
organisationId,
nummer: `${jahr}-0001`,
anlass: RechnungsAnlass.FEHLALARM,
stand: RechnungStand.BEZAHLT,
empfaenger: "Möbelhaus Sonnleitner GmbH",
strasse: "Industriestraße 14",
plz: "91315",
ort: "Musterhausen",
datum: datum(jahr, 3, 28),
faelligAm: datum(jahr, 4, 25),
betrag: "612.00",
bemerkung:
"Fehlalarm der Brandmeldeanlage am 26.03., verursacht durch Staub bei Umbauarbeiten. " +
"Kostenersatz nach Art. 28 Abs. 2 Nr. 4 BayFwG.",
buchungId: fehlalarmBuchung.id,
bezahltAm: datum(jahr, 4, 28),
erstelltVonId: kassier?.id ?? null,
positionen: {
create: [
{
organisationId,
reihenfolge: 0,
bezeichnung: "Einsatz LF 20, angefangene Stunde",
menge: "1",
einheit: "Std.",
einzelpreis: "396.00",
gesamtpreis: "396.00",
},
{
organisationId,
reihenfolge: 1,
bezeichnung: "Einsatzkräfte, je angefangene Stunde",
menge: "9",
einheit: "Pers.",
einzelpreis: "24.00",
gesamtpreis: "216.00",
},
],
},
},
});
await prisma.rechnung.create({
data: {
organisationId,
nummer: `${jahr}-0002`,
anlass: RechnungsAnlass.VERMIETUNG,
stand: RechnungStand.VERSENDET,
empfaenger: "Trachtenverein Almrausch Musterhausen e. V.",
empfaengerZusatz: "z. Hd. Frau Vorstand Berger",
strasse: "Dorfplatz 2",
plz: "91315",
ort: "Musterhausen",
datum: datum(jahr, 6, 20),
faelligAm: datum(jahr, 7, 18),
betrag: "180.00",
bemerkung: "Überlassung des Schulungsraums für die Jahresversammlung am 14.06.",
erstelltVonId: kassier?.id ?? null,
positionen: {
create: [
{
organisationId,
reihenfolge: 0,
bezeichnung: "Nutzung Schulungsraum, Tagespauschale",
menge: "1",
einheit: "Tag",
einzelpreis: "150.00",
gesamtpreis: "150.00",
},
{
organisationId,
reihenfolge: 1,
bezeichnung: "Reinigung",
menge: "1",
einheit: "pauschal",
einzelpreis: "30.00",
gesamtpreis: "30.00",
},
],
},
},
});
// --- Kassenprüfung des Vorjahres -----------------------------------------
//
// Zuletzt, und das ist der Punkt: ab hier ist der Zeitraum zu. Jede weitere
// Buchung mit einem Datum aus dem Vorjahr wird von der Datenbank abgelehnt.
const pruefer = await prisma.person.findMany({
where: { organisationId, aktiv: true, austrittsdatum: null },
orderBy: { mitgliedsnummer: "desc" },
take: 2,
select: { id: true },
});
// Die Zahlen sind nicht erfunden, sondern die Summe der Buchungen oben:
// Barkasse 250,00 + 3.860,00 − 1.420,60, Giro 4.800,00 + 7.280,00 − 7.113,60,
// Rücklage unberührt. Ein Seed mit erfundenen Beständen wäre genau der
// Fall, den die Kassenprüfung finden soll.
const vorjahrsposten = [
{ konto: bar.id, buch: "2689.40", gezaehlt: "2686.20" },
{ konto: giro.id, buch: "4966.40", gezaehlt: "4966.40" },
{ konto: ruecklage.id, buch: "12000.00", gezaehlt: "12000.00" },
];
const abschluss = await prisma.kassenabschluss.create({
data: {
organisationId,
vonAm: datum(vorjahr, 1, 1),
bisAm: datum(vorjahr, 12, 31),
bestandLautBuch: "19655.80",
gezaehlterBestand: "19652.60",
differenz: "-3.20",
erlaeuterung:
"In der Barkasse fehlen 3,20 €. Zurückzuführen auf das Maifest: das Wechselgeld " +
"wurde am Abend gezählt, ein Beleg über den Nachkauf von Eis fehlt. Die Prüfer " +
"halten den Betrag für erklärbar und schlagen Entlastung vor.",
prueferId: pruefer[0]?.id ?? null,
zweiterPrueferId: pruefer[1]?.id ?? null,
geprueftAm: datum(vorjahr + 1, 2, 8),
festgeschriebenAm: datum(vorjahr + 1, 2, 10),
posten: {
create: vorjahrsposten.map((posten) => ({
organisationId,
kontoId: posten.konto,
bestandLautBuch: posten.buch,
gezaehlterBestand: posten.gezaehlt,
differenz: (Number(posten.gezaehlt) - Number(posten.buch)).toFixed(2),
})),
},
},
});
log(
`Kasse: 3 Konten, ${STANDARDKATEGORIEN.length} Kategorien, ` +
`${vorjahrszeilen.length + laufendeZeilen.length} Buchungen, ` +
`${mitglieder.length} Beiträge, 2 Rechnungen und die Kassenprüfung ${vorjahr} ` +
`(festgeschrieben — der Zeitraum ist zu, Abschluss ${abschluss.id.slice(0, 8)})`,
);
}
// -----------------------------------------------------------------------------
// Ablauf
// -----------------------------------------------------------------------------
@@ -3293,6 +3943,9 @@ async function main(): Promise<void> {
// Phase 8 — Lokale KI
await wikiAnlegen(organisation.id);
// Phase 9 — Kassenverwaltung
await kassendatenAnlegen(organisation.id);
log("Fertig.");
log("");
log(`Wehr: ${ORGANISATION_NAME} (${ORGANISATION_SLUG})`);