Backend: - V19 migration: assemblies, assembly_agenda_items, assembly_attendees, assembly_votes, assembly_vote_records - Enums: AssemblyType, AssemblyStatus, AgendaItemType, VoteType, VoteDecision, VoteResult - Entities: Assembly, AssemblyAgendaItem, AssemblyAttendee, AssemblyVote, AssemblyVoteRecord - Repositories: Assembly, AgendaItem, Attendee, Vote, VoteRecord - AssemblyService: full lifecycle (create, invite, start, attend, vote, quorum, complete) - AssemblyProtocolService: OpenPDF protocol generation (§147 AO compliant) - AssemblyController: admin + portal endpoints - Extended: AuditEventType, NotificationType, StaffPermission Frontend: - Assembly service with full API client and TypeScript types - Admin assemblies list page with create dialog (agenda builder) - Admin assembly detail page (quorum, agenda, votes, attendees) - Navigation: Versammlungen with Gavel icon (after Finanzen) Legal basis: §32-§40 BGB (Mitgliederversammlung), §147 AO (retention)
67 KiB
Sprint 8 Implementation Plan — Vereinsverwaltung Complete
Date: 2026-06-13
Author: Patrick Plate / Lumen (Architect)
Status: Draft v2 (with legal citations)
Based on: cannamanage-sprint8-analysis.md
Sprint Goal: Club Treasury + General Assembly + Document Archive + Board Management
Legal Framework
This sprint implements core Vereinsverwaltung features. Each feature is grounded in specific German law:
Vereinsrecht (BGB)
| Section | Topic | Relevance to Sprint 8 |
|---|---|---|
| §21 BGB | Nicht wirtschaftlicher Verein | Cannabis Anbauvereinigung operates as e.V. — all features must respect this legal form |
| §25 BGB | Satzung regelt Verfassung | Fee schedules + assembly rules must be configurable per Satzung |
| §26 BGB | Vorstand | Board management feature — legal representation |
| §27 BGB | Bestellung/Abberufung des Vorstands | Board term tracking + elections at MV |
| §30 BGB | Besonderer Vertreter | Board positions beyond §26 Vorstand (Kassenwart, Schriftführer) |
| §32 BGB | Mitgliederversammlung | Assembly feature — decision-making organ |
| §33 BGB | Satzungsänderung | 75% majority for Satzungsänderung, einstimmig for Zweckänderung — vote type modeling |
| §34 BGB | Ausschluss vom Stimmrecht | Member cannot vote on matters concerning themselves |
| §36 BGB | Einberufung der MV | Notice period validation (minimum per Satzung, typically 14 days) |
| §37 BGB | Minderheitsverlangen | 10% of members can force extraordinary MV — future feature hook |
| §38 BGB | Mitgliedschaft | Not transferable, not inheritable — relevant for fee assignment logic |
| §39 BGB | Austritt | Withdrawal with max 2-year notice — affects fee obligation end date |
| §40 BGB | Abdingbarkeit | Which rules can be overridden by Satzung (configurable notice periods, quorum) |
| §56 BGB | Mindestmitgliederzahl | 7 members for founding — validation hook |
| §58 BGB | Satzungsinhalt | Required content including Beitragsregelung |
| §58 Nr. 2 BGB | Beitragsregelung | Satzung must define member contribution rules — legal basis for fee schedule feature |
| §67 BGB | Vereinsregister | Mitgliederverzeichnis requirements; Registergericht requires MV protocols for Vorstandsänderungen |
| §259 BGB | Rechenschaftspflicht | Duty to render accounts — legal basis for Kassenbuch + financial reports |
| §666 BGB | Auskunftspflicht | Duty of information to members — members can demand financial transparency |
| §670 BGB | Aufwendungsersatz | Expense reimbursement — basis for expense tracking feature |
Abgabenordnung (AO) — Tax Law
| Section | Topic | Relevance |
|---|---|---|
| §141 AO | Buchführungspflicht | Bookkeeping obligation if revenue > €600,000 or profit > €60,000 (unlikely for most clubs, but system must support it) |
| §143 AO | Aufzeichnungspflicht | Recording obligation for VAT — Kassenbuch must be accurate |
| §147 AO | Aufbewahrungspflichten | Retention periods: 6 years Handelsbriefe, 10 years Buchungsbelege — drives document retention policy |
Note: Cannabis clubs are likely NOT gemeinnützig (§51 AO), so §§51-68 AO typically don't apply. They operate as wirtschaftlicher Verein or ideeller Verein with Nebenzweckprivileg.
KCanG (Konsumcannabisgesetz)
| Section | Topic | Relevance |
|---|---|---|
| §2 KCanG | Definition Anbauvereinigung | Legal definition — validates club model |
| §16 KCanG | Max 500 Mitglieder | Member cap — existing enforcement |
| §17 KCanG | Voraussetzungen der Mitgliedschaft | Only one club per person — existing enforcement |
| §19 KCanG | Weitergabe | Distribution limits (25g/day, 50g/month) — existing enforcement |
| §20 KCanG | Schutz von Kindern und Jugendlichen | Age verification, U21 rules — existing enforcement |
| §22 KCanG | Dokumentationspflichten | Documentation requirements — financial records + protocols must be auditable |
| §23 KCanG | Präventionsbeauftragter | Board must include Präventionsbeauftragter — board position feature |
| §24 KCanG | Aufbewahrungsfristen | 5 years after member leaves — cannabis-specific data retention |
| §26 KCanG | Kontrolle und Überwachung | Regulatory oversight — all records must be producible on demand |
DSGVO (Data Protection)
| Article | Topic | Relevance |
|---|---|---|
| Art. 6(1)(b) | Vertragserfüllung | Processing member payment data for contract performance (membership agreement) |
| Art. 6(1)(c) | Rechtliche Verpflichtung | Processing for legal obligation (KCanG §22 documentation, §147 AO retention) |
| Art. 6(1)(f) | Berechtigtes Interesse | Fee collection + reminder sending as legitimate interest |
| Art. 15 | Auskunftsrecht | Members can request all their payment data — portal "Meine Zahlungen" satisfies this |
| Art. 17 | Recht auf Löschung | Right to erasure — BUT overridden by §24 KCanG (5-year retention) and §147 AO (10-year retention) for financial data |
Retention Policy Matrix (derived from law)
| Data Type | Retention | Legal Basis | Implementation |
|---|---|---|---|
| Payment records (Buchungsbelege) | 10 years | §147 Abs. 1 Nr. 4 AO | payments.created_at + 10 years |
| Receipts (Quittungen) | 10 years | §147 Abs. 1 Nr. 4 AO | Receipt PDFs retained |
| Financial reports | 10 years | §147 Abs. 1 Nr. 1 AO | Annual report PDFs retained |
| Geschäftsbriefe (payment reminders) | 6 years | §147 Abs. 1 Nr. 2 AO | payment_reminders table |
| MV Protocols | 10 years (practical) | §67 BGB (Vereinsregister needs them) | Protocol PDFs in document archive |
| Cannabis-specific member data | 5 years after leaving | §24 KCanG | Soft-delete + retention check |
| Board composition history | Permanent | §67 BGB (Vereinsregister) | Never deleted |
Overview
This plan implements four features in six phases:
graph TD
P1[Phase 1: Treasury Backend] --> P2[Phase 2: Treasury Frontend + PDF]
P2 --> P3[Phase 3: Mitgliederversammlung]
P3 --> P4[Phase 4: Dokumente + Vorstand]
P4 --> P5[Phase 5: Integration and Polish]
P5 --> P6[Phase 6: Testing and QA]
subgraph Existing
NS[NotificationDispatchService]
AS[AuditService]
PDF[OpenPDF - PdfReportGenerator]
EV[EventService + ClubEvent]
PT[PlanTierService]
end
NS --> P2
NS --> P3
AS --> P1
AS --> P3
AS --> P4
PDF --> P2
PDF --> P3
EV --> P3
PT --> P5
Phase 1: Treasury Backend
Legal basis: §58 Nr. 2 BGB (Satzung must regulate Beiträge), §259 BGB (Rechenschaftspflicht), §666 BGB (Auskunftspflicht), §670 BGB (Aufwendungsersatz), §147 AO (10-year retention for Buchungsbelege), Art. 6(1)(b) DSGVO (contract performance for payment processing), Art. 6(1)(c) DSGVO (legal obligation for financial record-keeping).
Implements fee schedules, payment/expense recording, Kassenbuch calculation, and balance tracking.
Step 1.1 — Extend Enums
Files to create/modify:
PaymentMethod.java(new)FeeInterval.java(new)ReminderType.java(new)StaffPermission.java(modify)AuditEventType.java(modify)
Approach:
// PaymentMethod.java
public enum PaymentMethod {
CASH, BANK_TRANSFER, SEPA, CARD
}
// FeeInterval.java
public enum FeeInterval {
MONTHLY, QUARTERLY, YEARLY, ONE_TIME
}
// ReminderType.java
public enum ReminderType {
FIRST, SECOND, FINAL
}
Add to StaffPermission.java:
// Sprint 8:
MANAGE_FINANCES, // Record payments/expenses, view balances, generate reports
VIEW_FINANCES, // Read-only financial access (Kassenprüfer)
MANAGE_ASSEMBLY, // Create/manage MVs, record attendance/votes
MANAGE_DOCUMENTS // Upload/manage documents
Add to AuditEventType.java:
// Sprint 8 — Finance events
PAYMENT_RECORDED,
PAYMENT_VOIDED,
EXPENSE_RECORDED,
EXPENSE_VOIDED,
FEE_SCHEDULE_CREATED,
FEE_SCHEDULE_UPDATED,
FEE_ASSIGNMENT_CHANGED,
PAYMENT_REMINDER_SENT,
FINANCIAL_REPORT_GENERATED,
// Sprint 8 — Assembly events
ASSEMBLY_CREATED,
ASSEMBLY_INVITATIONS_SENT,
ASSEMBLY_STARTED,
ASSEMBLY_COMPLETED,
ASSEMBLY_CANCELLED,
ASSEMBLY_ATTENDANCE_RECORDED,
ASSEMBLY_VOTE_RECORDED,
ASSEMBLY_ELECTION_RECORDED,
ASSEMBLY_PROTOCOL_GENERATED,
ASSEMBLY_PROTOCOL_UPLOADED,
// Sprint 8 — Document events
DOCUMENT_UPLOADED,
DOCUMENT_UPDATED,
DOCUMENT_DELETED,
DOCUMENT_VERSION_CREATED,
// Sprint 8 — Board events
BOARD_MEMBER_APPOINTED,
BOARD_MEMBER_TERM_ENDED
Dependencies: None (leaf changes) Acceptance criteria: All enums compile, existing functionality unaffected.
Step 1.2 — Flyway Migration V18: Treasury Tables
Files to create:
Content:
-- V18: Vereinsfinanzen (Club Treasury)
CREATE TABLE fee_schedules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name VARCHAR(100) NOT NULL,
amount NUMERIC(10,2) NOT NULL,
interval VARCHAR(20) NOT NULL DEFAULT 'MONTHLY',
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE member_fee_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
member_id UUID NOT NULL REFERENCES members(id),
fee_schedule_id UUID NOT NULL REFERENCES fee_schedules(id),
effective_from DATE NOT NULL DEFAULT CURRENT_DATE,
effective_until DATE,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(member_id, fee_schedule_id, effective_from)
);
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
member_id UUID NOT NULL REFERENCES members(id),
amount NUMERIC(10,2) NOT NULL,
payment_date DATE NOT NULL,
payment_method VARCHAR(30) NOT NULL,
reference VARCHAR(255),
period_from DATE,
period_to DATE,
receipt_number VARCHAR(50),
notes TEXT,
voided BOOLEAN NOT NULL DEFAULT false,
voided_reason TEXT,
voided_at TIMESTAMPTZ,
recorded_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE expense_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name VARCHAR(100) NOT NULL,
icon VARCHAR(50),
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE expenses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
category_id UUID NOT NULL REFERENCES expense_categories(id),
amount NUMERIC(10,2) NOT NULL,
expense_date DATE NOT NULL,
description VARCHAR(500) NOT NULL,
receipt_path VARCHAR(500),
payment_method VARCHAR(30),
voided BOOLEAN NOT NULL DEFAULT false,
voided_reason TEXT,
voided_at TIMESTAMPTZ,
recorded_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE payment_reminders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
member_id UUID NOT NULL REFERENCES members(id),
amount_due NUMERIC(10,2) NOT NULL,
reminder_date DATE NOT NULL DEFAULT CURRENT_DATE,
reminder_type VARCHAR(30) NOT NULL,
sent_via VARCHAR(30) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_fee_schedules_tenant ON fee_schedules(tenant_id, is_active);
CREATE INDEX idx_member_fee_assign_member ON member_fee_assignments(member_id);
CREATE INDEX idx_member_fee_assign_tenant ON member_fee_assignments(tenant_id);
CREATE INDEX idx_payments_tenant_date ON payments(tenant_id, payment_date DESC);
CREATE INDEX idx_payments_member ON payments(member_id, payment_date DESC);
CREATE INDEX idx_payments_receipt ON payments(tenant_id, receipt_number);
CREATE INDEX idx_expenses_tenant_date ON expenses(tenant_id, expense_date DESC);
CREATE INDEX idx_expenses_category ON expenses(category_id);
CREATE INDEX idx_expense_categories_tenant ON expense_categories(tenant_id, sort_order);
CREATE INDEX idx_payment_reminders_member ON payment_reminders(member_id);
Dependencies: V17 must be applied first Acceptance criteria: Migration runs cleanly on PostgreSQL, all indexes created.
Step 1.3 — Domain Entities for Treasury
Files to create:
FeeSchedule.javaMemberFeeAssignment.javaPayment.javaExpenseCategory.javaExpense.javaPaymentReminder.java
Approach: Follow existing patterns — extend AbstractTenantEntity, use JPA annotations, manual getters/setters (matching project style — no Lombok on entities).
@Entity
@Table(name = "fee_schedules")
public class FeeSchedule extends AbstractTenantEntity {
@Column(name = "name", nullable = false, length = 100)
private String name;
@Column(name = "amount", nullable = false, precision = 10, scale = 2)
private BigDecimal amount;
@Enumerated(EnumType.STRING)
@Column(name = "interval", nullable = false, length = 20)
private FeeInterval interval = FeeInterval.MONTHLY;
@Column(name = "description", columnDefinition = "TEXT")
private String description;
@Column(name = "is_active", nullable = false)
private boolean active = true;
@Column(name = "updated_at")
private Instant updatedAt;
// getters/setters...
}
@Entity
@Table(name = "payments")
public class Payment extends AbstractTenantEntity {
@Column(name = "member_id", nullable = false)
private UUID memberId;
@Column(name = "amount", nullable = false, precision = 10, scale = 2)
private BigDecimal amount;
@Column(name = "payment_date", nullable = false)
private LocalDate paymentDate;
@Enumerated(EnumType.STRING)
@Column(name = "payment_method", nullable = false, length = 30)
private PaymentMethod paymentMethod;
@Column(name = "reference", length = 255)
private String reference;
@Column(name = "period_from")
private LocalDate periodFrom;
@Column(name = "period_to")
private LocalDate periodTo;
@Column(name = "receipt_number", length = 50)
private String receiptNumber;
@Column(name = "notes", columnDefinition = "TEXT")
private String notes;
@Column(name = "voided", nullable = false)
private boolean voided = false;
@Column(name = "voided_reason", columnDefinition = "TEXT")
private String voidedReason;
@Column(name = "voided_at")
private Instant voidedAt;
@Column(name = "recorded_by", nullable = false)
private UUID recordedBy;
// getters/setters...
}
Dependencies: Step 1.1 (enums), Step 1.2 (migration) Acceptance criteria: All entities compile, JPA mappings valid.
Step 1.4 — Repositories for Treasury
Files to create:
FeeScheduleRepository.javaMemberFeeAssignmentRepository.javaPaymentRepository.javaExpenseCategoryRepository.javaExpenseRepository.javaPaymentReminderRepository.java
Approach: Spring Data JPA repositories with custom queries for financial logic.
public interface PaymentRepository extends JpaRepository<Payment, UUID> {
List<Payment> findByTenantIdAndVoidedFalseOrderByPaymentDateDesc(UUID tenantId);
List<Payment> findByMemberIdAndVoidedFalseOrderByPaymentDateDesc(UUID memberId);
@Query("SELECT SUM(p.amount) FROM Payment p WHERE p.memberId = :memberId AND p.voided = false " +
"AND p.periodFrom >= :from AND p.periodTo <= :to")
Optional<BigDecimal> sumPaymentsForPeriod(@Param("memberId") UUID memberId,
@Param("from") LocalDate from,
@Param("to") LocalDate to);
@Query("SELECT p FROM Payment p WHERE p.tenantId = :tenantId AND p.voided = false " +
"AND p.paymentDate BETWEEN :from AND :to ORDER BY p.paymentDate")
List<Payment> findByTenantAndDateRange(@Param("tenantId") UUID tenantId,
@Param("from") LocalDate from,
@Param("to") LocalDate to);
Optional<Payment> findTopByTenantIdAndReceiptNumberStartsWithOrderByReceiptNumberDesc(
UUID tenantId, String prefix);
}
public interface ExpenseRepository extends JpaRepository<Expense, UUID> {
List<Expense> findByTenantIdAndVoidedFalseOrderByExpenseDateDesc(UUID tenantId);
@Query("SELECT e FROM Expense e WHERE e.tenantId = :tenantId AND e.voided = false " +
"AND e.expenseDate BETWEEN :from AND :to ORDER BY e.expenseDate")
List<Expense> findByTenantAndDateRange(@Param("tenantId") UUID tenantId,
@Param("from") LocalDate from,
@Param("to") LocalDate to);
@Query("SELECT e.categoryId, SUM(e.amount) FROM Expense e " +
"WHERE e.tenantId = :tenantId AND e.voided = false " +
"AND e.expenseDate BETWEEN :from AND :to GROUP BY e.categoryId")
List<Object[]> sumByCategoryForPeriod(@Param("tenantId") UUID tenantId,
@Param("from") LocalDate from,
@Param("to") LocalDate to);
}
Dependencies: Step 1.3 (entities) Acceptance criteria: Repositories compile, queries valid JPQL.
Step 1.5 — FinanceService
Files to create:
Approach: Central service managing fee schedules, payments, expenses, and balance calculations.
@Slf4j
@Service
public class FinanceService {
private final FeeScheduleRepository feeScheduleRepo;
private final MemberFeeAssignmentRepository feeAssignmentRepo;
private final PaymentRepository paymentRepo;
private final ExpenseRepository expenseRepo;
private final ExpenseCategoryRepository categoryRepo;
private final AuditService auditService;
// Fee schedule CRUD
public FeeSchedule createFeeSchedule(UUID tenantId, String name, BigDecimal amount,
FeeInterval interval, String description) { ... }
public FeeSchedule updateFeeSchedule(UUID id, ...) { ... }
public List<FeeSchedule> listFeeSchedules(UUID tenantId) { ... }
// Fee assignments
public MemberFeeAssignment assignFeeToMember(UUID memberId, UUID feeScheduleId,
LocalDate effectiveFrom) { ... }
// Payments
public Payment recordPayment(UUID tenantId, UUID memberId, BigDecimal amount,
LocalDate date, PaymentMethod method, String reference,
LocalDate periodFrom, LocalDate periodTo,
String notes, UUID recordedBy) { ... }
public void voidPayment(UUID paymentId, String reason, UUID actorId) { ... }
// Expenses
public Expense recordExpense(UUID tenantId, UUID categoryId, BigDecimal amount,
LocalDate date, String description, String receiptPath,
PaymentMethod method, UUID recordedBy) { ... }
public void voidExpense(UUID expenseId, String reason, UUID actorId) { ... }
// Balance calculation
public MemberBalance calculateMemberBalance(UUID memberId) { ... }
public List<MemberBalance> calculateAllBalances(UUID tenantId) { ... }
// Receipt number generation: CM-{year}-{seq:06d}
private String generateReceiptNumber(UUID tenantId) { ... }
}
Key logic for calculateMemberBalance:
public MemberBalance calculateMemberBalance(UUID memberId) {
MemberFeeAssignment assignment = feeAssignmentRepo.findActiveFeeAssignment(memberId)
.orElse(null);
if (assignment == null) return MemberBalance.noFeeAssigned(memberId);
FeeSchedule schedule = feeScheduleRepo.findById(assignment.getFeeScheduleId()).orElseThrow();
// Calculate total due from effective_from to today
LocalDate from = assignment.getEffectiveFrom();
LocalDate to = LocalDate.now();
BigDecimal totalDue = calculateTotalDue(schedule, from, to);
// Calculate total paid
BigDecimal totalPaid = paymentRepo.sumPaymentsForMember(memberId).orElse(BigDecimal.ZERO);
BigDecimal outstanding = totalDue.subtract(totalPaid);
LocalDate lastPaymentDate = paymentRepo.findLastPaymentDate(memberId).orElse(null);
return new MemberBalance(memberId, schedule.getName(), totalDue, totalPaid,
outstanding, lastPaymentDate);
}
Dependencies: Step 1.4 (repositories), existing AuditService
Acceptance criteria: All CRUD operations work, balance calculation accurate, receipt numbers sequential.
Step 1.6 — KassenbuchService
Files to create:
Approach: Generates the Kassenbuch view — chronological list of all transactions with running balance.
@Slf4j
@Service
public class KassenbuchService {
public KassenbuchView generateKassenbuch(UUID tenantId, LocalDate from, LocalDate to) {
List<Payment> payments = paymentRepo.findByTenantAndDateRange(tenantId, from, to);
List<Expense> expenses = expenseRepo.findByTenantAndDateRange(tenantId, from, to);
// Merge into single timeline, calculate running balance
List<KassenbuchEntry> entries = mergeAndSort(payments, expenses);
BigDecimal runningBalance = calculateOpeningBalance(tenantId, from);
for (KassenbuchEntry entry : entries) {
if (entry.isIncome()) {
runningBalance = runningBalance.add(entry.getAmount());
} else {
runningBalance = runningBalance.subtract(entry.getAmount());
}
entry.setRunningBalance(runningBalance);
}
return new KassenbuchView(entries, runningBalance);
}
public String exportCsv(UUID tenantId, LocalDate from, LocalDate to) {
// Generate CSV with columns: Datum;Typ;Beschreibung;Einnahme;Ausgabe;Saldo
...
}
}
Dependencies: Step 1.5 (FinanceService) Acceptance criteria: Kassenbuch shows correct running balance, CSV export produces valid file.
Step 1.7 — Finance REST Controller
Files to create:
Approach: REST endpoints for all treasury operations. Protected by MANAGE_FINANCES permission.
@RestController
@RequestMapping("/api/finance")
@RequiredArgsConstructor
public class FinanceController {
@GetMapping("/fee-schedules")
public ResponseEntity<List<FeeScheduleDto>> listFeeSchedules() { ... }
@PostMapping("/fee-schedules")
public ResponseEntity<FeeScheduleDto> createFeeSchedule(@Valid @RequestBody CreateFeeScheduleRequest req) { ... }
@PostMapping("/payments")
public ResponseEntity<PaymentDto> recordPayment(@Valid @RequestBody RecordPaymentRequest req) { ... }
@PostMapping("/payments/{id}/void")
public ResponseEntity<Void> voidPayment(@PathVariable UUID id, @RequestBody VoidRequest req) { ... }
@GetMapping("/balances")
public ResponseEntity<List<MemberBalanceDto>> getAllBalances() { ... }
@GetMapping("/kassenbuch")
public ResponseEntity<KassenbuchViewDto> getKassenbuch(
@RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate from,
@RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate to) { ... }
@GetMapping("/kassenbuch/export")
public ResponseEntity<byte[]> exportCsv(
@RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate from,
@RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate to) { ... }
@GetMapping("/reports/annual/{year}")
public ResponseEntity<AnnualReportDto> getAnnualReport(@PathVariable int year) { ... }
}
Dependencies: Step 1.5, Step 1.6 Acceptance criteria: All endpoints return correct data, permission checks enforced.
Step 1.8 — Portal Finance Endpoints
Files to modify:
PortalController.java(or newPortalFinanceController.java)
Approach: Member-facing endpoints showing their own payment history and balance.
@RestController
@RequestMapping("/api/portal/finance")
public class PortalFinanceController {
@GetMapping("/my-payments")
public ResponseEntity<List<PaymentDto>> myPayments() {
UUID memberId = getCurrentMemberId();
return ResponseEntity.ok(financeService.getPaymentsForMember(memberId));
}
@GetMapping("/my-balance")
public ResponseEntity<MemberBalanceDto> myBalance() {
UUID memberId = getCurrentMemberId();
return ResponseEntity.ok(financeService.calculateMemberBalance(memberId).toDto());
}
@GetMapping("/receipts/{paymentId}")
public ResponseEntity<byte[]> downloadReceipt(@PathVariable UUID paymentId) {
// Verify payment belongs to current member
...
}
}
Dependencies: Step 1.7 Acceptance criteria: Members see only their own data, cannot access other members' payments.
Phase 2: Treasury Frontend + PDF
Legal basis: §259 BGB (Rechenschaftspflicht — members have right to financial transparency), §666 BGB (Auskunftspflicht — board must provide information on request), §147 AO (Quittungen must be retained 10 years), Art. 15 DSGVO (Auskunftsrecht — portal "Meine Zahlungen" satisfies member's right of access to their payment data), standard Vereinspraxis (member has Anspruch auf Beitragsquittung).
Step 2.1 — Receipt PDF Generation
Files to create:
Approach: Generate Quittung PDF using OpenPDF (same pattern as existing PdfReportGenerator).
@Slf4j
@Service
public class ReceiptPdfService {
public byte[] generateReceipt(Payment payment, Member member, Club club) {
Document document = new Document(PageSize.A4, 50, 50, 50, 50);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
PdfWriter.getInstance(document, baos);
document.open();
// Header: Club name + address
// Title: "Quittung Nr. CM-2026-000001"
// Body: Member name, amount, date, payment method, period covered
// Footer: Signature line, date, club stamp placeholder
document.close();
return baos.toByteArray();
}
}
Receipt PDF layout:
┌─────────────────────────────────────────┐
│ [Club Name] │
│ [Club Address] │
│ │
│ QUITTUNG │
│ Nr. CM-2026-000001 │
│ │
│ Erhalten von: [Member Name] │
│ Betrag: 30,00 € │
│ Datum: 01.07.2026 │
│ Zahlungsart: Überweisung │
│ Zeitraum: Juli 2026 │
│ Verwendung: Mitgliedsbeitrag │
│ │
│ _______________ _______________ │
│ Datum Unterschrift │
│ Kassenwart │
└─────────────────────────────────────────┘
Dependencies: Step 1.5, existing OpenPDF library Acceptance criteria: Valid PDF generated, correct German locale formatting (Euro, dates).
Step 2.2 — Annual Financial Report PDF
Files to create:
Approach: Generate Jahresabschluss PDF with summary tables.
Content:
- Title: "Jahresabschluss {year} — {Club Name}"
- Summary: Total income, total expenses, net balance
- Income breakdown: by month (table)
- Expense breakdown: by category (table)
- Running balance chart (month by month)
- Member payment summary: number of members, total received, outstanding
- Footer: Generated date, "Erstellt mit CannaManage"
Dependencies: Step 1.6 (KassenbuchService), OpenPDF Acceptance criteria: Annual report PDF matches expected format, all numbers correct.
Step 2.3 — Frontend: Finance Admin Pages
Files to create:
cannamanage-frontend/src/app/(dashboard-layout)/finance/page.tsx— Main finance dashboardcannamanage-frontend/src/app/(dashboard-layout)/finance/payments/page.tsx— Payment listcannamanage-frontend/src/app/(dashboard-layout)/finance/expenses/page.tsx— Expense listcannamanage-frontend/src/app/(dashboard-layout)/finance/balances/page.tsx— Balance overviewcannamanage-frontend/src/app/(dashboard-layout)/finance/reports/page.tsx— Report generationcannamanage-frontend/src/app/(dashboard-layout)/settings/finance/page.tsx— Fee schedules + categories configcannamanage-frontend/src/services/finance.ts— API clientcannamanage-frontend/src/data/mock/finance.ts— Mock data
Finance Dashboard layout:
┌──────────────────────────────────────────────────┐
│ Vereinsfinanzen │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Einnahmen│ │ Ausgaben │ │ Saldo │ │
│ │ 4.230€ │ │ 2.180€ │ │ 2.050€ │ │
│ │ diesen M.│ │ diesen M.│ │ │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ Tabs: [Kassenbuch] [Zahlungen] [Ausgaben] │
│ [Offene Beiträge] [Berichte] │
│ │
│ ┌───────────────────────────────────────┐ │
│ │ Kassenbuch │ │
│ │ Datum | Typ | Beschreibung | +/- | Saldo│
│ │ 01.07 | Einnahme | Beitrag M.X | +30 | 2050│
│ │ 28.06 | Ausgabe | Strom Juni | -85 | 2020│
│ │ ... │ │
│ └───────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
Dependencies: Step 1.7 (API endpoints) Acceptance criteria: All finance pages render, data fetched from API, responsive design.
Step 2.4 — Frontend: Portal Finance View
Files to create:
Layout:
┌────────────────────────────────────────┐
│ Meine Zahlungen │
│ │
│ ┌────────────┐ ┌────────────┐ │
│ │ Saldo │ │ Nächster │ │
│ │ 0,00 € │ │ Beitrag │ │
│ │ ✅ Aktuell │ │ 01.08.2026 │ │
│ └────────────┘ └────────────┘ │
│ │
│ Zahlungshistorie: │
│ ┌─────────────────────────────┐ │
│ │ 01.07.2026 | 30€ | ✅ | 📄 │ │
│ │ 01.06.2026 | 30€ | ✅ | 📄 │ │
│ │ 01.05.2026 | 30€ | ✅ | 📄 │ │
│ └─────────────────────────────┘ │
│ 📄 = Download Quittung │
└────────────────────────────────────────┘
Dependencies: Step 1.8 (portal API), Step 2.1 (receipt PDF) Acceptance criteria: Members see their balance and payment history, can download receipts.
Step 2.5 — Navigation Updates
Files to modify:
Approach: Add finance section to admin nav and portal nav.
Admin nav additions:
{
title: 'Finanzen',
icon: 'wallet',
path: '/finance',
permission: 'MANAGE_FINANCES',
children: [
{ title: 'Kassenbuch', path: '/finance' },
{ title: 'Zahlungen', path: '/finance/payments' },
{ title: 'Ausgaben', path: '/finance/expenses' },
{ title: 'Offene Beiträge', path: '/finance/balances' },
{ title: 'Berichte', path: '/finance/reports' },
]
}
Portal nav additions:
{ title: 'Meine Zahlungen', icon: 'receipt', path: '/portal/finance' }
Dependencies: All Phase 2 pages Acceptance criteria: Navigation renders correctly, permission-gated.
Phase 3: Mitgliederversammlung (General Assembly)
Legal basis: §32 BGB (MV as decision-making organ — Beschlussfassung), §33 BGB (75% majority for Satzungsänderung, unanimous for Zweckänderung — modeled as VoteType), §34 BGB (exclusion from voting on own matters), §36 BGB (Einberufung — notice period enforcement), §37 BGB (Minderheitsverlangen — 10% can force extraordinary MV), §40 BGB (Satzung can override quorum/notice defaults), §67 BGB (Registergericht requires MV protocols for Vorstandsänderungen — protocol generation + archival), §27 BGB (Bestellung/Abberufung des Vorstands at MV — election feature), §22 KCanG (Dokumentationspflichten — all decisions auditable).
Step 3.1 — Flyway Migration V19: Assembly Tables
Files to create:
Content: As defined in analysis document — general_assemblies, assembly_agenda_items, assembly_attendance, assembly_votes, assembly_elections tables with all indexes.
Dependencies: V18 Acceptance criteria: Migration runs cleanly.
Step 3.2 — Assembly Enums + Entities
Files to create:
AssemblyType.java—ORDINARY,EXTRAORDINARYAssemblyStatus.java—DRAFT,INVITED,IN_PROGRESS,COMPLETED,CANCELLEDAgendaItemType.java—DISCUSSION,VOTE,ELECTION,REPORT,OTHERVoteType.java—SIMPLE_MAJORITY,TWO_THIRDS,UNANIMOUSVoteResult.java—ACCEPTED,REJECTED,TABLEDGeneralAssembly.javaAssemblyAgendaItem.javaAssemblyAttendance.javaAssemblyVote.javaAssemblyElection.java
Approach: Standard JPA entities, following existing patterns.
@Entity
@Table(name = "general_assemblies", indexes = {
@Index(name = "idx_assemblies_tenant", columnList = "tenant_id, scheduled_at"),
@Index(name = "idx_assemblies_status", columnList = "tenant_id, status")
})
public class GeneralAssembly extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false)
private UUID clubId;
@Column(name = "title", nullable = false, length = 200)
private String title;
@Enumerated(EnumType.STRING)
@Column(name = "assembly_type", nullable = false, length = 30)
private AssemblyType assemblyType = AssemblyType.ORDINARY;
@Column(name = "scheduled_at", nullable = false)
private Instant scheduledAt;
@Column(name = "location", nullable = false, length = 300)
private String location;
@Column(name = "notice_period_days", nullable = false)
private int noticePeriodDays = 14;
@Column(name = "quorum_percentage", nullable = false)
private int quorumPercentage = 50;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 30)
private AssemblyStatus status = AssemblyStatus.DRAFT;
@Column(name = "invitation_sent_at")
private Instant invitationSentAt;
@Column(name = "started_at")
private Instant startedAt;
@Column(name = "ended_at")
private Instant endedAt;
@Column(name = "notes", columnDefinition = "TEXT")
private String notes;
@Column(name = "protocol_document_id")
private UUID protocolDocumentId;
@Column(name = "event_id")
private UUID eventId;
@Column(name = "created_by", nullable = false)
private UUID createdBy;
@OneToMany(mappedBy = "assemblyId", cascade = CascadeType.ALL)
@OrderBy("sortOrder ASC")
private List<AssemblyAgendaItem> agendaItems = new ArrayList<>();
// getters/setters...
}
Dependencies: Step 3.1 Acceptance criteria: All entities compile, relationships mapped correctly.
Step 3.3 — AssemblyService
Files to create:
Approach: Manages the MV lifecycle: create → invite → start → complete.
Key methods:
@Slf4j
@Service
@Transactional
public class AssemblyService {
// Lifecycle
public GeneralAssembly createAssembly(CreateAssemblyRequest req) { ... }
public void sendInvitations(UUID assemblyId) { ... } // transitions DRAFT → INVITED
public void startAssembly(UUID assemblyId) { ... } // transitions INVITED → IN_PROGRESS
public void completeAssembly(UUID assemblyId) { ... } // transitions IN_PROGRESS → COMPLETED
public void cancelAssembly(UUID assemblyId) { ... }
// Agenda
public AssemblyAgendaItem addAgendaItem(UUID assemblyId, ...) { ... }
public void reorderAgendaItems(UUID assemblyId, List<UUID> orderedIds) { ... }
// Attendance
public void checkInMember(UUID assemblyId, UUID memberId, UUID checkedInBy) { ... }
public void removeCheckIn(UUID assemblyId, UUID memberId) { ... }
public QuorumStatus getQuorumStatus(UUID assemblyId) { ... }
// Voting
public AssemblyVote createVote(UUID assemblyId, ...) { ... }
public void recordVoteResult(UUID voteId, int yes, int no, int abstain) { ... }
// Elections
public void recordElection(UUID assemblyId, String position, UUID electedMemberId,
Integer voteCount, LocalDate termStart, LocalDate termEnd) { ... }
}
sendInvitations logic:
public void sendInvitations(UUID assemblyId) {
GeneralAssembly assembly = assemblyRepo.findById(assemblyId).orElseThrow();
if (assembly.getStatus() != AssemblyStatus.DRAFT) {
throw new IllegalStateException("Can only invite from DRAFT status");
}
// Validate notice period
long daysUntil = ChronoUnit.DAYS.between(Instant.now(), assembly.getScheduledAt());
if (daysUntil < assembly.getNoticePeriodDays()) {
log.warn("Notice period not met: {} days until MV, {} required",
daysUntil, assembly.getNoticePeriodDays());
// Still allow — just warn (club can override)
}
// Create corresponding ClubEvent (integrates with Sprint 7 event calendar)
ClubEvent event = eventService.createEvent(assembly.getClubId(), assembly.getTitle(),
assembly.formatDescription(), EventType.GENERAL_ASSEMBLY,
assembly.getScheduledAt(), null, assembly.getLocation(), null,
assembly.getCreatedBy());
assembly.setEventId(event.getId());
// Send notification to all active members
notificationDispatchService.broadcast(assembly.getTenantId(),
NotificationType.ASSEMBLY_INVITATION,
"Einladung zur " + assembly.getTitle(),
formatInvitationBody(assembly));
assembly.setStatus(AssemblyStatus.INVITED);
assembly.setInvitationSentAt(Instant.now());
assemblyRepo.save(assembly);
auditService.log(AuditEventType.ASSEMBLY_INVITATIONS_SENT, ...);
}
getQuorumStatus logic:
public QuorumStatus getQuorumStatus(UUID assemblyId) {
GeneralAssembly assembly = assemblyRepo.findById(assemblyId).orElseThrow();
long attendeeCount = attendanceRepo.countByAssemblyId(assemblyId);
long totalActiveMembers = memberRepo.countByTenantIdAndStatus(
assembly.getTenantId(), MemberStatus.ACTIVE);
int percentage = (int) (attendeeCount * 100 / totalActiveMembers);
boolean quorumReached = percentage >= assembly.getQuorumPercentage();
return new QuorumStatus(attendeeCount, totalActiveMembers,
percentage, assembly.getQuorumPercentage(), quorumReached);
}
Dependencies: Step 3.2, existing EventService, NotificationDispatchService, AuditService
Acceptance criteria: Full MV lifecycle works, quorum calculation correct, invitations sent via notification system.
Step 3.4 — Protocol PDF Generation
Files to create:
Approach: Generate official MV protocol (Protokoll) as PDF.
Protocol structure:
PROTOKOLL
der ordentlichen Mitgliederversammlung
des [Club Name] e.V.
Datum: [date]
Ort: [location]
Beginn: [start time]
Ende: [end time]
Anwesende Mitglieder: [count] von [total] ([percentage]%)
Beschlussfähigkeit: ✅ Erreicht / ❌ Nicht erreicht
TAGESORDNUNG:
1. [item 1]
2. [item 2]
...
BESCHLÜSSE:
TOP 5: [vote title]
Ergebnis: Angenommen (Ja: 42, Nein: 3, Enthaltungen: 5)
WAHLEN:
1. Vorsitzende/r: [Name] (gewählt mit [X] Stimmen)
Kassenwart/in: [Name] (gewählt mit [X] Stimmen)
ANWESENHEITSLISTE:
[numbered list of all attendees]
___________________ ___________________
Versammlungsleiter/in Schriftführer/in
Dependencies: Step 3.3, OpenPDF Acceptance criteria: PDF generated with all required sections, German formatting.
Step 3.5 — Assembly REST Controller
Files to create:
Approach: Full CRUD + lifecycle endpoints as defined in analysis. Protected by MANAGE_ASSEMBLY permission.
Dependencies: Step 3.3, Step 3.4 Acceptance criteria: All endpoints functional, proper status transitions enforced.
Step 3.6 — Frontend: Assembly Pages
Files to create:
cannamanage-frontend/src/app/(dashboard-layout)/assemblies/page.tsx— MV listcannamanage-frontend/src/app/(dashboard-layout)/assemblies/new/page.tsx— Create MVcannamanage-frontend/src/app/(dashboard-layout)/assemblies/[id]/page.tsx— MV detail (tabs)cannamanage-frontend/src/app/(dashboard-layout)/assemblies/[id]/live/page.tsx— Live managementcannamanage-frontend/src/app/(portal-layout)/portal/assemblies/page.tsx— Portal: upcoming + past MVscannamanage-frontend/src/services/assemblies.ts— API clientcannamanage-frontend/src/data/mock/assemblies.ts— Mock data
Live management page layout:
┌─────────────────────────────────────────────────────┐
│ 🟢 MV Aktiv — Ordentliche MV 2026 │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Anwesend │ │ Quorum │ │
│ │ 38 / 72 │ │ 52% ✅ │ │
│ │ Mitglieder │ │ von 50% nötig│ │
│ └──────────────┘ └──────────────┘ │
│ │
│ Tabs: [Anwesenheit] [Abstimmungen] [Wahlen] │
│ │
│ Anwesenheit: │
│ 🔍 [Mitglied suchen...] │
│ ┌─────────────────────────────┐ │
│ │ ☑ Max Mustermann | 19:02 │ │
│ │ ☑ Erika Muster | 19:05 │ │
│ │ ☐ Hans Dampf | │ │
│ └─────────────────────────────┘ │
│ │
│ [📄 Protokoll generieren] [✅ MV beenden] │
└─────────────────────────────────────────────────────┘
Dependencies: Step 3.5 (API) Acceptance criteria: Full MV flow works from creation to protocol generation.
Phase 4: Dokumentenarchiv + Vorstandsverwaltung
Legal basis (Documents): §147 AO (Aufbewahrungspflichten — 6/10 year retention), §24 KCanG (5-year retention for cannabis-specific data after member leaves), §26 KCanG (Kontrolle — records producible on regulatory demand), §22 KCanG (Dokumentationspflichten), Art. 6(1)(c) DSGVO (processing for legal obligation — retention requirements override Art. 17 erasure right).
Legal basis (Board): §26 BGB (Vorstand — legal representation, minimum 1 person), §27 BGB (Bestellung und Abberufung — appointment/dismissal by MV), §30 BGB (Besonderer Vertreter — additional positions like Kassenwart, Schriftführer), §67 BGB (Vereinsregister — board composition changes must be registered), §23 KCanG (Präventionsbeauftragter — mandatory board position for cannabis clubs).
Step 4.1 — Flyway Migrations V20 + V21
Files to create:
Content: As defined in analysis document.
Dependencies: V19 Acceptance criteria: Migrations run cleanly.
Step 4.2 — Document Entities + Enums
Files to create:
DocumentAccessLevel.java—ALL_MEMBERS,BOARD_ONLYDocumentSource.java—MANUAL,SYSTEM_MV_PROTOCOL,SYSTEM_FINANCIAL_REPORTDocument.javaDocumentCategory.java
Dependencies: Step 4.1 Acceptance criteria: Entities compile.
Step 4.3 — DocumentStorageService (Filesystem)
Files to create:
Approach: Handles the actual filesystem operations — store, retrieve, delete files.
@Slf4j
@Service
public class DocumentStorageService {
@Value("${cannamanage.storage.base-path:/uploads}")
private String basePath;
private static final long MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB
private static final Set<String> ALLOWED_TYPES = Set.of(
"application/pdf", "application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"image/png", "image/jpeg"
);
public StoredFile store(UUID tenantId, UUID documentId, MultipartFile file) {
validateFile(file);
Path targetDir = Path.of(basePath, "documents", tenantId.toString(), documentId.toString());
Files.createDirectories(targetDir);
Path targetFile = targetDir.resolve(file.getOriginalFilename());
file.transferTo(targetFile);
return new StoredFile(targetFile.toString(), file.getSize());
}
public byte[] retrieve(String storagePath) {
return Files.readAllBytes(Path.of(storagePath));
}
public void delete(String storagePath) {
Files.deleteIfExists(Path.of(storagePath));
}
private void validateFile(MultipartFile file) {
if (file.getSize() > MAX_FILE_SIZE) throw new FileTooLargeException(...);
if (!ALLOWED_TYPES.contains(file.getContentType())) throw new UnsupportedFileTypeException(...);
}
}
Dependencies: None (uses Spring @Value for path configuration)
Acceptance criteria: Files stored in correct directory structure, validation works.
Step 4.4 — DocumentService + Controller
Files to create:
Approach: CRUD for document metadata + multipart upload handling.
@RestController
@RequestMapping("/api/documents")
public class DocumentController {
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<DocumentDto> uploadDocument(
@RequestPart("file") MultipartFile file,
@RequestPart("metadata") CreateDocumentRequest metadata) {
Document doc = documentService.uploadDocument(file, metadata);
return ResponseEntity.status(HttpStatus.CREATED).body(doc.toDto());
}
@GetMapping("/{id}/download")
public ResponseEntity<Resource> downloadDocument(@PathVariable UUID id) {
Document doc = documentService.getDocument(id);
byte[] content = storageService.retrieve(doc.getStoragePath());
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(doc.getContentType()))
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + doc.getFilename() + "\"")
.body(new ByteArrayResource(content));
}
}
Dependencies: Step 4.2, Step 4.3 Acceptance criteria: Upload, download, delete all work. Tenant isolation enforced.
Step 4.5 — Board Entities + Service
Files to create:
BoardService key methods:
@Slf4j
@Service
public class BoardService {
public List<BoardPosition> getPositions(UUID tenantId) { ... }
public BoardPosition createPosition(UUID tenantId, String name, boolean required, int maxHolders) { ... }
public BoardMember appointMember(UUID positionId, UUID memberId,
LocalDate termStart, LocalDate termEnd,
UUID assemblyId) { ... }
public void endTerm(UUID boardMemberId, String reason) { ... }
public List<BoardMember> getCurrentBoard(UUID tenantId) { ... }
public List<BoardMember> getBoardHistory(UUID tenantId) { ... }
// Called by MV elections
public void applyElectionResult(AssemblyElection election) {
// End current holder's term
// Appoint new member
}
}
Dependencies: Step 4.1 (V21 migration), Step 3.3 (linked from elections) Acceptance criteria: Board composition trackable, history preserved.
Step 4.6 — Frontend: Documents + Board Pages
Files to create:
cannamanage-frontend/src/app/(dashboard-layout)/documents/page.tsx— Document archivecannamanage-frontend/src/app/(dashboard-layout)/board/page.tsx— Board managementcannamanage-frontend/src/app/(portal-layout)/portal/documents/page.tsx— Portal doc accesscannamanage-frontend/src/app/(portal-layout)/portal/board/page.tsx— Portal board viewcannamanage-frontend/src/services/documents.tscannamanage-frontend/src/services/board.ts
Dependencies: Step 4.4, Step 4.5 Acceptance criteria: Upload/download works, board displayed correctly.
Phase 5: Integration & Polish
Legal basis: §259 BGB + §666 BGB (payment reminders serve the Verein's legitimate interest in collecting dues owed per Satzung — Art. 6(1)(f) DSGVO), §27 BGB (term expiry notifications ensure continuity of legal representation per §26 BGB), §147 AO (auto-archive ensures 10-year retention compliance for system-generated financial documents), §22 KCanG + §26 KCanG (all auto-archived protocols satisfy documentation and oversight requirements).
Step 5.1 — Payment Reminder Scheduler
Files to create:
Approach: Scheduled job that checks for overdue members and sends reminders via the notification system.
@Slf4j
@Service
public class PaymentReminderScheduler {
@Scheduled(cron = "0 0 9 * * MON") // Every Monday at 9:00 AM
public void checkOverduePayments() {
List<Club> clubs = clubRepo.findAllActive();
for (Club club : clubs) {
PlanTier tier = planTierService.getClubTier(club.getId());
if (tier == PlanTier.STARTER || tier == PlanTier.TRIAL) continue; // Pro+ only
List<MemberBalance> overdue = financeService.getOverdueMembers(club.getTenantId());
for (MemberBalance balance : overdue) {
ReminderType type = determineReminderType(balance);
sendReminder(balance, type);
}
}
}
private ReminderType determineReminderType(MemberBalance balance) {
long daysOverdue = ChronoUnit.DAYS.between(balance.getOverdueSince(), LocalDate.now());
if (daysOverdue > 60) return ReminderType.FINAL;
if (daysOverdue > 30) return ReminderType.SECOND;
return ReminderType.FIRST;
}
}
Dependencies: Step 1.5, Sprint 7 NotificationDispatchService, PlanTierService
Acceptance criteria: Reminders sent on schedule, only to Pro+ clubs, correct escalation.
Step 5.2 — Board Term Expiry Scheduler
Files to create:
Approach: Checks for board terms expiring within 30 days, notifies admins.
@Slf4j
@Service
public class BoardTermScheduler {
@Scheduled(cron = "0 0 8 1 * *") // 1st of every month at 8:00 AM
public void checkExpiringTerms() {
LocalDate threshold = LocalDate.now().plusDays(30);
List<BoardMember> expiring = boardMemberRepo.findByTermEndBeforeAndIsActiveTrue(threshold);
for (BoardMember bm : expiring) {
notificationDispatchService.sendToAdmins(bm.getTenantId(),
NotificationType.BOARD_TERM_EXPIRING,
"Amtszeit endet bald",
String.format("Die Amtszeit von %s als %s endet am %s.",
memberName, positionName, bm.getTermEnd()));
}
}
}
Dependencies: Step 4.5 Acceptance criteria: Notifications sent 30 days before term expiry.
Step 5.3 — Auto-Archive Integration
Files to modify:
AssemblyService.java(add protocol auto-store)FinancialReportService.java(add report auto-store)
Approach: When MV protocol or annual report PDF is generated, automatically create a Document record with source = SYSTEM_*.
// In AssemblyService.completeAssembly()
byte[] protocolPdf = protocolPdfService.generateProtocol(assembly);
Document doc = documentService.storeSystemDocument(
assembly.getTenantId(),
"Protokoll " + assembly.getTitle(),
"protokoll-" + assembly.getId() + ".pdf",
protocolPdf,
DocumentSource.SYSTEM_MV_PROTOCOL,
categoryRepo.findByTenantIdAndName(assembly.getTenantId(), "Protokolle")
);
assembly.setProtocolDocumentId(doc.getId());
Dependencies: Step 3.3, Step 4.4 Acceptance criteria: Protocols and reports auto-archived in document system.
Step 5.4 — Tier Enforcement Updates
Files to modify:
Add tier rules:
// Finance tier rules
private static final int STARTER_FEE_SCHEDULES_LIMIT = 2;
private static final int STARTER_EXPENSES_YEARLY_LIMIT = 50;
private static final int STARTER_DOCUMENT_STORAGE_MB = 100;
private static final int PRO_DOCUMENT_STORAGE_MB = 1024;
public void requireFinanceAccess(UUID clubId) { ... }
public void enforceExpenseLimit(UUID clubId) { ... }
public void enforceDocumentStorageLimit(UUID clubId, long currentUsageBytes) { ... }
public void requireReminderAccess(UUID clubId) { ... }
Dependencies: All feature services Acceptance criteria: Tier limits correctly enforced, appropriate error messages.
Step 5.5 — Seed Default Data on Club Creation
Files to modify:
ClubService.java(or createClubSetupService)
Approach: When a new club is created, seed default expense categories, document categories, and board positions.
Default expense categories:
- Miete, Strom/Nebenkosten, Cannabis-Einkauf, Anbaumaterial, Versicherung, Verwaltung, Sonstiges
Default document categories:
- Satzung, Protokolle, Verträge, Versicherungen, Behördliche Genehmigungen, Sonstiges
Default board positions:
-
- Vorsitzende/r (required), 2. Vorsitzende/r, Kassenwart/in (required), Schriftführer/in, Beisitzer/in (max_holders: 3)
Dependencies: Step 1.3, Step 4.2, Step 4.5 Acceptance criteria: New clubs start with sensible defaults.
Step 5.6 — NotificationType Extensions
Files to modify:
Add:
// Sprint 8:
PAYMENT_REMINDER,
ASSEMBLY_INVITATION,
BOARD_TERM_EXPIRING
Dependencies: None Acceptance criteria: New notification types available for dispatching.
Phase 6: Testing & QA
Step 6.1 — Unit Tests
Files to create:
cannamanage-service/src/test/java/de/cannamanage/service/FinanceServiceTest.javacannamanage-service/src/test/java/de/cannamanage/service/KassenbuchServiceTest.javacannamanage-service/src/test/java/de/cannamanage/service/ReceiptPdfServiceTest.javacannamanage-service/src/test/java/de/cannamanage/service/FinancialReportServiceTest.javacannamanage-service/src/test/java/de/cannamanage/service/PaymentReminderSchedulerTest.javacannamanage-service/src/test/java/de/cannamanage/service/AssemblyServiceTest.javacannamanage-service/src/test/java/de/cannamanage/service/ProtocolPdfServiceTest.javacannamanage-service/src/test/java/de/cannamanage/service/DocumentStorageServiceTest.javacannamanage-service/src/test/java/de/cannamanage/service/DocumentServiceTest.javacannamanage-service/src/test/java/de/cannamanage/service/BoardServiceTest.javacannamanage-service/src/test/java/de/cannamanage/service/BoardTermSchedulerTest.java
Dependencies: All Phase 1-5 services Acceptance criteria: 90%+ line coverage on business logic.
Step 6.2 — Integration Tests
Files to create:
cannamanage-api/src/test/java/de/cannamanage/api/FinanceControllerIntegrationTest.javacannamanage-api/src/test/java/de/cannamanage/api/AssemblyControllerIntegrationTest.javacannamanage-api/src/test/java/de/cannamanage/api/DocumentControllerIntegrationTest.javacannamanage-api/src/test/java/de/cannamanage/api/BoardControllerIntegrationTest.javacannamanage-api/src/test/java/de/cannamanage/api/PortalFinanceIntegrationTest.java
Dependencies: Step 6.1 Acceptance criteria: All endpoints tested with realistic scenarios, permission checks verified.
Step 6.3 — E2E Playwright Tests
Files to create:
cannamanage-frontend/e2e/finance.spec.ts— Full finance workflowcannamanage-frontend/e2e/assemblies.spec.ts— MV creation → completioncannamanage-frontend/e2e/documents.spec.ts— Upload/download/categorizecannamanage-frontend/e2e/board.spec.ts— Board managementcannamanage-frontend/e2e/portal-finance.spec.ts— Member payment view
Key test scenarios:
// finance.spec.ts
test('Admin records payment and member sees it in portal', async ({ page }) => {
// 1. Admin navigates to finance
// 2. Records €30 payment for Member X
// 3. Verifies receipt number generated
// 4. Downloads receipt PDF
// 5. Switches to portal as Member X
// 6. Verifies payment appears in history
// 7. Verifies balance shows €0 outstanding
});
// assemblies.spec.ts
test('Full MV lifecycle: create → invite → attend → vote → complete', async ({ page }) => {
// 1. Admin creates MV with agenda
// 2. Sends invitations
// 3. Starts MV
// 4. Checks in 3 members
// 5. Records vote (2 yes, 1 no)
// 6. Completes MV
// 7. Verifies protocol PDF generated
// 8. Verifies protocol auto-archived in documents
});
Dependencies: Step 6.2 Acceptance criteria: All E2E tests pass in CI.
File Summary
New Files (by module)
cannamanage-domain (enums): 10 new enum files cannamanage-domain (entities): 15 new entity files cannamanage-service: 12 new service files + 11 test files cannamanage-api: 5 new controller files + 4 migration files + 5 integration test files cannamanage-frontend: ~15 new pages + 5 service files + 5 mock files + 5 E2E test files
Total: ~90 new files
Modified Files
| File | Change |
|---|---|
StaffPermission.java |
+4 enum values |
AuditEventType.java |
+26 enum values |
NotificationType.java |
+3 enum values |
PlanTierService.java |
+4 tier enforcement methods |
ClubService.java |
+seed default data logic |
navigations.ts |
+finance, assembly, documents, board nav items |
EventType.java |
+GENERAL_ASSEMBLY value |
Dependency Graph
graph TD
S1_1[1.1 Enums] --> S1_3[1.3 Entities]
S1_2[1.2 V18 Migration] --> S1_3
S1_3 --> S1_4[1.4 Repositories]
S1_4 --> S1_5[1.5 FinanceService]
S1_5 --> S1_6[1.6 KassenbuchService]
S1_5 --> S1_7[1.7 FinanceController]
S1_6 --> S1_7
S1_7 --> S1_8[1.8 Portal Endpoints]
S1_5 --> S2_1[2.1 Receipt PDF]
S1_6 --> S2_2[2.2 Annual Report PDF]
S1_7 --> S2_3[2.3 Finance Frontend]
S1_8 --> S2_4[2.4 Portal Finance]
S2_3 --> S2_5[2.5 Navigation]
S2_4 --> S2_5
S3_1[3.1 V19 Migration] --> S3_2[3.2 Assembly Entities]
S3_2 --> S3_3[3.3 AssemblyService]
S3_3 --> S3_4[3.4 Protocol PDF]
S3_3 --> S3_5[3.5 AssemblyController]
S3_5 --> S3_6[3.6 Assembly Frontend]
S4_1[4.1 V20+V21 Migrations] --> S4_2[4.2 Document Entities]
S4_2 --> S4_3[4.3 DocumentStorageService]
S4_3 --> S4_4[4.4 DocumentService + Controller]
S4_1 --> S4_5[4.5 Board Entities + Service]
S4_4 --> S4_6[4.6 Doc + Board Frontend]
S4_5 --> S4_6
S1_5 --> S5_1[5.1 Payment Reminder Scheduler]
S4_5 --> S5_2[5.2 Board Term Scheduler]
S3_3 --> S5_3[5.3 Auto-Archive]
S4_4 --> S5_3
S5_1 --> S5_4[5.4 Tier Enforcement]
S5_3 --> S5_5[5.5 Seed Defaults]
S1_1 --> S5_6[5.6 NotificationType]
S5_4 --> S6_1[6.1 Unit Tests]
S5_5 --> S6_1
S6_1 --> S6_2[6.2 Integration Tests]
S6_2 --> S6_3[6.3 E2E Tests]