# 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
<!-- Spring Boot 3.3.x parent -->
<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>3.3.4</version>
</parent>

<!-- Modules -->
<modules>
  <module>cannamanage-domain</module>
  <module>cannamanage-service</module>
  <module>cannamanage-api</module>
</modules>

<!-- Key managed versions -->
<!-- Java 21, Hibernate 6.x (via Spring Boot BOM), Flyway 9.x -->
<!-- JJWT 0.12.x (Sprint 2), iText 7 (Sprint 3), Stripe 25.x (Sprint 4) -->
```

---

## 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*
