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

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

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

Navigation & i18n:
- Added Finanzen (Wallet icon) to admin sidebar
- Portal finance page for member payments
- Comprehensive de.json + en.json finance keys (~100 translations)
This commit is contained in:
Patrick Plate
2026-06-15 08:24:43 +02:00
parent 721503b231
commit 3211ade5be
13 changed files with 2419 additions and 1 deletions
@@ -6,13 +6,18 @@ import de.cannamanage.domain.entity.*;
import de.cannamanage.domain.enums.PaymentStatus;
import de.cannamanage.domain.enums.StaffPermission;
import de.cannamanage.service.FinanceService;
import de.cannamanage.service.FinancialReportService;
import de.cannamanage.service.ReceiptPdfService;
import de.cannamanage.service.repository.ClubRepository;
import de.cannamanage.service.repository.MemberRepository;
import de.cannamanage.service.repository.PaymentRepository;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
@@ -33,13 +38,22 @@ public class FinanceController {
private final FinanceService financeService;
private final StaffPermissionChecker permissionChecker;
private final MemberRepository memberRepository;
private final ReceiptPdfService receiptPdfService;
private final FinancialReportService financialReportService;
private final ClubRepository clubRepository;
public FinanceController(FinanceService financeService,
StaffPermissionChecker permissionChecker,
MemberRepository memberRepository) {
MemberRepository memberRepository,
ReceiptPdfService receiptPdfService,
FinancialReportService financialReportService,
ClubRepository clubRepository) {
this.financeService = financeService;
this.permissionChecker = permissionChecker;
this.memberRepository = memberRepository;
this.receiptPdfService = receiptPdfService;
this.financialReportService = financialReportService;
this.clubRepository = clubRepository;
}
// === Fee Schedules ===
@@ -243,6 +257,106 @@ public class FinanceController {
return ResponseEntity.ok(financeService.getMemberBalance(clubId, memberId));
}
// === Receipt PDF Download ===
@GetMapping("/finance/payments/{id}/receipt")
public ResponseEntity<byte[]> downloadReceipt(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
Payment payment = financeService.getPaymentById(id)
.orElseThrow(() -> new NoSuchElementException("Payment not found: " + id));
Member member = memberRepository.findById(payment.getMemberId())
.orElseThrow(() -> new NoSuchElementException("Member not found"));
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found"));
byte[] pdf = receiptPdfService.generateReceipt(payment, member, club);
String filename = "Quittung-" + (payment.getReceiptNumber() != null
? payment.getReceiptNumber() : id.toString().substring(0, 8)) + ".pdf";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.APPLICATION_PDF)
.contentLength(pdf.length)
.body(pdf);
}
// === Annual Report PDF ===
@GetMapping("/finance/reports/annual")
public ResponseEntity<byte[]> downloadAnnualReport(@RequestParam int year,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found"));
FinancialReportService.AnnualReportData reportData = financeService.buildAnnualReportData(clubId, year);
byte[] pdf = financialReportService.generateAnnualReport(reportData, club);
String filename = "Jahresabschluss-" + year + "-" + club.getName().replaceAll("\\s+", "_") + ".pdf";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.APPLICATION_PDF)
.contentLength(pdf.length)
.body(pdf);
}
// === Kassenbuch CSV Export ===
@GetMapping("/finance/ledger/export")
public ResponseEntity<byte[]> exportLedgerCsv(@RequestParam LocalDate from,
@RequestParam LocalDate to,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
byte[] csv = financeService.exportLedgerCsv(clubId, from, to);
String filename = "Kassenbuch-" + from + "-" + to + ".csv";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.parseMediaType("text/csv; charset=ISO-8859-1"))
.contentLength(csv.length)
.body(csv);
}
// === Portal: Receipt download (own payments only) ===
@GetMapping("/portal/finance/payments/{id}/receipt")
public ResponseEntity<byte[]> downloadMyReceipt(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails principal) {
UUID userId = UUID.fromString(principal.getUsername());
UUID clubId = TenantContext.getCurrentTenant();
UUID memberId = getMemberIdForUser(userId, clubId);
Payment payment = financeService.getPaymentById(id)
.orElseThrow(() -> new NoSuchElementException("Payment not found: " + id));
// Verify payment belongs to the requesting member
if (!payment.getMemberId().equals(memberId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new NoSuchElementException("Member not found"));
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found"));
byte[] pdf = receiptPdfService.generateReceipt(payment, member, club);
String filename = "Quittung-" + (payment.getReceiptNumber() != null
? payment.getReceiptNumber() : id.toString().substring(0, 8)) + ".pdf";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.APPLICATION_PDF)
.contentLength(pdf.length)
.body(pdf);
}
private UUID getMemberIdForUser(UUID userId, UUID clubId) {
return memberRepository.findByUserId(userId)
.map(Member::getId)