Finanzen: Uebersicht und Rechnungsrouten fuer WOMS

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 15:42:06 +00:00
1 parent 7dbf2ce75f
commit 3c5e06723f
7 files changed
+1194 -34

No files matched your search

+829
View File
@@ -0,0 +1,829 @@
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 } = 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()
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'])
assert.equal(body.steuer.kleinunternehmer.jahrCents, 0)
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('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: 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, [])
})
+6
View File
@@ -350,6 +350,12 @@ test('kleinunternehmer: über 100.000 Euro nennt die harte Grenze', () => {
assert.equal(t.dringlichkeit, 'hoch')
assert.ok(t.text.includes(euroDe(10000000)))
assert.ok(t.text.includes(euroDe(10500000)))
assert.equal(
t.text,
`Die Einnahmen 2026 betragen ${euroDe(10500000)} und liegen über der Grenze von ${euroDe(10000000)}. ` +
'Ab dem Umsatz, mit dem die Grenze überschritten wurde, gilt die Kleinunternehmerregelung (§ 19 UStG) nicht mehr – auch im laufenden Jahr. ' +
'Bitte umgehend mit der Steuerberatung klären.'
)
})
test('kleinunternehmer: ohne tax oder mit unlesbarem Anteil keine Aufgabe', () => {