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' }
),