Finanzen: Vorschau, Steuer und Aufgaben genauer (Fixkosten ohne ESt, verspaetete Auszahlungen, Abos nicht ladbar)
M6: fixkosten30Cents zaehlt nur Fixkosten, die ESt-Vorauszahlung bleibt sicherer Posten. M7: verspaetete Auszahlung (Ankunftstag vorbei, noch nicht bezahlt) kommt heute. M8: ohne Stripe sind Kleinunternehmer-Zaehler und Anteil unbekannt (null), keine Warnung daraus. M12: Verbindungsdokument ohne Sitzung zaehlt als keine Verbindung. M13: Buchungen privat/fixkosten gehen nicht in die Fixkosten-Vorschlaege. M14: Vorjahresumsatz ueber 25.000 EUR ist eine hohe Aufgabe. M15: scheitert das Laden der Abos, meldet abos.fehler statt die ganze Uebersicht zu kippen. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
83ee9d5a25
commit
039fdcaf27
6 files changed
+295
-20
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user