admin_nyelvi_javítas

This commit is contained in:
Roo
2026-07-25 10:22:03 +00:00
parent 33c4d793db
commit c9118bf52f
29 changed files with 4474 additions and 943 deletions

View File

@@ -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"])

View File

@@ -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)

View File

@@ -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)

View 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",
}

View File

@@ -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

View File

@@ -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")

View File

@@ -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 ---

View File

@@ -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

View 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}

View File

@@ -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(

View 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

View 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")

View 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,
)

View 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())

View 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())

View 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())