Files
AnnasRechnungsManager/prisma/schema.prisma
T
hwinkel f10a79471e Refactor: centralize Zod schemas and fully integrate into API routes
Improvements #1-3 deepening:

1. Server-side invoice amount validation
   - All amounts (qty × unitPrice) recalculated server-side using tax.ts
   - Prevents client-side manipulation attacks
   - Supports kleinunternehmer auto-inheritance

2. Comprehensive audit logging
   - LogAction type extended with 11 new actions
   - All CRUD operations now logged with metadata
   - Metadata includes: amounts, counts, status transitions, oldStatus/newStatus

3. Advanced Zod validation (centralized)
   - New file: app/lib/schemas.ts (220 lines, 18+ validators)
   - Custom validators: currencySchema, taxRateSchema, ibanSchema, taxIdSchema, vatIdSchema
   - All API routes (invoices, companies, customers) now use centralized schemas
   - Consistent German error messages
   - Single source of truth for validation logic

Additional improvements:
- DB indices applied: invoices(status, dueDate, deletedAt, customerId), customers(companyId)
- Migration 20260415192953_add_indices applied successfully
- Build succeeds without critical errors
- TypeScript compilation validates all schemas

Files modified:
- app/lib/schemas.ts (NEW)
- app/routes/api.invoices.ts (uses centralized schemas)
- app/routes/api.invoices.$id.ts (status transition validation)
- app/routes/api.companies.ts, api.companies.$id.ts
- app/routes/api.customers.ts, api.customers.$id.ts
- app/lib/logger.server.ts (metadata support)
- prisma/schema.prisma (indices)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 21:34:38 +02:00

246 lines
7.1 KiB
Plaintext

generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
shadowDatabaseUrl = env("SHADOW_DATABASE_URL")
}
enum UserRole {
USER
ADMIN
}
model User {
id String @id @default(cuid())
email String @unique
username String @unique
passwordHash String
name String
role UserRole @default(USER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
companies Company[]
auditLogs AuditLog[]
@@map("users")
}
model AuditLog {
id String @id @default(cuid())
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
action String
entity String?
entityId String?
metadata Json?
ipAddress String?
createdAt DateTime @default(now())
@@index([userId])
@@index([createdAt])
@@map("audit_logs")
}
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[]
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
}
enum TransactionType {
EINLAGE
ENTNAHME
}
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
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")
}
model Service {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
name String
description String? @db.Text
unit String?
unitPrice Decimal @db.Decimal(10, 2)
taxRate Decimal @db.Decimal(5, 2)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("services")
}
model Customer {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
name String
taxId String?
address String
zip String
city String
country String @default("DE")
email String?
phone String?
invoices Invoice[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([companyId])
@@map("customers")
}
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)
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])
@@index([status])
@@index([dueDate])
@@index([customerId])
@@index([deletedAt])
@@map("invoices")
}
enum InvoiceStatus {
DRAFT
SENT
PAID
CANCELLED
DELETED
}
enum Zahlungsart {
KASSE
BANK
}
model Anlagegut {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
bezeichnung String
anschaffungsdatum DateTime
anschaffungskosten Decimal @db.Decimal(10, 2)
nutzungsdauerJahre Int
restwert Decimal @db.Decimal(10, 2) @default(0)
beschreibung String? @db.Text
aktiv Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([companyId])
@@map("anlagegueter")
}
model InvoiceItem {
id String @id @default(cuid())
invoiceId String
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
position Int
description String @db.Text
quantity Decimal @db.Decimal(10, 3)
unit String?
unitPrice Decimal @db.Decimal(10, 2)
taxRate Decimal @db.Decimal(5, 2)
netAmount Decimal @db.Decimal(10, 2)
taxAmount Decimal @db.Decimal(10, 2)
grossAmount Decimal @db.Decimal(10, 2)
@@map("invoice_items")
}