diff --git a/server/routes/admin/finanzen.js b/server/routes/admin/finanzen.js index 4a4d352..30994a8 100644 --- a/server/routes/admin/finanzen.js +++ b/server/routes/admin/finanzen.js @@ -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) } }) diff --git a/server/services/finance/bankStore.js b/server/services/finance/bankStore.js index 883666c..25b104f 100644 --- a/server/services/finance/bankStore.js +++ b/server/services/finance/bankStore.js @@ -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 { diff --git a/server/services/finance/overview.js b/server/services/finance/overview.js new file mode 100644 index 0000000..7f2a8f6 --- /dev/null +++ b/server/services/finance/overview.js @@ -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), + } +} diff --git a/server/services/finance/stripeFinance.js b/server/services/finance/stripeFinance.js index f99dcf2..d1269a1 100644 --- a/server/services/finance/stripeFinance.js +++ b/server/services/finance/stripeFinance.js @@ -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() : '' diff --git a/server/services/finance/todos.js b/server/services/finance/todos.js index c807889..a502426 100644 --- a/server/services/finance/todos.js +++ b/server/services/finance/todos.js @@ -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' } ), diff --git a/test/financeOverview.test.js b/test/financeOverview.test.js new file mode 100644 index 0000000..e350b19 --- /dev/null +++ b/test/financeOverview.test.js @@ -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, []) +}) diff --git a/test/financeTodos.test.js b/test/financeTodos.test.js index 418d28a..2db97ec 100644 --- a/test/financeTodos.test.js +++ b/test/financeTodos.test.js @@ -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', () => {