Phase 9: Kassenverwaltung
Kassenbuch, Mitgliedsbeitraege, Rechnungen an Dritte, Kassenpruefung und die Auswertungen fuer die Jahreshauptversammlung. Die drei Festlegungen aus Phase-9.md sitzen in der Datenbank, nicht im Router: eine festgeschriebene Buchung ist unveraenderbar, ein geprueftes Jahr nimmt kein Datum mehr an, und gerechnet wird mit Prisma.Decimal. Korrigiert wird ueber eine Gegenbuchung mit Verweis - beide Buchungen bleiben stehen, damit im Kassenbuch zu sehen ist, dass etwas berichtigt wurde. Neun Modelle, drei Trigger, zwoelf CHECKs, sechs Router, fuenf Ansichten. Dazu der Bankimport fuer CAMT.053 und MT940 ohne Fremdbibliothek, der Kategorievorschlag aus dem eigenen Buchungsbestand und die Bruecke von Phase 8: aus einem gegengezeichneten Beleg wird eine Buchung, das Foto haengt danach an ihr. Nebenbei behoben: die Rechnungsnummer war ein Wettlauf, und eine Organisation liess sich nicht mehr loeschen, sobald sie eine festgeschriebene Buchung hatte - der Trigger kannte den Aufbewahrungsschalter nicht. 562 Unit-Tests und 239 E2E-Tests gruen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d70acad4cd
commit
4165334c5a
@@ -0,0 +1,153 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
alsText,
|
||||
alsWirkung,
|
||||
bestand,
|
||||
betrag,
|
||||
betragLesen,
|
||||
bilanz,
|
||||
positionssumme,
|
||||
runden,
|
||||
summe,
|
||||
} from "@/lib/kasse/betrag";
|
||||
|
||||
/**
|
||||
* Das Rechnen mit Geld.
|
||||
*
|
||||
* Der Grund für diese Datei ist eine einzige Zeile, die es hier nicht geben
|
||||
* darf: `0.1 + 0.2`. In Fließkomma ergibt das 0.30000000000000004, und in
|
||||
* einer Kasse ist das kein Rundungsfehler, sondern eine Differenz in der
|
||||
* Kassenprüfung, die niemand erklären kann. Deshalb rechnet die gesamte
|
||||
* Kasse mit `Prisma.Decimal`, und deshalb prüft dieser Test genau das nach.
|
||||
*/
|
||||
|
||||
describe("Beträge lesen", () => {
|
||||
it("nimmt Zahl, Text und Decimal entgegen", () => {
|
||||
expect(betrag(12.5).toFixed(2)).toBe("12.50");
|
||||
expect(betrag("12.50").toFixed(2)).toBe("12.50");
|
||||
expect(betrag(new Prisma.Decimal("12.5")).toFixed(2)).toBe("12.50");
|
||||
});
|
||||
|
||||
it("macht aus fehlenden Angaben null Euro", () => {
|
||||
expect(betrag(null).toFixed(2)).toBe("0.00");
|
||||
expect(betrag(undefined).toFixed(2)).toBe("0.00");
|
||||
expect(betrag("").toFixed(2)).toBe("0.00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Summe", () => {
|
||||
it("rechnet ohne Fließkommafehler", () => {
|
||||
// Die Probe aufs Exempel: in `number` ergäbe das 0.30000000000000004.
|
||||
expect(summe(["0.10", "0.20"]).toFixed(2)).toBe("0.30");
|
||||
});
|
||||
|
||||
it("addiert eine ganze Kassenseite auf den Cent", () => {
|
||||
const werte = ["1284.50", "412.90", "2340.00", "868.00", "1150.00", "624.00", "398.20", "36.00"];
|
||||
expect(summe(werte).toFixed(2)).toBe("7113.60");
|
||||
});
|
||||
|
||||
it("ergibt bei leerer Liste null Euro und nicht undefined", () => {
|
||||
expect(summe([]).toFixed(2)).toBe("0.00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Runden", () => {
|
||||
it("rundet kaufmännisch — ab der halben Stelle nach oben", () => {
|
||||
expect(runden(new Prisma.Decimal("2.345")).toFixed(2)).toBe("2.35");
|
||||
expect(runden(new Prisma.Decimal("2.344")).toFixed(2)).toBe("2.34");
|
||||
// Und nicht bankers rounding: 2,355 wird 2,36, nicht 2,36-oder-2,35.
|
||||
expect(runden(new Prisma.Decimal("2.355")).toFixed(2)).toBe("2.36");
|
||||
expect(runden(new Prisma.Decimal("2.365")).toFixed(2)).toBe("2.37");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Richtung", () => {
|
||||
it("macht aus Art und positivem Betrag ein Vorzeichen", () => {
|
||||
expect(alsWirkung("EINNAHME", "100.00").toFixed(2)).toBe("100.00");
|
||||
expect(alsWirkung("AUSGABE", "100.00").toFixed(2)).toBe("-100.00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bestand", () => {
|
||||
it("ist Anfangsbestand plus die Wirkung aller Buchungen", () => {
|
||||
const wert = bestand("250.00", [
|
||||
{ art: "EINNAHME", betrag: "3860.00" },
|
||||
{ art: "AUSGABE", betrag: "1420.60" },
|
||||
]);
|
||||
|
||||
expect(wert.toFixed(2)).toBe("2689.40");
|
||||
});
|
||||
|
||||
it("darf negativ werden — eine überzogene Barkasse ist ein Befund, kein Fehler", () => {
|
||||
expect(bestand("10.00", [{ art: "AUSGABE", betrag: "50.00" }]).toFixed(2)).toBe("-40.00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bilanz", () => {
|
||||
it("trennt Einnahmen und Ausgaben und bildet den Saldo", () => {
|
||||
const werte = bilanz([
|
||||
{ art: "EINNAHME", betrag: "6500.00" },
|
||||
{ art: "EINNAHME", betrag: "780.00" },
|
||||
{ art: "AUSGABE", betrag: "1284.50" },
|
||||
]);
|
||||
|
||||
expect(werte.einnahmen.toFixed(2)).toBe("7280.00");
|
||||
expect(werte.ausgaben.toFixed(2)).toBe("1284.50");
|
||||
expect(werte.saldo.toFixed(2)).toBe("5995.50");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Positionssumme", () => {
|
||||
it("rechnet Viertelstunden auf den Cent", () => {
|
||||
// 0,25 × 47,50 = 11,875 — gerundet 11,88 und nicht 11,87.
|
||||
expect(positionssumme("0.25", "47.50").toFixed(2)).toBe("11.88");
|
||||
});
|
||||
|
||||
it("rechnet ganze Mengen ohne Rest", () => {
|
||||
expect(positionssumme(9, "24.00").toFixed(2)).toBe("216.00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Eingaben lesen", () => {
|
||||
it("versteht die deutsche Schreibweise", () => {
|
||||
expect(betragLesen("1.234,56")?.toFixed(2)).toBe("1234.56");
|
||||
expect(betragLesen("1.234,56 €")?.toFixed(2)).toBe("1234.56");
|
||||
expect(betragLesen("12,50 EUR")?.toFixed(2)).toBe("12.50");
|
||||
});
|
||||
|
||||
it("versteht die englische Schreibweise", () => {
|
||||
expect(betragLesen("1,234.56")?.toFixed(2)).toBe("1234.56");
|
||||
expect(betragLesen("$1234.56")?.toFixed(2)).toBe("1234.56");
|
||||
});
|
||||
|
||||
it("liest auch Beträge ohne Trennzeichen", () => {
|
||||
expect(betragLesen("42")?.toFixed(2)).toBe("42.00");
|
||||
expect(betragLesen("-42,50")?.toFixed(2)).toBe("-42.50");
|
||||
});
|
||||
|
||||
it("gibt bei Unlesbarem null zurück — und nicht null Euro", () => {
|
||||
// Der Unterschied ist der ganze Punkt: null Euro ist eine Angabe,
|
||||
// unlesbar ist eine Rückfrage an den, der es eingetippt hat.
|
||||
expect(betragLesen("etwa zwanzig")).toBeNull();
|
||||
expect(betragLesen("")).toBeNull();
|
||||
expect(betragLesen("1,2,3")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Beträge schreiben", () => {
|
||||
it("setzt Tausenderpunkte und ein Komma", () => {
|
||||
expect(alsText("1234.56")).toBe("1.234,56 €");
|
||||
expect(alsText("12.50")).toBe("12,50 €");
|
||||
expect(alsText("1234567.89")).toBe("1.234.567,89 €");
|
||||
});
|
||||
|
||||
it("verwendet das typografische Minus", () => {
|
||||
expect(alsText("-3.20")).toBe("−3,20 €");
|
||||
});
|
||||
|
||||
it("macht aus fehlenden Angaben einen Gedankenstrich", () => {
|
||||
expect(alsText(null)).toBe("—");
|
||||
expect(alsText(undefined)).toBe("—");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user