Files
Webklar-Kundenbereich/server/services/finance/todos.js
T

333 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 = []
if (!connection) {
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
}
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)}. Die Kleinunternehmerregelung (§ 19 UStG) ist davon sofort betroffen. 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 { status, validUntil, lastSyncAt, lastSyncOk, lastError } oder null
* @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
* @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 } = {}) {
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) }),
...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))
}