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
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user