/** * Aufgabenliste "Zu erledigen" der Finanzen (rein, ohne I/O). Jede Aufgabe gehört zu einer festen Art * (Spec "Zu erledigen – Arten"), hat eine Dringlichkeit (hoch, mittel, info), einen deutschen Titel und Text, * ein Datum (YYYY-MM-DD, wonach innerhalb der Dringlichkeit sortiert wird) und eine Aktion für die Oberfläche. * Beträge sind ganze Cent; "heute" (Berliner Tag) und "jetzt" kommen von außen. Kaputte Datensätze fallen weg, * statt die ganze Liste zu kippen. */ import { addDays, berlinDate, daysBetween, formatDe } from './dates.js' import { euroDe } from './format.js' const HOCH = 'hoch' const MITTEL = 'mittel' const INFO = 'info' const RANK = { [HOCH]: 0, [MITTEL]: 1, [INFO]: 2 } const BANK_WARN_DAYS = 30 // Zugang läuft in <= 30 Tagen ab: mittel const BANK_URGENT_DAYS = 14 // ... in <= 14 Tagen: hoch const SYNC_MAX_AGE_MS = 24 * 3_600_000 const OVERDUE_URGENT_DAYS = 30 // ab 30 Tagen Verzug: hoch const DRAFT_MAX_AGE_DAYS = 3 // Entwurf älter als 3 Tage const RECURRING_MAX_AGE_DAYS = 60 // Vorschläge nur für Ausgaben, die zuletzt in den letzten 60 Tagen liefen const KLEIN_WARN_SHARE = 0.7 // Wie in tax.js; gelten nur, wenn das tax-Objekt die Grenzen nicht mitgibt const KLEIN_LIMIT_CENTS = 2_500_000 const KLEIN_HARD_LIMIT_CENTS = 10_000_000 const MAX_REMITTANCE = 80 const INTERVAL_TEXT = { monat: 'monatlich', quartal: 'vierteljährlich', jahr: 'jährlich' } const isCents = (n) => Number.isSafeInteger(n) /** Wert, der ein echter Kalendertag sein muss, sonst null. */ function validYmd(ymd) { try { daysBetween(ymd, ymd) return ymd } catch { return null } } /** Zeitstempel (ISO-Text) als Date, null bei fehlendem oder unlesbarem Wert. */ function parseInstant(iso) { if (!iso || typeof iso !== 'string') return null const date = new Date(iso) return Number.isNaN(date.getTime()) ? null : date } const trimmed = (value) => (typeof value === 'string' ? value.trim() : '') /** "1 Tag" / "5 Tage" (Nominativ) bzw. "1 Tag" / "5 Tagen" (Dativ). */ const days = (n, dative = false) => `${n} ${n === 1 ? 'Tag' : dative ? 'Tagen' : 'Tage'}` const clip = (text, max) => (text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text) function todo(art, dringlichkeit, titel, text, datum, aktion) { return { art, dringlichkeit, titel, text, datum, aktion } } // --- Bank --------------------------------------------------------------------------------------------------- function bankTodos({ today, now, connection }) { const result = [] // Ein Dokument ohne Sitzung (erste Freigabe begonnen, nie abgeschlossen) ist kein Bankzugang if (!connection || !trimmed(connection.sessionId)) { result.push( todo( 'bank-zugang', HOCH, 'Bankkonto verbinden', 'Es ist noch kein Bankkonto verbunden. Verbinden Sie das Konto, damit Zahlungseingänge abgerufen werden.', today, { typ: 'bank-verbinden' } ) ) return result } const validUntilDate = parseInstant(connection.validUntil) const validUntil = validUntilDate ? berlinDate(validUntilDate) : null const daysLeft = validUntil ? daysBetween(today, validUntil) : null const expired = connection.status === 'abgelaufen' || (daysLeft !== null && daysLeft < 0) if (expired) { const when = validUntil && validUntil < today ? `am ${formatDe(validUntil)} abgelaufen` : 'abgelaufen oder widerrufen worden' result.push( todo( 'bank-zugang', HOCH, 'Bankzugang abgelaufen', `Der Zugang zum Bankkonto ist ${when}. Verbinden Sie das Konto neu, damit wieder Buchungen abgerufen werden.`, validUntil && validUntil < today ? validUntil : today, { typ: 'bank-verbinden' } ) ) } else if (daysLeft !== null && daysLeft <= BANK_WARN_DAYS) { const phrase = daysLeft === 0 ? 'heute' : daysLeft === 1 ? 'morgen' : `in ${days(daysLeft, true)}` result.push( todo( 'bank-zugang', daysLeft <= BANK_URGENT_DAYS ? HOCH : MITTEL, 'Bankzugang läuft ab', `Der Zugang zum Bankkonto läuft am ${formatDe(validUntil)} ab (${phrase}). Verbinden Sie das Konto neu, damit der Abruf nicht unterbrochen wird.`, validUntil, { typ: 'bank-verbinden' } ) ) } if (connection.status === 'aktiv') { const lastSync = parseInstant(connection.lastSyncAt) const stale = !lastSync || now.getTime() - lastSync.getTime() > SYNC_MAX_AGE_MS if (connection.lastSyncOk === false || stale) { const lastDay = lastSync ? berlinDate(lastSync) : today const failed = connection.lastSyncOk === false const error = trimmed(connection.lastError) const last = lastSync ? `Der letzte erfolgreiche Abruf war am ${formatDe(lastDay)}.` : 'Es gab noch keinen erfolgreichen Abruf.' result.push( todo( 'bank-abruf', MITTEL, failed ? 'Bankabruf fehlgeschlagen' : 'Bankabruf veraltet', `${last}${failed && error ? ` Fehler: ${error}` : ''} Rufen Sie die Buchungen jetzt neu ab.`, lastDay, { typ: 'bank-aktualisieren' } ) ) } } return result } // --- Rechnungen --------------------------------------------------------------------------------------------- function overdueTodos({ today, invoices }) { const result = [] for (const invoice of invoices) { if (!invoice || (invoice.status !== 'ueberfaellig' && invoice.status !== 'offen')) continue const due = validYmd(invoice.dueDate) if (!due || due >= today || !isCents(invoice.remainingCents)) continue const late = daysBetween(due, today) const customer = trimmed(invoice.customerName) result.push( todo( 'ueberfaellig', late >= OVERDUE_URGENT_DAYS ? HOCH : MITTEL, ['Rechnung', trimmed(invoice.number), 'überfällig'].filter(Boolean).join(' '), `${customer ? `${customer}: ` : ''}${euroDe(invoice.remainingCents)} offen, fällig am ${formatDe(due)} (${days(late)} überfällig).`, due, { typ: 'rechnung-oeffnen', invoiceId: invoice.id, hostedUrl: invoice.hostedUrl || null } ) ) } return result } function draftTodos({ today, invoices }) { const result = [] for (const invoice of invoices) { if (!invoice || invoice.status !== 'entwurf') continue const created = validYmd(invoice.createdDate) if (!created) continue const age = daysBetween(created, today) if (age <= DRAFT_MAX_AGE_DAYS) continue const number = trimmed(invoice.number) const customer = trimmed(invoice.customerName) const amount = isCents(invoice.totalCents) ? invoice.totalCents : invoice.remainingCents result.push( todo( 'entwurf', INFO, 'Rechnungsentwurf nicht versendet', `Der Entwurf${number ? ` ${number}` : ''}${customer ? ` für ${customer}` : ''}${isCents(amount) ? ` über ${euroDe(amount)}` : ''} wurde vor ${days(age, true)} angelegt und noch nicht gesendet.`, created, { typ: 'rechnung-oeffnen', invoiceId: invoice.id, hostedUrl: invoice.hostedUrl || null } ) ) } return result } // --- Buchungen ---------------------------------------------------------------------------------------------- /** Erster Vorschlag mit Sicherheit "hoch" als Satzende, sonst leer. */ function hint(transaction) { const best = Array.isArray(transaction.vorschlaege) ? transaction.vorschlaege.find((item) => item?.confidence === 'hoch') : null if (!best) return '' if (best.kind === 'payout') return 'Vorschlag: Stripe-Auszahlung' if (best.kind === 'invoice') return `Vorschlag: Rechnung ${trimmed(best.number) || best.id || ''}`.trimEnd() return '' } function assignTodos({ transactions }) { const result = [] for (const tx of transactions) { if (!tx || tx.status !== 'BOOK' || tx.category || !isCents(tx.amountCents) || tx.amountCents <= 0) continue const date = validYmd(tx.bookingDate) if (!date) continue const from = trimmed(tx.counterpartyName) const remittance = clip(trimmed(tx.remittance), MAX_REMITTANCE) const suggestion = hint(tx) result.push( todo( 'zuordnung', MITTEL, 'Zahlungseingang zuordnen', `${euroDe(tx.amountCents)}${from ? ` von ${from}` : ''} am ${formatDe(date)}${remittance ? `: „${remittance}“` : ''}.${suggestion ? ` ${suggestion}` : ''}`, date, { typ: 'zuordnen', transactionId: tx.$id } ) ) } return result } function recurringTodos({ today, recurring }) { const result = [] const earliest = addDays(today, -RECURRING_MAX_AGE_DAYS) for (const entry of recurring) { const last = entry ? validYmd(entry.lastDate) : null if (!last || last < earliest || !isCents(entry.amountCents)) continue const rhythm = INTERVAL_TEXT[entry.interval] result.push( todo( 'fixkosten-vorschlag', INFO, 'Wiederkehrende Ausgabe erkannt', `${trimmed(entry.name) || 'Eine Ausgabe'}: ${euroDe(entry.amountCents)}${rhythm ? ` ${rhythm}` : ''}, zuletzt am ${formatDe(last)}${isCents(entry.count) ? ` (${entry.count} Buchungen)` : ''}. Als Fixkosten übernehmen?`, last, { typ: 'fixkosten-uebernehmen', vorschlag: entry } ) ) } return result } // --- Abos und Kleinunternehmer ------------------------------------------------------------------------------ function subscriptionTodos({ today, subscriptions }) { const result = [] for (const subscription of subscriptions) { if (!subscription || (subscription.status !== 'past_due' && subscription.status !== 'unpaid')) continue const failed = subscription.status === 'past_due' const customer = trimmed(subscription.customerName) const product = trimmed(subscription.productKey) || trimmed(subscription.planKey) result.push( todo( 'abo-zahlung', HOCH, failed ? 'Abo-Zahlung fehlgeschlagen' : 'Abo unbezahlt', `${customer ? `${customer}: ` : ''}Das Abo${product ? ` „${product}“` : ''}${isCents(subscription.priceAmount) ? ` (${euroDe(subscription.priceAmount)})` : ''} ${failed ? 'konnte nicht abgebucht werden.' : 'ist unbezahlt: Die Zahlung blieb aus.'}`, today, { typ: 'kunde-oeffnen', customerId: subscription.customerId } ) ) } return result } // Vorjahresumsatz ueber der Grenze: die Kleinunternehmerregelung gilt im laufenden Jahr gar nicht erst (§ 19 UStG) function previousYearTodos({ today, tax, settings }) { const revenue = settings?.previousYearRevenueCents const limit = isCents(tax?.kleinunternehmer?.grenzeFolgejahrCents) ? tax.kleinunternehmer.grenzeFolgejahrCents : KLEIN_LIMIT_CENTS if (!isCents(revenue) || revenue <= limit) return [] return [ todo( 'kleinunternehmer', HOCH, 'Vorjahresumsatz über 25.000 €', `Im Vorjahr lagen die Einnahmen bei ${euroDe(revenue)}. Damit gilt die Kleinunternehmerregelung (§ 19 UStG) in diesem Jahr nicht. Bitte Rechnungen und Rechnungsfuß mit der Steuerberatung klären.`, today, { typ: 'einstellungen' } ), ] } function smallBusinessTodos({ today, tax }) { const small = tax?.kleinunternehmer const share = small?.anteilFolgejahr if (typeof share !== 'number' || !Number.isFinite(share) || share < KLEIN_WARN_SHARE) return [] const limit = isCents(small.grenzeFolgejahrCents) ? small.grenzeFolgejahrCents : KLEIN_LIMIT_CENTS const hardLimit = isCents(small.grenzeHartCents) ? small.grenzeHartCents : KLEIN_HARD_LIMIT_CENTS const revenue = isCents(small.jahrCents) ? small.jahrCents : Math.round(share * limit) const percent = Math.floor(share * 100 + 1e-9) const year = today.slice(0, 4) if (revenue > hardLimit) { return [ todo( 'kleinunternehmer', HOCH, 'Kleinunternehmer-Grenze überschritten', `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' } ), ] } if (share >= 1) { return [ todo( 'kleinunternehmer', HOCH, 'Kleinunternehmer-Grenze erreicht', `Die Einnahmen ${year} betragen ${euroDe(revenue)} (${percent} % der Grenze von ${euroDe(limit)}). Über ${euroDe(limit)} endet die Kleinunternehmerregelung (§ 19 UStG) im Folgejahr, über ${euroDe(hardLimit)} schon im laufenden Jahr. Bitte mit der Steuerberatung klären.`, today, { typ: 'einstellungen' } ), ] } return [ todo( 'kleinunternehmer', MITTEL, 'Kleinunternehmer-Grenze rückt näher', `Die Einnahmen ${year} liegen bei ${euroDe(revenue)} und damit bei ${percent} % der Grenze von ${euroDe(limit)}. Wird sie überschritten, endet die Kleinunternehmerregelung (§ 19 UStG) im Folgejahr.`, today, { typ: 'einstellungen' } ), ] } /** * @param {object} args * @param {string} args.today Berliner Tag YYYY-MM-DD * @param {Date} args.now Zeitpunkt (Alter des letzten Bankabrufs) * @param {object|null} args.connection Bankverbindung { sessionId, status, validUntil, lastSyncAt, lastSyncOk, lastError } oder null (ohne sessionId zaehlt sie als nicht verbunden) * @param {Array} [args.invoices] gemappte Stripe-Rechnungen * @param {Array} [args.transactions] Bankbuchungen, optional mit vorschlaege * @param {Array} [args.subscriptions] Abos { customerId, status, priceAmount, productKey|planKey, customerName? } * @param {Array} [args.recurring] erkannte wiederkehrende Ausgaben (detectRecurring) * @param {object} [args.tax] Ergebnis von computeTax * @param {object} [args.settings] Einstellungen { previousYearRevenueCents }: ein Vorjahresumsatz ueber 25.000 EUR ist eine Aufgabe * @returns {Array<{ art, dringlichkeit, titel, text, datum, aktion }>} hoch vor mittel vor info, darin ältestes Datum zuerst */ export function buildTodos({ today, now = new Date(), connection, invoices, transactions, subscriptions, recurring, tax, settings } = {}) { daysBetween(today, today) // wirft bei einem ungültigen Tag const list = (value) => (Array.isArray(value) ? value : []) const all = [ ...bankTodos({ today, now, connection }), ...overdueTodos({ today, invoices: list(invoices) }), ...assignTodos({ transactions: list(transactions) }), ...subscriptionTodos({ today, subscriptions: list(subscriptions) }), ...draftTodos({ today, invoices: list(invoices) }), ...recurringTodos({ today, recurring: list(recurring) }), ...previousYearTodos({ today, tax, settings }), ...smallBusinessTodos({ today, tax }), ] // Array.prototype.sort ist stabil: bei gleicher Dringlichkeit und gleichem Datum bleibt die Reihenfolge der Arten return all.sort((a, b) => RANK[a.dringlichkeit] - RANK[b.dringlichkeit] || (a.datum < b.datum ? -1 : a.datum > b.datum ? 1 : 0)) }