import { outgoingAddressSchema, outgoingRowsSchema, outgoingReportSchema } from "../documentSchemas" import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination" import { and, desc, eq, ilike, inArray, or } from "drizzle-orm" import { randomUUID } from "node:crypto" import { validateBankAllocationCostCentre } from "../../modules/bank-allocation-details" import { accounts, bankstatements, createddocuments, customers, filetags, files, folders, incominginvoices, ownaccounts, serialExecutions, statementallocations, vendors, } from "../../../db/schema" import { useNextNumberRangeNumber } from "../../utils/functions" import { saveFile } from "../../utils/files" import { insertHistoryItem } from "../../utils/history" import { executeManualGeneration, finishManualGeneration } from "../../modules/serialexecution.service" import { updateOutgoingDocumentCostCentres } from "../../modules/outgoing-document-cost-centres.service" import { updateIncomingInvoiceCostCentres } from "../../modules/incoming-invoice-cost-centres.service" import { loadBankBalances } from "../../modules/bank-balances.service" import { dateField } from "../masterdataFields" import { prepareStatementAllocationInput, statementAllocationUuidArg, } from "../statementAllocations" import { McpContext, McpTool } from "../types" import { applyOutgoingRowCatalogDefaults } from "../outgoingRowDefaults" import { outgoingStatusTools } from "./outgoingStatus" export { prepareStatementAllocationInput } from "../statementAllocations" 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 UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i const serialConfigSchema = { type: "object", description: "Ausführungsplan einer Serienrechnungsvorlage.", properties: { firstExecution: { type: "string", description: "Datum der ersten Ausführung als ISO-8601-Wert." }, executionUntil: { type: ["string", "null"], description: "Optionales Datum der letzten Ausführung als ISO-8601-Wert." }, intervall: { type: "string", enum: ["wöchentlich", "2 - wöchentlich", "monatlich", "vierteljährlich", "halbjährlich", "jährlich"], }, active: { type: "boolean" }, dateDirection: { type: "string", enum: ["Rückwirkend", "Im Voraus"] }, }, } 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) } if (args.costcentre !== undefined) payload.costcentre = stringArg(args, "costcentre") 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, } } export type IncomingInvoiceFileUpload = { filename: string buffer: Buffer mimeType: string invoiceId?: number | null invoiceArgs?: Record | null prepare?: boolean } /** Shared by the Base64 tool and the binary MCP upload endpoint. */ export async function uploadIncomingInvoiceFile(context: McpContext, upload: IncomingInvoiceFileUpload) { const { filename, buffer, mimeType } = upload const invoiceId = upload.invoiceId || null const invoiceArgs = upload.invoiceArgs || null const shouldPrepare = upload.prepare !== false && !invoiceId && !invoiceArgs if (!buffer.length) throw new Error("Datei ist leer") if (buffer.length > MAX_MCP_UPLOAD_BYTES) throw new Error("Datei ist größer als 20 MB") 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) { // Only the uploaded file; other open invoice files stay for an explicit preparation run. await context.server.services.prepareIncomingInvoices.run(context.tenantId, { fileIds: [saved.id] }) } 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), } } 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"), } } 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([ validateBankAllocationCostCentre(context.server.db, context.tenantId, payload.costcentre), 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[] = [ ...outgoingStatusTools, { 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" }, ...paginationProperties, }, }, 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)) conditions.push(eq(createddocuments.archived, false)) const rows = await context.server.db .select() .from(createddocuments) .where(and(...conditions)) .orderBy(desc(createddocuments.createdAt), createddocuments.id) .limit(limitFromArgs(args) + 1) .offset(offsetFromArgs(args)) return paginatedResult(rows, args) }, }, { 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), eq(createddocuments.archived, false))) .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", enum: [...allowedOutgoingDocumentTypes] }, customer: { type: "number" }, contact: { type: "number" }, contract: { type: "number" }, project: { type: "number" }, costcentre: { type: "string", description: "Kostenstellen-UUID als Belegvorgabe." }, 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: outgoingAddressSchema, rows: outgoingRowsSchema, letterhead: { type: "number" }, availableInPortal: { type: "boolean" }, customSurchargePercentage: { type: "number" }, report: outgoingReportSchema, serialConfig: serialConfigSchema, }, }, async handler(context, args) { assertNoManualDocumentNumber(args) if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) } 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", enum: [...allowedOutgoingDocumentTypes] }, state: { type: "string" }, customer: { type: "number" }, contact: { type: "number" }, contract: { type: "number" }, project: { type: "number" }, costcentre: { type: "string", description: "Kostenstellen-UUID als Belegvorgabe." }, 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: outgoingAddressSchema, rows: outgoingRowsSchema, letterhead: { type: "number" }, availableInPortal: { type: "boolean" }, customSurchargePercentage: { type: "number" }, report: outgoingReportSchema, serialConfig: serialConfigSchema, }, }, 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") if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) } 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.cost_centres.update", title: "Kostenstellen eines Ausgangsbelegs ändern", description: "Ändert ausschließlich die Beleg- und Positionskostenstellen eines bereits fertiggestellten Ausgangsbelegs.", requiredPermissions: ["accounting.outgoing_documents.write"], inputSchema: { type: "object", required: ["id"], anyOf: [ { required: ["costcentre"] }, { required: ["rowCostCentres"] }, ], properties: { id: { type: "number" }, costcentre: { type: ["string", "null"], description: "UUID der Beleg-Kostenstelle oder null." }, rowCostCentres: { type: "array", items: { type: "object", required: ["rowId", "costcentre"], properties: { rowId: { type: "string", description: "ID der Belegposition." }, costcentre: { type: ["string", "null"], description: "UUID der Positionskostenstelle oder null." }, }, }, }, }, }, async handler(context, args) { const id = numberArg(args, "id") if (!id) throw new Error("id ist erforderlich") const input: { costcentre?: string | null rowCostCentres?: Array<{ rowId: string, costcentre: string | null }> } = {} if (Object.prototype.hasOwnProperty.call(args, "costcentre")) { const costcentre = args.costcentre if (costcentre !== null && (typeof costcentre !== "string" || !UUID_PATTERN.test(costcentre))) { throw new Error("costcentre muss eine gültige UUID oder null sein") } input.costcentre = costcentre as string | null } if (args.rowCostCentres !== undefined) { if (!Array.isArray(args.rowCostCentres)) throw new Error("rowCostCentres muss ein Array sein") input.rowCostCentres = args.rowCostCentres.map((assignment: any) => { const rowId = typeof assignment?.rowId === "string" ? assignment.rowId.trim() : "" const costcentre = assignment?.costcentre if (!rowId) throw new Error("Jede Positionszuordnung benötigt eine rowId") if (costcentre !== null && (typeof costcentre !== "string" || !UUID_PATTERN.test(costcentre))) { throw new Error(`Ungültige Kostenstelle für Position ${rowId}`) } return { rowId, costcentre } }) } const document = await updateOutgoingDocumentCostCentres( context.server, context.tenantId, context.userId, id, input, ) return { document } }, }, { 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: outgoingRowsSchema, report: outgoingReportSchema, 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") if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) } 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.serial_invoices.execute", title: "Serienrechnungslauf starten", description: "Erzeugt aus ausgewählten aktiven Serienrechnungsvorlagen einen neuen Rechnungslauf.", requiredPermissions: ["accounting.outgoing_documents.write"], inputSchema: { type: "object", required: ["executionDate", "templateIds"], properties: { executionDate: { type: "string", description: "Ausführungsdatum als ISO-8601-Wert." }, templateIds: { type: "array", minItems: 1, uniqueItems: true, items: { type: "number" }, }, }, }, async handler(context, args) { const executionDate = stringArg(args, "executionDate") const parsedExecutionDate = executionDate ? new Date(executionDate) : null const templateIds = Array.isArray(args.templateIds) ? [...new Set(args.templateIds.map(Number).filter((id) => Number.isFinite(id) && id > 0))] : [] if (!parsedExecutionDate || Number.isNaN(parsedExecutionDate.getTime())) { throw new Error("executionDate muss ein gültiger ISO-8601-Wert sein") } if (!templateIds.length) throw new Error("templateIds muss mindestens eine gültige ID enthalten") return executeManualGeneration( context.server, parsedExecutionDate, templateIds, context.tenantId, context.userId, ) }, }, { name: "accounting.serial_invoice_executions.list", title: "Serienrechnungsläufe auflisten", description: "Listet die zuletzt gestarteten Serienrechnungsläufe des aktiven Mandanten.", requiredPermissions: ["accounting.outgoing_documents.read"], inputSchema: { type: "object", properties: { status: { type: "string", enum: ["generating", "draft", "processing", "cancelling", "cancelled", "completed", "error"] }, ...paginationProperties, }, }, async handler(context, args) { const conditions = [eq(serialExecutions.tenant, context.tenantId)] const status = stringArg(args, "status") if (status) conditions.push(eq(serialExecutions.status, status)) const rows = await context.server.db .select() .from(serialExecutions) .where(and(...conditions)) .orderBy(desc(serialExecutions.createdAt), serialExecutions.id) .limit(limitFromArgs(args) + 1) .offset(offsetFromArgs(args)) return paginatedResult(rows, args) }, }, { name: "accounting.serial_invoice_executions.finish", title: "Serienrechnungslauf abschließen", description: "Finalisiert die erzeugten Rechnungen eines Serienrechnungslaufs und schließt den Lauf ab.", requiredPermissions: ["accounting.outgoing_documents.write"], inputSchema: { type: "object", required: ["id"], properties: { id: { type: "string", description: "UUID des Serienrechnungslaufs." }, }, }, async handler(context, args) { const id = stringArg(args, "id") if (!id || !UUID_PATTERN.test(id)) throw new Error("id muss eine gültige UUID sein") return finishManualGeneration(context.server, id, context.tenantId) }, }, { 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." }, ...paginationProperties, }, }, 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, accounts.id) .limit(limit + 1) .offset(offsetFromArgs(args)) return { accountChart, ...paginatedResult(rows, args) } }, }, { 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." }, ...paginationProperties, }, }, 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)) conditions.push(eq(incominginvoices.archived, false)) const rows = await context.server.db .select() .from(incominginvoices) .where(and(...conditions)) .orderBy(desc(incominginvoices.createdAt), incominginvoices.id) .limit(limitFromArgs(args) + 1) .offset(offsetFromArgs(args)) return paginatedResult(rows, args) }, }, { 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), eq(incominginvoices.archived, false))) .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" }, ...paginationProperties, }, }, 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), files.id) .limit(limitFromArgs(args) + 1) .offset(offsetFromArgs(args)) return paginatedResult(rows, args) }, }, { 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 dieser Datei an. Mit Dateizugriff besser ohne Base64 über die Adresse aus documents.files.upload_url (target=incoming_invoice) hochladen.", 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") return uploadIncomingInvoiceFile(context, { filename, buffer: base64BufferArg(args, "contentBase64"), mimeType: stringArg(args, "mimeType") || "application/octet-stream", invoiceId: numberArg(args, "invoiceId"), invoiceArgs: optionalObjectArg(args, "invoice") as Record | null, prepare: args.prepare !== false, }) }, }, { 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.prepare", title: "Belegvorbereitung starten", description: "Startet die Belegvorbereitung wie der Button „Belege vorbereiten“: Dateien vom Typ Eingangsrechnung ohne verknüpften Beleg werden ausgelesen (strukturierte E-Rechnung, sonst KI) und als Eingangsbeleg-Entwurf angelegt. Optional nur für bestimmte Datei-IDs. Kann je Datei einige Sekunden dauern.", requiredPermissions: ["accounting.incoming_invoices.write"], inputSchema: { type: "object", properties: { fileIds: { type: "array", items: { type: "string", format: "uuid" }, minItems: 1, maxItems: 50, description: "Optionale Datei-IDs; ohne Angabe alle offenen Eingangsrechnungsdateien.", }, }, additionalProperties: false, }, async handler(context, args) { let fileIds: string[] | undefined if (args.fileIds !== undefined) { if (!Array.isArray(args.fileIds) || !args.fileIds.length || args.fileIds.length > 50) { throw new Error("fileIds muss 1 bis 50 Datei-IDs enthalten") } if (args.fileIds.some((id) => typeof id !== "string" || !UUID_PATTERN.test(id))) { throw new Error("fileIds darf nur gültige UUIDs enthalten") } fileIds = [...new Set((args.fileIds as string[]).map((id) => id.toLowerCase()))] } const result = await context.server.services.prepareIncomingInvoices.run(context.tenantId, { fileIds }) if (result.error && !result.filesFound) throw new Error(result.error) const invoiceIds = result.created.map((entry) => entry.invoiceId) const invoices = invoiceIds.length ? await context.server.db .select() .from(incominginvoices) .where(and(eq(incominginvoices.tenant, context.tenantId), inArray(incominginvoices.id, invoiceIds))) : [] const handled = new Set([...result.created, ...result.skipped].map((entry) => entry.fileId)) return { filesFound: result.filesFound, created: result.created.map((entry) => { const invoice = invoices.find((row) => row.id === entry.invoiceId) || null return { ...entry, invoice, validation: invoice ? validateIncomingInvoiceData(invoice as Record) : null } }), skipped: [ ...result.skipped, ...(fileIds || []).filter((id) => !handled.has(id)) .map((fileId) => ({ fileId, reason: "Keine offene Eingangsrechnungsdatei (nicht gefunden, archiviert, anderer Dateityp oder bereits vorbereitet)" })), ], } }, }, { 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.cost_centres.update", title: "Kostenstellen eines Eingangsbelegs ändern", description: "Ändert ausschließlich die Positionskostenstellen eines Eingangsbelegs, auch wenn er gebucht oder einer Bankbuchung zugewiesen ist.", requiredPermissions: ["accounting.incoming_invoices.write"], inputSchema: { type: "object", required: ["id", "accountCostCentres"], properties: { id: { type: "number" }, accountCostCentres: { type: "array", minItems: 1, items: { type: "object", required: ["index", "costcentre"], properties: { index: { type: "integer", minimum: 0, description: "Nullbasierter Index der Position in accounts." }, costcentre: { type: ["string", "null"], description: "UUID der Positionskostenstelle oder null." }, }, }, }, }, }, async handler(context, args) { const id = numberArg(args, "id") if (!id) throw new Error("id ist erforderlich") const invoice = await updateIncomingInvoiceCostCentres( context.server, context.tenantId, context.userId, id, { accountCostCentres: args.accountCostCentres as any }, ) return { invoice } }, }, { 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_accounts.balances", title: "Kontostände abfragen", description: "Liefert die Kontostände der Bankkonten und Kassenbücher des aktiven Mandanten samt Summe. Bankkonten zeigen den zuletzt synchronisierten Buchungssaldo; mit asOf wird der Stand zu einem vergangenen Datum aus den Bankumsätzen zurückgerechnet. Kassenbücher ergeben sich aus Anfangsbestand und Buchungen.", requiredPermissions: ["accounting.bank.read"], inputSchema: { type: "object", properties: { account: { type: "number", description: "Optionale Bankkonto-ID; ohne Angabe alle Konten." }, asOf: { ...dateField.schema, description: "Optionaler Stichtag YYYY-MM-DD (Stand zum Tagesende)." }, }, }, async handler(context, args) { const account = numberArg(args, "account") const asOf = args.asOf === undefined || args.asOf === null ? null : dateField.parse(args.asOf, "asOf") const result = await loadBankBalances(context.server, context.tenantId, { account, asOf }) if (account && !result.accounts.length) throw new Error("Bankkonto nicht gefunden") return result }, }, { 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." }, ...paginationProperties, }, }, async handler(context, args) { const conditions = [eq(bankstatements.tenant, context.tenantId)] const account = numberArg(args, "account") if (account) conditions.push(eq(bankstatements.account, account)) 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) + 1) .offset(offsetFromArgs(args)) return paginatedResult(rows, args) }, }, { 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), eq(bankstatements.archived, false))) .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" }, ...paginationProperties, }, }, 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)) conditions.push(eq(statementallocations.archived, false)) const rows = await context.server.db .select() .from(statementallocations) .where(and(...conditions)) .orderBy(desc(statementallocations.created_at), statementallocations.id) .limit(limitFromArgs(args) + 1) .offset(offsetFromArgs(args)) return paginatedResult(rows, args) }, }, { 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: "DATEV-Steuerschlüssel auf den Bruttobetrag: 9/8 für 19/7 % Vorsteuer, 3/2 für 19/7 % Umsatzsteuer, 40 ohne Steuer (Automatik aufheben)." }, costcentre: { type: ["string", "null"], description: "Kostenstellen-UUID für direkte Bankbuchungen auf Sachkonten oder zusätzliche Konten." }, 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 } }, }, ]