from fastapi import APIRouter, Depends, HTTPException, status, Request, Header, Query, Form from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, and_ from typing import Optional, Dict, Any, List import logging import uuid from decimal import Decimal from app.api.deps import get_db, get_current_user from app.models.identity import User, Wallet, UserRole from app.models import FinancialLedger, WalletType, LedgerEntryType, LedgerStatus from app.models.marketplace.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 from app.services.billing_engine import upgrade_subscription, get_user_balance from app.services.subscription_activator import SubscriptionActivator from app.core.config import settings 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)): """ Univerzális csomagváltó a Billing Engine segítségével. Kezeli az 5+ csomagot, a Rank-ugrást és a különleges 'Service Coin' bónuszokat. """ try: result = await upgrade_subscription(db, current_user.id, target_package) return { "status": "success", "package": target_package, "price_paid": result.get("price_paid", 0.0), "new_plan": result.get("new_plan"), "expires_at": result.get("expires_at"), "transaction": result.get("transaction") } except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Upgrade error: {e}") raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}") @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 a Billing Engine segítségével. """ try: balances = await get_user_balance(db, current_user.id) return balances except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) 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)}") @router.get("/wallet/transactions") async def get_wallet_transactions( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), page: int = Query(1, ge=1, description="Oldalszám"), page_size: int = Query(20, ge=1, le=100, description="Elemek száma oldalanként"), wallet_type: Optional[str] = Query(None, description="Szűrés pénztárca típusra (EARNED, PURCHASED, SERVICE_COINS, VOUCHER)"), entry_type: Optional[str] = Query(None, description="Szűrés bejegyzés típusra (DEBIT, CREDIT)"), ): """ Bejelentkezett felhasználó tranzakciótörténetének lekérdezése (FinancialLedger). Visszaadja a főkönyvi bejegyzéseket lapozható formában, időrendben csökkenő sorrendben. """ try: # Base query conditions = [FinancialLedger.user_id == current_user.id] # Optional wallet_type filter if wallet_type: try: wt = WalletType(wallet_type.upper()) conditions.append(FinancialLedger.wallet_type == wt) except ValueError: raise HTTPException( status_code=400, detail=f"Érvénytelen wallet_type: {wallet_type}. Használd: {[wt.value for wt in WalletType]}" ) # Optional entry_type filter if entry_type: try: et = LedgerEntryType(entry_type.upper()) conditions.append(FinancialLedger.entry_type == et) except ValueError: raise HTTPException( status_code=400, detail=f"Érvénytelen entry_type: {entry_type}. Használd: {[et.value for et in LedgerEntryType]}" ) # Count total matching records count_stmt = select(func.count(FinancialLedger.id)).where(and_(*conditions)) count_result = await db.execute(count_stmt) total_count = count_result.scalar() or 0 # Fetch paginated results offset = (page - 1) * page_size stmt = ( select(FinancialLedger) .where(and_(*conditions)) .order_by(FinancialLedger.created_at.desc()) .offset(offset) .limit(page_size) ) result = await db.execute(stmt) entries = result.scalars().all() # Build response transactions = [] for entry in entries: description = "" if entry.details and isinstance(entry.details, dict): description = entry.details.get("description", "") transactions.append({ "id": entry.id, "transaction_id": str(entry.transaction_id), "amount": float(entry.amount), "entry_type": entry.entry_type.value, "wallet_type": entry.wallet_type.value if entry.wallet_type else None, "transaction_type": entry.transaction_type, "description": description, "status": entry.status.value if entry.status else None, "balance_after": float(entry.balance_after) if entry.balance_after else None, "currency": entry.currency or "EUR", "created_at": entry.created_at.isoformat() if entry.created_at else None, }) return { "success": True, "data": transactions, "pagination": { "page": page, "page_size": page_size, "total_count": total_count, "total_pages": max(1, (total_count + page_size - 1) // page_size), }, } except HTTPException: raise except Exception as e: logger.error(f"Tranzakció történet lekérdezési hiba: {e}") raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}") # ────────────────────────────────────────────────────────────────────────────── # Mock Payment Gateway — Checkout Page & Webhook Callback (Phase 2) # ────────────────────────────────────────────────────────────────────────────── # # THOUGHT PROCESS: # These two endpoints simulate a real payment gateway lifecycle: # # 1. GET /mock-payment/checkout/{intent_id} # Displays a simple HTML page with "Pay Now" button. # The user sees an amount and clicks to confirm payment. # This is the URL returned as `checkout_url` by MockPaymentGateway # in `simulate_redirect` mode. # # 2. POST /mock-payment/callback # Simulates the webhook callback that a real payment gateway # (like Stripe) would send after successful payment. # Updates PaymentIntent PENDING → COMPLETED and activates # the subscription using the metadata stored by FinancialManager. # # Together, these allow frontend testing of the full payment flow: # purchase → redirect → pay → callback → activate subscription # without requiring real Stripe integration. # ────────────────────────────────────────────────────────────────────────────── @router.get("/mock-payment/checkout/{intent_id}") async def mock_checkout_page( intent_id: str, db: AsyncSession = Depends(get_db), ): """ Mock payment checkout page. Displays a simple HTML page showing the payment amount and a "Pay Now" button. When the user clicks the button, it POSTs to the mock callback endpoint which finalizes the PaymentIntent and activates the subscription. This simulates redirecting the user to a banking/payment portal. Args: intent_id: The mock gateway intent ID (stored as stripe_session_id). db: Database session. Returns: HTMLResponse with a styled mock checkout page, or 404 if not found. """ logger.info( "[MOCK_PAYMENT_CHECKOUT] Page requested for intent: %s", intent_id, ) # Look up the PaymentIntent by stripe_session_id (= gateway intent_id) stmt = select(PaymentIntent).where( PaymentIntent.stripe_session_id == intent_id, PaymentIntent.status == PaymentIntentStatus.PENDING, ) result = await db.execute(stmt) payment_intent = result.scalar_one_or_none() if not payment_intent: logger.warning( "[MOCK_PAYMENT_CHECKOUT] PaymentIntent not found or not PENDING: " "intent_id=%s", intent_id, ) return HTMLResponse( content="

Payment not found or already processed

", status_code=404, ) amount = float(payment_intent.gross_amount) currency = payment_intent.currency # Determine the callback URL: POST back to the same origin callback_url = f"/api/v1/billing/mock-payment/callback" # Simple but styled HTML checkout page html_content = f""" Mock Payment Gateway
🧪 Mock Gateway — Development Mode

Complete Your Payment

This is a simulated payment page for testing

{amount:.2f} {currency}
Intent ID
{intent_id[:20]}...
Description
Subscription Package Purchase
""" logger.info( "[MOCK_PAYMENT_CHECKOUT] Serving checkout page: intent=%s amount=%.2f %s", intent_id, amount, currency, ) return HTMLResponse(content=html_content) @router.post("/mock-payment/callback") async def mock_payment_callback( intent_id: str = Form(...), db: AsyncSession = Depends(get_db), ): """ Mock payment gateway callback (webhook simulation). Simulates the webhook that a real payment gateway would send after successful payment. This endpoint: 1. Looks up the PENDING PaymentIntent by gateway intent_id 2. Updates it to COMPLETED 3. Activates the subscription using stored metadata (tier_id, user_id, org_id) 4. Returns a redirect to the frontend success page (or JSON response) Args: intent_id: The mock gateway intent ID (Form field). db: Database session. Returns: RedirectResponse to the frontend success URL, or JSONResponse if the frontend URL is not configured. """ logger.info( "[MOCK_PAYMENT_CALLBACK] Received callback for intent: %s", intent_id, ) # ── Step 1: Look up the PaymentIntent ───────────────────────────────── stmt = select(PaymentIntent).where( PaymentIntent.stripe_session_id == intent_id, PaymentIntent.status == PaymentIntentStatus.PENDING, ) result = await db.execute(stmt) payment_intent = result.scalar_one_or_none() if not payment_intent: logger.warning( "[MOCK_PAYMENT_CALLBACK] PaymentIntent not found or not PENDING: " "intent_id=%s", intent_id, ) return JSONResponse( {"error": "PaymentIntent not found or not PENDING"}, status_code=404, ) logger.info( "[MOCK_PAYMENT_CALLBACK] PaymentIntent found: id=%d amount=%.2f %s", payment_intent.id, float(payment_intent.gross_amount), payment_intent.currency, ) # ── Step 2: Update PaymentIntent to COMPLETED ───────────────────────── payment_intent.status = PaymentIntentStatus.COMPLETED payment_intent.completed_at = datetime.utcnow() # ── Step 3: Activate the subscription ───────────────────────────────── # Read the purchase context from metadata (stored by FinancialManager) meta = payment_intent.meta_data or {} tier_id = meta.get("tier_id") user_id = meta.get("user_id") org_id = meta.get("org_id") duration_days = meta.get("duration_days") subscription_id = None if tier_id and user_id: activator = SubscriptionActivator() try: if org_id: org_sub = await activator.activate_org_subscription( db=db, org_id=int(org_id), tier_id=int(tier_id), duration_days=int(duration_days) if duration_days else None, ) subscription_id = org_sub.id logger.info( "[MOCK_PAYMENT_CALLBACK] Org subscription activated: " "org_id=%s sub_id=%d", org_id, org_sub.id, ) else: user_sub = await activator.activate_user_subscription( db=db, user_id=int(user_id), tier_id=int(tier_id), duration_days=int(duration_days) if duration_days else None, ) subscription_id = user_sub.id logger.info( "[MOCK_PAYMENT_CALLBACK] User subscription activated: " "user_id=%s sub_id=%d", user_id, user_sub.id, ) except Exception as e: logger.error( "[MOCK_PAYMENT_CALLBACK] Subscription activation failed: %s", str(e), ) # Don't fail the callback — the PaymentIntent is already COMPLETED # The subscription can be activated manually or via retry logic else: logger.warning( "[MOCK_PAYMENT_CALLBACK] Missing tier_id or user_id in metadata. " "Subscription was NOT activated. metadata=%s", meta, ) await db.commit() logger.info( "[MOCK_PAYMENT_CALLBACK] Payment completed successfully: " "intent=%s, payment_intent_id=%d, amount=%.2f %s, " "subscription_id=%s", intent_id, payment_intent.id, float(payment_intent.gross_amount), payment_intent.currency, subscription_id, ) # ── Step 4: Redirect to frontend ────────────────────────────────────── # In production, redirect to the frontend success page. # If FRONTEND_URL is not configured, return JSON. frontend_url = getattr(settings, "FRONTEND_URL", None) or getattr(config, "FRONTEND_URL", None) if frontend_url: redirect_url = f"{frontend_url}/dashboard/subscription?payment=success" logger.info( "[MOCK_PAYMENT_CALLBACK] Redirecting to frontend: %s", redirect_url, ) return RedirectResponse(url=redirect_url, status_code=302) # Fallback: return JSON response return JSONResponse({ "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, "message": "Payment completed successfully", })