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
+153
View File
@@ -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("—");
});
});