import { and, desc, eq, ilike, or } from "drizzle-orm" import { randomUUID } from "node:crypto" import { accounts, bankstatements, createddocuments, filetags, files, folders, incominginvoices, statementallocations, } from "../../../db/schema" import { useNextNumberRangeNumber } from "../../utils/functions" import { saveFile } from "../../utils/files" import { McpTool } from "../types" const limitFromArgs = (args: Record, fallback = 25) => { const raw = Number(args.limit ?? fallback) if (!Number.isFinite(raw)) return fallback return Math.min(Math.max(Math.trunc(raw), 1), 100) } const stringArg = (args: Record, key: string) => { const value = args[key] return typeof value === "string" && value.trim() ? value.trim() : null } const numberArg = (args: Record, key: string) => { const value = Number(args[key]) return Number.isFinite(value) ? value : null } const hasValue = (value: unknown) => value !== null && value !== undefined && value !== "" const hasValidNumber = (value: unknown) => hasValue(value) && Number.isFinite(Number(value)) const MAX_MCP_UPLOAD_BYTES = 20 * 1024 * 1024 const allowedOutgoingDocumentTypes = new Set([ "quotes", "costEstimates", "confirmationOrders", "deliveryNotes", "packingSlips", "invoices", "advanceInvoices", "cancellationInvoices", "serialInvoices", ]) const allowedOutgoingDocumentTaxTypes = new Set([ "Standard", "13b UStG", "19 UStG", "12.3 UStG", ]) const outgoingDocumentTaxableTypes = new Set([ "invoices", "cancellationInvoices", "advanceInvoices", "serialInvoices", "confirmationOrders", "quotes", "costEstimates", ]) const documentTypeArg = (args: Record, key = "type") => { const type = stringArg(args, key) || "invoices" if (!allowedOutgoingDocumentTypes.has(type)) { throw new Error(`Ungültige Belegart: ${type}`) } return type } const normalizeOutgoingDocumentTaxType = (value: unknown) => { const taxType = typeof value === "string" && value.trim() ? value.trim() : "Standard" if (!allowedOutgoingDocumentTaxTypes.has(taxType)) { throw new Error(`Ungültiger Dokument-Steuertyp: ${taxType}`) } return taxType } const applyOutgoingDocumentTaxType = ( payload: Record, args: Record, documentType: string, existingTaxType?: unknown, existingRows?: unknown ) => { if (!outgoingDocumentTaxableTypes.has(documentType)) { payload.taxType = null return } const taxType = args.taxType !== undefined ? normalizeOutgoingDocumentTaxType(args.taxType) : existingTaxType ? normalizeOutgoingDocumentTaxType(existingTaxType) : "Standard" payload.taxType = taxType if (["13b UStG", "19 UStG", "12.3 UStG"].includes(taxType)) { const rows = Array.isArray(payload.rows) ? payload.rows : Array.isArray(existingRows) ? normalizeOutgoingDocumentRows(existingRows) : null if (!rows) return payload.rows = rows.map((row: any) => ({ ...row, taxPercent: 0, })) } } const optionalObjectArg = (args: Record, key: string) => { const value = args[key] return value && typeof value === "object" && !Array.isArray(value) ? value : null } const optionalArrayArg = (args: Record, key: string) => { const value = args[key] return Array.isArray(value) ? value : null } const normalizeOutgoingDocumentRow = (row: unknown, index: number) => { let normalizedRow = row if (typeof normalizedRow === "string") { try { normalizedRow = JSON.parse(normalizedRow) } catch { throw new Error(`Position ${index + 1} ist kein gültiges JSON-Objekt`) } } if (!normalizedRow || typeof normalizedRow !== "object" || Array.isArray(normalizedRow)) { throw new Error(`Position ${index + 1} muss ein Objekt sein`) } const rowPayload = { ...(normalizedRow as Record) } rowPayload.id = rowPayload.id || randomUUID() rowPayload.pos = rowPayload.pos || String(index + 1) rowPayload.mode = rowPayload.mode || "free" rowPayload.inputPrice = hasValidNumber(rowPayload.inputPrice) ? Number(rowPayload.inputPrice) : hasValidNumber(rowPayload.price) ? Number(rowPayload.price) : 0 rowPayload.price = hasValidNumber(rowPayload.price) ? Number(rowPayload.price) : rowPayload.inputPrice rowPayload.quantity = hasValidNumber(rowPayload.quantity) ? Number(rowPayload.quantity) : 1 rowPayload.discountPercent = hasValidNumber(rowPayload.discountPercent) ? Number(rowPayload.discountPercent) : 0 rowPayload.linkedEntitys = Array.isArray(rowPayload.linkedEntitys) ? rowPayload.linkedEntitys : [] return rowPayload } const normalizeOutgoingDocumentRows = (rows: unknown) => { if (!Array.isArray(rows)) return [] return rows.map((row, index) => normalizeOutgoingDocumentRow(row, index)) } const buildOutgoingDocumentPayload = ( args: Record, userId: string, tenantId: number, includeCreateDefaults = false ) => { const payload: Record = { updatedAt: new Date(), updatedBy: userId, } if (includeCreateDefaults) { payload.tenant = tenantId payload.createdBy = userId payload.created_by = userId payload.archived = false payload.state = stringArg(args, "state") || "Entwurf" payload.type = documentTypeArg(args) payload.rows = normalizeOutgoingDocumentRows(optionalArrayArg(args, "rows") || []) } const stringFields = [ "state", "documentDate", "deliveryDate", "deliveryDateEnd", "deliveryDateType", "payment_type", "title", "description", "startText", "endText", ] for (const field of stringFields) { if (args[field] !== undefined) payload[field] = stringArg(args, field) } for (const field of ["customer", "contact", "contract", "project", "plant", "letterhead", "createddocument"] as const) { if (args[field] !== undefined) payload[field] = numberArg(args, field) } for (const field of ["paymentDays"] as const) { if (args[field] !== undefined) payload[field] = numberArg(args, field) } if (args.type !== undefined) payload.type = documentTypeArg(args) if (args.address !== undefined) payload.address = optionalObjectArg(args, "address") if (args.info !== undefined) payload.info = optionalObjectArg(args, "info") if (args.agriculture !== undefined) payload.agriculture = optionalObjectArg(args, "agriculture") if (args.report !== undefined) payload.report = optionalObjectArg(args, "report") || {} if (args.serialConfig !== undefined) payload.serialConfig = optionalObjectArg(args, "serialConfig") || {} if (args.rows !== undefined) payload.rows = normalizeOutgoingDocumentRows(optionalArrayArg(args, "rows") || []) if (args.usedAdvanceInvoices !== undefined) payload.usedAdvanceInvoices = optionalArrayArg(args, "usedAdvanceInvoices") || [] if (typeof args.availableInPortal === "boolean") payload.availableInPortal = args.availableInPortal if (typeof args.advanceInvoiceResolved === "boolean") payload.advanceInvoiceResolved = args.advanceInvoiceResolved if (args.customSurchargePercentage !== undefined) payload.customSurchargePercentage = numberArg(args, "customSurchargePercentage") || 0 return payload } const assertNoManualDocumentNumber = (args: Record) => { if (args.documentNumber !== undefined || args.assignDocumentNumber !== undefined) { throw new Error("Belegnummern werden nur beim Finalisieren vergeben") } } const incomingInvoiceAccountsArg = (args: Record) => { if (args.accounts === undefined) return undefined if (!Array.isArray(args.accounts)) throw new Error("accounts muss ein Array sein") return args.accounts } const base64BufferArg = (args: Record, key: string) => { const value = stringArg(args, key) if (!value) throw new Error(`${key} ist erforderlich`) const base64 = value.includes(",") ? value.split(",").pop() || "" : value const buffer = Buffer.from(base64, "base64") if (!buffer.length) throw new Error(`${key} enthält keine Datei`) if (buffer.length > MAX_MCP_UPLOAD_BYTES) throw new Error("Datei ist größer als 20 MB") return buffer } const loadIncomingInvoiceFileDefaults = async (context: { server: any; tenantId: number }) => { const currentYear = new Date().getFullYear() const [tag] = await context.server.db .select({ id: filetags.id }) .from(filetags) .where(and( eq(filetags.tenant, context.tenantId), eq(filetags.incomingDocumentType, "invoices"), eq(filetags.archived, false) )) .limit(1) const [folder] = await context.server.db .select({ id: folders.id }) .from(folders) .where(and( eq(folders.tenant, context.tenantId), eq(folders.function, "incomingInvoices"), eq(folders.year, currentYear), eq(folders.archived, false) )) .limit(1) return { folderId: folder?.id || null, fileTagId: tag?.id || null, } } const buildIncomingInvoicePayload = ( args: Record, userId: string, tenantId: number, includeCreateDefaults = false ) => { const payload: Record = { updatedAt: new Date(), updatedBy: userId, } if (includeCreateDefaults) { payload.tenant = tenantId payload.state = "Entwurf" payload.expense = args.expense !== undefined ? args.expense === true : true payload.paid = false payload.archived = false } for (const field of ["state", "reference", "date", "dueDate", "description", "paymentType"] as const) { if (args[field] !== undefined) payload[field] = stringArg(args, field) } if (args.vendor !== undefined) payload.vendor = numberArg(args, "vendor") if (args.document !== undefined) payload.document = numberArg(args, "document") if (args.expense !== undefined) payload.expense = args.expense === true if (args.paid !== undefined) payload.paid = args.paid === true const accountsPayload = incomingInvoiceAccountsArg(args) if (accountsPayload !== undefined) payload.accounts = accountsPayload return payload } const isDepreciationBookingMode = (value: unknown) => ["depreciation", "depreciation_bundle"].includes(String(value || "")) const validateIncomingInvoiceData = (invoice: Record) => { const errors: Array<{ message: string; type: "breaking" | "warning" }> = [] if (!invoice.vendor) errors.push({ message: "Es ist kein Lieferant ausgewählt", type: "breaking" }) if (!String(invoice.reference || "").trim()) errors.push({ message: "Es ist keine Referenz angegeben", type: "breaking" }) if (!invoice.date) errors.push({ message: "Es ist kein Datum ausgewählt", type: "breaking" }) if (!Array.isArray(invoice.accounts) || invoice.accounts.length === 0) { errors.push({ message: "Es ist keine Position vorhanden", type: "breaking" }) } ;(Array.isArray(invoice.accounts) ? invoice.accounts : []).forEach((account: any, idx: number) => { const pos = idx + 1 if (!account?.account) errors.push({ message: `Pos ${pos}: Keine Kategorie`, type: "breaking" }) if (!hasValidNumber(account?.amountNet) && !hasValidNumber(account?.amountGross)) { errors.push({ message: `Pos ${pos}: Kein gültiger Betrag`, type: "breaking" }) } if (!account?.taxType) errors.push({ message: `Pos ${pos}: Kein Steuerschlüssel`, type: "breaking" }) if (hasValidNumber(account?.amountNet) && !hasValidNumber(account?.amountTax)) { errors.push({ message: `Pos ${pos}: Steuerbetrag fehlt, bitte Steuer neu berechnen`, type: "warning" }) } if (isDepreciationBookingMode(account?.bookingMode) && !Number(account?.depreciationMonths)) { errors.push({ message: `Pos ${pos}: Abschreibungsdauer fehlt`, type: "breaking" }) } if (account?.bookingMode === "depreciation_bundle" && !String(account?.depreciationGroup || "").trim()) { errors.push({ message: `Pos ${pos}: Sammelposten benötigt einen Gruppennamen`, type: "breaking" }) } }) const order = { breaking: 0, warning: 1 } errors.sort((a, b) => order[a.type] - order[b.type]) return { valid: errors.every((error) => error.type !== "breaking"), errors, blockingErrors: errors.filter((error) => error.type === "breaking"), warnings: errors.filter((error) => error.type === "warning"), } } export const accountingTools: McpTool[] = [ { name: "accounting.outgoing_documents.tax_types.list", title: "Steuertypen für Ausgangsbelege auflisten", description: "Listet die unterstützten Dokument-Steuertypen für Ausgangsbelege.", requiredPermissions: ["accounting.outgoing_documents.read"], inputSchema: { type: "object", properties: {}, }, async handler() { return { rows: [ { key: "Standard", label: "Standard", forcesZeroTaxPercent: false }, { key: "13b UStG", label: "13b UStG", forcesZeroTaxPercent: true }, { key: "19 UStG", label: "19 UStG Kleinunternehmer", forcesZeroTaxPercent: true }, { key: "12.3 UStG", label: "12.3 UStG", forcesZeroTaxPercent: true }, ], } }, }, { name: "accounting.outgoing_documents.list", title: "Ausgangsbelege auflisten", description: "Listet Ausgangsbelege des aktiven Mandanten mit optionalen Filtern.", requiredPermissions: ["accounting.outgoing_documents.read"], inputSchema: { type: "object", properties: { type: { type: "string", description: "Belegart, z. B. invoices, quotes oder deliveryNotes." }, state: { type: "string", description: "Optionaler Statusfilter, z. B. Entwurf oder Gebucht." }, customer: { type: "number" }, project: { type: "number" }, includeArchived: { type: "boolean", default: false }, limit: { type: "number", minimum: 1, maximum: 100 }, }, }, async handler(context, args) { const conditions = [eq(createddocuments.tenant, context.tenantId)] const type = stringArg(args, "type") const state = stringArg(args, "state") const customer = numberArg(args, "customer") const project = numberArg(args, "project") if (type) { if (!allowedOutgoingDocumentTypes.has(type)) throw new Error(`Ungültige Belegart: ${type}`) conditions.push(eq(createddocuments.type, type)) } if (state) conditions.push(eq(createddocuments.state, state)) if (customer) conditions.push(eq(createddocuments.customer, customer)) if (project) conditions.push(eq(createddocuments.project, project)) if (args.includeArchived !== true) conditions.push(eq(createddocuments.archived, false)) const rows = await context.server.db .select() .from(createddocuments) .where(and(...conditions)) .orderBy(desc(createddocuments.createdAt)) .limit(limitFromArgs(args)) return { rows } }, }, { name: "accounting.outgoing_documents.get", title: "Ausgangsbeleg laden", description: "Lädt einen Ausgangsbeleg des aktiven Mandanten anhand seiner ID.", requiredPermissions: ["accounting.outgoing_documents.read"], inputSchema: { type: "object", required: ["id"], properties: { id: { type: "number" }, }, }, async handler(context, args) { const id = numberArg(args, "id") if (!id) throw new Error("id ist erforderlich") const rows = await context.server.db .select() .from(createddocuments) .where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId))) .limit(1) if (!rows[0]) throw new Error("Ausgangsbeleg nicht gefunden") return { document: rows[0] } }, }, { name: "accounting.outgoing_documents.create", title: "Ausgangsbeleg erstellen", description: "Erstellt einen Ausgangsbeleg-Entwurf im aktiven Mandanten. Belegnummern werden erst beim Finalisieren vergeben.", requiredPermissions: ["accounting.outgoing_documents.write"], inputSchema: { type: "object", required: ["type"], properties: { type: { type: "string" }, customer: { type: "number" }, contact: { type: "number" }, contract: { type: "number" }, project: { type: "number" }, plant: { type: "number" }, documentDate: { type: "string" }, deliveryDate: { type: "string" }, deliveryDateEnd: { type: "string" }, deliveryDateType: { type: "string" }, paymentDays: { type: "number" }, payment_type: { type: "string" }, taxType: { type: "string", enum: ["Standard", "13b UStG", "19 UStG", "12.3 UStG"], description: "Steuertyp des gesamten Ausgangsdokuments, nicht der einzelne USt-Satz einer Position.", }, title: { type: "string" }, description: { type: "string" }, startText: { type: "string" }, endText: { type: "string" }, address: { type: "object" }, rows: { type: "array" }, letterhead: { type: "number" }, availableInPortal: { type: "boolean" }, customSurchargePercentage: { type: "number" }, report: { type: "object" }, }, }, async handler(context, args) { assertNoManualDocumentNumber(args) const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId, true) payload.state = "Entwurf" applyOutgoingDocumentTaxType(payload, args, String(payload.type)) const [created] = await context.server.db .insert(createddocuments) .values(payload as any) .returning() return { document: created } }, }, { name: "accounting.outgoing_documents.update", title: "Ausgangsbeleg aktualisieren", description: "Aktualisiert einen Ausgangsbeleg im aktiven Mandanten, solange er noch nicht finalisiert ist.", requiredPermissions: ["accounting.outgoing_documents.write"], inputSchema: { type: "object", required: ["id"], properties: { id: { type: "number" }, type: { type: "string" }, state: { type: "string" }, customer: { type: "number" }, contact: { type: "number" }, contract: { type: "number" }, project: { type: "number" }, plant: { type: "number" }, documentDate: { type: "string" }, deliveryDate: { type: "string" }, deliveryDateEnd: { type: "string" }, deliveryDateType: { type: "string" }, paymentDays: { type: "number" }, payment_type: { type: "string" }, taxType: { type: "string", enum: ["Standard", "13b UStG", "19 UStG", "12.3 UStG"], description: "Steuertyp des gesamten Ausgangsdokuments, nicht der einzelne USt-Satz einer Position.", }, title: { type: "string" }, description: { type: "string" }, startText: { type: "string" }, endText: { type: "string" }, address: { type: "object" }, rows: { type: "array" }, letterhead: { type: "number" }, availableInPortal: { type: "boolean" }, customSurchargePercentage: { type: "number" }, report: { type: "object" }, }, }, async handler(context, args) { const id = numberArg(args, "id") if (!id) throw new Error("id ist erforderlich") assertNoManualDocumentNumber(args) const [existing] = await context.server.db .select() .from(createddocuments) .where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId))) .limit(1) if (!existing) throw new Error("Ausgangsbeleg nicht gefunden") if (existing.state !== "Entwurf") throw new Error("Finalisierte Ausgangsbelege können nicht über update geändert werden") const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId) payload.state = "Entwurf" applyOutgoingDocumentTaxType(payload, args, String(payload.type || existing.type), existing.taxType, existing.rows) const [updated] = await context.server.db .update(createddocuments) .set(payload as any) .where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId))) .returning() return { document: updated } }, }, { name: "accounting.outgoing_documents.finalize", title: "Ausgangsbeleg finalisieren", description: "Finalisiert einen Ausgangsbeleg, setzt den Status auf Gebucht und vergibt dabei genau einmal eine Belegnummer.", requiredPermissions: ["accounting.outgoing_documents.write"], inputSchema: { type: "object", required: ["id"], properties: { id: { type: "number" }, documentDate: { type: "string" }, deliveryDate: { type: "string" }, deliveryDateEnd: { type: "string" }, paymentDays: { type: "number" }, payment_type: { type: "string" }, taxType: { type: "string", enum: ["Standard", "13b UStG", "19 UStG", "12.3 UStG"], description: "Steuertyp des gesamten Ausgangsdokuments, nicht der einzelne USt-Satz einer Position.", }, rows: { type: "array" }, report: { type: "object" }, availableInPortal: { type: "boolean" }, }, }, async handler(context, args) { const id = numberArg(args, "id") if (!id) throw new Error("id ist erforderlich") assertNoManualDocumentNumber(args) const [existing] = await context.server.db .select() .from(createddocuments) .where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId))) .limit(1) if (!existing) throw new Error("Ausgangsbeleg nicht gefunden") if (existing.documentNumber) throw new Error("Ausgangsbeleg wurde bereits finalisiert") const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId) const result = await useNextNumberRangeNumber(context.server, context.tenantId, existing.type) payload.state = "Gebucht" payload.documentNumber = result.usedNumber applyOutgoingDocumentTaxType(payload, args, existing.type, existing.taxType, existing.rows) const [updated] = await context.server.db .update(createddocuments) .set(payload as any) .where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId))) .returning() return { document: updated } }, }, { name: "accounting.outgoing_documents.archive", title: "Ausgangsbeleg archivieren", description: "Archiviert einen Ausgangsbeleg im aktiven Mandanten.", requiredPermissions: ["accounting.outgoing_documents.write"], inputSchema: { type: "object", required: ["id"], properties: { id: { type: "number" }, }, }, async handler(context, args) { const id = numberArg(args, "id") if (!id) throw new Error("id ist erforderlich") const [updated] = await context.server.db .update(createddocuments) .set({ archived: true, updatedAt: new Date(), updatedBy: context.userId, }) .where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId))) .returning() if (!updated) throw new Error("Ausgangsbeleg nicht gefunden") return { document: updated } }, }, { name: "accounting.accounts.search", title: "Konten suchen", description: "Sucht Sachkonten im aktiven Kontenrahmen des Mandanten.", requiredPermissions: ["accounting.accounts.read"], inputSchema: { type: "object", properties: { query: { type: "string", description: "Suchtext für Kontonummer, Bezeichnung oder Beschreibung." }, limit: { type: "number", minimum: 1, maximum: 100 }, }, }, async handler(context, args) { const tenantRows = await context.server.db.query.tenants.findMany({ where: (tenant, { eq }) => eq(tenant.id, context.tenantId), columns: { accountChart: true, }, limit: 1, }) const accountChart = tenantRows[0]?.accountChart || "skr03" const query = stringArg(args, "query") const limit = limitFromArgs(args) const whereCond = query ? and( eq(accounts.accountChart, accountChart), or( ilike(accounts.number, `%${query}%`), ilike(accounts.label, `%${query}%`), ilike(accounts.description, `%${query}%`) ) ) : eq(accounts.accountChart, accountChart) const rows = await context.server.db .select() .from(accounts) .where(whereCond) .orderBy(accounts.number) .limit(limit) return { accountChart, rows } }, }, { name: "accounting.incoming_invoices.list", title: "Eingangsrechnungen auflisten", description: "Listet Eingangsrechnungen des aktiven Mandanten.", requiredPermissions: ["accounting.incoming_invoices.read"], inputSchema: { type: "object", properties: { state: { type: "string", description: "Optionaler Statusfilter." }, paid: { type: "boolean", description: "Optionaler Zahlungsstatus." }, includeArchived: { type: "boolean", default: false }, limit: { type: "number", minimum: 1, maximum: 100 }, }, }, async handler(context, args) { const conditions = [eq(incominginvoices.tenant, context.tenantId)] const state = stringArg(args, "state") if (state) conditions.push(eq(incominginvoices.state, state)) if (typeof args.paid === "boolean") conditions.push(eq(incominginvoices.paid, args.paid)) if (args.includeArchived !== true) conditions.push(eq(incominginvoices.archived, false)) const rows = await context.server.db .select() .from(incominginvoices) .where(and(...conditions)) .orderBy(desc(incominginvoices.createdAt)) .limit(limitFromArgs(args)) return { rows } }, }, { name: "accounting.incoming_invoices.get", title: "Eingangsrechnung laden", description: "Lädt eine Eingangsrechnung des aktiven Mandanten anhand ihrer ID.", requiredPermissions: ["accounting.incoming_invoices.read"], inputSchema: { type: "object", required: ["id"], properties: { id: { type: "number" }, }, }, async handler(context, args) { const id = numberArg(args, "id") if (!id) throw new Error("id ist erforderlich") const rows = await context.server.db .select() .from(incominginvoices) .where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId))) .limit(1) if (!rows[0]) throw new Error("Eingangsrechnung nicht gefunden") return { invoice: rows[0] } }, }, { name: "accounting.incoming_invoices.files.list", title: "Dateien eines Eingangsbelegs auflisten", description: "Listet Dateien, die mit einem Eingangsbeleg im aktiven Mandanten verknüpft sind.", requiredPermissions: ["accounting.incoming_invoices.read"], inputSchema: { type: "object", required: ["id"], properties: { id: { type: "number" }, }, }, async handler(context, args) { const id = numberArg(args, "id") if (!id) throw new Error("id ist erforderlich") const rows = await context.server.db .select() .from(files) .where(and( eq(files.incominginvoice, id), eq(files.tenant, context.tenantId), eq(files.archived, false) )) .orderBy(desc(files.createdAt)) return { rows } }, }, { name: "accounting.incoming_invoices.files.upload", title: "Datei für Eingangsbeleg hochladen", description: "Lädt eine PDF- oder Bilddatei als Base64 hoch, verknüpft sie optional mit einem Eingangsbeleg oder stößt die automatische Vorbereitung an.", requiredPermissions: ["accounting.incoming_invoices.write"], inputSchema: { type: "object", required: ["filename", "contentBase64"], properties: { filename: { type: "string", description: "Dateiname, z. B. rechnung.pdf." }, contentBase64: { type: "string", description: "Dateiinhalt als Base64 oder Data-URL." }, mimeType: { type: "string", description: "MIME-Type, z. B. application/pdf oder image/jpeg." }, invoiceId: { type: "number", description: "Optional: vorhandener Eingangsbeleg, mit dem die Datei verknüpft wird." }, prepare: { type: "boolean", default: true, description: "Wenn kein invoiceId und keine invoice-Daten übergeben werden, wird nach dem Upload die automatische Eingangsbeleg-Vorbereitung ausgeführt.", }, invoice: { type: "object", description: "Optional: Daten für einen neuen Eingangsbeleg-Entwurf, der direkt mit der Datei verknüpft wird.", properties: { vendor: { type: "number" }, reference: { type: "string" }, date: { type: "string" }, dueDate: { type: "string" }, document: { type: "number" }, description: { type: "string" }, paymentType: { type: "string" }, accounts: { type: "array" }, expense: { type: "boolean" }, }, }, }, }, async handler(context, args) { const filename = stringArg(args, "filename") if (!filename) throw new Error("filename ist erforderlich") const buffer = base64BufferArg(args, "contentBase64") const mimeType = stringArg(args, "mimeType") || "application/octet-stream" const invoiceId = numberArg(args, "invoiceId") const invoiceArgs = optionalObjectArg(args, "invoice") as Record | null const shouldPrepare = args.prepare !== false && !invoiceId && !invoiceArgs let linkedInvoice: any = null if (invoiceId) { const [existing] = await context.server.db .select() .from(incominginvoices) .where(and(eq(incominginvoices.id, invoiceId), eq(incominginvoices.tenant, context.tenantId))) .limit(1) if (!existing) throw new Error("Eingangsbeleg nicht gefunden") linkedInvoice = existing } else if (invoiceArgs) { const payload = buildIncomingInvoicePayload(invoiceArgs, context.userId, context.tenantId, true) payload.state = "Entwurf" const [createdInvoice] = await context.server.db .insert(incominginvoices) .values(payload as any) .returning() linkedInvoice = createdInvoice } const defaults = await loadIncomingInvoiceFileDefaults(context) const saved = await saveFile( context.server, context.tenantId, null, { filename, content: buffer, contentType: mimeType, }, defaults.folderId, defaults.fileTagId, { incominginvoice: linkedInvoice?.id || null, createdBy: context.userId, updatedBy: context.userId, updatedAt: new Date(), } ) if (!saved) throw new Error("Datei konnte nicht gespeichert werden") if (shouldPrepare) { await context.server.services.prepareIncomingInvoices.run(context.tenantId) } const [file] = await context.server.db .select() .from(files) .where(and(eq(files.id, saved.id), eq(files.tenant, context.tenantId))) .limit(1) if (file?.incominginvoice && !linkedInvoice) { const [preparedInvoice] = await context.server.db .select() .from(incominginvoices) .where(and(eq(incominginvoices.id, file.incominginvoice), eq(incominginvoices.tenant, context.tenantId))) .limit(1) linkedInvoice = preparedInvoice || null } return { file, invoice: linkedInvoice, validation: linkedInvoice ? validateIncomingInvoiceData(linkedInvoice as Record) : null, prepared: Boolean(shouldPrepare && linkedInvoice), } }, }, { name: "accounting.incoming_invoices.validate", title: "Eingangsbeleg validieren", description: "Prüft einen Eingangsbeleg mit denselben Pflichtregeln wie die FEDEO-Oberfläche vor dem Buchen.", requiredPermissions: ["accounting.incoming_invoices.read"], inputSchema: { type: "object", required: ["id"], properties: { id: { type: "number" }, }, }, async handler(context, args) { const id = numberArg(args, "id") if (!id) throw new Error("id ist erforderlich") const rows = await context.server.db .select() .from(incominginvoices) .where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId))) .limit(1) if (!rows[0]) throw new Error("Eingangsbeleg nicht gefunden") return validateIncomingInvoiceData(rows[0] as Record) }, }, { name: "accounting.incoming_invoices.create", title: "Eingangsbeleg erstellen", description: "Erstellt einen Eingangsbeleg-Entwurf im aktiven Mandanten.", requiredPermissions: ["accounting.incoming_invoices.write"], inputSchema: { type: "object", properties: { vendor: { type: "number" }, reference: { type: "string" }, date: { type: "string" }, dueDate: { type: "string" }, document: { type: "number" }, description: { type: "string" }, paymentType: { type: "string" }, accounts: { type: "array" }, expense: { type: "boolean" }, }, }, async handler(context, args) { const payload = buildIncomingInvoicePayload(args, context.userId, context.tenantId, true) payload.state = "Entwurf" const [created] = await context.server.db .insert(incominginvoices) .values(payload as any) .returning() return { invoice: created, validation: validateIncomingInvoiceData(created as Record) } }, }, { name: "accounting.incoming_invoices.update", title: "Eingangsbeleg bearbeiten", description: "Bearbeitet einen noch nicht gebuchten Eingangsbeleg im aktiven Mandanten.", requiredPermissions: ["accounting.incoming_invoices.write"], inputSchema: { type: "object", required: ["id"], properties: { id: { type: "number" }, vendor: { type: "number" }, reference: { type: "string" }, date: { type: "string" }, dueDate: { type: "string" }, document: { type: "number" }, description: { type: "string" }, paymentType: { type: "string" }, accounts: { type: "array" }, expense: { type: "boolean" }, }, }, async handler(context, args) { const id = numberArg(args, "id") if (!id) throw new Error("id ist erforderlich") const [existing] = await context.server.db .select() .from(incominginvoices) .where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId))) .limit(1) if (!existing) throw new Error("Eingangsbeleg nicht gefunden") if (existing.state === "Gebucht") throw new Error("Gebuchte Eingangsbelege können nicht über update geändert werden") const payload = buildIncomingInvoicePayload(args, context.userId, context.tenantId) payload.state = existing.state || "Entwurf" const [updated] = await context.server.db .update(incominginvoices) .set(payload as any) .where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId))) .returning() return { invoice: updated, validation: validateIncomingInvoiceData(updated as Record) } }, }, { name: "accounting.incoming_invoices.book", title: "Eingangsbeleg buchen", description: "Validiert und bucht einen vorbereiteten oder als Entwurf gespeicherten Eingangsbeleg.", requiredPermissions: ["accounting.incoming_invoices.write"], inputSchema: { type: "object", required: ["id"], properties: { id: { type: "number" }, }, }, async handler(context, args) { const id = numberArg(args, "id") if (!id) throw new Error("id ist erforderlich") const [existing] = await context.server.db .select() .from(incominginvoices) .where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId))) .limit(1) if (!existing) throw new Error("Eingangsbeleg nicht gefunden") if (existing.state === "Gebucht") return { invoice: existing, validation: validateIncomingInvoiceData(existing as Record) } const validation = validateIncomingInvoiceData(existing as Record) if (!validation.valid) { return { booked: false, invoice: existing, validation, } } const [updated] = await context.server.db .update(incominginvoices) .set({ state: "Gebucht", updatedAt: new Date(), updatedBy: context.userId, }) .where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId))) .returning() return { booked: true, invoice: updated, validation: validateIncomingInvoiceData(updated as Record), } }, }, { name: "accounting.incoming_invoices.archive", title: "Eingangsbeleg archivieren", description: "Archiviert einen Eingangsbeleg im aktiven Mandanten.", requiredPermissions: ["accounting.incoming_invoices.write"], inputSchema: { type: "object", required: ["id"], properties: { id: { type: "number" }, }, }, async handler(context, args) { const id = numberArg(args, "id") if (!id) throw new Error("id ist erforderlich") const [updated] = await context.server.db .update(incominginvoices) .set({ archived: true, updatedAt: new Date(), updatedBy: context.userId, }) .where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId))) .returning() if (!updated) throw new Error("Eingangsbeleg nicht gefunden") return { invoice: updated } }, }, { name: "accounting.bank_statements.list", title: "Bankumsätze auflisten", description: "Listet Bankumsätze des aktiven Mandanten.", requiredPermissions: ["accounting.bank.read"], inputSchema: { type: "object", properties: { account: { type: "number", description: "Optionale Bankkonto-ID." }, includeArchived: { type: "boolean", default: false }, limit: { type: "number", minimum: 1, maximum: 100 }, }, }, async handler(context, args) { const conditions = [eq(bankstatements.tenant, context.tenantId)] const account = numberArg(args, "account") if (account) conditions.push(eq(bankstatements.account, account)) if (args.includeArchived !== true) conditions.push(eq(bankstatements.archived, false)) const rows = await context.server.db .select() .from(bankstatements) .where(and(...conditions)) .orderBy(desc(bankstatements.date), desc(bankstatements.id)) .limit(limitFromArgs(args)) return { rows } }, }, { name: "accounting.bank_statements.get", title: "Bankumsatz laden", description: "Lädt einen Bankumsatz des aktiven Mandanten anhand seiner ID.", requiredPermissions: ["accounting.bank.read"], inputSchema: { type: "object", required: ["id"], properties: { id: { type: "number" }, }, }, async handler(context, args) { const id = numberArg(args, "id") if (!id) throw new Error("id ist erforderlich") const rows = await context.server.db .select() .from(bankstatements) .where(and(eq(bankstatements.id, id), eq(bankstatements.tenant, context.tenantId))) .limit(1) if (!rows[0]) throw new Error("Bankumsatz nicht gefunden") return { bankStatement: rows[0] } }, }, { name: "accounting.statement_allocations.list", title: "Buchungszuordnungen auflisten", description: "Listet Buchungszuordnungen des aktiven Mandanten.", requiredPermissions: ["accounting.statement_allocations.read"], inputSchema: { type: "object", properties: { bankstatement: { type: "number" }, incominginvoice: { type: "number" }, includeArchived: { type: "boolean", default: false }, limit: { type: "number", minimum: 1, maximum: 100 }, }, }, async handler(context, args) { const conditions = [eq(statementallocations.tenant, context.tenantId)] const bankstatement = numberArg(args, "bankstatement") const incominginvoice = numberArg(args, "incominginvoice") if (bankstatement) conditions.push(eq(statementallocations.bankstatement, bankstatement)) if (incominginvoice) conditions.push(eq(statementallocations.incominginvoice, incominginvoice)) if (args.includeArchived !== true) conditions.push(eq(statementallocations.archived, false)) const rows = await context.server.db .select() .from(statementallocations) .where(and(...conditions)) .orderBy(desc(statementallocations.created_at)) .limit(limitFromArgs(args)) return { rows } }, }, ]