feat(sprint8): Phase 5+6 — Integration, schedulers, tier enforcement, testing
Phase 5 — Integration: - PaymentReminderScheduler: monthly cron at 9am, sends PAYMENT_REMINDER and PAYMENT_OVERDUE (30+ days) notifications, audit logged - BoardTermScheduler: daily cron at 8am, sends BOARD_TERM_EXPIRING notification 30 days before term end (1-day window for single delivery) - Assembly protocol auto-archive: completeAssembly() generates PDF via AssemblyProtocolService and stores it in DocumentService.archiveProtocol() - PlanTierService: Sprint 8 limits added (Kassenbuch 3mo starter, 1 MV/year starter, 100MB/1GB/unlimited document storage) - Notification wiring: PAYMENT_RECEIVED sent to member on recordPayment(), sendToAllMembers() added to NotificationService for assembly invitations - Seed data: 2 fee schedules (Regular €30, Reduced €15), 4 fee assignments, 3 sample payments, 2 board positions, 2 board members Phase 6 — Testing infrastructure: - V22 migration: protocol_document_id on assemblies table - Schedulers disabled in test profile (cannamanage.schedulers.enabled=false) - Scheduler property configurable via SCHEDULERS_ENABLED env var Additional fixes (pre-existing Phase 4 issues): - AssemblyProtocolService: fixed Document import ambiguity (OpenPDF vs entity) - AuditService: added convenience overloads for UUID actorId/clubId - ReceiptPdfService: fixed getReceiptNumber/getMemberNumber/getPaymentDate - StaffPermissionChecker: added getUserId/getClubId/getTenantId helpers - FinanceService: added getPaymentById, exportLedgerCsv methods
This commit is contained in:
+6
-1
@@ -4,7 +4,12 @@ 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.*;
|
||||
import de.cannamanage.domain.entity.Assembly;
|
||||
import de.cannamanage.domain.entity.AssemblyAgendaItem;
|
||||
import de.cannamanage.domain.entity.AssemblyAttendee;
|
||||
import de.cannamanage.domain.entity.AssemblyVote;
|
||||
import de.cannamanage.domain.entity.Club;
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import de.cannamanage.domain.enums.VoteResult;
|
||||
import de.cannamanage.service.repository.*;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
@@ -30,6 +30,8 @@ public class AssemblyService {
|
||||
private final MemberRepository memberRepository;
|
||||
private final NotificationService notificationService;
|
||||
private final AuditService auditService;
|
||||
private final AssemblyProtocolService assemblyProtocolService;
|
||||
private final DocumentService documentArchiveService;
|
||||
|
||||
public AssemblyService(AssemblyRepository assemblyRepository,
|
||||
AssemblyAgendaItemRepository agendaItemRepository,
|
||||
@@ -38,7 +40,9 @@ public class AssemblyService {
|
||||
AssemblyVoteRecordRepository voteRecordRepository,
|
||||
MemberRepository memberRepository,
|
||||
NotificationService notificationService,
|
||||
AuditService auditService) {
|
||||
AuditService auditService,
|
||||
AssemblyProtocolService assemblyProtocolService,
|
||||
DocumentService documentArchiveService) {
|
||||
this.assemblyRepository = assemblyRepository;
|
||||
this.agendaItemRepository = agendaItemRepository;
|
||||
this.attendeeRepository = attendeeRepository;
|
||||
@@ -47,6 +51,8 @@ public class AssemblyService {
|
||||
this.memberRepository = memberRepository;
|
||||
this.notificationService = notificationService;
|
||||
this.auditService = auditService;
|
||||
this.assemblyProtocolService = assemblyProtocolService;
|
||||
this.documentArchiveService = documentArchiveService;
|
||||
}
|
||||
|
||||
// === Assembly CRUD ===
|
||||
@@ -179,12 +185,40 @@ public class AssemblyService {
|
||||
assembly.setClosedAt(Instant.now());
|
||||
assembly.setUpdatedAt(Instant.now());
|
||||
|
||||
// Auto-archive: generate protocol PDF and store in document archive
|
||||
autoArchiveProtocol(assembly, completedBy);
|
||||
|
||||
auditService.log(AuditEventType.ASSEMBLY_COMPLETED, completedBy, assembly.getId().toString(),
|
||||
"Mitgliederversammlung geschlossen: " + assembly.getTitle());
|
||||
log.info("Assembly completed: {}", assembly.getTitle());
|
||||
return assemblyRepository.save(assembly);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-archive the assembly protocol PDF to the document archive.
|
||||
* Graceful: if document archival fails, log warning and continue.
|
||||
*/
|
||||
private void autoArchiveProtocol(Assembly assembly, UUID completedBy) {
|
||||
if (assemblyProtocolService == null || documentArchiveService == null) {
|
||||
log.debug("Document archive service not available, skipping protocol auto-archive");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
byte[] protocolPdf = assemblyProtocolService.generateProtocol(assembly.getId());
|
||||
String title = "Protokoll MV " + assembly.getTitle() + " " +
|
||||
assembly.getClosedAt().atZone(java.time.ZoneId.of("Europe/Berlin"))
|
||||
.format(java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy"));
|
||||
|
||||
UUID documentId = documentArchiveService.archiveProtocol(
|
||||
assembly.getClubId(), title, protocolPdf, completedBy);
|
||||
|
||||
assembly.setProtocolDocumentId(documentId);
|
||||
log.info("Protocol auto-archived as document {} for assembly {}", documentId, assembly.getId());
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to auto-archive protocol for assembly {}: {}", assembly.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// === Attendance ===
|
||||
|
||||
public AssemblyAttendee checkInAttendee(UUID assemblyId, UUID memberId, UUID proxyForMemberId) {
|
||||
|
||||
@@ -98,6 +98,24 @@ public class AuditService {
|
||||
null, null, null, description, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience overload with actorId (UUID) and entityId (String).
|
||||
* Used by Sprint 8 services: AssemblyService, DocumentService, BoardService.
|
||||
*/
|
||||
public AuditEvent log(AuditEventType eventType, UUID actorId, String entityId, String description) {
|
||||
return log(eventType, "System", entityId != null ? UUID.fromString(entityId) : null,
|
||||
actorId, null, null, description, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience overload with actorId (UUID) and clubId (UUID).
|
||||
* Used by Sprint 8 services: DocumentService.
|
||||
*/
|
||||
public AuditEvent log(AuditEventType eventType, UUID actorId, UUID clubId, String description) {
|
||||
return log(eventType, "System", clubId,
|
||||
actorId, null, null, description, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience overload with actorId for audit logging.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.BoardMember;
|
||||
import de.cannamanage.domain.entity.BoardPosition;
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import de.cannamanage.domain.enums.NotificationType;
|
||||
import de.cannamanage.service.repository.BoardMemberRepository;
|
||||
import de.cannamanage.service.repository.BoardPositionRepository;
|
||||
import de.cannamanage.service.repository.MemberRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
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.List;
|
||||
|
||||
/**
|
||||
* Scheduler that checks daily for board members whose term is about to expire (within 30 days).
|
||||
* Sends BOARD_TERM_EXPIRING notification once per board member.
|
||||
*
|
||||
* Uses a 1-day window (day 29-30 before expiry) to ensure single delivery without extra flags.
|
||||
*/
|
||||
@Service
|
||||
@ConditionalOnProperty(name = "cannamanage.schedulers.enabled", havingValue = "true", matchIfMissing = true)
|
||||
public class BoardTermScheduler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BoardTermScheduler.class);
|
||||
|
||||
private final BoardMemberRepository boardMemberRepository;
|
||||
private final BoardPositionRepository boardPositionRepository;
|
||||
private final MemberRepository memberRepository;
|
||||
private final NotificationService notificationService;
|
||||
|
||||
public BoardTermScheduler(BoardMemberRepository boardMemberRepository,
|
||||
BoardPositionRepository boardPositionRepository,
|
||||
MemberRepository memberRepository,
|
||||
NotificationService notificationService) {
|
||||
this.boardMemberRepository = boardMemberRepository;
|
||||
this.boardPositionRepository = boardPositionRepository;
|
||||
this.memberRepository = memberRepository;
|
||||
this.notificationService = notificationService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs daily at 8:00 AM.
|
||||
* Finds board members whose term ends within 30 days and sends notification.
|
||||
* Uses a 1-day window to prevent duplicate sends on subsequent days.
|
||||
*/
|
||||
@Scheduled(cron = "0 0 8 * * *")
|
||||
@Transactional
|
||||
public void checkExpiringTerms() {
|
||||
log.info("Board term scheduler started");
|
||||
LocalDate windowStart = LocalDate.now().plusDays(29);
|
||||
LocalDate windowEnd = LocalDate.now().plusDays(30);
|
||||
|
||||
// Find current board members with term ending in the 1-day window
|
||||
List<BoardMember> expiringMembers = boardMemberRepository.findAll()
|
||||
.stream()
|
||||
.filter(bm -> bm.getIsCurrent() != null && bm.getIsCurrent())
|
||||
.filter(bm -> bm.getTermEnd() != null)
|
||||
.filter(bm -> !bm.getTermEnd().isBefore(windowStart) && !bm.getTermEnd().isAfter(windowEnd))
|
||||
.toList();
|
||||
|
||||
int notificationsSent = 0;
|
||||
|
||||
for (BoardMember boardMember : expiringMembers) {
|
||||
Member member = memberRepository.findById(boardMember.getMemberId()).orElse(null);
|
||||
if (member == null || member.getUserId() == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Look up position title
|
||||
String positionName = boardPositionRepository.findById(boardMember.getPositionId())
|
||||
.map(BoardPosition::getTitle)
|
||||
.orElse("Vorstandsmitglied");
|
||||
|
||||
notificationService.sendNotification(
|
||||
member.getUserId(),
|
||||
NotificationType.BOARD_TERM_EXPIRING,
|
||||
"Amtszeit läuft ab",
|
||||
"Deine Amtszeit als " + positionName + " endet am " +
|
||||
boardMember.getTermEnd() +
|
||||
". Bitte kontaktiere den Vorstand bezüglich Neuwahlen.",
|
||||
"/board"
|
||||
);
|
||||
notificationsSent++;
|
||||
}
|
||||
|
||||
log.info("Board term scheduler completed: {} notifications sent", notificationsSent);
|
||||
}
|
||||
}
|
||||
@@ -138,4 +138,46 @@ public class DocumentService {
|
||||
public long getStorageUsage(UUID clubId) {
|
||||
return documentRepository.sumFileSizeByClubId(clubId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a protocol PDF programmatically (no MultipartFile needed).
|
||||
* Used by AssemblyService when auto-archiving assembly protocols.
|
||||
*
|
||||
* @return the document UUID
|
||||
*/
|
||||
@Transactional
|
||||
public UUID archiveProtocol(UUID clubId, String title, byte[] pdfBytes, UUID uploadedBy) {
|
||||
UUID documentId = UUID.randomUUID();
|
||||
String filename = title.replaceAll("[^a-zA-Z0-9äöüÄÖÜß\\s\\-]", "") + ".pdf";
|
||||
String storagePath = clubId + "/" + documentId + "_" + filename;
|
||||
Path fullPath = Paths.get(UPLOAD_BASE, storagePath);
|
||||
|
||||
try {
|
||||
Files.createDirectories(fullPath.getParent());
|
||||
Files.write(fullPath, pdfBytes);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to write protocol PDF to disk", e);
|
||||
}
|
||||
|
||||
Document doc = new Document();
|
||||
doc.setId(documentId);
|
||||
doc.setClubId(clubId);
|
||||
doc.setTitle(title);
|
||||
doc.setCategory(DocumentCategory.PROTOKOLL);
|
||||
doc.setFilename(filename);
|
||||
doc.setContentType("application/pdf");
|
||||
doc.setFileSize((long) pdfBytes.length);
|
||||
doc.setStoragePath(storagePath);
|
||||
doc.setAccessLevel(DocumentAccessLevel.ALL_MEMBERS);
|
||||
doc.setDescription("Automatisch generiertes MV-Protokoll");
|
||||
doc.setUploadedBy(uploadedBy);
|
||||
|
||||
documentRepository.save(doc);
|
||||
|
||||
auditService.log(AuditEventType.DOCUMENT_UPLOADED, uploadedBy, clubId,
|
||||
"Protocol auto-archived: " + title);
|
||||
|
||||
log.info("Protocol archived: {} for club {}", title, clubId);
|
||||
return documentId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,17 +30,23 @@ public class FinanceService {
|
||||
private final PaymentRepository paymentRepository;
|
||||
private final LedgerEntryRepository ledgerEntryRepository;
|
||||
private final AuditService auditService;
|
||||
private final NotificationService notificationService;
|
||||
private final MemberRepository memberRepository;
|
||||
|
||||
public FinanceService(FeeScheduleRepository feeScheduleRepository,
|
||||
MemberFeeAssignmentRepository assignmentRepository,
|
||||
PaymentRepository paymentRepository,
|
||||
LedgerEntryRepository ledgerEntryRepository,
|
||||
AuditService auditService) {
|
||||
AuditService auditService,
|
||||
NotificationService notificationService,
|
||||
MemberRepository memberRepository) {
|
||||
this.feeScheduleRepository = feeScheduleRepository;
|
||||
this.assignmentRepository = assignmentRepository;
|
||||
this.paymentRepository = paymentRepository;
|
||||
this.ledgerEntryRepository = ledgerEntryRepository;
|
||||
this.auditService = auditService;
|
||||
this.notificationService = notificationService;
|
||||
this.memberRepository = memberRepository;
|
||||
}
|
||||
|
||||
// === Fee Schedule CRUD ===
|
||||
@@ -171,6 +177,21 @@ public class FinanceService {
|
||||
saved.getId().toString(),
|
||||
"Payment recorded: " + amountCents + " cents from member " + memberId);
|
||||
|
||||
// Notify member that their payment was recorded (PAYMENT_RECEIVED)
|
||||
memberRepository.findById(memberId).ifPresent(member -> {
|
||||
if (member.getUserId() != null) {
|
||||
notificationService.sendNotification(
|
||||
member.getUserId(),
|
||||
NotificationType.PAYMENT_RECEIVED,
|
||||
"Zahlung erfasst",
|
||||
"Deine Zahlung über " + String.format("%.2f €", amountCents / 100.0) +
|
||||
" für den Zeitraum " + periodFrom + " bis " + periodTo +
|
||||
" wurde erfasst. Vielen Dank!",
|
||||
"/portal/finance"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
log.info("Payment recorded: {} cents from member {} for period {}-{} in club {}",
|
||||
amountCents, memberId, periodFrom, periodTo, clubId);
|
||||
|
||||
@@ -296,6 +317,12 @@ public class FinanceService {
|
||||
return balance;
|
||||
}
|
||||
|
||||
// === Payment Lookup ===
|
||||
|
||||
public java.util.Optional<Payment> getPaymentById(UUID paymentId) {
|
||||
return paymentRepository.findById(paymentId);
|
||||
}
|
||||
|
||||
// === Outstanding / Overdue Members ===
|
||||
|
||||
public List<Map<String, Object>> getOutstandingMembers(UUID clubId) {
|
||||
@@ -317,4 +344,27 @@ public class FinanceService {
|
||||
|
||||
return outstanding;
|
||||
}
|
||||
|
||||
// === Reports ===
|
||||
|
||||
public FinancialReportService.AnnualReportData buildAnnualReportData(UUID clubId, int year) {
|
||||
// Delegate to FinancialReportService for full report data building
|
||||
// This is a stub that will be fleshed out when the full report is needed
|
||||
throw new UnsupportedOperationException("Annual report data building not yet implemented — use FinancialReportService directly");
|
||||
}
|
||||
|
||||
public byte[] exportLedgerCsv(UUID clubId, LocalDate from, LocalDate to) {
|
||||
var entries = ledgerEntryRepository.findByClubIdAndTransactionDateBetween(clubId, from, to);
|
||||
var sb = new StringBuilder();
|
||||
sb.append("Datum;Typ;Kategorie;Betrag;Beschreibung;Referenz\n");
|
||||
for (var entry : entries) {
|
||||
sb.append(entry.getTransactionDate()).append(";");
|
||||
sb.append(entry.getTransactionType()).append(";");
|
||||
sb.append(entry.getCategory()).append(";");
|
||||
sb.append(String.format("%.2f", entry.getAmountCents() / 100.0)).append(";");
|
||||
sb.append(entry.getDescription() != null ? entry.getDescription() : "").append(";");
|
||||
sb.append(entry.getReference() != null ? entry.getReference() : "").append("\n");
|
||||
}
|
||||
return sb.toString().getBytes(java.nio.charset.StandardCharsets.ISO_8859_1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,19 @@ public class NotificationService {
|
||||
return notificationRepository.countByUserIdAndReadFalse(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to ALL active members in a specific club.
|
||||
* Used by Assembly invitations and cancellations.
|
||||
*/
|
||||
@Transactional
|
||||
public void sendToAllMembers(UUID clubId, NotificationType type, String title, String message) {
|
||||
var memberUserIds = memberRepository.findAllActiveUserIds();
|
||||
for (UUID userId : memberUserIds) {
|
||||
sendNotification(userId, type, title, message, null);
|
||||
}
|
||||
log.info("Notification sent to all {} members of club {}: {}", memberUserIds.size(), clubId, title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a single notification as read.
|
||||
*/
|
||||
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.FeeSchedule;
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import de.cannamanage.domain.entity.MemberFeeAssignment;
|
||||
import de.cannamanage.domain.entity.Payment;
|
||||
import de.cannamanage.domain.enums.AuditEventType;
|
||||
import de.cannamanage.domain.enums.FeeInterval;
|
||||
import de.cannamanage.domain.enums.MemberStatus;
|
||||
import de.cannamanage.domain.enums.NotificationType;
|
||||
import de.cannamanage.service.repository.FeeScheduleRepository;
|
||||
import de.cannamanage.service.repository.MemberFeeAssignmentRepository;
|
||||
import de.cannamanage.service.repository.MemberRepository;
|
||||
import de.cannamanage.service.repository.PaymentRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
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.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Scheduler that sends payment reminders on the 1st of each month at 9am.
|
||||
*
|
||||
* Logic:
|
||||
* - Find all active fee assignments (members who owe dues)
|
||||
* - Check if they have a PAID payment for the current period
|
||||
* - If not: send PAYMENT_REMINDER
|
||||
* - If overdue > 30 days: send PAYMENT_OVERDUE (escalation)
|
||||
*
|
||||
* Audit log each reminder sent for compliance (§147 AO).
|
||||
*/
|
||||
@Service
|
||||
@ConditionalOnProperty(name = "cannamanage.schedulers.enabled", havingValue = "true", matchIfMissing = true)
|
||||
public class PaymentReminderScheduler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PaymentReminderScheduler.class);
|
||||
|
||||
private final MemberFeeAssignmentRepository assignmentRepository;
|
||||
private final FeeScheduleRepository feeScheduleRepository;
|
||||
private final PaymentRepository paymentRepository;
|
||||
private final MemberRepository memberRepository;
|
||||
private final NotificationService notificationService;
|
||||
private final AuditService auditService;
|
||||
|
||||
public PaymentReminderScheduler(MemberFeeAssignmentRepository assignmentRepository,
|
||||
FeeScheduleRepository feeScheduleRepository,
|
||||
PaymentRepository paymentRepository,
|
||||
MemberRepository memberRepository,
|
||||
NotificationService notificationService,
|
||||
AuditService auditService) {
|
||||
this.assignmentRepository = assignmentRepository;
|
||||
this.feeScheduleRepository = feeScheduleRepository;
|
||||
this.paymentRepository = paymentRepository;
|
||||
this.memberRepository = memberRepository;
|
||||
this.notificationService = notificationService;
|
||||
this.auditService = auditService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs on the 1st of each month at 9:00 AM.
|
||||
* Finds members with overdue payments and sends reminders.
|
||||
*/
|
||||
@Scheduled(cron = "0 0 9 1 * *")
|
||||
@Transactional
|
||||
public void sendPaymentReminders() {
|
||||
log.info("Payment reminder scheduler started");
|
||||
LocalDate today = LocalDate.now();
|
||||
|
||||
// Get all active fee assignments
|
||||
List<MemberFeeAssignment> activeAssignments = assignmentRepository.findAll()
|
||||
.stream()
|
||||
.filter(a -> a.getValidTo() == null)
|
||||
.toList();
|
||||
|
||||
int remindersSent = 0;
|
||||
int escalationsSent = 0;
|
||||
|
||||
for (MemberFeeAssignment assignment : activeAssignments) {
|
||||
UUID memberId = assignment.getMemberId();
|
||||
UUID clubId = assignment.getClubId();
|
||||
|
||||
// Look up the fee schedule to determine interval
|
||||
FeeSchedule schedule = feeScheduleRepository.findById(assignment.getFeeScheduleId()).orElse(null);
|
||||
if (schedule == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine current billing period based on fee interval
|
||||
LocalDate periodStart = calculatePeriodStart(today, schedule.getInterval());
|
||||
LocalDate periodEnd = calculatePeriodEnd(today, schedule.getInterval());
|
||||
|
||||
// Check if payment exists for current period
|
||||
List<Payment> payments = paymentRepository.findPaidByMemberAndPeriod(
|
||||
clubId, memberId, periodStart, periodEnd);
|
||||
|
||||
if (payments.isEmpty()) {
|
||||
Member member = memberRepository.findById(memberId).orElse(null);
|
||||
if (member == null || member.getStatus() != MemberStatus.ACTIVE) {
|
||||
continue;
|
||||
}
|
||||
if (member.getUserId() == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
long daysOverdue = ChronoUnit.DAYS.between(periodStart, today);
|
||||
|
||||
if (daysOverdue > 30) {
|
||||
// Escalation: overdue > 30 days
|
||||
notificationService.sendNotification(
|
||||
member.getUserId(),
|
||||
NotificationType.PAYMENT_OVERDUE,
|
||||
"Zahlungsrückstand",
|
||||
"Dein Mitgliedsbeitrag ist seit über 30 Tagen überfällig. " +
|
||||
"Bitte begleiche den ausstehenden Betrag zeitnah.",
|
||||
"/portal/finance"
|
||||
);
|
||||
escalationsSent++;
|
||||
|
||||
auditService.log(AuditEventType.PAYMENT_RECORDED, "Payment",
|
||||
UUID.randomUUID(), null, "System",
|
||||
"SCHEDULER", "Payment overdue escalation sent to member " + memberId,
|
||||
null, null);
|
||||
} else {
|
||||
// Standard reminder
|
||||
notificationService.sendNotification(
|
||||
member.getUserId(),
|
||||
NotificationType.PAYMENT_REMINDER,
|
||||
"Beitragserinnerung",
|
||||
"Dein Mitgliedsbeitrag für den aktuellen Zeitraum ist noch offen. " +
|
||||
"Bitte überweise den fälligen Betrag.",
|
||||
"/portal/finance"
|
||||
);
|
||||
remindersSent++;
|
||||
|
||||
auditService.log(AuditEventType.PAYMENT_RECORDED, "Payment",
|
||||
UUID.randomUUID(), null, "System",
|
||||
"SCHEDULER", "Payment reminder sent to member " + memberId,
|
||||
null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Payment reminder scheduler completed: {} reminders, {} escalations sent",
|
||||
remindersSent, escalationsSent);
|
||||
}
|
||||
|
||||
private LocalDate calculatePeriodStart(LocalDate today, FeeInterval interval) {
|
||||
return switch (interval) {
|
||||
case MONTHLY -> today.withDayOfMonth(1);
|
||||
case QUARTERLY -> {
|
||||
int quarter = (today.getMonthValue() - 1) / 3;
|
||||
yield today.withMonth(quarter * 3 + 1).withDayOfMonth(1);
|
||||
}
|
||||
case SEMI_ANNUAL -> {
|
||||
int half = (today.getMonthValue() - 1) / 6;
|
||||
yield today.withMonth(half * 6 + 1).withDayOfMonth(1);
|
||||
}
|
||||
case ANNUAL -> today.withMonth(1).withDayOfMonth(1);
|
||||
};
|
||||
}
|
||||
|
||||
private LocalDate calculatePeriodEnd(LocalDate today, FeeInterval interval) {
|
||||
return switch (interval) {
|
||||
case MONTHLY -> today.withDayOfMonth(today.lengthOfMonth());
|
||||
case QUARTERLY -> {
|
||||
int quarter = (today.getMonthValue() - 1) / 3;
|
||||
LocalDate quarterStart = today.withMonth(quarter * 3 + 1).withDayOfMonth(1);
|
||||
yield quarterStart.plusMonths(3).minusDays(1);
|
||||
}
|
||||
case SEMI_ANNUAL -> {
|
||||
int half = (today.getMonthValue() - 1) / 6;
|
||||
LocalDate halfStart = today.withMonth(half * 6 + 1).withDayOfMonth(1);
|
||||
yield halfStart.plusMonths(6).minusDays(1);
|
||||
}
|
||||
case ANNUAL -> today.withMonth(12).withDayOfMonth(31);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ import java.util.UUID;
|
||||
* - Info Board: Starter = 5 posts/month, Pro/Enterprise = unlimited
|
||||
* - Events: all tiers (no limit)
|
||||
* - Notifications: all tiers
|
||||
* - Finance/Kassenbuch: Starter = last 3 months, Pro/Enterprise = full history
|
||||
* - Assemblies: Starter = 1 MV/year, Pro/Enterprise = unlimited
|
||||
* - Documents: Starter = 100MB, Pro = 1GB, Enterprise = unlimited
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -26,6 +29,12 @@ public class PlanTierService {
|
||||
private static final int STARTER_INFO_BOARD_CATEGORIES_LIMIT = 3;
|
||||
private static final int PRO_FORUM_CATEGORIES_LIMIT = 5;
|
||||
|
||||
// Sprint 8 tier limits
|
||||
private static final int STARTER_KASSENBUCH_MONTHS = 3;
|
||||
private static final int STARTER_ASSEMBLIES_PER_YEAR = 1;
|
||||
private static final long STARTER_DOCUMENT_STORAGE_BYTES = 100L * 1024 * 1024; // 100MB
|
||||
private static final long PRO_DOCUMENT_STORAGE_BYTES = 1024L * 1024 * 1024; // 1GB
|
||||
|
||||
private final SubscriptionRepository subscriptionRepository;
|
||||
|
||||
public PlanTierService(SubscriptionRepository subscriptionRepository) {
|
||||
@@ -142,4 +151,71 @@ public class PlanTierService {
|
||||
public boolean isEnterprise(UUID clubId) {
|
||||
return getClubTier(clubId) == PlanTier.ENTERPRISE;
|
||||
}
|
||||
|
||||
// === Sprint 8: Finance Tier Limits ===
|
||||
|
||||
/**
|
||||
* Get the maximum number of months of Kassenbuch history a club can view.
|
||||
* Starter: 3 months, Pro/Enterprise: unlimited (0 = no limit).
|
||||
*/
|
||||
public int getKassenbuchMonthsLimit(UUID clubId) {
|
||||
PlanTier tier = getClubTier(clubId);
|
||||
if (tier == PlanTier.STARTER || tier == PlanTier.TRIAL) {
|
||||
return STARTER_KASSENBUCH_MONTHS;
|
||||
}
|
||||
return 0; // unlimited
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a club can create a new assembly (Mitgliederversammlung) this year.
|
||||
* Starter: 1 per year, Pro/Enterprise: unlimited.
|
||||
*
|
||||
* @throws QuotaExceededException if assembly limit is reached
|
||||
*/
|
||||
public void checkAssemblyLimit(UUID clubId, int assembliesThisYear) {
|
||||
PlanTier tier = getClubTier(clubId);
|
||||
if ((tier == PlanTier.STARTER || tier == PlanTier.TRIAL)
|
||||
&& assembliesThisYear >= STARTER_ASSEMBLIES_PER_YEAR) {
|
||||
log.warn("Assembly limit reached for club {} (tier: {}, count: {})", clubId, tier, assembliesThisYear);
|
||||
throw new QuotaExceededException(
|
||||
QuotaViolationCode.TIER_UPGRADE_REQUIRED,
|
||||
"Im Starter-Tarif ist maximal " + STARTER_ASSEMBLIES_PER_YEAR +
|
||||
" Mitgliederversammlung pro Jahr möglich. " +
|
||||
"Upgrade auf Pro für unbegrenzte Versammlungen."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a club can upload more documents based on storage limit.
|
||||
* Starter: 100MB, Pro: 1GB, Enterprise: unlimited.
|
||||
*
|
||||
* @throws QuotaExceededException if storage limit is exceeded
|
||||
*/
|
||||
public void checkDocumentStorageLimit(UUID clubId, long currentUsageBytes, long newFileSize) {
|
||||
PlanTier tier = getClubTier(clubId);
|
||||
long limit = getDocumentStorageLimit(tier);
|
||||
|
||||
if (limit > 0 && (currentUsageBytes + newFileSize) > limit) {
|
||||
String limitStr = tier == PlanTier.STARTER || tier == PlanTier.TRIAL ? "100 MB" : "1 GB";
|
||||
log.warn("Document storage limit reached for club {} (tier: {}, usage: {} bytes)", clubId, tier, currentUsageBytes);
|
||||
throw new QuotaExceededException(
|
||||
QuotaViolationCode.TIER_UPGRADE_REQUIRED,
|
||||
"Speicherlimit erreicht (" + limitStr + "). " +
|
||||
"Upgrade deinen Tarif für mehr Speicherplatz."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the document storage limit in bytes for a tier.
|
||||
* Returns 0 for unlimited (Enterprise).
|
||||
*/
|
||||
public long getDocumentStorageLimit(PlanTier tier) {
|
||||
return switch (tier) {
|
||||
case TRIAL, STARTER -> STARTER_DOCUMENT_STORAGE_BYTES;
|
||||
case PRO -> PRO_DOCUMENT_STORAGE_BYTES;
|
||||
case ENTERPRISE -> 0L; // unlimited
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,9 +81,9 @@ public class ReceiptPdfService {
|
||||
title.setSpacingAfter(5);
|
||||
document.add(title);
|
||||
|
||||
// Receipt number
|
||||
String receiptNr = payment.getReceiptNumber() != null
|
||||
? payment.getReceiptNumber()
|
||||
// Receipt number (use reference or generate from ID)
|
||||
String receiptNr = payment.getReference() != null
|
||||
? payment.getReference()
|
||||
: "CM-" + payment.getId().toString().substring(0, 8).toUpperCase();
|
||||
Paragraph nrPara = new Paragraph("Nr. " + receiptNr, RECEIPT_NR_FONT);
|
||||
nrPara.setAlignment(Element.ALIGN_CENTER);
|
||||
@@ -98,15 +98,15 @@ public class ReceiptPdfService {
|
||||
detailsTable.setSpacingAfter(20);
|
||||
|
||||
addDetailRow(detailsTable, "Erhalten von:", getMemberDisplayName(member));
|
||||
addDetailRow(detailsTable, "Mitgliedsnr.:", member.getMemberNumber() != null
|
||||
? member.getMemberNumber() : "—");
|
||||
addDetailRow(detailsTable, "Mitgliedsnr.:", member.getMembershipNumber() != null
|
||||
? member.getMembershipNumber() : "—");
|
||||
|
||||
// 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, "Datum:", payment.getPaidAt().atZone(java.time.ZoneId.of("Europe/Berlin")).format(DATE_FMT));
|
||||
addDetailRow(detailsTable, "Zahlungsart:", translatePaymentMethod(payment.getPaymentMethod().name()));
|
||||
|
||||
// Period covered
|
||||
|
||||
Reference in New Issue
Block a user