diff --git a/backend/db/migrations/0084_cash_discounts.sql b/backend/db/migrations/0084_cash_discounts.sql new file mode 100644 index 00000000..d82923ae --- /dev/null +++ b/backend/db/migrations/0084_cash_discounts.sql @@ -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; diff --git a/backend/db/migrations/0085_cash_discount_terms.sql b/backend/db/migrations/0085_cash_discount_terms.sql new file mode 100644 index 00000000..5a853a0a --- /dev/null +++ b/backend/db/migrations/0085_cash_discount_terms.sql @@ -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; diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index 81c77fba..8cd47188 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -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 } ] } diff --git a/backend/db/schema/createddocuments.ts b/backend/db/schema/createddocuments.ts index 40b74fab..7229d1f4 100644 --- a/backend/db/schema/createddocuments.ts +++ b/backend/db/schema/createddocuments.ts @@ -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"), diff --git a/backend/db/schema/customers.ts b/backend/db/schema/customers.ts index c720d696..6f35a296 100644 --- a/backend/db/schema/customers.ts +++ b/backend/db/schema/customers.ts @@ -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"), diff --git a/backend/db/schema/incominginvoices.ts b/backend/db/schema/incominginvoices.ts index ef43f312..fff60936 100644 --- a/backend/db/schema/incominginvoices.ts +++ b/backend/db/schema/incominginvoices.ts @@ -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"), diff --git a/backend/db/schema/statementallocations.ts b/backend/db/schema/statementallocations.ts index 3d6032d6..18ee6536 100644 --- a/backend/db/schema/statementallocations.ts +++ b/backend/db/schema/statementallocations.ts @@ -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 ), diff --git a/backend/db/schema/tenants.ts b/backend/db/schema/tenants.ts index 8e7a8bb2..efa05d1b 100644 --- a/backend/db/schema/tenants.ts +++ b/backend/db/schema/tenants.ts @@ -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([]), diff --git a/backend/db/schema/vendors.ts b/backend/db/schema/vendors.ts index 32ae94ff..a9801d38 100644 --- a/backend/db/schema/vendors.ts +++ b/backend/db/schema/vendors.ts @@ -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), diff --git a/backend/package.json b/backend/package.json index 6302391b..6ab1b596 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/mcp/statementAllocations.ts b/backend/src/mcp/statementAllocations.ts index f5e26974..5e6d3b19 100644 --- a/backend/src/mcp/statementAllocations.ts +++ b/backend/src/mcp/statementAllocations.ts @@ -55,6 +55,14 @@ export const prepareStatementAllocationInput = (args: Record) = 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") diff --git a/backend/src/mcp/tools/accounting.ts b/backend/src/mcp/tools/accounting.ts index 779d7260..b38460a4 100644 --- a/backend/src/mcp/tools/accounting.ts +++ b/backend/src/mcp/tools/accounting.ts @@ -34,8 +34,10 @@ 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" @@ -246,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") @@ -431,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 @@ -634,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", @@ -691,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", @@ -881,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", @@ -908,11 +920,13 @@ export const accountingTools: McpTool[] = [ 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) @@ -1200,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" }, @@ -1317,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" }, @@ -1350,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" }, @@ -1613,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", @@ -1621,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." }, @@ -1646,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) diff --git a/backend/src/mcp/tools/masterdataWrites.ts b/backend/src/mcp/tools/masterdataWrites.ts index e86bda78..f572591d 100644 --- a/backend/src/mcp/tools/masterdataWrites.ts +++ b/backend/src/mcp/tools/masterdataWrites.ts @@ -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], }), diff --git a/backend/src/mcp/tools/outgoingStatus.ts b/backend/src/mcp/tools/outgoingStatus.ts index 8c3a265e..4cf7a0e8 100644 --- a/backend/src/mcp/tools/outgoingStatus.ts +++ b/backend/src/mcp/tools/outgoingStatus.ts @@ -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 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 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) { diff --git a/backend/src/modules/cash-discount.ts b/backend/src/modules/cash-discount.ts new file mode 100644 index 00000000..781d2130 --- /dev/null +++ b/backend/src/modules/cash-discount.ts @@ -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> +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 { + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw invalidInput("Die Skontokonten müssen als Objekt übergeben werden.") + } + const result: Partial = {} + for (const side of ["received", "granted"] as const) { + const entries: Partial> = {} + 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 + } + return result +} + +export function settledAllocationAmount(allocation: { amount?: any, cashDiscountAmount?: any }) { + return Number(allocation?.amount || 0) + Number(allocation?.cashDiscountAmount || 0) +} + +type GrossByRate = Record +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): 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) { + 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 +} diff --git a/backend/src/modules/cron/prepareIncomingInvoices.ts b/backend/src/modules/cron/prepareIncomingInvoices.ts index 493b3a0b..3a06aa79 100644 --- a/backend/src/modules/cron/prepareIncomingInvoices.ts +++ b/backend/src/modules/cron/prepareIncomingInvoices.ts @@ -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 = { "Direct Debit": "Einzug", "Transfer": "Überweisung", diff --git a/backend/src/modules/einvoice/buildZugferdInvoice.ts b/backend/src/modules/einvoice/buildZugferdInvoice.ts new file mode 100644 index 00000000..f7119986 --- /dev/null +++ b/backend/src/modules/einvoice/buildZugferdInvoice.ts @@ -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, "&") + .replace(//g, ">") + .replace(/"/g, """) + +const text = (value: unknown) => String(value ?? "").trim() +const el = (name: string, value: unknown, attributes = "") => { + const content = text(value) + return content ? `<${name}${attributes}>${escapeXml(content)}` : "" +} +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}>${formatted}` : "" +} + +const countryCodes: Record = { + 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 = { + 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 { + 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 `${el("ram:PostcodeCode", address.zip)}${el("ram:LineOne", address.street)}${el("ram:LineTwo", address.special)}${el("ram:CityName", address.city)}${countryCode(address.country)}` +} + +function tradeContact(name: unknown, phone?: unknown, email?: unknown) { + if (!text(name) && !text(phone) && !text(email)) return "" + return `${el("ram:PersonName", name)}${text(phone) ? `${el("ram:CompleteNumber", phone)}` : ""}${text(email) ? `${el("ram:URIID", email)}` : ""}` +} + +function taxRegistrations(vatId: unknown, taxNumber?: unknown) { + return [ + text(vatId) ? `${escapeXml(text(vatId).replace(/\s+/g, ""))}` : "", + text(taxNumber) ? `${escapeXml(text(taxNumber))}` : "", + ].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() + 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( + `` + + `${escapeXml(text(row.pos) || String(lineIndex))}` + + `${el("ram:SellerAssignedID", sellerId)}${escapeXml(name)}${el("ram:Description", description)}` + + `` + + (allowance > 0 + ? `${decimal(grossPrice)}false${decimal(allowance)}` + : "") + + `${decimal(netUnitPrice)}` + + `` + + `${decimal(quantity)}` + + `VAT${group.category}${decimal(group.rate)}` + + `${amount(lineTotal)}` + + ``, + ) + } + + 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 = `59SEPA-Lastschrift` + } else if (document.payment_type === "transfer" || !document.payment_type) { + paymentMeans = `58SEPA-Überweisung` + + (iban ? `${escapeXml(iban)}${el("ram:AccountName", accountName)}` : "") + + (iban && bic ? `${escapeXml(bic)}` : "") + + `` + } else { + paymentMeans = `1` + } + + 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 + ? `${el("ram:Description", termsDescription)}${dueDate ? dateElement("ram:DueDateDateTime", dueDate.toDate()) : ""}${el("ram:DirectDebitMandateID", document.payment_type === "direct-debit" ? context.sepaMandate?.reference : "")}` + : "" + + const taxElements = taxGroups.map((group) => + `${amount(group.basis * group.rate / 100)}VAT${el("ram:ExemptionReason", group.exemptionReason)}${amount(group.basis)}${group.category}${el("ram:ExemptionReasonCode", group.exemptionReasonCode)}${decimal(group.rate)}`, + ).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) + ? `${dateElement("ram:OccurrenceDateTime", document.deliveryDate)}` + : "" + const billingPeriod = showDelivery && isPeriod && (date102(document.deliveryDate) || date102(document.deliveryDateEnd)) + ? `${dateElement("ram:StartDateTime", document.deliveryDate)}${dateElement("ram:EndDateTime", document.deliveryDateEnd)}` + : "" + + 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 = `${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) ? `${escapeXml(text(businessInfo.email))}` : "") + + taxRegistrations(businessInfo.vatId, businessInfo.taxNumber) + + `` + + const buyerEmail = text(customerInfo.invoiceEmail || customerInfo.email) + const contact = context.contact + const buyer = `${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 ? `${escapeXml(buyerEmail)}` : "") + + taxRegistrations(customerInfo.ustid) + + `` + + const contractReference = el("ram:IssuerAssignedID", context.contract?.contractNumber) + const projectReference = context.project?.name + ? `${escapeXml(text(context.project.projectNumber || context.project.id))}${escapeXml(text(context.project.name))}` + : "" + const precedingInvoice = document.type === "cancellationInvoices" && text(context.linkedDocument?.documentNumber) + ? `${escapeXml(text(context.linkedDocument.documentNumber))}${date102(context.linkedDocument.documentDate) ? `${date102(context.linkedDocument.documentDate)}` : ""}` + : "" + + const notes = [ + text(document.title), + text(document.description), + ].filter(Boolean).map((note) => `${escapeXml(note)}`).join("") + + const xml = ` +` + + `${ZUGFERD_GUIDELINE_ID}` + + `${escapeXml(text(document.documentNumber))}${typeCode(document.type)}` + + `${date102(document.documentDate) || dayjs().format("YYYYMMDD")}${notes}` + + `${lines.join("")}` + + `${el("ram:BuyerReference", context.project?.customerRef)}${seller}${buyer}` + + (contractReference ? `${contractReference}` : "") + + `${projectReference}` + + `${deliveryEvent}` + + `` + + (document.payment_type === "direct-debit" ? el("ram:CreditorReferenceID", context.tenant?.creditorId) : "") + + el("ram:PaymentReference", document.documentNumber) + + `EUR${paymentMeans}${taxElements}${billingPeriod}${paymentTerms}` + + `` + + `${amount(lineNet)}` + + `${amount(lineNet)}` + + `${amount(taxTotal)}` + + `${amount(grossTotal)}` + + (prepaid ? `${amount(prepaid)}` : "") + + `${amount(payable)}` + + `${precedingInvoice}` + + `` + + return { xml, issues, totals: { lineNet, tax: taxTotal, gross: grossTotal, prepaid, payable } } +} diff --git a/backend/src/modules/einvoice/embedZugferd.ts b/backend/src/modules/einvoice/embedZugferd.ts new file mode 100644 index 00000000..68b591ae --- /dev/null +++ b/backend/src/modules/einvoice/embedZugferd.ts @@ -0,0 +1,80 @@ +import { AFRelationship, PDFDocument, PDFName } from "pdf-lib" +import { ZUGFERD_FILENAME } from "./buildZugferdInvoice" + +const escapeXml = (value: string) => value.replace(/&/g, "&").replace(//g, ">") + +function buildXmp(title: string, now: Date) { + const date = now.toISOString() + return ` + + + +application/pdf +${escapeXml(title)} + + +FEDEO +${date} +${date} +${date} + + +FEDEO + + +INVOICE +${ZUGFERD_FILENAME} +1.0 +EN 16931 + + + +Factur-X PDFA Extension Schema +urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0# +fx + +DocumentFileNameTextexternalname of the embedded XML invoice file +DocumentTypeTextexternalINVOICE +VersionTextexternalThe actual version of the Factur-X XML schema +ConformanceLevelTextexternalThe conformance level of the embedded Factur-X data + + + + + +` +} + +/** + * 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 })) +} diff --git a/backend/src/modules/einvoice/mapElectronicInvoice.ts b/backend/src/modules/einvoice/mapElectronicInvoice.ts index e8a25319..56c13e8f 100644 --- a/backend/src/modules/einvoice/mapElectronicInvoice.ts +++ b/backend/src/modules/einvoice/mapElectronicInvoice.ts @@ -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) => ({ diff --git a/backend/src/modules/einvoice/outgoingZugferd.service.ts b/backend/src/modules/einvoice/outgoingZugferd.service.ts new file mode 100644 index 00000000..bfa21c1b --- /dev/null +++ b/backend/src/modules/einvoice/outgoingZugferd.service.ts @@ -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 { + 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 } +} diff --git a/backend/src/modules/einvoice/parseElectronicInvoice.ts b/backend/src/modules/einvoice/parseElectronicInvoice.ts index cea54770..9e6b948f 100644 --- a/backend/src/modules/einvoice/parseElectronicInvoice.ts +++ b/backend/src/modules/einvoice/parseElectronicInvoice.ts @@ -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 => { }) 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 => { }, 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 => { } } +// 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 => { @@ -163,6 +179,7 @@ const parseUbl = (root: any, syntax: "ubl-invoice" | "ubl-credit-note"): Omit array(terms?.Note).map(text)).join("\n")), items, totals: { lineNet: number(monetary?.LineExtensionAmount) != null ? roundMoney(sign * number(monetary.LineExtensionAmount)!) : null, diff --git a/backend/src/modules/einvoice/types.ts b/backend/src/modules/einvoice/types.ts index 607a44e4..cd15cc34 100644 --- a/backend/src/modules/einvoice/types.ts +++ b/backend/src/modules/einvoice/types.ts @@ -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 diff --git a/backend/src/modules/outgoing-document-pdf.service.ts b/backend/src/modules/outgoing-document-pdf.service.ts index 7f8cded6..5404cda5 100644 --- a/backend/src/modules/outgoing-document-pdf.service.ts +++ b/backend/src/modules/outgoing-document-pdf.service.ts @@ -18,6 +18,7 @@ import { } 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"] @@ -130,6 +131,15 @@ export function buildOutgoingDocumentPdfData(source: any, data: OutgoingDocument } 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 @@ -160,6 +170,7 @@ export function buildOutgoingDocumentPdfData(source: any, data: OutgoingDocument 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) => Object.fromEntries(Object.entries(sums).map(([key, value]) => [key, renderCurrency(value)])) @@ -198,7 +209,7 @@ export function buildOutgoingDocumentPdfData(source: any, data: OutgoingDocument ], title: document.title, description: document.description, - endText: documentTemplateHandlebars.compile(document.endText || "")(context), + endText: appendCashDiscountText(documentTemplateHandlebars.compile(document.endText || "")(context), document.endText, cashDiscountTerms), startText: documentTemplateHandlebars.compile(document.startText || "")(context), rows, totalArray: [ diff --git a/backend/src/modules/serialexecution.service.ts b/backend/src/modules/serialexecution.service.ts index 9ea6e7d9..715ac94c 100644 --- a/backend/src/modules/serialexecution.service.ts +++ b/backend/src/modules/serialexecution.service.ts @@ -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 - // @ts-ignore - const fileBuffer = Buffer.from(pdfBase64.base64, "base64"); + // 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 + 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, diff --git a/backend/src/routes/banking.ts b/backend/src/routes/banking.ts index 8de6a5bb..01d19c06 100644 --- a/backend/src/routes/banking.ts +++ b/backend/src/routes/banking.ts @@ -11,6 +11,7 @@ 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, @@ -56,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) @@ -77,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) @@ -1168,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) - prepared.data = await useCurrentIncomingInvoiceAmount(req.user.tenant_id, prepared.data) + 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, diff --git a/backend/src/routes/functions.ts b/backend/src/routes/functions.ts index 8f6d96b3..d8068154 100644 --- a/backend/src/routes/functions.ts +++ b/backend/src/routes/functions.ts @@ -18,7 +18,8 @@ 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"; @@ -110,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} @@ -125,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, @@ -136,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) { @@ -149,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 diff --git a/backend/src/routes/resources/main.ts b/backend/src/routes/resources/main.ts index bdf34cd8..20ff4214 100644 --- a/backend/src/routes/resources/main.ts +++ b/backend/src/routes/resources/main.ts @@ -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( diff --git a/backend/src/routes/tenant.ts b/backend/src/routes/tenant.ts index 8681272a..f2c363dc 100644 --- a/backend/src/routes/tenant.ts +++ b/backend/src/routes/tenant.ts @@ -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) diff --git a/backend/src/utils/export/datev.ts b/backend/src/utils/export/datev.ts index 29f09cc9..eda65a11 100644 --- a/backend/src/utils/export/datev.ts +++ b/backend/src/utils/export/datev.ts @@ -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) { diff --git a/backend/src/utils/gpt.ts b/backend/src/utils/gpt.ts index e7334456..7e2059ef 100644 --- a/backend/src/utils/gpt.ts +++ b/backend/src/utils/gpt.ts @@ -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", }, diff --git a/backend/src/utils/liquidityForecast.ts b/backend/src/utils/liquidityForecast.ts index 5422c1f6..1818d5a1 100644 --- a/backend/src/utils/liquidityForecast.ts +++ b/backend/src/utils/liquidityForecast.ts @@ -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)) ); } }); diff --git a/backend/tests/cashDiscount.test.ts b/backend/tests/cashDiscount.test.ts new file mode 100644 index 00000000..624d0d11 --- /dev/null +++ b/backend/tests/cashDiscount.test.ts @@ -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) +}) diff --git a/backend/tests/einvoice.test.ts b/backend/tests/einvoice.test.ts index e58f9e95..c14f78ce 100644 --- a/backend/tests/einvoice.test.ts +++ b/backend/tests/einvoice.test.ts @@ -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( + "", + "#SKONTO#TAGE=10#PROZENT=2.00#\n", + )) + 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( + "", + "#SKONTO#TAGE=14#PROZENT=3.00#", + )) + const detection = await detectElectronicInvoice(xml, { name: "xrechnung.xml" }) + const parsed = parseElectronicInvoice(detection!.xml, detection!.syntax) + assert.deepEqual(parsed.cashDiscount, { percent: 3, days: 14 }) +}) diff --git a/backend/tests/zugferdInvoice.test.ts b/backend/tests/zugferdInvoice.test.ts new file mode 100644 index 00000000..5c69d591 --- /dev/null +++ b/backend/tests/zugferdInvoice.test.ts @@ -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 => ({ + 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(`]*)?>([^<]*)`, "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, /urn:cen.eu:en16931:2017<\/ram:ID>/) + assert.match(xml, /380<\/ram:TypeCode>/) + assert.match(xml, /Kunde & Söhne KG/) + assert.match(xml, /DE02120300000000202051<\/ram:IBANID>/) + assert.match(xml, /DE987654321<\/ram:ID>/) + assert.match(xml, /A-7<\/ram:SellerAssignedID>/) + assert.match(xml, /20261118<\/udt:DateTimeString>/) + assert.match(xml, /20261102/) + // Negative Preise werden als negative Menge mit positivem Nettopreis abgebildet (BR-27). + assert.match(xml, /5<\/ram:ChargeAmount><\/ram:NetPriceProductTradePrice><\/ram:SpecifiedLineTradeAgreement>-1 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, /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, /381<\/ram:TypeCode>/) + assert.equal(totals.gross, 119) + assert.match(xml, /2<\/ram:BilledQuantity>/) + assert.match(xml, /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, /595.00<\/ram:TotalPrepaidAmount>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, /AE<\/ram:CategoryCode>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, /Kein Ausweis von Umsatzsteuer, da Kleinunternehmer gemäß § 19 UStG<\/ram:ExemptionReason>/) + assert.match(smallBusiness.xml, /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, /20261001/) + assert.match(xml, /DE98ZZZ09999999999<\/ram:CreditorReferenceID>/) + assert.match(xml, /59<\/ram:TypeCode>/) + assert.match(xml, /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"]) +}) diff --git a/docs-site/content/bedienung/bankportal.md b/docs-site/content/bedienung/bankportal.md index 13f3a101..b15616ff 100644 --- a/docs-site/content/bedienung/bankportal.md +++ b/docs-site/content/bedienung/bankportal.md @@ -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. diff --git a/docs/bedienung/bankportal.md b/docs/bedienung/bankportal.md index 13f3a101..b15616ff 100644 --- a/docs/bedienung/bankportal.md +++ b/docs/bedienung/bankportal.md @@ -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. diff --git a/frontend/components/displayBWASummary.vue b/frontend/components/displayBWASummary.vue index 3b8950b4..90498b36 100644 --- a/frontend/components/displayBWASummary.vue +++ b/frontend/components/displayBWASummary.vue @@ -1,6 +1,7 @@ @@ -726,6 +835,9 @@ setup() {{ getAllocationTaxLabel(item) }} · Netto {{ displayCurrency(getBankAllocationAmounts(item).amountNet) }} · Steuer {{ displayCurrency(getBankAllocationAmounts(item).amountTax) }} +
+ Skonto {{ displayCurrency(item.cashDiscountAmount) }} · {{ getCashDiscountSplitLabel(item) }} +
{{ getAllocationBookingModeLabel(item) }} {{ item.depreciationGroup }} @@ -756,6 +868,9 @@ setup()
{{ displayCurrency(item.amount) }}
+
+ + {{ displayCurrency(item.cashDiscountAmount) }} Skonto +
Rechnung zuweisen + + Mit Skonto + Beleg zuweisen + + Mit Skonto +
{{ displayCurrency(document.openSum) }}
Offen
+ Skonto passt
+ + +
Offen
+ Skonto passt + + + + + + +