19 KiB
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
ComplianceServiceimplemented 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 verifyexits 0 on clean checkout./mvnw test -pl cannamanage-servicereports 10/10 tests passing- JaCoCo report shows
ComplianceServiceat 100% line + branch coverage docker compose up db -dstarts PostgreSQL; Flyway V1 migration applies cleanly- No
TODOcomments in production code paths - All 8 JPA entities have
@Column(nullable = false)on required fields ComplianceConstants.javacontains all CanG limits aspublic static final BigDecimalAbstractTenantEntity.tenantIdis@Column(updatable = false)- Code pushed to
http://192.168.188.119:30008/pplate/cannamanagemain 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