Finanzen: Einstellungen und Fixkostenliste
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6e43d54fe8
commit
34ff97e924
6 files changed
+923
-30
No files matched your search
@@ -1,10 +1,9 @@
|
||||
import { config } from '../../config.js'
|
||||
import { getDocument } from '../appwriteAdmin.js'
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { getStripe } from '../stripeBilling.js'
|
||||
import { getTransaction, listTransactions, updateTransaction } from './bankStore.js'
|
||||
import { euroDe } from './format.js'
|
||||
import { suggestMatches } from './matching.js'
|
||||
import { getFixedCost } from './settings.js'
|
||||
import { invalidateFinanceCache, listInvoices, listPayouts } from './stripeFinance.js'
|
||||
|
||||
/**
|
||||
@@ -156,14 +155,8 @@ async function resolvePayout(tx, payoutId) {
|
||||
|
||||
async function resolveFixedCost(tx, fixedCostId) {
|
||||
if (!isOutgoing(tx)) throw new AssignError(400, 'Fixkosten lassen sich nur Ausgängen zuordnen.')
|
||||
const missing = new AssignError(400, 'Diese Fixkosten-Position gibt es nicht.')
|
||||
if (!DOCUMENT_ID.test(fixedCostId)) throw missing
|
||||
try {
|
||||
await getDocument(config.collections.financeFixedCosts, fixedCostId)
|
||||
} catch (err) {
|
||||
if (err?.status === 404 || err?.status === 400) throw missing
|
||||
throw err
|
||||
}
|
||||
// getFixedCost kennt keine unmoeglichen IDs und liefert null, wenn es die Position nicht gibt
|
||||
if (!(await getFixedCost(fixedCostId))) throw new AssignError(400, 'Diese Fixkosten-Position gibt es nicht.')
|
||||
return { category: 'fixkosten', fixedCostId }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
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_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: '',
|
||||
})
|
||||
|
||||
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.`),
|
||||
}
|
||||
|
||||
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). 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
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { config } from '../../config.js'
|
||||
import { getStripe, getOrCreateStripeCustomer, subscriptionIdOfInvoice } from '../stripeBilling.js'
|
||||
import { getDocument } from '../appwriteAdmin.js'
|
||||
import { addDays, berlinDate, daysBetween } from './dates.js'
|
||||
import { getSettings } from './settings.js'
|
||||
|
||||
/**
|
||||
* Stripe-Rechnungen und -Auszahlungen fuer die Finanzen. Rechnungen und Auszahlungen werden nicht
|
||||
@@ -202,13 +203,14 @@ async function finalizeAndSend(stripe, invoiceId) {
|
||||
|
||||
/**
|
||||
* Projektrechnung bei Stripe anlegen: Entwurf mit den Positionen (Menge und Einzelpreis je Position), Zahlungsziel
|
||||
* in Tagen, Rechnungsfuss mit Kleinunternehmer-Hinweis und Bankdaten. Mit senden=true wird sie sofort finalisiert und
|
||||
* per E-Mail gesendet (offen), sonst bleibt sie ein Entwurf. Fehler mit status 400 bei ungueltigen Eingaben (auch
|
||||
* Summe ueber 99.999.999 Cent), einem Kunden, der noch Lead/verloren ist, oder fehlender E-Mail-Adresse bei senden=true
|
||||
* (alles vor dem ersten Stripe-Aufruf). Scheitert nur das Senden nach dem Festschreiben: status 502 mit invoiceId und
|
||||
* in Tagen, Rechnungsfuss mit Kleinunternehmer-Hinweis und den Bankdaten aus den Einstellungen (bankDetailsText, siehe
|
||||
* settings.js). Mit senden=true wird sie sofort finalisiert und per E-Mail gesendet (offen), sonst bleibt sie ein
|
||||
* Entwurf. Fehler mit status 400 bei ungueltigen Eingaben (auch Summe ueber 99.999.999 Cent), einem Kunden, der noch
|
||||
* Lead/verloren ist, oder fehlender E-Mail-Adresse bei senden=true (alles vor dem ersten Stripe-Aufruf, ebenso ein
|
||||
* Fehler beim Lesen der Einstellungen). Scheitert nur das Senden nach dem Festschreiben: status 502 mit invoiceId und
|
||||
* invoiceNumber (die offene Rechnung bleibt, erneut senden mit sendInvoiceNow).
|
||||
*/
|
||||
export async function createProjectInvoice({ customerId, items, daysUntilDue = DEFAULT_DAYS_UNTIL_DUE, memo = '', senden = false, createdBy, bankDetailsText = '' }) {
|
||||
export async function createProjectInvoice({ customerId, items, daysUntilDue = DEFAULT_DAYS_UNTIL_DUE, memo = '', senden = false, createdBy }) {
|
||||
const lines = validateItems(items)
|
||||
if (!isWholeNumberBetween(daysUntilDue, 0, MAX_DAYS_UNTIL_DUE)) {
|
||||
throw httpError(400, `Das Zahlungsziel muss eine ganze Zahl von 0 bis ${MAX_DAYS_UNTIL_DUE} Tagen sein.`)
|
||||
@@ -219,6 +221,7 @@ export async function createProjectInvoice({ customerId, items, daysUntilDue = D
|
||||
// Ohne Adresse lehnt Stripe das Senden erst nach dem Festschreiben ab: lieber vorab pruefen
|
||||
if (senden === true && !String(customer.email || '').trim()) throw httpError(400, NO_EMAIL_MESSAGE)
|
||||
|
||||
const { bankDetailsText } = await getSettings()
|
||||
const stripe = getStripe()
|
||||
const stripeCustomerId = await getOrCreateStripeCustomer(customer)
|
||||
let invoiceId = null
|
||||
|
||||
Reference in new issue
Block a user