13 Commits

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:23:33 +01:00
64 changed files with 3405 additions and 96 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,7 +30,14 @@ import {
statementAllocationUuidArg,
} from "../statementAllocations"
import { McpContext, McpTool } from "../types"
import { applyOutgoingRowCatalogDefaults } from "../outgoingRowDefaults"
import { analyzeOutgoingDocumentLayout, DEFAULT_CONTENT_BOTTOM_MM } from "../outgoingDocumentLayout"
import { withImages } from "../result"
import { renderOutgoingDocumentPdf } from "../../modules/outgoing-document-pdf.service"
import { assertOutgoingEInvoiceRequirements } from "../../modules/einvoice/outgoingZugferd.service"
import { renderPdfPagesAsPng } from "../../utils/pdfPreview"
import { outgoingStatusTools } from "./outgoingStatus"
import { normalizeCashDiscountDays, normalizeCashDiscountPercent, prepareCashDiscountAllocation } from "../../modules/cash-discount"
export { prepareStatementAllocationInput } from "../statementAllocations"
@@ -241,6 +248,8 @@ const buildOutgoingDocumentPayload = (
for (const field of ["paymentDays"] as const) {
if (args[field] !== undefined) payload[field] = numberArg(args, field)
}
if (args.cashDiscountPercent !== undefined) payload.cashDiscountPercent = normalizeCashDiscountPercent(args.cashDiscountPercent)
if (args.cashDiscountDays !== undefined) payload.cashDiscountDays = normalizeCashDiscountDays(args.cashDiscountDays)
if (args.type !== undefined) payload.type = documentTypeArg(args)
if (args.address !== undefined) payload.address = optionalObjectArg(args, "address")
@@ -426,6 +435,8 @@ const buildIncomingInvoicePayload = (
for (const field of ["state", "reference", "date", "dueDate", "description", "paymentType"] as const) {
if (args[field] !== undefined) payload[field] = stringArg(args, field)
}
if (args.cashDiscountPercent !== undefined) payload.cashDiscountPercent = normalizeCashDiscountPercent(args.cashDiscountPercent)
if (args.cashDiscountDueDate !== undefined) payload.cashDiscountDueDate = stringArg(args, "cashDiscountDueDate")
if (args.vendor !== undefined) payload.vendor = numberArg(args, "vendor")
if (args.document !== undefined) payload.document = numberArg(args, "document")
if (args.expense !== undefined) payload.expense = args.expense === true
@@ -629,6 +640,8 @@ export const accountingTools: McpTool[] = [
deliveryDateEnd: { type: "string" },
deliveryDateType: { type: "string" },
paymentDays: { type: "number" },
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent (0 bis 10). Wird im PDF als Skontohinweis ausgegeben." },
cashDiscountDays: { type: ["number", "null"], description: "Skontofrist in Tagen ab Belegdatum." },
payment_type: { type: "string" },
taxType: {
type: "string",
@@ -650,6 +663,7 @@ export const accountingTools: McpTool[] = [
},
async handler(context, args) {
assertNoManualDocumentNumber(args)
if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) }
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId, true)
payload.state = "Entwurf"
applyOutgoingDocumentTaxType(payload, args, String(payload.type))
@@ -685,6 +699,8 @@ export const accountingTools: McpTool[] = [
deliveryDateEnd: { type: "string" },
deliveryDateType: { type: "string" },
paymentDays: { type: "number" },
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent (0 bis 10). Wird im PDF als Skontohinweis ausgegeben." },
cashDiscountDays: { type: ["number", "null"], description: "Skontofrist in Tagen ab Belegdatum." },
payment_type: { type: "string" },
taxType: {
type: "string",
@@ -718,6 +734,7 @@ export const accountingTools: McpTool[] = [
if (!existing) throw new Error("Ausgangsbeleg nicht gefunden")
if (existing.state !== "Entwurf") throw new Error("Finalisierte Ausgangsbelege können nicht über update geändert werden")
if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) }
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId)
payload.state = "Entwurf"
applyOutgoingDocumentTaxType(payload, args, String(payload.type || existing.type), existing.taxType, existing.rows)
@@ -797,6 +814,69 @@ export const accountingTools: McpTool[] = [
return { document }
},
},
{
name: "accounting.outgoing_documents.preview",
title: "Ausgangsbeleg-Vorschau",
description: "Erzeugt die PDF-Vorschau eines Ausgangsbelegs wie im Belegeditor und liefert die Seiten als Bilder sowie das Seitenlayout (welche Position auf welcher Seite in welcher Höhe in mm landet) mit Warnungen zu übervollen Seiten. FEDEO bricht Positionen nicht automatisch um: Seitenumbrüche sind Positionen mit mode=pagebreak. Mit rows lässt sich eine geänderte Positionsliste testen, ohne zu speichern; anschließend mit accounting.outgoing_documents.update übernehmen.",
requiredPermissions: ["accounting.outgoing_documents.read"],
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "number" },
rows: { ...outgoingRowsSchema, description: "Optional: Positionsliste nur für diese Vorschau, z. B. mit zusätzlichen {\"mode\":\"pagebreak\"}-Einträgen. Wird nicht gespeichert." },
pages: { type: "array", items: { type: "integer", minimum: 1 }, maxItems: 10, description: "Seiten (ab 1), die als Bild geliefert werden. Standard: die ersten 10 Seiten." },
dpi: { type: "integer", minimum: 50, maximum: 150, default: 80, description: "Auflösung der Seitenbilder." },
images: { type: "boolean", default: true, description: "false liefert nur Layout und Warnungen ohne Bilder." },
maxContentBottomMm: { type: "number", minimum: 150, maximum: 297, default: DEFAULT_CONTENT_BOTTOM_MM, description: "Unterkante des nutzbaren Bereichs ab Blattoberkante; darunter beginnt meist die Fußzeile des Briefpapiers." },
includePdf: { type: "boolean", default: false, description: "PDF zusätzlich als Base64 liefern." },
},
additionalProperties: false,
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const dpi = args.dpi === undefined ? 80 : Number(args.dpi)
if (!Number.isSafeInteger(dpi) || dpi < 50 || dpi > 150) throw new Error("dpi muss zwischen 50 und 150 liegen")
const maxContentBottomMm = args.maxContentBottomMm === undefined ? DEFAULT_CONTENT_BOTTOM_MM : Number(args.maxContentBottomMm)
if (!Number.isFinite(maxContentBottomMm) || maxContentBottomMm < 150 || maxContentBottomMm > 297) throw new Error("maxContentBottomMm muss zwischen 150 und 297 liegen")
if (args.pages !== undefined && (!Array.isArray(args.pages) || args.pages.length > 10
|| args.pages.some((page) => !Number.isSafeInteger(page) || (page as number) < 1))) {
throw new Error("pages muss bis zu 10 Seitenzahlen ab 1 enthalten")
}
const [existing] = await context.server.db
.select()
.from(createddocuments)
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId)))
.limit(1)
if (!existing) throw new Error("Ausgangsbeleg nicht gefunden")
const document: Record<string, any> = { ...existing }
if (args.rows !== undefined) {
const payload: Record<string, unknown> = {
rows: normalizeOutgoingDocumentRows(await applyOutgoingRowCatalogDefaults(context, optionalArrayArg(args, "rows") || [])),
}
applyOutgoingDocumentTaxType(payload, {}, existing.type, existing.taxType, existing.rows)
document.rows = payload.rows
}
const { pdf, layout } = await renderOutgoingDocumentPdf(context.server, context.tenantId, document)
const analysis = analyzeOutgoingDocumentLayout(layout, maxContentBottomMm)
const requestedPages = (args.pages as number[] | undefined) || Array.from({ length: Math.min(layout.pageCount, 10) }, (_, index) => index + 1)
const pages = [...new Set(requestedPages)].filter((page) => page <= layout.pageCount)
const images = args.images === false ? [] : await renderPdfPagesAsPng(pdf, pages, dpi)
return withImages({
document: { id: existing.id, type: existing.type, state: existing.state, documentNumber: existing.documentNumber, title: existing.title },
previewOnly: args.rows !== undefined,
pageCount: layout.pageCount,
imagePages: images.map((image) => image.page),
...analysis,
...(args.includePdf === true ? { pdfBase64: pdf.toString("base64") } : {}),
}, images.map((image) => ({ data: image.png.toString("base64"), mimeType: "image/png" })))
},
},
{
name: "accounting.outgoing_documents.finalize",
title: "Ausgangsbeleg finalisieren",
@@ -811,6 +891,8 @@ export const accountingTools: McpTool[] = [
deliveryDate: { type: "string" },
deliveryDateEnd: { type: "string" },
paymentDays: { type: "number" },
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent (0 bis 10). Wird im PDF als Skontohinweis ausgegeben." },
cashDiscountDays: { type: ["number", "null"], description: "Skontofrist in Tagen ab Belegdatum." },
payment_type: { type: "string" },
taxType: {
type: "string",
@@ -836,12 +918,15 @@ export const accountingTools: McpTool[] = [
if (!existing) throw new Error("Ausgangsbeleg nicht gefunden")
if (existing.documentNumber) throw new Error("Ausgangsbeleg wurde bereits finalisiert")
if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) }
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId)
applyOutgoingDocumentTaxType(payload, args, existing.type, existing.taxType, existing.rows)
// Pflichtangaben der E-Rechnung vor der Nummernvergabe durchsetzen
await assertOutgoingEInvoiceRequirements(context.server, context.tenantId, { ...existing, ...payload })
const result = await useNextNumberRangeNumber(context.server, context.tenantId, existing.type)
payload.state = "Gebucht"
payload.documentNumber = result.usedNumber
applyOutgoingDocumentTaxType(payload, args, existing.type, existing.taxType, existing.rows)
const [updated] = await context.server.db
.update(createddocuments)
@@ -1129,6 +1214,8 @@ export const accountingTools: McpTool[] = [
reference: { type: "string" },
date: { type: "string" },
dueDate: { type: "string" },
cashDiscountPercent: { type: ["number", "null"] },
cashDiscountDueDate: { type: ["string", "null"] },
document: { type: "number" },
description: { type: "string" },
paymentType: { type: "string" },
@@ -1246,6 +1333,8 @@ export const accountingTools: McpTool[] = [
reference: { type: "string" },
date: { type: "string" },
dueDate: { type: "string" },
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent laut Rechnung." },
cashDiscountDueDate: { type: ["string", "null"], description: "Letzter Tag der Skontofrist (YYYY-MM-DD)." },
document: { type: "number" },
description: { type: "string" },
paymentType: { type: "string" },
@@ -1279,6 +1368,8 @@ export const accountingTools: McpTool[] = [
reference: { type: "string" },
date: { type: "string" },
dueDate: { type: "string" },
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent laut Rechnung." },
cashDiscountDueDate: { type: ["string", "null"], description: "Letzter Tag der Skontofrist (YYYY-MM-DD)." },
document: { type: "number" },
description: { type: "string" },
paymentType: { type: "string" },
@@ -1542,7 +1633,7 @@ export const accountingTools: McpTool[] = [
{
name: "accounting.statement_allocations.create",
title: "Bankzuweisung erstellen",
description: "Erstellt eine Bankzuweisung oder manuelle Soll/Haben-Buchung. Unterstützt Ausgangs- und Eingangsbelege, Sachkonten, zusätzliche Konten, Debitoren, Kreditoren, Teilbeträge, DATEV-Steuerschlüssel und Abschreibungen.",
description: "Erstellt eine Bankzuweisung oder manuelle Soll/Haben-Buchung. Unterstützt Ausgangs- und Eingangsbelege, Sachkonten, zusätzliche Konten, Debitoren, Kreditoren, Teilbeträge, Skonto, DATEV-Steuerschlüssel und Abschreibungen.",
requiredPermissions: ["accounting.statement_allocations.write"],
inputSchema: {
type: "object",
@@ -1550,6 +1641,7 @@ export const accountingTools: McpTool[] = [
properties: {
bankstatement: { type: "number", description: "Bankumsatz-ID. Weglassen für eine manuelle Soll/Haben-Buchung." },
amount: { type: "number", description: "Zuweisungsbetrag mit Vorzeichen des Bankumsatzes; bei manuellen Buchungen positiv." },
cashDiscountAmount: { type: "number", description: "Skonto (brutto) zu einem Ausgangs- oder Eingangsbeleg, zusätzlich zum gezahlten amount. Nur mit bankstatement; das Vorzeichen wird an amount angeglichen. Wird anteilig nach Steuersätzen auf die Skontokonten verteilt." },
createddocument: { type: "number", description: "Ausgangsbeleg-ID." },
incominginvoice: { type: "number", description: "Eingangsbeleg-ID." },
account: { type: "number", description: "Sachkonto-ID beziehungsweise Soll-Sachkonto." },
@@ -1575,8 +1667,9 @@ export const accountingTools: McpTool[] = [
},
},
async handler(context, args) {
const payload = prepareStatementAllocationInput(args)
await assertStatementAllocationReferences(context, payload)
const prepared = prepareStatementAllocationInput(args)
await assertStatementAllocationReferences(context, prepared)
const payload = await prepareCashDiscountAllocation(context.server.db, context.tenantId, prepared)
const [created] = await context.server.db
.insert(statementallocations)

View File

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

View File

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

View File

@@ -13,6 +13,10 @@ export type McpToolResult = {
content: Array<{
type: "text"
text: string
} | {
type: "image"
data: string
mimeType: string
}>
structuredContent?: Record<string, unknown>
isError?: boolean

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,8 @@ import isSameOrBefore from "dayjs/plugin/isSameOrBefore.js"
import duration from "dayjs/plugin/duration.js";
import timezone from "dayjs/plugin/timezone.js";
import {generateTimesEvaluation} from "../modules/time/evaluation.service";
import {citys, files} from "../../db/schema";
import {citys, createddocuments, files} from "../../db/schema";
import { attachZugferdToPdf, checkOutgoingEInvoice, EInvoiceRequirementsError } from "../modules/einvoice/outgoingZugferd.service";
import {and, eq, isNull, not} from "drizzle-orm";
import {cancelManualGeneration, executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service";
@@ -110,6 +111,8 @@ export default async function functionRoutes(server: FastifyInstance) {
const body = req.body as {
data: any
backgroundPath?: string
// Gespeicherter Beleg, aus dem die eingebettete E-Rechnung (ZUGFeRD) erzeugt wird
createdDocumentId?: number
}
const {type} = req.params as {type:string}
@@ -125,6 +128,17 @@ export default async function functionRoutes(server: FastifyInstance) {
body.data,
body.backgroundPath
)
if (body.createdDocumentId) {
const tenantId = (req as any).user.tenant_id
const [document] = await server.db
.select()
.from(createddocuments)
.where(and(eq(createddocuments.id, Number(body.createdDocumentId)), eq(createddocuments.tenant, tenantId)))
.limit(1)
if (!document) return reply.code(404).send({ error: "Beleg nicht gefunden" })
const result = await attachZugferdToPdf(server, tenantId, document, Buffer.from(pdf.base64, "base64"))
pdf = { ...pdf, base64: result.pdf.toString("base64"), einvoice: result.einvoice, einvoiceIssues: result.issues }
}
} else if(type === "timesheet") {
pdf = await createTimeSheetPDF(
server,
@@ -136,6 +150,9 @@ export default async function functionRoutes(server: FastifyInstance) {
return pdf // Fastify wandelt automatisch in JSON
} catch (err) {
if (err instanceof EInvoiceRequirementsError) {
return reply.code(422).send({ error: err.message, code: err.code, issues: err.issues })
}
console.log(err)
const unsupportedCharacterMessage = getUnsupportedPdfCharacterMessage(err, body.data)
if (unsupportedCharacterMessage) {
@@ -149,6 +166,14 @@ export default async function functionRoutes(server: FastifyInstance) {
}
})
// Prüft die Pflichtangaben der E-Rechnung für den aktuellen (ggf. ungespeicherten) Stand aus dem Editor
server.post("/functions/einvoice/check", async (req, reply) => {
const { document } = (req.body || {}) as { document?: any }
if (!document || typeof document !== "object") return reply.code(400).send({ error: "document fehlt" })
const issues = await checkOutgoingEInvoice(server, (req as any).user.tenant_id, document)
return { issues }
})
server.get("/functions/usenextnumber/:numberrange", async (req, reply) => {
const { numberrange } = req.params as { numberrange: string };
const tenant = (req as any).user.tenant_id

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,8 @@ import { v4 as uuidv4 } from 'uuid';
import {useFunctions} from "~/composables/useFunctions.js";
import EntityModalButtons from "~/components/EntityModalButtons.vue";
import { documentTemplateHandlebars } from "~/utils/handlebars";
import { getTitleSectionMove, moveTitleSection } from "~/utils/documentTitleSections.js";
import { appendCashDiscountText, buildCashDiscountTerms, formatCashDiscountText, getCashDiscountTemplateContext } from "~/utils/cashDiscount";
const dataStore = useDataStore()
const profileStore = useProfileStore()
@@ -56,6 +58,8 @@ const itemInfo = ref({
deliveryDateType: "Lieferdatum",
dateOfPerformance: null,
paymentDays: auth.activeTenantData.standardPaymentDays,
cashDiscountPercent: null,
cashDiscountDays: null,
payment_type: "transfer",
outgoingsepamandate: null,
availableInPortal: false,
@@ -634,7 +638,11 @@ const setupPage = async () => {
if (optionsToImport.deliveryDate) itemInfo.value.deliveryDate = linkedDocument.deliveryDate
if (optionsToImport.deliveryDateEnd) itemInfo.value.deliveryDateEnd = linkedDocument.deliveryDateEnd
if (optionsToImport.documentDate) itemInfo.value.documentDate = linkedDocument.documentDate
if (optionsToImport.paymentDays) itemInfo.value.paymentDays = linkedDocument.paymentDays
if (optionsToImport.paymentDays) {
itemInfo.value.paymentDays = linkedDocument.paymentDays
itemInfo.value.cashDiscountPercent = linkedDocument.cashDiscountPercent ?? null
itemInfo.value.cashDiscountDays = linkedDocument.cashDiscountDays ?? null
}
if (optionsToImport.payment_type) itemInfo.value.payment_type = linkedDocument.payment_type
if (optionsToImport.customSurchargePercentage) itemInfo.value.customSurchargePercentage = linkedDocument.customSurchargePercentage
if (optionsToImport.contactPerson) itemInfo.value.contactPerson = linkedDocument.contactPerson
@@ -662,6 +670,8 @@ const setupPage = async () => {
itemInfo.value.deliveryDateEnd = linkedDocument.deliveryDateEnd
itemInfo.value.documentDate = linkedDocument.documentDate
itemInfo.value.paymentDays = linkedDocument.paymentDays
itemInfo.value.cashDiscountPercent = linkedDocument.cashDiscountPercent ?? null
itemInfo.value.cashDiscountDays = linkedDocument.cashDiscountDays ?? null
itemInfo.value.payment_type = linkedDocument.payment_type
itemInfo.value.customSurchargePercentage = linkedDocument.customSurchargePercentage
itemInfo.value.contactPerson = linkedDocument.contactPerson
@@ -832,6 +842,10 @@ const setCustomerData = async (customerId, loadOnlyAdress = false) => {
itemInfo.value.address.country = customer.infoData.country
if (!loadOnlyAdress && customer.customPaymentDays) itemInfo.value.paymentDays = customer.customPaymentDays
if (!loadOnlyAdress && customer.customCashDiscountPercent) {
itemInfo.value.cashDiscountPercent = customer.customCashDiscountPercent
itemInfo.value.cashDiscountDays = customer.customCashDiscountDays ?? null
}
if (!loadOnlyAdress && customer.custom_payment_type) itemInfo.value.payment_type = customer.custom_payment_type
if (!loadOnlyAdress) {
itemInfo.value.taxType = normalizeTaxTypeValue(customer.customTaxType)
@@ -1017,6 +1031,24 @@ const toggleTitleSection = (rowId) => {
collapsedTitleRowIds.value = nextCollapsedTitleRowIds
}
const moveDocumentTitleSection = (rowId, direction) => {
if (itemInfo.value.type === 'cancellationInvoices') return
const rows = moveTitleSection(itemInfo.value.rows, rowId, direction)
if (rows === itemInfo.value.rows) return
itemInfo.value.rows = rows
setPosNumbers()
}
const getTitleSectionMoveMenuItems = (rowId) => [
{ label: 'Titelbereich nach oben', icon: 'i-heroicons-arrow-up', direction: 'up' },
{ label: 'Titelbereich nach unten', icon: 'i-heroicons-arrow-down', direction: 'down' }
].map(({ direction, ...item }) => ({
...item,
disabled: itemInfo.value.type === 'cancellationInvoices'
|| !getTitleSectionMove(itemInfo.value.rows, rowId, direction),
onSelect: () => moveDocumentTitleSection(rowId, direction)
}))
const hiddenDocumentRowIds = computed(() => {
const hiddenRowIds = new Set()
const collapsedTitleLevels = []
@@ -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(() => {
let errors = []
let errors = [...eInvoiceIssues.value]
if (itemInfo.value.customer === null) errors.push({message: "Es ist kein Kunde ausgewählt", type: "breaking"})
if (itemInfo.value.contact === null) errors.push({message: "Es ist kein Kontakt ausgewählt", type: "info"})
@@ -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(() => {
let totalProductsPurchasePrice = 0
@@ -1555,7 +1644,8 @@ const getDocumentData = async () => {
kundenname: customerData && customerData.name,
zahlungsziel_in_tagen: itemInfo.paymentDays,
zahlungsart: itemInfo.payment_type === "transfer" ? "Überweisung" : "Lastschrift",
diesel_gesamtverbrauch: (itemInfo.agriculture && itemInfo.agriculture.dieselUsageTotal) && itemInfo.agriculture.dieselUsageTotal
diesel_gesamtverbrauch: (itemInfo.agriculture && itemInfo.agriculture.dieselUsageTotal) && itemInfo.agriculture.dieselUsageTotal,
...getCashDiscountTemplateContext(cashDiscountTerms.value)
}
}
@@ -1665,7 +1755,7 @@ const getDocumentData = async () => {
],
title: itemInfo.value.title,
description: itemInfo.value.description,
endText: templateEndText(generateContext(itemInfo.value, contactData)),
endText: appendCashDiscountText(templateEndText(generateContext(itemInfo.value, contactData)), itemInfo.value.endText, cashDiscountTerms.value),
startText: templateStartText(generateContext(itemInfo.value, contactData)),
rows: rows,
totalArray: [
@@ -1720,7 +1810,7 @@ const getDocumentData = async () => {
const showDocument = ref(false)
const uri = ref("")
const generateDocument = async () => {
const generateDocument = async (options = {}) => {
showDocument.value = false
const selectedLetterhead = getSelectedLetterhead()
@@ -1731,7 +1821,7 @@ const generateDocument = async () => {
const path = selectedLetterhead.path
uri.value = await useFunctions().useCreatePDF(await getDocumentData(), path, "createdDocument")
uri.value = await useFunctions().useCreatePDF(await getDocumentData(), path, "createdDocument", options)
/*uri.value = await useNuxtApp().$api("/api/functions/createinvoicepdf",{
method: "POST",
body: {
@@ -1800,6 +1890,8 @@ const saveSerialInvoice = async () => {
project: normalizeEntityId(itemInfo.value.project),
costcentre: itemInfo.value.costcentre,
paymentDays: itemInfo.value.paymentDays,
cashDiscountPercent: itemInfo.value.cashDiscountPercent === "" || itemInfo.value.cashDiscountPercent === null ? null : Number(itemInfo.value.cashDiscountPercent),
cashDiscountDays: itemInfo.value.cashDiscountDays === "" || itemInfo.value.cashDiscountDays === null ? null : Number(itemInfo.value.cashDiscountDays),
payment_type: itemInfo.value.payment_type,
outgoingsepamandate: normalizeEntityId(itemInfo.value.outgoingsepamandate),
deliveryDateType: "Leistungszeitraum",
@@ -1916,6 +2008,8 @@ const saveDocument = async (state, resetup = false) => {
deliveryDate: itemInfo.value.deliveryDate,
deliveryDateEnd: itemInfo.value.deliveryDateEnd,
paymentDays: itemInfo.value.paymentDays,
cashDiscountPercent: itemInfo.value.cashDiscountPercent === "" || itemInfo.value.cashDiscountPercent === null ? null : Number(itemInfo.value.cashDiscountPercent),
cashDiscountDays: itemInfo.value.cashDiscountDays === "" || itemInfo.value.cashDiscountDays === null ? null : Number(itemInfo.value.cashDiscountDays),
payment_type: itemInfo.value.payment_type,
outgoingsepamandate: normalizeEntityId(itemInfo.value.outgoingsepamandate),
deliveryDateType: itemInfo.value.deliveryDateType,
@@ -1977,6 +2071,18 @@ const saveFinalizedCostCentres = async () => {
const selectedTab = ref("0")
const closeDocument = async () => {
// Vor Vorschau und Fertigstellung mit dem aktuellen Stand prüfen, nicht mit dem letzten (entprellten) Ergebnis
await refreshEInvoiceIssues()
const breakingErrors = findDocumentErrors.value.filter(i => i.type === "breaking")
if (breakingErrors.length > 0) {
selectedTab.value = "0"
toast.add({
title: "Beleg kann nicht fertiggestellt werden",
description: breakingErrors.map(i => i.message).join(" · "),
color: "error",
})
return
}
if(selectedTab.value === "0") {
await generateDocument()
@@ -1986,7 +2092,16 @@ const closeDocument = async () => {
await saveDocument("Gebucht")
await uploadGeneratedDocument()
try {
await uploadGeneratedDocument()
} catch (error) {
console.error(error)
toast.add({
title: "PDF bzw. E-Rechnung konnte nicht erstellt werden",
description: error?.data?.error || error?.message,
color: "error",
})
}
guard.value = false
@@ -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
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")
@@ -2671,6 +2793,39 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
class="w-full"
/>
</UFormField>
<UFormField
v-if="['invoices', 'advanceInvoices', 'serialInvoices'].includes(itemInfo.type)"
class="min-w-0 flex-1"
label="Skonto:"
:help="cashDiscountTerms ? formatCashDiscountText(cashDiscountTerms) : undefined"
>
<div class="flex gap-1">
<UInput
type="number"
step="0.01"
min="0"
max="10"
placeholder="%"
v-model="itemInfo.cashDiscountPercent"
class="w-full"
>
<template #trailing>
<span class="text-gray-500 dark:text-gray-400 text-xs">%</span>
</template>
</UInput>
<UInput
type="number"
min="0"
placeholder="Tage"
v-model="itemInfo.cashDiscountDays"
class="w-full"
>
<template #trailing>
<span class="text-gray-500 dark:text-gray-400 text-xs">Tage</span>
</template>
</UInput>
</div>
</UFormField>
<UFormField
class="min-w-0 flex-1"
label="Individueller Aufschlag:"
@@ -3116,7 +3271,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
v-model="itemInfo.rows"
handle=".handle"
tag="tbody"
itemKey="pos"
itemKey="id"
@end="setPosNumbers"
>
<template #item="{element: row}">
@@ -3141,6 +3296,21 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
@click="toggleTitleSection(row.id)"
/>
</UTooltip>
<UDropdownMenu
v-if="row.mode === 'title' && itemInfo.type !== 'cancellationInvoices'"
:items="getTitleSectionMoveMenuItems(row.id)"
:content="{ align: 'start' }"
>
<UTooltip text="Titel mit untergeordneten Zeilen verschieben">
<UButton
class="shrink-0"
variant="ghost"
color="neutral"
icon="i-heroicons-arrows-up-down"
aria-label="Titel mit untergeordneten Zeilen verschieben"
/>
</UTooltip>
</UDropdownMenu>
</div>
</td>
<td

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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