Files
Webklar-Kundenbereich/test/financeOverview.test.js
T
ClaudeandClaude Sonnet 5.5 039fdcaf27 Finanzen: Vorschau, Steuer und Aufgaben genauer (Fixkosten ohne ESt, verspaetete Auszahlungen, Abos nicht ladbar)
M6: fixkosten30Cents zaehlt nur Fixkosten, die ESt-Vorauszahlung bleibt sicherer Posten.
M7: verspaetete Auszahlung (Ankunftstag vorbei, noch nicht bezahlt) kommt heute.
M8: ohne Stripe sind Kleinunternehmer-Zaehler und Anteil unbekannt (null), keine Warnung daraus.
M12: Verbindungsdokument ohne Sitzung zaehlt als keine Verbindung.
M13: Buchungen privat/fixkosten gehen nicht in die Fixkosten-Vorschlaege.
M14: Vorjahresumsatz ueber 25.000 EUR ist eine hohe Aufgabe.
M15: scheitert das Laden der Abos, meldet abos.fehler statt die ganze Uebersicht zu kippen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00

1050 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, after, beforeEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import Stripe from 'stripe'
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: {
bankConnections: schemaKeys('bankConnections'),
bankTransactions: schemaKeys('bankTransactions'),
financeSettings: schemaKeys('financeSettings'),
financeFixedCosts: schemaKeys('financeFixedCosts'),
subscriptions: schemaKeys('subscriptions'),
},
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { seedFixtures, customers } = 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 { invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import('../server/services/finance/stripeFinance.js')
const { getStripe } = await import('../server/services/stripeBilling.js')
const { addDays, berlinDate } = await import('../server/services/finance/dates.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { buildOverview } = await import('../server/services/finance/overview.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 COLLECTIONS = () => [
config.collections.bankConnections,
config.collections.bankTransactions,
config.collections.financeSettings,
config.collections.financeFixedCosts,
config.collections.subscriptions,
]
const callsTo = (method, path) => st.calls.filter((c) => c.method === method && (path instanceof RegExp ? path.test(c.path) : c.path === path))
const TODAY = berlinDate()
const YEAR = TODAY.slice(0, 4)
const at = (days) => addDays(TODAY, days)
const unix = (ymd) => Math.floor(new Date(`${ymd}T12:00:00+02:00`).getTime() / 1000)
async function reset() {
mock.restoreAll()
invalidateFinanceCache()
resetInvoiceCreateGuardForTests()
st.calls.length = 0
st.invoices.clear()
st.customers.clear()
st.payouts.clear()
st.customers.set('cus_c1', { id: 'cus_c1', object: 'customer', name: 'Kunde Eins', email: 'k@example.invalid', metadata: { webklarCustomerId: 'c1' } })
st.customers.set('cus_c2', { id: 'cus_c2', object: 'customer', name: 'Kunde Zwei', email: 'k2@example.invalid', metadata: { webklarCustomerId: 'c2' } })
for (const collection of COLLECTIONS()) {
for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id)
}
// Kunde c2 hat einen Firmennamen: er geht dem Namen vor
aw.seed('customers', { ...customers.c2, companyName: 'Zwei GmbH' })
}
beforeEach(reset)
// ---------- Testdaten ----------
const FULL_BANK_KEYS = [
'status',
'ibanMasked',
'validUntil',
'lastSyncAt',
'lastSyncOk',
'lastError',
'balanceBookedCents',
'balanceAvailableCents',
'balanceDate',
'latestBookingDate',
]
const seedConnection = (over = {}) =>
aw.seed(config.collections.bankConnections, {
$id: 'finom',
status: 'aktiv',
sessionId: 'sess-geheim-4711',
accountUid: 'konto-4711',
iban: 'DE00000000000000009313',
ibanMasked: 'DE00 •••• 9313',
validUntil: '2099-03-29T00:00:00.000Z',
lastSyncAt: new Date().toISOString(),
lastSyncOk: true,
lastError: '',
balanceBookedCents: 10893,
// der Kontostand der Übersicht ist der gebuchte, nicht der verfügbare
balanceAvailableCents: 9999,
balanceDate: TODAY,
latestBookingDate: `${YEAR}-01-09`,
...over,
})
const tx = (id, over = {}) => ({
$id: id,
accountUid: 'konto-4711',
status: 'BOOK',
bookingDate: `${YEAR}-01-09`,
valueDate: `${YEAR}-01-09`,
amountCents: -449,
currency: 'EUR',
counterpartyName: 'PORKBUN.COM',
counterpartyIban: '',
remittance: '',
mcc: '',
entryReference: id,
category: '',
matchedInvoiceId: '',
matchedInvoiceNumber: '',
matchedPayoutId: '',
fixedCostId: '',
note: '',
syncedAt: new Date().toISOString(),
...over,
})
// Die neun Buchungen des echten Abrufs (Betraege wie in financeBankSync.test.js), alle im laufenden Jahr: Einnahmen
// 16.879, Ausgaben 5.986 -> Gewinn 10.893, Ruecklage 30 % = 3.268
const seedNine = () => {
const rows = [
tx('b1', { bookingDate: `${YEAR}-01-05`, amountCents: 16879, counterpartyName: 'KUNDE EINS GMBH', remittance: 'Rechnung WK-0005 vom 28.07.2026' }),
tx('b2', { bookingDate: `${YEAR}-01-06`, amountCents: -4358, counterpartyName: 'OPENROUTER, INC', mcc: '5734' }),
tx('b3', { bookingDate: `${YEAR}-01-07`, amountCents: -131, counterpartyName: 'PNL Fintech B.V.' }),
tx('b4', { amountCents: -50, counterpartyName: 'WEBKLAR WARTUNG' }),
tx('b5', { amountCents: -50, counterpartyName: 'WEBKLAR WARTUNG' }),
tx('b6', { amountCents: -50, counterpartyName: 'WEBKLAR WARTUNG' }),
tx('b7', { amountCents: -449 }),
tx('b8', { amountCents: -449 }),
tx('b9', { amountCents: -449 }),
]
for (const row of rows) aw.seed(config.collections.bankTransactions, row)
}
const seedSettings = (over = {}) =>
aw.seed(config.collections.financeSettings, {
$id: 'main',
minBufferCents: 2000,
taxReservePercent: 30,
previousYearRevenueCents: null,
estPrepaymentCents: 0,
bankDetailsText: '',
...over,
})
// Fixkosten der naechsten 30 Tage: 449 + 1.000 = 1.449; die inaktive Position zaehlt nicht
const seedCosts = () => {
const costs = config.collections.financeFixedCosts
aw.seed(costs, { $id: 'fk1', name: 'Porkbun', amountCents: 449, interval: 'monat', nextDate: at(10), counterpartyMatch: 'porkbun', active: true })
aw.seed(costs, { $id: 'fk2', name: 'Hetzner', amountCents: 1000, interval: 'quartal', nextDate: at(5), counterpartyMatch: 'hetzner', active: true })
aw.seed(costs, { $id: 'fk3', name: 'Alt', amountCents: 2500, interval: 'monat', nextDate: at(3), counterpartyMatch: '', active: false })
}
const seedSub = (id, over = {}) =>
aw.seed(config.collections.subscriptions, {
$id: id,
customerId: 'c1',
status: 'active',
productKey: 'hosting',
planKey: 'hosting_monat',
priceAmount: 2000,
currentPeriodEnd: new Date(`${at(20)}T12:00:00+02:00`).toISOString(),
cancelAtPeriodEnd: false,
cancelAt: null,
...over,
})
const rawInvoice = (over = {}) => ({
id: 'in_open',
object: 'invoice',
number: 'WK-0005',
status: 'open',
customer: 'cus_c1',
customer_name: 'Kunde Eins',
customer_email: 'k@example.invalid',
created: unix(at(-5)),
due_date: unix(at(2)),
total: 16879,
amount_due: 16879,
amount_paid: 0,
amount_remaining: 16879,
status_transitions: { paid_at: null },
metadata: { webklarCustomerId: 'c1' },
lines: { object: 'list', data: [], has_more: false },
...over,
})
const putInvoice = (over) => {
const invoice = rawInvoice(over)
st.invoices.set(invoice.id, invoice)
return invoice
}
// Eine offene Projekt-, eine ueberfaellige und eine bezahlte Rechnung, dazu ein junger Entwurf
function seedStripe() {
putInvoice()
putInvoice({
id: 'in_over',
number: 'WK-0006',
customer: 'cus_c2',
customer_name: 'Kunde Zwei',
created: unix(at(-30)),
due_date: unix(at(-10)),
total: 5000,
amount_due: 5000,
amount_remaining: 5000,
metadata: { webklarCustomerId: 'c2' },
})
putInvoice({
id: 'in_paid',
number: 'WK-0004',
status: 'paid',
created: unix(at(-20)),
due_date: unix(at(-6)),
total: 8000,
amount_due: 0,
amount_paid: 8000,
amount_remaining: 0,
status_transitions: { paid_at: unix(TODAY) },
})
putInvoice({ id: 'in_draft', number: null, status: 'draft', created: unix(TODAY), due_date: null, total: 3000, amount_due: 3000, amount_remaining: 3000 })
}
function seedAll() {
seedConnection()
seedNine()
seedSettings()
seedCosts()
seedSub('sub_a', { customerId: 'c1', status: 'active', priceAmount: 2000, productKey: 'hosting' })
seedSub('sub_b', { customerId: 'c2', status: 'past_due', priceAmount: 5000, productKey: 'wartung', planKey: 'wartung_monat' })
seedStripe()
}
const uebersicht = () => get(`${base}/uebersicht`, admin)
const ART = (body) => body.aufgaben.map((t) => t.art)
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 del = (path) => request('DELETE', path, admin)
const apiError = (message, extra = {}) => new Stripe.errors.StripeAPIError({ message, type: 'api_error', statusCode: 500, ...extra })
/** Stripe liefert weder Rechnungen noch Auszahlungen (wie bei einem Ausfall). */
function stripeDown() {
const error = apiError('Interner Stripe-Fehler sk_live_geheim')
mock.method(getStripe().invoices, 'list', () => ({ autoPagingToArray: () => Promise.reject(error) }))
mock.method(getStripe().payouts, 'list', () => Promise.reject(error))
}
// ---------- Zugriff ----------
test('alle neuen Routen: ohne JWT 401, Mitarbeiter ohne Admin-Label und Kunden 403', async () => {
putInvoice({ id: 'in_draft', status: 'draft', number: null })
const calls = [
['GET', `${base}/uebersicht`],
['GET', `${base}/rechnungen`],
['POST', `${base}/rechnungen`, { customerId: 'c1', items: [{ description: 'x', quantity: 1, unitAmountCents: 100 }] }],
['POST', `${base}/rechnungen/in_draft/senden`, {}],
['POST', `${base}/rechnungen/in_draft/stornieren`, {}],
['DELETE', `${base}/rechnungen/in_draft`],
]
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} Mitarbeiter`)
assert.equal((await request(method, path, { body, ...kunde })).status, 403, `${method} ${path} Kunde`)
}
assert.equal(st.invoices.size, 1)
assert.equal(st.invoices.get('in_draft').status, 'draft')
assert.equal(callsTo('POST', '/v1/invoices').length, 0)
})
// ---------- GET /uebersicht ----------
test('GET /uebersicht: Kopf, Abos, Altersgruppen und Aufgaben aus den Testdaten', async () => {
seedAll()
const body = await uebersicht()
assert.deepEqual(Object.keys(body), ['stand', 'kopf', 'aufgaben', 'vorschau', 'steuer', 'abos', 'rechnungen'])
// Kopf: 10.893 - Ruecklage 3.268 - Fixkosten 1.449 - Puffer 2.000 = 4.176
assert.equal(body.kopf.kontostandCents, 10893)
assert.equal(body.kopf.verfuegbarCents, 4176)
assert.deepEqual(body.kopf.aufschluesselung, { kontostandCents: 10893, steuerRuecklageCents: 3268, fixkosten30Cents: 1449, mindestpufferCents: 2000 })
// sicher: 10.893 - 1.000 (in 5 Tagen) - 449 (in 10 Tagen); erwartet: die Rechnung in 2 Tagen hebt den Stand, der Tiefpunkt ist heute
assert.deepEqual(body.kopf.tiefsterSicher, { amountCents: 9444, date: at(10) })
assert.deepEqual(body.kopf.tiefsterErwartet, { amountCents: 10893, date: TODAY })
assert.deepEqual(body.kopf.tiefsterSicher, body.vorschau.minSicher)
assert.deepEqual(body.kopf.tiefsterErwartet, body.vorschau.minErwartet)
// Vorschau: Fixkosten (ohne die inaktive Position), offene und ueberfaellige Rechnung, Abo-Verlaengerung
assert.deepEqual(
body.vorschau.posten.map((p) => [p.label, p.amountCents, p.sicherheit]),
[
['Rechnung WK-0005 · Kunde Eins', 16879, 'erwartet'],
['Hetzner', -1000, 'sicher'],
['Porkbun', -449, 'sicher'],
['Rechnung WK-0006 · Kunde Zwei (überfällig, angenommen)', 5000, 'erwartet'],
['Abo-Verlängerung', 2000, 'erwartet'],
]
)
assert.equal(body.vorschau.fixkosten30Cents, 1449)
assert.equal(typeof body.vorschau.annahme, 'string')
// Steuer
assert.equal(body.steuer.jahr, Number(YEAR))
assert.equal(body.steuer.ruecklageCents, 3268)
assert.equal(body.steuer.gewinnCents, 10893)
assert.equal(body.steuer.kleinunternehmer.jahrCents, 8000)
// Abos: aktiv 2.000 + past_due 5.000 = 7.000; gefaehrdet 5.000; ein zahlender Kunde (nur active zaehlt)
assert.equal(body.abos.monatlichCents, 7000)
assert.equal(body.abos.gefaehrdetCents, 5000)
assert.equal(body.abos.zahlendeKunden, 1)
assert.deepEqual(body.abos.liste, [
{ customerId: 'c2', kunde: 'Zwei GmbH', produkt: 'wartung', status: 'past_due', betragCents: 5000, naechsteAbbuchung: null },
{ customerId: 'c1', kunde: 'Kunde Eins', produkt: 'hosting', status: 'active', betragCents: 2000, naechsteAbbuchung: at(20) },
])
// Altersgruppen: die offene (in 2 Tagen faellig) ist nicht faellig, die ueberfaellige 10 Tage im Verzug
assert.deepEqual(body.rechnungen.altersgruppen, [
{ key: 'nicht-faellig', anzahl: 1, summeCents: 16879 },
{ key: '1-30', anzahl: 1, summeCents: 5000 },
{ key: '31-60', anzahl: 0, summeCents: 0 },
{ key: 'ueber-60', anzahl: 0, summeCents: 0 },
])
assert.deepEqual(body.rechnungen.entwuerfe, { anzahl: 1, summeCents: 3000 })
// Aufgaben: ueberfaellige Rechnung, fehlgeschlagene Abo-Zahlung (mit Kundenname) und die Zuordnung mit Vorschlag
const arts = ART(body)
assert.ok(arts.includes('ueberfaellig'))
assert.ok(arts.includes('abo-zahlung'))
const abo = body.aufgaben.find((t) => t.art === 'abo-zahlung')
assert.ok(abo.text.startsWith('Zwei GmbH: '), abo.text)
assert.deepEqual(abo.aktion, { typ: 'kunde-oeffnen', customerId: 'c2' })
const zuordnung = body.aufgaben.filter((t) => t.art === 'zuordnung')
assert.equal(zuordnung.length, 1)
assert.ok(zuordnung[0].text.endsWith('Vorschlag: Rechnung WK-0005'), zuordnung[0].text)
assert.equal(zuordnung[0].aktion.transactionId, 'b1')
assert.ok(!arts.includes('bank-zugang') && !arts.includes('bank-abruf'))
})
test('GET /uebersicht: Statuszeile mit Bank (nur die oeffentlichen Felder) und Stripe', async () => {
seedAll()
const before = Date.now()
const body = await uebersicht()
assert.deepEqual(Object.keys(body.stand), ['bank', 'stripe'])
assert.deepEqual(Object.keys(body.stand.bank), FULL_BANK_KEYS)
assert.equal(body.stand.bank.status, 'aktiv')
assert.equal(body.stand.bank.balanceBookedCents, 10893)
assert.equal(body.stand.bank.balanceAvailableCents, 9999)
assert.equal(body.stand.bank.ibanMasked, 'DE00 •••• 9313')
const raw = JSON.stringify(body)
for (const secret of ['sess-geheim-4711', 'konto-4711', 'DE00000000000000009313']) assert.ok(!raw.includes(secret), `${secret} darf nicht an die Seite`)
assert.equal(body.stand.stripe.ok, true)
assert.equal(body.stand.stripe.fehler, undefined)
const fetchedAt = Date.parse(body.stand.stripe.abgerufenUm)
assert.ok(fetchedAt >= before - 1000 && fetchedAt <= Date.now() + 1000)
})
test('GET /uebersicht: Stripe wird je Art nur einmal gefragt (Vorschlaege teilen den Zwischenspeicher)', async () => {
seedAll()
await uebersicht()
assert.equal(callsTo('GET', '/v1/invoices').length, 1)
assert.equal(callsTo('GET', '/v1/payouts').length, 1)
})
test('GET /uebersicht: Stripe-Auszahlungen unterwegs zaehlen zur sicheren Linie', async () => {
seedConnection()
st.payouts.set('po_1', { id: 'po_1', object: 'payout', amount: 45000, arrival_date: unix(at(3)), status: 'in_transit', currency: 'eur' })
const body = await uebersicht()
assert.deepEqual(body.vorschau.posten, [{ date: at(3), label: 'Stripe-Auszahlung', amountCents: 45000, sicherheit: 'sicher' }])
assert.equal(body.vorschau.endeSicherCents, 55893)
})
test('GET /uebersicht: leere Daten -> 200, flache Vorschau, nur Bank-Aufgaben', async () => {
seedConnection({ validUntil: `${YEAR}-12-31T00:00:00.000Z` })
const res = await rawGet(`${base}/uebersicht`, admin)
assert.equal(res.status, 200, JSON.stringify(res.body))
const body = res.body
assert.deepEqual(body.vorschau.posten, [])
assert.deepEqual(body.vorschau.minSicher, { amountCents: 10893, date: TODAY })
assert.deepEqual(body.vorschau.minErwartet, { amountCents: 10893, date: TODAY })
assert.equal(body.vorschau.endeSicherCents, 10893)
assert.equal(body.vorschau.endeErwartetCents, 10893)
// keine Einstellungen gespeichert: Standardwerte (Puffer 0), keine Ruecklage ohne Gewinn
assert.equal(body.kopf.kontostandCents, 10893)
assert.equal(body.kopf.verfuegbarCents, 10893)
assert.deepEqual(body.abos, { monatlichCents: 0, zahlendeKunden: 0, gefaehrdetCents: 0, liste: [] })
assert.deepEqual(body.rechnungen, {
altersgruppen: [
{ key: 'nicht-faellig', anzahl: 0, summeCents: 0 },
{ key: '1-30', anzahl: 0, summeCents: 0 },
{ key: '31-60', anzahl: 0, summeCents: 0 },
{ key: 'ueber-60', anzahl: 0, summeCents: 0 },
],
entwuerfe: { anzahl: 0, summeCents: 0 },
})
for (const art of ART(body)) assert.ok(art.startsWith('bank-'), `unerwartete Aufgabe ${art}`)
assert.equal(body.stand.stripe.ok, true)
})
test('GET /uebersicht: ohne Bankzugang -> Aufgabe "Bankkonto verbinden", Kontostand unbekannt (null)', async () => {
const res = await rawGet(`${base}/uebersicht`, admin)
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.stand.bank, null)
assert.equal(res.body.kopf.kontostandCents, null)
assert.equal(res.body.kopf.verfuegbarCents, null)
assert.equal(res.body.kopf.tiefsterSicher, null)
assert.equal(res.body.kopf.tiefsterErwartet, null)
assert.deepEqual(res.body.kopf.aufschluesselung, { kontostandCents: null, steuerRuecklageCents: 0, fixkosten30Cents: 0, mindestpufferCents: 0 })
assert.deepEqual(ART(res.body), ['bank-zugang'])
})
test('GET /uebersicht: Stripe 500 -> 200, Bankteil, Kopf und Aufgaben aus Bank und Abos, Stripe-Block leer', async () => {
seedAll()
stripeDown()
const res = await rawGet(`${base}/uebersicht`, admin)
assert.equal(res.status, 200, JSON.stringify(res.body))
const body = res.body
assert.deepEqual(body.stand.stripe, { ok: false, fehler: 'Stripe ist gerade nicht erreichbar.', abgerufenUm: null })
assert.ok(!JSON.stringify(body).includes('sk_live_geheim'))
assert.equal(body.stand.bank.balanceBookedCents, 10893)
// Kopf ohne Stripe-Posten: Fixkosten und Abo-Verlaengerung (Appwrite) bleiben, Rechnungen fehlen
assert.equal(body.kopf.kontostandCents, 10893)
assert.equal(body.kopf.verfuegbarCents, 4176)
assert.deepEqual(body.vorschau.posten.map((p) => p.label), ['Hetzner', 'Porkbun', 'Abo-Verlängerung'])
// Kleinunternehmer-Zaehler ohne Stripe-Zahltage waere zu niedrig: unbekannt statt 0 (M8)
assert.equal(body.steuer.kleinunternehmer.jahrCents, null)
assert.equal(body.steuer.kleinunternehmer.anteilFolgejahr, null)
assert.equal(body.steuer.kleinunternehmer.grenzeFolgejahrCents, 2500000)
assert.deepEqual(body.rechnungen.altersgruppen.map((g) => g.anzahl), [0, 0, 0, 0])
assert.deepEqual(body.rechnungen.entwuerfe, { anzahl: 0, summeCents: 0 })
// Aufgaben aus Bank- und Abodaten; die Zuordnung hat ohne Stripe keinen Vorschlag
const arts = ART(body)
assert.ok(arts.includes('abo-zahlung'))
assert.ok(!arts.includes('ueberfaellig'))
const zuordnung = body.aufgaben.find((t) => t.art === 'zuordnung')
assert.ok(zuordnung)
assert.ok(!zuordnung.text.includes('Vorschlag'))
assert.equal(body.abos.monatlichCents, 7000)
})
test('buildOverview: antwortet Stripe nicht binnen der Wartezeit, rechnet die Uebersicht ohne Stripe weiter (I2)', async () => {
seedAll()
const error = mock.method(console, 'error', () => {})
st.delayNext({ method: 'GET', path: '/v1/invoices', ms: 800 })
const started = Date.now()
const body = await buildOverview({ stripeWaitMs: 100 })
assert.ok(Date.now() - started < 600, `Uebersicht brauchte ${Date.now() - started} ms`)
assert.deepEqual(body.stand.stripe, { ok: false, fehler: 'Stripe ist gerade nicht erreichbar.', abgerufenUm: null })
assert.equal(body.stand.bank.balanceBookedCents, 10893)
assert.equal(body.kopf.kontostandCents, 10893)
assert.deepEqual(body.rechnungen.altersgruppen.map((g) => g.anzahl), [0, 0, 0, 0])
assert.ok(ART(body).includes('abo-zahlung'))
assert.equal(error.mock.callCount(), 1)
assert.match(error.mock.calls[0].arguments[0], /Stripe laden fehlgeschlagen: keine Antwort nach 100 ms/)
// spaeter antwortet Stripe wieder: die naechste Uebersicht ist vollstaendig
await new Promise((resolve) => setTimeout(resolve, 800))
invalidateFinanceCache()
const later = await buildOverview({ stripeWaitMs: 5000 })
assert.equal(later.stand.stripe.ok, true)
})
test('buildOverview: Stripe antwortet rechtzeitig -> ok, die Wartezeit haelt den Prozess nicht auf (I2)', async () => {
seedAll()
const body = await buildOverview({ stripeWaitMs: 5000 })
assert.equal(body.stand.stripe.ok, true)
})
test('GET /uebersicht: ohne Stripe kein Kleinunternehmer-Zähler und keine Warnung daraus, mit Stripe schon (M8)', async () => {
seedConnection()
seedSettings()
// 19.000 EUR "Rechnung ausserhalb von Stripe" im laufenden Jahr = 76 % der Grenze
aw.seed(config.collections.bankTransactions, tx('x1', { amountCents: 1_900_000, category: 'rechnung-extern', counterpartyName: 'GROSSKUNDE AG' }))
const ok = await uebersicht()
assert.equal(ok.steuer.kleinunternehmer.jahrCents, 1_900_000)
assert.equal(ok.steuer.kleinunternehmer.anteilFolgejahr, 0.76)
assert.equal(ok.aufgaben.filter((t) => t.art === 'kleinunternehmer').length, 1)
invalidateFinanceCache()
stripeDown()
const down = await uebersicht()
assert.equal(down.stand.stripe.ok, false)
assert.equal(down.steuer.kleinunternehmer.jahrCents, null)
assert.equal(down.steuer.kleinunternehmer.anteilFolgejahr, null)
assert.deepEqual(down.aufgaben.filter((t) => t.art === 'kleinunternehmer'), [])
// Gewinn und Ruecklage kommen aus der Bank und bleiben
assert.equal(down.steuer.einnahmenCents, 1_900_000)
assert.equal(down.steuer.ruecklageCents, ok.steuer.ruecklageCents)
})
test('GET /uebersicht: Verbindungsdokument ohne Sitzung -> wie keine Verbindung, Aufgabe "Bankkonto verbinden" (M12)', async () => {
aw.seed(config.collections.bankConnections, { $id: 'finom', authState: 'abc123', authStartedAt: new Date().toISOString() })
const body = await uebersicht()
assert.equal(body.stand.bank, null)
assert.equal(body.kopf.kontostandCents, null)
assert.equal(body.kopf.verfuegbarCents, null)
assert.deepEqual(ART(body), ['bank-zugang'])
assert.equal(body.aufgaben[0].titel, 'Bankkonto verbinden')
// mit Sitzung (auch bei einer neuen Freigabe in Arbeit) bleibt es ein Zugang
await deleteDocument(config.collections.bankConnections, 'finom')
seedConnection({ authState: 'neu', authStartedAt: new Date().toISOString() })
const connected = await uebersicht()
assert.equal(connected.stand.bank.status, 'aktiv')
assert.ok(!ART(connected).includes('bank-zugang'))
})
test('GET /uebersicht: Buchungen "privat" und "fixkosten" zählen nicht für Fixkosten-Vorschläge (M13)', async () => {
seedConnection()
const hetzner = (id, days, over = {}) => tx(id, { bookingDate: at(days), amountCents: -1000, counterpartyName: 'HETZNER ONLINE GMBH', ...over })
const seedThree = (prefix, over = {}) => [-58, -28, 0].forEach((days, i) => aw.seed(config.collections.bankTransactions, hetzner(`${prefix}${i}`, days, over)))
seedThree('open')
const suggested = (await uebersicht()).aufgaben.filter((t) => t.art === 'fixkosten-vorschlag')
assert.equal(suggested.length, 1)
assert.match(suggested[0].text, /HETZNER ONLINE GMBH/)
for (const id of ['open0', 'open1', 'open2']) await deleteDocument(config.collections.bankTransactions, id)
seedThree('privat', { category: 'privat' })
assert.deepEqual((await uebersicht()).aufgaben.filter((t) => t.art === 'fixkosten-vorschlag'), [])
for (const id of ['privat0', 'privat1', 'privat2']) await deleteDocument(config.collections.bankTransactions, id)
seedThree('fix', { category: 'fixkosten', fixedCostId: 'fk1' })
assert.deepEqual((await uebersicht()).aufgaben.filter((t) => t.art === 'fixkosten-vorschlag'), [])
for (const id of ['fix0', 'fix1', 'fix2']) await deleteDocument(config.collections.bankTransactions, id)
// zwei private und eine offene: ohne den Filter waeren es drei gleiche Abbuchungen
aw.seed(config.collections.bankTransactions, hetzner('m0', -58, { category: 'privat' }))
aw.seed(config.collections.bankTransactions, hetzner('m1', -28, { category: 'privat' }))
aw.seed(config.collections.bankTransactions, hetzner('m2', 0))
assert.deepEqual((await uebersicht()).aufgaben.filter((t) => t.art === 'fixkosten-vorschlag'), [])
})
test('GET /uebersicht: Vorjahresumsatz über 25.000 € aus den Einstellungen wird eine hohe Aufgabe (M14)', async () => {
seedConnection()
seedSettings({ previousYearRevenueCents: 2_600_000 })
const body = await uebersicht()
const todo = body.aufgaben.find((t) => t.titel === 'Vorjahresumsatz über 25.000 €')
assert.ok(todo, JSON.stringify(body.aufgaben))
assert.equal(todo.art, 'kleinunternehmer')
assert.equal(todo.dringlichkeit, 'hoch')
assert.match(todo.text, /26\.000,00/)
// hoch steht vor den mittleren Bank-Aufgaben
assert.equal(body.aufgaben[0].dringlichkeit, 'hoch')
await deleteDocument(config.collections.financeSettings, 'main')
seedSettings({ previousYearRevenueCents: 2_500_000 })
assert.ok(!(await uebersicht()).aufgaben.some((t) => t.titel === 'Vorjahresumsatz über 25.000 €'))
})
test('GET /uebersicht: Abos nicht ladbar -> 200 mit abos.fehler, der Rest bleibt, nichts aus Abos in Vorschau und Aufgaben (M15)', async () => {
seedAll()
seedSub('sub_c', { customerId: 'c1', status: 'past_due' })
const error = mock.method(console, 'error', () => {})
aw.failNext({ collection: config.collections.subscriptions, method: 'GET', status: 500, message: 'Appwrite kaputt 10.0.0.9' })
const res = await rawGet(`${base}/uebersicht`, admin)
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(res.body.abos, {
monatlichCents: null,
zahlendeKunden: null,
gefaehrdetCents: null,
liste: [],
fehler: 'Abos konnten nicht geladen werden.',
})
assert.ok(!JSON.stringify(res.body).includes('10.0.0.9'))
assert.equal(res.body.stand.bank.balanceBookedCents, 10893)
assert.equal(res.body.stand.stripe.ok, true)
assert.ok(!ART(res.body).includes('abo-zahlung'))
assert.ok(!res.body.vorschau.posten.some((p) => p.label === 'Abo-Verlängerung'))
assert.ok(res.body.vorschau.posten.some((p) => p.label === 'Porkbun'), 'Fixkosten bleiben')
assert.ok(ART(res.body).includes('ueberfaellig'))
assert.equal(error.mock.callCount(), 1)
assert.deepEqual(error.mock.calls[0].arguments, ['[finanzen] Abos laden fehlgeschlagen:', 'Error', 500, 500])
// der naechste Aufruf laedt wieder und hat kein fehler-Feld
mock.restoreAll()
const again = await uebersicht()
assert.equal(again.abos.fehler, undefined)
assert.equal(again.abos.liste.length, 3)
})
test('buildOverview: ESt-Vorauszahlung bleibt ein sicherer Posten, ist aber nicht in fixkosten30Cents und verfuegbarCents (M6)', async () => {
seedConnection()
seedSettings({ estPrepaymentCents: 50_000, minBufferCents: 0 })
aw.seed(config.collections.financeFixedCosts, { $id: 'fk1', name: 'Hetzner', amountCents: 449, interval: 'monat', nextDate: '2026-11-25', counterpartyMatch: '', active: true })
const body = await buildOverview({ now: new Date('2026-11-20T10:00:00Z') })
assert.deepEqual(body.vorschau.posten.map((p) => [p.date, p.label, p.amountCents]), [
['2026-11-25', 'Hetzner', -449],
['2026-12-10', 'ESt-Vorauszahlung', -50_000],
])
assert.equal(body.vorschau.fixkosten30Cents, 449)
assert.equal(body.kopf.aufschluesselung.fixkosten30Cents, 449)
// 10.893 - Ruecklage 0 - Fixkosten 449 - Puffer 0: die ESt ist Sache der Ruecklage, nicht noch einmal abgezogen
assert.equal(body.kopf.verfuegbarCents, 10893 - 449)
assert.equal(body.vorschau.endeSicherCents, 10893 - 449 - 50_000)
})
test('GET /uebersicht: nur die Auszahlungen scheitern -> Rechnungen bleiben, Stripe-Block meldet den Fehler', async () => {
seedAll()
st.failNext({ method: 'GET', path: '/v1/payouts', status: 400, message: 'Geheimer Stripe-Text' })
const body = await uebersicht()
assert.equal(body.stand.stripe.ok, false)
assert.equal(body.stand.stripe.fehler, 'Stripe ist gerade nicht erreichbar.')
assert.ok(!JSON.stringify(body).includes('Geheimer'))
assert.ok(ART(body).includes('ueberfaellig'))
assert.equal(body.rechnungen.altersgruppen[1].anzahl, 1)
// ohne vollstaendige Stripe-Daten gibt es keine Zuordnungsvorschlaege
assert.ok(!body.aufgaben.find((t) => t.art === 'zuordnung').text.includes('Vorschlag'))
})
test('GET /uebersicht: Altersgruppen an den Grenzen 30/31 und 60/61 Tage Verzug, Summen nach offenem Betrag', async () => {
seedConnection()
const late = (id, days, remaining) =>
putInvoice({
id,
number: id.toUpperCase(),
created: unix(at(-days - 14)),
due_date: unix(at(-days)),
total: remaining + 100,
amount_due: remaining + 100,
amount_remaining: remaining,
metadata: {},
})
late('in_a', 1, 100)
late('in_b', 30, 200)
late('in_c', 31, 400)
late('in_d', 60, 800)
late('in_e', 61, 1600)
putInvoice({ id: 'in_f', number: 'IN_F', due_date: unix(TODAY), total: 50, amount_due: 50, amount_remaining: 50, metadata: {} })
// bezahlt, storniert und Entwurf zaehlen nicht zu den Altersgruppen
putInvoice({ id: 'in_g', status: 'paid', amount_remaining: 0, amount_paid: 999, due_date: unix(at(-90)), metadata: {} })
putInvoice({ id: 'in_h', status: 'void', due_date: unix(at(-90)), metadata: {} })
const body = await uebersicht()
assert.deepEqual(body.rechnungen.altersgruppen, [
{ key: 'nicht-faellig', anzahl: 1, summeCents: 50 },
{ key: '1-30', anzahl: 2, summeCents: 300 },
{ key: '31-60', anzahl: 2, summeCents: 1200 },
{ key: 'ueber-60', anzahl: 1, summeCents: 1600 },
])
})
test('GET /uebersicht: Abo eines unbekannten Kunden und Fixkosten-Verweis auf eine geloeschte Position bringen nichts zu Fall', async () => {
seedConnection()
seedSub('sub_g', { customerId: 'geist', status: 'past_due', priceAmount: 700 })
aw.seed(config.collections.bankTransactions, tx('b1', { category: 'fixkosten', fixedCostId: 'geloescht', amountCents: -449 }))
const res = await rawGet(`${base}/uebersicht`, admin)
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(res.body.abos.liste, [{ customerId: 'geist', kunde: '', produkt: 'hosting', status: 'past_due', betragCents: 700, naechsteAbbuchung: null }])
const abo = res.body.aufgaben.find((t) => t.art === 'abo-zahlung')
assert.ok(abo)
assert.ok(!abo.text.startsWith(': '))
})
test('GET /uebersicht: Abo-Liste - Status, Produkt, kuendigende Abos ohne naechste Abbuchung, beendete Abos fehlen', async () => {
seedConnection()
const end = new Date(`${at(10)}T12:00:00+02:00`).toISOString()
seedSub('s1', { customerId: 'c1', status: 'active', productKey: 'hosting' })
seedSub('s2', { customerId: 'c2', status: 'active', productKey: undefined, planKey: 'wartung_monat', priceAmount: 1000, currentPeriodEnd: end, cancelAtPeriodEnd: true, cancelAt: null })
seedSub('s3', { customerId: 'c3', status: 'active', priceAmount: 300, currentPeriodEnd: end, cancelAtPeriodEnd: true, cancelAt: new Date(`${at(10)}T12:00:00+02:00`).toISOString() })
seedSub('s4', { customerId: 'c4', status: 'canceled', priceAmount: 9999 })
seedSub('s5', { customerId: 'c1', status: 'unpaid', priceAmount: 400 })
const body = await uebersicht()
const byId = Object.fromEntries(body.abos.liste.map((a) => [`${a.customerId}:${a.status}`, a]))
assert.equal(body.abos.liste.length, 4)
assert.equal(byId['c1:active'].naechsteAbbuchung, at(20))
// Stripe bucht zum Periodenende nichts mehr ab: keine naechste Abbuchung; das Abo zaehlt bis dahin zur Summe
assert.equal(byId['c2:active'].naechsteAbbuchung, null)
assert.equal(byId['c2:active'].produkt, 'wartung_monat')
assert.equal(byId['c3:active'].naechsteAbbuchung, null)
assert.equal(byId['c1:unpaid'].betragCents, 400)
assert.ok(!('c4:canceled' in byId))
// active 2000 + 1000 + 300; unpaid und canceled zaehlen nicht
assert.equal(body.abos.monatlichCents, 3300)
assert.equal(body.abos.gefaehrdetCents, 0)
assert.equal(body.abos.zahlendeKunden, 3)
assert.ok(ART(body).includes('abo-zahlung'))
})
test('GET /uebersicht: ueber 100.000 Euro Einnahmen -> Kleinunternehmer-Aufgabe mit dem engeren Text', async () => {
seedConnection()
putInvoice({ id: 'in_big', status: 'paid', total: 10100000, amount_due: 0, amount_paid: 10100000, amount_remaining: 0, status_transitions: { paid_at: unix(TODAY) }, metadata: {} })
const body = await uebersicht()
const klein = body.aufgaben.find((t) => t.art === 'kleinunternehmer')
assert.ok(klein)
assert.equal(klein.dringlichkeit, 'hoch')
assert.ok(klein.text.includes('nicht mehr – auch im laufenden Jahr'), klein.text)
})
test('buildOverview: ein Zeitpunkt (now) bestimmt "heute"', async () => {
seedConnection()
const overview = await buildOverview({ now: new Date('2031-05-17T08:00:00Z') })
assert.equal(overview.vorschau.minSicher.date, '2031-05-17')
assert.equal(overview.steuer.jahr, 2031)
})
test('GET /uebersicht: Appwrite scheitert -> 500 ohne Appwrite-Meldung', async () => {
seedConnection()
aw.failNext({ collection: config.collections.bankTransactions, method: 'GET', status: 500, message: 'Appwrite kaputt 10.0.0.9' })
const res = await rawGet(`${base}/uebersicht`, admin)
assert.equal(res.status, 500)
assert.equal(res.body.error, 'Die Übersicht konnte nicht geladen werden.')
assert.ok(!JSON.stringify(res.body).includes('10.0.0.9'))
})
// ---------- GET /rechnungen ----------
test('GET /rechnungen: Standard ist alle, neueste zuerst; Statusfilter', async () => {
seedStripe()
const all = await get(`${base}/rechnungen`, admin)
assert.deepEqual(Object.keys(all), ['rechnungen'])
assert.deepEqual(all.rechnungen.map((r) => r.id), ['in_draft', 'in_open', 'in_paid', 'in_over'])
assert.deepEqual((await get(`${base}/rechnungen?status=alle`, admin)).rechnungen.map((r) => r.id), ['in_draft', 'in_open', 'in_paid', 'in_over'])
const byStatus = async (status) => (await get(`${base}/rechnungen?status=${status}`, admin)).rechnungen.map((r) => r.id)
assert.deepEqual(await byStatus('ueberfaellig'), ['in_over'])
assert.deepEqual(await byStatus('offen'), ['in_open'])
assert.deepEqual(await byStatus('bezahlt'), ['in_paid'])
assert.deepEqual(await byStatus('entwurf'), ['in_draft'])
const [over] = (await get(`${base}/rechnungen?status=ueberfaellig`, admin)).rechnungen
assert.equal(over.status, 'ueberfaellig')
assert.equal(over.overdueDays, 10)
assert.equal(over.number, 'WK-0006')
assert.equal(over.remainingCents, 5000)
})
test('GET /rechnungen: kunde filtert auf webklarCustomerId, auch zusammen mit dem Status', async () => {
seedStripe()
const ids = async (query) => (await get(`${base}/rechnungen?${query}`, admin)).rechnungen.map((r) => r.id)
assert.deepEqual(await ids('kunde=c1'), ['in_draft', 'in_open', 'in_paid'])
assert.deepEqual(await ids('kunde=c2'), ['in_over'])
assert.deepEqual(await ids('kunde=c1&status=bezahlt'), ['in_paid'])
assert.deepEqual(await ids('kunde=c9'), [])
})
test('GET /rechnungen: unbekannter Status oder mehrfache Parameter -> 400', async () => {
seedStripe()
for (const query of ['status=irgendwas', 'status=offen&status=bezahlt', 'kunde=a&kunde=b']) {
const res = await rawGet(`${base}/rechnungen?${query}`, admin)
assert.equal(res.status, 400, query)
assert.equal(typeof res.body.error, 'string')
}
assert.equal(callsTo('GET', '/v1/invoices').length, 0)
})
test('GET /rechnungen: Stripe nicht erreichbar -> 502 mit deutscher Meldung', async () => {
stripeDown()
const res = await rawGet(`${base}/rechnungen`, admin)
assert.equal(res.status, 502)
assert.equal(res.body.error, 'Stripe ist gerade nicht erreichbar.')
assert.ok(!JSON.stringify(res.body).includes('sk_live_geheim'))
})
// ---------- POST /rechnungen ----------
const ITEMS = [
{ description: 'Website-Anpassung', quantity: 2, unitAmountCents: 5000 },
{ description: 'Domain', quantity: 1, unitAmountCents: 1200 },
]
const postInvoice = (body) => post(`${base}/rechnungen`, body, admin)
test('POST /rechnungen: Entwurf mit Standard-Zahlungsziel 14 Tage, createdBy aus dem Admin-Konto', async () => {
const res = await postInvoice({ customerId: 'c1', items: ITEMS, memo: 'Danke' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(Object.keys(res.body), ['rechnung'])
assert.equal(res.body.rechnung.status, 'entwurf')
assert.equal(res.body.rechnung.totalCents, 11200)
assert.equal(res.body.rechnung.webklarCustomerId, 'c1')
const [create] = callsTo('POST', '/v1/invoices')
assert.equal(create.body.days_until_due, '14')
assert.equal(create.body['metadata[erstelltVon]'], 'kenso@webklar.com')
assert.equal(create.body.description, 'Danke')
assert.equal(callsTo('POST', /\/finalize$/).length, 0)
assert.equal(callsTo('POST', /\/send$/).length, 0)
})
test('POST /rechnungen: daysUntilDue wird uebernommen, null zaehlt als fehlend', async () => {
assert.equal((await postInvoice({ customerId: 'c1', items: ITEMS, daysUntilDue: 30 })).status, 200)
assert.equal(callsTo('POST', '/v1/invoices')[0].body.days_until_due, '30')
assert.equal((await postInvoice({ customerId: 'c1', items: ITEMS, daysUntilDue: null })).status, 200)
assert.equal(callsTo('POST', '/v1/invoices')[1].body.days_until_due, '14')
const bad = await postInvoice({ customerId: 'c1', items: ITEMS, daysUntilDue: 61 })
assert.equal(bad.status, 400)
assert.equal(bad.body.error, 'Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.')
})
for (const senden of [true, 'true']) {
test(`POST /rechnungen: senden ${JSON.stringify(senden)} -> festgeschrieben und gesendet (offen)`, async () => {
const res = await postInvoice({ customerId: 'c1', items: ITEMS, senden })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.rechnung.status, 'offen')
assert.match(res.body.rechnung.number, /^WK-\d{4}$/)
assert.equal(callsTo('POST', /\/finalize$/).length, 1)
assert.equal(callsTo('POST', /\/send$/).length, 1)
})
}
for (const senden of [false, 'false', 'ja', 1, undefined, null]) {
test(`POST /rechnungen: senden ${JSON.stringify(senden)} bleibt ein Entwurf`, async () => {
const res = await postInvoice({ customerId: 'c1', items: ITEMS, senden })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.rechnung.status, 'entwurf')
assert.equal(callsTo('POST', /\/send$/).length, 0)
})
}
test('POST /rechnungen: ungueltige Eingaben -> 400 mit Meldung, nichts bei Stripe', async () => {
aw.seed('customers', { $id: 'lead1', email: 'lead@example.invalid', name: 'Interessent', customerStatus: 'lead', status: 'active' })
const cases = [
[{ customerId: 'c1', items: [] }, 'Eine Rechnung braucht 1 bis 20 Positionen.'],
[{ customerId: 'c1' }, 'Eine Rechnung braucht 1 bis 20 Positionen.'],
[{ customerId: 'c1', items: [{ description: 'x', quantity: 0, unitAmountCents: 100 }] }, 'Position 1: Die Menge muss eine ganze Zahl von 1 bis 999 sein.'],
[{ customerId: 'lead1', items: ITEMS }, 'Rechnungen nur für feste Kunden.'],
[{ customerId: 'gibtsnicht', items: ITEMS }, 'Kunde nicht gefunden.'],
[{ items: ITEMS }, 'Kunde nicht gefunden.'],
]
for (const [body, message] of cases) {
const res = await postInvoice(body)
assert.equal(res.status, 400, JSON.stringify(body))
assert.equal(res.body.error, message)
}
assert.equal(callsTo('POST', '/v1/invoices').length, 0)
assert.equal(st.invoices.size, 0)
})
test('POST /rechnungen: ohne Body -> 400, kein Absturz', async () => {
const res = await request('POST', `${base}/rechnungen`, { ...admin })
assert.equal(res.status, 400)
})
test('POST /rechnungen: Senden scheitert nach dem Festschreiben -> 502 mit invoiceId und Rechnungsnummer', async () => {
st.failNext({ method: 'POST', path: /\/send$/, status: 400, message: 'Geheimer Sendetext sk_live_abc' })
const res = await postInvoice({ customerId: 'c1', items: ITEMS, senden: true })
assert.equal(res.status, 502)
assert.match(res.body.invoiceId, /^in_/)
assert.match(res.body.invoiceNumber, /^WK-\d{4}$/)
assert.equal(res.body.error, `Rechnung ${res.body.invoiceNumber} wurde festgeschrieben, aber nicht versendet. Bitte erneut senden.`)
assert.ok(!JSON.stringify(res.body).includes('Geheimer'))
assert.deepEqual(Object.keys(res.body).sort(), ['error', 'invoiceId', 'invoiceNumber'])
// die Rechnung ist offen und laesst sich erneut senden
assert.equal(st.invoices.get(res.body.invoiceId).status, 'open')
const again = await post(`${base}/rechnungen/${res.body.invoiceId}/senden`, {}, admin)
assert.equal(again.status, 200, JSON.stringify(again.body))
assert.equal(again.body.rechnung.status, 'offen')
})
test('POST /rechnungen: idempotencyKey geht als Idempotency-Key an Stripe, ein ungueltiger -> 400 (I3)', async () => {
const res = await postInvoice({ customerId: 'c1', items: ITEMS, idempotencyKey: 'woms-formular-4711' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(callsTo('POST', '/v1/invoices')[0].idempotencyKey, 'woms-formular-4711')
for (const idempotencyKey of ['x'.repeat(101), 'mit Leerzeichen', 7]) {
const bad = await postInvoice({ customerId: 'c1', items: ITEMS, memo: 'anders', idempotencyKey })
assert.equal(bad.status, 400, String(idempotencyKey))
assert.equal(bad.body.error, 'Der Wiederholungsschlüssel ist ungültig (1 bis 100 Zeichen ohne Leerzeichen).')
}
assert.equal(callsTo('POST', '/v1/invoices').length, 1)
})
test('POST /rechnungen: derselbe Inhalt gleich noch einmal -> 409 "wird gerade schon erstellt", keine zweite Rechnung (I3)', async () => {
const body = { customerId: 'c1', items: ITEMS, senden: true }
const results = await Promise.all([postInvoice(body), postInvoice(body)])
assert.deepEqual(results.map((r) => r.status).sort(), [200, 409])
const conflict = results.find((r) => r.status === 409)
assert.deepEqual(conflict.body, { error: 'Diese Rechnung wird gerade schon erstellt.' })
assert.equal(st.invoices.size, 1)
assert.equal(callsTo('POST', /\/send$/).length, 1)
// auch einen Moment spaeter (Nutzer klickt noch einmal), aber nicht bei anderem Inhalt
assert.equal((await postInvoice(body)).status, 409)
assert.equal((await postInvoice({ ...body, memo: 'anderer Vermerk' })).status, 200)
assert.equal(st.invoices.size, 2)
})
test('POST /rechnungen: Rechnung nach dem Anlegen nicht lesbar -> 502 mit invoiceId und Nummer, Ursache im Protokoll (I3, M11)', async () => {
const error = mock.method(console, 'error', () => {})
st.failNext({ method: 'GET', path: /^\/v1\/invoices\/in_/, status: 400, message: 'Geheimer Lesetext sk_live_abc' })
const res = await postInvoice({ customerId: 'c1', items: ITEMS, senden: true })
assert.equal(res.status, 502)
assert.match(res.body.invoiceId, /^in_/)
assert.match(res.body.invoiceNumber, /^WK-\d{4}$/)
assert.equal(res.body.error, `Rechnung ${res.body.invoiceNumber} wurde gesendet, konnte aber nicht gelesen werden. Bitte die Rechnungsliste prüfen.`)
assert.deepEqual(Object.keys(res.body).sort(), ['error', 'invoiceId', 'invoiceNumber'])
assert.ok(!JSON.stringify(res.body).includes('Geheimer'))
assert.equal(st.invoices.get(res.body.invoiceId).status, 'open')
assert.equal(error.mock.callCount(), 1)
assert.deepEqual(error.mock.calls[0].arguments, ['[finanzen] Rechnung anlegen fehlgeschlagen:', 'StripeInvalidRequestError', 400, ''])
})
test('POST /rechnungen: Senden scheitert (502) -> Art, Status und Code der Stripe-Ursache im Protokoll, nie deren Text (M11)', async () => {
const error = mock.method(console, 'error', () => {})
st.failNext({ method: 'POST', path: /\/send$/, status: 400, message: 'Geheimer Sendetext sk_live_abc' })
const res = await postInvoice({ customerId: 'c1', items: ITEMS, senden: true })
assert.equal(res.status, 502)
assert.equal(error.mock.callCount(), 1)
assert.deepEqual(error.mock.calls[0].arguments, ['[finanzen] Rechnung anlegen fehlgeschlagen:', 'StripeInvalidRequestError', 400, ''])
assert.ok(!JSON.stringify(error.mock.calls).includes('Geheimer'))
})
test('POST /rechnungen: eine Anlage ohne Rechnung bei Stripe (400) schreibt keine zweite Ursache ins Protokoll (M11)', async () => {
const error = mock.method(console, 'error', () => {})
assert.equal((await postInvoice({ customerId: 'gibtsnicht', items: ITEMS })).status, 400)
assert.equal(error.mock.callCount(), 0)
})
test('POST /rechnungen: Stripe lehnt ab (4xx) -> 409, ueberspringt Stripe-Text; Stripe nicht erreichbar -> 502', async () => {
st.failNext({ method: 'POST', path: '/v1/invoices', status: 400, message: 'Geheimer Stripe-Text' })
const rejected = await postInvoice({ customerId: 'c1', items: ITEMS })
assert.equal(rejected.status, 409)
assert.equal(rejected.body.error, 'Stripe hat die Änderung abgelehnt.')
assert.ok(!JSON.stringify(rejected.body).includes('Geheimer'))
mock.method(getStripe().invoices, 'create', async () => {
throw new Stripe.errors.StripeConnectionError({ message: 'connect ECONNREFUSED 10.0.0.1:443', type: 'api_connection_error' })
})
const down = await postInvoice({ customerId: 'c1', items: ITEMS })
assert.equal(down.status, 502)
assert.equal(down.body.error, 'Stripe ist gerade nicht erreichbar.')
assert.ok(!JSON.stringify(down.body).includes('10.0.0.1'))
})
test('POST /rechnungen: Appwrite scheitert (Kunde laden) -> 500 ohne Appwrite-Meldung', async () => {
aw.failNext({ collection: 'customers', method: 'GET', status: 500, message: 'Appwrite kaputt 10.0.0.9' })
const res = await postInvoice({ customerId: 'c1', items: ITEMS })
assert.equal(res.status, 500)
assert.equal(res.body.error, 'Interner Fehler.')
assert.ok(!JSON.stringify(res.body).includes('10.0.0.9'))
})
// ---------- senden, stornieren, loeschen ----------
const senden = (id) => post(`${base}/rechnungen/${id}/senden`, {}, admin)
const stornieren = (id) => post(`${base}/rechnungen/${id}/stornieren`, {}, admin)
test('POST /rechnungen/:id/senden: Entwurf -> offen; offene Rechnung wird erneut gesendet', async () => {
const created = await postInvoice({ customerId: 'c1', items: ITEMS })
const id = created.body.rechnung.id
const sent = await senden(id)
assert.equal(sent.status, 200, JSON.stringify(sent.body))
assert.deepEqual(Object.keys(sent.body), ['rechnung'])
assert.equal(sent.body.rechnung.status, 'offen')
assert.equal(callsTo('POST', /\/finalize$/).length, 1)
assert.equal(callsTo('POST', /\/send$/).length, 1)
const again = await senden(id)
assert.equal(again.status, 200)
assert.equal(again.body.rechnung.status, 'offen')
assert.equal(callsTo('POST', /\/finalize$/).length, 1)
assert.equal(callsTo('POST', /\/send$/).length, 2)
})
test('POST /rechnungen/:id/senden: bezahlt -> 409, Abo-Rechnung -> 409, unbekannt oder ungueltige ID -> 404', async () => {
putInvoice({ id: 'in_paid', status: 'paid', amount_paid: 100, amount_remaining: 0 })
putInvoice({ id: 'in_abo', status: 'draft', number: null, subscription: 'sub_a' })
const paid = await senden('in_paid')
assert.equal(paid.status, 409)
assert.equal(paid.body.error, 'Diese Rechnung kann nicht mehr gesendet werden.')
const abo = await senden('in_abo')
assert.equal(abo.status, 409)
assert.equal(abo.body.error, 'Abo-Rechnungen verwaltet Stripe.')
assert.equal((await senden('in_gibtsnicht')).status, 404)
assert.equal((await senden('keine-id')).status, 404)
assert.equal(callsTo('POST', /\/(finalize|send)$/).length, 0)
})
test('POST /rechnungen/:id/senden: Kunde ohne E-Mail-Adresse -> 400', async () => {
putInvoice({ id: 'in_nomail', status: 'draft', number: null, customer_email: null, customer: 'cus_ohne' })
st.customers.set('cus_ohne', { id: 'cus_ohne', object: 'customer', name: 'Ohne', email: null, metadata: {} })
const res = await senden('in_nomail')
assert.equal(res.status, 400)
assert.equal(res.body.error, 'Für den Versand fehlt die E-Mail-Adresse des Kunden.')
})
test('POST /rechnungen/:id/stornieren: offene Rechnung -> storniert; Entwurf und bezahlte -> 409', async () => {
putInvoice({ id: 'in_open' })
putInvoice({ id: 'in_draft', status: 'draft', number: null })
putInvoice({ id: 'in_paid', status: 'paid', amount_paid: 100, amount_remaining: 0 })
const ok = await stornieren('in_open')
assert.equal(ok.status, 200, JSON.stringify(ok.body))
assert.deepEqual(Object.keys(ok.body), ['rechnung'])
assert.equal(ok.body.rechnung.status, 'storniert')
assert.equal(st.invoices.get('in_open').status, 'void')
for (const id of ['in_draft', 'in_paid']) {
const res = await stornieren(id)
assert.equal(res.status, 409, id)
assert.equal(res.body.error, 'Nur offene Rechnungen können storniert werden.')
}
assert.equal((await stornieren('in_gibtsnicht')).status, 404)
})
test('DELETE /rechnungen/:id: Entwurf wird geloescht, offene Rechnung -> 409', async () => {
const created = await postInvoice({ customerId: 'c1', items: ITEMS })
const draftId = created.body.rechnung.id
const ok = await del(`${base}/rechnungen/${draftId}`)
assert.equal(ok.status, 200, JSON.stringify(ok.body))
assert.deepEqual(ok.body, { ok: true })
assert.ok(!st.invoices.has(draftId))
putInvoice({ id: 'in_open' })
const open = await del(`${base}/rechnungen/in_open`)
assert.equal(open.status, 409)
assert.equal(open.body.error, 'Nur Entwürfe können gelöscht werden.')
assert.ok(st.invoices.has('in_open'))
putInvoice({ id: 'in_abo', status: 'draft', number: null, subscription: 'sub_a' })
assert.equal((await del(`${base}/rechnungen/in_abo`)).status, 409)
assert.equal((await del(`${base}/rechnungen/in_gibtsnicht`)).status, 404)
})
test('DELETE /rechnungen/:id: Stripe lehnt ab -> 409 ohne Stripe-Text', async () => {
putInvoice({ id: 'in_draft', status: 'draft', number: null })
st.failNext({ method: 'DELETE', path: '/v1/invoices/in_draft', status: 400, message: 'Geheimer Stripe-Text' })
const res = await del(`${base}/rechnungen/in_draft`)
assert.equal(res.status, 409)
assert.equal(res.body.error, 'Stripe hat die Änderung abgelehnt.')
assert.ok(!JSON.stringify(res.body).includes('Geheimer'))
})
test('Rechnungsaenderungen leeren den Zwischenspeicher: Liste und Uebersicht zeigen den neuen Stand', async () => {
seedConnection()
putInvoice({ id: 'in_open' })
assert.equal((await uebersicht()).rechnungen.altersgruppen[0].anzahl, 1)
assert.equal((await stornieren('in_open')).status, 200)
assert.equal((await uebersicht()).rechnungen.altersgruppen[0].anzahl, 0)
assert.deepEqual((await get(`${base}/rechnungen?status=offen`, admin)).rechnungen, [])
})