Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e570d30019 | ||
|
|
c5293f5915 | ||
|
|
c1177067e6 | ||
|
|
e50fb30ab3 |
@@ -17,6 +17,29 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
|
||||||
|
## [0.22.1] – 2026-10-03
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- **Die ganze Seite wurde auf dem Telefon herausgezoomt**, sobald die Warnungen geöffnet wurden oder eine Host-Detailansicht eine lange Server-ID enthielt. Ursache war jeweils ein Element breiter als das Fenster (#038)
|
||||||
|
- Die Laufzeit-Detailseite rechnete noch mit dem Listenpreis statt mit dem Betrag nach Rabatt – Marge und der Vorschlag für die Kundenrechnung waren um den Rabatt zu hoch (#037)
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- `npm run screenshots -- --mobil name=/pfad` für gezielte Aufnahmen in Telefonbreite (#038)
|
||||||
|
|
||||||
|
## [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
|
## [0.21.0] – 2026-10-03
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Zeitübersicht
|
## Zeitübersicht
|
||||||
|
|
||||||
**Gesamt:** 18 h 58 min
|
**Gesamt:** 20 h 33 min
|
||||||
|
|
||||||
| Issue | Zeit |
|
| Issue | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -42,6 +42,8 @@
|
|||||||
| #034 Bedienung auf dem Handy | 0 h 40 min |
|
| #034 Bedienung auf dem Handy | 0 h 40 min |
|
||||||
| #035 Tabellen als Karten | 0 h 50 min |
|
| #035 Tabellen als Karten | 0 h 50 min |
|
||||||
| #036 Standort am Host | 0 h 30 min |
|
| #036 Standort am Host | 0 h 30 min |
|
||||||
|
| #037 Rabatt je Position | 0 h 55 min |
|
||||||
|
| #038 Überbreite auf dem Handy | 0 h 40 min |
|
||||||
|
|
||||||
| Meilenstein | Zeit |
|
| Meilenstein | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -64,7 +66,7 @@
|
|||||||
|
|
||||||
## 2026-09-17 · Sitzung 1 · 19:36–laufend
|
## 2026-09-17 · Sitzung 1 · 19:36–laufend
|
||||||
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
|
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
|
||||||
**Version:** 0.21.0
|
**Version:** 0.22.1
|
||||||
|
|
||||||
### Erledigt
|
### Erledigt
|
||||||
- Auftrag gelesen, Rückfragen gestellt
|
- Auftrag gelesen, Rückfragen gestellt
|
||||||
@@ -189,6 +191,14 @@
|
|||||||
- 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
|
- 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
|
- 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
|
||||||
|
|
||||||
|
- Überbreite auf dem Handy (#038): Drei Verursacher, ein Muster – ein Element breiter als das Fenster zoomt die ganze Seite heraus
|
||||||
|
- Dabei einen eigenen Fehler aus #037 gefunden: Die Detailseite rechnete weiter mit dem Listenpreis. Beim Ändern einer Rechengröße reicht es nicht, die Abfragen anzupassen – auch die Seiten rechnen selbst
|
||||||
|
- Das Aufnahme-Skript kann jetzt gezielt in Telefonbreite aufnehmen; damit ließ sich die Behebung belegen statt nur behaupten
|
||||||
|
|
||||||
### Nächster Schritt
|
### Nächster Schritt
|
||||||
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
|
- #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)
|
- Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
## 7. Warnungen
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 194 KiB |
@@ -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.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# #038 Seite wird auf dem Handy herausgezoomt
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.22.1
|
||||||
|
**Labels:** bug, ui
|
||||||
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
Zwei Meldungen aus dem Einsatz: Beim Öffnen der Warnungen verkleinerte sich die ganze Seite, und die
|
||||||
|
Host-Detailansicht saß verschoben im Bild.
|
||||||
|
|
||||||
|
## Ursache
|
||||||
|
Beides derselbe Mechanismus: Ein Element ist breiter als das Fenster, also zoomt der Browser die
|
||||||
|
**ganze Seite** heraus, damit sie hineinpasst. Drei Verursacher:
|
||||||
|
|
||||||
|
1. Die Klappliste der Warnungen war mit 22 rem fest breiter als ein 390-px-Fenster.
|
||||||
|
2. Drei Tabellen in der Host-Detailansicht (Keys, Server-IDs, Tokens) standen ohne
|
||||||
|
`table-responsive` – anders als die übrigen.
|
||||||
|
3. Eine 32-stellige Server-ID bricht nicht von selbst um.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
- Klapplisten richten sich auf dem Telefon am Fenster aus (`position: fixed`, links und rechts 0,5 rem,
|
||||||
|
höchstens 70 % Fensterhöhe mit eigenem Scroll) statt am Knopf
|
||||||
|
- Die drei Tabellen bekommen ihre Hülle
|
||||||
|
- Lange Schlüssel und IDs dürfen umbrechen (`overflow-wrap: anywhere`)
|
||||||
|
- `npm run screenshots -- --mobil name=/pfad` nimmt gezielt eine Seite in Telefonbreite auf; damit
|
||||||
|
wurde die Host-Detailansicht gegengeprüft
|
||||||
|
|
||||||
|
## Nachtrag zu #037
|
||||||
|
Beim Prüfen fiel auf, dass die Laufzeit-Detailseite noch mit dem **Listenpreis** rechnete statt mit
|
||||||
|
dem Betrag nach Rabatt – Marge und Vorschlag für die Kundenrechnung waren dort um den Rabatt zu hoch.
|
||||||
|
Die Seite zeigt jetzt „990,00 € · 1.100,00 € − 110,00 € Rabatt“ und rechnet mit 990.
|
||||||
@@ -40,4 +40,6 @@ 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 |
|
| [#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 |
|
| [#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 |
|
| [#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 |
|
||||||
|
| [#038](038-ueberbreite-handy.md) | Seite wird auf dem Handy herausgezoomt | erledigt | v0.22.1 | 2 h | 0 h 40 min |
|
||||||
|
| | **Summe** | | | **162 h** | **20 h 33 min** |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proxledger",
|
"name": "proxledger",
|
||||||
"version": "0.21.0",
|
"version": "0.22.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"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;
|
||||||
@@ -261,9 +261,10 @@ model ProxmoxRechnung {
|
|||||||
nummer String @unique
|
nummer String @unique
|
||||||
datum DateTime @db.Date
|
datum DateTime @db.Date
|
||||||
faelligAm 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)
|
nettoBetrag Decimal @db.Decimal(12, 2)
|
||||||
ustSatz Decimal @default(19.00) @db.Decimal(5, 2)
|
/// Bei Proxmox in aller Regel 0: Reverse Charge nach Art. 196 (#037).
|
||||||
rabatt Decimal? @db.Decimal(12, 2)
|
ustSatz Decimal @default(0.00) @db.Decimal(5, 2)
|
||||||
zahlungsart String?
|
zahlungsart String?
|
||||||
bezahlt Boolean @default(false)
|
bezahlt Boolean @default(false)
|
||||||
bezahltAm DateTime? @db.Date
|
bezahltAm DateTime? @db.Date
|
||||||
@@ -284,7 +285,10 @@ model ProxmoxRechnungPosition {
|
|||||||
rechnungId String
|
rechnungId String
|
||||||
bezeichnung String
|
bezeichnung String
|
||||||
menge Int @default(1)
|
menge Int @default(1)
|
||||||
|
/// Listenpreis dieser Position, wie er auf der Rechnung steht.
|
||||||
nettoBetrag Decimal @db.Decimal(12, 2)
|
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)
|
rechnung ProxmoxRechnung @relation(fields: [rechnungId], references: [id], onDelete: Cascade)
|
||||||
laufzeit Laufzeit?
|
laufzeit Laufzeit?
|
||||||
|
|||||||
@@ -166,7 +166,6 @@ async function seedDemo() {
|
|||||||
faelligAm: datum(vorlage.faelligAm),
|
faelligAm: datum(vorlage.faelligAm),
|
||||||
nettoBetrag: zugehoerig.reduce((summe, l) => summe + (l.einkauf?.nettoBetrag ?? 0), 0),
|
nettoBetrag: zugehoerig.reduce((summe, l) => summe + (l.einkauf?.nettoBetrag ?? 0), 0),
|
||||||
ustSatz: vorlage.ustSatz,
|
ustSatz: vorlage.ustSatz,
|
||||||
rabatt: vorlage.rabatt ?? null,
|
|
||||||
zahlungsart: vorlage.zahlungsart ?? null,
|
zahlungsart: vorlage.zahlungsart ?? null,
|
||||||
bezahlt: vorlage.bezahlt,
|
bezahlt: vorlage.bezahlt,
|
||||||
bezahltAm: datum(vorlage.bezahltAm),
|
bezahltAm: datum(vorlage.bezahltAm),
|
||||||
@@ -175,6 +174,8 @@ async function seedDemo() {
|
|||||||
id: `prp-${l.id}`,
|
id: `prp-${l.id}`,
|
||||||
bezeichnung: `${l.key} · ${l.start} bis ${l.ende}`,
|
bezeichnung: `${l.key} · ${l.start} bis ${l.ende}`,
|
||||||
nettoBetrag: l.einkauf?.nettoBetrag ?? 0,
|
nettoBetrag: l.einkauf?.nettoBetrag ?? 0,
|
||||||
|
// Der Rabatt hängt seit #037 an der Position – wie auf der echten Proxmox-Rechnung.
|
||||||
|
rabatt: vorlage.rabatt ?? null,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000";
|
|||||||
const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder");
|
const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder");
|
||||||
const ALLE = process.argv.includes("--alle");
|
const ALLE = process.argv.includes("--alle");
|
||||||
|
|
||||||
|
/** Zusätzliche Handy-Aufnahmen von der Befehlszeile: --mobil name=/pfad */
|
||||||
|
const MOBIL_ZUSATZ = process.argv
|
||||||
|
.filter((wert, i) => process.argv[i - 1] === "--mobil")
|
||||||
|
.map((eintrag) => {
|
||||||
|
const [name, ...rest] = eintrag.split("=");
|
||||||
|
return { name, pfad: rest.join("="), titel: "Handy" };
|
||||||
|
});
|
||||||
|
|
||||||
/** Zusätzliche Seiten von der Befehlszeile: --seite name=/pfad */
|
/** Zusätzliche Seiten von der Befehlszeile: --seite name=/pfad */
|
||||||
const ZUSATZ = process.argv
|
const ZUSATZ = process.argv
|
||||||
.filter((wert, i) => process.argv[i - 1] === "--seite")
|
.filter((wert, i) => process.argv[i - 1] === "--seite")
|
||||||
@@ -187,6 +195,11 @@ async function main() {
|
|||||||
console.log(` ${seite.name}.png – ${seite.titel}`);
|
console.log(` ${seite.name}.png – ${seite.titel}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const seite of MOBIL_ZUSATZ) {
|
||||||
|
await aufnehmen(hell, seite.pfad, path.join(ZIEL, `${seite.name}.png`), 390, 1800, adresse);
|
||||||
|
console.log(` ${seite.name}.png – ${seite.titel}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (ALLE) {
|
if (ALLE) {
|
||||||
const dunkel = profilVorbereiten("dunkel", true);
|
const dunkel = profilVorbereiten("dunkel", true);
|
||||||
for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) {
|
for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) {
|
||||||
|
|||||||
@@ -225,7 +225,8 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
inhalt: (
|
inhalt: (
|
||||||
<>
|
<>
|
||||||
<h3 className="h6">Keys</h3>
|
<h3 className="h6">Keys</h3>
|
||||||
<table className="table table-striped align-middle mb-4">
|
<div className="table-responsive mb-4">
|
||||||
|
<table className="table table-striped align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Key</th>
|
<th scope="col">Key</th>
|
||||||
@@ -252,8 +253,10 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3 className="h6">Server-IDs</h3>
|
<h3 className="h6">Server-IDs</h3>
|
||||||
|
<div className="table-responsive">
|
||||||
<table className="table table-striped align-middle mb-0">
|
<table className="table table-striped align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -281,6 +284,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{host.serverIdHistorie.length > 1 ? (
|
{host.serverIdHistorie.length > 1 ? (
|
||||||
<p className="small pl-text-warning mt-2 mb-0">
|
<p className="small pl-text-warning mt-2 mb-0">
|
||||||
Mehrere Server-IDs bedeuten Hardwaretausch oder Neuinstallation – der Key muss
|
Mehrere Server-IDs bedeuten Hardwaretausch oder Neuinstallation – der Key muss
|
||||||
@@ -429,6 +433,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
titel: "Tokens",
|
titel: "Tokens",
|
||||||
icon: "bi-key-fill",
|
icon: "bi-key-fill",
|
||||||
inhalt: (
|
inhalt: (
|
||||||
|
<div className="table-responsive">
|
||||||
<table className="table table-striped align-middle mb-0">
|
<table className="table table-striped align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -476,6 +481,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
|||||||
|
|
||||||
const einkauf = laufzeit.proxmoxPosition?.rechnung ?? null;
|
const einkauf = laufzeit.proxmoxPosition?.rechnung ?? null;
|
||||||
const verkauf = laufzeit.kundenPosition?.rechnung ?? null;
|
const verkauf = laufzeit.kundenPosition?.rechnung ?? null;
|
||||||
const einkaufBetrag = laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null;
|
// Gerechnet wird mit dem Betrag nach Abzug des Rabatts – das ist, was tatsächlich gezahlt wird (#037).
|
||||||
|
const einkaufListenpreis = laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null;
|
||||||
|
const einkaufRabatt = Number(laufzeit.proxmoxPosition?.rabatt ?? 0);
|
||||||
|
const einkaufBetrag = einkaufListenpreis === null ? null : einkaufListenpreis - einkaufRabatt;
|
||||||
const verkaufBetrag = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
|
const verkaufBetrag = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
|
||||||
const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null;
|
const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null;
|
||||||
|
|
||||||
@@ -109,7 +112,14 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
|||||||
<hr />
|
<hr />
|
||||||
<dl className="row mb-0">
|
<dl className="row mb-0">
|
||||||
<dt className="col-5 fw-normal text-body-secondary">Einkauf netto</dt>
|
<dt className="col-5 fw-normal text-body-secondary">Einkauf netto</dt>
|
||||||
<dd className="col-7">{formatEuro(einkaufBetrag)}</dd>
|
<dd className="col-7">
|
||||||
|
{formatEuro(einkaufBetrag)}
|
||||||
|
{einkaufRabatt > 0 ? (
|
||||||
|
<span className="small text-body-secondary d-block">
|
||||||
|
{formatEuro(einkaufListenpreis)} − {formatEuro(einkaufRabatt)} Rabatt
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</dd>
|
||||||
<dt className="col-5 fw-normal text-body-secondary">Verkauf netto</dt>
|
<dt className="col-5 fw-normal text-body-secondary">Verkauf netto</dt>
|
||||||
<dd className="col-7">{formatEuro(verkaufBetrag)}</dd>
|
<dd className="col-7">{formatEuro(verkaufBetrag)}</dd>
|
||||||
<dt className="col-5 fw-normal text-body-secondary">Marge</dt>
|
<dt className="col-5 fw-normal text-body-secondary">Marge</dt>
|
||||||
@@ -135,7 +145,15 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
|||||||
) : (
|
) : (
|
||||||
position.bezeichnung
|
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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -156,12 +174,13 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
|||||||
nummer: einkauf?.nummer ?? null,
|
nummer: einkauf?.nummer ?? null,
|
||||||
datum: einkauf?.datum.toISOString() ?? null,
|
datum: einkauf?.datum.toISOString() ?? null,
|
||||||
faelligAm: einkauf?.faelligAm?.toISOString() ?? faelligVorschlag,
|
faelligAm: einkauf?.faelligAm?.toISOString() ?? faelligVorschlag,
|
||||||
nettoBetrag: einkaufBetrag,
|
nettoBetrag: einkaufListenpreis,
|
||||||
ustSatz: Number(einkauf?.ustSatz ?? vorgaben.steuersatz),
|
ustSatz: Number(einkauf?.ustSatz ?? vorgaben.steuersatz),
|
||||||
rabatt:
|
rabatt:
|
||||||
einkauf?.rabatt === null || einkauf?.rabatt === undefined
|
laufzeit.proxmoxPosition?.rabatt === null ||
|
||||||
|
laufzeit.proxmoxPosition?.rabatt === undefined
|
||||||
? null
|
? null
|
||||||
: Number(einkauf.rabatt),
|
: Number(laufzeit.proxmoxPosition.rabatt),
|
||||||
zahlungsart: einkauf?.zahlungsart ?? null,
|
zahlungsart: einkauf?.zahlungsart ?? null,
|
||||||
bezahlt: einkauf?.bezahlt ?? false,
|
bezahlt: einkauf?.bezahlt ?? false,
|
||||||
bezahltAm: einkauf?.bezahltAm?.toISOString() ?? null,
|
bezahltAm: einkauf?.bezahltAm?.toISOString() ?? null,
|
||||||
|
|||||||
@@ -395,6 +395,12 @@ body {
|
|||||||
width: 13rem;
|
width: 13rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Klapplisten in der Kopfzeile (Warnungen, Benutzermenü). */
|
||||||
|
.pl-klappliste {
|
||||||
|
width: 22rem;
|
||||||
|
max-width: calc(100vw - 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Mobil ---------- */
|
/* ---------- Mobil ---------- */
|
||||||
|
|
||||||
@media (max-width: 575.98px) {
|
@media (max-width: 575.98px) {
|
||||||
@@ -425,6 +431,30 @@ body {
|
|||||||
* Zellen ohne `data-label` stehen für sich – etwa eine Zeile mit Knöpfen.
|
* Zellen ohne `data-label` stehen für sich – etwa eine Zeile mit Knöpfen.
|
||||||
*/
|
*/
|
||||||
@media (max-width: 767.98px) {
|
@media (max-width: 767.98px) {
|
||||||
|
/*
|
||||||
|
* Auf dem Telefon ist die Klappliste breiter als das Fenster – der Browser zoomt dann die
|
||||||
|
* **ganze Seite** heraus, damit sie hineinpasst. Deshalb hier fest am Fenster ausrichten
|
||||||
|
* statt am Knopf (#038).
|
||||||
|
*/
|
||||||
|
.pl-klappliste {
|
||||||
|
position: fixed;
|
||||||
|
top: 3.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
left: 0.5rem;
|
||||||
|
width: auto;
|
||||||
|
max-width: none;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Lange Schlüssel und Server-IDs dürfen umbrechen. Sonst machen sie die Seite breiter als das
|
||||||
|
* Fenster – und der Browser zoomt die ganze Seite heraus, damit sie hineinpasst (#038).
|
||||||
|
*/
|
||||||
|
.pl-mono {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.pl-karten-tabelle,
|
.pl-karten-tabelle,
|
||||||
.pl-karten-tabelle tbody,
|
.pl-karten-tabelle tbody,
|
||||||
.pl-karten-tabelle tr,
|
.pl-karten-tabelle tr,
|
||||||
|
|||||||
@@ -119,8 +119,8 @@ export function SystemFormular({ werte, appUrl }: { werte: Einstellungen; appUrl
|
|||||||
|
|
||||||
<Feld
|
<Feld
|
||||||
name="steuersatz"
|
name="steuersatz"
|
||||||
beschriftung="Steuersatz in Prozent"
|
beschriftung="Steuersatz Verkauf in Prozent"
|
||||||
hinweis="Vorgabe für neue Rechnungspositionen."
|
hinweis="Vorgabe für neue Kundenrechnungen."
|
||||||
fehler={fehler.steuersatz}
|
fehler={fehler.steuersatz}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -135,6 +135,24 @@ export function SystemFormular({ werte, appUrl }: { werte: Einstellungen; appUrl
|
|||||||
/>
|
/>
|
||||||
</Feld>
|
</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
|
<Feld
|
||||||
name="zahlungszielTage"
|
name="zahlungszielTage"
|
||||||
beschriftung="Zahlungsziel in Tagen"
|
beschriftung="Zahlungsziel in Tagen"
|
||||||
|
|||||||
@@ -140,8 +140,7 @@ export function Header({
|
|||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
className={`dropdown-menu dropdown-menu-end shadow ${glockeOffen ? "show" : ""}`}
|
className={`dropdown-menu dropdown-menu-end shadow pl-klappliste ${glockeOffen ? "show" : ""}`}
|
||||||
style={{ width: "22rem" }}
|
|
||||||
>
|
>
|
||||||
<span className="dropdown-header">{alerts.length} offene Warnungen</span>
|
<span className="dropdown-header">{alerts.length} offene Warnungen</span>
|
||||||
<div className="dropdown-divider" />
|
<div className="dropdown-divider" />
|
||||||
@@ -206,7 +205,9 @@ export function Header({
|
|||||||
<i className="bi bi-person-circle" aria-hidden="true" />
|
<i className="bi bi-person-circle" aria-hidden="true" />
|
||||||
<span className="d-none d-sm-inline">{benutzer}</span>
|
<span className="d-none d-sm-inline">{benutzer}</span>
|
||||||
</button>
|
</button>
|
||||||
<div className={`dropdown-menu dropdown-menu-end shadow ${benutzerOffen ? "show" : ""}`}>
|
<div
|
||||||
|
className={`dropdown-menu dropdown-menu-end shadow pl-klappliste ${benutzerOffen ? "show" : ""}`}
|
||||||
|
>
|
||||||
<span className="dropdown-header">
|
<span className="dropdown-header">
|
||||||
Angemeldet als {benutzer} · {ROLLEN_TEXT[rolle]}
|
Angemeldet als {benutzer} · {ROLLEN_TEXT[rolle]}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState } from "react";
|
import { useActionState, useState } from "react";
|
||||||
import { kundenrechnungSpeichern, proxmoxRechnungSpeichern } from "@/server/actions/rechnungen";
|
import { kundenrechnungSpeichern, proxmoxRechnungSpeichern } from "@/server/actions/rechnungen";
|
||||||
import type { AktionsErgebnis } from "@/server/actions/kunden";
|
import type { AktionsErgebnis } from "@/server/actions/kunden";
|
||||||
|
import { formatEuro } from "@/lib/format";
|
||||||
|
|
||||||
const LEER: AktionsErgebnis = {};
|
const LEER: AktionsErgebnis = {};
|
||||||
|
|
||||||
@@ -42,6 +43,13 @@ export function ProxmoxRechnungFormular({
|
|||||||
}) {
|
}) {
|
||||||
const [ergebnis, aktion, laeuft] = useActionState(proxmoxRechnungSpeichern, LEER);
|
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 (
|
return (
|
||||||
<form action={aktion}>
|
<form action={aktion}>
|
||||||
<input type="hidden" name="laufzeitId" value={laufzeitId} />
|
<input type="hidden" name="laufzeitId" value={laufzeitId} />
|
||||||
@@ -93,16 +101,18 @@ export function ProxmoxRechnungFormular({
|
|||||||
|
|
||||||
<div className="col-md-3">
|
<div className="col-md-3">
|
||||||
<label className="form-label" htmlFor="px-netto">
|
<label className="form-label" htmlFor="px-netto">
|
||||||
Netto (€) *
|
Listenpreis netto (€) *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="px-netto"
|
id="px-netto"
|
||||||
name="nettoBetrag"
|
name="nettoBetrag"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
defaultValue={werte.nettoBetrag ?? ""}
|
value={netto}
|
||||||
|
onChange={(e) => setNetto(e.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<div className="form-text">Betrag dieser einen Laufzeit, wie er auf der Rechnung steht.</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-2">
|
<div className="col-md-2">
|
||||||
<label className="form-label" htmlFor="px-ust">
|
<label className="form-label" htmlFor="px-ust">
|
||||||
@@ -125,8 +135,18 @@ export function ProxmoxRechnungFormular({
|
|||||||
name="rabatt"
|
name="rabatt"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
inputMode="decimal"
|
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>
|
||||||
<div className="col-md-4">
|
<div className="col-md-4">
|
||||||
<label className="form-label" htmlFor="px-zahlungsart">
|
<label className="form-label" htmlFor="px-zahlungsart">
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export async function einstellungenSpeichernAktion(
|
|||||||
|
|
||||||
waehrung: text(formular, "waehrung") || vorher.waehrung,
|
waehrung: text(formular, "waehrung") || vorher.waehrung,
|
||||||
steuersatz: zahl(formular, "steuersatz"),
|
steuersatz: zahl(formular, "steuersatz"),
|
||||||
|
steuersatzEinkauf: zahl(formular, "steuersatzEinkauf"),
|
||||||
zahlungszielTage: zahl(formular, "zahlungszielTage"),
|
zahlungszielTage: zahl(formular, "zahlungszielTage"),
|
||||||
margeProzent: zahl(formular, "margeProzent"),
|
margeProzent: zahl(formular, "margeProzent"),
|
||||||
|
|
||||||
|
|||||||
@@ -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[] = [
|
export const REGELN: Regel[] = [
|
||||||
subscriptionLaeuftAb,
|
subscriptionLaeuftAb,
|
||||||
statusNichtAktiv,
|
statusNichtAktiv,
|
||||||
@@ -417,6 +449,7 @@ export const REGELN: Regel[] = [
|
|||||||
proxmoxRechnungFaellig,
|
proxmoxRechnungFaellig,
|
||||||
kundenrechnungFehlt,
|
kundenrechnungFehlt,
|
||||||
kundenrechnungUnbezahlt,
|
kundenrechnungUnbezahlt,
|
||||||
|
kundeBezahltEinkaufOffen,
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Wendet alle aktiven Regeln an. */
|
/** Wendet alle aktiven Regeln an. */
|
||||||
|
|||||||
@@ -24,7 +24,13 @@ export const einstellungenSchema = z.object({
|
|||||||
|
|
||||||
// --- Kaufmännisches ---
|
// --- Kaufmännisches ---
|
||||||
waehrung: z.enum(["EUR", "CHF"]).default("EUR"),
|
waehrung: z.enum(["EUR", "CHF"]).default("EUR"),
|
||||||
|
/** Steuersatz der Kundenrechnungen. */
|
||||||
steuersatz: z.number().min(0).max(100).default(19),
|
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),
|
zahlungszielTage: z.number().int().min(0).max(120).default(14),
|
||||||
/** Vorschlag für den Aufschlag auf den Einkaufspreis beim Anlegen einer Kundenrechnung. */
|
/** Vorschlag für den Aufschlag auf den Einkaufspreis beim Anlegen einer Kundenrechnung. */
|
||||||
margeProzent: z.number().min(0).max(500).default(20),
|
margeProzent: z.number().min(0).max(500).default(20),
|
||||||
|
|||||||
@@ -87,7 +87,11 @@ export async function laufzeitenVonKunde(kundeId: string) {
|
|||||||
einkaufSichtbar && laufzeit.proxmoxPosition
|
einkaufSichtbar && laufzeit.proxmoxPosition
|
||||||
? {
|
? {
|
||||||
nummer: laufzeit.proxmoxPosition.rechnung.nummer,
|
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,
|
bezahlt: laufzeit.proxmoxPosition.rechnung.bezahlt,
|
||||||
faelligAm: laufzeit.proxmoxPosition.rechnung.faelligAm,
|
faelligAm: laufzeit.proxmoxPosition.rechnung.faelligAm,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,8 +76,11 @@ export async function rechnungsListe(filter: RechnungsFilter) {
|
|||||||
const zeilen = laufzeiten.map((laufzeit) => {
|
const zeilen = laufzeiten.map((laufzeit) => {
|
||||||
// Einkaufspreis und Marge sind nichts für ein Kundenkonto. Sie werden hier entfernt,
|
// 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.
|
// 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 =
|
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;
|
const verkauf = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
|
|||||||
if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden.");
|
if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden.");
|
||||||
if (!eingabe.nummer.trim()) throw new RechnungsFehler("Bitte eine Rechnungsnummer angeben.");
|
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 nummer = eingabe.nummer.trim();
|
||||||
const bezeichnung = `${laufzeit.key} · ${laufzeit.host.hostname} · ${laufzeit.start.toISOString().slice(0, 10)} bis ${laufzeit.ende.toISOString().slice(0, 10)}`;
|
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,
|
datum: eingabe.datum,
|
||||||
faelligAm: eingabe.faelligAm,
|
faelligAm: eingabe.faelligAm,
|
||||||
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
|
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
|
||||||
rabatt: eingabe.rabatt ?? null,
|
|
||||||
zahlungsart: eingabe.zahlungsart ?? null,
|
zahlungsart: eingabe.zahlungsart ?? null,
|
||||||
bezahlt: eingabe.bezahlt,
|
bezahlt: eingabe.bezahlt,
|
||||||
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
|
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
|
||||||
@@ -97,7 +97,6 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
|
|||||||
faelligAm: eingabe.faelligAm,
|
faelligAm: eingabe.faelligAm,
|
||||||
nettoBetrag: eingabe.nettoBetrag,
|
nettoBetrag: eingabe.nettoBetrag,
|
||||||
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
|
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
|
||||||
rabatt: eingabe.rabatt ?? null,
|
|
||||||
zahlungsart: eingabe.zahlungsart ?? null,
|
zahlungsart: eingabe.zahlungsart ?? null,
|
||||||
bezahlt: eingabe.bezahlt,
|
bezahlt: eingabe.bezahlt,
|
||||||
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
|
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
|
||||||
@@ -109,10 +108,20 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
|
|||||||
const position = laufzeit.proxmoxPosition
|
const position = laufzeit.proxmoxPosition
|
||||||
? await tx.proxmoxRechnungPosition.update({
|
? await tx.proxmoxRechnungPosition.update({
|
||||||
where: { id: laufzeit.proxmoxPosition.id },
|
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({
|
: 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({
|
await tx.laufzeit.update({
|
||||||
@@ -120,9 +129,11 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
|
|||||||
data: { proxmoxPositionId: position.id, status: "BESTAETIGT" },
|
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 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 tx.proxmoxRechnung.update({ where: { id: rechnung.id }, data: { nettoBetrag: summe } });
|
||||||
|
|
||||||
await protokolliere("proxmox_rechnung.erfasst", "ProxmoxRechnung", rechnung.id, {
|
await protokolliere("proxmox_rechnung.erfasst", "ProxmoxRechnung", rechnung.id, {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ describe.runIf(datenbankVerfuegbar)("Abgleich der Warnungen", () => {
|
|||||||
|
|
||||||
it("legt die Standard-Einstellungen für alle Regeln an", async () => {
|
it("legt die Standard-Einstellungen für alle Regeln an", async () => {
|
||||||
const einstellungen = await testPrisma.warnregelEinstellung.findMany();
|
const einstellungen = await testPrisma.warnregelEinstellung.findMany();
|
||||||
expect(einstellungen).toHaveLength(11);
|
expect(einstellungen).toHaveLength(12);
|
||||||
const ablauf = einstellungen.find((e) => e.regel === "subscription-laeuft-ab");
|
const ablauf = einstellungen.find((e) => e.regel === "subscription-laeuft-ab");
|
||||||
expect(ablauf?.schwellwerte).toEqual({ tage: [30, 14, 7] });
|
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", () => {
|
describe("Warnregeln", () => {
|
||||||
it("prüft alle elf Regeln, wenn nichts abgeschaltet ist", () => {
|
it("prüft alle zwölf Regeln, wenn nichts abgeschaltet ist", () => {
|
||||||
expect(REGELN).toHaveLength(11);
|
expect(REGELN).toHaveLength(12);
|
||||||
expect(new Set(REGELN.map((r) => r.name)).size).toBe(11);
|
expect(new Set(REGELN.map((r) => r.name)).size).toBe(12);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Subscription läuft ab", () => {
|
describe("Subscription läuft ab", () => {
|
||||||
@@ -376,4 +376,42 @@ describe("Warnregeln", () => {
|
|||||||
const fingerprints = alleRegelnPruefen(k).map((kandidat) => kandidat.fingerprint);
|
const fingerprints = alleRegelnPruefen(k).map((kandidat) => kandidat.fingerprint);
|
||||||
expect(new Set(fingerprints).size).toBe(fingerprints.length);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||