feat(sprint8): Phase 2 — Treasury frontend + PDF receipts

Backend:
- ReceiptPdfService: Generates Quittung PDF per payment (OpenPDF, A4)
- FinancialReportService: Annual financial report PDF (Jahresabschluss)
- FinanceController: Added receipt download, annual report, CSV export endpoints
- Portal receipt download with member ownership verification

Frontend:
- src/services/finance.ts: Complete React Query service (types, hooks, mutations)
- /finance: Dashboard with KPI cards, recent transactions, outstanding members
- /finance/payments: Payment list with filtering, void, receipt download
- /finance/kassenbuch: Kassenbuch ledger with date range, CSV export
- /finance/fee-schedules: Fee schedule CRUD with interval management
- /finance/reports: Annual report PDF download
- /portal/finance: Member self-service balance + payment history + receipts

Navigation & i18n:
- Added Finanzen (Wallet icon) to admin sidebar
- Portal finance page for member payments
- Comprehensive de.json + en.json finance keys (~100 translations)
This commit is contained in:
Patrick Plate
2026-06-15 08:24:43 +02:00
parent 721503b231
commit 3211ade5be
13 changed files with 2419 additions and 1 deletions
@@ -0,0 +1,286 @@
package de.cannamanage.service;
import com.lowagie.text.*;
import com.lowagie.text.pdf.PdfPCell;
import com.lowagie.text.pdf.PdfPTable;
import com.lowagie.text.pdf.PdfWriter;
import de.cannamanage.domain.entity.Club;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.Year;
import java.time.format.DateTimeFormatter;
import java.time.format.TextStyle;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* Generates the annual financial report (Jahresabschluss) as PDF.
* Content: Income/expense totals, monthly breakdown, category breakdown, member payment summary.
* Legal basis: §259 BGB (Rechenschaftspflicht), §147 AO (10-year retention).
*/
@Service
public class FinancialReportService {
private static final Logger log = LoggerFactory.getLogger(FinancialReportService.class);
private static final Font HEADER_FONT = new Font(Font.HELVETICA, 16, Font.BOLD);
private static final Font TITLE_FONT = new Font(Font.HELVETICA, 12, Font.BOLD);
private static final Font SUBTITLE_FONT = new Font(Font.HELVETICA, 10, Font.BOLD);
private static final Font NORMAL_FONT = new Font(Font.HELVETICA, 10, Font.NORMAL);
private static final Font TABLE_HEADER_FONT = new Font(Font.HELVETICA, 9, Font.BOLD, Color.WHITE);
private static final Font TABLE_CELL_FONT = new Font(Font.HELVETICA, 9, Font.NORMAL);
private static final Font TABLE_CELL_BOLD = new Font(Font.HELVETICA, 9, Font.BOLD);
private static final Font FOOTER_FONT = new Font(Font.HELVETICA, 8, Font.ITALIC, Color.GRAY);
private static final Color PRIMARY_COLOR = new Color(34, 87, 58); // Dark green
private static final Color HEADER_BG = new Color(34, 87, 58);
private static final Color LIGHT_BG = new Color(245, 248, 245);
private static final Color INCOME_COLOR = new Color(22, 163, 74);
private static final Color EXPENSE_COLOR = new Color(220, 38, 38);
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
/**
* DTO for annual report data.
*/
public record AnnualReportData(
int year,
BigDecimal totalIncome,
BigDecimal totalExpenses,
BigDecimal netBalance,
List<MonthlyBreakdown> monthlyBreakdown,
List<CategoryBreakdown> expensesByCategory,
int totalMembers,
int paidMembers,
BigDecimal totalOutstanding
) {}
public record MonthlyBreakdown(
int month,
String monthName,
BigDecimal income,
BigDecimal expenses,
BigDecimal net
) {}
public record CategoryBreakdown(
String category,
BigDecimal amount,
double percentage
) {}
/**
* Generate the annual financial report PDF.
*/
public byte[] generateAnnualReport(AnnualReportData data, Club club) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
Document document = new Document(PageSize.A4, 50, 50, 50, 50);
try {
PdfWriter writer = PdfWriter.getInstance(document, baos);
writer.setPageEvent(new PdfFooterHandler());
document.open();
// === Page 1: Cover & Summary ===
// Club header
Paragraph clubHeader = new Paragraph(club.getName(), HEADER_FONT);
clubHeader.setSpacingAfter(5);
document.add(clubHeader);
// Title
Paragraph title = new Paragraph(
"Jahresabschluss " + data.year(), TITLE_FONT);
title.setSpacingAfter(5);
document.add(title);
// Subtitle
Paragraph subtitle = new Paragraph(
"Finanzübersicht für das Geschäftsjahr " + data.year(),
NORMAL_FONT);
subtitle.setSpacingAfter(20);
document.add(subtitle);
// --- Summary KPIs ---
addSectionTitle(document, "Zusammenfassung");
PdfPTable summaryTable = new PdfPTable(2);
summaryTable.setWidthPercentage(60);
summaryTable.setHorizontalAlignment(Element.ALIGN_LEFT);
summaryTable.setSpacingAfter(20);
addSummaryRow(summaryTable, "Gesamteinnahmen",
formatEuro(data.totalIncome()), INCOME_COLOR);
addSummaryRow(summaryTable, "Gesamtausgaben",
formatEuro(data.totalExpenses()), EXPENSE_COLOR);
addSummaryRow(summaryTable, "Saldo",
formatEuro(data.netBalance()),
data.netBalance().signum() >= 0 ? INCOME_COLOR : EXPENSE_COLOR);
document.add(summaryTable);
// --- Member payment summary ---
addSectionTitle(document, "Mitgliederbeiträge");
PdfPTable memberTable = new PdfPTable(2);
memberTable.setWidthPercentage(60);
memberTable.setHorizontalAlignment(Element.ALIGN_LEFT);
memberTable.setSpacingAfter(20);
addSummaryRow(memberTable, "Mitglieder gesamt",
String.valueOf(data.totalMembers()), Color.BLACK);
addSummaryRow(memberTable, "Beiträge bezahlt",
String.valueOf(data.paidMembers()), INCOME_COLOR);
addSummaryRow(memberTable, "Offene Beiträge",
formatEuro(data.totalOutstanding()), EXPENSE_COLOR);
document.add(memberTable);
// --- Monthly Breakdown Table ---
addSectionTitle(document, "Monatliche Übersicht");
PdfPTable monthlyTable = new PdfPTable(4);
monthlyTable.setWidthPercentage(100);
monthlyTable.setWidths(new float[]{30f, 23f, 23f, 24f});
monthlyTable.setSpacingAfter(20);
addColoredHeader(monthlyTable, "Monat");
addColoredHeader(monthlyTable, "Einnahmen");
addColoredHeader(monthlyTable, "Ausgaben");
addColoredHeader(monthlyTable, "Saldo");
BigDecimal runningTotal = BigDecimal.ZERO;
for (MonthlyBreakdown month : data.monthlyBreakdown()) {
runningTotal = runningTotal.add(month.net());
boolean isEven = month.month() % 2 == 0;
Color rowBg = isEven ? LIGHT_BG : Color.WHITE;
addCellWithBg(monthlyTable, month.monthName(), TABLE_CELL_FONT, rowBg);
addCellWithBg(monthlyTable, formatEuro(month.income()), TABLE_CELL_FONT, rowBg);
addCellWithBg(monthlyTable, formatEuro(month.expenses()), TABLE_CELL_FONT, rowBg);
addCellWithBg(monthlyTable, formatEuro(month.net()), TABLE_CELL_FONT, rowBg);
}
// Total row
addCellWithBg(monthlyTable, "GESAMT", TABLE_CELL_BOLD, LIGHT_BG);
addCellWithBg(monthlyTable, formatEuro(data.totalIncome()), TABLE_CELL_BOLD, LIGHT_BG);
addCellWithBg(monthlyTable, formatEuro(data.totalExpenses()), TABLE_CELL_BOLD, LIGHT_BG);
addCellWithBg(monthlyTable, formatEuro(data.netBalance()), TABLE_CELL_BOLD, LIGHT_BG);
document.add(monthlyTable);
// --- Expense Breakdown by Category ---
if (data.expensesByCategory() != null && !data.expensesByCategory().isEmpty()) {
addSectionTitle(document, "Ausgaben nach Kategorie");
PdfPTable categoryTable = new PdfPTable(3);
categoryTable.setWidthPercentage(80);
categoryTable.setWidths(new float[]{45f, 30f, 25f});
categoryTable.setSpacingAfter(20);
addColoredHeader(categoryTable, "Kategorie");
addColoredHeader(categoryTable, "Betrag");
addColoredHeader(categoryTable, "Anteil");
for (CategoryBreakdown cat : data.expensesByCategory()) {
addCell(categoryTable, translateCategory(cat.category()));
addCell(categoryTable, formatEuro(cat.amount()));
addCell(categoryTable, String.format("%.1f%%", cat.percentage()));
}
document.add(categoryTable);
}
// --- Footer note ---
Paragraph footerNote = new Paragraph(
"Erstellt am " + LocalDate.now().format(DATE_FMT)
+ " — Anbauvereinigung gemäß §2 KCanG",
FOOTER_FONT);
footerNote.setAlignment(Element.ALIGN_CENTER);
footerNote.setSpacingBefore(30);
document.add(footerNote);
document.close();
} catch (DocumentException e) {
log.error("Failed to generate annual report PDF for year {}", data.year(), e);
throw new RuntimeException("PDF generation failed", e);
}
return baos.toByteArray();
}
// --- Helper methods ---
private void addSectionTitle(Document document, String text) throws DocumentException {
Paragraph section = new Paragraph(text, SUBTITLE_FONT);
section.setSpacingBefore(10);
section.setSpacingAfter(8);
document.add(section);
}
private void addSummaryRow(PdfPTable table, String label, String value, Color valueColor) {
PdfPCell labelCell = new PdfPCell(new Phrase(label, NORMAL_FONT));
labelCell.setBorder(Rectangle.NO_BORDER);
labelCell.setPaddingBottom(5);
table.addCell(labelCell);
Font valueFont = new Font(Font.HELVETICA, 10, Font.BOLD, valueColor);
PdfPCell valueCell = new PdfPCell(new Phrase(value, valueFont));
valueCell.setBorder(Rectangle.NO_BORDER);
valueCell.setPaddingBottom(5);
valueCell.setHorizontalAlignment(Element.ALIGN_RIGHT);
table.addCell(valueCell);
}
private void addColoredHeader(PdfPTable table, String text) {
PdfPCell cell = new PdfPCell(new Phrase(text, TABLE_HEADER_FONT));
cell.setBackgroundColor(HEADER_BG);
cell.setPadding(6);
cell.setBorderWidth(0);
table.addCell(cell);
}
private void addCell(PdfPTable table, String text) {
PdfPCell cell = new PdfPCell(new Phrase(text, TABLE_CELL_FONT));
cell.setPadding(5);
cell.setBorderWidth(0.5f);
cell.setBorderColor(Color.LIGHT_GRAY);
table.addCell(cell);
}
private void addCellWithBg(PdfPTable table, String text, Font font, Color bg) {
PdfPCell cell = new PdfPCell(new Phrase(text, font));
cell.setBackgroundColor(bg);
cell.setPadding(5);
cell.setBorderWidth(0.5f);
cell.setBorderColor(Color.LIGHT_GRAY);
table.addCell(cell);
}
private String formatEuro(BigDecimal amount) {
return String.format(Locale.GERMAN, "%,.2f €", amount);
}
private String translateCategory(String category) {
return switch (category) {
case "RENT" -> "Miete";
case "UTILITIES" -> "Nebenkosten";
case "EQUIPMENT" -> "Ausstattung";
case "SEEDS" -> "Saatgut";
case "SUPPLIES" -> "Verbrauchsmaterial";
case "INSURANCE" -> "Versicherung";
case "LEGAL" -> "Rechtsberatung";
case "OTHER" -> "Sonstiges";
default -> category;
};
}
}
@@ -0,0 +1,212 @@
package de.cannamanage.service;
import com.lowagie.text.*;
import com.lowagie.text.pdf.PdfPCell;
import com.lowagie.text.pdf.PdfPTable;
import com.lowagie.text.pdf.PdfWriter;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.domain.entity.Member;
import de.cannamanage.domain.entity.Payment;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
/**
* Generates receipt (Quittung) PDFs for member payments.
* Layout: A4 portrait, single page, professional receipt format.
* Legal basis: §147 AO (10-year retention for Buchungsbelege).
*/
@Service
public class ReceiptPdfService {
private static final Logger log = LoggerFactory.getLogger(ReceiptPdfService.class);
private static final Font CLUB_NAME_FONT = new Font(Font.HELVETICA, 14, Font.BOLD);
private static final Font CLUB_ADDRESS_FONT = new Font(Font.HELVETICA, 9, Font.NORMAL, Color.DARK_GRAY);
private static final Font TITLE_FONT = new Font(Font.HELVETICA, 18, Font.BOLD);
private static final Font RECEIPT_NR_FONT = new Font(Font.HELVETICA, 10, Font.NORMAL, Color.GRAY);
private static final Font LABEL_FONT = new Font(Font.HELVETICA, 10, Font.BOLD);
private static final Font VALUE_FONT = new Font(Font.HELVETICA, 10, Font.NORMAL);
private static final Font AMOUNT_FONT = new Font(Font.HELVETICA, 14, Font.BOLD);
private static final Font FOOTER_FONT = new Font(Font.HELVETICA, 8, Font.ITALIC, Color.GRAY);
private static final Font SIGNATURE_FONT = new Font(Font.HELVETICA, 9, Font.NORMAL);
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private static final DateTimeFormatter PERIOD_FMT = DateTimeFormatter.ofPattern("MMMM yyyy", Locale.GERMAN);
/**
* Generate a receipt PDF for a payment.
*
* @param payment the payment record
* @param member the paying member
* @param club the club entity (for header)
* @return PDF bytes
*/
public byte[] generateReceipt(Payment payment, Member member, Club club) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
// A4 with generous margins
Document document = new Document(PageSize.A4, 60, 60, 60, 60);
try {
PdfWriter.getInstance(document, baos);
document.open();
// --- Club Header ---
Paragraph clubName = new Paragraph(club.getName(), CLUB_NAME_FONT);
clubName.setAlignment(Element.ALIGN_LEFT);
document.add(clubName);
if (club.getAddress() != null && !club.getAddress().isBlank()) {
Paragraph address = new Paragraph(club.getAddress(), CLUB_ADDRESS_FONT);
address.setSpacingAfter(5);
document.add(address);
}
// Horizontal rule
Paragraph hr = new Paragraph(" ");
hr.setSpacingBefore(5);
hr.setSpacingAfter(15);
document.add(hr);
// --- Title ---
Paragraph title = new Paragraph("QUITTUNG", TITLE_FONT);
title.setAlignment(Element.ALIGN_CENTER);
title.setSpacingAfter(5);
document.add(title);
// Receipt number
String receiptNr = payment.getReceiptNumber() != null
? payment.getReceiptNumber()
: "CM-" + payment.getId().toString().substring(0, 8).toUpperCase();
Paragraph nrPara = new Paragraph("Nr. " + receiptNr, RECEIPT_NR_FONT);
nrPara.setAlignment(Element.ALIGN_CENTER);
nrPara.setSpacingAfter(30);
document.add(nrPara);
// --- Receipt Details Table ---
PdfPTable detailsTable = new PdfPTable(2);
detailsTable.setWidthPercentage(80);
detailsTable.setHorizontalAlignment(Element.ALIGN_LEFT);
detailsTable.setWidths(new float[]{35f, 65f});
detailsTable.setSpacingAfter(20);
addDetailRow(detailsTable, "Erhalten von:", getMemberDisplayName(member));
addDetailRow(detailsTable, "Mitgliedsnr.:", member.getMemberNumber() != null
? member.getMemberNumber() : "");
// Amount - formatted as Euro
BigDecimal amount = BigDecimal.valueOf(payment.getAmountCents()).divide(BigDecimal.valueOf(100));
String amountStr = String.format(Locale.GERMAN, "%,.2f €", amount);
addDetailRow(detailsTable, "Betrag:", amountStr, AMOUNT_FONT);
addDetailRow(detailsTable, "Datum:", payment.getPaymentDate().format(DATE_FMT));
addDetailRow(detailsTable, "Zahlungsart:", translatePaymentMethod(payment.getPaymentMethod().name()));
// Period covered
if (payment.getPeriodFrom() != null && payment.getPeriodTo() != null) {
String period = formatPeriod(payment.getPeriodFrom(), payment.getPeriodTo());
addDetailRow(detailsTable, "Zeitraum:", period);
}
addDetailRow(detailsTable, "Verwendung:", "Mitgliedsbeitrag");
if (payment.getReference() != null && !payment.getReference().isBlank()) {
addDetailRow(detailsTable, "Referenz:", payment.getReference());
}
document.add(detailsTable);
// --- Spacer ---
document.add(new Paragraph(" "));
document.add(new Paragraph(" "));
document.add(new Paragraph(" "));
// --- Signature Section ---
PdfPTable sigTable = new PdfPTable(2);
sigTable.setWidthPercentage(80);
sigTable.setHorizontalAlignment(Element.ALIGN_LEFT);
sigTable.setWidths(new float[]{50f, 50f});
// Date line
PdfPCell dateCell = new PdfPCell();
dateCell.setBorder(Rectangle.TOP);
dateCell.setBorderWidth(0.5f);
dateCell.setPaddingTop(5);
dateCell.addElement(new Phrase("Datum", SIGNATURE_FONT));
sigTable.addCell(dateCell);
// Signature line
PdfPCell sigCell = new PdfPCell();
sigCell.setBorder(Rectangle.TOP);
sigCell.setBorderWidth(0.5f);
sigCell.setPaddingTop(5);
sigCell.addElement(new Phrase("Unterschrift Kassenwart", SIGNATURE_FONT));
sigTable.addCell(sigCell);
document.add(sigTable);
// --- Footer ---
Paragraph footer = new Paragraph(
"Anbauvereinigung gemäß §2 KCanG — " + club.getName(),
FOOTER_FONT
);
footer.setAlignment(Element.ALIGN_CENTER);
footer.setSpacingBefore(40);
document.add(footer);
document.close();
} catch (DocumentException e) {
log.error("Failed to generate receipt PDF for payment {}", payment.getId(), e);
throw new RuntimeException("PDF generation failed", e);
}
return baos.toByteArray();
}
private void addDetailRow(PdfPTable table, String label, String value) {
addDetailRow(table, label, value, VALUE_FONT);
}
private void addDetailRow(PdfPTable table, String label, String value, Font valueFont) {
PdfPCell labelCell = new PdfPCell(new Phrase(label, LABEL_FONT));
labelCell.setBorder(Rectangle.NO_BORDER);
labelCell.setPaddingBottom(8);
table.addCell(labelCell);
PdfPCell valueCell = new PdfPCell(new Phrase(value, valueFont));
valueCell.setBorder(Rectangle.NO_BORDER);
valueCell.setPaddingBottom(8);
table.addCell(valueCell);
}
private String getMemberDisplayName(Member member) {
String firstName = member.getFirstName() != null ? member.getFirstName() : "";
String lastName = member.getLastName() != null ? member.getLastName() : "";
return (firstName + " " + lastName).trim();
}
private String translatePaymentMethod(String method) {
return switch (method) {
case "CASH" -> "Bar";
case "BANK_TRANSFER" -> "Überweisung";
case "SEPA" -> "SEPA-Lastschrift";
case "CARD" -> "Kartenzahlung";
default -> method;
};
}
private String formatPeriod(LocalDate from, LocalDate to) {
if (from.getMonth() == to.getMonth() && from.getYear() == to.getYear()) {
return from.format(PERIOD_FMT);
}
return from.format(PERIOD_FMT) + " " + to.format(PERIOD_FMT);
}
}