feat(einstellungen): Betriebswerte im Dashboard einstellbar (#025)
Vier Bereiche unter Einstellungen -> System: Adresse und Agent, Kaufmaennisches, Sicherheit sowie Firma und Darstellung. Alles liegt als eine JSON-Zeile in der Tabelle Einstellung; fehlende Felder bekommen ihren Vorgabewert, eine kaputte Zeile legt den Betrieb nicht lahm. Die Werte wirken auch wirklich: Die Dashboard-Adresse und der Melderhythmus landen im ausgelieferten Einzeiler und im systemd-Timer (live geprueft), der Steuersatz in neuen Rechnungspositionen, die Sitzungsdauer im Token, die Firmenangaben im Kopf der PDF-Dateien. Nebenbei geschlossen: die OIDC-Luecke aus #024. Erlaubte Domaenen und ein Schalter gegen automatisch angelegte Konten. Dazu raeumt der Worker nachts nach den eingestellten Fristen auf - der juengste Report je Host bleibt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { einstellungen, einstellungenSchema, einstellungenSpeichern } from "@/server/einstellungen";
|
||||
|
||||
export interface AktionsErgebnis {
|
||||
fehler?: string;
|
||||
felder?: Record<string, string>;
|
||||
erfolg?: string;
|
||||
}
|
||||
|
||||
const zahl = (formular: FormData, feld: string) => {
|
||||
const roh = formular.get(feld)?.toString().replace(",", ".").trim();
|
||||
return roh === undefined || roh === "" ? undefined : Number(roh);
|
||||
};
|
||||
|
||||
const text = (formular: FormData, feld: string) => formular.get(feld)?.toString().trim() ?? "";
|
||||
const schalter = (formular: FormData, feld: string) => formular.get(feld) === "an";
|
||||
|
||||
export async function einstellungenSpeichernAktion(
|
||||
_zustand: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
await sitzungErforderlich();
|
||||
|
||||
const vorher = await einstellungen();
|
||||
|
||||
const ergebnis = einstellungenSchema.safeParse({
|
||||
dashboardUrl: text(formular, "dashboardUrl"),
|
||||
agentRhythmus: text(formular, "agentRhythmus") || vorher.agentRhythmus,
|
||||
agentStreuungStunden: zahl(formular, "agentStreuungStunden"),
|
||||
agentAutoUpdate: schalter(formular, "agentAutoUpdate"),
|
||||
|
||||
waehrung: text(formular, "waehrung") || vorher.waehrung,
|
||||
steuersatz: zahl(formular, "steuersatz"),
|
||||
zahlungszielTage: zahl(formular, "zahlungszielTage"),
|
||||
margeProzent: zahl(formular, "margeProzent"),
|
||||
|
||||
sitzungsdauerStunden: zahl(formular, "sitzungsdauerStunden"),
|
||||
anmeldeversuche: zahl(formular, "anmeldeversuche"),
|
||||
oidcDomaenen: text(formular, "oidcDomaenen"),
|
||||
oidcKontenAnlegen: schalter(formular, "oidcKontenAnlegen"),
|
||||
aufbewahrungReportsTage: zahl(formular, "aufbewahrungReportsTage"),
|
||||
aufbewahrungAuditTage: zahl(formular, "aufbewahrungAuditTage"),
|
||||
|
||||
firmaName: text(formular, "firmaName"),
|
||||
firmaAnschrift: text(formular, "firmaAnschrift"),
|
||||
firmaEmail: text(formular, "firmaEmail"),
|
||||
zeitzone: text(formular, "zeitzone") || vorher.zeitzone,
|
||||
});
|
||||
|
||||
if (!ergebnis.success) {
|
||||
const felder: Record<string, string> = {};
|
||||
for (const fehler of ergebnis.error.issues) {
|
||||
const feld = fehler.path[0];
|
||||
if (typeof feld === "string" && !felder[feld]) felder[feld] = fehler.message;
|
||||
}
|
||||
return { fehler: "Bitte die markierten Felder prüfen.", felder };
|
||||
}
|
||||
|
||||
await einstellungenSpeichern(ergebnis.data);
|
||||
|
||||
// Nur die geänderten Felder ins Protokoll – sonst steht bei jedem Speichern alles darin.
|
||||
const geaendert = Object.fromEntries(
|
||||
Object.entries(ergebnis.data).filter(
|
||||
([feld, wert]) => wert !== vorher[feld as keyof typeof vorher],
|
||||
),
|
||||
);
|
||||
await protokolliere("einstellungen.gespeichert", "Einstellung", "system", geaendert);
|
||||
|
||||
revalidatePath("/einstellungen/system");
|
||||
return { erfolg: "Einstellungen gespeichert." };
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export async function proxmoxRechnungSpeichern(
|
||||
datum: rechnungsdatum,
|
||||
faelligAm: datum(formular.get("faelligAm")),
|
||||
nettoBetrag: netto,
|
||||
ustSatz: betrag(formular.get("ustSatz")) ?? 19,
|
||||
ustSatz: betrag(formular.get("ustSatz")) ?? undefined,
|
||||
rabatt: betrag(formular.get("rabatt")),
|
||||
zahlungsart: formular.get("zahlungsart")?.toString().trim() || null,
|
||||
bezahlt: formular.get("bezahlt") === "on",
|
||||
@@ -139,7 +139,7 @@ export async function kundenrechnungSpeichern(
|
||||
nummer: formular.get("nummer")?.toString().trim() || null,
|
||||
datum: datum(formular.get("datum")),
|
||||
nettoBetrag: betrag(formular.get("nettoBetrag")),
|
||||
ustSatz: betrag(formular.get("ustSatz")) ?? 19,
|
||||
ustSatz: betrag(formular.get("ustSatz")) ?? undefined,
|
||||
bezahlt: formular.get("bezahlt") === "on",
|
||||
bezahltAm: datum(formular.get("bezahltAm")),
|
||||
anhangId,
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { einstellungen, onCalendar } from "@/server/einstellungen";
|
||||
import { AGENT_VERSION } from "./http";
|
||||
|
||||
/**
|
||||
* Liefert die Agent-Skripte aus dem Verzeichnis `agent/` aus und setzt dabei
|
||||
* Dashboard-Adresse und Agent-Version ein.
|
||||
* Liefert die Agent-Skripte aus dem Verzeichnis `agent/` aus und setzt dabei Dashboard-Adresse,
|
||||
* Agent-Version und die Vorgaben aus *Einstellungen → System* ein (Melderhythmus, Streuung,
|
||||
* Selbstaktualisierung).
|
||||
*/
|
||||
export async function skriptLaden(datei: "install.sh" | "proxledger-agent.sh", basisUrl: string) {
|
||||
const pfad = path.join(process.cwd(), "agent", datei);
|
||||
const inhalt = await readFile(pfad, "utf8");
|
||||
const [inhalt, werte] = await Promise.all([readFile(pfad, "utf8"), einstellungen()]);
|
||||
|
||||
return inhalt
|
||||
.replaceAll("@@DASHBOARD_URL@@", basisUrl.replace(/\/$/, ""))
|
||||
.replaceAll("@@AGENT_VERSION@@", AGENT_VERSION);
|
||||
.replaceAll("@@AGENT_VERSION@@", AGENT_VERSION)
|
||||
.replaceAll("@@TIMER_ONCALENDAR@@", onCalendar(werte.agentRhythmus))
|
||||
.replaceAll("@@TIMER_STREUUNG@@", `${werte.agentStreuungStunden}h`)
|
||||
.replaceAll("@@AUTO_UPDATE@@", werte.agentAutoUpdate ? "yes" : "no");
|
||||
}
|
||||
|
||||
/** Bevorzugt die konfigurierte Adresse; sonst die Herkunft der Anfrage. */
|
||||
export function basisUrl(request: Request): string {
|
||||
/**
|
||||
* Bevorzugt die im Dashboard eingetragene Adresse, dann `APP_URL`, sonst die Herkunft der Anfrage.
|
||||
* Die Reihenfolge ist wichtig: Hinter einem Reverse Proxy stimmt die Herkunft oft nicht.
|
||||
*/
|
||||
export async function basisUrl(request: Request): Promise<string> {
|
||||
const werte = await einstellungen();
|
||||
if (werte.dashboardUrl) return werte.dashboardUrl;
|
||||
if (process.env.APP_URL) return process.env.APP_URL;
|
||||
return new URL(request.url).origin;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { einstellungen } from "@/server/einstellungen";
|
||||
|
||||
/**
|
||||
* Löscht alte Reports und Protokolleinträge nach den eingestellten Fristen (#025).
|
||||
*
|
||||
* Der jüngste Report jedes Hosts bleibt **immer** erhalten – sonst stünde ein Host, der lange
|
||||
* nicht gemeldet hat, plötzlich ohne jede Angabe da. Eine Frist von 0 heißt: nichts löschen.
|
||||
*/
|
||||
export async function altdatenLoeschen(jetzt: Date = new Date()): Promise<{
|
||||
reports: number;
|
||||
protokoll: number;
|
||||
}> {
|
||||
const { aufbewahrungReportsTage, aufbewahrungAuditTage } = await einstellungen();
|
||||
|
||||
let reports = 0;
|
||||
if (aufbewahrungReportsTage > 0) {
|
||||
const grenze = new Date(jetzt.getTime() - aufbewahrungReportsTage * 86_400_000);
|
||||
|
||||
const neueste = await prisma.report.groupBy({
|
||||
by: ["hostId"],
|
||||
_max: { erhobenAm: true },
|
||||
});
|
||||
const behalten = await prisma.report.findMany({
|
||||
where: {
|
||||
OR: neueste
|
||||
.filter((eintrag) => eintrag._max.erhobenAm !== null)
|
||||
.map((eintrag) => ({ hostId: eintrag.hostId, erhobenAm: eintrag._max.erhobenAm as Date })),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const ergebnis = await prisma.report.deleteMany({
|
||||
where: {
|
||||
erhobenAm: { lt: grenze },
|
||||
id: { notIn: behalten.map((eintrag) => eintrag.id) },
|
||||
},
|
||||
});
|
||||
reports = ergebnis.count;
|
||||
}
|
||||
|
||||
let protokoll = 0;
|
||||
if (aufbewahrungAuditTage > 0) {
|
||||
const grenze = new Date(jetzt.getTime() - aufbewahrungAuditTage * 86_400_000);
|
||||
const ergebnis = await prisma.auditEintrag.deleteMany({ where: { erstelltAm: { lt: grenze } } });
|
||||
protokoll = ergebnis.count;
|
||||
}
|
||||
|
||||
return { reports, protokoll };
|
||||
}
|
||||
@@ -3,9 +3,26 @@ import type { BenutzerModel } from "@/generated/prisma/models";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { RateLimit } from "@/lib/ratelimit";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import { domaeneErlaubt, einstellungen } from "@/server/einstellungen";
|
||||
|
||||
/** Anmeldeversuche: fünf pro Viertelstunde je Kennung, damit Raten teuer wird. */
|
||||
export const anmeldeRateLimit = new RateLimit(5, 5 / 900);
|
||||
/**
|
||||
* Anmeldeversuche je Kennung und Viertelstunde. Die Zahl steht in den Einstellungen;
|
||||
* für jede Einstellung wird eine eigene Bremse gehalten, damit der Zustand beim Ändern
|
||||
* nicht verlorengeht.
|
||||
*/
|
||||
const bremsen = new Map<number, RateLimit>();
|
||||
|
||||
export function anmeldeBremse(versuche: number): RateLimit {
|
||||
const vorhanden = bremsen.get(versuche);
|
||||
if (vorhanden) return vorhanden;
|
||||
|
||||
const neu = new RateLimit(versuche, versuche / 900);
|
||||
bremsen.set(versuche, neu);
|
||||
return neu;
|
||||
}
|
||||
|
||||
/** Vorgabe für Aufrufer ohne Einstellungen (Tests). */
|
||||
export const anmeldeRateLimit = anmeldeBremse(5);
|
||||
|
||||
export class AnmeldeFehler extends Error {}
|
||||
|
||||
@@ -15,8 +32,9 @@ export async function mitPasswortAnmelden(
|
||||
passwort: string,
|
||||
quelle: string,
|
||||
): Promise<BenutzerModel> {
|
||||
const { anmeldeversuche } = await einstellungen();
|
||||
const kennung = `${quelle}:${email.toLowerCase()}`;
|
||||
if (!anmeldeRateLimit.pruefe(kennung).erlaubt) {
|
||||
if (!anmeldeBremse(anmeldeversuche).pruefe(kennung).erlaubt) {
|
||||
throw new AnmeldeFehler("Zu viele Versuche. Bitte einige Minuten warten.");
|
||||
}
|
||||
|
||||
@@ -41,10 +59,17 @@ export async function mitPasswortAnmelden(
|
||||
|
||||
/**
|
||||
* Ordnet eine OIDC-Anmeldung einem Benutzer zu: erst über die Subject-ID, sonst über die E-Mail.
|
||||
* Ein unbekanntes Konto wird angelegt – wer sich bei authentik anmelden darf, darf auch hier arbeiten.
|
||||
* Ob ein unbekanntes Konto angelegt werden darf und welche E-Mail-Domänen zugelassen sind,
|
||||
* steht in den Einstellungen (#025). Vorgabe bleibt: anlegen, alle Domänen.
|
||||
*/
|
||||
export async function mitOidcAnmelden(subject: string, email: string, name: string): Promise<BenutzerModel> {
|
||||
const normalisiert = email.toLowerCase().trim();
|
||||
const { oidcDomaenen, oidcKontenAnlegen } = await einstellungen();
|
||||
|
||||
if (!domaeneErlaubt(normalisiert, oidcDomaenen)) {
|
||||
await protokolliere("anmeldung.abgelehnt", "Benutzer", null, { email: normalisiert, grund: "domaene" });
|
||||
throw new AnmeldeFehler("Diese E-Mail-Domäne ist für die Anmeldung nicht zugelassen.");
|
||||
}
|
||||
|
||||
const vorhanden =
|
||||
(await prisma.benutzer.findUnique({ where: { oidcSubject: subject } })) ??
|
||||
@@ -54,6 +79,11 @@ export async function mitOidcAnmelden(subject: string, email: string, name: stri
|
||||
throw new AnmeldeFehler("Dieses Konto ist deaktiviert.");
|
||||
}
|
||||
|
||||
if (!vorhanden && !oidcKontenAnlegen) {
|
||||
await protokolliere("anmeldung.abgelehnt", "Benutzer", null, { email: normalisiert, grund: "kein-konto" });
|
||||
throw new AnmeldeFehler("Für diese Adresse gibt es hier kein Konto.");
|
||||
}
|
||||
|
||||
const benutzer = vorhanden
|
||||
? await prisma.benutzer.update({
|
||||
where: { id: vorhanden.id },
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Betriebseinstellungen, die sich im Dashboard ändern lassen (#025).
|
||||
*
|
||||
* Alles liegt in **einer** Zeile der Tabelle `Einstellung` als JSON – so ist ein Speichern
|
||||
* ein einziger Schreibvorgang, und fehlende Felder bekommen ihren Vorgabewert.
|
||||
*
|
||||
* Bewusst **nicht** hier: `APP_SECRET` und `DATABASE_URL`. Die braucht die Anwendung, bevor sie
|
||||
* die Datenbank überhaupt lesen kann; sie bleiben in der Umgebung.
|
||||
*/
|
||||
|
||||
const SCHLUESSEL = "system";
|
||||
|
||||
export const einstellungenSchema = z.object({
|
||||
// --- Adresse und Agent ---
|
||||
/** Adresse, die im Installations-Einzeiler und in Benachrichtigungen steht. Leer = aus `APP_URL`. */
|
||||
dashboardUrl: z.union([z.string().url("Bitte eine vollständige Adresse angeben."), z.literal("")]).default(""),
|
||||
agentRhythmus: z.enum(["taeglich", "zwoelfstuendlich", "stuendlich"]).default("taeglich"),
|
||||
/** Zufällige Verzögerung, damit nicht alle Hosts gleichzeitig melden. */
|
||||
agentStreuungStunden: z.number().int().min(0).max(12).default(4),
|
||||
agentAutoUpdate: z.boolean().default(false),
|
||||
|
||||
// --- Kaufmännisches ---
|
||||
waehrung: z.enum(["EUR", "CHF"]).default("EUR"),
|
||||
steuersatz: z.number().min(0).max(100).default(19),
|
||||
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),
|
||||
|
||||
// --- Sicherheit ---
|
||||
sitzungsdauerStunden: z.number().int().min(1).max(168).default(12),
|
||||
anmeldeversuche: z.number().int().min(3).max(50).default(5),
|
||||
/** Komma-getrennte Liste erlaubter E-Mail-Domänen für OIDC. Leer = alle. */
|
||||
oidcDomaenen: z.string().default(""),
|
||||
/** Darf sich jemand ohne vorhandenes Konto über OIDC anmelden? */
|
||||
oidcKontenAnlegen: z.boolean().default(true),
|
||||
/** 0 = unbegrenzt aufbewahren. */
|
||||
aufbewahrungReportsTage: z.number().int().min(0).max(3650).default(365),
|
||||
aufbewahrungAuditTage: z.number().int().min(0).max(3650).default(730),
|
||||
|
||||
// --- Firma und Darstellung ---
|
||||
firmaName: z.string().max(120).default(""),
|
||||
firmaAnschrift: z.string().max(400).default(""),
|
||||
firmaEmail: z.union([z.string().email("Keine gültige E-Mail-Adresse."), z.literal("")]).default(""),
|
||||
zeitzone: z.string().min(1).default("Europe/Berlin"),
|
||||
});
|
||||
|
||||
export type Einstellungen = z.infer<typeof einstellungenSchema>;
|
||||
|
||||
export const VORGABEN: Einstellungen = einstellungenSchema.parse({});
|
||||
|
||||
/**
|
||||
* Kurzer Zwischenspeicher: Die Einstellungen werden bei fast jeder Anfrage gebraucht,
|
||||
* ändern sich aber selten. Zehn Sekunden reichen, damit eine Änderung sofort spürbar ist.
|
||||
*/
|
||||
let zwischenspeicher: { werte: Einstellungen; bis: number } | null = null;
|
||||
|
||||
export async function einstellungen(): Promise<Einstellungen> {
|
||||
if (zwischenspeicher && zwischenspeicher.bis > Date.now()) return zwischenspeicher.werte;
|
||||
|
||||
const eintrag = await prisma.einstellung.findUnique({ where: { schluessel: SCHLUESSEL } });
|
||||
// Unbekannte oder kaputte Werte fallen auf die Vorgabe zurück, statt den Betrieb anzuhalten.
|
||||
const ergebnis = einstellungenSchema.safeParse(eintrag?.wert ?? {});
|
||||
const werte = ergebnis.success ? ergebnis.data : VORGABEN;
|
||||
|
||||
zwischenspeicher = { werte, bis: Date.now() + 10_000 };
|
||||
return werte;
|
||||
}
|
||||
|
||||
export async function einstellungenSpeichern(werte: Einstellungen): Promise<void> {
|
||||
await prisma.einstellung.upsert({
|
||||
where: { schluessel: SCHLUESSEL },
|
||||
create: { schluessel: SCHLUESSEL, wert: werte },
|
||||
update: { wert: werte },
|
||||
});
|
||||
zwischenspeicher = null;
|
||||
}
|
||||
|
||||
/** Nur für Tests: erzwingt das nächste Lesen aus der Datenbank. */
|
||||
export function zwischenspeicherLeeren(): void {
|
||||
zwischenspeicher = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fälligkeitsdatum, das ein leeres Rechnungsformular vorschlägt: heute plus Zahlungsziel.
|
||||
* Bewusst hier und nicht in der Seite – eine Komponente darf beim Rendern nicht die Uhr lesen.
|
||||
*/
|
||||
export async function faelligkeitsVorschlag(jetzt: Date = new Date()): Promise<string> {
|
||||
const { zahlungszielTage } = await einstellungen();
|
||||
return new Date(jetzt.getTime() + zahlungszielTage * 86_400_000).toISOString();
|
||||
}
|
||||
|
||||
/** systemd-Angabe für den Melderhythmus des Agents. */
|
||||
export function onCalendar(rhythmus: Einstellungen["agentRhythmus"]): string {
|
||||
switch (rhythmus) {
|
||||
case "stuendlich":
|
||||
return "hourly";
|
||||
case "zwoelfstuendlich":
|
||||
return "0/12:00:00";
|
||||
default:
|
||||
return "daily";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob eine E-Mail-Adresse zu den erlaubten OIDC-Domänen gehört.
|
||||
* Ohne Angabe ist jede Domäne erlaubt – so verhält es sich wie bisher.
|
||||
*/
|
||||
export function domaeneErlaubt(email: string, domaenen: string): boolean {
|
||||
const liste = domaenen
|
||||
.split(",")
|
||||
.map((eintrag) => eintrag.trim().toLowerCase().replace(/^@/, ""))
|
||||
.filter(Boolean);
|
||||
if (liste.length === 0) return true;
|
||||
|
||||
const domaene = email.toLowerCase().split("@")[1] ?? "";
|
||||
return liste.includes(domaene);
|
||||
}
|
||||
@@ -121,14 +121,17 @@ export function Bericht({
|
||||
untertitel,
|
||||
children,
|
||||
quer = false,
|
||||
firma,
|
||||
}: {
|
||||
titel: string;
|
||||
untertitel?: string;
|
||||
children: React.ReactNode;
|
||||
quer?: boolean;
|
||||
/** Firmenangaben und Zeitzone aus den Einstellungen (#025). */
|
||||
firma?: { name: string; anschrift: string; email: string; zeitzone: string };
|
||||
}) {
|
||||
const erstellt = new Intl.DateTimeFormat("de-DE", {
|
||||
timeZone: "Europe/Berlin",
|
||||
timeZone: firma?.zeitzone || "Europe/Berlin",
|
||||
dateStyle: "long",
|
||||
}).format(new Date());
|
||||
|
||||
@@ -138,8 +141,11 @@ export function Bericht({
|
||||
<View style={stile.kopf} fixed>
|
||||
<Logo />
|
||||
<View style={stile.kopfText}>
|
||||
<Text style={stile.marke}>ProxLedger</Text>
|
||||
<Text style={stile.untertitel}>Proxmox Subscription Dashboard</Text>
|
||||
<Text style={stile.marke}>{firma?.name || "ProxLedger"}</Text>
|
||||
<Text style={stile.untertitel}>
|
||||
{firma?.anschrift ? firma.anschrift.replace(/\n/g, " · ") : "Proxmox Subscription Dashboard"}
|
||||
</Text>
|
||||
{firma?.email ? <Text style={stile.untertitel}>{firma.email}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import { einstellungen } from "@/server/einstellungen";
|
||||
|
||||
export class RechnungsFehler extends Error {}
|
||||
|
||||
@@ -67,6 +68,7 @@ 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();
|
||||
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)}`;
|
||||
|
||||
@@ -79,7 +81,7 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
|
||||
data: {
|
||||
datum: eingabe.datum,
|
||||
faelligAm: eingabe.faelligAm,
|
||||
ustSatz: eingabe.ustSatz ?? 19,
|
||||
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
|
||||
rabatt: eingabe.rabatt ?? null,
|
||||
zahlungsart: eingabe.zahlungsart ?? null,
|
||||
bezahlt: eingabe.bezahlt,
|
||||
@@ -94,7 +96,7 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
|
||||
datum: eingabe.datum,
|
||||
faelligAm: eingabe.faelligAm,
|
||||
nettoBetrag: eingabe.nettoBetrag,
|
||||
ustSatz: eingabe.ustSatz ?? 19,
|
||||
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
|
||||
rabatt: eingabe.rabatt ?? null,
|
||||
zahlungsart: eingabe.zahlungsart ?? null,
|
||||
bezahlt: eingabe.bezahlt,
|
||||
@@ -146,6 +148,7 @@ export async function kundenrechnungErfassen(laufzeitId: string, eingabe: Kunden
|
||||
throw new RechnungsFehler("Der Host ist keinem Kunden zugewiesen – ohne Kunde keine Kundenrechnung.");
|
||||
}
|
||||
|
||||
const { steuersatz: vorgabeSteuersatz } = await einstellungen();
|
||||
const nummer = eingabe.nummer?.trim() || null;
|
||||
const bezeichnung = `${laufzeit.key} · ${laufzeit.host.hostname} · ${laufzeit.start.toISOString().slice(0, 10)} bis ${laufzeit.ende.toISOString().slice(0, 10)}`;
|
||||
const betrag = eingabe.nettoBetrag ?? 0;
|
||||
@@ -160,7 +163,7 @@ export async function kundenrechnungErfassen(laufzeitId: string, eingabe: Kunden
|
||||
erstellt: eingabe.erstellt,
|
||||
nummer,
|
||||
datum: eingabe.datum ?? null,
|
||||
ustSatz: eingabe.ustSatz ?? 19,
|
||||
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
|
||||
bezahlt: eingabe.bezahlt,
|
||||
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
|
||||
anhangId: eingabe.anhangId ?? null,
|
||||
|
||||
Reference in New Issue
Block a user