b22702317a
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)
1656 lines
67 KiB
Markdown
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]
|
|
```
|