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

+104 -29
View File
@@ -3,8 +3,9 @@ import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
import { withLocks } from '../../services/keyedLock.js'
import { EnableBankingError } from '../../services/finance/enableBanking.js'
import { completeBankAuth, startBankAuth, syncBank } from '../../services/finance/bankSync.js'
import { getConnection, listTransactions, saveConnection } from '../../services/finance/bankStore.js'
import { getConnection, listTransactions, publicConnection, saveConnection } from '../../services/finance/bankStore.js'
import { AssignError, assignTransaction, unassignTransaction, withSuggestions } from '../../services/finance/assign.js'
import { buildOverview } from '../../services/finance/overview.js'
import {
SettingsError,
createFixedCost,
@@ -14,11 +15,20 @@ import {
patchSettings,
updateFixedCost,
} from '../../services/finance/settings.js'
import {
InvoiceError,
STRIPE_UNAVAILABLE_MESSAGE,
createProjectInvoice,
deleteDraft,
listInvoices,
sendInvoiceNow,
voidInvoice,
} from '../../services/finance/stripeFinance.js'
/**
* Finanzen im WOMS (nur Admins): Bankstatus, Abruf, Neuverbindung, Buchungen und deren Zuordnung, Einstellungen und
* Fixkosten. Die Handler sind klein und rufen die Dienste in server/services/finance auf; weitere Finanz-Routen kommen
* in dieselbe Datei.
* Finanzen im WOMS (nur Admins): Übersicht, Bankstatus, Abruf, Neuverbindung, Buchungen und deren Zuordnung,
* Rechnungen (lesen, anlegen, senden, stornieren, Entwurf löschen), Einstellungen und Fixkosten. Die Handler sind klein
* und rufen die Dienste in server/services/finance auf.
*/
const router = Router()
@@ -32,29 +42,21 @@ const MANUAL_SYNC_LOCK = ['finanzen-bank-manual-sync']
// (z. B. 401) und alles, was Sitzungen verraten koennte, bleibt auf dem Server
const BANK_UNAVAILABLE_MESSAGE = 'Die Bank hat die Freigabe nicht bestätigt. Bitte erneut verbinden.'
/** Was die Seite vom Bankzugang sieht: nie Sitzung, IBAN, state oder Konto-Kennungen. */
function publicConnection(connection) {
const pick = (key) => connection[key] ?? null
return {
status: pick('status'),
ibanMasked: pick('ibanMasked'),
validUntil: pick('validUntil'),
lastSyncAt: pick('lastSyncAt'),
lastSyncOk: pick('lastSyncOk'),
lastError: pick('lastError'),
balanceBookedCents: pick('balanceBookedCents'),
balanceAvailableCents: pick('balanceAvailableCents'),
balanceDate: pick('balanceDate'),
latestBookingDate: pick('latestBookingDate'),
}
}
/** Dokument ohne Appwrite-Metafelder ($createdAt, $permissions, ...); nur $id bleibt. */
const publicDoc = (doc) => Object.fromEntries(Object.entries(doc).filter(([key]) => key === '$id' || !key.startsWith('$')))
/** Fehlerart fuers Protokoll: nie die Meldung (sie koennte Kennungen der Bank oder von Appwrite enthalten). */
const logFailure = (what, err) => console.error(`[finanzen] ${what} fehlgeschlagen:`, err?.name || 'Error', err?.status ?? '', err?.code ?? '')
router.get('/uebersicht', async (_req, res) => {
try {
return res.json(await buildOverview())
} catch (err) {
logFailure('Übersicht laden', err)
return res.status(500).json({ error: 'Die Übersicht konnte nicht geladen werden.' })
}
})
router.get('/bank', async (_req, res) => {
try {
const connection = await getConnection()
@@ -133,18 +135,24 @@ router.get('/buchungen', async (_req, res) => {
})
const STRIPE_REJECTED_MESSAGE = 'Stripe hat die Änderung abgelehnt.'
const STRIPE_UNAVAILABLE_MESSAGE = 'Stripe ist gerade nicht erreichbar.'
// Fehler des Stripe-Pakets (StripeInvalidRequestError, StripeConnectionError, ...) tragen type "Stripe..."
const isStripeError = (err) => typeof err?.type === 'string' && err.type.startsWith('Stripe')
/**
* Antwort auf einen Fehler beim Zuordnen: AssignError (eigene, anzeigbare Meldung) mit seinem Status; Stripe lehnt
* ab (4xx, ausser 429) -> 409; Stripe sonst nicht erreichbar -> 502; alles andere 500. Meldungen von Stripe, Appwrite
* und Netzfehlern (Adressen, Schluessel) gehen nie an die Seite.
* Antwort auf einen Fehler beim Zuordnen und bei den Rechnungen: AssignError und InvoiceError (eigene, anzeigbare
* Meldung) mit ihrem Status, bei einer festgeschriebenen, aber nicht gesendeten Rechnung (502) samt invoiceId und
* invoiceNumber; Stripe lehnt ab (4xx, ausser 429) -> 409; Stripe sonst nicht erreichbar -> 502; alles andere 500.
* Meldungen von Stripe, Appwrite und Netzfehlern (Adressen, Schluessel) gehen nie an die Seite, auch wenn ihre
* Fehler einen status tragen: nur die beiden eigenen Fehlerarten werden weitergegeben.
*/
function sendAssignError(res, what, err) {
if (err instanceof AssignError) return res.status(err.status).json({ error: err.message })
function sendFinanceError(res, what, err) {
if (err instanceof AssignError || err instanceof InvoiceError) {
const body = { error: err.message }
if (err.invoiceId) body.invoiceId = err.invoiceId
if (err.invoiceNumber !== undefined) body.invoiceNumber = err.invoiceNumber
return res.status(err.status).json(body)
}
logFailure(what, err)
if (isStripeError(err)) {
const status = err.statusCode
@@ -160,7 +168,7 @@ router.post('/buchungen/:id/zuordnen', async (req, res) => {
const buchung = await assignTransaction(req.params.id, { invoiceId, payoutId, fixedCostId, category, note })
return res.json({ buchung: publicDoc(buchung) })
} catch (err) {
return sendAssignError(res, 'Buchung zuordnen', err)
return sendFinanceError(res, 'Buchung zuordnen', err)
}
})
@@ -168,7 +176,74 @@ router.delete('/buchungen/:id/zuordnung', async (req, res) => {
try {
return res.json({ buchung: publicDoc(await unassignTransaction(req.params.id)) })
} catch (err) {
return sendAssignError(res, 'Zuordnung lösen', err)
return sendFinanceError(res, 'Zuordnung lösen', err)
}
})
// --- Rechnungen (Stripe) ----------------------------------------------------------------------------------------
const INVOICE_FILTERS = new Set(['alle', 'offen', 'ueberfaellig', 'bezahlt', 'entwurf'])
const DEFAULT_DAYS_UNTIL_DUE = 14
const createdOf = (invoice) => invoice.createdDate ?? ''
router.get('/rechnungen', async (req, res) => {
const status = req.query.status || 'alle'
const customer = req.query.kunde || ''
if (!INVOICE_FILTERS.has(status)) {
return res.status(400).json({ error: 'Unbekannter Filter (erlaubt: alle, offen, ueberfaellig, bezahlt, entwurf).' })
}
if (typeof customer !== 'string') return res.status(400).json({ error: 'Ungültiger Kunde.' })
try {
const invoices = (await listInvoices())
.filter((invoice) => (status === 'alle' || invoice.status === status) && (!customer || invoice.webklarCustomerId === customer))
// neueste zuerst (der Sortieralgorithmus ist stabil: am selben Tag bleibt Stripes Reihenfolge)
.sort((a, b) => (createdOf(a) < createdOf(b) ? 1 : createdOf(a) > createdOf(b) ? -1 : 0))
return res.json({ rechnungen: invoices })
} catch (err) {
return sendFinanceError(res, 'Rechnungen laden', err)
}
})
router.post('/rechnungen', async (req, res) => {
const { customerId, items, daysUntilDue, memo, senden } = req.body || {}
try {
const rechnung = await createProjectInvoice({
customerId,
items,
daysUntilDue: daysUntilDue ?? DEFAULT_DAYS_UNTIL_DUE,
memo,
// Nur ein echtes Ja sendet sofort: alles andere bleibt ein Entwurf
senden: senden === true || senden === 'true',
createdBy: req.appwriteUser?.email,
})
return res.json({ rechnung })
} catch (err) {
return sendFinanceError(res, 'Rechnung anlegen', err)
}
})
router.post('/rechnungen/:id/senden', async (req, res) => {
try {
return res.json({ rechnung: await sendInvoiceNow(req.params.id) })
} catch (err) {
return sendFinanceError(res, 'Rechnung senden', err)
}
})
router.post('/rechnungen/:id/stornieren', async (req, res) => {
try {
return res.json({ rechnung: await voidInvoice(req.params.id) })
} catch (err) {
return sendFinanceError(res, 'Rechnung stornieren', err)
}
})
router.delete('/rechnungen/:id', async (req, res) => {
try {
await deleteDraft(req.params.id)
return res.json({ ok: true })
} catch (err) {
return sendFinanceError(res, 'Entwurf löschen', err)
}
})
+17
View File
@@ -59,6 +59,23 @@ async function upsert(collectionId, id, patch, createData) {
return { how: 'aktualisiert', doc: await updateDocument(collectionId, id, patch) }
}
/** Was die Seite vom Bankzugang sieht (GET /bank und die Statuszeile der Übersicht): nie Sitzung, IBAN, state oder Konto-Kennungen. */
export function publicConnection(connection) {
const pick = (key) => connection[key] ?? null
return {
status: pick('status'),
ibanMasked: pick('ibanMasked'),
validUntil: pick('validUntil'),
lastSyncAt: pick('lastSyncAt'),
lastSyncOk: pick('lastSyncOk'),
lastError: pick('lastError'),
balanceBookedCents: pick('balanceBookedCents'),
balanceAvailableCents: pick('balanceAvailableCents'),
balanceDate: pick('balanceDate'),
latestBookingDate: pick('latestBookingDate'),
}
}
/** Der Bankzugang oder null, wenn es noch keinen gibt. */
export async function getConnection() {
try {
+222
View File
@@ -0,0 +1,222 @@
import { config } from '../../config.js'
import { getDocument, listDocuments, Query } from '../appwriteAdmin.js'
import { withSuggestions } from './assign.js'
import { getConnection, listTransactions, publicConnection } from './bankStore.js'
import { berlinDate, daysBetween } from './dates.js'
import { buildForecast } from './forecast.js'
import { detectRecurring } from './recurring.js'
import { getSettings, listFixedCosts } from './settings.js'
import { listInvoices, listPayouts, STRIPE_UNAVAILABLE_MESSAGE } from './stripeFinance.js'
import { computeTax } from './tax.js'
import { buildTodos } from './todos.js'
/**
* Alles für die Seite /finance in einer Antwort (GET /uebersicht): Statuszeile, Liquiditätskopf, Aufgaben, Vorschau,
* Steuer, Abos und Altersgruppen der Rechnungen. Die Rechnen-Module (forecast, tax, todos, recurring) sind rein; hier
* werden nur die Daten geladen (Bank und Einstellungen aus Appwrite, Rechnungen und Auszahlungen von Stripe) und
* zusammengesetzt. Scheitert Stripe, rechnet die Übersicht mit leeren Rechnungs- und Auszahlungslisten weiter und
* meldet das in stand.stripe; Appwrite-Fehler gehen als Fehler an den Aufrufer.
*/
const SUBSCRIPTION_LIMIT = 500
// Abos in der Liste: laufende (auch in der Testphase) und solche mit Zahlungsproblem; beendete fehlen
const LISTED_STATUSES = new Set(['active', 'trialing', 'past_due', 'unpaid'])
const NAME_BATCH = 10 // Kundennamen: höchstens so viele Anfragen an Appwrite gleichzeitig
const AGE_GROUPS = ['nicht-faellig', '1-30', '31-60', 'ueber-60']
const isCents = (n) => Number.isSafeInteger(n)
const trimmed = (value) => (typeof value === 'string' ? value.trim() : '')
/** Fehlerart fürs Protokoll: nie die Meldung (sie könnte Adressen oder Schlüssel enthalten). */
const logFailure = (what, err) => console.error(`[finanzen] ${what} fehlgeschlagen:`, err?.name || 'Error', err?.statusCode ?? err?.status ?? '', err?.code ?? '')
/** Zeitstempel (ISO-Text) als Berliner Kalendertag, null bei fehlendem oder unlesbarem Wert. */
function berlinDayOf(iso) {
if (!iso) return null
const date = new Date(iso)
return Number.isNaN(date.getTime()) ? null : berlinDate(date)
}
// --- Stripe -------------------------------------------------------------------------------------------------
/** Rechnungen und Auszahlungen; was nicht kommt, ist eine leere Liste und macht ok false. */
async function loadStripe(now) {
const [invoices, payouts] = await Promise.allSettled([listInvoices(), listPayouts()])
if (invoices.status === 'rejected') logFailure('Rechnungen laden', invoices.reason)
if (payouts.status === 'rejected') logFailure('Auszahlungen laden', payouts.reason)
const ok = invoices.status === 'fulfilled' && payouts.status === 'fulfilled'
return {
ok,
invoices: invoices.status === 'fulfilled' ? invoices.value : [],
payouts: payouts.status === 'fulfilled' ? payouts.value : [],
// abgerufenUm: wann der Stand geholt wurde; bei einem Fehler gibt es keinen
status: ok ? { ok: true, abgerufenUm: now.toISOString() } : { ok: false, fehler: STRIPE_UNAVAILABLE_MESSAGE, abgerufenUm: null },
}
}
// --- Abos ---------------------------------------------------------------------------------------------------
/** Kundennamen (Firmenname vor Name) zu den Kunden-IDs; ein fehlender oder nicht lesbarer Kunde bekommt den Namen "". */
async function customerNames(ids) {
const names = new Map()
for (let i = 0; i < ids.length; i += NAME_BATCH) {
await Promise.all(
ids.slice(i, i + NAME_BATCH).map(async (id) => {
try {
const customer = await getDocument(config.collections.customers, id)
names.set(id, trimmed(customer.companyName) || trimmed(customer.name))
} catch (err) {
if (err?.status !== 404) logFailure('Kundenname laden', err)
names.set(id, '')
}
})
)
}
return names
}
/**
* Tag der nächsten Abbuchung (Berliner Tag) oder null: Periodenende eines laufenden Abos, solange keine Kündigung
* davor greift (wie in forecast.js: ein gesetztes cancelAt gilt allein; cancelAtPeriodEnd ohne cancelAt heißt, dass
* zum Periodenende nichts mehr abgebucht wird). Bei Zahlungsproblemen steht kein Termin fest.
*/
function nextCharge(subscription) {
if (subscription.status !== 'active' && subscription.status !== 'trialing') return null
const periodEnd = berlinDayOf(subscription.currentPeriodEnd)
if (!periodEnd) return null
const cancelAt = berlinDayOf(subscription.cancelAt)
if (!cancelAt && subscription.cancelAtPeriodEnd) return null
if (cancelAt && periodEnd >= cancelAt) return null
return periodEnd
}
const RISK_ORDER = { past_due: 0, unpaid: 0 }
function buildSubscriptions(subscriptions) {
const amount = (subscription) => (isCents(subscription.priceAmount) ? subscription.priceAmount : 0)
const sumOf = (statuses) => subscriptions.filter((s) => statuses.includes(s.status)).reduce((sum, s) => sum + amount(s), 0)
const payingCustomers = new Set(subscriptions.filter((s) => s.status === 'active' && s.customerId).map((s) => s.customerId))
const liste = subscriptions
.map((s) => ({
customerId: s.customerId ?? '',
kunde: s.customerName,
produkt: s.productKey ?? s.planKey ?? '',
status: s.status,
betragCents: amount(s),
naechsteAbbuchung: nextCharge(s),
}))
// Zahlungsprobleme zuerst, dann nach Kunde (Reihenfolge der Eingabe bei Gleichstand)
.sort((a, b) => (RISK_ORDER[a.status] ?? 1) - (RISK_ORDER[b.status] ?? 1) || a.kunde.localeCompare(b.kunde, 'de', { sensitivity: 'base' }))
return {
monatlichCents: sumOf(['active', 'past_due']),
zahlendeKunden: payingCustomers.size,
gefaehrdetCents: sumOf(['past_due']),
liste,
}
}
// --- Rechnungen ---------------------------------------------------------------------------------------------
/** Tage im Verzug zum Stichtag (0 = nicht fällig); ohne lesbares Fälligkeitsdatum gilt der Wert aus dem Mapping. */
function daysLate(invoice, today) {
try {
return Math.max(0, daysBetween(invoice.dueDate, today))
} catch {
return invoice.status === 'ueberfaellig' && isCents(invoice.overdueDays) ? invoice.overdueDays : 0
}
}
const ageGroupOf = (late) => (late <= 0 ? 'nicht-faellig' : late <= 30 ? '1-30' : late <= 60 ? '31-60' : 'ueber-60')
function buildInvoiceSummary(invoices, today) {
const groups = Object.fromEntries(AGE_GROUPS.map((key) => [key, { key, anzahl: 0, summeCents: 0 }]))
const drafts = { anzahl: 0, summeCents: 0 }
for (const invoice of invoices) {
if (invoice.status === 'entwurf') {
drafts.anzahl += 1
if (isCents(invoice.totalCents)) drafts.summeCents += invoice.totalCents
} else if ((invoice.status === 'offen' || invoice.status === 'ueberfaellig') && isCents(invoice.remainingCents)) {
const group = groups[ageGroupOf(daysLate(invoice, today))]
group.anzahl += 1
group.summeCents += invoice.remainingCents
}
}
return { altersgruppen: AGE_GROUPS.map((key) => groups[key]), entwuerfe: drafts }
}
// --- Übersicht ----------------------------------------------------------------------------------------------
const isOpenIncoming = (tx) => tx.status === 'BOOK' && isCents(tx.amountCents) && tx.amountCents > 0 && !tx.category
/**
* @param {object} [args]
* @param {Date} [args.now] Zeitpunkt; "heute" ist der Berliner Tag dazu
* @returns {Promise<{ stand, kopf, aufgaben, vorschau, steuer, abos, rechnungen }>}
*/
export async function buildOverview({ now = new Date() } = {}) {
const today = berlinDate(now)
const [connection, transactions, settings, fixedCosts, subscriptions, stripe] = await Promise.all([
getConnection(),
listTransactions(),
getSettings(),
listFixedCosts(),
listDocuments(config.collections.subscriptions, [Query.limit(SUBSCRIPTION_LIMIT)]),
loadStripe(now),
])
// Kundennamen nur für die Abos der Liste
const listed = subscriptions.filter((s) => LISTED_STATUSES.has(s.status))
const names = await customerNames([...new Set(listed.map((s) => s.customerId).filter((id) => typeof id === 'string' && id))])
const named = listed.map((s) => ({ ...s, customerName: names.get(s.customerId) ?? '' }))
// Kontostand (gebucht) laut letztem Abruf; ohne Zugang oder ohne Abruf unbekannt (null)
const balance = connection && isCents(connection.balanceBookedCents) ? connection.balanceBookedCents : null
const minBuffer = isCents(settings.minBufferCents) ? settings.minBufferCents : 0
const forecast = buildForecast({
today,
balanceCents: balance ?? 0,
payouts: stripe.payouts,
invoices: stripe.invoices,
subscriptions,
fixedCosts,
settings,
})
const tax = computeTax({ today, transactions, invoices: stripe.invoices, settings })
// Zuordnungsvorschläge wie bei GET /buchungen, nur für gebuchte, offene Eingänge; ohne vollständige Stripe-Daten keine
const openIncoming = transactions.filter(isOpenIncoming)
const todoTransactions = stripe.ok && openIncoming.length ? (await withSuggestions(openIncoming)).buchungen : openIncoming
const aufgaben = buildTodos({
today,
now,
connection,
invoices: stripe.invoices,
transactions: todoTransactions,
subscriptions: named,
recurring: detectRecurring(transactions, { fixedCosts }),
tax,
})
return {
stand: { bank: connection ? publicConnection(connection) : null, stripe: stripe.status },
kopf: {
kontostandCents: balance,
verfuegbarCents: balance === null ? null : balance - tax.ruecklageCents - forecast.fixkosten30Cents - minBuffer,
aufschluesselung: {
kontostandCents: balance,
steuerRuecklageCents: tax.ruecklageCents,
fixkosten30Cents: forecast.fixkosten30Cents,
mindestpufferCents: minBuffer,
},
// ohne Kontostand wäre der tiefste Stand nur die Summe der Posten, nicht der Stand des Kontos
tiefsterSicher: balance === null ? null : forecast.minSicher,
tiefsterErwartet: balance === null ? null : forecast.minErwartet,
},
aufgaben,
vorschau: forecast,
steuer: tax,
abos: buildSubscriptions(named),
rechnungen: buildInvoiceSummary(stripe.invoices, today),
}
}
+15 -4
View File
@@ -18,6 +18,9 @@ const CACHE_MS = 60_000
const LOOKBACK_DAYS = 400
const MAX_INVOICES = 1000
/** Meldung an die Seite, wenn Stripe nicht antwortet (Übersicht, Rechnungsliste, Schreibzugriffe). */
export const STRIPE_UNAVAILABLE_MESSAGE = 'Stripe ist gerade nicht erreichbar.'
// Stripe-Status -> Status nach aussen; "open" wird je nach Faelligkeit zu "offen" oder "ueberfaellig"
const STATUS_DE = { draft: 'entwurf', open: 'offen', paid: 'bezahlt', void: 'storniert', uncollectible: 'uneinbringlich' }
@@ -118,12 +121,20 @@ const PAYMENT_REFERENCE_NOTE = 'Bitte geben Sie bei Überweisung die Rechnungsnu
const ABO_MESSAGE = 'Abo-Rechnungen verwaltet Stripe.'
const NO_EMAIL_MESSAGE = 'Für den Versand fehlt die E-Mail-Adresse des Kunden.'
function httpError(status, message) {
const err = new Error(message)
err.status = status
return err
/**
* Fehler dieses Moduls mit HTTP-Status und einer Meldung, die die Seite anzeigen darf (nur diese gibt die Route
* weiter; Stripe- und Appwrite-Fehler tragen auch status/statusCode, ihre Meldungen bleiben auf dem Server).
*/
export class InvoiceError extends Error {
constructor(status, message) {
super(message)
this.name = 'InvoiceError'
this.status = status
}
}
const httpError = (status, message) => new InvoiceError(status, message)
/** Rechnungsfuss: Kleinunternehmer-Hinweis, Bankdaten (falls gesetzt), Hinweis auf den Verwendungszweck. */
export function invoiceFooter(bankDetailsText = '') {
const bank = typeof bankDetailsText === 'string' ? bankDetailsText.trim() : ''
+1 -1
View File
@@ -273,7 +273,7 @@ function smallBusinessTodos({ today, tax }) {
'kleinunternehmer',
HOCH,
'Kleinunternehmer-Grenze überschritten',
`Die Einnahmen ${year} betragen ${euroDe(revenue)} und liegen über der Grenze von ${euroDe(hardLimit)}. Die Kleinunternehmerregelung (§ 19 UStG) ist davon sofort betroffen. Bitte umgehend mit der Steuerberatung klären.`,
`Die Einnahmen ${year} betragen ${euroDe(revenue)} und liegen über der Grenze von ${euroDe(hardLimit)}. 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.`,
today,
{ typ: 'einstellungen' }
),
+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', () => {