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
@@ -0,0 +1,61 @@
|
|||||||
|
import { FaTriangleExclamation } from 'react-icons/fa6'
|
||||||
|
import { Badge, Card, EmptyState } from '../ui'
|
||||||
|
import { aboView } from '../../lib/financeView'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block 5: „Abos“. Einnahmen pro Monat (aktiv und Zahlung fehlgeschlagen), zahlende Kunden, gefährdete Einnahmen
|
||||||
|
* (nur wenn > 0) und die Liste Kunde · Produkt · Status · Betrag · nächste Abbuchung. Konnten die Abos nicht
|
||||||
|
* geladen werden, steht stattdessen die Meldung des Servers da (keine Zahlen).
|
||||||
|
*/
|
||||||
|
export default function AboCard({ abos }) {
|
||||||
|
const v = aboView(abos)
|
||||||
|
|
||||||
|
if (v.error) {
|
||||||
|
return (
|
||||||
|
<Card title="Abos" className="fin-abo-card">
|
||||||
|
<p className="fin-hint fin-hint-warn" role="alert">
|
||||||
|
<FaTriangleExclamation aria-hidden="true" /> {v.error}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="Abos" className="fin-abo-card" bodyStyle={{ padding: 0 }}>
|
||||||
|
<div className="fin-stats">
|
||||||
|
<span className="fin-amount fin-stat-value">{v.monthlyText}</span>
|
||||||
|
<span className="fin-stat-label">Abo-Einnahmen pro Monat</span>
|
||||||
|
<span className="fin-amount fin-stat-value">{v.payingText}</span>
|
||||||
|
<span className="fin-stat-label">zahlende Kunden</span>
|
||||||
|
{v.atRiskText && (
|
||||||
|
<>
|
||||||
|
<span className="fin-amount fin-stat-value fin-flag">{v.atRiskText}</span>
|
||||||
|
<span className="fin-stat-label fin-flag">
|
||||||
|
<FaTriangleExclamation aria-hidden="true" /> gefährdet
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{v.rows.length === 0 ? (
|
||||||
|
<EmptyState title="Noch keine Abos." />
|
||||||
|
) : (
|
||||||
|
<ul className="fin-abos">
|
||||||
|
{v.rows.map((row) => (
|
||||||
|
<li key={row.key} className="fin-abo">
|
||||||
|
<div className="fin-abo-main">
|
||||||
|
<span className="fin-abo-customer">{row.customer}</span>
|
||||||
|
<span className="fin-abo-product muted"> · {row.product}</span>
|
||||||
|
</div>
|
||||||
|
<div className="fin-amount fin-abo-amount">{row.amountText}</div>
|
||||||
|
<div className="fin-abo-meta">
|
||||||
|
<Badge tone={row.tone}>{row.statusLabel}</Badge>
|
||||||
|
<span className="fin-abo-next muted">nächste Abbuchung: {row.nextText}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { FaTriangleExclamation } from 'react-icons/fa6'
|
||||||
|
import { Badge, Card, EmptyState } from '../ui'
|
||||||
|
import { forecastNotes, forecastRows } from '../../lib/financeView'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block 3: „Vorschau 30 Tage“. Je Posten Datum, Bezeichnung, „sicher“/„erwartet“ (als Text), Betrag mit Vorzeichen
|
||||||
|
* und der laufende Saldo beider Linien ab dem Kontostand. Ohne Kontostand entfallen die Saldo-Spalten (mit Hinweis).
|
||||||
|
* Darunter die Annahme des Servers und Hinweise, wenn Stripe oder die Abos fehlen. Schmal (Karte unter ca. 560 px)
|
||||||
|
* wird jede Zeile zweizeilig: Datum, Posten, Betrag / Sicherheit, Salden.
|
||||||
|
*/
|
||||||
|
export default function ForecastTable({ vorschau, kontostandCents, stripeDown = false, abosFehler = false }) {
|
||||||
|
const v = vorschau && typeof vorschau === 'object' ? vorschau : {}
|
||||||
|
const { rows, hasBalance } = forecastRows(v.posten, kontostandCents)
|
||||||
|
const notes = forecastNotes({ kontostandCents, stripeDown, abosFehler, hasRows: rows.length > 0 })
|
||||||
|
const assumption = typeof v.annahme === 'string' ? v.annahme.trim() : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="Vorschau 30 Tage" className="fin-forecast-card" bodyStyle={{ padding: 0 }}>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<EmptyState title="Keine Zahlungen in den nächsten 30 Tagen bekannt." />
|
||||||
|
) : (
|
||||||
|
<div className="fin-table-wrap">
|
||||||
|
<table className={`ui-table fin-forecast${hasBalance ? '' : ' fin-forecast-nobal'}`} role="table">
|
||||||
|
<caption className="fin-sr">Erwartete Zahlungen der nächsten 30 Tage</caption>
|
||||||
|
<thead role="rowgroup">
|
||||||
|
<tr role="row">
|
||||||
|
<th role="columnheader" scope="col">Datum</th>
|
||||||
|
<th role="columnheader" scope="col">Posten</th>
|
||||||
|
<th role="columnheader" scope="col">Sicherheit</th>
|
||||||
|
<th role="columnheader" scope="col" className="fin-amount">Betrag</th>
|
||||||
|
{hasBalance && (
|
||||||
|
<>
|
||||||
|
<th role="columnheader" scope="col" className="fin-amount">Saldo sicher</th>
|
||||||
|
<th role="columnheader" scope="col" className="fin-amount">Saldo erwartet</th>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody role="rowgroup">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<tr key={row.key} role="row">
|
||||||
|
<td role="cell" className="fin-forecast-date">{row.dateText}</td>
|
||||||
|
<td role="cell" className="fin-forecast-label">{row.label}</td>
|
||||||
|
<td role="cell" className="fin-forecast-kind">
|
||||||
|
<Badge tone={row.sicherheit === 'sicher' ? 'ok' : 'info'}>{row.sicherheit}</Badge>
|
||||||
|
</td>
|
||||||
|
<td role="cell" className="fin-amount fin-forecast-amount">{row.amountText}</td>
|
||||||
|
{hasBalance && (
|
||||||
|
<>
|
||||||
|
<td
|
||||||
|
role="cell"
|
||||||
|
data-label="Saldo sicher"
|
||||||
|
className={`fin-amount fin-forecast-sicher${row.saldoSicherNegative ? ' fin-neg' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="fin-saldo">{row.saldoSicherText}</span>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
role="cell"
|
||||||
|
data-label="Saldo erwartet"
|
||||||
|
className={`fin-amount fin-forecast-erwartet${row.saldoErwartetNegative ? ' fin-neg' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="fin-saldo">{row.saldoErwartetText}</span>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(notes.length > 0 || assumption) && (
|
||||||
|
<div className="fin-forecast-foot">
|
||||||
|
{notes.map((note) => (
|
||||||
|
<p key={note.key} className="fin-hint fin-hint-warn" role="status">
|
||||||
|
<FaTriangleExclamation aria-hidden="true" /> {note.text}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{assumption && <p className="fin-hint">{assumption}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Card } from '../ui'
|
||||||
|
import { taxView } from '../../lib/financeView'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block 4: „Steuer“. Rücklage (Betrag und woraus sie sich ergibt), Kleinunternehmer-Zähler gegen die Grenzen,
|
||||||
|
* Vorjahr (nur wenn eingetragen), Gewerbesteuer-Hinweis (nur wenn nötig) und die nächste ESt-Vorauszahlung
|
||||||
|
* (nur wenn eingestellt). Ist der Zähler unbekannt (Stripe nicht erreichbar), steht das als Text da, nie eine 0.
|
||||||
|
*/
|
||||||
|
export default function TaxCard({ steuer }) {
|
||||||
|
const v = taxView(steuer)
|
||||||
|
return (
|
||||||
|
<Card title="Steuer" className="fin-tax-card">
|
||||||
|
<div className="fin-facts">
|
||||||
|
<div className="fin-fact">
|
||||||
|
<div className="fin-fact-row">
|
||||||
|
<span className="fin-fact-label">Steuer-Rücklage</span>
|
||||||
|
<span className="fin-amount fin-fact-big">{v.reserve.amountText}</span>
|
||||||
|
</div>
|
||||||
|
{v.reserve.basisText && <div className="fin-fact-sub">{v.reserve.basisText}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fin-fact">
|
||||||
|
<div className="fin-fact-label">Kleinunternehmer (§ 19 UStG)</div>
|
||||||
|
<div className={`fin-fact-value${v.small.known ? '' : ' fin-fact-unknown'}`}>{v.small.counterText}</div>
|
||||||
|
{v.small.shareText && <div className="fin-fact-sub">{v.small.shareText}</div>}
|
||||||
|
<div className="fin-fact-sub">{v.small.hardText}</div>
|
||||||
|
{v.small.previousText && <div className="fin-fact-sub">{v.small.previousText}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{v.gewerbeText && (
|
||||||
|
<div className="fin-fact">
|
||||||
|
<div className="fin-fact-label">Gewerbesteuer</div>
|
||||||
|
<div className="fin-fact-sub fin-fact-sub-strong">{v.gewerbeText}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{v.prepayment && (
|
||||||
|
<div className="fin-fact">
|
||||||
|
<div className="fin-fact-row">
|
||||||
|
<span className="fin-fact-label">Nächste ESt-Vorauszahlung</span>
|
||||||
|
<span className="fin-amount fin-fact-strong">{v.prepayment.amountText}</span>
|
||||||
|
</div>
|
||||||
|
<div className="fin-fact-sub">am {v.prepayment.dateText}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -43,6 +43,13 @@ export function signedEuro(cents) {
|
|||||||
return cents > 0 && text !== '0,00 €' ? `+${text}` : text
|
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 DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/
|
||||||
const berlinDate = new Intl.DateTimeFormat('de-DE', {
|
const berlinDate = new Intl.DateTimeFormat('de-DE', {
|
||||||
timeZone: 'Europe/Berlin',
|
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.
|
// (kein React, kein Appwrite, unter Node testbar). Beträge sind Cent, Texte deutsch.
|
||||||
import {
|
import {
|
||||||
INTERVAL_LABEL,
|
INTERVAL_LABEL,
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
euro,
|
euro,
|
||||||
euroWhole,
|
euroWhole,
|
||||||
nextIntervalDate,
|
nextIntervalDate,
|
||||||
|
percentDe,
|
||||||
signedEuro,
|
signedEuro,
|
||||||
sinceLabel,
|
sinceLabel,
|
||||||
ymdBerlin,
|
ymdBerlin,
|
||||||
@@ -216,3 +217,176 @@ export function planTodoAction(aktion) {
|
|||||||
return planError('Diese Aktion ist nicht bekannt.')
|
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),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ import { PageHeader, Card, Button } from '../components/ui'
|
|||||||
import StatusLine from '../components/finance/StatusLine'
|
import StatusLine from '../components/finance/StatusLine'
|
||||||
import LiquidityHead from '../components/finance/LiquidityHead'
|
import LiquidityHead from '../components/finance/LiquidityHead'
|
||||||
import TodoList from '../components/finance/TodoList'
|
import TodoList from '../components/finance/TodoList'
|
||||||
|
import ForecastTable from '../components/finance/ForecastTable'
|
||||||
|
import TaxCard from '../components/finance/TaxCard'
|
||||||
|
import AboCard from '../components/finance/AboCard'
|
||||||
import { useFinanceOverview } from '../hooks/useFinanceOverview'
|
import { useFinanceOverview } from '../hooks/useFinanceOverview'
|
||||||
import { createFixedCost, refreshBank, startBankAuth } from '../lib/financeApi'
|
import { createFixedCost, refreshBank, startBankAuth } from '../lib/financeApi'
|
||||||
import { planTodoAction, safeUrl } from '../lib/financeView'
|
import { planTodoAction, safeUrl } from '../lib/financeView'
|
||||||
@@ -100,6 +103,18 @@ export default function FinancePage() {
|
|||||||
<StatusLine stand={data.stand} onRefresh={refresh} refreshing={refreshing} />
|
<StatusLine stand={data.stand} onRefresh={refresh} refreshing={refreshing} />
|
||||||
<LiquidityHead kopf={data.kopf} />
|
<LiquidityHead kopf={data.kopf} />
|
||||||
<TodoList aufgaben={data.aufgaben} onAction={handleAction} />
|
<TodoList aufgaben={data.aufgaben} onAction={handleAction} />
|
||||||
|
<div className="fin-cols">
|
||||||
|
<ForecastTable
|
||||||
|
vorschau={data.vorschau}
|
||||||
|
kontostandCents={data.kopf?.kontostandCents}
|
||||||
|
stripeDown={data.stand?.stripe?.ok === false}
|
||||||
|
abosFehler={Boolean(data.abos?.fehler)}
|
||||||
|
/>
|
||||||
|
<TaxCard steuer={data.steuer} />
|
||||||
|
</div>
|
||||||
|
<div className="fin-cols fin-cols-half">
|
||||||
|
<AboCard abos={data.abos} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1205,3 +1205,80 @@ textarea.form-control {
|
|||||||
.fin-todo { grid-template-columns: minmax(0, 1fr); gap: var(--sp-2); }
|
.fin-todo { grid-template-columns: minmax(0, 1fr); gap: var(--sp-2); }
|
||||||
.fin-todo .ui-btn { justify-self: start; }
|
.fin-todo .ui-btn { justify-self: start; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bloecke 3-5: Vorschau, Steuer, Abos */
|
||||||
|
.fin-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||||
|
.fin-neg { color: var(--danger); }
|
||||||
|
.fin-cols { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--sp-5); align-items: start; }
|
||||||
|
@media (min-width: 1100px) {
|
||||||
|
.fin-cols { grid-template-columns: minmax(0, 1.8fr) minmax(0, 1fr); }
|
||||||
|
.fin-cols-half { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Block 3: Vorschau (Tabelle; in einer schmalen Karte wird jede Zeile zweizeilig) */
|
||||||
|
.fin-forecast-card { container-type: inline-size; overflow: hidden; }
|
||||||
|
.fin-table-wrap { overflow-x: auto; }
|
||||||
|
.fin-forecast th, .fin-forecast td { padding: 9px 10px; vertical-align: baseline; }
|
||||||
|
.fin-forecast th.fin-amount { text-align: right; }
|
||||||
|
.fin-forecast th:first-child, .fin-forecast td:first-child { padding-left: var(--sp-4); }
|
||||||
|
.fin-forecast th:last-child, .fin-forecast td:last-child { padding-right: var(--sp-4); }
|
||||||
|
.fin-forecast tbody tr:last-child td { border-bottom: none; }
|
||||||
|
.fin-forecast-date { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||||
|
.fin-forecast-label { overflow-wrap: anywhere; }
|
||||||
|
.fin-forecast-foot { display: grid; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-top: 1px solid var(--border); }
|
||||||
|
.fin-forecast-foot .fin-hint svg { vertical-align: -2px; }
|
||||||
|
@container (max-width: 560px) {
|
||||||
|
.fin-forecast thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||||
|
.fin-forecast, .fin-forecast tbody { display: block; }
|
||||||
|
.fin-forecast tr {
|
||||||
|
display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 4px 0; align-items: baseline;
|
||||||
|
grid-template-areas: "date date kind kind amount amount" "label label label label label label" "sicher sicher sicher erwartet erwartet erwartet";
|
||||||
|
padding: 10px var(--sp-4); border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.fin-forecast-nobal tr { grid-template-areas: "date date kind kind amount amount" "label label label label label label"; }
|
||||||
|
.fin-forecast tbody tr:last-child { border-bottom: none; }
|
||||||
|
.fin-forecast td, .fin-forecast td:first-child, .fin-forecast td:last-child { display: block; padding: 0; border: 0; min-width: 0; }
|
||||||
|
.fin-forecast tbody tr:hover td { background: transparent; }
|
||||||
|
.fin-forecast-date { grid-area: date; }
|
||||||
|
.fin-forecast-kind { grid-area: kind; }
|
||||||
|
.fin-forecast-amount { grid-area: amount; font-weight: 700; }
|
||||||
|
.fin-forecast-label { grid-area: label; }
|
||||||
|
.fin-forecast td.fin-forecast-sicher, .fin-forecast td.fin-forecast-erwartet {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: baseline; column-gap: var(--sp-2); font-size: 13px;
|
||||||
|
}
|
||||||
|
.fin-forecast-sicher { grid-area: sicher; }
|
||||||
|
.fin-forecast-erwartet { grid-area: erwartet; }
|
||||||
|
.fin-forecast td.fin-forecast-sicher { padding-right: var(--sp-3); }
|
||||||
|
.fin-forecast td.fin-forecast-erwartet { padding-left: var(--sp-3); }
|
||||||
|
.fin-saldo { margin-left: auto; }
|
||||||
|
.fin-forecast td[data-label]::before { content: attr(data-label); color: var(--text-muted); font-size: 12px; font-weight: 500; white-space: nowrap; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Block 4: Steuer */
|
||||||
|
.fin-facts { display: grid; gap: var(--sp-4); }
|
||||||
|
.fin-fact { display: grid; gap: 3px; padding-bottom: var(--sp-4); border-bottom: 1px solid var(--border); }
|
||||||
|
.fin-fact:last-child { padding-bottom: 0; border-bottom: none; }
|
||||||
|
.fin-fact-row { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-3); }
|
||||||
|
.fin-fact-label { color: var(--text-muted); font-size: 13px; }
|
||||||
|
.fin-fact-big { font-size: 24px; font-weight: 800; }
|
||||||
|
.fin-fact-strong { font-size: 16px; font-weight: 700; }
|
||||||
|
.fin-fact-value { font-weight: 700; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
||||||
|
.fin-fact-unknown { color: var(--warn); }
|
||||||
|
.fin-fact-sub { font-size: 13px; color: var(--text-muted); overflow-wrap: anywhere; }
|
||||||
|
.fin-fact-sub-strong { color: var(--text); }
|
||||||
|
|
||||||
|
/* Block 5: Abos */
|
||||||
|
.fin-abo-card { overflow: hidden; }
|
||||||
|
.fin-stats { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 6px var(--sp-3); align-items: baseline; padding: var(--sp-4); border-bottom: 1px solid var(--border); }
|
||||||
|
.fin-stat-value { font-size: 20px; font-weight: 800; }
|
||||||
|
.fin-stat-label { color: var(--text-muted); }
|
||||||
|
.fin-stat-label.fin-flag { color: var(--danger); }
|
||||||
|
.fin-stat-label svg { vertical-align: -2px; }
|
||||||
|
.fin-abos { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.fin-abo {
|
||||||
|
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px var(--sp-3); align-items: baseline;
|
||||||
|
padding: 11px var(--sp-4); border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.fin-abo:last-child { border-bottom: none; }
|
||||||
|
.fin-abo-customer { font-weight: 700; overflow-wrap: anywhere; }
|
||||||
|
.fin-abo-meta { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 4px var(--sp-3); font-size: 13px; }
|
||||||
@@ -18,6 +18,7 @@ const {
|
|||||||
nextIntervalDate,
|
nextIntervalDate,
|
||||||
ymdBerlin,
|
ymdBerlin,
|
||||||
INTERVAL_LABEL,
|
INTERVAL_LABEL,
|
||||||
|
percentDe,
|
||||||
} = await loadModule('src/lib/financeFormat.js')
|
} = await loadModule('src/lib/financeFormat.js')
|
||||||
|
|
||||||
const MINUS = '−'
|
const MINUS = '−'
|
||||||
@@ -298,3 +299,20 @@ test('ymdBerlin: Berliner Kalendertag eines Zeitpunkts', () => {
|
|||||||
assert.equal(ymdBerlin(null), null)
|
assert.equal(ymdBerlin(null), null)
|
||||||
assert.equal(ymdBerlin(NaN), null)
|
assert.equal(ymdBerlin(NaN), null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('percentDe: Dezimalkomma, überflüssige Nullen entfallen', () => {
|
||||||
|
assert.equal(percentDe(30), '30')
|
||||||
|
assert.equal(percentDe(27.5), '27,5')
|
||||||
|
assert.equal(percentDe(30.25), '30,25')
|
||||||
|
assert.equal(percentDe(0), '0')
|
||||||
|
assert.equal(percentDe(100), '100')
|
||||||
|
assert.equal(percentDe(0.32, 1), '0,3')
|
||||||
|
assert.equal(percentDe(70.4, 1), '70,4')
|
||||||
|
assert.equal(percentDe(30.256), '30,26')
|
||||||
|
assert.equal(percentDe(-12.5), `${MINUS}12,5`)
|
||||||
|
assert.equal(percentDe(-0.001), '0')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('percentDe: Platzhalter für fehlende Werte', () => {
|
||||||
|
for (const v of [null, undefined, NaN, Infinity, 'x']) assert.equal(percentDe(v), '–')
|
||||||
|
})
|
||||||
@@ -17,6 +17,12 @@ const {
|
|||||||
todoButtonLabel,
|
todoButtonLabel,
|
||||||
fixedCostFromSuggestion,
|
fixedCostFromSuggestion,
|
||||||
planTodoAction,
|
planTodoAction,
|
||||||
|
forecastRows,
|
||||||
|
forecastNotes,
|
||||||
|
taxView,
|
||||||
|
aboView,
|
||||||
|
productLabel,
|
||||||
|
aboStatus,
|
||||||
} = await loadModule('src/lib/financeView.js')
|
} = await loadModule('src/lib/financeView.js')
|
||||||
|
|
||||||
const MINUS = '−'
|
const MINUS = '−'
|
||||||
@@ -256,3 +262,291 @@ test('planTodoAction: fehlende Angaben und unbekannte Aktionen geben eine Meldun
|
|||||||
assert.ok(plan.message.length > 0)
|
assert.ok(plan.message.length > 0)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Vorschau 30 Tage (Block 3) -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const posten = () => [
|
||||||
|
{ date: '2026-10-03', label: 'Rechnung WK-0005 · Kunde Eins', amountCents: 16879, sicherheit: 'erwartet' },
|
||||||
|
{ date: '2026-10-06', label: 'Hetzner', amountCents: -1000, sicherheit: 'sicher' },
|
||||||
|
{ date: '2026-10-10', label: 'Stripe-Auszahlung', amountCents: 2500, sicherheit: 'sicher' },
|
||||||
|
{ date: '2026-10-11', label: 'Miete', amountCents: -30000, sicherheit: 'sicher' },
|
||||||
|
]
|
||||||
|
|
||||||
|
test('forecastRows: laufende Salden, sicher zählt nur sichere Posten, erwartet alle', () => {
|
||||||
|
const { rows, hasBalance } = forecastRows(posten(), 10893)
|
||||||
|
assert.equal(hasBalance, true)
|
||||||
|
assert.deepEqual(rows.map((r) => r.saldoSicherCents), [10893, 9893, 12393, -17607])
|
||||||
|
assert.deepEqual(rows.map((r) => r.saldoErwartetCents), [27772, 26772, 29272, -728])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('forecastRows: Texte (Datum TT.MM.JJJJ, Betrag mit Vorzeichen, Salden mit U+2212)', () => {
|
||||||
|
const { rows } = forecastRows(posten(), 10893)
|
||||||
|
assert.equal(rows[0].dateText, '03.10.2026')
|
||||||
|
assert.equal(rows[0].label, 'Rechnung WK-0005 · Kunde Eins')
|
||||||
|
assert.equal(rows[0].sicherheit, 'erwartet')
|
||||||
|
assert.equal(rows[0].amountText, '+168,79 €')
|
||||||
|
assert.equal(rows[0].saldoSicherText, '108,93 €')
|
||||||
|
assert.equal(rows[0].saldoErwartetText, '277,72 €')
|
||||||
|
assert.equal(rows[1].sicherheit, 'sicher')
|
||||||
|
assert.equal(rows[1].amountText, `${MINUS}10,00 €`)
|
||||||
|
assert.equal(rows[3].amountText, `${MINUS}300,00 €`)
|
||||||
|
assert.equal(rows[3].saldoSicherText, `${MINUS}176,07 €`)
|
||||||
|
assert.equal(rows[3].saldoErwartetText, `${MINUS}7,28 €`)
|
||||||
|
assert.equal(rows[3].saldoSicherNegative, true)
|
||||||
|
assert.equal(rows[0].saldoSicherNegative, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('forecastRows: der tiefste laufende Saldo passt zu minSicher/minErwartet des Servers', () => {
|
||||||
|
const { rows } = forecastRows(posten(), 10893)
|
||||||
|
assert.equal(Math.min(10893, ...rows.map((r) => r.saldoSicherCents)), -17607)
|
||||||
|
assert.equal(Math.min(10893, ...rows.map((r) => r.saldoErwartetCents)), -728)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('forecastRows: ohne Kontostand keine Salden', () => {
|
||||||
|
for (const none of [null, undefined, NaN]) {
|
||||||
|
const { rows, hasBalance } = forecastRows(posten(), none)
|
||||||
|
assert.equal(hasBalance, false)
|
||||||
|
assert.equal(rows.length, 4)
|
||||||
|
assert.ok(rows.every((r) => r.saldoSicherCents === null && r.saldoErwartetCents === null))
|
||||||
|
assert.ok(rows.every((r) => r.saldoSicherText === '–' && r.saldoErwartetText === '–'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('forecastRows: Kontostand 0 ist ein Kontostand', () => {
|
||||||
|
const { rows, hasBalance } = forecastRows(posten().slice(1, 2), 0)
|
||||||
|
assert.equal(hasBalance, true)
|
||||||
|
assert.deepEqual(rows.map((r) => r.saldoSicherCents), [-1000])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('forecastRows: leer, kaputte Einträge und unbekannte Sicherheit (zählt als erwartet)', () => {
|
||||||
|
for (const none of [null, undefined, [], 'x', {}]) assert.deepEqual(forecastRows(none, 100).rows, [])
|
||||||
|
const { rows } = forecastRows(
|
||||||
|
[
|
||||||
|
null,
|
||||||
|
{ date: '2026-10-03', label: 'ohne Betrag', amountCents: null, sicherheit: 'sicher' },
|
||||||
|
{ date: '2026-10-04', label: ' ', amountCents: 500, sicherheit: 'irgendwas' },
|
||||||
|
],
|
||||||
|
1000
|
||||||
|
)
|
||||||
|
assert.equal(rows.length, 1)
|
||||||
|
assert.equal(rows[0].label, 'Zahlung')
|
||||||
|
assert.equal(rows[0].sicherheit, 'erwartet')
|
||||||
|
assert.equal(rows[0].saldoSicherCents, 1000)
|
||||||
|
assert.equal(rows[0].saldoErwartetCents, 1500)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('forecastRows: Serverreihenfolge bleibt, Schlüssel sind eindeutig', () => {
|
||||||
|
const same = { date: '2026-10-03', label: 'Hetzner', amountCents: -100, sicherheit: 'sicher' }
|
||||||
|
const { rows } = forecastRows([same, same], 500)
|
||||||
|
assert.deepEqual(rows.map((r) => r.saldoSicherCents), [400, 300])
|
||||||
|
assert.notEqual(rows[0].key, rows[1].key)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('forecastNotes: Hinweise zu Kontostand, Stripe und Abos', () => {
|
||||||
|
assert.deepEqual(forecastNotes({ kontostandCents: 100, stripeDown: false, abosFehler: false, hasRows: true }), [])
|
||||||
|
const all = forecastNotes({ kontostandCents: null, stripeDown: true, abosFehler: true, hasRows: true })
|
||||||
|
assert.deepEqual(all.map((n) => n.key), ['kontostand', 'stripe', 'abos'])
|
||||||
|
assert.equal(all[2].text, 'Abo-Verlängerungen fehlen, weil die Abos nicht geladen werden konnten.')
|
||||||
|
assert.match(all[0].text, /Ohne Kontostand/)
|
||||||
|
assert.match(all[1].text, /Stripe/)
|
||||||
|
// ohne Zeilen gibt es nichts zu saldieren
|
||||||
|
assert.deepEqual(forecastNotes({ kontostandCents: null, stripeDown: false, abosFehler: false, hasRows: false }), [])
|
||||||
|
assert.deepEqual(forecastNotes({ kontostandCents: null, stripeDown: false, abosFehler: true, hasRows: false }).map((n) => n.key), ['abos'])
|
||||||
|
assert.deepEqual(forecastNotes({}).map((n) => n.key), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Steuer (Block 4) ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const steuer = (extra = {}, klein = {}) => ({
|
||||||
|
jahr: 2026,
|
||||||
|
einnahmenCents: 16879,
|
||||||
|
ausgabenCents: 5986,
|
||||||
|
gewinnCents: 10893,
|
||||||
|
ruecklageCents: 3268,
|
||||||
|
prozent: 30,
|
||||||
|
gewerbesteuerHinweis: false,
|
||||||
|
kleinunternehmer: {
|
||||||
|
jahrCents: 8000,
|
||||||
|
vorjahrCents: null,
|
||||||
|
grenzeFolgejahrCents: 2500000,
|
||||||
|
grenzeHartCents: 10000000,
|
||||||
|
anteilFolgejahr: 0.0032,
|
||||||
|
...klein,
|
||||||
|
},
|
||||||
|
naechsteVorauszahlung: null,
|
||||||
|
...extra,
|
||||||
|
})
|
||||||
|
|
||||||
|
test('taxView: Rücklage mit Prozentsatz, Gewinn und Jahr aus den echten Werten', () => {
|
||||||
|
const v = taxView(steuer())
|
||||||
|
assert.equal(v.reserve.amountText, '32,68 €')
|
||||||
|
assert.equal(v.reserve.basisText, '30 % von 108,93 € Gewinn 2026')
|
||||||
|
const other = taxView(steuer({ jahr: 2027, prozent: 27.5, gewinnCents: 123456789, ruecklageCents: 100 }))
|
||||||
|
assert.equal(other.reserve.basisText, '27,5 % von 1.234.567,89 € Gewinn 2027')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('taxView: Verlust zeigt den negativen Gewinn mit U+2212', () => {
|
||||||
|
const v = taxView(steuer({ gewinnCents: -5000, ruecklageCents: 0 }))
|
||||||
|
assert.equal(v.reserve.amountText, '0,00 €')
|
||||||
|
assert.equal(v.reserve.basisText, `30 % von ${MINUS}50,00 € Gewinn 2026`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('taxView: Kleinunternehmer-Zähler „<Betrag> von 25.000 € (2026)“ und harte Grenze', () => {
|
||||||
|
const v = taxView(steuer())
|
||||||
|
assert.equal(v.small.known, true)
|
||||||
|
assert.equal(v.small.counterText, '80,00 € von 25.000 € (2026)')
|
||||||
|
assert.equal(v.small.shareText, '0,3 % der Grenze')
|
||||||
|
assert.equal(v.small.hardText, 'Grenze 100.000 €: darüber endet die Regelung sofort.')
|
||||||
|
assert.equal(v.small.previousText, null)
|
||||||
|
assert.equal(taxView(steuer({}, { anteilFolgejahr: 0.704 })).small.shareText, '70,4 % der Grenze')
|
||||||
|
assert.equal(taxView(steuer({}, { jahrCents: 0, anteilFolgejahr: 0 })).small.counterText, '0,00 € von 25.000 € (2026)')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('taxView: Stripe nicht erreichbar -> unbekannt statt 0', () => {
|
||||||
|
const v = taxView(steuer({}, { jahrCents: null, anteilFolgejahr: null }))
|
||||||
|
assert.equal(v.small.known, false)
|
||||||
|
assert.equal(v.small.counterText, 'unbekannt (Stripe nicht erreichbar)')
|
||||||
|
assert.equal(v.small.shareText, null)
|
||||||
|
assert.equal(v.small.hardText, 'Grenze 100.000 €: darüber endet die Regelung sofort.')
|
||||||
|
// nur der Zähler ist unbekannt, der Rest der Karte bleibt
|
||||||
|
assert.equal(v.reserve.amountText, '32,68 €')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('taxView: Vorjahr nur wenn gesetzt, über der Grenze mit Hinweis', () => {
|
||||||
|
assert.equal(taxView(steuer({}, { vorjahrCents: 1200000 })).small.previousText, 'Vorjahr: 12.000,00 €')
|
||||||
|
assert.equal(taxView(steuer({}, { vorjahrCents: 0 })).small.previousText, 'Vorjahr: 0,00 €')
|
||||||
|
assert.equal(taxView(steuer({}, { vorjahrCents: 2500000 })).small.previousText, 'Vorjahr: 25.000,00 €')
|
||||||
|
assert.equal(
|
||||||
|
taxView(steuer({}, { vorjahrCents: 2500001 })).small.previousText,
|
||||||
|
'Vorjahr: 25.000,01 € (über der Grenze von 25.000 €)'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('taxView: Gewerbesteuer-Hinweis nur mit gewerbesteuerHinweis', () => {
|
||||||
|
assert.equal(taxView(steuer()).gewerbeText, null)
|
||||||
|
const t = taxView(steuer({ gewerbesteuerHinweis: true })).gewerbeText
|
||||||
|
assert.match(t, /Gewerbesteuer/)
|
||||||
|
assert.match(t, /24\.500 €/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('taxView: nächste ESt-Vorauszahlung nur wenn gesetzt', () => {
|
||||||
|
assert.equal(taxView(steuer()).prepayment, null)
|
||||||
|
assert.deepEqual(taxView(steuer({ naechsteVorauszahlung: { date: '2026-12-10', amountCents: 100000 } })).prepayment, {
|
||||||
|
dateText: '10.12.2026',
|
||||||
|
amountText: '1.000,00 €',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('taxView: fehlende Teile werden nicht zu Müll', () => {
|
||||||
|
const v = taxView(null)
|
||||||
|
assert.equal(v.reserve.amountText, '–')
|
||||||
|
assert.equal(v.reserve.basisText, '')
|
||||||
|
assert.equal(v.small.known, false)
|
||||||
|
assert.equal(v.gewerbeText, null)
|
||||||
|
assert.equal(v.prepayment, null)
|
||||||
|
// ohne Grenzen aus dem Server gelten die gesetzlichen Werte
|
||||||
|
const bare = taxView({ jahr: 2026, kleinunternehmer: { jahrCents: 100 } })
|
||||||
|
assert.equal(bare.small.counterText, '1,00 € von 25.000 € (2026)')
|
||||||
|
assert.equal(bare.small.hardText, 'Grenze 100.000 €: darüber endet die Regelung sofort.')
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Abos (Block 5) -----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const abos = (extra = {}) => ({
|
||||||
|
monatlichCents: 7000,
|
||||||
|
zahlendeKunden: 1,
|
||||||
|
gefaehrdetCents: 5000,
|
||||||
|
liste: [
|
||||||
|
{ customerId: 'c2', kunde: 'Zwei GmbH', produkt: 'wartung', status: 'past_due', betragCents: 5000, naechsteAbbuchung: null },
|
||||||
|
{ customerId: 'c1', kunde: 'Kunde Eins', produkt: 'hosting', status: 'active', betragCents: 2000, naechsteAbbuchung: '2026-10-21' },
|
||||||
|
],
|
||||||
|
...extra,
|
||||||
|
})
|
||||||
|
|
||||||
|
test('aboView: Kennzahlen und Liste', () => {
|
||||||
|
const v = aboView(abos())
|
||||||
|
assert.equal(v.error, '')
|
||||||
|
assert.equal(v.monthlyText, '70,00 €')
|
||||||
|
assert.equal(v.payingText, '1')
|
||||||
|
assert.equal(v.atRiskText, '50,00 €')
|
||||||
|
assert.equal(v.rows.length, 2)
|
||||||
|
assert.deepEqual(
|
||||||
|
{ ...v.rows[0], key: undefined },
|
||||||
|
{
|
||||||
|
key: undefined,
|
||||||
|
customer: 'Zwei GmbH',
|
||||||
|
product: 'Wartungs-Abo',
|
||||||
|
statusLabel: 'Zahlung fehlgeschlagen',
|
||||||
|
tone: 'danger',
|
||||||
|
amountText: '50,00 €',
|
||||||
|
nextText: '–',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert.deepEqual(
|
||||||
|
{ ...v.rows[1], key: undefined },
|
||||||
|
{ key: undefined, customer: 'Kunde Eins', product: 'Hosting-Abo', statusLabel: 'aktiv', tone: 'ok', amountText: '20,00 €', nextText: '21.10.2026' }
|
||||||
|
)
|
||||||
|
assert.notEqual(v.rows[0].key, v.rows[1].key)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('aboView: „gefährdet“ nur über 0', () => {
|
||||||
|
assert.equal(aboView(abos({ gefaehrdetCents: 0 })).atRiskText, null)
|
||||||
|
assert.equal(aboView(abos({ gefaehrdetCents: null })).atRiskText, null)
|
||||||
|
assert.equal(aboView(abos({ gefaehrdetCents: 1 })).atRiskText, '0,01 €')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('aboView: Ladefehler zeigt den Text des Servers, keine Zahlen', () => {
|
||||||
|
const v = aboView({ monatlichCents: null, zahlendeKunden: null, gefaehrdetCents: null, liste: [], fehler: 'Abos konnten nicht geladen werden.' })
|
||||||
|
assert.equal(v.error, 'Abos konnten nicht geladen werden.')
|
||||||
|
assert.equal(v.monthlyText, '–')
|
||||||
|
assert.equal(v.payingText, '–')
|
||||||
|
assert.equal(v.atRiskText, null)
|
||||||
|
assert.deepEqual(v.rows, [])
|
||||||
|
// leerer Text -> Standardtext
|
||||||
|
assert.equal(aboView({ fehler: true, liste: [] }).error, 'Abos konnten nicht geladen werden.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('aboView: leer oder fehlend', () => {
|
||||||
|
for (const none of [null, undefined, {}, { liste: [] }, { liste: 'x' }]) {
|
||||||
|
const v = aboView(none)
|
||||||
|
assert.deepEqual(v.rows, [])
|
||||||
|
assert.equal(v.error, '')
|
||||||
|
}
|
||||||
|
assert.equal(aboView({ monatlichCents: 0, zahlendeKunden: 0, gefaehrdetCents: 0, liste: [] }).monthlyText, '0,00 €')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('aboView: unbekannter Kunde, unbekanntes Produkt und unbekannter Status', () => {
|
||||||
|
const v = aboView({
|
||||||
|
liste: [
|
||||||
|
{ customerId: '', kunde: '', produkt: '', status: 'trialing', betragCents: 100, naechsteAbbuchung: '2026-11-01' },
|
||||||
|
{ customerId: 'c3', kunde: ' Drei ', produkt: 'sonst', status: 'unpaid', betragCents: null, naechsteAbbuchung: 'kaputt' },
|
||||||
|
{ customerId: 'c4', kunde: 'Vier', produkt: 'hosting', status: 'paused', betragCents: 0, naechsteAbbuchung: null },
|
||||||
|
null,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert.equal(v.rows.length, 3)
|
||||||
|
assert.equal(v.rows[0].customer, 'Unbekannter Kunde')
|
||||||
|
assert.equal(v.rows[0].product, 'Abo')
|
||||||
|
assert.equal(v.rows[0].statusLabel, 'Testphase')
|
||||||
|
assert.equal(v.rows[0].tone, 'info')
|
||||||
|
assert.equal(v.rows[1].customer, 'Drei')
|
||||||
|
assert.equal(v.rows[1].product, 'sonst')
|
||||||
|
assert.equal(v.rows[1].statusLabel, 'unbezahlt')
|
||||||
|
assert.equal(v.rows[1].tone, 'danger')
|
||||||
|
assert.equal(v.rows[1].amountText, '–')
|
||||||
|
assert.equal(v.rows[1].nextText, '–')
|
||||||
|
assert.equal(v.rows[2].statusLabel, 'paused')
|
||||||
|
assert.equal(v.rows[2].tone, 'muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('productLabel und aboStatus', () => {
|
||||||
|
assert.equal(productLabel('hosting'), 'Hosting-Abo')
|
||||||
|
assert.equal(productLabel('wartung'), 'Wartungs-Abo')
|
||||||
|
assert.equal(productLabel(''), 'Abo')
|
||||||
|
assert.equal(productLabel(undefined), 'Abo')
|
||||||
|
assert.equal(productLabel('x_y'), 'x_y')
|
||||||
|
assert.deepEqual(aboStatus('active'), { label: 'aktiv', tone: 'ok' })
|
||||||
|
assert.deepEqual(aboStatus('past_due'), { label: 'Zahlung fehlgeschlagen', tone: 'danger' })
|
||||||
|
assert.deepEqual(aboStatus('weird'), { label: 'weird', tone: 'muted' })
|
||||||
|
assert.deepEqual(aboStatus(''), { label: 'unbekannt', tone: 'muted' })
|
||||||
|
})
|
||||||
Reference in new issue
Block a user