From 189a52b3cd62bffc35b631dbe3b72dbdda91dff4 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Mon, 16 Feb 2026 12:40:07 +0100 Subject: [PATCH] Added IBAN Saving, Automatic Saving, added Mitglieder --- .../0011_mighty_member_bankaccounts.sql | 16 + backend/db/migrations/meta/_journal.json | 7 + backend/db/schema/entitybankaccounts.ts | 39 +++ backend/db/schema/index.ts | 1 + backend/package.json | 3 +- backend/src/routes/banking.ts | 316 +++++++++++++++++- backend/src/routes/resources/main.ts | 171 +++++++++- backend/src/utils/resource.config.ts | 12 + frontend/components/EntityEdit.vue | 39 ++- frontend/components/EntityList.vue | 10 +- frontend/components/EntityShow.vue | 4 +- frontend/components/MainNav.vue | 14 +- .../pages/standardEntity/[type]/index.vue | 8 +- frontend/stores/data.js | 261 +++++++++++++++ 14 files changed, 879 insertions(+), 22 deletions(-) create mode 100644 backend/db/migrations/0011_mighty_member_bankaccounts.sql create mode 100644 backend/db/schema/entitybankaccounts.ts diff --git a/backend/db/migrations/0011_mighty_member_bankaccounts.sql b/backend/db/migrations/0011_mighty_member_bankaccounts.sql new file mode 100644 index 0000000..cc9bf0b --- /dev/null +++ b/backend/db/migrations/0011_mighty_member_bankaccounts.sql @@ -0,0 +1,16 @@ +CREATE TABLE "entitybankaccounts" ( + "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "entitybankaccounts_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1), + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "tenant" bigint NOT NULL, + "iban_encrypted" jsonb NOT NULL, + "bic_encrypted" jsonb NOT NULL, + "bank_name_encrypted" jsonb NOT NULL, + "description" text, + "updated_at" timestamp with time zone, + "updated_by" uuid, + "archived" boolean DEFAULT false NOT NULL +); +--> statement-breakpoint +ALTER TABLE "entitybankaccounts" ADD CONSTRAINT "entitybankaccounts_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "entitybankaccounts" ADD CONSTRAINT "entitybankaccounts_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action; diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index 2bb6588..3c99336 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1773000200000, "tag": "0010_sudden_billing_interval", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1773000300000, + "tag": "0011_mighty_member_bankaccounts", + "breakpoints": true } ] } diff --git a/backend/db/schema/entitybankaccounts.ts b/backend/db/schema/entitybankaccounts.ts new file mode 100644 index 0000000..6a12d3e --- /dev/null +++ b/backend/db/schema/entitybankaccounts.ts @@ -0,0 +1,39 @@ +import { + pgTable, + bigint, + timestamp, + text, + boolean, + jsonb, + uuid, +} from "drizzle-orm/pg-core" +import { tenants } from "./tenants" +import { authUsers } from "./auth_users" + +export const entitybankaccounts = pgTable("entitybankaccounts", { + id: bigint("id", { mode: "number" }) + .primaryKey() + .generatedByDefaultAsIdentity(), + + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + + tenant: bigint("tenant", { mode: "number" }) + .notNull() + .references(() => tenants.id), + + ibanEncrypted: jsonb("iban_encrypted").notNull(), + bicEncrypted: jsonb("bic_encrypted").notNull(), + bankNameEncrypted: jsonb("bank_name_encrypted").notNull(), + + description: text("description"), + + updatedAt: timestamp("updated_at", { withTimezone: true }), + updatedBy: uuid("updated_by").references(() => authUsers.id), + + archived: boolean("archived").notNull().default(false), +}) + +export type EntityBankAccount = typeof entitybankaccounts.$inferSelect +export type NewEntityBankAccount = typeof entitybankaccounts.$inferInsert diff --git a/backend/db/schema/index.ts b/backend/db/schema/index.ts index 5e25eac..a3f5416 100644 --- a/backend/db/schema/index.ts +++ b/backend/db/schema/index.ts @@ -23,6 +23,7 @@ export * from "./devices" export * from "./documentboxes" export * from "./enums" export * from "./events" +export * from "./entitybankaccounts" export * from "./files" export * from "./filetags" export * from "./folders" diff --git a/backend/package.json b/backend/package.json index 0a3c30d..7a9104b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,7 +9,8 @@ "dev:dav": "tsx watch src/webdav/server.ts", "build": "tsc", "start": "node dist/src/index.js", - "schema:index": "ts-node scripts/generate-schema-index.ts" + "schema:index": "ts-node scripts/generate-schema-index.ts", + "bankcodes:update": "tsx scripts/generate-de-bank-codes.ts" }, "repository": { "type": "git", diff --git a/backend/src/routes/banking.ts b/backend/src/routes/banking.ts index f06ab60..806c25f 100644 --- a/backend/src/routes/banking.ts +++ b/backend/src/routes/banking.ts @@ -4,10 +4,18 @@ import dayjs from "dayjs" import { secrets } from "../utils/secrets" import { insertHistoryItem } from "../utils/history" +import { decrypt, encrypt } from "../utils/crypt" +import { DE_BANK_CODE_TO_NAME } from "../utils/deBankCodes" import { bankrequisitions, + bankstatements, + createddocuments, + customers, + entitybankaccounts, + incominginvoices, statementallocations, + vendors, } from "../../db/schema" import { @@ -17,6 +25,284 @@ import { export default async function bankingRoutes(server: FastifyInstance) { + const normalizeIban = (value?: string | null) => + String(value || "").replace(/\s+/g, "").toUpperCase() + + const pickPartnerBankData = (statement: any, partnerType: "customer" | "vendor") => { + if (!statement) return null + + const prefersDebit = partnerType === "customer" + ? Number(statement.amount) >= 0 + : Number(statement.amount) > 0 + + const primary = prefersDebit + ? { iban: statement.debIban } + : { iban: statement.credIban } + const fallback = prefersDebit + ? { iban: statement.credIban } + : { iban: statement.debIban } + + const primaryIban = normalizeIban(primary.iban) + if (primaryIban) { + return { + iban: primaryIban, + } + } + + const fallbackIban = normalizeIban(fallback.iban) + if (fallbackIban) { + return { + iban: fallbackIban, + } + } + + return null + } + + const mergePartnerIban = (infoData: Record, iban: string, bankAccountId?: number | null) => { + if (!iban && !bankAccountId) return infoData || {} + const info = infoData && typeof infoData === "object" ? { ...infoData } : {} + + if (iban) { + const existing = Array.isArray(info.bankingIbans) ? info.bankingIbans : [] + const merged = [...new Set([...existing.map((i: string) => normalizeIban(i)), iban])] + info.bankingIbans = merged + if (!info.bankingIban) info.bankingIban = iban + } + + if (bankAccountId) { + const existingIds = Array.isArray(info.bankAccountIds) ? info.bankAccountIds : [] + if (!existingIds.includes(bankAccountId)) { + info.bankAccountIds = [...existingIds, bankAccountId] + } + } + + return info + } + + const ibanLengthByCountry: Record = { + DE: 22, + AT: 20, + CH: 21, + NL: 18, + BE: 16, + FR: 27, + ES: 24, + IT: 27, + LU: 20, + } + + const isValidIbanLocal = (iban: string) => { + const normalized = normalizeIban(iban) + if (!normalized || normalized.length < 15 || normalized.length > 34) return false + if (!/^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/.test(normalized)) return false + + const country = normalized.slice(0, 2) + const expectedLength = ibanLengthByCountry[country] + if (expectedLength && normalized.length !== expectedLength) return false + + const rearranged = normalized.slice(4) + normalized.slice(0, 4) + let numeric = "" + for (const ch of rearranged) { + if (ch >= "A" && ch <= "Z") numeric += (ch.charCodeAt(0) - 55).toString() + else numeric += ch + } + + let remainder = 0 + for (const digit of numeric) { + remainder = (remainder * 10 + Number(digit)) % 97 + } + + return remainder === 1 + } + + const resolveBankInstituteFromIbanLocal = (iban: string) => { + const normalized = normalizeIban(iban) + if (!isValidIbanLocal(normalized)) return null + + // Für DE-IBANs kann die BLZ aus Position 5-12 lokal gelesen werden. + if (normalized.startsWith("DE") && normalized.length === 22) { + const bankCode = normalized.slice(4, 12) + const bankName = DE_BANK_CODE_TO_NAME[bankCode] + if (bankName) return bankName + return `Unbekannt (BLZ ${bankCode})` + } + + return null + } + + const resolveEntityBankAccountId = async ( + tenantId: number, + userId: string, + iban: string + ) => { + const normalizedIban = normalizeIban(iban) + if (!normalizedIban) return null + + const bankInstitute = resolveBankInstituteFromIbanLocal(normalizedIban) + + const allAccounts = await server.db + .select({ + id: entitybankaccounts.id, + ibanEncrypted: entitybankaccounts.ibanEncrypted, + bankNameEncrypted: entitybankaccounts.bankNameEncrypted, + }) + .from(entitybankaccounts) + .where(eq(entitybankaccounts.tenant, tenantId)) + + const existing = allAccounts.find((row) => { + if (!row.ibanEncrypted) return false + try { + const decryptedIban = decrypt(row.ibanEncrypted as any) + return normalizeIban(decryptedIban) === normalizedIban + } catch { + return false + } + }) + + if (existing?.id) { + if (bankInstitute) { + let currentBankName = "" + try { + currentBankName = String(decrypt(existing.bankNameEncrypted as any) || "").trim() + } catch { + currentBankName = "" + } + + if (currentBankName !== bankInstitute) { + await server.db + .update(entitybankaccounts) + .set({ + bankNameEncrypted: encrypt(bankInstitute), + updatedAt: new Date(), + updatedBy: userId, + }) + .where(and(eq(entitybankaccounts.id, Number(existing.id)), eq(entitybankaccounts.tenant, tenantId))) + } + } + + return Number(existing.id) + } + + const [created] = await server.db + .insert(entitybankaccounts) + .values({ + tenant: tenantId, + ibanEncrypted: encrypt(normalizedIban), + bicEncrypted: encrypt("UNBEKANNT"), + bankNameEncrypted: encrypt(bankInstitute || "Unbekannt"), + description: "Automatisch aus Bankbuchung übernommen", + updatedAt: new Date(), + updatedBy: userId, + }) + .returning({ id: entitybankaccounts.id }) + + return created?.id ? Number(created.id) : null + } + + const assignIbanFromStatementToCustomer = async (tenantId: number, userId: string, statementId: number, createdDocumentId?: number) => { + if (!createdDocumentId) return + + const [statement] = await server.db + .select() + .from(bankstatements) + .where(and(eq(bankstatements.id, statementId), eq(bankstatements.tenant, tenantId))) + .limit(1) + + if (!statement) return + + const [doc] = await server.db + .select({ customer: createddocuments.customer }) + .from(createddocuments) + .where(and(eq(createddocuments.id, createdDocumentId), eq(createddocuments.tenant, tenantId))) + .limit(1) + + const customerId = doc?.customer + if (!customerId) return + + const partnerBank = pickPartnerBankData(statement, "customer") + if (!partnerBank?.iban) return + + const [customer] = await server.db + .select({ id: customers.id, infoData: customers.infoData }) + .from(customers) + .where(and(eq(customers.id, customerId), eq(customers.tenant, tenantId))) + .limit(1) + + if (!customer) return + + const bankAccountId = await resolveEntityBankAccountId( + tenantId, + userId, + partnerBank.iban + ) + + const newInfoData = mergePartnerIban( + (customer.infoData || {}) as Record, + partnerBank.iban, + bankAccountId + ) + await server.db + .update(customers) + .set({ + infoData: newInfoData, + updatedAt: new Date(), + updatedBy: userId, + }) + .where(and(eq(customers.id, customerId), eq(customers.tenant, tenantId))) + } + + const assignIbanFromStatementToVendor = async (tenantId: number, userId: string, statementId: number, incomingInvoiceId?: number) => { + if (!incomingInvoiceId) return + + const [statement] = await server.db + .select() + .from(bankstatements) + .where(and(eq(bankstatements.id, statementId), eq(bankstatements.tenant, tenantId))) + .limit(1) + + if (!statement) return + + const [invoice] = await server.db + .select({ vendor: incominginvoices.vendor }) + .from(incominginvoices) + .where(and(eq(incominginvoices.id, incomingInvoiceId), eq(incominginvoices.tenant, tenantId))) + .limit(1) + + const vendorId = invoice?.vendor + if (!vendorId) return + + const partnerBank = pickPartnerBankData(statement, "vendor") + if (!partnerBank?.iban) return + + const [vendor] = await server.db + .select({ id: vendors.id, infoData: vendors.infoData }) + .from(vendors) + .where(and(eq(vendors.id, vendorId), eq(vendors.tenant, tenantId))) + .limit(1) + + if (!vendor) return + + const bankAccountId = await resolveEntityBankAccountId( + tenantId, + userId, + partnerBank.iban + ) + + const newInfoData = mergePartnerIban( + (vendor.infoData || {}) as Record, + partnerBank.iban, + bankAccountId + ) + await server.db + .update(vendors) + .set({ + infoData: newInfoData, + updatedAt: new Date(), + updatedBy: userId, + }) + .where(and(eq(vendors.id, vendorId), eq(vendors.tenant, tenantId))) + } // ------------------------------------------------------------------ // 🔐 GoCardLess Token Handling @@ -171,9 +457,35 @@ export default async function bankingRoutes(server: FastifyInstance) { const createdRecord = inserted[0] + if (createdRecord?.createddocument) { + try { + await assignIbanFromStatementToCustomer( + req.user.tenant_id, + req.user.user_id, + Number(createdRecord.bankstatement), + Number(createdRecord.createddocument) + ) + } catch (err) { + server.log.warn({ err, allocationId: createdRecord.id }, "Konnte IBAN nicht automatisch beim Kunden hinterlegen") + } + } + + if (createdRecord?.incominginvoice) { + try { + await assignIbanFromStatementToVendor( + req.user.tenant_id, + req.user.user_id, + Number(createdRecord.bankstatement), + Number(createdRecord.incominginvoice) + ) + } catch (err) { + server.log.warn({ err, allocationId: createdRecord.id }, "Konnte IBAN nicht automatisch beim Lieferanten hinterlegen") + } + } + await insertHistoryItem(server, { entity: "bankstatements", - entityId: createdRecord.id, + entityId: Number(createdRecord.bankstatement), action: "created", created_by: req.user.user_id, tenant_id: req.user.tenant_id, @@ -216,7 +528,7 @@ export default async function bankingRoutes(server: FastifyInstance) { await insertHistoryItem(server, { entity: "bankstatements", - entityId: id, + entityId: Number(old.bankstatement), action: "deleted", created_by: req.user.user_id, tenant_id: req.user.tenant_id, diff --git a/backend/src/routes/resources/main.ts b/backend/src/routes/resources/main.ts index abdb90d..c05d514 100644 --- a/backend/src/routes/resources/main.ts +++ b/backend/src/routes/resources/main.ts @@ -15,6 +15,7 @@ import { useNextNumberRangeNumber } from "../../utils/functions"; import { insertHistoryItem } from "../../utils/history"; import { diffObjects } from "../../utils/diff"; import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service"; +import { decrypt, encrypt } from "../../utils/crypt"; // ------------------------------------------------------------- // SQL Suche auf mehreren Feldern (Haupttabelle + Relationen) @@ -69,6 +70,98 @@ function buildFieldUpdateHistoryText(resource: string, label: string, oldValue: return `${resource}: ${label} geändert von "${formatDiffValue(oldValue)}" zu "${formatDiffValue(newValue)}"` } +function applyResourceWhereFilters(resource: string, table: any, whereCond: any) { + if (resource === "members") { + return and(whereCond, eq(table.type, "Mitglied")) + } + return whereCond +} + +function normalizeMemberPayload(payload: Record) { + const infoData = payload.infoData && typeof payload.infoData === "object" ? payload.infoData : {} + const normalized = { + ...payload, + type: "Mitglied", + isCompany: false, + infoData, + } + + return normalized +} + +function validateMemberPayload(payload: Record) { + const infoData = payload.infoData && typeof payload.infoData === "object" ? payload.infoData : {} + const bankAccountIds = Array.isArray(infoData.bankAccountIds) ? infoData.bankAccountIds.filter(Boolean) : [] + const firstname = typeof payload.firstname === "string" ? payload.firstname.trim() : "" + const lastname = typeof payload.lastname === "string" ? payload.lastname.trim() : "" + + if (!firstname || !lastname) { + return "Für Mitglieder sind Vorname und Nachname erforderlich." + } + + if (!bankAccountIds.length) { + return "Für Mitglieder muss mindestens ein Bankkonto hinterlegt werden." + } + + if (infoData.hasSEPA && !infoData.sepaSignedAt) { + return "Wenn ein SEPA-Mandat hinterlegt ist, muss ein Unterschriftsdatum gesetzt werden." + } + + return null +} + +function maskIban(iban: string) { + if (!iban) return "" + const cleaned = iban.replace(/\s+/g, "") + if (cleaned.length <= 8) return cleaned + return `${cleaned.slice(0, 4)} **** **** ${cleaned.slice(-4)}` +} + +function decryptEntityBankAccount(row: Record) { + const iban = row.ibanEncrypted ? decrypt(row.ibanEncrypted as any) : null + const bic = row.bicEncrypted ? decrypt(row.bicEncrypted as any) : null + const bankName = row.bankNameEncrypted ? decrypt(row.bankNameEncrypted as any) : null + + return { + ...row, + iban, + bic, + bankName, + displayLabel: `${maskIban(iban || "")}${bankName ? ` | ${bankName}` : ""}${row.description ? ` (${row.description})` : ""}`.trim(), + } +} + +function prepareEntityBankAccountPayload(payload: Record, requireAll: boolean) { + const iban = typeof payload.iban === "string" ? payload.iban.trim() : "" + const bic = typeof payload.bic === "string" ? payload.bic.trim() : "" + const bankName = typeof payload.bankName === "string" ? payload.bankName.trim() : "" + + const hasAnyPlainField = Object.prototype.hasOwnProperty.call(payload, "iban") + || Object.prototype.hasOwnProperty.call(payload, "bic") + || Object.prototype.hasOwnProperty.call(payload, "bankName") + + if (!hasAnyPlainField && !requireAll) { + return { data: payload } + } + + if (!iban || !bic || !bankName) { + return { error: "IBAN, BIC und Bankinstitut sind Pflichtfelder." } + } + + const result: Record = { + ...payload, + ibanEncrypted: encrypt(iban), + bicEncrypted: encrypt(bic), + bankNameEncrypted: encrypt(bankName), + } + + delete result.iban + delete result.bic + delete result.bankName + + return { data: result } +} + export default async function resourceRoutes(server: FastifyInstance) { // ------------------------------------------------------------- @@ -91,6 +184,7 @@ export default async function resourceRoutes(server: FastifyInstance) { const table = config.table let whereCond: any = eq(table.tenant, tenantId) + whereCond = applyResourceWhereFilters(resource, table, whereCond) let q = server.db.select().from(table).$dynamic() const searchCols: any[] = (config.searchColumns || []).map(c => table[c]) @@ -160,7 +254,7 @@ export default async function resourceRoutes(server: FastifyInstance) { if(config.mtmListLoad) { for await (const relation of config.mtmListLoad) { const relTable = resourceConfig[relation].table - const parentKey = resource.substring(0, resource.length - 1) + const parentKey = config.relationKey || resource.substring(0, resource.length - 1) const relationRows = await server.db.select().from(relTable).where(inArray(relTable[parentKey], data.map(i => i.id))) data = data.map(row => ({ ...row, @@ -169,6 +263,10 @@ export default async function resourceRoutes(server: FastifyInstance) { } } + if (resource === "entitybankaccounts") { + return data.map((row) => decryptEntityBankAccount(row)) + } + return data } catch (err) { @@ -194,6 +292,7 @@ export default async function resourceRoutes(server: FastifyInstance) { const { search, distinctColumns } = req.query as { search?: string; distinctColumns?: string; }; let whereCond: any = eq(table.tenant, tenantId); + whereCond = applyResourceWhereFilters(resource, table, whereCond) const searchCols: any[] = (config.searchColumns || []).map(c => table[c]); let countQuery = server.db.select({ value: count(table.id) }).from(table).$dynamic(); @@ -258,7 +357,7 @@ export default async function resourceRoutes(server: FastifyInstance) { for (const colName of distinctColumns.split(",").map(c => c.trim())) { const col = (table as any)[colName]; if (!col) continue; - const dRows = await server.db.select({ v: col }).from(table).where(eq(table.tenant, tenantId)); + const dRows = await server.db.select({ v: col }).from(table).where(whereCond); distinctValues[colName] = [...new Set(dRows.map(r => r.v).filter(v => v != null && v !== ""))].sort(); } } @@ -289,7 +388,7 @@ export default async function resourceRoutes(server: FastifyInstance) { if (config.mtmListLoad) { for await (const relation of config.mtmListLoad) { const relTable = resourceConfig[relation].table; - const parentKey = resource.substring(0, resource.length - 1); + const parentKey = config.relationKey || resource.substring(0, resource.length - 1); const relationRows = await server.db.select().from(relTable).where(inArray(relTable[parentKey], data.map(i => i.id))); data = data.map(row => ({ ...row, @@ -298,6 +397,10 @@ export default async function resourceRoutes(server: FastifyInstance) { } } + if (resource === "entitybankaccounts") { + data = data.map((row) => decryptEntityBankAccount(row)) + } + return { data, queryConfig: { ...queryConfig, total, totalPages: Math.ceil(total / limit), distinctValues } @@ -321,10 +424,13 @@ export default async function resourceRoutes(server: FastifyInstance) { const { resource, no_relations } = req.params as { resource: string, no_relations?: boolean } const table = resourceConfig[resource].table + let whereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId)) + whereCond = applyResourceWhereFilters(resource, table, whereCond) + const projRows = await server.db .select() .from(table) - .where(and(eq(table.id, id), eq(table.tenant, tenantId))) + .where(whereCond) .limit(1) if (!projRows.length) @@ -347,12 +453,16 @@ export default async function resourceRoutes(server: FastifyInstance) { if (resourceConfig[resource].mtmLoad) { for await (const relation of resourceConfig[resource].mtmLoad) { const relTable = resourceConfig[relation].table - const parentKey = resource.substring(0, resource.length - 1) + const parentKey = resourceConfig[resource].relationKey || resource.substring(0, resource.length - 1) data[relation] = await server.db.select().from(relTable).where(eq(relTable[parentKey], id)) } } } + if (resource === "entitybankaccounts") { + return decryptEntityBankAccount(data) + } + return data } catch (err) { console.error("ERROR /resource/:resource/:id", err) @@ -369,10 +479,25 @@ export default async function resourceRoutes(server: FastifyInstance) { const config = resourceConfig[resource]; const table = config.table; - let createData = { ...body, tenant: req.user.tenant_id, archived: false }; + let createData: Record = { ...body, tenant: req.user.tenant_id, archived: false }; + + if (resource === "members") { + createData = normalizeMemberPayload(createData) + const validationError = validateMemberPayload(createData) + if (validationError) { + return reply.code(400).send({ error: validationError }) + } + } + + if (resource === "entitybankaccounts") { + const prepared = prepareEntityBankAccountPayload(createData, true) + if (prepared.error) return reply.code(400).send({ error: prepared.error }) + createData = prepared.data! + } if (config.numberRangeHolder && !body[config.numberRangeHolder]) { - const result = await useNextNumberRangeNumber(server, req.user.tenant_id, resource) + const numberRangeResource = resource === "members" ? "customers" : resource + const result = await useNextNumberRangeNumber(server, req.user.tenant_id, numberRangeResource) createData[config.numberRangeHolder] = result.usedNumber } @@ -404,6 +529,10 @@ export default async function resourceRoutes(server: FastifyInstance) { } } + if (resource === "entitybankaccounts") { + return decryptEntityBankAccount(created as Record) + } + return created; } catch (error) { console.error(error); @@ -430,17 +559,37 @@ export default async function resourceRoutes(server: FastifyInstance) { .where(and(eq(table.id, id), eq(table.tenant, tenantId))) .limit(1) - let data = { ...body, updated_at: new Date().toISOString(), updated_by: userId } + let data: Record = { ...body, updated_at: new Date().toISOString(), updated_by: userId } //@ts-ignore delete data.updatedBy; delete data.updatedAt; + if (resource === "members") { + data = normalizeMemberPayload(data) + const validationError = validateMemberPayload(data) + if (validationError) { + return reply.code(400).send({ error: validationError }) + } + } + + if (resource === "entitybankaccounts") { + const prepared = prepareEntityBankAccountPayload(data, false) + if (prepared.error) return reply.code(400).send({ error: prepared.error }) + data = { + ...prepared.data, + updated_at: data.updated_at, + updated_by: data.updated_by, + } + } + Object.keys(data).forEach((key) => { if ((key.includes("_at") || key.includes("At") || key.toLowerCase().includes("date")) && key !== "deliveryDateType") { data[key] = normalizeDate(data[key]) } }) - const [updated] = await server.db.update(table).set(data).where(and(eq(table.id, id), eq(table.tenant, tenantId))).returning() + let updateWhereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId)) + updateWhereCond = applyResourceWhereFilters(resource, table, updateWhereCond) + const [updated] = await server.db.update(table).set(data).where(updateWhereCond).returning() if (["products", "services", "hourrates"].includes(resource)) { await recalculateServicePricesForTenant(server, tenantId, userId); @@ -479,6 +628,10 @@ export default async function resourceRoutes(server: FastifyInstance) { } } + if (resource === "entitybankaccounts") { + return decryptEntityBankAccount(updated as Record) + } + return updated } catch (err) { console.error(err) diff --git a/backend/src/utils/resource.config.ts b/backend/src/utils/resource.config.ts index 5f8c0b3..0d5d2e7 100644 --- a/backend/src/utils/resource.config.ts +++ b/backend/src/utils/resource.config.ts @@ -3,6 +3,7 @@ import { bankaccounts, bankrequisitions, bankstatements, + entitybankaccounts, contacts, contracts, contracttypes, @@ -49,6 +50,13 @@ export const resourceConfig = { table: customers, numberRangeHolder: "customerNumber", }, + members: { + searchColumns: ["name", "customerNumber", "firstname", "lastname", "notes"], + mtmLoad: ["contacts","projects","plants","createddocuments","contracts"], + table: customers, + numberRangeHolder: "customerNumber", + relationKey: "customer", + }, contacts: { searchColumns: ["firstName", "lastName", "email", "phone", "notes"], table: contacts, @@ -175,6 +183,10 @@ export const resourceConfig = { bankrequisitions: { table: bankrequisitions, }, + entitybankaccounts: { + table: entitybankaccounts, + searchColumns: ["description"], + }, serialexecutions: { table: serialExecutions } diff --git a/frontend/components/EntityEdit.vue b/frontend/components/EntityEdit.vue index c594cfb..a5c07ba 100644 --- a/frontend/components/EntityEdit.vue +++ b/frontend/components/EntityEdit.vue @@ -69,20 +69,31 @@ generateOldItemData() const saveAllowed = computed(() => { if (!item.value) return false + const isFilledValue = (value) => { + if (Array.isArray(value)) return value.length > 0 + if (typeof value === "string") return value.trim().length > 0 + return value !== null && value !== undefined && value !== false + } + let allowedCount = 0 // Nur Input-Felder berücksichtigen - const relevantColumns = dataType.templateColumns.filter(i => i.inputType) + const relevantColumns = dataType.templateColumns.filter(i => { + if (!i.inputType) return false + if (i.showFunction && !i.showFunction(item.value)) return false + if (i.disabledFunction && i.disabledFunction(item.value)) return false + return true + }) relevantColumns.forEach(datapoint => { if(datapoint.required) { if(datapoint.key.includes(".")){ const [parentKey, childKey] = datapoint.key.split('.') // Prüfung: Existiert Parent UND ist Child "truthy" (nicht null/undefined/empty) - if(item.value[parentKey] && item.value[parentKey][childKey]) { + if(item.value[parentKey] && isFilledValue(item.value[parentKey][childKey])) { allowedCount += 1 } } else { - if(item.value[datapoint.key]) { + if(isFilledValue(item.value[datapoint.key])) { allowedCount += 1 } } @@ -427,6 +438,11 @@ const updateItem = async () => { /> + { /> + { /> + { /> + \ No newline at end of file + diff --git a/frontend/components/EntityList.vue b/frontend/components/EntityList.vue index b977b7a..62780c3 100644 --- a/frontend/components/EntityList.vue +++ b/frontend/components/EntityList.vue @@ -69,6 +69,12 @@ const profileStore = useProfileStore() const tempStore = useTempStore() const dataType = dataStore.dataTypes[type] +const canCreate = computed(() => { + if (type === "members") { + return has("members-create") || has("customers-create") + } + return has(`${type}-create`) +}) const selectedColumns = ref(tempStore.columns[type] ? tempStore.columns[type] : dataType.templateColumns.filter(i => !i.disabledInTable)) const columns = computed(() => dataType.templateColumns.filter((column) => !column.disabledInTable && selectedColumns.value.find(i => i.key === column.key))) @@ -138,7 +144,7 @@ const filteredRows = computed(() => { /> + {{dataType.labelSingle}} @@ -200,4 +206,4 @@ const filteredRows = computed(() => { \ No newline at end of file + diff --git a/frontend/components/EntityShow.vue b/frontend/components/EntityShow.vue index c9133d2..6d6fd31 100644 --- a/frontend/components/EntityShow.vue +++ b/frontend/components/EntityShow.vue @@ -69,7 +69,7 @@ const getAvailableQueryStringData = (keys) => { if(props.item.customer) { addParam("customer", props.item.customer.id) - } else if(type === "customers") { + } else if(type === "customers" || type === "members") { addParam("customer", props.item.id) } @@ -372,4 +372,4 @@ const changePinned = async () => { \ No newline at end of file + diff --git a/frontend/components/MainNav.vue b/frontend/components/MainNav.vue index 469cf5a..1481daa 100644 --- a/frontend/components/MainNav.vue +++ b/frontend/components/MainNav.vue @@ -5,6 +5,13 @@ const { has } = usePermission() // Lokaler State für den Taschenrechner const showCalculator = ref(false) +const tenantExtraModules = computed(() => { + const modules = auth.activeTenantData?.extraModules + return Array.isArray(modules) ? modules : [] +}) +const showMembersNav = computed(() => { + return tenantExtraModules.value.includes("verein") && (has("members") || has("customers")) +}) const links = computed(() => { return [ @@ -98,11 +105,16 @@ const links = computed(() => { } ] }, - ...(has("customers") || has("vendors") || has("contacts")) ? [{ + ...(has("customers") || has("vendors") || has("contacts") || showMembersNav.value) ? [{ label: "Kontakte", defaultOpen: false, icon: "i-heroicons-user-group", children: [ + ...showMembersNav.value ? [{ + label: "Mitglieder", + to: "/standardEntity/members", + icon: "i-heroicons-user-group" + }] : [], ...has("customers") ? [{ label: "Kunden", to: "/standardEntity/customers", diff --git a/frontend/pages/standardEntity/[type]/index.vue b/frontend/pages/standardEntity/[type]/index.vue index cb86758..41bb1da 100644 --- a/frontend/pages/standardEntity/[type]/index.vue +++ b/frontend/pages/standardEntity/[type]/index.vue @@ -49,6 +49,12 @@ const tempStore = useTempStore() const type = route.params.type const dataType = dataStore.dataTypes[type] +const canCreate = computed(() => { + if (type === "members") { + return has("members-create") || has("customers-create") + } + return has(`${type}-create`) +}) const selectedColumns = ref(tempStore.columns[type] ? tempStore.columns[type] : dataType.templateColumns.filter(i => !i.disabledInTable)) const columns = computed(() => dataType.templateColumns.filter((column) => !column.disabledInTable && selectedColumns.value.find(i => i.key === column.key))) @@ -240,7 +246,7 @@ const handleFilterChange = async (action,column) => { + {{dataType.labelSingle}} diff --git a/frontend/stores/data.js b/frontend/stores/data.js index acba5cc..f2e3ea3 100644 --- a/frontend/stores/data.js +++ b/frontend/stores/data.js @@ -43,6 +43,7 @@ import quantity from "~/components/helpRenderings/quantity.vue" import {useFunctions} from "~/composables/useFunctions.js"; import signDate from "~/components/columnRenderings/signDate.vue"; import sepaDate from "~/components/columnRenderings/sepaDate.vue"; +import bankAccounts from "~/components/columnRenderings/bankAccounts.vue"; // @ts-ignore export const useDataStore = defineStore('data', () => { @@ -410,6 +411,14 @@ export const useDataStore = defineStore('data', () => { inputType: "text", inputColumn: "Kontaktdaten" }, + { + key: "infoData.bankAccountIds", + label: "Bankkonten", + component: bankAccounts, + inputType: "bankaccountassign", + inputColumn: "Kontaktdaten", + disabledInTable: true + }, { key: "infoData.ustid", label: "USt-Id", @@ -436,6 +445,202 @@ export const useDataStore = defineStore('data', () => { ], showTabs: [{label: 'Informationen'},{label: 'Ansprechpartner'},{label: 'Dateien'},{label: 'Ausgangsbelege'},{label: 'Projekte'},{label: 'Objekte'},{label: 'Termine'},{label: 'Verträge'},{label: 'Wiki'}] }, + members: { + isArchivable: true, + label: "Mitglieder", + labelSingle: "Mitglied", + isStandardEntity: true, + redirect: true, + numberRangeHolder: "customerNumber", + historyItemHolder: "customer", + sortColumn: "customerNumber", + selectWithInformation: "*, projects(*), plants(*), contracts(*), contacts(*), createddocuments(*, statementallocations(*)), files(*), events(*)", + filters: [{ + name: "Archivierte ausblenden", + default: true, + "filterFunction": function (row) { + return !row.archived + } + }], + inputColumns: [ + "Allgemeines", + "Bank & Kontakt" + ], + templateColumns: [ + { + key: 'customerNumber', + label: "Mitgliedsnummer", + inputIsNumberRange: true, + inputType: "text", + inputColumn: "Allgemeines", + sortable: true + }, + { + key: "name", + label: "Name", + title: true, + sortable: true, + distinct: true + }, + { + key: "salutation", + label: "Anrede", + inputType: "text", + inputChangeFunction: function (row) { + row.name = `${row.salutation ? (row.salutation + " ") : ""}${row.title ? (row.title + " ") : ""}${row.firstname ? (row.firstname + " ") : ""}${row.lastname || ""}`.trim(); + }, + inputColumn: "Allgemeines", + sortable: true, + distinct: true + }, + { + key: "title", + label: "Titel", + inputType: "text", + inputChangeFunction: function (row) { + row.name = `${row.salutation ? (row.salutation + " ") : ""}${row.title ? (row.title + " ") : ""}${row.firstname ? (row.firstname + " ") : ""}${row.lastname || ""}`.trim(); + }, + inputColumn: "Allgemeines" + }, + { + key: "firstname", + label: "Vorname", + required: true, + inputType: "text", + inputChangeFunction: function (row) { + row.name = `${row.salutation ? (row.salutation + " ") : ""}${row.title ? (row.title + " ") : ""}${row.firstname ? (row.firstname + " ") : ""}${row.lastname || ""}`.trim(); + }, + inputColumn: "Allgemeines", + sortable: true + }, + { + key: "lastname", + label: "Nachname", + required: true, + inputType: "text", + inputChangeFunction: function (row) { + row.name = `${row.salutation ? (row.salutation + " ") : ""}${row.title ? (row.title + " ") : ""}${row.firstname ? (row.firstname + " ") : ""}${row.lastname || ""}`.trim(); + }, + inputColumn: "Allgemeines", + sortable: true + }, + { + key: "active", + label: "Aktiv", + component: active, + inputType: "bool", + inputColumn: "Allgemeines", + sortable: true, + distinct: true + }, + { + key: "customPaymentDays", + label: "Beitragsintervall in Tagen", + inputType: "number", + inputColumn: "Allgemeines", + sortable: true + }, + { + key: "infoData.bankAccountIds", + label: "Bankkonten", + component: bankAccounts, + inputType: "bankaccountassign", + required: true, + inputColumn: "Bank & Kontakt", + disabledInTable: true + }, + { + key: "infoData.hasSEPA", + label: "SEPA-Mandat vorhanden", + inputType: "bool", + inputColumn: "Bank & Kontakt", + disabledInTable: true + }, + { + key: "infoData.sepaSignedAt", + label: "SEPA unterschrieben am", + inputType: "date", + required: true, + showFunction: function (item) { + return Boolean(item.infoData?.hasSEPA) + }, + inputColumn: "Bank & Kontakt", + disabledInTable: true + }, + { + key: "infoData.street", + label: "Straße + Hausnummer", + inputType: "text", + disabledInTable: true, + inputColumn: "Bank & Kontakt" + }, + { + key: "infoData.special", + label: "Adresszusatz", + inputType: "text", + disabledInTable: true, + inputColumn: "Bank & Kontakt" + }, + { + key: "infoData.zip", + label: "Postleitzahl", + inputType: "text", + inputChangeFunction: async function (row) { + const zip = String(row.infoData.zip || "").replace(/\D/g, "") + row.infoData.zip = zip + if ([4, 5].includes(zip.length)) { + const zipData = await useFunctions().useZipCheck(zip) + row.infoData.zip = zipData?.zip || row.infoData.zip + row.infoData.city = zipData?.short || row.infoData.city + } + }, + disabledInTable: true, + inputColumn: "Bank & Kontakt" + }, + { + key: "infoData.city", + label: "Stadt", + inputType: "text", + disabledInTable: true, + inputColumn: "Bank & Kontakt" + }, + { + key: "infoData.country", + label: "Land", + inputType: "select", + selectDataType: "countrys", + selectOptionAttribute: "name", + selectValueAttribute: "name", + disabledInTable: true, + inputColumn: "Bank & Kontakt" + }, + { + key: "address", + label: "Adresse", + component: address, + inputColumn: "Bank & Kontakt" + }, + { + key: "infoData.tel", + label: "Telefon", + inputType: "text", + inputColumn: "Bank & Kontakt" + }, + { + key: "infoData.email", + label: "E-Mail", + inputType: "text", + inputColumn: "Bank & Kontakt" + }, + { + key: "notes", + label: "Notizen", + inputType: "textarea", + inputColumn: "Allgemeines" + }, + ], + showTabs: [{label: 'Informationen'},{label: 'Ansprechpartner'},{label: 'Dateien'},{label: 'Ausgangsbelege'},{label: 'Projekte'},{label: 'Objekte'},{label: 'Termine'},{label: 'Verträge'},{label: 'Wiki'}] + }, contacts: { isArchivable: true, label: "Kontakte", @@ -1455,6 +1660,13 @@ export const useDataStore = defineStore('data', () => { label: "Web", inputType: "text" }, + { + key: "infoData.bankAccountIds", + label: "Bankkonten", + component: bankAccounts, + inputType: "bankaccountassign", + disabledInTable: true + }, { key: "infoData.ustid", label: "USt-Id", @@ -2700,6 +2912,55 @@ export const useDataStore = defineStore('data', () => { bankaccounts: { label: "Bankkonten", labelSingle: "Bankkonto", + }, + entitybankaccounts: { + isArchivable: true, + label: "Bankverbindungen", + labelSingle: "Bankverbindung", + isStandardEntity: false, + redirect: false, + sortColumn: "created_at", + filters: [{ + name: "Archivierte ausblenden", + default: true, + "filterFunction": function (row) { + return !row.archived + } + }], + templateColumns: [ + { + key: "displayLabel", + label: "Bankverbindung", + title: true, + sortable: true + }, + { + key: "iban", + label: "IBAN", + required: true, + inputType: "text", + sortable: true + }, + { + key: "bic", + label: "BIC", + required: true, + inputType: "text", + sortable: true + }, + { + key: "bankName", + label: "Bankinstitut", + required: true, + inputType: "text", + sortable: true + }, + { + key: "description", + label: "Beschreibung", + inputType: "text", + }, + ], } }