feat(rechnungen): Rabatt je Position, Reverse Charge, Ablauf-Regel (#037)
Eine echte Proxmox-Rechnung zeigt je Subscription-Key eine Zeile mit Listenpreis und eine mit "Client Discount". Unser Modell hing den Rabatt an die Rechnung - bei einer Sammelrechnung ueber zwei Hosts liesse er sich damit keinem Host zuordnen, und die Marge je Host waere falsch. Schlimmer: Er wurde nirgends abgezogen. Jetzt haengt der Rabatt an der Position, der Rechnungsbetrag ist die Summe nach Abzug, und Marge wie Summen rechnen mit dem tatsaechlich gezahlten Betrag. Die Migration verteilt vorhandene Rabatte anteilig auf die Positionen und rechnet die Summen neu - nichts geht verloren. Dazu ein eigener Steuersatz fuer den Einkauf mit Vorgabe 0: Proxmox rechnet gegenueber Unternehmen mit USt-IdNr. ueber Reverse Charge ab. Neue zwoelfte Warnregel aus Florians Ablauf: Der Kunde hat bezahlt, die Proxmox-Rechnung ist noch offen - der Moment, an dem man selbst zahlt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"),
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user