L1L2 gen1Gen2 kialakítása
This commit is contained in:
120
.roo/history.md
120
.roo/history.md
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -47,4 +48,5 @@ api_router.include_router(marketing.router, prefix="/marketing", tags=["Marketin
|
||||
api_router.include_router(admin_permissions.router, prefix="", tags=["Admin Permissions (RBAC)"])
|
||||
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(locations.router, prefix="", tags=["Location Autocomplete"])
|
||||
api_router.include_router(admin_commission.router, prefix="/admin/commission-rules", tags=["Admin Commission Rules"])
|
||||
273
backend/app/api/v1/endpoints/admin_commission.py
Normal file
273
backend/app/api/v1/endpoints/admin_commission.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
@@ -379,4 +384,153 @@ async def restore_account_verify(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result.get("message", "Érvénytelen vagy lejárt kód.")
|
||||
)
|
||||
return result
|
||||
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)}"
|
||||
)
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
184
backend/app/models/marketplace/commission.py
Normal file
184
backend/app/models/marketplace/commission.py
Normal 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})>"
|
||||
)
|
||||
@@ -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) ---
|
||||
|
||||
@@ -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"))
|
||||
138
backend/app/schemas/commission.py
Normal file
138
backend/app/schemas/commission.py
Normal 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")
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
528
backend/app/services/commission_service.py
Normal file
528
backend/app/services/commission_service.py
Normal 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),
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
LOGIKA MEGŐRIZVE: Step 1 regisztráció slug és flotta nélkül.
|
||||
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()
|
||||
|
||||
|
||||
180
backend/backend/tests/active/seed_commission_test_data.py
Normal file
180
backend/backend/tests/active/seed_commission_test_data.py
Normal 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())
|
||||
194
backend/backend/tests/active/test_commission_distribution.py
Normal file
194
backend/backend/tests/active/test_commission_distribution.py
Normal 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())
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
{"id":"dev","timestamp":1783500293953}
|
||||
{"id":"dev","timestamp":1784853723611}
|
||||
@@ -1 +1 @@
|
||||
{"id":"dev","timestamp":1783500293953,"prerendered":[]}
|
||||
{"id":"dev","timestamp":1784853723611,"prerendered":[]}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
2
frontend_admin/.nuxt/nuxt.d.ts
vendored
2
frontend_admin/.nuxt/nuxt.d.ts
vendored
@@ -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" />
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
79
frontend_admin/i18n/locales/en/commission.json
Normal file
79
frontend_admin/i18n/locales/en/commission.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
79
frontend_admin/i18n/locales/hu/commission.json
Normal file
79
frontend_admin/i18n/locales/hu/commission.json
Normal 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ő"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -339,9 +339,20 @@ const menuGroups: MenuGroup[] = [
|
||||
title: 'menu.finance',
|
||||
items: [
|
||||
{
|
||||
path: '/packages',
|
||||
label: 'menu.packages',
|
||||
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-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'
|
||||
|
||||
@@ -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' },
|
||||
|
||||
795
frontend_admin/pages/finance/commission-rules.vue
Normal file
795
frontend_admin/pages/finance/commission-rules.vue
Normal 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>
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
503
plans/logic_spec_commission_rules_architecture.md
Normal file
503
plans/logic_spec_commission_rules_architecture.md
Normal 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
|
||||
180
tests/active/seed_commission_test_data.py
Normal file
180
tests/active/seed_commission_test_data.py
Normal 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())
|
||||
194
tests/active/test_commission_distribution.py
Normal file
194
tests/active/test_commission_distribution.py
Normal 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())
|
||||
Reference in New Issue
Block a user