diff --git a/CHANGELOG.md b/CHANGELOG.md index 96738a8..f96c993 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,20 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Sicherheit +## [0.22.0] – 2026-10-03 + +### Geändert +- **Der Rabatt gehört jetzt zur Rechnungsposition statt zur Rechnung** – so, wie Proxmox abrechnet: je Subscription-Key eine Zeile mit Listenpreis und eine mit „Client Discount“. Nur so bleibt er bei einer Sammelrechnung dem richtigen Host zugeordnet (#037) +- Der Rechnungsbetrag ist die Summe der Positionen nach Abzug ihrer Rabatte; Marge und Summen rechnen mit dem tatsächlich gezahlten Betrag (#037) +- Eigener Steuersatz für den Einkauf, Vorgabe 0 % wegen Reverse Charge (#037) +- Das Betragsfeld heißt „Listenpreis netto“ und zeigt beim Tippen, womit gerechnet wird (#037) + +### Hinzugefügt +- Zwölfte Warnregel `kunde-bezahlt-einkauf-offen`: Der Kunde hat bezahlt, die Proxmox-Rechnung ist noch offen – der Moment, an dem man selbst zahlt (#037) + +### Behoben +- Der Rabatt wurde bisher **nirgends abgezogen**, weder in der Summe noch in der Marge (#037) + ## [0.21.0] – 2026-10-03 ### Hinzugefügt diff --git a/DEVLOG.md b/DEVLOG.md index 0ab4baa..972b994 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,7 +2,7 @@ ## Zeitübersicht -**Gesamt:** 18 h 58 min +**Gesamt:** 19 h 53 min | Issue | Zeit | |---|---| @@ -42,6 +42,7 @@ | #034 Bedienung auf dem Handy | 0 h 40 min | | #035 Tabellen als Karten | 0 h 50 min | | #036 Standort am Host | 0 h 30 min | +| #037 Rabatt je Position | 0 h 55 min | | Meilenstein | Zeit | |---|---| @@ -64,7 +65,7 @@ ## 2026-09-17 · Sitzung 1 · 19:36–laufend **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) -**Version:** 0.21.0 +**Version:** 0.22.0 ### Erledigt - Auftrag gelesen, Rückfragen gestellt @@ -189,6 +190,10 @@ - Standort am Host (#036): Die Meldung lautete „kann man nicht einstellen“ – tatsächlich löschte jede Kundenzuweisung den vorhandenen Standort, weil das Formular ihn nicht mitsendete und die Aktion ihn trotzdem las - Beide Fehler kamen aus dem echten Einsatz, nicht aus den Tests. Das bleibt die ergiebigste Quelle +- Eine echte Proxmox-Rechnung hat drei Fehler im Rechnungsteil aufgedeckt (#037): Rabatt an der falschen Ebene, nirgends abgezogen, und 19 % USt als Vorgabe, wo Reverse Charge gilt +- Florians Schilderung des Ablaufs (unbezahlte Rechnung → Kundenrechnung → Kunde zahlt → selbst zahlen → PAID-Fassung) ergab die zwölfte Warnregel +- Lehre: Ein Beleg aus der Praxis sagt mehr über das Datenmodell als jede Annahme darüber, wie Rechnungen aussehen + ### Nächster Schritt - #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline - Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023) diff --git a/docs/anleitung/README.md b/docs/anleitung/README.md index 17e9378..cfb83d2 100644 --- a/docs/anleitung/README.md +++ b/docs/anleitung/README.md @@ -251,6 +251,23 @@ Positionen und wird automatisch nachgeführt. Die Jahresbahn zeigt zwölf Monate mit einer Linie für „heute“, gruppiert nach Kunde. Darunter stehen die Abläufe der nächsten 90 Tage und die bereits abgelaufenen Subscriptions. +### Der übliche Ablauf + +1. Proxmox schickt die Rechnung zunächst als **UNPAID**. Sie wird hier erfasst, Haken „bezahlt“ bleibt leer. +2. Die Kundenrechnung wird geschrieben und verschickt. +3. Der Kunde zahlt. Jetzt meldet ProxLedger: *Kunde hat bezahlt – Proxmox-Rechnung noch offen*. +4. Man zahlt bei Proxmox, setzt den Haken „bezahlt“ und lädt die **PAID**-Fassung des PDFs hoch – sie + ersetzt die alte. + +### Die Felder der Proxmox-Rechnung + +| Feld | Inhalt | +|---|---| +| Rechnungsnummer | Nummer von Proxmox. **Dieselbe Nummer bei einer weiteren Laufzeit** hängt diese als Position an dieselbe Rechnung – so werden Sammelrechnungen über mehrere Hosts abgebildet. | +| Listenpreis netto | Der Betrag dieser **einen** Laufzeit, wie er auf der Rechnung steht (z. B. 1.100 €). | +| Rabatt | Der „Client Discount“ dieser Laufzeit (z. B. 110 €). Gerechnet wird mit Listenpreis minus Rabatt. | +| USt | Bei Proxmox in aller Regel 0 – Reverse Charge nach Art. 196. Die Vorgabe steht unter *Einstellungen → System*. | + --- ## 7. Warnungen diff --git a/docs/issues/037-rabatt-und-ablauf.md b/docs/issues/037-rabatt-und-ablauf.md new file mode 100644 index 0000000..f357c72 --- /dev/null +++ b/docs/issues/037-rabatt-und-ablauf.md @@ -0,0 +1,46 @@ +# #037 Rabatt je Position und der tatsächliche Ablauf +**Status:** erledigt +**Meilenstein:** v0.22.0 +**Labels:** backend, ui +**Geschätzt:** 3 h **Tatsächlich:** 0 h 55 min + +## Auslöser +Florian zeigte eine echte Proxmox-Rechnung (#291017) und beschrieb den Ablauf: + +``` +Proxmox VE Standard Subscription 2 CPUs/year - pve2s-3ef3be4d75 € 1.100,00 +Proxmox VE Standard Subscription 2 CPUs/year - pve2s-b7a0d516af € 1.100,00 +Client Discount - … pve2s-3ef3be4d75 € -110,00 +Client Discount - … pve2s-b7a0d516af € -110,00 +Sub Total € 1.980,00 +``` + +Dazu: Man bekommt die Rechnung zuerst als **UNPAID**. Erst wenn der Kunde seine Rechnung bezahlt hat, +zahlt man selbst bei Proxmox – und bekommt dieselbe Rechnung dann als **PAID** mit Transaktion. + +## Was falsch war +1. **Der Rabatt hing an der Rechnung, nicht an der Position.** Bei einer Sammelrechnung über zwei + Hosts ließe sich ein Rabatt keinem Host zuordnen; die Marge je Host wäre falsch. +2. **Er wurde nirgends abgezogen** – weder in der Summe noch in der Marge. Ein Feld, das aussieht wie + eine Rechengröße und keine ist. +3. **Vorgabe 19 % USt** für den Einkauf. Proxmox sitzt in Österreich und rechnet gegenüber Unternehmen + mit USt-IdNr. über Reverse Charge ab – richtig sind 0 %. + +## Behebung +- `rabatt` wandert an `ProxmoxRechnungPosition`; die Migration verteilt vorhandene Werte anteilig auf + die Positionen und rechnet die Rechnungssummen neu. Nichts geht verloren. +- Rechnungsbetrag = Summe der Positionen **nach Abzug ihrer Rabatte** – wie das „Sub Total“ auf der + echten Rechnung. +- Marge und alle Summen rechnen mit dem Betrag nach Abzug. +- Das Formular heißt jetzt „Listenpreis netto“ und zeigt beim Tippen, womit gerechnet wird. +- Eigener Steuersatz für den Einkauf, Vorgabe 0 (Reverse Charge). +- Die Sammelrechnung listet je Position „1.100,00 € − 110,00 € = 990,00 €“. + +## Neue Warnregel +`kunde-bezahlt-einkauf-offen`: Der Kunde hat bezahlt, die Proxmox-Rechnung ist noch offen. Genau der +Zustand aus Florians Ablauf – das Geld ist da, die Rechnung auch, jetzt kann gezahlt werden. Bewusst +unabhängig vom Fälligkeitsdatum, denn die Reihenfolge ist Absicht und keine Nachlässigkeit. + +## Zum PDF +Die PAID-Fassung ersetzt beim erneuten Hochladen einfach die UNPAID-Fassung – gleiche Rechnungsnummer, +gleiche Rechnung. Gleiche Dateien werden an ihrer Prüfsumme erkannt und nicht doppelt abgelegt. diff --git a/docs/issues/README.md b/docs/issues/README.md index 472161e..41e8747 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -40,4 +40,5 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#034](034-mobile-bedienung.md) | Bedienung auf dem Handy | erledigt | v0.20.2 | 2 h | 0 h 40 min | | [#035](035-mobile-tabellen.md) | Tabellen auf dem Handy als Karten | erledigt | v0.21.0 | 3 h | 0 h 50 min | | [#036](036-standort-am-host.md) | Standort am Host setzen | erledigt | v0.21.0 | 1 h | 0 h 30 min | -| | **Summe** | | | **157 h** | **18 h 58 min** | +| [#037](037-rabatt-und-ablauf.md) | Rabatt je Position und der tatsächliche Ablauf | erledigt | v0.22.0 | 3 h | 0 h 55 min | +| | **Summe** | | | **160 h** | **19 h 53 min** | diff --git a/package.json b/package.json index 0a8ca7e..d929da0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proxledger", - "version": "0.21.0", + "version": "0.22.0", "private": true, "scripts": { "dev": "next dev", diff --git a/prisma/migrations/20261003163000_rabatt_je_position/migration.sql b/prisma/migrations/20261003163000_rabatt_je_position/migration.sql new file mode 100644 index 0000000..462bd4a --- /dev/null +++ b/prisma/migrations/20261003163000_rabatt_je_position/migration.sql @@ -0,0 +1,32 @@ +-- Rabatt gehört an die Position, nicht an die Rechnung (#037). +-- +-- Proxmox weist den Rabatt je Subscription-Key als eigene Zeile aus. Bei einer Sammelrechnung +-- über mehrere Hosts ließe sich ein Rabatt an der Rechnung keinem Host zuordnen – und die Marge +-- je Host wäre falsch. + +ALTER TABLE "ProxmoxRechnungPosition" ADD COLUMN "rabatt" DECIMAL(12,2); + +-- Vorhandene Rabatte von der Rechnung auf ihre Positionen verteilen, anteilig zum Nettobetrag. +-- Bei nur einer Position entspricht das genau dem bisherigen Wert. +UPDATE "ProxmoxRechnungPosition" AS p +SET "rabatt" = ROUND(r."rabatt" * p."nettoBetrag" / NULLIF(s."summe", 0), 2) +FROM "ProxmoxRechnung" AS r, + (SELECT "rechnungId", SUM("nettoBetrag") AS "summe" + FROM "ProxmoxRechnungPosition" + GROUP BY "rechnungId") AS s +WHERE p."rechnungId" = r."id" + AND s."rechnungId" = r."id" + AND r."rabatt" IS NOT NULL; + +-- Der Rechnungsbetrag ist ab jetzt die Summe der Positionen nach Abzug ihrer Rabatte. +UPDATE "ProxmoxRechnung" AS r +SET "nettoBetrag" = COALESCE(( + SELECT SUM(p."nettoBetrag" - COALESCE(p."rabatt", 0)) + FROM "ProxmoxRechnungPosition" AS p + WHERE p."rechnungId" = r."id" + ), r."nettoBetrag"); + +ALTER TABLE "ProxmoxRechnung" DROP COLUMN "rabatt"; + +-- Einkauf bei Proxmox läuft über Reverse Charge; 19 % war als Vorgabe irreführend. +ALTER TABLE "ProxmoxRechnung" ALTER COLUMN "ustSatz" SET DEFAULT 0.00; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f5ebc13..d4c7149 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -261,9 +261,10 @@ model ProxmoxRechnung { nummer String @unique datum DateTime @db.Date faelligAm DateTime? @db.Date + /// Summe der Positionen nach Abzug ihrer Rabatte – wird beim Speichern nachgeführt. nettoBetrag Decimal @db.Decimal(12, 2) - ustSatz Decimal @default(19.00) @db.Decimal(5, 2) - rabatt Decimal? @db.Decimal(12, 2) + /// Bei Proxmox in aller Regel 0: Reverse Charge nach Art. 196 (#037). + ustSatz Decimal @default(0.00) @db.Decimal(5, 2) zahlungsart String? bezahlt Boolean @default(false) bezahltAm DateTime? @db.Date @@ -284,7 +285,10 @@ model ProxmoxRechnungPosition { rechnungId String bezeichnung String menge Int @default(1) + /// Listenpreis dieser Position, wie er auf der Rechnung steht. nettoBetrag Decimal @db.Decimal(12, 2) + /// Rabatt dieser Position. Proxmox weist ihn je Key als eigene Zeile aus (#037). + rabatt Decimal? @db.Decimal(12, 2) rechnung ProxmoxRechnung @relation(fields: [rechnungId], references: [id], onDelete: Cascade) laufzeit Laufzeit? diff --git a/prisma/seed.ts b/prisma/seed.ts index bab1822..19b39ba 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -166,7 +166,6 @@ async function seedDemo() { faelligAm: datum(vorlage.faelligAm), nettoBetrag: zugehoerig.reduce((summe, l) => summe + (l.einkauf?.nettoBetrag ?? 0), 0), ustSatz: vorlage.ustSatz, - rabatt: vorlage.rabatt ?? null, zahlungsart: vorlage.zahlungsart ?? null, bezahlt: vorlage.bezahlt, bezahltAm: datum(vorlage.bezahltAm), @@ -175,6 +174,8 @@ async function seedDemo() { id: `prp-${l.id}`, bezeichnung: `${l.key} · ${l.start} bis ${l.ende}`, nettoBetrag: l.einkauf?.nettoBetrag ?? 0, + // Der Rabatt hängt seit #037 an der Position – wie auf der echten Proxmox-Rechnung. + rabatt: vorlage.rabatt ?? null, })), }, }, diff --git a/src/app/(dashboard)/rechnungen/[id]/page.tsx b/src/app/(dashboard)/rechnungen/[id]/page.tsx index 75f2f12..1570e2d 100644 --- a/src/app/(dashboard)/rechnungen/[id]/page.tsx +++ b/src/app/(dashboard)/rechnungen/[id]/page.tsx @@ -135,7 +135,15 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id: ) : ( position.bezeichnung )}{" "} - · {formatEuro(Number(position.nettoBetrag))} + ·{" "} + {position.rabatt ? ( + <> + {formatEuro(Number(position.nettoBetrag))} − {formatEuro(Number(position.rabatt))}{" "} + = {formatEuro(Number(position.nettoBetrag) - Number(position.rabatt))} + + ) : ( + formatEuro(Number(position.nettoBetrag)) + )} ))} @@ -159,9 +167,10 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id: nettoBetrag: einkaufBetrag, ustSatz: Number(einkauf?.ustSatz ?? vorgaben.steuersatz), rabatt: - einkauf?.rabatt === null || einkauf?.rabatt === undefined + laufzeit.proxmoxPosition?.rabatt === null || + laufzeit.proxmoxPosition?.rabatt === undefined ? null - : Number(einkauf.rabatt), + : Number(laufzeit.proxmoxPosition.rabatt), zahlungsart: einkauf?.zahlungsart ?? null, bezahlt: einkauf?.bezahlt ?? false, bezahltAm: einkauf?.bezahltAm?.toISOString() ?? null, diff --git a/src/components/einstellungen/SystemFormular.tsx b/src/components/einstellungen/SystemFormular.tsx index 9e3eb66..adb8a1f 100644 --- a/src/components/einstellungen/SystemFormular.tsx +++ b/src/components/einstellungen/SystemFormular.tsx @@ -119,8 +119,8 @@ export function SystemFormular({ werte, appUrl }: { werte: Einstellungen; appUrl + + + + Number(wert.replace(",", ".")) || 0; + const nettoZahl = zahl(netto); + const rabattZahl = zahl(rabatt); + return (
@@ -93,16 +101,18 @@ export function ProxmoxRechnungFormular({
setNetto(e.target.value)} required /> +
Betrag dieser einen Laufzeit, wie er auf der Rechnung steht.