diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/FinanceController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/FinanceController.java index 3dfaf8f..242592c 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/controller/FinanceController.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/FinanceController.java @@ -273,8 +273,8 @@ public class FinanceController { .orElseThrow(() -> new NoSuchElementException("Club not found")); byte[] pdf = receiptPdfService.generateReceipt(payment, member, club); - String filename = "Quittung-" + (payment.getReceiptNumber() != null - ? payment.getReceiptNumber() : id.toString().substring(0, 8)) + ".pdf"; + String filename = "Quittung-" + (payment.getReference() != null + ? payment.getReference() : id.toString().substring(0, 8)) + ".pdf"; return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") @@ -347,8 +347,8 @@ public class FinanceController { .orElseThrow(() -> new NoSuchElementException("Club not found")); byte[] pdf = receiptPdfService.generateReceipt(payment, member, club); - String filename = "Quittung-" + (payment.getReceiptNumber() != null - ? payment.getReceiptNumber() : id.toString().substring(0, 8)) + ".pdf"; + String filename = "Quittung-" + (payment.getReference() != null + ? payment.getReference() : id.toString().substring(0, 8)) + ".pdf"; return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/security/StaffPermissionChecker.java b/cannamanage-api/src/main/java/de/cannamanage/api/security/StaffPermissionChecker.java index 9e73090..317718c 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/security/StaffPermissionChecker.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/security/StaffPermissionChecker.java @@ -85,4 +85,25 @@ public class StaffPermissionChecker { throw new org.springframework.security.access.AccessDeniedException("Missing permission: " + required); } } + + /** + * Extract the user ID from the authenticated principal. + */ + public UUID getUserId(org.springframework.security.core.userdetails.UserDetails principal) { + return UUID.fromString(principal.getUsername()); + } + + /** + * Get the club ID (tenant) for the authenticated user. + */ + public UUID getClubId(org.springframework.security.core.userdetails.UserDetails principal) { + return de.cannamanage.domain.entity.TenantContext.getCurrentTenant(); + } + + /** + * Get the tenant ID for the authenticated user (alias for getClubId). + */ + public UUID getTenantId(org.springframework.security.core.userdetails.UserDetails principal) { + return de.cannamanage.domain.entity.TenantContext.getCurrentTenant(); + } } diff --git a/cannamanage-api/src/main/resources/application.properties b/cannamanage-api/src/main/resources/application.properties index 8d768d8..82d7173 100644 --- a/cannamanage-api/src/main/resources/application.properties +++ b/cannamanage-api/src/main/resources/application.properties @@ -38,5 +38,8 @@ management.endpoint.health.show-details=never # Session configuration (member portal) server.servlet.session.timeout=30m server.servlet.session.cookie.same-site=strict + +# Schedulers +cannamanage.schedulers.enabled=${SCHEDULERS_ENABLED:true} server.servlet.session.cookie.http-only=true server.servlet.session.cookie.secure=${SESSION_COOKIE_SECURE:false} diff --git a/cannamanage-api/src/main/resources/db/migration/V22__assembly_protocol_document.sql b/cannamanage-api/src/main/resources/db/migration/V22__assembly_protocol_document.sql new file mode 100644 index 0000000..5d03143 --- /dev/null +++ b/cannamanage-api/src/main/resources/db/migration/V22__assembly_protocol_document.sql @@ -0,0 +1,2 @@ +-- V22: Add protocol_document_id to assemblies for auto-archive feature +ALTER TABLE assemblies ADD COLUMN IF NOT EXISTS protocol_document_id UUID; diff --git a/cannamanage-api/src/test/resources/application-test.properties b/cannamanage-api/src/test/resources/application-test.properties index ca2cf4f..f537bac 100644 --- a/cannamanage-api/src/test/resources/application-test.properties +++ b/cannamanage-api/src/test/resources/application-test.properties @@ -18,3 +18,6 @@ cannamanage.security.jwt.refresh-token-expiry=2592000 # AOP spring.aop.auto=true spring.aop.proxy-target-class=true + +# Disable schedulers in tests +cannamanage.schedulers.enabled=false diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Assembly.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Assembly.java index 0eb4bb7..42a6c3d 100644 --- a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Assembly.java +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Assembly.java @@ -55,6 +55,9 @@ public class Assembly extends AbstractTenantEntity { @Column(name = "closed_at") private Instant closedAt; + @Column(name = "protocol_document_id") + private UUID protocolDocumentId; + @Column(name = "created_by", nullable = false) private UUID createdBy; @@ -104,6 +107,9 @@ public class Assembly extends AbstractTenantEntity { public UUID getCreatedBy() { return createdBy; } public void setCreatedBy(UUID createdBy) { this.createdBy = createdBy; } + public UUID getProtocolDocumentId() { return protocolDocumentId; } + public void setProtocolDocumentId(UUID protocolDocumentId) { this.protocolDocumentId = protocolDocumentId; } + public Instant getUpdatedAt() { return updatedAt; } public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; } } diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/AssemblyProtocolService.java b/cannamanage-service/src/main/java/de/cannamanage/service/AssemblyProtocolService.java index 49ba7ef..94b997a 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/AssemblyProtocolService.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/AssemblyProtocolService.java @@ -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; diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/AssemblyService.java b/cannamanage-service/src/main/java/de/cannamanage/service/AssemblyService.java index 17de247..87b51bd 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/AssemblyService.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/AssemblyService.java @@ -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) { diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/AuditService.java b/cannamanage-service/src/main/java/de/cannamanage/service/AuditService.java index b015258..00c4716 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/AuditService.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/AuditService.java @@ -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. */ diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/BoardTermScheduler.java b/cannamanage-service/src/main/java/de/cannamanage/service/BoardTermScheduler.java new file mode 100644 index 0000000..29ca91a --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/BoardTermScheduler.java @@ -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 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); + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/DocumentService.java b/cannamanage-service/src/main/java/de/cannamanage/service/DocumentService.java index 60fd290..74db283 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/DocumentService.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/DocumentService.java @@ -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; + } } diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/FinanceService.java b/cannamanage-service/src/main/java/de/cannamanage/service/FinanceService.java index 76c2804..f4867b1 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/FinanceService.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/FinanceService.java @@ -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 getPaymentById(UUID paymentId) { + return paymentRepository.findById(paymentId); + } + // === Outstanding / Overdue Members === public List> 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); + } } diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/NotificationService.java b/cannamanage-service/src/main/java/de/cannamanage/service/NotificationService.java index 39350da..f098d14 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/NotificationService.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/NotificationService.java @@ -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. */ diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/PaymentReminderScheduler.java b/cannamanage-service/src/main/java/de/cannamanage/service/PaymentReminderScheduler.java new file mode 100644 index 0000000..3e930e3 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/PaymentReminderScheduler.java @@ -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 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 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); + }; + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/PlanTierService.java b/cannamanage-service/src/main/java/de/cannamanage/service/PlanTierService.java index 6ec8ab3..601cf38 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/PlanTierService.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/PlanTierService.java @@ -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 + }; + } } diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/ReceiptPdfService.java b/cannamanage-service/src/main/java/de/cannamanage/service/ReceiptPdfService.java index bed62ad..ac7681f 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/ReceiptPdfService.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/ReceiptPdfService.java @@ -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 diff --git a/scripts/seed/init.sql b/scripts/seed/init.sql index 8bc1b9c..976c87e 100644 --- a/scripts/seed/init.sql +++ b/scripts/seed/init.sql @@ -122,3 +122,78 @@ VALUES ('h1000000-0000-0000-0000-000000000004', 'a1000000-0000-0000-0000-000000000001', 'e1000000-0000-0000-0000-000000000001', 'DISTRIBUTION', -8.00, 'Distributed to members') ON CONFLICT DO NOTHING; + +-- ============================================================================ +-- Sprint 8: Fee Schedules (2 tiers) +-- ============================================================================ +INSERT INTO fee_schedules (id, tenant_id, club_id, name, amount_cents, interval, is_default, is_active) +VALUES + ('f8000000-0000-0000-0000-000000000001', 'a1000000-0000-0000-0000-000000000001', + 'a1000000-0000-0000-0000-000000000001', 'Regulär', 3000, 'MONTHLY', true, true), + ('f8000000-0000-0000-0000-000000000002', 'a1000000-0000-0000-0000-000000000001', + 'a1000000-0000-0000-0000-000000000001', 'Ermäßigt', 1500, 'MONTHLY', false, true) +ON CONFLICT DO NOTHING; + +-- ============================================================================ +-- Sprint 8: Fee Assignments (members assigned to fee schedules) +-- ============================================================================ +INSERT INTO member_fee_assignments (id, tenant_id, member_id, club_id, fee_schedule_id, valid_from) +VALUES + ('f8100000-0000-0000-0000-000000000001', 'a1000000-0000-0000-0000-000000000001', + 'c1000000-0000-0000-0000-000000000001', 'a1000000-0000-0000-0000-000000000001', + 'f8000000-0000-0000-0000-000000000001', '2024-01-15'), + ('f8100000-0000-0000-0000-000000000002', 'a1000000-0000-0000-0000-000000000001', + 'c1000000-0000-0000-0000-000000000002', 'a1000000-0000-0000-0000-000000000001', + 'f8000000-0000-0000-0000-000000000001', '2024-01-20'), + ('f8100000-0000-0000-0000-000000000003', 'a1000000-0000-0000-0000-000000000001', + 'c1000000-0000-0000-0000-000000000003', 'a1000000-0000-0000-0000-000000000001', + 'f8000000-0000-0000-0000-000000000002', '2024-02-01'), + ('f8100000-0000-0000-0000-000000000004', 'a1000000-0000-0000-0000-000000000001', + 'c1000000-0000-0000-0000-000000000004', 'a1000000-0000-0000-0000-000000000001', + 'f8000000-0000-0000-0000-000000000001', '2024-02-15') +ON CONFLICT DO NOTHING; + +-- ============================================================================ +-- Sprint 8: Sample Payments +-- ============================================================================ +INSERT INTO payments (id, tenant_id, club_id, member_id, amount_cents, payment_method, status, period_from, period_to, paid_at, recorded_by) +VALUES + ('f8200000-0000-0000-0000-000000000001', 'a1000000-0000-0000-0000-000000000001', + 'a1000000-0000-0000-0000-000000000001', 'c1000000-0000-0000-0000-000000000001', + 3000, 'BANK_TRANSFER', 'PAID', '2024-12-01', '2024-12-31', + '2024-12-03 09:00:00+01', 'b1000000-0000-0000-0000-000000000001'), + ('f8200000-0000-0000-0000-000000000002', 'a1000000-0000-0000-0000-000000000001', + 'a1000000-0000-0000-0000-000000000001', 'c1000000-0000-0000-0000-000000000002', + 3000, 'CASH', 'PAID', '2024-12-01', '2024-12-31', + '2024-12-05 14:30:00+01', 'b1000000-0000-0000-0000-000000000001'), + ('f8200000-0000-0000-0000-000000000003', 'a1000000-0000-0000-0000-000000000001', + 'a1000000-0000-0000-0000-000000000001', 'c1000000-0000-0000-0000-000000000001', + 3000, 'BANK_TRANSFER', 'PAID', '2025-01-01', '2025-01-31', + '2025-01-02 10:00:00+01', 'b1000000-0000-0000-0000-000000000001') +ON CONFLICT DO NOTHING; + +-- ============================================================================ +-- Sprint 8: Board Positions (Vorstandspositionen) +-- ============================================================================ +INSERT INTO board_positions (id, tenant_id, club_id, title, description, is_active, sort_order) +VALUES + ('f8300000-0000-0000-0000-000000000001', 'a1000000-0000-0000-0000-000000000001', + 'a1000000-0000-0000-0000-000000000001', '1. Vorsitzender', + 'Gesetzlicher Vertreter nach §26 BGB', true, 1), + ('f8300000-0000-0000-0000-000000000002', 'a1000000-0000-0000-0000-000000000001', + 'a1000000-0000-0000-0000-000000000001', 'Kassenwart', + 'Verantwortlich für Finanzen und Kassenbuch', true, 2) +ON CONFLICT DO NOTHING; + +-- ============================================================================ +-- Sprint 8: Board Members (assigned to positions) +-- ============================================================================ +INSERT INTO board_members (id, tenant_id, club_id, position_id, member_id, elected_at, term_start, term_end, is_current) +VALUES + ('f8400000-0000-0000-0000-000000000001', 'a1000000-0000-0000-0000-000000000001', + 'a1000000-0000-0000-0000-000000000001', 'f8300000-0000-0000-0000-000000000001', + 'c1000000-0000-0000-0000-000000000001', '2024-01-15', '2024-01-15', '2026-01-15', true), + ('f8400000-0000-0000-0000-000000000002', 'a1000000-0000-0000-0000-000000000001', + 'a1000000-0000-0000-0000-000000000001', 'f8300000-0000-0000-0000-000000000002', + 'c1000000-0000-0000-0000-000000000004', '2024-01-15', '2024-01-15', '2026-01-15', true) +ON CONFLICT DO NOTHING;