37 Commits

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 11:31:47 +01:00
28f24aa998 Merge pull request 'feat(documents): Titel mitsamt untergeordneten Zeilen verschieben' (#256) from feat/move-document-title-groups into dev
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m0s
Build and Release FEDEO / build-and-release (push) Successful in 6m33s
Reviewed-on: #256
2026-11-04 07:50:04 +00:00
d4c4a2b9c1 Merge pull request 'dev' (#255) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m4s
Build and Release FEDEO / build-and-release (push) Successful in 6m36s
Reviewed-on: #255
2026-10-29 17:20:58 +00:00
1b617c7910 Merge pull request 'dev' (#252) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m1s
Build and Release FEDEO / build-and-release (push) Successful in 6m34s
Reviewed-on: #252
2026-10-27 20:39:01 +00:00
9676a270da Merge pull request 'dev' (#251) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m7s
Build and Release FEDEO / build-and-release (push) Successful in 6m36s
Reviewed-on: #251
2026-10-25 13:17:14 +00:00
f3b4fac4c0 Merge pull request 'dev' (#247) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m2s
Build and Release FEDEO / build-and-release (push) Successful in 6m25s
Reviewed-on: #247
2026-10-15 16:47:32 +00:00
a8c6c2625c Merge pull request 'dev' (#242) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m6s
Build and Release FEDEO / build-and-release (push) Successful in 6m31s
Reviewed-on: #242
2026-10-13 16:18:53 +00:00
a2a1151ccb Merge pull request 'dev' (#241) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m5s
Build and Release FEDEO / build-and-release (push) Successful in 6m29s
Reviewed-on: #241
2026-10-07 17:11:45 +00:00
daaed01d02 Merge pull request 'dev' (#240) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 59s
Build and Release FEDEO / build-and-release (push) Successful in 6m16s
Reviewed-on: #240
2026-10-02 16:30:02 +00:00
e7d6c482ad Merge pull request 'dev' (#239) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 58s
Build and Release FEDEO / build-and-release (push) Successful in 6m13s
Reviewed-on: #239
2026-10-02 15:17:16 +00:00
ed305f656c Merge pull request 'dev' (#238) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 57s
Build and Release FEDEO / build-and-release (push) Successful in 6m14s
Reviewed-on: #238
2026-10-02 14:18:01 +00:00
30e932a874 Merge pull request 'dev' (#237) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 58s
Build and Release FEDEO / build-and-release (push) Successful in 6m16s
Reviewed-on: #237
2026-10-02 12:51:46 +00:00
0a74e07288 Merge pull request 'fix(scanner): accept Epson empty write probes' (#234) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 58s
Build and Release FEDEO / build-and-release (push) Successful in 6m8s
Reviewed-on: #234
2026-10-01 15:36:56 +00:00
4c3de8c942 Merge pull request 'dev' (#233) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 58s
Build and Release FEDEO / build-and-release (push) Successful in 6m11s
Reviewed-on: #233
2026-09-30 17:23:15 +00:00
49d44d9701 Merge pull request 'dev' (#232) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m1s
Build and Release FEDEO / build-and-release (push) Successful in 6m18s
Reviewed-on: #232
2026-09-30 15:43:45 +00:00
96a7acff33 Merge pull request 'feat(selfhost): show sanitized update logs in admin UI' (#231) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 47s
Build and Release FEDEO / build-and-release (push) Successful in 6m7s
Reviewed-on: #231
2026-09-25 17:22:30 +00:00
65588686c7 Merge pull request 'fix(documents): normalize units when selecting and loading rows' (#230) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 48s
Build and Release FEDEO / build-and-release (push) Successful in 6m3s
Reviewed-on: #230
2026-09-25 09:54:08 +00:00
fcf9cc3fc9 Merge pull request 'dev' (#229) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 49s
Build and Release FEDEO / build-and-release (push) Successful in 6m6s
Reviewed-on: #229
2026-09-25 09:24:39 +00:00
20091143d2 Merge pull request 'dev' (#228) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 45s
Build and Release FEDEO / build-and-release (push) Successful in 6m5s
Reviewed-on: #228
2026-09-23 17:20:58 +00:00
f708d0a2b2 Merge pull request 'dev' (#227) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 44s
Build and Release FEDEO / build-and-release (push) Successful in 6m7s
Reviewed-on: #227
2026-09-23 16:15:44 +00:00
7e9f4409f6 Merge pull request 'dev' (#226) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 33s
Build and Release FEDEO / build-and-release (push) Successful in 6m10s
Reviewed-on: #226
2026-09-23 14:57:40 +00:00
2e63af1aa2 Merge pull request 'fix(release): stream large migration checksums from git' (#225) from flfeders/codex/release-migration-checksums into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 25s
Build and Release FEDEO / build-and-release (push) Successful in 5m59s
Reviewed-on: #225
2026-09-23 14:09:57 +00:00
5ebd2d508c Merge pull request 'fix(release): exclude legacy main history from commit checks' (#224) from dev into main
Some checks failed
Build and Release FEDEO / verify (push) Successful in 23s
Build and Release FEDEO / build-and-release (push) Failing after 5m57s
Reviewed-on: #224
2026-09-23 13:03:36 +00:00
9128fbb165 Merge pull request 'fix(ci): build dev images without repository write access' (#221) from dev into main
Some checks failed
Build and Release FEDEO / verify (push) Failing after 26s
Build and Release FEDEO / build-and-release (push) Has been skipped
Reviewed-on: #221
2026-09-20 16:25:38 +00:00
8473221c13 Merge pull request '3.0.0' (#220) from dev into main
Some checks failed
Build and Release FEDEO / verify (push) Failing after 22s
Build and Release FEDEO / build-and-release (push) Has been skipped
Reviewed-on: #220
2026-09-20 16:10:46 +00:00
e7554fa2cc .gitea/ISSUE_TEMPLATE/feature_request.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-22 17:42:28 +00:00
7c1fabf58a .gitea/ISSUE_TEMPLATE/bug_report.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-22 17:40:58 +00:00
1203b6cbd1 .gitea/ISSUE_TEMPLATE/bug_report.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-15 11:39:03 +00:00
525f2906fb .gitea/ISSUE_TEMPLATE/feature_request.md aktualisiert
Some checks failed
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has been cancelled
2026-01-15 11:38:50 +00:00
b105382abf .gitea/ISSUE_TEMPLATE/bug_report.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 15s
2026-01-15 11:38:00 +00:00
b1cdec7d17 Merge pull request 'Added feature request template' (#62) from dev into main
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 16s
Reviewed-on: #62
2026-01-15 11:31:57 +00:00
f1d512b2e5 Merge pull request 'dev' (#61) from dev into main
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 16s
Reviewed-on: #61
2026-01-15 11:29:15 +00:00
db21b43120 Merge pull request 'dev' (#40) from dev into main
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 18s
Build and Push Docker Images / build-frontend (push) Successful in 16s
Reviewed-on: #40
2026-01-08 22:21:06 +00:00
55 changed files with 2449 additions and 121 deletions

View File

@@ -2,37 +2,18 @@
name: 🐛 Bug Report name: 🐛 Bug Report
about: Erstelle einen Bericht, um uns zu helfen, das Projekt zu verbessern. about: Erstelle einen Bericht, um uns zu helfen, das Projekt zu verbessern.
title: '[BUG] ' title: '[BUG] '
labels: bug labels: Problem
assignees: '' assignees: ''
--- ---
**Beschreibung** **Beschreibung**
Eine klare und prägnante Beschreibung des Fehlers.
**Reproduktion** **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** **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.** **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.

View File

@@ -2,19 +2,16 @@
name: ✨ Feature Request name: ✨ Feature Request
about: Schlage eine Idee für dieses Projekt vor. about: Schlage eine Idee für dieses Projekt vor.
title: '[FEATURE] ' title: '[FEATURE] '
labels: enhancement labels: Funktionswunsch
assignees: '' assignees: ''
--- ---
**Ist dein Feature-Wunsch mit einem Problem verbunden?** **Ist dein Feature-Wunsch mit einem Problem verbunden?**
Eine klare Beschreibung des Problems (z.B. "Ich bin immer genervt, wenn...").
**Lösungsvorschlag** **Lösungsvorschlag**
Eine klare Beschreibung dessen, was du dir wünschst und wie es funktionieren soll.
**Alternativen** **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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,8 +34,10 @@ import { applyOutgoingRowCatalogDefaults } from "../outgoingRowDefaults"
import { analyzeOutgoingDocumentLayout, DEFAULT_CONTENT_BOTTOM_MM } from "../outgoingDocumentLayout" import { analyzeOutgoingDocumentLayout, DEFAULT_CONTENT_BOTTOM_MM } from "../outgoingDocumentLayout"
import { withImages } from "../result" import { withImages } from "../result"
import { renderOutgoingDocumentPdf } from "../../modules/outgoing-document-pdf.service" import { renderOutgoingDocumentPdf } from "../../modules/outgoing-document-pdf.service"
import { assertOutgoingEInvoiceRequirements } from "../../modules/einvoice/outgoingZugferd.service"
import { renderPdfPagesAsPng } from "../../utils/pdfPreview" import { renderPdfPagesAsPng } from "../../utils/pdfPreview"
import { outgoingStatusTools } from "./outgoingStatus" import { outgoingStatusTools } from "./outgoingStatus"
import { normalizeCashDiscountDays, normalizeCashDiscountPercent, prepareCashDiscountAllocation } from "../../modules/cash-discount"
export { prepareStatementAllocationInput } from "../statementAllocations" export { prepareStatementAllocationInput } from "../statementAllocations"
@@ -246,6 +248,8 @@ const buildOutgoingDocumentPayload = (
for (const field of ["paymentDays"] as const) { for (const field of ["paymentDays"] as const) {
if (args[field] !== undefined) payload[field] = numberArg(args, field) if (args[field] !== undefined) payload[field] = numberArg(args, field)
} }
if (args.cashDiscountPercent !== undefined) payload.cashDiscountPercent = normalizeCashDiscountPercent(args.cashDiscountPercent)
if (args.cashDiscountDays !== undefined) payload.cashDiscountDays = normalizeCashDiscountDays(args.cashDiscountDays)
if (args.type !== undefined) payload.type = documentTypeArg(args) if (args.type !== undefined) payload.type = documentTypeArg(args)
if (args.address !== undefined) payload.address = optionalObjectArg(args, "address") if (args.address !== undefined) payload.address = optionalObjectArg(args, "address")
@@ -431,6 +435,8 @@ const buildIncomingInvoicePayload = (
for (const field of ["state", "reference", "date", "dueDate", "description", "paymentType"] as const) { for (const field of ["state", "reference", "date", "dueDate", "description", "paymentType"] as const) {
if (args[field] !== undefined) payload[field] = stringArg(args, field) if (args[field] !== undefined) payload[field] = stringArg(args, field)
} }
if (args.cashDiscountPercent !== undefined) payload.cashDiscountPercent = normalizeCashDiscountPercent(args.cashDiscountPercent)
if (args.cashDiscountDueDate !== undefined) payload.cashDiscountDueDate = stringArg(args, "cashDiscountDueDate")
if (args.vendor !== undefined) payload.vendor = numberArg(args, "vendor") if (args.vendor !== undefined) payload.vendor = numberArg(args, "vendor")
if (args.document !== undefined) payload.document = numberArg(args, "document") if (args.document !== undefined) payload.document = numberArg(args, "document")
if (args.expense !== undefined) payload.expense = args.expense === true if (args.expense !== undefined) payload.expense = args.expense === true
@@ -634,6 +640,8 @@ export const accountingTools: McpTool[] = [
deliveryDateEnd: { type: "string" }, deliveryDateEnd: { type: "string" },
deliveryDateType: { type: "string" }, deliveryDateType: { type: "string" },
paymentDays: { type: "number" }, paymentDays: { type: "number" },
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent (0 bis 10). Wird im PDF als Skontohinweis ausgegeben." },
cashDiscountDays: { type: ["number", "null"], description: "Skontofrist in Tagen ab Belegdatum." },
payment_type: { type: "string" }, payment_type: { type: "string" },
taxType: { taxType: {
type: "string", type: "string",
@@ -691,6 +699,8 @@ export const accountingTools: McpTool[] = [
deliveryDateEnd: { type: "string" }, deliveryDateEnd: { type: "string" },
deliveryDateType: { type: "string" }, deliveryDateType: { type: "string" },
paymentDays: { type: "number" }, paymentDays: { type: "number" },
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent (0 bis 10). Wird im PDF als Skontohinweis ausgegeben." },
cashDiscountDays: { type: ["number", "null"], description: "Skontofrist in Tagen ab Belegdatum." },
payment_type: { type: "string" }, payment_type: { type: "string" },
taxType: { taxType: {
type: "string", type: "string",
@@ -881,6 +891,8 @@ export const accountingTools: McpTool[] = [
deliveryDate: { type: "string" }, deliveryDate: { type: "string" },
deliveryDateEnd: { type: "string" }, deliveryDateEnd: { type: "string" },
paymentDays: { type: "number" }, paymentDays: { type: "number" },
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent (0 bis 10). Wird im PDF als Skontohinweis ausgegeben." },
cashDiscountDays: { type: ["number", "null"], description: "Skontofrist in Tagen ab Belegdatum." },
payment_type: { type: "string" }, payment_type: { type: "string" },
taxType: { taxType: {
type: "string", type: "string",
@@ -908,11 +920,13 @@ export const accountingTools: McpTool[] = [
if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) } if (args.rows !== undefined) args = { ...args, rows: await applyOutgoingRowCatalogDefaults(context, args.rows) }
const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId) const payload = buildOutgoingDocumentPayload(args, context.userId, context.tenantId)
applyOutgoingDocumentTaxType(payload, args, existing.type, existing.taxType, existing.rows)
// Pflichtangaben der E-Rechnung vor der Nummernvergabe durchsetzen
await assertOutgoingEInvoiceRequirements(context.server, context.tenantId, { ...existing, ...payload })
const result = await useNextNumberRangeNumber(context.server, context.tenantId, existing.type) const result = await useNextNumberRangeNumber(context.server, context.tenantId, existing.type)
payload.state = "Gebucht" payload.state = "Gebucht"
payload.documentNumber = result.usedNumber payload.documentNumber = result.usedNumber
applyOutgoingDocumentTaxType(payload, args, existing.type, existing.taxType, existing.rows)
const [updated] = await context.server.db const [updated] = await context.server.db
.update(createddocuments) .update(createddocuments)
@@ -1200,6 +1214,8 @@ export const accountingTools: McpTool[] = [
reference: { type: "string" }, reference: { type: "string" },
date: { type: "string" }, date: { type: "string" },
dueDate: { type: "string" }, dueDate: { type: "string" },
cashDiscountPercent: { type: ["number", "null"] },
cashDiscountDueDate: { type: ["string", "null"] },
document: { type: "number" }, document: { type: "number" },
description: { type: "string" }, description: { type: "string" },
paymentType: { type: "string" }, paymentType: { type: "string" },
@@ -1317,6 +1333,8 @@ export const accountingTools: McpTool[] = [
reference: { type: "string" }, reference: { type: "string" },
date: { type: "string" }, date: { type: "string" },
dueDate: { type: "string" }, dueDate: { type: "string" },
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent laut Rechnung." },
cashDiscountDueDate: { type: ["string", "null"], description: "Letzter Tag der Skontofrist (YYYY-MM-DD)." },
document: { type: "number" }, document: { type: "number" },
description: { type: "string" }, description: { type: "string" },
paymentType: { type: "string" }, paymentType: { type: "string" },
@@ -1350,6 +1368,8 @@ export const accountingTools: McpTool[] = [
reference: { type: "string" }, reference: { type: "string" },
date: { type: "string" }, date: { type: "string" },
dueDate: { type: "string" }, dueDate: { type: "string" },
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent laut Rechnung." },
cashDiscountDueDate: { type: ["string", "null"], description: "Letzter Tag der Skontofrist (YYYY-MM-DD)." },
document: { type: "number" }, document: { type: "number" },
description: { type: "string" }, description: { type: "string" },
paymentType: { type: "string" }, paymentType: { type: "string" },
@@ -1613,7 +1633,7 @@ export const accountingTools: McpTool[] = [
{ {
name: "accounting.statement_allocations.create", name: "accounting.statement_allocations.create",
title: "Bankzuweisung erstellen", title: "Bankzuweisung erstellen",
description: "Erstellt eine Bankzuweisung oder manuelle Soll/Haben-Buchung. Unterstützt Ausgangs- und Eingangsbelege, Sachkonten, zusätzliche Konten, Debitoren, Kreditoren, Teilbeträge, DATEV-Steuerschlüssel und Abschreibungen.", description: "Erstellt eine Bankzuweisung oder manuelle Soll/Haben-Buchung. Unterstützt Ausgangs- und Eingangsbelege, Sachkonten, zusätzliche Konten, Debitoren, Kreditoren, Teilbeträge, Skonto, DATEV-Steuerschlüssel und Abschreibungen.",
requiredPermissions: ["accounting.statement_allocations.write"], requiredPermissions: ["accounting.statement_allocations.write"],
inputSchema: { inputSchema: {
type: "object", type: "object",
@@ -1621,6 +1641,7 @@ export const accountingTools: McpTool[] = [
properties: { properties: {
bankstatement: { type: "number", description: "Bankumsatz-ID. Weglassen für eine manuelle Soll/Haben-Buchung." }, bankstatement: { type: "number", description: "Bankumsatz-ID. Weglassen für eine manuelle Soll/Haben-Buchung." },
amount: { type: "number", description: "Zuweisungsbetrag mit Vorzeichen des Bankumsatzes; bei manuellen Buchungen positiv." }, amount: { type: "number", description: "Zuweisungsbetrag mit Vorzeichen des Bankumsatzes; bei manuellen Buchungen positiv." },
cashDiscountAmount: { type: "number", description: "Skonto (brutto) zu einem Ausgangs- oder Eingangsbeleg, zusätzlich zum gezahlten amount. Nur mit bankstatement; das Vorzeichen wird an amount angeglichen. Wird anteilig nach Steuersätzen auf die Skontokonten verteilt." },
createddocument: { type: "number", description: "Ausgangsbeleg-ID." }, createddocument: { type: "number", description: "Ausgangsbeleg-ID." },
incominginvoice: { type: "number", description: "Eingangsbeleg-ID." }, incominginvoice: { type: "number", description: "Eingangsbeleg-ID." },
account: { type: "number", description: "Sachkonto-ID beziehungsweise Soll-Sachkonto." }, account: { type: "number", description: "Sachkonto-ID beziehungsweise Soll-Sachkonto." },
@@ -1646,8 +1667,9 @@ export const accountingTools: McpTool[] = [
}, },
}, },
async handler(context, args) { async handler(context, args) {
const payload = prepareStatementAllocationInput(args) const prepared = prepareStatementAllocationInput(args)
await assertStatementAllocationReferences(context, payload) await assertStatementAllocationReferences(context, prepared)
const payload = await prepareCashDiscountAllocation(context.server.db, context.tenantId, prepared)
const [created] = await context.server.db const [created] = await context.server.db
.insert(statementallocations) .insert(statementallocations)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ import {
} from "../../db/schema" } from "../../db/schema"
import { documentTemplateHandlebars } from "../utils/handlebars" import { documentTemplateHandlebars } from "../utils/handlebars"
import { createInvoicePDF, InvoicePdfLayout } from "../utils/pdf" import { createInvoicePDF, InvoicePdfLayout } from "../utils/pdf"
import { appendCashDiscountText, buildCashDiscountTerms, getCashDiscountTemplateContext } from "./cash-discount"
const quoteLikeDocumentTypes = ["quotes", "costEstimates"] const quoteLikeDocumentTypes = ["quotes", "costEstimates"]
const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"] const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"]
@@ -130,6 +131,15 @@ export function buildOutgoingDocumentPdfData(source: any, data: OutgoingDocument
} }
const totals = calculateOutgoingDocumentTotals(document, data.advanceInvoices) const totals = calculateOutgoingDocumentTotals(document, data.advanceInvoices)
// Skonto bezieht sich bei Schlussrechnungen auf die Restforderung nach Abzug der Abschläge.
const cashDiscountTerms = ["invoices", "advanceInvoices"].includes(document.type)
? buildCashDiscountTerms({
percent: document.cashDiscountPercent,
days: document.cashDiscountDays,
documentDate: document.documentDate,
amount: document.type === "invoices" ? totals.totalSumToPay : totals.totalGross,
})
: null
const rows = document.rows.map((row: any) => { const rows = document.rows.map((row: any) => {
if (!["pagebreak", "title"].includes(row.mode)) { if (!["pagebreak", "title"].includes(row.mode)) {
if (row.agriculture?.description) row.descriptionText = row.agriculture.description if (row.agriculture?.description) row.descriptionText = row.agriculture.description
@@ -160,6 +170,7 @@ export function buildOutgoingDocumentPdfData(source: any, data: OutgoingDocument
zahlungsziel_in_tagen: document.paymentDays, zahlungsziel_in_tagen: document.paymentDays,
zahlungsart: document.payment_type === "transfer" ? "Überweisung" : "Lastschrift", zahlungsart: document.payment_type === "transfer" ? "Überweisung" : "Lastschrift",
diesel_gesamtverbrauch: document.agriculture?.dieselUsageTotal, diesel_gesamtverbrauch: document.agriculture?.dieselUsageTotal,
...getCashDiscountTemplateContext(cashDiscountTerms),
} }
const formatDate = (value: any) => value ? dayjs(value).format("DD.MM.YYYY") : "XXXX" const formatDate = (value: any) => value ? dayjs(value).format("DD.MM.YYYY") : "XXXX"
const formatSums = (sums: Record<string, number>) => Object.fromEntries(Object.entries(sums).map(([key, value]) => [key, renderCurrency(value)])) const formatSums = (sums: Record<string, number>) => Object.fromEntries(Object.entries(sums).map(([key, value]) => [key, renderCurrency(value)]))
@@ -198,7 +209,7 @@ export function buildOutgoingDocumentPdfData(source: any, data: OutgoingDocument
], ],
title: document.title, title: document.title,
description: document.description, description: document.description,
endText: documentTemplateHandlebars.compile(document.endText || "")(context), endText: appendCashDiscountText(documentTemplateHandlebars.compile(document.endText || "")(context), document.endText, cashDiscountTerms),
startText: documentTemplateHandlebars.compile(document.startText || "")(context), startText: documentTemplateHandlebars.compile(document.startText || "")(context),
rows, rows,
totalArray: [ totalArray: [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import {useFunctions} from "~/composables/useFunctions.js";
import EntityModalButtons from "~/components/EntityModalButtons.vue"; import EntityModalButtons from "~/components/EntityModalButtons.vue";
import { documentTemplateHandlebars } from "~/utils/handlebars"; import { documentTemplateHandlebars } from "~/utils/handlebars";
import { getTitleSectionMove, moveTitleSection } from "~/utils/documentTitleSections.js"; import { getTitleSectionMove, moveTitleSection } from "~/utils/documentTitleSections.js";
import { appendCashDiscountText, buildCashDiscountTerms, formatCashDiscountText, getCashDiscountTemplateContext } from "~/utils/cashDiscount";
const dataStore = useDataStore() const dataStore = useDataStore()
const profileStore = useProfileStore() const profileStore = useProfileStore()
@@ -57,6 +58,8 @@ const itemInfo = ref({
deliveryDateType: "Lieferdatum", deliveryDateType: "Lieferdatum",
dateOfPerformance: null, dateOfPerformance: null,
paymentDays: auth.activeTenantData.standardPaymentDays, paymentDays: auth.activeTenantData.standardPaymentDays,
cashDiscountPercent: null,
cashDiscountDays: null,
payment_type: "transfer", payment_type: "transfer",
outgoingsepamandate: null, outgoingsepamandate: null,
availableInPortal: false, availableInPortal: false,
@@ -635,7 +638,11 @@ const setupPage = async () => {
if (optionsToImport.deliveryDate) itemInfo.value.deliveryDate = linkedDocument.deliveryDate if (optionsToImport.deliveryDate) itemInfo.value.deliveryDate = linkedDocument.deliveryDate
if (optionsToImport.deliveryDateEnd) itemInfo.value.deliveryDateEnd = linkedDocument.deliveryDateEnd if (optionsToImport.deliveryDateEnd) itemInfo.value.deliveryDateEnd = linkedDocument.deliveryDateEnd
if (optionsToImport.documentDate) itemInfo.value.documentDate = linkedDocument.documentDate if (optionsToImport.documentDate) itemInfo.value.documentDate = linkedDocument.documentDate
if (optionsToImport.paymentDays) itemInfo.value.paymentDays = linkedDocument.paymentDays if (optionsToImport.paymentDays) {
itemInfo.value.paymentDays = linkedDocument.paymentDays
itemInfo.value.cashDiscountPercent = linkedDocument.cashDiscountPercent ?? null
itemInfo.value.cashDiscountDays = linkedDocument.cashDiscountDays ?? null
}
if (optionsToImport.payment_type) itemInfo.value.payment_type = linkedDocument.payment_type if (optionsToImport.payment_type) itemInfo.value.payment_type = linkedDocument.payment_type
if (optionsToImport.customSurchargePercentage) itemInfo.value.customSurchargePercentage = linkedDocument.customSurchargePercentage if (optionsToImport.customSurchargePercentage) itemInfo.value.customSurchargePercentage = linkedDocument.customSurchargePercentage
if (optionsToImport.contactPerson) itemInfo.value.contactPerson = linkedDocument.contactPerson if (optionsToImport.contactPerson) itemInfo.value.contactPerson = linkedDocument.contactPerson
@@ -663,6 +670,8 @@ const setupPage = async () => {
itemInfo.value.deliveryDateEnd = linkedDocument.deliveryDateEnd itemInfo.value.deliveryDateEnd = linkedDocument.deliveryDateEnd
itemInfo.value.documentDate = linkedDocument.documentDate itemInfo.value.documentDate = linkedDocument.documentDate
itemInfo.value.paymentDays = linkedDocument.paymentDays itemInfo.value.paymentDays = linkedDocument.paymentDays
itemInfo.value.cashDiscountPercent = linkedDocument.cashDiscountPercent ?? null
itemInfo.value.cashDiscountDays = linkedDocument.cashDiscountDays ?? null
itemInfo.value.payment_type = linkedDocument.payment_type itemInfo.value.payment_type = linkedDocument.payment_type
itemInfo.value.customSurchargePercentage = linkedDocument.customSurchargePercentage itemInfo.value.customSurchargePercentage = linkedDocument.customSurchargePercentage
itemInfo.value.contactPerson = linkedDocument.contactPerson itemInfo.value.contactPerson = linkedDocument.contactPerson
@@ -833,6 +842,10 @@ const setCustomerData = async (customerId, loadOnlyAdress = false) => {
itemInfo.value.address.country = customer.infoData.country itemInfo.value.address.country = customer.infoData.country
if (!loadOnlyAdress && customer.customPaymentDays) itemInfo.value.paymentDays = customer.customPaymentDays if (!loadOnlyAdress && customer.customPaymentDays) itemInfo.value.paymentDays = customer.customPaymentDays
if (!loadOnlyAdress && customer.customCashDiscountPercent) {
itemInfo.value.cashDiscountPercent = customer.customCashDiscountPercent
itemInfo.value.cashDiscountDays = customer.customCashDiscountDays ?? null
}
if (!loadOnlyAdress && customer.custom_payment_type) itemInfo.value.payment_type = customer.custom_payment_type if (!loadOnlyAdress && customer.custom_payment_type) itemInfo.value.payment_type = customer.custom_payment_type
if (!loadOnlyAdress) { if (!loadOnlyAdress) {
itemInfo.value.taxType = normalizeTaxTypeValue(customer.customTaxType) itemInfo.value.taxType = normalizeTaxTypeValue(customer.customTaxType)
@@ -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(() => { const findDocumentErrors = computed(() => {
let errors = [] let errors = [...eInvoiceIssues.value]
if (itemInfo.value.customer === null) errors.push({message: "Es ist kein Kunde ausgewählt", type: "breaking"}) if (itemInfo.value.customer === null) errors.push({message: "Es ist kein Kunde ausgewählt", type: "breaking"})
if (itemInfo.value.contact === null) errors.push({message: "Es ist kein Kontakt ausgewählt", type: "info"}) if (itemInfo.value.contact === null) errors.push({message: "Es ist kein Kontakt ausgewählt", type: "info"})
@@ -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(() => { const documentReport = computed(() => {
let totalProductsPurchasePrice = 0 let totalProductsPurchasePrice = 0
@@ -1574,7 +1644,8 @@ const getDocumentData = async () => {
kundenname: customerData && customerData.name, kundenname: customerData && customerData.name,
zahlungsziel_in_tagen: itemInfo.paymentDays, zahlungsziel_in_tagen: itemInfo.paymentDays,
zahlungsart: itemInfo.payment_type === "transfer" ? "Überweisung" : "Lastschrift", zahlungsart: itemInfo.payment_type === "transfer" ? "Überweisung" : "Lastschrift",
diesel_gesamtverbrauch: (itemInfo.agriculture && itemInfo.agriculture.dieselUsageTotal) && itemInfo.agriculture.dieselUsageTotal diesel_gesamtverbrauch: (itemInfo.agriculture && itemInfo.agriculture.dieselUsageTotal) && itemInfo.agriculture.dieselUsageTotal,
...getCashDiscountTemplateContext(cashDiscountTerms.value)
} }
} }
@@ -1684,7 +1755,7 @@ const getDocumentData = async () => {
], ],
title: itemInfo.value.title, title: itemInfo.value.title,
description: itemInfo.value.description, description: itemInfo.value.description,
endText: templateEndText(generateContext(itemInfo.value, contactData)), endText: appendCashDiscountText(templateEndText(generateContext(itemInfo.value, contactData)), itemInfo.value.endText, cashDiscountTerms.value),
startText: templateStartText(generateContext(itemInfo.value, contactData)), startText: templateStartText(generateContext(itemInfo.value, contactData)),
rows: rows, rows: rows,
totalArray: [ totalArray: [
@@ -1739,7 +1810,7 @@ const getDocumentData = async () => {
const showDocument = ref(false) const showDocument = ref(false)
const uri = ref("") const uri = ref("")
const generateDocument = async () => { const generateDocument = async (options = {}) => {
showDocument.value = false showDocument.value = false
const selectedLetterhead = getSelectedLetterhead() const selectedLetterhead = getSelectedLetterhead()
@@ -1750,7 +1821,7 @@ const generateDocument = async () => {
const path = selectedLetterhead.path const path = selectedLetterhead.path
uri.value = await useFunctions().useCreatePDF(await getDocumentData(), path, "createdDocument") uri.value = await useFunctions().useCreatePDF(await getDocumentData(), path, "createdDocument", options)
/*uri.value = await useNuxtApp().$api("/api/functions/createinvoicepdf",{ /*uri.value = await useNuxtApp().$api("/api/functions/createinvoicepdf",{
method: "POST", method: "POST",
body: { body: {
@@ -1819,6 +1890,8 @@ const saveSerialInvoice = async () => {
project: normalizeEntityId(itemInfo.value.project), project: normalizeEntityId(itemInfo.value.project),
costcentre: itemInfo.value.costcentre, costcentre: itemInfo.value.costcentre,
paymentDays: itemInfo.value.paymentDays, paymentDays: itemInfo.value.paymentDays,
cashDiscountPercent: itemInfo.value.cashDiscountPercent === "" || itemInfo.value.cashDiscountPercent === null ? null : Number(itemInfo.value.cashDiscountPercent),
cashDiscountDays: itemInfo.value.cashDiscountDays === "" || itemInfo.value.cashDiscountDays === null ? null : Number(itemInfo.value.cashDiscountDays),
payment_type: itemInfo.value.payment_type, payment_type: itemInfo.value.payment_type,
outgoingsepamandate: normalizeEntityId(itemInfo.value.outgoingsepamandate), outgoingsepamandate: normalizeEntityId(itemInfo.value.outgoingsepamandate),
deliveryDateType: "Leistungszeitraum", deliveryDateType: "Leistungszeitraum",
@@ -1935,6 +2008,8 @@ const saveDocument = async (state, resetup = false) => {
deliveryDate: itemInfo.value.deliveryDate, deliveryDate: itemInfo.value.deliveryDate,
deliveryDateEnd: itemInfo.value.deliveryDateEnd, deliveryDateEnd: itemInfo.value.deliveryDateEnd,
paymentDays: itemInfo.value.paymentDays, paymentDays: itemInfo.value.paymentDays,
cashDiscountPercent: itemInfo.value.cashDiscountPercent === "" || itemInfo.value.cashDiscountPercent === null ? null : Number(itemInfo.value.cashDiscountPercent),
cashDiscountDays: itemInfo.value.cashDiscountDays === "" || itemInfo.value.cashDiscountDays === null ? null : Number(itemInfo.value.cashDiscountDays),
payment_type: itemInfo.value.payment_type, payment_type: itemInfo.value.payment_type,
outgoingsepamandate: normalizeEntityId(itemInfo.value.outgoingsepamandate), outgoingsepamandate: normalizeEntityId(itemInfo.value.outgoingsepamandate),
deliveryDateType: itemInfo.value.deliveryDateType, deliveryDateType: itemInfo.value.deliveryDateType,
@@ -1996,6 +2071,18 @@ const saveFinalizedCostCentres = async () => {
const selectedTab = ref("0") const selectedTab = ref("0")
const closeDocument = async () => { const closeDocument = async () => {
// Vor Vorschau und Fertigstellung mit dem aktuellen Stand prüfen, nicht mit dem letzten (entprellten) Ergebnis
await refreshEInvoiceIssues()
const breakingErrors = findDocumentErrors.value.filter(i => i.type === "breaking")
if (breakingErrors.length > 0) {
selectedTab.value = "0"
toast.add({
title: "Beleg kann nicht fertiggestellt werden",
description: breakingErrors.map(i => i.message).join(" · "),
color: "error",
})
return
}
if(selectedTab.value === "0") { if(selectedTab.value === "0") {
await generateDocument() await generateDocument()
@@ -2005,7 +2092,16 @@ const closeDocument = async () => {
await saveDocument("Gebucht") await saveDocument("Gebucht")
try {
await uploadGeneratedDocument() await uploadGeneratedDocument()
} catch (error) {
console.error(error)
toast.add({
title: "PDF bzw. E-Rechnung konnte nicht erstellt werden",
description: error?.data?.error || error?.message,
color: "error",
})
}
guard.value = false guard.value = false
@@ -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 // Erzeugt die PDF aus dem aktuellen Stand und hängt sie an den Beleg an, ohne den Datensatz zu speichern
const uploadGeneratedDocument = async () => { const uploadGeneratedDocument = async () => {
await generateDocument() // Rechnungen werden als E-Rechnung (ZUGFeRD) abgelegt; das XML erzeugt das Backend aus dem gespeicherten Beleg
await generateDocument({
createdDocumentId: itemInfo.value.id,
onResult: (result) => {
const hints = (result?.einvoiceIssues || []).map(i => i.message)
if (hints.length) toast.add({ title: "E-Rechnung mit Hinweisen erstellt", description: hints.join(" · "), color: "warning" })
},
})
if (!uri.value) throw new Error("PDF konnte nicht erzeugt werden") if (!uri.value) throw new Error("PDF konnte nicht erzeugt werden")
@@ -2690,6 +2793,39 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
class="w-full" class="w-full"
/> />
</UFormField> </UFormField>
<UFormField
v-if="['invoices', 'advanceInvoices', 'serialInvoices'].includes(itemInfo.type)"
class="min-w-0 flex-1"
label="Skonto:"
:help="cashDiscountTerms ? formatCashDiscountText(cashDiscountTerms) : undefined"
>
<div class="flex gap-1">
<UInput
type="number"
step="0.01"
min="0"
max="10"
placeholder="%"
v-model="itemInfo.cashDiscountPercent"
class="w-full"
>
<template #trailing>
<span class="text-gray-500 dark:text-gray-400 text-xs">%</span>
</template>
</UInput>
<UInput
type="number"
min="0"
placeholder="Tage"
v-model="itemInfo.cashDiscountDays"
class="w-full"
>
<template #trailing>
<span class="text-gray-500 dark:text-gray-400 text-xs">Tage</span>
</template>
</UInput>
</div>
</UFormField>
<UFormField <UFormField
class="min-w-0 flex-1" class="min-w-0 flex-1"
label="Individueller Aufschlag:" label="Individueller Aufschlag:"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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