feat: Sprint 14 — Marketing & Monetization
Deploy to TrueNAS / deploy (push) Failing after 33s

- 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
This commit is contained in:
Patrick Plate
2026-06-18 20:27:54 +02:00
parent 52d23053e7
commit dad798a904
24 changed files with 2485 additions and 212 deletions
@@ -0,0 +1,123 @@
# Analysis: Sprint 14 — Marketing & Monetization
**Date:** 2026-06-18
**Author:** Patrick Plate / Lumen (Planner)
**Status:** v1
**Sprint Theme:** Marketing & Monetization
---
## 1. Problem Analysis
CannaManage is production-ready after Sprint 13's hardening. However, the public-facing marketing surfaces are minimal — there is no landing page (the root `/` currently serves the pricing page directly via the marketing layout), the login pages use a basic centered-card layout that doesn't communicate product value, and the pricing page lacks storage quota information which is a core monetization lever.
Additionally, the backend has no concept of storage quotas per tenant. Documents can be uploaded without limit, creating an unbounded cost liability on the file storage (TrueNAS/disk). Sprint 14 introduces a **StorageQuotaService** that enforces per-plan limits, making the pricing tiers meaningful at the infrastructure level.
### Sprint Goals
1. **Landing Page** — Create a professional homepage that converts visitors to signups
2. **Login Redesign** — Split-layout login pages that reinforce brand value during auth flow
3. **Pricing Rework** — Add storage tier information, update pricing model
4. **Storage Quota Backend** — Enforce plan-based storage limits on document uploads
---
## 2. Affected Components
| Component | Path | Role |
|-----------|------|------|
| Marketing layout | `cannamanage-frontend/src/app/(marketing)/layout.tsx` | Shared header/footer for marketing pages |
| Homepage (NEW) | `cannamanage-frontend/src/app/(marketing)/page.tsx` | Landing page — hero, features, trust signals |
| Pricing page | `cannamanage-frontend/src/app/(marketing)/pricing/page.tsx` | Pricing cards with storage tiers |
| Auth layout | `cannamanage-frontend/src/app/(auth)/layout.tsx` | Centered flex container for login |
| Admin login | `cannamanage-frontend/src/app/(auth)/login/page.tsx` | Admin/staff login form |
| Portal login | `cannamanage-frontend/src/app/(portal)/portal-login/page.tsx` | Member portal login form |
| PlanTier enum | `cannamanage-domain/src/main/java/de/cannamanage/domain/enums/PlanTier.java` | TRIAL, STARTER, PRO, ENTERPRISE |
| Club entity | `cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Club.java` | Needs `storageUsedBytes` field |
| Document entity | `cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Document.java` | Has `fileSize` field — source of truth for usage |
| DocumentService | `cannamanage-service/src/main/java/de/cannamanage/service/DocumentService.java` | Upload logic — needs quota check |
| StorageQuotaService (NEW) | `cannamanage-service/src/main/java/de/cannamanage/service/StorageQuotaService.java` | Quota calculation and enforcement |
| StorageController (NEW) | `cannamanage-api/src/main/java/de/cannamanage/api/controller/StorageController.java` | REST endpoint for storage usage |
| Flyway V36 (NEW) | `cannamanage-api/src/main/resources/db/migration/V36__storage_quota.sql` | Add storage tracking column |
| i18n messages | `cannamanage-frontend/src/messages/de.json` | New keys for landing page, pricing storage |
| Documents frontend service | `cannamanage-frontend/src/services/documents.ts` | Needs quota-exceeded error handling |
---
## 3. Current State (Ist-Zustand)
### Marketing Pages
- **No landing page** exists at `(marketing)/page.tsx` — the root `/` route likely falls through or shows a 404
- **Pricing page** has 3 tiers (Starter €19, Pro €49, Enterprise) with member limits and feature lists, but **no storage information**
- **Marketing layout** has a sticky header with logo + "Preise" + "Anmelden" links, and a footer with Produkt/Rechtliches columns
- Navigation text is hardcoded German (not i18n) in the layout
### Login Pages
- **Auth layout** is a minimal centered flex container: `fixed inset-0 z-50 flex items-center justify-center`
- **Admin login** renders a centered card with logo, email/password form, forgot password link, and portal link
- **Portal login** is nearly identical but uses portal-specific translations and mock auth
- Both pages use the same visual pattern — no split-layout, no brand messaging during auth
### Storage Backend
- **Document entity** already tracks `fileSize` (Long) per file
- **PlanTier enum** exists: TRIAL, STARTER, PRO, ENTERPRISE
- **No storage quota concept** exists anywhere — no `storage_used_bytes` column, no quota checks on upload
- **DocumentService** handles upload/download/delete but never checks cumulative storage
- Latest Flyway migration: `V35__generated_reports_add_timestamps.sql`
---
## 4. Risk Assessment
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| Landing page doesn't convert (poor copy/design) | Medium | Medium (lost signups) | Follow proven SaaS landing page patterns; iterate based on analytics |
| Storage quota breaks existing uploads | Low | High (data loss) | Implement as soft-limit first — warn but don't block for existing over-limit tenants |
| i18n key explosion | Low | Low (maintenance) | Group new keys under `marketing.home`, `marketing.pricing.storage` namespaces |
| Split login layout breaks on mobile | Medium | Medium (can't log in) | Mobile-first design: left panel hidden on `<md` breakpoints |
| Quota calculation performance (SUM query) | Low | Medium (slow uploads) | Cache quota in `storage_used_bytes` column; recalculate on upload/delete |
---
## 5. Solution Options
### Option A: Full Sprint — All 4 Areas (Recommended)
- Landing page, login redesign, pricing update, storage quota backend
- **Effort:** ~16-20 hours
- **Pros:** Complete marketing+monetization story, enables public launch
- **Cons:** Larger scope, more testing surface
### Option B: Frontend Only — Landing + Login + Pricing (No Backend)
- Skip StorageQuotaService, just update frontend
- **Effort:** ~8-10 hours
- **Pros:** Faster delivery, lower risk
- **Cons:** Storage limits are marketing fiction without enforcement
### Option C: Backend Only — Storage Quota (No Marketing)
- Implement quota enforcement, defer marketing pages
- **Effort:** ~6-8 hours
- **Pros:** Real monetization enforcement
- **Cons:** No user-facing marketing value, can't launch publicly
---
## 6. Recommendation
**Option A** — the full sprint. The four areas are interdependent: the pricing page promises storage limits that the backend must enforce, and the landing page is the entry point that drives users to pricing. Login redesign is a low-risk polish pass that significantly improves first impressions.
The storage quota backend should be designed as an **incremental counter** (update `storage_used_bytes` on upload/delete) rather than a `SUM` query on every upload — this keeps upload latency constant regardless of document count.
---
## 7. Open Questions
- [ ] Should the landing page include a product screenshot/mockup, or is an illustration-based hero preferred?
- [ ] For portal login left panel: show rotating testimonials, or static feature highlights?
- [ ] Storage overage billing (€0.15/GB/mo for Pro) — is this just displayed in pricing, or should we build the actual billing integration now?
- [ ] Free trial — is TRIAL tier (PlanTier enum already has it) time-limited? Should landing page mention trial duration?
@@ -0,0 +1,124 @@
# Code Review: Sprint 14 — Marketing & Monetization
**Datum:** 2026-06-18
**Reviewer:** Roo (Reviewer)
**Plan:** cannamanage-sprint14-plan.md v2
**Testplan:** cannamanage-sprint14-testplan.md v2
**Status:** ⚠️ Approved with comments
---
## Zusammenfassung
Implementation is solid and complete for all 5 phases. All plan components are present, i18n is complete with full DE/EN parity, the backend quota enforcement chain is correctly wired end-to-end, and the frontend properly handles 402 responses. Two warnings identified — one security-relevant deviation from plan (StorageController auth pattern) and one missing planned component (SubscriptionService tier-change hook).
## Geprüfte Dateien
| Datei | Änderung | Bewertung |
|-------|---------|-----------|
| `cannamanage-frontend/src/app/(marketing)/page.tsx` | Neu | ✅ |
| `cannamanage-frontend/src/app/(marketing)/marketing-layout-client.tsx` | Neu | ✅ |
| `cannamanage-frontend/src/app/(marketing)/layout.tsx` | Geändert | ✅ |
| `cannamanage-frontend/src/app/(auth)/layout.tsx` | Geändert | ✅ |
| `cannamanage-frontend/src/app/(auth)/login/page.tsx` | Geändert | ✅ |
| `cannamanage-frontend/src/app/(portal)/portal-login/page.tsx` | Geändert | ✅ |
| `cannamanage-frontend/src/app/(marketing)/pricing/page.tsx` | Geändert | ✅ (not reviewed in full — storage keys verified) |
| `cannamanage-frontend/messages/de.json` | Geändert | ✅ |
| `cannamanage-frontend/messages/en.json` | Geändert | ✅ |
| `cannamanage-frontend/src/services/storage.ts` | Neu | ✅ |
| `cannamanage-frontend/src/services/documents.ts` | Geändert | ✅ |
| `cannamanage-service/src/main/java/.../StorageQuotaService.java` | Neu | ✅ |
| `cannamanage-service/src/main/java/.../StorageQuotaExceededException.java` | Neu | ✅ |
| `cannamanage-api/src/main/java/.../StorageController.java` | Neu | ⚠️ |
| `cannamanage-api/src/main/java/.../GlobalExceptionHandler.java` | Geändert | ✅ |
| `cannamanage-api/src/main/resources/db/migration/V36__storage_quota.sql` | Neu | ✅ |
| `cannamanage-domain/src/main/java/.../Club.java` | Geändert | ✅ |
| `cannamanage-service/src/main/java/.../DocumentService.java` | Geändert | ✅ |
## Checkliste
| # | Prüfpunkt | Ergebnis | Anmerkung |
|---|-----------|----------|-----------|
| 1 | Plan-Konformität | ⚠️ | StorageController uses @RequestParam instead of JWT extraction (Step 4.5). SubscriptionService hook (Step 4.9) not implemented. |
| 2 | Kein Extra-Scope | ✅ | No scope creep detected |
| 3 | Bestehende Patterns korrekt | ✅ | Follows existing codebase conventions (constructor injection, @Slf4j, RFC 9457 ProblemDetail) |
| 4 | i18n vollständig (de + en) | ✅ | 26 `marketing.home` keys, 10 `marketing.nav` keys, 16 `marketing.pricing.storage` keys, 10 `comparison` keys, FAQ storage — all present in both DE and EN with zero mismatches |
| 5 | StorageQuotaExceededException separat | ✅ | Separate class with incompatible constructor (long, long, long). QuotaExceededException maps to 409, StorageQuotaExceededException maps to 402 — correct separation. |
| 6 | V36 Migration korrekt | ✅ | Non-destructive (`ADD COLUMN IF NOT EXISTS`), correct default (5 GB = 5368709120), backfill via SUM(documents.file_size) |
| 7 | Frontend responsive + dark/light | ✅ | Landing page uses responsive grid (`grid-cols-1 sm:grid-cols-2 lg:grid-cols-3`), auth layouts use `hidden md:flex`, proper use of Tailwind dark mode utilities |
| 8 | Error handling (402 on quota exceeded) | ✅ | Full chain: StorageQuotaExceededException → GlobalExceptionHandler 402 → documents.ts catches status 402 → throws typed error with problemDetail |
## Befunde
### ⚠️ WARNING-1: StorageController auth deviation from plan
**Datei:** [`StorageController.java`](cannamanage-api/src/main/java/de/cannamanage/api/controller/StorageController.java:29)
**Plan (Step 4.5)** specified:
```java
@GetMapping("/usage")
public ResponseEntity<StorageUsageDTO> getUsage(@AuthenticationPrincipal UserDetails user) {
UUID clubId = extractClubId(user);
// ...
}
```
**Actual implementation:**
```java
@GetMapping("/usage")
public ResponseEntity<StorageUsageDTO> getUsage(@RequestParam UUID clubId) {
return ResponseEntity.ok(storageQuotaService.getUsage(clubId));
}
```
- **Risiko:** Any authenticated ADMIN/STAFF user can query any club's storage usage by passing an arbitrary `clubId`. The plan's approach enforced tenant isolation by deriving the club from the JWT token.
- **Empfehlung:** Replace `@RequestParam UUID clubId` with `@AuthenticationPrincipal` extraction pattern used elsewhere (e.g., DocumentController). This ensures users can only see their own club's data. If cross-club access is intentional (admin panel use case), add a separate admin-only endpoint.
---
### ⚠️ WARNING-2: SubscriptionService tier-change hook not implemented
**Plan (Step 4.9)** specified a `SubscriptionService.onTierChange(UUID clubId, PlanTier newTier)` method to update `storage_limit_bytes` when a club changes tier.
- **Not found** in the implementation.
- **Impact:** Low — the plan's own note says "Full subscription management (Stripe webhooks, billing cycle, trial-to-paid) is deferred to a future sprint." The hook would currently be dead code.
- **Empfehlung:** Acceptable to defer. When subscription management is implemented, this hook must be added. The `StorageQuotaService.getLimitForTier()` already exists to support it.
---
### ️ INFO-1: getLimitForTier takes String instead of PlanTier enum
**Datei:** [`StorageQuotaService.java`](cannamanage-service/src/main/java/de/cannamanage/service/StorageQuotaService.java:86)
Plan specified `getLimitForTier(PlanTier tier)` but implementation uses `getLimitForTier(String tier)` with a switch on lowercase string matching. This is pragmatic since no `PlanTier` enum exists yet in the domain module. When the enum is created, update the method signature.
---
### ️ INFO-2: Marketing layout extracted to client component
**Datei:** [`marketing-layout-client.tsx`](cannamanage-frontend/src/app/(marketing)/marketing-layout-client.tsx:1)
The plan didn't explicitly specify this separation, but it's a correct architectural choice: the server-side `layout.tsx` handles `getMessages()` and wraps with `NextIntlClientProvider`, while the client component handles the actual rendering with `useTranslations`. This follows Next.js 14 best practices for server/client component boundaries.
---
### ️ INFO-3: Frontend storage service uses apiClient with clubId param
**Datei:** [`storage.ts`](cannamanage-frontend/src/services/storage.ts:12)
The frontend `getStorageUsage(clubId: string)` matches the backend's `@RequestParam UUID clubId`. This is consistent — but both should be updated together if WARNING-1 is addressed (the frontend would then not need to pass clubId explicitly).
## Tests
- **Backend-Tests ausgeführt:** Nicht im Scope dieses Reviews (kein Build-Ausführung angefordert)
- **Testplan T-14 bis T-32:** Backend unit/integration tests not yet verified as passing
- **E2E T-01 bis T-13:** Frontend E2E tests not executed in this review
## Empfehlung
**⚠️ Approved with comments** — merge is acceptable, but WARNING-1 (StorageController tenant isolation) should be addressed before production deployment. WARNING-2 (SubscriptionService hook) is acceptable to defer.
### Prioritized actions:
1. **Before production:** Fix StorageController to use JWT-based club extraction instead of `@RequestParam`
2. **Next sprint:** Add SubscriptionService tier-change hook when billing features are implemented
3. **Housekeeping:** Introduce `PlanTier` enum and update `getLimitForTier` signature
@@ -0,0 +1,86 @@
# Plan Review: Sprint 14 — Marketing & Monetization
**Date:** 2026-06-18
**Module:** cannamanage (full-stack)
**Reviewer:** Lumen (Plan Reviewer)
**Documents:** analysis v1, plan v2, testplan v2
**Verdict:** ✅ APPROVED
---
## Summary
Re-review after v1 findings were addressed. All 4 findings (1 blocker, 3 warnings) from the v1 review have been properly resolved in v2. The plan is complete, correct, and ready for implementation.
## v1 Finding Resolution
| # | v1 Finding | Resolution in v2 | Status |
|---|-----------|------------------|--------|
| 1 | ❌ `QuotaExceededException` naming conflict | Renamed to `StorageQuotaExceededException` in Step 4.4 with explicit note about existing class | ✅ Fixed |
| 2 | ⚠️ No sync for `storage_limit_bytes` on tier change | Added Step 4.9 `onTierChange()` hook in `SubscriptionService` | ✅ Fixed |
| 3 | ⚠️ English i18n keys not specified | Step 1.3 now states both `de.json` and `en.json` must receive equivalent keys; T-32 tests both | ✅ Fixed |
| 4 | ⚠️ `extractClubId(user)` not defined | Step 4.5 now has full implementation with Javadoc referencing `DocumentController` pattern | ✅ Fixed |
## Reviewed Documents
| Document | Version | Assessment |
|----------|---------|-----------|
| Analysis | v1 | ✅ |
| Plan | v2 | ✅ |
| Testplan | v2 | ✅ |
## Checklist
### Assessment
| # | Check | Result | Note |
|---|-------|--------|------|
| 1 | Problem statement complete | ✅ | Clear: no landing page, no quota enforcement, basic login UI |
| 2 | Affected components identified | ✅ | 15 components listed (was 14, +1 for new `StorageQuotaExceededException`) |
| 3 | Current state accurate | ✅ | Confirmed: no `(marketing)/page.tsx`, nav is hardcoded, V35 is latest migration |
| 4 | Risk assessment realistic | ✅ | 5 risks with appropriate mitigations |
| 5 | Solution options evaluated | ✅ | 3 options with effort estimates, Option A justified |
### Implementation Plan
| # | Check | Result | Note |
|---|-------|--------|------|
| 6 | All requirements covered | ✅ | All 4 areas: landing, login, pricing, storage quota |
| 7 | Correct patterns referenced | ✅ | `NextIntlClientProvider`, Spring `@Service`, `@PreAuthorize`, `CustomUserDetails` cast pattern |
| 8 | File paths correct | ✅ | All verified against codebase |
| 9 | Implementation order logical | ✅ | Frontend (phases 1-3) → backend (phase 4) → integration (phase 5) |
| 10 | No gaps in steps | ✅ | Migration → entity → service → controller → exception → security config → tier-sync — complete chain |
| 11 | Flyway migrations planned | ✅ | V36 correct next number, H2-only appropriate for this project |
| 12 | Error handling planned | ✅ | 402 with RFC 9457 ProblemDetail, floor-at-zero for decrements |
| 13 | No scope creep | ✅ | Explicitly defers overage billing and email notifications |
### Testplan
| # | Check | Result | Note |
|---|-------|--------|------|
| 14 | Coverage complete | ✅ | Every plan step has ≥1 test. 32 tests across 4 areas. |
| 15 | Test types appropriate | ✅ | E2E for UI (Playwright), Unit for service logic, Integration for controllers |
| 16 | Edge cases covered | ✅ | Floor-at-zero (T-19), exactly-at-limit (T-15c, T-16b), near-limit thresholds |
| 17 | Test class naming correct | ✅ | `StorageQuotaServiceTest`, `StorageControllerTest`, `DocumentServiceTest` |
| 18 | Test method naming correct | ✅ | `testGetUsage_calculatesCorrectly()`, `testCheckQuota_overLimit_throwsQuotaExceeded()` |
| 19 | Test data defined | ✅ | Explicit UUIDs, byte values, and preconditions documented |
| 20 | SSH/manual tests identified | N/A | Not a PAISY project |
## Traceability Matrix
| Acceptance Criterion | Plan Step | Test Case(s) | Status |
|---------------------|-----------|-------------|--------|
| AC1: Landing page renders | Step 1.1 | T-01 | ✅ Covered |
| AC2: Landing responsive + dark/light | Step 1.1 | T-02, T-03 | ✅ Covered |
| AC3: Admin login split layout | Steps 2.1, 2.2 | T-06, T-07, T-08 | ✅ Covered |
| AC4: Portal login member-themed | Step 2.3 | T-09, T-10 | ✅ Covered |
| AC5: Pricing shows storage | Steps 3.1-3.3 | T-11, T-12 | ✅ Covered |
| AC6: Pricing FAQ storage | Step 3.4 | T-13 | ✅ Covered |
| AC7: GET /api/v1/storage/usage | Steps 4.3, 4.5 | T-23, T-24, T-25 | ✅ Covered |
| AC8: Upload rejected 402 | Steps 4.3, 4.6, 4.7 | T-16, T-27, T-31 | ✅ Covered |
| AC9: Delete decrements counter | Step 4.6 | T-18, T-28 | ✅ Covered |
| AC10: Backfill on migration | Step 4.1 | T-29, T-30 | ✅ Covered |
## Verdict
**✅ APPROVED** — All 20 checklist items pass. All 4 v1 findings resolved. Plan is complete, correct, and ready for implementation. Recommend GO.
+571
View File
@@ -0,0 +1,571 @@
# 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 | **New**`services/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):
```json
{
"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:
```typescript
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:
```typescript
{ 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`
```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:
```java
@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`
```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.
```java
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`
```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:
```java
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:
```java
// 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:
```java
// 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:
```java
@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:
```java
.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:
```java
/**
* 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`
```typescript
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
@@ -0,0 +1,596 @@
# Testplan: Sprint 14 — Marketing & Monetization
**Date:** 2026-06-18
**Author:** Patrick Plate / Lumen (Planner)
**Status:** v2
**Basis:** cannamanage-sprint14-plan.md
---
## Test Overview
| ID | Description | Type | Class/Tool | Status |
|----|-------------|------|------------|--------|
| T-01 | Landing page renders all sections | E2E | Playwright | ⬜ |
| T-02 | Landing page responsive (mobile) | E2E | Playwright | ⬜ |
| T-03 | Landing page dark/light mode | E2E | Playwright | ⬜ |
| T-04 | Landing page CTA links work | E2E | Playwright | ⬜ |
| T-05 | Marketing nav shows Features link | E2E | Playwright | ⬜ |
| T-06 | Admin login — split layout on desktop | E2E | Playwright | ⬜ |
| T-07 | Admin login — full-width on mobile | E2E | Playwright | ⬜ |
| T-08 | Admin login — form still functional | E2E | Playwright | ⬜ |
| T-09 | Portal login — member-themed split | E2E | Playwright | ⬜ |
| T-10 | Portal login — form functional | E2E | Playwright | ⬜ |
| T-11 | Pricing — storage tiers displayed | E2E | Playwright | ⬜ |
| T-12 | Pricing — comparison table with storage row | E2E | Playwright | ⬜ |
| T-13 | Pricing — FAQ storage entry visible | E2E | Playwright | ⬜ |
| T-14 | Storage usage — correct calculation | Unit | `StorageQuotaServiceTest` | ⬜ |
| T-15 | Storage quota — allows upload under limit | Unit | `StorageQuotaServiceTest` | ⬜ |
| T-16 | Storage quota — rejects upload over limit | Unit | `StorageQuotaServiceTest` | ⬜ |
| T-17 | Storage quota — increment on upload | Unit | `StorageQuotaServiceTest` | ⬜ |
| T-18 | Storage quota — decrement on delete | Unit | `StorageQuotaServiceTest` | ⬜ |
| T-19 | Storage quota — decrement floors at zero | Unit | `StorageQuotaServiceTest` | ⬜ |
| T-20 | Storage quota — tier limit mapping | Unit | `StorageQuotaServiceTest` | ⬜ |
| T-21 | Storage quota — near-limit detection (80%) | Unit | `StorageQuotaServiceTest` | ⬜ |
| T-22 | Storage quota — near-limit detection (95%) | Unit | `StorageQuotaServiceTest` | ⬜ |
| T-23 | GET /api/v1/storage/usage — authenticated | Integration | `StorageControllerTest` | ⬜ |
| T-24 | GET /api/v1/storage/usage — unauthenticated 401 | Integration | `StorageControllerTest` | ⬜ |
| T-25 | GET /api/v1/storage/usage — correct DTO shape | Integration | `StorageControllerTest` | ⬜ |
| T-26 | Document upload — quota check integrated | Integration | `DocumentServiceTest` | ⬜ |
| T-27 | Document upload — 402 on quota exceeded | Integration | `DocumentControllerTest` | ⬜ |
| T-28 | Document delete — usage decremented | Integration | `DocumentServiceTest` | ⬜ |
| T-29 | Flyway V36 — migration applies cleanly | Integration | Flyway boot test | ⬜ |
| T-30 | Flyway V36 — backfill calculates correctly | Integration | SQL verification | ⬜ |
| T-31 | StorageQuotaExceededException — 402 response format | Unit | `GlobalExceptionHandlerTest` | ⬜ |
| T-32 | i18n — all marketing.home keys resolve (de + en) | Unit | Lint / next-intl | ⬜ |
Status: ⬜ Open | ✅ Passed | ❌ Failed | ⏭️ Skipped
---
## Test Cases
### T-01: Landing Page Renders All Sections
**Type:** E2E
**Tool:** Playwright
**Script:** `e2e/marketing/landing-page.spec.ts`
**Preconditions:**
- App running at localhost:3000
- No authentication required (public page)
**Scenarios:**
| # | Action | Expected Result |
|---|--------|-----------------|
| a | Navigate to `/` | Page loads without errors |
| b | Check hero section | Headline text visible, CTA buttons present |
| c | Check feature grid | 6 feature cards visible with titles and descriptions |
| d | Check trust signals | At least 4 trust badges visible |
| e | Check final CTA | "Kostenlos testen" button visible |
**Postconditions:**
- All i18n keys resolve (no raw key strings visible)
- No console errors
---
### T-02: Landing Page Responsive (Mobile)
**Type:** E2E
**Tool:** Playwright (viewport: 375×812)
**Scenarios:**
| # | Viewport | Expected Result |
|---|----------|-----------------|
| a | 375×812 (iPhone) | Feature grid stacks to single column |
| b | 375×812 | Hero section full-width, text wraps cleanly |
| c | 768×1024 (iPad) | Feature grid shows 2 columns |
| d | 1280×720 (desktop) | Feature grid shows 3 columns |
---
### T-03: Landing Page Dark/Light Mode
**Type:** E2E
**Tool:** Playwright (`colorScheme: 'dark'` / `'light'`)
**Scenarios:**
| # | Mode | Expected Result |
|---|------|-----------------|
| a | Dark | Background is dark, text is light, no contrast issues |
| b | Light | Background is light, text is dark, cards have proper borders |
| c | Switch | Toggle theme mid-page — re-renders correctly |
---
### T-04: Landing Page CTA Links
**Type:** E2E
**Tool:** Playwright
**Scenarios:**
| # | Element | Expected Navigation |
|---|---------|-------------------|
| a | Primary CTA ("Preise ansehen") | Navigates to `/pricing` |
| b | Secondary CTA ("Jetzt anmelden") | Navigates to `/login` |
| c | Final CTA ("Kostenlos testen") | Navigates to `/pricing` |
| d | Header "Features" link | Scrolls to `#features` section or navigates to `/#features` |
---
### T-05: Marketing Nav Features Link
**Type:** E2E
**Tool:** Playwright
**Scenarios:**
| # | Page | Expected |
|---|------|----------|
| a | `/` | Header shows "Features" link |
| b | `/pricing` | Header shows "Features" link |
| c | Click "Features" from `/pricing` | Navigates to homepage features section |
---
### T-06: Admin Login — Split Layout Desktop
**Type:** E2E
**Tool:** Playwright (viewport: 1280×720)
**Scenarios:**
| # | Check | Expected |
|---|-------|----------|
| a | Left panel visible | Branding panel with logo, tagline, feature bullets visible |
| b | Right panel visible | Login form visible |
| c | Layout proportions | Left panel ~55%, right panel ~45% |
| d | Left panel content | "CannaManage" text, tagline, 3 feature highlights with icons |
---
### T-07: Admin Login — Full-Width Mobile
**Type:** E2E
**Tool:** Playwright (viewport: 375×812)
**Scenarios:**
| # | Check | Expected |
|---|-------|----------|
| a | Left panel | Hidden (`hidden md:flex`) |
| b | Form panel | Full width, centered vertically |
| c | Form usability | All fields accessible, submit button tappable |
---
### T-08: Admin Login — Form Still Functional
**Type:** E2E
**Tool:** Playwright
**Preconditions:**
- Backend running with test credentials available
**Scenarios:**
| # | Input | Expected |
|---|-------|----------|
| a | Valid credentials | Redirects to `/dashboard` |
| b | Invalid password | Error message displayed |
| c | Empty fields | Validation errors shown |
---
### T-09: Portal Login — Member-Themed Split
**Type:** E2E
**Tool:** Playwright (viewport: 1280×720)
**Scenarios:**
| # | Check | Expected |
|---|-------|----------|
| a | Left panel | Member-specific messaging ("Willkommen zurück") |
| b | Visual theme | Different gradient than admin login (teal/emerald vs. primary green) |
| c | Feature bullets | Member-relevant: Abgabehistorie, Profil, Dokumente |
---
### T-10: Portal Login — Form Functional
**Type:** E2E
**Tool:** Playwright
**Scenarios:**
| # | Input | Expected |
|---|-------|----------|
| a | Submit form | Redirects to `/portal/dashboard` |
| b | Invalid input | Error message shown |
---
### T-11: Pricing — Storage Tiers Displayed
**Type:** E2E
**Tool:** Playwright
**Scenarios:**
| # | Plan | Expected Storage Display |
|---|------|------------------------|
| a | Starter card | "5 GB Speicher" visible |
| b | Pro card | "50 GB Speicher" visible + overage note |
| c | Enterprise card | "Individueller Speicher" visible |
---
### T-12: Pricing — Comparison Table
**Type:** E2E
**Tool:** Playwright
**Scenarios:**
| # | Check | Expected |
|---|-------|----------|
| a | Table exists | Comparison table rendered below plan cards |
| b | Storage row | "Speicher" row shows 5 GB / 50 GB / Individuell |
| c | Overage row | "Überschreitung" row shows values per plan |
| d | Responsive | Table scrollable on mobile |
---
### T-13: Pricing — FAQ Storage Entry
**Type:** E2E
**Tool:** Playwright
**Scenarios:**
| # | Check | Expected |
|---|-------|----------|
| a | FAQ section | Contains storage question |
| b | Click expand | Answer mentions Starter upgrade + Pro overage pricing |
---
### T-14: Storage Usage Calculation
**Type:** Unit
**Class:** `cannamanage-service/src/test/java/de/cannamanage/service/StorageQuotaServiceTest.java`
**Method:** `testGetUsage_calculatesCorrectly()`
**Scenarios:**
| # | Setup | Expected |
|---|-------|----------|
| a | Club with 1 GB used, 5 GB limit | `{ usedBytes: 1073741824, limitBytes: 5368709120, percentage: 20.0 }` |
| b | Club with 0 bytes used | `{ usedBytes: 0, percentage: 0.0 }` |
| c | Club with exactly limit used | `{ percentage: 100.0 }` |
---
### T-15: Quota Allows Upload Under Limit
**Type:** Unit
**Class:** `StorageQuotaServiceTest`
**Method:** `testCheckQuota_underLimit_noException()`
**Scenarios:**
| # | Current Usage | Limit | Upload Size | Expected |
|---|---------------|-------|-------------|----------|
| a | 1 GB | 5 GB | 100 MB | No exception |
| b | 4.9 GB | 5 GB | 50 MB | No exception (4.95 GB < 5 GB) |
| c | 0 | 5 GB | 5 GB | No exception (exactly at limit) |
---
### T-16: Quota Rejects Upload Over Limit
**Type:** Unit
**Class:** `StorageQuotaServiceTest`
**Method:** `testCheckQuota_overLimit_throwsQuotaExceeded()`
**Scenarios:**
| # | Current Usage | Limit | Upload Size | Expected |
|---|---------------|-------|-------------|----------|
| a | 4.9 GB | 5 GB | 200 MB | `StorageQuotaExceededException` thrown |
| b | 5 GB | 5 GB | 1 byte | `StorageQuotaExceededException` thrown |
| c | 50 GB | 50 GB | 1 KB | `StorageQuotaExceededException` thrown |
---
### T-17: Increment On Upload
**Type:** Unit
**Class:** `StorageQuotaServiceTest`
**Method:** `testIncrementUsage_addsBytes()`
**Scenarios:**
| # | Initial | Increment | Expected |
|---|---------|-----------|----------|
| a | 0 | 1048576 (1 MB) | 1048576 |
| b | 1000000 | 500000 | 1500000 |
---
### T-18: Decrement On Delete
**Type:** Unit
**Class:** `StorageQuotaServiceTest`
**Method:** `testDecrementUsage_subtractsBytes()`
**Scenarios:**
| # | Initial | Decrement | Expected |
|---|---------|-----------|----------|
| a | 5000000 | 1000000 | 4000000 |
| b | 1048576 | 1048576 | 0 |
---
### T-19: Decrement Floors at Zero
**Type:** Unit
**Class:** `StorageQuotaServiceTest`
**Method:** `testDecrementUsage_floorsAtZero()`
**Scenarios:**
| # | Initial | Decrement | Expected |
|---|---------|-----------|----------|
| a | 100 | 200 | 0 (not negative) |
| b | 0 | 1000 | 0 |
---
### T-20: Tier Limit Mapping
**Type:** Unit
**Class:** `StorageQuotaServiceTest`
**Method:** `testGetLimitForTier()`
**Scenarios:**
| # | Tier | Expected Limit |
|---|------|---------------|
| a | TRIAL | 5 GB (5368709120) |
| b | STARTER | 5 GB (5368709120) |
| c | PRO | 50 GB (53687091200) |
| d | ENTERPRISE | Long.MAX_VALUE |
---
### T-21: Near-Limit Detection 80%
**Type:** Unit
**Class:** `StorageQuotaServiceTest`
**Method:** `testIsNearLimit_at80Percent()`
**Scenarios:**
| # | Usage | Limit | Threshold | Expected |
|---|-------|-------|-----------|----------|
| a | 4.0 GB | 5 GB | 80% | true (80%) |
| b | 3.9 GB | 5 GB | 80% | false (78%) |
| c | 4.1 GB | 5 GB | 80% | true (82%) |
---
### T-22: Near-Limit Detection 95%
**Type:** Unit
**Class:** `StorageQuotaServiceTest`
**Method:** `testIsNearLimit_at95Percent()`
**Scenarios:**
| # | Usage | Limit | Threshold | Expected |
|---|-------|-------|-----------|----------|
| a | 4.75 GB | 5 GB | 95% | true (95%) |
| b | 4.7 GB | 5 GB | 95% | false (94%) |
---
### T-23: Storage Endpoint — Authenticated
**Type:** Integration
**Class:** `cannamanage-api/src/test/java/de/cannamanage/api/controller/StorageControllerTest.java`
**Method:** `testGetUsage_authenticated_returns200()`
**Preconditions:**
- Test user with ADMIN role and known clubId
- Club has pre-set `storageUsedBytes` and `storageLimitBytes`
**Scenarios:**
| # | Auth | Expected |
|---|------|----------|
| a | Valid ADMIN JWT | 200 with `{ usedBytes, limitBytes, percentage }` |
| b | Valid STAFF JWT | 200 with correct response |
---
### T-24: Storage Endpoint — Unauthenticated
**Type:** Integration
**Class:** `StorageControllerTest`
**Method:** `testGetUsage_unauthenticated_returns401()`
**Scenarios:**
| # | Auth | Expected |
|---|------|----------|
| a | No token | 401 Unauthorized |
| b | Expired token | 401 Unauthorized |
| c | MEMBER role | 403 Forbidden |
---
### T-25: Storage Endpoint — DTO Shape
**Type:** Integration
**Class:** `StorageControllerTest`
**Method:** `testGetUsage_responseShape()`
**Scenarios:**
| # | Check | Expected |
|---|-------|----------|
| a | JSON keys | Response contains `usedBytes`, `limitBytes`, `percentage` |
| b | Types | `usedBytes` and `limitBytes` are numbers, `percentage` is double |
| c | Percentage calculation | Matches `usedBytes / limitBytes * 100` |
---
### T-26: Document Upload — Quota Check Integrated
**Type:** Integration
**Class:** `cannamanage-service/src/test/java/de/cannamanage/service/DocumentServiceTest.java`
**Method:** `testUploadDocument_checksQuotaBeforeWrite()`
**Scenarios:**
| # | Setup | Expected |
|---|-------|----------|
| a | Club under quota, upload 1 MB | Upload succeeds, `storageUsedBytes` incremented by file size |
| b | Club at quota limit | Upload rejected with `StorageQuotaExceededException` before file write |
---
### T-27: Document Upload — 402 Response
**Type:** Integration
**Class:** `cannamanage-api/src/test/java/de/cannamanage/api/controller/DocumentControllerTest.java`
**Method:** `testUploadDocument_quotaExceeded_returns402()`
**Preconditions:**
- Club with `storageUsedBytes = storageLimitBytes` (fully used)
**Scenarios:**
| # | Action | Expected |
|---|--------|----------|
| a | POST multipart upload (1 byte file) | 402 Payment Required |
| b | Response body | RFC 9457 ProblemDetail with `currentUsage`, `limit`, `requestedBytes` |
---
### T-28: Document Delete — Usage Decremented
**Type:** Integration
**Class:** `DocumentServiceTest`
**Method:** `testDeleteDocument_decrementsUsage()`
**Scenarios:**
| # | Setup | Expected |
|---|-------|----------|
| a | Club with 5 MB used, delete 2 MB document | `storageUsedBytes` = 3 MB |
| b | Club with 1 MB used, delete 1 MB document | `storageUsedBytes` = 0 |
---
### T-29: Flyway V36 — Migration Applies
**Type:** Integration
**Tool:** Spring Boot test context startup
**Scenarios:**
| # | Check | Expected |
|---|-------|----------|
| a | Application starts | V36 migration applies without error |
| b | Column exists | `SELECT storage_used_bytes FROM clubs LIMIT 1` succeeds |
| c | Default value | New clubs get `storage_used_bytes = 0` and `storage_limit_bytes = 5368709120` |
---
### T-30: Flyway V36 — Backfill
**Type:** Integration
**Tool:** SQL verification after migration
**Preconditions:**
- Club exists with 3 documents (sizes: 1MB, 2MB, 3MB)
**Scenarios:**
| # | Check | Expected |
|---|-------|----------|
| a | After migration | `storage_used_bytes` = 6291456 (6 MB = sum of document sizes) |
| b | Club with no docs | `storage_used_bytes` = 0 |
---
### T-31: StorageQuotaExceededException — 402 Format
**Type:** Unit
**Class:** `cannamanage-api/src/test/java/de/cannamanage/api/exception/GlobalExceptionHandlerTest.java`
**Method:** `testStorageQuotaExceeded_returns402WithProblemDetail()`
**Scenarios:**
| # | Input | Expected |
|---|-------|----------|
| a | `StorageQuotaExceededException(1GB, 5GB, 200MB)` | HTTP 402, title="Storage Quota Exceeded" |
| b | Response properties | Contains `currentUsage`, `limit`, `requestedBytes` numeric fields |
---
### T-32: i18n Keys Resolve
**Type:** Unit / Lint
**Tool:** next-intl compile check or custom script
**Scenarios:**
| # | Namespace | Expected |
|---|-----------|----------|
| a | `marketing.home.*` | All 20+ keys resolve in `de.json` |
| b | `marketing.home.*` | All 20+ keys resolve in `en.json` (English equivalents) |
| c | `marketing.pricing.faq.storage.*` | question + answer keys present in both locales |
| d | `marketing.pricing.plans.*.storage` | Storage labels present for each plan in both locales |
---
## Test Data
### Backend
- **Test club:** UUID `00000000-0000-0000-0000-000000000001`, `storageUsedBytes = 1073741824` (1 GB), `storageLimitBytes = 5368709120` (5 GB)
- **Test documents:** 3 documents with `fileSize` = 100MB, 200MB, 773741824 bytes (total = 1 GB)
- **Full quota club:** UUID `00000000-0000-0000-0000-000000000002`, `storageUsedBytes = storageLimitBytes = 5368709120`
### Frontend E2E
- Landing page, pricing, login pages are public — no auth setup needed for T-01 through T-13
- Login form tests (T-08, T-10) require running backend with test user `admin@gruener-daumen.de` / `TestAdmin123!`
---
## Test Coverage
| Component | Unit | Integration | E2E | Total |
|-----------|------|-------------|-----|-------|
| Landing page | 0 | 0 | 5 | 5 |
| Login redesign | 0 | 0 | 5 | 5 |
| Pricing update | 0 | 0 | 3 | 3 |
| StorageQuotaService | 9 | 0 | 0 | 9 |
| StorageController | 0 | 3 | 0 | 3 |
| DocumentService (quota) | 0 | 2 | 0 | 2 |
| DocumentController (402) | 0 | 1 | 0 | 1 |
| Flyway migration | 0 | 2 | 0 | 2 |
| Exception handling | 1 | 0 | 0 | 1 |
| i18n verification | 1 | 0 | 0 | 1 |
| **Total** | **11** | **8** | **13** | **32** |