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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user