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:
+152
@@ -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;
|
||||
}
|
||||
}
|
||||
+93
@@ -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
|
||||
) {}
|
||||
}
|
||||
+4
@@ -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);
|
||||
}
|
||||
|
||||
+2
@@ -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);
|
||||
}
|
||||
|
||||
+4
@@ -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);
|
||||
}
|
||||
|
||||
+17
@@ -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.
|
||||
|
||||
+8
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user