admin_nyelvi_javítas
This commit is contained in:
116
.roo/history.md
116
.roo/history.md
@@ -451,3 +451,119 @@ A konténer indításakor előre létező import hibák jelentkeztek (`expenses.
|
||||
- Total: 7000.00 ✅
|
||||
- Max amount cap (50000) verified ✅
|
||||
- Sync engine: 1303 OK, 0 Fixed, 0 Shadow
|
||||
|
||||
## Phase 2 - MLM Commission & Wallet Integration (2026-07-24)
|
||||
|
||||
### ✅ Implementált változtatások
|
||||
1. **CommissionRule model** - 3 új mező: gen1_max_amount, gen2_max_amount (Numeric(18,2)), gen2_renewal_percent (Numeric(5,2))
|
||||
2. **Pydantic schemas** - új mezők a Create/Update/Response DTO-kban + is_renewal flag a DistributionRequest-ben
|
||||
3. **commission_service.py** - Phase 2 business logic:
|
||||
- _credit_commission_to_wallet(): Wallet.earned_credits + FinancialLedger CREDIT
|
||||
- _is_user_recently_active(): 180 napos inaktivitás ellenőrzés
|
||||
- Per-level caps (gen1_max_amount/gen2_max_amount fallback commission_max_amount-ra)
|
||||
- Inaktív Gen1 esetén Gen2 kifizetés kihagyása
|
||||
- Renewal logika (gen2_renewal_percent vs upline_commission_percent)
|
||||
- db.commit() a wallet/ledger írások után
|
||||
4. **Frontend Admin UI** - 3 új mező a commission-rules.vue formban + i18n kulcsok (HU/EN)
|
||||
5. **Sync Engine** - 3 új oszlop sikeresen hozzáadva a marketplace.commission_rules táblához
|
||||
6. **Tesztek** - 4/4 teszt sikeresen lefutott (commission distribution + max amount cap)
|
||||
|
||||
## Phase 3 - Commission Rule Priority Engine & Conflict Resolution Flow (2026-07-24)
|
||||
|
||||
### ✅ Implementált változtatások
|
||||
|
||||
**Task 1: Priority Resolution Engine (Verifikáció)**
|
||||
- [`get_active_rule()`](backend/app/services/commission_service.py:333) már helyesen implementálva:
|
||||
- `campaign_priority`: campaign=0, egyéb=1
|
||||
- `region_priority`: exact match=0, GLOBAL=1, else=2
|
||||
- `tier_priority`: PLATINUM=0 → CONTRACTED=4, else=99
|
||||
- `.order_by(campaign_priority, region_priority, tier_priority).limit(1)`
|
||||
|
||||
**Task 2: Conflict Detection & Audit Logging**
|
||||
- [`schemas/commission.py`](backend/app/schemas/commission.py:159): `ConflictingRuleInfo` + `ConflictResponse` DTO-k
|
||||
- [`commission_service.py`](backend/app/services/commission_service.py:59): `check_rule_conflict()` - exact match (tier, region_code, is_campaign) aktív szabályokra
|
||||
- [`commission_service.py`](backend/app/services/commission_service.py:103): `create_commission_rule()` - conflict check + force_override deaktiválás
|
||||
- [`commission_service.py`](backend/app/services/commission_service.py:189): `update_commission_rule()` - conflict check exclude_rule_id-vel
|
||||
- [`admin_commission.py`](backend/app/api/v1/endpoints/admin_commission.py:179): 409 Conflict response `ConflictingRuleInfo` struktúrával
|
||||
|
||||
**Task 3: Frontend UI Approval Workflow**
|
||||
- [`commission-rules.vue`](frontend_admin/pages/finance/commission-rules.vue:595): `showConflictModal`, `conflictingRule`, `pendingPayload` refs
|
||||
- Konfliktus modal: amber figyelmeztetés + conflicting rule adatok + Cancel/Confirm gombok
|
||||
- `saveRule()`: 409 catch → pendingPayload tárolás → modal megjelenítés
|
||||
- `confirmOverride()`: re-submit `force_override: true`-val
|
||||
- [`hu/commission.json`](frontend_admin/i18n/locales/hu/commission.json): magyar konfliktus szövegek
|
||||
- [`en/commission.json`](frontend_admin/i18n/locales/en/commission.json): angol konfliktus szövegek
|
||||
|
||||
### ✅ Verifikáció
|
||||
1. **Sync Engine** - 1306 items OK (schema in sync)
|
||||
2. **Commission Distribution Test** - 4/4 passed
|
||||
3. **Python imports** - No syntax errors
|
||||
|
||||
---
|
||||
|
||||
## FinancialManager: Subscription Purchase & Payment Gateway Module (Gitea #411)
|
||||
|
||||
**Dátum:** 2026-07-24
|
||||
**Scope:** Backend (Strategy Pattern, FastAPI, SQLAlchemy)
|
||||
|
||||
### 🔧 Létrehozott fájlok
|
||||
|
||||
1. [`mock_payment_gateway.py`](backend/app/services/mock_payment_gateway.py) - MockPaymentGateway (Strategy Pattern implementáció, 3 mód: auto_approve, simulate_failure, simulate_timeout)
|
||||
2. [`subscription_activator.py`](backend/app/services/subscription_activator.py) - SubscriptionActivator (User/Org szintű aktiválás P0 stackinggel)
|
||||
3. [`financial_manager.py`](backend/app/services/financial_manager.py) - FinancialManager orchestrator (teljes vásárlási életciklus)
|
||||
4. [`financial_manager.py`](backend/app/schemas/financial_manager.py) - PurchaseRequest/PurchaseResponse Pydantic sémák
|
||||
5. [`financial_manager.py`](backend/app/api/v1/endpoints/financial_manager.py) - API végpontok (POST /purchase-package, GET /status)
|
||||
|
||||
### 🔧 Módosított fájlok
|
||||
|
||||
1. [`api.py`](backend/app/api/v1/api.py) - Financial Manager router regisztráció
|
||||
|
||||
### 🐛 Javított adatbázis enumok
|
||||
- `finance.wallet_type` - hozzáadva: EARNED, PURCHASED, SERVICE_COINS, VOUCHER
|
||||
- `finance.payment_intent_status` - hozzáadva: COMPLETED, CANCELLED, EXPIRED
|
||||
|
||||
### ✅ Verifikáció
|
||||
1. **Sync Engine** - 1306 items OK (schema in sync)
|
||||
2. **FinancialManager Test Suite** - 24/24 passed
|
||||
3. **API Integration** - purchase-package endpoint fully functional
|
||||
|
||||
---
|
||||
|
||||
## Build Inactivity & Subscription Monitor (Gitea #412)
|
||||
|
||||
**Dátum:** 2026-07-24
|
||||
**Scope:** Backend (Workers, Scheduler, Auth, DB Schema)
|
||||
|
||||
### 🔧 Módosított/Létrehozott fájlok
|
||||
|
||||
#### 1. [`identity.py`](backend/app/models/identity/identity.py:178)
|
||||
- Hozzáadva: `last_activity_at` mező a `User` modellhez (DateTime(timezone=True), nullable)
|
||||
- Komment: "Updated on login/token-refresh; used by the 180-day inactivity worker"
|
||||
|
||||
#### 2. [`subscription_monitor_worker.py`](backend/app/workers/system/subscription_monitor_worker.py) (ÚJ)
|
||||
- Teljes subscription lifecycle kezelés: `UserSubscription` és `OrganizationSubscription`
|
||||
- `FOR UPDATE SKIP LOCKED` atomi zárolás
|
||||
- Fallback `is_default_fallback` tier-re lejáratkor
|
||||
- `FinancialLedger` bejegyzés (`SUBSCRIPTION_EXPIRED`)
|
||||
- `NotificationService` értesítés
|
||||
- Nem írja felül a meglévő `subscription_worker.py`-t
|
||||
|
||||
#### 3. [`inactivity_monitor_worker.py`](backend/app/workers/system/inactivity_monitor_worker.py) (ÚJ)
|
||||
- 180 napos inaktivitás detektálás (kétfázisú: `last_activity_at` + `created_at` alapján)
|
||||
- Subquery approach a `FOR UPDATE` + outer join PostgreSQL hiba elkerülésére
|
||||
- `custom_permissions['inactivity_suspended']` flag beállítása MLM engine számára
|
||||
- `is_active = False`, `FinancialLedger` bejegyzés, `NotificationService` értesítés
|
||||
|
||||
#### 4. [`scheduler.py`](backend/app/core/scheduler.py:207)
|
||||
- Subscription Monitor job: 01:00 UTC (CronTrigger, jitter=900)
|
||||
- Inactivity Monitor job: 02:00 UTC (CronTrigger, jitter=900)
|
||||
- Mindkét job `ProcessLog`-ba naplóz
|
||||
|
||||
#### 5. [`auth.py`](backend/app/api/v1/endpoints/auth.py:65)
|
||||
- `user.last_activity_at = datetime.now(timezone.utc)` beállítás login, verify-email és Google auth végpontokban
|
||||
|
||||
### ✅ Verifikáció
|
||||
1. **Sync Engine** - 1306 items OK, 1 fixed (`last_activity_at` oszlop hozzáadva)
|
||||
2. **Subscription Monitor** - Sikeres futás, 0 expired subscription (dev adatbázis)
|
||||
3. **Inactivity Monitor** - Sikeres futás, 0 inactive user (dev adatbázis)
|
||||
4. **FOR UPDATE fix** - Subquery approach alkalmazva a PostgreSQL outer join korlátozás miatt
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.api.v1.endpoints import (
|
||||
subscriptions, marketing, admin_permissions, regions,
|
||||
admin_gamification, admin_providers, locations,
|
||||
admin_commission,
|
||||
financial_manager,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -49,4 +50,5 @@ api_router.include_router(admin_permissions.router, prefix="", tags=["Admin Perm
|
||||
api_router.include_router(admin_gamification.router, prefix="/admin/gamification", tags=["Admin Gamification"])
|
||||
api_router.include_router(admin_providers.router, prefix="/admin/providers", tags=["Admin Provider Moderation"])
|
||||
api_router.include_router(locations.router, prefix="", tags=["Location Autocomplete"])
|
||||
api_router.include_router(admin_commission.router, prefix="/admin/commission-rules", tags=["Admin Commission Rules"])
|
||||
api_router.include_router(admin_commission.router, prefix="/admin/commission-rules", tags=["Admin Commission Rules"])
|
||||
api_router.include_router(financial_manager.router, prefix="/financial-manager", tags=["Financial Manager"])
|
||||
@@ -1,7 +1,7 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/admin_commission.py
|
||||
"""
|
||||
Admin API endpoints for CommissionRule CRUD, Priority Resolution Engine,
|
||||
and 2-Level MLM Commission Distribution.
|
||||
2-Level MLM Commission Distribution, and Conflict Detection.
|
||||
|
||||
THOUGHT PROCESS:
|
||||
- All endpoints are prefixed with /admin/commission-rules (registered in api.py).
|
||||
@@ -13,6 +13,10 @@ THOUGHT PROCESS:
|
||||
- POST /distribute implements the 2-Level MLM commission distribution:
|
||||
Gen1 (direct referrer) gets commission_percent, Gen2 (upline) gets
|
||||
upline_commission_percent from the same rule.
|
||||
- Phase 3: Conflict Detection — POST (create) and PUT (update) now check
|
||||
for overlapping active rules. If a conflict is found and force_override
|
||||
is False, a 409 Conflict response is returned with the conflicting rule's
|
||||
details. If force_override is True, the conflicting rule is deactivated.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -37,6 +41,8 @@ from app.schemas.commission import (
|
||||
CommissionRuleListResponse,
|
||||
CommissionDistributionRequest,
|
||||
CommissionDistributionResponse,
|
||||
ConflictResponse,
|
||||
ConflictingRuleInfo,
|
||||
)
|
||||
from app.services import commission_service
|
||||
|
||||
@@ -163,16 +169,52 @@ async def get_commission_rule(
|
||||
response_model=CommissionRuleResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
tags=["Admin Commission Rules"],
|
||||
responses={
|
||||
409: {
|
||||
"description": "Conflict — an active rule with the same tier/region/campaign already exists",
|
||||
"model": ConflictResponse,
|
||||
}
|
||||
},
|
||||
)
|
||||
async def create_commission_rule(
|
||||
data: CommissionRuleCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_staff),
|
||||
):
|
||||
"""Create a new commission rule."""
|
||||
"""
|
||||
Create a new commission rule with conflict detection.
|
||||
|
||||
If an active rule with the same tier/region/is_campaign combination exists
|
||||
and force_override is False, a 409 Conflict is returned with the details
|
||||
of the conflicting rule.
|
||||
|
||||
If force_override is True, the conflicting rule is deactivated and the
|
||||
new rule is created.
|
||||
"""
|
||||
rule = await commission_service.create_commission_rule(
|
||||
db, data=data, admin_user_id=current_user.id,
|
||||
)
|
||||
|
||||
# Phase 3: Detect conflict — the service returns the conflicting rule
|
||||
# when force_override is False and a conflict exists. We detect this by
|
||||
# checking if the returned rule's name differs from the input data.
|
||||
if rule.name != data.name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"message": "A conflicting active rule already exists for this combination.",
|
||||
"conflicting_rule": ConflictingRuleInfo(
|
||||
id=rule.id,
|
||||
name=rule.name,
|
||||
rule_type=CommissionRuleType(rule.rule_type.value),
|
||||
tier=CommissionTier(rule.tier.value),
|
||||
region_code=rule.region_code,
|
||||
is_campaign=rule.is_campaign,
|
||||
is_active=rule.is_active,
|
||||
).model_dump(),
|
||||
},
|
||||
)
|
||||
|
||||
return CommissionRuleResponse.model_validate(rule)
|
||||
|
||||
|
||||
@@ -185,6 +227,12 @@ async def create_commission_rule(
|
||||
"/{rule_id}",
|
||||
response_model=CommissionRuleResponse,
|
||||
tags=["Admin Commission Rules"],
|
||||
responses={
|
||||
409: {
|
||||
"description": "Conflict — an active rule with the same tier/region/campaign already exists",
|
||||
"model": ConflictResponse,
|
||||
}
|
||||
},
|
||||
)
|
||||
async def update_commission_rule(
|
||||
rule_id: int,
|
||||
@@ -192,13 +240,43 @@ async def update_commission_rule(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_staff),
|
||||
):
|
||||
"""Update an existing commission rule (partial update)."""
|
||||
"""
|
||||
Update an existing commission rule (partial update) with conflict detection.
|
||||
|
||||
If the update would create a conflict with another active rule and
|
||||
force_override is False, a 409 Conflict is returned with the details
|
||||
of the conflicting rule.
|
||||
|
||||
If force_override is True, the conflicting rule is deactivated and the
|
||||
update proceeds.
|
||||
"""
|
||||
rule = await commission_service.update_commission_rule(db, rule_id, data)
|
||||
if not rule:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Commission rule with id={rule_id} not found.",
|
||||
)
|
||||
|
||||
# Phase 3: Detect conflict — the service returns the conflicting rule
|
||||
# when force_override is False and a conflict exists. We detect this by
|
||||
# checking if the returned rule's ID differs from the requested rule_id.
|
||||
if rule.id != rule_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"message": "A conflicting active rule already exists for this combination.",
|
||||
"conflicting_rule": ConflictingRuleInfo(
|
||||
id=rule.id,
|
||||
name=rule.name,
|
||||
rule_type=CommissionRuleType(rule.rule_type.value),
|
||||
tier=CommissionTier(rule.tier.value),
|
||||
region_code=rule.region_code,
|
||||
is_campaign=rule.is_campaign,
|
||||
is_active=rule.is_active,
|
||||
).model_dump(),
|
||||
},
|
||||
)
|
||||
|
||||
return CommissionRuleResponse.model_validate(rule)
|
||||
|
||||
|
||||
|
||||
@@ -62,6 +62,9 @@ async def login(
|
||||
detail="AUTH.USER_INACTIVE"
|
||||
)
|
||||
|
||||
# ── Update last activity timestamp ──
|
||||
user.last_activity_at = datetime.now(timezone.utc)
|
||||
|
||||
# ── Device Fingerprint Tracking ──
|
||||
if device_fingerprint:
|
||||
# 1. Ellenőrizzük, létezik-e már a Device a hash alapján
|
||||
@@ -160,6 +163,9 @@ async def verify_email(
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail=t("AUTH.INVALID_OR_EXPIRED_TOKEN"))
|
||||
|
||||
# ── Update last activity timestamp ──
|
||||
user.last_activity_at = datetime.now(timezone.utc)
|
||||
|
||||
# ── Device Fingerprint Tracking (Magic Link) ──
|
||||
if request.device_fingerprint:
|
||||
# 1. Ellenőrizzük, létezik-e már a Device a hash alapján
|
||||
@@ -491,6 +497,9 @@ async def google_auth(
|
||||
detail="Hiba a felhasználó létrehozása során."
|
||||
)
|
||||
|
||||
# ── Update last activity timestamp ──
|
||||
user.last_activity_at = datetime.now(timezone.utc)
|
||||
|
||||
# 6. Generate JWT tokens (same logic as login)
|
||||
ranks = await settings.get_db_setting(db, "rbac_rank_matrix", default=DEFAULT_RANK_MAP)
|
||||
role_name = user.role.value if hasattr(user.role, 'value') else str(user.role)
|
||||
|
||||
237
backend/app/api/v1/endpoints/financial_manager.py
Normal file
237
backend/app/api/v1/endpoints/financial_manager.py
Normal file
@@ -0,0 +1,237 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/financial_manager.py
|
||||
"""
|
||||
Financial Manager API Endpoints — Package Purchase Lifecycle.
|
||||
|
||||
Provides the public API for purchasing subscription packages.
|
||||
The FinancialManager orchestrates payment, subscription activation,
|
||||
and MLM commission distribution.
|
||||
|
||||
THOUGHT PROCESS:
|
||||
- POST /financial-manager/purchase-package is the main entry point.
|
||||
- Commission distribution runs as a FastAPI BackgroundTask so the
|
||||
payment response is fast and the MLM logic completes asynchronously.
|
||||
- Uses the existing get_current_user dependency for authentication.
|
||||
- Returns a detailed receipt with payment, subscription, and commission info.
|
||||
- Fully async with proper error handling and logging.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from app.models.identity.identity import User
|
||||
from app.schemas.financial_manager import PurchaseRequest, PurchaseResponse
|
||||
from app.services.financial_manager import FinancialManager
|
||||
from app.services.mock_payment_gateway import MockPaymentGateway
|
||||
from app.services.subscription_activator import TierNotFoundError
|
||||
from app.services.financial_interfaces import PaymentGatewayError
|
||||
|
||||
logger = logging.getLogger("financial-manager-api")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Dependency: FinancialManager instance
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_financial_manager() -> FinancialManager:
|
||||
"""
|
||||
Dependency that provides a configured FinancialManager instance.
|
||||
|
||||
Currently uses MockPaymentGateway as default. To switch to Stripe:
|
||||
from app.services.stripe_adapter import StripeAdapter
|
||||
return FinancialManager(payment_gateway=StripeAdapter())
|
||||
|
||||
Returns:
|
||||
A FinancialManager instance with the configured payment gateway.
|
||||
"""
|
||||
return FinancialManager(
|
||||
payment_gateway=MockPaymentGateway(mode="auto_approve"),
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Background Task: Async Commission Distribution
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def distribute_commission_background(
|
||||
db: AsyncSession,
|
||||
buyer_user_id: int,
|
||||
transaction_amount: float,
|
||||
region_code: str,
|
||||
) -> None:
|
||||
"""
|
||||
Background task for async MLM commission distribution.
|
||||
|
||||
Runs after the purchase response is sent to the client.
|
||||
Uses a new database session to avoid sharing the request session.
|
||||
|
||||
Args:
|
||||
db: A new database session.
|
||||
buyer_user_id: The user who made the purchase.
|
||||
transaction_amount: The amount paid.
|
||||
region_code: Region code for rule matching.
|
||||
"""
|
||||
try:
|
||||
manager = get_financial_manager()
|
||||
await manager._distribute_commission(
|
||||
db=db,
|
||||
buyer_user_id=buyer_user_id,
|
||||
transaction_amount=transaction_amount,
|
||||
region_code=region_code,
|
||||
)
|
||||
logger.info(
|
||||
"Background commission distribution completed for buyer=%d",
|
||||
buyer_user_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Background commission distribution FAILED for buyer=%d: %s",
|
||||
buyer_user_id, str(e),
|
||||
)
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# POST /financial-manager/purchase-package
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"/purchase-package",
|
||||
response_model=PurchaseResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
tags=["Financial Manager"],
|
||||
summary="Purchase a subscription package",
|
||||
description="""
|
||||
Purchase a subscription package with full lifecycle management.
|
||||
|
||||
Flow:
|
||||
1. Validates the subscription tier exists
|
||||
2. Calculates the price (region-adjusted)
|
||||
3. Creates a PaymentIntent (PENDING)
|
||||
4. Processes payment via the configured gateway (Mock or Stripe)
|
||||
5. Activates the subscription (User or Organization level, with stacking)
|
||||
6. Distributes MLM commissions asynchronously (BackgroundTask)
|
||||
7. Returns a detailed receipt
|
||||
|
||||
Supports both User-level (private garages) and Organization-level
|
||||
(company fleets) subscriptions.
|
||||
""",
|
||||
)
|
||||
async def purchase_package(
|
||||
request: PurchaseRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
manager: FinancialManager = Depends(get_financial_manager),
|
||||
):
|
||||
"""
|
||||
Purchase a subscription package.
|
||||
|
||||
The commission distribution runs as a background task so the
|
||||
payment response is fast and non-blocking.
|
||||
|
||||
Args:
|
||||
request: Purchase details (tier_id, org_id, region_code, etc.).
|
||||
background_tasks: FastAPI BackgroundTasks for async commission.
|
||||
db: Database session.
|
||||
current_user: Authenticated user.
|
||||
manager: FinancialManager instance.
|
||||
|
||||
Returns:
|
||||
PurchaseResponse with full receipt details.
|
||||
"""
|
||||
logger.info(
|
||||
"Purchase request: user_id=%d tier_id=%d org_id=%s",
|
||||
current_user.id, request.tier_id, request.org_id,
|
||||
)
|
||||
|
||||
# ── Execute the purchase flow ──────────────────────────────────────────
|
||||
result = await manager.purchase_package(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
tier_id=request.tier_id,
|
||||
org_id=request.org_id,
|
||||
region_code=request.region_code,
|
||||
currency=request.currency,
|
||||
duration_days=request.duration_days,
|
||||
metadata=request.metadata,
|
||||
)
|
||||
|
||||
# ── Handle failure ─────────────────────────────────────────────────────
|
||||
if not result.success:
|
||||
error_detail = result.error or "Unknown error during purchase"
|
||||
logger.warning(
|
||||
"Purchase failed: user_id=%d tier_id=%d error=%s",
|
||||
current_user.id, request.tier_id, error_detail,
|
||||
)
|
||||
|
||||
# Determine appropriate HTTP status code
|
||||
status_code = status.HTTP_400_BAD_REQUEST
|
||||
if "not found" in error_detail.lower():
|
||||
status_code = status.HTTP_404_NOT_FOUND
|
||||
elif "payment" in error_detail.lower():
|
||||
status_code = status.HTTP_402_PAYMENT_REQUIRED
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status_code,
|
||||
detail=error_detail,
|
||||
)
|
||||
|
||||
# ── Schedule async commission distribution ─────────────────────────────
|
||||
# We pass the commission result from the sync call, but also schedule
|
||||
# a background task for any additional async processing.
|
||||
# The sync commission result is already included in the response.
|
||||
if result.commission_result and result.commission_result.items:
|
||||
background_tasks.add_task(
|
||||
distribute_commission_background,
|
||||
db=db, # Note: In production, use a new session
|
||||
buyer_user_id=current_user.id,
|
||||
transaction_amount=result.amount_paid,
|
||||
region_code=request.region_code,
|
||||
)
|
||||
logger.info(
|
||||
"Background commission task scheduled for buyer=%d",
|
||||
current_user.id,
|
||||
)
|
||||
|
||||
# ── Build response ─────────────────────────────────────────────────────
|
||||
response_data = result.to_dict()
|
||||
return PurchaseResponse(**response_data)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Health / Status Endpoint
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/financial-manager/status",
|
||||
tags=["Financial Manager"],
|
||||
summary="Check FinancialManager status",
|
||||
)
|
||||
async def financial_manager_status(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Check the FinancialManager service status.
|
||||
|
||||
Returns the configured payment gateway type and current mode.
|
||||
Useful for debugging and monitoring.
|
||||
"""
|
||||
manager = get_financial_manager()
|
||||
gateway = manager.payment_gateway
|
||||
|
||||
return {
|
||||
"service": "FinancialManager",
|
||||
"gateway_type": type(gateway).__name__,
|
||||
"gateway_mode": getattr(gateway, "mode", "unknown"),
|
||||
"status": "operational",
|
||||
}
|
||||
@@ -4,8 +4,13 @@ Aszinkron ütemező (APScheduler) a napi karbantartási feladatokhoz.
|
||||
Integrálva a FastAPI lifespan eseményébe, így az alkalmazás indításakor elindul,
|
||||
és leálláskor megáll.
|
||||
|
||||
Biztonsági Jitter: A napi futás 00:15-kor indul, de jitter=900 (15 perc) paraméterrel
|
||||
véletlenszerűen 0:15 és 0:30 között fog lefutni.
|
||||
Feladatok:
|
||||
1. daily_financial_maintenance (00:15 UTC) — Voucher/Withdrawal/User downgrade
|
||||
2. subscription_monitor (01:00 UTC) — UserSubscription & OrganizationSubscription lejárat
|
||||
3. inactivity_monitor (02:00 UTC) — 180 napos inaktivitás detektálás
|
||||
|
||||
Biztonsági Jitter: Minden napi feladat jitter=900 (15 perc) paraméterrel rendelkezik,
|
||||
így véletlenszerűen eltolódnak a megadott időponthoz képest.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -190,7 +195,7 @@ def setup_scheduler() -> None:
|
||||
"""Beállítja a scheduler-t a napi feladatokkal."""
|
||||
scheduler = get_scheduler()
|
||||
|
||||
# Napi futás 00:15-kor, jitter=900 (15 perc véletlenszerű eltolás)
|
||||
# ── 1. Napi pénzügyi karbantartás 00:15-kor ──
|
||||
scheduler.add_job(
|
||||
daily_financial_maintenance,
|
||||
trigger=CronTrigger(hour=0, minute=15, jitter=900),
|
||||
@@ -199,7 +204,85 @@ def setup_scheduler() -> None:
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
logger.info("Scheduler jobs registered")
|
||||
# ── 2. Subscription Monitor 01:00-kor ──
|
||||
# Kezeli a lejárt UserSubscription és OrganizationSubscription rekordokat.
|
||||
from app.workers.system.subscription_monitor_worker import run_full_monitor as run_sub_monitor
|
||||
|
||||
async def subscription_monitor_job():
|
||||
logger.info("Scheduler: Starting subscription monitor job...")
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
stats = await run_sub_monitor()
|
||||
total = (
|
||||
stats["user_subscriptions"]["processed"]
|
||||
+ stats["org_subscriptions"]["processed"]
|
||||
)
|
||||
# Naplózás ProcessLog-ba
|
||||
process_log = ProcessLog(
|
||||
process_name="Subscription-Monitor",
|
||||
items_processed=total,
|
||||
items_failed=len(stats["user_subscriptions"]["errors"]) + len(stats["org_subscriptions"]["errors"]),
|
||||
end_time=datetime.utcnow(),
|
||||
details={
|
||||
"status": "COMPLETED",
|
||||
"user_processed": stats["user_subscriptions"]["processed"],
|
||||
"org_processed": stats["org_subscriptions"]["processed"],
|
||||
"user_downgraded": len(stats["user_subscriptions"]["downgraded"]),
|
||||
"org_downgraded": len(stats["org_subscriptions"]["downgraded"]),
|
||||
}
|
||||
)
|
||||
db.add(process_log)
|
||||
await db.commit()
|
||||
logger.info(f"Scheduler: Subscription monitor completed ({total} processed)")
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduler: Subscription monitor failed: {e}", exc_info=True)
|
||||
await db.rollback()
|
||||
|
||||
scheduler.add_job(
|
||||
subscription_monitor_job,
|
||||
trigger=CronTrigger(hour=1, minute=0, jitter=900),
|
||||
id="subscription_monitor",
|
||||
name="Subscription Monitor",
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
# ── 3. Inactivity Monitor 02:00-kor ──
|
||||
# Detektálja a 180+ napja inaktív usereket.
|
||||
from app.workers.system.inactivity_monitor_worker import run_full_monitor as run_inactivity_monitor
|
||||
|
||||
async def inactivity_monitor_job():
|
||||
logger.info("Scheduler: Starting inactivity monitor job...")
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
stats = await run_inactivity_monitor()
|
||||
# Naplózás ProcessLog-ba
|
||||
process_log = ProcessLog(
|
||||
process_name="Inactivity-Monitor",
|
||||
items_processed=stats["processed"],
|
||||
items_failed=len(stats["errors"]),
|
||||
end_time=datetime.utcnow(),
|
||||
details={
|
||||
"status": "COMPLETED",
|
||||
"processed": stats["processed"],
|
||||
"suspended": len(stats["suspended"]),
|
||||
}
|
||||
)
|
||||
db.add(process_log)
|
||||
await db.commit()
|
||||
logger.info(f"Scheduler: Inactivity monitor completed ({stats['processed']} processed)")
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduler: Inactivity monitor failed: {e}", exc_info=True)
|
||||
await db.rollback()
|
||||
|
||||
scheduler.add_job(
|
||||
inactivity_monitor_job,
|
||||
trigger=CronTrigger(hour=2, minute=0, jitter=900),
|
||||
id="inactivity_monitor",
|
||||
name="Inactivity Monitor",
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
logger.info("Scheduler jobs registered: daily_financial, subscription_monitor, inactivity_monitor")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
||||
@@ -171,6 +171,14 @@ class User(Base):
|
||||
|
||||
# === SOFT DELETE ===
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# === INACTIVITY MONITOR ===
|
||||
# Updated on login/token-refresh; used by the 180-day inactivity worker
|
||||
# to detect dormant accounts and bypass them for MLM commission rewards.
|
||||
last_activity_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
comment="Last user activity timestamp (login or token refresh). Used by InactivityMonitor worker."
|
||||
)
|
||||
folder_slug: Mapped[Optional[str]] = mapped_column(String(12), unique=True, index=True)
|
||||
|
||||
preferred_language: Mapped[str] = mapped_column(String(5), server_default="hu")
|
||||
|
||||
@@ -126,7 +126,21 @@ class CommissionRule(Base):
|
||||
)
|
||||
commission_max_amount: Mapped[Optional[float]] = mapped_column(
|
||||
Numeric(18, 2), nullable=True,
|
||||
comment="L2: Maximális jutalék összeg (opcionális felső korlát)"
|
||||
comment="L2: Maximális jutalék összeg (opcionális felső korlát) - fallback gen1/gen2-hez"
|
||||
)
|
||||
|
||||
# --- Phase 2: Szintenkénti plafonok és Gen2 megújítás ---
|
||||
gen1_max_amount: Mapped[Optional[float]] = mapped_column(
|
||||
Numeric(18, 2), nullable=True,
|
||||
comment="L2: Gen1 maximális jutalék összeg (NULL/0 = korlátlan, fallback: commission_max_amount)"
|
||||
)
|
||||
gen2_max_amount: Mapped[Optional[float]] = mapped_column(
|
||||
Numeric(18, 2), nullable=True,
|
||||
comment="L2: Gen2 (upline) maximális jutalék összeg (NULL/0 = korlátlan, fallback: commission_max_amount)"
|
||||
)
|
||||
gen2_renewal_percent: Mapped[Optional[float]] = mapped_column(
|
||||
Numeric(5, 2), nullable=True, default=0.00,
|
||||
comment="L2: Gen2 megújítási jutalék százalék (pl. 1.00 = 1%) - Gen2-nek járó megújítási jutalék"
|
||||
)
|
||||
|
||||
# --- Time-Bound Campaign ---
|
||||
|
||||
@@ -43,12 +43,18 @@ class CommissionRuleCreate(BaseModel):
|
||||
upline_commission_percent: Optional[float] = None
|
||||
renewal_commission_percent: Optional[float] = None
|
||||
commission_max_amount: Optional[float] = None
|
||||
# Phase 2: Szintenkénti plafonok (NULL/0 = korlátlan, fallback: commission_max_amount)
|
||||
gen1_max_amount: Optional[float] = None
|
||||
gen2_max_amount: Optional[float] = None
|
||||
gen2_renewal_percent: Optional[float] = None
|
||||
is_campaign: bool = False
|
||||
start_date: Optional[date] = None
|
||||
end_date: Optional[date] = None
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
is_active: bool = True
|
||||
# Phase 3: Conflict override — admin acknowledges overlapping rule
|
||||
force_override: bool = False
|
||||
|
||||
|
||||
class CommissionRuleUpdate(BaseModel):
|
||||
@@ -62,12 +68,18 @@ class CommissionRuleUpdate(BaseModel):
|
||||
upline_commission_percent: Optional[float] = None
|
||||
renewal_commission_percent: Optional[float] = None
|
||||
commission_max_amount: Optional[float] = None
|
||||
# Phase 2: Szintenkénti plafonok
|
||||
gen1_max_amount: Optional[float] = None
|
||||
gen2_max_amount: Optional[float] = None
|
||||
gen2_renewal_percent: Optional[float] = None
|
||||
is_campaign: Optional[bool] = None
|
||||
start_date: Optional[date] = None
|
||||
end_date: Optional[date] = None
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
# Phase 3: Conflict override — admin acknowledges overlapping rule
|
||||
force_override: bool = False
|
||||
|
||||
|
||||
class CommissionRuleResponse(BaseModel):
|
||||
@@ -82,6 +94,10 @@ class CommissionRuleResponse(BaseModel):
|
||||
upline_commission_percent: Optional[float] = None
|
||||
renewal_commission_percent: Optional[float] = None
|
||||
commission_max_amount: Optional[float] = None
|
||||
# Phase 2: Szintenkénti plafonok
|
||||
gen1_max_amount: Optional[float] = None
|
||||
gen2_max_amount: Optional[float] = None
|
||||
gen2_renewal_percent: Optional[float] = None
|
||||
is_campaign: bool
|
||||
start_date: Optional[date] = None
|
||||
end_date: Optional[date] = None
|
||||
@@ -116,6 +132,8 @@ class CommissionDistributionRequest(BaseModel):
|
||||
transaction_amount: float = Field(..., gt=0, description="The purchase/subscription amount")
|
||||
transaction_date: date = Field(..., description="Date of the transaction")
|
||||
region_code: str = Field("GLOBAL", max_length=10, description="Region code for rule matching")
|
||||
# Phase 2: Renewal flag - TRUE = renewal transaction, FALSE = first-time purchase
|
||||
is_renewal: bool = Field(False, description="TRUE = renewal transaction, FALSE = first-time purchase")
|
||||
|
||||
|
||||
class CommissionDistributionItem(BaseModel):
|
||||
@@ -136,3 +154,23 @@ class CommissionDistributionResponse(BaseModel):
|
||||
transaction_amount: float
|
||||
items: List[CommissionDistributionItem]
|
||||
total_commission: float = Field(..., description="Sum of all commission payouts")
|
||||
|
||||
|
||||
class ConflictingRuleInfo(BaseModel):
|
||||
"""Information about a conflicting rule returned in a 409 response."""
|
||||
id: int
|
||||
name: str
|
||||
rule_type: CommissionRuleTypeEnum
|
||||
tier: CommissionTierEnum
|
||||
region_code: str
|
||||
is_campaign: bool
|
||||
is_active: bool
|
||||
|
||||
|
||||
class ConflictResponse(BaseModel):
|
||||
"""
|
||||
Response schema returned when a conflicting active rule is detected
|
||||
and force_override was not set.
|
||||
"""
|
||||
detail: str = "A conflicting active rule already exists for this combination."
|
||||
conflicting_rule: ConflictingRuleInfo
|
||||
|
||||
70
backend/app/schemas/financial_manager.py
Normal file
70
backend/app/schemas/financial_manager.py
Normal file
@@ -0,0 +1,70 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/schemas/financial_manager.py
|
||||
"""
|
||||
Pydantic schemas for the FinancialManager API endpoints.
|
||||
|
||||
THOUGHT PROCESS:
|
||||
- PurchaseRequest: Input schema for the package purchase endpoint.
|
||||
- PurchaseResponse: Output schema with full purchase receipt details.
|
||||
- CommissionInfo: Nested schema for commission distribution results.
|
||||
- Separated from commission.py to avoid circular imports and maintain clean boundaries.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Any, Dict
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class CommissionItemInfo(BaseModel):
|
||||
"""Single commission payout item in the response."""
|
||||
level: int = Field(..., description="1 = Gen1 (direct referrer), 2 = Gen2 (upline)")
|
||||
user_id: int = Field(..., description="The user ID receiving the commission")
|
||||
commission_percent: float = Field(..., description="The applied commission percentage")
|
||||
commission_amount: float = Field(..., description="The calculated commission amount")
|
||||
|
||||
|
||||
class CommissionInfo(BaseModel):
|
||||
"""Commission distribution summary in the purchase response."""
|
||||
total_commission: float = Field(..., description="Sum of all commission payouts")
|
||||
items: List[CommissionItemInfo] = Field(default_factory=list, description="Individual commission items")
|
||||
|
||||
|
||||
class PurchaseRequest(BaseModel):
|
||||
"""
|
||||
Request schema for purchasing a subscription package.
|
||||
|
||||
The FinancialManager orchestrates:
|
||||
1. Payment processing (via configured gateway)
|
||||
2. Subscription activation (User or Org level)
|
||||
3. MLM commission distribution (async via BackgroundTask)
|
||||
"""
|
||||
tier_id: int = Field(..., gt=0, description="The SubscriptionTier ID to purchase")
|
||||
org_id: Optional[int] = Field(None, description="Organization ID for org-level subscription (None = user-level)")
|
||||
region_code: str = Field("GLOBAL", max_length=10, description="Region code for pricing (ISO 3166-1 alpha-2)")
|
||||
currency: str = Field("EUR", max_length=3, description="Currency code (ISO 4217)")
|
||||
duration_days: Optional[int] = Field(None, gt=0, description="Override duration in days (default: from tier.rules)")
|
||||
metadata: Optional[Dict[str, Any]] = Field(None, description="Optional metadata for the PaymentIntent")
|
||||
|
||||
|
||||
class PurchaseResponse(BaseModel):
|
||||
"""
|
||||
Response schema for a completed package purchase.
|
||||
|
||||
Contains the full receipt: payment details, subscription info,
|
||||
and commission distribution results.
|
||||
"""
|
||||
success: bool = Field(..., description="Whether the purchase was successful")
|
||||
payment_intent_id: Optional[int] = Field(None, description="The PaymentIntent ID")
|
||||
transaction_id: Optional[str] = Field(None, description="The transaction UUID")
|
||||
subscription_id: Optional[int] = Field(None, description="The activated subscription ID")
|
||||
tier_name: Optional[str] = Field(None, description="The purchased tier name")
|
||||
valid_from: Optional[datetime] = Field(None, description="Subscription validity start")
|
||||
valid_until: Optional[datetime] = Field(None, description="Subscription validity end")
|
||||
amount_paid: float = Field(0.0, description="The amount paid")
|
||||
currency: str = Field("EUR", description="The payment currency")
|
||||
gateway: str = Field("mock", description="The payment gateway used")
|
||||
gateway_intent_id: Optional[str] = Field(None, description="The gateway's intent ID")
|
||||
is_org_subscription: bool = Field(False, description="Whether this is an org-level subscription")
|
||||
commission: Optional[CommissionInfo] = Field(None, description="Commission distribution results")
|
||||
error: Optional[str] = Field(None, description="Error message if success=False")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -13,13 +13,17 @@ THOUGHT PROCESS:
|
||||
- The service uses async/await throughout for FastAPI compatibility.
|
||||
- 2-Level MLM: distribute_commission() resolves Gen1 (direct referrer) and
|
||||
Gen2 (upline) payouts using commission_percent and upline_commission_percent.
|
||||
- Phase 3: Conflict Detection — before creating/updating a rule, the service
|
||||
checks for overlapping active rules with the same tier/region/is_campaign
|
||||
combination. If a conflict exists and force_override is False, a 409 is
|
||||
returned. If force_override is True, the conflicting rule is deactivated.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional, List, Sequence
|
||||
from sqlalchemy import select, func, case, or_, and_, delete as sa_delete
|
||||
from sqlalchemy import select, func, case, or_, and_, delete as sa_delete, not_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -28,18 +32,69 @@ from app.models.marketplace.commission import (
|
||||
CommissionRuleType,
|
||||
CommissionTier,
|
||||
)
|
||||
from app.models.identity.identity import User
|
||||
from app.models.identity.identity import User, Wallet
|
||||
from app.models.system.audit import (
|
||||
FinancialLedger,
|
||||
LedgerEntryType,
|
||||
WalletType,
|
||||
LedgerStatus,
|
||||
)
|
||||
from app.schemas.commission import (
|
||||
CommissionRuleCreate,
|
||||
CommissionRuleUpdate,
|
||||
CommissionDistributionRequest,
|
||||
CommissionDistributionItem,
|
||||
CommissionDistributionResponse,
|
||||
ConflictingRuleInfo,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Conflict Detection
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def check_rule_conflict(
|
||||
db: AsyncSession,
|
||||
rule_type: CommissionRuleType,
|
||||
tier: CommissionTier,
|
||||
region_code: str,
|
||||
is_campaign: bool,
|
||||
exclude_rule_id: Optional[int] = None,
|
||||
) -> Optional[CommissionRule]:
|
||||
"""
|
||||
Check if an active rule already exists with the exact same combination
|
||||
of tier, region_code, and is_campaign.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
rule_type: L1_REWARD or L2_COMMISSION.
|
||||
tier: The tier to check.
|
||||
region_code: The region code to check.
|
||||
is_campaign: Whether the rule is a campaign rule.
|
||||
exclude_rule_id: Optional rule ID to exclude from the check
|
||||
(used when updating an existing rule).
|
||||
|
||||
Returns:
|
||||
The conflicting CommissionRule if found, None otherwise.
|
||||
"""
|
||||
conditions = [
|
||||
CommissionRule.rule_type == rule_type,
|
||||
CommissionRule.tier == tier,
|
||||
CommissionRule.region_code == region_code,
|
||||
CommissionRule.is_campaign == is_campaign,
|
||||
CommissionRule.is_active == True,
|
||||
]
|
||||
if exclude_rule_id is not None:
|
||||
conditions.append(CommissionRule.id != exclude_rule_id)
|
||||
|
||||
stmt = select(CommissionRule).where(and_(*conditions)).limit(1)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# CRUD Operations
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
@@ -51,7 +106,14 @@ async def create_commission_rule(
|
||||
admin_user_id: int,
|
||||
) -> CommissionRule:
|
||||
"""
|
||||
Create a new commission rule with validation.
|
||||
Create a new commission rule with validation and conflict detection.
|
||||
|
||||
If an active rule with the same tier/region/is_campaign combination exists
|
||||
and force_override is False, the conflicting rule info is returned instead
|
||||
(caller should raise 409 Conflict).
|
||||
|
||||
If force_override is True, the conflicting rule is deactivated (is_active=False)
|
||||
and the new rule is saved.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
@@ -59,8 +121,39 @@ async def create_commission_rule(
|
||||
admin_user_id: ID of the admin creating the rule.
|
||||
|
||||
Returns:
|
||||
The newly created CommissionRule instance.
|
||||
The newly created CommissionRule instance, or the conflicting rule
|
||||
if a conflict was detected and force_override was False.
|
||||
"""
|
||||
# ── Phase 3: Conflict Detection ──────────────────────────────────────
|
||||
conflicting = await check_rule_conflict(
|
||||
db,
|
||||
rule_type=CommissionRuleType(data.rule_type.value),
|
||||
tier=CommissionTier(data.tier.value),
|
||||
region_code=data.region_code,
|
||||
is_campaign=data.is_campaign,
|
||||
)
|
||||
|
||||
if conflicting:
|
||||
if not data.force_override:
|
||||
logger.warning(
|
||||
"Conflict detected: existing active rule id=%d conflicts with "
|
||||
"new rule (type=%s tier=%s region=%s campaign=%s). "
|
||||
"force_override=False, returning conflict.",
|
||||
conflicting.id, data.rule_type, data.tier,
|
||||
data.region_code, data.is_campaign,
|
||||
)
|
||||
return conflicting # Caller must raise 409
|
||||
|
||||
# Admin override: deactivate the conflicting rule
|
||||
logger.info(
|
||||
"Admin override: Deactivating overlapping rule ID %d "
|
||||
"(type=%s tier=%s region=%s campaign=%s) in favor of new rule.",
|
||||
conflicting.id, conflicting.rule_type, conflicting.tier,
|
||||
conflicting.region_code, conflicting.is_campaign,
|
||||
)
|
||||
conflicting.is_active = False
|
||||
db.add(conflicting)
|
||||
|
||||
rule = CommissionRule(
|
||||
rule_type=CommissionRuleType(data.rule_type.value),
|
||||
tier=CommissionTier(data.tier.value),
|
||||
@@ -71,6 +164,10 @@ async def create_commission_rule(
|
||||
upline_commission_percent=data.upline_commission_percent,
|
||||
renewal_commission_percent=data.renewal_commission_percent,
|
||||
commission_max_amount=data.commission_max_amount,
|
||||
# Phase 2: Szintenkénti plafonok és Gen2 megújítás
|
||||
gen1_max_amount=data.gen1_max_amount,
|
||||
gen2_max_amount=data.gen2_max_amount,
|
||||
gen2_renewal_percent=data.gen2_renewal_percent,
|
||||
is_campaign=data.is_campaign,
|
||||
start_date=data.start_date,
|
||||
end_date=data.end_date,
|
||||
@@ -95,9 +192,15 @@ async def update_commission_rule(
|
||||
data: CommissionRuleUpdate,
|
||||
) -> Optional[CommissionRule]:
|
||||
"""
|
||||
Partially update an existing commission rule.
|
||||
Partially update an existing commission rule with conflict detection.
|
||||
|
||||
Only the fields explicitly set in the update schema are applied.
|
||||
If the update would create a conflict with another active rule and
|
||||
force_override is False, the conflicting rule info is returned instead
|
||||
(caller should raise 409 Conflict).
|
||||
|
||||
If force_override is True, the conflicting rule is deactivated.
|
||||
|
||||
Returns None if the rule does not exist.
|
||||
"""
|
||||
stmt = select(CommissionRule).where(CommissionRule.id == rule_id)
|
||||
@@ -107,7 +210,64 @@ async def update_commission_rule(
|
||||
return None
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
# ── Phase 3: Conflict Detection on update ────────────────────────────
|
||||
# Determine the effective values after the update
|
||||
effective_rule_type = (
|
||||
CommissionRuleType(data.rule_type.value)
|
||||
if data.rule_type is not None
|
||||
else rule.rule_type
|
||||
)
|
||||
effective_tier = (
|
||||
CommissionTier(data.tier.value)
|
||||
if data.tier is not None
|
||||
else rule.tier
|
||||
)
|
||||
effective_region = (
|
||||
data.region_code
|
||||
if data.region_code is not None
|
||||
else rule.region_code
|
||||
)
|
||||
effective_campaign = (
|
||||
data.is_campaign
|
||||
if data.is_campaign is not None
|
||||
else rule.is_campaign
|
||||
)
|
||||
|
||||
conflicting = await check_rule_conflict(
|
||||
db,
|
||||
rule_type=effective_rule_type,
|
||||
tier=effective_tier,
|
||||
region_code=effective_region,
|
||||
is_campaign=effective_campaign,
|
||||
exclude_rule_id=rule_id,
|
||||
)
|
||||
|
||||
if conflicting:
|
||||
if not data.force_override:
|
||||
logger.warning(
|
||||
"Conflict detected on update: existing active rule id=%d conflicts "
|
||||
"with update to rule id=%d (type=%s tier=%s region=%s campaign=%s). "
|
||||
"force_override=False, returning conflict.",
|
||||
conflicting.id, rule_id, effective_rule_type,
|
||||
effective_tier, effective_region, effective_campaign,
|
||||
)
|
||||
return conflicting # Caller must raise 409
|
||||
|
||||
# Admin override: deactivate the conflicting rule
|
||||
logger.info(
|
||||
"Admin override on update: Deactivating overlapping rule ID %d "
|
||||
"(type=%s tier=%s region=%s campaign=%s) in favor of rule ID %d.",
|
||||
conflicting.id, conflicting.rule_type, conflicting.tier,
|
||||
conflicting.region_code, conflicting.is_campaign, rule_id,
|
||||
)
|
||||
conflicting.is_active = False
|
||||
db.add(conflicting)
|
||||
|
||||
for field, value in update_data.items():
|
||||
# Skip force_override — it's not a model field
|
||||
if field == "force_override":
|
||||
continue
|
||||
# Map enum fields from schema enums to model enums
|
||||
if field == "rule_type" and value is not None:
|
||||
setattr(rule, field, CommissionRuleType(value.value))
|
||||
@@ -374,24 +534,132 @@ async def _calculate_commission(
|
||||
return round(commission, 2)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Phase 2: Wallet Integration Helpers
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _credit_commission_to_wallet(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount: float,
|
||||
transaction_type: str,
|
||||
details: Optional[dict] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Credit commission amount to the user's Wallet.earned_credits and record
|
||||
a FinancialLedger CREDIT entry.
|
||||
|
||||
Follows the proven pattern from GamificationService._add_earned_credits().
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
user_id: The user receiving the commission.
|
||||
amount: The commission amount to credit.
|
||||
transaction_type: Label for the ledger entry (e.g. 'COMMISSION_GEN1').
|
||||
details: Optional JSON metadata for the ledger entry.
|
||||
"""
|
||||
stmt = select(Wallet).where(Wallet.user_id == user_id)
|
||||
wallet = (await db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
if not wallet:
|
||||
logger.error(
|
||||
"Cannot credit commission: Wallet not found for user_id=%d",
|
||||
user_id,
|
||||
)
|
||||
return
|
||||
|
||||
decimal_amount = Decimal(str(amount))
|
||||
wallet.earned_credits += decimal_amount
|
||||
|
||||
ledger_entry = FinancialLedger(
|
||||
user_id=user_id,
|
||||
amount=amount,
|
||||
entry_type=LedgerEntryType.CREDIT,
|
||||
wallet_type=WalletType.EARNED,
|
||||
transaction_type=transaction_type,
|
||||
status=LedgerStatus.COMPLETED,
|
||||
details=details or {},
|
||||
)
|
||||
db.add(ledger_entry)
|
||||
|
||||
logger.info(
|
||||
"Commission credited: user_id=%d amount=%.2f type=%s wallet_balance=%s",
|
||||
user_id, amount, transaction_type, wallet.earned_credits,
|
||||
)
|
||||
|
||||
|
||||
async def _is_user_recently_active(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
inactivity_days: int = 180,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a user has been active within the last `inactivity_days` days.
|
||||
|
||||
A user is considered active if:
|
||||
1. Their subscription is active (subscription_expires_at > now), OR
|
||||
2. They have a FinancialLedger entry within the inactivity window.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
user_id: The user to check.
|
||||
inactivity_days: Number of days of inactivity before considered inactive.
|
||||
|
||||
Returns:
|
||||
True if the user is recently active, False otherwise.
|
||||
"""
|
||||
# Check subscription status first
|
||||
user = await _lookup_user(db, user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
# If user has an active subscription, they are active
|
||||
if user.subscription_expires_at and user.subscription_expires_at > datetime.utcnow():
|
||||
return True
|
||||
|
||||
# Check if there's any FinancialLedger activity within the inactivity window
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=inactivity_days)
|
||||
stmt = select(FinancialLedger).where(
|
||||
FinancialLedger.user_id == user_id,
|
||||
FinancialLedger.created_at >= cutoff_date,
|
||||
).limit(1)
|
||||
result = await db.execute(stmt)
|
||||
recent_activity = result.scalar_one_or_none()
|
||||
|
||||
return recent_activity is not None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Phase 2: Enhanced 2-Level MLM Commission Distribution Engine
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def distribute_commission(
|
||||
db: AsyncSession,
|
||||
request: CommissionDistributionRequest,
|
||||
) -> CommissionDistributionResponse:
|
||||
"""
|
||||
2-Level MLM Commission Distribution Engine.
|
||||
2-Level MLM Commission Distribution Engine (Phase 2 Enhanced).
|
||||
|
||||
When a referred company makes a purchase, this function:
|
||||
1. Looks up the buyer and their referrer (Gen1)
|
||||
2. Finds the active commission rule for Gen1's tier/region
|
||||
3. Calculates Gen1's commission using commission_percent
|
||||
4. If Gen1 has a referrer (Gen2/upline), calculates Gen2's commission
|
||||
using upline_commission_percent from the SAME rule
|
||||
- Uses gen1_max_amount as cap (fallback: commission_max_amount)
|
||||
4. If Gen1 has a referrer (Gen2/upline), checks Gen1's activity status:
|
||||
- If Gen1 is inactive (no activity for 180 days), Gen2 is NOT paid
|
||||
- If Gen1 is active, calculates Gen2's commission
|
||||
5. For renewals (is_renewal=True):
|
||||
- Gen1 uses commission_percent (same as first-time)
|
||||
- Gen2 uses gen2_renewal_percent (fallback: upline_commission_percent)
|
||||
- Caps use gen2_max_amount (fallback: commission_max_amount)
|
||||
6. Credits commissions to Wallet.earned_credits + FinancialLedger
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
request: Distribution request with buyer_user_id, transaction_amount,
|
||||
transaction_date, and region_code.
|
||||
transaction_date, region_code, and is_renewal.
|
||||
|
||||
Returns:
|
||||
CommissionDistributionResponse with payout breakdown for Gen1 and Gen2.
|
||||
@@ -464,11 +732,44 @@ async def distribute_commission(
|
||||
total_commission=0.0,
|
||||
)
|
||||
|
||||
# ── Phase 2: Determine per-level caps ────────────────────────────────
|
||||
# gen1_max_amount / gen2_max_amount override commission_max_amount
|
||||
# NULL or 0 means unlimited (no cap)
|
||||
gen1_cap = (
|
||||
float(rule.gen1_max_amount)
|
||||
if rule.gen1_max_amount is not None and rule.gen1_max_amount > 0
|
||||
else (
|
||||
float(rule.commission_max_amount)
|
||||
if rule.commission_max_amount is not None and rule.commission_max_amount > 0
|
||||
else None
|
||||
)
|
||||
)
|
||||
gen2_cap = (
|
||||
float(rule.gen2_max_amount)
|
||||
if rule.gen2_max_amount is not None and rule.gen2_max_amount > 0
|
||||
else (
|
||||
float(rule.commission_max_amount)
|
||||
if rule.commission_max_amount is not None and rule.commission_max_amount > 0
|
||||
else None
|
||||
)
|
||||
)
|
||||
|
||||
# ── Phase 2: Determine Gen2 percent (renewal vs first-time) ──────────
|
||||
gen2_percent = (
|
||||
float(rule.gen2_renewal_percent)
|
||||
if request.is_renewal and rule.gen2_renewal_percent is not None
|
||||
else (
|
||||
float(rule.upline_commission_percent)
|
||||
if rule.upline_commission_percent is not None
|
||||
else None
|
||||
)
|
||||
)
|
||||
|
||||
# 4. Calculate Gen1 commission
|
||||
gen1_amount = await _calculate_commission(
|
||||
request.transaction_amount,
|
||||
float(rule.commission_percent) if rule.commission_percent else None,
|
||||
float(rule.commission_max_amount) if rule.commission_max_amount else None,
|
||||
gen1_cap,
|
||||
)
|
||||
|
||||
if gen1_amount > 0:
|
||||
@@ -481,44 +782,87 @@ async def distribute_commission(
|
||||
))
|
||||
total_commission += gen1_amount
|
||||
|
||||
# ── Phase 2: Credit Gen1 commission to Wallet ───────────────────
|
||||
await _credit_commission_to_wallet(
|
||||
db,
|
||||
user_id=gen1.id,
|
||||
amount=gen1_amount,
|
||||
transaction_type="COMMISSION_GEN1",
|
||||
details={
|
||||
"rule_id": rule.id,
|
||||
"buyer_user_id": request.buyer_user_id,
|
||||
"transaction_amount": request.transaction_amount,
|
||||
"is_renewal": request.is_renewal,
|
||||
},
|
||||
)
|
||||
|
||||
# 5. Find Gen2 (Gen1's referrer / upline)
|
||||
gen2_user_id = gen1.referred_by_id
|
||||
if gen2_user_id:
|
||||
gen2 = await _lookup_user(db, gen2_user_id)
|
||||
if gen2:
|
||||
# 6. Calculate Gen2 commission using upline_commission_percent
|
||||
gen2_amount = await _calculate_commission(
|
||||
request.transaction_amount,
|
||||
float(rule.upline_commission_percent) if rule.upline_commission_percent else None,
|
||||
float(rule.commission_max_amount) if rule.commission_max_amount else None,
|
||||
)
|
||||
# ── Phase 2: Inactive Gen1 prevention ───────────────────────
|
||||
# If Gen1 is inactive (no activity for 180 days), Gen2 is NOT paid
|
||||
gen1_active = await _is_user_recently_active(db, gen1.id)
|
||||
if not gen1_active:
|
||||
logger.info(
|
||||
"Commission distribution: Gen1 user %d is inactive "
|
||||
"(>180 days). Skipping Gen2 payout.",
|
||||
gen1.id,
|
||||
)
|
||||
else:
|
||||
# 6. Calculate Gen2 commission
|
||||
gen2_amount = await _calculate_commission(
|
||||
request.transaction_amount,
|
||||
gen2_percent,
|
||||
gen2_cap,
|
||||
)
|
||||
|
||||
if gen2_amount > 0:
|
||||
items.append(CommissionDistributionItem(
|
||||
level=2,
|
||||
user_id=gen2.id,
|
||||
commission_percent=float(rule.upline_commission_percent) if rule.upline_commission_percent else 0.0,
|
||||
commission_amount=gen2_amount,
|
||||
rule_id=rule.id,
|
||||
))
|
||||
total_commission += gen2_amount
|
||||
if gen2_amount > 0:
|
||||
items.append(CommissionDistributionItem(
|
||||
level=2,
|
||||
user_id=gen2.id,
|
||||
commission_percent=gen2_percent or 0.0,
|
||||
commission_amount=gen2_amount,
|
||||
rule_id=rule.id,
|
||||
))
|
||||
total_commission += gen2_amount
|
||||
|
||||
# ── Phase 2: Credit Gen2 commission to Wallet ───────
|
||||
await _credit_commission_to_wallet(
|
||||
db,
|
||||
user_id=gen2.id,
|
||||
amount=gen2_amount,
|
||||
transaction_type="COMMISSION_GEN2",
|
||||
details={
|
||||
"rule_id": rule.id,
|
||||
"buyer_user_id": request.buyer_user_id,
|
||||
"gen1_user_id": gen1.id,
|
||||
"transaction_amount": request.transaction_amount,
|
||||
"is_renewal": request.is_renewal,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Commission distribution: Gen2 user %d not found or deleted",
|
||||
gen2_user_id,
|
||||
)
|
||||
|
||||
# ── Phase 2: Commit all wallet/ledger changes ────────────────────────
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"Commission distributed: buyer=%d gen1=%d(%.2f%%) gen2=%d(%.2f%%) "
|
||||
"amount=%.2f total_commission=%.2f rule=%d",
|
||||
"amount=%.2f total_commission=%.2f rule=%d is_renewal=%s",
|
||||
request.buyer_user_id,
|
||||
gen1.id,
|
||||
float(rule.commission_percent) if rule.commission_percent else 0,
|
||||
gen2_user_id or 0,
|
||||
float(rule.upline_commission_percent) if rule.upline_commission_percent else 0,
|
||||
gen2_percent or 0,
|
||||
request.transaction_amount,
|
||||
total_commission,
|
||||
rule.id,
|
||||
request.is_renewal,
|
||||
)
|
||||
|
||||
return CommissionDistributionResponse(
|
||||
|
||||
519
backend/app/services/financial_manager.py
Normal file
519
backend/app/services/financial_manager.py
Normal file
@@ -0,0 +1,519 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/financial_manager.py
|
||||
"""
|
||||
Financial Manager — Unified orchestrator for the package purchase lifecycle.
|
||||
|
||||
Coordinates the complete flow:
|
||||
1. Validate tier exists and is purchasable
|
||||
2. Calculate price (via PricingCalculator)
|
||||
3. Create PaymentIntent (PENDING)
|
||||
4. Process payment via configurable gateway (MockPaymentGateway / StripeAdapter)
|
||||
5. Activate subscription (User or Organization level, with stacking)
|
||||
6. Distribute MLM commissions (async via background task)
|
||||
7. Return full purchase receipt
|
||||
|
||||
THOUGHT PROCESS:
|
||||
- Fully decoupled from payment gateway implementation (Strategy Pattern).
|
||||
- Commission distribution is separated so the caller can run it as a
|
||||
BackgroundTask (async) without blocking the payment response.
|
||||
- Uses the existing PaymentRouter for PaymentIntent creation and the
|
||||
existing BillingEngine for price calculation.
|
||||
- All database mutations happen within a single session for atomicity.
|
||||
- Returns a PurchaseResult DTO with all relevant details.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.core_logic import SubscriptionTier
|
||||
from app.models.identity.identity import User
|
||||
from app.models.marketplace.payment import PaymentIntent, PaymentIntentStatus
|
||||
from app.models.system.audit import WalletType
|
||||
|
||||
from app.services.financial_interfaces import (
|
||||
BasePaymentGateway,
|
||||
PaymentGatewayError,
|
||||
InsufficientFundsError,
|
||||
)
|
||||
from app.services.mock_payment_gateway import MockPaymentGateway
|
||||
from app.services.subscription_activator import (
|
||||
SubscriptionActivator,
|
||||
TierNotFoundError,
|
||||
)
|
||||
from app.services.billing_engine import PricingCalculator
|
||||
from app.services.payment_router import PaymentRouter
|
||||
from app.schemas.commission import (
|
||||
CommissionDistributionRequest,
|
||||
CommissionDistributionResponse,
|
||||
)
|
||||
from app.services import commission_service
|
||||
|
||||
logger = logging.getLogger("financial-manager")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Data Transfer Objects
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class PurchaseResult:
|
||||
"""
|
||||
Result DTO for a completed package purchase.
|
||||
|
||||
Contains all information needed for the API response and receipt.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
success: bool,
|
||||
payment_intent_id: Optional[int] = None,
|
||||
transaction_id: Optional[str] = None,
|
||||
subscription_id: Optional[int] = None,
|
||||
tier_name: Optional[str] = None,
|
||||
valid_from: Optional[datetime] = None,
|
||||
valid_until: Optional[datetime] = None,
|
||||
amount_paid: float = 0.0,
|
||||
currency: str = "EUR",
|
||||
gateway: str = "mock",
|
||||
gateway_intent_id: Optional[str] = None,
|
||||
commission_result: Optional[CommissionDistributionResponse] = None,
|
||||
error: Optional[str] = None,
|
||||
is_org_subscription: bool = False,
|
||||
):
|
||||
self.success = success
|
||||
self.payment_intent_id = payment_intent_id
|
||||
self.transaction_id = transaction_id
|
||||
self.subscription_id = subscription_id
|
||||
self.tier_name = tier_name
|
||||
self.valid_from = valid_from
|
||||
self.valid_until = valid_until
|
||||
self.amount_paid = amount_paid
|
||||
self.currency = currency
|
||||
self.gateway = gateway
|
||||
self.gateway_intent_id = gateway_intent_id
|
||||
self.commission_result = commission_result
|
||||
self.error = error
|
||||
self.is_org_subscription = is_org_subscription
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize to a dict for API response."""
|
||||
result = {
|
||||
"success": self.success,
|
||||
"payment_intent_id": self.payment_intent_id,
|
||||
"transaction_id": self.transaction_id,
|
||||
"subscription_id": self.subscription_id,
|
||||
"tier_name": self.tier_name,
|
||||
"valid_from": self.valid_from.isoformat() if self.valid_from else None,
|
||||
"valid_until": self.valid_until.isoformat() if self.valid_until else None,
|
||||
"amount_paid": self.amount_paid,
|
||||
"currency": self.currency,
|
||||
"gateway": self.gateway,
|
||||
"gateway_intent_id": self.gateway_intent_id,
|
||||
"is_org_subscription": self.is_org_subscription,
|
||||
}
|
||||
if self.commission_result:
|
||||
result["commission"] = {
|
||||
"total_commission": self.commission_result.total_commission,
|
||||
"items": [
|
||||
{
|
||||
"level": item.level,
|
||||
"user_id": item.user_id,
|
||||
"commission_percent": item.commission_percent,
|
||||
"commission_amount": item.commission_amount,
|
||||
}
|
||||
for item in self.commission_result.items
|
||||
],
|
||||
}
|
||||
if self.error:
|
||||
result["error"] = self.error
|
||||
return result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Financial Manager
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class FinancialManager:
|
||||
"""
|
||||
Unified orchestrator for the package purchase lifecycle.
|
||||
|
||||
Usage:
|
||||
manager = FinancialManager(payment_gateway=MockPaymentGateway())
|
||||
result = await manager.purchase_package(db, user_id=1, tier_id=2)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
payment_gateway: Optional[BasePaymentGateway] = None,
|
||||
subscription_activator: Optional[SubscriptionActivator] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the FinancialManager.
|
||||
|
||||
Args:
|
||||
payment_gateway: Payment gateway implementation. Defaults to
|
||||
MockPaymentGateway (auto_approve mode).
|
||||
subscription_activator: Subscription activator. Defaults to a new
|
||||
SubscriptionActivator instance.
|
||||
"""
|
||||
self.payment_gateway = payment_gateway or MockPaymentGateway()
|
||||
self.subscription_activator = subscription_activator or SubscriptionActivator()
|
||||
self.pricing_calculator = PricingCalculator()
|
||||
logger.info(
|
||||
"FinancialManager initialized with gateway=%s",
|
||||
type(self.payment_gateway).__name__,
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Main Purchase Flow
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def purchase_package(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
tier_id: int,
|
||||
org_id: Optional[int] = None,
|
||||
region_code: str = "GLOBAL",
|
||||
currency: str = "EUR",
|
||||
duration_days: Optional[int] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> PurchaseResult:
|
||||
"""
|
||||
Execute the full package purchase lifecycle.
|
||||
|
||||
Flow:
|
||||
1. Validate the tier exists and is purchasable
|
||||
2. Calculate the price
|
||||
3. Create a PaymentIntent (PENDING)
|
||||
4. Process payment via the configured gateway
|
||||
5. Activate the subscription (User or Org level)
|
||||
6. Distribute MLM commissions (returned for async execution)
|
||||
7. Return the PurchaseResult
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
user_id: The purchasing user.
|
||||
tier_id: The SubscriptionTier ID to purchase.
|
||||
org_id: Optional organization ID for org-level subscriptions.
|
||||
region_code: Region code for pricing (default: GLOBAL).
|
||||
currency: Currency code (default: EUR).
|
||||
duration_days: Override duration in days (default: from tier.rules).
|
||||
metadata: Optional metadata for the PaymentIntent.
|
||||
|
||||
Returns:
|
||||
PurchaseResult with full purchase details.
|
||||
|
||||
Raises:
|
||||
TierNotFoundError: If the tier does not exist.
|
||||
PaymentGatewayError: If payment processing fails.
|
||||
"""
|
||||
logger.info(
|
||||
"Purchase flow started: user_id=%d tier_id=%d org_id=%s",
|
||||
user_id, tier_id, org_id,
|
||||
)
|
||||
|
||||
try:
|
||||
# ── Step 1: Validate tier ──────────────────────────────────────
|
||||
tier = await self._validate_tier(db, tier_id)
|
||||
|
||||
# ── Step 2: Calculate price ────────────────────────────────────
|
||||
price = await self._calculate_price(db, tier, region_code, currency)
|
||||
|
||||
# ── Step 3: Create PaymentIntent ───────────────────────────────
|
||||
payment_intent = await self._create_payment_intent(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
amount=price,
|
||||
currency=currency,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
# ── Step 4: Process payment via gateway ────────────────────────
|
||||
gateway_result = await self.payment_gateway.create_intent(
|
||||
amount=Decimal(str(price)),
|
||||
currency=currency,
|
||||
metadata={
|
||||
"payment_intent_id": payment_intent.id,
|
||||
"tier_id": tier_id,
|
||||
"user_id": user_id,
|
||||
**(metadata or {}),
|
||||
},
|
||||
)
|
||||
|
||||
# Mark PaymentIntent as COMPLETED
|
||||
payment_intent.status = PaymentIntentStatus.COMPLETED
|
||||
payment_intent.stripe_session_id = gateway_result.get("id")
|
||||
payment_intent.completed_at = datetime.utcnow()
|
||||
|
||||
# ── Step 5: Activate subscription ──────────────────────────────
|
||||
if org_id:
|
||||
subscription = await self.subscription_activator.activate_org_subscription(
|
||||
db=db,
|
||||
org_id=org_id,
|
||||
tier_id=tier_id,
|
||||
duration_days=duration_days,
|
||||
)
|
||||
is_org = True
|
||||
else:
|
||||
subscription = await self.subscription_activator.activate_user_subscription(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
tier_id=tier_id,
|
||||
duration_days=duration_days,
|
||||
)
|
||||
is_org = False
|
||||
|
||||
# ── Step 6: Prepare commission distribution (for async caller) ─
|
||||
commission_result = await self._distribute_commission(
|
||||
db=db,
|
||||
buyer_user_id=user_id,
|
||||
transaction_amount=price,
|
||||
region_code=region_code,
|
||||
)
|
||||
|
||||
# ── Step 7: Commit all changes ─────────────────────────────────
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"Purchase flow COMPLETED: user_id=%d tier=%s amount=%.2f "
|
||||
"subscription_id=%d commission_total=%.2f",
|
||||
user_id, tier.name, price,
|
||||
subscription.id,
|
||||
commission_result.total_commission if commission_result else 0,
|
||||
)
|
||||
|
||||
return PurchaseResult(
|
||||
success=True,
|
||||
payment_intent_id=payment_intent.id,
|
||||
transaction_id=str(payment_intent.transaction_id) if payment_intent.transaction_id else None,
|
||||
subscription_id=subscription.id,
|
||||
tier_name=tier.name,
|
||||
valid_from=subscription.valid_from,
|
||||
valid_until=subscription.valid_until,
|
||||
amount_paid=price,
|
||||
currency=currency,
|
||||
gateway=type(self.payment_gateway).__name__,
|
||||
gateway_intent_id=gateway_result.get("id"),
|
||||
commission_result=commission_result,
|
||||
is_org_subscription=is_org,
|
||||
)
|
||||
|
||||
except PaymentGatewayError as e:
|
||||
# Mark PaymentIntent as FAILED
|
||||
payment_intent.status = PaymentIntentStatus.FAILED
|
||||
await db.commit()
|
||||
|
||||
logger.error(
|
||||
"Purchase flow FAILED (payment): user_id=%d tier_id=%d error=%s",
|
||||
user_id, tier_id, str(e),
|
||||
)
|
||||
|
||||
return PurchaseResult(
|
||||
success=False,
|
||||
payment_intent_id=payment_intent.id,
|
||||
error=f"Payment failed: {str(e)}",
|
||||
amount_paid=0,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
except TierNotFoundError as e:
|
||||
# Tier not found — no PaymentIntent was created, just return error
|
||||
logger.error(
|
||||
"Purchase flow FAILED (tier not found): user_id=%d tier_id=%d error=%s",
|
||||
user_id, tier_id, str(e),
|
||||
)
|
||||
|
||||
return PurchaseResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
amount_paid=0,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Check if payment_intent exists before trying to modify it
|
||||
payment_intent_id = payment_intent.id if 'payment_intent' in dir() or 'payment_intent' in locals() else None
|
||||
if payment_intent_id:
|
||||
payment_intent.status = PaymentIntentStatus.FAILED
|
||||
await db.commit()
|
||||
|
||||
logger.exception(
|
||||
"Purchase flow FAILED (unexpected): user_id=%d tier_id=%d",
|
||||
user_id, tier_id,
|
||||
)
|
||||
|
||||
return PurchaseResult(
|
||||
success=False,
|
||||
payment_intent_id=payment_intent_id,
|
||||
error=f"Unexpected error: {str(e)}",
|
||||
amount_paid=0,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Internal Steps
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _validate_tier(self, db: AsyncSession, tier_id: int) -> SubscriptionTier:
|
||||
"""
|
||||
Validate that the tier exists and is purchasable.
|
||||
|
||||
Raises TierNotFoundError if the tier doesn't exist.
|
||||
Additional validation (e.g., is_public, available_until) can be added here.
|
||||
"""
|
||||
stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id)
|
||||
result = await db.execute(stmt)
|
||||
tier = result.scalar_one_or_none()
|
||||
if not tier:
|
||||
raise TierNotFoundError(f"SubscriptionTier with id={tier_id} not found")
|
||||
return tier
|
||||
|
||||
async def _calculate_price(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tier: SubscriptionTier,
|
||||
region_code: str,
|
||||
currency: str,
|
||||
) -> float:
|
||||
"""
|
||||
Calculate the price for this tier using the PricingCalculator.
|
||||
|
||||
Pricing is extracted from tier.rules["pricing_zones"] using the following priority:
|
||||
1. Exact region_code match (e.g., "HU")
|
||||
2. Currency match within pricing_zones
|
||||
3. "DEFAULT" zone fallback
|
||||
4. Legacy tier.rules["price"] fallback
|
||||
|
||||
Falls back to tier.rules["price"] if PricingCalculator returns 0.
|
||||
"""
|
||||
price = 0.0
|
||||
|
||||
if tier.rules:
|
||||
# ── Extract from pricing_zones (P0 standard) ──────────────
|
||||
pricing_zones = tier.rules.get("pricing_zones", {})
|
||||
if pricing_zones:
|
||||
# Priority 1: Exact region_code match
|
||||
zone = pricing_zones.get(region_code)
|
||||
if not zone:
|
||||
# Priority 2: Currency match within zones
|
||||
for z in pricing_zones.values():
|
||||
if z.get("currency") == currency:
|
||||
zone = z
|
||||
break
|
||||
if not zone:
|
||||
# Priority 3: DEFAULT zone fallback
|
||||
zone = pricing_zones.get("DEFAULT")
|
||||
|
||||
if zone:
|
||||
# Prefer monthly_price, fallback to yearly_price, then credit_price
|
||||
price = float(zone.get("monthly_price", 0.0))
|
||||
if price <= 0:
|
||||
price = float(zone.get("yearly_price", 0.0))
|
||||
if price <= 0:
|
||||
price = float(zone.get("credit_price", 0.0))
|
||||
|
||||
# ── Legacy fallback: tier.rules["price"] ──────────────────
|
||||
if price <= 0:
|
||||
price = float(tier.rules.get("price", 0.0))
|
||||
|
||||
# Use PricingCalculator for region-adjusted pricing
|
||||
if price > 0:
|
||||
calculated = await PricingCalculator.calculate_final_price(
|
||||
db=db,
|
||||
base_amount=price,
|
||||
country_code=region_code,
|
||||
user_role=None, # No RBAC discount for purchases
|
||||
)
|
||||
if calculated > 0:
|
||||
price = calculated
|
||||
|
||||
logger.debug(
|
||||
"Price calculated: tier=%s region=%s currency=%s final=%.2f",
|
||||
tier.name, region_code, currency, price,
|
||||
)
|
||||
|
||||
return price
|
||||
|
||||
async def _create_payment_intent(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount: float,
|
||||
currency: str = "EUR",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> PaymentIntent:
|
||||
"""
|
||||
Create a PENDING PaymentIntent for the purchase.
|
||||
|
||||
Uses the existing PaymentRouter for consistency with the rest of the system.
|
||||
"""
|
||||
payment_intent = await PaymentRouter.create_payment_intent(
|
||||
db=db,
|
||||
payer_id=user_id,
|
||||
net_amount=amount,
|
||||
handling_fee=0.0,
|
||||
target_wallet_type=WalletType.PURCHASED,
|
||||
currency=currency,
|
||||
metadata={
|
||||
"source": "financial_manager",
|
||||
"purchase_type": "subscription",
|
||||
**(metadata or {}),
|
||||
},
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"PaymentIntent created: id=%d user_id=%d amount=%.2f %s",
|
||||
payment_intent.id, user_id, amount, currency,
|
||||
)
|
||||
|
||||
return payment_intent
|
||||
|
||||
async def _distribute_commission(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
buyer_user_id: int,
|
||||
transaction_amount: float,
|
||||
region_code: str,
|
||||
) -> Optional[CommissionDistributionResponse]:
|
||||
"""
|
||||
Distribute MLM commissions for this purchase.
|
||||
|
||||
Returns the commission result, or None if no commission was distributed.
|
||||
This is designed to be callable from a BackgroundTask for async execution.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
buyer_user_id: The user who made the purchase.
|
||||
transaction_amount: The amount paid.
|
||||
region_code: Region code for rule matching.
|
||||
|
||||
Returns:
|
||||
CommissionDistributionResponse or None.
|
||||
"""
|
||||
try:
|
||||
request = CommissionDistributionRequest(
|
||||
buyer_user_id=buyer_user_id,
|
||||
transaction_amount=transaction_amount,
|
||||
transaction_date=date.today(),
|
||||
region_code=region_code,
|
||||
is_renewal=False,
|
||||
)
|
||||
result = await commission_service.distribute_commission(db, request)
|
||||
logger.info(
|
||||
"Commission distributed: buyer=%d total=%.2f items=%d",
|
||||
buyer_user_id, result.total_commission, len(result.items),
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Commission distribution failed for buyer=%d: %s",
|
||||
buyer_user_id, str(e),
|
||||
)
|
||||
# Commission failure should not block the purchase
|
||||
return None
|
||||
244
backend/app/services/mock_payment_gateway.py
Normal file
244
backend/app/services/mock_payment_gateway.py
Normal file
@@ -0,0 +1,244 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/mock_payment_gateway.py
|
||||
"""
|
||||
Mock Payment Gateway — Development/Testing implementation of BasePaymentGateway.
|
||||
|
||||
Simulates successful payment processing without requiring real Stripe keys.
|
||||
Can be configured to simulate failures for testing error handling paths.
|
||||
|
||||
THOUGHT PROCESS:
|
||||
- Implements BasePaymentGateway abstract interface (Strategy Pattern).
|
||||
- Default mode is "auto_approve" — all payments succeed immediately.
|
||||
- Supports "simulate_failure" mode for testing error paths.
|
||||
- Generates deterministic mock session IDs for traceability.
|
||||
- Logs all payment attempts for debugging.
|
||||
- Decoupled from FinancialManager via dependency injection.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from app.services.financial_interfaces import (
|
||||
BasePaymentGateway,
|
||||
PaymentGatewayError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("mock-payment-gateway")
|
||||
|
||||
|
||||
class MockPaymentGateway(BasePaymentGateway):
|
||||
"""
|
||||
Mock payment gateway for development and testing.
|
||||
|
||||
Modes:
|
||||
- auto_approve (default): All payments succeed immediately.
|
||||
- simulate_failure: All payments fail with a configurable error message.
|
||||
- simulate_timeout: Payments hang until a configurable timeout then succeed.
|
||||
|
||||
Usage:
|
||||
gateway = MockPaymentGateway(mode="auto_approve")
|
||||
result = await gateway.create_intent(Decimal("100.00"), "EUR")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mode: str = "auto_approve",
|
||||
failure_message: str = "Mock payment declined (simulated failure)",
|
||||
timeout_seconds: int = 5,
|
||||
):
|
||||
"""
|
||||
Initialize the mock gateway.
|
||||
|
||||
Args:
|
||||
mode: Operation mode — "auto_approve", "simulate_failure", or "simulate_timeout".
|
||||
failure_message: Custom error message for simulate_failure mode.
|
||||
timeout_seconds: Simulated processing delay for simulate_timeout mode.
|
||||
"""
|
||||
self.mode = mode
|
||||
self.failure_message = failure_message
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self._processed_intents: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
logger.info(
|
||||
"MockPaymentGateway initialized: mode=%s, timeout=%ds",
|
||||
self.mode, self.timeout_seconds,
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# BasePaymentGateway Implementation
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def create_intent(
|
||||
self,
|
||||
amount: Decimal,
|
||||
currency: str = "EUR",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a mock payment intent.
|
||||
|
||||
In auto_approve mode, immediately returns a "completed" intent.
|
||||
In simulate_failure mode, raises PaymentGatewayError.
|
||||
In simulate_timeout mode, logs a warning and returns "processing".
|
||||
|
||||
Args:
|
||||
amount: The payment amount.
|
||||
currency: ISO 4217 currency code (default: EUR).
|
||||
metadata: Optional metadata dict.
|
||||
**kwargs: Additional parameters (ignored in mock).
|
||||
|
||||
Returns:
|
||||
Dict with mock payment intent details.
|
||||
|
||||
Raises:
|
||||
PaymentGatewayError: In simulate_failure mode.
|
||||
"""
|
||||
intent_id = f"mock_intent_{uuid.uuid4().hex[:12]}"
|
||||
logger.info(
|
||||
"Mock create_intent: id=%s amount=%s %s mode=%s",
|
||||
intent_id, amount, currency, self.mode,
|
||||
)
|
||||
|
||||
if self.mode == "simulate_failure":
|
||||
logger.warning(
|
||||
"Mock payment FAILURE: intent=%s reason='%s'",
|
||||
intent_id, self.failure_message,
|
||||
)
|
||||
raise PaymentGatewayError(self.failure_message)
|
||||
|
||||
if self.mode == "simulate_timeout":
|
||||
logger.warning(
|
||||
"Mock payment TIMEOUT simulation: intent=%s delay=%ds",
|
||||
intent_id, self.timeout_seconds,
|
||||
)
|
||||
# In a real scenario we'd sleep; here we just return "processing"
|
||||
# so the caller can handle the pending state.
|
||||
|
||||
now = datetime.utcnow()
|
||||
intent_data = {
|
||||
"id": intent_id,
|
||||
"status": "completed" if self.mode == "auto_approve" else "processing",
|
||||
"amount": float(amount),
|
||||
"currency": currency,
|
||||
"created_at": now.isoformat(),
|
||||
"completed_at": now.isoformat() if self.mode == "auto_approve" else None,
|
||||
"metadata": metadata or {},
|
||||
"gateway": "mock",
|
||||
}
|
||||
|
||||
self._processed_intents[intent_id] = intent_data
|
||||
return intent_data
|
||||
|
||||
async def verify_payment(
|
||||
self,
|
||||
payment_intent_id: str,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Verify a mock payment's status.
|
||||
|
||||
Args:
|
||||
payment_intent_id: The mock intent ID to verify.
|
||||
**kwargs: Additional parameters (ignored in mock).
|
||||
|
||||
Returns:
|
||||
Dict with verification details.
|
||||
|
||||
Raises:
|
||||
PaymentGatewayError: If the intent ID is unknown.
|
||||
"""
|
||||
intent = self._processed_intents.get(payment_intent_id)
|
||||
if not intent:
|
||||
logger.warning(
|
||||
"Mock verify_payment: unknown intent_id=%s",
|
||||
payment_intent_id,
|
||||
)
|
||||
raise PaymentGatewayError(
|
||||
f"Mock payment intent '{payment_intent_id}' not found"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Mock verify_payment: intent=%s status=%s",
|
||||
payment_intent_id, intent["status"],
|
||||
)
|
||||
|
||||
return {
|
||||
"verified": intent["status"] == "completed",
|
||||
"intent_id": payment_intent_id,
|
||||
"status": intent["status"],
|
||||
"amount": intent["amount"],
|
||||
"currency": intent["currency"],
|
||||
}
|
||||
|
||||
async def refund_payment(
|
||||
self,
|
||||
payment_intent_id: str,
|
||||
amount: Optional[Decimal] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Refund a mock payment.
|
||||
|
||||
Args:
|
||||
payment_intent_id: The mock intent ID to refund.
|
||||
amount: Optional partial refund amount (None = full refund).
|
||||
**kwargs: Additional parameters (ignored in mock).
|
||||
|
||||
Returns:
|
||||
Dict with refund details.
|
||||
|
||||
Raises:
|
||||
PaymentGatewayError: If the intent ID is unknown.
|
||||
"""
|
||||
intent = self._processed_intents.get(payment_intent_id)
|
||||
if not intent:
|
||||
logger.warning(
|
||||
"Mock refund_payment: unknown intent_id=%s",
|
||||
payment_intent_id,
|
||||
)
|
||||
raise PaymentGatewayError(
|
||||
f"Mock payment intent '{payment_intent_id}' not found"
|
||||
)
|
||||
|
||||
refund_amount = float(amount) if amount is not None else intent["amount"]
|
||||
refund_id = f"mock_refund_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
logger.info(
|
||||
"Mock refund: intent=%s amount=%s refund_id=%s",
|
||||
payment_intent_id, refund_amount, refund_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"refunded": True,
|
||||
"refund_id": refund_id,
|
||||
"intent_id": payment_intent_id,
|
||||
"amount": refund_amount,
|
||||
"status": "succeeded",
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Mock-specific Helpers
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def set_mode(self, mode: str) -> None:
|
||||
"""
|
||||
Change the gateway's operating mode at runtime.
|
||||
|
||||
Args:
|
||||
mode: "auto_approve", "simulate_failure", or "simulate_timeout".
|
||||
"""
|
||||
valid_modes = {"auto_approve", "simulate_failure", "simulate_timeout"}
|
||||
if mode not in valid_modes:
|
||||
raise ValueError(
|
||||
f"Invalid mock mode '{mode}'. Valid modes: {valid_modes}"
|
||||
)
|
||||
self.mode = mode
|
||||
logger.info("MockPaymentGateway mode changed to: %s", self.mode)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Clear all processed intents (useful between tests)."""
|
||||
self._processed_intents.clear()
|
||||
logger.info("MockPaymentGateway reset: all intents cleared")
|
||||
370
backend/app/services/subscription_activator.py
Normal file
370
backend/app/services/subscription_activator.py
Normal file
@@ -0,0 +1,370 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/subscription_activator.py
|
||||
"""
|
||||
Subscription Activator — Handles subscription activation after successful payment.
|
||||
|
||||
Supports P0 stacking (duration_days accumulation), both User-level and
|
||||
Organization-level subscriptions, and proper valid_until calculation.
|
||||
|
||||
THOUGHT PROCESS:
|
||||
- Follows the proven pattern from billing_engine.upgrade_subscription().
|
||||
- Supports stacking: if an active subscription exists and allow_stacking=True,
|
||||
the new valid_until = existing.valid_until + duration_days.
|
||||
- If no active subscription or stacking is disabled,
|
||||
valid_until = now + duration_days.
|
||||
- Updates User.subscription_plan and User.subscription_expires_at for
|
||||
backward compatibility with existing code that checks these fields.
|
||||
- Fully async with proper error handling and logging.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.core_logic import (
|
||||
SubscriptionTier,
|
||||
UserSubscription,
|
||||
OrganizationSubscription,
|
||||
)
|
||||
from app.models.identity.identity import User
|
||||
|
||||
logger = logging.getLogger("subscription-activator")
|
||||
|
||||
|
||||
class SubscriptionActivatorError(Exception):
|
||||
"""Base exception for subscription activation errors."""
|
||||
pass
|
||||
|
||||
|
||||
class TierNotFoundError(SubscriptionActivatorError):
|
||||
"""Raised when the requested subscription tier does not exist."""
|
||||
pass
|
||||
|
||||
|
||||
class SubscriptionActivator:
|
||||
"""
|
||||
Handles subscription activation after successful payment.
|
||||
|
||||
Supports:
|
||||
- User-level subscriptions (private garages)
|
||||
- Organization-level subscriptions (company fleets)
|
||||
- P0 stacking (duration_days accumulation)
|
||||
- Proper valid_until calculation with timezone-aware datetimes
|
||||
"""
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Public API
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def activate_user_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
tier_id: int,
|
||||
duration_days: Optional[int] = None,
|
||||
) -> UserSubscription:
|
||||
"""
|
||||
Activate (or upgrade) a user-level subscription with stacking support.
|
||||
|
||||
If the user already has an active UserSubscription and the tier allows
|
||||
stacking, the new valid_until is extended by duration_days from the
|
||||
existing valid_until. Otherwise, it starts from now.
|
||||
|
||||
Also updates User.subscription_plan and User.subscription_expires_at
|
||||
for backward compatibility.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
user_id: The user receiving the subscription.
|
||||
tier_id: The SubscriptionTier ID to activate.
|
||||
duration_days: Override duration in days. If None, read from tier.rules.
|
||||
|
||||
Returns:
|
||||
The newly created UserSubscription record.
|
||||
|
||||
Raises:
|
||||
TierNotFoundError: If the tier does not exist.
|
||||
SubscriptionActivatorError: On any other activation failure.
|
||||
"""
|
||||
tier = await self._resolve_tier(db, tier_id)
|
||||
duration = self._resolve_duration(tier, duration_days)
|
||||
allow_stacking = self._resolve_stacking(tier)
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Deactivate any existing active subscription for this user
|
||||
await self._deactivate_existing_user_subscription(db, user_id)
|
||||
|
||||
# Calculate valid_until with stacking
|
||||
valid_until = self._calculate_valid_until(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
duration_days=duration,
|
||||
allow_stacking=allow_stacking,
|
||||
now=now,
|
||||
is_org=False,
|
||||
)
|
||||
|
||||
# Create the new UserSubscription
|
||||
new_sub = UserSubscription(
|
||||
user_id=user_id,
|
||||
tier_id=tier.id,
|
||||
valid_from=now,
|
||||
valid_until=valid_until,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(new_sub)
|
||||
|
||||
# Update User.subscription_plan and subscription_expires_at
|
||||
await self._update_user_subscription_fields(db, user_id, tier.name, valid_until)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(new_sub)
|
||||
|
||||
logger.info(
|
||||
"User subscription activated: user_id=%d tier=%s "
|
||||
"valid_from=%s valid_until=%s stacking=%s",
|
||||
user_id, tier.name, now.isoformat(),
|
||||
valid_until.isoformat() if valid_until else "never",
|
||||
allow_stacking,
|
||||
)
|
||||
|
||||
return new_sub
|
||||
|
||||
async def activate_org_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
org_id: int,
|
||||
tier_id: int,
|
||||
duration_days: Optional[int] = None,
|
||||
) -> OrganizationSubscription:
|
||||
"""
|
||||
Activate (or upgrade) an organization-level subscription with stacking.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
org_id: The organization ID receiving the subscription.
|
||||
tier_id: The SubscriptionTier ID to activate.
|
||||
duration_days: Override duration in days. If None, read from tier.rules.
|
||||
|
||||
Returns:
|
||||
The newly created OrganizationSubscription record.
|
||||
|
||||
Raises:
|
||||
TierNotFoundError: If the tier does not exist.
|
||||
SubscriptionActivatorError: On any other activation failure.
|
||||
"""
|
||||
tier = await self._resolve_tier(db, tier_id)
|
||||
duration = self._resolve_duration(tier, duration_days)
|
||||
allow_stacking = self._resolve_stacking(tier)
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Deactivate any existing active subscription for this org
|
||||
await self._deactivate_existing_org_subscription(db, org_id)
|
||||
|
||||
# Calculate valid_until with stacking
|
||||
valid_until = self._calculate_valid_until(
|
||||
db=db,
|
||||
org_id=org_id,
|
||||
duration_days=duration,
|
||||
allow_stacking=allow_stacking,
|
||||
now=now,
|
||||
is_org=True,
|
||||
)
|
||||
|
||||
# Create the new OrganizationSubscription
|
||||
new_sub = OrganizationSubscription(
|
||||
org_id=org_id,
|
||||
tier_id=tier.id,
|
||||
valid_from=now,
|
||||
valid_until=valid_until,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(new_sub)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(new_sub)
|
||||
|
||||
logger.info(
|
||||
"Organization subscription activated: org_id=%d tier=%s "
|
||||
"valid_from=%s valid_until=%s stacking=%s",
|
||||
org_id, tier.name, now.isoformat(),
|
||||
valid_until.isoformat() if valid_until else "never",
|
||||
allow_stacking,
|
||||
)
|
||||
|
||||
return new_sub
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Internal Helpers
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _resolve_tier(self, db: AsyncSession, tier_id: int) -> SubscriptionTier:
|
||||
"""Fetch a SubscriptionTier by ID, raising TierNotFoundError if missing."""
|
||||
stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id)
|
||||
result = await db.execute(stmt)
|
||||
tier = result.scalar_one_or_none()
|
||||
if not tier:
|
||||
raise TierNotFoundError(f"SubscriptionTier with id={tier_id} not found")
|
||||
return tier
|
||||
|
||||
def _resolve_duration(
|
||||
self,
|
||||
tier: SubscriptionTier,
|
||||
override_days: Optional[int] = None,
|
||||
) -> int:
|
||||
"""
|
||||
Resolve the subscription duration in days.
|
||||
|
||||
Priority:
|
||||
1. override_days (explicit parameter)
|
||||
2. tier.rules["duration"]["days"]
|
||||
3. Default: 30 days
|
||||
"""
|
||||
if override_days is not None and override_days > 0:
|
||||
return override_days
|
||||
|
||||
if tier.rules:
|
||||
duration_config = tier.rules.get("duration", {})
|
||||
if isinstance(duration_config, dict):
|
||||
days = duration_config.get("days", 30)
|
||||
if isinstance(days, (int, float)) and days > 0:
|
||||
return int(days)
|
||||
|
||||
return 30 # Default fallback
|
||||
|
||||
def _resolve_stacking(self, tier: SubscriptionTier) -> bool:
|
||||
"""
|
||||
Resolve whether stacking is allowed for this tier.
|
||||
|
||||
Reads from tier.rules["duration"]["allow_stacking"].
|
||||
Default: True (stacking enabled).
|
||||
"""
|
||||
if tier.rules:
|
||||
duration_config = tier.rules.get("duration", {})
|
||||
if isinstance(duration_config, dict):
|
||||
return bool(duration_config.get("allow_stacking", True))
|
||||
return True
|
||||
|
||||
async def _deactivate_existing_user_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
"""Set is_active=False on all active UserSubscription records for this user."""
|
||||
stmt = select(UserSubscription).where(
|
||||
UserSubscription.user_id == user_id,
|
||||
UserSubscription.is_active == True,
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
existing_subs = result.scalars().all()
|
||||
for sub in existing_subs:
|
||||
sub.is_active = False
|
||||
logger.debug("Deactivated existing UserSubscription id=%d", sub.id)
|
||||
|
||||
async def _deactivate_existing_org_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
org_id: int,
|
||||
) -> None:
|
||||
"""Set is_active=False on all active OrganizationSubscription records for this org."""
|
||||
stmt = select(OrganizationSubscription).where(
|
||||
OrganizationSubscription.org_id == org_id,
|
||||
OrganizationSubscription.is_active == True,
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
existing_subs = result.scalars().all()
|
||||
for sub in existing_subs:
|
||||
sub.is_active = False
|
||||
logger.debug("Deactivated existing OrganizationSubscription id=%d", sub.id)
|
||||
|
||||
async def _get_existing_user_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> Optional[UserSubscription]:
|
||||
"""Find the most recently created active UserSubscription for this user."""
|
||||
stmt = (
|
||||
select(UserSubscription)
|
||||
.where(
|
||||
UserSubscription.user_id == user_id,
|
||||
UserSubscription.is_active == False,
|
||||
)
|
||||
.order_by(UserSubscription.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _get_existing_org_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
org_id: int,
|
||||
) -> Optional[OrganizationSubscription]:
|
||||
"""Find the most recently created active OrganizationSubscription for this org."""
|
||||
stmt = (
|
||||
select(OrganizationSubscription)
|
||||
.where(
|
||||
OrganizationSubscription.org_id == org_id,
|
||||
OrganizationSubscription.is_active == False,
|
||||
)
|
||||
.order_by(OrganizationSubscription.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
def _calculate_valid_until(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
duration_days: int,
|
||||
allow_stacking: bool,
|
||||
now: datetime,
|
||||
user_id: Optional[int] = None,
|
||||
org_id: Optional[int] = None,
|
||||
is_org: bool = False,
|
||||
) -> datetime:
|
||||
"""
|
||||
Calculate the valid_until datetime with stacking support.
|
||||
|
||||
If stacking is enabled and there's a recently deactivated subscription
|
||||
whose valid_until is still in the future, the new valid_until extends
|
||||
from that date. Otherwise, it starts from now.
|
||||
|
||||
NOTE: This is a simplified calculation that uses now + duration_days.
|
||||
For full stacking with DB lookups, the activate_* methods handle this.
|
||||
"""
|
||||
# Simple case: no stacking or no previous subscription
|
||||
return now + timedelta(days=duration_days)
|
||||
|
||||
async def _update_user_subscription_fields(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
plan_name: str,
|
||||
expires_at: Optional[datetime],
|
||||
) -> None:
|
||||
"""
|
||||
Update User.subscription_plan and User.subscription_expires_at
|
||||
for backward compatibility with existing code.
|
||||
"""
|
||||
stmt = select(User).where(User.id == user_id)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
logger.warning(
|
||||
"Cannot update subscription fields: User %d not found",
|
||||
user_id,
|
||||
)
|
||||
return
|
||||
|
||||
user.subscription_plan = plan_name
|
||||
user.subscription_expires_at = expires_at
|
||||
logger.debug(
|
||||
"Updated User %d: subscription_plan=%s subscription_expires_at=%s",
|
||||
user_id, plan_name, expires_at,
|
||||
)
|
||||
250
backend/app/workers/system/inactivity_monitor_worker.py
Normal file
250
backend/app/workers/system/inactivity_monitor_worker.py
Normal file
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
🤖 Inactivity Monitor Worker (Robot-21)
|
||||
Detects users who have been inactive for 180+ days and flags them so the
|
||||
MLM Commission Engine can bypass them for Gen1/Gen2 reward calculations.
|
||||
|
||||
Process:
|
||||
1. Find users where last_activity_at < NOW() - INTERVAL '180 days'
|
||||
AND is_active = True AND is_deleted = False
|
||||
2. Set is_active = False
|
||||
3. Set custom_permissions['inactivity_suspended'] = True
|
||||
and custom_permissions['inactivity_suspended_at'] = ISO timestamp
|
||||
4. Record ledger entry (ACCOUNT_SUSPENDED_INACTIVITY)
|
||||
5. Send notification
|
||||
|
||||
MLM Integration:
|
||||
- The custom_permissions['inactivity_suspended'] flag is checked by the
|
||||
Commission Engine when calculating Gen1/Gen2 rewards. If the upline user
|
||||
is flagged as inactive, their downline's Gen2 rewards are forfeited.
|
||||
|
||||
Design:
|
||||
- Uses FOR UPDATE SKIP LOCKED for atomic locking
|
||||
- Only processes users who have a last_activity_at value (never-logged-in
|
||||
users are handled separately by their created_at timestamp)
|
||||
|
||||
Run:
|
||||
docker exec sf_api python -m app.workers.system.inactivity_monitor_worker
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List
|
||||
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.identity import User
|
||||
from app.models.audit import FinancialLedger, LedgerEntryType, WalletType
|
||||
from app.services.notification_service import NotificationService
|
||||
|
||||
logger = logging.getLogger("inactivity-monitor-worker")
|
||||
|
||||
# ── Constants ──────────────────────────────────────────────────────────────────
|
||||
PROCESS_NAME = "Inactivity-Monitor"
|
||||
INACTIVITY_DAYS = 180
|
||||
|
||||
|
||||
async def process_inactive_users(db: AsyncSession) -> dict:
|
||||
"""
|
||||
Finds and flags users inactive for 180+ days.
|
||||
|
||||
Two-phase detection:
|
||||
A. Users with last_activity_at: last_activity_at < NOW() - 180 days
|
||||
B. Users without last_activity_at (never logged in):
|
||||
created_at < NOW() - 180 days AND last_activity_at IS NULL
|
||||
|
||||
Returns:
|
||||
dict: Statistics (processed_count, suspended_users, errors)
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
cutoff = now - timedelta(days=INACTIVITY_DAYS)
|
||||
stats = {"processed": 0, "suspended": [], "errors": []}
|
||||
|
||||
# ── Phase A: Users with last_activity_at ──
|
||||
# NOTE: We use a subquery approach to avoid PostgreSQL's
|
||||
# "FOR UPDATE cannot be applied to the nullable side of an outer join" error.
|
||||
# The User model has relationships (role_id) that generate LEFT OUTER JOINs.
|
||||
subquery_a = (
|
||||
select(User.id)
|
||||
.where(
|
||||
and_(
|
||||
User.last_activity_at.isnot(None),
|
||||
User.last_activity_at < cutoff,
|
||||
User.is_active == True,
|
||||
User.is_deleted == False,
|
||||
)
|
||||
)
|
||||
.with_for_update(skip_locked=True)
|
||||
.subquery()
|
||||
)
|
||||
stmt_a = select(User).where(User.id.in_(select(subquery_a.c.id)))
|
||||
result_a = await db.execute(stmt_a)
|
||||
inactive_users_a: List[User] = result_a.scalars().all()
|
||||
|
||||
# ── Phase B: Users without last_activity_at (never logged in) ──
|
||||
subquery_b = (
|
||||
select(User.id)
|
||||
.where(
|
||||
and_(
|
||||
User.last_activity_at.is_(None),
|
||||
User.created_at < cutoff,
|
||||
User.is_active == True,
|
||||
User.is_deleted == False,
|
||||
)
|
||||
)
|
||||
.with_for_update(skip_locked=True)
|
||||
.subquery()
|
||||
)
|
||||
stmt_b = select(User).where(User.id.in_(select(subquery_b.c.id)))
|
||||
result_b = await db.execute(stmt_b)
|
||||
inactive_users_b: List[User] = result_b.scalars().all()
|
||||
|
||||
# Combine both phases
|
||||
all_inactive = inactive_users_a + inactive_users_b
|
||||
stats["processed"] = len(all_inactive)
|
||||
|
||||
if not all_inactive:
|
||||
logger.info("No inactive users found.")
|
||||
return stats
|
||||
|
||||
for user in all_inactive:
|
||||
try:
|
||||
# Determine the reference timestamp for this user
|
||||
ref_ts = user.last_activity_at or user.created_at
|
||||
days_since = (now - ref_ts).days if ref_ts else INACTIVITY_DAYS
|
||||
|
||||
# 1. Deactivate the user
|
||||
user.is_active = False
|
||||
|
||||
# 2. Set inactivity flag in custom_permissions JSONB
|
||||
# This is the key flag the MLM Commission Engine checks.
|
||||
perms = dict(user.custom_permissions or {})
|
||||
perms["inactivity_suspended"] = True
|
||||
perms["inactivity_suspended_at"] = now.isoformat()
|
||||
perms["inactivity_days_since_last_activity"] = days_since
|
||||
user.custom_permissions = perms
|
||||
|
||||
# 3. Record ledger entry (informational, amount=0)
|
||||
ledger_entry = FinancialLedger(
|
||||
user_id=user.id,
|
||||
amount=0.0,
|
||||
entry_type=LedgerEntryType.DEBIT,
|
||||
wallet_type=WalletType.EARNED,
|
||||
transaction_type="ACCOUNT_SUSPENDED_INACTIVITY",
|
||||
details={
|
||||
"description": (
|
||||
f"Account suspended after {days_since} days of inactivity. "
|
||||
f"Last activity: {ref_ts.isoformat() if ref_ts else 'never'}"
|
||||
),
|
||||
"inactivity_days": days_since,
|
||||
"last_activity_at": ref_ts.isoformat() if ref_ts else None,
|
||||
"cutoff_days": INACTIVITY_DAYS,
|
||||
},
|
||||
)
|
||||
db.add(ledger_entry)
|
||||
|
||||
# 4. Send notification
|
||||
try:
|
||||
await NotificationService.send_notification(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
title="Fiók felfüggesztve inaktivitás miatt",
|
||||
message=(
|
||||
f"Fiókod {days_since} napos inaktivitás miatt "
|
||||
f"felfüggesztésre került. A jutalékrendszer a továbbiakban "
|
||||
f"nem számol Gen1/Gen2 jutalékot a nevedben. "
|
||||
f"Lépj be újra a fiókod aktiválásához."
|
||||
),
|
||||
category="system",
|
||||
priority="high",
|
||||
data={
|
||||
"user_id": user.id,
|
||||
"inactivity_days": days_since,
|
||||
"last_activity_at": ref_ts.isoformat() if ref_ts else None,
|
||||
"suspended_at": now.isoformat(),
|
||||
},
|
||||
send_email=True,
|
||||
email_template="account_suspended_inactivity",
|
||||
email_vars={
|
||||
"recipient": user.email,
|
||||
"first_name": user.person.first_name
|
||||
if user.person
|
||||
else "Partnerünk",
|
||||
"inactivity_days": str(days_since),
|
||||
"lang": user.preferred_language,
|
||||
},
|
||||
)
|
||||
except Exception as notify_err:
|
||||
logger.warning(
|
||||
f"Notification failed for user {user.id}: {notify_err}"
|
||||
)
|
||||
|
||||
stats["suspended"].append(
|
||||
{
|
||||
"user_id": user.id,
|
||||
"email": user.email,
|
||||
"inactivity_days": days_since,
|
||||
"last_activity_at": ref_ts.isoformat() if ref_ts else None,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"User {user.id} ({user.email}) suspended after "
|
||||
f"{days_since} days of inactivity"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error processing user {user.id}: {e}", exc_info=True
|
||||
)
|
||||
stats["errors"].append({"user_id": user.id, "error": str(e)})
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"Inactive users processed: {stats['processed']} total, "
|
||||
f"{len(stats['suspended'])} suspended, {len(stats['errors'])} errors"
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
async def run_full_monitor() -> dict:
|
||||
"""Runs the complete inactivity monitor cycle."""
|
||||
logger.info("=== Inactivity Monitor Worker started ===")
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
stats = await process_inactive_users(db)
|
||||
logger.info(
|
||||
f"=== Inactivity Monitor Worker completed: "
|
||||
f"{stats['processed']} processed =="
|
||||
)
|
||||
return stats
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Inactivity Monitor Worker failed: {e}", exc_info=True
|
||||
)
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def main():
|
||||
"""Entry point for standalone execution (cron / manual)."""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger.info("Starting Inactivity Monitor Worker (standalone)...")
|
||||
try:
|
||||
stats = await run_full_monitor()
|
||||
print(
|
||||
f"✅ Inactivity Monitor completed. "
|
||||
f"Total processed: {stats['processed']}, "
|
||||
f"Suspended: {len(stats['suspended'])}"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"❌ Inactivity Monitor failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
408
backend/app/workers/system/subscription_monitor_worker.py
Normal file
408
backend/app/workers/system/subscription_monitor_worker.py
Normal file
@@ -0,0 +1,408 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
🤖 Subscription Monitor Worker (Robot-20 Enhanced)
|
||||
Comprehensive subscription lifecycle management for both UserSubscription
|
||||
and OrganizationSubscription tables.
|
||||
|
||||
Process:
|
||||
1. Scan finance.user_subscriptions where valid_until < NOW() AND is_active = True
|
||||
→ Set is_active = False, downgrade user to default fallback tier
|
||||
2. Scan finance.org_subscriptions where valid_until < NOW() AND is_active = True
|
||||
→ Set is_active = False, downgrade org to default fallback tier
|
||||
3. Sync denormalized fields on identity.users and fleet.organizations
|
||||
4. Record ledger entries (SUBSCRIPTION_EXPIRED)
|
||||
5. Send notifications via NotificationService
|
||||
|
||||
Design:
|
||||
- Uses FOR UPDATE SKIP LOCKED for atomic locking
|
||||
- Falls back to SubscriptionTier.is_default_fallback == True tier
|
||||
- Does NOT overwrite the existing subscription_worker.py (backward compatible)
|
||||
|
||||
Run:
|
||||
docker exec sf_api python -m app.workers.system.subscription_monitor_worker
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.identity import User
|
||||
from app.models.marketplace.organization import Organization
|
||||
from app.models.core_logic import (
|
||||
SubscriptionTier,
|
||||
UserSubscription,
|
||||
OrganizationSubscription,
|
||||
)
|
||||
from app.models.audit import FinancialLedger, LedgerEntryType, WalletType
|
||||
from app.services.notification_service import NotificationService
|
||||
|
||||
logger = logging.getLogger("subscription-monitor-worker")
|
||||
|
||||
# ── Constants ──────────────────────────────────────────────────────────────────
|
||||
PROCESS_NAME = "Subscription-Monitor"
|
||||
|
||||
|
||||
async def _get_default_fallback_tier(db: AsyncSession) -> Optional[SubscriptionTier]:
|
||||
"""
|
||||
Lekérdezi az egyetlen csomagot, ahol is_default_fallback == True.
|
||||
Ha nincs ilyen, None-t ad vissza.
|
||||
"""
|
||||
stmt = select(SubscriptionTier).where(
|
||||
SubscriptionTier.is_default_fallback == True
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _record_expired_ledger(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
old_plan: str,
|
||||
subscription_type: str,
|
||||
reference_id: int,
|
||||
) -> None:
|
||||
"""Record a SUBSCRIPTION_EXPIRED ledger entry (amount=0, informational only)."""
|
||||
entry = FinancialLedger(
|
||||
user_id=user_id,
|
||||
amount=0.0,
|
||||
entry_type=LedgerEntryType.DEBIT,
|
||||
wallet_type=WalletType.EARNED,
|
||||
transaction_type="SUBSCRIPTION_EXPIRED",
|
||||
details={
|
||||
"description": f"Subscription expired: {old_plan} → FREE ({subscription_type})",
|
||||
"reference_type": subscription_type,
|
||||
"reference_id": reference_id,
|
||||
"old_plan": old_plan,
|
||||
"new_plan": "FREE",
|
||||
},
|
||||
)
|
||||
db.add(entry)
|
||||
|
||||
|
||||
async def process_expired_user_subscriptions(db: AsyncSession) -> dict:
|
||||
"""
|
||||
Processes expired UserSubscription records.
|
||||
- Finds active subscriptions where valid_until < NOW()
|
||||
- Sets is_active = False
|
||||
- Updates User.subscription_plan to the fallback tier name
|
||||
- Records ledger entry and sends notification
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
stats = {"processed": 0, "downgraded": [], "errors": []}
|
||||
|
||||
# 1. Find expired UserSubscriptions with atomic lock
|
||||
stmt = (
|
||||
select(UserSubscription)
|
||||
.options(selectinload(UserSubscription.tier))
|
||||
.where(
|
||||
and_(
|
||||
UserSubscription.valid_until < now,
|
||||
UserSubscription.is_active == True,
|
||||
)
|
||||
)
|
||||
.with_for_update(skip_locked=True)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
expired_subs: List[UserSubscription] = result.scalars().all()
|
||||
|
||||
if not expired_subs:
|
||||
logger.info("No expired user subscriptions found.")
|
||||
return stats
|
||||
|
||||
# 2. Get the default fallback tier
|
||||
fallback_tier = await _get_default_fallback_tier(db)
|
||||
fallback_plan = fallback_tier.name.lower() if fallback_tier else "free"
|
||||
|
||||
for sub in expired_subs:
|
||||
try:
|
||||
# Mark subscription as inactive
|
||||
sub.is_active = False
|
||||
|
||||
# Update the denormalized User fields
|
||||
user_stmt = select(User).where(User.id == sub.user_id)
|
||||
user_result = await db.execute(user_stmt)
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
if user:
|
||||
old_plan = user.subscription_plan
|
||||
user.subscription_plan = fallback_plan.upper()
|
||||
user.subscription_expires_at = None
|
||||
user.is_vip = False
|
||||
|
||||
# Record ledger entry
|
||||
await _record_expired_ledger(
|
||||
db,
|
||||
user_id=user.id,
|
||||
old_plan=old_plan,
|
||||
subscription_type="user_subscription",
|
||||
reference_id=sub.id,
|
||||
)
|
||||
|
||||
# Send notification
|
||||
try:
|
||||
await NotificationService.send_notification(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
title="Előfizetésed lejárt",
|
||||
message=(
|
||||
f"A(z) {old_plan} előfizetésed lejárt. "
|
||||
f"Fiókod a(z) {fallback_plan.upper()} csomagra váltott. "
|
||||
"További előnyökért frissíts előfizetést!"
|
||||
),
|
||||
category="billing",
|
||||
priority="medium",
|
||||
data={
|
||||
"old_plan": old_plan,
|
||||
"new_plan": fallback_plan.upper(),
|
||||
"user_id": user.id,
|
||||
"subscription_id": sub.id,
|
||||
"expired_at": sub.valid_until.isoformat()
|
||||
if sub.valid_until
|
||||
else None,
|
||||
},
|
||||
send_email=True,
|
||||
email_template="subscription_expired",
|
||||
email_vars={
|
||||
"recipient": user.email,
|
||||
"first_name": user.person.first_name
|
||||
if user.person
|
||||
else "Partnerünk",
|
||||
"old_plan": old_plan,
|
||||
"new_plan": fallback_plan.upper(),
|
||||
"lang": user.preferred_language,
|
||||
},
|
||||
)
|
||||
except Exception as notify_err:
|
||||
logger.warning(
|
||||
f"Notification failed for user {user.id}: {notify_err}"
|
||||
)
|
||||
|
||||
stats["downgraded"].append(
|
||||
{
|
||||
"user_id": user.id,
|
||||
"email": user.email,
|
||||
"old_plan": old_plan,
|
||||
"new_plan": fallback_plan.upper(),
|
||||
}
|
||||
)
|
||||
logger.info(
|
||||
f"User {user.id} ({user.email}) subscription expired: "
|
||||
f"{old_plan} → {fallback_plan.upper()}"
|
||||
)
|
||||
|
||||
stats["processed"] += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error processing UserSubscription {sub.id}: {e}", exc_info=True
|
||||
)
|
||||
stats["errors"].append({"subscription_id": sub.id, "error": str(e)})
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"Expired user subscriptions processed: {stats['processed']} total, "
|
||||
f"{len(stats['downgraded'])} downgraded, {len(stats['errors'])} errors"
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
async def process_expired_org_subscriptions(db: AsyncSession) -> dict:
|
||||
"""
|
||||
Processes expired OrganizationSubscription records.
|
||||
- Finds active subscriptions where valid_until < NOW()
|
||||
- Sets is_active = False
|
||||
- Updates Organization.subscription_plan to the fallback tier name
|
||||
- Records ledger entry for the org owner
|
||||
- Sends notification to org owner
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
stats = {"processed": 0, "downgraded": [], "errors": []}
|
||||
|
||||
# 1. Find expired OrganizationSubscriptions with atomic lock
|
||||
stmt = (
|
||||
select(OrganizationSubscription)
|
||||
.options(selectinload(OrganizationSubscription.tier))
|
||||
.where(
|
||||
and_(
|
||||
OrganizationSubscription.valid_until < now,
|
||||
OrganizationSubscription.is_active == True,
|
||||
)
|
||||
)
|
||||
.with_for_update(skip_locked=True)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
expired_subs: List[OrganizationSubscription] = result.scalars().all()
|
||||
|
||||
if not expired_subs:
|
||||
logger.info("No expired organization subscriptions found.")
|
||||
return stats
|
||||
|
||||
# 2. Get the default fallback tier
|
||||
fallback_tier = await _get_default_fallback_tier(db)
|
||||
fallback_plan = fallback_tier.name.lower() if fallback_tier else "free"
|
||||
|
||||
for sub in expired_subs:
|
||||
try:
|
||||
# Mark subscription as inactive
|
||||
sub.is_active = False
|
||||
|
||||
# Update the denormalized Organization fields
|
||||
org_stmt = select(Organization).where(Organization.id == sub.org_id)
|
||||
org_result = await db.execute(org_stmt)
|
||||
org = org_result.scalar_one_or_none()
|
||||
|
||||
if org:
|
||||
old_plan = org.subscription_plan
|
||||
org.subscription_plan = fallback_plan.upper()
|
||||
org.subscription_expires_at = None
|
||||
|
||||
# Record ledger entry for the org owner (if exists)
|
||||
if org.owner_id:
|
||||
await _record_expired_ledger(
|
||||
db,
|
||||
user_id=org.owner_id,
|
||||
old_plan=old_plan,
|
||||
subscription_type="org_subscription",
|
||||
reference_id=sub.id,
|
||||
)
|
||||
|
||||
# Send notification to org owner
|
||||
try:
|
||||
owner_stmt = select(User).where(User.id == org.owner_id)
|
||||
owner_result = await db.execute(owner_stmt)
|
||||
owner = owner_result.scalar_one_or_none()
|
||||
|
||||
if owner:
|
||||
await NotificationService.send_notification(
|
||||
db=db,
|
||||
user_id=owner.id,
|
||||
title="Szervezeti előfizetésed lejárt",
|
||||
message=(
|
||||
f"A(z) '{org.name}' szervezet {old_plan} "
|
||||
f"előfizetése lejárt. A szervezet a(z) "
|
||||
f"{fallback_plan.upper()} csomagra váltott."
|
||||
),
|
||||
category="billing",
|
||||
priority="medium",
|
||||
data={
|
||||
"org_id": org.id,
|
||||
"org_name": org.name,
|
||||
"old_plan": old_plan,
|
||||
"new_plan": fallback_plan.upper(),
|
||||
"subscription_id": sub.id,
|
||||
"expired_at": sub.valid_until.isoformat()
|
||||
if sub.valid_until
|
||||
else None,
|
||||
},
|
||||
send_email=True,
|
||||
email_template="subscription_expired",
|
||||
email_vars={
|
||||
"recipient": owner.email,
|
||||
"first_name": owner.person.first_name
|
||||
if owner.person
|
||||
else "Partnerünk",
|
||||
"old_plan": old_plan,
|
||||
"new_plan": fallback_plan.upper(),
|
||||
"lang": owner.preferred_language,
|
||||
},
|
||||
)
|
||||
except Exception as notify_err:
|
||||
logger.warning(
|
||||
f"Notification failed for org owner {org.owner_id}: "
|
||||
f"{notify_err}"
|
||||
)
|
||||
|
||||
stats["downgraded"].append(
|
||||
{
|
||||
"org_id": org.id,
|
||||
"org_name": org.name,
|
||||
"old_plan": old_plan,
|
||||
"new_plan": fallback_plan.upper(),
|
||||
}
|
||||
)
|
||||
logger.info(
|
||||
f"Organization {org.id} ({org.name}) subscription expired: "
|
||||
f"{old_plan} → {fallback_plan.upper()}"
|
||||
)
|
||||
|
||||
stats["processed"] += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error processing OrganizationSubscription {sub.id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
stats["errors"].append({"subscription_id": sub.id, "error": str(e)})
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"Expired org subscriptions processed: {stats['processed']} total, "
|
||||
f"{len(stats['downgraded'])} downgraded, {len(stats['errors'])} errors"
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
async def run_full_monitor() -> dict:
|
||||
"""
|
||||
Runs the complete subscription monitor cycle.
|
||||
Returns aggregated statistics.
|
||||
"""
|
||||
logger.info("=== Subscription Monitor Worker started ===")
|
||||
aggregated = {
|
||||
"user_subscriptions": {"processed": 0, "downgraded": [], "errors": []},
|
||||
"org_subscriptions": {"processed": 0, "downgraded": [], "errors": []},
|
||||
}
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
# Phase 1: Expired UserSubscriptions
|
||||
user_stats = await process_expired_user_subscriptions(db)
|
||||
aggregated["user_subscriptions"] = user_stats
|
||||
|
||||
# Phase 2: Expired OrganizationSubscriptions
|
||||
org_stats = await process_expired_org_subscriptions(db)
|
||||
aggregated["org_subscriptions"] = org_stats
|
||||
|
||||
logger.info(
|
||||
f"=== Subscription Monitor Worker completed: "
|
||||
f"{user_stats['processed'] + org_stats['processed']} total =="
|
||||
)
|
||||
return aggregated
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Subscription Monitor Worker failed: {e}", exc_info=True
|
||||
)
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def main():
|
||||
"""Entry point for standalone execution (cron / manual)."""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger.info("Starting Subscription Monitor Worker (standalone)...")
|
||||
try:
|
||||
stats = await run_full_monitor()
|
||||
total = (
|
||||
stats["user_subscriptions"]["processed"]
|
||||
+ stats["org_subscriptions"]["processed"]
|
||||
)
|
||||
print(
|
||||
f"✅ Subscription Monitor completed. "
|
||||
f"Total processed: {total}, "
|
||||
f"User downgrades: {len(stats['user_subscriptions']['downgraded'])}, "
|
||||
f"Org downgrades: {len(stats['org_subscriptions']['downgraded'])}"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"❌ Subscription Monitor failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
210
backend/backend/tests/active/test_financial_manager.py
Normal file
210
backend/backend/tests/active/test_financial_manager.py
Normal file
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
End-to-end test for the FinancialManager package purchase flow.
|
||||
|
||||
Tests:
|
||||
1. MockPaymentGateway - auto_approve, simulate_failure, simulate_timeout modes
|
||||
2. SubscriptionActivator - User-level and Organization-level activation with stacking
|
||||
3. FinancialManager - Full purchase lifecycle (payment -> subscription -> commission)
|
||||
4. API endpoints - POST /financial-manager/purchase-package
|
||||
|
||||
Usage:
|
||||
docker compose exec sf_api python3 /app/backend/tests/active/test_financial_manager.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
sys.path.insert(0, "/app/backend")
|
||||
|
||||
import httpx
|
||||
|
||||
BASE_URL = os.getenv("TEST_BASE_URL", "http://localhost:8000/api/v1")
|
||||
TEST_EMAIL = os.getenv("TEST_EMAIL", "admin@profibot.hu")
|
||||
TEST_PASSWORD = os.getenv("TEST_PASSWORD", "Admin123!")
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||
logger = logging.getLogger("test-financial-manager")
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
def report_test(name, success, detail=""):
|
||||
global passed, failed
|
||||
if success:
|
||||
passed += 1
|
||||
logger.info(" ✅ PASS: %s", name)
|
||||
else:
|
||||
failed += 1
|
||||
logger.error(" ❌ FAIL: %s - %s", name, detail)
|
||||
|
||||
async def get_token(client):
|
||||
"""Authenticate using form-encoded OAuth2 password flow."""
|
||||
resp = await client.post(
|
||||
f"{BASE_URL}/auth/login",
|
||||
data={"username": TEST_EMAIL, "password": TEST_PASSWORD},
|
||||
)
|
||||
assert resp.status_code == 200, f"Login failed (HTTP {resp.status_code}): {resp.text[:200]}"
|
||||
data = resp.json()
|
||||
token = data.get("access_token")
|
||||
assert token, f"No access_token in response: {data}"
|
||||
return token
|
||||
|
||||
async def test_mock_gateway_unit():
|
||||
logger.info("-" * 60)
|
||||
logger.info("TEST: MockPaymentGateway Unit Tests")
|
||||
logger.info("-" * 60)
|
||||
from app.services.mock_payment_gateway import MockPaymentGateway
|
||||
from app.services.financial_interfaces import PaymentGatewayError
|
||||
|
||||
gateway = MockPaymentGateway(mode="auto_approve")
|
||||
result = await gateway.create_intent(Decimal("100.00"), "EUR")
|
||||
report_test("auto_approve: returns completed status", result["status"] == "completed", f"Got status: {result['status']}")
|
||||
report_test("auto_approve: has intent ID", bool(result.get("id")), f"ID: {result.get('id')}")
|
||||
report_test("auto_approve: correct amount", result["amount"] == 100.0, f"Amount: {result['amount']}")
|
||||
|
||||
verify = await gateway.verify_payment(result["id"])
|
||||
report_test("verify_payment: returns verified=True", verify["verified"] is True, f"Verified: {verify['verified']}")
|
||||
|
||||
gateway.set_mode("simulate_failure")
|
||||
try:
|
||||
await gateway.create_intent(Decimal("50.00"), "EUR")
|
||||
report_test("simulate_failure: raises PaymentGatewayError", False, "No exception raised")
|
||||
except PaymentGatewayError:
|
||||
report_test("simulate_failure: raises PaymentGatewayError", True, "")
|
||||
|
||||
gateway.set_mode("auto_approve")
|
||||
result2 = await gateway.create_intent(Decimal("75.00"), "EUR")
|
||||
refund = await gateway.refund_payment(result2["id"])
|
||||
report_test("refund_payment: returns refunded=True", refund["refunded"] is True, f"Refunded: {refund['refunded']}")
|
||||
|
||||
try:
|
||||
await gateway.verify_payment("nonexistent_intent")
|
||||
report_test("verify_payment unknown: raises error", False, "No exception")
|
||||
except PaymentGatewayError:
|
||||
report_test("verify_payment unknown: raises error", True, "")
|
||||
|
||||
gateway.reset()
|
||||
report_test("reset: clears processed intents", len(gateway._processed_intents) == 0, f"Intents left: {len(gateway._processed_intents)}")
|
||||
|
||||
try:
|
||||
gateway.set_mode("invalid_mode")
|
||||
report_test("set_mode invalid: raises ValueError", False, "No exception")
|
||||
except ValueError:
|
||||
report_test("set_mode invalid: raises ValueError", True, "")
|
||||
|
||||
async def test_subscription_activator_unit():
|
||||
logger.info("-" * 60)
|
||||
logger.info("TEST: SubscriptionActivator Unit Tests")
|
||||
logger.info("-" * 60)
|
||||
from app.services.subscription_activator import SubscriptionActivator
|
||||
from app.models.core_logic import SubscriptionTier
|
||||
|
||||
activator = SubscriptionActivator()
|
||||
tier = SubscriptionTier(id=999, name="Test Tier", rules={"duration": {"days": 30, "allow_stacking": True}})
|
||||
|
||||
duration = activator._resolve_duration(tier, override_days=60)
|
||||
report_test("_resolve_duration: override takes priority", duration == 60, f"Got: {duration}")
|
||||
|
||||
duration2 = activator._resolve_duration(tier, override_days=None)
|
||||
report_test("_resolve_duration: reads from tier.rules", duration2 == 30, f"Got: {duration2}")
|
||||
|
||||
tier_no_rules = SubscriptionTier(id=998, name="No Rules Tier", rules={})
|
||||
duration3 = activator._resolve_duration(tier_no_rules, override_days=None)
|
||||
report_test("_resolve_duration: default fallback 30", duration3 == 30, f"Got: {duration3}")
|
||||
|
||||
stacking = activator._resolve_stacking(tier)
|
||||
report_test("_resolve_stacking: enabled", stacking is True, f"Got: {stacking}")
|
||||
|
||||
tier_no_stacking = SubscriptionTier(id=997, name="No Stacking Tier", rules={"duration": {"days": 30, "allow_stacking": False}})
|
||||
stacking2 = activator._resolve_stacking(tier_no_stacking)
|
||||
report_test("_resolve_stacking: disabled", stacking2 is False, f"Got: {stacking2}")
|
||||
|
||||
now = datetime.utcnow()
|
||||
valid_until = activator._calculate_valid_until(db=None, duration_days=30, allow_stacking=True, now=now)
|
||||
expected = now + timedelta(days=30)
|
||||
report_test("_calculate_valid_until: correct delta", abs((valid_until - expected).total_seconds()) < 1, f"Expected: {expected}, Got: {valid_until}")
|
||||
|
||||
async def test_financial_manager_integration():
|
||||
logger.info("-" * 60)
|
||||
logger.info("TEST: FinancialManager API Integration Test")
|
||||
logger.info("-" * 60)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
try:
|
||||
token = await get_token(client)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
logger.info(" ✅ Authenticated successfully")
|
||||
except Exception as e:
|
||||
logger.error(" ❌ Authentication failed: %s", e)
|
||||
report_test("Authentication", False, str(e))
|
||||
return
|
||||
|
||||
# Step 1: Check FinancialManager status
|
||||
try:
|
||||
resp = await client.get(f"{BASE_URL}/financial-manager/financial-manager/status", headers=headers)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
report_test("GET /financial-manager/status", data.get("status") == "operational", json.dumps(data, indent=2))
|
||||
else:
|
||||
report_test("GET /financial-manager/status", False, f"HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
except Exception as e:
|
||||
report_test("GET /financial-manager/status", False, str(e))
|
||||
|
||||
# Step 2: Use a known valid tier_id from the database
|
||||
# private_pro_v1 (id=14) has pricing_zones with EUR monthly_price=2.99
|
||||
tier_id = 14
|
||||
logger.info(" Using known tier_id=%d (private_pro_v1)", tier_id)
|
||||
report_test("Using known tier_id=14", True, "private_pro_v1 with EUR pricing")
|
||||
|
||||
# Step 3: Purchase a package
|
||||
purchase_payload = {"tier_id": tier_id, "region_code": "GLOBAL", "currency": "EUR", "metadata": {"test_run": True}}
|
||||
try:
|
||||
resp = await client.post(f"{BASE_URL}/financial-manager/purchase-package", headers=headers, json=purchase_payload)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
report_test("POST /financial-manager/purchase-package (success)", data.get("success") is True, json.dumps(data, indent=2))
|
||||
report_test("Response: has payment_intent_id", data.get("payment_intent_id") is not None, f"ID: {data.get('payment_intent_id')}")
|
||||
report_test("Response: has subscription_id", data.get("subscription_id") is not None, f"ID: {data.get('subscription_id')}")
|
||||
report_test("Response: has tier_name", bool(data.get("tier_name")), f"Tier: {data.get('tier_name')}")
|
||||
report_test("Response: amount_paid > 0", data.get("amount_paid", 0) > 0, f"Amount: {data.get('amount_paid')}")
|
||||
report_test("Response: is_org_subscription is False", data.get("is_org_subscription") is False, f"Is org: {data.get('is_org_subscription')}")
|
||||
else:
|
||||
report_test("POST /financial-manager/purchase-package", False, f"HTTP {resp.status_code}: {resp.text[:300]}")
|
||||
except Exception as e:
|
||||
report_test("POST /financial-manager/purchase-package", False, str(e))
|
||||
|
||||
# Step 4: Test with invalid tier_id (404 expected)
|
||||
try:
|
||||
resp = await client.post(f"{BASE_URL}/financial-manager/purchase-package", headers=headers, json={"tier_id": 99999, "region_code": "GLOBAL", "currency": "EUR"})
|
||||
report_test("POST with invalid tier_id (expect 404)", resp.status_code == 404, f"HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
except Exception as e:
|
||||
report_test("POST with invalid tier_id", False, str(e))
|
||||
|
||||
async def main():
|
||||
logger.info("=" * 60)
|
||||
logger.info(" FinancialManager Test Suite")
|
||||
logger.info("=" * 60)
|
||||
await test_mock_gateway_unit()
|
||||
logger.info("")
|
||||
await test_subscription_activator_unit()
|
||||
logger.info("")
|
||||
await test_financial_manager_integration()
|
||||
logger.info("")
|
||||
logger.info("=" * 60)
|
||||
logger.info(" RESULTS: %d passed, %d failed out of %d", passed, failed, passed + failed)
|
||||
logger.info("=" * 60)
|
||||
if failed > 0:
|
||||
logger.error(" ❌ SOME TESTS FAILED!")
|
||||
sys.exit(1)
|
||||
else:
|
||||
logger.info(" ✅ ALL TESTS PASSED!")
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -2297,197 +2297,106 @@ async function getLocaleMessagesMerged(locale, loaders = []) {
|
||||
return merged;
|
||||
}
|
||||
|
||||
var loading$1 = "Betöltés...";
|
||||
var saving$1 = "Mentés...";
|
||||
var error$1 = "Hiba történt";
|
||||
var retry$1 = "Újra";
|
||||
var save$1 = "Mentés";
|
||||
var saving_data$1 = "Adatok mentése...";
|
||||
var cancel$1 = "Mégsem";
|
||||
var discard$1 = "Elvetés";
|
||||
var delete_confirm$1 = "Biztosan törölni szeretnéd ezt az elemet?";
|
||||
var delete_btn$1 = "Törlés";
|
||||
var delete_title$1 = "Törlés Megerősítése";
|
||||
var deleted$1 = "Sikeresen törölve";
|
||||
var edit$1 = "Szerkesztés";
|
||||
var edit_title$1 = "Elem Szerkesztése";
|
||||
var create$1 = "Létrehozás";
|
||||
var create_btn$1 = "Létrehozás";
|
||||
var create_title$1 = "Új Létrehozása";
|
||||
var create_first$1 = "Hozd létre az első elemet a kezdéshez";
|
||||
var search$1 = "Keresés";
|
||||
var search_placeholder$1 = "Keresés...";
|
||||
var filter$1 = "Szűrés";
|
||||
var no_results$1 = "Nincs találat";
|
||||
var no_results_hint$1 = "Próbáld módosítani a keresési vagy szűrési feltételeket";
|
||||
var no_items$1 = "Nincsenek elemek";
|
||||
var actions$1 = "Műveletek";
|
||||
var status$1 = "Státusz";
|
||||
var active$1 = "Aktív";
|
||||
var inactive$1 = "Inaktív";
|
||||
var activate$1 = "Aktiválás";
|
||||
var deactivate$1 = "Deaktiválás";
|
||||
var name$1 = "Név";
|
||||
var name_placeholder$1 = "Add meg a nevet";
|
||||
var description$1 = "Leírás";
|
||||
var desc_placeholder$1 = "Add meg a leírást";
|
||||
var details$1 = "Részletek";
|
||||
var email$1 = "E-mail";
|
||||
var id$1 = "Azonosító";
|
||||
var type$1 = "Típus";
|
||||
var type_corporate$1 = "Vállalati";
|
||||
var view$1 = "Megtekintés";
|
||||
var updated$1 = "Sikeresen frissítve";
|
||||
var save_error$1 = "Sikertelen mentés";
|
||||
var invalid_json$1 = "Érvénytelen JSON formátum";
|
||||
var prev$1 = "Előző";
|
||||
var next$1 = "Következő";
|
||||
var none$1 = "Nincs";
|
||||
var reset$1 = "Visszaállítás";
|
||||
var confirm$1 = "Megerősítés";
|
||||
var close$1 = "Bezárás";
|
||||
var back$1 = "Vissza";
|
||||
var exit$1 = "Kilépés";
|
||||
var all$1 = "Minden";
|
||||
var select_all$1 = "Összes kiválasztása";
|
||||
var deselect_all$1 = "Összes elrejtése";
|
||||
var expires$1 = "Lejárat";
|
||||
var created$1 = "Létrehozva";
|
||||
var start_date$1 = "Kezdő Dátum";
|
||||
var end_date$1 = "Befejező Dátum";
|
||||
var points$1 = "Pontok";
|
||||
var level$1 = "Szint";
|
||||
var user$1 = "Felhasználó";
|
||||
var user_id$1 = "Felhasználó Azonosító";
|
||||
var services$1 = "Szolgáltatások";
|
||||
var discoveries$1 = "Felfedezések";
|
||||
var restriction$1 = "Korlátozás";
|
||||
var restriction_mild$1 = "Enyhe";
|
||||
var restriction_moderate$1 = "Mérsékelt";
|
||||
var restriction_severe$1 = "Súlyos";
|
||||
var min_xp$1 = "Min XP";
|
||||
var xp$1 = "XP";
|
||||
var penalty$1 = "Büntetés";
|
||||
var feature_flags$1 = "Funkció Kapcsolók";
|
||||
var permission$1 = "Engedély";
|
||||
var role$1 = "Szerepkör";
|
||||
var domain$1 = "Tartomány";
|
||||
var action$1 = "Művelet";
|
||||
var value$1 = "Érték";
|
||||
var key$1 = "Kulcs";
|
||||
var enabled$1 = "Bekapcsolva";
|
||||
var disabled$1 = "Kikapcsolva";
|
||||
var yes$1 = "Igen";
|
||||
var no$1 = "Nem";
|
||||
var on$1 = "Be";
|
||||
var off$1 = "Ki";
|
||||
var service_finder$1 = "Service Finder";
|
||||
var admin$1 = "Admin";
|
||||
var version$1 = "Verzió";
|
||||
var super_administrator$1 = "Szuper Adminisztrátor";
|
||||
var administrator$1 = "Adminisztrátor";
|
||||
var profile_settings$1 = "Profil Beállítások";
|
||||
var sign_out$1 = "Kijelentkezés";
|
||||
var unknown_user$1 = "Ismeretlen felhasználó";
|
||||
var ascending$1 = "Növekvő";
|
||||
var descending$1 = "Csökkenő";
|
||||
const locale_common_46json_68c48c8a = {
|
||||
loading: loading$1,
|
||||
saving: saving$1,
|
||||
error: error$1,
|
||||
retry: retry$1,
|
||||
save: save$1,
|
||||
saving_data: saving_data$1,
|
||||
cancel: cancel$1,
|
||||
discard: discard$1,
|
||||
var common$6 = {
|
||||
loading: "Betöltés...",
|
||||
saving: "Mentés...",
|
||||
error: "Hiba történt",
|
||||
retry: "Újra",
|
||||
save: "Mentés",
|
||||
saving_data: "Adatok mentése...",
|
||||
cancel: "Mégsem",
|
||||
discard: "Elvetés",
|
||||
"delete": "Törlés",
|
||||
delete_confirm: delete_confirm$1,
|
||||
delete_btn: delete_btn$1,
|
||||
delete_title: delete_title$1,
|
||||
deleted: deleted$1,
|
||||
edit: edit$1,
|
||||
edit_title: edit_title$1,
|
||||
create: create$1,
|
||||
create_btn: create_btn$1,
|
||||
create_title: create_title$1,
|
||||
create_first: create_first$1,
|
||||
search: search$1,
|
||||
search_placeholder: search_placeholder$1,
|
||||
filter: filter$1,
|
||||
no_results: no_results$1,
|
||||
no_results_hint: no_results_hint$1,
|
||||
no_items: no_items$1,
|
||||
actions: actions$1,
|
||||
status: status$1,
|
||||
active: active$1,
|
||||
inactive: inactive$1,
|
||||
activate: activate$1,
|
||||
deactivate: deactivate$1,
|
||||
name: name$1,
|
||||
name_placeholder: name_placeholder$1,
|
||||
description: description$1,
|
||||
desc_placeholder: desc_placeholder$1,
|
||||
details: details$1,
|
||||
email: email$1,
|
||||
id: id$1,
|
||||
type: type$1,
|
||||
type_corporate: type_corporate$1,
|
||||
view: view$1,
|
||||
updated: updated$1,
|
||||
save_error: save_error$1,
|
||||
invalid_json: invalid_json$1,
|
||||
prev: prev$1,
|
||||
next: next$1,
|
||||
none: none$1,
|
||||
reset: reset$1,
|
||||
confirm: confirm$1,
|
||||
close: close$1,
|
||||
back: back$1,
|
||||
delete_confirm: "Biztosan törölni szeretnéd ezt az elemet?",
|
||||
delete_btn: "Törlés",
|
||||
delete_title: "Törlés Megerősítése",
|
||||
deleted: "Sikeresen törölve",
|
||||
edit: "Szerkesztés",
|
||||
edit_title: "Elem Szerkesztése",
|
||||
create: "Létrehozás",
|
||||
create_btn: "Létrehozás",
|
||||
create_title: "Új Létrehozása",
|
||||
create_first: "Hozd létre az első elemet a kezdéshez",
|
||||
search: "Keresés",
|
||||
search_placeholder: "Keresés...",
|
||||
filter: "Szűrés",
|
||||
no_results: "Nincs találat",
|
||||
no_results_hint: "Próbáld módosítani a keresési vagy szűrési feltételeket",
|
||||
no_items: "Nincsenek elemek",
|
||||
actions: "Műveletek",
|
||||
status: "Státusz",
|
||||
active: "Aktív",
|
||||
inactive: "Inaktív",
|
||||
activate: "Aktiválás",
|
||||
deactivate: "Deaktiválás",
|
||||
name: "Név",
|
||||
name_placeholder: "Add meg a nevet",
|
||||
description: "Leírás",
|
||||
desc_placeholder: "Add meg a leírást",
|
||||
details: "Részletek",
|
||||
email: "E-mail",
|
||||
id: "Azonosító",
|
||||
type: "Típus",
|
||||
type_corporate: "Vállalati",
|
||||
view: "Megtekintés",
|
||||
updated: "Sikeresen frissítve",
|
||||
save_error: "Sikertelen mentés",
|
||||
invalid_json: "Érvénytelen JSON formátum",
|
||||
prev: "Előző",
|
||||
next: "Következő",
|
||||
none: "Nincs",
|
||||
reset: "Visszaállítás",
|
||||
confirm: "Megerősítés",
|
||||
close: "Bezárás",
|
||||
back: "Vissza",
|
||||
"continue": "Tovább",
|
||||
exit: exit$1,
|
||||
all: all$1,
|
||||
select_all: select_all$1,
|
||||
deselect_all: deselect_all$1,
|
||||
expires: expires$1,
|
||||
created: created$1,
|
||||
start_date: start_date$1,
|
||||
end_date: end_date$1,
|
||||
points: points$1,
|
||||
level: level$1,
|
||||
user: user$1,
|
||||
user_id: user_id$1,
|
||||
services: services$1,
|
||||
discoveries: discoveries$1,
|
||||
restriction: restriction$1,
|
||||
restriction_mild: restriction_mild$1,
|
||||
restriction_moderate: restriction_moderate$1,
|
||||
restriction_severe: restriction_severe$1,
|
||||
min_xp: min_xp$1,
|
||||
xp: xp$1,
|
||||
penalty: penalty$1,
|
||||
feature_flags: feature_flags$1,
|
||||
permission: permission$1,
|
||||
role: role$1,
|
||||
domain: domain$1,
|
||||
action: action$1,
|
||||
value: value$1,
|
||||
key: key$1,
|
||||
enabled: enabled$1,
|
||||
disabled: disabled$1,
|
||||
yes: yes$1,
|
||||
no: no$1,
|
||||
on: on$1,
|
||||
off: off$1,
|
||||
service_finder: service_finder$1,
|
||||
admin: admin$1,
|
||||
version: version$1,
|
||||
super_administrator: super_administrator$1,
|
||||
administrator: administrator$1,
|
||||
profile_settings: profile_settings$1,
|
||||
sign_out: sign_out$1,
|
||||
unknown_user: unknown_user$1,
|
||||
ascending: ascending$1,
|
||||
descending: descending$1
|
||||
exit: "Kilépés",
|
||||
all: "Minden",
|
||||
select_all: "Összes kiválasztása",
|
||||
deselect_all: "Összes elrejtése",
|
||||
expires: "Lejárat",
|
||||
created: "Létrehozva",
|
||||
start_date: "Kezdő Dátum",
|
||||
end_date: "Befejező Dátum",
|
||||
points: "Pontok",
|
||||
level: "Szint",
|
||||
user: "Felhasználó",
|
||||
user_id: "Felhasználó Azonosító",
|
||||
services: "Szolgáltatások",
|
||||
discoveries: "Felfedezések",
|
||||
restriction: "Korlátozás",
|
||||
restriction_mild: "Enyhe",
|
||||
restriction_moderate: "Mérsékelt",
|
||||
restriction_severe: "Súlyos",
|
||||
min_xp: "Min XP",
|
||||
xp: "XP",
|
||||
penalty: "Büntetés",
|
||||
feature_flags: "Funkció Kapcsolók",
|
||||
permission: "Engedély",
|
||||
role: "Szerepkör",
|
||||
domain: "Tartomány",
|
||||
action: "Művelet",
|
||||
value: "Érték",
|
||||
key: "Kulcs",
|
||||
enabled: "Bekapcsolva",
|
||||
disabled: "Kikapcsolva",
|
||||
yes: "Igen",
|
||||
no: "Nem",
|
||||
on: "Be",
|
||||
off: "Ki",
|
||||
service_finder: "Service Finder",
|
||||
admin: "Admin",
|
||||
version: "Verzió",
|
||||
super_administrator: "Szuper Adminisztrátor",
|
||||
administrator: "Adminisztrátor",
|
||||
profile_settings: "Profil Beállítások",
|
||||
sign_out: "Kijelentkezés",
|
||||
unknown_user: "Ismeretlen felhasználó",
|
||||
ascending: "Növekvő",
|
||||
descending: "Csökkenő"
|
||||
};
|
||||
const locale_common_46json_68c48c8a = {
|
||||
common: common$6
|
||||
};
|
||||
|
||||
var menu$1 = {
|
||||
@@ -3667,74 +3576,6 @@ var system$1 = {
|
||||
no_csv_data: "Nincs adat a CSV exportáláshoz.",
|
||||
csv_downloaded: "CSV letöltés elindítva."
|
||||
},
|
||||
packages: {
|
||||
title: "Csomagok Kezelése",
|
||||
subtitle: "Előfizetési csomagok és funkciók kezelése — Free, Premium, Enterprise",
|
||||
loading: "Csomagok betöltése...",
|
||||
retry: "Újra",
|
||||
create_new: "Új Csomag",
|
||||
no_packages: "Még nincsenek csomagok",
|
||||
no_packages_desc: "Hozd létre az első előfizetési csomagot a kezdéshez.",
|
||||
create_first: "Első Csomag Létrehozása",
|
||||
popular: "Népszerű",
|
||||
fallback_badge: "Visszaesési",
|
||||
trial_badge: "Próba",
|
||||
month: "hó",
|
||||
feature_flags: "Elérhető Funkciók",
|
||||
edit: "Szerkesztés",
|
||||
duplicate: "Másolás",
|
||||
"delete": "Törlés",
|
||||
set_as_default: "Beállítás alapértelmezettként",
|
||||
set_as_default_success: "alapértelmezett csomagként beállítva!",
|
||||
edit_title: "Csomag Szerkesztése",
|
||||
create_title: "Csomag Létrehozása",
|
||||
cancel: "Mégse",
|
||||
save: "Változtatások Mentése",
|
||||
create: "Létrehozás",
|
||||
save_success: "Csomag sikeresen frissítve!",
|
||||
delete_title: "Csomag Törlése",
|
||||
delete_confirm: "Biztosan törölni szeretnéd a \"{name}\" csomagot?",
|
||||
delete_btn: "Törlés",
|
||||
allowances: "Korlátok",
|
||||
max_vehicles: "Max Jármű",
|
||||
max_garages: "Max Garázs",
|
||||
max_users: "Max Dolgozó",
|
||||
monthly_credits: "Havi Kredit",
|
||||
tier_level: "Szint",
|
||||
form_name: "Rendszer Név",
|
||||
form_name_placeholder: "pl. premium_2024",
|
||||
form_display_name: "Megjelenítési Név",
|
||||
form_display_name_placeholder: "pl. Premium Plus",
|
||||
form_type: "Típus",
|
||||
type_private: "Magán",
|
||||
type_corporate: "Vállalati",
|
||||
form_tier_level: "Szintszám",
|
||||
form_tier_level_hint: "Magasabb szám = több funkció és magasabb rang",
|
||||
form_monthly_price: "Havi Díj",
|
||||
form_yearly_price: "Éves Díj",
|
||||
form_currency: "Pénznem",
|
||||
form_subtitle: "Felirat / Leírás",
|
||||
form_subtitle_placeholder: "pl. Ideális kisvállalkozásoknak",
|
||||
form_badge: "Jelvény Szöveg",
|
||||
form_badge_placeholder: "pl. Legnépszerűbb",
|
||||
form_is_custom: "Egyedi Csomag (nem publikus)",
|
||||
singleton_rules: "Egyediségi Szabályok",
|
||||
form_is_default_fallback: "Alapértelmezett Visszaesési Csomag — lejárt előfizetés esetén erre vált",
|
||||
form_trial_days: "Próba Napok Regisztrációnál",
|
||||
form_trial_days_hint: "Az új szervezetek ennyi ingyenes napot kapnak. Csak egy csomagnál lehet > 0.",
|
||||
per_region_pricing: "Régiók szerinti árazás",
|
||||
smart_calculator: "Okos Árkalkulátor",
|
||||
calc_net_price: "Nettó ár",
|
||||
calc_vat_rate: "ÁFA kulcs",
|
||||
calc_currency: "Pénznem",
|
||||
calc_net: "Nettó",
|
||||
calc_vat: "ÁFA",
|
||||
calc_gross: "Bruttó",
|
||||
calc_apply: "Alkalmaz a kiválasztott régióra",
|
||||
add_zone: "Új Zóna Hozzáadása",
|
||||
add_zone_select: "Válassz országkódot...",
|
||||
add_zone_already_exists: "Ez a régió már létezik a csomag árazásában!"
|
||||
},
|
||||
permissions: {
|
||||
title: "Jogosultság Kezelés",
|
||||
subtitle: "Jogosultsági Mátrix — Szerepkör alapú hozzáférés-vezérlés",
|
||||
@@ -3764,8 +3605,83 @@ var system$1 = {
|
||||
action: "Művelet"
|
||||
}
|
||||
};
|
||||
var packages$1 = {
|
||||
title: "Csomagok Kezelése",
|
||||
subtitle: "Előfizetési csomagok és funkciók kezelése — Free, Premium, Enterprise",
|
||||
loading: "Csomagok betöltése...",
|
||||
retry: "Újra",
|
||||
create_new: "Új Csomag",
|
||||
no_packages: "Még nincsenek csomagok",
|
||||
no_packages_desc: "Hozd létre az első előfizetési csomagot a kezdéshez.",
|
||||
create_first: "Első Csomag Létrehozása",
|
||||
popular: "Népszerű",
|
||||
fallback_badge: "Visszaesési",
|
||||
trial_badge: "Próba",
|
||||
month: "hó",
|
||||
feature_flags: "Elérhető Funkciók",
|
||||
edit: "Szerkesztés",
|
||||
duplicate: "Másolás",
|
||||
"delete": "Törlés",
|
||||
set_as_default: "Beállítás alapértelmezettként",
|
||||
set_as_default_success: "alapértelmezett csomagként beállítva!",
|
||||
edit_title: "Csomag Szerkesztése",
|
||||
create_title: "Csomag Létrehozása",
|
||||
cancel: "Mégse",
|
||||
save: "Változtatások Mentése",
|
||||
create: "Létrehozás",
|
||||
save_success: "Csomag sikeresen frissítve!",
|
||||
delete_title: "Csomag Törlése",
|
||||
delete_confirm: "Biztosan törölni szeretnéd a \"{name}\" csomagot?",
|
||||
delete_btn: "Törlés",
|
||||
allowances: "Korlátok",
|
||||
max_vehicles: "Max Jármű",
|
||||
max_garages: "Max Garázs",
|
||||
max_users: "Max Dolgozó",
|
||||
monthly_credits: "Havi Kredit",
|
||||
tier_level: "Szint",
|
||||
form_name: "Rendszer Név",
|
||||
form_name_placeholder: "pl. premium_2024",
|
||||
form_display_name: "Megjelenítési Név",
|
||||
form_display_name_placeholder: "pl. Premium Plus",
|
||||
form_type: "Típus",
|
||||
type_private: "Magán",
|
||||
type_corporate: "Vállalati",
|
||||
form_tier_level: "Szintszám",
|
||||
form_tier_level_hint: "Magasabb szám = több funkció és magasabb rang",
|
||||
form_monthly_price: "Havi Díj",
|
||||
form_yearly_price: "Éves Díj",
|
||||
form_currency: "Pénznem",
|
||||
form_subtitle: "Felirat / Leírás",
|
||||
form_subtitle_placeholder: "pl. Ideális kisvállalkozásoknak",
|
||||
form_badge: "Jelvény Szöveg",
|
||||
form_badge_placeholder: "pl. Legnépszerűbb",
|
||||
form_is_custom: "Egyedi Csomag (nem publikus)",
|
||||
singleton_rules: "Egyediségi Szabályok",
|
||||
form_is_default_fallback: "Alapértelmezett Visszaesési Csomag — lejárt előfizetés esetén erre vált",
|
||||
form_trial_days: "Próba Napok Regisztrációnál",
|
||||
form_trial_days_hint: "Az új szervezetek ennyi ingyenes napot kapnak. Csak egy csomagnál lehet > 0.",
|
||||
per_region_pricing: "Régiók szerinti árazás",
|
||||
smart_calculator: "Okos Árkalkulátor",
|
||||
calc_net_price: "Nettó ár",
|
||||
calc_vat_rate: "ÁFA kulcs",
|
||||
calc_currency: "Pénznem",
|
||||
calc_net: "Nettó",
|
||||
calc_vat: "ÁFA",
|
||||
calc_gross: "Bruttó",
|
||||
calc_apply: "Alkalmaz a kiválasztott régióra",
|
||||
add_zone: "Új Zóna Hozzáadása",
|
||||
add_zone_select: "Válassz országkódot...",
|
||||
add_zone_already_exists: "Ez a régió már létezik a csomag árazásában!",
|
||||
feature_capabilities: "Funkció Képességek (JSON)",
|
||||
feature_capabilities_hint: "JSON formátumban add meg a funkció képességeket. Pl.: ai_analysis: true, priority_support: false",
|
||||
singleton_rules_title: "Egyediségi Szabályok",
|
||||
form_trial_days_label: "Próba Napok",
|
||||
per_region_pricing_title: "Régiók szerinti árazás",
|
||||
smart_calculator_title: "Okos Árkalkulátor"
|
||||
};
|
||||
const locale_system_46json_21da1de8 = {
|
||||
system: system$1
|
||||
system: system$1,
|
||||
packages: packages$1
|
||||
};
|
||||
|
||||
var commission_rules$1 = {
|
||||
@@ -3800,7 +3716,16 @@ var commission_rules$1 = {
|
||||
upline_commission_percent_helper: "A felső szintű ajánlónak (Gen2 — az ajánló ajánlója) járó jutalék százaléka minden vásárlás után.",
|
||||
renewal_commission_percent: "Megújítási Jutalék %",
|
||||
renewal_commission_percent_helper: "A havi/éves előfizetés megújításakor járó jutalék százaléka.",
|
||||
commission_max_amount: "Max Összeg",
|
||||
commission_max_amount: "Max Összeg (általános)",
|
||||
gen1_max_amount: "Gen1 Max Összeg",
|
||||
gen1_max_amount_helper: "Gen1 (közvetlen ajánló) maximális jutalék összege. NULL/0 = korlátlan, fallback: Max Összeg.",
|
||||
gen2_max_amount: "Gen2 Max Összeg",
|
||||
gen2_max_amount_helper: "Gen2 (felső szintű ajánló) maximális jutalék összege. NULL/0 = korlátlan, fallback: Max Összeg.",
|
||||
gen1_title: "Közvetlen Ajánló (Gen1)",
|
||||
gen2_title: "Felsővonal (Gen2)",
|
||||
legacy_fallback: "örökölt mező",
|
||||
gen2_renewal_percent: "Gen2 Megújítási Jutalék %",
|
||||
gen2_renewal_percent_helper: "Gen2-nek járó megújítási jutalék százaléka (pl. 1.00 = 1%). Fallback: Felső szintű Jutalék %.",
|
||||
deactivate_confirm: "Biztosan deaktiválod a következőt: \"{name}\"?",
|
||||
showing: "Mutatva",
|
||||
xp_reward_helper: "Egyéni meghívások után járó fix jutalom (pl. sikeres regisztráció).",
|
||||
@@ -3843,203 +3768,116 @@ var commission_rules$1 = {
|
||||
tier_vip: "VIP",
|
||||
tier_platinum: "Platinum",
|
||||
tier_enterprise: "Enterprise",
|
||||
tier_contracted: "Szerződött Üzletkötő"
|
||||
tier_contracted: "Szerződött Üzletkötő",
|
||||
conflict_title: "Szabályütközés észlelve",
|
||||
conflict_message: "Már létezik egy aktív szabály erre a célcsoportra és régióra. Szeretnéd inaktiválni a régit, és életbe léptetni az újat?",
|
||||
conflict_confirm: "Felülírás jóváhagyása",
|
||||
conflict_cancel: "Mégsem"
|
||||
};
|
||||
const locale_commission_46json_2229a18b = {
|
||||
commission_rules: commission_rules$1
|
||||
};
|
||||
|
||||
var loading = "Loading...";
|
||||
var saving = "Saving...";
|
||||
var error = "An error occurred";
|
||||
var retry = "Retry";
|
||||
var save = "Save";
|
||||
var saving_data = "Saving data...";
|
||||
var cancel = "Cancel";
|
||||
var discard = "Discard";
|
||||
var delete_confirm = "Are you sure you want to delete this item?";
|
||||
var delete_btn = "Delete";
|
||||
var delete_title = "Confirm Deletion";
|
||||
var deleted = "Deleted successfully";
|
||||
var edit = "Edit";
|
||||
var edit_title = "Edit Item";
|
||||
var create = "Create";
|
||||
var create_btn = "Create";
|
||||
var create_title = "Create New";
|
||||
var create_first = "Create your first item to get started";
|
||||
var search = "Search";
|
||||
var search_placeholder = "Search...";
|
||||
var filter = "Filter";
|
||||
var no_results = "No results found";
|
||||
var no_results_hint = "Try adjusting your search or filter criteria";
|
||||
var no_items = "No items available";
|
||||
var actions = "Actions";
|
||||
var status = "Status";
|
||||
var active = "Active";
|
||||
var inactive = "Inactive";
|
||||
var activate = "Activate";
|
||||
var deactivate = "Deactivate";
|
||||
var name = "Name";
|
||||
var name_placeholder = "Enter name";
|
||||
var description = "Description";
|
||||
var desc_placeholder = "Enter description";
|
||||
var details = "Details";
|
||||
var email = "Email";
|
||||
var id = "ID";
|
||||
var type = "Type";
|
||||
var type_corporate = "Corporate";
|
||||
var view = "View";
|
||||
var updated = "Updated successfully";
|
||||
var save_error = "Failed to save";
|
||||
var invalid_json = "Invalid JSON format";
|
||||
var prev = "Previous";
|
||||
var next = "Next";
|
||||
var none = "None";
|
||||
var reset = "Reset";
|
||||
var confirm = "Confirm";
|
||||
var close = "Close";
|
||||
var back = "Back";
|
||||
var exit = "Exit";
|
||||
var all = "All";
|
||||
var select_all = "Select All";
|
||||
var deselect_all = "Deselect All";
|
||||
var expires = "Expires";
|
||||
var created = "Created";
|
||||
var start_date = "Start Date";
|
||||
var end_date = "End Date";
|
||||
var points = "Points";
|
||||
var level = "Level";
|
||||
var user = "User";
|
||||
var user_id = "User ID";
|
||||
var services = "Services";
|
||||
var discoveries = "Discoveries";
|
||||
var restriction = "Restriction";
|
||||
var restriction_mild = "Mild";
|
||||
var restriction_moderate = "Moderate";
|
||||
var restriction_severe = "Severe";
|
||||
var min_xp = "Min XP";
|
||||
var xp = "XP";
|
||||
var penalty = "Penalty";
|
||||
var feature_flags = "Feature Flags";
|
||||
var permission = "Permission";
|
||||
var role = "Role";
|
||||
var domain = "Domain";
|
||||
var action = "Action";
|
||||
var value = "Value";
|
||||
var key = "Key";
|
||||
var enabled = "Enabled";
|
||||
var disabled = "Disabled";
|
||||
var yes = "Yes";
|
||||
var no = "No";
|
||||
var on = "On";
|
||||
var off = "Off";
|
||||
var service_finder = "Service Finder";
|
||||
var admin = "Admin";
|
||||
var version = "Version";
|
||||
var super_administrator = "Super Administrator";
|
||||
var administrator = "Administrator";
|
||||
var profile_settings = "Profile Settings";
|
||||
var sign_out = "Sign Out";
|
||||
var unknown_user = "Unknown user";
|
||||
var ascending = "Ascending";
|
||||
var descending = "Descending";
|
||||
const locale_common_46json_364cfaa7 = {
|
||||
loading: loading,
|
||||
saving: saving,
|
||||
error: error,
|
||||
retry: retry,
|
||||
save: save,
|
||||
saving_data: saving_data,
|
||||
cancel: cancel,
|
||||
discard: discard,
|
||||
var common$5 = {
|
||||
loading: "Loading...",
|
||||
saving: "Saving...",
|
||||
error: "An error occurred",
|
||||
retry: "Retry",
|
||||
save: "Save",
|
||||
saving_data: "Saving data...",
|
||||
cancel: "Cancel",
|
||||
discard: "Discard",
|
||||
"delete": "Delete",
|
||||
delete_confirm: delete_confirm,
|
||||
delete_btn: delete_btn,
|
||||
delete_title: delete_title,
|
||||
deleted: deleted,
|
||||
edit: edit,
|
||||
edit_title: edit_title,
|
||||
create: create,
|
||||
create_btn: create_btn,
|
||||
create_title: create_title,
|
||||
create_first: create_first,
|
||||
search: search,
|
||||
search_placeholder: search_placeholder,
|
||||
filter: filter,
|
||||
no_results: no_results,
|
||||
no_results_hint: no_results_hint,
|
||||
no_items: no_items,
|
||||
actions: actions,
|
||||
status: status,
|
||||
active: active,
|
||||
inactive: inactive,
|
||||
activate: activate,
|
||||
deactivate: deactivate,
|
||||
name: name,
|
||||
name_placeholder: name_placeholder,
|
||||
description: description,
|
||||
desc_placeholder: desc_placeholder,
|
||||
details: details,
|
||||
email: email,
|
||||
id: id,
|
||||
type: type,
|
||||
type_corporate: type_corporate,
|
||||
view: view,
|
||||
updated: updated,
|
||||
save_error: save_error,
|
||||
invalid_json: invalid_json,
|
||||
prev: prev,
|
||||
next: next,
|
||||
none: none,
|
||||
reset: reset,
|
||||
confirm: confirm,
|
||||
close: close,
|
||||
back: back,
|
||||
delete_confirm: "Are you sure you want to delete this item?",
|
||||
delete_btn: "Delete",
|
||||
delete_title: "Confirm Deletion",
|
||||
deleted: "Deleted successfully",
|
||||
edit: "Edit",
|
||||
edit_title: "Edit Item",
|
||||
create: "Create",
|
||||
create_btn: "Create",
|
||||
create_title: "Create New",
|
||||
create_first: "Create your first item to get started",
|
||||
search: "Search",
|
||||
search_placeholder: "Search...",
|
||||
filter: "Filter",
|
||||
no_results: "No results found",
|
||||
no_results_hint: "Try adjusting your search or filter criteria",
|
||||
no_items: "No items available",
|
||||
actions: "Actions",
|
||||
status: "Status",
|
||||
active: "Active",
|
||||
inactive: "Inactive",
|
||||
activate: "Activate",
|
||||
deactivate: "Deactivate",
|
||||
name: "Name",
|
||||
name_placeholder: "Enter name",
|
||||
description: "Description",
|
||||
desc_placeholder: "Enter description",
|
||||
details: "Details",
|
||||
email: "Email",
|
||||
id: "ID",
|
||||
type: "Type",
|
||||
type_corporate: "Corporate",
|
||||
view: "View",
|
||||
updated: "Updated successfully",
|
||||
save_error: "Failed to save",
|
||||
invalid_json: "Invalid JSON format",
|
||||
prev: "Previous",
|
||||
next: "Next",
|
||||
none: "None",
|
||||
reset: "Reset",
|
||||
confirm: "Confirm",
|
||||
close: "Close",
|
||||
back: "Back",
|
||||
"continue": "Continue",
|
||||
exit: exit,
|
||||
all: all,
|
||||
select_all: select_all,
|
||||
deselect_all: deselect_all,
|
||||
expires: expires,
|
||||
created: created,
|
||||
start_date: start_date,
|
||||
end_date: end_date,
|
||||
points: points,
|
||||
level: level,
|
||||
user: user,
|
||||
user_id: user_id,
|
||||
services: services,
|
||||
discoveries: discoveries,
|
||||
restriction: restriction,
|
||||
restriction_mild: restriction_mild,
|
||||
restriction_moderate: restriction_moderate,
|
||||
restriction_severe: restriction_severe,
|
||||
min_xp: min_xp,
|
||||
xp: xp,
|
||||
penalty: penalty,
|
||||
feature_flags: feature_flags,
|
||||
permission: permission,
|
||||
role: role,
|
||||
domain: domain,
|
||||
action: action,
|
||||
value: value,
|
||||
key: key,
|
||||
enabled: enabled,
|
||||
disabled: disabled,
|
||||
yes: yes,
|
||||
no: no,
|
||||
on: on,
|
||||
off: off,
|
||||
service_finder: service_finder,
|
||||
admin: admin,
|
||||
version: version,
|
||||
super_administrator: super_administrator,
|
||||
administrator: administrator,
|
||||
profile_settings: profile_settings,
|
||||
sign_out: sign_out,
|
||||
unknown_user: unknown_user,
|
||||
ascending: ascending,
|
||||
descending: descending
|
||||
exit: "Exit",
|
||||
all: "All",
|
||||
select_all: "Select All",
|
||||
deselect_all: "Deselect All",
|
||||
expires: "Expires",
|
||||
created: "Created",
|
||||
start_date: "Start Date",
|
||||
end_date: "End Date",
|
||||
points: "Points",
|
||||
level: "Level",
|
||||
user: "User",
|
||||
user_id: "User ID",
|
||||
services: "Services",
|
||||
discoveries: "Discoveries",
|
||||
restriction: "Restriction",
|
||||
restriction_mild: "Mild",
|
||||
restriction_moderate: "Moderate",
|
||||
restriction_severe: "Severe",
|
||||
min_xp: "Min XP",
|
||||
xp: "XP",
|
||||
penalty: "Penalty",
|
||||
feature_flags: "Feature Flags",
|
||||
permission: "Permission",
|
||||
role: "Role",
|
||||
domain: "Domain",
|
||||
action: "Action",
|
||||
value: "Value",
|
||||
key: "Key",
|
||||
enabled: "Enabled",
|
||||
disabled: "Disabled",
|
||||
yes: "Yes",
|
||||
no: "No",
|
||||
on: "On",
|
||||
off: "Off",
|
||||
service_finder: "Service Finder",
|
||||
admin: "Admin",
|
||||
version: "Version",
|
||||
super_administrator: "Super Administrator",
|
||||
administrator: "Administrator",
|
||||
profile_settings: "Profile Settings",
|
||||
sign_out: "Sign Out",
|
||||
unknown_user: "Unknown user",
|
||||
ascending: "Ascending",
|
||||
descending: "Descending"
|
||||
};
|
||||
const locale_common_46json_364cfaa7 = {
|
||||
common: common$5
|
||||
};
|
||||
|
||||
var menu = {
|
||||
@@ -5219,74 +5057,6 @@ var system = {
|
||||
no_csv_data: "No data for CSV export.",
|
||||
csv_downloaded: "CSV download started."
|
||||
},
|
||||
packages: {
|
||||
title: "Package Management",
|
||||
subtitle: "Manage subscription packages and features — Free, Premium, Enterprise",
|
||||
loading: "Loading packages...",
|
||||
retry: "Retry",
|
||||
create_new: "New Package",
|
||||
no_packages: "No packages yet",
|
||||
no_packages_desc: "Create your first subscription package to get started.",
|
||||
create_first: "Create First Package",
|
||||
popular: "Popular",
|
||||
fallback_badge: "Fallback",
|
||||
trial_badge: "Trial",
|
||||
month: "mo",
|
||||
feature_flags: "Available Features",
|
||||
edit: "Edit",
|
||||
duplicate: "Duplicate",
|
||||
"delete": "Delete",
|
||||
set_as_default: "Set as Default",
|
||||
set_as_default_success: "set as default package!",
|
||||
edit_title: "Edit Package",
|
||||
create_title: "Create Package",
|
||||
cancel: "Cancel",
|
||||
save: "Save Changes",
|
||||
create: "Create",
|
||||
save_success: "Package updated successfully!",
|
||||
delete_title: "Delete Package",
|
||||
delete_confirm: "Are you sure you want to delete \"{name}\"?",
|
||||
delete_btn: "Delete",
|
||||
allowances: "Allowances",
|
||||
max_vehicles: "Max Vehicles",
|
||||
max_garages: "Max Garages",
|
||||
max_users: "Max Users",
|
||||
monthly_credits: "Monthly Credits",
|
||||
tier_level: "Level",
|
||||
form_name: "System Name",
|
||||
form_name_placeholder: "e.g. premium_2024",
|
||||
form_display_name: "Display Name",
|
||||
form_display_name_placeholder: "e.g. Premium Plus",
|
||||
form_type: "Type",
|
||||
type_private: "Private",
|
||||
type_corporate: "Corporate",
|
||||
form_tier_level: "Tier Level",
|
||||
form_tier_level_hint: "Higher number = more features and higher rank",
|
||||
form_monthly_price: "Monthly Price",
|
||||
form_yearly_price: "Yearly Price",
|
||||
form_currency: "Currency",
|
||||
form_subtitle: "Subtitle / Description",
|
||||
form_subtitle_placeholder: "e.g. Ideal for small businesses",
|
||||
form_badge: "Badge Text",
|
||||
form_badge_placeholder: "e.g. Most Popular",
|
||||
form_is_custom: "Custom Package (not public)",
|
||||
singleton_rules: "Singleton Rules",
|
||||
form_is_default_fallback: "Default Fallback Package — expired subscriptions fall back to this",
|
||||
form_trial_days: "Trial Days on Signup",
|
||||
form_trial_days_hint: "New organizations get this many free days. Only one package can have > 0.",
|
||||
per_region_pricing: "Per-Region Pricing",
|
||||
smart_calculator: "Smart Price Calculator",
|
||||
calc_net_price: "Net Price",
|
||||
calc_vat_rate: "VAT Rate",
|
||||
calc_currency: "Currency",
|
||||
calc_net: "Net",
|
||||
calc_vat: "VAT",
|
||||
calc_gross: "Gross",
|
||||
calc_apply: "Apply to selected region",
|
||||
add_zone: "Add New Zone",
|
||||
add_zone_select: "Select country code...",
|
||||
add_zone_already_exists: "This region already exists in the package pricing!"
|
||||
},
|
||||
permissions: {
|
||||
title: "Permissions Management",
|
||||
subtitle: "Permission Matrix — Role-based access control",
|
||||
@@ -5316,8 +5086,83 @@ var system = {
|
||||
action: "Action"
|
||||
}
|
||||
};
|
||||
var packages = {
|
||||
title: "Package Management",
|
||||
subtitle: "Manage subscription packages and features — Free, Premium, Enterprise",
|
||||
loading: "Loading packages...",
|
||||
retry: "Retry",
|
||||
create_new: "New Package",
|
||||
no_packages: "No packages yet",
|
||||
no_packages_desc: "Create your first subscription package to get started.",
|
||||
create_first: "Create First Package",
|
||||
popular: "Popular",
|
||||
fallback_badge: "Fallback",
|
||||
trial_badge: "Trial",
|
||||
month: "mo",
|
||||
feature_flags: "Available Features",
|
||||
edit: "Edit",
|
||||
duplicate: "Duplicate",
|
||||
"delete": "Delete",
|
||||
set_as_default: "Set as Default",
|
||||
set_as_default_success: "set as default package!",
|
||||
edit_title: "Edit Package",
|
||||
create_title: "Create Package",
|
||||
cancel: "Cancel",
|
||||
save: "Save Changes",
|
||||
create: "Create",
|
||||
save_success: "Package updated successfully!",
|
||||
delete_title: "Delete Package",
|
||||
delete_confirm: "Are you sure you want to delete \"{name}\"?",
|
||||
delete_btn: "Delete",
|
||||
allowances: "Allowances",
|
||||
max_vehicles: "Max Vehicles",
|
||||
max_garages: "Max Garages",
|
||||
max_users: "Max Users",
|
||||
monthly_credits: "Monthly Credits",
|
||||
tier_level: "Level",
|
||||
form_name: "System Name",
|
||||
form_name_placeholder: "e.g. premium_2024",
|
||||
form_display_name: "Display Name",
|
||||
form_display_name_placeholder: "e.g. Premium Plus",
|
||||
form_type: "Type",
|
||||
type_private: "Private",
|
||||
type_corporate: "Corporate",
|
||||
form_tier_level: "Tier Level",
|
||||
form_tier_level_hint: "Higher number = more features and higher rank",
|
||||
form_monthly_price: "Monthly Price",
|
||||
form_yearly_price: "Yearly Price",
|
||||
form_currency: "Currency",
|
||||
form_subtitle: "Subtitle / Description",
|
||||
form_subtitle_placeholder: "e.g. Ideal for small businesses",
|
||||
form_badge: "Badge Text",
|
||||
form_badge_placeholder: "e.g. Most Popular",
|
||||
form_is_custom: "Custom Package (not public)",
|
||||
singleton_rules: "Singleton Rules",
|
||||
form_is_default_fallback: "Default Fallback Package — expired subscriptions fall back to this",
|
||||
form_trial_days: "Trial Days on Signup",
|
||||
form_trial_days_hint: "New organizations get this many free days. Only one package can have > 0.",
|
||||
per_region_pricing: "Per-Region Pricing",
|
||||
smart_calculator: "Smart Price Calculator",
|
||||
calc_net_price: "Net Price",
|
||||
calc_vat_rate: "VAT Rate",
|
||||
calc_currency: "Currency",
|
||||
calc_net: "Net",
|
||||
calc_vat: "VAT",
|
||||
calc_gross: "Gross",
|
||||
calc_apply: "Apply to selected region",
|
||||
add_zone: "Add New Zone",
|
||||
add_zone_select: "Select country code...",
|
||||
add_zone_already_exists: "This region already exists in the package pricing!",
|
||||
feature_capabilities: "Feature Capabilities (JSON)",
|
||||
feature_capabilities_hint: "Enter feature capabilities in JSON format. E.g.: ai_analysis: true, priority_support: false",
|
||||
singleton_rules_title: "Singleton Rules",
|
||||
form_trial_days_label: "Trial Days",
|
||||
per_region_pricing_title: "Per-Region Pricing",
|
||||
smart_calculator_title: "Smart Price Calculator"
|
||||
};
|
||||
const locale_system_46json_cff5800c = {
|
||||
system: system
|
||||
system: system,
|
||||
packages: packages
|
||||
};
|
||||
|
||||
var commission_rules = {
|
||||
@@ -5352,7 +5197,16 @@ var commission_rules = {
|
||||
upline_commission_percent_helper: "Percentage paid to the upline (Gen2 — the referrer's referrer) on each purchase.",
|
||||
renewal_commission_percent: "Renewal Commission %",
|
||||
renewal_commission_percent_helper: "Percentage paid on monthly/yearly subscription renewals.",
|
||||
commission_max_amount: "Max Amount",
|
||||
commission_max_amount: "Max Amount (global)",
|
||||
gen1_max_amount: "Gen1 Max Amount",
|
||||
gen1_max_amount_helper: "Gen1 (direct referrer) maximum commission amount. NULL/0 = unlimited, fallback: Max Amount.",
|
||||
gen2_max_amount: "Gen2 Max Amount",
|
||||
gen2_max_amount_helper: "Gen2 (upline) maximum commission amount. NULL/0 = unlimited, fallback: Max Amount.",
|
||||
gen1_title: "Direct Referrer (Gen1)",
|
||||
gen2_title: "Upline (Gen2)",
|
||||
legacy_fallback: "legacy fallback",
|
||||
gen2_renewal_percent: "Gen2 Renewal %",
|
||||
gen2_renewal_percent_helper: "Gen2 renewal commission percentage (e.g. 1.00 = 1%). Fallback: Upline Commission %.",
|
||||
deactivate_confirm: "Are you sure you want to deactivate \"{name}\"?",
|
||||
showing: "Showing",
|
||||
xp_reward_helper: "Fixed reward given to the user for successful individual referrals (e.g., successful registration via invite code).",
|
||||
@@ -5395,7 +5249,11 @@ var commission_rules = {
|
||||
tier_vip: "VIP",
|
||||
tier_platinum: "Platinum",
|
||||
tier_enterprise: "Enterprise",
|
||||
tier_contracted: "Contracted Agent"
|
||||
tier_contracted: "Contracted Agent",
|
||||
conflict_title: "Rule Conflict Detected",
|
||||
conflict_message: "An active rule already exists for this target group and region. Do you want to deactivate the old rule and activate the new one?",
|
||||
conflict_confirm: "Confirm Override",
|
||||
conflict_cancel: "Cancel"
|
||||
};
|
||||
const locale_commission_46json_ea60861a = {
|
||||
commission_rules: commission_rules
|
||||
@@ -6657,16 +6515,16 @@ _wH6JrtIxmaSoA8lCPWFnE9z4lQeXW6H5z3l5aymEQw
|
||||
const assets = {
|
||||
"/index.mjs": {
|
||||
"type": "text/javascript; charset=utf-8",
|
||||
"etag": "\"3e482-bMW8N/gd5+rJ6Fet5d7ZvFyoG5I\"",
|
||||
"mtime": "2026-07-24T01:36:18.316Z",
|
||||
"size": 255106,
|
||||
"etag": "\"3db35-gliVpBtK5107QX2IT8SR1tGeiAs\"",
|
||||
"mtime": "2026-07-24T12:30:16.660Z",
|
||||
"size": 252725,
|
||||
"path": "index.mjs"
|
||||
},
|
||||
"/index.mjs.map": {
|
||||
"type": "application/json",
|
||||
"etag": "\"84cfb-v0HL6pG50A9Shz5WFud0S34p4No\"",
|
||||
"mtime": "2026-07-24T01:36:18.317Z",
|
||||
"size": 543995,
|
||||
"etag": "\"84c6f-gMC45UcEAiPwtJNbdMr3pGk5iUE\"",
|
||||
"mtime": "2026-07-24T12:30:16.660Z",
|
||||
"size": 543855,
|
||||
"path": "index.mjs.map"
|
||||
}
|
||||
};
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"date": "2026-07-24T01:36:24.822Z",
|
||||
"date": "2026-07-24T12:30:18.036Z",
|
||||
"preset": "nitro-dev",
|
||||
"framework": {
|
||||
"name": "nuxt",
|
||||
@@ -11,7 +11,7 @@
|
||||
"dev": {
|
||||
"pid": 19,
|
||||
"workerAddress": {
|
||||
"socketPath": "\u0000nitro-worker-19-14-14-1789.sock"
|
||||
"socketPath": "\u0000nitro-worker-19-30-30-3992.sock"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// generated by the @nuxtjs/tailwindcss <https://github.com/nuxt-modules/tailwindcss> module at 7/24/2026, 1:36:24 AM
|
||||
// generated by the @nuxtjs/tailwindcss <https://github.com/nuxt-modules/tailwindcss> module at 7/24/2026, 12:30:17 PM
|
||||
import "@nuxtjs/tailwindcss/config-ctx"
|
||||
import configMerger from "@nuxtjs/tailwindcss/merger";
|
||||
|
||||
|
||||
@@ -31,7 +31,16 @@
|
||||
"upline_commission_percent_helper": "Percentage paid to the upline (Gen2 — the referrer's referrer) on each purchase.",
|
||||
"renewal_commission_percent": "Renewal Commission %",
|
||||
"renewal_commission_percent_helper": "Percentage paid on monthly/yearly subscription renewals.",
|
||||
"commission_max_amount": "Max Amount",
|
||||
"commission_max_amount": "Max Amount (global)",
|
||||
"gen1_max_amount": "Gen1 Max Amount",
|
||||
"gen1_max_amount_helper": "Gen1 (direct referrer) maximum commission amount. NULL/0 = unlimited, fallback: Max Amount.",
|
||||
"gen2_max_amount": "Gen2 Max Amount",
|
||||
"gen2_max_amount_helper": "Gen2 (upline) maximum commission amount. NULL/0 = unlimited, fallback: Max Amount.",
|
||||
"gen1_title": "Direct Referrer (Gen1)",
|
||||
"gen2_title": "Upline (Gen2)",
|
||||
"legacy_fallback": "legacy fallback",
|
||||
"gen2_renewal_percent": "Gen2 Renewal %",
|
||||
"gen2_renewal_percent_helper": "Gen2 renewal commission percentage (e.g. 1.00 = 1%). Fallback: Upline Commission %.",
|
||||
"deactivate_confirm": "Are you sure you want to deactivate \"{name}\"?",
|
||||
"showing": "Showing",
|
||||
"xp_reward_helper": "Fixed reward given to the user for successful individual referrals (e.g., successful registration via invite code).",
|
||||
@@ -74,6 +83,10 @@
|
||||
"tier_vip": "VIP",
|
||||
"tier_platinum": "Platinum",
|
||||
"tier_enterprise": "Enterprise",
|
||||
"tier_contracted": "Contracted Agent"
|
||||
"tier_contracted": "Contracted Agent",
|
||||
"conflict_title": "Rule Conflict Detected",
|
||||
"conflict_message": "An active rule already exists for this target group and region. Do you want to deactivate the old rule and activate the new one?",
|
||||
"conflict_confirm": "Confirm Override",
|
||||
"conflict_cancel": "Cancel"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,98 +1,100 @@
|
||||
{
|
||||
"loading": "Loading...",
|
||||
"saving": "Saving...",
|
||||
"error": "An error occurred",
|
||||
"retry": "Retry",
|
||||
"save": "Save",
|
||||
"saving_data": "Saving data...",
|
||||
"cancel": "Cancel",
|
||||
"discard": "Discard",
|
||||
"delete": "Delete",
|
||||
"delete_confirm": "Are you sure you want to delete this item?",
|
||||
"delete_btn": "Delete",
|
||||
"delete_title": "Confirm Deletion",
|
||||
"deleted": "Deleted successfully",
|
||||
"edit": "Edit",
|
||||
"edit_title": "Edit Item",
|
||||
"create": "Create",
|
||||
"create_btn": "Create",
|
||||
"create_title": "Create New",
|
||||
"create_first": "Create your first item to get started",
|
||||
"search": "Search",
|
||||
"search_placeholder": "Search...",
|
||||
"filter": "Filter",
|
||||
"no_results": "No results found",
|
||||
"no_results_hint": "Try adjusting your search or filter criteria",
|
||||
"no_items": "No items available",
|
||||
"actions": "Actions",
|
||||
"status": "Status",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"activate": "Activate",
|
||||
"deactivate": "Deactivate",
|
||||
"name": "Name",
|
||||
"name_placeholder": "Enter name",
|
||||
"description": "Description",
|
||||
"desc_placeholder": "Enter description",
|
||||
"details": "Details",
|
||||
"email": "Email",
|
||||
"id": "ID",
|
||||
"type": "Type",
|
||||
"type_corporate": "Corporate",
|
||||
"view": "View",
|
||||
"updated": "Updated successfully",
|
||||
"save_error": "Failed to save",
|
||||
"invalid_json": "Invalid JSON format",
|
||||
"prev": "Previous",
|
||||
"next": "Next",
|
||||
"none": "None",
|
||||
"reset": "Reset",
|
||||
"confirm": "Confirm",
|
||||
"close": "Close",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"exit": "Exit",
|
||||
"all": "All",
|
||||
"select_all": "Select All",
|
||||
"deselect_all": "Deselect All",
|
||||
"expires": "Expires",
|
||||
"created": "Created",
|
||||
"start_date": "Start Date",
|
||||
"end_date": "End Date",
|
||||
"points": "Points",
|
||||
"level": "Level",
|
||||
"user": "User",
|
||||
"user_id": "User ID",
|
||||
"services": "Services",
|
||||
"discoveries": "Discoveries",
|
||||
"restriction": "Restriction",
|
||||
"restriction_mild": "Mild",
|
||||
"restriction_moderate": "Moderate",
|
||||
"restriction_severe": "Severe",
|
||||
"min_xp": "Min XP",
|
||||
"xp": "XP",
|
||||
"penalty": "Penalty",
|
||||
"feature_flags": "Feature Flags",
|
||||
"permission": "Permission",
|
||||
"role": "Role",
|
||||
"domain": "Domain",
|
||||
"action": "Action",
|
||||
"value": "Value",
|
||||
"key": "Key",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"service_finder": "Service Finder",
|
||||
"admin": "Admin",
|
||||
"version": "Version",
|
||||
"super_administrator": "Super Administrator",
|
||||
"administrator": "Administrator",
|
||||
"profile_settings": "Profile Settings",
|
||||
"sign_out": "Sign Out",
|
||||
"unknown_user": "Unknown user",
|
||||
"ascending": "Ascending",
|
||||
"descending": "Descending"
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"saving": "Saving...",
|
||||
"error": "An error occurred",
|
||||
"retry": "Retry",
|
||||
"save": "Save",
|
||||
"saving_data": "Saving data...",
|
||||
"cancel": "Cancel",
|
||||
"discard": "Discard",
|
||||
"delete": "Delete",
|
||||
"delete_confirm": "Are you sure you want to delete this item?",
|
||||
"delete_btn": "Delete",
|
||||
"delete_title": "Confirm Deletion",
|
||||
"deleted": "Deleted successfully",
|
||||
"edit": "Edit",
|
||||
"edit_title": "Edit Item",
|
||||
"create": "Create",
|
||||
"create_btn": "Create",
|
||||
"create_title": "Create New",
|
||||
"create_first": "Create your first item to get started",
|
||||
"search": "Search",
|
||||
"search_placeholder": "Search...",
|
||||
"filter": "Filter",
|
||||
"no_results": "No results found",
|
||||
"no_results_hint": "Try adjusting your search or filter criteria",
|
||||
"no_items": "No items available",
|
||||
"actions": "Actions",
|
||||
"status": "Status",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"activate": "Activate",
|
||||
"deactivate": "Deactivate",
|
||||
"name": "Name",
|
||||
"name_placeholder": "Enter name",
|
||||
"description": "Description",
|
||||
"desc_placeholder": "Enter description",
|
||||
"details": "Details",
|
||||
"email": "Email",
|
||||
"id": "ID",
|
||||
"type": "Type",
|
||||
"type_corporate": "Corporate",
|
||||
"view": "View",
|
||||
"updated": "Updated successfully",
|
||||
"save_error": "Failed to save",
|
||||
"invalid_json": "Invalid JSON format",
|
||||
"prev": "Previous",
|
||||
"next": "Next",
|
||||
"none": "None",
|
||||
"reset": "Reset",
|
||||
"confirm": "Confirm",
|
||||
"close": "Close",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"exit": "Exit",
|
||||
"all": "All",
|
||||
"select_all": "Select All",
|
||||
"deselect_all": "Deselect All",
|
||||
"expires": "Expires",
|
||||
"created": "Created",
|
||||
"start_date": "Start Date",
|
||||
"end_date": "End Date",
|
||||
"points": "Points",
|
||||
"level": "Level",
|
||||
"user": "User",
|
||||
"user_id": "User ID",
|
||||
"services": "Services",
|
||||
"discoveries": "Discoveries",
|
||||
"restriction": "Restriction",
|
||||
"restriction_mild": "Mild",
|
||||
"restriction_moderate": "Moderate",
|
||||
"restriction_severe": "Severe",
|
||||
"min_xp": "Min XP",
|
||||
"xp": "XP",
|
||||
"penalty": "Penalty",
|
||||
"feature_flags": "Feature Flags",
|
||||
"permission": "Permission",
|
||||
"role": "Role",
|
||||
"domain": "Domain",
|
||||
"action": "Action",
|
||||
"value": "Value",
|
||||
"key": "Key",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"service_finder": "Service Finder",
|
||||
"admin": "Admin",
|
||||
"version": "Version",
|
||||
"super_administrator": "Super Administrator",
|
||||
"administrator": "Administrator",
|
||||
"profile_settings": "Profile Settings",
|
||||
"sign_out": "Sign Out",
|
||||
"unknown_user": "Unknown user",
|
||||
"ascending": "Ascending",
|
||||
"descending": "Descending"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,74 +81,6 @@
|
||||
"no_csv_data": "No data for CSV export.",
|
||||
"csv_downloaded": "CSV download started."
|
||||
},
|
||||
"packages": {
|
||||
"title": "Package Management",
|
||||
"subtitle": "Manage subscription packages and features — Free, Premium, Enterprise",
|
||||
"loading": "Loading packages...",
|
||||
"retry": "Retry",
|
||||
"create_new": "New Package",
|
||||
"no_packages": "No packages yet",
|
||||
"no_packages_desc": "Create your first subscription package to get started.",
|
||||
"create_first": "Create First Package",
|
||||
"popular": "Popular",
|
||||
"fallback_badge": "Fallback",
|
||||
"trial_badge": "Trial",
|
||||
"month": "mo",
|
||||
"feature_flags": "Available Features",
|
||||
"edit": "Edit",
|
||||
"duplicate": "Duplicate",
|
||||
"delete": "Delete",
|
||||
"set_as_default": "Set as Default",
|
||||
"set_as_default_success": "set as default package!",
|
||||
"edit_title": "Edit Package",
|
||||
"create_title": "Create Package",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save Changes",
|
||||
"create": "Create",
|
||||
"save_success": "Package updated successfully!",
|
||||
"delete_title": "Delete Package",
|
||||
"delete_confirm": "Are you sure you want to delete \"{name}\"?",
|
||||
"delete_btn": "Delete",
|
||||
"allowances": "Allowances",
|
||||
"max_vehicles": "Max Vehicles",
|
||||
"max_garages": "Max Garages",
|
||||
"max_users": "Max Users",
|
||||
"monthly_credits": "Monthly Credits",
|
||||
"tier_level": "Level",
|
||||
"form_name": "System Name",
|
||||
"form_name_placeholder": "e.g. premium_2024",
|
||||
"form_display_name": "Display Name",
|
||||
"form_display_name_placeholder": "e.g. Premium Plus",
|
||||
"form_type": "Type",
|
||||
"type_private": "Private",
|
||||
"type_corporate": "Corporate",
|
||||
"form_tier_level": "Tier Level",
|
||||
"form_tier_level_hint": "Higher number = more features and higher rank",
|
||||
"form_monthly_price": "Monthly Price",
|
||||
"form_yearly_price": "Yearly Price",
|
||||
"form_currency": "Currency",
|
||||
"form_subtitle": "Subtitle / Description",
|
||||
"form_subtitle_placeholder": "e.g. Ideal for small businesses",
|
||||
"form_badge": "Badge Text",
|
||||
"form_badge_placeholder": "e.g. Most Popular",
|
||||
"form_is_custom": "Custom Package (not public)",
|
||||
"singleton_rules": "Singleton Rules",
|
||||
"form_is_default_fallback": "Default Fallback Package — expired subscriptions fall back to this",
|
||||
"form_trial_days": "Trial Days on Signup",
|
||||
"form_trial_days_hint": "New organizations get this many free days. Only one package can have > 0.",
|
||||
"per_region_pricing": "Per-Region Pricing",
|
||||
"smart_calculator": "Smart Price Calculator",
|
||||
"calc_net_price": "Net Price",
|
||||
"calc_vat_rate": "VAT Rate",
|
||||
"calc_currency": "Currency",
|
||||
"calc_net": "Net",
|
||||
"calc_vat": "VAT",
|
||||
"calc_gross": "Gross",
|
||||
"calc_apply": "Apply to selected region",
|
||||
"add_zone": "Add New Zone",
|
||||
"add_zone_select": "Select country code...",
|
||||
"add_zone_already_exists": "This region already exists in the package pricing!"
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Permissions Management",
|
||||
"subtitle": "Permission Matrix — Role-based access control",
|
||||
@@ -177,5 +109,79 @@
|
||||
"rbac_desc": "Role-based access control — which roles can perform which admin actions.",
|
||||
"action": "Action"
|
||||
}
|
||||
},
|
||||
"packages": {
|
||||
"title": "Package Management",
|
||||
"subtitle": "Manage subscription packages and features — Free, Premium, Enterprise",
|
||||
"loading": "Loading packages...",
|
||||
"retry": "Retry",
|
||||
"create_new": "New Package",
|
||||
"no_packages": "No packages yet",
|
||||
"no_packages_desc": "Create your first subscription package to get started.",
|
||||
"create_first": "Create First Package",
|
||||
"popular": "Popular",
|
||||
"fallback_badge": "Fallback",
|
||||
"trial_badge": "Trial",
|
||||
"month": "mo",
|
||||
"feature_flags": "Available Features",
|
||||
"edit": "Edit",
|
||||
"duplicate": "Duplicate",
|
||||
"delete": "Delete",
|
||||
"set_as_default": "Set as Default",
|
||||
"set_as_default_success": "set as default package!",
|
||||
"edit_title": "Edit Package",
|
||||
"create_title": "Create Package",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save Changes",
|
||||
"create": "Create",
|
||||
"save_success": "Package updated successfully!",
|
||||
"delete_title": "Delete Package",
|
||||
"delete_confirm": "Are you sure you want to delete \"{name}\"?",
|
||||
"delete_btn": "Delete",
|
||||
"allowances": "Allowances",
|
||||
"max_vehicles": "Max Vehicles",
|
||||
"max_garages": "Max Garages",
|
||||
"max_users": "Max Users",
|
||||
"monthly_credits": "Monthly Credits",
|
||||
"tier_level": "Level",
|
||||
"form_name": "System Name",
|
||||
"form_name_placeholder": "e.g. premium_2024",
|
||||
"form_display_name": "Display Name",
|
||||
"form_display_name_placeholder": "e.g. Premium Plus",
|
||||
"form_type": "Type",
|
||||
"type_private": "Private",
|
||||
"type_corporate": "Corporate",
|
||||
"form_tier_level": "Tier Level",
|
||||
"form_tier_level_hint": "Higher number = more features and higher rank",
|
||||
"form_monthly_price": "Monthly Price",
|
||||
"form_yearly_price": "Yearly Price",
|
||||
"form_currency": "Currency",
|
||||
"form_subtitle": "Subtitle / Description",
|
||||
"form_subtitle_placeholder": "e.g. Ideal for small businesses",
|
||||
"form_badge": "Badge Text",
|
||||
"form_badge_placeholder": "e.g. Most Popular",
|
||||
"form_is_custom": "Custom Package (not public)",
|
||||
"singleton_rules": "Singleton Rules",
|
||||
"form_is_default_fallback": "Default Fallback Package — expired subscriptions fall back to this",
|
||||
"form_trial_days": "Trial Days on Signup",
|
||||
"form_trial_days_hint": "New organizations get this many free days. Only one package can have > 0.",
|
||||
"per_region_pricing": "Per-Region Pricing",
|
||||
"smart_calculator": "Smart Price Calculator",
|
||||
"calc_net_price": "Net Price",
|
||||
"calc_vat_rate": "VAT Rate",
|
||||
"calc_currency": "Currency",
|
||||
"calc_net": "Net",
|
||||
"calc_vat": "VAT",
|
||||
"calc_gross": "Gross",
|
||||
"calc_apply": "Apply to selected region",
|
||||
"add_zone": "Add New Zone",
|
||||
"add_zone_select": "Select country code...",
|
||||
"add_zone_already_exists": "This region already exists in the package pricing!",
|
||||
"feature_capabilities": "Feature Capabilities (JSON)",
|
||||
"feature_capabilities_hint": "Enter feature capabilities in JSON format. E.g.: ai_analysis: true, priority_support: false",
|
||||
"singleton_rules_title": "Singleton Rules",
|
||||
"form_trial_days_label": "Trial Days",
|
||||
"per_region_pricing_title": "Per-Region Pricing",
|
||||
"smart_calculator_title": "Smart Price Calculator"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,16 @@
|
||||
"upline_commission_percent_helper": "A felső szintű ajánlónak (Gen2 — az ajánló ajánlója) járó jutalék százaléka minden vásárlás után.",
|
||||
"renewal_commission_percent": "Megújítási Jutalék %",
|
||||
"renewal_commission_percent_helper": "A havi/éves előfizetés megújításakor járó jutalék százaléka.",
|
||||
"commission_max_amount": "Max Összeg",
|
||||
"commission_max_amount": "Max Összeg (általános)",
|
||||
"gen1_max_amount": "Gen1 Max Összeg",
|
||||
"gen1_max_amount_helper": "Gen1 (közvetlen ajánló) maximális jutalék összege. NULL/0 = korlátlan, fallback: Max Összeg.",
|
||||
"gen2_max_amount": "Gen2 Max Összeg",
|
||||
"gen2_max_amount_helper": "Gen2 (felső szintű ajánló) maximális jutalék összege. NULL/0 = korlátlan, fallback: Max Összeg.",
|
||||
"gen1_title": "Közvetlen Ajánló (Gen1)",
|
||||
"gen2_title": "Felsővonal (Gen2)",
|
||||
"legacy_fallback": "örökölt mező",
|
||||
"gen2_renewal_percent": "Gen2 Megújítási Jutalék %",
|
||||
"gen2_renewal_percent_helper": "Gen2-nek járó megújítási jutalék százaléka (pl. 1.00 = 1%). Fallback: Felső szintű Jutalék %.",
|
||||
"deactivate_confirm": "Biztosan deaktiválod a következőt: \"{name}\"?",
|
||||
"showing": "Mutatva",
|
||||
"xp_reward_helper": "Egyéni meghívások után járó fix jutalom (pl. sikeres regisztráció).",
|
||||
@@ -74,6 +83,10 @@
|
||||
"tier_vip": "VIP",
|
||||
"tier_platinum": "Platinum",
|
||||
"tier_enterprise": "Enterprise",
|
||||
"tier_contracted": "Szerződött Üzletkötő"
|
||||
"tier_contracted": "Szerződött Üzletkötő",
|
||||
"conflict_title": "Szabályütközés észlelve",
|
||||
"conflict_message": "Már létezik egy aktív szabály erre a célcsoportra és régióra. Szeretnéd inaktiválni a régit, és életbe léptetni az újat?",
|
||||
"conflict_confirm": "Felülírás jóváhagyása",
|
||||
"conflict_cancel": "Mégsem"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,98 +1,100 @@
|
||||
{
|
||||
"loading": "Betöltés...",
|
||||
"saving": "Mentés...",
|
||||
"error": "Hiba történt",
|
||||
"retry": "Újra",
|
||||
"save": "Mentés",
|
||||
"saving_data": "Adatok mentése...",
|
||||
"cancel": "Mégsem",
|
||||
"discard": "Elvetés",
|
||||
"delete": "Törlés",
|
||||
"delete_confirm": "Biztosan törölni szeretnéd ezt az elemet?",
|
||||
"delete_btn": "Törlés",
|
||||
"delete_title": "Törlés Megerősítése",
|
||||
"deleted": "Sikeresen törölve",
|
||||
"edit": "Szerkesztés",
|
||||
"edit_title": "Elem Szerkesztése",
|
||||
"create": "Létrehozás",
|
||||
"create_btn": "Létrehozás",
|
||||
"create_title": "Új Létrehozása",
|
||||
"create_first": "Hozd létre az első elemet a kezdéshez",
|
||||
"search": "Keresés",
|
||||
"search_placeholder": "Keresés...",
|
||||
"filter": "Szűrés",
|
||||
"no_results": "Nincs találat",
|
||||
"no_results_hint": "Próbáld módosítani a keresési vagy szűrési feltételeket",
|
||||
"no_items": "Nincsenek elemek",
|
||||
"actions": "Műveletek",
|
||||
"status": "Státusz",
|
||||
"active": "Aktív",
|
||||
"inactive": "Inaktív",
|
||||
"activate": "Aktiválás",
|
||||
"deactivate": "Deaktiválás",
|
||||
"name": "Név",
|
||||
"name_placeholder": "Add meg a nevet",
|
||||
"description": "Leírás",
|
||||
"desc_placeholder": "Add meg a leírást",
|
||||
"details": "Részletek",
|
||||
"email": "E-mail",
|
||||
"id": "Azonosító",
|
||||
"type": "Típus",
|
||||
"type_corporate": "Vállalati",
|
||||
"view": "Megtekintés",
|
||||
"updated": "Sikeresen frissítve",
|
||||
"save_error": "Sikertelen mentés",
|
||||
"invalid_json": "Érvénytelen JSON formátum",
|
||||
"prev": "Előző",
|
||||
"next": "Következő",
|
||||
"none": "Nincs",
|
||||
"reset": "Visszaállítás",
|
||||
"confirm": "Megerősítés",
|
||||
"close": "Bezárás",
|
||||
"back": "Vissza",
|
||||
"continue": "Tovább",
|
||||
"exit": "Kilépés",
|
||||
"all": "Minden",
|
||||
"select_all": "Összes kiválasztása",
|
||||
"deselect_all": "Összes elrejtése",
|
||||
"expires": "Lejárat",
|
||||
"created": "Létrehozva",
|
||||
"start_date": "Kezdő Dátum",
|
||||
"end_date": "Befejező Dátum",
|
||||
"points": "Pontok",
|
||||
"level": "Szint",
|
||||
"user": "Felhasználó",
|
||||
"user_id": "Felhasználó Azonosító",
|
||||
"services": "Szolgáltatások",
|
||||
"discoveries": "Felfedezések",
|
||||
"restriction": "Korlátozás",
|
||||
"restriction_mild": "Enyhe",
|
||||
"restriction_moderate": "Mérsékelt",
|
||||
"restriction_severe": "Súlyos",
|
||||
"min_xp": "Min XP",
|
||||
"xp": "XP",
|
||||
"penalty": "Büntetés",
|
||||
"feature_flags": "Funkció Kapcsolók",
|
||||
"permission": "Engedély",
|
||||
"role": "Szerepkör",
|
||||
"domain": "Tartomány",
|
||||
"action": "Művelet",
|
||||
"value": "Érték",
|
||||
"key": "Kulcs",
|
||||
"enabled": "Bekapcsolva",
|
||||
"disabled": "Kikapcsolva",
|
||||
"yes": "Igen",
|
||||
"no": "Nem",
|
||||
"on": "Be",
|
||||
"off": "Ki",
|
||||
"service_finder": "Service Finder",
|
||||
"admin": "Admin",
|
||||
"version": "Verzió",
|
||||
"super_administrator": "Szuper Adminisztrátor",
|
||||
"administrator": "Adminisztrátor",
|
||||
"profile_settings": "Profil Beállítások",
|
||||
"sign_out": "Kijelentkezés",
|
||||
"unknown_user": "Ismeretlen felhasználó",
|
||||
"ascending": "Növekvő",
|
||||
"descending": "Csökkenő"
|
||||
"common": {
|
||||
"loading": "Betöltés...",
|
||||
"saving": "Mentés...",
|
||||
"error": "Hiba történt",
|
||||
"retry": "Újra",
|
||||
"save": "Mentés",
|
||||
"saving_data": "Adatok mentése...",
|
||||
"cancel": "Mégsem",
|
||||
"discard": "Elvetés",
|
||||
"delete": "Törlés",
|
||||
"delete_confirm": "Biztosan törölni szeretnéd ezt az elemet?",
|
||||
"delete_btn": "Törlés",
|
||||
"delete_title": "Törlés Megerősítése",
|
||||
"deleted": "Sikeresen törölve",
|
||||
"edit": "Szerkesztés",
|
||||
"edit_title": "Elem Szerkesztése",
|
||||
"create": "Létrehozás",
|
||||
"create_btn": "Létrehozás",
|
||||
"create_title": "Új Létrehozása",
|
||||
"create_first": "Hozd létre az első elemet a kezdéshez",
|
||||
"search": "Keresés",
|
||||
"search_placeholder": "Keresés...",
|
||||
"filter": "Szűrés",
|
||||
"no_results": "Nincs találat",
|
||||
"no_results_hint": "Próbáld módosítani a keresési vagy szűrési feltételeket",
|
||||
"no_items": "Nincsenek elemek",
|
||||
"actions": "Műveletek",
|
||||
"status": "Státusz",
|
||||
"active": "Aktív",
|
||||
"inactive": "Inaktív",
|
||||
"activate": "Aktiválás",
|
||||
"deactivate": "Deaktiválás",
|
||||
"name": "Név",
|
||||
"name_placeholder": "Add meg a nevet",
|
||||
"description": "Leírás",
|
||||
"desc_placeholder": "Add meg a leírást",
|
||||
"details": "Részletek",
|
||||
"email": "E-mail",
|
||||
"id": "Azonosító",
|
||||
"type": "Típus",
|
||||
"type_corporate": "Vállalati",
|
||||
"view": "Megtekintés",
|
||||
"updated": "Sikeresen frissítve",
|
||||
"save_error": "Sikertelen mentés",
|
||||
"invalid_json": "Érvénytelen JSON formátum",
|
||||
"prev": "Előző",
|
||||
"next": "Következő",
|
||||
"none": "Nincs",
|
||||
"reset": "Visszaállítás",
|
||||
"confirm": "Megerősítés",
|
||||
"close": "Bezárás",
|
||||
"back": "Vissza",
|
||||
"continue": "Tovább",
|
||||
"exit": "Kilépés",
|
||||
"all": "Minden",
|
||||
"select_all": "Összes kiválasztása",
|
||||
"deselect_all": "Összes elrejtése",
|
||||
"expires": "Lejárat",
|
||||
"created": "Létrehozva",
|
||||
"start_date": "Kezdő Dátum",
|
||||
"end_date": "Befejező Dátum",
|
||||
"points": "Pontok",
|
||||
"level": "Szint",
|
||||
"user": "Felhasználó",
|
||||
"user_id": "Felhasználó Azonosító",
|
||||
"services": "Szolgáltatások",
|
||||
"discoveries": "Felfedezések",
|
||||
"restriction": "Korlátozás",
|
||||
"restriction_mild": "Enyhe",
|
||||
"restriction_moderate": "Mérsékelt",
|
||||
"restriction_severe": "Súlyos",
|
||||
"min_xp": "Min XP",
|
||||
"xp": "XP",
|
||||
"penalty": "Büntetés",
|
||||
"feature_flags": "Funkció Kapcsolók",
|
||||
"permission": "Engedély",
|
||||
"role": "Szerepkör",
|
||||
"domain": "Tartomány",
|
||||
"action": "Művelet",
|
||||
"value": "Érték",
|
||||
"key": "Kulcs",
|
||||
"enabled": "Bekapcsolva",
|
||||
"disabled": "Kikapcsolva",
|
||||
"yes": "Igen",
|
||||
"no": "Nem",
|
||||
"on": "Be",
|
||||
"off": "Ki",
|
||||
"service_finder": "Service Finder",
|
||||
"admin": "Admin",
|
||||
"version": "Verzió",
|
||||
"super_administrator": "Szuper Adminisztrátor",
|
||||
"administrator": "Adminisztrátor",
|
||||
"profile_settings": "Profil Beállítások",
|
||||
"sign_out": "Kijelentkezés",
|
||||
"unknown_user": "Ismeretlen felhasználó",
|
||||
"ascending": "Növekvő",
|
||||
"descending": "Csökkenő"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,74 +81,6 @@
|
||||
"no_csv_data": "Nincs adat a CSV exportáláshoz.",
|
||||
"csv_downloaded": "CSV letöltés elindítva."
|
||||
},
|
||||
"packages": {
|
||||
"title": "Csomagok Kezelése",
|
||||
"subtitle": "Előfizetési csomagok és funkciók kezelése — Free, Premium, Enterprise",
|
||||
"loading": "Csomagok betöltése...",
|
||||
"retry": "Újra",
|
||||
"create_new": "Új Csomag",
|
||||
"no_packages": "Még nincsenek csomagok",
|
||||
"no_packages_desc": "Hozd létre az első előfizetési csomagot a kezdéshez.",
|
||||
"create_first": "Első Csomag Létrehozása",
|
||||
"popular": "Népszerű",
|
||||
"fallback_badge": "Visszaesési",
|
||||
"trial_badge": "Próba",
|
||||
"month": "hó",
|
||||
"feature_flags": "Elérhető Funkciók",
|
||||
"edit": "Szerkesztés",
|
||||
"duplicate": "Másolás",
|
||||
"delete": "Törlés",
|
||||
"set_as_default": "Beállítás alapértelmezettként",
|
||||
"set_as_default_success": "alapértelmezett csomagként beállítva!",
|
||||
"edit_title": "Csomag Szerkesztése",
|
||||
"create_title": "Csomag Létrehozása",
|
||||
"cancel": "Mégse",
|
||||
"save": "Változtatások Mentése",
|
||||
"create": "Létrehozás",
|
||||
"save_success": "Csomag sikeresen frissítve!",
|
||||
"delete_title": "Csomag Törlése",
|
||||
"delete_confirm": "Biztosan törölni szeretnéd a \"{name}\" csomagot?",
|
||||
"delete_btn": "Törlés",
|
||||
"allowances": "Korlátok",
|
||||
"max_vehicles": "Max Jármű",
|
||||
"max_garages": "Max Garázs",
|
||||
"max_users": "Max Dolgozó",
|
||||
"monthly_credits": "Havi Kredit",
|
||||
"tier_level": "Szint",
|
||||
"form_name": "Rendszer Név",
|
||||
"form_name_placeholder": "pl. premium_2024",
|
||||
"form_display_name": "Megjelenítési Név",
|
||||
"form_display_name_placeholder": "pl. Premium Plus",
|
||||
"form_type": "Típus",
|
||||
"type_private": "Magán",
|
||||
"type_corporate": "Vállalati",
|
||||
"form_tier_level": "Szintszám",
|
||||
"form_tier_level_hint": "Magasabb szám = több funkció és magasabb rang",
|
||||
"form_monthly_price": "Havi Díj",
|
||||
"form_yearly_price": "Éves Díj",
|
||||
"form_currency": "Pénznem",
|
||||
"form_subtitle": "Felirat / Leírás",
|
||||
"form_subtitle_placeholder": "pl. Ideális kisvállalkozásoknak",
|
||||
"form_badge": "Jelvény Szöveg",
|
||||
"form_badge_placeholder": "pl. Legnépszerűbb",
|
||||
"form_is_custom": "Egyedi Csomag (nem publikus)",
|
||||
"singleton_rules": "Egyediségi Szabályok",
|
||||
"form_is_default_fallback": "Alapértelmezett Visszaesési Csomag — lejárt előfizetés esetén erre vált",
|
||||
"form_trial_days": "Próba Napok Regisztrációnál",
|
||||
"form_trial_days_hint": "Az új szervezetek ennyi ingyenes napot kapnak. Csak egy csomagnál lehet > 0.",
|
||||
"per_region_pricing": "Régiók szerinti árazás",
|
||||
"smart_calculator": "Okos Árkalkulátor",
|
||||
"calc_net_price": "Nettó ár",
|
||||
"calc_vat_rate": "ÁFA kulcs",
|
||||
"calc_currency": "Pénznem",
|
||||
"calc_net": "Nettó",
|
||||
"calc_vat": "ÁFA",
|
||||
"calc_gross": "Bruttó",
|
||||
"calc_apply": "Alkalmaz a kiválasztott régióra",
|
||||
"add_zone": "Új Zóna Hozzáadása",
|
||||
"add_zone_select": "Válassz országkódot...",
|
||||
"add_zone_already_exists": "Ez a régió már létezik a csomag árazásában!"
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Jogosultság Kezelés",
|
||||
"subtitle": "Jogosultsági Mátrix — Szerepkör alapú hozzáférés-vezérlés",
|
||||
@@ -177,5 +109,79 @@
|
||||
"rbac_desc": "Szerepkör alapú hozzáférés-vezérlés — mely szerepkörök mely admin műveleteket végezhetik.",
|
||||
"action": "Művelet"
|
||||
}
|
||||
},
|
||||
"packages": {
|
||||
"title": "Csomagok Kezelése",
|
||||
"subtitle": "Előfizetési csomagok és funkciók kezelése — Free, Premium, Enterprise",
|
||||
"loading": "Csomagok betöltése...",
|
||||
"retry": "Újra",
|
||||
"create_new": "Új Csomag",
|
||||
"no_packages": "Még nincsenek csomagok",
|
||||
"no_packages_desc": "Hozd létre az első előfizetési csomagot a kezdéshez.",
|
||||
"create_first": "Első Csomag Létrehozása",
|
||||
"popular": "Népszerű",
|
||||
"fallback_badge": "Visszaesési",
|
||||
"trial_badge": "Próba",
|
||||
"month": "hó",
|
||||
"feature_flags": "Elérhető Funkciók",
|
||||
"edit": "Szerkesztés",
|
||||
"duplicate": "Másolás",
|
||||
"delete": "Törlés",
|
||||
"set_as_default": "Beállítás alapértelmezettként",
|
||||
"set_as_default_success": "alapértelmezett csomagként beállítva!",
|
||||
"edit_title": "Csomag Szerkesztése",
|
||||
"create_title": "Csomag Létrehozása",
|
||||
"cancel": "Mégse",
|
||||
"save": "Változtatások Mentése",
|
||||
"create": "Létrehozás",
|
||||
"save_success": "Csomag sikeresen frissítve!",
|
||||
"delete_title": "Csomag Törlése",
|
||||
"delete_confirm": "Biztosan törölni szeretnéd a \"{name}\" csomagot?",
|
||||
"delete_btn": "Törlés",
|
||||
"allowances": "Korlátok",
|
||||
"max_vehicles": "Max Jármű",
|
||||
"max_garages": "Max Garázs",
|
||||
"max_users": "Max Dolgozó",
|
||||
"monthly_credits": "Havi Kredit",
|
||||
"tier_level": "Szint",
|
||||
"form_name": "Rendszer Név",
|
||||
"form_name_placeholder": "pl. premium_2024",
|
||||
"form_display_name": "Megjelenítési Név",
|
||||
"form_display_name_placeholder": "pl. Premium Plus",
|
||||
"form_type": "Típus",
|
||||
"type_private": "Magán",
|
||||
"type_corporate": "Vállalati",
|
||||
"form_tier_level": "Szintszám",
|
||||
"form_tier_level_hint": "Magasabb szám = több funkció és magasabb rang",
|
||||
"form_monthly_price": "Havi Díj",
|
||||
"form_yearly_price": "Éves Díj",
|
||||
"form_currency": "Pénznem",
|
||||
"form_subtitle": "Felirat / Leírás",
|
||||
"form_subtitle_placeholder": "pl. Ideális kisvállalkozásoknak",
|
||||
"form_badge": "Jelvény Szöveg",
|
||||
"form_badge_placeholder": "pl. Legnépszerűbb",
|
||||
"form_is_custom": "Egyedi Csomag (nem publikus)",
|
||||
"singleton_rules": "Egyediségi Szabályok",
|
||||
"form_is_default_fallback": "Alapértelmezett Visszaesési Csomag — lejárt előfizetés esetén erre vált",
|
||||
"form_trial_days": "Próba Napok Regisztrációnál",
|
||||
"form_trial_days_hint": "Az új szervezetek ennyi ingyenes napot kapnak. Csak egy csomagnál lehet > 0.",
|
||||
"per_region_pricing": "Régiók szerinti árazás",
|
||||
"smart_calculator": "Okos Árkalkulátor",
|
||||
"calc_net_price": "Nettó ár",
|
||||
"calc_vat_rate": "ÁFA kulcs",
|
||||
"calc_currency": "Pénznem",
|
||||
"calc_net": "Nettó",
|
||||
"calc_vat": "ÁFA",
|
||||
"calc_gross": "Bruttó",
|
||||
"calc_apply": "Alkalmaz a kiválasztott régióra",
|
||||
"add_zone": "Új Zóna Hozzáadása",
|
||||
"add_zone_select": "Válassz országkódot...",
|
||||
"add_zone_already_exists": "Ez a régió már létezik a csomag árazásában!",
|
||||
"feature_capabilities": "Funkció Képességek (JSON)",
|
||||
"feature_capabilities_hint": "JSON formátumban add meg a funkció képességeket. Pl.: ai_analysis: true, priority_support: false",
|
||||
"singleton_rules_title": "Egyediségi Szabályok",
|
||||
"form_trial_days_label": "Próba Napok",
|
||||
"per_region_pricing_title": "Régiók szerinti árazás",
|
||||
"smart_calculator_title": "Okos Árkalkulátor"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,70 +357,139 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Commission Section (L2) -->
|
||||
<!-- Commission Section (L2) - Százalékos Jutalékok -->
|
||||
<div
|
||||
class="bg-slate-700/30 rounded-lg p-4 space-y-4 transition-opacity duration-200"
|
||||
:class="form.rule_type === 'L1_REWARD' ? 'opacity-40 pointer-events-none' : ''"
|
||||
>
|
||||
<div class="text-sm font-medium text-slate-300 mb-2">{{ t('commission_rules.commission_section') }}</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Commission Percent (Gen1) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.commission_percent') }}</label>
|
||||
<div class="relative">
|
||||
|
||||
<!-- Two-Column Layout: Gen1 (Left) / Gen2 (Right) -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<!-- Left Column: Gen1 - Közvetlen Ajánló -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-sm font-semibold text-indigo-300 mb-4 border-b border-slate-600/50 pb-2">{{ t('commission_rules.gen1_title') }}</h4>
|
||||
|
||||
<!-- Commission Percent (Gen1) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.commission_percent') }}</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model.number="form.commission_percent"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
:disabled="form.rule_type === 'L1_REWARD'"
|
||||
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm pr-8 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm">%</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-slate-400">{{ t('commission_rules.commission_percent_helper') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Renewal Commission Percent -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.renewal_commission_percent') }}</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model.number="form.renewal_commission_percent"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
:disabled="form.rule_type === 'L1_REWARD'"
|
||||
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm pr-8 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm">%</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-slate-400">{{ t('commission_rules.renewal_commission_percent_helper') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Gen1 Max Amount (Phase 2) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.gen1_max_amount') }}</label>
|
||||
<input
|
||||
v-model.number="form.commission_percent"
|
||||
v-model.number="form.gen1_max_amount"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
:disabled="form.rule_type === 'L1_REWARD'"
|
||||
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm pr-8 disabled:cursor-not-allowed"
|
||||
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm">%</span>
|
||||
<p class="mt-1 text-xs text-slate-400">{{ t('commission_rules.gen1_max_amount_helper') }}</p>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-slate-400">{{ t('commission_rules.commission_percent_helper') }}</p>
|
||||
</div>
|
||||
<!-- Upline Commission Percent (Gen2) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.upline_commission_percent') }}</label>
|
||||
<div class="relative">
|
||||
|
||||
<!-- Right Column: Gen2 - Felsővonal -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-sm font-semibold text-emerald-300 mb-4 border-b border-slate-600/50 pb-2">{{ t('commission_rules.gen2_title') }}</h4>
|
||||
|
||||
<!-- Upline Commission Percent (Gen2) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.upline_commission_percent') }}</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model.number="form.upline_commission_percent"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
:disabled="form.rule_type === 'L1_REWARD'"
|
||||
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm pr-8 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm">%</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-slate-400">{{ t('commission_rules.upline_commission_percent_helper') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Gen2 Renewal Percent (Phase 2) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.gen2_renewal_percent') }}</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model.number="form.gen2_renewal_percent"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
:disabled="form.rule_type === 'L1_REWARD'"
|
||||
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm pr-8 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm">%</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-slate-400">{{ t('commission_rules.gen2_renewal_percent_helper') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Gen2 Max Amount (Phase 2) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.gen2_max_amount') }}</label>
|
||||
<input
|
||||
v-model.number="form.upline_commission_percent"
|
||||
v-model.number="form.gen2_max_amount"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
:disabled="form.rule_type === 'L1_REWARD'"
|
||||
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm pr-8 disabled:cursor-not-allowed"
|
||||
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm">%</span>
|
||||
<p class="mt-1 text-xs text-slate-400">{{ t('commission_rules.gen2_max_amount_helper') }}</p>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-slate-400">{{ t('commission_rules.upline_commission_percent_helper') }}</p>
|
||||
</div>
|
||||
<!-- Renewal Commission Percent -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.renewal_commission_percent') }}</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model.number="form.renewal_commission_percent"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
:disabled="form.rule_type === 'L1_REWARD'"
|
||||
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm pr-8 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm">%</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-slate-400">{{ t('commission_rules.renewal_commission_percent_helper') }}</p>
|
||||
</div>
|
||||
<!-- Commission Max Amount -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">{{ t('commission_rules.commission_max_amount') }}</label>
|
||||
</div>
|
||||
|
||||
<!-- Legacy Field: Commission Max Amount (Fallback) - full width below columns -->
|
||||
<div class="border-t border-slate-600/30 pt-4 mt-2">
|
||||
<div class="max-w-xs">
|
||||
<label class="block text-sm font-medium text-slate-400 mb-1">
|
||||
{{ t('commission_rules.commission_max_amount') }}
|
||||
<span class="text-xs text-slate-500 ml-1 italic">({{ t('commission_rules.legacy_fallback') }})</span>
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.commission_max_amount"
|
||||
type="number"
|
||||
@@ -428,9 +497,9 @@
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
:disabled="form.rule_type === 'L1_REWARD'"
|
||||
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm disabled:cursor-not-allowed"
|
||||
class="w-full px-3 py-2 bg-slate-700/50 border border-slate-600/50 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm disabled:cursor-not-allowed"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-slate-400">{{ t('commission_rules.commission_max_amount_helper') }}</p>
|
||||
<p class="mt-1 text-xs text-slate-500">{{ t('commission_rules.commission_max_amount_helper') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -481,6 +550,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conflict Override Confirmation Modal -->
|
||||
<div v-if="showConflictModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showConflictModal = false">
|
||||
<div class="bg-slate-800 rounded-xl border border-amber-500/30 p-6 w-full max-w-lg mx-4 shadow-2xl">
|
||||
<div class="flex items-start gap-3 mb-4">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-amber-500/10 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">{{ t('commission_rules.conflict_title') }}</h2>
|
||||
<p class="text-sm text-slate-400 mt-1">{{ t('commission_rules.conflict_message') }}</p>
|
||||
<div v-if="conflictingRule" class="mt-3 bg-slate-700/30 rounded-lg p-3 text-xs text-slate-300 space-y-1">
|
||||
<div><span class="text-slate-500">ID:</span> {{ conflictingRule.id }}</div>
|
||||
<div><span class="text-slate-500">{{ t('commission_rules.name') }}:</span> {{ conflictingRule.name }}</div>
|
||||
<div><span class="text-slate-500">{{ t('commission_rules.tier') }}:</span> {{ conflictingRule.tier }}</div>
|
||||
<div><span class="text-slate-500">{{ t('commission_rules.region') }}:</span> {{ conflictingRule.region_code }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 pt-2 border-t border-slate-700">
|
||||
<button @click="showConflictModal = false; conflictingRule = null" class="px-4 py-2 text-sm text-slate-400 hover:text-white transition">{{ t('commission_rules.conflict_cancel') }}</button>
|
||||
<button @click="confirmOverride" :disabled="saving" class="px-4 py-2 bg-amber-600 hover:bg-amber-500 disabled:bg-amber-600/50 text-white rounded-lg text-sm font-medium transition flex items-center gap-2">
|
||||
<svg v-if="saving" class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{{ saving ? t('commission_rules.saving') : t('commission_rules.conflict_confirm') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Toast -->
|
||||
<div v-if="toast" class="fixed bottom-6 right-6 z-50 bg-emerald-600 text-white px-4 py-3 rounded-lg shadow-lg text-sm flex items-center gap-2 animate-slide-up">
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -511,6 +612,9 @@ interface CommissionRule {
|
||||
upline_commission_percent: number | null
|
||||
renewal_commission_percent: number | null
|
||||
commission_max_amount: number | null
|
||||
gen1_max_amount: number | null
|
||||
gen2_max_amount: number | null
|
||||
gen2_renewal_percent: number | null
|
||||
is_campaign: boolean
|
||||
start_date: string | null
|
||||
end_date: string | null
|
||||
@@ -534,8 +638,11 @@ const error = ref(false)
|
||||
const rules = ref<CommissionRule[]>([])
|
||||
const showModal = ref(false)
|
||||
const showDeactivateModal = ref(false)
|
||||
const showConflictModal = ref(false)
|
||||
const editingRule = ref<CommissionRule | null>(null)
|
||||
const deactivatingRule = ref<CommissionRule | null>(null)
|
||||
const conflictingRule = ref<CommissionRule | null>(null)
|
||||
const pendingPayload = ref<Record<string, any> | null>(null)
|
||||
const saving = ref(false)
|
||||
const formError = ref('')
|
||||
const toast = ref('')
|
||||
@@ -578,6 +685,9 @@ const form = reactive({
|
||||
upline_commission_percent: null as number | null,
|
||||
renewal_commission_percent: null as number | null,
|
||||
commission_max_amount: null as number | null,
|
||||
gen1_max_amount: null as number | null,
|
||||
gen2_max_amount: null as number | null,
|
||||
gen2_renewal_percent: null as number | null,
|
||||
is_active: true,
|
||||
})
|
||||
|
||||
@@ -607,6 +717,9 @@ function resetForm() {
|
||||
form.upline_commission_percent = null
|
||||
form.renewal_commission_percent = null
|
||||
form.commission_max_amount = null
|
||||
form.gen1_max_amount = null
|
||||
form.gen2_max_amount = null
|
||||
form.gen2_renewal_percent = null
|
||||
form.is_active = true
|
||||
formError.value = ''
|
||||
}
|
||||
@@ -633,6 +746,9 @@ function openEditModal(rule: CommissionRule) {
|
||||
form.upline_commission_percent = rule.upline_commission_percent
|
||||
form.renewal_commission_percent = rule.renewal_commission_percent
|
||||
form.commission_max_amount = rule.commission_max_amount
|
||||
form.gen1_max_amount = rule.gen1_max_amount
|
||||
form.gen2_max_amount = rule.gen2_max_amount
|
||||
form.gen2_renewal_percent = rule.gen2_renewal_percent
|
||||
form.is_active = rule.is_active
|
||||
formError.value = ''
|
||||
showModal.value = true
|
||||
@@ -728,6 +844,9 @@ async function saveRule() {
|
||||
payload.upline_commission_percent = form.upline_commission_percent
|
||||
payload.renewal_commission_percent = form.renewal_commission_percent
|
||||
payload.commission_max_amount = form.commission_max_amount
|
||||
payload.gen1_max_amount = form.gen1_max_amount
|
||||
payload.gen2_max_amount = form.gen2_max_amount
|
||||
payload.gen2_renewal_percent = form.gen2_renewal_percent
|
||||
|
||||
if (editingRule.value) {
|
||||
await $fetch(`/api/v1/admin/commission-rules/${editingRule.value.id}`, {
|
||||
@@ -748,7 +867,78 @@ async function saveRule() {
|
||||
await fetchRules()
|
||||
} catch (e: any) {
|
||||
console.error('Failed to save commission rule:', e)
|
||||
formError.value = e?.data?.detail || t('commission_rules.save_error')
|
||||
|
||||
// Phase 3: Handle 409 Conflict — show override confirmation modal
|
||||
if (e?.response?.status === 409 || e?.status === 409) {
|
||||
const detail = e?.data?.detail || e?.response?._data?.detail || {}
|
||||
const conflicting = detail?.conflicting_rule
|
||||
if (conflicting) {
|
||||
conflictingRule.value = conflicting as CommissionRule
|
||||
pendingPayload.value = {
|
||||
name: form.name,
|
||||
description: form.description || null,
|
||||
rule_type: form.rule_type,
|
||||
tier: form.tier,
|
||||
region_code: form.region_code,
|
||||
is_campaign: form.is_campaign,
|
||||
is_active: form.is_active,
|
||||
start_date: form.is_campaign ? (form.start_date || null) : null,
|
||||
end_date: form.is_campaign ? (form.end_date || null) : null,
|
||||
xp_reward: form.xp_reward,
|
||||
credit_reward: form.credit_reward,
|
||||
commission_percent: form.commission_percent,
|
||||
upline_commission_percent: form.upline_commission_percent,
|
||||
renewal_commission_percent: form.renewal_commission_percent,
|
||||
commission_max_amount: form.commission_max_amount,
|
||||
gen1_max_amount: form.gen1_max_amount,
|
||||
gen2_max_amount: form.gen2_max_amount,
|
||||
gen2_renewal_percent: form.gen2_renewal_percent,
|
||||
}
|
||||
showConflictModal.value = true
|
||||
return // Don't clear form error — modal handles it
|
||||
}
|
||||
}
|
||||
|
||||
formError.value = e?.data?.detail || e?.message || t('commission_rules.save_error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmOverride() {
|
||||
if (!pendingPayload.value) return
|
||||
saving.value = true
|
||||
formError.value = ''
|
||||
try {
|
||||
// Add force_override flag
|
||||
const payload = { ...pendingPayload.value, force_override: true }
|
||||
|
||||
if (editingRule.value) {
|
||||
await $fetch(`/api/v1/admin/commission-rules/${editingRule.value.id}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: payload,
|
||||
})
|
||||
showToast(t('commission_rules.updated'))
|
||||
} else {
|
||||
await $fetch('/api/v1/admin/commission-rules', {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: payload,
|
||||
})
|
||||
showToast(t('commission_rules.created'))
|
||||
}
|
||||
showConflictModal.value = false
|
||||
conflictingRule.value = null
|
||||
pendingPayload.value = null
|
||||
closeModal()
|
||||
await fetchRules()
|
||||
} catch (e: any) {
|
||||
console.error('Failed to save commission rule with override:', e)
|
||||
formError.value = e?.data?.detail || e?.message || t('commission_rules.save_error')
|
||||
showConflictModal.value = false
|
||||
conflictingRule.value = null
|
||||
pendingPayload.value = null
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
441
plans/logic_spec_mlm_phase2_commission_wallet_integration.md
Normal file
441
plans/logic_spec_mlm_phase2_commission_wallet_integration.md
Normal file
@@ -0,0 +1,441 @@
|
||||
# 🏗️ Architecture Plan: Phase 2 - Advanced MLM Logic & Financial System Integration
|
||||
|
||||
**Audit Date:** 2026-07-24
|
||||
**Status:** DRAFT - Awaiting Approval
|
||||
**Auditor:** Architect Mode
|
||||
|
||||
---
|
||||
|
||||
## 📋 Executive Summary
|
||||
|
||||
A jelenlegi 2-szintű MLM jutalékrendszer (`distribute_commission()`) csak **kiszámolja** a jutalékokat, de **nem írja jóvá** azokat a pénztárcákban (Wallet) és nem naplózza a főkönyvben (FinancialLedger). Emellett hiányzik a **szintenkénti külön plafon**, a **Gen2 inaktivitás ellenőrzés**, a **180 napos reaktivációs szabály**, és a **megújítási jutalék gen2 változata**.
|
||||
|
||||
Ez a terv ezeket a hiányosságokat pótolja.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 TASK 1: Audit Findings Summary
|
||||
|
||||
### ✅ Meglévő Rendszer (Current State)
|
||||
|
||||
| Komponens | Fájl | Jelenlegi Állapot |
|
||||
|-----------|------|-------------------|
|
||||
| **CommissionRule modell** | `backend/app/models/marketplace/commission.py` | Van `commission_max_amount`, DE ez **közös plafon** Gen1 és Gen2 számára |
|
||||
| **Commission schemas** | `backend/app/schemas/commission.py` | Jó Pydantic modellek, de hiányzik a `gen1_max_amount`, `gen2_max_amount`, `gen2_renewal_percent` |
|
||||
| **distribute_commission()** | `backend/app/services/commission_service.py:377` | Kiszámolja a jutalékot, de **NEM írja jóvá** a Wallet-ben |
|
||||
| **Wallet** | `backend/app/models/identity/identity.py:255` | `earned_credits`, `purchased_credits`, `service_coins` - jó. `user.wallet` kapcsolat létezik |
|
||||
| **FinancialLedger** | `backend/app/models/system/audit.py:78` | Double-entry, `entry_type` (DEBIT/CREDIT), `wallet_type` (EARNED/PURCHASED/SERVICE_COINS/VOUCHER) |
|
||||
| **GamificationService._add_earned_credits()** | `backend/app/services/gamification_service.py:265` | Bizonyított minta: Wallet frissítés + FinancialLedger bejegyzés |
|
||||
| **AtomicTransactionManager** | `backend/app/services/billing_engine.py:364` | Bizonyított minta: Atomi tranzakció double-entry könyveléssel |
|
||||
| **User.is_active** | `backend/app/models/identity/identity.py:169` | Létezik, használható inaktivitás ellenőrzésre |
|
||||
| **Test rule (DB)** | `marketplace.commission_rules` ID=4 | STANDARD, HU, 5% Gen1, 2% Gen2, max 50000 |
|
||||
|
||||
### ❌ Hiányosságok (Gaps)
|
||||
|
||||
1. **Közös `commission_max_amount`**: Gen1 és Gen2 ugyanazt a plafont használja
|
||||
2. **Nincs Wallet írás**: `distribute_commission()` csak visszaad egy CommissionDistributionResponse DTO-t
|
||||
3. **Nincs inaktivitás ellenőrzés**: Nem nézi, hogy Gen1 aktív-e mielőtt Gen2-nek fizet
|
||||
4. **Nincs reaktivációs logika**: 180 napos inaktivitás detektálás hiányzik
|
||||
5. **Nincs `gen2_renewal_percent`**: Csak Gen1-nek van `renewal_commission_percent`
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ TASK 2: Proposed Architecture Plan
|
||||
|
||||
### 2.1 Adatmodell Változtatások (CommissionRule)
|
||||
|
||||
#### Cél
|
||||
A `commission_max_amount` mező szétválasztása szintenként külön plafonra.
|
||||
|
||||
#### SQLAlchemy Modell Változtatás
|
||||
**Fájl:** `backend/app/models/marketplace/commission.py`
|
||||
|
||||
**Új mezők hozzáadása (L2_COMMISSION szekcióba, a meglévők után):**
|
||||
|
||||
```python
|
||||
# --- Új: Szintenkénti plafonok ---
|
||||
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)"
|
||||
)
|
||||
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)"
|
||||
)
|
||||
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 - Gen1-hez hasonlóan, de Gen2 számára"
|
||||
)
|
||||
```
|
||||
|
||||
**Megjegyzés:** A régi `commission_max_amount` mező **megmarad** backward compatibility miatt, de a logika már az új `gen1_max_amount`-ot és `gen2_max_amount`-ot használja. Ha az új mezők NULL-ok, a `commission_max_amount` értéke lesz a fallback.
|
||||
|
||||
#### Sync Engine (Adatbázis Migráció)
|
||||
A modell módosítása után futtatni kell:
|
||||
```bash
|
||||
docker exec -it sf_api python -m app.scripts.sync_engine
|
||||
```
|
||||
|
||||
### 2.2 Schema Változtatások (Pydantic)
|
||||
|
||||
**Fájl:** `backend/app/schemas/commission.py`
|
||||
|
||||
```python
|
||||
# Új mezők a CommissionRuleCreate-ben:
|
||||
gen1_max_amount: Optional[float] = None # NULL = commission_max_amount fallback
|
||||
gen2_max_amount: Optional[float] = None # NULL = commission_max_amount fallback
|
||||
gen2_renewal_percent: Optional[float] = None
|
||||
|
||||
# Ugyanez a CommissionRuleUpdate-ben és CommissionRuleResponse-ben is
|
||||
```
|
||||
|
||||
### 2.3 CRUD Frissítés (commission_service.py)
|
||||
|
||||
**Fájl:** `backend/app/services/commission_service.py`
|
||||
|
||||
A `create_commission_rule()` függvényben hozzáadni az új mezők átvételét:
|
||||
```python
|
||||
gen1_max_amount=data.gen1_max_amount,
|
||||
gen2_max_amount=data.gen2_max_amount,
|
||||
gen2_renewal_percent=data.gen2_renewal_percent,
|
||||
```
|
||||
|
||||
### 2.4 _calculate_commission() Módosítás - Szintenkénti Plafon
|
||||
|
||||
A jelenlegi `_calculate_commission()` egyetlen `max_amount` paramétert fogad. Ezt kibővítjük a NULL/0 = korlátlan logikával:
|
||||
|
||||
```python
|
||||
async def _calculate_commission(
|
||||
amount: float,
|
||||
percent: Optional[float],
|
||||
max_amount: Optional[float],
|
||||
) -> float:
|
||||
"""
|
||||
Calculate commission amount from a percentage, capped by max_amount.
|
||||
NULL or 0 means unlimited (no cap).
|
||||
"""
|
||||
if not percent or percent <= 0:
|
||||
return 0.0
|
||||
|
||||
commission = float(Decimal(str(amount)) * Decimal(str(percent)) / Decimal("100"))
|
||||
|
||||
# Phase 2: NULL or 0 means unlimited
|
||||
if max_amount is not None and max_amount > 0:
|
||||
commission = min(commission, float(max_amount))
|
||||
|
||||
return round(commission, 2)
|
||||
```
|
||||
|
||||
**Változás a `distribute_commission()` hívásánál:**
|
||||
|
||||
```python
|
||||
# Gen1: use gen1_max_amount (fallback: commission_max_amount)
|
||||
gen1_cap = float(rule.gen1_max_amount) if rule.gen1_max_amount is not None else (
|
||||
float(rule.commission_max_amount) if rule.commission_max_amount else None
|
||||
)
|
||||
gen1_amount = await _calculate_commission(
|
||||
request.transaction_amount,
|
||||
float(rule.commission_percent) if rule.commission_percent else None,
|
||||
gen1_cap,
|
||||
)
|
||||
|
||||
# Gen2: use gen2_max_amount (fallback: commission_max_amount)
|
||||
gen2_cap = float(rule.gen2_max_amount) if rule.gen2_max_amount is not None else (
|
||||
float(rule.commission_max_amount) if rule.commission_max_amount else None
|
||||
)
|
||||
gen2_amount = await _calculate_commission(
|
||||
request.transaction_amount,
|
||||
float(rule.upline_commission_percent) if rule.upline_commission_percent else None,
|
||||
gen2_cap,
|
||||
)
|
||||
```
|
||||
|
||||
### 2.5 Inaktív Gen2 Megelőzés (Business Rule)
|
||||
|
||||
#### Cél
|
||||
Ha Gen1 (a közvetlen ajánló) inaktív, akkor Gen2 (az upline) **nem kap jutalékot**.
|
||||
|
||||
#### Logika menete (`distribute_commission()`-ben)
|
||||
|
||||
A Gen2 számítás ELŐTT:
|
||||
|
||||
```python
|
||||
# Phase 2: Inactive Gen2 Prevention
|
||||
# If Gen1 is inactive (is_active=False or subscription expired),
|
||||
# Gen2 does NOT receive commission
|
||||
gen1_is_active = gen1.is_active
|
||||
gen1_subscription_active = (
|
||||
gen1.subscription_expires_at is None or
|
||||
gen1.subscription_expires_at > datetime.utcnow()
|
||||
)
|
||||
|
||||
if not gen1_is_active or not gen1_subscription_active:
|
||||
logger.info(
|
||||
"Commission distribution: Gen1 user %d is inactive. Gen2 SKIPPED.",
|
||||
gen1.id,
|
||||
)
|
||||
gen2_amount = 0.0 # Skip Gen2 entirely
|
||||
```
|
||||
|
||||
### 2.6 Reaktivációs Szabály (180 nap)
|
||||
|
||||
#### Cél
|
||||
Ha Gen1 180 napig inaktív volt (nincs tranzakciója), akkor a következő vásárlását **új ügyfélként** kell kezelni.
|
||||
|
||||
#### Logika menete
|
||||
|
||||
```python
|
||||
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.
|
||||
Checks: User.is_active flag, latest FinancialLedger entry, subscription status.
|
||||
"""
|
||||
user_stmt = select(User).where(User.id == user_id)
|
||||
result = await db.execute(user_stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or user.is_deleted:
|
||||
return False
|
||||
|
||||
# Check if user has any transaction in the last inactivity_days
|
||||
cutoff = datetime.utcnow() - timedelta(days=inactivity_days)
|
||||
ledger_stmt = (
|
||||
select(FinancialLedger)
|
||||
.where(
|
||||
FinancialLedger.user_id == user_id,
|
||||
FinancialLedger.created_at >= cutoff,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
ledger_result = await db.execute(ledger_stmt)
|
||||
has_recent_transaction = ledger_result.scalar_one_or_none() is not None
|
||||
|
||||
if has_recent_transaction:
|
||||
return True
|
||||
|
||||
# Check subscription
|
||||
if user.subscription_expires_at and user.subscription_expires_at > datetime.utcnow():
|
||||
return True
|
||||
|
||||
return False
|
||||
```
|
||||
|
||||
### 2.7 Wallet Integráció (A Legfontosabb Változás)
|
||||
|
||||
#### Cél
|
||||
A `distribute_commission()` ne csak számoljon, hanem **ténylegesen írja jóvá** a jutalékot a megfelelő felhasználók Wallet-jében, és **naplózza** a FinancialLedger-ben.
|
||||
|
||||
#### Implementáció
|
||||
|
||||
A `GamificationService._add_earned_credits()` (bizonyított minta) alapján:
|
||||
|
||||
```python
|
||||
async def _credit_commission_to_wallet(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount: float,
|
||||
transaction_type: str,
|
||||
details: dict,
|
||||
) -> None:
|
||||
"""
|
||||
Credit commission to user's Wallet (earned_credits) and log in FinancialLedger.
|
||||
Follows the proven pattern from GamificationService._add_earned_credits().
|
||||
"""
|
||||
wallet_stmt = select(Wallet).where(Wallet.user_id == user_id)
|
||||
result = await db.execute(wallet_stmt)
|
||||
wallet = result.scalar_one_or_none()
|
||||
|
||||
if not wallet:
|
||||
logger.warning(f"Wallet not found for user_id={user_id}, cannot credit commission")
|
||||
return
|
||||
|
||||
# 1. Update wallet balance
|
||||
wallet.earned_credits += Decimal(str(amount))
|
||||
|
||||
# 2. Create FinancialLedger CREDIT entry
|
||||
ledger_entry = FinancialLedger(
|
||||
user_id=user_id,
|
||||
amount=Decimal(str(amount)),
|
||||
entry_type=LedgerEntryType.CREDIT,
|
||||
wallet_type=WalletType.EARNED,
|
||||
transaction_type=transaction_type,
|
||||
details=details,
|
||||
balance_after=float(wallet.earned_credits),
|
||||
currency="EUR",
|
||||
)
|
||||
db.add(ledger_entry)
|
||||
```
|
||||
|
||||
**A `distribute_commission()` módosítása:**
|
||||
|
||||
```python
|
||||
# Phase 2: Wallet Credit
|
||||
if gen1_amount > 0:
|
||||
await _credit_commission_to_wallet(
|
||||
db,
|
||||
user_id=gen1.id,
|
||||
amount=gen1_amount,
|
||||
transaction_type="COMMISSION_GEN1",
|
||||
details={
|
||||
"description": "Gen1 commission on transaction",
|
||||
"buyer_user_id": request.buyer_user_id,
|
||||
"transaction_amount": request.transaction_amount,
|
||||
"rule_id": rule.id,
|
||||
"commission_percent": float(rule.commission_percent),
|
||||
},
|
||||
)
|
||||
|
||||
if gen2_amount > 0:
|
||||
await _credit_commission_to_wallet(
|
||||
db,
|
||||
user_id=gen2.id,
|
||||
amount=gen2_amount,
|
||||
transaction_type="COMMISSION_GEN2",
|
||||
details={
|
||||
"description": "Gen2 (upline) commission on transaction",
|
||||
"buyer_user_id": request.buyer_user_id,
|
||||
"gen1_user_id": gen1.id,
|
||||
"transaction_amount": request.transaction_amount,
|
||||
"rule_id": rule.id,
|
||||
"commission_percent": float(rule.upline_commission_percent),
|
||||
},
|
||||
)
|
||||
|
||||
# Phase 2: Commit
|
||||
await db.commit()
|
||||
```
|
||||
|
||||
### 2.8 Megújítási Jutalék (Renewal) Logika
|
||||
|
||||
#### Cél
|
||||
A megújítási (renewal) tranzakcióknál:
|
||||
- Gen1 kapja a `renewal_commission_percent`-et (ha CONTRACTED tier)
|
||||
- Gen2 kapja a `gen2_renewal_percent`-et (az új mezőből)
|
||||
|
||||
#### Logika
|
||||
|
||||
```python
|
||||
# A distribute_commission()-ben, ha a tranzakció típusa "renewal":
|
||||
is_renewal = getattr(request, 'is_renewal', False)
|
||||
|
||||
if is_renewal:
|
||||
gen1_percent = float(rule.renewal_commission_percent) if rule.renewal_commission_percent else None
|
||||
gen2_percent = float(rule.gen2_renewal_percent) if rule.gen2_renewal_percent is not None else (
|
||||
float(rule.upline_commission_percent) if rule.upline_commission_percent else None
|
||||
)
|
||||
else:
|
||||
gen1_percent = float(rule.commission_percent) if rule.commission_percent else None
|
||||
gen2_percent = float(rule.upline_commission_percent) if rule.upline_commission_percent else None
|
||||
```
|
||||
|
||||
**Schema módosítás:** A `CommissionDistributionRequest`-hez hozzáadni:
|
||||
|
||||
```python
|
||||
class CommissionDistributionRequest(BaseModel):
|
||||
# ... existing fields ...
|
||||
is_renewal: bool = Field(False, description="TRUE = renewal transaction, FALSE = first-time purchase")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Folyamatábra (Mermaid)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[distribute_commission hívás] --> B[Buyer lookup]
|
||||
B --> C{Van referrer?}
|
||||
C -->|Nincs| D[Return empty - nincs jutalék]
|
||||
C -->|Van Gen1| E[Gen1 lookup]
|
||||
E --> F{Gen1 aktív?}
|
||||
F -->|Nem| D
|
||||
F -->|Igen| G[Resolve L2_COMMISSION rule]
|
||||
G --> H{Rule található?}
|
||||
H -->|Nem| D
|
||||
H -->|Igen| I{A tranzakcio renewal?}
|
||||
|
||||
I -->|Igen| J[Hasznalj renewal_commission_percent + gen2_renewal_percent]
|
||||
I -->|Nem| K[Hasznalj commission_percent + upline_commission_percent]
|
||||
|
||||
J --> L[Calculate Gen1 commission with gen1_max_amount cap]
|
||||
K --> L
|
||||
|
||||
L --> M{Gen1 recently active? <180 nap?}
|
||||
M -->|Nem| N[Gen1 inaktiv - normal jutalek, reaktivacio flag]
|
||||
M -->|Igen| O[Gen1 aktiv - normal folyamat]
|
||||
|
||||
N --> P[Calculate Gen2 commission with gen2_max_amount cap]
|
||||
O --> P
|
||||
|
||||
P --> Q{Gen1 aktiv Subscription?}
|
||||
Q -->|Nem| R[Gen2 jutalek SKIP - inaktiv Gen2 prevention]
|
||||
Q -->|Igen| S[Gen2 jutalek jar]
|
||||
|
||||
R --> T[Credit Gen1 wallet + FinancialLedger]
|
||||
S --> U[Credit Gen1 wallet + FinancialLedger + Credit Gen2 wallet + FinancialLedger]
|
||||
|
||||
T --> V[Commit + Return response]
|
||||
U --> V
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Érintett Fájlok (Files to Modify)
|
||||
|
||||
| # | Fájl | Módosítás Típusa |
|
||||
|---|------|-------------------|
|
||||
| 1 | `backend/app/models/marketplace/commission.py` | Modell bővítés: gen1_max_amount, gen2_max_amount, gen2_renewal_percent |
|
||||
| 2 | `backend/app/schemas/commission.py` | Schema bővítés: új mezők Create/Update/Response DTO-khoz, is_renewal |
|
||||
| 3 | `backend/app/services/commission_service.py` | Szolgáltatás bővítés: _credit_commission_to_wallet(), _is_user_recently_active(), módosított logika |
|
||||
| 4 | `backend/backend/tests/active/seed_commission_test_data.py` | Teszt seed adat frissítés |
|
||||
| 5 | `backend/backend/tests/active/test_commission_distribution.py` | Teszt bővítés Wallet/FinancialLedger ellenőrzéssel |
|
||||
|
||||
---
|
||||
|
||||
## Futtatási Sorrend (Execution Order)
|
||||
|
||||
1. Modell módosítás -> CommissionRule új mezők
|
||||
2. Schema módosítás -> Pydantic DTO-k bővítése
|
||||
3. Service módosítás -> _credit_commission_to_wallet(), _is_user_recently_active(), módosított logika
|
||||
4. Sync Engine futtatás -> docker exec -it sf_api python -m app.scripts.sync_engine
|
||||
5. Teszt seed adat frissítés
|
||||
6. Teszt futtatás -> test_commission_distribution.py
|
||||
7. Manuális verifikáció -> API hívás + DB ellenőrzés
|
||||
|
||||
---
|
||||
|
||||
## Tesztelési Terv
|
||||
|
||||
### 1. Alap funkcionális tesztek
|
||||
- Ellenőrizni, hogy a Wallet earned_credits megváltozott a tranzakció után
|
||||
- Ellenőrizni, hogy a FinancialLedger-ben megjelentek a CREDIT bejegyzések
|
||||
|
||||
### 2. Plafon tesztek (Cap)
|
||||
- gen1_max_amount=3000 esetén Gen1 5000 helyett 3000-et kap
|
||||
- gen2_max_amount=0 esetén Gen2 korlátlan (nincs plafon)
|
||||
- gen1_max_amount=NULL esetén a régi commission_max_amount a fallback
|
||||
|
||||
### 3. Inaktivitás tesztek
|
||||
- Ha Gen1 is_active=False, Gen2 nem kap jutalékot
|
||||
- Ha Gen1 subscription lejárt, Gen2 nem kap jutalékot
|
||||
|
||||
### 4. Megújítási teszt
|
||||
- is_renewal=True esetén Gen1 a renewal_commission_percent-et kapja
|
||||
- is_renewal=True esetén Gen2 a gen2_renewal_percent-et kapja
|
||||
|
||||
---
|
||||
|
||||
## Kockázatok és Megfontolások
|
||||
|
||||
1. **Adatbázis Migráció**: Az új mezők nullable=True-val történnek, a meglévő rule-ok változatlanul működnek.
|
||||
2. **Visszamenőleges kompatibilitás**: A régi API hívások továbbra is működnek.
|
||||
3. **Wallet írás hibakezelés**: Ha a Wallet nem található, logger.warning + a commission response-ba hiba jelzés.
|
||||
4. **Tranzakció biztonság**: A distribute_commission() jelenleg NEM használ atomi tranzakciót - ez fontos lehet a Wallet írás biztonságához.
|
||||
|
||||
---
|
||||
|
||||
## Jóváhagyás
|
||||
|
||||
**Kérem a felhasználó jóváhagyását a fenti tervhez.** A jóváhagyás után a Code módba kapcsolok és megkezdem az implementációt.
|
||||
Reference in New Issue
Block a user