Files
cannamanage/docs/sprint-14/cannamanage-sprint14-plan.md
T
Patrick Plate dad798a904
Deploy to TrueNAS / deploy (push) Failing after 33s
feat: Sprint 14 — Marketing & Monetization
- Landing page with hero, feature grid, trust signals
- Split-layout login redesign (admin + portal)
- Pricing page with storage tiers (5GB/50GB/unlimited)
- StorageQuotaService backend (V36 migration, 402 on exceeded)
- Frontend storage integration + 402 error handling
- StorageController uses TenantContext for tenant isolation
- onTierChange() hook for subscription tier updates
2026-06-18 20:28:35 +02:00

24 KiB
Raw Blame History

Plan: Sprint 14 — Marketing & Monetization

Date: 2026-06-18 Author: Patrick Plate / Lumen (Planner) Status: v2 Basis: cannamanage-sprint14-analysis.md


Background

Sprint 14 transforms CannaManage from an internal tool into a market-ready SaaS product. It delivers four interconnected pieces: a converting landing page, premium-feeling login experiences, an updated pricing page with storage tiers, and the backend enforcement that makes storage quotas real. Together these enable the public launch.


Architecture

┌─────────────────────────────────────────────────────────────────────┐
│                     Frontend — Marketing Layer                        │
├─────────────────────────────────────────────────────────────────────┤
│                                                                       │
│  (marketing)/page.tsx ──► Landing page (hero, features, CTA)         │
│  (marketing)/pricing/page.tsx ──► Updated with storage tiers         │
│  (marketing)/layout.tsx ──► Updated nav (Features link)              │
│                                                                       │
├─────────────────────────────────────────────────────────────────────┤
│                     Frontend — Auth Layer                             │
├─────────────────────────────────────────────────────────────────────┤
│                                                                       │
│  (auth)/layout.tsx ──► Split layout (branding left, form right)      │
│  (auth)/login/page.tsx ──► Form-only (layout handles split)          │
│  (portal)/portal-login/page.tsx ──► Member-themed split login        │
│                                                                       │
├─────────────────────────────────────────────────────────────────────┤
│                     Backend — Storage Quota                           │
├─────────────────────────────────────────────────────────────────────┤
│                                                                       │
│  StorageQuotaService ──► getUsage(clubId), checkQuota(clubId, size)  │
│  StorageController ──► GET /api/v1/storage/usage                     │
│  DocumentService ──► Pre-upload quota check                          │
│  Club entity ──► storageUsedBytes column (incremental counter)       │
│  V36 migration ──► ALTER TABLE clubs ADD storage_used_bytes          │
│                                                                       │
└─────────────────────────────────────────────────────────────────────┘

Components

# Component Module Action
1 Landing page cannamanage-frontend New(marketing)/page.tsx
2 Auth layout (split) cannamanage-frontend Modify(auth)/layout.tsx
3 Admin login page cannamanage-frontend Modify — remove layout wrapper, form only
4 Portal login page cannamanage-frontend Modify — member-themed split variant
5 Pricing page cannamanage-frontend Modify — add storage tiers, update model
6 Marketing layout cannamanage-frontend Modify — add "Features" nav link
7 i18n messages (de) cannamanage-frontend Modify — add marketing.home, pricing.storage keys
8 i18n messages (en) cannamanage-frontend Modify — full English equivalents for marketing.home.* and marketing.pricing.storage.*
9 StorageQuotaService cannamanage-service New — quota logic
10 StorageController cannamanage-api New — REST endpoint
11 Club entity cannamanage-domain Modify — add storageUsedBytes
12 DocumentService cannamanage-service Modify — add quota check on upload
13 Flyway V36 cannamanage-api New — migration SQL
14 Frontend storage service cannamanage-frontend Newservices/storage.ts
15 StorageQuotaExceededException cannamanage-service New — 402 storage exception

Implementation Steps

Phase 1: Landing Page

Step 1.1 — Create (marketing)/page.tsx

File: cannamanage-frontend/src/app/(marketing)/page.tsx

A full landing page with these sections:

  • Hero — Headline ("Die smarte Verwaltung für deinen Anbauverein"), subheadline, primary CTA (→ /pricing), secondary CTA (→ /login)
  • Feature grid — 6 cards in a 2×3 / 3×2 responsive grid:
    1. Compliance Tracking (§22 KCanG documentation)
    2. Grow Management (calendar, stages, sensors)
    3. Member Portal (self-service, history, profile)
    4. Distribution Quotas (25g/day, 50g/month enforcement)
    5. Document Archive (GoBD-compliant, retention periods)
    6. Financial Management (fees, SEPA, bank import)
  • Trust signals — "Für Anbauvereine in Deutschland" badge, CanVerG compliance, DSGVO/GoBD, TLS encryption
  • Final CTA — "Jetzt kostenlos testen" → /pricing

Use lucide-react icons. All text via useTranslations("marketing.home"). Responsive: single column on mobile, grid on tablet+. Dark/light mode compatible.

Step 1.2 — Update marketing layout navigation

File: cannamanage-frontend/src/app/(marketing)/layout.tsx

  • Add "Features" link in header nav (scrolls to #features anchor on homepage, or links to /#features)
  • Internationalize hardcoded "Preise" and "Anmelden" strings via useTranslations
  • Add "Features" link to footer "Produkt" column

Step 1.3 — Add i18n keys for landing page (de + en)

Files:

  • cannamanage-frontend/messages/de.json
  • cannamanage-frontend/messages/en.json

Both locale files must receive entries for the marketing.home.* and marketing.pricing.storage.* namespaces. The German keys are the primary source; the English file must contain equivalent translations for all keys.

Add under marketing.home (German example):

{
  "marketing": {
    "home": {
      "heroTitle": "Die smarte Verwaltung für deinen Anbauverein",
      "heroSubtitle": "Compliance, Anbau, Mitglieder und Abgaben — alles in einer Plattform. Rechtssicher nach KCanG.",
      "ctaPrimary": "Preise ansehen",
      "ctaSecondary": "Jetzt anmelden",
      "featuresTitle": "Alles, was dein Verein braucht",
      "featuresSubtitle": "Von der Mitgliederverwaltung bis zur Behördenmeldung — CannaManage deckt den gesamten Vereinsbetrieb ab.",
      "feature1Title": "Compliance Tracking",
      "feature1Desc": "Automatische Überwachung der KCanG-Vorgaben mit Fristen und Checklisten.",
      "feature2Title": "Grow Management",
      "feature2Desc": "Anbaukalender, Wachstumsphasen und Sensorintegration.",
      "feature3Title": "Mitglieder-Portal",
      "feature3Desc": "Self-Service für Mitglieder: Profil, Abgabehistorie, Dokumente.",
      "feature4Title": "Abgabe-Quotas",
      "feature4Desc": "Automatische Einhaltung der 25g/Tag und 50g/Monat Grenzen.",
      "feature5Title": "Dokumenten-Archiv",
      "feature5Desc": "GoBD-konforme Ablage mit Aufbewahrungsfristen und Versionierung.",
      "feature6Title": "Finanzverwaltung",
      "feature6Desc": "Mitgliedsbeiträge, SEPA-Export und Bankimport.",
      "trustTitle": "Vertrauen durch Compliance",
      "trustCanverg": "CanVerG-konform",
      "trustDsgvo": "DSGVO & GoBD",
      "trustEncryption": "TLS-verschlüsselt",
      "trustGerman": "Hosting in Deutschland",
      "ctaFinalTitle": "Bereit für den nächsten Schritt?",
      "ctaFinalSubtitle": "Starte jetzt mit CannaManage und bring deinen Verein auf das nächste Level.",
      "ctaFinalButton": "Kostenlos testen"
    }
  }
}

Phase 2: Login Redesign

Step 2.1 — Redesign auth layout to split-layout

File: cannamanage-frontend/src/app/(auth)/layout.tsx

Replace the simple centered container with a split layout:

┌──────────────────────────────────────────────────┐
│  Left Panel (hidden <md)  │  Right Panel (form)  │
│                           │                      │
│  • App logo + name        │  {children}          │
│  • Tagline                │                      │
│  • 3 feature highlights   │                      │
│  • Background gradient    │                      │
│                           │                      │
└──────────────────────────────────────────────────┘
  • Left panel: hidden md:flex md:w-1/2 lg:w-[55%] — dark gradient background with primary color accent
  • Right panel: w-full md:w-1/2 lg:w-[45%] flex items-center justify-center p-8
  • Left panel content: CannaManage logo, "Dein Verein, digital verwaltet" tagline, 3 bullet points with icons (Compliance, Mitglieder, Abgaben)
  • Still wraps {children} in NextIntlClientProvider

Step 2.2 — Adjust admin login page

File: cannamanage-frontend/src/app/(auth)/login/page.tsx

  • Remove the logo/branding section (now in layout's left panel)
  • Keep only the form card itself: title "Anmelden", email, password, submit, forgot password, portal link
  • The page becomes purely the form — layout handles the split

Step 2.3 — Create portal login with member theming

File: cannamanage-frontend/src/app/(portal)/portal-login/page.tsx

Portal login gets its own split layout inline (since it's in a different route group):

  • Left panel: member-focused messaging — "Willkommen zurück", "Dein persönlicher Bereich", icons for Abgabehistorie/Profil/Dokumente
  • Right panel: login form (same structure as admin but portal-specific translations)
  • Visual differentiation: left panel uses a slightly different gradient (e.g., emerald/teal tint vs. primary green)
  • Full-page layout (no separate layout.tsx needed — inline the split)

Phase 3: Pricing Page Update

Step 3.1 — Update pricing data model

File: cannamanage-frontend/src/app/(marketing)/pricing/page.tsx

Update the plans array to include storage:

const plans = [
  {
    id: "starter",
    icon: Leaf,
    price: "19",
    memberLimit: "30",
    storage: "5",      // GB
    features: [
      "memberManagement",
      "distributionTracking",
      "complianceReports",
      "quotaMonitoring",
      "memberPortal",
      "emailSupport",
    ],
  },
  {
    id: "pro",
    icon: Cannabis,
    price: "49",
    memberLimit: "100",
    storage: "50",     // GB
    storageOverage: "0.15",  // €/GB/month
    popular: true,
    features: [
      "allStarter",
      "growCalendar",
      "staffManagement",
      "advancedReports",
      "pdfExport",
      "apiAccess",
      "prioritySupport",
    ],
  },
  {
    id: "enterprise",
    icon: Building2,
    price: null,
    memberLimit: "unlimited",
    storage: "custom",
    features: [
      "allPro",
      "unlimitedMembers",
      "multiClub",
      "customIntegrations",
      "sla",
      "dedicatedSupport",
      "onboarding",
    ],
  },
]

Step 3.2 — Render storage in plan cards

In each plan card, add a storage badge below the member limit:

  • Starter: "5 GB Speicher"
  • Pro: "50 GB Speicher" + small note "(danach 0,15 €/GB/Monat)"
  • Enterprise: "Individueller Speicher"

Step 3.3 — Add storage row to feature comparison

Below the plan cards, add a comparison table section:

Feature Starter Pro Enterprise
Mitglieder 30 100 Unbegrenzt
Speicher 5 GB 50 GB Individuell
Überschreitung Upgrade erforderlich 0,15 €/GB/Mo
Grow-Kalender
API-Zugang
Multi-Club

Step 3.4 — Add FAQ entry about storage

Add to the faqs array:

{ id: "storage" }  // "Was passiert wenn mein Speicherplatz voll ist?"

i18n keys:

  • marketing.pricing.faq.storage.question: "Was passiert, wenn mein Speicher voll ist?"
  • marketing.pricing.faq.storage.answer: "Im Starter-Plan kannst du auf Pro upgraden. Im Pro-Plan wird zusätzlicher Speicher mit 0,15 €/GB/Monat berechnet. Enterprise-Kunden haben individuelle Speichervereinbarungen."

Phase 4: Storage Quota Backend

Step 4.1 — Flyway migration V36

File: cannamanage-api/src/main/resources/db/migration/V36__storage_quota.sql

-- Add storage tracking to clubs
ALTER TABLE clubs ADD COLUMN IF NOT EXISTS storage_used_bytes BIGINT DEFAULT 0;
ALTER TABLE clubs ADD COLUMN IF NOT EXISTS storage_limit_bytes BIGINT DEFAULT 5368709120;
-- Default: 5 GB (5 * 1024^3) = Starter tier

-- Backfill existing clubs with actual usage
UPDATE clubs c SET storage_used_bytes = COALESCE(
    (SELECT SUM(d.file_size) FROM documents d WHERE d.club_id = c.id), 0
);

Step 4.2 — Update Club entity

File: cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Club.java

Add fields:

@Column(name = "storage_used_bytes", nullable = false)
private Long storageUsedBytes = 0L;

@Column(name = "storage_limit_bytes", nullable = false)
private Long storageLimitBytes = 5_368_709_120L; // 5 GB default

With getters/setters.

Step 4.3 — Create StorageQuotaService

File: cannamanage-service/src/main/java/de/cannamanage/service/StorageQuotaService.java

@Service
@Slf4j
public class StorageQuotaService {

    private final ClubRepository clubRepository;

    // Plan tier limits
    private static final long STARTER_LIMIT = 5L * 1024 * 1024 * 1024;   // 5 GB
    private static final long PRO_LIMIT = 50L * 1024 * 1024 * 1024;      // 50 GB
    private static final long ENTERPRISE_LIMIT = Long.MAX_VALUE;          // Unlimited

    public StorageUsageDTO getUsage(UUID clubId) {
        Club club = clubRepository.findById(clubId).orElseThrow();
        long used = club.getStorageUsedBytes();
        long limit = club.getStorageLimitBytes();
        double percentage = limit > 0 ? (double) used / limit * 100 : 0;
        return new StorageUsageDTO(used, limit, percentage);
    }

    public void checkQuota(UUID clubId, long additionalBytes) {
        Club club = clubRepository.findById(clubId).orElseThrow();
        long newTotal = club.getStorageUsedBytes() + additionalBytes;
        if (newTotal > club.getStorageLimitBytes()) {
            throw new StorageQuotaExceededException(club.getStorageUsedBytes(),
                club.getStorageLimitBytes(), additionalBytes);
        }
    }

    public void incrementUsage(UUID clubId, long bytes) {
        Club club = clubRepository.findById(clubId).orElseThrow();
        club.setStorageUsedBytes(club.getStorageUsedBytes() + bytes);
        clubRepository.save(club);
    }

    public void decrementUsage(UUID clubId, long bytes) {
        Club club = clubRepository.findById(clubId).orElseThrow();
        long newUsage = Math.max(0, club.getStorageUsedBytes() - bytes);
        club.setStorageUsedBytes(newUsage);
        clubRepository.save(club);
    }

    public static long getLimitForTier(PlanTier tier) {
        return switch (tier) {
            case TRIAL, STARTER -> STARTER_LIMIT;
            case PRO -> PRO_LIMIT;
            case ENTERPRISE -> ENTERPRISE_LIMIT;
        };
    }

    public boolean isNearLimit(UUID clubId, int thresholdPercent) {
        StorageUsageDTO usage = getUsage(clubId);
        return usage.percentage() >= thresholdPercent;
    }
}

Step 4.4 — Create StorageQuotaExceededException

File: cannamanage-service/src/main/java/de/cannamanage/service/exception/StorageQuotaExceededException.java

Note: The existing QuotaExceededException (at cannamanage-service/src/main/java/de/cannamanage/service/exception/QuotaExceededException.java) is reserved for CanG distribution quotas (25g/day, 50g/month) and takes a QuotaViolationCode. The storage exception needs its own class with an incompatible constructor signature.

public class StorageQuotaExceededException extends RuntimeException {
    private final long currentUsage;
    private final long limit;
    private final long requestedBytes;

    public StorageQuotaExceededException(long currentUsage, long limit, long requestedBytes) {
        super("Storage quota exceeded: current=%d, limit=%d, requested=%d"
            .formatted(currentUsage, limit, requestedBytes));
        this.currentUsage = currentUsage;
        this.limit = limit;
        this.requestedBytes = requestedBytes;
    }

    // Getters
}

Map in GlobalExceptionHandler to HTTP 402 Payment Required with RFC 9457 problem detail.

Step 4.5 — Create StorageController

File: cannamanage-api/src/main/java/de/cannamanage/api/controller/StorageController.java

@RestController
@RequestMapping("/api/v1/storage")
@PreAuthorize("hasAnyRole('ADMIN', 'STAFF')")
public class StorageController {

    private final StorageQuotaService storageQuotaService;

    @GetMapping("/usage")
    public ResponseEntity<StorageUsageDTO> getUsage(@AuthenticationPrincipal UserDetails user) {
        UUID clubId = extractClubId(user);
        return ResponseEntity.ok(storageQuotaService.getUsage(clubId));
    }

    /**
     * Extracts the clubId from the authenticated user's JWT claims.
     * The user's club association is stored as a "clubId" claim in the token,
     * set during authentication by AuthService. This follows the same pattern
     * used in DocumentController and other club-scoped controllers.
     */
    private UUID extractClubId(UserDetails user) {
        // Cast to our CustomUserDetails which carries the clubId from JWT
        var customUser = (CustomUserDetails) user;
        return customUser.getClubId();
    }
}

Response DTO:

public record StorageUsageDTO(long usedBytes, long limitBytes, double percentage) {}

Step 4.6 — Update DocumentService for quota check

File: cannamanage-service/src/main/java/de/cannamanage/service/DocumentService.java

In the upload method, add before file write:

// Check storage quota before upload
storageQuotaService.checkQuota(clubId, file.getSize());

// ... existing upload logic ...

// After successful save, increment usage counter
storageQuotaService.incrementUsage(clubId, file.getSize());

In the delete method, add after file removal:

// Decrement usage counter after successful delete
storageQuotaService.decrementUsage(clubId, document.getFileSize());

Step 4.7 — Handle 402 in GlobalExceptionHandler

File: cannamanage-api/src/main/java/de/cannamanage/api/exception/GlobalExceptionHandler.java

Add handler:

@ExceptionHandler(StorageQuotaExceededException.class)
public ResponseEntity<ProblemDetail> handleStorageQuotaExceeded(StorageQuotaExceededException ex) {
    ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.PAYMENT_REQUIRED);
    problem.setTitle("Storage Quota Exceeded");
    problem.setDetail("Upload would exceed storage limit. Current: " + ex.getCurrentUsage() 
        + " bytes, Limit: " + ex.getLimit() + " bytes, Requested: " + ex.getRequestedBytes() + " bytes");
    problem.setProperty("currentUsage", ex.getCurrentUsage());
    problem.setProperty("limit", ex.getLimit());
    problem.setProperty("requestedBytes", ex.getRequestedBytes());
    return ResponseEntity.status(HttpStatus.PAYMENT_REQUIRED).body(problem);
}

Step 4.8 — Add SecurityConfig matcher for storage endpoint

File: cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java

Add:

.requestMatchers(HttpMethod.GET, "/api/v1/storage/**").hasAnyRole("ADMIN", "STAFF")

Step 4.9 — Subscription tier change hook (storage_limit_bytes sync)

File: cannamanage-service/src/main/java/de/cannamanage/service/SubscriptionService.java

When a club's subscription tier changes (e.g., Starter→Pro), storage_limit_bytes must be updated to match the new tier. Add an onTierChange() hook:

/**
 * Called when a club upgrades/downgrades their subscription tier.
 * Updates the storage_limit_bytes to match the new tier's allocation.
 */
public void onTierChange(UUID clubId, PlanTier newTier) {
    Club club = clubRepository.findById(clubId).orElseThrow();
    long newLimit = StorageQuotaService.getLimitForTier(newTier);
    club.setStorageLimitBytes(newLimit);
    clubRepository.save(club);
    log.info("Club {} storage limit updated to {} bytes (tier: {})", clubId, newLimit, newTier);
}

Note: This is a minimal hook. Full subscription management (Stripe webhooks, billing cycle, trial-to-paid) is deferred to a future sprint. For now, this method is called from the admin panel when manually changing a club's tier.


Phase 5: Frontend Storage Integration

Step 5.1 — Create storage service

File: cannamanage-frontend/src/services/storage.ts

export interface StorageUsage {
  usedBytes: number
  limitBytes: number
  percentage: number
}

export async function getStorageUsage(): Promise<StorageUsage> {
  const response = await fetch('/api/v1/storage/usage', { ... })
  return response.json()
}

export function formatBytes(bytes: number): string {
  if (bytes === 0) return '0 B'
  const k = 1024
  const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
  const i = Math.floor(Math.log(bytes) / Math.log(k))
  return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}

Step 5.2 — Handle 402 in document upload UI

File: cannamanage-frontend/src/services/documents.ts

In the upload handler, catch 402 responses and display a quota-exceeded toast/dialog with upgrade CTA pointing to /pricing.


Open Questions

  • Should storage overage (Pro tier, €0.15/GB/mo) auto-allow uploads beyond limit, or still block?
    • Recommendation: For now, block at limit for all tiers. Overage billing is a future sprint (requires Stripe integration).
  • Email notifications at 80%/95% — implement the hook in this sprint or defer?
    • Recommendation: Implement the detection (isNearLimit), log a warning, defer actual email sending.

Acceptance Criteria

  1. Landing page renders at / with hero, 6 features, trust signals, and CTA
  2. Landing page is responsive (mobile/tablet/desktop) and supports dark/light mode
  3. Admin login at /login shows split layout on desktop, full-width form on mobile
  4. Portal login at /portal-login shows member-themed split layout
  5. Pricing page shows storage limits per tier and comparison table with storage row
  6. Pricing page has FAQ entry explaining storage limits
  7. GET /api/v1/storage/usage returns { usedBytes, limitBytes, percentage } for authenticated users
  8. Document upload is rejected with HTTP 402 when quota would be exceeded
  9. Document deletion decrements the storage counter
  10. Existing clubs have their storage_used_bytes backfilled on migration