diff --git a/backend/src/mcp/statementAllocations.ts b/backend/src/mcp/statementAllocations.ts new file mode 100644 index 0000000..c1c33e6 --- /dev/null +++ b/backend/src/mcp/statementAllocations.ts @@ -0,0 +1,141 @@ +const hasValue = (value: unknown) => value !== null && value !== undefined && value !== "" + +const stringArg = (args: Record, key: string) => { + const value = args[key] + return typeof value === "string" && value.trim() ? value.trim() : null +} + +const statementAllocationTargetFields = [ + "createddocument", + "incominginvoice", + "account", + "ownaccount", + "customer", + "vendor", +] as const + +const manualDebitFields = ["account", "customer", "vendor", "ownaccount"] as const +const manualCreditFields = ["contraAccount", "contraCustomer", "contraVendor", "contraOwnaccount"] as const +const bookingModes = new Set(["expense", "depreciation_single", "depreciation_bundle"]) +const depreciationMethods = new Set(["linear", "degressive"]) + +const requiredNumber = (args: Record, key: string) => { + const value = Number(args[key]) + if (!hasValue(args[key]) || !Number.isFinite(value)) throw new Error(`${key} muss eine gültige Zahl sein`) + return value +} + +const optionalNumericId = (args: Record, key: string) => { + if (!hasValue(args[key])) return undefined + const value = Number(args[key]) + if (!Number.isInteger(value) || value <= 0) throw new Error(`${key} muss eine gültige ID sein`) + return value +} + +export const statementAllocationUuidArg = (args: Record, key: string) => { + if (!hasValue(args[key])) return undefined + const value = String(args[key]).trim() + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) { + throw new Error(`${key} muss eine gültige UUID sein`) + } + return value +} + +const validDate = (value: string | null) => + Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(`${value}T00:00:00Z`))) + +export const prepareStatementAllocationInput = (args: Record) => { + const payload: Record = {} + const amount = requiredNumber(args, "amount") + const bankstatement = optionalNumericId(args, "bankstatement") + const bookingMode = stringArg(args, "bookingMode") || "expense" + + if (!bookingModes.has(bookingMode)) throw new Error(`Ungültige Aufwandsart: ${bookingMode}`) + + payload.amount = amount + if (args.description !== undefined) payload.description = stringArg(args, "description") + payload.bookingMode = bookingMode + if (args.datevTaxKey !== undefined) payload.datevTaxKey = stringArg(args, "datevTaxKey") + + for (const field of ["createddocument", "incominginvoice", "account", "customer", "vendor"] as const) { + const value = optionalNumericId(args, field) + if (value !== undefined) payload[field] = value + } + for (const field of ["ownaccount", "contraOwnaccount"] as const) { + const value = statementAllocationUuidArg(args, field) + if (value !== undefined) payload[field] = value + } + for (const field of ["contraAccount", "contraCustomer", "contraVendor"] as const) { + const value = optionalNumericId(args, field) + if (value !== undefined) payload[field] = value + } + + if (bankstatement !== undefined) { + const selectedTargets = statementAllocationTargetFields.filter((field) => hasValue(payload[field])) + if (selectedTargets.length !== 1) { + throw new Error("Für eine Bankzuweisung muss genau ein Ziel ausgewählt werden") + } + if (amount === 0) throw new Error("Der Zuweisungsbetrag darf nicht 0 sein") + if (manualCreditFields.some((field) => hasValue(payload[field]))) { + throw new Error("Haben-Konten sind nur bei manuellen Buchungen zulässig") + } + + payload.bankstatement = bankstatement + } else { + const manualBookingDate = stringArg(args, "manualBookingDate") + if (!validDate(manualBookingDate)) { + throw new Error("Für manuelle Buchungen ist ein gültiges Buchungsdatum erforderlich") + } + if (amount <= 0) throw new Error("Für manuelle Buchungen muss der Betrag größer als 0 sein") + + const manualInvoiceSide = stringArg(args, "manualInvoiceSide") + const hasInvoice = hasValue(payload.incominginvoice) + if (hasInvoice && manualInvoiceSide !== "debit" && manualInvoiceSide !== "credit") { + throw new Error("Für zugewiesene Eingangsbelege muss Soll oder Haben ausgewählt sein") + } + if (!hasInvoice && manualInvoiceSide) { + throw new Error("manualInvoiceSide ist nur zusammen mit incominginvoice zulässig") + } + + const debitCount = manualDebitFields.filter((field) => hasValue(payload[field])).length + + (hasInvoice && manualInvoiceSide === "debit" ? 1 : 0) + const creditCount = manualCreditFields.filter((field) => hasValue(payload[field])).length + + (hasInvoice && manualInvoiceSide === "credit" ? 1 : 0) + if (debitCount !== 1 || creditCount !== 1) { + throw new Error("Für manuelle Buchungen muss genau ein Soll- und ein Haben-Konto ausgewählt werden") + } + + payload.bankstatement = null + payload.manualBookingDate = manualBookingDate + if (hasInvoice) payload.manualInvoiceSide = manualInvoiceSide + } + + if (bookingMode === "expense") return payload + + const depreciationMonths = requiredNumber(args, "depreciationMonths") + const depreciationStartDate = stringArg(args, "depreciationStartDate") + const depreciationMethod = stringArg(args, "depreciationMethod") || "linear" + const residualValue = args.residualValue === undefined ? 0 : requiredNumber(args, "residualValue") + + if (!Number.isInteger(depreciationMonths) || depreciationMonths <= 0) { + throw new Error("Die Abschreibungsdauer muss eine positive Anzahl Monate sein") + } + if (!validDate(depreciationStartDate)) throw new Error("Ein gültiger Abschreibungsbeginn ist erforderlich") + if (!depreciationMethods.has(depreciationMethod)) { + throw new Error(`Ungültige Abschreibungsmethode: ${depreciationMethod}`) + } + if (residualValue < 0) throw new Error("Der Restwert darf nicht negativ sein") + + payload.depreciationMonths = depreciationMonths + payload.depreciationStartDate = depreciationStartDate + payload.depreciationMethod = depreciationMethod + if (args.depreciationLabel !== undefined) payload.depreciationLabel = stringArg(args, "depreciationLabel") + payload.depreciationGroup = bookingMode === "depreciation_bundle" ? stringArg(args, "depreciationGroup") : null + payload.residualValue = residualValue + + if (bookingMode === "depreciation_bundle" && !payload.depreciationGroup) { + throw new Error("Für eine Sammelabschreibung ist eine Abschreibungsgruppe erforderlich") + } + + return payload +} diff --git a/backend/src/mcp/tools/accounting.ts b/backend/src/mcp/tools/accounting.ts index 91c4d16..ae840c3 100644 --- a/backend/src/mcp/tools/accounting.ts +++ b/backend/src/mcp/tools/accounting.ts @@ -4,16 +4,26 @@ import { accounts, bankstatements, createddocuments, + customers, filetags, files, folders, incominginvoices, + ownaccounts, statementallocations, + vendors, } from "../../../db/schema" import { useNextNumberRangeNumber } from "../../utils/functions" import { saveFile } from "../../utils/files" +import { insertHistoryItem } from "../../utils/history" +import { + prepareStatementAllocationInput, + statementAllocationUuidArg, +} from "../statementAllocations" import { McpTool } from "../types" +export { prepareStatementAllocationInput } from "../statementAllocations" + const limitFromArgs = (args: Record, fallback = 25) => { const raw = Number(args.limit ?? fallback) if (!Number.isFinite(raw)) return fallback @@ -357,6 +367,43 @@ const validateIncomingInvoiceData = (invoice: Record) => { } } +const assertTenantEntityExists = async ( + context: any, + table: any, + idColumn: any, + tenantColumn: any | null, + id: unknown, + label: string, +) => { + if (!hasValue(id)) return + + const [row] = await context.server.db + .select({ id: idColumn }) + .from(table) + .where(tenantColumn + ? and(eq(idColumn, id as any), eq(tenantColumn, context.tenantId)) + : eq(idColumn, id as any)) + .limit(1) + + if (!row) throw new Error(`${label} nicht gefunden`) +} + +const assertStatementAllocationReferences = async (context: any, payload: Record) => { + await Promise.all([ + assertTenantEntityExists(context, bankstatements, bankstatements.id, bankstatements.tenant, payload.bankstatement, "Bankumsatz"), + assertTenantEntityExists(context, createddocuments, createddocuments.id, createddocuments.tenant, payload.createddocument, "Ausgangsbeleg"), + assertTenantEntityExists(context, incominginvoices, incominginvoices.id, incominginvoices.tenant, payload.incominginvoice, "Eingangsbeleg"), + assertTenantEntityExists(context, accounts, accounts.id, null, payload.account, "Sachkonto"), + assertTenantEntityExists(context, accounts, accounts.id, null, payload.contraAccount, "Haben-Sachkonto"), + assertTenantEntityExists(context, ownaccounts, ownaccounts.id, ownaccounts.tenant, payload.ownaccount, "Zusätzliches Konto"), + assertTenantEntityExists(context, ownaccounts, ownaccounts.id, ownaccounts.tenant, payload.contraOwnaccount, "Zusätzliches Haben-Konto"), + assertTenantEntityExists(context, customers, customers.id, customers.tenant, payload.customer, "Debitor"), + assertTenantEntityExists(context, customers, customers.id, customers.tenant, payload.contraCustomer, "Haben-Debitor"), + assertTenantEntityExists(context, vendors, vendors.id, vendors.tenant, payload.vendor, "Kreditor"), + assertTenantEntityExists(context, vendors, vendors.id, vendors.tenant, payload.contraVendor, "Haben-Kreditor"), + ]) +} + export const accountingTools: McpTool[] = [ { name: "accounting.outgoing_documents.tax_types.list", @@ -1175,4 +1222,113 @@ export const accountingTools: McpTool[] = [ return { rows } }, }, + { + name: "accounting.statement_allocations.create", + title: "Bankzuweisung erstellen", + description: "Erstellt eine Bankzuweisung oder manuelle Soll/Haben-Buchung. Unterstützt Ausgangs- und Eingangsbelege, Sachkonten, zusätzliche Konten, Debitoren, Kreditoren, Teilbeträge, DATEV-Steuerschlüssel und Abschreibungen.", + requiredPermissions: ["accounting.statement_allocations.write"], + inputSchema: { + type: "object", + required: ["amount"], + properties: { + bankstatement: { type: "number", description: "Bankumsatz-ID. Weglassen für eine manuelle Soll/Haben-Buchung." }, + amount: { type: "number", description: "Zuweisungsbetrag mit Vorzeichen des Bankumsatzes; bei manuellen Buchungen positiv." }, + createddocument: { type: "number", description: "Ausgangsbeleg-ID." }, + incominginvoice: { type: "number", description: "Eingangsbeleg-ID." }, + account: { type: "number", description: "Sachkonto-ID beziehungsweise Soll-Sachkonto." }, + ownaccount: { type: "string", description: "UUID eines zusätzlichen Kontos beziehungsweise Soll-Kontos." }, + customer: { type: "number", description: "Debitor-ID beziehungsweise Soll-Debitor." }, + vendor: { type: "number", description: "Kreditor-ID beziehungsweise Soll-Kreditor." }, + contraAccount: { type: "number", description: "Haben-Sachkonto für manuelle Buchungen." }, + contraOwnaccount: { type: "string", description: "UUID eines zusätzlichen Haben-Kontos." }, + contraCustomer: { type: "number", description: "Haben-Debitor für manuelle Buchungen." }, + contraVendor: { type: "number", description: "Haben-Kreditor für manuelle Buchungen." }, + manualBookingDate: { type: "string", format: "date", description: "Buchungsdatum einer manuellen Buchung." }, + manualInvoiceSide: { type: "string", enum: ["debit", "credit"], description: "Soll/Haben-Seite eines Eingangsbelegs in einer manuellen Buchung." }, + description: { type: "string" }, + datevTaxKey: { type: "string", description: "Optionaler DATEV-Steuerschlüssel, z. B. 9, 8, 19 oder 18." }, + bookingMode: { type: "string", enum: ["expense", "depreciation_single", "depreciation_bundle"], default: "expense" }, + depreciationMonths: { type: "number", minimum: 1 }, + depreciationStartDate: { type: "string", format: "date" }, + depreciationMethod: { type: "string", enum: ["linear", "degressive"], default: "linear" }, + depreciationLabel: { type: "string" }, + depreciationGroup: { type: "string" }, + residualValue: { type: "number", minimum: 0, default: 0 }, + }, + }, + async handler(context, args) { + const payload = prepareStatementAllocationInput(args) + await assertStatementAllocationReferences(context, payload) + + const [created] = await context.server.db + .insert(statementallocations) + .values({ + ...payload, + tenant: context.tenantId, + updated_at: new Date(), + updated_by: context.userId, + }) + .returning() + + if (created?.bankstatement) { + await insertHistoryItem(context.server, { + entity: "bankstatements", + entityId: Number(created.bankstatement), + action: "created", + created_by: context.userId, + tenant_id: context.tenantId, + oldVal: null, + newVal: created, + text: "Buchung über MCP erstellt", + }) + } + + return { allocation: created } + }, + }, + { + name: "accounting.statement_allocations.delete", + title: "Bankzuweisung löschen", + description: "Löscht eine Bankzuweisung oder manuelle Soll/Haben-Buchung im aktiven Mandanten.", + requiredPermissions: ["accounting.statement_allocations.write"], + inputSchema: { + type: "object", + required: ["id"], + properties: { + id: { type: "string", description: "UUID der Bankzuweisung." }, + }, + }, + async handler(context, args) { + const id = statementAllocationUuidArg(args, "id") + if (!id) throw new Error("id ist erforderlich") + + const [existing] = await context.server.db + .select() + .from(statementallocations) + .where(and(eq(statementallocations.id, id), eq(statementallocations.tenant, context.tenantId))) + .limit(1) + + if (!existing) throw new Error("Bankzuweisung nicht gefunden") + + const [deleted] = await context.server.db + .delete(statementallocations) + .where(and(eq(statementallocations.id, id), eq(statementallocations.tenant, context.tenantId))) + .returning() + + if (existing.bankstatement) { + await insertHistoryItem(context.server, { + entity: "bankstatements", + entityId: Number(existing.bankstatement), + action: "deleted", + created_by: context.userId, + tenant_id: context.tenantId, + oldVal: existing, + newVal: null, + text: "Buchung über MCP gelöscht", + }) + } + + return { deleted: true, allocation: deleted } + }, + }, ] diff --git a/backend/src/modules/bootstrap.service.ts b/backend/src/modules/bootstrap.service.ts index d53b9bb..df8a8c9 100644 --- a/backend/src/modules/bootstrap.service.ts +++ b/backend/src/modules/bootstrap.service.ts @@ -42,6 +42,7 @@ const adminPermissions = [ "accounting.incoming_invoices.write", "accounting.bank.read", "accounting.statement_allocations.read", + "accounting.statement_allocations.write", "organisation.customers.read", "organisation.projects.read", "organisation.plants.read", diff --git a/backend/tests/mcpStatementAllocations.test.ts b/backend/tests/mcpStatementAllocations.test.ts new file mode 100644 index 0000000..4c18ea9 --- /dev/null +++ b/backend/tests/mcpStatementAllocations.test.ts @@ -0,0 +1,114 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { + accountingTools, + prepareStatementAllocationInput, +} from "../src/mcp/tools/accounting" + +const createTool = () => accountingTools.find((tool) => tool.name === "accounting.statement_allocations.create") +const deleteTool = () => accountingTools.find((tool) => tool.name === "accounting.statement_allocations.delete") + +test("registers write tools for creating and deleting statement allocations", () => { + assert.deepEqual(createTool()?.requiredPermissions, ["accounting.statement_allocations.write"]) + assert.deepEqual(deleteTool()?.requiredPermissions, ["accounting.statement_allocations.write"]) +}) + +test("prepares a direct bank allocation to every target type exposed by the UI", () => { + const base = { + bankstatement: 42, + amount: -125.5, + description: "Teilzahlung", + } + + const targets = [ + ["createddocument", 11], + ["incominginvoice", 12], + ["account", 13], + ["ownaccount", "57d31d62-11a2-47c5-b074-69f9c5ab8bba"], + ["customer", 14], + ["vendor", 15], + ] as const + + for (const [field, value] of targets) { + assert.deepEqual(prepareStatementAllocationInput({ ...base, [field]: value }), { + bankstatement: 42, + amount: -125.5, + description: "Teilzahlung", + bookingMode: "expense", + [field]: value, + }) + } +}) + +test("prepares depreciation metadata for direct account allocations", () => { + assert.deepEqual(prepareStatementAllocationInput({ + bankstatement: 42, + amount: -1200, + account: 13, + bookingMode: "depreciation_bundle", + depreciationMonths: 60, + depreciationStartDate: "2026-08-01", + depreciationMethod: "degressive", + depreciationLabel: "Werkzeug", + depreciationGroup: "BGA 2026", + residualValue: 100, + }), { + bankstatement: 42, + amount: -1200, + account: 13, + bookingMode: "depreciation_bundle", + depreciationMonths: 60, + depreciationStartDate: "2026-08-01", + depreciationMethod: "degressive", + depreciationLabel: "Werkzeug", + depreciationGroup: "BGA 2026", + residualValue: 100, + }) +}) + +test("prepares manual Soll/Haben bookings including incoming invoices", () => { + assert.deepEqual(prepareStatementAllocationInput({ + manualBookingDate: "2026-08-31", + amount: 99.95, + incominginvoice: 7, + manualInvoiceSide: "debit", + contraOwnaccount: "57d31d62-11a2-47c5-b074-69f9c5ab8bba", + datevTaxKey: "9", + description: "Manuelle Buchung", + }), { + bankstatement: null, + manualBookingDate: "2026-08-31", + amount: 99.95, + incominginvoice: 7, + manualInvoiceSide: "debit", + contraOwnaccount: "57d31d62-11a2-47c5-b074-69f9c5ab8bba", + datevTaxKey: "9", + description: "Manuelle Buchung", + bookingMode: "expense", + }) +}) + +test("rejects ambiguous bank targets and invalid manual booking sides", () => { + assert.throws( + () => prepareStatementAllocationInput({ bankstatement: 42, amount: 10, account: 1, vendor: 2 }), + /genau ein Ziel/, + ) + assert.throws( + () => prepareStatementAllocationInput({ manualBookingDate: "2026-08-31", amount: 10, account: 1 }), + /Soll- und ein Haben-Konto/, + ) +}) + +test("rejects incomplete depreciation settings", () => { + assert.throws( + () => prepareStatementAllocationInput({ + bankstatement: 42, + amount: -1200, + account: 13, + bookingMode: "depreciation_bundle", + depreciationMonths: 0, + depreciationStartDate: "2026-08-01", + }), + /Abschreibungsdauer/, + ) +})