Finanzen: Vorschau, Steuer und Abos
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
88a98b858f
commit
3826f1ab72
9 files changed
+780
-1
No files matched your search
@@ -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
@@ -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),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user