Epic 3: Economy & Billing Engine (Pénzügyi Motor)

This commit is contained in:
Roo
2026-03-08 23:15:52 +00:00
parent 8d25f44ec6
commit 4e40af8a08
69 changed files with 3758 additions and 72 deletions

View File

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

View File

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

View File

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

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