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`
|
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)
|
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
|
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,
|
admin_packages, admin_services, admin_organizations, admin_users, admin_persons, constants, providers,
|
||||||
subscriptions, marketing, admin_permissions, regions,
|
subscriptions, marketing, admin_permissions, regions,
|
||||||
admin_gamification, admin_providers, locations,
|
admin_gamification, admin_providers, locations,
|
||||||
|
admin_commission,
|
||||||
)
|
)
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
@@ -48,3 +49,4 @@ api_router.include_router(admin_permissions.router, prefix="", tags=["Admin Perm
|
|||||||
api_router.include_router(admin_gamification.router, prefix="/admin/gamification", tags=["Admin Gamification"])
|
api_router.include_router(admin_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(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 sqlalchemy import select
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.services.auth_service import AuthService
|
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.security import create_tokens, DEFAULT_RANK_MAP
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.services.config_service import config
|
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 app.core.translation_helper import t # Translation helper
|
||||||
from pydantic import BaseModel, Field, EmailStr
|
from pydantic import BaseModel, Field, EmailStr
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
import httpx
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -380,3 +385,152 @@ async def restore_account_verify(
|
|||||||
detail=result.get("message", "Érvénytelen vagy lejárt kód.")
|
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)
|
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."""
|
"""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:
|
if vehicle_class:
|
||||||
stmt = stmt.where(BodyTypeDictionary.vehicle_class == vehicle_class)
|
stmt = stmt.where(BodyTypeDictionary.vehicle_class == vehicle_class)
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
@@ -97,10 +97,7 @@ async def list_body_types(
|
|||||||
"id": r.id,
|
"id": r.id,
|
||||||
"vehicle_class": r.vehicle_class,
|
"vehicle_class": r.vehicle_class,
|
||||||
"code": r.code,
|
"code": r.code,
|
||||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
|
||||||
"name_i18n": r.name_i18n if r.name_i18n else {},
|
"name_i18n": r.name_i18n if r.name_i18n else {},
|
||||||
# --- [DEPRECATED] Old columns (Phase 3) ---
|
|
||||||
"name_hu": r.name_hu,
|
|
||||||
}
|
}
|
||||||
for r in rows
|
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.api.deps import get_db, get_current_user
|
||||||
from app.models.fleet_finance.models import AssetCost, CostCategory
|
from app.models.fleet_finance.models import AssetCost, CostCategory
|
||||||
from app.models.vehicle.asset import Asset
|
from app.models.vehicle.asset import Asset
|
||||||
from app.models.vehicle.asset_event import AssetEvent
|
from app.models.vehicle.asset import AssetEvent
|
||||||
from app.models.vehicle.odometer import OdometerReading
|
from app.models.vehicle.asset import OdometerReading
|
||||||
from app.models.fleet.organization import Organization
|
from app.models.marketplace.organization import Organization
|
||||||
from app.models.fleet.org_member import OrganizationMember
|
from app.models.marketplace.organization import OrganizationMember
|
||||||
from app.models.marketplace.service import ServiceProvider, ServiceProfile, ServiceExpertise
|
from app.models.identity.social import ServiceProvider
|
||||||
from app.models.system.system_parameter import SystemParameter
|
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.schemas.asset_cost import AssetCostCreate, AssetCostUpdate, AssetCostResponse
|
||||||
from app.services.gamification_service import GamificationService
|
from app.services.gamification_service import GamificationService
|
||||||
from app.services.provider_service import find_or_create_provider_by_name
|
from app.services.provider_service import find_or_create_provider_by_name
|
||||||
|
|||||||
@@ -77,12 +77,8 @@ async def get_category_tree(
|
|||||||
nodes.append(CategoryTreeNode(
|
nodes.append(CategoryTreeNode(
|
||||||
id=tag.id,
|
id=tag.id,
|
||||||
key=tag.key,
|
key=tag.key,
|
||||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
|
||||||
name_i18n=tag.name_i18n if tag.name_i18n else {},
|
name_i18n=tag.name_i18n if tag.name_i18n else {},
|
||||||
description_i18n=tag.description_i18n,
|
description_i18n=tag.description_i18n,
|
||||||
# --- [DEPRECATED] Old columns (Phase 3) ---
|
|
||||||
name_hu=tag.name_hu,
|
|
||||||
name_en=tag.name_en,
|
|
||||||
level=tag.level,
|
level=tag.level,
|
||||||
path=tag.path,
|
path=tag.path,
|
||||||
is_official=tag.is_official,
|
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.
|
Szöveges kereső a Szint 2 (Szakma) és Szint 3 (Specifikus feladat) címkékhez.
|
||||||
|
|
||||||
Csak azokat listázza, ahol is_official=True.
|
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:
|
try:
|
||||||
# --- 🏗️ i18n JSONB Migration (Phase 3): JSONB → String cast keresés ---
|
|
||||||
stmt = select(ExpertiseTag).where(
|
stmt = select(ExpertiseTag).where(
|
||||||
ExpertiseTag.level >= 2,
|
ExpertiseTag.level >= 2,
|
||||||
ExpertiseTag.is_official == True,
|
ExpertiseTag.is_official == True,
|
||||||
or_(
|
or_(
|
||||||
cast(ExpertiseTag.name_i18n, String).ilike(f"%{q}%"),
|
cast(ExpertiseTag.name_i18n, String).ilike(f"%{q}%"),
|
||||||
ExpertiseTag.key.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)
|
result = await db.execute(stmt)
|
||||||
tags = result.scalars().all()
|
tags = result.scalars().all()
|
||||||
@@ -134,11 +126,7 @@ async def autocomplete_categories(
|
|||||||
CategoryAutocompleteItem(
|
CategoryAutocompleteItem(
|
||||||
id=t.id,
|
id=t.id,
|
||||||
key=t.key,
|
key=t.key,
|
||||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
|
||||||
name_i18n=t.name_i18n if t.name_i18n else {},
|
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,
|
level=t.level,
|
||||||
path=t.path,
|
path=t.path,
|
||||||
parent_id=t.parent_id,
|
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.staged_data import ServiceStaging, DiscoveryParameter, StagedVehicleData
|
||||||
from .marketplace.service_request import ServiceRequest
|
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)
|
# 8. Közösségi és értékelési modellek (Social 3)
|
||||||
from .identity.social import ServiceProvider, Vote, Competition, UserScore, ServiceReview, ModerationStatus, SourceType
|
from .identity.social import ServiceProvider, Vote, Competition, UserScore, ServiceReview, ModerationStatus, SourceType
|
||||||
|
|
||||||
@@ -100,4 +103,6 @@ __all__ = [
|
|||||||
"CampaignStatus", "CreativeType", "PlacementType",
|
"CampaignStatus", "CreativeType", "PlacementType",
|
||||||
# RBAC Phase 1
|
# RBAC Phase 1
|
||||||
"SystemRole", "SystemPermission", "SystemRolePermission",
|
"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)
|
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'
|
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)
|
# --- 🏗️ i18n JSONB Migration (Phase 1 - Complete) ---
|
||||||
description: Mapped[Optional[str]] = mapped_column(String)
|
|
||||||
# --- 🏗️ i18n JSONB Migration (Phase 1) ---
|
|
||||||
name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))
|
name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))
|
||||||
description_i18n: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
description_i18n: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||||
credit_cost: Mapped[int] = mapped_column(Integer, default=0) # alapértelmezett ár kreditben
|
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"))
|
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"))
|
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_active: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
is_deleted: 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
|
from .service_request import ServiceRequest
|
||||||
|
|
||||||
|
# Commission Rules (Phase 2)
|
||||||
|
from .commission import CommissionRule, CommissionRuleType, CommissionTier
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Organization",
|
"Organization",
|
||||||
"OrganizationMember",
|
"OrganizationMember",
|
||||||
@@ -52,4 +55,7 @@ __all__ = [
|
|||||||
"LocationType",
|
"LocationType",
|
||||||
"StagedVehicleData",
|
"StagedVehicleData",
|
||||||
"ServiceRequest",
|
"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)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
key: Mapped[str] = mapped_column(String(50), unique=True, index=True)
|
key: Mapped[str] = mapped_column(String(50), unique=True, index=True)
|
||||||
name_hu: Mapped[Optional[str]] = mapped_column(String(100))
|
# --- 🏗️ i18n JSONB Migration (Phase 1 - Complete) ---
|
||||||
name_en: Mapped[Optional[str]] = mapped_column(String(100))
|
|
||||||
# --- 🏗️ i18n JSONB Migration (Phase 1) ---
|
|
||||||
name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))
|
name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))
|
||||||
description_i18n: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
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)
|
category: Mapped[Optional[str]] = mapped_column(String(30), index=True)
|
||||||
|
|
||||||
# --- 🏗️ 4-LEVEL HIERARCHY (2026-06-17) ---
|
# --- 🏗️ 4-LEVEL HIERARCHY (2026-06-17) ---
|
||||||
|
|||||||
@@ -172,6 +172,5 @@ class BodyTypeDictionary(Base):
|
|||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
vehicle_class: Mapped[str] = mapped_column(String(50), index=True, nullable=False)
|
vehicle_class: Mapped[str] = mapped_column(String(50), index=True, nullable=False)
|
||||||
code: Mapped[str] = mapped_column(String(50), 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 - Complete) ---
|
||||||
# --- 🏗️ i18n JSONB Migration (Phase 1) ---
|
|
||||||
name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))
|
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
|
id: int
|
||||||
key: str
|
key: str
|
||||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
|
||||||
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
|
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)")
|
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
|
level: int = 0
|
||||||
path: Optional[str] = None
|
path: Optional[str] = None
|
||||||
is_official: bool = True
|
is_official: bool = True
|
||||||
@@ -57,11 +53,7 @@ class CategoryAutocompleteItem(BaseModel):
|
|||||||
"""
|
"""
|
||||||
id: int
|
id: int
|
||||||
key: str
|
key: str
|
||||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
|
||||||
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
|
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
|
level: int = 0
|
||||||
path: Optional[str] = None
|
path: Optional[str] = None
|
||||||
parent_id: Optional[int] = None
|
parent_id: Optional[int] = None
|
||||||
@@ -80,11 +72,7 @@ class ExpertiseCategoryOut(BaseModel):
|
|||||||
"""
|
"""
|
||||||
id: int
|
id: int
|
||||||
key: str
|
key: str
|
||||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
|
||||||
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
|
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
|
category: Optional[str] = None
|
||||||
level: int = 0
|
level: int = 0
|
||||||
parent_id: Optional[int] = None
|
parent_id: Optional[int] = None
|
||||||
@@ -101,11 +89,7 @@ class CategoryInfo(BaseModel):
|
|||||||
szolgáltatásokat a kártyákon.
|
szolgáltatásokat a kártyákon.
|
||||||
"""
|
"""
|
||||||
id: int
|
id: int
|
||||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
|
||||||
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
|
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
|
level: int = 0
|
||||||
key: str
|
key: str
|
||||||
|
|
||||||
|
|||||||
@@ -52,13 +52,9 @@ async def seed_dismantler_category():
|
|||||||
# Beszúrás
|
# Beszúrás
|
||||||
tag = ExpertiseTag(
|
tag = ExpertiseTag(
|
||||||
key=DISMANTLER_CATEGORY["key"],
|
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_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"],
|
category=DISMANTLER_CATEGORY["category"],
|
||||||
search_keywords=DISMANTLER_CATEGORY["search_keywords"],
|
search_keywords=DISMANTLER_CATEGORY["search_keywords"],
|
||||||
description=DISMANTLER_CATEGORY["description"],
|
|
||||||
is_official=True,
|
is_official=True,
|
||||||
discovery_points=10,
|
discovery_points=10,
|
||||||
usage_count=0,
|
usage_count=0,
|
||||||
|
|||||||
@@ -230,14 +230,11 @@ async def seed_enterprise_taxonomy():
|
|||||||
"hu": tag_data["name_hu"],
|
"hu": tag_data["name_hu"],
|
||||||
"en": tag_data["name_en"],
|
"en": tag_data["name_en"],
|
||||||
}),
|
}),
|
||||||
name_hu=tag_data["name_hu"],
|
|
||||||
name_en=tag_data["name_en"],
|
|
||||||
category=tag_data.get("category", "other"),
|
category=tag_data.get("category", "other"),
|
||||||
level=tag_data["level"],
|
level=tag_data["level"],
|
||||||
parent_id=tag_data.get("parent_id"),
|
parent_id=tag_data.get("parent_id"),
|
||||||
path=path,
|
path=path,
|
||||||
search_keywords=tag_data.get("search_keywords", []),
|
search_keywords=tag_data.get("search_keywords", []),
|
||||||
description=tag_data.get("description", ""),
|
|
||||||
is_official=True,
|
is_official=True,
|
||||||
discovery_points=10,
|
discovery_points=10,
|
||||||
usage_count=0,
|
usage_count=0,
|
||||||
|
|||||||
@@ -145,13 +145,9 @@ async def seed_expertise_tags():
|
|||||||
for cat in BASE_CATEGORIES:
|
for cat in BASE_CATEGORIES:
|
||||||
tag = ExpertiseTag(
|
tag = ExpertiseTag(
|
||||||
key=cat["key"],
|
key=cat["key"],
|
||||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
|
||||||
name_i18n=cat.get("name_i18n", {"hu": cat["name_hu"], "en": cat["name_en"]}),
|
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"],
|
category=cat["category"],
|
||||||
search_keywords=cat["search_keywords"],
|
search_keywords=cat["search_keywords"],
|
||||||
description=cat["description"],
|
|
||||||
is_official=True,
|
is_official=True,
|
||||||
discovery_points=10,
|
discovery_points=10,
|
||||||
usage_count=0,
|
usage_count=0,
|
||||||
|
|||||||
@@ -69,16 +69,12 @@ async def seed_gas_station_categories():
|
|||||||
# Beszúrás
|
# Beszúrás
|
||||||
tag = ExpertiseTag(
|
tag = ExpertiseTag(
|
||||||
key=cat["key"],
|
key=cat["key"],
|
||||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
|
||||||
name_i18n=cat.get("name_i18n", {"hu": cat["name_hu"], "en": cat["name_en"]}),
|
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"],
|
category=cat["category"],
|
||||||
level=cat["level"],
|
level=cat["level"],
|
||||||
parent_id=cat["parent_id"],
|
parent_id=cat["parent_id"],
|
||||||
path=cat["path"],
|
path=cat["path"],
|
||||||
search_keywords=cat["search_keywords"],
|
search_keywords=cat["search_keywords"],
|
||||||
description=cat["description"],
|
|
||||||
is_official=True,
|
is_official=True,
|
||||||
discovery_points=10,
|
discovery_points=10,
|
||||||
usage_count=0,
|
usage_count=0,
|
||||||
|
|||||||
@@ -607,7 +607,8 @@ async def seed_insurance_providers():
|
|||||||
logger.error("Futtasd előbb a seed_expertise_tags.py scriptet!")
|
logger.error("Futtasd előbb a seed_expertise_tags.py scriptet!")
|
||||||
return
|
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
|
inserted_count = 0
|
||||||
skipped_count = 0
|
skipped_count = 0
|
||||||
@@ -673,7 +674,7 @@ async def seed_insurance_providers():
|
|||||||
)
|
)
|
||||||
db.add(new_expertise)
|
db.add(new_expertise)
|
||||||
await db.flush()
|
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
|
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(
|
select(
|
||||||
ServiceProfile.organization_id,
|
ServiceProfile.organization_id,
|
||||||
ExpertiseTag.id,
|
ExpertiseTag.id,
|
||||||
ExpertiseTag.name_hu,
|
ExpertiseTag.name_i18n,
|
||||||
ExpertiseTag.name_en,
|
|
||||||
ExpertiseTag.level,
|
ExpertiseTag.level,
|
||||||
ExpertiseTag.key,
|
ExpertiseTag.key,
|
||||||
)
|
)
|
||||||
@@ -499,8 +498,7 @@ async def search_providers(
|
|||||||
org_categories_map[org_id] = []
|
org_categories_map[org_id] = []
|
||||||
org_categories_map[org_id].append({
|
org_categories_map[org_id].append({
|
||||||
"id": cat_row.id,
|
"id": cat_row.id,
|
||||||
"name_hu": cat_row.name_hu,
|
"name_i18n": cat_row.name_i18n,
|
||||||
"name_en": cat_row.name_en,
|
|
||||||
"level": cat_row.level,
|
"level": cat_row.level,
|
||||||
"key": cat_row.key,
|
"key": cat_row.key,
|
||||||
})
|
})
|
||||||
@@ -518,8 +516,7 @@ async def search_providers(
|
|||||||
categories_out = [
|
categories_out = [
|
||||||
CategoryInfo(
|
CategoryInfo(
|
||||||
id=cat["id"],
|
id=cat["id"],
|
||||||
name_hu=cat["name_hu"],
|
name_i18n=cat["name_i18n"],
|
||||||
name_en=cat["name_en"],
|
|
||||||
level=cat["level"],
|
level=cat["level"],
|
||||||
key=cat["key"],
|
key=cat["key"],
|
||||||
)
|
)
|
||||||
@@ -871,8 +868,7 @@ async def _create_new_tags(
|
|||||||
existing_stmt = select(ExpertiseTag).where(
|
existing_stmt = select(ExpertiseTag).where(
|
||||||
or_(
|
or_(
|
||||||
ExpertiseTag.key == _slugify(tag_name),
|
ExpertiseTag.key == _slugify(tag_name),
|
||||||
ExpertiseTag.name_hu == tag_name,
|
cast(ExpertiseTag.name_i18n, String).ilike(f"%{tag_name}%"),
|
||||||
ExpertiseTag.name_en == tag_name,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
existing_result = await db.execute(existing_stmt)
|
existing_result = await db.execute(existing_stmt)
|
||||||
@@ -890,15 +886,13 @@ async def _create_new_tags(
|
|||||||
new_key = _slugify(tag_name)
|
new_key = _slugify(tag_name)
|
||||||
new_tag = ExpertiseTag(
|
new_tag = ExpertiseTag(
|
||||||
key=new_key,
|
key=new_key,
|
||||||
name_hu=tag_name,
|
name_i18n={"hu": tag_name, "en": tag_name},
|
||||||
name_en=tag_name,
|
|
||||||
level=3,
|
level=3,
|
||||||
is_official=False,
|
is_official=False,
|
||||||
category="user_created",
|
category="user_created",
|
||||||
parent_id=None,
|
parent_id=None,
|
||||||
path=None,
|
path=None,
|
||||||
search_keywords=[tag_name.lower()],
|
search_keywords=[tag_name.lower()],
|
||||||
description=f"User-created tag: {tag_name}",
|
|
||||||
)
|
)
|
||||||
db.add(new_tag)
|
db.add(new_tag)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|||||||
@@ -5,14 +5,24 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from app.models.identity import User, Person, SocialAccount, UserRole
|
from app.models.identity import User, Person, SocialAccount, UserRole
|
||||||
from app.services.security_service import security_service
|
from app.services.security_service import security_service
|
||||||
|
from app.core.security import generate_secure_slug
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class SocialAuthService:
|
class SocialAuthService:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_or_create_social_user(db: AsyncSession, provider: str, social_id: str, email: str, first_name: str, last_name: str):
|
async def get_or_create_social_user(
|
||||||
|
db: AsyncSession,
|
||||||
|
provider: str,
|
||||||
|
social_id: str,
|
||||||
|
email: str,
|
||||||
|
first_name: str,
|
||||||
|
last_name: str,
|
||||||
|
referred_by_code: str = None
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
LOGIKA MEGŐRIZVE: Step 1 regisztráció slug és flotta nélkül.
|
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
|
# 1. Meglévő fiók ellenőrzése
|
||||||
stmt = select(SocialAccount).where(SocialAccount.provider == provider, SocialAccount.social_id == social_id)
|
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()
|
user = (await db.execute(stmt_u)).scalar_one_or_none()
|
||||||
|
|
||||||
if not user:
|
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)
|
db.add(new_person)
|
||||||
await db.flush()
|
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)
|
db.add(user)
|
||||||
await db.flush()
|
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(
|
entry = BodyTypeDictionary(
|
||||||
vehicle_class=bt["vehicle_class"],
|
vehicle_class=bt["vehicle_class"],
|
||||||
code=bt["code"],
|
code=bt["code"],
|
||||||
name_hu=bt["name_hu"],
|
name_i18n={"hu": bt["name_hu"]},
|
||||||
)
|
)
|
||||||
session.add(entry)
|
session.add(entry)
|
||||||
|
|
||||||
|
|||||||
@@ -1030,11 +1030,9 @@ async def _flatten_and_insert(db, parent_id: int | None, level: int, path_prefix
|
|||||||
|
|
||||||
tag = ExpertiseTag(
|
tag = ExpertiseTag(
|
||||||
key=key,
|
key=key,
|
||||||
name_hu=node["name_hu"],
|
name_i18n={"hu": node["name_hu"], "en": node["name_en"]},
|
||||||
name_en=node["name_en"],
|
|
||||||
category=node.get("category", ""),
|
category=node.get("category", ""),
|
||||||
search_keywords=node.get("search_keywords", []),
|
search_keywords=node.get("search_keywords", []),
|
||||||
description=node.get("description", ""),
|
|
||||||
is_official=True,
|
is_official=True,
|
||||||
level=level,
|
level=level,
|
||||||
parent_id=parent_id,
|
parent_id=parent_id,
|
||||||
@@ -1095,11 +1093,9 @@ async def seed_expertise_taxonomy():
|
|||||||
for key, node in UNIVERSAL_CATEGORIES.items():
|
for key, node in UNIVERSAL_CATEGORIES.items():
|
||||||
tag = ExpertiseTag(
|
tag = ExpertiseTag(
|
||||||
key=key,
|
key=key,
|
||||||
name_hu=node["name_hu"],
|
name_i18n={"hu": node["name_hu"], "en": node["name_en"]},
|
||||||
name_en=node["name_en"],
|
|
||||||
category=node.get("category", ""),
|
category=node.get("category", ""),
|
||||||
search_keywords=node.get("search_keywords", []),
|
search_keywords=node.get("search_keywords", []),
|
||||||
description=node.get("description", ""),
|
|
||||||
is_official=True,
|
is_official=True,
|
||||||
level=1,
|
level=1,
|
||||||
parent_id=None,
|
parent_id=None,
|
||||||
|
|||||||
@@ -2504,7 +2504,9 @@ var menu$1 = {
|
|||||||
user_list: "Felhasználók listája",
|
user_list: "Felhasználók listája",
|
||||||
persons: "Személyek",
|
persons: "Személyek",
|
||||||
finance: "Pénzügyek",
|
finance: "Pénzügyek",
|
||||||
|
finance_management: "Pénzügyek Kezelése",
|
||||||
packages: "Csomagok",
|
packages: "Csomagok",
|
||||||
|
commission_rules: "Jutalék Szabályok",
|
||||||
gamification: "Gamification",
|
gamification: "Gamification",
|
||||||
game_settings: "Játékmenet Beállítások",
|
game_settings: "Játékmenet Beállítások",
|
||||||
point_rules: "Pontszabályok",
|
point_rules: "Pontszabályok",
|
||||||
@@ -3766,6 +3768,87 @@ const locale_system_46json_21da1de8 = {
|
|||||||
system: system$1
|
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 loading = "Loading...";
|
||||||
var saving = "Saving...";
|
var saving = "Saving...";
|
||||||
var error = "An error occurred";
|
var error = "An error occurred";
|
||||||
@@ -3973,7 +4056,9 @@ var menu = {
|
|||||||
user_list: "User List",
|
user_list: "User List",
|
||||||
persons: "Persons",
|
persons: "Persons",
|
||||||
finance: "Finance",
|
finance: "Finance",
|
||||||
|
finance_management: "Finance Management",
|
||||||
packages: "Packages",
|
packages: "Packages",
|
||||||
|
commission_rules: "Commission Rules",
|
||||||
gamification: "Gamification",
|
gamification: "Gamification",
|
||||||
game_settings: "Game Settings",
|
game_settings: "Game Settings",
|
||||||
point_rules: "Point Rules",
|
point_rules: "Point Rules",
|
||||||
@@ -5235,6 +5320,87 @@ const locale_system_46json_cff5800c = {
|
|||||||
system: system
|
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 = {
|
var common$4 = {
|
||||||
loading: "Wird geladen...",
|
loading: "Wird geladen...",
|
||||||
saving: "Wird gespeichert...",
|
saving: "Wird gespeichert...",
|
||||||
@@ -5746,6 +5912,11 @@ const localeLoaders = {
|
|||||||
key: "locale_system_46json_21da1de8",
|
key: "locale_system_46json_21da1de8",
|
||||||
load: () => Promise.resolve(locale_system_46json_21da1de8),
|
load: () => Promise.resolve(locale_system_46json_21da1de8),
|
||||||
cache: true
|
cache: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "locale_commission_46json_2229a18b",
|
||||||
|
load: () => Promise.resolve(locale_commission_46json_2229a18b),
|
||||||
|
cache: true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
en: [
|
en: [
|
||||||
@@ -5788,6 +5959,11 @@ const localeLoaders = {
|
|||||||
key: "locale_system_46json_cff5800c",
|
key: "locale_system_46json_cff5800c",
|
||||||
load: () => Promise.resolve(locale_system_46json_cff5800c),
|
load: () => Promise.resolve(locale_system_46json_cff5800c),
|
||||||
cache: true
|
cache: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "locale_commission_46json_ea60861a",
|
||||||
|
load: () => Promise.resolve(locale_commission_46json_ea60861a),
|
||||||
|
cache: true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
de: [
|
de: [
|
||||||
@@ -6481,16 +6657,16 @@ _wH6JrtIxmaSoA8lCPWFnE9z4lQeXW6H5z3l5aymEQw
|
|||||||
const assets = {
|
const assets = {
|
||||||
"/index.mjs": {
|
"/index.mjs": {
|
||||||
"type": "text/javascript; charset=utf-8",
|
"type": "text/javascript; charset=utf-8",
|
||||||
"etag": "\"3c8cf-iVwUAHqMbwVWb0CE25IIZzxdTQs\"",
|
"etag": "\"3e482-bMW8N/gd5+rJ6Fet5d7ZvFyoG5I\"",
|
||||||
"mtime": "2026-07-08T11:00:08.610Z",
|
"mtime": "2026-07-24T01:36:18.316Z",
|
||||||
"size": 248015,
|
"size": 255106,
|
||||||
"path": "index.mjs"
|
"path": "index.mjs"
|
||||||
},
|
},
|
||||||
"/index.mjs.map": {
|
"/index.mjs.map": {
|
||||||
"type": "application/json",
|
"type": "application/json",
|
||||||
"etag": "\"84c42-k0hYb3YBaxguGfUt43nQybikfko\"",
|
"etag": "\"84cfb-v0HL6pG50A9Shz5WFud0S34p4No\"",
|
||||||
"mtime": "2026-07-08T11:00:08.610Z",
|
"mtime": "2026-07-24T01:36:18.317Z",
|
||||||
"size": 543810,
|
"size": 543995,
|
||||||
"path": "index.mjs.map"
|
"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",
|
"preset": "nitro-dev",
|
||||||
"framework": {
|
"framework": {
|
||||||
"name": "nuxt",
|
"name": "nuxt",
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"dev": {
|
"dev": {
|
||||||
"pid": 19,
|
"pid": 19,
|
||||||
"workerAddress": {
|
"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/i18n" />
|
||||||
|
/// <reference types="@nuxtjs/tailwindcss" />
|
||||||
/// <reference types="@pinia/nuxt" />
|
/// <reference types="@pinia/nuxt" />
|
||||||
/// <reference types="@nuxt/telemetry" />
|
/// <reference types="@nuxt/telemetry" />
|
||||||
/// <reference types="@nuxt/devtools" />
|
/// <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 "@nuxtjs/tailwindcss/config-ctx"
|
||||||
import configMerger from "@nuxtjs/tailwindcss/merger";
|
import configMerger from "@nuxtjs/tailwindcss/merger";
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"paths": {
|
"paths": {
|
||||||
"nuxt": [
|
|
||||||
"../node_modules/nuxt"
|
|
||||||
],
|
|
||||||
"@unhead/vue": [
|
"@unhead/vue": [
|
||||||
"../node_modules/@unhead/vue"
|
"../node_modules/@unhead/vue"
|
||||||
],
|
],
|
||||||
@@ -19,6 +16,9 @@
|
|||||||
"@nuxt/schema": [
|
"@nuxt/schema": [
|
||||||
"../node_modules/@nuxt/schema"
|
"../node_modules/@nuxt/schema"
|
||||||
],
|
],
|
||||||
|
"nuxt": [
|
||||||
|
"../node_modules/nuxt"
|
||||||
|
],
|
||||||
"vue-i18n": [
|
"vue-i18n": [
|
||||||
"../node_modules/vue-i18n/dist/vue-i18n",
|
"../node_modules/vue-i18n/dist/vue-i18n",
|
||||||
"../node_modules/vue-i18n"
|
"../node_modules/vue-i18n"
|
||||||
@@ -47,6 +47,9 @@
|
|||||||
"../node_modules/@intlify/message-compiler/dist/message-compiler",
|
"../node_modules/@intlify/message-compiler/dist/message-compiler",
|
||||||
"../node_modules/@intlify/message-compiler"
|
"../node_modules/@intlify/message-compiler"
|
||||||
],
|
],
|
||||||
|
"nitropack": [
|
||||||
|
"../node_modules/nitropack"
|
||||||
|
],
|
||||||
"defu": [
|
"defu": [
|
||||||
"../node_modules/defu"
|
"../node_modules/defu"
|
||||||
],
|
],
|
||||||
@@ -65,9 +68,6 @@
|
|||||||
"unplugin-vue-router/client": [
|
"unplugin-vue-router/client": [
|
||||||
"../node_modules/unplugin-vue-router/client"
|
"../node_modules/unplugin-vue-router/client"
|
||||||
],
|
],
|
||||||
"nitropack": [
|
|
||||||
"../node_modules/nitropack"
|
|
||||||
],
|
|
||||||
"vite/client": [
|
"vite/client": [
|
||||||
"../node_modules/vite/client"
|
"../node_modules/vite/client"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -29,9 +29,6 @@
|
|||||||
"@@/*": [
|
"@@/*": [
|
||||||
"../*"
|
"../*"
|
||||||
],
|
],
|
||||||
"nuxt": [
|
|
||||||
"../node_modules/nuxt"
|
|
||||||
],
|
|
||||||
"@unhead/vue": [
|
"@unhead/vue": [
|
||||||
"../node_modules/@unhead/vue"
|
"../node_modules/@unhead/vue"
|
||||||
],
|
],
|
||||||
@@ -47,6 +44,9 @@
|
|||||||
"@nuxt/schema": [
|
"@nuxt/schema": [
|
||||||
"../node_modules/@nuxt/schema"
|
"../node_modules/@nuxt/schema"
|
||||||
],
|
],
|
||||||
|
"nuxt": [
|
||||||
|
"../node_modules/nuxt"
|
||||||
|
],
|
||||||
"vue-i18n": [
|
"vue-i18n": [
|
||||||
"../node_modules/vue-i18n"
|
"../node_modules/vue-i18n"
|
||||||
],
|
],
|
||||||
@@ -68,6 +68,9 @@
|
|||||||
"@intlify/message-compiler": [
|
"@intlify/message-compiler": [
|
||||||
"../node_modules/@intlify/message-compiler"
|
"../node_modules/@intlify/message-compiler"
|
||||||
],
|
],
|
||||||
|
"nitropack": [
|
||||||
|
"../node_modules/nitropack"
|
||||||
|
],
|
||||||
"defu": [
|
"defu": [
|
||||||
"../node_modules/defu"
|
"../node_modules/defu"
|
||||||
],
|
],
|
||||||
@@ -86,9 +89,6 @@
|
|||||||
"unplugin-vue-router/client": [
|
"unplugin-vue-router/client": [
|
||||||
"../node_modules/unplugin-vue-router/client"
|
"../node_modules/unplugin-vue-router/client"
|
||||||
],
|
],
|
||||||
"nitropack": [
|
|
||||||
"../node_modules/nitropack"
|
|
||||||
],
|
|
||||||
"vite/client": [
|
"vite/client": [
|
||||||
"../node_modules/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",
|
"user_list": "User List",
|
||||||
"persons": "Persons",
|
"persons": "Persons",
|
||||||
"finance": "Finance",
|
"finance": "Finance",
|
||||||
|
"finance_management": "Finance Management",
|
||||||
"packages": "Packages",
|
"packages": "Packages",
|
||||||
|
"commission_rules": "Commission Rules",
|
||||||
"gamification": "Gamification",
|
"gamification": "Gamification",
|
||||||
"game_settings": "Game Settings",
|
"game_settings": "Game Settings",
|
||||||
"point_rules": "Point Rules",
|
"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",
|
"user_list": "Felhasználók listája",
|
||||||
"persons": "Személyek",
|
"persons": "Személyek",
|
||||||
"finance": "Pénzügyek",
|
"finance": "Pénzügyek",
|
||||||
|
"finance_management": "Pénzügyek Kezelése",
|
||||||
"packages": "Csomagok",
|
"packages": "Csomagok",
|
||||||
|
"commission_rules": "Jutalék Szabályok",
|
||||||
"gamification": "Gamification",
|
"gamification": "Gamification",
|
||||||
"game_settings": "Játékmenet Beállítások",
|
"game_settings": "Játékmenet Beállítások",
|
||||||
"point_rules": "Pontszabályok",
|
"point_rules": "Pontszabályok",
|
||||||
|
|||||||
@@ -339,9 +339,20 @@ const menuGroups: MenuGroup[] = [
|
|||||||
title: 'menu.finance',
|
title: 'menu.finance',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
path: '/packages',
|
label: 'menu.finance_management',
|
||||||
label: 'menu.packages',
|
|
||||||
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m8.25 3v6.75m0 0l-3-3m3 3l3-3M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" /></svg>',
|
icon: '<svg class="w-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('/users')) return 'menu.users'
|
||||||
if (route.path.startsWith('/logs')) return 'menu.system_logs'
|
if (route.path.startsWith('/logs')) return 'menu.system_logs'
|
||||||
if (route.path.startsWith('/packages')) return 'menu.packages'
|
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.startsWith('/providers')) {
|
||||||
if (route.path === '/providers/pending') return 'providers.pending_title'
|
if (route.path === '/providers/pending') return 'providers.pending_title'
|
||||||
if (route.path.startsWith('/providers/')) return 'providers.provider_detail'
|
if (route.path.startsWith('/providers/')) return 'providers.provider_detail'
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export default defineNuxtConfig({
|
|||||||
files: [
|
files: [
|
||||||
'hu/common.json', 'hu/menu.json', 'hu/dashboard.json', 'hu/providers.json',
|
'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/garages.json', 'hu/users.json', 'hu/gamification.json', 'hu/system.json',
|
||||||
|
'hu/commission.json',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -30,6 +31,7 @@ export default defineNuxtConfig({
|
|||||||
files: [
|
files: [
|
||||||
'en/common.json', 'en/menu.json', 'en/dashboard.json', 'en/providers.json',
|
'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/garages.json', 'en/users.json', 'en/gamification.json', 'en/system.json',
|
||||||
|
'en/commission.json',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ code: 'de', iso: 'de-DE', file: 'de.json', name: 'Deutsch' },
|
{ 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>
|
></div>
|
||||||
|
|
||||||
<!-- 3D Perspective Container -->
|
<!-- 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 -->
|
<!-- Flippable Card Body -->
|
||||||
<div
|
<div
|
||||||
class="relative transition-transform duration-700 transform-style-3d"
|
class="relative transition-transform duration-700 transform-style-3d"
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<!-- ==================== FRONT FACE: LOGIN ==================== -->
|
<!-- ==================== FRONT FACE: LOGIN ==================== -->
|
||||||
<form
|
<form
|
||||||
@submit.prevent="handleLogin"
|
@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' }"
|
:class="{ 'pointer-events-none': authMode !== 'login' }"
|
||||||
>
|
>
|
||||||
<!-- Close Button (X) -->
|
<!-- Close Button (X) -->
|
||||||
@@ -200,9 +200,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Social Login Buttons -->
|
<!-- Social Login Buttons -->
|
||||||
<div class="mb-6 grid grid-cols-2 gap-3">
|
<div class="mb-6 grid grid-cols-1 gap-3">
|
||||||
<button
|
<button
|
||||||
type="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"
|
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 -->
|
<!-- Google Logo Placeholder -->
|
||||||
@@ -216,6 +217,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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 -->
|
<!-- Apple Logo Placeholder -->
|
||||||
@@ -243,7 +245,7 @@
|
|||||||
<form
|
<form
|
||||||
v-if="activeBackFace === 'register'"
|
v-if="activeBackFace === 'register'"
|
||||||
@submit.prevent="handleRegister"
|
@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) -->
|
<!-- Close Button (X) -->
|
||||||
<button
|
<button
|
||||||
@@ -379,6 +381,21 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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) -->
|
<!-- Error Message (translated via i18n) -->
|
||||||
<div
|
<div
|
||||||
v-if="authStore.error && authMode === 'register'"
|
v-if="authStore.error && authMode === 'register'"
|
||||||
@@ -396,6 +413,47 @@
|
|||||||
{{ authStore.isLoading ? t('auth.loginLoading') : t('auth.registerButton') }}
|
{{ authStore.isLoading ? t('auth.loginLoading') : t('auth.registerButton') }}
|
||||||
</button>
|
</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 -->
|
<!-- Switch back to Login -->
|
||||||
<p class="mt-6 text-center text-sm text-white/50">
|
<p class="mt-6 text-center text-sm text-white/50">
|
||||||
{{ t('auth.haveAccount') }}
|
{{ t('auth.haveAccount') }}
|
||||||
@@ -413,7 +471,7 @@
|
|||||||
<form
|
<form
|
||||||
v-if="activeBackFace === 'forgot'"
|
v-if="activeBackFace === 'forgot'"
|
||||||
@submit.prevent="handleForgotPassword"
|
@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) -->
|
<!-- Close Button (X) -->
|
||||||
<button
|
<button
|
||||||
@@ -528,7 +586,7 @@
|
|||||||
<form
|
<form
|
||||||
v-if="activeBackFace === 'resend'"
|
v-if="activeBackFace === 'resend'"
|
||||||
@submit.prevent="handleResend"
|
@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) -->
|
<!-- Close Button (X) -->
|
||||||
<button
|
<button
|
||||||
@@ -629,7 +687,7 @@
|
|||||||
<form
|
<form
|
||||||
v-if="activeBackFace === 'restore'"
|
v-if="activeBackFace === 'restore'"
|
||||||
@submit.prevent="handleRestoreStep"
|
@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) -->
|
<!-- Close Button (X) -->
|
||||||
<button
|
<button
|
||||||
@@ -883,6 +941,7 @@ const lastName = ref('')
|
|||||||
const firstName = ref('')
|
const firstName = ref('')
|
||||||
const regEmail = ref('')
|
const regEmail = ref('')
|
||||||
const regPassword = ref('')
|
const regPassword = ref('')
|
||||||
|
const referredByCode = ref('')
|
||||||
|
|
||||||
// ── Forgot password form fields ──
|
// ── Forgot password form fields ──
|
||||||
const forgotEmail = ref('')
|
const forgotEmail = ref('')
|
||||||
@@ -1071,12 +1130,17 @@ async function handleLogin() {
|
|||||||
// ── Register Logic ──
|
// ── Register Logic ──
|
||||||
async function handleRegister() {
|
async function handleRegister() {
|
||||||
try {
|
try {
|
||||||
await authStore.register({
|
const payload: Record<string, any> = {
|
||||||
email: regEmail.value,
|
email: regEmail.value,
|
||||||
password: regPassword.value,
|
password: regPassword.value,
|
||||||
first_name: firstName.value,
|
first_name: firstName.value,
|
||||||
last_name: lastName.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
|
// Save email to localStorage for next visit
|
||||||
localStorage.setItem('saved-email', regEmail.value)
|
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 ──
|
// ── Forgot Password Logic ──
|
||||||
async function handleForgotPassword() {
|
async function handleForgotPassword() {
|
||||||
forgotError.value = ''
|
forgotError.value = ''
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
password: string
|
password: string
|
||||||
first_name: string
|
first_name: string
|
||||||
last_name: string
|
last_name: string
|
||||||
|
referred_by_code?: string
|
||||||
}) {
|
}) {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
error.value = null
|
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.
|
* Clear any stored error message.
|
||||||
*/
|
*/
|
||||||
@@ -760,5 +807,6 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
clearError,
|
clearError,
|
||||||
fetchMyOrganizations,
|
fetchMyOrganizations,
|
||||||
switchOrganization,
|
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