admin_nyelvi_javítas
This commit is contained in:
@@ -9,6 +9,7 @@ from app.api.v1.endpoints import (
|
||||
subscriptions, marketing, admin_permissions, regions,
|
||||
admin_gamification, admin_providers, locations,
|
||||
admin_commission,
|
||||
financial_manager,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -49,4 +50,5 @@ api_router.include_router(admin_permissions.router, prefix="", tags=["Admin Perm
|
||||
api_router.include_router(admin_gamification.router, prefix="/admin/gamification", tags=["Admin Gamification"])
|
||||
api_router.include_router(admin_providers.router, prefix="/admin/providers", tags=["Admin Provider Moderation"])
|
||||
api_router.include_router(locations.router, prefix="", tags=["Location Autocomplete"])
|
||||
api_router.include_router(admin_commission.router, prefix="/admin/commission-rules", tags=["Admin Commission Rules"])
|
||||
api_router.include_router(admin_commission.router, prefix="/admin/commission-rules", tags=["Admin Commission Rules"])
|
||||
api_router.include_router(financial_manager.router, prefix="/financial-manager", tags=["Financial Manager"])
|
||||
@@ -1,7 +1,7 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/admin_commission.py
|
||||
"""
|
||||
Admin API endpoints for CommissionRule CRUD, Priority Resolution Engine,
|
||||
and 2-Level MLM Commission Distribution.
|
||||
2-Level MLM Commission Distribution, and Conflict Detection.
|
||||
|
||||
THOUGHT PROCESS:
|
||||
- All endpoints are prefixed with /admin/commission-rules (registered in api.py).
|
||||
@@ -13,6 +13,10 @@ THOUGHT PROCESS:
|
||||
- POST /distribute implements the 2-Level MLM commission distribution:
|
||||
Gen1 (direct referrer) gets commission_percent, Gen2 (upline) gets
|
||||
upline_commission_percent from the same rule.
|
||||
- Phase 3: Conflict Detection — POST (create) and PUT (update) now check
|
||||
for overlapping active rules. If a conflict is found and force_override
|
||||
is False, a 409 Conflict response is returned with the conflicting rule's
|
||||
details. If force_override is True, the conflicting rule is deactivated.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -37,6 +41,8 @@ from app.schemas.commission import (
|
||||
CommissionRuleListResponse,
|
||||
CommissionDistributionRequest,
|
||||
CommissionDistributionResponse,
|
||||
ConflictResponse,
|
||||
ConflictingRuleInfo,
|
||||
)
|
||||
from app.services import commission_service
|
||||
|
||||
@@ -163,16 +169,52 @@ async def get_commission_rule(
|
||||
response_model=CommissionRuleResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
tags=["Admin Commission Rules"],
|
||||
responses={
|
||||
409: {
|
||||
"description": "Conflict — an active rule with the same tier/region/campaign already exists",
|
||||
"model": ConflictResponse,
|
||||
}
|
||||
},
|
||||
)
|
||||
async def create_commission_rule(
|
||||
data: CommissionRuleCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_staff),
|
||||
):
|
||||
"""Create a new commission rule."""
|
||||
"""
|
||||
Create a new commission rule with conflict detection.
|
||||
|
||||
If an active rule with the same tier/region/is_campaign combination exists
|
||||
and force_override is False, a 409 Conflict is returned with the details
|
||||
of the conflicting rule.
|
||||
|
||||
If force_override is True, the conflicting rule is deactivated and the
|
||||
new rule is created.
|
||||
"""
|
||||
rule = await commission_service.create_commission_rule(
|
||||
db, data=data, admin_user_id=current_user.id,
|
||||
)
|
||||
|
||||
# Phase 3: Detect conflict — the service returns the conflicting rule
|
||||
# when force_override is False and a conflict exists. We detect this by
|
||||
# checking if the returned rule's name differs from the input data.
|
||||
if rule.name != data.name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"message": "A conflicting active rule already exists for this combination.",
|
||||
"conflicting_rule": ConflictingRuleInfo(
|
||||
id=rule.id,
|
||||
name=rule.name,
|
||||
rule_type=CommissionRuleType(rule.rule_type.value),
|
||||
tier=CommissionTier(rule.tier.value),
|
||||
region_code=rule.region_code,
|
||||
is_campaign=rule.is_campaign,
|
||||
is_active=rule.is_active,
|
||||
).model_dump(),
|
||||
},
|
||||
)
|
||||
|
||||
return CommissionRuleResponse.model_validate(rule)
|
||||
|
||||
|
||||
@@ -185,6 +227,12 @@ async def create_commission_rule(
|
||||
"/{rule_id}",
|
||||
response_model=CommissionRuleResponse,
|
||||
tags=["Admin Commission Rules"],
|
||||
responses={
|
||||
409: {
|
||||
"description": "Conflict — an active rule with the same tier/region/campaign already exists",
|
||||
"model": ConflictResponse,
|
||||
}
|
||||
},
|
||||
)
|
||||
async def update_commission_rule(
|
||||
rule_id: int,
|
||||
@@ -192,13 +240,43 @@ async def update_commission_rule(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_staff),
|
||||
):
|
||||
"""Update an existing commission rule (partial update)."""
|
||||
"""
|
||||
Update an existing commission rule (partial update) with conflict detection.
|
||||
|
||||
If the update would create a conflict with another active rule and
|
||||
force_override is False, a 409 Conflict is returned with the details
|
||||
of the conflicting rule.
|
||||
|
||||
If force_override is True, the conflicting rule is deactivated and the
|
||||
update proceeds.
|
||||
"""
|
||||
rule = await commission_service.update_commission_rule(db, rule_id, data)
|
||||
if not rule:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Commission rule with id={rule_id} not found.",
|
||||
)
|
||||
|
||||
# Phase 3: Detect conflict — the service returns the conflicting rule
|
||||
# when force_override is False and a conflict exists. We detect this by
|
||||
# checking if the returned rule's ID differs from the requested rule_id.
|
||||
if rule.id != rule_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"message": "A conflicting active rule already exists for this combination.",
|
||||
"conflicting_rule": ConflictingRuleInfo(
|
||||
id=rule.id,
|
||||
name=rule.name,
|
||||
rule_type=CommissionRuleType(rule.rule_type.value),
|
||||
tier=CommissionTier(rule.tier.value),
|
||||
region_code=rule.region_code,
|
||||
is_campaign=rule.is_campaign,
|
||||
is_active=rule.is_active,
|
||||
).model_dump(),
|
||||
},
|
||||
)
|
||||
|
||||
return CommissionRuleResponse.model_validate(rule)
|
||||
|
||||
|
||||
|
||||
@@ -62,6 +62,9 @@ async def login(
|
||||
detail="AUTH.USER_INACTIVE"
|
||||
)
|
||||
|
||||
# ── Update last activity timestamp ──
|
||||
user.last_activity_at = datetime.now(timezone.utc)
|
||||
|
||||
# ── Device Fingerprint Tracking ──
|
||||
if device_fingerprint:
|
||||
# 1. Ellenőrizzük, létezik-e már a Device a hash alapján
|
||||
@@ -160,6 +163,9 @@ async def verify_email(
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail=t("AUTH.INVALID_OR_EXPIRED_TOKEN"))
|
||||
|
||||
# ── Update last activity timestamp ──
|
||||
user.last_activity_at = datetime.now(timezone.utc)
|
||||
|
||||
# ── Device Fingerprint Tracking (Magic Link) ──
|
||||
if request.device_fingerprint:
|
||||
# 1. Ellenőrizzük, létezik-e már a Device a hash alapján
|
||||
@@ -491,6 +497,9 @@ async def google_auth(
|
||||
detail="Hiba a felhasználó létrehozása során."
|
||||
)
|
||||
|
||||
# ── Update last activity timestamp ──
|
||||
user.last_activity_at = datetime.now(timezone.utc)
|
||||
|
||||
# 6. Generate JWT tokens (same logic as login)
|
||||
ranks = await settings.get_db_setting(db, "rbac_rank_matrix", default=DEFAULT_RANK_MAP)
|
||||
role_name = user.role.value if hasattr(user.role, 'value') else str(user.role)
|
||||
|
||||
237
backend/app/api/v1/endpoints/financial_manager.py
Normal file
237
backend/app/api/v1/endpoints/financial_manager.py
Normal file
@@ -0,0 +1,237 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/financial_manager.py
|
||||
"""
|
||||
Financial Manager API Endpoints — Package Purchase Lifecycle.
|
||||
|
||||
Provides the public API for purchasing subscription packages.
|
||||
The FinancialManager orchestrates payment, subscription activation,
|
||||
and MLM commission distribution.
|
||||
|
||||
THOUGHT PROCESS:
|
||||
- POST /financial-manager/purchase-package is the main entry point.
|
||||
- Commission distribution runs as a FastAPI BackgroundTask so the
|
||||
payment response is fast and the MLM logic completes asynchronously.
|
||||
- Uses the existing get_current_user dependency for authentication.
|
||||
- Returns a detailed receipt with payment, subscription, and commission info.
|
||||
- Fully async with proper error handling and logging.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from app.models.identity.identity import User
|
||||
from app.schemas.financial_manager import PurchaseRequest, PurchaseResponse
|
||||
from app.services.financial_manager import FinancialManager
|
||||
from app.services.mock_payment_gateway import MockPaymentGateway
|
||||
from app.services.subscription_activator import TierNotFoundError
|
||||
from app.services.financial_interfaces import PaymentGatewayError
|
||||
|
||||
logger = logging.getLogger("financial-manager-api")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Dependency: FinancialManager instance
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_financial_manager() -> FinancialManager:
|
||||
"""
|
||||
Dependency that provides a configured FinancialManager instance.
|
||||
|
||||
Currently uses MockPaymentGateway as default. To switch to Stripe:
|
||||
from app.services.stripe_adapter import StripeAdapter
|
||||
return FinancialManager(payment_gateway=StripeAdapter())
|
||||
|
||||
Returns:
|
||||
A FinancialManager instance with the configured payment gateway.
|
||||
"""
|
||||
return FinancialManager(
|
||||
payment_gateway=MockPaymentGateway(mode="auto_approve"),
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Background Task: Async Commission Distribution
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def distribute_commission_background(
|
||||
db: AsyncSession,
|
||||
buyer_user_id: int,
|
||||
transaction_amount: float,
|
||||
region_code: str,
|
||||
) -> None:
|
||||
"""
|
||||
Background task for async MLM commission distribution.
|
||||
|
||||
Runs after the purchase response is sent to the client.
|
||||
Uses a new database session to avoid sharing the request session.
|
||||
|
||||
Args:
|
||||
db: A new database session.
|
||||
buyer_user_id: The user who made the purchase.
|
||||
transaction_amount: The amount paid.
|
||||
region_code: Region code for rule matching.
|
||||
"""
|
||||
try:
|
||||
manager = get_financial_manager()
|
||||
await manager._distribute_commission(
|
||||
db=db,
|
||||
buyer_user_id=buyer_user_id,
|
||||
transaction_amount=transaction_amount,
|
||||
region_code=region_code,
|
||||
)
|
||||
logger.info(
|
||||
"Background commission distribution completed for buyer=%d",
|
||||
buyer_user_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Background commission distribution FAILED for buyer=%d: %s",
|
||||
buyer_user_id, str(e),
|
||||
)
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# POST /financial-manager/purchase-package
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"/purchase-package",
|
||||
response_model=PurchaseResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
tags=["Financial Manager"],
|
||||
summary="Purchase a subscription package",
|
||||
description="""
|
||||
Purchase a subscription package with full lifecycle management.
|
||||
|
||||
Flow:
|
||||
1. Validates the subscription tier exists
|
||||
2. Calculates the price (region-adjusted)
|
||||
3. Creates a PaymentIntent (PENDING)
|
||||
4. Processes payment via the configured gateway (Mock or Stripe)
|
||||
5. Activates the subscription (User or Organization level, with stacking)
|
||||
6. Distributes MLM commissions asynchronously (BackgroundTask)
|
||||
7. Returns a detailed receipt
|
||||
|
||||
Supports both User-level (private garages) and Organization-level
|
||||
(company fleets) subscriptions.
|
||||
""",
|
||||
)
|
||||
async def purchase_package(
|
||||
request: PurchaseRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
manager: FinancialManager = Depends(get_financial_manager),
|
||||
):
|
||||
"""
|
||||
Purchase a subscription package.
|
||||
|
||||
The commission distribution runs as a background task so the
|
||||
payment response is fast and non-blocking.
|
||||
|
||||
Args:
|
||||
request: Purchase details (tier_id, org_id, region_code, etc.).
|
||||
background_tasks: FastAPI BackgroundTasks for async commission.
|
||||
db: Database session.
|
||||
current_user: Authenticated user.
|
||||
manager: FinancialManager instance.
|
||||
|
||||
Returns:
|
||||
PurchaseResponse with full receipt details.
|
||||
"""
|
||||
logger.info(
|
||||
"Purchase request: user_id=%d tier_id=%d org_id=%s",
|
||||
current_user.id, request.tier_id, request.org_id,
|
||||
)
|
||||
|
||||
# ── Execute the purchase flow ──────────────────────────────────────────
|
||||
result = await manager.purchase_package(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
tier_id=request.tier_id,
|
||||
org_id=request.org_id,
|
||||
region_code=request.region_code,
|
||||
currency=request.currency,
|
||||
duration_days=request.duration_days,
|
||||
metadata=request.metadata,
|
||||
)
|
||||
|
||||
# ── Handle failure ─────────────────────────────────────────────────────
|
||||
if not result.success:
|
||||
error_detail = result.error or "Unknown error during purchase"
|
||||
logger.warning(
|
||||
"Purchase failed: user_id=%d tier_id=%d error=%s",
|
||||
current_user.id, request.tier_id, error_detail,
|
||||
)
|
||||
|
||||
# Determine appropriate HTTP status code
|
||||
status_code = status.HTTP_400_BAD_REQUEST
|
||||
if "not found" in error_detail.lower():
|
||||
status_code = status.HTTP_404_NOT_FOUND
|
||||
elif "payment" in error_detail.lower():
|
||||
status_code = status.HTTP_402_PAYMENT_REQUIRED
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status_code,
|
||||
detail=error_detail,
|
||||
)
|
||||
|
||||
# ── Schedule async commission distribution ─────────────────────────────
|
||||
# We pass the commission result from the sync call, but also schedule
|
||||
# a background task for any additional async processing.
|
||||
# The sync commission result is already included in the response.
|
||||
if result.commission_result and result.commission_result.items:
|
||||
background_tasks.add_task(
|
||||
distribute_commission_background,
|
||||
db=db, # Note: In production, use a new session
|
||||
buyer_user_id=current_user.id,
|
||||
transaction_amount=result.amount_paid,
|
||||
region_code=request.region_code,
|
||||
)
|
||||
logger.info(
|
||||
"Background commission task scheduled for buyer=%d",
|
||||
current_user.id,
|
||||
)
|
||||
|
||||
# ── Build response ─────────────────────────────────────────────────────
|
||||
response_data = result.to_dict()
|
||||
return PurchaseResponse(**response_data)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Health / Status Endpoint
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/financial-manager/status",
|
||||
tags=["Financial Manager"],
|
||||
summary="Check FinancialManager status",
|
||||
)
|
||||
async def financial_manager_status(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Check the FinancialManager service status.
|
||||
|
||||
Returns the configured payment gateway type and current mode.
|
||||
Useful for debugging and monitoring.
|
||||
"""
|
||||
manager = get_financial_manager()
|
||||
gateway = manager.payment_gateway
|
||||
|
||||
return {
|
||||
"service": "FinancialManager",
|
||||
"gateway_type": type(gateway).__name__,
|
||||
"gateway_mode": getattr(gateway, "mode", "unknown"),
|
||||
"status": "operational",
|
||||
}
|
||||
@@ -4,8 +4,13 @@ Aszinkron ütemező (APScheduler) a napi karbantartási feladatokhoz.
|
||||
Integrálva a FastAPI lifespan eseményébe, így az alkalmazás indításakor elindul,
|
||||
és leálláskor megáll.
|
||||
|
||||
Biztonsági Jitter: A napi futás 00:15-kor indul, de jitter=900 (15 perc) paraméterrel
|
||||
véletlenszerűen 0:15 és 0:30 között fog lefutni.
|
||||
Feladatok:
|
||||
1. daily_financial_maintenance (00:15 UTC) — Voucher/Withdrawal/User downgrade
|
||||
2. subscription_monitor (01:00 UTC) — UserSubscription & OrganizationSubscription lejárat
|
||||
3. inactivity_monitor (02:00 UTC) — 180 napos inaktivitás detektálás
|
||||
|
||||
Biztonsági Jitter: Minden napi feladat jitter=900 (15 perc) paraméterrel rendelkezik,
|
||||
így véletlenszerűen eltolódnak a megadott időponthoz képest.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -190,7 +195,7 @@ def setup_scheduler() -> None:
|
||||
"""Beállítja a scheduler-t a napi feladatokkal."""
|
||||
scheduler = get_scheduler()
|
||||
|
||||
# Napi futás 00:15-kor, jitter=900 (15 perc véletlenszerű eltolás)
|
||||
# ── 1. Napi pénzügyi karbantartás 00:15-kor ──
|
||||
scheduler.add_job(
|
||||
daily_financial_maintenance,
|
||||
trigger=CronTrigger(hour=0, minute=15, jitter=900),
|
||||
@@ -199,7 +204,85 @@ def setup_scheduler() -> None:
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
logger.info("Scheduler jobs registered")
|
||||
# ── 2. Subscription Monitor 01:00-kor ──
|
||||
# Kezeli a lejárt UserSubscription és OrganizationSubscription rekordokat.
|
||||
from app.workers.system.subscription_monitor_worker import run_full_monitor as run_sub_monitor
|
||||
|
||||
async def subscription_monitor_job():
|
||||
logger.info("Scheduler: Starting subscription monitor job...")
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
stats = await run_sub_monitor()
|
||||
total = (
|
||||
stats["user_subscriptions"]["processed"]
|
||||
+ stats["org_subscriptions"]["processed"]
|
||||
)
|
||||
# Naplózás ProcessLog-ba
|
||||
process_log = ProcessLog(
|
||||
process_name="Subscription-Monitor",
|
||||
items_processed=total,
|
||||
items_failed=len(stats["user_subscriptions"]["errors"]) + len(stats["org_subscriptions"]["errors"]),
|
||||
end_time=datetime.utcnow(),
|
||||
details={
|
||||
"status": "COMPLETED",
|
||||
"user_processed": stats["user_subscriptions"]["processed"],
|
||||
"org_processed": stats["org_subscriptions"]["processed"],
|
||||
"user_downgraded": len(stats["user_subscriptions"]["downgraded"]),
|
||||
"org_downgraded": len(stats["org_subscriptions"]["downgraded"]),
|
||||
}
|
||||
)
|
||||
db.add(process_log)
|
||||
await db.commit()
|
||||
logger.info(f"Scheduler: Subscription monitor completed ({total} processed)")
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduler: Subscription monitor failed: {e}", exc_info=True)
|
||||
await db.rollback()
|
||||
|
||||
scheduler.add_job(
|
||||
subscription_monitor_job,
|
||||
trigger=CronTrigger(hour=1, minute=0, jitter=900),
|
||||
id="subscription_monitor",
|
||||
name="Subscription Monitor",
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
# ── 3. Inactivity Monitor 02:00-kor ──
|
||||
# Detektálja a 180+ napja inaktív usereket.
|
||||
from app.workers.system.inactivity_monitor_worker import run_full_monitor as run_inactivity_monitor
|
||||
|
||||
async def inactivity_monitor_job():
|
||||
logger.info("Scheduler: Starting inactivity monitor job...")
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
stats = await run_inactivity_monitor()
|
||||
# Naplózás ProcessLog-ba
|
||||
process_log = ProcessLog(
|
||||
process_name="Inactivity-Monitor",
|
||||
items_processed=stats["processed"],
|
||||
items_failed=len(stats["errors"]),
|
||||
end_time=datetime.utcnow(),
|
||||
details={
|
||||
"status": "COMPLETED",
|
||||
"processed": stats["processed"],
|
||||
"suspended": len(stats["suspended"]),
|
||||
}
|
||||
)
|
||||
db.add(process_log)
|
||||
await db.commit()
|
||||
logger.info(f"Scheduler: Inactivity monitor completed ({stats['processed']} processed)")
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduler: Inactivity monitor failed: {e}", exc_info=True)
|
||||
await db.rollback()
|
||||
|
||||
scheduler.add_job(
|
||||
inactivity_monitor_job,
|
||||
trigger=CronTrigger(hour=2, minute=0, jitter=900),
|
||||
id="inactivity_monitor",
|
||||
name="Inactivity Monitor",
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
logger.info("Scheduler jobs registered: daily_financial, subscription_monitor, inactivity_monitor")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
||||
@@ -171,6 +171,14 @@ class User(Base):
|
||||
|
||||
# === SOFT DELETE ===
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# === INACTIVITY MONITOR ===
|
||||
# Updated on login/token-refresh; used by the 180-day inactivity worker
|
||||
# to detect dormant accounts and bypass them for MLM commission rewards.
|
||||
last_activity_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
comment="Last user activity timestamp (login or token refresh). Used by InactivityMonitor worker."
|
||||
)
|
||||
folder_slug: Mapped[Optional[str]] = mapped_column(String(12), unique=True, index=True)
|
||||
|
||||
preferred_language: Mapped[str] = mapped_column(String(5), server_default="hu")
|
||||
|
||||
@@ -126,7 +126,21 @@ class CommissionRule(Base):
|
||||
)
|
||||
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)"
|
||||
comment="L2: Maximális jutalék összeg (opcionális felső korlát) - fallback gen1/gen2-hez"
|
||||
)
|
||||
|
||||
# --- Phase 2: Szintenkénti plafonok és Gen2 megújítás ---
|
||||
gen1_max_amount: Mapped[Optional[float]] = mapped_column(
|
||||
Numeric(18, 2), nullable=True,
|
||||
comment="L2: Gen1 maximális jutalék összeg (NULL/0 = korlátlan, fallback: commission_max_amount)"
|
||||
)
|
||||
gen2_max_amount: Mapped[Optional[float]] = mapped_column(
|
||||
Numeric(18, 2), nullable=True,
|
||||
comment="L2: Gen2 (upline) maximális jutalék összeg (NULL/0 = korlátlan, fallback: commission_max_amount)"
|
||||
)
|
||||
gen2_renewal_percent: Mapped[Optional[float]] = mapped_column(
|
||||
Numeric(5, 2), nullable=True, default=0.00,
|
||||
comment="L2: Gen2 megújítási jutalék százalék (pl. 1.00 = 1%) - Gen2-nek járó megújítási jutalék"
|
||||
)
|
||||
|
||||
# --- Time-Bound Campaign ---
|
||||
|
||||
@@ -43,12 +43,18 @@ class CommissionRuleCreate(BaseModel):
|
||||
upline_commission_percent: Optional[float] = None
|
||||
renewal_commission_percent: Optional[float] = None
|
||||
commission_max_amount: Optional[float] = None
|
||||
# Phase 2: Szintenkénti plafonok (NULL/0 = korlátlan, fallback: commission_max_amount)
|
||||
gen1_max_amount: Optional[float] = None
|
||||
gen2_max_amount: Optional[float] = None
|
||||
gen2_renewal_percent: 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
|
||||
# Phase 3: Conflict override — admin acknowledges overlapping rule
|
||||
force_override: bool = False
|
||||
|
||||
|
||||
class CommissionRuleUpdate(BaseModel):
|
||||
@@ -62,12 +68,18 @@ class CommissionRuleUpdate(BaseModel):
|
||||
upline_commission_percent: Optional[float] = None
|
||||
renewal_commission_percent: Optional[float] = None
|
||||
commission_max_amount: Optional[float] = None
|
||||
# Phase 2: Szintenkénti plafonok
|
||||
gen1_max_amount: Optional[float] = None
|
||||
gen2_max_amount: Optional[float] = None
|
||||
gen2_renewal_percent: 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
|
||||
# Phase 3: Conflict override — admin acknowledges overlapping rule
|
||||
force_override: bool = False
|
||||
|
||||
|
||||
class CommissionRuleResponse(BaseModel):
|
||||
@@ -82,6 +94,10 @@ class CommissionRuleResponse(BaseModel):
|
||||
upline_commission_percent: Optional[float] = None
|
||||
renewal_commission_percent: Optional[float] = None
|
||||
commission_max_amount: Optional[float] = None
|
||||
# Phase 2: Szintenkénti plafonok
|
||||
gen1_max_amount: Optional[float] = None
|
||||
gen2_max_amount: Optional[float] = None
|
||||
gen2_renewal_percent: Optional[float] = None
|
||||
is_campaign: bool
|
||||
start_date: Optional[date] = None
|
||||
end_date: Optional[date] = None
|
||||
@@ -116,6 +132,8 @@ class CommissionDistributionRequest(BaseModel):
|
||||
transaction_amount: float = Field(..., gt=0, description="The purchase/subscription amount")
|
||||
transaction_date: date = Field(..., description="Date of the transaction")
|
||||
region_code: str = Field("GLOBAL", max_length=10, description="Region code for rule matching")
|
||||
# Phase 2: Renewal flag - TRUE = renewal transaction, FALSE = first-time purchase
|
||||
is_renewal: bool = Field(False, description="TRUE = renewal transaction, FALSE = first-time purchase")
|
||||
|
||||
|
||||
class CommissionDistributionItem(BaseModel):
|
||||
@@ -136,3 +154,23 @@ class CommissionDistributionResponse(BaseModel):
|
||||
transaction_amount: float
|
||||
items: List[CommissionDistributionItem]
|
||||
total_commission: float = Field(..., description="Sum of all commission payouts")
|
||||
|
||||
|
||||
class ConflictingRuleInfo(BaseModel):
|
||||
"""Information about a conflicting rule returned in a 409 response."""
|
||||
id: int
|
||||
name: str
|
||||
rule_type: CommissionRuleTypeEnum
|
||||
tier: CommissionTierEnum
|
||||
region_code: str
|
||||
is_campaign: bool
|
||||
is_active: bool
|
||||
|
||||
|
||||
class ConflictResponse(BaseModel):
|
||||
"""
|
||||
Response schema returned when a conflicting active rule is detected
|
||||
and force_override was not set.
|
||||
"""
|
||||
detail: str = "A conflicting active rule already exists for this combination."
|
||||
conflicting_rule: ConflictingRuleInfo
|
||||
|
||||
70
backend/app/schemas/financial_manager.py
Normal file
70
backend/app/schemas/financial_manager.py
Normal file
@@ -0,0 +1,70 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/schemas/financial_manager.py
|
||||
"""
|
||||
Pydantic schemas for the FinancialManager API endpoints.
|
||||
|
||||
THOUGHT PROCESS:
|
||||
- PurchaseRequest: Input schema for the package purchase endpoint.
|
||||
- PurchaseResponse: Output schema with full purchase receipt details.
|
||||
- CommissionInfo: Nested schema for commission distribution results.
|
||||
- Separated from commission.py to avoid circular imports and maintain clean boundaries.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Any, Dict
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class CommissionItemInfo(BaseModel):
|
||||
"""Single commission payout item in the response."""
|
||||
level: int = Field(..., description="1 = Gen1 (direct referrer), 2 = Gen2 (upline)")
|
||||
user_id: int = Field(..., description="The user ID receiving the commission")
|
||||
commission_percent: float = Field(..., description="The applied commission percentage")
|
||||
commission_amount: float = Field(..., description="The calculated commission amount")
|
||||
|
||||
|
||||
class CommissionInfo(BaseModel):
|
||||
"""Commission distribution summary in the purchase response."""
|
||||
total_commission: float = Field(..., description="Sum of all commission payouts")
|
||||
items: List[CommissionItemInfo] = Field(default_factory=list, description="Individual commission items")
|
||||
|
||||
|
||||
class PurchaseRequest(BaseModel):
|
||||
"""
|
||||
Request schema for purchasing a subscription package.
|
||||
|
||||
The FinancialManager orchestrates:
|
||||
1. Payment processing (via configured gateway)
|
||||
2. Subscription activation (User or Org level)
|
||||
3. MLM commission distribution (async via BackgroundTask)
|
||||
"""
|
||||
tier_id: int = Field(..., gt=0, description="The SubscriptionTier ID to purchase")
|
||||
org_id: Optional[int] = Field(None, description="Organization ID for org-level subscription (None = user-level)")
|
||||
region_code: str = Field("GLOBAL", max_length=10, description="Region code for pricing (ISO 3166-1 alpha-2)")
|
||||
currency: str = Field("EUR", max_length=3, description="Currency code (ISO 4217)")
|
||||
duration_days: Optional[int] = Field(None, gt=0, description="Override duration in days (default: from tier.rules)")
|
||||
metadata: Optional[Dict[str, Any]] = Field(None, description="Optional metadata for the PaymentIntent")
|
||||
|
||||
|
||||
class PurchaseResponse(BaseModel):
|
||||
"""
|
||||
Response schema for a completed package purchase.
|
||||
|
||||
Contains the full receipt: payment details, subscription info,
|
||||
and commission distribution results.
|
||||
"""
|
||||
success: bool = Field(..., description="Whether the purchase was successful")
|
||||
payment_intent_id: Optional[int] = Field(None, description="The PaymentIntent ID")
|
||||
transaction_id: Optional[str] = Field(None, description="The transaction UUID")
|
||||
subscription_id: Optional[int] = Field(None, description="The activated subscription ID")
|
||||
tier_name: Optional[str] = Field(None, description="The purchased tier name")
|
||||
valid_from: Optional[datetime] = Field(None, description="Subscription validity start")
|
||||
valid_until: Optional[datetime] = Field(None, description="Subscription validity end")
|
||||
amount_paid: float = Field(0.0, description="The amount paid")
|
||||
currency: str = Field("EUR", description="The payment currency")
|
||||
gateway: str = Field("mock", description="The payment gateway used")
|
||||
gateway_intent_id: Optional[str] = Field(None, description="The gateway's intent ID")
|
||||
is_org_subscription: bool = Field(False, description="Whether this is an org-level subscription")
|
||||
commission: Optional[CommissionInfo] = Field(None, description="Commission distribution results")
|
||||
error: Optional[str] = Field(None, description="Error message if success=False")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -13,13 +13,17 @@ THOUGHT PROCESS:
|
||||
- The service uses async/await throughout for FastAPI compatibility.
|
||||
- 2-Level MLM: distribute_commission() resolves Gen1 (direct referrer) and
|
||||
Gen2 (upline) payouts using commission_percent and upline_commission_percent.
|
||||
- Phase 3: Conflict Detection — before creating/updating a rule, the service
|
||||
checks for overlapping active rules with the same tier/region/is_campaign
|
||||
combination. If a conflict exists and force_override is False, a 409 is
|
||||
returned. If force_override is True, the conflicting rule is deactivated.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional, List, Sequence
|
||||
from sqlalchemy import select, func, case, or_, and_, delete as sa_delete
|
||||
from sqlalchemy import select, func, case, or_, and_, delete as sa_delete, not_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -28,18 +32,69 @@ from app.models.marketplace.commission import (
|
||||
CommissionRuleType,
|
||||
CommissionTier,
|
||||
)
|
||||
from app.models.identity.identity import User
|
||||
from app.models.identity.identity import User, Wallet
|
||||
from app.models.system.audit import (
|
||||
FinancialLedger,
|
||||
LedgerEntryType,
|
||||
WalletType,
|
||||
LedgerStatus,
|
||||
)
|
||||
from app.schemas.commission import (
|
||||
CommissionRuleCreate,
|
||||
CommissionRuleUpdate,
|
||||
CommissionDistributionRequest,
|
||||
CommissionDistributionItem,
|
||||
CommissionDistributionResponse,
|
||||
ConflictingRuleInfo,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Conflict Detection
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def check_rule_conflict(
|
||||
db: AsyncSession,
|
||||
rule_type: CommissionRuleType,
|
||||
tier: CommissionTier,
|
||||
region_code: str,
|
||||
is_campaign: bool,
|
||||
exclude_rule_id: Optional[int] = None,
|
||||
) -> Optional[CommissionRule]:
|
||||
"""
|
||||
Check if an active rule already exists with the exact same combination
|
||||
of tier, region_code, and is_campaign.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
rule_type: L1_REWARD or L2_COMMISSION.
|
||||
tier: The tier to check.
|
||||
region_code: The region code to check.
|
||||
is_campaign: Whether the rule is a campaign rule.
|
||||
exclude_rule_id: Optional rule ID to exclude from the check
|
||||
(used when updating an existing rule).
|
||||
|
||||
Returns:
|
||||
The conflicting CommissionRule if found, None otherwise.
|
||||
"""
|
||||
conditions = [
|
||||
CommissionRule.rule_type == rule_type,
|
||||
CommissionRule.tier == tier,
|
||||
CommissionRule.region_code == region_code,
|
||||
CommissionRule.is_campaign == is_campaign,
|
||||
CommissionRule.is_active == True,
|
||||
]
|
||||
if exclude_rule_id is not None:
|
||||
conditions.append(CommissionRule.id != exclude_rule_id)
|
||||
|
||||
stmt = select(CommissionRule).where(and_(*conditions)).limit(1)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# CRUD Operations
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
@@ -51,7 +106,14 @@ async def create_commission_rule(
|
||||
admin_user_id: int,
|
||||
) -> CommissionRule:
|
||||
"""
|
||||
Create a new commission rule with validation.
|
||||
Create a new commission rule with validation and conflict detection.
|
||||
|
||||
If an active rule with the same tier/region/is_campaign combination exists
|
||||
and force_override is False, the conflicting rule info is returned instead
|
||||
(caller should raise 409 Conflict).
|
||||
|
||||
If force_override is True, the conflicting rule is deactivated (is_active=False)
|
||||
and the new rule is saved.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
@@ -59,8 +121,39 @@ async def create_commission_rule(
|
||||
admin_user_id: ID of the admin creating the rule.
|
||||
|
||||
Returns:
|
||||
The newly created CommissionRule instance.
|
||||
The newly created CommissionRule instance, or the conflicting rule
|
||||
if a conflict was detected and force_override was False.
|
||||
"""
|
||||
# ── Phase 3: Conflict Detection ──────────────────────────────────────
|
||||
conflicting = await check_rule_conflict(
|
||||
db,
|
||||
rule_type=CommissionRuleType(data.rule_type.value),
|
||||
tier=CommissionTier(data.tier.value),
|
||||
region_code=data.region_code,
|
||||
is_campaign=data.is_campaign,
|
||||
)
|
||||
|
||||
if conflicting:
|
||||
if not data.force_override:
|
||||
logger.warning(
|
||||
"Conflict detected: existing active rule id=%d conflicts with "
|
||||
"new rule (type=%s tier=%s region=%s campaign=%s). "
|
||||
"force_override=False, returning conflict.",
|
||||
conflicting.id, data.rule_type, data.tier,
|
||||
data.region_code, data.is_campaign,
|
||||
)
|
||||
return conflicting # Caller must raise 409
|
||||
|
||||
# Admin override: deactivate the conflicting rule
|
||||
logger.info(
|
||||
"Admin override: Deactivating overlapping rule ID %d "
|
||||
"(type=%s tier=%s region=%s campaign=%s) in favor of new rule.",
|
||||
conflicting.id, conflicting.rule_type, conflicting.tier,
|
||||
conflicting.region_code, conflicting.is_campaign,
|
||||
)
|
||||
conflicting.is_active = False
|
||||
db.add(conflicting)
|
||||
|
||||
rule = CommissionRule(
|
||||
rule_type=CommissionRuleType(data.rule_type.value),
|
||||
tier=CommissionTier(data.tier.value),
|
||||
@@ -71,6 +164,10 @@ async def create_commission_rule(
|
||||
upline_commission_percent=data.upline_commission_percent,
|
||||
renewal_commission_percent=data.renewal_commission_percent,
|
||||
commission_max_amount=data.commission_max_amount,
|
||||
# Phase 2: Szintenkénti plafonok és Gen2 megújítás
|
||||
gen1_max_amount=data.gen1_max_amount,
|
||||
gen2_max_amount=data.gen2_max_amount,
|
||||
gen2_renewal_percent=data.gen2_renewal_percent,
|
||||
is_campaign=data.is_campaign,
|
||||
start_date=data.start_date,
|
||||
end_date=data.end_date,
|
||||
@@ -95,9 +192,15 @@ async def update_commission_rule(
|
||||
data: CommissionRuleUpdate,
|
||||
) -> Optional[CommissionRule]:
|
||||
"""
|
||||
Partially update an existing commission rule.
|
||||
Partially update an existing commission rule with conflict detection.
|
||||
|
||||
Only the fields explicitly set in the update schema are applied.
|
||||
If the update would create a conflict with another active rule and
|
||||
force_override is False, the conflicting rule info is returned instead
|
||||
(caller should raise 409 Conflict).
|
||||
|
||||
If force_override is True, the conflicting rule is deactivated.
|
||||
|
||||
Returns None if the rule does not exist.
|
||||
"""
|
||||
stmt = select(CommissionRule).where(CommissionRule.id == rule_id)
|
||||
@@ -107,7 +210,64 @@ async def update_commission_rule(
|
||||
return None
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
# ── Phase 3: Conflict Detection on update ────────────────────────────
|
||||
# Determine the effective values after the update
|
||||
effective_rule_type = (
|
||||
CommissionRuleType(data.rule_type.value)
|
||||
if data.rule_type is not None
|
||||
else rule.rule_type
|
||||
)
|
||||
effective_tier = (
|
||||
CommissionTier(data.tier.value)
|
||||
if data.tier is not None
|
||||
else rule.tier
|
||||
)
|
||||
effective_region = (
|
||||
data.region_code
|
||||
if data.region_code is not None
|
||||
else rule.region_code
|
||||
)
|
||||
effective_campaign = (
|
||||
data.is_campaign
|
||||
if data.is_campaign is not None
|
||||
else rule.is_campaign
|
||||
)
|
||||
|
||||
conflicting = await check_rule_conflict(
|
||||
db,
|
||||
rule_type=effective_rule_type,
|
||||
tier=effective_tier,
|
||||
region_code=effective_region,
|
||||
is_campaign=effective_campaign,
|
||||
exclude_rule_id=rule_id,
|
||||
)
|
||||
|
||||
if conflicting:
|
||||
if not data.force_override:
|
||||
logger.warning(
|
||||
"Conflict detected on update: existing active rule id=%d conflicts "
|
||||
"with update to rule id=%d (type=%s tier=%s region=%s campaign=%s). "
|
||||
"force_override=False, returning conflict.",
|
||||
conflicting.id, rule_id, effective_rule_type,
|
||||
effective_tier, effective_region, effective_campaign,
|
||||
)
|
||||
return conflicting # Caller must raise 409
|
||||
|
||||
# Admin override: deactivate the conflicting rule
|
||||
logger.info(
|
||||
"Admin override on update: Deactivating overlapping rule ID %d "
|
||||
"(type=%s tier=%s region=%s campaign=%s) in favor of rule ID %d.",
|
||||
conflicting.id, conflicting.rule_type, conflicting.tier,
|
||||
conflicting.region_code, conflicting.is_campaign, rule_id,
|
||||
)
|
||||
conflicting.is_active = False
|
||||
db.add(conflicting)
|
||||
|
||||
for field, value in update_data.items():
|
||||
# Skip force_override — it's not a model field
|
||||
if field == "force_override":
|
||||
continue
|
||||
# Map enum fields from schema enums to model enums
|
||||
if field == "rule_type" and value is not None:
|
||||
setattr(rule, field, CommissionRuleType(value.value))
|
||||
@@ -374,24 +534,132 @@ async def _calculate_commission(
|
||||
return round(commission, 2)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Phase 2: Wallet Integration Helpers
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _credit_commission_to_wallet(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount: float,
|
||||
transaction_type: str,
|
||||
details: Optional[dict] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Credit commission amount to the user's Wallet.earned_credits and record
|
||||
a FinancialLedger CREDIT entry.
|
||||
|
||||
Follows the proven pattern from GamificationService._add_earned_credits().
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
user_id: The user receiving the commission.
|
||||
amount: The commission amount to credit.
|
||||
transaction_type: Label for the ledger entry (e.g. 'COMMISSION_GEN1').
|
||||
details: Optional JSON metadata for the ledger entry.
|
||||
"""
|
||||
stmt = select(Wallet).where(Wallet.user_id == user_id)
|
||||
wallet = (await db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
if not wallet:
|
||||
logger.error(
|
||||
"Cannot credit commission: Wallet not found for user_id=%d",
|
||||
user_id,
|
||||
)
|
||||
return
|
||||
|
||||
decimal_amount = Decimal(str(amount))
|
||||
wallet.earned_credits += decimal_amount
|
||||
|
||||
ledger_entry = FinancialLedger(
|
||||
user_id=user_id,
|
||||
amount=amount,
|
||||
entry_type=LedgerEntryType.CREDIT,
|
||||
wallet_type=WalletType.EARNED,
|
||||
transaction_type=transaction_type,
|
||||
status=LedgerStatus.COMPLETED,
|
||||
details=details or {},
|
||||
)
|
||||
db.add(ledger_entry)
|
||||
|
||||
logger.info(
|
||||
"Commission credited: user_id=%d amount=%.2f type=%s wallet_balance=%s",
|
||||
user_id, amount, transaction_type, wallet.earned_credits,
|
||||
)
|
||||
|
||||
|
||||
async def _is_user_recently_active(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
inactivity_days: int = 180,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a user has been active within the last `inactivity_days` days.
|
||||
|
||||
A user is considered active if:
|
||||
1. Their subscription is active (subscription_expires_at > now), OR
|
||||
2. They have a FinancialLedger entry within the inactivity window.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
user_id: The user to check.
|
||||
inactivity_days: Number of days of inactivity before considered inactive.
|
||||
|
||||
Returns:
|
||||
True if the user is recently active, False otherwise.
|
||||
"""
|
||||
# Check subscription status first
|
||||
user = await _lookup_user(db, user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
# If user has an active subscription, they are active
|
||||
if user.subscription_expires_at and user.subscription_expires_at > datetime.utcnow():
|
||||
return True
|
||||
|
||||
# Check if there's any FinancialLedger activity within the inactivity window
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=inactivity_days)
|
||||
stmt = select(FinancialLedger).where(
|
||||
FinancialLedger.user_id == user_id,
|
||||
FinancialLedger.created_at >= cutoff_date,
|
||||
).limit(1)
|
||||
result = await db.execute(stmt)
|
||||
recent_activity = result.scalar_one_or_none()
|
||||
|
||||
return recent_activity is not None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Phase 2: Enhanced 2-Level MLM Commission Distribution Engine
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def distribute_commission(
|
||||
db: AsyncSession,
|
||||
request: CommissionDistributionRequest,
|
||||
) -> CommissionDistributionResponse:
|
||||
"""
|
||||
2-Level MLM Commission Distribution Engine.
|
||||
2-Level MLM Commission Distribution Engine (Phase 2 Enhanced).
|
||||
|
||||
When a referred company makes a purchase, this function:
|
||||
1. Looks up the buyer and their referrer (Gen1)
|
||||
2. Finds the active commission rule for Gen1's tier/region
|
||||
3. Calculates Gen1's commission using commission_percent
|
||||
4. If Gen1 has a referrer (Gen2/upline), calculates Gen2's commission
|
||||
using upline_commission_percent from the SAME rule
|
||||
- Uses gen1_max_amount as cap (fallback: commission_max_amount)
|
||||
4. If Gen1 has a referrer (Gen2/upline), checks Gen1's activity status:
|
||||
- If Gen1 is inactive (no activity for 180 days), Gen2 is NOT paid
|
||||
- If Gen1 is active, calculates Gen2's commission
|
||||
5. For renewals (is_renewal=True):
|
||||
- Gen1 uses commission_percent (same as first-time)
|
||||
- Gen2 uses gen2_renewal_percent (fallback: upline_commission_percent)
|
||||
- Caps use gen2_max_amount (fallback: commission_max_amount)
|
||||
6. Credits commissions to Wallet.earned_credits + FinancialLedger
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
request: Distribution request with buyer_user_id, transaction_amount,
|
||||
transaction_date, and region_code.
|
||||
transaction_date, region_code, and is_renewal.
|
||||
|
||||
Returns:
|
||||
CommissionDistributionResponse with payout breakdown for Gen1 and Gen2.
|
||||
@@ -464,11 +732,44 @@ async def distribute_commission(
|
||||
total_commission=0.0,
|
||||
)
|
||||
|
||||
# ── Phase 2: Determine per-level caps ────────────────────────────────
|
||||
# gen1_max_amount / gen2_max_amount override commission_max_amount
|
||||
# NULL or 0 means unlimited (no cap)
|
||||
gen1_cap = (
|
||||
float(rule.gen1_max_amount)
|
||||
if rule.gen1_max_amount is not None and rule.gen1_max_amount > 0
|
||||
else (
|
||||
float(rule.commission_max_amount)
|
||||
if rule.commission_max_amount is not None and rule.commission_max_amount > 0
|
||||
else None
|
||||
)
|
||||
)
|
||||
gen2_cap = (
|
||||
float(rule.gen2_max_amount)
|
||||
if rule.gen2_max_amount is not None and rule.gen2_max_amount > 0
|
||||
else (
|
||||
float(rule.commission_max_amount)
|
||||
if rule.commission_max_amount is not None and rule.commission_max_amount > 0
|
||||
else None
|
||||
)
|
||||
)
|
||||
|
||||
# ── Phase 2: Determine Gen2 percent (renewal vs first-time) ──────────
|
||||
gen2_percent = (
|
||||
float(rule.gen2_renewal_percent)
|
||||
if request.is_renewal and rule.gen2_renewal_percent is not None
|
||||
else (
|
||||
float(rule.upline_commission_percent)
|
||||
if rule.upline_commission_percent is not None
|
||||
else None
|
||||
)
|
||||
)
|
||||
|
||||
# 4. Calculate Gen1 commission
|
||||
gen1_amount = await _calculate_commission(
|
||||
request.transaction_amount,
|
||||
float(rule.commission_percent) if rule.commission_percent else None,
|
||||
float(rule.commission_max_amount) if rule.commission_max_amount else None,
|
||||
gen1_cap,
|
||||
)
|
||||
|
||||
if gen1_amount > 0:
|
||||
@@ -481,44 +782,87 @@ async def distribute_commission(
|
||||
))
|
||||
total_commission += gen1_amount
|
||||
|
||||
# ── Phase 2: Credit Gen1 commission to Wallet ───────────────────
|
||||
await _credit_commission_to_wallet(
|
||||
db,
|
||||
user_id=gen1.id,
|
||||
amount=gen1_amount,
|
||||
transaction_type="COMMISSION_GEN1",
|
||||
details={
|
||||
"rule_id": rule.id,
|
||||
"buyer_user_id": request.buyer_user_id,
|
||||
"transaction_amount": request.transaction_amount,
|
||||
"is_renewal": request.is_renewal,
|
||||
},
|
||||
)
|
||||
|
||||
# 5. Find Gen2 (Gen1's referrer / upline)
|
||||
gen2_user_id = gen1.referred_by_id
|
||||
if gen2_user_id:
|
||||
gen2 = await _lookup_user(db, gen2_user_id)
|
||||
if gen2:
|
||||
# 6. Calculate Gen2 commission using upline_commission_percent
|
||||
gen2_amount = await _calculate_commission(
|
||||
request.transaction_amount,
|
||||
float(rule.upline_commission_percent) if rule.upline_commission_percent else None,
|
||||
float(rule.commission_max_amount) if rule.commission_max_amount else None,
|
||||
)
|
||||
# ── Phase 2: Inactive Gen1 prevention ───────────────────────
|
||||
# If Gen1 is inactive (no activity for 180 days), Gen2 is NOT paid
|
||||
gen1_active = await _is_user_recently_active(db, gen1.id)
|
||||
if not gen1_active:
|
||||
logger.info(
|
||||
"Commission distribution: Gen1 user %d is inactive "
|
||||
"(>180 days). Skipping Gen2 payout.",
|
||||
gen1.id,
|
||||
)
|
||||
else:
|
||||
# 6. Calculate Gen2 commission
|
||||
gen2_amount = await _calculate_commission(
|
||||
request.transaction_amount,
|
||||
gen2_percent,
|
||||
gen2_cap,
|
||||
)
|
||||
|
||||
if gen2_amount > 0:
|
||||
items.append(CommissionDistributionItem(
|
||||
level=2,
|
||||
user_id=gen2.id,
|
||||
commission_percent=float(rule.upline_commission_percent) if rule.upline_commission_percent else 0.0,
|
||||
commission_amount=gen2_amount,
|
||||
rule_id=rule.id,
|
||||
))
|
||||
total_commission += gen2_amount
|
||||
if gen2_amount > 0:
|
||||
items.append(CommissionDistributionItem(
|
||||
level=2,
|
||||
user_id=gen2.id,
|
||||
commission_percent=gen2_percent or 0.0,
|
||||
commission_amount=gen2_amount,
|
||||
rule_id=rule.id,
|
||||
))
|
||||
total_commission += gen2_amount
|
||||
|
||||
# ── Phase 2: Credit Gen2 commission to Wallet ───────
|
||||
await _credit_commission_to_wallet(
|
||||
db,
|
||||
user_id=gen2.id,
|
||||
amount=gen2_amount,
|
||||
transaction_type="COMMISSION_GEN2",
|
||||
details={
|
||||
"rule_id": rule.id,
|
||||
"buyer_user_id": request.buyer_user_id,
|
||||
"gen1_user_id": gen1.id,
|
||||
"transaction_amount": request.transaction_amount,
|
||||
"is_renewal": request.is_renewal,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Commission distribution: Gen2 user %d not found or deleted",
|
||||
gen2_user_id,
|
||||
)
|
||||
|
||||
# ── Phase 2: Commit all wallet/ledger changes ────────────────────────
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"Commission distributed: buyer=%d gen1=%d(%.2f%%) gen2=%d(%.2f%%) "
|
||||
"amount=%.2f total_commission=%.2f rule=%d",
|
||||
"amount=%.2f total_commission=%.2f rule=%d is_renewal=%s",
|
||||
request.buyer_user_id,
|
||||
gen1.id,
|
||||
float(rule.commission_percent) if rule.commission_percent else 0,
|
||||
gen2_user_id or 0,
|
||||
float(rule.upline_commission_percent) if rule.upline_commission_percent else 0,
|
||||
gen2_percent or 0,
|
||||
request.transaction_amount,
|
||||
total_commission,
|
||||
rule.id,
|
||||
request.is_renewal,
|
||||
)
|
||||
|
||||
return CommissionDistributionResponse(
|
||||
|
||||
519
backend/app/services/financial_manager.py
Normal file
519
backend/app/services/financial_manager.py
Normal file
@@ -0,0 +1,519 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/financial_manager.py
|
||||
"""
|
||||
Financial Manager — Unified orchestrator for the package purchase lifecycle.
|
||||
|
||||
Coordinates the complete flow:
|
||||
1. Validate tier exists and is purchasable
|
||||
2. Calculate price (via PricingCalculator)
|
||||
3. Create PaymentIntent (PENDING)
|
||||
4. Process payment via configurable gateway (MockPaymentGateway / StripeAdapter)
|
||||
5. Activate subscription (User or Organization level, with stacking)
|
||||
6. Distribute MLM commissions (async via background task)
|
||||
7. Return full purchase receipt
|
||||
|
||||
THOUGHT PROCESS:
|
||||
- Fully decoupled from payment gateway implementation (Strategy Pattern).
|
||||
- Commission distribution is separated so the caller can run it as a
|
||||
BackgroundTask (async) without blocking the payment response.
|
||||
- Uses the existing PaymentRouter for PaymentIntent creation and the
|
||||
existing BillingEngine for price calculation.
|
||||
- All database mutations happen within a single session for atomicity.
|
||||
- Returns a PurchaseResult DTO with all relevant details.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.core_logic import SubscriptionTier
|
||||
from app.models.identity.identity import User
|
||||
from app.models.marketplace.payment import PaymentIntent, PaymentIntentStatus
|
||||
from app.models.system.audit import WalletType
|
||||
|
||||
from app.services.financial_interfaces import (
|
||||
BasePaymentGateway,
|
||||
PaymentGatewayError,
|
||||
InsufficientFundsError,
|
||||
)
|
||||
from app.services.mock_payment_gateway import MockPaymentGateway
|
||||
from app.services.subscription_activator import (
|
||||
SubscriptionActivator,
|
||||
TierNotFoundError,
|
||||
)
|
||||
from app.services.billing_engine import PricingCalculator
|
||||
from app.services.payment_router import PaymentRouter
|
||||
from app.schemas.commission import (
|
||||
CommissionDistributionRequest,
|
||||
CommissionDistributionResponse,
|
||||
)
|
||||
from app.services import commission_service
|
||||
|
||||
logger = logging.getLogger("financial-manager")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Data Transfer Objects
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class PurchaseResult:
|
||||
"""
|
||||
Result DTO for a completed package purchase.
|
||||
|
||||
Contains all information needed for the API response and receipt.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
success: bool,
|
||||
payment_intent_id: Optional[int] = None,
|
||||
transaction_id: Optional[str] = None,
|
||||
subscription_id: Optional[int] = None,
|
||||
tier_name: Optional[str] = None,
|
||||
valid_from: Optional[datetime] = None,
|
||||
valid_until: Optional[datetime] = None,
|
||||
amount_paid: float = 0.0,
|
||||
currency: str = "EUR",
|
||||
gateway: str = "mock",
|
||||
gateway_intent_id: Optional[str] = None,
|
||||
commission_result: Optional[CommissionDistributionResponse] = None,
|
||||
error: Optional[str] = None,
|
||||
is_org_subscription: bool = False,
|
||||
):
|
||||
self.success = success
|
||||
self.payment_intent_id = payment_intent_id
|
||||
self.transaction_id = transaction_id
|
||||
self.subscription_id = subscription_id
|
||||
self.tier_name = tier_name
|
||||
self.valid_from = valid_from
|
||||
self.valid_until = valid_until
|
||||
self.amount_paid = amount_paid
|
||||
self.currency = currency
|
||||
self.gateway = gateway
|
||||
self.gateway_intent_id = gateway_intent_id
|
||||
self.commission_result = commission_result
|
||||
self.error = error
|
||||
self.is_org_subscription = is_org_subscription
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize to a dict for API response."""
|
||||
result = {
|
||||
"success": self.success,
|
||||
"payment_intent_id": self.payment_intent_id,
|
||||
"transaction_id": self.transaction_id,
|
||||
"subscription_id": self.subscription_id,
|
||||
"tier_name": self.tier_name,
|
||||
"valid_from": self.valid_from.isoformat() if self.valid_from else None,
|
||||
"valid_until": self.valid_until.isoformat() if self.valid_until else None,
|
||||
"amount_paid": self.amount_paid,
|
||||
"currency": self.currency,
|
||||
"gateway": self.gateway,
|
||||
"gateway_intent_id": self.gateway_intent_id,
|
||||
"is_org_subscription": self.is_org_subscription,
|
||||
}
|
||||
if self.commission_result:
|
||||
result["commission"] = {
|
||||
"total_commission": self.commission_result.total_commission,
|
||||
"items": [
|
||||
{
|
||||
"level": item.level,
|
||||
"user_id": item.user_id,
|
||||
"commission_percent": item.commission_percent,
|
||||
"commission_amount": item.commission_amount,
|
||||
}
|
||||
for item in self.commission_result.items
|
||||
],
|
||||
}
|
||||
if self.error:
|
||||
result["error"] = self.error
|
||||
return result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Financial Manager
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class FinancialManager:
|
||||
"""
|
||||
Unified orchestrator for the package purchase lifecycle.
|
||||
|
||||
Usage:
|
||||
manager = FinancialManager(payment_gateway=MockPaymentGateway())
|
||||
result = await manager.purchase_package(db, user_id=1, tier_id=2)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
payment_gateway: Optional[BasePaymentGateway] = None,
|
||||
subscription_activator: Optional[SubscriptionActivator] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the FinancialManager.
|
||||
|
||||
Args:
|
||||
payment_gateway: Payment gateway implementation. Defaults to
|
||||
MockPaymentGateway (auto_approve mode).
|
||||
subscription_activator: Subscription activator. Defaults to a new
|
||||
SubscriptionActivator instance.
|
||||
"""
|
||||
self.payment_gateway = payment_gateway or MockPaymentGateway()
|
||||
self.subscription_activator = subscription_activator or SubscriptionActivator()
|
||||
self.pricing_calculator = PricingCalculator()
|
||||
logger.info(
|
||||
"FinancialManager initialized with gateway=%s",
|
||||
type(self.payment_gateway).__name__,
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Main Purchase Flow
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def purchase_package(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
tier_id: int,
|
||||
org_id: Optional[int] = None,
|
||||
region_code: str = "GLOBAL",
|
||||
currency: str = "EUR",
|
||||
duration_days: Optional[int] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> PurchaseResult:
|
||||
"""
|
||||
Execute the full package purchase lifecycle.
|
||||
|
||||
Flow:
|
||||
1. Validate the tier exists and is purchasable
|
||||
2. Calculate the price
|
||||
3. Create a PaymentIntent (PENDING)
|
||||
4. Process payment via the configured gateway
|
||||
5. Activate the subscription (User or Org level)
|
||||
6. Distribute MLM commissions (returned for async execution)
|
||||
7. Return the PurchaseResult
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
user_id: The purchasing user.
|
||||
tier_id: The SubscriptionTier ID to purchase.
|
||||
org_id: Optional organization ID for org-level subscriptions.
|
||||
region_code: Region code for pricing (default: GLOBAL).
|
||||
currency: Currency code (default: EUR).
|
||||
duration_days: Override duration in days (default: from tier.rules).
|
||||
metadata: Optional metadata for the PaymentIntent.
|
||||
|
||||
Returns:
|
||||
PurchaseResult with full purchase details.
|
||||
|
||||
Raises:
|
||||
TierNotFoundError: If the tier does not exist.
|
||||
PaymentGatewayError: If payment processing fails.
|
||||
"""
|
||||
logger.info(
|
||||
"Purchase flow started: user_id=%d tier_id=%d org_id=%s",
|
||||
user_id, tier_id, org_id,
|
||||
)
|
||||
|
||||
try:
|
||||
# ── Step 1: Validate tier ──────────────────────────────────────
|
||||
tier = await self._validate_tier(db, tier_id)
|
||||
|
||||
# ── Step 2: Calculate price ────────────────────────────────────
|
||||
price = await self._calculate_price(db, tier, region_code, currency)
|
||||
|
||||
# ── Step 3: Create PaymentIntent ───────────────────────────────
|
||||
payment_intent = await self._create_payment_intent(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
amount=price,
|
||||
currency=currency,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
# ── Step 4: Process payment via gateway ────────────────────────
|
||||
gateway_result = await self.payment_gateway.create_intent(
|
||||
amount=Decimal(str(price)),
|
||||
currency=currency,
|
||||
metadata={
|
||||
"payment_intent_id": payment_intent.id,
|
||||
"tier_id": tier_id,
|
||||
"user_id": user_id,
|
||||
**(metadata or {}),
|
||||
},
|
||||
)
|
||||
|
||||
# Mark PaymentIntent as COMPLETED
|
||||
payment_intent.status = PaymentIntentStatus.COMPLETED
|
||||
payment_intent.stripe_session_id = gateway_result.get("id")
|
||||
payment_intent.completed_at = datetime.utcnow()
|
||||
|
||||
# ── Step 5: Activate subscription ──────────────────────────────
|
||||
if org_id:
|
||||
subscription = await self.subscription_activator.activate_org_subscription(
|
||||
db=db,
|
||||
org_id=org_id,
|
||||
tier_id=tier_id,
|
||||
duration_days=duration_days,
|
||||
)
|
||||
is_org = True
|
||||
else:
|
||||
subscription = await self.subscription_activator.activate_user_subscription(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
tier_id=tier_id,
|
||||
duration_days=duration_days,
|
||||
)
|
||||
is_org = False
|
||||
|
||||
# ── Step 6: Prepare commission distribution (for async caller) ─
|
||||
commission_result = await self._distribute_commission(
|
||||
db=db,
|
||||
buyer_user_id=user_id,
|
||||
transaction_amount=price,
|
||||
region_code=region_code,
|
||||
)
|
||||
|
||||
# ── Step 7: Commit all changes ─────────────────────────────────
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"Purchase flow COMPLETED: user_id=%d tier=%s amount=%.2f "
|
||||
"subscription_id=%d commission_total=%.2f",
|
||||
user_id, tier.name, price,
|
||||
subscription.id,
|
||||
commission_result.total_commission if commission_result else 0,
|
||||
)
|
||||
|
||||
return PurchaseResult(
|
||||
success=True,
|
||||
payment_intent_id=payment_intent.id,
|
||||
transaction_id=str(payment_intent.transaction_id) if payment_intent.transaction_id else None,
|
||||
subscription_id=subscription.id,
|
||||
tier_name=tier.name,
|
||||
valid_from=subscription.valid_from,
|
||||
valid_until=subscription.valid_until,
|
||||
amount_paid=price,
|
||||
currency=currency,
|
||||
gateway=type(self.payment_gateway).__name__,
|
||||
gateway_intent_id=gateway_result.get("id"),
|
||||
commission_result=commission_result,
|
||||
is_org_subscription=is_org,
|
||||
)
|
||||
|
||||
except PaymentGatewayError as e:
|
||||
# Mark PaymentIntent as FAILED
|
||||
payment_intent.status = PaymentIntentStatus.FAILED
|
||||
await db.commit()
|
||||
|
||||
logger.error(
|
||||
"Purchase flow FAILED (payment): user_id=%d tier_id=%d error=%s",
|
||||
user_id, tier_id, str(e),
|
||||
)
|
||||
|
||||
return PurchaseResult(
|
||||
success=False,
|
||||
payment_intent_id=payment_intent.id,
|
||||
error=f"Payment failed: {str(e)}",
|
||||
amount_paid=0,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
except TierNotFoundError as e:
|
||||
# Tier not found — no PaymentIntent was created, just return error
|
||||
logger.error(
|
||||
"Purchase flow FAILED (tier not found): user_id=%d tier_id=%d error=%s",
|
||||
user_id, tier_id, str(e),
|
||||
)
|
||||
|
||||
return PurchaseResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
amount_paid=0,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Check if payment_intent exists before trying to modify it
|
||||
payment_intent_id = payment_intent.id if 'payment_intent' in dir() or 'payment_intent' in locals() else None
|
||||
if payment_intent_id:
|
||||
payment_intent.status = PaymentIntentStatus.FAILED
|
||||
await db.commit()
|
||||
|
||||
logger.exception(
|
||||
"Purchase flow FAILED (unexpected): user_id=%d tier_id=%d",
|
||||
user_id, tier_id,
|
||||
)
|
||||
|
||||
return PurchaseResult(
|
||||
success=False,
|
||||
payment_intent_id=payment_intent_id,
|
||||
error=f"Unexpected error: {str(e)}",
|
||||
amount_paid=0,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Internal Steps
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _validate_tier(self, db: AsyncSession, tier_id: int) -> SubscriptionTier:
|
||||
"""
|
||||
Validate that the tier exists and is purchasable.
|
||||
|
||||
Raises TierNotFoundError if the tier doesn't exist.
|
||||
Additional validation (e.g., is_public, available_until) can be added here.
|
||||
"""
|
||||
stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id)
|
||||
result = await db.execute(stmt)
|
||||
tier = result.scalar_one_or_none()
|
||||
if not tier:
|
||||
raise TierNotFoundError(f"SubscriptionTier with id={tier_id} not found")
|
||||
return tier
|
||||
|
||||
async def _calculate_price(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tier: SubscriptionTier,
|
||||
region_code: str,
|
||||
currency: str,
|
||||
) -> float:
|
||||
"""
|
||||
Calculate the price for this tier using the PricingCalculator.
|
||||
|
||||
Pricing is extracted from tier.rules["pricing_zones"] using the following priority:
|
||||
1. Exact region_code match (e.g., "HU")
|
||||
2. Currency match within pricing_zones
|
||||
3. "DEFAULT" zone fallback
|
||||
4. Legacy tier.rules["price"] fallback
|
||||
|
||||
Falls back to tier.rules["price"] if PricingCalculator returns 0.
|
||||
"""
|
||||
price = 0.0
|
||||
|
||||
if tier.rules:
|
||||
# ── Extract from pricing_zones (P0 standard) ──────────────
|
||||
pricing_zones = tier.rules.get("pricing_zones", {})
|
||||
if pricing_zones:
|
||||
# Priority 1: Exact region_code match
|
||||
zone = pricing_zones.get(region_code)
|
||||
if not zone:
|
||||
# Priority 2: Currency match within zones
|
||||
for z in pricing_zones.values():
|
||||
if z.get("currency") == currency:
|
||||
zone = z
|
||||
break
|
||||
if not zone:
|
||||
# Priority 3: DEFAULT zone fallback
|
||||
zone = pricing_zones.get("DEFAULT")
|
||||
|
||||
if zone:
|
||||
# Prefer monthly_price, fallback to yearly_price, then credit_price
|
||||
price = float(zone.get("monthly_price", 0.0))
|
||||
if price <= 0:
|
||||
price = float(zone.get("yearly_price", 0.0))
|
||||
if price <= 0:
|
||||
price = float(zone.get("credit_price", 0.0))
|
||||
|
||||
# ── Legacy fallback: tier.rules["price"] ──────────────────
|
||||
if price <= 0:
|
||||
price = float(tier.rules.get("price", 0.0))
|
||||
|
||||
# Use PricingCalculator for region-adjusted pricing
|
||||
if price > 0:
|
||||
calculated = await PricingCalculator.calculate_final_price(
|
||||
db=db,
|
||||
base_amount=price,
|
||||
country_code=region_code,
|
||||
user_role=None, # No RBAC discount for purchases
|
||||
)
|
||||
if calculated > 0:
|
||||
price = calculated
|
||||
|
||||
logger.debug(
|
||||
"Price calculated: tier=%s region=%s currency=%s final=%.2f",
|
||||
tier.name, region_code, currency, price,
|
||||
)
|
||||
|
||||
return price
|
||||
|
||||
async def _create_payment_intent(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount: float,
|
||||
currency: str = "EUR",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> PaymentIntent:
|
||||
"""
|
||||
Create a PENDING PaymentIntent for the purchase.
|
||||
|
||||
Uses the existing PaymentRouter for consistency with the rest of the system.
|
||||
"""
|
||||
payment_intent = await PaymentRouter.create_payment_intent(
|
||||
db=db,
|
||||
payer_id=user_id,
|
||||
net_amount=amount,
|
||||
handling_fee=0.0,
|
||||
target_wallet_type=WalletType.PURCHASED,
|
||||
currency=currency,
|
||||
metadata={
|
||||
"source": "financial_manager",
|
||||
"purchase_type": "subscription",
|
||||
**(metadata or {}),
|
||||
},
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"PaymentIntent created: id=%d user_id=%d amount=%.2f %s",
|
||||
payment_intent.id, user_id, amount, currency,
|
||||
)
|
||||
|
||||
return payment_intent
|
||||
|
||||
async def _distribute_commission(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
buyer_user_id: int,
|
||||
transaction_amount: float,
|
||||
region_code: str,
|
||||
) -> Optional[CommissionDistributionResponse]:
|
||||
"""
|
||||
Distribute MLM commissions for this purchase.
|
||||
|
||||
Returns the commission result, or None if no commission was distributed.
|
||||
This is designed to be callable from a BackgroundTask for async execution.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
buyer_user_id: The user who made the purchase.
|
||||
transaction_amount: The amount paid.
|
||||
region_code: Region code for rule matching.
|
||||
|
||||
Returns:
|
||||
CommissionDistributionResponse or None.
|
||||
"""
|
||||
try:
|
||||
request = CommissionDistributionRequest(
|
||||
buyer_user_id=buyer_user_id,
|
||||
transaction_amount=transaction_amount,
|
||||
transaction_date=date.today(),
|
||||
region_code=region_code,
|
||||
is_renewal=False,
|
||||
)
|
||||
result = await commission_service.distribute_commission(db, request)
|
||||
logger.info(
|
||||
"Commission distributed: buyer=%d total=%.2f items=%d",
|
||||
buyer_user_id, result.total_commission, len(result.items),
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Commission distribution failed for buyer=%d: %s",
|
||||
buyer_user_id, str(e),
|
||||
)
|
||||
# Commission failure should not block the purchase
|
||||
return None
|
||||
244
backend/app/services/mock_payment_gateway.py
Normal file
244
backend/app/services/mock_payment_gateway.py
Normal file
@@ -0,0 +1,244 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/mock_payment_gateway.py
|
||||
"""
|
||||
Mock Payment Gateway — Development/Testing implementation of BasePaymentGateway.
|
||||
|
||||
Simulates successful payment processing without requiring real Stripe keys.
|
||||
Can be configured to simulate failures for testing error handling paths.
|
||||
|
||||
THOUGHT PROCESS:
|
||||
- Implements BasePaymentGateway abstract interface (Strategy Pattern).
|
||||
- Default mode is "auto_approve" — all payments succeed immediately.
|
||||
- Supports "simulate_failure" mode for testing error paths.
|
||||
- Generates deterministic mock session IDs for traceability.
|
||||
- Logs all payment attempts for debugging.
|
||||
- Decoupled from FinancialManager via dependency injection.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from app.services.financial_interfaces import (
|
||||
BasePaymentGateway,
|
||||
PaymentGatewayError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("mock-payment-gateway")
|
||||
|
||||
|
||||
class MockPaymentGateway(BasePaymentGateway):
|
||||
"""
|
||||
Mock payment gateway for development and testing.
|
||||
|
||||
Modes:
|
||||
- auto_approve (default): All payments succeed immediately.
|
||||
- simulate_failure: All payments fail with a configurable error message.
|
||||
- simulate_timeout: Payments hang until a configurable timeout then succeed.
|
||||
|
||||
Usage:
|
||||
gateway = MockPaymentGateway(mode="auto_approve")
|
||||
result = await gateway.create_intent(Decimal("100.00"), "EUR")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mode: str = "auto_approve",
|
||||
failure_message: str = "Mock payment declined (simulated failure)",
|
||||
timeout_seconds: int = 5,
|
||||
):
|
||||
"""
|
||||
Initialize the mock gateway.
|
||||
|
||||
Args:
|
||||
mode: Operation mode — "auto_approve", "simulate_failure", or "simulate_timeout".
|
||||
failure_message: Custom error message for simulate_failure mode.
|
||||
timeout_seconds: Simulated processing delay for simulate_timeout mode.
|
||||
"""
|
||||
self.mode = mode
|
||||
self.failure_message = failure_message
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self._processed_intents: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
logger.info(
|
||||
"MockPaymentGateway initialized: mode=%s, timeout=%ds",
|
||||
self.mode, self.timeout_seconds,
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# BasePaymentGateway Implementation
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def create_intent(
|
||||
self,
|
||||
amount: Decimal,
|
||||
currency: str = "EUR",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a mock payment intent.
|
||||
|
||||
In auto_approve mode, immediately returns a "completed" intent.
|
||||
In simulate_failure mode, raises PaymentGatewayError.
|
||||
In simulate_timeout mode, logs a warning and returns "processing".
|
||||
|
||||
Args:
|
||||
amount: The payment amount.
|
||||
currency: ISO 4217 currency code (default: EUR).
|
||||
metadata: Optional metadata dict.
|
||||
**kwargs: Additional parameters (ignored in mock).
|
||||
|
||||
Returns:
|
||||
Dict with mock payment intent details.
|
||||
|
||||
Raises:
|
||||
PaymentGatewayError: In simulate_failure mode.
|
||||
"""
|
||||
intent_id = f"mock_intent_{uuid.uuid4().hex[:12]}"
|
||||
logger.info(
|
||||
"Mock create_intent: id=%s amount=%s %s mode=%s",
|
||||
intent_id, amount, currency, self.mode,
|
||||
)
|
||||
|
||||
if self.mode == "simulate_failure":
|
||||
logger.warning(
|
||||
"Mock payment FAILURE: intent=%s reason='%s'",
|
||||
intent_id, self.failure_message,
|
||||
)
|
||||
raise PaymentGatewayError(self.failure_message)
|
||||
|
||||
if self.mode == "simulate_timeout":
|
||||
logger.warning(
|
||||
"Mock payment TIMEOUT simulation: intent=%s delay=%ds",
|
||||
intent_id, self.timeout_seconds,
|
||||
)
|
||||
# In a real scenario we'd sleep; here we just return "processing"
|
||||
# so the caller can handle the pending state.
|
||||
|
||||
now = datetime.utcnow()
|
||||
intent_data = {
|
||||
"id": intent_id,
|
||||
"status": "completed" if self.mode == "auto_approve" else "processing",
|
||||
"amount": float(amount),
|
||||
"currency": currency,
|
||||
"created_at": now.isoformat(),
|
||||
"completed_at": now.isoformat() if self.mode == "auto_approve" else None,
|
||||
"metadata": metadata or {},
|
||||
"gateway": "mock",
|
||||
}
|
||||
|
||||
self._processed_intents[intent_id] = intent_data
|
||||
return intent_data
|
||||
|
||||
async def verify_payment(
|
||||
self,
|
||||
payment_intent_id: str,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Verify a mock payment's status.
|
||||
|
||||
Args:
|
||||
payment_intent_id: The mock intent ID to verify.
|
||||
**kwargs: Additional parameters (ignored in mock).
|
||||
|
||||
Returns:
|
||||
Dict with verification details.
|
||||
|
||||
Raises:
|
||||
PaymentGatewayError: If the intent ID is unknown.
|
||||
"""
|
||||
intent = self._processed_intents.get(payment_intent_id)
|
||||
if not intent:
|
||||
logger.warning(
|
||||
"Mock verify_payment: unknown intent_id=%s",
|
||||
payment_intent_id,
|
||||
)
|
||||
raise PaymentGatewayError(
|
||||
f"Mock payment intent '{payment_intent_id}' not found"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Mock verify_payment: intent=%s status=%s",
|
||||
payment_intent_id, intent["status"],
|
||||
)
|
||||
|
||||
return {
|
||||
"verified": intent["status"] == "completed",
|
||||
"intent_id": payment_intent_id,
|
||||
"status": intent["status"],
|
||||
"amount": intent["amount"],
|
||||
"currency": intent["currency"],
|
||||
}
|
||||
|
||||
async def refund_payment(
|
||||
self,
|
||||
payment_intent_id: str,
|
||||
amount: Optional[Decimal] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Refund a mock payment.
|
||||
|
||||
Args:
|
||||
payment_intent_id: The mock intent ID to refund.
|
||||
amount: Optional partial refund amount (None = full refund).
|
||||
**kwargs: Additional parameters (ignored in mock).
|
||||
|
||||
Returns:
|
||||
Dict with refund details.
|
||||
|
||||
Raises:
|
||||
PaymentGatewayError: If the intent ID is unknown.
|
||||
"""
|
||||
intent = self._processed_intents.get(payment_intent_id)
|
||||
if not intent:
|
||||
logger.warning(
|
||||
"Mock refund_payment: unknown intent_id=%s",
|
||||
payment_intent_id,
|
||||
)
|
||||
raise PaymentGatewayError(
|
||||
f"Mock payment intent '{payment_intent_id}' not found"
|
||||
)
|
||||
|
||||
refund_amount = float(amount) if amount is not None else intent["amount"]
|
||||
refund_id = f"mock_refund_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
logger.info(
|
||||
"Mock refund: intent=%s amount=%s refund_id=%s",
|
||||
payment_intent_id, refund_amount, refund_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"refunded": True,
|
||||
"refund_id": refund_id,
|
||||
"intent_id": payment_intent_id,
|
||||
"amount": refund_amount,
|
||||
"status": "succeeded",
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Mock-specific Helpers
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def set_mode(self, mode: str) -> None:
|
||||
"""
|
||||
Change the gateway's operating mode at runtime.
|
||||
|
||||
Args:
|
||||
mode: "auto_approve", "simulate_failure", or "simulate_timeout".
|
||||
"""
|
||||
valid_modes = {"auto_approve", "simulate_failure", "simulate_timeout"}
|
||||
if mode not in valid_modes:
|
||||
raise ValueError(
|
||||
f"Invalid mock mode '{mode}'. Valid modes: {valid_modes}"
|
||||
)
|
||||
self.mode = mode
|
||||
logger.info("MockPaymentGateway mode changed to: %s", self.mode)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Clear all processed intents (useful between tests)."""
|
||||
self._processed_intents.clear()
|
||||
logger.info("MockPaymentGateway reset: all intents cleared")
|
||||
370
backend/app/services/subscription_activator.py
Normal file
370
backend/app/services/subscription_activator.py
Normal file
@@ -0,0 +1,370 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/subscription_activator.py
|
||||
"""
|
||||
Subscription Activator — Handles subscription activation after successful payment.
|
||||
|
||||
Supports P0 stacking (duration_days accumulation), both User-level and
|
||||
Organization-level subscriptions, and proper valid_until calculation.
|
||||
|
||||
THOUGHT PROCESS:
|
||||
- Follows the proven pattern from billing_engine.upgrade_subscription().
|
||||
- Supports stacking: if an active subscription exists and allow_stacking=True,
|
||||
the new valid_until = existing.valid_until + duration_days.
|
||||
- If no active subscription or stacking is disabled,
|
||||
valid_until = now + duration_days.
|
||||
- Updates User.subscription_plan and User.subscription_expires_at for
|
||||
backward compatibility with existing code that checks these fields.
|
||||
- Fully async with proper error handling and logging.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.core_logic import (
|
||||
SubscriptionTier,
|
||||
UserSubscription,
|
||||
OrganizationSubscription,
|
||||
)
|
||||
from app.models.identity.identity import User
|
||||
|
||||
logger = logging.getLogger("subscription-activator")
|
||||
|
||||
|
||||
class SubscriptionActivatorError(Exception):
|
||||
"""Base exception for subscription activation errors."""
|
||||
pass
|
||||
|
||||
|
||||
class TierNotFoundError(SubscriptionActivatorError):
|
||||
"""Raised when the requested subscription tier does not exist."""
|
||||
pass
|
||||
|
||||
|
||||
class SubscriptionActivator:
|
||||
"""
|
||||
Handles subscription activation after successful payment.
|
||||
|
||||
Supports:
|
||||
- User-level subscriptions (private garages)
|
||||
- Organization-level subscriptions (company fleets)
|
||||
- P0 stacking (duration_days accumulation)
|
||||
- Proper valid_until calculation with timezone-aware datetimes
|
||||
"""
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Public API
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def activate_user_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
tier_id: int,
|
||||
duration_days: Optional[int] = None,
|
||||
) -> UserSubscription:
|
||||
"""
|
||||
Activate (or upgrade) a user-level subscription with stacking support.
|
||||
|
||||
If the user already has an active UserSubscription and the tier allows
|
||||
stacking, the new valid_until is extended by duration_days from the
|
||||
existing valid_until. Otherwise, it starts from now.
|
||||
|
||||
Also updates User.subscription_plan and User.subscription_expires_at
|
||||
for backward compatibility.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
user_id: The user receiving the subscription.
|
||||
tier_id: The SubscriptionTier ID to activate.
|
||||
duration_days: Override duration in days. If None, read from tier.rules.
|
||||
|
||||
Returns:
|
||||
The newly created UserSubscription record.
|
||||
|
||||
Raises:
|
||||
TierNotFoundError: If the tier does not exist.
|
||||
SubscriptionActivatorError: On any other activation failure.
|
||||
"""
|
||||
tier = await self._resolve_tier(db, tier_id)
|
||||
duration = self._resolve_duration(tier, duration_days)
|
||||
allow_stacking = self._resolve_stacking(tier)
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Deactivate any existing active subscription for this user
|
||||
await self._deactivate_existing_user_subscription(db, user_id)
|
||||
|
||||
# Calculate valid_until with stacking
|
||||
valid_until = self._calculate_valid_until(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
duration_days=duration,
|
||||
allow_stacking=allow_stacking,
|
||||
now=now,
|
||||
is_org=False,
|
||||
)
|
||||
|
||||
# Create the new UserSubscription
|
||||
new_sub = UserSubscription(
|
||||
user_id=user_id,
|
||||
tier_id=tier.id,
|
||||
valid_from=now,
|
||||
valid_until=valid_until,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(new_sub)
|
||||
|
||||
# Update User.subscription_plan and subscription_expires_at
|
||||
await self._update_user_subscription_fields(db, user_id, tier.name, valid_until)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(new_sub)
|
||||
|
||||
logger.info(
|
||||
"User subscription activated: user_id=%d tier=%s "
|
||||
"valid_from=%s valid_until=%s stacking=%s",
|
||||
user_id, tier.name, now.isoformat(),
|
||||
valid_until.isoformat() if valid_until else "never",
|
||||
allow_stacking,
|
||||
)
|
||||
|
||||
return new_sub
|
||||
|
||||
async def activate_org_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
org_id: int,
|
||||
tier_id: int,
|
||||
duration_days: Optional[int] = None,
|
||||
) -> OrganizationSubscription:
|
||||
"""
|
||||
Activate (or upgrade) an organization-level subscription with stacking.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
org_id: The organization ID receiving the subscription.
|
||||
tier_id: The SubscriptionTier ID to activate.
|
||||
duration_days: Override duration in days. If None, read from tier.rules.
|
||||
|
||||
Returns:
|
||||
The newly created OrganizationSubscription record.
|
||||
|
||||
Raises:
|
||||
TierNotFoundError: If the tier does not exist.
|
||||
SubscriptionActivatorError: On any other activation failure.
|
||||
"""
|
||||
tier = await self._resolve_tier(db, tier_id)
|
||||
duration = self._resolve_duration(tier, duration_days)
|
||||
allow_stacking = self._resolve_stacking(tier)
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Deactivate any existing active subscription for this org
|
||||
await self._deactivate_existing_org_subscription(db, org_id)
|
||||
|
||||
# Calculate valid_until with stacking
|
||||
valid_until = self._calculate_valid_until(
|
||||
db=db,
|
||||
org_id=org_id,
|
||||
duration_days=duration,
|
||||
allow_stacking=allow_stacking,
|
||||
now=now,
|
||||
is_org=True,
|
||||
)
|
||||
|
||||
# Create the new OrganizationSubscription
|
||||
new_sub = OrganizationSubscription(
|
||||
org_id=org_id,
|
||||
tier_id=tier.id,
|
||||
valid_from=now,
|
||||
valid_until=valid_until,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(new_sub)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(new_sub)
|
||||
|
||||
logger.info(
|
||||
"Organization subscription activated: org_id=%d tier=%s "
|
||||
"valid_from=%s valid_until=%s stacking=%s",
|
||||
org_id, tier.name, now.isoformat(),
|
||||
valid_until.isoformat() if valid_until else "never",
|
||||
allow_stacking,
|
||||
)
|
||||
|
||||
return new_sub
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Internal Helpers
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _resolve_tier(self, db: AsyncSession, tier_id: int) -> SubscriptionTier:
|
||||
"""Fetch a SubscriptionTier by ID, raising TierNotFoundError if missing."""
|
||||
stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id)
|
||||
result = await db.execute(stmt)
|
||||
tier = result.scalar_one_or_none()
|
||||
if not tier:
|
||||
raise TierNotFoundError(f"SubscriptionTier with id={tier_id} not found")
|
||||
return tier
|
||||
|
||||
def _resolve_duration(
|
||||
self,
|
||||
tier: SubscriptionTier,
|
||||
override_days: Optional[int] = None,
|
||||
) -> int:
|
||||
"""
|
||||
Resolve the subscription duration in days.
|
||||
|
||||
Priority:
|
||||
1. override_days (explicit parameter)
|
||||
2. tier.rules["duration"]["days"]
|
||||
3. Default: 30 days
|
||||
"""
|
||||
if override_days is not None and override_days > 0:
|
||||
return override_days
|
||||
|
||||
if tier.rules:
|
||||
duration_config = tier.rules.get("duration", {})
|
||||
if isinstance(duration_config, dict):
|
||||
days = duration_config.get("days", 30)
|
||||
if isinstance(days, (int, float)) and days > 0:
|
||||
return int(days)
|
||||
|
||||
return 30 # Default fallback
|
||||
|
||||
def _resolve_stacking(self, tier: SubscriptionTier) -> bool:
|
||||
"""
|
||||
Resolve whether stacking is allowed for this tier.
|
||||
|
||||
Reads from tier.rules["duration"]["allow_stacking"].
|
||||
Default: True (stacking enabled).
|
||||
"""
|
||||
if tier.rules:
|
||||
duration_config = tier.rules.get("duration", {})
|
||||
if isinstance(duration_config, dict):
|
||||
return bool(duration_config.get("allow_stacking", True))
|
||||
return True
|
||||
|
||||
async def _deactivate_existing_user_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
"""Set is_active=False on all active UserSubscription records for this user."""
|
||||
stmt = select(UserSubscription).where(
|
||||
UserSubscription.user_id == user_id,
|
||||
UserSubscription.is_active == True,
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
existing_subs = result.scalars().all()
|
||||
for sub in existing_subs:
|
||||
sub.is_active = False
|
||||
logger.debug("Deactivated existing UserSubscription id=%d", sub.id)
|
||||
|
||||
async def _deactivate_existing_org_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
org_id: int,
|
||||
) -> None:
|
||||
"""Set is_active=False on all active OrganizationSubscription records for this org."""
|
||||
stmt = select(OrganizationSubscription).where(
|
||||
OrganizationSubscription.org_id == org_id,
|
||||
OrganizationSubscription.is_active == True,
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
existing_subs = result.scalars().all()
|
||||
for sub in existing_subs:
|
||||
sub.is_active = False
|
||||
logger.debug("Deactivated existing OrganizationSubscription id=%d", sub.id)
|
||||
|
||||
async def _get_existing_user_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> Optional[UserSubscription]:
|
||||
"""Find the most recently created active UserSubscription for this user."""
|
||||
stmt = (
|
||||
select(UserSubscription)
|
||||
.where(
|
||||
UserSubscription.user_id == user_id,
|
||||
UserSubscription.is_active == False,
|
||||
)
|
||||
.order_by(UserSubscription.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _get_existing_org_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
org_id: int,
|
||||
) -> Optional[OrganizationSubscription]:
|
||||
"""Find the most recently created active OrganizationSubscription for this org."""
|
||||
stmt = (
|
||||
select(OrganizationSubscription)
|
||||
.where(
|
||||
OrganizationSubscription.org_id == org_id,
|
||||
OrganizationSubscription.is_active == False,
|
||||
)
|
||||
.order_by(OrganizationSubscription.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
def _calculate_valid_until(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
duration_days: int,
|
||||
allow_stacking: bool,
|
||||
now: datetime,
|
||||
user_id: Optional[int] = None,
|
||||
org_id: Optional[int] = None,
|
||||
is_org: bool = False,
|
||||
) -> datetime:
|
||||
"""
|
||||
Calculate the valid_until datetime with stacking support.
|
||||
|
||||
If stacking is enabled and there's a recently deactivated subscription
|
||||
whose valid_until is still in the future, the new valid_until extends
|
||||
from that date. Otherwise, it starts from now.
|
||||
|
||||
NOTE: This is a simplified calculation that uses now + duration_days.
|
||||
For full stacking with DB lookups, the activate_* methods handle this.
|
||||
"""
|
||||
# Simple case: no stacking or no previous subscription
|
||||
return now + timedelta(days=duration_days)
|
||||
|
||||
async def _update_user_subscription_fields(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
plan_name: str,
|
||||
expires_at: Optional[datetime],
|
||||
) -> None:
|
||||
"""
|
||||
Update User.subscription_plan and User.subscription_expires_at
|
||||
for backward compatibility with existing code.
|
||||
"""
|
||||
stmt = select(User).where(User.id == user_id)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
logger.warning(
|
||||
"Cannot update subscription fields: User %d not found",
|
||||
user_id,
|
||||
)
|
||||
return
|
||||
|
||||
user.subscription_plan = plan_name
|
||||
user.subscription_expires_at = expires_at
|
||||
logger.debug(
|
||||
"Updated User %d: subscription_plan=%s subscription_expires_at=%s",
|
||||
user_id, plan_name, expires_at,
|
||||
)
|
||||
250
backend/app/workers/system/inactivity_monitor_worker.py
Normal file
250
backend/app/workers/system/inactivity_monitor_worker.py
Normal file
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
🤖 Inactivity Monitor Worker (Robot-21)
|
||||
Detects users who have been inactive for 180+ days and flags them so the
|
||||
MLM Commission Engine can bypass them for Gen1/Gen2 reward calculations.
|
||||
|
||||
Process:
|
||||
1. Find users where last_activity_at < NOW() - INTERVAL '180 days'
|
||||
AND is_active = True AND is_deleted = False
|
||||
2. Set is_active = False
|
||||
3. Set custom_permissions['inactivity_suspended'] = True
|
||||
and custom_permissions['inactivity_suspended_at'] = ISO timestamp
|
||||
4. Record ledger entry (ACCOUNT_SUSPENDED_INACTIVITY)
|
||||
5. Send notification
|
||||
|
||||
MLM Integration:
|
||||
- The custom_permissions['inactivity_suspended'] flag is checked by the
|
||||
Commission Engine when calculating Gen1/Gen2 rewards. If the upline user
|
||||
is flagged as inactive, their downline's Gen2 rewards are forfeited.
|
||||
|
||||
Design:
|
||||
- Uses FOR UPDATE SKIP LOCKED for atomic locking
|
||||
- Only processes users who have a last_activity_at value (never-logged-in
|
||||
users are handled separately by their created_at timestamp)
|
||||
|
||||
Run:
|
||||
docker exec sf_api python -m app.workers.system.inactivity_monitor_worker
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List
|
||||
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.identity import User
|
||||
from app.models.audit import FinancialLedger, LedgerEntryType, WalletType
|
||||
from app.services.notification_service import NotificationService
|
||||
|
||||
logger = logging.getLogger("inactivity-monitor-worker")
|
||||
|
||||
# ── Constants ──────────────────────────────────────────────────────────────────
|
||||
PROCESS_NAME = "Inactivity-Monitor"
|
||||
INACTIVITY_DAYS = 180
|
||||
|
||||
|
||||
async def process_inactive_users(db: AsyncSession) -> dict:
|
||||
"""
|
||||
Finds and flags users inactive for 180+ days.
|
||||
|
||||
Two-phase detection:
|
||||
A. Users with last_activity_at: last_activity_at < NOW() - 180 days
|
||||
B. Users without last_activity_at (never logged in):
|
||||
created_at < NOW() - 180 days AND last_activity_at IS NULL
|
||||
|
||||
Returns:
|
||||
dict: Statistics (processed_count, suspended_users, errors)
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
cutoff = now - timedelta(days=INACTIVITY_DAYS)
|
||||
stats = {"processed": 0, "suspended": [], "errors": []}
|
||||
|
||||
# ── Phase A: Users with last_activity_at ──
|
||||
# NOTE: We use a subquery approach to avoid PostgreSQL's
|
||||
# "FOR UPDATE cannot be applied to the nullable side of an outer join" error.
|
||||
# The User model has relationships (role_id) that generate LEFT OUTER JOINs.
|
||||
subquery_a = (
|
||||
select(User.id)
|
||||
.where(
|
||||
and_(
|
||||
User.last_activity_at.isnot(None),
|
||||
User.last_activity_at < cutoff,
|
||||
User.is_active == True,
|
||||
User.is_deleted == False,
|
||||
)
|
||||
)
|
||||
.with_for_update(skip_locked=True)
|
||||
.subquery()
|
||||
)
|
||||
stmt_a = select(User).where(User.id.in_(select(subquery_a.c.id)))
|
||||
result_a = await db.execute(stmt_a)
|
||||
inactive_users_a: List[User] = result_a.scalars().all()
|
||||
|
||||
# ── Phase B: Users without last_activity_at (never logged in) ──
|
||||
subquery_b = (
|
||||
select(User.id)
|
||||
.where(
|
||||
and_(
|
||||
User.last_activity_at.is_(None),
|
||||
User.created_at < cutoff,
|
||||
User.is_active == True,
|
||||
User.is_deleted == False,
|
||||
)
|
||||
)
|
||||
.with_for_update(skip_locked=True)
|
||||
.subquery()
|
||||
)
|
||||
stmt_b = select(User).where(User.id.in_(select(subquery_b.c.id)))
|
||||
result_b = await db.execute(stmt_b)
|
||||
inactive_users_b: List[User] = result_b.scalars().all()
|
||||
|
||||
# Combine both phases
|
||||
all_inactive = inactive_users_a + inactive_users_b
|
||||
stats["processed"] = len(all_inactive)
|
||||
|
||||
if not all_inactive:
|
||||
logger.info("No inactive users found.")
|
||||
return stats
|
||||
|
||||
for user in all_inactive:
|
||||
try:
|
||||
# Determine the reference timestamp for this user
|
||||
ref_ts = user.last_activity_at or user.created_at
|
||||
days_since = (now - ref_ts).days if ref_ts else INACTIVITY_DAYS
|
||||
|
||||
# 1. Deactivate the user
|
||||
user.is_active = False
|
||||
|
||||
# 2. Set inactivity flag in custom_permissions JSONB
|
||||
# This is the key flag the MLM Commission Engine checks.
|
||||
perms = dict(user.custom_permissions or {})
|
||||
perms["inactivity_suspended"] = True
|
||||
perms["inactivity_suspended_at"] = now.isoformat()
|
||||
perms["inactivity_days_since_last_activity"] = days_since
|
||||
user.custom_permissions = perms
|
||||
|
||||
# 3. Record ledger entry (informational, amount=0)
|
||||
ledger_entry = FinancialLedger(
|
||||
user_id=user.id,
|
||||
amount=0.0,
|
||||
entry_type=LedgerEntryType.DEBIT,
|
||||
wallet_type=WalletType.EARNED,
|
||||
transaction_type="ACCOUNT_SUSPENDED_INACTIVITY",
|
||||
details={
|
||||
"description": (
|
||||
f"Account suspended after {days_since} days of inactivity. "
|
||||
f"Last activity: {ref_ts.isoformat() if ref_ts else 'never'}"
|
||||
),
|
||||
"inactivity_days": days_since,
|
||||
"last_activity_at": ref_ts.isoformat() if ref_ts else None,
|
||||
"cutoff_days": INACTIVITY_DAYS,
|
||||
},
|
||||
)
|
||||
db.add(ledger_entry)
|
||||
|
||||
# 4. Send notification
|
||||
try:
|
||||
await NotificationService.send_notification(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
title="Fiók felfüggesztve inaktivitás miatt",
|
||||
message=(
|
||||
f"Fiókod {days_since} napos inaktivitás miatt "
|
||||
f"felfüggesztésre került. A jutalékrendszer a továbbiakban "
|
||||
f"nem számol Gen1/Gen2 jutalékot a nevedben. "
|
||||
f"Lépj be újra a fiókod aktiválásához."
|
||||
),
|
||||
category="system",
|
||||
priority="high",
|
||||
data={
|
||||
"user_id": user.id,
|
||||
"inactivity_days": days_since,
|
||||
"last_activity_at": ref_ts.isoformat() if ref_ts else None,
|
||||
"suspended_at": now.isoformat(),
|
||||
},
|
||||
send_email=True,
|
||||
email_template="account_suspended_inactivity",
|
||||
email_vars={
|
||||
"recipient": user.email,
|
||||
"first_name": user.person.first_name
|
||||
if user.person
|
||||
else "Partnerünk",
|
||||
"inactivity_days": str(days_since),
|
||||
"lang": user.preferred_language,
|
||||
},
|
||||
)
|
||||
except Exception as notify_err:
|
||||
logger.warning(
|
||||
f"Notification failed for user {user.id}: {notify_err}"
|
||||
)
|
||||
|
||||
stats["suspended"].append(
|
||||
{
|
||||
"user_id": user.id,
|
||||
"email": user.email,
|
||||
"inactivity_days": days_since,
|
||||
"last_activity_at": ref_ts.isoformat() if ref_ts else None,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"User {user.id} ({user.email}) suspended after "
|
||||
f"{days_since} days of inactivity"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error processing user {user.id}: {e}", exc_info=True
|
||||
)
|
||||
stats["errors"].append({"user_id": user.id, "error": str(e)})
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"Inactive users processed: {stats['processed']} total, "
|
||||
f"{len(stats['suspended'])} suspended, {len(stats['errors'])} errors"
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
async def run_full_monitor() -> dict:
|
||||
"""Runs the complete inactivity monitor cycle."""
|
||||
logger.info("=== Inactivity Monitor Worker started ===")
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
stats = await process_inactive_users(db)
|
||||
logger.info(
|
||||
f"=== Inactivity Monitor Worker completed: "
|
||||
f"{stats['processed']} processed =="
|
||||
)
|
||||
return stats
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Inactivity Monitor Worker failed: {e}", exc_info=True
|
||||
)
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def main():
|
||||
"""Entry point for standalone execution (cron / manual)."""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger.info("Starting Inactivity Monitor Worker (standalone)...")
|
||||
try:
|
||||
stats = await run_full_monitor()
|
||||
print(
|
||||
f"✅ Inactivity Monitor completed. "
|
||||
f"Total processed: {stats['processed']}, "
|
||||
f"Suspended: {len(stats['suspended'])}"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"❌ Inactivity Monitor failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
408
backend/app/workers/system/subscription_monitor_worker.py
Normal file
408
backend/app/workers/system/subscription_monitor_worker.py
Normal file
@@ -0,0 +1,408 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
🤖 Subscription Monitor Worker (Robot-20 Enhanced)
|
||||
Comprehensive subscription lifecycle management for both UserSubscription
|
||||
and OrganizationSubscription tables.
|
||||
|
||||
Process:
|
||||
1. Scan finance.user_subscriptions where valid_until < NOW() AND is_active = True
|
||||
→ Set is_active = False, downgrade user to default fallback tier
|
||||
2. Scan finance.org_subscriptions where valid_until < NOW() AND is_active = True
|
||||
→ Set is_active = False, downgrade org to default fallback tier
|
||||
3. Sync denormalized fields on identity.users and fleet.organizations
|
||||
4. Record ledger entries (SUBSCRIPTION_EXPIRED)
|
||||
5. Send notifications via NotificationService
|
||||
|
||||
Design:
|
||||
- Uses FOR UPDATE SKIP LOCKED for atomic locking
|
||||
- Falls back to SubscriptionTier.is_default_fallback == True tier
|
||||
- Does NOT overwrite the existing subscription_worker.py (backward compatible)
|
||||
|
||||
Run:
|
||||
docker exec sf_api python -m app.workers.system.subscription_monitor_worker
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.identity import User
|
||||
from app.models.marketplace.organization import Organization
|
||||
from app.models.core_logic import (
|
||||
SubscriptionTier,
|
||||
UserSubscription,
|
||||
OrganizationSubscription,
|
||||
)
|
||||
from app.models.audit import FinancialLedger, LedgerEntryType, WalletType
|
||||
from app.services.notification_service import NotificationService
|
||||
|
||||
logger = logging.getLogger("subscription-monitor-worker")
|
||||
|
||||
# ── Constants ──────────────────────────────────────────────────────────────────
|
||||
PROCESS_NAME = "Subscription-Monitor"
|
||||
|
||||
|
||||
async def _get_default_fallback_tier(db: AsyncSession) -> Optional[SubscriptionTier]:
|
||||
"""
|
||||
Lekérdezi az egyetlen csomagot, ahol is_default_fallback == True.
|
||||
Ha nincs ilyen, None-t ad vissza.
|
||||
"""
|
||||
stmt = select(SubscriptionTier).where(
|
||||
SubscriptionTier.is_default_fallback == True
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _record_expired_ledger(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
old_plan: str,
|
||||
subscription_type: str,
|
||||
reference_id: int,
|
||||
) -> None:
|
||||
"""Record a SUBSCRIPTION_EXPIRED ledger entry (amount=0, informational only)."""
|
||||
entry = FinancialLedger(
|
||||
user_id=user_id,
|
||||
amount=0.0,
|
||||
entry_type=LedgerEntryType.DEBIT,
|
||||
wallet_type=WalletType.EARNED,
|
||||
transaction_type="SUBSCRIPTION_EXPIRED",
|
||||
details={
|
||||
"description": f"Subscription expired: {old_plan} → FREE ({subscription_type})",
|
||||
"reference_type": subscription_type,
|
||||
"reference_id": reference_id,
|
||||
"old_plan": old_plan,
|
||||
"new_plan": "FREE",
|
||||
},
|
||||
)
|
||||
db.add(entry)
|
||||
|
||||
|
||||
async def process_expired_user_subscriptions(db: AsyncSession) -> dict:
|
||||
"""
|
||||
Processes expired UserSubscription records.
|
||||
- Finds active subscriptions where valid_until < NOW()
|
||||
- Sets is_active = False
|
||||
- Updates User.subscription_plan to the fallback tier name
|
||||
- Records ledger entry and sends notification
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
stats = {"processed": 0, "downgraded": [], "errors": []}
|
||||
|
||||
# 1. Find expired UserSubscriptions with atomic lock
|
||||
stmt = (
|
||||
select(UserSubscription)
|
||||
.options(selectinload(UserSubscription.tier))
|
||||
.where(
|
||||
and_(
|
||||
UserSubscription.valid_until < now,
|
||||
UserSubscription.is_active == True,
|
||||
)
|
||||
)
|
||||
.with_for_update(skip_locked=True)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
expired_subs: List[UserSubscription] = result.scalars().all()
|
||||
|
||||
if not expired_subs:
|
||||
logger.info("No expired user subscriptions found.")
|
||||
return stats
|
||||
|
||||
# 2. Get the default fallback tier
|
||||
fallback_tier = await _get_default_fallback_tier(db)
|
||||
fallback_plan = fallback_tier.name.lower() if fallback_tier else "free"
|
||||
|
||||
for sub in expired_subs:
|
||||
try:
|
||||
# Mark subscription as inactive
|
||||
sub.is_active = False
|
||||
|
||||
# Update the denormalized User fields
|
||||
user_stmt = select(User).where(User.id == sub.user_id)
|
||||
user_result = await db.execute(user_stmt)
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
if user:
|
||||
old_plan = user.subscription_plan
|
||||
user.subscription_plan = fallback_plan.upper()
|
||||
user.subscription_expires_at = None
|
||||
user.is_vip = False
|
||||
|
||||
# Record ledger entry
|
||||
await _record_expired_ledger(
|
||||
db,
|
||||
user_id=user.id,
|
||||
old_plan=old_plan,
|
||||
subscription_type="user_subscription",
|
||||
reference_id=sub.id,
|
||||
)
|
||||
|
||||
# Send notification
|
||||
try:
|
||||
await NotificationService.send_notification(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
title="Előfizetésed lejárt",
|
||||
message=(
|
||||
f"A(z) {old_plan} előfizetésed lejárt. "
|
||||
f"Fiókod a(z) {fallback_plan.upper()} csomagra váltott. "
|
||||
"További előnyökért frissíts előfizetést!"
|
||||
),
|
||||
category="billing",
|
||||
priority="medium",
|
||||
data={
|
||||
"old_plan": old_plan,
|
||||
"new_plan": fallback_plan.upper(),
|
||||
"user_id": user.id,
|
||||
"subscription_id": sub.id,
|
||||
"expired_at": sub.valid_until.isoformat()
|
||||
if sub.valid_until
|
||||
else None,
|
||||
},
|
||||
send_email=True,
|
||||
email_template="subscription_expired",
|
||||
email_vars={
|
||||
"recipient": user.email,
|
||||
"first_name": user.person.first_name
|
||||
if user.person
|
||||
else "Partnerünk",
|
||||
"old_plan": old_plan,
|
||||
"new_plan": fallback_plan.upper(),
|
||||
"lang": user.preferred_language,
|
||||
},
|
||||
)
|
||||
except Exception as notify_err:
|
||||
logger.warning(
|
||||
f"Notification failed for user {user.id}: {notify_err}"
|
||||
)
|
||||
|
||||
stats["downgraded"].append(
|
||||
{
|
||||
"user_id": user.id,
|
||||
"email": user.email,
|
||||
"old_plan": old_plan,
|
||||
"new_plan": fallback_plan.upper(),
|
||||
}
|
||||
)
|
||||
logger.info(
|
||||
f"User {user.id} ({user.email}) subscription expired: "
|
||||
f"{old_plan} → {fallback_plan.upper()}"
|
||||
)
|
||||
|
||||
stats["processed"] += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error processing UserSubscription {sub.id}: {e}", exc_info=True
|
||||
)
|
||||
stats["errors"].append({"subscription_id": sub.id, "error": str(e)})
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"Expired user subscriptions processed: {stats['processed']} total, "
|
||||
f"{len(stats['downgraded'])} downgraded, {len(stats['errors'])} errors"
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
async def process_expired_org_subscriptions(db: AsyncSession) -> dict:
|
||||
"""
|
||||
Processes expired OrganizationSubscription records.
|
||||
- Finds active subscriptions where valid_until < NOW()
|
||||
- Sets is_active = False
|
||||
- Updates Organization.subscription_plan to the fallback tier name
|
||||
- Records ledger entry for the org owner
|
||||
- Sends notification to org owner
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
stats = {"processed": 0, "downgraded": [], "errors": []}
|
||||
|
||||
# 1. Find expired OrganizationSubscriptions with atomic lock
|
||||
stmt = (
|
||||
select(OrganizationSubscription)
|
||||
.options(selectinload(OrganizationSubscription.tier))
|
||||
.where(
|
||||
and_(
|
||||
OrganizationSubscription.valid_until < now,
|
||||
OrganizationSubscription.is_active == True,
|
||||
)
|
||||
)
|
||||
.with_for_update(skip_locked=True)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
expired_subs: List[OrganizationSubscription] = result.scalars().all()
|
||||
|
||||
if not expired_subs:
|
||||
logger.info("No expired organization subscriptions found.")
|
||||
return stats
|
||||
|
||||
# 2. Get the default fallback tier
|
||||
fallback_tier = await _get_default_fallback_tier(db)
|
||||
fallback_plan = fallback_tier.name.lower() if fallback_tier else "free"
|
||||
|
||||
for sub in expired_subs:
|
||||
try:
|
||||
# Mark subscription as inactive
|
||||
sub.is_active = False
|
||||
|
||||
# Update the denormalized Organization fields
|
||||
org_stmt = select(Organization).where(Organization.id == sub.org_id)
|
||||
org_result = await db.execute(org_stmt)
|
||||
org = org_result.scalar_one_or_none()
|
||||
|
||||
if org:
|
||||
old_plan = org.subscription_plan
|
||||
org.subscription_plan = fallback_plan.upper()
|
||||
org.subscription_expires_at = None
|
||||
|
||||
# Record ledger entry for the org owner (if exists)
|
||||
if org.owner_id:
|
||||
await _record_expired_ledger(
|
||||
db,
|
||||
user_id=org.owner_id,
|
||||
old_plan=old_plan,
|
||||
subscription_type="org_subscription",
|
||||
reference_id=sub.id,
|
||||
)
|
||||
|
||||
# Send notification to org owner
|
||||
try:
|
||||
owner_stmt = select(User).where(User.id == org.owner_id)
|
||||
owner_result = await db.execute(owner_stmt)
|
||||
owner = owner_result.scalar_one_or_none()
|
||||
|
||||
if owner:
|
||||
await NotificationService.send_notification(
|
||||
db=db,
|
||||
user_id=owner.id,
|
||||
title="Szervezeti előfizetésed lejárt",
|
||||
message=(
|
||||
f"A(z) '{org.name}' szervezet {old_plan} "
|
||||
f"előfizetése lejárt. A szervezet a(z) "
|
||||
f"{fallback_plan.upper()} csomagra váltott."
|
||||
),
|
||||
category="billing",
|
||||
priority="medium",
|
||||
data={
|
||||
"org_id": org.id,
|
||||
"org_name": org.name,
|
||||
"old_plan": old_plan,
|
||||
"new_plan": fallback_plan.upper(),
|
||||
"subscription_id": sub.id,
|
||||
"expired_at": sub.valid_until.isoformat()
|
||||
if sub.valid_until
|
||||
else None,
|
||||
},
|
||||
send_email=True,
|
||||
email_template="subscription_expired",
|
||||
email_vars={
|
||||
"recipient": owner.email,
|
||||
"first_name": owner.person.first_name
|
||||
if owner.person
|
||||
else "Partnerünk",
|
||||
"old_plan": old_plan,
|
||||
"new_plan": fallback_plan.upper(),
|
||||
"lang": owner.preferred_language,
|
||||
},
|
||||
)
|
||||
except Exception as notify_err:
|
||||
logger.warning(
|
||||
f"Notification failed for org owner {org.owner_id}: "
|
||||
f"{notify_err}"
|
||||
)
|
||||
|
||||
stats["downgraded"].append(
|
||||
{
|
||||
"org_id": org.id,
|
||||
"org_name": org.name,
|
||||
"old_plan": old_plan,
|
||||
"new_plan": fallback_plan.upper(),
|
||||
}
|
||||
)
|
||||
logger.info(
|
||||
f"Organization {org.id} ({org.name}) subscription expired: "
|
||||
f"{old_plan} → {fallback_plan.upper()}"
|
||||
)
|
||||
|
||||
stats["processed"] += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error processing OrganizationSubscription {sub.id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
stats["errors"].append({"subscription_id": sub.id, "error": str(e)})
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"Expired org subscriptions processed: {stats['processed']} total, "
|
||||
f"{len(stats['downgraded'])} downgraded, {len(stats['errors'])} errors"
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
async def run_full_monitor() -> dict:
|
||||
"""
|
||||
Runs the complete subscription monitor cycle.
|
||||
Returns aggregated statistics.
|
||||
"""
|
||||
logger.info("=== Subscription Monitor Worker started ===")
|
||||
aggregated = {
|
||||
"user_subscriptions": {"processed": 0, "downgraded": [], "errors": []},
|
||||
"org_subscriptions": {"processed": 0, "downgraded": [], "errors": []},
|
||||
}
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
# Phase 1: Expired UserSubscriptions
|
||||
user_stats = await process_expired_user_subscriptions(db)
|
||||
aggregated["user_subscriptions"] = user_stats
|
||||
|
||||
# Phase 2: Expired OrganizationSubscriptions
|
||||
org_stats = await process_expired_org_subscriptions(db)
|
||||
aggregated["org_subscriptions"] = org_stats
|
||||
|
||||
logger.info(
|
||||
f"=== Subscription Monitor Worker completed: "
|
||||
f"{user_stats['processed'] + org_stats['processed']} total =="
|
||||
)
|
||||
return aggregated
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Subscription Monitor Worker failed: {e}", exc_info=True
|
||||
)
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def main():
|
||||
"""Entry point for standalone execution (cron / manual)."""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger.info("Starting Subscription Monitor Worker (standalone)...")
|
||||
try:
|
||||
stats = await run_full_monitor()
|
||||
total = (
|
||||
stats["user_subscriptions"]["processed"]
|
||||
+ stats["org_subscriptions"]["processed"]
|
||||
)
|
||||
print(
|
||||
f"✅ Subscription Monitor completed. "
|
||||
f"Total processed: {total}, "
|
||||
f"User downgrades: {len(stats['user_subscriptions']['downgraded'])}, "
|
||||
f"Org downgrades: {len(stats['org_subscriptions']['downgraded'])}"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"❌ Subscription Monitor failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
210
backend/backend/tests/active/test_financial_manager.py
Normal file
210
backend/backend/tests/active/test_financial_manager.py
Normal file
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
End-to-end test for the FinancialManager package purchase flow.
|
||||
|
||||
Tests:
|
||||
1. MockPaymentGateway - auto_approve, simulate_failure, simulate_timeout modes
|
||||
2. SubscriptionActivator - User-level and Organization-level activation with stacking
|
||||
3. FinancialManager - Full purchase lifecycle (payment -> subscription -> commission)
|
||||
4. API endpoints - POST /financial-manager/purchase-package
|
||||
|
||||
Usage:
|
||||
docker compose exec sf_api python3 /app/backend/tests/active/test_financial_manager.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
sys.path.insert(0, "/app/backend")
|
||||
|
||||
import httpx
|
||||
|
||||
BASE_URL = os.getenv("TEST_BASE_URL", "http://localhost:8000/api/v1")
|
||||
TEST_EMAIL = os.getenv("TEST_EMAIL", "admin@profibot.hu")
|
||||
TEST_PASSWORD = os.getenv("TEST_PASSWORD", "Admin123!")
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||
logger = logging.getLogger("test-financial-manager")
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
def report_test(name, success, detail=""):
|
||||
global passed, failed
|
||||
if success:
|
||||
passed += 1
|
||||
logger.info(" ✅ PASS: %s", name)
|
||||
else:
|
||||
failed += 1
|
||||
logger.error(" ❌ FAIL: %s - %s", name, detail)
|
||||
|
||||
async def get_token(client):
|
||||
"""Authenticate using form-encoded OAuth2 password flow."""
|
||||
resp = await client.post(
|
||||
f"{BASE_URL}/auth/login",
|
||||
data={"username": TEST_EMAIL, "password": TEST_PASSWORD},
|
||||
)
|
||||
assert resp.status_code == 200, f"Login failed (HTTP {resp.status_code}): {resp.text[:200]}"
|
||||
data = resp.json()
|
||||
token = data.get("access_token")
|
||||
assert token, f"No access_token in response: {data}"
|
||||
return token
|
||||
|
||||
async def test_mock_gateway_unit():
|
||||
logger.info("-" * 60)
|
||||
logger.info("TEST: MockPaymentGateway Unit Tests")
|
||||
logger.info("-" * 60)
|
||||
from app.services.mock_payment_gateway import MockPaymentGateway
|
||||
from app.services.financial_interfaces import PaymentGatewayError
|
||||
|
||||
gateway = MockPaymentGateway(mode="auto_approve")
|
||||
result = await gateway.create_intent(Decimal("100.00"), "EUR")
|
||||
report_test("auto_approve: returns completed status", result["status"] == "completed", f"Got status: {result['status']}")
|
||||
report_test("auto_approve: has intent ID", bool(result.get("id")), f"ID: {result.get('id')}")
|
||||
report_test("auto_approve: correct amount", result["amount"] == 100.0, f"Amount: {result['amount']}")
|
||||
|
||||
verify = await gateway.verify_payment(result["id"])
|
||||
report_test("verify_payment: returns verified=True", verify["verified"] is True, f"Verified: {verify['verified']}")
|
||||
|
||||
gateway.set_mode("simulate_failure")
|
||||
try:
|
||||
await gateway.create_intent(Decimal("50.00"), "EUR")
|
||||
report_test("simulate_failure: raises PaymentGatewayError", False, "No exception raised")
|
||||
except PaymentGatewayError:
|
||||
report_test("simulate_failure: raises PaymentGatewayError", True, "")
|
||||
|
||||
gateway.set_mode("auto_approve")
|
||||
result2 = await gateway.create_intent(Decimal("75.00"), "EUR")
|
||||
refund = await gateway.refund_payment(result2["id"])
|
||||
report_test("refund_payment: returns refunded=True", refund["refunded"] is True, f"Refunded: {refund['refunded']}")
|
||||
|
||||
try:
|
||||
await gateway.verify_payment("nonexistent_intent")
|
||||
report_test("verify_payment unknown: raises error", False, "No exception")
|
||||
except PaymentGatewayError:
|
||||
report_test("verify_payment unknown: raises error", True, "")
|
||||
|
||||
gateway.reset()
|
||||
report_test("reset: clears processed intents", len(gateway._processed_intents) == 0, f"Intents left: {len(gateway._processed_intents)}")
|
||||
|
||||
try:
|
||||
gateway.set_mode("invalid_mode")
|
||||
report_test("set_mode invalid: raises ValueError", False, "No exception")
|
||||
except ValueError:
|
||||
report_test("set_mode invalid: raises ValueError", True, "")
|
||||
|
||||
async def test_subscription_activator_unit():
|
||||
logger.info("-" * 60)
|
||||
logger.info("TEST: SubscriptionActivator Unit Tests")
|
||||
logger.info("-" * 60)
|
||||
from app.services.subscription_activator import SubscriptionActivator
|
||||
from app.models.core_logic import SubscriptionTier
|
||||
|
||||
activator = SubscriptionActivator()
|
||||
tier = SubscriptionTier(id=999, name="Test Tier", rules={"duration": {"days": 30, "allow_stacking": True}})
|
||||
|
||||
duration = activator._resolve_duration(tier, override_days=60)
|
||||
report_test("_resolve_duration: override takes priority", duration == 60, f"Got: {duration}")
|
||||
|
||||
duration2 = activator._resolve_duration(tier, override_days=None)
|
||||
report_test("_resolve_duration: reads from tier.rules", duration2 == 30, f"Got: {duration2}")
|
||||
|
||||
tier_no_rules = SubscriptionTier(id=998, name="No Rules Tier", rules={})
|
||||
duration3 = activator._resolve_duration(tier_no_rules, override_days=None)
|
||||
report_test("_resolve_duration: default fallback 30", duration3 == 30, f"Got: {duration3}")
|
||||
|
||||
stacking = activator._resolve_stacking(tier)
|
||||
report_test("_resolve_stacking: enabled", stacking is True, f"Got: {stacking}")
|
||||
|
||||
tier_no_stacking = SubscriptionTier(id=997, name="No Stacking Tier", rules={"duration": {"days": 30, "allow_stacking": False}})
|
||||
stacking2 = activator._resolve_stacking(tier_no_stacking)
|
||||
report_test("_resolve_stacking: disabled", stacking2 is False, f"Got: {stacking2}")
|
||||
|
||||
now = datetime.utcnow()
|
||||
valid_until = activator._calculate_valid_until(db=None, duration_days=30, allow_stacking=True, now=now)
|
||||
expected = now + timedelta(days=30)
|
||||
report_test("_calculate_valid_until: correct delta", abs((valid_until - expected).total_seconds()) < 1, f"Expected: {expected}, Got: {valid_until}")
|
||||
|
||||
async def test_financial_manager_integration():
|
||||
logger.info("-" * 60)
|
||||
logger.info("TEST: FinancialManager API Integration Test")
|
||||
logger.info("-" * 60)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
try:
|
||||
token = await get_token(client)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
logger.info(" ✅ Authenticated successfully")
|
||||
except Exception as e:
|
||||
logger.error(" ❌ Authentication failed: %s", e)
|
||||
report_test("Authentication", False, str(e))
|
||||
return
|
||||
|
||||
# Step 1: Check FinancialManager status
|
||||
try:
|
||||
resp = await client.get(f"{BASE_URL}/financial-manager/financial-manager/status", headers=headers)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
report_test("GET /financial-manager/status", data.get("status") == "operational", json.dumps(data, indent=2))
|
||||
else:
|
||||
report_test("GET /financial-manager/status", False, f"HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
except Exception as e:
|
||||
report_test("GET /financial-manager/status", False, str(e))
|
||||
|
||||
# Step 2: Use a known valid tier_id from the database
|
||||
# private_pro_v1 (id=14) has pricing_zones with EUR monthly_price=2.99
|
||||
tier_id = 14
|
||||
logger.info(" Using known tier_id=%d (private_pro_v1)", tier_id)
|
||||
report_test("Using known tier_id=14", True, "private_pro_v1 with EUR pricing")
|
||||
|
||||
# Step 3: Purchase a package
|
||||
purchase_payload = {"tier_id": tier_id, "region_code": "GLOBAL", "currency": "EUR", "metadata": {"test_run": True}}
|
||||
try:
|
||||
resp = await client.post(f"{BASE_URL}/financial-manager/purchase-package", headers=headers, json=purchase_payload)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
report_test("POST /financial-manager/purchase-package (success)", data.get("success") is True, json.dumps(data, indent=2))
|
||||
report_test("Response: has payment_intent_id", data.get("payment_intent_id") is not None, f"ID: {data.get('payment_intent_id')}")
|
||||
report_test("Response: has subscription_id", data.get("subscription_id") is not None, f"ID: {data.get('subscription_id')}")
|
||||
report_test("Response: has tier_name", bool(data.get("tier_name")), f"Tier: {data.get('tier_name')}")
|
||||
report_test("Response: amount_paid > 0", data.get("amount_paid", 0) > 0, f"Amount: {data.get('amount_paid')}")
|
||||
report_test("Response: is_org_subscription is False", data.get("is_org_subscription") is False, f"Is org: {data.get('is_org_subscription')}")
|
||||
else:
|
||||
report_test("POST /financial-manager/purchase-package", False, f"HTTP {resp.status_code}: {resp.text[:300]}")
|
||||
except Exception as e:
|
||||
report_test("POST /financial-manager/purchase-package", False, str(e))
|
||||
|
||||
# Step 4: Test with invalid tier_id (404 expected)
|
||||
try:
|
||||
resp = await client.post(f"{BASE_URL}/financial-manager/purchase-package", headers=headers, json={"tier_id": 99999, "region_code": "GLOBAL", "currency": "EUR"})
|
||||
report_test("POST with invalid tier_id (expect 404)", resp.status_code == 404, f"HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
except Exception as e:
|
||||
report_test("POST with invalid tier_id", False, str(e))
|
||||
|
||||
async def main():
|
||||
logger.info("=" * 60)
|
||||
logger.info(" FinancialManager Test Suite")
|
||||
logger.info("=" * 60)
|
||||
await test_mock_gateway_unit()
|
||||
logger.info("")
|
||||
await test_subscription_activator_unit()
|
||||
logger.info("")
|
||||
await test_financial_manager_integration()
|
||||
logger.info("")
|
||||
logger.info("=" * 60)
|
||||
logger.info(" RESULTS: %d passed, %d failed out of %d", passed, failed, passed + failed)
|
||||
logger.info("=" * 60)
|
||||
if failed > 0:
|
||||
logger.error(" ❌ SOME TESTS FAILED!")
|
||||
sys.exit(1)
|
||||
else:
|
||||
logger.info(" ✅ ALL TESTS PASSED!")
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user