feat(banking): add cost centres and VAT to account allocations
This commit is contained in:
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