diff --git a/server/services/finance/forecast.js b/server/services/finance/forecast.js index 7612ee5..9dc081c 100644 --- a/server/services/finance/forecast.js +++ b/server/services/finance/forecast.js @@ -3,9 +3,11 @@ import { addDays, berlinDate, daysBetween } from './dates.js' /** * Liquiditaetsvorschau (rein, ohne I/O). Zwei Linien ueber denselben Zeitraum (heute bis heute + horizonDays, * beide Tage eingeschlossen): - * - "sicher": Stripe-Auszahlungen, die schon unterwegs sind, Fixkosten und ESt-Vorauszahlungen. + * - "sicher": Stripe-Auszahlungen, die schon unterwegs sind (mit Ankunftstag vor heute: heute), Fixkosten und + * ESt-Vorauszahlungen. * - "erwartet": offene Rechnungen und Abo-Verlaengerungen; sie kommen zu den sicheren Posten dazu. * Betraege sind ganze Cent mit Vorzeichen (+ Zufluss, - Abfluss), Tage YYYY-MM-DD; "heute" kommt von aussen. + * fixkosten30Cents ist die Summe der Fixkosten im Zeitraum (ohne ESt-Vorauszahlungen). */ export const FORECAST_ANNAHME = @@ -105,15 +107,23 @@ export function buildForecast({ if (payout?.status !== 'pending' && payout?.status !== 'in_transit') continue if (!isCents(payout.amountCents) || payout.amountCents === 0) continue const arrival = validYmd(payout.arrivalDate) - if (arrival && inRange(arrival)) add(arrival, 'Stripe-Auszahlung', payout.amountCents, SICHER) + if (!arrival) continue + // Eine noch nicht angekommene Auszahlung mit Ankunftstag vor heute (Stripe ist verspaetet) kommt frueh, nicht nie: heute + const date = arrival < today ? today : arrival + if (inRange(date)) add(date, 'Stripe-Auszahlung', payout.amountCents, SICHER) } + // Nur die Fixkosten zaehlen fuer fixkosten30Cents: die ESt-Vorauszahlung steckt schon in der Steuerruecklage + let fixkosten30Cents = 0 for (const cost of fixedCosts || []) { if (!cost || cost.active === false) continue const step = INTERVAL_MONTHS[cost.interval] const start = validYmd(cost.nextDate) if (!step || !start || !isCents(cost.amountCents) || cost.amountCents <= 0) continue - for (const date of occurrencesIn(start, step, today, end)) add(date, cost.name || 'Fixkosten', -cost.amountCents, SICHER) + for (const date of occurrencesIn(start, step, today, end)) { + add(date, cost.name || 'Fixkosten', -cost.amountCents, SICHER) + fixkosten30Cents += cost.amountCents + } } const prepayment = settings?.estPrepaymentCents @@ -175,8 +185,6 @@ export function buildForecast({ const sicher = line((item) => item.sicherheit === SICHER) const erwartet = line(() => true) - const fixkosten30Cents = posten.reduce((sum, item) => (item.sicherheit === SICHER && item.amountCents < 0 ? sum - item.amountCents : sum), 0) - return { posten, minSicher: sicher.min, diff --git a/server/services/finance/overview.js b/server/services/finance/overview.js index 9b428b8..f00381c 100644 --- a/server/services/finance/overview.js +++ b/server/services/finance/overview.js @@ -15,7 +15,8 @@ import { buildTodos } from './todos.js' * Steuer, Abos und Altersgruppen der Rechnungen. Die Rechnen-Module (forecast, tax, todos, recurring) sind rein; hier * werden nur die Daten geladen (Bank und Einstellungen aus Appwrite, Rechnungen und Auszahlungen von Stripe) und * zusammengesetzt. Scheitert Stripe, rechnet die Übersicht mit leeren Rechnungs- und Auszahlungslisten weiter und - * meldet das in stand.stripe; Appwrite-Fehler gehen als Fehler an den Aufrufer. + * meldet das in stand.stripe; scheitert das Laden der Abos, meldet das abos.fehler. Andere Appwrite-Fehler gehen als Fehler + * an den Aufrufer. */ const SUBSCRIPTION_LIMIT = 500 @@ -75,6 +76,24 @@ async function loadStripe(now, waitMs) { // --- Abos --------------------------------------------------------------------------------------------------- +// Abos aus Appwrite: scheitert das Laden, rechnet die Uebersicht ohne Abos weiter und die Seite zeigt "unbekannt" +const SUBSCRIPTIONS_FAILED = Object.freeze({ + monatlichCents: null, + zahlendeKunden: null, + gefaehrdetCents: null, + liste: Object.freeze([]), + fehler: 'Abos konnten nicht geladen werden.', +}) + +async function loadSubscriptions() { + try { + return { ok: true, docs: await listDocuments(config.collections.subscriptions, [Query.limit(SUBSCRIPTION_LIMIT)]) } + } catch (err) { + logFailure('Abos laden', err) + return { ok: false, docs: [] } + } +} + /** Kundennamen (Firmenname vor Name) zu den Kunden-IDs; ein fehlender oder nicht lesbarer Kunde bekommt den Namen "". */ async function customerNames(ids) { const names = new Map() @@ -175,14 +194,17 @@ const isOpenIncoming = (tx) => tx.status === 'BOOK' && isCents(tx.amountCents) & */ export async function buildOverview({ now = new Date(), stripeWaitMs = STRIPE_WAIT_MS } = {}) { const today = berlinDate(now) - const [connection, transactions, settings, fixedCosts, subscriptions, stripe] = await Promise.all([ + const [storedConnection, transactions, settings, fixedCosts, loadedSubscriptions, stripe] = await Promise.all([ getConnection(), listTransactions(), getSettings(), listFixedCosts(), - listDocuments(config.collections.subscriptions, [Query.limit(SUBSCRIPTION_LIMIT)]), + loadSubscriptions(), loadStripe(now, stripeWaitMs), ]) + // Ein Dokument ohne Sitzung (erste Freigabe begonnen, nie abgeschlossen) ist kein Bankzugang + const connection = trimmed(storedConnection?.sessionId) ? storedConnection : null + const subscriptions = loadedSubscriptions.docs // Kundennamen nur für die Abos der Liste const listed = subscriptions.filter((s) => LISTED_STATUSES.has(s.status)) @@ -202,7 +224,10 @@ export async function buildOverview({ now = new Date(), stripeWaitMs = STRIPE_WA fixedCosts, settings, }) - const tax = computeTax({ today, transactions, invoices: stripe.invoices, settings }) + const computedTax = computeTax({ today, transactions, invoices: stripe.invoices, settings }) + // Der Kleinunternehmer-Zaehler stuetzt sich auf die Zahltage der Stripe-Rechnungen: ohne Stripe waere er zu niedrig und + // wuerde Sicherheit vortaeuschen (die Seite zeigt "unbekannt"), eine Warnung daraus waere falsch + const tax = stripe.ok ? computedTax : { ...computedTax, kleinunternehmer: { ...computedTax.kleinunternehmer, jahrCents: null, anteilFolgejahr: null } } // Zuordnungsvorschläge wie bei GET /buchungen, nur für gebuchte, offene Eingänge; ohne vollständige Stripe-Daten keine const openIncoming = transactions.filter(isOpenIncoming) @@ -214,8 +239,13 @@ export async function buildOverview({ now = new Date(), stripeWaitMs = STRIPE_WA invoices: stripe.invoices, transactions: todoTransactions, subscriptions: named, - recurring: detectRecurring(transactions, { fixedCosts }), + // Was schon zugeordnet ist (privat, Fixkosten), ist kein Vorschlag mehr + recurring: detectRecurring( + transactions.filter((tx) => tx.category !== 'privat' && tx.category !== 'fixkosten'), + { fixedCosts } + ), tax, + settings, }) return { @@ -236,7 +266,7 @@ export async function buildOverview({ now = new Date(), stripeWaitMs = STRIPE_WA aufgaben, vorschau: forecast, steuer: tax, - abos: buildSubscriptions(named), + abos: loadedSubscriptions.ok ? buildSubscriptions(named) : SUBSCRIPTIONS_FAILED, rechnungen: buildInvoiceSummary(stripe.invoices, today), } } diff --git a/server/services/finance/todos.js b/server/services/finance/todos.js index a502426..2de3f32 100644 --- a/server/services/finance/todos.js +++ b/server/services/finance/todos.js @@ -61,7 +61,8 @@ function todo(art, dringlichkeit, titel, text, datum, aktion) { function bankTodos({ today, now, connection }) { const result = [] - if (!connection) { + // Ein Dokument ohne Sitzung (erste Freigabe begonnen, nie abgeschlossen) ist kein Bankzugang + if (!connection || !trimmed(connection.sessionId)) { result.push( todo( 'bank-zugang', @@ -256,6 +257,23 @@ function subscriptionTodos({ today, subscriptions }) { return result } +// Vorjahresumsatz ueber der Grenze: die Kleinunternehmerregelung gilt im laufenden Jahr gar nicht erst (§ 19 UStG) +function previousYearTodos({ today, tax, settings }) { + const revenue = settings?.previousYearRevenueCents + const limit = isCents(tax?.kleinunternehmer?.grenzeFolgejahrCents) ? tax.kleinunternehmer.grenzeFolgejahrCents : KLEIN_LIMIT_CENTS + if (!isCents(revenue) || revenue <= limit) return [] + return [ + todo( + 'kleinunternehmer', + HOCH, + 'Vorjahresumsatz über 25.000 €', + `Im Vorjahr lagen die Einnahmen bei ${euroDe(revenue)}. Damit gilt die Kleinunternehmerregelung (§ 19 UStG) in diesem Jahr nicht. Bitte Rechnungen und Rechnungsfuß mit der Steuerberatung klären.`, + today, + { typ: 'einstellungen' } + ), + ] +} + function smallBusinessTodos({ today, tax }) { const small = tax?.kleinunternehmer const share = small?.anteilFolgejahr @@ -307,15 +325,16 @@ function smallBusinessTodos({ today, tax }) { * @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 {object|null} args.connection Bankverbindung { sessionId, status, validUntil, lastSyncAt, lastSyncOk, lastError } oder null (ohne sessionId zaehlt sie als nicht verbunden) * @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 + * @param {object} [args.settings] Einstellungen { previousYearRevenueCents }: ein Vorjahresumsatz ueber 25.000 EUR ist eine Aufgabe * @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 } = {}) { +export function buildTodos({ today, now = new Date(), connection, invoices, transactions, subscriptions, recurring, tax, settings } = {}) { daysBetween(today, today) // wirft bei einem ungültigen Tag const list = (value) => (Array.isArray(value) ? value : []) const all = [ @@ -325,6 +344,7 @@ export function buildTodos({ today, now = new Date(), connection, invoices, tran ...subscriptionTodos({ today, subscriptions: list(subscriptions) }), ...draftTodos({ today, invoices: list(invoices) }), ...recurringTodos({ today, recurring: list(recurring) }), + ...previousYearTodos({ today, tax, settings }), ...smallBusinessTodos({ today, tax }), ] // Array.prototype.sort ist stabil: bei gleicher Dringlichkeit und gleichem Datum bleibt die Reihenfolge der Arten diff --git a/test/financeForecast.test.js b/test/financeForecast.test.js index f33d21f..bbd6a6c 100644 --- a/test/financeForecast.test.js +++ b/test/financeForecast.test.js @@ -192,7 +192,8 @@ test('ESt-Vorauszahlung 50000: 10.12. bei Horizont 90, keine bei Horizont 30; oh const settings = { estPrepaymentCents: 50000 } const r90 = buildForecast({ today: TODAY, horizonDays: 90, balanceCents: BALANCE, settings }) assert.deepEqual(r90.posten, [{ date: '2026-12-10', label: 'ESt-Vorauszahlung', amountCents: -50000, sicherheit: 'sicher' }]) - assert.equal(r90.fixkosten30Cents, 50000) + // die ESt-Vorauszahlung ist keine Fixkosten-Position: sie steckt schon in der Steuerruecklage (M6) + assert.equal(r90.fixkosten30Cents, 0) assert.deepEqual(r90.minSicher, { amountCents: -39107, date: '2026-12-10' }) assert.deepEqual(buildForecast({ today: TODAY, horizonDays: 30, balanceCents: BALANCE, settings }).posten, []) assert.deepEqual(buildForecast({ today: TODAY, horizonDays: 90, balanceCents: BALANCE, settings: { estPrepaymentCents: 0 } }).posten, []) @@ -205,7 +206,7 @@ test('ESt-Vorauszahlung: alle vier Termine, auch über den Jahreswechsel', () => assert.deepEqual(dates(buildForecast({ today: '2026-12-10', horizonDays: 0, balanceCents: 0, settings: { estPrepaymentCents: 100 } })), ['2026-12-10']) }) -test('Auszahlung in_transit 1500 am 03.10.: sicher +1500; pending zählt, paid/canceled/failed und vergangene nicht', () => { +test('Auszahlung in_transit 1500 am 03.10.: sicher +1500; pending zählt, paid/canceled/failed nicht, eine verspätete (Ankunftstag vorbei) kommt heute (M7)', () => { const payouts = [ { id: 'po_1', amountCents: 1500, arrivalDate: '2026-10-03', status: 'in_transit' }, { id: 'po_2', amountCents: 700, arrivalDate: '2026-10-06', status: 'pending' }, @@ -217,14 +218,52 @@ test('Auszahlung in_transit 1500 am 03.10.: sicher +1500; pending zählt, paid/c ] const r = base({ payouts }) assert.deepEqual(r.posten, [ + { date: TODAY, label: 'Stripe-Auszahlung', amountCents: 999, sicherheit: 'sicher' }, { date: '2026-10-03', label: 'Stripe-Auszahlung', amountCents: 1500, sicherheit: 'sicher' }, { date: '2026-10-06', label: 'Stripe-Auszahlung', amountCents: 700, sicherheit: 'sicher' }, ]) - assert.equal(r.endeSicherCents, 13093) - assert.equal(r.endeErwartetCents, 13093) + assert.equal(r.endeSicherCents, 14092) + assert.equal(r.endeErwartetCents, 14092) assert.equal(r.fixkosten30Cents, 0) }) +test('verspätete Auszahlung: Ankunftstag gestern oder vor Wochen -> heute, auch in_transit; ungültige Tage fallen weg (M7)', () => { + const r = base({ + payouts: [ + { id: 'po_1', amountCents: 4000, arrivalDate: '2026-09-30', status: 'in_transit' }, + { id: 'po_2', amountCents: 500, arrivalDate: '2026-08-01', status: 'pending' }, + { id: 'po_3', amountCents: 900, arrivalDate: 'gestern', status: 'pending' }, + ], + }) + assert.deepEqual(r.posten.map((p) => [p.date, p.amountCents]), [[TODAY, 4000], [TODAY, 500]]) + assert.deepEqual(r.minSicher, { amountCents: 10893, date: TODAY }) + assert.equal(r.endeSicherCents, 15393) + // ein Abfluss am selben Tag kommt weiter zuerst + const mixed = base({ + payouts: [{ id: 'po_1', amountCents: 4000, arrivalDate: '2026-09-30', status: 'in_transit' }], + fixedCosts: [monthly({ nextDate: TODAY })], + }) + assert.deepEqual(mixed.posten.map((p) => p.amountCents), [-449, 4000]) +}) + +test('fixkosten30Cents zählt nur Fixkosten, nicht die ESt-Vorauszahlung; die ESt bleibt ein sicherer Posten (M6)', () => { + const r = buildForecast({ + today: TODAY, + horizonDays: 90, + balanceCents: BALANCE, + settings: { estPrepaymentCents: 50000 }, + fixedCosts: [monthly({ nextDate: '2026-10-10' })], + }) + // 449 monatlich: 10.10., 10.11., 10.12., 10.01. liegt hinter dem Horizont (30.12.) + assert.equal(r.fixkosten30Cents, 3 * 449) + assert.deepEqual(r.posten.filter((p) => p.label === 'ESt-Vorauszahlung'), [{ date: '2026-12-10', label: 'ESt-Vorauszahlung', amountCents: -50000, sicherheit: 'sicher' }]) + // die Linie rechnet die ESt weiter ein: 10893 - 3 * 449 - 50000 + assert.equal(r.endeSicherCents, 10893 - 3 * 449 - 50000) + + // nur ESt, keine Fixkosten: 0 + assert.equal(buildForecast({ today: TODAY, horizonDays: 90, balanceCents: 0, settings: { estPrepaymentCents: 50000 } }).fixkosten30Cents, 0) +}) + test('Sortierung: nach Datum, am selben Tag Abflüsse zuerst', () => { const r = base({ payouts: [{ id: 'po_1', amountCents: 1500, arrivalDate: '2026-10-14', status: 'in_transit' }], diff --git a/test/financeOverview.test.js b/test/financeOverview.test.js index 7441bdb..7d8cea7 100644 --- a/test/financeOverview.test.js +++ b/test/financeOverview.test.js @@ -446,7 +446,10 @@ test('GET /uebersicht: Stripe 500 -> 200, Bankteil, Kopf und Aufgaben aus Bank u assert.equal(body.kopf.kontostandCents, 10893) assert.equal(body.kopf.verfuegbarCents, 4176) assert.deepEqual(body.vorschau.posten.map((p) => p.label), ['Hetzner', 'Porkbun', 'Abo-Verlängerung']) - assert.equal(body.steuer.kleinunternehmer.jahrCents, 0) + // Kleinunternehmer-Zaehler ohne Stripe-Zahltage waere zu niedrig: unbekannt statt 0 (M8) + assert.equal(body.steuer.kleinunternehmer.jahrCents, null) + assert.equal(body.steuer.kleinunternehmer.anteilFolgejahr, null) + assert.equal(body.steuer.kleinunternehmer.grenzeFolgejahrCents, 2500000) assert.deepEqual(body.rechnungen.altersgruppen.map((g) => g.anzahl), [0, 0, 0, 0]) assert.deepEqual(body.rechnungen.entwuerfe, { anzahl: 0, summeCents: 0 }) @@ -487,6 +490,136 @@ test('buildOverview: Stripe antwortet rechtzeitig -> ok, die Wartezeit haelt den assert.equal(body.stand.stripe.ok, true) }) +test('GET /uebersicht: ohne Stripe kein Kleinunternehmer-Zähler und keine Warnung daraus, mit Stripe schon (M8)', async () => { + seedConnection() + seedSettings() + // 19.000 EUR "Rechnung ausserhalb von Stripe" im laufenden Jahr = 76 % der Grenze + aw.seed(config.collections.bankTransactions, tx('x1', { amountCents: 1_900_000, category: 'rechnung-extern', counterpartyName: 'GROSSKUNDE AG' })) + + const ok = await uebersicht() + assert.equal(ok.steuer.kleinunternehmer.jahrCents, 1_900_000) + assert.equal(ok.steuer.kleinunternehmer.anteilFolgejahr, 0.76) + assert.equal(ok.aufgaben.filter((t) => t.art === 'kleinunternehmer').length, 1) + + invalidateFinanceCache() + stripeDown() + const down = await uebersicht() + assert.equal(down.stand.stripe.ok, false) + assert.equal(down.steuer.kleinunternehmer.jahrCents, null) + assert.equal(down.steuer.kleinunternehmer.anteilFolgejahr, null) + assert.deepEqual(down.aufgaben.filter((t) => t.art === 'kleinunternehmer'), []) + // Gewinn und Ruecklage kommen aus der Bank und bleiben + assert.equal(down.steuer.einnahmenCents, 1_900_000) + assert.equal(down.steuer.ruecklageCents, ok.steuer.ruecklageCents) +}) + +test('GET /uebersicht: Verbindungsdokument ohne Sitzung -> wie keine Verbindung, Aufgabe "Bankkonto verbinden" (M12)', async () => { + aw.seed(config.collections.bankConnections, { $id: 'finom', authState: 'abc123', authStartedAt: new Date().toISOString() }) + const body = await uebersicht() + assert.equal(body.stand.bank, null) + assert.equal(body.kopf.kontostandCents, null) + assert.equal(body.kopf.verfuegbarCents, null) + assert.deepEqual(ART(body), ['bank-zugang']) + assert.equal(body.aufgaben[0].titel, 'Bankkonto verbinden') + + // mit Sitzung (auch bei einer neuen Freigabe in Arbeit) bleibt es ein Zugang + await deleteDocument(config.collections.bankConnections, 'finom') + seedConnection({ authState: 'neu', authStartedAt: new Date().toISOString() }) + const connected = await uebersicht() + assert.equal(connected.stand.bank.status, 'aktiv') + assert.ok(!ART(connected).includes('bank-zugang')) +}) + +test('GET /uebersicht: Buchungen "privat" und "fixkosten" zählen nicht für Fixkosten-Vorschläge (M13)', async () => { + seedConnection() + const hetzner = (id, days, over = {}) => tx(id, { bookingDate: at(days), amountCents: -1000, counterpartyName: 'HETZNER ONLINE GMBH', ...over }) + const seedThree = (prefix, over = {}) => [-58, -28, 0].forEach((days, i) => aw.seed(config.collections.bankTransactions, hetzner(`${prefix}${i}`, days, over))) + + seedThree('open') + const suggested = (await uebersicht()).aufgaben.filter((t) => t.art === 'fixkosten-vorschlag') + assert.equal(suggested.length, 1) + assert.match(suggested[0].text, /HETZNER ONLINE GMBH/) + for (const id of ['open0', 'open1', 'open2']) await deleteDocument(config.collections.bankTransactions, id) + + seedThree('privat', { category: 'privat' }) + assert.deepEqual((await uebersicht()).aufgaben.filter((t) => t.art === 'fixkosten-vorschlag'), []) + for (const id of ['privat0', 'privat1', 'privat2']) await deleteDocument(config.collections.bankTransactions, id) + + seedThree('fix', { category: 'fixkosten', fixedCostId: 'fk1' }) + assert.deepEqual((await uebersicht()).aufgaben.filter((t) => t.art === 'fixkosten-vorschlag'), []) + for (const id of ['fix0', 'fix1', 'fix2']) await deleteDocument(config.collections.bankTransactions, id) + + // zwei private und eine offene: ohne den Filter waeren es drei gleiche Abbuchungen + aw.seed(config.collections.bankTransactions, hetzner('m0', -58, { category: 'privat' })) + aw.seed(config.collections.bankTransactions, hetzner('m1', -28, { category: 'privat' })) + aw.seed(config.collections.bankTransactions, hetzner('m2', 0)) + assert.deepEqual((await uebersicht()).aufgaben.filter((t) => t.art === 'fixkosten-vorschlag'), []) +}) + +test('GET /uebersicht: Vorjahresumsatz über 25.000 € aus den Einstellungen wird eine hohe Aufgabe (M14)', async () => { + seedConnection() + seedSettings({ previousYearRevenueCents: 2_600_000 }) + const body = await uebersicht() + const todo = body.aufgaben.find((t) => t.titel === 'Vorjahresumsatz über 25.000 €') + assert.ok(todo, JSON.stringify(body.aufgaben)) + assert.equal(todo.art, 'kleinunternehmer') + assert.equal(todo.dringlichkeit, 'hoch') + assert.match(todo.text, /26\.000,00/) + // hoch steht vor den mittleren Bank-Aufgaben + assert.equal(body.aufgaben[0].dringlichkeit, 'hoch') + + await deleteDocument(config.collections.financeSettings, 'main') + seedSettings({ previousYearRevenueCents: 2_500_000 }) + assert.ok(!(await uebersicht()).aufgaben.some((t) => t.titel === 'Vorjahresumsatz über 25.000 €')) +}) + +test('GET /uebersicht: Abos nicht ladbar -> 200 mit abos.fehler, der Rest bleibt, nichts aus Abos in Vorschau und Aufgaben (M15)', async () => { + seedAll() + seedSub('sub_c', { customerId: 'c1', status: 'past_due' }) + const error = mock.method(console, 'error', () => {}) + aw.failNext({ collection: config.collections.subscriptions, method: 'GET', status: 500, message: 'Appwrite kaputt 10.0.0.9' }) + const res = await rawGet(`${base}/uebersicht`, admin) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(res.body.abos, { + monatlichCents: null, + zahlendeKunden: null, + gefaehrdetCents: null, + liste: [], + fehler: 'Abos konnten nicht geladen werden.', + }) + assert.ok(!JSON.stringify(res.body).includes('10.0.0.9')) + assert.equal(res.body.stand.bank.balanceBookedCents, 10893) + assert.equal(res.body.stand.stripe.ok, true) + assert.ok(!ART(res.body).includes('abo-zahlung')) + assert.ok(!res.body.vorschau.posten.some((p) => p.label === 'Abo-Verlängerung')) + assert.ok(res.body.vorschau.posten.some((p) => p.label === 'Porkbun'), 'Fixkosten bleiben') + assert.ok(ART(res.body).includes('ueberfaellig')) + assert.equal(error.mock.callCount(), 1) + assert.deepEqual(error.mock.calls[0].arguments, ['[finanzen] Abos laden fehlgeschlagen:', 'Error', 500, 500]) + + // der naechste Aufruf laedt wieder und hat kein fehler-Feld + mock.restoreAll() + const again = await uebersicht() + assert.equal(again.abos.fehler, undefined) + assert.equal(again.abos.liste.length, 3) +}) + +test('buildOverview: ESt-Vorauszahlung bleibt ein sicherer Posten, ist aber nicht in fixkosten30Cents und verfuegbarCents (M6)', async () => { + seedConnection() + seedSettings({ estPrepaymentCents: 50_000, minBufferCents: 0 }) + aw.seed(config.collections.financeFixedCosts, { $id: 'fk1', name: 'Hetzner', amountCents: 449, interval: 'monat', nextDate: '2026-11-25', counterpartyMatch: '', active: true }) + const body = await buildOverview({ now: new Date('2026-11-20T10:00:00Z') }) + assert.deepEqual(body.vorschau.posten.map((p) => [p.date, p.label, p.amountCents]), [ + ['2026-11-25', 'Hetzner', -449], + ['2026-12-10', 'ESt-Vorauszahlung', -50_000], + ]) + assert.equal(body.vorschau.fixkosten30Cents, 449) + assert.equal(body.kopf.aufschluesselung.fixkosten30Cents, 449) + // 10.893 - Ruecklage 0 - Fixkosten 449 - Puffer 0: die ESt ist Sache der Ruecklage, nicht noch einmal abgezogen + assert.equal(body.kopf.verfuegbarCents, 10893 - 449) + assert.equal(body.vorschau.endeSicherCents, 10893 - 449 - 50_000) +}) + test('GET /uebersicht: nur die Auszahlungen scheitern -> Rechnungen bleiben, Stripe-Block meldet den Fehler', async () => { seedAll() st.failNext({ method: 'GET', path: '/v1/payouts', status: 400, message: 'Geheimer Stripe-Text' }) diff --git a/test/financeTodos.test.js b/test/financeTodos.test.js index 2db97ec..6811458 100644 --- a/test/financeTodos.test.js +++ b/test/financeTodos.test.js @@ -9,7 +9,7 @@ const NOW = new Date('2026-10-01T10:00:00.000Z') const HOUR = 3_600_000 const ago = (hours) => new Date(NOW.getTime() - hours * HOUR).toISOString() -const connection = (extra = {}) => ({ status: 'aktiv', validUntil: '2027-03-29T00:00:00.000Z', lastSyncAt: ago(1), lastSyncOk: true, lastError: '', ...extra }) +const connection = (extra = {}) => ({ sessionId: 'sess-1', status: 'aktiv', validUntil: '2027-03-29T00:00:00.000Z', lastSyncAt: ago(1), lastSyncOk: true, lastError: '', ...extra }) const invoice = (extra = {}) => ({ id: 'in_1', number: 'WK-2026-0001', status: 'ueberfaellig', customerName: 'Muster GmbH', createdDate: '2026-08-01', dueDate: addDays(TODAY, -5), remainingCents: 50000, paidCents: 0, paidDate: null, overdueDays: 5, source: 'projekt', hostedUrl: 'https://pay.example/in_1', ...extra, @@ -51,6 +51,19 @@ test('bank-zugang: keine Verbindung -> „Bankkonto verbinden“ hoch', () => { } }) +test('bank-zugang: Verbindungsdokument ohne Sitzung (erste Freigabe nie abgeschlossen) zählt als keine Verbindung (M12)', () => { + for (const abandoned of [{ sessionId: undefined }, { sessionId: null }, { sessionId: '' }, { sessionId: ' ' }]) { + // sonst kaeme "Bankabruf veraltet", weil es nie einen Abruf gab: es bleibt bei der einen Aufgabe + const list = todos({ connection: { authState: 'abc', authStartedAt: '2026-10-01T09:00:00.000Z', ...abandoned } }) + assert.equal(list.length, 1, JSON.stringify(list)) + assert.equal(list[0].titel, 'Bankkonto verbinden') + assert.equal(list[0].dringlichkeit, 'hoch') + assert.deepEqual(list[0].aktion, { typ: 'bank-verbinden' }) + } + // mit Sitzung ist es ein Zugang (hier ruhig) + assert.deepEqual(todos({ connection: connection({ sessionId: 'sess-x' }) }), []) +}) + test('bank-zugang: Zugang gültig bis 20.10. (19 Tage) -> mittel, bis 10.10. (9 Tage) -> hoch', () => { const mid = only(todos({ connection: connection({ validUntil: '2026-10-20T00:00:00.000Z' }) }), 'bank-zugang') assert.equal(mid.length, 1) @@ -358,6 +371,38 @@ test('kleinunternehmer: über 100.000 Euro nennt die harte Grenze', () => { ) }) +test('kleinunternehmer: Vorjahresumsatz über 25.000 € -> hoch mit festem Titel und Text (M14)', () => { + const list = only(todos({ settings: { previousYearRevenueCents: 2_600_050 } }), 'kleinunternehmer') + assert.equal(list.length, 1) + assert.deepEqual(list[0], { + art: 'kleinunternehmer', + dringlichkeit: 'hoch', + titel: 'Vorjahresumsatz über 25.000 €', + text: `Im Vorjahr lagen die Einnahmen bei ${euroDe(2_600_050)}. Damit gilt die Kleinunternehmerregelung (§ 19 UStG) in diesem Jahr nicht. Bitte Rechnungen und Rechnungsfuß mit der Steuerberatung klären.`, + datum: TODAY, + aktion: { typ: 'einstellungen' }, + }) + assert.match(list[0].text, /26\.000,50/) +}) + +test('kleinunternehmer: Vorjahresumsatz genau 25.000 €, darunter, fehlend oder unlesbar -> keine Vorjahres-Aufgabe (M14)', () => { + for (const previousYearRevenueCents of [2_500_000, 100, 0, null, undefined, '3000000', 30_000.5]) { + assert.deepEqual(only(todos({ settings: { previousYearRevenueCents } }), 'kleinunternehmer'), [], String(previousYearRevenueCents)) + } + assert.deepEqual(only(todos({ settings: null }), 'kleinunternehmer'), []) + assert.deepEqual(only(todos(), 'kleinunternehmer'), []) + // 25.000,01 € ist darüber + assert.equal(only(todos({ settings: { previousYearRevenueCents: 2_500_001 } }), 'kleinunternehmer').length, 1) +}) + +test('kleinunternehmer: Vorjahres-Aufgabe und Aufgabe zum laufenden Jahr stehen nebeneinander, hoch vor mittel (M14)', () => { + const list = only(todos({ tax: tax(0.72), settings: { previousYearRevenueCents: 3_000_000 } }), 'kleinunternehmer') + assert.deepEqual(list.map((t) => [t.dringlichkeit, t.titel]), [ + ['hoch', 'Vorjahresumsatz über 25.000 €'], + ['mittel', 'Kleinunternehmer-Grenze rückt näher'], + ]) +}) + test('kleinunternehmer: ohne tax oder mit unlesbarem Anteil keine Aufgabe', () => { assert.deepEqual(only(todos({ tax: null }), 'kleinunternehmer'), []) assert.deepEqual(only(todos({ tax: {} }), 'kleinunternehmer'), [])