Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 468f8b51f6 | |||
| e9697ef197 | |||
| 549b469a41 | |||
| bd768e404d | |||
| af4a283eb5 | |||
| 28f24aa998 | |||
| 05b0c12841 | |||
| c46c50a2a3 | |||
| 72ff7addaa | |||
| d4c4a2b9c1 | |||
| 6ae8b7ad9e | |||
| 544d607088 | |||
| c10bfac23b |
5
backend/db/migrations/0084_cash_discounts.sql
Normal file
5
backend/db/migrations/0084_cash_discounts.sql
Normal 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;
|
||||||
15
backend/db/migrations/0085_cash_discount_terms.sql
Normal file
15
backend/db/migrations/0085_cash_discount_terms.sql
Normal 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;
|
||||||
@@ -568,6 +568,20 @@
|
|||||||
"when": 1792022400001,
|
"when": 1792022400001,
|
||||||
"tag": "0083_collaboration_notifications",
|
"tag": "0083_collaboration_notifications",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 81,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1792108800001,
|
||||||
|
"tag": "0084_cash_discounts",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 82,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1792195200001,
|
||||||
|
"tag": "0085_cash_discount_terms",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ export const createddocuments = pgTable("createddocuments", {
|
|||||||
|
|
||||||
deliveryDateType: text("deliveryDateType"),
|
deliveryDateType: text("deliveryDateType"),
|
||||||
paymentDays: smallint("paymentDays"),
|
paymentDays: smallint("paymentDays"),
|
||||||
|
cashDiscountPercent: doublePrecision("cashDiscountPercent"),
|
||||||
|
cashDiscountDays: smallint("cashDiscountDays"),
|
||||||
deliveryDate: text("deliveryDate"),
|
deliveryDate: text("deliveryDate"),
|
||||||
|
|
||||||
contactPerson: uuid("contactPerson"),
|
contactPerson: uuid("contactPerson"),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
jsonb,
|
jsonb,
|
||||||
smallint,
|
smallint,
|
||||||
uuid,
|
uuid,
|
||||||
|
doublePrecision,
|
||||||
} from "drizzle-orm/pg-core"
|
} from "drizzle-orm/pg-core"
|
||||||
import { tenants } from "./tenants"
|
import { tenants } from "./tenants"
|
||||||
import { authUsers } from "./auth_users"
|
import { authUsers } from "./auth_users"
|
||||||
@@ -41,6 +42,8 @@ export const customers = pgTable(
|
|||||||
profiles: jsonb("profiles").notNull().default([]),
|
profiles: jsonb("profiles").notNull().default([]),
|
||||||
|
|
||||||
customPaymentDays: smallint("customPaymentDays"),
|
customPaymentDays: smallint("customPaymentDays"),
|
||||||
|
customCashDiscountPercent: doublePrecision("customCashDiscountPercent"),
|
||||||
|
customCashDiscountDays: smallint("customCashDiscountDays"),
|
||||||
|
|
||||||
firstname: text("firstname"),
|
firstname: text("firstname"),
|
||||||
lastname: text("lastname"),
|
lastname: text("lastname"),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
jsonb,
|
jsonb,
|
||||||
numeric,
|
numeric,
|
||||||
uuid,
|
uuid,
|
||||||
|
doublePrecision,
|
||||||
} from "drizzle-orm/pg-core"
|
} from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
import { vendors } from "./vendors"
|
import { vendors } from "./vendors"
|
||||||
@@ -36,6 +37,8 @@ export const incominginvoices = pgTable("incominginvoices", {
|
|||||||
document: bigint("document", { mode: "number" }),
|
document: bigint("document", { mode: "number" }),
|
||||||
|
|
||||||
dueDate: text("dueDate"),
|
dueDate: text("dueDate"),
|
||||||
|
cashDiscountPercent: doublePrecision("cashDiscountPercent"),
|
||||||
|
cashDiscountDueDate: text("cashDiscountDueDate"),
|
||||||
|
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
timestamp,
|
timestamp,
|
||||||
boolean,
|
boolean,
|
||||||
doublePrecision,
|
doublePrecision,
|
||||||
|
jsonb,
|
||||||
} from "drizzle-orm/pg-core"
|
} from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
import { tenants } from "./tenants"
|
import { tenants } from "./tenants"
|
||||||
@@ -30,6 +31,10 @@ export const statementallocations = pgTable("statementallocations", {
|
|||||||
|
|
||||||
amount: doublePrecision("amount").notNull().default(0),
|
amount: doublePrecision("amount").notNull().default(0),
|
||||||
|
|
||||||
|
// Skonto mit dem Vorzeichen der Zahlung; amount bleibt der tatsächlich gezahlte Betrag.
|
||||||
|
cashDiscountAmount: doublePrecision("cash_discount_amount"),
|
||||||
|
cashDiscountSplit: jsonb("cash_discount_split"),
|
||||||
|
|
||||||
incominginvoice: bigint("ii_id", { mode: "number" }).references(
|
incominginvoice: bigint("ii_id", { mode: "number" }).references(
|
||||||
() => incominginvoices.id
|
() => incominginvoices.id
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -154,6 +154,8 @@ export const tenants = pgTable(
|
|||||||
}),
|
}),
|
||||||
accountChart: text("accountChart").notNull().default("skr03"),
|
accountChart: text("accountChart").notNull().default("skr03"),
|
||||||
|
|
||||||
|
cashDiscountAccounts: jsonb("cashDiscountAccounts").notNull().default({}),
|
||||||
|
|
||||||
standardEmailForInvoices: text("standardEmailForInvoices"),
|
standardEmailForInvoices: text("standardEmailForInvoices"),
|
||||||
|
|
||||||
extraModules: jsonb("extraModules").notNull().default([]),
|
extraModules: jsonb("extraModules").notNull().default([]),
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
boolean,
|
boolean,
|
||||||
jsonb,
|
jsonb,
|
||||||
uuid,
|
uuid,
|
||||||
|
doublePrecision,
|
||||||
|
smallint,
|
||||||
} from "drizzle-orm/pg-core"
|
} from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
import { tenants } from "./tenants"
|
import { tenants } from "./tenants"
|
||||||
@@ -36,6 +38,8 @@ export const vendors = pgTable("vendors", {
|
|||||||
archived: boolean("archived").notNull().default(false),
|
archived: boolean("archived").notNull().default(false),
|
||||||
|
|
||||||
defaultPaymentMethod: text("defaultPaymentMethod"),
|
defaultPaymentMethod: text("defaultPaymentMethod"),
|
||||||
|
cashDiscountPercent: doublePrecision("cashDiscountPercent"),
|
||||||
|
cashDiscountDays: smallint("cashDiscountDays"),
|
||||||
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||||
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"fill": "ts-node src/webdav/fill-file-sizes.ts",
|
"fill": "ts-node src/webdav/fill-file-sizes.ts",
|
||||||
"dev:dav": "tsx watch src/webdav/server.ts",
|
"dev:dav": "tsx watch src/webdav/server.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"test:einvoice": "tsx --test tests/einvoice.test.ts",
|
"test:einvoice": "tsx --test tests/einvoice.test.ts tests/zugferdInvoice.test.ts",
|
||||||
"start": "node dist/src/index.js",
|
"start": "node dist/src/index.js",
|
||||||
"schema:index": "ts-node scripts/generate-schema-index.ts",
|
"schema:index": "ts-node scripts/generate-schema-index.ts",
|
||||||
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
|
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export const outgoingRowsSchema = {
|
|||||||
pos: { type: "string", description: "Positionsnummer, z. B. 1.1; wird sonst erzeugt." },
|
pos: { type: "string", description: "Positionsnummer, z. B. 1.1; wird sonst erzeugt." },
|
||||||
mode: { type: "string", enum: ["free", "normal", "service", "title", "text", "pagebreak"], default: "free" },
|
mode: { type: "string", enum: ["free", "normal", "service", "title", "text", "pagebreak"], default: "free" },
|
||||||
text: { type: "string" },
|
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." },
|
product: { type: "integer", minimum: 1, description: "Artikel-ID bei mode=normal." },
|
||||||
service: { type: "integer", minimum: 1, description: "Leistungs-ID bei mode=service." },
|
service: { type: "integer", minimum: 1, description: "Leistungs-ID bei mode=service." },
|
||||||
quantity: { type: "number", default: 1 },
|
quantity: { type: "number", default: 1 },
|
||||||
|
|||||||
95
backend/src/mcp/outgoingDocumentLayout.ts
Normal file
95
backend/src/mcp/outgoingDocumentLayout.ts
Normal 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.",
|
||||||
|
}
|
||||||
|
}
|
||||||
62
backend/src/mcp/outgoingRowDefaults.ts
Normal file
62
backend/src/mcp/outgoingRowDefaults.ts
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,6 +2,12 @@ import { McpToolResult } from "./types"
|
|||||||
|
|
||||||
const OMIT_ARCHIVED = Symbol("omit-archived")
|
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 {
|
function omitArchivedRecords(value: unknown): unknown | typeof OMIT_ARCHIVED {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value
|
return value
|
||||||
@@ -32,12 +38,15 @@ export function asToolResult(payload: unknown): McpToolResult {
|
|||||||
? resultPayload as Record<string, unknown>
|
? resultPayload as Record<string, unknown>
|
||||||
: { result: resultPayload }
|
: { result: resultPayload }
|
||||||
|
|
||||||
|
const images: McpImage[] = (payload as any)?.[MCP_IMAGES] || []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify(resultPayload, null, 2),
|
text: JSON.stringify(resultPayload, null, 2),
|
||||||
},
|
},
|
||||||
|
...images.map((image) => ({ type: "image" as const, data: image.data, mimeType: image.mimeType })),
|
||||||
],
|
],
|
||||||
structuredContent,
|
structuredContent,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ export const prepareStatementAllocationInput = (args: Record<string, unknown>) =
|
|||||||
if (!bookingModes.has(bookingMode)) throw new Error(`Ungültige Aufwandsart: ${bookingMode}`)
|
if (!bookingModes.has(bookingMode)) throw new Error(`Ungültige Aufwandsart: ${bookingMode}`)
|
||||||
|
|
||||||
payload.amount = amount
|
payload.amount = amount
|
||||||
|
if (hasValue(args.cashDiscountAmount)) {
|
||||||
|
const cashDiscountAmount = Number(args.cashDiscountAmount)
|
||||||
|
if (!Number.isFinite(cashDiscountAmount)) throw new Error("cashDiscountAmount muss eine gültige Zahl sein")
|
||||||
|
if (cashDiscountAmount !== 0) {
|
||||||
|
if (!hasValue(args.bankstatement)) throw new Error("Skonto ist nur zusammen mit einer Bankzahlung möglich")
|
||||||
|
payload.cashDiscountAmount = cashDiscountAmount
|
||||||
|
}
|
||||||
|
}
|
||||||
if (args.description !== undefined) payload.description = stringArg(args, "description")
|
if (args.description !== undefined) payload.description = stringArg(args, "description")
|
||||||
payload.bookingMode = bookingMode
|
payload.bookingMode = bookingMode
|
||||||
if (args.datevTaxKey !== undefined) payload.datevTaxKey = stringArg(args, "datevTaxKey")
|
if (args.datevTaxKey !== undefined) payload.datevTaxKey = stringArg(args, "datevTaxKey")
|
||||||
|
|||||||
@@ -30,7 +30,14 @@ import {
|
|||||||
statementAllocationUuidArg,
|
statementAllocationUuidArg,
|
||||||
} from "../statementAllocations"
|
} from "../statementAllocations"
|
||||||
import { McpContext, McpTool } from "../types"
|
import { McpContext, McpTool } from "../types"
|
||||||
|
import { applyOutgoingRowCatalogDefaults } from "../outgoingRowDefaults"
|
||||||
|
import { analyzeOutgoingDocumentLayout, DEFAULT_CONTENT_BOTTOM_MM } from "../outgoingDocumentLayout"
|
||||||
|
import { withImages } from "../result"
|
||||||
|
import { renderOutgoingDocumentPdf } from "../../modules/outgoing-document-pdf.service"
|
||||||
|
import { assertOutgoingEInvoiceRequirements } from "../../modules/einvoice/outgoingZugferd.service"
|
||||||
|
import { renderPdfPagesAsPng } from "../../utils/pdfPreview"
|
||||||
import { outgoingStatusTools } from "./outgoingStatus"
|
import { outgoingStatusTools } from "./outgoingStatus"
|
||||||
|
import { normalizeCashDiscountDays, normalizeCashDiscountPercent, prepareCashDiscountAllocation } from "../../modules/cash-discount"
|
||||||
|
|
||||||
export { prepareStatementAllocationInput } from "../statementAllocations"
|
export { prepareStatementAllocationInput } from "../statementAllocations"
|
||||||
|
|
||||||
@@ -241,6 +248,8 @@ const buildOutgoingDocumentPayload = (
|
|||||||
for (const field of ["paymentDays"] as const) {
|
for (const field of ["paymentDays"] as const) {
|
||||||
if (args[field] !== undefined) payload[field] = numberArg(args, field)
|
if (args[field] !== undefined) payload[field] = numberArg(args, field)
|
||||||
}
|
}
|
||||||
|
if (args.cashDiscountPercent !== undefined) payload.cashDiscountPercent = normalizeCashDiscountPercent(args.cashDiscountPercent)
|
||||||
|
if (args.cashDiscountDays !== undefined) payload.cashDiscountDays = normalizeCashDiscountDays(args.cashDiscountDays)
|
||||||
|
|
||||||
if (args.type !== undefined) payload.type = documentTypeArg(args)
|
if (args.type !== undefined) payload.type = documentTypeArg(args)
|
||||||
if (args.address !== undefined) payload.address = optionalObjectArg(args, "address")
|
if (args.address !== undefined) payload.address = optionalObjectArg(args, "address")
|
||||||
@@ -426,6 +435,8 @@ const buildIncomingInvoicePayload = (
|
|||||||
for (const field of ["state", "reference", "date", "dueDate", "description", "paymentType"] as const) {
|
for (const field of ["state", "reference", "date", "dueDate", "description", "paymentType"] as const) {
|
||||||
if (args[field] !== undefined) payload[field] = stringArg(args, field)
|
if (args[field] !== undefined) payload[field] = stringArg(args, field)
|
||||||
}
|
}
|
||||||
|
if (args.cashDiscountPercent !== undefined) payload.cashDiscountPercent = normalizeCashDiscountPercent(args.cashDiscountPercent)
|
||||||
|
if (args.cashDiscountDueDate !== undefined) payload.cashDiscountDueDate = stringArg(args, "cashDiscountDueDate")
|
||||||
if (args.vendor !== undefined) payload.vendor = numberArg(args, "vendor")
|
if (args.vendor !== undefined) payload.vendor = numberArg(args, "vendor")
|
||||||
if (args.document !== undefined) payload.document = numberArg(args, "document")
|
if (args.document !== undefined) payload.document = numberArg(args, "document")
|
||||||
if (args.expense !== undefined) payload.expense = args.expense === true
|
if (args.expense !== undefined) payload.expense = args.expense === true
|
||||||
@@ -629,6 +640,8 @@ export const accountingTools: McpTool[] = [
|
|||||||
deliveryDateEnd: { type: "string" },
|
deliveryDateEnd: { type: "string" },
|
||||||
deliveryDateType: { type: "string" },
|
deliveryDateType: { type: "string" },
|
||||||
paymentDays: { type: "number" },
|
paymentDays: { type: "number" },
|
||||||
|
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent (0 bis 10). Wird im PDF als Skontohinweis ausgegeben." },
|
||||||
|
cashDiscountDays: { type: ["number", "null"], description: "Skontofrist in Tagen ab Belegdatum." },
|
||||||
payment_type: { type: "string" },
|
payment_type: { type: "string" },
|
||||||
taxType: {
|
taxType: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -650,6 +663,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
assertNoManualDocumentNumber(args)
|
assertNoManualDocumentNumber(args)
|
||||||
|
if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) }
|
||||||
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId, true)
|
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId, true)
|
||||||
payload.state = "Entwurf"
|
payload.state = "Entwurf"
|
||||||
applyOutgoingDocumentTaxType(payload, args, String(payload.type))
|
applyOutgoingDocumentTaxType(payload, args, String(payload.type))
|
||||||
@@ -685,6 +699,8 @@ export const accountingTools: McpTool[] = [
|
|||||||
deliveryDateEnd: { type: "string" },
|
deliveryDateEnd: { type: "string" },
|
||||||
deliveryDateType: { type: "string" },
|
deliveryDateType: { type: "string" },
|
||||||
paymentDays: { type: "number" },
|
paymentDays: { type: "number" },
|
||||||
|
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent (0 bis 10). Wird im PDF als Skontohinweis ausgegeben." },
|
||||||
|
cashDiscountDays: { type: ["number", "null"], description: "Skontofrist in Tagen ab Belegdatum." },
|
||||||
payment_type: { type: "string" },
|
payment_type: { type: "string" },
|
||||||
taxType: {
|
taxType: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -718,6 +734,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
if (!existing) throw new Error("Ausgangsbeleg nicht gefunden")
|
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 (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)
|
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId)
|
||||||
payload.state = "Entwurf"
|
payload.state = "Entwurf"
|
||||||
applyOutgoingDocumentTaxType(payload, args, String(payload.type || existing.type), existing.taxType, existing.rows)
|
applyOutgoingDocumentTaxType(payload, args, String(payload.type || existing.type), existing.taxType, existing.rows)
|
||||||
@@ -797,6 +814,69 @@ export const accountingTools: McpTool[] = [
|
|||||||
return { document }
|
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",
|
name: "accounting.outgoing_documents.finalize",
|
||||||
title: "Ausgangsbeleg finalisieren",
|
title: "Ausgangsbeleg finalisieren",
|
||||||
@@ -811,6 +891,8 @@ export const accountingTools: McpTool[] = [
|
|||||||
deliveryDate: { type: "string" },
|
deliveryDate: { type: "string" },
|
||||||
deliveryDateEnd: { type: "string" },
|
deliveryDateEnd: { type: "string" },
|
||||||
paymentDays: { type: "number" },
|
paymentDays: { type: "number" },
|
||||||
|
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent (0 bis 10). Wird im PDF als Skontohinweis ausgegeben." },
|
||||||
|
cashDiscountDays: { type: ["number", "null"], description: "Skontofrist in Tagen ab Belegdatum." },
|
||||||
payment_type: { type: "string" },
|
payment_type: { type: "string" },
|
||||||
taxType: {
|
taxType: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -836,12 +918,15 @@ export const accountingTools: McpTool[] = [
|
|||||||
if (!existing) throw new Error("Ausgangsbeleg nicht gefunden")
|
if (!existing) throw new Error("Ausgangsbeleg nicht gefunden")
|
||||||
if (existing.documentNumber) throw new Error("Ausgangsbeleg wurde bereits finalisiert")
|
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)
|
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId)
|
||||||
|
applyOutgoingDocumentTaxType(payload, args, existing.type, existing.taxType, existing.rows)
|
||||||
|
// Pflichtangaben der E-Rechnung vor der Nummernvergabe durchsetzen
|
||||||
|
await assertOutgoingEInvoiceRequirements(context.server, context.tenantId, { ...existing, ...payload })
|
||||||
const result = await useNextNumberRangeNumber(context.server, context.tenantId, existing.type)
|
const result = await useNextNumberRangeNumber(context.server, context.tenantId, existing.type)
|
||||||
|
|
||||||
payload.state = "Gebucht"
|
payload.state = "Gebucht"
|
||||||
payload.documentNumber = result.usedNumber
|
payload.documentNumber = result.usedNumber
|
||||||
applyOutgoingDocumentTaxType(payload, args, existing.type, existing.taxType, existing.rows)
|
|
||||||
|
|
||||||
const [updated] = await context.server.db
|
const [updated] = await context.server.db
|
||||||
.update(createddocuments)
|
.update(createddocuments)
|
||||||
@@ -1129,6 +1214,8 @@ export const accountingTools: McpTool[] = [
|
|||||||
reference: { type: "string" },
|
reference: { type: "string" },
|
||||||
date: { type: "string" },
|
date: { type: "string" },
|
||||||
dueDate: { type: "string" },
|
dueDate: { type: "string" },
|
||||||
|
cashDiscountPercent: { type: ["number", "null"] },
|
||||||
|
cashDiscountDueDate: { type: ["string", "null"] },
|
||||||
document: { type: "number" },
|
document: { type: "number" },
|
||||||
description: { type: "string" },
|
description: { type: "string" },
|
||||||
paymentType: { type: "string" },
|
paymentType: { type: "string" },
|
||||||
@@ -1246,6 +1333,8 @@ export const accountingTools: McpTool[] = [
|
|||||||
reference: { type: "string" },
|
reference: { type: "string" },
|
||||||
date: { type: "string" },
|
date: { type: "string" },
|
||||||
dueDate: { type: "string" },
|
dueDate: { type: "string" },
|
||||||
|
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent laut Rechnung." },
|
||||||
|
cashDiscountDueDate: { type: ["string", "null"], description: "Letzter Tag der Skontofrist (YYYY-MM-DD)." },
|
||||||
document: { type: "number" },
|
document: { type: "number" },
|
||||||
description: { type: "string" },
|
description: { type: "string" },
|
||||||
paymentType: { type: "string" },
|
paymentType: { type: "string" },
|
||||||
@@ -1279,6 +1368,8 @@ export const accountingTools: McpTool[] = [
|
|||||||
reference: { type: "string" },
|
reference: { type: "string" },
|
||||||
date: { type: "string" },
|
date: { type: "string" },
|
||||||
dueDate: { type: "string" },
|
dueDate: { type: "string" },
|
||||||
|
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent laut Rechnung." },
|
||||||
|
cashDiscountDueDate: { type: ["string", "null"], description: "Letzter Tag der Skontofrist (YYYY-MM-DD)." },
|
||||||
document: { type: "number" },
|
document: { type: "number" },
|
||||||
description: { type: "string" },
|
description: { type: "string" },
|
||||||
paymentType: { type: "string" },
|
paymentType: { type: "string" },
|
||||||
@@ -1542,7 +1633,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
{
|
{
|
||||||
name: "accounting.statement_allocations.create",
|
name: "accounting.statement_allocations.create",
|
||||||
title: "Bankzuweisung erstellen",
|
title: "Bankzuweisung erstellen",
|
||||||
description: "Erstellt eine Bankzuweisung oder manuelle Soll/Haben-Buchung. Unterstützt Ausgangs- und Eingangsbelege, Sachkonten, zusätzliche Konten, Debitoren, Kreditoren, Teilbeträge, DATEV-Steuerschlüssel und Abschreibungen.",
|
description: "Erstellt eine Bankzuweisung oder manuelle Soll/Haben-Buchung. Unterstützt Ausgangs- und Eingangsbelege, Sachkonten, zusätzliche Konten, Debitoren, Kreditoren, Teilbeträge, Skonto, DATEV-Steuerschlüssel und Abschreibungen.",
|
||||||
requiredPermissions: ["accounting.statement_allocations.write"],
|
requiredPermissions: ["accounting.statement_allocations.write"],
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -1550,6 +1641,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
properties: {
|
properties: {
|
||||||
bankstatement: { type: "number", description: "Bankumsatz-ID. Weglassen für eine manuelle Soll/Haben-Buchung." },
|
bankstatement: { type: "number", description: "Bankumsatz-ID. Weglassen für eine manuelle Soll/Haben-Buchung." },
|
||||||
amount: { type: "number", description: "Zuweisungsbetrag mit Vorzeichen des Bankumsatzes; bei manuellen Buchungen positiv." },
|
amount: { type: "number", description: "Zuweisungsbetrag mit Vorzeichen des Bankumsatzes; bei manuellen Buchungen positiv." },
|
||||||
|
cashDiscountAmount: { type: "number", description: "Skonto (brutto) zu einem Ausgangs- oder Eingangsbeleg, zusätzlich zum gezahlten amount. Nur mit bankstatement; das Vorzeichen wird an amount angeglichen. Wird anteilig nach Steuersätzen auf die Skontokonten verteilt." },
|
||||||
createddocument: { type: "number", description: "Ausgangsbeleg-ID." },
|
createddocument: { type: "number", description: "Ausgangsbeleg-ID." },
|
||||||
incominginvoice: { type: "number", description: "Eingangsbeleg-ID." },
|
incominginvoice: { type: "number", description: "Eingangsbeleg-ID." },
|
||||||
account: { type: "number", description: "Sachkonto-ID beziehungsweise Soll-Sachkonto." },
|
account: { type: "number", description: "Sachkonto-ID beziehungsweise Soll-Sachkonto." },
|
||||||
@@ -1575,8 +1667,9 @@ export const accountingTools: McpTool[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
const payload = prepareStatementAllocationInput(args)
|
const prepared = prepareStatementAllocationInput(args)
|
||||||
await assertStatementAllocationReferences(context, payload)
|
await assertStatementAllocationReferences(context, prepared)
|
||||||
|
const payload = await prepareCashDiscountAllocation(context.server.db, context.tenantId, prepared)
|
||||||
|
|
||||||
const [created] = await context.server.db
|
const [created] = await context.server.db
|
||||||
.insert(statementallocations)
|
.insert(statementallocations)
|
||||||
|
|||||||
@@ -46,14 +46,14 @@ export const masterdataWriteConfigs: Config[] = [
|
|||||||
makeConfig({
|
makeConfig({
|
||||||
resource: "customers", namespace: "customers", label: "Kunde", resultKey: "customer", table: tables.customers,
|
resource: "customers", namespace: "customers", label: "Kunde", resultKey: "customer", table: tables.customers,
|
||||||
required: ["name"], numberField: "customerNumber",
|
required: ["name"], numberField: "customerNumber",
|
||||||
fieldNames: "name customerNumber infoData active notes type isCompany profiles customPaymentDays firstname lastname customSurchargePercentage salutation title nameAddition availableInPortal customPaymentType customTaxType memberrelation",
|
fieldNames: "name customerNumber infoData active notes type isCompany profiles customPaymentDays firstname lastname customSurchargePercentage salutation title nameAddition availableInPortal customPaymentType customTaxType memberrelation customCashDiscountPercent customCashDiscountDays",
|
||||||
overrides: { customerNumber: textField(true), infoData: infoDataField, profiles: profilesField, memberrelation: idField },
|
overrides: { customerNumber: textField(true), infoData: infoDataField, profiles: profilesField, memberrelation: idField },
|
||||||
references: [commonProfiles, bankAccounts, { field: "memberrelation", table: tables.memberrelations }],
|
references: [commonProfiles, bankAccounts, { field: "memberrelation", table: tables.memberrelations }],
|
||||||
}),
|
}),
|
||||||
makeConfig({
|
makeConfig({
|
||||||
resource: "vendors", namespace: "vendors", label: "Lieferant", resultKey: "vendor", table: tables.vendors,
|
resource: "vendors", namespace: "vendors", label: "Lieferant", resultKey: "vendor", table: tables.vendors,
|
||||||
required: ["name"], numberField: "vendorNumber",
|
required: ["name"], numberField: "vendorNumber",
|
||||||
fieldNames: "name vendorNumber infoData notes hasSEPA profiles defaultPaymentMethod",
|
fieldNames: "name vendorNumber infoData notes hasSEPA profiles defaultPaymentMethod cashDiscountPercent cashDiscountDays",
|
||||||
overrides: { vendorNumber: textField(true), infoData: infoDataField, profiles: profilesField },
|
overrides: { vendorNumber: textField(true), infoData: infoDataField, profiles: profilesField },
|
||||||
references: [commonProfiles, bankAccounts],
|
references: [commonProfiles, bankAccounts],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -41,10 +41,10 @@ function documentAmount(document: any, advanceInvoices: any[]) {
|
|||||||
return money(money(net) + money(tax19) + money(tax7) - advanceAmount)
|
return money(money(net) + money(tax19) + money(tax7) - advanceAmount)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function outgoingDocumentStatus(document: any, paidAmount: number, cancellations: any[], advanceInvoices: any[]) {
|
export function outgoingDocumentStatus(document: any, paidAmount: number, cancellations: any[], advanceInvoices: any[], cashDiscountAmount = 0) {
|
||||||
const amount = documentAmount(document, advanceInvoices)
|
const amount = documentAmount(document, advanceInvoices)
|
||||||
if (![amount, paidAmount].every(Number.isFinite)) throw new Error(`Beleg ${document.id}: Betrag kann nicht berechnet werden`)
|
if (![amount, paidAmount, cashDiscountAmount].every(Number.isFinite)) throw new Error(`Beleg ${document.id}: Betrag kann nicht berechnet werden`)
|
||||||
const openAmount = money(amount - paidAmount)
|
const openAmount = money(amount - paidAmount - cashDiscountAmount)
|
||||||
const cancelled = cancellations.length > 0
|
const cancelled = cancellations.length > 0
|
||||||
const applicable = document.state === "Gebucht" && ["invoices", "advanceInvoices"].includes(document.type) && !cancelled
|
const applicable = document.state === "Gebucht" && ["invoices", "advanceInvoices"].includes(document.type) && !cancelled
|
||||||
// Signed invoices and refunds use the same payment-direction comparison.
|
// Signed invoices and refunds use the same payment-direction comparison.
|
||||||
@@ -55,7 +55,7 @@ export function outgoingDocumentStatus(document: any, paidAmount: number, cancel
|
|||||||
id: document.id, type: document.type, documentNumber: document.documentNumber, documentDate: document.documentDate,
|
id: document.id, type: document.type, documentNumber: document.documentNumber, documentDate: document.documentDate,
|
||||||
state: document.state, customer: document.customer, project: document.project,
|
state: document.state, customer: document.customer, project: document.project,
|
||||||
cancelled, cancellationDocuments: cancellations.map(({ id, documentNumber }) => ({ id, documentNumber })),
|
cancelled, cancellationDocuments: cancellations.map(({ id, documentNumber }) => ({ id, documentNumber })),
|
||||||
paymentStatus, amount, paidAmount: money(paidAmount), openAmount,
|
paymentStatus, amount, paidAmount: money(paidAmount), cashDiscountAmount: money(cashDiscountAmount), openAmount,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ async function statusesFor(context: McpContext, documents: Array<typeof createdd
|
|||||||
const advanceIds = [...new Set(documents.flatMap(document => Array.isArray(document.usedAdvanceInvoices)
|
const advanceIds = [...new Set(documents.flatMap(document => Array.isArray(document.usedAdvanceInvoices)
|
||||||
? document.usedAdvanceInvoices.filter(id => typeof id === "number" && Number.isSafeInteger(id) && id > 0) as number[] : []))]
|
? document.usedAdvanceInvoices.filter(id => typeof id === "number" && Number.isSafeInteger(id) && id > 0) as number[] : []))]
|
||||||
const [allocations, cancellations, advances] = await Promise.all([
|
const [allocations, cancellations, advances] = await Promise.all([
|
||||||
context.server.db.select({ createddocument: statementallocations.createddocument, amount: statementallocations.amount }).from(statementallocations)
|
context.server.db.select({ createddocument: statementallocations.createddocument, amount: statementallocations.amount, cashDiscountAmount: statementallocations.cashDiscountAmount }).from(statementallocations)
|
||||||
.where(and(eq(statementallocations.tenant, context.tenantId), eq(statementallocations.archived, false), inArray(statementallocations.createddocument, ids))),
|
.where(and(eq(statementallocations.tenant, context.tenantId), eq(statementallocations.archived, false), inArray(statementallocations.createddocument, ids))),
|
||||||
context.server.db.select({ id: createddocuments.id, createddocument: createddocuments.createddocument, documentNumber: createddocuments.documentNumber }).from(createddocuments)
|
context.server.db.select({ id: createddocuments.id, createddocument: createddocuments.createddocument, documentNumber: createddocuments.documentNumber }).from(createddocuments)
|
||||||
.where(and(eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false), eq(createddocuments.type, "cancellationInvoices"),
|
.where(and(eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false), eq(createddocuments.type, "cancellationInvoices"),
|
||||||
@@ -73,15 +73,19 @@ async function statusesFor(context: McpContext, documents: Array<typeof createdd
|
|||||||
advanceIds.length ? context.server.db.select().from(createddocuments)
|
advanceIds.length ? context.server.db.select().from(createddocuments)
|
||||||
.where(and(eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false), inArray(createddocuments.id, advanceIds))) : [],
|
.where(and(eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false), inArray(createddocuments.id, advanceIds))) : [],
|
||||||
])
|
])
|
||||||
return documents.map(document => outgoingDocumentStatus(document,
|
return documents.map(document => {
|
||||||
allocations.filter(allocation => allocation.createddocument === document.id).reduce((sum, allocation) => sum + Number(allocation.amount), 0),
|
const documentAllocations = allocations.filter(allocation => allocation.createddocument === document.id)
|
||||||
cancellations.filter(cancellation => cancellation.createddocument === document.id), advances))
|
return outgoingDocumentStatus(document,
|
||||||
|
documentAllocations.reduce((sum, allocation) => sum + Number(allocation.amount), 0),
|
||||||
|
cancellations.filter(cancellation => cancellation.createddocument === document.id), advances,
|
||||||
|
documentAllocations.reduce((sum, allocation) => sum + Number(allocation.cashDiscountAmount || 0), 0))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const outgoingStatusTools: McpTool[] = [
|
export const outgoingStatusTools: McpTool[] = [
|
||||||
{
|
{
|
||||||
name: "accounting.outgoing_documents.status.get", title: "Ausgangsbelegstatus laden",
|
name: "accounting.outgoing_documents.status.get", title: "Ausgangsbelegstatus laden",
|
||||||
description: "Lädt Buchungs-, Zahlungs- und Stornostatus mit Gesamtbetrag, zugeordneten Zahlungen und offenem Betrag. Zahlungsstatus gilt für gebuchte, nicht stornierte Rechnungen und Abschlagsrechnungen; sonst null. Ändert keine Belege oder Buchungen.",
|
description: "Lädt Buchungs-, Zahlungs- und Stornostatus mit Gesamtbetrag, zugeordneten Zahlungen, gebuchtem Skonto und offenem Betrag. Zahlungsstatus gilt für gebuchte, nicht stornierte Rechnungen und Abschlagsrechnungen; sonst null. Ändert keine Belege oder Buchungen.",
|
||||||
requiredPermissions: ["accounting.outgoing_documents.read"],
|
requiredPermissions: ["accounting.outgoing_documents.read"],
|
||||||
inputSchema: { ...objectField({ id: idField }).schema, required: ["id"] },
|
inputSchema: { ...objectField({ id: idField }).schema, required: ["id"] },
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ export type McpToolResult = {
|
|||||||
content: Array<{
|
content: Array<{
|
||||||
type: "text"
|
type: "text"
|
||||||
text: string
|
text: string
|
||||||
|
} | {
|
||||||
|
type: "image"
|
||||||
|
data: string
|
||||||
|
mimeType: string
|
||||||
}>
|
}>
|
||||||
structuredContent?: Record<string, unknown>
|
structuredContent?: Record<string, unknown>
|
||||||
isError?: boolean
|
isError?: boolean
|
||||||
|
|||||||
378
backend/src/modules/cash-discount.ts
Normal file
378
backend/src/modules/cash-discount.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ const formatInvoiceItemDescription = (item: any) => {
|
|||||||
return parts.join(" - ")
|
return parts.join(" - ")
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => {
|
export const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => {
|
||||||
const itemInfo: any = {
|
const itemInfo: any = {
|
||||||
tenant: tenantId,
|
tenant: tenantId,
|
||||||
state: "Vorbereitet",
|
state: "Vorbereitet",
|
||||||
@@ -43,6 +43,17 @@ const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => {
|
|||||||
if (data.issuer?.id) itemInfo.vendor = data.issuer.id
|
if (data.issuer?.id) itemInfo.vendor = data.issuer.id
|
||||||
if (data.invoice_duedate && dayjs(data.invoice_duedate).isValid()) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString()
|
if (data.invoice_duedate && dayjs(data.invoice_duedate).isValid()) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString()
|
||||||
|
|
||||||
|
// Skonto nur übernehmen, wenn die Rechnung ihn ausdrücklich nennt (siehe Prompt).
|
||||||
|
const cashDiscountPercent = Number(data.cash_discount_percent)
|
||||||
|
if (Number.isFinite(cashDiscountPercent) && cashDiscountPercent > 0 && cashDiscountPercent <= 10) {
|
||||||
|
itemInfo.cashDiscountPercent = Number(cashDiscountPercent.toFixed(2))
|
||||||
|
if (data.cash_discount_due_date && dayjs(data.cash_discount_due_date).isValid()) {
|
||||||
|
itemInfo.cashDiscountDueDate = dayjs(data.cash_discount_due_date).format("YYYY-MM-DD")
|
||||||
|
} else if (Number.isInteger(data.cash_discount_days) && data.invoice_date && dayjs(data.invoice_date).isValid()) {
|
||||||
|
itemInfo.cashDiscountDueDate = dayjs(data.invoice_date).add(data.cash_discount_days, "day").format("YYYY-MM-DD")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const mapPayment: Record<string, string> = {
|
const mapPayment: Record<string, string> = {
|
||||||
"Direct Debit": "Einzug",
|
"Direct Debit": "Einzug",
|
||||||
"Transfer": "Überweisung",
|
"Transfer": "Überweisung",
|
||||||
|
|||||||
374
backend/src/modules/einvoice/buildZugferdInvoice.ts
Normal file
374
backend/src/modules/einvoice/buildZugferdInvoice.ts
Normal 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
|
||||||
|
const text = (value: unknown) => String(value ?? "").trim()
|
||||||
|
const el = (name: string, value: unknown, attributes = "") => {
|
||||||
|
const content = text(value)
|
||||||
|
return content ? `<${name}${attributes}>${escapeXml(content)}</${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 } }
|
||||||
|
}
|
||||||
80
backend/src/modules/einvoice/embedZugferd.ts
Normal file
80
backend/src/modules/einvoice/embedZugferd.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { AFRelationship, PDFDocument, PDFName } from "pdf-lib"
|
||||||
|
import { ZUGFERD_FILENAME } from "./buildZugferdInvoice"
|
||||||
|
|
||||||
|
const escapeXml = (value: string) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||||
|
|
||||||
|
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 }))
|
||||||
|
}
|
||||||
@@ -41,6 +41,13 @@ export const findElectronicInvoiceVendor = async (
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addDaysToDate = (date: string, days: number) => {
|
||||||
|
const parsed = new Date(`${String(date).slice(0, 10)}T00:00:00Z`)
|
||||||
|
if (Number.isNaN(parsed.getTime())) return null
|
||||||
|
parsed.setUTCDate(parsed.getUTCDate() + days)
|
||||||
|
return parsed.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
export const mapElectronicInvoiceToIncomingInvoice = (
|
export const mapElectronicInvoiceToIncomingInvoice = (
|
||||||
invoice: ParsedElectronicInvoice,
|
invoice: ParsedElectronicInvoice,
|
||||||
tenantId: number,
|
tenantId: number,
|
||||||
@@ -64,6 +71,10 @@ export const mapElectronicInvoiceToIncomingInvoice = (
|
|||||||
reference: invoice.invoiceNumber,
|
reference: invoice.invoiceNumber,
|
||||||
date: invoice.invoiceDate,
|
date: invoice.invoiceDate,
|
||||||
dueDate: invoice.dueDate,
|
dueDate: invoice.dueDate,
|
||||||
|
cashDiscountPercent: invoice.cashDiscount?.percent ?? null,
|
||||||
|
cashDiscountDueDate: invoice.cashDiscount && invoice.invoiceDate
|
||||||
|
? addDaysToDate(invoice.invoiceDate, invoice.cashDiscount.days)
|
||||||
|
: null,
|
||||||
paymentType: paymentTypeByCode[invoice.paymentMeansCode || ""] || "Sonstiges",
|
paymentType: paymentTypeByCode[invoice.paymentMeansCode || ""] || "Sonstiges",
|
||||||
description: descriptionParts.join("\n"),
|
description: descriptionParts.join("\n"),
|
||||||
accounts: invoice.items.map((item) => ({
|
accounts: invoice.items.map((item) => ({
|
||||||
|
|||||||
106
backend/src/modules/einvoice/outgoingZugferd.service.ts
Normal file
106
backend/src/modules/einvoice/outgoingZugferd.service.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { XMLParser } from "fast-xml-parser"
|
import { XMLParser } from "fast-xml-parser"
|
||||||
import type { ElectronicInvoiceSyntax } from "./detectElectronicInvoice"
|
import type { ElectronicInvoiceSyntax } from "./detectElectronicInvoice"
|
||||||
import type { ElectronicInvoiceItem, ParsedElectronicInvoice } from "./types"
|
import type { ElectronicInvoiceItem, ParsedElectronicInvoice } from "./types"
|
||||||
|
import { parseCashDiscountPaymentTerms } from "../cash-discount"
|
||||||
|
|
||||||
const parser = new XMLParser({
|
const parser = new XMLParser({
|
||||||
ignoreAttributes: false,
|
ignoreAttributes: false,
|
||||||
@@ -83,7 +84,9 @@ const parseCii = (root: any): Omit<ParsedElectronicInvoice, "validation"> => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const paymentMeans = array(settlement?.SpecifiedTradeSettlementPaymentMeans)[0] || {}
|
const paymentMeans = array(settlement?.SpecifiedTradeSettlementPaymentMeans)[0] || {}
|
||||||
const paymentTerms = array(settlement?.SpecifiedTradePaymentTerms)[0] || {}
|
const paymentTermsList = array(settlement?.SpecifiedTradePaymentTerms)
|
||||||
|
const paymentTerms = paymentTermsList[0] || {}
|
||||||
|
const cashDiscount = ciiCashDiscount(paymentTermsList)
|
||||||
const guideline = root?.ExchangedDocumentContext?.GuidelineSpecifiedDocumentContextParameter?.ID
|
const guideline = root?.ExchangedDocumentContext?.GuidelineSpecifiedDocumentContextParameter?.ID
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -103,6 +106,7 @@ const parseCii = (root: any): Omit<ParsedElectronicInvoice, "validation"> => {
|
|||||||
},
|
},
|
||||||
buyerReference: text(agreement?.BuyerReference),
|
buyerReference: text(agreement?.BuyerReference),
|
||||||
paymentMeansCode: text(paymentMeans?.TypeCode),
|
paymentMeansCode: text(paymentMeans?.TypeCode),
|
||||||
|
cashDiscount,
|
||||||
items,
|
items,
|
||||||
totals: {
|
totals: {
|
||||||
lineNet: number(monetary?.LineTotalAmount) != null ? roundMoney(sign * number(monetary.LineTotalAmount)!) : null,
|
lineNet: number(monetary?.LineTotalAmount) != null ? roundMoney(sign * number(monetary.LineTotalAmount)!) : null,
|
||||||
@@ -113,6 +117,18 @@ const parseCii = (root: any): Omit<ParsedElectronicInvoice, "validation"> => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skonto steht in XRechnung als "#SKONTO#TAGE=14#PROZENT=2.00#" im Zahlungsbedingungstext,
|
||||||
|
// in ZUGFeRD EXTENDED zusätzlich strukturiert als ApplicableTradePaymentDiscountTerms.
|
||||||
|
const ciiCashDiscount = (paymentTermsList: any[]) => {
|
||||||
|
for (const terms of paymentTermsList) {
|
||||||
|
const discountTerms = array(terms?.ApplicableTradePaymentDiscountTerms)[0]
|
||||||
|
const percent = number(discountTerms?.CalculationPercent)
|
||||||
|
const days = number(discountTerms?.BasisPeriodMeasure)
|
||||||
|
if (percent && percent > 0 && days !== null && Number.isInteger(days)) return { percent, days }
|
||||||
|
}
|
||||||
|
return parseCashDiscountPaymentTerms(paymentTermsList.flatMap((terms: any) => array(terms?.Description).map(text)).join("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
const ublPartyName = (party: any) => text(party?.PartyLegalEntity?.RegistrationName) || text(party?.PartyName?.Name)
|
const ublPartyName = (party: any) => text(party?.PartyLegalEntity?.RegistrationName) || text(party?.PartyName?.Name)
|
||||||
|
|
||||||
const parseUbl = (root: any, syntax: "ubl-invoice" | "ubl-credit-note"): Omit<ParsedElectronicInvoice, "validation"> => {
|
const parseUbl = (root: any, syntax: "ubl-invoice" | "ubl-credit-note"): Omit<ParsedElectronicInvoice, "validation"> => {
|
||||||
@@ -163,6 +179,7 @@ const parseUbl = (root: any, syntax: "ubl-invoice" | "ubl-credit-note"): Omit<Pa
|
|||||||
},
|
},
|
||||||
buyerReference: text(root?.BuyerReference),
|
buyerReference: text(root?.BuyerReference),
|
||||||
paymentMeansCode: text(paymentMeans?.PaymentMeansCode),
|
paymentMeansCode: text(paymentMeans?.PaymentMeansCode),
|
||||||
|
cashDiscount: parseCashDiscountPaymentTerms(array(root?.PaymentTerms).flatMap((terms: any) => array(terms?.Note).map(text)).join("\n")),
|
||||||
items,
|
items,
|
||||||
totals: {
|
totals: {
|
||||||
lineNet: number(monetary?.LineExtensionAmount) != null ? roundMoney(sign * number(monetary.LineExtensionAmount)!) : null,
|
lineNet: number(monetary?.LineExtensionAmount) != null ? roundMoney(sign * number(monetary.LineExtensionAmount)!) : null,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export type ParsedElectronicInvoice = {
|
|||||||
}
|
}
|
||||||
buyerReference: string | null
|
buyerReference: string | null
|
||||||
paymentMeansCode: string | null
|
paymentMeansCode: string | null
|
||||||
|
cashDiscount?: { percent: number, days: number } | null
|
||||||
items: ElectronicInvoiceItem[]
|
items: ElectronicInvoiceItem[]
|
||||||
totals: {
|
totals: {
|
||||||
lineNet: number | null
|
lineNet: number | null
|
||||||
|
|||||||
287
backend/src/modules/outgoing-document-pdf.service.ts
Normal file
287
backend/src/modules/outgoing-document-pdf.service.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -9,7 +9,9 @@ import { saveFile } from "../utils/files";
|
|||||||
import {FastifyInstance} from "fastify";
|
import {FastifyInstance} from "fastify";
|
||||||
import {useNextNumberRangeNumber} from "../utils/functions";
|
import {useNextNumberRangeNumber} from "../utils/functions";
|
||||||
import {createInvoicePDF} from "../utils/pdf"; // Achtung: Muss Node.js Buffer unterstützen!
|
import {createInvoicePDF} from "../utils/pdf"; // Achtung: Muss Node.js Buffer unterstützen!
|
||||||
|
import { assertOutgoingEInvoiceRequirements, attachZugferdToPdf, EInvoiceRequirementsError } from "./einvoice/outgoingZugferd.service";
|
||||||
import { documentTemplateHandlebars } from "../utils/handlebars";
|
import { documentTemplateHandlebars } from "../utils/handlebars";
|
||||||
|
import { appendCashDiscountText, buildCashDiscountTerms, getCashDiscountTemplateContext } from "./cash-discount"
|
||||||
|
|
||||||
dayjs.extend(quarterOfYear);
|
dayjs.extend(quarterOfYear);
|
||||||
|
|
||||||
@@ -200,6 +202,7 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
|||||||
|
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
|
const einvoiceErrors: string[] = [];
|
||||||
|
|
||||||
// 5. Loop durch Dokumente
|
// 5. Loop durch Dokumente
|
||||||
for (const doc of documents) {
|
for (const doc of documents) {
|
||||||
@@ -215,6 +218,9 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
|||||||
doc.letterhead ? fetchById(server, schema.letterheads, doc.letterhead) : null
|
doc.letterhead ? fetchById(server, schema.letterheads, doc.letterhead) : null
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Pflichtangaben vor der Nummernvergabe prüfen, damit keine Lücken im Nummernkreis entstehen
|
||||||
|
await assertOutgoingEInvoiceRequirements(server, tenantId, doc);
|
||||||
|
|
||||||
const pdfData = await getCloseData(
|
const pdfData = await getCloseData(
|
||||||
server,
|
server,
|
||||||
doc,
|
doc,
|
||||||
@@ -232,8 +238,14 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
|||||||
console.log(pdfBase64);
|
console.log(pdfBase64);
|
||||||
|
|
||||||
// E. Datei speichern
|
// E. Datei speichern
|
||||||
// @ts-ignore
|
// E-Rechnung (ZUGFeRD) in die PDF einbetten; Grundlage ist der Beleg mit der neuen Nummer
|
||||||
const fileBuffer = Buffer.from(pdfBase64.base64, "base64");
|
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`;
|
const filename = `${pdfData.documentNumber}.pdf`;
|
||||||
|
|
||||||
await saveFile(
|
await saveFile(
|
||||||
@@ -266,6 +278,7 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
|||||||
} catch (innerErr) {
|
} catch (innerErr) {
|
||||||
console.error(`Fehler beim Finalisieren von Doc ID ${doc.id}:`, innerErr);
|
console.error(`Fehler beim Finalisieren von Doc ID ${doc.id}:`, innerErr);
|
||||||
errorCount++;
|
errorCount++;
|
||||||
|
if (innerErr instanceof EInvoiceRequirementsError) einvoiceErrors.push(`Beleg ${doc.id}: ${innerErr.message}`);
|
||||||
// Optional: Status des einzelnen Dokuments auf Error setzen
|
// Optional: Status des einzelnen Dokuments auf Error setzen
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,7 +288,7 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
|||||||
|
|
||||||
|
|
||||||
const execution = await settleExecution(server, executionId, tenantId, finalStatus,
|
const execution = await settleExecution(server, executionId, tenantId, finalStatus,
|
||||||
`Abgeschlossen: ${successCount} erfolgreich, ${errorCount} Fehler.`);
|
`Abgeschlossen: ${successCount} erfolgreich, ${errorCount} Fehler.${einvoiceErrors.length ? ` ${einvoiceErrors.join(" ")}` : ""}`);
|
||||||
return { success: execution.status === "completed", execution, processed: successCount, errors: errorCount };
|
return { success: execution.status === "completed", execution, processed: successCount, errors: errorCount };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await settleExecution(server, executionId, tenantId, "error", "Kritischer Fehler beim Finalisieren.");
|
await settleExecution(server, executionId, tenantId, "error", "Kritischer Fehler beim Finalisieren.");
|
||||||
@@ -421,6 +434,8 @@ async function getSaveData(item: any, tenant: any, firstDate: string, lastDate:
|
|||||||
deliveryDate: firstDate,
|
deliveryDate: firstDate,
|
||||||
deliveryDateEnd: lastDate,
|
deliveryDateEnd: lastDate,
|
||||||
paymentDays: item.paymentDays,
|
paymentDays: item.paymentDays,
|
||||||
|
cashDiscountPercent: item.cashDiscountPercent,
|
||||||
|
cashDiscountDays: item.cashDiscountDays,
|
||||||
payment_type: item.payment_type,
|
payment_type: item.payment_type,
|
||||||
deliveryDateType: item.deliveryDateType,
|
deliveryDateType: item.deliveryDateType,
|
||||||
info: {}, // Achtung: Postgres erwartet hier valides JSON Objekt
|
info: {}, // Achtung: Postgres erwartet hier valides JSON Objekt
|
||||||
@@ -646,6 +661,12 @@ export function getDocumentDataBackend(
|
|||||||
|
|
||||||
|
|
||||||
// --- 5. Handlebars Context ---
|
// --- 5. Handlebars Context ---
|
||||||
|
const cashDiscountTerms = buildCashDiscountTerms({
|
||||||
|
percent: itemInfo.cashDiscountPercent,
|
||||||
|
days: itemInfo.cashDiscountDays,
|
||||||
|
documentDate: itemInfo.documentDate ? dayjs(itemInfo.documentDate).format("YYYY-MM-DD") : null,
|
||||||
|
amount: totals.totalGross,
|
||||||
|
});
|
||||||
const generateContext = () => {
|
const generateContext = () => {
|
||||||
return {
|
return {
|
||||||
// lohnkosten: null, // Backend hat diesen Wert oft nicht direkt, ggf. aus itemInfo holen
|
// lohnkosten: null, // Backend hat diesen Wert oft nicht direkt, ggf. aus itemInfo holen
|
||||||
@@ -656,7 +677,8 @@ export function getDocumentDataBackend(
|
|||||||
kundenname: customerData && customerData.name,
|
kundenname: customerData && customerData.name,
|
||||||
zahlungsziel_in_tagen: itemInfo.paymentDays,
|
zahlungsziel_in_tagen: itemInfo.paymentDays,
|
||||||
zahlungsart: itemInfo.payment_type === "transfer" ? "Überweisung" : "Lastschrift",
|
zahlungsart: itemInfo.payment_type === "transfer" ? "Überweisung" : "Lastschrift",
|
||||||
diesel_gesamtverbrauch: (itemInfo.agriculture && itemInfo.agriculture.dieselUsageTotal) || null
|
diesel_gesamtverbrauch: (itemInfo.agriculture && itemInfo.agriculture.dieselUsageTotal) || null,
|
||||||
|
...getCashDiscountTemplateContext(cashDiscountTerms),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -756,7 +778,7 @@ export function getDocumentDataBackend(
|
|||||||
title: itemInfo.title,
|
title: itemInfo.title,
|
||||||
description: itemInfo.description,
|
description: itemInfo.description,
|
||||||
// Handlebars Compilation ausführen
|
// Handlebars Compilation ausführen
|
||||||
endText: templateEndText(generateContext()),
|
endText: appendCashDiscountText(templateEndText(generateContext()), itemInfo.endText, cashDiscountTerms),
|
||||||
startText: templateStartText(generateContext()),
|
startText: templateStartText(generateContext()),
|
||||||
rows: rows,
|
rows: rows,
|
||||||
totalArray: totalArray,
|
totalArray: totalArray,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { centralServicesClient } from "../modules/push-server.client"
|
|||||||
import { findBankInstitutionByBic } from "../modules/banking-institution"
|
import { findBankInstitutionByBic } from "../modules/banking-institution"
|
||||||
import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../modules/bank-allocation-details"
|
import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../modules/bank-allocation-details"
|
||||||
import { CASHBOOK_BANK_ID } from "../modules/bank-balances.service"
|
import { CASHBOOK_BANK_ID } from "../modules/bank-balances.service"
|
||||||
|
import { prepareCashDiscountAllocation, settledAllocationAmount } from "../modules/cash-discount"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
bankrequisitions,
|
bankrequisitions,
|
||||||
@@ -56,7 +57,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
eq(bankstatements.id, statementId),
|
eq(bankstatements.id, statementId),
|
||||||
eq(bankstatements.tenant, tenantId)
|
eq(bankstatements.tenant, tenantId)
|
||||||
)).limit(1),
|
)).limit(1),
|
||||||
server.db.select({ amount: statementallocations.amount }).from(statementallocations).where(and(
|
server.db.select({ amount: statementallocations.amount, cashDiscountAmount: statementallocations.cashDiscountAmount }).from(statementallocations).where(and(
|
||||||
eq(statementallocations.incominginvoice, invoiceId),
|
eq(statementallocations.incominginvoice, invoiceId),
|
||||||
eq(statementallocations.tenant, tenantId),
|
eq(statementallocations.tenant, tenantId),
|
||||||
eq(statementallocations.archived, false)
|
eq(statementallocations.archived, false)
|
||||||
@@ -77,10 +78,9 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
const correctedTax = invoice.taxAmountOverride !== null && invoice.taxAmountOverride !== undefined && invoice.taxAmountOverride !== ""
|
const correctedTax = invoice.taxAmountOverride !== null && invoice.taxAmountOverride !== undefined && invoice.taxAmountOverride !== ""
|
||||||
? Number(invoice.taxAmountOverride)
|
? Number(invoice.taxAmountOverride)
|
||||||
: accountTotals.tax
|
: accountTotals.tax
|
||||||
const legacyRemaining = Math.max(0, Math.abs(accountTotals.net + accountTotals.tax)
|
const invoiceSettled = invoiceAllocations.reduce((sum, item) => sum + Math.abs(settledAllocationAmount(item)), 0)
|
||||||
- invoiceAllocations.reduce((sum, item) => sum + Math.abs(Number(item.amount || 0)), 0))
|
const legacyRemaining = Math.max(0, Math.abs(accountTotals.net + accountTotals.tax) - invoiceSettled)
|
||||||
const correctedRemaining = Math.max(0, Math.abs(accountTotals.net + correctedTax)
|
const correctedRemaining = Math.max(0, Math.abs(accountTotals.net + correctedTax) - invoiceSettled)
|
||||||
- invoiceAllocations.reduce((sum, item) => sum + Math.abs(Number(item.amount || 0)), 0))
|
|
||||||
const statementRemaining = Math.max(0, Math.abs(Number(statement.amount || 0))
|
const statementRemaining = Math.max(0, Math.abs(Number(statement.amount || 0))
|
||||||
- statementAllocations.reduce((sum, item) => sum + Math.abs(Number(item.amount || 0)), 0))
|
- statementAllocations.reduce((sum, item) => sum + Math.abs(Number(item.amount || 0)), 0))
|
||||||
const requestedAmount = Number(allocation.amount || 0)
|
const requestedAmount = Number(allocation.amount || 0)
|
||||||
@@ -1168,7 +1168,10 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
if (prepared.error) return reply.code(400).send({ error: prepared.error })
|
if (prepared.error) return reply.code(400).send({ error: prepared.error })
|
||||||
Object.assign(prepared.data, normalizeBankAllocationDetails(prepared.data))
|
Object.assign(prepared.data, normalizeBankAllocationDetails(prepared.data))
|
||||||
await validateBankAllocationCostCentre(server.db, req.user.tenant_id, prepared.data.costcentre)
|
await validateBankAllocationCostCentre(server.db, req.user.tenant_id, prepared.data.costcentre)
|
||||||
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({
|
const inserted = await server.db.insert(statementallocations).values({
|
||||||
...prepared.data,
|
...prepared.data,
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import isSameOrBefore from "dayjs/plugin/isSameOrBefore.js"
|
|||||||
import duration from "dayjs/plugin/duration.js";
|
import duration from "dayjs/plugin/duration.js";
|
||||||
import timezone from "dayjs/plugin/timezone.js";
|
import timezone from "dayjs/plugin/timezone.js";
|
||||||
import {generateTimesEvaluation} from "../modules/time/evaluation.service";
|
import {generateTimesEvaluation} from "../modules/time/evaluation.service";
|
||||||
import {citys, files} from "../../db/schema";
|
import {citys, createddocuments, files} from "../../db/schema";
|
||||||
|
import { attachZugferdToPdf, checkOutgoingEInvoice, EInvoiceRequirementsError } from "../modules/einvoice/outgoingZugferd.service";
|
||||||
import {and, eq, isNull, not} from "drizzle-orm";
|
import {and, eq, isNull, not} from "drizzle-orm";
|
||||||
import {cancelManualGeneration, executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
|
import {cancelManualGeneration, executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
|
||||||
import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service";
|
import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service";
|
||||||
@@ -110,6 +111,8 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
const body = req.body as {
|
const body = req.body as {
|
||||||
data: any
|
data: any
|
||||||
backgroundPath?: string
|
backgroundPath?: string
|
||||||
|
// Gespeicherter Beleg, aus dem die eingebettete E-Rechnung (ZUGFeRD) erzeugt wird
|
||||||
|
createdDocumentId?: number
|
||||||
}
|
}
|
||||||
const {type} = req.params as {type:string}
|
const {type} = req.params as {type:string}
|
||||||
|
|
||||||
@@ -125,6 +128,17 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
body.data,
|
body.data,
|
||||||
body.backgroundPath
|
body.backgroundPath
|
||||||
)
|
)
|
||||||
|
if (body.createdDocumentId) {
|
||||||
|
const tenantId = (req as any).user.tenant_id
|
||||||
|
const [document] = await server.db
|
||||||
|
.select()
|
||||||
|
.from(createddocuments)
|
||||||
|
.where(and(eq(createddocuments.id, Number(body.createdDocumentId)), eq(createddocuments.tenant, tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
if (!document) return reply.code(404).send({ error: "Beleg nicht gefunden" })
|
||||||
|
const result = await attachZugferdToPdf(server, tenantId, document, Buffer.from(pdf.base64, "base64"))
|
||||||
|
pdf = { ...pdf, base64: result.pdf.toString("base64"), einvoice: result.einvoice, einvoiceIssues: result.issues }
|
||||||
|
}
|
||||||
} else if(type === "timesheet") {
|
} else if(type === "timesheet") {
|
||||||
pdf = await createTimeSheetPDF(
|
pdf = await createTimeSheetPDF(
|
||||||
server,
|
server,
|
||||||
@@ -136,6 +150,9 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
return pdf // Fastify wandelt automatisch in JSON
|
return pdf // Fastify wandelt automatisch in JSON
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof EInvoiceRequirementsError) {
|
||||||
|
return reply.code(422).send({ error: err.message, code: err.code, issues: err.issues })
|
||||||
|
}
|
||||||
console.log(err)
|
console.log(err)
|
||||||
const unsupportedCharacterMessage = getUnsupportedPdfCharacterMessage(err, body.data)
|
const unsupportedCharacterMessage = getUnsupportedPdfCharacterMessage(err, body.data)
|
||||||
if (unsupportedCharacterMessage) {
|
if (unsupportedCharacterMessage) {
|
||||||
@@ -149,6 +166,14 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Prüft die Pflichtangaben der E-Rechnung für den aktuellen (ggf. ungespeicherten) Stand aus dem Editor
|
||||||
|
server.post("/functions/einvoice/check", async (req, reply) => {
|
||||||
|
const { document } = (req.body || {}) as { document?: any }
|
||||||
|
if (!document || typeof document !== "object") return reply.code(400).send({ error: "document fehlt" })
|
||||||
|
const issues = await checkOutgoingEInvoice(server, (req as any).user.tenant_id, document)
|
||||||
|
return { issues }
|
||||||
|
})
|
||||||
|
|
||||||
server.get("/functions/usenextnumber/:numberrange", async (req, reply) => {
|
server.get("/functions/usenextnumber/:numberrange", async (req, reply) => {
|
||||||
const { numberrange } = req.params as { numberrange: string };
|
const { numberrange } = req.params as { numberrange: string };
|
||||||
const tenant = (req as any).user.tenant_id
|
const tenant = (req as any).user.tenant_id
|
||||||
|
|||||||
@@ -507,6 +507,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
id: statementallocations.id,
|
id: statementallocations.id,
|
||||||
createddocument: statementallocations.createddocument,
|
createddocument: statementallocations.createddocument,
|
||||||
amount: statementallocations.amount,
|
amount: statementallocations.amount,
|
||||||
|
cashDiscountAmount: statementallocations.cashDiscountAmount,
|
||||||
})
|
})
|
||||||
.from(statementallocations)
|
.from(statementallocations)
|
||||||
.where(and(
|
.where(and(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {and, desc, eq, inArray, isNull} from "drizzle-orm"
|
|||||||
import { enrichProfilesWithBranches } from "../utils/profileBranches"
|
import { enrichProfilesWithBranches } from "../utils/profileBranches"
|
||||||
import { enrichProfilesWithTeams } from "../utils/profileTeams"
|
import { enrichProfilesWithTeams } from "../utils/profileTeams"
|
||||||
import { rotateRefreshToken } from "../utils/authTokens"
|
import { rotateRefreshToken } from "../utils/authTokens"
|
||||||
|
import { normalizeCashDiscountAccountsInput } from "../modules/cash-discount"
|
||||||
|
|
||||||
|
|
||||||
export default async function tenantRoutes(server: FastifyInstance) {
|
export default async function tenantRoutes(server: FastifyInstance) {
|
||||||
@@ -287,6 +288,14 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
|||||||
delete data.portalConfig.primayColor
|
delete data.portalConfig.primayColor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(data, "cashDiscountAccounts")) {
|
||||||
|
try {
|
||||||
|
data.cashDiscountAccounts = normalizeCashDiscountAccountsInput(data.cashDiscountAccounts)
|
||||||
|
} catch (err: any) {
|
||||||
|
return reply.code(400).send({ error: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await server.db
|
const updated = await server.db
|
||||||
.update(tenants)
|
.update(tenants)
|
||||||
.set(data)
|
.set(data)
|
||||||
|
|||||||
@@ -104,6 +104,51 @@ export const getBankAccountBookingLine = (alloc: any) => {
|
|||||||
return columns.join(";");
|
return columns.join(";");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Skonto als eigene Buchung je Steuersatz: Skontokonto (Automatikkonto) gegen Debitor bzw. Kreditor.
|
||||||
|
// Positiver Skonto (Zahlungseingang) steht im Soll des Skontokontos, negativer (Zahlungsausgang) im Haben.
|
||||||
|
export const getCashDiscountBookingLines = (alloc: any) => {
|
||||||
|
const split = Array.isArray(alloc?.cashDiscountSplit) ? alloc.cashDiscountSplit : [];
|
||||||
|
if (!split.length) return [];
|
||||||
|
|
||||||
|
const bs = alloc.bankstatement;
|
||||||
|
const cd = alloc.createddocument;
|
||||||
|
const ii = alloc.incominginvoice;
|
||||||
|
const partner = cd ? cd.customer : ii?.vendor;
|
||||||
|
const partnerNumber = cd ? partner?.customerNumber : partner?.vendorNumber;
|
||||||
|
const reference = cd ? cd.documentNumber : ii?.reference;
|
||||||
|
const prefix = cd ? "Skonto AR" : "Skonto ER";
|
||||||
|
const date = bs?.date || alloc.manualBookingDate;
|
||||||
|
const quote = (value: any) => `"${escapeString(String(value ?? ""))}"`;
|
||||||
|
|
||||||
|
return split
|
||||||
|
.filter((item: any) => Number(item?.gross) && item?.account)
|
||||||
|
.map((item: any) => {
|
||||||
|
const columns = Array(124).fill("");
|
||||||
|
columns[0] = displayCurrency(Number(item.gross), true);
|
||||||
|
columns[1] = quote(Number(item.gross) > 0 ? "S" : "H");
|
||||||
|
columns[6] = item.account;
|
||||||
|
columns[7] = partnerNumber || "";
|
||||||
|
columns[8] = quote("");
|
||||||
|
columns[9] = formatDatevDate(date, "DDMM");
|
||||||
|
columns[10] = quote(reference || "");
|
||||||
|
columns[13] = quote(`${prefix} ${reference || ""} ${item.rate} % - ${partner?.name || ""}`.substring(0, 59));
|
||||||
|
columns[20] = quote("Geschäftspartner");
|
||||||
|
columns[21] = quote(partner?.name || "");
|
||||||
|
columns[22] = quote("Kundennummer");
|
||||||
|
columns[23] = quote(partnerNumber || "");
|
||||||
|
columns[24] = quote("Belegnummer");
|
||||||
|
columns[25] = quote(reference || "");
|
||||||
|
columns[28] = quote("Belegdatum");
|
||||||
|
columns[29] = quote(formatDatevDate(date, "DD.MM.YYYY"));
|
||||||
|
if (bs?.id) {
|
||||||
|
columns[47] = "Bank-Id";
|
||||||
|
columns[48] = bs.id;
|
||||||
|
}
|
||||||
|
columns[112] = "0";
|
||||||
|
return columns.join(";");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const getIncomingInvoiceTaxOverride = (invoice: any) => {
|
const getIncomingInvoiceTaxOverride = (invoice: any) => {
|
||||||
if (invoice.taxAmountOverride === null || invoice.taxAmountOverride === undefined || invoice.taxAmountOverride === "") return null;
|
if (invoice.taxAmountOverride === null || invoice.taxAmountOverride === undefined || invoice.taxAmountOverride === "") return null;
|
||||||
|
|
||||||
@@ -479,6 +524,10 @@ export async function buildExportZip(
|
|||||||
const ii = alloc.incominginvoice;
|
const ii = alloc.incominginvoice;
|
||||||
const vend = ii.vendor;
|
const vend = ii.vendor;
|
||||||
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${vend?.vendorNumber};"";${formatDatevDate(ii.date, "DDMM")};"${ii.reference}";;;"${`ZA${alloc.description} ${bsText} `.substring(0,59)}";;;;;;;"Geschäftspartner";"${vend?.name}";"Kundennummer";"${vend?.vendorNumber}";"Belegnummer";"${ii.reference}";"Leistungsdatum";"${formatDatevDate(ii.date, "DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${vend?.vendorNumber};"";${formatDatevDate(ii.date, "DDMM")};"${ii.reference}";;;"${`ZA${alloc.description} ${bsText} `.substring(0,59)}";;;;;;;"Geschäftspartner";"${vend?.name}";"Kundennummer";"${vend?.vendorNumber}";"Belegnummer";"${ii.reference}";"Leistungsdatum";"${formatDatevDate(ii.date, "DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if((alloc.createddocument && alloc.createddocument.customer) || (alloc.incominginvoice && alloc.incominginvoice.vendor)) {
|
||||||
|
bookingLines.push(...getCashDiscountBookingLines(alloc));
|
||||||
} else if(alloc.account) {
|
} else if(alloc.account) {
|
||||||
bookingLines.push(getBankAccountBookingLine(alloc));
|
bookingLines.push(getBankAccountBookingLine(alloc));
|
||||||
} else if(alloc.vendor) {
|
} else if(alloc.vendor) {
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ const InstructionFormat = z.object({
|
|||||||
tax: nullableNumber,
|
tax: nullableNumber,
|
||||||
total: nullableNumber,
|
total: nullableNumber,
|
||||||
terms: nullableString,
|
terms: nullableString,
|
||||||
|
cash_discount_percent: nullableNumber,
|
||||||
|
cash_discount_days: nullableNumber,
|
||||||
|
cash_discount_due_date: nullableString,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
@@ -190,6 +193,7 @@ export const getInvoiceDataFromGPT = async function (
|
|||||||
"Do not merge summary totals into fabricated invoice_items.\n" +
|
"Do not merge summary totals into fabricated invoice_items.\n" +
|
||||||
"Match issuer by name to vendor.id.\n" +
|
"Match issuer by name to vendor.id.\n" +
|
||||||
"Match invoice items to account id based on label/number.\n" +
|
"Match invoice items to account id based on label/number.\n" +
|
||||||
|
"Set cash_discount_percent, cash_discount_days and cash_discount_due_date only when the invoice explicitly offers Skonto/cash discount (e.g. '2 % Skonto bei Zahlung innerhalb 10 Tagen'); otherwise null.\n" +
|
||||||
"Convert dates to YYYY-MM-DD.\n" +
|
"Convert dates to YYYY-MM-DD.\n" +
|
||||||
"Keep invoice items in original order.\n",
|
"Keep invoice items in original order.\n",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -685,14 +685,14 @@ export const generateLiquidityForecast = async (
|
|||||||
if (allocation.createddocument) {
|
if (allocation.createddocument) {
|
||||||
allocationByDocument.set(
|
allocationByDocument.set(
|
||||||
allocation.createddocument,
|
allocation.createddocument,
|
||||||
roundMoney((allocationByDocument.get(allocation.createddocument) || 0) + Number(allocation.amount || 0))
|
roundMoney((allocationByDocument.get(allocation.createddocument) || 0) + Number(allocation.amount || 0) + Number(allocation.cashDiscountAmount || 0))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allocation.incominginvoice) {
|
if (allocation.incominginvoice) {
|
||||||
allocationByIncomingInvoice.set(
|
allocationByIncomingInvoice.set(
|
||||||
allocation.incominginvoice,
|
allocation.incominginvoice,
|
||||||
roundMoney((allocationByIncomingInvoice.get(allocation.incominginvoice) || 0) + Number(allocation.amount || 0))
|
roundMoney((allocationByIncomingInvoice.get(allocation.incominginvoice) || 0) + Number(allocation.amount || 0) + Number(allocation.cashDiscountAmount || 0))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"]
|
||||||
const isPackingSlip = invoiceData?.type === "packingSlips"
|
const isPackingSlip = invoiceData?.type === "packingSlips"
|
||||||
|
|
||||||
@@ -153,8 +163,14 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
- paymentQrLayout.sizeMm * mm
|
- 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) => {
|
const getTextWidthBeforePaymentQr = (page: any, startX: number) => {
|
||||||
if (!paymentQrCode) return 500
|
if (!paymentQrCode || paymentQrOnNewPage) return 500
|
||||||
|
|
||||||
return Math.max(
|
return Math.max(
|
||||||
240,
|
240,
|
||||||
@@ -496,6 +512,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
}
|
}
|
||||||
|
|
||||||
invoiceData.rows.forEach((row, index) => {
|
invoiceData.rows.forEach((row, index) => {
|
||||||
|
const layoutTop = rowHeight
|
||||||
|
|
||||||
if (!["pagebreak", "title", "text"].includes(row.mode)) {
|
if (!["pagebreak", "title", "text"].includes(row.mode)) {
|
||||||
|
|
||||||
@@ -881,10 +898,18 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(rowHeight)
|
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
|
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)) {
|
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||||
pages[pageCounter - 1].drawLine({
|
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)
|
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, {
|
pages[pageCounter - 1].drawText(invoiceData.endText, {
|
||||||
...endTextPosition,
|
...endTextPosition,
|
||||||
size: 10,
|
size: 10,
|
||||||
@@ -1012,6 +1052,16 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (paymentQrCode) {
|
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 lastPage = pages[pages.length - 1]
|
||||||
const qrSize = paymentQrLayout.sizeMm * mm
|
const qrSize = paymentQrLayout.sizeMm * mm
|
||||||
const bottomMargin = paymentQrLayout.bottomMarginMm * mm
|
const bottomMargin = paymentQrLayout.bottomMarginMm * mm
|
||||||
@@ -1030,6 +1080,12 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
font: fontBold,
|
font: fontBold,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (layout) layout.paymentQr = {
|
||||||
|
page: pages.length,
|
||||||
|
topMm: (lastPage.getHeight() - (qrY + qrSize + labelGap + 8)) / mm,
|
||||||
|
leftMm: qrX / mm,
|
||||||
|
}
|
||||||
|
|
||||||
lastPage.drawImage(paymentQrCode, {
|
lastPage.drawImage(paymentQrCode, {
|
||||||
x: qrX,
|
x: qrX,
|
||||||
y: qrY,
|
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()
|
return await pdfDoc.saveAsBase64()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
30
backend/src/utils/pdfPreview.ts
Normal file
30
backend/src/utils/pdfPreview.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
141
backend/tests/cashDiscount.test.ts
Normal file
141
backend/tests/cashDiscount.test.ts
Normal 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)
|
||||||
|
})
|
||||||
@@ -66,3 +66,27 @@ test("markiert Summenabweichungen als Prüfungsfehler", () => {
|
|||||||
|
|
||||||
assert.match(parsed.validation.errors.join(" "), /Positionssumme/)
|
assert.match(parsed.validation.errors.join(" "), /Positionssumme/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("reads XRechnung cash discount terms from CII payment terms", async () => {
|
||||||
|
const xml = Buffer.from(ciiInvoice.toString().replace(
|
||||||
|
"<ram:SpecifiedTradePaymentTerms>",
|
||||||
|
"<ram:SpecifiedTradePaymentTerms><ram:Description>#SKONTO#TAGE=10#PROZENT=2.00#\n</ram:Description>",
|
||||||
|
))
|
||||||
|
const detection = await detectElectronicInvoice(xml, { name: "rechnung.xml" })
|
||||||
|
const parsed = parseElectronicInvoice(detection!.xml, detection!.syntax)
|
||||||
|
assert.deepEqual(parsed.cashDiscount, { percent: 2, days: 10 })
|
||||||
|
|
||||||
|
const mapped = mapElectronicInvoiceToIncomingInvoice(parsed, 1, 5, "xml", null)
|
||||||
|
assert.equal(mapped.cashDiscountPercent, 2)
|
||||||
|
assert.equal(mapped.cashDiscountDueDate, "2026-07-31")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reads cash discount terms from UBL payment terms notes", async () => {
|
||||||
|
const xml = Buffer.from(ublInvoice.toString().replace(
|
||||||
|
"<cac:PaymentMeans>",
|
||||||
|
"<cac:PaymentTerms><cbc:Note>#SKONTO#TAGE=14#PROZENT=3.00#</cbc:Note></cac:PaymentTerms><cac:PaymentMeans>",
|
||||||
|
))
|
||||||
|
const detection = await detectElectronicInvoice(xml, { name: "xrechnung.xml" })
|
||||||
|
const parsed = parseElectronicInvoice(detection!.xml, detection!.syntax)
|
||||||
|
assert.deepEqual(parsed.cashDiscount, { percent: 3, days: 14 })
|
||||||
|
})
|
||||||
|
|||||||
125
backend/tests/mcpOutgoingDocumentPreview.test.ts
Normal file
125
backend/tests/mcpOutgoingDocumentPreview.test.ts
Normal 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"))
|
||||||
|
})
|
||||||
31
backend/tests/mcpOutgoingRowDefaults.test.ts
Normal file
31
backend/tests/mcpOutgoingRowDefaults.test.ts
Normal 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/)
|
||||||
|
})
|
||||||
209
backend/tests/zugferdInvoice.test.ts
Normal file
209
backend/tests/zugferdInvoice.test.ts
Normal 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 & 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"])
|
||||||
|
})
|
||||||
@@ -36,6 +36,23 @@ Vor dem Buchen werden Netto-, Steuer- und Bruttobetrag angezeigt. Beispielsweise
|
|||||||
|
|
||||||
Der DATEV-Export übergibt den Bruttobetrag mit dem gewählten BU-Schlüssel (9/8 für Vorsteuer, 3/2 für Umsatzsteuer) und die Kostenstellennummer in **KOST1**. „Ohne Umsatzsteuer“ verwendet Schlüssel 40, um auch eine Kontenautomatik aufzuheben. Bestehende Buchungen ohne Steuerauswahl werden nicht nachträglich umgerechnet. Die Zuordnung folgt den [DATEV-Steuerschlüsseln](https://www.datev.de/dnlexom/v2/content/files/st2286167563_de.pdf) und der [DATEV-Dokumentation zur Aufhebung der Automatik](https://apps.datev.de/help-center/api/knowledge-help/v1/content/entities/st2112778507_de.pdf?docId=0907888&save=False).
|
Der DATEV-Export übergibt den Bruttobetrag mit dem gewählten BU-Schlüssel (9/8 für Vorsteuer, 3/2 für Umsatzsteuer) und die Kostenstellennummer in **KOST1**. „Ohne Umsatzsteuer“ verwendet Schlüssel 40, um auch eine Kontenautomatik aufzuheben. Bestehende Buchungen ohne Steuerauswahl werden nicht nachträglich umgerechnet. Die Zuordnung folgt den [DATEV-Steuerschlüsseln](https://www.datev.de/dnlexom/v2/content/files/st2286167563_de.pdf) und der [DATEV-Dokumentation zur Aufhebung der Automatik](https://apps.datev.de/help-center/api/knowledge-help/v1/content/entities/st2112778507_de.pdf?docId=0907888&save=False).
|
||||||
|
|
||||||
|
## Zahlung mit Skonto ausgleichen
|
||||||
|
|
||||||
|
Zahlt ein Kunde weniger als den offenen Betrag oder ziehst du bei einem Lieferanten Skonto ab, öffne den Bankumsatz und wähle beim Beleg **Mit Skonto ausgleichen** (Prozent-Symbol). Der Vorschlag ist die Differenz zwischen offenem Betrag und Zahlung. Zahlung und Skonto gleichen den Beleg gemeinsam aus; der Bankumsatz enthält weiterhin nur den gezahlten Betrag.
|
||||||
|
|
||||||
|
- Der Skonto wird anteilig nach den Steuersätzen des Belegs verteilt und mindert die Umsatzsteuer (Ausgangsbelege) bzw. Vorsteuer (Eingangsbelege) im Zeitraum der Zahlung.
|
||||||
|
- Bei Schlussrechnungen bleiben die Abschläge mit ihren ursprünglich berechneten Beträgen abgezogen. Skonto auf eine Abschlagsrechnung wird bei deren Zahlung gebucht, Skonto auf die Schlussrechnung bezieht sich auf die Restforderung.
|
||||||
|
- Der DATEV-Export erzeugt je Steuersatz eine eigene Buchung vom Skontokonto gegen Debitor bzw. Kreditor mit der Rechnungsnummer als Belegfeld.
|
||||||
|
- Standard sind die SKR-03-Automatikkonten 3736/3731/3730 (erhaltene Skonti) und 8736/8731/8730 (gewährte Skonti). Abweichende Konten trägst du unter **Firmeneinstellungen → Rechnung & Kontakt → Skontokonten** ein.
|
||||||
|
- Skonto über 10 % des Belegs, auf Belege nach §13b UStG oder mit innergemeinschaftlichem Erwerb wird abgelehnt und muss manuell gebucht werden.
|
||||||
|
- Wird die Bankzuordnung gelöscht, entfällt auch der Skonto.
|
||||||
|
|
||||||
|
### Skontobedingungen auf Belegen
|
||||||
|
|
||||||
|
- **Ausgangsbelege:** Im Beleg-Editor Skontosatz und Frist in Tagen eintragen. Kunden können unter *Skonto in %* und *Skontofrist in Tagen* eine Vorgabe haben, die bei der Kundenauswahl übernommen wird. Im PDF steht automatisch ein Satz wie „Bei Zahlung bis zum 14.11.2026 gewähren wir 2 % Skonto (23,80 €), Zahlbetrag 1.166,20 €.“ vor dem Endtext. Wer den Satz selbst formulieren will, nutzt in der Textvorlage `{{skonto_hinweis}}`, `{{skonto_prozent}}`, `{{skonto_datum}}`, `{{skonto_betrag}}` oder `{{zahlbetrag_mit_skonto}}`; dann entfällt der automatische Satz. Bei Schlussrechnungen bezieht sich der Skonto auf die Restforderung nach Abzug der Abschläge.
|
||||||
|
- **Eingangsbelege:** Skontosatz und letzter Skontotag werden aus E-Rechnungen (`#SKONTO#TAGE=…#PROZENT=…#` bzw. ZUGFeRD-Skontobedingungen) und, wenn ausdrücklich genannt, von der KI-Vorbereitung übernommen. Lieferanten können eine Vorgabe haben, die bei der Lieferantenauswahl eingesetzt wird.
|
||||||
|
- **Vorschläge:** Entspricht eine Zahlung dem offenen Betrag abzüglich des vereinbarten Skontos und liegt sie in der Frist, zeigt die Bankzuordnung „Passt mit 2 % Skonto“. In der Vorschlagsübersicht lässt sich die Zahlung dann direkt mit Skonto zuweisen.
|
||||||
|
|
||||||
## Gute Praxis
|
## Gute Praxis
|
||||||
|
|
||||||
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.
|
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.
|
||||||
|
|||||||
@@ -36,6 +36,23 @@ Vor dem Buchen werden Netto-, Steuer- und Bruttobetrag angezeigt. Beispielsweise
|
|||||||
|
|
||||||
Der DATEV-Export übergibt den Bruttobetrag mit dem gewählten BU-Schlüssel (9/8 für Vorsteuer, 3/2 für Umsatzsteuer) und die Kostenstellennummer in **KOST1**. „Ohne Umsatzsteuer“ verwendet Schlüssel 40, um auch eine Kontenautomatik aufzuheben. Bestehende Buchungen ohne Steuerauswahl werden nicht nachträglich umgerechnet. Die Zuordnung folgt den [DATEV-Steuerschlüsseln](https://www.datev.de/dnlexom/v2/content/files/st2286167563_de.pdf) und der [DATEV-Dokumentation zur Aufhebung der Automatik](https://apps.datev.de/help-center/api/knowledge-help/v1/content/entities/st2112778507_de.pdf?docId=0907888&save=False).
|
Der DATEV-Export übergibt den Bruttobetrag mit dem gewählten BU-Schlüssel (9/8 für Vorsteuer, 3/2 für Umsatzsteuer) und die Kostenstellennummer in **KOST1**. „Ohne Umsatzsteuer“ verwendet Schlüssel 40, um auch eine Kontenautomatik aufzuheben. Bestehende Buchungen ohne Steuerauswahl werden nicht nachträglich umgerechnet. Die Zuordnung folgt den [DATEV-Steuerschlüsseln](https://www.datev.de/dnlexom/v2/content/files/st2286167563_de.pdf) und der [DATEV-Dokumentation zur Aufhebung der Automatik](https://apps.datev.de/help-center/api/knowledge-help/v1/content/entities/st2112778507_de.pdf?docId=0907888&save=False).
|
||||||
|
|
||||||
|
## Zahlung mit Skonto ausgleichen
|
||||||
|
|
||||||
|
Zahlt ein Kunde weniger als den offenen Betrag oder ziehst du bei einem Lieferanten Skonto ab, öffne den Bankumsatz und wähle beim Beleg **Mit Skonto ausgleichen** (Prozent-Symbol). Der Vorschlag ist die Differenz zwischen offenem Betrag und Zahlung. Zahlung und Skonto gleichen den Beleg gemeinsam aus; der Bankumsatz enthält weiterhin nur den gezahlten Betrag.
|
||||||
|
|
||||||
|
- Der Skonto wird anteilig nach den Steuersätzen des Belegs verteilt und mindert die Umsatzsteuer (Ausgangsbelege) bzw. Vorsteuer (Eingangsbelege) im Zeitraum der Zahlung.
|
||||||
|
- Bei Schlussrechnungen bleiben die Abschläge mit ihren ursprünglich berechneten Beträgen abgezogen. Skonto auf eine Abschlagsrechnung wird bei deren Zahlung gebucht, Skonto auf die Schlussrechnung bezieht sich auf die Restforderung.
|
||||||
|
- Der DATEV-Export erzeugt je Steuersatz eine eigene Buchung vom Skontokonto gegen Debitor bzw. Kreditor mit der Rechnungsnummer als Belegfeld.
|
||||||
|
- Standard sind die SKR-03-Automatikkonten 3736/3731/3730 (erhaltene Skonti) und 8736/8731/8730 (gewährte Skonti). Abweichende Konten trägst du unter **Firmeneinstellungen → Rechnung & Kontakt → Skontokonten** ein.
|
||||||
|
- Skonto über 10 % des Belegs, auf Belege nach §13b UStG oder mit innergemeinschaftlichem Erwerb wird abgelehnt und muss manuell gebucht werden.
|
||||||
|
- Wird die Bankzuordnung gelöscht, entfällt auch der Skonto.
|
||||||
|
|
||||||
|
### Skontobedingungen auf Belegen
|
||||||
|
|
||||||
|
- **Ausgangsbelege:** Im Beleg-Editor Skontosatz und Frist in Tagen eintragen. Kunden können unter *Skonto in %* und *Skontofrist in Tagen* eine Vorgabe haben, die bei der Kundenauswahl übernommen wird. Im PDF steht automatisch ein Satz wie „Bei Zahlung bis zum 14.11.2026 gewähren wir 2 % Skonto (23,80 €), Zahlbetrag 1.166,20 €.“ vor dem Endtext. Wer den Satz selbst formulieren will, nutzt in der Textvorlage `{{skonto_hinweis}}`, `{{skonto_prozent}}`, `{{skonto_datum}}`, `{{skonto_betrag}}` oder `{{zahlbetrag_mit_skonto}}`; dann entfällt der automatische Satz. Bei Schlussrechnungen bezieht sich der Skonto auf die Restforderung nach Abzug der Abschläge.
|
||||||
|
- **Eingangsbelege:** Skontosatz und letzter Skontotag werden aus E-Rechnungen (`#SKONTO#TAGE=…#PROZENT=…#` bzw. ZUGFeRD-Skontobedingungen) und, wenn ausdrücklich genannt, von der KI-Vorbereitung übernommen. Lieferanten können eine Vorgabe haben, die bei der Lieferantenauswahl eingesetzt wird.
|
||||||
|
- **Vorschläge:** Entspricht eine Zahlung dem offenen Betrag abzüglich des vereinbarten Skontos und liegt sie in der Frist, zeigt die Bankzuordnung „Passt mit 2 % Skonto“. In der Vorschlagsübersicht lässt sich die Zahlung dann direkt mit Skonto zuweisen.
|
||||||
|
|
||||||
## Gute Praxis
|
## Gute Praxis
|
||||||
|
|
||||||
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.
|
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import { getBankAllocationTaxBreakdown } from "~/utils/bankAllocationTax"
|
import { getBankAllocationTaxBreakdown } from "~/utils/bankAllocationTax"
|
||||||
|
import { getCashDiscountNetTotals } from "~/utils/cashDiscount"
|
||||||
import {
|
import {
|
||||||
getCreatedDocumentTaxBreakdown,
|
getCreatedDocumentTaxBreakdown,
|
||||||
getIncomingInvoiceTaxBreakdown
|
getIncomingInvoiceTaxBreakdown
|
||||||
@@ -90,7 +91,7 @@ const loadSummary = async () => {
|
|||||||
return touchesAccount && date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day")
|
return touchesAccount && date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day")
|
||||||
})
|
})
|
||||||
|
|
||||||
const income = outputDocs.reduce((sum: number, doc: any) => {
|
const documentIncome = outputDocs.reduce((sum: number, doc: any) => {
|
||||||
return sum + (doc.rows || []).reduce((rowSum: number, row: any) => {
|
return sum + (doc.rows || []).reduce((rowSum: number, row: any) => {
|
||||||
if (!row || ["pagebreak", "title", "text"].includes(row.mode)) {
|
if (!row || ["pagebreak", "title", "text"].includes(row.mode)) {
|
||||||
return rowSum
|
return rowSum
|
||||||
@@ -132,7 +133,14 @@ const loadSummary = async () => {
|
|||||||
const { output, input } = getBankAllocationTaxBreakdown(allocation)
|
const { output, input } = getBankAllocationTaxBreakdown(allocation)
|
||||||
return sum + output.tax19 + output.tax7 - input.tax19 - input.tax7
|
return sum + output.tax19 + output.tax7 - input.tax19 - input.tax7
|
||||||
}, 0)
|
}, 0)
|
||||||
const expenses = invoiceExpenses + directAccountExpenses + depreciations
|
// Skonto wirkt im Monat der Zahlung auf Erlöse und Aufwand.
|
||||||
|
const cashDiscountTotals = getCashDiscountNetTotals((allocations || []).filter((allocation: any) => {
|
||||||
|
const date = dayjs(allocation.bankstatement?.date || allocation.bankstatement?.valueDate)
|
||||||
|
return date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day")
|
||||||
|
}))
|
||||||
|
const expenses = invoiceExpenses + directAccountExpenses + depreciations - cashDiscountTotals.receivedNet
|
||||||
|
|
||||||
|
const income = documentIncome - cashDiscountTotals.grantedNet
|
||||||
|
|
||||||
summary.value = {
|
summary.value = {
|
||||||
label: dayjs().format("MMMM YYYY"),
|
label: dayjs().format("MMMM YYYY"),
|
||||||
|
|||||||
@@ -34,15 +34,19 @@ export const useFunctions = () => {
|
|||||||
return (await useNuxtApp().$api(`/api/banking/link/${institutionId}`)).link
|
return (await useNuxtApp().$api(`/api/banking/link/${institutionId}`)).link
|
||||||
}
|
}
|
||||||
|
|
||||||
const useCreatePDF = async (data,path,type) => {
|
// options.createdDocumentId: gespeicherter Beleg, aus dem das Backend die eingebettete E-Rechnung (ZUGFeRD) erzeugt
|
||||||
|
const useCreatePDF = async (data,path,type,options = {}) => {
|
||||||
const returnData = await useNuxtApp().$api(`/api/functions/pdf/${type}`, {
|
const returnData = await useNuxtApp().$api(`/api/functions/pdf/${type}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
data: data,
|
data: data,
|
||||||
backgroundPath: path,
|
backgroundPath: path,
|
||||||
|
...(options.createdDocumentId ? { createdDocumentId: options.createdDocumentId } : {}),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (options.onResult) options.onResult(returnData)
|
||||||
|
|
||||||
console.log(returnData)
|
console.log(returnData)
|
||||||
|
|
||||||
return `data:${returnData.mimeType};base64,${returnData.base64}`
|
return `data:${returnData.mimeType};base64,${returnData.base64}`
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { sumSettledAllocations } from "~/utils/cashDiscount"
|
||||||
|
|
||||||
export const useSum = () => {
|
export const useSum = () => {
|
||||||
const unwrapCreatedDocuments = (createddocuments = []) => {
|
const unwrapCreatedDocuments = (createddocuments = []) => {
|
||||||
@@ -83,8 +84,8 @@ export const useSum = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getCreatedDocumentOpenAmount = (createddocument, createddocuments = []) => {
|
const getCreatedDocumentOpenAmount = (createddocument, createddocuments = []) => {
|
||||||
let amountPaid = 0
|
// Gebuchter Skonto gleicht den Beleg zusammen mit der Zahlung aus.
|
||||||
createddocument.statementallocations.forEach(allocation => amountPaid += allocation.amount)
|
const amountPaid = sumSettledAllocations(createddocument.statementallocations || [])
|
||||||
|
|
||||||
return Number((getCreatedDocumentSum(createddocument, createddocuments) - amountPaid).toFixed(2))
|
return Number((getCreatedDocumentSum(createddocument, createddocuments) - amountPaid).toFixed(2))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { getCashDiscountNetTotals } from "~/utils/cashDiscount"
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import { getBankAllocationAmounts, getBankAllocationTaxBreakdown } from "~/utils/bankAllocationTax"
|
import { getBankAllocationAmounts, getBankAllocationTaxBreakdown } from "~/utils/bankAllocationTax"
|
||||||
import {
|
import {
|
||||||
@@ -183,8 +184,12 @@ const selectedPeriodBounds = computed(() => {
|
|||||||
return { start, end }
|
return { start, end }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Skonto wirkt im Zeitraum der Zahlung: gewährter Skonto mindert Erlöse, erhaltener Skonto den Aufwand.
|
||||||
|
const cashDiscountTotals = computed(() => getCashDiscountNetTotals(filteredStatementAllocations.value))
|
||||||
|
|
||||||
const incomeTotal = computed(() => {
|
const incomeTotal = computed(() => {
|
||||||
return Number(filteredDocuments.value.reduce((sum, doc) => sum + computeDocumentNet(doc), 0).toFixed(2))
|
const documents = filteredDocuments.value.reduce((sum, doc) => sum + computeDocumentNet(doc), 0)
|
||||||
|
return Number((documents - cashDiscountTotals.value.grantedNet).toFixed(2))
|
||||||
})
|
})
|
||||||
|
|
||||||
const expenseNetTotal = computed(() => {
|
const expenseNetTotal = computed(() => {
|
||||||
@@ -196,7 +201,7 @@ const expenseNetTotal = computed(() => {
|
|||||||
|
|
||||||
const depreciations = depreciationTotal.value
|
const depreciations = depreciationTotal.value
|
||||||
|
|
||||||
return Number((invoiceExpenses + directAccountExpenses + depreciations).toFixed(2))
|
return Number((invoiceExpenses + directAccountExpenses + depreciations - cashDiscountTotals.value.receivedNet).toFixed(2))
|
||||||
})
|
})
|
||||||
|
|
||||||
const expenseGrossTotal = computed(() => {
|
const expenseGrossTotal = computed(() => {
|
||||||
@@ -206,7 +211,7 @@ const expenseGrossTotal = computed(() => {
|
|||||||
|
|
||||||
const depreciations = depreciationTotal.value
|
const depreciations = depreciationTotal.value
|
||||||
|
|
||||||
return Number((invoiceExpenses + directAccountExpenses + depreciations).toFixed(2))
|
return Number((invoiceExpenses + directAccountExpenses + depreciations - cashDiscountTotals.value.receivedGross).toFixed(2))
|
||||||
})
|
})
|
||||||
|
|
||||||
const taxSummary = computed(() => {
|
const taxSummary = computed(() => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import { getBankAllocationTaxBreakdown, isDirectBankAllocation } from "~/utils/bankAllocationTax"
|
import { getBankAllocationTaxBreakdown, isDirectBankAllocation } from "~/utils/bankAllocationTax"
|
||||||
|
import { hasCashDiscount } from "~/utils/cashDiscount"
|
||||||
import customParseFormat from "dayjs/plugin/customParseFormat"
|
import customParseFormat from "dayjs/plugin/customParseFormat"
|
||||||
import {
|
import {
|
||||||
formatTaxEvaluationPeriodLabel,
|
formatTaxEvaluationPeriodLabel,
|
||||||
@@ -54,7 +55,7 @@ const loadData = async () => {
|
|||||||
createdDocuments.value = (docs || []).filter(isRelevantOutputDocument)
|
createdDocuments.value = (docs || []).filter(isRelevantOutputDocument)
|
||||||
incomingInvoices.value = (incoming || []).filter(isRelevantInputInvoice)
|
incomingInvoices.value = (incoming || []).filter(isRelevantInputInvoice)
|
||||||
manualBookings.value = (manual as any[]) || []
|
manualBookings.value = (manual as any[]) || []
|
||||||
bankAllocations.value = (allocations || []).filter(isDirectBankAllocation)
|
bankAllocations.value = (allocations || []).filter((allocation) => isDirectBankAllocation(allocation) || hasCashDiscount(allocation))
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { matchCashDiscountPayment, sumSettledAllocations } from "~/utils/cashDiscount"
|
||||||
import { parseDate } from "@internationalized/date"
|
import { parseDate } from "@internationalized/date"
|
||||||
|
|
||||||
const {$api, $dayjs} = useNuxtApp()
|
const {$api, $dayjs} = useNuxtApp()
|
||||||
@@ -103,12 +104,12 @@ const loadSuggestionData = async () => {
|
|||||||
.map(i => ({
|
.map(i => ({
|
||||||
...i,
|
...i,
|
||||||
docTotal: useSum().getCreatedDocumentSum(i, createddocuments.value),
|
docTotal: useSum().getCreatedDocumentSum(i, createddocuments.value),
|
||||||
statementTotal: Number(i.statementallocations.reduce((n, {amount}) => n + amount, 0)),
|
statementTotal: Number(sumSettledAllocations(i.statementallocations)),
|
||||||
openSum: useSum().getCreatedDocumentOpenAmount(i, createddocuments.value).toFixed(2)
|
openSum: useSum().getCreatedDocumentOpenAmount(i, createddocuments.value).toFixed(2)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
openIncomingInvoices.value = invoiceItems
|
openIncomingInvoices.value = invoiceItems
|
||||||
.filter(i => i.state === "Gebucht" && !i.archived && i.statementallocations.reduce((n, {amount}) => n + amount, 0).toFixed(2) !== getInvoiceSum(i, false))
|
.filter(i => i.state === "Gebucht" && !i.archived && sumSettledAllocations(i.statementallocations).toFixed(2) !== getInvoiceSum(i, false))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Vorschlagsdaten konnten nicht geladen werden:", err)
|
console.error("Vorschlagsdaten konnten nicht geladen werden:", err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -232,7 +233,7 @@ const calculateOpenSum = (statement) => {
|
|||||||
const getInvoiceSum = (invoice, onlyOpenSum) => {
|
const getInvoiceSum = (invoice, onlyOpenSum) => {
|
||||||
let sum = Number(useSum().getIncomingInvoiceSum(invoice))
|
let sum = Number(useSum().getIncomingInvoiceSum(invoice))
|
||||||
|
|
||||||
if (onlyOpenSum) sum = sum + Number(invoice.statementallocations.reduce((n, {amount}) => n + amount, 0))
|
if (onlyOpenSum) sum = sum + Number(sumSettledAllocations(invoice.statementallocations))
|
||||||
|
|
||||||
if (invoice.expense) {
|
if (invoice.expense) {
|
||||||
return (sum * -1).toFixed(2)
|
return (sum * -1).toFixed(2)
|
||||||
@@ -386,25 +387,52 @@ const getTopEntitySuggestion = (statement) => {
|
|||||||
return suggestions[0] || null
|
return suggestions[0] || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zahlung entspricht dem offenen Betrag abzüglich vereinbartem Skonto und liegt in der Frist.
|
||||||
|
const getCashDiscountMatch = (statement, kind, entity) => {
|
||||||
|
const paymentDate = getStatementDate(statement) ? $dayjs(getStatementDate(statement)).format("YYYY-MM-DD") : null
|
||||||
|
if (kind === "createddocument") {
|
||||||
|
const days = entity.cashDiscountDays
|
||||||
|
return matchCashDiscountPayment({
|
||||||
|
open: entity.openSum,
|
||||||
|
payment: calculateOpenSum(statement),
|
||||||
|
percent: entity.cashDiscountPercent,
|
||||||
|
dueDate: entity.documentDate && days !== null && days !== undefined && days !== ""
|
||||||
|
? $dayjs(entity.documentDate).add(Number(days), "day").format("YYYY-MM-DD")
|
||||||
|
: null,
|
||||||
|
paymentDate
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return matchCashDiscountPayment({
|
||||||
|
open: getInvoiceSum(entity, true),
|
||||||
|
payment: calculateOpenSum(statement),
|
||||||
|
percent: entity.cashDiscountPercent,
|
||||||
|
dueDate: entity.cashDiscountDueDate || null,
|
||||||
|
paymentDate
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const getDocumentSuggestionMeta = (statement, document, suggestion) => {
|
const getDocumentSuggestionMeta = (statement, document, suggestion) => {
|
||||||
let score = 0
|
let score = 0
|
||||||
const remaining = Math.abs(Number(calculateOpenSum(statement)))
|
const remaining = Math.abs(Number(calculateOpenSum(statement)))
|
||||||
const openSum = Math.abs(Number(document.openSum))
|
const openSum = Math.abs(Number(document.openSum))
|
||||||
const purposeMatches = getMatchingPurposeParts(statement.text, [document.documentNumber, document.title, document.description])
|
const purposeMatches = getMatchingPurposeParts(statement.text, [document.documentNumber, document.title, document.description])
|
||||||
const amountLabel = getAmountMatchLabel(openSum, remaining)
|
const cashDiscountMatch = getCashDiscountMatch(statement, "createddocument", document)
|
||||||
const withinTolerance = isWithinAmountTolerance(openSum, remaining)
|
const amountLabel = cashDiscountMatch
|
||||||
|
? `Passt mit ${String(cashDiscountMatch.percent).replace(".", ",")} % Skonto`
|
||||||
|
: getAmountMatchLabel(openSum, remaining)
|
||||||
|
const withinTolerance = cashDiscountMatch || isWithinAmountTolerance(openSum, remaining)
|
||||||
|
|
||||||
if (!withinTolerance) {
|
if (!withinTolerance) {
|
||||||
return { score: 0, amountLabel: null, purposeMatches: [] }
|
return { score: 0, amountLabel: null, purposeMatches: [], cashDiscountMatch: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (openSum === remaining) score += 100
|
if (openSum === remaining || cashDiscountMatch) score += 100
|
||||||
else score += Math.max(0, 60 - Math.abs(openSum - remaining))
|
else score += Math.max(0, 60 - Math.abs(openSum - remaining))
|
||||||
|
|
||||||
if (suggestion && document.customer?.id === suggestion.id) score += 80
|
if (suggestion && document.customer?.id === suggestion.id) score += 80
|
||||||
if (purposeMatches.length > 0) score += 50 + (purposeMatches.length * 10)
|
if (purposeMatches.length > 0) score += 50 + (purposeMatches.length * 10)
|
||||||
|
|
||||||
return { score, amountLabel, purposeMatches }
|
return { score, amountLabel, purposeMatches, cashDiscountMatch }
|
||||||
}
|
}
|
||||||
|
|
||||||
const getIncomingInvoiceSuggestionMeta = (statement, invoice, suggestion) => {
|
const getIncomingInvoiceSuggestionMeta = (statement, invoice, suggestion) => {
|
||||||
@@ -412,20 +440,23 @@ const getIncomingInvoiceSuggestionMeta = (statement, invoice, suggestion) => {
|
|||||||
const remaining = Math.abs(Number(calculateOpenSum(statement)))
|
const remaining = Math.abs(Number(calculateOpenSum(statement)))
|
||||||
const openSum = Math.abs(Number(getInvoiceSum(invoice, true)))
|
const openSum = Math.abs(Number(getInvoiceSum(invoice, true)))
|
||||||
const purposeMatches = getMatchingPurposeParts(statement.text, [invoice.reference, invoice.description])
|
const purposeMatches = getMatchingPurposeParts(statement.text, [invoice.reference, invoice.description])
|
||||||
const amountLabel = getAmountMatchLabel(openSum, remaining)
|
const cashDiscountMatch = getCashDiscountMatch(statement, "incominginvoice", invoice)
|
||||||
const withinTolerance = isWithinAmountTolerance(openSum, remaining)
|
const amountLabel = cashDiscountMatch
|
||||||
|
? `Passt mit ${String(cashDiscountMatch.percent).replace(".", ",")} % Skonto`
|
||||||
|
: getAmountMatchLabel(openSum, remaining)
|
||||||
|
const withinTolerance = cashDiscountMatch || isWithinAmountTolerance(openSum, remaining)
|
||||||
|
|
||||||
if (!withinTolerance) {
|
if (!withinTolerance) {
|
||||||
return { score: 0, amountLabel: null, purposeMatches: [] }
|
return { score: 0, amountLabel: null, purposeMatches: [], cashDiscountMatch: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (openSum === remaining) score += 100
|
if (openSum === remaining || cashDiscountMatch) score += 100
|
||||||
else score += Math.max(0, 60 - Math.abs(openSum - remaining))
|
else score += Math.max(0, 60 - Math.abs(openSum - remaining))
|
||||||
|
|
||||||
if (suggestion && invoice.vendor?.id === suggestion.id) score += 80
|
if (suggestion && invoice.vendor?.id === suggestion.id) score += 80
|
||||||
if (purposeMatches.length > 0) score += 50 + (purposeMatches.length * 10)
|
if (purposeMatches.length > 0) score += 50 + (purposeMatches.length * 10)
|
||||||
|
|
||||||
return { score, amountLabel, purposeMatches }
|
return { score, amountLabel, purposeMatches, cashDiscountMatch }
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRowSuggestions = (statement) => {
|
const getRowSuggestions = (statement) => {
|
||||||
@@ -434,7 +465,7 @@ const getRowSuggestions = (statement) => {
|
|||||||
const topDocument = (entitySuggestion?.type === "customer" ? openDocuments.value.filter(doc => doc.customer?.id === entitySuggestion.id) : openDocuments.value)
|
const topDocument = (entitySuggestion?.type === "customer" ? openDocuments.value.filter(doc => doc.customer?.id === entitySuggestion.id) : openDocuments.value)
|
||||||
.map(document => {
|
.map(document => {
|
||||||
const meta = getDocumentSuggestionMeta(statement, document, entitySuggestion?.type === "customer" ? entitySuggestion : null)
|
const meta = getDocumentSuggestionMeta(statement, document, entitySuggestion?.type === "customer" ? entitySuggestion : null)
|
||||||
return { ...document, suggestionScore: meta.score, suggestionAmountLabel: meta.amountLabel, suggestionPurposeMatches: meta.purposeMatches, suggestionKey: `document:${document.id}` }
|
return { ...document, suggestionScore: meta.score, suggestionAmountLabel: meta.amountLabel, suggestionPurposeMatches: meta.purposeMatches, suggestionCashDiscount: meta.cashDiscountMatch, suggestionKey: `document:${document.id}` }
|
||||||
})
|
})
|
||||||
.filter(document => !isSuggestionDismissed(statement.id, document.suggestionKey))
|
.filter(document => !isSuggestionDismissed(statement.id, document.suggestionKey))
|
||||||
.filter(document => document.suggestionScore >= 80 || document.suggestionPurposeMatches?.length > 0)
|
.filter(document => document.suggestionScore >= 80 || document.suggestionPurposeMatches?.length > 0)
|
||||||
@@ -443,7 +474,7 @@ const getRowSuggestions = (statement) => {
|
|||||||
const topIncomingInvoice = (entitySuggestion?.type === "vendor" ? openIncomingInvoices.value.filter(invoice => invoice.vendor?.id === entitySuggestion.id) : openIncomingInvoices.value)
|
const topIncomingInvoice = (entitySuggestion?.type === "vendor" ? openIncomingInvoices.value.filter(invoice => invoice.vendor?.id === entitySuggestion.id) : openIncomingInvoices.value)
|
||||||
.map(invoice => {
|
.map(invoice => {
|
||||||
const meta = getIncomingInvoiceSuggestionMeta(statement, invoice, entitySuggestion?.type === "vendor" ? entitySuggestion : null)
|
const meta = getIncomingInvoiceSuggestionMeta(statement, invoice, entitySuggestion?.type === "vendor" ? entitySuggestion : null)
|
||||||
return { ...invoice, suggestionScore: meta.score, suggestionAmountLabel: meta.amountLabel, suggestionPurposeMatches: meta.purposeMatches, suggestionKey: `invoice:${invoice.id}` }
|
return { ...invoice, suggestionScore: meta.score, suggestionAmountLabel: meta.amountLabel, suggestionPurposeMatches: meta.purposeMatches, suggestionCashDiscount: meta.cashDiscountMatch, suggestionKey: `invoice:${invoice.id}` }
|
||||||
})
|
})
|
||||||
.filter(invoice => !isSuggestionDismissed(statement.id, invoice.suggestionKey))
|
.filter(invoice => !isSuggestionDismissed(statement.id, invoice.suggestionKey))
|
||||||
.filter(invoice => invoice.suggestionScore >= 80 || invoice.suggestionPurposeMatches?.length > 0)
|
.filter(invoice => invoice.suggestionScore >= 80 || invoice.suggestionPurposeMatches?.length > 0)
|
||||||
@@ -551,6 +582,24 @@ const handleAssignIncomingInvoice = async (row, invoice, event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bucht die Zahlung zusammen mit dem vereinbarten Skonto, wenn Betrag und Frist zu den Skontobedingungen passen.
|
||||||
|
const handleAssignWithCashDiscount = async (row, kind, entity, event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
const match = entity.suggestionCashDiscount
|
||||||
|
if (!match) return
|
||||||
|
try {
|
||||||
|
await saveAllocation({
|
||||||
|
[kind]: entity.id,
|
||||||
|
bankstatement: row.id,
|
||||||
|
amount: Number(calculateOpenSum(row)),
|
||||||
|
cashDiscountAmount: match.discount,
|
||||||
|
description: "Automatischer Vorschlag mit Skonto"
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: "Skonto konnte nicht gebucht werden", description: err?.data?.error, color: "error" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const dismissSuggestion = (row, suggestionKey, event) => {
|
const dismissSuggestion = (row, suggestionKey, event) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
const bankingSettings = tempStore.settings?.banking || {}
|
const bankingSettings = tempStore.settings?.banking || {}
|
||||||
@@ -927,7 +976,15 @@ onMounted(() => {
|
|||||||
</UBadge>
|
</UBadge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<UButton size="sm" color="emerald" @click="handleAssignDocument(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topDocument, $event)">
|
<UButton
|
||||||
|
v-if="selectedSuggestionRow.suggestions.topDocument.suggestionCashDiscount"
|
||||||
|
size="sm"
|
||||||
|
color="warning"
|
||||||
|
@click="handleAssignWithCashDiscount(selectedSuggestionRow.row, 'createddocument', selectedSuggestionRow.suggestions.topDocument, $event)"
|
||||||
|
>
|
||||||
|
Mit Skonto zuweisen
|
||||||
|
</UButton>
|
||||||
|
<UButton v-else size="sm" color="emerald" @click="handleAssignDocument(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topDocument, $event)">
|
||||||
Zuweisen
|
Zuweisen
|
||||||
</UButton>
|
</UButton>
|
||||||
<UButton size="sm" color="gray" variant="ghost" @click="dismissSuggestion(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topDocument.suggestionKey, $event)">
|
<UButton size="sm" color="gray" variant="ghost" @click="dismissSuggestion(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topDocument.suggestionKey, $event)">
|
||||||
@@ -950,7 +1007,15 @@ onMounted(() => {
|
|||||||
</UBadge>
|
</UBadge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<UButton size="sm" color="error" @click="handleAssignIncomingInvoice(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topIncomingInvoice, $event)">
|
<UButton
|
||||||
|
v-if="selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionCashDiscount"
|
||||||
|
size="sm"
|
||||||
|
color="warning"
|
||||||
|
@click="handleAssignWithCashDiscount(selectedSuggestionRow.row, 'incominginvoice', selectedSuggestionRow.suggestions.topIncomingInvoice, $event)"
|
||||||
|
>
|
||||||
|
Mit Skonto zuweisen
|
||||||
|
</UButton>
|
||||||
|
<UButton v-else size="sm" color="error" @click="handleAssignIncomingInvoice(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topIncomingInvoice, $event)">
|
||||||
Zuweisen
|
Zuweisen
|
||||||
</UButton>
|
</UButton>
|
||||||
<UButton size="sm" color="gray" variant="ghost" @click="dismissSuggestion(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionKey, $event)">
|
<UButton size="sm" color="gray" variant="ghost" @click="dismissSuggestion(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionKey, $event)">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { BANK_ALLOCATION_TAX_ITEMS, getBankAllocationAmounts } from "~/utils/bankAllocationTax"
|
import { BANK_ALLOCATION_TAX_ITEMS, getBankAllocationAmounts } from "~/utils/bankAllocationTax"
|
||||||
|
import { matchCashDiscountPayment, sumSettledAllocations } from "~/utils/cashDiscount"
|
||||||
import {
|
import {
|
||||||
DEPRECIATION_METHOD_ITEMS,
|
DEPRECIATION_METHOD_ITEMS,
|
||||||
EXPENSE_BOOKING_MODE_ITEMS,
|
EXPENSE_BOOKING_MODE_ITEMS,
|
||||||
@@ -54,7 +55,7 @@ const rebuildDocumentLists = () => {
|
|||||||
return {
|
return {
|
||||||
...i,
|
...i,
|
||||||
docTotal: useSum().getCreatedDocumentSum(i, createddocuments.value),
|
docTotal: useSum().getCreatedDocumentSum(i, createddocuments.value),
|
||||||
statementTotal: Number(i.statementallocations.reduce((n, {amount}) => n + amount, 0)),
|
statementTotal: Number(sumSettledAllocations(i.statementallocations)),
|
||||||
openSum: useSum().getCreatedDocumentOpenAmount(i, createddocuments.value).toFixed(2)
|
openSum: useSum().getCreatedDocumentOpenAmount(i, createddocuments.value).toFixed(2)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -62,7 +63,7 @@ const rebuildDocumentLists = () => {
|
|||||||
allocatedDocuments.value = documents.filter(i => i.statementallocations.find(x => x.bankstatement === itemInfo.value.id))
|
allocatedDocuments.value = documents.filter(i => i.statementallocations.find(x => x.bankstatement === itemInfo.value.id))
|
||||||
allocatedIncomingInvoices.value = incominginvoices.value.filter(i => i.statementallocations.find(x => x.bankstatement === itemInfo.value.id))
|
allocatedIncomingInvoices.value = incominginvoices.value.filter(i => i.statementallocations.find(x => x.bankstatement === itemInfo.value.id))
|
||||||
openIncomingInvoices.value = incominginvoices.value.filter(i =>
|
openIncomingInvoices.value = incominginvoices.value.filter(i =>
|
||||||
!i.archived && i.statementallocations.reduce((n, {amount}) => n + amount, 0).toFixed(2) !== getInvoiceSum(i, false)
|
!i.archived && sumSettledAllocations(i.statementallocations).toFixed(2) !== getInvoiceSum(i, false)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +137,7 @@ const separateIBAN = (input) => {
|
|||||||
const getInvoiceSum = (invoice, onlyOpenSum) => {
|
const getInvoiceSum = (invoice, onlyOpenSum) => {
|
||||||
let sum = Number(useSum().getIncomingInvoiceSum(invoice))
|
let sum = Number(useSum().getIncomingInvoiceSum(invoice))
|
||||||
|
|
||||||
if (onlyOpenSum) sum = sum + Number(invoice.statementallocations.reduce((n, {amount}) => n + amount, 0))
|
if (onlyOpenSum) sum = sum + Number(sumSettledAllocations(invoice.statementallocations))
|
||||||
|
|
||||||
if (invoice.expense) {
|
if (invoice.expense) {
|
||||||
return (sum * -1).toFixed(2)
|
return (sum * -1).toFixed(2)
|
||||||
@@ -420,6 +421,30 @@ const isWithinAmountTolerance = (candidateSum, remaining) => {
|
|||||||
return difference / reference <= 0.1
|
return difference / reference <= 0.1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Erkennt Zahlungen, die genau dem offenen Betrag abzüglich vereinbartem Skonto entsprechen und in der Frist liegen.
|
||||||
|
const getCashDiscountMatch = (kind, entity) => {
|
||||||
|
const paymentDate = itemInfo.value?.date || itemInfo.value?.valueDate
|
||||||
|
if (kind === "createddocument") {
|
||||||
|
const days = entity.cashDiscountDays
|
||||||
|
return matchCashDiscountPayment({
|
||||||
|
open: entity.openSum,
|
||||||
|
payment: calculateOpenSum.value,
|
||||||
|
percent: entity.cashDiscountPercent,
|
||||||
|
dueDate: entity.documentDate && days !== null && days !== undefined && days !== ""
|
||||||
|
? dayjs(entity.documentDate).add(Number(days), "day").format("YYYY-MM-DD")
|
||||||
|
: null,
|
||||||
|
paymentDate: paymentDate ? dayjs(paymentDate).format("YYYY-MM-DD") : null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return matchCashDiscountPayment({
|
||||||
|
open: getInvoiceSum(entity, true),
|
||||||
|
payment: calculateOpenSum.value,
|
||||||
|
percent: entity.cashDiscountPercent,
|
||||||
|
dueDate: entity.cashDiscountDueDate || null,
|
||||||
|
paymentDate: paymentDate ? dayjs(paymentDate).format("YYYY-MM-DD") : null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const getDocumentSuggestionMeta = (document, suggestion) => {
|
const getDocumentSuggestionMeta = (document, suggestion) => {
|
||||||
let score = 0
|
let score = 0
|
||||||
const remaining = Math.abs(Number(calculateOpenSum.value))
|
const remaining = Math.abs(Number(calculateOpenSum.value))
|
||||||
@@ -429,18 +454,22 @@ const getDocumentSuggestionMeta = (document, suggestion) => {
|
|||||||
document.title,
|
document.title,
|
||||||
document.description
|
document.description
|
||||||
])
|
])
|
||||||
const amountLabel = getAmountMatchLabel(openSum, remaining)
|
const cashDiscountMatch = getCashDiscountMatch("createddocument", document)
|
||||||
const withinTolerance = isWithinAmountTolerance(openSum, remaining)
|
const amountLabel = cashDiscountMatch
|
||||||
|
? `Passt mit ${String(cashDiscountMatch.percent).replace(".", ",")} % Skonto`
|
||||||
|
: getAmountMatchLabel(openSum, remaining)
|
||||||
|
const withinTolerance = cashDiscountMatch || isWithinAmountTolerance(openSum, remaining)
|
||||||
|
|
||||||
if (!withinTolerance) {
|
if (!withinTolerance) {
|
||||||
return {
|
return {
|
||||||
score: 0,
|
score: 0,
|
||||||
purposeMatches: [],
|
purposeMatches: [],
|
||||||
amountLabel: null
|
amountLabel: null,
|
||||||
|
cashDiscountMatch: null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (openSum === remaining) score += 100
|
if (openSum === remaining || cashDiscountMatch) score += 100
|
||||||
else score += Math.max(0, 60 - Math.abs(openSum - remaining))
|
else score += Math.max(0, 60 - Math.abs(openSum - remaining))
|
||||||
|
|
||||||
if (suggestion && document.customer?.id === suggestion.id) score += 80
|
if (suggestion && document.customer?.id === suggestion.id) score += 80
|
||||||
@@ -449,7 +478,8 @@ const getDocumentSuggestionMeta = (document, suggestion) => {
|
|||||||
return {
|
return {
|
||||||
score,
|
score,
|
||||||
purposeMatches,
|
purposeMatches,
|
||||||
amountLabel
|
amountLabel,
|
||||||
|
cashDiscountMatch
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,18 +491,22 @@ const getInvoiceSuggestionMeta = (invoice, suggestion) => {
|
|||||||
invoice.reference,
|
invoice.reference,
|
||||||
invoice.description
|
invoice.description
|
||||||
])
|
])
|
||||||
const amountLabel = getAmountMatchLabel(openSum, remaining)
|
const cashDiscountMatch = getCashDiscountMatch("incominginvoice", invoice)
|
||||||
const withinTolerance = isWithinAmountTolerance(openSum, remaining)
|
const amountLabel = cashDiscountMatch
|
||||||
|
? `Passt mit ${String(cashDiscountMatch.percent).replace(".", ",")} % Skonto`
|
||||||
|
: getAmountMatchLabel(openSum, remaining)
|
||||||
|
const withinTolerance = cashDiscountMatch || isWithinAmountTolerance(openSum, remaining)
|
||||||
|
|
||||||
if (!withinTolerance) {
|
if (!withinTolerance) {
|
||||||
return {
|
return {
|
||||||
score: 0,
|
score: 0,
|
||||||
purposeMatches: [],
|
purposeMatches: [],
|
||||||
amountLabel: null
|
amountLabel: null,
|
||||||
|
cashDiscountMatch: null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (openSum === remaining) score += 100
|
if (openSum === remaining || cashDiscountMatch) score += 100
|
||||||
else score += Math.max(0, 60 - Math.abs(openSum - remaining))
|
else score += Math.max(0, 60 - Math.abs(openSum - remaining))
|
||||||
|
|
||||||
if (suggestion && invoice.vendor?.id === suggestion.id) score += 80
|
if (suggestion && invoice.vendor?.id === suggestion.id) score += 80
|
||||||
@@ -481,7 +515,8 @@ const getInvoiceSuggestionMeta = (invoice, suggestion) => {
|
|||||||
return {
|
return {
|
||||||
score,
|
score,
|
||||||
purposeMatches,
|
purposeMatches,
|
||||||
amountLabel
|
amountLabel,
|
||||||
|
cashDiscountMatch
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,6 +532,7 @@ const suggestedDocuments = computed(() => {
|
|||||||
suggestionScore: meta.score,
|
suggestionScore: meta.score,
|
||||||
suggestionPurposeMatches: meta.purposeMatches,
|
suggestionPurposeMatches: meta.purposeMatches,
|
||||||
suggestionAmountLabel: meta.amountLabel,
|
suggestionAmountLabel: meta.amountLabel,
|
||||||
|
suggestionCashDiscount: meta.cashDiscountMatch,
|
||||||
suggestionKey: `document:${document.id}`
|
suggestionKey: `document:${document.id}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -517,6 +553,7 @@ const suggestedIncomingInvoices = computed(() => {
|
|||||||
suggestionScore: meta.score,
|
suggestionScore: meta.score,
|
||||||
suggestionPurposeMatches: meta.purposeMatches,
|
suggestionPurposeMatches: meta.purposeMatches,
|
||||||
suggestionAmountLabel: meta.amountLabel,
|
suggestionAmountLabel: meta.amountLabel,
|
||||||
|
suggestionCashDiscount: meta.cashDiscountMatch,
|
||||||
suggestionKey: `invoice:${invoice.id}`
|
suggestionKey: `invoice:${invoice.id}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -536,6 +573,7 @@ const fallbackSuggestedDocuments = computed(() => {
|
|||||||
suggestionScore: meta.score,
|
suggestionScore: meta.score,
|
||||||
suggestionPurposeMatches: meta.purposeMatches,
|
suggestionPurposeMatches: meta.purposeMatches,
|
||||||
suggestionAmountLabel: meta.amountLabel,
|
suggestionAmountLabel: meta.amountLabel,
|
||||||
|
suggestionCashDiscount: meta.cashDiscountMatch,
|
||||||
suggestionKey: `document:${document.id}`
|
suggestionKey: `document:${document.id}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -556,6 +594,7 @@ const fallbackSuggestedIncomingInvoices = computed(() => {
|
|||||||
suggestionScore: meta.score,
|
suggestionScore: meta.score,
|
||||||
suggestionPurposeMatches: meta.purposeMatches,
|
suggestionPurposeMatches: meta.purposeMatches,
|
||||||
suggestionAmountLabel: meta.amountLabel,
|
suggestionAmountLabel: meta.amountLabel,
|
||||||
|
suggestionCashDiscount: meta.cashDiscountMatch,
|
||||||
suggestionKey: `invoice:${invoice.id}`
|
suggestionKey: `invoice:${invoice.id}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -603,6 +642,76 @@ const getAllocationBookingModeLabel = (item) => {
|
|||||||
return "Sofortaufwand"
|
return "Sofortaufwand"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skonto: Die Zahlung gleicht zusammen mit dem Skonto den offenen Betrag des Belegs aus.
|
||||||
|
const cashDiscountModalOpen = ref(false)
|
||||||
|
const cashDiscountTarget = ref(null)
|
||||||
|
const cashDiscountInput = ref(0)
|
||||||
|
const savingCashDiscount = ref(false)
|
||||||
|
const cashDiscountError = ref("")
|
||||||
|
|
||||||
|
const getCashDiscountCandidate = (kind, entity) => {
|
||||||
|
const open = Number(kind === "createddocument" ? entity.openSum : getInvoiceSum(entity, true))
|
||||||
|
const payment = Number(manualAllocationSum.value)
|
||||||
|
if (!open || !payment || Math.sign(open) !== Math.sign(payment)) return null
|
||||||
|
if (Math.abs(payment) >= Math.abs(open) - 0.004) return null
|
||||||
|
return { open, payment, discount: Number((open - payment).toFixed(2)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const canBookCashDiscount = (kind, entity) => Boolean(getCashDiscountCandidate(kind, entity))
|
||||||
|
|
||||||
|
const openCashDiscountModal = (kind, entity) => {
|
||||||
|
const candidate = getCashDiscountCandidate(kind, entity)
|
||||||
|
if (!candidate) return
|
||||||
|
cashDiscountTarget.value = {
|
||||||
|
kind,
|
||||||
|
id: entity.id,
|
||||||
|
label: kind === "createddocument"
|
||||||
|
? `${entity.documentNumber} (${entity.customer?.name || "Ohne Kunde"})`
|
||||||
|
: `${entity.reference || "Eingangsbeleg"} (${entity.vendor?.name || "Ohne Lieferant"})`,
|
||||||
|
...candidate
|
||||||
|
}
|
||||||
|
cashDiscountInput.value = Math.abs(candidate.discount)
|
||||||
|
cashDiscountError.value = ""
|
||||||
|
cashDiscountModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const cashDiscountPercent = computed(() => {
|
||||||
|
const target = cashDiscountTarget.value
|
||||||
|
if (!target || !target.open) return 0
|
||||||
|
return Math.abs(Number(cashDiscountInput.value || 0)) / Math.abs(target.open) * 100
|
||||||
|
})
|
||||||
|
|
||||||
|
const cashDiscountRemaining = computed(() => {
|
||||||
|
const target = cashDiscountTarget.value
|
||||||
|
if (!target) return 0
|
||||||
|
return Number((Math.abs(target.open) - Math.abs(target.payment) - Math.abs(Number(cashDiscountInput.value || 0))).toFixed(2))
|
||||||
|
})
|
||||||
|
|
||||||
|
const confirmCashDiscount = async () => {
|
||||||
|
const target = cashDiscountTarget.value
|
||||||
|
if (!target || savingCashDiscount.value) return
|
||||||
|
savingCashDiscount.value = true
|
||||||
|
try {
|
||||||
|
await saveAllocation({
|
||||||
|
[target.kind]: target.id,
|
||||||
|
bankstatement: itemInfo.value.id,
|
||||||
|
amount: target.payment,
|
||||||
|
cashDiscountAmount: Math.abs(Number(cashDiscountInput.value || 0)),
|
||||||
|
description: allocationDescription.value || "Zahlung mit Skonto"
|
||||||
|
})
|
||||||
|
cashDiscountModalOpen.value = false
|
||||||
|
cashDiscountTarget.value = null
|
||||||
|
} catch (err) {
|
||||||
|
cashDiscountError.value = err?.data?.error || "Zahlung und Skonto konnten nicht gebucht werden."
|
||||||
|
} finally {
|
||||||
|
savingCashDiscount.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCashDiscountSplitLabel = (item) => (item.cashDiscountSplit || [])
|
||||||
|
.map(split => `${split.account} (${split.rate} %): ${displayCurrency(split.gross)}`)
|
||||||
|
.join(" · ")
|
||||||
|
|
||||||
setup()
|
setup()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -726,6 +835,9 @@ setup()
|
|||||||
{{ getAllocationTaxLabel(item) }} · Netto {{ displayCurrency(getBankAllocationAmounts(item).amountNet) }}
|
{{ getAllocationTaxLabel(item) }} · Netto {{ displayCurrency(getBankAllocationAmounts(item).amountNet) }}
|
||||||
· Steuer {{ displayCurrency(getBankAllocationAmounts(item).amountTax) }}
|
· Steuer {{ displayCurrency(getBankAllocationAmounts(item).amountTax) }}
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="Number(item.cashDiscountAmount)" class="text-xs text-amber-700 dark:text-amber-400 mt-1">
|
||||||
|
Skonto {{ displayCurrency(item.cashDiscountAmount) }}<span v-if="item.cashDiscountSplit?.length"> · {{ getCashDiscountSplitLabel(item) }}</span>
|
||||||
|
</div>
|
||||||
<div class="mt-1 flex flex-wrap gap-1" v-if="item.bookingMode && item.bookingMode !== 'expense'">
|
<div class="mt-1 flex flex-wrap gap-1" v-if="item.bookingMode && item.bookingMode !== 'expense'">
|
||||||
<UBadge size="xs" color="warning" variant="soft">{{ getAllocationBookingModeLabel(item) }}</UBadge>
|
<UBadge size="xs" color="warning" variant="soft">{{ getAllocationBookingModeLabel(item) }}</UBadge>
|
||||||
<UBadge size="xs" color="neutral" variant="soft" v-if="item.depreciationGroup">{{ item.depreciationGroup }}</UBadge>
|
<UBadge size="xs" color="neutral" variant="soft" v-if="item.depreciationGroup">{{ item.depreciationGroup }}</UBadge>
|
||||||
@@ -756,6 +868,9 @@ setup()
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<div class="font-mono text-sm font-semibold">{{ displayCurrency(item.amount) }}</div>
|
<div class="font-mono text-sm font-semibold">{{ displayCurrency(item.amount) }}</div>
|
||||||
|
<div v-if="Number(item.cashDiscountAmount)" class="font-mono text-xs text-amber-600">
|
||||||
|
+ {{ displayCurrency(item.cashDiscountAmount) }} Skonto
|
||||||
|
</div>
|
||||||
<UButton
|
<UButton
|
||||||
icon="i-heroicons-trash"
|
icon="i-heroicons-trash"
|
||||||
color="error"
|
color="error"
|
||||||
@@ -1003,6 +1118,15 @@ setup()
|
|||||||
>
|
>
|
||||||
Rechnung zuweisen
|
Rechnung zuweisen
|
||||||
</UButton>
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
v-if="canBookCashDiscount('createddocument', document)"
|
||||||
|
size="xs"
|
||||||
|
color="warning"
|
||||||
|
:variant="document.suggestionCashDiscount ? 'solid' : 'soft'"
|
||||||
|
@click="openCashDiscountModal('createddocument', document)"
|
||||||
|
>
|
||||||
|
Mit Skonto
|
||||||
|
</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
size="xs"
|
size="xs"
|
||||||
color="gray"
|
color="gray"
|
||||||
@@ -1037,6 +1161,15 @@ setup()
|
|||||||
>
|
>
|
||||||
Beleg zuweisen
|
Beleg zuweisen
|
||||||
</UButton>
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
v-if="canBookCashDiscount('incominginvoice', invoice)"
|
||||||
|
size="xs"
|
||||||
|
color="warning"
|
||||||
|
:variant="invoice.suggestionCashDiscount ? 'solid' : 'soft'"
|
||||||
|
@click="openCashDiscountModal('incominginvoice', invoice)"
|
||||||
|
>
|
||||||
|
Mit Skonto
|
||||||
|
</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
size="xs"
|
size="xs"
|
||||||
color="gray"
|
color="gray"
|
||||||
@@ -1087,6 +1220,7 @@ setup()
|
|||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<div class="font-mono font-bold text-sm">{{ displayCurrency(document.openSum) }}</div>
|
<div class="font-mono font-bold text-sm">{{ displayCurrency(document.openSum) }}</div>
|
||||||
<div class="text-xs text-gray-400">Offen</div>
|
<div class="text-xs text-gray-400">Offen</div>
|
||||||
|
<UBadge v-if="getCashDiscountMatch('createddocument', document)" size="xs" color="warning" variant="subtle">Skonto passt</UBadge>
|
||||||
</div>
|
</div>
|
||||||
<UTooltip text="Zuweisen">
|
<UTooltip text="Zuweisen">
|
||||||
<UButton
|
<UButton
|
||||||
@@ -1098,6 +1232,16 @@ setup()
|
|||||||
@click="saveAllocation({createddocument: document.id, bankstatement: itemInfo.id, amount: Number(Number(document.openSum) < manualAllocationSum ? document.openSum : manualAllocationSum), description: allocationDescription})"
|
@click="saveAllocation({createddocument: document.id, bankstatement: itemInfo.id, amount: Number(Number(document.openSum) < manualAllocationSum ? document.openSum : manualAllocationSum), description: allocationDescription})"
|
||||||
/>
|
/>
|
||||||
</UTooltip>
|
</UTooltip>
|
||||||
|
<UTooltip text="Mit Skonto ausgleichen">
|
||||||
|
<UButton
|
||||||
|
v-if="!itemInfo.statementallocations.find(i => i.createddocument === document.id) && canBookCashDiscount('createddocument', document)"
|
||||||
|
icon="i-heroicons-receipt-percent"
|
||||||
|
size="sm"
|
||||||
|
color="warning"
|
||||||
|
variant="soft"
|
||||||
|
@click="openCashDiscountModal('createddocument', document)"
|
||||||
|
/>
|
||||||
|
</UTooltip>
|
||||||
<UButton
|
<UButton
|
||||||
icon="i-heroicons-eye"
|
icon="i-heroicons-eye"
|
||||||
color="gray"
|
color="gray"
|
||||||
@@ -1146,6 +1290,7 @@ setup()
|
|||||||
{{ displayCurrency(getInvoiceSum(item, true)) }}
|
{{ displayCurrency(getInvoiceSum(item, true)) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-400">Offen</div>
|
<div class="text-xs text-gray-400">Offen</div>
|
||||||
|
<UBadge v-if="getCashDiscountMatch('incominginvoice', item)" size="xs" color="warning" variant="subtle">Skonto passt</UBadge>
|
||||||
</div>
|
</div>
|
||||||
<UTooltip text="Zuweisen">
|
<UTooltip text="Zuweisen">
|
||||||
<UButton
|
<UButton
|
||||||
@@ -1157,6 +1302,16 @@ setup()
|
|||||||
@click="saveAllocation({incominginvoice: item.id, bankstatement: itemInfo.id, amount: Number(Math.abs(getInvoiceSum(item,true)) > Math.abs(manualAllocationSum) ? manualAllocationSum : getInvoiceSum(item,true)), description: allocationDescription})"
|
@click="saveAllocation({incominginvoice: item.id, bankstatement: itemInfo.id, amount: Number(Math.abs(getInvoiceSum(item,true)) > Math.abs(manualAllocationSum) ? manualAllocationSum : getInvoiceSum(item,true)), description: allocationDescription})"
|
||||||
/>
|
/>
|
||||||
</UTooltip>
|
</UTooltip>
|
||||||
|
<UTooltip text="Mit Skonto ausgleichen">
|
||||||
|
<UButton
|
||||||
|
v-if="!itemInfo.statementallocations.find(i => i.incominginvoice === item.id) && canBookCashDiscount('incominginvoice', item)"
|
||||||
|
icon="i-heroicons-receipt-percent"
|
||||||
|
size="sm"
|
||||||
|
color="warning"
|
||||||
|
variant="soft"
|
||||||
|
@click="openCashDiscountModal('incominginvoice', item)"
|
||||||
|
/>
|
||||||
|
</UTooltip>
|
||||||
<UButton
|
<UButton
|
||||||
icon="i-heroicons-eye"
|
icon="i-heroicons-eye"
|
||||||
color="gray"
|
color="gray"
|
||||||
@@ -1180,6 +1335,70 @@ setup()
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</UDashboardPanelContent>
|
</UDashboardPanelContent>
|
||||||
|
|
||||||
|
<UModal v-model:open="cashDiscountModalOpen" :ui="{ width: 'sm:max-w-lg' }">
|
||||||
|
<template #content>
|
||||||
|
<UCard v-if="cashDiscountTarget">
|
||||||
|
<template #header>
|
||||||
|
<div class="text-lg font-semibold">Mit Skonto ausgleichen</div>
|
||||||
|
<div class="text-sm text-gray-500 mt-0.5">{{ cashDiscountTarget.label }}</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div class="text-gray-500">Offener Betrag</div>
|
||||||
|
<div class="text-right font-mono">{{ displayCurrency(Math.abs(cashDiscountTarget.open)) }}</div>
|
||||||
|
<div class="text-gray-500">Zahlung aus diesem Umsatz</div>
|
||||||
|
<div class="text-right font-mono">{{ displayCurrency(Math.abs(cashDiscountTarget.payment)) }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UFormField label="Skonto (brutto)" :help="`${cashDiscountPercent.toFixed(2).replace('.', ',')} % des offenen Betrags`">
|
||||||
|
<UInput v-model="cashDiscountInput" type="number" step="0.01" min="0">
|
||||||
|
<template #trailing><span class="text-gray-500 text-xs">EUR</span></template>
|
||||||
|
</UInput>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-if="cashDiscountPercent > 5"
|
||||||
|
color="warning"
|
||||||
|
variant="subtle"
|
||||||
|
icon="i-heroicons-exclamation-triangle"
|
||||||
|
title="Ungewöhnlich hoher Skonto"
|
||||||
|
description="Bitte prüfen, ob es sich wirklich um Skonto handelt und nicht um eine Teilzahlung."
|
||||||
|
/>
|
||||||
|
<UAlert
|
||||||
|
v-if="cashDiscountError"
|
||||||
|
color="error"
|
||||||
|
variant="subtle"
|
||||||
|
icon="i-heroicons-x-circle"
|
||||||
|
:description="cashDiscountError"
|
||||||
|
/>
|
||||||
|
<div v-if="Math.abs(cashDiscountRemaining) >= 0.01" class="text-sm text-amber-600">
|
||||||
|
Danach bleiben {{ displayCurrency(cashDiscountRemaining) }} offen.
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Der Skonto wird anteilig nach Steuersätzen auf die Skontokonten verteilt
|
||||||
|
({{ cashDiscountTarget.kind === 'createddocument' ? 'gewährte' : 'erhaltene' }} Skonti) und korrigiert die
|
||||||
|
{{ cashDiscountTarget.kind === 'createddocument' ? 'Umsatzsteuer' : 'Vorsteuer' }} im Monat der Zahlung.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<UButton color="neutral" variant="ghost" @click="cashDiscountModalOpen = false">Abbrechen</UButton>
|
||||||
|
<UButton
|
||||||
|
color="primary"
|
||||||
|
:loading="savingCashDiscount"
|
||||||
|
:disabled="!(Number(cashDiscountInput) > 0) || cashDiscountRemaining < -0.009"
|
||||||
|
@click="confirmCashDiscount"
|
||||||
|
>
|
||||||
|
Zahlung und Skonto buchen
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
import {useFunctions} from "~/composables/useFunctions.js";
|
import {useFunctions} from "~/composables/useFunctions.js";
|
||||||
import EntityModalButtons from "~/components/EntityModalButtons.vue";
|
import EntityModalButtons from "~/components/EntityModalButtons.vue";
|
||||||
import { documentTemplateHandlebars } from "~/utils/handlebars";
|
import { documentTemplateHandlebars } from "~/utils/handlebars";
|
||||||
|
import { getTitleSectionMove, moveTitleSection } from "~/utils/documentTitleSections.js";
|
||||||
|
import { appendCashDiscountText, buildCashDiscountTerms, formatCashDiscountText, getCashDiscountTemplateContext } from "~/utils/cashDiscount";
|
||||||
|
|
||||||
const dataStore = useDataStore()
|
const dataStore = useDataStore()
|
||||||
const profileStore = useProfileStore()
|
const profileStore = useProfileStore()
|
||||||
@@ -56,6 +58,8 @@ const itemInfo = ref({
|
|||||||
deliveryDateType: "Lieferdatum",
|
deliveryDateType: "Lieferdatum",
|
||||||
dateOfPerformance: null,
|
dateOfPerformance: null,
|
||||||
paymentDays: auth.activeTenantData.standardPaymentDays,
|
paymentDays: auth.activeTenantData.standardPaymentDays,
|
||||||
|
cashDiscountPercent: null,
|
||||||
|
cashDiscountDays: null,
|
||||||
payment_type: "transfer",
|
payment_type: "transfer",
|
||||||
outgoingsepamandate: null,
|
outgoingsepamandate: null,
|
||||||
availableInPortal: false,
|
availableInPortal: false,
|
||||||
@@ -634,7 +638,11 @@ const setupPage = async () => {
|
|||||||
if (optionsToImport.deliveryDate) itemInfo.value.deliveryDate = linkedDocument.deliveryDate
|
if (optionsToImport.deliveryDate) itemInfo.value.deliveryDate = linkedDocument.deliveryDate
|
||||||
if (optionsToImport.deliveryDateEnd) itemInfo.value.deliveryDateEnd = linkedDocument.deliveryDateEnd
|
if (optionsToImport.deliveryDateEnd) itemInfo.value.deliveryDateEnd = linkedDocument.deliveryDateEnd
|
||||||
if (optionsToImport.documentDate) itemInfo.value.documentDate = linkedDocument.documentDate
|
if (optionsToImport.documentDate) itemInfo.value.documentDate = linkedDocument.documentDate
|
||||||
if (optionsToImport.paymentDays) itemInfo.value.paymentDays = linkedDocument.paymentDays
|
if (optionsToImport.paymentDays) {
|
||||||
|
itemInfo.value.paymentDays = linkedDocument.paymentDays
|
||||||
|
itemInfo.value.cashDiscountPercent = linkedDocument.cashDiscountPercent ?? null
|
||||||
|
itemInfo.value.cashDiscountDays = linkedDocument.cashDiscountDays ?? null
|
||||||
|
}
|
||||||
if (optionsToImport.payment_type) itemInfo.value.payment_type = linkedDocument.payment_type
|
if (optionsToImport.payment_type) itemInfo.value.payment_type = linkedDocument.payment_type
|
||||||
if (optionsToImport.customSurchargePercentage) itemInfo.value.customSurchargePercentage = linkedDocument.customSurchargePercentage
|
if (optionsToImport.customSurchargePercentage) itemInfo.value.customSurchargePercentage = linkedDocument.customSurchargePercentage
|
||||||
if (optionsToImport.contactPerson) itemInfo.value.contactPerson = linkedDocument.contactPerson
|
if (optionsToImport.contactPerson) itemInfo.value.contactPerson = linkedDocument.contactPerson
|
||||||
@@ -662,6 +670,8 @@ const setupPage = async () => {
|
|||||||
itemInfo.value.deliveryDateEnd = linkedDocument.deliveryDateEnd
|
itemInfo.value.deliveryDateEnd = linkedDocument.deliveryDateEnd
|
||||||
itemInfo.value.documentDate = linkedDocument.documentDate
|
itemInfo.value.documentDate = linkedDocument.documentDate
|
||||||
itemInfo.value.paymentDays = linkedDocument.paymentDays
|
itemInfo.value.paymentDays = linkedDocument.paymentDays
|
||||||
|
itemInfo.value.cashDiscountPercent = linkedDocument.cashDiscountPercent ?? null
|
||||||
|
itemInfo.value.cashDiscountDays = linkedDocument.cashDiscountDays ?? null
|
||||||
itemInfo.value.payment_type = linkedDocument.payment_type
|
itemInfo.value.payment_type = linkedDocument.payment_type
|
||||||
itemInfo.value.customSurchargePercentage = linkedDocument.customSurchargePercentage
|
itemInfo.value.customSurchargePercentage = linkedDocument.customSurchargePercentage
|
||||||
itemInfo.value.contactPerson = linkedDocument.contactPerson
|
itemInfo.value.contactPerson = linkedDocument.contactPerson
|
||||||
@@ -832,6 +842,10 @@ const setCustomerData = async (customerId, loadOnlyAdress = false) => {
|
|||||||
itemInfo.value.address.country = customer.infoData.country
|
itemInfo.value.address.country = customer.infoData.country
|
||||||
|
|
||||||
if (!loadOnlyAdress && customer.customPaymentDays) itemInfo.value.paymentDays = customer.customPaymentDays
|
if (!loadOnlyAdress && customer.customPaymentDays) itemInfo.value.paymentDays = customer.customPaymentDays
|
||||||
|
if (!loadOnlyAdress && customer.customCashDiscountPercent) {
|
||||||
|
itemInfo.value.cashDiscountPercent = customer.customCashDiscountPercent
|
||||||
|
itemInfo.value.cashDiscountDays = customer.customCashDiscountDays ?? null
|
||||||
|
}
|
||||||
if (!loadOnlyAdress && customer.custom_payment_type) itemInfo.value.payment_type = customer.custom_payment_type
|
if (!loadOnlyAdress && customer.custom_payment_type) itemInfo.value.payment_type = customer.custom_payment_type
|
||||||
if (!loadOnlyAdress) {
|
if (!loadOnlyAdress) {
|
||||||
itemInfo.value.taxType = normalizeTaxTypeValue(customer.customTaxType)
|
itemInfo.value.taxType = normalizeTaxTypeValue(customer.customTaxType)
|
||||||
@@ -1017,6 +1031,24 @@ const toggleTitleSection = (rowId) => {
|
|||||||
collapsedTitleRowIds.value = nextCollapsedTitleRowIds
|
collapsedTitleRowIds.value = nextCollapsedTitleRowIds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const moveDocumentTitleSection = (rowId, direction) => {
|
||||||
|
if (itemInfo.value.type === 'cancellationInvoices') return
|
||||||
|
const rows = moveTitleSection(itemInfo.value.rows, rowId, direction)
|
||||||
|
if (rows === itemInfo.value.rows) return
|
||||||
|
itemInfo.value.rows = rows
|
||||||
|
setPosNumbers()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTitleSectionMoveMenuItems = (rowId) => [
|
||||||
|
{ label: 'Titelbereich nach oben', icon: 'i-heroicons-arrow-up', direction: 'up' },
|
||||||
|
{ label: 'Titelbereich nach unten', icon: 'i-heroicons-arrow-down', direction: 'down' }
|
||||||
|
].map(({ direction, ...item }) => ({
|
||||||
|
...item,
|
||||||
|
disabled: itemInfo.value.type === 'cancellationInvoices'
|
||||||
|
|| !getTitleSectionMove(itemInfo.value.rows, rowId, direction),
|
||||||
|
onSelect: () => moveDocumentTitleSection(rowId, direction)
|
||||||
|
}))
|
||||||
|
|
||||||
const hiddenDocumentRowIds = computed(() => {
|
const hiddenDocumentRowIds = computed(() => {
|
||||||
const hiddenRowIds = new Set()
|
const hiddenRowIds = new Set()
|
||||||
const collapsedTitleLevels = []
|
const collapsedTitleLevels = []
|
||||||
@@ -1099,8 +1131,54 @@ const checkAddressZip = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pflichtangaben der E-Rechnung (ZUGFeRD) prüft das Backend, da Firmen- und Kundendaten dort vollständig vorliegen
|
||||||
|
const eInvoiceDocumentTypes = ["invoices", "advanceInvoices", "cancellationInvoices", "serialInvoices"]
|
||||||
|
const eInvoiceIssues = ref([])
|
||||||
|
const eInvoiceCheckSource = computed(() => {
|
||||||
|
if (!loaded.value || isTemplateMode.value || !eInvoiceDocumentTypes.includes(itemInfo.value.type)) return null
|
||||||
|
const item = itemInfo.value
|
||||||
|
return JSON.stringify({
|
||||||
|
type: item.type,
|
||||||
|
customer: item.customer,
|
||||||
|
contact: item.contact,
|
||||||
|
project: item.project,
|
||||||
|
contract: item.contract,
|
||||||
|
address: item.address,
|
||||||
|
rows: item.rows,
|
||||||
|
taxType: item.taxType,
|
||||||
|
payment_type: item.payment_type,
|
||||||
|
outgoingsepamandate: item.outgoingsepamandate,
|
||||||
|
deliveryDateType: item.deliveryDateType,
|
||||||
|
usedAdvanceInvoices: item.usedAdvanceInvoices,
|
||||||
|
createddocument: item.createddocument,
|
||||||
|
created_by: item.created_by,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const refreshEInvoiceIssues = async () => {
|
||||||
|
const source = eInvoiceCheckSource.value
|
||||||
|
if (!source) {
|
||||||
|
eInvoiceIssues.value = []
|
||||||
|
return eInvoiceIssues.value
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await useNuxtApp().$api("/api/functions/einvoice/check", {
|
||||||
|
method: "POST",
|
||||||
|
body: { document: JSON.parse(source) },
|
||||||
|
})
|
||||||
|
// Nur übernehmen, wenn sich der Beleg während der Anfrage nicht geändert hat
|
||||||
|
if (source === eInvoiceCheckSource.value) eInvoiceIssues.value = result?.issues || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
eInvoiceIssues.value = [{ message: "E-Rechnung: Die Pflichtangaben konnten nicht geprüft werden", type: "breaking" }]
|
||||||
|
}
|
||||||
|
return eInvoiceIssues.value
|
||||||
|
}
|
||||||
|
|
||||||
|
watchDebounced(eInvoiceCheckSource, refreshEInvoiceIssues, { debounce: 500, immediate: true })
|
||||||
|
|
||||||
const findDocumentErrors = computed(() => {
|
const findDocumentErrors = computed(() => {
|
||||||
let errors = []
|
let errors = [...eInvoiceIssues.value]
|
||||||
|
|
||||||
if (itemInfo.value.customer === null) errors.push({message: "Es ist kein Kunde ausgewählt", type: "breaking"})
|
if (itemInfo.value.customer === null) errors.push({message: "Es ist kein Kunde ausgewählt", type: "breaking"})
|
||||||
if (itemInfo.value.contact === null) errors.push({message: "Es ist kein Kontakt ausgewählt", type: "info"})
|
if (itemInfo.value.contact === null) errors.push({message: "Es ist kein Kontakt ausgewählt", type: "info"})
|
||||||
@@ -1338,6 +1416,17 @@ const documentTotal = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Skonto bezieht sich bei Schlussrechnungen auf die Restforderung nach Abzug der Abschläge.
|
||||||
|
const cashDiscountTerms = computed(() => {
|
||||||
|
if (!["invoices", "advanceInvoices", "serialInvoices"].includes(itemInfo.value.type)) return null
|
||||||
|
return buildCashDiscountTerms({
|
||||||
|
percent: itemInfo.value.cashDiscountPercent,
|
||||||
|
days: itemInfo.value.cashDiscountDays,
|
||||||
|
documentDate: itemInfo.value.documentDate ? dayjs(itemInfo.value.documentDate).format("YYYY-MM-DD") : null,
|
||||||
|
amount: itemInfo.value.type === "invoices" ? documentTotal.value.totalSumToPay : documentTotal.value.totalGross
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const documentReport = computed(() => {
|
const documentReport = computed(() => {
|
||||||
|
|
||||||
let totalProductsPurchasePrice = 0
|
let totalProductsPurchasePrice = 0
|
||||||
@@ -1555,7 +1644,8 @@ const getDocumentData = async () => {
|
|||||||
kundenname: customerData && customerData.name,
|
kundenname: customerData && customerData.name,
|
||||||
zahlungsziel_in_tagen: itemInfo.paymentDays,
|
zahlungsziel_in_tagen: itemInfo.paymentDays,
|
||||||
zahlungsart: itemInfo.payment_type === "transfer" ? "Überweisung" : "Lastschrift",
|
zahlungsart: itemInfo.payment_type === "transfer" ? "Überweisung" : "Lastschrift",
|
||||||
diesel_gesamtverbrauch: (itemInfo.agriculture && itemInfo.agriculture.dieselUsageTotal) && itemInfo.agriculture.dieselUsageTotal
|
diesel_gesamtverbrauch: (itemInfo.agriculture && itemInfo.agriculture.dieselUsageTotal) && itemInfo.agriculture.dieselUsageTotal,
|
||||||
|
...getCashDiscountTemplateContext(cashDiscountTerms.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1665,7 +1755,7 @@ const getDocumentData = async () => {
|
|||||||
],
|
],
|
||||||
title: itemInfo.value.title,
|
title: itemInfo.value.title,
|
||||||
description: itemInfo.value.description,
|
description: itemInfo.value.description,
|
||||||
endText: templateEndText(generateContext(itemInfo.value, contactData)),
|
endText: appendCashDiscountText(templateEndText(generateContext(itemInfo.value, contactData)), itemInfo.value.endText, cashDiscountTerms.value),
|
||||||
startText: templateStartText(generateContext(itemInfo.value, contactData)),
|
startText: templateStartText(generateContext(itemInfo.value, contactData)),
|
||||||
rows: rows,
|
rows: rows,
|
||||||
totalArray: [
|
totalArray: [
|
||||||
@@ -1720,7 +1810,7 @@ const getDocumentData = async () => {
|
|||||||
|
|
||||||
const showDocument = ref(false)
|
const showDocument = ref(false)
|
||||||
const uri = ref("")
|
const uri = ref("")
|
||||||
const generateDocument = async () => {
|
const generateDocument = async (options = {}) => {
|
||||||
showDocument.value = false
|
showDocument.value = false
|
||||||
const selectedLetterhead = getSelectedLetterhead()
|
const selectedLetterhead = getSelectedLetterhead()
|
||||||
|
|
||||||
@@ -1731,7 +1821,7 @@ const generateDocument = async () => {
|
|||||||
|
|
||||||
const path = selectedLetterhead.path
|
const path = selectedLetterhead.path
|
||||||
|
|
||||||
uri.value = await useFunctions().useCreatePDF(await getDocumentData(), path, "createdDocument")
|
uri.value = await useFunctions().useCreatePDF(await getDocumentData(), path, "createdDocument", options)
|
||||||
/*uri.value = await useNuxtApp().$api("/api/functions/createinvoicepdf",{
|
/*uri.value = await useNuxtApp().$api("/api/functions/createinvoicepdf",{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
@@ -1800,6 +1890,8 @@ const saveSerialInvoice = async () => {
|
|||||||
project: normalizeEntityId(itemInfo.value.project),
|
project: normalizeEntityId(itemInfo.value.project),
|
||||||
costcentre: itemInfo.value.costcentre,
|
costcentre: itemInfo.value.costcentre,
|
||||||
paymentDays: itemInfo.value.paymentDays,
|
paymentDays: itemInfo.value.paymentDays,
|
||||||
|
cashDiscountPercent: itemInfo.value.cashDiscountPercent === "" || itemInfo.value.cashDiscountPercent === null ? null : Number(itemInfo.value.cashDiscountPercent),
|
||||||
|
cashDiscountDays: itemInfo.value.cashDiscountDays === "" || itemInfo.value.cashDiscountDays === null ? null : Number(itemInfo.value.cashDiscountDays),
|
||||||
payment_type: itemInfo.value.payment_type,
|
payment_type: itemInfo.value.payment_type,
|
||||||
outgoingsepamandate: normalizeEntityId(itemInfo.value.outgoingsepamandate),
|
outgoingsepamandate: normalizeEntityId(itemInfo.value.outgoingsepamandate),
|
||||||
deliveryDateType: "Leistungszeitraum",
|
deliveryDateType: "Leistungszeitraum",
|
||||||
@@ -1916,6 +2008,8 @@ const saveDocument = async (state, resetup = false) => {
|
|||||||
deliveryDate: itemInfo.value.deliveryDate,
|
deliveryDate: itemInfo.value.deliveryDate,
|
||||||
deliveryDateEnd: itemInfo.value.deliveryDateEnd,
|
deliveryDateEnd: itemInfo.value.deliveryDateEnd,
|
||||||
paymentDays: itemInfo.value.paymentDays,
|
paymentDays: itemInfo.value.paymentDays,
|
||||||
|
cashDiscountPercent: itemInfo.value.cashDiscountPercent === "" || itemInfo.value.cashDiscountPercent === null ? null : Number(itemInfo.value.cashDiscountPercent),
|
||||||
|
cashDiscountDays: itemInfo.value.cashDiscountDays === "" || itemInfo.value.cashDiscountDays === null ? null : Number(itemInfo.value.cashDiscountDays),
|
||||||
payment_type: itemInfo.value.payment_type,
|
payment_type: itemInfo.value.payment_type,
|
||||||
outgoingsepamandate: normalizeEntityId(itemInfo.value.outgoingsepamandate),
|
outgoingsepamandate: normalizeEntityId(itemInfo.value.outgoingsepamandate),
|
||||||
deliveryDateType: itemInfo.value.deliveryDateType,
|
deliveryDateType: itemInfo.value.deliveryDateType,
|
||||||
@@ -1977,6 +2071,18 @@ const saveFinalizedCostCentres = async () => {
|
|||||||
const selectedTab = ref("0")
|
const selectedTab = ref("0")
|
||||||
|
|
||||||
const closeDocument = async () => {
|
const closeDocument = async () => {
|
||||||
|
// Vor Vorschau und Fertigstellung mit dem aktuellen Stand prüfen, nicht mit dem letzten (entprellten) Ergebnis
|
||||||
|
await refreshEInvoiceIssues()
|
||||||
|
const breakingErrors = findDocumentErrors.value.filter(i => i.type === "breaking")
|
||||||
|
if (breakingErrors.length > 0) {
|
||||||
|
selectedTab.value = "0"
|
||||||
|
toast.add({
|
||||||
|
title: "Beleg kann nicht fertiggestellt werden",
|
||||||
|
description: breakingErrors.map(i => i.message).join(" · "),
|
||||||
|
color: "error",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if(selectedTab.value === "0") {
|
if(selectedTab.value === "0") {
|
||||||
await generateDocument()
|
await generateDocument()
|
||||||
@@ -1986,7 +2092,16 @@ const closeDocument = async () => {
|
|||||||
|
|
||||||
await saveDocument("Gebucht")
|
await saveDocument("Gebucht")
|
||||||
|
|
||||||
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
|
guard.value = false
|
||||||
|
|
||||||
@@ -2000,7 +2115,14 @@ const closeDocument = async () => {
|
|||||||
|
|
||||||
// Erzeugt die PDF aus dem aktuellen Stand und hängt sie an den Beleg an, ohne den Datensatz zu speichern
|
// Erzeugt die PDF aus dem aktuellen Stand und hängt sie an den Beleg an, ohne den Datensatz zu speichern
|
||||||
const uploadGeneratedDocument = async () => {
|
const uploadGeneratedDocument = async () => {
|
||||||
await generateDocument()
|
// Rechnungen werden als E-Rechnung (ZUGFeRD) abgelegt; das XML erzeugt das Backend aus dem gespeicherten Beleg
|
||||||
|
await generateDocument({
|
||||||
|
createdDocumentId: itemInfo.value.id,
|
||||||
|
onResult: (result) => {
|
||||||
|
const hints = (result?.einvoiceIssues || []).map(i => i.message)
|
||||||
|
if (hints.length) toast.add({ title: "E-Rechnung mit Hinweisen erstellt", description: hints.join(" · "), color: "warning" })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
if (!uri.value) throw new Error("PDF konnte nicht erzeugt werden")
|
if (!uri.value) throw new Error("PDF konnte nicht erzeugt werden")
|
||||||
|
|
||||||
@@ -2671,6 +2793,39 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
<UFormField
|
||||||
|
v-if="['invoices', 'advanceInvoices', 'serialInvoices'].includes(itemInfo.type)"
|
||||||
|
class="min-w-0 flex-1"
|
||||||
|
label="Skonto:"
|
||||||
|
:help="cashDiscountTerms ? formatCashDiscountText(cashDiscountTerms) : undefined"
|
||||||
|
>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<UInput
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
max="10"
|
||||||
|
placeholder="%"
|
||||||
|
v-model="itemInfo.cashDiscountPercent"
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
<template #trailing>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400 text-xs">%</span>
|
||||||
|
</template>
|
||||||
|
</UInput>
|
||||||
|
<UInput
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
placeholder="Tage"
|
||||||
|
v-model="itemInfo.cashDiscountDays"
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
<template #trailing>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400 text-xs">Tage</span>
|
||||||
|
</template>
|
||||||
|
</UInput>
|
||||||
|
</div>
|
||||||
|
</UFormField>
|
||||||
<UFormField
|
<UFormField
|
||||||
class="min-w-0 flex-1"
|
class="min-w-0 flex-1"
|
||||||
label="Individueller Aufschlag:"
|
label="Individueller Aufschlag:"
|
||||||
@@ -3116,7 +3271,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
|||||||
v-model="itemInfo.rows"
|
v-model="itemInfo.rows"
|
||||||
handle=".handle"
|
handle=".handle"
|
||||||
tag="tbody"
|
tag="tbody"
|
||||||
itemKey="pos"
|
itemKey="id"
|
||||||
@end="setPosNumbers"
|
@end="setPosNumbers"
|
||||||
>
|
>
|
||||||
<template #item="{element: row}">
|
<template #item="{element: row}">
|
||||||
@@ -3141,6 +3296,21 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
|||||||
@click="toggleTitleSection(row.id)"
|
@click="toggleTitleSection(row.id)"
|
||||||
/>
|
/>
|
||||||
</UTooltip>
|
</UTooltip>
|
||||||
|
<UDropdownMenu
|
||||||
|
v-if="row.mode === 'title' && itemInfo.type !== 'cancellationInvoices'"
|
||||||
|
:items="getTitleSectionMoveMenuItems(row.id)"
|
||||||
|
:content="{ align: 'start' }"
|
||||||
|
>
|
||||||
|
<UTooltip text="Titel mit untergeordneten Zeilen verschieben">
|
||||||
|
<UButton
|
||||||
|
class="shrink-0"
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
icon="i-heroicons-arrows-up-down"
|
||||||
|
aria-label="Titel mit untergeordneten Zeilen verschieben"
|
||||||
|
/>
|
||||||
|
</UTooltip>
|
||||||
|
</UDropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
|
|||||||
@@ -164,6 +164,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { sumSettledAllocations } from "~/utils/cashDiscount"
|
||||||
import CreateDocumentModal from "~/components/createDocumentModal.vue"
|
import CreateDocumentModal from "~/components/createDocumentModal.vue"
|
||||||
import CreateDocumentFromTemplateModal from "~/components/createDocumentFromTemplateModal.vue"
|
import CreateDocumentFromTemplateModal from "~/components/createDocumentFromTemplateModal.vue"
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
@@ -337,10 +338,7 @@ const documentAmounts = computed(() => {
|
|||||||
|
|
||||||
items.value.forEach((item) => {
|
items.value.forEach((item) => {
|
||||||
const amount = sum.getCreatedDocumentSum(item, items.value)
|
const amount = sum.getCreatedDocumentSum(item, items.value)
|
||||||
const amountPaid = (item.statementallocations || []).reduce(
|
const amountPaid = sumSettledAllocations(item.statementallocations || [])
|
||||||
(total, allocation) => total + Number(allocation.amount || 0),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
|
|
||||||
result.set(item.id, {
|
result.set(item.id, {
|
||||||
amount,
|
amount,
|
||||||
|
|||||||
@@ -427,6 +427,7 @@ const togglePortalRelease = async () => {
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-gray-500">Betrag</p>
|
<p class="text-xs text-gray-500">Betrag</p>
|
||||||
<p class="font-medium">{{ formatCurrency(allocation.amount) }}</p>
|
<p class="font-medium">{{ formatCurrency(allocation.amount) }}</p>
|
||||||
|
<p v-if="Number(allocation.cashDiscountAmount)" class="text-xs text-amber-600">+ {{ formatCurrency(allocation.cashDiscountAmount) }} Skonto</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-gray-500">Text</p>
|
<p class="text-xs text-gray-500">Text</p>
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ const itemInfo = ref({
|
|||||||
reference: "",
|
reference: "",
|
||||||
date: null,
|
date: null,
|
||||||
dueDate: null,
|
dueDate: null,
|
||||||
|
cashDiscountPercent: null,
|
||||||
|
cashDiscountDueDate: null,
|
||||||
paymentType: "Überweisung",
|
paymentType: "Überweisung",
|
||||||
description: "",
|
description: "",
|
||||||
state: "Entwurf",
|
state: "Entwurf",
|
||||||
@@ -147,6 +149,28 @@ const setDateFieldToToday = (field) => {
|
|||||||
itemInfo.value[field] = dayjs().toDate()
|
itemInfo.value[field] = dayjs().toDate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skontofrist als reines Datum speichern, damit der Vergleich mit dem Zahlungsdatum nicht durch Zeitzonen kippt.
|
||||||
|
const setCashDiscountDueDate = (value) => {
|
||||||
|
itemInfo.value.cashDiscountDueDate = value ? dayjs(value.toString()).format("YYYY-MM-DD") : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lieferanten-Vorgabe nur bei bewusster Auswahl übernehmen, nicht beim Laden eines Belegs.
|
||||||
|
const applyVendorCashDiscount = (vendorId) => {
|
||||||
|
const vendor = vendors.value.find((item) => item.id === vendorId)
|
||||||
|
if (!vendor?.cashDiscountPercent || itemInfo.value.cashDiscountPercent) return
|
||||||
|
itemInfo.value.cashDiscountPercent = vendor.cashDiscountPercent
|
||||||
|
if (itemInfo.value.date && vendor.cashDiscountDays !== null && vendor.cashDiscountDays !== undefined) {
|
||||||
|
itemInfo.value.cashDiscountDueDate = dayjs(itemInfo.value.date).add(vendor.cashDiscountDays, "day").format("YYYY-MM-DD")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cashDiscountAmount = computed(() => {
|
||||||
|
const percent = Number(itemInfo.value.cashDiscountPercent || 0)
|
||||||
|
if (!(percent > 0)) return null
|
||||||
|
const gross = (itemInfo.value.accounts || []).reduce((sum, account) => sum + Number(account.amountNet || 0) + Number(account.amountTax || 0), 0)
|
||||||
|
return Number((Math.abs(gross) * percent / 100).toFixed(2))
|
||||||
|
})
|
||||||
|
|
||||||
const getDateButtonLabel = (value, emptyLabel = "Kein Datum") => value ? dayjs(value).format('DD.MM.YYYY') : emptyLabel
|
const getDateButtonLabel = (value, emptyLabel = "Kein Datum") => value ? dayjs(value).format('DD.MM.YYYY') : emptyLabel
|
||||||
const formatDate = (value) => value ? dayjs(value).format("DD.MM.YYYY") : "-"
|
const formatDate = (value) => value ? dayjs(value).format("DD.MM.YYYY") : "-"
|
||||||
const formatMoney = (value) => `${Number(value || 0).toFixed(2).replace(".", ",")} €`
|
const formatMoney = (value) => `${Number(value || 0).toFixed(2).replace(".", ",")} €`
|
||||||
@@ -333,6 +357,9 @@ const updateIncomingInvoice = async (setBooked = false) => {
|
|||||||
|
|
||||||
let item = { ...itemInfo.value }
|
let item = { ...itemInfo.value }
|
||||||
item.accounts = (item.accounts || []).map((account) => ensureDepreciationDefaults({ ...account }, item.date))
|
item.accounts = (item.accounts || []).map((account) => ensureDepreciationDefaults({ ...account }, item.date))
|
||||||
|
const cashDiscountPercent = Number(item.cashDiscountPercent)
|
||||||
|
item.cashDiscountPercent = Number.isFinite(cashDiscountPercent) && cashDiscountPercent > 0 ? cashDiscountPercent : null
|
||||||
|
item.cashDiscountDueDate = item.cashDiscountPercent && item.cashDiscountDueDate ? dayjs(item.cashDiscountDueDate).format("YYYY-MM-DD") : null
|
||||||
delete item.files
|
delete item.files
|
||||||
item.state = keepBooked ? "Gebucht" : "Entwurf"
|
item.state = keepBooked ? "Gebucht" : "Entwurf"
|
||||||
|
|
||||||
@@ -569,6 +596,14 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
|
|||||||
<dt class="text-gray-500">Fälligkeitsdatum</dt>
|
<dt class="text-gray-500">Fälligkeitsdatum</dt>
|
||||||
<dd class="font-medium">{{ formatDate(itemInfo.dueDate) }}</dd>
|
<dd class="font-medium">{{ formatDate(itemInfo.dueDate) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="itemInfo.cashDiscountPercent">
|
||||||
|
<dt class="text-gray-500">Skonto</dt>
|
||||||
|
<dd class="font-medium">
|
||||||
|
{{ String(itemInfo.cashDiscountPercent).replace(".", ",") }} %
|
||||||
|
<span v-if="cashDiscountAmount">({{ formatMoney(cashDiscountAmount) }})</span>
|
||||||
|
<span v-if="itemInfo.cashDiscountDueDate"> bis {{ formatDate(itemInfo.cashDiscountDueDate) }}</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-gray-500">Bankbuchungsdatum</dt>
|
<dt class="text-gray-500">Bankbuchungsdatum</dt>
|
||||||
<dd class="font-medium">{{ bankBookingDateLabel }}</dd>
|
<dd class="font-medium">{{ bankBookingDateLabel }}</dd>
|
||||||
@@ -602,6 +637,7 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-gray-500">Betrag</p>
|
<p class="text-xs text-gray-500">Betrag</p>
|
||||||
<p class="font-medium">{{ formatMoney(allocation.amount) }}</p>
|
<p class="font-medium">{{ formatMoney(allocation.amount) }}</p>
|
||||||
|
<p v-if="Number(allocation.cashDiscountAmount)" class="text-xs text-amber-600">+ {{ formatMoney(allocation.cashDiscountAmount) }} Skonto</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-gray-500">Text</p>
|
<p class="text-xs text-gray-500">Text</p>
|
||||||
@@ -636,6 +672,7 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
|
|||||||
:disabled="mode === 'show'"
|
:disabled="mode === 'show'"
|
||||||
:filter-fields="['name', 'vendorNumber']"
|
:filter-fields="['name', 'vendorNumber']"
|
||||||
:color="itemInfo.vendor ? 'primary' : 'error'"
|
:color="itemInfo.vendor ? 'primary' : 'error'"
|
||||||
|
@update:model-value="applyVendorCashDiscount"
|
||||||
>
|
>
|
||||||
<template #default>
|
<template #default>
|
||||||
{{ vendors.find(v => v.id === itemInfo.vendor)?.name || 'Bitte wählen' }}
|
{{ vendors.find(v => v.id === itemInfo.vendor)?.name || 'Bitte wählen' }}
|
||||||
@@ -656,7 +693,7 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
|
|||||||
v-if="mode !== 'show'"
|
v-if="mode !== 'show'"
|
||||||
type="vendors"
|
type="vendors"
|
||||||
:id="itemInfo.vendor"
|
:id="itemInfo.vendor"
|
||||||
@return-data="(data) => itemInfo.vendor = data.id"
|
@return-data="(data) => { itemInfo.vendor = data.id; applyVendorCashDiscount(data.id) }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
@@ -735,6 +772,47 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
|
|||||||
</UPopover>
|
</UPopover>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField
|
||||||
|
label="Skonto"
|
||||||
|
:help="cashDiscountAmount ? `Abzug ${formatMoney(cashDiscountAmount)} bei Zahlung in der Frist` : undefined"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
max="10"
|
||||||
|
placeholder="z. B. 2"
|
||||||
|
v-model="itemInfo.cashDiscountPercent"
|
||||||
|
:disabled="mode === 'show'"
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
<template #trailing><span class="text-gray-500 text-xs">%</span></template>
|
||||||
|
</UInput>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField label="Skonto bis">
|
||||||
|
<UPopover class="w-full" :content="{ side: 'bottom', align: 'start' }">
|
||||||
|
<UButton
|
||||||
|
block
|
||||||
|
icon="i-heroicons-calendar"
|
||||||
|
:label="getDateButtonLabel(itemInfo.cashDiscountDueDate, 'Keine Skontofrist')"
|
||||||
|
:disabled="mode === 'show' || !itemInfo.cashDiscountPercent"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
class="w-full justify-start"
|
||||||
|
/>
|
||||||
|
<template #content>
|
||||||
|
<div class="p-2">
|
||||||
|
<UCalendar
|
||||||
|
:model-value="getCalendarValue(itemInfo.cashDiscountDueDate)"
|
||||||
|
@update:model-value="setCashDiscountDueDate"
|
||||||
|
:week-starts-on="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UPopover>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
<UFormField label="Beschreibung / Notiz" class="md:col-span-2">
|
<UFormField label="Beschreibung / Notiz" class="md:col-span-2">
|
||||||
<UTextarea class="w-full" v-model="itemInfo.description" :rows="2" autoresize :disabled="mode === 'show'" />
|
<UTextarea class="w-full" v-model="itemInfo.description" :rows="2" autoresize :disabled="mode === 'show'" />
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { sumSettledAllocations } from "~/utils/cashDiscount"
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import {useSum} from "~/composables/useSum.js";
|
import {useSum} from "~/composables/useSum.js";
|
||||||
|
|
||||||
@@ -199,8 +200,8 @@ const getInvoiceSum = (invoice) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getPaidAmount = (item) => {
|
const getPaidAmount = (item) => {
|
||||||
let amountPaid = 0
|
// Gebuchter Skonto zählt zum ausgeglichenen Betrag.
|
||||||
item.statementallocations.forEach(allocation => amountPaid += allocation.amount)
|
const amountPaid = sumSettledAllocations(item.statementallocations || [])
|
||||||
return Number(Math.abs(amountPaid).toFixed(2))
|
return Number(Math.abs(amountPaid).toFixed(2))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { resolveCashDiscountAccounts } from "~/utils/cashDiscount"
|
||||||
import {
|
import {
|
||||||
TAX_EVALUATION_PERIOD_OPTIONS,
|
TAX_EVALUATION_PERIOD_OPTIONS,
|
||||||
normalizeTaxEvaluationPeriod
|
normalizeTaxEvaluationPeriod
|
||||||
@@ -273,6 +274,20 @@ watch(() => auth.activeTenantData?.portalConfig, (config) => {
|
|||||||
})
|
})
|
||||||
const accountChart = ref(auth.activeTenantData.accountChart || "skr03")
|
const accountChart = ref(auth.activeTenantData.accountChart || "skr03")
|
||||||
const taxEvaluationPeriod = ref(normalizeTaxEvaluationPeriod(auth.activeTenantData.taxEvaluationPeriod))
|
const taxEvaluationPeriod = ref(normalizeTaxEvaluationPeriod(auth.activeTenantData.taxEvaluationPeriod))
|
||||||
|
const buildCashDiscountForm = (config) => {
|
||||||
|
const accounts = resolveCashDiscountAccounts(config)
|
||||||
|
return { received: { ...accounts.received }, granted: { ...accounts.granted } }
|
||||||
|
}
|
||||||
|
const cashDiscountAccounts = ref(buildCashDiscountForm(auth.activeTenantData?.cashDiscountAccounts))
|
||||||
|
const cashDiscountRateLabels = { "19": "19 %", "7": "7 %", "0": "ohne USt" }
|
||||||
|
const saveCashDiscountAccounts = async () => {
|
||||||
|
try {
|
||||||
|
await updateTenant({ cashDiscountAccounts: cashDiscountAccounts.value })
|
||||||
|
toast.add({ title: "Skontokonten gespeichert", color: "success" })
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: "Skontokonten konnten nicht gespeichert werden", description: err?.data?.error, color: "error" })
|
||||||
|
}
|
||||||
|
}
|
||||||
const accountChartOptions = [
|
const accountChartOptions = [
|
||||||
{ label: "SKR 03", value: "skr03" },
|
{ label: "SKR 03", value: "skr03" },
|
||||||
{ label: "Verein", value: "verein" }
|
{ label: "Verein", value: "verein" }
|
||||||
@@ -293,6 +308,7 @@ const updateTenant = async (newData) => {
|
|||||||
features.value = { ...defaultFeatures, ...(res?.features || {}) }
|
features.value = { ...defaultFeatures, ...(res?.features || {}) }
|
||||||
businessInfo.value = normalizeBusinessInfo(res?.businessInfo)
|
businessInfo.value = normalizeBusinessInfo(res?.businessInfo)
|
||||||
taxEvaluationPeriod.value = normalizeTaxEvaluationPeriod(res?.taxEvaluationPeriod)
|
taxEvaluationPeriod.value = normalizeTaxEvaluationPeriod(res?.taxEvaluationPeriod)
|
||||||
|
cashDiscountAccounts.value = buildCashDiscountForm(res?.cashDiscountAccounts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const saveFeatures = async () => {
|
const saveFeatures = async () => {
|
||||||
@@ -776,6 +792,32 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
Zeitraum speichern
|
Zeitraum speichern
|
||||||
</UButton>
|
</UButton>
|
||||||
|
<div class="mt-6">
|
||||||
|
<div class="text-sm font-medium">Skontokonten</div>
|
||||||
|
<p class="text-xs text-muted mt-1">
|
||||||
|
DATEV-Automatikkonten für Skonto bei Zahlungen. Standard ist SKR 03.
|
||||||
|
</p>
|
||||||
|
<div class="mt-3 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div v-for="side in ['received', 'granted']" :key="side" class="space-y-2">
|
||||||
|
<div class="text-xs font-semibold uppercase text-muted">
|
||||||
|
{{ side === 'received' ? 'Erhaltene Skonti (Eingangsbelege)' : 'Gewährte Skonti (Ausgangsbelege)' }}
|
||||||
|
</div>
|
||||||
|
<UFormField
|
||||||
|
v-for="rate in ['19', '7', '0']"
|
||||||
|
:key="`${side}-${rate}`"
|
||||||
|
:label="cashDiscountRateLabels[rate]"
|
||||||
|
>
|
||||||
|
<UInput v-model="cashDiscountAccounts[side][rate]" inputmode="numeric"/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
class="mt-3"
|
||||||
|
@click="saveCashDiscountAccounts"
|
||||||
|
>
|
||||||
|
Skontokonten speichern
|
||||||
|
</UButton>
|
||||||
</UForm>
|
</UForm>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
|
|||||||
@@ -29,12 +29,18 @@ const variableDefinitions = [
|
|||||||
{ key: '{{titel}}', label: 'Titel', desc: 'Titel des Kunden' },
|
{ key: '{{titel}}', label: 'Titel', desc: 'Titel des Kunden' },
|
||||||
{ key: '{{zahlungsziel_in_tagen}}', label: 'Zahlungsziel', desc: 'In Tagen' },
|
{ key: '{{zahlungsziel_in_tagen}}', label: 'Zahlungsziel', desc: 'In Tagen' },
|
||||||
{ key: '{{lohnkosten}}', label: 'Lohnkosten', desc: 'Ausgewiesene Lohnkosten' },
|
{ key: '{{lohnkosten}}', label: 'Lohnkosten', desc: 'Ausgewiesene Lohnkosten' },
|
||||||
|
{ key: '{{skonto_hinweis}}', label: 'Skonto-Hinweis', desc: 'Kompletter Satz; ohne Skontovariable wird er automatisch vor den Endtext gesetzt' },
|
||||||
|
{ key: '{{skonto_prozent}}', label: 'Skonto %', desc: 'Skontosatz' },
|
||||||
|
{ key: '{{skonto_datum}}', label: 'Skonto bis', desc: 'Datum der Skontofrist' },
|
||||||
|
{ key: '{{skonto_betrag}}', label: 'Skontobetrag', desc: 'Abzugsbetrag in Euro' },
|
||||||
|
{ key: '{{zahlbetrag_mit_skonto}}', label: 'Zahlbetrag mit Skonto', desc: 'Rechnungsbetrag abzüglich Skonto' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const conditionalExamples = [
|
const conditionalExamples = [
|
||||||
'{{#if vorname}}Hallo {{vorname}},{{/if}}',
|
'{{#if vorname}}Hallo {{vorname}},{{/if}}',
|
||||||
'{{#if (eq zahlungsart "Überweisung")}}Bitte überweisen Sie den Betrag.{{/if}}',
|
'{{#if (eq zahlungsart "Überweisung")}}Bitte überweisen Sie den Betrag.{{/if}}',
|
||||||
'{{#if (gt zahlungsziel_in_tagen 14)}}Vielen Dank für Ihre Zahlung innerhalb des erweiterten Zahlungsziels.{{/if}}',
|
'{{#if (gt zahlungsziel_in_tagen 14)}}Vielen Dank für Ihre Zahlung innerhalb des erweiterten Zahlungsziels.{{/if}}',
|
||||||
|
'{{#if skonto_prozent}}Bei Zahlung bis {{skonto_datum}} ziehen Sie bitte {{skonto_prozent}} % Skonto ab.{{/if}}',
|
||||||
]
|
]
|
||||||
|
|
||||||
// --- Shortcuts ---
|
// --- Shortcuts ---
|
||||||
|
|||||||
@@ -345,6 +345,18 @@ export const useDataStore = defineStore('data', () => {
|
|||||||
inputType: "number",
|
inputType: "number",
|
||||||
inputColumn: "Allgemeines",
|
inputColumn: "Allgemeines",
|
||||||
sortable: true
|
sortable: true
|
||||||
|
},{
|
||||||
|
key: "customCashDiscountPercent",
|
||||||
|
label: "Skonto in %",
|
||||||
|
inputType: "number",
|
||||||
|
inputColumn: "Allgemeines",
|
||||||
|
disabledInTable: true
|
||||||
|
},{
|
||||||
|
key: "customCashDiscountDays",
|
||||||
|
label: "Skontofrist in Tagen",
|
||||||
|
inputType: "number",
|
||||||
|
inputColumn: "Allgemeines",
|
||||||
|
disabledInTable: true
|
||||||
},{
|
},{
|
||||||
key: "custom_payment_type",
|
key: "custom_payment_type",
|
||||||
label: "Zahlungsart",
|
label: "Zahlungsart",
|
||||||
@@ -2007,6 +2019,18 @@ export const useDataStore = defineStore('data', () => {
|
|||||||
inputType: "bool",
|
inputType: "bool",
|
||||||
component: sepa
|
component: sepa
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "cashDiscountPercent",
|
||||||
|
label: "Skonto in %",
|
||||||
|
inputType: "number",
|
||||||
|
disabledInTable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "cashDiscountDays",
|
||||||
|
label: "Skontofrist in Tagen",
|
||||||
|
inputType: "number",
|
||||||
|
disabledInTable: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "defaultPaymentMethod",
|
key: "defaultPaymentMethod",
|
||||||
label: "Standard Zahlungsmethode",
|
label: "Standard Zahlungsmethode",
|
||||||
|
|||||||
119
frontend/tests/document-title-sections.test.cjs
Normal file
119
frontend/tests/document-title-sections.test.cjs
Normal 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)
|
||||||
|
})
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getCashDiscountTaxBreakdown } from "./cashDiscount"
|
||||||
|
|
||||||
export const BANK_ALLOCATION_TAX_ITEMS = [
|
export const BANK_ALLOCATION_TAX_ITEMS = [
|
||||||
{ value: "9", label: "19 % Vorsteuer", rate: 19, kind: "input" },
|
{ value: "9", label: "19 % Vorsteuer", rate: 19, kind: "input" },
|
||||||
{ value: "8", label: "7 % Vorsteuer", rate: 7, kind: "input" },
|
{ value: "8", label: "7 % Vorsteuer", rate: 7, kind: "input" },
|
||||||
@@ -25,19 +27,19 @@ export function getBankAllocationAmounts(allocation: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getBankAllocationTaxBreakdown(allocation: any) {
|
export function getBankAllocationTaxBreakdown(allocation: any) {
|
||||||
const output = { net19: 0, tax19: 0, net7: 0, tax7: 0, net0: 0 }
|
// Skonto auf zugeordnete Belege korrigiert die Steuer im Zeitraum der Zahlung.
|
||||||
const input = { ...output }
|
const { output, input } = getCashDiscountTaxBreakdown(allocation)
|
||||||
if (!isDirectBankAllocation(allocation)) return { output, input }
|
if (!isDirectBankAllocation(allocation)) return { output, input }
|
||||||
const { amountNet, amountTax, rate, kind } = getBankAllocationAmounts(allocation)
|
const { amountNet, amountTax, rate, kind } = getBankAllocationAmounts(allocation)
|
||||||
if (kind === "none") return { output, input }
|
if (kind === "none") return { output, input }
|
||||||
const target = kind === "input" ? input : output
|
const target = kind === "input" ? input : output
|
||||||
const sign = kind === "input" ? -1 : 1
|
const sign = kind === "input" ? -1 : 1
|
||||||
if (rate === 19) {
|
if (rate === 19) {
|
||||||
target.net19 = amountNet * sign
|
target.net19 += amountNet * sign
|
||||||
target.tax19 = amountTax * sign
|
target.tax19 += amountTax * sign
|
||||||
} else if (rate === 7) {
|
} else if (rate === 7) {
|
||||||
target.net7 = amountNet * sign
|
target.net7 += amountNet * sign
|
||||||
target.tax7 = amountTax * sign
|
target.tax7 += amountTax * sign
|
||||||
}
|
}
|
||||||
return { output, input }
|
return { output, input }
|
||||||
}
|
}
|
||||||
|
|||||||
162
frontend/utils/cashDiscount.ts
Normal file
162
frontend/utils/cashDiscount.ts
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
41
frontend/utils/documentTitleSections.js
Normal file
41
frontend/utils/documentTitleSections.js
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user