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 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(),
});
}
+17 -15
View File
@@ -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 });
}
+18 -18
View File
@@ -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<string, number> = {};
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
+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 });
}
+127 -17
View File
@@ -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);
+14 -19
View File
@@ -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<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 } }) {
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(),
});
}
+23 -19
View File
@@ -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 }
);
+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 });
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 }),
},
+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 });
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,
})),
};
}
+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 });
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,
})),
};
}
+294 -110
View File
@@ -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<Transaction | null>(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() {
</p>
</div>
<div className="flex items-center gap-3">
<Button onClick={openCreateUmbuchung} variant="outline">
<Plus className="h-4 w-4" />
Umbuchung
</Button>
<Button onClick={openCreate}>
<Plus className="h-4 w-4" />
Neue Transaktion
@@ -187,7 +242,7 @@ export default function CompanyMoney() {
</Card>
</div>
{/* Tabelle */}
{/* Split-View: Kasse und Bank nebeneinander */}
{sortedTransactions.length === 0 ? (
<Card>
<CardContent className="py-16 text-center text-gray-400">
@@ -199,80 +254,181 @@ export default function CompanyMoney() {
</CardContent>
</Card>
) : (
<Card>
<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-4 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">
Konto
</th>
<th className="px-4 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-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.map((transaction) => (
<tr key={transaction.id} className="hover:bg-slate-50/60 group">
<td className="px-4 py-2.5 text-slate-600 whitespace-nowrap">
{transaction.date}
</td>
<td className="px-4 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'}>
{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-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">
<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>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Kasse Tabelle */}
<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">
<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>
))}
</tbody>
</table>
</div>
</Card>
</thead>
<tbody className="divide-y divide-slate-100">
{sortedTransactions
.filter((t) => t.account === 'kasse')
.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>
{/* 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 */}
@@ -297,33 +453,61 @@ export default function CompanyMoney() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Konto <span className="text-red-500">*</span>
</label>
<select
value={form.account}
onChange={(e) => setForm((f) => ({ ...f, account: e.target.value as 'kasse' | 'bank' }))}
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>
{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>
<label className="block text-sm font-medium text-gray-700 mb-1">
Konto <span className="text-red-500">*</span>
</label>
<select
value={form.account}
onChange={(e) => setForm((f) => ({ ...f, account: e.target.value as 'kasse' | 'bank' }))}
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">
Typ <span className="text-red-500">*</span>
</label>
<select
value={form.type}
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"
>
<option value="einlage">Einlage</option>
<option value="entnahme">Entnahme</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Typ <span className="text-red-500">*</span>
</label>
<select
value={form.type}
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"
>
<option value="einlage">Einnahme (Einlage)</option>
<option value="entnahme">Ausgabe (Entnahme)</option>
</select>
</div>
</>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
@@ -360,7 +544,7 @@ export default function CompanyMoney() {
</Button>
<Button onClick={handleSave} disabled={saving || !formValid}>
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
{editingTransaction ? "Speichern" : "Hinzufügen"}
{editingTransaction ? "Speichern" : isUmbuchung ? "Umbuchung durchführen" : "Hinzufügen"}
</Button>
</div>
</DialogContent>