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",
|
||||
}
|
||||
Reference in New Issue
Block a user