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() {
) : (
-
-
-
-
-
- |
- Datum
- |
-
- Konto
- |
-
- Typ
- |
-
- Beschreibung
- |
-
- Betrag
- |
- |
-
-
-
- {sortedTransactions.map((transaction) => (
-
- |
- {transaction.date}
- |
-
- {transaction.account === 'kasse' ? 'Kasse' : 'Bank'}
- |
-
-
- {transaction.type === 'einlage' ? 'Einlage' : 'Entnahme'}
-
- |
-
- {transaction.description}
- |
-
-
- {transaction.type === 'einlage' ? '+' : '-'}{formatCurrency(transaction.amount)}
-
- |
-
-
-
-
-
- |
+
+ {/* Kasse Tabelle */}
+
+
+
Kasse
+
+
+
+
+
+ |
+ 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
+
+
+
+
+
+ |
+ Datum
+ |
+
+ Typ
+ |
+
+ Beschreibung
+ |
+
+ Kategorie
+ |
+
+ Betrag
+ |
+ |
+
+
+
+ {sortedTransactions
+ .filter((t) => t.account === 'bank')
+ .map((transaction) => (
+
+ |
+ {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()