L1L2 gen1Gen2 kialakítása

This commit is contained in:
Roo
2026-07-24 09:56:21 +00:00
parent 4594de6c11
commit 33c4d793db
49 changed files with 4108 additions and 127 deletions

View File

@@ -331,3 +331,123 @@ if any([row_address_zip, row_address_street_name, row_address_street_type, row_a
1. **Adatbázis:** 33 rekord a `marketplace.service_providers` táblában, mind `status=approved`, `source=api_import`
2. **API Search:** `GET /api/v1/providers/search?query=biztosito` visszaadja a biztosítókat (pl. Allianz, Generali, Groupama)
3. **ServiceProfile** és **ServiceExpertise** kapcsolatok létrejöttek minden providerhez
---
## P0 EPIC: i18n JSONB Database Migration (Phase 1) — Gitea #399
**Dátum:** 2026-07-23
**Scope:** Backend (Models, API, Services, Schemas, Seeds)
### 🔧 Módosított fájlok
#### 1. Modellek — Régi oszlopok eltávolítva
- [`marketplace/service.py`](backend/app/models/marketplace/service.py) — `ExpertiseTag`: `name_hu`, `name_en`, `name_translations`, `description` → csak `name_i18n`, `description_i18n`
- [`vehicle/vehicle_definitions.py`](backend/app/models/vehicle/vehicle_definitions.py) — `BodyTypeDictionary`: `name_hu` → csak `name_i18n`
- [`core_logic.py`](backend/app/models/core_logic.py) — `ServiceCatalog`: `name`, `description` → csak `name_i18n`, `description_i18n`
#### 2. API Végpontok
- [`providers.py`](backend/app/api/v1/endpoints/providers.py) — Category tree, autocomplete: `name_hu`/`name_en` → `name_i18n`
- [`catalog.py`](backend/app/api/v1/endpoints/catalog.py) — Body types: `name_hu` → `name_i18n`
#### 3. Service Réteg
- [`provider_service.py`](backend/app/services/provider_service.py) — CategoryInfo, tag creation, tag search: `name_hu`/`name_en` → `name_i18n`
#### 4. Schema Réteg
- [`provider.py`](backend/app/schemas/provider.py) — `CategoryTreeNode`, `CategoryAutocompleteItem`, `ExpertiseCategoryOut`, `CategoryInfo`: `name_hu`/`name_en` → `name_i18n`
#### 5. Seed Scriptek (7 fájl)
- `seed_expertise_tags.py`, `seed_dismantler_category.py`, `seed_gas_station.py`, `seed_expertise_enterprise.py`, `seed_insurance_providers.py`, `seed_body_types.py`, `seed_expertise_taxonomy.py`
#### 6. Adatbázis
- 6 shadow column eltávolítva SQL ALTER TABLE segítségével
- Sync engine: 0 shadow column, 1281 OK — tökéletes szinkron
### ⚠️ Megjegyzés
- A konténer újraépítése után pre-existing import hibák derültek ki az `expenses.py`-ban (3 db):
1. `from app.models.fleet.organization import Organization` → `app.models.marketplace.organization`
2. `from app.models.fleet.org_member import OrganizationMember` → `app.models.marketplace.organization`
3. `from app.models.marketplace.service import ServiceProvider` → `app.models.identity.social`
4. `from app.models.system.system_parameter import SystemParameter` → `app.models.system.system`
- Mind a 4 import hiba javítva, konténer újraépítve, API sikeresen fut.
A konténer indításakor előre létező import hibák jelentkeztek (`expenses.py` → `Organization`, `AssetEvent`, `OdometerReading` rossz import útvonalai), amelyek NEM kapcsolódnak a #399-es kártyához. Ezek javítása külön kártyát igényel.
---
## P1 EPIC - Registration UI Upgrade & Google SSO Integration
**Dátum:** 2026-07-23
### Módosított fájlok
#### Backend
1. **`backend/app/api/v1/endpoints/auth.py`** — Google OAuth handler hozzáadva:
- `GoogleAuthRequest` Pydantic modell (`id_token`, `access_token`, `referred_by_code` mezőkkel)
- `POST /auth/google` végpont: Google token verifikáció (tokeninfo API), felhasználó lekérés/létrehozás `SocialAuthService.get_or_create_social_user()`-on keresztül, JWT token generálás, refresh_token cookie beállítás
- Támogatja mind az `id_token` (Google Sign-In), mind az `access_token` (OAuth2 token client) típusokat
2. **`backend/app/services/social_auth_service.py`** — `get_or_create_social_user()` metódus kiegészítve:
- `referred_by_code` paraméter hozzáadva
- Új social regisztrációnál a meghívó kód alapján a referrer (`User.referral_code`) felkutatása és `referred_by_id` beállítása
- `generate_secure_slug` importálva az új felhasználók referral_code generálásához
#### Frontend
3. **`frontend_app/src/components/LoginModal.vue`** — Regisztrációs UI frissítve:
- `referredByCode` ref hozzáadva
- "Meghívó kód" (Referral Code) input mező hozzáadva a regisztrációs űrlapba (jelszó mező alá)
- "Vagy regisztrálj Social fiókkal" elválasztó + Google gomb hozzáadva a regisztrációs űrlapba (a login form dizájnját tükrözve)
- Google gomb @click esemény bekötve a login űrlapon (`handleGoogleLogin`)
- `handleGoogleRegister()` függvény: a meghívó kódot localStorage-ba menti (`pending_referral_code`), majd meghívja a `handleGoogleLogin()`-t
- `handleRegister()` frissítve: `referred_by_code` mezőt is küld a backendnek
- `loadGoogleScript()`: dinamikusan betölti a Google Identity Services SDK-t
4. **`frontend_app/src/stores/auth.ts`** — `googleAuth` action hozzáadva:
- `access_token` küldése a `/auth/google` végpontra
- Opcionális `referred_by_code` támogatása
- Token perzisztálás, user profil és szervezetek betöltése
5. **`frontend_app/.env`** — `VITE_GOOGLE_CLIENT_ID` környezeti változó hozzáadva
### Technikai részletek
- **Google OAuth flow**: A frontend a Google Identity Services (GIS) `initTokenClient`-jével kér `access_token`-t, amit a backend a `https://oauth2.googleapis.com/tokeninfo?access_token={token}` endpointon verifikál
- **Referral code flow**: Regisztrációkor a meghívó kódot a frontend elküldi a backendnek, ami a `User.referral_code` mező alapján megkeresi a referrer-t és beállítja a `referred_by_id` kapcsolatot
- **Biztonság**: A backend ellenőrzi a token `azp` (authorized party) mezőjét a `GOOGLE_CLIENT_ID`-val
- **Adatbázis**: Nincs új migráció, a meglévő `User.referral_code` és `referred_by_id` mezők használva
- **Sync engine**: 1281 elem szinkronban, 0 hiba
- **Modul betöltés**: Mindkét módosított backend modul sikeresen betöltődik
## 2026-07-23 — Referral & Credit Ecosystem Full Stack Analysis (#409)
**Elemzés típusa:** Read-only audit
**Vizsgált komponensek:** identity.users, identity.wallet, gamification.point_rules, gamification.points_ledger, audit.financial_ledger, AuthService, SocialAuthService, GamificationService, LoginModal.vue, auth.ts
**Főbb megállapítások:**
1. Single-level (L1) referral tracking létezik `User.referred_by_id` mezőn keresztül
2. Jutalmazás kizárólag XP alapú (50 pont P2P_REFERRAL_SUCCESS), nincs kredit-alapú jutalom
3. **KRITIKUS HIBA:** `auth.ts register()` nem küldi el a `referred_by_code`-ot a payload-ban
4. Hiányzik: dedikált Referral tábla, multi-level (L2/L3) követés, commission engine, referral API-k
5. FinancialLedger és Wallet infrastruktúra alkalmas lenne bővítésre
**Dokumentáció:** `/opt/docker/docs/referral_credit_ecosystem_analysis.md`
---
## 2-Level MLM Commission Distribution
**Dátum:** 2026-07-24
### Megvalósított komponensek:
1. **Model** [`backend/app/models/marketplace/commission.py`](backend/app/models/marketplace/commission.py:119) — `upline_commission_percent` oszlop hozzáadva (Numeric(5,2), nullable, default=0.00)
2. **Schemas** [`backend/app/schemas/commission.py`](backend/app/schemas/commission.py) — `CommissionDistributionRequest`, `CommissionDistributionItem`, `CommissionDistributionResponse` új Pydantic modellek
3. **Service** [`backend/app/services/commission_service.py`](backend/app/services/commission_service.py:377) — `distribute_commission()` 2-level MLM engine: buyer → Gen1 (commission_percent) → Gen2 (upline_commission_percent)
4. **API** [`backend/app/api/v1/endpoints/admin_commission.py`](backend/app/api/v1/endpoints/admin_commission.py:250) — `POST /admin/commission-rules/distribute` végpont
5. **Frontend** [`frontend_admin/pages/finance/commission-rules.vue`](frontend_admin/pages/finance/commission-rules.vue) — `upline_commission_percent` mező az admin UI-ban
6. **i18n** — EN/HU locale fájlok frissítve
### Teszteredmények:
- **4/4 teszt PASS**
- Gen1: 5% of 100000 = 5000.00 ✅
- Gen2: 2% of 100000 = 2000.00 ✅
- Total: 7000.00 ✅
- Max amount cap (50000) verified ✅
- Sync engine: 1303 OK, 0 Fixed, 0 Shadow

View File

@@ -8,6 +8,7 @@ from app.api.v1.endpoints import (
admin_packages, admin_services, admin_organizations, admin_users, admin_persons, constants, providers,
subscriptions, marketing, admin_permissions, regions,
admin_gamification, admin_providers, locations,
admin_commission,
)
api_router = APIRouter()
@@ -48,3 +49,4 @@ api_router.include_router(admin_permissions.router, prefix="", tags=["Admin Perm
api_router.include_router(admin_gamification.router, prefix="/admin/gamification", tags=["Admin Gamification"])
api_router.include_router(admin_providers.router, prefix="/admin/providers", tags=["Admin Provider Moderation"])
api_router.include_router(locations.router, prefix="", tags=["Location Autocomplete"])
api_router.include_router(admin_commission.router, prefix="/admin/commission-rules", tags=["Admin Commission Rules"])

View File

@@ -0,0 +1,273 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/admin_commission.py
"""
Admin API endpoints for CommissionRule CRUD, Priority Resolution Engine,
and 2-Level MLM Commission Distribution.
THOUGHT PROCESS:
- All endpoints are prefixed with /admin/commission-rules (registered in api.py).
- Staff-level access via get_current_staff dependency (SUPERADMIN, ADMIN, etc.).
- The GET /active endpoint implements the Priority Resolution Algorithm
(Campaign > Region > Tier) from the service layer.
- Pagination, filtering by type/tier/region/active/campaign are supported.
- Soft-delete via PATCH setting is_active=False; hard DELETE also available.
- POST /distribute implements the 2-Level MLM commission distribution:
Gen1 (direct referrer) gets commission_percent, Gen2 (upline) gets
upline_commission_percent from the same rule.
"""
import logging
from datetime import date
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_staff
from app.db.session import get_db
from app.models.identity.identity import User
from app.models.marketplace.commission import (
CommissionRule,
CommissionRuleType,
CommissionTier,
)
from app.schemas.commission import (
CommissionRuleCreate,
CommissionRuleUpdate,
CommissionRuleResponse,
CommissionRuleListResponse,
CommissionDistributionRequest,
CommissionDistributionResponse,
)
from app.services import commission_service
logger = logging.getLogger("admin-commission")
router = APIRouter()
# ──────────────────────────────────────────────────────────────────────────────
# LIST: GET /admin/commission-rules
# ──────────────────────────────────────────────────────────────────────────────
@router.get(
"",
response_model=CommissionRuleListResponse,
tags=["Admin Commission Rules"],
)
async def list_commission_rules(
page: int = Query(1, ge=1, description="Oldalszám"),
page_size: int = Query(20, ge=1, le=100, description="Elemek száma oldalanként"),
rule_type: Optional[CommissionRuleType] = Query(None, description="Szűrés típus szerint (L1_REWARD / L2_COMMISSION)"),
tier: Optional[CommissionTier] = Query(None, description="Szűrés szint szerint (STANDARD / VIP / PLATINUM / ENTERPRISE)"),
region_code: Optional[str] = Query(None, max_length=10, description="Szűrés régió szerint (pl. HU, GLOBAL)"),
is_active: Optional[bool] = Query(None, description="Szűrés aktív/inaktív státusz szerint"),
is_campaign: Optional[bool] = Query(None, description="Szűrés kampány/állandó szabály szerint"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_staff),
):
"""List all commission rules with optional filters and pagination."""
rules, total = await commission_service.list_rules(
db,
page=page,
page_size=page_size,
rule_type=rule_type,
tier=tier,
region_code=region_code,
is_active=is_active,
is_campaign=is_campaign,
)
return CommissionRuleListResponse(
items=[CommissionRuleResponse.model_validate(r) for r in rules],
total=total,
page=page,
page_size=page_size,
)
# ──────────────────────────────────────────────────────────────────────────────
# GET ACTIVE RULE: GET /admin/commission-rules/active
# ──────────────────────────────────────────────────────────────────────────────
@router.get(
"/active",
response_model=CommissionRuleResponse,
tags=["Admin Commission Rules"],
)
async def get_active_commission_rule(
rule_type: CommissionRuleType = Query(..., description="Jutalék típusa (L1_REWARD / L2_COMMISSION)"),
tier: CommissionTier = Query(CommissionTier.STANDARD, description="Felhasználó szintje"),
region_code: str = Query("GLOBAL", max_length=10, description="Régiókód (ISO 3166-1 alpha-2)"),
transaction_date: date = Query(..., description="Tranzakció dátuma (ISO 8601, pl. 2026-07-24)"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_staff),
):
"""
Priority Resolution Engine: Find the most specific active rule.
Resolution order:
1. Campaign rules over permanent rules
2. Most specific region match (HU > GLOBAL)
3. Highest tier match (PLATINUM > VIP > STANDARD > ENTERPRISE)
"""
rule = await commission_service.get_active_rule(
db,
rule_type=rule_type,
tier=tier,
region_code=region_code,
transaction_date=transaction_date,
)
if not rule:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No active commission rule found for the given parameters.",
)
return CommissionRuleResponse.model_validate(rule)
# ──────────────────────────────────────────────────────────────────────────────
# GET SINGLE: GET /admin/commission-rules/{rule_id}
# ──────────────────────────────────────────────────────────────────────────────
@router.get(
"/{rule_id}",
response_model=CommissionRuleResponse,
tags=["Admin Commission Rules"],
)
async def get_commission_rule(
rule_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_staff),
):
"""Get a single commission rule by ID."""
from sqlalchemy import select
stmt = select(CommissionRule).where(CommissionRule.id == rule_id)
result = await db.execute(stmt)
rule = result.scalar_one_or_none()
if not rule:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Commission rule with id={rule_id} not found.",
)
return CommissionRuleResponse.model_validate(rule)
# ──────────────────────────────────────────────────────────────────────────────
# CREATE: POST /admin/commission-rules
# ──────────────────────────────────────────────────────────────────────────────
@router.post(
"",
response_model=CommissionRuleResponse,
status_code=status.HTTP_201_CREATED,
tags=["Admin Commission Rules"],
)
async def create_commission_rule(
data: CommissionRuleCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_staff),
):
"""Create a new commission rule."""
rule = await commission_service.create_commission_rule(
db, data=data, admin_user_id=current_user.id,
)
return CommissionRuleResponse.model_validate(rule)
# ──────────────────────────────────────────────────────────────────────────────
# UPDATE: PUT /admin/commission-rules/{rule_id}
# ──────────────────────────────────────────────────────────────────────────────
@router.put(
"/{rule_id}",
response_model=CommissionRuleResponse,
tags=["Admin Commission Rules"],
)
async def update_commission_rule(
rule_id: int,
data: CommissionRuleUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_staff),
):
"""Update an existing commission rule (partial update)."""
rule = await commission_service.update_commission_rule(db, rule_id, data)
if not rule:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Commission rule with id={rule_id} not found.",
)
return CommissionRuleResponse.model_validate(rule)
# ──────────────────────────────────────────────────────────────────────────────
# SOFT DELETE (DEACTIVATE): DELETE /admin/commission-rules/{rule_id}
# ──────────────────────────────────────────────────────────────────────────────
@router.delete(
"/{rule_id}",
status_code=status.HTTP_200_OK,
tags=["Admin Commission Rules"],
)
async def deactivate_commission_rule(
rule_id: int,
hard_delete: bool = Query(False, description="Végleges törlés (alapértelmezett: soft-delete / deaktiválás)"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_staff),
):
"""
Deactivate (soft-delete) or permanently delete a commission rule.
By default, sets is_active=False (soft delete).
Use ?hard_delete=true for permanent removal.
"""
if hard_delete:
deleted = await commission_service.hard_delete_commission_rule(db, rule_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Commission rule with id={rule_id} not found.",
)
return {"message": f"Commission rule {rule_id} permanently deleted."}
rule = await commission_service.deactivate_commission_rule(db, rule_id)
if not rule:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Commission rule with id={rule_id} not found.",
)
return CommissionRuleResponse.model_validate(rule)
# ──────────────────────────────────────────────────────────────────────────────
# 2-LEVEL MLM DISTRIBUTION: POST /admin/commission-rules/distribute
# ──────────────────────────────────────────────────────────────────────────────
@router.post(
"/distribute",
response_model=CommissionDistributionResponse,
tags=["Admin Commission Rules"],
)
async def distribute_commission(
request: CommissionDistributionRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_staff),
):
"""
2-Level MLM Commission Distribution Engine.
When a referred company makes a purchase, this endpoint:
1. Looks up the buyer's direct referrer (Gen1)
2. Finds the active L2_COMMISSION rule for Gen1's tier/region
3. Calculates Gen1's commission using commission_percent
4. If Gen1 has a referrer (Gen2/upline), calculates Gen2's commission
using upline_commission_percent from the same rule
Returns a breakdown of all commission payouts.
"""
result = await commission_service.distribute_commission(db, request)
return result

View File

@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.db.session import get_db
from app.services.auth_service import AuthService
from app.services.social_auth_service import SocialAuthService
from app.core.security import create_tokens, DEFAULT_RANK_MAP
from app.core.config import settings
from app.services.config_service import config
@@ -15,6 +16,10 @@ from app.models.identity import User, Device, UserDeviceLink # JAVÍTVA: Device-
from app.core.translation_helper import t # Translation helper
from pydantic import BaseModel, Field, EmailStr
from datetime import datetime, timezone
import httpx
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -380,3 +385,152 @@ async def restore_account_verify(
detail=result.get("message", "Érvénytelen vagy lejárt kód.")
)
return result
# ── GOOGLE OAUTH ──
class GoogleAuthRequest(BaseModel):
"""Google token from the frontend after successful Google Sign-In.
The frontend sends either an id_token (from Google Sign-In / One Tap)
or an access_token (from Google OAuth2 token client).
"""
id_token: Optional[str] = Field(None, description="Google ID token (JWT) from Google Sign-In")
access_token: Optional[str] = Field(None, description="Google OAuth2 access token from token client")
referred_by_code: Optional[str] = Field(None, description="Optional referral code from registration form")
@router.post("/google", response_model=Token)
async def google_auth(
request: GoogleAuthRequest,
response: Response,
db: AsyncSession = Depends(get_db),
):
"""
Google OAuth bejelentkezés / regisztráció.
A frontend Google Sign-In gombjára kattintva a felhasználó Google tokent kap.
Ezt a tokent küldi el a backendnek, amely:
1. Ellenőrzi a token érvényességét a Google nyilvános kulcsaival
2. Kinyeri az email-t, nevet és Google ID-t
3. Ha a felhasználó már létezik (Login): visszaadja a JWT tokent
4. Ha nem létezik (Register): létrehozza az új felhasználót a Google adataival
5. Opcionális referral_code tárolása, ha a regisztrációs űrlapon megadták
Supports both id_token (from Google Sign-In) and access_token (from OAuth2 token client).
"""
try:
# 1. Determine which token was provided and build verification URL
if request.id_token:
google_verify_url = f"https://oauth2.googleapis.com/tokeninfo?id_token={request.id_token}"
elif request.access_token:
google_verify_url = f"https://oauth2.googleapis.com/tokeninfo?access_token={request.access_token}"
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Hiányzó Google token. Adjon meg id_token vagy access_token értéket."
)
# 2. Verify Google token
async with httpx.AsyncClient() as client:
google_resp = await client.get(google_verify_url)
if google_resp.status_code != 200:
logger.error(f"Google token verification failed: {google_resp.text}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Érvénytelen Google token."
)
google_data = google_resp.json()
# 3. Validate audience (client ID) — only for id_token
# For access_token, the 'aud' field may not be present; validate by 'azp' or skip
if request.id_token:
if google_data.get("aud") != settings.GOOGLE_CLIENT_ID:
logger.error(f"Google token audience mismatch: {google_data.get('aud')}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Érvénytelen Google token (audience mismatch)."
)
else:
# For access_token, check azp (authorized party) matches our client ID
if google_data.get("azp") != settings.GOOGLE_CLIENT_ID:
logger.error(f"Google token azp mismatch: {google_data.get('azp')}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Érvénytelen Google token (azp mismatch)."
)
# 4. Extract user info
google_id = google_data.get("sub")
email = google_data.get("email", "")
first_name = google_data.get("given_name", "")
last_name = google_data.get("family_name", "")
if not google_id or not email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Hiányzó Google felhasználói adatok."
)
# 5. Get or create user via SocialAuthService
user = await SocialAuthService.get_or_create_social_user(
db=db,
provider="google",
social_id=google_id,
email=email,
first_name=first_name,
last_name=last_name,
referred_by_code=request.referred_by_code
)
if not user:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Hiba a felhasználó létrehozása során."
)
# 6. Generate JWT tokens (same logic as login)
ranks = await settings.get_db_setting(db, "rbac_rank_matrix", default=DEFAULT_RANK_MAP)
role_name = user.role.value if hasattr(user.role, 'value') else str(user.role)
role_key = role_name.upper()
token_data = {
"sub": str(user.id),
"role": role_name,
"rank": ranks.get(role_key, 10),
"scope_level": user.scope_level or "individual",
"scope_id": str(user.scope_id) if user.scope_id else str(user.id)
}
access, refresh = create_tokens(data=token_data, remember_me=False)
max_age_days = await config.get_setting(db, "auth_refresh_default_days", default=1)
max_age_sec = int(max_age_days) * 24 * 60 * 60
response.set_cookie(
key="refresh_token",
value=refresh,
httponly=True,
secure=True,
samesite="lax",
domain=settings.COOKIE_DOMAIN,
max_age=max_age_sec
)
return {
"access_token": access,
"refresh_token": refresh,
"token_type": "bearer",
"is_active": user.is_active
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Google auth error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Google hitelesítési hiba: {str(e)}"
)

View File

@@ -87,7 +87,7 @@ async def list_body_types(
current_user = Depends(deps.get_current_user)
):
"""Karosszéria-típus szótár lekérése. Opcionálisan szűrhető vehicle_class alapján."""
stmt = select(BodyTypeDictionary).order_by(BodyTypeDictionary.name_hu)
stmt = select(BodyTypeDictionary).order_by(BodyTypeDictionary.name_i18n)
if vehicle_class:
stmt = stmt.where(BodyTypeDictionary.vehicle_class == vehicle_class)
result = await db.execute(stmt)
@@ -97,10 +97,7 @@ async def list_body_types(
"id": r.id,
"vehicle_class": r.vehicle_class,
"code": r.code,
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
"name_i18n": r.name_i18n if r.name_i18n else {},
# --- [DEPRECATED] Old columns (Phase 3) ---
"name_hu": r.name_hu,
}
for r in rows
]

View File

@@ -39,12 +39,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_current_user
from app.models.fleet_finance.models import AssetCost, CostCategory
from app.models.vehicle.asset import Asset
from app.models.vehicle.asset_event import AssetEvent
from app.models.vehicle.odometer import OdometerReading
from app.models.fleet.organization import Organization
from app.models.fleet.org_member import OrganizationMember
from app.models.marketplace.service import ServiceProvider, ServiceProfile, ServiceExpertise
from app.models.system.system_parameter import SystemParameter
from app.models.vehicle.asset import AssetEvent
from app.models.vehicle.asset import OdometerReading
from app.models.marketplace.organization import Organization
from app.models.marketplace.organization import OrganizationMember
from app.models.identity.social import ServiceProvider
from app.models.marketplace.service import ServiceProfile, ServiceExpertise
from app.models.system.system import SystemParameter
from app.schemas.asset_cost import AssetCostCreate, AssetCostUpdate, AssetCostResponse
from app.services.gamification_service import GamificationService
from app.services.provider_service import find_or_create_provider_by_name

View File

@@ -77,12 +77,8 @@ async def get_category_tree(
nodes.append(CategoryTreeNode(
id=tag.id,
key=tag.key,
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n=tag.name_i18n if tag.name_i18n else {},
description_i18n=tag.description_i18n,
# --- [DEPRECATED] Old columns (Phase 3) ---
name_hu=tag.name_hu,
name_en=tag.name_en,
level=tag.level,
path=tag.path,
is_official=tag.is_official,
@@ -111,21 +107,17 @@ async def autocomplete_categories(
Szöveges kereső a Szint 2 (Szakma) és Szint 3 (Specifikus feladat) címkékhez.
Csak azokat listázza, ahol is_official=True.
A keresés a name_i18n JSONB (minden nyelven), key és name_hu/name_en mezőkben történik.
A keresés a name_i18n JSONB (minden nyelven) és key mezőkben történik.
"""
try:
# --- 🏗️ i18n JSONB Migration (Phase 3): JSONB → String cast keresés ---
stmt = select(ExpertiseTag).where(
ExpertiseTag.level >= 2,
ExpertiseTag.is_official == True,
or_(
cast(ExpertiseTag.name_i18n, String).ilike(f"%{q}%"),
ExpertiseTag.key.ilike(f"%{q}%"),
# --- [DEPRECATED] Old column fallback (Phase 3) ---
ExpertiseTag.name_hu.ilike(f"%{q}%"),
ExpertiseTag.name_en.ilike(f"%{q}%"),
),
).order_by(ExpertiseTag.level, ExpertiseTag.name_hu).limit(20)
).order_by(ExpertiseTag.level, ExpertiseTag.name_i18n).limit(20)
result = await db.execute(stmt)
tags = result.scalars().all()
@@ -134,11 +126,7 @@ async def autocomplete_categories(
CategoryAutocompleteItem(
id=t.id,
key=t.key,
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n=t.name_i18n if t.name_i18n else {},
# --- [DEPRECATED] Old columns (Phase 3) ---
name_hu=t.name_hu,
name_en=t.name_en,
level=t.level,
path=t.path,
parent_id=t.parent_id,

View File

@@ -37,6 +37,9 @@ from .marketplace.service import ServiceProfile, ExpertiseTag, ServiceExpertise
from .marketplace.staged_data import ServiceStaging, DiscoveryParameter, StagedVehicleData
from .marketplace.service_request import ServiceRequest
# 7b. Jutalék szabályok (Commission Rules)
from .marketplace.commission import CommissionRule, CommissionRuleType, CommissionTier
# 8. Közösségi és értékelési modellek (Social 3)
from .identity.social import ServiceProvider, Vote, Competition, UserScore, ServiceReview, ModerationStatus, SourceType
@@ -100,4 +103,6 @@ __all__ = [
"CampaignStatus", "CreativeType", "PlacementType",
# RBAC Phase 1
"SystemRole", "SystemPermission", "SystemRolePermission",
# Commission Rules (Phase 2)
"CommissionRule", "CommissionRuleType", "CommissionTier",
]

View File

@@ -250,9 +250,7 @@ class ServiceCatalog(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
service_code: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False) # pl. 'SRV_DATA_EXPORT'
name: Mapped[str] = mapped_column(String, nullable=False)
description: Mapped[Optional[str]] = mapped_column(String)
# --- 🏗️ i18n JSONB Migration (Phase 1) ---
# --- 🏗️ i18n JSONB Migration (Phase 1 - Complete) ---
name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))
description_i18n: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
credit_cost: Mapped[int] = mapped_column(Integer, default=0) # alapértelmezett ár kreditben

View File

@@ -159,6 +159,13 @@ class User(Base):
referred_by_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
current_sales_agent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
# Commission tier: determines which commission rules apply to this user
# STANDARD = one-time commission only, CONTRACTED = first-time + recurring renewal commission
commission_tier: Mapped[str] = mapped_column(
String(20), nullable=False, default="STANDARD", server_default=text("'STANDARD'"),
comment="Jutalék besorolás: STANDARD (egyszeri jutalék) vagy CONTRACTED (első + megújítási jutalék)"
)
is_active: Mapped[bool] = mapped_column(Boolean, default=False)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)

View File

@@ -30,6 +30,9 @@ from .staged_data import (
from .service_request import ServiceRequest
# Commission Rules (Phase 2)
from .commission import CommissionRule, CommissionRuleType, CommissionTier
__all__ = [
"Organization",
"OrganizationMember",
@@ -52,4 +55,7 @@ __all__ = [
"LocationType",
"StagedVehicleData",
"ServiceRequest",
"CommissionRule",
"CommissionRuleType",
"CommissionTier",
]

View File

@@ -0,0 +1,184 @@
# /opt/docker/dev/service_finder/backend/app/models/marketplace/commission.py
"""
CommissionRule: Dynamic, tiered, time-bound commission and reward rules.
Supports:
- L1 rewards (XP/credits for individual referrals)
- L2 commissions (% for company-to-company purchases)
- Tier-based multipliers (Standard, VIP, Platinum)
- Regional overrides (HU, Global, etc.)
- Time-bound promotional campaigns
THOUGHT PROCESS:
- Single table for both L1 and L2 avoids join complexity; nullable fields
differentiate type-specific values.
- region_code as simple string (ISO 3166-1 alpha-2) follows existing pattern
(see InsuranceProvider.country_code).
- start_date/end_date as Date (day-granular campaigns, no timezone issues).
- is_campaign boolean enables quick filtering without date parsing.
- UniqueConstraint on 5 columns prevents duplicate rules for the same
type/tier/region/date combination.
- metadata_json JSONB for future-proof UI fields (colors, icons, tooltips).
"""
import enum
from datetime import datetime, date
from typing import Optional
from sqlalchemy import (
String, Integer, Float, Boolean, DateTime, Date,
Text, Enum as SQLEnum, Numeric, UniqueConstraint, text
)
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB
from sqlalchemy.sql import func
from app.database import Base
class CommissionRuleType(str, enum.Enum):
"""
Két elsődleges jutalom típus:
- L1_REWARD: XP/kredit jutalom egyéni ajánlóknak (referral)
- L2_COMMISSION: Százalékos jutalék céges vásárlások után
"""
L1_REWARD = "L1_REWARD" # XP/credits for individual referrers
L2_COMMISSION = "L2_COMMISSION" # % commission for company purchases
class CommissionTier(str, enum.Enum):
"""Jutalék szintek / rétegek."""
STANDARD = "STANDARD"
VIP = "VIP"
PLATINUM = "PLATINUM"
ENTERPRISE = "ENTERPRISE"
CONTRACTED = "CONTRACTED"
class CommissionRule(Base):
"""
Dinamikus jutalék/jutalom szabályok.
Minden szabály egy adott típushoz (L1/L2), szinthez (tier),
régióhoz és opcionális időablakhoz tartozik.
A lekérdező motor a tranzakció időpontjában érvényes,
legspecifikusabb szabályt alkalmazza.
"""
__tablename__ = "commission_rules"
__table_args__ = (
UniqueConstraint(
'rule_type', 'tier', 'region_code',
'start_date', 'end_date',
name='uix_commission_rule_unique'
),
{"schema": "marketplace", "extend_existing": True}
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
# --- Rule Classification ---
rule_type: Mapped[CommissionRuleType] = mapped_column(
SQLEnum(CommissionRuleType, name="commission_rule_type", schema="marketplace"),
nullable=False,
index=True,
comment="L1_REWARD = XP/kredit jutalom, L2_COMMISSION = % jutalék"
)
tier: Mapped[CommissionTier] = mapped_column(
SQLEnum(CommissionTier, name="commission_tier", schema="marketplace"),
nullable=False,
default=CommissionTier.STANDARD,
index=True,
comment="Jutalék szint (STANDARD, VIP, PLATINUM, ENTERPRISE)"
)
# --- Regional Scope ---
region_code: Mapped[str] = mapped_column(
String(10),
nullable=False,
default="GLOBAL",
index=True,
comment="ISO 3166-1 alpha-2 országkód (pl. 'HU', 'DE') vagy 'GLOBAL'"
)
# --- Reward / Commission Values ---
# L1_REWARD fields
xp_reward: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True, default=0,
comment="L1: XP jutalom értéke"
)
credit_reward: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True, default=0,
comment="L1: Kredit jutalom értéke"
)
# L2_COMMISSION fields
commission_percent: Mapped[Optional[float]] = mapped_column(
Numeric(5, 2), nullable=True, default=0.00,
comment="L2: Jutalék százalék (pl. 5.00 = 5%)"
)
upline_commission_percent: Mapped[Optional[float]] = mapped_column(
Numeric(5, 2), nullable=True, default=0.00,
comment="L2: Upline (Gen2) jutalék százalék — a közvetlen ajánló feletti szintnek járó jutalék (pl. 2.00 = 2%)"
)
renewal_commission_percent: Mapped[Optional[float]] = mapped_column(
Numeric(5, 2), nullable=True, default=0.00,
comment="L2: Megújítási jutalék százalék (pl. 2.50 = 2.5%) - havi/éves előfizetés megújításakor járó jutalék"
)
commission_max_amount: Mapped[Optional[float]] = mapped_column(
Numeric(18, 2), nullable=True,
comment="L2: Maximális jutalék összeg (opcionális felső korlát)"
)
# --- Time-Bound Campaign ---
is_campaign: Mapped[bool] = mapped_column(
Boolean, default=False, server_default=text("false"),
comment="TRUE = időkorlátos kampány, FALSE = állandó szabály"
)
start_date: Mapped[Optional[date]] = mapped_column(
Date, nullable=True, index=True,
comment="Kampány kezdő dátuma (NULL = azonnal érvényes)"
)
end_date: Mapped[Optional[date]] = mapped_column(
Date, nullable=True, index=True,
comment="Kampány záró dátuma (NULL = nincs lejárat)"
)
# --- Metadata ---
name: Mapped[str] = mapped_column(
String(200), nullable=False,
comment="Emberi olvasható szabály név (pl. 'HU VIP Nyári Kampány')"
)
description: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
comment="Részletes leírás a szabályról"
)
is_active: Mapped[bool] = mapped_column(
Boolean, default=True, server_default=text("true"), index=True,
comment="TRUE = aktív és használható, FALSE = letiltva"
)
# --- Audit Trail ---
created_by: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True,
comment="Létrehozó admin felhasználó ID"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(),
onupdate=func.now()
)
# --- Extensibility ---
metadata_json: Mapped[Optional[dict]] = mapped_column(
JSONB, nullable=True, server_default=text("'{}'::jsonb"),
comment="Bővíthető metaadatok (pl. campaign banner URL, notes)"
)
def __repr__(self) -> str:
return (
f"<CommissionRule(id={self.id}, type='{self.rule_type}', "
f"tier='{self.tier}', region='{self.region_code}', "
f"active={self.is_active})>"
)

View File

@@ -111,12 +111,9 @@ class ExpertiseTag(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
key: Mapped[str] = mapped_column(String(50), unique=True, index=True)
name_hu: Mapped[Optional[str]] = mapped_column(String(100))
name_en: Mapped[Optional[str]] = mapped_column(String(100))
# --- 🏗️ i18n JSONB Migration (Phase 1) ---
# --- 🏗️ i18n JSONB Migration (Phase 1 - Complete) ---
name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))
description_i18n: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
name_translations: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
category: Mapped[Optional[str]] = mapped_column(String(30), index=True)
# --- 🏗️ 4-LEVEL HIERARCHY (2026-06-17) ---

View File

@@ -172,6 +172,5 @@ class BodyTypeDictionary(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
vehicle_class: Mapped[str] = mapped_column(String(50), index=True, nullable=False)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name_hu: Mapped[str] = mapped_column(String(100), nullable=False)
# --- 🏗️ i18n JSONB Migration (Phase 1) ---
# --- 🏗️ i18n JSONB Migration (Phase 1 - Complete) ---
name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))

View File

@@ -0,0 +1,138 @@
# /opt/docker/dev/service_finder/backend/app/schemas/commission.py
"""
Pydantic schemas for CommissionRule CRUD operations.
THOUGHT PROCESS:
- CommissionRuleCreate: All fields required for creating a new rule.
Uses Optional for type-specific fields (L1 vs L2).
- CommissionRuleUpdate: All fields optional for partial updates.
- CommissionRuleResponse: Full read model with from_attributes=True
for ORM compatibility.
- CommissionRuleListResponse: Paginated wrapper for admin listing.
"""
from pydantic import BaseModel, Field
from datetime import date, datetime
from typing import Optional, List
from enum import Enum
class CommissionRuleTypeEnum(str, Enum):
"""Jutalék típus: L1 = egyéni jutalom, L2 = céges jutalék."""
L1_REWARD = "L1_REWARD"
L2_COMMISSION = "L2_COMMISSION"
class CommissionTierEnum(str, Enum):
"""Jutalék szintek."""
STANDARD = "STANDARD"
VIP = "VIP"
PLATINUM = "PLATINUM"
ENTERPRISE = "ENTERPRISE"
CONTRACTED = "CONTRACTED"
class CommissionRuleCreate(BaseModel):
"""Schema új jutalék szabály létrehozásához."""
rule_type: CommissionRuleTypeEnum
tier: CommissionTierEnum = CommissionTierEnum.STANDARD
region_code: str = "GLOBAL"
xp_reward: Optional[int] = None
credit_reward: Optional[int] = None
commission_percent: Optional[float] = None
upline_commission_percent: Optional[float] = None
renewal_commission_percent: Optional[float] = None
commission_max_amount: Optional[float] = None
is_campaign: bool = False
start_date: Optional[date] = None
end_date: Optional[date] = None
name: str
description: Optional[str] = None
is_active: bool = True
class CommissionRuleUpdate(BaseModel):
"""Schema meglévő jutalék szabály módosításához (minden mező opcionális)."""
rule_type: Optional[CommissionRuleTypeEnum] = None
tier: Optional[CommissionTierEnum] = None
region_code: Optional[str] = None
xp_reward: Optional[int] = None
credit_reward: Optional[int] = None
commission_percent: Optional[float] = None
upline_commission_percent: Optional[float] = None
renewal_commission_percent: Optional[float] = None
commission_max_amount: Optional[float] = None
is_campaign: Optional[bool] = None
start_date: Optional[date] = None
end_date: Optional[date] = None
name: Optional[str] = None
description: Optional[str] = None
is_active: Optional[bool] = None
class CommissionRuleResponse(BaseModel):
"""Schema jutalék szabály adatainak lekéréséhez."""
id: int
rule_type: CommissionRuleTypeEnum
tier: CommissionTierEnum
region_code: str
xp_reward: Optional[int] = None
credit_reward: Optional[int] = None
commission_percent: Optional[float] = None
upline_commission_percent: Optional[float] = None
renewal_commission_percent: Optional[float] = None
commission_max_amount: Optional[float] = None
is_campaign: bool
start_date: Optional[date] = None
end_date: Optional[date] = None
name: str
description: Optional[str] = None
is_active: bool
created_by: Optional[int] = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class CommissionRuleListResponse(BaseModel):
"""Paginated list response for admin commission rules."""
items: List[CommissionRuleResponse]
total: int
page: int
page_size: int
class CommissionDistributionRequest(BaseModel):
"""
Request schema for the 2-level MLM commission distribution engine.
When a referred company makes a purchase, this request triggers the
distribution logic:
- Gen1 (direct referrer) receives commission_percent
- Gen2 (upline / Gen1's referrer) receives upline_commission_percent
"""
buyer_user_id: int = Field(..., description="The user ID who made the purchase")
transaction_amount: float = Field(..., gt=0, description="The purchase/subscription amount")
transaction_date: date = Field(..., description="Date of the transaction")
region_code: str = Field("GLOBAL", max_length=10, description="Region code for rule matching")
class CommissionDistributionItem(BaseModel):
"""Single commission payout item for one level."""
level: int = Field(..., description="1 = Gen1 (direct referrer), 2 = Gen2 (upline)")
user_id: int = Field(..., description="The user ID receiving the commission")
commission_percent: float = Field(..., description="The applied commission percentage")
commission_amount: float = Field(..., description="The calculated commission amount")
rule_id: int = Field(..., description="The CommissionRule ID that was applied")
class CommissionDistributionResponse(BaseModel):
"""
Response schema for the 2-level MLM commission distribution.
Contains the payout breakdown for both Gen1 and Gen2 levels.
"""
transaction_amount: float
items: List[CommissionDistributionItem]
total_commission: float = Field(..., description="Sum of all commission payouts")

View File

@@ -35,12 +35,8 @@ class CategoryTreeNode(BaseModel):
"""
id: int
key: str
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
description_i18n: Optional[dict] = Field(None, description="Lokalizált leírás (JSONB)")
# --- [DEPRECATED] Old columns (Phase 3) ---
name_hu: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
name_en: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
level: int = 0
path: Optional[str] = None
is_official: bool = True
@@ -57,11 +53,7 @@ class CategoryAutocompleteItem(BaseModel):
"""
id: int
key: str
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
# --- [DEPRECATED] Old columns (Phase 3) ---
name_hu: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
name_en: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
level: int = 0
path: Optional[str] = None
parent_id: Optional[int] = None
@@ -80,11 +72,7 @@ class ExpertiseCategoryOut(BaseModel):
"""
id: int
key: str
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
# --- [DEPRECATED] Old columns (Phase 3) ---
name_hu: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
name_en: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
category: Optional[str] = None
level: int = 0
parent_id: Optional[int] = None
@@ -101,11 +89,7 @@ class CategoryInfo(BaseModel):
szolgáltatásokat a kártyákon.
"""
id: int
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
# --- [DEPRECATED] Old columns (Phase 3) ---
name_hu: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
name_en: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
level: int = 0
key: str

View File

@@ -52,13 +52,9 @@ async def seed_dismantler_category():
# Beszúrás
tag = ExpertiseTag(
key=DISMANTLER_CATEGORY["key"],
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n=DISMANTLER_CATEGORY.get("name_i18n", {"hu": DISMANTLER_CATEGORY["name_hu"], "en": DISMANTLER_CATEGORY["name_en"]}),
name_hu=DISMANTLER_CATEGORY["name_hu"],
name_en=DISMANTLER_CATEGORY["name_en"],
category=DISMANTLER_CATEGORY["category"],
search_keywords=DISMANTLER_CATEGORY["search_keywords"],
description=DISMANTLER_CATEGORY["description"],
is_official=True,
discovery_points=10,
usage_count=0,

View File

@@ -230,14 +230,11 @@ async def seed_enterprise_taxonomy():
"hu": tag_data["name_hu"],
"en": tag_data["name_en"],
}),
name_hu=tag_data["name_hu"],
name_en=tag_data["name_en"],
category=tag_data.get("category", "other"),
level=tag_data["level"],
parent_id=tag_data.get("parent_id"),
path=path,
search_keywords=tag_data.get("search_keywords", []),
description=tag_data.get("description", ""),
is_official=True,
discovery_points=10,
usage_count=0,

View File

@@ -145,13 +145,9 @@ async def seed_expertise_tags():
for cat in BASE_CATEGORIES:
tag = ExpertiseTag(
key=cat["key"],
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n=cat.get("name_i18n", {"hu": cat["name_hu"], "en": cat["name_en"]}),
name_hu=cat["name_hu"],
name_en=cat["name_en"],
category=cat["category"],
search_keywords=cat["search_keywords"],
description=cat["description"],
is_official=True,
discovery_points=10,
usage_count=0,

View File

@@ -69,16 +69,12 @@ async def seed_gas_station_categories():
# Beszúrás
tag = ExpertiseTag(
key=cat["key"],
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n=cat.get("name_i18n", {"hu": cat["name_hu"], "en": cat["name_en"]}),
name_hu=cat["name_hu"],
name_en=cat["name_en"],
category=cat["category"],
level=cat["level"],
parent_id=cat["parent_id"],
path=cat["path"],
search_keywords=cat["search_keywords"],
description=cat["description"],
is_official=True,
discovery_points=10,
usage_count=0,

View File

@@ -607,7 +607,8 @@ async def seed_insurance_providers():
logger.error("Futtasd előbb a seed_expertise_tags.py scriptet!")
return
logger.info(f"✅ Expertise tag megtalálva: {expertise_tag.name_hu} (ID={expertise_tag.id})")
tag_name = expertise_tag.name_i18n.get("hu", expertise_tag.key) if expertise_tag.name_i18n else expertise_tag.key
logger.info(f"✅ Expertise tag megtalálva: {tag_name} (ID={expertise_tag.id})")
inserted_count = 0
skipped_count = 0
@@ -673,7 +674,7 @@ async def seed_insurance_providers():
)
db.add(new_expertise)
await db.flush()
logger.info(f"✅ ServiceExpertise kapcsolat létrehozva: {expertise_tag.name_hu}")
logger.info(f"✅ ServiceExpertise kapcsolat létrehozva: {tag_name}")
inserted_count += 1

View File

@@ -0,0 +1,528 @@
# /opt/docker/dev/service_finder/backend/app/services/commission_service.py
"""
CommissionRule service layer — CRUD + Priority Resolution Engine + 2-Level MLM Distribution.
THOUGHT PROCESS:
- The Priority Algorithm (Campaign > Region > Tier) is implemented in
get_active_rule() using SQLAlchemy case() expressions for server-side
ordering. This avoids loading all matching rules into Python memory.
- Campaign rules (is_campaign=True) always sort before permanent rules.
- Specific region match (e.g. "HU") sorts before "GLOBAL".
- Higher tiers (PLATINUM=0, VIP=1, STANDARD=2, ENTERPRISE=3) sort first.
- Soft-delete is handled via is_active=False (not actual row deletion).
- The service uses async/await throughout for FastAPI compatibility.
- 2-Level MLM: distribute_commission() resolves Gen1 (direct referrer) and
Gen2 (upline) payouts using commission_percent and upline_commission_percent.
"""
import logging
from datetime import date
from decimal import Decimal
from typing import Optional, List, Sequence
from sqlalchemy import select, func, case, or_, and_, delete as sa_delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.marketplace.commission import (
CommissionRule,
CommissionRuleType,
CommissionTier,
)
from app.models.identity.identity import User
from app.schemas.commission import (
CommissionRuleCreate,
CommissionRuleUpdate,
CommissionDistributionRequest,
CommissionDistributionItem,
CommissionDistributionResponse,
)
logger = logging.getLogger(__name__)
# ──────────────────────────────────────────────────────────────────────────────
# CRUD Operations
# ──────────────────────────────────────────────────────────────────────────────
async def create_commission_rule(
db: AsyncSession,
data: CommissionRuleCreate,
admin_user_id: int,
) -> CommissionRule:
"""
Create a new commission rule with validation.
Args:
db: Database session.
data: Pydantic schema with rule data.
admin_user_id: ID of the admin creating the rule.
Returns:
The newly created CommissionRule instance.
"""
rule = CommissionRule(
rule_type=CommissionRuleType(data.rule_type.value),
tier=CommissionTier(data.tier.value),
region_code=data.region_code,
xp_reward=data.xp_reward,
credit_reward=data.credit_reward,
commission_percent=data.commission_percent,
upline_commission_percent=data.upline_commission_percent,
renewal_commission_percent=data.renewal_commission_percent,
commission_max_amount=data.commission_max_amount,
is_campaign=data.is_campaign,
start_date=data.start_date,
end_date=data.end_date,
name=data.name,
description=data.description,
is_active=data.is_active,
created_by=admin_user_id,
)
db.add(rule)
await db.commit()
await db.refresh(rule)
logger.info(
"Commission rule created: id=%d type=%s tier=%s region=%s",
rule.id, rule.rule_type, rule.tier, rule.region_code,
)
return rule
async def update_commission_rule(
db: AsyncSession,
rule_id: int,
data: CommissionRuleUpdate,
) -> Optional[CommissionRule]:
"""
Partially update an existing commission rule.
Only the fields explicitly set in the update schema are applied.
Returns None if the rule does not exist.
"""
stmt = select(CommissionRule).where(CommissionRule.id == rule_id)
result = await db.execute(stmt)
rule = result.scalar_one_or_none()
if not rule:
return None
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
# Map enum fields from schema enums to model enums
if field == "rule_type" and value is not None:
setattr(rule, field, CommissionRuleType(value.value))
elif field == "tier" and value is not None:
setattr(rule, field, CommissionTier(value.value))
else:
setattr(rule, field, value)
await db.commit()
await db.refresh(rule)
logger.info("Commission rule updated: id=%d", rule.id)
return rule
async def deactivate_commission_rule(
db: AsyncSession,
rule_id: int,
) -> Optional[CommissionRule]:
"""
Soft-delete a commission rule by setting is_active=False.
Returns the deactivated rule, or None if not found.
"""
stmt = select(CommissionRule).where(CommissionRule.id == rule_id)
result = await db.execute(stmt)
rule = result.scalar_one_or_none()
if not rule:
return None
rule.is_active = False
await db.commit()
await db.refresh(rule)
logger.info("Commission rule deactivated: id=%d", rule.id)
return rule
async def hard_delete_commission_rule(
db: AsyncSession,
rule_id: int,
) -> bool:
"""
Permanently delete a commission rule (admin-only, use with caution).
Returns True if deleted, False if not found.
"""
stmt = select(CommissionRule).where(CommissionRule.id == rule_id)
result = await db.execute(stmt)
rule = result.scalar_one_or_none()
if not rule:
return False
await db.delete(rule)
await db.commit()
logger.info("Commission rule hard-deleted: id=%d", rule_id)
return True
# ──────────────────────────────────────────────────────────────────────────────
# Priority Resolution Engine
# ──────────────────────────────────────────────────────────────────────────────
async def get_active_rule(
db: AsyncSession,
rule_type: CommissionRuleType,
tier: CommissionTier,
region_code: str,
transaction_date: date,
) -> Optional[CommissionRule]:
"""
Priority Resolution Algorithm: Find the most specific active rule.
Resolution order (highest priority first):
1. Campaign rules (is_campaign=True) over permanent rules
2. Most specific region match (e.g. "HU" > "GLOBAL")
3. Highest tier match (PLATINUM > VIP > STANDARD > ENTERPRISE)
Falls back to GLOBAL/STANDARD default if no specific match exists.
Args:
db: Database session.
rule_type: L1_REWARD or L2_COMMISSION.
tier: The referrer/buyer's tier.
region_code: ISO 3166-1 alpha-2 region code.
transaction_date: The date of the transaction.
Returns:
The best matching CommissionRule, or None if no rule exists.
"""
# Build priority expressions using SQLAlchemy case()
# Lower numeric value = higher priority
campaign_priority = case(
(CommissionRule.is_campaign == True, 0), # campaigns first
else_=1
)
region_priority = case(
(CommissionRule.region_code == region_code, 0), # exact region match
(CommissionRule.region_code == "GLOBAL", 1), # global fallback
else_=2
)
# Tier ordering: PLATINUM (0) > VIP (1) > STANDARD (2) > ENTERPRISE (3) > CONTRACTED (4)
tier_order = {
CommissionTier.PLATINUM: 0,
CommissionTier.VIP: 1,
CommissionTier.STANDARD: 2,
CommissionTier.ENTERPRISE: 3,
CommissionTier.CONTRACTED: 4,
}
tier_priority = case(
*[(CommissionRule.tier == k, v) for k, v in tier_order.items()],
else_=99
)
stmt = (
select(CommissionRule)
.where(
CommissionRule.rule_type == rule_type,
CommissionRule.is_active == True,
# Date range: NULL means "always valid"
or_(
CommissionRule.start_date.is_(None),
CommissionRule.start_date <= transaction_date
),
or_(
CommissionRule.end_date.is_(None),
CommissionRule.end_date >= transaction_date
),
# Region: match exact or GLOBAL
or_(
CommissionRule.region_code == region_code,
CommissionRule.region_code == "GLOBAL"
),
# Tier: match exact or STANDARD fallback
or_(
CommissionRule.tier == tier,
CommissionRule.tier == CommissionTier.STANDARD
)
)
.order_by(campaign_priority, region_priority, tier_priority)
.limit(1)
)
result = await db.execute(stmt)
rule = result.scalar_one_or_none()
if rule:
logger.debug(
"Active rule resolved: id=%d type=%s tier=%s region=%s campaign=%s",
rule.id, rule.rule_type, rule.tier, rule.region_code, rule.is_campaign,
)
else:
logger.warning(
"No active rule found for type=%s tier=%s region=%s date=%s",
rule_type, tier, region_code, transaction_date,
)
return rule
# ──────────────────────────────────────────────────────────────────────────────
# Admin Listing with Filters & Pagination
# ──────────────────────────────────────────────────────────────────────────────
async def list_rules(
db: AsyncSession,
page: int = 1,
page_size: int = 20,
rule_type: Optional[CommissionRuleType] = None,
tier: Optional[CommissionTier] = None,
region_code: Optional[str] = None,
is_active: Optional[bool] = None,
is_campaign: Optional[bool] = None,
) -> tuple[Sequence[CommissionRule], int]:
"""
List commission rules with optional filters and pagination.
Returns:
Tuple of (rules_list, total_count).
"""
# Build base query
base_query = select(CommissionRule)
# Apply filters
conditions = []
if rule_type is not None:
conditions.append(CommissionRule.rule_type == rule_type)
if tier is not None:
conditions.append(CommissionRule.tier == tier)
if region_code is not None:
conditions.append(CommissionRule.region_code == region_code)
if is_active is not None:
conditions.append(CommissionRule.is_active == is_active)
if is_campaign is not None:
conditions.append(CommissionRule.is_campaign == is_campaign)
if conditions:
base_query = base_query.where(and_(*conditions))
# Get total count
count_query = select(func.count()).select_from(base_query.subquery())
count_result = await db.execute(count_query)
total = count_result.scalar() or 0
# Apply pagination and ordering
offset = (page - 1) * page_size
stmt = (
base_query
.order_by(CommissionRule.updated_at.desc())
.offset(offset)
.limit(page_size)
)
result = await db.execute(stmt)
rules = result.scalars().all()
return rules, total
# ──────────────────────────────────────────────────────────────────────────────
# 2-Level MLM Commission Distribution Engine
# ──────────────────────────────────────────────────────────────────────────────
async def _lookup_user(
db: AsyncSession,
user_id: int,
) -> Optional[User]:
"""Look up a user by ID, return None if not found or deleted."""
stmt = select(User).where(
User.id == user_id,
User.is_deleted == False,
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def _calculate_commission(
amount: float,
percent: Optional[float],
max_amount: Optional[float],
) -> float:
"""
Calculate commission amount from a percentage, capped by max_amount.
Args:
amount: The transaction/subscription amount.
percent: The commission percentage (e.g. 5.00 = 5%).
max_amount: Optional cap on the commission payout.
Returns:
The calculated commission amount.
"""
if not percent or percent <= 0:
return 0.0
commission = float(Decimal(str(amount)) * Decimal(str(percent)) / Decimal("100"))
if max_amount is not None and max_amount > 0:
commission = min(commission, float(max_amount))
return round(commission, 2)
async def distribute_commission(
db: AsyncSession,
request: CommissionDistributionRequest,
) -> CommissionDistributionResponse:
"""
2-Level MLM Commission Distribution Engine.
When a referred company makes a purchase, this function:
1. Looks up the buyer and their referrer (Gen1)
2. Finds the active commission rule for Gen1's tier/region
3. Calculates Gen1's commission using commission_percent
4. If Gen1 has a referrer (Gen2/upline), calculates Gen2's commission
using upline_commission_percent from the SAME rule
Args:
db: Database session.
request: Distribution request with buyer_user_id, transaction_amount,
transaction_date, and region_code.
Returns:
CommissionDistributionResponse with payout breakdown for Gen1 and Gen2.
"""
items: List[CommissionDistributionItem] = []
total_commission = 0.0
# 1. Look up the buyer
buyer = await _lookup_user(db, request.buyer_user_id)
if not buyer:
logger.warning(
"Commission distribution: buyer user %d not found or deleted",
request.buyer_user_id,
)
return CommissionDistributionResponse(
transaction_amount=request.transaction_amount,
items=[],
total_commission=0.0,
)
# 2. Find Gen1 (the buyer's direct referrer)
gen1_user_id = buyer.referred_by_id
if not gen1_user_id:
logger.info(
"Commission distribution: buyer %d has no referrer (Gen1)",
request.buyer_user_id,
)
return CommissionDistributionResponse(
transaction_amount=request.transaction_amount,
items=[],
total_commission=0.0,
)
gen1 = await _lookup_user(db, gen1_user_id)
if not gen1:
logger.warning(
"Commission distribution: Gen1 user %d not found or deleted",
gen1_user_id,
)
return CommissionDistributionResponse(
transaction_amount=request.transaction_amount,
items=[],
total_commission=0.0,
)
# 3. Resolve the active commission rule for Gen1
gen1_tier = CommissionTier(gen1.commission_tier) if hasattr(gen1, 'commission_tier') and gen1.commission_tier else CommissionTier.STANDARD
try:
gen1_tier_enum = CommissionTier(gen1_tier)
except ValueError:
gen1_tier_enum = CommissionTier.STANDARD
rule = await get_active_rule(
db,
rule_type=CommissionRuleType.L2_COMMISSION,
tier=gen1_tier_enum,
region_code=request.region_code,
transaction_date=request.transaction_date,
)
if not rule:
logger.warning(
"Commission distribution: no active L2_COMMISSION rule for "
"tier=%s region=%s date=%s",
gen1_tier_enum, request.region_code, request.transaction_date,
)
return CommissionDistributionResponse(
transaction_amount=request.transaction_amount,
items=[],
total_commission=0.0,
)
# 4. Calculate Gen1 commission
gen1_amount = await _calculate_commission(
request.transaction_amount,
float(rule.commission_percent) if rule.commission_percent else None,
float(rule.commission_max_amount) if rule.commission_max_amount else None,
)
if gen1_amount > 0:
items.append(CommissionDistributionItem(
level=1,
user_id=gen1.id,
commission_percent=float(rule.commission_percent) if rule.commission_percent else 0.0,
commission_amount=gen1_amount,
rule_id=rule.id,
))
total_commission += gen1_amount
# 5. Find Gen2 (Gen1's referrer / upline)
gen2_user_id = gen1.referred_by_id
if gen2_user_id:
gen2 = await _lookup_user(db, gen2_user_id)
if gen2:
# 6. Calculate Gen2 commission using upline_commission_percent
gen2_amount = await _calculate_commission(
request.transaction_amount,
float(rule.upline_commission_percent) if rule.upline_commission_percent else None,
float(rule.commission_max_amount) if rule.commission_max_amount else None,
)
if gen2_amount > 0:
items.append(CommissionDistributionItem(
level=2,
user_id=gen2.id,
commission_percent=float(rule.upline_commission_percent) if rule.upline_commission_percent else 0.0,
commission_amount=gen2_amount,
rule_id=rule.id,
))
total_commission += gen2_amount
else:
logger.warning(
"Commission distribution: Gen2 user %d not found or deleted",
gen2_user_id,
)
logger.info(
"Commission distributed: buyer=%d gen1=%d(%.2f%%) gen2=%d(%.2f%%) "
"amount=%.2f total_commission=%.2f rule=%d",
request.buyer_user_id,
gen1.id,
float(rule.commission_percent) if rule.commission_percent else 0,
gen2_user_id or 0,
float(rule.upline_commission_percent) if rule.upline_commission_percent else 0,
request.transaction_amount,
total_commission,
rule.id,
)
return CommissionDistributionResponse(
transaction_amount=request.transaction_amount,
items=items,
total_commission=round(total_commission, 2),
)

View File

@@ -479,8 +479,7 @@ async def search_providers(
select(
ServiceProfile.organization_id,
ExpertiseTag.id,
ExpertiseTag.name_hu,
ExpertiseTag.name_en,
ExpertiseTag.name_i18n,
ExpertiseTag.level,
ExpertiseTag.key,
)
@@ -499,8 +498,7 @@ async def search_providers(
org_categories_map[org_id] = []
org_categories_map[org_id].append({
"id": cat_row.id,
"name_hu": cat_row.name_hu,
"name_en": cat_row.name_en,
"name_i18n": cat_row.name_i18n,
"level": cat_row.level,
"key": cat_row.key,
})
@@ -518,8 +516,7 @@ async def search_providers(
categories_out = [
CategoryInfo(
id=cat["id"],
name_hu=cat["name_hu"],
name_en=cat["name_en"],
name_i18n=cat["name_i18n"],
level=cat["level"],
key=cat["key"],
)
@@ -871,8 +868,7 @@ async def _create_new_tags(
existing_stmt = select(ExpertiseTag).where(
or_(
ExpertiseTag.key == _slugify(tag_name),
ExpertiseTag.name_hu == tag_name,
ExpertiseTag.name_en == tag_name,
cast(ExpertiseTag.name_i18n, String).ilike(f"%{tag_name}%"),
)
)
existing_result = await db.execute(existing_stmt)
@@ -890,15 +886,13 @@ async def _create_new_tags(
new_key = _slugify(tag_name)
new_tag = ExpertiseTag(
key=new_key,
name_hu=tag_name,
name_en=tag_name,
name_i18n={"hu": tag_name, "en": tag_name},
level=3,
is_official=False,
category="user_created",
parent_id=None,
path=None,
search_keywords=[tag_name.lower()],
description=f"User-created tag: {tag_name}",
)
db.add(new_tag)
await db.flush()

View File

@@ -5,14 +5,24 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.identity import User, Person, SocialAccount, UserRole
from app.services.security_service import security_service
from app.core.security import generate_secure_slug
logger = logging.getLogger(__name__)
class SocialAuthService:
@staticmethod
async def get_or_create_social_user(db: AsyncSession, provider: str, social_id: str, email: str, first_name: str, last_name: str):
async def get_or_create_social_user(
db: AsyncSession,
provider: str,
social_id: str,
email: str,
first_name: str,
last_name: str,
referred_by_code: str = None
):
"""
LOGIKA MEGŐRIZVE: Step 1 regisztráció slug és flotta nélkül.
Támogatja a meghívó kód (referral code) átvételét Google SSO regisztrációnál.
"""
# 1. Meglévő fiók ellenőrzése
stmt = select(SocialAccount).where(SocialAccount.provider == provider, SocialAccount.social_id == social_id)
@@ -26,11 +36,37 @@ class SocialAuthService:
user = (await db.execute(stmt_u)).scalar_one_or_none()
if not user:
new_person = Person(first_name=first_name or "Social", last_name=last_name or "User", is_active=False)
# Meghívó keresése referral kód alapján
referred_by_id = None
if referred_by_code:
referrer_stmt = select(User).where(User.referral_code == referred_by_code)
referrer = (await db.execute(referrer_stmt)).scalar_one_or_none()
if referrer:
referred_by_id = referrer.id
logger.info(f"Social user {email} referred by {referrer.email} (ID: {referrer.id})")
else:
logger.warning(f"Referral code '{referred_by_code}' not found for social registration, ignoring.")
new_person = Person(
first_name=first_name or "Social",
last_name=last_name or "User",
is_active=False,
identity_docs={},
ice_contact={}
)
db.add(new_person)
await db.flush()
user = User(email=email, person_id=new_person.id, role=UserRole.USER, is_active=False)
referral_code = generate_secure_slug(8).upper()
user = User(
email=email,
person_id=new_person.id,
role=UserRole.USER,
is_active=False,
referral_code=referral_code,
referred_by_id=referred_by_id
)
db.add(user)
await db.flush()

View File

@@ -0,0 +1,180 @@
"""
Seed test data for Commission Distribution verification.
Creates:
1. Three users with a referral chain: UserC (buyer) -> UserB (Gen1) -> UserA (Gen2)
2. An active L2_COMMISSION rule with commission_percent and upline_commission_percent
Usage:
docker compose exec sf_api python /app/backend/tests/active/seed_commission_test_data.py
"""
import asyncio
import os
import sys
sys.path.insert(0, "/app/backend")
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy import text
from datetime import date
async def seed():
database_url = os.getenv(
"DATABASE_URL",
"postgresql+asyncpg://service_finder:service_finder@postgres:5432/service_finder",
)
engine = create_async_engine(database_url, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as db:
# Check if test users already exist
existing = await db.execute(
text("SELECT id, email FROM identity.users WHERE email LIKE 'commission_test_%' ORDER BY id")
)
existing_users = existing.fetchall()
if existing_users:
print(f"⚠️ Test users already exist: {[u.email for u in existing_users]}")
print(" Skipping user creation.")
# Find the chain
result = await db.execute(
text("""
SELECT u1.id AS buyer_id, u1.email AS buyer_email,
u2.id AS gen1_id, u2.email AS gen1_email,
u3.id AS gen2_id, u3.email AS gen2_email
FROM identity.users u1
JOIN identity.users u2 ON u1.referred_by_id = u2.id
JOIN identity.users u3 ON u2.referred_by_id = u3.id
WHERE u1.email = 'commission_test_buyer@test.com'
""")
)
row = result.fetchone()
if row:
print(f"\n✅ Referral chain intact:")
print(f" Buyer(Gen0): ID={row.buyer_id} < {row.buyer_email}")
print(f" Gen1: ID={row.gen1_id} < {row.gen1_email}")
print(f" Gen2: ID={row.gen2_id} < {row.gen2_email}")
else:
print("Creating test users with referral chain...")
# Create Gen2 (top-level referrer) - UserA
result = await db.execute(
text("""
INSERT INTO identity.users
(email, hashed_password, role, commission_tier, is_active, ui_mode,
subscription_plan, is_vip, is_deleted, preferred_language,
region_code, preferred_currency, scope_level,
custom_permissions, alternative_emails, email_history,
visual_settings, created_at)
VALUES
('commission_test_gen2@test.com', 'abc123', 'USER', 'STANDARD', TRUE, 'personal',
'free', FALSE, FALSE, 'hu',
'HU', 'HUF', 'user',
'{}'::json, '[]'::json, '[]'::json,
'{}'::jsonb, NOW())
RETURNING id
""")
)
gen2_id = result.scalar()
print(f" Created Gen2 (Upline): ID={gen2_id}")
# Create Gen1 (referrer) - UserB, referred by Gen2
result = await db.execute(
text("""
INSERT INTO identity.users
(email, hashed_password, role, commission_tier, is_active, ui_mode,
referred_by_id,
subscription_plan, is_vip, is_deleted, preferred_language,
region_code, preferred_currency, scope_level,
custom_permissions, alternative_emails, email_history,
visual_settings, created_at)
VALUES
('commission_test_gen1@test.com', 'abc123', 'USER', 'STANDARD', TRUE, 'personal',
:gen2_id,
'free', FALSE, FALSE, 'hu',
'HU', 'HUF', 'user',
'{}'::json, '[]'::json, '[]'::json,
'{}'::jsonb, NOW())
RETURNING id
""").bindparams(gen2_id=gen2_id)
)
gen1_id = result.scalar()
print(f" Created Gen1 (Referrer): ID={gen1_id}")
# Create Buyer (Gen0) - UserC, referred by Gen1
result = await db.execute(
text("""
INSERT INTO identity.users
(email, hashed_password, role, commission_tier, is_active, ui_mode,
referred_by_id,
subscription_plan, is_vip, is_deleted, preferred_language,
region_code, preferred_currency, scope_level,
custom_permissions, alternative_emails, email_history,
visual_settings, created_at)
VALUES
('commission_test_buyer@test.com', 'abc123', 'USER', 'STANDARD', TRUE, 'personal',
:gen1_id,
'free', FALSE, FALSE, 'hu',
'HU', 'HUF', 'user',
'{}'::json, '[]'::json, '[]'::json,
'{}'::jsonb, NOW())
RETURNING id
""").bindparams(gen1_id=gen1_id)
)
buyer_id = result.scalar()
print(f" Created Buyer (Gen0): ID={buyer_id}")
print(f"\n✅ Referral chain created:")
print(f" Buyer(Gen0): ID={buyer_id} -> referred_by={gen1_id}")
print(f" Gen1: ID={gen1_id} -> referred_by={gen2_id}")
print(f" Gen2: ID={gen2_id}")
# Check for existing L2_COMMISSION rules
rule_result = await db.execute(
text("""
SELECT id, name, commission_percent, upline_commission_percent,
commission_max_amount, is_active
FROM marketplace.commission_rules
WHERE rule_type = 'L2_COMMISSION'
ORDER BY id
""")
)
existing_rules = rule_result.fetchall()
if existing_rules:
print(f"\n⚠️ L2_COMMISSION rules already exist:")
for r in existing_rules:
print(f" ID={r.id}, Name={r.name}, Active={r.is_active}, "
f"commission={r.commission_percent}%, upline={r.upline_commission_percent}%, "
f"max={r.commission_max_amount}")
else:
print("\nCreating L2_COMMISSION rule...")
result = await db.execute(
text("""
INSERT INTO marketplace.commission_rules
(rule_type, name, description, tier, region_code,
commission_percent, upline_commission_percent,
commission_max_amount, is_campaign, is_active,
start_date, end_date, created_by)
VALUES
('L2_COMMISSION', 'Standard 2-Level Commission', 'Default 2-level MLM commission rule',
'STANDARD', 'HU',
5.00, 2.00,
50000.00, FALSE, TRUE,
:start_date, NULL, 1)
RETURNING id
""").bindparams(start_date=date(2026, 1, 1))
)
rule_id = result.scalar()
print(f" Created L2_COMMISSION rule: ID={rule_id}")
print(f" commission_percent=5.00%, upline_commission_percent=2.00%, max=50000.00")
await db.commit()
print("\n✅ Seed data created successfully!")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(seed())

View File

@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""
Commission Distribution Verification Test
Tests the 2-Level MLM commission distribution logic end-to-end.
Prerequisites:
- Seed data created via seed_commission_test_data.py
Usage:
docker compose exec sf_api python /app/backend/tests/active/test_commission_distribution.py
"""
import asyncio
import httpx
import sys
import os
BASE_URL = "http://sf_api:8000/api/v1"
ADMIN_EMAIL = "admin@profibot.hu"
ADMIN_PASSWORD = "Admin123!"
async def main():
passed = 0
failed = 0
async with httpx.AsyncClient(base_url=BASE_URL) as client:
# ── Step 1: Login ──
print("=" * 60)
print("STEP 1: Login with admin user")
print("=" * 60)
login_data = {
"username": ADMIN_EMAIL,
"password": ADMIN_PASSWORD,
"grant_type": "password",
}
login_resp = await client.post(
"/auth/login",
data=login_data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if login_resp.status_code != 200:
print(f"❌ Login failed: {login_resp.status_code} {login_resp.text}")
sys.exit(1)
token_data = login_resp.json()
access_token = token_data.get("access_token")
if not access_token:
print(f"❌ No access_token in response: {token_data}")
sys.exit(1)
print(f"✅ Login successful, token obtained")
headers = {"Authorization": f"Bearer {access_token}"}
passed += 1
# ── Step 2: Check referral chain via DB ──
print("\n" + "=" * 60)
print("STEP 2: Verify referral chain exists")
print("=" * 60)
# We'll check via the users list endpoint
resp = await client.get(
"/admin/users?limit=50",
headers=headers,
)
if resp.status_code != 200:
print(f"❌ Failed to list users: {resp.status_code} {resp.text}")
failed += 1
else:
users = resp.json()
# Find our test users
test_users = [u for u in (users.get("users") or users.get("data") or users if isinstance(users, list) else [])
if isinstance(u, dict) and "commission_test" in str(u.get("email", ""))]
# Try to find from response
print(f"✅ Users endpoint responded (status={resp.status_code})")
passed += 1
# ── Step 3: Test commission distribution ──
print("\n" + "=" * 60)
print("STEP 3: Test commission distribution API")
print("=" * 60)
# Use the known test user IDs from seed data
# Buyer (Gen0) = ID 155, Gen1 = 154, Gen2 = 153
# Rule ID 4: STANDARD, HU, 5% Gen1, 2% Gen2, max 50000
distribute_payload = {
"buyer_user_id": 155,
"transaction_amount": 100000.00,
"transaction_date": "2026-07-24",
"region_code": "HU",
}
print(f"Request: POST /admin/commission-rules/distribute")
print(f"Payload: {distribute_payload}")
resp = await client.post(
"/admin/commission-rules/distribute",
json=distribute_payload,
headers=headers,
)
print(f"Response status: {resp.status_code}")
if resp.status_code == 200:
data = resp.json()
print(f"Response body: {data}")
print()
# Validate response structure
assert "transaction_amount" in data, "Missing transaction_amount"
assert "items" in data, "Missing items"
assert "total_commission" in data, "Missing total_commission"
assert len(data["items"]) > 0, "No commission items returned"
# Check Gen1 commission (5% of 100000 = 5000)
gen1_item = next((i for i in data["items"] if i["level"] == 1), None)
assert gen1_item is not None, "Missing Gen1 commission item"
assert gen1_item["user_id"] == 154, f"Expected Gen1 user_id=154, got {gen1_item['user_id']}"
assert gen1_item["commission_amount"] == 5000.00, \
f"Expected Gen1 commission=5000.00, got {gen1_item['commission_amount']}"
# Check Gen2 commission (2% of 100000 = 2000)
gen2_item = next((i for i in data["items"] if i["level"] == 2), None)
assert gen2_item is not None, "Missing Gen2 commission item"
assert gen2_item["user_id"] == 153, f"Expected Gen2 user_id=153, got {gen2_item['user_id']}"
assert gen2_item["commission_amount"] == 2000.00, \
f"Expected Gen2 commission=2000.00, got {gen2_item['commission_amount']}"
# Check total
assert data["total_commission"] == 7000.00, \
f"Expected total_commission=7000.00, got {data['total_commission']}"
print("✅ Commission distribution test PASSED!")
print(f" Gen1 (User 154): 5% of 100000 = 5000.00 ✅")
print(f" Gen2 (User 153): 2% of 100000 = 2000.00 ✅")
print(f" Total: 7000.00 ✅")
passed += 1
elif resp.status_code == 404:
print(f"⚠️ No matching rule found: {resp.text}")
print(" This means the rule resolution didn't match. Check tier/region.")
else:
print(f"❌ Distribution failed: {resp.text}")
failed += 1
# ── Step 4: Test with max_amount cap ──
print("\n" + "=" * 60)
print("STEP 4: Test commission with max_amount cap")
print("=" * 60)
# With a very large transaction, Gen1 should be capped at 50000
# 5% of 2000000 = 100000, but max is 50000
cap_payload = {
"buyer_user_id": 155,
"transaction_amount": 2000000.00,
"transaction_date": "2026-07-24",
"region_code": "HU",
}
resp = await client.post(
"/admin/commission-rules/distribute",
json=cap_payload,
headers=headers,
)
if resp.status_code == 200:
data = resp.json()
gen1_item = next((i for i in data["items"] if i["level"] == 1), None)
if gen1_item:
# 5% of 2M = 100000, capped at 50000
assert gen1_item["commission_amount"] <= 50000.00, \
f"Gen1 commission {gen1_item['commission_amount']} exceeds max 50000"
print(f"✅ Max amount cap verified: Gen1 commission={gen1_item['commission_amount']} (capped at 50000)")
passed += 1
else:
print("⚠️ No Gen1 item in capped test")
else:
print(f"⚠️ Cap test skipped (status={resp.status_code})")
# ── Summary ──
print("\n" + "=" * 60)
print(f"RESULTS: {passed} passed, {failed} failed")
print("=" * 60)
if failed > 0:
sys.exit(1)
else:
print("\n✅ All commission distribution tests PASSED!")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -58,7 +58,7 @@ async def seed():
entry = BodyTypeDictionary(
vehicle_class=bt["vehicle_class"],
code=bt["code"],
name_hu=bt["name_hu"],
name_i18n={"hu": bt["name_hu"]},
)
session.add(entry)

View File

@@ -1030,11 +1030,9 @@ async def _flatten_and_insert(db, parent_id: int | None, level: int, path_prefix
tag = ExpertiseTag(
key=key,
name_hu=node["name_hu"],
name_en=node["name_en"],
name_i18n={"hu": node["name_hu"], "en": node["name_en"]},
category=node.get("category", ""),
search_keywords=node.get("search_keywords", []),
description=node.get("description", ""),
is_official=True,
level=level,
parent_id=parent_id,
@@ -1095,11 +1093,9 @@ async def seed_expertise_taxonomy():
for key, node in UNIVERSAL_CATEGORIES.items():
tag = ExpertiseTag(
key=key,
name_hu=node["name_hu"],
name_en=node["name_en"],
name_i18n={"hu": node["name_hu"], "en": node["name_en"]},
category=node.get("category", ""),
search_keywords=node.get("search_keywords", []),
description=node.get("description", ""),
is_official=True,
level=1,
parent_id=None,

View File

@@ -2504,7 +2504,9 @@ var menu$1 = {
user_list: "Felhasználók listája",
persons: "Személyek",
finance: "Pénzügyek",
finance_management: "Pénzügyek Kezelése",
packages: "Csomagok",
commission_rules: "Jutalék Szabályok",
gamification: "Gamification",
game_settings: "Játékmenet Beállítások",
point_rules: "Pontszabályok",
@@ -3766,6 +3768,87 @@ const locale_system_46json_21da1de8 = {
system: system$1
};
var commission_rules$1 = {
title: "Jutalék Szabályok",
subtitle: "Fix jutalmak és százalékos jutalékok kezelése a partnerrendszerben",
create: "Új szabály",
create_title: "Jutalék Szabály Létrehozása",
edit_title: "Jutalék Szabály Szerkesztése",
created: "Jutalék szabály sikeresen létrehozva",
deactivated: "Jutalék szabály sikeresen deaktiválva",
no_items: "Nincsenek jutalék szabályok",
all_types: "Minden típus",
all_tiers: "Minden szint",
filter_region: "Régió",
active_only: "Csak aktív",
campaign_only: "Csak kampány",
tier: "Szint",
region: "Régió",
region_code: "Régiókód",
campaign_dates: "Kampány / Dátumok",
campaign: "Kampány",
permanent: "Állandó",
l1_reward: "Fix Összegű Jutalom (Mikrotranzakció)",
l2_commission: "Százalékos Jutalék (Vásárlás után)",
is_campaign: "Ez egy kampány szabály",
rewards_section: "Fix Jutalmak (XP / Kredit)",
xp_reward: "XP Jutalom",
credit_reward: "Credit Jutalom",
commission_section: "Százalékos Jutalékok",
commission_percent: "Jutalék % (Gen1)",
upline_commission_percent: "Felső szintű Jutalék % (Gen2)",
upline_commission_percent_helper: "A felső szintű ajánlónak (Gen2 — az ajánló ajánlója) járó jutalék százaléka minden vásárlás után.",
renewal_commission_percent: "Megújítási Jutalék %",
renewal_commission_percent_helper: "A havi/éves előfizetés megújításakor járó jutalék százaléka.",
commission_max_amount: "Max Összeg",
deactivate_confirm: "Biztosan deaktiválod a következőt: \"{name}\"?",
showing: "Mutatva",
xp_reward_helper: "Egyéni meghívások után járó fix jutalom (pl. sikeres regisztráció).",
credit_reward_helper: "Egyéni meghívások után járó fix jutalom (pl. sikeres regisztráció).",
commission_percent_helper: "A meghívott cég előfizetési díjából számított százalékos jutalék.",
commission_max_amount_helper: "A kifizethető maximális jutalék összege ezen a szabályon belül.",
region_code_label: "Régiókód",
region_global: "Globális",
region_hu: "Magyarország",
region_de: "Németország",
region_at: "Ausztria",
region_ro: "Románia",
region_sk: "Szlovákia",
id: "Azonosító",
name: "Név",
type: "Típus",
status: "Státusz",
actions: "Műveletek",
active: "Aktív",
inactive: "Inaktív",
edit: "Szerkesztés",
deactivate: "Deaktiválás",
prev: "Előző",
next: "Következő",
loading: "Betöltés...",
error: "Hiba történt",
retry: "Újra",
save: "Mentés",
saving: "Mentés...",
cancel: "Mégsem",
name_placeholder: "Add meg a nevet",
description: "Leírás",
desc_placeholder: "Add meg a leírást",
start_date: "Kezdő Dátum",
end_date: "Befejező Dátum",
delete_title: "Törlés Megerősítése",
updated: "Sikeresen frissítve",
save_error: "Sikertelen mentés",
tier_standard: "Standard",
tier_vip: "VIP",
tier_platinum: "Platinum",
tier_enterprise: "Enterprise",
tier_contracted: "Szerződött Üzletkötő"
};
const locale_commission_46json_2229a18b = {
commission_rules: commission_rules$1
};
var loading = "Loading...";
var saving = "Saving...";
var error = "An error occurred";
@@ -3973,7 +4056,9 @@ var menu = {
user_list: "User List",
persons: "Persons",
finance: "Finance",
finance_management: "Finance Management",
packages: "Packages",
commission_rules: "Commission Rules",
gamification: "Gamification",
game_settings: "Game Settings",
point_rules: "Point Rules",
@@ -5235,6 +5320,87 @@ const locale_system_46json_cff5800c = {
system: system
};
var commission_rules = {
title: "Commission Rules",
subtitle: "Manage fixed rewards and percentage commissions for the referral system",
create: "New Rule",
create_title: "Create Commission Rule",
edit_title: "Edit Commission Rule",
created: "Commission rule created successfully",
deactivated: "Commission rule deactivated successfully",
no_items: "No commission rules found",
all_types: "All Types",
all_tiers: "All Tiers",
filter_region: "Region",
active_only: "Active only",
campaign_only: "Campaign only",
tier: "Tier",
region: "Region",
region_code: "Region Code",
campaign_dates: "Campaign / Dates",
campaign: "Campaign",
permanent: "Permanent",
l1_reward: "Fixed Amount Reward (Microtransaction)",
l2_commission: "Percentage Commission (Post-Purchase)",
is_campaign: "This is a campaign rule",
rewards_section: "Fixed Rewards (XP / Credit)",
xp_reward: "XP Reward",
credit_reward: "Credit Reward",
commission_section: "Percentage Commissions",
commission_percent: "Commission % (Gen1)",
upline_commission_percent: "Upline Commission % (Gen2)",
upline_commission_percent_helper: "Percentage paid to the upline (Gen2 — the referrer's referrer) on each purchase.",
renewal_commission_percent: "Renewal Commission %",
renewal_commission_percent_helper: "Percentage paid on monthly/yearly subscription renewals.",
commission_max_amount: "Max Amount",
deactivate_confirm: "Are you sure you want to deactivate \"{name}\"?",
showing: "Showing",
xp_reward_helper: "Fixed reward given to the user for successful individual referrals (e.g., successful registration via invite code).",
credit_reward_helper: "Fixed reward given to the user for successful individual referrals (e.g., successful registration via invite code).",
commission_percent_helper: "Percentage calculated from the invited company's subscription fee.",
commission_max_amount_helper: "Maximum payable limit for this specific commission rule.",
region_code_label: "Region Code",
region_global: "Global",
region_hu: "Hungary",
region_de: "Germany",
region_at: "Austria",
region_ro: "Romania",
region_sk: "Slovakia",
id: "ID",
name: "Name",
type: "Type",
status: "Status",
actions: "Actions",
active: "Active",
inactive: "Inactive",
edit: "Edit",
deactivate: "Deactivate",
prev: "Previous",
next: "Next",
loading: "Loading...",
error: "An error occurred",
retry: "Retry",
save: "Save",
saving: "Saving...",
cancel: "Cancel",
name_placeholder: "Enter name",
description: "Description",
desc_placeholder: "Enter description",
start_date: "Start Date",
end_date: "End Date",
delete_title: "Confirm Deletion",
updated: "Updated successfully",
save_error: "Failed to save",
tier_standard: "Standard",
tier_vip: "VIP",
tier_platinum: "Platinum",
tier_enterprise: "Enterprise",
tier_contracted: "Contracted Agent"
};
const locale_commission_46json_ea60861a = {
commission_rules: commission_rules
};
var common$4 = {
loading: "Wird geladen...",
saving: "Wird gespeichert...",
@@ -5746,6 +5912,11 @@ const localeLoaders = {
key: "locale_system_46json_21da1de8",
load: () => Promise.resolve(locale_system_46json_21da1de8),
cache: true
},
{
key: "locale_commission_46json_2229a18b",
load: () => Promise.resolve(locale_commission_46json_2229a18b),
cache: true
}
],
en: [
@@ -5788,6 +5959,11 @@ const localeLoaders = {
key: "locale_system_46json_cff5800c",
load: () => Promise.resolve(locale_system_46json_cff5800c),
cache: true
},
{
key: "locale_commission_46json_ea60861a",
load: () => Promise.resolve(locale_commission_46json_ea60861a),
cache: true
}
],
de: [
@@ -6481,16 +6657,16 @@ _wH6JrtIxmaSoA8lCPWFnE9z4lQeXW6H5z3l5aymEQw
const assets = {
"/index.mjs": {
"type": "text/javascript; charset=utf-8",
"etag": "\"3c8cf-iVwUAHqMbwVWb0CE25IIZzxdTQs\"",
"mtime": "2026-07-08T11:00:08.610Z",
"size": 248015,
"etag": "\"3e482-bMW8N/gd5+rJ6Fet5d7ZvFyoG5I\"",
"mtime": "2026-07-24T01:36:18.316Z",
"size": 255106,
"path": "index.mjs"
},
"/index.mjs.map": {
"type": "application/json",
"etag": "\"84c42-k0hYb3YBaxguGfUt43nQybikfko\"",
"mtime": "2026-07-08T11:00:08.610Z",
"size": 543810,
"etag": "\"84cfb-v0HL6pG50A9Shz5WFud0S34p4No\"",
"mtime": "2026-07-24T01:36:18.317Z",
"size": 543995,
"path": "index.mjs.map"
}
};

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"id":"dev","timestamp":1783500293953}
{"id":"dev","timestamp":1784853723611}

View File

@@ -1 +1 @@
{"id":"dev","timestamp":1783500293953,"prerendered":[]}
{"id":"dev","timestamp":1784853723611,"prerendered":[]}

View File

@@ -1,5 +1,5 @@
{
"date": "2026-07-08T11:00:15.496Z",
"date": "2026-07-24T01:36:24.822Z",
"preset": "nitro-dev",
"framework": {
"name": "nuxt",
@@ -11,7 +11,7 @@
"dev": {
"pid": 19,
"workerAddress": {
"socketPath": "\u0000nitro-worker-19-3-3-6895.sock"
"socketPath": "\u0000nitro-worker-19-14-14-1789.sock"
}
}
}

View File

@@ -1,5 +1,5 @@
/// <reference types="@nuxtjs/tailwindcss" />
/// <reference types="@nuxtjs/i18n" />
/// <reference types="@nuxtjs/tailwindcss" />
/// <reference types="@pinia/nuxt" />
/// <reference types="@nuxt/telemetry" />
/// <reference types="@nuxt/devtools" />

View File

@@ -1,4 +1,4 @@
// generated by the @nuxtjs/tailwindcss <https://github.com/nuxt-modules/tailwindcss> module at 7/8/2026, 11:05:19 AM
// generated by the @nuxtjs/tailwindcss <https://github.com/nuxt-modules/tailwindcss> module at 7/24/2026, 1:36:24 AM
import "@nuxtjs/tailwindcss/config-ctx"
import configMerger from "@nuxtjs/tailwindcss/merger";

View File

@@ -1,9 +1,6 @@
{
"compilerOptions": {
"paths": {
"nuxt": [
"../node_modules/nuxt"
],
"@unhead/vue": [
"../node_modules/@unhead/vue"
],
@@ -19,6 +16,9 @@
"@nuxt/schema": [
"../node_modules/@nuxt/schema"
],
"nuxt": [
"../node_modules/nuxt"
],
"vue-i18n": [
"../node_modules/vue-i18n/dist/vue-i18n",
"../node_modules/vue-i18n"
@@ -47,6 +47,9 @@
"../node_modules/@intlify/message-compiler/dist/message-compiler",
"../node_modules/@intlify/message-compiler"
],
"nitropack": [
"../node_modules/nitropack"
],
"defu": [
"../node_modules/defu"
],
@@ -65,9 +68,6 @@
"unplugin-vue-router/client": [
"../node_modules/unplugin-vue-router/client"
],
"nitropack": [
"../node_modules/nitropack"
],
"vite/client": [
"../node_modules/vite/client"
],

View File

@@ -29,9 +29,6 @@
"@@/*": [
"../*"
],
"nuxt": [
"../node_modules/nuxt"
],
"@unhead/vue": [
"../node_modules/@unhead/vue"
],
@@ -47,6 +44,9 @@
"@nuxt/schema": [
"../node_modules/@nuxt/schema"
],
"nuxt": [
"../node_modules/nuxt"
],
"vue-i18n": [
"../node_modules/vue-i18n"
],
@@ -68,6 +68,9 @@
"@intlify/message-compiler": [
"../node_modules/@intlify/message-compiler"
],
"nitropack": [
"../node_modules/nitropack"
],
"defu": [
"../node_modules/defu"
],
@@ -86,9 +89,6 @@
"unplugin-vue-router/client": [
"../node_modules/unplugin-vue-router/client"
],
"nitropack": [
"../node_modules/nitropack"
],
"vite/client": [
"../node_modules/vite/client"
],

View File

@@ -0,0 +1,79 @@
{
"commission_rules": {
"title": "Commission Rules",
"subtitle": "Manage fixed rewards and percentage commissions for the referral system",
"create": "New Rule",
"create_title": "Create Commission Rule",
"edit_title": "Edit Commission Rule",
"created": "Commission rule created successfully",
"deactivated": "Commission rule deactivated successfully",
"no_items": "No commission rules found",
"all_types": "All Types",
"all_tiers": "All Tiers",
"filter_region": "Region",
"active_only": "Active only",
"campaign_only": "Campaign only",
"tier": "Tier",
"region": "Region",
"region_code": "Region Code",
"campaign_dates": "Campaign / Dates",
"campaign": "Campaign",
"permanent": "Permanent",
"l1_reward": "Fixed Amount Reward (Microtransaction)",
"l2_commission": "Percentage Commission (Post-Purchase)",
"is_campaign": "This is a campaign rule",
"rewards_section": "Fixed Rewards (XP / Credit)",
"xp_reward": "XP Reward",
"credit_reward": "Credit Reward",
"commission_section": "Percentage Commissions",
"commission_percent": "Commission % (Gen1)",
"upline_commission_percent": "Upline Commission % (Gen2)",
"upline_commission_percent_helper": "Percentage paid to the upline (Gen2 — the referrer's referrer) on each purchase.",
"renewal_commission_percent": "Renewal Commission %",
"renewal_commission_percent_helper": "Percentage paid on monthly/yearly subscription renewals.",
"commission_max_amount": "Max Amount",
"deactivate_confirm": "Are you sure you want to deactivate \"{name}\"?",
"showing": "Showing",
"xp_reward_helper": "Fixed reward given to the user for successful individual referrals (e.g., successful registration via invite code).",
"credit_reward_helper": "Fixed reward given to the user for successful individual referrals (e.g., successful registration via invite code).",
"commission_percent_helper": "Percentage calculated from the invited company's subscription fee.",
"commission_max_amount_helper": "Maximum payable limit for this specific commission rule.",
"region_code_label": "Region Code",
"region_global": "Global",
"region_hu": "Hungary",
"region_de": "Germany",
"region_at": "Austria",
"region_ro": "Romania",
"region_sk": "Slovakia",
"id": "ID",
"name": "Name",
"type": "Type",
"status": "Status",
"actions": "Actions",
"active": "Active",
"inactive": "Inactive",
"edit": "Edit",
"deactivate": "Deactivate",
"prev": "Previous",
"next": "Next",
"loading": "Loading...",
"error": "An error occurred",
"retry": "Retry",
"save": "Save",
"saving": "Saving...",
"cancel": "Cancel",
"name_placeholder": "Enter name",
"description": "Description",
"desc_placeholder": "Enter description",
"start_date": "Start Date",
"end_date": "End Date",
"delete_title": "Confirm Deletion",
"updated": "Updated successfully",
"save_error": "Failed to save",
"tier_standard": "Standard",
"tier_vip": "VIP",
"tier_platinum": "Platinum",
"tier_enterprise": "Enterprise",
"tier_contracted": "Contracted Agent"
}
}

View File

@@ -13,7 +13,9 @@
"user_list": "User List",
"persons": "Persons",
"finance": "Finance",
"finance_management": "Finance Management",
"packages": "Packages",
"commission_rules": "Commission Rules",
"gamification": "Gamification",
"game_settings": "Game Settings",
"point_rules": "Point Rules",

View File

@@ -0,0 +1,79 @@
{
"commission_rules": {
"title": "Jutalék Szabályok",
"subtitle": "Fix jutalmak és százalékos jutalékok kezelése a partnerrendszerben",
"create": "Új szabály",
"create_title": "Jutalék Szabály Létrehozása",
"edit_title": "Jutalék Szabály Szerkesztése",
"created": "Jutalék szabály sikeresen létrehozva",
"deactivated": "Jutalék szabály sikeresen deaktiválva",
"no_items": "Nincsenek jutalék szabályok",
"all_types": "Minden típus",
"all_tiers": "Minden szint",
"filter_region": "Régió",
"active_only": "Csak aktív",
"campaign_only": "Csak kampány",
"tier": "Szint",
"region": "Régió",
"region_code": "Régiókód",
"campaign_dates": "Kampány / Dátumok",
"campaign": "Kampány",
"permanent": "Állandó",
"l1_reward": "Fix Összegű Jutalom (Mikrotranzakció)",
"l2_commission": "Százalékos Jutalék (Vásárlás után)",
"is_campaign": "Ez egy kampány szabály",
"rewards_section": "Fix Jutalmak (XP / Kredit)",
"xp_reward": "XP Jutalom",
"credit_reward": "Credit Jutalom",
"commission_section": "Százalékos Jutalékok",
"commission_percent": "Jutalék % (Gen1)",
"upline_commission_percent": "Felső szintű Jutalék % (Gen2)",
"upline_commission_percent_helper": "A felső szintű ajánlónak (Gen2 — az ajánló ajánlója) járó jutalék százaléka minden vásárlás után.",
"renewal_commission_percent": "Megújítási Jutalék %",
"renewal_commission_percent_helper": "A havi/éves előfizetés megújításakor járó jutalék százaléka.",
"commission_max_amount": "Max Összeg",
"deactivate_confirm": "Biztosan deaktiválod a következőt: \"{name}\"?",
"showing": "Mutatva",
"xp_reward_helper": "Egyéni meghívások után járó fix jutalom (pl. sikeres regisztráció).",
"credit_reward_helper": "Egyéni meghívások után járó fix jutalom (pl. sikeres regisztráció).",
"commission_percent_helper": "A meghívott cég előfizetési díjából számított százalékos jutalék.",
"commission_max_amount_helper": "A kifizethető maximális jutalék összege ezen a szabályon belül.",
"region_code_label": "Régiókód",
"region_global": "Globális",
"region_hu": "Magyarország",
"region_de": "Németország",
"region_at": "Ausztria",
"region_ro": "Románia",
"region_sk": "Szlovákia",
"id": "Azonosító",
"name": "Név",
"type": "Típus",
"status": "Státusz",
"actions": "Műveletek",
"active": "Aktív",
"inactive": "Inaktív",
"edit": "Szerkesztés",
"deactivate": "Deaktiválás",
"prev": "Előző",
"next": "Következő",
"loading": "Betöltés...",
"error": "Hiba történt",
"retry": "Újra",
"save": "Mentés",
"saving": "Mentés...",
"cancel": "Mégsem",
"name_placeholder": "Add meg a nevet",
"description": "Leírás",
"desc_placeholder": "Add meg a leírást",
"start_date": "Kezdő Dátum",
"end_date": "Befejező Dátum",
"delete_title": "Törlés Megerősítése",
"updated": "Sikeresen frissítve",
"save_error": "Sikertelen mentés",
"tier_standard": "Standard",
"tier_vip": "VIP",
"tier_platinum": "Platinum",
"tier_enterprise": "Enterprise",
"tier_contracted": "Szerződött Üzletkötő"
}
}

View File

@@ -13,7 +13,9 @@
"user_list": "Felhasználók listája",
"persons": "Személyek",
"finance": "Pénzügyek",
"finance_management": "Pénzügyek Kezelése",
"packages": "Csomagok",
"commission_rules": "Jutalék Szabályok",
"gamification": "Gamification",
"game_settings": "Játékmenet Beállítások",
"point_rules": "Pontszabályok",

View File

@@ -338,10 +338,21 @@ const menuGroups: MenuGroup[] = [
{
title: 'menu.finance',
items: [
{
label: 'menu.finance_management',
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m8.25 3v6.75m0 0l-3-3m3 3l3-3M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" /></svg>',
children: [
{
path: '/packages',
label: 'menu.packages',
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m8.25 3v6.75m0 0l-3-3m3 3l3-3M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" /></svg>',
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /></svg>',
},
{
path: '/finance/commission-rules',
label: 'menu.commission_rules',
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>',
},
],
},
],
},
@@ -460,6 +471,7 @@ const pageTitle = computed(() => {
if (route.path.startsWith('/users')) return 'menu.users'
if (route.path.startsWith('/logs')) return 'menu.system_logs'
if (route.path.startsWith('/packages')) return 'menu.packages'
if (route.path.startsWith('/finance/commission-rules')) return 'commission_rules.title'
if (route.path.startsWith('/providers')) {
if (route.path === '/providers/pending') return 'providers.pending_title'
if (route.path.startsWith('/providers/')) return 'providers.provider_detail'

View File

@@ -23,6 +23,7 @@ export default defineNuxtConfig({
files: [
'hu/common.json', 'hu/menu.json', 'hu/dashboard.json', 'hu/providers.json',
'hu/garages.json', 'hu/users.json', 'hu/gamification.json', 'hu/system.json',
'hu/commission.json',
],
},
{
@@ -30,6 +31,7 @@ export default defineNuxtConfig({
files: [
'en/common.json', 'en/menu.json', 'en/dashboard.json', 'en/providers.json',
'en/garages.json', 'en/users.json', 'en/gamification.json', 'en/system.json',
'en/commission.json',
],
},
{ code: 'de', iso: 'de-DE', file: 'de.json', name: 'Deutsch' },

View File

@@ -0,0 +1,795 @@
<template>
<div>
<!-- Page Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-white">{{ t('commission_rules.title') }}</h1>
<p class="text-slate-400 mt-1">{{ t('commission_rules.subtitle') }}</p>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-20">
<div class="text-slate-400 flex items-center gap-3">
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>{{ t('commission_rules.loading') }}</span>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="bg-rose-500/10 border border-rose-500/30 rounded-xl p-6 mb-8">
<p class="text-rose-400">{{ t('commission_rules.error') }}</p>
<button @click="fetchRules" class="mt-3 text-sm text-rose-300 hover:text-rose-200 underline">{{ t('commission_rules.retry') }}</button>
</div>
<template v-else>
<!-- Filters & Create Button -->
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<!-- Filters -->
<div class="flex flex-wrap items-center gap-3">
<!-- Rule Type Filter -->
<select
v-model="filterType"
class="px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50"
@change="fetchRules"
>
<option value="">{{ t('commission_rules.all_types') }}</option>
<option value="L1_REWARD">{{ t('commission_rules.l1_reward') }}</option>
<option value="L2_COMMISSION">{{ t('commission_rules.l2_commission') }}</option>
</select>
<!-- Tier Filter -->
<select
v-model="filterTier"
class="px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50"
@change="fetchRules"
>
<option value="">{{ t('commission_rules.all_tiers') }}</option>
<option value="STANDARD">{{ t('commission_rules.tier_standard') }}</option>
<option value="VIP">{{ t('commission_rules.tier_vip') }}</option>
<option value="PLATINUM">{{ t('commission_rules.tier_platinum') }}</option>
<option value="ENTERPRISE">{{ t('commission_rules.tier_enterprise') }}</option>
<option value="CONTRACTED">{{ t('commission_rules.tier_contracted') }}</option>
</select>
<!-- Region Filter -->
<input
v-model="filterRegion"
type="text"
:placeholder="t('commission_rules.filter_region')"
maxlength="10"
class="w-28 px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50"
@input="debouncedFetch"
/>
<!-- Active Only Toggle -->
<label class="flex items-center gap-2 text-sm text-slate-300 cursor-pointer select-none">
<input
v-model="filterActive"
type="checkbox"
class="w-4 h-4 rounded bg-slate-700 border-slate-600 text-indigo-500 focus:ring-indigo-500/50"
@change="fetchRules"
/>
{{ t('commission_rules.active_only') }}
</label>
<!-- Campaign Only Toggle -->
<label class="flex items-center gap-2 text-sm text-slate-300 cursor-pointer select-none">
<input
v-model="filterCampaign"
type="checkbox"
class="w-4 h-4 rounded bg-slate-700 border-slate-600 text-indigo-500 focus:ring-indigo-500/50"
@change="fetchRules"
/>
{{ t('commission_rules.campaign_only') }}
</label>
</div>
<!-- Create Button -->
<button @click="openCreateModal" class="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-sm font-medium transition flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{{ t('commission_rules.create') }}
</button>
</div>
<!-- Rules Table -->
<div class="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
<table class="w-full">
<thead>
<tr class="border-b border-slate-700">
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('commission_rules.id') }}</th>
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('commission_rules.name') }}</th>
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('commission_rules.type') }}</th>
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('commission_rules.tier') }}</th>
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('commission_rules.region') }}</th>
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('commission_rules.campaign_dates') }}</th>
<th class="text-left px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('commission_rules.status') }}</th>
<th class="text-right px-6 py-4 text-xs font-medium text-slate-400 uppercase tracking-wider">{{ t('commission_rules.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-700/50">
<tr v-for="rule in rules" :key="rule.id" class="hover:bg-slate-700/30 transition">
<td class="px-6 py-4 text-sm text-slate-400">{{ rule.id }}</td>
<td class="px-6 py-4">
<div class="text-sm font-medium text-white">{{ rule.name }}</div>
<div v-if="rule.description" class="text-xs text-slate-500 mt-0.5 max-w-xs truncate">{{ rule.description }}</div>
</td>
<td class="px-6 py-4">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="rule.rule_type === 'L1_REWARD' ? 'bg-amber-500/10 text-amber-400' : 'bg-blue-500/10 text-blue-400'"
>
{{ rule.rule_type === 'L1_REWARD' ? t('commission_rules.l1_reward') : t('commission_rules.l2_commission') }}
</span>
</td>
<td class="px-6 py-4">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="tierBadgeClass(rule.tier)"
>
{{ tierLabel(rule.tier) }}
</span>
</td>
<td class="px-6 py-4">
<code class="text-sm font-mono text-indigo-300 bg-indigo-500/10 px-2 py-0.5 rounded">{{ rule.region_code }}</code>
</td>
<td class="px-6 py-4">
<template v-if="rule.is_campaign">
<div class="text-xs text-slate-300">
<span class="text-amber-400 font-medium">{{ t('commission_rules.campaign') }}</span>
</div>
<div class="text-xs text-slate-500 mt-0.5">
{{ rule.start_date || '—' }} {{ rule.end_date || '—' }}
</div>
</template>
<span v-else class="text-xs text-slate-500">{{ t('commission_rules.permanent') }}</span>
</td>
<td class="px-6 py-4">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="rule.is_active ? 'bg-emerald-500/10 text-emerald-400' : 'bg-slate-500/10 text-slate-400'"
>
{{ rule.is_active ? t('commission_rules.active') : t('commission_rules.inactive') }}
</span>
</td>
<td class="px-6 py-4 text-right">
<div class="flex items-center justify-end gap-2">
<button @click="openEditModal(rule)" class="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition" :title="t('commission_rules.edit')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button @click="confirmDeactivate(rule)" class="p-1.5 rounded-lg text-slate-400 hover:text-rose-400 hover:bg-rose-500/10 transition" :title="t('commission_rules.deactivate')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
</tr>
<tr v-if="rules.length === 0">
<td colspan="8" class="px-6 py-12 text-center text-sm text-slate-500">
{{ t('commission_rules.no_items') }}
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div v-if="totalPages > 1" class="mt-6 flex items-center justify-between">
<div class="text-sm text-slate-400">
{{ t('commission_rules.showing') }} {{ (currentPage - 1) * pageSize + 1 }}{{ Math.min(currentPage * pageSize, total) }} / {{ total }}
</div>
<div class="flex items-center gap-2">
<button
:disabled="currentPage <= 1"
class="px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-30 disabled:cursor-not-allowed"
:class="currentPage > 1 ? 'bg-slate-700 text-slate-300 hover:bg-slate-600 hover:text-white' : 'bg-slate-800 text-slate-600'"
@click="goToPage(currentPage - 1)"
>
{{ t('commission_rules.prev') }}
</button>
<span class="text-sm text-slate-400 px-2">{{ currentPage }} / {{ totalPages }}</span>
<button
:disabled="currentPage >= totalPages"
class="px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-30 disabled:cursor-not-allowed"
:class="currentPage < totalPages ? 'bg-slate-700 text-slate-300 hover:bg-slate-600 hover:text-white' : 'bg-slate-800 text-slate-600'"
@click="goToPage(currentPage + 1)"
>
{{ t('commission_rules.next') }}
</button>
</div>
</div>
</template>
<!-- Create/Edit Modal -->
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm overflow-y-auto py-8" @click.self="closeModal">
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 w-full max-w-2xl mx-4 shadow-2xl my-auto">
<h2 class="text-lg font-semibold text-white mb-6">
{{ editingRule ? t('commission_rules.edit_title') : t('commission_rules.create_title') }}
</h2>
<form @submit.prevent="saveRule" class="space-y-5">
<!-- Row: Name + Rule Type -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Name -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.name') }} <span class="text-rose-400">*</span></label>
<input
v-model="form.name"
type="text"
required
maxlength="255"
:placeholder="t('commission_rules.name_placeholder')"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm"
/>
</div>
<!-- Rule Type -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.type') }} <span class="text-rose-400">*</span></label>
<select
v-model="form.rule_type"
required
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm"
>
<option value="L1_REWARD">{{ t('commission_rules.l1_reward') }}</option>
<option value="L2_COMMISSION">{{ t('commission_rules.l2_commission') }}</option>
</select>
</div>
</div>
<!-- Row: Tier + Region -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Tier -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.tier') }}</label>
<select
v-model="form.tier"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm"
>
<option value="STANDARD">{{ t('commission_rules.tier_standard') }}</option>
<option value="VIP">{{ t('commission_rules.tier_vip') }}</option>
<option value="PLATINUM">{{ t('commission_rules.tier_platinum') }}</option>
<option value="ENTERPRISE">{{ t('commission_rules.tier_enterprise') }}</option>
<option value="CONTRACTED">{{ t('commission_rules.tier_contracted') }}</option>
</select>
</div>
<!-- Region Code -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.region_code_label') }}</label>
<select
v-model="form.region_code"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm"
>
<option value="GLOBAL">{{ t('commission_rules.region_global') }}</option>
<option value="HU">{{ t('commission_rules.region_hu') }}</option>
<option value="DE">{{ t('commission_rules.region_de') }}</option>
<option value="AT">{{ t('commission_rules.region_at') }}</option>
<option value="RO">{{ t('commission_rules.region_ro') }}</option>
<option value="SK">{{ t('commission_rules.region_sk') }}</option>
</select>
</div>
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.description') }}</label>
<textarea
v-model="form.description"
rows="2"
:placeholder="t('commission_rules.desc_placeholder')"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm resize-none"
></textarea>
</div>
<!-- Campaign Section -->
<div class="bg-slate-700/30 rounded-lg p-4 space-y-4">
<div class="flex items-center gap-3">
<input
v-model="form.is_campaign"
type="checkbox"
id="is_campaign"
class="w-4 h-4 rounded bg-slate-700 border-slate-600 text-indigo-500 focus:ring-indigo-500/50"
/>
<label for="is_campaign" class="text-sm text-slate-300 font-medium">{{ t('commission_rules.is_campaign') }}</label>
</div>
<div v-if="form.is_campaign" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Start Date -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.start_date') }}</label>
<input
v-model="form.start_date"
type="date"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm"
/>
</div>
<!-- End Date -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.end_date') }}</label>
<input
v-model="form.end_date"
type="date"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm"
/>
</div>
</div>
</div>
<!-- Rewards Section (L1) -->
<div
class="bg-slate-700/30 rounded-lg p-4 space-y-4 transition-opacity duration-200"
:class="form.rule_type === 'L2_COMMISSION' ? 'opacity-40 pointer-events-none' : ''"
>
<div class="text-sm font-medium text-slate-300 mb-2">{{ t('commission_rules.rewards_section') }}</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- XP Reward -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.xp_reward') }}</label>
<input
v-model.number="form.xp_reward"
type="number"
min="0"
placeholder="0"
:disabled="form.rule_type === 'L2_COMMISSION'"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm disabled:cursor-not-allowed"
/>
<p class="mt-1 text-xs text-slate-400">{{ t('commission_rules.xp_reward_helper') }}</p>
</div>
<!-- Credit Reward -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.credit_reward') }}</label>
<input
v-model.number="form.credit_reward"
type="number"
min="0"
placeholder="0"
:disabled="form.rule_type === 'L2_COMMISSION'"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm disabled:cursor-not-allowed"
/>
<p class="mt-1 text-xs text-slate-400">{{ t('commission_rules.credit_reward_helper') }}</p>
</div>
</div>
</div>
<!-- Commission Section (L2) -->
<div
class="bg-slate-700/30 rounded-lg p-4 space-y-4 transition-opacity duration-200"
:class="form.rule_type === 'L1_REWARD' ? 'opacity-40 pointer-events-none' : ''"
>
<div class="text-sm font-medium text-slate-300 mb-2">{{ t('commission_rules.commission_section') }}</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Commission Percent (Gen1) -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.commission_percent') }}</label>
<div class="relative">
<input
v-model.number="form.commission_percent"
type="number"
min="0"
max="100"
step="0.01"
placeholder="0.00"
:disabled="form.rule_type === 'L1_REWARD'"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm pr-8 disabled:cursor-not-allowed"
/>
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm">%</span>
</div>
<p class="mt-1 text-xs text-slate-400">{{ t('commission_rules.commission_percent_helper') }}</p>
</div>
<!-- Upline Commission Percent (Gen2) -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.upline_commission_percent') }}</label>
<div class="relative">
<input
v-model.number="form.upline_commission_percent"
type="number"
min="0"
max="100"
step="0.01"
placeholder="0.00"
:disabled="form.rule_type === 'L1_REWARD'"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm pr-8 disabled:cursor-not-allowed"
/>
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm">%</span>
</div>
<p class="mt-1 text-xs text-slate-400">{{ t('commission_rules.upline_commission_percent_helper') }}</p>
</div>
<!-- Renewal Commission Percent -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.renewal_commission_percent') }}</label>
<div class="relative">
<input
v-model.number="form.renewal_commission_percent"
type="number"
min="0"
max="100"
step="0.01"
placeholder="0.00"
:disabled="form.rule_type === 'L1_REWARD'"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm pr-8 disabled:cursor-not-allowed"
/>
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm">%</span>
</div>
<p class="mt-1 text-xs text-slate-400">{{ t('commission_rules.renewal_commission_percent_helper') }}</p>
</div>
<!-- Commission Max Amount -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.commission_max_amount') }}</label>
<input
v-model.number="form.commission_max_amount"
type="number"
min="0"
step="0.01"
placeholder="0.00"
:disabled="form.rule_type === 'L1_REWARD'"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm disabled:cursor-not-allowed"
/>
<p class="mt-1 text-xs text-slate-400">{{ t('commission_rules.commission_max_amount_helper') }}</p>
</div>
</div>
</div>
<!-- Is Active -->
<div class="flex items-center gap-3">
<input
v-model="form.is_active"
type="checkbox"
id="form_is_active"
class="w-4 h-4 rounded bg-slate-700 border-slate-600 text-indigo-500 focus:ring-indigo-500/50"
/>
<label for="form_is_active" class="text-sm text-slate-300">{{ t('commission_rules.active') }}</label>
</div>
<!-- Error Message -->
<div v-if="formError" class="bg-rose-500/10 border border-rose-500/30 rounded-lg p-3">
<p class="text-sm text-rose-400">{{ formError }}</p>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-2 border-t border-slate-700">
<button type="button" @click="closeModal" class="px-4 py-2 text-sm text-slate-400 hover:text-white transition">{{ t('commission_rules.cancel') }}</button>
<button type="submit" :disabled="saving" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:bg-indigo-600/50 text-white rounded-lg text-sm font-medium transition flex items-center gap-2">
<svg v-if="saving" class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ saving ? t('commission_rules.saving') : t('commission_rules.save') }}
</button>
</div>
</form>
</div>
</div>
<!-- Deactivate Confirmation Modal -->
<div v-if="showDeactivateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showDeactivateModal = false">
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 w-full max-w-md mx-4 shadow-2xl">
<h2 class="text-lg font-semibold text-white mb-2">{{ t('commission_rules.delete_title') }}</h2>
<p class="text-sm text-slate-400 mb-6">
{{ t('commission_rules.deactivate_confirm', { name: deactivatingRule?.name }) }}
</p>
<div class="flex justify-end gap-3">
<button @click="showDeactivateModal = false" class="px-4 py-2 text-sm text-slate-400 hover:text-white transition">{{ t('commission_rules.cancel') }}</button>
<button @click="deactivateRule" :disabled="saving" class="px-4 py-2 bg-rose-600 hover:bg-rose-500 disabled:bg-rose-600/50 text-white rounded-lg text-sm font-medium transition">
{{ saving ? t('commission_rules.saving') : t('commission_rules.deactivate') }}
</button>
</div>
</div>
</div>
<!-- Success Toast -->
<div v-if="toast" class="fixed bottom-6 right-6 z-50 bg-emerald-600 text-white px-4 py-3 rounded-lg shadow-lg text-sm flex items-center gap-2 animate-slide-up">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ toast }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
definePageMeta({
middleware: 'auth',
})
const { t } = useI18n()
interface CommissionRule {
id: number
rule_type: 'L1_REWARD' | 'L2_COMMISSION'
tier: 'STANDARD' | 'VIP' | 'PLATINUM' | 'ENTERPRISE' | 'CONTRACTED'
region_code: string
xp_reward: number | null
credit_reward: number | null
commission_percent: number | null
upline_commission_percent: number | null
renewal_commission_percent: number | null
commission_max_amount: number | null
is_campaign: boolean
start_date: string | null
end_date: string | null
name: string
description: string | null
is_active: boolean
created_by: number | null
created_at: string
updated_at: string
}
interface PaginatedResponse {
items: CommissionRule[]
total: number
page: number
page_size: number
}
const loading = ref(true)
const error = ref(false)
const rules = ref<CommissionRule[]>([])
const showModal = ref(false)
const showDeactivateModal = ref(false)
const editingRule = ref<CommissionRule | null>(null)
const deactivatingRule = ref<CommissionRule | null>(null)
const saving = ref(false)
const formError = ref('')
const toast = ref('')
// Pagination
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
// Filters
const filterType = ref('')
const filterTier = ref('')
const filterRegion = ref('')
const filterActive = ref(false)
const filterCampaign = ref(false)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function debouncedFetch() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
currentPage.value = 1
fetchRules()
}, 400)
}
const form = reactive({
name: '',
description: '',
rule_type: 'L1_REWARD' as 'L1_REWARD' | 'L2_COMMISSION',
tier: 'STANDARD' as 'STANDARD' | 'VIP' | 'PLATINUM' | 'ENTERPRISE' | 'CONTRACTED',
region_code: 'GLOBAL',
is_campaign: false,
start_date: '',
end_date: '',
xp_reward: null as number | null,
credit_reward: null as number | null,
commission_percent: null as number | null,
upline_commission_percent: null as number | null,
renewal_commission_percent: null as number | null,
commission_max_amount: null as number | null,
is_active: true,
})
function getHeaders(): Record<string, string> {
const tokenCookie = useCookie('access_token')
const token = tokenCookie.value
return token ? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } : { 'Content-Type': 'application/json' }
}
function showToast(msg: string) {
toast.value = msg
setTimeout(() => { toast.value = '' }, 3000)
}
function resetForm() {
form.name = ''
form.description = ''
form.rule_type = 'L1_REWARD'
form.tier = 'STANDARD'
form.region_code = 'GLOBAL'
form.is_campaign = false
form.start_date = ''
form.end_date = ''
form.xp_reward = null
form.credit_reward = null
form.commission_percent = null
form.upline_commission_percent = null
form.renewal_commission_percent = null
form.commission_max_amount = null
form.is_active = true
formError.value = ''
}
function openCreateModal() {
editingRule.value = null
resetForm()
showModal.value = true
}
function openEditModal(rule: CommissionRule) {
editingRule.value = rule
form.name = rule.name
form.description = rule.description || ''
form.rule_type = rule.rule_type
form.tier = rule.tier
form.region_code = rule.region_code
form.is_campaign = rule.is_campaign
form.start_date = rule.start_date || ''
form.end_date = rule.end_date || ''
form.xp_reward = rule.xp_reward
form.credit_reward = rule.credit_reward
form.commission_percent = rule.commission_percent
form.upline_commission_percent = rule.upline_commission_percent
form.renewal_commission_percent = rule.renewal_commission_percent
form.commission_max_amount = rule.commission_max_amount
form.is_active = rule.is_active
formError.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingRule.value = null
resetForm()
}
function confirmDeactivate(rule: CommissionRule) {
deactivatingRule.value = rule
showDeactivateModal.value = true
}
function tierLabel(tier: string): string {
const key = `commission_rules.tier_${tier.toLowerCase()}`
const label = t(key)
return label !== key ? label : tier
}
function tierBadgeClass(tier: string): string {
switch (tier) {
case 'STANDARD': return 'bg-slate-500/10 text-slate-400'
case 'VIP': return 'bg-purple-500/10 text-purple-400'
case 'PLATINUM': return 'bg-cyan-500/10 text-cyan-400'
case 'ENTERPRISE': return 'bg-amber-500/10 text-amber-400'
case 'CONTRACTED': return 'bg-indigo-500/10 text-indigo-400'
default: return 'bg-slate-500/10 text-slate-400'
}
}
function goToPage(page: number) {
if (page < 1 || page > totalPages.value) return
currentPage.value = page
fetchRules()
}
async function fetchRules() {
loading.value = true
error.value = false
try {
const params = new URLSearchParams()
params.set('page', String(currentPage.value))
params.set('page_size', String(pageSize.value))
if (filterType.value) params.set('rule_type', filterType.value)
if (filterTier.value) params.set('tier', filterTier.value)
if (filterRegion.value) params.set('region_code', filterRegion.value)
if (filterActive.value) params.set('is_active', 'true')
if (filterCampaign.value) params.set('is_campaign', 'true')
const data = await $fetch<PaginatedResponse>(`/api/v1/admin/commission-rules?${params.toString()}`, {
headers: getHeaders(),
})
rules.value = data.items
total.value = data.total
} catch (e) {
console.error('Failed to fetch commission rules:', e)
error.value = true
} finally {
loading.value = false
}
}
async function saveRule() {
saving.value = true
formError.value = ''
try {
const payload: Record<string, any> = {
name: form.name,
description: form.description || null,
rule_type: form.rule_type,
tier: form.tier,
region_code: form.region_code,
is_campaign: form.is_campaign,
is_active: form.is_active,
}
// Only include campaign dates if is_campaign is true
if (form.is_campaign) {
payload.start_date = form.start_date || null
payload.end_date = form.end_date || null
} else {
payload.start_date = null
payload.end_date = null
}
// Type-specific fields
payload.xp_reward = form.xp_reward
payload.credit_reward = form.credit_reward
payload.commission_percent = form.commission_percent
payload.upline_commission_percent = form.upline_commission_percent
payload.renewal_commission_percent = form.renewal_commission_percent
payload.commission_max_amount = form.commission_max_amount
if (editingRule.value) {
await $fetch(`/api/v1/admin/commission-rules/${editingRule.value.id}`, {
method: 'PUT',
headers: getHeaders(),
body: payload,
})
showToast(t('commission_rules.updated'))
} else {
await $fetch('/api/v1/admin/commission-rules', {
method: 'POST',
headers: getHeaders(),
body: payload,
})
showToast(t('commission_rules.created'))
}
closeModal()
await fetchRules()
} catch (e: any) {
console.error('Failed to save commission rule:', e)
formError.value = e?.data?.detail || t('commission_rules.save_error')
} finally {
saving.value = false
}
}
async function deactivateRule() {
if (!deactivatingRule.value) return
saving.value = true
try {
await $fetch(`/api/v1/admin/commission-rules/${deactivatingRule.value.id}`, {
method: 'DELETE',
headers: getHeaders(),
})
showToast(t('commission_rules.deactivated'))
showDeactivateModal.value = false
deactivatingRule.value = null
await fetchRules()
} catch (e) {
console.error('Failed to deactivate commission rule:', e)
} finally {
saving.value = false
}
}
onMounted(() => {
fetchRules()
})
</script>
<style scoped>
.animate-slide-up {
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(1rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -12,7 +12,7 @@
></div>
<!-- 3D Perspective Container -->
<div class="perspective-1000 relative z-10 w-full max-w-md">
<div class="perspective-1000 relative z-10 w-full max-w-md max-h-[85vh]">
<!-- Flippable Card Body -->
<div
class="relative transition-transform duration-700 transform-style-3d"
@@ -24,7 +24,7 @@
<!-- ==================== FRONT FACE: LOGIN ==================== -->
<form
@submit.prevent="handleLogin"
class="backface-hidden w-full rounded-2xl border border-[#75A882]/30 bg-[#062535] p-8 shadow-2xl"
class="backface-hidden w-full rounded-2xl border border-[#75A882]/30 bg-[#062535] p-8 shadow-2xl overflow-y-auto max-h-[80vh]"
:class="{ 'pointer-events-none': authMode !== 'login' }"
>
<!-- Close Button (X) -->
@@ -200,9 +200,10 @@
</div>
<!-- Social Login Buttons -->
<div class="mb-6 grid grid-cols-2 gap-3">
<div class="mb-6 grid grid-cols-1 gap-3">
<button
type="button"
@click="handleGoogleLogin"
class="flex items-center justify-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white/50 transition-all duration-200 hover:border-white/20 hover:text-white/80"
>
<!-- Google Logo Placeholder -->
@@ -216,6 +217,7 @@
</button>
<button
type="button"
v-if="false"
class="flex items-center justify-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white/50 transition-all duration-200 hover:border-white/20 hover:text-white/80"
>
<!-- Apple Logo Placeholder -->
@@ -243,7 +245,7 @@
<form
v-if="activeBackFace === 'register'"
@submit.prevent="handleRegister"
class="backface-hidden rotate-y-180 absolute inset-0 w-full rounded-2xl border border-[#75A882]/30 bg-[#062535] p-8 shadow-2xl"
class="backface-hidden rotate-y-180 absolute inset-0 w-full rounded-2xl border border-[#75A882]/30 bg-[#062535] p-8 shadow-2xl overflow-y-auto"
>
<!-- Close Button (X) -->
<button
@@ -379,6 +381,21 @@
</button>
</div>
<!-- Referral Code Input (Meghívó kód) -->
<div class="mb-5">
<label for="reg-referral" class="mb-2 block text-sm font-medium text-white/80">
Meghívó kód
</label>
<input
id="reg-referral"
v-model="referredByCode"
type="text"
placeholder="Pl. ABC123"
autocomplete="off"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-white/30 backdrop-blur-sm transition-all duration-200 focus:border-[#75A882] focus:outline-none focus:ring-2 focus:ring-[#75A882]/50"
/>
</div>
<!-- Error Message (translated via i18n) -->
<div
v-if="authStore.error && authMode === 'register'"
@@ -396,6 +413,47 @@
{{ authStore.isLoading ? t('auth.loginLoading') : t('auth.registerButton') }}
</button>
<!-- Social Register Divider (Premium) -->
<div class="my-6 flex items-center gap-3">
<span class="flex-1 border-t border-white/10"></span>
<span class="flex items-center gap-2 text-sm text-white/40">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Vagy regisztrálj Social fiókkal
</span>
<span class="flex-1 border-t border-white/10"></span>
</div>
<!-- Social Register Buttons -->
<div class="mb-6 grid grid-cols-1 gap-3">
<button
type="button"
@click="handleGoogleRegister"
class="flex items-center justify-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white/50 transition-all duration-200 hover:border-white/20 hover:text-white/80"
>
<!-- Google Logo Placeholder -->
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Google
</button>
<button
type="button"
v-if="false"
class="flex items-center justify-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white/50 transition-all duration-200 hover:border-white/20 hover:text-white/80"
>
<!-- Apple Logo Placeholder -->
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
</svg>
Apple
</button>
</div>
<!-- Switch back to Login -->
<p class="mt-6 text-center text-sm text-white/50">
{{ t('auth.haveAccount') }}
@@ -413,7 +471,7 @@
<form
v-if="activeBackFace === 'forgot'"
@submit.prevent="handleForgotPassword"
class="backface-hidden rotate-x-180 absolute inset-0 w-full rounded-2xl border border-[#75A882]/30 bg-[#062535] p-8 shadow-2xl"
class="backface-hidden rotate-x-180 absolute inset-0 w-full rounded-2xl border border-[#75A882]/30 bg-[#062535] p-8 shadow-2xl overflow-y-auto"
>
<!-- Close Button (X) -->
<button
@@ -528,7 +586,7 @@
<form
v-if="activeBackFace === 'resend'"
@submit.prevent="handleResend"
class="backface-hidden rotate-y-180 absolute inset-0 w-full rounded-2xl border border-[#75A882]/30 bg-[#062535] p-8 shadow-2xl"
class="backface-hidden rotate-y-180 absolute inset-0 w-full rounded-2xl border border-[#75A882]/30 bg-[#062535] p-8 shadow-2xl overflow-y-auto"
>
<!-- Close Button (X) -->
<button
@@ -629,7 +687,7 @@
<form
v-if="activeBackFace === 'restore'"
@submit.prevent="handleRestoreStep"
class="backface-hidden rotate-y-180 absolute inset-0 w-full rounded-2xl border border-[#75A882]/30 bg-[#062535] p-8 shadow-2xl"
class="backface-hidden rotate-y-180 absolute inset-0 w-full rounded-2xl border border-[#75A882]/30 bg-[#062535] p-8 shadow-2xl overflow-y-auto"
>
<!-- Close Button (X) -->
<button
@@ -883,6 +941,7 @@ const lastName = ref('')
const firstName = ref('')
const regEmail = ref('')
const regPassword = ref('')
const referredByCode = ref('')
// ── Forgot password form fields ──
const forgotEmail = ref('')
@@ -1071,12 +1130,17 @@ async function handleLogin() {
// ── Register Logic ──
async function handleRegister() {
try {
await authStore.register({
const payload: Record<string, any> = {
email: regEmail.value,
password: regPassword.value,
first_name: firstName.value,
last_name: lastName.value,
})
}
if (referredByCode.value) {
payload.referred_by_code = referredByCode.value
}
await authStore.register(payload)
// Save email to localStorage for next visit
localStorage.setItem('saved-email', regEmail.value)
@@ -1089,6 +1153,84 @@ async function handleRegister() {
}
}
// ── Google Login (from Login face) ──
async function handleGoogleLogin() {
try {
const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID
if (!googleClientId) {
console.error('VITE_GOOGLE_CLIENT_ID is not configured')
return
}
// Load Google Identity Services if not already loaded
if (!(window as any).google?.accounts) {
await loadGoogleScript()
}
// Use Google OAuth2 token client to get an access_token.
// The backend will verify this token via Google's tokeninfo endpoint.
const accessToken = await new Promise<string>((resolve, reject) => {
const client = (window as any).google.accounts.oauth2.initTokenClient({
client_id: googleClientId,
scope: 'openid email profile',
callback: (response: any) => {
if (response.error) {
reject(new Error(response.error))
return
}
resolve(response.access_token)
},
})
client.requestAccessToken()
})
// Send the token to our backend
await authStore.googleAuth(accessToken)
emit('close')
if (authStore.isKycComplete) {
router.push('/dashboard')
} else {
router.push('/complete-kyc')
}
} catch (err) {
console.error('Google login failed:', err)
}
}
// ── Google Register (from Register face — saves referral code first) ──
async function handleGoogleRegister() {
try {
// Save referral code to localStorage before redirecting
if (referredByCode.value) {
localStorage.setItem('pending_referral_code', referredByCode.value)
}
// Same flow as login
await handleGoogleLogin()
} catch (err) {
console.error('Google register failed:', err)
}
}
/**
* Dynamically load the Google Identity Services script.
*/
function loadGoogleScript(): Promise<void> {
return new Promise((resolve, reject) => {
if ((window as any).google?.accounts) {
resolve()
return
}
const script = document.createElement('script')
script.src = 'https://accounts.google.com/gsi/client'
script.async = true
script.defer = true
script.onload = () => resolve()
script.onerror = () => reject(new Error('Failed to load Google Identity Services script'))
document.head.appendChild(script)
})
}
// ── Forgot Password Logic ──
async function handleForgotPassword() {
forgotError.value = ''

View File

@@ -287,6 +287,7 @@ export const useAuthStore = defineStore('auth', () => {
password: string
first_name: string
last_name: string
referred_by_code?: string
}) {
isLoading.value = true
error.value = null
@@ -629,6 +630,52 @@ export const useAuthStore = defineStore('auth', () => {
}
}
/**
* Google OAuth login/register.
* Sends the Google ID token to the backend. If the user typed a referral code
* in the registration form before clicking Google, it's passed along.
*/
async function googleAuth(token: string, referredByCode?: string) {
isLoading.value = true
error.value = null
try {
const payload: Record<string, any> = { access_token: token }
if (referredByCode) {
payload.referred_by_code = referredByCode
}
const res = await api.post('/auth/google', payload)
const data = res.data as {
access_token: string
refresh_token?: string
token_type: string
is_active: boolean
}
// Persist token
localStorage.setItem('access_token', data.access_token)
if (data.refresh_token) {
localStorage.setItem('refresh_token', data.refresh_token)
}
token.value = data.access_token
// Immediately fetch the user profile
await fetchUser()
// Fetch organizations
await fetchMyOrganizations()
return data
} catch (err: any) {
error.value = getErrorMessage(err, 'auth.googleAuthFailed')
throw err
} finally {
isLoading.value = false
}
}
/**
* Clear any stored error message.
*/
@@ -760,5 +807,6 @@ export const useAuthStore = defineStore('auth', () => {
clearError,
fetchMyOrganizations,
switchOrganization,
googleAuth,
}
})

View File

@@ -0,0 +1,503 @@
# 🏗️ Architectural Plan: Tiered & Time-Bound Commission Rules
## 1. Executive Summary
This document outlines the architectural design for a **dynamic, multi-dimensional commission and reward rule engine**. The system must support:
- **Tiered rewards** (e.g., Standard, VIP, Platinum) with different XP/credit/commission values per tier
- **Regional overrides** (e.g., HU-specific commission vs. Global default)
- **Time-bound campaigns** (promotional periods with start/end dates)
- **Rule type differentiation** (L1 referral XP/credits for individuals, L2 commission % for company purchases)
The design follows the existing **modular multi-directory pattern** used throughout the Service Finder codebase.
---
## 2. Schema Design: `CommissionRule` Model
### 2.1 Placement Decision
After analyzing the existing model structure:
| Directory | Schema | Purpose | Fit? |
|-----------|--------|---------|------|
| `backend/app/models/marketplace/finance.py` | `finance` | Issuers, financial ledger | ❌ Too narrow (invoicing focus) |
| `backend/app/models/fleet_finance/models.py` | `fleet_finance` | Asset costs, insurance, tax | ❌ Vehicle-centric |
| `backend/app/models/gamification/gamification.py` | `gamification` | Points, levels, badges | ❌ User reward focus, no commission |
| `backend/app/models/system/system.py` | `system` | Parameters, notifications | ❌ Generic config, no domain rules |
| **`backend/app/models/marketplace/`** | **`marketplace`** | **Services, providers, orgs** | ✅ **Best fit — commission rules govern marketplace transactions** |
**Decision:** Create a new file [`backend/app/models/marketplace/commission.py`](backend/app/models/marketplace/commission.py) in the `marketplace` schema. This keeps commission rules alongside the service provider and organization models they govern.
### 2.2 The `CommissionRule` Model
```python
# backend/app/models/marketplace/commission.py
"""
CommissionRule: Dynamic, tiered, time-bound commission and reward rules.
Supports:
- L1 rewards (XP/credits for individual referrals)
- L2 commissions (% for company-to-company purchases)
- Tier-based multipliers (Standard, VIP, Platinum)
- Regional overrides (HU, Global, etc.)
- Time-bound promotional campaigns
"""
import enum
import uuid
from datetime import datetime, date
from typing import Optional
from sqlalchemy import (
String, Integer, Float, Boolean, DateTime, Date,
Text, Enum as SQLEnum, Numeric, UniqueConstraint, text
)
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB
from sqlalchemy.sql import func
from app.database import Base
class CommissionRuleType(str, enum.Enum):
"""
Két elsődleges jutalom típus:
- L1_REWARD: XP/kredit jutalom egyéni ajánlóknak (referral)
- L2_COMMISSION: Százalékos jutalék céges vásárlások után
"""
L1_REWARD = "L1_REWARD" # XP/credits for individual referrers
L2_COMMISSION = "L2_COMMISSION" # % commission for company purchases
class CommissionTier(str, enum.Enum):
"""Jutalék szintek / rétegek."""
STANDARD = "STANDARD"
VIP = "VIP"
PLATINUM = "PLATINUM"
ENTERPRISE = "ENTERPRISE"
class CommissionRule(Base):
"""
Dinamikus jutalék/jutalom szabályok.
Minden szabály egy adott típushoz (L1/L2), szinthez (tier),
régióhoz és opcionális időablakhoz tartozik.
A lekérdező motor a tranzakció időpontjában érvényes,
legspecifikusabb szabályt alkalmazza.
"""
__tablename__ = "commission_rules"
__table_args__ = (
UniqueConstraint(
'rule_type', 'tier', 'region_code',
'start_date', 'end_date',
name='uix_commission_rule_unique'
),
{"schema": "marketplace", "extend_existing": True}
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
# --- Rule Classification ---
rule_type: Mapped[CommissionRuleType] = mapped_column(
SQLEnum(CommissionRuleType, name="commission_rule_type", schema="marketplace"),
nullable=False,
index=True,
comment="L1_REWARD = XP/kredit jutalom, L2_COMMISSION = % jutalék"
)
tier: Mapped[CommissionTier] = mapped_column(
SQLEnum(CommissionTier, name="commission_tier", schema="marketplace"),
nullable=False,
default=CommissionTier.STANDARD,
index=True,
comment="Jutalék szint (STANDARD, VIP, PLATINUM, ENTERPRISE)"
)
# --- Regional Scope ---
region_code: Mapped[str] = mapped_column(
String(10),
nullable=False,
default="GLOBAL",
index=True,
comment="ISO 3166-1 alpha-2 országkód (pl. 'HU', 'DE') vagy 'GLOBAL'"
)
# --- Reward / Commission Values ---
# L1_REWARD fields
xp_reward: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True, default=0,
comment="L1: XP jutalom értéke"
)
credit_reward: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True, default=0,
comment="L1: Kredit jutalom értéke"
)
# L2_COMMISSION fields
commission_percent: Mapped[Optional[float]] = mapped_column(
Numeric(5, 2), nullable=True, default=0.00,
comment="L2: Jutalék százalék (pl. 5.00 = 5%)"
)
commission_max_amount: Mapped[Optional[float]] = mapped_column(
Numeric(18, 2), nullable=True,
comment="L2: Maximális jutalék összeg (opcionális felső korlát)"
)
# --- Time-Bound Campaign ---
is_campaign: Mapped[bool] = mapped_column(
Boolean, default=False, server_default=text("false"),
comment="TRUE = időkorlátos kampány, FALSE = állandó szabály"
)
start_date: Mapped[Optional[date]] = mapped_column(
Date, nullable=True, index=True,
comment="Kampány kezdő dátuma (NULL = azonnal érvényes)"
)
end_date: Mapped[Optional[date]] = mapped_column(
Date, nullable=True, index=True,
comment="Kampány záró dátuma (NULL = nincs lejárat)"
)
# --- Metadata ---
name: Mapped[str] = mapped_column(
String(200), nullable=False,
comment="Emberi olvasható szabály név (pl. 'HU VIP Nyári Kampány')"
)
description: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
comment="Részletes leírás a szabályról"
)
is_active: Mapped[bool] = mapped_column(
Boolean, default=True, server_default=text("true"), index=True,
comment="TRUE = aktív és használható, FALSE = letiltva"
)
# --- Audit Trail ---
created_by: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True,
comment="Létrehozó admin felhasználó ID"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(),
onupdate=func.now()
)
# --- Extensibility ---
metadata_json: Mapped[Optional[dict]] = mapped_column(
JSONB, nullable=True, server_default=text("'{}'::jsonb"),
comment="Bővíthető metaadatok (pl. campaign banner URL, notes)"
)
def __repr__(self) -> str:
return (
f"<CommissionRule(id={self.id}, type='{self.rule_type}', "
f"tier='{self.tier}', region='{self.region_code}', "
f"active={self.is_active})>"
)
```
### 2.3 Key Design Decisions
| Decision | Rationale |
|----------|-----------|
| **Single table** for both L1 and L2 | Avoids join complexity; nullable fields for type-specific values |
| **`region_code` as simple string** | Follows existing pattern (see `InsuranceProvider.country_code`); avoids FK to a regions table for flexibility |
| **`start_date`/`end_date` as `Date`** | Campaigns are day-granular; timezone issues avoided by using dates |
| **`is_campaign` boolean** | Quick filtering for campaign vs. permanent rules without date parsing |
| **`UniqueConstraint` on 5 columns** | Prevents duplicate rules for the same type/tier/region/date combination |
| **`metadata_json` JSONB** | Future-proofing for UI-specific fields (colors, icons, tooltips) |
---
## 3. Query Engine: Finding the "Active" Rule at Transaction Time
### 3.1 Priority Resolution Algorithm
When a transaction occurs (e.g., a referral signup or a company purchase), the engine must find the **most specific active rule**:
```
1. Filter: rule_type = {L1_REWARD | L2_COMMISSION}
AND is_active = true
AND (start_date IS NULL OR start_date <= transaction_date)
AND (end_date IS NULL OR end_date >= transaction_date)
2. Sort by specificity (most specific first):
a. Campaign rules (is_campaign = true) over permanent rules
b. Most specific region match (HU > GLOBAL)
c. Highest tier match (PLATINUM > VIP > STANDARD)
3. Return the top match, or fall back to the GLOBAL/STANDARD default
```
### 3.2 SQLAlchemy Query Example
```python
from sqlalchemy import select, and_, or_, case
from datetime import date
async def get_active_commission_rule(
db: AsyncSession,
rule_type: CommissionRuleType,
tier: CommissionTier,
region_code: str,
transaction_date: date
) -> Optional[CommissionRule]:
"""
Megkeresi a leginkább releváns aktív jutalék szabályt.
"""
# Build priority: campaign > permanent, specific region > GLOBAL, higher tier first
priority = case(
(CommissionRule.is_campaign == True, 0), # campaigns first
else_=1
)
region_priority = case(
(CommissionRule.region_code == region_code, 0),
(CommissionRule.region_code == "GLOBAL", 1),
else_=2
)
tier_order = {
"PLATINUM": 0,
"VIP": 1,
"STANDARD": 2,
"ENTERPRISE": 3,
}
tier_priority = case(
*[(CommissionRule.tier == k, v) for k, v in tier_order.items()],
else_=99
)
stmt = (
select(CommissionRule)
.where(
CommissionRule.rule_type == rule_type,
CommissionRule.is_active == True,
or_(
CommissionRule.start_date.is_(None),
CommissionRule.start_date <= transaction_date
),
or_(
CommissionRule.end_date.is_(None),
CommissionRule.end_date >= transaction_date
),
or_(
CommissionRule.region_code == region_code,
CommissionRule.region_code == "GLOBAL"
),
or_(
CommissionRule.tier == tier,
CommissionRule.tier == CommissionTier.STANDARD
)
)
.order_by(priority, region_priority, tier_priority)
.limit(1)
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
```
---
## 4. Modular Architecture Compliance
### 4.1 File Placement
```
backend/app/models/marketplace/
├── __init__.py # ← Add CommissionRule export
├── commission.py # ← NEW: CommissionRule model
├── finance.py # existing
├── logistics.py # existing
├── organization.py # existing
├── payment.py # existing
├── service.py # existing
├── service_request.py # existing
└── staged_data.py # existing
```
### 4.2 Registration in [`__init__.py`](backend/app/models/marketplace/__init__.py)
Add to the marketplace package exports:
```python
from .commission import CommissionRule, CommissionRuleType, CommissionTier
```
### 4.3 Registration in [`backend/app/models/__init__.py`](backend/app/models/__init__.py)
Add to the master model registry:
```python
# In the marketplace section:
from .marketplace.commission import CommissionRule, CommissionRuleType, CommissionTier
```
And add to `__all__`:
```python
"CommissionRule", "CommissionRuleType", "CommissionTier",
```
---
## 5. Admin API Outline (FastAPI)
### 5.1 Endpoint Structure
All endpoints under `/api/v1/admin/commission-rules` with admin-level authentication.
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/admin/commission-rules` | List all rules (with pagination, filtering by type/tier/region/active) |
| `GET` | `/api/v1/admin/commission-rules/{id}` | Get single rule details |
| `POST` | `/api/v1/admin/commission-rules` | Create a new rule |
| `PUT` | `/api/v1/admin/commission-rules/{id}` | Update an existing rule |
| `DELETE` | `/api/v1/admin/commission-rules/{id}` | Soft-delete / deactivate a rule |
| `GET` | `/api/v1/admin/commission-rules/active` | Get currently active rules for a given type/tier/region |
### 5.2 Pydantic Schemas
Create [`backend/app/schemas/commission.py`](backend/app/schemas/commission.py):
```python
from pydantic import BaseModel, Field
from datetime import date, datetime
from typing import Optional
from enum import Enum
class CommissionRuleTypeEnum(str, Enum):
L1_REWARD = "L1_REWARD"
L2_COMMISSION = "L2_COMMISSION"
class CommissionTierEnum(str, Enum):
STANDARD = "STANDARD"
VIP = "VIP"
PLATINUM = "PLATINUM"
ENTERPRISE = "ENTERPRISE"
class CommissionRuleCreate(BaseModel):
rule_type: CommissionRuleTypeEnum
tier: CommissionTierEnum = CommissionTierEnum.STANDARD
region_code: str = "GLOBAL"
xp_reward: Optional[int] = None
credit_reward: Optional[int] = None
commission_percent: Optional[float] = None
commission_max_amount: Optional[float] = None
is_campaign: bool = False
start_date: Optional[date] = None
end_date: Optional[date] = None
name: str
description: Optional[str] = None
is_active: bool = True
class CommissionRuleUpdate(BaseModel):
rule_type: Optional[CommissionRuleTypeEnum] = None
tier: Optional[CommissionTierEnum] = None
region_code: Optional[str] = None
xp_reward: Optional[int] = None
credit_reward: Optional[int] = None
commission_percent: Optional[float] = None
commission_max_amount: Optional[float] = None
is_campaign: Optional[bool] = None
start_date: Optional[date] = None
end_date: Optional[date] = None
name: Optional[str] = None
description: Optional[str] = None
is_active: Optional[bool] = None
class CommissionRuleResponse(BaseModel):
id: int
rule_type: CommissionRuleTypeEnum
tier: CommissionTierEnum
region_code: str
xp_reward: Optional[int]
credit_reward: Optional[int]
commission_percent: Optional[float]
commission_max_amount: Optional[float]
is_campaign: bool
start_date: Optional[date]
end_date: Optional[date]
name: str
description: Optional[str]
is_active: bool
created_by: Optional[int]
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class CommissionRuleListResponse(BaseModel):
items: list[CommissionRuleResponse]
total: int
page: int
page_size: int
```
### 5.3 Service Layer
Create [`backend/app/services/commission_service.py`](backend/app/services/commission_service.py) with:
- `create_commission_rule(db, data, admin_user_id)` — Create with validation
- `update_commission_rule(db, rule_id, data)` — Partial update
- `deactivate_commission_rule(db, rule_id)` — Soft delete (set `is_active=False`)
- `get_active_rule(db, rule_type, tier, region, transaction_date)` — The resolution engine
- `list_rules(db, filters, pagination)` — Admin listing with filters
### 5.4 Endpoint File
Create [`backend/app/api/v1/endpoints/admin_commission.py`](backend/app/api/v1/endpoints/admin_commission.py) with CRUD endpoints.
---
## 6. Database Migration
After implementing the model, generate an Alembic migration:
```bash
docker compose exec sf_api alembic revision --autogenerate -m "Add commission_rules table to marketplace schema"
docker compose exec sf_api alembic upgrade head
```
Or use the project's sync engine:
```bash
docker compose exec sf_api python -m app.scripts.sync_engine
```
---
## 7. Future Considerations
| Feature | Approach |
|---------|----------|
| **Multi-currency commission amounts** | Add `currency` column (default: `EUR`); extend `commission_max_amount` |
| **Stacking rules** (multiple campaigns) | Add `stackable` boolean; engine sums matching rules |
| **Minimum transaction thresholds** | Add `min_transaction_amount` column |
| **Referrer tier promotion** | Auto-promote users based on total referred revenue; integrate with `UserStats` |
| **Audit log for rule changes** | Use existing `system.operational_logs` table |
| **Caching active rules** | Redis cache with TTL; invalidate on rule CRUD |
---
## 8. Implementation Order
1. ✅ Create [`backend/app/models/marketplace/commission.py`](backend/app/models/marketplace/commission.py) — Model definition
2. ✅ Update [`backend/app/models/marketplace/__init__.py`](backend/app/models/marketplace/__init__.py) — Export new model
3. ✅ Update [`backend/app/models/__init__.py`](backend/app/models/__init__.py) — Register in master registry
4. 🔲 Create [`backend/app/schemas/commission.py`](backend/app/schemas/commission.py) — Pydantic schemas
5. 🔲 Create [`backend/app/services/commission_service.py`](backend/app/services/commission_service.py) — Business logic
6. 🔲 Create [`backend/app/api/v1/endpoints/admin_commission.py`](backend/app/api/v1/endpoints/admin_commission.py) — API endpoints
7. 🔲 Register endpoint in router
8. 🔲 Run sync engine to create table
9. 🔲 Write tests

View File

@@ -0,0 +1,180 @@
"""
Seed test data for Commission Distribution verification.
Creates:
1. Three users with a referral chain: UserC (buyer) -> UserB (Gen1) -> UserA (Gen2)
2. An active L2_COMMISSION rule with commission_percent and upline_commission_percent
Usage:
docker compose exec sf_api python /app/backend/tests/active/seed_commission_test_data.py
"""
import asyncio
import os
import sys
sys.path.insert(0, "/app/backend")
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy import text
from datetime import date
async def seed():
database_url = os.getenv(
"DATABASE_URL",
"postgresql+asyncpg://service_finder:service_finder@postgres:5432/service_finder",
)
engine = create_async_engine(database_url, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as db:
# Check if test users already exist
existing = await db.execute(
text("SELECT id, email FROM identity.users WHERE email LIKE 'commission_test_%' ORDER BY id")
)
existing_users = existing.fetchall()
if existing_users:
print(f"⚠️ Test users already exist: {[u.email for u in existing_users]}")
print(" Skipping user creation.")
# Find the chain
result = await db.execute(
text("""
SELECT u1.id AS buyer_id, u1.email AS buyer_email,
u2.id AS gen1_id, u2.email AS gen1_email,
u3.id AS gen2_id, u3.email AS gen2_email
FROM identity.users u1
JOIN identity.users u2 ON u1.referred_by_id = u2.id
JOIN identity.users u3 ON u2.referred_by_id = u3.id
WHERE u1.email = 'commission_test_buyer@test.com'
""")
)
row = result.fetchone()
if row:
print(f"\n✅ Referral chain intact:")
print(f" Buyer(Gen0): ID={row.buyer_id} < {row.buyer_email}")
print(f" Gen1: ID={row.gen1_id} < {row.gen1_email}")
print(f" Gen2: ID={row.gen2_id} < {row.gen2_email}")
else:
print("Creating test users with referral chain...")
# Create Gen2 (top-level referrer) - UserA
result = await db.execute(
text("""
INSERT INTO identity.users
(email, hashed_password, role, commission_tier, is_active, ui_mode,
subscription_plan, is_vip, is_deleted, preferred_language,
region_code, preferred_currency, scope_level,
custom_permissions, alternative_emails, email_history,
visual_settings, created_at)
VALUES
('commission_test_gen2@test.com', 'abc123', 'USER', 'STANDARD', TRUE, 'personal',
'free', FALSE, FALSE, 'hu',
'HU', 'HUF', 'user',
'{}'::json, '[]'::json, '[]'::json,
'{}'::jsonb, NOW())
RETURNING id
""")
)
gen2_id = result.scalar()
print(f" Created Gen2 (Upline): ID={gen2_id}")
# Create Gen1 (referrer) - UserB, referred by Gen2
result = await db.execute(
text("""
INSERT INTO identity.users
(email, hashed_password, role, commission_tier, is_active, ui_mode,
referred_by_id,
subscription_plan, is_vip, is_deleted, preferred_language,
region_code, preferred_currency, scope_level,
custom_permissions, alternative_emails, email_history,
visual_settings, created_at)
VALUES
('commission_test_gen1@test.com', 'abc123', 'USER', 'STANDARD', TRUE, 'personal',
:gen2_id,
'free', FALSE, FALSE, 'hu',
'HU', 'HUF', 'user',
'{}'::json, '[]'::json, '[]'::json,
'{}'::jsonb, NOW())
RETURNING id
""").bindparams(gen2_id=gen2_id)
)
gen1_id = result.scalar()
print(f" Created Gen1 (Referrer): ID={gen1_id}")
# Create Buyer (Gen0) - UserC, referred by Gen1
result = await db.execute(
text("""
INSERT INTO identity.users
(email, hashed_password, role, commission_tier, is_active, ui_mode,
referred_by_id,
subscription_plan, is_vip, is_deleted, preferred_language,
region_code, preferred_currency, scope_level,
custom_permissions, alternative_emails, email_history,
visual_settings, created_at)
VALUES
('commission_test_buyer@test.com', 'abc123', 'USER', 'STANDARD', TRUE, 'personal',
:gen1_id,
'free', FALSE, FALSE, 'hu',
'HU', 'HUF', 'user',
'{}'::json, '[]'::json, '[]'::json,
'{}'::jsonb, NOW())
RETURNING id
""").bindparams(gen1_id=gen1_id)
)
buyer_id = result.scalar()
print(f" Created Buyer (Gen0): ID={buyer_id}")
print(f"\n✅ Referral chain created:")
print(f" Buyer(Gen0): ID={buyer_id} -> referred_by={gen1_id}")
print(f" Gen1: ID={gen1_id} -> referred_by={gen2_id}")
print(f" Gen2: ID={gen2_id}")
# Check for existing L2_COMMISSION rules
rule_result = await db.execute(
text("""
SELECT id, name, commission_percent, upline_commission_percent,
commission_max_amount, is_active
FROM marketplace.commission_rules
WHERE rule_type = 'L2_COMMISSION'
ORDER BY id
""")
)
existing_rules = rule_result.fetchall()
if existing_rules:
print(f"\n⚠️ L2_COMMISSION rules already exist:")
for r in existing_rules:
print(f" ID={r.id}, Name={r.name}, Active={r.is_active}, "
f"commission={r.commission_percent}%, upline={r.upline_commission_percent}%, "
f"max={r.commission_max_amount}")
else:
print("\nCreating L2_COMMISSION rule...")
result = await db.execute(
text("""
INSERT INTO marketplace.commission_rules
(rule_type, name, description, tier, region_code,
commission_percent, upline_commission_percent,
commission_max_amount, is_campaign, is_active,
start_date, end_date, created_by)
VALUES
('L2_COMMISSION', 'Standard 2-Level Commission', 'Default 2-level MLM commission rule',
'STANDARD', 'HU',
5.00, 2.00,
50000.00, FALSE, TRUE,
:start_date, NULL, 1)
RETURNING id
""").bindparams(start_date=date(2026, 1, 1))
)
rule_id = result.scalar()
print(f" Created L2_COMMISSION rule: ID={rule_id}")
print(f" commission_percent=5.00%, upline_commission_percent=2.00%, max=50000.00")
await db.commit()
print("\n✅ Seed data created successfully!")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(seed())

View File

@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""
Commission Distribution Verification Test
Tests the 2-Level MLM commission distribution logic end-to-end.
Prerequisites:
- Seed data created via seed_commission_test_data.py
Usage:
docker compose exec sf_api python /app/backend/tests/active/test_commission_distribution.py
"""
import asyncio
import httpx
import sys
import os
BASE_URL = "http://sf_api:8000/api/v1"
ADMIN_EMAIL = "admin@profibot.hu"
ADMIN_PASSWORD = "Admin123!"
async def main():
passed = 0
failed = 0
async with httpx.AsyncClient(base_url=BASE_URL) as client:
# ── Step 1: Login ──
print("=" * 60)
print("STEP 1: Login with admin user")
print("=" * 60)
login_data = {
"username": ADMIN_EMAIL,
"password": ADMIN_PASSWORD,
"grant_type": "password",
}
login_resp = await client.post(
"/auth/login",
data=login_data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if login_resp.status_code != 200:
print(f"❌ Login failed: {login_resp.status_code} {login_resp.text}")
sys.exit(1)
token_data = login_resp.json()
access_token = token_data.get("access_token")
if not access_token:
print(f"❌ No access_token in response: {token_data}")
sys.exit(1)
print(f"✅ Login successful, token obtained")
headers = {"Authorization": f"Bearer {access_token}"}
passed += 1
# ── Step 2: Check referral chain via DB ──
print("\n" + "=" * 60)
print("STEP 2: Verify referral chain exists")
print("=" * 60)
# We'll check via the users list endpoint
resp = await client.get(
"/admin/users?limit=50",
headers=headers,
)
if resp.status_code != 200:
print(f"❌ Failed to list users: {resp.status_code} {resp.text}")
failed += 1
else:
users = resp.json()
# Find our test users
test_users = [u for u in (users.get("users") or users.get("data") or users if isinstance(users, list) else [])
if isinstance(u, dict) and "commission_test" in str(u.get("email", ""))]
# Try to find from response
print(f"✅ Users endpoint responded (status={resp.status_code})")
passed += 1
# ── Step 3: Test commission distribution ──
print("\n" + "=" * 60)
print("STEP 3: Test commission distribution API")
print("=" * 60)
# Use the known test user IDs from seed data
# Buyer (Gen0) = ID 155, Gen1 = 154, Gen2 = 153
# Rule ID 4: STANDARD, HU, 5% Gen1, 2% Gen2, max 50000
distribute_payload = {
"buyer_user_id": 155,
"transaction_amount": 100000.00,
"transaction_date": "2026-07-24",
"region_code": "HU",
}
print(f"Request: POST /admin/commission-rules/distribute")
print(f"Payload: {distribute_payload}")
resp = await client.post(
"/admin/commission-rules/distribute",
json=distribute_payload,
headers=headers,
)
print(f"Response status: {resp.status_code}")
if resp.status_code == 200:
data = resp.json()
print(f"Response body: {data}")
print()
# Validate response structure
assert "transaction_amount" in data, "Missing transaction_amount"
assert "items" in data, "Missing items"
assert "total_commission" in data, "Missing total_commission"
assert len(data["items"]) > 0, "No commission items returned"
# Check Gen1 commission (5% of 100000 = 5000)
gen1_item = next((i for i in data["items"] if i["level"] == 1), None)
assert gen1_item is not None, "Missing Gen1 commission item"
assert gen1_item["user_id"] == 154, f"Expected Gen1 user_id=154, got {gen1_item['user_id']}"
assert gen1_item["commission_amount"] == 5000.00, \
f"Expected Gen1 commission=5000.00, got {gen1_item['commission_amount']}"
# Check Gen2 commission (2% of 100000 = 2000)
gen2_item = next((i for i in data["items"] if i["level"] == 2), None)
assert gen2_item is not None, "Missing Gen2 commission item"
assert gen2_item["user_id"] == 153, f"Expected Gen2 user_id=153, got {gen2_item['user_id']}"
assert gen2_item["commission_amount"] == 2000.00, \
f"Expected Gen2 commission=2000.00, got {gen2_item['commission_amount']}"
# Check total
assert data["total_commission"] == 7000.00, \
f"Expected total_commission=7000.00, got {data['total_commission']}"
print("✅ Commission distribution test PASSED!")
print(f" Gen1 (User 154): 5% of 100000 = 5000.00 ✅")
print(f" Gen2 (User 153): 2% of 100000 = 2000.00 ✅")
print(f" Total: 7000.00 ✅")
passed += 1
elif resp.status_code == 404:
print(f"⚠️ No matching rule found: {resp.text}")
print(" This means the rule resolution didn't match. Check tier/region.")
else:
print(f"❌ Distribution failed: {resp.text}")
failed += 1
# ── Step 4: Test with max_amount cap ──
print("\n" + "=" * 60)
print("STEP 4: Test commission with max_amount cap")
print("=" * 60)
# With a very large transaction, Gen1 should be capped at 50000
# 5% of 2000000 = 100000, but max is 50000
cap_payload = {
"buyer_user_id": 155,
"transaction_amount": 2000000.00,
"transaction_date": "2026-07-24",
"region_code": "HU",
}
resp = await client.post(
"/admin/commission-rules/distribute",
json=cap_payload,
headers=headers,
)
if resp.status_code == 200:
data = resp.json()
gen1_item = next((i for i in data["items"] if i["level"] == 1), None)
if gen1_item:
# 5% of 2M = 100000, capped at 50000
assert gen1_item["commission_amount"] <= 50000.00, \
f"Gen1 commission {gen1_item['commission_amount']} exceeds max 50000"
print(f"✅ Max amount cap verified: Gen1 commission={gen1_item['commission_amount']} (capped at 50000)")
passed += 1
else:
print("⚠️ No Gen1 item in capped test")
else:
print(f"⚠️ Cap test skipped (status={resp.status_code})")
# ── Summary ──
print("\n" + "=" * 60)
print(f"RESULTS: {passed} passed, {failed} failed")
print("=" * 60)
if failed > 0:
sys.exit(1)
else:
print("\n✅ All commission distribution tests PASSED!")
if __name__ == "__main__":
asyncio.run(main())