# 🏗️ Architecture Plan: Phase 2 - Advanced MLM Logic & Financial System Integration **Audit Date:** 2026-07-24 **Status:** DRAFT - Awaiting Approval **Auditor:** Architect Mode --- ## 📋 Executive Summary A jelenlegi 2-szintű MLM jutalékrendszer (`distribute_commission()`) csak **kiszámolja** a jutalékokat, de **nem írja jóvá** azokat a pénztárcákban (Wallet) és nem naplózza a főkönyvben (FinancialLedger). Emellett hiányzik a **szintenkénti külön plafon**, a **Gen2 inaktivitás ellenőrzés**, a **180 napos reaktivációs szabály**, és a **megújítási jutalék gen2 változata**. Ez a terv ezeket a hiányosságokat pótolja. --- ## 🔍 TASK 1: Audit Findings Summary ### ✅ Meglévő Rendszer (Current State) | Komponens | Fájl | Jelenlegi Állapot | |-----------|------|-------------------| | **CommissionRule modell** | `backend/app/models/marketplace/commission.py` | Van `commission_max_amount`, DE ez **közös plafon** Gen1 és Gen2 számára | | **Commission schemas** | `backend/app/schemas/commission.py` | Jó Pydantic modellek, de hiányzik a `gen1_max_amount`, `gen2_max_amount`, `gen2_renewal_percent` | | **distribute_commission()** | `backend/app/services/commission_service.py:377` | Kiszámolja a jutalékot, de **NEM írja jóvá** a Wallet-ben | | **Wallet** | `backend/app/models/identity/identity.py:255` | `earned_credits`, `purchased_credits`, `service_coins` - jó. `user.wallet` kapcsolat létezik | | **FinancialLedger** | `backend/app/models/system/audit.py:78` | Double-entry, `entry_type` (DEBIT/CREDIT), `wallet_type` (EARNED/PURCHASED/SERVICE_COINS/VOUCHER) | | **GamificationService._add_earned_credits()** | `backend/app/services/gamification_service.py:265` | Bizonyított minta: Wallet frissítés + FinancialLedger bejegyzés | | **AtomicTransactionManager** | `backend/app/services/billing_engine.py:364` | Bizonyított minta: Atomi tranzakció double-entry könyveléssel | | **User.is_active** | `backend/app/models/identity/identity.py:169` | Létezik, használható inaktivitás ellenőrzésre | | **Test rule (DB)** | `marketplace.commission_rules` ID=4 | STANDARD, HU, 5% Gen1, 2% Gen2, max 50000 | ### ❌ Hiányosságok (Gaps) 1. **Közös `commission_max_amount`**: Gen1 és Gen2 ugyanazt a plafont használja 2. **Nincs Wallet írás**: `distribute_commission()` csak visszaad egy CommissionDistributionResponse DTO-t 3. **Nincs inaktivitás ellenőrzés**: Nem nézi, hogy Gen1 aktív-e mielőtt Gen2-nek fizet 4. **Nincs reaktivációs logika**: 180 napos inaktivitás detektálás hiányzik 5. **Nincs `gen2_renewal_percent`**: Csak Gen1-nek van `renewal_commission_percent` --- ## 🏗️ TASK 2: Proposed Architecture Plan ### 2.1 Adatmodell Változtatások (CommissionRule) #### Cél A `commission_max_amount` mező szétválasztása szintenként külön plafonra. #### SQLAlchemy Modell Változtatás **Fájl:** `backend/app/models/marketplace/commission.py` **Új mezők hozzáadása (L2_COMMISSION szekcióba, a meglévők után):** ```python # --- Új: Szintenkénti plafonok --- gen1_max_amount: Mapped[Optional[float]] = mapped_column( Numeric(18, 2), nullable=True, comment="L2: Gen1 maximális jutalék összeg (NULL/0 = korlátlan)" ) gen2_max_amount: Mapped[Optional[float]] = mapped_column( Numeric(18, 2), nullable=True, comment="L2: Gen2 (upline) maximális jutalék összeg (NULL/0 = korlátlan)" ) gen2_renewal_percent: Mapped[Optional[float]] = mapped_column( Numeric(5, 2), nullable=True, default=0.00, comment="L2: Gen2 megújítási jutalék százalék - Gen1-hez hasonlóan, de Gen2 számára" ) ``` **Megjegyzés:** A régi `commission_max_amount` mező **megmarad** backward compatibility miatt, de a logika már az új `gen1_max_amount`-ot és `gen2_max_amount`-ot használja. Ha az új mezők NULL-ok, a `commission_max_amount` értéke lesz a fallback. #### Sync Engine (Adatbázis Migráció) A modell módosítása után futtatni kell: ```bash docker exec -it sf_api python -m app.scripts.sync_engine ``` ### 2.2 Schema Változtatások (Pydantic) **Fájl:** `backend/app/schemas/commission.py` ```python # Új mezők a CommissionRuleCreate-ben: gen1_max_amount: Optional[float] = None # NULL = commission_max_amount fallback gen2_max_amount: Optional[float] = None # NULL = commission_max_amount fallback gen2_renewal_percent: Optional[float] = None # Ugyanez a CommissionRuleUpdate-ben és CommissionRuleResponse-ben is ``` ### 2.3 CRUD Frissítés (commission_service.py) **Fájl:** `backend/app/services/commission_service.py` A `create_commission_rule()` függvényben hozzáadni az új mezők átvételét: ```python gen1_max_amount=data.gen1_max_amount, gen2_max_amount=data.gen2_max_amount, gen2_renewal_percent=data.gen2_renewal_percent, ``` ### 2.4 _calculate_commission() Módosítás - Szintenkénti Plafon A jelenlegi `_calculate_commission()` egyetlen `max_amount` paramétert fogad. Ezt kibővítjük a NULL/0 = korlátlan logikával: ```python async def _calculate_commission( amount: float, percent: Optional[float], max_amount: Optional[float], ) -> float: """ Calculate commission amount from a percentage, capped by max_amount. NULL or 0 means unlimited (no cap). """ if not percent or percent <= 0: return 0.0 commission = float(Decimal(str(amount)) * Decimal(str(percent)) / Decimal("100")) # Phase 2: NULL or 0 means unlimited if max_amount is not None and max_amount > 0: commission = min(commission, float(max_amount)) return round(commission, 2) ``` **Változás a `distribute_commission()` hívásánál:** ```python # Gen1: use gen1_max_amount (fallback: commission_max_amount) gen1_cap = float(rule.gen1_max_amount) if rule.gen1_max_amount is not None else ( float(rule.commission_max_amount) if rule.commission_max_amount else None ) gen1_amount = await _calculate_commission( request.transaction_amount, float(rule.commission_percent) if rule.commission_percent else None, gen1_cap, ) # Gen2: use gen2_max_amount (fallback: commission_max_amount) gen2_cap = float(rule.gen2_max_amount) if rule.gen2_max_amount is not None else ( float(rule.commission_max_amount) if rule.commission_max_amount else None ) gen2_amount = await _calculate_commission( request.transaction_amount, float(rule.upline_commission_percent) if rule.upline_commission_percent else None, gen2_cap, ) ``` ### 2.5 Inaktív Gen2 Megelőzés (Business Rule) #### Cél Ha Gen1 (a közvetlen ajánló) inaktív, akkor Gen2 (az upline) **nem kap jutalékot**. #### Logika menete (`distribute_commission()`-ben) A Gen2 számítás ELŐTT: ```python # Phase 2: Inactive Gen2 Prevention # If Gen1 is inactive (is_active=False or subscription expired), # Gen2 does NOT receive commission gen1_is_active = gen1.is_active gen1_subscription_active = ( gen1.subscription_expires_at is None or gen1.subscription_expires_at > datetime.utcnow() ) if not gen1_is_active or not gen1_subscription_active: logger.info( "Commission distribution: Gen1 user %d is inactive. Gen2 SKIPPED.", gen1.id, ) gen2_amount = 0.0 # Skip Gen2 entirely ``` ### 2.6 Reaktivációs Szabály (180 nap) #### Cél Ha Gen1 180 napig inaktív volt (nincs tranzakciója), akkor a következő vásárlását **új ügyfélként** kell kezelni. #### Logika menete ```python async def _is_user_recently_active( db: AsyncSession, user_id: int, inactivity_days: int = 180, ) -> bool: """ Check if a user has been active within the last inactivity_days days. Checks: User.is_active flag, latest FinancialLedger entry, subscription status. """ user_stmt = select(User).where(User.id == user_id) result = await db.execute(user_stmt) user = result.scalar_one_or_none() if not user or user.is_deleted: return False # Check if user has any transaction in the last inactivity_days cutoff = datetime.utcnow() - timedelta(days=inactivity_days) ledger_stmt = ( select(FinancialLedger) .where( FinancialLedger.user_id == user_id, FinancialLedger.created_at >= cutoff, ) .limit(1) ) ledger_result = await db.execute(ledger_stmt) has_recent_transaction = ledger_result.scalar_one_or_none() is not None if has_recent_transaction: return True # Check subscription if user.subscription_expires_at and user.subscription_expires_at > datetime.utcnow(): return True return False ``` ### 2.7 Wallet Integráció (A Legfontosabb Változás) #### Cél A `distribute_commission()` ne csak számoljon, hanem **ténylegesen írja jóvá** a jutalékot a megfelelő felhasználók Wallet-jében, és **naplózza** a FinancialLedger-ben. #### Implementáció A `GamificationService._add_earned_credits()` (bizonyított minta) alapján: ```python async def _credit_commission_to_wallet( db: AsyncSession, user_id: int, amount: float, transaction_type: str, details: dict, ) -> None: """ Credit commission to user's Wallet (earned_credits) and log in FinancialLedger. Follows the proven pattern from GamificationService._add_earned_credits(). """ wallet_stmt = select(Wallet).where(Wallet.user_id == user_id) result = await db.execute(wallet_stmt) wallet = result.scalar_one_or_none() if not wallet: logger.warning(f"Wallet not found for user_id={user_id}, cannot credit commission") return # 1. Update wallet balance wallet.earned_credits += Decimal(str(amount)) # 2. Create FinancialLedger CREDIT entry ledger_entry = FinancialLedger( user_id=user_id, amount=Decimal(str(amount)), entry_type=LedgerEntryType.CREDIT, wallet_type=WalletType.EARNED, transaction_type=transaction_type, details=details, balance_after=float(wallet.earned_credits), currency="EUR", ) db.add(ledger_entry) ``` **A `distribute_commission()` módosítása:** ```python # Phase 2: Wallet Credit if gen1_amount > 0: await _credit_commission_to_wallet( db, user_id=gen1.id, amount=gen1_amount, transaction_type="COMMISSION_GEN1", details={ "description": "Gen1 commission on transaction", "buyer_user_id": request.buyer_user_id, "transaction_amount": request.transaction_amount, "rule_id": rule.id, "commission_percent": float(rule.commission_percent), }, ) if gen2_amount > 0: await _credit_commission_to_wallet( db, user_id=gen2.id, amount=gen2_amount, transaction_type="COMMISSION_GEN2", details={ "description": "Gen2 (upline) commission on transaction", "buyer_user_id": request.buyer_user_id, "gen1_user_id": gen1.id, "transaction_amount": request.transaction_amount, "rule_id": rule.id, "commission_percent": float(rule.upline_commission_percent), }, ) # Phase 2: Commit await db.commit() ``` ### 2.8 Megújítási Jutalék (Renewal) Logika #### Cél A megújítási (renewal) tranzakcióknál: - Gen1 kapja a `renewal_commission_percent`-et (ha CONTRACTED tier) - Gen2 kapja a `gen2_renewal_percent`-et (az új mezőből) #### Logika ```python # A distribute_commission()-ben, ha a tranzakció típusa "renewal": is_renewal = getattr(request, 'is_renewal', False) if is_renewal: gen1_percent = float(rule.renewal_commission_percent) if rule.renewal_commission_percent else None gen2_percent = float(rule.gen2_renewal_percent) if rule.gen2_renewal_percent is not None else ( float(rule.upline_commission_percent) if rule.upline_commission_percent else None ) else: gen1_percent = float(rule.commission_percent) if rule.commission_percent else None gen2_percent = float(rule.upline_commission_percent) if rule.upline_commission_percent else None ``` **Schema módosítás:** A `CommissionDistributionRequest`-hez hozzáadni: ```python class CommissionDistributionRequest(BaseModel): # ... existing fields ... is_renewal: bool = Field(False, description="TRUE = renewal transaction, FALSE = first-time purchase") ``` --- ## Folyamatábra (Mermaid) ```mermaid flowchart TD A[distribute_commission hívás] --> B[Buyer lookup] B --> C{Van referrer?} C -->|Nincs| D[Return empty - nincs jutalék] C -->|Van Gen1| E[Gen1 lookup] E --> F{Gen1 aktív?} F -->|Nem| D F -->|Igen| G[Resolve L2_COMMISSION rule] G --> H{Rule található?} H -->|Nem| D H -->|Igen| I{A tranzakcio renewal?} I -->|Igen| J[Hasznalj renewal_commission_percent + gen2_renewal_percent] I -->|Nem| K[Hasznalj commission_percent + upline_commission_percent] J --> L[Calculate Gen1 commission with gen1_max_amount cap] K --> L L --> M{Gen1 recently active? <180 nap?} M -->|Nem| N[Gen1 inaktiv - normal jutalek, reaktivacio flag] M -->|Igen| O[Gen1 aktiv - normal folyamat] N --> P[Calculate Gen2 commission with gen2_max_amount cap] O --> P P --> Q{Gen1 aktiv Subscription?} Q -->|Nem| R[Gen2 jutalek SKIP - inaktiv Gen2 prevention] Q -->|Igen| S[Gen2 jutalek jar] R --> T[Credit Gen1 wallet + FinancialLedger] S --> U[Credit Gen1 wallet + FinancialLedger + Credit Gen2 wallet + FinancialLedger] T --> V[Commit + Return response] U --> V ``` --- ## Érintett Fájlok (Files to Modify) | # | Fájl | Módosítás Típusa | |---|------|-------------------| | 1 | `backend/app/models/marketplace/commission.py` | Modell bővítés: gen1_max_amount, gen2_max_amount, gen2_renewal_percent | | 2 | `backend/app/schemas/commission.py` | Schema bővítés: új mezők Create/Update/Response DTO-khoz, is_renewal | | 3 | `backend/app/services/commission_service.py` | Szolgáltatás bővítés: _credit_commission_to_wallet(), _is_user_recently_active(), módosított logika | | 4 | `backend/backend/tests/active/seed_commission_test_data.py` | Teszt seed adat frissítés | | 5 | `backend/backend/tests/active/test_commission_distribution.py` | Teszt bővítés Wallet/FinancialLedger ellenőrzéssel | --- ## Futtatási Sorrend (Execution Order) 1. Modell módosítás -> CommissionRule új mezők 2. Schema módosítás -> Pydantic DTO-k bővítése 3. Service módosítás -> _credit_commission_to_wallet(), _is_user_recently_active(), módosított logika 4. Sync Engine futtatás -> docker exec -it sf_api python -m app.scripts.sync_engine 5. Teszt seed adat frissítés 6. Teszt futtatás -> test_commission_distribution.py 7. Manuális verifikáció -> API hívás + DB ellenőrzés --- ## Tesztelési Terv ### 1. Alap funkcionális tesztek - Ellenőrizni, hogy a Wallet earned_credits megváltozott a tranzakció után - Ellenőrizni, hogy a FinancialLedger-ben megjelentek a CREDIT bejegyzések ### 2. Plafon tesztek (Cap) - gen1_max_amount=3000 esetén Gen1 5000 helyett 3000-et kap - gen2_max_amount=0 esetén Gen2 korlátlan (nincs plafon) - gen1_max_amount=NULL esetén a régi commission_max_amount a fallback ### 3. Inaktivitás tesztek - Ha Gen1 is_active=False, Gen2 nem kap jutalékot - Ha Gen1 subscription lejárt, Gen2 nem kap jutalékot ### 4. Megújítási teszt - is_renewal=True esetén Gen1 a renewal_commission_percent-et kapja - is_renewal=True esetén Gen2 a gen2_renewal_percent-et kapja --- ## Kockázatok és Megfontolások 1. **Adatbázis Migráció**: Az új mezők nullable=True-val történnek, a meglévő rule-ok változatlanul működnek. 2. **Visszamenőleges kompatibilitás**: A régi API hívások továbbra is működnek. 3. **Wallet írás hibakezelés**: Ha a Wallet nem található, logger.warning + a commission response-ba hiba jelzés. 4. **Tranzakció biztonság**: A distribute_commission() jelenleg NEM használ atomi tranzakciót - ez fontos lehet a Wallet írás biztonságához. --- ## Jóváhagyás **Kérem a felhasználó jóváhagyását a fenti tervhez.** A jóváhagyás után a Code módba kapcsolok és megkezdem az implementációt.