# đŸ—ïž 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"" ) ``` ### 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