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, resetInvoiceCreateGuardForTests } = 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() resetInvoiceCreateGuardForTests() 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 (derselbe Inhalt wie eben: den Schutz vor doppeltem Anlegen zuruecksetzen) assert.equal((await patchSettings({ bankDetailsText: '' })).status, 200) resetInvoiceCreateGuardForTests() 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) })