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
+12 -13
View File
@@ -1,10 +1,9 @@
import { getApiUser } from "@/session.server"; import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server"; import prisma from "@/lib/prisma.server";
import { z } from "zod"; import { z } from "zod";
import { AusgabeKategorie } from "@prisma/client";
const updateSchema = z.object({ const updateSchema = z.object({
kategorie: z.nativeEnum(AusgabeKategorie), kategorie: z.string().min(1),
betrag: z.number().positive(), betrag: z.number().positive(),
steuersatz: z.number().min(0).default(0), steuersatz: z.number().min(0).default(0),
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"), 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); const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 }); if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const ausgabe = await prisma.betriebsausgabe.findFirst({ const buchung = await prisma.buchung.findFirst({
where: { id: params.id, company: { userId: user.id } }, 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") { 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 }); return Response.json({ ok: true });
} }
@@ -30,22 +29,22 @@ export async function action({ request, params }: { request: Request; params: {
const parsed = updateSchema.safeParse(body); const parsed = updateSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 }); 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 }, where: { id: params.id },
data: { data: {
kategorie: parsed.data.kategorie, kategorie: parsed.data.kategorie,
betrag: parsed.data.betrag, amount: parsed.data.betrag,
steuersatz: parsed.data.steuersatz, steuersatz: parsed.data.steuersatz,
zahlungsart: parsed.data.zahlungsart, zahlungsart: parsed.data.zahlungsart,
datum: new Date(parsed.data.datum), account: parsed.data.zahlungsart === "KASSE" ? "KASSE" : "BANK",
beschreibung: parsed.data.beschreibung, date: new Date(parsed.data.datum),
description: parsed.data.beschreibung,
}, },
}); });
return Response.json({ return Response.json({
...updated, ...updated,
betrag: Number(updated.betrag), amount: Number(updated.amount),
steuersatz: Number(updated.steuersatz), date: updated.date.toISOString(),
datum: updated.datum.toISOString(),
}); });
} }
+17 -15
View File
@@ -1,11 +1,10 @@
import { getApiUser } from "@/session.server"; import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server"; import prisma from "@/lib/prisma.server";
import { z } from "zod"; import { z } from "zod";
import { AusgabeKategorie } from "@prisma/client";
const createSchema = z.object({ const createSchema = z.object({
companyId: z.string().min(1), companyId: z.string().min(1),
kategorie: z.nativeEnum(AusgabeKategorie), kategorie: z.string().min(1),
betrag: z.number().positive(), betrag: z.number().positive(),
steuersatz: z.number().min(0).default(0), steuersatz: z.number().min(0).default(0),
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"), 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 } }); const company = await prisma.company.findFirst({ where: { id: companyId, userId: user.id } });
if (!company) return Response.json({ error: "Not found" }, { status: 404 }); if (!company) return Response.json({ error: "Not found" }, { status: 404 });
const ausgaben = await prisma.betriebsausgabe.findMany({ const ausgaben = await prisma.buchung.findMany({
where: { where: {
companyId, companyId,
type: "ENTNAHME",
isBusinessRecord: true,
...(year ? { ...(year ? {
datum: { date: {
gte: new Date(`${year}-01-01`), gte: new Date(`${year}-01-01`),
lt: new Date(`${year + 1}-01-01`), lt: new Date(`${year + 1}-01-01`),
}, },
} : {}), } : {}),
}, },
orderBy: { datum: "desc" }, orderBy: { date: "desc" },
}); });
return Response.json( return Response.json(
ausgaben.map((a) => ({ ausgaben.map((a) => ({
...a, ...a,
betrag: Number(a.betrag), amount: Number(a.amount),
steuersatz: Number(a.steuersatz), date: a.date.toISOString(),
datum: a.datum.toISOString(),
})) }))
); );
} }
@@ -62,22 +62,24 @@ export async function action({ request }: { request: Request }) {
}); });
if (!company) return Response.json({ error: "Company not found" }, { status: 404 }); if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
const ausgabe = await prisma.betriebsausgabe.create({ const ausgabe = await prisma.buchung.create({
data: { data: {
companyId: parsed.data.companyId, 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, kategorie: parsed.data.kategorie,
betrag: parsed.data.betrag,
steuersatz: parsed.data.steuersatz, steuersatz: parsed.data.steuersatz,
zahlungsart: parsed.data.zahlungsart, zahlungsart: parsed.data.zahlungsart,
datum: new Date(parsed.data.datum), isBusinessRecord: true,
beschreibung: parsed.data.beschreibung,
}, },
}); });
return Response.json({ return Response.json({
...ausgabe, ...ausgabe,
betrag: Number(ausgabe.betrag), amount: Number(ausgabe.amount),
steuersatz: Number(ausgabe.steuersatz), date: ausgabe.date.toISOString(),
datum: ausgabe.datum.toISOString(),
}, { status: 201 }); }, { status: 201 });
} }
+18 -18
View File
@@ -63,42 +63,42 @@ export async function loader({ request }: { request: Request }) {
const bank = Number(bankAgg._sum.grossTotal ?? 0); const bank = Number(bankAgg._sum.grossTotal ?? 0);
const summeAktiva = forderungen + bank; const summeAktiva = forderungen + bank;
// Betriebsausgaben für das Jahr // Betriebsausgaben für das Jahr (from buchungen with type=ENTNAHME and isBusinessRecord=true)
const ausgaben = await prisma.betriebsausgabe.findMany({ const ausgaben = await prisma.buchung.findMany({
where: { companyId, datum: { gte: yearStart, lt: yearEnd } }, 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 ausgabenVorsteuer = ausgaben.reduce((s, a) => {
const brutto = Number(a.betrag); const brutto = Number(a.amount);
const rate = Number(a.steuersatz) / 100; const rate = (a.steuersatz || 0) / 100;
return s + (rate > 0 ? Math.round((brutto / (1 + rate)) * rate * 100) / 100 : 0); return s + (rate > 0 ? Math.round((brutto / (1 + rate)) * rate * 100) / 100 : 0);
}, 0); }, 0);
// Ausgaben nach Kategorie // Ausgaben nach Kategorie
const ausgabenByKategorieMap: Record<string, number> = {}; const ausgabenByKategorieMap: Record<string, number> = {};
for (const a of ausgaben) { for (const a of ausgaben) {
const k = a.kategorie; const k = a.kategorie || "Sonstige";
ausgabenByKategorieMap[k] = (ausgabenByKategorieMap[k] ?? 0) + Number(a.betrag); ausgabenByKategorieMap[k] = (ausgabenByKategorieMap[k] ?? 0) + Number(a.amount);
} }
const ausgabenByKategorie = Object.entries(ausgabenByKategorieMap).map(([kategorie, betrag]) => ({ kategorie, betrag })); const ausgabenByKategorie = Object.entries(ausgabenByKategorieMap).map(([kategorie, betrag]) => ({ kategorie, betrag }));
// Sonstige Einnahmen für das Jahr // Sonstige Einnahmen für das Jahr (from buchungen with type=EINLAGE and isBusinessRecord=true)
const einnahmen = await prisma.betriebseinnahme.findMany({ const einnahmen = await prisma.buchung.findMany({
where: { companyId, datum: { gte: yearStart, lt: yearEnd } }, 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 einnahmenUst = einnahmen.reduce((s, e) => {
const brutto = Number(e.betrag); const brutto = Number(e.amount);
const rate = Number(e.steuersatz) / 100; const rate = (e.steuersatz || 0) / 100;
return s + (rate > 0 ? Math.round((brutto / (1 + rate)) * rate * 100) / 100 : 0); return s + (rate > 0 ? Math.round((brutto / (1 + rate)) * rate * 100) / 100 : 0);
}, 0); }, 0);
// Kasse / Bank aus sonstigen Einnahmen und Ausgaben (nach Zahlungsart) // 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 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.betrag), 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.betrag), 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.betrag), 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 // Kasse-Saldo = bezahlte Rechnungen (Kasse-Anteil wird nicht getrennt) + sonstige Einnahmen Kasse - Ausgaben Kasse
// Bank-Näherung = bezahlte Rechnungen + sonstige Einnahmen Bank - Ausgaben Bank // Bank-Näherung = bezahlte Rechnungen + sonstige Einnahmen Bank - Ausgaben Bank
+101
View File
@@ -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 });
}
+116 -6
View File
@@ -8,9 +8,11 @@ type Transaction = {
type: "einlage" | "entnahme"; type: "einlage" | "entnahme";
amount: number; amount: number;
description: string; 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 { return {
id: buchung.id, id: buchung.id,
date: buchung.date.toISOString().split("T")[0], 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", type: buchung.type === "EINLAGE" ? "einlage" : "entnahme",
amount: Number(buchung.amount), amount: Number(buchung.amount),
description: buchung.description || "", 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 }); 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 }, where: { companyId: id },
orderBy: { date: "desc" }, 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 transactions = buchungen.map(toTransaction);
const balance = transactions.reduce((sum: number, t: Transaction) => sum + (t.type === "einlage" ? t.amount : -t.amount), 0 as number); const balance = transactions.reduce((sum: number, t: Transaction) => sum + (t.type === "einlage" ? t.amount : -t.amount), 0 as number);
@@ -60,10 +75,47 @@ export async function action({ request, params }: { request: Request; params: {
if (method === "POST") { if (method === "POST") {
const amount = Number(data.amount); 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 }); return Response.json({ error: "Ungültige Daten" }, { status: 400 });
} }
// 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({ await prisma.buchung.create({
data: { data: {
companyId: id, companyId: id,
@@ -74,6 +126,7 @@ export async function action({ request, params }: { request: Request; params: {
description: data.description || "", description: data.description || "",
}, },
}); });
}
} else if (method === "PUT") { } else if (method === "PUT") {
if (!transactionId) return Response.json({ error: "transactionId required" }, { status: 400 }); if (!transactionId) return Response.json({ error: "transactionId required" }, { status: 400 });
const amount = Number(data.amount); 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 }); 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 }); 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({ await prisma.buchung.update({
where: { id: transactionId }, where: { id: transactionId },
data: { data: {
@@ -97,12 +169,50 @@ export async function action({ request, params }: { request: Request; params: {
} else if (method === "DELETE") { } else if (method === "DELETE") {
if (!transactionId) return Response.json({ error: "transactionId required" }, { status: 400 }); if (!transactionId) return Response.json({ error: "transactionId required" }, { status: 400 });
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 } }); await prisma.buchung.deleteMany({ where: { id: transactionId, companyId: id } });
}
} else { } else {
return Response.json({ error: "Method not allowed" }, { status: 405 }); 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 transactions = buchungen.map(toTransaction);
const balance = transactions.reduce((sum: number, t: Transaction) => sum + (t.type === "einlage" ? t.amount : -t.amount), 0 as number); const balance = transactions.reduce((sum: number, t: Transaction) => sum + (t.type === "einlage" ? t.amount : -t.amount), 0 as number);
+14 -19
View File
@@ -1,10 +1,9 @@
import { getApiUser } from "@/session.server"; import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server"; import prisma from "@/lib/prisma.server";
import { z } from "zod"; import { z } from "zod";
import { EinnahmeKategorie } from "@prisma/client";
const updateSchema = z.object({ const updateSchema = z.object({
kategorie: z.nativeEnum(EinnahmeKategorie), kategorie: z.string().min(1),
betrag: z.number().positive(), betrag: z.number().positive(),
steuersatz: z.number().min(0).default(0), steuersatz: z.number().min(0).default(0),
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"), 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 {Request} request - The request object.
* @param {Object} params - The route parameters. * @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<Response>} - A promise resolving to a Response object. * @returns {Promise<Response>} - 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 } }) { export async function action({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request); const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 }); if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const einnahme = await prisma.betriebseinnahme.findFirst({ const buchung = await prisma.buchung.findFirst({
where: { id: params.id, company: { userId: user.id } }, 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") { 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 }); return Response.json({ ok: true });
} }
@@ -43,22 +38,22 @@ export async function action({ request, params }: { request: Request; params: {
const parsed = updateSchema.safeParse(body); const parsed = updateSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 }); 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 }, where: { id: params.id },
data: { data: {
kategorie: parsed.data.kategorie, kategorie: parsed.data.kategorie,
betrag: parsed.data.betrag, amount: parsed.data.betrag,
steuersatz: parsed.data.steuersatz, steuersatz: parsed.data.steuersatz,
zahlungsart: parsed.data.zahlungsart, zahlungsart: parsed.data.zahlungsart,
datum: new Date(parsed.data.datum), account: parsed.data.zahlungsart === "KASSE" ? "KASSE" : "BANK",
beschreibung: parsed.data.beschreibung, date: new Date(parsed.data.datum),
description: parsed.data.beschreibung,
}, },
}); });
return Response.json({ return Response.json({
...updated, ...updated,
betrag: Number(updated.betrag), amount: Number(updated.amount),
steuersatz: Number(updated.steuersatz), date: updated.date.toISOString(),
datum: updated.datum.toISOString(),
}); });
} }
+23 -19
View File
@@ -1,11 +1,10 @@
import { getApiUser } from "@/session.server"; import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server"; import prisma from "@/lib/prisma.server";
import { z } from "zod"; import { z } from "zod";
import { EinnahmeKategorie } from "@prisma/client";
const createSchema = z.object({ const createSchema = z.object({
companyId: z.string().min(1), companyId: z.string().min(1),
kategorie: z.nativeEnum(EinnahmeKategorie), kategorie: z.string().min(1),
betrag: z.number().positive(), betrag: z.number().positive(),
steuersatz: z.number().min(0).default(0), steuersatz: z.number().min(0).default(0),
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"), 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. * 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 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. * 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 } }); const company = await prisma.company.findFirst({ where: { id: companyId, userId: user.id } });
if (!company) return Response.json({ error: "Not found" }, { status: 404 }); if (!company) return Response.json({ error: "Not found" }, { status: 404 });
const einnahmen = await prisma.betriebseinnahme.findMany({ const einnahmen = await prisma.buchung.findMany({
where: { where: {
companyId, companyId,
type: "EINLAGE",
isBusinessRecord: true,
...(year ? { ...(year ? {
datum: { date: {
gte: new Date(`${year}-01-01`), gte: new Date(`${year}-01-01`),
lt: new Date(`${year + 1}-01-01`), lt: new Date(`${year + 1}-01-01`),
}, },
} : {}), } : {}),
}, },
orderBy: { datum: "asc" }, orderBy: { date: "asc" },
}); });
return Response.json( return Response.json(
einnahmen.map((e) => ({ einnahmen.map((e) => ({
...e, ...e,
betrag: Number(e.betrag), amount: Number(e.amount),
steuersatz: Number(e.steuersatz), date: e.date.toISOString(),
datum: e.datum.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: * Requires a JSON object in the request body with the following shape:
* { * {
* companyId: string, * companyId: string,
* kategorie: EinnahmeKategorie, * kategorie: string (BuchungKategorie name),
* betrag: number, * betrag: number,
* steuersatz: number,
* zahlungsart: "KASSE" | "BANK",
* datum: string, * datum: string,
* beschreibung: 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 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. * 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 }); if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
const einnahme = await prisma.betriebseinnahme.create({ const einnahme = await prisma.buchung.create({
data: { data: {
companyId: parsed.data.companyId, 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, kategorie: parsed.data.kategorie,
betrag: parsed.data.betrag,
steuersatz: parsed.data.steuersatz, steuersatz: parsed.data.steuersatz,
zahlungsart: parsed.data.zahlungsart, zahlungsart: parsed.data.zahlungsart,
datum: new Date(parsed.data.datum), isBusinessRecord: true,
beschreibung: parsed.data.beschreibung,
}, },
}); });
return Response.json( return Response.json(
{ {
...einnahme, ...einnahme,
betrag: Number(einnahme.betrag), amount: Number(einnahme.amount),
steuersatz: Number(einnahme.steuersatz), date: einnahme.date.toISOString(),
datum: einnahme.datum.toISOString(),
}, },
{ status: 201 } { status: 201 }
); );
+36
View File
@@ -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 }); if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const newStatus = parsed.data.status; const newStatus = parsed.data.status;
const oldStatus = invoice.status;
let numberUpdate: string | null | undefined = undefined; let numberUpdate: string | null | undefined = undefined;
if (newStatus === "DELETED") { if (newStatus === "DELETED") {
@@ -111,10 +112,45 @@ export async function action({ request, params }: { request: Request; params: {
numberUpdate = await generateInvoiceNumber(invoice.companyId); 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({ const updated = await prisma.invoice.update({
where: { id: params.id }, where: { id: params.id },
data: { data: {
status: newStatus, 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, deletedAt: newStatus === "DELETED" ? new Date() : null,
...(numberUpdate !== undefined && { number: numberUpdate }), ...(numberUpdate !== undefined && { number: numberUpdate }),
}, },
+11 -9
View File
@@ -52,12 +52,14 @@ export async function loader({ request, params }: { request: Request; params: {
if (!company) throw new Response("Not Found", { status: 404 }); if (!company) throw new Response("Not Found", { status: 404 });
const year = new Date().getFullYear(); const year = new Date().getFullYear();
const ausgaben = await prisma.betriebsausgabe.findMany({ const ausgaben = await prisma.buchung.findMany({
where: { where: {
companyId: params.id, 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 { return {
@@ -66,12 +68,12 @@ export async function loader({ request, params }: { request: Request; params: {
initialYear: year, initialYear: year,
ausgaben: ausgaben.map((a) => ({ ausgaben: ausgaben.map((a) => ({
id: a.id, id: a.id,
kategorie: a.kategorie as AusgabeKategorieKey, kategorie: (a.kategorie || "SONSTIGER_BETRIEBSBEDARF") as AusgabeKategorieKey,
betrag: Number(a.betrag), betrag: Number(a.amount),
steuersatz: Number(a.steuersatz), steuersatz: a.steuersatz || 19,
zahlungsart: a.zahlungsart as "KASSE" | "BANK", zahlungsart: (a.zahlungsart as "KASSE" | "BANK") || "BANK",
datum: a.datum.toISOString(), datum: a.date.toISOString(),
beschreibung: a.beschreibung, beschreibung: a.description,
})), })),
}; };
} }
+11 -9
View File
@@ -52,12 +52,14 @@ export async function loader({ request, params }: { request: Request; params: {
if (!company) throw new Response("Not Found", { status: 404 }); if (!company) throw new Response("Not Found", { status: 404 });
const year = new Date().getFullYear(); const year = new Date().getFullYear();
const einnahmen = await prisma.betriebseinnahme.findMany({ const einnahmen = await prisma.buchung.findMany({
where: { where: {
companyId: params.id, 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 { return {
@@ -66,12 +68,12 @@ export async function loader({ request, params }: { request: Request; params: {
initialYear: year, initialYear: year,
einnahmen: einnahmen.map((e) => ({ einnahmen: einnahmen.map((e) => ({
id: e.id, id: e.id,
kategorie: e.kategorie as EinnahmeKategorieKey, kategorie: (e.kategorie || "SONSTIGE_EINNAHMEN") as EinnahmeKategorieKey,
betrag: Number(e.betrag), betrag: Number(e.amount),
steuersatz: Number(e.steuersatz), steuersatz: e.steuersatz || 0,
zahlungsart: e.zahlungsart as "KASSE" | "BANK", zahlungsart: (e.zahlungsart as "KASSE" | "BANK") || "BANK",
datum: e.datum.toISOString(), datum: e.date.toISOString(),
beschreibung: e.beschreibung, beschreibung: e.description,
})), })),
}; };
} }
+201 -17
View File
@@ -16,6 +16,8 @@ type Transaction = {
type: 'einlage' | 'entnahme'; type: 'einlage' | 'entnahme';
amount: number; amount: number;
description: string; description: string;
isBusinessRecord: boolean;
kategorie: string | null;
}; };
export async function loader({ request, params }: { request: Request; params: { id: string } }) { 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({ const buchungen = await prisma.buchung.findMany({
where: { companyId: company.id }, where: { companyId: company.id },
orderBy: { date: 'desc' }, 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 => ({ 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', type: b.type === 'EINLAGE' ? 'einlage' : 'entnahme',
amount: Number(b.amount), amount: Number(b.amount),
description: b.description || '', 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); 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 [dialogOpen, setDialogOpen] = useState(false);
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null); const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
const [isUmbuchung, setIsUmbuchung] = useState(false);
const [form, setForm] = useState({ const [form, setForm] = useState({
date: new Date().toISOString().split('T')[0], date: new Date().toISOString().split('T')[0],
account: 'kasse' as 'kasse' | 'bank', account: 'kasse' as 'kasse' | 'bank',
type: 'einlage' as 'einlage' | 'entnahme', type: 'einlage' as 'einlage' | 'entnahme',
amount: '', amount: '',
description: '', description: '',
toAccount: 'bank' as 'kasse' | 'bank',
}); });
function openCreate() { function openCreate() {
setEditingTransaction(null); setEditingTransaction(null);
setIsUmbuchung(false);
setForm({ setForm({
date: new Date().toISOString().split('T')[0], date: new Date().toISOString().split('T')[0],
account: 'kasse', account: 'kasse',
type: 'einlage', type: 'einlage',
amount: '', amount: '',
description: '', 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); setDialogOpen(true);
} }
@@ -93,7 +125,16 @@ export default function CompanyMoney() {
async function handleSave() { async function handleSave() {
setSaving(true); setSaving(true);
const payload = { const payload = isUmbuchung
? {
date: form.date,
account: form.account,
type: 'umbuchung',
toAccount: form.toAccount,
amount: parseFloat(form.amount),
description: form.description,
}
: {
date: form.date, date: form.date,
account: form.account, account: form.account,
type: form.type, type: form.type,
@@ -103,17 +144,27 @@ export default function CompanyMoney() {
try { try {
if (editingTransaction) { if (editingTransaction) {
await fetch(`/api/companies/${companyId}/money?transactionId=${editingTransaction.id}`, { const res = await fetch(`/api/companies/${companyId}/money?transactionId=${editingTransaction.id}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!res.ok) {
const error = await res.json();
alert(error.error || "Fehler beim Speichern");
return;
}
} else { } else {
await fetch(`/api/companies/${companyId}/money`, { const res = await fetch(`/api/companies/${companyId}/money`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!res.ok) {
const error = await res.json();
alert(error.error || "Fehler beim Speichern");
return;
}
} }
setDialogOpen(false); setDialogOpen(false);
revalidate(); revalidate();
@@ -158,6 +209,10 @@ export default function CompanyMoney() {
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button onClick={openCreateUmbuchung} variant="outline">
<Plus className="h-4 w-4" />
Umbuchung
</Button>
<Button onClick={openCreate}> <Button onClick={openCreate}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Neue Transaktion Neue Transaktion
@@ -187,7 +242,7 @@ export default function CompanyMoney() {
</Card> </Card>
</div> </div>
{/* Tabelle */} {/* Split-View: Kasse und Bank nebeneinander */}
{sortedTransactions.length === 0 ? ( {sortedTransactions.length === 0 ? (
<Card> <Card>
<CardContent className="py-16 text-center text-gray-400"> <CardContent className="py-16 text-center text-gray-400">
@@ -199,23 +254,28 @@ export default function CompanyMoney() {
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Kasse Tabelle */}
<Card> <Card>
<div className="px-4 py-3 border-b border-slate-200 bg-slate-50">
<h3 className="font-semibold text-slate-700">Kasse</h3>
</div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm border-collapse"> <table className="w-full text-sm border-collapse">
<thead> <thead>
<tr className="border-b border-slate-200 bg-slate-50"> <tr className="border-b border-slate-200 bg-slate-50">
<th className="px-4 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide"> <th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
Datum Datum
</th> </th>
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide"> <th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
Konto
</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
Typ Typ
</th> </th>
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide"> <th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
Beschreibung Beschreibung
</th> </th>
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
Kategorie
</th>
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide"> <th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">
Betrag Betrag
</th> </th>
@@ -223,15 +283,14 @@ export default function CompanyMoney() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100"> <tbody className="divide-y divide-slate-100">
{sortedTransactions.map((transaction) => ( {sortedTransactions
.filter((t) => t.account === 'kasse')
.map((transaction) => (
<tr key={transaction.id} className="hover:bg-slate-50/60 group"> <tr key={transaction.id} className="hover:bg-slate-50/60 group">
<td className="px-4 py-2.5 text-slate-600 whitespace-nowrap"> <td className="px-3 py-2.5 text-slate-600 whitespace-nowrap">
{transaction.date} {transaction.date}
</td> </td>
<td className="px-4 py-2.5 text-slate-600 whitespace-nowrap"> <td className="px-3 py-2.5 text-slate-600 whitespace-nowrap">
{transaction.account === 'kasse' ? 'Kasse' : 'Bank'}
</td>
<td className="px-4 py-2.5 text-slate-600 whitespace-nowrap">
<Badge variant={transaction.type === 'einlage' ? 'default' : 'destructive'}> <Badge variant={transaction.type === 'einlage' ? 'default' : 'destructive'}>
{transaction.type === 'einlage' ? 'Einlage' : 'Entnahme'} {transaction.type === 'einlage' ? 'Einlage' : 'Entnahme'}
</Badge> </Badge>
@@ -239,12 +298,20 @@ export default function CompanyMoney() {
<td className="px-3 py-2.5 text-slate-700"> <td className="px-3 py-2.5 text-slate-700">
{transaction.description} {transaction.description}
</td> </td>
<td className="px-3 py-2.5 text-slate-600 text-xs">
{transaction.kategorie || '—'}
</td>
<td className="px-3 py-2.5 text-right font-medium whitespace-nowrap"> <td className="px-3 py-2.5 text-right font-medium whitespace-nowrap">
<span className={transaction.type === 'einlage' ? 'text-green-600' : 'text-red-600'}> <span className={transaction.type === 'einlage' ? 'text-green-600' : 'text-red-600'}>
{transaction.type === 'einlage' ? '+' : '-'}{formatCurrency(transaction.amount)} {transaction.type === 'einlage' ? '+' : '-'}{formatCurrency(transaction.amount)}
</span> </span>
</td> </td>
<td className="px-3 py-2.5"> <td className="px-3 py-2.5">
{transaction.isBusinessRecord ? (
<span className="text-xs text-gray-500 font-medium">
Automatisch
</span>
) : (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button <button
onClick={() => openEdit(transaction)} onClick={() => openEdit(transaction)}
@@ -266,6 +333,7 @@ export default function CompanyMoney() {
)} )}
</button> </button>
</div> </div>
)}
</td> </td>
</tr> </tr>
))} ))}
@@ -273,6 +341,94 @@ export default function CompanyMoney() {
</table> </table>
</div> </div>
</Card> </Card>
{/* Bank Tabelle */}
<Card>
<div className="px-4 py-3 border-b border-slate-200 bg-slate-50">
<h3 className="font-semibold text-slate-700">Bank</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
Datum
</th>
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
Typ
</th>
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
Beschreibung
</th>
<th className="px-3 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
Kategorie
</th>
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">
Betrag
</th>
<th className="px-3 py-2.5 w-16" />
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{sortedTransactions
.filter((t) => t.account === 'bank')
.map((transaction) => (
<tr key={transaction.id} className="hover:bg-slate-50/60 group">
<td className="px-3 py-2.5 text-slate-600 whitespace-nowrap">
{transaction.date}
</td>
<td className="px-3 py-2.5 text-slate-600 whitespace-nowrap">
<Badge variant={transaction.type === 'einlage' ? 'default' : 'destructive'}>
{transaction.type === 'einlage' ? 'Einlage' : 'Entnahme'}
</Badge>
</td>
<td className="px-3 py-2.5 text-slate-700">
{transaction.description}
</td>
<td className="px-3 py-2.5 text-slate-600 text-xs">
{transaction.kategorie || '—'}
</td>
<td className="px-3 py-2.5 text-right font-medium whitespace-nowrap">
<span className={transaction.type === 'einlage' ? 'text-green-600' : 'text-red-600'}>
{transaction.type === 'einlage' ? '+' : '-'}{formatCurrency(transaction.amount)}
</span>
</td>
<td className="px-3 py-2.5">
{transaction.isBusinessRecord ? (
<span className="text-xs text-gray-500 font-medium">
Automatisch
</span>
) : (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => openEdit(transaction)}
className="p-1 rounded hover:bg-slate-100 text-slate-500 hover:text-slate-700"
title="Bearbeiten"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => handleDelete(transaction.id)}
disabled={deleting === transaction.id}
className="p-1 rounded hover:bg-red-50 text-slate-400 hover:text-red-600"
title="Löschen"
>
{deleting === transaction.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)} )}
{/* Dialog: Anlegen / Bearbeiten */} {/* Dialog: Anlegen / Bearbeiten */}
@@ -297,6 +453,32 @@ export default function CompanyMoney() {
/> />
</div> </div>
{isUmbuchung ? (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Von (Konto) <span className="text-red-500">*</span>
</label>
<select
value={form.account}
onChange={(e) => setForm((f) => ({ ...f, account: e.target.value as 'kasse' | 'bank', toAccount: e.target.value === 'kasse' ? 'bank' : 'kasse' }))}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="kasse">Kasse</option>
<option value="bank">Bank</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nach (Konto)
</label>
<div className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm bg-gray-50">
{form.toAccount === 'kasse' ? 'Kasse' : 'Bank'}
</div>
</div>
</>
) : (
<>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Konto <span className="text-red-500">*</span> Konto <span className="text-red-500">*</span>
@@ -320,10 +502,12 @@ export default function CompanyMoney() {
onChange={(e) => setForm((f) => ({ ...f, type: e.target.value as 'einlage' | 'entnahme' }))} onChange={(e) => setForm((f) => ({ ...f, type: e.target.value as 'einlage' | 'entnahme' }))}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
> >
<option value="einlage">Einlage</option> <option value="einlage">Einnahme (Einlage)</option>
<option value="entnahme">Entnahme</option> <option value="entnahme">Ausgabe (Entnahme)</option>
</select> </select>
</div> </div>
</>
)}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
@@ -360,7 +544,7 @@ export default function CompanyMoney() {
</Button> </Button>
<Button onClick={handleSave} disabled={saving || !formValid}> <Button onClick={handleSave} disabled={saving || !formValid}>
{saving && <Loader2 className="h-4 w-4 animate-spin" />} {saving && <Loader2 className="h-4 w-4 animate-spin" />}
{editingTransaction ? "Speichern" : "Hinzufügen"} {editingTransaction ? "Speichern" : isUmbuchung ? "Umbuchung durchführen" : "Hinzufügen"}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
-1
View File
@@ -1311,7 +1311,6 @@
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0",
"engines": { "engines": {
"node": ">=16.13" "node": ">=16.13"
}, },
@@ -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`;
+25 -69
View File
@@ -70,16 +70,28 @@ model Company {
customers Customer[] customers Customer[]
invoices Invoice[] invoices Invoice[]
services Service[] services Service[]
betriebsausgaben Betriebsausgabe[]
betriebseinnahmen Betriebseinnahme[]
anlagegueter Anlagegut[] anlagegueter Anlagegut[]
buchungen Buchung[] buchungen Buchung[]
kategorien BuchungKategorie[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@map("companies") @@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 { enum TransactionAccount {
KASSE KASSE
BANK BANK
@@ -99,11 +111,20 @@ model Buchung {
type TransactionType type TransactionType
amount Decimal @db.Decimal(10, 2) amount Decimal @db.Decimal(10, 2)
description String? @db.Text 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([companyId]) @@index([companyId])
@@index([date]) @@index([date])
@@index([isBusinessRecord])
@@map("buchungen") @@map("buchungen")
} }
@@ -158,6 +179,8 @@ model Invoice {
netTotal Decimal @db.Decimal(10, 2) netTotal Decimal @db.Decimal(10, 2)
taxTotal Decimal @db.Decimal(10, 2) taxTotal Decimal @db.Decimal(10, 2)
grossTotal 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime?
@@ -179,36 +202,6 @@ enum Zahlungsart {
BANK 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 { model Anlagegut {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -228,43 +221,6 @@ model Anlagegut {
@@map("anlagegueter") @@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 { model InvoiceItem {
id String @id @default(cuid()) id String @id @default(cuid())
+53 -1
View File
@@ -136,8 +136,60 @@ async function main() {
data: { invoiceSequence: 1 }, 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("\n✅ Seed complete!");
console.log("Login: anna@example.de / demo123"); console.log("Login: anna@example.de / annas_password");
} }
main() main()