import { config } from '../../config.js' import { createDocument, deleteDocument, getDocument, ID, listDocuments, Query, updateDocument } from '../appwriteAdmin.js' /** * Einstellungen (ein Dokument "main" in financeSettings) und Fixkostenliste (financeFixedCosts) der Finanzen. * Jede Eingabe wird hier geprueft; Fehler sind SettingsError mit status 400 (ungueltig) oder 404 (Position * unbekannt) und einer deutschen Meldung, die die Seite anzeigen darf. Alles andere (Appwrite) fliegt * unveraendert weiter und gehoert nicht an die Seite. */ /** Fehler mit HTTP-Status und einer Meldung, die die Seite anzeigen darf (nur diese gibt die Route weiter). */ export class SettingsError extends Error { constructor(status, message) { super(message) this.name = 'SettingsError' this.status = status } } const SETTINGS_ID = 'main' // Dokument-IDs wie in Appwrite (hoechstens 36 Zeichen, auch eine UUID); alles andere kann es nicht geben const DOCUMENT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ const MAX_BANK_DETAILS = 500 const MAX_STEUERNUMMER = 40 const MAX_NAME = 120 const MAX_MATCH = 120 const MAX_BUFFER_CENTS = 100_000_000 const MAX_TAX_RESERVE_PERCENT = 60 const MAX_LIST = 1000 const INTERVALS = ['monat', 'quartal', 'jahr'] /** Standardwerte der Einstellungen; previousYearRevenueCents null heisst "nicht gesetzt". */ export const DEFAULT_SETTINGS = Object.freeze({ minBufferCents: 0, taxReservePercent: 30, previousYearRevenueCents: null, estPrepaymentCents: 0, bankDetailsText: '', steuernummer: '', }) const invalid = (field, message) => new SettingsError(400, `${field}: ${message}`) const isPlainObject = (value) => Object.prototype.toString.call(value) === '[object Object]' function wholeNumber(field, value, min, max, message) { if (!Number.isSafeInteger(value) || value < min || value > max) throw invalid(field, message) return value } /** Text ohne Rand-Leerraum mit 0 bis max Zeichen (min 1: nicht leer). */ function text(field, value, { min, max }, message) { const trimmed = typeof value === 'string' ? value.trim() : null if (trimmed === null || trimmed.length < min || trimmed.length > max) throw invalid(field, message) return trimmed } function calendarDate(field, value) { const message = 'Das Datum muss im Format JJJJ-MM-TT stehen und es geben.' if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) throw invalid(field, message) const date = new Date(`${value}T00:00:00Z`) if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) throw invalid(field, message) return value } // Je Feld eine Pruefung: liefert den bereinigten Wert oder wirft SettingsError 400 mit dem Feldnamen const SETTINGS_RULES = { minBufferCents: (v) => wholeNumber('minBufferCents', v, 0, MAX_BUFFER_CENTS, 'Der Mindestpuffer muss eine ganze Zahl von 0 bis 100.000.000 Cent sein.'), taxReservePercent: (v) => wholeNumber('taxReservePercent', v, 0, MAX_TAX_RESERVE_PERCENT, `Die Steuer-Rücklage muss eine ganze Zahl von 0 bis ${MAX_TAX_RESERVE_PERCENT} Prozent sein.`), previousYearRevenueCents: (v) => v === null ? null : wholeNumber('previousYearRevenueCents', v, 0, Number.MAX_SAFE_INTEGER, 'Der Vorjahresumsatz muss eine ganze Zahl ab 0 Cent sein (oder leer).'), estPrepaymentCents: (v) => wholeNumber('estPrepaymentCents', v, 0, Number.MAX_SAFE_INTEGER, 'Die ESt-Vorauszahlung muss eine ganze Zahl ab 0 Cent sein.'), bankDetailsText: (v) => text('bankDetailsText', v, { min: 0, max: MAX_BANK_DETAILS }, `Der Bankverbindungstext muss ein Text mit höchstens ${MAX_BANK_DETAILS} Zeichen sein.`), // Steuernummer oder USt-IdNr. für die Auftragsbestätigung: Buchstaben A-Z, Ziffern, Leerzeichen, / - und . (kein // Tabulator, kein Zeilenumbruch, keine Umlaute); '' löscht den Wert. Die Meldung ohne Feldnamen (sie erscheint im Formular). steuernummer: (v) => { const trimmed = typeof v === 'string' ? v.trim() : null if (trimmed === null || trimmed.length > MAX_STEUERNUMMER || !/^[A-Za-z0-9/ .-]*$/.test(trimmed)) { throw new SettingsError(400, 'Steuernummer ungültig.') } return trimmed }, } const FIXED_COST_RULES = { name: (v) => text('name', v, { min: 1, max: MAX_NAME }, `Der Name braucht 1 bis ${MAX_NAME} Zeichen.`), amountCents: (v) => wholeNumber('amountCents', v, 1, Number.MAX_SAFE_INTEGER, 'Der Betrag muss eine ganze Zahl über 0 Cent sein.'), interval: (v) => { if (!INTERVALS.includes(v)) throw invalid('interval', 'Der Rhythmus muss monat, quartal oder jahr sein.') return v }, nextDate: (v) => calendarDate('nextDate', v), // klein gespeichert: der Abgleich mit der Gegenpartei einer Buchung ignoriert Gross- und Kleinschreibung counterpartyMatch: (v) => text('counterpartyMatch', v, { min: 0, max: MAX_MATCH }, `Der Suchbegriff muss ein Text mit höchstens ${MAX_MATCH} Zeichen sein.`).toLowerCase(), active: (v) => { if (typeof v !== 'boolean') throw invalid('active', 'Aktiv muss wahr oder falsch sein.') return v }, } const FIXED_COST_REQUIRED = ['name', 'amountCents', 'interval', 'nextDate'] /** * Eingabe gegen die Regeln pruefen: ein Objekt, nur bekannte Felder (Metafelder wie $id und Schluessel wie * __proto__ sind unbekannt), jeder Wert gueltig; fehlende (undefined) Felder bleiben weg. Liefert die bereinigten Felder. */ function parseFields(input, rules) { if (!isPlainObject(input)) throw new SettingsError(400, 'Ungültige Eingabe: ein Objekt mit Feldern wird erwartet.') const data = {} for (const key of Object.keys(input)) { if (!Object.hasOwn(rules, key)) throw new SettingsError(400, `Unbekanntes Feld: ${key.slice(0, 40)}.`) if (input[key] !== undefined) data[key] = rules[key](input[key]) } return data } const isMissing = (err) => err?.status === 404 // --------------------------------------------------------------------------- // Einstellungen // --------------------------------------------------------------------------- /** Dokument mit den Standardwerten zusammenfuehren (fehlende und null-Felder fallen auf sie zurueck); ohne Metafelder. */ function mergeSettings(doc) { const merged = {} for (const [key, fallback] of Object.entries(DEFAULT_SETTINGS)) { const value = doc?.[key] merged[key] = value === undefined || value === null ? fallback : value } return merged } /** Die Einstellungen; gibt es das Dokument noch nicht, nur die Standardwerte (es wird nichts angelegt). */ export async function getSettings() { try { return mergeSettings(await getDocument(config.collections.financeSettings, SETTINGS_ID)) } catch (err) { if (isMissing(err)) return mergeSettings(null) throw err } } // Aktualisieren; fehlt das Dokument (404), mit den Standardwerten und der Aenderung anlegen. Legt zwischen beiden // Schritten ein anderer Aufruf dasselbe Dokument an (409), gilt wieder das Aktualisieren. async function saveSettings(data) { const collection = config.collections.financeSettings try { return await updateDocument(collection, SETTINGS_ID, data) } catch (err) { if (!isMissing(err)) throw err } try { return await createDocument(collection, { ...DEFAULT_SETTINGS, ...data }, SETTINGS_ID) } catch (err) { if (err?.status !== 409) throw err } return updateDocument(collection, SETTINGS_ID, data) } /** * Einstellungen aendern; patch nennt nur die zu aendernden Felder: minBufferCents 0 bis 100.000.000, * taxReservePercent 0 bis 60, previousYearRevenueCents >= 0 oder null (null loescht den Wert), estPrepaymentCents >= 0, * bankDetailsText hoechstens 500 Zeichen (ohne Rand-Leerraum gespeichert), steuernummer hoechstens 40 Zeichen aus * Buchstaben, Ziffern, Leerzeichen, / - und . (getrimmt gespeichert, '' loescht). Ein unbekanntes oder ungueltiges Feld: * SettingsError 400 mit dem Feldnamen, es wird nichts gespeichert. Legt das Dokument an, falls es fehlt; ein leerer * patch aendert und legt nichts an. Liefert die Einstellungen danach. */ export async function patchSettings(patch) { const data = parseFields(patch, SETTINGS_RULES) if (Object.keys(data).length === 0) return getSettings() return mergeSettings(await saveSettings(data)) } // --------------------------------------------------------------------------- // Fixkosten // --------------------------------------------------------------------------- const notFound = () => new SettingsError(404, 'Fixkosten-Position nicht gefunden.') function fixedCostId(id) { if (typeof id !== 'string' || !DOCUMENT_ID.test(id)) throw notFound() return id } /** Alle Fixkosten-Positionen, nach Name sortiert. */ export async function listFixedCosts() { const docs = await listDocuments(config.collections.financeFixedCosts, [Query.limit(MAX_LIST)]) return docs.sort((a, b) => String(a.name).localeCompare(String(b.name), 'de', { sensitivity: 'base' }) || a.$id.localeCompare(b.$id)) } /** Eine Position oder null (auch bei einer ID, die es nie geben kann). */ export async function getFixedCost(id) { if (typeof id !== 'string' || !DOCUMENT_ID.test(id)) return null try { return await getDocument(config.collections.financeFixedCosts, id) } catch (err) { if (isMissing(err)) return null throw err } } /** * Position anlegen: name 1 bis 120 Zeichen, amountCents ganze Zahl > 0, interval monat | quartal | jahr, nextDate * JJJJ-MM-TT (ein echtes Datum), counterpartyMatch hoechstens 120 Zeichen (leer als Standard, klein gespeichert), * active (Standard true). Ungueltig oder unbekanntes Feld: SettingsError 400 mit dem Feldnamen. Liefert die Position. */ export async function createFixedCost(input) { const given = parseFields(input, FIXED_COST_RULES) for (const key of FIXED_COST_REQUIRED) { if (given[key] === undefined) throw invalid(key, 'Pflichtfeld.') } return createDocument(config.collections.financeFixedCosts, { counterpartyMatch: '', active: true, ...given }, ID.unique()) } /** Felder einer Position aendern (dieselben Regeln, jedes Feld einzeln); unbekannte Position: SettingsError 404. */ export async function updateFixedCost(id, patch) { const docId = fixedCostId(id) const data = parseFields(patch, FIXED_COST_RULES) if (Object.keys(data).length === 0) { const current = await getFixedCost(docId) if (!current) throw notFound() return current } try { return await updateDocument(config.collections.financeFixedCosts, docId, data) } catch (err) { if (isMissing(err)) throw notFound() throw err } } /** Position loeschen; unbekannte Position: SettingsError 404. Gebuchte Zuordnungen behalten die ID. */ export async function deleteFixedCost(id) { const docId = fixedCostId(id) try { await deleteDocument(config.collections.financeFixedCosts, docId) } catch (err) { if (isMissing(err)) throw notFound() throw err } }