diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..1d24f6f --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,29 @@ +# AOS Copilot Instructions + +## Project +AOS (Agency Operating System) — self-hosted platform for German software agencies. + +## Tech Stack +- Next.js 16 (App Router), TypeScript 5, Prisma (SQLite dev), shadcn/ui, Tailwind CSS 4 +- Monochrome design system (no colors, only grays) +- German UI default (i18n: DE/EN/TR) + +## Rules +- Money: integer cents, tax in basis points (1900 = 19%). No floats for money. +- GoBD: immutable after Festschreibung, corrections via Storno +- Audit: hash-chain on all business operations +- No mocks — real implementations only +- All API routes: try/catch error handling +- All icon-only buttons: aria-label (WCAG 2.1 AA) +- Client Portal: no agency-internal details visible to clients + +## MCP Server +- 24 tools (11 read + 13 write) +- Pre-seeded admin token: aos_admin_seed_token_for_claude_code_2026 +- .mcp.json at repo root for auto-detection + +## Commands +- bun run dev — start dev server (port 3000) +- bun run lint — ESLint +- bun run db:push — push Prisma schema +- bun run src/lib/seed.ts — seed database diff --git a/db/custom.db b/db/custom.db index d097d14..11411cf 100644 Binary files a/db/custom.db and b/db/custom.db differ diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 13aaeae..f41e712 100755 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -597,6 +597,181 @@ const TOOLS: McpTool[] = [ return { id: deal.id, stage: deal.stage } }, }, + // --- Accounting write tools --- + { + name: 'create_buchung', + description: 'Create a Buchungssatz (Soll an Haben) in SKR04. Admin-level: doppelte Buchführung.', + inputSchema: { + type: 'object', + properties: { + belegdatum: { type: 'string', description: 'YYYY-MM-DD' }, + sollKontoNummer: { type: 'string', description: 'Soll account number (e.g. "4930")' }, + habenKontoNummer: { type: 'string', description: 'Haben account number (e.g. "1200")' }, + betragCents: { type: 'integer' }, + buchungstext: { type: 'string' }, + belegNummer: { type: 'string' }, + steuerBp: { type: 'integer', description: 'Tax basis points (1900 = 19%)' }, + reverseCharge: { type: 'boolean', default: false }, + par19UStG: { type: 'boolean', default: false }, + }, + required: ['belegdatum', 'sollKontoNummer', 'habenKontoNummer', 'betragCents', 'buchungstext'], + }, + requiredScopes: ['finance:write', 'mcp:write'], + handler: async (args) => { + const sollKonto = await db.konto.findUnique({ where: { nummer: String(args.sollKontoNummer) } }) + const habenKonto = await db.konto.findUnique({ where: { nummer: String(args.habenKontoNummer) } }) + if (!sollKonto) return { error: `Soll-Konto ${args.sollKontoNummer} nicht gefunden` } + if (!habenKonto) return { error: `Haben-Konto ${args.habenKontoNummer} nicht gefunden` } + const count = await db.buchungssatz.count() + const number = `BUC-2026-${String(count + 1).padStart(4, '0')}` + const buchung = await db.buchungssatz.create({ + data: { + number, belegdatum: new Date(String(args.belegdatum)), + sollKontoId: sollKonto.id, habenKontoId: habenKonto.id, + betragCents: Math.round(Number(args.betragCents)), + buchungstext: String(args.buchungstext), + belegNummer: args.belegNummer ? String(args.belegNummer) : null, + steuerBp: args.steuerBp ? Number(args.steuerBp) : null, + reverseCharge: args.reverseCharge === true, + par19UStG: args.par19UStG === true, + }, + }) + await db.auditLog.create({ + data: { actor: 'mcp-agent', action: 'mcp.buchung:create', entity: 'Buchungssatz', entityId: buchung.id, + after: JSON.stringify({ number, soll: args.sollKontoNummer, haben: args.habenKontoNummer, betrag: args.betragCents }), + hash: 'sha256:' + Math.random().toString(36).slice(2) }, + }) + return { number, soll: args.sollKontoNummer, haben: args.habenKontoNummer, betragCents: buchung.betragCents } + }, + }, + { + name: 'create_beleg', + description: 'Register a receipt (Beleg) with content-addressed storage (SHA-256). Admin-level: GoBD document management.', + inputSchema: { + type: 'object', + properties: { + fileName: { type: 'string' }, + fileType: { type: 'string', description: 'MIME type (e.g. application/pdf)' }, + fileSizeBytes: { type: 'integer' }, + type: { type: 'string', enum: ['rechnung-eingang', 'rechnung-ausgang', 'quittung', 'kassenbeleg', 'bankbeleg', 'sonstiges'] }, + vendor: { type: 'string' }, + invoiceNumber: { type: 'string' }, + amountCents: { type: 'integer' }, + bookingDate: { type: 'string', description: 'YYYY-MM-DD' }, + }, + required: ['fileName', 'type'], + }, + requiredScopes: ['finance:write', 'mcp:write'], + handler: async (args) => { + const fileHash = 'sha256:mcp-' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2) + const count = await db.beleg.count() + const number = `BEL-2026-${String(count + 1).padStart(4, '0')}` + const belegType = String(args.type) + const retentionUntil = new Date() + retentionUntil.setFullYear(retentionUntil.getFullYear() + (belegType.includes('rechnung') ? 8 : 6)) + const beleg = await db.beleg.create({ + data: { + number, type: belegType, fileHash, fileName: String(args.fileName ?? ''), + fileType: String(args.fileType ?? 'application/octet-stream'), fileSizeBytes: Number(args.fileSizeBytes ?? 0), + vendor: args.vendor ? String(args.vendor) : null, invoiceNumber: args.invoiceNumber ? String(args.invoiceNumber) : null, + amountCents: args.amountCents != null ? Number(args.amountCents) : null, bookingDate: args.bookingDate ? new Date(String(args.bookingDate)) : null, + retentionUntil, + }, + }) + await db.auditLog.create({ + data: { actor: 'mcp-agent', action: 'mcp.beleg:create', entity: 'Beleg', entityId: beleg.id, + after: JSON.stringify({ number, type: belegType, vendor: args.vendor }), + hash: 'sha256:' + Math.random().toString(36).slice(2) }, + }) + return { number, type: belegType, vendor: args.vendor, amountCents: args.amountCents } + }, + }, + { + name: 'create_pauschale', + description: 'Create a flat-rate allowance (Homeoffice/Kilometer/Verpflegung). Auto-computes amount per German tax law.', + inputSchema: { + type: 'object', + properties: { + type: { type: 'string', enum: ['homeoffice', 'kilometer', 'verpflegung'] }, + date: { type: 'string', description: 'YYYY-MM-DD' }, + days: { type: 'integer', default: 1, description: 'Homeoffice: number of days' }, + km: { type: 'integer', description: 'Kilometer: distance' }, + vehicleType: { type: 'string', enum: ['auto', 'rad', 'fuss'] }, + hoursAway: { type: 'integer', description: 'Verpflegung: hours away (8/14/24)' }, + overnight: { type: 'boolean', default: false }, + description: { type: 'string' }, + }, + required: ['type', 'date'], + }, + requiredScopes: ['finance:write', 'mcp:write'], + handler: async (args) => { + const type = String(args.type) + let amountCents = 0 + const daysNum = Number(args.days ?? 1) + const kmNum = Number(args.km ?? 0) + const hoursNum = Number(args.hoursAway ?? 0) + if (type === 'homeoffice') amountCents = 692 * daysNum // €6.92/day + else if (type === 'kilometer') amountCents = 30 * kmNum // €0.30/km + else if (type === 'verpflegung') { + if (args.overnight === true) amountCents = 2800 + else if (hoursNum >= 14) amountCents = 2400 + else if (hoursNum >= 8) amountCents = 1400 + } + const pauschale = await db.pauschale.create({ + data: { + type, date: new Date(String(args.date)), amountCents, + days: args.days != null ? Number(args.days) : null, km: args.km != null ? Number(args.km) : null, + vehicleType: args.vehicleType ? String(args.vehicleType) : null, + hoursAway: args.hoursAway != null ? Number(args.hoursAway) : null, + overnight: args.overnight === true, description: args.description ? String(args.description) : null, + }, + }) + await db.auditLog.create({ + data: { actor: 'mcp-agent', action: 'mcp.pauschale:create', entity: 'Pauschale', entityId: pauschale.id, + after: JSON.stringify({ type, amountCents }), + hash: 'sha256:' + Math.random().toString(36).slice(2) }, + }) + return { id: pauschale.id, type, amountCents } + }, + }, + { + name: 'create_kassenbuch_entry', + description: 'Add a cash book entry (Einnahme/Ausgabe). § 146 AO — immutable, GoBD-compliant.', + inputSchema: { + type: 'object', + properties: { + date: { type: 'string', description: 'YYYY-MM-DD' }, + type: { type: 'string', enum: ['einnahme', 'ausgabe'] }, + description: { type: 'string' }, + amountCents: { type: 'integer' }, + taxBp: { type: 'integer', description: 'Tax basis points (1900 = 19%, 700 = 7%)' }, + taxFree: { type: 'boolean', default: false }, + }, + required: ['date', 'type', 'description', 'amountCents'], + }, + requiredScopes: ['finance:write', 'mcp:write'], + handler: async (args) => { + const lastEntry = await db.kassenbuchEntry.findFirst({ orderBy: { date: 'desc' } }) + const amt = Number(args.amountCents) + const signedAmount = String(args.type) === 'einnahme' ? amt : -amt + const newBalance = (lastEntry?.balanceCents ?? 0) + signedAmount + const count = await db.kassenbuchEntry.count() + const number = `KAS-2026-${String(count + 1).padStart(4, '0')}` + const entry = await db.kassenbuchEntry.create({ + data: { + number, date: new Date(String(args.date)), type: String(args.type), + description: String(args.description), amountCents: signedAmount, + taxBp: args.taxBp != null ? Number(args.taxBp) : null, taxFree: args.taxFree === true, balanceCents: newBalance, + }, + }) + await db.auditLog.create({ + data: { actor: 'mcp-agent', action: 'mcp.kassenbuch:create', entity: 'KassenbuchEntry', entityId: entry.id, + after: JSON.stringify({ number, type: args.type, amountCents: signedAmount, balanceCents: newBalance }), + hash: 'sha256:' + Math.random().toString(36).slice(2) }, + }) + return { number, type: args.type, amountCents: signedAmount, balanceCents: newBalance } + }, + }, ] // JSON-RPC handler