# 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 { List findByTenantIdAndVoidedFalseOrderByPaymentDateDesc(UUID tenantId); List 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 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 findByTenantAndDateRange(@Param("tenantId") UUID tenantId, @Param("from") LocalDate from, @Param("to") LocalDate to); Optional findTopByTenantIdAndReceiptNumberStartsWithOrderByReceiptNumberDesc( UUID tenantId, String prefix); } ``` ```java public interface ExpenseRepository extends JpaRepository { List 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 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 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 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 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 payments = paymentRepo.findByTenantAndDateRange(tenantId, from, to); List expenses = expenseRepo.findByTenantAndDateRange(tenantId, from, to); // Merge into single timeline, calculate running balance List 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> listFeeSchedules() { ... } @PostMapping("/fee-schedules") public ResponseEntity createFeeSchedule(@Valid @RequestBody CreateFeeScheduleRequest req) { ... } @PostMapping("/payments") public ResponseEntity recordPayment(@Valid @RequestBody RecordPaymentRequest req) { ... } @PostMapping("/payments/{id}/void") public ResponseEntity voidPayment(@PathVariable UUID id, @RequestBody VoidRequest req) { ... } @GetMapping("/balances") public ResponseEntity> getAllBalances() { ... } @GetMapping("/kassenbuch") public ResponseEntity getKassenbuch( @RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate from, @RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate to) { ... } @GetMapping("/kassenbuch/export") public ResponseEntity exportCsv( @RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate from, @RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate to) { ... } @GetMapping("/reports/annual/{year}") public ResponseEntity 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> myPayments() { UUID memberId = getCurrentMemberId(); return ResponseEntity.ok(financeService.getPaymentsForMember(memberId)); } @GetMapping("/my-balance") public ResponseEntity myBalance() { UUID memberId = getCurrentMemberId(); return ResponseEntity.ok(financeService.calculateMemberBalance(memberId).toDto()); } @GetMapping("/receipts/{paymentId}") public ResponseEntity 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 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 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 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 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 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 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 getCurrentBoard(UUID tenantId) { ... } public List 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 clubs = clubRepo.findAllActive(); for (Club club : clubs) { PlanTier tier = planTierService.getClubTier(club.getId()); if (tier == PlanTier.STARTER || tier == PlanTier.TRIAL) continue; // Pro+ only List 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 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] ```