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:
@@ -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;
|
||||
}
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
+48
@@ -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);
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
+44
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user