16 KiB
🏗️ 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)
- Közös
commission_max_amount: Gen1 és Gen2 ugyanazt a plafont használja - Nincs Wallet írás:
distribute_commission()csak visszaad egy CommissionDistributionResponse DTO-t - Nincs inaktivitás ellenőrzés: Nem nézi, hogy Gen1 aktív-e mielőtt Gen2-nek fizet
- Nincs reaktivációs logika: 180 napos inaktivitás detektálás hiányzik
- Nincs
gen2_renewal_percent: Csak Gen1-nek vanrenewal_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):
# --- Ú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:
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
# Ú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:
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:
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:
# 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:
# 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
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:
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:
# 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
# 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:
class CommissionDistributionRequest(BaseModel):
# ... existing fields ...
is_renewal: bool = Field(False, description="TRUE = renewal transaction, FALSE = first-time purchase")
Folyamatábra (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)
- Modell módosítás -> CommissionRule új mezők
- Schema módosítás -> Pydantic DTO-k bővítése
- Service módosítás -> _credit_commission_to_wallet(), _is_user_recently_active(), módosított logika
- Sync Engine futtatás -> docker exec -it sf_api python -m app.scripts.sync_engine
- Teszt seed adat frissítés
- Teszt futtatás -> test_commission_distribution.py
- 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
- Adatbázis Migráció: Az új mezők nullable=True-val történnek, a meglévő rule-ok változatlanul működnek.
- Visszamenőleges kompatibilitás: A régi API hívások továbbra is működnek.
- Wallet írás hibakezelés: Ha a Wallet nem található, logger.warning + a commission response-ba hiba jelzés.
- 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.