feat(sprint8): Phase 1 — Treasury backend (fee schedules, payments, Kassenbuch)

- Extend StaffPermission with MANAGE_FINANCES, VIEW_FINANCES
- Extend AuditEventType with PAYMENT_RECORDED, PAYMENT_VOIDED, FEE_SCHEDULE_CREATED, FEE_SCHEDULE_UPDATED, EXPENSE_RECORDED
- Extend NotificationType with PAYMENT_REMINDER, PAYMENT_OVERDUE, PAYMENT_RECEIVED
- New enums: PaymentMethod, PaymentStatus, TransactionType, FeeInterval, ExpenseCategory
- V18 Flyway migration: fee_schedules, member_fee_assignments, payments, ledger_entries tables
- Entities: FeeSchedule, MemberFeeAssignment, Payment, LedgerEntry
- Repositories with financial queries (balance, outstanding, period sums)
- FinanceService: fee schedule CRUD, record/void payments, expenses, Kassenbuch, summaries
- FinanceController: 14 admin endpoints + 2 portal self-service endpoints
- LedgerEntry is append-only per §147 AO (no update/delete)
- All amounts in cents (Integer) to avoid floating-point precision issues
This commit is contained in:
Patrick Plate
2026-06-15 08:00:04 +02:00
parent cfb38e8fc6
commit 721503b231
25 changed files with 1225 additions and 3 deletions
@@ -0,0 +1,320 @@
package de.cannamanage.service;
import de.cannamanage.domain.entity.*;
import de.cannamanage.domain.enums.*;
import de.cannamanage.service.repository.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.LocalDate;
import java.util.*;
/**
* Service for club treasury management — fee schedules, payments, expenses, Kassenbuch.
* All amounts in cents (Integer) to avoid floating-point precision issues.
* LedgerEntry is append-only per §147 AO.
*/
@Service
@Transactional
public class FinanceService {
private static final Logger log = LoggerFactory.getLogger(FinanceService.class);
private final FeeScheduleRepository feeScheduleRepository;
private final MemberFeeAssignmentRepository assignmentRepository;
private final PaymentRepository paymentRepository;
private final LedgerEntryRepository ledgerEntryRepository;
private final AuditService auditService;
public FinanceService(FeeScheduleRepository feeScheduleRepository,
MemberFeeAssignmentRepository assignmentRepository,
PaymentRepository paymentRepository,
LedgerEntryRepository ledgerEntryRepository,
AuditService auditService) {
this.feeScheduleRepository = feeScheduleRepository;
this.assignmentRepository = assignmentRepository;
this.paymentRepository = paymentRepository;
this.ledgerEntryRepository = ledgerEntryRepository;
this.auditService = auditService;
}
// === Fee Schedule CRUD ===
public FeeSchedule createFeeSchedule(UUID clubId, String name, int amountCents,
FeeInterval interval, boolean isDefault) {
var schedule = new FeeSchedule();
schedule.setClubId(clubId);
schedule.setName(name);
schedule.setAmountCents(amountCents);
schedule.setInterval(interval);
schedule.setIsDefault(isDefault);
schedule.setIsActive(true);
// If this is set as default, unset any existing default
if (isDefault) {
feeScheduleRepository.findByClubIdAndIsDefaultTrue(clubId)
.ifPresent(existing -> {
existing.setIsDefault(false);
feeScheduleRepository.save(existing);
});
}
FeeSchedule saved = feeScheduleRepository.save(schedule);
log.info("Fee schedule created: {} '{}' ({} cents/{}) for club {}",
saved.getId(), name, amountCents, interval, clubId);
auditService.log(AuditEventType.FEE_SCHEDULE_CREATED, "FeeSchedule",
saved.getId().toString(), "Fee schedule created: " + name);
return saved;
}
public FeeSchedule updateFeeSchedule(UUID scheduleId, String name, Integer amountCents,
FeeInterval interval, Boolean isDefault) {
FeeSchedule schedule = feeScheduleRepository.findById(scheduleId)
.orElseThrow(() -> new NoSuchElementException("Fee schedule not found: " + scheduleId));
if (name != null) schedule.setName(name);
if (amountCents != null) schedule.setAmountCents(amountCents);
if (interval != null) schedule.setInterval(interval);
if (isDefault != null && isDefault) {
feeScheduleRepository.findByClubIdAndIsDefaultTrue(schedule.getClubId())
.ifPresent(existing -> {
if (!existing.getId().equals(scheduleId)) {
existing.setIsDefault(false);
feeScheduleRepository.save(existing);
}
});
schedule.setIsDefault(true);
}
FeeSchedule saved = feeScheduleRepository.save(schedule);
auditService.log(AuditEventType.FEE_SCHEDULE_UPDATED, "FeeSchedule",
saved.getId().toString(), "Fee schedule updated: " + saved.getName());
return saved;
}
public void deactivateFeeSchedule(UUID scheduleId) {
FeeSchedule schedule = feeScheduleRepository.findById(scheduleId)
.orElseThrow(() -> new NoSuchElementException("Fee schedule not found: " + scheduleId));
schedule.setIsActive(false);
schedule.setIsDefault(false);
feeScheduleRepository.save(schedule);
log.info("Fee schedule deactivated: {}", scheduleId);
}
public List<FeeSchedule> getActiveFeeSchedules(UUID clubId) {
return feeScheduleRepository.findByClubIdAndIsActiveTrue(clubId);
}
// === Fee Assignment ===
public MemberFeeAssignment assignFeeSchedule(UUID memberId, UUID clubId,
UUID feeScheduleId, LocalDate validFrom) {
// Close any existing open assignment
assignmentRepository.findByMemberIdAndValidToIsNull(memberId)
.ifPresent(existing -> {
existing.setValidTo(validFrom.minusDays(1));
assignmentRepository.save(existing);
});
var assignment = new MemberFeeAssignment();
assignment.setMemberId(memberId);
assignment.setClubId(clubId);
assignment.setFeeScheduleId(feeScheduleId);
assignment.setValidFrom(validFrom);
return assignmentRepository.save(assignment);
}
// === Payments ===
public Payment recordPayment(UUID clubId, UUID memberId, int amountCents,
PaymentMethod paymentMethod, LocalDate periodFrom,
LocalDate periodTo, String reference, String notes,
UUID recordedBy) {
var payment = new Payment();
payment.setClubId(clubId);
payment.setMemberId(memberId);
payment.setAmountCents(amountCents);
payment.setPaymentMethod(paymentMethod);
payment.setStatus(PaymentStatus.PAID);
payment.setPeriodFrom(periodFrom);
payment.setPeriodTo(periodTo);
payment.setReference(reference);
payment.setNotes(notes);
payment.setRecordedBy(recordedBy);
payment.setPaidAt(Instant.now());
Payment saved = paymentRepository.save(payment);
// Create corresponding ledger entry (INCOME)
var ledgerEntry = new LedgerEntry();
ledgerEntry.setClubId(clubId);
ledgerEntry.setTransactionType(TransactionType.INCOME);
ledgerEntry.setCategory("MEMBERSHIP_FEE");
ledgerEntry.setAmountCents(amountCents);
ledgerEntry.setDescription("Mitgliedsbeitrag: " + periodFrom + " - " + periodTo);
ledgerEntry.setReference(reference);
ledgerEntry.setPaymentId(saved.getId());
ledgerEntry.setRecordedBy(recordedBy);
ledgerEntry.setTransactionDate(LocalDate.now());
ledgerEntryRepository.save(ledgerEntry);
auditService.log(AuditEventType.PAYMENT_RECORDED, "Payment",
saved.getId().toString(),
"Payment recorded: " + amountCents + " cents from member " + memberId);
log.info("Payment recorded: {} cents from member {} for period {}-{} in club {}",
amountCents, memberId, periodFrom, periodTo, clubId);
return saved;
}
public Payment voidPayment(UUID paymentId, UUID voidedBy, String reason) {
Payment payment = paymentRepository.findById(paymentId)
.orElseThrow(() -> new NoSuchElementException("Payment not found: " + paymentId));
if (payment.getStatus() == PaymentStatus.VOIDED) {
throw new IllegalStateException("Payment already voided: " + paymentId);
}
payment.setStatus(PaymentStatus.VOIDED);
payment.setVoidedAt(Instant.now());
payment.setVoidedBy(voidedBy);
payment.setVoidReason(reason);
Payment saved = paymentRepository.save(payment);
// Create compensating negative ledger entry
var compensating = new LedgerEntry();
compensating.setClubId(payment.getClubId());
compensating.setTransactionType(TransactionType.EXPENSE);
compensating.setCategory("MEMBERSHIP_FEE_VOID");
compensating.setAmountCents(payment.getAmountCents());
compensating.setDescription("Storno: Mitgliedsbeitrag " + payment.getPeriodFrom() + " - " + payment.getPeriodTo() + ". Grund: " + reason);
compensating.setPaymentId(paymentId);
compensating.setRecordedBy(voidedBy);
compensating.setTransactionDate(LocalDate.now());
ledgerEntryRepository.save(compensating);
auditService.log(AuditEventType.PAYMENT_VOIDED, "Payment",
paymentId.toString(), "Payment voided: " + reason);
log.info("Payment voided: {} — reason: {}", paymentId, reason);
return saved;
}
public Page<Payment> getPayments(UUID clubId, Pageable pageable) {
return paymentRepository.findByClubId(clubId, pageable);
}
public Page<Payment> getPaymentsByMember(UUID clubId, UUID memberId, Pageable pageable) {
return paymentRepository.findByClubIdAndMemberId(clubId, memberId, pageable);
}
public Page<Payment> getPaymentsByStatus(UUID clubId, PaymentStatus status, Pageable pageable) {
return paymentRepository.findByClubIdAndStatus(clubId, status, pageable);
}
// === Expenses ===
public LedgerEntry recordExpense(UUID clubId, ExpenseCategory category, int amountCents,
String description, String reference, UUID recordedBy,
LocalDate transactionDate) {
var entry = new LedgerEntry();
entry.setClubId(clubId);
entry.setTransactionType(TransactionType.EXPENSE);
entry.setCategory(category.name());
entry.setAmountCents(amountCents);
entry.setDescription(description);
entry.setReference(reference);
entry.setRecordedBy(recordedBy);
entry.setTransactionDate(transactionDate);
LedgerEntry saved = ledgerEntryRepository.save(entry);
auditService.log(AuditEventType.EXPENSE_RECORDED, "LedgerEntry",
saved.getId().toString(),
"Expense recorded: " + amountCents + " cents (" + category + ")");
log.info("Expense recorded: {} cents ({}) in club {}", amountCents, category, clubId);
return saved;
}
// === Kassenbuch / Ledger ===
public Page<LedgerEntry> getLedgerEntries(UUID clubId, LocalDate from, LocalDate to, Pageable pageable) {
return ledgerEntryRepository.findByClubIdAndTransactionDateBetween(clubId, from, to, pageable);
}
// === Financial Summary ===
public Map<String, Object> getFinancialSummary(UUID clubId, LocalDate from, LocalDate to) {
Long totalIncome = ledgerEntryRepository.sumIncomeByClubAndDateRange(clubId, from, to);
Long totalExpenses = ledgerEntryRepository.sumExpensesByClubAndDateRange(clubId, from, to);
Long balance = ledgerEntryRepository.calculateBalance(clubId, to);
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("totalIncomeCents", totalIncome);
summary.put("totalExpensesCents", totalExpenses);
summary.put("netCents", totalIncome - totalExpenses);
summary.put("balanceCents", balance);
summary.put("periodFrom", from.toString());
summary.put("periodTo", to.toString());
return summary;
}
// === Member Balance ===
public Map<String, Object> getMemberBalance(UUID clubId, UUID memberId) {
Long totalPaid = paymentRepository.sumPaidByMember(clubId, memberId);
Map<String, Object> balance = new LinkedHashMap<>();
balance.put("memberId", memberId);
balance.put("totalPaidCents", totalPaid);
// Get current fee assignment
assignmentRepository.findByMemberIdAndValidToIsNull(memberId)
.ifPresent(assignment -> {
feeScheduleRepository.findById(assignment.getFeeScheduleId())
.ifPresent(schedule -> {
balance.put("currentSchedule", schedule.getName());
balance.put("amountPerIntervalCents", schedule.getAmountCents());
balance.put("interval", schedule.getInterval().name());
});
});
return balance;
}
// === Outstanding / Overdue Members ===
public List<Map<String, Object>> getOutstandingMembers(UUID clubId) {
List<MemberFeeAssignment> activeAssignments = assignmentRepository.findByClubIdAndValidToIsNull(clubId);
List<Map<String, Object>> outstanding = new ArrayList<>();
for (MemberFeeAssignment assignment : activeAssignments) {
Long totalPaid = paymentRepository.sumPaidByMember(clubId, assignment.getMemberId());
// Simple check: if no payments at all, flag as outstanding
if (totalPaid == 0) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("memberId", assignment.getMemberId());
entry.put("feeScheduleId", assignment.getFeeScheduleId());
entry.put("validFrom", assignment.getValidFrom().toString());
entry.put("totalPaidCents", 0);
outstanding.add(entry);
}
}
return outstanding;
}
}
@@ -0,0 +1,19 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.FeeSchedule;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface FeeScheduleRepository extends JpaRepository<FeeSchedule, UUID> {
List<FeeSchedule> findByClubIdAndIsActiveTrue(UUID clubId);
List<FeeSchedule> findByClubId(UUID clubId);
Optional<FeeSchedule> findByClubIdAndIsDefaultTrue(UUID clubId);
}
@@ -0,0 +1,48 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.LedgerEntry;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
@Repository
public interface LedgerEntryRepository extends JpaRepository<LedgerEntry, UUID> {
Page<LedgerEntry> findByClubIdAndTransactionDateBetween(UUID clubId, LocalDate from, LocalDate to, Pageable pageable);
List<LedgerEntry> findByClubIdAndTransactionDateBetween(UUID clubId, LocalDate from, LocalDate to);
@Query("SELECT COALESCE(SUM(le.amountCents), 0) FROM LedgerEntry le " +
"WHERE le.clubId = :clubId AND le.category = :category " +
"AND le.transactionDate >= :from AND le.transactionDate <= :to")
Long sumByClubIdAndCategoryAndDateRange(@Param("clubId") UUID clubId,
@Param("category") String category,
@Param("from") LocalDate from,
@Param("to") LocalDate to);
@Query("SELECT COALESCE(SUM(CASE WHEN le.transactionType = 'INCOME' THEN le.amountCents ELSE 0 END), 0) " +
"FROM LedgerEntry le WHERE le.clubId = :clubId " +
"AND le.transactionDate >= :from AND le.transactionDate <= :to")
Long sumIncomeByClubAndDateRange(@Param("clubId") UUID clubId,
@Param("from") LocalDate from,
@Param("to") LocalDate to);
@Query("SELECT COALESCE(SUM(CASE WHEN le.transactionType = 'EXPENSE' THEN le.amountCents ELSE 0 END), 0) " +
"FROM LedgerEntry le WHERE le.clubId = :clubId " +
"AND le.transactionDate >= :from AND le.transactionDate <= :to")
Long sumExpensesByClubAndDateRange(@Param("clubId") UUID clubId,
@Param("from") LocalDate from,
@Param("to") LocalDate to);
@Query("SELECT COALESCE(SUM(CASE WHEN le.transactionType = 'INCOME' THEN le.amountCents " +
"ELSE -le.amountCents END), 0) FROM LedgerEntry le " +
"WHERE le.clubId = :clubId AND le.transactionDate <= :asOf")
Long calculateBalance(@Param("clubId") UUID clubId, @Param("asOf") LocalDate asOf);
}
@@ -0,0 +1,19 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.MemberFeeAssignment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface MemberFeeAssignmentRepository extends JpaRepository<MemberFeeAssignment, UUID> {
Optional<MemberFeeAssignment> findByMemberIdAndValidToIsNull(UUID memberId);
List<MemberFeeAssignment> findByClubId(UUID clubId);
List<MemberFeeAssignment> findByClubIdAndValidToIsNull(UUID clubId);
}
@@ -0,0 +1,44 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.Payment;
import de.cannamanage.domain.enums.PaymentStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
@Repository
public interface PaymentRepository extends JpaRepository<Payment, UUID> {
List<Payment> findByClubIdAndMemberId(UUID clubId, UUID memberId);
Page<Payment> findByClubId(UUID clubId, Pageable pageable);
Page<Payment> findByClubIdAndMemberId(UUID clubId, UUID memberId, Pageable pageable);
Page<Payment> findByClubIdAndStatus(UUID clubId, PaymentStatus status, Pageable pageable);
@Query("SELECT p FROM Payment p WHERE p.clubId = :clubId AND p.memberId = :memberId " +
"AND p.status = 'PAID' AND p.periodFrom >= :from AND p.periodTo <= :to")
List<Payment> findPaidByMemberAndPeriod(@Param("clubId") UUID clubId,
@Param("memberId") UUID memberId,
@Param("from") LocalDate from,
@Param("to") LocalDate to);
@Query("SELECT COALESCE(SUM(p.amountCents), 0) FROM Payment p " +
"WHERE p.clubId = :clubId AND p.status = 'PAID' " +
"AND p.periodFrom >= :from AND p.periodTo <= :to")
Long sumByClubIdAndPeriod(@Param("clubId") UUID clubId,
@Param("from") LocalDate from,
@Param("to") LocalDate to);
@Query("SELECT COALESCE(SUM(p.amountCents), 0) FROM Payment p " +
"WHERE p.clubId = :clubId AND p.memberId = :memberId AND p.status = 'PAID'")
Long sumPaidByMember(@Param("clubId") UUID clubId, @Param("memberId") UUID memberId);
}