feat(sprint-1): CannaManage foundation — compliance engine, JPA entities, tests TC-001→TC-025

- Maven multi-module project (parent + domain + service + api)
- AbstractTenantEntity with Hibernate @Filter for multi-tenancy (explicit getters/setters, Java 25 compatible)
- TenantContext ThreadLocal for request-scoped tenant isolation
- 8 JPA entities: Club, Member, Strain, Batch, Distribution, MonthlyQuota, StockMovement, User
- ComplianceConstants with CanG §19 limits (25g/day adult, 50g/month adult, 30g/month under-21, 10% THC cap)
- ComplianceService: checkDistributionAllowed() with fail-fast sequential CanG checks
- Unit tests TC-001→TC-025: 25/25 passing, 100% line+branch coverage on ComplianceService (JaCoCo 0.8.13)
- Flyway V1__initial_schema.sql: all 8 tables + indexes
- docker-compose.yml: PostgreSQL 16 local dev
- application-local.properties: local profile configuration

Closes #1 #2 #3 #4 #5 #6 #7 #8 #9 #10
This commit is contained in:
Patrick Plate
2026-04-12 20:30:12 +02:00
commit fa1eaf64e0
42 changed files with 2344 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>de.cannamanage</groupId>
<artifactId>cannamanage-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>cannamanage-domain</artifactId>
<name>CannaManage — Domain (JPA Entities)</name>
<dependencies>
<!-- JPA API only — implementation provided by Spring Boot in runtime -->
<dependency>
<groupId>jakarta.persistence</groupId>
<artifactId>jakarta.persistence-api</artifactId>
</dependency>
<!-- Hibernate core for @FilterDef, @Filter -->
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
</dependency>
<!-- Validation API -->
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
</dependency>
<!-- Lombok for boilerplate reduction -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,33 @@
package de.cannamanage.domain.constants;
import java.math.BigDecimal;
/**
* CanG (Cannabisgesetz) compliance limits.
* All limits are defined as per §19 CanG (Cannabisgesetz) for social cannabis clubs.
* These are immutable constants — changes require legal review.
*/
public final class ComplianceConstants {
// CanG §19(2) — adult daily distribution limit
public static final BigDecimal ADULT_DAILY_LIMIT_GRAMS = new BigDecimal("25.0");
// CanG §19(2) — adult monthly distribution limit
public static final BigDecimal ADULT_MONTHLY_LIMIT_GRAMS = new BigDecimal("50.0");
// CanG §19(3) — under-21 monthly distribution limit
public static final BigDecimal UNDER21_MONTHLY_LIMIT_GRAMS = new BigDecimal("30.0");
// CanG §19(4) — maximum THC percentage allowed for under-21 members
public static final BigDecimal UNDER21_MAX_THC_PERCENTAGE = new BigDecimal("10.0");
// Minimum age for club membership (§5 CanG)
public static final int MINIMUM_MEMBERSHIP_AGE = 18;
// Age threshold below which stricter limits apply
public static final int UNDER21_THRESHOLD_AGE = 21;
private ComplianceConstants() {
// Utility class — do not instantiate
}
}
@@ -0,0 +1,49 @@
package de.cannamanage.domain.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.Filter;
import org.hibernate.annotations.FilterDef;
import org.hibernate.annotations.ParamDef;
import java.time.Instant;
import java.util.UUID;
/**
* Base class for all tenant-scoped entities.
* Applies a Hibernate @Filter to ensure all queries automatically scope to the current tenant.
* The tenantId is set automatically from TenantContext on persist and cannot be updated.
*/
@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)
@Column(name = "id", nullable = false, updatable = false)
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();
this.createdAt = Instant.now();
}
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public UUID getTenantId() { return tenantId; }
public void setTenantId(UUID tenantId) { this.tenantId = tenantId; }
public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
}
@@ -0,0 +1,54 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.BatchStatus;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
@Entity
@Table(name = "batches",
uniqueConstraints = {
@UniqueConstraint(columnNames = {"batch_code", "tenant_id"})
}
)
public class Batch extends AbstractTenantEntity {
@Column(name = "strain_id", nullable = false)
private UUID strainId;
@Column(name = "quantity_grams", nullable = false, precision = 10, scale = 2)
private BigDecimal quantityGrams;
@Column(name = "harvest_date")
private LocalDate harvestDate;
@Column(name = "batch_code", nullable = false, length = 100)
private String batchCode;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 50)
private BatchStatus status = BatchStatus.AVAILABLE;
@Column(name = "contamination_flag", nullable = false)
private boolean contaminationFlag = false;
public UUID getStrainId() { return strainId; }
public void setStrainId(UUID strainId) { this.strainId = strainId; }
public BigDecimal getQuantityGrams() { return quantityGrams; }
public void setQuantityGrams(BigDecimal quantityGrams) { this.quantityGrams = quantityGrams; }
public LocalDate getHarvestDate() { return harvestDate; }
public void setHarvestDate(LocalDate harvestDate) { this.harvestDate = harvestDate; }
public String getBatchCode() { return batchCode; }
public void setBatchCode(String batchCode) { this.batchCode = batchCode; }
public BatchStatus getStatus() { return status; }
public void setStatus(BatchStatus status) { this.status = status; }
public boolean isContaminationFlag() { return contaminationFlag; }
public void setContaminationFlag(boolean contaminationFlag) { this.contaminationFlag = contaminationFlag; }
}
@@ -0,0 +1,40 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.ClubStatus;
import jakarta.persistence.*;
@Entity
@Table(name = "clubs")
public class Club extends AbstractTenantEntity {
@Column(name = "name", nullable = false, length = 255)
private String name;
@Column(name = "address")
private String address;
@Column(name = "license_number", nullable = false, unique = true, length = 100)
private String licenseNumber;
@Column(name = "max_members", nullable = false)
private Integer maxMembers = 500;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 50)
private ClubStatus status = ClubStatus.ACTIVE;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getAddress() { return address; }
public void setAddress(String address) { this.address = address; }
public String getLicenseNumber() { return licenseNumber; }
public void setLicenseNumber(String licenseNumber) { this.licenseNumber = licenseNumber; }
public Integer getMaxMembers() { return maxMembers; }
public void setMaxMembers(Integer maxMembers) { this.maxMembers = maxMembers; }
public ClubStatus getStatus() { return status; }
public void setStatus(ClubStatus status) { this.status = status; }
}
@@ -0,0 +1,52 @@
package de.cannamanage.domain.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
/**
* Immutable distribution record — all fields are updatable=false.
* Required for CanG §26 record-keeping obligations.
*/
@Entity
@Table(name = "distributions")
public class Distribution extends AbstractTenantEntity {
@Column(name = "member_id", nullable = false, updatable = false)
private UUID memberId;
@Column(name = "batch_id", nullable = false, updatable = false)
private UUID batchId;
@Column(name = "quantity_grams", nullable = false, updatable = false, precision = 10, scale = 2)
private BigDecimal quantityGrams;
@Column(name = "distributed_at", nullable = false, updatable = false)
private Instant distributedAt;
@Column(name = "recorded_by", nullable = false, updatable = false)
private UUID recordedBy;
@Column(name = "notes", updatable = false)
private String notes;
public UUID getMemberId() { return memberId; }
public void setMemberId(UUID memberId) { this.memberId = memberId; }
public UUID getBatchId() { return batchId; }
public void setBatchId(UUID batchId) { this.batchId = batchId; }
public BigDecimal getQuantityGrams() { return quantityGrams; }
public void setQuantityGrams(BigDecimal quantityGrams) { this.quantityGrams = quantityGrams; }
public Instant getDistributedAt() { return distributedAt; }
public void setDistributedAt(Instant distributedAt) { this.distributedAt = distributedAt; }
public UUID getRecordedBy() { return recordedBy; }
public void setRecordedBy(UUID recordedBy) { this.recordedBy = recordedBy; }
public String getNotes() { return notes; }
public void setNotes(String notes) { this.notes = notes; }
}
@@ -0,0 +1,78 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.MemberStatus;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.util.UUID;
@Entity
@Table(name = "members",
uniqueConstraints = {
@UniqueConstraint(columnNames = {"email", "tenant_id"}),
@UniqueConstraint(columnNames = {"membership_number", "tenant_id"})
}
)
public class Member extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false)
private UUID clubId;
@Column(name = "first_name", nullable = false, length = 100)
private String firstName;
@Column(name = "last_name", nullable = false, length = 100)
private String lastName;
@Column(name = "email", nullable = false, length = 255)
private String email;
@Column(name = "date_of_birth", nullable = false)
private LocalDate dateOfBirth;
@Column(name = "membership_date", nullable = false)
private LocalDate membershipDate;
@Column(name = "membership_number", nullable = false, length = 50)
private String membershipNumber;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 50)
private MemberStatus status = MemberStatus.ACTIVE;
@Column(name = "is_under_21", nullable = false)
private boolean under21 = false;
@Column(name = "prevention_officer", nullable = false)
private boolean preventionOfficer = false;
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public String getFirstName() { return firstName; }
public void setFirstName(String firstName) { this.firstName = firstName; }
public String getLastName() { return lastName; }
public void setLastName(String lastName) { this.lastName = lastName; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public LocalDate getDateOfBirth() { return dateOfBirth; }
public void setDateOfBirth(LocalDate dateOfBirth) { this.dateOfBirth = dateOfBirth; }
public LocalDate getMembershipDate() { return membershipDate; }
public void setMembershipDate(LocalDate membershipDate) { this.membershipDate = membershipDate; }
public String getMembershipNumber() { return membershipNumber; }
public void setMembershipNumber(String membershipNumber) { this.membershipNumber = membershipNumber; }
public MemberStatus getStatus() { return status; }
public void setStatus(MemberStatus status) { this.status = status; }
public boolean isUnder21() { return under21; }
public void setUnder21(boolean under21) { this.under21 = under21; }
public boolean isPreventionOfficer() { return preventionOfficer; }
public void setPreventionOfficer(boolean preventionOfficer) { this.preventionOfficer = preventionOfficer; }
}
@@ -0,0 +1,57 @@
package de.cannamanage.domain.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.util.UUID;
/**
* Tracks monthly distribution totals per member per calendar month.
* One row per (member_id, year, month) — unique constraint enforced at DB level.
* @Version for optimistic locking — concurrent distribution processing must not corrupt totals.
*/
@Entity
@Table(name = "monthly_quotas",
uniqueConstraints = {
@UniqueConstraint(columnNames = {"member_id", "year", "month"})
}
)
public class MonthlyQuota extends AbstractTenantEntity {
@Column(name = "member_id", nullable = false)
private UUID memberId;
@Column(name = "year", nullable = false)
private Integer year;
@Column(name = "month", nullable = false)
private Integer month;
@Column(name = "total_distributed", nullable = false, precision = 10, scale = 2)
private BigDecimal totalDistributed = BigDecimal.ZERO;
@Column(name = "max_allowed", nullable = false, precision = 10, scale = 2)
private BigDecimal maxAllowed;
@Version
@Column(name = "version", nullable = false)
private Long version = 0L;
public UUID getMemberId() { return memberId; }
public void setMemberId(UUID memberId) { this.memberId = memberId; }
public Integer getYear() { return year; }
public void setYear(Integer year) { this.year = year; }
public Integer getMonth() { return month; }
public void setMonth(Integer month) { this.month = month; }
public BigDecimal getTotalDistributed() { return totalDistributed; }
public void setTotalDistributed(BigDecimal totalDistributed) { this.totalDistributed = totalDistributed; }
public BigDecimal getMaxAllowed() { return maxAllowed; }
public void setMaxAllowed(BigDecimal maxAllowed) { this.maxAllowed = maxAllowed; }
public Long getVersion() { return version; }
public void setVersion(Long version) { this.version = version; }
}
@@ -0,0 +1,37 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.StockMovementType;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.util.UUID;
@Entity
@Table(name = "stock_movements")
public class StockMovement extends AbstractTenantEntity {
@Column(name = "batch_id", nullable = false)
private UUID batchId;
@Enumerated(EnumType.STRING)
@Column(name = "movement_type", nullable = false, length = 50)
private StockMovementType movementType;
@Column(name = "quantity_grams", nullable = false, precision = 10, scale = 2)
private BigDecimal quantityGrams;
@Column(name = "reason")
private String reason;
public UUID getBatchId() { return batchId; }
public void setBatchId(UUID batchId) { this.batchId = batchId; }
public StockMovementType getMovementType() { return movementType; }
public void setMovementType(StockMovementType movementType) { this.movementType = movementType; }
public BigDecimal getQuantityGrams() { return quantityGrams; }
public void setQuantityGrams(BigDecimal quantityGrams) { this.quantityGrams = quantityGrams; }
public String getReason() { return reason; }
public void setReason(String reason) { this.reason = reason; }
}
@@ -0,0 +1,34 @@
package de.cannamanage.domain.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "strains")
public class Strain extends AbstractTenantEntity {
@Column(name = "name", nullable = false, length = 255)
private String name;
@Column(name = "thc_percentage", nullable = false, precision = 5, scale = 2)
private BigDecimal thcPercentage;
@Column(name = "cbd_percentage", nullable = false, precision = 5, scale = 2)
private BigDecimal cbdPercentage = BigDecimal.ZERO;
@Column(name = "description")
private String description;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public BigDecimal getThcPercentage() { return thcPercentage; }
public void setThcPercentage(BigDecimal thcPercentage) { this.thcPercentage = thcPercentage; }
public BigDecimal getCbdPercentage() { return cbdPercentage; }
public void setCbdPercentage(BigDecimal cbdPercentage) { this.cbdPercentage = cbdPercentage; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
}
@@ -0,0 +1,27 @@
package de.cannamanage.domain.entity;
import java.util.UUID;
/**
* ThreadLocal holder for the current tenant's UUID.
* Must be set at the start of each request (e.g., via a servlet filter or Spring interceptor)
* and cleared at the end to prevent tenant leakage.
*/
public final class TenantContext {
private static final ThreadLocal<UUID> CURRENT_TENANT = new ThreadLocal<>();
private TenantContext() {}
public static UUID getCurrentTenant() {
return CURRENT_TENANT.get();
}
public static void setCurrentTenant(UUID tenantId) {
CURRENT_TENANT.set(tenantId);
}
public static void clear() {
CURRENT_TENANT.remove();
}
}
@@ -0,0 +1,59 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.UserRole;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "users",
uniqueConstraints = {
@UniqueConstraint(columnNames = {"email", "tenant_id"})
}
)
public class User extends AbstractTenantEntity {
@Column(name = "member_id")
private UUID memberId;
@Column(name = "email", nullable = false, length = 255)
private String email;
@Column(name = "password_hash", nullable = false, length = 255)
private String passwordHash;
@Enumerated(EnumType.STRING)
@Column(name = "role", nullable = false, length = 50)
private UserRole role = UserRole.ROLE_MEMBER;
@Column(name = "last_login")
private Instant lastLogin;
@Column(name = "active", nullable = false)
private boolean active = true;
@Column(name = "refresh_token_hash", length = 255)
private String refreshTokenHash;
public UUID getMemberId() { return memberId; }
public void setMemberId(UUID memberId) { this.memberId = memberId; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPasswordHash() { return passwordHash; }
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
public UserRole getRole() { return role; }
public void setRole(UserRole role) { this.role = role; }
public Instant getLastLogin() { return lastLogin; }
public void setLastLogin(Instant lastLogin) { this.lastLogin = lastLogin; }
public boolean isActive() { return active; }
public void setActive(boolean active) { this.active = active; }
public String getRefreshTokenHash() { return refreshTokenHash; }
public void setRefreshTokenHash(String refreshTokenHash) { this.refreshTokenHash = refreshTokenHash; }
}
@@ -0,0 +1,8 @@
package de.cannamanage.domain.enums;
public enum BatchStatus {
AVAILABLE,
EXHAUSTED,
RECALLED,
QUARANTINED
}
@@ -0,0 +1,8 @@
package de.cannamanage.domain.enums;
public enum ClubStatus {
ACTIVE,
SUSPENDED,
REVOKED,
PENDING
}
@@ -0,0 +1,9 @@
package de.cannamanage.domain.enums;
public enum MemberStatus {
ACTIVE,
SUSPENDED,
EXPELLED,
PENDING_APPROVAL,
RESIGNED
}
@@ -0,0 +1,8 @@
package de.cannamanage.domain.enums;
public enum StockMovementType {
IN,
OUT,
RECALL,
ADJUSTMENT
}
@@ -0,0 +1,8 @@
package de.cannamanage.domain.enums;
public enum UserRole {
ROLE_ADMIN,
ROLE_MANAGER,
ROLE_MEMBER,
ROLE_PREVENTION_OFFICER
}