From a29c38756cbd2a98f17d4bd14eacf9734e457056 Mon Sep 17 00:00:00 2001 From: Patrick Plate Date: Mon, 15 Jun 2026 12:22:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(sprint9):=20Phase=202=20=E2=80=94=20Financ?= =?UTF-8?q?ial=20report=20generators=20(E=C3=9CR,=20Kassenbuch,=20Beitrags?= =?UTF-8?q?bescheinigung)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../api/controller/ReportController.java | 33 +- .../service/ReportGeneratorService.java | 12 + .../BeitragsbescheinigungGenerator.java | 301 +++++++++++ .../service/report/EurReportGenerator.java | 489 ++++++++++++++++++ .../report/KassenbuchExportGenerator.java | 310 +++++++++++ .../report/MemberReportParameters.java | 12 + 6 files changed, 1155 insertions(+), 2 deletions(-) create mode 100644 cannamanage-service/src/main/java/de/cannamanage/service/report/BeitragsbescheinigungGenerator.java create mode 100644 cannamanage-service/src/main/java/de/cannamanage/service/report/EurReportGenerator.java create mode 100644 cannamanage-service/src/main/java/de/cannamanage/service/report/KassenbuchExportGenerator.java create mode 100644 cannamanage-service/src/main/java/de/cannamanage/service/report/MemberReportParameters.java diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/ReportController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/ReportController.java index cc2ea26..cc7a0c3 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/controller/ReportController.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/ReportController.java @@ -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>> listReportTypes() { + Map> availableTypes = reportGeneratorService.getAvailableTypes(); + + List> response = new ArrayList<>(); + for (var entry : availableTypes.entrySet()) { + Map 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); } /** diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/ReportGeneratorService.java b/cannamanage-service/src/main/java/de/cannamanage/service/ReportGeneratorService.java index 3f81292..9f957d6 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/ReportGeneratorService.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/ReportGeneratorService.java @@ -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> getAvailableTypes() { + Map> result = new java.util.LinkedHashMap<>(); + generators.forEach((type, generator) -> + result.put(type, generator.supportedFormats())); + return result; + } } diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/report/BeitragsbescheinigungGenerator.java b/cannamanage-service/src/main/java/de/cannamanage/service/report/BeitragsbescheinigungGenerator.java new file mode 100644 index 0000000..e19d8b5 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/report/BeitragsbescheinigungGenerator.java @@ -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 { + + 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 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 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); + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/report/EurReportGenerator.java b/cannamanage-service/src/main/java/de/cannamanage/service/report/EurReportGenerator.java new file mode 100644 index 0000000..2bb885a --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/report/EurReportGenerator.java @@ -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 { + + 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 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 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 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 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 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 entries, DateRangeReportParameters params) { + Map incomeByCategory = new LinkedHashMap<>(); + Map expenseByCategory = new LinkedHashMap<>(); + Map 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 incomeByCategory, + Map expenseByCategory, + Map monthlyData, + long totalIncome, + long totalExpenses + ) {} + + private static class MonthlyData { + long income; + long expense; + + MonthlyData(long income, long expense) { + this.income = income; + this.expense = expense; + } + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/report/KassenbuchExportGenerator.java b/cannamanage-service/src/main/java/de/cannamanage/service/report/KassenbuchExportGenerator.java new file mode 100644 index 0000000..b9e3a96 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/report/KassenbuchExportGenerator.java @@ -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 { + + 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 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 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 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 getOrderedEntries(UUID clubId, DateRangeReportParameters params) { + List 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); + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/report/MemberReportParameters.java b/cannamanage-service/src/main/java/de/cannamanage/service/report/MemberReportParameters.java new file mode 100644 index 0000000..610539f --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/report/MemberReportParameters.java @@ -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 { +}