feat(sprint9): Phase 6 — Compliance dashboard, RetentionService, testing

Backend:
- ComplianceDashboardService: traffic-light status per ComplianceArea
  (KCANG/FINANCE/DSGVO/VEREIN) based on deadlines, payments, board positions
- RetentionService: scheduled anonymization of expired member data (KCanG §24,
  5 years), with dry-run preview and retention report endpoints
- ComplianceDeadlineSeeder: seeds 5 standard recurring deadlines on club creation
- ComplianceDashboardController: GET /api/v1/compliance/dashboard,
  GET /retention, POST /retention/preview
- Repository additions: countOverdue, countActive board positions/members

Frontend:
- /compliance page with traffic-light status cards per area
- Overdue deadlines section (highlighted red) with 'days overdue' badges
- Upcoming deadlines with 'days until due' badges and 'Complete' buttons
- Retention info cards (KCanG §24: 5y, AO §147: 10y, DSGVO: 2y)
- Navigation: added 'Compliance-Status' to sidebar under Compliance group
- compliance-dashboard.ts service with mock data for dev mode

Build verified: pnpm build passes clean.
This commit is contained in:
Patrick Plate
2026-06-15 14:12:01 +02:00
parent 87511e0485
commit 57f418f7c9
15 changed files with 1273 additions and 3 deletions
@@ -0,0 +1,152 @@
package de.cannamanage.service;
import de.cannamanage.domain.entity.ComplianceDeadline;
import de.cannamanage.domain.enums.ComplianceArea;
import de.cannamanage.domain.enums.ComplianceStatus;
import de.cannamanage.service.repository.BoardMemberRepository;
import de.cannamanage.service.repository.BoardPositionRepository;
import de.cannamanage.service.repository.ComplianceDeadlineRepository;
import de.cannamanage.service.repository.PaymentRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.*;
/**
* Read-only compliance dashboard service.
* Calculates green/yellow/red status per compliance area based on deadline adherence
* and entity state. No mutations — purely analytical.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ComplianceDashboardService {
private final ComplianceDeadlineRepository deadlineRepository;
private final PaymentRepository paymentRepository;
private final BoardPositionRepository boardPositionRepository;
private final BoardMemberRepository boardMemberRepository;
/**
* Calculates compliance status for all areas of a club.
* GREEN = all obligations met, YELLOW = warning (approaching deadline), RED = overdue/critical.
*/
public Map<ComplianceArea, ComplianceStatus> getComplianceStatus(UUID clubId) {
Map<ComplianceArea, ComplianceStatus> statusMap = new EnumMap<>(ComplianceArea.class);
statusMap.put(ComplianceArea.KCANG, calculateAreaDeadlineStatus(clubId, ComplianceArea.KCANG));
statusMap.put(ComplianceArea.FINANCE, calculateFinanceStatus(clubId));
statusMap.put(ComplianceArea.DSGVO, calculateAreaDeadlineStatus(clubId, ComplianceArea.DSGVO));
statusMap.put(ComplianceArea.VEREIN, calculateVereinStatus(clubId));
return statusMap;
}
/**
* Upcoming deadlines within the given number of days (not yet completed).
*/
public List<ComplianceDeadline> getUpcomingDeadlines(UUID clubId, int days) {
LocalDate now = LocalDate.now();
LocalDate horizon = now.plusDays(days);
return deadlineRepository.findByClubIdAndDueDateBetween(clubId, now, horizon)
.stream()
.filter(d -> d.getCompletedAt() == null)
.toList();
}
/**
* Overdue deadlines (past due date and not completed).
*/
public List<ComplianceDeadline> getOverdueDeadlines(UUID clubId) {
return deadlineRepository.findByClubIdAndDueDateBeforeAndCompletedAtIsNull(clubId, LocalDate.now());
}
// --- Private status calculation methods ---
/**
* FINANCE: combines deadline status with payment overdue status.
* red if any payment > 60 days overdue, yellow if any overdue exists.
*/
private ComplianceStatus calculateFinanceStatus(UUID clubId) {
ComplianceStatus deadlineStatus = calculateAreaDeadlineStatus(clubId, ComplianceArea.FINANCE);
LocalDate now = LocalDate.now();
long overdueCount = paymentRepository.countOverdueByClubId(clubId, now);
long severelyOverdueCount = paymentRepository.countOverdueByClubIdAndDaysPast(clubId, now.minusDays(60));
ComplianceStatus paymentStatus;
if (severelyOverdueCount > 0) {
paymentStatus = ComplianceStatus.RED;
} else if (overdueCount > 0) {
paymentStatus = ComplianceStatus.YELLOW;
} else {
paymentStatus = ComplianceStatus.GREEN;
}
return worstOf(deadlineStatus, paymentStatus);
}
/**
* VEREIN: combines deadline status with board position vacancy checks.
* red if vacant positions, yellow if term expiring within 90 days.
*/
private ComplianceStatus calculateVereinStatus(UUID clubId) {
ComplianceStatus deadlineStatus = calculateAreaDeadlineStatus(clubId, ComplianceArea.VEREIN);
long totalPositions = boardPositionRepository.countByClubIdAndIsActiveTrue(clubId);
long filledPositions = boardMemberRepository.countByClubIdAndIsCurrentTrue(clubId);
ComplianceStatus boardStatus;
if (totalPositions == 0) {
boardStatus = ComplianceStatus.GREEN;
} else if (filledPositions < totalPositions) {
boardStatus = ComplianceStatus.RED;
} else {
long expiringCount = boardMemberRepository.countByClubIdAndIsCurrentTrueAndTermEndBefore(
clubId, LocalDate.now().plusDays(90));
boardStatus = expiringCount > 0 ? ComplianceStatus.YELLOW : ComplianceStatus.GREEN;
}
return worstOf(deadlineStatus, boardStatus);
}
/**
* Generic deadline-based status calculation for any compliance area.
* RED = overdue deadlines exist, YELLOW = due within 30 days, GREEN = all clear.
*/
private ComplianceStatus calculateAreaDeadlineStatus(UUID clubId, ComplianceArea area) {
LocalDate now = LocalDate.now();
List<ComplianceDeadline> overdue = deadlineRepository
.findByClubIdAndDueDateBeforeAndCompletedAtIsNull(clubId, now)
.stream()
.filter(d -> d.getArea() == area)
.toList();
if (!overdue.isEmpty()) {
return ComplianceStatus.RED;
}
List<ComplianceDeadline> upcoming = deadlineRepository
.findByClubIdAndDueDateBetween(clubId, now, now.plusDays(30))
.stream()
.filter(d -> d.getArea() == area && d.getCompletedAt() == null)
.toList();
if (!upcoming.isEmpty()) {
return ComplianceStatus.YELLOW;
}
return ComplianceStatus.GREEN;
}
private ComplianceStatus worstOf(ComplianceStatus a, ComplianceStatus b) {
if (a == ComplianceStatus.RED || b == ComplianceStatus.RED) return ComplianceStatus.RED;
if (a == ComplianceStatus.YELLOW || b == ComplianceStatus.YELLOW) return ComplianceStatus.YELLOW;
return ComplianceStatus.GREEN;
}
}
@@ -0,0 +1,93 @@
package de.cannamanage.service;
import de.cannamanage.domain.entity.ComplianceDeadline;
import de.cannamanage.domain.enums.ComplianceArea;
import de.cannamanage.service.repository.ComplianceDeadlineRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* Seeds standard compliance deadlines when a club is created.
* These are legally required recurring tasks that every cannabis club must track.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ComplianceDeadlineSeeder {
private final ComplianceDeadlineRepository deadlineRepository;
/**
* Seeds initial compliance deadlines for a newly created club.
* Called during club creation or on first admin login.
*/
@Transactional
public void seedDefaultDeadlines(UUID clubId) {
// Check if deadlines already exist for this club
List<ComplianceDeadline> existing = deadlineRepository.findByTenantIdOrderByDueDateAsc(clubId);
if (!existing.isEmpty()) {
log.debug("Club {} already has {} deadlines — skipping seed", clubId, existing.size());
return;
}
int currentYear = LocalDate.now().getYear();
log.info("Seeding default compliance deadlines for club {}", clubId);
// KCanG §22: Jahresbericht an Behörde (annual, January)
createDeadline(clubId, ComplianceArea.KCANG,
"Jahresbericht an Behörde",
"Jährlicher Bericht gemäß KCanG §22 an die zuständige Behörde",
LocalDate.of(currentYear + 1, 1, 31),
true, "YEARLY");
// §4(3) EStG: EÜR erstellen (annual, March)
createDeadline(clubId, ComplianceArea.FINANCE,
"Einnahmen-Überschuss-Rechnung (EÜR)",
"Erstellung der EÜR gemäß §4(3) EStG für das Vorjahr",
LocalDate.of(currentYear + 1, 3, 31),
true, "YEARLY");
// Satzung: Mitgliederversammlung (annual, configurable)
createDeadline(clubId, ComplianceArea.VEREIN,
"Ordentliche Mitgliederversammlung",
"Jährliche Mitgliederversammlung gemäß Vereinssatzung",
LocalDate.of(currentYear, 12, 31),
true, "YEARLY");
// Art. 30 DSGVO: VVT aktualisieren (annual)
createDeadline(clubId, ComplianceArea.DSGVO,
"Verzeichnis von Verarbeitungstätigkeiten (VVT) aktualisieren",
"Jährliche Überprüfung und Aktualisierung des VVT gemäß Art. 30 DSGVO",
LocalDate.of(currentYear, 12, 31),
true, "YEARLY");
// Satzung: Kassenprüfung (annual, before MV)
createDeadline(clubId, ComplianceArea.FINANCE,
"Kassenprüfung durchführen",
"Prüfung der Vereinskasse durch gewählte Kassenprüfer (vor der MV)",
LocalDate.of(currentYear, 11, 30),
true, "YEARLY");
log.info("Seeded 5 default compliance deadlines for club {}", clubId);
}
private void createDeadline(UUID clubId, ComplianceArea area, String title,
String description, LocalDate dueDate,
boolean isRecurring, String recurrenceRule) {
ComplianceDeadline deadline = new ComplianceDeadline();
deadline.setClubId(clubId);
deadline.setArea(area);
deadline.setTitle(title);
deadline.setDescription(description);
deadline.setDueDate(dueDate);
deadline.setIsRecurring(isRecurring);
deadline.setRecurrenceRule(recurrenceRule);
deadlineRepository.save(deadline);
}
}
@@ -0,0 +1,215 @@
package de.cannamanage.service;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.domain.entity.Member;
import de.cannamanage.domain.enums.AuditEventType;
import de.cannamanage.domain.enums.MemberStatus;
import de.cannamanage.service.repository.ClubRepository;
import de.cannamanage.service.repository.DistributionRepository;
import de.cannamanage.service.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.*;
/**
* Automated data lifecycle management per German retention laws.
* - KCanG §24: 5 years for cannabis member records after leaving
* - AO §147: 10 years for financial records
* - DSGVO: 2 years for inactive communication data
*
* IMPORTANT: Never hard-deletes financial records — only marks as retention_expired.
* Anonymization replaces PII with "ANONYMISIERT-{hash}" while keeping structure for stats.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnProperty(name = "cannamanage.schedulers.enabled", havingValue = "true", matchIfMissing = false)
public class RetentionService {
private final ClubRepository clubRepository;
private final MemberRepository memberRepository;
private final DistributionRepository distributionRepository;
private final AuditService auditService;
/**
* Daily scheduled retention processing at 2:00 AM.
* Processes each club independently.
*/
@Scheduled(cron = "0 0 2 * * *")
@Transactional
public void processRetention() {
log.info("Starting scheduled retention processing");
List<Club> clubs = clubRepository.findAll();
int totalAnonymized = 0;
for (Club club : clubs) {
try {
RetentionResult result = processClubRetention(club.getId());
totalAnonymized += result.membersAnonymized();
log.info("Club {}: anonymized {} members", club.getId(), result.membersAnonymized());
} catch (Exception e) {
log.error("Retention processing failed for club {}: {}", club.getId(), e.getMessage(), e);
}
}
log.info("Retention processing complete. Total members anonymized: {}", totalAnonymized);
}
/**
* Processes retention for a single club.
* Returns a result with counts of what was affected.
*/
@Transactional
public RetentionResult processClubRetention(UUID clubId) {
int membersAnonymized = anonymizeExpiredMembers(clubId);
return new RetentionResult(membersAnonymized, 0, 0);
}
/**
* Dry-run: shows what WOULD be affected without making changes.
*/
@Transactional(readOnly = true)
public RetentionPreview previewRetention(UUID clubId) {
LocalDate cutoffDate = LocalDate.now().minusYears(5);
List<Member> expiredMembers = memberRepository.findByClubIdAndStatusAndMembershipDateBefore(
clubId, MemberStatus.LEFT, cutoffDate);
// Filter to only those not yet anonymized
List<Member> toAnonymize = expiredMembers.stream()
.filter(m -> !m.getFirstName().startsWith("ANONYMISIERT"))
.toList();
return new RetentionPreview(
toAnonymize.size(),
toAnonymize.stream()
.map(m -> new RetentionPreviewItem(
m.getId(),
m.getMembershipNumber(),
m.getMembershipDate(),
"KCanG §24 — 5 Jahre nach Austritt"))
.toList()
);
}
/**
* Gets a retention report showing upcoming, completed, and scheduled deletions.
*/
@Transactional(readOnly = true)
public RetentionReport getRetentionReport(UUID clubId) {
LocalDate now = LocalDate.now();
LocalDate kcangCutoff = now.minusYears(5);
// Already anonymized
List<Member> anonymized = memberRepository.findByClubIdAndStatus(clubId, MemberStatus.LEFT)
.stream()
.filter(m -> m.getFirstName().startsWith("ANONYMISIERT"))
.toList();
// Upcoming (will be anonymized within next year)
LocalDate upcomingCutoff = now.minusYears(4);
List<Member> upcoming = memberRepository.findByClubIdAndStatusAndMembershipDateBefore(
clubId, MemberStatus.LEFT, upcomingCutoff)
.stream()
.filter(m -> !m.getFirstName().startsWith("ANONYMISIERT"))
.filter(m -> m.getMembershipDate().isBefore(kcangCutoff.plusYears(1)))
.toList();
return new RetentionReport(
anonymized.size(),
upcoming.size(),
kcangCutoff,
List.of(
new RetentionScheduleItem("KCanG §24", "Mitgliederdaten nach Austritt", 5),
new RetentionScheduleItem("AO §147", "Finanzdaten (Aufbewahrungspflicht)", 10),
new RetentionScheduleItem("DSGVO", "Kommunikationsdaten (inaktiv)", 2)
)
);
}
// --- Private methods ---
/**
* Anonymizes members who left > 5 years ago (KCanG §24).
* Replaces PII with "ANONYMISIERT-{hash}" while keeping record structure.
*/
private int anonymizeExpiredMembers(UUID clubId) {
LocalDate cutoffDate = LocalDate.now().minusYears(5);
List<Member> expiredMembers = memberRepository.findByClubIdAndStatusAndMembershipDateBefore(
clubId, MemberStatus.LEFT, cutoffDate);
int count = 0;
for (Member member : expiredMembers) {
// Skip already anonymized
if (member.getFirstName().startsWith("ANONYMISIERT")) {
continue;
}
String hash = member.getId().toString().substring(0, 8);
// Anonymize PII fields
member.setFirstName("ANONYMISIERT-" + hash);
member.setLastName("ANONYMISIERT-" + hash);
member.setEmail("anonymisiert-" + hash + "@deleted.local");
member.setDateOfBirth(LocalDate.of(1900, 1, 1)); // sentinel date
memberRepository.save(member);
// Audit the anonymization
auditService.log(
AuditEventType.RETENTION_DELETED,
"Member",
member.getId(),
null,
"SYSTEM",
"RETENTION_SERVICE",
"KCanG §24: Member data anonymized after 5-year retention period",
"{\"membershipNumber\":\"" + member.getMembershipNumber() + "\"}",
null
);
count++;
}
return count;
}
// --- DTOs ---
public record RetentionResult(
int membersAnonymized,
int financialRecordsExpired,
int communicationRecordsDeleted
) {}
public record RetentionPreview(
int affectedCount,
List<RetentionPreviewItem> items
) {}
public record RetentionPreviewItem(
UUID memberId,
String membershipNumber,
LocalDate membershipDate,
String reason
) {}
public record RetentionReport(
int totalAnonymized,
int upcomingAnonymizations,
LocalDate currentCutoffDate,
List<RetentionScheduleItem> retentionSchedule
) {}
public record RetentionScheduleItem(
String legalBasis,
String description,
int retentionYears
) {}
}
@@ -19,4 +19,8 @@ public interface BoardMemberRepository extends JpaRepository<BoardMember, UUID>
List<BoardMember> findByClubIdAndIsCurrentTrueAndTermEndBefore(UUID clubId, LocalDate date);
List<BoardMember> findByClubIdAndIsCurrentTrueAndTermEndBetween(UUID clubId, LocalDate from, LocalDate to);
long countByClubIdAndIsCurrentTrue(UUID clubId);
long countByClubIdAndIsCurrentTrueAndTermEndBefore(UUID clubId, LocalDate date);
}
@@ -11,4 +11,6 @@ public interface BoardPositionRepository extends JpaRepository<BoardPosition, UU
List<BoardPosition> findByClubIdAndIsActiveTrueOrderBySortOrderAsc(UUID clubId);
List<BoardPosition> findByClubIdOrderBySortOrderAsc(UUID clubId);
long countByClubIdAndIsActiveTrue(UUID clubId);
}
@@ -23,4 +23,8 @@ public interface DocumentRepository extends JpaRepository<Document, UUID> {
@Query("SELECT COALESCE(SUM(d.fileSize), 0) FROM Document d WHERE d.clubId = :clubId")
Long sumFileSizeByClubId(@Param("clubId") UUID clubId);
@Query("SELECT CASE WHEN COUNT(d) > 0 THEN true ELSE false END FROM Document d " +
"WHERE d.clubId = :clubId AND d.category = :category")
boolean existsByClubIdAndCategory(@Param("clubId") UUID clubId, @Param("category") DocumentCategory category);
}
@@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
@@ -57,6 +58,22 @@ public interface MemberRepository extends JpaRepository<Member, UUID> {
.toList();
}
/**
* Find members by club and status (for retention processing).
*/
default List<Member> findByClubIdAndStatus(UUID clubId, MemberStatus status) {
return findByTenantIdAndStatus(clubId, status);
}
/**
* Find members who left before a given date (for retention: KCanG §24).
*/
@Query("SELECT m FROM Member m WHERE m.tenantId = :tenantId AND m.status = :status AND m.membershipDate < :date")
List<Member> findByClubIdAndStatusAndMembershipDateBefore(
@org.springframework.data.repository.query.Param("tenantId") UUID clubId,
@org.springframework.data.repository.query.Param("status") MemberStatus status,
@org.springframework.data.repository.query.Param("date") LocalDate date);
/**
* Get all active member user IDs (for broadcast notifications).
* Uses the Hibernate tenant filter, so no explicit tenantId parameter needed.
@@ -41,4 +41,12 @@ public interface PaymentRepository extends JpaRepository<Payment, UUID> {
@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);
@Query("SELECT COUNT(p) FROM Payment p WHERE p.clubId = :clubId " +
"AND p.status = 'PENDING' AND p.dueDate < :now")
long countOverdueByClubId(@Param("clubId") UUID clubId, @Param("now") LocalDate now);
@Query("SELECT COUNT(p) FROM Payment p WHERE p.clubId = :clubId " +
"AND p.status = 'PENDING' AND p.dueDate < :cutoff")
long countOverdueByClubIdAndDaysPast(@Param("clubId") UUID clubId, @Param("cutoff") LocalDate cutoff);
}