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:
@@ -273,8 +273,8 @@ public class FinanceController {
|
|||||||
.orElseThrow(() -> new NoSuchElementException("Club not found"));
|
.orElseThrow(() -> new NoSuchElementException("Club not found"));
|
||||||
|
|
||||||
byte[] pdf = receiptPdfService.generateReceipt(payment, member, club);
|
byte[] pdf = receiptPdfService.generateReceipt(payment, member, club);
|
||||||
String filename = "Quittung-" + (payment.getReceiptNumber() != null
|
String filename = "Quittung-" + (payment.getReference() != null
|
||||||
? payment.getReceiptNumber() : id.toString().substring(0, 8)) + ".pdf";
|
? payment.getReference() : id.toString().substring(0, 8)) + ".pdf";
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||||
@@ -347,8 +347,8 @@ public class FinanceController {
|
|||||||
.orElseThrow(() -> new NoSuchElementException("Club not found"));
|
.orElseThrow(() -> new NoSuchElementException("Club not found"));
|
||||||
|
|
||||||
byte[] pdf = receiptPdfService.generateReceipt(payment, member, club);
|
byte[] pdf = receiptPdfService.generateReceipt(payment, member, club);
|
||||||
String filename = "Quittung-" + (payment.getReceiptNumber() != null
|
String filename = "Quittung-" + (payment.getReference() != null
|
||||||
? payment.getReceiptNumber() : id.toString().substring(0, 8)) + ".pdf";
|
? payment.getReference() : id.toString().substring(0, 8)) + ".pdf";
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||||
|
|||||||
@@ -85,4 +85,25 @@ public class StaffPermissionChecker {
|
|||||||
throw new org.springframework.security.access.AccessDeniedException("Missing permission: " + required);
|
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)
|
# Session configuration (member portal)
|
||||||
server.servlet.session.timeout=30m
|
server.servlet.session.timeout=30m
|
||||||
server.servlet.session.cookie.same-site=strict
|
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.http-only=true
|
||||||
server.servlet.session.cookie.secure=${SESSION_COOKIE_SECURE:false}
|
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
|
# AOP
|
||||||
spring.aop.auto=true
|
spring.aop.auto=true
|
||||||
spring.aop.proxy-target-class=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")
|
@Column(name = "closed_at")
|
||||||
private Instant closedAt;
|
private Instant closedAt;
|
||||||
|
|
||||||
|
@Column(name = "protocol_document_id")
|
||||||
|
private UUID protocolDocumentId;
|
||||||
|
|
||||||
@Column(name = "created_by", nullable = false)
|
@Column(name = "created_by", nullable = false)
|
||||||
private UUID createdBy;
|
private UUID createdBy;
|
||||||
|
|
||||||
@@ -104,6 +107,9 @@ public class Assembly extends AbstractTenantEntity {
|
|||||||
public UUID getCreatedBy() { return createdBy; }
|
public UUID getCreatedBy() { return createdBy; }
|
||||||
public void setCreatedBy(UUID createdBy) { this.createdBy = 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 Instant getUpdatedAt() { return updatedAt; }
|
||||||
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
|
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -4,7 +4,12 @@ import com.lowagie.text.*;
|
|||||||
import com.lowagie.text.pdf.PdfPCell;
|
import com.lowagie.text.pdf.PdfPCell;
|
||||||
import com.lowagie.text.pdf.PdfPTable;
|
import com.lowagie.text.pdf.PdfPTable;
|
||||||
import com.lowagie.text.pdf.PdfWriter;
|
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.domain.enums.VoteResult;
|
||||||
import de.cannamanage.service.repository.*;
|
import de.cannamanage.service.repository.*;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ public class AssemblyService {
|
|||||||
private final MemberRepository memberRepository;
|
private final MemberRepository memberRepository;
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
|
private final AssemblyProtocolService assemblyProtocolService;
|
||||||
|
private final DocumentService documentArchiveService;
|
||||||
|
|
||||||
public AssemblyService(AssemblyRepository assemblyRepository,
|
public AssemblyService(AssemblyRepository assemblyRepository,
|
||||||
AssemblyAgendaItemRepository agendaItemRepository,
|
AssemblyAgendaItemRepository agendaItemRepository,
|
||||||
@@ -38,7 +40,9 @@ public class AssemblyService {
|
|||||||
AssemblyVoteRecordRepository voteRecordRepository,
|
AssemblyVoteRecordRepository voteRecordRepository,
|
||||||
MemberRepository memberRepository,
|
MemberRepository memberRepository,
|
||||||
NotificationService notificationService,
|
NotificationService notificationService,
|
||||||
AuditService auditService) {
|
AuditService auditService,
|
||||||
|
AssemblyProtocolService assemblyProtocolService,
|
||||||
|
DocumentService documentArchiveService) {
|
||||||
this.assemblyRepository = assemblyRepository;
|
this.assemblyRepository = assemblyRepository;
|
||||||
this.agendaItemRepository = agendaItemRepository;
|
this.agendaItemRepository = agendaItemRepository;
|
||||||
this.attendeeRepository = attendeeRepository;
|
this.attendeeRepository = attendeeRepository;
|
||||||
@@ -47,6 +51,8 @@ public class AssemblyService {
|
|||||||
this.memberRepository = memberRepository;
|
this.memberRepository = memberRepository;
|
||||||
this.notificationService = notificationService;
|
this.notificationService = notificationService;
|
||||||
this.auditService = auditService;
|
this.auditService = auditService;
|
||||||
|
this.assemblyProtocolService = assemblyProtocolService;
|
||||||
|
this.documentArchiveService = documentArchiveService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Assembly CRUD ===
|
// === Assembly CRUD ===
|
||||||
@@ -179,12 +185,40 @@ public class AssemblyService {
|
|||||||
assembly.setClosedAt(Instant.now());
|
assembly.setClosedAt(Instant.now());
|
||||||
assembly.setUpdatedAt(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(),
|
auditService.log(AuditEventType.ASSEMBLY_COMPLETED, completedBy, assembly.getId().toString(),
|
||||||
"Mitgliederversammlung geschlossen: " + assembly.getTitle());
|
"Mitgliederversammlung geschlossen: " + assembly.getTitle());
|
||||||
log.info("Assembly completed: {}", assembly.getTitle());
|
log.info("Assembly completed: {}", assembly.getTitle());
|
||||||
return assemblyRepository.save(assembly);
|
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 ===
|
// === Attendance ===
|
||||||
|
|
||||||
public AssemblyAttendee checkInAttendee(UUID assemblyId, UUID memberId, UUID proxyForMemberId) {
|
public AssemblyAttendee checkInAttendee(UUID assemblyId, UUID memberId, UUID proxyForMemberId) {
|
||||||
|
|||||||
@@ -98,6 +98,24 @@ public class AuditService {
|
|||||||
null, null, null, description, null, null);
|
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.
|
* 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) {
|
public long getStorageUsage(UUID clubId) {
|
||||||
return documentRepository.sumFileSizeByClubId(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 PaymentRepository paymentRepository;
|
||||||
private final LedgerEntryRepository ledgerEntryRepository;
|
private final LedgerEntryRepository ledgerEntryRepository;
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
|
private final NotificationService notificationService;
|
||||||
|
private final MemberRepository memberRepository;
|
||||||
|
|
||||||
public FinanceService(FeeScheduleRepository feeScheduleRepository,
|
public FinanceService(FeeScheduleRepository feeScheduleRepository,
|
||||||
MemberFeeAssignmentRepository assignmentRepository,
|
MemberFeeAssignmentRepository assignmentRepository,
|
||||||
PaymentRepository paymentRepository,
|
PaymentRepository paymentRepository,
|
||||||
LedgerEntryRepository ledgerEntryRepository,
|
LedgerEntryRepository ledgerEntryRepository,
|
||||||
AuditService auditService) {
|
AuditService auditService,
|
||||||
|
NotificationService notificationService,
|
||||||
|
MemberRepository memberRepository) {
|
||||||
this.feeScheduleRepository = feeScheduleRepository;
|
this.feeScheduleRepository = feeScheduleRepository;
|
||||||
this.assignmentRepository = assignmentRepository;
|
this.assignmentRepository = assignmentRepository;
|
||||||
this.paymentRepository = paymentRepository;
|
this.paymentRepository = paymentRepository;
|
||||||
this.ledgerEntryRepository = ledgerEntryRepository;
|
this.ledgerEntryRepository = ledgerEntryRepository;
|
||||||
this.auditService = auditService;
|
this.auditService = auditService;
|
||||||
|
this.notificationService = notificationService;
|
||||||
|
this.memberRepository = memberRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Fee Schedule CRUD ===
|
// === Fee Schedule CRUD ===
|
||||||
@@ -171,6 +177,21 @@ public class FinanceService {
|
|||||||
saved.getId().toString(),
|
saved.getId().toString(),
|
||||||
"Payment recorded: " + amountCents + " cents from member " + memberId);
|
"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 {}",
|
log.info("Payment recorded: {} cents from member {} for period {}-{} in club {}",
|
||||||
amountCents, memberId, periodFrom, periodTo, clubId);
|
amountCents, memberId, periodFrom, periodTo, clubId);
|
||||||
|
|
||||||
@@ -296,6 +317,12 @@ public class FinanceService {
|
|||||||
return balance;
|
return balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Payment Lookup ===
|
||||||
|
|
||||||
|
public java.util.Optional<Payment> getPaymentById(UUID paymentId) {
|
||||||
|
return paymentRepository.findById(paymentId);
|
||||||
|
}
|
||||||
|
|
||||||
// === Outstanding / Overdue Members ===
|
// === Outstanding / Overdue Members ===
|
||||||
|
|
||||||
public List<Map<String, Object>> getOutstandingMembers(UUID clubId) {
|
public List<Map<String, Object>> getOutstandingMembers(UUID clubId) {
|
||||||
@@ -317,4 +344,27 @@ public class FinanceService {
|
|||||||
|
|
||||||
return outstanding;
|
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);
|
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.
|
* 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
|
* - Info Board: Starter = 5 posts/month, Pro/Enterprise = unlimited
|
||||||
* - Events: all tiers (no limit)
|
* - Events: all tiers (no limit)
|
||||||
* - Notifications: all tiers
|
* - 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
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@@ -26,6 +29,12 @@ public class PlanTierService {
|
|||||||
private static final int STARTER_INFO_BOARD_CATEGORIES_LIMIT = 3;
|
private static final int STARTER_INFO_BOARD_CATEGORIES_LIMIT = 3;
|
||||||
private static final int PRO_FORUM_CATEGORIES_LIMIT = 5;
|
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;
|
private final SubscriptionRepository subscriptionRepository;
|
||||||
|
|
||||||
public PlanTierService(SubscriptionRepository subscriptionRepository) {
|
public PlanTierService(SubscriptionRepository subscriptionRepository) {
|
||||||
@@ -142,4 +151,71 @@ public class PlanTierService {
|
|||||||
public boolean isEnterprise(UUID clubId) {
|
public boolean isEnterprise(UUID clubId) {
|
||||||
return getClubTier(clubId) == PlanTier.ENTERPRISE;
|
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);
|
title.setSpacingAfter(5);
|
||||||
document.add(title);
|
document.add(title);
|
||||||
|
|
||||||
// Receipt number
|
// Receipt number (use reference or generate from ID)
|
||||||
String receiptNr = payment.getReceiptNumber() != null
|
String receiptNr = payment.getReference() != null
|
||||||
? payment.getReceiptNumber()
|
? payment.getReference()
|
||||||
: "CM-" + payment.getId().toString().substring(0, 8).toUpperCase();
|
: "CM-" + payment.getId().toString().substring(0, 8).toUpperCase();
|
||||||
Paragraph nrPara = new Paragraph("Nr. " + receiptNr, RECEIPT_NR_FONT);
|
Paragraph nrPara = new Paragraph("Nr. " + receiptNr, RECEIPT_NR_FONT);
|
||||||
nrPara.setAlignment(Element.ALIGN_CENTER);
|
nrPara.setAlignment(Element.ALIGN_CENTER);
|
||||||
@@ -98,15 +98,15 @@ public class ReceiptPdfService {
|
|||||||
detailsTable.setSpacingAfter(20);
|
detailsTable.setSpacingAfter(20);
|
||||||
|
|
||||||
addDetailRow(detailsTable, "Erhalten von:", getMemberDisplayName(member));
|
addDetailRow(detailsTable, "Erhalten von:", getMemberDisplayName(member));
|
||||||
addDetailRow(detailsTable, "Mitgliedsnr.:", member.getMemberNumber() != null
|
addDetailRow(detailsTable, "Mitgliedsnr.:", member.getMembershipNumber() != null
|
||||||
? member.getMemberNumber() : "—");
|
? member.getMembershipNumber() : "—");
|
||||||
|
|
||||||
// Amount - formatted as Euro
|
// Amount - formatted as Euro
|
||||||
BigDecimal amount = BigDecimal.valueOf(payment.getAmountCents()).divide(BigDecimal.valueOf(100));
|
BigDecimal amount = BigDecimal.valueOf(payment.getAmountCents()).divide(BigDecimal.valueOf(100));
|
||||||
String amountStr = String.format(Locale.GERMAN, "%,.2f €", amount);
|
String amountStr = String.format(Locale.GERMAN, "%,.2f €", amount);
|
||||||
addDetailRow(detailsTable, "Betrag:", amountStr, AMOUNT_FONT);
|
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()));
|
addDetailRow(detailsTable, "Zahlungsart:", translatePaymentMethod(payment.getPaymentMethod().name()));
|
||||||
|
|
||||||
// Period covered
|
// Period covered
|
||||||
|
|||||||
@@ -122,3 +122,78 @@ VALUES
|
|||||||
('h1000000-0000-0000-0000-000000000004', 'a1000000-0000-0000-0000-000000000001',
|
('h1000000-0000-0000-0000-000000000004', 'a1000000-0000-0000-0000-000000000001',
|
||||||
'e1000000-0000-0000-0000-000000000001', 'DISTRIBUTION', -8.00, 'Distributed to members')
|
'e1000000-0000-0000-0000-000000000001', 'DISTRIBUTION', -8.00, 'Distributed to members')
|
||||||
ON CONFLICT DO NOTHING;
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user