dev #257

Merged
flfeders merged 8 commits from dev into main 2026-11-04 10:35:20 +00:00
55 changed files with 2638 additions and 94 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import {
timestamp, timestamp,
boolean, boolean,
doublePrecision, doublePrecision,
jsonb,
} from "drizzle-orm/pg-core" } from "drizzle-orm/pg-core"
import { tenants } from "./tenants" import { tenants } from "./tenants"
@@ -30,6 +31,10 @@ export const statementallocations = pgTable("statementallocations", {
amount: doublePrecision("amount").notNull().default(0), 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( incominginvoice: bigint("ii_id", { mode: "number" }).references(
() => incominginvoices.id () => incominginvoices.id
), ),

View File

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

View File

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

View File

@@ -9,7 +9,7 @@
"fill": "ts-node src/webdav/fill-file-sizes.ts", "fill": "ts-node src/webdav/fill-file-sizes.ts",
"dev:dav": "tsx watch src/webdav/server.ts", "dev:dav": "tsx watch src/webdav/server.ts",
"build": "tsc", "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", "start": "node dist/src/index.js",
"schema:index": "ts-node scripts/generate-schema-index.ts", "schema:index": "ts-node scripts/generate-schema-index.ts",
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts", "bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",

View File

@@ -55,6 +55,14 @@ export const prepareStatementAllocationInput = (args: Record<string, unknown>) =
if (!bookingModes.has(bookingMode)) throw new Error(`Ungültige Aufwandsart: ${bookingMode}`) if (!bookingModes.has(bookingMode)) throw new Error(`Ungültige Aufwandsart: ${bookingMode}`)
payload.amount = amount 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") if (args.description !== undefined) payload.description = stringArg(args, "description")
payload.bookingMode = bookingMode payload.bookingMode = bookingMode
if (args.datevTaxKey !== undefined) payload.datevTaxKey = stringArg(args, "datevTaxKey") if (args.datevTaxKey !== undefined) payload.datevTaxKey = stringArg(args, "datevTaxKey")

View File

@@ -34,8 +34,10 @@ import { applyOutgoingRowCatalogDefaults } from "../outgoingRowDefaults"
import { analyzeOutgoingDocumentLayout, DEFAULT_CONTENT_BOTTOM_MM } from "../outgoingDocumentLayout" import { analyzeOutgoingDocumentLayout, DEFAULT_CONTENT_BOTTOM_MM } from "../outgoingDocumentLayout"
import { withImages } from "../result" import { withImages } from "../result"
import { renderOutgoingDocumentPdf } from "../../modules/outgoing-document-pdf.service" import { renderOutgoingDocumentPdf } from "../../modules/outgoing-document-pdf.service"
import { assertOutgoingEInvoiceRequirements } from "../../modules/einvoice/outgoingZugferd.service"
import { renderPdfPagesAsPng } from "../../utils/pdfPreview" import { renderPdfPagesAsPng } from "../../utils/pdfPreview"
import { outgoingStatusTools } from "./outgoingStatus" import { outgoingStatusTools } from "./outgoingStatus"
import { normalizeCashDiscountDays, normalizeCashDiscountPercent, prepareCashDiscountAllocation } from "../../modules/cash-discount"
export { prepareStatementAllocationInput } from "../statementAllocations" export { prepareStatementAllocationInput } from "../statementAllocations"
@@ -246,6 +248,8 @@ const buildOutgoingDocumentPayload = (
for (const field of ["paymentDays"] as const) { for (const field of ["paymentDays"] as const) {
if (args[field] !== undefined) payload[field] = numberArg(args, field) 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.type !== undefined) payload.type = documentTypeArg(args)
if (args.address !== undefined) payload.address = optionalObjectArg(args, "address") 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) { for (const field of ["state", "reference", "date", "dueDate", "description", "paymentType"] as const) {
if (args[field] !== undefined) payload[field] = stringArg(args, field) 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.vendor !== undefined) payload.vendor = numberArg(args, "vendor")
if (args.document !== undefined) payload.document = numberArg(args, "document") if (args.document !== undefined) payload.document = numberArg(args, "document")
if (args.expense !== undefined) payload.expense = args.expense === true if (args.expense !== undefined) payload.expense = args.expense === true
@@ -634,6 +640,8 @@ export const accountingTools: McpTool[] = [
deliveryDateEnd: { type: "string" }, deliveryDateEnd: { type: "string" },
deliveryDateType: { type: "string" }, deliveryDateType: { type: "string" },
paymentDays: { type: "number" }, 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" }, payment_type: { type: "string" },
taxType: { taxType: {
type: "string", type: "string",
@@ -691,6 +699,8 @@ export const accountingTools: McpTool[] = [
deliveryDateEnd: { type: "string" }, deliveryDateEnd: { type: "string" },
deliveryDateType: { type: "string" }, deliveryDateType: { type: "string" },
paymentDays: { type: "number" }, 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" }, payment_type: { type: "string" },
taxType: { taxType: {
type: "string", type: "string",
@@ -881,6 +891,8 @@ export const accountingTools: McpTool[] = [
deliveryDate: { type: "string" }, deliveryDate: { type: "string" },
deliveryDateEnd: { type: "string" }, deliveryDateEnd: { type: "string" },
paymentDays: { type: "number" }, 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" }, payment_type: { type: "string" },
taxType: { taxType: {
type: "string", type: "string",
@@ -908,11 +920,13 @@ export const accountingTools: McpTool[] = [
if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) } if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) }
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId) 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) const result = await useNextNumberRangeNumber(context.server, context.tenantId, existing.type)
payload.state = "Gebucht" payload.state = "Gebucht"
payload.documentNumber = result.usedNumber payload.documentNumber = result.usedNumber
applyOutgoingDocumentTaxType(payload, args, existing.type, existing.taxType, existing.rows)
const [updated] = await context.server.db const [updated] = await context.server.db
.update(createddocuments) .update(createddocuments)
@@ -1200,6 +1214,8 @@ export const accountingTools: McpTool[] = [
reference: { type: "string" }, reference: { type: "string" },
date: { type: "string" }, date: { type: "string" },
dueDate: { type: "string" }, dueDate: { type: "string" },
cashDiscountPercent: { type: ["number", "null"] },
cashDiscountDueDate: { type: ["string", "null"] },
document: { type: "number" }, document: { type: "number" },
description: { type: "string" }, description: { type: "string" },
paymentType: { type: "string" }, paymentType: { type: "string" },
@@ -1317,6 +1333,8 @@ export const accountingTools: McpTool[] = [
reference: { type: "string" }, reference: { type: "string" },
date: { type: "string" }, date: { type: "string" },
dueDate: { 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" }, document: { type: "number" },
description: { type: "string" }, description: { type: "string" },
paymentType: { type: "string" }, paymentType: { type: "string" },
@@ -1350,6 +1368,8 @@ export const accountingTools: McpTool[] = [
reference: { type: "string" }, reference: { type: "string" },
date: { type: "string" }, date: { type: "string" },
dueDate: { 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" }, document: { type: "number" },
description: { type: "string" }, description: { type: "string" },
paymentType: { type: "string" }, paymentType: { type: "string" },
@@ -1613,7 +1633,7 @@ export const accountingTools: McpTool[] = [
{ {
name: "accounting.statement_allocations.create", name: "accounting.statement_allocations.create",
title: "Bankzuweisung erstellen", 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"], requiredPermissions: ["accounting.statement_allocations.write"],
inputSchema: { inputSchema: {
type: "object", type: "object",
@@ -1621,6 +1641,7 @@ export const accountingTools: McpTool[] = [
properties: { properties: {
bankstatement: { type: "number", description: "Bankumsatz-ID. Weglassen für eine manuelle Soll/Haben-Buchung." }, 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." }, 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." }, createddocument: { type: "number", description: "Ausgangsbeleg-ID." },
incominginvoice: { type: "number", description: "Eingangsbeleg-ID." }, incominginvoice: { type: "number", description: "Eingangsbeleg-ID." },
account: { type: "number", description: "Sachkonto-ID beziehungsweise Soll-Sachkonto." }, account: { type: "number", description: "Sachkonto-ID beziehungsweise Soll-Sachkonto." },
@@ -1646,8 +1667,9 @@ export const accountingTools: McpTool[] = [
}, },
}, },
async handler(context, args) { async handler(context, args) {
const payload = prepareStatementAllocationInput(args) const prepared = prepareStatementAllocationInput(args)
await assertStatementAllocationReferences(context, payload) await assertStatementAllocationReferences(context, prepared)
const payload = await prepareCashDiscountAllocation(context.server.db, context.tenantId, prepared)
const [created] = await context.server.db const [created] = await context.server.db
.insert(statementallocations) .insert(statementallocations)

View File

@@ -46,14 +46,14 @@ export const masterdataWriteConfigs: Config[] = [
makeConfig({ makeConfig({
resource: "customers", namespace: "customers", label: "Kunde", resultKey: "customer", table: tables.customers, resource: "customers", namespace: "customers", label: "Kunde", resultKey: "customer", table: tables.customers,
required: ["name"], numberField: "customerNumber", 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 }, overrides: { customerNumber: textField(true), infoData: infoDataField, profiles: profilesField, memberrelation: idField },
references: [commonProfiles, bankAccounts, { field: "memberrelation", table: tables.memberrelations }], references: [commonProfiles, bankAccounts, { field: "memberrelation", table: tables.memberrelations }],
}), }),
makeConfig({ makeConfig({
resource: "vendors", namespace: "vendors", label: "Lieferant", resultKey: "vendor", table: tables.vendors, resource: "vendors", namespace: "vendors", label: "Lieferant", resultKey: "vendor", table: tables.vendors,
required: ["name"], numberField: "vendorNumber", 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 }, overrides: { vendorNumber: textField(true), infoData: infoDataField, profiles: profilesField },
references: [commonProfiles, bankAccounts], references: [commonProfiles, bankAccounts],
}), }),

View File

@@ -41,10 +41,10 @@ function documentAmount(document: any, advanceInvoices: any[]) {
return money(money(net) + money(tax19) + money(tax7) - advanceAmount) 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) const amount = documentAmount(document, advanceInvoices)
if (![amount, paidAmount].every(Number.isFinite)) throw new Error(`Beleg ${document.id}: Betrag kann nicht berechnet werden`) if (![amount, paidAmount, cashDiscountAmount].every(Number.isFinite)) throw new Error(`Beleg ${document.id}: Betrag kann nicht berechnet werden`)
const openAmount = money(amount - paidAmount) const openAmount = money(amount - paidAmount - cashDiscountAmount)
const cancelled = cancellations.length > 0 const cancelled = cancellations.length > 0
const applicable = document.state === "Gebucht" && ["invoices", "advanceInvoices"].includes(document.type) && !cancelled const applicable = document.state === "Gebucht" && ["invoices", "advanceInvoices"].includes(document.type) && !cancelled
// Signed invoices and refunds use the same payment-direction comparison. // 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, id: document.id, type: document.type, documentNumber: document.documentNumber, documentDate: document.documentDate,
state: document.state, customer: document.customer, project: document.project, state: document.state, customer: document.customer, project: document.project,
cancelled, cancellationDocuments: cancellations.map(({ id, documentNumber }) => ({ id, documentNumber })), cancelled, cancellationDocuments: cancellations.map(({ id, documentNumber }) => ({ id, documentNumber })),
paymentStatus, amount, paidAmount: money(paidAmount), openAmount, paymentStatus, amount, paidAmount: money(paidAmount), cashDiscountAmount: money(cashDiscountAmount), openAmount,
} }
} }
@@ -65,7 +65,7 @@ async function statusesFor(context: McpContext, documents: Array<typeof createdd
const advanceIds = [...new Set(documents.flatMap(document => Array.isArray(document.usedAdvanceInvoices) const advanceIds = [...new Set(documents.flatMap(document => Array.isArray(document.usedAdvanceInvoices)
? document.usedAdvanceInvoices.filter(id => typeof id === "number" && Number.isSafeInteger(id) && id > 0) as number[] : []))] ? document.usedAdvanceInvoices.filter(id => typeof id === "number" && Number.isSafeInteger(id) && id > 0) as number[] : []))]
const [allocations, cancellations, advances] = await Promise.all([ 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))), .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) 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"), .where(and(eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false), eq(createddocuments.type, "cancellationInvoices"),
@@ -73,15 +73,19 @@ async function statusesFor(context: McpContext, documents: Array<typeof createdd
advanceIds.length ? context.server.db.select().from(createddocuments) advanceIds.length ? context.server.db.select().from(createddocuments)
.where(and(eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false), inArray(createddocuments.id, advanceIds))) : [], .where(and(eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false), inArray(createddocuments.id, advanceIds))) : [],
]) ])
return documents.map(document => outgoingDocumentStatus(document, return documents.map(document => {
allocations.filter(allocation => allocation.createddocument === document.id).reduce((sum, allocation) => sum + Number(allocation.amount), 0), const documentAllocations = allocations.filter(allocation => allocation.createddocument === document.id)
cancellations.filter(cancellation => cancellation.createddocument === document.id), advances)) 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[] = [ export const outgoingStatusTools: McpTool[] = [
{ {
name: "accounting.outgoing_documents.status.get", title: "Ausgangsbelegstatus laden", 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"], requiredPermissions: ["accounting.outgoing_documents.read"],
inputSchema: { ...objectField({ id: idField }).schema, required: ["id"] }, inputSchema: { ...objectField({ id: idField }).schema, required: ["id"] },
async handler(context, args) { async handler(context, args) {

View File

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

View File

@@ -31,7 +31,7 @@ const formatInvoiceItemDescription = (item: any) => {
return parts.join(" - ") return parts.join(" - ")
} }
const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => { export const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => {
const itemInfo: any = { const itemInfo: any = {
tenant: tenantId, tenant: tenantId,
state: "Vorbereitet", state: "Vorbereitet",
@@ -43,6 +43,17 @@ const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => {
if (data.issuer?.id) itemInfo.vendor = data.issuer.id 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() if (data.invoice_duedate && dayjs(data.invoice_duedate).isValid()) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString()
// Skonto nur übernehmen, wenn die Rechnung ihn ausdrücklich nennt (siehe Prompt).
const cashDiscountPercent = Number(data.cash_discount_percent)
if (Number.isFinite(cashDiscountPercent) && cashDiscountPercent > 0 && cashDiscountPercent <= 10) {
itemInfo.cashDiscountPercent = Number(cashDiscountPercent.toFixed(2))
if (data.cash_discount_due_date && dayjs(data.cash_discount_due_date).isValid()) {
itemInfo.cashDiscountDueDate = dayjs(data.cash_discount_due_date).format("YYYY-MM-DD")
} else if (Number.isInteger(data.cash_discount_days) && data.invoice_date && dayjs(data.invoice_date).isValid()) {
itemInfo.cashDiscountDueDate = dayjs(data.invoice_date).add(data.cash_discount_days, "day").format("YYYY-MM-DD")
}
}
const mapPayment: Record<string, string> = { const mapPayment: Record<string, string> = {
"Direct Debit": "Einzug", "Direct Debit": "Einzug",
"Transfer": "Überweisung", "Transfer": "Überweisung",

View File

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

View File

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

View File

@@ -41,6 +41,13 @@ export const findElectronicInvoiceVendor = async (
return null 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 = ( export const mapElectronicInvoiceToIncomingInvoice = (
invoice: ParsedElectronicInvoice, invoice: ParsedElectronicInvoice,
tenantId: number, tenantId: number,
@@ -64,6 +71,10 @@ export const mapElectronicInvoiceToIncomingInvoice = (
reference: invoice.invoiceNumber, reference: invoice.invoiceNumber,
date: invoice.invoiceDate, date: invoice.invoiceDate,
dueDate: invoice.dueDate, 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", paymentType: paymentTypeByCode[invoice.paymentMeansCode || ""] || "Sonstiges",
description: descriptionParts.join("\n"), description: descriptionParts.join("\n"),
accounts: invoice.items.map((item) => ({ accounts: invoice.items.map((item) => ({

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ import {
} from "../../db/schema" } from "../../db/schema"
import { documentTemplateHandlebars } from "../utils/handlebars" import { documentTemplateHandlebars } from "../utils/handlebars"
import { createInvoicePDF, InvoicePdfLayout } from "../utils/pdf" import { createInvoicePDF, InvoicePdfLayout } from "../utils/pdf"
import { appendCashDiscountText, buildCashDiscountTerms, getCashDiscountTemplateContext } from "./cash-discount"
const quoteLikeDocumentTypes = ["quotes", "costEstimates"] const quoteLikeDocumentTypes = ["quotes", "costEstimates"]
const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"] const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"]
@@ -130,6 +131,15 @@ export function buildOutgoingDocumentPdfData(source: any, data: OutgoingDocument
} }
const totals = calculateOutgoingDocumentTotals(document, data.advanceInvoices) 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) => { const rows = document.rows.map((row: any) => {
if (!["pagebreak", "title"].includes(row.mode)) { if (!["pagebreak", "title"].includes(row.mode)) {
if (row.agriculture?.description) row.descriptionText = row.agriculture.description 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, zahlungsziel_in_tagen: document.paymentDays,
zahlungsart: document.payment_type === "transfer" ? "Überweisung" : "Lastschrift", zahlungsart: document.payment_type === "transfer" ? "Überweisung" : "Lastschrift",
diesel_gesamtverbrauch: document.agriculture?.dieselUsageTotal, diesel_gesamtverbrauch: document.agriculture?.dieselUsageTotal,
...getCashDiscountTemplateContext(cashDiscountTerms),
} }
const formatDate = (value: any) => value ? dayjs(value).format("DD.MM.YYYY") : "XXXX" const formatDate = (value: any) => value ? dayjs(value).format("DD.MM.YYYY") : "XXXX"
const formatSums = (sums: Record<string, number>) => Object.fromEntries(Object.entries(sums).map(([key, value]) => [key, renderCurrency(value)])) const formatSums = (sums: Record<string, number>) => 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, title: document.title,
description: document.description, 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), startText: documentTemplateHandlebars.compile(document.startText || "")(context),
rows, rows,
totalArray: [ totalArray: [

View File

@@ -9,7 +9,9 @@ import { saveFile } from "../utils/files";
import {FastifyInstance} from "fastify"; import {FastifyInstance} from "fastify";
import {useNextNumberRangeNumber} from "../utils/functions"; import {useNextNumberRangeNumber} from "../utils/functions";
import {createInvoicePDF} from "../utils/pdf"; // Achtung: Muss Node.js Buffer unterstützen! 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 { documentTemplateHandlebars } from "../utils/handlebars";
import { appendCashDiscountText, buildCashDiscountTerms, getCashDiscountTemplateContext } from "./cash-discount"
dayjs.extend(quarterOfYear); dayjs.extend(quarterOfYear);
@@ -200,6 +202,7 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
let successCount = 0; let successCount = 0;
let errorCount = 0; let errorCount = 0;
const einvoiceErrors: string[] = [];
// 5. Loop durch Dokumente // 5. Loop durch Dokumente
for (const doc of documents) { 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 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( const pdfData = await getCloseData(
server, server,
doc, doc,
@@ -232,8 +238,14 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
console.log(pdfBase64); console.log(pdfBase64);
// E. Datei speichern // E. Datei speichern
// E-Rechnung (ZUGFeRD) in die PDF einbetten; Grundlage ist der Beleg mit der neuen Nummer
const { pdf: fileBuffer } = await attachZugferdToPdf(
server,
tenantId,
{ ...doc, state: "Gebucht", documentNumber: pdfData.documentNumber, title: pdfData.title },
// @ts-ignore // @ts-ignore
const fileBuffer = Buffer.from(pdfBase64.base64, "base64"); Buffer.from(pdfBase64.base64, "base64"),
);
const filename = `${pdfData.documentNumber}.pdf`; const filename = `${pdfData.documentNumber}.pdf`;
await saveFile( await saveFile(
@@ -266,6 +278,7 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
} catch (innerErr) { } catch (innerErr) {
console.error(`Fehler beim Finalisieren von Doc ID ${doc.id}:`, innerErr); console.error(`Fehler beim Finalisieren von Doc ID ${doc.id}:`, innerErr);
errorCount++; errorCount++;
if (innerErr instanceof EInvoiceRequirementsError) einvoiceErrors.push(`Beleg ${doc.id}: ${innerErr.message}`);
// Optional: Status des einzelnen Dokuments auf Error setzen // 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, 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 }; return { success: execution.status === "completed", execution, processed: successCount, errors: errorCount };
} catch (error) { } catch (error) {
await settleExecution(server, executionId, tenantId, "error", "Kritischer Fehler beim Finalisieren."); 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, deliveryDate: firstDate,
deliveryDateEnd: lastDate, deliveryDateEnd: lastDate,
paymentDays: item.paymentDays, paymentDays: item.paymentDays,
cashDiscountPercent: item.cashDiscountPercent,
cashDiscountDays: item.cashDiscountDays,
payment_type: item.payment_type, payment_type: item.payment_type,
deliveryDateType: item.deliveryDateType, deliveryDateType: item.deliveryDateType,
info: {}, // Achtung: Postgres erwartet hier valides JSON Objekt info: {}, // Achtung: Postgres erwartet hier valides JSON Objekt
@@ -646,6 +661,12 @@ export function getDocumentDataBackend(
// --- 5. Handlebars Context --- // --- 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 = () => { const generateContext = () => {
return { return {
// lohnkosten: null, // Backend hat diesen Wert oft nicht direkt, ggf. aus itemInfo holen // lohnkosten: null, // Backend hat diesen Wert oft nicht direkt, ggf. aus itemInfo holen
@@ -656,7 +677,8 @@ export function getDocumentDataBackend(
kundenname: customerData && customerData.name, kundenname: customerData && customerData.name,
zahlungsziel_in_tagen: itemInfo.paymentDays, zahlungsziel_in_tagen: itemInfo.paymentDays,
zahlungsart: itemInfo.payment_type === "transfer" ? "Überweisung" : "Lastschrift", 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, title: itemInfo.title,
description: itemInfo.description, description: itemInfo.description,
// Handlebars Compilation ausführen // Handlebars Compilation ausführen
endText: templateEndText(generateContext()), endText: appendCashDiscountText(templateEndText(generateContext()), itemInfo.endText, cashDiscountTerms),
startText: templateStartText(generateContext()), startText: templateStartText(generateContext()),
rows: rows, rows: rows,
totalArray: totalArray, totalArray: totalArray,

View File

@@ -11,6 +11,7 @@ import { centralServicesClient } from "../modules/push-server.client"
import { findBankInstitutionByBic } from "../modules/banking-institution" import { findBankInstitutionByBic } from "../modules/banking-institution"
import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../modules/bank-allocation-details" import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../modules/bank-allocation-details"
import { CASHBOOK_BANK_ID } from "../modules/bank-balances.service" import { CASHBOOK_BANK_ID } from "../modules/bank-balances.service"
import { prepareCashDiscountAllocation, settledAllocationAmount } from "../modules/cash-discount"
import { import {
bankrequisitions, bankrequisitions,
@@ -56,7 +57,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
eq(bankstatements.id, statementId), eq(bankstatements.id, statementId),
eq(bankstatements.tenant, tenantId) eq(bankstatements.tenant, tenantId)
)).limit(1), )).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.incominginvoice, invoiceId),
eq(statementallocations.tenant, tenantId), eq(statementallocations.tenant, tenantId),
eq(statementallocations.archived, false) 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 !== "" const correctedTax = invoice.taxAmountOverride !== null && invoice.taxAmountOverride !== undefined && invoice.taxAmountOverride !== ""
? Number(invoice.taxAmountOverride) ? Number(invoice.taxAmountOverride)
: accountTotals.tax : accountTotals.tax
const legacyRemaining = Math.max(0, Math.abs(accountTotals.net + accountTotals.tax) const invoiceSettled = invoiceAllocations.reduce((sum, item) => sum + Math.abs(settledAllocationAmount(item)), 0)
- invoiceAllocations.reduce((sum, item) => sum + Math.abs(Number(item.amount || 0)), 0)) const legacyRemaining = Math.max(0, Math.abs(accountTotals.net + accountTotals.tax) - invoiceSettled)
const correctedRemaining = Math.max(0, Math.abs(accountTotals.net + correctedTax) const correctedRemaining = Math.max(0, Math.abs(accountTotals.net + correctedTax) - invoiceSettled)
- invoiceAllocations.reduce((sum, item) => sum + Math.abs(Number(item.amount || 0)), 0))
const statementRemaining = Math.max(0, Math.abs(Number(statement.amount || 0)) const statementRemaining = Math.max(0, Math.abs(Number(statement.amount || 0))
- statementAllocations.reduce((sum, item) => sum + Math.abs(Number(item.amount || 0)), 0)) - statementAllocations.reduce((sum, item) => sum + Math.abs(Number(item.amount || 0)), 0))
const requestedAmount = Number(allocation.amount || 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 }) if (prepared.error) return reply.code(400).send({ error: prepared.error })
Object.assign(prepared.data, normalizeBankAllocationDetails(prepared.data)) Object.assign(prepared.data, normalizeBankAllocationDetails(prepared.data))
await validateBankAllocationCostCentre(server.db, req.user.tenant_id, prepared.data.costcentre) await validateBankAllocationCostCentre(server.db, req.user.tenant_id, prepared.data.costcentre)
if (!Number(prepared.data.cashDiscountAmount || 0)) {
prepared.data = await useCurrentIncomingInvoiceAmount(req.user.tenant_id, prepared.data) prepared.data = await 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({ const inserted = await server.db.insert(statementallocations).values({
...prepared.data, ...prepared.data,

View File

@@ -18,7 +18,8 @@ import isSameOrBefore from "dayjs/plugin/isSameOrBefore.js"
import duration from "dayjs/plugin/duration.js"; import duration from "dayjs/plugin/duration.js";
import timezone from "dayjs/plugin/timezone.js"; import timezone from "dayjs/plugin/timezone.js";
import {generateTimesEvaluation} from "../modules/time/evaluation.service"; 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 {and, eq, isNull, not} from "drizzle-orm";
import {cancelManualGeneration, executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service"; import {cancelManualGeneration, executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.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 { const body = req.body as {
data: any data: any
backgroundPath?: string backgroundPath?: string
// Gespeicherter Beleg, aus dem die eingebettete E-Rechnung (ZUGFeRD) erzeugt wird
createdDocumentId?: number
} }
const {type} = req.params as {type:string} const {type} = req.params as {type:string}
@@ -125,6 +128,17 @@ export default async function functionRoutes(server: FastifyInstance) {
body.data, body.data,
body.backgroundPath 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") { } else if(type === "timesheet") {
pdf = await createTimeSheetPDF( pdf = await createTimeSheetPDF(
server, server,
@@ -136,6 +150,9 @@ export default async function functionRoutes(server: FastifyInstance) {
return pdf // Fastify wandelt automatisch in JSON return pdf // Fastify wandelt automatisch in JSON
} catch (err) { } catch (err) {
if (err instanceof EInvoiceRequirementsError) {
return reply.code(422).send({ error: err.message, code: err.code, issues: err.issues })
}
console.log(err) console.log(err)
const unsupportedCharacterMessage = getUnsupportedPdfCharacterMessage(err, body.data) const unsupportedCharacterMessage = getUnsupportedPdfCharacterMessage(err, body.data)
if (unsupportedCharacterMessage) { 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) => { server.get("/functions/usenextnumber/:numberrange", async (req, reply) => {
const { numberrange } = req.params as { numberrange: string }; const { numberrange } = req.params as { numberrange: string };
const tenant = (req as any).user.tenant_id const tenant = (req as any).user.tenant_id

View File

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

View File

@@ -15,6 +15,7 @@ import {and, desc, eq, inArray, isNull} from "drizzle-orm"
import { enrichProfilesWithBranches } from "../utils/profileBranches" import { enrichProfilesWithBranches } from "../utils/profileBranches"
import { enrichProfilesWithTeams } from "../utils/profileTeams" import { enrichProfilesWithTeams } from "../utils/profileTeams"
import { rotateRefreshToken } from "../utils/authTokens" import { rotateRefreshToken } from "../utils/authTokens"
import { normalizeCashDiscountAccountsInput } from "../modules/cash-discount"
export default async function tenantRoutes(server: FastifyInstance) { export default async function tenantRoutes(server: FastifyInstance) {
@@ -287,6 +288,14 @@ export default async function tenantRoutes(server: FastifyInstance) {
delete data.portalConfig.primayColor 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 const updated = await server.db
.update(tenants) .update(tenants)
.set(data) .set(data)

View File

@@ -104,6 +104,51 @@ export const getBankAccountBookingLine = (alloc: any) => {
return columns.join(";"); 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) => { const getIncomingInvoiceTaxOverride = (invoice: any) => {
if (invoice.taxAmountOverride === null || invoice.taxAmountOverride === undefined || invoice.taxAmountOverride === "") return null; if (invoice.taxAmountOverride === null || invoice.taxAmountOverride === undefined || invoice.taxAmountOverride === "") return null;
@@ -479,6 +524,10 @@ export async function buildExportZip(
const ii = alloc.incominginvoice; const ii = alloc.incominginvoice;
const vend = ii.vendor; 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;;;;"";;;;;;`); 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) { } else if(alloc.account) {
bookingLines.push(getBankAccountBookingLine(alloc)); bookingLines.push(getBankAccountBookingLine(alloc));
} else if(alloc.vendor) { } else if(alloc.vendor) {

View File

@@ -74,6 +74,9 @@ const InstructionFormat = z.object({
tax: nullableNumber, tax: nullableNumber,
total: nullableNumber, total: nullableNumber,
terms: nullableString, 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" + "Do not merge summary totals into fabricated invoice_items.\n" +
"Match issuer by name to vendor.id.\n" + "Match issuer by name to vendor.id.\n" +
"Match invoice items to account id based on label/number.\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" + "Convert dates to YYYY-MM-DD.\n" +
"Keep invoice items in original order.\n", "Keep invoice items in original order.\n",
}, },

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,6 +36,23 @@ Vor dem Buchen werden Netto-, Steuer- und Bruttobetrag angezeigt. Beispielsweise
Der DATEV-Export übergibt den Bruttobetrag mit dem gewählten BU-Schlüssel (9/8 für Vorsteuer, 3/2 für Umsatzsteuer) und die Kostenstellennummer in **KOST1**. „Ohne Umsatzsteuer“ verwendet Schlüssel 40, um auch eine Kontenautomatik aufzuheben. Bestehende Buchungen ohne Steuerauswahl werden nicht nachträglich umgerechnet. Die Zuordnung folgt den [DATEV-Steuerschlüsseln](https://www.datev.de/dnlexom/v2/content/files/st2286167563_de.pdf) und der [DATEV-Dokumentation zur Aufhebung der Automatik](https://apps.datev.de/help-center/api/knowledge-help/v1/content/entities/st2112778507_de.pdf?docId=0907888&save=False). 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 ## Gute Praxis
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden. - Regelmäßig abrufen, damit sich keine großen Rückstände bilden.

View File

@@ -36,6 +36,23 @@ Vor dem Buchen werden Netto-, Steuer- und Bruttobetrag angezeigt. Beispielsweise
Der DATEV-Export übergibt den Bruttobetrag mit dem gewählten BU-Schlüssel (9/8 für Vorsteuer, 3/2 für Umsatzsteuer) und die Kostenstellennummer in **KOST1**. „Ohne Umsatzsteuer“ verwendet Schlüssel 40, um auch eine Kontenautomatik aufzuheben. Bestehende Buchungen ohne Steuerauswahl werden nicht nachträglich umgerechnet. Die Zuordnung folgt den [DATEV-Steuerschlüsseln](https://www.datev.de/dnlexom/v2/content/files/st2286167563_de.pdf) und der [DATEV-Dokumentation zur Aufhebung der Automatik](https://apps.datev.de/help-center/api/knowledge-help/v1/content/entities/st2112778507_de.pdf?docId=0907888&save=False). 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 ## Gute Praxis
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden. - Regelmäßig abrufen, damit sich keine großen Rückstände bilden.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -427,6 +427,7 @@ const togglePortalRelease = async () => {
<div> <div>
<p class="text-xs text-gray-500">Betrag</p> <p class="text-xs text-gray-500">Betrag</p>
<p class="font-medium">{{ formatCurrency(allocation.amount) }}</p> <p class="font-medium">{{ formatCurrency(allocation.amount) }}</p>
<p v-if="Number(allocation.cashDiscountAmount)" class="text-xs text-amber-600">+ {{ formatCurrency(allocation.cashDiscountAmount) }} Skonto</p>
</div> </div>
<div> <div>
<p class="text-xs text-gray-500">Text</p> <p class="text-xs text-gray-500">Text</p>

View File

@@ -34,6 +34,8 @@ const itemInfo = ref({
reference: "", reference: "",
date: null, date: null,
dueDate: null, dueDate: null,
cashDiscountPercent: null,
cashDiscountDueDate: null,
paymentType: "Überweisung", paymentType: "Überweisung",
description: "", description: "",
state: "Entwurf", state: "Entwurf",
@@ -147,6 +149,28 @@ const setDateFieldToToday = (field) => {
itemInfo.value[field] = dayjs().toDate() itemInfo.value[field] = dayjs().toDate()
} }
// Skontofrist als reines Datum speichern, damit der Vergleich mit dem Zahlungsdatum nicht durch Zeitzonen kippt.
const setCashDiscountDueDate = (value) => {
itemInfo.value.cashDiscountDueDate = value ? dayjs(value.toString()).format("YYYY-MM-DD") : null
}
// Lieferanten-Vorgabe nur bei bewusster Auswahl übernehmen, nicht beim Laden eines Belegs.
const applyVendorCashDiscount = (vendorId) => {
const vendor = vendors.value.find((item) => item.id === vendorId)
if (!vendor?.cashDiscountPercent || itemInfo.value.cashDiscountPercent) return
itemInfo.value.cashDiscountPercent = vendor.cashDiscountPercent
if (itemInfo.value.date && vendor.cashDiscountDays !== null && vendor.cashDiscountDays !== undefined) {
itemInfo.value.cashDiscountDueDate = dayjs(itemInfo.value.date).add(vendor.cashDiscountDays, "day").format("YYYY-MM-DD")
}
}
const cashDiscountAmount = computed(() => {
const percent = Number(itemInfo.value.cashDiscountPercent || 0)
if (!(percent > 0)) return null
const gross = (itemInfo.value.accounts || []).reduce((sum, account) => sum + Number(account.amountNet || 0) + Number(account.amountTax || 0), 0)
return Number((Math.abs(gross) * percent / 100).toFixed(2))
})
const getDateButtonLabel = (value, emptyLabel = "Kein Datum") => value ? dayjs(value).format('DD.MM.YYYY') : emptyLabel const getDateButtonLabel = (value, emptyLabel = "Kein Datum") => value ? dayjs(value).format('DD.MM.YYYY') : emptyLabel
const formatDate = (value) => value ? dayjs(value).format("DD.MM.YYYY") : "-" const formatDate = (value) => value ? dayjs(value).format("DD.MM.YYYY") : "-"
const formatMoney = (value) => `${Number(value || 0).toFixed(2).replace(".", ",")} €` const formatMoney = (value) => `${Number(value || 0).toFixed(2).replace(".", ",")} €`
@@ -333,6 +357,9 @@ const updateIncomingInvoice = async (setBooked = false) => {
let item = { ...itemInfo.value } let item = { ...itemInfo.value }
item.accounts = (item.accounts || []).map((account) => ensureDepreciationDefaults({ ...account }, item.date)) item.accounts = (item.accounts || []).map((account) => ensureDepreciationDefaults({ ...account }, item.date))
const cashDiscountPercent = Number(item.cashDiscountPercent)
item.cashDiscountPercent = Number.isFinite(cashDiscountPercent) && cashDiscountPercent > 0 ? cashDiscountPercent : null
item.cashDiscountDueDate = item.cashDiscountPercent && item.cashDiscountDueDate ? dayjs(item.cashDiscountDueDate).format("YYYY-MM-DD") : null
delete item.files delete item.files
item.state = keepBooked ? "Gebucht" : "Entwurf" item.state = keepBooked ? "Gebucht" : "Entwurf"
@@ -569,6 +596,14 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
<dt class="text-gray-500">Fälligkeitsdatum</dt> <dt class="text-gray-500">Fälligkeitsdatum</dt>
<dd class="font-medium">{{ formatDate(itemInfo.dueDate) }}</dd> <dd class="font-medium">{{ formatDate(itemInfo.dueDate) }}</dd>
</div> </div>
<div v-if="itemInfo.cashDiscountPercent">
<dt class="text-gray-500">Skonto</dt>
<dd class="font-medium">
{{ String(itemInfo.cashDiscountPercent).replace(".", ",") }} %
<span v-if="cashDiscountAmount">({{ formatMoney(cashDiscountAmount) }})</span>
<span v-if="itemInfo.cashDiscountDueDate"> bis {{ formatDate(itemInfo.cashDiscountDueDate) }}</span>
</dd>
</div>
<div> <div>
<dt class="text-gray-500">Bankbuchungsdatum</dt> <dt class="text-gray-500">Bankbuchungsdatum</dt>
<dd class="font-medium">{{ bankBookingDateLabel }}</dd> <dd class="font-medium">{{ bankBookingDateLabel }}</dd>
@@ -602,6 +637,7 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
<div> <div>
<p class="text-xs text-gray-500">Betrag</p> <p class="text-xs text-gray-500">Betrag</p>
<p class="font-medium">{{ formatMoney(allocation.amount) }}</p> <p class="font-medium">{{ formatMoney(allocation.amount) }}</p>
<p v-if="Number(allocation.cashDiscountAmount)" class="text-xs text-amber-600">+ {{ formatMoney(allocation.cashDiscountAmount) }} Skonto</p>
</div> </div>
<div> <div>
<p class="text-xs text-gray-500">Text</p> <p class="text-xs text-gray-500">Text</p>
@@ -636,6 +672,7 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
:disabled="mode === 'show'" :disabled="mode === 'show'"
:filter-fields="['name', 'vendorNumber']" :filter-fields="['name', 'vendorNumber']"
:color="itemInfo.vendor ? 'primary' : 'error'" :color="itemInfo.vendor ? 'primary' : 'error'"
@update:model-value="applyVendorCashDiscount"
> >
<template #default> <template #default>
{{ vendors.find(v => v.id === itemInfo.vendor)?.name || 'Bitte wählen' }} {{ vendors.find(v => v.id === itemInfo.vendor)?.name || 'Bitte wählen' }}
@@ -656,7 +693,7 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
v-if="mode !== 'show'" v-if="mode !== 'show'"
type="vendors" type="vendors"
:id="itemInfo.vendor" :id="itemInfo.vendor"
@return-data="(data) => itemInfo.vendor = data.id" @return-data="(data) => { itemInfo.vendor = data.id; applyVendorCashDiscount(data.id) }"
/> />
</div> </div>
</UFormField> </UFormField>
@@ -735,6 +772,47 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
</UPopover> </UPopover>
</UFormField> </UFormField>
<UFormField
label="Skonto"
:help="cashDiscountAmount ? `Abzug ${formatMoney(cashDiscountAmount)} bei Zahlung in der Frist` : undefined"
>
<UInput
type="number"
step="0.01"
min="0"
max="10"
placeholder="z. B. 2"
v-model="itemInfo.cashDiscountPercent"
:disabled="mode === 'show'"
class="w-full"
>
<template #trailing><span class="text-gray-500 text-xs">%</span></template>
</UInput>
</UFormField>
<UFormField label="Skonto bis">
<UPopover class="w-full" :content="{ side: 'bottom', align: 'start' }">
<UButton
block
icon="i-heroicons-calendar"
:label="getDateButtonLabel(itemInfo.cashDiscountDueDate, 'Keine Skontofrist')"
:disabled="mode === 'show' || !itemInfo.cashDiscountPercent"
color="neutral"
variant="outline"
class="w-full justify-start"
/>
<template #content>
<div class="p-2">
<UCalendar
:model-value="getCalendarValue(itemInfo.cashDiscountDueDate)"
@update:model-value="setCashDiscountDueDate"
:week-starts-on="1"
/>
</div>
</template>
</UPopover>
</UFormField>
<UFormField label="Beschreibung / Notiz" class="md:col-span-2"> <UFormField label="Beschreibung / Notiz" class="md:col-span-2">
<UTextarea class="w-full" v-model="itemInfo.description" :rows="2" autoresize :disabled="mode === 'show'" /> <UTextarea class="w-full" v-model="itemInfo.description" :rows="2" autoresize :disabled="mode === 'show'" />
</UFormField> </UFormField>

View File

@@ -1,4 +1,5 @@
<script setup> <script setup>
import { sumSettledAllocations } from "~/utils/cashDiscount"
import dayjs from "dayjs" import dayjs from "dayjs"
import {useSum} from "~/composables/useSum.js"; import {useSum} from "~/composables/useSum.js";
@@ -199,8 +200,8 @@ const getInvoiceSum = (invoice) => {
} }
const getPaidAmount = (item) => { const getPaidAmount = (item) => {
let amountPaid = 0 // Gebuchter Skonto zählt zum ausgeglichenen Betrag.
item.statementallocations.forEach(allocation => amountPaid += allocation.amount) const amountPaid = sumSettledAllocations(item.statementallocations || [])
return Number(Math.abs(amountPaid).toFixed(2)) return Number(Math.abs(amountPaid).toFixed(2))
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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