feat(rechnungen): Rabatt je Position, Reverse Charge, Ablauf-Regel (#037)
Eine echte Proxmox-Rechnung zeigt je Subscription-Key eine Zeile mit Listenpreis und eine mit "Client Discount". Unser Modell hing den Rabatt an die Rechnung - bei einer Sammelrechnung ueber zwei Hosts liesse er sich damit keinem Host zuordnen, und die Marge je Host waere falsch. Schlimmer: Er wurde nirgends abgezogen. Jetzt haengt der Rabatt an der Position, der Rechnungsbetrag ist die Summe nach Abzug, und Marge wie Summen rechnen mit dem tatsaechlich gezahlten Betrag. Die Migration verteilt vorhandene Rabatte anteilig auf die Positionen und rechnet die Summen neu - nichts geht verloren. Dazu ein eigener Steuersatz fuer den Einkauf mit Vorgabe 0: Proxmox rechnet gegenueber Unternehmen mit USt-IdNr. ueber Reverse Charge ab. Neue zwoelfte Warnregel aus Florians Ablauf: Der Kunde hat bezahlt, die Proxmox-Rechnung ist noch offen - der Moment, an dem man selbst zahlt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,7 +30,7 @@ describe.runIf(datenbankVerfuegbar)("Abgleich der Warnungen", () => {
|
||||
|
||||
it("legt die Standard-Einstellungen für alle Regeln an", async () => {
|
||||
const einstellungen = await testPrisma.warnregelEinstellung.findMany();
|
||||
expect(einstellungen).toHaveLength(11);
|
||||
expect(einstellungen).toHaveLength(12);
|
||||
const ablauf = einstellungen.find((e) => e.regel === "subscription-laeuft-ab");
|
||||
expect(ablauf?.schwellwerte).toEqual({ tage: [30, 14, 7] });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
/**
|
||||
* Rabatt je Position und Sammelrechnung über mehrere Hosts – so, wie Proxmox abrechnet (#037):
|
||||
* je Key eine Zeile mit Listenpreis und eine Zeile mit „Client Discount“.
|
||||
*/
|
||||
vi.mock("@/server/auth/sitzungspflicht", () => ({
|
||||
sitzungErforderlich: async () => ({ benutzerId: "test", rolle: "ADMIN", kundeId: null }),
|
||||
sichtbarerKunde: async () => null,
|
||||
darfEinkaufSehen: async () => true,
|
||||
angemeldet: async () => null,
|
||||
}));
|
||||
|
||||
const { proxmoxRechnungErfassen } = await import("@/server/services/rechnungen");
|
||||
|
||||
async function laufzeit(key: string) {
|
||||
const kunde = await testPrisma.kunde.upsert({
|
||||
where: { kundennummer: "K-1" },
|
||||
update: {},
|
||||
create: { name: "Wilde IT", kundennummer: "K-1" },
|
||||
});
|
||||
const host = await testPrisma.host.create({
|
||||
data: { agentId: `a-${key}`, hostname: `hv-${key}`, produkt: "PVE", kundeId: kunde.id },
|
||||
});
|
||||
return testPrisma.laufzeit.create({
|
||||
data: {
|
||||
hostId: host.id,
|
||||
key,
|
||||
start: new Date("2026-10-17"),
|
||||
ende: new Date("2027-10-16"),
|
||||
status: "VORSCHLAG",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const eingabe = {
|
||||
nummer: "291017",
|
||||
datum: new Date("2026-09-17"),
|
||||
faelligAm: new Date("2026-10-17"),
|
||||
nettoBetrag: 1100,
|
||||
rabatt: 110,
|
||||
ustSatz: 0,
|
||||
bezahlt: false,
|
||||
bezahltAm: null,
|
||||
zahlungsart: null,
|
||||
anhangId: null,
|
||||
notizen: null,
|
||||
};
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Proxmox-Rechnung mit Rabatt", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testPrisma.$disconnect();
|
||||
});
|
||||
|
||||
it("legt den Rabatt an der Position ab, nicht an der Rechnung", async () => {
|
||||
const l = await laufzeit("pve2s-3ef3be4d75");
|
||||
await proxmoxRechnungErfassen(l.id, eingabe);
|
||||
|
||||
const position = await testPrisma.proxmoxRechnungPosition.findFirstOrThrow({});
|
||||
expect(Number(position.nettoBetrag)).toBe(1100);
|
||||
expect(Number(position.rabatt)).toBe(110);
|
||||
});
|
||||
|
||||
it("rechnet die Rechnungssumme als Listenpreise minus Rabatte", async () => {
|
||||
// Genau der Fall aus der echten Rechnung 291017: zwei Keys, je 1100 minus 110 = 1980.
|
||||
const ersteLaufzeit = await laufzeit("pve2s-3ef3be4d75");
|
||||
const zweiteLaufzeit = await laufzeit("pve2s-b7a0d516af");
|
||||
|
||||
await proxmoxRechnungErfassen(ersteLaufzeit.id, eingabe);
|
||||
await proxmoxRechnungErfassen(zweiteLaufzeit.id, eingabe);
|
||||
|
||||
const rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({
|
||||
where: { nummer: "291017" },
|
||||
include: { positionen: true },
|
||||
});
|
||||
|
||||
expect(rechnung.positionen).toHaveLength(2);
|
||||
expect(Number(rechnung.nettoBetrag)).toBe(1980);
|
||||
});
|
||||
|
||||
it("bestätigt die Laufzeit beim Erfassen", async () => {
|
||||
const l = await laufzeit("pve2s-3ef3be4d75");
|
||||
await proxmoxRechnungErfassen(l.id, eingabe);
|
||||
|
||||
const danach = await testPrisma.laufzeit.findUniqueOrThrow({ where: { id: l.id } });
|
||||
expect(danach.status).toBe("BESTAETIGT");
|
||||
});
|
||||
|
||||
it("führt die Summe nach, wenn sich ein Betrag ändert", async () => {
|
||||
const l = await laufzeit("pve2s-3ef3be4d75");
|
||||
await proxmoxRechnungErfassen(l.id, eingabe);
|
||||
await proxmoxRechnungErfassen(l.id, { ...eingabe, nettoBetrag: 1200, rabatt: 0 });
|
||||
|
||||
const rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({ where: { nummer: "291017" } });
|
||||
expect(Number(rechnung.nettoBetrag)).toBe(1200);
|
||||
});
|
||||
});
|
||||
@@ -72,9 +72,9 @@ function ausRegel(k: RegelKontext, name: string) {
|
||||
}
|
||||
|
||||
describe("Warnregeln", () => {
|
||||
it("prüft alle elf Regeln, wenn nichts abgeschaltet ist", () => {
|
||||
expect(REGELN).toHaveLength(11);
|
||||
expect(new Set(REGELN.map((r) => r.name)).size).toBe(11);
|
||||
it("prüft alle zwölf Regeln, wenn nichts abgeschaltet ist", () => {
|
||||
expect(REGELN).toHaveLength(12);
|
||||
expect(new Set(REGELN.map((r) => r.name)).size).toBe(12);
|
||||
});
|
||||
|
||||
describe("Subscription läuft ab", () => {
|
||||
@@ -376,4 +376,42 @@ describe("Warnregeln", () => {
|
||||
const fingerprints = alleRegelnPruefen(k).map((kandidat) => kandidat.fingerprint);
|
||||
expect(new Set(fingerprints).size).toBe(fingerprints.length);
|
||||
});
|
||||
|
||||
describe("Kunde hat bezahlt, Einkauf noch offen", () => {
|
||||
it("meldet, sobald der Kunde bezahlt hat und die Proxmox-Rechnung offen ist", () => {
|
||||
// Der übliche Ablauf: unbezahlte Rechnung von Proxmox, Kundenrechnung, Kunde zahlt,
|
||||
// dann zahlt man selbst. Dieser Zustand ist der Moment zum Zahlen.
|
||||
const k = kontext({
|
||||
laufzeiten: [laufzeit({ einkaufBezahlt: false, verkaufErstellt: true, verkaufBezahlt: true })],
|
||||
});
|
||||
|
||||
const treffer = alleRegelnPruefen(k).filter((a) => a.regel === "kunde-bezahlt-einkauf-offen");
|
||||
expect(treffer).toHaveLength(1);
|
||||
expect(treffer[0].schwere).toBe("INFO");
|
||||
});
|
||||
|
||||
it("schweigt, solange der Kunde noch nicht bezahlt hat", () => {
|
||||
const k = kontext({
|
||||
laufzeiten: [laufzeit({ einkaufBezahlt: false, verkaufErstellt: true, verkaufBezahlt: false })],
|
||||
});
|
||||
|
||||
expect(alleRegelnPruefen(k).filter((a) => a.regel === "kunde-bezahlt-einkauf-offen")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("schweigt, wenn die Proxmox-Rechnung längst bezahlt ist", () => {
|
||||
const k = kontext({
|
||||
laufzeiten: [laufzeit({ einkaufBezahlt: true, verkaufErstellt: true, verkaufBezahlt: true })],
|
||||
});
|
||||
|
||||
expect(alleRegelnPruefen(k).filter((a) => a.regel === "kunde-bezahlt-einkauf-offen")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("schweigt, wenn es noch gar keine Einkaufsrechnung gibt", () => {
|
||||
const k = kontext({
|
||||
laufzeiten: [laufzeit({ einkaufBezahlt: null, verkaufErstellt: true, verkaufBezahlt: true })],
|
||||
});
|
||||
|
||||
expect(alleRegelnPruefen(k).filter((a) => a.regel === "kunde-bezahlt-einkauf-offen")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user