feat(banking): add cost centres and VAT to account allocations
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "statementallocations"
|
||||
ADD COLUMN "costcentre" uuid REFERENCES "costcentres"("id");
|
||||
@@ -477,6 +477,13 @@
|
||||
"when": 1788850800000,
|
||||
"tag": "0070_native_communication_chat",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 68,
|
||||
"version": "7",
|
||||
"when": 1790121600000,
|
||||
"tag": "0071_statement_allocation_costcentre",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { incominginvoices } from "./incominginvoices"
|
||||
import { createddocuments } from "./createddocuments"
|
||||
import { bankstatements } from "./bankstatements"
|
||||
import { accounts } from "./accounts" // Falls noch nicht erstellt → bitte melden!
|
||||
import { costcentres } from "./costcentres"
|
||||
|
||||
export const statementallocations = pgTable("statementallocations", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
@@ -58,6 +59,7 @@ export const statementallocations = pgTable("statementallocations", {
|
||||
|
||||
manualBookingDate: text("manual_booking_date"),
|
||||
datevTaxKey: text("datev_tax_key"),
|
||||
costcentre: uuid("costcentre").references(() => costcentres.id),
|
||||
|
||||
bookingMode: text("booking_mode").notNull().default("expense"),
|
||||
depreciationMonths: integer("depreciation_months"),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { normalizeBankAllocationDetails } from "../modules/bank-allocation-details"
|
||||
|
||||
const hasValue = (value: unknown) => value !== null && value !== undefined && value !== ""
|
||||
|
||||
const stringArg = (args: Record<string, unknown>, key: string) => {
|
||||
@@ -110,6 +112,10 @@ export const prepareStatementAllocationInput = (args: Record<string, unknown>) =
|
||||
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
|
||||
|
||||
const depreciationMonths = requiredNumber(args, "depreciationMonths")
|
||||
|
||||
@@ -2,6 +2,7 @@ import { outgoingAddressSchema, outgoingRowsSchema, outgoingReportSchema } from
|
||||
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
|
||||
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { validateBankAllocationCostCentre } from "../../modules/bank-allocation-details"
|
||||
import {
|
||||
accounts,
|
||||
bankstatements,
|
||||
@@ -406,6 +407,7 @@ const assertTenantEntityExists = async (
|
||||
|
||||
const assertStatementAllocationReferences = async (context: any, payload: Record<string, any>) => {
|
||||
await Promise.all([
|
||||
validateBankAllocationCostCentre(context.server.db, context.tenantId, payload.costcentre),
|
||||
assertTenantEntityExists(context, bankstatements, bankstatements.id, bankstatements.tenant, payload.bankstatement, "Bankumsatz"),
|
||||
assertTenantEntityExists(context, createddocuments, createddocuments.id, createddocuments.tenant, payload.createddocument, "Ausgangsbeleg"),
|
||||
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." },
|
||||
manualInvoiceSide: { type: "string", enum: ["debit", "credit"], description: "Soll/Haben-Seite eines Eingangsbelegs in einer manuellen Buchung." },
|
||||
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" },
|
||||
depreciationMonths: { type: "number", minimum: 1 },
|
||||
depreciationStartDate: { type: "string", format: "date" },
|
||||
|
||||
30
backend/src/modules/bank-allocation-details.ts
Normal file
30
backend/src/modules/bank-allocation-details.ts
Normal 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.")
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { DE_BANK_CODE_TO_NAME } from "../utils/deBankCodes"
|
||||
import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics"
|
||||
import { centralServicesClient } from "../modules/push-server.client"
|
||||
import { findBankInstitutionByBic } from "../modules/banking-institution"
|
||||
import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../modules/bank-allocation-details"
|
||||
|
||||
import {
|
||||
bankrequisitions,
|
||||
@@ -1166,6 +1167,8 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
const { data: payload } = req.body as { data: any }
|
||||
const prepared = prepareStatementAllocationPayload(payload)
|
||||
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)
|
||||
|
||||
const inserted = await server.db.insert(statementallocations).values({
|
||||
@@ -1217,6 +1220,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
return reply.send(createdRecord)
|
||||
|
||||
} catch (err) {
|
||||
if (err.statusCode === 400) return reply.code(400).send({ error: err.message })
|
||||
console.error(err)
|
||||
return reply.code(500).send({ error: "Failed to create statement" })
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
vendors,
|
||||
bankaccounts,
|
||||
bankstatements,
|
||||
ownaccounts
|
||||
ownaccounts,
|
||||
costcentres
|
||||
} from "../../../db/schema";
|
||||
|
||||
dayjs.extend(isBetween);
|
||||
@@ -80,6 +81,29 @@ const formatDatevDate = (date: dayjs.ConfigType, format: string) => {
|
||||
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) => {
|
||||
if (invoice.taxAmountOverride === null || invoice.taxAmountOverride === undefined || invoice.taxAmountOverride === "") return null;
|
||||
|
||||
@@ -136,7 +160,7 @@ export async function buildExportZip(
|
||||
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 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)
|
||||
@@ -209,6 +233,7 @@ export async function buildExportZip(
|
||||
direct_vend: vendors, // Direkte Zuordnung an Kreditor
|
||||
direct_cust: customers, // Direkte Zuordnung an Debitor
|
||||
own: ownaccounts,
|
||||
costcentre: costcentres,
|
||||
contra_acc: ContraAccount,
|
||||
contra_vend: ContraVendor,
|
||||
contra_cust: ContraCustomer,
|
||||
@@ -233,6 +258,7 @@ export async function buildExportZip(
|
||||
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
|
||||
.leftJoin(customers, eq(statementallocations.customer, customers.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(ContraVendor, eq(statementallocations.contraVendor, ContraVendor.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
|
||||
const statementallocationsList = allocRaw.map(r => ({
|
||||
...r.allocation,
|
||||
bankstatement: {
|
||||
bankstatement: r.bs ? {
|
||||
...r.bs,
|
||||
account: r.ba // Nesting für bs.account.datevNumber
|
||||
},
|
||||
} : null,
|
||||
createddocument: r.cd ? {
|
||||
...r.cd,
|
||||
customer: r.cd_cust
|
||||
@@ -273,6 +299,7 @@ export async function buildExportZip(
|
||||
vendor: r.direct_vend,
|
||||
customer: r.direct_cust,
|
||||
ownaccount: r.own,
|
||||
costcentre: r.costcentre,
|
||||
contraAccount: r.contra_acc,
|
||||
contraVendor: r.contra_vend,
|
||||
contraCustomer: r.contra_cust,
|
||||
@@ -453,9 +480,7 @@ export async function buildExportZip(
|
||||
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;;;;"";;;;;;`);
|
||||
} else if(alloc.account) {
|
||||
const acc = alloc.account;
|
||||
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;;;;"";;;;;;`);
|
||||
bookingLines.push(getBankAccountBookingLine(alloc));
|
||||
} else if(alloc.vendor) {
|
||||
const vend = alloc.vendor;
|
||||
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";
|
||||
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) {
|
||||
const own = alloc.ownaccount;
|
||||
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;;;;"";;;;;;`);
|
||||
bookingLines.push(getBankAccountBookingLine(alloc));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
69
backend/tests/bankAllocationDetails.test.ts
Normal file
69
backend/tests/bankAllocationDetails.test.ts
Normal 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)
|
||||
})
|
||||
@@ -1,6 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
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", () => {
|
||||
const revenueLines = getCreatedDocumentRevenueLines({
|
||||
@@ -27,3 +29,77 @@ test("preserves negative gross amounts for cancellations", () => {
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user