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; scheitert das Laden der Abos, meldet das abos.fehler. Andere 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 ------------------------------------------------------------------------------------------------- // So lange wartet die Uebersicht hoechstens auf Stripe (die Anfragen selbst haben eigene Zeitgrenzen, siehe stripeFinance) const STRIPE_WAIT_MS = 20_000 const failedStatus = () => ({ ok: false, fehler: STRIPE_UNAVAILABLE_MESSAGE, abgerufenUm: null }) /** * Rechnungen und Auszahlungen; was nicht kommt, ist eine leere Liste und macht ok false. Antwortet Stripe nicht binnen * waitMs, rechnet die Uebersicht ohne Stripe weiter (wie bei einem Fehler); die Anfragen laufen im Hintergrund aus. */ async function loadStripe(now, waitMs) { let timer const timedOut = new Promise((resolve) => { timer = setTimeout(resolve, waitMs, null) timer.unref?.() }) const settled = await Promise.race([Promise.allSettled([listInvoices(), listPayouts()]), timedOut]) clearTimeout(timer) if (!settled) { console.error(`[finanzen] Stripe laden fehlgeschlagen: keine Antwort nach ${waitMs} ms`) return { ok: false, invoices: [], payouts: [], status: failedStatus() } } const [invoices, payouts] = settled 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() } : failedStatus(), } } // --- Abos --------------------------------------------------------------------------------------------------- // Abos aus Appwrite: scheitert das Laden, rechnet die Uebersicht ohne Abos weiter und die Seite zeigt "unbekannt" const SUBSCRIPTIONS_FAILED = Object.freeze({ monatlichCents: null, zahlendeKunden: null, gefaehrdetCents: null, liste: Object.freeze([]), fehler: 'Abos konnten nicht geladen werden.', }) async function loadSubscriptions() { try { return { ok: true, docs: await listDocuments(config.collections.subscriptions, [Query.limit(SUBSCRIPTION_LIMIT)]) } } catch (err) { logFailure('Abos laden', err) return { ok: false, docs: [] } } } /** 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 * @param {number} [args.stripeWaitMs] so lange wartet die Uebersicht hoechstens auf Stripe (Voreinstellung 20 s) * @returns {Promise<{ stand, kopf, aufgaben, vorschau, steuer, abos, rechnungen }>} */ export async function buildOverview({ now = new Date(), stripeWaitMs = STRIPE_WAIT_MS } = {}) { const today = berlinDate(now) const [storedConnection, transactions, settings, fixedCosts, loadedSubscriptions, stripe] = await Promise.all([ getConnection(), listTransactions(), getSettings(), listFixedCosts(), loadSubscriptions(), loadStripe(now, stripeWaitMs), ]) // Ein Dokument ohne Sitzung (erste Freigabe begonnen, nie abgeschlossen) ist kein Bankzugang const connection = trimmed(storedConnection?.sessionId) ? storedConnection : null const subscriptions = loadedSubscriptions.docs // 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 computedTax = computeTax({ today, transactions, invoices: stripe.invoices, settings }) // Der Kleinunternehmer-Zaehler stuetzt sich auf die Zahltage der Stripe-Rechnungen: ohne Stripe waere er zu niedrig und // wuerde Sicherheit vortaeuschen (die Seite zeigt "unbekannt"), eine Warnung daraus waere falsch const tax = stripe.ok ? computedTax : { ...computedTax, kleinunternehmer: { ...computedTax.kleinunternehmer, jahrCents: null, anteilFolgejahr: null } } // 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, // Was schon zugeordnet ist (privat, Fixkosten), ist kein Vorschlag mehr recurring: detectRecurring( transactions.filter((tx) => tx.category !== 'privat' && tx.category !== 'fixkosten'), { fixedCosts } ), tax, settings, }) 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: loadedSubscriptions.ok ? buildSubscriptions(named) : SUBSCRIPTIONS_FAILED, rechnungen: buildInvoiceSummary(stripe.invoices, today), } }