Files
Webklar-Kundenbereich/server/services/finance/overview.js
T
ClaudeandClaude Sonnet 5.5 daa451094a Finanzen: Zeitgrenzen fuer Stripe-Lesen, Schutz vor doppelt angelegten Rechnungen
I2: Leseanfragen an Stripe mit timeout 15 s und einer Wiederholung (auch Folgeseiten), die Uebersicht
wartet hoechstens 20 s auf Stripe und rechnet sonst ohne Stripe weiter.
I3: idempotencyKey vom Aufrufer an stripe.invoices.create, Schutz im Prozess gegen gleichzeitiges und
30 s spaeteres Doppelanlegen (409), 502 mit invoiceId/Nummer wenn die fertige Rechnung nicht lesbar ist.
M9: Warnung, wenn die Rechnungsliste bei 1000 abgeschnitten wird.
M11: Ursache eines 502 an einer bestehenden Rechnung (Art, Status, Code) im Protokoll.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00

243 lines
11 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; 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 ---------------------------------------------------------------------------------------------------
/** 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 [connection, transactions, settings, fixedCosts, subscriptions, stripe] = await Promise.all([
getConnection(),
listTransactions(),
getSettings(),
listFixedCosts(),
listDocuments(config.collections.subscriptions, [Query.limit(SUBSCRIPTION_LIMIT)]),
loadStripe(now, stripeWaitMs),
])
// 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),
}
}