Ohne daysUntilDue gilt bei Anzahlung und Schluss auftrag.zahlungszielTage (Route und createProjectInvoice), sonst weiterhin 14 Tage; eine Angabe gilt vor der AB. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
338 lines
13 KiB
JavaScript
338 lines
13 KiB
JavaScript
import { Router } from 'express'
|
|
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
|
import { withLocks } from '../../services/keyedLock.js'
|
|
import { AuftragError } from '../../services/auftraege/fehler.js'
|
|
import { EnableBankingError } from '../../services/finance/enableBanking.js'
|
|
import { completeBankAuth, startBankAuth, syncBank } from '../../services/finance/bankSync.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,
|
|
deleteFixedCost,
|
|
getSettings,
|
|
listFixedCosts,
|
|
patchSettings,
|
|
updateFixedCost,
|
|
} from '../../services/finance/settings.js'
|
|
import {
|
|
InvoiceError,
|
|
STRIPE_UNAVAILABLE_MESSAGE,
|
|
createProjectInvoice,
|
|
deleteDraft,
|
|
listInvoices,
|
|
rechnungsVorschau,
|
|
sendInvoiceNow,
|
|
voidInvoice,
|
|
} from '../../services/finance/stripeFinance.js'
|
|
|
|
/**
|
|
* Finanzen im WOMS (nur Admins): Übersicht, Bankstatus, Abruf, Neuverbindung, Buchungen und deren Zuordnung,
|
|
* Rechnungen (lesen, anlegen, Vorschau, senden, stornieren, Entwurf löschen), Einstellungen und Fixkosten. Die Handler sind klein
|
|
* und rufen die Dienste in server/services/finance auf.
|
|
*/
|
|
const router = Router()
|
|
|
|
router.use(requireAppwriteStaff)
|
|
|
|
// Der Knopf "Aktualisieren" darf die Bank hoechstens alle 10 Minuten abfragen
|
|
const MANUAL_SYNC_COOLDOWN_MS = 10 * 60_000
|
|
const MANUAL_SYNC_LOCK = ['finanzen-bank-manual-sync']
|
|
|
|
// Eine Meldung fuer jeden Fehler auf dem Weg zur Bank (Freigabe starten, Rueckkehr): der Status der Bank
|
|
// (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.'
|
|
|
|
/** 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?.type || err?.name || 'Error', err?.status ?? err?.statusCode ?? '', 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()
|
|
return res.json({ verbindung: connection ? publicConnection(connection) : null })
|
|
} catch (err) {
|
|
logFailure('Bankstatus laden', err)
|
|
return res.status(500).json({ error: 'Bankstatus konnte nicht geladen werden.' })
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Sperre des Knopfs "Aktualisieren": prueft lastManualSyncAt und setzt es gleich, bevor der Abruf beginnt (zwei
|
|
* Klicks gleichzeitig starten so nur einen Abruf). Liefert null (frei) oder die Minuten bis zum naechsten Abruf.
|
|
* Ohne Bankzugang gibt es nichts zu sperren; der Abruf meldet das dann selbst.
|
|
*/
|
|
function claimManualSync() {
|
|
return withLocks(MANUAL_SYNC_LOCK, async () => {
|
|
const connection = await getConnection()
|
|
if (!connection) return null
|
|
const now = Date.now()
|
|
const lastMs = Date.parse(connection.lastManualSyncAt ?? '')
|
|
if (!Number.isNaN(lastMs) && now - lastMs < MANUAL_SYNC_COOLDOWN_MS) {
|
|
return Math.ceil((MANUAL_SYNC_COOLDOWN_MS - (now - lastMs)) / 60_000)
|
|
}
|
|
await saveConnection({ lastManualSyncAt: new Date(now).toISOString() })
|
|
return null
|
|
})
|
|
}
|
|
|
|
router.post('/bank/aktualisieren', async (req, res) => {
|
|
try {
|
|
const waitMinutes = await claimManualSync()
|
|
if (waitMinutes !== null) {
|
|
return res.status(429).json({ error: `Bitte in ${waitMinutes} ${waitMinutes === 1 ? 'Minute' : 'Minuten'} erneut versuchen.` })
|
|
}
|
|
// Ein Mensch loest den Abruf aus: IP und Browser gehen an die Bank (zaehlt nicht auf ihr Tageslimit)
|
|
return res.json(await syncBank({ psu: { ip: req.ip, userAgent: req.get('User-Agent') } }))
|
|
} catch (err) {
|
|
logFailure('Bankabruf starten', err)
|
|
return res.status(500).json({ error: 'Der Bankabruf konnte nicht gestartet werden.' })
|
|
}
|
|
})
|
|
|
|
router.post('/bank/verbinden', async (_req, res) => {
|
|
try {
|
|
return res.json(await startBankAuth())
|
|
} catch (err) {
|
|
logFailure('Freigabe starten', err)
|
|
return res.status(502).json({ error: BANK_UNAVAILABLE_MESSAGE })
|
|
}
|
|
})
|
|
|
|
router.post('/bank/rueckkehr', async (req, res) => {
|
|
const { code, state, error } = req.body || {}
|
|
try {
|
|
const { validUntil } = await completeBankAuth({ code, state, error }, { psu: { ip: req.ip, userAgent: req.get('User-Agent') } })
|
|
return res.json({ ok: true, validUntil })
|
|
} catch (err) {
|
|
// 400 mit deutscher Meldung kommt aus der Pruefung der Rueckkehr (state, Ablauf, Fehler von Finom); ein 400 der
|
|
// Bank selbst (EnableBankingError, z. B. ungueltiger Code) gehoert nicht dazu
|
|
if (err?.status === 400 && !(err instanceof EnableBankingError)) return res.status(400).json({ error: err.message })
|
|
// Alles andere (auch ein 401 der Bank beim Codetausch) ist fuer die Seite: Freigabe nicht zustande gekommen
|
|
logFailure('Rückkehr von der Bank', err)
|
|
return res.status(502).json({ error: BANK_UNAVAILABLE_MESSAGE })
|
|
}
|
|
})
|
|
|
|
router.get('/buchungen', async (_req, res) => {
|
|
try {
|
|
// Vorschlaege kommen von Stripe: ein Ausfall dort (stripeFehler) macht die Liste nie kaputt
|
|
return res.json(await withSuggestions((await listTransactions()).map(publicDoc)))
|
|
} catch (err) {
|
|
logFailure('Buchungen laden', err)
|
|
return res.status(500).json({ error: 'Buchungen konnten nicht geladen werden.' })
|
|
}
|
|
})
|
|
|
|
const STRIPE_REJECTED_MESSAGE = 'Stripe hat die Änderung abgelehnt.'
|
|
|
|
// 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 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 sendFinanceError(res, what, err) {
|
|
if (err instanceof AssignError || err instanceof InvoiceError || err instanceof AuftragError) {
|
|
// Ein 502 an einer bestehenden Rechnung (nicht gesendet, nicht lesbar) hat seine Ursache bei Stripe: nur Art, Status und Code ins Protokoll
|
|
if (err.status === 502 && err.cause) logFailure(what, err.cause)
|
|
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
|
|
if (status >= 400 && status < 500 && status !== 429) return res.status(409).json({ error: STRIPE_REJECTED_MESSAGE })
|
|
return res.status(502).json({ error: STRIPE_UNAVAILABLE_MESSAGE })
|
|
}
|
|
return res.status(500).json({ error: 'Interner Fehler.' })
|
|
}
|
|
|
|
router.post('/buchungen/:id/zuordnen', async (req, res) => {
|
|
const { invoiceId, payoutId, fixedCostId, category, note } = req.body || {}
|
|
try {
|
|
const buchung = await assignTransaction(req.params.id, { invoiceId, payoutId, fixedCostId, category, note }, { by: req.appwriteUser?.email })
|
|
return res.json({ buchung: publicDoc(buchung) })
|
|
} catch (err) {
|
|
return sendFinanceError(res, 'Buchung zuordnen', err)
|
|
}
|
|
})
|
|
|
|
router.delete('/buchungen/:id/zuordnung', async (req, res) => {
|
|
try {
|
|
return res.json({ buchung: publicDoc(await unassignTransaction(req.params.id)) })
|
|
} catch (err) {
|
|
return sendFinanceError(res, 'Zuordnung lösen', err)
|
|
}
|
|
})
|
|
|
|
// --- Rechnungen (Stripe) ----------------------------------------------------------------------------------------
|
|
|
|
const INVOICE_FILTERS = new Set(['alle', 'offen', 'ueberfaellig', 'bezahlt', 'entwurf'])
|
|
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, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum,
|
|
daysUntilDue, memo, senden, idempotencyKey,
|
|
} = req.body || {}
|
|
try {
|
|
// Die Typen der Felder prueft baueRechnung (400); die Route reicht sie unveraendert durch
|
|
const rechnung = await createProjectInvoice({
|
|
customerId,
|
|
art,
|
|
projektId,
|
|
auftragId,
|
|
items,
|
|
arbeitsblattIds,
|
|
anzahlung,
|
|
zeilentexte,
|
|
leistungszeitraum,
|
|
// Ohne Angabe entscheidet createProjectInvoice (AB-Zahlungsziel bei Anzahlung und Schluss, sonst 14 Tage)
|
|
daysUntilDue: daysUntilDue ?? undefined,
|
|
memo,
|
|
// Nur ein echtes Ja sendet sofort: alles andere bleibt ein Entwurf
|
|
senden: senden === true || senden === 'true',
|
|
// Optional: ein Schluessel je beabsichtigter Rechnung schuetzt vor doppeltem Anlegen (siehe createProjectInvoice)
|
|
idempotencyKey,
|
|
createdBy: req.appwriteUser?.email,
|
|
})
|
|
return res.json({ rechnung })
|
|
} catch (err) {
|
|
return sendFinanceError(res, 'Rechnung anlegen', err)
|
|
}
|
|
})
|
|
|
|
// Vorschau: dieselben Pruefungen wie das Anlegen, aber ohne Schreibzugriff
|
|
router.post('/rechnungen/vorschau', async (req, res) => {
|
|
const { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum } = req.body || {}
|
|
try {
|
|
return res.json(await rechnungsVorschau({ customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum }))
|
|
} catch (err) {
|
|
return sendFinanceError(res, 'Rechnungsvorschau', 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)
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Antwort auf einen Fehler bei Einstellungen und Fixkosten: SettingsError (400 ungueltige Eingabe, 404 unbekannte
|
|
* Position, mit anzeigbarer Meldung); alles andere (Appwrite) wird protokolliert und ist ein 500 ohne dessen Meldung.
|
|
*/
|
|
function sendSettingsError(res, what, err) {
|
|
if (err instanceof SettingsError) return res.status(err.status).json({ error: err.message })
|
|
logFailure(what, err)
|
|
return res.status(500).json({ error: 'Interner Fehler.' })
|
|
}
|
|
|
|
router.get('/einstellungen', async (_req, res) => {
|
|
try {
|
|
return res.json({ einstellungen: await getSettings() })
|
|
} catch (err) {
|
|
return sendSettingsError(res, 'Einstellungen laden', err)
|
|
}
|
|
})
|
|
|
|
router.patch('/einstellungen', async (req, res) => {
|
|
try {
|
|
return res.json({ einstellungen: await patchSettings(req.body) })
|
|
} catch (err) {
|
|
return sendSettingsError(res, 'Einstellungen speichern', err)
|
|
}
|
|
})
|
|
|
|
router.get('/fixkosten', async (_req, res) => {
|
|
try {
|
|
return res.json({ fixkosten: (await listFixedCosts()).map(publicDoc) })
|
|
} catch (err) {
|
|
return sendSettingsError(res, 'Fixkosten laden', err)
|
|
}
|
|
})
|
|
|
|
router.post('/fixkosten', async (req, res) => {
|
|
try {
|
|
return res.status(201).json({ position: publicDoc(await createFixedCost(req.body)) })
|
|
} catch (err) {
|
|
return sendSettingsError(res, 'Fixkosten anlegen', err)
|
|
}
|
|
})
|
|
|
|
router.patch('/fixkosten/:id', async (req, res) => {
|
|
try {
|
|
return res.json({ position: publicDoc(await updateFixedCost(req.params.id, req.body)) })
|
|
} catch (err) {
|
|
return sendSettingsError(res, 'Fixkosten ändern', err)
|
|
}
|
|
})
|
|
|
|
router.delete('/fixkosten/:id', async (req, res) => {
|
|
try {
|
|
await deleteFixedCost(req.params.id)
|
|
return res.json({ ok: true })
|
|
} catch (err) {
|
|
return sendSettingsError(res, 'Fixkosten löschen', err)
|
|
}
|
|
})
|
|
|
|
export default router
|