170 lines
5.9 KiB
TypeScript
170 lines
5.9 KiB
TypeScript
import { Router, Response } from 'express';
|
|
import { PrismaClient, SettlementStatus } from '@prisma/client';
|
|
import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth';
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
/**
|
|
* Router für die Nebenkostenabrechnung.
|
|
*
|
|
* Der Vermieter trägt eine Abrechnung (Zeitraum + Gesamtbetrag) ein; die App
|
|
* verteilt den Betrag automatisch proportional zur Zimmergröße (sizeSqm) auf
|
|
* alle aktuell aktiven Mieter — dieselbe faire Grundlage wie bei der
|
|
* Nettokaltmiete. Zugriffsregel: ein Mieter sieht nur seinen EIGENEN Anteil,
|
|
* niemals die Anteile anderer Mieter (private Abrechnungsdaten). Vermieter/
|
|
* Admin sehen die komplette Abrechnung inkl. aller Anteile.
|
|
*/
|
|
export const utilityStatementsRouter = Router();
|
|
|
|
const SHARE_SELECT = {
|
|
id: true,
|
|
shareAmount: true,
|
|
settlementStatus: true,
|
|
settledAt: true,
|
|
user: { select: { id: true, fullName: true } },
|
|
room: { select: { id: true, roomNumber: true } },
|
|
};
|
|
|
|
const STATEMENT_SELECT_LANDLORD = {
|
|
id: true,
|
|
periodStart: true,
|
|
periodEnd: true,
|
|
totalAmount: true,
|
|
description: true,
|
|
createdAt: true,
|
|
createdBy: { select: { id: true, fullName: true } },
|
|
shares: { select: SHARE_SELECT },
|
|
};
|
|
|
|
// POST /utility-statements — Abrechnung anlegen, automatischer Split nach
|
|
// Zimmergröße unter allen aktuell aktiven Mietern (Vermieter/Admin only).
|
|
utilityStatementsRouter.post(
|
|
'/utility-statements',
|
|
requireAuth,
|
|
requireRole('LANDLORD', 'ADMIN'),
|
|
async (req: AuthedRequest, res: Response) => {
|
|
const { periodStart, periodEnd, totalAmount, description } = req.body || {};
|
|
|
|
const parsedAmount = Number(totalAmount);
|
|
if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
|
|
return res.status(400).json({ error: 'totalAmount muss eine positive Zahl sein' });
|
|
}
|
|
const start = new Date(periodStart);
|
|
const end = new Date(periodEnd);
|
|
if (isNaN(start.getTime()) || isNaN(end.getTime()) || start >= end) {
|
|
return res.status(400).json({ error: 'periodStart/periodEnd sind ungültig' });
|
|
}
|
|
|
|
// Aktive Verträge mit Zimmer laden, um proportional nach sizeSqm zu splitten.
|
|
const activeContracts = await prisma.contract.findMany({
|
|
where: { isActive: true },
|
|
select: { userId: true, roomId: true, room: { select: { sizeSqm: true } } },
|
|
});
|
|
|
|
if (activeContracts.length === 0) {
|
|
return res.status(409).json({ error: 'Keine aktiven Mietverträge vorhanden, Split nicht möglich' });
|
|
}
|
|
|
|
const totalSqm = activeContracts.reduce((sum, c) => sum + Number(c.room.sizeSqm), 0);
|
|
|
|
let allocated = 0;
|
|
const shares = activeContracts.map((c, idx) => {
|
|
const isLast = idx === activeContracts.length - 1;
|
|
let shareAmount: number;
|
|
if (isLast) {
|
|
// letzter Anteil bekommt den Rest, um Rundungsdifferenzen zu vermeiden
|
|
shareAmount = Math.round((parsedAmount - allocated) * 100) / 100;
|
|
} else {
|
|
shareAmount = Math.round(((parsedAmount * Number(c.room.sizeSqm)) / totalSqm) * 100) / 100;
|
|
allocated += shareAmount;
|
|
}
|
|
return {
|
|
userId: c.userId,
|
|
roomId: c.roomId,
|
|
shareAmount,
|
|
settlementStatus: SettlementStatus.OPEN,
|
|
};
|
|
});
|
|
|
|
const statement = await prisma.utilityStatement.create({
|
|
data: {
|
|
periodStart: start,
|
|
periodEnd: end,
|
|
totalAmount: parsedAmount,
|
|
description: typeof description === 'string' ? description.trim().slice(0, 300) : null,
|
|
createdById: req.user!.id,
|
|
shares: { create: shares },
|
|
},
|
|
select: STATEMENT_SELECT_LANDLORD,
|
|
});
|
|
|
|
res.status(201).json({ statement });
|
|
},
|
|
);
|
|
|
|
// GET /utility-statements — Vermieter/Admin sehen alle Abrechnungen mit allen
|
|
// Anteilen; Mieter sehen alle Abrechnungen, aber NUR ihren eigenen Anteil
|
|
// (kein Einblick in die Anteile anderer Mieter).
|
|
utilityStatementsRouter.get('/utility-statements', requireAuth, async (req: AuthedRequest, res: Response) => {
|
|
const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN';
|
|
|
|
const statements = await prisma.utilityStatement.findMany({
|
|
orderBy: { periodStart: 'desc' },
|
|
select: {
|
|
id: true,
|
|
periodStart: true,
|
|
periodEnd: true,
|
|
totalAmount: true,
|
|
description: true,
|
|
createdAt: true,
|
|
createdBy: { select: { id: true, fullName: true } },
|
|
shares: {
|
|
select: SHARE_SELECT,
|
|
where: isLandlord ? undefined : { userId: req.user!.id },
|
|
},
|
|
},
|
|
});
|
|
|
|
res.status(200).json({ statements });
|
|
});
|
|
|
|
// PATCH /utility-statement-shares/:id — eigenen Anteil als beglichen markieren
|
|
// (Mieter selbst) oder durch Vermieter/Admin.
|
|
utilityStatementsRouter.patch(
|
|
'/utility-statement-shares/:id',
|
|
requireAuth,
|
|
async (req: AuthedRequest, res: Response) => {
|
|
const share = await prisma.utilityStatementShare.findUnique({ where: { id: req.params.id } });
|
|
if (!share) return res.status(404).json({ error: 'Anteil nicht gefunden' });
|
|
|
|
const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN';
|
|
if (!isLandlord && share.userId !== req.user!.id) {
|
|
return res.status(403).json({ error: 'Keine Berechtigung, diesen Anteil zu ändern' });
|
|
}
|
|
|
|
const updated = await prisma.utilityStatementShare.update({
|
|
where: { id: req.params.id },
|
|
data: { settlementStatus: SettlementStatus.SETTLED, settledAt: new Date() },
|
|
select: SHARE_SELECT,
|
|
});
|
|
|
|
res.status(200).json({ share: updated });
|
|
},
|
|
);
|
|
|
|
// DELETE /utility-statements/:id — nur Vermieter/Admin.
|
|
utilityStatementsRouter.delete(
|
|
'/utility-statements/:id',
|
|
requireAuth,
|
|
requireRole('LANDLORD', 'ADMIN'),
|
|
async (req: AuthedRequest, res: Response) => {
|
|
try {
|
|
await prisma.utilityStatementShare.deleteMany({ where: { statementId: req.params.id } });
|
|
await prisma.utilityStatement.delete({ where: { id: req.params.id } });
|
|
res.status(204).send();
|
|
} catch {
|
|
res.status(404).json({ error: 'Abrechnung nicht gefunden' });
|
|
}
|
|
},
|
|
);
|