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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+301
@@ -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);
|
||||
}
|
||||
}
|
||||
+489
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+310
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
@@ -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 {
|
||||
}
|
||||
Reference in New Issue
Block a user