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:
Patrick Plate
2026-06-15 09:22:49 +02:00
parent e4698827ee
commit 61b0cd92be
17 changed files with 639 additions and 13 deletions
@@ -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 + "\"")
@@ -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();
}
}
@@ -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}
@@ -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;
@@ -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
@@ -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; }
}
@@ -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.
*/
@@ -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
+75
View File
@@ -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;