- 01-PROJECT-CHARTER.md: project charter with Gantt chart and risk register - 02-USER-STORIES.md: 25 user stories with MoSCoW priorities and ACs - 03-ARCHITECTURE.md: system architecture, ERD (8 entities), multi-tenancy design - 04-FLOWCHARTS.md: 5 business logic flow charts (distribution, recall, etc) - 05-API-SPEC.md: REST API spec (7 controllers, 30+ endpoints) - 06-WIREFRAMES.md: 6 screen wireframes with AI-generated mockup images - 07-CODING-STANDARDS.md: Java 21 standards, Git strategy, compliance rules - 08-TEST-PLAN.md: 26 test cases, JaCoCo coverage gates - 09-DEPLOYMENT-GUIDE.md: Hetzner Docker Compose + Gitea CI/CD pipeline - README.md + CHANGELOG.md + 10-RETROSPECTIVE.md - 5 AI-generated UI mockup images (Flux Schnell/ComfyUI)
50 KiB
CannaManage REST API Specification v1.0
Base URL: https://{club-domain}/api/v1
Content-Type: application/json
Authentication: Bearer JWT token via Authorization: Bearer <token> header
Versioning: URL-based — current version /api/v1/, next version /api/v2/
Table of Contents
- Authentication & Conventions
- Error Format
- Pagination Envelope
- Custom Error Codes
- Auth Controller
- Club Controller
- Member Controller
- Distribution Controller
- Stock Controller
- Report Controller
- Compliance Controller
1. Authentication & Conventions
JWT Claims
The access token payload contains:
{
"sub": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenant_id": "8e1a2b3c-4d5e-6f70-8091-a2b3c4d5e6f7",
"role": "ADMIN",
"email": "admin@gruener-daumen-ev.de",
"iat": 1712345678,
"exp": 1712349278
}
tenant_id is ALWAYS resolved from the JWT. It is never accepted as a request parameter or path variable. Any attempt to access data belonging to a different tenant returns 403 TENANT_VIOLATION.
Roles
| Role | Description |
|---|---|
ADMIN |
Club administrator — full access to club data |
MEMBER |
Regular club member — read-only access to own profile and distributions |
Token Lifetimes
| Token | Lifetime |
|---|---|
| Access token | 1 hour |
| Refresh token | 30 days |
2. Error Format
All error responses use application/problem+json format:
{
"status": 400,
"error": "BAD_REQUEST",
"code": "QUOTA_EXCEEDED_MONTHLY",
"message": "Monthly distribution quota of 50g exceeded for member.",
"timestamp": "2026-04-06T10:15:30.000Z",
"path": "/api/v1/distributions"
}
| Field | Type | Description |
|---|---|---|
status |
integer |
HTTP status code |
error |
string |
HTTP status reason phrase |
code |
string |
Machine-readable application error code (see §4) |
message |
string |
Human-readable description |
timestamp |
string |
ISO 8601 UTC timestamp |
path |
string |
Request path that caused the error |
3. Pagination Envelope
All list endpoints returning paginated results use this envelope:
{
"content": [ "...array of items..." ],
"page": 0,
"size": 20,
"totalElements": 150,
"totalPages": 8
}
Standard query parameters for paginated endpoints:
| Parameter | Type | Default | Description |
|---|---|---|---|
page |
integer |
0 |
Zero-based page index |
size |
integer |
20 |
Page size (max: 100) |
sort |
string |
varies | Field name + direction, e.g. createdAt,desc |
4. Custom Error Codes
| Code | HTTP Status | Description |
|---|---|---|
QUOTA_EXCEEDED_DAILY |
422 | Member has reached the 25 g/day distribution limit (KCanG §19 Abs. 5) |
QUOTA_EXCEEDED_MONTHLY |
422 | Member has reached the monthly limit: 50 g (≥21 yrs) or 30 g (<21 yrs) |
BATCH_RECALLED |
422 | The requested batch has an active contamination/recall flag |
MEMBER_INACTIVE |
422 | Member status is SUSPENDED or EXPELLED — distributions blocked |
MEMBER_UNDERAGE |
422 | Member date of birth indicates they are under 18 years old |
DISTRIBUTION_IMMUTABLE |
422 | Attempt to modify or delete an existing distribution record (audit trail protected) |
TENANT_VIOLATION |
403 | Requested resource belongs to a different tenant than the JWT claims |
DSGVO_CONSENT_MISSING |
422 | Member has no DSGVO (GDPR) data processing consent on record |
BATCH_INSUFFICIENT_STOCK |
422 | Batch does not have sufficient remaining quantity for the requested distribution |
INVALID_CREDENTIALS |
401 | Email/password combination is incorrect |
TOKEN_EXPIRED |
401 | Access or refresh token has expired |
TOKEN_INVALID |
401 | Token signature is invalid or malformed |
MEMBER_NOT_FOUND |
404 | No member with the given UUID exists in this tenant |
BATCH_NOT_FOUND |
404 | No batch with the given UUID exists in this tenant |
DISTRIBUTION_NOT_FOUND |
404 | No distribution with the given UUID exists in this tenant |
5. Auth Controller (/auth)
5.1 POST /auth/login
Authenticate with email and password. Returns a short-lived access token and a long-lived refresh token.
Authentication: None required
Request Body:
{
"email": "admin@gruener-daumen-ev.de",
"password": "supersecret123"
}
| Field | Type | Required | Description |
|---|---|---|---|
email |
string |
✅ | Club administrator email address |
password |
string |
✅ | Plaintext password (transmitted over TLS) |
Success Response — 200 OK:
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4...",
"tokenType": "Bearer",
"expiresIn": 3600,
"member": {
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"email": "admin@gruener-daumen-ev.de",
"role": "ADMIN",
"clubId": "8e1a2b3c-4d5e-6f70-8091-a2b3c4d5e6f7",
"clubName": "Grüner Daumen e.V."
}
}
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 401 | INVALID_CREDENTIALS |
Email not found or password does not match |
| 400 | BAD_REQUEST |
Missing required fields |
| 429 | TOO_MANY_REQUESTS |
Rate limit exceeded (5 failed attempts per 15 minutes) |
5.2 POST /auth/refresh
Exchange a valid refresh token for a new access token.
Authentication: Refresh token in request body (not a Bearer token)
Request Body:
{
"refreshToken": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4..."
}
Success Response — 200 OK:
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"tokenType": "Bearer",
"expiresIn": 3600
}
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 401 | TOKEN_EXPIRED |
Refresh token has passed its 30-day lifetime |
| 401 | TOKEN_INVALID |
Refresh token signature is invalid or has been revoked |
| 400 | BAD_REQUEST |
refreshToken field is missing |
5.3 POST /auth/logout
Invalidate the current refresh token. Access tokens remain valid until natural expiry (TTL-based, no server-side revocation).
Authentication: Bearer access token required
Request Body: None
Success Response — 204 No Content
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 401 | TOKEN_INVALID |
Bearer token is invalid or expired |
6. Club Controller (/clubs)
6.1 GET /clubs/me
Retrieve the authenticated admin's club details. Tenant is resolved from JWT.
Authentication: Bearer token — role ADMIN
Success Response — 200 OK:
{
"id": "8e1a2b3c-4d5e-6f70-8091-a2b3c4d5e6f7",
"name": "Grüner Daumen e.V.",
"registrationNumber": "VR 12345",
"address": {
"street": "Hanfstraße 42",
"city": "Berlin",
"postalCode": "10115",
"state": "Berlin"
},
"contactEmail": "info@gruener-daumen-ev.de",
"contactPhone": "+49 30 12345678",
"foundedDate": "2024-04-01",
"maxMembers": 500,
"currentMemberCount": 87,
"status": "ACTIVE",
"createdAt": "2024-04-01T09:00:00.000Z",
"updatedAt": "2026-03-15T14:22:00.000Z"
}
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
| 403 | FORBIDDEN |
Authenticated user does not have ADMIN role |
6.2 PUT /clubs/me
Update the authenticated admin's club details.
Authentication: Bearer token — role ADMIN
Request Body:
{
"name": "Grüner Daumen e.V.",
"registrationNumber": "VR 12345",
"address": {
"street": "Hanfstraße 42",
"city": "Berlin",
"postalCode": "10115",
"state": "Berlin"
},
"contactEmail": "info@gruener-daumen-ev.de",
"contactPhone": "+49 30 12345678",
"maxMembers": 500
}
| Field | Type | Required | Description |
|---|---|---|---|
name |
string |
✅ | Official club name as registered |
registrationNumber |
string |
✅ | Vereinsregister number |
address |
object |
✅ | Club registered address |
contactEmail |
string |
✅ | Public contact email |
contactPhone |
string |
❌ | Public contact phone |
maxMembers |
integer |
❌ | Maximum membership capacity (default: 500, KCanG limit) |
Success Response — 200 OK: Full club object (same as GET /clubs/me)
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 400 | BAD_REQUEST |
Validation failure (invalid email format, missing required fields) |
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
| 403 | FORBIDDEN |
Authenticated user does not have ADMIN role |
6.3 GET /clubs/me/stats
Retrieve dashboard statistics for the authenticated club.
Authentication: Bearer token — role ADMIN
Success Response — 200 OK:
{
"memberCount": {
"total": 87,
"active": 82,
"pending": 3,
"suspended": 1,
"expelled": 1
},
"distributionsThisMonth": {
"count": 214,
"totalGrams": 3280.5,
"uniqueMembers": 74
},
"stock": {
"totalGrams": 12500.0,
"activeBatches": 4,
"strainCount": 6
},
"complianceAlerts": {
"membersAtDailyLimit": 2,
"membersAtMonthlyLimit": 5,
"recalledBatchesActive": 0
},
"generatedAt": "2026-04-06T10:00:00.000Z"
}
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
| 403 | FORBIDDEN |
Authenticated user does not have ADMIN role |
7. Member Controller (/members)
7.1 GET /members
List all members of the authenticated club, paginated and optionally filtered by status.
Authentication: Bearer token — role ADMIN
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page |
integer |
0 |
Page index (zero-based) |
size |
integer |
20 |
Page size |
sort |
string |
lastName,asc |
Sort field and direction |
status |
string |
(all) | Filter by status: ACTIVE, PENDING, SUSPENDED, EXPELLED |
search |
string |
(none) | Full-text search against first name, last name, or member number |
Success Response — 200 OK:
{
"content": [
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"memberNumber": "GD-2024-001",
"firstName": "Max",
"lastName": "Mustermann",
"email": "max@example.de",
"status": "ACTIVE",
"dateOfBirth": "1990-05-15",
"joinDate": "2024-05-01",
"monthlyQuotaGrams": 50,
"createdAt": "2024-05-01T10:00:00.000Z"
}
],
"page": 0,
"size": 20,
"totalElements": 87,
"totalPages": 5
}
Note:
dateOfBirthis only included for ADMIN role. Member role responses omit it.
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
| 403 | FORBIDDEN |
Authenticated user does not have ADMIN role |
7.2 POST /members
Create a new club member. Generates a unique member number automatically.
Authentication: Bearer token — role ADMIN
Request Body:
{
"firstName": "Max",
"lastName": "Mustermann",
"email": "max@example.de",
"dateOfBirth": "1990-05-15",
"address": {
"street": "Musterstraße 1",
"city": "Berlin",
"postalCode": "10115",
"state": "Berlin"
},
"phone": "+49 176 12345678",
"dsgvoConsentDate": "2026-04-06",
"joinDate": "2026-04-06",
"notes": "Referred by member GD-2024-015"
}
| Field | Type | Required | Description |
|---|---|---|---|
firstName |
string |
✅ | Legal first name |
lastName |
string |
✅ | Legal last name |
email |
string |
✅ | Contact email (must be unique within tenant) |
dateOfBirth |
string |
✅ | ISO 8601 date — used for quota calculation (≥21: 50 g/month, <21: 30 g/month) |
address |
object |
✅ | Member's registered home address |
phone |
string |
❌ | Contact phone number |
dsgvoConsentDate |
string |
✅ | Date member signed DSGVO consent form |
joinDate |
string |
✅ | Official membership start date |
notes |
string |
❌ | Internal admin notes (not visible to member) |
Success Response — 201 Created:
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"memberNumber": "GD-2026-088",
"firstName": "Max",
"lastName": "Mustermann",
"email": "max@example.de",
"status": "ACTIVE",
"dateOfBirth": "1990-05-15",
"monthlyQuotaGrams": 50,
"joinDate": "2026-04-06",
"dsgvoConsentDate": "2026-04-06",
"createdAt": "2026-04-06T10:30:00.000Z"
}
monthlyQuotaGramsis computed server-side based on age at time of creation: 50 g for members ≥21 years old, 30 g for members aged 18–20.
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 400 | BAD_REQUEST |
Missing required fields or validation failure |
| 409 | CONFLICT |
Email already registered for another member in this tenant |
| 422 | MEMBER_UNDERAGE |
Computed age from dateOfBirth is under 18 |
| 422 | DSGVO_CONSENT_MISSING |
dsgvoConsentDate is null or missing |
| 422 | QUOTA_EXCEEDED_DAILY |
Club has reached maximum member capacity (500 members per KCanG §15) |
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
| 403 | FORBIDDEN |
Authenticated user does not have ADMIN role |
7.3 GET /members/{id}
Retrieve full details for a single member.
Authentication: Bearer token — role ADMIN
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
UUID |
Member's unique identifier |
Success Response — 200 OK:
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"memberNumber": "GD-2026-088",
"firstName": "Max",
"lastName": "Mustermann",
"email": "max@example.de",
"phone": "+49 176 12345678",
"dateOfBirth": "1990-05-15",
"address": {
"street": "Musterstraße 1",
"city": "Berlin",
"postalCode": "10115",
"state": "Berlin"
},
"status": "ACTIVE",
"monthlyQuotaGrams": 50,
"joinDate": "2026-04-06",
"dsgvoConsentDate": "2026-04-06",
"notes": "Referred by member GD-2024-015",
"createdAt": "2026-04-06T10:30:00.000Z",
"updatedAt": "2026-04-06T10:30:00.000Z"
}
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 404 | MEMBER_NOT_FOUND |
No member with given UUID exists in this tenant |
| 403 | TENANT_VIOLATION |
Member UUID exists but belongs to a different tenant |
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
| 403 | FORBIDDEN |
Authenticated user does not have ADMIN role |
7.4 PUT /members/{id}
Update an existing member's details.
Authentication: Bearer token — role ADMIN
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
UUID |
Member's unique identifier |
Request Body: Same structure as POST /members (all fields optional except those required for compliance)
Success Response — 200 OK: Full updated member object
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 400 | BAD_REQUEST |
Validation failure |
| 404 | MEMBER_NOT_FOUND |
No member with given UUID exists in this tenant |
| 403 | TENANT_VIOLATION |
Member UUID belongs to a different tenant |
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
| 403 | FORBIDDEN |
Authenticated user does not have ADMIN role |
7.5 DELETE /members/{id}
Soft-delete / expel a member. Sets status to EXPELLED and records an expulsion timestamp. No data is physically deleted — all historical records (distributions, etc.) are retained for compliance auditing.
Authentication: Bearer token — role ADMIN
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
UUID |
Member's unique identifier |
Request Body:
{
"reason": "Voluntary membership resignation",
"effectiveDate": "2026-04-06"
}
| Field | Type | Required | Description |
|---|---|---|---|
reason |
string |
✅ | Reason for expulsion/resignation (stored in audit log) |
effectiveDate |
string |
✅ | ISO 8601 date when expulsion takes effect |
Success Response — 200 OK:
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"memberNumber": "GD-2026-088",
"status": "EXPELLED",
"expelledAt": "2026-04-06T11:00:00.000Z",
"expulsionReason": "Voluntary membership resignation"
}
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 400 | BAD_REQUEST |
Missing reason or effectiveDate |
| 404 | MEMBER_NOT_FOUND |
No member with given UUID in this tenant |
| 409 | CONFLICT |
Member already has EXPELLED status |
| 403 | TENANT_VIOLATION |
Member UUID belongs to a different tenant |
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
| 403 | FORBIDDEN |
Authenticated user does not have ADMIN role |
7.6 GET /members/me
Retrieve the authenticated member's own profile.
Authentication: Bearer token — role MEMBER
Success Response — 200 OK:
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"memberNumber": "GD-2026-088",
"firstName": "Max",
"lastName": "Mustermann",
"email": "max@example.de",
"status": "ACTIVE",
"monthlyQuotaGrams": 50,
"joinDate": "2026-04-06"
}
dateOfBirth,address,notes, and internal fields are omitted from member self-view.
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
| 422 | MEMBER_INACTIVE |
Member account is suspended or expelled |
7.7 GET /members/{id}/quota
Get the current month's quota usage for a member.
Authentication: Bearer token — role ADMIN, or MEMBER accessing their own ID
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
UUID |
Member's unique identifier |
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
month |
string |
current month | ISO 8601 year-month, e.g. 2026-04 |
Success Response — 200 OK:
{
"memberId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"memberNumber": "GD-2026-088",
"month": "2026-04",
"monthlyLimitGrams": 50,
"distributedThisMonthGrams": 22.5,
"remainingMonthlyGrams": 27.5,
"dailyLimitGrams": 25,
"distributedTodayGrams": 5.0,
"remainingTodayGrams": 20.0,
"distributionCount": 3,
"quotaExceeded": false,
"nearLimit": false
}
nearLimitistruewhen remaining grams ≤ 10 g (monthly) or ≤ 5 g (daily).
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 403 | FORBIDDEN |
MEMBER role accessing another member's quota |
| 404 | MEMBER_NOT_FOUND |
No member with given UUID in this tenant |
| 403 | TENANT_VIOLATION |
Member UUID belongs to a different tenant |
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
7.8 GET /members/{id}/distributions
Get distribution history for a member, paginated.
Authentication: Bearer token — role ADMIN, or MEMBER accessing their own ID
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
UUID |
Member's unique identifier |
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page |
integer |
0 |
Page index |
size |
integer |
20 |
Page size |
sort |
string |
distributedAt,desc |
Sort field and direction |
from |
string |
— | ISO 8601 date filter start, e.g. 2026-01-01 |
to |
string |
— | ISO 8601 date filter end |
Success Response — 200 OK:
{
"content": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"distributedAt": "2026-04-06T09:30:00.000Z",
"strainName": "Blue Dream",
"batchId": "f1e2d3c4-b5a6-9780-bcde-fa0987654321",
"quantityGrams": 5.0,
"notes": null
}
],
"page": 0,
"size": 20,
"totalElements": 42,
"totalPages": 3
}
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 403 | FORBIDDEN |
MEMBER role accessing another member's distributions |
| 404 | MEMBER_NOT_FOUND |
No member with given UUID in this tenant |
| 403 | TENANT_VIOLATION |
Member UUID belongs to a different tenant |
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
8. Distribution Controller (/distributions)
8.1 GET /distributions
List all distributions for the authenticated club, filterable by date range and member.
Authentication: Bearer token — role ADMIN
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page |
integer |
0 |
Page index |
size |
integer |
20 |
Page size |
sort |
string |
distributedAt,desc |
Sort field and direction |
from |
string |
— | ISO 8601 date filter start |
to |
string |
— | ISO 8601 date filter end |
memberId |
UUID |
— | Filter by specific member |
batchId |
UUID |
— | Filter by specific batch |
Success Response — 200 OK:
{
"content": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"distributedAt": "2026-04-06T09:30:00.000Z",
"member": {
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"memberNumber": "GD-2026-088",
"firstName": "Max",
"lastName": "Mustermann"
},
"strain": {
"id": "c4d5e6f7-a8b9-0123-cdef-456789abcdef",
"name": "Blue Dream",
"thcPercent": 18.5,
"cbdPercent": 0.3
},
"batchId": "f1e2d3c4-b5a6-9780-bcde-fa0987654321",
"batchCode": "BATCH-2026-003",
"quantityGrams": 5.0,
"handedOutBy": "admin@gruener-daumen-ev.de",
"notes": null,
"correctionNotes": []
}
],
"page": 0,
"size": 20,
"totalElements": 214,
"totalPages": 11
}
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
| 403 | FORBIDDEN |
Authenticated user does not have ADMIN role |
8.2 POST /distributions
Record a new distribution. All compliance rules are checked before the record is created. This operation is atomic — either all checks pass and the record is written, or the entire request fails.
Authentication: Bearer token — role ADMIN
Request Body:
{
"memberId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"batchId": "f1e2d3c4-b5a6-9780-bcde-fa0987654321",
"quantityGrams": 5.0,
"notes": "Member requested half portion"
}
| Field | Type | Required | Description |
|---|---|---|---|
memberId |
UUID |
✅ | Receiving member's UUID |
batchId |
UUID |
✅ | Stock batch UUID to draw from |
quantityGrams |
number |
✅ | Amount in grams (max 2 decimal places) |
notes |
string |
❌ | Optional admin note for this distribution |
Success Response — 201 Created:
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"distributedAt": "2026-04-06T09:30:00.000Z",
"memberId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"memberNumber": "GD-2026-088",
"batchId": "f1e2d3c4-b5a6-9780-bcde-fa0987654321",
"batchCode": "BATCH-2026-003",
"strainName": "Blue Dream",
"quantityGrams": 5.0,
"remainingMonthlyQuotaGrams": 22.5,
"remainingDailyQuotaGrams": 15.0,
"handedOutBy": "admin@gruener-daumen-ev.de"
}
Compliance Checks (in order of precedence):
- Member exists and belongs to this tenant
- Member status is
ACTIVE - Member has DSGVO consent on record
- Batch exists, belongs to this tenant, and is not recalled
- Batch has sufficient remaining stock
quantityGrams≤ remaining daily quota (25 g/day - already distributed today)quantityGrams≤ remaining monthly quota (50 g or 30 g - already distributed this month)
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 422 | MEMBER_INACTIVE |
Member status is SUSPENDED or EXPELLED |
| 422 | DSGVO_CONSENT_MISSING |
Member has no DSGVO consent date |
| 422 | BATCH_RECALLED |
Batch has an active contamination recall |
| 422 | BATCH_INSUFFICIENT_STOCK |
Batch has less remaining stock than quantityGrams |
| 422 | QUOTA_EXCEEDED_DAILY |
Distribution would exceed 25 g/day limit |
| 422 | QUOTA_EXCEEDED_MONTHLY |
Distribution would exceed monthly quota |
| 404 | MEMBER_NOT_FOUND |
Member UUID not found in this tenant |
| 404 | BATCH_NOT_FOUND |
Batch UUID not found in this tenant |
| 403 | TENANT_VIOLATION |
Member or batch belongs to different tenant |
| 400 | BAD_REQUEST |
quantityGrams ≤ 0 or > 25 |
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
| 403 | FORBIDDEN |
Authenticated user does not have ADMIN role |
KCanG Compliance Note: Single distributions of more than 25 g are rejected even if monthly quota remains. The legal daily limit is 25 g per person per calendar day (KCanG §19 Abs. 5).
8.3 GET /distributions/{id}
Retrieve a single distribution record by ID.
Authentication: Bearer token — role ADMIN
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
UUID |
Distribution's unique identifier |
Success Response — 200 OK: Full distribution object (same structure as list items in §8.1)
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 404 | DISTRIBUTION_NOT_FOUND |
No distribution with given UUID in this tenant |
| 403 | TENANT_VIOLATION |
Distribution belongs to a different tenant |
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
| 403 | FORBIDDEN |
Authenticated user does not have ADMIN role |
8.4 POST /distributions/{id}/notes
Add a correction note to an existing distribution. Distribution records themselves are immutable — amount, member, batch, and timestamp cannot be changed. Correction notes provide an auditable annotation layer for errors or clarifications.
Authentication: Bearer token — role ADMIN
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
UUID |
Distribution's unique identifier |
Request Body:
{
"note": "Entry error — scale was miscalibrated. Actual weight was approximately 4.8g. Scale recalibrated and verified on 2026-04-06.",
"correctedBy": "admin@gruener-daumen-ev.de"
}
| Field | Type | Required | Description |
|---|---|---|---|
note |
string |
✅ | Correction note text (max 2000 characters) |
correctedBy |
string |
✅ | Email of admin entering the correction |
Success Response — 201 Created:
{
"noteId": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
"distributionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"note": "Entry error — scale was miscalibrated. Actual weight was approximately 4.8g.",
"correctedBy": "admin@gruener-daumen-ev.de",
"createdAt": "2026-04-06T11:15:00.000Z"
}
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 404 | DISTRIBUTION_NOT_FOUND |
No distribution with given UUID in this tenant |
| 403 | TENANT_VIOLATION |
Distribution belongs to a different tenant |
| 400 | BAD_REQUEST |
Missing note field or note exceeds 2000 chars |
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
| 403 | FORBIDDEN |
Authenticated user does not have ADMIN role |
Audit Trail Note: Correction notes do NOT change the original distribution amount. For compliance reporting, the original amount is always used. Correction notes appear in distribution detail views and audit exports but do not affect quota calculations.
9. Stock Controller (/stock)
9.1 GET /stock/strains
List all cannabis strains registered in the club's catalogue.
Authentication: Bearer token — role ADMIN
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page |
integer |
0 |
Page index |
size |
integer |
50 |
Page size |
active |
boolean |
— | Filter by active strains only |
Success Response — 200 OK:
{
"content": [
{
"id": "c4d5e6f7-a8b9-0123-cdef-456789abcdef",
"name": "Blue Dream",
"variety": "HYBRID",
"thcPercent": 18.5,
"cbdPercent": 0.3,
"description": "Classic hybrid, earthy and sweet aroma",
"active": true,
"createdAt": "2025-01-15T10:00:00.000Z"
}
],
"page": 0,
"size": 50,
"totalElements": 6,
"totalPages": 1
}
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
| 403 | FORBIDDEN |
Authenticated user does not have ADMIN role |
9.2 POST /stock/strains
Register a new cannabis strain in the club's catalogue.
Authentication: Bearer token — role ADMIN
Request Body:
{
"name": "OG Kush",
"variety": "INDICA",
"thcPercent": 22.0,
"cbdPercent": 0.1,
"description": "Classic indica, piney and citrus notes"
}
| Field | Type | Required | Description |
|---|---|---|---|
name |
string |
✅ | Strain name |
variety |
string |
✅ | SATIVA, INDICA, or HYBRID |
thcPercent |
number |
✅ | THC content percentage (0–100) |
cbdPercent |
number |
✅ | CBD content percentage (0–100) |
description |
string |
❌ | Optional descriptive notes |
Success Response — 201 Created: Full strain object
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 409 | CONFLICT |
Strain with same name already exists in this tenant |
| 400 | BAD_REQUEST |
Invalid variety value or missing required fields |
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
| 403 | FORBIDDEN |
Authenticated user does not have ADMIN role |
9.3 GET /stock/batches
List all stock batches for the authenticated club.
Authentication: Bearer token — role ADMIN
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page |
integer |
0 |
Page index |
size |
integer |
20 |
Page size |
status |
string |
— | Filter: AVAILABLE, DEPLETED, RECALLED |
strainId |
UUID |
— | Filter by strain |
Success Response — 200 OK:
{
"content": [
{
"id": "f1e2d3c4-b5a6-9780-bcde-fa0987654321",
"batchCode": "BATCH-2026-003",
"strain": {
"id": "c4d5e6f7-a8b9-0123-cdef-456789abcdef",
"name": "Blue Dream"
},
"initialQuantityGrams": 2000.0,
"remainingQuantityGrams": 850.5,
"status": "AVAILABLE",
"harvestDate": "2026-02-15",
"labTestDate": "2026-03-01",
"labTestReference": "LAB-2026-1234",
"thcPercent": 19.2,
"addedAt": "2026-03-10T08:00:00.000Z"
}
],
"page": 0,
"size": 20,
"totalElements": 4,
"totalPages": 1
}
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
| 403 | FORBIDDEN |
Authenticated user does not have ADMIN role |
9.4 POST /stock/batches
Add a new stock batch for a registered strain.
Authentication: Bearer token — role ADMIN
Request Body:
{
"strainId": "c4d5e6f7-a8b9-0123-cdef-456789abcdef",
"initialQuantityGrams": 2000.0,
"harvestDate": "2026-02-15",
"labTestDate": "2026-03-01",
"labTestReference": "LAB-2026-1234",
"thcPercent": 19.2,
"cbdPercent": 0.4,
"notes": "Batch from indoor cultivation cycle 3"
}
| Field | Type | Required | Description |
|---|---|---|---|
strainId |
UUID |
✅ | Strain this batch belongs to |
initialQuantityGrams |
number |
✅ | Starting weight in grams |
harvestDate |
string |
✅ | ISO 8601 date of harvest |
labTestDate |
string |
✅ | ISO 8601 date of laboratory analysis |
labTestReference |
string |
✅ | Lab report reference number (audit requirement) |
thcPercent |
number |
✅ | Actual THC % from lab test |
cbdPercent |
number |
✅ | Actual CBD % from lab test |
notes |
string |
❌ | Internal notes |
Success Response — 201 Created: Full batch object (same as list item above)
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 400 | BAD_REQUEST |
Missing required fields or invalid quantities |
| 404 | — | strainId not found in this tenant |
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
| 403 | FORBIDDEN |
Authenticated user does not have ADMIN role |
9.5 GET /stock/batches/{id}
Retrieve full details for a specific batch including distribution history summary.
Authentication: Bearer token — role ADMIN
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
UUID |
Batch unique identifier |
Success Response — 200 OK:
{
"id": "f1e2d3c4-b5a6-9780-bcde-fa0987654321",
"batchCode": "BATCH-2026-003",
"strain": {
"id": "c4d5e6f7-a8b9-0123-cdef-456789abcdef",
"name": "Blue Dream",
"variety": "HYBRID"
},
"initialQuantityGrams": 2000.0,
"remainingQuantityGrams": 850.5,
"distributedQuantityGrams": 1149.5,
"distributionCount": 48,
"status": "AVAILABLE",
"harvestDate": "2026-02-15",
"labTestDate": "2026-03-01",
"labTestReference": "LAB-2026-1234",
"thcPercent": 19.2,
"cbdPercent": 0.4,
"notes": "Batch from indoor cultivation cycle 3",
"recallInfo": null,
"addedAt": "2026-03-10T08:00:00.000Z",
"updatedAt": "2026-04-06T09:30:00.000Z"
}
recallInfoisnullfor non-recalled batches. See §9.6 for recall structure.
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 404 | BATCH_NOT_FOUND |
No batch with given UUID in this tenant |
| 403 | TENANT_VIOLATION |
Batch belongs to a different tenant |
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
| 403 | FORBIDDEN |
Authenticated user does not have ADMIN role |
9.6 POST /stock/batches/{id}/recall
Flag a batch as recalled due to contamination or safety concerns. This immediately prevents any new distributions from this batch. Members who received product from this batch can be identified via GET /reports/recall/{batchId}.
Authentication: Bearer token — role ADMIN
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
UUID |
Batch unique identifier |
Request Body:
{
"reason": "Pesticide residue detected above legal threshold in repeat lab test",
"detectedAt": "2026-04-06",
"severity": "HIGH",
"labReference": "LAB-2026-9876"
}
| Field | Type | Required | Description |
|---|---|---|---|
reason |
string |
✅ | Description of the contamination/recall reason |
detectedAt |
string |
✅ | ISO 8601 date contamination was detected |
severity |
string |
✅ | LOW, MEDIUM, or HIGH |
labReference |
string |
❌ | Lab report reference for contamination finding |
Success Response — 200 OK:
{
"id": "f1e2d3c4-b5a6-9780-bcde-fa0987654321",
"batchCode": "BATCH-2026-003",
"status": "RECALLED",
"recallInfo": {
"reason": "Pesticide residue detected above legal threshold",
"detectedAt": "2026-04-06",
"severity": "HIGH",
"labReference": "LAB-2026-9876",
"recalledAt": "2026-04-06T11:30:00.000Z",
"recalledBy": "admin@gruener-daumen-ev.de",
"affectedMemberCount": 23
}
}
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 404 | BATCH_NOT_FOUND |
No batch with given UUID in this tenant |
| 409 | CONFLICT |
Batch is already in RECALLED status |
| 403 | TENANT_VIOLATION |
Batch belongs to a different tenant |
| 400 | BAD_REQUEST |
Missing required fields or invalid severity value |
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
| 403 | FORBIDDEN |
Authenticated user does not have ADMIN role |
9.7 GET /stock/summary (ADMIN view)
Retrieve a complete stock summary showing totals by strain.
Authentication: Bearer token — role ADMIN
Success Response — 200 OK:
{
"totalAvailableGrams": 12500.0,
"activeBatches": 4,
"strains": [
{
"strainId": "c4d5e6f7-a8b9-0123-cdef-456789abcdef",
"strainName": "Blue Dream",
"availableGrams": 850.5,
"batchCount": 1
},
{
"strainId": "d5e6f7a8-b9c0-1234-defa-5678901bcdef",
"strainName": "OG Kush",
"availableGrams": 11649.5,
"batchCount": 3
}
],
"recalledBatches": 0,
"generatedAt": "2026-04-06T10:00:00.000Z"
}
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
| 403 | FORBIDDEN |
Authenticated user does not have ADMIN role |
9.8 GET /stock/summary (MEMBER view)
Retrieve stock availability for members — shows strain availability status only. No quantities are exposed to members.
Authentication: Bearer token — role MEMBER
Success Response — 200 OK:
{
"strains": [
{
"strainName": "Blue Dream",
"variety": "HYBRID",
"available": true
},
{
"strainName": "OG Kush",
"variety": "INDICA",
"available": true
}
],
"generatedAt": "2026-04-06T10:00:00.000Z"
}
MEMBER view is served by the same endpoint path — the response schema differs based on the JWT role claim. No grams, no batch codes, no THC percentages beyond what's in the strain catalogue.
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
10. Report Controller (/reports)
All report endpoints are ADMIN-only. Reports are tenant-scoped — all data is filtered to the requesting club's tenant_id.
10.1 GET /reports/monthly
Generate the monthly compliance report. Supports JSON (default), PDF, and CSV output.
Authentication: Bearer token — role ADMIN
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
month |
string |
current month | ISO 8601 year-month, e.g. 2026-03 |
format |
string |
json |
Output format: json, pdf, csv |
Success Response — 200 OK (format=json):
{
"reportId": "RPT-2026-04-GD",
"clubName": "Grüner Daumen e.V.",
"month": "2026-03",
"generatedAt": "2026-04-06T10:00:00.000Z",
"summary": {
"totalDistributions": 214,
"totalGramsDistributed": 3280.5,
"uniqueMembers": 74,
"activeMembers": 82,
"newMembers": 3,
"expelledMembers": 1
},
"complianceSummary": {
"quotaViolationsDetected": 0,
"recallsTriggered": 0,
"dsgvoNonCompliantMembers": 0
},
"distributions": [
{
"date": "2026-03-01",
"count": 9,
"totalGrams": 132.5
}
]
}
Success Response — format=pdf:
Content-Type: application/pdfContent-Disposition: attachment; filename="cannamanage-report-2026-03.pdf"- Binary PDF body
Success Response — format=csv:
Content-Type: text/csv; charset=UTF-8Content-Disposition: attachment; filename="cannamanage-report-2026-03.csv"- UTF-8 CSV with BOM for Excel compatibility
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 400 | BAD_REQUEST |
Invalid month format or format value |
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
| 403 | FORBIDDEN |
Authenticated user does not have ADMIN role |
10.2 GET /reports/members
Export the full member list — intended for presentation to authorities during official inspections.
Authentication: Bearer token — role ADMIN
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
format |
string |
json |
Output format: json, pdf, csv |
status |
string |
ACTIVE |
Filter by status |
asOf |
string |
today | ISO 8601 date — membership as of this date |
Success Response — 200 OK (format=json):
{
"clubName": "Grüner Daumen e.V.",
"asOf": "2026-04-06",
"totalCount": 82,
"members": [
{
"memberNumber": "GD-2024-001",
"firstName": "Max",
"lastName": "Mustermann",
"dateOfBirth": "1990-05-15",
"joinDate": "2024-05-01",
"status": "ACTIVE",
"dsgvoConsentDate": "2024-05-01"
}
]
}
This report includes date of birth and DSGVO consent date as required by KCanG inspection protocols.
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 400 | BAD_REQUEST |
Invalid format or status value |
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
| 403 | FORBIDDEN |
Authenticated user does not have ADMIN role |
10.3 GET /reports/recall/{batchId}
Generate a recall impact report for a specific batch — identifies all members who received product from the recalled batch.
Authentication: Bearer token — role ADMIN
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
batchId |
UUID |
Recalled batch unique identifier |
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
format |
string |
json |
Output format: json, pdf, csv |
Success Response — 200 OK (format=json):
{
"batchCode": "BATCH-2026-003",
"strainName": "Blue Dream",
"recallReason": "Pesticide residue detected above legal threshold",
"recalledAt": "2026-04-06T11:30:00.000Z",
"severity": "HIGH",
"affectedMembers": [
{
"memberNumber": "GD-2024-001",
"firstName": "Max",
"lastName": "Mustermann",
"email": "max@example.de",
"phone": "+49 176 12345678",
"totalReceivedGrams": 15.0,
"lastDistributionDate": "2026-04-05",
"distributionCount": 3
}
],
"totalAffectedMembers": 23,
"totalAffectedGrams": 345.5,
"generatedAt": "2026-04-06T11:35:00.000Z"
}
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 404 | BATCH_NOT_FOUND |
No batch with given UUID in this tenant |
| 400 | BAD_REQUEST |
Invalid format value |
| 403 | TENANT_VIOLATION |
Batch belongs to a different tenant |
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
| 403 | FORBIDDEN |
Authenticated user does not have ADMIN role |
11. Compliance Controller (/compliance)
11.1 GET /compliance/quota/{memberId}
Check the current compliance status for a specific member's quota. Intended for real-time verification before handing out product at the counter.
Authentication: Bearer token — role ADMIN
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
memberId |
UUID |
Member's unique identifier |
Success Response — 200 OK:
{
"memberId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"memberNumber": "GD-2026-088",
"memberStatus": "ACTIVE",
"dsgvoConsentPresent": true,
"quota": {
"month": "2026-04",
"monthlyLimitGrams": 50,
"distributedThisMonthGrams": 22.5,
"remainingMonthlyGrams": 27.5,
"dailyLimitGrams": 25,
"distributedTodayGrams": 0.0,
"remainingTodayGrams": 25.0
},
"canReceive": true,
"blockingReasons": []
}
Blocked member example:
{
"memberId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"memberNumber": "GD-2026-089",
"memberStatus": "ACTIVE",
"dsgvoConsentPresent": true,
"quota": {
"month": "2026-04",
"monthlyLimitGrams": 30,
"distributedThisMonthGrams": 30.0,
"remainingMonthlyGrams": 0.0,
"dailyLimitGrams": 25,
"distributedTodayGrams": 0.0,
"remainingTodayGrams": 25.0
},
"canReceive": false,
"blockingReasons": ["QUOTA_EXCEEDED_MONTHLY"]
}
blockingReasonsis an array — multiple blocks can apply simultaneously (e.g., bothMEMBER_INACTIVEandDSGVO_CONSENT_MISSING).
Error Responses:
| HTTP Status | Error Code | Condition |
|---|---|---|
| 404 | MEMBER_NOT_FOUND |
No member with given UUID in this tenant |
| 403 | TENANT_VIOLATION |
Member belongs to a different tenant |
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
| 403 | FORBIDDEN |
Authenticated user does not have ADMIN role |
11.2 GET /compliance/check
Dry-run compliance pre-check for a proposed distribution. Nothing is recorded — this is a read-only validation endpoint used to preview whether a specific distribution would pass all rules.
Authentication: Bearer token — role ADMIN
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
memberId |
UUID |
✅ | Member who would receive |
batchId |
UUID |
✅ | Batch to distribute from |
quantityGrams |
number |
✅ | Proposed quantity in grams |
Example request:
GET /api/v1/compliance/check?memberId=3fa85f64-...&batchId=f1e2d3c4-...&quantityGrams=10
Success Response — 200 OK (all checks pass):
{
"memberId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"batchId": "f1e2d3c4-b5a6-9780-bcde-fa0987654321",
"quantityGrams": 10.0,
"allowed": true,
"checks": {
"memberActive": true,
"dsgvoConsentPresent": true,
"batchAvailable": true,
"batchNotRecalled": true,
"batchSufficientStock": true,
"dailyQuotaOk": true,
"monthlyQuotaOk": true
},
"quotaAfter": {
"remainingMonthlyGrams": 17.5,
"remainingTodayGrams": 15.0
}
}
Success Response — 200 OK (checks fail — still HTTP 200, not an error):
{
"memberId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"batchId": "f1e2d3c4-b5a6-9780-bcde-fa0987654321",
"quantityGrams": 30.0,
"allowed": false,
"checks": {
"memberActive": true,
"dsgvoConsentPresent": true,
"batchAvailable": true,
"batchNotRecalled": true,
"batchSufficientStock": true,
"dailyQuotaOk": false,
"monthlyQuotaOk": true
},
"violations": ["QUOTA_EXCEEDED_DAILY"],
"quotaAfter": null
}
This endpoint always returns
200 OK. Theallowedfield indicates pass/fail. Useviolationsarray to display which rules would be broken.
Error Responses (true errors only):
| HTTP Status | Error Code | Condition |
|---|---|---|
| 400 | BAD_REQUEST |
Missing required query parameters or quantityGrams ≤ 0 |
| 404 | MEMBER_NOT_FOUND |
Member UUID not found in this tenant |
| 404 | BATCH_NOT_FOUND |
Batch UUID not found in this tenant |
| 403 | TENANT_VIOLATION |
Member or batch belongs to a different tenant |
| 401 | TOKEN_INVALID |
Bearer token missing or invalid |
| 403 | FORBIDDEN |
Authenticated user does not have ADMIN role |
Appendix A: Member Status Lifecycle
PENDING → ACTIVE → SUSPENDED → ACTIVE (reinstatement possible)
ACTIVE → EXPELLED (permanent, no reinstatement via API)
PENDING → EXPELLED (rejected application)
| Status | Distributions allowed | Login allowed |
|---|---|---|
PENDING |
No | No |
ACTIVE |
Yes | Yes |
SUSPENDED |
No | Yes (read-only) |
EXPELLED |
No | No |
Appendix B: KCanG Compliance Reference
Key limits implemented in the distribution compliance engine (as of KCanG 2024):
| Rule | Limit | Applied To |
|---|---|---|
| Daily distribution | 25 g/day | All adult members |
| Monthly distribution (≥21 years) | 50 g/month | Members aged 21+ |
| Monthly distribution (18–20 years) | 30 g/month | Members aged 18–20 |
| Minimum age | 18 years | All members |
| Maximum club members | 500 | Per Anbauvereinigung |
| Simultaneous memberships | 1 club | Per person (enforced externally) |
Age is calculated at the time of each distribution request, not at membership creation time. A member who turns 21 during the month automatically becomes eligible for the 50 g limit on their birthday.
Appendix C: Tenant Isolation Guarantee
Every database query includes an implicit WHERE tenant_id = ? clause derived from the JWT. The following guarantees hold:
- A request authenticated with tenant A's JWT cannot read, modify, or delete data belonging to tenant B — even if a valid UUID belonging to tenant B is provided.
- Any attempt to access cross-tenant resources returns
403 TENANT_VIOLATION. - Cross-tenant violations are logged server-side to the security audit log.
- Tenant ID is never accepted from the request (body, headers, or query parameters) — it is always derived from the validated JWT signature.