Ausgehende SEPA-Mandate einführen #183
This commit is contained in:
53
backend/db/migrations/0037_outgoing_sepa_mandates.sql
Normal file
53
backend/db/migrations/0037_outgoing_sepa_mandates.sql
Normal file
@@ -0,0 +1,53 @@
|
||||
CREATE TABLE "outgoingsepamandates" (
|
||||
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "outgoingsepamandates_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,
|
||||
"customer" bigint NOT NULL,
|
||||
"bankaccount" bigint NOT NULL,
|
||||
"reference" text NOT NULL,
|
||||
"status" text DEFAULT 'Entwurf' NOT NULL,
|
||||
"mandate_type" text DEFAULT 'CORE' NOT NULL,
|
||||
"sequence_type" text DEFAULT 'RCUR' NOT NULL,
|
||||
"signed_at" timestamp with time zone,
|
||||
"valid_from" timestamp with time zone,
|
||||
"valid_until" timestamp with time zone,
|
||||
"default_mandate" boolean DEFAULT false NOT NULL,
|
||||
"notes" text,
|
||||
"archived" boolean DEFAULT false NOT NULL,
|
||||
"updated_at" timestamp with time zone,
|
||||
"updated_by" uuid
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "outgoingsepamandates" ADD CONSTRAINT "outgoingsepamandates_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "outgoingsepamandates" ADD CONSTRAINT "outgoingsepamandates_customer_customers_id_fk" FOREIGN KEY ("customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "outgoingsepamandates" ADD CONSTRAINT "outgoingsepamandates_bankaccount_entitybankaccounts_id_fk" FOREIGN KEY ("bankaccount") REFERENCES "public"."entitybankaccounts"("id") ON DELETE no action ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "outgoingsepamandates" ADD CONSTRAINT "outgoingsepamandates_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "contracts" ADD COLUMN "outgoingsepamandate" bigint;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "contracts" ADD CONSTRAINT "contracts_outgoingsepamandate_outgoingsepamandates_id_fk" FOREIGN KEY ("outgoingsepamandate") REFERENCES "public"."outgoingsepamandates"("id") ON DELETE no action ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "createddocuments" ADD COLUMN "outgoingsepamandate" bigint;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "createddocuments" ADD CONSTRAINT "createddocuments_outgoingsepamandate_outgoingsepamandates_id_fk" FOREIGN KEY ("outgoingsepamandate") REFERENCES "public"."outgoingsepamandates"("id") ON DELETE no action ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "historyitems" ADD COLUMN "outgoingsepamandate" bigint;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_outgoingsepamandate_outgoingsepamandates_id_fk" FOREIGN KEY ("outgoingsepamandate") REFERENCES "public"."outgoingsepamandates"("id") ON DELETE cascade ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tenants" ALTER COLUMN "numberRanges" SET DEFAULT '{"vendors":{"prefix":"","suffix":"","nextNumber":10000},"customers":{"prefix":"","suffix":"","nextNumber":10000},"products":{"prefix":"AT-","suffix":"","nextNumber":1000},"quotes":{"prefix":"AN-","suffix":"","nextNumber":1000},"costEstimates":{"prefix":"KS-","suffix":"","nextNumber":1000},"confirmationOrders":{"prefix":"AB-","suffix":"","nextNumber":1000},"invoices":{"prefix":"RE-","suffix":"","nextNumber":1000},"deliveryNotes":{"prefix":"LS-","suffix":"","nextNumber":1000},"packingSlips":{"prefix":"PS-","suffix":"","nextNumber":1000},"spaces":{"prefix":"LP-","suffix":"","nextNumber":1000},"customerspaces":{"prefix":"KLP-","suffix":"","nextNumber":1000},"inventoryitems":{"prefix":"IA-","suffix":"","nextNumber":1000},"customerinventoryitems":{"prefix":"KIA-","suffix":"","nextNumber":1000},"projects":{"prefix":"PRJ-","suffix":"","nextNumber":1000},"costcentres":{"prefix":"KST-","suffix":"","nextNumber":1000},"outgoingsepamandates":{"prefix":"SEPA-","suffix":"","nextNumber":1000}}'::jsonb;
|
||||
--> statement-breakpoint
|
||||
UPDATE "tenants"
|
||||
SET "numberRanges" = COALESCE("numberRanges", '{}'::jsonb) || jsonb_build_object(
|
||||
'outgoingsepamandates',
|
||||
COALESCE("numberRanges"->'outgoingsepamandates', '{"prefix":"SEPA-","suffix":"","nextNumber":1000}'::jsonb)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
UPDATE "tenants"
|
||||
SET "features" = COALESCE("features", '{}'::jsonb) || jsonb_build_object(
|
||||
'outgoingsepamandates',
|
||||
COALESCE("features"->'outgoingsepamandates', 'true'::jsonb)
|
||||
);
|
||||
@@ -253,6 +253,13 @@
|
||||
"when": 1778194800000,
|
||||
"tag": "0036_allowed_contracttypes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 36,
|
||||
"version": "7",
|
||||
"when": 1778840100000,
|
||||
"tag": "0037_outgoing_sepa_mandates",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { customers } from "./customers"
|
||||
import { contacts } from "./contacts"
|
||||
import { contracttypes } from "./contracttypes"
|
||||
import { authUsers } from "./auth_users"
|
||||
import { outgoingsepamandates } from "./outgoingsepamandates"
|
||||
|
||||
export const contracts = pgTable(
|
||||
"contracts",
|
||||
@@ -60,6 +61,9 @@ export const contracts = pgTable(
|
||||
bankingOwner: text("bankingOwner"),
|
||||
sepaRef: text("sepaRef"),
|
||||
sepaDate: timestamp("sepaDate", { withTimezone: true }),
|
||||
outgoingsepamandate: bigint("outgoingsepamandate", { mode: "number" }).references(
|
||||
() => outgoingsepamandates.id
|
||||
),
|
||||
|
||||
paymentType: text("paymentType"),
|
||||
billingInterval: text("billingInterval"),
|
||||
|
||||
@@ -19,6 +19,7 @@ import { projects } from "./projects"
|
||||
import { plants } from "./plants"
|
||||
import { authUsers } from "./auth_users"
|
||||
import {serialExecutions} from "./serialexecutions";
|
||||
import { outgoingsepamandates } from "./outgoingsepamandates"
|
||||
|
||||
export const createddocuments = pgTable("createddocuments", {
|
||||
id: bigint("id", { mode: "number" })
|
||||
@@ -118,6 +119,10 @@ export const createddocuments = pgTable("createddocuments", {
|
||||
() => contracts.id
|
||||
),
|
||||
|
||||
outgoingsepamandate: bigint("outgoingsepamandate", { mode: "number" }).references(
|
||||
() => outgoingsepamandates.id
|
||||
),
|
||||
|
||||
serialexecution: uuid("serialexecution").references(() => serialExecutions.id)
|
||||
})
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import { authUsers } from "./auth_users"
|
||||
import {files} from "./files";
|
||||
import { memberrelations } from "./memberrelations";
|
||||
import { contracts } from "./contracts";
|
||||
import { outgoingsepamandates } from "./outgoingsepamandates";
|
||||
|
||||
export const historyitems = pgTable("historyitems", {
|
||||
id: bigint("id", { mode: "number" })
|
||||
@@ -114,6 +115,11 @@ export const historyitems = pgTable("historyitems", {
|
||||
|
||||
memberrelation: bigint("memberrelation", { mode: "number" }).references(() => memberrelations.id),
|
||||
|
||||
outgoingsepamandate: bigint("outgoingsepamandate", { mode: "number" }).references(
|
||||
() => outgoingsepamandates.id,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
|
||||
config: jsonb("config"),
|
||||
|
||||
projecttype: bigint("projecttype", { mode: "number" }).references(
|
||||
|
||||
@@ -57,6 +57,7 @@ export * from "./notifications_items"
|
||||
export * from "./notifications_preferences"
|
||||
export * from "./notifications_preferences_defaults"
|
||||
export * from "./ownaccounts"
|
||||
export * from "./outgoingsepamandates"
|
||||
export * from "./plants"
|
||||
export * from "./productcategories"
|
||||
export * from "./products"
|
||||
|
||||
61
backend/db/schema/outgoingsepamandates.ts
Normal file
61
backend/db/schema/outgoingsepamandates.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
pgTable,
|
||||
bigint,
|
||||
text,
|
||||
timestamp,
|
||||
boolean,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core"
|
||||
|
||||
import { tenants } from "./tenants"
|
||||
import { customers } from "./customers"
|
||||
import { entitybankaccounts } from "./entitybankaccounts"
|
||||
import { authUsers } from "./auth_users"
|
||||
|
||||
export const outgoingsepamandates = pgTable("outgoingsepamandates", {
|
||||
id: bigint("id", { mode: "number" })
|
||||
.primaryKey()
|
||||
.generatedByDefaultAsIdentity(),
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
|
||||
tenant: bigint("tenant", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => tenants.id),
|
||||
|
||||
customer: bigint("customer", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => customers.id),
|
||||
|
||||
bankaccount: bigint("bankaccount", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => entitybankaccounts.id),
|
||||
|
||||
reference: text("reference").notNull(),
|
||||
|
||||
status: text("status").notNull().default("Entwurf"),
|
||||
|
||||
mandateType: text("mandate_type").notNull().default("CORE"),
|
||||
|
||||
sequenceType: text("sequence_type").notNull().default("RCUR"),
|
||||
|
||||
signedAt: timestamp("signed_at", { withTimezone: true }),
|
||||
|
||||
validFrom: timestamp("valid_from", { withTimezone: true }),
|
||||
|
||||
validUntil: timestamp("valid_until", { withTimezone: true }),
|
||||
|
||||
defaultMandate: boolean("default_mandate").notNull().default(false),
|
||||
|
||||
notes: text("notes"),
|
||||
|
||||
archived: boolean("archived").notNull().default(false),
|
||||
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||
})
|
||||
|
||||
export type OutgoingSepaMandate = typeof outgoingsepamandates.$inferSelect
|
||||
export type NewOutgoingSepaMandate = typeof outgoingsepamandates.$inferInsert
|
||||
@@ -91,6 +91,7 @@ export const tenants = pgTable(
|
||||
createDocument: true,
|
||||
serialInvoice: true,
|
||||
incomingInvoices: true,
|
||||
outgoingsepamandates: true,
|
||||
costcentres: true,
|
||||
branches: true,
|
||||
teams: true,
|
||||
@@ -140,6 +141,7 @@ export const tenants = pgTable(
|
||||
customerinventoryitems: { prefix: "KIA-", suffix: "", nextNumber: 1000 },
|
||||
projects: { prefix: "PRJ-", suffix: "", nextNumber: 1000 },
|
||||
costcentres: { prefix: "KST-", suffix: "", nextNumber: 1000 },
|
||||
outgoingsepamandates: { prefix: "SEPA-", suffix: "", nextNumber: 1000 },
|
||||
}),
|
||||
accountChart: text("accountChart").notNull().default("skr03"),
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ const columnMap: Record<string, any> = {
|
||||
customerspaces: historyitems.customerspace,
|
||||
customerinventoryitems: historyitems.customerinventoryitem,
|
||||
memberrelations: historyitems.memberrelation,
|
||||
outgoingsepamandates: historyitems.outgoingsepamandate,
|
||||
};
|
||||
|
||||
const insertFieldMap: Record<string, string> = {
|
||||
@@ -53,6 +54,7 @@ const insertFieldMap: Record<string, string> = {
|
||||
customerspaces: "customerspace",
|
||||
customerinventoryitems: "customerinventoryitem",
|
||||
memberrelations: "memberrelation",
|
||||
outgoingsepamandates: "outgoingsepamandate",
|
||||
}
|
||||
|
||||
const parseId = (value: string) => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
sql,
|
||||
} from "drizzle-orm"
|
||||
|
||||
import { authProfiles, costcentres } from "../../../db/schema";
|
||||
import { authProfiles, costcentres, customers, entitybankaccounts } from "../../../db/schema";
|
||||
import { resourceConfig } from "../../utils/resource.config";
|
||||
import { useNextNumberRangeNumber } from "../../utils/functions";
|
||||
import { getHistoryEntityLabel, insertHistoryItem } from "../../utils/history";
|
||||
@@ -365,6 +365,50 @@ function prepareEntityBankAccountPayload(payload: Record<string, any>, requireAl
|
||||
return { data: result }
|
||||
}
|
||||
|
||||
async function validateOutgoingSepaMandatePayload(
|
||||
server: FastifyInstance,
|
||||
tenantId: number,
|
||||
payload: Record<string, any>,
|
||||
existing: Record<string, any> | null = null
|
||||
) {
|
||||
const customerId = Number(payload.customer ?? existing?.customer)
|
||||
const bankaccountId = Number(payload.bankaccount ?? existing?.bankaccount)
|
||||
|
||||
if (!customerId || !bankaccountId) {
|
||||
return "Kunde und Bankverbindung sind Pflichtfelder."
|
||||
}
|
||||
|
||||
const [customer] = await server.db
|
||||
.select()
|
||||
.from(customers)
|
||||
.where(and(eq(customers.id, customerId), eq(customers.tenant, tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!customer) {
|
||||
return "Kunde nicht gefunden."
|
||||
}
|
||||
|
||||
const [bankaccount] = await server.db
|
||||
.select()
|
||||
.from(entitybankaccounts)
|
||||
.where(and(eq(entitybankaccounts.id, bankaccountId), eq(entitybankaccounts.tenant, tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!bankaccount) {
|
||||
return "Bankverbindung nicht gefunden."
|
||||
}
|
||||
|
||||
const assignedBankAccountIds = Array.isArray((customer.infoData as any)?.bankAccountIds)
|
||||
? (customer.infoData as any).bankAccountIds.map((id: any) => Number(id))
|
||||
: []
|
||||
|
||||
if (!assignedBankAccountIds.includes(bankaccountId)) {
|
||||
return "Die Bankverbindung ist dem ausgewählten Kunden nicht zugeordnet."
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default async function resourceRoutes(server: FastifyInstance) {
|
||||
|
||||
// -------------------------------------------------------------
|
||||
@@ -796,6 +840,13 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
if (resource === "outgoingsepamandates") {
|
||||
const validationError = await validateOutgoingSepaMandatePayload(server, req.user.tenant_id, createData)
|
||||
if (validationError) {
|
||||
return reply.code(400).send({ error: validationError })
|
||||
}
|
||||
}
|
||||
|
||||
if (config.numberRangeHolder && !body[config.numberRangeHolder]) {
|
||||
const numberRangeResource = resource === "members" ? "customers" : resource
|
||||
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, numberRangeResource)
|
||||
@@ -809,6 +860,17 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
|
||||
const [created] = await server.db.insert(table).values(createData).returning()
|
||||
|
||||
if (resource === "outgoingsepamandates" && created?.defaultMandate) {
|
||||
await server.db
|
||||
.update(table)
|
||||
.set({ defaultMandate: false })
|
||||
.where(and(
|
||||
eq(table.tenant, req.user.tenant_id),
|
||||
eq(table.customer, created.customer),
|
||||
sql`${table.id} <> ${created.id}`
|
||||
))
|
||||
}
|
||||
|
||||
if (["products", "services", "hourrates"].includes(resource)) {
|
||||
await recalculateServicePricesForTenant(server, req.user.tenant_id, req.user?.user_id || null);
|
||||
}
|
||||
@@ -917,6 +979,13 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
if (resource === "outgoingsepamandates") {
|
||||
const validationError = await validateOutgoingSepaMandatePayload(server, tenantId, data, oldRecord)
|
||||
if (validationError) {
|
||||
return reply.code(400).send({ error: validationError })
|
||||
}
|
||||
}
|
||||
|
||||
Object.keys(data).forEach((key) => {
|
||||
const value = data[key]
|
||||
const shouldNormalize =
|
||||
@@ -934,6 +1003,17 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
updateWhereCond = applyResourceWhereFilters(resource, table, updateWhereCond)
|
||||
const [updated] = await server.db.update(table).set(data).where(updateWhereCond).returning()
|
||||
|
||||
if (resource === "outgoingsepamandates" && updated?.defaultMandate) {
|
||||
await server.db
|
||||
.update(table)
|
||||
.set({ defaultMandate: false })
|
||||
.where(and(
|
||||
eq(table.tenant, tenantId),
|
||||
eq(table.customer, updated.customer),
|
||||
sql`${table.id} <> ${updated.id}`
|
||||
))
|
||||
}
|
||||
|
||||
if (["products", "services", "hourrates"].includes(resource)) {
|
||||
await recalculateServicePricesForTenant(server, tenantId, userId);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ const HISTORY_ENTITY_LABELS: Record<string, string> = {
|
||||
files: "Dateien",
|
||||
memberrelations: "Mitgliedsverhältnisse",
|
||||
teams: "Teams",
|
||||
outgoingsepamandates: "Ausgehende SEPA-Mandate",
|
||||
}
|
||||
|
||||
export function getHistoryEntityLabel(entity: string) {
|
||||
@@ -94,6 +95,7 @@ export async function insertHistoryItem(
|
||||
incominginvoices: "incomingInvoice",
|
||||
files: "file",
|
||||
memberrelations: "memberrelation",
|
||||
outgoingsepamandates: "outgoingsepamandate",
|
||||
}
|
||||
|
||||
const fkColumn = columnMap[params.entity]
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
letterheads,
|
||||
memberrelations,
|
||||
ownaccounts,
|
||||
outgoingsepamandates,
|
||||
plants,
|
||||
productcategories,
|
||||
products,
|
||||
@@ -53,7 +54,7 @@ export const resourceConfig = {
|
||||
},
|
||||
customers: {
|
||||
searchColumns: ["name", "nameAddition", "customerNumber", "firstname", "lastname", "notes"],
|
||||
mtmLoad: ["contacts","projects","plants","createddocuments","contracts","customerinventoryitems","customerspaces"],
|
||||
mtmLoad: ["contacts","projects","plants","createddocuments","contracts","outgoingsepamandates","customerinventoryitems","customerspaces"],
|
||||
table: customers,
|
||||
numberRangeHolder: "customerNumber",
|
||||
},
|
||||
@@ -77,7 +78,13 @@ export const resourceConfig = {
|
||||
table: contracts,
|
||||
searchColumns: ["name", "notes", "contractNumber", "paymentType", "billingInterval", "sepaRef", "bankingName"],
|
||||
numberRangeHolder: "contractNumber",
|
||||
mtoLoad: ["customer", "contracttype"],
|
||||
mtoLoad: ["customer", "contracttype", "outgoingsepamandate"],
|
||||
},
|
||||
outgoingsepamandates: {
|
||||
table: outgoingsepamandates,
|
||||
searchColumns: ["reference", "status", "mandateType", "sequenceType", "notes"],
|
||||
numberRangeHolder: "reference",
|
||||
mtoLoad: ["customer", "bankaccount"],
|
||||
},
|
||||
contracttypes: {
|
||||
table: contracttypes,
|
||||
@@ -200,7 +207,7 @@ export const resourceConfig = {
|
||||
},
|
||||
createddocuments: {
|
||||
table: createddocuments,
|
||||
mtoLoad: ["customer", "project", "contact", "contract", "plant","letterhead","createddocument"],
|
||||
mtoLoad: ["customer", "project", "contact", "contract", "plant","letterhead","createddocument", "outgoingsepamandate"],
|
||||
mtmLoad: ["statementallocations","files","createddocuments"],
|
||||
mtmListLoad: ["statementallocations", "files"],
|
||||
},
|
||||
@@ -235,6 +242,10 @@ export const resourceConfig = {
|
||||
table: entitybankaccounts,
|
||||
searchColumns: ["description"],
|
||||
},
|
||||
bankaccount: {
|
||||
table: entitybankaccounts,
|
||||
searchColumns: ["description"],
|
||||
},
|
||||
serialexecutions: {
|
||||
table: serialExecutions
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user