diff --git a/backend/db/migrations/0030_manual_statementallocations.sql b/backend/db/migrations/0030_manual_statementallocations.sql new file mode 100644 index 0000000..fc1da29 --- /dev/null +++ b/backend/db/migrations/0030_manual_statementallocations.sql @@ -0,0 +1,10 @@ +ALTER TABLE "statementallocations" ALTER COLUMN "bs_id" DROP NOT NULL; +ALTER TABLE "statementallocations" ADD COLUMN "manual_booking_date" text; +ALTER TABLE "statementallocations" ADD COLUMN "contra_account" bigint; +ALTER TABLE "statementallocations" ADD COLUMN "contra_ownaccount" uuid; +ALTER TABLE "statementallocations" ADD COLUMN "contra_customer" bigint; +ALTER TABLE "statementallocations" ADD COLUMN "contra_vendor" bigint; +ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_account_accounts_id_fk" FOREIGN KEY ("contra_account") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action; +ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_ownaccount_ownaccounts_id_fk" FOREIGN KEY ("contra_ownaccount") REFERENCES "public"."ownaccounts"("id") ON DELETE no action ON UPDATE no action; +ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_customer_customers_id_fk" FOREIGN KEY ("contra_customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action; +ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_vendor_vendors_id_fk" FOREIGN KEY ("contra_vendor") REFERENCES "public"."vendors"("id") ON DELETE no action ON UPDATE no action; diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index 312fd46..f8581b6 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -211,6 +211,13 @@ "when": 1776211200000, "tag": "0029_events_quick", "breakpoints": true + }, + { + "idx": 30, + "version": "7", + "when": 1776297600000, + "tag": "0030_manual_statementallocations", + "breakpoints": true } ] } diff --git a/backend/db/schema/statementallocations.ts b/backend/db/schema/statementallocations.ts index 1cf8e71..dff28a9 100644 --- a/backend/db/schema/statementallocations.ts +++ b/backend/db/schema/statementallocations.ts @@ -23,9 +23,7 @@ export const statementallocations = pgTable("statementallocations", { id: uuid("id").primaryKey().defaultRandom(), // foreign keys - bankstatement: integer("bs_id") - .notNull() - .references(() => bankstatements.id), + bankstatement: integer("bs_id").references(() => bankstatements.id), createddocument: integer("cd_id").references(() => createddocuments.id), @@ -43,14 +41,22 @@ export const statementallocations = pgTable("statementallocations", { () => accounts.id ), + contraAccount: bigint("contra_account", { mode: "number" }).references( + () => accounts.id + ), + created_at: timestamp("created_at", { withTimezone: false, }).defaultNow(), ownaccount: uuid("ownaccount").references(() => ownaccounts.id), + contraOwnaccount: uuid("contra_ownaccount").references(() => ownaccounts.id), + description: text("description"), + manualBookingDate: text("manual_booking_date"), + bookingMode: text("booking_mode").notNull().default("expense"), depreciationMonths: integer("depreciation_months"), depreciationStartDate: text("depreciation_start_date"), @@ -65,6 +71,12 @@ export const statementallocations = pgTable("statementallocations", { vendor: bigint("vendor", { mode: "number" }).references(() => vendors.id), + contraCustomer: bigint("contra_customer", { mode: "number" }).references( + () => customers.id + ), + + contraVendor: bigint("contra_vendor", { mode: "number" }).references(() => vendors.id), + updated_at: timestamp("updated_at", { withTimezone: true }), updated_by: uuid("updated_by").references(() => authUsers.id), diff --git a/backend/src/routes/banking.ts b/backend/src/routes/banking.ts index 9755b09..311044e 100644 --- a/backend/src/routes/banking.ts +++ b/backend/src/routes/banking.ts @@ -11,10 +11,12 @@ import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics" import { bankrequisitions, bankstatements, + accounts, createddocuments, customers, entitybankaccounts, incominginvoices, + ownaccounts, statementallocations, vendors, } from "../../db/schema" @@ -22,10 +24,57 @@ import { import { eq, and, + isNull, + aliasedTable, } from "drizzle-orm" export default async function bankingRoutes(server: FastifyInstance) { + const ContraAccounts = aliasedTable(accounts, "contra_accounts") + const ContraCustomers = aliasedTable(customers, "contra_customers") + const ContraVendors = aliasedTable(vendors, "contra_vendors") + const ContraOwnaccounts = aliasedTable(ownaccounts, "contra_ownaccounts") + + const normalizeManualSide = (payload: any, keys: string[]) => + keys.filter((key) => payload[key] !== null && payload[key] !== undefined && payload[key] !== "") + + const prepareStatementAllocationPayload = (payload: any) => { + const next = { ...payload } + const isManualBooking = !next.bankstatement + + if (!isManualBooking) { + next.manualBookingDate = null + next.contraAccount = null + next.contraCustomer = null + next.contraVendor = null + next.contraOwnaccount = null + return { data: next } + } + + const debitKeys = ["account", "customer", "vendor", "ownaccount"] + const creditKeys = ["contraAccount", "contraCustomer", "contraVendor", "contraOwnaccount"] + const debitSide = normalizeManualSide(next, debitKeys) + const creditSide = normalizeManualSide(next, creditKeys) + + if (!next.manualBookingDate || !dayjs(next.manualBookingDate).isValid()) { + return { error: "Für manuelle Buchungen ist ein gültiges Buchungsdatum erforderlich." } + } + + if (!Number.isFinite(Number(next.amount)) || Number(next.amount) <= 0) { + return { error: "Für manuelle Buchungen muss der Betrag größer als 0 sein." } + } + + if (debitSide.length !== 1 || creditSide.length !== 1) { + return { error: "Für manuelle Buchungen muss genau ein Soll- und ein Haben-Konto ausgewählt werden." } + } + + next.amount = Math.abs(Number(next.amount)) + next.bankstatement = null + next.manualBookingDate = dayjs(next.manualBookingDate).format("YYYY-MM-DD") + + return { data: next } + } + const normalizeIban = (value?: string | null) => String(value || "").replace(/\s+/g, "").toUpperCase() @@ -677,6 +726,56 @@ export default async function bankingRoutes(server: FastifyInstance) { } }) + // ------------------------------------------------------------------ + // 📒 List Manual Statement Allocations + // ------------------------------------------------------------------ + server.get("/banking/manual-bookings", async (req, reply) => { + try { + if (!req.user) return reply.code(401).send({ error: "Unauthorized" }) + + const rows = await server.db.select({ + allocation: statementallocations, + account: accounts, + customer: customers, + vendor: vendors, + ownaccount: ownaccounts, + contraAccount: ContraAccounts, + contraCustomer: ContraCustomers, + contraVendor: ContraVendors, + contraOwnaccount: ContraOwnaccounts, + }) + .from(statementallocations) + .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(ContraAccounts, eq(statementallocations.contraAccount, ContraAccounts.id)) + .leftJoin(ContraCustomers, eq(statementallocations.contraCustomer, ContraCustomers.id)) + .leftJoin(ContraVendors, eq(statementallocations.contraVendor, ContraVendors.id)) + .leftJoin(ContraOwnaccounts, eq(statementallocations.contraOwnaccount, ContraOwnaccounts.id)) + .where(and( + eq(statementallocations.tenant, req.user.tenant_id), + eq(statementallocations.archived, false), + isNull(statementallocations.bankstatement) + )) + + return reply.send(rows.map((row) => ({ + ...row.allocation, + account: row.account, + customer: row.customer, + vendor: row.vendor, + ownaccount: row.ownaccount, + contraAccount: row.contraAccount, + contraCustomer: row.contraCustomer, + contraVendor: row.contraVendor, + contraOwnaccount: row.contraOwnaccount, + }))) + } catch (err) { + console.error(err) + return reply.code(500).send({ error: "Failed to load manual bookings" }) + } + }) + // ------------------------------------------------------------------ // 💰 Create Statement Allocation @@ -686,9 +785,11 @@ export default async function bankingRoutes(server: FastifyInstance) { if (!req.user) return reply.code(401).send({ error: "Unauthorized" }) const { data: payload } = req.body as { data: any } + const prepared = prepareStatementAllocationPayload(payload) + if (prepared.error) return reply.code(400).send({ error: prepared.error }) const inserted = await server.db.insert(statementallocations).values({ - ...payload, + ...prepared.data, tenant: req.user.tenant_id }).returning() @@ -720,16 +821,18 @@ export default async function bankingRoutes(server: FastifyInstance) { } } - await insertHistoryItem(server, { - entity: "bankstatements", - entityId: Number(createdRecord.bankstatement), - action: "created", - created_by: req.user.user_id, - tenant_id: req.user.tenant_id, - oldVal: null, - newVal: createdRecord, - text: "Buchung erstellt", - }) + if (createdRecord.bankstatement) { + await insertHistoryItem(server, { + entity: "bankstatements", + entityId: Number(createdRecord.bankstatement), + action: "created", + created_by: req.user.user_id, + tenant_id: req.user.tenant_id, + oldVal: null, + newVal: createdRecord, + text: "Buchung erstellt", + }) + } return reply.send(createdRecord) @@ -763,16 +866,18 @@ export default async function bankingRoutes(server: FastifyInstance) { .delete(statementallocations) .where(eq(statementallocations.id, id)) - await insertHistoryItem(server, { - entity: "bankstatements", - entityId: Number(old.bankstatement), - action: "deleted", - created_by: req.user.user_id, - tenant_id: req.user.tenant_id, - oldVal: old, - newVal: null, - text: "Buchung gelöscht", - }) + if (old.bankstatement) { + await insertHistoryItem(server, { + entity: "bankstatements", + entityId: Number(old.bankstatement), + action: "deleted", + created_by: req.user.user_id, + tenant_id: req.user.tenant_id, + oldVal: old, + newVal: null, + text: "Buchung gelöscht", + }) + } return reply.send({ success: true }) diff --git a/backend/src/utils/export/datev.ts b/backend/src/utils/export/datev.ts index 4d9b3ca..a6c4210 100644 --- a/backend/src/utils/export/datev.ts +++ b/backend/src/utils/export/datev.ts @@ -8,7 +8,7 @@ import { s3 } from "../s3"; import { secrets } from "../secrets"; // Drizzle Core Imports -import { eq, and, inArray, gte, lte, asc, aliasedTable } from "drizzle-orm"; +import { eq, and, or, isNull, inArray, gte, lte, asc, aliasedTable } from "drizzle-orm"; // Tabellen Imports (keine Relations nötig!) import { @@ -136,6 +136,10 @@ export async function buildExportZip( const CdCustomer = aliasedTable(customers, "cd_customer"); const IiVendor = aliasedTable(vendors, "ii_vendor"); + const ContraAccount = aliasedTable(accounts, "contra_account"); + const ContraVendor = aliasedTable(vendors, "contra_vendor"); + const ContraCustomer = aliasedTable(customers, "contra_customer"); + const ContraOwnaccount = aliasedTable(ownaccounts, "contra_ownaccount"); const allocRaw = await server.db.select({ allocation: statementallocations, @@ -148,11 +152,15 @@ export async function buildExportZip( acc: accounts, direct_vend: vendors, // Direkte Zuordnung an Kreditor direct_cust: customers, // Direkte Zuordnung an Debitor - own: ownaccounts + own: ownaccounts, + contra_acc: ContraAccount, + contra_vend: ContraVendor, + contra_cust: ContraCustomer, + contra_own: ContraOwnaccount }) .from(statementallocations) - // JOIN 1: Bankstatement (Pflicht, für Datum Filter) - .innerJoin(bankstatements, eq(statementallocations.bankstatement, bankstatements.id)) + // JOIN 1: Bankstatement (optional, manuelle Buchungen haben keinen Bankumsatz) + .leftJoin(bankstatements, eq(statementallocations.bankstatement, bankstatements.id)) // JOIN 2: Bankaccount (für DATEV Nummer) .leftJoin(bankaccounts, eq(bankstatements.account, bankaccounts.id)) @@ -169,13 +177,25 @@ export async function buildExportZip( .leftJoin(vendors, eq(statementallocations.vendor, vendors.id)) .leftJoin(customers, eq(statementallocations.customer, customers.id)) .leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id)) + .leftJoin(ContraAccount, eq(statementallocations.contraAccount, ContraAccount.id)) + .leftJoin(ContraVendor, eq(statementallocations.contraVendor, ContraVendor.id)) + .leftJoin(ContraCustomer, eq(statementallocations.contraCustomer, ContraCustomer.id)) + .leftJoin(ContraOwnaccount, eq(statementallocations.contraOwnaccount, ContraOwnaccount.id)) .where(and( eq(statementallocations.tenant, tenantId), eq(statementallocations.archived, false), - // Datum Filter direkt auf dem Bankstatement - gte(bankstatements.date, startDate), - lte(bankstatements.date, endDate) + or( + and( + gte(bankstatements.date, startDate), + lte(bankstatements.date, endDate) + ), + and( + isNull(statementallocations.bankstatement), + gte(statementallocations.manualBookingDate, startDate), + lte(statementallocations.manualBookingDate, endDate) + ) + ) )); // Mapping: Wir bauen das komplexe Objekt nach, das die CSV Logik erwartet @@ -196,7 +216,11 @@ export async function buildExportZip( account: r.acc, vendor: r.direct_vend, customer: r.direct_cust, - ownaccount: r.own + ownaccount: r.own, + contraAccount: r.contra_acc, + contraVendor: r.contra_vend, + contraCustomer: r.contra_cust, + contraOwnaccount: r.contra_own })); // --- D) Stammdaten Accounts --- @@ -311,8 +335,32 @@ export async function buildExportZip( }); // Bank + const getManualBookingSide = (alloc: any, side: "debit" | "credit") => { + const prefix = side === "credit" ? "contra" : ""; + const account = side === "credit" ? alloc.contraAccount : alloc.account; + const vendor = side === "credit" ? alloc.contraVendor : alloc.vendor; + const customer = side === "credit" ? alloc.contraCustomer : alloc.customer; + const ownaccount = side === "credit" ? alloc.contraOwnaccount : alloc.ownaccount; + + if (account) return { number: account.number, name: account.label, type: "Sachkonto" }; + if (vendor) return { number: vendor.vendorNumber, name: vendor.name, type: "Kreditor" }; + if (customer) return { number: customer.customerNumber, name: customer.name, type: "Debitor" }; + if (ownaccount) return { number: ownaccount.number, name: ownaccount.name, type: "Eigenes Konto" }; + return { number: "", name: "", type: prefix }; + }; + statementallocationsList.forEach(alloc => { const bs = alloc.bankstatement; // durch Mapping verfügbar + + if(!bs && alloc.manualBookingDate) { + const debit = getManualBookingSide(alloc, "debit"); + const credit = getManualBookingSide(alloc, "credit"); + const dateManual = dayjs(alloc.manualBookingDate).format("DDMM"); + const dateManualFull = dayjs(alloc.manualBookingDate).format("DD.MM.YYYY"); + bookingLines.push(`${displayCurrency(alloc.amount,true)};"S";;;;;${debit.number};${credit.number};"";${dateManual};"";;;"${`MB ${debit.number} an ${credit.number} ${escapeString(alloc.description)}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${escapeString(debit.name || credit.name)}";"Kundennummer";"${debit.number}";"Belegnummer";"";"Leistungsdatum";"${dateManualFull}";"Belegdatum";"${dateManualFull}";;;;;;;;;;"";;;;;;;;Manuelle-Buchung;${alloc.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`); + return; + } + if(!bs) return; let shSelector = Math.sign(alloc.amount) === -1 ? "H" : "S"; @@ -425,4 +473,4 @@ export async function buildExportZip( console.error("DATEV Export Error:", error); throw error; } -} \ No newline at end of file +} diff --git a/frontend/components/EntityShowSubOwnAccountsStatements.vue b/frontend/components/EntityShowSubOwnAccountsStatements.vue index 00b4d66..36d60c7 100644 --- a/frontend/components/EntityShowSubOwnAccountsStatements.vue +++ b/frontend/components/EntityShowSubOwnAccountsStatements.vue @@ -35,11 +35,13 @@ const getStatementLike = (allocation) => allocation?.bankstatement || (typeof al const getAllocationDate = (allocation) => { const statement = getStatementLike(allocation) - return statement?.date || statement?.valueDate || allocation?.bs_id?.date || allocation?.bs_id?.valueDate || null + return statement?.date || statement?.valueDate || allocation?.bs_id?.date || allocation?.bs_id?.valueDate || allocation?.manualBookingDate || null } const getAllocationPartner = (allocation) => { const statement = getStatementLike(allocation) + if (!statement && allocation?.manualBookingDate) return "Manuelle Buchung" + return statement?.debName || statement?.credName || statement?.partner || allocation?.bs_id?.debName || allocation?.bs_id?.credName || "" } const getAllocationDescription = (allocation) => { @@ -48,12 +50,20 @@ const getAllocationDescription = (allocation) => { return allocation?.description || statement?.purpose || statement?.text || statement?.description || allocation?.bs_id?.purpose || allocation?.bs_id?.text || "" } const hasContent = (value) => value !== null && value !== undefined && String(value).trim() !== "" +const isContraAllocation = (allocation) => + sameAccount(allocation.contraAccount?.id || allocation.contraAccount) + || sameAccount(allocation.contraOwnaccount?.id || allocation.contraOwnaccount) + +const touchesCurrentAccount = (allocation) => + sameAccount(allocation.account?.id || allocation.account) + || sameAccount(allocation.ownaccount?.id || allocation.ownaccount) + || isContraAllocation(allocation) const monthItems = [ { label: "Ganzes Jahr", value: "all" }, { label: "Januar", value: "1" }, { label: "Februar", value: "2" }, - { label: "Maerz", value: "3" }, + { label: "März", value: "3" }, { label: "April", value: "4" }, { label: "Mai", value: "5" }, { label: "Juni", value: "6" }, @@ -73,7 +83,7 @@ const allAllocations = computed(() => { date: getAllocationDate(allocation), partner: getAllocationPartner(allocation), description: getAllocationDescription(allocation), - amount: Number(allocation.amount || 0) + amount: Number(allocation.amount || 0) * (isContraAllocation(allocation) ? -1 : 1) })) const incomingInvoiceRows = incomingInvoices.value.flatMap((invoice) => { @@ -162,7 +172,7 @@ const setup = async () => { loading.value = true statementAllocations.value = (await useEntities("statementallocations").select("*, bankstatement(*), createddocument(*), incominginvoice(*)")) - .filter((allocation) => sameAccount(allocation.account?.id || allocation.account) || sameAccount(allocation.ownaccount?.id || allocation.ownaccount)) + .filter((allocation) => touchesCurrentAccount(allocation)) incomingInvoices.value = (await useEntities("incominginvoices").select("*, vendor(*)")) .filter((invoice) => (invoice.accounts || []).some((account) => sameAccount(account.account?.id || account.account))) @@ -248,7 +258,7 @@ const selectAllocation = (allocationLike) => { - Keine Buchungen im ausgewaehlten Zeitraum + Keine Buchungen im ausgewählten Zeitraum @@ -259,7 +269,7 @@ const selectAllocation = (allocationLike) => { - {{ row.original.bankstatement.date && dayjs(row.original.bankstatement.date).isValid() ? dayjs(row.original.bankstatement.date).format('DD.MM.YYYY') : '-' }} + {{ row.original.date && dayjs(row.original.date).isValid() ? dayjs(row.original.date).format('DD.MM.YYYY') : '-' }} diff --git a/frontend/components/MainNav.vue b/frontend/components/MainNav.vue index debe12f..f68d10a 100644 --- a/frontend/components/MainNav.vue +++ b/frontend/components/MainNav.vue @@ -156,6 +156,11 @@ const links = computed(() => { to: "/accounting/bwa", icon: "i-heroicons-chart-bar-square", } : null, + (featureEnabled("accounts") || featureEnabled("ownaccounts")) ? { + label: "Manuelle Buchungen", + to: "/accounting/manual-bookings", + icon: "i-heroicons-arrows-right-left", + } : null, featureEnabled("banking") ? { label: "Liquidität", to: "/accounting/liquidity", diff --git a/frontend/pages/accounting/manual-bookings.vue b/frontend/pages/accounting/manual-bookings.vue new file mode 100644 index 0000000..11d8e98 --- /dev/null +++ b/frontend/pages/accounting/manual-bookings.vue @@ -0,0 +1,282 @@ + + + + + + + + + + + Neue Soll/Haben-Buchung + Zum Beispiel: Kreditor 70000 an Konto 2742. + + + + + + + + + + + + + + + + {{ item.number }} + {{ item.name }} + {{ item.type }} + + + + + + + + {{ item.number }} + {{ item.name }} + {{ item.type }} + + + + + + + + + + + + Manuelle Buchung erstellen + + + + + + + + Erfasste manuelle Buchungen + Diese Buchungen laufen im DATEV-Export mit. + + + + Lade Buchungen... + + Noch keine manuellen Buchungen erfasst. + + + + + + {{ dayjs(booking.manualBookingDate).format("DD.MM.YYYY") }} + {{ displayCurrency(booking.amount) }} + + + Soll: + {{ getBookingSide(booking, "debit").number }} - {{ getBookingSide(booking, "debit").name }} + + + Haben: + {{ getBookingSide(booking, "credit").number }} - {{ getBookingSide(booking, "credit").name }} + + + {{ booking.description }} + + + + + + + + +
Keine Buchungen im ausgewaehlten Zeitraum
Keine Buchungen im ausgewählten Zeitraum
Zum Beispiel: Kreditor 70000 an Konto 2742.
Diese Buchungen laufen im DATEV-Export mit.