Compare commits

...
4 Commits
Author SHA1 Message Date
florian.wilde c1177067e6 Merge branch 'phase/037-rabatt-position'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 16:42:12 +02:00
florian.wildeandClaude Opus 5 e50fb30ab3 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>
2026-10-03 16:42:12 +02:00
florian.wilde 97f6450f1f Merge branch 'phase/035-mobil-tabellen'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 16:20:20 +02:00
florian.wildeandClaude Opus 5 ca2af0c2fc feat(ui): Tabellen als Karten auf dem Handy, Standort am Host (#035, #036)
Breite Tabellen (Kunden, Rechnungen, Ablauf-Kalender) werden unterhalb von
768 px zu Karten: jede Angabe mit ihrer Beschriftung, statt rechts
abgeschnitten zu sein. Kartenkoepfe brechen um, die Jahresbahn bekommt eine
schmalere Namensspalte und einen Hinweis aufs Wischen.

Standort am Host (#036): Das Formular sendete nur den Kunden, die Aktion las
aber auch standortId - jede Kundenzuweisung hat den Standort still geloescht.
Jetzt stehen Kunde und Standort in einem Formular, die Liste richtet sich nach
dem Kunden, und die Aktion prueft, dass der Standort zum Kunden gehoert.

Nebenbefund: Das Kontrollkaestchen fuer den Rechnungsstatus trug
pl-touch-target und wurde dadurch selbst zum 44 Pixel hohen leeren Kasten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 16:20:20 +02:00
46 changed files with 847 additions and 94 deletions
+26
View File
@@ -17,6 +17,32 @@ 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
- Standort eines Hosts lässt sich in der Detailansicht setzen; die Auswahl richtet sich nach dem gewählten Kunden (#036)
- Breite Tabellen werden auf dem Telefon zu Karten: jede Angabe mit ihrer Beschriftung, statt rechts abgeschnitten zu sein (#035)
### Behoben
- **Jede Kundenzuweisung löschte den Standort des Hosts**, weil das Formular ihn nicht mitsendete, die Aktion ihn aber las (#036)
- Ein Standort, der zu einem anderen Kunden gehört, wird nicht mehr gespeichert (#036)
- Kartenköpfe mit Knöpfen liefen auf dem Telefon über den Rand (#035)
- Das Kontrollkästchen für den Rechnungsstatus wurde auf dem Telefon zum 44 Pixel hohen leeren Kasten (#035)
## [0.20.2] – 2026-10-03
### Behoben
+13 -2
View File
@@ -2,7 +2,7 @@
## Zeitübersicht
**Gesamt:** 17 h 38 min
**Gesamt:** 19 h 53 min
| Issue | Zeit |
|---|---|
@@ -40,6 +40,9 @@
| #032 LXC-Installation korrigiert | 0 h 20 min |
| #033 Weiterleitung auf Lausch-Adresse | 0 h 30 min |
| #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 |
|---|---|
@@ -62,7 +65,7 @@
## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
**Version:** 0.20.2
**Version:** 0.22.0
### Erledigt
- Auftrag gelesen, Rückfragen gestellt
@@ -183,6 +186,14 @@
- Mobile Bedienung (#034): Das Menü ließ sich auf dem Telefon nicht schließen – AdminLTE-Markup ohne AdminLTE-JavaScript. Eigener Zustand, abdunkelnde Fläche, Schließen beim Navigieren und mit Escape
- Lehre: Die Mobilansicht war mit Screenshots geprüft, nicht mit den Fingern. Ein Bild zeigt nicht, dass etwas nicht wieder weggeht
- Breite Tabellen werden auf dem Telefon zu Karten (#035); dabei fiel auf, dass `pl-touch-target` am Kontrollkästchen selbst saß und es zum leeren Kasten aufblies
- 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)
+20
View File
@@ -175,6 +175,9 @@ weitergeben, und der Zurück-Knopf funktioniert wie erwartet.
---
Im Detail eines Hosts lassen sich Kunde **und Standort** gemeinsam ändern. Die Standortliste zeigt nur
die Standorte des gewählten Kunden; angelegt werden sie auf der Kundenseite.
## 4a. Cluster
**In Proxmox hat jeder Knoten eine eigene Subscription** mit eigener Server-ID – ein Cluster mit drei
@@ -248,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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 178 KiB

+28
View File
@@ -0,0 +1,28 @@
# #035 Tabellen auf dem Handy als Karten
**Status:** erledigt
**Meilenstein:** v0.21.0
**Labels:** ui
**Geschätzt:** 3 h **Tatsächlich:** 0 h 50 min
## Auslöser
„Etwas schöner darstellen auf dem Handy muss doch drin sein.“ Auf Rechnungen, Kunden und Jahresbahn
war rechts jeweils die Hälfte abgeschnitten, Knöpfe im Kartenkopf liefen über den Rand, und Namen
brachen Buchstabe für Buchstabe um.
## Aufgaben
- [x] `.pl-karten-tabelle`: unterhalb von 768 px wird jede Tabellenzeile zur Karte, jede Zelle bekommt
ihre Beschriftung aus `data-label` davor
- [x] Angewandt auf Kunden, Rechnungen und den Ablauf-Kalender der Jahresbahn
- [x] Kartenkopf bricht um, statt über den Rand zu laufen
- [x] Jahresbahn: schmalere Namensspalte auf dem Telefon, dazu der Hinweis aufs seitliche Wischen
- [x] Mobilansichten der breiten Seiten kommen jetzt mit in die Bilderstrecke
## Warum Karten statt Scrollen
Eine Tabelle mit elf Spalten lässt sich auf 390 px nicht sinnvoll anzeigen. Seitliches Scrollen
versteckt die Hälfte der Angaben, und Spalten wegzulassen nimmt sie ganz weg. Als Karte steht jede
Angabe mit ihrer Beschriftung da – länger, aber vollständig.
## Nebenbefund
Das Kontrollkästchen für den Rechnungsstatus trug `pl-touch-target`, also `min-height: 44px` – und
wurde damit selbst zum 44 Pixel hohen leeren Kasten. Die Fläche zum Treffen bekommt jetzt sein Umfeld,
das Kästchen behält seine Größe.
+22
View File
@@ -0,0 +1,22 @@
# #036 Standort am Host setzen
**Status:** erledigt
**Meilenstein:** v0.21.0
**Labels:** bug, ui
**Geschätzt:** 1 h **Tatsächlich:** 0 h 30 min
## Auslöser
„Den Standort bei den Hosts kann man nicht einstellen.“
## Was wirklich los war
Schlimmer als gemeldet: Das Formular in der Host-Detailansicht sendete **nur** den Kunden. Die Aktion
`hostKundeSetzen` las aber auch `standortId` – und bekam nichts. Jede Kundenzuweisung hat damit den
Standort **stillschweigend gelöscht**. Setzen ging nur beim Ausrollen über die Sammelzuweisung unter
*Hosts → Nicht zugeordnet*; danach war er beim nächsten Speichern wieder weg.
## Behebung
- Kunde und Standort in **einem** Formular, die Standortliste richtet sich nach dem gewählten Kunden
- Wechselt der Kunde, wird die Standortauswahl zurückgesetzt statt stehen zu bleiben
- Hat ein Kunde keine Standorte, sagt das Formular es, statt eine leere Auswahl anzubieten
- Die Aktion prüft jetzt, dass der Standort zum Kunden gehört, und verwirft ihn sonst – eine falsche
Zuordnung wäre schlimmer als keine
- Vier Tests, darunter der Fall „Standort eines anderen Kunden“
+46
View File
@@ -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.
+4 -1
View File
@@ -38,4 +38,7 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#032](032-lxc-installation-korrekturen.md) | LXC-Installation: Herkunft der Skripte und Speicher | erledigt | v0.20.1 | 1 h | 0 h 20 min |
| [#033](033-weiterleitung-lauschadresse.md) | Abmelden führte auf die Lausch-Adresse | erledigt | v0.20.1 | 1 h | 0 h 30 min |
| [#034](034-mobile-bedienung.md) | Bedienung auf dem Handy | erledigt | v0.20.2 | 2 h | 0 h 40 min |
| | **Summe** | | | **153 h** | **17 h 38 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 |
| [#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** |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "proxledger",
"version": "0.20.2",
"version": "0.22.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -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;
+6 -2
View File
@@ -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?
+2 -1
View File
@@ -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,
})),
},
},
+2 -1
View File
@@ -195,7 +195,8 @@ async function main() {
console.log(` ${seite}-dunkel.png`);
}
for (const seite of ["dashboard", "hosts"]) {
// Die Seiten mit den breitesten Tabellen – dort entscheidet sich die Mobilansicht (#035).
for (const seite of ["dashboard", "hosts", "kunden", "rechnungen", "jahresbahn"]) {
const eintrag = SEITEN.find((s) => s.name === seite);
await aufnehmen(hell, eintrag.pfad, path.join(ZIEL, `mobil-${seite}.png`), 390, 1500, adresse);
console.log(` mobil-${seite}.png`);
+12 -26
View File
@@ -16,7 +16,7 @@ import { Tabs } from "@/components/ui/Tabs";
import { PRODUKT_TEXT } from "@/lib/anzeige";
import { prisma } from "@/lib/db";
import { formatDatum, formatDatumZeit, formatEuro, formatUptime, formatZahl } from "@/lib/format";
import { hostKundeSetzen } from "@/server/actions/host-detail";
import { KundeStandortFormular } from "@/components/hosts/KundeStandortFormular";
import { hostTokenWiderrufen } from "@/server/actions/tokens";
import { hostMitDetails } from "@/server/queries/hostliste";
import { meldungsGrenze } from "@/server/queries/hosts";
@@ -43,7 +43,11 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
const kunden = await prisma.kunde.findMany({
orderBy: { name: "asc" },
select: { id: true, name: true },
select: {
id: true,
name: true,
standorte: { orderBy: { name: "asc" }, select: { id: true, name: true } },
},
});
// Welche Cluster-Mitglieder melden sich selbst? Fehlende Knoten haben keinen Agent (#022).
@@ -167,30 +171,12 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
<Zeile bezeichnung="Erstmals gesehen">{formatDatum(host.erstmalsGesehenAm)}</Zeile>
<Zeile bezeichnung="Letzte Meldung">{formatDatumZeit(host.letzterReportAm)}</Zeile>
<form action={hostKundeSetzen} className="mt-3">
<input type="hidden" name="hostId" value={host.id} />
<label className="form-label small" htmlFor="kundeId">
Kunde ändern
</label>
<div className="input-group input-group-sm">
<select
id="kundeId"
name="kundeId"
className="form-select"
defaultValue={host.kundeId ?? ""}
>
<option value="">– nicht zugeordnet –</option>
{kunden.map((kunde) => (
<option key={kunde.id} value={kunde.id}>
{kunde.name}
</option>
))}
</select>
<button type="submit" className="btn btn-outline-primary">
Setzen
</button>
</div>
</form>
<KundeStandortFormular
hostId={host.id}
kunden={kunden}
kundeId={host.kundeId}
standortId={host.standortId}
/>
</Card>
</div>
+16 -15
View File
@@ -41,8 +41,8 @@ export default async function JahresbahnSeite() {
<p className="mb-0 text-body-secondary">Noch kein Host mit Ablaufdatum gemeldet.</p>
) : (
<div className="table-responsive">
<div style={{ minWidth: "820px" }}>
<div className="d-flex text-body-secondary small mb-2" style={{ marginLeft: "13rem" }}>
<div className="pl-timeline">
<div className="d-flex text-body-secondary small mb-2 pl-timeline-kopf">
{monate.map((monat) => (
<div key={monat} style={{ width: `${100 / 12}%` }}>
{monat}
@@ -72,7 +72,7 @@ export default async function JahresbahnSeite() {
: "var(--pl-info)";
return (
<div key={eintrag.hostId} className="d-flex align-items-center mb-1">
<div className="small text-truncate" style={{ width: "13rem" }}>
<div className="small text-truncate pl-timeline-name">
<Link href={`/hosts/${eintrag.hostId}`} className="text-decoration-none">
{eintrag.hostname}
</Link>
@@ -112,7 +112,8 @@ export default async function JahresbahnSeite() {
)}
<p className="small text-body-secondary mb-0">
Die rote Linie markiert heute ({formatDatum(jetzt)}). Balkenfarbe: rot unter 14 Tagen, gelb
<span className="d-md-none">Seitwärts wischen zeigt die späteren Monate. </span>Die rote
Linie markiert heute ({formatDatum(jetzt)}). Balkenfarbe: rot unter 14 Tagen, gelb
unter 30 Tagen, sonst blau. Ein Klick führt zur Laufzeit mit ihren Rechnungen.
</p>
</Card>
@@ -125,11 +126,11 @@ export default async function JahresbahnSeite() {
tabelle
>
<div className="table-responsive">
<table className="table table-striped align-middle">
<table className="table table-striped align-middle pl-karten-tabelle">
<thead>
<tr>
<th scope="col">Ablauf</th>
<th scope="col">Host</th>
<th scope="col">Ablauf</th>
<th scope="col">Kunde</th>
<th scope="col">Key</th>
<th scope="col">Proxmox</th>
@@ -139,15 +140,15 @@ export default async function JahresbahnSeite() {
<tbody>
{naechste90Tage.map((eintrag) => (
<tr key={eintrag.hostId}>
<td>
<AblaufBadge datum={eintrag.ablauf.toISOString()} heute={jetzt} />
</td>
<td>
<Link href={`/hosts/${eintrag.hostId}`} className="text-decoration-none">
<td className="pl-karten-kopf">
<Link href={`/hosts/${eintrag.hostId}`} className="fw-semibold text-decoration-none">
{eintrag.hostname}
</Link>
</td>
<td>
<td data-label="Ablauf">
<AblaufBadge datum={eintrag.ablauf.toISOString()} heute={jetzt} />
</td>
<td data-label="Kunde">
{eintrag.kunde ? (
<Link href={`/kunden/${eintrag.kunde.id}`} className="text-decoration-none">
{eintrag.kunde.name}
@@ -156,13 +157,13 @@ export default async function JahresbahnSeite() {
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
)}
</td>
<td>
<td data-label="Key">
<CopyableCode wert={eintrag.key} />
</td>
<td>
<td data-label="Proxmox">
<EinkaufBadge bezahlt={eintrag.einkaufBezahlt} />
</td>
<td>
<td data-label="Kundenrechnung">
<VerkaufBadge erstellt={eintrag.verkaufErstellt ?? false} bezahlt={false} />
</td>
</tr>
+10 -8
View File
@@ -39,7 +39,7 @@ export default async function KundenSeite() {
}
>
<div className="table-responsive">
<table className="table table-striped align-middle">
<table className="table table-striped align-middle pl-karten-tabelle">
<thead>
<tr>
<th scope="col">Kunde</th>
@@ -56,26 +56,28 @@ export default async function KundenSeite() {
const punkte = rechnungspunkte.get(kunde.id);
return (
<tr key={kunde.id}>
<td>
<td className="pl-karten-kopf">
<Link href={`/kunden/${kunde.id}`} className="fw-semibold text-decoration-none">
{kunde.name}
</Link>
<div className="small text-body-secondary">{kunde.ansprechpartner ?? "–"}</div>
</td>
<td className="pl-mono">{kunde.kundennummer}</td>
<td>{kunde.hosts}</td>
<td>{kunde.aktiv}</td>
<td>
<td className="pl-mono" data-label="Kundennummer">
{kunde.kundennummer}
</td>
<td data-label="Hosts">{kunde.hosts}</td>
<td data-label="Aktiv lizenziert">{kunde.aktiv}</td>
<td data-label="Nächster Ablauf">
<AblaufBadge datum={kunde.naechsterAblauf?.toISOString() ?? null} heute={heute} />
</td>
<td>
<td data-label="Warnungen">
{kunde.offeneAlerts > 0 ? (
<Badge farbe="warning">{kunde.offeneAlerts} offen</Badge>
) : (
<Badge farbe="success">keine</Badge>
)}
</td>
<td>
<td data-label="Rechnungen">
{punkte && punkte.offen > 0 ? (
<Badge farbe="danger">{punkte.offen} offen</Badge>
) : (
+12 -3
View File
@@ -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))}{" "}
= <strong>{formatEuro(Number(position.nettoBetrag) - Number(position.rabatt))}</strong>
</>
) : (
formatEuro(Number(position.nettoBetrag))
)}
</li>
))}
</ul>
@@ -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,
+22 -11
View File
@@ -197,7 +197,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
</form>
<div className="table-responsive">
<table className="table table-striped align-middle">
<table className="table table-striped align-middle pl-karten-tabelle">
<thead>
<tr>
<th scope="col">Kunde</th>
@@ -234,7 +234,13 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
const faelligIn = tageBis(zeile.einkaufFaelligAm, heute);
return (
<tr key={zeile.id}>
<td>
{/* Auf dem Telefon steht der Host als Überschrift der Karte, sonst als Spalte. */}
<td className="d-md-none pl-karten-kopf">
<Link href={`/hosts/${zeile.hostId}`} className="fw-semibold text-decoration-none">
{zeile.hostname}
</Link>
</td>
<td data-label="Kunde">
{zeile.kunde ? (
<Link href={`/kunden/${zeile.kunde.id}`} className="text-decoration-none">
{zeile.kunde.name}
@@ -248,13 +254,13 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
{zeile.hostname}
</Link>
</td>
<td className="d-none d-lg-table-cell">
<td className="d-none d-lg-table-cell" data-label="Laufzeit">
<Link href={`/rechnungen/${zeile.id}`} className="text-decoration-none">
{formatDatum(zeile.start)} – {formatDatum(zeile.ende)}
</Link>
{zeile.vorschlag ? <Badge farbe="info">Vorschlag</Badge> : null}
</td>
<td>
<td data-label="Proxmox-Rechnung">
<CopyableCode wert={zeile.einkaufNummer} />
{zeile.einkaufBezahlt === false && faelligIn !== null && faelligIn <= 7 ? (
<div className="small pl-text-danger">
@@ -262,8 +268,10 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
</div>
) : null}
</td>
<td className="d-none d-xl-table-cell">{formatEuro(zeile.einkaufBetrag)}</td>
<td className="text-center">
<td className="d-none d-xl-table-cell" data-label="Einkauf">
{formatEuro(zeile.einkaufBetrag)}
</td>
<td className="text-center" data-label="Einkauf bezahlt">
<StatusHaken
laufzeitId={zeile.id}
hostId={zeile.hostId}
@@ -273,11 +281,13 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
titel="Proxmox-Rechnung bezahlt"
/>
</td>
<td>
<td data-label="Kundenrechnung">
<CopyableCode wert={zeile.verkaufNummer} />
</td>
<td className="d-none d-xl-table-cell">{formatEuro(zeile.verkaufBetrag)}</td>
<td className="text-center">
<td className="d-none d-xl-table-cell" data-label="Verkauf">
{formatEuro(zeile.verkaufBetrag)}
</td>
<td className="text-center" data-label="Rechnung erstellt">
<StatusHaken
laufzeitId={zeile.id}
hostId={zeile.hostId}
@@ -286,7 +296,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
titel="Kundenrechnung erstellt"
/>
</td>
<td className="text-center">
<td className="text-center" data-label="Vom Kunden bezahlt">
<StatusHaken
laufzeitId={zeile.id}
hostId={zeile.hostId}
@@ -297,6 +307,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
/>
</td>
<td
data-label="Marge"
className={`d-none d-xl-table-cell ${(zeile.marge ?? 0) >= 0 ? "pl-text-success" : "pl-text-danger"}`}
>
{zeile.marge === null ? "–" : formatEuro(zeile.marge)}
@@ -306,7 +317,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
})}
{zeilen.length === 0 ? (
<tr>
<td colSpan={11} className="text-center text-body-secondary py-4">
<td colSpan={12} className="text-center text-body-secondary py-4">
Nichts gefunden. <Link href="/rechnungen?status=alle">Alle anzeigen</Link>
</td>
</tr>
+118
View File
@@ -382,10 +382,128 @@ body {
transform: translateX(-50%);
}
/* Zeitleiste der Jahresbahn: Breiten als Klassen, damit sie auf dem Telefon kleiner ausfallen. */
.pl-timeline {
min-width: 820px;
}
.pl-timeline-kopf {
margin-left: 13rem;
}
.pl-timeline-name {
width: 13rem;
}
/* ---------- Mobil ---------- */
@media (max-width: 575.98px) {
.pl-touch-target {
min-height: 44px;
}
/*
* Ein Kontrollkästchen darf nicht selbst 44 px hoch werden – sonst steht in der Zeile ein
* großer leerer Kasten. Die Fläche zum Treffen bekommt stattdessen sein Umfeld (#035).
*/
.pl-haken {
min-width: 44px;
min-height: 44px;
}
.pl-haken .form-check-input {
width: 1.4rem;
height: 1.4rem;
}
}
/*
* Breite Tabellen auf dem Telefon: Jede Zeile wird zur Karte, jede Zelle bekommt ihre
* Beschriftung aus `data-label` davor. Das ersetzt das Scrollen nach rechts, bei dem die
* Hälfte der Angaben unsichtbar bleibt (#035).
*
* Zellen ohne `data-label` stehen für sich – etwa eine Zeile mit Knöpfen.
*/
@media (max-width: 767.98px) {
.pl-karten-tabelle,
.pl-karten-tabelle tbody,
.pl-karten-tabelle tr,
.pl-karten-tabelle td {
display: block;
width: 100%;
}
.pl-karten-tabelle thead {
display: none;
}
.pl-karten-tabelle tr {
padding: 0.35rem 0.25rem;
border-bottom: 1px solid var(--bs-border-color);
}
.pl-karten-tabelle tr:last-child {
border-bottom: 0;
}
.pl-karten-tabelle td {
display: flex;
gap: 0.75rem;
align-items: baseline;
justify-content: space-between;
padding: 0.3rem 0.75rem;
text-align: right;
border: 0;
}
.pl-karten-tabelle td::before {
flex: 0 0 auto;
font-size: 0.8125rem;
color: var(--bs-secondary-color);
text-align: left;
content: attr(data-label);
}
/* Die erste Zelle trägt den Namen – sie steht als Überschrift der Karte für sich. */
.pl-karten-tabelle td.pl-karten-kopf {
display: block;
padding-top: 0.6rem;
font-size: 1.0625rem;
text-align: left;
}
.pl-karten-tabelle td.pl-karten-kopf::before {
content: none;
}
/* Lange Namen und Schlüssel sollen umbrechen, aber nicht Buchstabe für Buchstabe. */
.pl-karten-tabelle td a,
.pl-karten-tabelle td .pl-mono {
overflow-wrap: anywhere;
word-break: normal;
}
/* Auf dem Telefon bleibt mehr Platz für die Monate, wenn die Namensspalte schrumpft. */
.pl-timeline {
min-width: 560px;
}
.pl-timeline-kopf {
margin-left: 7rem;
}
.pl-timeline-name {
width: 7rem;
}
/* Kartenkopf mit Knöpfen: untereinander statt nebeneinander über den Rand hinaus. */
.card-header {
flex-wrap: wrap;
gap: 0.5rem;
}
.card-header .card-tools {
flex-wrap: wrap;
width: 100%;
}
}
@@ -119,8 +119,8 @@ export function SystemFormular({ werte, appUrl }: { werte: Einstellungen; appUrl
<Feld
name="steuersatz"
beschriftung="Steuersatz in Prozent"
hinweis="Vorgabe für neue Rechnungspositionen."
beschriftung="Steuersatz Verkauf in Prozent"
hinweis="Vorgabe für neue Kundenrechnungen."
fehler={fehler.steuersatz}
>
<input
@@ -135,6 +135,24 @@ export function SystemFormular({ werte, appUrl }: { werte: Einstellungen; appUrl
/>
</Feld>
<Feld
name="steuersatzEinkauf"
beschriftung="Steuersatz Einkauf in Prozent"
hinweis="Für die Proxmox-Rechnungen. 0 bei Reverse Charge – der Normalfall mit USt-IdNr."
fehler={fehler.steuersatzEinkauf}
>
<input
type="number"
step="0.1"
min={0}
max={100}
className="form-control"
id="steuersatzEinkauf"
name="steuersatzEinkauf"
defaultValue={werte.steuersatzEinkauf}
/>
</Feld>
<Feld
name="zahlungszielTage"
beschriftung="Zahlungsziel in Tagen"
@@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import { hostKundeSetzen } from "@/server/actions/host-detail";
export interface KundeMitStandorten {
id: string;
name: string;
standorte: { id: string; name: string }[];
}
/**
* Kunde und Standort eines Hosts ändern (#036).
*
* Beides gehört in **ein** Formular: Die Standorte hängen am Kunden, und ein Formular, das nur den
* Kunden sendet, würde den Standort beim Speichern mitlöschen – genau das passierte vorher.
*/
export function KundeStandortFormular({
hostId,
kunden,
kundeId,
standortId,
}: {
hostId: string;
kunden: KundeMitStandorten[];
kundeId: string | null;
standortId: string | null;
}) {
const [gewaehlterKunde, setGewaehlterKunde] = useState(kundeId ?? "");
const standorte = kunden.find((kunde) => kunde.id === gewaehlterKunde)?.standorte ?? [];
return (
<form action={hostKundeSetzen} className="mt-3">
<input type="hidden" name="hostId" value={hostId} />
<label className="form-label small" htmlFor="kundeId">
Kunde ändern
</label>
<select
id="kundeId"
name="kundeId"
className="form-select form-select-sm mb-2"
value={gewaehlterKunde}
onChange={(e) => setGewaehlterKunde(e.target.value)}
>
<option value="">– nicht zugeordnet –</option>
{kunden.map((kunde) => (
<option key={kunde.id} value={kunde.id}>
{kunde.name}
</option>
))}
</select>
<label className="form-label small" htmlFor="standortId">
Standort
</label>
<div className="input-group input-group-sm">
<select
id="standortId"
name="standortId"
className="form-select"
defaultValue={standortId ?? ""}
disabled={standorte.length === 0}
// `key` erzwingt ein frisches Feld, wenn der Kunde wechselt – sonst bliebe die alte Auswahl stehen.
key={gewaehlterKunde}
>
<option value="">– keiner –</option>
{standorte.map((standort) => (
<option key={standort.id} value={standort.id}>
{standort.name}
</option>
))}
</select>
<button type="submit" className="btn btn-outline-primary">
Setzen
</button>
</div>
{gewaehlterKunde && standorte.length === 0 ? (
<div className="form-text">
Für diesen Kunden gibt es noch keinen Standort. Anlegen lässt er sich auf der Kundenseite.
</div>
) : null}
</form>
);
}
@@ -1,8 +1,9 @@
"use client";
import { useActionState } from "react";
import { useActionState, useState } from "react";
import { kundenrechnungSpeichern, proxmoxRechnungSpeichern } from "@/server/actions/rechnungen";
import type { AktionsErgebnis } from "@/server/actions/kunden";
import { formatEuro } from "@/lib/format";
const LEER: AktionsErgebnis = {};
@@ -42,6 +43,13 @@ export function ProxmoxRechnungFormular({
}) {
const [ergebnis, aktion, laeuft] = useActionState(proxmoxRechnungSpeichern, LEER);
// Listenpreis und Rabatt im Zustand, damit der tatsächlich gezahlte Betrag sofort danebensteht.
const [netto, setNetto] = useState(werte.nettoBetrag === null ? "" : String(werte.nettoBetrag));
const [rabatt, setRabatt] = useState(werte.rabatt === null ? "" : String(werte.rabatt));
const zahl = (wert: string) => Number(wert.replace(",", ".")) || 0;
const nettoZahl = zahl(netto);
const rabattZahl = zahl(rabatt);
return (
<form action={aktion}>
<input type="hidden" name="laufzeitId" value={laufzeitId} />
@@ -93,16 +101,18 @@ export function ProxmoxRechnungFormular({
<div className="col-md-3">
<label className="form-label" htmlFor="px-netto">
Netto (€) *
Listenpreis netto (€) *
</label>
<input
id="px-netto"
name="nettoBetrag"
className="form-control"
inputMode="decimal"
defaultValue={werte.nettoBetrag ?? ""}
value={netto}
onChange={(e) => setNetto(e.target.value)}
required
/>
<div className="form-text">Betrag dieser einen Laufzeit, wie er auf der Rechnung steht.</div>
</div>
<div className="col-md-2">
<label className="form-label" htmlFor="px-ust">
@@ -125,8 +135,18 @@ export function ProxmoxRechnungFormular({
name="rabatt"
className="form-control"
inputMode="decimal"
defaultValue={werte.rabatt ?? ""}
value={rabatt}
onChange={(e) => setRabatt(e.target.value)}
/>
<div className="form-text">
{rabattZahl > 0 ? (
<>
Gerechnet wird mit <strong>{formatEuro(nettoZahl - rabattZahl)}</strong>.
</>
) : (
"Proxmox weist ihn je Key als eigene Zeile aus („Client Discount“)."
)}
</div>
</div>
<div className="col-md-4">
<label className="form-label" htmlFor="px-zahlungsart">
+2 -2
View File
@@ -37,7 +37,7 @@ export function StatusHaken({
}
return (
<span className="d-inline-flex align-items-center justify-content-center">
<span className="pl-haken d-inline-flex align-items-center justify-content-center">
{laeuft ? (
<span
className="spinner-border spinner-border-sm text-body-secondary"
@@ -47,7 +47,7 @@ export function StatusHaken({
) : (
<input
type="checkbox"
className="form-check-input pl-touch-target"
className="form-check-input"
checked={wert}
disabled={deaktiviert}
onChange={(e) => umschalten(e.target.checked)}
+1
View File
@@ -35,6 +35,7 @@ export async function einstellungenSpeichernAktion(
waehrung: text(formular, "waehrung") || vorher.waehrung,
steuersatz: zahl(formular, "steuersatz"),
steuersatzEinkauf: zahl(formular, "steuersatzEinkauf"),
zahlungszielTage: zahl(formular, "zahlungszielTage"),
margeProzent: zahl(formular, "margeProzent"),
+18 -2
View File
@@ -57,8 +57,24 @@ export async function hostKundeSetzen(formular: FormData) {
const standortId = formular.get("standortId")?.toString() || null;
if (!hostId) return;
await prisma.host.update({ where: { id: hostId }, data: { kundeId, standortId } });
await protokolliere("host.zugewiesen", "Host", hostId, { kundeId, standortId });
// Ein Standort gehört immer zu einem Kunden. Passt beides nicht zusammen – etwa weil der Kunde
// gewechselt hat –, wird der Standort verworfen statt eine falsche Zuordnung zu speichern.
const gueltigerStandort =
standortId && kundeId
? ((await prisma.standort.findFirst({
where: { id: standortId, kundeId },
select: { id: true },
})) ?? null)
: null;
await prisma.host.update({
where: { id: hostId },
data: { kundeId, standortId: gueltigerStandort?.id ?? null },
});
await protokolliere("host.zugewiesen", "Host", hostId, {
kundeId,
standortId: gueltigerStandort?.id ?? null,
});
if (kundeId) {
await prisma.alert.updateMany({
+33
View File
@@ -405,6 +405,38 @@ const kundenrechnungUnbezahlt: Regel = {
},
};
/**
* Der übliche Ablauf: Erst kommt die unbezahlte Rechnung von Proxmox, dann stellt man dem Kunden
* seine Rechnung, und **nachdem der Kunde gezahlt hat**, zahlt man selbst bei Proxmox. Hat der Kunde
* bezahlt und die Einkaufsrechnung ist noch offen, liegt das Geld da und die Rechnung auch – das ist
* der Moment zum Zahlen, unabhängig vom Fälligkeitsdatum (#037).
*/
const kundeBezahltEinkaufOffen: Regel = {
name: "kunde-bezahlt-einkauf-offen",
beschreibung: "Der Kunde hat bezahlt, die Proxmox-Rechnung ist noch offen",
standardSchwellwerte: {},
pruefe(kontext) {
return kontext.laufzeiten.flatMap((laufzeit) => {
if (!laufzeit.verkaufBezahlt || laufzeit.einkaufBezahlt !== false) return [];
return [
{
fingerprint: `kunde-bezahlt-einkauf-offen:${laufzeit.id}`,
regel: "kunde-bezahlt-einkauf-offen",
schwere: "INFO" as AlertSchwere,
titel: "Kunde hat bezahlt – Proxmox-Rechnung noch offen",
beschreibung: `${laufzeit.hostname} · ${kundenHinweis(laufzeit.kundeName)} · Einkauf ${
laufzeit.einkaufNummer ?? "ohne Nummer"
} über ${formatEuro(laufzeit.einkaufBetrag)} netto ist noch nicht bezahlt`,
hostId: laufzeit.hostId,
kundeId: laufzeit.kundeId,
laufzeitId: laufzeit.id,
},
];
});
},
};
export const REGELN: Regel[] = [
subscriptionLaeuftAb,
statusNichtAktiv,
@@ -417,6 +449,7 @@ export const REGELN: Regel[] = [
proxmoxRechnungFaellig,
kundenrechnungFehlt,
kundenrechnungUnbezahlt,
kundeBezahltEinkaufOffen,
];
/** Wendet alle aktiven Regeln an. */
+6
View File
@@ -24,7 +24,13 @@ export const einstellungenSchema = z.object({
// --- Kaufmännisches ---
waehrung: z.enum(["EUR", "CHF"]).default("EUR"),
/** Steuersatz der Kundenrechnungen. */
steuersatz: z.number().min(0).max(100).default(19),
/**
* Steuersatz der Proxmox-Rechnungen. Vorgabe 0: Proxmox sitzt in Österreich und rechnet
* gegenüber Unternehmen mit USt-IdNr. über Reverse Charge ab (#037).
*/
steuersatzEinkauf: z.number().min(0).max(100).default(0),
zahlungszielTage: z.number().int().min(0).max(120).default(14),
/** Vorschlag für den Aufschlag auf den Einkaufspreis beim Anlegen einer Kundenrechnung. */
margeProzent: z.number().min(0).max(500).default(20),
+5 -1
View File
@@ -87,7 +87,11 @@ export async function laufzeitenVonKunde(kundeId: string) {
einkaufSichtbar && laufzeit.proxmoxPosition
? {
nummer: laufzeit.proxmoxPosition.rechnung.nummer,
nettoBetrag: Number(laufzeit.proxmoxPosition.nettoBetrag),
// Nach Abzug des Rabatts – siehe #037.
nettoBetrag:
Number(laufzeit.proxmoxPosition.nettoBetrag) - Number(laufzeit.proxmoxPosition.rabatt ?? 0),
listenpreis: Number(laufzeit.proxmoxPosition.nettoBetrag),
rabatt: Number(laufzeit.proxmoxPosition.rabatt ?? 0),
bezahlt: laufzeit.proxmoxPosition.rechnung.bezahlt,
faelligAm: laufzeit.proxmoxPosition.rechnung.faelligAm,
}
+4 -1
View File
@@ -76,8 +76,11 @@ export async function rechnungsListe(filter: RechnungsFilter) {
const zeilen = laufzeiten.map((laufzeit) => {
// Einkaufspreis und Marge sind nichts für ein Kundenkonto. Sie werden hier entfernt,
// nicht erst in der Anzeige – sonst stünden sie im ausgelieferten HTML.
// Gerechnet wird mit dem Betrag nach Abzug des Rabatts – das ist, was tatsächlich gezahlt wird.
const einkauf =
einkaufSichtbar && laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null;
einkaufSichtbar && laufzeit.proxmoxPosition
? Number(laufzeit.proxmoxPosition.nettoBetrag) - Number(laufzeit.proxmoxPosition.rabatt ?? 0)
: null;
const verkauf = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
return {
+18 -7
View File
@@ -68,7 +68,8 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden.");
if (!eingabe.nummer.trim()) throw new RechnungsFehler("Bitte eine Rechnungsnummer angeben.");
const { steuersatz: vorgabeSteuersatz } = await einstellungen();
// Einkauf bei Proxmox: eigener Satz, Vorgabe 0 wegen Reverse Charge.
const { steuersatzEinkauf: vorgabeSteuersatz } = await einstellungen();
const nummer = eingabe.nummer.trim();
const bezeichnung = `${laufzeit.key} · ${laufzeit.host.hostname} · ${laufzeit.start.toISOString().slice(0, 10)} bis ${laufzeit.ende.toISOString().slice(0, 10)}`;
@@ -82,7 +83,6 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
datum: eingabe.datum,
faelligAm: eingabe.faelligAm,
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
rabatt: eingabe.rabatt ?? null,
zahlungsart: eingabe.zahlungsart ?? null,
bezahlt: eingabe.bezahlt,
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
@@ -97,7 +97,6 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
faelligAm: eingabe.faelligAm,
nettoBetrag: eingabe.nettoBetrag,
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
rabatt: eingabe.rabatt ?? null,
zahlungsart: eingabe.zahlungsart ?? null,
bezahlt: eingabe.bezahlt,
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
@@ -109,10 +108,20 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
const position = laufzeit.proxmoxPosition
? await tx.proxmoxRechnungPosition.update({
where: { id: laufzeit.proxmoxPosition.id },
data: { rechnungId: rechnung.id, bezeichnung, nettoBetrag: eingabe.nettoBetrag },
data: {
rechnungId: rechnung.id,
bezeichnung,
nettoBetrag: eingabe.nettoBetrag,
rabatt: eingabe.rabatt ?? null,
},
})
: await tx.proxmoxRechnungPosition.create({
data: { rechnungId: rechnung.id, bezeichnung, nettoBetrag: eingabe.nettoBetrag },
data: {
rechnungId: rechnung.id,
bezeichnung,
nettoBetrag: eingabe.nettoBetrag,
rabatt: eingabe.rabatt ?? null,
},
});
await tx.laufzeit.update({
@@ -120,9 +129,11 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
data: { proxmoxPositionId: position.id, status: "BESTAETIGT" },
});
// Der Rechnungsbetrag ist immer die Summe der Positionen.
// Der Rechnungsbetrag ist immer die Summe der Positionen – nach Abzug ihrer Rabatte.
// Genau so steht es auch auf der Rechnung von Proxmox: Listenpreise und Rabatte als eigene
// Zeilen, darunter die Summe.
const positionen = await tx.proxmoxRechnungPosition.findMany({ where: { rechnungId: rechnung.id } });
const summe = positionen.reduce((s, p) => s + Number(p.nettoBetrag), 0);
const summe = positionen.reduce((s, p) => s + Number(p.nettoBetrag) - Number(p.rabatt ?? 0), 0);
await tx.proxmoxRechnung.update({ where: { id: rechnung.id }, data: { nettoBetrag: summe } });
await protokolliere("proxmox_rechnung.erfasst", "ProxmoxRechnung", rechnung.id, {
+1 -1
View File
@@ -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] });
});
+102
View File
@@ -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);
});
});
+94
View File
@@ -0,0 +1,94 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/** Zuweisung von Kunde und Standort an einen Host (#036). */
vi.mock("@/server/auth/sitzungspflicht", () => ({
sitzungErforderlich: async () => ({ benutzerId: "test", rolle: "ADMIN", kundeId: null }),
sichtbarerKunde: async () => null,
darfEinkaufSehen: async () => true,
angemeldet: async () => null,
}));
vi.mock("next/cache", () => ({ revalidatePath: () => {} }));
const { hostKundeSetzen } = await import("@/server/actions/host-detail");
async function aufbau() {
const [kundeA, kundeB] = await Promise.all([
testPrisma.kunde.create({ data: { name: "Kunde A", kundennummer: "K-A" } }),
testPrisma.kunde.create({ data: { name: "Kunde B", kundennummer: "K-B" } }),
]);
const [standortA, standortB] = await Promise.all([
testPrisma.standort.create({ data: { kundeId: kundeA.id, name: "Rechenzentrum A" } }),
testPrisma.standort.create({ data: { kundeId: kundeB.id, name: "Rechenzentrum B" } }),
]);
const host = await testPrisma.host.create({
data: { agentId: "a-1", hostname: "pve-01", produkt: "PVE" },
});
return { kundeA, kundeB, standortA, standortB, host };
}
function formular(werte: Record<string, string>) {
const daten = new FormData();
for (const [name, wert] of Object.entries(werte)) daten.set(name, wert);
return daten;
}
describe.runIf(datenbankVerfuegbar)("Kunde und Standort setzen", () => {
beforeEach(async () => {
await datenbankLeeren();
});
afterAll(async () => {
await testPrisma.$disconnect();
});
it("setzt Kunde und Standort zusammen", async () => {
const { kundeA, standortA, host } = await aufbau();
await hostKundeSetzen(formular({ hostId: host.id, kundeId: kundeA.id, standortId: standortA.id }));
const danach = await testPrisma.host.findUniqueOrThrow({ where: { id: host.id } });
expect(danach.kundeId).toBe(kundeA.id);
expect(danach.standortId).toBe(standortA.id);
});
it("verwirft einen Standort, der zu einem anderen Kunden gehört", async () => {
const { kundeA, standortB, host } = await aufbau();
await hostKundeSetzen(formular({ hostId: host.id, kundeId: kundeA.id, standortId: standortB.id }));
const danach = await testPrisma.host.findUniqueOrThrow({ where: { id: host.id } });
expect(danach.kundeId).toBe(kundeA.id);
expect(danach.standortId).toBeNull();
});
it("löst die Zuordnung samt Standort, wenn kein Kunde gewählt ist", async () => {
const { kundeA, standortA, host } = await aufbau();
await hostKundeSetzen(formular({ hostId: host.id, kundeId: kundeA.id, standortId: standortA.id }));
await hostKundeSetzen(formular({ hostId: host.id, kundeId: "", standortId: "" }));
const danach = await testPrisma.host.findUniqueOrThrow({ where: { id: host.id } });
expect(danach.kundeId).toBeNull();
expect(danach.standortId).toBeNull();
});
it("schließt die Warnung „Host ohne Kunde“ beim Zuweisen", async () => {
const { kundeA, host } = await aufbau();
await testPrisma.alert.create({
data: {
regel: "host-ohne-kunde",
fingerprint: `host-ohne-kunde:${host.id}`,
schwere: "INFO",
titel: "Host ohne Kunde",
beschreibung: "…",
hostId: host.id,
},
});
await hostKundeSetzen(formular({ hostId: host.id, kundeId: kundeA.id }));
const alert = await testPrisma.alert.findFirstOrThrow({ where: { hostId: host.id } });
expect(alert.erledigtAm).not.toBeNull();
});
});
+41 -3
View File
@@ -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);
});
});
});