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(); });