Finanzen: Uebersicht und Rechnungsrouten fuer WOMS
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7dbf2ce75f
commit
3c5e06723f
7 files changed
+1194
-34
No files matched your search
+104
-29
@@ -3,8 +3,9 @@ import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||
import { withLocks } from '../../services/keyedLock.js'
|
||||
import { EnableBankingError } from '../../services/finance/enableBanking.js'
|
||||
import { completeBankAuth, startBankAuth, syncBank } from '../../services/finance/bankSync.js'
|
||||
import { getConnection, listTransactions, saveConnection } from '../../services/finance/bankStore.js'
|
||||
import { getConnection, listTransactions, publicConnection, saveConnection } from '../../services/finance/bankStore.js'
|
||||
import { AssignError, assignTransaction, unassignTransaction, withSuggestions } from '../../services/finance/assign.js'
|
||||
import { buildOverview } from '../../services/finance/overview.js'
|
||||
import {
|
||||
SettingsError,
|
||||
createFixedCost,
|
||||
@@ -14,11 +15,20 @@ import {
|
||||
patchSettings,
|
||||
updateFixedCost,
|
||||
} from '../../services/finance/settings.js'
|
||||
import {
|
||||
InvoiceError,
|
||||
STRIPE_UNAVAILABLE_MESSAGE,
|
||||
createProjectInvoice,
|
||||
deleteDraft,
|
||||
listInvoices,
|
||||
sendInvoiceNow,
|
||||
voidInvoice,
|
||||
} from '../../services/finance/stripeFinance.js'
|
||||
|
||||
/**
|
||||
* Finanzen im WOMS (nur Admins): Bankstatus, Abruf, Neuverbindung, Buchungen und deren Zuordnung, Einstellungen und
|
||||
* Fixkosten. Die Handler sind klein und rufen die Dienste in server/services/finance auf; weitere Finanz-Routen kommen
|
||||
* in dieselbe Datei.
|
||||
* Finanzen im WOMS (nur Admins): Übersicht, Bankstatus, Abruf, Neuverbindung, Buchungen und deren Zuordnung,
|
||||
* Rechnungen (lesen, anlegen, senden, stornieren, Entwurf löschen), Einstellungen und Fixkosten. Die Handler sind klein
|
||||
* und rufen die Dienste in server/services/finance auf.
|
||||
*/
|
||||
const router = Router()
|
||||
|
||||
@@ -32,29 +42,21 @@ const MANUAL_SYNC_LOCK = ['finanzen-bank-manual-sync']
|
||||
// (z. B. 401) und alles, was Sitzungen verraten koennte, bleibt auf dem Server
|
||||
const BANK_UNAVAILABLE_MESSAGE = 'Die Bank hat die Freigabe nicht bestätigt. Bitte erneut verbinden.'
|
||||
|
||||
/** Was die Seite vom Bankzugang sieht: nie Sitzung, IBAN, state oder Konto-Kennungen. */
|
||||
function publicConnection(connection) {
|
||||
const pick = (key) => connection[key] ?? null
|
||||
return {
|
||||
status: pick('status'),
|
||||
ibanMasked: pick('ibanMasked'),
|
||||
validUntil: pick('validUntil'),
|
||||
lastSyncAt: pick('lastSyncAt'),
|
||||
lastSyncOk: pick('lastSyncOk'),
|
||||
lastError: pick('lastError'),
|
||||
balanceBookedCents: pick('balanceBookedCents'),
|
||||
balanceAvailableCents: pick('balanceAvailableCents'),
|
||||
balanceDate: pick('balanceDate'),
|
||||
latestBookingDate: pick('latestBookingDate'),
|
||||
}
|
||||
}
|
||||
|
||||
/** Dokument ohne Appwrite-Metafelder ($createdAt, $permissions, ...); nur $id bleibt. */
|
||||
const publicDoc = (doc) => Object.fromEntries(Object.entries(doc).filter(([key]) => key === '$id' || !key.startsWith('$')))
|
||||
|
||||
/** Fehlerart fuers Protokoll: nie die Meldung (sie koennte Kennungen der Bank oder von Appwrite enthalten). */
|
||||
const logFailure = (what, err) => console.error(`[finanzen] ${what} fehlgeschlagen:`, err?.name || 'Error', err?.status ?? '', err?.code ?? '')
|
||||
|
||||
router.get('/uebersicht', async (_req, res) => {
|
||||
try {
|
||||
return res.json(await buildOverview())
|
||||
} catch (err) {
|
||||
logFailure('Übersicht laden', err)
|
||||
return res.status(500).json({ error: 'Die Übersicht konnte nicht geladen werden.' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/bank', async (_req, res) => {
|
||||
try {
|
||||
const connection = await getConnection()
|
||||
@@ -133,18 +135,24 @@ router.get('/buchungen', async (_req, res) => {
|
||||
})
|
||||
|
||||
const STRIPE_REJECTED_MESSAGE = 'Stripe hat die Änderung abgelehnt.'
|
||||
const STRIPE_UNAVAILABLE_MESSAGE = 'Stripe ist gerade nicht erreichbar.'
|
||||
|
||||
// Fehler des Stripe-Pakets (StripeInvalidRequestError, StripeConnectionError, ...) tragen type "Stripe..."
|
||||
const isStripeError = (err) => typeof err?.type === 'string' && err.type.startsWith('Stripe')
|
||||
|
||||
/**
|
||||
* Antwort auf einen Fehler beim Zuordnen: AssignError (eigene, anzeigbare Meldung) mit seinem Status; Stripe lehnt
|
||||
* ab (4xx, ausser 429) -> 409; Stripe sonst nicht erreichbar -> 502; alles andere 500. Meldungen von Stripe, Appwrite
|
||||
* und Netzfehlern (Adressen, Schluessel) gehen nie an die Seite.
|
||||
* Antwort auf einen Fehler beim Zuordnen und bei den Rechnungen: AssignError und InvoiceError (eigene, anzeigbare
|
||||
* Meldung) mit ihrem Status, bei einer festgeschriebenen, aber nicht gesendeten Rechnung (502) samt invoiceId und
|
||||
* invoiceNumber; Stripe lehnt ab (4xx, ausser 429) -> 409; Stripe sonst nicht erreichbar -> 502; alles andere 500.
|
||||
* Meldungen von Stripe, Appwrite und Netzfehlern (Adressen, Schluessel) gehen nie an die Seite, auch wenn ihre
|
||||
* Fehler einen status tragen: nur die beiden eigenen Fehlerarten werden weitergegeben.
|
||||
*/
|
||||
function sendAssignError(res, what, err) {
|
||||
if (err instanceof AssignError) return res.status(err.status).json({ error: err.message })
|
||||
function sendFinanceError(res, what, err) {
|
||||
if (err instanceof AssignError || err instanceof InvoiceError) {
|
||||
const body = { error: err.message }
|
||||
if (err.invoiceId) body.invoiceId = err.invoiceId
|
||||
if (err.invoiceNumber !== undefined) body.invoiceNumber = err.invoiceNumber
|
||||
return res.status(err.status).json(body)
|
||||
}
|
||||
logFailure(what, err)
|
||||
if (isStripeError(err)) {
|
||||
const status = err.statusCode
|
||||
@@ -160,7 +168,7 @@ router.post('/buchungen/:id/zuordnen', async (req, res) => {
|
||||
const buchung = await assignTransaction(req.params.id, { invoiceId, payoutId, fixedCostId, category, note })
|
||||
return res.json({ buchung: publicDoc(buchung) })
|
||||
} catch (err) {
|
||||
return sendAssignError(res, 'Buchung zuordnen', err)
|
||||
return sendFinanceError(res, 'Buchung zuordnen', err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -168,7 +176,74 @@ router.delete('/buchungen/:id/zuordnung', async (req, res) => {
|
||||
try {
|
||||
return res.json({ buchung: publicDoc(await unassignTransaction(req.params.id)) })
|
||||
} catch (err) {
|
||||
return sendAssignError(res, 'Zuordnung lösen', err)
|
||||
return sendFinanceError(res, 'Zuordnung lösen', err)
|
||||
}
|
||||
})
|
||||
|
||||
// --- Rechnungen (Stripe) ----------------------------------------------------------------------------------------
|
||||
|
||||
const INVOICE_FILTERS = new Set(['alle', 'offen', 'ueberfaellig', 'bezahlt', 'entwurf'])
|
||||
const DEFAULT_DAYS_UNTIL_DUE = 14
|
||||
const createdOf = (invoice) => invoice.createdDate ?? ''
|
||||
|
||||
router.get('/rechnungen', async (req, res) => {
|
||||
const status = req.query.status || 'alle'
|
||||
const customer = req.query.kunde || ''
|
||||
if (!INVOICE_FILTERS.has(status)) {
|
||||
return res.status(400).json({ error: 'Unbekannter Filter (erlaubt: alle, offen, ueberfaellig, bezahlt, entwurf).' })
|
||||
}
|
||||
if (typeof customer !== 'string') return res.status(400).json({ error: 'Ungültiger Kunde.' })
|
||||
try {
|
||||
const invoices = (await listInvoices())
|
||||
.filter((invoice) => (status === 'alle' || invoice.status === status) && (!customer || invoice.webklarCustomerId === customer))
|
||||
// neueste zuerst (der Sortieralgorithmus ist stabil: am selben Tag bleibt Stripes Reihenfolge)
|
||||
.sort((a, b) => (createdOf(a) < createdOf(b) ? 1 : createdOf(a) > createdOf(b) ? -1 : 0))
|
||||
return res.json({ rechnungen: invoices })
|
||||
} catch (err) {
|
||||
return sendFinanceError(res, 'Rechnungen laden', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/rechnungen', async (req, res) => {
|
||||
const { customerId, items, daysUntilDue, memo, senden } = req.body || {}
|
||||
try {
|
||||
const rechnung = await createProjectInvoice({
|
||||
customerId,
|
||||
items,
|
||||
daysUntilDue: daysUntilDue ?? DEFAULT_DAYS_UNTIL_DUE,
|
||||
memo,
|
||||
// Nur ein echtes Ja sendet sofort: alles andere bleibt ein Entwurf
|
||||
senden: senden === true || senden === 'true',
|
||||
createdBy: req.appwriteUser?.email,
|
||||
})
|
||||
return res.json({ rechnung })
|
||||
} catch (err) {
|
||||
return sendFinanceError(res, 'Rechnung anlegen', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/rechnungen/:id/senden', async (req, res) => {
|
||||
try {
|
||||
return res.json({ rechnung: await sendInvoiceNow(req.params.id) })
|
||||
} catch (err) {
|
||||
return sendFinanceError(res, 'Rechnung senden', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/rechnungen/:id/stornieren', async (req, res) => {
|
||||
try {
|
||||
return res.json({ rechnung: await voidInvoice(req.params.id) })
|
||||
} catch (err) {
|
||||
return sendFinanceError(res, 'Rechnung stornieren', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/rechnungen/:id', async (req, res) => {
|
||||
try {
|
||||
await deleteDraft(req.params.id)
|
||||
return res.json({ ok: true })
|
||||
} catch (err) {
|
||||
return sendFinanceError(res, 'Entwurf löschen', err)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -59,6 +59,23 @@ async function upsert(collectionId, id, patch, createData) {
|
||||
return { how: 'aktualisiert', doc: await updateDocument(collectionId, id, patch) }
|
||||
}
|
||||
|
||||
/** Was die Seite vom Bankzugang sieht (GET /bank und die Statuszeile der Übersicht): nie Sitzung, IBAN, state oder Konto-Kennungen. */
|
||||
export function publicConnection(connection) {
|
||||
const pick = (key) => connection[key] ?? null
|
||||
return {
|
||||
status: pick('status'),
|
||||
ibanMasked: pick('ibanMasked'),
|
||||
validUntil: pick('validUntil'),
|
||||
lastSyncAt: pick('lastSyncAt'),
|
||||
lastSyncOk: pick('lastSyncOk'),
|
||||
lastError: pick('lastError'),
|
||||
balanceBookedCents: pick('balanceBookedCents'),
|
||||
balanceAvailableCents: pick('balanceAvailableCents'),
|
||||
balanceDate: pick('balanceDate'),
|
||||
latestBookingDate: pick('latestBookingDate'),
|
||||
}
|
||||
}
|
||||
|
||||
/** Der Bankzugang oder null, wenn es noch keinen gibt. */
|
||||
export async function getConnection() {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { config } from '../../config.js'
|
||||
import { getDocument, listDocuments, Query } from '../appwriteAdmin.js'
|
||||
import { withSuggestions } from './assign.js'
|
||||
import { getConnection, listTransactions, publicConnection } from './bankStore.js'
|
||||
import { berlinDate, daysBetween } from './dates.js'
|
||||
import { buildForecast } from './forecast.js'
|
||||
import { detectRecurring } from './recurring.js'
|
||||
import { getSettings, listFixedCosts } from './settings.js'
|
||||
import { listInvoices, listPayouts, STRIPE_UNAVAILABLE_MESSAGE } from './stripeFinance.js'
|
||||
import { computeTax } from './tax.js'
|
||||
import { buildTodos } from './todos.js'
|
||||
|
||||
/**
|
||||
* Alles für die Seite /finance in einer Antwort (GET /uebersicht): Statuszeile, Liquiditätskopf, Aufgaben, Vorschau,
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const SUBSCRIPTION_LIMIT = 500
|
||||
// Abos in der Liste: laufende (auch in der Testphase) und solche mit Zahlungsproblem; beendete fehlen
|
||||
const LISTED_STATUSES = new Set(['active', 'trialing', 'past_due', 'unpaid'])
|
||||
const NAME_BATCH = 10 // Kundennamen: höchstens so viele Anfragen an Appwrite gleichzeitig
|
||||
const AGE_GROUPS = ['nicht-faellig', '1-30', '31-60', 'ueber-60']
|
||||
|
||||
const isCents = (n) => Number.isSafeInteger(n)
|
||||
const trimmed = (value) => (typeof value === 'string' ? value.trim() : '')
|
||||
|
||||
/** Fehlerart fürs Protokoll: nie die Meldung (sie könnte Adressen oder Schlüssel enthalten). */
|
||||
const logFailure = (what, err) => console.error(`[finanzen] ${what} fehlgeschlagen:`, err?.name || 'Error', err?.statusCode ?? err?.status ?? '', err?.code ?? '')
|
||||
|
||||
/** Zeitstempel (ISO-Text) als Berliner Kalendertag, null bei fehlendem oder unlesbarem Wert. */
|
||||
function berlinDayOf(iso) {
|
||||
if (!iso) return null
|
||||
const date = new Date(iso)
|
||||
return Number.isNaN(date.getTime()) ? null : berlinDate(date)
|
||||
}
|
||||
|
||||
// --- Stripe -------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Rechnungen und Auszahlungen; was nicht kommt, ist eine leere Liste und macht ok false. */
|
||||
async function loadStripe(now) {
|
||||
const [invoices, payouts] = await Promise.allSettled([listInvoices(), listPayouts()])
|
||||
if (invoices.status === 'rejected') logFailure('Rechnungen laden', invoices.reason)
|
||||
if (payouts.status === 'rejected') logFailure('Auszahlungen laden', payouts.reason)
|
||||
const ok = invoices.status === 'fulfilled' && payouts.status === 'fulfilled'
|
||||
return {
|
||||
ok,
|
||||
invoices: invoices.status === 'fulfilled' ? invoices.value : [],
|
||||
payouts: payouts.status === 'fulfilled' ? payouts.value : [],
|
||||
// abgerufenUm: wann der Stand geholt wurde; bei einem Fehler gibt es keinen
|
||||
status: ok ? { ok: true, abgerufenUm: now.toISOString() } : { ok: false, fehler: STRIPE_UNAVAILABLE_MESSAGE, abgerufenUm: null },
|
||||
}
|
||||
}
|
||||
|
||||
// --- Abos ---------------------------------------------------------------------------------------------------
|
||||
|
||||
/** 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()
|
||||
for (let i = 0; i < ids.length; i += NAME_BATCH) {
|
||||
await Promise.all(
|
||||
ids.slice(i, i + NAME_BATCH).map(async (id) => {
|
||||
try {
|
||||
const customer = await getDocument(config.collections.customers, id)
|
||||
names.set(id, trimmed(customer.companyName) || trimmed(customer.name))
|
||||
} catch (err) {
|
||||
if (err?.status !== 404) logFailure('Kundenname laden', err)
|
||||
names.set(id, '')
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag der nächsten Abbuchung (Berliner Tag) oder null: Periodenende eines laufenden Abos, solange keine Kündigung
|
||||
* davor greift (wie in forecast.js: ein gesetztes cancelAt gilt allein; cancelAtPeriodEnd ohne cancelAt heißt, dass
|
||||
* zum Periodenende nichts mehr abgebucht wird). Bei Zahlungsproblemen steht kein Termin fest.
|
||||
*/
|
||||
function nextCharge(subscription) {
|
||||
if (subscription.status !== 'active' && subscription.status !== 'trialing') return null
|
||||
const periodEnd = berlinDayOf(subscription.currentPeriodEnd)
|
||||
if (!periodEnd) return null
|
||||
const cancelAt = berlinDayOf(subscription.cancelAt)
|
||||
if (!cancelAt && subscription.cancelAtPeriodEnd) return null
|
||||
if (cancelAt && periodEnd >= cancelAt) return null
|
||||
return periodEnd
|
||||
}
|
||||
|
||||
const RISK_ORDER = { past_due: 0, unpaid: 0 }
|
||||
|
||||
function buildSubscriptions(subscriptions) {
|
||||
const amount = (subscription) => (isCents(subscription.priceAmount) ? subscription.priceAmount : 0)
|
||||
const sumOf = (statuses) => subscriptions.filter((s) => statuses.includes(s.status)).reduce((sum, s) => sum + amount(s), 0)
|
||||
const payingCustomers = new Set(subscriptions.filter((s) => s.status === 'active' && s.customerId).map((s) => s.customerId))
|
||||
const liste = subscriptions
|
||||
.map((s) => ({
|
||||
customerId: s.customerId ?? '',
|
||||
kunde: s.customerName,
|
||||
produkt: s.productKey ?? s.planKey ?? '',
|
||||
status: s.status,
|
||||
betragCents: amount(s),
|
||||
naechsteAbbuchung: nextCharge(s),
|
||||
}))
|
||||
// Zahlungsprobleme zuerst, dann nach Kunde (Reihenfolge der Eingabe bei Gleichstand)
|
||||
.sort((a, b) => (RISK_ORDER[a.status] ?? 1) - (RISK_ORDER[b.status] ?? 1) || a.kunde.localeCompare(b.kunde, 'de', { sensitivity: 'base' }))
|
||||
return {
|
||||
monatlichCents: sumOf(['active', 'past_due']),
|
||||
zahlendeKunden: payingCustomers.size,
|
||||
gefaehrdetCents: sumOf(['past_due']),
|
||||
liste,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Rechnungen ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Tage im Verzug zum Stichtag (0 = nicht fällig); ohne lesbares Fälligkeitsdatum gilt der Wert aus dem Mapping. */
|
||||
function daysLate(invoice, today) {
|
||||
try {
|
||||
return Math.max(0, daysBetween(invoice.dueDate, today))
|
||||
} catch {
|
||||
return invoice.status === 'ueberfaellig' && isCents(invoice.overdueDays) ? invoice.overdueDays : 0
|
||||
}
|
||||
}
|
||||
|
||||
const ageGroupOf = (late) => (late <= 0 ? 'nicht-faellig' : late <= 30 ? '1-30' : late <= 60 ? '31-60' : 'ueber-60')
|
||||
|
||||
function buildInvoiceSummary(invoices, today) {
|
||||
const groups = Object.fromEntries(AGE_GROUPS.map((key) => [key, { key, anzahl: 0, summeCents: 0 }]))
|
||||
const drafts = { anzahl: 0, summeCents: 0 }
|
||||
for (const invoice of invoices) {
|
||||
if (invoice.status === 'entwurf') {
|
||||
drafts.anzahl += 1
|
||||
if (isCents(invoice.totalCents)) drafts.summeCents += invoice.totalCents
|
||||
} else if ((invoice.status === 'offen' || invoice.status === 'ueberfaellig') && isCents(invoice.remainingCents)) {
|
||||
const group = groups[ageGroupOf(daysLate(invoice, today))]
|
||||
group.anzahl += 1
|
||||
group.summeCents += invoice.remainingCents
|
||||
}
|
||||
}
|
||||
return { altersgruppen: AGE_GROUPS.map((key) => groups[key]), entwuerfe: drafts }
|
||||
}
|
||||
|
||||
// --- Übersicht ----------------------------------------------------------------------------------------------
|
||||
|
||||
const isOpenIncoming = (tx) => tx.status === 'BOOK' && isCents(tx.amountCents) && tx.amountCents > 0 && !tx.category
|
||||
|
||||
/**
|
||||
* @param {object} [args]
|
||||
* @param {Date} [args.now] Zeitpunkt; "heute" ist der Berliner Tag dazu
|
||||
* @returns {Promise<{ stand, kopf, aufgaben, vorschau, steuer, abos, rechnungen }>}
|
||||
*/
|
||||
export async function buildOverview({ now = new Date() } = {}) {
|
||||
const today = berlinDate(now)
|
||||
const [connection, transactions, settings, fixedCosts, subscriptions, stripe] = await Promise.all([
|
||||
getConnection(),
|
||||
listTransactions(),
|
||||
getSettings(),
|
||||
listFixedCosts(),
|
||||
listDocuments(config.collections.subscriptions, [Query.limit(SUBSCRIPTION_LIMIT)]),
|
||||
loadStripe(now),
|
||||
])
|
||||
|
||||
// Kundennamen nur für die Abos der Liste
|
||||
const listed = subscriptions.filter((s) => LISTED_STATUSES.has(s.status))
|
||||
const names = await customerNames([...new Set(listed.map((s) => s.customerId).filter((id) => typeof id === 'string' && id))])
|
||||
const named = listed.map((s) => ({ ...s, customerName: names.get(s.customerId) ?? '' }))
|
||||
|
||||
// Kontostand (gebucht) laut letztem Abruf; ohne Zugang oder ohne Abruf unbekannt (null)
|
||||
const balance = connection && isCents(connection.balanceBookedCents) ? connection.balanceBookedCents : null
|
||||
const minBuffer = isCents(settings.minBufferCents) ? settings.minBufferCents : 0
|
||||
|
||||
const forecast = buildForecast({
|
||||
today,
|
||||
balanceCents: balance ?? 0,
|
||||
payouts: stripe.payouts,
|
||||
invoices: stripe.invoices,
|
||||
subscriptions,
|
||||
fixedCosts,
|
||||
settings,
|
||||
})
|
||||
const tax = computeTax({ today, transactions, invoices: stripe.invoices, settings })
|
||||
|
||||
// Zuordnungsvorschläge wie bei GET /buchungen, nur für gebuchte, offene Eingänge; ohne vollständige Stripe-Daten keine
|
||||
const openIncoming = transactions.filter(isOpenIncoming)
|
||||
const todoTransactions = stripe.ok && openIncoming.length ? (await withSuggestions(openIncoming)).buchungen : openIncoming
|
||||
const aufgaben = buildTodos({
|
||||
today,
|
||||
now,
|
||||
connection,
|
||||
invoices: stripe.invoices,
|
||||
transactions: todoTransactions,
|
||||
subscriptions: named,
|
||||
recurring: detectRecurring(transactions, { fixedCosts }),
|
||||
tax,
|
||||
})
|
||||
|
||||
return {
|
||||
stand: { bank: connection ? publicConnection(connection) : null, stripe: stripe.status },
|
||||
kopf: {
|
||||
kontostandCents: balance,
|
||||
verfuegbarCents: balance === null ? null : balance - tax.ruecklageCents - forecast.fixkosten30Cents - minBuffer,
|
||||
aufschluesselung: {
|
||||
kontostandCents: balance,
|
||||
steuerRuecklageCents: tax.ruecklageCents,
|
||||
fixkosten30Cents: forecast.fixkosten30Cents,
|
||||
mindestpufferCents: minBuffer,
|
||||
},
|
||||
// ohne Kontostand wäre der tiefste Stand nur die Summe der Posten, nicht der Stand des Kontos
|
||||
tiefsterSicher: balance === null ? null : forecast.minSicher,
|
||||
tiefsterErwartet: balance === null ? null : forecast.minErwartet,
|
||||
},
|
||||
aufgaben,
|
||||
vorschau: forecast,
|
||||
steuer: tax,
|
||||
abos: buildSubscriptions(named),
|
||||
rechnungen: buildInvoiceSummary(stripe.invoices, today),
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@ const CACHE_MS = 60_000
|
||||
const LOOKBACK_DAYS = 400
|
||||
const MAX_INVOICES = 1000
|
||||
|
||||
/** Meldung an die Seite, wenn Stripe nicht antwortet (Übersicht, Rechnungsliste, Schreibzugriffe). */
|
||||
export const STRIPE_UNAVAILABLE_MESSAGE = 'Stripe ist gerade nicht erreichbar.'
|
||||
|
||||
// Stripe-Status -> Status nach aussen; "open" wird je nach Faelligkeit zu "offen" oder "ueberfaellig"
|
||||
const STATUS_DE = { draft: 'entwurf', open: 'offen', paid: 'bezahlt', void: 'storniert', uncollectible: 'uneinbringlich' }
|
||||
|
||||
@@ -118,12 +121,20 @@ const PAYMENT_REFERENCE_NOTE = 'Bitte geben Sie bei Überweisung die Rechnungsnu
|
||||
const ABO_MESSAGE = 'Abo-Rechnungen verwaltet Stripe.'
|
||||
const NO_EMAIL_MESSAGE = 'Für den Versand fehlt die E-Mail-Adresse des Kunden.'
|
||||
|
||||
function httpError(status, message) {
|
||||
const err = new Error(message)
|
||||
err.status = status
|
||||
return err
|
||||
/**
|
||||
* Fehler dieses Moduls mit HTTP-Status und einer Meldung, die die Seite anzeigen darf (nur diese gibt die Route
|
||||
* weiter; Stripe- und Appwrite-Fehler tragen auch status/statusCode, ihre Meldungen bleiben auf dem Server).
|
||||
*/
|
||||
export class InvoiceError extends Error {
|
||||
constructor(status, message) {
|
||||
super(message)
|
||||
this.name = 'InvoiceError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
const httpError = (status, message) => new InvoiceError(status, message)
|
||||
|
||||
/** Rechnungsfuss: Kleinunternehmer-Hinweis, Bankdaten (falls gesetzt), Hinweis auf den Verwendungszweck. */
|
||||
export function invoiceFooter(bankDetailsText = '') {
|
||||
const bank = typeof bankDetailsText === 'string' ? bankDetailsText.trim() : ''
|
||||
|
||||
@@ -273,7 +273,7 @@ function smallBusinessTodos({ today, tax }) {
|
||||
'kleinunternehmer',
|
||||
HOCH,
|
||||
'Kleinunternehmer-Grenze überschritten',
|
||||
`Die Einnahmen ${year} betragen ${euroDe(revenue)} und liegen über der Grenze von ${euroDe(hardLimit)}. Die Kleinunternehmerregelung (§ 19 UStG) ist davon sofort betroffen. Bitte umgehend mit der Steuerberatung klären.`,
|
||||
`Die Einnahmen ${year} betragen ${euroDe(revenue)} und liegen über der Grenze von ${euroDe(hardLimit)}. Ab dem Umsatz, mit dem die Grenze überschritten wurde, gilt die Kleinunternehmerregelung (§ 19 UStG) nicht mehr – auch im laufenden Jahr. Bitte umgehend mit der Steuerberatung klären.`,
|
||||
today,
|
||||
{ typ: 'einstellungen' }
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user