31 Commits

Author SHA1 Message Date
468f8b51f6 Merge pull request 'dev' (#257) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 59s
Build and Release FEDEO / build-and-release (push) Successful in 6m52s
Reviewed-on: #257
2026-11-04 10:35:20 +00:00
e9697ef197 feat(accounting): Pflichtangaben der E-Rechnung vor dem Fertigstellen durchsetzen
All checks were successful
Build and Release FEDEO / verify (push) Successful in 59s
Build and Release FEDEO / build-and-release (push) Successful in 6m27s
Fehlende Pflichtangaben (Firmendaten, Steuer-ID, Kundenanschrift, IBAN,
§ 13b-USt-Id, Lastschriftmandat) erscheinen im Editor als blockierende
Fehler und verhindern Vorschau und Fertigstellung. Serienrechnungslauf,
MCP-Finalisierung und PDF-Erzeugung prüfen serverseitig vor der
Nummernvergabe bzw. Ablage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 11:32:02 +01:00
549b469a41 feat(accounting): Ausgangsrechnungen als ZUGFeRD-E-Rechnung (EN 16931) erzeugen
Rechnungen, Abschlags-, Storno- und Serienrechnungen erhalten beim
Fertigstellen ein eingebettetes factur-x.xml (CII, Profil EN 16931) samt
Factur-X-XMP-Metadaten. Das XML wird serverseitig aus dem gespeicherten
Beleg erzeugt; fehlende Pflichtangaben werden als Hinweis gemeldet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 11:32:02 +01:00
bd768e404d feat(accounting): Skontobedingungen auf Belegen und automatische Skonto-Vorschläge
Ausgangs- und Eingangsbelege erhalten Skontosatz und Frist, mit Vorgaben
bei Kunden und Lieferanten. Das PDF ergänzt automatisch einen Skontohinweis
(oder nutzt neue Textvorlagen-Variablen); bei Schlussrechnungen gilt der
Skonto für die Restforderung. Skontobedingungen werden aus E-Rechnungen
(#SKONTO# und ZUGFeRD) sowie von der KI-Vorbereitung übernommen. Die
Bankzuordnung erkennt Zahlungen abzüglich Skonto innerhalb der Frist und
bietet sie in der Vorschlagsübersicht direkt an. BWA-Erlöse und -Aufwand
berücksichtigen gebuchten Skonto im Zeitraum der Zahlung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 11:32:02 +01:00
af4a283eb5 feat(accounting): Skonto bei Zahlungen auf Ausgangs- und Eingangsbelege buchen
Skonto wird an der Bankzuordnung gespeichert und gleicht den Beleg zusammen
mit der Zahlung aus. Der Betrag wird anteilig nach Steuersätzen auf die
Skontokonten verteilt (Standard SKR03, je Mandant einstellbar), im
DATEV-Export als eigene Buchung je Steuersatz ausgegeben und in der
USt-Auswertung im Zeitraum der Zahlung berücksichtigt. Schlussrechnungen
verteilen den Skonto auf die Restforderung nach Abzug der Abschläge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 11:31:47 +01:00
28f24aa998 Merge pull request 'feat(documents): Titel mitsamt untergeordneten Zeilen verschieben' (#256) from feat/move-document-title-groups into dev
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m0s
Build and Release FEDEO / build-and-release (push) Successful in 6m33s
Reviewed-on: #256
2026-11-04 07:50:04 +00:00
05b0c12841 feat(documents): expose complete title section movement in editor 2026-11-03 21:47:14 +00:00
c46c50a2a3 test(documents): cover nested title section moves 2026-11-03 21:45:35 +00:00
72ff7addaa feat(documents): add title section movement helpers 2026-11-03 21:44:40 +00:00
d4c4a2b9c1 Merge pull request 'dev' (#255) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m4s
Build and Release FEDEO / build-and-release (push) Successful in 6m36s
Reviewed-on: #255
2026-10-29 17:20:58 +00:00
6ae8b7ad9e fix(pdf): Überweisungs-QR-Code überdeckt keine Positionen und Summen mehr
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m1s
Build and Release FEDEO / build-and-release (push) Successful in 6m19s
Reichen Positionen oder Summen auf der letzten Seite in den Bereich des QR-Codes, wird er auf einer eigenen Folgeseite gedruckt. Die MCP-Vorschau meldet das und schlägt einen früheren Seitenumbruch vor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:37:36 +01:00
544d607088 feat(mcp): Vorschau von Ausgangsbelegen mit Seitenbildern und Umbruchprüfung
- accounting.outgoing_documents.preview rendert den Beleg wie der Editor und liefert Seiten als PNG-Bilder im MCP-Ergebnis
- Layout je Seite in mm mit Warnungen zu übervollen Seiten, allein stehenden Überschriften, leeren Seiten und vom Überweisungs-QR-Code überdeckten Beträgen samt Vorschlag für den Seitenumbruch
- rows testet geänderte Positionen samt Seitenumbrüchen, ohne zu speichern
- Belegdaten für das PDF als Backend-Port von getDocumentData (Titelsummen, Überträge, Abschläge, Optional-/Alternativpositionen)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:29:17 +01:00
c10bfac23b fix(mcp): Artikel- und Leistungsbeschreibung in Ausgangsbelegpositionen übernehmen
Wie im Belegeditor werden bei mode=normal/service fehlende Beschreibung, Name, Einheit, Preis, USt-Satz und Kostenstelle aus Artikel bzw. Leistung übernommen. descriptionText wird als description gespeichert, weil der Editor nur description liest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:23:33 +01:00
1b617c7910 Merge pull request 'dev' (#252) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m1s
Build and Release FEDEO / build-and-release (push) Successful in 6m34s
Reviewed-on: #252
2026-10-27 20:39:01 +00:00
9e003125c2 feat(mcp): Dateien ohne Base64 per HTTP hochladen
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m2s
Build and Release FEDEO / build-and-release (push) Successful in 6m19s
- POST /api/mcp/files nimmt multipart/form-data oder Rohdaten mit dem MCP-Token an, Ziel Dokumentenablage oder Eingangsrechnung
- documents.files.upload_url liefert Adresse, Parameter und curl-Beispiele
- Upload einer Eingangsrechnung bereitet nur noch die hochgeladene Datei vor

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:50:34 +01:00
76e82eae4f feat(accounting): fehlende PDF von Ausgangsbelegen nacherzeugen
Auf der Belegansicht erscheint "PDF nacherzeugen", wenn ein
fertiggestellter Beleg kein angehängtes Dokument hat. Die PDF wird mit
derselben Logik wie beim Fertigstellen erzeugt und angehängt, ohne den
Datensatz zu speichern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:40:25 +01:00
f814689ddd fix(accounting): Belegvorbereitung im Hintergrund ausführen und Dubletten verhindern
- „Belege vorbereiten“ startet den Lauf im Hintergrund (202) und fragt den Status ab; ein laufender Vorgang wird nicht erneut gestartet und beim Wiederöffnen der Seite weiterverfolgt
- Eingangsbeleg und Dateiverknüpfung werden in einer Transaktion angelegt; ist die Datei bereits verknüpft, wird der neue Beleg verworfen
- Fehler einzelner Dateien brechen den Lauf nicht mehr ab

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:38:45 +01:00
755f1b7e0d feat(accounting): Kostenstellen von Eingangsbelegen jederzeit nachträglich ändern
Eigener Endpunkt und MCP-Tool ändern ausschließlich die Positionskostenstellen,
auch bei gebuchten oder einer Bankbuchung zugewiesenen Eingangsbelegen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:50:36 +01:00
c6fd53307c feat(mcp): Belegvorbereitung für Eingangsrechnungen anstoßen
- accounting.incoming_invoices.prepare startet die Vorbereitung für alle offenen oder ausgewählte Dateien und liefert angelegte Entwürfe samt Validierung sowie übersprungene Dateien
- Vorbereitungslauf meldet sein Ergebnis und läuft je Mandant nacheinander, damit parallele Aufrufe keine doppelten Belege anlegen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:03:36 +01:00
0777315d1d feat(mcp): Archivierte Einträge wiederherstellen und Kontostände abfragen
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m5s
Build and Release FEDEO / build-and-release (push) Successful in 6m21s
- archive.entities.list / archive.entities.restore für alle per MCP archivierbaren Entitäten mit Berechtigungsprüfung je Entität und Historieneintrag
- accounting.bank_accounts.balances liefert Stände von Bankkonten und Kassenbüchern, optional zu einem Stichtag

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 14:28:05 +01:00
9676a270da Merge pull request 'dev' (#251) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m7s
Build and Release FEDEO / build-and-release (push) Successful in 6m36s
Reviewed-on: #251
2026-10-25 13:17:14 +00:00
36dffc057a fix(release): exclude merged pre-baseline mobile history from commit checks
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m6s
Build and Release FEDEO / build-and-release (push) Successful in 6m25s
Merge 7a1986bb brought in the old mobile branch (89185b4b), whose commits
predate the release baseline and do not follow Conventional Commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:55:48 +01:00
ea31a15eac fix(accounting): Kostenstellen-Auswertung wertet Einnahmen positiv und Ausgaben negativ
Some checks failed
Build and Release FEDEO / verify (push) Failing after 1m9s
Build and Release FEDEO / build-and-release (push) Has been skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:47:34 +01:00
f0775ad52a fix(mobile): Chat startet bei neuester Nachricht und Chats nach letzter Aktivität sortieren
Der Verlauf nutzt eine invertierte Liste und öffnet dadurch immer ganz unten.
Raumlisten liefern lastActivityAt; die App sortiert alle Chats danach und
zieht einen Chat nach dem Senden sofort nach oben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:40:09 +02:00
e0a94f5e29 fix(mobile): KI-AGENT: Neu angelegte Chats vor veralteten Listen schützen 2026-10-19 11:44:07 +02:00
da84e98b8a feat(mobile): KI-AGENT: Föderierte Chats und gemeinsame Sichtbarkeit ergänzen 2026-10-19 11:40:26 +02:00
bfba29b15e build(ios): KI-AGENT: TestFlight-Buildnummer auf 15 erhöhen 2026-10-19 11:09:59 +02:00
7a1986bbb8 chore(sync): KI-AGENT: Aktuellen Entwicklungsstand für TestFlight übernehmen 2026-10-19 11:09:09 +02:00
89185b4b08 KI-AGENT: Direkte Teltonika-Fahrtenbucharchitektur konzipiert 2026-09-12 20:05:49 +02:00
aa08f5c358 KI-AGENT: iOS-Buildnummer für TestFlight auf 14 erhöhen 2026-09-08 23:13:44 +02:00
2c1047e624 KI-AGENT: Kalender, Terminbearbeitung und Dateivorschau verbessern 2026-09-08 23:08:36 +02:00
110 changed files with 6813 additions and 683 deletions

View File

@@ -0,0 +1,5 @@
ALTER TABLE "tenants" ADD COLUMN "cashDiscountAccounts" jsonb DEFAULT '{}'::jsonb NOT NULL;
--> statement-breakpoint
ALTER TABLE "statementallocations" ADD COLUMN "cash_discount_amount" double precision;
--> statement-breakpoint
ALTER TABLE "statementallocations" ADD COLUMN "cash_discount_split" jsonb;

View File

@@ -0,0 +1,15 @@
ALTER TABLE "createddocuments" ADD COLUMN "cashDiscountPercent" double precision;
--> statement-breakpoint
ALTER TABLE "createddocuments" ADD COLUMN "cashDiscountDays" smallint;
--> statement-breakpoint
ALTER TABLE "incominginvoices" ADD COLUMN "cashDiscountPercent" double precision;
--> statement-breakpoint
ALTER TABLE "incominginvoices" ADD COLUMN "cashDiscountDueDate" text;
--> statement-breakpoint
ALTER TABLE "customers" ADD COLUMN "customCashDiscountPercent" double precision;
--> statement-breakpoint
ALTER TABLE "customers" ADD COLUMN "customCashDiscountDays" smallint;
--> statement-breakpoint
ALTER TABLE "vendors" ADD COLUMN "cashDiscountPercent" double precision;
--> statement-breakpoint
ALTER TABLE "vendors" ADD COLUMN "cashDiscountDays" smallint;

View File

@@ -568,6 +568,20 @@
"when": 1792022400001,
"tag": "0083_collaboration_notifications",
"breakpoints": true
},
{
"idx": 81,
"version": "7",
"when": 1792108800001,
"tag": "0084_cash_discounts",
"breakpoints": true
},
{
"idx": 82,
"version": "7",
"when": 1792195200001,
"tag": "0085_cash_discount_terms",
"breakpoints": true
}
]
}

View File

@@ -71,6 +71,8 @@ export const createddocuments = pgTable("createddocuments", {
deliveryDateType: text("deliveryDateType"),
paymentDays: smallint("paymentDays"),
cashDiscountPercent: doublePrecision("cashDiscountPercent"),
cashDiscountDays: smallint("cashDiscountDays"),
deliveryDate: text("deliveryDate"),
contactPerson: uuid("contactPerson"),

View File

@@ -7,6 +7,7 @@ import {
jsonb,
smallint,
uuid,
doublePrecision,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
@@ -41,6 +42,8 @@ export const customers = pgTable(
profiles: jsonb("profiles").notNull().default([]),
customPaymentDays: smallint("customPaymentDays"),
customCashDiscountPercent: doublePrecision("customCashDiscountPercent"),
customCashDiscountDays: smallint("customCashDiscountDays"),
firstname: text("firstname"),
lastname: text("lastname"),

View File

@@ -7,6 +7,7 @@ import {
jsonb,
numeric,
uuid,
doublePrecision,
} from "drizzle-orm/pg-core"
import { vendors } from "./vendors"
@@ -36,6 +37,8 @@ export const incominginvoices = pgTable("incominginvoices", {
document: bigint("document", { mode: "number" }),
dueDate: text("dueDate"),
cashDiscountPercent: doublePrecision("cashDiscountPercent"),
cashDiscountDueDate: text("cashDiscountDueDate"),
description: text("description"),

View File

@@ -7,6 +7,7 @@ import {
timestamp,
boolean,
doublePrecision,
jsonb,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
@@ -30,6 +31,10 @@ export const statementallocations = pgTable("statementallocations", {
amount: doublePrecision("amount").notNull().default(0),
// Skonto mit dem Vorzeichen der Zahlung; amount bleibt der tatsächlich gezahlte Betrag.
cashDiscountAmount: doublePrecision("cash_discount_amount"),
cashDiscountSplit: jsonb("cash_discount_split"),
incominginvoice: bigint("ii_id", { mode: "number" }).references(
() => incominginvoices.id
),

View File

@@ -154,6 +154,8 @@ export const tenants = pgTable(
}),
accountChart: text("accountChart").notNull().default("skr03"),
cashDiscountAccounts: jsonb("cashDiscountAccounts").notNull().default({}),
standardEmailForInvoices: text("standardEmailForInvoices"),
extraModules: jsonb("extraModules").notNull().default([]),

View File

@@ -6,6 +6,8 @@ import {
boolean,
jsonb,
uuid,
doublePrecision,
smallint,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
@@ -36,6 +38,8 @@ export const vendors = pgTable("vendors", {
archived: boolean("archived").notNull().default(false),
defaultPaymentMethod: text("defaultPaymentMethod"),
cashDiscountPercent: doublePrecision("cashDiscountPercent"),
cashDiscountDays: smallint("cashDiscountDays"),
updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id),

View File

@@ -9,7 +9,7 @@
"fill": "ts-node src/webdav/fill-file-sizes.ts",
"dev:dav": "tsx watch src/webdav/server.ts",
"build": "tsc",
"test:einvoice": "tsx --test tests/einvoice.test.ts",
"test:einvoice": "tsx --test tests/einvoice.test.ts tests/zugferdInvoice.test.ts",
"start": "node dist/src/index.js",
"schema:index": "ts-node scripts/generate-schema-index.ts",
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",

View File

@@ -17,7 +17,8 @@ export const outgoingRowsSchema = {
pos: { type: "string", description: "Positionsnummer, z. B. 1.1; wird sonst erzeugt." },
mode: { type: "string", enum: ["free", "normal", "service", "title", "text", "pagebreak"], default: "free" },
text: { type: "string" },
descriptionText: { type: "string" },
description: { type: "string", description: "Positionsbeschreibung. Ohne Angabe wird sie bei mode=normal/service aus Artikel bzw. Leistung übernommen, ebenso fehlender Name, Einheit, Preis und USt-Satz." },
descriptionText: { type: "string", description: "Veraltet; wird als description übernommen, wenn description fehlt." },
product: { type: "integer", minimum: 1, description: "Artikel-ID bei mode=normal." },
service: { type: "integer", minimum: 1, description: "Leistungs-ID bei mode=service." },
quantity: { type: "number", default: 1 },

View File

@@ -0,0 +1,93 @@
import { assertToolPermission } from "./authz"
import { assertInternalDocumentAccess, MCP_FILE_LIMIT } from "./documentFiles"
import { McpContext, McpTool } from "./types"
import { createScannerInboxService, validateScannerFile } from "../modules/scanner-inbox.service"
import { uploadIncomingInvoiceFile } from "./tools/accounting"
import { secrets } from "../utils/secrets"
export const MCP_UPLOAD_PATH = "/api/mcp/files"
export const uploadTargets = ["inbox", "incoming_invoice"] as const
export type UploadTarget = typeof uploadTargets[number]
export type McpFileUpload = {
target: UploadTarget
filename: string
body: Buffer
mimeType?: string | null
invoiceId?: number | null
prepare?: boolean
}
export function mcpUploadUrl() {
const base = secrets.API_BASE_URL?.replace(/\/+$/, "")
return base ? `${base}${MCP_UPLOAD_PATH}` : MCP_UPLOAD_PATH
}
const badRequest = (message: string, statusCode = 400) => Object.assign(new Error(message), { statusCode })
/** Reads the query options of the binary upload endpoint. */
export function parseUploadQuery(query: Record<string, unknown>) {
const target = (query.target ?? "inbox") as UploadTarget
if (!uploadTargets.includes(target)) throw badRequest(`target muss ${uploadTargets.join(" oder ")} sein.`)
let invoiceId: number | null = null
if (query.invoiceId !== undefined && query.invoiceId !== "") {
invoiceId = Number(query.invoiceId)
if (!Number.isSafeInteger(invoiceId) || invoiceId < 1) throw badRequest("invoiceId muss eine positive ganze Zahl sein.")
}
if (query.prepare !== undefined && !["true", "false"].includes(String(query.prepare))) throw badRequest("prepare muss true oder false sein.")
if (target === "inbox" && (invoiceId || query.prepare !== undefined)) throw badRequest("invoiceId und prepare gelten nur für target=incoming_invoice.")
const filename = typeof query.filename === "string" && query.filename.trim() ? query.filename.trim() : null
return { target, invoiceId, prepare: String(query.prepare) !== "false", filename }
}
export async function storeMcpFileUpload(context: McpContext, upload: McpFileUpload) {
if (!upload.body.length) throw badRequest("Die Datei ist leer.")
if (upload.body.length > MCP_FILE_LIMIT) throw badRequest("MCP-Uploads sind auf 20 MiB begrenzt.", 413)
if (upload.target === "inbox") {
assertToolPermission(context, { name: "documents.files.upload", requiredPermissions: ["documents.write"] })
await assertInternalDocumentAccess(context)
const mimeType = validateScannerFile(upload.filename, upload.body)
const result = await createScannerInboxService(context.server.db).upload(context.tenantId, context.userId, upload.filename, upload.body)
return { ...result, filename: upload.filename, mimeType, size: upload.body.length }
}
assertToolPermission(context, { name: "accounting.incoming_invoices.files.upload", requiredPermissions: ["accounting.incoming_invoices.write"] })
return uploadIncomingInvoiceFile(context, {
filename: upload.filename,
buffer: upload.body,
mimeType: upload.mimeType || "application/octet-stream",
invoiceId: upload.invoiceId,
prepare: upload.prepare,
})
}
export const uploadUrlTool: McpTool = {
name: "documents.files.upload_url",
title: "Upload-Adresse für Dateien",
description: "Liefert die HTTP-Adresse, über die Dateien ohne Base64 direkt hochgeladen werden (multipart/form-data oder Rohdaten, maximal 20 MiB). Für Uploads mit Dateizugriff, z. B. per curl, statt documents.files.upload oder accounting.incoming_invoices.files.upload verwenden.",
// Informational only; the endpoint checks the permission of the chosen target.
requiredPermissions: [],
inputSchema: { type: "object", properties: {}, additionalProperties: false },
async handler() {
const url = mcpUploadUrl()
return {
url,
method: "POST",
authentication: "Authorization: Bearer <derselbe MCP-Token>",
bodies: {
multipart: "multipart/form-data mit dem Dateifeld file; der Dateiname wird aus dem Upload übernommen.",
raw: "Dateiinhalt als Body mit passendem Content-Type (application/pdf, image/jpeg, image/png, image/tiff, application/xml oder application/octet-stream) und Query-Parameter filename.",
},
query: {
target: "inbox (Standard, Dokumentenablage, benötigt documents.write) oder incoming_invoice (Eingangsrechnung, benötigt accounting.incoming_invoices.write).",
filename: "Dateiname; bei Rohdaten erforderlich, bei multipart optional zum Überschreiben.",
invoiceId: "Nur incoming_invoice: vorhandenen Eingangsbeleg verknüpfen.",
prepare: "Nur incoming_invoice: false unterdrückt die automatische Belegvorbereitung (Standard true).",
},
response: "JSON wie beim entsprechenden Base64-Werkzeug.",
examples: [
`curl -H "Authorization: Bearer $FEDEO_MCP_TOKEN" -F "file=@rechnung.pdf" "${url}?target=incoming_invoice"`,
`curl -H "Authorization: Bearer $FEDEO_MCP_TOKEN" -H "Content-Type: application/pdf" --data-binary @scan.pdf "${url}?filename=scan.pdf"`,
],
}
},
}

View File

@@ -0,0 +1,95 @@
import { InvoicePdfLayout } from "../utils/pdf"
/** Typical letterheads reserve the bottom ~35 mm of A4 for their footer. */
export const DEFAULT_CONTENT_BOTTOM_MM = 262
type Warning = { type: string, page: number, message: string, insertPagebreakBeforeIndex?: number }
const round = (value: number) => Math.round(value * 10) / 10
/**
* Summarises where rows land and suggests page breaks. FEDEO never breaks pages automatically,
* so content below the usable area runs into the letterhead footer or off the page.
*/
export function analyzeOutgoingDocumentLayout(layout: InvoicePdfLayout, maxContentBottomMm = DEFAULT_CONTENT_BOTTOM_MM) {
const warnings: Warning[] = []
const blocks = [
...layout.rows.filter((row) => row.mode !== "pagebreak").map((row) => ({ page: row.page, bottomMm: row.bottomMm })),
...(layout.totals ? [layout.totals] : []),
...(layout.endText ? [layout.endText] : []),
]
const pages = Array.from({ length: layout.pageCount }, (_, offset) => {
const page = offset + 1
const rows = layout.rows.filter((row) => row.page === page && row.mode !== "pagebreak")
const contentBottomMm = Math.max(0, ...blocks.filter((block) => block.page === page).map((block) => block.bottomMm))
return {
page,
rows: rows.map((row) => ({ index: row.index, pos: row.pos, mode: row.mode, text: row.text, topMm: round(row.topMm), bottomMm: round(row.bottomMm) })),
contentBottomMm: round(contentBottomMm),
freeMm: round(maxContentBottomMm - contentBottomMm),
}
})
for (const page of pages) {
const overflowing = page.rows.find((row) => row.bottomMm > maxContentBottomMm)
if (overflowing) {
// Keep headings with their first position instead of leaving them at the page end.
let before = page.rows.indexOf(overflowing)
while (before > 0 && page.rows[before - 1].mode === "title") before -= 1
const target = page.rows[before]
warnings.push({
type: "overflow",
page: page.page,
message: `Position ${overflowing.pos || overflowing.index + 1} reicht bis ${overflowing.bottomMm} mm und damit über den nutzbaren Bereich (${maxContentBottomMm} mm).`,
...(target.index > 0 ? { insertPagebreakBeforeIndex: target.index } : {}),
})
}
const last = page.rows[page.rows.length - 1]
if (last?.mode === "title" && page.page < layout.pageCount) {
warnings.push({ type: "orphanTitle", page: page.page, message: `Überschrift ${last.pos || last.index + 1} steht allein am Seitenende.`, insertPagebreakBeforeIndex: last.index })
}
if (!page.rows.length && page.page > 1 && page.page !== layout.paymentQr?.page && !blocks.some((block) => block.page === page.page)) {
warnings.push({ type: "emptyPage", page: page.page, message: "Die Seite ist leer, vermutlich durch doppelte oder abschließende Seitenumbrüche." })
}
}
// The renderer moves the payment QR code to an extra page when the amounts reach its spot.
const qr = layout.paymentQr
const qrAlone = qr && !blocks.some((block) => block.page === qr.page)
if (qr && qrAlone) {
const previous = pages[qr.page - 2]
const reaching = previous?.rows.find((row) => row.bottomMm > qr.topMm)
let before = reaching ? previous.rows.indexOf(reaching) : -1
while (before > 0 && previous.rows[before - 1].mode === "title") before -= 1
warnings.push({
type: "paymentQrOwnPage",
page: qr.page,
message: `Der Überweisungs-QR-Code steht allein auf Seite ${qr.page}, weil Positionen oder Summen auf Seite ${qr.page - 1} tiefer als ${round(qr.topMm)} mm reichen. Ein früherer Seitenumbruch hält ihn bei den Summen.`,
insertPagebreakBeforeIndex: before > 0 ? previous.rows[before].index : layout.rows.length,
})
}
for (const [name, block] of [["Summenblock", layout.totals], ["Schlusstext", layout.endText]] as const) {
if (!block || block.bottomMm <= maxContentBottomMm || pages[block.page - 1]?.rows.some((row) => row.bottomMm > maxContentBottomMm)) continue
warnings.push({
type: "overflow",
page: block.page,
message: `${name} reicht bis ${round(block.bottomMm)} mm und damit über den nutzbaren Bereich (${maxContentBottomMm} mm).`,
insertPagebreakBeforeIndex: layout.rows.length,
})
}
return {
layout: {
pageHeightMm: round(layout.pageHeightMm),
maxContentBottomMm,
pages,
totals: layout.totals && { page: layout.totals.page, topMm: round(layout.totals.topMm), bottomMm: round(layout.totals.bottomMm) },
endText: layout.endText && { page: layout.endText.page, topMm: round(layout.endText.topMm), bottomMm: round(layout.endText.bottomMm) },
paymentQr: layout.paymentQr && { page: layout.paymentQr.page, topMm: round(layout.paymentQr.topMm), leftMm: round(layout.paymentQr.leftMm) },
},
warnings,
hint: "Seitenumbruch setzen: an der Stelle insertPagebreakBeforeIndex ein Objekt {\"mode\":\"pagebreak\"} in rows einfügen (Index 0-basiert; rows.length = vor Summenblock). Zuerst mit rows in dieser Vorschau prüfen, dann mit accounting.outgoing_documents.update speichern. Die Bilder sind maßgeblich; die mm-Werte sind Schätzungen des Renderers.",
}
}

View File

@@ -0,0 +1,62 @@
import { and, eq, inArray } from "drizzle-orm"
import { products, services } from "../../db/schema"
import { McpContext } from "./types"
const isMissing = (value: unknown) => value === undefined || value === null || value === ""
const ids = (rows: Record<string, any>[], mode: string, key: string) =>
[...new Set(rows.filter((row) => row.mode === mode && Number.isSafeInteger(Number(row[key])) && Number(row[key]) > 0).map((row) => Number(row[key])))]
const parseRow = (row: unknown) => {
if (typeof row !== "string") return row
try { return JSON.parse(row) } catch { return row }
}
/**
* Mirrors the document editor: selecting a product or service fills name, description, unit,
* price, tax rate and (for services) cost centre. Values passed by the caller always win.
*/
export async function applyOutgoingRowCatalogDefaults(context: Pick<McpContext, "server" | "tenantId">, rows: unknown) {
if (!Array.isArray(rows)) return rows
const parsed = rows.map(parseRow)
const objects = parsed.filter((row): row is Record<string, any> => !!row && typeof row === "object" && !Array.isArray(row))
const productIds = ids(objects, "normal", "product")
const serviceIds = ids(objects, "service", "service")
const [productRows, serviceRows] = await Promise.all([
productIds.length ? context.server.db.select().from(products)
.where(and(eq(products.tenant, context.tenantId), eq(products.archived, false), inArray(products.id, productIds))) : [],
serviceIds.length ? context.server.db.select().from(services)
.where(and(eq(services.tenant, context.tenantId), eq(services.archived, false), inArray(services.id, serviceIds))) : [],
])
const productMap = new Map<number, any>(productRows.map((row: any) => [row.id, row] as const))
const serviceMap = new Map<number, any>(serviceRows.map((row: any) => [row.id, row] as const))
return parsed.map((row, index) => {
if (!row || typeof row !== "object" || Array.isArray(row)) return row
const result = { ...(row as Record<string, any>) }
let defaults: Record<string, unknown> | null = null
if (result.mode === "normal" && !isMissing(result.product)) {
const product = productMap.get(Number(result.product))
if (!product) throw new Error(`Position ${index + 1}: Artikel ${result.product} nicht gefunden oder archiviert`)
defaults = { text: product.name, description: product.description, unit: product.unit, price: product.selling_price, taxPercent: product.tax_percentage }
} else if (result.mode === "service" && !isMissing(result.service)) {
const service = serviceMap.get(Number(result.service))
if (!service) throw new Error(`Position ${index + 1}: Leistung ${result.service} nicht gefunden oder archiviert`)
defaults = {
text: service.name, description: service.description, unit: service.unit,
price: service.sellingPriceComposed?.total || service.sellingPrice, taxPercent: service.taxPercentage, costCentre: service.costcentre,
}
}
// The editor stores the position text in description; descriptionText is only derived for the PDF.
if (isMissing(result.description) && !isMissing(result.descriptionText)) result.description = result.descriptionText
if (!defaults) return result
for (const key of ["text", "description", "unit", "costCentre"]) {
if (isMissing(result[key]) && !isMissing(defaults[key])) result[key] = defaults[key]
}
if (isMissing(result.price) && isMissing(result.inputPrice) && !isMissing(defaults.price)) {
result.inputPrice = Number(defaults.price)
result.price = Number(defaults.price)
}
if (isMissing(result.taxPercent) && !isMissing(defaults.taxPercent)) result.taxPercent = Number(defaults.taxPercent)
return result
})
}

View File

@@ -3,6 +3,8 @@ import { masterdataTools } from "./tools/masterdata"
import { organisationTools } from "./tools/organisation"
import { wikiTools } from "./tools/wiki"
import { documentTools } from "./tools/documents"
import { archiveTools } from "./tools/archive"
import { uploadUrlTool } from "./fileUploads"
export const mcpTools = [
...accountingTools,
@@ -10,6 +12,8 @@ export const mcpTools = [
...organisationTools,
...wikiTools,
...documentTools,
...archiveTools,
uploadUrlTool,
]
export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool]))

View File

@@ -2,6 +2,12 @@ import { McpToolResult } from "./types"
const OMIT_ARCHIVED = Symbol("omit-archived")
/** Attach images to a tool payload; they become MCP image content next to the JSON text. */
export const MCP_IMAGES = Symbol("mcp-images")
export type McpImage = { data: string, mimeType: string }
export const withImages = <T extends Record<string, unknown>>(payload: T, images: McpImage[]) =>
Object.assign(payload, { [MCP_IMAGES]: images })
function omitArchivedRecords(value: unknown): unknown | typeof OMIT_ARCHIVED {
if (Array.isArray(value)) {
return value
@@ -32,12 +38,15 @@ export function asToolResult(payload: unknown): McpToolResult {
? resultPayload as Record<string, unknown>
: { result: resultPayload }
const images: McpImage[] = (payload as any)?.[MCP_IMAGES] || []
return {
content: [
{
type: "text",
text: JSON.stringify(resultPayload, null, 2),
},
...images.map((image) => ({ type: "image" as const, data: image.data, mimeType: image.mimeType })),
],
structuredContent,
}

View File

@@ -55,6 +55,14 @@ export const prepareStatementAllocationInput = (args: Record<string, unknown>) =
if (!bookingModes.has(bookingMode)) throw new Error(`Ungültige Aufwandsart: ${bookingMode}`)
payload.amount = amount
if (hasValue(args.cashDiscountAmount)) {
const cashDiscountAmount = Number(args.cashDiscountAmount)
if (!Number.isFinite(cashDiscountAmount)) throw new Error("cashDiscountAmount muss eine gültige Zahl sein")
if (cashDiscountAmount !== 0) {
if (!hasValue(args.bankstatement)) throw new Error("Skonto ist nur zusammen mit einer Bankzahlung möglich")
payload.cashDiscountAmount = cashDiscountAmount
}
}
if (args.description !== undefined) payload.description = stringArg(args, "description")
payload.bookingMode = bookingMode
if (args.datevTaxKey !== undefined) payload.datevTaxKey = stringArg(args, "datevTaxKey")

View File

@@ -1,6 +1,6 @@
import { outgoingAddressSchema, outgoingRowsSchema, outgoingReportSchema } from "../documentSchemas"
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
import { and, desc, eq, ilike, or } from "drizzle-orm"
import { and, desc, eq, ilike, inArray, or } from "drizzle-orm"
import { randomUUID } from "node:crypto"
import { validateBankAllocationCostCentre } from "../../modules/bank-allocation-details"
import {
@@ -22,12 +22,22 @@ 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 { McpTool } from "../types"
import { McpContext, McpTool } from "../types"
import { applyOutgoingRowCatalogDefaults } from "../outgoingRowDefaults"
import { analyzeOutgoingDocumentLayout, DEFAULT_CONTENT_BOTTOM_MM } from "../outgoingDocumentLayout"
import { withImages } from "../result"
import { renderOutgoingDocumentPdf } from "../../modules/outgoing-document-pdf.service"
import { assertOutgoingEInvoiceRequirements } from "../../modules/einvoice/outgoingZugferd.service"
import { renderPdfPagesAsPng } from "../../utils/pdfPreview"
import { outgoingStatusTools } from "./outgoingStatus"
import { normalizeCashDiscountDays, normalizeCashDiscountPercent, prepareCashDiscountAllocation } from "../../modules/cash-discount"
export { prepareStatementAllocationInput } from "../statementAllocations"
@@ -238,6 +248,8 @@ const buildOutgoingDocumentPayload = (
for (const field of ["paymentDays"] as const) {
if (args[field] !== undefined) payload[field] = numberArg(args, field)
}
if (args.cashDiscountPercent !== undefined) payload.cashDiscountPercent = normalizeCashDiscountPercent(args.cashDiscountPercent)
if (args.cashDiscountDays !== undefined) payload.cashDiscountDays = normalizeCashDiscountDays(args.cashDiscountDays)
if (args.type !== undefined) payload.type = documentTypeArg(args)
if (args.address !== undefined) payload.address = optionalObjectArg(args, "address")
@@ -309,6 +321,98 @@ const loadIncomingInvoiceFileDefaults = async (context: { server: any; tenantId:
}
}
export type IncomingInvoiceFileUpload = {
filename: string
buffer: Buffer
mimeType: string
invoiceId?: number | null
invoiceArgs?: Record<string, unknown> | 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<string, any>) : null,
prepared: Boolean(shouldPrepare && linkedInvoice),
}
}
const buildIncomingInvoicePayload = (
args: Record<string, unknown>,
userId: string,
@@ -331,6 +435,8 @@ const buildIncomingInvoicePayload = (
for (const field of ["state", "reference", "date", "dueDate", "description", "paymentType"] as const) {
if (args[field] !== undefined) payload[field] = stringArg(args, field)
}
if (args.cashDiscountPercent !== undefined) payload.cashDiscountPercent = normalizeCashDiscountPercent(args.cashDiscountPercent)
if (args.cashDiscountDueDate !== undefined) payload.cashDiscountDueDate = stringArg(args, "cashDiscountDueDate")
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
@@ -534,6 +640,8 @@ export const accountingTools: McpTool[] = [
deliveryDateEnd: { type: "string" },
deliveryDateType: { type: "string" },
paymentDays: { type: "number" },
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent (0 bis 10). Wird im PDF als Skontohinweis ausgegeben." },
cashDiscountDays: { type: ["number", "null"], description: "Skontofrist in Tagen ab Belegdatum." },
payment_type: { type: "string" },
taxType: {
type: "string",
@@ -555,6 +663,7 @@ export const accountingTools: McpTool[] = [
},
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))
@@ -590,6 +699,8 @@ export const accountingTools: McpTool[] = [
deliveryDateEnd: { type: "string" },
deliveryDateType: { type: "string" },
paymentDays: { type: "number" },
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent (0 bis 10). Wird im PDF als Skontohinweis ausgegeben." },
cashDiscountDays: { type: ["number", "null"], description: "Skontofrist in Tagen ab Belegdatum." },
payment_type: { type: "string" },
taxType: {
type: "string",
@@ -623,6 +734,7 @@ export const accountingTools: McpTool[] = [
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)
@@ -702,6 +814,69 @@ export const accountingTools: McpTool[] = [
return { document }
},
},
{
name: "accounting.outgoing_documents.preview",
title: "Ausgangsbeleg-Vorschau",
description: "Erzeugt die PDF-Vorschau eines Ausgangsbelegs wie im Belegeditor und liefert die Seiten als Bilder sowie das Seitenlayout (welche Position auf welcher Seite in welcher Höhe in mm landet) mit Warnungen zu übervollen Seiten. FEDEO bricht Positionen nicht automatisch um: Seitenumbrüche sind Positionen mit mode=pagebreak. Mit rows lässt sich eine geänderte Positionsliste testen, ohne zu speichern; anschließend mit accounting.outgoing_documents.update übernehmen.",
requiredPermissions: ["accounting.outgoing_documents.read"],
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "number" },
rows: { ...outgoingRowsSchema, description: "Optional: Positionsliste nur für diese Vorschau, z. B. mit zusätzlichen {\"mode\":\"pagebreak\"}-Einträgen. Wird nicht gespeichert." },
pages: { type: "array", items: { type: "integer", minimum: 1 }, maxItems: 10, description: "Seiten (ab 1), die als Bild geliefert werden. Standard: die ersten 10 Seiten." },
dpi: { type: "integer", minimum: 50, maximum: 150, default: 80, description: "Auflösung der Seitenbilder." },
images: { type: "boolean", default: true, description: "false liefert nur Layout und Warnungen ohne Bilder." },
maxContentBottomMm: { type: "number", minimum: 150, maximum: 297, default: DEFAULT_CONTENT_BOTTOM_MM, description: "Unterkante des nutzbaren Bereichs ab Blattoberkante; darunter beginnt meist die Fußzeile des Briefpapiers." },
includePdf: { type: "boolean", default: false, description: "PDF zusätzlich als Base64 liefern." },
},
additionalProperties: false,
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const dpi = args.dpi === undefined ? 80 : Number(args.dpi)
if (!Number.isSafeInteger(dpi) || dpi < 50 || dpi > 150) throw new Error("dpi muss zwischen 50 und 150 liegen")
const maxContentBottomMm = args.maxContentBottomMm === undefined ? DEFAULT_CONTENT_BOTTOM_MM : Number(args.maxContentBottomMm)
if (!Number.isFinite(maxContentBottomMm) || maxContentBottomMm < 150 || maxContentBottomMm > 297) throw new Error("maxContentBottomMm muss zwischen 150 und 297 liegen")
if (args.pages !== undefined && (!Array.isArray(args.pages) || args.pages.length > 10
|| args.pages.some((page) => !Number.isSafeInteger(page) || (page as number) < 1))) {
throw new Error("pages muss bis zu 10 Seitenzahlen ab 1 enthalten")
}
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")
const document: Record<string, any> = { ...existing }
if (args.rows !== undefined) {
const payload: Record<string, unknown> = {
rows: normalizeOutgoingDocumentRows(await applyOutgoingRowCatalogDefaults(context, optionalArrayArg(args, "rows") || [])),
}
applyOutgoingDocumentTaxType(payload, {}, existing.type, existing.taxType, existing.rows)
document.rows = payload.rows
}
const { pdf, layout } = await renderOutgoingDocumentPdf(context.server, context.tenantId, document)
const analysis = analyzeOutgoingDocumentLayout(layout, maxContentBottomMm)
const requestedPages = (args.pages as number[] | undefined) || Array.from({ length: Math.min(layout.pageCount, 10) }, (_, index) => index + 1)
const pages = [...new Set(requestedPages)].filter((page) => page <= layout.pageCount)
const images = args.images === false ? [] : await renderPdfPagesAsPng(pdf, pages, dpi)
return withImages({
document: { id: existing.id, type: existing.type, state: existing.state, documentNumber: existing.documentNumber, title: existing.title },
previewOnly: args.rows !== undefined,
pageCount: layout.pageCount,
imagePages: images.map((image) => image.page),
...analysis,
...(args.includePdf === true ? { pdfBase64: pdf.toString("base64") } : {}),
}, images.map((image) => ({ data: image.png.toString("base64"), mimeType: "image/png" })))
},
},
{
name: "accounting.outgoing_documents.finalize",
title: "Ausgangsbeleg finalisieren",
@@ -716,6 +891,8 @@ export const accountingTools: McpTool[] = [
deliveryDate: { type: "string" },
deliveryDateEnd: { type: "string" },
paymentDays: { type: "number" },
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent (0 bis 10). Wird im PDF als Skontohinweis ausgegeben." },
cashDiscountDays: { type: ["number", "null"], description: "Skontofrist in Tagen ab Belegdatum." },
payment_type: { type: "string" },
taxType: {
type: "string",
@@ -741,12 +918,15 @@ export const accountingTools: McpTool[] = [
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)
applyOutgoingDocumentTaxType(payload, args, existing.type, existing.taxType, existing.rows)
// Pflichtangaben der E-Rechnung vor der Nummernvergabe durchsetzen
await assertOutgoingEInvoiceRequirements(context.server, context.tenantId, { ...existing, ...payload })
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)
@@ -1011,7 +1191,7 @@ export const accountingTools: McpTool[] = [
{
name: "accounting.incoming_invoices.files.upload",
title: "Datei für Eingangsbeleg hochladen",
description: "Lädt eine PDF- oder Bilddatei als Base64 hoch, verknüpft sie optional mit einem Eingangsbeleg oder stößt die automatische Vorbereitung an.",
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",
@@ -1034,6 +1214,8 @@ export const accountingTools: McpTool[] = [
reference: { type: "string" },
date: { type: "string" },
dueDate: { type: "string" },
cashDiscountPercent: { type: ["number", "null"] },
cashDiscountDueDate: { type: ["string", "null"] },
document: { type: "number" },
description: { type: "string" },
paymentType: { type: "string" },
@@ -1047,83 +1229,14 @@ export const accountingTools: McpTool[] = [
const filename = stringArg(args, "filename")
if (!filename) throw new Error("filename ist erforderlich")
const buffer = base64BufferArg(args, "contentBase64")
const mimeType = stringArg(args, "mimeType") || "application/octet-stream"
const invoiceId = numberArg(args, "invoiceId")
const invoiceArgs = optionalObjectArg(args, "invoice") as Record<string, unknown> | null
const shouldPrepare = args.prepare !== false && !invoiceId && !invoiceArgs
let linkedInvoice: any = null
if (invoiceId) {
const [existing] = await context.server.db
.select()
.from(incominginvoices)
.where(and(eq(incominginvoices.id, invoiceId), eq(incominginvoices.tenant, context.tenantId)))
.limit(1)
if (!existing) throw new Error("Eingangsbeleg nicht gefunden")
linkedInvoice = existing
} else if (invoiceArgs) {
const payload = buildIncomingInvoicePayload(invoiceArgs, context.userId, context.tenantId, true)
payload.state = "Entwurf"
const [createdInvoice] = await context.server.db
.insert(incominginvoices)
.values(payload as any)
.returning()
linkedInvoice = createdInvoice
}
const defaults = await loadIncomingInvoiceFileDefaults(context)
const saved = await saveFile(
context.server,
context.tenantId,
null,
{
return uploadIncomingInvoiceFile(context, {
filename,
content: buffer,
contentType: mimeType,
},
defaults.folderId,
defaults.fileTagId,
{
incominginvoice: linkedInvoice?.id || null,
createdBy: context.userId,
updatedBy: context.userId,
updatedAt: new Date(),
}
)
if (!saved) throw new Error("Datei konnte nicht gespeichert werden")
if (shouldPrepare) {
await context.server.services.prepareIncomingInvoices.run(context.tenantId)
}
const [file] = await context.server.db
.select()
.from(files)
.where(and(eq(files.id, saved.id), eq(files.tenant, context.tenantId)))
.limit(1)
if (file?.incominginvoice && !linkedInvoice) {
const [preparedInvoice] = await context.server.db
.select()
.from(incominginvoices)
.where(and(eq(incominginvoices.id, file.incominginvoice), eq(incominginvoices.tenant, context.tenantId)))
.limit(1)
linkedInvoice = preparedInvoice || null
}
return {
file,
invoice: linkedInvoice,
validation: linkedInvoice ? validateIncomingInvoiceData(linkedInvoice as Record<string, any>) : null,
prepared: Boolean(shouldPrepare && linkedInvoice),
}
buffer: base64BufferArg(args, "contentBase64"),
mimeType: stringArg(args, "mimeType") || "application/octet-stream",
invoiceId: numberArg(args, "invoiceId"),
invoiceArgs: optionalObjectArg(args, "invoice") as Record<string, unknown> | null,
prepare: args.prepare !== false,
})
},
},
{
@@ -1152,6 +1265,62 @@ export const accountingTools: McpTool[] = [
return validateIncomingInvoiceData(rows[0] as Record<string, any>)
},
},
{
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<string, any>) : 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",
@@ -1164,6 +1333,8 @@ export const accountingTools: McpTool[] = [
reference: { type: "string" },
date: { type: "string" },
dueDate: { type: "string" },
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent laut Rechnung." },
cashDiscountDueDate: { type: ["string", "null"], description: "Letzter Tag der Skontofrist (YYYY-MM-DD)." },
document: { type: "number" },
description: { type: "string" },
paymentType: { type: "string" },
@@ -1197,6 +1368,8 @@ export const accountingTools: McpTool[] = [
reference: { type: "string" },
date: { type: "string" },
dueDate: { type: "string" },
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent laut Rechnung." },
cashDiscountDueDate: { type: ["string", "null"], description: "Letzter Tag der Skontofrist (YYYY-MM-DD)." },
document: { type: "number" },
description: { type: "string" },
paymentType: { type: "string" },
@@ -1229,6 +1402,44 @@ export const accountingTools: McpTool[] = [
return { invoice: updated, validation: validateIncomingInvoiceData(updated as Record<string, any>) }
},
},
{
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",
@@ -1310,6 +1521,26 @@ export const accountingTools: McpTool[] = [
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",
@@ -1402,7 +1633,7 @@ export const accountingTools: McpTool[] = [
{
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.",
description: "Erstellt eine Bankzuweisung oder manuelle Soll/Haben-Buchung. Unterstützt Ausgangs- und Eingangsbelege, Sachkonten, zusätzliche Konten, Debitoren, Kreditoren, Teilbeträge, Skonto, DATEV-Steuerschlüssel und Abschreibungen.",
requiredPermissions: ["accounting.statement_allocations.write"],
inputSchema: {
type: "object",
@@ -1410,6 +1641,7 @@ export const accountingTools: McpTool[] = [
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." },
cashDiscountAmount: { type: "number", description: "Skonto (brutto) zu einem Ausgangs- oder Eingangsbeleg, zusätzlich zum gezahlten amount. Nur mit bankstatement; das Vorzeichen wird an amount angeglichen. Wird anteilig nach Steuersätzen auf die Skontokonten verteilt." },
createddocument: { type: "number", description: "Ausgangsbeleg-ID." },
incominginvoice: { type: "number", description: "Eingangsbeleg-ID." },
account: { type: "number", description: "Sachkonto-ID beziehungsweise Soll-Sachkonto." },
@@ -1435,8 +1667,9 @@ export const accountingTools: McpTool[] = [
},
},
async handler(context, args) {
const payload = prepareStatementAllocationInput(args)
await assertStatementAllocationReferences(context, payload)
const prepared = prepareStatementAllocationInput(args)
await assertStatementAllocationReferences(context, prepared)
const payload = await prepareCashDiscountAllocation(context.server.db, context.tenantId, prepared)
const [created] = await context.server.db
.insert(statementallocations)

View File

@@ -0,0 +1,150 @@
import { and, desc, eq, getTableColumns, ilike, or } from "drizzle-orm"
import * as tables from "../../../db/schema"
import { assertToolPermission } from "../authz"
import { assertInternalDocumentAccess } from "../documentFiles"
import { idField, InputField, numericField, objectField, textField, uuidField } from "../masterdataFields"
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
import { McpContext, McpTool } from "../types"
import { insertHistoryItem } from "../../utils/history"
import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service"
type ArchiveEntity = {
label: string
table: any
/** Key used by the history log and the generic resource routes. */
resource: string
permission: string
searchColumns: string[]
uuid?: boolean
internalDocuments?: boolean
}
const masterdata = (label: string, table: any, resource: string, namespace: string, searchColumns: string[], uuid = false): ArchiveEntity =>
({ label, table, resource, permission: `masterdata.${namespace}`, searchColumns, uuid })
/** Every entity the MCP can archive; restore checks the matching write permission. */
export const archiveEntities: Record<string, ArchiveEntity> = {
customers: masterdata("Kunde", tables.customers, "customers", "customers", ["name", "customerNumber"]),
vendors: masterdata("Lieferant", tables.vendors, "vendors", "vendors", ["name", "vendorNumber"]),
contacts: masterdata("Kontakt", tables.contacts, "contacts", "contacts", ["fullName", "email"]),
products: masterdata("Artikel", tables.products, "products", "products", ["name", "article_number"]),
services: masterdata("Leistung", tables.services, "services", "services", ["name"]),
cost_centres: masterdata("Kostenstelle", tables.costcentres, "costcentres", "cost_centres", ["name", "number"], true),
branches: masterdata("Niederlassung", tables.branches, "branches", "branches", ["name", "number"]),
teams: masterdata("Team", tables.teams, "teams", "teams", ["name"]),
vehicles: masterdata("Fahrzeug", tables.vehicles, "vehicles", "vehicles", ["name", "license_plate"]),
inventory_items: masterdata("Inventar", tables.inventoryitems, "inventoryitems", "inventory_items", ["name", "articleNumber"]),
projects: { label: "Projekt", table: tables.projects, resource: "projects", permission: "organisation.projects", searchColumns: ["name", "projectNumber"] },
events: { label: "Termin", table: tables.events, resource: "events", permission: "organisation.events", searchColumns: ["name"] },
tasks: { label: "Aufgabe", table: tables.tasks, resource: "tasks", permission: "organisation.tasks", searchColumns: ["name"] },
outgoing_documents: { label: "Ausgangsbeleg", table: tables.createddocuments, resource: "createddocuments", permission: "accounting.outgoing_documents", searchColumns: ["documentNumber", "title"] },
incoming_invoices: { label: "Eingangsbeleg", table: tables.incominginvoices, resource: "incominginvoices", permission: "accounting.incoming_invoices", searchColumns: ["reference", "description"] },
files: { label: "Datei", table: tables.files, resource: "files", permission: "documents", searchColumns: ["name"], uuid: true, internalDocuments: true },
}
const entityField: InputField = {
schema: { type: "string", enum: Object.keys(archiveEntities), description: "Art des archivierten Datensatzes." },
parse(value, path) {
if (typeof value !== "string" || !Object.prototype.hasOwnProperty.call(archiveEntities, value)) throw new Error(`${path}: unbekannte Entität`)
return value
},
}
const entityIdField: InputField = {
schema: { type: ["integer", "string"], description: "ID des Datensatzes; UUID bei cost_centres und files, sonst ganze Zahl." },
parse: (value) => value,
}
const searchField: InputField = {
schema: { type: "string", maxLength: 200, description: "Optionaler Suchtext, z. B. Name oder Nummer." },
parse(value, path) {
const text = textField().parse(value, path)
if (text.length > 200) throw new Error(`${path}: maximal 200 Zeichen`)
return text
},
}
const listInput = objectField({
entity: entityField,
search: searchField,
limit: { ...numericField(true, 1, 100), schema: paginationProperties.limit },
offset: { ...numericField(true, 0), schema: paginationProperties.offset },
})
const restoreInput = objectField({ entity: entityField, id: entityIdField })
const columnKey = (table: any, ...names: string[]) => names.find((name) => name in getTableColumns(table))
/** asToolResult hides records flagged archived; archived rows are marked explicitly instead. */
const withoutArchivedFlag = ({ archived: _archived, path: _path, extractedText: _text, ...row }: Record<string, any>) => row
async function authorize(context: McpContext, tool: McpTool, entity: ArchiveEntity, access: "read" | "write") {
assertToolPermission(context, { name: tool.name, requiredPermissions: [`${entity.permission}.${access}`] })
if (entity.internalDocuments) await assertInternalDocumentAccess(context)
}
export function prepareArchiveRestoreInput(args: Record<string, unknown>) {
const input = restoreInput.parse(args, "arguments")
if (!input.entity) throw new Error("entity ist erforderlich")
if (input.id === undefined || input.id === null) throw new Error("id ist erforderlich")
const entity = archiveEntities[input.entity]
return { key: input.entity as string, entity, id: (entity.uuid ? uuidField : idField).parse(input.id, "id") }
}
const listTool: McpTool = {
name: "archive.entities.list",
title: "Archivierte Einträge auflisten",
description: "Listet archivierte Datensätze einer Entitätsart im aktiven Mandanten, zuletzt geänderte zuerst. IDs anschließend mit archive.entities.restore wiederherstellen.",
// Checked per entity in the handler.
requiredPermissions: [],
inputSchema: { ...listInput.schema, required: ["entity"] },
async handler(context, args) {
const input = listInput.parse(args, "arguments")
if (!input.entity) throw new Error("entity ist erforderlich")
const entity = archiveEntities[input.entity]
await authorize(context, listTool, entity, "read")
const table = entity.table
const conditions: any[] = [eq(table.tenant, context.tenantId), eq(table.archived, true)]
if (input.search) {
const pattern = `%${input.search.replace(/[\\%_]/g, "\\$&")}%`
conditions.push(or(...entity.searchColumns.map((column) => ilike(table[column], pattern))))
}
const updatedAt = columnKey(table, "updatedAt", "updated_at")!
const rows = await context.server.db.select().from(table).where(and(...conditions))
.orderBy(desc(table[updatedAt]), desc(table.id)).limit(limitFromArgs(input) + 1).offset(offsetFromArgs(input))
const result = paginatedResult(rows, input)
return { ...result, entity: input.entity, rows: result.rows.map((row: any) => ({ ...withoutArchivedFlag(row), isArchived: true })) }
},
}
const restoreTool: McpTool = {
name: "archive.entities.restore",
title: "Archivierten Eintrag wiederherstellen",
description: `Stellt einen archivierten Datensatz im aktiven Mandanten wieder her (entity: ${Object.keys(archiveEntities).join(", ")}). Benötigt die Schreibberechtigung der jeweiligen Entität.`,
requiredPermissions: [],
readOnly: false,
inputSchema: { ...restoreInput.schema, required: ["entity", "id"] },
async handler(context, args) {
const { key, entity, id } = prepareArchiveRestoreInput(args)
await authorize(context, restoreTool, entity, "write")
const table = entity.table
return context.server.db.transaction(async (tx) => {
const server = { ...context.server, db: tx } as unknown as McpContext["server"]
const scope = and(eq(table.id, id), eq(table.tenant, context.tenantId))
const [existing] = await tx.select().from(table).where(scope).limit(1).for("update")
if (!existing) throw new Error(`${entity.label} nicht gefunden`)
if (!existing.archived) throw new Error(`${entity.label} ist nicht archiviert`)
const [result] = await tx.update(table).set({
archived: false,
[columnKey(table, "updatedAt", "updated_at")!]: new Date(),
[columnKey(table, "updatedBy", "updated_by")!]: context.userId,
}).where(scope).returning()
if (["products", "services"].includes(entity.resource)) {
await recalculateServicePricesForTenant(server, context.tenantId, context.userId)
}
await insertHistoryItem(server, {
tenant_id: context.tenantId, created_by: context.userId, entity: entity.resource, entityId: result.id,
action: "restored", oldVal: existing, newVal: result,
})
return { entity: key, id: result.id, success: true, action: "restored", record: withoutArchivedFlag(result) }
})
},
}
export const archiveTools: McpTool[] = [listTool, restoreTool]

View File

@@ -135,7 +135,7 @@ export function createDocumentTools(dependencies: Dependencies = {}): McpTool[]
const body = await readFile(file.path, MCP_FILE_LIMIT)
return { ...metadata, size: body.length, contentBase64: body.toString("base64") }
}),
tool("files.upload", "PDF oder Scan hochladen", "Lädt eine vollständige PDF-, JPEG-, PNG- oder TIFF-Datei als Base64 (maximal 20 MiB, ohne Data-URL-Präfix) in die Dokumentenablage. Danach documents.inbox.file verwenden. Gleicher Dateiname und Inhalt werden nicht erneut angelegt; anderer Inhalt unter demselben Namen wird abgewiesen.", true,
tool("files.upload", "PDF oder Scan hochladen", "Lädt eine vollständige PDF-, JPEG-, PNG- oder TIFF-Datei als Base64 (maximal 20 MiB, ohne Data-URL-Präfix) in die Dokumentenablage. Mit Dateizugriff besser ohne Base64 über die Adresse aus documents.files.upload_url hochladen. Danach documents.inbox.file verwenden. Gleicher Dateiname und Inhalt werden nicht erneut angelegt; anderer Inhalt unter demselben Namen wird abgewiesen.", true,
{ filename: filenameField, contentBase64: { schema: { type: "string", minLength: 1, maxLength: Math.ceil(MCP_FILE_LIMIT / 3) * 4 }, parse: value => value } },
["filename", "contentBase64"], async (context, args) => {
const { body, mimeType } = decodeDocumentUpload(args.filename as string, args.contentBase64)

View File

@@ -46,14 +46,14 @@ export const masterdataWriteConfigs: Config[] = [
makeConfig({
resource: "customers", namespace: "customers", label: "Kunde", resultKey: "customer", table: tables.customers,
required: ["name"], numberField: "customerNumber",
fieldNames: "name customerNumber infoData active notes type isCompany profiles customPaymentDays firstname lastname customSurchargePercentage salutation title nameAddition availableInPortal customPaymentType customTaxType memberrelation",
fieldNames: "name customerNumber infoData active notes type isCompany profiles customPaymentDays firstname lastname customSurchargePercentage salutation title nameAddition availableInPortal customPaymentType customTaxType memberrelation customCashDiscountPercent customCashDiscountDays",
overrides: { customerNumber: textField(true), infoData: infoDataField, profiles: profilesField, memberrelation: idField },
references: [commonProfiles, bankAccounts, { field: "memberrelation", table: tables.memberrelations }],
}),
makeConfig({
resource: "vendors", namespace: "vendors", label: "Lieferant", resultKey: "vendor", table: tables.vendors,
required: ["name"], numberField: "vendorNumber",
fieldNames: "name vendorNumber infoData notes hasSEPA profiles defaultPaymentMethod",
fieldNames: "name vendorNumber infoData notes hasSEPA profiles defaultPaymentMethod cashDiscountPercent cashDiscountDays",
overrides: { vendorNumber: textField(true), infoData: infoDataField, profiles: profilesField },
references: [commonProfiles, bankAccounts],
}),

View File

@@ -41,10 +41,10 @@ function documentAmount(document: any, advanceInvoices: any[]) {
return money(money(net) + money(tax19) + money(tax7) - advanceAmount)
}
export function outgoingDocumentStatus(document: any, paidAmount: number, cancellations: any[], advanceInvoices: any[]) {
export function outgoingDocumentStatus(document: any, paidAmount: number, cancellations: any[], advanceInvoices: any[], cashDiscountAmount = 0) {
const amount = documentAmount(document, advanceInvoices)
if (![amount, paidAmount].every(Number.isFinite)) throw new Error(`Beleg ${document.id}: Betrag kann nicht berechnet werden`)
const openAmount = money(amount - paidAmount)
if (![amount, paidAmount, cashDiscountAmount].every(Number.isFinite)) throw new Error(`Beleg ${document.id}: Betrag kann nicht berechnet werden`)
const openAmount = money(amount - paidAmount - cashDiscountAmount)
const cancelled = cancellations.length > 0
const applicable = document.state === "Gebucht" && ["invoices", "advanceInvoices"].includes(document.type) && !cancelled
// Signed invoices and refunds use the same payment-direction comparison.
@@ -55,7 +55,7 @@ export function outgoingDocumentStatus(document: any, paidAmount: number, cancel
id: document.id, type: document.type, documentNumber: document.documentNumber, documentDate: document.documentDate,
state: document.state, customer: document.customer, project: document.project,
cancelled, cancellationDocuments: cancellations.map(({ id, documentNumber }) => ({ id, documentNumber })),
paymentStatus, amount, paidAmount: money(paidAmount), openAmount,
paymentStatus, amount, paidAmount: money(paidAmount), cashDiscountAmount: money(cashDiscountAmount), openAmount,
}
}
@@ -65,7 +65,7 @@ async function statusesFor(context: McpContext, documents: Array<typeof createdd
const advanceIds = [...new Set(documents.flatMap(document => Array.isArray(document.usedAdvanceInvoices)
? document.usedAdvanceInvoices.filter(id => typeof id === "number" && Number.isSafeInteger(id) && id > 0) as number[] : []))]
const [allocations, cancellations, advances] = await Promise.all([
context.server.db.select({ createddocument: statementallocations.createddocument, amount: statementallocations.amount }).from(statementallocations)
context.server.db.select({ createddocument: statementallocations.createddocument, amount: statementallocations.amount, cashDiscountAmount: statementallocations.cashDiscountAmount }).from(statementallocations)
.where(and(eq(statementallocations.tenant, context.tenantId), eq(statementallocations.archived, false), inArray(statementallocations.createddocument, ids))),
context.server.db.select({ id: createddocuments.id, createddocument: createddocuments.createddocument, documentNumber: createddocuments.documentNumber }).from(createddocuments)
.where(and(eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false), eq(createddocuments.type, "cancellationInvoices"),
@@ -73,15 +73,19 @@ async function statusesFor(context: McpContext, documents: Array<typeof createdd
advanceIds.length ? context.server.db.select().from(createddocuments)
.where(and(eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false), inArray(createddocuments.id, advanceIds))) : [],
])
return documents.map(document => outgoingDocumentStatus(document,
allocations.filter(allocation => allocation.createddocument === document.id).reduce((sum, allocation) => sum + Number(allocation.amount), 0),
cancellations.filter(cancellation => cancellation.createddocument === document.id), advances))
return documents.map(document => {
const documentAllocations = allocations.filter(allocation => allocation.createddocument === document.id)
return outgoingDocumentStatus(document,
documentAllocations.reduce((sum, allocation) => sum + Number(allocation.amount), 0),
cancellations.filter(cancellation => cancellation.createddocument === document.id), advances,
documentAllocations.reduce((sum, allocation) => sum + Number(allocation.cashDiscountAmount || 0), 0))
})
}
export const outgoingStatusTools: McpTool[] = [
{
name: "accounting.outgoing_documents.status.get", title: "Ausgangsbelegstatus laden",
description: "Lädt Buchungs-, Zahlungs- und Stornostatus mit Gesamtbetrag, zugeordneten Zahlungen und offenem Betrag. Zahlungsstatus gilt für gebuchte, nicht stornierte Rechnungen und Abschlagsrechnungen; sonst null. Ändert keine Belege oder Buchungen.",
description: "Lädt Buchungs-, Zahlungs- und Stornostatus mit Gesamtbetrag, zugeordneten Zahlungen, gebuchtem Skonto und offenem Betrag. Zahlungsstatus gilt für gebuchte, nicht stornierte Rechnungen und Abschlagsrechnungen; sonst null. Ändert keine Belege oder Buchungen.",
requiredPermissions: ["accounting.outgoing_documents.read"],
inputSchema: { ...objectField({ id: idField }).schema, required: ["id"] },
async handler(context, args) {

View File

@@ -13,6 +13,10 @@ export type McpToolResult = {
content: Array<{
type: "text"
text: string
} | {
type: "image"
data: string
mimeType: string
}>
structuredContent?: Record<string, unknown>
isError?: boolean
@@ -23,6 +27,8 @@ export type McpTool = {
title: string
description: string
requiredPermissions: string[]
/** Overrides the readOnlyHint derived from requiredPermissions, e.g. for per-entity checks. */
readOnly?: boolean
inputSchema: Record<string, unknown>
handler: (context: McpContext, args: Record<string, unknown>) => Promise<unknown>
}

View File

@@ -0,0 +1,71 @@
import { FastifyInstance } from "fastify"
import { and, eq, sql } from "drizzle-orm"
import { bankaccounts, bankstatements } from "../../db/schema"
export const CASHBOOK_BANK_ID = "fedeo-cashbook"
type BalanceAccount = Pick<typeof bankaccounts.$inferSelect, "id" | "name" | "iban" | "ownerName" | "bankId" | "datevNumber" | "balance" | "expired" | "syncedAt">
const roundMoney = (value: number) => Math.round(value * 100) / 100
/**
* Bank accounts store the last synced booked balance; cashbooks store their opening balance
* and derive the current balance from their bookings. For a past date, bank balances are
* reconstructed by reverting the statements booked after that date.
*/
export function calculateAccountBalance(account: BalanceAccount, statementSum: number, asOf: string | null) {
const cashbook = account.bankId === CASHBOOK_BANK_ID
const stored = Number(account.balance || 0)
const balance = cashbook ? stored + statementSum : stored - statementSum
return {
id: account.id,
type: cashbook ? "cashbook" : "bank",
name: account.name || account.ownerName || (cashbook ? "Kassenbuch" : "Bankkonto"),
iban: cashbook ? null : account.iban,
datevNumber: account.datevNumber,
expired: account.expired,
balance: roundMoney(balance),
currency: "EUR",
...(cashbook ? { openingBalance: roundMoney(stored) } : { syncedBalance: roundMoney(stored), syncedAt: account.syncedAt }),
source: cashbook ? "cashbook_bookings" : asOf ? "bank_sync_reconstructed" : "bank_sync",
}
}
export async function loadBankBalances(server: FastifyInstance, tenantId: number, options: { account?: number | null; asOf?: string | null } = {}) {
const asOf = options.asOf || null
const conditions = [eq(bankaccounts.tenant, tenantId), eq(bankaccounts.archived, false)]
if (options.account) conditions.push(eq(bankaccounts.id, options.account))
const accounts = await server.db.select().from(bankaccounts).where(and(...conditions)).orderBy(bankaccounts.name, bankaccounts.id)
const sums = async (dateCondition: any) => {
const rows = await server.db
.select({ account: bankstatements.account, total: sql<string>`coalesce(sum(${bankstatements.amount}), 0)` })
.from(bankstatements)
.where(and(eq(bankstatements.tenant, tenantId), eq(bankstatements.archived, false), ...(dateCondition ? [dateCondition] : [])))
.groupBy(bankstatements.account)
return new Map(rows.map((row) => [row.account, Number(row.total)]))
}
// Synced statements may carry a full timestamp; compare the calendar day only.
const bookingDay = sql`left(${bankstatements.date}, 10)`
// Cashbooks need bookings up to the date, bank accounts only those after it.
const cashbookSums = await sums(asOf ? sql`${bookingDay} <= ${asOf}` : null)
const bankSums = asOf ? await sums(sql`${bookingDay} > ${asOf}`) : new Map<number, number>()
const balances = accounts.map((account) => calculateAccountBalance(
account,
(account.bankId === CASHBOOK_BANK_ID ? cashbookSums : bankSums).get(account.id) || 0,
asOf,
))
const active = balances.filter((account) => !account.expired)
return {
asOf,
accounts: balances,
totals: {
balance: roundMoney(active.reduce((sum, account) => sum + account.balance, 0)),
bank: roundMoney(active.filter((account) => account.type === "bank").reduce((sum, account) => sum + account.balance, 0)),
cashbook: roundMoney(active.filter((account) => account.type === "cashbook").reduce((sum, account) => sum + account.balance, 0)),
currency: "EUR",
note: "Summen ohne abgelaufene Bankverbindungen.",
},
}
}

View File

@@ -0,0 +1,378 @@
import { and, eq, inArray } from "drizzle-orm"
import { createddocuments, incominginvoices, statementallocations, tenants } from "../../db/schema"
// Skonto wird an der Zahlungszuordnung gespeichert. amount bleibt der tatsächlich gezahlte Betrag,
// cashDiscountAmount trägt dasselbe Vorzeichen und schließt zusammen mit amount den Beleg aus.
export type CashDiscountRate = "19" | "7" | "0"
export type CashDiscountSide = "received" | "granted"
export type CashDiscountAccountConfig = Record<CashDiscountSide, Record<CashDiscountRate, string>>
export type CashDiscountSplitItem = { rate: number, account: string, gross: number, net: number, tax: number }
export const CASH_DISCOUNT_RATES: CashDiscountRate[] = ["19", "7", "0"]
// SKR03: Erhaltene Skonti (Eingangsbelege) und gewährte Skonti (Ausgangsbelege), jeweils Automatikkonten.
export const DEFAULT_CASH_DISCOUNT_ACCOUNTS: CashDiscountAccountConfig = {
received: { "19": "3736", "7": "3731", "0": "3730" },
granted: { "19": "8736", "7": "8731", "0": "8730" },
}
// Weicht der Skonto um mehr als diesen Anteil vom Beleg ab, liegt sehr wahrscheinlich ein Eingabefehler vor.
export const MAX_CASH_DISCOUNT_SHARE = 0.1
const invalidInput = (message: string) => Object.assign(new Error(message), { statusCode: 400 })
const money = (value: number) => Number(value.toFixed(2))
const sameSign = (value: number, sign: number) => sign === 0 || Math.sign(value) === 0 || Math.sign(value) === sign
export function resolveCashDiscountAccounts(config: any): CashDiscountAccountConfig {
const result = {
received: { ...DEFAULT_CASH_DISCOUNT_ACCOUNTS.received },
granted: { ...DEFAULT_CASH_DISCOUNT_ACCOUNTS.granted },
}
for (const side of ["received", "granted"] as const) {
for (const rate of CASH_DISCOUNT_RATES) {
const value = config?.[side]?.[rate]
if (typeof value === "string" && value.trim()) result[side][rate] = value.trim()
else if (typeof value === "number" && Number.isFinite(value)) result[side][rate] = String(value)
}
}
return result
}
// Validiert die Einstellung aus den Firmeneinstellungen. Leere Felder fallen auf SKR03 zurück.
export function normalizeCashDiscountAccountsInput(input: any): Partial<CashDiscountAccountConfig> {
if (!input || typeof input !== "object" || Array.isArray(input)) {
throw invalidInput("Die Skontokonten müssen als Objekt übergeben werden.")
}
const result: Partial<CashDiscountAccountConfig> = {}
for (const side of ["received", "granted"] as const) {
const entries: Partial<Record<CashDiscountRate, string>> = {}
for (const rate of CASH_DISCOUNT_RATES) {
const raw = input?.[side]?.[rate]
if (raw === undefined || raw === null || String(raw).trim() === "") continue
const value = String(raw).trim()
if (!/^\d{4,9}$/.test(value)) {
throw invalidInput(`Das Skontokonto ${value} muss aus 4 bis 9 Ziffern bestehen.`)
}
entries[rate] = value
}
if (Object.keys(entries).length) result[side] = entries as Record<CashDiscountRate, string>
}
return result
}
export function settledAllocationAmount(allocation: { amount?: any, cashDiscountAmount?: any }) {
return Number(allocation?.amount || 0) + Number(allocation?.cashDiscountAmount || 0)
}
type GrossByRate = Record<CashDiscountRate, number>
const emptyGross = (): GrossByRate => ({ "19": 0, "7": 0, "0": 0 })
// Bruttobeträge eines Eingangsbelegs je Steuersatz. Vorzeichen: Ausgaben negativ (wie die Bankzuordnung).
export function getIncomingInvoiceGrossByRate(invoice: any): GrossByRate {
const gross = emptyGross()
const accounts = Array.isArray(invoice?.accounts) ? invoice.accounts : []
for (const account of accounts) {
const taxType = account?.taxType === null || account?.taxType === undefined ? "null" : String(account.taxType)
const amount = Number(account?.amountNet || 0) + Number(account?.amountTax || 0)
if (!Number.isFinite(amount)) throw invalidInput("Der Eingangsbeleg enthält ungültige Beträge.")
if (amount === 0) continue
if (taxType === "19" || taxType === "7") gross[taxType] += amount
else if (taxType === "null" || taxType === "" || taxType === "0") gross["0"] += amount
else {
throw invalidInput("Skonto auf Belege mit innergemeinschaftlichem Erwerb oder §13b UStG bitte manuell buchen.")
}
}
const override = invoice?.taxAmountOverride
if (override !== null && override !== undefined && override !== "") {
const calculatedTax = accounts.reduce((sum: number, account: any) => sum + Number(account?.amountTax || 0), 0)
const correction = Number(override) - calculatedTax
if (Number.isFinite(correction) && Math.abs(correction) >= 0.005) {
if (gross["19"] !== 0) gross["19"] += correction
else if (gross["7"] !== 0) gross["7"] += correction
}
}
const sign = invoice?.expense === false ? 1 : -1
return {
"19": money(gross["19"] * sign),
"7": money(gross["7"] * sign),
"0": money(gross["0"] * sign),
}
}
const rowsGrossByRate = (document: any, taxFree: boolean): GrossByRate => {
const net = emptyGross()
for (const row of Array.isArray(document?.rows) ? document.rows : []) {
if (!row || ["pagebreak", "title", "text"].includes(row.mode)) continue
const rowNet = Number((Number(row.quantity || 0) * Number(row.price || 0) * (1 - Number(row.discountPercent || 0) / 100)).toFixed(3))
if (!Number.isFinite(rowNet)) throw invalidInput(`Beleg ${document.id}: ungültiger Positionsbetrag`)
const taxPercent = Number(row.taxPercent)
if (!taxFree && taxPercent === 19) net["19"] += rowNet
else if (!taxFree && taxPercent === 7) net["7"] += rowNet
else net["0"] += rowNet
}
return {
"19": money(money(net["19"]) + money(net["19"] * 0.19)),
"7": money(money(net["7"]) + money(net["7"] * 0.07)),
"0": money(net["0"]),
}
}
const createdDocumentAmounts = (document: any, advanceInvoices: any[]) => {
if (document?.taxType === "13b UStG") {
throw invalidInput("Skonto auf Ausgangsbelege nach §13b UStG bitte manuell buchen.")
}
const taxFree = ["19 UStG", "12.3 UStG"].includes(String(document?.taxType || ""))
const gross = rowsGrossByRate(document, taxFree)
const advances = emptyGross()
for (const id of Array.isArray(document?.usedAdvanceInvoices) ? document.usedAdvanceInvoices : []) {
const row = advanceInvoices.find((invoice) => invoice.id === id)?.rows?.find((row: any) => row?.advanceInvoiceData)
if (!row) continue
const rate = String(Number(row.taxPercent)) as CashDiscountRate
const amount = Number(row.price) * ((100 + Number(row.taxPercent || 0)) / 100)
advances[!taxFree && (rate === "19" || rate === "7") ? rate : "0"] += amount
}
return { gross, advances }
}
// Bruttobeträge eines Ausgangsbelegs je Steuersatz. Bei Schlussrechnungen werden die Abschläge mit ihren
// ursprünglich berechneten Beträgen abgezogen, damit sich der Skonto auf die Restforderung verteilt.
export function getCreatedDocumentGrossByRate(document: any, advanceInvoices: any[] = []): GrossByRate {
const { gross, advances } = createdDocumentAmounts(document, advanceInvoices)
const total = gross["19"] + gross["7"] + gross["0"]
const sign = Math.sign(total) || 1
const remaining = emptyGross()
for (const rate of CASH_DISCOUNT_RATES) {
const value = money(gross[rate] - advances[rate])
remaining[rate] = sameSign(value, sign) ? value : 0
}
return remaining["19"] || remaining["7"] || remaining["0"] ? remaining : gross
}
// Offene Gesamtforderung des Ausgangsbelegs: Bruttobetrag abzüglich der berechneten Abschläge.
export function getCreatedDocumentGrossTotal(document: any, advanceInvoices: any[] = []) {
const { gross, advances } = createdDocumentAmounts(document, advanceInvoices)
return money(gross["19"] + gross["7"] + gross["0"] - advances["19"] - advances["7"] - advances["0"])
}
// Verteilt den Skonto anteilig auf die Steuersätze; Rundungsdifferenzen gehen an den größten Anteil.
export function splitCashDiscount(amount: number, grossByRate: GrossByRate, accounts: Record<CashDiscountRate, string>): CashDiscountSplitItem[] {
const discount = money(amount)
if (!discount) return []
const weights = CASH_DISCOUNT_RATES
.map((rate) => ({ rate, weight: Math.abs(Number(grossByRate[rate] || 0)) }))
.filter((item) => item.weight > 0)
if (!weights.length) weights.push({ rate: "0", weight: 1 })
const totalWeight = weights.reduce((sum, item) => sum + item.weight, 0)
const items = weights.map((item) => ({ rate: item.rate, gross: money(discount * item.weight / totalWeight) }))
const difference = money(discount - items.reduce((sum, item) => sum + item.gross, 0))
if (difference) {
const largest = items.reduce((max, item) => Math.abs(item.gross) > Math.abs(max.gross) ? item : max, items[0])
largest.gross = money(largest.gross + difference)
}
return items.filter((item) => item.gross !== 0).map((item) => {
const rate = Number(item.rate)
const net = money(item.gross / (1 + rate / 100))
return { rate, account: accounts[item.rate], gross: item.gross, net, tax: money(item.gross - net) }
})
}
// Bereitet Skonto für eine neue Bankzuordnung vor: Vorzeichen, Plausibilität und Steueraufteilung.
export async function prepareCashDiscountAllocation(db: any, tenantId: number, payload: Record<string, any>) {
const requested = Number(payload.cashDiscountAmount || 0)
if (!Number.isFinite(requested)) throw invalidInput("Der Skontobetrag muss eine gültige Zahl sein.")
if (Math.abs(requested) < 0.005) {
return { ...payload, cashDiscountAmount: null, cashDiscountSplit: null }
}
const amount = Number(payload.amount || 0)
if (!payload.bankstatement) throw invalidInput("Skonto kann nur zusammen mit einer Bankzahlung gebucht werden.")
const hasDocument = Boolean(payload.createddocument)
const hasInvoice = Boolean(payload.incominginvoice)
if (hasDocument === hasInvoice) {
throw invalidInput("Skonto ist nur bei der Zuordnung genau eines Ausgangs- oder Eingangsbelegs möglich.")
}
if (!amount) throw invalidInput("Für Skonto muss ein Zahlbetrag zugeordnet werden.")
const discount = money(Math.sign(amount) * Math.abs(requested))
const [tenant] = await db.select({ cashDiscountAccounts: tenants.cashDiscountAccounts })
.from(tenants).where(eq(tenants.id, tenantId)).limit(1)
const accounts = resolveCashDiscountAccounts(tenant?.cashDiscountAccounts)
let grossByRate: GrossByRate
let total: number
let existing: any[]
if (hasDocument) {
const documentId = Number(payload.createddocument)
const [document] = await db.select().from(createddocuments).where(and(
eq(createddocuments.id, documentId),
eq(createddocuments.tenant, tenantId),
)).limit(1)
if (!document || document.archived) throw invalidInput("Der Ausgangsbeleg wurde nicht gefunden.")
if (document.state !== "Gebucht") throw invalidInput("Skonto ist nur auf gebuchte Ausgangsbelege möglich.")
const advanceIds = (Array.isArray(document.usedAdvanceInvoices) ? document.usedAdvanceInvoices : [])
.filter((id: any) => Number.isSafeInteger(id) && id > 0)
const advanceInvoices = advanceIds.length
? await db.select().from(createddocuments).where(and(
eq(createddocuments.tenant, tenantId),
inArray(createddocuments.id, advanceIds),
))
: []
grossByRate = getCreatedDocumentGrossByRate(document, advanceInvoices)
total = getCreatedDocumentGrossTotal(document, advanceInvoices)
existing = await db.select({ amount: statementallocations.amount, cashDiscountAmount: statementallocations.cashDiscountAmount })
.from(statementallocations).where(and(
eq(statementallocations.createddocument, documentId),
eq(statementallocations.tenant, tenantId),
eq(statementallocations.archived, false),
))
} else {
const invoiceId = Number(payload.incominginvoice)
const [invoice] = await db.select().from(incominginvoices).where(and(
eq(incominginvoices.id, invoiceId),
eq(incominginvoices.tenant, tenantId),
)).limit(1)
if (!invoice || invoice.archived) throw invalidInput("Der Eingangsbeleg wurde nicht gefunden.")
if (invoice.state !== "Gebucht") throw invalidInput("Skonto ist nur auf gebuchte Eingangsbelege möglich.")
grossByRate = getIncomingInvoiceGrossByRate(invoice)
total = money(grossByRate["19"] + grossByRate["7"] + grossByRate["0"])
existing = await db.select({ amount: statementallocations.amount, cashDiscountAmount: statementallocations.cashDiscountAmount })
.from(statementallocations).where(and(
eq(statementallocations.incominginvoice, invoiceId),
eq(statementallocations.tenant, tenantId),
eq(statementallocations.archived, false),
))
}
validateCashDiscount({ amount, discount, total, settled: existing.reduce((sum, item) => sum + settledAllocationAmount(item), 0) })
const side: CashDiscountSide = hasDocument ? "granted" : "received"
return {
...payload,
cashDiscountAmount: discount,
cashDiscountSplit: splitCashDiscount(discount, grossByRate, accounts[side]),
}
}
export function validateCashDiscount({ amount, discount, total, settled }: { amount: number, discount: number, total: number, settled: number }) {
if (!total) throw invalidInput("Der Beleg hat keinen Betrag, auf den Skonto gebucht werden kann.")
if (Math.sign(amount) !== Math.sign(total)) {
throw invalidInput("Skonto ist nur bei Zahlungen in Richtung des Belegs möglich.")
}
const open = money(total - settled)
if (Math.abs(amount + discount) > Math.abs(open) + 0.02 || Math.sign(open) !== Math.sign(total)) {
throw invalidInput(`Zahlung und Skonto übersteigen den offenen Betrag von ${Math.abs(open).toFixed(2).replace(".", ",")} €.`)
}
if (Math.abs(discount) > Math.abs(total) * MAX_CASH_DISCOUNT_SHARE + 0.005) {
throw invalidInput(`Der Skonto ist höher als ${MAX_CASH_DISCOUNT_SHARE * 100} % des Belegbetrags. Bitte Betrag prüfen.`)
}
}
// ---------------------------------------------------------------------------
// Skonto-Bedingungen auf Belegen
// ---------------------------------------------------------------------------
export type CashDiscountTerms = {
percent: number
days: number | null
dueDate: string | null
discount: number
payable: number
}
const formatEuro = (value: number) => new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(value)
const formatGermanDate = (value: string) => {
const [year, month, day] = value.split("-")
return `${day}.${month}.${year}`
}
const addDays = (date: string, days: number) => {
const parsed = new Date(`${String(date).slice(0, 10)}T00:00:00Z`)
if (Number.isNaN(parsed.getTime())) return null
parsed.setUTCDate(parsed.getUTCDate() + days)
return parsed.toISOString().slice(0, 10)
}
export function normalizeCashDiscountPercent(value: unknown) {
if (value === null || value === undefined || value === "") return null
const percent = Number(String(value).replace(",", "."))
if (!Number.isFinite(percent) || percent < 0 || percent > 10) {
throw invalidInput("Der Skontosatz muss zwischen 0 und 10 % liegen.")
}
return percent === 0 ? null : Number(percent.toFixed(2))
}
export function normalizeCashDiscountDays(value: unknown) {
if (value === null || value === undefined || value === "") return null
const days = Number(value)
if (!Number.isInteger(days) || days < 0 || days > 365) {
throw invalidInput("Die Skontofrist muss zwischen 0 und 365 Tagen liegen.")
}
return days
}
// Berechnet Frist, Skonto und Zahlbetrag aus Satz, Tagen und Belegdatum.
export function buildCashDiscountTerms({ percent, days, documentDate, dueDate, amount }: {
percent: unknown, days?: unknown, documentDate?: string | null, dueDate?: string | null, amount: number
}): CashDiscountTerms | null {
const normalizedPercent = Number(percent)
if (!Number.isFinite(normalizedPercent) || normalizedPercent <= 0 || !Number.isFinite(amount) || !amount) return null
const normalizedDays = days === null || days === undefined || days === "" ? null : Number(days)
const computedDueDate = dueDate
? String(dueDate).slice(0, 10)
: documentDate && normalizedDays !== null && Number.isFinite(normalizedDays) ? addDays(documentDate, normalizedDays) : null
const discount = money(amount * normalizedPercent / 100)
return {
percent: normalizedPercent,
days: normalizedDays !== null && Number.isFinite(normalizedDays) ? normalizedDays : null,
dueDate: computedDueDate,
discount,
payable: money(amount - discount),
}
}
export function formatCashDiscountText(terms: CashDiscountTerms | null) {
if (!terms) return ""
const percent = String(terms.percent).replace(".", ",")
const deadline = terms.dueDate
? `bis zum ${formatGermanDate(terms.dueDate)}`
: terms.days !== null ? `innerhalb von ${terms.days} Tagen` : "bei sofortiger Zahlung"
return `Bei Zahlung ${deadline} gewähren wir ${percent} % Skonto (${formatEuro(Math.abs(terms.discount))}), Zahlbetrag ${formatEuro(Math.abs(terms.payable))}.`
}
// Kontextvariablen für Textvorlagen; der Satz wird automatisch ergänzt, wenn die Vorlage keine Skontovariable nutzt.
export function getCashDiscountTemplateContext(terms: CashDiscountTerms | null) {
if (!terms) return {}
return {
skonto_prozent: String(terms.percent).replace(".", ","),
skonto_tage: terms.days,
skonto_datum: terms.dueDate ? formatGermanDate(terms.dueDate) : null,
skonto_betrag: formatEuro(Math.abs(terms.discount)),
zahlbetrag_mit_skonto: formatEuro(Math.abs(terms.payable)),
skonto_hinweis: formatCashDiscountText(terms),
}
}
export function appendCashDiscountText(renderedText: string, template: string | null | undefined, terms: CashDiscountTerms | null) {
if (!terms || /skonto_/i.test(String(template || ""))) return renderedText
const sentence = formatCashDiscountText(terms)
return renderedText ? `${sentence}\n\n${renderedText}` : sentence
}
// Liest Skontobedingungen aus E-Rechnungen: XRechnung "#SKONTO#TAGE=14#PROZENT=2.00#" im Zahlungsbedingungstext.
export function parseCashDiscountPaymentTerms(text: unknown): { percent: number, days: number } | null {
const source = String(text || "")
const matches = [...source.matchAll(/#SKONTO#([^\n]*?)#(?:\s|$)/gi)]
for (const match of matches) {
const segment = match[1]
const days = Number(segment.match(/TAGE=(\d+)/i)?.[1])
const percent = Number(segment.match(/PROZENT=(\d+(?:[.,]\d+)?)/i)?.[1]?.replace(",", "."))
if (Number.isInteger(days) && Number.isFinite(percent) && percent > 0) return { percent, days }
}
return null
}

View File

@@ -242,9 +242,11 @@ export class FederationService {
for (const b of bindings) {
if (b.status === "pending" && new Date(b.invitation.expiresAt).getTime() > Date.now()) result.invitations.push({ ...b.invitation, id: b.id, remote: true })
if (b.status === "active") {
const [unread] = await rows(this.service.db, sql`SELECT count(*)::int AS count FROM collaboration_remote_messages WHERE binding_id=${b.id} AND seq>${b.last_read_seq} AND (payload->>'own')='false'`)
const [unread] = await rows(this.service.db, sql`SELECT count(*)::int AS count,
(SELECT payload->>'createdAt' FROM collaboration_remote_messages WHERE binding_id=${b.id} ORDER BY seq DESC LIMIT 1) AS "lastActivityAt"
FROM collaboration_remote_messages WHERE binding_id=${b.id} AND seq>${b.last_read_seq} AND (payload->>'own')='false'`)
result.rooms.push({ id: b.id, name: b.snapshot?.name || b.invitation.name, topic: b.snapshot?.topic || b.invitation.topic,
ownerName: b.invitation.ownerName, status: b.snapshot?.status || 'active', remote: true, unread: unread.count })
ownerName: b.invitation.ownerName, status: b.snapshot?.status || 'active', remote: true, unread: unread.count, lastActivityAt: unread.lastActivityAt || null })
}
}
return result

View File

@@ -144,6 +144,8 @@ export class CollaborationService {
t.name AS "ownerName", m.overview_allowed AS "overviewAllowed",
(SELECT count(*)::int FROM collaboration_messages cm WHERE cm.room_id=r.id
AND cm.seq>=m.history_from_seq AND cm.seq>m.last_read_seq AND cm.principal_id<>${principal.id}) AS unread
,coalesce((SELECT max(cm.created_at) FROM collaboration_messages cm WHERE cm.room_id=r.id
AND cm.seq>=m.history_from_seq), r.created_at) AS "lastActivityAt"
FROM collaboration_members m JOIN collaboration_rooms r ON r.id=m.room_id JOIN tenants t ON t.id=r.tenant_id
LEFT JOIN projects p ON p.id=r.project_id
WHERE m.principal_id=${principal.id} AND m.status='active' AND (m.expires_at IS NULL OR m.expires_at>now())

View File

@@ -17,7 +17,7 @@ import {
incominginvoices,
} from "../../../db/schema"
import { eq, and, isNull, not } from "drizzle-orm"
import { eq, and, inArray, isNull, not } from "drizzle-orm"
const formatInvoiceItemDescription = (item: any) => {
const parts = [
@@ -31,7 +31,7 @@ const formatInvoiceItemDescription = (item: any) => {
return parts.join(" - ")
}
const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => {
export const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => {
const itemInfo: any = {
tenant: tenantId,
state: "Vorbereitet",
@@ -43,6 +43,17 @@ const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => {
if (data.issuer?.id) itemInfo.vendor = data.issuer.id
if (data.invoice_duedate && dayjs(data.invoice_duedate).isValid()) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString()
// Skonto nur übernehmen, wenn die Rechnung ihn ausdrücklich nennt (siehe Prompt).
const cashDiscountPercent = Number(data.cash_discount_percent)
if (Number.isFinite(cashDiscountPercent) && cashDiscountPercent > 0 && cashDiscountPercent <= 10) {
itemInfo.cashDiscountPercent = Number(cashDiscountPercent.toFixed(2))
if (data.cash_discount_due_date && dayjs(data.cash_discount_due_date).isValid()) {
itemInfo.cashDiscountDueDate = dayjs(data.cash_discount_due_date).format("YYYY-MM-DD")
} else if (Number.isInteger(data.cash_discount_days) && data.invoice_date && dayjs(data.invoice_date).isValid()) {
itemInfo.cashDiscountDueDate = dayjs(data.invoice_date).add(data.cash_discount_days, "day").format("YYYY-MM-DD")
}
}
const mapPayment: Record<string, string> = {
"Direct Debit": "Einzug",
"Transfer": "Überweisung",
@@ -86,8 +97,25 @@ const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => {
return itemInfo
}
export type IncomingInvoicePreparationResult = {
filesFound: number
created: Array<{ fileId: string, invoiceId: number, source: string | null }>
skipped: Array<{ fileId: string, reason: string }>
error?: string
}
export type IncomingInvoicePreparationStatus = {
running: boolean
startedAt: string | null
finishedAt: string | null
result: IncomingInvoicePreparationResult | null
}
class FileAlreadyPreparedError extends Error {}
export function prepareIncomingInvoices(server: FastifyInstance) {
const processInvoices = async (tenantId:number) => {
const processInvoices = async (tenantId:number, fileIds?: string[]) => {
const result: IncomingInvoicePreparationResult = { filesFound: 0, created: [], skipped: [] }
console.log("▶ Starting Incoming Invoice Preparation")
const tenantsRes = await server.db
@@ -98,7 +126,7 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
if (!tenantsRes.length) {
console.log("No tenants with autoPrepareIncomingInvoices = true")
return
return { ...result, error: "Mandant nicht gefunden" }
}
console.log(`Processing tenants: ${tenantsRes.map(t => t.id).join(", ")}`)
@@ -124,6 +152,7 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
const invoiceFileTag = tagRes?.[0]?.id
if (!invoiceFileTag) {
server.log.error(`❌ Missing filetag 'invoices' for tenant ${tenantId}`)
result.error = "Dateityp für Eingangsrechnungen fehlt"
continue
}
@@ -137,10 +166,13 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
eq(files.type, invoiceFileTag),
isNull(files.incominginvoice),
eq(files.archived, false),
not(isNull(files.path))
not(isNull(files.path)),
...(fileIds ? [inArray(files.id, fileIds)] : [])
)
)
result.filesFound += filesRes.length
if (!filesRes.length) {
console.log(`No invoice files for tenant ${tenantId}`)
continue
@@ -150,6 +182,23 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
// 3️⃣ Strukturierte E-Rechnung bevorzugen, GPT als PDF-Fallback verwenden
// -------------------------------------------------------------
for (const file of filesRes) {
try {
await processFile(tenantId, file, result)
} catch (error) {
if (error instanceof FileAlreadyPreparedError) {
result.skipped.push({ fileId: file.id, reason: "Datei wurde bereits vorbereitet" })
continue
}
server.log.error(error, `Datei ${file.id} konnte nicht vorbereitet werden.`)
result.skipped.push({ fileId: file.id, reason: "Eingangsbeleg konnte nicht angelegt werden" })
}
}
}
return result
}
const processFile = async (tenantId: number, file: typeof files.$inferSelect, result: IncomingInvoicePreparationResult) => {
console.log(`Processing file ${file.id} for tenant ${tenantId}`)
let fileData: Buffer
@@ -158,7 +207,8 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
fileData = await loadFileBuffer(file.path!)
} catch (error) {
server.log.error(error, `Datei ${file.id} konnte nicht aus S3 geladen werden.`)
continue
result.skipped.push({ fileId: file.id, reason: "Datei konnte nicht geladen werden" })
return
}
const electronicInvoice = await detectElectronicInvoice(fileData, file)
@@ -186,7 +236,10 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
} catch (error) {
server.log.error(error, `E-Rechnung aus Datei ${file.id} konnte nicht verarbeitet werden.`)
if (electronicInvoice.container === "xml") continue
if (electronicInvoice.container === "xml") {
result.skipped.push({ fileId: file.id, reason: "E-Rechnung konnte nicht verarbeitet werden" })
return
}
}
}
@@ -195,50 +248,86 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
if (!data) {
server.log.warn(`GPT returned no data for file ${file.id}`)
continue
result.skipped.push({ fileId: file.id, reason: "Keine Belegdaten erkannt" })
return
}
itemInfo = mapGptDataToIncomingInvoice(data, tenantId)
}
// ---------------------------------------------------------
// 4️⃣ IncomingInvoice erstellen
// 4️⃣ IncomingInvoice anlegen und Datei atomar verknüpfen.
// Die Verknüpfung greift nur, solange die Datei noch frei ist;
// sonst wird der Beleg verworfen, damit keine Dubletten entstehen.
// ---------------------------------------------------------
const inserted = await server.db
const newInvoice = await server.db.transaction(async (tx) => {
const [invoice] = await tx
.insert(incominginvoices)
.values(itemInfo)
.returning()
const newInvoice = inserted?.[0]
if (!invoice) throw new Error("Eingangsbeleg konnte nicht angelegt werden")
if (!newInvoice) {
server.log.error(`Failed to insert incoming invoice for file ${file.id}`)
continue
}
// ---------------------------------------------------------
// 5️⃣ Datei mit incominginvoice-ID verbinden
// ---------------------------------------------------------
await server.db
const linked = await tx
.update(files)
.set({ incominginvoice: newInvoice.id })
.where(eq(files.id, file.id))
.set({ incominginvoice: invoice.id })
.where(and(eq(files.id, file.id), isNull(files.incominginvoice)))
.returning({ id: files.id })
if (!linked.length) throw new FileAlreadyPreparedError()
return invoice
})
result.created.push({ fileId: file.id, invoiceId: newInvoice.id, source: newInvoice.preparationSource ?? null })
console.log(`IncomingInvoice ${newInvoice.id} created for file ${file.id}`)
}
}
// Serialize runs per tenant so concurrent triggers cannot prepare the same file twice.
const running = new Map<number, Promise<unknown>>()
const backgroundRuns = new Map<number, IncomingInvoicePreparationStatus>()
return
}
return {
run: async (tenant:number) => {
await processInvoices(tenant)
const run = async (tenant:number, options: { fileIds?: string[] } = {}) => {
const previous = running.get(tenant) || Promise.resolve()
const current = previous.catch(() => undefined).then(() => processInvoices(tenant, options.fileIds))
running.set(tenant, current)
try {
const result = await current
console.log("Incoming Invoice Preparation Completed.")
return result
} finally {
if (running.get(tenant) === current) running.delete(tenant)
}
}
const status = (tenant: number): IncomingInvoicePreparationStatus =>
backgroundRuns.get(tenant) || { running: false, startedAt: null, finishedAt: null, result: null }
// Startet die Vorbereitung losgelöst von der Anfrage; ein laufender Vorgang wird nicht erneut gestartet.
const start = (tenant: number): IncomingInvoicePreparationStatus => {
const current = status(tenant)
if (current.running) return current
const next: IncomingInvoicePreparationStatus = { running: true, startedAt: new Date().toISOString(), finishedAt: null, result: null }
backgroundRuns.set(tenant, next)
run(tenant)
.then((result) => {
backgroundRuns.set(tenant, { ...next, running: false, finishedAt: new Date().toISOString(), result })
})
.catch((error) => {
server.log.error(error, `Belegvorbereitung für Mandant ${tenant} fehlgeschlagen.`)
backgroundRuns.set(tenant, {
...next,
running: false,
finishedAt: new Date().toISOString(),
result: { filesFound: 0, created: [], skipped: [], error: "Belegvorbereitung fehlgeschlagen" },
})
})
return next
}
}
return { run, start, status }
}

View File

@@ -0,0 +1,374 @@
import dayjs from "dayjs"
import { buildCashDiscountTerms } from "../cash-discount"
/**
* Erzeugt das strukturierte CII-XML (ZUGFeRD 2.x / Factur-X, Profil EN 16931) für Ausgangsrechnungen.
* Grundlage ist der gespeicherte Beleg, nicht die formatierten PDF-Daten, damit alle Beträge numerisch bleiben.
*/
export const ZUGFERD_DOCUMENT_TYPES = ["invoices", "advanceInvoices", "cancellationInvoices", "serialInvoices"]
export const ZUGFERD_GUIDELINE_ID = "urn:cen.eu:en16931:2017"
export const ZUGFERD_FILENAME = "factur-x.xml"
export type ZugferdInvoiceContext = {
tenant: any
customer: any
contact?: any
contactPerson?: any
project?: any
contract?: any
linkedDocument?: any
sepaMandate?: any
units: any[]
products: any[]
services: any[]
bankAccounts: any[]
advanceInvoices: any[]
}
export type ZugferdInvoiceResult = {
xml: string
issues: ZugferdIssue[]
totals: { lineNet: number, tax: number, gross: number, prepaid: number, payable: number }
}
type TaxCategory = "S" | "Z" | "E" | "AE"
type TaxGroup = { category: TaxCategory, rate: number, basis: number, exemptionReason?: string, exemptionReasonCode?: string }
const round = (value: number, digits = 2) => {
const factor = 10 ** digits
const rounded = Math.round((value + Number.EPSILON * Math.sign(value)) * factor) / factor
return Object.is(rounded, -0) ? 0 : rounded
}
const amount = (value: number) => round(value).toFixed(2)
const decimal = (value: number, maxDigits = 4) => String(round(value, maxDigits))
const escapeXml = (value: unknown) => String(value ?? "")
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
const text = (value: unknown) => String(value ?? "").trim()
const el = (name: string, value: unknown, attributes = "") => {
const content = text(value)
return content ? `<${name}${attributes}>${escapeXml(content)}</${name}>` : ""
}
const date102 = (value: unknown) => {
if (!value) return ""
const parsed = dayjs(value as any)
return parsed.isValid() ? parsed.format("YYYYMMDD") : ""
}
const dateElement = (name: string, value: unknown) => {
const formatted = date102(value)
return formatted ? `<${name}><udt:DateTimeString format="102">${formatted}</udt:DateTimeString></${name}>` : ""
}
const countryCodes: Record<string, string> = {
deutschland: "DE", germany: "DE", österreich: "AT", oesterreich: "AT", austria: "AT", schweiz: "CH", switzerland: "CH",
liechtenstein: "LI", frankreich: "FR", france: "FR", italien: "IT", italy: "IT", niederlande: "NL", netherlands: "NL",
belgien: "BE", belgium: "BE", luxemburg: "LU", luxembourg: "LU", dänemark: "DK", daenemark: "DK", denmark: "DK",
polen: "PL", poland: "PL", tschechien: "CZ", "tschechische republik": "CZ", spanien: "ES", spain: "ES",
portugal: "PT", schweden: "SE", sweden: "SE", norwegen: "NO", norway: "NO", finnland: "FI", finland: "FI",
ungarn: "HU", hungary: "HU", slowakei: "SK", slowenien: "SI", kroatien: "HR", rumänien: "RO", bulgarien: "BG",
griechenland: "GR", irland: "IE", ireland: "IE", "vereinigtes königreich": "GB", großbritannien: "GB", england: "GB",
"united kingdom": "GB", usa: "US", "vereinigte staaten": "US", estland: "EE", lettland: "LV", litauen: "LT",
malta: "MT", zypern: "CY", türkei: "TR",
}
export function countryCode(value: unknown) {
const normalized = text(value).toLocaleLowerCase("de")
if (!normalized) return "DE"
if (/^[a-z]{2}$/.test(normalized)) return normalized.toUpperCase()
return countryCodes[normalized] || "DE"
}
// UN/ECE Recommendation 20/21, abgebildet über das Kürzel der FEDEO-Einheit.
const unitCodes: Record<string, string> = {
stk: "H87", stück: "H87", m: "MTR", meter: "MTR", km: "KMT", kilometer: "KMT", tag: "DAY", tage: "DAY",
std: "HUR", h: "HUR", stunde: "HUR", stunden: "HUR", min: "MIN", minute: "MIN", minuten: "MIN",
pausch: "LS", pauschal: "LS", psch: "LS", mon: "MON", monat: "MON", jahr: "ANN", woche: "WEE",
pck: "PK", packung: "PK", pers: "IE", person: "IE", l: "LTR", liter: "LTR", ha: "HAR", hektar: "HAR",
"m²": "MTK", m2: "MTK", qm: "MTK", quadratmeter: "MTK", "m³": "MTQ", m3: "MTQ", kubikmeter: "MTQ",
kg: "KGM", kilogramm: "KGM", g: "GRM", t: "TNE", tonne: "TNE", gb: "E34", gigabyte: "E34", kwp: "KWT", kwh: "KWH",
}
export function unitCode(unit: any) {
for (const candidate of [unit?.short, unit?.single, unit?.name]) {
const code = unitCodes[text(candidate).toLocaleLowerCase("de")]
if (code) return code
}
return "C62"
}
const isItemRow = (row: any) => !["pagebreak", "title", "text"].includes(row?.mode) && !row?.optional && !row?.alternative
function taxGroupFor(taxType: string | null | undefined, rate: number): Omit<TaxGroup, "basis"> {
if (taxType === "13b UStG") return {
category: "AE", rate: 0, exemptionReasonCode: "VATEX-EU-AE",
exemptionReason: "Steuerschuldnerschaft des Leistungsempfängers gemäß § 13b UStG",
}
if (taxType === "19 UStG") return {
category: "E", rate: 0,
exemptionReason: "Kein Ausweis von Umsatzsteuer, da Kleinunternehmer gemäß § 19 UStG",
}
if (taxType === "12.3 UStG") return { category: "Z", rate: 0 }
return rate > 0 ? { category: "S", rate } : { category: "Z", rate: 0 }
}
function advanceInvoiceGross(invoice: any) {
const row = (invoice?.rows || []).find((item: any) => item.advanceInvoiceData)
return row ? Number(row.price) * ((100 + Number(row.taxPercent)) / 100) : 0
}
function typeCode(type: string) {
if (type === "advanceInvoices") return "386"
if (type === "cancellationInvoices") return "381"
return "380"
}
function partyAddress(address: { zip?: unknown, street?: unknown, special?: unknown, city?: unknown, country?: unknown }) {
return `<ram:PostalTradeAddress>${el("ram:PostcodeCode", address.zip)}${el("ram:LineOne", address.street)}${el("ram:LineTwo", address.special)}${el("ram:CityName", address.city)}<ram:CountryID>${countryCode(address.country)}</ram:CountryID></ram:PostalTradeAddress>`
}
function tradeContact(name: unknown, phone?: unknown, email?: unknown) {
if (!text(name) && !text(phone) && !text(email)) return ""
return `<ram:DefinedTradeContact>${el("ram:PersonName", name)}${text(phone) ? `<ram:TelephoneUniversalCommunication>${el("ram:CompleteNumber", phone)}</ram:TelephoneUniversalCommunication>` : ""}${text(email) ? `<ram:EmailURIUniversalCommunication>${el("ram:URIID", email)}</ram:EmailURIUniversalCommunication>` : ""}</ram:DefinedTradeContact>`
}
function taxRegistrations(vatId: unknown, taxNumber?: unknown) {
return [
text(vatId) ? `<ram:SpecifiedTaxRegistration><ram:ID schemeID="VA">${escapeXml(text(vatId).replace(/\s+/g, ""))}</ram:ID></ram:SpecifiedTaxRegistration>` : "",
text(taxNumber) ? `<ram:SpecifiedTaxRegistration><ram:ID schemeID="FC">${escapeXml(text(taxNumber))}</ram:ID></ram:SpecifiedTaxRegistration>` : "",
].join("")
}
export type ZugferdIssue = { message: string, type: "breaking" | "info" }
const sellerIban = (context: ZugferdInvoiceContext) => {
const businessInfo = context.tenant?.businessInfo || {}
const account = context.bankAccounts.find((item) => !item.archived && !item.expired && item.iban)
return text(businessInfo.iban || account?.iban).replace(/\s+/g, "")
}
/**
* Pflichtangaben einer E-Rechnung (EN 16931 / § 14 UStG). "breaking" verhindert das Fertigstellen,
* "info" ist ein Hinweis. requireNumber prüft zusätzlich Nummer und Datum, die erst beim Fertigstellen entstehen.
*/
export function checkZugferdRequirements(document: any, context: ZugferdInvoiceContext, options: { requireNumber?: boolean } = {}): ZugferdIssue[] {
const issues: ZugferdIssue[] = []
const breaking = (message: string) => issues.push({ message: `E-Rechnung: ${message}`, type: "breaking" })
const info = (message: string) => issues.push({ message: `E-Rechnung: ${message}`, type: "info" })
const businessInfo = context.tenant?.businessInfo || {}
const customer = context.customer || {}
const customerInfo = customer.infoData || {}
const address = document.address || {}
if (options.requireNumber && !text(document.documentNumber)) breaking("Die Rechnungsnummer fehlt")
if (options.requireNumber && !date102(document.documentDate)) breaking("Das Rechnungsdatum fehlt")
if (!text(businessInfo.name || context.tenant?.name)) breaking("Der Firmenname fehlt in den Firmendaten (Einstellungen > Firmendaten)")
if (!text(businessInfo.street) || !text(businessInfo.zip) || !text(businessInfo.city)) breaking("Die Firmenanschrift ist in den Firmendaten unvollständig (Straße, PLZ, Ort)")
if (!text(businessInfo.vatId) && !text(businessInfo.taxNumber)) breaking("In den Firmendaten ist weder eine USt-IdNr. noch eine Steuernummer hinterlegt")
if (document.customer && !text(customer.name)) breaking("Der Kunde hat keinen Namen")
if (document.customer && (!text(address.street ?? customerInfo.street) || !text(address.zip ?? customerInfo.zip) || !text(address.city ?? customerInfo.city))) {
breaking("Die Anschrift des Kunden ist unvollständig (Straße, PLZ, Ort)")
}
if (document.taxType === "13b UStG") {
if (document.customer && !text(customerInfo.ustid)) breaking("Für § 13b UStG muss beim Kunden eine USt-Id hinterlegt sein")
if (!text(businessInfo.vatId)) breaking("Für § 13b UStG muss in den Firmendaten eine USt-IdNr. hinterlegt sein")
}
if (!(document.rows || []).some(isItemRow)) breaking("Der Beleg enthält keine abrechenbaren Positionen")
if (document.payment_type === "direct-debit") {
if (!text(context.tenant?.creditorId)) breaking("Für SEPA-Lastschrift ist in den Firmendaten keine Gläubiger-ID hinterlegt")
if (!text(context.sepaMandate?.reference)) breaking("Für SEPA-Lastschrift ist kein Mandat mit Mandatsreferenz ausgewählt")
} else if ((document.payment_type === "transfer" || !document.payment_type) && !sellerIban(context)) {
breaking("Für Überweisung ist keine IBAN hinterlegt (Firmendaten oder Bankkonto)")
}
if (text(document.deliveryDateType) === "Kein Lieferdatum anzeigen") info("Es wird kein Liefer- oder Leistungsdatum angegeben (§ 14 UStG)")
return issues
}
export function buildZugferdInvoiceXml(document: any, context: ZugferdInvoiceContext): ZugferdInvoiceResult {
const businessInfo = context.tenant?.businessInfo || {}
const customer = context.customer || {}
const customerInfo = customer.infoData || {}
const address = document.address || {}
// Stornorechnungen tragen negative Preise; als Gutschrift (381) werden die Beträge positiv ausgewiesen.
const sign = document.type === "cancellationInvoices" ? -1 : 1
const issues = checkZugferdRequirements(document, context, { requireNumber: true })
const groups = new Map<string, TaxGroup>()
const lines: string[] = []
let lineIndex = 0
for (const row of document.rows || []) {
if (!isItemRow(row)) continue
lineIndex++
const discountPercent = Number(row.discountPercent) || 0
const grossUnitPrice = sign * (Number(row.price) || 0)
let netUnitPrice = grossUnitPrice * (1 - discountPercent / 100)
let quantity = Number(row.quantity) || 0
let grossPrice = grossUnitPrice
// EN 16931 verlangt nicht-negative Einzelpreise (BR-27); Minderungen laufen über eine negative Menge.
if (netUnitPrice < 0 || (netUnitPrice === 0 && grossPrice < 0)) {
netUnitPrice = -netUnitPrice
grossPrice = -grossPrice
quantity = -quantity
}
const lineTotal = round(quantity * netUnitPrice)
const rate = Number(row.taxPercent) || 0
const group = taxGroupFor(document.taxType, rate)
const key = `${group.category}:${group.rate}`
const existing = groups.get(key)
if (existing) existing.basis = round(existing.basis + lineTotal)
else groups.set(key, { ...group, basis: lineTotal })
const product = row.mode === "normal" ? context.products.find((item) => item.id === row.product) : null
const service = row.mode === "service" ? context.services.find((item) => item.id === row.service) : null
const name = text(product?.name || service?.name || row.text) || `Position ${row.pos ?? lineIndex}`
const sellerId = product?.article_number
const description = text(row.agriculture?.description || row.description)
const unit = context.units.find((item) => item.id === row.unit)
const allowance = round(grossPrice - netUnitPrice, 4)
lines.push(
`<ram:IncludedSupplyChainTradeLineItem>` +
`<ram:AssociatedDocumentLineDocument><ram:LineID>${escapeXml(text(row.pos) || String(lineIndex))}</ram:LineID></ram:AssociatedDocumentLineDocument>` +
`<ram:SpecifiedTradeProduct>${el("ram:SellerAssignedID", sellerId)}<ram:Name>${escapeXml(name)}</ram:Name>${el("ram:Description", description)}</ram:SpecifiedTradeProduct>` +
`<ram:SpecifiedLineTradeAgreement>` +
(allowance > 0
? `<ram:GrossPriceProductTradePrice><ram:ChargeAmount>${decimal(grossPrice)}</ram:ChargeAmount><ram:AppliedTradeAllowanceCharge><ram:ChargeIndicator><udt:Indicator>false</udt:Indicator></ram:ChargeIndicator><ram:ActualAmount>${decimal(allowance)}</ram:ActualAmount></ram:AppliedTradeAllowanceCharge></ram:GrossPriceProductTradePrice>`
: "") +
`<ram:NetPriceProductTradePrice><ram:ChargeAmount>${decimal(netUnitPrice)}</ram:ChargeAmount></ram:NetPriceProductTradePrice>` +
`</ram:SpecifiedLineTradeAgreement>` +
`<ram:SpecifiedLineTradeDelivery><ram:BilledQuantity unitCode="${unitCode(unit)}">${decimal(quantity)}</ram:BilledQuantity></ram:SpecifiedLineTradeDelivery>` +
`<ram:SpecifiedLineTradeSettlement><ram:ApplicableTradeTax><ram:TypeCode>VAT</ram:TypeCode><ram:CategoryCode>${group.category}</ram:CategoryCode><ram:RateApplicablePercent>${decimal(group.rate)}</ram:RateApplicablePercent></ram:ApplicableTradeTax>` +
`<ram:SpecifiedTradeSettlementLineMonetarySummation><ram:LineTotalAmount>${amount(lineTotal)}</ram:LineTotalAmount></ram:SpecifiedTradeSettlementLineMonetarySummation></ram:SpecifiedLineTradeSettlement>` +
`</ram:IncludedSupplyChainTradeLineItem>`,
)
}
const taxGroups = [...groups.values()]
const lineNet = round(taxGroups.reduce((sum, group) => sum + group.basis, 0))
const taxTotal = round(taxGroups.reduce((sum, group) => sum + round(group.basis * group.rate / 100), 0))
const grossTotal = round(lineNet + taxTotal)
const prepaid = ["invoices", "cancellationInvoices"].includes(document.type)
? round(context.advanceInvoices.reduce((sum, invoice) => sum + advanceInvoiceGross(invoice), 0))
: 0
const payable = round(grossTotal - prepaid)
// Zahlungsinformationen
const paymentAccount = context.bankAccounts.find((account) => !account.archived && !account.expired && account.iban)
const iban = text(businessInfo.iban || paymentAccount?.iban).replace(/\s+/g, "")
const bic = text(businessInfo.bic || paymentAccount?.bic).replace(/\s+/g, "")
const accountName = text(businessInfo.bankAccountOwner || paymentAccount?.ownerName || businessInfo.name)
let paymentMeans = ""
if (document.payment_type === "direct-debit") {
paymentMeans = `<ram:SpecifiedTradeSettlementPaymentMeans><ram:TypeCode>59</ram:TypeCode><ram:Information>SEPA-Lastschrift</ram:Information></ram:SpecifiedTradeSettlementPaymentMeans>`
} else if (document.payment_type === "transfer" || !document.payment_type) {
paymentMeans = `<ram:SpecifiedTradeSettlementPaymentMeans><ram:TypeCode>58</ram:TypeCode><ram:Information>SEPA-Überweisung</ram:Information>` +
(iban ? `<ram:PayeePartyCreditorFinancialAccount><ram:IBANID>${escapeXml(iban)}</ram:IBANID>${el("ram:AccountName", accountName)}</ram:PayeePartyCreditorFinancialAccount>` : "") +
(iban && bic ? `<ram:PayeeSpecifiedCreditorFinancialInstitution><ram:BICID>${escapeXml(bic)}</ram:BICID></ram:PayeeSpecifiedCreditorFinancialInstitution>` : "") +
`</ram:SpecifiedTradeSettlementPaymentMeans>`
} else {
paymentMeans = `<ram:SpecifiedTradeSettlementPaymentMeans><ram:TypeCode>1</ram:TypeCode></ram:SpecifiedTradeSettlementPaymentMeans>`
}
const paymentDays = document.paymentDays === null || document.paymentDays === undefined || document.paymentDays === "" ? null : Number(document.paymentDays)
const dueDate = date102(document.documentDate) && paymentDays !== null && Number.isFinite(paymentDays)
? dayjs(document.documentDate).add(paymentDays, "day")
: null
const cashDiscount = ["invoices", "advanceInvoices"].includes(document.type)
? buildCashDiscountTerms({ percent: document.cashDiscountPercent, days: document.cashDiscountDays, documentDate: document.documentDate, amount: payable })
: null
const termsDescription = [
payable <= 0 ? "" : dueDate ? `Zahlbar bis zum ${dueDate.format("DD.MM.YYYY")} ohne Abzug.` : "Zahlbar sofort ohne Abzug.",
// Strukturierte Skontoangabe nach XRechnung-Konvention, die von gängigen Empfängern ausgewertet wird.
cashDiscount && cashDiscount.days !== null
? `#SKONTO#TAGE=${cashDiscount.days}#PROZENT=${cashDiscount.percent.toFixed(2)}#BASISBETRAG=${amount(payable)}#`
: "",
].filter(Boolean).join("\n")
const paymentTerms = termsDescription || dueDate || context.sepaMandate?.reference
? `<ram:SpecifiedTradePaymentTerms>${el("ram:Description", termsDescription)}${dueDate ? dateElement("ram:DueDateDateTime", dueDate.toDate()) : ""}${el("ram:DirectDebitMandateID", document.payment_type === "direct-debit" ? context.sepaMandate?.reference : "")}</ram:SpecifiedTradePaymentTerms>`
: ""
const taxElements = taxGroups.map((group) =>
`<ram:ApplicableTradeTax><ram:CalculatedAmount>${amount(group.basis * group.rate / 100)}</ram:CalculatedAmount><ram:TypeCode>VAT</ram:TypeCode>${el("ram:ExemptionReason", group.exemptionReason)}<ram:BasisAmount>${amount(group.basis)}</ram:BasisAmount><ram:CategoryCode>${group.category}</ram:CategoryCode>${el("ram:ExemptionReasonCode", group.exemptionReasonCode)}<ram:RateApplicablePercent>${decimal(group.rate)}</ram:RateApplicablePercent></ram:ApplicableTradeTax>`,
).join("")
// Leistungsdatum bzw. Leistungszeitraum
const deliveryType = text(document.deliveryDateType)
const isPeriod = ["Lieferzeitraum", "Leistungszeitraum"].includes(deliveryType)
const showDelivery = deliveryType !== "Kein Lieferdatum anzeigen"
const deliveryEvent = showDelivery && !isPeriod && date102(document.deliveryDate)
? `<ram:ActualDeliverySupplyChainEvent>${dateElement("ram:OccurrenceDateTime", document.deliveryDate)}</ram:ActualDeliverySupplyChainEvent>`
: ""
const billingPeriod = showDelivery && isPeriod && (date102(document.deliveryDate) || date102(document.deliveryDateEnd))
? `<ram:BillingSpecifiedPeriod>${dateElement("ram:StartDateTime", document.deliveryDate)}${dateElement("ram:EndDateTime", document.deliveryDateEnd)}</ram:BillingSpecifiedPeriod>`
: ""
const contactPerson = context.contactPerson || {}
const sellerContact = tradeContact(contactPerson.full_name, document.contactTel || contactPerson.fixed_tel || contactPerson.mobile_tel || businessInfo.phone, contactPerson.email || businessInfo.email)
const seller = `<ram:SellerTradeParty>${el("ram:Name", businessInfo.name || context.tenant?.name)}${sellerContact}` +
partyAddress({ zip: businessInfo.zip, street: businessInfo.street, city: businessInfo.city, country: businessInfo.country }) +
(text(businessInfo.email) ? `<ram:URIUniversalCommunication><ram:URIID schemeID="EM">${escapeXml(text(businessInfo.email))}</ram:URIID></ram:URIUniversalCommunication>` : "") +
taxRegistrations(businessInfo.vatId, businessInfo.taxNumber) +
`</ram:SellerTradeParty>`
const buyerEmail = text(customerInfo.invoiceEmail || customerInfo.email)
const contact = context.contact
const buyer = `<ram:BuyerTradeParty>${el("ram:ID", typeof customer.customerNumber === "object" ? "" : customer.customerNumber)}${el("ram:Name", customer.name)}` +
(contact ? tradeContact([contact.firstName, contact.lastName].filter(Boolean).join(" "), contact.phoneHome || contact.phoneMobile, contact.email) : "") +
partyAddress({
zip: address.zip ?? customerInfo.zip,
street: address.street ?? customerInfo.street,
special: address.special ?? customerInfo.special,
city: address.city ?? customerInfo.city,
country: address.country ?? customerInfo.country,
}) +
(buyerEmail ? `<ram:URIUniversalCommunication><ram:URIID schemeID="EM">${escapeXml(buyerEmail)}</ram:URIID></ram:URIUniversalCommunication>` : "") +
taxRegistrations(customerInfo.ustid) +
`</ram:BuyerTradeParty>`
const contractReference = el("ram:IssuerAssignedID", context.contract?.contractNumber)
const projectReference = context.project?.name
? `<ram:SpecifiedProcuringProject><ram:ID>${escapeXml(text(context.project.projectNumber || context.project.id))}</ram:ID><ram:Name>${escapeXml(text(context.project.name))}</ram:Name></ram:SpecifiedProcuringProject>`
: ""
const precedingInvoice = document.type === "cancellationInvoices" && text(context.linkedDocument?.documentNumber)
? `<ram:InvoiceReferencedDocument><ram:IssuerAssignedID>${escapeXml(text(context.linkedDocument.documentNumber))}</ram:IssuerAssignedID>${date102(context.linkedDocument.documentDate) ? `<ram:FormattedIssueDateTime><qdt:DateTimeString format="102">${date102(context.linkedDocument.documentDate)}</qdt:DateTimeString></ram:FormattedIssueDateTime>` : ""}</ram:InvoiceReferencedDocument>`
: ""
const notes = [
text(document.title),
text(document.description),
].filter(Boolean).map((note) => `<ram:IncludedNote><ram:Content>${escapeXml(note)}</ram:Content></ram:IncludedNote>`).join("")
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100" xmlns:qdt="urn:un:unece:uncefact:data:standard:QualifiedDataType:100" xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100" xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100">` +
`<rsm:ExchangedDocumentContext><ram:GuidelineSpecifiedDocumentContextParameter><ram:ID>${ZUGFERD_GUIDELINE_ID}</ram:ID></ram:GuidelineSpecifiedDocumentContextParameter></rsm:ExchangedDocumentContext>` +
`<rsm:ExchangedDocument><ram:ID>${escapeXml(text(document.documentNumber))}</ram:ID><ram:TypeCode>${typeCode(document.type)}</ram:TypeCode>` +
`<ram:IssueDateTime><udt:DateTimeString format="102">${date102(document.documentDate) || dayjs().format("YYYYMMDD")}</udt:DateTimeString></ram:IssueDateTime>${notes}</rsm:ExchangedDocument>` +
`<rsm:SupplyChainTradeTransaction>${lines.join("")}` +
`<ram:ApplicableHeaderTradeAgreement>${el("ram:BuyerReference", context.project?.customerRef)}${seller}${buyer}` +
(contractReference ? `<ram:ContractReferencedDocument>${contractReference}</ram:ContractReferencedDocument>` : "") +
`${projectReference}</ram:ApplicableHeaderTradeAgreement>` +
`<ram:ApplicableHeaderTradeDelivery>${deliveryEvent}</ram:ApplicableHeaderTradeDelivery>` +
`<ram:ApplicableHeaderTradeSettlement>` +
(document.payment_type === "direct-debit" ? el("ram:CreditorReferenceID", context.tenant?.creditorId) : "") +
el("ram:PaymentReference", document.documentNumber) +
`<ram:InvoiceCurrencyCode>EUR</ram:InvoiceCurrencyCode>${paymentMeans}${taxElements}${billingPeriod}${paymentTerms}` +
`<ram:SpecifiedTradeSettlementHeaderMonetarySummation>` +
`<ram:LineTotalAmount>${amount(lineNet)}</ram:LineTotalAmount>` +
`<ram:TaxBasisTotalAmount>${amount(lineNet)}</ram:TaxBasisTotalAmount>` +
`<ram:TaxTotalAmount currencyID="EUR">${amount(taxTotal)}</ram:TaxTotalAmount>` +
`<ram:GrandTotalAmount>${amount(grossTotal)}</ram:GrandTotalAmount>` +
(prepaid ? `<ram:TotalPrepaidAmount>${amount(prepaid)}</ram:TotalPrepaidAmount>` : "") +
`<ram:DuePayableAmount>${amount(payable)}</ram:DuePayableAmount>` +
`</ram:SpecifiedTradeSettlementHeaderMonetarySummation>${precedingInvoice}` +
`</ram:ApplicableHeaderTradeSettlement></rsm:SupplyChainTradeTransaction></rsm:CrossIndustryInvoice>`
return { xml, issues, totals: { lineNet, tax: taxTotal, gross: grossTotal, prepaid, payable } }
}

View File

@@ -0,0 +1,80 @@
import { AFRelationship, PDFDocument, PDFName } from "pdf-lib"
import { ZUGFERD_FILENAME } from "./buildZugferdInvoice"
const escapeXml = (value: string) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
function buildXmp(title: string, now: Date) {
const date = now.toISOString()
return `<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:format>application/pdf</dc:format>
<dc:title><rdf:Alt><rdf:li xml:lang="x-default">${escapeXml(title)}</rdf:li></rdf:Alt></dc:title>
</rdf:Description>
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/">
<xmp:CreatorTool>FEDEO</xmp:CreatorTool>
<xmp:CreateDate>${date}</xmp:CreateDate>
<xmp:ModifyDate>${date}</xmp:ModifyDate>
<xmp:MetadataDate>${date}</xmp:MetadataDate>
</rdf:Description>
<rdf:Description rdf:about="" xmlns:pdf="http://ns.adobe.com/pdf/1.3/">
<pdf:Producer>FEDEO</pdf:Producer>
</rdf:Description>
<rdf:Description rdf:about="" xmlns:fx="urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#">
<fx:DocumentType>INVOICE</fx:DocumentType>
<fx:DocumentFileName>${ZUGFERD_FILENAME}</fx:DocumentFileName>
<fx:Version>1.0</fx:Version>
<fx:ConformanceLevel>EN 16931</fx:ConformanceLevel>
</rdf:Description>
<rdf:Description rdf:about="" xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#" xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#">
<pdfaExtension:schemas><rdf:Bag><rdf:li rdf:parseType="Resource">
<pdfaSchema:schema>Factur-X PDFA Extension Schema</pdfaSchema:schema>
<pdfaSchema:namespaceURI>urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#</pdfaSchema:namespaceURI>
<pdfaSchema:prefix>fx</pdfaSchema:prefix>
<pdfaSchema:property><rdf:Seq>
<rdf:li rdf:parseType="Resource"><pdfaProperty:name>DocumentFileName</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>name of the embedded XML invoice file</pdfaProperty:description></rdf:li>
<rdf:li rdf:parseType="Resource"><pdfaProperty:name>DocumentType</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>INVOICE</pdfaProperty:description></rdf:li>
<rdf:li rdf:parseType="Resource"><pdfaProperty:name>Version</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>The actual version of the Factur-X XML schema</pdfaProperty:description></rdf:li>
<rdf:li rdf:parseType="Resource"><pdfaProperty:name>ConformanceLevel</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>The conformance level of the embedded Factur-X data</pdfaProperty:description></rdf:li>
</rdf:Seq></pdfaSchema:property>
</rdf:li></rdf:Bag></pdfaExtension:schemas>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>`
}
/**
* Bettet das CII-XML als factur-x.xml (AFRelationship Alternative) in die PDF ein und ergänzt die
* Factur-X/ZUGFeRD-XMP-Metadaten. Ergebnis ist eine hybride ZUGFeRD-Rechnung (PDF + strukturierte Daten).
*/
export async function embedZugferdXml(pdfBytes: Uint8Array | Buffer, xml: string, options: { title?: string } = {}) {
const pdfDoc = await PDFDocument.load(pdfBytes, { updateMetadata: false })
const now = new Date()
const title = options.title || "Rechnung"
await pdfDoc.attach(Buffer.from(xml, "utf8"), ZUGFERD_FILENAME, {
mimeType: "text/xml",
description: "Factur-X/ZUGFeRD Rechnung",
creationDate: now,
modificationDate: now,
afRelationship: AFRelationship.Alternative,
})
pdfDoc.setTitle(title, { showInWindowTitleBar: true })
pdfDoc.setProducer("FEDEO")
pdfDoc.setCreator("FEDEO")
pdfDoc.setCreationDate(now)
pdfDoc.setModificationDate(now)
pdfDoc.setLanguage("de-DE")
const metadata = pdfDoc.context.stream(Buffer.from(buildXmp(title, now), "utf8"), {
Type: "Metadata",
Subtype: "XML",
})
pdfDoc.catalog.set(PDFName.of("Metadata"), pdfDoc.context.register(metadata))
pdfDoc.setSubject("ZUGFeRD / Factur-X EN 16931")
return Buffer.from(await pdfDoc.save({ useObjectStreams: false }))
}

View File

@@ -41,6 +41,13 @@ export const findElectronicInvoiceVendor = async (
return null
}
const addDaysToDate = (date: string, days: number) => {
const parsed = new Date(`${String(date).slice(0, 10)}T00:00:00Z`)
if (Number.isNaN(parsed.getTime())) return null
parsed.setUTCDate(parsed.getUTCDate() + days)
return parsed.toISOString().slice(0, 10)
}
export const mapElectronicInvoiceToIncomingInvoice = (
invoice: ParsedElectronicInvoice,
tenantId: number,
@@ -64,6 +71,10 @@ export const mapElectronicInvoiceToIncomingInvoice = (
reference: invoice.invoiceNumber,
date: invoice.invoiceDate,
dueDate: invoice.dueDate,
cashDiscountPercent: invoice.cashDiscount?.percent ?? null,
cashDiscountDueDate: invoice.cashDiscount && invoice.invoiceDate
? addDaysToDate(invoice.invoiceDate, invoice.cashDiscount.days)
: null,
paymentType: paymentTypeByCode[invoice.paymentMeansCode || ""] || "Sonstiges",
description: descriptionParts.join("\n"),
accounts: invoice.items.map((item) => ({

View File

@@ -0,0 +1,106 @@
import { FastifyInstance } from "fastify"
import { and, eq, inArray } from "drizzle-orm"
import {
authProfiles,
bankaccounts,
contacts,
contracts,
createddocuments,
customers,
outgoingsepamandates,
products,
projects,
services,
tenants,
units,
} from "../../../db/schema"
import { buildZugferdInvoiceXml, checkZugferdRequirements, ZUGFERD_DOCUMENT_TYPES, ZugferdInvoiceContext, ZugferdIssue } from "./buildZugferdInvoice"
import { embedZugferdXml } from "./embedZugferd"
export const isZugferdDocumentType = (type: unknown) => ZUGFERD_DOCUMENT_TYPES.includes(String(type))
export const isZugferdDocument = (document: any) => Boolean(document && isZugferdDocumentType(document.type) && document.documentNumber)
export class EInvoiceRequirementsError extends Error {
statusCode = 422
code = "EINVOICE_REQUIREMENTS"
constructor(public issues: ZugferdIssue[]) {
super(`Die E-Rechnung kann nicht erstellt werden: ${issues.map((issue) => issue.message.replace(/^E-Rechnung: /, "")).join("; ")}`)
}
}
export const breakingIssues = (issues: ZugferdIssue[]) => issues.filter((issue) => issue.type === "breaking")
/** Prüft die Pflichtangaben eines (auch ungespeicherten) Belegs. Andere Belegarten liefern keine Hinweise. */
export async function checkOutgoingEInvoice(server: FastifyInstance, tenantId: number, document: any, options: { requireNumber?: boolean } = {}) {
if (!document || !isZugferdDocumentType(document.type)) return [] as ZugferdIssue[]
const context = await loadZugferdContext(server, tenantId, document)
return checkZugferdRequirements(document, context, options)
}
/** Wirft EInvoiceRequirementsError, wenn Pflichtangaben fehlen. */
export async function assertOutgoingEInvoiceRequirements(server: FastifyInstance, tenantId: number, document: any, options: { requireNumber?: boolean } = {}) {
const blocking = breakingIssues(await checkOutgoingEInvoice(server, tenantId, document, options))
if (blocking.length) throw new EInvoiceRequirementsError(blocking)
}
const byId = async (server: FastifyInstance, table: any, id: unknown, tenantId: number) => {
if (id === null || id === undefined || id === "") return null
const [row] = await server.db.select().from(table).where(and(eq(table.id, Number(id)), eq(table.tenant, tenantId))).limit(1)
return row || null
}
export async function loadZugferdContext(server: FastifyInstance, tenantId: number, document: any): Promise<ZugferdInvoiceContext> {
const rows = document.rows || []
const productIds = [...new Set(rows.filter((row: any) => row.product).map((row: any) => Number(row.product)))] as number[]
const serviceIds = [...new Set(rows.filter((row: any) => row.service).map((row: any) => Number(row.service)))] as number[]
const advanceIds = (Array.isArray(document.usedAdvanceInvoices) ? document.usedAdvanceInvoices : []).map(Number) as number[]
const profileUser = document.contactPerson || document.created_by
const [[tenant], customer, contact, project, contract, linkedDocument, sepaMandate, unitRows, productRows, serviceRows, bankAccountRows, advanceRows, profileRows] = await Promise.all([
server.db.select().from(tenants).where(eq(tenants.id, tenantId)).limit(1),
byId(server, customers, document.customer, tenantId),
byId(server, contacts, document.contact, tenantId),
byId(server, projects, document.project, tenantId),
byId(server, contracts, document.contract, tenantId),
byId(server, createddocuments, document.createddocument ?? document.linkedDocument, tenantId),
byId(server, outgoingsepamandates, document.outgoingsepamandate, tenantId),
server.db.select().from(units),
productIds.length ? server.db.select().from(products).where(and(eq(products.tenant, tenantId), inArray(products.id, productIds))) : [],
serviceIds.length ? server.db.select().from(services).where(and(eq(services.tenant, tenantId), inArray(services.id, serviceIds))) : [],
server.db.select().from(bankaccounts).where(and(eq(bankaccounts.tenant, tenantId), eq(bankaccounts.archived, false))),
advanceIds.length ? server.db.select().from(createddocuments).where(and(eq(createddocuments.tenant, tenantId), inArray(createddocuments.id, advanceIds))) : [],
profileUser
? server.db.select().from(authProfiles).where(and(eq(authProfiles.user_id, profileUser), eq(authProfiles.tenant_id, tenantId))).limit(1)
: [],
])
return {
tenant,
customer,
contact,
contactPerson: (profileRows as any[])[0] || null,
project,
contract,
linkedDocument,
sepaMandate,
units: unitRows,
products: productRows as any[],
services: serviceRows as any[],
bankAccounts: bankAccountRows,
advanceInvoices: advanceIds.map((id) => (advanceRows as any[]).find((invoice) => invoice.id === id)).filter(Boolean),
}
}
/**
* Macht aus der gerenderten Rechnungs-PDF eine ZUGFeRD-Rechnung. Fehlende Pflichtangaben führen zu
* EInvoiceRequirementsError, damit keine Rechnung ohne gültige E-Rechnung abgelegt wird.
*/
export async function attachZugferdToPdf(server: FastifyInstance, tenantId: number, document: any, pdf: Buffer) {
if (!isZugferdDocument(document)) return { pdf, issues: [] as ZugferdIssue[], einvoice: false }
const context = await loadZugferdContext(server, tenantId, document)
const { xml, issues } = buildZugferdInvoiceXml(document, context)
const blocking = breakingIssues(issues)
if (blocking.length) throw new EInvoiceRequirementsError(blocking)
const result = await embedZugferdXml(pdf, xml, { title: document.title || `Rechnung ${document.documentNumber}` })
return { pdf: result, issues, einvoice: true }
}

View File

@@ -1,6 +1,7 @@
import { XMLParser } from "fast-xml-parser"
import type { ElectronicInvoiceSyntax } from "./detectElectronicInvoice"
import type { ElectronicInvoiceItem, ParsedElectronicInvoice } from "./types"
import { parseCashDiscountPaymentTerms } from "../cash-discount"
const parser = new XMLParser({
ignoreAttributes: false,
@@ -83,7 +84,9 @@ const parseCii = (root: any): Omit<ParsedElectronicInvoice, "validation"> => {
})
const paymentMeans = array(settlement?.SpecifiedTradeSettlementPaymentMeans)[0] || {}
const paymentTerms = array(settlement?.SpecifiedTradePaymentTerms)[0] || {}
const paymentTermsList = array(settlement?.SpecifiedTradePaymentTerms)
const paymentTerms = paymentTermsList[0] || {}
const cashDiscount = ciiCashDiscount(paymentTermsList)
const guideline = root?.ExchangedDocumentContext?.GuidelineSpecifiedDocumentContextParameter?.ID
return {
@@ -103,6 +106,7 @@ const parseCii = (root: any): Omit<ParsedElectronicInvoice, "validation"> => {
},
buyerReference: text(agreement?.BuyerReference),
paymentMeansCode: text(paymentMeans?.TypeCode),
cashDiscount,
items,
totals: {
lineNet: number(monetary?.LineTotalAmount) != null ? roundMoney(sign * number(monetary.LineTotalAmount)!) : null,
@@ -113,6 +117,18 @@ const parseCii = (root: any): Omit<ParsedElectronicInvoice, "validation"> => {
}
}
// Skonto steht in XRechnung als "#SKONTO#TAGE=14#PROZENT=2.00#" im Zahlungsbedingungstext,
// in ZUGFeRD EXTENDED zusätzlich strukturiert als ApplicableTradePaymentDiscountTerms.
const ciiCashDiscount = (paymentTermsList: any[]) => {
for (const terms of paymentTermsList) {
const discountTerms = array(terms?.ApplicableTradePaymentDiscountTerms)[0]
const percent = number(discountTerms?.CalculationPercent)
const days = number(discountTerms?.BasisPeriodMeasure)
if (percent && percent > 0 && days !== null && Number.isInteger(days)) return { percent, days }
}
return parseCashDiscountPaymentTerms(paymentTermsList.flatMap((terms: any) => array(terms?.Description).map(text)).join("\n"))
}
const ublPartyName = (party: any) => text(party?.PartyLegalEntity?.RegistrationName) || text(party?.PartyName?.Name)
const parseUbl = (root: any, syntax: "ubl-invoice" | "ubl-credit-note"): Omit<ParsedElectronicInvoice, "validation"> => {
@@ -163,6 +179,7 @@ const parseUbl = (root: any, syntax: "ubl-invoice" | "ubl-credit-note"): Omit<Pa
},
buyerReference: text(root?.BuyerReference),
paymentMeansCode: text(paymentMeans?.PaymentMeansCode),
cashDiscount: parseCashDiscountPaymentTerms(array(root?.PaymentTerms).flatMap((terms: any) => array(terms?.Note).map(text)).join("\n")),
items,
totals: {
lineNet: number(monetary?.LineExtensionAmount) != null ? roundMoney(sign * number(monetary.LineExtensionAmount)!) : null,

View File

@@ -28,6 +28,7 @@ export type ParsedElectronicInvoice = {
}
buyerReference: string | null
paymentMeansCode: string | null
cashDiscount?: { percent: number, days: number } | null
items: ElectronicInvoiceItem[]
totals: {
lineNet: number | null

View File

@@ -0,0 +1,126 @@
import { and, eq, inArray } from "drizzle-orm"
import { FastifyInstance } from "fastify"
import { costcentres, incominginvoices } from "../../db/schema"
import { insertHistoryItem } from "../utils/history"
export type AccountCostCentreAssignment = {
index: number
costcentre: string | null
}
export type IncomingInvoiceCostCentreUpdate = {
accountCostCentres?: AccountCostCentreAssignment[]
}
const serviceError = (message: string, statusCode = 400) =>
Object.assign(new Error(message), { statusCode })
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
export function normalizeAccountCostCentreAssignments(input: unknown): AccountCostCentreAssignment[] {
if (!Array.isArray(input)) throw serviceError("accountCostCentres muss ein Array sein")
if (!input.length) throw serviceError("Mindestens eine Positionskostenstelle ist erforderlich")
const seen = new Set<number>()
return input.map((assignment) => {
const index = assignment?.index
const costcentre = assignment?.costcentre
if (!Number.isInteger(index) || index < 0) throw serviceError("Jede Positionszuordnung benötigt einen gültigen index")
if (seen.has(index)) throw serviceError(`Position ${index + 1} ist mehrfach angegeben`)
seen.add(index)
if (costcentre !== null && (typeof costcentre !== "string" || !UUID_PATTERN.test(costcentre))) {
throw serviceError(`Ungültige Kostenstelle für Position ${index + 1}`)
}
return { index, costcentre }
})
}
export function applyAccountCostCentreAssignments(
accounts: unknown,
assignments: AccountCostCentreAssignment[] = [],
) {
if (!Array.isArray(accounts)) throw serviceError("Der Eingangsbeleg enthält keine Positionen")
for (const { index } of assignments) {
const account = accounts[index]
if (!account || typeof account !== "object" || Array.isArray(account)) {
throw serviceError(`Position ${index + 1} wurde im Eingangsbeleg nicht gefunden`)
}
}
const assignmentMap = new Map(assignments.map((assignment) => [assignment.index, assignment.costcentre]))
return accounts.map((account, index) => {
if (!assignmentMap.has(index)) return account
const { costcentre: _legacyCostCentre, ...rest } = account as Record<string, unknown>
return { ...rest, costCentre: assignmentMap.get(index) ?? null }
})
}
export async function updateIncomingInvoiceCostCentres(
server: FastifyInstance,
tenantId: number,
userId: string,
invoiceId: number,
input: IncomingInvoiceCostCentreUpdate,
) {
const assignments = normalizeAccountCostCentreAssignments(input?.accountCostCentres)
const [existing] = await server.db
.select()
.from(incominginvoices)
.where(and(
eq(incominginvoices.id, invoiceId),
eq(incominginvoices.tenant, tenantId),
eq(incominginvoices.archived, false),
))
.limit(1)
if (!existing) throw serviceError("Eingangsbeleg nicht gefunden", 404)
const referencedCostCentreIds = [...new Set(
assignments.map((assignment) => assignment.costcentre).filter((id): id is string => Boolean(id)),
)]
if (referencedCostCentreIds.length) {
const validCostCentres = await server.db
.select({ id: costcentres.id })
.from(costcentres)
.where(and(
eq(costcentres.tenant, tenantId),
eq(costcentres.archived, false),
inArray(costcentres.id, referencedCostCentreIds),
))
if (validCostCentres.length !== referencedCostCentreIds.length) {
throw serviceError("Mindestens eine Kostenstelle wurde nicht gefunden oder ist archiviert")
}
}
const [updated] = await server.db
.update(incominginvoices)
.set({
accounts: applyAccountCostCentreAssignments(existing.accounts, assignments),
updatedAt: new Date(),
updatedBy: userId,
})
.where(and(
eq(incominginvoices.id, invoiceId),
eq(incominginvoices.tenant, tenantId),
eq(incominginvoices.archived, false),
))
.returning()
if (!updated) throw serviceError("Eingangsbeleg nicht gefunden", 404)
await insertHistoryItem(server, {
tenant_id: tenantId,
created_by: userId,
entity: "incominginvoices",
entityId: invoiceId,
action: "updated",
oldVal: existing,
newVal: updated,
text: "Kostenstellen des Eingangsbelegs geändert",
})
return updated
}

View File

@@ -0,0 +1,287 @@
import { FastifyInstance } from "fastify"
import dayjs from "dayjs"
import { and, eq, inArray } from "drizzle-orm"
import {
authProfiles,
bankaccounts,
contacts,
contracts,
createddocuments,
customers,
letterheads,
plants,
products,
projects,
services,
tenants,
units,
} from "../../db/schema"
import { documentTemplateHandlebars } from "../utils/handlebars"
import { createInvoicePDF, InvoicePdfLayout } from "../utils/pdf"
import { appendCashDiscountText, buildCashDiscountTerms, getCashDiscountTemplateContext } from "./cash-discount"
const quoteLikeDocumentTypes = ["quotes", "costEstimates"]
const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"]
const invoiceLikeDocumentTypes = ["invoices", "advanceInvoices", "serialInvoices", "cancellationInvoices"]
/** Same format as useCurrency in the frontend. */
export const renderCurrency = (value: any, currency = "€") =>
`${Number(value).toFixed(2).replace(".", ",")} ${currency}`.replace(/\B(?=(\d{3})+(?!\d))/g, ".")
const rowSum = (row: any) => Number(row.quantity) * Number(row.price) * (1 - Number(row.discountPercent) / 100)
export function documentNumberTitle(type: string) {
if (invoiceLikeDocumentTypes.includes(type)) return "Rechnungsnummer"
if (quoteLikeDocumentTypes.includes(type)) return type === "costEstimates" ? "Kostenschätzungsnummer" : "Angebotsnummer"
if (deliveryNoteLikeDocumentTypes.includes(type)) return type === "packingSlips" ? "Packscheinnummer" : "Lieferscheinnummer"
if (type === "confirmationOrders") return "Auftragsbestätigungsnr."
return "Belegnummer"
}
const formatNumberLikeValue = (value: any) => {
if (value === null || value === undefined || value === "") return "-"
if (typeof value === "string" || typeof value === "number") return String(value)
const directValue = value.value ?? value.number ?? value.currentNumber ?? value.customerNumber ?? value.id
if (directValue !== null && directValue !== undefined && directValue !== "") return String(directValue)
if (value.prefix !== undefined || value.nextNumber !== undefined || value.suffix !== undefined) {
return `${value.prefix || ""}${value.nextNumber || ""}${value.suffix || ""}` || "-"
}
return "-"
}
/** Port of documentTotal from the document editor. */
export function calculateOutgoingDocumentTotals(document: any, advanceInvoices: any[]) {
let totalNet = 0, total19 = 0, totalNet19 = 0, total7 = 0, totalNet7 = 0, totalNet0 = 0
const rows = document.rows || []
rows.filter((row: any) => !row.optional && !row.alternative).forEach((row: any) => {
if (["pagebreak", "title", "text"].includes(row.mode)) return
const rowPrice = Number(rowSum(row).toFixed(3))
totalNet += rowPrice
if (row.taxPercent === 19) { total19 += rowPrice * 0.19; totalNet19 += rowPrice }
else if (row.taxPercent === 7) { total7 += rowPrice * 0.07; totalNet7 += rowPrice }
else if (row.taxPercent === 0) totalNet0 += rowPrice
})
const titleSums: Record<string, number> = {}
const titleSumsTransfer: Record<string, number> = {}
let activeTitleKeys: string[] = []
let transferCounter = 0
rows.forEach((row: any) => {
if (row.mode === "title") {
const title = `${row.pos} - ${row.text}`
const titleLevel = Math.min(Math.max(Number(row.titleLevel) || 1, 1), 3)
titleSums[title] = 0
if (activeTitleKeys.some(Boolean)) titleSumsTransfer[title] = transferCounter
activeTitleKeys = activeTitleKeys.slice(0, titleLevel - 1)
activeTitleKeys[titleLevel - 1] = title
activeTitleKeys = activeTitleKeys.slice(0, titleLevel)
} else if (!["pagebreak", "text"].includes(row.mode) && activeTitleKeys.some(Boolean) && !row.optional && !row.alternative) {
const sum = rowSum(row)
activeTitleKeys.forEach((key) => { if (key) titleSums[key] = Number(titleSums[key]) + sum })
transferCounter += sum
}
})
const totalGross = Number(totalNet.toFixed(2)) + Number(total19.toFixed(2)) + Number(total7.toFixed(2))
const totalGrossAlreadyPaid = advanceInvoices.reduce((sum, invoice) => {
const advanceRow = (invoice?.rows || []).find((row: any) => row.advanceInvoiceData)
return advanceRow ? sum + advanceRow.price * ((100 + advanceRow.taxPercent) / 100) : sum
}, 0)
const totalSumToPay = document.type === "invoices" ? totalGross - totalGrossAlreadyPaid
: document.type === "cancellationInvoices" ? totalGross + totalGrossAlreadyPaid : 0
return { titleSums, titleSumsTransfer, totalNet, total19, totalNet19, total7, totalNet7, total0: 0, totalNet0, totalGross, totalGrossAlreadyPaid, totalSumToPay }
}
export type OutgoingDocumentPdfContext = {
tenant: any
customer: any
contact?: any
contactPerson?: any
plant?: any
project?: any
contract?: any
units: any[]
products: any[]
services: any[]
bankAccounts: any[]
advanceInvoices: any[]
}
/** Port of getDocumentData from the document editor, so MCP previews match the UI preview. */
export function buildOutgoingDocumentPdfData(source: any, data: OutgoingDocumentPdfContext) {
const document = structuredClone(source)
document.rows = document.rows || []
const address = document.address || {}
const customer = data.customer || {}
const contact = data.contact || null
const contactPerson = data.contactPerson || {}
const businessInfo = data.tenant.businessInfo || {}
const paymentQrBankAccount = data.bankAccounts.find((account) => !account.archived && !account.expired && account.iban)
const paymentQrIban = businessInfo.iban || paymentQrBankAccount?.iban
const paymentQrBic = businessInfo.bic || paymentQrBankAccount?.bic
const paymentQrRecipient = businessInfo.bankAccountOwner || paymentQrBankAccount?.ownerName || businessInfo.name || data.tenant.name
if ((data.tenant.extraModules || []).includes("agriculture")) {
document.rows.forEach((row: any) => {
if (row.agriculture?.dieselUsage) {
row.agriculture.description = `${row.agriculture.dieselUsage} L Diesel zu ${renderCurrency(row.agriculture.dieselPrice)}/L verbraucht ${row.description ? "\n" + row.description : ""}`
}
})
}
const totals = calculateOutgoingDocumentTotals(document, data.advanceInvoices)
// Skonto bezieht sich bei Schlussrechnungen auf die Restforderung nach Abzug der Abschläge.
const cashDiscountTerms = ["invoices", "advanceInvoices"].includes(document.type)
? buildCashDiscountTerms({
percent: document.cashDiscountPercent,
days: document.cashDiscountDays,
documentDate: document.documentDate,
amount: document.type === "invoices" ? totals.totalSumToPay : totals.totalGross,
})
: null
const rows = document.rows.map((row: any) => {
if (!["pagebreak", "title"].includes(row.mode)) {
if (row.agriculture?.description) row.descriptionText = row.agriculture.description
else if (row.description) row.descriptionText = row.description
else delete row.descriptionText
}
if (["pagebreak", "title", "text"].includes(row.mode)) return row
if (row.mode === "normal") row.text = data.products.find((item) => item.id === row.product)?.name || ""
if (row.mode === "service") row.text = data.services.find((item) => item.id === row.service)?.name || ""
return {
...row,
rowAmount: renderCurrency(rowSum(row)),
quantity: String(row.quantity).replace(".", ","),
unit: data.units.find((item) => item.id === row.unit)?.short || "",
pos: String(row.pos),
price: renderCurrency(row.price),
discountText: `(Rabatt: ${row.discountPercent} %)`,
}
})
const context = {
lohnkosten: document.report?.totalHoursSellingPrice ? renderCurrency(document.report.totalHoursSellingPrice) : null,
anrede: contact?.salutation || customer.salutation,
titel: contact?.title || customer.title,
vorname: contact?.firstName || customer.firstname,
nachname: contact?.lastName || customer.lastname,
kundenname: customer.name,
zahlungsziel_in_tagen: document.paymentDays,
zahlungsart: document.payment_type === "transfer" ? "Überweisung" : "Lastschrift",
diesel_gesamtverbrauch: document.agriculture?.dieselUsageTotal,
...getCashDiscountTemplateContext(cashDiscountTerms),
}
const formatDate = (value: any) => value ? dayjs(value).format("DD.MM.YYYY") : "XXXX"
const formatSums = (sums: Record<string, number>) => Object.fromEntries(Object.entries(sums).map(([key, value]) => [key, renderCurrency(value)]))
const recipientCountry = String(address.country ?? customer.infoData?.country ?? "").trim()
const contactTel = document.contactTel || contactPerson.fixed_tel || contactPerson.mobile_tel
return {
type: document.type,
taxType: document.taxType,
adressLine: `${businessInfo.name}, ${businessInfo.street}, ${businessInfo.zip} ${businessInfo.city}`,
recipient: [
customer.name,
...(customer.nameAddition ? [customer.nameAddition] : []),
...(contact ? [`${contact.firstName} ${contact.lastName}`] : []),
address.street,
...(address.special ? [address.special] : []),
`${address.zip} ${address.city}`,
...(recipientCountry && recipientCountry.toLocaleLowerCase("de") !== "deutschland" ? [recipientCountry] : []),
],
info: [
{ label: documentNumberTitle(document.type), content: document.documentNumber || "XXXX" },
{ label: "Kundennummer", content: formatNumberLikeValue(customer.customerNumber) },
{ label: "Belegdatum", content: formatDate(document.documentDate) },
...(document.deliveryDateType !== "Kein Lieferdatum anzeigen" ? [{
label: document.deliveryDateType,
content: !["Lieferzeitraum", "Leistungszeitraum"].includes(document.deliveryDateType)
? formatDate(document.deliveryDate)
: `${formatDate(document.deliveryDate)} - ${formatDate(document.deliveryDateEnd)}`,
}] : []),
{ label: "Ansprechpartner", content: contactPerson.full_name },
...(contactTel ? [{ label: "Telefon", content: contactTel }] : []),
...(contactPerson.email ? [{ label: "E-Mail", content: contactPerson.email }] : []),
...(document.plant ? [{ label: "Objekt", content: data.plant?.name || "Objekt nicht gefunden" }] : []),
...(document.project ? [{ label: "Projekt", content: data.project?.name || "Projekt nicht gefunden" }] : []),
...(document.contract ? [{ label: "Vertrag", content: data.contract?.contractNumber || "Vertrag nicht gefunden" }] : []),
],
title: document.title,
description: document.description,
endText: appendCashDiscountText(documentTemplateHandlebars.compile(document.endText || "")(context), document.endText, cashDiscountTerms),
startText: documentTemplateHandlebars.compile(document.startText || "")(context),
rows,
totalArray: [
{ label: "Nettobetrag", content: renderCurrency(totals.totalNet) },
...[19, 7, 0].flatMap((rate) => rows.find((row: any) => row.taxPercent === rate) && document.taxType !== "13b UStG" ? [{
label: `zzgl. ${rate}% USt auf ${renderCurrency((totals as any)[`totalNet${rate}`])}`,
content: renderCurrency((totals as any)[`total${rate}`]),
}] : []),
{ label: "Gesamtbetrag", content: renderCurrency(totals.totalGross) },
],
total: {
totalNet: renderCurrency(totals.totalNet),
total19: renderCurrency(totals.total19),
total0: renderCurrency(totals.total0),
totalGross: renderCurrency(totals.totalGross),
totalGrossAlreadyPaid: renderCurrency(totals.totalGrossAlreadyPaid),
totalSumToPay: renderCurrency(totals.totalSumToPay),
titleSums: formatSums(totals.titleSums),
titleSumsTransfer: formatSums(totals.titleSumsTransfer),
},
agriculture: document.agriculture,
usedAdvanceInvoices: data.advanceInvoices,
paymentQr: paymentQrIban && document.payment_type === "transfer" ? {
iban: paymentQrIban,
bic: paymentQrBic,
recipientName: paymentQrRecipient,
amount: totals.totalSumToPay,
remittance: document.documentNumber || document.title,
} : null,
}
}
const one = async (server: FastifyInstance, table: any, id: unknown, tenantId: number) => {
if (id === null || id === undefined) return null
const [row] = await server.db.select().from(table).where(and(eq(table.id, id as any), eq(table.tenant, tenantId))).limit(1)
return row || null
}
export async function renderOutgoingDocumentPdf(server: FastifyInstance, tenantId: number, document: any) {
if (!document.letterhead) throw Object.assign(new Error("Es ist kein Briefpapier ausgewählt"), { statusCode: 400 })
if (!document.customer) throw Object.assign(new Error("Es ist kein Kunde ausgewählt"), { statusCode: 400 })
const rows = document.rows || []
const productIds = [...new Set(rows.filter((row: any) => row.product).map((row: any) => Number(row.product)))] as number[]
const serviceIds = [...new Set(rows.filter((row: any) => row.service).map((row: any) => Number(row.service)))] as number[]
const advanceIds = (Array.isArray(document.usedAdvanceInvoices) ? document.usedAdvanceInvoices : []).map(Number) as number[]
const [[tenant], letterhead, customer, contact, plant, project, contract, unitRows, productRows, serviceRows, bankAccountRows, advanceInvoices, [contactPerson]] = await Promise.all([
server.db.select().from(tenants).where(eq(tenants.id, tenantId)).limit(1),
one(server, letterheads, document.letterhead, tenantId),
one(server, customers, document.customer, tenantId),
one(server, contacts, document.contact, tenantId),
one(server, plants, document.plant, tenantId),
one(server, projects, document.project, tenantId),
one(server, contracts, document.contract, tenantId),
server.db.select().from(units),
productIds.length ? server.db.select().from(products).where(and(eq(products.tenant, tenantId), inArray(products.id, productIds))) : [],
serviceIds.length ? server.db.select().from(services).where(and(eq(services.tenant, tenantId), inArray(services.id, serviceIds))) : [],
server.db.select().from(bankaccounts).where(and(eq(bankaccounts.tenant, tenantId), eq(bankaccounts.archived, false))),
advanceIds.length ? server.db.select().from(createddocuments).where(and(eq(createddocuments.tenant, tenantId), inArray(createddocuments.id, advanceIds))) : [],
document.created_by
? server.db.select().from(authProfiles).where(and(eq(authProfiles.user_id, document.created_by), eq(authProfiles.tenant_id, tenantId))).limit(1)
: [null],
])
if (!letterhead?.path) throw Object.assign(new Error("Briefpapier nicht gefunden"), { statusCode: 400 })
if (!customer) throw Object.assign(new Error("Kunde nicht gefunden"), { statusCode: 400 })
const pdfData = buildOutgoingDocumentPdfData(document, {
tenant, customer, contact, contactPerson, plant, project, contract,
units: unitRows, products: productRows, services: serviceRows, bankAccounts: bankAccountRows,
advanceInvoices: advanceIds.map((id) => advanceInvoices.find((invoice: any) => invoice.id === id)).filter(Boolean),
})
const layout: InvoicePdfLayout = { pageHeightMm: 297, rows: [], pageCount: 0 }
const pdf: any = await createInvoicePDF(server, "base64", pdfData, letterhead.path, layout)
return { pdf: Buffer.from(pdf.base64, "base64"), layout }
}

View File

@@ -9,7 +9,9 @@ import { saveFile } from "../utils/files";
import {FastifyInstance} from "fastify";
import {useNextNumberRangeNumber} from "../utils/functions";
import {createInvoicePDF} from "../utils/pdf"; // Achtung: Muss Node.js Buffer unterstützen!
import { assertOutgoingEInvoiceRequirements, attachZugferdToPdf, EInvoiceRequirementsError } from "./einvoice/outgoingZugferd.service";
import { documentTemplateHandlebars } from "../utils/handlebars";
import { appendCashDiscountText, buildCashDiscountTerms, getCashDiscountTemplateContext } from "./cash-discount"
dayjs.extend(quarterOfYear);
@@ -200,6 +202,7 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
let successCount = 0;
let errorCount = 0;
const einvoiceErrors: string[] = [];
// 5. Loop durch Dokumente
for (const doc of documents) {
@@ -215,6 +218,9 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
doc.letterhead ? fetchById(server, schema.letterheads, doc.letterhead) : null
]);
// Pflichtangaben vor der Nummernvergabe prüfen, damit keine Lücken im Nummernkreis entstehen
await assertOutgoingEInvoiceRequirements(server, tenantId, doc);
const pdfData = await getCloseData(
server,
doc,
@@ -232,8 +238,14 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
console.log(pdfBase64);
// E. Datei speichern
// E-Rechnung (ZUGFeRD) in die PDF einbetten; Grundlage ist der Beleg mit der neuen Nummer
const { pdf: fileBuffer } = await attachZugferdToPdf(
server,
tenantId,
{ ...doc, state: "Gebucht", documentNumber: pdfData.documentNumber, title: pdfData.title },
// @ts-ignore
const fileBuffer = Buffer.from(pdfBase64.base64, "base64");
Buffer.from(pdfBase64.base64, "base64"),
);
const filename = `${pdfData.documentNumber}.pdf`;
await saveFile(
@@ -266,6 +278,7 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
} catch (innerErr) {
console.error(`Fehler beim Finalisieren von Doc ID ${doc.id}:`, innerErr);
errorCount++;
if (innerErr instanceof EInvoiceRequirementsError) einvoiceErrors.push(`Beleg ${doc.id}: ${innerErr.message}`);
// Optional: Status des einzelnen Dokuments auf Error setzen
}
}
@@ -275,7 +288,7 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
const execution = await settleExecution(server, executionId, tenantId, finalStatus,
`Abgeschlossen: ${successCount} erfolgreich, ${errorCount} Fehler.`);
`Abgeschlossen: ${successCount} erfolgreich, ${errorCount} Fehler.${einvoiceErrors.length ? ` ${einvoiceErrors.join(" ")}` : ""}`);
return { success: execution.status === "completed", execution, processed: successCount, errors: errorCount };
} catch (error) {
await settleExecution(server, executionId, tenantId, "error", "Kritischer Fehler beim Finalisieren.");
@@ -421,6 +434,8 @@ async function getSaveData(item: any, tenant: any, firstDate: string, lastDate:
deliveryDate: firstDate,
deliveryDateEnd: lastDate,
paymentDays: item.paymentDays,
cashDiscountPercent: item.cashDiscountPercent,
cashDiscountDays: item.cashDiscountDays,
payment_type: item.payment_type,
deliveryDateType: item.deliveryDateType,
info: {}, // Achtung: Postgres erwartet hier valides JSON Objekt
@@ -646,6 +661,12 @@ export function getDocumentDataBackend(
// --- 5. Handlebars Context ---
const cashDiscountTerms = buildCashDiscountTerms({
percent: itemInfo.cashDiscountPercent,
days: itemInfo.cashDiscountDays,
documentDate: itemInfo.documentDate ? dayjs(itemInfo.documentDate).format("YYYY-MM-DD") : null,
amount: totals.totalGross,
});
const generateContext = () => {
return {
// lohnkosten: null, // Backend hat diesen Wert oft nicht direkt, ggf. aus itemInfo holen
@@ -656,7 +677,8 @@ export function getDocumentDataBackend(
kundenname: customerData && customerData.name,
zahlungsziel_in_tagen: itemInfo.paymentDays,
zahlungsart: itemInfo.payment_type === "transfer" ? "Überweisung" : "Lastschrift",
diesel_gesamtverbrauch: (itemInfo.agriculture && itemInfo.agriculture.dieselUsageTotal) || null
diesel_gesamtverbrauch: (itemInfo.agriculture && itemInfo.agriculture.dieselUsageTotal) || null,
...getCashDiscountTemplateContext(cashDiscountTerms),
};
};
@@ -756,7 +778,7 @@ export function getDocumentDataBackend(
title: itemInfo.title,
description: itemInfo.description,
// Handlebars Compilation ausführen
endText: templateEndText(generateContext()),
endText: appendCashDiscountText(templateEndText(generateContext()), itemInfo.endText, cashDiscountTerms),
startText: templateStartText(generateContext()),
rows: rows,
totalArray: totalArray,

View File

@@ -10,6 +10,8 @@ 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 { CASHBOOK_BANK_ID } from "../modules/bank-balances.service"
import { prepareCashDiscountAllocation, settledAllocationAmount } from "../modules/cash-discount"
import {
bankrequisitions,
@@ -33,9 +35,7 @@ import {
desc,
} from "drizzle-orm"
export default async function bankingRoutes(server: FastifyInstance) {
const CASHBOOK_BANK_ID = "fedeo-cashbook"
const ContraAccounts = aliasedTable(accounts, "contra_accounts")
const ContraCustomers = aliasedTable(customers, "contra_customers")
const ContraVendors = aliasedTable(vendors, "contra_vendors")
@@ -57,7 +57,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
eq(bankstatements.id, statementId),
eq(bankstatements.tenant, tenantId)
)).limit(1),
server.db.select({ amount: statementallocations.amount }).from(statementallocations).where(and(
server.db.select({ amount: statementallocations.amount, cashDiscountAmount: statementallocations.cashDiscountAmount }).from(statementallocations).where(and(
eq(statementallocations.incominginvoice, invoiceId),
eq(statementallocations.tenant, tenantId),
eq(statementallocations.archived, false)
@@ -78,10 +78,9 @@ export default async function bankingRoutes(server: FastifyInstance) {
const correctedTax = invoice.taxAmountOverride !== null && invoice.taxAmountOverride !== undefined && invoice.taxAmountOverride !== ""
? Number(invoice.taxAmountOverride)
: accountTotals.tax
const legacyRemaining = Math.max(0, Math.abs(accountTotals.net + accountTotals.tax)
- invoiceAllocations.reduce((sum, item) => sum + Math.abs(Number(item.amount || 0)), 0))
const correctedRemaining = Math.max(0, Math.abs(accountTotals.net + correctedTax)
- invoiceAllocations.reduce((sum, item) => sum + Math.abs(Number(item.amount || 0)), 0))
const invoiceSettled = invoiceAllocations.reduce((sum, item) => sum + Math.abs(settledAllocationAmount(item)), 0)
const legacyRemaining = Math.max(0, Math.abs(accountTotals.net + accountTotals.tax) - invoiceSettled)
const correctedRemaining = Math.max(0, Math.abs(accountTotals.net + correctedTax) - invoiceSettled)
const statementRemaining = Math.max(0, Math.abs(Number(statement.amount || 0))
- statementAllocations.reduce((sum, item) => sum + Math.abs(Number(item.amount || 0)), 0))
const requestedAmount = Number(allocation.amount || 0)
@@ -1169,7 +1168,10 @@ export default async function bankingRoutes(server: FastifyInstance) {
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)
if (!Number(prepared.data.cashDiscountAmount || 0)) {
prepared.data = await useCurrentIncomingInvoiceAmount(req.user.tenant_id, prepared.data)
}
prepared.data = await prepareCashDiscountAllocation(server.db, req.user.tenant_id, prepared.data)
const inserted = await server.db.insert(statementallocations).values({
...prepared.data,

View File

@@ -158,6 +158,16 @@ export default async function communicationRoutes(server: FastifyInstance) {
entityUuid: room.entityUuid,
exists: true,
})
// Rooms without messages fall back to their creation date so new rooms still sort by recency.
const withActivity = async <T extends { room: typeof communicationRooms.$inferSelect }>(rows: T[]) => {
const ids = rows.map(({ room }) => room.id)
const latest = ids.length ? await server.db.select({
roomId: communicationMessages.roomId,
at: sql<Date>`max(${communicationMessages.createdAt})`.mapWith(communicationMessages.createdAt),
}).from(communicationMessages).where(inArray(communicationMessages.roomId, ids)).groupBy(communicationMessages.roomId) : []
const byRoom = new Map(latest.map((row) => [row.roomId, row.at]))
return (room: typeof communicationRooms.$inferSelect) => (byRoom.get(room.id) || room.createdAt).toISOString()
}
const messagesForRoom = async (roomId: string, currentUserId: string, tenantId: number, afterId = 0, limit = 50) => {
const conditions = [eq(communicationMessages.roomId, roomId)]
@@ -295,7 +305,8 @@ export default async function communicationRoutes(server: FastifyInstance) {
eq(communicationRooms.archived, false),
eq(communicationRoomMembers.userId, req.user.user_id)
)).orderBy(asc(communicationRooms.name))
return { rooms: rows.map(({ room }) => roomToApi(room)) }
const activity = await withActivity(rows)
return { rooms: rows.map(({ room }) => ({ ...roomToApi(room), lastActivityAt: activity(room) })) }
})
server.post("/communication/chat/rooms", async (req: any, reply) => {
@@ -335,9 +346,10 @@ export default async function communicationRoutes(server: FastifyInstance) {
projectChatAccess(server.db, req),
])
const roomsByProject = new Map(roomRows.map(({ room }) => [room.entityId, room]))
const activity = await withActivity(roomRows)
return { rooms: projectRows.map((project) => {
const room = roomsByProject.get(project.id)
return room ? { ...roomToApi(room), projectId: project.id, projectNumber: project.projectNumber } : {
return room ? { ...roomToApi(room), lastActivityAt: activity(room), projectId: project.id, projectNumber: project.projectNumber } : {
key: `project_${project.id}`,
name: project.projectNumber ? `${project.projectNumber} · ${project.name}` : project.name,
topic: `Projektkommunikation zu ${project.name}`,
@@ -387,11 +399,13 @@ export default async function communicationRoutes(server: FastifyInstance) {
.innerJoin(communicationRoomMembers, eq(communicationRoomMembers.roomId, communicationRooms.id))
.where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.type, "direct"), eq(communicationRoomMembers.userId, req.user.user_id)))
const roomsByKey = new Map(existingRows.map(({ room }) => [room.key, room]))
const activity = await withActivity(existingRows)
return { rooms: users.map((user) => {
const key = directRoomKey(req.user.user_id, user.userId)
const room = roomsByKey.get(key)
return room ? {
...roomToApi(room),
lastActivityAt: activity(room),
name: displayName(user),
topic: `Direktnachricht mit ${displayName(user)}`,
entityUuid: user.userId,

View File

@@ -18,10 +18,12 @@ import isSameOrBefore from "dayjs/plugin/isSameOrBefore.js"
import duration from "dayjs/plugin/duration.js";
import timezone from "dayjs/plugin/timezone.js";
import {generateTimesEvaluation} from "../modules/time/evaluation.service";
import {citys, files} from "../../db/schema";
import {citys, createddocuments, files} from "../../db/schema";
import { attachZugferdToPdf, checkOutgoingEInvoice, EInvoiceRequirementsError } from "../modules/einvoice/outgoingZugferd.service";
import {and, eq, isNull, not} from "drizzle-orm";
import {cancelManualGeneration, 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 { s3 } from "../utils/s3";
import { secrets } from "../utils/secrets";
import { storeExtractedTextForFile } from "../utils/documentText";
@@ -109,6 +111,8 @@ export default async function functionRoutes(server: FastifyInstance) {
const body = req.body as {
data: any
backgroundPath?: string
// Gespeicherter Beleg, aus dem die eingebettete E-Rechnung (ZUGFeRD) erzeugt wird
createdDocumentId?: number
}
const {type} = req.params as {type:string}
@@ -124,6 +128,17 @@ export default async function functionRoutes(server: FastifyInstance) {
body.data,
body.backgroundPath
)
if (body.createdDocumentId) {
const tenantId = (req as any).user.tenant_id
const [document] = await server.db
.select()
.from(createddocuments)
.where(and(eq(createddocuments.id, Number(body.createdDocumentId)), eq(createddocuments.tenant, tenantId)))
.limit(1)
if (!document) return reply.code(404).send({ error: "Beleg nicht gefunden" })
const result = await attachZugferdToPdf(server, tenantId, document, Buffer.from(pdf.base64, "base64"))
pdf = { ...pdf, base64: result.pdf.toString("base64"), einvoice: result.einvoice, einvoiceIssues: result.issues }
}
} else if(type === "timesheet") {
pdf = await createTimeSheetPDF(
server,
@@ -135,6 +150,9 @@ export default async function functionRoutes(server: FastifyInstance) {
return pdf // Fastify wandelt automatisch in JSON
} catch (err) {
if (err instanceof EInvoiceRequirementsError) {
return reply.code(422).send({ error: err.message, code: err.code, issues: err.issues })
}
console.log(err)
const unsupportedCharacterMessage = getUnsupportedPdfCharacterMessage(err, body.data)
if (unsupportedCharacterMessage) {
@@ -148,6 +166,14 @@ export default async function functionRoutes(server: FastifyInstance) {
}
})
// Prüft die Pflichtangaben der E-Rechnung für den aktuellen (ggf. ungespeicherten) Stand aus dem Editor
server.post("/functions/einvoice/check", async (req, reply) => {
const { document } = (req.body || {}) as { document?: any }
if (!document || typeof document !== "object") return reply.code(400).send({ error: "document fehlt" })
const issues = await checkOutgoingEInvoice(server, (req as any).user.tenant_id, document)
return { issues }
})
server.get("/functions/usenextnumber/:numberrange", async (req, reply) => {
const { numberrange } = req.params as { numberrange: string };
const tenant = (req as any).user.tenant_id
@@ -334,6 +360,26 @@ export default async function functionRoutes(server: FastifyInstance) {
}
})
server.put('/functions/incoming-invoices/:id/cost-centres', async (req, reply) => {
try {
const { id } = req.params as { id: string }
const invoiceId = Number(id)
if (!Number.isFinite(invoiceId)) return reply.code(400).send({ error: "Ungültige Eingangsbeleg-ID" })
const invoice = await updateIncomingInvoiceCostCentres(
server,
req.user.tenant_id,
req.user.user_id,
invoiceId,
req.body as any,
)
return { invoice }
} catch (error) {
const statusCode = (error as any)?.statusCode || 500
return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Kostenstellen konnten nicht geändert werden" })
}
})
server.post('/functions/services/bankstatementsync', async (req, reply) => {
const result = await server.services.bankStatements.run(req.user.tenant_id);
if (result.errors.length > 0) {
@@ -348,8 +394,11 @@ export default async function functionRoutes(server: FastifyInstance) {
})
server.post('/functions/services/prepareincominginvoices', async (req, reply) => {
return reply.code(202).send(server.services.prepareIncomingInvoices.start(req.user.tenant_id))
})
await server.services.prepareIncomingInvoices.run(req.user.tenant_id)
server.get('/functions/services/prepareincominginvoices', async (req) => {
return server.services.prepareIncomingInvoices.status(req.user.tenant_id)
})
server.get('/functions/liquidity-forecast', async (req, reply) => {

View File

@@ -11,6 +11,11 @@ import { s3 } from "../utils/s3"
import { secrets } from "../utils/secrets"
import { GetObjectCommand } from "@aws-sdk/client-s3"
import { sanitizeFilename } from "../utils/filename"
import multipart from "@fastify/multipart"
import { MCP_FILE_LIMIT } from "../mcp/documentFiles"
import { parseUploadQuery, storeMcpFileUpload } from "../mcp/fileUploads"
const RAW_UPLOAD_TYPES = ["application/pdf", "application/octet-stream", "application/xml", "text/xml", "image/jpeg", "image/png", "image/tiff"]
const SUPPORTED_PROTOCOL_VERSIONS = [
"2025-11-25",
@@ -62,7 +67,13 @@ const requireMcpTokenManagementPermission = (req: any) => {
throw Object.assign(new Error("Fehlende Berechtigung: mcp.tokens.write"), { statusCode: 403 })
}
export default async function mcpRoutes(server: FastifyInstance) {
type McpRouteOptions = {
/** Replaceable in tests to avoid object storage. */
storeUpload?: typeof storeMcpFileUpload
}
export default async function mcpRoutes(server: FastifyInstance, options: McpRouteOptions = {}) {
const storeUpload = options.storeUpload || storeMcpFileUpload
server.get("/mcp/tokens", async (req, reply) => {
try {
requireMcpTokenManagementPermission(req)
@@ -221,6 +232,37 @@ export default async function mcpRoutes(server: FastifyInstance) {
}
})
// Binary uploads without Base64; parsers stay scoped so JSON-RPC parsing is unaffected.
await server.register(async (uploads) => {
await uploads.register(multipart, { limits: { fileSize: MCP_FILE_LIMIT, files: 1 } })
uploads.addContentTypeParser(RAW_UPLOAD_TYPES, { parseAs: "buffer", bodyLimit: MCP_FILE_LIMIT }, (_req, body, done) => done(null, body))
uploads.post("/mcp/files", async (req, reply) => {
try {
const context = await createMcpContext(server, req)
const query = parseUploadQuery(req.query as Record<string, unknown>)
let body: Buffer
let filename = query.filename
let mimeType = req.headers["content-type"]?.split(";")[0].trim() || null
if (req.isMultipart()) {
const part = await req.file()
if (!part || part.fieldname !== "file") return reply.code(400).send({ error: "Bitte die Datei im Feld file übertragen." })
body = await part.toBuffer()
filename ||= part.filename
mimeType = part.mimetype
} else if (Buffer.isBuffer(req.body)) {
body = req.body
} else {
return reply.code(415).send({ error: `Bitte multipart/form-data oder einen der Content-Types ${RAW_UPLOAD_TYPES.join(", ")} verwenden.` })
}
if (!filename) return reply.code(400).send({ error: "filename ist erforderlich." })
return await storeUpload(context, { ...query, filename, body, mimeType })
} catch (error) {
return reply.code((error as any)?.statusCode || 400).send({ error: error instanceof Error ? error.message : "Upload fehlgeschlagen." })
}
})
})
server.post("/mcp", { bodyLimit: MCP_BODY_LIMIT }, async (req, reply) => {
const body = req.body as JsonRpcRequest | JsonRpcRequest[]
const requests = Array.isArray(body) ? body : [body]
@@ -270,7 +312,7 @@ export default async function mcpRoutes(server: FastifyInstance) {
description: tool.description,
inputSchema: tool.inputSchema,
annotations: {
readOnlyHint: !tool.requiredPermissions.some((permission) => permission.endsWith(".write")),
readOnlyHint: tool.readOnly ?? !tool.requiredPermissions.some((permission) => permission.endsWith(".write")),
},
})),
}))

View File

@@ -507,6 +507,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
id: statementallocations.id,
createddocument: statementallocations.createddocument,
amount: statementallocations.amount,
cashDiscountAmount: statementallocations.cashDiscountAmount,
})
.from(statementallocations)
.where(and(

View File

@@ -15,6 +15,7 @@ import {and, desc, eq, inArray, isNull} from "drizzle-orm"
import { enrichProfilesWithBranches } from "../utils/profileBranches"
import { enrichProfilesWithTeams } from "../utils/profileTeams"
import { rotateRefreshToken } from "../utils/authTokens"
import { normalizeCashDiscountAccountsInput } from "../modules/cash-discount"
export default async function tenantRoutes(server: FastifyInstance) {
@@ -287,6 +288,14 @@ export default async function tenantRoutes(server: FastifyInstance) {
delete data.portalConfig.primayColor
}
if (Object.prototype.hasOwnProperty.call(data, "cashDiscountAccounts")) {
try {
data.cashDiscountAccounts = normalizeCashDiscountAccountsInput(data.cashDiscountAccounts)
} catch (err: any) {
return reply.code(400).send({ error: err.message })
}
}
const updated = await server.db
.update(tenants)
.set(data)

View File

@@ -104,6 +104,51 @@ export const getBankAccountBookingLine = (alloc: any) => {
return columns.join(";");
};
// Skonto als eigene Buchung je Steuersatz: Skontokonto (Automatikkonto) gegen Debitor bzw. Kreditor.
// Positiver Skonto (Zahlungseingang) steht im Soll des Skontokontos, negativer (Zahlungsausgang) im Haben.
export const getCashDiscountBookingLines = (alloc: any) => {
const split = Array.isArray(alloc?.cashDiscountSplit) ? alloc.cashDiscountSplit : [];
if (!split.length) return [];
const bs = alloc.bankstatement;
const cd = alloc.createddocument;
const ii = alloc.incominginvoice;
const partner = cd ? cd.customer : ii?.vendor;
const partnerNumber = cd ? partner?.customerNumber : partner?.vendorNumber;
const reference = cd ? cd.documentNumber : ii?.reference;
const prefix = cd ? "Skonto AR" : "Skonto ER";
const date = bs?.date || alloc.manualBookingDate;
const quote = (value: any) => `"${escapeString(String(value ?? ""))}"`;
return split
.filter((item: any) => Number(item?.gross) && item?.account)
.map((item: any) => {
const columns = Array(124).fill("");
columns[0] = displayCurrency(Number(item.gross), true);
columns[1] = quote(Number(item.gross) > 0 ? "S" : "H");
columns[6] = item.account;
columns[7] = partnerNumber || "";
columns[8] = quote("");
columns[9] = formatDatevDate(date, "DDMM");
columns[10] = quote(reference || "");
columns[13] = quote(`${prefix} ${reference || ""} ${item.rate} % - ${partner?.name || ""}`.substring(0, 59));
columns[20] = quote("Geschäftspartner");
columns[21] = quote(partner?.name || "");
columns[22] = quote("Kundennummer");
columns[23] = quote(partnerNumber || "");
columns[24] = quote("Belegnummer");
columns[25] = quote(reference || "");
columns[28] = quote("Belegdatum");
columns[29] = quote(formatDatevDate(date, "DD.MM.YYYY"));
if (bs?.id) {
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;
@@ -479,6 +524,10 @@ export async function buildExportZip(
const ii = alloc.incominginvoice;
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;;;;"";;;;;;`);
}
if((alloc.createddocument && alloc.createddocument.customer) || (alloc.incominginvoice && alloc.incominginvoice.vendor)) {
bookingLines.push(...getCashDiscountBookingLines(alloc));
} else if(alloc.account) {
bookingLines.push(getBankAccountBookingLine(alloc));
} else if(alloc.vendor) {

View File

@@ -74,6 +74,9 @@ const InstructionFormat = z.object({
tax: nullableNumber,
total: nullableNumber,
terms: nullableString,
cash_discount_percent: nullableNumber,
cash_discount_days: nullableNumber,
cash_discount_due_date: nullableString,
});
// ---------------------------------------------------------
@@ -190,6 +193,7 @@ export const getInvoiceDataFromGPT = async function (
"Do not merge summary totals into fabricated invoice_items.\n" +
"Match issuer by name to vendor.id.\n" +
"Match invoice items to account id based on label/number.\n" +
"Set cash_discount_percent, cash_discount_days and cash_discount_due_date only when the invoice explicitly offers Skonto/cash discount (e.g. '2 % Skonto bei Zahlung innerhalb 10 Tagen'); otherwise null.\n" +
"Convert dates to YYYY-MM-DD.\n" +
"Keep invoice items in original order.\n",
},

View File

@@ -50,7 +50,7 @@ export async function insertHistoryItem(
created_by: string | null
entity: string
entityId: string | number
action: "created" | "updated" | "unchanged" | "deleted" | "archived"
action: "created" | "updated" | "unchanged" | "deleted" | "archived" | "restored"
oldVal?: any
newVal?: any
text?: string
@@ -62,6 +62,7 @@ export async function insertHistoryItem(
updated: `Eintrag in ${entityLabel} geändert`,
unchanged: `Eintrag in ${entityLabel} unverändert`,
archived: `Eintrag in ${entityLabel} archiviert`,
restored: `Eintrag in ${entityLabel} wiederhergestellt`,
deleted: `Eintrag in ${entityLabel} gelöscht`
}

View File

@@ -685,14 +685,14 @@ export const generateLiquidityForecast = async (
if (allocation.createddocument) {
allocationByDocument.set(
allocation.createddocument,
roundMoney((allocationByDocument.get(allocation.createddocument) || 0) + Number(allocation.amount || 0))
roundMoney((allocationByDocument.get(allocation.createddocument) || 0) + Number(allocation.amount || 0) + Number(allocation.cashDiscountAmount || 0))
);
}
if (allocation.incominginvoice) {
allocationByIncomingInvoice.set(
allocation.incominginvoice,
roundMoney((allocationByIncomingInvoice.get(allocation.incominginvoice) || 0) + Number(allocation.amount || 0))
roundMoney((allocationByIncomingInvoice.get(allocation.incominginvoice) || 0) + Number(allocation.amount || 0) + Number(allocation.cashDiscountAmount || 0))
);
}
});

View File

@@ -117,7 +117,17 @@ const getDuration = (time) => {
}
export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoiceData, backgroundPath:string) => {
/** Positions in mm from the top edge, recorded while rendering for layout checks (e.g. page breaks). */
export type InvoicePdfLayout = {
pageHeightMm: number
rows: Array<{ index: number, id?: string, pos?: string, mode: string, text: string, page: number, topMm: number, bottomMm: number }>
totals?: { page: number, topMm: number, bottomMm: number }
endText?: { page: number, topMm: number, bottomMm: number }
paymentQr?: { page: number, topMm: number, leftMm: number }
pageCount: number
}
export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoiceData, backgroundPath:string, layout?: InvoicePdfLayout) => {
const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"]
const isPackingSlip = invoiceData?.type === "packingSlips"
@@ -153,8 +163,14 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
- paymentQrLayout.sizeMm * mm
}
// Top edge (mm from the top) of the QR label at its fixed bottom-right spot.
const getPaymentQrTopMm = (page: any) => page.getHeight() / mm
- (paymentQrLayout.bottomMarginMm + paymentQrLayout.sizeMm + paymentQrLayout.labelGapMm) - 8 / mm
// Set once the amounts are placed: the QR moves to its own page if it would cover them.
let paymentQrOnNewPage = false
const getTextWidthBeforePaymentQr = (page: any, startX: number) => {
if (!paymentQrCode) return 500
if (!paymentQrCode || paymentQrOnNewPage) return 500
return Math.max(
240,
@@ -496,6 +512,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
}
invoiceData.rows.forEach((row, index) => {
const layoutTop = rowHeight
if (!["pagebreak", "title", "text"].includes(row.mode)) {
@@ -881,10 +898,18 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
}
}
console.log(rowHeight)
layout?.rows.push({
index, id: row.id, pos: row.pos, mode: row.mode, text: String(row.text || "").slice(0, 80),
// rowHeight is the baseline of the first text line; shift to the visible cap height.
page: pageCounter, topMm: row.mode === "pagebreak" ? 30 : layoutTop - 3, bottomMm: row.mode === "pagebreak" ? 30 : rowHeight - 3,
})
})
let endTextDiff = 35
const layoutContentEnd = rowHeight
// Visible bottom of the last row (rowHeight is the next baseline); totals extend it below.
let amountsBottomMm = rowHeight - 3
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
pages[pageCounter - 1].drawLine({
@@ -999,8 +1024,23 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
}
// Totals are baselines 8 mm apart; tax notes (13b/19/12.3) add one more line below.
const totalLines = invoiceData.totalArray.length + (["13b UStG", "19 UStG", "12.3 UStG"].includes(invoiceData.taxType) ? 1 : 0)
amountsBottomMm = rowHeight + (totalLines - 1) * 8 + 1
paymentQrOnNewPage = Boolean(paymentQrCode) && amountsBottomMm + 2 > getPaymentQrTopMm(pages[pageCounter - 1])
const endTextPosition = getCoordinatesForPDFLib(21, rowHeight + endTextDiff + (invoiceData.totalArray.length - 3) * 8, page1)
if (layout) {
const maxWidth = getTextWidthBeforePaymentQr(pages[pageCounter - 1], endTextPosition.x)
const lines = String(invoiceData.endText || "").split("\n")
.reduce((sum, line) => sum + Math.max(1, Math.ceil(font.widthOfTextAtSize(line, 10) / maxWidth)), 0)
const topMm = rowHeight + endTextDiff + (invoiceData.totalArray.length - 3) * 8
layout.totals = { page: pageCounter, topMm: layoutContentEnd, bottomMm: amountsBottomMm }
// drawText places the baseline of the first line; 10 pt line height ≈ 3.53 mm.
layout.endText = { page: pageCounter, topMm: topMm - 3.53, bottomMm: topMm + (lines - 1) * 3.53 + 1 }
}
pages[pageCounter - 1].drawText(invoiceData.endText, {
...endTextPosition,
size: 10,
@@ -1012,6 +1052,16 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
}
if (paymentQrCode) {
// Invoice-like documents decide this before placing the end text.
if (deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
paymentQrOnNewPage = amountsBottomMm + 2 > getPaymentQrTopMm(pages[pages.length - 1])
}
if (paymentQrOnNewPage) {
// Positions or totals reach the QR area; never draw the code over amounts.
const qrPage = pdfDoc.addPage()
qrPage.drawPage(secondPageBackground, { x: 0, y: 0 })
pages.push(qrPage)
}
const lastPage = pages[pages.length - 1]
const qrSize = paymentQrLayout.sizeMm * mm
const bottomMargin = paymentQrLayout.bottomMarginMm * mm
@@ -1030,6 +1080,12 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
font: fontBold,
})
if (layout) layout.paymentQr = {
page: pages.length,
topMm: (lastPage.getHeight() - (qrY + qrSize + labelGap + 8)) / mm,
leftMm: qrX / mm,
}
lastPage.drawImage(paymentQrCode, {
x: qrX,
y: qrY,
@@ -1038,6 +1094,11 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
})
}
if (layout) {
layout.pageCount = pages.length
layout.pageHeightMm = page1.getHeight() / mm
}
return await pdfDoc.saveAsBase64()
}

View File

@@ -0,0 +1,30 @@
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { execFile } from "node:child_process"
import { promisify } from "node:util"
const execFileAsync = promisify(execFile)
/** Renders selected PDF pages (1-based) to PNG with poppler's pdftoppm. */
export async function renderPdfPagesAsPng(pdf: Buffer, pages: number[], dpi: number) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "fedeo-preview-"))
try {
const input = path.join(dir, "document.pdf")
await fs.writeFile(input, pdf)
const images: Array<{ page: number, png: Buffer }> = []
for (const page of pages) {
const prefix = path.join(dir, `page-${page}`)
try {
await execFileAsync("pdftoppm", ["-png", "-r", String(dpi), "-f", String(page), "-l", String(page), "-singlefile", input, prefix], { timeout: 30_000 })
} catch (error: any) {
if (error?.code === "ENOENT") throw Object.assign(new Error("Die Seitenvorschau benötigt pdftoppm (poppler-utils) auf dem Server."), { statusCode: 501 })
throw error
}
images.push({ page, png: await fs.readFile(`${prefix}.png`) })
}
return images
} finally {
await fs.rm(dir, { recursive: true, force: true })
}
}

View File

@@ -0,0 +1,24 @@
import assert from "node:assert/strict"
import test from "node:test"
import { calculateAccountBalance, CASHBOOK_BANK_ID } from "../src/modules/bank-balances.service"
const syncedAt = new Date("2026-10-20T08:00:00Z")
const bank = { id: 1, name: "Giro", iban: "DE00", ownerName: null, bankId: "bank", datevNumber: "1200", balance: 1000.1, expired: false, syncedAt }
const cashbook = { ...bank, id: 2, name: "Kasse", iban: "CASH-1", bankId: CASHBOOK_BANK_ID, datevNumber: "1000", balance: 50 }
test("bank accounts report the synced balance and revert later statements for a past date", () => {
assert.equal(calculateAccountBalance(bank, 0, null).balance, 1000.1)
assert.equal(calculateAccountBalance(bank, 0, null).source, "bank_sync")
const past = calculateAccountBalance(bank, -200.05, "2026-10-01")
assert.equal(past.balance, 1200.15)
assert.equal(past.syncedBalance, 1000.1)
assert.equal(past.source, "bank_sync_reconstructed")
})
test("cashbooks add their bookings to the opening balance", () => {
const result = calculateAccountBalance(cashbook, 12.345, null)
assert.equal(result.type, "cashbook")
assert.equal(result.balance, 62.35)
assert.equal(result.openingBalance, 50)
assert.equal(result.iban, null)
})

View File

@@ -0,0 +1,141 @@
import assert from "node:assert/strict"
import test from "node:test"
import {
getCreatedDocumentGrossByRate,
getCreatedDocumentGrossTotal,
getIncomingInvoiceGrossByRate,
normalizeCashDiscountAccountsInput,
resolveCashDiscountAccounts,
splitCashDiscount,
validateCashDiscount,
} from "../src/modules/cash-discount"
import { getCashDiscountBookingLines } from "../src/utils/export/datev"
import { prepareStatementAllocationInput } from "../src/mcp/statementAllocations"
const defaults = resolveCashDiscountAccounts({})
test("splits incoming invoice discount by tax rate onto received discount accounts", () => {
const gross = getIncomingInvoiceGrossByRate({
expense: true,
accounts: [
{ taxType: "19", amountNet: 100, amountTax: 19 },
{ taxType: "7", amountNet: 100, amountTax: 7 },
],
})
assert.deepEqual(gross, { "19": -119, "7": -107, "0": 0 })
const split = splitCashDiscount(-4.52, gross, defaults.received)
assert.deepEqual(split, [
{ rate: 19, account: "3736", gross: -2.38, net: -2, tax: -0.38 },
{ rate: 7, account: "3731", gross: -2.14, net: -2, tax: -0.14 },
])
})
test("assigns rounding differences to the largest share", () => {
const split = splitCashDiscount(1, { "19": 1, "7": 1, "0": 1 }, defaults.granted)
assert.equal(split.reduce((sum, item) => sum + item.gross, 0).toFixed(2), "1.00")
assert.equal(split.length, 3)
})
test("rejects discounts on intra-community acquisitions", () => {
assert.throws(
() => getIncomingInvoiceGrossByRate({ accounts: [{ taxType: "19I", amountNet: 100, amountTax: 0 }] }),
/manuell buchen/,
)
})
test("final invoices spread discounts over the remaining claim after advance invoices", () => {
const advance = { id: 7, rows: [{ advanceInvoiceData: true, price: 1000, taxPercent: 19 }] }
const final = {
id: 8,
usedAdvanceInvoices: [7],
rows: [
{ mode: "normal", quantity: 1, price: 2000, discountPercent: 0, taxPercent: 19 },
{ mode: "normal", quantity: 1, price: 1000, discountPercent: 0, taxPercent: 7 },
],
}
assert.deepEqual(getCreatedDocumentGrossByRate(final, [advance]), { "19": 1190, "7": 1070, "0": 0 })
assert.equal(getCreatedDocumentGrossTotal(final, [advance]), 2260)
})
test("tax exempt small business invoices use the account without tax", () => {
const gross = getCreatedDocumentGrossByRate({
taxType: "19 UStG",
rows: [{ mode: "normal", quantity: 1, price: 100, discountPercent: 0, taxPercent: 19 }],
})
assert.deepEqual(gross, { "19": 0, "7": 0, "0": 100 })
assert.deepEqual(splitCashDiscount(2, gross, defaults.granted), [{ rate: 0, account: "8730", gross: 2, net: 2, tax: 0 }])
})
test("validates payment plus discount against the open amount", () => {
assert.doesNotThrow(() => validateCashDiscount({ amount: 98, discount: 2, total: 100, settled: 0 }))
assert.doesNotThrow(() => validateCashDiscount({ amount: -98, discount: -2, total: -100, settled: 0 }))
assert.throws(() => validateCashDiscount({ amount: 99, discount: 2, total: 100, settled: 0 }), /offenen Betrag/)
assert.throws(() => validateCashDiscount({ amount: 50, discount: 2, total: 100, settled: 50 }), /offenen Betrag/)
assert.throws(() => validateCashDiscount({ amount: 80, discount: 20, total: 100, settled: 0 }), /höher als 10 %/)
assert.throws(() => validateCashDiscount({ amount: -98, discount: -2, total: 100, settled: 0 }), /Richtung/)
})
test("normalizes configured discount accounts and keeps SKR03 defaults", () => {
const input = normalizeCashDiscountAccountsInput({ received: { "19": " 3737 ", "7": "" } })
assert.deepEqual(input, { received: { "19": "3737" } })
assert.equal(resolveCashDiscountAccounts(input).received["19"], "3737")
assert.equal(resolveCashDiscountAccounts(input).received["7"], "3731")
assert.throws(() => normalizeCashDiscountAccountsInput({ granted: { "19": "87A6" } }), /Ziffern/)
})
test("exports discount bookings per tax rate against the business partner", () => {
const lines = getCashDiscountBookingLines({
bankstatement: { id: 42, date: "2026-10-05" },
incominginvoice: { reference: "RE-1", vendor: { vendorNumber: "70001", name: "Lieferant" } },
cashDiscountSplit: [
{ rate: 19, account: "3736", gross: -2.38, net: -2, tax: -0.38 },
{ rate: 7, account: "3731", gross: -2.14, net: -2, tax: -0.14 },
],
}).map(line => line.split(";"))
assert.equal(lines.length, 2)
assert.equal(lines[0].length, 124)
assert.deepEqual([lines[0][0], lines[0][1], lines[0][6], lines[0][7], lines[0][9], lines[0][10]],
["2,38", '"H"', "3736", "70001", "0510", '"RE-1"'])
const [granted] = getCashDiscountBookingLines({
bankstatement: { id: 43, date: "2026-10-05" },
createddocument: { documentNumber: "RE-100", customer: { customerNumber: "10001", name: "Kunde" } },
cashDiscountSplit: [{ rate: 19, account: "8736", gross: 2.38, net: 2, tax: 0.38 }],
}).map(line => line.split(";"))
assert.deepEqual([granted[0], granted[1], granted[6], granted[7]], ["2,38", '"S"', "8736", "10001"])
})
test("MCP allocation input accepts cash discounts only with bank statements", () => {
const payload = prepareStatementAllocationInput({ bankstatement: 1, createddocument: 2, amount: 98, cashDiscountAmount: 2 })
assert.equal(payload.cashDiscountAmount, 2)
assert.throws(() => prepareStatementAllocationInput({
amount: 98, cashDiscountAmount: 2, manualBookingDate: "2026-10-01", account: 1, contraAccount: 2,
}), /Bankzahlung/)
})
test("builds cash discount terms and the PDF sentence", async () => {
const { appendCashDiscountText, buildCashDiscountTerms, formatCashDiscountText, getCashDiscountTemplateContext, parseCashDiscountPaymentTerms } = await import("../src/modules/cash-discount")
const terms = buildCashDiscountTerms({ percent: 2, days: 10, documentDate: "2026-11-04", amount: 1190 })
assert.deepEqual(terms, { percent: 2, days: 10, dueDate: "2026-11-14", discount: 23.8, payable: 1166.2 })
assert.equal(formatCashDiscountText(terms).replace(/ /g, " "),
"Bei Zahlung bis zum 14.11.2026 gewähren wir 2 % Skonto (23,80 €), Zahlbetrag 1.166,20 €.")
assert.equal(getCashDiscountTemplateContext(terms).skonto_datum, "14.11.2026")
assert.match(appendCashDiscountText("Vielen Dank.", "Vielen Dank.", terms), /^Bei Zahlung .*\n\nVielen Dank\.$/)
assert.equal(appendCashDiscountText("Eigener Text", "{{skonto_hinweis}}", terms), "Eigener Text")
assert.equal(buildCashDiscountTerms({ percent: null, days: 10, documentDate: "2026-11-04", amount: 100 }), null)
assert.deepEqual(parseCashDiscountPaymentTerms("#SKONTO#TAGE=7#PROZENT=3.00#\n#SKONTO#TAGE=14#PROZENT=2.00#"), { percent: 3, days: 7 })
assert.deepEqual(parseCashDiscountPaymentTerms("#SKONTO#TAGE=14#PROZENT=2,5#BASISBETRAG=100.00#"), { percent: 2.5, days: 14 })
assert.equal(parseCashDiscountPaymentTerms("Zahlbar ohne Abzug"), null)
})
test("maps explicit AI cash discount data onto incoming invoices", async () => {
const { mapGptDataToIncomingInvoice } = await import("../src/modules/cron/prepareIncomingInvoices")
const mapped = mapGptDataToIncomingInvoice({ invoice_date: "2026-11-01", cash_discount_percent: 2, cash_discount_days: 10 }, 1)
assert.equal(mapped.cashDiscountPercent, 2)
assert.equal(mapped.cashDiscountDueDate, "2026-11-11")
const ignored = mapGptDataToIncomingInvoice({ invoice_date: "2026-11-01", cash_discount_percent: 25 }, 1)
assert.equal(ignored.cashDiscountPercent, undefined)
})

View File

@@ -66,3 +66,27 @@ test("markiert Summenabweichungen als Prüfungsfehler", () => {
assert.match(parsed.validation.errors.join(" "), /Positionssumme/)
})
test("reads XRechnung cash discount terms from CII payment terms", async () => {
const xml = Buffer.from(ciiInvoice.toString().replace(
"<ram:SpecifiedTradePaymentTerms>",
"<ram:SpecifiedTradePaymentTerms><ram:Description>#SKONTO#TAGE=10#PROZENT=2.00#\n</ram:Description>",
))
const detection = await detectElectronicInvoice(xml, { name: "rechnung.xml" })
const parsed = parseElectronicInvoice(detection!.xml, detection!.syntax)
assert.deepEqual(parsed.cashDiscount, { percent: 2, days: 10 })
const mapped = mapElectronicInvoiceToIncomingInvoice(parsed, 1, 5, "xml", null)
assert.equal(mapped.cashDiscountPercent, 2)
assert.equal(mapped.cashDiscountDueDate, "2026-07-31")
})
test("reads cash discount terms from UBL payment terms notes", async () => {
const xml = Buffer.from(ublInvoice.toString().replace(
"<cac:PaymentMeans>",
"<cac:PaymentTerms><cbc:Note>#SKONTO#TAGE=14#PROZENT=3.00#</cbc:Note></cac:PaymentTerms><cac:PaymentMeans>",
))
const detection = await detectElectronicInvoice(xml, { name: "xrechnung.xml" })
const parsed = parseElectronicInvoice(detection!.xml, detection!.syntax)
assert.deepEqual(parsed.cashDiscount, { percent: 3, days: 14 })
})

View File

@@ -0,0 +1,48 @@
import assert from "node:assert/strict"
import test from "node:test"
import {
applyAccountCostCentreAssignments,
normalizeAccountCostCentreAssignments,
} from "../src/modules/incoming-invoice-cost-centres.service"
import { mcpToolMap } from "../src/mcp/registry"
const CENTRE = "8f1c2b3a-4d5e-4f60-8a7b-9c0d1e2f3a4b"
test("changes only requested account cost centres", () => {
const accounts = [
{ account: 1, amountNet: 100, taxType: "19", costCentre: "old-1" },
{ account: 2, amountNet: 50, taxType: "7", costCentre: "old-2" },
]
assert.deepEqual(applyAccountCostCentreAssignments(accounts, [
{ index: 1, costcentre: CENTRE },
]), [
accounts[0],
{ account: 2, amountNet: 50, taxType: "7", costCentre: CENTRE },
])
})
test("rejects assignments for unknown invoice positions", () => {
assert.throws(
() => applyAccountCostCentreAssignments([{ account: 1 }], [{ index: 3, costcentre: null }]),
/Position 4.*nicht gefunden/,
)
})
test("validates cost centre assignments", () => {
assert.throws(() => normalizeAccountCostCentreAssignments([]), /Mindestens eine/)
assert.throws(() => normalizeAccountCostCentreAssignments([{ index: -1, costcentre: null }]), /gültigen index/)
assert.throws(() => normalizeAccountCostCentreAssignments([{ index: 0, costcentre: "x" }]), /Ungültige Kostenstelle/)
assert.throws(
() => normalizeAccountCostCentreAssignments([{ index: 0, costcentre: null }, { index: 0, costcentre: CENTRE }]),
/mehrfach/,
)
assert.deepEqual(normalizeAccountCostCentreAssignments([{ index: 0, costcentre: CENTRE }]), [{ index: 0, costcentre: CENTRE }])
})
test("registers a dedicated incoming invoice cost centre tool", () => {
const tool = mcpToolMap.get("accounting.incoming_invoices.cost_centres.update")
assert.deepEqual(tool?.requiredPermissions, ["accounting.incoming_invoices.write"])
assert.deepEqual(tool?.inputSchema.required, ["id", "accountCostCentres"])
})

View File

@@ -0,0 +1,45 @@
import assert from "node:assert/strict"
import test from "node:test"
import { prepareIncomingInvoices } from "../src/modules/cron/prepareIncomingInvoices"
function server(tenantLookups: { count: number }) {
let release: () => void = () => undefined
const gate = new Promise<void>((resolve) => { release = resolve })
const db: any = {
select: () => ({
from: () => ({
where: () => ({
orderBy: async () => {
tenantLookups.count += 1
await gate
return []
},
}),
}),
}),
}
const log = { error: () => undefined, info: () => undefined, warn: () => undefined }
return { server: { db, log } as any, release: () => release() }
}
test("start runs the preparation in the background and does not start a second run", async () => {
const lookups = { count: 0 }
const { server: srv, release } = server(lookups)
const service = prepareIncomingInvoices(srv)
const first = service.start(7)
const second = service.start(7)
assert.equal(first.running, true)
assert.equal(second.startedAt, first.startedAt)
assert.equal(service.status(8).running, false)
release()
await new Promise((resolve) => setImmediate(resolve))
await new Promise((resolve) => setImmediate(resolve))
const status = service.status(7)
assert.equal(lookups.count, 1)
assert.equal(status.running, false)
assert.ok(status.finishedAt)
assert.equal(status.result?.error, "Mandant nicht gefunden")
})

View File

@@ -0,0 +1,99 @@
import assert from "node:assert/strict"
import test from "node:test"
import { getTableColumns, getTableName } from "drizzle-orm"
import { PgDialect } from "drizzle-orm/pg-core"
import * as schema from "../db/schema"
import { mcpToolMap } from "../src/mcp/registry"
import { archiveEntities, prepareArchiveRestoreInput } from "../src/mcp/tools/archive"
import { asToolResult } from "../src/mcp/result"
const tenantId = 7
const userId = "00000000-0000-4000-8000-000000000001"
const uuid = "00000000-0000-4000-8000-000000000002"
const dialect = new PgDialect()
/** Minimal Drizzle stand-in which evaluates equality predicates, including tenant filters. */
function fixture(initial: Record<string, any[]> = {}, listRows: any[] = []) {
const state = structuredClone(initial)
const rowsFor = (table: any) => state[getTableName(table)] ||= []
const matches = (table: any, where: any, row: any) => {
const query = dialect.sqlToQuery(where)
const columns = getTableColumns(table) as Record<string, any>
const keyFor = (name: string) => Object.keys(columns).find((key) => columns[key].name === name)!
return [...query.sql.matchAll(/"\w+"\."(\w+)" = \$(\d+)/g)]
.every((match) => row[keyFor(match[1])] === query.params[Number(match[2]) - 1])
}
const listed: string[] = []
const db: any = {
transaction: async (callback: any) => callback(db),
select: () => ({ from: (table: any) => {
let condition: any
const query: any = {
where: (where: any) => { condition = where; return query },
orderBy: () => query,
offset: () => query,
limit: () => query,
for: () => query,
then: (resolve: any, reject: any) => Promise.resolve().then(() => {
if (listRows.length) { listed.push(dialect.sqlToQuery(condition).sql); return listRows }
return rowsFor(table).filter((row) => matches(table, condition, row)).map((row) => ({ ...row }))
}).then(resolve, reject),
}
return query
} }),
insert: (table: any) => ({ values: async (data: any) => { rowsFor(table).push(data) } }),
update: (table: any) => ({ set: (data: any) => ({ where: (where: any) => ({
returning: async () => rowsFor(table).filter((row) => matches(table, where, row)).map((row) => ({ ...Object.assign(row, data) })),
}) }) }),
}
const context: any = { server: { db, log: { warn: () => undefined } }, tenantId, userId, isAdmin: true, permissions: [] }
return { context, rows: rowsFor, listed }
}
const call = async (context: any, name: string, args: Record<string, any>) => await mcpToolMap.get(`archive.entities.${name}`)!.handler(context, args) as any
for (const [key, entity] of Object.entries(archiveEntities)) {
test(`${key}: restore unarchives within the tenant and records history`, async () => {
const id = entity.uuid ? uuid : 1
const f = fixture({
[getTableName(entity.table)]: [{ id, tenant: tenantId, archived: true }],
// Restoring documents requires an internal (non-portal) profile.
auth_profiles: [{ tenant_id: tenantId, user_id: userId, customer_for_portal: null }],
})
const result = await call(f.context, "restore", { entity: key, id })
assert.deepEqual(asToolResult(result).structuredContent?.success, true)
assert.equal(result.record.archived, undefined)
const [row] = f.rows(entity.table)
assert.equal(row.archived, false)
assert.equal(row.updatedBy ?? row.updated_by, userId)
await assert.rejects(call(f.context, "restore", { entity: key, id }), /nicht archiviert/)
})
}
test("restore rejects foreign tenants, unknown entities, invalid IDs and missing write permission", async () => {
const f = fixture({ customers: [{ id: 1, tenant: 8, archived: true }], tasks: [{ id: 2, tenant: tenantId, archived: true }] })
await assert.rejects(call(f.context, "restore", { entity: "customers", id: 1 }), /nicht gefunden/)
assert.equal(f.rows(schema.customers)[0].archived, true)
await assert.rejects(call(f.context, "restore", { entity: "accounts", id: 1 }), /unbekannte Entität/)
assert.throws(() => prepareArchiveRestoreInput({ entity: "cost_centres", id: 1 }), /UUID/)
assert.throws(() => prepareArchiveRestoreInput({ entity: "tasks", id: "abc" }), /ganze Zahl/)
f.context.isAdmin = false
f.context.permissions = ["organisation.tasks.read"]
await assert.rejects(call(f.context, "restore", { entity: "tasks", id: 2 }), /Fehlende Berechtigung.*organisation\.tasks\.write/)
f.context.permissions.push("organisation.tasks.write")
await call(f.context, "restore", { entity: "tasks", id: 2 })
assert.equal(f.rows(schema.tasks)[0].archived, false)
})
test("list returns archived rows visibly and requires the read permission", async () => {
const f = fixture({}, [{ id: 1, tenant: tenantId, archived: true, name: "Alt" }])
const result = asToolResult(await call(f.context, "list", { entity: "customers", search: "Al" }))
assert.deepEqual(result.structuredContent?.rows, [{ id: 1, tenant: tenantId, name: "Alt", isArchived: true }])
assert.match(f.listed[0], /"customers"\."archived" = \$2/)
f.context.isAdmin = false
f.context.permissions = []
await assert.rejects(call(f.context, "list", { entity: "customers" }), /masterdata\.customers\.read/)
})
test("restore is advertised as a write tool", () => {
assert.equal(mcpToolMap.get("archive.entities.restore")!.readOnly, false)
})

View File

@@ -0,0 +1,106 @@
import assert from "node:assert/strict"
import test from "node:test"
import { createHash } from "node:crypto"
import Fastify from "fastify"
import { drizzle } from "drizzle-orm/pg-proxy"
import mcpRoutes from "../src/routes/mcp"
import authPlugin from "../src/plugins/auth"
import { mcpToolMap } from "../src/mcp/registry"
import { parseUploadQuery, storeMcpFileUpload } from "../src/mcp/fileUploads"
import { MCP_FILE_LIMIT } from "../src/mcp/documentFiles"
import { secrets } from "../src/utils/secrets"
const id = "00000000-0000-4000-8000-000000000001"
const pdf = Buffer.from("%PDF-1.7\nMCP fixture\n%%EOF")
const bearer = "fedeo_mcp_test_upload_token"
const headers = { authorization: `Bearer ${bearer}` }
async function app(t: any) {
const uploads: any[] = []
const db = drizzle(async (sql, params) => {
if (sql.includes('from "m2m_api_keys"')) {
assert.ok(params.includes(createHash("sha256").update(bearer).digest("hex")))
return { rows: [[id, 7, id, true, null, "test@example.invalid", true]] }
}
if (sql.startsWith('update "m2m_api_keys"')) return { rows: [] }
if (sql.includes('from "auth_users"')) return { rows: [[true]] }
if (sql.includes('from "tenants"')) return { rows: [[null, null]] }
return { rows: [] }
})
const server = Fastify()
t.after(() => server.close())
server.decorate("db", db as any)
await server.register(authPlugin)
await server.register(mcpRoutes, {
prefix: "/api",
storeUpload: async (context: any, upload: any) => { uploads.push({ tenantId: context.tenantId, ...upload }); return { stored: true } },
})
return { server, uploads }
}
const multipartBody = (filename: string, content: Buffer, field = "file") => {
const boundary = "----fedeo-test"
const payload = Buffer.concat([
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${field}"; filename="${filename}"\r\nContent-Type: application/pdf\r\n\r\n`),
content,
Buffer.from(`\r\n--${boundary}--\r\n`),
])
return { payload, headers: { ...headers, "content-type": `multipart/form-data; boundary=${boundary}` } }
}
test("binary endpoint accepts multipart and raw uploads without Base64", async (t) => {
const { server, uploads } = await app(t)
const multipart = multipartBody("rechnung.pdf", pdf)
const first = await server.inject({ method: "POST", url: "/api/mcp/files?target=incoming_invoice&invoiceId=5&prepare=false", ...multipart })
assert.equal(first.statusCode, 200, first.body)
assert.deepEqual(first.json(), { stored: true })
assert.equal(uploads[0].tenantId, 7)
assert.equal(uploads[0].target, "incoming_invoice")
assert.equal(uploads[0].filename, "rechnung.pdf")
assert.equal(uploads[0].invoiceId, 5)
assert.equal(uploads[0].prepare, false)
assert.deepEqual(uploads[0].body, pdf)
const raw = await server.inject({ method: "POST", url: "/api/mcp/files?filename=scan.pdf", headers: { ...headers, "content-type": "application/pdf" }, payload: pdf })
assert.equal(raw.statusCode, 200, raw.body)
assert.equal(uploads[1].target, "inbox")
assert.equal(uploads[1].mimeType, "application/pdf")
assert.deepEqual(uploads[1].body, pdf)
})
test("binary endpoint rejects missing auth, filenames, wrong fields, types and oversized files", async (t) => {
const { server, uploads } = await app(t)
const raw = { "content-type": "application/pdf" }
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", headers: raw, payload: pdf })).statusCode, 401)
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files", headers: { ...headers, ...raw }, payload: pdf })).statusCode, 400)
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", ...multipartBody("a.pdf", pdf, "other") })).statusCode, 400)
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", headers: { ...headers, "content-type": "text/html" }, payload: "<p>" })).statusCode, 415)
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf&target=wiki", headers: { ...headers, ...raw }, payload: pdf })).statusCode, 400)
const tooLarge = Buffer.alloc(MCP_FILE_LIMIT + 1)
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", headers: { ...headers, ...raw }, payload: tooLarge })).statusCode, 413)
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files", ...multipartBody("a.pdf", tooLarge) })).statusCode, 413)
assert.equal(uploads.length, 0)
})
test("query parsing keeps invoice options to the incoming invoice target", () => {
assert.deepEqual(parseUploadQuery({}), { target: "inbox", invoiceId: null, prepare: true, filename: null })
assert.throws(() => parseUploadQuery({ invoiceId: "3" }), /nur für target=incoming_invoice/)
assert.throws(() => parseUploadQuery({ target: "incoming_invoice", invoiceId: "x" }), /positive ganze Zahl/)
assert.throws(() => parseUploadQuery({ target: "incoming_invoice", prepare: "ja" }), /true oder false/)
})
test("stored uploads require the permission of the chosen target", async () => {
const context: any = { server: { db: null }, tenantId: 7, userId: id, isAdmin: false, permissions: ["documents.read"] }
await assert.rejects(storeMcpFileUpload(context, { target: "inbox", filename: "a.pdf", body: pdf }), /documents\.write/)
await assert.rejects(storeMcpFileUpload(context, { target: "incoming_invoice", filename: "a.pdf", body: pdf }), /accounting\.incoming_invoices\.write/)
await assert.rejects(storeMcpFileUpload(context, { target: "inbox", filename: "a.pdf", body: Buffer.alloc(0) }), /leer/)
})
test("upload_url tool returns the absolute endpoint with examples", async (t) => {
const apiBase = secrets.API_BASE_URL
secrets.API_BASE_URL = "https://app.fedeo.test/backend/"
t.after(() => { secrets.API_BASE_URL = apiBase })
const result: any = await mcpToolMap.get("documents.files.upload_url")!.handler({} as any, {})
assert.equal(result.url, "https://app.fedeo.test/backend/api/mcp/files")
assert.match(result.examples[0], /-F "file=@rechnung\.pdf"/)
})

View File

@@ -0,0 +1,41 @@
import assert from "node:assert/strict"
import test from "node:test"
import { mcpToolMap } from "../src/mcp/registry"
const fileA = "00000000-0000-4000-8000-00000000000a"
const fileB = "00000000-0000-4000-8000-00000000000b"
const tool = mcpToolMap.get("accounting.incoming_invoices.prepare")!
function context(result: any, invoices: any[] = []) {
const calls: any[] = []
const db: any = {
select: () => ({ from: () => ({ where: async () => invoices }) }),
}
const services = { prepareIncomingInvoices: { run: async (tenantId: number, options: any) => { calls.push({ tenantId, options }); return result } } }
return { calls, context: { server: { db, services }, tenantId: 7, userId: "user", isAdmin: true, permissions: [] } as any }
}
test("prepare runs for the active tenant and returns created drafts with validation", async () => {
const invoice = { id: 11, tenant: 7, state: "Entwurf", archived: false }
const { calls, context: ctx } = context({ filesFound: 1, created: [{ fileId: fileA, invoiceId: 11, source: "gpt" }], skipped: [] }, [invoice])
const result: any = await tool.handler(ctx, {})
assert.deepEqual(calls, [{ tenantId: 7, options: { fileIds: undefined } }])
assert.equal(result.created[0].invoice.id, 11)
assert.ok(result.created[0].validation)
assert.deepEqual(result.skipped, [])
})
test("prepare limits to given files and reports files it could not handle", async () => {
const { calls, context: ctx } = context({ filesFound: 1, created: [], skipped: [{ fileId: fileA, reason: "Keine Belegdaten erkannt" }] })
const result: any = await tool.handler(ctx, { fileIds: [fileA, fileB.toUpperCase(), fileB] })
assert.deepEqual(calls[0].options.fileIds, [fileA, fileB])
assert.deepEqual(result.skipped.map((entry: any) => entry.fileId), [fileA, fileB])
})
test("prepare validates input and surfaces tenant configuration errors", async () => {
const { context: ctx } = context({ filesFound: 0, created: [], skipped: [], error: "Dateityp für Eingangsrechnungen fehlt" })
await assert.rejects(tool.handler(ctx, { fileIds: [] }), /1 bis 50/)
await assert.rejects(tool.handler(ctx, { fileIds: ["abc"] }), /UUID/)
await assert.rejects(tool.handler(ctx, {}), /Dateityp für Eingangsrechnungen fehlt/)
assert.deepEqual(tool.requiredPermissions, ["accounting.incoming_invoices.write"])
})

View File

@@ -0,0 +1,125 @@
import assert from "node:assert/strict"
import test from "node:test"
import { execFileSync } from "node:child_process"
import { Readable } from "node:stream"
import { PDFDocument } from "pdf-lib"
import { initS3, s3 } from "../src/utils/s3"
import { createInvoicePDF, InvoicePdfLayout } from "../src/utils/pdf"
import { buildOutgoingDocumentPdfData, calculateOutgoingDocumentTotals } from "../src/modules/outgoing-document-pdf.service"
import { analyzeOutgoingDocumentLayout } from "../src/mcp/outgoingDocumentLayout"
import { renderPdfPagesAsPng } from "../src/utils/pdfPreview"
import { asToolResult, withImages } from "../src/mcp/result"
import { mcpToolMap } from "../src/mcp/registry"
const hasPdftoppm = (() => { try { execFileSync("pdftoppm", ["-v"], { stdio: "ignore" }); return true } catch { return false } })()
const document = (rows: any[]) => ({
id: 1, type: "invoices", taxType: "Standard", state: "Entwurf", title: "Rechnung-Nr. XXXX", documentDate: "2026-10-28",
deliveryDateType: "Lieferdatum", deliveryDate: "2026-10-27", address: { street: "Weg 1", zip: "12345", city: "Ort" },
startText: "Hallo {{vorname}},", endText: "Zahlbar in {{zahlungsziel_in_tagen}} Tagen.", paymentDays: 14, payment_type: "transfer",
usedAdvanceInvoices: [], report: {}, rows,
})
const context = {
tenant: { name: "Firma", businessInfo: { name: "Firma", street: "Str. 1", zip: "1", city: "Stadt", iban: "DE02120300000000202051" }, extraModules: [] },
customer: { name: "Kunde", customerNumber: "K-1", firstname: "Ada" },
contactPerson: { full_name: "Bea Beispiel", email: "bea@example.invalid" },
units: [{ id: 1, short: "Stk" }], products: [{ id: 3, name: "Kabel" }], services: [], bankAccounts: [], advanceInvoices: [],
}
const positions = (count: number) => Array.from({ length: count }, (_, index) => ({
id: `r${index}`, pos: String(index + 1), mode: index % 2 ? "normal" : "free", product: 3, text: `Position ${index + 1}`,
description: "Zweizeilige\nBeschreibung", quantity: 2, unit: 1, price: 10.5, taxPercent: 19, discountPercent: 0,
}))
test("pdf data matches the editor: title sums, transfers, optional rows and handlebars", () => {
const rows = [
{ pos: "1", mode: "title", text: "Bau", titleLevel: 1 },
{ pos: "1.1", mode: "free", text: "A", quantity: 2, price: 10, discountPercent: 0, taxPercent: 19 },
{ pos: "1.2", mode: "free", text: "B", quantity: 1, price: 5, discountPercent: 0, taxPercent: 19, optional: true },
{ mode: "pagebreak" },
{ pos: "2", mode: "title", text: "Elektro", titleLevel: 1 },
{ pos: "2.1", mode: "normal", product: 3, quantity: 1, price: 100, discountPercent: 10, taxPercent: 7, unit: 1 },
]
const totals = calculateOutgoingDocumentTotals({ type: "invoices", rows }, [])
assert.equal(totals.totalNet, 110)
assert.deepEqual(totals.titleSums, { "1 - Bau": 20, "2 - Elektro": 90 })
assert.deepEqual(totals.titleSumsTransfer, { "2 - Elektro": 20 })
const data: any = buildOutgoingDocumentPdfData(document(rows), context)
assert.equal(data.startText, "Hallo Ada,")
assert.equal(data.endText, "Zahlbar in 14 Tagen.")
assert.equal(data.rows[5].text, "Kabel")
assert.equal(data.rows[5].unit, "Stk")
assert.equal(data.total.titleSumsTransfer["2 - Elektro"], "20,00 €")
assert.deepEqual(data.totalArray.map((item: any) => item.label), ["Nettobetrag", "zzgl. 19% USt auf 20,00 €", "zzgl. 7% USt auf 90,00 €", "Gesamtbetrag"])
assert.equal(data.info[0].label, "Rechnungsnummer")
})
test("layout analysis flags overflowing rows and suggests a page break that keeps titles", () => {
const layout: InvoicePdfLayout = { pageHeightMm: 297, pageCount: 1, rows: [
{ index: 0, pos: "1", mode: "free", text: "A", page: 1, topMm: 200, bottomMm: 240 },
{ index: 1, pos: "2", mode: "title", text: "T", page: 1, topMm: 240, bottomMm: 250 },
{ index: 2, pos: "2.1", mode: "free", text: "B", page: 1, topMm: 250, bottomMm: 270 },
], totals: { page: 1, topMm: 270, bottomMm: 300 } }
const { warnings, layout: summary } = analyzeOutgoingDocumentLayout(layout, 262)
assert.equal(warnings.length, 1)
assert.equal(warnings[0].insertPagebreakBeforeIndex, 1)
assert.equal(summary.pages[0].freeMm, -38)
})
test("rendered preview records rows per page and rasterizes pages", { skip: !hasPdftoppm && "pdftoppm fehlt" }, async (t) => {
const letterhead = await PDFDocument.create()
letterhead.addPage([595.28, 841.89]).drawText("Briefpapier", { x: 40, y: 40, size: 8 })
const background = Buffer.from(await letterhead.save())
await initS3()
t.mock.method(s3, "send", async () => ({ Body: Readable.from([background]) }))
const rows: any[] = positions(30)
rows.splice(12, 0, { mode: "pagebreak" })
const data = buildOutgoingDocumentPdfData(document(rows), context)
const layout: InvoicePdfLayout = { pageHeightMm: 0, rows: [], pageCount: 0 }
const pdf: any = await createInvoicePDF({} as any, "base64", data, "letterhead.pdf", layout)
// Rows overflow page 2, so the payment QR code gets an extra third page.
assert.equal(layout.pageCount, 3)
assert.equal(layout.paymentQr!.page, 3)
assert.equal(layout.rows.find((row) => row.index === 13)!.page, 2)
assert.ok(layout.endText && layout.totals)
const analysis = analyzeOutgoingDocumentLayout(layout)
assert.ok(analysis.warnings.some((warning) => warning.type === "overflow" && warning.page === 2), JSON.stringify(analysis.warnings))
const images = await renderPdfPagesAsPng(Buffer.from(pdf.base64, "base64"), [1, 2], 50)
assert.deepEqual(images.map((image) => image.page), [1, 2])
assert.ok(images[0].png.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
const result = asToolResult(withImages({ pageCount: 2 }, images.map((image) => ({ data: image.png.toString("base64"), mimeType: "image/png" }))))
assert.deepEqual(result.content.map((item) => item.type), ["text", "image", "image"])
assert.deepEqual(result.structuredContent, { pageCount: 2 })
})
test("preview tool is read-only for outgoing documents", () => {
assert.deepEqual(mcpToolMap.get("accounting.outgoing_documents.preview")!.requiredPermissions, ["accounting.outgoing_documents.read"])
})
test("payment QR code moves to its own page instead of covering amounts", { skip: !hasPdftoppm && "pdftoppm fehlt" }, async (t) => {
const letterhead = await PDFDocument.create()
letterhead.addPage([595.28, 841.89]).drawText("Briefpapier", { x: 40, y: 40, size: 8 })
const background = Buffer.from(await letterhead.save())
await initS3()
t.mock.method(s3, "send", async () => ({ Body: Readable.from([background]) }))
const render = async (count: number) => {
const layout: InvoicePdfLayout = { pageHeightMm: 0, rows: [], pageCount: 0 }
await createInvoicePDF({} as any, "base64", buildOutgoingDocumentPdfData(document(positions(count)), context), "letterhead.pdf", layout)
return layout
}
const short = await render(3)
assert.equal(short.pageCount, 1)
assert.equal(short.paymentQr!.page, 1)
assert.ok(short.totals!.bottomMm < short.paymentQr!.topMm)
const long = await render(6)
assert.equal(long.pageCount, 2)
assert.equal(long.paymentQr!.page, 2)
assert.ok(long.rows.every((row) => row.page === 1))
const analysis = analyzeOutgoingDocumentLayout(long)
const warning = analysis.warnings.find((item) => item.type === "paymentQrOwnPage")
assert.ok(warning, JSON.stringify(analysis.warnings))
assert.ok(!analysis.warnings.some((item) => item.type === "emptyPage"))
})

View File

@@ -0,0 +1,31 @@
import assert from "node:assert/strict"
import test from "node:test"
import { getTableName } from "drizzle-orm"
import { applyOutgoingRowCatalogDefaults } from "../src/mcp/outgoingRowDefaults"
const catalog: Record<string, any[]> = {
products: [{ id: 1, name: "Kabel", description: "NYM-J 3x1,5", unit: 2, selling_price: 1.25, tax_percentage: 19 }],
services: [{ id: 5, name: "Montage", description: "Montage vor Ort", unit: 3, sellingPrice: 50, sellingPriceComposed: { total: 55 }, taxPercentage: 7, costcentre: "cc" }],
}
const context: any = { tenantId: 7, server: { db: { select: () => ({ from: (table: any) => ({ where: async () => catalog[getTableName(table)] }) }) } } }
test("selected products and services bring their description and editor defaults", async () => {
const rows: any = await applyOutgoingRowCatalogDefaults(context, [
{ mode: "normal", product: 1, quantity: 3 },
JSON.stringify({ mode: "service", service: 5 }),
{ mode: "free", text: "Frei", descriptionText: "Alt" },
])
assert.deepEqual(rows[0], { mode: "normal", product: 1, quantity: 3, text: "Kabel", description: "NYM-J 3x1,5", unit: 2, inputPrice: 1.25, price: 1.25, taxPercent: 19 })
assert.equal(rows[1].description, "Montage vor Ort")
assert.equal(rows[1].price, 55)
assert.equal(rows[1].costCentre, "cc")
assert.equal(rows[2].description, "Alt")
})
test("caller values win and unknown catalog entries are rejected", async () => {
const [row]: any = await applyOutgoingRowCatalogDefaults(context, [{ mode: "normal", product: 1, description: "Eigene", price: 2, taxPercent: 7 }])
assert.equal(row.description, "Eigene")
assert.equal(row.price, 2)
assert.equal(row.taxPercent, 7)
await assert.rejects(applyOutgoingRowCatalogDefaults(context, [{ mode: "normal", product: 9 }]), /Artikel 9 nicht gefunden/)
})

View File

@@ -40,11 +40,12 @@ for (const tool of mcpTools.filter((tool) => (tool.inputSchema.properties as any
return values
}) }
}, { schema })
const context = { server: { db }, tenantId: 7, userId: "test" } as any
const context = { server: { db }, tenantId: 7, userId: "test", isAdmin: true } as any
const properties = tool.inputSchema.properties as any
const ids: unknown[] = []
let offset = 0
for (let page = 0; page < 3; page++) {
const result: any = await tool.handler(context, { ...((tool.inputSchema.properties as any).id ? { id: 1 } : {}), limit: 100, offset })
const result: any = await tool.handler(context, { ...(properties.id ? { id: 1 } : {}), ...(properties.entity ? { entity: properties.entity.enum[0] } : {}), limit: 100, offset })
assert.equal(result.rows.length, page < 2 ? 100 : 5)
assert.equal(result.pagination.hasMore, page < 2)
assert.equal(result.pagination.nextOffset, page < 2 ? offset + 100 : null)

View File

@@ -0,0 +1,209 @@
import assert from "node:assert/strict"
import test from "node:test"
import { PDFDocument, StandardFonts } from "pdf-lib"
import { buildZugferdInvoiceXml, checkZugferdRequirements, countryCode, unitCode, ZugferdInvoiceContext } from "../src/modules/einvoice/buildZugferdInvoice"
import { embedZugferdXml } from "../src/modules/einvoice/embedZugferd"
import { detectElectronicInvoice } from "../src/modules/einvoice/detectElectronicInvoice"
import { parseElectronicInvoice } from "../src/modules/einvoice/parseElectronicInvoice"
const context = (overrides: Partial<ZugferdInvoiceContext> = {}): ZugferdInvoiceContext => ({
tenant: {
name: "Muster GmbH",
creditorId: "DE98ZZZ09999999999",
businessInfo: {
name: "Muster GmbH", street: "Hauptstraße 1", zip: "12345", city: "Musterstadt",
vatId: "DE123456789", taxNumber: "12/345/67890", email: "rechnung@muster.de",
iban: "DE02120300000000202051", bic: "BYLADEM1001", bankAccountOwner: "Muster GmbH",
},
},
customer: {
name: "Kunde & Söhne KG", customerNumber: "10001",
infoData: { street: "Kundenweg 5", zip: "54321", city: "Kundenstadt", country: "Deutschland", ustid: "DE987654321", invoiceEmail: "buchhaltung@kunde.de" },
},
contactPerson: { full_name: "Erika Muster", email: "erika@muster.de" },
units: [{ id: 1, short: "Stk" }, { id: 6, short: "Std" }],
products: [{ id: 7, name: "Kabel NYM 3x1,5", article_number: "A-7" }],
services: [{ id: 3, name: "Montage" }],
bankAccounts: [],
advanceInvoices: [],
...overrides,
})
const invoice = (overrides: any = {}) => ({
id: 1,
type: "invoices",
customer: 5,
documentNumber: "RE-2026-0042",
documentDate: "2026-11-04",
deliveryDateType: "Lieferdatum",
deliveryDate: "2026-11-02",
paymentDays: 14,
payment_type: "transfer",
taxType: "Standard",
title: "Rechnung-Nr. RE-2026-0042",
address: { street: "Kundenweg 5", zip: "54321", city: "Kundenstadt" },
rows: [
{ pos: "1", mode: "normal", product: 7, quantity: 10, unit: 1, price: 2.345, discountPercent: 0, taxPercent: 19 },
{ pos: "2", mode: "service", service: 3, quantity: 1.5, unit: 6, price: 60, discountPercent: 10, taxPercent: 19 },
{ pos: "3", mode: "free", text: "Fachbuch", quantity: 1, unit: 1, price: 20, discountPercent: 0, taxPercent: 7 },
{ mode: "title", text: "Optionales" },
{ pos: "4", mode: "free", text: "Optional", quantity: 1, unit: 1, price: 999, discountPercent: 0, taxPercent: 19, optional: true },
{ pos: "5", mode: "free", text: "Gutschrift Altmaterial", quantity: 1, unit: 1, price: -5, discountPercent: 0, taxPercent: 19 },
],
...overrides,
})
const tag = (xml: string, name: string) => [...xml.matchAll(new RegExp(`<ram:${name}(?:\\s[^>]*)?>([^<]*)</ram:${name}>`, "g"))].map((match) => match[1])
test("erzeugt eine EN-16931-Rechnung mit konsistenten Summen", () => {
const { xml, issues, totals } = buildZugferdInvoiceXml(invoice(), context())
assert.deepEqual(issues, [])
// 23,45 + 81,00 + 20,00 - 5,00 (optionale Position ausgeschlossen)
assert.equal(totals.lineNet, 119.45)
// 19 %: 99,45 -> 18,90; 7 %: 20,00 -> 1,40
assert.equal(totals.tax, 20.3)
assert.equal(totals.gross, 139.75)
assert.equal(totals.payable, 139.75)
assert.match(xml, /<ram:ID>urn:cen.eu:en16931:2017<\/ram:ID>/)
assert.match(xml, /<ram:TypeCode>380<\/ram:TypeCode>/)
assert.match(xml, /Kunde &amp; Söhne KG/)
assert.match(xml, /<ram:IBANID>DE02120300000000202051<\/ram:IBANID>/)
assert.match(xml, /<ram:ID schemeID="VA">DE987654321<\/ram:ID>/)
assert.match(xml, /<ram:SellerAssignedID>A-7<\/ram:SellerAssignedID>/)
assert.match(xml, /<ram:DueDateDateTime><udt:DateTimeString format="102">20261118<\/udt:DateTimeString>/)
assert.match(xml, /<ram:OccurrenceDateTime><udt:DateTimeString format="102">20261102/)
// Negative Preise werden als negative Menge mit positivem Nettopreis abgebildet (BR-27).
assert.match(xml, /<ram:ChargeAmount>5<\/ram:ChargeAmount><\/ram:NetPriceProductTradePrice><\/ram:SpecifiedLineTradeAgreement><ram:SpecifiedLineTradeDelivery><ram:BilledQuantity unitCode="H87">-1</)
assert.ok(tag(xml, "ChargeAmount").every((value) => Number(value) >= 0))
// vier Positionen plus zwei Steuergruppen (19 % und 7 %)
assert.deepEqual(tag(xml, "CategoryCode"), ["S", "S", "S", "S", "S", "S"])
assert.deepEqual(tag(xml, "RateApplicablePercent").slice(-2), ["19", "7"])
})
test("das eigene XML wird vom FEDEO-Parser ohne Fehler gelesen", async () => {
const { xml } = buildZugferdInvoiceXml(invoice(), context())
const parsed = parseElectronicInvoice(Buffer.from(xml), "cii")
assert.deepEqual(parsed.validation.errors, [])
assert.equal(parsed.invoiceNumber, "RE-2026-0042")
assert.equal(parsed.seller.vatId, "DE123456789")
assert.equal(parsed.totals.gross, 139.75)
assert.equal(parsed.items.length, 4)
})
test("bettet das XML als factur-x.xml in die PDF ein", async () => {
const source = await PDFDocument.create()
const page = source.addPage()
page.drawText("Rechnung", { font: await source.embedFont(StandardFonts.Helvetica), x: 50, y: 700 })
const { xml } = buildZugferdInvoiceXml(invoice(), context())
const pdf = await embedZugferdXml(await source.save(), xml, { title: "Rechnung RE-2026-0042" })
const detection = await detectElectronicInvoice(pdf, { name: "RE-2026-0042.pdf", mimeType: "application/pdf" })
assert.equal(detection?.container, "pdf")
assert.equal(detection?.syntax, "cii")
assert.equal(detection?.attachmentName, "factur-x.xml")
assert.equal(detection?.xml.toString("utf8"), xml)
const raw = pdf.toString("latin1")
assert.match(raw, /\/AFRelationship \/Alternative/)
assert.match(raw, /<fx:ConformanceLevel>EN 16931<\/fx:ConformanceLevel>/)
assert.equal((await PDFDocument.load(pdf)).getPageCount(), 1)
})
test("Stornorechnung wird als Gutschrift mit positiven Beträgen und Bezug zur Rechnung ausgegeben", () => {
const { xml, totals } = buildZugferdInvoiceXml(invoice({
type: "cancellationInvoices",
documentNumber: "RE-2026-0043",
rows: [{ pos: "1", mode: "free", text: "Montage", quantity: 2, unit: 6, price: -50, discountPercent: 0, taxPercent: 19 }],
}), context({ linkedDocument: { documentNumber: "RE-2026-0042", documentDate: "2026-11-04" } }))
assert.match(xml, /<ram:TypeCode>381<\/ram:TypeCode>/)
assert.equal(totals.gross, 119)
assert.match(xml, /<ram:BilledQuantity unitCode="HUR">2<\/ram:BilledQuantity>/)
assert.match(xml, /<ram:InvoiceReferencedDocument><ram:IssuerAssignedID>RE-2026-0042<\/ram:IssuerAssignedID>/)
})
test("Schlussrechnung zieht Abschläge als bereits gezahlten Betrag ab", () => {
const { xml, totals } = buildZugferdInvoiceXml(invoice({
rows: [{ pos: "1", mode: "free", text: "Gesamtleistung", quantity: 1, unit: 1, price: 1000, discountPercent: 0, taxPercent: 19 }],
}), context({ advanceInvoices: [{ rows: [{ advanceInvoiceData: {}, price: 500, taxPercent: 19 }] }] }))
assert.equal(totals.gross, 1190)
assert.equal(totals.prepaid, 595)
assert.equal(totals.payable, 595)
assert.match(xml, /<ram:TotalPrepaidAmount>595.00<\/ram:TotalPrepaidAmount><ram:DuePayableAmount>595.00<\/ram:DuePayableAmount>/)
})
test("§ 13b und Kleinunternehmer verwenden die passenden Steuerkategorien", () => {
const reverseCharge = buildZugferdInvoiceXml(invoice({ taxType: "13b UStG", rows: [{ pos: "1", mode: "free", text: "Bauleistung", quantity: 1, unit: 1, price: 100, taxPercent: 0 }] }), context())
assert.match(reverseCharge.xml, /<ram:CategoryCode>AE<\/ram:CategoryCode><ram:ExemptionReasonCode>VATEX-EU-AE<\/ram:ExemptionReasonCode>/)
assert.equal(reverseCharge.totals.tax, 0)
const smallBusiness = buildZugferdInvoiceXml(invoice({ taxType: "19 UStG", rows: [{ pos: "1", mode: "free", text: "Beratung", quantity: 1, unit: 1, price: 100, taxPercent: 0 }] }), context())
assert.match(smallBusiness.xml, /<ram:ExemptionReason>Kein Ausweis von Umsatzsteuer, da Kleinunternehmer gemäß § 19 UStG<\/ram:ExemptionReason>/)
assert.match(smallBusiness.xml, /<ram:CategoryCode>E<\/ram:CategoryCode>/)
})
test("Leistungszeitraum, Lastschrift und Skonto werden strukturiert übertragen", () => {
const { xml, issues } = buildZugferdInvoiceXml(invoice({
deliveryDateType: "Leistungszeitraum", deliveryDate: "2026-10-01", deliveryDateEnd: "2026-10-31",
payment_type: "direct-debit", cashDiscountPercent: 2, cashDiscountDays: 7,
}), context({ sepaMandate: { reference: "MANDAT-1" } }))
assert.deepEqual(issues, [])
assert.match(xml, /<ram:BillingSpecifiedPeriod><ram:StartDateTime><udt:DateTimeString format="102">20261001/)
assert.match(xml, /<ram:CreditorReferenceID>DE98ZZZ09999999999<\/ram:CreditorReferenceID>/)
assert.match(xml, /<ram:TypeCode>59<\/ram:TypeCode>/)
assert.match(xml, /<ram:DirectDebitMandateID>MANDAT-1<\/ram:DirectDebitMandateID>/)
assert.match(xml, /#SKONTO#TAGE=7#PROZENT=2.00#BASISBETRAG=139.75#/)
assert.doesNotMatch(xml, /ActualDeliverySupplyChainEvent/)
})
test("ordnet Länder und Einheiten den Codelisten zu", () => {
assert.equal(countryCode("Österreich"), "AT")
assert.equal(countryCode(""), "DE")
assert.equal(countryCode("ch"), "CH")
assert.equal(unitCode({ short: "Std" }), "HUR")
assert.equal(unitCode({ short: "m²" }), "MTK")
assert.equal(unitCode({ short: "unbekannt" }), "C62")
})
const breakingMessages = (document: any, ctx: ZugferdInvoiceContext, requireNumber = false) =>
checkZugferdRequirements(document, ctx, { requireNumber }).filter((issue) => issue.type === "breaking").map((issue) => issue.message)
test("Pflichtangaben: vollständiger Beleg hat keine blockierenden Fehler", () => {
assert.deepEqual(breakingMessages(invoice(), context(), true), [])
assert.deepEqual(breakingMessages(invoice({ documentNumber: null }), context()), [])
assert.deepEqual(breakingMessages(invoice({ documentNumber: null }), context(), true), ["E-Rechnung: Die Rechnungsnummer fehlt"])
})
test("Pflichtangaben: fehlende Firmendaten, Kundenanschrift und IBAN blockieren", () => {
const ctx = context({
tenant: { businessInfo: { name: "Muster GmbH", street: "", zip: "12345", city: "Musterstadt" } },
})
const messages = breakingMessages(invoice({ address: { street: "Kundenweg 5", zip: "", city: "Kundenstadt" } }), ctx)
assert.ok(messages.some((message) => message.includes("Firmenanschrift")))
assert.ok(messages.some((message) => message.includes("weder eine USt-IdNr. noch eine Steuernummer")))
assert.ok(messages.some((message) => message.includes("Anschrift des Kunden")))
assert.ok(messages.some((message) => message.includes("keine IBAN")))
// Ein Bankkonto mit IBAN genügt anstelle der IBAN in den Firmendaten
const withAccount = context({ bankAccounts: [{ iban: "DE02120300000000202051" }] })
withAccount.tenant.businessInfo.iban = ""
assert.deepEqual(breakingMessages(invoice(), withAccount), [])
})
test("Pflichtangaben: § 13b und Lastschrift verlangen ihre Zusatzangaben", () => {
const reverseCharge = breakingMessages(invoice({ taxType: "13b UStG" }), context({ customer: { name: "Bau AG", infoData: {} } }))
assert.deepEqual(reverseCharge, ["E-Rechnung: Für § 13b UStG muss beim Kunden eine USt-Id hinterlegt sein"])
const directDebit = breakingMessages(invoice({ payment_type: "direct-debit" }), context({ tenant: { ...context().tenant, creditorId: "" } }))
assert.deepEqual(directDebit, [
"E-Rechnung: Für SEPA-Lastschrift ist in den Firmendaten keine Gläubiger-ID hinterlegt",
"E-Rechnung: Für SEPA-Lastschrift ist kein Mandat mit Mandatsreferenz ausgewählt",
])
const noDate = checkZugferdRequirements(invoice({ deliveryDateType: "Kein Lieferdatum anzeigen" }), context())
assert.deepEqual(noDate.map((issue) => issue.type), ["info"])
})

View File

@@ -36,6 +36,23 @@ Vor dem Buchen werden Netto-, Steuer- und Bruttobetrag angezeigt. Beispielsweise
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).
## Zahlung mit Skonto ausgleichen
Zahlt ein Kunde weniger als den offenen Betrag oder ziehst du bei einem Lieferanten Skonto ab, öffne den Bankumsatz und wähle beim Beleg **Mit Skonto ausgleichen** (Prozent-Symbol). Der Vorschlag ist die Differenz zwischen offenem Betrag und Zahlung. Zahlung und Skonto gleichen den Beleg gemeinsam aus; der Bankumsatz enthält weiterhin nur den gezahlten Betrag.
- Der Skonto wird anteilig nach den Steuersätzen des Belegs verteilt und mindert die Umsatzsteuer (Ausgangsbelege) bzw. Vorsteuer (Eingangsbelege) im Zeitraum der Zahlung.
- Bei Schlussrechnungen bleiben die Abschläge mit ihren ursprünglich berechneten Beträgen abgezogen. Skonto auf eine Abschlagsrechnung wird bei deren Zahlung gebucht, Skonto auf die Schlussrechnung bezieht sich auf die Restforderung.
- Der DATEV-Export erzeugt je Steuersatz eine eigene Buchung vom Skontokonto gegen Debitor bzw. Kreditor mit der Rechnungsnummer als Belegfeld.
- Standard sind die SKR-03-Automatikkonten 3736/3731/3730 (erhaltene Skonti) und 8736/8731/8730 (gewährte Skonti). Abweichende Konten trägst du unter **Firmeneinstellungen → Rechnung & Kontakt → Skontokonten** ein.
- Skonto über 10 % des Belegs, auf Belege nach §13b UStG oder mit innergemeinschaftlichem Erwerb wird abgelehnt und muss manuell gebucht werden.
- Wird die Bankzuordnung gelöscht, entfällt auch der Skonto.
### Skontobedingungen auf Belegen
- **Ausgangsbelege:** Im Beleg-Editor Skontosatz und Frist in Tagen eintragen. Kunden können unter *Skonto in %* und *Skontofrist in Tagen* eine Vorgabe haben, die bei der Kundenauswahl übernommen wird. Im PDF steht automatisch ein Satz wie „Bei Zahlung bis zum 14.11.2026 gewähren wir 2 % Skonto (23,80 €), Zahlbetrag 1.166,20 €.“ vor dem Endtext. Wer den Satz selbst formulieren will, nutzt in der Textvorlage `{{skonto_hinweis}}`, `{{skonto_prozent}}`, `{{skonto_datum}}`, `{{skonto_betrag}}` oder `{{zahlbetrag_mit_skonto}}`; dann entfällt der automatische Satz. Bei Schlussrechnungen bezieht sich der Skonto auf die Restforderung nach Abzug der Abschläge.
- **Eingangsbelege:** Skontosatz und letzter Skontotag werden aus E-Rechnungen (`#SKONTO#TAGE=…#PROZENT=…#` bzw. ZUGFeRD-Skontobedingungen) und, wenn ausdrücklich genannt, von der KI-Vorbereitung übernommen. Lieferanten können eine Vorgabe haben, die bei der Lieferantenauswahl eingesetzt wird.
- **Vorschläge:** Entspricht eine Zahlung dem offenen Betrag abzüglich des vereinbarten Skontos und liegt sie in der Frist, zeigt die Bankzuordnung „Passt mit 2 % Skonto“. In der Vorschlagsübersicht lässt sich die Zahlung dann direkt mit Skonto zuweisen.
## Gute Praxis
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.

View File

@@ -36,6 +36,23 @@ Vor dem Buchen werden Netto-, Steuer- und Bruttobetrag angezeigt. Beispielsweise
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).
## Zahlung mit Skonto ausgleichen
Zahlt ein Kunde weniger als den offenen Betrag oder ziehst du bei einem Lieferanten Skonto ab, öffne den Bankumsatz und wähle beim Beleg **Mit Skonto ausgleichen** (Prozent-Symbol). Der Vorschlag ist die Differenz zwischen offenem Betrag und Zahlung. Zahlung und Skonto gleichen den Beleg gemeinsam aus; der Bankumsatz enthält weiterhin nur den gezahlten Betrag.
- Der Skonto wird anteilig nach den Steuersätzen des Belegs verteilt und mindert die Umsatzsteuer (Ausgangsbelege) bzw. Vorsteuer (Eingangsbelege) im Zeitraum der Zahlung.
- Bei Schlussrechnungen bleiben die Abschläge mit ihren ursprünglich berechneten Beträgen abgezogen. Skonto auf eine Abschlagsrechnung wird bei deren Zahlung gebucht, Skonto auf die Schlussrechnung bezieht sich auf die Restforderung.
- Der DATEV-Export erzeugt je Steuersatz eine eigene Buchung vom Skontokonto gegen Debitor bzw. Kreditor mit der Rechnungsnummer als Belegfeld.
- Standard sind die SKR-03-Automatikkonten 3736/3731/3730 (erhaltene Skonti) und 8736/8731/8730 (gewährte Skonti). Abweichende Konten trägst du unter **Firmeneinstellungen → Rechnung & Kontakt → Skontokonten** ein.
- Skonto über 10 % des Belegs, auf Belege nach §13b UStG oder mit innergemeinschaftlichem Erwerb wird abgelehnt und muss manuell gebucht werden.
- Wird die Bankzuordnung gelöscht, entfällt auch der Skonto.
### Skontobedingungen auf Belegen
- **Ausgangsbelege:** Im Beleg-Editor Skontosatz und Frist in Tagen eintragen. Kunden können unter *Skonto in %* und *Skontofrist in Tagen* eine Vorgabe haben, die bei der Kundenauswahl übernommen wird. Im PDF steht automatisch ein Satz wie „Bei Zahlung bis zum 14.11.2026 gewähren wir 2 % Skonto (23,80 €), Zahlbetrag 1.166,20 €.“ vor dem Endtext. Wer den Satz selbst formulieren will, nutzt in der Textvorlage `{{skonto_hinweis}}`, `{{skonto_prozent}}`, `{{skonto_datum}}`, `{{skonto_betrag}}` oder `{{zahlbetrag_mit_skonto}}`; dann entfällt der automatische Satz. Bei Schlussrechnungen bezieht sich der Skonto auf die Restforderung nach Abzug der Abschläge.
- **Eingangsbelege:** Skontosatz und letzter Skontotag werden aus E-Rechnungen (`#SKONTO#TAGE=…#PROZENT=…#` bzw. ZUGFeRD-Skontobedingungen) und, wenn ausdrücklich genannt, von der KI-Vorbereitung übernommen. Lieferanten können eine Vorgabe haben, die bei der Lieferantenauswahl eingesetzt wird.
- **Vorschläge:** Entspricht eine Zahlung dem offenen Betrag abzüglich des vereinbarten Skontos und liegt sie in der Frist, zeigt die Bankzuordnung „Passt mit 2 % Skonto“. In der Vorschlagsübersicht lässt sich die Zahlung dann direkt mit Skonto zuweisen.
## Gute Praxis
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.

309
docs/fahrtenbuch-konzept.md Normal file
View File

@@ -0,0 +1,309 @@
# Konzept: Fahrtenbuch mit direkter Teltonika-Anbindung
## Zielbild
Die Teltonika-OBD-II-Tracker senden ihre AVL-Daten direkt an FEDEO. Traccar entfällt vollständig aus der produktiven Kette.
```text
Teltonika-Tracker
│ TCP/TLS, Codec 8 Extended
▼
FEDEO Tracking-Ingest
│ atomare Speicherung, danach ACK
▼
PostgreSQL ──► Fahrtbildungs-Worker ──► Fahrtenbuch-API ──► Nuxt-Oberfläche/PDF-Export
```
Die empfohlene Lösung besteht aus einem kleinen, eigenständigen Dienst `tracking-server`, den bestehenden FEDEO-Diensten und PostgreSQL. Der Empfangsdienst darf nicht Teil des HTTP-Prozesses sein: Teltonika verwendet langlebige TCP-Verbindungen und ein Binärprotokoll, während das bestehende Backend Fastify/HTTP bedient. Die getrennten Prozesse können unabhängig neu gestartet und skaliert werden.
Ein zusätzlicher Message-Broker ist für die derzeit erkennbare Größenordnung nicht nötig. Der Tracker puffert Daten bei Verbindungsproblemen; PostgreSQL übernimmt die dauerhafte Übergabe an den Fahrtbildungs-Worker.
## Annahmen und noch zu bestätigende Angaben
- Mit „Teltonika OBD2“ ist voraussichtlich ein Gerät der FMx003-Familie gemeint, zum Beispiel FMB003, FMC003 oder FMM003. Vor der Implementierung müssen exaktes Modell, Firmwarestand und Mobilfunkvariante feststehen.
- Die Geräte werden auf **Codec 8 Extended über TCP/TLS** eingestellt. Teltonika empfiehlt Codec 8 Extended, weil nur damit auch AVL-IDs oberhalb von 255 und variable I/O-Werte vollständig übertragen werden können.
- Das Fahrtenbuch soll nicht nur eine Streckenübersicht, sondern bei Bedarf ein steuerlich nutzbares elektronisches Fahrtenbuch für Deutschland sein.
- Ein OBD-Tracker erkennt das Fahrzeug, aber nicht automatisch die fahrende Person. Ohne zusätzlichen BLE-/iButton-Fahrerschlüssel wird der im Fahrzeug hinterlegte Standardfahrer vorgeschlagen und muss gegebenenfalls geändert werden.
## 1. Tracking-Ingest
### Aufgabe
Ein neues Paket beziehungsweise ein neuer Dienst `tracking-server/` wird in TypeScript implementiert. Er verwendet `node:tls` beziehungsweise während der lokalen Entwicklung `node:net` und übernimmt ausschließlich:
1. TCP-Verbindung und IMEI-Handshake,
2. Prüfung, ob die IMEI einem aktiven FEDEO-Fahrzeug zugeordnet ist,
3. Framing und Parsen von Codec 8 (`0x08`) und Codec 8 Extended (`0x8E`),
4. Prüfung von Paketlänge, doppelter Datensatzanzahl und CRC-16/IBM,
5. normalisierte Speicherung aller AVL-Datensätze in einer Datenbanktransaktion,
6. Teltonika-ACK erst nach erfolgreichem Commit,
7. Aktualisierung von `last_seen_at` und einfachen Betriebsmetriken.
Der produktive Gerätevertrag ist Codec 8 Extended. Codec 8 sollte der Parser trotzdem beherrschen, damit ein falsch oder noch nicht umkonfiguriertes Gerät sichtbar diagnostiziert werden kann.
Bei Datenbankfehlern wird **kein** positiver ACK gesendet. Das Gerät behält die Datensätze und versucht die Übertragung erneut. Doppelte Zustellungen müssen durch einen fachlichen Idempotenzschlüssel wirkungslos bleiben. Unbekannte IMEIs werden abgewiesen und nur als begrenztes Sicherheitsereignis protokolliert.
### Parser-Grenzen
Der Binärparser darf nicht mit Fachlogik vermischt werden. Sein Ergebnis ist ein neutrales Objekt:
```ts
type AvlRecord = {
recordedAt: Date
priority: number
longitude: number
latitude: number
altitude: number
angle: number
satellites: number
speedKmh: number
eventIoId: number
io: Record<number, bigint | Buffer>
}
```
Für die Fachlogik gibt es danach ein modell- und firmwareabhängiges I/O-Mapping. Relevante Standardwerte sind typischerweise:
- AVL 239: Zündung,
- AVL 240: Bewegung,
- AVL 16: Teltonika-Gesamtkilometerzähler,
- AVL 389: OEM-Gesamtkilometerstand,
- AVL 256: VIN,
- AVL 134: Fahrtdistanz.
Einheiten und Verfügbarkeit werden nicht im Parser fest verdrahtet, sondern über getestete Definitionen je Gerätefamilie normalisiert. Insbesondere der OEM-Kilometerstand ist vom unterstützten Fahrzeug und der auf das Gerät geladenen OBD-Konfiguration abhängig.
### Sicherheit und Betrieb
- Eigener TCP-Port, zum Beispiel `5027`, mit TLS 1.2; die konkrete TLS-Unterstützung muss für Modell und Firmware geprüft werden.
- IMEI-Zulassungsliste aus der Datenbank, Rate-Limit pro Quell-IP und harte Grenzen für Paketgröße und Verbindungsdauer.
- Keine Befehlssteuerung in Version 1. Codec 12/14 kann später getrennt ergänzt werden.
- Metriken: aktive Verbindungen, akzeptierte/abgelehnte Pakete, CRC-Fehler, unbekannte IMEIs, Verzögerung `received_at - recorded_at`, letzter Datensatz je Tracker und offene Fahrten.
- Alarm, wenn ein aktiver Tracker länger als eine konfigurierbare Zeit keine Daten liefert.
## 2. Datenmodell
Die vorhandene Tabelle `vehicles` bleibt die fachliche Fahrzeugquelle. Die generische Tabelle `devices` sollte nicht mit GPS-Sonderfeldern überladen werden.
### `vehicle_trackers`
Zuordnung und Gerätekonfiguration:
- `id uuid`, `tenant bigint`, `vehicle bigint`
- `imei text unique`, `model text`, `firmware text`
- `protocol text`, `enabled boolean`
- `installed_at`, `removed_at`, `last_seen_at`
- `io_profile_version text`
- eindeutige aktive Zuordnung eines Trackers zu einem Fahrzeug
Eine zeitliche Zuordnung ist wichtig: Beim Umbau eines Trackers dürfen alte Positionen nicht nachträglich dem neuen Fahrzeug zugerechnet werden.
### `tracking_packets`
Technische Nachvollziehbarkeit und gezielte Fehleranalyse:
- Tracker, Empfangszeit, Codec, CRC, Datensatzanzahl
- SHA-256 des vollständigen Frames als Idempotenzschlüssel
- optionaler Rohframe als `bytea` mit kurzer, konfigurierbarer Aufbewahrung, zum Beispiel 30 oder 90 Tage
- Parse-Status und Fehlercode
### `tracking_positions`
Unveränderliche normalisierte Messpunkte:
- Mandant, Tracker und die zum Messzeitpunkt gültige Fahrzeugzuordnung
- Gerätezeit `recorded_at` und Serverzeit `received_at`
- Koordinaten, Höhe, Richtung, Geschwindigkeit, Satelliten, Priorität
- normalisierte Werte `ignition`, `movement`, `odometer_m`, `oem_odometer_m`
- `event_io_id` und vollständige I/O-Werte als `jsonb`
- Qualitätsmerkmale wie `valid_gnss`, Zeitabweichung und Kilometerstandsquelle
Eindeutigkeit entsteht aus Tracker, Gerätezeit und einem stabilen Datensatz-Fingerabdruck. Für größere Datenmengen kann die Tabelle später monatsweise nach `recorded_at` partitioniert werden. Indizes werden mindestens auf `(vehicle, recorded_at)` und `(tracker, recorded_at)` benötigt.
### `tracking_trips`
Das fachliche Fahrtenbuch:
- Fahrzeug, Fahrer, Start/Ende und Start-/Zielkoordinaten
- Start-/Zieladresse als zum Abschlusszeitpunkt gespeicherter Text
- Kilometerstand Beginn/Ende, berechnete Distanz und jeweilige Quelle
- Klassifizierung `betrieblich`, `privat` oder `wohnung_erste_taetigkeitsstaette`
- Reisezweck, Kunde/Geschäftspartner sowie optionale FEDEO-Verknüpfungen zu Kunde, Projekt und Termin
- Status `offen`, `zu_pruefen`, `bestaetigt`, `gesperrt`
- Qualitätsstatus mit konkreten Hinweisen, zum Beispiel fehlender OEM-Kilometerstand, GPS-Lücke oder unplausibler Sprung
- Referenzen auf ersten und letzten Messpunkt
### `tracking_trip_revisions` und `tracking_logbook_exports`
Fahrten werden nie unbemerkt überschrieben. Jede Änderung erzeugt eine unveränderliche Revision mit altem und neuem Wert, Benutzer, Zeit, Änderungsgrund sowie einer Hash-Verkettung zur vorherigen Revision. Auch Zusammenführen, Teilen und Löschen sind fachliche Revisionen; ein physisches Löschen bestätigter Fahrten ist nicht erlaubt.
Ein abgeschlossener Monats- oder Jahresexport speichert PDF, maschinenlesbare CSV/JSON-Daten und ein Manifest mit SHA-256-Prüfsummen. Der Export enthält die Änderungshistorie in gewöhnlich einsehbarer Form. „Gesperrt“ bedeutet: weitere Korrekturen erzeugen eine sichtbare Folgerevision und gegebenenfalls eine neue Exportversion.
## 3. Fahrtbildung
Die Fahrtbildung läuft als wiederholbarer Worker auf Ereigniszeit, nicht auf Empfangszeit. Das ist wichtig, weil Tracker bei Funklöchern ältere Datensätze nachliefern.
### Zustandsautomat je Tracker
```text
STEHT
└─ Zündung an + Bewegung/Geschwindigkeit ─► FÄHRT
FÄHRT
├─ weitere Punkte ────────────────────────► FÄHRT
└─ Zündung aus + Nachlaufzeit ────────────► STEHT + Fahrt abschließen
```
- Primäres Start-/Endesignal ist AVL 239 (Zündung), plausibilisiert mit Bewegung und Geschwindigkeit.
- Eine konfigurierbare Nachlaufzeit von zunächst 120 Sekunden verhindert Fahrtteilungen an Ampeln oder bei kurzen Signalwechseln.
- Eine Fahrt wird nicht sofort endgültig: Ein Watermark, zum Beispiel zehn Minuten nach dem letzten bekannten Gerätezeitpunkt, erlaubt verspätete Datensätze. Betroffene offene Zeitfenster werden deterministisch neu berechnet.
- Bei fehlendem Zündungssignal greift ein klar markierter Fallback aus Bewegung, Geschwindigkeit und Stillstandszeit.
- Kilometerstandsquelle in absteigender Qualität: OEM-Gesamtkilometerstand (AVL 389), kalibrierter Teltonika-Gesamtkilometerzähler (AVL 16), Fahrtdistanz/Positionssumme. FEDEO speichert Quelle und Abweichung, statt Werte still zu vermischen.
- Unplausible GNSS-Punkte werden nicht für die Distanz verwendet, bleiben aber unverändert gespeichert.
- Zeitrücksprünge, Kilometerzählersprünge, GPS-Lücken und Tracker-Ausbau erzeugen Prüffälle und niemals stillschweigend „saubere“ Fahrten.
Die Zählerwerte müssen im Pilotfahrzeug gegen den Tachostand geprüft werden. Liefert ein Fahrzeug keinen OEM-Gesamtkilometerstand, wird einmalig ein dokumentierter Tachostand mit dem Teltonika-Zähler verknüpft. Der daraus entstehende Offset bleibt versioniert.
## 4. Bedienung in FEDEO
Der vorhandene, bislang nur konfigurierte Ressourcenschlüssel `trackingtrips` kann weiterverwendet werden. Für das Fahrtenbuch ist dennoch eine eigene Seite sinnvoller als die generische Standard-Entity-Maske.
### Ansichten
- **Fahrten:** chronologische Liste mit Fahrzeug, Fahrer, Start/Ziel, Zeit, Kilometerständen, Distanz, Klassifizierung, Zweck und Prüfstatus.
- **Prüfen:** schnelle Stapelbearbeitung aller offenen Fahrten; Vorschläge aus Kalender, Kunde/Projekt und wiederkehrenden Zielen.
- **Karte:** Route einer ausgewählten Fahrt, nicht standardmäßig die vollständige Mitarbeiterhistorie.
- **Fahrzeug:** Trackerstatus, letzter Empfang, Kilometerstandsqualität und offene Warnungen.
- **Export:** Monats-/Jahresabschluss, PDF-Vorschau und Prüfdateien.
Eine betriebliche Fahrt verlangt Ziel, Reisezweck und Geschäftspartner beziehungsweise einen konkreten betrieblichen Vorgang. Privatfahrten speichern für normale Benutzer nur die steuerlich erforderlichen Werte; die Sichtbarkeit genauer Privat-Routen sollte über eine eigene Berechtigung begrenzt werden.
### API-Schnittstellen
Spezialisierte, mandantengeprüfte Routen sind der generischen Ressourcen-API vorzuziehen:
- `GET /api/tracking/trips`
- `GET /api/tracking/trips/:id`
- `PATCH /api/tracking/trips/:id/classification`
- `POST /api/tracking/trips/:id/confirm`
- `POST /api/tracking/trips/:id/split`
- `POST /api/tracking/trips/merge`
- `GET /api/tracking/vehicles/:id/status`
- `POST /api/tracking/exports`
- `GET /api/tracking/exports/:id`
Ändernde Aufrufe verlangen einen Änderungsgrund, sobald eine Fahrt bereits bestätigt war. Berechtigungen sollten mindestens `tracking.view`, `tracking.edit_own`, `tracking.edit_all`, `tracking.view_private_routes`, `tracking.manage_devices`, `tracking.lock` und `tracking.export` unterscheiden.
## 5. Steuerliche Nachvollziehbarkeit und Datenschutz
Für ein deutsches elektronisches Fahrtenbuch reichen GPS-Punkte allein nicht. Nach der Rechtsprechung müssen die Aufzeichnungen zeitnah, fortlaufend und in geschlossener Form vorliegen. Änderungen müssen technisch ausgeschlossen oder in ihrer Reichweite direkt im Fahrtenbuch erkennbar sein. Für betriebliche Fahrten gehören grundsätzlich Datum, Kilometerstand zu Beginn und Ende, Reiseziel, Reisezweck und aufgesuchte Geschäftspartner dazu.
Deshalb sind folgende Punkte keine spätere Zusatzfunktion, sondern Bestandteil der ersten fachlich nutzbaren Version:
- append-only Messdaten und Änderungshistorie,
- sichtbare Revisionsangaben im Fahrtenbuch und im Export,
- Erinnerung und Eskalation für zeitnahe Klassifizierung,
- Vollständigkeits- und Plausibilitätsprüfung pro Fahrzeug,
- reproduzierbare, versionierte Monatsabschlüsse,
- dokumentierte Verfahrensbeschreibung und Wiederherstellungstests.
Das Konzept ist eine technische Auslegung, keine Zusicherung der steuerlichen Anerkennung. Export und Verfahrensdokumentation sollten vor dem produktiven steuerlichen Einsatz mit der Steuerberatung abgestimmt werden.
Standortdaten von Beschäftigten erfordern zudem ein Rollen- und Löschkonzept, Zweckbindung, transparente Information und gegebenenfalls Beteiligung des Betriebsrats. Rohpunkte sollten nur so lange wie fachlich und rechtlich erforderlich gespeichert werden. Reverse-Geocoding erfolgt nur für Start und Ziel, asynchron und mit Cache; ein selbst betriebener Dienst oder ein vertraglich geprüfter Anbieter vermeidet unnötige Weitergabe vollständiger Bewegungsprofile.
## 6. Gerätekonfiguration
Für einen Pilot-Tracker wird ein versioniertes Teltonika-Konfigurationsprofil erstellt:
- Hauptserver: FEDEO-Domain und Tracking-Port
- Transport: TCP mit TLS, sofern Modell/Firmware dies unterstützen
- Datenprotokoll: Codec 8 Extended
- Datenerfassung in Bewegung zunächst alle 30 Sekunden oder 100 Meter sowie bei relevanter Richtungsänderung
- Ereignisdatensatz bei Zündung an/aus
- AVL 239, 240, 16, 389, 256 und 134 aktivieren, soweit vom Gerät unterstützt
- lokale Pufferung und älteste Datensätze zuerst senden
- Serverbestätigung auf AVL-Ebene
Das Standardintervall von mehreren Minuten ist für belastbare Start-/Zielpunkte und Routen zu grob. Die endgültigen Intervalle werden im Pilot gegen Datenvolumen, SIM-Tarif und Fahrzeugbatterie getestet.
## 7. Einführung ohne Datenverlust
### Phase 0 – Geräte-Spike
Ein reales Gerät und Fahrzeug werden festgelegt. Modell, Firmware, IMEI, empfangene AVL-IDs, Zündungslogik und Kilometerstandsquellen werden dokumentiert. Ein aufgezeichneter Binärdatenbestand aus definierten Testfahrten bildet anschließend einen dauerhaft nutzbaren Parser- und Fahrtbildungs-Testvektor.
### Phase 1 – Shadow-Ingest
Teltonika unterstützt einen zweiten Server und einen Duplikatmodus. Während der Pilotphase sendet das Gerät an Traccar **und** FEDEO. FEDEO bestätigt und speichert Daten, beeinflusst aber noch kein produktives Fahrtenbuch. Über mehrere Wochen werden Paketvollständigkeit, Kilometerstände und erkannte Fahrten verglichen.
### Phase 2 – Fahrtenbuch-Pilot
Fahrtbildung, Prüfansicht, Klassifizierung, Revisionen und Export werden für wenige Fahrzeuge aktiviert. Kriterien für die Freigabe:
- kein Paketverlust bei Neustart oder Datenbankunterbrechung,
- idempotente Verarbeitung wiederholter Pakete,
- reproduzierbare Fahrtbildung bei ungeordneter Zustellung,
- Kilometerstandsabweichungen erklärt und sichtbar,
- vollständiger Export inklusive Änderungen,
- Rechte- und Mandantentrennung getestet.
### Phase 3 – Umschaltung
Nach erfolgreichem Parallelbetrieb wird FEDEO Hauptserver. Traccar bleibt zunächst als Teltonika-Backupserver konfiguriert, anschließend wird es entfernt. Der Rückfall besteht in der ersten Zeit lediglich aus dem Zurückschalten des Geräteprofils; ein Datenbank-Rollback ist nicht erforderlich.
## 8. Empfohlener Implementierungsumfang
### Iteration 1: belastbarer Empfang
- Schemata `vehicle_trackers`, `tracking_packets`, `tracking_positions`
- Codec-8/8E-Parser mit CRC- und offiziellen Beispielvektoren
- TCP/TLS-Dienst, IMEI-Zuordnung, ACK nach Commit
- Trackerstatus im Backend und technische Metriken
- Docker-Compose-Erweiterung und Betriebsdokumentation
### Iteration 2: Fahrten
- Schemata `tracking_trips` und Qualitätsmeldungen
- deterministischer Fahrtbildungs-Worker
- Listen-, Detail- und Kartenansicht
- manuelle Klassifizierung und Fahrerzuordnung
### Iteration 3: belastbares Fahrtenbuch
- unveränderliche Revisionen und Hash-Verkettung
- zeitnahe Erinnerungen, Bestätigung und Monatsabschluss
- PDF/CSV/JSON-Export mit Prüfmanifest
- Projekt-/Kunden-/Kalender-Vorschläge
- Rechte-, Datenschutz- und Verfahrensdokumentation
## 9. Tests, die vor Abschaltung von Traccar bestehen müssen
- fragmentierte und zusammengefasste TCP-Frames
- ungültige Länge, CRC, Codec-ID, IMEI und Datensatzanzahl
- Disconnect vor und nach ACK
- dasselbe Paket mehrfach und gleichzeitig zugestellt
- mehrere AVL-Datensätze pro Paket
- negative Koordinaten und variable I/O-Felder aus Codec 8 Extended
- Offline-Nachlieferung über mehrere Stunden in falscher Empfangsreihenfolge
- Zündung an ohne Bewegung, kurze Stopps, Tunnel, GPS-Sprung und Mitternacht
- fehlender oder zurückgesetzter Kilometerzähler
- Trackerwechsel zwischen Fahrzeugen
- Mandantentrennung jeder API-Route
- Änderung einer bestätigten und einer gesperrten Fahrt
- Export-Reproduzierbarkeit und Prüfung der Hash-Kette
## Empfehlung
Zuerst sollte **nicht** die Oberfläche gebaut werden, sondern ein zwei- bis dreitägiger Geräte-Spike mit einem konkreten Tracker. Danach ist Iteration 1 überschaubar und risikoarm. Der direkte Teltonika-Empfang selbst ist technisch klar definiert; die eigentliche Sorgfalt liegt in verspäteten Daten, Kilometerstandsqualität, Fahreridentität und der unveränderlich sichtbaren Bearbeitungshistorie.
## Quellen
- [Teltonika: Codec 8 und Codec 8 Extended](https://wiki.teltonika-gps.com/view/Teltonika_Data_Sending_Protocols)
- [Teltonika: Empfehlung für Codec 8 Extended](https://wiki.teltonika-gps.com/view/Which_data_protocol_to_choose_Codec_8_or_Codec_8_Extended)
- [Teltonika: FMC003 Data Sending Parameters](https://wiki.teltonika-gps.com/view/FMC003_Teltonika_Data_Sending_Parameters_ID)
- [Teltonika: FMB003 Trip-/Odometer-Einstellungen](https://wiki.teltonika-gps.com/view/FMB003_Trip/Odometer_settings)
- [Teltonika: FMB003 GPRS-, Zweitserver- und TLS-Einstellungen](https://wiki.teltonika-gps.com/view/FMB003_GPRS_settings)
- [Teltonika: OEM-Gesamtkilometerstand und Fahrzeugkompatibilität](https://wiki.teltonika-gps.com/view/Pay_as_You_Drive)
- [Bundesfinanzhof: Anforderungen an ein elektronisches Fahrtenbuch, VI B 37/23](https://www.bundesfinanzhof.de/de/entscheidung/entscheidungen-online/detail/STRE202450011/)
- [Bundesfinanzhof: Inhalt und geschlossene Form des Fahrtenbuchs, VI R 33/10](https://www.bundesfinanzhof.de/de/entscheidung/entscheidungen-online/detail/STRE201210116/)
- [BMF: Einkommensteuer-Handbuch 2025, Anhang 16 III](https://lsth.bundesfinanzministerium.de/esth/2025/B-Anhaenge/Anhang-16/III/inhalt.html)

View File

@@ -41,7 +41,7 @@ Die Pipeline nutzt die fest gepinnten Pakete `@semantic-release/commit-analyzer`
Es zählt der höchste erforderliche Sprung seit dem letzten stabilen Tag `vMAJOR.MINOR.PATCH`. Eine Änderung und ihr vollständiger Git-Revert innerhalb desselben Release-Bereichs heben sich auf. Selbst verfasste Nachrichten müssen dem Schema entsprechen; echte Merge-Commits und automatisch generierte Git-Reverts werden gesondert behandelt.
Der erste Release heißt `3.0.0`. `bootstrapCommit` in `release.config.json` begrenzt die Prüfung und die erste Änderungsliste auf die Einführung dieser Versionierung auf `dev`. `legacyHistoryHeads` hält zusätzlich den letzten alten `main`-Stand fest (`e7554fa2…` vor der ersten Übernahme von `dev`). Dessen Historie enthält ältere Änderungen an Issue-Vorlagen, die nicht vom `dev`-Ausgangscommit erreichbar sind. Beide festgelegten Historien werden aus Commit-Prüfung, Versionsberechnung und Release Notes ausgeschlossen. Neue Commits zählen auch dann, wenn ihr Zweig von einem dieser alten Stände abzweigt.
Der erste Release heißt `3.0.0`. `bootstrapCommit` in `release.config.json` begrenzt die Prüfung und die erste Änderungsliste auf die Einführung dieser Versionierung auf `dev`. `legacyHistoryHeads` hält zusätzlich den letzten alten `main`-Stand fest (`e7554fa2…` vor der ersten Übernahme von `dev`). Dessen Historie enthält ältere Änderungen an Issue-Vorlagen, die nicht vom `dev`-Ausgangscommit erreichbar sind. Außerdem ist der alte Mobile-Zweig (`89185b4b…`, Stand vor dem Ausgangscommit) eingetragen, der mit `7a1986bb` nach `dev` übernommen wurde. Alle festgelegten Historien werden aus Commit-Prüfung, Versionsberechnung und Release Notes ausgeschlossen. Neue Commits zählen auch dann, wenn ihr Zweig von einem dieser alten Stände abzweigt.
Ältere Commits werden weder umgeschrieben noch nachträglich klassifiziert. Diese festen Ausgangscommits nach Einführung nicht verschieben und keine späteren Änderungen hinzufügen, um die Commit-Prüfung zu umgehen. Der vollständige Checkout muss auch die alten Branch-Historien enthalten; ein fehlender Ausgangscommit führt zum Abbruch.

View File

@@ -119,10 +119,13 @@ const reportRows = computed(() => {
relevantCostCentreIds.value.has(account.costCentre)
)
// Expenses reduce the cost centre result; incoming documents marked as income increase it.
const sign = invoice.expense === false ? 1 : -1
return matchingAccounts.map((account, index) => {
const amountNet = Number(account.amountNet || 0)
const amountTax = Number(account.amountTax || 0)
const amountGross = Number(account.amountGross || amountNet + amountTax || 0)
const amountNet = Number(account.amountNet || 0) * sign
const amountTax = Number(account.amountTax || 0) * sign
const amountGross = Number(account.amountGross || Number(account.amountNet || 0) + Number(account.amountTax || 0) || 0) * sign
const accountCostCentre = costCentreMap.value.get(getCostCentreId(account.costCentre))
return {
@@ -196,8 +199,7 @@ const reportRows = computed(() => {
const centre = costCentreMap.value.get(getCostCentreId(allocation.costcentre))
const account = accounts.value.find(account => account.id === (allocation.account?.id || allocation.account)) || allocation.ownaccount
const amounts = getBankAllocationAmounts(allocation)
// As for incoming documents, expenses are positive and refunds negative.
const sign = amounts.kind === "input" || (amounts.kind === "none" && amounts.amountGross < 0) ? -1 : 1
// The bank amount is already signed: outflows (expenses) negative, inflows positive.
return [{
id: `bank-${allocation.id}`,
sourceLabel: "Bankbuchung",
@@ -209,9 +211,9 @@ const reportRows = computed(() => {
accountLabel: `${account?.number || ""} ${account?.label || account?.name || ""}`.trim() || "-",
costCentreName: centre ? `${centre.number} - ${centre.name}` : "-",
description: allocation.description || statement.text || "-",
amountNet: amounts.amountNet * sign,
amountTax: amounts.amountTax * sign,
amountGross: amounts.amountGross * sign,
amountNet: amounts.amountNet,
amountTax: amounts.amountTax,
amountGross: amounts.amountGross,
}]
})
return [...incomingRows, ...outgoingRows, ...bankRows]

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import dayjs from "dayjs"
import { getBankAllocationTaxBreakdown } from "~/utils/bankAllocationTax"
import { getCashDiscountNetTotals } from "~/utils/cashDiscount"
import {
getCreatedDocumentTaxBreakdown,
getIncomingInvoiceTaxBreakdown
@@ -90,7 +91,7 @@ const loadSummary = async () => {
return touchesAccount && date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day")
})
const income = outputDocs.reduce((sum: number, doc: any) => {
const documentIncome = outputDocs.reduce((sum: number, doc: any) => {
return sum + (doc.rows || []).reduce((rowSum: number, row: any) => {
if (!row || ["pagebreak", "title", "text"].includes(row.mode)) {
return rowSum
@@ -132,7 +133,14 @@ const loadSummary = async () => {
const { output, input } = getBankAllocationTaxBreakdown(allocation)
return sum + output.tax19 + output.tax7 - input.tax19 - input.tax7
}, 0)
const expenses = invoiceExpenses + directAccountExpenses + depreciations
// Skonto wirkt im Monat der Zahlung auf Erlöse und Aufwand.
const cashDiscountTotals = getCashDiscountNetTotals((allocations || []).filter((allocation: any) => {
const date = dayjs(allocation.bankstatement?.date || allocation.bankstatement?.valueDate)
return date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day")
}))
const expenses = invoiceExpenses + directAccountExpenses + depreciations - cashDiscountTotals.receivedNet
const income = documentIncome - cashDiscountTotals.grantedNet
summary.value = {
label: dayjs().format("MMMM YYYY"),

View File

@@ -34,15 +34,19 @@ export const useFunctions = () => {
return (await useNuxtApp().$api(`/api/banking/link/${institutionId}`)).link
}
const useCreatePDF = async (data,path,type) => {
// options.createdDocumentId: gespeicherter Beleg, aus dem das Backend die eingebettete E-Rechnung (ZUGFeRD) erzeugt
const useCreatePDF = async (data,path,type,options = {}) => {
const returnData = await useNuxtApp().$api(`/api/functions/pdf/${type}`, {
method: "POST",
body: {
data: data,
backgroundPath: path,
...(options.createdDocumentId ? { createdDocumentId: options.createdDocumentId } : {}),
}
})
if (options.onResult) options.onResult(returnData)
console.log(returnData)
return `data:${returnData.mimeType};base64,${returnData.base64}`

View File

@@ -1,3 +1,4 @@
import { sumSettledAllocations } from "~/utils/cashDiscount"
export const useSum = () => {
const unwrapCreatedDocuments = (createddocuments = []) => {
@@ -83,8 +84,8 @@ export const useSum = () => {
}
const getCreatedDocumentOpenAmount = (createddocument, createddocuments = []) => {
let amountPaid = 0
createddocument.statementallocations.forEach(allocation => amountPaid += allocation.amount)
// Gebuchter Skonto gleicht den Beleg zusammen mit der Zahlung aus.
const amountPaid = sumSettledAllocations(createddocument.statementallocations || [])
return Number((getCreatedDocumentSum(createddocument, createddocuments) - amountPaid).toFixed(2))
}

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { getCashDiscountNetTotals } from "~/utils/cashDiscount"
import dayjs from "dayjs"
import { getBankAllocationAmounts, getBankAllocationTaxBreakdown } from "~/utils/bankAllocationTax"
import {
@@ -183,8 +184,12 @@ const selectedPeriodBounds = computed(() => {
return { start, end }
})
// Skonto wirkt im Zeitraum der Zahlung: gewährter Skonto mindert Erlöse, erhaltener Skonto den Aufwand.
const cashDiscountTotals = computed(() => getCashDiscountNetTotals(filteredStatementAllocations.value))
const incomeTotal = computed(() => {
return Number(filteredDocuments.value.reduce((sum, doc) => sum + computeDocumentNet(doc), 0).toFixed(2))
const documents = filteredDocuments.value.reduce((sum, doc) => sum + computeDocumentNet(doc), 0)
return Number((documents - cashDiscountTotals.value.grantedNet).toFixed(2))
})
const expenseNetTotal = computed(() => {
@@ -196,7 +201,7 @@ const expenseNetTotal = computed(() => {
const depreciations = depreciationTotal.value
return Number((invoiceExpenses + directAccountExpenses + depreciations).toFixed(2))
return Number((invoiceExpenses + directAccountExpenses + depreciations - cashDiscountTotals.value.receivedNet).toFixed(2))
})
const expenseGrossTotal = computed(() => {
@@ -206,7 +211,7 @@ const expenseGrossTotal = computed(() => {
const depreciations = depreciationTotal.value
return Number((invoiceExpenses + directAccountExpenses + depreciations).toFixed(2))
return Number((invoiceExpenses + directAccountExpenses + depreciations - cashDiscountTotals.value.receivedGross).toFixed(2))
})
const taxSummary = computed(() => {

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import dayjs from "dayjs"
import { getBankAllocationTaxBreakdown, isDirectBankAllocation } from "~/utils/bankAllocationTax"
import { hasCashDiscount } from "~/utils/cashDiscount"
import customParseFormat from "dayjs/plugin/customParseFormat"
import {
formatTaxEvaluationPeriodLabel,
@@ -54,7 +55,7 @@ const loadData = async () => {
createdDocuments.value = (docs || []).filter(isRelevantOutputDocument)
incomingInvoices.value = (incoming || []).filter(isRelevantInputInvoice)
manualBookings.value = (manual as any[]) || []
bankAllocations.value = (allocations || []).filter(isDirectBankAllocation)
bankAllocations.value = (allocations || []).filter((allocation) => isDirectBankAllocation(allocation) || hasCashDiscount(allocation))
} finally {
loading.value = false
}

View File

@@ -1,4 +1,5 @@
<script setup>
import { matchCashDiscountPayment, sumSettledAllocations } from "~/utils/cashDiscount"
import { parseDate } from "@internationalized/date"
const {$api, $dayjs} = useNuxtApp()
@@ -103,12 +104,12 @@ const loadSuggestionData = async () => {
.map(i => ({
...i,
docTotal: useSum().getCreatedDocumentSum(i, createddocuments.value),
statementTotal: Number(i.statementallocations.reduce((n, {amount}) => n + amount, 0)),
statementTotal: Number(sumSettledAllocations(i.statementallocations)),
openSum: useSum().getCreatedDocumentOpenAmount(i, createddocuments.value).toFixed(2)
}))
openIncomingInvoices.value = invoiceItems
.filter(i => i.state === "Gebucht" && !i.archived && i.statementallocations.reduce((n, {amount}) => n + amount, 0).toFixed(2) !== getInvoiceSum(i, false))
.filter(i => i.state === "Gebucht" && !i.archived && sumSettledAllocations(i.statementallocations).toFixed(2) !== getInvoiceSum(i, false))
} catch (err) {
console.error("Vorschlagsdaten konnten nicht geladen werden:", err)
} finally {
@@ -232,7 +233,7 @@ const calculateOpenSum = (statement) => {
const getInvoiceSum = (invoice, onlyOpenSum) => {
let sum = Number(useSum().getIncomingInvoiceSum(invoice))
if (onlyOpenSum) sum = sum + Number(invoice.statementallocations.reduce((n, {amount}) => n + amount, 0))
if (onlyOpenSum) sum = sum + Number(sumSettledAllocations(invoice.statementallocations))
if (invoice.expense) {
return (sum * -1).toFixed(2)
@@ -386,25 +387,52 @@ const getTopEntitySuggestion = (statement) => {
return suggestions[0] || null
}
// Zahlung entspricht dem offenen Betrag abzüglich vereinbartem Skonto und liegt in der Frist.
const getCashDiscountMatch = (statement, kind, entity) => {
const paymentDate = getStatementDate(statement) ? $dayjs(getStatementDate(statement)).format("YYYY-MM-DD") : null
if (kind === "createddocument") {
const days = entity.cashDiscountDays
return matchCashDiscountPayment({
open: entity.openSum,
payment: calculateOpenSum(statement),
percent: entity.cashDiscountPercent,
dueDate: entity.documentDate && days !== null && days !== undefined && days !== ""
? $dayjs(entity.documentDate).add(Number(days), "day").format("YYYY-MM-DD")
: null,
paymentDate
})
}
return matchCashDiscountPayment({
open: getInvoiceSum(entity, true),
payment: calculateOpenSum(statement),
percent: entity.cashDiscountPercent,
dueDate: entity.cashDiscountDueDate || null,
paymentDate
})
}
const getDocumentSuggestionMeta = (statement, document, suggestion) => {
let score = 0
const remaining = Math.abs(Number(calculateOpenSum(statement)))
const openSum = Math.abs(Number(document.openSum))
const purposeMatches = getMatchingPurposeParts(statement.text, [document.documentNumber, document.title, document.description])
const amountLabel = getAmountMatchLabel(openSum, remaining)
const withinTolerance = isWithinAmountTolerance(openSum, remaining)
const cashDiscountMatch = getCashDiscountMatch(statement, "createddocument", document)
const amountLabel = cashDiscountMatch
? `Passt mit ${String(cashDiscountMatch.percent).replace(".", ",")} % Skonto`
: getAmountMatchLabel(openSum, remaining)
const withinTolerance = cashDiscountMatch || isWithinAmountTolerance(openSum, remaining)
if (!withinTolerance) {
return { score: 0, amountLabel: null, purposeMatches: [] }
return { score: 0, amountLabel: null, purposeMatches: [], cashDiscountMatch: null }
}
if (openSum === remaining) score += 100
if (openSum === remaining || cashDiscountMatch) score += 100
else score += Math.max(0, 60 - Math.abs(openSum - remaining))
if (suggestion && document.customer?.id === suggestion.id) score += 80
if (purposeMatches.length > 0) score += 50 + (purposeMatches.length * 10)
return { score, amountLabel, purposeMatches }
return { score, amountLabel, purposeMatches, cashDiscountMatch }
}
const getIncomingInvoiceSuggestionMeta = (statement, invoice, suggestion) => {
@@ -412,20 +440,23 @@ const getIncomingInvoiceSuggestionMeta = (statement, invoice, suggestion) => {
const remaining = Math.abs(Number(calculateOpenSum(statement)))
const openSum = Math.abs(Number(getInvoiceSum(invoice, true)))
const purposeMatches = getMatchingPurposeParts(statement.text, [invoice.reference, invoice.description])
const amountLabel = getAmountMatchLabel(openSum, remaining)
const withinTolerance = isWithinAmountTolerance(openSum, remaining)
const cashDiscountMatch = getCashDiscountMatch(statement, "incominginvoice", invoice)
const amountLabel = cashDiscountMatch
? `Passt mit ${String(cashDiscountMatch.percent).replace(".", ",")} % Skonto`
: getAmountMatchLabel(openSum, remaining)
const withinTolerance = cashDiscountMatch || isWithinAmountTolerance(openSum, remaining)
if (!withinTolerance) {
return { score: 0, amountLabel: null, purposeMatches: [] }
return { score: 0, amountLabel: null, purposeMatches: [], cashDiscountMatch: null }
}
if (openSum === remaining) score += 100
if (openSum === remaining || cashDiscountMatch) score += 100
else score += Math.max(0, 60 - Math.abs(openSum - remaining))
if (suggestion && invoice.vendor?.id === suggestion.id) score += 80
if (purposeMatches.length > 0) score += 50 + (purposeMatches.length * 10)
return { score, amountLabel, purposeMatches }
return { score, amountLabel, purposeMatches, cashDiscountMatch }
}
const getRowSuggestions = (statement) => {
@@ -434,7 +465,7 @@ const getRowSuggestions = (statement) => {
const topDocument = (entitySuggestion?.type === "customer" ? openDocuments.value.filter(doc => doc.customer?.id === entitySuggestion.id) : openDocuments.value)
.map(document => {
const meta = getDocumentSuggestionMeta(statement, document, entitySuggestion?.type === "customer" ? entitySuggestion : null)
return { ...document, suggestionScore: meta.score, suggestionAmountLabel: meta.amountLabel, suggestionPurposeMatches: meta.purposeMatches, suggestionKey: `document:${document.id}` }
return { ...document, suggestionScore: meta.score, suggestionAmountLabel: meta.amountLabel, suggestionPurposeMatches: meta.purposeMatches, suggestionCashDiscount: meta.cashDiscountMatch, suggestionKey: `document:${document.id}` }
})
.filter(document => !isSuggestionDismissed(statement.id, document.suggestionKey))
.filter(document => document.suggestionScore >= 80 || document.suggestionPurposeMatches?.length > 0)
@@ -443,7 +474,7 @@ const getRowSuggestions = (statement) => {
const topIncomingInvoice = (entitySuggestion?.type === "vendor" ? openIncomingInvoices.value.filter(invoice => invoice.vendor?.id === entitySuggestion.id) : openIncomingInvoices.value)
.map(invoice => {
const meta = getIncomingInvoiceSuggestionMeta(statement, invoice, entitySuggestion?.type === "vendor" ? entitySuggestion : null)
return { ...invoice, suggestionScore: meta.score, suggestionAmountLabel: meta.amountLabel, suggestionPurposeMatches: meta.purposeMatches, suggestionKey: `invoice:${invoice.id}` }
return { ...invoice, suggestionScore: meta.score, suggestionAmountLabel: meta.amountLabel, suggestionPurposeMatches: meta.purposeMatches, suggestionCashDiscount: meta.cashDiscountMatch, suggestionKey: `invoice:${invoice.id}` }
})
.filter(invoice => !isSuggestionDismissed(statement.id, invoice.suggestionKey))
.filter(invoice => invoice.suggestionScore >= 80 || invoice.suggestionPurposeMatches?.length > 0)
@@ -551,6 +582,24 @@ const handleAssignIncomingInvoice = async (row, invoice, event) => {
})
}
// Bucht die Zahlung zusammen mit dem vereinbarten Skonto, wenn Betrag und Frist zu den Skontobedingungen passen.
const handleAssignWithCashDiscount = async (row, kind, entity, event) => {
event.stopPropagation()
const match = entity.suggestionCashDiscount
if (!match) return
try {
await saveAllocation({
[kind]: entity.id,
bankstatement: row.id,
amount: Number(calculateOpenSum(row)),
cashDiscountAmount: match.discount,
description: "Automatischer Vorschlag mit Skonto"
})
} catch (err) {
toast.add({ title: "Skonto konnte nicht gebucht werden", description: err?.data?.error, color: "error" })
}
}
const dismissSuggestion = (row, suggestionKey, event) => {
event.stopPropagation()
const bankingSettings = tempStore.settings?.banking || {}
@@ -927,7 +976,15 @@ onMounted(() => {
</UBadge>
</div>
</div>
<UButton size="sm" color="emerald" @click="handleAssignDocument(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topDocument, $event)">
<UButton
v-if="selectedSuggestionRow.suggestions.topDocument.suggestionCashDiscount"
size="sm"
color="warning"
@click="handleAssignWithCashDiscount(selectedSuggestionRow.row, 'createddocument', selectedSuggestionRow.suggestions.topDocument, $event)"
>
Mit Skonto zuweisen
</UButton>
<UButton v-else size="sm" color="emerald" @click="handleAssignDocument(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topDocument, $event)">
Zuweisen
</UButton>
<UButton size="sm" color="gray" variant="ghost" @click="dismissSuggestion(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topDocument.suggestionKey, $event)">
@@ -950,7 +1007,15 @@ onMounted(() => {
</UBadge>
</div>
</div>
<UButton size="sm" color="error" @click="handleAssignIncomingInvoice(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topIncomingInvoice, $event)">
<UButton
v-if="selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionCashDiscount"
size="sm"
color="warning"
@click="handleAssignWithCashDiscount(selectedSuggestionRow.row, 'incominginvoice', selectedSuggestionRow.suggestions.topIncomingInvoice, $event)"
>
Mit Skonto zuweisen
</UButton>
<UButton v-else size="sm" color="error" @click="handleAssignIncomingInvoice(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topIncomingInvoice, $event)">
Zuweisen
</UButton>
<UButton size="sm" color="gray" variant="ghost" @click="dismissSuggestion(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionKey, $event)">

View File

@@ -1,6 +1,7 @@
<script setup>
import dayjs from "dayjs";
import { BANK_ALLOCATION_TAX_ITEMS, getBankAllocationAmounts } from "~/utils/bankAllocationTax"
import { matchCashDiscountPayment, sumSettledAllocations } from "~/utils/cashDiscount"
import {
DEPRECIATION_METHOD_ITEMS,
EXPENSE_BOOKING_MODE_ITEMS,
@@ -54,7 +55,7 @@ const rebuildDocumentLists = () => {
return {
...i,
docTotal: useSum().getCreatedDocumentSum(i, createddocuments.value),
statementTotal: Number(i.statementallocations.reduce((n, {amount}) => n + amount, 0)),
statementTotal: Number(sumSettledAllocations(i.statementallocations)),
openSum: useSum().getCreatedDocumentOpenAmount(i, createddocuments.value).toFixed(2)
}
})
@@ -62,7 +63,7 @@ const rebuildDocumentLists = () => {
allocatedDocuments.value = documents.filter(i => i.statementallocations.find(x => x.bankstatement === itemInfo.value.id))
allocatedIncomingInvoices.value = incominginvoices.value.filter(i => i.statementallocations.find(x => x.bankstatement === itemInfo.value.id))
openIncomingInvoices.value = incominginvoices.value.filter(i =>
!i.archived && i.statementallocations.reduce((n, {amount}) => n + amount, 0).toFixed(2) !== getInvoiceSum(i, false)
!i.archived && sumSettledAllocations(i.statementallocations).toFixed(2) !== getInvoiceSum(i, false)
)
}
@@ -136,7 +137,7 @@ const separateIBAN = (input) => {
const getInvoiceSum = (invoice, onlyOpenSum) => {
let sum = Number(useSum().getIncomingInvoiceSum(invoice))
if (onlyOpenSum) sum = sum + Number(invoice.statementallocations.reduce((n, {amount}) => n + amount, 0))
if (onlyOpenSum) sum = sum + Number(sumSettledAllocations(invoice.statementallocations))
if (invoice.expense) {
return (sum * -1).toFixed(2)
@@ -420,6 +421,30 @@ const isWithinAmountTolerance = (candidateSum, remaining) => {
return difference / reference <= 0.1
}
// Erkennt Zahlungen, die genau dem offenen Betrag abzüglich vereinbartem Skonto entsprechen und in der Frist liegen.
const getCashDiscountMatch = (kind, entity) => {
const paymentDate = itemInfo.value?.date || itemInfo.value?.valueDate
if (kind === "createddocument") {
const days = entity.cashDiscountDays
return matchCashDiscountPayment({
open: entity.openSum,
payment: calculateOpenSum.value,
percent: entity.cashDiscountPercent,
dueDate: entity.documentDate && days !== null && days !== undefined && days !== ""
? dayjs(entity.documentDate).add(Number(days), "day").format("YYYY-MM-DD")
: null,
paymentDate: paymentDate ? dayjs(paymentDate).format("YYYY-MM-DD") : null
})
}
return matchCashDiscountPayment({
open: getInvoiceSum(entity, true),
payment: calculateOpenSum.value,
percent: entity.cashDiscountPercent,
dueDate: entity.cashDiscountDueDate || null,
paymentDate: paymentDate ? dayjs(paymentDate).format("YYYY-MM-DD") : null
})
}
const getDocumentSuggestionMeta = (document, suggestion) => {
let score = 0
const remaining = Math.abs(Number(calculateOpenSum.value))
@@ -429,18 +454,22 @@ const getDocumentSuggestionMeta = (document, suggestion) => {
document.title,
document.description
])
const amountLabel = getAmountMatchLabel(openSum, remaining)
const withinTolerance = isWithinAmountTolerance(openSum, remaining)
const cashDiscountMatch = getCashDiscountMatch("createddocument", document)
const amountLabel = cashDiscountMatch
? `Passt mit ${String(cashDiscountMatch.percent).replace(".", ",")} % Skonto`
: getAmountMatchLabel(openSum, remaining)
const withinTolerance = cashDiscountMatch || isWithinAmountTolerance(openSum, remaining)
if (!withinTolerance) {
return {
score: 0,
purposeMatches: [],
amountLabel: null
amountLabel: null,
cashDiscountMatch: null
}
}
if (openSum === remaining) score += 100
if (openSum === remaining || cashDiscountMatch) score += 100
else score += Math.max(0, 60 - Math.abs(openSum - remaining))
if (suggestion && document.customer?.id === suggestion.id) score += 80
@@ -449,7 +478,8 @@ const getDocumentSuggestionMeta = (document, suggestion) => {
return {
score,
purposeMatches,
amountLabel
amountLabel,
cashDiscountMatch
}
}
@@ -461,18 +491,22 @@ const getInvoiceSuggestionMeta = (invoice, suggestion) => {
invoice.reference,
invoice.description
])
const amountLabel = getAmountMatchLabel(openSum, remaining)
const withinTolerance = isWithinAmountTolerance(openSum, remaining)
const cashDiscountMatch = getCashDiscountMatch("incominginvoice", invoice)
const amountLabel = cashDiscountMatch
? `Passt mit ${String(cashDiscountMatch.percent).replace(".", ",")} % Skonto`
: getAmountMatchLabel(openSum, remaining)
const withinTolerance = cashDiscountMatch || isWithinAmountTolerance(openSum, remaining)
if (!withinTolerance) {
return {
score: 0,
purposeMatches: [],
amountLabel: null
amountLabel: null,
cashDiscountMatch: null
}
}
if (openSum === remaining) score += 100
if (openSum === remaining || cashDiscountMatch) score += 100
else score += Math.max(0, 60 - Math.abs(openSum - remaining))
if (suggestion && invoice.vendor?.id === suggestion.id) score += 80
@@ -481,7 +515,8 @@ const getInvoiceSuggestionMeta = (invoice, suggestion) => {
return {
score,
purposeMatches,
amountLabel
amountLabel,
cashDiscountMatch
}
}
@@ -497,6 +532,7 @@ const suggestedDocuments = computed(() => {
suggestionScore: meta.score,
suggestionPurposeMatches: meta.purposeMatches,
suggestionAmountLabel: meta.amountLabel,
suggestionCashDiscount: meta.cashDiscountMatch,
suggestionKey: `document:${document.id}`
}
})
@@ -517,6 +553,7 @@ const suggestedIncomingInvoices = computed(() => {
suggestionScore: meta.score,
suggestionPurposeMatches: meta.purposeMatches,
suggestionAmountLabel: meta.amountLabel,
suggestionCashDiscount: meta.cashDiscountMatch,
suggestionKey: `invoice:${invoice.id}`
}
})
@@ -536,6 +573,7 @@ const fallbackSuggestedDocuments = computed(() => {
suggestionScore: meta.score,
suggestionPurposeMatches: meta.purposeMatches,
suggestionAmountLabel: meta.amountLabel,
suggestionCashDiscount: meta.cashDiscountMatch,
suggestionKey: `document:${document.id}`
}
})
@@ -556,6 +594,7 @@ const fallbackSuggestedIncomingInvoices = computed(() => {
suggestionScore: meta.score,
suggestionPurposeMatches: meta.purposeMatches,
suggestionAmountLabel: meta.amountLabel,
suggestionCashDiscount: meta.cashDiscountMatch,
suggestionKey: `invoice:${invoice.id}`
}
})
@@ -603,6 +642,76 @@ const getAllocationBookingModeLabel = (item) => {
return "Sofortaufwand"
}
// Skonto: Die Zahlung gleicht zusammen mit dem Skonto den offenen Betrag des Belegs aus.
const cashDiscountModalOpen = ref(false)
const cashDiscountTarget = ref(null)
const cashDiscountInput = ref(0)
const savingCashDiscount = ref(false)
const cashDiscountError = ref("")
const getCashDiscountCandidate = (kind, entity) => {
const open = Number(kind === "createddocument" ? entity.openSum : getInvoiceSum(entity, true))
const payment = Number(manualAllocationSum.value)
if (!open || !payment || Math.sign(open) !== Math.sign(payment)) return null
if (Math.abs(payment) >= Math.abs(open) - 0.004) return null
return { open, payment, discount: Number((open - payment).toFixed(2)) }
}
const canBookCashDiscount = (kind, entity) => Boolean(getCashDiscountCandidate(kind, entity))
const openCashDiscountModal = (kind, entity) => {
const candidate = getCashDiscountCandidate(kind, entity)
if (!candidate) return
cashDiscountTarget.value = {
kind,
id: entity.id,
label: kind === "createddocument"
? `${entity.documentNumber} (${entity.customer?.name || "Ohne Kunde"})`
: `${entity.reference || "Eingangsbeleg"} (${entity.vendor?.name || "Ohne Lieferant"})`,
...candidate
}
cashDiscountInput.value = Math.abs(candidate.discount)
cashDiscountError.value = ""
cashDiscountModalOpen.value = true
}
const cashDiscountPercent = computed(() => {
const target = cashDiscountTarget.value
if (!target || !target.open) return 0
return Math.abs(Number(cashDiscountInput.value || 0)) / Math.abs(target.open) * 100
})
const cashDiscountRemaining = computed(() => {
const target = cashDiscountTarget.value
if (!target) return 0
return Number((Math.abs(target.open) - Math.abs(target.payment) - Math.abs(Number(cashDiscountInput.value || 0))).toFixed(2))
})
const confirmCashDiscount = async () => {
const target = cashDiscountTarget.value
if (!target || savingCashDiscount.value) return
savingCashDiscount.value = true
try {
await saveAllocation({
[target.kind]: target.id,
bankstatement: itemInfo.value.id,
amount: target.payment,
cashDiscountAmount: Math.abs(Number(cashDiscountInput.value || 0)),
description: allocationDescription.value || "Zahlung mit Skonto"
})
cashDiscountModalOpen.value = false
cashDiscountTarget.value = null
} catch (err) {
cashDiscountError.value = err?.data?.error || "Zahlung und Skonto konnten nicht gebucht werden."
} finally {
savingCashDiscount.value = false
}
}
const getCashDiscountSplitLabel = (item) => (item.cashDiscountSplit || [])
.map(split => `${split.account} (${split.rate} %): ${displayCurrency(split.gross)}`)
.join(" · ")
setup()
</script>
@@ -726,6 +835,9 @@ setup()
{{ getAllocationTaxLabel(item) }} · Netto {{ displayCurrency(getBankAllocationAmounts(item).amountNet) }}
· Steuer {{ displayCurrency(getBankAllocationAmounts(item).amountTax) }}
</div>
<div v-if="Number(item.cashDiscountAmount)" class="text-xs text-amber-700 dark:text-amber-400 mt-1">
Skonto {{ displayCurrency(item.cashDiscountAmount) }}<span v-if="item.cashDiscountSplit?.length"> · {{ getCashDiscountSplitLabel(item) }}</span>
</div>
<div class="mt-1 flex flex-wrap gap-1" v-if="item.bookingMode && item.bookingMode !== 'expense'">
<UBadge size="xs" color="warning" variant="soft">{{ getAllocationBookingModeLabel(item) }}</UBadge>
<UBadge size="xs" color="neutral" variant="soft" v-if="item.depreciationGroup">{{ item.depreciationGroup }}</UBadge>
@@ -756,6 +868,9 @@ setup()
</div>
<div class="text-right">
<div class="font-mono text-sm font-semibold">{{ displayCurrency(item.amount) }}</div>
<div v-if="Number(item.cashDiscountAmount)" class="font-mono text-xs text-amber-600">
+ {{ displayCurrency(item.cashDiscountAmount) }} Skonto
</div>
<UButton
icon="i-heroicons-trash"
color="error"
@@ -1003,6 +1118,15 @@ setup()
>
Rechnung zuweisen
</UButton>
<UButton
v-if="canBookCashDiscount('createddocument', document)"
size="xs"
color="warning"
:variant="document.suggestionCashDiscount ? 'solid' : 'soft'"
@click="openCashDiscountModal('createddocument', document)"
>
Mit Skonto
</UButton>
<UButton
size="xs"
color="gray"
@@ -1037,6 +1161,15 @@ setup()
>
Beleg zuweisen
</UButton>
<UButton
v-if="canBookCashDiscount('incominginvoice', invoice)"
size="xs"
color="warning"
:variant="invoice.suggestionCashDiscount ? 'solid' : 'soft'"
@click="openCashDiscountModal('incominginvoice', invoice)"
>
Mit Skonto
</UButton>
<UButton
size="xs"
color="gray"
@@ -1087,6 +1220,7 @@ setup()
<div class="text-right">
<div class="font-mono font-bold text-sm">{{ displayCurrency(document.openSum) }}</div>
<div class="text-xs text-gray-400">Offen</div>
<UBadge v-if="getCashDiscountMatch('createddocument', document)" size="xs" color="warning" variant="subtle">Skonto passt</UBadge>
</div>
<UTooltip text="Zuweisen">
<UButton
@@ -1098,6 +1232,16 @@ setup()
@click="saveAllocation({createddocument: document.id, bankstatement: itemInfo.id, amount: Number(Number(document.openSum) < manualAllocationSum ? document.openSum : manualAllocationSum), description: allocationDescription})"
/>
</UTooltip>
<UTooltip text="Mit Skonto ausgleichen">
<UButton
v-if="!itemInfo.statementallocations.find(i => i.createddocument === document.id) && canBookCashDiscount('createddocument', document)"
icon="i-heroicons-receipt-percent"
size="sm"
color="warning"
variant="soft"
@click="openCashDiscountModal('createddocument', document)"
/>
</UTooltip>
<UButton
icon="i-heroicons-eye"
color="gray"
@@ -1146,6 +1290,7 @@ setup()
{{ displayCurrency(getInvoiceSum(item, true)) }}
</div>
<div class="text-xs text-gray-400">Offen</div>
<UBadge v-if="getCashDiscountMatch('incominginvoice', item)" size="xs" color="warning" variant="subtle">Skonto passt</UBadge>
</div>
<UTooltip text="Zuweisen">
<UButton
@@ -1157,6 +1302,16 @@ setup()
@click="saveAllocation({incominginvoice: item.id, bankstatement: itemInfo.id, amount: Number(Math.abs(getInvoiceSum(item,true)) > Math.abs(manualAllocationSum) ? manualAllocationSum : getInvoiceSum(item,true)), description: allocationDescription})"
/>
</UTooltip>
<UTooltip text="Mit Skonto ausgleichen">
<UButton
v-if="!itemInfo.statementallocations.find(i => i.incominginvoice === item.id) && canBookCashDiscount('incominginvoice', item)"
icon="i-heroicons-receipt-percent"
size="sm"
color="warning"
variant="soft"
@click="openCashDiscountModal('incominginvoice', item)"
/>
</UTooltip>
<UButton
icon="i-heroicons-eye"
color="gray"
@@ -1180,6 +1335,70 @@ setup()
</div>
</div>
</UDashboardPanelContent>
<UModal v-model:open="cashDiscountModalOpen" :ui="{ width: 'sm:max-w-lg' }">
<template #content>
<UCard v-if="cashDiscountTarget">
<template #header>
<div class="text-lg font-semibold">Mit Skonto ausgleichen</div>
<div class="text-sm text-gray-500 mt-0.5">{{ cashDiscountTarget.label }}</div>
</template>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-2 text-sm">
<div class="text-gray-500">Offener Betrag</div>
<div class="text-right font-mono">{{ displayCurrency(Math.abs(cashDiscountTarget.open)) }}</div>
<div class="text-gray-500">Zahlung aus diesem Umsatz</div>
<div class="text-right font-mono">{{ displayCurrency(Math.abs(cashDiscountTarget.payment)) }}</div>
</div>
<UFormField label="Skonto (brutto)" :help="`${cashDiscountPercent.toFixed(2).replace('.', ',')} % des offenen Betrags`">
<UInput v-model="cashDiscountInput" type="number" step="0.01" min="0">
<template #trailing><span class="text-gray-500 text-xs">EUR</span></template>
</UInput>
</UFormField>
<UAlert
v-if="cashDiscountPercent > 5"
color="warning"
variant="subtle"
icon="i-heroicons-exclamation-triangle"
title="Ungewöhnlich hoher Skonto"
description="Bitte prüfen, ob es sich wirklich um Skonto handelt und nicht um eine Teilzahlung."
/>
<UAlert
v-if="cashDiscountError"
color="error"
variant="subtle"
icon="i-heroicons-x-circle"
:description="cashDiscountError"
/>
<div v-if="Math.abs(cashDiscountRemaining) >= 0.01" class="text-sm text-amber-600">
Danach bleiben {{ displayCurrency(cashDiscountRemaining) }} offen.
</div>
<p class="text-xs text-gray-500">
Der Skonto wird anteilig nach Steuersätzen auf die Skontokonten verteilt
({{ cashDiscountTarget.kind === 'createddocument' ? 'gewährte' : 'erhaltene' }} Skonti) und korrigiert die
{{ cashDiscountTarget.kind === 'createddocument' ? 'Umsatzsteuer' : 'Vorsteuer' }} im Monat der Zahlung.
</p>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton color="neutral" variant="ghost" @click="cashDiscountModalOpen = false">Abbrechen</UButton>
<UButton
color="primary"
:loading="savingCashDiscount"
:disabled="!(Number(cashDiscountInput) > 0) || cashDiscountRemaining < -0.009"
@click="confirmCashDiscount"
>
Zahlung und Skonto buchen
</UButton>
</div>
</template>
</UCard>
</template>
</UModal>
</template>
<style scoped>

View File

@@ -5,6 +5,8 @@ import { v4 as uuidv4 } from 'uuid';
import {useFunctions} from "~/composables/useFunctions.js";
import EntityModalButtons from "~/components/EntityModalButtons.vue";
import { documentTemplateHandlebars } from "~/utils/handlebars";
import { getTitleSectionMove, moveTitleSection } from "~/utils/documentTitleSections.js";
import { appendCashDiscountText, buildCashDiscountTerms, formatCashDiscountText, getCashDiscountTemplateContext } from "~/utils/cashDiscount";
const dataStore = useDataStore()
const profileStore = useProfileStore()
@@ -56,6 +58,8 @@ const itemInfo = ref({
deliveryDateType: "Lieferdatum",
dateOfPerformance: null,
paymentDays: auth.activeTenantData.standardPaymentDays,
cashDiscountPercent: null,
cashDiscountDays: null,
payment_type: "transfer",
outgoingsepamandate: null,
availableInPortal: false,
@@ -634,7 +638,11 @@ const setupPage = async () => {
if (optionsToImport.deliveryDate) itemInfo.value.deliveryDate = linkedDocument.deliveryDate
if (optionsToImport.deliveryDateEnd) itemInfo.value.deliveryDateEnd = linkedDocument.deliveryDateEnd
if (optionsToImport.documentDate) itemInfo.value.documentDate = linkedDocument.documentDate
if (optionsToImport.paymentDays) itemInfo.value.paymentDays = linkedDocument.paymentDays
if (optionsToImport.paymentDays) {
itemInfo.value.paymentDays = linkedDocument.paymentDays
itemInfo.value.cashDiscountPercent = linkedDocument.cashDiscountPercent ?? null
itemInfo.value.cashDiscountDays = linkedDocument.cashDiscountDays ?? null
}
if (optionsToImport.payment_type) itemInfo.value.payment_type = linkedDocument.payment_type
if (optionsToImport.customSurchargePercentage) itemInfo.value.customSurchargePercentage = linkedDocument.customSurchargePercentage
if (optionsToImport.contactPerson) itemInfo.value.contactPerson = linkedDocument.contactPerson
@@ -662,6 +670,8 @@ const setupPage = async () => {
itemInfo.value.deliveryDateEnd = linkedDocument.deliveryDateEnd
itemInfo.value.documentDate = linkedDocument.documentDate
itemInfo.value.paymentDays = linkedDocument.paymentDays
itemInfo.value.cashDiscountPercent = linkedDocument.cashDiscountPercent ?? null
itemInfo.value.cashDiscountDays = linkedDocument.cashDiscountDays ?? null
itemInfo.value.payment_type = linkedDocument.payment_type
itemInfo.value.customSurchargePercentage = linkedDocument.customSurchargePercentage
itemInfo.value.contactPerson = linkedDocument.contactPerson
@@ -730,6 +740,12 @@ const setupPage = async () => {
await router.replace('/createDocument')
return
}
if (route.query.regeneratePdf && route.params.id) {
await regenerateMissingPdf()
return
}
loaded.value = true
}
@@ -826,6 +842,10 @@ const setCustomerData = async (customerId, loadOnlyAdress = false) => {
itemInfo.value.address.country = customer.infoData.country
if (!loadOnlyAdress && customer.customPaymentDays) itemInfo.value.paymentDays = customer.customPaymentDays
if (!loadOnlyAdress && customer.customCashDiscountPercent) {
itemInfo.value.cashDiscountPercent = customer.customCashDiscountPercent
itemInfo.value.cashDiscountDays = customer.customCashDiscountDays ?? null
}
if (!loadOnlyAdress && customer.custom_payment_type) itemInfo.value.payment_type = customer.custom_payment_type
if (!loadOnlyAdress) {
itemInfo.value.taxType = normalizeTaxTypeValue(customer.customTaxType)
@@ -1011,6 +1031,24 @@ const toggleTitleSection = (rowId) => {
collapsedTitleRowIds.value = nextCollapsedTitleRowIds
}
const moveDocumentTitleSection = (rowId, direction) => {
if (itemInfo.value.type === 'cancellationInvoices') return
const rows = moveTitleSection(itemInfo.value.rows, rowId, direction)
if (rows === itemInfo.value.rows) return
itemInfo.value.rows = rows
setPosNumbers()
}
const getTitleSectionMoveMenuItems = (rowId) => [
{ label: 'Titelbereich nach oben', icon: 'i-heroicons-arrow-up', direction: 'up' },
{ label: 'Titelbereich nach unten', icon: 'i-heroicons-arrow-down', direction: 'down' }
].map(({ direction, ...item }) => ({
...item,
disabled: itemInfo.value.type === 'cancellationInvoices'
|| !getTitleSectionMove(itemInfo.value.rows, rowId, direction),
onSelect: () => moveDocumentTitleSection(rowId, direction)
}))
const hiddenDocumentRowIds = computed(() => {
const hiddenRowIds = new Set()
const collapsedTitleLevels = []
@@ -1093,8 +1131,54 @@ const checkAddressZip = async () => {
}
}
// Pflichtangaben der E-Rechnung (ZUGFeRD) prüft das Backend, da Firmen- und Kundendaten dort vollständig vorliegen
const eInvoiceDocumentTypes = ["invoices", "advanceInvoices", "cancellationInvoices", "serialInvoices"]
const eInvoiceIssues = ref([])
const eInvoiceCheckSource = computed(() => {
if (!loaded.value || isTemplateMode.value || !eInvoiceDocumentTypes.includes(itemInfo.value.type)) return null
const item = itemInfo.value
return JSON.stringify({
type: item.type,
customer: item.customer,
contact: item.contact,
project: item.project,
contract: item.contract,
address: item.address,
rows: item.rows,
taxType: item.taxType,
payment_type: item.payment_type,
outgoingsepamandate: item.outgoingsepamandate,
deliveryDateType: item.deliveryDateType,
usedAdvanceInvoices: item.usedAdvanceInvoices,
createddocument: item.createddocument,
created_by: item.created_by,
})
})
const refreshEInvoiceIssues = async () => {
const source = eInvoiceCheckSource.value
if (!source) {
eInvoiceIssues.value = []
return eInvoiceIssues.value
}
try {
const result = await useNuxtApp().$api("/api/functions/einvoice/check", {
method: "POST",
body: { document: JSON.parse(source) },
})
// Nur übernehmen, wenn sich der Beleg während der Anfrage nicht geändert hat
if (source === eInvoiceCheckSource.value) eInvoiceIssues.value = result?.issues || []
} catch (error) {
console.error(error)
eInvoiceIssues.value = [{ message: "E-Rechnung: Die Pflichtangaben konnten nicht geprüft werden", type: "breaking" }]
}
return eInvoiceIssues.value
}
watchDebounced(eInvoiceCheckSource, refreshEInvoiceIssues, { debounce: 500, immediate: true })
const findDocumentErrors = computed(() => {
let errors = []
let errors = [...eInvoiceIssues.value]
if (itemInfo.value.customer === null) errors.push({message: "Es ist kein Kunde ausgewählt", type: "breaking"})
if (itemInfo.value.contact === null) errors.push({message: "Es ist kein Kontakt ausgewählt", type: "info"})
@@ -1332,6 +1416,17 @@ const documentTotal = computed(() => {
}
})
// Skonto bezieht sich bei Schlussrechnungen auf die Restforderung nach Abzug der Abschläge.
const cashDiscountTerms = computed(() => {
if (!["invoices", "advanceInvoices", "serialInvoices"].includes(itemInfo.value.type)) return null
return buildCashDiscountTerms({
percent: itemInfo.value.cashDiscountPercent,
days: itemInfo.value.cashDiscountDays,
documentDate: itemInfo.value.documentDate ? dayjs(itemInfo.value.documentDate).format("YYYY-MM-DD") : null,
amount: itemInfo.value.type === "invoices" ? documentTotal.value.totalSumToPay : documentTotal.value.totalGross
})
})
const documentReport = computed(() => {
let totalProductsPurchasePrice = 0
@@ -1549,7 +1644,8 @@ const getDocumentData = async () => {
kundenname: customerData && customerData.name,
zahlungsziel_in_tagen: itemInfo.paymentDays,
zahlungsart: itemInfo.payment_type === "transfer" ? "Überweisung" : "Lastschrift",
diesel_gesamtverbrauch: (itemInfo.agriculture && itemInfo.agriculture.dieselUsageTotal) && itemInfo.agriculture.dieselUsageTotal
diesel_gesamtverbrauch: (itemInfo.agriculture && itemInfo.agriculture.dieselUsageTotal) && itemInfo.agriculture.dieselUsageTotal,
...getCashDiscountTemplateContext(cashDiscountTerms.value)
}
}
@@ -1659,7 +1755,7 @@ const getDocumentData = async () => {
],
title: itemInfo.value.title,
description: itemInfo.value.description,
endText: templateEndText(generateContext(itemInfo.value, contactData)),
endText: appendCashDiscountText(templateEndText(generateContext(itemInfo.value, contactData)), itemInfo.value.endText, cashDiscountTerms.value),
startText: templateStartText(generateContext(itemInfo.value, contactData)),
rows: rows,
totalArray: [
@@ -1714,7 +1810,7 @@ const getDocumentData = async () => {
const showDocument = ref(false)
const uri = ref("")
const generateDocument = async () => {
const generateDocument = async (options = {}) => {
showDocument.value = false
const selectedLetterhead = getSelectedLetterhead()
@@ -1725,7 +1821,7 @@ const generateDocument = async () => {
const path = selectedLetterhead.path
uri.value = await useFunctions().useCreatePDF(await getDocumentData(), path, "createdDocument")
uri.value = await useFunctions().useCreatePDF(await getDocumentData(), path, "createdDocument", options)
/*uri.value = await useNuxtApp().$api("/api/functions/createinvoicepdf",{
method: "POST",
body: {
@@ -1794,6 +1890,8 @@ const saveSerialInvoice = async () => {
project: normalizeEntityId(itemInfo.value.project),
costcentre: itemInfo.value.costcentre,
paymentDays: itemInfo.value.paymentDays,
cashDiscountPercent: itemInfo.value.cashDiscountPercent === "" || itemInfo.value.cashDiscountPercent === null ? null : Number(itemInfo.value.cashDiscountPercent),
cashDiscountDays: itemInfo.value.cashDiscountDays === "" || itemInfo.value.cashDiscountDays === null ? null : Number(itemInfo.value.cashDiscountDays),
payment_type: itemInfo.value.payment_type,
outgoingsepamandate: normalizeEntityId(itemInfo.value.outgoingsepamandate),
deliveryDateType: "Leistungszeitraum",
@@ -1910,6 +2008,8 @@ const saveDocument = async (state, resetup = false) => {
deliveryDate: itemInfo.value.deliveryDate,
deliveryDateEnd: itemInfo.value.deliveryDateEnd,
paymentDays: itemInfo.value.paymentDays,
cashDiscountPercent: itemInfo.value.cashDiscountPercent === "" || itemInfo.value.cashDiscountPercent === null ? null : Number(itemInfo.value.cashDiscountPercent),
cashDiscountDays: itemInfo.value.cashDiscountDays === "" || itemInfo.value.cashDiscountDays === null ? null : Number(itemInfo.value.cashDiscountDays),
payment_type: itemInfo.value.payment_type,
outgoingsepamandate: normalizeEntityId(itemInfo.value.outgoingsepamandate),
deliveryDateType: itemInfo.value.deliveryDateType,
@@ -1971,6 +2071,18 @@ const saveFinalizedCostCentres = async () => {
const selectedTab = ref("0")
const closeDocument = async () => {
// Vor Vorschau und Fertigstellung mit dem aktuellen Stand prüfen, nicht mit dem letzten (entprellten) Ergebnis
await refreshEInvoiceIssues()
const breakingErrors = findDocumentErrors.value.filter(i => i.type === "breaking")
if (breakingErrors.length > 0) {
selectedTab.value = "0"
toast.add({
title: "Beleg kann nicht fertiggestellt werden",
description: breakingErrors.map(i => i.message).join(" · "),
color: "error",
})
return
}
if(selectedTab.value === "0") {
await generateDocument()
@@ -1980,7 +2092,39 @@ const closeDocument = async () => {
await saveDocument("Gebucht")
await generateDocument()
try {
await uploadGeneratedDocument()
} catch (error) {
console.error(error)
toast.add({
title: "PDF bzw. E-Rechnung konnte nicht erstellt werden",
description: error?.data?.error || error?.message,
color: "error",
})
}
guard.value = false
await router.push(`/createDocument/show/${itemInfo.value.id}`)
}
}
// Erzeugt die PDF aus dem aktuellen Stand und hängt sie an den Beleg an, ohne den Datensatz zu speichern
const uploadGeneratedDocument = async () => {
// Rechnungen werden als E-Rechnung (ZUGFeRD) abgelegt; das XML erzeugt das Backend aus dem gespeicherten Beleg
await generateDocument({
createdDocumentId: itemInfo.value.id,
onResult: (result) => {
const hints = (result?.einvoiceIssues || []).map(i => i.message)
if (hints.length) toast.add({ title: "E-Rechnung mit Hinweisen erstellt", description: hints.join(" · "), color: "warning" })
},
})
if (!uri.value) throw new Error("PDF konnte nicht erzeugt werden")
let fileData = {}
@@ -2012,15 +2156,39 @@ const closeDocument = async () => {
let file = dataURLtoFile(uri.value, `${itemInfo.value.documentNumber}.pdf`)
await useFiles().uploadFiles(fileData, [file])
}
const regenerateMissingPdf = async () => {
guard.value = false
const showPath = `/createDocument/show/${route.params.id}`
await router.push(`/createDocument/show/${itemInfo.value.id}`)
try {
const document = await useEntities("createddocuments").selectSingle(route.params.id, "*,files(*)")
if (document?.state === "Entwurf" || document?.type === "serialInvoices") {
toast.add({ title: "PDF kann nur für fertiggestellte Belege nacherzeugt werden", color: "error" })
} else if ((document?.files || []).length > 0) {
toast.add({ title: "Am Beleg ist bereits ein Dokument angehängt", color: "warning" })
} else {
// Vertrags- und Mandats-Watcher überschreiben beim Laden die Zahlungsdaten, für die PDF gilt der gespeicherte Stand
const restoreStoredPaymentData = () => {
itemInfo.value.payment_type = document.payment_type
itemInfo.value.outgoingsepamandate = normalizeEntityId(document.outgoingsepamandate)
}
await nextTick()
restoreStoredPaymentData()
await nextTick()
restoreStoredPaymentData()
await uploadGeneratedDocument()
toast.add({ title: "PDF nacherzeugt", color: "success" })
}
} catch (error) {
console.error(error)
toast.add({ title: "PDF konnte nicht nacherzeugt werden", description: error?.data?.error || error?.message, color: "error" })
}
await router.replace(showPath)
}
const getTextTemplateByType = (type, pos) => {
@@ -2625,6 +2793,39 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
class="w-full"
/>
</UFormField>
<UFormField
v-if="['invoices', 'advanceInvoices', 'serialInvoices'].includes(itemInfo.type)"
class="min-w-0 flex-1"
label="Skonto:"
:help="cashDiscountTerms ? formatCashDiscountText(cashDiscountTerms) : undefined"
>
<div class="flex gap-1">
<UInput
type="number"
step="0.01"
min="0"
max="10"
placeholder="%"
v-model="itemInfo.cashDiscountPercent"
class="w-full"
>
<template #trailing>
<span class="text-gray-500 dark:text-gray-400 text-xs">%</span>
</template>
</UInput>
<UInput
type="number"
min="0"
placeholder="Tage"
v-model="itemInfo.cashDiscountDays"
class="w-full"
>
<template #trailing>
<span class="text-gray-500 dark:text-gray-400 text-xs">Tage</span>
</template>
</UInput>
</div>
</UFormField>
<UFormField
class="min-w-0 flex-1"
label="Individueller Aufschlag:"
@@ -3070,7 +3271,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
v-model="itemInfo.rows"
handle=".handle"
tag="tbody"
itemKey="pos"
itemKey="id"
@end="setPosNumbers"
>
<template #item="{element: row}">
@@ -3095,6 +3296,21 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
@click="toggleTitleSection(row.id)"
/>
</UTooltip>
<UDropdownMenu
v-if="row.mode === 'title' && itemInfo.type !== 'cancellationInvoices'"
:items="getTitleSectionMoveMenuItems(row.id)"
:content="{ align: 'start' }"
>
<UTooltip text="Titel mit untergeordneten Zeilen verschieben">
<UButton
class="shrink-0"
variant="ghost"
color="neutral"
icon="i-heroicons-arrows-up-down"
aria-label="Titel mit untergeordneten Zeilen verschieben"
/>
</UTooltip>
</UDropdownMenu>
</div>
</td>
<td

View File

@@ -164,6 +164,7 @@
</template>
<script setup>
import { sumSettledAllocations } from "~/utils/cashDiscount"
import CreateDocumentModal from "~/components/createDocumentModal.vue"
import CreateDocumentFromTemplateModal from "~/components/createDocumentFromTemplateModal.vue"
import dayjs from "dayjs";
@@ -337,10 +338,7 @@ const documentAmounts = computed(() => {
items.value.forEach((item) => {
const amount = sum.getCreatedDocumentSum(item, items.value)
const amountPaid = (item.statementallocations || []).reduce(
(total, allocation) => total + Number(allocation.amount || 0),
0
)
const amountPaid = sumSettledAllocations(item.statementallocations || [])
result.set(item.id, {
amount,

View File

@@ -110,10 +110,18 @@ const setupPage = async () => {
links.value = createddocuments.filter(i => i.createddocument?.id === itemInfo.value.id)
}
if (itemInfo.value.files?.length > 0) {
linkedDocument.value = await useFiles().selectDocument(itemInfo.value.files[0].id)
}
}
}
const canRegeneratePdf = computed(() => itemInfo.value.id
&& itemInfo.value.state !== 'Entwurf'
&& itemInfo.value.type !== 'serialInvoices'
&& Array.isArray(itemInfo.value.files)
&& itemInfo.value.files.length === 0)
setupPage()
const getStatementLike = (allocation) => allocation?.bankstatement || (typeof allocation?.bs_id === "object" ? allocation.bs_id : null)
@@ -282,6 +290,12 @@ const togglePortalRelease = async () => {
:loading="costCentresLoading"
@click="openCostCentres"
>Kostenstellen bearbeiten</UButton>
<UButton
v-if="canRegeneratePdf"
icon="i-heroicons-document-arrow-down"
variant="outline"
@click="router.push(`/createDocument/edit/${itemInfo.id}?regeneratePdf=1`)"
>PDF nacherzeugen</UButton>
<!-- <UButton
:to="dataStore.documents.find(i => i.createdDocument === itemInfo.id) ? dataStore.documents.find(i => i.createdDocument === itemInfo.id).url : ''"
target="_blank"
@@ -413,6 +427,7 @@ const togglePortalRelease = async () => {
<div>
<p class="text-xs text-gray-500">Betrag</p>
<p class="font-medium">{{ formatCurrency(allocation.amount) }}</p>
<p v-if="Number(allocation.cashDiscountAmount)" class="text-xs text-amber-600">+ {{ formatCurrency(allocation.cashDiscountAmount) }} Skonto</p>
</div>
<div>
<p class="text-xs text-gray-500">Text</p>

View File

@@ -34,6 +34,8 @@ const itemInfo = ref({
reference: "",
date: null,
dueDate: null,
cashDiscountPercent: null,
cashDiscountDueDate: null,
paymentType: "Überweisung",
description: "",
state: "Entwurf",
@@ -147,6 +149,28 @@ const setDateFieldToToday = (field) => {
itemInfo.value[field] = dayjs().toDate()
}
// Skontofrist als reines Datum speichern, damit der Vergleich mit dem Zahlungsdatum nicht durch Zeitzonen kippt.
const setCashDiscountDueDate = (value) => {
itemInfo.value.cashDiscountDueDate = value ? dayjs(value.toString()).format("YYYY-MM-DD") : null
}
// Lieferanten-Vorgabe nur bei bewusster Auswahl übernehmen, nicht beim Laden eines Belegs.
const applyVendorCashDiscount = (vendorId) => {
const vendor = vendors.value.find((item) => item.id === vendorId)
if (!vendor?.cashDiscountPercent || itemInfo.value.cashDiscountPercent) return
itemInfo.value.cashDiscountPercent = vendor.cashDiscountPercent
if (itemInfo.value.date && vendor.cashDiscountDays !== null && vendor.cashDiscountDays !== undefined) {
itemInfo.value.cashDiscountDueDate = dayjs(itemInfo.value.date).add(vendor.cashDiscountDays, "day").format("YYYY-MM-DD")
}
}
const cashDiscountAmount = computed(() => {
const percent = Number(itemInfo.value.cashDiscountPercent || 0)
if (!(percent > 0)) return null
const gross = (itemInfo.value.accounts || []).reduce((sum, account) => sum + Number(account.amountNet || 0) + Number(account.amountTax || 0), 0)
return Number((Math.abs(gross) * percent / 100).toFixed(2))
})
const getDateButtonLabel = (value, emptyLabel = "Kein Datum") => value ? dayjs(value).format('DD.MM.YYYY') : emptyLabel
const formatDate = (value) => value ? dayjs(value).format("DD.MM.YYYY") : "-"
const formatMoney = (value) => `${Number(value || 0).toFixed(2).replace(".", ",")} €`
@@ -200,6 +224,47 @@ const getAccountLabel = (item) => {
return account ? `${account.number || ""} ${account.label || ""}`.trim() : "-"
}
const getCostCentreLabel = (item) => costcentres.value.find((costcentre) => costcentre.id === item.costCentre)?.name || "-"
const canEditCostCentres = computed(() => mode.value === "show" && Boolean(itemInfo.value.id) && !itemInfo.value.archived)
const costCentresOpen = ref(false)
const costCentresSaving = ref(false)
const costCentreDraft = ref([])
const costCentreOptionLabel = (id) => {
const centre = costcentres.value.find((item) => item.id === id)
return centre ? [centre.number, centre.name].filter(Boolean).join(" - ") : (id ? "Bisherige Kostenstelle" : "Keine Kostenstelle")
}
const openCostCentres = () => {
costCentreDraft.value = (itemInfo.value.accounts || []).map((account, index) => ({
index,
label: `${index + 1}. ${account.description || getAccountLabel(account)}`,
costcentre: account.costCentre || null,
}))
costCentresOpen.value = true
}
const saveCostCentres = async () => {
if (costCentresSaving.value) return
costCentresSaving.value = true
try {
const accountCostCentres = costCentreDraft.value
.filter((row) => row.costcentre !== (itemInfo.value.accounts[row.index]?.costCentre || null))
.map(({ index, costcentre }) => ({ index, costcentre }))
if (accountCostCentres.length) {
const { invoice } = await useNuxtApp().$api(`/api/functions/incoming-invoices/${itemInfo.value.id}/cost-centres`, {
method: "PUT",
body: { accountCostCentres },
})
itemInfo.value.accounts = normalizeIncomingInvoiceAccounts(invoice.accounts || [], invoice.date)
toast.add({ title: "Kostenstellen gespeichert", color: "success" })
}
costCentresOpen.value = false
} catch (error) {
toast.add({ title: "Kostenstellen konnten nicht gespeichert werden", description: error?.data?.error || "Bitte versuche es erneut.", color: "error" })
} finally {
costCentresSaving.value = false
}
}
const getBookingModeLabel = (item) => EXPENSE_BOOKING_MODE_ITEMS.find((mode) => mode.value === item.bookingMode)?.label || "-"
const getTaxLabel = (item) => taxOptions.value.find((tax) => tax.key === item.taxType)?.label || "-"
@@ -292,6 +357,9 @@ const updateIncomingInvoice = async (setBooked = false) => {
let item = { ...itemInfo.value }
item.accounts = (item.accounts || []).map((account) => ensureDepreciationDefaults({ ...account }, item.date))
const cashDiscountPercent = Number(item.cashDiscountPercent)
item.cashDiscountPercent = Number.isFinite(cashDiscountPercent) && cashDiscountPercent > 0 ? cashDiscountPercent : null
item.cashDiscountDueDate = item.cashDiscountPercent && item.cashDiscountDueDate ? dayjs(item.cashDiscountDueDate).format("YYYY-MM-DD") : null
delete item.files
item.state = keepBooked ? "Gebucht" : "Entwurf"
@@ -362,6 +430,14 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
>
Bearbeiten
</UButton>
<UButton
v-if="canEditCostCentres"
icon="i-heroicons-tag"
variant="outline"
@click="openCostCentres"
>
Kostenstellen bearbeiten
</UButton>
<ArchiveButton
v-if="canArchiveIncomingInvoice"
color="error"
@@ -520,6 +596,14 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
<dt class="text-gray-500">Fälligkeitsdatum</dt>
<dd class="font-medium">{{ formatDate(itemInfo.dueDate) }}</dd>
</div>
<div v-if="itemInfo.cashDiscountPercent">
<dt class="text-gray-500">Skonto</dt>
<dd class="font-medium">
{{ String(itemInfo.cashDiscountPercent).replace(".", ",") }} %
<span v-if="cashDiscountAmount">({{ formatMoney(cashDiscountAmount) }})</span>
<span v-if="itemInfo.cashDiscountDueDate"> bis {{ formatDate(itemInfo.cashDiscountDueDate) }}</span>
</dd>
</div>
<div>
<dt class="text-gray-500">Bankbuchungsdatum</dt>
<dd class="font-medium">{{ bankBookingDateLabel }}</dd>
@@ -553,6 +637,7 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
<div>
<p class="text-xs text-gray-500">Betrag</p>
<p class="font-medium">{{ formatMoney(allocation.amount) }}</p>
<p v-if="Number(allocation.cashDiscountAmount)" class="text-xs text-amber-600">+ {{ formatMoney(allocation.cashDiscountAmount) }} Skonto</p>
</div>
<div>
<p class="text-xs text-gray-500">Text</p>
@@ -587,6 +672,7 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
:disabled="mode === 'show'"
:filter-fields="['name', 'vendorNumber']"
:color="itemInfo.vendor ? 'primary' : 'error'"
@update:model-value="applyVendorCashDiscount"
>
<template #default>
{{ vendors.find(v => v.id === itemInfo.vendor)?.name || 'Bitte wählen' }}
@@ -607,7 +693,7 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
v-if="mode !== 'show'"
type="vendors"
:id="itemInfo.vendor"
@return-data="(data) => itemInfo.vendor = data.id"
@return-data="(data) => { itemInfo.vendor = data.id; applyVendorCashDiscount(data.id) }"
/>
</div>
</UFormField>
@@ -686,6 +772,47 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
</UPopover>
</UFormField>
<UFormField
label="Skonto"
:help="cashDiscountAmount ? `Abzug ${formatMoney(cashDiscountAmount)} bei Zahlung in der Frist` : undefined"
>
<UInput
type="number"
step="0.01"
min="0"
max="10"
placeholder="z. B. 2"
v-model="itemInfo.cashDiscountPercent"
:disabled="mode === 'show'"
class="w-full"
>
<template #trailing><span class="text-gray-500 text-xs">%</span></template>
</UInput>
</UFormField>
<UFormField label="Skonto bis">
<UPopover class="w-full" :content="{ side: 'bottom', align: 'start' }">
<UButton
block
icon="i-heroicons-calendar"
:label="getDateButtonLabel(itemInfo.cashDiscountDueDate, 'Keine Skontofrist')"
:disabled="mode === 'show' || !itemInfo.cashDiscountPercent"
color="neutral"
variant="outline"
class="w-full justify-start"
/>
<template #content>
<div class="p-2">
<UCalendar
:model-value="getCalendarValue(itemInfo.cashDiscountDueDate)"
@update:model-value="setCashDiscountDueDate"
:week-starts-on="1"
/>
</div>
</template>
</UPopover>
</UFormField>
<UFormField label="Beschreibung / Notiz" class="md:col-span-2">
<UTextarea class="w-full" v-model="itemInfo.description" :rows="2" autoresize :disabled="mode === 'show'" />
</UFormField>
@@ -1060,6 +1187,27 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
</div>
</div>
<UModal v-model:open="costCentresOpen" title="Kostenstellen bearbeiten" :dismissible="!costCentresSaving" :close="!costCentresSaving" :ui="{ content: 'sm:max-w-2xl' }">
<template #body>
<p class="mb-4 text-sm text-muted">Die Kostenstellen der Positionen können jederzeit geändert werden, auch bei gebuchten oder zugewiesenen Belegen. Alle anderen Angaben bleiben unverändert.</p>
<div class="space-y-4">
<UFormField v-for="row in costCentreDraft" :key="row.index" :label="row.label">
<InputGroup>
<USelectMenu v-model="row.costcentre" :items="costcentres" value-key="id" label-key="name" :filter-fields="['name', 'number']" class="w-full" :disabled="costCentresSaving">
<template #default>{{ costCentreOptionLabel(row.costcentre) }}</template>
<template #item="{ item }">{{ [item.number, item.name].filter(Boolean).join(' - ') }}</template>
</USelectMenu>
<UButton icon="i-heroicons-x-mark" variant="outline" :aria-label="`Kostenstelle für ${row.label} entfernen`" :disabled="costCentresSaving || !row.costcentre" @click="row.costcentre = null" />
</InputGroup>
</UFormField>
</div>
</template>
<template #footer>
<UButton variant="outline" :disabled="costCentresSaving" @click="costCentresOpen = false">Abbrechen</UButton>
<UButton :loading="costCentresSaving" @click="saveCostCentres">Kostenstellen speichern</UButton>
</template>
</UModal>
</template>
<style scoped>

View File

@@ -1,4 +1,5 @@
<script setup>
import { sumSettledAllocations } from "~/utils/cashDiscount"
import dayjs from "dayjs"
import {useSum} from "~/composables/useSum.js";
@@ -64,35 +65,89 @@ const setupPage = async () => {
items.value = await useEntities(type).select("*, vendor(id,name), statementallocations(id,amount)",sort.value.column,sort.value.direction === "asc")
}
// Funktion zum Vorbereiten der Belege
const prepareInvoices = async () => {
isPreparing.value = true
try {
await $api('/api/functions/services/prepareincominginvoices', { method: 'POST' })
// Die Belegvorbereitung läuft im Backend im Hintergrund; hier wird nur der Status abgefragt.
const preparationEndpoint = '/api/functions/services/prepareincominginvoices'
let preparationPollTimer = null
toast.add({
title: 'Erfolg',
description: 'Eingangsbelege wurden vorbereitet.',
icon: 'i-heroicons-check-circle',
color: 'green'
})
// Liste neu laden
await setupPage()
} catch (error) {
console.error(error)
const showPreparationResult = (status) => {
const result = status?.result
if (result?.error && !result.created?.length) {
toast.add({
title: 'Fehler',
description: 'Beim Vorbereiten der Belege ist ein Fehler aufgetreten.',
description: result.error,
icon: 'i-heroicons-exclamation-circle',
color: 'red'
})
} finally {
return
}
const created = result?.created?.length || 0
const skipped = result?.skipped?.length || 0
toast.add({
title: 'Belegvorbereitung abgeschlossen',
description: `${created} Eingangsbeleg(e) vorbereitet${skipped ? `, ${skipped} Datei(en) übersprungen` : ''}.`,
icon: 'i-heroicons-check-circle',
color: 'green'
})
}
const pollPreparation = (notify) => {
clearTimeout(preparationPollTimer)
preparationPollTimer = setTimeout(async () => {
try {
const status = await $api(preparationEndpoint)
if (status?.running) {
pollPreparation(notify)
return
}
isPreparing.value = false
if (notify) showPreparationResult(status)
await setupPage()
} catch (error) {
console.error(error)
pollPreparation(notify)
}
}, 3000)
}
const prepareInvoices = async () => {
isPreparing.value = true
try {
await $api(preparationEndpoint, { method: 'POST' })
toast.add({
title: 'Belegvorbereitung gestartet',
description: 'Die Belege werden im Hintergrund vorbereitet.',
icon: 'i-heroicons-sparkles'
})
pollPreparation(true)
} catch (error) {
console.error(error)
isPreparing.value = false
toast.add({
title: 'Fehler',
description: 'Die Belegvorbereitung konnte nicht gestartet werden.',
icon: 'i-heroicons-exclamation-circle',
color: 'red'
})
}
}
const resumePreparationStatus = async () => {
try {
const status = await $api(preparationEndpoint)
if (status?.running) {
isPreparing.value = true
pollPreparation(true)
}
} catch (error) {
console.error(error)
}
}
onBeforeUnmount(() => clearTimeout(preparationPollTimer))
setupPage()
resumePreparationStatus()
const selectedColumns = ref(tempStore.columns[type] ? [...tempStore.columns[type]] : dataType.templateColumns.filter(i => !i.disabledInTable))
if (!selectedColumns.value.find((column) => column.key === openAmountColumnKey)) {
@@ -145,8 +200,8 @@ const getInvoiceSum = (invoice) => {
}
const getPaidAmount = (item) => {
let amountPaid = 0
item.statementallocations.forEach(allocation => amountPaid += allocation.amount)
// Gebuchter Skonto zählt zum ausgeglichenen Betrag.
const amountPaid = sumSettledAllocations(item.statementallocations || [])
return Number(Math.abs(amountPaid).toFixed(2))
}

View File

@@ -1,4 +1,5 @@
<script setup>
import { resolveCashDiscountAccounts } from "~/utils/cashDiscount"
import {
TAX_EVALUATION_PERIOD_OPTIONS,
normalizeTaxEvaluationPeriod
@@ -273,6 +274,20 @@ watch(() => auth.activeTenantData?.portalConfig, (config) => {
})
const accountChart = ref(auth.activeTenantData.accountChart || "skr03")
const taxEvaluationPeriod = ref(normalizeTaxEvaluationPeriod(auth.activeTenantData.taxEvaluationPeriod))
const buildCashDiscountForm = (config) => {
const accounts = resolveCashDiscountAccounts(config)
return { received: { ...accounts.received }, granted: { ...accounts.granted } }
}
const cashDiscountAccounts = ref(buildCashDiscountForm(auth.activeTenantData?.cashDiscountAccounts))
const cashDiscountRateLabels = { "19": "19 %", "7": "7 %", "0": "ohne USt" }
const saveCashDiscountAccounts = async () => {
try {
await updateTenant({ cashDiscountAccounts: cashDiscountAccounts.value })
toast.add({ title: "Skontokonten gespeichert", color: "success" })
} catch (err) {
toast.add({ title: "Skontokonten konnten nicht gespeichert werden", description: err?.data?.error, color: "error" })
}
}
const accountChartOptions = [
{ label: "SKR 03", value: "skr03" },
{ label: "Verein", value: "verein" }
@@ -293,6 +308,7 @@ const updateTenant = async (newData) => {
features.value = { ...defaultFeatures, ...(res?.features || {}) }
businessInfo.value = normalizeBusinessInfo(res?.businessInfo)
taxEvaluationPeriod.value = normalizeTaxEvaluationPeriod(res?.taxEvaluationPeriod)
cashDiscountAccounts.value = buildCashDiscountForm(res?.cashDiscountAccounts)
}
}
const saveFeatures = async () => {
@@ -776,6 +792,32 @@ onMounted(() => {
>
Zeitraum speichern
</UButton>
<div class="mt-6">
<div class="text-sm font-medium">Skontokonten</div>
<p class="text-xs text-muted mt-1">
DATEV-Automatikkonten für Skonto bei Zahlungen. Standard ist SKR 03.
</p>
<div class="mt-3 grid grid-cols-1 gap-4 md:grid-cols-2">
<div v-for="side in ['received', 'granted']" :key="side" class="space-y-2">
<div class="text-xs font-semibold uppercase text-muted">
{{ side === 'received' ? 'Erhaltene Skonti (Eingangsbelege)' : 'Gewährte Skonti (Ausgangsbelege)' }}
</div>
<UFormField
v-for="rate in ['19', '7', '0']"
:key="`${side}-${rate}`"
:label="cashDiscountRateLabels[rate]"
>
<UInput v-model="cashDiscountAccounts[side][rate]" inputmode="numeric"/>
</UFormField>
</div>
</div>
</div>
<UButton
class="mt-3"
@click="saveCashDiscountAccounts"
>
Skontokonten speichern
</UButton>
</UForm>
</UCard>

View File

@@ -29,12 +29,18 @@ const variableDefinitions = [
{ key: '{{titel}}', label: 'Titel', desc: 'Titel des Kunden' },
{ key: '{{zahlungsziel_in_tagen}}', label: 'Zahlungsziel', desc: 'In Tagen' },
{ key: '{{lohnkosten}}', label: 'Lohnkosten', desc: 'Ausgewiesene Lohnkosten' },
{ key: '{{skonto_hinweis}}', label: 'Skonto-Hinweis', desc: 'Kompletter Satz; ohne Skontovariable wird er automatisch vor den Endtext gesetzt' },
{ key: '{{skonto_prozent}}', label: 'Skonto %', desc: 'Skontosatz' },
{ key: '{{skonto_datum}}', label: 'Skonto bis', desc: 'Datum der Skontofrist' },
{ key: '{{skonto_betrag}}', label: 'Skontobetrag', desc: 'Abzugsbetrag in Euro' },
{ key: '{{zahlbetrag_mit_skonto}}', label: 'Zahlbetrag mit Skonto', desc: 'Rechnungsbetrag abzüglich Skonto' },
]
const conditionalExamples = [
'{{#if vorname}}Hallo {{vorname}},{{/if}}',
'{{#if (eq zahlungsart "Überweisung")}}Bitte überweisen Sie den Betrag.{{/if}}',
'{{#if (gt zahlungsziel_in_tagen 14)}}Vielen Dank für Ihre Zahlung innerhalb des erweiterten Zahlungsziels.{{/if}}',
'{{#if skonto_prozent}}Bei Zahlung bis {{skonto_datum}} ziehen Sie bitte {{skonto_prozent}} % Skonto ab.{{/if}}',
]
// --- Shortcuts ---

View File

@@ -345,6 +345,18 @@ export const useDataStore = defineStore('data', () => {
inputType: "number",
inputColumn: "Allgemeines",
sortable: true
},{
key: "customCashDiscountPercent",
label: "Skonto in %",
inputType: "number",
inputColumn: "Allgemeines",
disabledInTable: true
},{
key: "customCashDiscountDays",
label: "Skontofrist in Tagen",
inputType: "number",
inputColumn: "Allgemeines",
disabledInTable: true
},{
key: "custom_payment_type",
label: "Zahlungsart",
@@ -2007,6 +2019,18 @@ export const useDataStore = defineStore('data', () => {
inputType: "bool",
component: sepa
},
{
key: "cashDiscountPercent",
label: "Skonto in %",
inputType: "number",
disabledInTable: true
},
{
key: "cashDiscountDays",
label: "Skontofrist in Tagen",
inputType: "number",
disabledInTable: true
},
{
key: "defaultPaymentMethod",
label: "Standard Zahlungsmethode",

View File

@@ -0,0 +1,119 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const { parse, compileScript } = require('@vue/compiler-sfc')
const { ref, computed } = require('vue')
const helpers = import('../utils/documentTitleSections.js')
const title = (id, titleLevel = 1) => ({ id, mode: 'title', titleLevel, text: id })
const position = (id, mode = 'free') => ({ id, mode, quantity: 2, price: 10, linkedEntitys: [42] })
const ids = rows => Array.from(rows, row => row.id)
test('moving a top-level title carries every nested title and row, preserving data and order', async () => {
const { moveTitleSection } = await helpers
const rows = [position('intro', 'text'), title('a'), position('article', 'normal'),
title('a.1', 2), position('service', 'service'), title('a.1.1', 3),
position('note', 'text'), position('break', 'pagebreak'), title('b'), position('last')]
const before = JSON.stringify(rows)
const moved = moveTitleSection(rows, 'a', 'down')
assert.deepEqual(ids(moved), ['intro', 'b', 'last', 'a', 'article', 'a.1', 'service', 'a.1.1', 'note', 'break'])
assert.equal(JSON.stringify(rows), before)
for (const row of rows) assert.equal(moved.find(item => item.id === row.id), row)
assert.deepEqual(moveTitleSection(moved, 'a', 'up'), rows)
})
test('subtitles exchange complete sibling sections without leaving their parent', async () => {
const { moveTitleSection, getTitleSectionMove } = await helpers
const rows = [title('a'), position('parent-intro'), title('a.1', 2), title('a.1.1', 3),
position('child'), title('a.2', 2), position('sibling'), title('b'), title('b.1', 2)]
assert.deepEqual(ids(moveTitleSection(rows, 'a.2', 'up')),
['a', 'parent-intro', 'a.2', 'sibling', 'a.1', 'a.1.1', 'child', 'b', 'b.1'])
assert.deepEqual(ids(moveTitleSection(rows, 'a.1', 'down')),
['a', 'parent-intro', 'a.2', 'sibling', 'a.1', 'a.1.1', 'child', 'b', 'b.1'])
for (const [id, direction] of [['a.1', 'up'], ['a.2', 'down'], ['b.1', 'up']]) {
assert.equal(getTitleSectionMove(rows, id, direction), null)
assert.equal(moveTitleSection(rows, id, direction), rows)
}
})
test('level-three titles move within their level-two parent', async () => {
const { moveTitleSection } = await helpers
const rows = [title('a'), title('a.1', 2), title('x', 3), position('x-row'),
title('y', 3), position('y-row'), title('a.2', 2)]
const moved = moveTitleSection(rows, 'x', 'down')
assert.deepEqual(ids(moved), ['a', 'a.1', 'y', 'y-row', 'x', 'x-row', 'a.2'])
assert.equal(moveTitleSection(moved, 'x', 'down'), moved)
})
test('empty titles, the final section and legacy levels are supported', async () => {
const { moveTitleSection } = await helpers
const rows = [{ id: 'legacy', mode: 'title' }, title('empty', '1'), title('last', 1), position('end')]
assert.deepEqual(ids(moveTitleSection(rows, 'legacy', 'down')), ['empty', 'legacy', 'last', 'end'])
assert.deepEqual(ids(moveTitleSection(rows, 'last', 'up')), ['legacy', 'last', 'end', 'empty'])
assert.equal(moveTitleSection(rows, 'last', 'down'), rows)
})
test('missing intermediate levels still retain their enclosing section', async () => {
const { moveTitleSection } = await helpers
const rows = [title('a'), title('x', 3), position('x-row'), title('y', 3), title('b')]
assert.deepEqual(ids(moveTitleSection(rows, 'x', 'down')), ['a', 'y', 'x', 'x-row', 'b'])
assert.equal(moveTitleSection(rows, 'x', 'up'), rows)
assert.equal(moveTitleSection(rows, 'y', 'down'), rows)
})
test('invalid requests and document boundaries leave rows untouched', async () => {
const { moveTitleSection } = await helpers
const rows = [position('intro'), title('only'), position('last')]
for (const [id, direction] of [['only', 'up'], ['only', 'down'], ['intro', 'down'],
['missing', 'up'], ['only', 'sideways']]) {
assert.equal(moveTitleSection(rows, id, direction), rows)
}
const empty = []
assert.equal(moveTitleSection(empty, 'missing', 'up'), empty)
})
async function editorFixture(rows, type = 'invoices') {
const { descriptor, errors } = parse(fs.readFileSync(`${__dirname}/../pages/createDocument/edit/[[id]].vue`, 'utf8'))
assert.deepEqual(errors, [])
compileScript(descriptor, { id: 'document-editor', inlineTemplate: true })
const script = ts.createSourceFile('editor.js', descriptor.scriptSetup.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
const names = new Set(['moveDocumentTitleSection', 'getTitleSectionMoveMenuItems', 'setPosNumbers',
'collapsedTitleRowIds', 'toggleTitleSection', 'hiddenDocumentRowIds', 'isDocumentRowHidden'])
const source = script.statements.filter(statement => ts.isVariableStatement(statement)
&& statement.declarationList.declarations.some(declaration => names.has(declaration.name.getText(script))))
.map(statement => statement.getText(script)).join('\n')
const context = vm.createContext({ ...await helpers, ref, computed, itemInfo: ref({ type, rows }) })
vm.runInContext(source, context)
return { context, run: code => vm.runInContext(code, context) }
}
test('the editor menu moves collapsed sections and updates numbering and reactive visibility', async () => {
const { run, context } = await editorFixture([title('a'), title('a.1', 2), position('a-row'),
title('b'), position('b-row')])
run("setPosNumbers(); toggleTitleSection('a'); toggleTitleSection('a.1')")
assert.equal(run("isDocumentRowHidden('a-row')"), true)
assert.equal(run("getTitleSectionMoveMenuItems('a')[0].disabled"), true)
assert.equal(run("getTitleSectionMoveMenuItems('a')[1].disabled"), false)
run("getTitleSectionMoveMenuItems('a')[1].onSelect()")
assert.deepEqual(ids(context.itemInfo.value.rows), ['b', 'b-row', 'a', 'a.1', 'a-row'])
assert.deepEqual(Array.from(context.itemInfo.value.rows, row => row.pos), ['1', '1.1', '2', '2.1', '2.1.1'])
assert.equal(run("isDocumentRowHidden('b-row')"), false)
assert.equal(run("isDocumentRowHidden('a-row')"), true)
run("toggleTitleSection('a')")
assert.equal(run("isDocumentRowHidden('a.1')"), false)
assert.equal(run("isDocumentRowHidden('a-row')"), true)
run("toggleTitleSection('a.1')")
assert.equal(run("isDocumentRowHidden('a-row')"), false)
assert.equal(run("getTitleSectionMoveMenuItems('a')[1].disabled"), true)
assert.equal(run("getTitleSectionMoveMenuItems('a')[0].disabled"), false)
})
test('cancellation documents reject section moves even when called directly', async () => {
const { run, context } = await editorFixture([title('a'), position('a-row'), title('b')], 'cancellationInvoices')
const before = JSON.stringify(context.itemInfo.value.rows)
assert.equal(run("getTitleSectionMoveMenuItems('a').every(item => item.disabled)"), true)
run("moveDocumentTitleSection('a', 'down')")
assert.equal(JSON.stringify(context.itemInfo.value.rows), before)
})

View File

@@ -1,3 +1,5 @@
import { getCashDiscountTaxBreakdown } from "./cashDiscount"
export const BANK_ALLOCATION_TAX_ITEMS = [
{ value: "9", label: "19 % Vorsteuer", rate: 19, kind: "input" },
{ value: "8", label: "7 % Vorsteuer", rate: 7, kind: "input" },
@@ -25,19 +27,19 @@ export function getBankAllocationAmounts(allocation: any) {
}
export function getBankAllocationTaxBreakdown(allocation: any) {
const output = { net19: 0, tax19: 0, net7: 0, tax7: 0, net0: 0 }
const input = { ...output }
// Skonto auf zugeordnete Belege korrigiert die Steuer im Zeitraum der Zahlung.
const { output, input } = getCashDiscountTaxBreakdown(allocation)
if (!isDirectBankAllocation(allocation)) return { output, input }
const { amountNet, amountTax, rate, kind } = getBankAllocationAmounts(allocation)
if (kind === "none") return { output, input }
const target = kind === "input" ? input : output
const sign = kind === "input" ? -1 : 1
if (rate === 19) {
target.net19 = amountNet * sign
target.tax19 = amountTax * sign
target.net19 += amountNet * sign
target.tax19 += amountTax * sign
} else if (rate === 7) {
target.net7 = amountNet * sign
target.tax7 = amountTax * sign
target.net7 += amountNet * sign
target.tax7 += amountTax * sign
}
return { output, input }
}

View File

@@ -0,0 +1,162 @@
// Skonto wird an der Bankzuordnung gespeichert (cashDiscountAmount, gleiches Vorzeichen wie amount).
// Zahlbetrag und Skonto gleichen den Beleg gemeinsam aus; der Bankumsatz selbst enthält nur amount.
export const CASH_DISCOUNT_RATES = ["19", "7", "0"] as const
export const DEFAULT_CASH_DISCOUNT_ACCOUNTS = {
received: { "19": "3736", "7": "3731", "0": "3730" },
granted: { "19": "8736", "7": "8731", "0": "8730" },
}
export const getAllocationCashDiscount = (allocation: any) => Number(allocation?.cashDiscountAmount || 0)
export const getAllocationSettledAmount = (allocation: any) =>
Number(allocation?.amount || 0) + getAllocationCashDiscount(allocation)
export const sumSettledAllocations = (allocations: any[] = []) =>
(allocations || []).reduce((sum, allocation) => sum + getAllocationSettledAmount(allocation), 0)
export const hasCashDiscount = (allocation: any) =>
!allocation?.archived && Array.isArray(allocation?.cashDiscountSplit) && allocation.cashDiscountSplit.length > 0
// Steuerkorrektur nach § 17 UStG im Zeitraum der Zahlung.
// Ausgangsbelege mindern die Umsatzsteuer, Eingangsbelege die Vorsteuer (Vorzeichen aus der Zahlung).
export function getCashDiscountTaxBreakdown(allocation: any) {
const output = { net19: 0, tax19: 0, net7: 0, tax7: 0, net0: 0 }
const input = { ...output }
if (!hasCashDiscount(allocation)) return { output, input }
const isOutgoing = Boolean(allocation.createddocument || allocation.cd_id)
const target = isOutgoing ? output : input
const sign = isOutgoing ? -1 : 1
for (const item of allocation.cashDiscountSplit) {
const net = Number(item?.net || 0) * sign
const tax = Number(item?.tax || 0) * sign
if (Number(item?.rate) === 19) {
target.net19 += net
target.tax19 += tax
} else if (Number(item?.rate) === 7) {
target.net7 += net
target.tax7 += tax
} else {
target.net0 += net
}
}
return { output, input }
}
export const resolveCashDiscountAccounts = (config: any) => {
const result = {
received: { ...DEFAULT_CASH_DISCOUNT_ACCOUNTS.received },
granted: { ...DEFAULT_CASH_DISCOUNT_ACCOUNTS.granted },
}
for (const side of ["received", "granted"] as const) {
for (const rate of CASH_DISCOUNT_RATES) {
const value = config?.[side]?.[rate]
if (value !== undefined && value !== null && String(value).trim()) result[side][rate] = String(value).trim()
}
}
return result
}
// ---------------------------------------------------------------------------
// Skonto-Bedingungen auf Belegen (gleiche Logik wie backend/src/modules/cash-discount.ts)
// ---------------------------------------------------------------------------
const roundMoney = (value: number) => Number(value.toFixed(2))
const formatEuro = (value: number) => new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(value)
const formatGermanDate = (value: string) => {
const [year, month, day] = value.split("-")
return `${day}.${month}.${year}`
}
const addDays = (date: any, days: number) => {
if (!date) return null
const iso = typeof date === "string" ? date.slice(0, 10) : new Date(date?.toISOString?.() || date).toISOString().slice(0, 10)
const parsed = new Date(`${iso}T00:00:00Z`)
if (Number.isNaN(parsed.getTime())) return null
parsed.setUTCDate(parsed.getUTCDate() + days)
return parsed.toISOString().slice(0, 10)
}
export function buildCashDiscountTerms({ percent, days, documentDate, dueDate, amount }: any) {
const normalizedPercent = Number(percent)
if (!Number.isFinite(normalizedPercent) || normalizedPercent <= 0 || !Number.isFinite(Number(amount)) || !Number(amount)) return null
const normalizedDays = days === null || days === undefined || days === "" ? null : Number(days)
const computedDueDate = dueDate
? String(dueDate).slice(0, 10)
: normalizedDays !== null && Number.isFinite(normalizedDays) ? addDays(documentDate, normalizedDays) : null
const discount = roundMoney(Number(amount) * normalizedPercent / 100)
return {
percent: normalizedPercent,
days: normalizedDays !== null && Number.isFinite(normalizedDays) ? normalizedDays : null,
dueDate: computedDueDate,
discount,
payable: roundMoney(Number(amount) - discount),
}
}
export function formatCashDiscountText(terms: any) {
if (!terms) return ""
const percent = String(terms.percent).replace(".", ",")
const deadline = terms.dueDate
? `bis zum ${formatGermanDate(terms.dueDate)}`
: terms.days !== null ? `innerhalb von ${terms.days} Tagen` : "bei sofortiger Zahlung"
return `Bei Zahlung ${deadline} gewähren wir ${percent} % Skonto (${formatEuro(Math.abs(terms.discount))}), Zahlbetrag ${formatEuro(Math.abs(terms.payable))}.`
}
export function getCashDiscountTemplateContext(terms: any) {
if (!terms) return {}
return {
skonto_prozent: String(terms.percent).replace(".", ","),
skonto_tage: terms.days,
skonto_datum: terms.dueDate ? formatGermanDate(terms.dueDate) : null,
skonto_betrag: formatEuro(Math.abs(terms.discount)),
zahlbetrag_mit_skonto: formatEuro(Math.abs(terms.payable)),
skonto_hinweis: formatCashDiscountText(terms),
}
}
export function appendCashDiscountText(renderedText: string, template: string | null | undefined, terms: any) {
if (!terms || /skonto_/i.test(String(template || ""))) return renderedText
const sentence = formatCashDiscountText(terms)
return renderedText ? `${sentence}\n\n${renderedText}` : sentence
}
// Liegt eine Zahlung innerhalb der Skontofrist und passt sie zum Betrag abzüglich Skonto?
export function matchCashDiscountPayment({ open, payment, percent, dueDate, paymentDate }: any) {
const openAmount = Math.abs(Number(open || 0))
const paid = Math.abs(Number(payment || 0))
const rate = Number(percent || 0)
if (!openAmount || !paid || !(rate > 0)) return null
if (dueDate && paymentDate && String(paymentDate).slice(0, 10) > String(dueDate).slice(0, 10)) return null
const expected = roundMoney(openAmount * (1 - rate / 100))
if (Math.abs(expected - paid) > 0.05) return null
return { discount: roundMoney(openAmount - paid), percent: rate }
}
// Nettowirkung von Skonto für die BWA: gewährter Skonto mindert Erlöse, erhaltener Skonto den Aufwand.
// Vorzeichen: Erlösminderung positiv; Aufwand im Vorzeichen der Eingangsbelege (Ausgaben positiv).
export function getCashDiscountNetTotals(allocations: any[] = []) {
let grantedNet = 0
let receivedNet = 0
let receivedGross = 0
for (const allocation of allocations || []) {
if (!hasCashDiscount(allocation)) continue
const isOutgoing = Boolean(allocation.createddocument || allocation.cd_id)
for (const item of allocation.cashDiscountSplit) {
if (isOutgoing) grantedNet += Number(item?.net || 0)
else {
receivedNet += Number(item?.net || 0)
receivedGross += Number(item?.gross || 0)
}
}
}
return {
grantedNet: roundMoney(grantedNet),
// Eingangsbelege: Zahlungen (und damit Skonto) sind negativ, Aufwand wird positiv geführt.
receivedNet: roundMoney(-receivedNet),
receivedGross: roundMoney(-receivedGross),
}
}

View File

@@ -0,0 +1,41 @@
const titleLevel = row => Math.min(Math.max(Number(row.titleLevel) || 1, 1), 3)
// A section includes every row up to the next title at the same or a higher level.
const sectionEnd = (rows, start) => {
const level = titleLevel(rows[start])
let end = start + 1
while (end < rows.length && (rows[end].mode !== 'title' || titleLevel(rows[end]) > level)) end += 1
return end
}
export const getTitleSectionMove = (rows, rowId, direction) => {
if (!['up', 'down'].includes(direction)) return null
const start = rows.findIndex(row => row.id === rowId)
if (start < 0 || rows[start].mode !== 'title') return null
const level = titleLevel(rows[start])
const end = sectionEnd(rows, start)
if (direction === 'down') {
// A title at a higher level ends the parent section: never cross it.
if (end === rows.length || titleLevel(rows[end]) !== level) return null
return { start, end, target: sectionEnd(rows, end) }
}
for (let previous = start - 1; previous >= 0; previous -= 1) {
if (rows[previous].mode !== 'title' || titleLevel(rows[previous]) > level) continue
if (titleLevel(rows[previous]) < level) return null
return { start, end, target: previous }
}
return null
}
export const moveTitleSection = (rows, rowId, direction) => {
const move = getTitleSectionMove(rows, rowId, direction)
if (!move) return rows
const { start, end, target } = move
const result = rows.slice()
const section = result.splice(start, end - start)
result.splice(target > start ? target - section.length : target, 0, ...section)
return result
}

View File

@@ -23,6 +23,8 @@ xcrun simctl launch booted software.federspiel.fedeo
## TestFlight vorbereiten
Der Preflight prüft Lint, die mobilen Chat-Regressionstests und Expo Doctor. Für eine separate Typprüfung: `npx tsc --noEmit`.
```bash
npm run preflight:testflight
npm run build:ios:testflight
@@ -35,3 +37,9 @@ npm run submit:ios:testflight
```
Der TestFlight-Build nutzt das EAS-Profil `testflight` aus `eas.json` mit Store-Distribution und automatischer Buildnummer-Erhöhung.
## Mobile Chats
Die Kommunikation zeigt interne Gruppen-, Projekt- und Direktchats sowie lokale und föderierte Unterräume mit bestehender Mitgliedschaft. Einladungen können angenommen oder abgelehnt werden. Leserechte, freigegebene Projektinformationen und wartende beziehungsweise fehlgeschlagene Zustellungen werden berücksichtigt. Bilder werden vor dem Senden verkleinert; Reaktionen funktionieren in beiden Chatarten.
Ausgeblendete Chats verwenden dieselben benutzer- und mandantenbezogenen Einstellungen wie das Web. Die App lädt sie beim Öffnen, beim Zurückkehren in den Vordergrund und während der Nutzung regelmäßig neu. Ausgeblendete Räume zählen nicht zum Chat-Badge und können über das Augensymbol wieder eingeblendet werden. Nachrichtenentwürfe bleiben beim Raumwechsel innerhalb derselben Sitzung erhalten und werden bei einem Sitzungswechsel verworfen.

View File

@@ -11,7 +11,7 @@
"ios": {
"supportsTablet": true,
"bundleIdentifier": "software.federspiel.fedeo",
"buildNumber": "13",
"buildNumber": "16",
"infoPlist": {
"NSCameraUsageDescription": "Die Kamera wird benötigt, um Fotos zu Projekten und Objekten als Dokumente hochzuladen.",
"NSPhotoLibraryUsageDescription": "Der Zugriff auf Fotos wird benötigt, um Bilder als Dokumente hochzuladen.",

View File

@@ -1,12 +1,13 @@
import * as Notifications from 'expo-notifications';
import { Redirect, Tabs } from 'expo-router';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { AppState, DeviceEventEmitter } from 'react-native';
import { HapticTab } from '@/components/haptic-tab';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { fetchChatUnreadCounts } from '@/src/lib/api';
import { chatApi } from '@/src/lib/chat-api';
import { useAuth } from '@/src/providers/auth-provider';
export default function TabLayout() {
@@ -14,31 +15,27 @@ export default function TabLayout() {
const { activeTenantId, isBootstrapping, token, requiresTenantSelection } = useAuth();
const [communicationUnread, setCommunicationUnread] = useState(0);
const refreshCommunicationUnread = useCallback(async () => {
if (!token || requiresTenantSelection) {
useEffect(() => {
let active = true;
let revision = 0;
setCommunicationUnread(0);
await Notifications.setBadgeCountAsync(0);
return;
}
void Notifications.setBadgeCountAsync(0).catch(() => undefined);
async function refresh() {
if (!token || !activeTenantId || requiresTenantSelection || AppState.currentState !== 'active') return;
const request = ++revision;
try {
const unread = await fetchChatUnreadCounts(token);
const total = Object.values(unread).reduce((sum, room) => sum + (room.count || 0), 0);
const total = await chatApi.unread(token);
if (!active || request !== revision) return;
setCommunicationUnread(total);
await Notifications.setBadgeCountAsync(total);
} catch {
// Badge loading must not block tab navigation.
} catch { /* Badge loading must not block tab navigation. */ }
}
}, [requiresTenantSelection, token]);
useEffect(() => {
void refreshCommunicationUnread();
if (!token || requiresTenantSelection) return undefined;
const id = setInterval(() => void refreshCommunicationUnread(), 30000);
return () => clearInterval(id);
}, [activeTenantId, refreshCommunicationUnread, requiresTenantSelection, token]);
void refresh();
const timer = setInterval(() => void refresh(), 30000);
const foreground = AppState.addEventListener('change', state => { if (state === 'active') void refresh(); });
const changed = DeviceEventEmitter.addListener('fedeo:chat-changed', () => void refresh());
return () => { active = false; ++revision; clearInterval(timer); foreground.remove(); changed.remove(); };
}, [activeTenantId, requiresTenantSelection, token]);
const communicationBadge = useMemo(() => {
if (!communicationUnread) return undefined;

View File

@@ -1,294 +1,229 @@
import { Ionicons } from '@expo/vector-icons';
import { useIsFocused } from '@react-navigation/native';
import { randomUUID } from 'expo-crypto';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
FlatList,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
RefreshControl,
StyleSheet,
Text,
TextInput,
View,
ActivityIndicator, AppState, DeviceEventEmitter, FlatList, KeyboardAvoidingView, Modal, Platform,
Pressable, ScrollView, StyleSheet, Text, TextInput, View,
} from 'react-native';
import {
ChatMember,
ChatMessage,
ChatRoom,
createChatRoom,
fetchChatMembers,
fetchChatMessages,
fetchChatRooms,
markChatRoomRead,
provisionChatRoom,
sendChatMessage,
syncChatRoom,
} from '@/src/lib/api';
import { ChatConversation, type ChatDraft } from '@/components/chat-conversation';
import { apiRequest } from '@/src/lib/api';
import { chatApi } from '@/src/lib/chat-api';
import { collaborationKey, isChatAccessError, matchesChatSearch, touchChatRoom, visibleChatRooms, type ChatDirectory, type MobileChatRoom } from '@/src/lib/chat';
import { getApiBaseUrlSync } from '@/src/lib/server-config';
import { useAuth } from '@/src/providers/auth-provider';
const PRIMARY = '#69c350';
function normalizeRoomKey(value: string): string {
return value.trim().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/ß/g, 'ss').replace(/[^a-z0-9._=-]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 48);
}
function messageTime(value: ChatMessage['timestamp']): string {
if (!value) return '';
return new Date(value).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
}
function mergeMessages(current: ChatMessage[], incoming: ChatMessage[]): ChatMessage[] {
const byId = new Map<number, ChatMessage>();
for (const message of current) byId.set(message.id, message);
for (const message of incoming) byId.set(message.id, message);
return Array.from(byId.values()).sort((a, b) => a.id - b.id);
}
export default function CommunicationScreen() {
const { token } = useAuth();
const listRef = useRef<FlatList<ChatMessage>>(null);
const [rooms, setRooms] = useState<ChatRoom[]>([]);
const [members, setMembers] = useState<ChatMember[]>([]);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [activeRoomKey, setActiveRoomKey] = useState('allgemein');
const [draft, setDraft] = useState('');
const { token, activeTenantId } = useAuth();
if (!token || !activeTenantId) return null;
return <ChatScreen key={`${getApiBaseUrlSync()}:${activeTenantId}:${token}`} token={token} />;
}
function ChatScreen({ token }: { token: string }) {
const focused = useIsFocused();
const [foreground, setForeground] = useState(AppState.currentState === 'active');
const enabled = focused && foreground;
const [directory, setDirectory] = useState<ChatDirectory | null>(null);
const [activeKey, setActiveKey] = useState('');
const [search, setSearch] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [membersOpen, setMembersOpen] = useState(false);
const [busy, setBusy] = useState('');
const [showHidden, setShowHidden] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [roomName, setRoomName] = useState('');
const [roomTopic, setRoomTopic] = useState('');
const [roomKey, setRoomKey] = useState('');
const afterIdRef = useRef(0);
const activeRoom = useMemo(
() => rooms.find((room) => room.key === activeRoomKey) || rooms[0] || null,
[activeRoomKey, rooms]
);
const visibleRooms = useMemo(() => {
const query = search.trim().toLowerCase();
return rooms.filter((room) => !query || [room.name, room.topic, room.email, room.projectNumber]
.filter(Boolean).some((value) => String(value).toLowerCase().includes(query)));
}, [rooms, search]);
const loadRooms = useCallback(async () => {
if (!token) return [];
const result = await fetchChatRooms(token);
setRooms(result);
return result;
}, [token]);
const loadRoom = useCallback(async (key: string) => {
if (!token) return;
const [nextMessages, nextMembers] = await Promise.all([
fetchChatMessages(token, key),
fetchChatMembers(token, key),
]);
setMessages(nextMessages);
setMembers(nextMembers);
afterIdRef.current = nextMessages[nextMessages.length - 1]?.id || 0;
await markChatRoomRead(token, key, afterIdRef.current);
}, [token]);
const [roomType, setRoomType] = useState<'internal' | 'collaboration'>('internal');
const [projectId, setProjectId] = useState<number | null>(null);
const [drafts, setDrafts] = useState<Record<string, ChatDraft>>({});
const alive = useRef(true);
const directoryRevision = useRef(0);
const selectionRevision = useRef(0);
const operation = useRef(false);
const loadingDirectory = useRef(false);
const directoryRef = useRef(directory);
directoryRef.current = directory;
const [emptyDraft] = useState<ChatDraft>(() => ({ text: '', image: null, clientId: randomUUID() }));
const rooms = useMemo(() => directory ? visibleChatRooms(directory) : [], [directory]);
const filteredRooms = useMemo(() => rooms.filter(room => matchesChatSearch(room, search)), [rooms, search]);
const hiddenRooms = useMemo(() => directory?.rooms.filter(room => directory.hiddenRoomKeys.includes(room.key)) || [], [directory]);
const activeRoom = rooms.find(room => room.key === activeKey && room.exists);
const refresh = useCallback(async () => {
if (!token) return;
if (loadingDirectory.current || operation.current) return;
const version = ++directoryRevision.current;
loadingDirectory.current = true;
try {
setError(null);
const nextRooms = await loadRooms();
const selected = nextRooms.find((room) => room.key === activeRoomKey) || nextRooms[0];
if (selected?.exists) {
setActiveRoomKey(selected.key);
await loadRoom(selected.key);
}
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : 'Chat konnte nicht geladen werden.');
const result = await chatApi.directory(token);
if (!alive.current || version !== directoryRevision.current) return;
directoryRef.current = result; setDirectory(result); setError('');
const available = visibleChatRooms(result);
setActiveKey(previous => available.some(room => room.key === previous && room.exists)
? previous : available.find(room => room.exists)?.key || '');
} catch (err) {
if (!alive.current || version !== directoryRevision.current) return;
setError(err instanceof Error ? err.message : 'Chats konnten nicht geladen werden.');
if (isChatAccessError(err)) { directoryRef.current = null; setDirectory(null); setActiveKey(''); setDrafts({}); }
} finally {
setLoading(false);
setRefreshing(false);
loadingDirectory.current = false;
if (alive.current && version === directoryRevision.current) setLoading(false);
}
}, [activeRoomKey, loadRoom, loadRooms, token]);
useEffect(() => { void refresh(); }, [refresh]);
}, [token]);
useEffect(() => {
if (!token || !activeRoom?.exists) return undefined;
const id = setInterval(async () => {
try {
const result = await syncChatRoom(token, activeRoom.key, afterIdRef.current);
if (!result.messages?.length) return;
setMessages((current) => mergeMessages(current, result.messages || []));
afterIdRef.current = result.nextId || afterIdRef.current;
await markChatRoomRead(token, activeRoom.key, afterIdRef.current);
} catch {
// Beim nächsten Intervall erneut versuchen.
}
}, 3000);
return () => clearInterval(id);
}, [activeRoom, token]);
alive.current = true;
const listener = AppState.addEventListener('change', state => setForeground(state === 'active'));
return () => { alive.current = false; listener.remove(); };
}, []);
useEffect(() => {
if (!enabled) return;
void refresh();
const timer = setInterval(() => void refresh(), 15000);
return () => clearInterval(timer);
}, [enabled, refresh]);
async function selectRoom(room: ChatRoom) {
if (!token) return;
async function select(room: MobileChatRoom) {
if (directoryRef.current?.hiddenRoomKeys.includes(room.key)) return;
const version = ++selectionRevision.current;
setError('');
if (room.exists) { setActiveKey(room.key); return; }
try {
setLoading(true);
const readyRoom = room.exists ? room : await provisionChatRoom(token, room);
setActiveRoomKey(readyRoom.key);
await loadRooms();
await loadRoom(readyRoom.key);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : 'Chatraum konnte nicht geöffnet werden.');
} finally {
setLoading(false);
}
setBusy(room.key);
const ready = await chatApi.provision(token, room);
if (!alive.current || version !== selectionRevision.current || directoryRef.current?.hiddenRoomKeys.includes(room.key)) return;
// A list requested before provisioning must not replace the new room with its placeholder.
++directoryRevision.current;
setDirectory(previous => previous && ({ ...previous, rooms: previous.rooms.map(item => item.key === room.key ? ready : item) }));
setActiveKey(ready.key);
} catch (err) { if (alive.current && version === selectionRevision.current) setError(err instanceof Error ? err.message : 'Chat konnte nicht geöffnet werden.'); }
finally { if (alive.current && version === selectionRevision.current) setBusy(''); }
}
async function submitMessage() {
if (!token || !activeRoom?.exists || !draft.trim() || sending) return;
const text = draft.trim();
setDraft('');
setSending(true);
async function mutate(label: string, action: () => Promise<string | void>) {
if (operation.current) return;
operation.current = true; ++directoryRevision.current;
setBusy(label); setError('');
try {
const message = await sendChatMessage(token, activeRoom.key, text);
setMessages((current) => mergeMessages(current, [message]));
afterIdRef.current = Math.max(afterIdRef.current, message.id);
await markChatRoomRead(token, activeRoom.key, message.id);
} catch (nextError) {
setDraft(text);
setError(nextError instanceof Error ? nextError.message : 'Nachricht konnte nicht gesendet werden.');
} finally {
setSending(false);
const selectKey = await action();
if (!alive.current) return;
DeviceEventEmitter.emit('fedeo:chat-changed');
const result = await chatApi.directory(token);
if (!alive.current) return;
directoryRef.current = result; setDirectory(result);
const available = visibleChatRooms(result);
setActiveKey(previous => available.find(room => room.key === (selectKey || previous) && room.exists)?.key
|| available.find(room => room.exists)?.key || '');
} catch (err) { if (alive.current) setError(err instanceof Error ? err.message : 'Änderung konnte nicht gespeichert werden.'); }
finally { operation.current = false; if (alive.current) { setBusy(''); setLoading(false); } }
}
}
async function submitRoom() {
if (!token || !roomName.trim()) return;
try {
const created = await createChatRoom(token, {
key: normalizeRoomKey(roomKey || roomName),
name: roomName.trim(),
topic: roomTopic.trim() || null,
async function hide(room: MobileChatRoom, hidden: boolean) {
await mutate(room.key, async () => {
await chatApi.hide(token, room.key, hidden);
if (!alive.current) return;
// Update immediately after success, even if refreshing the directory subsequently fails.
setDirectory(previous => {
if (!previous) return previous;
const next = { ...previous, hiddenRoomKeys: hidden ? [...new Set([...previous.hiddenRoomKeys, room.key])]
: previous.hiddenRoomKeys.filter(key => key !== room.key) };
directoryRef.current = next;
return next;
});
if (hidden) setActiveKey(previous => previous === room.key ? '' : previous);
else { setShowHidden(false); return room.key; }
});
setCreateOpen(false);
setRoomName('');
setRoomKey('');
setRoomTopic('');
await loadRooms();
await selectRoom(created);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : 'Chatraum konnte nicht erstellt werden.');
}
async function respond(id: string, accept: boolean) {
await mutate(id, async () => {
const result = await chatApi.respond(token, id, accept);
return accept && result.roomId ? collaborationKey(result.roomId) : undefined;
});
}
function renderRoom({ item }: { item: ChatRoom }) {
const active = item.key === activeRoom?.key;
return (
<Pressable style={[styles.roomChip, active && styles.roomChipActive]} onPress={() => void selectRoom(item)}>
<Text style={styles.roomGroup}>{item.group || 'Räume'}</Text>
<Text style={[styles.roomName, active && styles.roomTextActive]} numberOfLines={1}>{item.name}</Text>
{item.unread ? <Text style={styles.roomBadge}>{item.unread}</Text> : null}
</Pressable>
);
async function create() {
if (!roomName.trim()) return;
await mutate('create', async () => {
let key: string;
if (roomType === 'collaboration') {
const created = await apiRequest<{ id: string }>('/api/collaboration/rooms', { token, method: 'POST', body: { name: roomName.trim(), topic: roomTopic.trim(), projectId } });
key = collaborationKey(created.id);
} else {
const normalized = roomName.toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/ß/g, 'ss').replace(/[^a-z0-9._=-]+/g, '_').replace(/^[._=-]+|[._=-]+$/g, '').slice(0, 48);
const created = await apiRequest<{ key: string }>('/api/communication/chat/rooms', { token, method: 'POST', body: { name: roomName.trim(), topic: roomTopic.trim(), key: normalized } });
key = created.key;
}
function renderMessage({ item }: { item: ChatMessage }) {
return (
<View style={[styles.messageRow, item.own && styles.messageRowOwn]}>
<View style={[styles.messageBubble, item.own && styles.messageBubbleOwn]}>
{!item.own ? <Text style={styles.messageAuthor}>{item.senderDisplayName || 'Benutzer'}</Text> : null}
<Text style={[styles.messageBody, item.own && styles.messageBodyOwn]}>{item.body}</Text>
<Text style={[styles.messageTime, item.own && styles.messageTimeOwn]}>{messageTime(item.timestamp)}</Text>
</View>
</View>
);
if (alive.current) { setCreateOpen(false); setRoomName(''); setRoomTopic(''); }
return key;
});
}
return (
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.screen}>
<View style={styles.header}>
<View style={styles.headerMain}>
<Text style={styles.title}>Chat</Text>
<Text style={styles.subtitle} numberOfLines={1}>{activeRoom?.name || 'FEDEO Kommunikation'}</Text>
</View>
<Pressable style={styles.headerButton} onPress={() => setCreateOpen(true)}><Text style={styles.headerButtonText}>+</Text></Pressable>
<Pressable style={styles.headerButton} onPress={() => setMembersOpen((value) => !value)}><Text style={styles.headerButtonText}>☰</Text></Pressable>
</View>
{error ? <Text style={styles.error}>{error}</Text> : null}
<View style={styles.roomPanel}>
<TextInput style={styles.searchInput} placeholder="Räume, Projekte oder Personen suchen" value={search} onChangeText={setSearch} />
<FlatList data={visibleRooms} horizontal keyExtractor={(item) => item.key} renderItem={renderRoom}
showsHorizontalScrollIndicator={false} contentContainerStyle={styles.roomList} />
</View>
{membersOpen ? (
<View style={styles.membersPanel}>
<Text style={styles.membersTitle}>Teilnehmer · {members.length}</Text>
<Text style={styles.membersText} numberOfLines={2}>{members.map((member) => member.own ? 'Du' : member.displayName).join(' · ')}</Text>
</View>
) : null}
{loading ? (
<View style={styles.loading}><ActivityIndicator /><Text style={styles.loadingText}>Nachrichten werden geladen …</Text></View>
) : (
<FlatList ref={listRef} data={messages} keyExtractor={(item) => String(item.id)} renderItem={renderMessage}
contentContainerStyle={styles.messageList}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); void refresh(); }} />}
ListEmptyComponent={<Text style={styles.emptyText}>Noch keine Nachrichten in diesem Raum.</Text>}
onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: true })} />
)}
<View style={styles.composer}>
<TextInput style={styles.composerInput} placeholder="Nachricht schreiben" value={draft} onChangeText={setDraft} multiline />
<Pressable style={[styles.sendButton, (!draft.trim() || sending) && styles.sendButtonDisabled]} onPress={() => void submitMessage()}>
<Text style={styles.sendButtonText}>{sending ? '…' : 'Senden'}</Text>
</Pressable>
</View>
<Modal transparent visible={createOpen} animationType="fade" onRequestClose={() => setCreateOpen(false)}>
<View style={styles.modalBackdrop}><View style={styles.modalCard}>
<Text style={styles.modalTitle}>Chatraum erstellen</Text>
<TextInput style={styles.modalInput} placeholder="Name" value={roomName} onChangeText={setRoomName} />
<TextInput style={styles.modalInput} placeholder="Schlüssel, optional" value={roomKey} onChangeText={setRoomKey} autoCapitalize="none" />
<TextInput style={styles.modalInput} placeholder="Beschreibung, optional" value={roomTopic} onChangeText={setRoomTopic} />
<View style={styles.modalActions}>
<Pressable onPress={() => setCreateOpen(false)}><Text style={styles.linkText}>Abbrechen</Text></Pressable>
<Pressable style={styles.primaryButton} onPress={() => void submitRoom()}><Text style={styles.primaryButtonText}>Erstellen</Text></Pressable>
return <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} keyboardVerticalOffset={Platform.OS === 'ios' ? 95 : 0} style={styles.screen}>
<View style={styles.toolbar}>
<TextInput style={styles.search} accessibilityLabel="Chats durchsuchen" placeholder="Räume, Projekte oder Personen suchen" value={search} onChangeText={setSearch} />
<Pressable accessibilityRole="button" accessibilityLabel="Chat erstellen" onPress={() => setCreateOpen(true)} disabled={!directory || !!busy} style={styles.icon}><Ionicons name="add" size={25} /></Pressable>
<Pressable accessibilityRole="button" accessibilityLabel={`Ausgeblendete Chats (${hiddenRooms.length})`} onPress={() => setShowHidden(true)} style={styles.icon}><Ionicons name="eye-off-outline" size={23} /></Pressable>
</View>
{!!error && <View style={styles.errorBox}><Text accessibilityRole="alert" style={styles.error}>{error}</Text><Pressable onPress={() => void refresh()}><Text style={styles.link}>Erneut laden</Text></Pressable></View>}
{!!directory?.invitations.length && <ScrollView style={styles.invitations} contentContainerStyle={{ gap: 8 }}>
{directory.invitations.map(invitation => <View key={invitation.id} style={styles.invitation}>
<Text style={styles.bold}>Einladung: {invitation.name}</Text>
<Text style={styles.hint}>{invitation.ownerName}{invitation.remote ? ' · Extern' : ''} · {invitation.role === 'reader' ? 'Nur lesen' : 'Lesen und schreiben'}</Text>
{invitation.overviewAllowed && <Text style={styles.hint}>Projektübersicht freigegeben</Text>}
<View style={styles.actions}><Pressable accessibilityRole="button" disabled={!!busy} onPress={() => void respond(invitation.id, true)} style={styles.button}><Text style={styles.white}>{busy === invitation.id ? 'Bitte warten …' : 'Annehmen'}</Text></Pressable>
<Pressable accessibilityRole="button" disabled={!!busy} onPress={() => void respond(invitation.id, false)} style={styles.secondary}><Text>Ablehnen</Text></Pressable></View>
</View>)}
</ScrollView>}
<View style={styles.roomPanel}><FlatList horizontal data={filteredRooms} keyExtractor={room => room.key} showsHorizontalScrollIndicator={false} contentContainerStyle={styles.roomList}
ListEmptyComponent={!loading ? <Text style={styles.hint}>{search ? 'Keine passenden Chats.' : 'Keine sichtbaren Chats.'}</Text> : null}
renderItem={({ item }) => <Pressable accessibilityRole="button" accessibilityState={{ selected: activeKey === item.key }} disabled={!!busy} onPress={() => void select(item)} style={[styles.room, activeKey === item.key && styles.selectedRoom]}>
<Text style={styles.group}>{item.group}{item.remote ? ' · EXTERN' : ''}</Text><Text style={styles.bold} numberOfLines={1}>{item.name}</Text>
{!!item.unread && <Text style={styles.badge}>{item.unread}</Text>}
</Pressable>} /></View>
{loading ? <View style={styles.empty}><ActivityIndicator /><Text>Chats werden geladen …</Text></View> : activeRoom ?
<ChatConversation key={activeRoom.key} token={token} room={activeRoom} enabled={enabled} hiding={!!busy}
draft={drafts[activeKey] || emptyDraft} onHide={() => void hide(activeRoom, true)}
onDraft={value => setDrafts(previous => ({ ...previous, [activeKey]: { ...value, clientId: randomUUID() } }))}
onSent={clientId => {
setDirectory(previous => { const next = previous && { ...previous, rooms: touchChatRoom(previous.rooms, activeRoom.key) }; directoryRef.current = next; return next; });
setDrafts(previous => { if (previous[activeKey]?.clientId !== clientId) return previous; const next = { ...previous }; delete next[activeKey]; return next; });
}} />
: <View style={styles.empty}><Ionicons name="chatbubbles-outline" size={36} color="#69c350" /><Text>Wähle einen Chat aus.</Text></View>}
<Modal transparent visible={showHidden} animationType="slide" onRequestClose={() => setShowHidden(false)}>
<View style={styles.overlay}><View style={styles.modal}><Text style={styles.title}>Ausgeblendete Chats</Text><Text style={styles.hint}>Diese Einstellung gilt auch im Web.</Text>
<FlatList data={hiddenRooms.filter(room => matchesChatSearch(room, search))} keyExtractor={room => room.key} style={{ maxHeight: 380 }}
ListEmptyComponent={<Text style={styles.hint}>Keine ausgeblendeten Chats gefunden.</Text>}
renderItem={({ item }) => <View style={styles.hiddenRow}><View style={{ flex: 1 }}><Text style={styles.bold}>{item.name}</Text><Text style={styles.hint}>{item.group}</Text></View>
<Pressable accessibilityRole="button" accessibilityLabel={`${item.name} einblenden`} disabled={!!busy} onPress={() => void hide(item, false)} style={styles.secondary}><Text style={styles.link}>Einblenden</Text></Pressable></View>} />
<Pressable onPress={() => setShowHidden(false)} style={styles.secondary}><Text>Schließen</Text></Pressable>
</View></View>
</Modal>
</KeyboardAvoidingView>
);
<Modal transparent visible={createOpen} animationType="slide" onRequestClose={() => setCreateOpen(false)}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.overlay}><View style={styles.modal}>
<Text style={styles.title}>Chat erstellen</Text>
{directory?.canManage && <View style={styles.actions}>{(['internal', 'collaboration'] as const).map(type => <Pressable key={type} onPress={() => setRoomType(type)} style={[styles.secondary, roomType === type && styles.selectedRoom]}><Text>{type === 'internal' ? 'Gruppenchat' : 'Unterraum'}</Text></Pressable>)}</View>}
<TextInput style={styles.input} accessibilityLabel="Chatname" placeholder="Name" value={roomName} onChangeText={setRoomName} maxLength={200} />
<TextInput style={styles.input} accessibilityLabel="Chatbeschreibung" placeholder="Beschreibung, optional" value={roomTopic} onChangeText={setRoomTopic} maxLength={1000} />
{roomType === 'collaboration' && <><Text style={styles.hint}>Projekt zuordnen (optional)</Text><ScrollView style={{ maxHeight: 150 }}>
<Pressable onPress={() => setProjectId(null)} style={styles.secondary}><Text>{projectId === null ? '✓ ' : ''}Ohne Projekt</Text></Pressable>
{directory?.projects.map(project => <Pressable key={project.id} onPress={() => setProjectId(project.id)} style={styles.secondary}><Text>{projectId === project.id ? '✓ ' : ''}{project.name}</Text></Pressable>)}
</ScrollView></>}
{!!error && <Text accessibilityRole="alert" style={styles.error}>{error}</Text>}
<View style={styles.actions}><Pressable disabled={!!busy} onPress={() => setCreateOpen(false)} style={styles.secondary}><Text>Abbrechen</Text></Pressable>
<Pressable disabled={!!busy || !roomName.trim()} onPress={() => void create()} style={styles.button}><Text style={styles.white}>{busy === 'create' ? 'Wird erstellt …' : 'Erstellen'}</Text></Pressable></View>
</View></KeyboardAvoidingView>
</Modal>
</KeyboardAvoidingView>;
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: '#f9fafb' },
header: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 16, backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb' },
headerMain: { flex: 1 }, title: { fontSize: 20, fontWeight: '800', color: '#111827' }, subtitle: { color: '#6b7280', marginTop: 2 },
headerButton: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: '#d1d5db' },
headerButtonText: { fontSize: 20, fontWeight: '700' }, error: { margin: 10, padding: 10, borderRadius: 8, color: '#991b1b', backgroundColor: '#fee2e2' },
roomPanel: { paddingVertical: 10, backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb' },
searchInput: { marginHorizontal: 16, padding: 9, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 10 },
roomList: { paddingHorizontal: 12, paddingTop: 10, gap: 8 }, roomChip: { width: 150, padding: 10, borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10 },
roomChipActive: { backgroundColor: '#eff9ea', borderColor: PRIMARY }, roomGroup: { color: '#6b7280', fontSize: 10, textTransform: 'uppercase' },
roomName: { color: '#111827', fontSize: 14, fontWeight: '700', marginTop: 2 }, roomTextActive: { color: '#2f6f25' },
roomBadge: { position: 'absolute', right: 6, top: 6, backgroundColor: PRIMARY, color: '#fff', borderRadius: 9, paddingHorizontal: 6, overflow: 'hidden', fontSize: 11 },
membersPanel: { padding: 12, backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb' }, membersTitle: { fontWeight: '700', color: '#111827' }, membersText: { marginTop: 4, color: '#6b7280', fontSize: 12 },
loading: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 8 }, loadingText: { color: '#6b7280' },
messageList: { flexGrow: 1, padding: 14, gap: 10 }, emptyText: { marginTop: 40, textAlign: 'center', color: '#9ca3af' },
messageRow: { flexDirection: 'row' }, messageRowOwn: { justifyContent: 'flex-end' }, messageBubble: { maxWidth: '82%', padding: 11, borderRadius: 16, backgroundColor: '#f3f4f6' },
messageBubbleOwn: { backgroundColor: PRIMARY }, messageAuthor: { color: '#2f6f25', fontSize: 12, fontWeight: '700', marginBottom: 3 }, messageBody: { color: '#111827', fontSize: 15 }, messageBodyOwn: { color: '#fff' },
messageTime: { marginTop: 4, color: '#9ca3af', fontSize: 10, textAlign: 'right' }, messageTimeOwn: { color: '#e7f7e2' },
composer: { flexDirection: 'row', alignItems: 'flex-end', gap: 8, padding: 12, backgroundColor: '#fff', borderTopWidth: 1, borderTopColor: '#e5e7eb' },
composerInput: { flex: 1, maxHeight: 120, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 18, paddingHorizontal: 13, paddingVertical: 9 },
sendButton: { backgroundColor: PRIMARY, borderRadius: 18, paddingHorizontal: 15, paddingVertical: 10 }, sendButtonDisabled: { opacity: 0.45 }, sendButtonText: { color: '#fff', fontWeight: '700' },
modalBackdrop: { flex: 1, justifyContent: 'center', padding: 24, backgroundColor: 'rgba(0,0,0,0.45)' }, modalCard: { padding: 18, gap: 12, borderRadius: 14, backgroundColor: '#fff' },
modalTitle: { fontSize: 18, fontWeight: '800', color: '#111827' }, modalInput: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 9, padding: 10 },
modalActions: { flexDirection: 'row', justifyContent: 'flex-end', alignItems: 'center', gap: 18 }, linkText: { color: '#2f6f25', fontWeight: '700' }, primaryButton: { paddingHorizontal: 14, paddingVertical: 10, borderRadius: 9, backgroundColor: PRIMARY }, primaryButtonText: { color: '#fff', fontWeight: '700' },
screen: { flex: 1, backgroundColor: '#f9fafb' }, toolbar: { flexDirection: 'row', alignItems: 'center', padding: 10, gap: 5, backgroundColor: '#fff' },
search: { flex: 1, minWidth: 0, minHeight: 42, padding: 9, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 10, fontSize: 13, color: '#111827' },
icon: { padding: 8, minWidth: 40, minHeight: 44, justifyContent: 'center', alignItems: 'center' }, roomPanel: { backgroundColor: '#fff', borderBottomWidth: 1, borderColor: '#e5e7eb' },
roomList: { paddingHorizontal: 12, paddingBottom: 10, gap: 8 }, room: { width: 165, padding: 10, borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10 },
selectedRoom: { backgroundColor: '#eff9ea', borderColor: '#69c350', borderWidth: 1 }, group: { fontSize: 10, color: '#6b7280', textTransform: 'uppercase', marginBottom: 4 },
bold: { fontWeight: '700', color: '#111827', fontSize: 13 }, badge: { marginTop: 4, alignSelf: 'flex-start', paddingHorizontal: 6, borderRadius: 8, backgroundColor: '#dcfce7', color: '#166534', fontSize: 11 },
empty: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 10 }, hint: { fontSize: 12, color: '#6b7280' }, error: { fontSize: 12, color: '#991b1b' }, errorBox: { backgroundColor: '#fef2f2', padding: 10, gap: 4 },
link: { color: '#2f6f25', fontWeight: '600' }, invitations: { maxHeight: 170, flexGrow: 0, padding: 10, backgroundColor: '#f0fdf4' }, invitation: { gap: 5, paddingBottom: 8 },
actions: { flexDirection: 'row', alignItems: 'center', gap: 8 }, button: { paddingVertical: 11, paddingHorizontal: 16, borderRadius: 8, backgroundColor: '#438b30' },
white: { color: '#fff', fontWeight: '600' }, secondary: { padding: 11, borderRadius: 8 }, overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.4)', justifyContent: 'center', padding: 20 },
modal: { maxHeight: '85%', backgroundColor: '#fff', borderRadius: 16, padding: 18, gap: 12 }, title: { fontSize: 18, fontWeight: '700', color: '#111827' },
hiddenRow: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 9, borderBottomWidth: 1, borderColor: '#e5e7eb' }, input: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 8, padding: 12, color: '#111827' },
});

View File

@@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import { router } from 'expo-router';
import { fetchStaffTimeSpans, fetchTasks, Task } from '@/src/lib/api';
import { EventCreateModal } from '@/components/event-create-modal';
import { CalendarEvent, fetchEvents, fetchProjects, fetchStaffTimeSpans, fetchTasks, Project, Task } from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider';
const PRIMARY = '#69c350';
@@ -38,12 +39,25 @@ function formatDateTime(value: string | null): string {
});
}
function dateKey(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function formatEventTime(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
export default function DashboardScreen() {
const { token, user } = useAuth();
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [createEventOpen, setCreateEventOpen] = useState(false);
const [selectedCalendarDate, setSelectedCalendarDate] = useState(() => new Date());
const [data, setData] = useState<DashboardData>({
tasks: [],
openTasks: 0,
@@ -62,9 +76,11 @@ export default function DashboardScreen() {
setError(null);
try {
const [taskRows, spans] = await Promise.all([
const [taskRows, spans, eventRows, projectRows] = await Promise.all([
fetchTasks(token),
fetchStaffTimeSpans(token, currentUserId),
fetchEvents(token),
fetchProjects(token),
]);
const tasks = taskRows || [];
@@ -92,6 +108,8 @@ export default function DashboardScreen() {
pendingSubmissions,
todayMinutes,
});
setEvents(eventRows);
setProjects(projectRows);
} catch (err) {
setError(err instanceof Error ? err.message : 'Dashboard konnte nicht geladen werden.');
} finally {
@@ -112,6 +130,26 @@ export default function DashboardScreen() {
await loadDashboard(false);
}
const calendarDays = useMemo(() => {
const start = new Date();
start.setHours(0, 0, 0, 0);
return Array.from({ length: 7 }, (_, index) => {
const date = new Date(start);
date.setDate(start.getDate() + index);
return date;
});
}, []);
const selectedEvents = useMemo(() => {
const selectedKey = dateKey(selectedCalendarDate);
return events
.filter((event) => {
const start = new Date(event.startDate);
return !Number.isNaN(start.getTime()) && dateKey(start) === selectedKey;
})
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
}, [events, selectedCalendarDate]);
return (
<ScrollView
contentContainerStyle={styles.container}
@@ -158,6 +196,60 @@ export default function DashboardScreen() {
</View>
</View>
<View style={styles.calendarCard}>
<View style={styles.calendarHeader}>
<View>
<Text style={styles.calendarTitle}>Kalender</Text>
<Text style={styles.calendarSubtitle}>
{selectedCalendarDate.toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: 'long' })}
</Text>
</View>
<Pressable onPress={() => router.push('/more/calendar' as any)}>
<Text style={styles.calendarLink}>Monat öffnen</Text>
</Pressable>
</View>
<View style={styles.calendarDays}>
{calendarDays.map((date) => {
const key = dateKey(date);
const selected = key === dateKey(selectedCalendarDate);
const count = events.filter((event) => {
const start = new Date(event.startDate);
return !Number.isNaN(start.getTime()) && dateKey(start) === key;
}).length;
return (
<Pressable
key={key}
style={[styles.calendarDay, selected ? styles.calendarDaySelected : null]}
onPress={() => setSelectedCalendarDate(date)}>
<Text style={[styles.calendarWeekday, selected ? styles.calendarDayTextSelected : null]}>
{date.toLocaleDateString('de-DE', { weekday: 'short' }).replace('.', '')}
</Text>
<Text style={[styles.calendarDayNumber, selected ? styles.calendarDayTextSelected : null]}>{date.getDate()}</Text>
<View style={[styles.calendarDot, count > 0 ? styles.calendarDotActive : null]} />
</Pressable>
);
})}
</View>
{selectedEvents.length === 0 ? <Text style={styles.calendarEmpty}>Keine Termine an diesem Tag.</Text> : null}
{selectedEvents.slice(0, 3).map((event) => (
<Pressable
key={String(event.id)}
style={styles.calendarEvent}
onPress={() => router.push(`/more/event/${event.id}` as any)}>
<View style={[styles.calendarEventMark, { backgroundColor: event.color || PRIMARY }]} />
<Text style={styles.calendarEventTime}>{formatEventTime(event.startDate)}</Text>
<Text style={styles.calendarEventName} numberOfLines={1}>{event.name}</Text>
{event.state === 'Entwurf' ? <Text style={styles.calendarEventDraft}>Entwurf</Text> : null}
<Text style={styles.calendarEventArrow}>›</Text>
</Pressable>
))}
<Pressable style={styles.calendarAddButton} onPress={() => setCreateEventOpen(true)}>
<Text style={styles.calendarAddButtonText}>+ Termin eintragen</Text>
</Pressable>
</View>
<View style={styles.quickActionsCard}>
<Text style={styles.quickActionsTitle}>Schnellzugriff</Text>
<View style={styles.quickActionsRow}>
@@ -175,6 +267,14 @@ export default function DashboardScreen() {
</Pressable>
</View>
</View>
<EventCreateModal
visible={createEventOpen}
projects={projects}
initialDate={selectedCalendarDate}
onClose={() => setCreateEventOpen(false)}
onCreated={() => loadDashboard(false)}
/>
</>
) : null}
</ScrollView>
@@ -232,6 +332,35 @@ const styles = StyleSheet.create({
padding: 12,
gap: 10,
},
calendarCard: {
backgroundColor: '#ffffff',
borderRadius: 12,
borderWidth: 1,
borderColor: '#e5e7eb',
padding: 12,
gap: 10,
},
calendarHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
calendarTitle: { color: '#111827', fontSize: 16, fontWeight: '700' },
calendarSubtitle: { color: '#6b7280', fontSize: 12, marginTop: 2 },
calendarLink: { color: '#3d7a30', fontSize: 12, fontWeight: '700' },
calendarDays: { flexDirection: 'row', gap: 4 },
calendarDay: { flex: 1, minHeight: 62, borderRadius: 9, alignItems: 'center', justifyContent: 'center', gap: 2 },
calendarDaySelected: { backgroundColor: PRIMARY },
calendarWeekday: { color: '#6b7280', fontSize: 10, fontWeight: '600', textTransform: 'uppercase' },
calendarDayNumber: { color: '#111827', fontSize: 16, fontWeight: '700' },
calendarDayTextSelected: { color: '#ffffff' },
calendarDot: { width: 5, height: 5, borderRadius: 3, backgroundColor: 'transparent' },
calendarDotActive: { backgroundColor: '#f59e0b' },
calendarEmpty: { color: '#6b7280', fontSize: 13, paddingVertical: 4 },
calendarEvent: { minHeight: 44, flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 9, overflow: 'hidden' },
calendarEventMark: { width: 4, alignSelf: 'stretch' },
calendarEventTime: { color: '#3d7a30', fontSize: 12, fontWeight: '700', paddingHorizontal: 8 },
calendarEventName: { flex: 1, color: '#111827', fontSize: 13, fontWeight: '600' },
calendarEventDraft: { color: '#92400e', backgroundColor: '#fef3c7', borderRadius: 999, paddingHorizontal: 7, paddingVertical: 3, fontSize: 10, fontWeight: '700' },
calendarEventArrow: { color: '#9ca3af', fontSize: 21, paddingHorizontal: 7 },
calendarAddButton: { minHeight: 40, borderRadius: 9, backgroundColor: PRIMARY, alignItems: 'center', justifyContent: 'center' },
calendarAddButtonText: { color: '#ffffff', fontSize: 13, fontWeight: '700' },
quickActionsTitle: {
color: '#111827',
fontSize: 15,

View File

@@ -14,7 +14,6 @@ import {
} from 'react-native';
import { router, useLocalSearchParams } from 'expo-router';
import * as DocumentPicker from 'expo-document-picker';
import * as WebBrowser from 'expo-web-browser';
import { BarcodeScanningResult, BarcodeType, CameraView, useCameraPermissions } from 'expo-camera';
import { HistorySection } from '@/components/history-section';
@@ -32,7 +31,7 @@ import {
uploadCustomerFile,
} from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider';
import { getOpenableFileUrl } from '@/src/lib/file-opening';
import { openAuthenticatedFile } from '@/src/lib/file-opening';
const PRIMARY = '#69c350';
@@ -276,13 +275,13 @@ export default function CustomerDetailScreen() {
}
async function onOpenFile(file: ProjectFile) {
if (!file.url) return;
await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), {
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
controlsColor: PRIMARY,
showTitle: true,
enableDefaultShareMenuItem: true,
});
if (!token || !file.url) return;
setError(null);
try {
await openAuthenticatedFile(file, token);
} catch (err) {
setError(err instanceof Error ? err.message : 'Das Dokument konnte nicht geöffnet werden.');
}
}
async function onPickAndUpload() {

View File

@@ -3,8 +3,9 @@ import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, T
import { useLocalSearchParams } from 'expo-router';
import * as WebBrowser from 'expo-web-browser';
import { EventCreateModal } from '@/components/event-create-modal';
import { HistorySection } from '@/components/history-section';
import { CalendarEvent, fetchEventById } from '@/src/lib/api';
import { CalendarEvent, EventState, fetchEventById, fetchProjects, Project, updateEvent } from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider';
const PRIMARY = '#69c350';
@@ -37,6 +38,9 @@ export default function EventDetailScreen() {
const eventId = Number(params.id);
const { token } = useAuth();
const [event, setEvent] = useState<CalendarEvent | null>(null);
const [projects, setProjects] = useState<Project[]>([]);
const [editOpen, setEditOpen] = useState(false);
const [updatingState, setUpdatingState] = useState(false);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -47,7 +51,12 @@ export default function EventDetailScreen() {
if (showSpinner) setLoading(true);
setError(null);
try {
setEvent(await fetchEventById(token, eventId));
const [eventRow, projectRows] = await Promise.all([
fetchEventById(token, eventId),
fetchProjects(token),
]);
setEvent(eventRow);
setProjects(projectRows);
} catch (err) {
setError(err instanceof Error ? err.message : 'Der Termin konnte nicht geladen werden.');
} finally {
@@ -72,13 +81,26 @@ export default function EventDetailScreen() {
await WebBrowser.openBrowserAsync(url, { controlsColor: PRIMARY, showTitle: true });
}
async function changeState(nextState: EventState) {
if (!token || !event || updatingState || event.state === nextState) return;
setUpdatingState(true);
setError(null);
try {
const updated = await updateEvent(token, eventId, { state: nextState });
setEvent((current) => current ? { ...current, state: updated.state } : updated);
} catch (err) {
setError(err instanceof Error ? err.message : 'Der Status konnte nicht geändert werden.');
} finally {
setUpdatingState(false);
}
}
const rows = event
? [
{ label: 'Beginn', value: formatDateTime(event.startDate) },
{ label: 'Ende', value: formatDateTime(event.endDate) },
{ label: 'Projekt', value: relationName(event.project) },
{ label: 'Kunde', value: relationName(event.customer) },
{ label: 'Status', value: String(event.state || '-') },
{ label: 'Wiederholung', value: String(event.repeatInterval || '-') },
]
: [];
@@ -97,6 +119,25 @@ export default function EventDetailScreen() {
<View style={styles.headingRow}>
<View style={[styles.colorMark, { backgroundColor: event.color || PRIMARY }]} />
<Text style={styles.title}>{event.name}</Text>
<Pressable style={styles.editButton} onPress={() => setEditOpen(true)}>
<Text style={styles.editButtonText}>Bearbeiten</Text>
</Pressable>
</View>
<View style={styles.statusSection}>
<Text style={styles.statusLabel}>Status</Text>
<View style={styles.statusRow}>
{(['Entwurf', 'Final'] as EventState[]).map((option) => (
<Pressable
key={option}
disabled={updatingState}
style={[styles.statusButton, event.state === option ? styles.statusButtonActive : null]}
onPress={() => changeState(option)}>
<Text style={[styles.statusButtonText, event.state === option ? styles.statusButtonTextActive : null]}>
{option}
</Text>
</Pressable>
))}
</View>
</View>
<View style={styles.table}>
{rows.map((row) => (
@@ -110,6 +151,13 @@ export default function EventDetailScreen() {
{event.link ? <Pressable style={styles.linkButton} onPress={openLink}><Text style={styles.linkButtonText}>Link öffnen</Text></Pressable> : null}
</View>
<HistorySection resource="events" resourceId={eventId} />
<EventCreateModal
visible={editOpen}
event={event}
projects={projects}
onClose={() => setEditOpen(false)}
onUpdated={() => load(false)}
/>
</>
) : null}
</ScrollView>
@@ -122,6 +170,15 @@ const styles = StyleSheet.create({
headingRow: { flexDirection: 'row', alignItems: 'center', gap: 9 },
colorMark: { width: 6, height: 28, borderRadius: 3 },
title: { flex: 1, color: '#111827', fontSize: 19, fontWeight: '700' },
editButton: { borderRadius: 8, backgroundColor: PRIMARY, paddingHorizontal: 10, paddingVertical: 7 },
editButtonText: { color: '#ffffff', fontSize: 12, fontWeight: '700' },
statusSection: { gap: 6 },
statusLabel: { color: '#6b7280', fontSize: 12, textTransform: 'uppercase', fontWeight: '600' },
statusRow: { flexDirection: 'row', gap: 8 },
statusButton: { flex: 1, minHeight: 40, borderRadius: 9, borderWidth: 1, borderColor: '#d1d5db', alignItems: 'center', justifyContent: 'center' },
statusButtonActive: { borderColor: PRIMARY, backgroundColor: '#eff9ea' },
statusButtonText: { color: '#6b7280', fontSize: 13, fontWeight: '700' },
statusButtonTextActive: { color: '#3d7a30' },
table: { borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10, overflow: 'hidden' },
row: { borderBottomWidth: 1, borderBottomColor: '#e5e7eb', paddingHorizontal: 10, paddingVertical: 8, gap: 2 },
label: { color: '#6b7280', fontSize: 12, textTransform: 'uppercase', fontWeight: '600' },

View File

@@ -134,6 +134,7 @@ export default function EventsScreen() {
{projectName(event) ? <Text style={styles.eventProject}>{projectName(event)}</Text> : null}
{event.notes ? <Text style={styles.eventNotes} numberOfLines={2}>{String(event.notes)}</Text> : null}
</View>
{event.state === 'Entwurf' ? <Text style={styles.eventDraft}>Entwurf</Text> : null}
<Text style={styles.arrow}>›</Text>
</Pressable>
))
@@ -183,6 +184,7 @@ const styles = StyleSheet.create({
eventDate: { color: '#374151', fontSize: 13 },
eventProject: { color: '#3d7a30', fontSize: 12, fontWeight: '600' },
eventNotes: { color: '#6b7280', fontSize: 12 },
eventDraft: { alignSelf: 'center', color: '#92400e', backgroundColor: '#fef3c7', borderRadius: 999, paddingHorizontal: 8, paddingVertical: 4, fontSize: 10, fontWeight: '700' },
arrow: { alignSelf: 'center', color: '#9ca3af', fontSize: 25, paddingRight: 12 },
loadingBox: { paddingVertical: 24, alignItems: 'center', gap: 8 },
loadingText: { color: '#6b7280' },

View File

@@ -3,12 +3,11 @@ import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, T
import { router, useLocalSearchParams } from 'expo-router';
import * as DocumentPicker from 'expo-document-picker';
import * as ImagePicker from 'expo-image-picker';
import * as WebBrowser from 'expo-web-browser';
import { HistorySection } from '@/components/history-section';
import { fetchPlantById, fetchPlantFiles, Plant, ProjectFile, uploadPlantFile } from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider';
import { getOpenableFileUrl } from '@/src/lib/file-opening';
import { openAuthenticatedFile } from '@/src/lib/file-opening';
const PRIMARY = '#69c350';
@@ -90,13 +89,13 @@ export default function PlantDetailScreen() {
}
async function onOpenFile(file: ProjectFile) {
if (!file.url) return;
await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), {
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
controlsColor: PRIMARY,
showTitle: true,
enableDefaultShareMenuItem: true,
});
if (!token || !file.url) return;
setError(null);
try {
await openAuthenticatedFile(file, token);
} catch (err) {
setError(err instanceof Error ? err.message : 'Das Dokument konnte nicht geöffnet werden.');
}
}
async function onPickAndUpload() {

View File

@@ -15,7 +15,6 @@ import {
import { router, useLocalSearchParams } from 'expo-router';
import * as DocumentPicker from 'expo-document-picker';
import * as ImagePicker from 'expo-image-picker';
import * as WebBrowser from 'expo-web-browser';
import { EventCreateModal } from '@/components/event-create-modal';
import { HistorySection } from '@/components/history-section';
@@ -34,7 +33,7 @@ import {
updateTask,
} from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider';
import { getOpenableFileUrl } from '@/src/lib/file-opening';
import { openAuthenticatedFile } from '@/src/lib/file-opening';
const PRIMARY = '#69c350';
const TASK_STATUS_ORDER: TaskStatus[] = ['Offen', 'In Bearbeitung', 'Abgeschlossen'];
@@ -193,13 +192,13 @@ export default function ProjectDetailScreen() {
}
async function onOpenFile(file: ProjectFile) {
if (!file.url) return;
await WebBrowser.openBrowserAsync(getOpenableFileUrl(file.url), {
presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET,
controlsColor: PRIMARY,
showTitle: true,
enableDefaultShareMenuItem: true,
});
if (!token || !file.url) return;
setError(null);
try {
await openAuthenticatedFile(file, token);
} catch (err) {
setError(err instanceof Error ? err.message : 'Das Dokument konnte nicht geöffnet werden.');
}
}
async function onCompleteTask(task: Task) {

View File

@@ -0,0 +1,179 @@
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import { useEffect, useMemo, useRef, useState } from 'react';
import { ActivityIndicator, FlatList, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { useChatConversation } from '@/hooks/use-chat-conversation';
import { chatApi } from '@/src/lib/chat-api';
import { prepareMobileChatImage } from '@/src/lib/chat-image';
import { reactionEmojis, type ChatAttachment, type MobileChatMessage, type MobileChatRoom } from '@/src/lib/chat';
export type ChatDraft = { text: string; image: ChatAttachment | null; clientId: string };
type Props = {
token: string; room: MobileChatRoom; enabled: boolean; draft: ChatDraft;
onDraft: (value: Pick<ChatDraft, 'text' | 'image'>) => void;
onSent: (clientId: string) => void; onHide: () => void; hiding: boolean;
};
function MessageImage({ token, room, message }: { token: string; room: MobileChatRoom; message: MobileChatMessage }) {
const [uri, setUri] = useState('');
const [error, setError] = useState(false);
const [expanded, setExpanded] = useState(false);
const [retry, setRetry] = useState(0);
const roomRef = useRef(room);
roomRef.current = room;
useEffect(() => {
let active = true;
setUri(''); setError(false); setExpanded(false);
if (message.pending || message.failed) return;
chatApi.image(token, roomRef.current, message.id).then(value => { if (active) setUri(value); })
.catch(() => { if (active) setError(true); });
return () => { active = false; };
}, [message.failed, message.id, message.pending, retry, room.key, token]);
const image = message.image!;
const width = Math.min(260, image.width);
const height = Math.min(220, width * image.height / image.width);
if (message.pending || message.failed) return <Text style={styles.hint}>📷 {image.name}</Text>;
return <View style={{ marginVertical: 8 }}>
{uri ? <Pressable accessibilityRole="button" accessibilityLabel={`Bild öffnen: ${image.name}`} onPress={() => setExpanded(true)}>
<Image source={{ uri }} cachePolicy="none" contentFit="contain" style={{ width, height }} accessibilityLabel={image.name} />
</Pressable> : error ? <Pressable accessibilityRole="button" onPress={() => setRetry(value => value + 1)}>
<Text style={styles.link}>Bild erneut laden</Text>
</Pressable> : <ActivityIndicator accessibilityLabel="Bild wird geladen" />}
<Modal visible={expanded} animationType="fade" onRequestClose={() => setExpanded(false)}>
<View style={styles.imageModal}>
<Pressable accessibilityRole="button" onPress={() => setExpanded(false)} style={styles.closeImage}><Text style={styles.white}>Schließen</Text></Pressable>
<Image source={{ uri }} cachePolicy="none" contentFit="contain" style={styles.fullImage} accessibilityLabel={image.name} />
</View>
</Modal>
</View>;
}
export function ChatConversation({ token, room, enabled, draft, onDraft, onSent, onHide, hiding }: Props) {
const chat = useChatConversation(token, room, enabled);
const [membersOpen, setMembersOpen] = useState(false);
const [reactionMessage, setReactionMessage] = useState<MobileChatMessage | null>(null);
const [preparing, setPreparing] = useState(false);
const [imageError, setImageError] = useState('');
const list = useRef<FlatList<MobileChatMessage>>(null);
const alive = useRef(true);
const pickerGeneration = useRef(0);
const allowed = enabled && !!chat.detail?.canSend && !chat.loading;
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
useEffect(() => { if (!allowed) setReactionMessage(null); }, [allowed]);
const attach = async () => {
if (!allowed || preparing || chat.sending) return;
const version = ++pickerGeneration.current;
setPreparing(true); setImageError('');
try {
const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], quality: 1 });
if (result.canceled || !alive.current || version !== pickerGeneration.current) return;
const image = await prepareMobileChatImage(result.assets[0]);
// The system photo picker can temporarily background the app. Keep the local
// attachment; sending still requires refreshed room permissions afterwards.
if (alive.current && version === pickerGeneration.current) onDraft({ text: draft.text, image });
} catch (error) {
if (alive.current && version === pickerGeneration.current) setImageError(error instanceof Error ? error.message : 'Bild konnte nicht geöffnet werden.');
} finally { if (alive.current && version === pickerGeneration.current) setPreparing(false); }
};
const send = async () => {
if (!allowed || preparing || (!draft.text.trim() && !draft.image)) return;
const sent = await chat.send(draft.text.trim(), draft.image, draft.clientId);
if (sent) { onSent(draft.clientId); list.current?.scrollToOffset({ offset: 0, animated: true }); }
};
const time = (message: MobileChatMessage) => {
const value = message.createdAt || message.timestamp;
return value ? new Date(value).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : '';
};
const older = () => void chat.older();
const renderMessage = ({ item }: { item: MobileChatMessage }) => {
const canReact = allowed && !chat.detail?.offline && !item.pending && !item.failed;
return <View style={[styles.messageRow, item.own && styles.ownRow]}>
<View style={[styles.bubble, item.own && styles.ownBubble]}>
<Text style={styles.author}>{item.own ? 'Du' : item.senderName || item.senderDisplayName || 'Benutzer'}</Text>
{item.image && <MessageImage key={`${chat.detail?.revision || 0}:${item.id}`} token={token} room={room} message={item} />}
{!!item.body && <Text selectable style={styles.body}>{item.body}</Text>}
<Text style={styles.time}>{time(item)}{item.pending ? ' · Wartet auf Zustellung' : item.failed ? ' · Zustellung fehlgeschlagen' : ''}</Text>
<View style={styles.reactions}>
{(chat.reactions[item.id] || []).map(reaction => <Pressable key={reaction.emoji} accessibilityRole="button"
accessibilityLabel={`${reaction.emoji}: ${reaction.count}${reaction.own ? ', eigene Reaktion entfernen' : ', reagieren'}`}
accessibilityState={{ selected: reaction.own, disabled: !canReact || chat.reacting }} disabled={!canReact || chat.reacting}
onPress={() => void chat.react(item, reaction.emoji)} style={[styles.reaction, reaction.own && styles.ownReaction]}>
<Text>{reaction.emoji} {reaction.count}</Text>
</Pressable>)}
{canReact && <Pressable accessibilityRole="button" accessibilityLabel="Reaktion hinzufügen" disabled={chat.reacting}
onPress={() => setReactionMessage(item)} style={styles.reaction}><Text>☺ +</Text></Pressable>}
</View>
</View>
</View>;
};
// The inverted list renders the newest message at the bottom and always opens there.
const newestFirst = useMemo(() => [...chat.messages].reverse(), [chat.messages]);
return <View style={styles.container}>
<View style={styles.header}>
<View style={styles.grow}><Text style={styles.title} numberOfLines={1}>{chat.detail?.name || room.name}</Text>
<Text style={styles.hint} numberOfLines={1}>{room.remote ? 'Extern · ' : ''}{room.ownerName || room.group}{chat.detail?.status === 'archived' ? ' · Archiviert' : ''}</Text></View>
<Pressable accessibilityRole="button" accessibilityLabel="Teilnehmer anzeigen" onPress={() => setMembersOpen(value => !value)} style={styles.iconButton}><Ionicons name="people-outline" size={23} /></Pressable>
<Pressable accessibilityRole="button" accessibilityLabel="Chat ausblenden" disabled={hiding} onPress={onHide} style={styles.iconButton}><Ionicons name="eye-off-outline" size={23} /></Pressable>
</View>
{membersOpen && <View style={styles.info}><Text style={styles.author}>Teilnehmer · {chat.members.length}</Text>
<Text>{chat.members.map(member => `${member.own ? 'Du' : member.name}${member.kind === 'guest' ? ' (Gast)' : ''}`).join(' · ') || 'Keine Teilnehmer verfügbar.'}</Text></View>}
{!!chat.error && <View style={styles.errorBox}><Text accessibilityRole="alert" style={styles.error}>{chat.error}</Text><Pressable onPress={chat.retry}><Text style={styles.link}>Erneut laden</Text></Pressable></View>}
{chat.detail?.offline && <Text style={styles.notice}>Die Partnerinstanz ist nicht erreichbar. Neue Nachrichten warten auf Zustellung.</Text>}
{!!chat.detail?.overview && <View style={styles.info}><Text style={styles.author}>Freigegebene Projektinformationen</Text>
{Object.entries(chat.detail.overview).filter(([, value]) => !!value).map(([key, value]) => <Text key={key}>{key === 'status' ? 'Status: ' : key === 'date' ? 'Termin: ' : ''}{value}</Text>)}
</View>}
{chat.loading ? <View style={styles.empty}><ActivityIndicator /><Text>Nachrichten werden geladen …</Text></View> :
!chat.messages.length ? <View style={styles.grow}><Text style={[styles.hint, styles.messages]}>Noch keine Nachrichten in diesem Chat.</Text></View> :
<FlatList ref={list} inverted data={newestFirst} keyExtractor={item => String(item.id)} renderItem={renderMessage}
style={styles.grow} contentContainerStyle={styles.messages} keyboardShouldPersistTaps="handled"
maintainVisibleContentPosition={{ minIndexForVisible: 1, autoscrollToTopThreshold: 100 }}
ListFooterComponent={chat.hasMore ? <Pressable disabled={chat.olderLoading} onPress={older} style={styles.loadOlder}><Text style={styles.link}>{chat.olderLoading ? 'Wird geladen …' : 'Ältere Nachrichten laden'}</Text></Pressable> : null} />}
{chat.detail && <View style={styles.composer}>
{!chat.detail.canSend && <Text style={styles.hint}>Dieser Chat ist nur lesbar.</Text>}
{!!imageError && <Text accessibilityRole="alert" style={styles.error}>{imageError}</Text>}
{draft.image && <View style={styles.attachment}><Image source={{ uri: draft.image.dataUrl }} cachePolicy="none" style={styles.thumbnail} contentFit="contain" />
<Text numberOfLines={1} style={styles.grow}>{draft.image.name}</Text>
<Pressable accessibilityLabel="Bild entfernen" disabled={chat.sending || preparing} onPress={() => onDraft({ text: draft.text, image: null })} style={styles.iconButton}><Ionicons name="close" size={23} /></Pressable></View>}
<View style={styles.composerRow}>
<Pressable accessibilityRole="button" accessibilityLabel="Bild anhängen" disabled={!allowed || preparing || chat.sending} onPress={() => void attach()} style={styles.iconButton}>
{preparing ? <ActivityIndicator /> : <Ionicons name="image-outline" size={25} color={allowed ? '#2f6f25' : '#9ca3af'} />}</Pressable>
<TextInput accessibilityLabel="Nachricht" placeholder={allowed ? 'Nachricht schreiben …' : 'Nur lesbar'} style={styles.input} multiline maxLength={10000}
editable={allowed && !preparing && !chat.sending} value={draft.text} onChangeText={text => onDraft({ text, image: draft.image })} />
<Pressable accessibilityRole="button" accessibilityLabel="Nachricht senden" disabled={!allowed || preparing || chat.sending || (!draft.text.trim() && !draft.image)} onPress={() => void send()}
style={[styles.send, (!allowed || preparing || chat.sending || (!draft.text.trim() && !draft.image)) && styles.disabled]}>
{chat.sending ? <ActivityIndicator color="#fff" /> : <Ionicons name="send" size={21} color="#fff" />}</Pressable>
</View>
</View>}
<Modal transparent visible={!!reactionMessage && allowed} animationType="fade" onRequestClose={() => setReactionMessage(null)}>
<Pressable style={styles.overlay} onPress={() => setReactionMessage(null)}><View style={styles.reactionPicker}>
<Text style={styles.title}>Reaktion auswählen</Text><ScrollView horizontal contentContainerStyle={styles.reactions}>
{reactionEmojis.map(emoji => <Pressable key={emoji} accessibilityRole="button" accessibilityLabel={`Mit ${emoji} reagieren`} style={styles.emoji}
onPress={() => { if (reactionMessage) void chat.react(reactionMessage, emoji); setReactionMessage(null); }}><Text style={{ fontSize: 28 }}>{emoji}</Text></Pressable>)}
</ScrollView></View></Pressable>
</Modal>
</View>;
}
const styles = StyleSheet.create({
container: { flex: 1, minHeight: 0 }, grow: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', padding: 12, gap: 8, backgroundColor: '#fff', borderBottomWidth: 1, borderColor: '#e5e7eb' },
title: { fontSize: 16, fontWeight: '700', color: '#111827' }, hint: { color: '#6b7280', fontSize: 12 },
iconButton: { padding: 10, minHeight: 44, minWidth: 44, alignItems: 'center', justifyContent: 'center' },
messages: { padding: 12, gap: 12 }, messageRow: { flexDirection: 'row' }, ownRow: { justifyContent: 'flex-end' },
bubble: { maxWidth: '90%', padding: 12, borderRadius: 14, backgroundColor: '#fff', borderWidth: 1, borderColor: '#e5e7eb' },
ownBubble: { backgroundColor: '#eff9ea', borderColor: '#c7e6bb' }, author: { fontWeight: '700', fontSize: 12, color: '#374151', marginBottom: 4 },
body: { color: '#111827', fontSize: 15, lineHeight: 21 }, time: { color: '#6b7280', fontSize: 10, marginTop: 6, textAlign: 'right' },
reactions: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8 }, reaction: { paddingHorizontal: 9, paddingVertical: 5, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 16, backgroundColor: '#fff' },
ownReaction: { backgroundColor: '#dcfce7', borderColor: '#69c350' }, info: { padding: 12, gap: 3, backgroundColor: '#f0fdf4' },
notice: { padding: 12, backgroundColor: '#fffbeb', color: '#92400e', fontSize: 12 }, errorBox: { padding: 10, backgroundColor: '#fef2f2', gap: 5 }, error: { color: '#991b1b', fontSize: 12 },
link: { color: '#2f6f25', fontWeight: '600', paddingVertical: 5 }, empty: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 10 },
composer: { padding: 10, backgroundColor: '#fff', borderTopWidth: 1, borderColor: '#e5e7eb', gap: 8 }, composerRow: { flexDirection: 'row', alignItems: 'flex-end', gap: 6 },
input: { flex: 1, minHeight: 44, maxHeight: 120, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 12, padding: 10, fontSize: 15, color: '#111827' },
send: { width: 44, height: 44, borderRadius: 12, backgroundColor: '#438b30', alignItems: 'center', justifyContent: 'center' }, disabled: { opacity: 0.4 },
attachment: { flexDirection: 'row', alignItems: 'center', gap: 8 }, thumbnail: { width: 52, height: 52 }, loadOlder: { alignItems: 'center', padding: 10 },
overlay: { flex: 1, justifyContent: 'center', padding: 20, backgroundColor: 'rgba(0,0,0,0.4)' }, reactionPicker: { backgroundColor: '#fff', borderRadius: 16, padding: 16 }, emoji: { padding: 9 },
imageModal: { flex: 1, backgroundColor: '#111827', paddingTop: 60, paddingBottom: 30 }, closeImage: { alignSelf: 'flex-end', padding: 20 }, white: { color: '#fff' }, fullImage: { flex: 1 },
});

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker';
import {
KeyboardAvoidingView,
Keyboard,
Modal,
Platform,
Pressable,
@@ -12,7 +13,7 @@ import {
View,
} from 'react-native';
import { CalendarEvent, createEvent, Project } from '@/src/lib/api';
import { CalendarEvent, createEvent, EventState, Project, updateEvent } from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider';
const PRIMARY = '#69c350';
@@ -22,8 +23,10 @@ type EventCreateModalProps = {
projects?: Project[];
initialProjectId?: number | null;
initialDate?: Date | null;
event?: CalendarEvent | null;
onClose: () => void;
onCreated: (event: CalendarEvent) => void | Promise<void>;
onCreated?: (event: CalendarEvent) => void | Promise<void>;
onUpdated?: (event: CalendarEvent) => void | Promise<void>;
};
function pad(value: number): string {
@@ -64,13 +67,16 @@ export function EventCreateModal({
projects = [],
initialProjectId = null,
initialDate = null,
event = null,
onClose,
onCreated,
onUpdated,
}: EventCreateModalProps) {
const { token } = useAuth();
const [name, setName] = useState('');
const [notes, setNotes] = useState('');
const [link, setLink] = useState('');
const [state, setState] = useState<EventState>('Final');
const [start, setStart] = useState(() => createDefaults(initialDate).start);
const [end, setEnd] = useState(() => createDefaults(initialDate).end);
const [activePicker, setActivePicker] = useState<'startDate' | 'startTime' | 'endDate' | 'endTime' | null>(null);
@@ -82,16 +88,19 @@ export function EventCreateModal({
useEffect(() => {
if (!visible) return;
const defaults = createDefaults(initialDate);
setName('');
setNotes('');
setLink('');
setStart(defaults.start);
setEnd(defaults.end);
const eventStart = event?.startDate ? new Date(event.startDate) : null;
const eventEnd = event?.endDate ? new Date(event.endDate) : null;
setName(event?.name || '');
setNotes(String(event?.notes || ''));
setLink(String(event?.link || ''));
setState(event?.state === 'Entwurf' ? 'Entwurf' : 'Final');
setStart(eventStart && !Number.isNaN(eventStart.getTime()) ? eventStart : defaults.start);
setEnd(eventEnd && !Number.isNaN(eventEnd.getTime()) ? eventEnd : defaults.end);
setActivePicker(null);
setSelectedProjectId(initialProjectId);
setSelectedProjectId(relationId(event?.project) ?? initialProjectId);
setProjectSearch('');
setError(null);
}, [initialDate, initialProjectId, visible]);
}, [event, initialDate, initialProjectId, visible]);
const selectedProject = useMemo(
() => projects.find((project) => Number(project.id) === selectedProjectId) || null,
@@ -130,6 +139,11 @@ export function EventCreateModal({
}
}
function openPicker(picker: 'startDate' | 'startTime' | 'endDate' | 'endTime') {
Keyboard.dismiss();
setActivePicker(picker);
}
async function save() {
if (!token || saving) return;
const title = name.trim();
@@ -146,18 +160,22 @@ export function EventCreateModal({
setSaving(true);
setError(null);
try {
const event = await createEvent(token, {
const payload = {
name: title,
startDate: start.toISOString(),
endDate: end.toISOString(),
project: selectedProjectId,
customer: relationId(selectedProject?.customer),
customer: relationId(selectedProject?.customer) ?? relationId(event?.customer),
notes: notes.trim() || null,
link: link.trim() || null,
state: 'Final',
repeatInterval: 'Keine Wiederholung',
});
await onCreated(event);
state,
repeatInterval: event?.repeatInterval || 'Keine Wiederholung',
};
const savedEvent = event
? await updateEvent(token, Number(event.id), payload)
: await createEvent(token, payload);
if (event) await onUpdated?.(savedEvent);
else await onCreated?.(savedEvent);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Der Termin konnte nicht gespeichert werden.');
@@ -171,7 +189,7 @@ export function EventCreateModal({
<View style={styles.overlay}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.keyboardWrap}>
<View style={styles.card}>
<Text style={styles.title}>Neuer Termin</Text>
<Text style={styles.title}>{event ? 'Termin bearbeiten' : 'Neuer Termin'}</Text>
<ScrollView style={styles.scroll} contentContainerStyle={styles.form} keyboardShouldPersistTaps="handled">
<TextInput
autoFocus
@@ -184,41 +202,37 @@ export function EventCreateModal({
<Text style={styles.label}>Beginn</Text>
<View style={styles.row}>
<Pressable style={[styles.pickerButton, styles.dateInput]} onPress={() => setActivePicker('startDate')}>
<Pressable style={[styles.pickerButton, styles.dateInput]} onPress={() => openPicker('startDate')}>
<Text style={styles.pickerButtonText}>{formatDate(start)}</Text>
</Pressable>
<Pressable style={[styles.pickerButton, styles.timeInput]} onPress={() => setActivePicker('startTime')}>
<Pressable style={[styles.pickerButton, styles.timeInput]} onPress={() => openPicker('startTime')}>
<Text style={styles.pickerButtonText}>{formatTime(start)}</Text>
</Pressable>
</View>
<Text style={styles.label}>Ende</Text>
<View style={styles.row}>
<Pressable style={[styles.pickerButton, styles.dateInput]} onPress={() => setActivePicker('endDate')}>
<Pressable style={[styles.pickerButton, styles.dateInput]} onPress={() => openPicker('endDate')}>
<Text style={styles.pickerButtonText}>{formatDate(end)}</Text>
</Pressable>
<Pressable style={[styles.pickerButton, styles.timeInput]} onPress={() => setActivePicker('endTime')}>
<Pressable style={[styles.pickerButton, styles.timeInput]} onPress={() => openPicker('endTime')}>
<Text style={styles.pickerButtonText}>{formatTime(end)}</Text>
</Pressable>
</View>
{activePicker ? (
<View style={styles.pickerWrap}>
<DateTimePicker
value={activePicker.startsWith('start') ? start : end}
mode={activePicker.endsWith('Date') ? 'date' : 'time'}
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
locale="de-DE"
minuteInterval={5}
onChange={updatePicker}
/>
{Platform.OS === 'ios' ? (
<Pressable style={styles.pickerDoneButton} onPress={() => setActivePicker(null)}>
<Text style={styles.pickerDoneText}>Übernehmen</Text>
<Text style={styles.label}>Status</Text>
<View style={styles.statusRow}>
{(['Entwurf', 'Final'] as EventState[]).map((option) => (
<Pressable
key={option}
style={[styles.statusButton, state === option ? styles.statusButtonActive : null]}
onPress={() => setState(option)}>
<Text style={[styles.statusButtonText, state === option ? styles.statusButtonTextActive : null]}>
{option}
</Text>
</Pressable>
) : null}
))}
</View>
) : null}
<Text style={styles.label}>Projekt</Text>
{selectedProject ? (
@@ -279,11 +293,45 @@ export function EventCreateModal({
<Text style={styles.secondaryButtonText}>Abbrechen</Text>
</Pressable>
<Pressable style={[styles.primaryButton, saving ? styles.disabled : null]} onPress={save} disabled={saving}>
<Text style={styles.primaryButtonText}>{saving ? 'Speichere...' : 'Anlegen'}</Text>
<Text style={styles.primaryButtonText}>{saving ? 'Speichere...' : event ? 'Speichern' : 'Anlegen'}</Text>
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
{activePicker && Platform.OS === 'ios' ? (
<View style={styles.pickerOverlay}>
<Pressable style={styles.pickerBackdrop} onPress={() => setActivePicker(null)} />
<View style={styles.pickerSheet}>
<View style={styles.pickerHeader}>
<Text style={styles.pickerTitle}>
{activePicker.startsWith('start') ? 'Beginn' : 'Ende'} · {activePicker.endsWith('Date') ? 'Datum' : 'Uhrzeit'}
</Text>
<Pressable style={styles.pickerDoneButton} onPress={() => setActivePicker(null)}>
<Text style={styles.pickerDoneText}>Fertig</Text>
</Pressable>
</View>
<DateTimePicker
value={activePicker.startsWith('start') ? start : end}
mode={activePicker.endsWith('Date') ? 'date' : 'time'}
display="spinner"
locale="de-DE"
minuteInterval={5}
onChange={updatePicker}
/>
</View>
</View>
) : null}
{activePicker && Platform.OS === 'android' ? (
<DateTimePicker
value={activePicker.startsWith('start') ? start : end}
mode={activePicker.endsWith('Date') ? 'date' : 'time'}
display="default"
minuteInterval={5}
onChange={updatePicker}
/>
) : null}
</View>
</Modal>
);
@@ -321,9 +369,38 @@ const styles = StyleSheet.create({
pickerButtonText: { color: '#111827', fontSize: 15, fontWeight: '600' },
dateInput: { flex: 1 },
timeInput: { width: 92 },
pickerWrap: { borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10, padding: 8 },
pickerDoneButton: { alignSelf: 'flex-end', paddingHorizontal: 10, paddingVertical: 7 },
pickerOverlay: { ...StyleSheet.absoluteFillObject, justifyContent: 'flex-end', zIndex: 10 },
pickerBackdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(17, 24, 39, 0.25)' },
pickerSheet: {
backgroundColor: '#ffffff',
borderTopLeftRadius: 18,
borderTopRightRadius: 18,
paddingHorizontal: 12,
paddingTop: 8,
paddingBottom: 22,
shadowColor: '#111827',
shadowOpacity: 0.2,
shadowRadius: 10,
shadowOffset: { width: 0, height: -4 },
},
pickerHeader: { minHeight: 42, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
pickerTitle: { color: '#374151', fontSize: 14, fontWeight: '700' },
pickerDoneButton: { paddingHorizontal: 10, paddingVertical: 7 },
pickerDoneText: { color: '#3d7a30', fontSize: 14, fontWeight: '700' },
statusRow: { flexDirection: 'row', gap: 8 },
statusButton: {
flex: 1,
minHeight: 42,
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#ffffff',
},
statusButtonActive: { borderColor: PRIMARY, backgroundColor: '#eff9ea' },
statusButtonText: { color: '#6b7280', fontSize: 14, fontWeight: '700' },
statusButtonTextActive: { color: '#3d7a30' },
multiline: { minHeight: 90, textAlignVertical: 'top' },
selectedProject: {
flexDirection: 'row',

View File

@@ -0,0 +1,149 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { chatApi } from '@/src/lib/chat-api';
import {
applyChatAccess, chatCursor, isChatAccessError, mergeChatMessages,
type ChatAttachment, type ChatDetail, type ChatPerson, type ChatReactions,
type MobileChatMessage, type MobileChatRoom,
} from '@/src/lib/chat';
const errorText = (error: unknown) => error instanceof Error ? error.message : 'Chat konnte nicht geladen werden.';
// Each mounted conversation belongs to one session and one room. Late responses cannot
// repopulate an unmounted conversation or a conversation whose access was revoked.
export function useChatConversation(token: string, room: MobileChatRoom, enabled: boolean) {
const [detail, setDetail] = useState<ChatDetail | null>(null);
const [messages, setMessages] = useState<MobileChatMessage[]>([]);
const [members, setMembers] = useState<ChatPerson[]>([]);
const [reactions, setReactions] = useState<ChatReactions>({});
const [loading, setLoading] = useState(true);
const [olderLoading, setOlderLoading] = useState(false);
const [hasMore, setHasMore] = useState(false);
const [sending, setSending] = useState(false);
const [reacting, setReacting] = useState(false);
const [error, setError] = useState('');
const [reload, setReload] = useState(0);
const generation = useRef(0);
const state = useRef<{ detail: ChatDetail | null; messages: MobileChatMessage[] }>({ detail: null, messages: [] });
const busy = useRef({ send: false, reaction: false, older: false });
const reactionRevision = useRef(0);
const roomRef = useRef(room);
roomRef.current = room;
const current = useCallback((version: number) => generation.current === version, []);
const update = useCallback((next: typeof state.current) => {
state.current = next; setDetail(next.detail); setMessages(next.messages);
}, []);
const fail = useCallback((err: unknown, version: number) => {
if (!current(version)) return;
setError(errorText(err));
if (isChatAccessError(err)) {
++generation.current; ++reactionRevision.current;
update({ detail: null, messages: [] }); setMembers([]); setReactions({});
setLoading(false); setSending(false); setReacting(false); setOlderLoading(false); setHasMore(false);
}
}, [current, update]);
useEffect(() => {
const version = ++generation.current;
const target = roomRef.current;
busy.current = { send: false, reaction: false, older: false };
setSending(false); setReacting(false); setOlderLoading(false);
if (!enabled) return;
let polling = false;
async function read(nextMessages: MobileChatMessage[]) {
const cursor = chatCursor(target, nextMessages);
if (current(version) && cursor) {
try { await chatApi.read(token, target, cursor); } catch { /* Read receipts do not block the chat. */ }
}
}
async function refreshReactions(nextMessages: MobileChatMessage[]) {
if (busy.current.reaction) return;
const revision = ++reactionRevision.current;
try {
const result = await chatApi.reactions(token, target, nextMessages);
if (current(version) && reactionRevision.current === revision) setReactions(result);
} catch { /* A temporary reaction failure must not hide the conversation. */ }
}
async function load() {
setLoading(true); setError(''); setHasMore(false);
update({ detail: null, messages: [] }); setMembers([]); setReactions({});
try {
const [nextDetail, page, people] = await Promise.all([
chatApi.detail(token, target), chatApi.messages(token, target), chatApi.members(token, target),
]);
if (!current(version)) return;
const nextMessages = target.source === 'collaboration' ? applyChatAccess(page.messages, nextDetail) : page.messages;
update({ detail: nextDetail, messages: nextMessages }); setMembers(people); setHasMore(!!page.hasMore);
await Promise.all([read(nextMessages), refreshReactions(nextMessages)]);
} catch (err) { fail(err, version); }
finally { if (current(version)) setLoading(false); }
}
async function poll() {
if (!current(version) || polling || !state.current.detail || busy.current.older) return;
polling = true;
try {
const [nextDetail, page, people] = await Promise.all([
chatApi.detail(token, target), chatApi.messages(token, target, { after: chatCursor(target, state.current.messages) }),
chatApi.members(token, target),
]);
if (!current(version)) return;
const collaboration = target.source === 'collaboration';
const retained = collaboration ? applyChatAccess(state.current.messages, nextDetail, state.current.detail) : state.current.messages;
let nextMessages = mergeChatMessages(retained, page.messages, collaboration);
if (collaboration) nextMessages = applyChatAccess(nextMessages, nextDetail);
update({ detail: nextDetail, messages: nextMessages }); setMembers(people); setError('');
await Promise.all([read(nextMessages), refreshReactions(nextMessages)]);
} catch (err) { fail(err, version); }
finally { polling = false; }
}
void load();
const timer = setInterval(() => void poll(), 3000);
return () => { ++generation.current; ++reactionRevision.current; clearInterval(timer); };
}, [current, enabled, fail, reload, room.key, token, update]);
async function send(text: string, image: ChatAttachment | null, clientId: string) {
if (!enabled || !state.current.detail?.canSend || busy.current.send) return false;
const version = generation.current;
const membershipRevision = state.current.detail.revision;
busy.current.send = true; setSending(true); setError('');
try {
const message = await chatApi.send(token, room, text, image, clientId);
// Clear the matching draft after a successful delivery, even if its room is no
// longer open. Never append a late message after access/history has changed.
if (!current(version) || state.current.detail?.revision !== membershipRevision) return true;
const next = mergeChatMessages(state.current.messages, [message], room.source === 'collaboration');
update({ ...state.current, messages: next });
// The send is complete even if the read receipt cannot be delivered.
const cursor = chatCursor(room, next);
if (cursor) void chatApi.read(token, room, cursor).catch(() => undefined);
return true;
} catch (err) { fail(err, version); return false; }
finally { if (current(version)) { busy.current.send = false; setSending(false); } }
}
async function older() {
const before = state.current.messages.find(message => message.seq)?.seq;
if (!enabled || !before || !hasMore || busy.current.older) return;
const version = generation.current;
busy.current.older = true; setOlderLoading(true);
try {
const page = await chatApi.messages(token, room, { before });
if (!current(version) || !state.current.detail) return;
const next = applyChatAccess(mergeChatMessages(state.current.messages, page.messages, true), state.current.detail);
update({ ...state.current, messages: next }); setHasMore(!!page.hasMore);
} catch (err) { fail(err, version); }
finally { if (current(version)) { busy.current.older = false; setOlderLoading(false); } }
}
async function react(message: MobileChatMessage, emoji: string) {
if (!enabled || !state.current.detail?.canSend || state.current.detail.offline || message.pending || message.failed || busy.current.reaction) return;
const version = generation.current;
++reactionRevision.current; busy.current.reaction = true; setReacting(true);
try {
const active = !(reactions[message.id] || []).some(item => item.emoji === emoji && item.own);
const result = await chatApi.react(token, room, message.id, emoji, active);
if (current(version)) setReactions(previous => ({ ...previous, [message.id]: result.reactions[message.id] || [] }));
} catch (err) { if (current(version)) setError(errorText(err)); }
finally { if (current(version)) { busy.current.reaction = false; setReacting(false); } }
}
return { detail, messages, members, reactions, loading, olderLoading, hasMore, sending, reacting, error,
send, older, react, retry: () => setReload(value => value + 1) };
}

View File

@@ -16,17 +16,20 @@
"expo": "~54.0.37",
"expo-camera": "~17.0.10",
"expo-constants": "~18.0.14",
"expo-crypto": "~15.0.9",
"expo-document-picker": "^14.0.8",
"expo-file-system": "~19.0.24",
"expo-font": "~14.0.12",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "~17.0.11",
"expo-linking": "~8.0.12",
"expo-notifications": "~0.32.17",
"expo-router": "~6.0.24",
"expo-secure-store": "^15.0.8",
"expo-share-intent": "^5.1.1",
"expo-sharing": "~14.0.8",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8",
@@ -48,6 +51,7 @@
"@types/react": "~19.1.0",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"react-test-renderer": "19.1.0",
"typescript": "~5.9.2"
},
"engines": {
@@ -6249,6 +6253,18 @@
"react-native": "*"
}
},
"node_modules/expo-crypto": {
"version": "15.0.9",
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.9.tgz",
"integrity": "sha512-SNWKa2fXx7v9gkp1h/7nqXY5XN7qgNDn3yRc2aO0gWGbeMbvob/haMxxsPFe9f51aqH5NjNCqHf2kvLhvAd8KQ==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-document-picker": {
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
@@ -6317,6 +6333,18 @@
"expo": "*"
}
},
"node_modules/expo-image-manipulator": {
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-14.0.8.tgz",
"integrity": "sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q==",
"license": "MIT",
"dependencies": {
"expo-image-loader": "~6.0.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-image-picker": {
"version": "17.0.11",
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.11.tgz",
@@ -6835,6 +6863,15 @@
"node": ">=10"
}
},
"node_modules/expo-sharing": {
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.8.tgz",
"integrity": "sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-splash-screen": {
"version": "31.0.13",
"resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.13.tgz",
@@ -11178,6 +11215,20 @@
}
}
},
"node_modules/react-test-renderer": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-19.1.0.tgz",
"integrity": "sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw==",
"dev": true,
"license": "MIT",
"dependencies": {
"react-is": "^19.1.0",
"scheduler": "^0.26.0"
},
"peerDependencies": {
"react": "^19.1.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",

View File

@@ -14,7 +14,8 @@
"web": "expo start --web",
"lint": "expo lint",
"doctor": "npx expo-doctor",
"preflight:testflight": "npm run lint && npm run doctor",
"test": "node --experimental-strip-types --test tests/*.test.mjs",
"preflight:testflight": "npm run lint && npm test && npm run doctor",
"build:ios:dev": "eas build --profile development --platform ios",
"build:ios:preview": "eas build --profile preview --platform ios",
"build:ios:testflight": "eas build --profile testflight --platform ios",
@@ -29,17 +30,20 @@
"expo": "~54.0.37",
"expo-camera": "~17.0.10",
"expo-constants": "~18.0.14",
"expo-crypto": "~15.0.9",
"expo-document-picker": "^14.0.8",
"expo-file-system": "~19.0.24",
"expo-font": "~14.0.12",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "~17.0.11",
"expo-linking": "~8.0.12",
"expo-notifications": "~0.32.17",
"expo-router": "~6.0.24",
"expo-secure-store": "^15.0.8",
"expo-share-intent": "^5.1.1",
"expo-sharing": "~14.0.8",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8",
@@ -61,6 +65,7 @@
"@types/react": "~19.1.0",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"react-test-renderer": "19.1.0",
"typescript": "~5.9.2"
},
"private": true

Some files were not shown because too many files have changed in this diff Show More