Refactor financial transaction handling: Consolidate Einnahmen and Ausgaben into Buchung model, update routes and UI components, and add new migration scripts for database schema changes.

This commit is contained in:
hwinkel
2026-03-24 21:06:07 +01:00
parent d582c748a2
commit 1ec15600b5
18 changed files with 928 additions and 358 deletions
@@ -0,0 +1,17 @@
-- Add source linking fields to Buchung model
ALTER TABLE `buchungen` ADD COLUMN `betriebseinnahmeId` VARCHAR(191) NULL;
ALTER TABLE `buchungen` ADD COLUMN `betriebsausgabeId` VARCHAR(191) NULL;
ALTER TABLE `buchungen` ADD COLUMN `linkedBuchungId` VARCHAR(191) NULL;
-- Add unique constraints for 1:1 relations
ALTER TABLE `buchungen` ADD UNIQUE INDEX `buchungen_betriebseinnahmeId_key`(`betriebseinnahmeId`);
ALTER TABLE `buchungen` ADD UNIQUE INDEX `buchungen_betriebsausgabeId_key`(`betriebsausgabeId`);
-- Add foreign key constraints
ALTER TABLE `buchungen` ADD CONSTRAINT `buchungen_betriebseinnahmeId_fkey` FOREIGN KEY (`betriebseinnahmeId`) REFERENCES `betriebseinnahmen`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `buchungen` ADD CONSTRAINT `buchungen_betriebsausgabeId_fkey` FOREIGN KEY (`betriebsausgabeId`) REFERENCES `betriebsausgaben`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `buchungen` ADD CONSTRAINT `buchungen_linkedBuchungId_fkey` FOREIGN KEY (`linkedBuchungId`) REFERENCES `buchungen`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- Add indexes for linked fields
ALTER TABLE `buchungen` ADD INDEX `buchungen_betriebsausgabeId_idx`(`betriebsausgabeId`);
ALTER TABLE `buchungen` ADD INDEX `buchungen_linkedBuchungId_idx`(`linkedBuchungId`);
@@ -0,0 +1,9 @@
-- Add buchungId field to invoices table
ALTER TABLE `invoices` ADD COLUMN `buchungId` VARCHAR(191) NULL;
-- Create unique index for the foreign key
ALTER TABLE `invoices` ADD UNIQUE INDEX `invoices_buchungId_key`(`buchungId`);
-- Add foreign key constraint
ALTER TABLE `invoices` ADD CONSTRAINT `invoices_buchungId_fkey`
FOREIGN KEY (`buchungId`) REFERENCES `buchungen`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,41 @@
-- CreateTable for BuchungKategorie
CREATE TABLE `buchung_kategorien` (
`id` VARCHAR(191) NOT NULL,
`companyId` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`typ` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `buchung_kategorien_companyId_idx`(`companyId`),
UNIQUE INDEX `buchung_kategorien_companyId_name_typ_key`(`companyId`, `name`, `typ`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey for BuchungKategorie
ALTER TABLE `buchung_kategorien` ADD CONSTRAINT `buchung_kategorien_companyId_fkey` FOREIGN KEY (`companyId`) REFERENCES `companies`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AlterTable `buchungen` - add new columns
ALTER TABLE `buchungen` ADD COLUMN `kategorie` VARCHAR(191) NULL;
ALTER TABLE `buchungen` ADD COLUMN `steuersatz` INT NULL;
ALTER TABLE `buchungen` ADD COLUMN `zahlungsart` ENUM('KASSE', 'BANK') NULL;
ALTER TABLE `buchungen` ADD COLUMN `isBusinessRecord` BOOLEAN NOT NULL DEFAULT false;
-- Add index for isBusinessRecord
ALTER TABLE `buchungen` ADD INDEX `buchungen_isBusinessRecord_idx`(`isBusinessRecord`);
-- Migrate existing data from betriebseinnahmen/betriebsausgaben to Buchung
-- This is handled by the post-migration script
-- AlterTable `betriebseinnahmen` - change kategorie from Enum to String
ALTER TABLE `betriebseinnahmen` MODIFY `kategorie` VARCHAR(191) NOT NULL;
ALTER TABLE `betriebseinnahmen` MODIFY `steuersatz` INT NOT NULL DEFAULT 0;
-- AlterTable `betriebsausgaben` - change kategorie from Enum to String
ALTER TABLE `betriebsausgaben` MODIFY `kategorie` VARCHAR(191) NOT NULL;
ALTER TABLE `betriebsausgaben` MODIFY `steuersatz` INT NOT NULL DEFAULT 0;
-- Drop old foreign key constraints from buchungen (if they exist from previous migration)
-- These will be re-added if needed, but for now we're consolidating
-- Note: The enum columns EinnahmeKategorie and AusgabeKategorie are automatically
-- dropped by Prisma when they're no longer referenced in the schema.
@@ -0,0 +1,61 @@
-- Migration: Drop legacy betriebseinnahmen and betriebsausgaben tables
-- This consolidates all transaction data into the buchungen table
-- STEP 1: Copy existing betriebseinnahmen into buchungen
-- Only copy those that don't already have a linked Buchung (via betriebseinnahmeId)
INSERT INTO `buchungen` (id, companyId, date, account, type, amount, description,
kategorie, steuersatz, zahlungsart, isBusinessRecord, createdAt, updatedAt)
SELECT
CONCAT('migr-ein-', e.id),
e.companyId,
e.datum,
e.zahlungsart,
'EINLAGE',
e.betrag,
e.beschreibung,
e.kategorie,
e.steuersatz,
e.zahlungsart,
true,
e.createdAt,
e.updatedAt
FROM `betriebseinnahmen` e
WHERE NOT EXISTS (
SELECT 1 FROM `buchungen` b WHERE b.betriebseinnahmeId = e.id
);
-- STEP 2: Copy existing betriebsausgaben into buchungen
INSERT INTO `buchungen` (id, companyId, date, account, type, amount, description,
kategorie, steuersatz, zahlungsart, isBusinessRecord, createdAt, updatedAt)
SELECT
CONCAT('migr-aus-', a.id),
a.companyId,
a.datum,
a.zahlungsart,
'ENTNAHME',
a.betrag,
a.beschreibung,
a.kategorie,
a.steuersatz,
a.zahlungsart,
true,
a.createdAt,
a.updatedAt
FROM `betriebsausgaben` a
WHERE NOT EXISTS (
SELECT 1 FROM `buchungen` b WHERE b.betriebsausgabeId = a.id
);
-- STEP 3: Remove FK constraints before dropping columns/tables
ALTER TABLE `buchungen` DROP FOREIGN KEY `buchungen_betriebseinnahmeId_fkey`;
ALTER TABLE `buchungen` DROP FOREIGN KEY `buchungen_betriebsausgabeId_fkey`;
-- STEP 4: Remove old linking columns from buchungen
ALTER TABLE `buchungen` DROP INDEX `buchungen_betriebseinnahmeId_key`;
ALTER TABLE `buchungen` DROP INDEX `buchungen_betriebsausgabeId_key`;
ALTER TABLE `buchungen` DROP COLUMN `betriebseinnahmeId`;
ALTER TABLE `buchungen` DROP COLUMN `betriebsausgabeId`;
-- STEP 5: Drop old tables
DROP TABLE `betriebsausgaben`;
DROP TABLE `betriebseinnahmen`;
+83 -127
View File
@@ -45,41 +45,53 @@ model AuditLog {
}
model Company {
id String @id @default(cuid())
name String
legalForm String?
taxId String?
vatId String?
address String
zip String
city String
country String @default("DE")
email String?
phone String?
website String?
bankIban String?
bankBic String?
bankName String?
invoicePrefix String @default("RE")
invoiceSequence Int @default(0)
kleinunternehmer Boolean @default(false)
archived Boolean @default(false)
archivedAt DateTime?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
customers Customer[]
invoices Invoice[]
services Service[]
betriebsausgaben Betriebsausgabe[]
betriebseinnahmen Betriebseinnahme[]
anlagegueter Anlagegut[]
buchungen Buchung[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
name String
legalForm String?
taxId String?
vatId String?
address String
zip String
city String
country String @default("DE")
email String?
phone String?
website String?
bankIban String?
bankBic String?
bankName String?
invoicePrefix String @default("RE")
invoiceSequence Int @default(0)
kleinunternehmer Boolean @default(false)
archived Boolean @default(false)
archivedAt DateTime?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
customers Customer[]
invoices Invoice[]
services Service[]
anlagegueter Anlagegut[]
buchungen Buchung[]
kategorien BuchungKategorie[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("companies")
}
model BuchungKategorie {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
name String // e.g., "Fußpflege", "Miete", "Privateinlagen"
typ String // "EINNAHME" or "AUSGABE"
createdAt DateTime @default(now())
@@unique([companyId, name, typ])
@@index([companyId])
@@map("buchung_kategorien")
}
enum TransactionAccount {
KASSE
BANK
@@ -91,19 +103,28 @@ enum TransactionType {
}
model Buchung {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
date DateTime
account TransactionAccount
type TransactionType
amount Decimal @db.Decimal(10, 2)
description String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
date DateTime
account TransactionAccount
type TransactionType
amount Decimal @db.Decimal(10, 2)
description String? @db.Text
kategorie String? // Name of BuchungKategorie (nullable for manual transactions)
steuersatz Int? // Tax rate: 0, 7, 19 (nullable for non-business records)
zahlungsart Zahlungsart? // KASSE or BANK
isBusinessRecord Boolean @default(false) // True if this is from Einnahme/Ausgabe
linkedBuchungId String?
linkedBuchung Buchung? @relation("BuchungLink", fields: [linkedBuchungId], references: [id], onDelete: SetNull)
linkedFrom Buchung[] @relation("BuchungLink")
invoice Invoice? // Back-relation: Invoice -> Buchung (via buchungId)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([companyId])
@@index([date])
@@index([isBusinessRecord])
@@map("buchungen")
}
@@ -142,25 +163,27 @@ model Customer {
}
model Invoice {
id String @id @default(cuid())
number String?
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
customerId String
customer Customer @relation(fields: [customerId], references: [id])
issueDate DateTime
deliveryDate DateTime?
dueDate DateTime
status InvoiceStatus @default(DRAFT)
kleinunternehmer Boolean @default(false)
notes String? @db.Text
items InvoiceItem[]
netTotal Decimal @db.Decimal(10, 2)
taxTotal Decimal @db.Decimal(10, 2)
grossTotal Decimal @db.Decimal(10, 2)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
id String @id @default(cuid())
number String?
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
customerId String
customer Customer @relation(fields: [customerId], references: [id])
issueDate DateTime
deliveryDate DateTime?
dueDate DateTime
status InvoiceStatus @default(DRAFT)
kleinunternehmer Boolean @default(false)
notes String? @db.Text
items InvoiceItem[]
netTotal Decimal @db.Decimal(10, 2)
taxTotal Decimal @db.Decimal(10, 2)
grossTotal Decimal @db.Decimal(10, 2)
buchungId String? @unique // Link to auto-created Buchung when PAID
buchung Buchung? @relation(fields: [buchungId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@unique([companyId, number])
@@map("invoices")
@@ -179,36 +202,6 @@ enum Zahlungsart {
BANK
}
enum EinnahmeKategorie {
FUSSPFLEGE
PRIVATEINLAGEN
DARLEHEN
STEUERERSTATTUNGEN
VERSICHERUNGSERSTATTUNGEN
ZINSERTRAEGE
VERMIETUNG_VERPACHTUNG
VERAEUSSERUNGSERLOES
EIGENVERBRAUCH
SONSTIGE_EINNAHMEN
}
model Betriebseinnahme {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
kategorie EinnahmeKategorie
betrag Decimal @db.Decimal(10, 2)
steuersatz Decimal @db.Decimal(5, 2) @default(0)
zahlungsart Zahlungsart @default(BANK)
datum DateTime
beschreibung String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([companyId])
@@index([datum])
@@map("betriebseinnahmen")
}
model Anlagegut {
id String @id @default(cuid())
@@ -228,43 +221,6 @@ model Anlagegut {
@@map("anlagegueter")
}
enum AusgabeKategorie {
WAREN_ROHSTOFFE
GERINGWERTIGE_WIRTSCHAFTSGUETER
ABSCHREIBUNGEN
MIETE
STROM_WASSER
TELEKOMMUNIKATION
FORTBILDUNG_MESSEN
BEITRAEGE
VERSICHERUNGEN
WERBEKOSTEN
ZINSEN
REISEKOSTEN
REPARATUREN_INSTANDHALTUNG
BUEROBEDARF
REPRAESENTATIONSKOSTEN
SONSTIGER_BETRIEBSBEDARF
NEBENKOSTEN_GELDVERKEHR
}
model Betriebsausgabe {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
kategorie AusgabeKategorie
betrag Decimal @db.Decimal(10, 2)
steuersatz Decimal @db.Decimal(5, 2) @default(0)
zahlungsart Zahlungsart @default(BANK)
datum DateTime
beschreibung String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([companyId])
@@index([datum])
@@map("betriebsausgaben")
}
model InvoiceItem {
id String @id @default(cuid())
+53 -1
View File
@@ -136,8 +136,60 @@ async function main() {
data: { invoiceSequence: 1 },
});
// Seed BuchungKategorien for demo company
const einnahmeKategorien = [
"Fußpflege",
"Privateinlagen",
"Darlehen",
"Steuererstattungen",
"Versicherungserstattungen",
"Zinsertrage",
"Vermietung/Verpachtung",
"Veräußerungserlös",
"Eigenverbrauch",
"Sonstige Einnahmen",
];
const ausgabeKategorien = [
"Waren und Rohstoffe",
"Geringwertige Wirtschaftsgüter",
"Abschreibungen",
"Miete",
"Strom und Wasser",
"Telekommunikation",
"Fortbildung und Messen",
"Beiträge",
"Versicherungen",
"Werbekosten",
"Zinsen",
"Reisekosten",
"Reparaturen und Instandhaltung",
"Bürobedarf",
"Repräsentationskosten",
"Sonstiger Betriebsbedarf",
"Nebenkosten Geldverkehr",
];
for (const name of einnahmeKategorien) {
await prisma.buchungKategorie.upsert({
where: { companyId_name_typ: { companyId: company.id, name, typ: "EINNAHME" } },
update: {},
create: { companyId: company.id, name, typ: "EINNAHME" },
});
}
console.log(`✓ Seeded ${einnahmeKategorien.length} Einnahme categories`);
for (const name of ausgabeKategorien) {
await prisma.buchungKategorie.upsert({
where: { companyId_name_typ: { companyId: company.id, name, typ: "AUSGABE" } },
update: {},
create: { companyId: company.id, name, typ: "AUSGABE" },
});
}
console.log(`✓ Seeded ${ausgabeKategorien.length} Ausgabe categories`);
console.log("\n✅ Seed complete!");
console.log("Login: anna@example.de / demo123");
console.log("Login: anna@example.de / annas_password");
}
main()