Files
cannamanage/docs/sprint-8/cannamanage-sprint8-plan.md
Patrick Plate b22702317a feat(sprint8): Phase 3 — Mitgliederversammlung (assemblies, voting, protocol PDF)
Backend:
- V19 migration: assemblies, assembly_agenda_items, assembly_attendees, assembly_votes, assembly_vote_records
- Enums: AssemblyType, AssemblyStatus, AgendaItemType, VoteType, VoteDecision, VoteResult
- Entities: Assembly, AssemblyAgendaItem, AssemblyAttendee, AssemblyVote, AssemblyVoteRecord
- Repositories: Assembly, AgendaItem, Attendee, Vote, VoteRecord
- AssemblyService: full lifecycle (create, invite, start, attend, vote, quorum, complete)
- AssemblyProtocolService: OpenPDF protocol generation (§147 AO compliant)
- AssemblyController: admin + portal endpoints
- Extended: AuditEventType, NotificationType, StaffPermission

Frontend:
- Assembly service with full API client and TypeScript types
- Admin assemblies list page with create dialog (agenda builder)
- Admin assembly detail page (quorum, agenda, votes, attendees)
- Navigation: Versammlungen with Gavel icon (after Finanzen)

Legal basis: §32-§40 BGB (Mitgliederversammlung), §147 AO (retention)
2026-06-15 08:39:10 +02:00

67 KiB

Sprint 8 Implementation Plan — Vereinsverwaltung Complete

Date: 2026-06-13 Author: Patrick Plate / Lumen (Architect) Status: Draft v2 (with legal citations) Based on: cannamanage-sprint8-analysis.md Sprint Goal: Club Treasury + General Assembly + Document Archive + Board Management


This sprint implements core Vereinsverwaltung features. Each feature is grounded in specific German law:

Vereinsrecht (BGB)

Section Topic Relevance to Sprint 8
§21 BGB Nicht wirtschaftlicher Verein Cannabis Anbauvereinigung operates as e.V. — all features must respect this legal form
§25 BGB Satzung regelt Verfassung Fee schedules + assembly rules must be configurable per Satzung
§26 BGB Vorstand Board management feature — legal representation
§27 BGB Bestellung/Abberufung des Vorstands Board term tracking + elections at MV
§30 BGB Besonderer Vertreter Board positions beyond §26 Vorstand (Kassenwart, Schriftführer)
§32 BGB Mitgliederversammlung Assembly feature — decision-making organ
§33 BGB Satzungsänderung 75% majority for Satzungsänderung, einstimmig for Zweckänderung — vote type modeling
§34 BGB Ausschluss vom Stimmrecht Member cannot vote on matters concerning themselves
§36 BGB Einberufung der MV Notice period validation (minimum per Satzung, typically 14 days)
§37 BGB Minderheitsverlangen 10% of members can force extraordinary MV — future feature hook
§38 BGB Mitgliedschaft Not transferable, not inheritable — relevant for fee assignment logic
§39 BGB Austritt Withdrawal with max 2-year notice — affects fee obligation end date
§40 BGB Abdingbarkeit Which rules can be overridden by Satzung (configurable notice periods, quorum)
§56 BGB Mindestmitgliederzahl 7 members for founding — validation hook
§58 BGB Satzungsinhalt Required content including Beitragsregelung
§58 Nr. 2 BGB Beitragsregelung Satzung must define member contribution rules — legal basis for fee schedule feature
§67 BGB Vereinsregister Mitgliederverzeichnis requirements; Registergericht requires MV protocols for Vorstandsänderungen
§259 BGB Rechenschaftspflicht Duty to render accounts — legal basis for Kassenbuch + financial reports
§666 BGB Auskunftspflicht Duty of information to members — members can demand financial transparency
§670 BGB Aufwendungsersatz Expense reimbursement — basis for expense tracking feature

Abgabenordnung (AO) — Tax Law

Section Topic Relevance
§141 AO Buchführungspflicht Bookkeeping obligation if revenue > €600,000 or profit > €60,000 (unlikely for most clubs, but system must support it)
§143 AO Aufzeichnungspflicht Recording obligation for VAT — Kassenbuch must be accurate
§147 AO Aufbewahrungspflichten Retention periods: 6 years Handelsbriefe, 10 years Buchungsbelege — drives document retention policy

Note: Cannabis clubs are likely NOT gemeinnützig (§51 AO), so §§51-68 AO typically don't apply. They operate as wirtschaftlicher Verein or ideeller Verein with Nebenzweckprivileg.

KCanG (Konsumcannabisgesetz)

Section Topic Relevance
§2 KCanG Definition Anbauvereinigung Legal definition — validates club model
§16 KCanG Max 500 Mitglieder Member cap — existing enforcement
§17 KCanG Voraussetzungen der Mitgliedschaft Only one club per person — existing enforcement
§19 KCanG Weitergabe Distribution limits (25g/day, 50g/month) — existing enforcement
§20 KCanG Schutz von Kindern und Jugendlichen Age verification, U21 rules — existing enforcement
§22 KCanG Dokumentationspflichten Documentation requirements — financial records + protocols must be auditable
§23 KCanG Präventionsbeauftragter Board must include Präventionsbeauftragter — board position feature
§24 KCanG Aufbewahrungsfristen 5 years after member leaves — cannabis-specific data retention
§26 KCanG Kontrolle und Überwachung Regulatory oversight — all records must be producible on demand

DSGVO (Data Protection)

Article Topic Relevance
Art. 6(1)(b) Vertragserfüllung Processing member payment data for contract performance (membership agreement)
Art. 6(1)(c) Rechtliche Verpflichtung Processing for legal obligation (KCanG §22 documentation, §147 AO retention)
Art. 6(1)(f) Berechtigtes Interesse Fee collection + reminder sending as legitimate interest
Art. 15 Auskunftsrecht Members can request all their payment data — portal "Meine Zahlungen" satisfies this
Art. 17 Recht auf Löschung Right to erasure — BUT overridden by §24 KCanG (5-year retention) and §147 AO (10-year retention) for financial data

Retention Policy Matrix (derived from law)

Data Type Retention Legal Basis Implementation
Payment records (Buchungsbelege) 10 years §147 Abs. 1 Nr. 4 AO payments.created_at + 10 years
Receipts (Quittungen) 10 years §147 Abs. 1 Nr. 4 AO Receipt PDFs retained
Financial reports 10 years §147 Abs. 1 Nr. 1 AO Annual report PDFs retained
Geschäftsbriefe (payment reminders) 6 years §147 Abs. 1 Nr. 2 AO payment_reminders table
MV Protocols 10 years (practical) §67 BGB (Vereinsregister needs them) Protocol PDFs in document archive
Cannabis-specific member data 5 years after leaving §24 KCanG Soft-delete + retention check
Board composition history Permanent §67 BGB (Vereinsregister) Never deleted

Overview

This plan implements four features in six phases:

graph TD
    P1[Phase 1: Treasury Backend] --> P2[Phase 2: Treasury Frontend + PDF]
    P2 --> P3[Phase 3: Mitgliederversammlung]
    P3 --> P4[Phase 4: Dokumente + Vorstand]
    P4 --> P5[Phase 5: Integration and Polish]
    P5 --> P6[Phase 6: Testing and QA]

    subgraph Existing
        NS[NotificationDispatchService]
        AS[AuditService]
        PDF[OpenPDF - PdfReportGenerator]
        EV[EventService + ClubEvent]
        PT[PlanTierService]
    end

    NS --> P2
    NS --> P3
    AS --> P1
    AS --> P3
    AS --> P4
    PDF --> P2
    PDF --> P3
    EV --> P3
    PT --> P5

Phase 1: Treasury Backend

Legal basis: §58 Nr. 2 BGB (Satzung must regulate Beiträge), §259 BGB (Rechenschaftspflicht), §666 BGB (Auskunftspflicht), §670 BGB (Aufwendungsersatz), §147 AO (10-year retention for Buchungsbelege), Art. 6(1)(b) DSGVO (contract performance for payment processing), Art. 6(1)(c) DSGVO (legal obligation for financial record-keeping).

Implements fee schedules, payment/expense recording, Kassenbuch calculation, and balance tracking.

Step 1.1 — Extend Enums

Files to create/modify:

Approach:

// PaymentMethod.java
public enum PaymentMethod {
    CASH, BANK_TRANSFER, SEPA, CARD
}

// FeeInterval.java
public enum FeeInterval {
    MONTHLY, QUARTERLY, YEARLY, ONE_TIME
}

// ReminderType.java
public enum ReminderType {
    FIRST, SECOND, FINAL
}

Add to StaffPermission.java:

// Sprint 8:
MANAGE_FINANCES,       // Record payments/expenses, view balances, generate reports
VIEW_FINANCES,         // Read-only financial access (Kassenprüfer)
MANAGE_ASSEMBLY,       // Create/manage MVs, record attendance/votes
MANAGE_DOCUMENTS       // Upload/manage documents

Add to AuditEventType.java:

// Sprint 8 — Finance events
PAYMENT_RECORDED,
PAYMENT_VOIDED,
EXPENSE_RECORDED,
EXPENSE_VOIDED,
FEE_SCHEDULE_CREATED,
FEE_SCHEDULE_UPDATED,
FEE_ASSIGNMENT_CHANGED,
PAYMENT_REMINDER_SENT,
FINANCIAL_REPORT_GENERATED,

// Sprint 8 — Assembly events
ASSEMBLY_CREATED,
ASSEMBLY_INVITATIONS_SENT,
ASSEMBLY_STARTED,
ASSEMBLY_COMPLETED,
ASSEMBLY_CANCELLED,
ASSEMBLY_ATTENDANCE_RECORDED,
ASSEMBLY_VOTE_RECORDED,
ASSEMBLY_ELECTION_RECORDED,
ASSEMBLY_PROTOCOL_GENERATED,
ASSEMBLY_PROTOCOL_UPLOADED,

// Sprint 8 — Document events
DOCUMENT_UPLOADED,
DOCUMENT_UPDATED,
DOCUMENT_DELETED,
DOCUMENT_VERSION_CREATED,

// Sprint 8 — Board events
BOARD_MEMBER_APPOINTED,
BOARD_MEMBER_TERM_ENDED

Dependencies: None (leaf changes) Acceptance criteria: All enums compile, existing functionality unaffected.


Step 1.2 — Flyway Migration V18: Treasury Tables

Files to create:

Content:

-- V18: Vereinsfinanzen (Club Treasury)

CREATE TABLE fee_schedules (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL,
    name VARCHAR(100) NOT NULL,
    amount NUMERIC(10,2) NOT NULL,
    interval VARCHAR(20) NOT NULL DEFAULT 'MONTHLY',
    description TEXT,
    is_active BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE member_fee_assignments (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL,
    member_id UUID NOT NULL REFERENCES members(id),
    fee_schedule_id UUID NOT NULL REFERENCES fee_schedules(id),
    effective_from DATE NOT NULL DEFAULT CURRENT_DATE,
    effective_until DATE,
    notes TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE(member_id, fee_schedule_id, effective_from)
);

CREATE TABLE payments (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL,
    member_id UUID NOT NULL REFERENCES members(id),
    amount NUMERIC(10,2) NOT NULL,
    payment_date DATE NOT NULL,
    payment_method VARCHAR(30) NOT NULL,
    reference VARCHAR(255),
    period_from DATE,
    period_to DATE,
    receipt_number VARCHAR(50),
    notes TEXT,
    voided BOOLEAN NOT NULL DEFAULT false,
    voided_reason TEXT,
    voided_at TIMESTAMPTZ,
    recorded_by UUID NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE expense_categories (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL,
    name VARCHAR(100) NOT NULL,
    icon VARCHAR(50),
    sort_order INTEGER NOT NULL DEFAULT 0,
    is_active BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE expenses (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL,
    category_id UUID NOT NULL REFERENCES expense_categories(id),
    amount NUMERIC(10,2) NOT NULL,
    expense_date DATE NOT NULL,
    description VARCHAR(500) NOT NULL,
    receipt_path VARCHAR(500),
    payment_method VARCHAR(30),
    voided BOOLEAN NOT NULL DEFAULT false,
    voided_reason TEXT,
    voided_at TIMESTAMPTZ,
    recorded_by UUID NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE payment_reminders (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL,
    member_id UUID NOT NULL REFERENCES members(id),
    amount_due NUMERIC(10,2) NOT NULL,
    reminder_date DATE NOT NULL DEFAULT CURRENT_DATE,
    reminder_type VARCHAR(30) NOT NULL,
    sent_via VARCHAR(30) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_fee_schedules_tenant ON fee_schedules(tenant_id, is_active);
CREATE INDEX idx_member_fee_assign_member ON member_fee_assignments(member_id);
CREATE INDEX idx_member_fee_assign_tenant ON member_fee_assignments(tenant_id);
CREATE INDEX idx_payments_tenant_date ON payments(tenant_id, payment_date DESC);
CREATE INDEX idx_payments_member ON payments(member_id, payment_date DESC);
CREATE INDEX idx_payments_receipt ON payments(tenant_id, receipt_number);
CREATE INDEX idx_expenses_tenant_date ON expenses(tenant_id, expense_date DESC);
CREATE INDEX idx_expenses_category ON expenses(category_id);
CREATE INDEX idx_expense_categories_tenant ON expense_categories(tenant_id, sort_order);
CREATE INDEX idx_payment_reminders_member ON payment_reminders(member_id);

Dependencies: V17 must be applied first Acceptance criteria: Migration runs cleanly on PostgreSQL, all indexes created.


Step 1.3 — Domain Entities for Treasury

Files to create:

Approach: Follow existing patterns — extend AbstractTenantEntity, use JPA annotations, manual getters/setters (matching project style — no Lombok on entities).

@Entity
@Table(name = "fee_schedules")
public class FeeSchedule extends AbstractTenantEntity {

    @Column(name = "name", nullable = false, length = 100)
    private String name;

    @Column(name = "amount", nullable = false, precision = 10, scale = 2)
    private BigDecimal amount;

    @Enumerated(EnumType.STRING)
    @Column(name = "interval", nullable = false, length = 20)
    private FeeInterval interval = FeeInterval.MONTHLY;

    @Column(name = "description", columnDefinition = "TEXT")
    private String description;

    @Column(name = "is_active", nullable = false)
    private boolean active = true;

    @Column(name = "updated_at")
    private Instant updatedAt;

    // getters/setters...
}
@Entity
@Table(name = "payments")
public class Payment extends AbstractTenantEntity {

    @Column(name = "member_id", nullable = false)
    private UUID memberId;

    @Column(name = "amount", nullable = false, precision = 10, scale = 2)
    private BigDecimal amount;

    @Column(name = "payment_date", nullable = false)
    private LocalDate paymentDate;

    @Enumerated(EnumType.STRING)
    @Column(name = "payment_method", nullable = false, length = 30)
    private PaymentMethod paymentMethod;

    @Column(name = "reference", length = 255)
    private String reference;

    @Column(name = "period_from")
    private LocalDate periodFrom;

    @Column(name = "period_to")
    private LocalDate periodTo;

    @Column(name = "receipt_number", length = 50)
    private String receiptNumber;

    @Column(name = "notes", columnDefinition = "TEXT")
    private String notes;

    @Column(name = "voided", nullable = false)
    private boolean voided = false;

    @Column(name = "voided_reason", columnDefinition = "TEXT")
    private String voidedReason;

    @Column(name = "voided_at")
    private Instant voidedAt;

    @Column(name = "recorded_by", nullable = false)
    private UUID recordedBy;

    // getters/setters...
}

Dependencies: Step 1.1 (enums), Step 1.2 (migration) Acceptance criteria: All entities compile, JPA mappings valid.


Step 1.4 — Repositories for Treasury

Files to create:

Approach: Spring Data JPA repositories with custom queries for financial logic.

public interface PaymentRepository extends JpaRepository<Payment, UUID> {

    List<Payment> findByTenantIdAndVoidedFalseOrderByPaymentDateDesc(UUID tenantId);

    List<Payment> findByMemberIdAndVoidedFalseOrderByPaymentDateDesc(UUID memberId);

    @Query("SELECT SUM(p.amount) FROM Payment p WHERE p.memberId = :memberId AND p.voided = false " +
           "AND p.periodFrom >= :from AND p.periodTo <= :to")
    Optional<BigDecimal> sumPaymentsForPeriod(@Param("memberId") UUID memberId,
                                              @Param("from") LocalDate from,
                                              @Param("to") LocalDate to);

    @Query("SELECT p FROM Payment p WHERE p.tenantId = :tenantId AND p.voided = false " +
           "AND p.paymentDate BETWEEN :from AND :to ORDER BY p.paymentDate")
    List<Payment> findByTenantAndDateRange(@Param("tenantId") UUID tenantId,
                                           @Param("from") LocalDate from,
                                           @Param("to") LocalDate to);

    Optional<Payment> findTopByTenantIdAndReceiptNumberStartsWithOrderByReceiptNumberDesc(
            UUID tenantId, String prefix);
}
public interface ExpenseRepository extends JpaRepository<Expense, UUID> {

    List<Expense> findByTenantIdAndVoidedFalseOrderByExpenseDateDesc(UUID tenantId);

    @Query("SELECT e FROM Expense e WHERE e.tenantId = :tenantId AND e.voided = false " +
           "AND e.expenseDate BETWEEN :from AND :to ORDER BY e.expenseDate")
    List<Expense> findByTenantAndDateRange(@Param("tenantId") UUID tenantId,
                                            @Param("from") LocalDate from,
                                            @Param("to") LocalDate to);

    @Query("SELECT e.categoryId, SUM(e.amount) FROM Expense e " +
           "WHERE e.tenantId = :tenantId AND e.voided = false " +
           "AND e.expenseDate BETWEEN :from AND :to GROUP BY e.categoryId")
    List<Object[]> sumByCategoryForPeriod(@Param("tenantId") UUID tenantId,
                                          @Param("from") LocalDate from,
                                          @Param("to") LocalDate to);
}

Dependencies: Step 1.3 (entities) Acceptance criteria: Repositories compile, queries valid JPQL.


Step 1.5 — FinanceService

Files to create:

Approach: Central service managing fee schedules, payments, expenses, and balance calculations.

@Slf4j
@Service
public class FinanceService {

    private final FeeScheduleRepository feeScheduleRepo;
    private final MemberFeeAssignmentRepository feeAssignmentRepo;
    private final PaymentRepository paymentRepo;
    private final ExpenseRepository expenseRepo;
    private final ExpenseCategoryRepository categoryRepo;
    private final AuditService auditService;

    // Fee schedule CRUD
    public FeeSchedule createFeeSchedule(UUID tenantId, String name, BigDecimal amount,
                                          FeeInterval interval, String description) { ... }
    public FeeSchedule updateFeeSchedule(UUID id, ...) { ... }
    public List<FeeSchedule> listFeeSchedules(UUID tenantId) { ... }

    // Fee assignments
    public MemberFeeAssignment assignFeeToMember(UUID memberId, UUID feeScheduleId,
                                                  LocalDate effectiveFrom) { ... }

    // Payments
    public Payment recordPayment(UUID tenantId, UUID memberId, BigDecimal amount,
                                  LocalDate date, PaymentMethod method, String reference,
                                  LocalDate periodFrom, LocalDate periodTo,
                                  String notes, UUID recordedBy) { ... }
    public void voidPayment(UUID paymentId, String reason, UUID actorId) { ... }

    // Expenses
    public Expense recordExpense(UUID tenantId, UUID categoryId, BigDecimal amount,
                                  LocalDate date, String description, String receiptPath,
                                  PaymentMethod method, UUID recordedBy) { ... }
    public void voidExpense(UUID expenseId, String reason, UUID actorId) { ... }

    // Balance calculation
    public MemberBalance calculateMemberBalance(UUID memberId) { ... }
    public List<MemberBalance> calculateAllBalances(UUID tenantId) { ... }

    // Receipt number generation: CM-{year}-{seq:06d}
    private String generateReceiptNumber(UUID tenantId) { ... }
}

Key logic for calculateMemberBalance:

public MemberBalance calculateMemberBalance(UUID memberId) {
    MemberFeeAssignment assignment = feeAssignmentRepo.findActiveFeeAssignment(memberId)
            .orElse(null);
    if (assignment == null) return MemberBalance.noFeeAssigned(memberId);

    FeeSchedule schedule = feeScheduleRepo.findById(assignment.getFeeScheduleId()).orElseThrow();

    // Calculate total due from effective_from to today
    LocalDate from = assignment.getEffectiveFrom();
    LocalDate to = LocalDate.now();
    BigDecimal totalDue = calculateTotalDue(schedule, from, to);

    // Calculate total paid
    BigDecimal totalPaid = paymentRepo.sumPaymentsForMember(memberId).orElse(BigDecimal.ZERO);

    BigDecimal outstanding = totalDue.subtract(totalPaid);
    LocalDate lastPaymentDate = paymentRepo.findLastPaymentDate(memberId).orElse(null);

    return new MemberBalance(memberId, schedule.getName(), totalDue, totalPaid,
                             outstanding, lastPaymentDate);
}

Dependencies: Step 1.4 (repositories), existing AuditService Acceptance criteria: All CRUD operations work, balance calculation accurate, receipt numbers sequential.


Step 1.6 — KassenbuchService

Files to create:

Approach: Generates the Kassenbuch view — chronological list of all transactions with running balance.

@Slf4j
@Service
public class KassenbuchService {

    public KassenbuchView generateKassenbuch(UUID tenantId, LocalDate from, LocalDate to) {
        List<Payment> payments = paymentRepo.findByTenantAndDateRange(tenantId, from, to);
        List<Expense> expenses = expenseRepo.findByTenantAndDateRange(tenantId, from, to);

        // Merge into single timeline, calculate running balance
        List<KassenbuchEntry> entries = mergeAndSort(payments, expenses);
        BigDecimal runningBalance = calculateOpeningBalance(tenantId, from);

        for (KassenbuchEntry entry : entries) {
            if (entry.isIncome()) {
                runningBalance = runningBalance.add(entry.getAmount());
            } else {
                runningBalance = runningBalance.subtract(entry.getAmount());
            }
            entry.setRunningBalance(runningBalance);
        }

        return new KassenbuchView(entries, runningBalance);
    }

    public String exportCsv(UUID tenantId, LocalDate from, LocalDate to) {
        // Generate CSV with columns: Datum;Typ;Beschreibung;Einnahme;Ausgabe;Saldo
        ...
    }
}

Dependencies: Step 1.5 (FinanceService) Acceptance criteria: Kassenbuch shows correct running balance, CSV export produces valid file.


Step 1.7 — Finance REST Controller

Files to create:

Approach: REST endpoints for all treasury operations. Protected by MANAGE_FINANCES permission.

@RestController
@RequestMapping("/api/finance")
@RequiredArgsConstructor
public class FinanceController {

    @GetMapping("/fee-schedules")
    public ResponseEntity<List<FeeScheduleDto>> listFeeSchedules() { ... }

    @PostMapping("/fee-schedules")
    public ResponseEntity<FeeScheduleDto> createFeeSchedule(@Valid @RequestBody CreateFeeScheduleRequest req) { ... }

    @PostMapping("/payments")
    public ResponseEntity<PaymentDto> recordPayment(@Valid @RequestBody RecordPaymentRequest req) { ... }

    @PostMapping("/payments/{id}/void")
    public ResponseEntity<Void> voidPayment(@PathVariable UUID id, @RequestBody VoidRequest req) { ... }

    @GetMapping("/balances")
    public ResponseEntity<List<MemberBalanceDto>> getAllBalances() { ... }

    @GetMapping("/kassenbuch")
    public ResponseEntity<KassenbuchViewDto> getKassenbuch(
            @RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate from,
            @RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate to) { ... }

    @GetMapping("/kassenbuch/export")
    public ResponseEntity<byte[]> exportCsv(
            @RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate from,
            @RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate to) { ... }

    @GetMapping("/reports/annual/{year}")
    public ResponseEntity<AnnualReportDto> getAnnualReport(@PathVariable int year) { ... }
}

Dependencies: Step 1.5, Step 1.6 Acceptance criteria: All endpoints return correct data, permission checks enforced.


Step 1.8 — Portal Finance Endpoints

Files to modify:

Approach: Member-facing endpoints showing their own payment history and balance.

@RestController
@RequestMapping("/api/portal/finance")
public class PortalFinanceController {

    @GetMapping("/my-payments")
    public ResponseEntity<List<PaymentDto>> myPayments() {
        UUID memberId = getCurrentMemberId();
        return ResponseEntity.ok(financeService.getPaymentsForMember(memberId));
    }

    @GetMapping("/my-balance")
    public ResponseEntity<MemberBalanceDto> myBalance() {
        UUID memberId = getCurrentMemberId();
        return ResponseEntity.ok(financeService.calculateMemberBalance(memberId).toDto());
    }

    @GetMapping("/receipts/{paymentId}")
    public ResponseEntity<byte[]> downloadReceipt(@PathVariable UUID paymentId) {
        // Verify payment belongs to current member
        ...
    }
}

Dependencies: Step 1.7 Acceptance criteria: Members see only their own data, cannot access other members' payments.


Phase 2: Treasury Frontend + PDF

Legal basis: §259 BGB (Rechenschaftspflicht — members have right to financial transparency), §666 BGB (Auskunftspflicht — board must provide information on request), §147 AO (Quittungen must be retained 10 years), Art. 15 DSGVO (Auskunftsrecht — portal "Meine Zahlungen" satisfies member's right of access to their payment data), standard Vereinspraxis (member has Anspruch auf Beitragsquittung).

Step 2.1 — Receipt PDF Generation

Files to create:

Approach: Generate Quittung PDF using OpenPDF (same pattern as existing PdfReportGenerator).

@Slf4j
@Service
public class ReceiptPdfService {

    public byte[] generateReceipt(Payment payment, Member member, Club club) {
        Document document = new Document(PageSize.A4, 50, 50, 50, 50);
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        PdfWriter.getInstance(document, baos);
        document.open();

        // Header: Club name + address
        // Title: "Quittung Nr. CM-2026-000001"
        // Body: Member name, amount, date, payment method, period covered
        // Footer: Signature line, date, club stamp placeholder

        document.close();
        return baos.toByteArray();
    }
}

Receipt PDF layout:

┌─────────────────────────────────────────┐
│  [Club Name]                            │
│  [Club Address]                         │
│                                         │
│  QUITTUNG                               │
│  Nr. CM-2026-000001                     │
│                                         │
│  Erhalten von:  [Member Name]           │
│  Betrag:        30,00 €                 │
│  Datum:         01.07.2026              │
│  Zahlungsart:   Überweisung             │
│  Zeitraum:      Juli 2026               │
│  Verwendung:    Mitgliedsbeitrag        │
│                                         │
│  _______________    _______________     │
│  Datum              Unterschrift         │
│                     Kassenwart           │
└─────────────────────────────────────────┘

Dependencies: Step 1.5, existing OpenPDF library Acceptance criteria: Valid PDF generated, correct German locale formatting (Euro, dates).


Step 2.2 — Annual Financial Report PDF

Files to create:

Approach: Generate Jahresabschluss PDF with summary tables.

Content:

  • Title: "Jahresabschluss {year} — {Club Name}"
  • Summary: Total income, total expenses, net balance
  • Income breakdown: by month (table)
  • Expense breakdown: by category (table)
  • Running balance chart (month by month)
  • Member payment summary: number of members, total received, outstanding
  • Footer: Generated date, "Erstellt mit CannaManage"

Dependencies: Step 1.6 (KassenbuchService), OpenPDF Acceptance criteria: Annual report PDF matches expected format, all numbers correct.


Step 2.3 — Frontend: Finance Admin Pages

Files to create:

Finance Dashboard layout:

┌──────────────────────────────────────────────────┐
│  Vereinsfinanzen                                  │
│                                                   │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐          │
│  │ Einnahmen│  │ Ausgaben │  │  Saldo  │          │
│  │ 4.230€  │  │ 2.180€  │  │ 2.050€  │          │
│  │ diesen M.│  │ diesen M.│  │         │          │
│  └─────────┘  └─────────┘  └─────────┘          │
│                                                   │
│  Tabs: [Kassenbuch] [Zahlungen] [Ausgaben]       │
│         [Offene Beiträge] [Berichte]             │
│                                                   │
│  ┌───────────────────────────────────────┐       │
│  │ Kassenbuch                             │       │
│  │ Datum    | Typ      | Beschreibung | +/- | Saldo│
│  │ 01.07   | Einnahme | Beitrag M.X  | +30 | 2050│
│  │ 28.06   | Ausgabe  | Strom Juni   | -85 | 2020│
│  │ ...                                    │       │
│  └───────────────────────────────────────┘       │
└──────────────────────────────────────────────────┘

Dependencies: Step 1.7 (API endpoints) Acceptance criteria: All finance pages render, data fetched from API, responsive design.


Step 2.4 — Frontend: Portal Finance View

Files to create:

Layout:

┌────────────────────────────────────────┐
│  Meine Zahlungen                        │
│                                         │
│  ┌────────────┐  ┌────────────┐        │
│  │ Saldo      │  │ Nächster   │        │
│  │ 0,00 €     │  │ Beitrag    │        │
│  │ ✅ Aktuell │  │ 01.08.2026 │        │
│  └────────────┘  └────────────┘        │
│                                         │
│  Zahlungshistorie:                      │
│  ┌─────────────────────────────┐       │
│  │ 01.07.2026 | 30€ | ✅ | 📄  │       │
│  │ 01.06.2026 | 30€ | ✅ | 📄  │       │
│  │ 01.05.2026 | 30€ | ✅ | 📄  │       │
│  └─────────────────────────────┘       │
│  📄 = Download Quittung                 │
└────────────────────────────────────────┘

Dependencies: Step 1.8 (portal API), Step 2.1 (receipt PDF) Acceptance criteria: Members see their balance and payment history, can download receipts.


Step 2.5 — Navigation Updates

Files to modify:

Approach: Add finance section to admin nav and portal nav.

Admin nav additions:

{
  title: 'Finanzen',
  icon: 'wallet',
  path: '/finance',
  permission: 'MANAGE_FINANCES',
  children: [
    { title: 'Kassenbuch', path: '/finance' },
    { title: 'Zahlungen', path: '/finance/payments' },
    { title: 'Ausgaben', path: '/finance/expenses' },
    { title: 'Offene Beiträge', path: '/finance/balances' },
    { title: 'Berichte', path: '/finance/reports' },
  ]
}

Portal nav additions:

{ title: 'Meine Zahlungen', icon: 'receipt', path: '/portal/finance' }

Dependencies: All Phase 2 pages Acceptance criteria: Navigation renders correctly, permission-gated.


Phase 3: Mitgliederversammlung (General Assembly)

Legal basis: §32 BGB (MV as decision-making organ — Beschlussfassung), §33 BGB (75% majority for Satzungsänderung, unanimous for Zweckänderung — modeled as VoteType), §34 BGB (exclusion from voting on own matters), §36 BGB (Einberufung — notice period enforcement), §37 BGB (Minderheitsverlangen — 10% can force extraordinary MV), §40 BGB (Satzung can override quorum/notice defaults), §67 BGB (Registergericht requires MV protocols for Vorstandsänderungen — protocol generation + archival), §27 BGB (Bestellung/Abberufung des Vorstands at MV — election feature), §22 KCanG (Dokumentationspflichten — all decisions auditable).

Step 3.1 — Flyway Migration V19: Assembly Tables

Files to create:

Content: As defined in analysis document — general_assemblies, assembly_agenda_items, assembly_attendance, assembly_votes, assembly_elections tables with all indexes.

Dependencies: V18 Acceptance criteria: Migration runs cleanly.


Step 3.2 — Assembly Enums + Entities

Files to create:

Approach: Standard JPA entities, following existing patterns.

@Entity
@Table(name = "general_assemblies", indexes = {
    @Index(name = "idx_assemblies_tenant", columnList = "tenant_id, scheduled_at"),
    @Index(name = "idx_assemblies_status", columnList = "tenant_id, status")
})
public class GeneralAssembly extends AbstractTenantEntity {

    @Column(name = "club_id", nullable = false)
    private UUID clubId;

    @Column(name = "title", nullable = false, length = 200)
    private String title;

    @Enumerated(EnumType.STRING)
    @Column(name = "assembly_type", nullable = false, length = 30)
    private AssemblyType assemblyType = AssemblyType.ORDINARY;

    @Column(name = "scheduled_at", nullable = false)
    private Instant scheduledAt;

    @Column(name = "location", nullable = false, length = 300)
    private String location;

    @Column(name = "notice_period_days", nullable = false)
    private int noticePeriodDays = 14;

    @Column(name = "quorum_percentage", nullable = false)
    private int quorumPercentage = 50;

    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false, length = 30)
    private AssemblyStatus status = AssemblyStatus.DRAFT;

    @Column(name = "invitation_sent_at")
    private Instant invitationSentAt;

    @Column(name = "started_at")
    private Instant startedAt;

    @Column(name = "ended_at")
    private Instant endedAt;

    @Column(name = "notes", columnDefinition = "TEXT")
    private String notes;

    @Column(name = "protocol_document_id")
    private UUID protocolDocumentId;

    @Column(name = "event_id")
    private UUID eventId;

    @Column(name = "created_by", nullable = false)
    private UUID createdBy;

    @OneToMany(mappedBy = "assemblyId", cascade = CascadeType.ALL)
    @OrderBy("sortOrder ASC")
    private List<AssemblyAgendaItem> agendaItems = new ArrayList<>();

    // getters/setters...
}

Dependencies: Step 3.1 Acceptance criteria: All entities compile, relationships mapped correctly.


Step 3.3 — AssemblyService

Files to create:

Approach: Manages the MV lifecycle: create → invite → start → complete.

Key methods:

@Slf4j
@Service
@Transactional
public class AssemblyService {

    // Lifecycle
    public GeneralAssembly createAssembly(CreateAssemblyRequest req) { ... }
    public void sendInvitations(UUID assemblyId) { ... }  // transitions DRAFT → INVITED
    public void startAssembly(UUID assemblyId) { ... }    // transitions INVITED → IN_PROGRESS
    public void completeAssembly(UUID assemblyId) { ... } // transitions IN_PROGRESS → COMPLETED
    public void cancelAssembly(UUID assemblyId) { ... }

    // Agenda
    public AssemblyAgendaItem addAgendaItem(UUID assemblyId, ...) { ... }
    public void reorderAgendaItems(UUID assemblyId, List<UUID> orderedIds) { ... }

    // Attendance
    public void checkInMember(UUID assemblyId, UUID memberId, UUID checkedInBy) { ... }
    public void removeCheckIn(UUID assemblyId, UUID memberId) { ... }
    public QuorumStatus getQuorumStatus(UUID assemblyId) { ... }

    // Voting
    public AssemblyVote createVote(UUID assemblyId, ...) { ... }
    public void recordVoteResult(UUID voteId, int yes, int no, int abstain) { ... }

    // Elections
    public void recordElection(UUID assemblyId, String position, UUID electedMemberId,
                               Integer voteCount, LocalDate termStart, LocalDate termEnd) { ... }
}

sendInvitations logic:

public void sendInvitations(UUID assemblyId) {
    GeneralAssembly assembly = assemblyRepo.findById(assemblyId).orElseThrow();
    if (assembly.getStatus() != AssemblyStatus.DRAFT) {
        throw new IllegalStateException("Can only invite from DRAFT status");
    }

    // Validate notice period
    long daysUntil = ChronoUnit.DAYS.between(Instant.now(), assembly.getScheduledAt());
    if (daysUntil < assembly.getNoticePeriodDays()) {
        log.warn("Notice period not met: {} days until MV, {} required",
                 daysUntil, assembly.getNoticePeriodDays());
        // Still allow — just warn (club can override)
    }

    // Create corresponding ClubEvent (integrates with Sprint 7 event calendar)
    ClubEvent event = eventService.createEvent(assembly.getClubId(), assembly.getTitle(),
            assembly.formatDescription(), EventType.GENERAL_ASSEMBLY,
            assembly.getScheduledAt(), null, assembly.getLocation(), null,
            assembly.getCreatedBy());
    assembly.setEventId(event.getId());

    // Send notification to all active members
    notificationDispatchService.broadcast(assembly.getTenantId(),
            NotificationType.ASSEMBLY_INVITATION,
            "Einladung zur " + assembly.getTitle(),
            formatInvitationBody(assembly));

    assembly.setStatus(AssemblyStatus.INVITED);
    assembly.setInvitationSentAt(Instant.now());
    assemblyRepo.save(assembly);

    auditService.log(AuditEventType.ASSEMBLY_INVITATIONS_SENT, ...);
}

getQuorumStatus logic:

public QuorumStatus getQuorumStatus(UUID assemblyId) {
    GeneralAssembly assembly = assemblyRepo.findById(assemblyId).orElseThrow();
    long attendeeCount = attendanceRepo.countByAssemblyId(assemblyId);
    long totalActiveMembers = memberRepo.countByTenantIdAndStatus(
            assembly.getTenantId(), MemberStatus.ACTIVE);

    int percentage = (int) (attendeeCount * 100 / totalActiveMembers);
    boolean quorumReached = percentage >= assembly.getQuorumPercentage();

    return new QuorumStatus(attendeeCount, totalActiveMembers,
                            percentage, assembly.getQuorumPercentage(), quorumReached);
}

Dependencies: Step 3.2, existing EventService, NotificationDispatchService, AuditService Acceptance criteria: Full MV lifecycle works, quorum calculation correct, invitations sent via notification system.


Step 3.4 — Protocol PDF Generation

Files to create:

Approach: Generate official MV protocol (Protokoll) as PDF.

Protocol structure:

PROTOKOLL
der ordentlichen Mitgliederversammlung
des [Club Name] e.V.

Datum:    [date]
Ort:      [location]
Beginn:   [start time]
Ende:     [end time]

Anwesende Mitglieder: [count] von [total] ([percentage]%)
Beschlussfähigkeit:   ✅ Erreicht / ❌ Nicht erreicht

TAGESORDNUNG:
1. [item 1]
2. [item 2]
...

BESCHLÜSSE:
TOP 5: [vote title]
  Ergebnis: Angenommen (Ja: 42, Nein: 3, Enthaltungen: 5)

WAHLEN:
1. Vorsitzende/r: [Name] (gewählt mit [X] Stimmen)
Kassenwart/in: [Name] (gewählt mit [X] Stimmen)

ANWESENHEITSLISTE:
[numbered list of all attendees]

___________________          ___________________
Versammlungsleiter/in        Schriftführer/in

Dependencies: Step 3.3, OpenPDF Acceptance criteria: PDF generated with all required sections, German formatting.


Step 3.5 — Assembly REST Controller

Files to create:

Approach: Full CRUD + lifecycle endpoints as defined in analysis. Protected by MANAGE_ASSEMBLY permission.

Dependencies: Step 3.3, Step 3.4 Acceptance criteria: All endpoints functional, proper status transitions enforced.


Step 3.6 — Frontend: Assembly Pages

Files to create:

Live management page layout:

┌─────────────────────────────────────────────────────┐
│  🟢 MV Aktiv — Ordentliche MV 2026                  │
│                                                      │
│  ┌──────────────┐  ┌──────────────┐                 │
│  │ Anwesend     │  │ Quorum       │                 │
│  │ 38 / 72      │  │ 52% ✅       │                 │
│  │ Mitglieder   │  │ von 50% nötig│                 │
│  └──────────────┘  └──────────────┘                 │
│                                                      │
│  Tabs: [Anwesenheit] [Abstimmungen] [Wahlen]        │
│                                                      │
│  Anwesenheit:                                        │
│  🔍 [Mitglied suchen...]                            │
│  ┌─────────────────────────────┐                    │
│  │ ☑ Max Mustermann  | 19:02  │                    │
│  │ ☑ Erika Muster    | 19:05  │                    │
│  │ ☐ Hans Dampf      |        │                    │
│  └─────────────────────────────┘                    │
│                                                      │
│  [📄 Protokoll generieren]  [✅ MV beenden]          │
└─────────────────────────────────────────────────────┘

Dependencies: Step 3.5 (API) Acceptance criteria: Full MV flow works from creation to protocol generation.


Phase 4: Dokumentenarchiv + Vorstandsverwaltung

Legal basis (Documents): §147 AO (Aufbewahrungspflichten — 6/10 year retention), §24 KCanG (5-year retention for cannabis-specific data after member leaves), §26 KCanG (Kontrolle — records producible on regulatory demand), §22 KCanG (Dokumentationspflichten), Art. 6(1)(c) DSGVO (processing for legal obligation — retention requirements override Art. 17 erasure right).

Legal basis (Board): §26 BGB (Vorstand — legal representation, minimum 1 person), §27 BGB (Bestellung und Abberufung — appointment/dismissal by MV), §30 BGB (Besonderer Vertreter — additional positions like Kassenwart, Schriftführer), §67 BGB (Vereinsregister — board composition changes must be registered), §23 KCanG (Präventionsbeauftragter — mandatory board position for cannabis clubs).

Step 4.1 — Flyway Migrations V20 + V21

Files to create:

Content: As defined in analysis document.

Dependencies: V19 Acceptance criteria: Migrations run cleanly.


Step 4.2 — Document Entities + Enums

Files to create:

Dependencies: Step 4.1 Acceptance criteria: Entities compile.


Step 4.3 — DocumentStorageService (Filesystem)

Files to create:

Approach: Handles the actual filesystem operations — store, retrieve, delete files.

@Slf4j
@Service
public class DocumentStorageService {

    @Value("${cannamanage.storage.base-path:/uploads}")
    private String basePath;

    private static final long MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB
    private static final Set<String> ALLOWED_TYPES = Set.of(
        "application/pdf", "application/msword",
        "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
        "application/vnd.ms-excel",
        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        "image/png", "image/jpeg"
    );

    public StoredFile store(UUID tenantId, UUID documentId, MultipartFile file) {
        validateFile(file);
        Path targetDir = Path.of(basePath, "documents", tenantId.toString(), documentId.toString());
        Files.createDirectories(targetDir);
        Path targetFile = targetDir.resolve(file.getOriginalFilename());
        file.transferTo(targetFile);
        return new StoredFile(targetFile.toString(), file.getSize());
    }

    public byte[] retrieve(String storagePath) {
        return Files.readAllBytes(Path.of(storagePath));
    }

    public void delete(String storagePath) {
        Files.deleteIfExists(Path.of(storagePath));
    }

    private void validateFile(MultipartFile file) {
        if (file.getSize() > MAX_FILE_SIZE) throw new FileTooLargeException(...);
        if (!ALLOWED_TYPES.contains(file.getContentType())) throw new UnsupportedFileTypeException(...);
    }
}

Dependencies: None (uses Spring @Value for path configuration) Acceptance criteria: Files stored in correct directory structure, validation works.


Step 4.4 — DocumentService + Controller

Files to create:

Approach: CRUD for document metadata + multipart upload handling.

@RestController
@RequestMapping("/api/documents")
public class DocumentController {

    @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public ResponseEntity<DocumentDto> uploadDocument(
            @RequestPart("file") MultipartFile file,
            @RequestPart("metadata") CreateDocumentRequest metadata) {
        Document doc = documentService.uploadDocument(file, metadata);
        return ResponseEntity.status(HttpStatus.CREATED).body(doc.toDto());
    }

    @GetMapping("/{id}/download")
    public ResponseEntity<Resource> downloadDocument(@PathVariable UUID id) {
        Document doc = documentService.getDocument(id);
        byte[] content = storageService.retrieve(doc.getStoragePath());
        return ResponseEntity.ok()
                .contentType(MediaType.parseMediaType(doc.getContentType()))
                .header(HttpHeaders.CONTENT_DISPOSITION,
                        "attachment; filename=\"" + doc.getFilename() + "\"")
                .body(new ByteArrayResource(content));
    }
}

Dependencies: Step 4.2, Step 4.3 Acceptance criteria: Upload, download, delete all work. Tenant isolation enforced.


Step 4.5 — Board Entities + Service

Files to create:

BoardService key methods:

@Slf4j
@Service
public class BoardService {

    public List<BoardPosition> getPositions(UUID tenantId) { ... }
    public BoardPosition createPosition(UUID tenantId, String name, boolean required, int maxHolders) { ... }

    public BoardMember appointMember(UUID positionId, UUID memberId,
                                      LocalDate termStart, LocalDate termEnd,
                                      UUID assemblyId) { ... }

    public void endTerm(UUID boardMemberId, String reason) { ... }

    public List<BoardMember> getCurrentBoard(UUID tenantId) { ... }

    public List<BoardMember> getBoardHistory(UUID tenantId) { ... }

    // Called by MV elections
    public void applyElectionResult(AssemblyElection election) {
        // End current holder's term
        // Appoint new member
    }
}

Dependencies: Step 4.1 (V21 migration), Step 3.3 (linked from elections) Acceptance criteria: Board composition trackable, history preserved.


Step 4.6 — Frontend: Documents + Board Pages

Files to create:

Dependencies: Step 4.4, Step 4.5 Acceptance criteria: Upload/download works, board displayed correctly.


Phase 5: Integration & Polish

Legal basis: §259 BGB + §666 BGB (payment reminders serve the Verein's legitimate interest in collecting dues owed per Satzung — Art. 6(1)(f) DSGVO), §27 BGB (term expiry notifications ensure continuity of legal representation per §26 BGB), §147 AO (auto-archive ensures 10-year retention compliance for system-generated financial documents), §22 KCanG + §26 KCanG (all auto-archived protocols satisfy documentation and oversight requirements).

Step 5.1 — Payment Reminder Scheduler

Files to create:

Approach: Scheduled job that checks for overdue members and sends reminders via the notification system.

@Slf4j
@Service
public class PaymentReminderScheduler {

    @Scheduled(cron = "0 0 9 * * MON")  // Every Monday at 9:00 AM
    public void checkOverduePayments() {
        List<Club> clubs = clubRepo.findAllActive();
        for (Club club : clubs) {
            PlanTier tier = planTierService.getClubTier(club.getId());
            if (tier == PlanTier.STARTER || tier == PlanTier.TRIAL) continue; // Pro+ only

            List<MemberBalance> overdue = financeService.getOverdueMembers(club.getTenantId());
            for (MemberBalance balance : overdue) {
                ReminderType type = determineReminderType(balance);
                sendReminder(balance, type);
            }
        }
    }

    private ReminderType determineReminderType(MemberBalance balance) {
        long daysOverdue = ChronoUnit.DAYS.between(balance.getOverdueSince(), LocalDate.now());
        if (daysOverdue > 60) return ReminderType.FINAL;
        if (daysOverdue > 30) return ReminderType.SECOND;
        return ReminderType.FIRST;
    }
}

Dependencies: Step 1.5, Sprint 7 NotificationDispatchService, PlanTierService Acceptance criteria: Reminders sent on schedule, only to Pro+ clubs, correct escalation.


Step 5.2 — Board Term Expiry Scheduler

Files to create:

Approach: Checks for board terms expiring within 30 days, notifies admins.

@Slf4j
@Service
public class BoardTermScheduler {

    @Scheduled(cron = "0 0 8 1 * *")  // 1st of every month at 8:00 AM
    public void checkExpiringTerms() {
        LocalDate threshold = LocalDate.now().plusDays(30);
        List<BoardMember> expiring = boardMemberRepo.findByTermEndBeforeAndIsActiveTrue(threshold);

        for (BoardMember bm : expiring) {
            notificationDispatchService.sendToAdmins(bm.getTenantId(),
                    NotificationType.BOARD_TERM_EXPIRING,
                    "Amtszeit endet bald",
                    String.format("Die Amtszeit von %s als %s endet am %s.",
                            memberName, positionName, bm.getTermEnd()));
        }
    }
}

Dependencies: Step 4.5 Acceptance criteria: Notifications sent 30 days before term expiry.


Step 5.3 — Auto-Archive Integration

Files to modify:

Approach: When MV protocol or annual report PDF is generated, automatically create a Document record with source = SYSTEM_*.

// In AssemblyService.completeAssembly()
byte[] protocolPdf = protocolPdfService.generateProtocol(assembly);
Document doc = documentService.storeSystemDocument(
        assembly.getTenantId(),
        "Protokoll " + assembly.getTitle(),
        "protokoll-" + assembly.getId() + ".pdf",
        protocolPdf,
        DocumentSource.SYSTEM_MV_PROTOCOL,
        categoryRepo.findByTenantIdAndName(assembly.getTenantId(), "Protokolle")
);
assembly.setProtocolDocumentId(doc.getId());

Dependencies: Step 3.3, Step 4.4 Acceptance criteria: Protocols and reports auto-archived in document system.


Step 5.4 — Tier Enforcement Updates

Files to modify:

Add tier rules:

// Finance tier rules
private static final int STARTER_FEE_SCHEDULES_LIMIT = 2;
private static final int STARTER_EXPENSES_YEARLY_LIMIT = 50;
private static final int STARTER_DOCUMENT_STORAGE_MB = 100;
private static final int PRO_DOCUMENT_STORAGE_MB = 1024;

public void requireFinanceAccess(UUID clubId) { ... }
public void enforceExpenseLimit(UUID clubId) { ... }
public void enforceDocumentStorageLimit(UUID clubId, long currentUsageBytes) { ... }
public void requireReminderAccess(UUID clubId) { ... }

Dependencies: All feature services Acceptance criteria: Tier limits correctly enforced, appropriate error messages.


Step 5.5 — Seed Default Data on Club Creation

Files to modify:

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:

Add:

// Sprint 8:
PAYMENT_REMINDER,
ASSEMBLY_INVITATION,
BOARD_TERM_EXPIRING

Dependencies: None Acceptance criteria: New notification types available for dispatching.


Phase 6: Testing & QA

Step 6.1 — Unit Tests

Files to create:

  • cannamanage-service/src/test/java/de/cannamanage/service/FinanceServiceTest.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:

// finance.spec.ts
test('Admin records payment and member sees it in portal', async ({ page }) => {
    // 1. Admin navigates to finance
    // 2. Records €30 payment for Member X
    // 3. Verifies receipt number generated
    // 4. Downloads receipt PDF
    // 5. Switches to portal as Member X
    // 6. Verifies payment appears in history
    // 7. Verifies balance shows €0 outstanding
});

// assemblies.spec.ts
test('Full MV lifecycle: create → invite → attend → vote → complete', async ({ page }) => {
    // 1. Admin creates MV with agenda
    // 2. Sends invitations
    // 3. Starts MV
    // 4. Checks in 3 members
    // 5. Records vote (2 yes, 1 no)
    // 6. Completes MV
    // 7. Verifies protocol PDF generated
    // 8. Verifies protocol auto-archived in documents
});

Dependencies: Step 6.2 Acceptance criteria: All E2E tests pass in CI.


File Summary

New Files (by module)

cannamanage-domain (enums): 10 new enum files cannamanage-domain (entities): 15 new entity files cannamanage-service: 12 new service files + 11 test files cannamanage-api: 5 new controller files + 4 migration files + 5 integration test files cannamanage-frontend: ~15 new pages + 5 service files + 5 mock files + 5 E2E test files

Total: ~90 new files

Modified Files

File Change
StaffPermission.java +4 enum values
AuditEventType.java +26 enum values
NotificationType.java +3 enum values
PlanTierService.java +4 tier enforcement methods
ClubService.java +seed default data logic
navigations.ts +finance, assembly, documents, board nav items
EventType.java +GENERAL_ASSEMBLY value

Dependency Graph

graph TD
    S1_1[1.1 Enums] --> S1_3[1.3 Entities]
    S1_2[1.2 V18 Migration] --> S1_3
    S1_3 --> S1_4[1.4 Repositories]
    S1_4 --> S1_5[1.5 FinanceService]
    S1_5 --> S1_6[1.6 KassenbuchService]
    S1_5 --> S1_7[1.7 FinanceController]
    S1_6 --> S1_7
    S1_7 --> S1_8[1.8 Portal Endpoints]
    S1_5 --> S2_1[2.1 Receipt PDF]
    S1_6 --> S2_2[2.2 Annual Report PDF]
    S1_7 --> S2_3[2.3 Finance Frontend]
    S1_8 --> S2_4[2.4 Portal Finance]
    S2_3 --> S2_5[2.5 Navigation]
    S2_4 --> S2_5

    S3_1[3.1 V19 Migration] --> S3_2[3.2 Assembly Entities]
    S3_2 --> S3_3[3.3 AssemblyService]
    S3_3 --> S3_4[3.4 Protocol PDF]
    S3_3 --> S3_5[3.5 AssemblyController]
    S3_5 --> S3_6[3.6 Assembly Frontend]

    S4_1[4.1 V20+V21 Migrations] --> S4_2[4.2 Document Entities]
    S4_2 --> S4_3[4.3 DocumentStorageService]
    S4_3 --> S4_4[4.4 DocumentService + Controller]
    S4_1 --> S4_5[4.5 Board Entities + Service]
    S4_4 --> S4_6[4.6 Doc + Board Frontend]
    S4_5 --> S4_6

    S1_5 --> S5_1[5.1 Payment Reminder Scheduler]
    S4_5 --> S5_2[5.2 Board Term Scheduler]
    S3_3 --> S5_3[5.3 Auto-Archive]
    S4_4 --> S5_3
    S5_1 --> S5_4[5.4 Tier Enforcement]
    S5_3 --> S5_5[5.5 Seed Defaults]
    S1_1 --> S5_6[5.6 NotificationType]

    S5_4 --> S6_1[6.1 Unit Tests]
    S5_5 --> S6_1
    S6_1 --> S6_2[6.2 Integration Tests]
    S6_2 --> S6_3[6.3 E2E Tests]