# Vereins-Membership & Feature-System Shinkan v1

**Status:** Konzept + M1–M3 teilweise produktiv (siehe Entscheidungs-Doc §2)  
**Stand:** 2026-06-06  
**Bezüge:** Schwesterprojekt Mitai (`v9c_subscription_system.sql`, `FEATURE_ENFORCEMENT.md`), `CAPABILITY_CATALOG.v1.md`, `MULTI_TENANCY_RBAC_ARCHITECTURE.md` §4.6, `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`, **`MEMBERSHIP_RBAC_DECISIONS_2026-06.md`**

---

## 1. Zweck

Shinkan verkauft und limitiert **nicht Einzelpersonen** (wie Mitai), sondern **Vereine**. Dieses Dokument definiert:

- das **Feature-Registry**-Muster (limitierbare Funktionen),
- das **Vereins-Abo** (`club_plans`, `club_subscriptions`),
- **Kontingente** und Enforcement,
- die **Abbildung von Mitai** und **Vermeidung von Refactoring-Schulden**.

Capabilities (Rollen: *darf ich die Funktion?*) → `CAPABILITY_CATALOG.v1.md`.

---

## 2. Grundprinzip: Zwei Achsen

```mermaid
flowchart TB
    subgraph cap [Achse 1 — Capabilities]
        CR[club_role_capability_grants]
        PR[portal_role_capability_grants]
    end

    subgraph feat [Achse 2 — Features / Kontingente]
        FP[club_plans]
        FPL[club_plan_limits]
        FS[club_subscriptions]
        FU[club_feature_usage]
    end

    subgraph gov [Achse 3 — Governance]
        GV[visibility / club_id / created_by]
    end

    REQ[HTTP Request] --> ACCT[Account-Lifecycle]
    ACCT --> cap
    cap --> gov
    gov --> feat
    feat --> EXEC[Ausführung + increment]
```

| Frage | System | Subjekt |
|-------|--------|---------|
| Darf Trainer X KI nutzen? | Capability `exercises.ai.suggest` | `profile_id` + `club_role` |
| Wie viele KI-Aufrufe hat Verein Y? | Feature `ai_calls` | **`club_id`** |
| Darf ich diese Übung ändern? | Governance | Objekt + Mitgliedschaft |

**Beide Achsen müssen erfüllt sein** (AND), außer dokumentierte Plattform-Ausnahmen.

---

## 3. Mitai-Mapping (was übernehmen, was nicht)

### 3.1 Übernehmen (Pattern)

| Mitai (Person) | Shinkan (Verein) | Anmerkung |
|----------------|------------------|-----------|
| `features` (TEXT-PK, Registry) | `features` (`app='shinkan'`) | Gemeinsames Muster, ggf. später Jinkendo-weit |
| `tiers` | `club_plans` | Produktdefinition |
| `tier_limits` | `club_plan_limits` | Matrix Plan × Feature |
| `user_feature_restrictions` | `club_feature_overrides` | Admin-Override pro Verein |
| `user_feature_usage` | `club_feature_usage` | Verbrauch pro Verein |
| `access_grants` | `club_access_grants` | Trial, Promo, manuelle Freischaltung |
| `check_feature_access()` | `check_club_feature_access()` | Subjekt `club_id` |
| `increment_feature_usage()` | `increment_club_feature_usage()` | Nur bei INSERT / KI-Call |
| 4-Phasen-Rollout | identisch | Log → UI → Hard-Block |
| `GET /api/features/usage` | `GET /api/clubs/{id}/entitlements` | siehe Capability-Doc §7 |

### 3.2 Nicht übernehmen

| Mitai | Shinkan-Grund |
|-------|---------------|
| `profiles.tier` als Haupt-Abo | Verein zahlt, nicht Einzeltrainer |
| `subscriptions` (Shinkan `001`, INT-Features) | Ungenutzt, Schema-Drift |
| `get_effective_tier(profile_id)` für Shinkan-Limits | Ersetzen durch `get_effective_club_plan(club_id)` |
| Profil-zentrierte Enforcement-Hooks allein | Primär `club_id`; Profil nur für Attribution |

### 3.3 Parallelität Jinkendo-Familie (später)

`CENTRAL_SUBSCRIPTION_SYSTEM.md` (Mitai): zentrales Personen-Abo über Apps.

**Zielbild ohne Refactoring:**

```
features.enforcement_subject ∈ { 'club', 'profile', 'portal' }

effektives_limit(feature) = merge(
    club_plan_limit(club_id, feature),      # Shinkan-Hauptquelle
    profile_grant_limit(profile_id, feature) # optional Jinkendo-Bonus
)
```

Merge-Regel (Vorschlag): **Maximum** der erlaubten Kontingente, boolean = OR. Details vor Stripe festlegen.

---

## 4. Ist-Zustand Shinkan (Drift — zuerst bereinigen)

| Artefakt | Problem |
|----------|---------|
| `backend/migrations/001_auth_membership.sql` | `features.id SERIAL`, `tier_limits.tier VARCHAR` |
| `backend/auth.py` `check_feature_access()` | Erwartet Mitai-v9c-Schema (`features.id TEXT`, `tier_id`, `limit_type`, …) |
| Kein Router | Ruft `check_feature_access` auf |
| `profiles.tier` | Existiert, ohne Shinkan-Enforcement |

**Pflicht vor Phase 3 (Enforcement):** Migration `0XX_club_features_v1.sql` — v9c-kompatibles Feature-Schema + Vereins-Tabellen; alte `001`-Feature-Zeilen migrieren oder deprecaten.

---

## 5. Ziel-Schema (v1)

### 5.1 Feature-Registry (app-weit, Mitai-kompatibel)

```sql
-- Konzept — Implementierung als nummerierte Migration
CREATE TABLE features (
    id              TEXT PRIMARY KEY,           -- z.B. 'ai_calls'
    app             TEXT NOT NULL DEFAULT 'shinkan',
    name            TEXT NOT NULL,
    description     TEXT,
    category        TEXT NOT NULL,              -- 'content'|'planning'|'ai'|'org'|'integration'|'platform'
    limit_type      TEXT NOT NULL DEFAULT 'count',  -- 'count' | 'boolean'
    reset_period    TEXT NOT NULL DEFAULT 'never',  -- 'never' | 'daily' | 'monthly'
    default_limit   INTEGER,                    -- NULL=∞, 0=aus
    enforcement_subject TEXT NOT NULL DEFAULT 'club',  -- 'club'|'profile'|'portal'
    active          BOOLEAN NOT NULL DEFAULT true,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ DEFAULT NOW()
);
```

### 5.2 Vereins-Produkte & Abo

```sql
CREATE TABLE club_plans (
    id              TEXT PRIMARY KEY,           -- 'free', 'verein_starter', 'verein_pro'
    name            TEXT NOT NULL,
    description     TEXT,
    price_monthly_cents INTEGER,
    price_yearly_cents  INTEGER,
    stripe_price_id_monthly TEXT,
    stripe_price_id_yearly  TEXT,
    active          BOOLEAN NOT NULL DEFAULT true,
    sort_order      INTEGER NOT NULL DEFAULT 0,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE club_subscriptions (
    id              SERIAL PRIMARY KEY,
    club_id         INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
    plan_id         TEXT NOT NULL REFERENCES club_plans(id),
    status          TEXT NOT NULL DEFAULT 'active',  -- active|trial|past_due|cancelled
    started_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    ends_at         TIMESTAMPTZ,
    trial_ends_at   TIMESTAMPTZ,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE (club_id)  -- ein aktiver Plan pro Verein (v1)
);

CREATE TABLE club_plan_limits (
    id              SERIAL PRIMARY KEY,
    plan_id         TEXT NOT NULL REFERENCES club_plans(id) ON DELETE CASCADE,
    feature_id      TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
    limit_value     INTEGER,                    -- NULL=∞, 0=deaktiviert
    UNIQUE (plan_id, feature_id)
);
```

### 5.3 Overrides, Grants, Verbrauch

```sql
CREATE TABLE club_feature_overrides (
    id              SERIAL PRIMARY KEY,
    club_id         INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
    feature_id      TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
    limit_value     INTEGER NOT NULL,
    reason          TEXT,
    set_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE (club_id, feature_id)
);

CREATE TABLE club_access_grants (
    id              SERIAL PRIMARY KEY,
    club_id         INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
    plan_id         TEXT REFERENCES club_plans(id),
    feature_id      TEXT REFERENCES features(id),  -- optional Einzel-Feature
    grant_limit     INTEGER,
    starts_at       TIMESTAMPTZ NOT NULL,
    ends_at         TIMESTAMPTZ NOT NULL,
    reason          TEXT,
    created_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL
);

CREATE TABLE club_feature_usage (
    id              SERIAL PRIMARY KEY,
    club_id         INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
    feature_id      TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
    usage_count     INTEGER NOT NULL DEFAULT 0,
    reset_at        TIMESTAMPTZ,
    last_used_at    TIMESTAMPTZ,
    UNIQUE (club_id, feature_id)
);

-- Optional: Attribution / Fairness / Audit
CREATE TABLE club_feature_usage_events (
    id              BIGSERIAL PRIMARY KEY,
    club_id         INT NOT NULL,
    feature_id      TEXT NOT NULL,
    profile_id      INT REFERENCES profiles(id) ON DELETE SET NULL,
    action          TEXT NOT NULL,              -- 'ai_suggest', 'exercise_create', ...
    created_at      TIMESTAMPTZ DEFAULT NOW()
);
```

### 5.4 Capabilities (Rollen — Kurzreferenz)

Siehe `CAPABILITY_CATALOG.v1.md` für IDs. Tabellen:

```sql
CREATE TABLE capabilities (
    id              TEXT PRIMARY KEY,
    name            TEXT NOT NULL,
    description     TEXT,
    domain          TEXT NOT NULL,
    min_account_state TEXT NOT NULL DEFAULT 'active_member',
    linked_feature_id TEXT REFERENCES features(id),  -- optional Kontingent
    active          BOOLEAN NOT NULL DEFAULT true
);

CREATE TABLE club_role_capability_grants (
    role_code       TEXT NOT NULL,              -- club_admin, trainer, ...
    capability_id   TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
    PRIMARY KEY (role_code, capability_id)
);

CREATE TABLE portal_role_capability_grants (
    portal_role     TEXT NOT NULL,              -- admin, superadmin
    capability_id   TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
    PRIMARY KEY (portal_role, capability_id)
);
```

---

## 6. Shinkan Feature-Katalog (Seed v1)

Übernahme aus `001_auth_membership.sql` + Ist-Endpoints, angereichert:

| feature_id | category | limit_type | reset_period | enforcement_subject | Default Free | Beschreibung |
|------------|----------|------------|--------------|---------------------|--------------|--------------|
| `exercises` | content | count | never | club | 100 | Anzahl Übungen im Verein (Bestand) |
| `exercise_media` | content | count | monthly | club | 20 | Medien-Uploads / Monat |
| `training_units` | planning | count | monthly | club | 40 | Geplante/durchgeführte Einheiten |
| `training_programs` | planning | count | never | club | 5 | Module + Rahmenprogramme (kombiniert v1) |
| `training_groups` | org | count | never | club | 10 | Trainingsgruppen |
| `active_members` | org | count | never | club | 25 | Aktive Mitglieder |
| `ai_calls` | ai | count | monthly | club | 0 | KI-Aufrufe (Suggest, Regenerate, Planung) |
| `ai_pipeline` | ai | boolean | never | club | 0 | Erweiterte KI-Pipelines (Batch, später) |
| `wiki_import` | integration | boolean | never | portal | 0 | MediaWiki-Import (Superadmin) |
| `data_export` | integration | boolean | never | club | 0 | Export-Funktionen (wenn eingeführt) |

**Hinweis:** Free-Defaults sind Produktentscheidung — Tabelle dient Implementierung.

### 6.1 Beispiel-Pläne (Seed)

| plan_id | ai_calls/Monat | exercises | active_members |
|---------|----------------|-----------|----------------|
| `free` | 0 | 100 | 25 |
| `verein_starter` | 30 | 500 | 80 |
| `verein_pro` | 200 | NULL (∞) | NULL |
| `pilot` | 100 | NULL | NULL |

Jeder Verein erhält bei Anlage durch Superadmin initial `club_subscriptions.plan_id = 'free'` (oder `pilot`).

---

## 7. Auflösungslogik

### 7.1 Effektiver Vereinsplan

```python
def get_effective_club_plan(cur, club_id: int) -> str:
    """
    1. Aktiver club_access_grants mit plan_id (höchste Priorität, Zeitfenster)
    2. club_subscriptions.status == 'active' → plan_id
    3. Fallback 'free'
    """
```

### 7.2 Feature-Limit (analog Mitai `check_feature_access`)

```python
def check_club_feature_access(
    cur,
    club_id: int,
    feature_id: str,
    *,
    profile_id: int | None = None,  # nur für Logging / optionale Profil-Boni später
) -> dict:
    """
    Priorität:
    1. club_feature_overrides (club_id, feature_id)
    2. club_plan_limits für get_effective_club_plan(club_id)
    3. features.default_limit

    Auswertung:
    - limit_type boolean: limit_value == 1
    - limit_type count: used < limit (club_feature_usage, reset beachten)

    Returns: { allowed, limit, used, remaining, reason, reset_at }
    """
```

### 7.3 Vollständige Request-Kette

```
1. require_auth
2. assert_account_state(min_state)          # unverified / verified_pending_club / active_member
3. get_tenant_context
4. assert_capability(tenant, cap_id)        # Rollen-Achse
5. assert_content_governance(...)           # nur bei Objekt-Endpoints
6. check_club_feature_access(club_id, feature_id)
7. … Business-Logik …
8. consume_club_feature_with_usage(…) + merge_feature_usage_into_response(payload, usage)
   # Standard: zählen, JSON-Log phase=consume, feature_usage in Response
9. optional: club_feature_usage_events (profile_id, action)
```

**Response-Standard (alle Consume-Endpoints):** JSON-Feld `feature_usage` — Map `feature_id → { allowed, used, limit, remaining, reason, … }` wie `GET /me/entitlements`. Frontend: `request()` synchronisiert Entitlements automatisch (`featureUsageSync.js`); UI-Komponenten brauchen keinen Einzelcode.

### 7.4 Wer zählt als Verbrauch?

| Aktion | increment | Subjekt |
|--------|-----------|---------|
| `POST /exercises` (neu) | `exercises` | `club_id` des Objekts oder `effective_club_id` |
| Medien-Upload | `exercise_media` | Verein des Mediums |
| KI Suggest/Regenerate | `ai_calls` | `effective_club_id` |
| Mitglied hinzufügen | `active_members` | Ziel-`club_id` |
| Trainingsgruppe anlegen | `training_groups` | `club_id` |

**Mitai-Regel:** Counter **nicht** bei UPDATE/DELETE erhöhen.

---

## 8. API-Oberfläche

### 8.1 Nutzer / Vereinsadmin

```
GET /api/clubs/{club_id}/entitlements
```

Kombiniert Capabilities + Feature-Kontingente (siehe `CAPABILITY_CATALOG.v1.md` §7.1).

```
GET /api/me/entitlements?club_id=12
```

Bequemer Alias für aktiven Verein.

### 8.2 Superadmin / Plattform

| Endpoint | Zweck |
|----------|-------|
| `GET/PUT /api/admin/club-plans` | Plan-CRUD |
| `GET/PUT /api/admin/club-plan-limits` | Matrix |
| `GET/PUT /api/admin/clubs/{id}/subscription` | Verein-Abo |
| `GET/PUT /api/admin/clubs/{id}/feature-overrides` | Sonderkontingente |
| `POST /api/admin/clubs/{id}/access-grants` | Trial/Promo |

Vorbild UI: Mitai `AdminTierLimitsPage.jsx`, `AdminUserRestrictionsPage.jsx` → Vereins-Kontext.

### 8.3 Geplant: Vereinsgründung

```
POST /api/club-creation-requests     # Nutzer (verified_pending_club)
GET  /api/admin/club-creation-requests
POST /api/admin/club-creation-requests/{id}/approve  # legt club + subscription an
```

---

## 9. Vier-Phasen-Rollout (aus Mitai)

| Phase | Shinkan-Aktivität | Nutzer sichtbar? |
|-------|-------------------|------------------|
| **0** | Schema-Migration, Seed `features` + `club_plans`, Drift `001` bereinigen | Nein |
| **1** | Account-Gates + Capability-Grants (ohne Limits) | Onboarding-Hinweise |
| **2** | `check_club_feature_access` — **nur JSON-Log** (`feature_logger` analog Mitai) | Nein |
| **3** | `GET …/entitlements` + UsageBadge im UI | Ja (Kontingent-Anzeige) |
| **4** | HTTP 403 bei Limit + `increment` | Ja (Hard-Block) |

**Reihenfolge innerhalb Phase 4:** zuerst `ai_calls`, dann `exercise_media`, dann Bestands-Limits (`exercises`, `active_members`).

---

## 10. CI / Test-Isolation (Betrieb)

Unabhängig vom Membership-System — **Pflicht** wegen Prod-Vorfälle (`access_layer_it_*@test.local`):

| Regel | Umsetzung |
|-------|-----------|
| Integrationstests nie gegen Prod-DB | Eigene Test-DB oder Job-Postgres in Gitea |
| `ENVIRONMENT=production` + `ALLOW_INTEGRATION_TESTS` | Default `0`, Tests abbrechen |
| Test-Accounts | E-Mail `@test.local` oder `profiles.is_test_account` |
| Cleanup | Fixture-`finally` + Nightly-Job löscht Leichen |

`.gitea/workflows/test.yml`: pytest-backend gegen Deploy-DB **ersetzen** durch isolierte DB (eigenes Epic, parallel zu Membership).

---

## 11. Implementierungs-Roadmap (gesamt)

| Schritt | Deliverable | Membership-relevant |
|---------|-------------|-------------------|
| M0 | CI-Isolation + Prod-Cleanup-Runbook | Nein |
| M1 | Migration Feature-Schema v9c + `club_plans`/`club_subscriptions` (leer nutzbar) | **Ja** |
| M2 | `check_club_feature_access` + Seed Pläne | **Ja** |
| M3 | Account-Lifecycle + Capability-Grants | Capabilities |
| M4 | `GET /me/entitlements` | **Ja** |
| M5 | Enforcement `ai_calls` (Phase 4) | **Ja** |
| M6 | Admin Plan-Matrix UI | **Ja** |
| M7 | `club_creation_requests` | Prozess |
| M8 | Stripe / Rechnung | Später |

**Nach Produktentscheidungen 2026-06-06** (Details `MEMBERSHIP_RBAC_DECISIONS_2026-06.md` §4):

| Phase | Paket | Priorität |
|-------|--------|-----------|
| A | Onboarding-Gates vollständig (`verified_pending_club`) | **Als Nächstes** |
| B | M7 Vereinsgründung beantragen | hoch |
| C | M5 Hard-Block `ai_calls` | danach |
| D | M6 Superadmin-UI | danach |
| E | Systemrolle `co_trainer` + Frontend-Entitlements | v1 Rollen |
| F | Trainer-Member-Budgets (v2) | später |

---

## 12. Offene Produktentscheidungen

Vor M6 festlegen:

1. **Zählen `active_members`:** alle Mitglieder oder nur Rollen mit Planungsrecht?
2. **Soft-Limit vs. Hard-Stop:** Warnung bei 80 % oder sofort 403?
3. **Pilotverein:** eigener Plan `pilot` mit hohen Limits?
4. **KI-Fairness:** nur Vereinslimit oder zusätzlich Max pro Trainer/Monat?
5. **Offizielle Inhalte:** für `verified_pending_club` sichtbar oder gesperrt? → **entschieden: gesperrt** (`MEMBERSHIP_RBAC_DECISIONS_2026-06.md` §1.1)
6. **Portal `admin` vs. `superadmin`:** Wer darf Vereine anlegen? (Ziel: nur `superadmin` für Freigabe)

---

## 13. Referenzen

| Pfad | Inhalt |
|------|--------|
| `c:/dev/mitai-jinkendo/backend/migrations/v9c_subscription_system.sql` | Mitai-Schema-Vorlage |
| `c:/dev/mitai-jinkendo/.claude/docs/architecture/FEATURE_ENFORCEMENT.md` | 4-Phasen-Modell |
| `c:/dev/mitai-jinkendo/.claude/docs/technical/MEMBERSHIP_SYSTEM.md` | Mitai-Hauptdoku |
| `c:/dev/mitai-jinkendo/.claude/docs/technical/CENTRAL_SUBSCRIPTION_SYSTEM.md` | Jinkendo-Familie später |
| `CAPABILITY_CATALOG.v1.md` | Rollen & Capabilities |
| `MULTI_TENANCY_RBAC_ARCHITECTURE.md` §4.6 | Ursprüngliches Vereinsabo-Zielbild |
| `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` | Stufe E/F |

---

**Changelog**

- 2026-06-06: v1 — Mitai-Mapping, Ziel-Schema, Feature-Seed, Auflösungslogik, Rollout.
