From 3211ade5be4426297a9d876aa95974406333b73e Mon Sep 17 00:00:00 2001 From: Patrick Plate Date: Mon, 15 Jun 2026 08:24:43 +0200 Subject: [PATCH] =?UTF-8?q?feat(sprint8):=20Phase=202=20=E2=80=94=20Treasu?= =?UTF-8?q?ry=20frontend=20+=20PDF=20receipts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../api/controller/FinanceController.java | 116 ++++- cannamanage-frontend/messages/de.json | 110 +++++ cannamanage-frontend/messages/en.json | 110 +++++ .../finance/fee-schedules/page.tsx | 181 +++++++ .../finance/kassenbuch/page.tsx | 157 ++++++ .../app/(dashboard-layout)/finance/page.tsx | 457 ++++++++++++++++++ .../finance/payments/page.tsx | 169 +++++++ .../finance/reports/page.tsx | 80 +++ .../(portal-layout)/portal/finance/page.tsx | 138 ++++++ cannamanage-frontend/src/data/navigations.ts | 5 + cannamanage-frontend/src/services/finance.ts | 399 +++++++++++++++ .../service/FinancialReportService.java | 286 +++++++++++ .../service/ReceiptPdfService.java | 212 ++++++++ 13 files changed, 2419 insertions(+), 1 deletion(-) create mode 100644 cannamanage-frontend/src/app/(dashboard-layout)/finance/fee-schedules/page.tsx create mode 100644 cannamanage-frontend/src/app/(dashboard-layout)/finance/kassenbuch/page.tsx create mode 100644 cannamanage-frontend/src/app/(dashboard-layout)/finance/page.tsx create mode 100644 cannamanage-frontend/src/app/(dashboard-layout)/finance/payments/page.tsx create mode 100644 cannamanage-frontend/src/app/(dashboard-layout)/finance/reports/page.tsx create mode 100644 cannamanage-frontend/src/app/(portal-layout)/portal/finance/page.tsx create mode 100644 cannamanage-frontend/src/services/finance.ts create mode 100644 cannamanage-service/src/main/java/de/cannamanage/service/FinancialReportService.java create mode 100644 cannamanage-service/src/main/java/de/cannamanage/service/ReceiptPdfService.java diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/FinanceController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/FinanceController.java index cc97f2c..3dfaf8f 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/controller/FinanceController.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/FinanceController.java @@ -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 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 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 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 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) diff --git a/cannamanage-frontend/messages/de.json b/cannamanage-frontend/messages/de.json index f74d6e5..3eee8fd 100644 --- a/cannamanage-frontend/messages/de.json +++ b/cannamanage-frontend/messages/de.json @@ -839,5 +839,115 @@ "topicLocked": "Dieses Thema ist gesperrt. Neue Antworten sind nicht möglich.", "reportReason": "Grund der Meldung:", "backToTopics": "Zurück zur Übersicht" + }, + "finance": { + "title": "Vereinsfinanzen", + "subtitle": "Übersicht über Einnahmen, Ausgaben und Mitgliedsbeiträge", + "totalBalance": "Gesamtsaldo", + "incomeThisMonth": "Einnahmen (Monat)", + "expensesThisMonth": "Ausgaben (Monat)", + "outstandingMembers": "Offene Beiträge", + "outstandingTitle": "Säumige Mitglieder", + "monthsOverdue": "Monate überfällig", + "noOutstanding": "Keine offenen Beiträge", + "recentTransactions": "Letzte Buchungen", + "noTransactions": "Keine Buchungen vorhanden", + "recordPayment": "Zahlung erfassen", + "recordExpense": "Ausgabe erfassen", + "payments": "Zahlungen", + "kassenbuch": "Kassenbuch", + "feeSchedules": "Beitragsordnung", + "reports": "Berichte", + "exportCsv": "CSV exportieren", + "from": "Von", + "to": "Bis", + "date": "Datum", + "type": "Typ", + "description": "Beschreibung", + "income": "Einnahme", + "expense": "Ausgabe", + "balance": "Saldo", + "incomeLabel": "Einnahme", + "expenseLabel": "Ausgabe", + "loading": "Laden...", + "previous": "Zurück", + "next": "Weiter", + "member": "Mitglied", + "amount": "Betrag", + "period": "Zeitraum", + "paymentMethod": "Zahlungsart", + "actions": "Aktionen", + "filterByStatus": "Nach Status filtern", + "allStatuses": "Alle", + "noPayments": "Keine Zahlungen vorhanden", + "voidReason": "Grund für die Stornierung:", + "memberIdPlaceholder": "Mitglieds-ID eingeben", + "periodFrom": "Zeitraum von", + "periodTo": "Zeitraum bis", + "reference": "Referenz", + "referencePlaceholder": "z.B. Überweisungsreferenz", + "expenseReferencePlaceholder": "z.B. Rechnungsnummer", + "saving": "Wird gespeichert...", + "category": "Kategorie", + "descriptionPlaceholder": "z.B. Stromrechnung Juni", + "bankTransfer": "Überweisung", + "cash": "Bar", + "sepa": "SEPA-Lastschrift", + "card": "Kartenzahlung", + "createFeeSchedule": "Beitragssatz erstellen", + "scheduleName": "Bezeichnung", + "scheduleNamePlaceholder": "z.B. Monatsbeitrag Standard", + "intervalLabel": "Intervall", + "scheduleDescPlaceholder": "Optionale Beschreibung", + "default": "Standard", + "deactivate": "Deaktivieren", + "noFeeSchedules": "Keine Beitragssätze vorhanden", + "annualReport": "Jahresabschluss", + "annualReportDescription": "Vollständiger Finanzbericht mit Einnahmen, Ausgaben und Mitgliederbeiträgen.", + "auditorReport": "Kassenprüfbericht", + "auditorReportDescription": "Bericht für den Kassenprüfer mit allen Transaktionsdetails.", + "downloadPdf": "PDF herunterladen", + "comingSoon": "Demnächst verfügbar", + "status": { + "label": "Status", + "paid": "Bezahlt", + "pending": "Ausstehend", + "overdue": "Überfällig", + "voided": "Storniert" + }, + "method": { + "BANK_TRANSFER": "Überweisung", + "CASH": "Bar", + "SEPA": "SEPA-Lastschrift", + "CARD": "Kartenzahlung" + }, + "interval": { + "MONTHLY": "Monatlich", + "QUARTERLY": "Vierteljährlich", + "YEARLY": "Jährlich", + "ONE_TIME": "Einmalig" + }, + "categories": { + "rent": "Miete", + "utilities": "Nebenkosten", + "equipment": "Ausstattung", + "seeds": "Saatgut", + "supplies": "Verbrauchsmaterial", + "insurance": "Versicherung", + "legal": "Rechtsberatung", + "other": "Sonstiges" + }, + "portal": { + "title": "Meine Zahlungen", + "currentBalance": "Aktueller Saldo", + "allPaid": "Alle Beiträge bezahlt", + "paymentDue": "Zahlung fällig", + "noFeeAssigned": "Kein Beitragssatz zugewiesen", + "feeSchedule": "Beitragssatz", + "noFee": "Keiner", + "lastPayment": "Letzte Zahlung", + "paymentHistory": "Zahlungshistorie", + "noPayments": "Noch keine Zahlungen vorhanden" + } } } diff --git a/cannamanage-frontend/messages/en.json b/cannamanage-frontend/messages/en.json index c099d51..d53cb28 100644 --- a/cannamanage-frontend/messages/en.json +++ b/cannamanage-frontend/messages/en.json @@ -839,5 +839,115 @@ "topicLocked": "This topic is locked. New replies are not possible.", "reportReason": "Reason for report:", "backToTopics": "Back to overview" + }, + "finance": { + "title": "Club Finances", + "subtitle": "Overview of income, expenses, and member contributions", + "totalBalance": "Total Balance", + "incomeThisMonth": "Income (Month)", + "expensesThisMonth": "Expenses (Month)", + "outstandingMembers": "Outstanding Fees", + "outstandingTitle": "Outstanding Members", + "monthsOverdue": "months overdue", + "noOutstanding": "No outstanding fees", + "recentTransactions": "Recent Transactions", + "noTransactions": "No transactions available", + "recordPayment": "Record Payment", + "recordExpense": "Record Expense", + "payments": "Payments", + "kassenbuch": "Cash Book", + "feeSchedules": "Fee Schedules", + "reports": "Reports", + "exportCsv": "Export CSV", + "from": "From", + "to": "To", + "date": "Date", + "type": "Type", + "description": "Description", + "income": "Income", + "expense": "Expense", + "balance": "Balance", + "incomeLabel": "Income", + "expenseLabel": "Expense", + "loading": "Loading...", + "previous": "Previous", + "next": "Next", + "member": "Member", + "amount": "Amount", + "period": "Period", + "paymentMethod": "Payment Method", + "actions": "Actions", + "filterByStatus": "Filter by status", + "allStatuses": "All", + "noPayments": "No payments available", + "voidReason": "Reason for voiding:", + "memberIdPlaceholder": "Enter member ID", + "periodFrom": "Period from", + "periodTo": "Period to", + "reference": "Reference", + "referencePlaceholder": "e.g. transfer reference", + "expenseReferencePlaceholder": "e.g. invoice number", + "saving": "Saving...", + "category": "Category", + "descriptionPlaceholder": "e.g. Electricity bill June", + "bankTransfer": "Bank Transfer", + "cash": "Cash", + "sepa": "SEPA Direct Debit", + "card": "Card Payment", + "createFeeSchedule": "Create Fee Schedule", + "scheduleName": "Name", + "scheduleNamePlaceholder": "e.g. Standard Monthly Fee", + "intervalLabel": "Interval", + "scheduleDescPlaceholder": "Optional description", + "default": "Default", + "deactivate": "Deactivate", + "noFeeSchedules": "No fee schedules available", + "annualReport": "Annual Report", + "annualReportDescription": "Complete financial report with income, expenses, and member contributions.", + "auditorReport": "Auditor Report", + "auditorReportDescription": "Report for the auditor with all transaction details.", + "downloadPdf": "Download PDF", + "comingSoon": "Coming soon", + "status": { + "label": "Status", + "paid": "Paid", + "pending": "Pending", + "overdue": "Overdue", + "voided": "Voided" + }, + "method": { + "BANK_TRANSFER": "Bank Transfer", + "CASH": "Cash", + "SEPA": "SEPA Direct Debit", + "CARD": "Card Payment" + }, + "interval": { + "MONTHLY": "Monthly", + "QUARTERLY": "Quarterly", + "YEARLY": "Yearly", + "ONE_TIME": "One-time" + }, + "categories": { + "rent": "Rent", + "utilities": "Utilities", + "equipment": "Equipment", + "seeds": "Seeds", + "supplies": "Supplies", + "insurance": "Insurance", + "legal": "Legal", + "other": "Other" + }, + "portal": { + "title": "My Payments", + "currentBalance": "Current Balance", + "allPaid": "All fees paid", + "paymentDue": "Payment due", + "noFeeAssigned": "No fee schedule assigned", + "feeSchedule": "Fee Schedule", + "noFee": "None", + "lastPayment": "Last payment", + "paymentHistory": "Payment History", + "noPayments": "No payments yet" + } } } diff --git a/cannamanage-frontend/src/app/(dashboard-layout)/finance/fee-schedules/page.tsx b/cannamanage-frontend/src/app/(dashboard-layout)/finance/fee-schedules/page.tsx new file mode 100644 index 0000000..b1a6237 --- /dev/null +++ b/cannamanage-frontend/src/app/(dashboard-layout)/finance/fee-schedules/page.tsx @@ -0,0 +1,181 @@ +"use client" + +import { useState } from "react" +import { + formatAmountCents, + useCreateFeeScheduleMutation, + useDeactivateFeeScheduleMutation, + useFeeSchedulesQuery, +} from "@/services/finance" +import { useTranslations } from "next-intl" +import { Plus, Power } from "lucide-react" + +import type { CreateFeeScheduleRequest, FeeInterval } from "@/services/finance" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Select } from "@/components/ui/select" + +export default function FeeSchedulesPage() { + const t = useTranslations("finance") + const { data: schedules, isLoading } = useFeeSchedulesQuery() + const createMutation = useCreateFeeScheduleMutation() + const deactivateMutation = useDeactivateFeeScheduleMutation() + const [showCreate, setShowCreate] = useState(false) + + return ( +
+
+

{t("feeSchedules")}

+ + + + + + + {t("createFeeSchedule")} + + { + createMutation.mutate(data, { + onSuccess: () => setShowCreate(false), + }) + }} + isLoading={createMutation.isPending} + /> + + +
+ +
+ {schedules?.map((schedule) => ( + + + {schedule.name} + {schedule.isDefault && ( + {t("default")} + )} + + +
+
+ {formatAmountCents(schedule.amountCents)} +
+
+ {t(`interval.${schedule.interval}`)} +
+ {schedule.description && ( +

+ {schedule.description} +

+ )} +
+ +
+
+
+
+ ))} + {!schedules?.length && !isLoading && ( +

+ {t("noFeeSchedules")} +

+ )} +
+
+ ) +} + +function FeeScheduleForm({ + onSubmit, + isLoading, +}: { + onSubmit: (data: CreateFeeScheduleRequest) => void + isLoading: boolean +}) { + const t = useTranslations("finance") + const [name, setName] = useState("") + const [amount, setAmount] = useState("") + const [interval, setInterval] = useState("MONTHLY") + const [description, setDescription] = useState("") + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + onSubmit({ + name, + amountCents: Math.round(parseFloat(amount) * 100), + interval, + description: description || undefined, + }) + } + + return ( +
+
+ + setName(e.target.value)} + placeholder={t("scheduleNamePlaceholder")} + required + /> +
+
+ + setAmount(e.target.value)} + placeholder="30.00" + required + /> +
+
+ + +
+
+ + setDescription(e.target.value)} + placeholder={t("scheduleDescPlaceholder")} + /> +
+ +
+ ) +} diff --git a/cannamanage-frontend/src/app/(dashboard-layout)/finance/kassenbuch/page.tsx b/cannamanage-frontend/src/app/(dashboard-layout)/finance/kassenbuch/page.tsx new file mode 100644 index 0000000..8f6a81d --- /dev/null +++ b/cannamanage-frontend/src/app/(dashboard-layout)/finance/kassenbuch/page.tsx @@ -0,0 +1,157 @@ +"use client" + +import { useState } from "react" +import { + formatAmountCents, + getKassenbuchCsvDownloadUrl, + useLedgerQuery, +} from "@/services/finance" +import { endOfMonth, format, startOfMonth } from "date-fns" +import { useTranslations } from "next-intl" +import { Download, TrendingDown, TrendingUp } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" + +export default function KassenbuchPage() { + const t = useTranslations("finance") + const now = new Date() + const [from, setFrom] = useState(format(startOfMonth(now), "yyyy-MM-dd")) + const [to, setTo] = useState(format(endOfMonth(now), "yyyy-MM-dd")) + const [page, setPage] = useState(0) + + const { data: ledger, isLoading } = useLedgerQuery(from, to, { + page, + size: 50, + }) + + return ( +
+
+

{t("kassenbuch")}

+ + + +
+ + {/* Date range picker */} + + +
+ + setFrom(e.target.value)} + /> +
+
+ + setTo(e.target.value)} + /> +
+
+
+ + {/* Ledger table */} + + +
+ + + + + + + + + + + + + {ledger?.content?.map((entry) => ( + + + + + + + + + ))} + {(!ledger?.content || ledger.content.length === 0) && ( + + + + )} + +
{t("date")}{t("type")} + {t("description")} + {t("income")}{t("expense")}{t("balance")}
{entry.transactionDate} + + {entry.transactionType === "INCOME" ? ( + + ) : ( + + )} + {entry.transactionType === "INCOME" + ? t("incomeLabel") + : t("expenseLabel")} + + {entry.description} + {entry.transactionType === "INCOME" + ? formatAmountCents(entry.amountCents) + : ""} + + {entry.transactionType === "EXPENSE" + ? formatAmountCents(entry.amountCents) + : ""} + + {entry.runningBalanceCents != null + ? formatAmountCents(entry.runningBalanceCents) + : "—"} +
+ {isLoading ? t("loading") : t("noTransactions")} +
+
+
+
+ + {/* Pagination */} + {ledger && ledger.totalPages > 1 && ( +
+ + + {page + 1} / {ledger.totalPages} + + +
+ )} +
+ ) +} diff --git a/cannamanage-frontend/src/app/(dashboard-layout)/finance/page.tsx b/cannamanage-frontend/src/app/(dashboard-layout)/finance/page.tsx new file mode 100644 index 0000000..906dea6 --- /dev/null +++ b/cannamanage-frontend/src/app/(dashboard-layout)/finance/page.tsx @@ -0,0 +1,457 @@ +"use client" + +import { useState } from "react" +import { + formatAmountCents, + getKassenbuchCsvDownloadUrl, + useFinancialSummaryQuery, + useLedgerQuery, + useOutstandingMembersQuery, + useRecordExpenseMutation, + useRecordPaymentMutation, +} from "@/services/finance" +import { endOfMonth, format, startOfMonth } from "date-fns" +import { useTranslations } from "next-intl" +import { + AlertTriangle, + Banknote, + Download, + Plus, + TrendingDown, + TrendingUp, +} from "lucide-react" + +import type { + ExpenseCategory, + PaymentMethod, + RecordExpenseRequest, + RecordPaymentRequest, +} from "@/services/finance" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Select } from "@/components/ui/select" + +export default function FinancePage() { + const t = useTranslations("finance") + const now = new Date() + const monthStart = format(startOfMonth(now), "yyyy-MM-dd") + const monthEnd = format(endOfMonth(now), "yyyy-MM-dd") + + const { data: summary } = useFinancialSummaryQuery(monthStart, monthEnd) + const { data: outstanding } = useOutstandingMembersQuery() + const { data: ledger } = useLedgerQuery(monthStart, monthEnd, { + page: 0, + size: 10, + }) + + const [showPaymentDialog, setShowPaymentDialog] = useState(false) + const [showExpenseDialog, setShowExpenseDialog] = useState(false) + + const recordPayment = useRecordPaymentMutation() + const recordExpense = useRecordExpenseMutation() + + return ( +
+
+
+

{t("title")}

+

{t("subtitle")}

+
+
+ + + + + + + {t("recordPayment")} + + { + recordPayment.mutate(data, { + onSuccess: () => setShowPaymentDialog(false), + }) + }} + isLoading={recordPayment.isPending} + /> + + + + + + + + + + {t("recordExpense")} + + { + recordExpense.mutate(data, { + onSuccess: () => setShowExpenseDialog(false), + }) + }} + isLoading={recordExpense.isPending} + /> + + +
+
+ + {/* KPI Cards */} +
+ + + + {t("totalBalance")} + + + + +
+ {summary ? formatAmountCents(summary.netBalanceCents) : "—"} +
+
+
+ + + + {t("incomeThisMonth")} + + + + +
+ {summary ? formatAmountCents(summary.incomeThisMonthCents) : "—"} +
+
+
+ + + + {t("expensesThisMonth")} + + + + +
+ {summary + ? formatAmountCents(summary.expensesThisMonthCents) + : "—"} +
+
+
+ + + + {t("outstandingMembers")} + + + + +
+ {summary ? summary.outstandingMembersCount : "—"} +
+
+
+
+ + {/* Recent Transactions & Outstanding */} +
+ + + {t("recentTransactions")} + + + + + + {ledger?.content && ledger.content.length > 0 ? ( +
+ {ledger.content.map((entry) => ( +
+
+
+ {entry.transactionType === "INCOME" ? ( + + ) : ( + + )} +
+
+

+ {entry.description} +

+

+ {entry.transactionDate} +

+
+
+ + {entry.transactionType === "INCOME" ? "+" : "-"} + {formatAmountCents(entry.amountCents)} + +
+ ))} +
+ ) : ( +

+ {t("noTransactions")} +

+ )} +
+
+ + + + {t("outstandingTitle")} + + + {outstanding && outstanding.length > 0 ? ( +
+ {outstanding.slice(0, 5).map((member) => ( +
+
+

{member.memberName}

+

+ {member.monthsOverdue} {t("monthsOverdue")} +

+
+ + {formatAmountCents(member.outstandingCents)} + +
+ ))} +
+ ) : ( +

+ {t("noOutstanding")} +

+ )} +
+
+
+
+ ) +} + +function PaymentForm({ + onSubmit, + isLoading, +}: { + onSubmit: (data: RecordPaymentRequest) => void + isLoading: boolean +}) { + const t = useTranslations("finance") + const [memberId, setMemberId] = useState("") + const [amount, setAmount] = useState("") + const [method, setMethod] = useState("BANK_TRANSFER") + const [periodFrom, setPeriodFrom] = useState("") + const [periodTo, setPeriodTo] = useState("") + const [reference, setReference] = useState("") + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + onSubmit({ + memberId, + amountCents: Math.round(parseFloat(amount) * 100), + paymentMethod: method, + periodFrom: periodFrom || undefined, + periodTo: periodTo || undefined, + reference: reference || undefined, + }) + } + + return ( +
+
+ + setMemberId(e.target.value)} + placeholder={t("memberIdPlaceholder")} + required + /> +
+
+ + setAmount(e.target.value)} + placeholder="30.00" + required + /> +
+
+ + +
+
+
+ + setPeriodFrom(e.target.value)} + /> +
+
+ + setPeriodTo(e.target.value)} + /> +
+
+
+ + setReference(e.target.value)} + placeholder={t("referencePlaceholder")} + /> +
+ +
+ ) +} + +function ExpenseForm({ + onSubmit, + isLoading, +}: { + onSubmit: (data: RecordExpenseRequest) => void + isLoading: boolean +}) { + const t = useTranslations("finance") + const [category, setCategory] = useState("OTHER") + const [amount, setAmount] = useState("") + const [description, setDescription] = useState("") + const [reference, setReference] = useState("") + const [date, setDate] = useState(format(new Date(), "yyyy-MM-dd")) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + onSubmit({ + category, + amountCents: Math.round(parseFloat(amount) * 100), + description, + reference: reference || undefined, + transactionDate: date || undefined, + }) + } + + return ( +
+
+ + +
+
+ + setAmount(e.target.value)} + placeholder="85.00" + required + /> +
+
+ + setDescription(e.target.value)} + placeholder={t("descriptionPlaceholder")} + required + /> +
+
+ + setDate(e.target.value)} + required + /> +
+
+ + setReference(e.target.value)} + placeholder={t("expenseReferencePlaceholder")} + /> +
+ +
+ ) +} diff --git a/cannamanage-frontend/src/app/(dashboard-layout)/finance/payments/page.tsx b/cannamanage-frontend/src/app/(dashboard-layout)/finance/payments/page.tsx new file mode 100644 index 0000000..e86ec25 --- /dev/null +++ b/cannamanage-frontend/src/app/(dashboard-layout)/finance/payments/page.tsx @@ -0,0 +1,169 @@ +"use client" + +import { useState } from "react" +import { + formatAmountCents, + getReceiptDownloadUrl, + usePaymentsQuery, + useVoidPaymentMutation, +} from "@/services/finance" +import { useTranslations } from "next-intl" +import { Ban, Receipt } from "lucide-react" + +import type { PaymentStatus } from "@/services/finance" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { Select } from "@/components/ui/select" + +export default function PaymentsPage() { + const t = useTranslations("finance") + const [statusFilter, setStatusFilter] = useState("ALL") + const [page, setPage] = useState(0) + + const { data: payments, isLoading } = usePaymentsQuery({ + status: + statusFilter === "ALL" ? undefined : (statusFilter as PaymentStatus), + page, + size: 20, + }) + + const voidMutation = useVoidPaymentMutation() + + const handleVoid = (id: string) => { + const reason = prompt(t("voidReason")) + if (reason) { + voidMutation.mutate({ id, reason }) + } + } + + return ( +
+
+

{t("payments")}

+ +
+ + + +
+ + + + + + + + + + + + + + {payments?.content?.map((payment) => ( + + + + + + + + + + ))} + {(!payments?.content || payments.content.length === 0) && ( + + + + )} + +
{t("member")}{t("amount")}{t("period")} + {t("paymentMethod")} + {t("date")} + {t("status.label")} + {t("actions")}
+ {payment.memberName || payment.memberId.substring(0, 8)} + + {formatAmountCents(payment.amountCents)} + + {payment.periodFrom && payment.periodTo + ? `${payment.periodFrom} – ${payment.periodTo}` + : "—"} + + {t(`method.${payment.paymentMethod}`)} + {payment.paymentDate} + + {payment.voided ? t("status.voided") : t("status.paid")} + + +
+ {!payment.voided && ( + <> + + + + + + )} +
+
+ {isLoading ? t("loading") : t("noPayments")} +
+
+
+
+ + {payments && payments.totalPages > 1 && ( +
+ + + {page + 1} / {payments.totalPages} + + +
+ )} +
+ ) +} diff --git a/cannamanage-frontend/src/app/(dashboard-layout)/finance/reports/page.tsx b/cannamanage-frontend/src/app/(dashboard-layout)/finance/reports/page.tsx new file mode 100644 index 0000000..07001f4 --- /dev/null +++ b/cannamanage-frontend/src/app/(dashboard-layout)/finance/reports/page.tsx @@ -0,0 +1,80 @@ +"use client" + +import { useState } from "react" +import { getAnnualReportDownloadUrl } from "@/services/finance" +import { useTranslations } from "next-intl" +import { Download, FileText } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Select } from "@/components/ui/select" + +export default function ReportsPage() { + const t = useTranslations("finance") + const currentYear = new Date().getFullYear() + const [selectedYear, setSelectedYear] = useState(String(currentYear)) + + const years = Array.from({ length: 5 }, (_, i) => currentYear - i) + + return ( +
+

{t("reports")}

+ +
+ + + + + {t("annualReport")} + + + +

+ {t("annualReportDescription")} +

+
+ + + + +
+
+
+ + + + + + {t("auditorReport")} + + + +

+ {t("auditorReportDescription")} +

+ +
+
+
+
+ ) +} diff --git a/cannamanage-frontend/src/app/(portal-layout)/portal/finance/page.tsx b/cannamanage-frontend/src/app/(portal-layout)/portal/finance/page.tsx new file mode 100644 index 0000000..8d693ed --- /dev/null +++ b/cannamanage-frontend/src/app/(portal-layout)/portal/finance/page.tsx @@ -0,0 +1,138 @@ +"use client" + +import { + formatAmountCents, + getPortalReceiptDownloadUrl, + useMyBalanceQuery, + useMyPaymentsQuery, +} from "@/services/finance" +import { useTranslations } from "next-intl" +import { AlertCircle, CheckCircle, Clock, Receipt } from "lucide-react" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" + +export default function PortalFinancePage() { + const t = useTranslations("finance") + const { data: balance } = useMyBalanceQuery() + const { data: payments } = useMyPaymentsQuery({ page: 0, size: 20 }) + + return ( +
+

{t("portal.title")}

+ + {/* Balance Cards */} +
+ + + + {t("portal.currentBalance")} + + {balance?.status === "CURRENT" ? ( + + ) : balance?.status === "OVERDUE" ? ( + + ) : ( + + )} + + +
0 + ? "text-red-600" + : "" + }`} + > + {balance ? formatAmountCents(balance.outstandingCents) : "—"} +
+

+ {balance?.status === "CURRENT" + ? t("portal.allPaid") + : balance?.status === "OVERDUE" + ? t("portal.paymentDue") + : t("portal.noFeeAssigned")} +

+
+
+ + + + + {t("portal.feeSchedule")} + + + +
+ {balance?.feeScheduleName || t("portal.noFee")} +
+ {balance?.lastPaymentDate && ( +

+ {t("portal.lastPayment")}: {balance.lastPaymentDate} +

+ )} +
+
+
+ + {/* Payment History */} + + + {t("portal.paymentHistory")} + + + {payments?.content && payments.content.length > 0 ? ( +
+ {payments.content.map((payment) => ( +
+
+
+ +
+
+

+ {formatAmountCents(payment.amountCents)} +

+

+ {payment.paymentDate} + {payment.periodFrom && + payment.periodTo && + ` • ${payment.periodFrom} – ${payment.periodTo}`} +

+
+
+
+ + {payment.voided ? t("status.voided") : t("status.paid")} + + {!payment.voided && ( + + + + )} +
+
+ ))} +
+ ) : ( +

+ {t("portal.noPayments")} +

+ )} +
+
+
+ ) +} diff --git a/cannamanage-frontend/src/data/navigations.ts b/cannamanage-frontend/src/data/navigations.ts index b69debe..2c0f558 100644 --- a/cannamanage-frontend/src/data/navigations.ts +++ b/cannamanage-frontend/src/data/navigations.ts @@ -39,6 +39,11 @@ export const navigationsData: NavigationType[] = [ href: "/info-board", iconName: "Megaphone", }, + { + title: "Finanzen", + href: "/finance", + iconName: "Wallet", + }, { title: "Kalender", href: "/calendar", diff --git a/cannamanage-frontend/src/services/finance.ts b/cannamanage-frontend/src/services/finance.ts new file mode 100644 index 0000000..c016843 --- /dev/null +++ b/cannamanage-frontend/src/services/finance.ts @@ -0,0 +1,399 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" + +import { apiClient } from "@/lib/api-client" + +// --- Types --- + +export type PaymentMethod = "CASH" | "BANK_TRANSFER" | "SEPA" | "CARD" +export type PaymentStatus = "PAID" | "PENDING" | "OVERDUE" | "VOIDED" +export type FeeInterval = "MONTHLY" | "QUARTERLY" | "YEARLY" | "ONE_TIME" +export type ExpenseCategory = + | "RENT" + | "UTILITIES" + | "EQUIPMENT" + | "SEEDS" + | "SUPPLIES" + | "INSURANCE" + | "LEGAL" + | "OTHER" +export type TransactionType = "INCOME" | "EXPENSE" + +export interface FeeSchedule { + id: string + tenantId: string + name: string + amountCents: number + interval: FeeInterval + description: string | null + active: boolean + isDefault: boolean + createdAt: string + updatedAt: string +} + +export interface MemberFeeAssignment { + id: string + tenantId: string + memberId: string + feeScheduleId: string + validFrom: string + validUntil: string | null + notes: string | null + createdAt: string +} + +export interface Payment { + id: string + tenantId: string + memberId: string + memberName?: string + amountCents: number + paymentDate: string + paymentMethod: PaymentMethod + reference: string | null + periodFrom: string | null + periodTo: string | null + receiptNumber: string | null + notes: string | null + voided: boolean + voidedReason: string | null + voidedAt: string | null + recordedBy: string + createdAt: string +} + +export interface LedgerEntry { + id: string + tenantId: string + transactionDate: string + transactionType: TransactionType + category: string | null + description: string + amountCents: number + reference: string | null + relatedPaymentId: string | null + relatedExpenseId: string | null + runningBalanceCents?: number + recordedBy: string + createdAt: string +} + +export interface FinancialSummary { + totalIncomeCents: number + totalExpenseCents: number + netBalanceCents: number + incomeThisMonthCents: number + expensesThisMonthCents: number + outstandingMembersCount: number + totalOutstandingCents: number +} + +export interface MemberBalance { + memberId: string + memberName: string + feeScheduleName: string | null + totalDueCents: number + totalPaidCents: number + outstandingCents: number + lastPaymentDate: string | null + status: "CURRENT" | "OVERDUE" | "NO_FEE" +} + +export interface OutstandingMember { + memberId: string + memberName: string + memberNumber: string | null + outstandingCents: number + lastPaymentDate: string | null + monthsOverdue: number +} + +// --- Request Types --- + +export interface CreateFeeScheduleRequest { + name: string + amountCents: number + interval: FeeInterval + description?: string + isDefault?: boolean +} + +export interface UpdateFeeScheduleRequest { + name?: string + amountCents?: number + interval?: FeeInterval + description?: string + isDefault?: boolean +} + +export interface AssignFeeRequest { + feeScheduleId: string + validFrom: string +} + +export interface RecordPaymentRequest { + memberId: string + amountCents: number + paymentMethod: PaymentMethod + periodFrom?: string + periodTo?: string + reference?: string + notes?: string +} + +export interface VoidPaymentRequest { + reason: string +} + +export interface RecordExpenseRequest { + category: ExpenseCategory + amountCents: number + description: string + reference?: string + transactionDate?: string +} + +// --- Response Types --- + +export interface PaginatedResponse { + content: T[] + totalElements: number + totalPages: number + number: number + size: number +} + +// --- Admin Query Hooks --- + +export function useFeeSchedulesQuery() { + return useQuery({ + queryKey: ["finance", "fee-schedules"], + queryFn: () => apiClient("/finance/fee-schedules"), + }) +} + +export function usePaymentsQuery(options?: { + memberId?: string + status?: PaymentStatus + page?: number + size?: number +}) { + return useQuery({ + queryKey: ["finance", "payments", options], + queryFn: () => { + const params = new URLSearchParams() + if (options?.memberId) params.set("memberId", options.memberId) + if (options?.status) params.set("status", options.status) + params.set("page", String(options?.page ?? 0)) + params.set("size", String(options?.size ?? 20)) + return apiClient>( + `/finance/payments?${params}` + ) + }, + }) +} + +export function useLedgerQuery( + from: string, + to: string, + options?: { page?: number; size?: number } +) { + return useQuery({ + queryKey: ["finance", "ledger", from, to, options], + queryFn: () => { + const params = new URLSearchParams() + params.set("from", from) + params.set("to", to) + params.set("page", String(options?.page ?? 0)) + params.set("size", String(options?.size ?? 50)) + return apiClient>( + `/finance/ledger?${params}` + ) + }, + enabled: !!from && !!to, + }) +} + +export function useFinancialSummaryQuery(from: string, to: string) { + return useQuery({ + queryKey: ["finance", "summary", from, to], + queryFn: () => + apiClient(`/finance/summary?from=${from}&to=${to}`), + enabled: !!from && !!to, + }) +} + +export function useOutstandingMembersQuery() { + return useQuery({ + queryKey: ["finance", "outstanding"], + queryFn: () => apiClient("/finance/outstanding"), + }) +} + +export function useMemberBalanceQuery(memberId: string | undefined) { + return useQuery({ + queryKey: ["finance", "balance", memberId], + queryFn: () => + apiClient(`/finance/members/${memberId}/balance`), + enabled: !!memberId, + }) +} + +// --- Portal Query Hooks --- + +export function useMyPaymentsQuery(options?: { page?: number; size?: number }) { + return useQuery({ + queryKey: ["portal", "finance", "payments", options], + queryFn: () => { + const params = new URLSearchParams() + params.set("page", String(options?.page ?? 0)) + params.set("size", String(options?.size ?? 20)) + return apiClient>( + `/portal/finance/payments?${params}` + ) + }, + }) +} + +export function useMyBalanceQuery() { + return useQuery({ + queryKey: ["portal", "finance", "balance"], + queryFn: () => apiClient("/portal/finance/balance"), + }) +} + +// --- Mutation Hooks --- + +export function useCreateFeeScheduleMutation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (data: CreateFeeScheduleRequest) => + apiClient("/finance/fee-schedules", { + method: "POST", + body: data, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["finance", "fee-schedules"] }) + }, + }) +} + +export function useUpdateFeeScheduleMutation(id: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (data: UpdateFeeScheduleRequest) => + apiClient(`/finance/fee-schedules/${id}`, { + method: "PUT", + body: data, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["finance", "fee-schedules"] }) + }, + }) +} + +export function useDeactivateFeeScheduleMutation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: string) => + apiClient(`/finance/fee-schedules/${id}/deactivate`, { + method: "POST", + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["finance", "fee-schedules"] }) + }, + }) +} + +export function useAssignFeeMutation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ + memberId, + ...data + }: AssignFeeRequest & { memberId: string }) => + apiClient( + `/finance/members/${memberId}/assign-fee`, + { + method: "POST", + body: data, + } + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["finance"] }) + }, + }) +} + +export function useRecordPaymentMutation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (data: RecordPaymentRequest) => + apiClient("/finance/payments", { + method: "POST", + body: data, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["finance"] }) + }, + }) +} + +export function useVoidPaymentMutation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, reason }: { id: string; reason: string }) => + apiClient(`/finance/payments/${id}/void`, { + method: "POST", + body: { reason } as VoidPaymentRequest, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["finance"] }) + }, + }) +} + +export function useRecordExpenseMutation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (data: RecordExpenseRequest) => + apiClient("/finance/expenses", { + method: "POST", + body: data, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["finance"] }) + }, + }) +} + +// --- Utility: PDF Download --- + +export function getReceiptDownloadUrl(paymentId: string): string { + return `/api/backend/finance/payments/${paymentId}/receipt` +} + +export function getPortalReceiptDownloadUrl(paymentId: string): string { + return `/api/backend/portal/finance/payments/${paymentId}/receipt` +} + +export function getAnnualReportDownloadUrl(year: number): string { + return `/api/backend/finance/reports/annual?year=${year}` +} + +export function getKassenbuchCsvDownloadUrl(from: string, to: string): string { + return `/api/backend/finance/ledger/export?from=${from}&to=${to}` +} + +// --- Utility: Amount formatting --- + +export function formatAmountCents(cents: number): string { + return new Intl.NumberFormat("de-DE", { + style: "currency", + currency: "EUR", + }).format(cents / 100) +} + +export function formatAmountCentsShort(cents: number): string { + return `${(cents / 100).toFixed(2).replace(".", ",")} €` +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/FinancialReportService.java b/cannamanage-service/src/main/java/de/cannamanage/service/FinancialReportService.java new file mode 100644 index 0000000..93d5094 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/FinancialReportService.java @@ -0,0 +1,286 @@ +package de.cannamanage.service; + +import com.lowagie.text.*; +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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.awt.Color; +import java.io.ByteArrayOutputStream; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.Year; +import java.time.format.DateTimeFormatter; +import java.time.format.TextStyle; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Generates the annual financial report (Jahresabschluss) as PDF. + * Content: Income/expense totals, monthly breakdown, category breakdown, member payment summary. + * Legal basis: §259 BGB (Rechenschaftspflicht), §147 AO (10-year retention). + */ +@Service +public class FinancialReportService { + + private static final Logger log = LoggerFactory.getLogger(FinancialReportService.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 Color PRIMARY_COLOR = new Color(34, 87, 58); // Dark green + 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 Color INCOME_COLOR = new Color(22, 163, 74); + private static final Color EXPENSE_COLOR = new Color(220, 38, 38); + + private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy"); + + /** + * DTO for annual report data. + */ + public record AnnualReportData( + int year, + BigDecimal totalIncome, + BigDecimal totalExpenses, + BigDecimal netBalance, + List monthlyBreakdown, + List expensesByCategory, + int totalMembers, + int paidMembers, + BigDecimal totalOutstanding + ) {} + + public record MonthlyBreakdown( + int month, + String monthName, + BigDecimal income, + BigDecimal expenses, + BigDecimal net + ) {} + + public record CategoryBreakdown( + String category, + BigDecimal amount, + double percentage + ) {} + + /** + * Generate the annual financial report PDF. + */ + public byte[] generateAnnualReport(AnnualReportData data, Club club) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + Document document = new Document(PageSize.A4, 50, 50, 50, 50); + + try { + PdfWriter writer = PdfWriter.getInstance(document, baos); + writer.setPageEvent(new PdfFooterHandler()); + document.open(); + + // === Page 1: Cover & Summary === + + // Club header + Paragraph clubHeader = new Paragraph(club.getName(), HEADER_FONT); + clubHeader.setSpacingAfter(5); + document.add(clubHeader); + + // Title + Paragraph title = new Paragraph( + "Jahresabschluss " + data.year(), TITLE_FONT); + title.setSpacingAfter(5); + document.add(title); + + // Subtitle + Paragraph subtitle = new Paragraph( + "Finanzübersicht für das Geschäftsjahr " + data.year(), + NORMAL_FONT); + subtitle.setSpacingAfter(20); + document.add(subtitle); + + // --- Summary KPIs --- + addSectionTitle(document, "Zusammenfassung"); + + PdfPTable summaryTable = new PdfPTable(2); + summaryTable.setWidthPercentage(60); + summaryTable.setHorizontalAlignment(Element.ALIGN_LEFT); + summaryTable.setSpacingAfter(20); + + addSummaryRow(summaryTable, "Gesamteinnahmen", + formatEuro(data.totalIncome()), INCOME_COLOR); + addSummaryRow(summaryTable, "Gesamtausgaben", + formatEuro(data.totalExpenses()), EXPENSE_COLOR); + addSummaryRow(summaryTable, "Saldo", + formatEuro(data.netBalance()), + data.netBalance().signum() >= 0 ? INCOME_COLOR : EXPENSE_COLOR); + + document.add(summaryTable); + + // --- Member payment summary --- + addSectionTitle(document, "Mitgliederbeiträge"); + + PdfPTable memberTable = new PdfPTable(2); + memberTable.setWidthPercentage(60); + memberTable.setHorizontalAlignment(Element.ALIGN_LEFT); + memberTable.setSpacingAfter(20); + + addSummaryRow(memberTable, "Mitglieder gesamt", + String.valueOf(data.totalMembers()), Color.BLACK); + addSummaryRow(memberTable, "Beiträge bezahlt", + String.valueOf(data.paidMembers()), INCOME_COLOR); + addSummaryRow(memberTable, "Offene Beiträge", + formatEuro(data.totalOutstanding()), EXPENSE_COLOR); + + document.add(memberTable); + + // --- Monthly Breakdown Table --- + addSectionTitle(document, "Monatliche Übersicht"); + + PdfPTable monthlyTable = new PdfPTable(4); + monthlyTable.setWidthPercentage(100); + monthlyTable.setWidths(new float[]{30f, 23f, 23f, 24f}); + monthlyTable.setSpacingAfter(20); + + addColoredHeader(monthlyTable, "Monat"); + addColoredHeader(monthlyTable, "Einnahmen"); + addColoredHeader(monthlyTable, "Ausgaben"); + addColoredHeader(monthlyTable, "Saldo"); + + BigDecimal runningTotal = BigDecimal.ZERO; + for (MonthlyBreakdown month : data.monthlyBreakdown()) { + runningTotal = runningTotal.add(month.net()); + + boolean isEven = month.month() % 2 == 0; + Color rowBg = isEven ? LIGHT_BG : Color.WHITE; + + addCellWithBg(monthlyTable, month.monthName(), TABLE_CELL_FONT, rowBg); + addCellWithBg(monthlyTable, formatEuro(month.income()), TABLE_CELL_FONT, rowBg); + addCellWithBg(monthlyTable, formatEuro(month.expenses()), TABLE_CELL_FONT, rowBg); + addCellWithBg(monthlyTable, formatEuro(month.net()), TABLE_CELL_FONT, rowBg); + } + + // Total row + addCellWithBg(monthlyTable, "GESAMT", TABLE_CELL_BOLD, LIGHT_BG); + addCellWithBg(monthlyTable, formatEuro(data.totalIncome()), TABLE_CELL_BOLD, LIGHT_BG); + addCellWithBg(monthlyTable, formatEuro(data.totalExpenses()), TABLE_CELL_BOLD, LIGHT_BG); + addCellWithBg(monthlyTable, formatEuro(data.netBalance()), TABLE_CELL_BOLD, LIGHT_BG); + + document.add(monthlyTable); + + // --- Expense Breakdown by Category --- + if (data.expensesByCategory() != null && !data.expensesByCategory().isEmpty()) { + addSectionTitle(document, "Ausgaben nach Kategorie"); + + PdfPTable categoryTable = new PdfPTable(3); + categoryTable.setWidthPercentage(80); + categoryTable.setWidths(new float[]{45f, 30f, 25f}); + categoryTable.setSpacingAfter(20); + + addColoredHeader(categoryTable, "Kategorie"); + addColoredHeader(categoryTable, "Betrag"); + addColoredHeader(categoryTable, "Anteil"); + + for (CategoryBreakdown cat : data.expensesByCategory()) { + addCell(categoryTable, translateCategory(cat.category())); + addCell(categoryTable, formatEuro(cat.amount())); + addCell(categoryTable, String.format("%.1f%%", cat.percentage())); + } + + document.add(categoryTable); + } + + // --- Footer note --- + Paragraph footerNote = new Paragraph( + "Erstellt am " + LocalDate.now().format(DATE_FMT) + + " — Anbauvereinigung gemäß §2 KCanG", + FOOTER_FONT); + footerNote.setAlignment(Element.ALIGN_CENTER); + footerNote.setSpacingBefore(30); + document.add(footerNote); + + document.close(); + + } catch (DocumentException e) { + log.error("Failed to generate annual report PDF for year {}", data.year(), e); + throw new RuntimeException("PDF generation failed", e); + } + + return baos.toByteArray(); + } + + // --- Helper methods --- + + private void addSectionTitle(Document document, String text) throws DocumentException { + Paragraph section = new Paragraph(text, SUBTITLE_FONT); + section.setSpacingBefore(10); + section.setSpacingAfter(8); + document.add(section); + } + + private void addSummaryRow(PdfPTable table, String label, String value, Color valueColor) { + PdfPCell labelCell = new PdfPCell(new Phrase(label, NORMAL_FONT)); + labelCell.setBorder(Rectangle.NO_BORDER); + labelCell.setPaddingBottom(5); + table.addCell(labelCell); + + Font valueFont = new Font(Font.HELVETICA, 10, Font.BOLD, valueColor); + PdfPCell valueCell = new PdfPCell(new Phrase(value, valueFont)); + valueCell.setBorder(Rectangle.NO_BORDER); + valueCell.setPaddingBottom(5); + valueCell.setHorizontalAlignment(Element.ALIGN_RIGHT); + table.addCell(valueCell); + } + + private void addColoredHeader(PdfPTable table, String text) { + PdfPCell cell = new PdfPCell(new Phrase(text, TABLE_HEADER_FONT)); + cell.setBackgroundColor(HEADER_BG); + cell.setPadding(6); + cell.setBorderWidth(0); + table.addCell(cell); + } + + private void addCell(PdfPTable table, String text) { + PdfPCell cell = new PdfPCell(new Phrase(text, TABLE_CELL_FONT)); + cell.setPadding(5); + cell.setBorderWidth(0.5f); + cell.setBorderColor(Color.LIGHT_GRAY); + table.addCell(cell); + } + + private void addCellWithBg(PdfPTable table, String text, Font font, Color bg) { + PdfPCell cell = new PdfPCell(new Phrase(text, font)); + cell.setBackgroundColor(bg); + cell.setPadding(5); + cell.setBorderWidth(0.5f); + cell.setBorderColor(Color.LIGHT_GRAY); + table.addCell(cell); + } + + private String formatEuro(BigDecimal amount) { + return String.format(Locale.GERMAN, "%,.2f €", amount); + } + + private String translateCategory(String category) { + return switch (category) { + case "RENT" -> "Miete"; + case "UTILITIES" -> "Nebenkosten"; + case "EQUIPMENT" -> "Ausstattung"; + case "SEEDS" -> "Saatgut"; + case "SUPPLIES" -> "Verbrauchsmaterial"; + case "INSURANCE" -> "Versicherung"; + case "LEGAL" -> "Rechtsberatung"; + case "OTHER" -> "Sonstiges"; + default -> category; + }; + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/ReceiptPdfService.java b/cannamanage-service/src/main/java/de/cannamanage/service/ReceiptPdfService.java new file mode 100644 index 0000000..bed62ad --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/ReceiptPdfService.java @@ -0,0 +1,212 @@ +package de.cannamanage.service; + +import com.lowagie.text.*; +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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.awt.Color; +import java.io.ByteArrayOutputStream; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Locale; + +/** + * Generates receipt (Quittung) PDFs for member payments. + * Layout: A4 portrait, single page, professional receipt format. + * Legal basis: §147 AO (10-year retention for Buchungsbelege). + */ +@Service +public class ReceiptPdfService { + + private static final Logger log = LoggerFactory.getLogger(ReceiptPdfService.class); + + private static final Font CLUB_NAME_FONT = new Font(Font.HELVETICA, 14, Font.BOLD); + private static final Font CLUB_ADDRESS_FONT = new Font(Font.HELVETICA, 9, Font.NORMAL, Color.DARK_GRAY); + private static final Font TITLE_FONT = new Font(Font.HELVETICA, 18, Font.BOLD); + private static final Font RECEIPT_NR_FONT = new Font(Font.HELVETICA, 10, Font.NORMAL, Color.GRAY); + private static final Font LABEL_FONT = new Font(Font.HELVETICA, 10, Font.BOLD); + private static final Font VALUE_FONT = new Font(Font.HELVETICA, 10, Font.NORMAL); + private static final Font AMOUNT_FONT = new Font(Font.HELVETICA, 14, 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); + + private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy"); + private static final DateTimeFormatter PERIOD_FMT = DateTimeFormatter.ofPattern("MMMM yyyy", Locale.GERMAN); + + /** + * Generate a receipt PDF for a payment. + * + * @param payment the payment record + * @param member the paying member + * @param club the club entity (for header) + * @return PDF bytes + */ + public byte[] generateReceipt(Payment payment, Member member, Club club) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + // A4 with generous margins + Document document = new Document(PageSize.A4, 60, 60, 60, 60); + + try { + PdfWriter.getInstance(document, baos); + document.open(); + + // --- Club Header --- + Paragraph clubName = new Paragraph(club.getName(), CLUB_NAME_FONT); + clubName.setAlignment(Element.ALIGN_LEFT); + document.add(clubName); + + if (club.getAddress() != null && !club.getAddress().isBlank()) { + Paragraph address = new Paragraph(club.getAddress(), CLUB_ADDRESS_FONT); + address.setSpacingAfter(5); + document.add(address); + } + + // Horizontal rule + Paragraph hr = new Paragraph(" "); + hr.setSpacingBefore(5); + hr.setSpacingAfter(15); + document.add(hr); + + // --- Title --- + Paragraph title = new Paragraph("QUITTUNG", TITLE_FONT); + title.setAlignment(Element.ALIGN_CENTER); + title.setSpacingAfter(5); + document.add(title); + + // Receipt number + String receiptNr = payment.getReceiptNumber() != null + ? payment.getReceiptNumber() + : "CM-" + payment.getId().toString().substring(0, 8).toUpperCase(); + Paragraph nrPara = new Paragraph("Nr. " + receiptNr, RECEIPT_NR_FONT); + nrPara.setAlignment(Element.ALIGN_CENTER); + nrPara.setSpacingAfter(30); + document.add(nrPara); + + // --- Receipt Details Table --- + PdfPTable detailsTable = new PdfPTable(2); + detailsTable.setWidthPercentage(80); + detailsTable.setHorizontalAlignment(Element.ALIGN_LEFT); + detailsTable.setWidths(new float[]{35f, 65f}); + detailsTable.setSpacingAfter(20); + + addDetailRow(detailsTable, "Erhalten von:", getMemberDisplayName(member)); + addDetailRow(detailsTable, "Mitgliedsnr.:", member.getMemberNumber() != null + ? member.getMemberNumber() : "—"); + + // Amount - formatted as Euro + BigDecimal amount = BigDecimal.valueOf(payment.getAmountCents()).divide(BigDecimal.valueOf(100)); + String amountStr = String.format(Locale.GERMAN, "%,.2f €", amount); + addDetailRow(detailsTable, "Betrag:", amountStr, AMOUNT_FONT); + + addDetailRow(detailsTable, "Datum:", payment.getPaymentDate().format(DATE_FMT)); + addDetailRow(detailsTable, "Zahlungsart:", translatePaymentMethod(payment.getPaymentMethod().name())); + + // Period covered + if (payment.getPeriodFrom() != null && payment.getPeriodTo() != null) { + String period = formatPeriod(payment.getPeriodFrom(), payment.getPeriodTo()); + addDetailRow(detailsTable, "Zeitraum:", period); + } + + addDetailRow(detailsTable, "Verwendung:", "Mitgliedsbeitrag"); + + if (payment.getReference() != null && !payment.getReference().isBlank()) { + addDetailRow(detailsTable, "Referenz:", payment.getReference()); + } + + document.add(detailsTable); + + // --- Spacer --- + document.add(new Paragraph(" ")); + document.add(new Paragraph(" ")); + document.add(new Paragraph(" ")); + + // --- Signature Section --- + PdfPTable sigTable = new PdfPTable(2); + sigTable.setWidthPercentage(80); + sigTable.setHorizontalAlignment(Element.ALIGN_LEFT); + sigTable.setWidths(new float[]{50f, 50f}); + + // Date line + PdfPCell dateCell = new PdfPCell(); + dateCell.setBorder(Rectangle.TOP); + dateCell.setBorderWidth(0.5f); + dateCell.setPaddingTop(5); + dateCell.addElement(new Phrase("Datum", SIGNATURE_FONT)); + sigTable.addCell(dateCell); + + // Signature line + PdfPCell sigCell = new PdfPCell(); + sigCell.setBorder(Rectangle.TOP); + sigCell.setBorderWidth(0.5f); + sigCell.setPaddingTop(5); + sigCell.addElement(new Phrase("Unterschrift Kassenwart", SIGNATURE_FONT)); + sigTable.addCell(sigCell); + + document.add(sigTable); + + // --- Footer --- + Paragraph footer = new Paragraph( + "Anbauvereinigung gemäß §2 KCanG — " + club.getName(), + FOOTER_FONT + ); + footer.setAlignment(Element.ALIGN_CENTER); + footer.setSpacingBefore(40); + document.add(footer); + + document.close(); + + } catch (DocumentException e) { + log.error("Failed to generate receipt PDF for payment {}", payment.getId(), e); + throw new RuntimeException("PDF generation failed", e); + } + + return baos.toByteArray(); + } + + private void addDetailRow(PdfPTable table, String label, String value) { + addDetailRow(table, label, value, VALUE_FONT); + } + + private void addDetailRow(PdfPTable table, String label, String value, Font valueFont) { + PdfPCell labelCell = new PdfPCell(new Phrase(label, LABEL_FONT)); + labelCell.setBorder(Rectangle.NO_BORDER); + labelCell.setPaddingBottom(8); + table.addCell(labelCell); + + PdfPCell valueCell = new PdfPCell(new Phrase(value, valueFont)); + valueCell.setBorder(Rectangle.NO_BORDER); + valueCell.setPaddingBottom(8); + table.addCell(valueCell); + } + + private String getMemberDisplayName(Member member) { + String firstName = member.getFirstName() != null ? member.getFirstName() : ""; + String lastName = member.getLastName() != null ? member.getLastName() : ""; + return (firstName + " " + lastName).trim(); + } + + private String translatePaymentMethod(String method) { + return switch (method) { + case "CASH" -> "Bar"; + case "BANK_TRANSFER" -> "Überweisung"; + case "SEPA" -> "SEPA-Lastschrift"; + case "CARD" -> "Kartenzahlung"; + default -> method; + }; + } + + private String formatPeriod(LocalDate from, LocalDate to) { + if (from.getMonth() == to.getMonth() && from.getYear() == to.getYear()) { + return from.format(PERIOD_FMT); + } + return from.format(PERIOD_FMT) + " – " + to.format(PERIOD_FMT); + } +}