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