RABAC felépítése megtörtént ill hirdetési portál alapok lerakva

This commit is contained in:
Roo
2026-06-19 06:06:34 +00:00
parent fe3c32597d
commit 9ba2d9180d
30 changed files with 4336 additions and 76 deletions

View File

@@ -5,7 +5,8 @@ from app.api.v1.endpoints import (
services, admin, expenses, evidence, social, security,
billing, finance_admin, analytics, vehicles, system_parameters,
gamification, translations, users, reports, dictionaries,
admin_packages, admin_services, constants, providers
admin_packages, admin_services, constants, providers,
subscriptions, marketing,
)
api_router = APIRouter()
@@ -35,4 +36,6 @@ api_router.include_router(admin_packages.router, prefix="/admin/packages", tags=
api_router.include_router(admin_services.router, prefix="/admin/services", tags=["Admin Service Catalog"])
api_router.include_router(billing.router, prefix="/billing", tags=["Billing & Wallet"])
api_router.include_router(constants.router, prefix="/constants", tags=["Constants"])
api_router.include_router(providers.router, prefix="/providers", tags=["Providers"])
api_router.include_router(providers.router, prefix="/providers", tags=["Providers"])
api_router.include_router(subscriptions.router, prefix="/subscriptions", tags=["Subscriptions"])
api_router.include_router(marketing.router, prefix="/marketing", tags=["Marketing & Ads"])

View File

@@ -7,7 +7,7 @@ from app.api.deps import get_db, get_current_user
from app.models.identity import User
from app.models import Asset # JAVÍTVA: Asset modell
from app.models.marketplace.organization import Organization
from app.models.core_logic import SubscriptionTier
from app.models.core_logic import SubscriptionTier, OrganizationSubscription
logger = logging.getLogger(__name__)
@@ -18,6 +18,7 @@ async def scan_registration_document(file: UploadFile = File(...), db: AsyncSess
# ── P0 FEATURE: Quota engine sync — read org's subscription_tier rules ──
# Instead of hardcoded plan-based lookup from system_parameters,
# we now read the organization's assigned subscription_tier rules.
# P0 BOOSTER: extra_allowances.extra_vehicles is added on top.
max_allowed = 1 # default fallback
if current_user.scope_id:
@@ -37,6 +38,22 @@ async def scan_registration_document(file: UploadFile = File(...), db: AsyncSess
elif org:
# Fallback to legacy base_asset_limit if no tier assigned
max_allowed = max(org.base_asset_limit or 1, 1)
# ── P0 BOOSTER: Add extra_allowances.extra_vehicles ──
try:
org_sub_stmt = select(OrganizationSubscription.extra_allowances).where(
OrganizationSubscription.org_id == current_user.scope_id,
OrganizationSubscription.is_active == True
).limit(1)
org_sub_result = await db.execute(org_sub_stmt)
extra_allowances = org_sub_result.scalar_one_or_none()
if extra_allowances and isinstance(extra_allowances, dict):
extra_vehicles = int(extra_allowances.get('extra_vehicles', 0))
max_allowed += extra_vehicles
if extra_vehicles > 0:
logger.info(f"[P0 BOOSTER] Extra vehicles for org {current_user.scope_id}: +{extra_vehicles}")
except Exception as e:
logger.debug(f"Could not read extra_allowances for org {current_user.scope_id}: {e}")
stmt_count = select(func.count(Asset.id)).where(
Asset.owner_org_id == current_user.scope_id,

View File

@@ -0,0 +1,136 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/marketing.py
"""
Marketing & Ad Placement Public API Endpoints.
Provides:
- GET /placements/{zone} — Get active ads for a specific placement zone
"""
from datetime import datetime, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from pydantic import BaseModel
from app.db.session import get_db
from app.models.marketing import (
Placement,
CampaignPlacement,
Campaign,
Creative,
CampaignStatus,
)
router = APIRouter()
class AdResponse(BaseModel):
"""Egy hirdetés válaszmodellje a frontend számára."""
id: int
campaign_id: int
campaign_name: str
creative_id: int
creative_type: str
content_url: Optional[str] = None
html_snippet: Optional[str] = None
alt_text: Optional[str] = None
target_url: Optional[str] = None
priority: int = 0
class PlacementResponse(BaseModel):
"""Egy placement zóna összes aktív hirdetésének válasza."""
zone: str
ads: List[AdResponse]
@router.get("/placements/{zone}", response_model=PlacementResponse)
async def get_placement_ads(
zone: str,
db: AsyncSession = Depends(get_db),
):
"""
Aktív hirdetések lekérése egy adott placement zónához.
**Logika:**
1. Megkeresi a Placement rekordot a `name` alapján (case-insensitive).
2. Lekéri az összes aktív kampányhoz tartozó CampaignPlacement kapcsolatot.
3. Visszaadja a kapcsolódó Creative-okat prioritás szerint rendezve.
4. Ha nincs aktív kampány, üres listával tér vissza (nem dob hibát).
"""
# 1. Find the placement by name (case-insensitive)
placement_stmt = select(Placement).where(
Placement.name.ilike(zone),
Placement.is_active == True,
)
result = await db.execute(placement_stmt)
placement = result.scalar_one_or_none()
if not placement:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Placement zone '{zone}' not found",
)
# 2. Find active campaign-placement links with eager-loaded relations
campaign_placements_stmt = (
select(CampaignPlacement)
.options(
selectinload(CampaignPlacement.campaign),
selectinload(CampaignPlacement.creative),
)
.where(
and_(
CampaignPlacement.placement_id == placement.id,
CampaignPlacement.is_active == True,
)
)
.order_by(CampaignPlacement.priority.asc())
)
result = await db.execute(campaign_placements_stmt)
campaign_placements = result.scalars().all()
# 3. Filter: only include CampaignPlacements where the campaign is ACTIVE
now_utc = datetime.now(timezone.utc)
active_ads: List[AdResponse] = []
for cp in campaign_placements:
campaign = cp.campaign
creative = cp.creative
if not campaign or not creative:
continue
# Check campaign status
if campaign.status != CampaignStatus.ACTIVE.value:
continue
# Check campaign date range
if campaign.start_date and campaign.start_date > now_utc:
continue
if campaign.end_date and campaign.end_date < now_utc:
continue
active_ads.append(
AdResponse(
id=cp.id,
campaign_id=campaign.id,
campaign_name=campaign.name,
creative_id=creative.id,
creative_type=creative.creative_type,
content_url=creative.content_url,
html_snippet=creative.html_snippet,
alt_text=creative.alt_text,
target_url=creative.target_url,
priority=cp.priority,
)
)
return PlacementResponse(
zone=zone,
ads=active_ads,
)

View File

@@ -0,0 +1,176 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/subscriptions.py
"""
Subscription Public API Endpoints.
Provides:
- GET /public — Public package catalog with dynamic pricing resolution
based on the user's organization country (or fallback to DEFAULT zone).
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api import deps
from app.db.session import get_db
from app.models.core_logic import SubscriptionTier
from app.models.identity import User
from app.models.marketplace.organization import Organization
from app.schemas.subscription import (
PublicSubscriptionTierResponse,
SubscriptionTierResponse,
PricingZoneModel,
)
router = APIRouter()
# ── Pricing Resolution ────────────────────────────────────────────────────────
DEFAULT_COUNTRY = "DEFAULT"
def resolve_pricing(
rules: dict,
country_code: str,
) -> Optional[dict]:
"""
Feloldja a megfelelő árazást a `pricing_zones` JSONB mezőből
a felhasználó országkódja alapján.
**Logika:**
1. Ha a `rules` tartalmaz `pricing_zones`-t, megkeresi a country_code-nak
megfelelő zónát.
2. Ha nincs pontos egyezés, visszaesik a "DEFAULT" zónára.
3. Ha a `pricing_zones` hiányzik, visszaesik a legacy `pricing` mezőre.
4. Ha semmi sincs, None-t ad vissza.
"""
if not rules or not isinstance(rules, dict):
return None
# 1. Try pricing_zones first
pricing_zones = rules.get("pricing_zones")
if pricing_zones and isinstance(pricing_zones, dict):
# Exact match
if country_code in pricing_zones:
return pricing_zones[country_code]
# Fallback to DEFAULT
if "DEFAULT" in pricing_zones:
return pricing_zones["DEFAULT"]
# 2. Fallback to legacy pricing
legacy_pricing = rules.get("pricing")
if legacy_pricing and isinstance(legacy_pricing, dict):
return {
"monthly_price": legacy_pricing.get("monthly_price", 0),
"yearly_price": legacy_pricing.get("yearly_price", 0),
"currency": legacy_pricing.get("currency", "EUR"),
"credit_price": legacy_pricing.get("credit_price"),
}
return None
async def get_user_country_code(
current_user: User,
db: AsyncSession,
) -> str:
"""
Meghatározza a felhasználó országkódját.
**Logika:**
1. Ha a felhasználónak van aktív szervezete (active_organization_id),
lekéri annak country_code mezőjét.
2. Ha nincs aktív szervezet, a felhasználó Person rekordjának
address országát próbálja meg lekérni.
3. Ha egyik sem elérhető, visszaadja a "DEFAULT" értéket.
"""
# 1. Try active organization
active_org_id = getattr(current_user, "active_organization_id", None)
if active_org_id:
org_stmt = select(Organization.country_code).where(
Organization.id == active_org_id,
Organization.is_deleted == False,
)
org_result = await db.execute(org_stmt)
org_country = org_result.scalar_one_or_none()
if org_country:
return org_country.upper()
# 2. Try user's person -> address country
person = getattr(current_user, "person", None)
if person:
address = getattr(person, "address", None)
if address:
country = getattr(address, "country", None)
if country:
return country.upper()
# 3. Fallback
return DEFAULT_COUNTRY
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get(
"/public",
response_model=List[PublicSubscriptionTierResponse],
summary="Publikus csomagok listázása feloldott árazással",
description=(
"Visszaadja az összes publikus előfizetési csomagot, "
"a felhasználó régiójára feloldott árazással (resolved_pricing). "
"A feloldás a pricing_zones JSONB mezőből történik a felhasználó "
"országkódja alapján."
),
)
async def get_public_subscriptions(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
):
"""
Publikus előfizetési csomagok listázása dinamikus árazással.
**Szabály:**
- Csak azokat a csomagokat adja vissza, ahol a `rules.lifecycle.is_public` = true
(JSONB mező alapján szűrve). Ha a mező hiányzik, alapértelmezés szerint publikus.
- Minden csomaghoz tartozik egy `resolved_pricing` mező, amely a felhasználó
országkódja alapján feloldott árazást tartalmazza.
- A frontend tovább szűrhet a `name` mező alapján (private_ vs corp_ előtag).
"""
# 1. Determine user's country code
country_code = await get_user_country_code(current_user, db)
# 2. Fetch all public tiers
stmt = (
select(SubscriptionTier)
.order_by(SubscriptionTier.id)
)
result = await db.execute(stmt)
tiers = result.scalars().all()
# 3. Build response with resolved pricing
response: List[PublicSubscriptionTierResponse] = []
for t in tiers:
rules = t.rules or {}
if not rules.get("lifecycle", {}).get("is_public", True):
continue
# Resolve pricing for this user's country
resolved = resolve_pricing(rules, country_code)
resolved_pricing = None
if resolved:
resolved_pricing = PricingZoneModel(**resolved)
response.append(
PublicSubscriptionTierResponse(
id=t.id,
name=t.name,
rules=rules,
is_custom=t.is_custom,
resolved_pricing=resolved_pricing,
)
)
return response

View File

@@ -50,6 +50,13 @@ from .identity.security import PendingAction, ActionStatus
from .system.legal import LegalDocument, LegalAcceptance
from .marketplace.logistics import Location, LocationType
# 10. Marketing / Ad Engine models
from .marketing import (
Campaign, Creative, Placement, CampaignCreative,
CampaignPlacement, AdImpression, AdClick,
CampaignStatus, CreativeType, PlacementType,
)
# Aliasok a Digital Twin kompatibilitáshoz
Vehicle = Asset
@@ -81,5 +88,9 @@ __all__ = [
"Vehicle", "UserVehicle", "VehicleCatalog", "ServiceRecord", "VehicleModelDefinition", "ReferenceLookup",
"VehicleType", "FeatureDefinition", "ModelFeatureMap", "LegalDocument", "LegalAcceptance",
"Location", "LocationType", "Issuer", "IssuerType", "CostCategory", "VehicleCost", "ExternalReferenceLibrary", "ExternalReferenceQueue",
"GbCatalogDiscovery", "Season", "StagedVehicleData", "OneTimePassword"
"GbCatalogDiscovery", "Season", "StagedVehicleData", "OneTimePassword",
# Marketing / Ad Engine
"Campaign", "Creative", "Placement", "CampaignCreative",
"CampaignPlacement", "AdImpression", "AdClick",
"CampaignStatus", "CreativeType", "PlacementType",
]

View File

@@ -23,8 +23,8 @@ class SubscriptionTier(Base):
is_custom: Mapped[bool] = mapped_column(Boolean, default=False)
class OrganizationSubscription(Base):
"""
Szervezetek aktuális előfizetései és azok érvényessége.
"""
Szervezetek aktuális előfizetései és azok érvényessége.
"""
__tablename__ = "org_subscriptions"
__table_args__ = {"schema": "finance"}
@@ -40,6 +40,11 @@ class OrganizationSubscription(Base):
valid_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
valid_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# ── P0 BOOSTER ARCHITECTURE: extra_allowances JSONB ──
# Tárolja a darabáron vett extra kvótákat a csomag keretein felül.
# Példa: {"extra_vehicles": 1, "extra_garages": 1, "extra_credits": 100}
extra_allowances: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"), default=dict)
class UserSubscription(Base):
"""

View File

@@ -0,0 +1,300 @@
# /opt/docker/dev/service_finder/backend/app/models/marketing.py
"""
📢 Ad Engine — Marketing Schema Models
Extensible Native Ad Engine for Service Finder.
Supports:
- Campaign lifecycle (draft → scheduled → active → paused → completed → cancelled)
- Internal (IMAGE) and external (HTML_SNIPPET) creatives
- Priority-based + weighted random ad selection with fallback
- Impression and click tracking for analytics
Schema: marketing
"""
from __future__ import annotations
import enum
from datetime import datetime
from typing import Optional, List
from sqlalchemy import (
String, Integer, Boolean, DateTime, Float, Text,
ForeignKey, UniqueConstraint, Index, CheckConstraint, text
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.database import Base
# ── Enums (Python-side only; stored as String in DB for portability) ───────────
class CampaignStatus(str, enum.Enum):
"""Kampány életciklus állapotai."""
DRAFT = "draft"
SCHEDULED = "scheduled"
ACTIVE = "active"
PAUSED = "paused"
COMPLETED = "completed"
CANCELLED = "cancelled"
class CreativeType(str, enum.Enum):
"""Kreatív típusok (bővíthető)."""
IMAGE = "image" # Belső feltöltésű kép
HTML_SNIPPET = "html_snippet" # Külső HTML kód (pl. Google Ads)
# Jövőbeli: VIDEO, CAROUSEL, TEXT
class PlacementType(str, enum.Enum):
"""Hirdetési helyek a felületen."""
SIDEBAR = "sidebar"
BANNER_TOP = "banner_top"
BANNER_BOTTOM = "banner_bottom"
MODAL = "modal"
INLINE = "inline"
DASHBOARD_WIDGET = "dashboard_widget"
# ── Models ─────────────────────────────────────────────────────────────────────
class Campaign(Base):
"""
Hirdetési kampány.
Egy kampány több kreatívot és több elhelyezést (placement) is
tartalmazhat a many-to-many kapcsolaton keresztül.
"""
__tablename__ = "campaigns"
__table_args__ = (
CheckConstraint(
"status IN ('draft', 'scheduled', 'active', 'paused', 'completed', 'cancelled')",
name="ck_campaign_status"
),
{"schema": "marketing"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Kampány életciklus (String instead of native PG enum for portability)
status: Mapped[str] = mapped_column(
String(20),
default=CampaignStatus.DRAFT.value,
nullable=False,
index=True,
)
# Célzási beállítások (JSONB a rugalmasságért)
# Pl. {"target_tier": ["free", "premium"], "target_country": ["HU", "UK"]}
targeting: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True, server_default=text("'{}'::jsonb"))
# Prioritás (kisebb szám = magasabb prioritás)
priority: Mapped[int] = mapped_column(Integer, default=100, nullable=False)
# Időbeli korlátok
start_date: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
end_date: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Napi költési limit (impresszióban vagy pénzben)
daily_impression_limit: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
total_impression_limit: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# Metrikák (denormalizált gyors lekérdezéshez)
current_impressions: Mapped[int] = mapped_column(Integer, default=0)
current_clicks: Mapped[int] = mapped_column(Integer, default=0)
# Metaadatok
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
created_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # Admin user ID
# Kapcsolatok
creatives: Mapped[List["CampaignCreative"]] = relationship(
"CampaignCreative", back_populates="campaign", cascade="all, delete-orphan"
)
placements: Mapped[List["CampaignPlacement"]] = relationship(
"CampaignPlacement", back_populates="campaign", cascade="all, delete-orphan"
)
class Creative(Base):
"""
Kreatív (hirdetés anyag).
Két fő típus:
- IMAGE: belső feltöltésű kép (image_url, alt_text)
- HTML_SNIPPET: külső HTML kód (pl. Google Ads vagy más hirdetési hálózat)
"""
__tablename__ = "creatives"
__table_args__ = (
CheckConstraint(
"creative_type IN ('image', 'html_snippet')",
name="ck_creative_type"
),
{"schema": "marketing"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
# Típus (String instead of native PG enum for portability)
creative_type: Mapped[str] = mapped_column(
String(20),
nullable=False,
)
# IMAGE típushoz
image_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
alt_text: Mapped[Optional[str]] = mapped_column(String(300), nullable=True)
click_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # Átkattintási link
# HTML_SNIPPET típushoz
html_snippet: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Közös mezők
width: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
height: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
weight: Mapped[float] = mapped_column(Float, default=1.0) # Súly a random kiválasztáshoz
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# Kapcsolatok
campaigns: Mapped[List["CampaignCreative"]] = relationship(
"CampaignCreative", back_populates="creative", cascade="all, delete-orphan"
)
class Placement(Base):
"""
Hirdetési hely (placement) a felületen.
Meghatározza, hogy a hirdetés hol jelenjen meg a frontenden.
"""
__tablename__ = "placements"
__table_args__ = (
CheckConstraint(
"placement_type IN ('sidebar', 'banner_top', 'banner_bottom', 'modal', 'inline', 'dashboard_widget')",
name="ck_placement_type"
),
{"schema": "marketing"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
placement_type: Mapped[str] = mapped_column(
String(20),
nullable=False,
)
description: Mapped[Optional[str]] = mapped_column(String(300), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# Kapcsolatok
campaigns: Mapped[List["CampaignPlacement"]] = relationship(
"CampaignPlacement", back_populates="placement", cascade="all, delete-orphan"
)
class CampaignCreative(Base):
"""
Many-to-Many kapcsolótábla: Campaign <-> Creative.
Lehetővé teszi, hogy egy kampány több kreatívot használjon,
és egy kreatív több kampányban is szerepelhessen.
"""
__tablename__ = "campaign_creatives"
__table_args__ = (
UniqueConstraint("campaign_id", "creative_id", name="uq_campaign_creative"),
{"schema": "marketing"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
campaign_id: Mapped[int] = mapped_column(Integer, ForeignKey("marketing.campaigns.id", ondelete="CASCADE"), nullable=False)
creative_id: Mapped[int] = mapped_column(Integer, ForeignKey("marketing.creatives.id", ondelete="CASCADE"), nullable=False)
# Kapcsolatok
campaign: Mapped["Campaign"] = relationship("Campaign", back_populates="creatives")
creative: Mapped["Creative"] = relationship("Creative", back_populates="campaigns")
class CampaignPlacement(Base):
"""
Many-to-Many kapcsolótábla: Campaign <-> Placement.
Meghatározza, hogy egy kampány mely hirdetési helyeken jelenjen meg.
"""
__tablename__ = "campaign_placements"
__table_args__ = (
UniqueConstraint("campaign_id", "placement_id", name="uq_campaign_placement"),
{"schema": "marketing"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
campaign_id: Mapped[int] = mapped_column(Integer, ForeignKey("marketing.campaigns.id", ondelete="CASCADE"), nullable=False)
placement_id: Mapped[int] = mapped_column(Integer, ForeignKey("marketing.placements.id", ondelete="CASCADE"), nullable=False)
# Kapcsolatok
campaign: Mapped["Campaign"] = relationship("Campaign", back_populates="placements")
placement: Mapped["Placement"] = relationship("Placement", back_populates="campaigns")
class AdImpression(Base):
"""
Hirdetés megjelenítési napló.
Minden egyes alkalommal rögzíti, amikor egy hirdetés megjelent
a felületen. Ez az adat szolgál a kampányok analitikájához.
"""
__tablename__ = "ad_impressions"
__table_args__ = (
Index("idx_impression_campaign", "campaign_id"),
Index("idx_impression_created", "created_at"),
{"schema": "marketing"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
campaign_id: Mapped[int] = mapped_column(Integer, ForeignKey("marketing.campaigns.id", ondelete="CASCADE"), nullable=False)
creative_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("marketing.creatives.id", ondelete="SET NULL"), nullable=True)
placement_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("marketing.placements.id", ondelete="SET NULL"), nullable=True)
# Kontextus
user_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # Bejelentkezett user (ha van)
session_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) # Anonymous session
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True) # IPv4 vagy IPv6
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# Időbélyeg
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class AdClick(Base):
"""
Hirdetés kattintási napló.
Minden egyes kattintást rögzít, ami egy hirdetésen történt.
"""
__tablename__ = "ad_clicks"
__table_args__ = (
Index("idx_click_campaign", "campaign_id"),
Index("idx_click_created", "created_at"),
{"schema": "marketing"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
impression_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("marketing.ad_impressions.id", ondelete="SET NULL"), nullable=True
)
campaign_id: Mapped[int] = mapped_column(Integer, ForeignKey("marketing.campaigns.id", ondelete="CASCADE"), nullable=False)
creative_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("marketing.creatives.id", ondelete="SET NULL"), nullable=True)
user_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
session_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -6,7 +6,10 @@ A rules struktúra a seed_packages.py-ban definiált formátumot követi:
{
"type": "private" | "corporate",
"pricing": {"monthly_price": float, "yearly_price": float, "currency": "EUR"},
"pricing_zones": {
"HU": {"monthly_price": 3000, "yearly_price": 32500, "currency": "HUF"},
"DEFAULT": {"monthly_price": 12.99, "yearly_price": 129.99, "currency": "EUR"}
},
"allowances": {"max_vehicles": int, "max_garages": int, "monthly_free_credits": int},
"entitlements": ["SRV_..."],
"affiliate": {"commission_rate_percent": int, "referral_bonus_credits": int}
@@ -25,8 +28,16 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
# ── Belső (embedded) modellek a rules JSONB struktúrához ──────────────────────
class PricingZoneModel(BaseModel):
"""Egyetlen árazási zóna adatai (ország-specifikus ár)."""
monthly_price: float = Field(..., ge=0, description="Havi előfizetési díj")
yearly_price: float = Field(..., ge=0, description="Éves előfizetési díj")
currency: str = Field(default="EUR", min_length=3, max_length=3, description="Pénznem ISO 4217 kódja")
credit_price: Optional[int] = Field(default=None, ge=0, description="Kreditár (havi kredit költség a csomaghoz)")
class PricingModel(BaseModel):
"""Árazási információk egy előfizetési csomaghoz."""
"""Árazási információk egy előfizetési csomaghoz (legacy, egyzónás)."""
monthly_price: float = Field(..., ge=0, description="Havi előfizetési díj (EUR)")
yearly_price: float = Field(..., ge=0, description="Éves előfizetési díj (EUR)")
currency: str = Field(default="EUR", min_length=3, max_length=3, description="Pénznem ISO 4217 kódja")
@@ -40,6 +51,19 @@ class AllowancesModel(BaseModel):
monthly_free_credits: int = Field(..., ge=0, description="Havi ingyenes kreditek száma")
class DurationModel(BaseModel):
"""Előfizetés időtartamának beállításai (duration_days stacking)."""
days: int = Field(default=30, ge=1, description="Előfizetés időtartama napokban (pl. 30 = havi, 365 = éves)")
allow_stacking: bool = Field(default=True, description="Időhalmozás engedélyezése (stacking): ha True, a meglévő valid_until-hoz hozzáadódik")
class AdPolicyModel(BaseModel):
"""Hirdetési szabályok a csomaghoz."""
show_ads: bool = Field(default=True, description="Megjelenjenek-e hirdetések a felhasználónak")
ad_free_grace_days: int = Field(default=0, ge=0, description="Hány nap türelmi idő a reklámok eltűnéséig fizetés után")
max_daily_impressions: Optional[int] = Field(default=None, ge=0, description="Maximális napi hirdetésmegjelenítés (None = korlátlan)")
class LifecycleModel(BaseModel):
"""Életciklus állapotok egy előfizetési csomaghoz."""
is_public: bool = Field(default=True, description="Publikus-e a csomag (látszik-e a felhasználóknak)")
@@ -52,6 +76,13 @@ class AffiliateModel(BaseModel):
referral_bonus_credits: int = Field(..., ge=0, description="Ajánlási bónusz kreditek száma")
class MarketingModel(BaseModel):
"""Marketing információk egy előfizetési csomaghoz (kártya megjelenítéshez)."""
subtitle: Optional[str] = Field(default=None, description="Rövid leíró szöveg a kártyán (pl. 'Kisebb flotta esetén.')")
badge: Optional[str] = Field(default=None, description="Jelvény szöveg (pl. 'Legnépszerűbb', 'Pro')")
highlight_color: Optional[str] = Field(default=None, description="Kiemelő szín hex kódja (pl. '#70BC84')")
class SubscriptionRulesModel(BaseModel):
"""
A subscription_tiers.rules JSONB oszlopának erősen tipizált modellje.
@@ -59,11 +90,15 @@ class SubscriptionRulesModel(BaseModel):
Fields:
type: "private" (magánszemély) vagy "corporate" (vállalati)
display_name: Emberi olvasásra szánt megjelenítési név (pl. "Privát Ingyenes")
pricing: Árazási adatok (havi/éves díj, pénznem)
pricing: Árazási adatok (legacy, egyzónás) - deprecated, használd a pricing_zones-t
pricing_zones: Több régiós árazási adatok (országkód -> PricingZoneModel)
allowances: Korlátok (max jármű, garázs, kredit)
entitlements: Jogosultságok listája (pl. SRV_DATA_EXPORT)
affiliate: Partnerprogram beállítások
lifecycle: Életciklus állapotok (opcionális, pl. is_public)
duration: Előfizetés időtartamának beállításai (duration_days, stacking)
ad_policy: Hirdetési szabályok (show_ads, ad_free_grace_days)
marketing: Marketing adatok a kártya megjelenítéshez (subtitle, badge, highlight_color)
"""
type: str = Field(..., pattern=r"^(private|corporate)$", description="Csomag típusa")
display_name: Optional[str] = Field(
@@ -72,7 +107,11 @@ class SubscriptionRulesModel(BaseModel):
)
pricing: Optional[PricingModel] = Field(
default=None,
description="Árazási adatok (havi/éves díj, pénznem). Opcionális a hiányos seed adatok miatt."
description="Árazási adatok (legacy, egyzónás). Deprecated: használd a pricing_zones-t.",
)
pricing_zones: Optional[Dict[str, PricingZoneModel]] = Field(
default=None,
description="Több régiós árazás. Kulcs: országkód (pl. 'HU', 'DE', 'DEFAULT').",
)
allowances: Optional[AllowancesModel] = Field(
default=None,
@@ -87,6 +126,18 @@ class SubscriptionRulesModel(BaseModel):
default=None,
description="Életciklus állapotok (is_public, available_until)"
)
duration: Optional[DurationModel] = Field(
default=None,
description="Előfizetés időtartamának beállításai (duration_days, allow_stacking)"
)
ad_policy: Optional[AdPolicyModel] = Field(
default=None,
description="Hirdetési szabályok (show_ads, ad_free_grace_days, max_daily_impressions)"
)
marketing: Optional[MarketingModel] = Field(
default=None,
description="Marketing adatok a kártya megjelenítéshez (subtitle, badge, highlight_color)"
)
@field_validator("type")
@classmethod
@@ -109,6 +160,23 @@ class SubscriptionTierResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
class PublicSubscriptionTierResponse(BaseModel):
"""
Publikus előfizetési csomag válasz a frontend számára.
Tartalmazza a feloldott (resolved) árazást a felhasználó régiója alapján.
"""
id: int
name: str
rules: SubscriptionRulesModel
is_custom: bool
resolved_pricing: Optional[PricingZoneModel] = Field(
default=None,
description="A felhasználó régiójára feloldott árazás (egyetlen PricingZoneModel).",
)
model_config = ConfigDict(from_attributes=True)
class SubscriptionTierCreate(BaseModel):
"""Új előfizetési csomag létrehozása (POST /)."""
name: str = Field(..., min_length=2, max_length=100, description="Csomag neve (pl. private_pro_v2)")

View File

@@ -10,7 +10,10 @@ Architektúra:
A csomagok rules JSONB oszlopa egy szabványos struktúrát követ:
{
"type": "private" | "corporate",
"pricing": {"monthly_price": float, "yearly_price": float, "currency": "EUR"},
"pricing_zones": {
"HU": {"monthly_price": 3000, "yearly_price": 32500, "currency": "HUF"},
"DEFAULT": {"monthly_price": 12.99, "yearly_price": 129.99, "currency": "EUR"}
},
"allowances": {"max_vehicles": int, "max_garages": int, "monthly_free_credits": int},
"entitlements": ["SRV_..."],
"affiliate": {"commission_rate_percent": int, "referral_bonus_credits": int}
@@ -31,6 +34,46 @@ logging.basicConfig(
logger = logging.getLogger("Seed-Packages")
# ---------------------------------------------------------------------------
# Pricing Zones helper
# ---------------------------------------------------------------------------
def pricing_zones(
eur_monthly: float,
eur_yearly: float,
eur_credit: int = 0,
huf_monthly: int = 0,
huf_yearly: int = 0,
huf_credit: int = 0,
usd_monthly: float = 0,
usd_yearly: float = 0,
usd_credit: int = 0,
) -> dict:
"""Create a pricing_zones dict with DEFAULT (EUR), HU (HUF), and optionally USD zones."""
zones = {
"DEFAULT": {
"monthly_price": eur_monthly,
"yearly_price": eur_yearly,
"currency": "EUR",
"credit_price": eur_credit if eur_credit > 0 else None,
},
}
if huf_monthly > 0:
zones["HU"] = {
"monthly_price": huf_monthly,
"yearly_price": huf_yearly,
"currency": "HUF",
"credit_price": huf_credit if huf_credit > 0 else None,
}
if usd_monthly > 0:
zones["US"] = {
"monthly_price": usd_monthly,
"yearly_price": usd_yearly,
"currency": "USD",
"credit_price": usd_credit if usd_credit > 0 else None,
}
return zones
# ---------------------------------------------------------------------------
# Csomagdefiníciók
# ---------------------------------------------------------------------------
@@ -46,6 +89,12 @@ PACKAGES = [
"currency": "EUR",
"credit_price": 0,
},
"pricing_zones": pricing_zones(
eur_monthly=0.0,
eur_yearly=0.0,
huf_monthly=0,
huf_yearly=0,
),
"allowances": {
"max_vehicles": 1,
"max_garages": 1,
@@ -59,6 +108,19 @@ PACKAGES = [
"lifecycle": {
"is_public": True,
},
"duration": {
"days": 30,
"allow_stacking": True,
},
"ad_policy": {
"show_ads": True,
"ad_free_grace_days": 0,
"max_daily_impressions": 50,
},
"marketing": {
"subtitle": "Alap szintű járműkövetés kezdőknek.",
"badge": "Ingyenes",
},
},
"is_custom": False,
},
@@ -73,6 +135,17 @@ PACKAGES = [
"currency": "EUR",
"credit_price": 500,
},
"pricing_zones": pricing_zones(
eur_monthly=4.99,
eur_yearly=49.99,
eur_credit=500,
huf_monthly=1890,
huf_yearly=18990,
huf_credit=500,
usd_monthly=5.99,
usd_yearly=59.99,
usd_credit=500,
),
"allowances": {
"max_vehicles": 3,
"max_garages": 1,
@@ -86,6 +159,20 @@ PACKAGES = [
"lifecycle": {
"is_public": True,
},
"duration": {
"days": 30,
"allow_stacking": True,
},
"ad_policy": {
"show_ads": True,
"ad_free_grace_days": 3,
"max_daily_impressions": 100,
},
"marketing": {
"subtitle": "Kisebb flotta esetén ideális választás.",
"badge": "Pro",
"highlight_color": "#70BC84",
},
},
"is_custom": False,
},
@@ -100,6 +187,17 @@ PACKAGES = [
"currency": "EUR",
"credit_price": 1200,
},
"pricing_zones": pricing_zones(
eur_monthly=9.99,
eur_yearly=99.99,
eur_credit=1200,
huf_monthly=3790,
huf_yearly=37990,
huf_credit=1200,
usd_monthly=11.99,
usd_yearly=119.99,
usd_credit=1200,
),
"allowances": {
"max_vehicles": 10,
"max_garages": 1,
@@ -113,6 +211,20 @@ PACKAGES = [
"lifecycle": {
"is_public": True,
},
"duration": {
"days": 30,
"allow_stacking": True,
},
"ad_policy": {
"show_ads": False,
"ad_free_grace_days": 0,
"max_daily_impressions": None,
},
"marketing": {
"subtitle": "VIP profi garázs kisvállalkozók számára.",
"badge": "VIP",
"highlight_color": "#F59E0B",
},
},
"is_custom": False,
},
@@ -127,6 +239,17 @@ PACKAGES = [
"currency": "EUR",
"credit_price": 3000,
},
"pricing_zones": pricing_zones(
eur_monthly=29.99,
eur_yearly=299.99,
eur_credit=3000,
huf_monthly=11990,
huf_yearly=119990,
huf_credit=3000,
usd_monthly=34.99,
usd_yearly=349.99,
usd_credit=3000,
),
"allowances": {
"max_vehicles": 20,
"max_garages": 3,
@@ -140,6 +263,20 @@ PACKAGES = [
"lifecycle": {
"is_public": True,
},
"duration": {
"days": 30,
"allow_stacking": True,
},
"ad_policy": {
"show_ads": False,
"ad_free_grace_days": 0,
"max_daily_impressions": None,
},
"marketing": {
"subtitle": "Közepes flották számára tervezve.",
"badge": "Prémium",
"highlight_color": "#3B82F6",
},
},
"is_custom": False,
},
@@ -154,6 +291,17 @@ PACKAGES = [
"currency": "EUR",
"credit_price": 6000,
},
"pricing_zones": pricing_zones(
eur_monthly=59.99,
eur_yearly=599.99,
eur_credit=6000,
huf_monthly=23990,
huf_yearly=239990,
huf_credit=6000,
usd_monthly=69.99,
usd_yearly=699.99,
usd_credit=6000,
),
"allowances": {
"max_vehicles": 50,
"max_garages": 10,
@@ -171,6 +319,20 @@ PACKAGES = [
"lifecycle": {
"is_public": True,
},
"duration": {
"days": 30,
"allow_stacking": True,
},
"ad_policy": {
"show_ads": False,
"ad_free_grace_days": 0,
"max_daily_impressions": None,
},
"marketing": {
"subtitle": "Nagy flották prémium menedzsmentje.",
"badge": "Plus",
"highlight_color": "#8B5CF6",
},
},
"is_custom": False,
},
@@ -185,6 +347,17 @@ PACKAGES = [
"currency": "EUR",
"credit_price": 15000,
},
"pricing_zones": pricing_zones(
eur_monthly=149.99,
eur_yearly=1499.99,
eur_credit=15000,
huf_monthly=59990,
huf_yearly=599990,
huf_credit=15000,
usd_monthly=169.99,
usd_yearly=1699.99,
usd_credit=15000,
),
"allowances": {
"max_vehicles": 200,
"max_garages": 50,
@@ -203,6 +376,20 @@ PACKAGES = [
"lifecycle": {
"is_public": True,
},
"duration": {
"days": 30,
"allow_stacking": True,
},
"ad_policy": {
"show_ads": False,
"ad_free_grace_days": 0,
"max_daily_impressions": None,
},
"marketing": {
"subtitle": "Vállalati szintű flottairányítás.",
"badge": "VIP",
"highlight_color": "#EF4444",
},
},
"is_custom": False,
},
@@ -215,28 +402,31 @@ async def seed():
logger.info("=" * 60)
async with AsyncSessionLocal() as db:
# 1. Töröljük a meglévő rekordokat (tiszta lap)
logger.info("Törlöm a meglévő subscription_tiers rekordokat...")
await db.execute(delete(SubscriptionTier))
await db.commit()
logger.info("✅ Meglévő rekordok törölve.")
# 1. UPSERT: Meglévő csomagok frissítése vagy új beszúrása
from sqlalchemy.dialects.postgresql import insert as pg_insert
# 2. Beszúrjuk a 6 új csomagot
inserted_count = 0
upserted_count = 0
for pkg in PACKAGES:
tier = SubscriptionTier(
stmt = pg_insert(SubscriptionTier).values(
name=pkg["name"],
rules=pkg["rules"],
is_custom=pkg["is_custom"],
)
db.add(tier)
inserted_count += 1
logger.info(f" Hozzáadva: {pkg['name']}")
stmt = stmt.on_conflict_do_update(
index_elements=[SubscriptionTier.name],
set_={
"rules": stmt.excluded.rules,
"is_custom": stmt.excluded.is_custom,
}
)
await db.execute(stmt)
upserted_count += 1
logger.info(f" Upsert: {pkg['name']}")
await db.commit()
logger.info(f"{inserted_count} csomag sikeresen beszúrva.")
logger.info(f"{upserted_count} csomag sikeresen upsertelve.")
# 3. Visszaigazolás: listázzuk ki az összes rekordot
# 2. Visszaigazolás
logger.info("-" * 60)
logger.info("Visszaigazolás - system.subscription_tiers tartalma:")
logger.info("-" * 60)
@@ -256,7 +446,7 @@ async def seed():
logger.info(f"\n✅ Összesen {len(tiers)} aktív csomag a rendszerben.")
# 4. JSONB példa kiírása
# 3. JSONB példa kiírása
if tiers:
sample = tiers[0]
logger.info("=" * 60)

View File

@@ -494,9 +494,11 @@ class AssetService:
Get the vehicle limit for a user, checking:
1. Subscription tier JSONB rules['allowances']['max_vehicles'] (PRIMARY source of truth)
2. Organization-level base_asset_limit (fallback if no user subscription)
3. Config-based limits (legacy fallback)
3. P0 BOOSTER: extra_allowances from finance.org_subscriptions (additive on top of tier)
4. Config-based limits (legacy fallback)
P0: The subscription_tier JSONB rules are now the Single Source of Truth.
P0 Booster: extra_allowances.extra_vehicles is added on top of the tier limit.
Legacy string-based subscription_plan lookups have been removed.
Args:
@@ -508,7 +510,7 @@ class AssetService:
Maximum allowed vehicles
"""
from app.models.identity import User
from app.models.core_logic import UserSubscription, SubscriptionTier
from app.models.core_logic import UserSubscription, SubscriptionTier, OrganizationSubscription
from app.models.marketplace.organization import Organization
from app.services.config_service import config
@@ -577,7 +579,33 @@ class AssetService:
except Exception as e:
logger.debug(f"Could not read org subscription tier limit for org {org_id}: {e}")
# ── 3. CONFIG-BASED LIMIT (legacy fallback, role-based only) ──
# ── 3. P0 BOOSTER: extra_allowances from finance.org_subscriptions ──
# Extra allowances are additive on top of the tier limit.
# Read from the active org subscription's extra_allowances JSONB.
extra_vehicles = 0
try:
org_sub_stmt = (
select(OrganizationSubscription.extra_allowances)
.where(
OrganizationSubscription.org_id == org_id,
OrganizationSubscription.is_active == True
)
.limit(1)
)
org_sub_result = await db.execute(org_sub_stmt)
extra_allowances = org_sub_result.scalar_one_or_none()
if extra_allowances and isinstance(extra_allowances, dict):
extra_vehicles = int(extra_allowances.get('extra_vehicles', 0))
if extra_vehicles > 0:
logger.info(
f"[P0 BOOSTER] Extra vehicles for org {org_id}: "
f"extra_vehicles={extra_vehicles} (from extra_allowances JSONB)"
)
except Exception as e:
logger.debug(f"Could not read extra_allowances for org {org_id}: {e}")
# ── 4. CONFIG-BASED LIMIT (legacy fallback, role-based only) ──
config_limit = None
try:
limits = await config.get_setting(db, "VEHICLE_LIMIT")
@@ -591,16 +619,20 @@ class AssetService:
if config_limit is None:
config_limit = 1 # absolute fallback
# ── 4. FINAL: Take the HIGHEST of all applicable limits ──
# ── 5. FINAL: Take the HIGHEST of all applicable limits, then ADD boosters ──
final_limit = config_limit
if org_limit is not None:
final_limit = max(final_limit, org_limit)
if subscription_limit is not None:
final_limit = max(final_limit, subscription_limit)
# Add extra_allowances on top of the base limit
final_limit = final_limit + extra_vehicles
logger.info(
f"[P0] Vehicle limit for user {user_id} (role={user_role}): "
f"subscription_tier={subscription_limit}, org={org_limit}, config={config_limit}, "
f"subscription_tier={subscription_limit}, org={org_limit}, "
f"extra_vehicles={extra_vehicles}, config={config_limit}, "
f"final={final_limit}"
)

View File

@@ -727,6 +727,9 @@ async def upgrade_subscription(
"""
Felhasználó előfizetésének frissítése (csomagváltás).
P0 Stacking Feature: A duration_days mezőt a tier.rules-ból olvassa ki,
és a valid_until számításánál időhalmozást (stacking) alkalmaz.
Args:
db: Database session
user_id: Felhasználó ID
@@ -735,7 +738,7 @@ async def upgrade_subscription(
Returns:
Dict: Tranzakció részletei és az új előfizetés információi
"""
from app.models.core_logic import SubscriptionTier
from app.models.core_logic import SubscriptionTier, UserSubscription
from app.models.identity import User
# 1. Ellenőrizze, hogy a cél csomag létezik-e
@@ -746,23 +749,67 @@ async def upgrade_subscription(
if not tier:
raise ValueError(f"Subscription tier '{target_package}' not found")
# ── P0 STACKING: duration_days kiolvasása a tier.rules-ból ──
duration_days = 30 # alapértelmezett 30 nap
allow_stacking = True
if tier.rules:
duration_config = tier.rules.get("duration", {})
if isinstance(duration_config, dict):
duration_days = duration_config.get("days", 30)
allow_stacking = duration_config.get("allow_stacking", True)
now = datetime.utcnow()
# 2. Számítsa ki az árát a csomagnak (egyszerűsítve: fix ár a tier.rules-ból)
price = tier.rules.get("price", 0.0) if tier.rules else 0.0
if price <= 0:
# Ingyenes csomag, nincs levonás
logger.info(f"Upgrading user {user_id} to free tier {target_package}")
# Frissítse a felhasználó subscription_plan mezőjét
user_stmt = select(User).where(User.id == user_id)
user_result = await db.execute(user_stmt)
user = user_result.scalar_one()
user.subscription_plan = target_package
user.subscription_expires_at = datetime.utcnow() + timedelta(days=30) # 30 nap
# ── P0 STACKING: user_subscriptions tábla kezelése ──
# Lekérdezzük a meglévő aktív user subscription-t
us_stmt = select(UserSubscription).where(
UserSubscription.user_id == user_id,
UserSubscription.is_active == True
)
us_result = await db.execute(us_stmt)
existing_us = us_result.scalar_one_or_none()
if existing_us:
existing_us.is_active = False
# Stacking számítás
if existing_us and allow_stacking and existing_us.valid_until and existing_us.valid_until > now:
new_valid_until = existing_us.valid_until + timedelta(days=duration_days)
else:
new_valid_until = now + timedelta(days=duration_days)
# Új UserSubscription rekord
new_us = UserSubscription(
user_id=user_id,
tier_id=tier.id,
valid_from=now,
valid_until=new_valid_until,
is_active=True
)
db.add(new_us)
user.subscription_expires_at = new_valid_until
return {
"success": True,
"message": f"Upgraded to {target_package} (free)",
"new_plan": target_package,
"price_paid": 0.0
"price_paid": 0.0,
"valid_until": new_valid_until.isoformat(),
"duration_days": duration_days,
"stacking_applied": allow_stacking and bool(existing_us and existing_us.valid_until and existing_us.valid_until > now)
}
# 3. Ár kiszámítása a PricingCalculator segítségével
@@ -789,15 +836,51 @@ async def upgrade_subscription(
# 5. Frissítse a felhasználó előfizetési adatait
user.subscription_plan = target_package
user.subscription_expires_at = datetime.utcnow() + timedelta(days=30) # 30 nap
logger.info(f"User {user_id} upgraded to {target_package} for {final_price} EUR")
# ── P0 STACKING: user_subscriptions tábla kezelése ──
us_stmt = select(UserSubscription).where(
UserSubscription.user_id == user_id,
UserSubscription.is_active == True
)
us_result = await db.execute(us_stmt)
existing_us = us_result.scalar_one_or_none()
if existing_us:
existing_us.is_active = False
# Stacking számítás
if existing_us and allow_stacking and existing_us.valid_until and existing_us.valid_until > now:
new_valid_until = existing_us.valid_until + timedelta(days=duration_days)
else:
new_valid_until = now + timedelta(days=duration_days)
# Új UserSubscription rekord
new_us = UserSubscription(
user_id=user_id,
tier_id=tier.id,
valid_from=now,
valid_until=new_valid_until,
is_active=True
)
db.add(new_us)
user.subscription_expires_at = new_valid_until
logger.info(
f"User {user_id} upgraded to {target_package} for {final_price} EUR, "
f"valid_until={new_valid_until.isoformat()}, "
f"duration_days={duration_days}, "
f"stacking={allow_stacking}"
)
return {
"success": True,
"transaction": transaction,
"new_plan": target_package,
"price_paid": final_price,
"valid_until": new_valid_until.isoformat(),
"duration_days": duration_days,
"stacking_applied": allow_stacking and bool(existing_us and existing_us.valid_until and existing_us.valid_until > now),
"expires_at": user.subscription_expires_at.isoformat()
}
@@ -816,6 +899,12 @@ async def upgrade_org_subscription(
subscription_tier_id FK on the Organization record, and also creates/
updates a record in finance.org_subscriptions for audit trail.
P0 Stacking Feature: A duration_days mezőt a tier.rules-ból olvassa ki,
és a valid_until számításánál időhalmozást (stacking) alkalmaz:
- Ha a meglévő előfizetés még aktív (valid_until > NOW):
új valid_until = régi valid_until + duration_days
- Ha lejárt vagy nincs: új valid_until = NOW + duration_days
Args:
db: Database session
org_id: Organization ID
@@ -860,24 +949,54 @@ async def upgrade_org_subscription(
sub_result = await db.execute(sub_stmt)
existing_sub = sub_result.scalar_one_or_none()
# ── P0 STACKING: duration_days kiolvasása a tier.rules-ból ──
duration_days = 30 # alapértelmezett 30 nap
allow_stacking = True
if tier.rules:
duration_config = tier.rules.get("duration", {})
if isinstance(duration_config, dict):
duration_days = duration_config.get("days", 30)
allow_stacking = duration_config.get("allow_stacking", True)
now = datetime.utcnow()
if existing_sub:
# Deaktiváljuk a régit
existing_sub.is_active = False
existing_sub.valid_until = datetime.utcnow()
existing_sub.valid_until = now
# Új aktív subscription rekord
# ── P0 STACKING: valid_until számítás időhalmozással ──
if existing_sub and allow_stacking and existing_sub.valid_until and existing_sub.valid_until > now:
# Stacking: a meglévő valid_until-hoz hozzáadjuk a duration_days-t
new_valid_until = existing_sub.valid_until + timedelta(days=duration_days)
logger.info(
f"STACKING: org_id={org_id} existing valid_until={existing_sub.valid_until} "
f"+ {duration_days} days = {new_valid_until}"
)
else:
# Nincs stacking: mostól számítva + duration_days
new_valid_until = now + timedelta(days=duration_days)
# Új aktív subscription rekord stackinggel számolt valid_until-lal
new_sub = OrganizationSubscription(
org_id=org_id,
tier_id=tier_id,
valid_from=datetime.utcnow(),
valid_from=now,
valid_until=new_valid_until,
is_active=True
)
db.add(new_sub)
# Frissítsük a szervezet denormalizált mezőit is
org.subscription_expires_at = new_valid_until
logger.info(
f"Org subscription upgraded: org_id={org_id}, "
f"tier={tier.name} (id={tier_id}), "
f"max_vehicles={max_vehicles}, "
f"valid_until={new_valid_until.isoformat()}, "
f"duration_days={duration_days}, "
f"stacking={allow_stacking}, "
f"actor={actor_user_id}"
)
@@ -887,6 +1006,9 @@ async def upgrade_org_subscription(
"tier_id": tier_id,
"tier_name": tier.name,
"max_vehicles": max_vehicles,
"valid_until": new_valid_until.isoformat(),
"duration_days": duration_days,
"stacking_applied": allow_stacking and bool(existing_sub and existing_sub.valid_until and existing_sub.valid_until > now),
"message": f"Organization {org_id} upgraded to {tier.name}"
}