From 34ff97e924af514e732cce415c3884d7a49dc35b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 10:28:21 +0000 Subject: [PATCH] Finanzen: Einstellungen und Fixkostenliste Co-Authored-By: Claude Sonnet 5.5 --- server/routes/admin/finanzen.js | 83 +++- server/services/finance/assign.js | 13 +- server/services/finance/settings.js | 234 +++++++++ server/services/finance/stripeFinance.js | 13 +- test/financeSettings.test.js | 587 +++++++++++++++++++++++ test/financeStripeWrite.test.js | 23 +- 6 files changed, 923 insertions(+), 30 deletions(-) create mode 100644 server/services/finance/settings.js create mode 100644 test/financeSettings.test.js diff --git a/server/routes/admin/finanzen.js b/server/routes/admin/finanzen.js index b01df43..4a4d352 100644 --- a/server/routes/admin/finanzen.js +++ b/server/routes/admin/finanzen.js @@ -5,10 +5,20 @@ import { EnableBankingError } from '../../services/finance/enableBanking.js' import { completeBankAuth, startBankAuth, syncBank } from '../../services/finance/bankSync.js' import { getConnection, listTransactions, saveConnection } from '../../services/finance/bankStore.js' import { AssignError, assignTransaction, unassignTransaction, withSuggestions } from '../../services/finance/assign.js' +import { + SettingsError, + createFixedCost, + deleteFixedCost, + getSettings, + listFixedCosts, + patchSettings, + updateFixedCost, +} from '../../services/finance/settings.js' /** - * Finanzen im WOMS (nur Admins): Bankstatus, Abruf, Neuverbindung, Buchungen und deren Zuordnung. Die Handler sind klein und rufen - * die Dienste in server/services/finance auf; weitere Finanz-Routen kommen in dieselbe Datei. + * Finanzen im WOMS (nur Admins): Bankstatus, Abruf, Neuverbindung, Buchungen und deren Zuordnung, Einstellungen und + * Fixkosten. Die Handler sind klein und rufen die Dienste in server/services/finance auf; weitere Finanz-Routen kommen + * in dieselbe Datei. */ const router = Router() @@ -39,8 +49,8 @@ function publicConnection(connection) { } } -/** Buchung ohne Appwrite-Metafelder ($createdAt, $permissions, ...); nur $id bleibt. */ -const publicTransaction = (doc) => Object.fromEntries(Object.entries(doc).filter(([key]) => key === '$id' || !key.startsWith('$'))) +/** Dokument ohne Appwrite-Metafelder ($createdAt, $permissions, ...); nur $id bleibt. */ +const publicDoc = (doc) => Object.fromEntries(Object.entries(doc).filter(([key]) => key === '$id' || !key.startsWith('$'))) /** Fehlerart fuers Protokoll: nie die Meldung (sie koennte Kennungen der Bank oder von Appwrite enthalten). */ const logFailure = (what, err) => console.error(`[finanzen] ${what} fehlgeschlagen:`, err?.name || 'Error', err?.status ?? '', err?.code ?? '') @@ -115,7 +125,7 @@ router.post('/bank/rueckkehr', async (req, res) => { router.get('/buchungen', async (_req, res) => { try { // Vorschlaege kommen von Stripe: ein Ausfall dort (stripeFehler) macht die Liste nie kaputt - return res.json(await withSuggestions((await listTransactions()).map(publicTransaction))) + return res.json(await withSuggestions((await listTransactions()).map(publicDoc))) } catch (err) { logFailure('Buchungen laden', err) return res.status(500).json({ error: 'Buchungen konnten nicht geladen werden.' }) @@ -148,7 +158,7 @@ router.post('/buchungen/:id/zuordnen', async (req, res) => { const { invoiceId, payoutId, fixedCostId, category, note } = req.body || {} try { const buchung = await assignTransaction(req.params.id, { invoiceId, payoutId, fixedCostId, category, note }) - return res.json({ buchung: publicTransaction(buchung) }) + return res.json({ buchung: publicDoc(buchung) }) } catch (err) { return sendAssignError(res, 'Buchung zuordnen', err) } @@ -156,10 +166,69 @@ router.post('/buchungen/:id/zuordnen', async (req, res) => { router.delete('/buchungen/:id/zuordnung', async (req, res) => { try { - return res.json({ buchung: publicTransaction(await unassignTransaction(req.params.id)) }) + return res.json({ buchung: publicDoc(await unassignTransaction(req.params.id)) }) } catch (err) { return sendAssignError(res, 'Zuordnung lösen', err) } }) +/** + * Antwort auf einen Fehler bei Einstellungen und Fixkosten: SettingsError (400 ungueltige Eingabe, 404 unbekannte + * Position, mit anzeigbarer Meldung); alles andere (Appwrite) wird protokolliert und ist ein 500 ohne dessen Meldung. + */ +function sendSettingsError(res, what, err) { + if (err instanceof SettingsError) return res.status(err.status).json({ error: err.message }) + logFailure(what, err) + return res.status(500).json({ error: 'Interner Fehler.' }) +} + +router.get('/einstellungen', async (_req, res) => { + try { + return res.json({ einstellungen: await getSettings() }) + } catch (err) { + return sendSettingsError(res, 'Einstellungen laden', err) + } +}) + +router.patch('/einstellungen', async (req, res) => { + try { + return res.json({ einstellungen: await patchSettings(req.body) }) + } catch (err) { + return sendSettingsError(res, 'Einstellungen speichern', err) + } +}) + +router.get('/fixkosten', async (_req, res) => { + try { + return res.json({ fixkosten: (await listFixedCosts()).map(publicDoc) }) + } catch (err) { + return sendSettingsError(res, 'Fixkosten laden', err) + } +}) + +router.post('/fixkosten', async (req, res) => { + try { + return res.status(201).json({ position: publicDoc(await createFixedCost(req.body)) }) + } catch (err) { + return sendSettingsError(res, 'Fixkosten anlegen', err) + } +}) + +router.patch('/fixkosten/:id', async (req, res) => { + try { + return res.json({ position: publicDoc(await updateFixedCost(req.params.id, req.body)) }) + } catch (err) { + return sendSettingsError(res, 'Fixkosten ändern', err) + } +}) + +router.delete('/fixkosten/:id', async (req, res) => { + try { + await deleteFixedCost(req.params.id) + return res.json({ ok: true }) + } catch (err) { + return sendSettingsError(res, 'Fixkosten löschen', err) + } +}) + export default router diff --git a/server/services/finance/assign.js b/server/services/finance/assign.js index 46860ed..5d12dd3 100644 --- a/server/services/finance/assign.js +++ b/server/services/finance/assign.js @@ -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 } } diff --git a/server/services/finance/settings.js b/server/services/finance/settings.js new file mode 100644 index 0000000..0426cea --- /dev/null +++ b/server/services/finance/settings.js @@ -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 + } +} diff --git a/server/services/finance/stripeFinance.js b/server/services/finance/stripeFinance.js index 92739e8..f99dcf2 100644 --- a/server/services/finance/stripeFinance.js +++ b/server/services/finance/stripeFinance.js @@ -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 diff --git a/test/financeSettings.test.js b/test/financeSettings.test.js new file mode 100644 index 0000000..ba74670 --- /dev/null +++ b/test/financeSettings.test.js @@ -0,0 +1,587 @@ +import { test, after, beforeEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Nachbauten starten und die Umgebung setzen, BEVOR config.js geladen wird (Muster wie financeAssign.test.js) +const aw = await startAppwriteMock({ + schema: { + financeSettings: schemaKeys('financeSettings'), + financeFixedCosts: schemaKeys('financeFixedCosts'), + bankTransactions: schemaKeys('bankTransactions'), + }, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) + +const { config } = await import('../server/config.js') +const { seedFixtures } = await import('./support/fixtures.js') +const { startTestApp, get, post, rawGet } = await import('./support/testApp.js') +const { default: financeRoutes } = await import('../server/routes/admin/finanzen.js') +const settings = await import('../server/services/finance/settings.js') +const { createProjectInvoice, invalidateFinanceCache } = await import('../server/services/finance/stripeFinance.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') + +const app = await startTestApp({ '/api/admin/finanzen': financeRoutes }) +after(async () => { + mock.restoreAll() + await Promise.all([app.close(), aw.close(), st.close()]) +}) + +seedFixtures(aw) + +const admin = { headers: { Authorization: 'Bearer jwt-admin' } } +const staff = { headers: { Authorization: 'Bearer jwt-staff' } } +const kunde = { headers: { Authorization: 'Bearer jwt-kunde' } } +const base = '/api/admin/finanzen' +const SETTINGS = () => config.collections.financeSettings +const COSTS = () => config.collections.financeFixedCosts +const TX = () => config.collections.bankTransactions +const storedSettings = () => aw.docs(SETTINGS()).get('main') +const storedCost = (id) => aw.docs(COSTS()).get(id) +const callsTo = (method, path) => st.calls.filter((c) => c.method === method && (path instanceof RegExp ? path.test(c.path) : c.path === path)) + +const DEFAULTS = { minBufferCents: 0, taxReservePercent: 30, previousYearRevenueCents: null, estPrepaymentCents: 0, bankDetailsText: '' } +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ + +async function reset() { + mock.restoreAll() + invalidateFinanceCache() + st.calls.length = 0 + st.invoices.clear() + st.customers.clear() + st.customers.set('cus_c1', { id: 'cus_c1', object: 'customer', name: 'Kunde Eins', email: 'k@example.invalid', metadata: { webklarCustomerId: 'c1' } }) + for (const collection of [SETTINGS(), COSTS(), TX()]) { + for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id) + } +} +beforeEach(reset) + +const request = async (method, path, { body, headers } = {}) => { + const res = await fetch(app.url + path, { + method, + headers: { ...(body !== undefined ? { 'content-type': 'application/json' } : {}), ...(headers || {}) }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + const text = await res.text() + return { status: res.status, body: text ? JSON.parse(text) : null } +} +const patchSettings = (body, auth = admin) => request('PATCH', `${base}/einstellungen`, { body, ...auth }) +const getSettings = () => get(`${base}/einstellungen`, admin) +const postCost = (body) => post(`${base}/fixkosten`, body, admin) +const patchCost = (id, body) => request('PATCH', `${base}/fixkosten/${id}`, { body, ...admin }) +const deleteCost = (id) => request('DELETE', `${base}/fixkosten/${id}`, admin) + +const PORKBUN = { name: 'Porkbun', amountCents: 449, interval: 'monat', nextDate: '2026-10-29', counterpartyMatch: 'PORKBUN' } +const seedCost = (id, over = {}) => + aw.seed(COSTS(), { $id: id, name: 'Porkbun', amountCents: 449, interval: 'monat', nextDate: '2026-10-29', counterpartyMatch: 'porkbun', active: true, ...over }) + +// ---------- Zugriff ---------- + +test('alle Routen: ohne JWT 401, Mitarbeiter ohne Admin-Label und Kunden 403', async () => { + seedCost('fk1') + const calls = [ + ['GET', `${base}/einstellungen`, undefined], + ['PATCH', `${base}/einstellungen`, { taxReservePercent: 10 }], + ['GET', `${base}/fixkosten`, undefined], + ['POST', `${base}/fixkosten`, PORKBUN], + ['PATCH', `${base}/fixkosten/fk1`, { active: false }], + ['DELETE', `${base}/fixkosten/fk1`, undefined], + ] + for (const [method, path, body] of calls) { + assert.equal((await request(method, path, { body })).status, 401, `${method} ${path} ohne JWT`) + assert.equal((await request(method, path, { body, ...staff })).status, 403, `${method} ${path} jwt-staff`) + assert.equal((await request(method, path, { body, ...kunde })).status, 403, `${method} ${path} jwt-kunde`) + } + // nichts wurde veraendert + assert.equal(storedSettings(), undefined) + assert.equal(storedCost('fk1').active, true) + assert.equal(aw.docs(COSTS()).size, 1) +}) + +// ---------- Einstellungen lesen ---------- + +test('GET /einstellungen ohne Dokument: Standardwerte, es wird nichts angelegt', async () => { + assert.deepEqual(await getSettings(), { einstellungen: DEFAULTS }) + assert.equal(aw.docs(SETTINGS()).size, 0) + assert.deepEqual(await settings.getSettings(), DEFAULTS) + assert.equal(aw.docs(SETTINGS()).size, 0) +}) + +test('GET /einstellungen: gespeicherte Werte ueberschreiben die Standardwerte, fehlende und null-Felder fallen auf sie zurueck', async () => { + // wie das Setup-Skript es anlegt: ohne previousYearRevenueCents und bankDetailsText + aw.seed(SETTINGS(), { $id: 'main', taxReservePercent: 25, minBufferCents: 50000, estPrepaymentCents: 0 }) + assert.deepEqual((await getSettings()).einstellungen, { ...DEFAULTS, taxReservePercent: 25, minBufferCents: 50000 }) + + // Appwrite liefert fuer nie gesetzte Felder null + aw.seed(SETTINGS(), { $id: 'main', taxReservePercent: null, minBufferCents: null, estPrepaymentCents: 12300, previousYearRevenueCents: null, bankDetailsText: null }) + assert.deepEqual((await getSettings()).einstellungen, { ...DEFAULTS, estPrepaymentCents: 12300 }) + + aw.seed(SETTINGS(), { $id: 'main', previousYearRevenueCents: 0, bankDetailsText: 'IBAN DE00' }) + assert.deepEqual((await getSettings()).einstellungen, { ...DEFAULTS, previousYearRevenueCents: 0, bankDetailsText: 'IBAN DE00' }) +}) + +test('GET /einstellungen: nur die fuenf Felder, nie Appwrite-Metafelder', async () => { + aw.seed(SETTINGS(), { $id: 'main', taxReservePercent: 25, secret: 'intern' }) + const { einstellungen } = await getSettings() + assert.deepEqual(Object.keys(einstellungen).sort(), Object.keys(DEFAULTS).sort()) +}) + +// ---------- Einstellungen aendern ---------- + +test('PATCH { taxReservePercent: 25 } ohne Dokument: 25, das Dokument main wird angelegt (mit den Standardwerten)', async () => { + const res = await patchSettings({ taxReservePercent: 25 }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(res.body, { einstellungen: { ...DEFAULTS, taxReservePercent: 25 } }) + const doc = storedSettings() + assert.ok(doc, 'financeSettings/main fehlt') + assert.equal(doc.taxReservePercent, 25) + assert.equal(doc.minBufferCents, 0) + assert.equal(doc.estPrepaymentCents, 0) + assert.deepEqual((await getSettings()).einstellungen, { ...DEFAULTS, taxReservePercent: 25 }) +}) + +test('PATCH aendert nur die genannten Felder, auch mehrere auf einmal', async () => { + aw.seed(SETTINGS(), { $id: 'main', taxReservePercent: 30, minBufferCents: 1000, estPrepaymentCents: 500, bankDetailsText: 'IBAN DE00' }) + const res = await patchSettings({ minBufferCents: 250000, bankDetailsText: 'IBAN DE11\nBIC TESTDEFF', estPrepaymentCents: 0 }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(res.body.einstellungen, { + ...DEFAULTS, + taxReservePercent: 30, + minBufferCents: 250000, + estPrepaymentCents: 0, + bankDetailsText: 'IBAN DE11\nBIC TESTDEFF', + }) + assert.equal(storedSettings().taxReservePercent, 30) + assert.equal(aw.docs(SETTINGS()).size, 1) +}) + +test('PATCH: Grenzwerte sind erlaubt (0 und 60 Prozent, Puffer 100.000.000, 500 Zeichen Bankdaten, Vorjahr 0)', async () => { + for (const patch of [ + { taxReservePercent: 0 }, + { taxReservePercent: 60 }, + { minBufferCents: 0 }, + { minBufferCents: 100_000_000 }, + { previousYearRevenueCents: 0 }, + { estPrepaymentCents: 0 }, + { bankDetailsText: 'x'.repeat(500) }, + { bankDetailsText: '' }, + ]) { + const res = await patchSettings(patch) + assert.equal(res.status, 200, `${JSON.stringify(patch).slice(0, 60)}: ${JSON.stringify(res.body).slice(0, 120)}`) + const [key, value] = Object.entries(patch)[0] + assert.equal(res.body.einstellungen[key], value) + } +}) + +test('PATCH: der Bankverbindungstext wird ohne Rand-Leerraum gespeichert', async () => { + const res = await patchSettings({ bankDetailsText: ' IBAN DE00 0000\n ' }) + assert.equal(res.status, 200) + assert.equal(res.body.einstellungen.bankDetailsText, 'IBAN DE00 0000') + assert.equal(storedSettings().bankDetailsText, 'IBAN DE00 0000') +}) + +test('PATCH { previousYearRevenueCents: null } loescht den Wert wieder', async () => { + assert.equal((await patchSettings({ previousYearRevenueCents: 4500000 })).body.einstellungen.previousYearRevenueCents, 4500000) + assert.equal(storedSettings().previousYearRevenueCents, 4500000) + const res = await patchSettings({ previousYearRevenueCents: null }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(res.body.einstellungen.previousYearRevenueCents, null) + assert.equal(storedSettings().previousYearRevenueCents, null) + assert.equal((await getSettings()).einstellungen.previousYearRevenueCents, null) +}) + +test('PATCH: ungueltige Werte -> 400 mit dem Feldnamen, nichts wird gespeichert oder angelegt', async () => { + const invalid = [ + ['taxReservePercent', 80], + ['taxReservePercent', 61], + ['taxReservePercent', -1], + ['taxReservePercent', 25.5], + ['taxReservePercent', '25'], + ['taxReservePercent', null], + ['taxReservePercent', true], + ['minBufferCents', -1], + ['minBufferCents', 100_000_001], + ['minBufferCents', 1.5], + ['minBufferCents', null], + ['minBufferCents', '1000'], + ['previousYearRevenueCents', -1], + ['previousYearRevenueCents', 1.5], + ['previousYearRevenueCents', 'abc'], + ['previousYearRevenueCents', Number.MAX_SAFE_INTEGER + 2], + ['estPrepaymentCents', -1], + ['estPrepaymentCents', 0.5], + ['estPrepaymentCents', null], + ['bankDetailsText', 'x'.repeat(501)], + ['bankDetailsText', 123], + ['bankDetailsText', null], + ['bankDetailsText', ['IBAN']], + ] + for (const [field, value] of invalid) { + const res = await patchSettings({ [field]: value }) + assert.equal(res.status, 400, `${field}=${String(value).slice(0, 20)} -> ${res.status}`) + assert.ok(res.body.error.includes(field), `Meldung nennt ${field}: ${res.body.error}`) + } + assert.equal(aw.docs(SETTINGS()).size, 0) + + // ein ungueltiges Feld unter gueltigen: gar nichts wird gespeichert + aw.seed(SETTINGS(), { $id: 'main', taxReservePercent: 30, minBufferCents: 100 }) + const mixed = await patchSettings({ minBufferCents: 999, taxReservePercent: 80 }) + assert.equal(mixed.status, 400) + assert.equal(storedSettings().minBufferCents, 100) +}) + +test('PATCH: unbekannter Schluessel -> 400 (auch neben gueltigen), nichts wird gespeichert', async () => { + const res = await patchSettings({ foo: 1 }) + assert.equal(res.status, 400) + assert.ok(res.body.error.includes('foo'), res.body.error) + assert.equal(aw.docs(SETTINGS()).size, 0) + assert.equal((await patchSettings({ taxReservePercent: 20, foo: 1 })).status, 400) + assert.equal(aw.docs(SETTINGS()).size, 0) + // Metafelder und Prototyp-Schluessel sind genauso unbekannt + assert.equal((await patchSettings({ $id: 'x' })).status, 400) + const res2 = await fetch(app.url + `${base}/einstellungen`, { + method: 'PATCH', + headers: { 'content-type': 'application/json', ...admin.headers }, + body: '{"__proto__":{"taxReservePercent":1}}', + }) + assert.equal(res2.status, 400) + assert.equal(aw.docs(SETTINGS()).size, 0) +}) + +test('PATCH: kein Objekt -> 400; ein leeres Objekt aendert nichts und legt nichts an', async () => { + for (const body of [[], 'text', 5, null]) { + assert.equal((await patchSettings(body)).status, 400, JSON.stringify(body)) + } + const res = await patchSettings({}) + assert.equal(res.status, 200) + assert.deepEqual(res.body, { einstellungen: DEFAULTS }) + assert.equal(aw.docs(SETTINGS()).size, 0) +}) + +test('PATCH: zwei gleichzeitige Aenderungen ohne Dokument legen es einmal an und behalten beide Felder', async () => { + const [a, b] = await Promise.all([patchSettings({ taxReservePercent: 20 }), patchSettings({ minBufferCents: 70000 })]) + assert.equal(a.status, 200, JSON.stringify(a.body)) + assert.equal(b.status, 200, JSON.stringify(b.body)) + assert.equal(aw.docs(SETTINGS()).size, 1) + assert.equal(storedSettings().taxReservePercent, 20) + assert.equal(storedSettings().minBufferCents, 70000) +}) + +test('Einstellungen: ein Fehler von Appwrite wird 500 "Interner Fehler." ohne dessen Meldung', async () => { + const muted = mock.method(console, 'error', () => {}) + aw.failNext({ collection: SETTINGS(), method: 'GET', status: 500, message: 'geheime Appwrite-Meldung' }) + const read = await rawGet(`${base}/einstellungen`, admin) + assert.equal(read.status, 500) + assert.deepEqual(read.body, { error: 'Interner Fehler.' }) + + aw.seed(SETTINGS(), { $id: 'main', taxReservePercent: 30 }) + aw.failNext({ collection: SETTINGS(), method: 'PATCH', status: 500, message: 'geheime Appwrite-Meldung' }) + const write = await patchSettings({ taxReservePercent: 20 }) + assert.equal(write.status, 500) + assert.deepEqual(write.body, { error: 'Interner Fehler.' }) + assert.equal(storedSettings().taxReservePercent, 30) + assert.ok(muted.mock.calls.length >= 2) + assert.ok(!JSON.stringify(muted.mock.calls.map((c) => c.arguments)).includes('geheime')) +}) + +// ---------- Fixkosten ---------- + +test('POST /fixkosten: PORKBUN wird klein gespeichert, active ist Standard true, die ID ist eine UUID', async () => { + const res = await postCost(PORKBUN) + assert.equal(res.status, 201, JSON.stringify(res.body)) + const { position } = res.body + assert.match(position.$id, UUID) + assert.deepEqual(Object.keys(position).filter((key) => key.startsWith('$')), ['$id']) + assert.deepEqual( + { ...position, $id: undefined }, + { $id: undefined, name: 'Porkbun', amountCents: 449, interval: 'monat', nextDate: '2026-10-29', counterpartyMatch: 'porkbun', active: true } + ) + const doc = storedCost(position.$id) + assert.equal(doc.counterpartyMatch, 'porkbun') + assert.equal(doc.active, true) +}) + +test('POST /fixkosten: Name ohne Rand-Leerraum, Suchbegriff getrimmt und klein, ohne Suchbegriff leer, active false moeglich', async () => { + const res = await postCost({ name: ' Hetzner Server ', amountCents: 1990, interval: 'quartal', nextDate: '2027-01-01', counterpartyMatch: ' Hetzner Online GmbH ', active: false }) + assert.equal(res.status, 201, JSON.stringify(res.body)) + assert.equal(res.body.position.name, 'Hetzner Server') + assert.equal(res.body.position.counterpartyMatch, 'hetzner online gmbh') + assert.equal(res.body.position.active, false) + const bare = await postCost({ name: 'Versicherung', amountCents: 12000, interval: 'jahr', nextDate: '2027-03-31' }) + assert.equal(bare.status, 201) + assert.equal(bare.body.position.counterpartyMatch, '') + assert.equal(bare.body.position.active, true) + assert.equal(aw.docs(COSTS()).size, 2) +}) + +test('POST /fixkosten: Grenzwerte sind erlaubt (Name und Suchbegriff 120 Zeichen, 1 Cent, 29.02. im Schaltjahr)', async () => { + const res = await postCost({ name: 'N'.repeat(120), amountCents: 1, interval: 'jahr', nextDate: '2028-02-29', counterpartyMatch: 'M'.repeat(120) }) + assert.equal(res.status, 201, JSON.stringify(res.body)) + assert.equal(res.body.position.counterpartyMatch, 'm'.repeat(120)) +}) + +test('POST /fixkosten: ungueltige Eingaben -> 400 mit dem Feldnamen, nichts wird angelegt', async () => { + const invalid = [ + ['name', ''], + ['name', ' '], + ['name', 'x'.repeat(121)], + ['name', 5], + ['name', undefined], + ['amountCents', 0], + ['amountCents', -449], + ['amountCents', 4.49], + ['amountCents', '449'], + ['amountCents', null], + ['amountCents', undefined], + ['interval', 'woche'], + ['interval', 'Monat'], + ['interval', ''], + ['interval', undefined], + ['nextDate', '2026-13-01'], + ['nextDate', '2026-02-30'], + ['nextDate', '2027-02-29'], + ['nextDate', '29.10.2026'], + ['nextDate', '2026-1-1'], + ['nextDate', '2026-10-29T00:00:00Z'], + ['nextDate', 20261029], + ['nextDate', undefined], + ['counterpartyMatch', 'x'.repeat(121)], + ['counterpartyMatch', 7], + ['active', 'ja'], + ['active', 1], + ['active', null], + ] + for (const [field, value] of invalid) { + const res = await postCost({ ...PORKBUN, [field]: value }) + assert.equal(res.status, 400, `${field}=${String(value).slice(0, 20)} -> ${res.status}`) + assert.ok(res.body.error.includes(field), `Meldung nennt ${field}: ${res.body.error}`) + } + assert.equal(aw.docs(COSTS()).size, 0) +}) + +test('POST /fixkosten: unbekannter Schluessel und Nicht-Objekt -> 400', async () => { + const extra = await postCost({ ...PORKBUN, foo: 1 }) + assert.equal(extra.status, 400) + assert.ok(extra.body.error.includes('foo'), extra.body.error) + assert.equal((await postCost({ ...PORKBUN, $id: 'mein-id' })).status, 400) + assert.equal((await postCost([])).status, 400) + assert.equal(aw.docs(COSTS()).size, 0) +}) + +test('GET /fixkosten: leer -> []; sonst alle, nach Name sortiert, ohne Appwrite-Metafelder ausser $id', async () => { + assert.deepEqual(await get(`${base}/fixkosten`, admin), { fixkosten: [] }) + seedCost('fk2', { name: 'Zoom' }) + seedCost('fk1', { name: 'hetzner', active: false }) + seedCost('fk3', { name: 'Adobe' }) + const { fixkosten } = await get(`${base}/fixkosten`, admin) + assert.deepEqual(fixkosten.map((item) => item.name), ['Adobe', 'hetzner', 'Zoom']) + assert.deepEqual(fixkosten.map((item) => item.$id), ['fk3', 'fk1', 'fk2']) + assert.equal(fixkosten[1].active, false) + for (const item of fixkosten) assert.deepEqual(Object.keys(item).filter((key) => key.startsWith('$')), ['$id']) + assert.deepEqual(Object.keys(fixkosten[0]).sort(), ['$id', 'active', 'amountCents', 'counterpartyMatch', 'interval', 'name', 'nextDate']) +}) + +test('GET /fixkosten: fragt Appwrite mit einer Obergrenze von 1000 (Standard waeren nur 25 Positionen)', async () => { + const realFetch = globalThis.fetch + const urls = [] + mock.method(globalThis, 'fetch', (url, options) => { + urls.push(decodeURIComponent(String(url))) + return realFetch(url, options) + }) + await settings.listFixedCosts() + const listing = urls.find((url) => url.includes(`/collections/${COSTS()}/documents`)) + assert.ok(listing, 'keine Anfrage an financeFixedCosts') + assert.match(listing, /"method":"limit","values":\[1000\]/) +}) + +test('PATCH /fixkosten/:id: aendert genannte Felder, der Suchbegriff wird klein gespeichert', async () => { + seedCost('fk1') + const res = await patchCost('fk1', { name: ' Porkbun Domains ', amountCents: 499, interval: 'jahr', nextDate: '2027-10-29', counterpartyMatch: 'PORKBUN LLC', active: false }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(res.body.position, { + $id: 'fk1', + name: 'Porkbun Domains', + amountCents: 499, + interval: 'jahr', + nextDate: '2027-10-29', + counterpartyMatch: 'porkbun llc', + active: false, + }) + assert.equal(storedCost('fk1').counterpartyMatch, 'porkbun llc') + // nur ein Feld: der Rest bleibt; Suchbegriff leeren geht + const second = await patchCost('fk1', { active: true, counterpartyMatch: '' }) + assert.equal(second.status, 200) + assert.equal(second.body.position.name, 'Porkbun Domains') + assert.equal(second.body.position.active, true) + assert.equal(second.body.position.counterpartyMatch, '') +}) + +test('PATCH /fixkosten/:id: ungueltig -> 400 mit Feldnamen, nichts wird geaendert', async () => { + seedCost('fk1') + const invalid = [ + ['name', ''], + ['name', 'x'.repeat(121)], + ['amountCents', 0], + ['amountCents', 1.5], + ['interval', 'woche'], + ['nextDate', '2026-02-30'], + ['counterpartyMatch', 'x'.repeat(121)], + ['active', 'false'], + ] + for (const [field, value] of invalid) { + const res = await patchCost('fk1', { [field]: value }) + assert.equal(res.status, 400, `${field}=${String(value).slice(0, 20)}`) + assert.ok(res.body.error.includes(field), res.body.error) + } + assert.equal((await patchCost('fk1', { foo: 1 })).status, 400) + assert.equal((await patchCost('fk1', { $id: 'anders' })).status, 400) + assert.equal((await patchCost('fk1', [])).status, 400) + assert.equal((await patchCost('fk1', { name: 'Neu', amountCents: 0 })).status, 400) + assert.equal(storedCost('fk1').name, 'Porkbun') + assert.equal(storedCost('fk1').amountCents, 449) +}) + +test('PATCH /fixkosten/:id: unbekannte oder unmoegliche ID -> 404; leeres Objekt liefert die Position unveraendert', async () => { + seedCost('fk1') + for (const id of ['gibtsnicht', 'x'.repeat(40), '.punkt', 'a%20b']) { + const res = await patchCost(id, { active: false }) + assert.equal(res.status, 404, id) + assert.match(res.body.error, /Fixkosten/) + } + // auch ein leeres Objekt prueft zuerst, ob es die Position gibt + assert.equal((await patchCost('gibtsnicht', {})).status, 404) + const same = await patchCost('fk1', {}) + assert.equal(same.status, 200) + assert.equal(same.body.position.$id, 'fk1') + assert.equal(same.body.position.name, 'Porkbun') + assert.equal(aw.docs(COSTS()).size, 1) +}) + +test('DELETE /fixkosten/:id: loescht die Position; unbekannt -> 404', async () => { + seedCost('fk1') + seedCost('fk2') + const res = await deleteCost('fk1') + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(res.body, { ok: true }) + assert.equal(storedCost('fk1'), undefined) + assert.ok(storedCost('fk2')) + assert.equal((await deleteCost('fk1')).status, 404) + assert.equal((await deleteCost('x'.repeat(40))).status, 404) + assert.equal(aw.docs(COSTS()).size, 1) +}) + +test('Fixkosten: ein Fehler von Appwrite wird 500 "Interner Fehler." ohne dessen Meldung', async () => { + const muted = mock.method(console, 'error', () => {}) + seedCost('fk1') + const attempts = [ + ['GET', () => rawGet(`${base}/fixkosten`, admin)], + ['POST', () => postCost(PORKBUN)], + ['PATCH', () => patchCost('fk1', { active: false })], + ['DELETE', () => deleteCost('fk1')], + ] + for (const [method, run] of attempts) { + aw.failNext({ collection: COSTS(), method, status: 500, message: 'geheime Appwrite-Meldung' }) + const res = await run() + assert.equal(res.status, 500, method) + assert.deepEqual(res.body, { error: 'Interner Fehler.' }, method) + } + assert.equal(storedCost('fk1').active, true) + assert.equal(aw.docs(COSTS()).size, 1) + assert.ok(!JSON.stringify(muted.mock.calls.map((c) => c.arguments)).includes('geheime')) +}) + +// ---------- Dienste ---------- + +test('Dienste: getFixedCost liefert die Position oder null, ungueltige IDs sind unbekannt', async () => { + seedCost('fk1') + assert.equal((await settings.getFixedCost('fk1')).name, 'Porkbun') + assert.equal(await settings.getFixedCost('gibtsnicht'), null) + assert.equal(await settings.getFixedCost('x'.repeat(40)), null) + assert.equal(await settings.getFixedCost(''), null) + assert.equal(await settings.getFixedCost(undefined), null) + assert.equal(await settings.getFixedCost({ $id: 'fk1' }), null) +}) + +test('Dienste: Validierungsfehler tragen status 400, unbekannte Position status 404', async () => { + await assert.rejects(settings.patchSettings({ taxReservePercent: 80 }), (err) => err.status === 400 && /taxReservePercent/.test(err.message)) + await assert.rejects(settings.createFixedCost({ ...PORKBUN, interval: 'woche' }), (err) => err.status === 400 && /interval/.test(err.message)) + await assert.rejects(settings.updateFixedCost('gibtsnicht', { active: false }), (err) => err.status === 404) + await assert.rejects(settings.deleteFixedCost('gibtsnicht'), (err) => err.status === 404) + const created = await settings.createFixedCost(PORKBUN) + assert.match(created.$id, UUID) + assert.equal((await settings.listFixedCosts()).length, 1) + await settings.deleteFixedCost(created.$id) + assert.deepEqual(await settings.listFixedCosts(), []) +}) + +// ---------- Zuordnung mit einer angelegten Position ---------- + +test('Zuordnen: eine ueber POST /fixkosten angelegte Position (UUID) laesst sich einer Buchung zuordnen', async () => { + const outgoing = { + accountUid: 'konto-1', + status: 'BOOK', + bookingDate: '2026-09-29', + valueDate: '2026-09-29', + amountCents: -449, + currency: 'EUR', + counterpartyName: 'PORKBUN LLC', + counterpartyIban: '', + remittance: '', + mcc: '', + category: '', + matchedInvoiceId: '', + matchedInvoiceNumber: '', + matchedPayoutId: '', + fixedCostId: '', + note: '', + syncedAt: '2026-10-01T09:00:00.000Z', + } + aw.seed(TX(), { ...outgoing, $id: 'b1', entryReference: 'b1' }) + aw.seed(TX(), { ...outgoing, $id: 'b2', entryReference: 'b2' }) + const { position } = (await postCost(PORKBUN)).body + const res = await post(`${base}/buchungen/b1/zuordnen`, { fixedCostId: position.$id }, admin) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(res.body.buchung.category, 'fixkosten') + assert.equal(res.body.buchung.fixedCostId, position.$id) + + // gibt es die Position nicht (mehr), bleibt es bei der bisherigen Meldung + await deleteCost(position.$id) + const gone = await post(`${base}/buchungen/b2/zuordnen`, { fixedCostId: position.$id }, admin) + assert.equal(gone.status, 400) + assert.equal(gone.body.error, 'Diese Fixkosten-Position gibt es nicht.') +}) + +// ---------- Rechnungsfuss aus den Einstellungen ---------- + +const ITEMS = [{ description: 'Website-Anpassung', quantity: 1, unitAmountCents: 5000 }] +const PAYMENT_NOTE = 'Bitte geben Sie bei Überweisung die Rechnungsnummer als Verwendungszweck an.' +const footerOfLastDraft = () => callsTo('POST', '/v1/invoices').at(-1).body.footer + +test('Rechnung: nach PATCH { bankDetailsText } steht die IBAN-Zeile im Rechnungsfuss', async () => { + assert.equal((await patchSettings({ bankDetailsText: 'IBAN DE00 0000 0000 0000 0000 00\nBIC TESTDEFF' })).status, 200) + await createProjectInvoice({ customerId: 'c1', items: ITEMS, createdBy: 'kenso@example.invalid' }) + assert.equal( + footerOfLastDraft(), + `${config.stripe.invoiceFooter}\nIBAN DE00 0000 0000 0000 0000 00\nBIC TESTDEFF\n${PAYMENT_NOTE}` + ) + + // wieder leer: der Fuss hat keine Bankzeile mehr + assert.equal((await patchSettings({ bankDetailsText: '' })).status, 200) + await createProjectInvoice({ customerId: 'c1', items: ITEMS, createdBy: 'kenso@example.invalid' }) + assert.equal(footerOfLastDraft(), `${config.stripe.invoiceFooter}\n${PAYMENT_NOTE}`) +}) + +test('Rechnung: ohne Einstellungsdokument nur Kleinunternehmer-Hinweis und Verwendungszweck-Satz; ein uebergebener Text wird ignoriert', async () => { + await createProjectInvoice({ customerId: 'c1', items: ITEMS, createdBy: 'k', bankDetailsText: 'IBAN DE99 aus dem Aufruf' }) + assert.equal(footerOfLastDraft(), `${config.stripe.invoiceFooter}\n${PAYMENT_NOTE}`) + assert.equal(aw.docs(SETTINGS()).size, 0) +}) + +test('Rechnung: sind die Einstellungen nicht lesbar, scheitert sie vor jedem Stripe-Aufruf', async () => { + aw.failNext({ collection: SETTINGS(), method: 'GET', status: 500 }) + await assert.rejects(createProjectInvoice({ customerId: 'c1', items: ITEMS, createdBy: 'k' })) + assert.equal(st.calls.filter((c) => c.method !== 'GET').length, 0) +}) diff --git a/test/financeStripeWrite.test.js b/test/financeStripeWrite.test.js index 1b8e4c0..6462e10 100644 --- a/test/financeStripeWrite.test.js +++ b/test/financeStripeWrite.test.js @@ -13,6 +13,7 @@ const { config } = await import('../server/config.js') const { seedFixtures } = await import('./support/fixtures.js') const { berlinDate } = await import('../server/services/finance/dates.js') const { getStripe } = await import('../server/services/stripeBilling.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { invoiceFooter, createProjectInvoice, sendInvoiceNow, voidInvoice, deleteDraft, listInvoices, invalidateFinanceCache } = await import( '../server/services/finance/stripeFinance.js' ) @@ -96,14 +97,20 @@ test('invoiceFooter: Kleinunternehmer-Hinweis, Bankdaten (falls gesetzt), Verwen ) }) -test('createProjectInvoice: Entwurf mit Positionen, Rechnungsfuss und Metadaten (nicht gesendet)', async () => { - const result = await createProjectInvoice({ - customerId: 'c1', - items: BASE_ITEMS, - memo: 'Leistungen im September', - createdBy: 'kenso@example.invalid', - bankDetailsText: 'IBAN DE00 0000 0000 0000 0000 00', - }) +test('createProjectInvoice: Entwurf mit Positionen, Rechnungsfuss (Bankdaten aus den Einstellungen) und Metadaten (nicht gesendet)', async () => { + // Der Bankverbindungstext kommt aus financeSettings/main (siehe settings.js), nicht aus dem Aufruf + aw.seed(config.collections.financeSettings, { $id: 'main', bankDetailsText: 'IBAN DE00 0000 0000 0000 0000 00' }) + let result + try { + result = await createProjectInvoice({ + customerId: 'c1', + items: BASE_ITEMS, + memo: 'Leistungen im September', + createdBy: 'kenso@example.invalid', + }) + } finally { + await deleteDocument(config.collections.financeSettings, 'main') + } const [create] = callsTo('POST', '/v1/invoices') assert.ok(create, 'POST /v1/invoices fehlt')