Epic 3: Economy & Billing Engine (Pénzügyi Motor)
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/api.py
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1.endpoints import (
|
||||
auth, catalog, assets, organizations, documents,
|
||||
services, admin, expenses, evidence, social
|
||||
auth, catalog, assets, organizations, documents,
|
||||
services, admin, expenses, evidence, social, security,
|
||||
billing
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -17,4 +18,5 @@ api_router.include_router(documents.router, prefix="/documents", tags=["Document
|
||||
api_router.include_router(admin.router, prefix="/admin", tags=["Admin Control Center (Sentinel)"])
|
||||
api_router.include_router(evidence.router, prefix="/evidence", tags=["Evidence & OCR (Robot 3)"])
|
||||
api_router.include_router(expenses.router, prefix="/expenses", tags=["Fleet Expenses (TCO)"])
|
||||
api_router.include_router(social.router, prefix="/social", tags=["Social & Leaderboard"])
|
||||
api_router.include_router(social.router, prefix="/social", tags=["Social & Leaderboard"])
|
||||
api_router.include_router(security.router, prefix="/security", tags=["Dual Control (Security)"])
|
||||
@@ -21,11 +21,12 @@ async def login(db: AsyncSession = Depends(get_db), form_data: OAuth2PasswordReq
|
||||
|
||||
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)
|
||||
role_key = role_name.upper() # A DEFAULT_RANK_MAP nagybetűs kulcsokat vár
|
||||
|
||||
token_data = {
|
||||
"sub": str(user.id),
|
||||
"role": role_name,
|
||||
"rank": ranks.get(role_name, 10),
|
||||
"rank": ranks.get(role_key, 10),
|
||||
"scope_level": user.scope_level or "individual",
|
||||
"scope_id": str(user.scope_id) if user.scope_id else str(user.id)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
# backend/app/api/v1/endpoints/billing.py
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request, Header
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import Optional, Dict, Any
|
||||
import logging
|
||||
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from app.models.identity import User, Wallet, UserRole
|
||||
from app.models.audit import FinancialLedger
|
||||
from app.models.audit import FinancialLedger, WalletType
|
||||
from app.models.payment import PaymentIntent, PaymentIntentStatus
|
||||
from app.services.config_service import config
|
||||
from app.services.payment_router import PaymentRouter
|
||||
from app.services.stripe_adapter import stripe_adapter
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@router.post("/upgrade")
|
||||
async def upgrade_account(target_package: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
@@ -60,4 +67,291 @@ async def upgrade_account(target_package: str, db: AsyncSession = Depends(get_db
|
||||
))
|
||||
|
||||
await db.commit()
|
||||
return {"status": "success", "package": target_package, "rank_granted": pkg_info["rank"]}
|
||||
return {"status": "success", "package": target_package, "rank_granted": pkg_info["rank"]}
|
||||
|
||||
|
||||
@router.post("/payment-intent/create")
|
||||
async def create_payment_intent(
|
||||
request: Dict[str, Any],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
PaymentIntent létrehozása (Prior Intent - Kettős Lakat 1. lépés).
|
||||
|
||||
Body:
|
||||
- net_amount: float (kötelező)
|
||||
- handling_fee: float (alapértelmezett: 0)
|
||||
- target_wallet_type: string (EARNED, PURCHASED, SERVICE_COINS, VOUCHER)
|
||||
- beneficiary_id: int (opcionális)
|
||||
- currency: string (alapértelmezett: "EUR")
|
||||
- metadata: dict (opcionális)
|
||||
"""
|
||||
try:
|
||||
# Adatok kinyerése
|
||||
net_amount = request.get("net_amount")
|
||||
handling_fee = request.get("handling_fee", 0.0)
|
||||
target_wallet_type_str = request.get("target_wallet_type")
|
||||
beneficiary_id = request.get("beneficiary_id")
|
||||
currency = request.get("currency", "EUR")
|
||||
metadata = request.get("metadata", {})
|
||||
|
||||
# Validáció
|
||||
if net_amount is None or net_amount <= 0:
|
||||
raise HTTPException(status_code=400, detail="net_amount pozitív szám kell legyen")
|
||||
|
||||
if handling_fee < 0:
|
||||
raise HTTPException(status_code=400, detail="handling_fee nem lehet negatív")
|
||||
|
||||
try:
|
||||
target_wallet_type = WalletType(target_wallet_type_str)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Érvénytelen target_wallet_type: {target_wallet_type_str}. Használd: {[wt.value for wt in WalletType]}"
|
||||
)
|
||||
|
||||
# PaymentIntent létrehozása
|
||||
payment_intent = await PaymentRouter.create_payment_intent(
|
||||
db=db,
|
||||
payer_id=current_user.id,
|
||||
net_amount=net_amount,
|
||||
handling_fee=handling_fee,
|
||||
target_wallet_type=target_wallet_type,
|
||||
beneficiary_id=beneficiary_id,
|
||||
currency=currency,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"payment_intent_id": payment_intent.id,
|
||||
"intent_token": str(payment_intent.intent_token),
|
||||
"net_amount": float(payment_intent.net_amount),
|
||||
"handling_fee": float(payment_intent.handling_fee),
|
||||
"gross_amount": float(payment_intent.gross_amount),
|
||||
"currency": payment_intent.currency,
|
||||
"status": payment_intent.status.value,
|
||||
"expires_at": payment_intent.expires_at.isoformat() if payment_intent.expires_at else None,
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"PaymentIntent létrehozási hiba: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/payment-intent/{payment_intent_id}/stripe-checkout")
|
||||
async def initiate_stripe_checkout(
|
||||
payment_intent_id: int,
|
||||
request: Dict[str, Any],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Stripe Checkout Session indítása PaymentIntent alapján.
|
||||
|
||||
Body:
|
||||
- success_url: string (kötelező)
|
||||
- cancel_url: string (kötelező)
|
||||
"""
|
||||
try:
|
||||
success_url = request.get("success_url")
|
||||
cancel_url = request.get("cancel_url")
|
||||
|
||||
if not success_url or not cancel_url:
|
||||
raise HTTPException(status_code=400, detail="success_url és cancel_url kötelező")
|
||||
|
||||
# Ellenőrizzük, hogy a PaymentIntent a felhasználóhoz tartozik-e
|
||||
stmt = select(PaymentIntent).where(
|
||||
PaymentIntent.id == payment_intent_id,
|
||||
PaymentIntent.payer_id == current_user.id
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
payment_intent = result.scalar_one_or_none()
|
||||
|
||||
if not payment_intent:
|
||||
raise HTTPException(status_code=404, detail="PaymentIntent nem található vagy nincs hozzáférésed")
|
||||
|
||||
# Stripe Checkout indítása
|
||||
session_data = await PaymentRouter.initiate_stripe_payment(
|
||||
db=db,
|
||||
payment_intent_id=payment_intent_id,
|
||||
success_url=success_url,
|
||||
cancel_url=cancel_url
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"checkout_url": session_data["checkout_url"],
|
||||
"stripe_session_id": session_data["stripe_session_id"],
|
||||
"expires_at": session_data["expires_at"],
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Stripe Checkout indítási hiba: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/payment-intent/{payment_intent_id}/process-internal")
|
||||
async def process_internal_payment(
|
||||
payment_intent_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Belső ajándékozás feldolgozása (SmartDeduction használatával).
|
||||
Csak akkor engedélyezett, ha a PaymentIntent PENDING státuszú és a felhasználó a payer.
|
||||
"""
|
||||
try:
|
||||
# Ellenőrizzük, hogy a PaymentIntent a felhasználóhoz tartozik-e
|
||||
stmt = select(PaymentIntent).where(
|
||||
PaymentIntent.id == payment_intent_id,
|
||||
PaymentIntent.payer_id == current_user.id,
|
||||
PaymentIntent.status == PaymentIntentStatus.PENDING
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
payment_intent = result.scalar_one_or_none()
|
||||
|
||||
if not payment_intent:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="PaymentIntent nem található, nincs hozzáférésed, vagy nem PENDING státuszú"
|
||||
)
|
||||
|
||||
# Belső fizetés feldolgozása
|
||||
result = await PaymentRouter.process_internal_payment(db, payment_intent_id)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=400, detail=result.get("error", "Ismeretlen hiba"))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"transaction_id": result.get("transaction_id"),
|
||||
"used_amounts": result.get("used_amounts"),
|
||||
"beneficiary_credited": result.get("beneficiary_credited", False),
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Belső fizetés feldolgozási hiba: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/stripe-webhook")
|
||||
async def stripe_webhook(
|
||||
request: Request,
|
||||
stripe_signature: Optional[str] = Header(None),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Stripe webhook végpont a Kettős Lakat validációval.
|
||||
|
||||
Stripe a következő header-t küldi: Stripe-Signature
|
||||
"""
|
||||
if not stripe_signature:
|
||||
raise HTTPException(status_code=400, detail="Missing Stripe-Signature header")
|
||||
|
||||
try:
|
||||
# Request body kiolvasása
|
||||
payload = await request.body()
|
||||
|
||||
# Webhook feldolgozása
|
||||
result = await PaymentRouter.process_stripe_webhook(
|
||||
db=db,
|
||||
payload=payload,
|
||||
signature=stripe_signature
|
||||
)
|
||||
|
||||
if not result.get("success", False):
|
||||
error_msg = result.get("error", "Unknown error")
|
||||
logger.error(f"Stripe webhook feldolgozás sikertelen: {error_msg}")
|
||||
raise HTTPException(status_code=400, detail=error_msg)
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Stripe webhook végpont hiba: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/payment-intent/{payment_intent_id}/status")
|
||||
async def get_payment_intent_status(
|
||||
payment_intent_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
PaymentIntent státusz lekérdezése.
|
||||
"""
|
||||
try:
|
||||
# Ellenőrizzük, hogy a PaymentIntent a felhasználóhoz tartozik-e
|
||||
stmt = select(PaymentIntent).where(
|
||||
PaymentIntent.id == payment_intent_id,
|
||||
PaymentIntent.payer_id == current_user.id
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
payment_intent = result.scalar_one_or_none()
|
||||
|
||||
if not payment_intent:
|
||||
raise HTTPException(status_code=404, detail="PaymentIntent nem található vagy nincs hozzáférésed")
|
||||
|
||||
return {
|
||||
"id": payment_intent.id,
|
||||
"intent_token": str(payment_intent.intent_token),
|
||||
"net_amount": float(payment_intent.net_amount),
|
||||
"handling_fee": float(payment_intent.handling_fee),
|
||||
"gross_amount": float(payment_intent.gross_amount),
|
||||
"currency": payment_intent.currency,
|
||||
"status": payment_intent.status.value,
|
||||
"target_wallet_type": payment_intent.target_wallet_type.value,
|
||||
"beneficiary_id": payment_intent.beneficiary_id,
|
||||
"stripe_session_id": payment_intent.stripe_session_id,
|
||||
"transaction_id": str(payment_intent.transaction_id) if payment_intent.transaction_id else None,
|
||||
"created_at": payment_intent.created_at.isoformat(),
|
||||
"updated_at": payment_intent.updated_at.isoformat(),
|
||||
"completed_at": payment_intent.completed_at.isoformat() if payment_intent.completed_at else None,
|
||||
"expires_at": payment_intent.expires_at.isoformat() if payment_intent.expires_at else None,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"PaymentIntent státusz lekérdezési hiba: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/wallet/balance")
|
||||
async def get_wallet_balance(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Felhasználó pénztárca egyenlegének lekérdezése.
|
||||
"""
|
||||
try:
|
||||
stmt = select(Wallet).where(Wallet.user_id == current_user.id)
|
||||
result = await db.execute(stmt)
|
||||
wallet = result.scalar_one_or_none()
|
||||
|
||||
if not wallet:
|
||||
raise HTTPException(status_code=404, detail="Pénztárca nem található")
|
||||
|
||||
return {
|
||||
"earned": float(wallet.earned_credits),
|
||||
"purchased": float(wallet.purchased_credits),
|
||||
"service_coins": float(wallet.service_coins),
|
||||
"total": float(
|
||||
wallet.earned_credits +
|
||||
wallet.purchased_credits +
|
||||
wallet.service_coins
|
||||
),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Pénztárca egyenleg lekérdezési hiba: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}")
|
||||
173
backend/app/api/v1/endpoints/security.py
Normal file
173
backend/app/api/v1/endpoints/security.py
Normal file
@@ -0,0 +1,173 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/security.py
|
||||
"""
|
||||
Dual Control (Négy szem elv) API végpontok.
|
||||
Kiemelt műveletek jóváhagyási folyamata.
|
||||
"""
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from app.models.identity import User, UserRole
|
||||
from app.services.security_service import security_service
|
||||
from app.schemas.security import (
|
||||
PendingActionCreate, PendingActionResponse, PendingActionApprove, PendingActionReject
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/request", response_model=PendingActionResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def request_action(
|
||||
request: PendingActionCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Dual Control: Jóváhagyási kérelem indítása kiemelt művelethez.
|
||||
|
||||
Engedélyezett művelettípusok:
|
||||
- CHANGE_ROLE: Felhasználó szerepkörének módosítása
|
||||
- SET_VIP: VIP státusz beállítása
|
||||
- WALLET_ADJUST: Pénztár egyenleg módosítása (nagy összeg)
|
||||
- SOFT_DELETE_USER: Felhasználó soft delete
|
||||
- ORGANIZATION_TRANSFER: Szervezet tulajdonjog átadása
|
||||
"""
|
||||
# Csak admin és superadmin kezdeményezhet kiemelt műveleteket
|
||||
if current_user.role not in [UserRole.admin, UserRole.superadmin]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Csak adminisztrátorok kezdeményezhetnek Dual Control műveleteket."
|
||||
)
|
||||
|
||||
try:
|
||||
action = await security_service.request_action(
|
||||
db, requester_id=current_user.id,
|
||||
action_type=request.action_type,
|
||||
payload=request.payload,
|
||||
reason=request.reason
|
||||
)
|
||||
return PendingActionResponse.from_orm(action)
|
||||
except Exception as e:
|
||||
logger.error(f"Dual Control request error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Hiba a kérelem létrehozásakor: {str(e)}"
|
||||
)
|
||||
|
||||
@router.get("/pending", response_model=List[PendingActionResponse])
|
||||
async def list_pending_actions(
|
||||
action_type: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Függőben lévő Dual Control műveletek listázása.
|
||||
|
||||
Admin és superadmin látja az összes függőben lévő műveletet.
|
||||
Egyéb felhasználók csak a sajátjaikat láthatják.
|
||||
"""
|
||||
if current_user.role in [UserRole.admin, UserRole.superadmin]:
|
||||
user_id = None
|
||||
else:
|
||||
user_id = current_user.id
|
||||
|
||||
actions = await security_service.get_pending_actions(db, user_id=user_id, action_type=action_type)
|
||||
return [PendingActionResponse.from_orm(action) for action in actions]
|
||||
|
||||
@router.post("/approve/{action_id}", response_model=PendingActionResponse)
|
||||
async def approve_action(
|
||||
action_id: int,
|
||||
approve_data: PendingActionApprove,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Dual Control: Művelet jóváhagyása.
|
||||
|
||||
Csak admin/superadmin hagyhat jóvá, és nem lehet a saját kérése.
|
||||
"""
|
||||
if current_user.role not in [UserRole.admin, UserRole.superadmin]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Csak adminisztrátorok hagyhatnak jóvá műveleteket."
|
||||
)
|
||||
|
||||
try:
|
||||
await security_service.approve_action(db, approver_id=current_user.id, action_id=action_id)
|
||||
# Frissített művelet lekérdezése
|
||||
from sqlalchemy import select
|
||||
from app.models.security import PendingAction
|
||||
stmt = select(PendingAction).where(PendingAction.id == action_id)
|
||||
action = (await db.execute(stmt)).scalar_one()
|
||||
return PendingActionResponse.from_orm(action)
|
||||
except Exception as e:
|
||||
logger.error(f"Dual Control approve error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
@router.post("/reject/{action_id}", response_model=PendingActionResponse)
|
||||
async def reject_action(
|
||||
action_id: int,
|
||||
reject_data: PendingActionReject,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Dual Control: Művelet elutasítása.
|
||||
|
||||
Csak admin/superadmin utasíthat el, és nem lehet a saját kérése.
|
||||
"""
|
||||
if current_user.role not in [UserRole.admin, UserRole.superadmin]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Csak adminisztrátorok utasíthatnak el műveleteket."
|
||||
)
|
||||
|
||||
try:
|
||||
await security_service.reject_action(
|
||||
db, approver_id=current_user.id,
|
||||
action_id=action_id, reason=reject_data.reason
|
||||
)
|
||||
# Frissített művelet lekérdezése
|
||||
from sqlalchemy import select
|
||||
from app.models.security import PendingAction
|
||||
stmt = select(PendingAction).where(PendingAction.id == action_id)
|
||||
action = (await db.execute(stmt)).scalar_one()
|
||||
return PendingActionResponse.from_orm(action)
|
||||
except Exception as e:
|
||||
logger.error(f"Dual Control reject error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
@router.get("/{action_id}", response_model=PendingActionResponse)
|
||||
async def get_action(
|
||||
action_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Egy konkrét Dual Control művelet lekérdezése.
|
||||
|
||||
Csak a művelet létrehozója vagy admin/superadmin érheti el.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from app.models.security import PendingAction
|
||||
stmt = select(PendingAction).where(PendingAction.id == action_id)
|
||||
action = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if not action:
|
||||
raise HTTPException(status_code=404, detail="Művelet nem található.")
|
||||
|
||||
if current_user.role not in [UserRole.admin, UserRole.superadmin] and action.requester_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Nincs jogosultságod ehhez a művelethez."
|
||||
)
|
||||
|
||||
return PendingActionResponse.from_orm(action)
|
||||
Reference in New Issue
Block a user