M6: fixkosten30Cents zaehlt nur Fixkosten, die ESt-Vorauszahlung bleibt sicherer Posten. M7: verspaetete Auszahlung (Ankunftstag vorbei, noch nicht bezahlt) kommt heute. M8: ohne Stripe sind Kleinunternehmer-Zaehler und Anteil unbekannt (null), keine Warnung daraus. M12: Verbindungsdokument ohne Sitzung zaehlt als keine Verbindung. M13: Buchungen privat/fixkosten gehen nicht in die Fixkosten-Vorschlaege. M14: Vorjahresumsatz ueber 25.000 EUR ist eine hohe Aufgabe. M15: scheitert das Laden der Abos, meldet abos.fehler statt die ganze Uebersicht zu kippen. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
273 lines
12 KiB
JavaScript
273 lines
12 KiB
JavaScript
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),
|
|
}
|
|
}
|