# CannaManage — Sprint 1 Implementation Plan **Sprint:** 1 — Foundation **Phase:** Phase 1 (Weeks 1–8 of Phase 0 Foundation) **Author:** Lumen (architect mode), 2026-04-10 **Status:** Ready for Patrick's approval --- ## Sprint Goal > **"Get the compliance engine running and fully tested — with zero production code and zero API yet."** Sprint 1 produces a compilable, testable Maven multi-module project with: - All core JPA entities modelled - Flyway V1 baseline migration SQL - `ComplianceService` implemented with 100% unit test coverage (TC-001 → TC-010) - A working local dev environment (Docker Compose: PostgreSQL + app) No UI, no REST API, no Stripe in Sprint 1. The compliance engine is the legal heart of the product — validate it first. --- ## Deliverables | # | Deliverable | Definition of Done | |---|------------|-------------------| | D1 | Maven multi-module project scaffold | `./mvnw clean verify` passes with no test failures | | D2 | `cannamanage-domain` module | All 8 JPA entities compile; `AbstractTenantEntity` wired | | D3 | Flyway `V1__initial_schema.sql` | Migration applies cleanly against PostgreSQL 16 | | D4 | `ComplianceService` | All 5 business methods implemented | | D5 | Unit test suite TC-001 → TC-010 | JaCoCo reports 100% line + branch coverage on `ComplianceService` | | D6 | Local dev `docker-compose.yml` | `docker compose up db` starts PostgreSQL; app connects cleanly | --- ## 1. Maven Multi-Module Structure ``` cannamanage/ ← root POM (parent) ├── pom.xml ← parent POM (BOM: Spring Boot 3.x, Java 21) │ ├── cannamanage-domain/ ← JPA entities, enums, constants │ └── src/main/java/de/cannamanage/domain/ │ ├── entity/ ← JPA entity classes │ ├── enums/ ← MemberStatus, BatchStatus, etc. │ └── constants/ │ └── ComplianceConstants.java │ ├── cannamanage-service/ ← Business logic, services (TESTED HERE) │ └── src/ │ ├── main/java/de/cannamanage/service/ │ │ ├── ComplianceService.java │ │ ├── dto/ ← QuotaStatus, ComplianceCheckResult, etc. │ │ └── exception/ ← QuotaExceededException, MemberIneligibleException │ └── test/java/de/cannamanage/service/ │ └── ComplianceServiceTest.java ← TC-001 to TC-010 │ ├── cannamanage-api/ ← Spring Boot app entry point (REST controllers — Sprint 2) │ └── src/main/java/de/cannamanage/api/ │ └── CannaManageApplication.java │ └── docker-compose.yml ← Local dev: PostgreSQL 16 ``` ### Parent POM key dependencies (BOM managed) ```xml org.springframework.boot spring-boot-starter-parent 3.3.4 cannamanage-domain cannamanage-service cannamanage-api ``` --- ## 2. `cannamanage-domain` — JPA Entities ### 2.1 `AbstractTenantEntity` (base class for all entities) ```java // de.cannamanage.domain.entity.AbstractTenantEntity @MappedSuperclass @FilterDef( name = "tenantFilter", parameters = @ParamDef(name = "tenantId", type = UUID.class) ) @Filter(name = "tenantFilter", condition = "tenant_id = :tenantId") public abstract class AbstractTenantEntity { @Id @GeneratedValue(strategy = GenerationType.UUID) private UUID id; @Column(name = "tenant_id", nullable = false, updatable = false) private UUID tenantId; @Column(name = "created_at", nullable = false, updatable = false) private Instant createdAt; @PrePersist void onCreate() { this.tenantId = TenantContext.getCurrentTenant(); // ThreadLocal this.createdAt = Instant.now(); } } ``` ### 2.2 Entities to implement (Sprint 1) | Entity | Key fields | Notes | |--------|-----------|-------| | `Club` | id, name, licenseNumber, maxMembers, status | Root tenant aggregate | | `Member` | id, clubId, firstName, lastName, email, dob, membershipNumber, status, isUnder21 | `isUnder21` derived from DOB | | `Strain` | id, name, thcPercentage, cbdPercentage | Immutable once created | | `Batch` | id, strainId, quantityGrams, harvestDate, batchCode, status, contaminationFlag | status: AVAILABLE → EXHAUSTED / RECALLED | | `Distribution` | id, memberId, batchId, quantityGrams, distributedAt, recordedBy, notes | `@Column(updatable=false)` on all fields — immutable | | `MonthlyQuota` | id, memberId, year, month, totalDistributed, maxAllowed, version | `@Version` for optimistic lock | | `StockMovement` | id, batchId, movementType, quantityGrams, reason, createdAt | Audit journal | | `User` | id, memberId, email, passwordHash, role, lastLogin, active, refreshTokenHash | Login identity | ### 2.3 `ComplianceConstants.java` ```java // de.cannamanage.domain.constants.ComplianceConstants public final class ComplianceConstants { // CanG §19(2) — adult limits public static final BigDecimal ADULT_DAILY_LIMIT_GRAMS = new BigDecimal("25.0"); public static final BigDecimal ADULT_MONTHLY_LIMIT_GRAMS = new BigDecimal("50.0"); // CanG §19(3) — under-21 limits public static final BigDecimal UNDER21_MONTHLY_LIMIT_GRAMS = new BigDecimal("30.0"); // CanG §19(4) — under-21 THC cap public static final BigDecimal UNDER21_MAX_THC_PERCENTAGE = new BigDecimal("10.0"); // Minimum membership age public static final int MINIMUM_MEMBERSHIP_AGE = 18; // Under-21 threshold public static final int UNDER21_THRESHOLD_AGE = 21; private ComplianceConstants() {} } ``` --- ## 3. Flyway `V1__initial_schema.sql` Location: `cannamanage-api/src/main/resources/db/migration/V1__initial_schema.sql` ```sql -- Clubs (root of tenant hierarchy) CREATE TABLE clubs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, name VARCHAR(255) NOT NULL, address TEXT, license_number VARCHAR(100) NOT NULL UNIQUE, max_members INT NOT NULL DEFAULT 500, status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Members CREATE TABLE members ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, club_id UUID NOT NULL REFERENCES clubs(id), first_name VARCHAR(100) NOT NULL, last_name VARCHAR(100) NOT NULL, email VARCHAR(255) NOT NULL, date_of_birth DATE NOT NULL, membership_date DATE NOT NULL DEFAULT CURRENT_DATE, membership_number VARCHAR(50) NOT NULL, status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', is_under_21 BOOLEAN NOT NULL DEFAULT FALSE, prevention_officer BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(email, tenant_id), UNIQUE(membership_number, tenant_id) ); -- Strains CREATE TABLE strains ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, name VARCHAR(255) NOT NULL, thc_percentage NUMERIC(5,2) NOT NULL, cbd_percentage NUMERIC(5,2) NOT NULL DEFAULT 0.00, description TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Batches CREATE TABLE batches ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, strain_id UUID NOT NULL REFERENCES strains(id), quantity_grams NUMERIC(10,2) NOT NULL, harvest_date DATE, batch_code VARCHAR(100) NOT NULL, status VARCHAR(50) NOT NULL DEFAULT 'AVAILABLE', contamination_flag BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(batch_code, tenant_id) ); -- Distributions (immutable — no UPDATE/DELETE via app) CREATE TABLE distributions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, member_id UUID NOT NULL REFERENCES members(id), batch_id UUID NOT NULL REFERENCES batches(id), quantity_grams NUMERIC(10,2) NOT NULL, distributed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), recorded_by UUID NOT NULL REFERENCES members(id), notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Monthly quotas (one row per member per calendar month) CREATE TABLE monthly_quotas ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, member_id UUID NOT NULL REFERENCES members(id), year INT NOT NULL, month INT NOT NULL CHECK (month >= 1 AND month <= 12), total_distributed NUMERIC(10,2) NOT NULL DEFAULT 0.00, max_allowed NUMERIC(10,2) NOT NULL, version BIGINT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(member_id, year, month) ); -- Stock movements (audit journal) CREATE TABLE stock_movements ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, batch_id UUID NOT NULL REFERENCES batches(id), movement_type VARCHAR(50) NOT NULL, -- IN, OUT, RECALL, ADJUSTMENT quantity_grams NUMERIC(10,2) NOT NULL, reason TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Users (login identities) CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, member_id UUID REFERENCES members(id), email VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL, role VARCHAR(50) NOT NULL DEFAULT 'ROLE_MEMBER', last_login TIMESTAMPTZ, active BOOLEAN NOT NULL DEFAULT TRUE, refresh_token_hash VARCHAR(255), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(email, tenant_id) ); -- Indexes for common query patterns CREATE INDEX idx_members_club_id ON members(club_id); CREATE INDEX idx_members_tenant_id ON members(tenant_id); CREATE INDEX idx_distributions_member_id ON distributions(member_id); CREATE INDEX idx_distributions_tenant_id ON distributions(tenant_id); CREATE INDEX idx_distributions_distributed_at ON distributions(distributed_at); CREATE INDEX idx_monthly_quotas_member_month ON monthly_quotas(member_id, year, month); CREATE INDEX idx_batches_tenant_status ON batches(tenant_id, status); ``` --- ## 4. `ComplianceService` — Implementation Spec Package: `de.cannamanage.service` ### 4.1 Dependencies (injected via constructor) ```java @Service @Transactional public class ComplianceService { private final MemberRepository memberRepository; private final DistributionRepository distributionRepository; private final BatchRepository batchRepository; private final MonthlyQuotaRepository monthlyQuotaRepository; private final StrainRepository strainRepository; // constructor injection... } ``` ### 4.2 Method: `checkDistributionAllowed(UUID memberId, UUID batchId, BigDecimal quantityGrams)` **Algorithm (sequential checks, fail-fast):** ``` 1. Load Member — throw MemberNotFoundException if not found 2. CHECK: member.status == ACTIVE → else throw QuotaExceededException(MEMBER_INACTIVE) 3. Load Batch → CHECK: batch.status == AVAILABLE → else throw BatchUnavailableException 4. Load Strain via batch.strainId 5. IF member.isUnder21 AND strain.thcPercentage > UNDER21_MAX_THC_PERCENTAGE → throw QuotaExceededException(HIGH_THC_RESTRICTED_UNDER_21) 6. Calculate todayDistributed = SUM(distributions.quantityGrams WHERE memberId AND date=TODAY) CHECK: todayDistributed + quantityGrams > ADULT_DAILY_LIMIT_GRAMS → throw QuotaExceededException(QUOTA_EXCEEDED_DAILY) 7. Get or create MonthlyQuota for (memberId, currentYear, currentMonth) SET maxAllowed = isUnder21 ? UNDER21_MONTHLY_LIMIT_GRAMS : ADULT_MONTHLY_LIMIT_GRAMS CHECK: quota.totalDistributed + quantityGrams > quota.maxAllowed → throw QuotaExceededException(QUOTA_EXCEEDED_MONTHLY) 8. Return ComplianceCheckResult(allowed=true, remainingDaily, remainingMonthly) ``` ### 4.3 `QuotaExceededException` — error codes ```java public enum QuotaViolationCode { MEMBER_INACTIVE, QUOTA_EXCEEDED_DAILY, QUOTA_EXCEEDED_MONTHLY, HIGH_THC_RESTRICTED_UNDER_21, BATCH_UNAVAILABLE } ``` ### 4.4 DTOs ```java // ComplianceCheckResult record ComplianceCheckResult( boolean allowed, BigDecimal remainingDaily, BigDecimal remainingMonthly, boolean isUnder21 ) {} // QuotaStatus record QuotaStatus( BigDecimal totalAllowed, BigDecimal totalUsed, BigDecimal remaining, boolean isUnder21, int year, int month ) {} ``` --- ## 5. Unit Test Suite (TC-001 → TC-010) **Class:** `ComplianceServiceTest` in `cannamanage-service` **Coverage requirement:** 100% line + branch on `ComplianceService` **Tools:** JUnit 5, Mockito 5, AssertJ ### Test structure ```java @ExtendWith(MockitoExtension.class) class ComplianceServiceTest { @Mock MemberRepository memberRepository; @Mock DistributionRepository distributionRepository; @Mock BatchRepository batchRepository; @Mock MonthlyQuotaRepository monthlyQuotaRepository; @Mock StrainRepository strainRepository; @InjectMocks ComplianceService complianceService; // Test fixtures private static final UUID ADULT_MEMBER_ID = UUID.randomUUID(); private static final UUID UNDER21_MEMBER_ID = UUID.randomUUID(); private static final UUID BATCH_ID = UUID.randomUUID(); private static final UUID HIGH_THC_STRAIN_ID = UUID.randomUUID(); // TC-001: adult at monthly limit → throws QUOTA_EXCEEDED_MONTHLY // TC-002: under-21 at monthly limit → throws QUOTA_EXCEEDED_MONTHLY // TC-003: adult at daily limit → throws QUOTA_EXCEEDED_DAILY // TC-004: under-21 + high THC strain → throws HIGH_THC_RESTRICTED_UNDER_21 // TC-005: adult at 49g requesting 2g → throws QUOTA_EXCEEDED_MONTHLY // TC-006: adult at 0g requesting 25g → allowed, remaining=0 // TC-007: adult at 24.9g requesting 0.1g → allowed, remainingDaily=0 // TC-008: adult at 24.9g requesting 0.2g → throws QUOTA_EXCEEDED_DAILY // TC-009: SUSPENDED member → throws MEMBER_INACTIVE // TC-010: EXPELLED member → throws MEMBER_INACTIVE } ``` ### Key mock patterns ```java // TC-001 example mock setup Member adultMember = new Member(); adultMember.setId(ADULT_MEMBER_ID); adultMember.setUnder21(false); adultMember.setStatus(MemberStatus.ACTIVE); when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember)); MonthlyQuota quota = new MonthlyQuota(); quota.setTotalDistributed(new BigDecimal("50.0")); quota.setMaxAllowed(ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS); when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt())) .thenReturn(Optional.of(quota)); // Assert assertThatThrownBy(() -> complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("1.0"))) .isInstanceOf(QuotaExceededException.class) .extracting("code") .isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_MONTHLY); ``` --- ## 6. Local Dev Docker Compose ```yaml # docker-compose.yml (root of cannamanage project) version: '3.9' services: db: image: postgres:16-alpine container_name: cannamanage-db-local environment: POSTGRES_DB: cannamanage POSTGRES_USER: cannamanage POSTGRES_PASSWORD: dev_password_change_in_prod ports: - "5432:5432" volumes: - pgdata_local:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U cannamanage"] interval: 5s timeout: 3s retries: 5 volumes: pgdata_local: ``` ```properties # cannamanage-api/src/main/resources/application-local.properties spring.datasource.url=jdbc:postgresql://localhost:5432/cannamanage spring.datasource.username=cannamanage spring.datasource.password=dev_password_change_in_prod spring.jpa.hibernate.ddl-auto=validate # Flyway owns schema spring.flyway.enabled=true spring.flyway.locations=classpath:db/migration logging.level.de.cannamanage=DEBUG ``` **Run locally:** ```bash git clone http://192.168.188.119:30008/pplate/cannamanage.git cd cannamanage docker compose up db -d ./mvnw spring-boot:run -pl cannamanage-api -Dspring.profiles.active=local ``` --- ## 7. Sprint 1 Gitea Issues (already created: #1–#10) Based on the Sprint 1 board at `http://truenas.local:30008/pplate/cannamanage/wiki/Sprint-1-Board`, these map to: | Gitea Issue | Sprint 1 Deliverable | |-------------|---------------------| | #1 | Maven multi-module project scaffold | | #2 | `AbstractTenantEntity` + `TenantContext` ThreadLocal | | #3 | All 8 JPA entities in `cannamanage-domain` | | #4 | `ComplianceConstants.java` | | #5 | Flyway `V1__initial_schema.sql` | | #6 | `ComplianceService` implementation | | #7 | Unit tests TC-001 → TC-010 (100% coverage) | | #8 | `docker-compose.yml` local dev | | #9 | `application-local.properties` | | #10 | JaCoCo coverage gate in parent POM | --- ## 8. Out of Scope — Sprint 1 These are **explicitly deferred** to Sprint 2+: - REST API controllers (`AuthController`, `MemberController`, `DistributionController`) - Spring Security + JWT filter chain - PrimeFaces JSF frontend - Stripe billing integration - iText 7 PDF reports - Email notifications - Testcontainers integration tests (TC-018 → TC-022) - Hetzner deployment / CI pipeline - `MemberService` (TC-011 → TC-015) --- ## 9. Definition of Done — Sprint 1 - [ ] `./mvnw clean verify` exits 0 on clean checkout - [ ] `./mvnw test -pl cannamanage-service` reports 10/10 tests passing - [ ] JaCoCo report shows `ComplianceService` at 100% line + branch coverage - [ ] `docker compose up db -d` starts PostgreSQL; Flyway V1 migration applies cleanly - [ ] No `TODO` comments in production code paths - [ ] All 8 JPA entities have `@Column(nullable = false)` on required fields - [ ] `ComplianceConstants.java` contains all CanG limits as `public static final BigDecimal` - [ ] `AbstractTenantEntity.tenantId` is `@Column(updatable = false)` - [ ] Code pushed to `http://192.168.188.119:30008/pplate/cannamanage` main branch --- ## 10. Recommended Implementation Order ``` Day 1: Root pom.xml + module scaffolds → ./mvnw compile passes Day 2: AbstractTenantEntity + TenantContext + ComplianceConstants Day 3: All 8 JPA entities (compile-time only, no DB yet) Day 4: Flyway V1 SQL + docker-compose.yml → migration applies Day 5: ComplianceService skeleton (method signatures + DTOs) Day 6: TC-001 → TC-005 (the exception/blocking cases) Day 7: TC-006 → TC-010 (boundary + happy path cases) Day 8: JaCoCo gate; clean up; push to Gitea ``` *Assuming ~2–3 hours of evening/weekend coding per day as side project.* --- *Plan created: 2026-04-10 | Sprint start: when Patrick approves | Estimated coding sessions: 8 × 2-3h*