Files
service-finder/plans/logic_spec_commission_rules_architecture.md
2026-07-24 09:56:21 +00:00

17 KiB

🏗️ 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 in the marketplace schema. This keeps commission rules alongside the service provider and organization models they govern.

2.2 The CommissionRule Model

# 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

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

Add to the marketplace package exports:

from .commission import CommissionRule, CommissionRuleType, CommissionTier

4.3 Registration in backend/app/models/__init__.py

Add to the master model registry:

# In the marketplace section:
from .marketplace.commission import CommissionRule, CommissionRuleType, CommissionTier

And add to __all__:

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

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 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 with CRUD endpoints.


6. Database Migration

After implementing the model, generate an Alembic migration:

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:

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 — Model definition
  2. Update backend/app/models/marketplace/__init__.py — Export new model
  3. Update backend/app/models/__init__.py — Register in master registry
  4. 🔲 Create backend/app/schemas/commission.py — Pydantic schemas
  5. 🔲 Create backend/app/services/commission_service.py — Business logic
  6. 🔲 Create 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