L1L2 gen1Gen2 kialakítása
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user