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:
+115
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user