feat(sprint9): Phase 2 — Financial report generators (EÜR, Kassenbuch, Beitragsbescheinigung)

Implements Sprint 9 Phase 2 financial report generators:

- MemberReportParameters: new parameter record for per-member reports
- EurReportGenerator: Einnahmen-Überschuss-Rechnung (§4(3) EStG)
  - PDF: professional layout with income/expense sections, monthly breakdown
  - CSV: semicolon-delimited, ISO-8859-1, German decimal format
  - JSON: ELSTER-compatible structure for Steuerberater import
- KassenbuchExportGenerator: GoBD-compliant cash book export
  - PDF: landscape A4, running balance, sequential Beleg-Nr
  - CSV: GoBD-compliant format with injection prevention
  - Includes opening balance calculation and period totals
- BeitragsbescheinigungGenerator: membership fee confirmation per member
  - PDF: club letterhead, payment table, signature lines
  - For member tax purposes (Sonderausgaben)
- ReportGeneratorService: added getAvailableTypes() method
- ReportController: added GET /api/v1/reports/types endpoint

All generators are @Service beans auto-discovered by ReportGeneratorService.
Docker build verified green.
This commit is contained in:
Patrick Plate
2026-06-15 12:22:53 +02:00
parent 26a77dd269
commit a29c38756c
6 changed files with 1155 additions and 2 deletions
@@ -5,9 +5,12 @@ import de.cannamanage.api.dto.report.MonthlyReportResponse;
import de.cannamanage.api.dto.report.RecallReportResponse;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.ExportFormat;
import de.cannamanage.domain.enums.MemberStatus;
import de.cannamanage.domain.enums.ReportType;
import de.cannamanage.service.CsvReportGenerator;
import de.cannamanage.service.PdfReportGenerator;
import de.cannamanage.service.ReportGeneratorService;
import de.cannamanage.service.ReportService;
import de.cannamanage.service.model.report.MemberListReport;
import de.cannamanage.service.model.report.MonthlyReport;
@@ -20,7 +23,7 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.time.YearMonth;
import java.util.UUID;
import java.util.*;
/**
* REST controller for compliance and operational reports.
@@ -34,15 +37,41 @@ public class ReportController {
private final PdfReportGenerator pdfGenerator;
private final CsvReportGenerator csvGenerator;
private final ClubRepository clubRepository;
private final ReportGeneratorService reportGeneratorService;
public ReportController(ReportService reportService,
PdfReportGenerator pdfGenerator,
CsvReportGenerator csvGenerator,
ClubRepository clubRepository) {
ClubRepository clubRepository,
ReportGeneratorService reportGeneratorService) {
this.reportService = reportService;
this.pdfGenerator = pdfGenerator;
this.csvGenerator = csvGenerator;
this.clubRepository = clubRepository;
this.reportGeneratorService = reportGeneratorService;
}
/**
* List all available report types with their supported export formats.
* GET /api/v1/reports/types
*/
@GetMapping("/types")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(#root, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE_REPORT)")
public ResponseEntity<List<Map<String, Object>>> listReportTypes() {
Map<ReportType, Set<ExportFormat>> availableTypes = reportGeneratorService.getAvailableTypes();
List<Map<String, Object>> response = new ArrayList<>();
for (var entry : availableTypes.entrySet()) {
Map<String, Object> typeInfo = new LinkedHashMap<>();
typeInfo.put("type", entry.getKey().name());
typeInfo.put("formats", entry.getValue().stream()
.map(ExportFormat::name)
.sorted()
.toList());
response.add(typeInfo);
}
return ResponseEntity.ok(response);
}
/**
@@ -13,6 +13,7 @@ import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -125,4 +126,15 @@ public class ReportGeneratorService {
public boolean hasGenerator(ReportType type) {
return generators.containsKey(type);
}
/**
* List all registered report types with their supported formats.
* Used by GET /api/v1/reports/types endpoint.
*/
public Map<ReportType, Set<ExportFormat>> getAvailableTypes() {
Map<ReportType, Set<ExportFormat>> result = new java.util.LinkedHashMap<>();
generators.forEach((type, generator) ->
result.put(type, generator.supportedFormats()));
return result;
}
}
@@ -0,0 +1,301 @@
package de.cannamanage.service.report;
import com.lowagie.text.Chunk;
import com.lowagie.text.Document;
import com.lowagie.text.Element;
import com.lowagie.text.Font;
import com.lowagie.text.PageSize;
import com.lowagie.text.Paragraph;
import com.lowagie.text.Phrase;
import com.lowagie.text.Rectangle;
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 de.cannamanage.domain.enums.ExportFormat;
import de.cannamanage.domain.enums.ReportType;
import de.cannamanage.service.repository.ClubRepository;
import de.cannamanage.service.repository.MemberRepository;
import de.cannamanage.service.repository.PaymentRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.TextStyle;
import java.util.*;
/**
* Generates Beitragsbescheinigung (membership fee confirmation) per member.
* Per-member annual PDF confirming paid membership fees for tax purposes.
* PDF only — no CSV/JSON for this document type.
*/
@Service
public class BeitragsbescheinigungGenerator implements ReportGenerator<MemberReportParameters> {
private static final Logger log = LoggerFactory.getLogger(BeitragsbescheinigungGenerator.class);
private static final Font HEADER_FONT = new Font(Font.HELVETICA, 14, 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 SMALL_FONT = new Font(Font.HELVETICA, 9, 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 Font SIGNATURE_FONT = new Font(Font.HELVETICA, 9, Font.NORMAL, Color.DARK_GRAY);
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 DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private static final Locale GERMAN = Locale.GERMANY;
private final PaymentRepository paymentRepository;
private final MemberRepository memberRepository;
private final ClubRepository clubRepository;
public BeitragsbescheinigungGenerator(PaymentRepository paymentRepository,
MemberRepository memberRepository,
ClubRepository clubRepository) {
this.paymentRepository = paymentRepository;
this.memberRepository = memberRepository;
this.clubRepository = clubRepository;
}
@Override
public ReportType getType() {
return ReportType.FEE_CONFIRMATION;
}
@Override
public Set<ExportFormat> supportedFormats() {
return Set.of(ExportFormat.PDF);
}
@Override
public byte[] generatePdf(MemberReportParameters params, UUID clubId) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found: " + clubId));
Member member = memberRepository.findById(params.memberId())
.orElseThrow(() -> new NoSuchElementException("Member not found: " + params.memberId()));
int year = params.year();
LocalDate yearStart = LocalDate.of(year, 1, 1);
LocalDate yearEnd = LocalDate.of(year, 12, 31);
List<Payment> payments = paymentRepository.findPaidByMemberAndPeriod(
clubId, params.memberId(), yearStart, yearEnd);
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
Document document = new Document(PageSize.A4, 60, 60, 60, 60);
PdfWriter.getInstance(document, baos);
document.open();
// === Letterhead ===
Paragraph letterhead = new Paragraph();
letterhead.add(new Chunk(club.getName(), HEADER_FONT));
letterhead.add(Chunk.NEWLINE);
if (club.getAddressStreet() != null) {
letterhead.add(new Chunk(club.getAddressStreet(), SMALL_FONT));
letterhead.add(Chunk.NEWLINE);
}
if (club.getAddressPostalCode() != null || club.getAddressCity() != null) {
String postalCity = (club.getAddressPostalCode() != null ? club.getAddressPostalCode() + " " : "")
+ (club.getAddressCity() != null ? club.getAddressCity() : "");
letterhead.add(new Chunk(postalCity, SMALL_FONT));
letterhead.add(Chunk.NEWLINE);
}
if (club.getRegistrationNumber() != null) {
letterhead.add(new Chunk("Vereinsregisternummer: " + club.getRegistrationNumber(), SMALL_FONT));
letterhead.add(Chunk.NEWLINE);
}
document.add(letterhead);
document.add(Chunk.NEWLINE);
document.add(Chunk.NEWLINE);
// === Title ===
Paragraph title = new Paragraph(
"Beitragsbescheinigung für das Jahr " + year, TITLE_FONT);
title.setAlignment(Element.ALIGN_CENTER);
document.add(title);
document.add(Chunk.NEWLINE);
document.add(Chunk.NEWLINE);
// === Member info ===
Paragraph memberInfo = new Paragraph();
memberInfo.add(new Chunk("Mitglied: ", SUBTITLE_FONT));
memberInfo.add(new Chunk(member.getFirstName() + " " + member.getLastName(), NORMAL_FONT));
memberInfo.add(Chunk.NEWLINE);
memberInfo.add(new Chunk("Mitgliedsnummer: ", SUBTITLE_FONT));
memberInfo.add(new Chunk(member.getMembershipNumber(), NORMAL_FONT));
memberInfo.add(Chunk.NEWLINE);
memberInfo.add(new Chunk("Mitglied seit: ", SUBTITLE_FONT));
memberInfo.add(new Chunk(member.getMembershipDate().format(DATE_FMT), NORMAL_FONT));
document.add(memberInfo);
document.add(Chunk.NEWLINE);
document.add(Chunk.NEWLINE);
// === Payment table ===
if (payments.isEmpty()) {
document.add(new Paragraph(
"Für das Jahr " + year + " liegen keine bezahlten Beiträge vor.",
NORMAL_FONT));
} else {
Paragraph tableTitle = new Paragraph("Gezahlte Mitgliedsbeiträge:", SUBTITLE_FONT);
document.add(tableTitle);
document.add(Chunk.NEWLINE);
PdfPTable table = new PdfPTable(4);
table.setWidthPercentage(90);
table.setWidths(new float[]{2f, 1.5f, 1.5f, 1.5f});
table.setHorizontalAlignment(Element.ALIGN_LEFT);
addHeaderCell(table, "Zeitraum");
addHeaderCell(table, "Betrag");
addHeaderCell(table, "Zahlungsdatum");
addHeaderCell(table, "Zahlungsart");
long totalCents = 0;
int rowIdx = 0;
for (Payment payment : payments) {
boolean alternate = rowIdx % 2 == 1;
String period = formatPeriod(payment.getPeriodFrom(), payment.getPeriodTo());
String amount = formatCentsDisplay(payment.getAmountCents());
String paidDate = payment.getPaidAt() != null
? payment.getPaidAt().atZone(java.time.ZoneId.of("Europe/Berlin"))
.toLocalDate().format(DATE_FMT)
: "";
String method = payment.getPaymentMethod() != null
? formatPaymentMethod(payment.getPaymentMethod().name())
: "";
addDataCell(table, period, alternate);
addDataCell(table, amount, alternate);
addDataCell(table, paidDate, alternate);
addDataCell(table, method, alternate);
totalCents += payment.getAmountCents();
rowIdx++;
}
document.add(table);
document.add(Chunk.NEWLINE);
// Total
Paragraph totalPara = new Paragraph();
totalPara.add(new Chunk("Gesamtbetrag der gezahlten Mitgliedsbeiträge: ", SUBTITLE_FONT));
totalPara.add(new Chunk(formatCentsDisplay(totalCents), TITLE_FONT));
document.add(totalPara);
}
document.add(Chunk.NEWLINE);
document.add(Chunk.NEWLINE);
// === Note ===
Paragraph note = new Paragraph(
"Diese Bescheinigung dient als Nachweis über gezahlte Vereinsbeiträge.",
NORMAL_FONT);
document.add(note);
document.add(Chunk.NEWLINE);
document.add(Chunk.NEWLINE);
document.add(Chunk.NEWLINE);
// === Signature lines ===
Paragraph signatureSection = new Paragraph();
signatureSection.add(new Chunk(
club.getAddressCity() != null ? club.getAddressCity() + ", " : "",
SIGNATURE_FONT));
signatureSection.add(new Chunk(LocalDate.now().format(DATE_FMT), SIGNATURE_FONT));
document.add(signatureSection);
document.add(Chunk.NEWLINE);
document.add(Chunk.NEWLINE);
// Signature line with underscores
PdfPTable sigTable = new PdfPTable(2);
sigTable.setWidthPercentage(80);
sigTable.setWidths(new float[]{1f, 1f});
PdfPCell sigCell1 = new PdfPCell(new Phrase("___________________________\nKassenwart", SIGNATURE_FONT));
sigCell1.setBorder(Rectangle.NO_BORDER);
sigCell1.setPaddingTop(20);
sigCell1.setHorizontalAlignment(Element.ALIGN_CENTER);
sigTable.addCell(sigCell1);
PdfPCell sigCell2 = new PdfPCell(new Phrase("___________________________\nVorstand", SIGNATURE_FONT));
sigCell2.setBorder(Rectangle.NO_BORDER);
sigCell2.setPaddingTop(20);
sigCell2.setHorizontalAlignment(Element.ALIGN_CENTER);
sigTable.addCell(sigCell2);
document.add(sigTable);
// Footer
document.add(Chunk.NEWLINE);
document.add(Chunk.NEWLINE);
Paragraph footer = new Paragraph(
"Erstellt am " + LocalDate.now().format(DATE_FMT),
FOOTER_FONT);
footer.setAlignment(Element.ALIGN_CENTER);
document.add(footer);
document.close();
log.info("Generated Beitragsbescheinigung PDF for member {} in club {}, year {}, size={} bytes",
params.memberId(), clubId, year, baos.size());
return baos.toByteArray();
} catch (Exception e) {
throw new RuntimeException("Failed to generate Beitragsbescheinigung PDF", e);
}
}
// === Helper methods ===
private String formatCentsDisplay(long cents) {
long euros = cents / 100;
long remainder = Math.abs(cents % 100);
return String.format(GERMAN, "%,d,%02d €", euros, remainder);
}
private String formatPeriod(LocalDate from, LocalDate to) {
if (from == null || to == null) return "";
// If same month, show just that month
if (from.getYear() == to.getYear() && from.getMonthValue() == to.getMonthValue()) {
return from.getMonth().getDisplayName(TextStyle.FULL, GERMAN) + " " + from.getYear();
}
return from.format(DATE_FMT) + " " + to.format(DATE_FMT);
}
private String formatPaymentMethod(String method) {
return switch (method) {
case "BANK_TRANSFER" -> "Überweisung";
case "CASH" -> "Bar";
case "SEPA_DIRECT_DEBIT" -> "Lastschrift";
case "STRIPE" -> "Online";
default -> method;
};
}
private void addHeaderCell(PdfPTable table, String text) {
PdfPCell cell = new PdfPCell(new Phrase(text, TABLE_HEADER_FONT));
cell.setBackgroundColor(HEADER_BG);
cell.setPadding(5);
cell.setHorizontalAlignment(Element.ALIGN_CENTER);
table.addCell(cell);
}
private void addDataCell(PdfPTable table, String text, boolean alternate) {
PdfPCell cell = new PdfPCell(new Phrase(text, TABLE_CELL_FONT));
if (alternate) cell.setBackgroundColor(LIGHT_BG);
cell.setPadding(4);
cell.setBorder(Rectangle.BOTTOM);
cell.setBorderColor(Color.LIGHT_GRAY);
table.addCell(cell);
}
}
@@ -0,0 +1,489 @@
package de.cannamanage.service.report;
import com.lowagie.text.Chunk;
import com.lowagie.text.Document;
import com.lowagie.text.Element;
import com.lowagie.text.Font;
import com.lowagie.text.PageSize;
import com.lowagie.text.Paragraph;
import com.lowagie.text.Phrase;
import com.lowagie.text.Rectangle;
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.LedgerEntry;
import de.cannamanage.domain.enums.ExportFormat;
import de.cannamanage.domain.enums.ReportType;
import de.cannamanage.domain.enums.TransactionType;
import de.cannamanage.service.repository.ClubRepository;
import de.cannamanage.service.repository.LedgerEntryRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.io.OutputStreamWriter;
import java.nio.charset.Charset;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.TextStyle;
import java.util.*;
import java.util.stream.Collectors;
/**
* Generates the Einnahmen-Überschuss-Rechnung (§4 Abs. 3 EStG).
* Supports PDF, CSV (semicolon-delimited, ISO-8859-1), and JSON (ELSTER-compatible structure).
* All amounts from LedgerEntry.amountCents — divide by 100 for display.
*/
@Service
public class EurReportGenerator implements ReportGenerator<DateRangeReportParameters> {
private static final Logger log = LoggerFactory.getLogger(EurReportGenerator.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 Font TOTAL_FONT = new Font(Font.HELVETICA, 11, Font.BOLD);
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 DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private static final Charset ISO_8859_1 = Charset.forName("ISO-8859-1");
private static final Locale GERMAN = Locale.GERMANY;
// German display names for expense categories
private static final Map<String, String> CATEGORY_LABELS = Map.ofEntries(
Map.entry("RENT", "Miete/Pacht"),
Map.entry("ELECTRICITY", "Strom/Energie"),
Map.entry("CANNABIS_PURCHASE", "Cannabis-Einkauf"),
Map.entry("GROW_MATERIALS", "Anbaumaterial"),
Map.entry("INSURANCE", "Versicherungen"),
Map.entry("ADMINISTRATION", "Verwaltung"),
Map.entry("EVENTS", "Veranstaltungen"),
Map.entry("OTHER", "Sonstige Ausgaben"),
Map.entry("MEMBERSHIP_FEE", "Mitgliedsbeiträge"),
Map.entry("OTHER_INCOME", "Sonstige Einnahmen")
);
private final LedgerEntryRepository ledgerEntryRepository;
private final ClubRepository clubRepository;
public EurReportGenerator(LedgerEntryRepository ledgerEntryRepository,
ClubRepository clubRepository) {
this.ledgerEntryRepository = ledgerEntryRepository;
this.clubRepository = clubRepository;
}
@Override
public ReportType getType() {
return ReportType.EUR;
}
@Override
public Set<ExportFormat> supportedFormats() {
return Set.of(ExportFormat.PDF, ExportFormat.CSV, ExportFormat.JSON);
}
@Override
public byte[] generatePdf(DateRangeReportParameters params, UUID clubId) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found: " + clubId));
List<LedgerEntry> entries = ledgerEntryRepository
.findByClubIdAndTransactionDateBetween(clubId, params.from(), params.to());
EurData data = calculateEurData(entries, params);
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
Document document = new Document(PageSize.A4, 50, 50, 50, 50);
PdfWriter.getInstance(document, baos);
document.open();
// Header
Paragraph header = new Paragraph("EINNAHMEN-ÜBERSCHUSS-RECHNUNG", HEADER_FONT);
header.setAlignment(Element.ALIGN_CENTER);
document.add(header);
document.add(Chunk.NEWLINE);
// Club info
Paragraph clubInfo = new Paragraph();
clubInfo.add(new Chunk("Verein: ", SUBTITLE_FONT));
clubInfo.add(new Chunk(club.getName(), NORMAL_FONT));
clubInfo.add(Chunk.NEWLINE);
clubInfo.add(new Chunk("Kalenderjahr: ", SUBTITLE_FONT));
clubInfo.add(new Chunk(String.valueOf(params.year() != null ? params.year() : params.from().getYear()), NORMAL_FONT));
clubInfo.add(Chunk.NEWLINE);
clubInfo.add(new Chunk("Erstellt am: ", SUBTITLE_FONT));
clubInfo.add(new Chunk(LocalDate.now().format(DATE_FMT), NORMAL_FONT));
if (club.getRegistrationNumber() != null) {
clubInfo.add(Chunk.NEWLINE);
clubInfo.add(new Chunk("Vereinsregisternummer: ", SUBTITLE_FONT));
clubInfo.add(new Chunk(club.getRegistrationNumber(), NORMAL_FONT));
}
document.add(clubInfo);
document.add(Chunk.NEWLINE);
// Section A: Betriebseinnahmen
document.add(new Paragraph("I. BETRIEBSEINNAHMEN", TITLE_FONT));
document.add(Chunk.NEWLINE);
PdfPTable incomeTable = new PdfPTable(2);
incomeTable.setWidthPercentage(80);
incomeTable.setWidths(new float[]{3f, 1.5f});
incomeTable.setHorizontalAlignment(Element.ALIGN_LEFT);
for (var incomeEntry : data.incomeByCategory.entrySet()) {
addEurRow(incomeTable, getCategoryLabel(incomeEntry.getKey()), incomeEntry.getValue());
}
addSeparatorRow(incomeTable);
addEurTotalRow(incomeTable, "Summe Einnahmen", data.totalIncome);
document.add(incomeTable);
document.add(Chunk.NEWLINE);
// Section B: Betriebsausgaben
document.add(new Paragraph("II. BETRIEBSAUSGABEN", TITLE_FONT));
document.add(Chunk.NEWLINE);
PdfPTable expenseTable = new PdfPTable(2);
expenseTable.setWidthPercentage(80);
expenseTable.setWidths(new float[]{3f, 1.5f});
expenseTable.setHorizontalAlignment(Element.ALIGN_LEFT);
for (var expenseEntry : data.expenseByCategory.entrySet()) {
addEurRow(expenseTable, getCategoryLabel(expenseEntry.getKey()), expenseEntry.getValue());
}
addSeparatorRow(expenseTable);
addEurTotalRow(expenseTable, "Summe Ausgaben", data.totalExpenses);
document.add(expenseTable);
document.add(Chunk.NEWLINE);
// Section C: Gewinnermittlung
document.add(new Paragraph("III. GEWINNERMITTLUNG", TITLE_FONT));
document.add(Chunk.NEWLINE);
PdfPTable resultTable = new PdfPTable(2);
resultTable.setWidthPercentage(80);
resultTable.setWidths(new float[]{3f, 1.5f});
resultTable.setHorizontalAlignment(Element.ALIGN_LEFT);
addEurRow(resultTable, "Einnahmen gesamt", data.totalIncome);
addEurRow(resultTable, "Ausgaben gesamt", data.totalExpenses);
addSeparatorRow(resultTable);
long result = data.totalIncome - data.totalExpenses;
String label = result >= 0 ? "Überschuss" : "Fehlbetrag";
addEurTotalRow(resultTable, label, result);
document.add(resultTable);
document.add(Chunk.NEWLINE);
// Monthly breakdown table
document.add(new Paragraph("IV. MONATLICHE ÜBERSICHT", TITLE_FONT));
document.add(Chunk.NEWLINE);
PdfPTable monthTable = new PdfPTable(4);
monthTable.setWidthPercentage(90);
monthTable.setWidths(new float[]{2f, 1.5f, 1.5f, 1.5f});
addTableHeaderCell(monthTable, "Monat");
addTableHeaderCell(monthTable, "Einnahmen");
addTableHeaderCell(monthTable, "Ausgaben");
addTableHeaderCell(monthTable, "Ergebnis");
for (int month = 1; month <= 12; month++) {
MonthlyData md = data.monthlyData.getOrDefault(month, new MonthlyData(0, 0));
String monthName = java.time.Month.of(month)
.getDisplayName(TextStyle.FULL, GERMAN);
boolean alternate = month % 2 == 0;
addMonthCell(monthTable, monthName, alternate);
addAmountCell(monthTable, md.income, alternate);
addAmountCell(monthTable, md.expense, alternate);
addAmountCell(monthTable, md.income - md.expense, alternate);
}
document.add(monthTable);
document.add(Chunk.NEWLINE);
// Footer
Paragraph footer = new Paragraph(
"Erstellt gemäß §4 Abs. 3 EStG (Einnahmen-Überschuss-Rechnung)",
FOOTER_FONT);
footer.setAlignment(Element.ALIGN_CENTER);
document.add(footer);
document.close();
log.info("Generated EÜR PDF for club {}, year {}, size={} bytes",
clubId, params.year(), baos.size());
return baos.toByteArray();
} catch (Exception e) {
throw new RuntimeException("Failed to generate EÜR PDF", e);
}
}
@Override
public byte[] generateCsv(DateRangeReportParameters params, UUID clubId) {
List<LedgerEntry> entries = ledgerEntryRepository
.findByClubIdAndTransactionDateBetween(clubId, params.from(), params.to());
EurData data = calculateEurData(entries, params);
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
OutputStreamWriter writer = new OutputStreamWriter(baos, ISO_8859_1)) {
// Header row
writer.write("Monat;Einnahmen;Ausgaben;Ergebnis\n");
// Monthly rows
for (int month = 1; month <= 12; month++) {
MonthlyData md = data.monthlyData.getOrDefault(month, new MonthlyData(0, 0));
String monthName = java.time.Month.of(month)
.getDisplayName(TextStyle.FULL, GERMAN);
writer.write(sanitizeCsv(monthName) + ";"
+ formatCentsCsv(md.income) + ";"
+ formatCentsCsv(md.expense) + ";"
+ formatCentsCsv(md.income - md.expense) + "\n");
}
// Totals row
writer.write("GESAMT;"
+ formatCentsCsv(data.totalIncome) + ";"
+ formatCentsCsv(data.totalExpenses) + ";"
+ formatCentsCsv(data.totalIncome - data.totalExpenses) + "\n");
writer.flush();
return baos.toByteArray();
} catch (Exception e) {
throw new RuntimeException("Failed to generate EÜR CSV", e);
}
}
@Override
public byte[] generateJson(DateRangeReportParameters params, UUID clubId) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found: " + clubId));
List<LedgerEntry> entries = ledgerEntryRepository
.findByClubIdAndTransactionDateBetween(clubId, params.from(), params.to());
EurData data = calculateEurData(entries, params);
int year = params.year() != null ? params.year() : params.from().getYear();
// Build ELSTER-compatible JSON structure
StringBuilder json = new StringBuilder();
json.append("{\n");
json.append(" \"verein\": {\n");
json.append(" \"name\": ").append(jsonStr(club.getName())).append(",\n");
json.append(" \"registernummer\": ").append(jsonStr(club.getRegistrationNumber())).append(",\n");
json.append(" \"anschrift\": ").append(jsonStr(buildAddress(club))).append("\n");
json.append(" },\n");
json.append(" \"steuerjahr\": ").append(year).append(",\n");
json.append(" \"erstelltAm\": ").append(jsonStr(LocalDate.now().toString())).append(",\n");
// Einnahmen
json.append(" \"einnahmen\": {\n");
int incomeIdx = 0;
for (var entry : data.incomeByCategory.entrySet()) {
json.append(" ").append(jsonStr(entry.getKey())).append(": ")
.append(formatCentsJson(entry.getValue()));
json.append(++incomeIdx < data.incomeByCategory.size() ? ",\n" : "\n");
}
json.append(" },\n");
// Ausgaben
json.append(" \"ausgaben\": {\n");
int expenseIdx = 0;
for (var entry : data.expenseByCategory.entrySet()) {
json.append(" ").append(jsonStr(entry.getKey())).append(": ")
.append(formatCentsJson(entry.getValue()));
json.append(++expenseIdx < data.expenseByCategory.size() ? ",\n" : "\n");
}
json.append(" },\n");
// Zusammenfassung
json.append(" \"zusammenfassung\": {\n");
json.append(" \"einnahmenGesamt\": ").append(formatCentsJson(data.totalIncome)).append(",\n");
json.append(" \"ausgabenGesamt\": ").append(formatCentsJson(data.totalExpenses)).append(",\n");
long result = data.totalIncome - data.totalExpenses;
json.append(" \"ergebnis\": ").append(formatCentsJson(result)).append(",\n");
json.append(" \"ergebnisTyp\": ").append(jsonStr(result >= 0 ? "Überschuss" : "Fehlbetrag")).append("\n");
json.append(" },\n");
// Monthly breakdown
json.append(" \"monatsuebersicht\": [\n");
for (int month = 1; month <= 12; month++) {
MonthlyData md = data.monthlyData.getOrDefault(month, new MonthlyData(0, 0));
String monthName = java.time.Month.of(month).getDisplayName(TextStyle.FULL, GERMAN);
json.append(" { \"monat\": ").append(jsonStr(monthName))
.append(", \"einnahmen\": ").append(formatCentsJson(md.income))
.append(", \"ausgaben\": ").append(formatCentsJson(md.expense))
.append(", \"ergebnis\": ").append(formatCentsJson(md.income - md.expense))
.append(" }");
json.append(month < 12 ? ",\n" : "\n");
}
json.append(" ],\n");
json.append(" \"rechtsgrundlage\": \"§4 Abs. 3 EStG (Einnahmen-Überschuss-Rechnung)\"\n");
json.append("}\n");
return json.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);
}
// === Internal data processing ===
private EurData calculateEurData(List<LedgerEntry> entries, DateRangeReportParameters params) {
Map<String, Long> incomeByCategory = new LinkedHashMap<>();
Map<String, Long> expenseByCategory = new LinkedHashMap<>();
Map<Integer, MonthlyData> monthlyData = new TreeMap<>();
for (LedgerEntry entry : entries) {
String category = entry.getCategory() != null ? entry.getCategory() : "OTHER";
int month = entry.getTransactionDate().getMonthValue();
long amount = entry.getAmountCents() != null ? entry.getAmountCents().longValue() : 0L;
if (entry.getTransactionType() == TransactionType.INCOME) {
incomeByCategory.merge(category, amount, Long::sum);
monthlyData.computeIfAbsent(month, m -> new MonthlyData(0, 0)).income += amount;
} else {
expenseByCategory.merge(category, amount, Long::sum);
monthlyData.computeIfAbsent(month, m -> new MonthlyData(0, 0)).expense += amount;
}
}
long totalIncome = incomeByCategory.values().stream().mapToLong(Long::longValue).sum();
long totalExpenses = expenseByCategory.values().stream().mapToLong(Long::longValue).sum();
return new EurData(incomeByCategory, expenseByCategory, monthlyData, totalIncome, totalExpenses);
}
// === Formatting helpers ===
private String formatCentsDisplay(long cents) {
long euros = cents / 100;
long remainder = Math.abs(cents % 100);
return String.format(GERMAN, "%,d,%02d €", euros, remainder);
}
private String formatCentsCsv(long cents) {
long euros = cents / 100;
long remainder = Math.abs(cents % 100);
return String.format("%d,%02d", euros, remainder);
}
private String formatCentsJson(long cents) {
long euros = cents / 100;
long remainder = Math.abs(cents % 100);
return String.format("%d.%02d", euros, remainder);
}
private String sanitizeCsv(String value) {
if (value == null) return "";
// CSV injection prevention: prefix cells starting with =, +, -, @ with single-quote
if (value.startsWith("=") || value.startsWith("+") || value.startsWith("-") || value.startsWith("@")) {
return "'" + value;
}
return value.contains(";") ? "\"" + value.replace("\"", "\"\"") + "\"" : value;
}
private String getCategoryLabel(String category) {
return CATEGORY_LABELS.getOrDefault(category, category);
}
private String buildAddress(Club club) {
StringBuilder sb = new StringBuilder();
if (club.getAddressStreet() != null) sb.append(club.getAddressStreet());
if (club.getAddressPostalCode() != null || club.getAddressCity() != null) {
if (sb.length() > 0) sb.append(", ");
if (club.getAddressPostalCode() != null) sb.append(club.getAddressPostalCode()).append(" ");
if (club.getAddressCity() != null) sb.append(club.getAddressCity());
}
return sb.length() > 0 ? sb.toString() : (club.getAddress() != null ? club.getAddress() : "");
}
private String jsonStr(String value) {
if (value == null) return "null";
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
}
// === PDF helper methods ===
private void addEurRow(PdfPTable table, String label, long cents) {
PdfPCell labelCell = new PdfPCell(new Phrase(label, TABLE_CELL_FONT));
labelCell.setBorder(Rectangle.NO_BORDER);
labelCell.setPaddingBottom(3);
table.addCell(labelCell);
PdfPCell amountCell = new PdfPCell(new Phrase(formatCentsDisplay(cents), TABLE_CELL_FONT));
amountCell.setBorder(Rectangle.NO_BORDER);
amountCell.setHorizontalAlignment(Element.ALIGN_RIGHT);
amountCell.setPaddingBottom(3);
table.addCell(amountCell);
}
private void addEurTotalRow(PdfPTable table, String label, long cents) {
PdfPCell labelCell = new PdfPCell(new Phrase(label, TABLE_CELL_BOLD));
labelCell.setBorder(Rectangle.NO_BORDER);
labelCell.setPaddingBottom(5);
labelCell.setPaddingTop(3);
table.addCell(labelCell);
PdfPCell amountCell = new PdfPCell(new Phrase(formatCentsDisplay(cents), TABLE_CELL_BOLD));
amountCell.setBorder(Rectangle.NO_BORDER);
amountCell.setHorizontalAlignment(Element.ALIGN_RIGHT);
amountCell.setPaddingBottom(5);
amountCell.setPaddingTop(3);
table.addCell(amountCell);
}
private void addSeparatorRow(PdfPTable table) {
for (int i = 0; i < 2; i++) {
PdfPCell cell = new PdfPCell(new Phrase(""));
cell.setBorder(Rectangle.TOP);
cell.setBorderColor(Color.DARK_GRAY);
cell.setPaddingBottom(3);
table.addCell(cell);
}
}
private void addTableHeaderCell(PdfPTable table, String text) {
PdfPCell cell = new PdfPCell(new Phrase(text, TABLE_HEADER_FONT));
cell.setBackgroundColor(HEADER_BG);
cell.setPadding(5);
cell.setHorizontalAlignment(Element.ALIGN_CENTER);
table.addCell(cell);
}
private void addMonthCell(PdfPTable table, String text, boolean alternate) {
PdfPCell cell = new PdfPCell(new Phrase(text, TABLE_CELL_FONT));
if (alternate) cell.setBackgroundColor(LIGHT_BG);
cell.setBorder(Rectangle.NO_BORDER);
cell.setPadding(4);
table.addCell(cell);
}
private void addAmountCell(PdfPTable table, long cents, boolean alternate) {
PdfPCell cell = new PdfPCell(new Phrase(formatCentsDisplay(cents), TABLE_CELL_FONT));
if (alternate) cell.setBackgroundColor(LIGHT_BG);
cell.setBorder(Rectangle.NO_BORDER);
cell.setPadding(4);
cell.setHorizontalAlignment(Element.ALIGN_RIGHT);
table.addCell(cell);
}
// === Internal data classes ===
private record EurData(
Map<String, Long> incomeByCategory,
Map<String, Long> expenseByCategory,
Map<Integer, MonthlyData> monthlyData,
long totalIncome,
long totalExpenses
) {}
private static class MonthlyData {
long income;
long expense;
MonthlyData(long income, long expense) {
this.income = income;
this.expense = expense;
}
}
}
@@ -0,0 +1,310 @@
package de.cannamanage.service.report;
import com.lowagie.text.Chunk;
import com.lowagie.text.Document;
import com.lowagie.text.Element;
import com.lowagie.text.Font;
import com.lowagie.text.PageSize;
import com.lowagie.text.Paragraph;
import com.lowagie.text.Phrase;
import com.lowagie.text.Rectangle;
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.LedgerEntry;
import de.cannamanage.domain.enums.ExportFormat;
import de.cannamanage.domain.enums.ReportType;
import de.cannamanage.domain.enums.TransactionType;
import de.cannamanage.service.repository.ClubRepository;
import de.cannamanage.service.repository.LedgerEntryRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.io.OutputStreamWriter;
import java.nio.charset.Charset;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.*;
/**
* GoBD-compliant Kassenbuch (cash book) export generator.
* Columns: Datum, Beleg-Nr, Buchungstext, Einnahme, Ausgabe, Saldo (running balance).
* CSV: GoBD-compliant format, ISO-8859-1 encoding, semicolon delimiter.
* PDF: professional table layout with running balance and period totals.
* Legal basis: §146 AO (Ordnungsvorschriften), §147 AO (Aufbewahrungsfristen).
*/
@Service
public class KassenbuchExportGenerator implements ReportGenerator<DateRangeReportParameters> {
private static final Logger log = LoggerFactory.getLogger(KassenbuchExportGenerator.class);
private static final Font HEADER_FONT = new Font(Font.HELVETICA, 14, 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, 9, Font.NORMAL);
private static final Font TABLE_HEADER_FONT = new Font(Font.HELVETICA, 8, Font.BOLD, Color.WHITE);
private static final Font TABLE_CELL_FONT = new Font(Font.HELVETICA, 8, Font.NORMAL);
private static final Font TABLE_CELL_BOLD = new Font(Font.HELVETICA, 8, Font.BOLD);
private static final Font FOOTER_FONT = new Font(Font.HELVETICA, 7, Font.ITALIC, Color.GRAY);
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 DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private static final Charset ISO_8859_1 = Charset.forName("ISO-8859-1");
private static final Locale GERMAN = Locale.GERMANY;
private final LedgerEntryRepository ledgerEntryRepository;
private final ClubRepository clubRepository;
public KassenbuchExportGenerator(LedgerEntryRepository ledgerEntryRepository,
ClubRepository clubRepository) {
this.ledgerEntryRepository = ledgerEntryRepository;
this.clubRepository = clubRepository;
}
@Override
public ReportType getType() {
return ReportType.KASSENBUCH_EXPORT;
}
@Override
public Set<ExportFormat> supportedFormats() {
return Set.of(ExportFormat.PDF, ExportFormat.CSV);
}
@Override
public byte[] generatePdf(DateRangeReportParameters params, UUID clubId) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found: " + clubId));
List<LedgerEntry> entries = getOrderedEntries(clubId, params);
long openingBalance = calculateOpeningBalance(clubId, params.from());
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
// Landscape A4 for wide table
Document document = new Document(PageSize.A4.rotate(), 40, 40, 40, 40);
PdfWriter.getInstance(document, baos);
document.open();
// Header
Paragraph header = new Paragraph("KASSENBUCH", HEADER_FONT);
header.setAlignment(Element.ALIGN_CENTER);
document.add(header);
Paragraph subtitle = new Paragraph();
subtitle.setAlignment(Element.ALIGN_CENTER);
subtitle.add(new Chunk(club.getName(), SUBTITLE_FONT));
subtitle.add(Chunk.NEWLINE);
subtitle.add(new Chunk("Zeitraum: " + params.from().format(DATE_FMT)
+ " bis " + params.to().format(DATE_FMT), NORMAL_FONT));
document.add(subtitle);
document.add(Chunk.NEWLINE);
// Opening balance
Paragraph openingPara = new Paragraph(
"Anfangsbestand: " + formatCentsDisplay(openingBalance), SUBTITLE_FONT);
document.add(openingPara);
document.add(Chunk.NEWLINE);
// Table
PdfPTable table = new PdfPTable(6);
table.setWidthPercentage(100);
table.setWidths(new float[]{1.2f, 1f, 3.5f, 1.2f, 1.2f, 1.4f});
// Table headers
addHeaderCell(table, "Datum");
addHeaderCell(table, "Beleg-Nr");
addHeaderCell(table, "Buchungstext");
addHeaderCell(table, "Einnahme");
addHeaderCell(table, "Ausgabe");
addHeaderCell(table, "Saldo");
long runningBalance = openingBalance;
long totalIncome = 0;
long totalExpenses = 0;
int rowIndex = 0;
for (LedgerEntry entry : entries) {
boolean alternate = rowIndex % 2 == 1;
long income = 0;
long expense = 0;
if (entry.getTransactionType() == TransactionType.INCOME) {
income = entry.getAmountCents() != null ? entry.getAmountCents().longValue() : 0L;
runningBalance += income;
totalIncome += income;
} else {
expense = entry.getAmountCents() != null ? entry.getAmountCents().longValue() : 0L;
runningBalance -= expense;
totalExpenses += expense;
}
addDataCell(table, entry.getTransactionDate().format(DATE_FMT), alternate, false);
addDataCell(table, buildBelegNr(entry, rowIndex + 1), alternate, false);
addDataCell(table, entry.getDescription(), alternate, false);
addDataCell(table, income > 0 ? formatCentsDisplay(income) : "", alternate, false);
addDataCell(table, expense > 0 ? formatCentsDisplay(expense) : "", alternate, false);
addDataCell(table, formatCentsDisplay(runningBalance), alternate, true);
rowIndex++;
}
// Totals row
addTotalCell(table, "SUMME");
addTotalCell(table, "");
addTotalCell(table, rowIndex + " Buchungen");
addTotalCell(table, formatCentsDisplay(totalIncome));
addTotalCell(table, formatCentsDisplay(totalExpenses));
addTotalCell(table, formatCentsDisplay(runningBalance));
document.add(table);
document.add(Chunk.NEWLINE);
// Footer
Paragraph footer = new Paragraph(
"Aufbewahrungspflicht: 10 Jahre gemäß §147 AO. " +
"Erstellt am " + LocalDate.now().format(DATE_FMT) + ".",
FOOTER_FONT);
footer.setAlignment(Element.ALIGN_CENTER);
document.add(footer);
document.close();
log.info("Generated Kassenbuch PDF for club {}, {} entries, size={} bytes",
clubId, entries.size(), baos.size());
return baos.toByteArray();
} catch (Exception e) {
throw new RuntimeException("Failed to generate Kassenbuch PDF", e);
}
}
@Override
public byte[] generateCsv(DateRangeReportParameters params, UUID clubId) {
List<LedgerEntry> entries = getOrderedEntries(clubId, params);
long openingBalance = calculateOpeningBalance(clubId, params.from());
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
OutputStreamWriter writer = new OutputStreamWriter(baos, ISO_8859_1)) {
// GoBD-compliant CSV header
writer.write("Datum;Beleg-Nr;Buchungstext;Einnahme;Ausgabe;Saldo\n");
long runningBalance = openingBalance;
int rowIndex = 0;
for (LedgerEntry entry : entries) {
rowIndex++;
long income = 0;
long expense = 0;
if (entry.getTransactionType() == TransactionType.INCOME) {
income = entry.getAmountCents() != null ? entry.getAmountCents().longValue() : 0L;
runningBalance += income;
} else {
expense = entry.getAmountCents() != null ? entry.getAmountCents().longValue() : 0L;
runningBalance -= expense;
}
writer.write(entry.getTransactionDate().format(DATE_FMT) + ";");
writer.write(sanitizeCsv(buildBelegNr(entry, rowIndex)) + ";");
writer.write(sanitizeCsv(entry.getDescription()) + ";");
writer.write(formatCentsCsv(income) + ";");
writer.write(formatCentsCsv(expense) + ";");
writer.write(formatCentsCsv(runningBalance) + "\n");
}
writer.flush();
log.info("Generated Kassenbuch CSV for club {}, {} entries", clubId, entries.size());
return baos.toByteArray();
} catch (Exception e) {
throw new RuntimeException("Failed to generate Kassenbuch CSV", e);
}
}
// === Internal helpers ===
private List<LedgerEntry> getOrderedEntries(UUID clubId, DateRangeReportParameters params) {
List<LedgerEntry> entries = ledgerEntryRepository
.findByClubIdAndTransactionDateBetween(clubId, params.from(), params.to());
// Sort by date then by creation (id natural order for sequential numbering)
entries.sort(Comparator.comparing(LedgerEntry::getTransactionDate)
.thenComparing(LedgerEntry::getId));
return entries;
}
private long calculateOpeningBalance(UUID clubId, LocalDate from) {
// Balance as of the day before the period starts
LocalDate dayBefore = from.minusDays(1);
return ledgerEntryRepository.calculateBalance(clubId, dayBefore);
}
/**
* Build sequential Beleg-Nr. GoBD requires sequential numbering with no gaps.
* Format: YYYY-NNNN (year + sequential number within export).
*/
private String buildBelegNr(LedgerEntry entry, int sequentialNumber) {
if (entry.getReference() != null && !entry.getReference().isBlank()) {
return entry.getReference();
}
int year = entry.getTransactionDate().getYear();
return String.format("%d-%04d", year, sequentialNumber);
}
private String formatCentsDisplay(long cents) {
long euros = cents / 100;
long remainder = Math.abs(cents % 100);
return String.format(GERMAN, "%,d,%02d €", euros, remainder);
}
private String formatCentsCsv(long cents) {
long euros = cents / 100;
long remainder = Math.abs(cents % 100);
return String.format("%d,%02d", euros, remainder);
}
/**
* CSV injection prevention: prefix cells starting with =, +, -, @ with single-quote.
*/
private String sanitizeCsv(String value) {
if (value == null) return "";
if (value.startsWith("=") || value.startsWith("+") || value.startsWith("-") || value.startsWith("@")) {
return "'" + value;
}
// Escape semicolons and quotes
if (value.contains(";") || value.contains("\"") || value.contains("\n")) {
return "\"" + value.replace("\"", "\"\"") + "\"";
}
return value;
}
// === PDF cell helpers ===
private void addHeaderCell(PdfPTable table, String text) {
PdfPCell cell = new PdfPCell(new Phrase(text, TABLE_HEADER_FONT));
cell.setBackgroundColor(HEADER_BG);
cell.setPadding(5);
cell.setHorizontalAlignment(Element.ALIGN_CENTER);
table.addCell(cell);
}
private void addDataCell(PdfPTable table, String text, boolean alternate, boolean bold) {
PdfPCell cell = new PdfPCell(new Phrase(text, bold ? TABLE_CELL_BOLD : TABLE_CELL_FONT));
if (alternate) cell.setBackgroundColor(LIGHT_BG);
cell.setPadding(3);
cell.setBorder(Rectangle.BOTTOM);
cell.setBorderColor(Color.LIGHT_GRAY);
table.addCell(cell);
}
private void addTotalCell(PdfPTable table, String text) {
PdfPCell cell = new PdfPCell(new Phrase(text, TABLE_CELL_BOLD));
cell.setBackgroundColor(new Color(230, 230, 230));
cell.setPadding(5);
cell.setBorder(Rectangle.TOP);
cell.setBorderColor(Color.DARK_GRAY);
table.addCell(cell);
}
}
@@ -0,0 +1,12 @@
package de.cannamanage.service.report;
import java.util.UUID;
/**
* Parameters for member-specific reports (Beitragsbescheinigung, etc.).
*/
public record MemberReportParameters(
UUID memberId,
int year
) implements ReportParameters {
}