diff --git a/README.md b/README.md index 8f4a3ab7..cbcbfc9c 100644 --- a/README.md +++ b/README.md @@ -499,6 +499,8 @@ sudo python3 /opt/fedeo/scripts/selfhost-update.py status Beim ersten Update die erzeugte Compose-Differenz prüfen, eigene Anpassungen in eine Override-Datei übertragen und den Austausch mit `--accept-compose-replacement` bestätigen. Bestehende Installationen müssen das Update-Skript zunächst aus einem passenden Release übernehmen. +Optional führt `fedeo-updater.service` die Aufträge unabhängig von der anfragenden Verbindung aus. Nach Bereitstellung der zugehörigen Dienstdateien installiert `sudo bash /opt/fedeo/scripts/selfhost-update-service-install.sh` den systemd-Dienst. Die lokale Unix-Socket-API bietet Prüfung, Update-Start und Status; Backend-Anbindung und Frontend-Button folgen separat. Einrichtung und API-Beispiele stehen in der folgenden Anleitung. + Voraussetzungen, unterstützte Konfigurationen und Wiederherstellung stehen in [Vollständige Selfhost-Updates](docs/selfhost-updates.md). Für ältere Releases steht der manuelle Ablauf in [Versionierung und Releases](docs/releases.md). ## Backup-Empfehlung diff --git a/backend/db/migrations/0071_statement_allocation_costcentre.sql b/backend/db/migrations/0071_statement_allocation_costcentre.sql new file mode 100644 index 00000000..8ac5f23e --- /dev/null +++ b/backend/db/migrations/0071_statement_allocation_costcentre.sql @@ -0,0 +1,2 @@ +ALTER TABLE "statementallocations" + ADD COLUMN "costcentre" uuid REFERENCES "costcentres"("id"); diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index 9ba06fde..ab6d3bcd 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -477,6 +477,13 @@ "when": 1788850800000, "tag": "0070_native_communication_chat", "breakpoints": true + }, + { + "idx": 68, + "version": "7", + "when": 1790121600000, + "tag": "0071_statement_allocation_costcentre", + "breakpoints": true } ] } diff --git a/backend/db/schema/statementallocations.ts b/backend/db/schema/statementallocations.ts index 8f674b44..3d6032d6 100644 --- a/backend/db/schema/statementallocations.ts +++ b/backend/db/schema/statementallocations.ts @@ -18,6 +18,7 @@ import { incominginvoices } from "./incominginvoices" import { createddocuments } from "./createddocuments" import { bankstatements } from "./bankstatements" import { accounts } from "./accounts" // Falls noch nicht erstellt → bitte melden! +import { costcentres } from "./costcentres" export const statementallocations = pgTable("statementallocations", { id: uuid("id").primaryKey().defaultRandom(), @@ -58,6 +59,7 @@ export const statementallocations = pgTable("statementallocations", { manualBookingDate: text("manual_booking_date"), datevTaxKey: text("datev_tax_key"), + costcentre: uuid("costcentre").references(() => costcentres.id), bookingMode: text("booking_mode").notNull().default("expense"), depreciationMonths: integer("depreciation_months"), diff --git a/backend/src/mcp/statementAllocations.ts b/backend/src/mcp/statementAllocations.ts index c1c33e65..f5e26974 100644 --- a/backend/src/mcp/statementAllocations.ts +++ b/backend/src/mcp/statementAllocations.ts @@ -1,3 +1,5 @@ +import { normalizeBankAllocationDetails } from "../modules/bank-allocation-details" + const hasValue = (value: unknown) => value !== null && value !== undefined && value !== "" const stringArg = (args: Record, key: string) => { @@ -110,6 +112,10 @@ export const prepareStatementAllocationInput = (args: Record) = if (hasInvoice) payload.manualInvoiceSide = manualInvoiceSide } + const details = normalizeBankAllocationDetails({ ...payload, costcentre: args.costcentre }) + if (args.costcentre !== undefined) payload.costcentre = details.costcentre + if (args.datevTaxKey !== undefined) payload.datevTaxKey = details.datevTaxKey + if (bookingMode === "expense") return payload const depreciationMonths = requiredNumber(args, "depreciationMonths") diff --git a/backend/src/mcp/tools/accounting.ts b/backend/src/mcp/tools/accounting.ts index 310aa7ae..06521ead 100644 --- a/backend/src/mcp/tools/accounting.ts +++ b/backend/src/mcp/tools/accounting.ts @@ -2,6 +2,7 @@ import { outgoingAddressSchema, outgoingRowsSchema, outgoingReportSchema } from import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination" import { and, desc, eq, ilike, or } from "drizzle-orm" import { randomUUID } from "node:crypto" +import { validateBankAllocationCostCentre } from "../../modules/bank-allocation-details" import { accounts, bankstatements, @@ -406,6 +407,7 @@ const assertTenantEntityExists = async ( 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"), @@ -1419,7 +1421,8 @@ export const accountingTools: McpTool[] = [ manualBookingDate: { type: "string", format: "date", description: "Buchungsdatum einer manuellen Buchung." }, manualInvoiceSide: { type: "string", enum: ["debit", "credit"], description: "Soll/Haben-Seite eines Eingangsbelegs in einer manuellen Buchung." }, description: { type: "string" }, - datevTaxKey: { type: "string", description: "Optionaler DATEV-Steuerschlüssel, z. B. 9, 8, 19 oder 18." }, + 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" }, diff --git a/backend/src/modules/bank-allocation-details.ts b/backend/src/modules/bank-allocation-details.ts new file mode 100644 index 00000000..cec07586 --- /dev/null +++ b/backend/src/modules/bank-allocation-details.ts @@ -0,0 +1,30 @@ +import { and, eq } from "drizzle-orm" +import { costcentres } from "../../db/schema" + +const invalidInput = (message: string) => Object.assign(new Error(message), { statusCode: 400 }) + +export function normalizeBankAllocationDetails(payload: Record) { + const costcentre = payload.costcentre ? String(payload.costcentre).trim() : null + const datevTaxKey = payload.datevTaxKey ? String(payload.datevTaxKey).trim() : null + if (costcentre && !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(costcentre)) { + throw invalidInput("Die Kostenstelle muss eine gültige UUID sein.") + } + if (datevTaxKey && !/^\d{1,4}$/.test(datevTaxKey)) { + throw invalidInput("Der DATEV-Steuerschlüssel muss aus 1 bis 4 Ziffern bestehen.") + } + if (costcentre && (!payload.bankstatement || !(payload.account || payload.ownaccount) + || payload.createddocument || payload.incominginvoice || payload.customer || payload.vendor)) { + throw invalidInput("Kostenstellen sind hier nur für direkte Bankbuchungen auf Buchungskonten zulässig.") + } + return { costcentre, datevTaxKey } +} + +export async function validateBankAllocationCostCentre(db: any, tenantId: number, costcentre: string | null) { + if (!costcentre) return + const [record] = await db.select({ id: costcentres.id }).from(costcentres).where(and( + eq(costcentres.id, costcentre), + eq(costcentres.tenant, tenantId), + eq(costcentres.archived, false), + )).limit(1) + if (!record) throw invalidInput("Die Kostenstelle wurde nicht gefunden oder ist archiviert.") +} diff --git a/backend/src/routes/banking.ts b/backend/src/routes/banking.ts index d80da564..92349ebd 100644 --- a/backend/src/routes/banking.ts +++ b/backend/src/routes/banking.ts @@ -9,6 +9,7 @@ import { DE_BANK_CODE_TO_NAME } from "../utils/deBankCodes" import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics" import { centralServicesClient } from "../modules/push-server.client" import { findBankInstitutionByBic } from "../modules/banking-institution" +import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../modules/bank-allocation-details" import { bankrequisitions, @@ -1166,6 +1167,8 @@ export default async function bankingRoutes(server: FastifyInstance) { const { data: payload } = req.body as { data: any } const prepared = prepareStatementAllocationPayload(payload) if (prepared.error) return reply.code(400).send({ error: prepared.error }) + Object.assign(prepared.data, normalizeBankAllocationDetails(prepared.data)) + await validateBankAllocationCostCentre(server.db, req.user.tenant_id, prepared.data.costcentre) prepared.data = await useCurrentIncomingInvoiceAmount(req.user.tenant_id, prepared.data) const inserted = await server.db.insert(statementallocations).values({ @@ -1217,6 +1220,7 @@ export default async function bankingRoutes(server: FastifyInstance) { return reply.send(createdRecord) } catch (err) { + if (err.statusCode === 400) return reply.code(400).send({ error: err.message }) console.error(err) return reply.code(500).send({ error: "Failed to create statement" }) } diff --git a/backend/src/utils/export/datev.ts b/backend/src/utils/export/datev.ts index 1182d242..29f09cc9 100644 --- a/backend/src/utils/export/datev.ts +++ b/backend/src/utils/export/datev.ts @@ -23,7 +23,8 @@ import { vendors, bankaccounts, bankstatements, - ownaccounts + ownaccounts, + costcentres } from "../../../db/schema"; dayjs.extend(isBetween); @@ -80,6 +81,29 @@ const formatDatevDate = (date: dayjs.ConfigType, format: string) => { return parsed.isValid() ? parsed.tz(DATEV_TIMEZONE).format(format) : ""; }; +export const getBankAccountBookingLine = (alloc: any) => { + const bs = alloc.bankstatement; + const account = alloc.account || alloc.ownaccount; + const columns = Array(124).fill(""); + const quote = (value: any) => `"${escapeString(String(value ?? ""))}"`; + columns[0] = displayCurrency(Number(alloc.amount), true); + columns[1] = quote(Number(alloc.amount) < 0 ? "H" : "S"); + columns[6] = bs.account?.datevNumber || ""; + columns[7] = account.number; + columns[8] = quote(alloc.datevTaxKey || ""); + columns[9] = formatDatevDate(bs.date, "DDMM"); + columns[13] = quote(`${Number(alloc.amount) > 0 ? "ZE" : "ZA"} ${account.number} - ${account.label || account.name || ""} ${alloc.description || ""} ${bs.text || ""}`.substring(0, 59)); + columns[20] = quote("Geschäftspartner"); + columns[21] = quote(bs.debName || bs.credName || ""); + columns[28] = quote("Belegdatum"); + columns[29] = quote(formatDatevDate(bs.date, "DD.MM.YYYY")); + columns[36] = quote(alloc.costcentre?.number || ""); + columns[47] = "Bank-Id"; + columns[48] = bs.id; + columns[112] = "0"; + return columns.join(";"); +}; + const getIncomingInvoiceTaxOverride = (invoice: any) => { if (invoice.taxAmountOverride === null || invoice.taxAmountOverride === undefined || invoice.taxAmountOverride === "") return null; @@ -136,7 +160,7 @@ export async function buildExportZip( const endDateFmt = formatDatevDate(endDate, "YYYYMMDD"); let header = `"EXTF";700;21;"Buchungsstapel";13;${dateNowStr};;"FE";"Florian Federspiel";;${beraternr};${mandantennr};20250101;4;${startDateFmt};${endDateFmt};"Buchungsstapel";"FF";1;0;1;"EUR";;"";;;"03";;;"";""`; - let colHeaders = `Umsatz;Soll-/Haben-Kennzeichen;WKZ Umsatz;Kurs;Basisumsatz;WKZ Basisumsatz;Konto;Gegenkonto;BU-Schluessel;Belegdatum;Belegfeld 1;Belegfeld 2;Skonto;Buchungstext;Postensperre;Diverse Adressnummer;Geschaeftspartnerbank;Sachverhalt;Zinssperre;Beleglink;Beleginfo - Art 1;Beleginfo - Inhalt 1;Beleginfo - Art 2;Beleginfo - Inhalt 2;Beleginfo - Art 3;Beleginfo - Inhalt 3;Beleginfo - Art 4;Beleginfo - Inhalt 4;Beleginfo - Art 5;Beleginfo - Inhalt 5;Beleginfo - Art 6;Beleginfo - Inhalt 6;Beleginfo - Art 7;Beleginfo - Inhalt 7;Beleginfo - Art 8;Beleginfo - Inhalt 8;KOST1 - Kostenstelle;KOST2 - Kostenstelle;Kost Menge;EU-Land u. USt-IdNr. (Bestimmung);EU-Steuersatz (Bestimmung);Abw. Versteuerungsart;Sachverhalt L+L;Funktionsergaenzung L+L;BU 49 Hauptfunktionstyp;BU 49 Hauptfunktionsnummer;BU 49 Funktionsergaenzung;Zusatzinformation - Art 1;Zusatzinformation - Inhalt 1;Zusatzinformation - Art 2;Zusatzinformation - Inhalt 2;Zusatzinformation - Art 3;Zusatzinformation - Inhalt 3;Zusatzinformation - Art 4;Zusatzinformation - Inhalt 4;Zusatzinformation - Art 5;Zusatzinformation - Inhalt 5;Zusatzinformation - Art 6;Zusatzinformation - Inhalt 6;Zusatzinformation - Art 7;Zusatzinformation - Inhalt 7;Zusatzinformation - Art 8;Zusatzinformation - Inhalt 8;Zusatzinformation - Art 9;Zusatzinformation - Inhalt 9;Zusatzinformation - Art 10;Zusatzinformation - Inhalt 10;Zusatzinformation - Art 11;Zusatzinformation - Inhalt 11;Zusatzinformation - Art 12;Zusatzinformation - Inhalt 12;Zusatzinformation - Art 13;Zusatzinformation - Inhalt 13;Zusatzinformation - Art 14;Zusatzinformation - Inhalt 14;Zusatzinformation - Art 15;Zusatzinformation - Inhalt 15;Zusatzinformation - Art 16;Zusatzinformation - Inhalt 16;Zusatzinformation - Art 17;Zusatzinformation - Inhalt 17;Zusatzinformation - Art 18;Zusatzinformation - Inhalt 18;Zusatzinformation - Art 19;Zusatzinformation - Inhalt 19;Zusatzinformation - Art 20;Zusatzinformation - Inhalt 20;Stueck;Gewicht;Zahlweise;Zahlweise;Veranlagungsjahr;Zugeordnete Faelligkeit;Skontotyp;Auftragsnummer;Buchungstyp;USt-Schluessel (Anzahlungen);EU-Mitgliedstaat (Anzahlungen);Sachverhalt L+L (Anzahlungen);EU-Steuersatz (Anzahlungen);Erloeskonto (Anzahlungen);Herkunft-Kz;Leerfeld;KOST-Datum;SEPA-Mandatsreferenz;Skontosperre;Gesellschaftername;Beteiligtennummer;Identifikationsnummer;Zeichnernummer;Postensperre bis;Bezeichnung SoBil-Sachverhalt;Kennzeichen SoBil-Buchung;Festschreibung;Leistungsdatum;Datum Zuord. Steuerperiode;Faelligkeit;Generalumkehr;Steuersatz;Land;Abrechnungsreferenz;BVV-Position;EU-Mitgliedstaat u. UStID(Ursprung);EU-Steuersatz(Ursprung);Abw. Skontokonto`; + let colHeaders = `Umsatz;Soll-/Haben-Kennzeichen;WKZ Umsatz;Kurs;Basisumsatz;WKZ Basisumsatz;Konto;Gegenkonto;BU-Schluessel;Belegdatum;Belegfeld 1;Belegfeld 2;Skonto;Buchungstext;Postensperre;Diverse Adressnummer;Geschaeftspartnerbank;Sachverhalt;Zinssperre;Beleglink;Beleginfo - Art 1;Beleginfo - Inhalt 1;Beleginfo - Art 2;Beleginfo - Inhalt 2;Beleginfo - Art 3;Beleginfo - Inhalt 3;Beleginfo - Art 4;Beleginfo - Inhalt 4;Beleginfo - Art 5;Beleginfo - Inhalt 5;Beleginfo - Art 6;Beleginfo - Inhalt 6;Beleginfo - Art 7;Beleginfo - Inhalt 7;Beleginfo - Art 8;Beleginfo - Inhalt 8;KOST1 - Kostenstelle;KOST2 - Kostenstelle;Kost Menge;EU-Land u. USt-IdNr. (Bestimmung);EU-Steuersatz (Bestimmung);Abw. Versteuerungsart;Sachverhalt L+L;Funktionsergaenzung L+L;BU 49 Hauptfunktionstyp;BU 49 Hauptfunktionsnummer;BU 49 Funktionsergaenzung;Zusatzinformation - Art 1;Zusatzinformation - Inhalt 1;Zusatzinformation - Art 2;Zusatzinformation - Inhalt 2;Zusatzinformation - Art 3;Zusatzinformation - Inhalt 3;Zusatzinformation - Art 4;Zusatzinformation - Inhalt 4;Zusatzinformation - Art 5;Zusatzinformation - Inhalt 5;Zusatzinformation - Art 6;Zusatzinformation - Inhalt 6;Zusatzinformation - Art 7;Zusatzinformation - Inhalt 7;Zusatzinformation - Art 8;Zusatzinformation - Inhalt 8;Zusatzinformation - Art 9;Zusatzinformation - Inhalt 9;Zusatzinformation - Art 10;Zusatzinformation - Inhalt 10;Zusatzinformation - Art 11;Zusatzinformation - Inhalt 11;Zusatzinformation - Art 12;Zusatzinformation - Inhalt 12;Zusatzinformation - Art 13;Zusatzinformation - Inhalt 13;Zusatzinformation - Art 14;Zusatzinformation - Inhalt 14;Zusatzinformation - Art 15;Zusatzinformation - Inhalt 15;Zusatzinformation - Art 16;Zusatzinformation - Inhalt 16;Zusatzinformation - Art 17;Zusatzinformation - Inhalt 17;Zusatzinformation - Art 18;Zusatzinformation - Inhalt 18;Zusatzinformation - Art 19;Zusatzinformation - Inhalt 19;Zusatzinformation - Art 20;Zusatzinformation - Inhalt 20;Stueck;Gewicht;Zahlweise;Veranlagungsjahr;Zugeordnete Faelligkeit;Skontotyp;Auftragsnummer;Buchungstyp;USt-Schluessel (Anzahlungen);EU-Mitgliedstaat (Anzahlungen);Sachverhalt L+L (Anzahlungen);EU-Steuersatz (Anzahlungen);Erloeskonto (Anzahlungen);Herkunft-Kz;Leerfeld;KOST-Datum;SEPA-Mandatsreferenz;Skontosperre;Gesellschaftername;Beteiligtennummer;Identifikationsnummer;Zeichnernummer;Postensperre bis;Bezeichnung SoBil-Sachverhalt;Kennzeichen SoBil-Buchung;Festschreibung;Leistungsdatum;Datum Zuord. Steuerperiode;Faelligkeit;Generalumkehr;Steuersatz;Land;Abrechnungsreferenz;BVV-Position;EU-Mitgliedstaat u. UStID(Ursprung);EU-Steuersatz(Ursprung);Abw. Skontokonto`; // --------------------------------------------------------- // 1. DATEN LADEN (CORE API SELECT & JOIN) @@ -209,6 +233,7 @@ export async function buildExportZip( direct_vend: vendors, // Direkte Zuordnung an Kreditor direct_cust: customers, // Direkte Zuordnung an Debitor own: ownaccounts, + costcentre: costcentres, contra_acc: ContraAccount, contra_vend: ContraVendor, contra_cust: ContraCustomer, @@ -233,6 +258,7 @@ export async function buildExportZip( .leftJoin(vendors, eq(statementallocations.vendor, vendors.id)) .leftJoin(customers, eq(statementallocations.customer, customers.id)) .leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id)) + .leftJoin(costcentres, and(eq(statementallocations.costcentre, costcentres.id), eq(costcentres.tenant, tenantId))) .leftJoin(ContraAccount, eq(statementallocations.contraAccount, ContraAccount.id)) .leftJoin(ContraVendor, eq(statementallocations.contraVendor, ContraVendor.id)) .leftJoin(ContraCustomer, eq(statementallocations.contraCustomer, ContraCustomer.id)) @@ -257,10 +283,10 @@ export async function buildExportZip( // Mapping: Wir bauen das komplexe Objekt nach, das die CSV Logik erwartet const statementallocationsList = allocRaw.map(r => ({ ...r.allocation, - bankstatement: { + bankstatement: r.bs ? { ...r.bs, account: r.ba // Nesting für bs.account.datevNumber - }, + } : null, createddocument: r.cd ? { ...r.cd, customer: r.cd_cust @@ -273,6 +299,7 @@ export async function buildExportZip( vendor: r.direct_vend, customer: r.direct_cust, ownaccount: r.own, + costcentre: r.costcentre, contraAccount: r.contra_acc, contraVendor: r.contra_vend, contraCustomer: r.contra_cust, @@ -453,9 +480,7 @@ export async function buildExportZip( const vend = ii.vendor; bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${vend?.vendorNumber};"";${formatDatevDate(ii.date, "DDMM")};"${ii.reference}";;;"${`ZA${alloc.description} ${bsText} `.substring(0,59)}";;;;;;;"Geschäftspartner";"${vend?.name}";"Kundennummer";"${vend?.vendorNumber}";"Belegnummer";"${ii.reference}";"Leistungsdatum";"${formatDatevDate(ii.date, "DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`); } else if(alloc.account) { - const acc = alloc.account; - let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA"; - bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${acc.number};"";${dateVal};"";;;"${`${vorzeichen} ${acc.number} - ${escapeString(acc.label)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${bs.credName || ''}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`); + bookingLines.push(getBankAccountBookingLine(alloc)); } else if(alloc.vendor) { const vend = alloc.vendor; let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA"; @@ -465,9 +490,7 @@ export async function buildExportZip( let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA"; bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${cust.customerNumber};"";${dateVal};"";;;"${`${vorzeichen} ${cust.customerNumber} - ${escapeString(cust.name)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${cust.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`); } else if(alloc.ownaccount) { - const own = alloc.ownaccount; - let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA"; - bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${own.number};"";${dateVal};"";;;"${`${vorzeichen} ${own.number} - ${escapeString(own.name)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${own.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`); + bookingLines.push(getBankAccountBookingLine(alloc)); } }); diff --git a/backend/tests/bankAllocationDetails.test.ts b/backend/tests/bankAllocationDetails.test.ts new file mode 100644 index 00000000..07609d39 --- /dev/null +++ b/backend/tests/bankAllocationDetails.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { PgDialect } from "drizzle-orm/pg-core" +import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../src/modules/bank-allocation-details" +import { prepareStatementAllocationInput } from "../src/mcp/statementAllocations" +import bankingRoutes from "../src/routes/banking" +import { statementallocations } from "../db/schema" + +const costcentre = "57d31d62-11a2-47c5-b074-69f9c5ab8bba" + +test("keeps cost centre and VAT key on the gross bank allocation through input preparation", () => { + const prepared = prepareStatementAllocationInput({ bankstatement: 42, account: 13, amount: -119, costcentre, datevTaxKey: " 9 " }) + assert.equal(prepared.amount, -119) + assert.equal(prepared.costcentre, costcentre) + assert.equal(prepared.datevTaxKey, "9") + assert.deepEqual(normalizeBankAllocationDetails({ amount: -119, account: 13, bankstatement: 42 }), { costcentre: null, datevTaxKey: null }) +}) + +test("rejects invalid cost centres, ambiguous document assignments and malformed tax keys", () => { + assert.throws(() => normalizeBankAllocationDetails({ costcentre: "invalid" }), /UUID/) + assert.throws(() => normalizeBankAllocationDetails({ costcentre, bankstatement: 1, account: 13, incominginvoice: 2 }), /direkte Bankbuchungen/) + assert.throws(() => normalizeBankAllocationDetails({ datevTaxKey: '9";evil' }), /Steuerschlüssel/) +}) + +test("cost centre lookup requires the active tenant and excludes archived centres", async () => { + let condition: any + const query = { from: () => query, where: (value: any) => { condition = value; return query }, limit: async () => [] } + await assert.rejects(validateBankAllocationCostCentre({ select: () => query }, 7, costcentre), /nicht gefunden oder ist archiviert/) + const sql = new PgDialect().sqlToQuery(condition) + assert.match(sql.sql, /"costcentres"\."tenant"/) + assert.match(sql.sql, /"costcentres"\."archived"/) + assert.deepEqual(sql.params, [costcentre, 7, false]) + await validateBankAllocationCostCentre({ select: () => ({ ...query, from: () => ({ where: () => ({ limit: async () => [{ id: costcentre }] }) }) }) }, 7, costcentre) + await validateBankAllocationCostCentre({}, 7, null) +}) + +test("banking API persists the gross allocation with cost centre and VAT, and rejects unknown centres before writing", async () => { + const handlers = new Map() + const saved: any[] = [] + let centreExists = true + const lookup: any = { from: () => lookup, where: () => lookup, limit: async () => centreExists ? [{ id: costcentre }] : [] } + const server: any = { + get() {}, patch() {}, delete() {}, + post: (url: string, handler: any) => handlers.set(url, handler), + db: { + select: () => lookup, + insert: (table: any) => ({ values: (value: any) => { + if (table === statementallocations) saved.push(value) + return { returning: async () => [{ id: "allocation", ...value }] } + } }), + }, + } + await bankingRoutes(server) + const handler = handlers.get("/banking/statements") + const request = { user: { tenant_id: 7, user_id: "user" }, body: { data: { bankstatement: 42, account: 13, amount: -119, costcentre, datevTaxKey: "9" } } } + let status = 200 + let response: any + const reply = { code: (code: number) => { status = code; return reply }, send: (body: any) => { response = body; return reply } } + await handler(request, reply) + assert.equal(status, 200) + assert.equal(response.amount, -119) + assert.equal(response.costcentre, costcentre) + assert.equal(response.datevTaxKey, "9") + assert.equal(saved[0].tenant, 7) + centreExists = false + await handler(request, reply) + assert.equal(status, 400) + assert.equal(saved.length, 1) +}) diff --git a/backend/tests/datevExport.test.ts b/backend/tests/datevExport.test.ts index 6ee76448..e2dfea79 100644 --- a/backend/tests/datevExport.test.ts +++ b/backend/tests/datevExport.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { getCreatedDocumentRevenueLines } from "../src/utils/export/datev"; +import { buildExportZip, getBankAccountBookingLine, getCreatedDocumentRevenueLines } from "../src/utils/export/datev"; +import { Uint8ArrayReader, TextWriter, ZipReader } from "@zip.js/zip.js"; +import { statementallocations } from "../db/schema"; test("exports gross amounts for DATEV automatic revenue accounts", () => { const revenueLines = getCreatedDocumentRevenueLines({ @@ -27,3 +29,77 @@ test("preserves negative gross amounts for cancellations", () => { assert.deepEqual(revenueLines, [{ account: "8400", amount: -119 }]); }); + +for (const [amount, taxKey, side] of [[-119, "9", "H"], [-107, "8", "H"], [119, "3", "S"], [107, "2", "S"], [119, "9", "S"], [-119, "3", "H"], [-100, "40", "H"]] as const) { + test(`exports gross ${amount} with tax key ${taxKey} and cost centre`, () => { + const columns = getBankAccountBookingLine({ + amount, datevTaxKey: taxKey, account: { number: 4900, label: "Konto" }, + costcentre: { id: "internal-uuid", number: "KST-1001" }, + bankstatement: { id: 42, date: "2026-09-23", account: { datevNumber: "1200" } }, + }).split(";"); + assert.equal(columns.length, 124); + assert.equal(columns[0], `${Math.abs(amount)},00`); + assert.equal(columns[1], `"${side}"`); + assert.equal(columns[6], "1200"); + assert.equal(columns[7], "4900"); + assert.equal(columns[8], `"${taxKey}"`); + assert.equal(columns[36], '"KST-1001"'); + assert.equal(columns[112], "0"); + }); +} + +test("exports legacy allocations and additional accounts without inventing a tax key", () => { + const columns = getBankAccountBookingLine({ + amount: -12.34, ownaccount: { number: "4901", name: "Zusatzkonto" }, + bankstatement: { id: 1, date: "2026-09-23", account: { datevNumber: "1200" } }, + }).split(";"); + assert.equal(columns[0], "12,34"); + assert.equal(columns[7], "4901"); + assert.equal(columns[8], '""'); + assert.equal(columns[36], '""'); +}); + +test("ZIP export carries allocation metadata into the matching CSV columns and leaves invoice payments untaxed", async () => { + const bank = { id: 42, date: "2026-09-23", text: "Zahlung" }; + const rows = [ + { allocation: { id: "a", amount: -59.5, datevTaxKey: "9" }, bs: bank, ba: { datevNumber: "1200" }, acc: { number: 4900 }, costcentre: { number: "1001" } }, + { allocation: { id: "b", amount: -59.5, datevTaxKey: "9" }, bs: bank, ba: { datevNumber: "1200" }, own: { number: 4901 }, costcentre: { number: "1002" } }, + { allocation: { id: "c", amount: -119, datevTaxKey: "9" }, bs: bank, ba: { datevNumber: "1200" }, ii: { id: 3, date: bank.date, reference: "ER-3" }, ii_vend: { vendorNumber: 70001 } }, + { allocation: { id: "d", amount: 119, datevTaxKey: "9", manualBookingDate: bank.date }, bs: null, acc: { number: 4900 }, contra_acc: { number: 1200 } }, + ]; + const db = { + select: () => { + let result: any[] = []; + const query: any = { + from: (table: any) => { result = table === statementallocations ? rows : []; return query; }, + leftJoin: () => query, where: () => query, orderBy: () => query, + then: (resolve: any) => Promise.resolve(result).then(resolve), + }; + return query; + }, + }; + const archive = await buildExportZip({ db } as any, 1, "2026-09-01", "2026-09-30", "123", "456"); + const reader = new ZipReader(new Uint8ArrayReader(archive)); + try { + const entries = await reader.getEntries(); + const csvEntry = entries.find(entry => entry.filename.startsWith("EXTF_Buchungsstapel")); + assert.ok(csvEntry && "getData" in csvEntry); + const csv = await csvEntry.getData!(new TextWriter()); + const [, header, ...bookings] = csv.trim().split("\n").map(line => line.split(";")); + assert.equal(bookings.length, 4); + assert.equal(header.length, 124); + for (const [i, costcentre] of ["1001", "1002"].entries()) { + assert.equal(bookings[i].length, header.length); + assert.equal(bookings[i][header.indexOf("Umsatz")], "59,50"); + assert.equal(bookings[i][header.indexOf("BU-Schluessel")], '"9"'); + assert.equal(bookings[i][header.indexOf("KOST1 - Kostenstelle")], `"${costcentre}"`); + assert.equal(bookings[i][header.indexOf("Festschreibung")], "0"); + } + assert.equal(bookings[2][8], '""'); + assert.equal(bookings[3][8], '"9"'); + assert.equal(bookings[3][6], "4900"); + assert.equal(bookings[3][7], "1200"); + } finally { + await reader.close(); + } +}); diff --git a/docs-site/content/bedienung/bankportal.md b/docs-site/content/bedienung/bankportal.md index 7c75f256..13f3a101 100644 --- a/docs-site/content/bedienung/bankportal.md +++ b/docs-site/content/bedienung/bankportal.md @@ -24,6 +24,18 @@ Im Bankportal verbindest du Konten, prüfst Umsätze und unterstützt die Zuordn - `Vorschläge`: Automatische Zuordnungen zu Belegen oder Kategorien. - `Manuelle Zuordnung`: Falls kein passender Vorschlag vorhanden ist. +## Direkt auf ein Buchungskonto buchen + +Öffne einen Bankumsatz und wähle das Buchungskonto. Der zugewiesene Betrag ist immer der **Bruttobetrag**, auch bei Teilbuchungen. + +- Wähle **19 % / 7 % Vorsteuer** für Ausgaben mit abziehbarer Vorsteuer oder **19 % / 7 % Umsatzsteuer** für steuerpflichtige Einnahmen. Bei Erstattungen bleibt die ursprüngliche Steuerart erhalten; das Vorzeichen kehrt ihre Wirkung um. +- Wähle **Ohne Umsatzsteuer**, wenn aus dieser Buchung keine Steuer berechnet werden soll. +- Wähle bei Bedarf eine **Kostenstelle**. Die Zuordnung gilt für den jeweiligen Teilbetrag und erscheint auch in der Auswertung der übergeordneten Kostenstelle. + +Vor dem Buchen werden Netto-, Steuer- und Bruttobetrag angezeigt. Beispielsweise ergeben **−119,00 EUR mit 19 % Vorsteuer** einen Nettoaufwand von 100,00 EUR und 19,00 EUR Vorsteuer. Umsatzsteuerauswertung, BWA und Abschreibungsgrundlage berücksichtigen die Steueraufteilung. Die Zuordnung zu einem vorhandenen Rechnungsbeleg erfasst dessen Steuer nicht noch einmal. + +Der DATEV-Export übergibt den Bruttobetrag mit dem gewählten BU-Schlüssel (9/8 für Vorsteuer, 3/2 für Umsatzsteuer) und die Kostenstellennummer in **KOST1**. „Ohne Umsatzsteuer“ verwendet Schlüssel 40, um auch eine Kontenautomatik aufzuheben. Bestehende Buchungen ohne Steuerauswahl werden nicht nachträglich umgerechnet. Die Zuordnung folgt den [DATEV-Steuerschlüsseln](https://www.datev.de/dnlexom/v2/content/files/st2286167563_de.pdf) und der [DATEV-Dokumentation zur Aufhebung der Automatik](https://apps.datev.de/help-center/api/knowledge-help/v1/content/entities/st2112778507_de.pdf?docId=0907888&save=False). + ## Gute Praxis - Regelmäßig abrufen, damit sich keine großen Rückstände bilden. diff --git a/docs/bedienung/bankportal.md b/docs/bedienung/bankportal.md index 7c75f256..13f3a101 100644 --- a/docs/bedienung/bankportal.md +++ b/docs/bedienung/bankportal.md @@ -24,6 +24,18 @@ Im Bankportal verbindest du Konten, prüfst Umsätze und unterstützt die Zuordn - `Vorschläge`: Automatische Zuordnungen zu Belegen oder Kategorien. - `Manuelle Zuordnung`: Falls kein passender Vorschlag vorhanden ist. +## Direkt auf ein Buchungskonto buchen + +Öffne einen Bankumsatz und wähle das Buchungskonto. Der zugewiesene Betrag ist immer der **Bruttobetrag**, auch bei Teilbuchungen. + +- Wähle **19 % / 7 % Vorsteuer** für Ausgaben mit abziehbarer Vorsteuer oder **19 % / 7 % Umsatzsteuer** für steuerpflichtige Einnahmen. Bei Erstattungen bleibt die ursprüngliche Steuerart erhalten; das Vorzeichen kehrt ihre Wirkung um. +- Wähle **Ohne Umsatzsteuer**, wenn aus dieser Buchung keine Steuer berechnet werden soll. +- Wähle bei Bedarf eine **Kostenstelle**. Die Zuordnung gilt für den jeweiligen Teilbetrag und erscheint auch in der Auswertung der übergeordneten Kostenstelle. + +Vor dem Buchen werden Netto-, Steuer- und Bruttobetrag angezeigt. Beispielsweise ergeben **−119,00 EUR mit 19 % Vorsteuer** einen Nettoaufwand von 100,00 EUR und 19,00 EUR Vorsteuer. Umsatzsteuerauswertung, BWA und Abschreibungsgrundlage berücksichtigen die Steueraufteilung. Die Zuordnung zu einem vorhandenen Rechnungsbeleg erfasst dessen Steuer nicht noch einmal. + +Der DATEV-Export übergibt den Bruttobetrag mit dem gewählten BU-Schlüssel (9/8 für Vorsteuer, 3/2 für Umsatzsteuer) und die Kostenstellennummer in **KOST1**. „Ohne Umsatzsteuer“ verwendet Schlüssel 40, um auch eine Kontenautomatik aufzuheben. Bestehende Buchungen ohne Steuerauswahl werden nicht nachträglich umgerechnet. Die Zuordnung folgt den [DATEV-Steuerschlüsseln](https://www.datev.de/dnlexom/v2/content/files/st2286167563_de.pdf) und der [DATEV-Dokumentation zur Aufhebung der Automatik](https://apps.datev.de/help-center/api/knowledge-help/v1/content/entities/st2112778507_de.pdf?docId=0907888&save=False). + ## Gute Praxis - Regelmäßig abrufen, damit sich keine großen Rückstände bilden. diff --git a/docs/releases.md b/docs/releases.md index 1e372e4a..6bba9404 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -53,7 +53,7 @@ Backend, Frontend, Dokumentation, Website und zentrale Dienste werden als gemein - Bewegliche Kanäle: `latest` und `main` zeigen nach erfolgreicher Veröffentlichung auf denselben Release. - Entwicklung: `dev` sowie unveränderliche `dev-sha--`-Images. Alle Komponenten eines erfolgreichen Laufs erhalten denselben Snapshot-Tag. Auch eine Wiederholung desselben Commits baut einen eigenen Snapshot. Die eingebettete Entwicklungsversion enthält Commit-Anzahl und Commit-ID. - Release-Builds erhalten zunächst einen eindeutigen temporären `build-`-Tag. Diese Tags können später nach einer Aufbewahrungsfrist bereinigt werden; stabile Versionen und deren Digests müssen erhalten bleiben. -- Release-Dateien: `CHANGELOG.md`, `release-manifest.json`, `docker-compose.selfhost.yml`, `.env.example`, `selfhost-install.sh`, `selfhost-setup.sh`, `selfhost-update.py`, `SELFHOST-UPDATES.md` und `SHA256SUMS`. +- Release-Dateien: `CHANGELOG.md`, `release-manifest.json`, `docker-compose.selfhost.yml`, `.env.example`, `selfhost-install.sh`, `selfhost-setup.sh`, `selfhost-update.py`, `selfhost-update-service.py`, `selfhost-update-service-install.sh`, `fedeo-updater.service`, `SELFHOST-UPDATES.md` und `SHA256SUMS`. Das Manifest dokumentiert die Image-Digests, den Commit und SHA-256-Prüfsummen aller enthaltenen SQL-Migrationen. Es beschreibt den ausgelieferten Migrationsstand; den tatsächlich angewendeten Stand einer Installation bestimmt deren Datenbank. diff --git a/docs/selfhost-updates.md b/docs/selfhost-updates.md index 64a0a085..d9673461 100644 --- a/docs/selfhost-updates.md +++ b/docs/selfhost-updates.md @@ -1,6 +1,6 @@ # Vollständige Selfhost-Updates -`scripts/selfhost-update.py` ist der gemeinsame Update-Ablauf für die Kommandozeile und einen zukünftigen Update-Dienst. Diese Implementierung installiert noch keinen Dienst und stellt noch keinen Update-Button bereit. Der Prozess muss auf dem Docker-Host außerhalb der FEDEO-Container laufen. +`scripts/selfhost-update.py` ist der gemeinsame Update-Ablauf für die Kommandozeile und den optionalen Host-Dienst `fedeo-updater.service`. Beide laufen auf dem Docker-Host außerhalb der FEDEO-Container und verwenden dieselbe exklusive Sperre. Der Dienst bietet bereits eine lokale Schnittstelle; Backend-Anbindung und Update-Button im Frontend folgen separat. ## Voraussetzungen und Umfang @@ -13,12 +13,20 @@ Der Updater verweigert externe Datenbanken, externen S3-/Seafile-Speicher, zusätzliche Dienste, Compose-Profile, geänderte Datenpfade und PostgreSQL-Imagewechsel. Dafür braucht es zunächst passende Backup-/Migrationsadapter. Eigene Einstellungen innerhalb der unterstützten Dienste können in `docker-compose.override.yml` **oder** `docker-compose.override.yaml` bleiben; der Updater verwendet diese Datei bei jedem Compose-Aufruf. Änderungen an dieser Override-Datei werden nicht überschrieben. +Eine gezielte Ausnahme ist die Stilllegung des **entfernten alten Matrix-Stacks**: `matrix-db`, `matrix-synapse`, `matrix-redis`, `matrix-element`, `matrix-livekit`, `matrix-rtc-jwt`, `matrix-turn` und `matrix-well-known` dürfen in der bestehenden Konfiguration vorkommen. Die Zielkonfiguration muss ausschließlich die sieben FEDEO-Dienste enthalten, auch nach Zusammenführung mit einer Override-Datei. Matrix wird nicht in den neuen Stack übernommen. Andere Zusatzdienste bleiben gesperrt; die Fehlermeldung nennt die konkreten Abweichungen. Ein Abbruch mit `mutationStarted: false` und `recoveryRequired: false` benötigt keine Wiederherstellung und keinen `resolve`-Aufruf. + +Für diesen Übergang müssen die Matrix-Daten lokal unter `matrix/` liegen, insbesondere `matrix/postgres` und `matrix/synapse`; symbolische Links, andere konfigurierte Datenpfade, benannte Volumes und spezielle Compose-Konfigurationen benötigen eine manuelle Stilllegung. Die tatsächlichen Bind-Mounts der alten Container müssen zur Compose-Datei passen. Der Plan nennt `retiredServices` und `retainedDataPaths` und berücksichtigt den zusätzlichen Backup-Platz. Alle Matrix-Container werden vor dem Backup gestoppt. Das komplette lokale `matrix/`-Verzeichnis wird bei gestoppter Matrix-Datenbank in `files.tar` gesichert. Erst nach erfolgreichem Backup setzt der Updater die Neustartrichtlinie der alten Matrix-Container auf `no`, damit sie auch nach einem Host-Neustart stillgelegt bleiben. Er löscht weder diese Container noch ihre Daten oder Volumes. Image-seitig angelegte anonyme Volumes und schreibbare Container-Schichten bleiben an den alten Containern erhalten; sie sind nicht Bestandteil von `files.tar`. Die alten Container daher für die Wiederherstellung behalten und nicht mit `--remove-orphans` oder einem Docker-Prune entfernen. + +Die Stilllegung vor der Dateisicherung entspricht den Anforderungen an ein [PostgreSQL-Dateisystem-Backup](https://www.postgresql.org/docs/16/backup-file.html); die Container-Richtlinie wird mit [Docker `update --restart`](https://docs.docker.com/reference/cli/docker/container/update/) geändert. + Die Zielversion muss bereits die festen MinIO-Digests und die neuen Backend-/Frontend-Healthchecks enthalten. Ältere Releases bleiben über den in `docs/releases.md` beschriebenen manuellen Ablauf installierbar. Ein Versionswechsel des Betriebssystems oder der Docker Engine gehört nicht zu diesem Ablauf. ## Bereitstellung Neue versionierte Installer laden das Skript und diese Anleitung bereits ins Betriebsverzeichnis und berücksichtigen Python 3 bei den Basispaketen. Bestehende Installationen können `selfhost-update.py` und `SELFHOST-UPDATES.md` aus den Assets eines entsprechenden veröffentlichten Releases übernehmen. Zuerst mit dessen `SHA256SUMS` prüfen, anschließend das Skript unter `/opt/fedeo/scripts/selfhost-update.py` ablegen. Für diese erstmalige Bereitstellung ist noch ein Serverzugriff erforderlich. +Ein bereits heruntergeladener Updater aus Release `3.1.0` enthält die Matrix-Stilllegung noch nicht. Dafür das Hostskript durch den korrigierten Stand aus einem neueren Release oder dem geprüften Repository-Commit ersetzen. Dieser kann weiterhin `3.1.0` als Zielrelease verwenden; dessen veröffentlichte Assets werden nicht verändert. Ein erneuter Download des alten `3.1.0`-Updater-Assets liefert weiterhin den alten Stand. Der Updater ersetzt sich beim Anwenden nicht selbst. + Downloads erfolgen ausschließlich vom konfigurierten FEDEO-Repository über HTTPS. Die Dateiprüfsummen werden gegen `SHA256SUMS`, das Manifest gegen den Git-Tag geprüft. Die Prüfsummen sind **keine unabhängige Signatur**; das Vertrauen liegt beim Gitea-Server und dessen Release-Veröffentlichung. Falls das Repository privat ist, `FEDEO_RELEASE_TOKEN` mit Leserechten nur für den Aufruf setzen. Registry-Anmeldung bei Bedarf vorher über `docker login git.federspiel.tech` im Konto des Updaters durchführen. Der Updater speichert den Release-Token nicht in Statusdateien und gibt ihn bei Weiterleitungen an andere Hosts nicht weiter. ## Prüfen, anwenden und Status lesen @@ -32,7 +40,7 @@ sudo python3 /opt/fedeo/scripts/selfhost-update.py check --version 3.1.0 `3.1.0` ist ein Beispiel und muss durch einen tatsächlich veröffentlichten Zielrelease ersetzt werden. Ein anderes Betriebsverzeichnis wird mit `--directory /pfad/zur/instanz` angegeben. -Die Prüfung lädt die Betriebsdateien und erstellt einen Plan einschließlich `compose.diff` und Release Notes unter `/opt/fedeo/.updates//`. Sie verändert die laufende Installation nicht und lädt noch keine Docker-Images. Die JSON-Ausgabe nennt Zielversion, Commit, betroffene Dienste, geschätzten Backup-Platz und die Pfade der Vorschau. Unveröffentlichte Entwürfe und Vorabversionen werden ignoriert. +Die Prüfung lädt die Betriebsdateien und erstellt einen Plan einschließlich `compose.diff` und Release Notes unter `/opt/fedeo/.updates//`. Sie verändert die laufende Installation nicht und lädt noch keine Docker-Images. Die JSON-Ausgabe nennt Zielversion, Commit, betroffene Dienste, geschätzten Backup-Platz und die Pfade der Vorschau. `updateAvailable: false` bedeutet, dass die geprüfte Version nicht neuer als die installierte ist. Unveröffentlichte Entwürfe und Vorabversionen werden ignoriert. Beim ersten Update kennt der Updater die Herkunft der vorhandenen Compose-Datei noch nicht. Die erzeugte `compose.diff` prüfen und eigene Anpassungen in die Override-Datei übertragen. Dann den Austausch ausdrücklich akzeptieren: @@ -47,9 +55,72 @@ sudo python3 /opt/fedeo/scripts/selfhost-update.py apply --version 3.2.0 sudo python3 /opt/fedeo/scripts/selfhost-update.py status ``` -Eine explizite Zielversion ist beim Anwenden erforderlich. `apply` prüft die Release-Dateien und die tatsächliche Installation nochmals. Bei gleichen oder älteren stabilen Zielversionen wird abgebrochen. Änderungen an `.env`, Basis-Compose oder der Override-Datei während des Downloads führen ebenfalls vor der Wartungsphase zum Abbruch. +Eine explizite Zielversion ist beim Anwenden erforderlich. `apply` prüft die Release-Dateien und die tatsächliche Installation nochmals. Beim Anwenden gleicher oder älterer stabiler Zielversionen wird abgebrochen. Änderungen an `.env`, Basis-Compose oder der Override-Datei während des Downloads führen ebenfalls vor der Wartungsphase zum Abbruch. -Während des ersten Ausbaus den Befehl in einer bestehenden persistenten Terminalsitzung ausführen. Der spätere Host-Dienst übernimmt die vom Browser unabhängige Ausführung. Ein Neustart des FEDEO-Stacks beendet diesen Host-Prozess nicht; ein Neustart des Hosts unterbricht ihn. +Direkte CLI-Aufrufe in einer persistenten Terminalsitzung ausführen. Alternativ übernimmt der folgende Host-Dienst die von der Verbindung unabhängige Ausführung. Ein Neustart des FEDEO-Stacks beendet diese Host-Prozesse nicht. Ein erzwungener Host-Neustart kann sie unterbrechen; beim normalen Dienst-Stop wartet der Dienst auf den laufenden Auftrag. + +## Host-Dienst installieren + +Benötigt zusätzlich einen Linux-Host mit systemd und Docker als `docker.service`. Die Dateien `selfhost-update.py`, `selfhost-update-service.py`, `selfhost-update-service-install.sh` und `fedeo-updater.service` müssen aus demselben geprüften Stand im selben Verzeichnis liegen, beispielsweise `/opt/fedeo/scripts/`. Neuere Installer laden sie dorthin; neuere Releases enthalten alle vier Dateien samt Prüfsummen. Release `3.1.0` enthält den Dienst noch nicht. Zum Nachrüsten können die vier Dateien aus dem geprüften Repository-Commit kopiert werden, ohne die laufende FEDEO-Version zu ändern. + +```bash +sudo bash /opt/fedeo/scripts/selfhost-update-service-install.sh --directory /opt/fedeo +sudo systemctl status fedeo-updater.service +sudo curl --unix-socket /run/fedeo-updater/control.sock http://localhost/v1/status +``` + +Der Installer legt die Python-Module root-eigen unter `/usr/local/lib/fedeo-updater/` ab, installiert die systemd-Unit und aktiviert den Dienst beim Systemstart. Bestehende FEDEO-Container werden durch die Dienstinstallation nicht neu gestartet. Bei einer erneuten Dienstinstallation wartet ein normales `systemctl stop` einen laufenden Auftrag ab; ein aktiver CLI-Updater blockiert den Dateiaustausch. Die Unit kann vorab ohne Installation angezeigt werden: + +```bash +bash /opt/fedeo/scripts/selfhost-update-service-install.sh --directory /opt/fedeo --print-unit +``` + +Der Dienst läuft wegen Docker und der Backups als root. Seine HTTP-Schnittstelle lauscht ausschließlich auf `/run/fedeo-updater/control.sock`, nicht auf einem TCP-Port. Verzeichnis und Socket gehören `root:fedeo-updater` mit `0750` bzw. `0660`. Zugriff auf diese Gruppe erlaubt instanzweite Updates; nur vertrauenswürdigen Host-Prozessen Zugriff geben. Die spätere Backend-Anbindung muss Systemadministratoren prüfen und darf nur diese kontrollierte Schnittstelle verwenden. Es wird kein Docker-Socket in Backend oder Frontend eingebunden. Dieser Stand richtet noch keine Backend-Route und keinen Frontend-Button ein. + +Registry-Anmeldung bei privaten Images als root durchführen. Für private Releases kann `/etc/fedeo/updater.env` root-lesbar (`0600`) `FEDEO_RELEASE_TOKEN=...` enthalten; danach den Dienst neu starten. Dieser Token wird nicht an API-Clients ausgegeben. Die Unit verwendet `/root` als Home-Verzeichnis für die Docker-Anmeldung. Der Dienst ersetzt sich bei einem FEDEO-Update nicht selbst; neue Dienstdateien zusammen bereitstellen und den Dienstinstaller erneut ausführen. + +## Lokale Dienst-API + +Alle Antworten sind JSON mit `Cache-Control: no-store`. Mutationen benötigen `Content-Type: application/json`, höchstens 4096 Byte und eine vom Client erzeugte `requestId` (8–80 Zeichen, Buchstaben/Ziffern/Unterstrich/Bindestrich). Für jede neue Aktion eine neue ID verwenden; bei einem Verbindungsfehler dieselbe ID und denselben Inhalt wiederholen. Das liefert denselben Auftrag statt eines zweiten Updates, auch nach einem Dienstneustart. Eine andere Anfrage mit derselben ID wird mit `409 REQUEST_CONFLICT` abgelehnt. + +| Aufruf | Bedeutung | +| --- | --- | +| `GET /v1/status` | Installierter Stand, Zustand der Update-Engine und aktueller Dienstauftrag | +| `POST /v1/check` | Asynchrone Prüfung; `requestId`, optional `version` ohne `v` | +| `GET /v1/requests/` | Auftrag, bereinigter Prüfplan und aktueller Fortschritt | +| `POST /v1/apply` | Asynchrones Update; `requestId`, feste `version` und erfolgreiche `checkRequestId` | + +Beispiel für eine Prüfung des neuesten Releases: + +```bash +CHECK_ID="$(python3 -c 'import uuid; print(uuid.uuid4())')" +curl --unix-socket /run/fedeo-updater/control.sock \ + -H 'Content-Type: application/json' \ + -d "{\"requestId\":\"$CHECK_ID\"}" http://localhost/v1/check + +curl --unix-socket /run/fedeo-updater/control.sock \ + "http://localhost/v1/requests/$CHECK_ID" +``` + +Neue Aufträge antworten sofort mit `202`. `queued` und `running` bedeuten, dass die Arbeit noch läuft; `checked` bedeutet erfolgreiche Prüfung und `up_to_date`, dass keine neuere Version vorliegt. Der bereinigte Plan enthält Version, Commit, Platzbedarf und stillzulegende Dienste. Vollständige Release Notes und Compose-Diff bleiben auf dem Host, da insbesondere bestehende Compose-Dateien Geheimnisse enthalten können. + +Nach `checked`, `updateAvailable: true` und Prüfung des Plans eine konkrete Zielversion einsetzen: + +```bash +APPLY_ID="$(python3 -c 'import uuid; print(uuid.uuid4())')" +TARGET_VERSION=X.Y.Z +curl --unix-socket /run/fedeo-updater/control.sock \ + -H 'Content-Type: application/json' \ + -d "{\"requestId\":\"$APPLY_ID\",\"version\":\"$TARGET_VERSION\",\"checkRequestId\":\"$CHECK_ID\"}" \ + http://localhost/v1/apply + +curl --unix-socket /run/fedeo-updater/control.sock \ + "http://localhost/v1/requests/$APPLY_ID" +``` + +`succeeded` bestätigt den Abschluss. Der Dienst bindet den Start an genau den geprüften Release und die geprüften Konfigurationsdateien. Bei zwischenzeitlichen Änderungen bricht er mit `PLAN_CHANGED` vor Image-Pull/Wartungsphase ab. `requiresComposeAdoption: true` benötigt zunächst eine manuell geprüfte Übernahme über die CLI; die API bietet dafür keinen Freigabeschalter. Ebenso bleiben `resolve`, freie Shell-Befehle, alternative Betriebsverzeichnisse und Download-Adressen außerhalb der API. Ein zweiter Auftrag bzw. ein laufender CLI-Updater führt zu `409 BUSY`, ein offener Wiederherstellungsfall zu `409 RECOVERY_REQUIRED`. + +Aufträge liegen dauerhaft und root-lesbar unter `.updates/service/`. Nach einem harten Dienstabbruch werden offene Aufträge als `interrupted` markiert und nicht automatisch erneut gestartet. Bei normalem Stop/Restart nimmt der Dienst keine neue Arbeit mehr an und lässt den aktiven Auftrag fertiglaufen (`KillMode=mixed`, `TimeoutStopSec=infinity`). Auch ein reguläres Herunterfahren des Hosts kann dadurch bis zum Auftragsende warten. Nach erzwungenem Abbruch oder Host-Neustart den Status prüfen; unterbrochene Updates folgen den bestehenden Wiederherstellungsprüfungen. Auftragsspezifische Fehlerdetails stehen in `.updates/service/.log`, Werkzeugfehler weiterhin im geschützten `commands.log` des Engine-Auftrags. Über die API werden nur feste Fehlermeldungen und erlaubte Statusfelder ausgegeben, keine Rohlogs, Konfiguration, Dateipfade oder Prüffingerprints. ## Ablauf und Speicherung @@ -59,6 +130,7 @@ Während des ersten Ausbaus den Befehl in einer bestehenden persistenten Termina 4. Speicherplatz prüfen: mindestens 5 GiB frei am Docker-Datenverzeichnis vor dem Pull sowie geschätzte Backup-Größe plus Reserve im Betriebsverzeichnis. Images laden, Backup-Platz erneut prüfen. 5. Schreibende Dienste und Proxy anhalten. PostgreSQL bleibt für `pg_dump` aktiv. 6. Konfiguration, Container-Metadaten, PostgreSQL-Dump im Custom-Format, globale PostgreSQL-Rollen, gestoppten MinIO-Dateispeicher und Zertifikate sichern. Dump und TAR auf Lesbarkeit prüfen und SHA-256-Prüfsummen schreiben. `backup/complete.json` markiert ein vollständig geschriebenes Backup; ein Restore-Probelauf auf einer getrennten Instanz wird dadurch nicht ersetzt. + Beim Übergang vom alten Matrix-Stack enthält das TAR zusätzlich `matrix/`. Container-Metadaten enthalten die ursprünglichen Image-IDs, Mounts und Neustartrichtlinien; `complete.json` nennt die stillgelegten Dienste und erhaltenen Container. Vor dem Dateiaustausch wird deren automatischer Neustart deaktiviert. Das Zielrelease enthält keine Matrix-Dienste und keine Backend-Abhängigkeit auf Synapse mehr. 7. Compose-Datei und `.env` einzeln atomar ersetzen. In `.env` wird ausschließlich `FEDEO_VERSION` verändert; `.env.example` und Setup-Skript werden erneuert. Den laufenden Updater ersetzt dieser Vorgang nicht. 8. Infrastruktur starten, Bucket-Einrichtung ausführen und Migrationen explizit in einem benannten Einmalcontainer starten. Beim anschließenden regulären Backend-Start läuft dessen bestehende Migrationsprüfung nochmals; bereits angewendete Migrationen werden dabei übersprungen. 9. Stack mit `up --wait` starten. Backend-/Frontend-Image-IDs und Healthchecks sowie öffentlich gemeldete Version und Commit prüfen. Erst dann den Release als installiert speichern. @@ -86,6 +158,8 @@ Für die Rückkehr zum alten Stand in einem geplanten Wartungsfenster: sudo python3 /opt/fedeo/scripts/selfhost-update.py resolve --version 3.0.0 ``` +Bei Rückkehr zum alten Matrix-Stack dessen ursprüngliche Compose-Datei aus demselben Backup verwenden. Falls nötig, das vollständige `matrix/`-Verzeichnis aus `files.tar` bei gestoppten Matrix-Diensten wiederherstellen, insbesondere den gesamten PostgreSQL-Cluster mit dem ursprünglichen Datenbank-Image. Vorhandene Container-Schichten und anonyme Volumes bleiben an den behaltenen Containern erhalten. Für jeden betroffenen Container die ursprüngliche `HostConfig.RestartPolicy` aus `backup/containers.json` wieder setzen, beispielsweise `docker update --restart=unless-stopped CONTAINER_ID`, wenn genau diese Richtlinie gespeichert wurde. Danach die zuvor laufenden Matrix-Dienste mit der ursprünglichen Compose-Konfiguration starten und prüfen. `resolve` für den alten Stand prüft auch deren Image, Laufzustand und Neustartrichtlinie. `resolve` für den Zielrelease verlangt weiterhin gestoppte Matrix-Container mit deaktiviertem automatischem Neustart. Es führt diese Schritte nicht selbst aus. + `resolve` prüft die angegebene Backend-Version, Backend-Erreichbarkeit, Frontend-Erreichbarkeit, laufende Kerndienste, passende Anwendungs-Images und das Fehlen eines zurückgebliebenen Migrationscontainers. Es stellt selbst **keine Daten wieder her**. Bei alten Entwicklungsinstallationen die tatsächlich von `/backend/version` gemeldete Entwicklungsversion angeben. Ein späteres Update kann nach einer manuellen Compose-Wiederherstellung erneut `--accept-compose-replacement` erfordern. ## Entwicklung und Tests @@ -95,4 +169,4 @@ npm test npm run commits:check ``` -Die Update-Tests verwenden den echten Compose-Parser und einen simulierten Docker-Daemon. Sie prüfen u. a. Erfolg, Prüfsummen, Release-Auswahl, unveränderte Konfiguration, Speicherplatz, parallele Aufträge, Backupfehler, Migrationsfehler, falsche laufende Versionen und unterbrochene Aufträge. Ein vollständiger Test mit realem PostgreSQL-/MinIO-Restore auf einer separaten Selfhost-Testinstanz ist vor dem Produktionseinsatz zusätzlich erforderlich. +Die Update-Tests verwenden den echten Compose-Parser und einen simulierten Docker-Daemon. Sie prüfen u. a. Erfolg, Prüfsummen, Release-Auswahl, unveränderte Konfiguration, Speicherplatz, parallele Aufträge, Backupfehler, Migrationsfehler, falsche laufende Versionen und unterbrochene Aufträge. Die Matrix-Tests prüfen Stilllegung, Daten-/Container-Erhalt, Backup vor Änderung der Neustartrichtlinien, Fehler und Unterbrechungen sowie die weitere Ablehnung unbekannter Dienste. Ein vollständiger Test mit realem PostgreSQL-/MinIO-/Matrix-Restore auf einer separaten Selfhost-Testinstanz ist vor dem Produktionseinsatz zusätzlich erforderlich. diff --git a/frontend/components/costcentreDisplay.vue b/frontend/components/costcentreDisplay.vue index 09a4e06d..138dc130 100644 --- a/frontend/components/costcentreDisplay.vue +++ b/frontend/components/costcentreDisplay.vue @@ -1,5 +1,6 @@