Finanzen: Steuerruecklage, Kleinunternehmer-Zaehler und Aufgabenliste

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 15:42:06 +00:00
1 parent 34dfe181e4
commit 7dbf2ce75f
4 files changed
+1039

No files matched your search

+86
View File
@@ -0,0 +1,86 @@
/**
* Steuer-Auswertung der Finanzen (rein, ohne I/O): Rücklage auf den Gewinn des laufenden Jahres, Zähler für die
* Kleinunternehmer-Grenzen (§ 19 UStG) und die nächste Einkommensteuer-Vorauszahlung. Beträge sind ganze Cent;
* "heute" kommt als Berliner Kalendertag von außen.
*/
import { daysBetween } from './dates.js'
const GRENZE_FOLGEJAHR_CENTS = 2_500_000 // 25.000 EUR: wer im Jahr darüber liegt, ist im Folgejahr kein Kleinunternehmer mehr
const GRENZE_HART_CENTS = 10_000_000 // 100.000 EUR: darüber endet die Regelung sofort
const GEWERBESTEUER_FREIBETRAG_CENTS = 2_450_000 // 24.500 EUR Gewinn bei Personengesellschaften
const DEFAULT_RESERVE_PERCENT = 30 // wie DEFAULT_SETTINGS.taxReservePercent (settings.js, hier nicht importiert: Modul bleibt rein)
const EST_MONTHS = [3, 6, 9, 12] // ESt-Vorauszahlung jeweils am 10.
const EST_DAY = 10
const isCents = (n) => Number.isSafeInteger(n)
const pad = (n) => String(n).padStart(2, '0')
/** Nächster ESt-Vorauszahlungstermin ab heute (heute zählt mit) als YYYY-MM-DD. */
function nextEstDate(today) {
const year = Number(today.slice(0, 4))
for (const month of EST_MONTHS) {
const date = `${String(year).padStart(4, '0')}-${pad(month)}-${pad(EST_DAY)}`
if (date >= today) return date
}
return `${String(year + 1).padStart(4, '0')}-${pad(EST_MONTHS[0])}-${pad(EST_DAY)}`
}
/**
* @param {object} args
* @param {string} args.today Berliner Tag YYYY-MM-DD
* @param {Array} [args.transactions] Bankbuchungen { status, bookingDate, amountCents (+ Eingang / - Ausgang), category }
* @param {Array} [args.invoices] Stripe-Rechnungen (gemappt) { paidCents, paidDate }
* @param {object} [args.settings] { taxReservePercent, previousYearRevenueCents, estPrepaymentCents }
*
* Einnahmen und Ausgaben: gebuchte Bankbuchungen (BOOK) des Kalenderjahres von today ohne Kategorie "privat".
* Der Kleinunternehmer-Zähler nimmt statt der Bankeingänge die Zahltage der Stripe-Rechnungen plus Buchungen
* "rechnung-extern"; Stripe-Auszahlungen und Rechnungsbuchungen zählen dort nicht (sonst doppelt).
*/
export function computeTax({ today, transactions = [], invoices = [], settings } = {}) {
daysBetween(today, today) // wirft bei einem ungültigen Tag
const jahr = Number(today.slice(0, 4))
const prefix = `${today.slice(0, 4)}-`
let einnahmenCents = 0
let ausgabenCents = 0
let externCents = 0
for (const tx of transactions || []) {
if (!tx || tx.status !== 'BOOK' || typeof tx.bookingDate !== 'string' || !tx.bookingDate.startsWith(prefix)) continue
if (!isCents(tx.amountCents) || tx.category === 'privat') continue
if (tx.amountCents > 0) {
einnahmenCents += tx.amountCents
if (tx.category === 'rechnung-extern') externCents += tx.amountCents
} else {
ausgabenCents -= tx.amountCents
}
}
let stripeCents = 0
for (const invoice of invoices || []) {
if (!invoice || typeof invoice.paidDate !== 'string' || !invoice.paidDate.startsWith(prefix)) continue
if (isCents(invoice.paidCents) && invoice.paidCents > 0) stripeCents += invoice.paidCents
}
const gewinnCents = einnahmenCents - ausgabenCents
const prozent = Number.isFinite(settings?.taxReservePercent) ? settings.taxReservePercent : DEFAULT_RESERVE_PERCENT
const jahrCents = stripeCents + externCents
const prepayment = settings?.estPrepaymentCents
return {
jahr,
einnahmenCents,
ausgabenCents,
gewinnCents,
ruecklageCents: Math.round((Math.max(0, gewinnCents) * prozent) / 100),
prozent,
gewerbesteuerHinweis: gewinnCents > GEWERBESTEUER_FREIBETRAG_CENTS,
kleinunternehmer: {
jahrCents,
vorjahrCents: isCents(settings?.previousYearRevenueCents) ? settings.previousYearRevenueCents : null,
grenzeFolgejahrCents: GRENZE_FOLGEJAHR_CENTS,
grenzeHartCents: GRENZE_HART_CENTS,
anteilFolgejahr: jahrCents / GRENZE_FOLGEJAHR_CENTS,
},
naechsteVorauszahlung: isCents(prepayment) && prepayment > 0 ? { date: nextEstDate(today), amountCents: prepayment } : null,
}
}
+332
View File
@@ -0,0 +1,332 @@
/**
* 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))
}