commit fa1eaf64e0eca1cea54c47b34a81010b5c87b33d Author: Patrick Plate Date: Sun Apr 12 20:30:12 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d78a8f --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +target/ +*.class +*.jar +*.war +.idea/ +*.iml +.DS_Store +*.swp +.mvn/wrapper/maven-wrapper.jar diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..7c6b218 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip diff --git a/cannamanage-api/pom.xml b/cannamanage-api/pom.xml new file mode 100644 index 0000000..33c8016 --- /dev/null +++ b/cannamanage-api/pom.xml @@ -0,0 +1,72 @@ + + + 4.0.0 + + + de.cannamanage + cannamanage-parent + 1.0.0-SNAPSHOT + ../pom.xml + + + cannamanage-api + CannaManage — API (Spring Boot Entry Point) + + + + de.cannamanage + cannamanage-domain + + + de.cannamanage + cannamanage-service + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.postgresql + postgresql + runtime + + + org.flywaydb + flyway-database-postgresql + runtime + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/CannaManageApplication.java b/cannamanage-api/src/main/java/de/cannamanage/api/CannaManageApplication.java new file mode 100644 index 0000000..68ec08b --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/CannaManageApplication.java @@ -0,0 +1,21 @@ +package de.cannamanage.api; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * CannaManage Spring Boot application entry point. + * REST controllers are deferred to Sprint 2. + * Sprint 1 focus: compliance engine validation only. + */ +@SpringBootApplication(scanBasePackages = "de.cannamanage") +@EntityScan(basePackages = "de.cannamanage.domain.entity") +@EnableJpaRepositories(basePackages = "de.cannamanage.service.repository") +public class CannaManageApplication { + + public static void main(String[] args) { + SpringApplication.run(CannaManageApplication.class, args); + } +} diff --git a/cannamanage-api/src/main/resources/application-local.properties b/cannamanage-api/src/main/resources/application-local.properties new file mode 100644 index 0000000..14926d5 --- /dev/null +++ b/cannamanage-api/src/main/resources/application-local.properties @@ -0,0 +1,8 @@ +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 +spring.flyway.enabled=true +spring.flyway.locations=classpath:db/migration +logging.level.de.cannamanage=DEBUG +logging.level.org.flywaydb=INFO diff --git a/cannamanage-api/src/main/resources/application.properties b/cannamanage-api/src/main/resources/application.properties new file mode 100644 index 0000000..490d972 --- /dev/null +++ b/cannamanage-api/src/main/resources/application.properties @@ -0,0 +1,4 @@ +spring.application.name=cannamanage +# Default profile — override with -Dspring.profiles.active=local +spring.jpa.hibernate.ddl-auto=validate +spring.flyway.enabled=false diff --git a/cannamanage-api/src/main/resources/db/migration/V1__initial_schema.sql b/cannamanage-api/src/main/resources/db/migration/V1__initial_schema.sql new file mode 100644 index 0000000..ee0b54f --- /dev/null +++ b/cannamanage-api/src/main/resources/db/migration/V1__initial_schema.sql @@ -0,0 +1,120 @@ +-- CannaManage V1 Initial Schema +-- Implements all tables required for CanG §19 compliance tracking + +-- 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 — append-only for CanG §26 compliance) +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, + quantity_grams NUMERIC(10,2) NOT NULL, + reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Users (login identities — Sprint 2 auth) +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) +); + +-- Performance indexes +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); diff --git a/cannamanage-domain/pom.xml b/cannamanage-domain/pom.xml new file mode 100644 index 0000000..ee6e5b1 --- /dev/null +++ b/cannamanage-domain/pom.xml @@ -0,0 +1,41 @@ + + + 4.0.0 + + + de.cannamanage + cannamanage-parent + 1.0.0-SNAPSHOT + ../pom.xml + + + cannamanage-domain + CannaManage — Domain (JPA Entities) + + + + + jakarta.persistence + jakarta.persistence-api + + + + org.hibernate.orm + hibernate-core + + + + jakarta.validation + jakarta.validation-api + + + + org.projectlombok + lombok + true + + + + diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/constants/ComplianceConstants.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/constants/ComplianceConstants.java new file mode 100644 index 0000000..320439c --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/constants/ComplianceConstants.java @@ -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 + } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/AbstractTenantEntity.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/AbstractTenantEntity.java new file mode 100644 index 0000000..0696499 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/AbstractTenantEntity.java @@ -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; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Batch.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Batch.java new file mode 100644 index 0000000..e9cea7f --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Batch.java @@ -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; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Club.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Club.java new file mode 100644 index 0000000..27d74b4 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Club.java @@ -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; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Distribution.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Distribution.java new file mode 100644 index 0000000..d29b0f9 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Distribution.java @@ -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; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Member.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Member.java new file mode 100644 index 0000000..1cabbeb --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Member.java @@ -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; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/MonthlyQuota.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/MonthlyQuota.java new file mode 100644 index 0000000..448f4a2 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/MonthlyQuota.java @@ -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; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/StockMovement.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/StockMovement.java new file mode 100644 index 0000000..1ec7e6b --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/StockMovement.java @@ -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; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Strain.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Strain.java new file mode 100644 index 0000000..c237644 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Strain.java @@ -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; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/TenantContext.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/TenantContext.java new file mode 100644 index 0000000..b5edb0d --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/TenantContext.java @@ -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 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(); + } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/User.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/User.java new file mode 100644 index 0000000..5771099 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/User.java @@ -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; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/BatchStatus.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/BatchStatus.java new file mode 100644 index 0000000..f5662ce --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/BatchStatus.java @@ -0,0 +1,8 @@ +package de.cannamanage.domain.enums; + +public enum BatchStatus { + AVAILABLE, + EXHAUSTED, + RECALLED, + QUARANTINED +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/ClubStatus.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/ClubStatus.java new file mode 100644 index 0000000..2f9e4df --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/ClubStatus.java @@ -0,0 +1,8 @@ +package de.cannamanage.domain.enums; + +public enum ClubStatus { + ACTIVE, + SUSPENDED, + REVOKED, + PENDING +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/MemberStatus.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/MemberStatus.java new file mode 100644 index 0000000..4726272 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/MemberStatus.java @@ -0,0 +1,9 @@ +package de.cannamanage.domain.enums; + +public enum MemberStatus { + ACTIVE, + SUSPENDED, + EXPELLED, + PENDING_APPROVAL, + RESIGNED +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/StockMovementType.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/StockMovementType.java new file mode 100644 index 0000000..d7e78c4 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/StockMovementType.java @@ -0,0 +1,8 @@ +package de.cannamanage.domain.enums; + +public enum StockMovementType { + IN, + OUT, + RECALL, + ADJUSTMENT +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/UserRole.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/UserRole.java new file mode 100644 index 0000000..b29e1d4 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/UserRole.java @@ -0,0 +1,8 @@ +package de.cannamanage.domain.enums; + +public enum UserRole { + ROLE_ADMIN, + ROLE_MANAGER, + ROLE_MEMBER, + ROLE_PREVENTION_OFFICER +} diff --git a/cannamanage-service/pom.xml b/cannamanage-service/pom.xml new file mode 100644 index 0000000..f9e78e9 --- /dev/null +++ b/cannamanage-service/pom.xml @@ -0,0 +1,103 @@ + + + 4.0.0 + + + de.cannamanage + cannamanage-parent + 1.0.0-SNAPSHOT + ../pom.xml + + + cannamanage-service + CannaManage — Service (Business Logic) + + + + + de.cannamanage + cannamanage-domain + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + org.springframework + spring-tx + + + + org.projectlombok + lombok + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.assertj + assertj-core + test + + + + + + + org.jacoco + jacoco-maven-plugin + + + prepare-agent + + prepare-agent + + + + report + test + + report + + + + check + + check + + + + + CLASS + + de.cannamanage.service.ComplianceService + + + + LINE + COVEREDRATIO + 1.00 + + + BRANCH + COVEREDRATIO + 1.00 + + + + + + + + + + + diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/ComplianceService.java b/cannamanage-service/src/main/java/de/cannamanage/service/ComplianceService.java new file mode 100644 index 0000000..e1e79af --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/ComplianceService.java @@ -0,0 +1,210 @@ +package de.cannamanage.service; + +import de.cannamanage.domain.constants.ComplianceConstants; +import de.cannamanage.domain.entity.Batch; +import de.cannamanage.domain.entity.Member; +import de.cannamanage.domain.entity.MonthlyQuota; +import de.cannamanage.domain.entity.Strain; +import de.cannamanage.domain.enums.BatchStatus; +import de.cannamanage.domain.enums.MemberStatus; +import de.cannamanage.service.dto.ComplianceCheckResult; +import de.cannamanage.service.dto.QuotaStatus; +import de.cannamanage.service.exception.BatchNotFoundException; +import de.cannamanage.service.exception.MemberNotFoundException; +import de.cannamanage.service.exception.QuotaExceededException; +import de.cannamanage.service.exception.QuotaViolationCode; +import de.cannamanage.service.repository.BatchRepository; +import de.cannamanage.service.repository.DistributionRepository; +import de.cannamanage.service.repository.MemberRepository; +import de.cannamanage.service.repository.MonthlyQuotaRepository; +import de.cannamanage.service.repository.StrainRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.UUID; + +/** + * Core compliance engine implementing CanG §19 distribution rules. + * All checks are fail-fast — the first violation throws immediately. + */ +@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; + + public ComplianceService( + MemberRepository memberRepository, + DistributionRepository distributionRepository, + BatchRepository batchRepository, + MonthlyQuotaRepository monthlyQuotaRepository, + StrainRepository strainRepository) { + this.memberRepository = memberRepository; + this.distributionRepository = distributionRepository; + this.batchRepository = batchRepository; + this.monthlyQuotaRepository = monthlyQuotaRepository; + this.strainRepository = strainRepository; + } + + /** + * Checks whether a distribution is permitted under CanG §19. + * Sequential fail-fast checks in legally mandated order. + */ + public ComplianceCheckResult checkDistributionAllowed( + UUID memberId, UUID batchId, BigDecimal quantityGrams) { + + // 1. Load member + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberNotFoundException(memberId)); + + // 2. Member must be ACTIVE + if (member.getStatus() != MemberStatus.ACTIVE) { + throw new QuotaExceededException( + QuotaViolationCode.MEMBER_INACTIVE, + "Member " + memberId + " is not active (status=" + member.getStatus() + ")"); + } + + // 3. Batch must be AVAILABLE + Batch batch = batchRepository.findById(batchId) + .orElseThrow(() -> new BatchNotFoundException(batchId)); + + if (batch.getStatus() != BatchStatus.AVAILABLE) { + throw new QuotaExceededException( + QuotaViolationCode.BATCH_UNAVAILABLE, + "Batch " + batchId + " is not available (status=" + batch.getStatus() + ")"); + } + + // 4. Load strain + Strain strain = strainRepository.findById(batch.getStrainId()) + .orElseThrow(() -> new BatchNotFoundException(batch.getStrainId())); + + // 5. Under-21 THC restriction (CanG §19(4)) + if (member.isUnder21() && + strain.getThcPercentage().compareTo(ComplianceConstants.UNDER21_MAX_THC_PERCENTAGE) > 0) { + throw new QuotaExceededException( + QuotaViolationCode.HIGH_THC_RESTRICTED_UNDER_21, + "Under-21 member cannot receive strain with THC% > " + + ComplianceConstants.UNDER21_MAX_THC_PERCENTAGE); + } + + // 6. Daily limit check (CanG §19(2)) + LocalDate today = LocalDate.now(ZoneOffset.UTC); + Instant dayStart = today.atStartOfDay(ZoneOffset.UTC).toInstant(); + Instant dayEnd = today.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant(); + + BigDecimal todayDistributed = distributionRepository + .sumQuantityByMemberAndDay(memberId, dayStart, dayEnd); + + BigDecimal dailyLimit = ComplianceConstants.ADULT_DAILY_LIMIT_GRAMS; + if (todayDistributed.add(quantityGrams).compareTo(dailyLimit) > 0) { + throw new QuotaExceededException( + QuotaViolationCode.QUOTA_EXCEEDED_DAILY, + "Daily limit of " + dailyLimit + "g would be exceeded. " + + "Already distributed today: " + todayDistributed + "g"); + } + + // 7. Monthly limit check (CanG §19(2)/(3)) + int year = today.getYear(); + int month = today.getMonthValue(); + + BigDecimal monthlyLimit = member.isUnder21() + ? ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS + : ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS; + + MonthlyQuota quota = monthlyQuotaRepository + .findByMemberIdAndYearAndMonth(memberId, year, month) + .orElseGet(() -> createNewQuota(memberId, year, month, monthlyLimit)); + + if (quota.getTotalDistributed().add(quantityGrams).compareTo(quota.getMaxAllowed()) > 0) { + throw new QuotaExceededException( + QuotaViolationCode.QUOTA_EXCEEDED_MONTHLY, + "Monthly limit of " + quota.getMaxAllowed() + "g would be exceeded. " + + "Already distributed this month: " + quota.getTotalDistributed() + "g"); + } + + // 8. All checks passed + BigDecimal remainingDaily = dailyLimit + .subtract(todayDistributed) + .subtract(quantityGrams); + BigDecimal remainingMonthly = quota.getMaxAllowed() + .subtract(quota.getTotalDistributed()) + .subtract(quantityGrams); + + return new ComplianceCheckResult(true, remainingDaily, remainingMonthly, member.isUnder21()); + } + + @Transactional(readOnly = true) + public QuotaStatus getQuotaStatus(UUID memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberNotFoundException(memberId)); + + LocalDate today = LocalDate.now(ZoneOffset.UTC); + int year = today.getYear(); + int month = today.getMonthValue(); + + BigDecimal monthlyLimit = member.isUnder21() + ? ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS + : ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS; + + MonthlyQuota quota = monthlyQuotaRepository + .findByMemberIdAndYearAndMonth(memberId, year, month) + .orElseGet(() -> { + MonthlyQuota empty = new MonthlyQuota(); + empty.setTotalDistributed(BigDecimal.ZERO); + empty.setMaxAllowed(monthlyLimit); + return empty; + }); + + BigDecimal remaining = quota.getMaxAllowed().subtract(quota.getTotalDistributed()); + + return new QuotaStatus( + quota.getMaxAllowed(), + quota.getTotalDistributed(), + remaining, + member.isUnder21(), + year, + month + ); + } + + public void validateMembershipAge(LocalDate dateOfBirth) { + LocalDate today = LocalDate.now(ZoneOffset.UTC); + int age = today.getYear() - dateOfBirth.getYear(); + if (today.getDayOfYear() < dateOfBirth.getDayOfYear()) { + age--; + } + if (age < ComplianceConstants.MINIMUM_MEMBERSHIP_AGE) { + throw new QuotaExceededException( + QuotaViolationCode.MEMBER_INACTIVE, + "Applicant is under minimum membership age of " + + ComplianceConstants.MINIMUM_MEMBERSHIP_AGE); + } + } + + public boolean isUnder21(LocalDate dateOfBirth) { + LocalDate today = LocalDate.now(ZoneOffset.UTC); + int age = today.getYear() - dateOfBirth.getYear(); + if (today.getDayOfYear() < dateOfBirth.getDayOfYear()) { + age--; + } + return age < ComplianceConstants.UNDER21_THRESHOLD_AGE; + } + + private MonthlyQuota createNewQuota(UUID memberId, int year, int month, BigDecimal maxAllowed) { + MonthlyQuota quota = new MonthlyQuota(); + quota.setMemberId(memberId); + quota.setYear(year); + quota.setMonth(month); + quota.setTotalDistributed(BigDecimal.ZERO); + quota.setMaxAllowed(maxAllowed); + return monthlyQuotaRepository.save(quota); + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/dto/ComplianceCheckResult.java b/cannamanage-service/src/main/java/de/cannamanage/service/dto/ComplianceCheckResult.java new file mode 100644 index 0000000..99d0887 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/dto/ComplianceCheckResult.java @@ -0,0 +1,15 @@ +package de.cannamanage.service.dto; + +import java.math.BigDecimal; + +/** + * Result of a compliance check for a potential cannabis distribution. + * When {@code allowed} is true, {@code remainingDaily} and {@code remainingMonthly} + * reflect the quota after the requested distribution would be applied. + */ +public record ComplianceCheckResult( + boolean allowed, + BigDecimal remainingDaily, + BigDecimal remainingMonthly, + boolean isUnder21 +) {} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/dto/QuotaStatus.java b/cannamanage-service/src/main/java/de/cannamanage/service/dto/QuotaStatus.java new file mode 100644 index 0000000..a59af47 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/dto/QuotaStatus.java @@ -0,0 +1,15 @@ +package de.cannamanage.service.dto; + +import java.math.BigDecimal; + +/** + * Current quota status for a member in a given calendar month. + */ +public record QuotaStatus( + BigDecimal totalAllowed, + BigDecimal totalUsed, + BigDecimal remaining, + boolean isUnder21, + int year, + int month +) {} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/exception/BatchNotFoundException.java b/cannamanage-service/src/main/java/de/cannamanage/service/exception/BatchNotFoundException.java new file mode 100644 index 0000000..b3b3df0 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/exception/BatchNotFoundException.java @@ -0,0 +1,10 @@ +package de.cannamanage.service.exception; + +import java.util.UUID; + +public class BatchNotFoundException extends RuntimeException { + + public BatchNotFoundException(UUID batchId) { + super("Batch not found: " + batchId); + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/exception/MemberNotFoundException.java b/cannamanage-service/src/main/java/de/cannamanage/service/exception/MemberNotFoundException.java new file mode 100644 index 0000000..e104650 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/exception/MemberNotFoundException.java @@ -0,0 +1,10 @@ +package de.cannamanage.service.exception; + +import java.util.UUID; + +public class MemberNotFoundException extends RuntimeException { + + public MemberNotFoundException(UUID memberId) { + super("Member not found: " + memberId); + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/exception/QuotaExceededException.java b/cannamanage-service/src/main/java/de/cannamanage/service/exception/QuotaExceededException.java new file mode 100644 index 0000000..7b09b6a --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/exception/QuotaExceededException.java @@ -0,0 +1,20 @@ +package de.cannamanage.service.exception; + +/** + * Thrown when a cannabis distribution would violate a CanG compliance rule. + * The {@code code} field identifies which specific rule was violated, + * enabling the API layer to return structured error responses. + */ +public class QuotaExceededException extends RuntimeException { + + private final QuotaViolationCode code; + + public QuotaExceededException(QuotaViolationCode code, String message) { + super(message); + this.code = code; + } + + public QuotaViolationCode getCode() { + return code; + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/exception/QuotaViolationCode.java b/cannamanage-service/src/main/java/de/cannamanage/service/exception/QuotaViolationCode.java new file mode 100644 index 0000000..1516dfa --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/exception/QuotaViolationCode.java @@ -0,0 +1,9 @@ +package de.cannamanage.service.exception; + +public enum QuotaViolationCode { + MEMBER_INACTIVE, + QUOTA_EXCEEDED_DAILY, + QUOTA_EXCEEDED_MONTHLY, + HIGH_THC_RESTRICTED_UNDER_21, + BATCH_UNAVAILABLE +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/BatchRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/BatchRepository.java new file mode 100644 index 0000000..4ccaf64 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/BatchRepository.java @@ -0,0 +1,11 @@ +package de.cannamanage.service.repository; + +import de.cannamanage.domain.entity.Batch; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface BatchRepository extends JpaRepository { +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/DistributionRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/DistributionRepository.java new file mode 100644 index 0000000..128fe62 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/DistributionRepository.java @@ -0,0 +1,23 @@ +package de.cannamanage.service.repository; + +import de.cannamanage.domain.entity.Distribution; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +@Repository +public interface DistributionRepository extends JpaRepository { + + @Query("SELECT COALESCE(SUM(d.quantityGrams), 0) FROM Distribution d " + + "WHERE d.memberId = :memberId AND d.distributedAt >= :dayStart AND d.distributedAt < :dayEnd") + BigDecimal sumQuantityByMemberAndDay( + @Param("memberId") UUID memberId, + @Param("dayStart") Instant dayStart, + @Param("dayEnd") Instant dayEnd + ); +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/MemberRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/MemberRepository.java new file mode 100644 index 0000000..6a04ff5 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/MemberRepository.java @@ -0,0 +1,11 @@ +package de.cannamanage.service.repository; + +import de.cannamanage.domain.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface MemberRepository extends JpaRepository { +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/MonthlyQuotaRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/MonthlyQuotaRepository.java new file mode 100644 index 0000000..57065a1 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/MonthlyQuotaRepository.java @@ -0,0 +1,14 @@ +package de.cannamanage.service.repository; + +import de.cannamanage.domain.entity.MonthlyQuota; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface MonthlyQuotaRepository extends JpaRepository { + + Optional findByMemberIdAndYearAndMonth(UUID memberId, int year, int month); +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/StrainRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/StrainRepository.java new file mode 100644 index 0000000..e645d85 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/StrainRepository.java @@ -0,0 +1,11 @@ +package de.cannamanage.service.repository; + +import de.cannamanage.domain.entity.Strain; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface StrainRepository extends JpaRepository { +} diff --git a/cannamanage-service/src/test/java/de/cannamanage/service/ComplianceServiceTest.java b/cannamanage-service/src/test/java/de/cannamanage/service/ComplianceServiceTest.java new file mode 100644 index 0000000..f5f3cf5 --- /dev/null +++ b/cannamanage-service/src/test/java/de/cannamanage/service/ComplianceServiceTest.java @@ -0,0 +1,467 @@ +package de.cannamanage.service; + +import de.cannamanage.domain.constants.ComplianceConstants; +import de.cannamanage.domain.entity.Batch; +import de.cannamanage.domain.entity.Member; +import de.cannamanage.domain.entity.MonthlyQuota; +import de.cannamanage.domain.entity.Strain; +import de.cannamanage.domain.enums.BatchStatus; +import de.cannamanage.domain.enums.MemberStatus; +import de.cannamanage.service.exception.MemberNotFoundException; +import de.cannamanage.service.exception.QuotaExceededException; +import de.cannamanage.service.exception.QuotaViolationCode; +import de.cannamanage.service.repository.BatchRepository; +import de.cannamanage.service.repository.DistributionRepository; +import de.cannamanage.service.repository.MemberRepository; +import de.cannamanage.service.repository.MonthlyQuotaRepository; +import de.cannamanage.service.repository.StrainRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ComplianceServiceTest { + + @Mock MemberRepository memberRepository; + @Mock DistributionRepository distributionRepository; + @Mock BatchRepository batchRepository; + @Mock MonthlyQuotaRepository monthlyQuotaRepository; + @Mock StrainRepository strainRepository; + + @InjectMocks ComplianceService complianceService; + + private static final UUID ADULT_MEMBER_ID = UUID.fromString("11111111-1111-1111-1111-111111111111"); + private static final UUID UNDER21_MEMBER_ID = UUID.fromString("22222222-2222-2222-2222-222222222222"); + private static final UUID BATCH_ID = UUID.fromString("33333333-3333-3333-3333-333333333333"); + private static final UUID STRAIN_ID = UUID.fromString("44444444-4444-4444-4444-444444444444"); + private static final UUID HIGH_THC_STRAIN_ID = UUID.fromString("55555555-5555-5555-5555-555555555555"); + + private Member adultMember; + private Member under21Member; + private Batch availableBatch; + private Strain normalStrain; + private Strain highThcStrain; + + @BeforeEach + void setUp() { + adultMember = new Member(); + adultMember.setStatus(MemberStatus.ACTIVE); + adultMember.setUnder21(false); + + under21Member = new Member(); + under21Member.setStatus(MemberStatus.ACTIVE); + under21Member.setUnder21(true); + + normalStrain = new Strain(); + normalStrain.setThcPercentage(new BigDecimal("8.0")); + normalStrain.setCbdPercentage(new BigDecimal("2.0")); + + highThcStrain = new Strain(); + highThcStrain.setThcPercentage(new BigDecimal("22.0")); + highThcStrain.setCbdPercentage(BigDecimal.ZERO); + + availableBatch = new Batch(); + availableBatch.setStatus(BatchStatus.AVAILABLE); + availableBatch.setStrainId(STRAIN_ID); + } + + // TC-001: Adult at monthly limit → QUOTA_EXCEEDED_MONTHLY + @Test + @DisplayName("TC-001: Adult at 50g monthly usage → QUOTA_EXCEEDED_MONTHLY") + void tc001_adultAtMonthlyLimit() { + when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember)); + when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch)); + when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain)); + when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class))) + .thenReturn(BigDecimal.ZERO); + MonthlyQuota quota = buildQuota("50.0", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS); + when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt())) + .thenReturn(Optional.of(quota)); + + assertThatThrownBy(() -> + complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("1.0"))) + .isInstanceOf(QuotaExceededException.class) + .extracting("code").isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_MONTHLY); + } + + // TC-002: Under-21 at monthly limit → QUOTA_EXCEEDED_MONTHLY + @Test + @DisplayName("TC-002: Under-21 at 30g monthly usage → QUOTA_EXCEEDED_MONTHLY") + void tc002_under21AtMonthlyLimit() { + when(memberRepository.findById(UNDER21_MEMBER_ID)).thenReturn(Optional.of(under21Member)); + when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch)); + when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain)); + when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class))) + .thenReturn(BigDecimal.ZERO); + MonthlyQuota quota = buildQuota("30.0", ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS); + when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt())) + .thenReturn(Optional.of(quota)); + + assertThatThrownBy(() -> + complianceService.checkDistributionAllowed(UNDER21_MEMBER_ID, BATCH_ID, new BigDecimal("1.0"))) + .isInstanceOf(QuotaExceededException.class) + .extracting("code").isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_MONTHLY); + } + + // TC-003: Adult at daily limit → QUOTA_EXCEEDED_DAILY + @Test + @DisplayName("TC-003: Adult at 25g today requesting 1g → QUOTA_EXCEEDED_DAILY") + void tc003_adultAtDailyLimit() { + when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember)); + when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch)); + when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain)); + when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class))) + .thenReturn(new BigDecimal("25.0")); + + assertThatThrownBy(() -> + complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("1.0"))) + .isInstanceOf(QuotaExceededException.class) + .extracting("code").isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_DAILY); + } + + // TC-004: Under-21 + high THC → HIGH_THC_RESTRICTED_UNDER_21 + @Test + @DisplayName("TC-004: Under-21 + 22% THC batch → HIGH_THC_RESTRICTED_UNDER_21") + void tc004_under21HighThcStrain() { + Batch highThcBatch = new Batch(); + highThcBatch.setStatus(BatchStatus.AVAILABLE); + highThcBatch.setStrainId(HIGH_THC_STRAIN_ID); + + when(memberRepository.findById(UNDER21_MEMBER_ID)).thenReturn(Optional.of(under21Member)); + when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(highThcBatch)); + when(strainRepository.findById(HIGH_THC_STRAIN_ID)).thenReturn(Optional.of(highThcStrain)); + + assertThatThrownBy(() -> + complianceService.checkDistributionAllowed(UNDER21_MEMBER_ID, BATCH_ID, new BigDecimal("5.0"))) + .isInstanceOf(QuotaExceededException.class) + .extracting("code").isEqualTo(QuotaViolationCode.HIGH_THC_RESTRICTED_UNDER_21); + } + + // TC-005: Adult at 49g requesting 2g → QUOTA_EXCEEDED_MONTHLY (boundary) + @Test + @DisplayName("TC-005: Adult at 49g monthly requesting 2g → QUOTA_EXCEEDED_MONTHLY (boundary)") + void tc005_adultAt49gRequesting2g() { + when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember)); + when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch)); + when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain)); + when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class))) + .thenReturn(BigDecimal.ZERO); + MonthlyQuota quota = buildQuota("49.0", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS); + when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt())) + .thenReturn(Optional.of(quota)); + + assertThatThrownBy(() -> + complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("2.0"))) + .isInstanceOf(QuotaExceededException.class) + .extracting("code").isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_MONTHLY); + } + + // TC-006: Adult at 0g requesting 25g → allowed, remainingDaily=0 + @Test + @DisplayName("TC-006: Adult at 0g requesting 25g → allowed, remainingDaily=0") + void tc006_adultAt0gRequesting25g_allowed() { + when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember)); + when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch)); + when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain)); + when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class))) + .thenReturn(BigDecimal.ZERO); + MonthlyQuota quota = buildQuota("0.0", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS); + when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt())) + .thenReturn(Optional.of(quota)); + + var result = complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("25.0")); + + assertThat(result.allowed()).isTrue(); + assertThat(result.remainingDaily()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(result.remainingMonthly()).isEqualByComparingTo(new BigDecimal("25.0")); + assertThat(result.isUnder21()).isFalse(); + } + + // TC-007: Adult at 24.9g today requesting 0.1g → allowed, remainingDaily=0 + @Test + @DisplayName("TC-007: Adult at 24.9g today requesting 0.1g → allowed, remainingDaily=0") + void tc007_adultAt24dot9gRequesting0dot1g_allowed() { + when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember)); + when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch)); + when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain)); + when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class))) + .thenReturn(new BigDecimal("24.9")); + MonthlyQuota quota = buildQuota("24.9", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS); + when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt())) + .thenReturn(Optional.of(quota)); + + var result = complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("0.1")); + + assertThat(result.allowed()).isTrue(); + assertThat(result.remainingDaily()).isEqualByComparingTo(BigDecimal.ZERO); + } + + // TC-008: Adult at 24.9g today requesting 0.2g → QUOTA_EXCEEDED_DAILY (boundary) + @Test + @DisplayName("TC-008: Adult at 24.9g today requesting 0.2g → QUOTA_EXCEEDED_DAILY (boundary)") + void tc008_adultAt24dot9gRequesting0dot2g_dailyExceeded() { + when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember)); + when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch)); + when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain)); + when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class))) + .thenReturn(new BigDecimal("24.9")); + + assertThatThrownBy(() -> + complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("0.2"))) + .isInstanceOf(QuotaExceededException.class) + .extracting("code").isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_DAILY); + } + + // TC-009: SUSPENDED member → MEMBER_INACTIVE + @Test + @DisplayName("TC-009: SUSPENDED member → MEMBER_INACTIVE") + void tc009_suspendedMember() { + Member suspended = new Member(); + suspended.setStatus(MemberStatus.SUSPENDED); + when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(suspended)); + + assertThatThrownBy(() -> + complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("5.0"))) + .isInstanceOf(QuotaExceededException.class) + .extracting("code").isEqualTo(QuotaViolationCode.MEMBER_INACTIVE); + } + + // TC-010: EXPELLED member → MEMBER_INACTIVE + @Test + @DisplayName("TC-010: EXPELLED member → MEMBER_INACTIVE") + void tc010_expelledMember() { + Member expelled = new Member(); + expelled.setStatus(MemberStatus.EXPELLED); + when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(expelled)); + + assertThatThrownBy(() -> + complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("5.0"))) + .isInstanceOf(QuotaExceededException.class) + .extracting("code").isEqualTo(QuotaViolationCode.MEMBER_INACTIVE); + } + + // TC-011: No existing quota → creates new quota (createNewQuota path) + @Test + @DisplayName("TC-011: No existing quota → creates new quota, distribution allowed") + void tc011_noExistingQuota_createsNewAndAllows() { + when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember)); + when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch)); + when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain)); + when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class))) + .thenReturn(BigDecimal.ZERO); + // Return empty — triggers createNewQuota + when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt())) + .thenReturn(Optional.empty()); + MonthlyQuota newQuota = buildQuota("0.0", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS); + when(monthlyQuotaRepository.save(any())).thenReturn(newQuota); + + var result = complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("10.0")); + + assertThat(result.allowed()).isTrue(); + assertThat(result.isUnder21()).isFalse(); + } + + // TC-012: Under-21 no existing quota → creates quota with under-21 limit + @Test + @DisplayName("TC-012: Under-21 no existing quota → creates quota with 30g limit") + void tc012_under21NoExistingQuota_createsNewWithUnder21Limit() { + when(memberRepository.findById(UNDER21_MEMBER_ID)).thenReturn(Optional.of(under21Member)); + when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch)); + when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain)); + when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class))) + .thenReturn(BigDecimal.ZERO); + when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt())) + .thenReturn(Optional.empty()); + MonthlyQuota newQuota = buildQuota("0.0", ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS); + when(monthlyQuotaRepository.save(any())).thenReturn(newQuota); + + var result = complianceService.checkDistributionAllowed(UNDER21_MEMBER_ID, BATCH_ID, new BigDecimal("5.0")); + + assertThat(result.allowed()).isTrue(); + assertThat(result.isUnder21()).isTrue(); + } + + // TC-013: BATCH_UNAVAILABLE — batch is RECALLED + @Test + @DisplayName("TC-013: RECALLED batch → BATCH_UNAVAILABLE") + void tc013_recalledBatch() { + Batch recalled = new Batch(); + recalled.setStatus(BatchStatus.RECALLED); + recalled.setStrainId(STRAIN_ID); + + when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember)); + when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(recalled)); + + assertThatThrownBy(() -> + complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("5.0"))) + .isInstanceOf(QuotaExceededException.class) + .extracting("code").isEqualTo(QuotaViolationCode.BATCH_UNAVAILABLE); + } + + // TC-014: getQuotaStatus — adult member with existing quota + @Test + @DisplayName("TC-014: getQuotaStatus — adult with existing quota returns correct status") + void tc014_getQuotaStatus_adultWithExistingQuota() { + when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember)); + MonthlyQuota quota = buildQuota("20.0", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS); + when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt())) + .thenReturn(Optional.of(quota)); + + var status = complianceService.getQuotaStatus(ADULT_MEMBER_ID); + + assertThat(status.totalAllowed()).isEqualByComparingTo(ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS); + assertThat(status.totalUsed()).isEqualByComparingTo(new BigDecimal("20.0")); + assertThat(status.remaining()).isEqualByComparingTo(new BigDecimal("30.0")); + assertThat(status.isUnder21()).isFalse(); + } + + // TC-015: getQuotaStatus — under-21 with no existing quota + @Test + @DisplayName("TC-015: getQuotaStatus — under-21 with no quota returns 30g limit") + void tc015_getQuotaStatus_under21NoExistingQuota() { + when(memberRepository.findById(UNDER21_MEMBER_ID)).thenReturn(Optional.of(under21Member)); + when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt())) + .thenReturn(Optional.empty()); + + var status = complianceService.getQuotaStatus(UNDER21_MEMBER_ID); + + assertThat(status.totalAllowed()).isEqualByComparingTo(ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS); + assertThat(status.totalUsed()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(status.isUnder21()).isTrue(); + } + + // TC-016: getQuotaStatus — member not found throws exception + @Test + @DisplayName("TC-016: getQuotaStatus — unknown member ID throws MemberNotFoundException") + void tc016_getQuotaStatus_memberNotFound() { + UUID unknown = UUID.fromString("99999999-9999-9999-9999-999999999999"); + when(memberRepository.findById(unknown)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> complianceService.getQuotaStatus(unknown)) + .isInstanceOf(MemberNotFoundException.class); + } + + // TC-017: validateMembershipAge — 18-year-old is allowed + @Test + @DisplayName("TC-017: validateMembershipAge — 18-year-old is allowed") + void tc017_validateMembershipAge_18YearsOld_allowed() { + // Just before birthday this year to keep age = 18 + LocalDate dob = LocalDate.now().minusYears(18).minusDays(1); + // Should not throw + complianceService.validateMembershipAge(dob); + } + + // TC-018: validateMembershipAge — 17-year-old is rejected + @Test + @DisplayName("TC-018: validateMembershipAge — 17-year-old is rejected") + void tc018_validateMembershipAge_17YearsOld_rejected() { + LocalDate dob = LocalDate.now().minusYears(17); + + assertThatThrownBy(() -> complianceService.validateMembershipAge(dob)) + .isInstanceOf(QuotaExceededException.class) + .extracting("code").isEqualTo(QuotaViolationCode.MEMBER_INACTIVE); + } + + // TC-019: isUnder21 — 20-year-old returns true + @Test + @DisplayName("TC-019: isUnder21 — 20-year-old returns true") + void tc019_isUnder21_20YearsOld_returnsTrue() { + LocalDate dob = LocalDate.now().minusYears(20).minusDays(1); + assertThat(complianceService.isUnder21(dob)).isTrue(); + } + + // TC-020: isUnder21 — 21-year-old returns false + @Test + @DisplayName("TC-020: isUnder21 — 21-year-old returns false") + void tc020_isUnder21_21YearsOld_returnsFalse() { + LocalDate dob = LocalDate.now().minusYears(21).minusDays(1); + assertThat(complianceService.isUnder21(dob)).isFalse(); + } + + // TC-021: member not found in checkDistributionAllowed → MemberNotFoundException + @Test + @DisplayName("TC-021: Unknown member ID in checkDistributionAllowed → MemberNotFoundException") + void tc021_memberNotFoundInCheck() { + UUID unknown = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + when(memberRepository.findById(unknown)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> + complianceService.checkDistributionAllowed(unknown, BATCH_ID, new BigDecimal("5.0"))) + .isInstanceOf(de.cannamanage.service.exception.MemberNotFoundException.class); + } + + // TC-022: batch not found in checkDistributionAllowed → BatchNotFoundException + @Test + @DisplayName("TC-022: Unknown batch ID in checkDistributionAllowed → BatchNotFoundException") + void tc022_batchNotFoundInCheck() { + UUID unknownBatch = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember)); + when(batchRepository.findById(unknownBatch)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> + complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, unknownBatch, new BigDecimal("5.0"))) + .isInstanceOf(de.cannamanage.service.exception.BatchNotFoundException.class); + } + + // TC-023: strain not found for batch → BatchNotFoundException + @Test + @DisplayName("TC-023: Strain not found for batch → BatchNotFoundException") + void tc023_strainNotFoundForBatch() { + UUID unknownStrain = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc"); + Batch batchWithUnknownStrain = new Batch(); + batchWithUnknownStrain.setStatus(BatchStatus.AVAILABLE); + batchWithUnknownStrain.setStrainId(unknownStrain); + + when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember)); + when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(batchWithUnknownStrain)); + when(strainRepository.findById(unknownStrain)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> + complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("5.0"))) + .isInstanceOf(de.cannamanage.service.exception.BatchNotFoundException.class); + } + + // TC-024: validateMembershipAge — birthday not yet occurred this year (age-- branch) + @Test + @DisplayName("TC-024: validateMembershipAge — birthday later this year → age is 17, rejected") + void tc024_validateMembershipAge_birthdayLaterThisYear() { + // Person who will turn 18 tomorrow — today they are 17 → should throw + LocalDate dob = LocalDate.now().plusDays(1).minusYears(18); + assertThatThrownBy(() -> complianceService.validateMembershipAge(dob)) + .isInstanceOf(QuotaExceededException.class) + .extracting("code").isEqualTo(QuotaViolationCode.MEMBER_INACTIVE); + } + + // TC-025: isUnder21 — birthday not yet occurred this year → age-- branch + @Test + @DisplayName("TC-025: isUnder21 — person turns 21 tomorrow → still under 21 today") + void tc025_isUnder21_birthdayTomorrow_stillUnder21() { + // Person who will turn 21 tomorrow — today they are 20 → still under 21 + LocalDate dob = LocalDate.now().plusDays(1).minusYears(21); + assertThat(complianceService.isUnder21(dob)).isTrue(); + } + + // Helper + private MonthlyQuota buildQuota(String totalDistributed, BigDecimal maxAllowed) { + MonthlyQuota quota = new MonthlyQuota(); + quota.setTotalDistributed(new BigDecimal(totalDistributed)); + quota.setMaxAllowed(maxAllowed); + return quota; + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fc8d2d6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +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: diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..5761d94 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..b95a28d --- /dev/null +++ b/pom.xml @@ -0,0 +1,70 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.4 + + + + de.cannamanage + cannamanage-parent + 1.0.0-SNAPSHOT + pom + CannaManage — Parent POM + Cannabis Social Club SaaS — German CanG Compliance Platform + + + cannamanage-domain + cannamanage-service + cannamanage-api + + + + 21 + 21 + 21 + UTF-8 + UTF-8 + + 6.5.3.Final + 10.15.0 + 3.26.3 + 5.12.0 + + 0.8.13 + 1.00 + + + + + + + de.cannamanage + cannamanage-domain + ${project.version} + + + de.cannamanage + cannamanage-service + ${project.version} + + + + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + + +