feat(banking): add cost centres and VAT to account allocations

This commit is contained in:
2026-09-23 18:08:09 +02:00
parent 847a616285
commit cbec5b058d
21 changed files with 517 additions and 42 deletions

View File

@@ -0,0 +1,2 @@
ALTER TABLE "statementallocations"
ADD COLUMN "costcentre" uuid REFERENCES "costcentres"("id");

View File

@@ -477,6 +477,13 @@
"when": 1788850800000, "when": 1788850800000,
"tag": "0070_native_communication_chat", "tag": "0070_native_communication_chat",
"breakpoints": true "breakpoints": true
},
{
"idx": 68,
"version": "7",
"when": 1790121600000,
"tag": "0071_statement_allocation_costcentre",
"breakpoints": true
} }
] ]
} }

View File

@@ -18,6 +18,7 @@ import { incominginvoices } from "./incominginvoices"
import { createddocuments } from "./createddocuments" import { createddocuments } from "./createddocuments"
import { bankstatements } from "./bankstatements" import { bankstatements } from "./bankstatements"
import { accounts } from "./accounts" // Falls noch nicht erstellt → bitte melden! import { accounts } from "./accounts" // Falls noch nicht erstellt → bitte melden!
import { costcentres } from "./costcentres"
export const statementallocations = pgTable("statementallocations", { export const statementallocations = pgTable("statementallocations", {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
@@ -58,6 +59,7 @@ export const statementallocations = pgTable("statementallocations", {
manualBookingDate: text("manual_booking_date"), manualBookingDate: text("manual_booking_date"),
datevTaxKey: text("datev_tax_key"), datevTaxKey: text("datev_tax_key"),
costcentre: uuid("costcentre").references(() => costcentres.id),
bookingMode: text("booking_mode").notNull().default("expense"), bookingMode: text("booking_mode").notNull().default("expense"),
depreciationMonths: integer("depreciation_months"), depreciationMonths: integer("depreciation_months"),

View File

@@ -1,3 +1,5 @@
import { normalizeBankAllocationDetails } from "../modules/bank-allocation-details"
const hasValue = (value: unknown) => value !== null && value !== undefined && value !== "" const hasValue = (value: unknown) => value !== null && value !== undefined && value !== ""
const stringArg = (args: Record<string, unknown>, key: string) => { const stringArg = (args: Record<string, unknown>, key: string) => {
@@ -110,6 +112,10 @@ export const prepareStatementAllocationInput = (args: Record<string, unknown>) =
if (hasInvoice) payload.manualInvoiceSide = manualInvoiceSide if (hasInvoice) payload.manualInvoiceSide = manualInvoiceSide
} }
const details = normalizeBankAllocationDetails({ ...payload, costcentre: args.costcentre })
if (args.costcentre !== undefined) payload.costcentre = details.costcentre
if (args.datevTaxKey !== undefined) payload.datevTaxKey = details.datevTaxKey
if (bookingMode === "expense") return payload if (bookingMode === "expense") return payload
const depreciationMonths = requiredNumber(args, "depreciationMonths") const depreciationMonths = requiredNumber(args, "depreciationMonths")

View File

@@ -2,6 +2,7 @@ import { outgoingAddressSchema, outgoingRowsSchema, outgoingReportSchema } from
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination" import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
import { and, desc, eq, ilike, or } from "drizzle-orm" import { and, desc, eq, ilike, or } from "drizzle-orm"
import { randomUUID } from "node:crypto" import { randomUUID } from "node:crypto"
import { validateBankAllocationCostCentre } from "../../modules/bank-allocation-details"
import { import {
accounts, accounts,
bankstatements, bankstatements,
@@ -406,6 +407,7 @@ const assertTenantEntityExists = async (
const assertStatementAllocationReferences = async (context: any, payload: Record<string, any>) => { const assertStatementAllocationReferences = async (context: any, payload: Record<string, any>) => {
await Promise.all([ await Promise.all([
validateBankAllocationCostCentre(context.server.db, context.tenantId, payload.costcentre),
assertTenantEntityExists(context, bankstatements, bankstatements.id, bankstatements.tenant, payload.bankstatement, "Bankumsatz"), assertTenantEntityExists(context, bankstatements, bankstatements.id, bankstatements.tenant, payload.bankstatement, "Bankumsatz"),
assertTenantEntityExists(context, createddocuments, createddocuments.id, createddocuments.tenant, payload.createddocument, "Ausgangsbeleg"), assertTenantEntityExists(context, createddocuments, createddocuments.id, createddocuments.tenant, payload.createddocument, "Ausgangsbeleg"),
assertTenantEntityExists(context, incominginvoices, incominginvoices.id, incominginvoices.tenant, payload.incominginvoice, "Eingangsbeleg"), assertTenantEntityExists(context, incominginvoices, incominginvoices.id, incominginvoices.tenant, payload.incominginvoice, "Eingangsbeleg"),
@@ -1419,7 +1421,8 @@ export const accountingTools: McpTool[] = [
manualBookingDate: { type: "string", format: "date", description: "Buchungsdatum einer manuellen Buchung." }, manualBookingDate: { type: "string", format: "date", description: "Buchungsdatum einer manuellen Buchung." },
manualInvoiceSide: { type: "string", enum: ["debit", "credit"], description: "Soll/Haben-Seite eines Eingangsbelegs in einer manuellen Buchung." }, manualInvoiceSide: { type: "string", enum: ["debit", "credit"], description: "Soll/Haben-Seite eines Eingangsbelegs in einer manuellen Buchung." },
description: { type: "string" }, description: { type: "string" },
datevTaxKey: { type: "string", description: "Optionaler DATEV-Steuerschlüssel, z. B. 9, 8, 19 oder 18." }, datevTaxKey: { type: "string", description: "DATEV-Steuerschlüssel auf den Bruttobetrag: 9/8 für 19/7 % Vorsteuer, 3/2 für 19/7 % Umsatzsteuer, 40 ohne Steuer (Automatik aufheben)." },
costcentre: { type: ["string", "null"], description: "Kostenstellen-UUID für direkte Bankbuchungen auf Sachkonten oder zusätzliche Konten." },
bookingMode: { type: "string", enum: ["expense", "depreciation_single", "depreciation_bundle"], default: "expense" }, bookingMode: { type: "string", enum: ["expense", "depreciation_single", "depreciation_bundle"], default: "expense" },
depreciationMonths: { type: "number", minimum: 1 }, depreciationMonths: { type: "number", minimum: 1 },
depreciationStartDate: { type: "string", format: "date" }, depreciationStartDate: { type: "string", format: "date" },

View File

@@ -0,0 +1,30 @@
import { and, eq } from "drizzle-orm"
import { costcentres } from "../../db/schema"
const invalidInput = (message: string) => Object.assign(new Error(message), { statusCode: 400 })
export function normalizeBankAllocationDetails(payload: Record<string, any>) {
const costcentre = payload.costcentre ? String(payload.costcentre).trim() : null
const datevTaxKey = payload.datevTaxKey ? String(payload.datevTaxKey).trim() : null
if (costcentre && !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(costcentre)) {
throw invalidInput("Die Kostenstelle muss eine gültige UUID sein.")
}
if (datevTaxKey && !/^\d{1,4}$/.test(datevTaxKey)) {
throw invalidInput("Der DATEV-Steuerschlüssel muss aus 1 bis 4 Ziffern bestehen.")
}
if (costcentre && (!payload.bankstatement || !(payload.account || payload.ownaccount)
|| payload.createddocument || payload.incominginvoice || payload.customer || payload.vendor)) {
throw invalidInput("Kostenstellen sind hier nur für direkte Bankbuchungen auf Buchungskonten zulässig.")
}
return { costcentre, datevTaxKey }
}
export async function validateBankAllocationCostCentre(db: any, tenantId: number, costcentre: string | null) {
if (!costcentre) return
const [record] = await db.select({ id: costcentres.id }).from(costcentres).where(and(
eq(costcentres.id, costcentre),
eq(costcentres.tenant, tenantId),
eq(costcentres.archived, false),
)).limit(1)
if (!record) throw invalidInput("Die Kostenstelle wurde nicht gefunden oder ist archiviert.")
}

View File

@@ -9,6 +9,7 @@ import { DE_BANK_CODE_TO_NAME } from "../utils/deBankCodes"
import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics" import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics"
import { centralServicesClient } from "../modules/push-server.client" 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 { import {
bankrequisitions, bankrequisitions,
@@ -1166,6 +1167,8 @@ export default async function bankingRoutes(server: FastifyInstance) {
const { data: payload } = req.body as { data: any } const { data: payload } = req.body as { data: any }
const prepared = prepareStatementAllocationPayload(payload) const prepared = prepareStatementAllocationPayload(payload)
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))
await validateBankAllocationCostCentre(server.db, req.user.tenant_id, prepared.data.costcentre)
prepared.data = await useCurrentIncomingInvoiceAmount(req.user.tenant_id, prepared.data) prepared.data = await useCurrentIncomingInvoiceAmount(req.user.tenant_id, prepared.data)
const inserted = await server.db.insert(statementallocations).values({ const inserted = await server.db.insert(statementallocations).values({
@@ -1217,6 +1220,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
return reply.send(createdRecord) return reply.send(createdRecord)
} catch (err) { } catch (err) {
if (err.statusCode === 400) return reply.code(400).send({ error: err.message })
console.error(err) console.error(err)
return reply.code(500).send({ error: "Failed to create statement" }) return reply.code(500).send({ error: "Failed to create statement" })
} }

View File

@@ -23,7 +23,8 @@ import {
vendors, vendors,
bankaccounts, bankaccounts,
bankstatements, bankstatements,
ownaccounts ownaccounts,
costcentres
} from "../../../db/schema"; } from "../../../db/schema";
dayjs.extend(isBetween); dayjs.extend(isBetween);
@@ -80,6 +81,29 @@ const formatDatevDate = (date: dayjs.ConfigType, format: string) => {
return parsed.isValid() ? parsed.tz(DATEV_TIMEZONE).format(format) : ""; return parsed.isValid() ? parsed.tz(DATEV_TIMEZONE).format(format) : "";
}; };
export const getBankAccountBookingLine = (alloc: any) => {
const bs = alloc.bankstatement;
const account = alloc.account || alloc.ownaccount;
const columns = Array(124).fill("");
const quote = (value: any) => `"${escapeString(String(value ?? ""))}"`;
columns[0] = displayCurrency(Number(alloc.amount), true);
columns[1] = quote(Number(alloc.amount) < 0 ? "H" : "S");
columns[6] = bs.account?.datevNumber || "";
columns[7] = account.number;
columns[8] = quote(alloc.datevTaxKey || "");
columns[9] = formatDatevDate(bs.date, "DDMM");
columns[13] = quote(`${Number(alloc.amount) > 0 ? "ZE" : "ZA"} ${account.number} - ${account.label || account.name || ""} ${alloc.description || ""} ${bs.text || ""}`.substring(0, 59));
columns[20] = quote("Geschäftspartner");
columns[21] = quote(bs.debName || bs.credName || "");
columns[28] = quote("Belegdatum");
columns[29] = quote(formatDatevDate(bs.date, "DD.MM.YYYY"));
columns[36] = quote(alloc.costcentre?.number || "");
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;
@@ -136,7 +160,7 @@ export async function buildExportZip(
const endDateFmt = formatDatevDate(endDate, "YYYYMMDD"); const endDateFmt = formatDatevDate(endDate, "YYYYMMDD");
let header = `"EXTF";700;21;"Buchungsstapel";13;${dateNowStr};;"FE";"Florian Federspiel";;${beraternr};${mandantennr};20250101;4;${startDateFmt};${endDateFmt};"Buchungsstapel";"FF";1;0;1;"EUR";;"";;;"03";;;"";""`; let header = `"EXTF";700;21;"Buchungsstapel";13;${dateNowStr};;"FE";"Florian Federspiel";;${beraternr};${mandantennr};20250101;4;${startDateFmt};${endDateFmt};"Buchungsstapel";"FF";1;0;1;"EUR";;"";;;"03";;;"";""`;
let colHeaders = `Umsatz;Soll-/Haben-Kennzeichen;WKZ Umsatz;Kurs;Basisumsatz;WKZ Basisumsatz;Konto;Gegenkonto;BU-Schluessel;Belegdatum;Belegfeld 1;Belegfeld 2;Skonto;Buchungstext;Postensperre;Diverse Adressnummer;Geschaeftspartnerbank;Sachverhalt;Zinssperre;Beleglink;Beleginfo - Art 1;Beleginfo - Inhalt 1;Beleginfo - Art 2;Beleginfo - Inhalt 2;Beleginfo - Art 3;Beleginfo - Inhalt 3;Beleginfo - Art 4;Beleginfo - Inhalt 4;Beleginfo - Art 5;Beleginfo - Inhalt 5;Beleginfo - Art 6;Beleginfo - Inhalt 6;Beleginfo - Art 7;Beleginfo - Inhalt 7;Beleginfo - Art 8;Beleginfo - Inhalt 8;KOST1 - Kostenstelle;KOST2 - Kostenstelle;Kost Menge;EU-Land u. USt-IdNr. (Bestimmung);EU-Steuersatz (Bestimmung);Abw. Versteuerungsart;Sachverhalt L+L;Funktionsergaenzung L+L;BU 49 Hauptfunktionstyp;BU 49 Hauptfunktionsnummer;BU 49 Funktionsergaenzung;Zusatzinformation - Art 1;Zusatzinformation - Inhalt 1;Zusatzinformation - Art 2;Zusatzinformation - Inhalt 2;Zusatzinformation - Art 3;Zusatzinformation - Inhalt 3;Zusatzinformation - Art 4;Zusatzinformation - Inhalt 4;Zusatzinformation - Art 5;Zusatzinformation - Inhalt 5;Zusatzinformation - Art 6;Zusatzinformation - Inhalt 6;Zusatzinformation - Art 7;Zusatzinformation - Inhalt 7;Zusatzinformation - Art 8;Zusatzinformation - Inhalt 8;Zusatzinformation - Art 9;Zusatzinformation - Inhalt 9;Zusatzinformation - Art 10;Zusatzinformation - Inhalt 10;Zusatzinformation - Art 11;Zusatzinformation - Inhalt 11;Zusatzinformation - Art 12;Zusatzinformation - Inhalt 12;Zusatzinformation - Art 13;Zusatzinformation - Inhalt 13;Zusatzinformation - Art 14;Zusatzinformation - Inhalt 14;Zusatzinformation - Art 15;Zusatzinformation - Inhalt 15;Zusatzinformation - Art 16;Zusatzinformation - Inhalt 16;Zusatzinformation - Art 17;Zusatzinformation - Inhalt 17;Zusatzinformation - Art 18;Zusatzinformation - Inhalt 18;Zusatzinformation - Art 19;Zusatzinformation - Inhalt 19;Zusatzinformation - Art 20;Zusatzinformation - Inhalt 20;Stueck;Gewicht;Zahlweise;Zahlweise;Veranlagungsjahr;Zugeordnete Faelligkeit;Skontotyp;Auftragsnummer;Buchungstyp;USt-Schluessel (Anzahlungen);EU-Mitgliedstaat (Anzahlungen);Sachverhalt L+L (Anzahlungen);EU-Steuersatz (Anzahlungen);Erloeskonto (Anzahlungen);Herkunft-Kz;Leerfeld;KOST-Datum;SEPA-Mandatsreferenz;Skontosperre;Gesellschaftername;Beteiligtennummer;Identifikationsnummer;Zeichnernummer;Postensperre bis;Bezeichnung SoBil-Sachverhalt;Kennzeichen SoBil-Buchung;Festschreibung;Leistungsdatum;Datum Zuord. Steuerperiode;Faelligkeit;Generalumkehr;Steuersatz;Land;Abrechnungsreferenz;BVV-Position;EU-Mitgliedstaat u. UStID(Ursprung);EU-Steuersatz(Ursprung);Abw. Skontokonto`; let colHeaders = `Umsatz;Soll-/Haben-Kennzeichen;WKZ Umsatz;Kurs;Basisumsatz;WKZ Basisumsatz;Konto;Gegenkonto;BU-Schluessel;Belegdatum;Belegfeld 1;Belegfeld 2;Skonto;Buchungstext;Postensperre;Diverse Adressnummer;Geschaeftspartnerbank;Sachverhalt;Zinssperre;Beleglink;Beleginfo - Art 1;Beleginfo - Inhalt 1;Beleginfo - Art 2;Beleginfo - Inhalt 2;Beleginfo - Art 3;Beleginfo - Inhalt 3;Beleginfo - Art 4;Beleginfo - Inhalt 4;Beleginfo - Art 5;Beleginfo - Inhalt 5;Beleginfo - Art 6;Beleginfo - Inhalt 6;Beleginfo - Art 7;Beleginfo - Inhalt 7;Beleginfo - Art 8;Beleginfo - Inhalt 8;KOST1 - Kostenstelle;KOST2 - Kostenstelle;Kost Menge;EU-Land u. USt-IdNr. (Bestimmung);EU-Steuersatz (Bestimmung);Abw. Versteuerungsart;Sachverhalt L+L;Funktionsergaenzung L+L;BU 49 Hauptfunktionstyp;BU 49 Hauptfunktionsnummer;BU 49 Funktionsergaenzung;Zusatzinformation - Art 1;Zusatzinformation - Inhalt 1;Zusatzinformation - Art 2;Zusatzinformation - Inhalt 2;Zusatzinformation - Art 3;Zusatzinformation - Inhalt 3;Zusatzinformation - Art 4;Zusatzinformation - Inhalt 4;Zusatzinformation - Art 5;Zusatzinformation - Inhalt 5;Zusatzinformation - Art 6;Zusatzinformation - Inhalt 6;Zusatzinformation - Art 7;Zusatzinformation - Inhalt 7;Zusatzinformation - Art 8;Zusatzinformation - Inhalt 8;Zusatzinformation - Art 9;Zusatzinformation - Inhalt 9;Zusatzinformation - Art 10;Zusatzinformation - Inhalt 10;Zusatzinformation - Art 11;Zusatzinformation - Inhalt 11;Zusatzinformation - Art 12;Zusatzinformation - Inhalt 12;Zusatzinformation - Art 13;Zusatzinformation - Inhalt 13;Zusatzinformation - Art 14;Zusatzinformation - Inhalt 14;Zusatzinformation - Art 15;Zusatzinformation - Inhalt 15;Zusatzinformation - Art 16;Zusatzinformation - Inhalt 16;Zusatzinformation - Art 17;Zusatzinformation - Inhalt 17;Zusatzinformation - Art 18;Zusatzinformation - Inhalt 18;Zusatzinformation - Art 19;Zusatzinformation - Inhalt 19;Zusatzinformation - Art 20;Zusatzinformation - Inhalt 20;Stueck;Gewicht;Zahlweise;Veranlagungsjahr;Zugeordnete Faelligkeit;Skontotyp;Auftragsnummer;Buchungstyp;USt-Schluessel (Anzahlungen);EU-Mitgliedstaat (Anzahlungen);Sachverhalt L+L (Anzahlungen);EU-Steuersatz (Anzahlungen);Erloeskonto (Anzahlungen);Herkunft-Kz;Leerfeld;KOST-Datum;SEPA-Mandatsreferenz;Skontosperre;Gesellschaftername;Beteiligtennummer;Identifikationsnummer;Zeichnernummer;Postensperre bis;Bezeichnung SoBil-Sachverhalt;Kennzeichen SoBil-Buchung;Festschreibung;Leistungsdatum;Datum Zuord. Steuerperiode;Faelligkeit;Generalumkehr;Steuersatz;Land;Abrechnungsreferenz;BVV-Position;EU-Mitgliedstaat u. UStID(Ursprung);EU-Steuersatz(Ursprung);Abw. Skontokonto`;
// --------------------------------------------------------- // ---------------------------------------------------------
// 1. DATEN LADEN (CORE API SELECT & JOIN) // 1. DATEN LADEN (CORE API SELECT & JOIN)
@@ -209,6 +233,7 @@ export async function buildExportZip(
direct_vend: vendors, // Direkte Zuordnung an Kreditor direct_vend: vendors, // Direkte Zuordnung an Kreditor
direct_cust: customers, // Direkte Zuordnung an Debitor direct_cust: customers, // Direkte Zuordnung an Debitor
own: ownaccounts, own: ownaccounts,
costcentre: costcentres,
contra_acc: ContraAccount, contra_acc: ContraAccount,
contra_vend: ContraVendor, contra_vend: ContraVendor,
contra_cust: ContraCustomer, contra_cust: ContraCustomer,
@@ -233,6 +258,7 @@ export async function buildExportZip(
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id)) .leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
.leftJoin(customers, eq(statementallocations.customer, customers.id)) .leftJoin(customers, eq(statementallocations.customer, customers.id))
.leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id)) .leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id))
.leftJoin(costcentres, and(eq(statementallocations.costcentre, costcentres.id), eq(costcentres.tenant, tenantId)))
.leftJoin(ContraAccount, eq(statementallocations.contraAccount, ContraAccount.id)) .leftJoin(ContraAccount, eq(statementallocations.contraAccount, ContraAccount.id))
.leftJoin(ContraVendor, eq(statementallocations.contraVendor, ContraVendor.id)) .leftJoin(ContraVendor, eq(statementallocations.contraVendor, ContraVendor.id))
.leftJoin(ContraCustomer, eq(statementallocations.contraCustomer, ContraCustomer.id)) .leftJoin(ContraCustomer, eq(statementallocations.contraCustomer, ContraCustomer.id))
@@ -257,10 +283,10 @@ export async function buildExportZip(
// Mapping: Wir bauen das komplexe Objekt nach, das die CSV Logik erwartet // Mapping: Wir bauen das komplexe Objekt nach, das die CSV Logik erwartet
const statementallocationsList = allocRaw.map(r => ({ const statementallocationsList = allocRaw.map(r => ({
...r.allocation, ...r.allocation,
bankstatement: { bankstatement: r.bs ? {
...r.bs, ...r.bs,
account: r.ba // Nesting für bs.account.datevNumber account: r.ba // Nesting für bs.account.datevNumber
}, } : null,
createddocument: r.cd ? { createddocument: r.cd ? {
...r.cd, ...r.cd,
customer: r.cd_cust customer: r.cd_cust
@@ -273,6 +299,7 @@ export async function buildExportZip(
vendor: r.direct_vend, vendor: r.direct_vend,
customer: r.direct_cust, customer: r.direct_cust,
ownaccount: r.own, ownaccount: r.own,
costcentre: r.costcentre,
contraAccount: r.contra_acc, contraAccount: r.contra_acc,
contraVendor: r.contra_vend, contraVendor: r.contra_vend,
contraCustomer: r.contra_cust, contraCustomer: r.contra_cust,
@@ -453,9 +480,7 @@ export async function buildExportZip(
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;;;;"";;;;;;`);
} else if(alloc.account) { } else if(alloc.account) {
const acc = alloc.account; bookingLines.push(getBankAccountBookingLine(alloc));
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${acc.number};"";${dateVal};"";;;"${`${vorzeichen} ${acc.number} - ${escapeString(acc.label)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${bs.credName || ''}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
} else if(alloc.vendor) { } else if(alloc.vendor) {
const vend = alloc.vendor; const vend = alloc.vendor;
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA"; let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
@@ -465,9 +490,7 @@ export async function buildExportZip(
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA"; let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${cust.customerNumber};"";${dateVal};"";;;"${`${vorzeichen} ${cust.customerNumber} - ${escapeString(cust.name)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${cust.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`); bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${cust.customerNumber};"";${dateVal};"";;;"${`${vorzeichen} ${cust.customerNumber} - ${escapeString(cust.name)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${cust.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
} else if(alloc.ownaccount) { } else if(alloc.ownaccount) {
const own = alloc.ownaccount; bookingLines.push(getBankAccountBookingLine(alloc));
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${own.number};"";${dateVal};"";;;"${`${vorzeichen} ${own.number} - ${escapeString(own.name)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${own.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
} }
}); });

View File

@@ -0,0 +1,69 @@
import assert from "node:assert/strict"
import test from "node:test"
import { PgDialect } from "drizzle-orm/pg-core"
import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../src/modules/bank-allocation-details"
import { prepareStatementAllocationInput } from "../src/mcp/statementAllocations"
import bankingRoutes from "../src/routes/banking"
import { statementallocations } from "../db/schema"
const costcentre = "57d31d62-11a2-47c5-b074-69f9c5ab8bba"
test("keeps cost centre and VAT key on the gross bank allocation through input preparation", () => {
const prepared = prepareStatementAllocationInput({ bankstatement: 42, account: 13, amount: -119, costcentre, datevTaxKey: " 9 " })
assert.equal(prepared.amount, -119)
assert.equal(prepared.costcentre, costcentre)
assert.equal(prepared.datevTaxKey, "9")
assert.deepEqual(normalizeBankAllocationDetails({ amount: -119, account: 13, bankstatement: 42 }), { costcentre: null, datevTaxKey: null })
})
test("rejects invalid cost centres, ambiguous document assignments and malformed tax keys", () => {
assert.throws(() => normalizeBankAllocationDetails({ costcentre: "invalid" }), /UUID/)
assert.throws(() => normalizeBankAllocationDetails({ costcentre, bankstatement: 1, account: 13, incominginvoice: 2 }), /direkte Bankbuchungen/)
assert.throws(() => normalizeBankAllocationDetails({ datevTaxKey: '9";evil' }), /Steuerschlüssel/)
})
test("cost centre lookup requires the active tenant and excludes archived centres", async () => {
let condition: any
const query = { from: () => query, where: (value: any) => { condition = value; return query }, limit: async () => [] }
await assert.rejects(validateBankAllocationCostCentre({ select: () => query }, 7, costcentre), /nicht gefunden oder ist archiviert/)
const sql = new PgDialect().sqlToQuery(condition)
assert.match(sql.sql, /"costcentres"\."tenant"/)
assert.match(sql.sql, /"costcentres"\."archived"/)
assert.deepEqual(sql.params, [costcentre, 7, false])
await validateBankAllocationCostCentre({ select: () => ({ ...query, from: () => ({ where: () => ({ limit: async () => [{ id: costcentre }] }) }) }) }, 7, costcentre)
await validateBankAllocationCostCentre({}, 7, null)
})
test("banking API persists the gross allocation with cost centre and VAT, and rejects unknown centres before writing", async () => {
const handlers = new Map<string, any>()
const saved: any[] = []
let centreExists = true
const lookup: any = { from: () => lookup, where: () => lookup, limit: async () => centreExists ? [{ id: costcentre }] : [] }
const server: any = {
get() {}, patch() {}, delete() {},
post: (url: string, handler: any) => handlers.set(url, handler),
db: {
select: () => lookup,
insert: (table: any) => ({ values: (value: any) => {
if (table === statementallocations) saved.push(value)
return { returning: async () => [{ id: "allocation", ...value }] }
} }),
},
}
await bankingRoutes(server)
const handler = handlers.get("/banking/statements")
const request = { user: { tenant_id: 7, user_id: "user" }, body: { data: { bankstatement: 42, account: 13, amount: -119, costcentre, datevTaxKey: "9" } } }
let status = 200
let response: any
const reply = { code: (code: number) => { status = code; return reply }, send: (body: any) => { response = body; return reply } }
await handler(request, reply)
assert.equal(status, 200)
assert.equal(response.amount, -119)
assert.equal(response.costcentre, costcentre)
assert.equal(response.datevTaxKey, "9")
assert.equal(saved[0].tenant, 7)
centreExists = false
await handler(request, reply)
assert.equal(status, 400)
assert.equal(saved.length, 1)
})

View File

@@ -1,6 +1,8 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { getCreatedDocumentRevenueLines } from "../src/utils/export/datev"; import { buildExportZip, getBankAccountBookingLine, getCreatedDocumentRevenueLines } from "../src/utils/export/datev";
import { Uint8ArrayReader, TextWriter, ZipReader } from "@zip.js/zip.js";
import { statementallocations } from "../db/schema";
test("exports gross amounts for DATEV automatic revenue accounts", () => { test("exports gross amounts for DATEV automatic revenue accounts", () => {
const revenueLines = getCreatedDocumentRevenueLines({ const revenueLines = getCreatedDocumentRevenueLines({
@@ -27,3 +29,77 @@ test("preserves negative gross amounts for cancellations", () => {
assert.deepEqual(revenueLines, [{ account: "8400", amount: -119 }]); assert.deepEqual(revenueLines, [{ account: "8400", amount: -119 }]);
}); });
for (const [amount, taxKey, side] of [[-119, "9", "H"], [-107, "8", "H"], [119, "3", "S"], [107, "2", "S"], [119, "9", "S"], [-119, "3", "H"], [-100, "40", "H"]] as const) {
test(`exports gross ${amount} with tax key ${taxKey} and cost centre`, () => {
const columns = getBankAccountBookingLine({
amount, datevTaxKey: taxKey, account: { number: 4900, label: "Konto" },
costcentre: { id: "internal-uuid", number: "KST-1001" },
bankstatement: { id: 42, date: "2026-09-23", account: { datevNumber: "1200" } },
}).split(";");
assert.equal(columns.length, 124);
assert.equal(columns[0], `${Math.abs(amount)},00`);
assert.equal(columns[1], `"${side}"`);
assert.equal(columns[6], "1200");
assert.equal(columns[7], "4900");
assert.equal(columns[8], `"${taxKey}"`);
assert.equal(columns[36], '"KST-1001"');
assert.equal(columns[112], "0");
});
}
test("exports legacy allocations and additional accounts without inventing a tax key", () => {
const columns = getBankAccountBookingLine({
amount: -12.34, ownaccount: { number: "4901", name: "Zusatzkonto" },
bankstatement: { id: 1, date: "2026-09-23", account: { datevNumber: "1200" } },
}).split(";");
assert.equal(columns[0], "12,34");
assert.equal(columns[7], "4901");
assert.equal(columns[8], '""');
assert.equal(columns[36], '""');
});
test("ZIP export carries allocation metadata into the matching CSV columns and leaves invoice payments untaxed", async () => {
const bank = { id: 42, date: "2026-09-23", text: "Zahlung" };
const rows = [
{ allocation: { id: "a", amount: -59.5, datevTaxKey: "9" }, bs: bank, ba: { datevNumber: "1200" }, acc: { number: 4900 }, costcentre: { number: "1001" } },
{ allocation: { id: "b", amount: -59.5, datevTaxKey: "9" }, bs: bank, ba: { datevNumber: "1200" }, own: { number: 4901 }, costcentre: { number: "1002" } },
{ allocation: { id: "c", amount: -119, datevTaxKey: "9" }, bs: bank, ba: { datevNumber: "1200" }, ii: { id: 3, date: bank.date, reference: "ER-3" }, ii_vend: { vendorNumber: 70001 } },
{ allocation: { id: "d", amount: 119, datevTaxKey: "9", manualBookingDate: bank.date }, bs: null, acc: { number: 4900 }, contra_acc: { number: 1200 } },
];
const db = {
select: () => {
let result: any[] = [];
const query: any = {
from: (table: any) => { result = table === statementallocations ? rows : []; return query; },
leftJoin: () => query, where: () => query, orderBy: () => query,
then: (resolve: any) => Promise.resolve(result).then(resolve),
};
return query;
},
};
const archive = await buildExportZip({ db } as any, 1, "2026-09-01", "2026-09-30", "123", "456");
const reader = new ZipReader(new Uint8ArrayReader(archive));
try {
const entries = await reader.getEntries();
const csvEntry = entries.find(entry => entry.filename.startsWith("EXTF_Buchungsstapel"));
assert.ok(csvEntry && "getData" in csvEntry);
const csv = await csvEntry.getData!(new TextWriter());
const [, header, ...bookings] = csv.trim().split("\n").map(line => line.split(";"));
assert.equal(bookings.length, 4);
assert.equal(header.length, 124);
for (const [i, costcentre] of ["1001", "1002"].entries()) {
assert.equal(bookings[i].length, header.length);
assert.equal(bookings[i][header.indexOf("Umsatz")], "59,50");
assert.equal(bookings[i][header.indexOf("BU-Schluessel")], '"9"');
assert.equal(bookings[i][header.indexOf("KOST1 - Kostenstelle")], `"${costcentre}"`);
assert.equal(bookings[i][header.indexOf("Festschreibung")], "0");
}
assert.equal(bookings[2][8], '""');
assert.equal(bookings[3][8], '"9"');
assert.equal(bookings[3][6], "4900");
assert.equal(bookings[3][7], "1200");
} finally {
await reader.close();
}
});

View File

@@ -24,6 +24,18 @@ Im Bankportal verbindest du Konten, prüfst Umsätze und unterstützt die Zuordn
- `Vorschläge`: Automatische Zuordnungen zu Belegen oder Kategorien. - `Vorschläge`: Automatische Zuordnungen zu Belegen oder Kategorien.
- `Manuelle Zuordnung`: Falls kein passender Vorschlag vorhanden ist. - `Manuelle Zuordnung`: Falls kein passender Vorschlag vorhanden ist.
## Direkt auf ein Buchungskonto buchen
Öffne einen Bankumsatz und wähle das Buchungskonto. Der zugewiesene Betrag ist immer der **Bruttobetrag**, auch bei Teilbuchungen.
- Wähle **19 % / 7 % Vorsteuer** für Ausgaben mit abziehbarer Vorsteuer oder **19 % / 7 % Umsatzsteuer** für steuerpflichtige Einnahmen. Bei Erstattungen bleibt die ursprüngliche Steuerart erhalten; das Vorzeichen kehrt ihre Wirkung um.
- Wähle **Ohne Umsatzsteuer**, wenn aus dieser Buchung keine Steuer berechnet werden soll.
- Wähle bei Bedarf eine **Kostenstelle**. Die Zuordnung gilt für den jeweiligen Teilbetrag und erscheint auch in der Auswertung der übergeordneten Kostenstelle.
Vor dem Buchen werden Netto-, Steuer- und Bruttobetrag angezeigt. Beispielsweise ergeben **−119,00 EUR mit 19 % Vorsteuer** einen Nettoaufwand von 100,00 EUR und 19,00 EUR Vorsteuer. Umsatzsteuerauswertung, BWA und Abschreibungsgrundlage berücksichtigen die Steueraufteilung. Die Zuordnung zu einem vorhandenen Rechnungsbeleg erfasst dessen Steuer nicht noch einmal.
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).
## 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

@@ -24,6 +24,18 @@ Im Bankportal verbindest du Konten, prüfst Umsätze und unterstützt die Zuordn
- `Vorschläge`: Automatische Zuordnungen zu Belegen oder Kategorien. - `Vorschläge`: Automatische Zuordnungen zu Belegen oder Kategorien.
- `Manuelle Zuordnung`: Falls kein passender Vorschlag vorhanden ist. - `Manuelle Zuordnung`: Falls kein passender Vorschlag vorhanden ist.
## Direkt auf ein Buchungskonto buchen
Öffne einen Bankumsatz und wähle das Buchungskonto. Der zugewiesene Betrag ist immer der **Bruttobetrag**, auch bei Teilbuchungen.
- Wähle **19 % / 7 % Vorsteuer** für Ausgaben mit abziehbarer Vorsteuer oder **19 % / 7 % Umsatzsteuer** für steuerpflichtige Einnahmen. Bei Erstattungen bleibt die ursprüngliche Steuerart erhalten; das Vorzeichen kehrt ihre Wirkung um.
- Wähle **Ohne Umsatzsteuer**, wenn aus dieser Buchung keine Steuer berechnet werden soll.
- Wähle bei Bedarf eine **Kostenstelle**. Die Zuordnung gilt für den jeweiligen Teilbetrag und erscheint auch in der Auswertung der übergeordneten Kostenstelle.
Vor dem Buchen werden Netto-, Steuer- und Bruttobetrag angezeigt. Beispielsweise ergeben **−119,00 EUR mit 19 % Vorsteuer** einen Nettoaufwand von 100,00 EUR und 19,00 EUR Vorsteuer. Umsatzsteuerauswertung, BWA und Abschreibungsgrundlage berücksichtigen die Steueraufteilung. Die Zuordnung zu einem vorhandenen Rechnungsbeleg erfasst dessen Steuer nicht noch einmal.
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).
## 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,5 +1,6 @@
<script setup> <script setup>
import dayjs from "dayjs" import dayjs from "dayjs"
import { getBankAllocationAmounts, isDirectBankAllocation } from "~/utils/bankAllocationTax"
const props = defineProps({ const props = defineProps({
item: { item: {
@@ -12,6 +13,8 @@ const loading = ref(true)
const incomingInvoices = ref([]) const incomingInvoices = ref([])
const createddocuments = ref([]) const createddocuments = ref([])
const costcentres = ref([]) const costcentres = ref([])
const bankAllocations = ref([])
const accounts = ref([])
const selectedYear = ref(String(dayjs().year())) const selectedYear = ref(String(dayjs().year()))
const selectedMonth = ref("all") const selectedMonth = ref("all")
@@ -74,8 +77,10 @@ const relevantCostCentreIds = computed(() => {
const yearItems = computed(() => { const yearItems = computed(() => {
const years = [...new Set( const years = [...new Set(
incomingInvoices.value [...incomingInvoices.value.map(invoice => invoice.date),
.map((invoice) => invoice.date ? String(dayjs(invoice.date).year()) : null) ...createddocuments.value.map(document => document.documentDate),
...bankAllocations.value.map(allocation => allocation.bankstatement?.date)]
.map((date) => date ? String(dayjs(date).year()) : null)
.filter(Boolean) .filter(Boolean)
)].sort((a, b) => Number(b) - Number(a)) )].sort((a, b) => Number(b) - Number(a))
@@ -183,7 +188,33 @@ const reportRows = computed(() => {
.filter(Boolean) .filter(Boolean)
}) })
return [...incomingRows, ...outgoingRows] const bankRows = bankAllocations.value.flatMap((allocation) => {
const statement = allocation.bankstatement
const date = dayjs(statement.date || statement.valueDate)
if (!date.isValid() || String(date.year()) !== selectedYear.value
|| (selectedMonth.value !== "all" && date.month() + 1 !== Number(selectedMonth.value))) return []
const centre = costCentreMap.value.get(getCostCentreId(allocation.costcentre))
const account = accounts.value.find(account => account.id === (allocation.account?.id || allocation.account)) || allocation.ownaccount
const amounts = getBankAllocationAmounts(allocation)
// As for incoming documents, expenses are positive and refunds negative.
const sign = amounts.kind === "input" || (amounts.kind === "none" && amounts.amountGross < 0) ? -1 : 1
return [{
id: `bank-${allocation.id}`,
sourceLabel: "Bankbuchung",
statementId: statement.id,
reference: `Bank #${statement.id}`,
date: statement.date || statement.valueDate,
state: "Gebucht",
vendorName: statement.debName || statement.credName || "-",
accountLabel: `${account?.number || ""} ${account?.label || account?.name || ""}`.trim() || "-",
costCentreName: centre ? `${centre.number} - ${centre.name}` : "-",
description: allocation.description || statement.text || "-",
amountNet: amounts.amountNet * sign,
amountTax: amounts.amountTax * sign,
amountGross: amounts.amountGross * sign,
}]
})
return [...incomingRows, ...outgoingRows, ...bankRows]
}) })
const totals = computed(() => { const totals = computed(() => {
@@ -212,8 +243,12 @@ const setupPage = async () => {
loading.value = true loading.value = true
costcentres.value = await useEntities("costcentres").select("*", null, false, true) costcentres.value = await useEntities("costcentres").select("*", null, false, true)
accounts.value = await useEntities("accounts").selectSpecial()
const invoices = await useEntities("incominginvoices").select("*, vendor(id,name)") const invoices = await useEntities("incominginvoices").select("*, vendor(id,name)")
const documents = await useEntities("createddocuments").select("*, customer(id,name)") const documents = await useEntities("createddocuments").select("*, customer(id,name)")
const allocations = await useEntities("statementallocations").select("*, bankstatement(*), account(*), ownaccount(*)")
bankAllocations.value = allocations.filter(allocation => isDirectBankAllocation(allocation)
&& relevantCostCentreIds.value.has(getCostCentreId(allocation.costcentre)))
incomingInvoices.value = invoices.filter((invoice) => incomingInvoices.value = invoices.filter((invoice) =>
(invoice.accounts || []).some((account) => relevantCostCentreIds.value.has(account.costCentre)) (invoice.accounts || []).some((account) => relevantCostCentreIds.value.has(account.costCentre))
@@ -283,7 +318,10 @@ setupPage()
class="w-full" class="w-full"
> >
<template #reference-cell="{ row }"> <template #reference-cell="{ row }">
<div class="truncate font-medium">{{ row.original.reference }}</div> <NuxtLink v-if="row.original.statementId" :to="`/banking/statements/show/${row.original.statementId}`" class="font-medium text-primary">
{{ row.original.reference }}
</NuxtLink>
<div v-else class="truncate font-medium">{{ row.original.reference }}</div>
</template> </template>
<template #date-cell="{ row }"> <template #date-cell="{ row }">

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import dayjs from "dayjs" import dayjs from "dayjs"
import { getBankAllocationTaxBreakdown } from "~/utils/bankAllocationTax"
import { import {
getCreatedDocumentTaxBreakdown, getCreatedDocumentTaxBreakdown,
getIncomingInvoiceTaxBreakdown getIncomingInvoiceTaxBreakdown
@@ -125,6 +126,12 @@ const loadSummary = async () => {
return sum + breakdown.tax19 + breakdown.tax7 return sum + breakdown.tax19 + breakdown.tax7
}, 0) }, 0)
const bankTaxBalance = (allocations || []).reduce((sum: number, allocation: any) => {
const date = dayjs(allocation.bankstatement?.date || allocation.bankstatement?.valueDate)
if (!date.isValid() || date.isBefore(bounds.start, "day") || date.isAfter(bounds.end, "day")) return sum
const { output, input } = getBankAllocationTaxBreakdown(allocation)
return sum + output.tax19 + output.tax7 - input.tax19 - input.tax7
}, 0)
const expenses = invoiceExpenses + directAccountExpenses + depreciations const expenses = invoiceExpenses + directAccountExpenses + depreciations
summary.value = { summary.value = {
@@ -133,7 +140,7 @@ const loadSummary = async () => {
expenses: Number(expenses.toFixed(2)), expenses: Number(expenses.toFixed(2)),
depreciations: Number(depreciations.toFixed(2)), depreciations: Number(depreciations.toFixed(2)),
result: Number((income - Math.abs(expenses)).toFixed(2)), result: Number((income - Math.abs(expenses)).toFixed(2)),
taxBalance: Number((outputTax - inputTax).toFixed(2)), taxBalance: Number((outputTax - inputTax + bankTaxBalance).toFixed(2)),
incomeCount: outputDocs.length, incomeCount: outputDocs.length,
expenseCount: inputDocs.filter((invoice: any) => getIncomingInvoiceImmediateExpenseNet(invoice) > 0).length + directAccountAllocations.filter((allocation: any) => getStatementAllocationImmediateExpenseAmount(allocation) > 0).length, expenseCount: inputDocs.filter((invoice: any) => getIncomingInvoiceImmediateExpenseNet(invoice) > 0).length + directAccountAllocations.filter((allocation: any) => getStatementAllocationImmediateExpenseAmount(allocation) > 0).length,
depreciationCount: depreciationRows.length depreciationCount: depreciationRows.length

View File

@@ -1,3 +1,4 @@
import { getBankAllocationAmounts } from "../utils/bankAllocationTax"
import dayjs from "dayjs" import dayjs from "dayjs"
export const EXPENSE_BOOKING_MODE_ITEMS = [ export const EXPENSE_BOOKING_MODE_ITEMS = [
@@ -308,7 +309,7 @@ export const getStatementAllocationImmediateExpenseImpact = (allocation: any) =>
if (allocation?.account === null || allocation?.account === undefined) return 0 if (allocation?.account === null || allocation?.account === undefined) return 0
const mode = normalizeExpenseBookingMode(allocation?.bookingMode) const mode = normalizeExpenseBookingMode(allocation?.bookingMode)
const amount = Number(allocation?.amount || 0) const amount = getBankAllocationAmounts(allocation).amountNet
if (mode !== "expense") return 0 if (mode !== "expense") return 0
return Number((-amount).toFixed(2)) return Number((-amount).toFixed(2))
} }
@@ -317,7 +318,7 @@ export const getStatementAllocationImmediateExpenseAmount = (allocation: any) =>
return Math.max(0, getStatementAllocationImmediateExpenseImpact(allocation)) return Math.max(0, getStatementAllocationImmediateExpenseImpact(allocation))
} }
export const getStatementAccountExpenseTotal = (allocations: any[] = []) => { export const getStatementAccountExpenseTotal = (allocations: any[] = [], gross = false) => {
const balances = new Map<string, number>() const balances = new Map<string, number>()
const addAmount = (account: any, amount: number) => { const addAmount = (account: any, amount: number) => {
const id = account?.id || account const id = account?.id || account
@@ -330,7 +331,8 @@ export const getStatementAccountExpenseTotal = (allocations: any[] = []) => {
;(allocations || []).forEach((allocation) => { ;(allocations || []).forEach((allocation) => {
if (normalizeExpenseBookingMode(allocation?.bookingMode) !== "expense") return if (normalizeExpenseBookingMode(allocation?.bookingMode) !== "expense") return
const amount = Number(allocation?.amount || 0) const amounts = getBankAllocationAmounts(allocation)
const amount = gross ? amounts.amountGross : amounts.amountNet
addAmount(allocation?.account, amount) addAmount(allocation?.account, amount)
addAmount(allocation?.contraAccount, -amount) addAmount(allocation?.contraAccount, -amount)
}) })
@@ -344,7 +346,7 @@ export const getStatementAccountExpenseTotal = (allocations: any[] = []) => {
export const getStatementAllocationDepreciationAmount = (allocation: any, rangeStart: any, rangeEnd: any) => { export const getStatementAllocationDepreciationAmount = (allocation: any, rangeStart: any, rangeEnd: any) => {
const mode = normalizeExpenseBookingMode(allocation?.bookingMode) const mode = normalizeExpenseBookingMode(allocation?.bookingMode)
const rawAmount = Number(allocation?.amount || 0) const rawAmount = getBankAllocationAmounts(allocation).amountNet
const amount = Math.abs(rawAmount) const amount = Math.abs(rawAmount)
if (mode === "expense" || rawAmount >= 0 || amount <= 0) return 0 if (mode === "expense" || rawAmount >= 0 || amount <= 0) return 0
@@ -360,7 +362,7 @@ export const getStatementAllocationDepreciationAmount = (allocation: any, rangeS
} }
export const getStatementAllocationDepreciationRow = (allocation: any, rangeStart: any, rangeEnd: any) => { export const getStatementAllocationDepreciationRow = (allocation: any, rangeStart: any, rangeEnd: any) => {
const totalAmount = Math.abs(Number(allocation?.amount || 0)) const totalAmount = Math.abs(getBankAllocationAmounts(allocation).amountNet)
const months = Number(allocation?.depreciationMonths || 0) const months = Number(allocation?.depreciationMonths || 0)
const startDate = allocation?.depreciationStartDate || allocation?.created_at const startDate = allocation?.depreciationStartDate || allocation?.created_at
const method = normalizeDepreciationMethod(allocation?.depreciationMethod) const method = normalizeDepreciationMethod(allocation?.depreciationMethod)

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import dayjs from "dayjs" import dayjs from "dayjs"
import { getBankAllocationAmounts, getBankAllocationTaxBreakdown } from "~/utils/bankAllocationTax"
import { import {
getCreatedDocumentTaxBreakdown, getCreatedDocumentTaxBreakdown,
getIncomingInvoiceTaxBreakdown getIncomingInvoiceTaxBreakdown
@@ -201,7 +202,7 @@ const expenseNetTotal = computed(() => {
const expenseGrossTotal = computed(() => { const expenseGrossTotal = computed(() => {
const invoiceExpenses = filteredIncomingInvoices.value.reduce((sum, invoice) => sum + getIncomingInvoiceImmediateExpenseGross(invoice), 0) const invoiceExpenses = filteredIncomingInvoices.value.reduce((sum, invoice) => sum + getIncomingInvoiceImmediateExpenseGross(invoice), 0)
const directAccountExpenses = getStatementAccountExpenseTotal(filteredStatementAllocations.value) const directAccountExpenses = getStatementAccountExpenseTotal(filteredStatementAllocations.value, true)
const depreciations = depreciationTotal.value const depreciations = depreciationTotal.value
@@ -231,6 +232,13 @@ const taxSummary = computed(() => {
} }
}, { net19: 0, tax19: 0, net7: 0, tax7: 0, net0: 0 }) }, { net19: 0, tax19: 0, net7: 0, tax7: 0, net0: 0 })
filteredStatementAllocations.value.forEach((allocation) => {
const breakdown = getBankAllocationTaxBreakdown(allocation)
for (const key of ["net19", "tax19", "net7", "tax7", "net0"] as const) {
output[key] += breakdown.output[key]
input[key] += breakdown.input[key]
}
})
const outputTax = Number((output.tax19 + output.tax7).toFixed(2)) const outputTax = Number((output.tax19 + output.tax7).toFixed(2))
const inputTax = Number((input.tax19 + input.tax7).toFixed(2)) const inputTax = Number((input.tax19 + input.tax7).toFixed(2))
@@ -303,13 +311,9 @@ const accountRows = computed(() => {
const debitBookings = filteredStatementAllocations.value const debitBookings = filteredStatementAllocations.value
.filter((allocation) => sameId(allocation.account?.id || allocation.account, account.id)) .filter((allocation) => sameId(allocation.account?.id || allocation.account, account.id))
.map((allocation) => { .map((allocation) => {
const amount = Number(allocation.amount || 0)
return { return {
type: "statementallocation", type: "statementallocation",
amountNet: amount, ...getBankAllocationAmounts(allocation)
amountTax: 0,
amountGross: amount
} }
}) })

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { getBankAllocationAmounts } from "~/utils/bankAllocationTax"
import dayjs from "dayjs" import dayjs from "dayjs"
import { import {
DEPRECIATION_METHOD_ITEMS, DEPRECIATION_METHOD_ITEMS,
@@ -114,7 +115,7 @@ const depreciationAssets = computed(() => {
if (!isDepreciationBookingMode(allocation?.bookingMode)) return null if (!isDepreciationBookingMode(allocation?.bookingMode)) return null
const status = getAssetDepreciationStatus({ const status = getAssetDepreciationStatus({
amount: Math.abs(Number(allocation.amount || 0)), amount: Math.abs(getBankAllocationAmounts(allocation).amountNet),
depreciationMonths: allocation.depreciationMonths, depreciationMonths: allocation.depreciationMonths,
depreciationStartDate: allocation.depreciationStartDate || allocation.created_at, depreciationStartDate: allocation.depreciationStartDate || allocation.created_at,
depreciationMethod: allocation.depreciationMethod, depreciationMethod: allocation.depreciationMethod,
@@ -135,7 +136,7 @@ const depreciationAssets = computed(() => {
months: Number(allocation.depreciationMonths || 0), months: Number(allocation.depreciationMonths || 0),
startDate: allocation.depreciationStartDate || allocation.created_at, startDate: allocation.depreciationStartDate || allocation.created_at,
residualValue: Number(allocation.residualValue || 0), residualValue: Number(allocation.residualValue || 0),
originalValue: Math.abs(Number(allocation.amount || 0)), originalValue: Math.abs(getBankAllocationAmounts(allocation).amountNet),
currentPeriodAmount: Number(currentPeriodRow?.amount || 0), currentPeriodAmount: Number(currentPeriodRow?.amount || 0),
depreciated: status.depreciated, depreciated: status.depreciated,
remaining: status.remaining, remaining: status.remaining,

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import dayjs from "dayjs" import dayjs from "dayjs"
import { getBankAllocationTaxBreakdown, isDirectBankAllocation } from "~/utils/bankAllocationTax"
import customParseFormat from "dayjs/plugin/customParseFormat" import customParseFormat from "dayjs/plugin/customParseFormat"
import { import {
formatTaxEvaluationPeriodLabel, formatTaxEvaluationPeriodLabel,
@@ -20,6 +21,7 @@ const loading = ref(true)
const createdDocuments = ref<any[]>([]) const createdDocuments = ref<any[]>([])
const incomingInvoices = ref<any[]>([]) const incomingInvoices = ref<any[]>([])
const manualBookings = ref<any[]>([]) const manualBookings = ref<any[]>([])
const bankAllocations = ref<any[]>([])
const periodType = computed(() => normalizeTaxEvaluationPeriod(auth.activeTenantData?.taxEvaluationPeriod)) const periodType = computed(() => normalizeTaxEvaluationPeriod(auth.activeTenantData?.taxEvaluationPeriod))
@@ -42,15 +44,17 @@ const loadData = async () => {
loading.value = true loading.value = true
try { try {
const [docs, incoming, manual] = await Promise.all([ const [docs, incoming, manual, allocations] = await Promise.all([
useEntities("createddocuments").select(), useEntities("createddocuments").select(),
useEntities("incominginvoices").select(), useEntities("incominginvoices").select(),
useNuxtApp().$api("/api/banking/manual-bookings") useNuxtApp().$api("/api/banking/manual-bookings"),
useEntities("statementallocations").select("*, bankstatement(*)")
]) ])
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)
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -108,6 +112,18 @@ const periods = computed(() => {
} }
}, { net19: 0, tax19: 0, net7: 0, tax7: 0, net0: 0 }) }, { net19: 0, tax19: 0, net7: 0, tax7: 0, net0: 0 })
const directBookings = bankAllocations.value.filter((allocation) => {
const date = dayjs(allocation.bankstatement?.date || allocation.bankstatement?.valueDate)
return date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day")
})
directBookings.forEach((allocation) => {
const breakdown = getBankAllocationTaxBreakdown(allocation)
for (const key of ["net19", "tax19", "net7", "tax7", "net0"] as const) {
output[key] += breakdown.output[key]
input[key] += breakdown.input[key]
}
})
output.tax19 = Number((output.tax19 + manualTax.outputTax19).toFixed(2)) output.tax19 = Number((output.tax19 + manualTax.outputTax19).toFixed(2))
input.tax19 = Number((input.tax19 + manualTax.inputTax19).toFixed(2)) input.tax19 = Number((input.tax19 + manualTax.inputTax19).toFixed(2))
@@ -127,6 +143,7 @@ const periods = computed(() => {
input, input,
outputCount: outputDocs.length, outputCount: outputDocs.length,
inputCount: inputDocs.length, inputCount: inputDocs.length,
bankCount: directBookings.length,
} }
}) })
}) })
@@ -136,7 +153,7 @@ const currentPeriod = computed(() => periods.value[0] || null)
const columns = [ const columns = [
{ key: "label", label: "Zeitraum" }, { key: "label", label: "Zeitraum" },
{ key: "range", label: "Datumsbereich" }, { key: "range", label: "Datumsbereich" },
{ key: "outputTax", label: "USt Rechnungen" }, { key: "outputTax", label: "Umsatzsteuer" },
{ key: "inputTax", label: "Vorsteuer" }, { key: "inputTax", label: "Vorsteuer" },
{ key: "balance", label: "Ergebnis" }, { key: "balance", label: "Ergebnis" },
{ key: "documents", label: "Belege" }, { key: "documents", label: "Belege" },
@@ -166,7 +183,8 @@ onMounted(loadData)
</h2> </h2>
<p class="text-sm text-gray-500 dark:text-gray-400"> <p class="text-sm text-gray-500 dark:text-gray-400">
Intervall: {{ periodType === "monthly" ? "monatlich" : periodType === "quarterly" ? "quartalsweise" : "jährlich" }}. Intervall: {{ periodType === "monthly" ? "monatlich" : periodType === "quarterly" ? "quartalsweise" : "jährlich" }}.
Berücksichtigt werden gebuchte Ausgangsrechnungen, Abschlags- und Stornorechnungen, gebuchte Eingangsbelege sowie manuelle Buchungen auf SKR03 1576 und 1776. Berücksichtigt werden gebuchte Ausgangsrechnungen, Abschlags- und Stornorechnungen, gebuchte Eingangsbelege,
direkte Bankbuchungen mit Umsatzsteuer oder Vorsteuer sowie manuelle Buchungen auf SKR03 1576 und 1776.
</p> </p>
<p v-if="currentPeriod" class="text-sm text-gray-500 dark:text-gray-400"> <p v-if="currentPeriod" class="text-sm text-gray-500 dark:text-gray-400">
{{ currentPeriod.range }} {{ currentPeriod.range }}
@@ -203,7 +221,7 @@ onMounted(loadData)
{{ formatCurrency(currentPeriod.balance) }} {{ formatCurrency(currentPeriod.balance) }}
</div> </div>
<div class="mt-3 text-sm text-gray-500 dark:text-gray-400"> <div class="mt-3 text-sm text-gray-500 dark:text-gray-400">
{{ currentPeriod.outputCount }} Ausgangsbelege | {{ currentPeriod.inputCount }} Eingangsbelege {{ currentPeriod.outputCount }} Ausgangsbelege | {{ currentPeriod.inputCount }} Eingangsbelege | {{ currentPeriod.bankCount }} direkte Bankbuchungen
</div> </div>
</UCard> </UCard>
</div> </div>
@@ -211,7 +229,7 @@ onMounted(loadData)
<div v-if="currentPeriod" class="mt-6 grid gap-4 xl:grid-cols-2"> <div v-if="currentPeriod" class="mt-6 grid gap-4 xl:grid-cols-2">
<UCard> <UCard>
<template #header> <template #header>
<div class="font-semibold">Ausgangsrechnungen</div> <div class="font-semibold">Umsatzsteuer aus Belegen und Bankbuchungen</div>
</template> </template>
<div class="space-y-3 text-sm"> <div class="space-y-3 text-sm">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -239,7 +257,7 @@ onMounted(loadData)
<UCard> <UCard>
<template #header> <template #header>
<div class="font-semibold">Eingangsbelege</div> <div class="font-semibold">Vorsteuer aus Belegen und Bankbuchungen</div>
</template> </template>
<div class="space-y-3 text-sm"> <div class="space-y-3 text-sm">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -298,7 +316,7 @@ onMounted(loadData)
</template> </template>
<template #documents-cell="{ row }"> <template #documents-cell="{ row }">
{{ row.original.outputCount }} / {{ row.original.inputCount }} {{ row.original.outputCount }} / {{ row.original.inputCount }} · {{ row.original.bankCount }} Bankbuchungen
</template> </template>
<template #empty> <template #empty>
<TableEmptyState label="Keine Daten für die USt-Auswertung vorhanden" icon="i-heroicons-calculator" /> <TableEmptyState label="Keine Daten für die USt-Auswertung vorhanden" icon="i-heroicons-calculator" />

View File

@@ -1,5 +1,6 @@
<script setup> <script setup>
import dayjs from "dayjs"; import dayjs from "dayjs";
import { BANK_ALLOCATION_TAX_ITEMS, getBankAllocationAmounts } from "~/utils/bankAllocationTax"
import { import {
DEPRECIATION_METHOD_ITEMS, DEPRECIATION_METHOD_ITEMS,
EXPENSE_BOOKING_MODE_ITEMS, EXPENSE_BOOKING_MODE_ITEMS,
@@ -36,6 +37,7 @@ const createddocuments = ref([])
const incominginvoices = ref([]) const incominginvoices = ref([])
const accounts = ref([]) const accounts = ref([])
const ownaccounts = ref([]) const ownaccounts = ref([])
const costcentres = ref([])
const loading = ref(true) const loading = ref(true)
const loadingDocuments = ref(true) const loadingDocuments = ref(true)
@@ -76,15 +78,17 @@ const setup = async () => {
useEntities("vendors").select() useEntities("vendors").select()
]) ])
const [statement, accountItems] = await Promise.all([ const [statement, accountItems, costCentreItems] = await Promise.all([
route.params.id route.params.id
? useEntities("bankstatements").selectSingle(route.params.id, "*, statementallocations(*, cd_id(*), ii_id(*))") ? useEntities("bankstatements").selectSingle(route.params.id, "*, statementallocations(*, cd_id(*), ii_id(*))")
: Promise.resolve(itemInfo.value), : Promise.resolve(itemInfo.value),
useEntities("accounts").selectSpecial("*", "number", true) useEntities("accounts").selectSpecial("*", "number", true),
useEntities("costcentres").select()
]) ])
itemInfo.value = statement itemInfo.value = statement
accounts.value = accountItems accounts.value = accountItems
costcentres.value = costCentreItems
if (itemInfo.value) oldItemInfo.value = JSON.parse(JSON.stringify(itemInfo.value)) if (itemInfo.value) oldItemInfo.value = JSON.parse(JSON.stringify(itemInfo.value))
manualAllocationSum.value = calculateOpenSum.value manualAllocationSum.value = calculateOpenSum.value
@@ -164,6 +168,21 @@ const selectAccount = (id) => {
const manualAllocationSum = ref(itemInfo.value.amount || 0) const manualAllocationSum = ref(itemInfo.value.amount || 0)
const allocationDescription = ref("") const allocationDescription = ref("")
const allocationCostcentre = ref(null)
const allocationTaxKey = ref(null)
const allocationAmounts = computed(() => getBankAllocationAmounts({
bankstatement: itemInfo.value.id,
account: accountToSave.value,
ownaccount: ownAccountToSave.value,
amount: manualAllocationSum.value,
datevTaxKey: allocationTaxKey.value,
}))
const getCostCentreLabel = (id) => {
const centre = costcentres.value.find(item => item.id === id)
return centre ? `${centre.number} - ${centre.name}` : id
}
const getAllocationTaxLabel = (item) => BANK_ALLOCATION_TAX_ITEMS.find(tax => tax.value === item.datevTaxKey)?.label
|| (item.datevTaxKey ? `Steuerschlüssel ${item.datevTaxKey}` : "")
const allocationBookingMode = ref("expense") const allocationBookingMode = ref("expense")
const allocationDepreciationMonths = ref(36) const allocationDepreciationMonths = ref(36)
const allocationDepreciationStartDate = ref(dayjs().format("YYYY-MM-DD")) const allocationDepreciationStartDate = ref(dayjs().format("YYYY-MM-DD"))
@@ -179,6 +198,10 @@ const isDepreciationAllocation = computed(() => isDepreciationBookingMode(alloca
const buildAllocationPayload = (payload) => { const buildAllocationPayload = (payload) => {
const base = { const base = {
...payload, ...payload,
...((payload.account || payload.ownaccount) ? {
costcentre: allocationCostcentre.value || null,
datevTaxKey: allocationTaxKey.value || null,
} : {}),
bookingMode: normalizeExpenseBookingMode(allocationBookingMode.value) bookingMode: normalizeExpenseBookingMode(allocationBookingMode.value)
} }
@@ -232,6 +255,8 @@ const saveAllocation = async (allocation) => {
vendorAccountToSave.value = null vendorAccountToSave.value = null
customerAccountToSave.value = null customerAccountToSave.value = null
ownAccountToSave.value = null ownAccountToSave.value = null
allocationCostcentre.value = null
allocationTaxKey.value = null
allocationBookingMode.value = "expense" allocationBookingMode.value = "expense"
allocationDepreciationMonths.value = 36 allocationDepreciationMonths.value = 36
allocationDepreciationStartDate.value = dayjs(itemInfo.value?.date || new Date()).format("YYYY-MM-DD") allocationDepreciationStartDate.value = dayjs(itemInfo.value?.date || new Date()).format("YYYY-MM-DD")
@@ -696,6 +721,11 @@ setup()
{{ getAllocationLabel(item) }} {{ getAllocationLabel(item) }}
</div> </div>
<div class="text-xs text-gray-500 mt-0.5" v-if="item.description">{{ item.description }}</div> <div class="text-xs text-gray-500 mt-0.5" v-if="item.description">{{ item.description }}</div>
<div v-if="item.costcentre" class="text-xs text-gray-500 mt-1">Kostenstelle: {{ getCostCentreLabel(item.costcentre) }}</div>
<div v-if="item.datevTaxKey" class="text-xs text-gray-500 mt-1">
{{ getAllocationTaxLabel(item) }} · Netto {{ displayCurrency(getBankAllocationAmounts(item).amountNet) }}
· Steuer {{ displayCurrency(getBankAllocationAmounts(item).amountTax) }}
</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>
@@ -747,7 +777,7 @@ setup()
class="p-4 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 shadow-sm shrink-0 z-10"> class="p-4 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 shadow-sm shrink-0 z-10">
<div class="grid grid-cols-12 gap-4 items-end"> <div class="grid grid-cols-12 gap-4 items-end">
<div class="col-span-12 md:col-span-3"> <div class="col-span-12 md:col-span-3">
<UFormField label="Betrag" size="sm"> <UFormField label="Bruttobetrag" size="sm">
<UInput v-model="manualAllocationSum" type="number" step="0.01"> <UInput v-model="manualAllocationSum" type="number" step="0.01">
<template #trailing><span class="text-gray-500 text-xs">EUR</span></template> <template #trailing><span class="text-gray-500 text-xs">EUR</span></template>
</UInput> </UInput>
@@ -778,7 +808,7 @@ setup()
<UButton <UButton
icon="i-heroicons-check" icon="i-heroicons-check"
label="Buchen" label="Buchen"
:disabled="!accountToSave" :disabled="!accountToSave || !allocationTaxKey"
@click="saveAllocation({bankstatement: itemInfo.id, amount: manualAllocationSum, account: accountToSave, description: allocationDescription })" @click="saveAllocation({bankstatement: itemInfo.id, amount: manualAllocationSum, account: accountToSave, description: allocationDescription })"
/> />
</div> </div>
@@ -793,6 +823,28 @@ setup()
</div> </div>
</div> </div>
<div class="mt-3 grid grid-cols-1 md:grid-cols-2 gap-3">
<UFormField label="Umsatzsteuer / Vorsteuer" size="sm">
<USelectMenu v-model="allocationTaxKey" :items="BANK_ALLOCATION_TAX_ITEMS" value-key="value" label-key="label"
placeholder="Steuer auswählen …" class="w-full" />
</UFormField>
<UFormField label="Kostenstelle (optional)" size="sm">
<div class="flex gap-1">
<USelectMenu v-model="allocationCostcentre" :items="costcentres" value-key="id" label-key="name"
:filter-fields="['number', 'name']" placeholder="Keine Kostenstelle" class="w-full">
<template #default>{{ allocationCostcentre ? getCostCentreLabel(allocationCostcentre) : 'Keine Kostenstelle' }}</template>
<template #item-label="{ item }">{{ item.number }} - {{ item.name }}</template>
</USelectMenu>
<UButton v-if="allocationCostcentre" icon="i-heroicons-x-mark" variant="ghost" aria-label="Kostenstelle entfernen"
@click="allocationCostcentre = null" />
</div>
</UFormField>
<p v-if="allocationTaxKey && (accountToSave || ownAccountToSave)" class="md:col-span-2 text-xs text-gray-500">
Netto {{ displayCurrency(allocationAmounts.amountNet) }} · Steuer {{ displayCurrency(allocationAmounts.amountTax) }}
· Brutto {{ displayCurrency(allocationAmounts.amountGross) }}
</p>
</div>
<div v-if="showMoreWithoutRecipe" <div v-if="showMoreWithoutRecipe"
class="mt-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"> class="mt-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div class="grid grid-cols-1 md:grid-cols-3 gap-3"> <div class="grid grid-cols-1 md:grid-cols-3 gap-3">
@@ -866,6 +918,7 @@ setup()
</USelectMenu> </USelectMenu>
<div class="md:col-span-3 flex justify-end"> <div class="md:col-span-3 flex justify-end">
<UButton size="xs" label="Zuweisen" <UButton size="xs" label="Zuweisen"
:disabled="!!ownAccountToSave && !allocationTaxKey"
@click="saveAllocation({bankstatement: itemInfo.id, amount: manualAllocationSum, ownaccount: ownAccountToSave ? ownAccountToSave : null, customer: customerAccountToSave ? customerAccountToSave : null, vendor: vendorAccountToSave ? vendorAccountToSave : null, description: allocationDescription })"/> @click="saveAllocation({bankstatement: itemInfo.id, amount: manualAllocationSum, ownaccount: ownAccountToSave ? ownAccountToSave : null, customer: customerAccountToSave ? customerAccountToSave : null, vendor: vendorAccountToSave ? vendorAccountToSave : null, description: allocationDescription })"/>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,63 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const path = require('node:path')
function loadModule(file) {
const filename = path.resolve(__dirname, file)
const module = { exports: {} }
const localRequire = id => id.startsWith('.') ? loadModule(path.relative(__dirname, path.resolve(path.dirname(filename), id + '.ts'))) : require(id)
vm.runInNewContext(ts.transpile(fs.readFileSync(filename, 'utf8'), { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true }), { module, exports: module.exports, require: localRequire })
return module.exports
}
const { getBankAllocationAmounts, getBankAllocationTaxBreakdown } = loadModule('../utils/bankAllocationTax.ts')
const { getStatementAccountExpenseTotal, getStatementAllocationDepreciationAmount } = loadModule('../composables/useDepreciation.ts')
const allocation = (amount, datevTaxKey, extra = {}) => ({ bankstatement: { id: 42 }, account: 13, amount, datevTaxKey, ...extra })
for (const [gross, key, net, tax, side, rate] of [
[-119, '9', -100, -19, 'input', 19], [-107, '8', -100, -7, 'input', 7],
[119, '3', 100, 19, 'output', 19], [107, '2', 100, 7, 'output', 7],
[59.5, '9', 50, 9.5, 'input', 19], [-59.5, '3', -50, -9.5, 'output', 19],
]) {
test(`gross ${gross} and key ${key} produce correct net, VAT and refund signs`, () => {
const item = allocation(gross, key)
const amounts = getBankAllocationAmounts(item)
assert.equal(amounts.amountGross, gross)
assert.equal(amounts.amountNet, net)
assert.equal(amounts.amountTax, tax)
const result = getBankAllocationTaxBreakdown(item)
assert.equal(result[side][`net${rate}`], net * (side === 'input' ? -1 : 1))
assert.equal(result[side][`tax${rate}`], tax * (side === 'input' ? -1 : 1))
})
}
test('rounding preserves gross, and tax-free and legacy allocations retain their amounts', () => {
const amounts = getBankAllocationAmounts(allocation(-10, '9'))
assert.equal(amounts.amountNet, -8.4)
assert.equal(amounts.amountTax, -1.6)
for (const key of ['40', null]) {
const value = getBankAllocationAmounts(allocation(-100, key))
assert.equal(value.amountNet, -100)
assert.equal(value.amountTax, 0)
}
})
test('invoice settlements, archived bookings and manual bookings are not counted again', () => {
for (const extra of [{ incominginvoice: 1 }, { createddocument: 1 }, { archived: true }, { bankstatement: null }]) {
const result = getBankAllocationTaxBreakdown(allocation(-119, '9', extra))
assert.ok([...Object.values(result.input), ...Object.values(result.output)].every(value => value === 0))
}
})
test('additional accounts carry the same tax breakdown', () => {
assert.equal(getBankAllocationAmounts(allocation(-119, '9', { account: null, ownaccount: 'own' })).amountTax, -19)
})
test('BWA separates net expenses from gross cash flows including partial refunds', () => {
const items = [allocation(-119, '9'), allocation(59.5, '9')]
assert.equal(getStatementAccountExpenseTotal(items), 50)
assert.equal(getStatementAccountExpenseTotal(items, true), 59.5)
})
test('depreciation uses the net acquisition amount when input VAT is selected', () => {
assert.equal(getStatementAllocationDepreciationAmount(allocation(-1428, '9', {
bookingMode: 'depreciation_single', depreciationMonths: 12, depreciationStartDate: '2026-01-01',
}), '2026-01-01', '2026-01-31'), 100)
})

View File

@@ -0,0 +1,43 @@
export const BANK_ALLOCATION_TAX_ITEMS = [
{ value: "9", label: "19 % Vorsteuer", rate: 19, kind: "input" },
{ value: "8", label: "7 % Vorsteuer", rate: 7, kind: "input" },
{ value: "3", label: "19 % Umsatzsteuer", rate: 19, kind: "output" },
{ value: "2", label: "7 % Umsatzsteuer", rate: 7, kind: "output" },
{ value: "40", label: "Ohne Umsatzsteuer", rate: 0, kind: "none" },
]
export const isDirectBankAllocation = (allocation: any) => Boolean(
allocation?.bankstatement && (allocation.account || allocation.ownaccount)
&& !allocation.createddocument && !allocation.incominginvoice
&& !allocation.customer && !allocation.vendor && !allocation.archived
)
// The signed bank amount always remains gross. Refunds retain their sign.
export function getBankAllocationAmounts(allocation: any) {
const amountGross = Number(allocation?.amount || 0)
const tax = isDirectBankAllocation(allocation)
? BANK_ALLOCATION_TAX_ITEMS.find(item => item.value === String(allocation.datevTaxKey))
: undefined
const rate = tax?.rate || 0
const amountNet = Number((amountGross / (1 + rate / 100)).toFixed(2))
const amountTax = Number((amountGross - amountNet).toFixed(2))
return { amountGross, amountNet, amountTax, rate, kind: tax?.kind || "none" }
}
export function getBankAllocationTaxBreakdown(allocation: any) {
const output = { net19: 0, tax19: 0, net7: 0, tax7: 0, net0: 0 }
const input = { ...output }
if (!isDirectBankAllocation(allocation)) return { output, input }
const { amountNet, amountTax, rate, kind } = getBankAllocationAmounts(allocation)
if (kind === "none") return { output, input }
const target = kind === "input" ? input : output
const sign = kind === "input" ? -1 : 1
if (rate === 19) {
target.net19 = amountNet * sign
target.tax19 = amountTax * sign
} else if (rate === 7) {
target.net7 = amountNet * sign
target.tax7 = amountTax * sign
}
return { output, input }
}