Files
cannamanage/docs/sprint-8/cannamanage-sprint8-plan.md
Patrick Plate b22702317a feat(sprint8): Phase 3 — Mitgliederversammlung (assemblies, voting, protocol PDF)
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)
2026-06-15 08:39:10 +02:00

1656 lines
67 KiB
Markdown

# 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:
```mermaid
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`](cannamanage-domain/src/main/java/de/cannamanage/domain/enums/PaymentMethod.java) (new)
- [`FeeInterval.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/enums/FeeInterval.java) (new)
- [`ReminderType.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/enums/ReminderType.java) (new)
- [`StaffPermission.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/enums/StaffPermission.java) (modify)
- [`AuditEventType.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AuditEventType.java) (modify)
**Approach:**
```java
// 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`:
```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`:
```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:**
- [`V18__vereinsfinanzen.sql`](cannamanage-api/src/main/resources/db/migration/V18__vereinsfinanzen.sql)
**Content:**
```sql
-- 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.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/entity/FeeSchedule.java)
- [`MemberFeeAssignment.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/entity/MemberFeeAssignment.java)
- [`Payment.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Payment.java)
- [`ExpenseCategory.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/entity/ExpenseCategory.java)
- [`Expense.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Expense.java)
- [`PaymentReminder.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/entity/PaymentReminder.java)
**Approach:** Follow existing patterns — extend `AbstractTenantEntity`, use JPA annotations, manual getters/setters (matching project style — no Lombok on entities).
```java
@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...
}
```
```java
@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.java`](cannamanage-service/src/main/java/de/cannamanage/service/repository/FeeScheduleRepository.java)
- [`MemberFeeAssignmentRepository.java`](cannamanage-service/src/main/java/de/cannamanage/service/repository/MemberFeeAssignmentRepository.java)
- [`PaymentRepository.java`](cannamanage-service/src/main/java/de/cannamanage/service/repository/PaymentRepository.java)
- [`ExpenseCategoryRepository.java`](cannamanage-service/src/main/java/de/cannamanage/service/repository/ExpenseCategoryRepository.java)
- [`ExpenseRepository.java`](cannamanage-service/src/main/java/de/cannamanage/service/repository/ExpenseRepository.java)
- [`PaymentReminderRepository.java`](cannamanage-service/src/main/java/de/cannamanage/service/repository/PaymentReminderRepository.java)
**Approach:** Spring Data JPA repositories with custom queries for financial logic.
```java
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);
}
```
```java
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:**
- [`FinanceService.java`](cannamanage-service/src/main/java/de/cannamanage/service/FinanceService.java)
**Approach:** Central service managing fee schedules, payments, expenses, and balance calculations.
```java
@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`:
```java
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:**
- [`KassenbuchService.java`](cannamanage-service/src/main/java/de/cannamanage/service/KassenbuchService.java)
**Approach:** Generates the Kassenbuch view — chronological list of all transactions with running balance.
```java
@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:**
- [`FinanceController.java`](cannamanage-api/src/main/java/de/cannamanage/api/controller/FinanceController.java)
**Approach:** REST endpoints for all treasury operations. Protected by `MANAGE_FINANCES` permission.
```java
@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`](cannamanage-api/src/main/java/de/cannamanage/api/controller/PortalController.java) (or new `PortalFinanceController.java`)
**Approach:** Member-facing endpoints showing their own payment history and balance.
```java
@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:**
- [`ReceiptPdfService.java`](cannamanage-service/src/main/java/de/cannamanage/service/ReceiptPdfService.java)
**Approach:** Generate Quittung PDF using OpenPDF (same pattern as existing `PdfReportGenerator`).
```java
@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:**
- [`FinancialReportService.java`](cannamanage-service/src/main/java/de/cannamanage/service/FinancialReportService.java)
**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`](cannamanage-frontend/src/app/(dashboard-layout)/finance/page.tsx) — Main finance dashboard
- [`cannamanage-frontend/src/app/(dashboard-layout)/finance/payments/page.tsx`](cannamanage-frontend/src/app/(dashboard-layout)/finance/payments/page.tsx) — Payment list
- [`cannamanage-frontend/src/app/(dashboard-layout)/finance/expenses/page.tsx`](cannamanage-frontend/src/app/(dashboard-layout)/finance/expenses/page.tsx) — Expense list
- [`cannamanage-frontend/src/app/(dashboard-layout)/finance/balances/page.tsx`](cannamanage-frontend/src/app/(dashboard-layout)/finance/balances/page.tsx) — Balance overview
- [`cannamanage-frontend/src/app/(dashboard-layout)/finance/reports/page.tsx`](cannamanage-frontend/src/app/(dashboard-layout)/finance/reports/page.tsx) — Report generation
- [`cannamanage-frontend/src/app/(dashboard-layout)/settings/finance/page.tsx`](cannamanage-frontend/src/app/(dashboard-layout)/settings/finance/page.tsx) — Fee schedules + categories config
- [`cannamanage-frontend/src/services/finance.ts`](cannamanage-frontend/src/services/finance.ts) — API client
- [`cannamanage-frontend/src/data/mock/finance.ts`](cannamanage-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:**
- [`cannamanage-frontend/src/app/(portal-layout)/portal/finance/page.tsx`](cannamanage-frontend/src/app/(portal-layout)/portal/finance/page.tsx)
**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:**
- [`navigations.ts`](cannamanage-frontend/src/data/navigations.ts)
**Approach:** Add finance section to admin nav and portal nav.
Admin nav additions:
```typescript
{
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:
```typescript
{ 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:**
- [`V19__mitgliederversammlung.sql`](cannamanage-api/src/main/resources/db/migration/V19__mitgliederversammlung.sql)
**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`](cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AssemblyType.java) — `ORDINARY`, `EXTRAORDINARY`
- [`AssemblyStatus.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AssemblyStatus.java) — `DRAFT`, `INVITED`, `IN_PROGRESS`, `COMPLETED`, `CANCELLED`
- [`AgendaItemType.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AgendaItemType.java) — `DISCUSSION`, `VOTE`, `ELECTION`, `REPORT`, `OTHER`
- [`VoteType.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/enums/VoteType.java) — `SIMPLE_MAJORITY`, `TWO_THIRDS`, `UNANIMOUS`
- [`VoteResult.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/enums/VoteResult.java) — `ACCEPTED`, `REJECTED`, `TABLED`
- [`GeneralAssembly.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/entity/GeneralAssembly.java)
- [`AssemblyAgendaItem.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/entity/AssemblyAgendaItem.java)
- [`AssemblyAttendance.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/entity/AssemblyAttendance.java)
- [`AssemblyVote.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/entity/AssemblyVote.java)
- [`AssemblyElection.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/entity/AssemblyElection.java)
**Approach:** Standard JPA entities, following existing patterns.
```java
@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:**
- [`AssemblyService.java`](cannamanage-service/src/main/java/de/cannamanage/service/AssemblyService.java)
**Approach:** Manages the MV lifecycle: create → invite → start → complete.
Key methods:
```java
@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:
```java
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:
```java
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:**
- [`ProtocolPdfService.java`](cannamanage-service/src/main/java/de/cannamanage/service/ProtocolPdfService.java)
**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:**
- [`AssemblyController.java`](cannamanage-api/src/main/java/de/cannamanage/api/controller/AssemblyController.java)
**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`](cannamanage-frontend/src/app/(dashboard-layout)/assemblies/page.tsx) — MV list
- [`cannamanage-frontend/src/app/(dashboard-layout)/assemblies/new/page.tsx`](cannamanage-frontend/src/app/(dashboard-layout)/assemblies/new/page.tsx) — Create MV
- [`cannamanage-frontend/src/app/(dashboard-layout)/assemblies/[id]/page.tsx`](cannamanage-frontend/src/app/(dashboard-layout)/assemblies/[id]/page.tsx) — MV detail (tabs)
- [`cannamanage-frontend/src/app/(dashboard-layout)/assemblies/[id]/live/page.tsx`](cannamanage-frontend/src/app/(dashboard-layout)/assemblies/[id]/live/page.tsx) — Live management
- [`cannamanage-frontend/src/app/(portal-layout)/portal/assemblies/page.tsx`](cannamanage-frontend/src/app/(portal-layout)/portal/assemblies/page.tsx) — Portal: upcoming + past MVs
- [`cannamanage-frontend/src/services/assemblies.ts`](cannamanage-frontend/src/services/assemblies.ts) — API client
- [`cannamanage-frontend/src/data/mock/assemblies.ts`](cannamanage-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:**
- [`V20__dokumentenarchiv.sql`](cannamanage-api/src/main/resources/db/migration/V20__dokumentenarchiv.sql)
- [`V21__vorstandsverwaltung.sql`](cannamanage-api/src/main/resources/db/migration/V21__vorstandsverwaltung.sql)
**Content:** As defined in analysis document.
**Dependencies:** V19
**Acceptance criteria:** Migrations run cleanly.
---
### Step 4.2 — Document Entities + Enums
**Files to create:**
- [`DocumentAccessLevel.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/enums/DocumentAccessLevel.java) — `ALL_MEMBERS`, `BOARD_ONLY`
- [`DocumentSource.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/enums/DocumentSource.java) — `MANUAL`, `SYSTEM_MV_PROTOCOL`, `SYSTEM_FINANCIAL_REPORT`
- [`Document.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Document.java)
- [`DocumentCategory.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/entity/DocumentCategory.java)
**Dependencies:** Step 4.1
**Acceptance criteria:** Entities compile.
---
### Step 4.3 — DocumentStorageService (Filesystem)
**Files to create:**
- [`DocumentStorageService.java`](cannamanage-service/src/main/java/de/cannamanage/service/DocumentStorageService.java)
**Approach:** Handles the actual filesystem operations — store, retrieve, delete files.
```java
@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:**
- [`DocumentService.java`](cannamanage-service/src/main/java/de/cannamanage/service/DocumentService.java)
- [`DocumentController.java`](cannamanage-api/src/main/java/de/cannamanage/api/controller/DocumentController.java)
**Approach:** CRUD for document metadata + multipart upload handling.
```java
@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:**
- [`BoardPosition.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/entity/BoardPosition.java)
- [`BoardMember.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/entity/BoardMember.java)
- [`BoardService.java`](cannamanage-service/src/main/java/de/cannamanage/service/BoardService.java)
- [`BoardController.java`](cannamanage-api/src/main/java/de/cannamanage/api/controller/BoardController.java)
**BoardService key methods:**
```java
@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`](cannamanage-frontend/src/app/(dashboard-layout)/documents/page.tsx) — Document archive
- [`cannamanage-frontend/src/app/(dashboard-layout)/board/page.tsx`](cannamanage-frontend/src/app/(dashboard-layout)/board/page.tsx) — Board management
- [`cannamanage-frontend/src/app/(portal-layout)/portal/documents/page.tsx`](cannamanage-frontend/src/app/(portal-layout)/portal/documents/page.tsx) — Portal doc access
- [`cannamanage-frontend/src/app/(portal-layout)/portal/board/page.tsx`](cannamanage-frontend/src/app/(portal-layout)/portal/board/page.tsx) — Portal board view
- [`cannamanage-frontend/src/services/documents.ts`](cannamanage-frontend/src/services/documents.ts)
- [`cannamanage-frontend/src/services/board.ts`](cannamanage-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:**
- [`PaymentReminderScheduler.java`](cannamanage-service/src/main/java/de/cannamanage/service/PaymentReminderScheduler.java)
**Approach:** Scheduled job that checks for overdue members and sends reminders via the notification system.
```java
@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:**
- [`BoardTermScheduler.java`](cannamanage-service/src/main/java/de/cannamanage/service/BoardTermScheduler.java)
**Approach:** Checks for board terms expiring within 30 days, notifies admins.
```java
@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`](cannamanage-service/src/main/java/de/cannamanage/service/AssemblyService.java) (add protocol auto-store)
- [`FinancialReportService.java`](cannamanage-service/src/main/java/de/cannamanage/service/FinancialReportService.java) (add report auto-store)
**Approach:** When MV protocol or annual report PDF is generated, automatically create a `Document` record with `source = SYSTEM_*`.
```java
// 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:**
- [`PlanTierService.java`](cannamanage-service/src/main/java/de/cannamanage/service/PlanTierService.java)
**Add tier rules:**
```java
// 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`](cannamanage-service/src/main/java/de/cannamanage/service/ClubService.java) (or create `ClubSetupService`)
**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:
- 1. 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:**
- [`NotificationType.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/enums/NotificationType.java)
**Add:**
```java
// 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.java`
- `cannamanage-service/src/test/java/de/cannamanage/service/KassenbuchServiceTest.java`
- `cannamanage-service/src/test/java/de/cannamanage/service/ReceiptPdfServiceTest.java`
- `cannamanage-service/src/test/java/de/cannamanage/service/FinancialReportServiceTest.java`
- `cannamanage-service/src/test/java/de/cannamanage/service/PaymentReminderSchedulerTest.java`
- `cannamanage-service/src/test/java/de/cannamanage/service/AssemblyServiceTest.java`
- `cannamanage-service/src/test/java/de/cannamanage/service/ProtocolPdfServiceTest.java`
- `cannamanage-service/src/test/java/de/cannamanage/service/DocumentStorageServiceTest.java`
- `cannamanage-service/src/test/java/de/cannamanage/service/DocumentServiceTest.java`
- `cannamanage-service/src/test/java/de/cannamanage/service/BoardServiceTest.java`
- `cannamanage-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.java`
- `cannamanage-api/src/test/java/de/cannamanage/api/AssemblyControllerIntegrationTest.java`
- `cannamanage-api/src/test/java/de/cannamanage/api/DocumentControllerIntegrationTest.java`
- `cannamanage-api/src/test/java/de/cannamanage/api/BoardControllerIntegrationTest.java`
- `cannamanage-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 workflow
- `cannamanage-frontend/e2e/assemblies.spec.ts` — MV creation → completion
- `cannamanage-frontend/e2e/documents.spec.ts` — Upload/download/categorize
- `cannamanage-frontend/e2e/board.spec.ts` — Board management
- `cannamanage-frontend/e2e/portal-finance.spec.ts` — Member payment view
**Key test scenarios:**
```typescript
// 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
```mermaid
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]
```