From 5fe823f52a8619317a5fe339b4674cdfbca30e14 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Mon, 11 May 2026 17:13:49 +0200 Subject: [PATCH] =?UTF-8?q?Implementiert=20Kassenbuch=20f=C3=BCr=20Barkass?= =?UTF-8?q?en?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Barkassen können als manuelle Bankkonten angelegt werden. Kassenbuchungen erzeugen Bankbewegungen mit Gegenkonto und sind über eine neue Buchhaltungsseite erreichbar. --- backend/src/routes/banking.ts | 285 +++++++++++++ frontend/components/MainNav.vue | 5 + frontend/pages/accounting/cashbooks.vue | 512 ++++++++++++++++++++++++ 3 files changed, 802 insertions(+) create mode 100644 frontend/pages/accounting/cashbooks.vue diff --git a/backend/src/routes/banking.ts b/backend/src/routes/banking.ts index deffc1d..d5b1684 100644 --- a/backend/src/routes/banking.ts +++ b/backend/src/routes/banking.ts @@ -10,6 +10,7 @@ import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics" import { bankrequisitions, + bankaccounts, bankstatements, accounts, createddocuments, @@ -26,10 +27,12 @@ import { and, isNull, aliasedTable, + desc, } from "drizzle-orm" export default async function bankingRoutes(server: FastifyInstance) { + const CASHBOOK_BANK_ID = "fedeo-cashbook" const ContraAccounts = aliasedTable(accounts, "contra_accounts") const ContraCustomers = aliasedTable(customers, "contra_customers") const ContraVendors = aliasedTable(vendors, "contra_vendors") @@ -40,6 +43,24 @@ export default async function bankingRoutes(server: FastifyInstance) { const normalizeManualSide = (payload: any, keys: string[]) => keys.filter((key) => payload[key] !== null && payload[key] !== undefined && payload[key] !== "") + const cashbookAccountFilter = (tenantId: number) => and( + eq(bankaccounts.tenant, tenantId), + eq(bankaccounts.bankId, CASHBOOK_BANK_ID), + eq(bankaccounts.archived, false) + ) + + const buildCashbookCounterPayload = (type: string, id: any) => { + const numericId = type === "ownaccount" ? id : Number(id) + if (!id || (type !== "ownaccount" && !Number.isFinite(numericId))) return null + + if (type === "account") return { account: numericId } + if (type === "customer") return { customer: numericId } + if (type === "vendor") return { vendor: numericId } + if (type === "ownaccount") return { ownaccount: numericId } + if (type === "incominginvoice") return { incominginvoice: numericId } + return null + } + const prepareStatementAllocationPayload = (payload: any) => { const next = { ...payload } const isManualBooking = !next.bankstatement @@ -89,6 +110,270 @@ export default async function bankingRoutes(server: FastifyInstance) { return { data: next } } + server.get("/banking/cashbooks", async (req, reply) => { + try { + if (!req.user) return reply.code(401).send({ error: "Unauthorized" }) + + const rows = await server.db + .select() + .from(bankaccounts) + .where(cashbookAccountFilter(req.user.tenant_id)) + .orderBy(bankaccounts.name) + + return reply.send(rows) + } catch (err) { + server.log.error(err) + return reply.code(500).send({ error: "Failed to load cashbooks" }) + } + }) + + server.post("/banking/cashbooks", async (req, reply) => { + try { + if (!req.user) return reply.code(401).send({ error: "Unauthorized" }) + + const body = req.body as { + name?: string + datevNumber?: string + openingBalance?: number + } + + const name = String(body.name || "").trim() + const datevNumber = String(body.datevNumber || "").trim() + const openingBalance = Number(body.openingBalance || 0) + + if (!name) return reply.code(400).send({ error: "Bitte eine Bezeichnung für die Kasse angeben." }) + if (!datevNumber) return reply.code(400).send({ error: "Bitte eine Kontennummer für die Kasse angeben." }) + if (!Number.isFinite(openingBalance)) return reply.code(400).send({ error: "Der Anfangsbestand ist ungültig." }) + + const uniquePart = `${req.user.tenant_id}-${Date.now()}` + const inserted = await server.db.insert(bankaccounts).values({ + name, + iban: `CASH-${uniquePart}`, + tenant: req.user.tenant_id, + bankId: CASHBOOK_BANK_ID, + ownerName: name, + accountId: `cashbook-${uniquePart}`, + balance: openingBalance, + datevNumber, + updatedBy: req.user.user_id, + }).returning() + + const createdRecord = inserted[0] + return reply.send(createdRecord) + } catch (err) { + server.log.error(err) + return reply.code(500).send({ error: "Failed to create cashbook" }) + } + }) + + server.patch("/banking/cashbooks/:id/archive", async (req, reply) => { + try { + if (!req.user) return reply.code(401).send({ error: "Unauthorized" }) + + const { id } = req.params as { id: string } + const updated = await server.db.update(bankaccounts) + .set({ + archived: true, + updatedAt: new Date(), + updatedBy: req.user.user_id, + }) + .where(and( + eq(bankaccounts.id, Number(id)), + eq(bankaccounts.tenant, req.user.tenant_id), + eq(bankaccounts.bankId, CASHBOOK_BANK_ID) + )) + .returning() + + if (!updated[0]) return reply.code(404).send({ error: "Cashbook not found" }) + return reply.send(updated[0]) + } catch (err) { + server.log.error(err) + return reply.code(500).send({ error: "Failed to archive cashbook" }) + } + }) + + server.get("/banking/cashbooks/:id/bookings", async (req, reply) => { + try { + if (!req.user) return reply.code(401).send({ error: "Unauthorized" }) + + const { id } = req.params as { id: string } + const cashbookId = Number(id) + if (!Number.isFinite(cashbookId)) return reply.code(400).send({ error: "Ungültige Kasse." }) + + const rows = await server.db.select({ + statement: bankstatements, + allocation: statementallocations, + account: accounts, + customer: customers, + vendor: vendors, + ownaccount: ownaccounts, + incominginvoice: ManualInvoices, + incominginvoiceVendor: ManualInvoiceVendors, + }) + .from(bankstatements) + .leftJoin(statementallocations, eq(statementallocations.bankstatement, bankstatements.id)) + .leftJoin(accounts, eq(statementallocations.account, accounts.id)) + .leftJoin(customers, eq(statementallocations.customer, customers.id)) + .leftJoin(vendors, eq(statementallocations.vendor, vendors.id)) + .leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id)) + .leftJoin(ManualInvoices, eq(statementallocations.incominginvoice, ManualInvoices.id)) + .leftJoin(ManualInvoiceVendors, eq(ManualInvoices.vendor, ManualInvoiceVendors.id)) + .where(and( + eq(bankstatements.tenant, req.user.tenant_id), + eq(bankstatements.account, cashbookId), + eq(bankstatements.archived, false) + )) + .orderBy(desc(bankstatements.date), desc(bankstatements.createdAt)) + + return reply.send(rows.map((row) => ({ + ...row.statement, + allocation: row.allocation, + account: row.account, + customer: row.customer, + vendor: row.vendor, + ownaccount: row.ownaccount, + incominginvoice: row.incominginvoice ? { + ...row.incominginvoice, + vendor: row.incominginvoiceVendor, + } : null, + }))) + } catch (err) { + server.log.error(err) + return reply.code(500).send({ error: "Failed to load cashbook bookings" }) + } + }) + + server.post("/banking/cashbooks/:id/bookings", async (req, reply) => { + try { + if (!req.user) return reply.code(401).send({ error: "Unauthorized" }) + + const { id } = req.params as { id: string } + const cashbookId = Number(id) + const body = req.body as { + date?: string + amount?: number + direction?: "income" | "expense" + counterType?: string + counterId?: string | number + description?: string + datevTaxKey?: string | null + } + + if (!Number.isFinite(cashbookId)) return reply.code(400).send({ error: "Ungültige Kasse." }) + if (!body.date || !dayjs(body.date).isValid()) return reply.code(400).send({ error: "Bitte ein gültiges Buchungsdatum angeben." }) + if (!Number.isFinite(Number(body.amount)) || Number(body.amount) <= 0) return reply.code(400).send({ error: "Der Betrag muss größer als 0 sein." }) + if (body.direction !== "income" && body.direction !== "expense") return reply.code(400).send({ error: "Bitte Einnahme oder Ausgabe auswählen." }) + + const cashbook = await server.db.select().from(bankaccounts).where(and( + eq(bankaccounts.id, cashbookId), + eq(bankaccounts.tenant, req.user.tenant_id), + eq(bankaccounts.bankId, CASHBOOK_BANK_ID), + eq(bankaccounts.archived, false) + )).limit(1) + + if (!cashbook[0]) return reply.code(404).send({ error: "Kasse nicht gefunden." }) + + const counterPayload = buildCashbookCounterPayload(String(body.counterType || ""), body.counterId) + if (!counterPayload) return reply.code(400).send({ error: "Bitte ein Gegenkonto auswählen." }) + + const signedAmount = body.direction === "income" + ? Math.abs(Number(body.amount)) + : -Math.abs(Number(body.amount)) + const description = String(body.description || "").trim() || (body.direction === "income" ? "Bareinnahme" : "Barausgabe") + + const created = await server.db.transaction(async (tx) => { + const insertedStatements = await tx.insert(bankstatements).values({ + account: cashbookId, + date: dayjs(body.date).format("YYYY-MM-DD"), + amount: signedAmount, + tenant: req.user.tenant_id, + text: description, + currency: "EUR", + credName: body.direction === "income" ? cashbook[0].name : description, + debName: body.direction === "expense" ? cashbook[0].name : description, + updatedBy: req.user.user_id, + }).returning() + + const statement = insertedStatements[0] + const insertedAllocations = await tx.insert(statementallocations).values({ + bankstatement: statement.id, + amount: signedAmount, + tenant: req.user.tenant_id, + description, + datevTaxKey: body.datevTaxKey ? String(body.datevTaxKey).trim() : null, + ...counterPayload, + }).returning() + + return { + statement, + allocation: insertedAllocations[0], + } + }) + + await insertHistoryItem(server, { + entity: "bankstatements", + entityId: Number(created.statement.id), + action: "created", + created_by: req.user.user_id, + tenant_id: req.user.tenant_id, + oldVal: null, + newVal: created, + text: "Kassenbuchung erstellt", + }) + + return reply.send(created) + } catch (err) { + server.log.error(err) + return reply.code(500).send({ error: "Failed to create cashbook booking" }) + } + }) + + server.delete("/banking/cashbook-bookings/:id", async (req, reply) => { + try { + if (!req.user) return reply.code(401).send({ error: "Unauthorized" }) + + const { id } = req.params as { id: string } + const statementId = Number(id) + if (!Number.isFinite(statementId)) return reply.code(400).send({ error: "Ungültige Buchung." }) + + const records = await server.db.select({ + statement: bankstatements, + cashbook: bankaccounts, + }) + .from(bankstatements) + .innerJoin(bankaccounts, eq(bankstatements.account, bankaccounts.id)) + .where(and( + eq(bankstatements.id, statementId), + eq(bankstatements.tenant, req.user.tenant_id), + eq(bankaccounts.bankId, CASHBOOK_BANK_ID) + )) + .limit(1) + + if (!records[0]) return reply.code(404).send({ error: "Kassenbuchung nicht gefunden." }) + + await server.db.transaction(async (tx) => { + await tx.delete(statementallocations).where(eq(statementallocations.bankstatement, statementId)) + await tx.delete(bankstatements).where(eq(bankstatements.id, statementId)) + }) + + await insertHistoryItem(server, { + entity: "bankstatements", + entityId: statementId, + action: "deleted", + created_by: req.user.user_id, + tenant_id: req.user.tenant_id, + oldVal: records[0].statement, + newVal: null, + text: "Kassenbuchung gelöscht", + }) + + return reply.send({ success: true }) + } catch (err) { + server.log.error(err) + return reply.code(500).send({ error: "Failed to delete cashbook booking" }) + } + }) + const normalizeIban = (value?: string | null) => String(value || "").replace(/\s+/g, "").toUpperCase() diff --git a/frontend/components/MainNav.vue b/frontend/components/MainNav.vue index 2f31c45..bb9d84c 100644 --- a/frontend/components/MainNav.vue +++ b/frontend/components/MainNav.vue @@ -193,6 +193,11 @@ const links = computed(() => { to: "/banking", icon: "i-heroicons-document-text", } : null, + featureEnabled("banking") ? { + label: "Kassenbuch", + to: "/accounting/cashbooks", + icon: "i-heroicons-banknotes", + } : null, (featureEnabled("accounts") || featureEnabled("ownaccounts")) ? { label: "Manuelle Buchungen", to: "/accounting/manual-bookings", diff --git a/frontend/pages/accounting/cashbooks.vue b/frontend/pages/accounting/cashbooks.vue new file mode 100644 index 0000000..0ae937d --- /dev/null +++ b/frontend/pages/accounting/cashbooks.vue @@ -0,0 +1,512 @@ + + +