From 1ec15600b55e7e0c642b064772fc4bcb51bb83b5 Mon Sep 17 00:00:00 2001 From: hwinkel Date: Tue, 24 Mar 2026 21:06:07 +0100 Subject: [PATCH] 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. --- app/routes/api.ausgaben.$id.ts | 25 +- app/routes/api.ausgaben.ts | 32 +- app/routes/api.bilanzen.ts | 36 +- app/routes/api.companies.$id.kategorien.ts | 101 +++++ app/routes/api.companies.$id.money.ts | 144 ++++++- app/routes/api.einnahmen.$id.ts | 33 +- app/routes/api.einnahmen.ts | 42 +- app/routes/api.invoices.$id.ts | 36 ++ app/routes/companies.$id.ausgaben.tsx | 20 +- app/routes/companies.$id.einnahmen.tsx | 20 +- app/routes/companies.$id.money.tsx | 404 +++++++++++++----- package-lock.json | 1 - .../migration.sql | 17 + .../migration.sql | 9 + .../migration.sql | 41 ++ .../migration.sql | 61 +++ prisma/schema.prisma | 210 ++++----- prisma/seed.ts | 54 ++- 18 files changed, 928 insertions(+), 358 deletions(-) create mode 100644 app/routes/api.companies.$id.kategorien.ts create mode 100644 prisma/migrations/20260324195649_add_buchung_source_links/migration.sql create mode 100644 prisma/migrations/20260324_add_invoice_buchung_link/migration.sql create mode 100644 prisma/migrations/20260324_consolidate_transactions/migration.sql create mode 100644 prisma/migrations/20260324_drop_legacy_einnahmen_ausgaben/migration.sql diff --git a/app/routes/api.ausgaben.$id.ts b/app/routes/api.ausgaben.$id.ts index 957bd34..b2596df 100644 --- a/app/routes/api.ausgaben.$id.ts +++ b/app/routes/api.ausgaben.$id.ts @@ -1,10 +1,9 @@ import { getApiUser } from "@/session.server"; import prisma from "@/lib/prisma.server"; import { z } from "zod"; -import { AusgabeKategorie } from "@prisma/client"; const updateSchema = z.object({ - kategorie: z.nativeEnum(AusgabeKategorie), + kategorie: z.string().min(1), betrag: z.number().positive(), steuersatz: z.number().min(0).default(0), zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"), @@ -16,13 +15,13 @@ export async function action({ request, params }: { request: Request; params: { const user = await getApiUser(request); if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 }); - const ausgabe = await prisma.betriebsausgabe.findFirst({ - where: { id: params.id, company: { userId: user.id } }, + const buchung = await prisma.buchung.findFirst({ + where: { id: params.id, company: { userId: user.id }, type: "ENTNAHME", isBusinessRecord: true }, }); - if (!ausgabe) return Response.json({ error: "Not found" }, { status: 404 }); + if (!buchung) return Response.json({ error: "Not found" }, { status: 404 }); if (request.method === "DELETE") { - await prisma.betriebsausgabe.delete({ where: { id: params.id } }); + await prisma.buchung.delete({ where: { id: params.id } }); return Response.json({ ok: true }); } @@ -30,22 +29,22 @@ export async function action({ request, params }: { request: Request; params: { const parsed = updateSchema.safeParse(body); if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 }); - const updated = await prisma.betriebsausgabe.update({ + const updated = await prisma.buchung.update({ where: { id: params.id }, data: { kategorie: parsed.data.kategorie, - betrag: parsed.data.betrag, + amount: parsed.data.betrag, steuersatz: parsed.data.steuersatz, zahlungsart: parsed.data.zahlungsart, - datum: new Date(parsed.data.datum), - beschreibung: parsed.data.beschreibung, + account: parsed.data.zahlungsart === "KASSE" ? "KASSE" : "BANK", + date: new Date(parsed.data.datum), + description: parsed.data.beschreibung, }, }); return Response.json({ ...updated, - betrag: Number(updated.betrag), - steuersatz: Number(updated.steuersatz), - datum: updated.datum.toISOString(), + amount: Number(updated.amount), + date: updated.date.toISOString(), }); } diff --git a/app/routes/api.ausgaben.ts b/app/routes/api.ausgaben.ts index 1523574..03d7bde 100644 --- a/app/routes/api.ausgaben.ts +++ b/app/routes/api.ausgaben.ts @@ -1,11 +1,10 @@ import { getApiUser } from "@/session.server"; import prisma from "@/lib/prisma.server"; import { z } from "zod"; -import { AusgabeKategorie } from "@prisma/client"; const createSchema = z.object({ companyId: z.string().min(1), - kategorie: z.nativeEnum(AusgabeKategorie), + kategorie: z.string().min(1), betrag: z.number().positive(), steuersatz: z.number().min(0).default(0), zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"), @@ -26,25 +25,26 @@ export async function loader({ request }: { request: Request }) { const company = await prisma.company.findFirst({ where: { id: companyId, userId: user.id } }); if (!company) return Response.json({ error: "Not found" }, { status: 404 }); - const ausgaben = await prisma.betriebsausgabe.findMany({ + const ausgaben = await prisma.buchung.findMany({ where: { companyId, + type: "ENTNAHME", + isBusinessRecord: true, ...(year ? { - datum: { + date: { gte: new Date(`${year}-01-01`), lt: new Date(`${year + 1}-01-01`), }, } : {}), }, - orderBy: { datum: "desc" }, + orderBy: { date: "desc" }, }); return Response.json( ausgaben.map((a) => ({ ...a, - betrag: Number(a.betrag), - steuersatz: Number(a.steuersatz), - datum: a.datum.toISOString(), + amount: Number(a.amount), + date: a.date.toISOString(), })) ); } @@ -62,22 +62,24 @@ export async function action({ request }: { request: Request }) { }); if (!company) return Response.json({ error: "Company not found" }, { status: 404 }); - const ausgabe = await prisma.betriebsausgabe.create({ + const ausgabe = await prisma.buchung.create({ data: { companyId: parsed.data.companyId, + account: parsed.data.zahlungsart === "KASSE" ? "KASSE" : "BANK", + type: "ENTNAHME", + amount: parsed.data.betrag, + date: new Date(parsed.data.datum), + description: parsed.data.beschreibung, kategorie: parsed.data.kategorie, - betrag: parsed.data.betrag, steuersatz: parsed.data.steuersatz, zahlungsart: parsed.data.zahlungsart, - datum: new Date(parsed.data.datum), - beschreibung: parsed.data.beschreibung, + isBusinessRecord: true, }, }); return Response.json({ ...ausgabe, - betrag: Number(ausgabe.betrag), - steuersatz: Number(ausgabe.steuersatz), - datum: ausgabe.datum.toISOString(), + amount: Number(ausgabe.amount), + date: ausgabe.date.toISOString(), }, { status: 201 }); } diff --git a/app/routes/api.bilanzen.ts b/app/routes/api.bilanzen.ts index 60980bb..aa2c9c3 100644 --- a/app/routes/api.bilanzen.ts +++ b/app/routes/api.bilanzen.ts @@ -63,42 +63,42 @@ export async function loader({ request }: { request: Request }) { const bank = Number(bankAgg._sum.grossTotal ?? 0); const summeAktiva = forderungen + bank; - // Betriebsausgaben für das Jahr - const ausgaben = await prisma.betriebsausgabe.findMany({ - where: { companyId, datum: { gte: yearStart, lt: yearEnd } }, + // Betriebsausgaben für das Jahr (from buchungen with type=ENTNAHME and isBusinessRecord=true) + const ausgaben = await prisma.buchung.findMany({ + where: { companyId, type: "ENTNAHME", isBusinessRecord: true, date: { gte: yearStart, lt: yearEnd } }, }); - const ausgabenGesamt = ausgaben.reduce((s, a) => s + Number(a.betrag), 0); + const ausgabenGesamt = ausgaben.reduce((s, a) => s + Number(a.amount), 0); const ausgabenVorsteuer = ausgaben.reduce((s, a) => { - const brutto = Number(a.betrag); - const rate = Number(a.steuersatz) / 100; + const brutto = Number(a.amount); + const rate = (a.steuersatz || 0) / 100; return s + (rate > 0 ? Math.round((brutto / (1 + rate)) * rate * 100) / 100 : 0); }, 0); // Ausgaben nach Kategorie const ausgabenByKategorieMap: Record = {}; for (const a of ausgaben) { - const k = a.kategorie; - ausgabenByKategorieMap[k] = (ausgabenByKategorieMap[k] ?? 0) + Number(a.betrag); + const k = a.kategorie || "Sonstige"; + ausgabenByKategorieMap[k] = (ausgabenByKategorieMap[k] ?? 0) + Number(a.amount); } const ausgabenByKategorie = Object.entries(ausgabenByKategorieMap).map(([kategorie, betrag]) => ({ kategorie, betrag })); - // Sonstige Einnahmen für das Jahr - const einnahmen = await prisma.betriebseinnahme.findMany({ - where: { companyId, datum: { gte: yearStart, lt: yearEnd } }, + // Sonstige Einnahmen für das Jahr (from buchungen with type=EINLAGE and isBusinessRecord=true) + const einnahmen = await prisma.buchung.findMany({ + where: { companyId, type: "EINLAGE", isBusinessRecord: true, date: { gte: yearStart, lt: yearEnd } }, }); - const sonstigeEinnahmen = einnahmen.reduce((s, e) => s + Number(e.betrag), 0); + const sonstigeEinnahmen = einnahmen.reduce((s, e) => s + Number(e.amount), 0); const einnahmenUst = einnahmen.reduce((s, e) => { - const brutto = Number(e.betrag); - const rate = Number(e.steuersatz) / 100; + const brutto = Number(e.amount); + const rate = (e.steuersatz || 0) / 100; return s + (rate > 0 ? Math.round((brutto / (1 + rate)) * rate * 100) / 100 : 0); }, 0); // Kasse / Bank aus sonstigen Einnahmen und Ausgaben (nach Zahlungsart) - const einnahmenKasse = einnahmen.filter((e) => e.zahlungsart === "KASSE").reduce((s, e) => s + Number(e.betrag), 0); - const ausgabenKasse = ausgaben.filter((a) => a.zahlungsart === "KASSE").reduce((s, a) => s + Number(a.betrag), 0); - const einnahmenBank = einnahmen.filter((e) => e.zahlungsart === "BANK").reduce((s, e) => s + Number(e.betrag), 0); - const ausgabenBank = ausgaben.filter((a) => a.zahlungsart === "BANK").reduce((s, a) => s + Number(a.betrag), 0); + const einnahmenKasse = einnahmen.filter((e) => e.zahlungsart === "KASSE").reduce((s, e) => s + Number(e.amount), 0); + const ausgabenKasse = ausgaben.filter((a) => a.zahlungsart === "KASSE").reduce((s, a) => s + Number(a.amount), 0); + const einnahmenBank = einnahmen.filter((e) => e.zahlungsart === "BANK").reduce((s, e) => s + Number(e.amount), 0); + const ausgabenBank = ausgaben.filter((a) => a.zahlungsart === "BANK").reduce((s, a) => s + Number(a.amount), 0); // Kasse-Saldo = bezahlte Rechnungen (Kasse-Anteil wird nicht getrennt) + sonstige Einnahmen Kasse - Ausgaben Kasse // Bank-Näherung = bezahlte Rechnungen + sonstige Einnahmen Bank - Ausgaben Bank diff --git a/app/routes/api.companies.$id.kategorien.ts b/app/routes/api.companies.$id.kategorien.ts new file mode 100644 index 0000000..8975574 --- /dev/null +++ b/app/routes/api.companies.$id.kategorien.ts @@ -0,0 +1,101 @@ +import { getApiUser } from "@/session.server"; +import prisma from "@/lib/prisma.server"; +import { z } from "zod"; + +const createSchema = z.object({ + name: z.string().min(1), + typ: z.enum(["EINNAHME", "AUSGABE"]), +}); + +/** + * Loader: GET all categories for a company + * + * Query params: + * - typ (optional): "EINNAHME" or "AUSGABE" to filter by type + * + * Returns a JSON array of BuchungKategorie records. + */ +export async function loader({ request, params }: { request: Request; params: { id: string } }) { + const user = await getApiUser(request); + if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 }); + + const company = await prisma.company.findFirst({ + where: { id: params.id, userId: user.id }, + }); + if (!company) return Response.json({ error: "Company not found" }, { status: 404 }); + + const { searchParams } = new URL(request.url); + const typ = searchParams.get("typ"); + + const kategorien = await prisma.buchungKategorie.findMany({ + where: { + companyId: params.id, + ...(typ ? { typ } : {}), + }, + orderBy: { name: "asc" }, + }); + + return Response.json(kategorien); +} + +/** + * Action: POST to create a new category, DELETE to remove one + * + * POST body: + * { name: string, typ: "EINNAHME" | "AUSGABE" } + * + * DELETE query param: + * - kategorieId: the id of the category to delete + */ +export async function action({ request, params }: { request: Request; params: { id: string } }) { + const user = await getApiUser(request); + if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 }); + + const company = await prisma.company.findFirst({ + where: { id: params.id, userId: user.id }, + }); + if (!company) return Response.json({ error: "Company not found" }, { status: 404 }); + + if (request.method === "DELETE") { + const { searchParams } = new URL(request.url); + const kategorieId = searchParams.get("kategorieId"); + + if (!kategorieId) { + return Response.json({ error: "kategorieId required" }, { status: 400 }); + } + + // Check that kategorie belongs to this company + const kategorie = await prisma.buchungKategorie.findFirst({ + where: { id: kategorieId, companyId: params.id }, + }); + if (!kategorie) return Response.json({ error: "Category not found" }, { status: 404 }); + + // Check if kategorie is in use + const inUse = await prisma.buchung.findFirst({ + where: { kategorie: kategorie.name, companyId: params.id }, + }); + if (inUse) { + return Response.json( + { error: "Category is in use and cannot be deleted" }, + { status: 409 } + ); + } + + await prisma.buchungKategorie.delete({ where: { id: kategorieId } }); + return Response.json({ ok: true }); + } + + const body = await request.json(); + const parsed = createSchema.safeParse(body); + if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 }); + + const kategorie = await prisma.buchungKategorie.create({ + data: { + companyId: params.id, + name: parsed.data.name, + typ: parsed.data.typ, + }, + }); + + return Response.json(kategorie, { status: 201 }); +} diff --git a/app/routes/api.companies.$id.money.ts b/app/routes/api.companies.$id.money.ts index eed2fa8..3e18175 100644 --- a/app/routes/api.companies.$id.money.ts +++ b/app/routes/api.companies.$id.money.ts @@ -8,9 +8,11 @@ type Transaction = { type: "einlage" | "entnahme"; amount: number; description: string; + isBusinessRecord: boolean; + kategorie: string | null; }; -function toTransaction(buchung: { id: string; date: Date; account: "KASSE" | "BANK"; type: "EINLAGE" | "ENTNAHME"; amount: { toString(): string } | number | string; description: string | null | undefined; }): Transaction { +function toTransaction(buchung: { id: string; date: Date; account: "KASSE" | "BANK"; type: "EINLAGE" | "ENTNAHME"; amount: { toString(): string } | number | string; description: string | null | undefined; isBusinessRecord: boolean; kategorie: string | null | undefined }): Transaction { return { id: buchung.id, date: buchung.date.toISOString().split("T")[0], @@ -18,6 +20,8 @@ function toTransaction(buchung: { id: string; date: Date; account: "KASSE" | "BA type: buchung.type === "EINLAGE" ? "einlage" : "entnahme", amount: Number(buchung.amount), description: buchung.description || "", + isBusinessRecord: buchung.isBusinessRecord, + kategorie: buchung.kategorie || null, }; } @@ -32,10 +36,21 @@ export async function loader({ request, params }: { request: Request; params: { if (!company) return Response.json({ error: "Company not found" }, { status: 404 }); - const buchungen = await prisma.buchung.findMany({ + const buchungen = (await prisma.buchung.findMany({ where: { companyId: id }, orderBy: { date: "desc" }, - }); + select: { + id: true, + date: true, + account: true, + type: true, + amount: true, + description: true, + isBusinessRecord: true, + kategorie: true, + }, + })) as unknown as Array<{ id: string; date: Date; account: "KASSE" | "BANK"; type: "EINLAGE" | "ENTNAHME"; amount: any; description: string | null; isBusinessRecord: boolean; kategorie: string | null }>; + const transactions = buchungen.map(toTransaction); const balance = transactions.reduce((sum: number, t: Transaction) => sum + (t.type === "einlage" ? t.amount : -t.amount), 0 as number); @@ -60,20 +75,58 @@ export async function action({ request, params }: { request: Request; params: { if (method === "POST") { const amount = Number(data.amount); - if (!data.date || !data.account || !data.type || Number.isNaN(amount) || amount <= 0) { + if (!data.date || !data.account || Number.isNaN(amount) || amount <= 0) { return Response.json({ error: "Ungültige Daten" }, { status: 400 }); } - await prisma.buchung.create({ - data: { - companyId: id, - date: new Date(data.date), - account: data.account === "bank" ? "BANK" : "KASSE", - type: data.type === "entnahme" ? "ENTNAHME" : "EINLAGE", - amount: amount, - description: data.description || "", - }, - }); + // Check if this is an Umbuchung (transfer between accounts) + if (data.type === "umbuchung") { + if (!data.toAccount) { + return Response.json({ error: "toAccount erforderlich für Umbuchung" }, { status: 400 }); + } + + await prisma.$transaction(async (tx) => { + // ENTNAHME from source account + const entnahme = await tx.buchung.create({ + data: { + companyId: id, + date: new Date(data.date), + account: data.account === "bank" ? "BANK" : "KASSE", + type: "ENTNAHME", + amount: amount, + description: data.description || "", + }, + }); + + // EINLAGE to target account, linked to the entnahme + await tx.buchung.create({ + data: { + companyId: id, + date: new Date(data.date), + account: data.toAccount === "bank" ? "BANK" : "KASSE", + type: "EINLAGE", + amount: amount, + description: data.description || "", + linkedBuchungId: entnahme.id, + }, + }); + }); + } else { + if (!data.type) { + return Response.json({ error: "type erforderlich" }, { status: 400 }); + } + + await prisma.buchung.create({ + data: { + companyId: id, + date: new Date(data.date), + account: data.account === "bank" ? "BANK" : "KASSE", + type: data.type === "entnahme" ? "ENTNAHME" : "EINLAGE", + amount: amount, + description: data.description || "", + }, + }); + } } else if (method === "PUT") { if (!transactionId) return Response.json({ error: "transactionId required" }, { status: 400 }); const amount = Number(data.amount); @@ -81,9 +134,28 @@ export async function action({ request, params }: { request: Request; params: { return Response.json({ error: "Ungültige Daten" }, { status: 400 }); } - const exist = await prisma.buchung.findFirst({ where: { id: transactionId, companyId: id } }); + const exist = (await prisma.buchung.findFirst({ + where: { id: transactionId, companyId: id }, + select: { + id: true, + date: true, + account: true, + type: true, + amount: true, + description: true, + isBusinessRecord: true, + }, + })) as unknown as { id: string; date: Date; account: "KASSE" | "BANK"; type: "EINLAGE" | "ENTNAHME"; amount: any; description: string | null; isBusinessRecord: boolean } | null; if (!exist) return Response.json({ error: "Transaction not found" }, { status: 404 }); + // Block edit if this is an auto-created Buchung (from Einnahme/Ausgabe) + if (exist.isBusinessRecord) { + return Response.json( + { error: "Automatisch erstellte Transaktionen können nicht direkt bearbeitet werden" }, + { status: 400 } + ); + } + await prisma.buchung.update({ where: { id: transactionId }, data: { @@ -97,12 +169,50 @@ export async function action({ request, params }: { request: Request; params: { } else if (method === "DELETE") { if (!transactionId) return Response.json({ error: "transactionId required" }, { status: 400 }); - await prisma.buchung.deleteMany({ where: { id: transactionId, companyId: id } }); + const exist = await prisma.buchung.findFirst({ where: { id: transactionId, companyId: id } }); + if (!exist) return Response.json({ error: "Transaction not found" }, { status: 404 }); + + // For Umbuchung (linked transactions), delete both + const linkedId = (exist as any).linkedBuchungId; + const isLinkedFrom = await prisma.buchung.findFirst({ + where: { linkedBuchungId: transactionId } as any, + }); + + if (linkedId || isLinkedFrom) { + await prisma.$transaction(async (tx) => { + // If this is the ENTNAHME, delete linked EINLAGE + if (linkedId) { + await tx.buchung.deleteMany({ where: { id: linkedId } }); + } + // If this is the EINLAGE, delete linked ENTNAHME + if (isLinkedFrom) { + await tx.buchung.deleteMany({ where: { id: isLinkedFrom.id } }); + } + // Delete this entry + await tx.buchung.deleteMany({ where: { id: transactionId, companyId: id } }); + }); + } else { + // Regular transaction + await prisma.buchung.deleteMany({ where: { id: transactionId, companyId: id } }); + } } else { return Response.json({ error: "Method not allowed" }, { status: 405 }); } - const buchungen = await prisma.buchung.findMany({ where: { companyId: id }, orderBy: { date: "desc" } }); + const buchungen = await prisma.buchung.findMany({ + where: { companyId: id }, + orderBy: { date: "desc" }, + select: { + id: true, + date: true, + account: true, + type: true, + amount: true, + description: true, + isBusinessRecord: true, + kategorie: true, + }, + }); const transactions = buchungen.map(toTransaction); const balance = transactions.reduce((sum: number, t: Transaction) => sum + (t.type === "einlage" ? t.amount : -t.amount), 0 as number); diff --git a/app/routes/api.einnahmen.$id.ts b/app/routes/api.einnahmen.$id.ts index e324c59..05d03d0 100644 --- a/app/routes/api.einnahmen.$id.ts +++ b/app/routes/api.einnahmen.$id.ts @@ -1,10 +1,9 @@ import { getApiUser } from "@/session.server"; import prisma from "@/lib/prisma.server"; import { z } from "zod"; -import { EinnahmeKategorie } from "@prisma/client"; const updateSchema = z.object({ - kategorie: z.nativeEnum(EinnahmeKategorie), + kategorie: z.string().min(1), betrag: z.number().positive(), steuersatz: z.number().min(0).default(0), zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"), @@ -13,29 +12,25 @@ const updateSchema = z.object({ }); /** - * Handles an API request to create, update or delete a einnahme. + * Handles an API request to update or delete a einnahme (Buchung). * * @param {Request} request - The request object. * @param {Object} params - The route parameters. - * @param {string} params.id - The id of the einnahme to update or delete. + * @param {string} params.id - The id of the Buchung (einnahme) to update or delete. * * @returns {Promise} - A promise resolving to a Response object. - * - * @throws {Response} - If the request is unauthorized, returns a 401 response with an error message. - * @throws {Response} - If the einnahme is not found, returns a 404 response with an error message. - * @throws {Response} - If the request body is invalid, returns a 400 response with an error message containing the validation errors. */ export async function action({ request, params }: { request: Request; params: { id: string } }) { const user = await getApiUser(request); if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 }); - const einnahme = await prisma.betriebseinnahme.findFirst({ - where: { id: params.id, company: { userId: user.id } }, + const buchung = await prisma.buchung.findFirst({ + where: { id: params.id, company: { userId: user.id }, type: "EINLAGE", isBusinessRecord: true }, }); - if (!einnahme) return Response.json({ error: "Not found" }, { status: 404 }); + if (!buchung) return Response.json({ error: "Not found" }, { status: 404 }); if (request.method === "DELETE") { - await prisma.betriebseinnahme.delete({ where: { id: params.id } }); + await prisma.buchung.delete({ where: { id: params.id } }); return Response.json({ ok: true }); } @@ -43,22 +38,22 @@ export async function action({ request, params }: { request: Request; params: { const parsed = updateSchema.safeParse(body); if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 }); - const updated = await prisma.betriebseinnahme.update({ + const updated = await prisma.buchung.update({ where: { id: params.id }, data: { kategorie: parsed.data.kategorie, - betrag: parsed.data.betrag, + amount: parsed.data.betrag, steuersatz: parsed.data.steuersatz, zahlungsart: parsed.data.zahlungsart, - datum: new Date(parsed.data.datum), - beschreibung: parsed.data.beschreibung, + account: parsed.data.zahlungsart === "KASSE" ? "KASSE" : "BANK", + date: new Date(parsed.data.datum), + description: parsed.data.beschreibung, }, }); return Response.json({ ...updated, - betrag: Number(updated.betrag), - steuersatz: Number(updated.steuersatz), - datum: updated.datum.toISOString(), + amount: Number(updated.amount), + date: updated.date.toISOString(), }); } diff --git a/app/routes/api.einnahmen.ts b/app/routes/api.einnahmen.ts index 87a1a56..4d5cddf 100644 --- a/app/routes/api.einnahmen.ts +++ b/app/routes/api.einnahmen.ts @@ -1,11 +1,10 @@ import { getApiUser } from "@/session.server"; import prisma from "@/lib/prisma.server"; import { z } from "zod"; -import { EinnahmeKategorie } from "@prisma/client"; const createSchema = z.object({ companyId: z.string().min(1), - kategorie: z.nativeEnum(EinnahmeKategorie), + kategorie: z.string().min(1), betrag: z.number().positive(), steuersatz: z.number().min(0).default(0), zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"), @@ -18,7 +17,7 @@ const createSchema = z.object({ * * Requires a companyId search parameter. If year is provided, filters einnahmen for the given year. * - * Returns a list of einnahmen as a JSON object. + * Returns a list of einnahmen (Buchungen with isBusinessRecord=true, type=EINLAGE) as a JSON object. * * If the request is unauthorized, returns a 401 response with an error message. * If the request body is invalid, returns a 400 response with an error message containing the validation errors. @@ -37,42 +36,45 @@ export async function loader({ request }: { request: Request }) { const company = await prisma.company.findFirst({ where: { id: companyId, userId: user.id } }); if (!company) return Response.json({ error: "Not found" }, { status: 404 }); - const einnahmen = await prisma.betriebseinnahme.findMany({ + const einnahmen = await prisma.buchung.findMany({ where: { companyId, + type: "EINLAGE", + isBusinessRecord: true, ...(year ? { - datum: { + date: { gte: new Date(`${year}-01-01`), lt: new Date(`${year + 1}-01-01`), }, } : {}), }, - orderBy: { datum: "asc" }, + orderBy: { date: "asc" }, }); return Response.json( einnahmen.map((e) => ({ ...e, - betrag: Number(e.betrag), - steuersatz: Number(e.steuersatz), - datum: e.datum.toISOString(), + amount: Number(e.amount), + date: e.date.toISOString(), })) ); } /** - * Creates a new einnahme for a given company. + * Creates a new einnahme (Buchung) for a given company. * * Requires a JSON object in the request body with the following shape: * { * companyId: string, - * kategorie: EinnahmeKategorie, + * kategorie: string (BuchungKategorie name), * betrag: number, + * steuersatz: number, + * zahlungsart: "KASSE" | "BANK", * datum: string, * beschreibung: string, * } * - * Returns the created einnahme as a JSON object. + * Returns the created Buchung as a JSON object. * * If the request is unauthorized, returns a 401 response with an error message. * If the request body is invalid, returns a 400 response with an error message containing the validation errors. @@ -91,24 +93,26 @@ export async function action({ request }: { request: Request }) { }); if (!company) return Response.json({ error: "Company not found" }, { status: 404 }); - const einnahme = await prisma.betriebseinnahme.create({ + const einnahme = await prisma.buchung.create({ data: { companyId: parsed.data.companyId, + account: parsed.data.zahlungsart === "KASSE" ? "KASSE" : "BANK", + type: "EINLAGE", + amount: parsed.data.betrag, + date: new Date(parsed.data.datum), + description: parsed.data.beschreibung, kategorie: parsed.data.kategorie, - betrag: parsed.data.betrag, steuersatz: parsed.data.steuersatz, zahlungsart: parsed.data.zahlungsart, - datum: new Date(parsed.data.datum), - beschreibung: parsed.data.beschreibung, + isBusinessRecord: true, }, }); return Response.json( { ...einnahme, - betrag: Number(einnahme.betrag), - steuersatz: Number(einnahme.steuersatz), - datum: einnahme.datum.toISOString(), + amount: Number(einnahme.amount), + date: einnahme.date.toISOString(), }, { status: 201 } ); diff --git a/app/routes/api.invoices.$id.ts b/app/routes/api.invoices.$id.ts index 919ff5b..09c1302 100644 --- a/app/routes/api.invoices.$id.ts +++ b/app/routes/api.invoices.$id.ts @@ -103,6 +103,7 @@ export async function action({ request, params }: { request: Request; params: { if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 }); const newStatus = parsed.data.status; + const oldStatus = invoice.status; let numberUpdate: string | null | undefined = undefined; if (newStatus === "DELETED") { @@ -111,10 +112,45 @@ export async function action({ request, params }: { request: Request; params: { numberUpdate = await generateInvoiceNumber(invoice.companyId); } + // Handle Buchung sync: Create when PAID, delete when unpaying + if (newStatus === "PAID" && oldStatus !== "PAID") { + // Create a Buchung for the invoice payment + const buchung = await prisma.buchung.create({ + data: { + companyId: invoice.companyId, + date: invoice.issueDate, + account: "BANK", + type: "EINLAGE", + amount: invoice.grossTotal, + description: `Rechnung ${invoice.number}`, + kategorie: "Rechnungseinnahme", + isBusinessRecord: true, + }, + }); + + // Update invoice with buchungId + const updated = await prisma.invoice.update({ + where: { id: params.id }, + data: { + status: newStatus, + buchungId: buchung.id, + deletedAt: null, + ...(numberUpdate !== undefined && { number: numberUpdate }), + }, + }); + return Response.json(updated); + } + + if (newStatus !== "PAID" && oldStatus === "PAID" && invoice.buchungId) { + // Delete the linked Buchung when unpaying + await prisma.buchung.delete({ where: { id: invoice.buchungId } }); + } + const updated = await prisma.invoice.update({ where: { id: params.id }, data: { status: newStatus, + buchungId: newStatus === "PAID" ? invoice.buchungId : null, deletedAt: newStatus === "DELETED" ? new Date() : null, ...(numberUpdate !== undefined && { number: numberUpdate }), }, diff --git a/app/routes/companies.$id.ausgaben.tsx b/app/routes/companies.$id.ausgaben.tsx index e8741f6..17fe522 100644 --- a/app/routes/companies.$id.ausgaben.tsx +++ b/app/routes/companies.$id.ausgaben.tsx @@ -52,12 +52,14 @@ export async function loader({ request, params }: { request: Request; params: { if (!company) throw new Response("Not Found", { status: 404 }); const year = new Date().getFullYear(); - const ausgaben = await prisma.betriebsausgabe.findMany({ + const ausgaben = await prisma.buchung.findMany({ where: { companyId: params.id, - datum: { gte: new Date(`${year}-01-01`), lt: new Date(`${year + 1}-01-01`) }, + type: "ENTNAHME", + isBusinessRecord: true, + date: { gte: new Date(`${year}-01-01`), lt: new Date(`${year + 1}-01-01`) }, }, - orderBy: { datum: "desc" }, + orderBy: { date: "desc" }, }); return { @@ -66,12 +68,12 @@ export async function loader({ request, params }: { request: Request; params: { initialYear: year, ausgaben: ausgaben.map((a) => ({ id: a.id, - kategorie: a.kategorie as AusgabeKategorieKey, - betrag: Number(a.betrag), - steuersatz: Number(a.steuersatz), - zahlungsart: a.zahlungsart as "KASSE" | "BANK", - datum: a.datum.toISOString(), - beschreibung: a.beschreibung, + kategorie: (a.kategorie || "SONSTIGER_BETRIEBSBEDARF") as AusgabeKategorieKey, + betrag: Number(a.amount), + steuersatz: a.steuersatz || 19, + zahlungsart: (a.zahlungsart as "KASSE" | "BANK") || "BANK", + datum: a.date.toISOString(), + beschreibung: a.description, })), }; } diff --git a/app/routes/companies.$id.einnahmen.tsx b/app/routes/companies.$id.einnahmen.tsx index 77c1184..285ae15 100644 --- a/app/routes/companies.$id.einnahmen.tsx +++ b/app/routes/companies.$id.einnahmen.tsx @@ -52,12 +52,14 @@ export async function loader({ request, params }: { request: Request; params: { if (!company) throw new Response("Not Found", { status: 404 }); const year = new Date().getFullYear(); - const einnahmen = await prisma.betriebseinnahme.findMany({ + const einnahmen = await prisma.buchung.findMany({ where: { companyId: params.id, - datum: { gte: new Date(`${year}-01-01`), lt: new Date(`${year + 1}-01-01`) }, + type: "EINLAGE", + isBusinessRecord: true, + date: { gte: new Date(`${year}-01-01`), lt: new Date(`${year + 1}-01-01`) }, }, - orderBy: { datum: "desc" }, + orderBy: { date: "desc" }, }); return { @@ -66,12 +68,12 @@ export async function loader({ request, params }: { request: Request; params: { initialYear: year, einnahmen: einnahmen.map((e) => ({ id: e.id, - kategorie: e.kategorie as EinnahmeKategorieKey, - betrag: Number(e.betrag), - steuersatz: Number(e.steuersatz), - zahlungsart: e.zahlungsart as "KASSE" | "BANK", - datum: e.datum.toISOString(), - beschreibung: e.beschreibung, + kategorie: (e.kategorie || "SONSTIGE_EINNAHMEN") as EinnahmeKategorieKey, + betrag: Number(e.amount), + steuersatz: e.steuersatz || 0, + zahlungsart: (e.zahlungsart as "KASSE" | "BANK") || "BANK", + datum: e.date.toISOString(), + beschreibung: e.description, })), }; } diff --git a/app/routes/companies.$id.money.tsx b/app/routes/companies.$id.money.tsx index 781561a..6d91a2f 100644 --- a/app/routes/companies.$id.money.tsx +++ b/app/routes/companies.$id.money.tsx @@ -16,6 +16,8 @@ type Transaction = { type: 'einlage' | 'entnahme'; amount: number; description: string; + isBusinessRecord: boolean; + kategorie: string | null; }; export async function loader({ request, params }: { request: Request; params: { id: string } }) { @@ -29,6 +31,16 @@ export async function loader({ request, params }: { request: Request; params: { const buchungen = await prisma.buchung.findMany({ where: { companyId: company.id }, orderBy: { date: 'desc' }, + select: { + id: true, + date: true, + account: true, + type: true, + amount: true, + description: true, + isBusinessRecord: true, + kategorie: true, + }, }); const transactions: Transaction[] = buchungen.map((b): Transaction => ({ @@ -38,6 +50,8 @@ export async function loader({ request, params }: { request: Request; params: { type: b.type === 'EINLAGE' ? 'einlage' : 'entnahme', amount: Number(b.amount), description: b.description || '', + isBusinessRecord: b.isBusinessRecord, + kategorie: b.kategorie || null, })); const balance = transactions.reduce((sum: number, t: Transaction) => sum + (t.type === 'einlage' ? t.amount : -t.amount), 0); @@ -59,22 +73,40 @@ export default function CompanyMoney() { const [dialogOpen, setDialogOpen] = useState(false); const [editingTransaction, setEditingTransaction] = useState(null); + const [isUmbuchung, setIsUmbuchung] = useState(false); const [form, setForm] = useState({ date: new Date().toISOString().split('T')[0], account: 'kasse' as 'kasse' | 'bank', type: 'einlage' as 'einlage' | 'entnahme', amount: '', description: '', + toAccount: 'bank' as 'kasse' | 'bank', }); function openCreate() { setEditingTransaction(null); + setIsUmbuchung(false); setForm({ date: new Date().toISOString().split('T')[0], account: 'kasse', type: 'einlage', amount: '', description: '', + toAccount: 'bank', + }); + setDialogOpen(true); + } + + function openCreateUmbuchung() { + setEditingTransaction(null); + setIsUmbuchung(true); + setForm({ + date: new Date().toISOString().split('T')[0], + account: 'kasse', + type: 'umbuchung', + amount: '', + description: '', + toAccount: 'bank', }); setDialogOpen(true); } @@ -93,27 +125,46 @@ export default function CompanyMoney() { async function handleSave() { setSaving(true); - const payload = { - date: form.date, - account: form.account, - type: form.type, - amount: parseFloat(form.amount), - description: form.description, - }; + const payload = isUmbuchung + ? { + date: form.date, + account: form.account, + type: 'umbuchung', + toAccount: form.toAccount, + amount: parseFloat(form.amount), + description: form.description, + } + : { + date: form.date, + account: form.account, + type: form.type, + amount: parseFloat(form.amount), + description: form.description, + }; try { if (editingTransaction) { - await fetch(`/api/companies/${companyId}/money?transactionId=${editingTransaction.id}`, { + const res = await fetch(`/api/companies/${companyId}/money?transactionId=${editingTransaction.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); + if (!res.ok) { + const error = await res.json(); + alert(error.error || "Fehler beim Speichern"); + return; + } } else { - await fetch(`/api/companies/${companyId}/money`, { + const res = await fetch(`/api/companies/${companyId}/money`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); + if (!res.ok) { + const error = await res.json(); + alert(error.error || "Fehler beim Speichern"); + return; + } } setDialogOpen(false); revalidate(); @@ -158,6 +209,10 @@ export default function CompanyMoney() {

+
- {/* Tabelle */} + {/* Split-View: Kasse und Bank nebeneinander */} {sortedTransactions.length === 0 ? ( @@ -199,80 +254,181 @@ export default function CompanyMoney() { ) : ( - -
- - - - - - - - - - - - {sortedTransactions.map((transaction) => ( - - - - - - - +
+ {/* Kasse Tabelle */} + +
+

Kasse

+
+
+
- Datum - - Konto - - Typ - - Beschreibung - - Betrag - -
- {transaction.date} - - {transaction.account === 'kasse' ? 'Kasse' : 'Bank'} - - - {transaction.type === 'einlage' ? 'Einlage' : 'Entnahme'} - - - {transaction.description} - - - {transaction.type === 'einlage' ? '+' : '-'}{formatCurrency(transaction.amount)} - - -
- - -
-
+ + + + + + + + - ))} - -
+ Datum + + Typ + + Beschreibung + + Kategorie + + Betrag +
-
-
+ + + {sortedTransactions + .filter((t) => t.account === 'kasse') + .map((transaction) => ( + + + {transaction.date} + + + + {transaction.type === 'einlage' ? 'Einlage' : 'Entnahme'} + + + + {transaction.description} + + + {transaction.kategorie || '—'} + + + + {transaction.type === 'einlage' ? '+' : '-'}{formatCurrency(transaction.amount)} + + + + {transaction.isBusinessRecord ? ( + + Automatisch + + ) : ( +
+ + +
+ )} + + + ))} + + + + + + {/* Bank Tabelle */} + +
+

Bank

+
+
+ + + + + + + + + + + + {sortedTransactions + .filter((t) => t.account === 'bank') + .map((transaction) => ( + + + + + + + + + ))} + +
+ Datum + + Typ + + Beschreibung + + Kategorie + + Betrag + +
+ {transaction.date} + + + {transaction.type === 'einlage' ? 'Einlage' : 'Entnahme'} + + + {transaction.description} + + {transaction.kategorie || '—'} + + + {transaction.type === 'einlage' ? '+' : '-'}{formatCurrency(transaction.amount)} + + + {transaction.isBusinessRecord ? ( + + Automatisch + + ) : ( +
+ + +
+ )} +
+
+
+ )} {/* Dialog: Anlegen / Bearbeiten */} @@ -297,33 +453,61 @@ export default function CompanyMoney() { /> -
- - -
+ {isUmbuchung ? ( + <> +
+ + +
+
+ +
+ {form.toAccount === 'kasse' ? 'Kasse' : 'Bank'} +
+
+ + ) : ( + <> +
+ + +
-
- - -
+
+ + +
+ + )}
diff --git a/package-lock.json b/package-lock.json index c908a64..2899e68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1311,7 +1311,6 @@ "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", "hasInstallScript": true, - "license": "Apache-2.0", "engines": { "node": ">=16.13" }, diff --git a/prisma/migrations/20260324195649_add_buchung_source_links/migration.sql b/prisma/migrations/20260324195649_add_buchung_source_links/migration.sql new file mode 100644 index 0000000..cb31dee --- /dev/null +++ b/prisma/migrations/20260324195649_add_buchung_source_links/migration.sql @@ -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`); diff --git a/prisma/migrations/20260324_add_invoice_buchung_link/migration.sql b/prisma/migrations/20260324_add_invoice_buchung_link/migration.sql new file mode 100644 index 0000000..e214e1c --- /dev/null +++ b/prisma/migrations/20260324_add_invoice_buchung_link/migration.sql @@ -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; diff --git a/prisma/migrations/20260324_consolidate_transactions/migration.sql b/prisma/migrations/20260324_consolidate_transactions/migration.sql new file mode 100644 index 0000000..9680c40 --- /dev/null +++ b/prisma/migrations/20260324_consolidate_transactions/migration.sql @@ -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. diff --git a/prisma/migrations/20260324_drop_legacy_einnahmen_ausgaben/migration.sql b/prisma/migrations/20260324_drop_legacy_einnahmen_ausgaben/migration.sql new file mode 100644 index 0000000..5db9d4b --- /dev/null +++ b/prisma/migrations/20260324_drop_legacy_einnahmen_ausgaben/migration.sql @@ -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`; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6a5e200..198ce1e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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()) diff --git a/prisma/seed.ts b/prisma/seed.ts index 95eb26d..698c23a 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -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()