Finanzen: Vorschau, Steuer und Abos

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 15:12:18 +00:00
1 parent 88a98b858f
commit 3826f1ab72
9 files changed
+780 -1

No files matched your search

+7
View File
@@ -43,6 +43,13 @@ export function signedEuro(cents) {
return cents > 0 && text !== '0,00 €' ? `+${text}` : text
}
/** 30 -> '30', 27.5 -> '27,5', 0.32 (1 Stelle) -> '0,3'; höchstens `maxDigits` Nachkommastellen, ohne überflüssige Nullen; ungültig -> '–'. */
export function percentDe(value, maxDigits = 2) {
if (!isNum(value)) return PLACEHOLDER
const rounded = Number(value.toFixed(maxDigits))
return String(rounded).replace('-', MINUS).replace('.', ',')
}
const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/
const berlinDate = new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
+175 -1
View File
@@ -1,4 +1,4 @@
// Reine Darstellungs-Helfer fuer Statuszeile, Liquiditaetskopf und Aufgabenliste der Finanzen-Seite
// Reine Darstellungs-Helfer fuer die Bloecke der Finanzen-Seite (Status, Liquiditaet, Aufgaben, Vorschau, Steuer, Abos)
// (kein React, kein Appwrite, unter Node testbar). Beträge sind Cent, Texte deutsch.
import {
INTERVAL_LABEL,
@@ -6,6 +6,7 @@ import {
euro,
euroWhole,
nextIntervalDate,
percentDe,
signedEuro,
sinceLabel,
ymdBerlin,
@@ -216,3 +217,176 @@ export function planTodoAction(aktion) {
return planError('Diese Aktion ist nicht bekannt.')
}
}
// --- Vorschau 30 Tage (Block 3) ----------------------------------------------------------------------------
/**
* Zeilen der Vorschau mit laufendem Saldo beider Linien, beginnend beim Kontostand: „sicher“ zählt nur die
* sicheren Posten, „erwartet“ alle (wie buildForecast im Portal, daher passen die Tiefststände). Die Reihenfolge
* des Servers bleibt. Ohne Kontostand (null) gibt es keine Salden (hasBalance false, Saldo-Felder null / '–').
* Je Zeile: { key, dateText, label, sicherheit ('sicher'|'erwartet'), amountCents, amountText,
* saldoSicherCents, saldoSicherText, saldoSicherNegative, saldoErwartetCents, saldoErwartetText, saldoErwartetNegative }.
*/
export function forecastRows(posten, kontostandCents) {
const items = Array.isArray(posten) ? posten.filter((p) => p && typeof p === 'object' && isNum(p.amountCents)) : []
const hasBalance = isNum(kontostandCents)
let sicher = hasBalance ? kontostandCents : null
let erwartet = hasBalance ? kontostandCents : null
const rows = items.map((p, index) => {
const sure = p.sicherheit === 'sicher'
if (hasBalance) {
erwartet += p.amountCents
if (sure) sicher += p.amountCents
}
return {
key: [index, p.date || '', p.label || ''].join('|'),
dateText: dateDe(p.date),
label: text(p.label) || 'Zahlung',
sicherheit: sure ? 'sicher' : 'erwartet',
amountCents: p.amountCents,
amountText: signedEuro(p.amountCents),
saldoSicherCents: sicher,
saldoSicherText: euro(sicher),
saldoSicherNegative: hasBalance && sicher < 0,
saldoErwartetCents: erwartet,
saldoErwartetText: euro(erwartet),
saldoErwartetNegative: hasBalance && erwartet < 0,
}
})
return { rows, hasBalance }
}
/**
* Hinweise unter der Vorschau als [{ key, text }]: kein Kontostand (keine Salden), Stripe nicht erreichbar
* (Rechnungen und Auszahlungen fehlen), Abos nicht geladen (Verlängerungen fehlen).
*/
export function forecastNotes({ kontostandCents, stripeDown = false, abosFehler = false, hasRows = false } = {}) {
const notes = []
if (hasRows && !isNum(kontostandCents)) {
notes.push({
key: 'kontostand',
text: 'Ohne Kontostand zeigt die Vorschau keine Salden. Sobald die Bank verbunden und abgerufen ist, erscheinen sie hier.',
})
}
if (stripeDown) {
notes.push({
key: 'stripe',
text: 'Stripe war nicht erreichbar: Offene Rechnungen und Stripe-Auszahlungen fehlen in dieser Vorschau.',
})
}
if (abosFehler) {
notes.push({ key: 'abos', text: 'Abo-Verlängerungen fehlen, weil die Abos nicht geladen werden konnten.' })
}
return notes
}
// --- Steuer (Block 4) --------------------------------------------------------------------------------------
// Gesetzliche Werte (§ 19 UStG, § 11 GewStG) als Rückfall, falls der Server eine Grenze nicht mitschickt
const KLEIN_LIMIT_CENTS = 2_500_000
const KLEIN_HARD_LIMIT_CENTS = 10_000_000
const GEWERBE_ALLOWANCE_CENTS = 2_450_000
/**
* Texte der Steuerkarte aus `steuer`:
* { reserve: { amountText, basisText }, small: { known, counterText, shareText, hardText, previousText },
* gewerbeText, prepayment: { dateText, amountText } | null }.
* Ist der Kleinunternehmer-Zähler null (Stripe nicht erreichbar), steht „unbekannt“ statt einer Zahl.
*/
export function taxView(steuer) {
const s = steuer && typeof steuer === 'object' ? steuer : {}
const k = s.kleinunternehmer && typeof s.kleinunternehmer === 'object' ? s.kleinunternehmer : {}
const year = isNum(s.jahr) ? s.jahr : null
const limit = isNum(k.grenzeFolgejahrCents) ? k.grenzeFolgejahrCents : KLEIN_LIMIT_CENTS
const hard = isNum(k.grenzeHartCents) ? k.grenzeHartCents : KLEIN_HARD_LIMIT_CENTS
const known = isNum(k.jahrCents)
const basisKnown = isNum(s.prozent) && isNum(s.gewinnCents)
const reserve = {
amountText: euro(s.ruecklageCents),
basisText: basisKnown ? `${percentDe(s.prozent)} % von ${euro(s.gewinnCents)} Gewinn${year !== null ? ` ${year}` : ''}` : '',
}
const previous = isNum(k.vorjahrCents)
? `Vorjahr: ${euro(k.vorjahrCents)}${k.vorjahrCents > limit ? ` (über der Grenze von ${euroWhole(limit)})` : ''}`
: null
const prepay = s.naechsteVorauszahlung
const prepayment =
prepay && typeof prepay === 'object' && isNum(prepay.amountCents)
? { dateText: dateDe(prepay.date), amountText: euro(prepay.amountCents) }
: null
return {
reserve,
small: {
known,
counterText: known ? `${euro(k.jahrCents)} von ${euroWhole(limit)}${year !== null ? ` (${year})` : ''}` : 'unbekannt (Stripe nicht erreichbar)',
shareText: known && isNum(k.anteilFolgejahr) ? `${percentDe(k.anteilFolgejahr * 100, 1)} % der Grenze` : null,
hardText: `Grenze ${euroWhole(hard)}: darüber endet die Regelung sofort.`,
previousText: previous,
},
gewerbeText:
s.gewerbesteuerHinweis === true
? `Der Gewinn liegt über dem Gewerbesteuer-Freibetrag für Personengesellschaften (${euroWhole(GEWERBE_ALLOWANCE_CENTS)}). Es kann Gewerbesteuer anfallen.`
: null,
prepayment,
}
}
// --- Abos (Block 5) ----------------------------------------------------------------------------------------
const PRODUCT_LABEL = { hosting: 'Hosting-Abo', wartung: 'Wartungs-Abo' }
const ABO_STATUS = {
active: { label: 'aktiv', tone: 'ok' },
trialing: { label: 'Testphase', tone: 'info' },
past_due: { label: 'Zahlung fehlgeschlagen', tone: 'danger' },
unpaid: { label: 'unbezahlt', tone: 'danger' },
}
const ABOS_FAILED = 'Abos konnten nicht geladen werden.'
const has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key)
/** Katalogname eines Abo-Schlüssels (hosting -> „Hosting-Abo“); ohne Schlüssel „Abo“, unbekannter Schlüssel bleibt stehen. */
export function productLabel(key) {
const k = text(key)
if (!k) return 'Abo'
return has(PRODUCT_LABEL, k) ? PRODUCT_LABEL[k] : k
}
/** Stripe-Status eines Abos -> { label, tone }; der Text steht immer dabei, nicht nur die Farbe. */
export function aboStatus(status) {
const s = text(status)
if (has(ABO_STATUS, s)) return ABO_STATUS[s]
return { label: s || 'unbekannt', tone: 'muted' }
}
/**
* Texte der Abo-Karte: { error, monthlyText, payingText, atRiskText (null bis auf > 0), rows }.
* `fehler` (Abos nicht ladbar) gibt den Text des Servers und keine Zahlen. rows: { key, customer, product,
* statusLabel, tone, amountText, nextText } in Serverreihenfolge (Zahlungsprobleme zuerst).
*/
export function aboView(abos) {
const a = abos && typeof abos === 'object' ? abos : {}
if (a.fehler) {
return { error: text(a.fehler) || ABOS_FAILED, monthlyText: '–', payingText: '–', atRiskText: null, rows: [] }
}
const list = Array.isArray(a.liste) ? a.liste.filter((x) => x && typeof x === 'object') : []
return {
error: '',
monthlyText: euro(a.monatlichCents),
payingText: isNum(a.zahlendeKunden) ? String(a.zahlendeKunden) : '–',
atRiskText: isNum(a.gefaehrdetCents) && a.gefaehrdetCents > 0 ? euro(a.gefaehrdetCents) : null,
rows: list.map((abo, index) => {
const status = aboStatus(abo.status)
return {
key: [index, abo.customerId || '', abo.produkt || ''].join('|'),
customer: text(abo.kunde) || 'Unbekannter Kunde',
product: productLabel(abo.produkt),
statusLabel: status.label,
tone: status.tone,
amountText: euro(abo.betragCents),
nextText: dateDe(abo.naechsteAbbuchung),
}
}),
}
}