Files
service-finder/plans/logic_spec_mlm_phase2_commission_wallet_integration.md
2026-07-25 10:22:03 +00:00

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)

  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):

# --- Ú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)

  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.