wg-verwaltung/backend/src/routes/expenses.ts

191 lines
7.0 KiB
TypeScript

import { Router, Response } from 'express';
import { PrismaClient, ExpenseCategory, SettlementStatus } from '@prisma/client';
import { AuthedRequest, requireAuth } from '../middleware/auth';
const prisma = new PrismaClient();
/**
* Router für die WG-Kasse (Ausgaben-Teiler).
*
* Jeder (Mieter oder Vermieter) kann eine Auslage erfassen (z. B. Spülmittel,
* Klopapier). Der Betrag wird automatisch durch alle aktiven Mieter geteilt:
* der Zahler legt aus, alle ANDEREN Mieter bekommen einen offenen Anteil
* ("schulden" dem Zahler ihren Teil zurück). Die WG-Kasse ist als Ganzes für
* alle sichtbar (gemeinschaftliche Organisationsinfo, kein privates Datum).
*/
export const expensesRouter = Router();
const CATEGORIES = Object.values(ExpenseCategory);
const EXPENSE_SELECT = {
id: true,
amount: true,
description: true,
category: true,
receiptUrl: true,
createdAt: true,
payer: { select: { id: true, fullName: true } },
shares: {
select: {
id: true,
shareAmount: true,
settlementStatus: true,
settledAt: true,
user: { select: { id: true, fullName: true } },
},
},
};
// POST /expenses — Auslage erfassen, automatischer Split unter allen Mietern
// (außer dem Zahler selbst, der hat seinen Anteil ja bereits ausgelegt).
expensesRouter.post('/expenses', requireAuth, async (req: AuthedRequest, res: Response) => {
const { amount, description, category, receiptUrl } = req.body || {};
const parsedAmount = Number(amount);
if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
return res.status(400).json({ error: 'amount muss eine positive Zahl sein' });
}
if (!description || typeof description !== 'string' || !description.trim()) {
return res.status(400).json({ error: 'description ist erforderlich' });
}
if (category && !CATEGORIES.includes(category)) {
return res.status(400).json({ error: `Ungültige Kategorie. Erlaubt: ${CATEGORIES.join(', ')}` });
}
const tenants = await prisma.user.findMany({
where: { role: 'TENANT' },
select: { id: true },
});
// Anteil unter ALLEN Mitbewohnern (inkl. Zahler, falls dieser selbst Mieter
// ist) gleich aufteilen — nur die anderen bekommen einen offenen Anteil,
// der Zahler hat seinen Teil bereits durch die Auslage beglichen.
const participantCount = Math.max(tenants.length, 1);
const shareAmount = Math.round((parsedAmount / participantCount) * 100) / 100;
const owingTenants = tenants.filter((t) => t.id !== req.user!.id);
const expense = await prisma.expense.create({
data: {
payerId: req.user!.id,
amount: parsedAmount,
description: description.trim().slice(0, 200),
category: category || ExpenseCategory.HAUSHALT,
receiptUrl: typeof receiptUrl === 'string' && receiptUrl.trim() ? receiptUrl.trim() : null,
shares: {
create: owingTenants.map((t) => ({
userId: t.id,
shareAmount,
settlementStatus: SettlementStatus.OPEN,
})),
},
},
select: EXPENSE_SELECT,
});
res.status(201).json({ expense });
});
// GET /expenses — alle Ausgaben der WG-Kasse (für alle sichtbar).
expensesRouter.get('/expenses', requireAuth, async (_req: AuthedRequest, res: Response) => {
const expenses = await prisma.expense.findMany({
orderBy: { createdAt: 'desc' },
select: EXPENSE_SELECT,
});
res.status(200).json({ expenses });
});
// GET /expenses/balance — Saldo pro Mieter: was er anderen schuldet (offene
// eigene Anteile) und was ihm andere noch schulden (offene Anteile an seinen
// eigenen Auslagen). Für alle sichtbar, da Teil der gemeinsamen WG-Kasse.
expensesRouter.get('/expenses/balance', requireAuth, async (_req: AuthedRequest, res: Response) => {
const [openSharesOwed, openSharesToReceive] = await Promise.all([
prisma.expenseShare.groupBy({
by: ['userId'],
where: { settlementStatus: SettlementStatus.OPEN },
_sum: { shareAmount: true },
}),
prisma.expenseShare.findMany({
where: { settlementStatus: SettlementStatus.OPEN },
select: { shareAmount: true, expense: { select: { payerId: true } } },
}),
]);
const receiveByPayer = new Map();
for (const share of openSharesToReceive) {
const payerId = share.expense.payerId;
receiveByPayer.set(payerId, (receiveByPayer.get(payerId) || 0) + Number(share.shareAmount));
}
const owedByUser = new Map();
for (const row of openSharesOwed) {
owedByUser.set(row.userId, Number(row._sum.shareAmount || 0));
}
const userIds = new Set([...owedByUser.keys(), ...receiveByPayer.keys()]);
const users = await prisma.user.findMany({
where: { id: { in: Array.from(userIds) } },
select: { id: true, fullName: true },
});
const balance = users.map((u) => ({
user: u,
owes: Math.round((owedByUser.get(u.id) || 0) * 100) / 100,
isOwed: Math.round((receiveByPayer.get(u.id) || 0) * 100) / 100,
}));
res.status(200).json({ balance });
});
// PATCH /expense-shares/:id — Anteil als beglichen markieren. Darf der
// Schuldner selbst (hat bezahlt), der Zahler der Auslage (hat Geld erhalten)
// oder Vermieter/Admin.
expensesRouter.patch('/expense-shares/:id', requireAuth, async (req: AuthedRequest, res: Response) => {
const share = await prisma.expenseShare.findUnique({
where: { id: req.params.id },
include: { expense: { select: { payerId: true } } },
});
if (!share) return res.status(404).json({ error: 'Anteil nicht gefunden' });
const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN';
const isDebtor = share.userId === req.user!.id;
const isPayer = share.expense.payerId === req.user!.id;
if (!isLandlord && !isDebtor && !isPayer) {
return res.status(403).json({ error: 'Keine Berechtigung, diesen Anteil zu ändern' });
}
const updated = await prisma.expenseShare.update({
where: { id: req.params.id },
data: { settlementStatus: SettlementStatus.SETTLED, settledAt: new Date() },
select: {
id: true,
shareAmount: true,
settlementStatus: true,
settledAt: true,
user: { select: { id: true, fullName: true } },
},
});
res.status(200).json({ share: updated });
});
// DELETE /expenses/:id — nur der Ersteller (bevor Anteile beglichen wurden)
// oder Vermieter/Admin.
expensesRouter.delete('/expenses/:id', requireAuth, async (req: AuthedRequest, res: Response) => {
const expense = await prisma.expense.findUnique({
where: { id: req.params.id },
include: { shares: true },
});
if (!expense) return res.status(404).json({ error: 'Ausgabe nicht gefunden' });
const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN';
if (!isLandlord && expense.payerId !== req.user!.id) {
return res.status(403).json({ error: 'Du kannst nur eigene Ausgaben löschen' });
}
if (!isLandlord && expense.shares.some((s) => s.settlementStatus === SettlementStatus.SETTLED)) {
return res.status(409).json({ error: 'Ausgabe hat bereits beglichene Anteile und kann nicht mehr gelöscht werden' });
}
await prisma.expense.delete({ where: { id: req.params.id } });
res.status(204).send();
});