191 lines
7.0 KiB
TypeScript
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();
|
|
});
|