Files
cannamanage/docs/sprint-6/cannamanage-sprint6-plan.md
T

53 KiB
Raw Blame History

CannaManage — Sprint 6 Implementation Plan

Date: 2026-06-12 Author: Patrick Plate / Lumen (Planner) Status: Draft v2 Base Branch: main Sprint Branch: sprint/6-production Sprint Goal: Production readiness — deploy, DSGVO compliance, Stripe payments, immutable audit log, grow calendar, notifications, launch

Sprint Structure: Sprint 6 is the "go-live" sprint, split into 7 phases:

  • Phase 1 — Production deployment (IONOS VPS + Docker Compose + TLS)
  • Phase 2 — DSGVO consent management
  • Phase 3 — Stripe integration (SEPA + PayPal + Card)
  • Phase 4 — Immutable audit log
  • Phase 5 — Grow calendar (FULL — sensors, photo log, feeding schedule)
  • Phase 6 — Notifications + PWA
  • Phase 7 — Launch checklist + documentation

Estimated effort: ~2324 days (single worker, sequential). Phases are prioritized — Phases 14 are hard requirements for launch, Phases 57 are value-adds.


0. Decisions (Confirmed )

# Decision Detail Status
D1 Payment provider Stripe (fresh account for CannaManage) — full-featured API for SEPA, PayPal, Card. Handles mandates, invoicing, dunning (Mahnwesen), billing portal. Cost: €0.35/SEPA tx. Confirmed
D2 Primary payment method SEPA Lastschrift (direct debit) — standard for German SaaS recurring billing. PayPal + Card as fallback via Stripe Payment Element. Confirmed
D3 Deploy target IONOS VPS (existing plate-software.de server, 8 GB RAM, ~2 cores) — deploy alongside existing services. Docker Compose, Nginx reverse proxy, Let's Encrypt TLS. Already running, no new provisioning needed. Confirmed
D4 Domain Subdomain on plate-software.de (e.g., cannamanage.plate-software.de or app.plate-software.de/cannamanage) — IONOS DNS, no new domain registration needed Confirmed
D5 Backup strategy PostgreSQL pg_dump — 7 days daily + 4 weeks weekly retention, stored on IONOS server Confirmed
D6 CI/CD Gitea Actions (self-hosted on plate-software.de) → build Docker images → deploy locally Confirmed
D7 Monitoring Uptime Kuma (self-hosted) or simple cron + curl health check with Telegram/email alerts Confirmed
D8 DSGVO approach Consent-first — users must accept before using the app. Full Art. 15 (data export) + Art. 17 (erasure) compliance. Confirmed
D9 Audit log Immutable append-only table with server-generated timestamps (Europe/Berlin). No UPDATE/DELETE on audit records. Confirmed
D10 Grow calendar FullCalendar (React) — FULL scope: sensors (temp/humidity), photo log, feeding schedule, stage tracking with batch linking. Confirmed
D11 Notifications WebSocket (SockJS/STOMP) for real-time + Web Push API for background push (opt-in) Confirmed
D12 Pricing model 3-month free trial → Tiered: Starter (≤30 members, €19/mo), Pro (≤100 members, €49/mo), Enterprise (custom) Confirmed
D13 PWA scope Basic in Sprint 6 — manifest + offline page + install prompt. Full offline sync deferred. Confirmed

1. Sprint 5 Recap (Context)

Delivered Status
Full-stack integration (React Query ↔ Spring Boot API)
Docker Compose (PostgreSQL + backend + frontend, health checks)
Next.js 15.5.18 upgrade (8+ CVEs resolved)
Staff management UI (list, invite, edit permissions, revoke)
System test harness (Docker Compose test profile, Playwright E2E)
Per-component loading, offline resilience, error boundary
CORS configuration, seed data strategy (SQL + API-driven)

Critical gap from Sprint 5: App runs locally with Docker Compose but has no production deployment, no payment system, no DSGVO compliance, and no audit trail. These are hard blockers for going live.


2. Sprint 6 Scope

IN Scope — Hard Requirements (Phases 14)

# Feature Priority Effort
1 Production deployment — IONOS VPS (existing plate-software.de), Nginx subdomain, TLS, Docker Compose prod, backups, Gitea Actions CI/CD, monitoring P0 3 days
2 DSGVO consent management — consent entity, banner, audit log, data export (Art. 15), data deletion (Art. 17) P0 3 days
3 Stripe integration — fresh account, SEPA + PayPal + Card, 3-month free trial, tiered subscriptions (Starter €19/Pro €49/Enterprise), billing portal, webhooks P0 4 days
4 Immutable audit log — event entity, AuditService, admin UI, PDF export P0 2 days

Phases 14 effort: ~12 days

IN Scope — Value Adds (Phases 57)

# Feature Priority Effort
5 Grow calendar (FULL) — entity, CRUD API, FullCalendar UI, batch linking, sensor integration (temp/humidity), photo uploads, feeding schedules P1 56 days
6 Notifications + PWA — WebSocket, bell icon, push notifications, service worker, manifest P1 3 days
7 Launch checklist + documentation — security scan, perf test, ToS/Datenschutz pages, pricing, landing page P2 3 days

Phases 57 effort: ~1112 days

Total estimated effort: ~2324 days (single worker, sequential)

OUT of Scope (Sprint 7+)

  • 2FA (TOTP) for members and staff
  • Inspector read-only mode (Behörde officials)
  • Monthly report auto-sealing (SHA-256 hash)
  • Cryptographic hash chain on distributions
  • Mobile app (React Native or Flutter)
  • Multi-club marketplace
  • Advanced analytics / BI dashboard

3. Architecture Decisions

3.1 Production Infrastructure (Phase 1)

┌──────────────────────────────────────────────────────┐
│          IONOS VPS (existing plate-software.de)       │
│   ~2 cores, 8 GB RAM — co-hosted with other services │
│                                                      │
│  ┌─────────────────────────────────────────────────┐ │
│  │              Nginx (reverse proxy)               │ │
│  │  - TLS termination (Let's Encrypt / certbot)    │ │
│  │  - HTTP/2, HSTS, security headers               │ │
│  │  - WebSocket upgrade (ws://)                    │ │
│  │  - Rate limiting (limit_req_zone)               │ │
│  └───────────┬──────────────────┬──────────────────┘ │
│              │                  │                     │
│  ┌───────────▼──────┐  ┌───────▼──────────────────┐ │
│  │   Frontend :3000  │  │    Backend :8080          │ │
│  │  (Next.js SSR)    │  │  (Spring Boot + JPA)     │ │
│  └──────────────────┘  └──────────┬───────────────┘ │
│                                    │                  │
│                         ┌──────────▼───────────────┐ │
│                         │   PostgreSQL 16 :5432     │ │
│                         │   (pgdata volume)         │ │
│                         └──────────────────────────┘ │
│                                                      │
│  ┌────────────────┐  ┌───────────────────────────┐  │
│  │  Uptime Kuma   │  │  pg_dump cron (daily)     │  │
│  │  :3001         │  │  → /backup/ (7-day rotate)│  │
│  └────────────────┘  └───────────────────────────┘  │
└──────────────────────────────────────────────────────┘

Nginx configuration (key directives):

# /etc/nginx/sites-available/cannamanage.plate-software.de
server {
    listen 443 ssl http2;
    server_name cannamanage.plate-software.de;

    ssl_certificate /etc/letsencrypt/live/cannamanage.plate-software.de/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/cannamanage.plate-software.de/privkey.pem;

    # Security headers
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
    add_header X-Content-Type-Options nosniff always;
    add_header X-Frame-Options DENY always;
    add_header Referrer-Policy strict-origin-when-cross-origin always;

    # Rate limiting
    limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;

    # Frontend (Next.js)
    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # Backend API
    location /api/ {
        limit_req zone=api burst=50 nodelay;
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # WebSocket
    location /ws/ {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
    }

    # Stripe webhook (no rate limit — Stripe needs reliable delivery)
    location /api/v1/webhooks/stripe {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header Stripe-Signature $http_stripe_signature;
    }
}

server {
    listen 80;
    server_name cannamanage.plate-software.de;
    return 301 https://$host$request_uri;
}

Docker Compose production profile:

# docker-compose.prod.yml
version: '3.9'

services:
  db:
    image: postgres:16-alpine
    container_name: cannamanage-db
    restart: always
    environment:
      POSTGRES_DB: ${DB_NAME}
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./backup:/backup
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
      interval: 10s
      timeout: 5s
      retries: 5

  backend:
    image: ghcr.io/pplate/cannamanage-backend:${TAG:-latest}
    container_name: cannamanage-backend
    restart: always
    environment:
      - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/${DB_NAME}
      - SPRING_DATASOURCE_USERNAME=${DB_USER}
      - SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD}
      - SPRING_PROFILES_ACTIVE=prod
      - JWT_SECRET=${JWT_SECRET}
      - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
      - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
      - STRIPE_PRICE_STARTER=${STRIPE_PRICE_STARTER}
      - STRIPE_PRICE_PRO=${STRIPE_PRICE_PRO}
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
      interval: 15s
      timeout: 5s
      retries: 5
      start_period: 45s

  frontend:
    image: ghcr.io/pplate/cannamanage-frontend:${TAG:-latest}
    container_name: cannamanage-frontend
    restart: always
    environment:
      - BACKEND_URL=http://backend:8080
      - NEXTAUTH_URL=https://cannamanage.plate-software.de
      - NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
      - AUTH_TRUST_HOST=true
      - NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY}
    depends_on:
      backend:
        condition: service_healthy

volumes:
  pgdata:

Backup cron script:

#!/bin/bash
# /opt/cannamanage/backup.sh — runs daily via cron
BACKUP_DIR="/opt/cannamanage/backup"
DAILY_RETENTION=7
WEEKLY_RETENTION=28
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DAY_OF_WEEK=$(date +%u)

docker exec cannamanage-db pg_dump -U ${DB_USER} -d ${DB_NAME} \
  | gzip > "${BACKUP_DIR}/cannamanage_daily_${TIMESTAMP}.sql.gz"

# Weekly backup on Sundays
if [ "$DAY_OF_WEEK" -eq 7 ]; then
  cp "${BACKUP_DIR}/cannamanage_daily_${TIMESTAMP}.sql.gz" \
     "${BACKUP_DIR}/cannamanage_weekly_${TIMESTAMP}.sql.gz"
fi

# Rotate: 7 days daily + 4 weeks weekly
find "${BACKUP_DIR}" -name "*_daily_*.sql.gz" -mtime +${DAILY_RETENTION} -delete
find "${BACKUP_DIR}" -name "*_weekly_*.sql.gz" -mtime +${WEEKLY_RETENTION} -delete

Gitea Actions CI/CD (self-hosted on plate-software.de):

# .gitea/workflows/deploy.yml
name: Deploy to Production
on:
  push:
    branches: [main]
    paths-ignore: ['docs/**', '*.md']

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Build backend
        run: mvn -f cannamanage-api/pom.xml package -DskipTests -B

      - name: Build Docker images
        run: |
          docker build -f Dockerfile.backend -t cannamanage-backend:${{ gitea.sha }} .
          docker build -f cannamanage-frontend/Dockerfile -t cannamanage-frontend:${{ gitea.sha }} ./cannamanage-frontend

      - name: Deploy locally
        run: |
          cd /opt/cannamanage
          export TAG=${{ gitea.sha }}
          docker compose -f docker-compose.prod.yml up -d --remove-orphans
          docker image prune -f
┌─────────────────────────────────────────────────────────┐
│                  DSGVO Consent Flow                       │
│                                                          │
│  User Login ─→ Check consent_accepted? ─→ YES ─→ App    │
│                        │                                 │
│                        NO                                │
│                        │                                 │
│                        ▼                                 │
│              Consent Banner (modal)                       │
│              - Datenschutzerklärung link                  │
│              - AGB link                                   │
│              - "Akzeptieren" button                       │
│              - "Ablehnen" → logout                        │
│                        │                                 │
│                    ACCEPT                                 │
│                        │                                 │
│                        ▼                                 │
│         ConsentController.accept()                        │
│         → stores Consent entity                          │
│         → audit log entry                                │
│         → sets consent cookie                            │
│         → redirects to app                               │
└─────────────────────────────────────────────────────────┘

Entity:

@Entity
@Table(name = "consent")
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class Consent extends AbstractTenantEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @Column(name = "consent_type", nullable = false)
    @Enumerated(EnumType.STRING)
    private ConsentType consentType; // PRIVACY_POLICY, TERMS_OF_SERVICE, DATA_PROCESSING

    @Column(name = "version", nullable = false)
    private String version; // "2026-06-v1" — tracks which version was accepted

    @Column(name = "accepted_at", nullable = false)
    private Instant acceptedAt;

    @Column(name = "revoked_at")
    private Instant revokedAt;

    @Column(name = "ip_address")
    private String ipAddress;

    @Column(name = "user_agent")
    private String userAgent;
}

Flyway migration (V6__consent_and_audit.sql):

-- Consent table
CREATE TABLE consent (
    id BIGSERIAL PRIMARY KEY,
    club_id BIGINT NOT NULL REFERENCES club(id),
    user_id BIGINT NOT NULL REFERENCES users(id),
    consent_type VARCHAR(50) NOT NULL,
    version VARCHAR(50) NOT NULL,
    accepted_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    revoked_at TIMESTAMP WITH TIME ZONE,
    ip_address VARCHAR(45),
    user_agent VARCHAR(500),
    CONSTRAINT uq_consent_user_type UNIQUE (user_id, consent_type, version)
);

CREATE INDEX idx_consent_user ON consent(user_id);
CREATE INDEX idx_consent_club ON consent(club_id);

Data export endpoint (Art. 15):

@GetMapping("/api/v1/dsgvo/export")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<byte[]> exportMyData(@AuthenticationPrincipal UserDetails user) {
    // Collects: user profile, all distributions, quota history, consent records, login history
    byte[] zipFile = dsgvoService.exportUserData(user.getUsername());
    return ResponseEntity.ok()
        .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=meine-daten.zip")
        .contentType(MediaType.APPLICATION_OCTET_STREAM)
        .body(zipFile);
}

Data deletion endpoint (Art. 17):

@DeleteMapping("/api/v1/dsgvo/erasure")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Void> requestErasure(@AuthenticationPrincipal UserDetails user) {
    // Soft-delete user data, anonymize distributions (keep aggregates for compliance),
    // revoke all consents, invalidate sessions
    // NOTE: Some data must be retained for legal compliance (tax records, 10-year retention)
    dsgvoService.processErasureRequest(user.getUsername());
    return ResponseEntity.noContent().build();
}

3.3 Stripe Payment Architecture (Phase 3)

┌───────────────────────────────────────────────────────────────┐
│                     Stripe Integration                         │
│                                                               │
│  Club Onboarding ─→ Stripe Customer creation                  │
│                       │                                       │
│                       ▼                                       │
│  Billing Page ─→ Stripe Checkout Session                      │
│  (/settings/billing)  (Payment Element: SEPA/PayPal/Card)     │
│                       │                                       │
│                       ▼                                       │
│               Subscription active                             │
│                       │                                       │
│                       ├─→ invoice.paid → extend access         │
│                       ├─→ invoice.payment_failed → warn user   │
│                       └─→ customer.subscription.deleted → lock │
│                                                               │
│  Self-service ─→ Stripe Billing Portal                        │
│  (update payment method, cancel, download invoices)           │
└───────────────────────────────────────────────────────────────┘

Pricing tiers:

Tier Price Features Stripe Price ID
Free Trial €0 (14 days) Full features, 1 club, max 7 members — (trial period on Starter)
Starter €29/month 1 club, up to 50 members, email support price_starter_monthly
Pro €79/month 1 club, unlimited members, priority support, API access price_pro_monthly

Backend — StripeService:

@Service
@Slf4j
public class StripeService {

    @Value("${stripe.secret-key}")
    private String stripeSecretKey;

    @Value("${stripe.webhook-secret}")
    private String webhookSecret;

    @PostConstruct
    void init() {
        Stripe.apiKey = stripeSecretKey;
    }

    public Customer createCustomer(Club club, User owner) {
        CustomerCreateParams params = CustomerCreateParams.builder()
            .setEmail(owner.getEmail())
            .setName(club.getName())
            .putMetadata("club_id", club.getId().toString())
            .putMetadata("user_id", owner.getId().toString())
            .build();
        return Customer.create(params);
    }

    public Session createCheckoutSession(Club club, String priceId, String successUrl, String cancelUrl) {
        SessionCreateParams params = SessionCreateParams.builder()
            .setCustomer(club.getStripeCustomerId())
            .setMode(SessionCreateParams.Mode.SUBSCRIPTION)
            .addPaymentMethodType(SessionCreateParams.PaymentMethodType.SEPA_DEBIT)
            .addPaymentMethodType(SessionCreateParams.PaymentMethodType.CARD)
            .addPaymentMethodType(SessionCreateParams.PaymentMethodType.PAYPAL)
            .addLineItem(SessionCreateParams.LineItem.builder()
                .setPrice(priceId)
                .setQuantity(1L)
                .build())
            .setSuccessUrl(successUrl + "?session_id={CHECKOUT_SESSION_ID}")
            .setCancelUrl(cancelUrl)
            .setSubscriptionData(SessionCreateParams.SubscriptionData.builder()
                .setTrialPeriodDays(14L)
                .build())
            .build();
        return Session.create(params);
    }

    public Session createBillingPortalSession(Club club, String returnUrl) {
        com.stripe.param.billingportal.SessionCreateParams params =
            com.stripe.param.billingportal.SessionCreateParams.builder()
                .setCustomer(club.getStripeCustomerId())
                .setReturnUrl(returnUrl)
                .build();
        return com.stripe.model.billingportal.Session.create(params);
    }

    public void handleWebhook(String payload, String sigHeader) {
        Event event = Webhook.constructEvent(payload, sigHeader, webhookSecret);

        switch (event.getType()) {
            case "invoice.paid" -> handleInvoicePaid(event);
            case "invoice.payment_failed" -> handlePaymentFailed(event);
            case "customer.subscription.deleted" -> handleSubscriptionCanceled(event);
            case "customer.subscription.updated" -> handleSubscriptionUpdated(event);
        }
    }
}

Webhook controller:

@RestController
@RequestMapping("/api/v1/webhooks")
public class StripeWebhookController {

    @PostMapping("/stripe")
    public ResponseEntity<String> handleStripeWebhook(
            @RequestBody String payload,
            @RequestHeader("Stripe-Signature") String sigHeader) {
        stripeService.handleWebhook(payload, sigHeader);
        return ResponseEntity.ok("OK");
    }
}

Frontend — billing page (/settings/billing):

// src/app/[locale]/(admin)/settings/billing/page.tsx
export default function BillingPage() {
  const { data: subscription } = useSubscription()
  const createCheckout = useCreateCheckoutSession()
  const openPortal = useOpenBillingPortal()

  return (
    <div className="space-y-6">
      <h1>Abonnement & Zahlung</h1>

      {/* Current plan */}
      <Card>
        <CardHeader><CardTitle>Aktueller Plan</CardTitle></CardHeader>
        <CardContent>
          <Badge>{subscription?.plan ?? "Free Trial"}</Badge>
          <p>Nächste Zahlung: {subscription?.nextPaymentDate}</p>
          <p>Zahlungsart: {subscription?.paymentMethod}</p>
        </CardContent>
        <CardFooter>
          <Button onClick={() => openPortal.mutate()}>
            Zahlungsmethode verwalten
          </Button>
        </CardFooter>
      </Card>

      {/* Upgrade options (if on trial/starter) */}
      {!subscription?.plan && <PricingCards onSelect={(priceId) => createCheckout.mutate(priceId)} />}

      {/* Invoice history */}
      <InvoiceHistoryTable />
    </div>
  )
}

Database changes (Club entity):

// Add to Club entity
@Column(name = "stripe_customer_id")
private String stripeCustomerId;

@Column(name = "subscription_status")
@Enumerated(EnumType.STRING)
private SubscriptionStatus subscriptionStatus; // TRIALING, ACTIVE, PAST_DUE, CANCELED, NONE

@Column(name = "subscription_plan")
private String subscriptionPlan; // "starter", "pro"

@Column(name = "trial_ends_at")
private Instant trialEndsAt;

3.4 Immutable Audit Log Architecture (Phase 4)

@Entity
@Table(name = "audit_event")
@Data @Builder @NoArgsConstructor @AllArgsConstructor
@Immutable // Hibernate: prevents UPDATE/DELETE
public class AuditEvent {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

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

    @Column(name = "actor_id")
    private Long actorId; // null for system events

    @Column(name = "actor_name", nullable = false)
    private String actorName; // "Patrick Plate" or "SYSTEM"

    @Column(name = "event_type", nullable = false)
    @Enumerated(EnumType.STRING)
    private AuditEventType eventType;

    @Column(name = "entity_type")
    private String entityType; // "Distribution", "Member", "Batch", etc.

    @Column(name = "entity_id")
    private Long entityId;

    @Column(name = "description", nullable = false)
    private String description; // Human-readable: "25g Northern Lights an Max Mustermann ausgegeben"

    @Column(name = "details", columnDefinition = "JSONB")
    private String details; // JSON with before/after values

    @Column(name = "ip_address")
    private String ipAddress;

    @Column(name = "created_at", nullable = false, updatable = false)
    private Instant createdAt; // Server-generated, Europe/Berlin

    @PrePersist
    void prePersist() {
        this.createdAt = Instant.now();
    }
}

AuditEventType enum:

public enum AuditEventType {
    // Distributions
    DISTRIBUTION_CREATED,
    DISTRIBUTION_VOIDED,

    // Members
    MEMBER_CREATED,
    MEMBER_UPDATED,
    MEMBER_DEACTIVATED,
    MEMBER_DELETED,

    // Batches
    BATCH_CREATED,
    BATCH_RECALLED,
    BATCH_DESTROYED,

    // Stock
    STOCK_ADJUSTED,

    // Auth
    USER_LOGIN,
    USER_LOGOUT,
    USER_LOGIN_FAILED,

    // Staff
    STAFF_INVITED,
    STAFF_PERMISSIONS_CHANGED,
    STAFF_REVOKED,

    // DSGVO
    CONSENT_ACCEPTED,
    CONSENT_REVOKED,
    DATA_EXPORT_REQUESTED,
    DATA_ERASURE_REQUESTED,

    // Billing
    SUBSCRIPTION_CREATED,
    SUBSCRIPTION_CANCELED,
    PAYMENT_RECEIVED,
    PAYMENT_FAILED,

    // Grow
    GROW_ENTRY_CREATED,
    GROW_ENTRY_UPDATED,
    HARVEST_LINKED_TO_BATCH
}

AuditService (aspect-style):

@Service
@Slf4j
public class AuditService {

    private final AuditEventRepository repository;

    public void log(AuditEventType type, Long clubId, Long actorId, String actorName,
                    String entityType, Long entityId, String description, Object details) {
        AuditEvent event = AuditEvent.builder()
            .clubId(clubId)
            .actorId(actorId)
            .actorName(actorName)
            .eventType(type)
            .entityType(entityType)
            .entityId(entityId)
            .description(description)
            .details(details != null ? objectMapper.writeValueAsString(details) : null)
            .build();
        repository.save(event);
        log.debug("Audit: {} by {} — {}", type, actorName, description);
    }
}

3.5 Grow Calendar Architecture (Phase 5)

Entity:

@Entity
@Table(name = "grow_entry")
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class GrowEntry extends AbstractTenantEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "strain_id", nullable = false)
    private Strain strain;

    @Column(name = "stage", nullable = false)
    @Enumerated(EnumType.STRING)
    private GrowStage stage; // SEEDLING, VEGETATIVE, FLOWERING, HARVEST, DRYING, CURING

    @Column(name = "start_date", nullable = false)
    private LocalDate startDate;

    @Column(name = "end_date")
    private LocalDate endDate;

    @Column(name = "plant_count")
    private Integer plantCount;

    @Column(name = "notes")
    private String notes;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "batch_id")
    private Batch linkedBatch; // null until harvest → batch linking

    @Column(name = "expected_yield_grams")
    private BigDecimal expectedYieldGrams;

    @Column(name = "actual_yield_grams")
    private BigDecimal actualYieldGrams;
}

GrowStage enum with colors:

public enum GrowStage {
    SEEDLING("#4ade80"),      // green-400
    VEGETATIVE("#22c55e"),    // green-500
    FLOWERING("#a855f7"),     // purple-500
    HARVEST("#f59e0b"),       // amber-500
    DRYING("#78716c"),        // stone-500
    CURING("#d97706");        // amber-600

    private final String color;
    GrowStage(String color) { this.color = color; }
    public String getColor() { return color; }
}

FullCalendar integration:

// src/app/[locale]/(admin)/grow/page.tsx
import FullCalendar from "@fullcalendar/react"
import dayGridPlugin from "@fullcalendar/daygrid"
import interactionPlugin from "@fullcalendar/interaction"

export default function GrowPage() {
  const { data: entries } = useGrowEntries()

  const events = entries?.map(entry => ({
    id: entry.id.toString(),
    title: `${entry.strain.name}${entry.stage}`,
    start: entry.startDate,
    end: entry.endDate,
    backgroundColor: STAGE_COLORS[entry.stage],
    extendedProps: { entry }
  }))

  return (
    <FullCalendar
      plugins={[dayGridPlugin, interactionPlugin]}
      initialView="dayGridMonth"
      events={events}
      editable={true}
      selectable={true}
      dateClick={handleDateClick}
      eventClick={handleEventClick}
      locale="de"
    />
  )
}

3.6 WebSocket + Notifications Architecture (Phase 6)

Backend (Spring WebSocket + STOMP):

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic", "/queue");
        config.setApplicationDestinationPrefixes("/app");
        config.setUserDestinationPrefix("/user");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
            .setAllowedOriginPatterns("*")
            .withSockJS();
    }
}

Notification events:

@Service
public class NotificationService {

    private final SimpMessagingTemplate messagingTemplate;

    public void notifyQuotaWarning(Long userId, int percentUsed) {
        messagingTemplate.convertAndSendToUser(
            userId.toString(),
            "/queue/notifications",
            new NotificationEvent("QUOTA_WARNING",
                String.format("Kontingent zu %d%% ausgeschöpft", percentUsed))
        );
    }

    public void notifyBatchRecall(Long clubId, String batchName) {
        messagingTemplate.convertAndSend(
            "/topic/club/" + clubId + "/notifications",
            new NotificationEvent("BATCH_RECALL",
                String.format("Charge '%s' wurde zurückgerufen!", batchName))
        );
    }

    public void notifyNewDistribution(Long memberId, String strainName, BigDecimal amount) {
        messagingTemplate.convertAndSendToUser(
            memberId.toString(),
            "/queue/notifications",
            new NotificationEvent("DISTRIBUTION",
                String.format("%.1fg %s wurde für dich ausgegeben", amount, strainName))
        );
    }
}

PWA manifest (public/manifest.json):

{
  "name": "CannaManage",
  "short_name": "CannaManage",
  "description": "Cannabis-Anbauvereinigung Management",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#0a0a0a",
  "theme_color": "#16a34a",
  "icons": [
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}

4. Phase Details

Phase 1: Production Deployment (Hetzner VPS)

Effort: 3 days

Step Task Files
1.1 Provision Hetzner VPS (CX31), install Docker + Docker Compose + Nginx + Certbot Server setup (manual)
1.2 Register domain, configure DNS A/AAAA records DNS config (manual)
1.3 Create docker-compose.prod.yml with env vars, volumes, restart policies docker-compose.prod.yml
1.4 Create .env.prod.example with all required env vars documented .env.prod.example
1.5 Configure Nginx reverse proxy (TLS, HTTP/2, WebSocket, security headers, rate limiting) Server: /etc/nginx/sites-available/cannamanage.de
1.6 Set up Let's Encrypt auto-renewal (certbot timer) Server cron
1.7 Create backup script + cron job (daily pg_dump, 7-day rotation) scripts/backup.sh
1.8 Set up GitHub Actions CI/CD (build → push GHCR → SSH deploy) .github/workflows/deploy.yml
1.9 Install Uptime Kuma for health monitoring + alerting Docker container on VPS
1.10 Add Spring Actuator production config (metrics, health, no info exposure) application-prod.properties
1.11 Create application-prod.properties with production-safe defaults cannamanage-api/src/main/resources/application-prod.properties

Acceptance Criteria:

  • https://cannamanage.de serves the frontend over HTTPS (A+ SSL Labs rating)
  • Backend API reachable at https://cannamanage.de/api/v1/actuator/health
  • docker compose -f docker-compose.prod.yml up -d starts all services cleanly
  • Push to main triggers automatic deployment via GitHub Actions
  • Daily backup cron creates compressed pg_dump, old backups rotated
  • Uptime Kuma shows green status, alerts on downtime within 60s
  • Security headers present (HSTS, X-Frame-Options, CSP basic)
  • Rate limiting active (>30 req/s returns 429)

Effort: 3 days

Step Task Files
2.1 Create Flyway migration V6__consent_and_audit.sql (consent table + audit_event table) db/migration/V6__consent_and_audit.sql
2.2 Create Consent entity + ConsentType enum + ConsentRepository cannamanage-domain/
2.3 Create ConsentService (accept, revoke, check, list) cannamanage-service/
2.4 Create ConsentController REST endpoints cannamanage-api/
2.5 Create DsgvoService (data export ZIP, data erasure with anonymization) cannamanage-service/
2.6 Create DsgvoController (GET /dsgvo/export, DELETE /dsgvo/erasure) cannamanage-api/
2.7 Frontend: Consent modal/banner (shown on first login if not accepted) cannamanage-frontend/
2.8 Frontend: /settings/privacy page (revoke consent, download data, request deletion) cannamanage-frontend/
2.9 Create Datenschutzerklärung (Privacy Policy) static page cannamanage-frontend/
2.10 Create AGB (Terms of Service) static page cannamanage-frontend/
2.11 Integration test: consent flow (accept → access granted, revoke → access blocked) Tests

Acceptance Criteria:

  • First login shows consent modal — cannot proceed without accepting
  • "Ablehnen" logs out the user immediately
  • Consent entity stores IP address, user agent, timestamp, version
  • Consent audit log entry created on accept/revoke
  • GET /api/v1/dsgvo/export returns ZIP with all user data (JSON format)
  • DELETE /api/v1/dsgvo/erasure anonymizes user data, retains aggregates for legal compliance
  • Privacy policy and ToS pages accessible without authentication
  • Revoking consent blocks further app access until re-accepted
  • Consent version tracked — new policy version re-prompts acceptance

Phase 3: Stripe Integration (SEPA + PayPal + Card)

Effort: 4 days

Step Task Files
3.1 Add Stripe Java SDK dependency to pom.xml cannamanage-service/pom.xml
3.2 Create StripeService (customer creation, checkout session, billing portal, webhook handling) cannamanage-service/
3.3 Create SubscriptionController (create checkout, get status, open portal) cannamanage-api/
3.4 Create StripeWebhookController (signature verification + event dispatch) cannamanage-api/
3.5 Flyway migration: add Stripe columns to club table db/migration/V7__stripe_columns.sql
3.6 Create Stripe Customer on club creation (onboarding hook) ClubService.java modification
3.7 Implement subscription status enforcement (middleware: block API if subscription expired) SubscriptionFilter.java
3.8 Frontend: /settings/billing page (plan display, payment method, invoices) cannamanage-frontend/
3.9 Frontend: pricing cards component (Starter vs Pro comparison) cannamanage-frontend/
3.10 Frontend: checkout redirect + success/cancel handling cannamanage-frontend/
3.11 Configure Stripe webhook endpoint in Stripe Dashboard (test + live modes) Manual (Stripe Dashboard)
3.12 Test mode E2E: create subscription, trigger webhook, verify access Integration test

Acceptance Criteria:

  • Club owner can subscribe via SEPA Lastschrift (direct debit)
  • Club owner can subscribe via PayPal or Card as alternatives
  • 14-day free trial starts automatically for new clubs
  • After trial expiry without payment → club access locked with "Please subscribe" message
  • invoice.paid webhook extends access, stores payment in audit log
  • invoice.payment_failed webhook shows warning banner to club owner
  • customer.subscription.deleted webhook locks club access
  • Billing portal link opens Stripe-hosted management (update card, cancel, invoices)
  • /settings/billing shows current plan, next payment date, payment method
  • Webhook endpoint validates Stripe signature (rejects spoofed events)
  • All payment events logged in audit log

Phase 4: Immutable Audit Log

Effort: 2 days

Step Task Files
4.1 Create AuditEvent entity (with @Immutable) + AuditEventType enum cannamanage-domain/
4.2 Create AuditEventRepository (read-only, no delete methods) cannamanage-service/
4.3 Create AuditService (log method, used by all other services) cannamanage-service/
4.4 Integrate AuditService into existing services (Distribution, Member, Batch, Stock, Auth, Staff) Multiple service files
4.5 Create AuditController (GET paginated, filter by type/date/actor) cannamanage-api/
4.6 Create audit PDF export endpoint (for Behörde inspections) cannamanage-api/
4.7 Frontend: /admin/audit-log page (read-only table, filterable, searchable) cannamanage-frontend/
4.8 Frontend: PDF export button on audit log page cannamanage-frontend/
4.9 Ensure timestamps are server-generated (Europe/Berlin) and audit records cannot be modified Entity + DB constraints
4.10 Unit tests for AuditService, integration test for immutability Tests

Acceptance Criteria:

  • Every distribution, member change, batch action, login, and staff change creates an audit entry
  • Audit entries are immutable (no UPDATE/DELETE — enforced by Hibernate @Immutable + DB trigger)
  • /admin/audit-log page shows chronological event list with filters (type, date range, actor)
  • PDF export generates a printable report for Behörde inspection
  • Timestamps are server-generated in Europe/Berlin timezone (not client-submitted)
  • Audit log includes DSGVO events (consent accept/revoke, data export, erasure)
  • Audit log includes payment events (subscription created, payment received/failed)
  • Search works across description field (full-text)
  • Pagination works (default 50 per page)

Phase 5: Grow Calendar (FULL)

Effort: 56 days

Step Task Files
5.1 Flyway migration: V8__grow_calendar.sql (grow_entry + sensor_reading + photo_log + feeding_schedule tables) db/migration/V8__grow_calendar.sql
5.2 Create GrowEntry entity + GrowStage enum cannamanage-domain/
5.3 Create SensorReading entity (temp, humidity, timestamp, grow_entry_id) cannamanage-domain/
5.4 Create GrowPhoto entity (url, caption, timestamp, grow_entry_id) cannamanage-domain/
5.5 Create FeedingSchedule entity (nutrient, amount, frequency, grow_entry_id) cannamanage-domain/
5.6 Create repositories for all grow entities cannamanage-service/
5.7 Create GrowCalendarService (CRUD, stage transitions, batch linking) cannamanage-service/
5.8 Create SensorService (record readings, aggregation, alerts on threshold breach) cannamanage-service/
5.9 Create GrowCalendarController (GET/POST/PUT/DELETE + sensor + photo + feeding endpoints) cannamanage-api/
5.10 Frontend: /grow page with FullCalendar (month view, color-coded stages) cannamanage-frontend/
5.11 Frontend: create/edit grow entry dialog (strain, stage, dates, plant count, notes) cannamanage-frontend/
5.12 Frontend: harvest → batch linking dialog (connect grow entry to existing batch) cannamanage-frontend/
5.13 Frontend: grow entry detail panel (show full lifecycle of a grow) cannamanage-frontend/
5.14 Frontend: sensor dashboard (temp/humidity charts, last 7d/30d, threshold alerts) cannamanage-frontend/
5.15 Frontend: photo log gallery (upload, timeline view, captions) cannamanage-frontend/
5.16 Frontend: feeding schedule editor (add nutrients, amounts, frequency, calendar overlay) cannamanage-frontend/
5.17 Unit + integration tests for grow services Tests

Acceptance Criteria:

  • FullCalendar renders grow entries as color-coded blocks (one color per stage)
  • Click date → create new grow entry (select strain, stage, plant count)
  • Drag to resize → adjusts end date
  • Click entry → opens detail panel with edit/delete options
  • Stage progression: Seedling → Vegetative → Flowering → Harvest → Drying → Curing
  • Harvest stage allows linking to an existing batch (traceability: grow → batch → distribution)
  • Calendar shows de locale (German month names, Monday start)
  • Entries filtered by club (multi-tenant)
  • Audit log entry for create/update/link operations
  • Sensors: Record temp/humidity readings (manual entry or API endpoint for IoT devices)
  • Sensors: Chart visualization (line chart, last 7d/30d toggle)
  • Sensors: Configurable threshold alerts (e.g., temp > 30°C → notification)
  • Photos: Upload photos per grow entry (max 10MB, JPEG/PNG)
  • Photos: Gallery view with timeline and captions
  • Feeding: Define feeding schedule (nutrient name, amount in ml/g, frequency)
  • Feeding: Calendar overlay showing feeding days

Phase 6: Notifications + PWA

Effort: 3 days

Step Task Files
6.1 Add Spring WebSocket + STOMP dependencies cannamanage-api/pom.xml
6.2 Create WebSocketConfig (STOMP broker, SockJS fallback) cannamanage-api/
6.3 Create NotificationService (quota warning, batch recall, distribution, payment) cannamanage-service/
6.4 Integrate notifications into existing services (trigger on events) Multiple services
6.5 Create NotificationController (GET unread count, mark as read) cannamanage-api/
6.6 Frontend: WebSocket connection hook (SockJS client, auto-reconnect) cannamanage-frontend/
6.7 Frontend: notification bell in header (unread badge count) cannamanage-frontend/
6.8 Frontend: notification dropdown/panel (list of recent notifications) cannamanage-frontend/
6.9 PWA: create manifest.json + icons (192px, 512px) cannamanage-frontend/public/
6.10 PWA: create service worker (offline page, asset caching) cannamanage-frontend/public/sw.js
6.11 PWA: install prompt (iOS/Android home-screen) cannamanage-frontend/
6.12 Web Push: VAPID key generation, push subscription endpoint, opt-in toggle Backend + Frontend

Acceptance Criteria:

  • Real-time notification when quota reaches 80% (appears in bell without page refresh)
  • Real-time notification on batch recall (all affected members notified)
  • Real-time notification on new distribution (member portal bell)
  • Notification bell shows unread count badge
  • Click bell → dropdown with notification list (mark as read)
  • PWA installable on mobile (home screen icon, standalone mode)
  • Offline page shown when network unavailable (instead of browser error)
  • Web Push notifications (opt-in) delivered even when app is closed
  • WebSocket reconnects automatically after network drop
  • Notification events logged in audit log

Phase 7: Launch Checklist + Documentation

Effort: 3 days

Step Task Files
7.1 Security scan (Snyk Code + SCA + SonarQube) — fix any Critical/High findings Scan results
7.2 Performance test (k6 or artillery) — target: 100 concurrent users, <500ms p95 scripts/load-test.js
7.3 Update wiki with Sprint 6 documentation Wiki
7.4 Create landing page (/) with product description, features, screenshots cannamanage-frontend/
7.5 Create pricing page (/pricing) with tier comparison + CTA buttons cannamanage-frontend/
7.6 Create Datenschutzerklärung legal page (DSGVO compliant) cannamanage-frontend/
7.7 Create Impressum legal page (required by German law) cannamanage-frontend/
7.8 Create AGB (Terms of Service) legal page cannamanage-frontend/
7.9 Create robots.txt + sitemap.xml for SEO cannamanage-frontend/public/
7.10 Final smoke test on production (full user journey: register → subscribe → distribute → audit) Manual
7.11 Set up error tracking (Sentry or similar — optional, can use Uptime Kuma logs) Config

Acceptance Criteria:

  • Zero Critical/High security findings (Snyk + SonarQube clean)
  • p95 latency < 500ms under 100 concurrent users
  • Landing page renders with product description, screenshots, and CTA
  • Pricing page shows Free Trial / Starter / Pro with feature comparison
  • All legal pages present: Datenschutzerklärung, Impressum, AGB
  • Full user journey works on production: signup → consent → subscribe → add member → distribute → audit log
  • Wiki updated with Sprint 6 architecture and deployment docs
  • robots.txt + sitemap.xml present for search engine indexing
  • HTTPS everywhere (no mixed content)

5. Risk Assessment

# Risk Probability Impact Mitigation
R1 Stripe integration complexity (SEPA mandates, webhook reliability) Medium High Start with test mode, use Stripe CLI for local webhook testing, extensive logging
R2 DSGVO compliance gaps (incomplete data export, retention conflicts) Medium High Legal review of Datenschutzerklärung before launch, document what data is retained and why
R3 Production deployment issues (SSL, DNS propagation, Docker networking) Low Medium Test with staging subdomain first (staging.cannamanage.de), blue-green deploy
R4 WebSocket scalability on single VPS Low Low Hetzner CX31 handles 1000+ concurrent WS connections easily for MVP; scale later if needed
R5 Stripe webhook delivery failures (lost events) Low High Implement idempotent webhook handler + periodic reconciliation job (check subscription status every 6h)
R6 Backup corruption / restore failure Low Critical Test restore procedure monthly, verify backup integrity with checksum
R7 FullCalendar bundle size (large JS library) Low Low Dynamic import, lazy-load only on /grow route
R8 German legal requirements (Impressum, Datenschutz) Medium High Consult template generators (e.g., e-recht24.de), have Patrick review before launch

6. Database Migrations Summary

Migration Phase Description
V6__consent_and_audit.sql 2 + 4 consent table + audit_event table
V7__stripe_columns.sql 3 Add stripe_customer_id, subscription_status, subscription_plan, trial_ends_at to club
V8__grow_calendar.sql 5 grow_entry table with strain/stage/dates/batch FK
V9__notifications.sql 6 notification table (persisted notifications for history)

7. New Dependencies

Backend (Maven)

Dependency Version Purpose Phase
com.stripe:stripe-java 26.x Stripe API SDK 3
org.springframework.boot:spring-boot-starter-websocket (managed) WebSocket + STOMP 6

Frontend (npm)

Dependency Version Purpose Phase
@stripe/stripe-js ^4.x Stripe.js for Payment Element 3
@fullcalendar/react ^6.x Calendar component 5
@fullcalendar/daygrid ^6.x Month/week grid view 5
@fullcalendar/interaction ^6.x Drag/drop, click events 5
@stomp/stompjs ^7.x STOMP over WebSocket 6
sockjs-client ^1.x SockJS fallback transport 6
next-pwa ^5.x PWA plugin for Next.js (or @ducanh2912/next-pwa) 6
web-push ^3.x VAPID push (if server-side push needed) 6

8. Open Questions (for Patrick's return)

# Question Answer Status
Q1 Domain? Subdomain on existing plate-software.de (e.g., cannamanage.plate-software.de) — IONOS DNS Resolved
Q2 Server? IONOS existing VPS (8 GB RAM, ~2 cores) — already running, deploy alongside existing services Resolved
Q3 Stripe account? Fresh Stripe account for CannaManage (test mode first, then live) Resolved
Q4 Pricing model? 3-month free trial → Tiered: Starter (≤30 members, €19/mo), Pro (≤100 members, €49/mo), Enterprise (custom) Resolved
Q5 CI/CD? Gitea Actions (self-hosted on plate-software.de) Resolved
Q6 Backup strategy? 7 days daily + 4 weeks weekly retention Resolved
Q7 Grow calendar scope? FULL — sensors (temp/humidity), photo log, feeding schedule, not just date tracking Resolved
Q8 PWA scope? Basic in Sprint 6 — manifest + offline page + install prompt Resolved

9. Sprint 5 Backlog Items Addressed

Sprint 5 Backlog Item Sprint 6 Phase Status
Notification System (email + in-app + push) Phase 6 Planned
PWA Manifest + Service Worker Phase 6 Planned
Club Settings UI Deferred (Sprint 7)
Inspector Read-Only Mode Deferred (Sprint 7)
Monthly Report Auto-Sealing Deferred (Sprint 7)
Cryptographic Hash Chain Deferred (Sprint 7)
2FA (TOTP) Deferred (Sprint 7)
Micro-interactions (Framer Motion) Deferred (Sprint 7)

10. Success Criteria (Sprint 6 Complete)

The sprint is complete when:

  1. Production livecannamanage.plate-software.de serves the full app over HTTPS with automated Gitea Actions CI/CD
  2. DSGVO compliant — consent management, data export, data deletion all functional
  3. Payments working — clubs can subscribe via SEPA/PayPal/Card, billing portal accessible
  4. Audit trail — every significant action logged immutably, exportable as PDF
  5. Grow tracking — full grow calendar with stage management, batch linking, sensor data, photo log, and feeding schedules
  6. Real-time notifications — WebSocket bell icon + PWA installable
  7. Launch-ready — legal pages present, security scanned, performance tested

References