Files

19 KiB
Raw Permalink Blame History

CannaManage — Sprint 1 Implementation Plan

Sprint: 1 — Foundation
Phase: Phase 1 (Weeks 18 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)

<!-- 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)

// 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

// 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

-- 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)

@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

public enum QuotaViolationCode {
    MEMBER_INACTIVE,
    QUOTA_EXCEEDED_DAILY,
    QUOTA_EXCEEDED_MONTHLY,
    HIGH_THC_RESTRICTED_UNDER_21,
    BATCH_UNAVAILABLE
}

4.4 DTOs

// 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

@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

// 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

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

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

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 ~23 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