# 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

1. [Authentication & Conventions](#1-authentication--conventions)
2. [Error Format](#2-error-format)
3. [Pagination Envelope](#3-pagination-envelope)
4. [Custom Error Codes](#4-custom-error-codes)
5. [Auth Controller](#5-auth-controller-auth)
6. [Club Controller](#6-club-controller-clubs)
7. [Member Controller](#7-member-controller-members)
8. [Distribution Controller](#8-distribution-controller-distributions)
9. [Stock Controller](#9-stock-controller-stock)
10. [Report Controller](#10-report-controller-reports)
11. [Compliance Controller](#11-compliance-controller-compliance)

---

## 1. Authentication & Conventions

### JWT Claims

The access token payload contains:

```json
{
  "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:

```json
{
  "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:

```json
{
  "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:**

```json
{
  "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`:**

```json
{
  "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:**

```json
{
  "refreshToken": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4..."
}
```

**Success Response — `200 OK`:**

```json
{
  "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`:**

```json
{
  "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:**

```json
{
  "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`:**

```json
{
  "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`:**

```json
{
  "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:** `dateOfBirth` is 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:**

```json
{
  "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`:**

```json
{
  "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"
}
```

> `monthlyQuotaGrams` is 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`:**

```json
{
  "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:**

```json
{
  "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`:**

```json
{
  "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`:**

```json
{
  "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`:**

```json
{
  "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
}
```

> `nearLimit` is `true` when 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`:**

```json
{
  "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`:**

```json
{
  "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:**

```json
{
  "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`:**

```json
{
  "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):**

1. Member exists and belongs to this tenant
2. Member status is `ACTIVE`
3. Member has DSGVO consent on record
4. Batch exists, belongs to this tenant, and is not recalled
5. Batch has sufficient remaining stock
6. `quantityGrams` ≤ remaining daily quota (25 g/day - already distributed today)
7. `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:**

```json
{
  "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`:**

```json
{
  "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`:**

```json
{
  "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:**

```json
{
  "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`:**

```json
{
  "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:**

```json
{
  "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`:**

```json
{
  "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"
}
```

> `recallInfo` is `null` for 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:**

```json
{
  "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`:**

```json
{
  "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`:**

```json
{
  "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`:**

```json
{
  "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):**

```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/pdf`
- `Content-Disposition: attachment; filename="cannamanage-report-2026-03.pdf"`
- Binary PDF body

**Success Response — format=csv:**

- `Content-Type: text/csv; charset=UTF-8`
- `Content-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):**

```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):**

```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`:**

```json
{
  "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:**

```json
{
  "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"]
}
```

> `blockingReasons` is an array — multiple blocks can apply simultaneously (e.g., both `MEMBER_INACTIVE` and `DSGVO_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):**

```json
{
  "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):**

```json
{
  "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`. The `allowed` field indicates pass/fail. Use `violations` array 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:

1. 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.
2. Any attempt to access cross-tenant resources returns `403 TENANT_VIOLATION`.
3. Cross-tenant violations are logged server-side to the security audit log.
4. Tenant ID is **never** accepted from the request (body, headers, or query parameters) — it is always derived from the validated JWT signature.
