Files
service-finder/backend/app/api/v1/endpoints/billing.py

713 lines
28 KiB
Python
Executable File

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="<h1>Payment not found or already processed</h1>",
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"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mock Payment Gateway</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex; justify-content: center; align-items: center;
min-height: 100vh; padding: 20px;
}}
.card {{
background: white; border-radius: 16px; padding: 40px;
max-width: 420px; width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,0.3);
text-align: center;
}}
.card h1 {{ font-size: 24px; color: #1a1a2e; margin-bottom: 8px; }}
.card .subtitle {{ color: #666; font-size: 14px; margin-bottom: 24px; }}
.amount {{
font-size: 48px; font-weight: 700; color: #1a1a2e;
margin: 20px 0; padding: 20px 0;
border-top: 2px solid #f0f0f0; border-bottom: 2px solid #f0f0f0;
}}
.amount .currency {{ font-size: 24px; color: #666; }}
.details {{ text-align: left; margin: 20px 0; padding: 16px; background: #f8f9fa; border-radius: 8px; }}
.details dt {{ font-size: 12px; color: #999; text-transform: uppercase; letter-spacing: 0.5px; }}
.details dd {{ font-size: 14px; color: #333; margin-bottom: 8px; }}
.btn-pay {{
display: inline-block; padding: 14px 48px; margin-top: 16px;
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
color: white; border: none; border-radius: 8px;
font-size: 18px; font-weight: 600; cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
width: 100%;
}}
.btn-pay:hover {{ transform: translateY(-2px); box-shadow: 0 8px 24px rgba(34,197,94,0.4); }}
.btn-pay:active {{ transform: translateY(0); }}
.footer {{ margin-top: 20px; font-size: 12px; color: #999; }}
.badge {{
display: inline-block; padding: 4px 12px; border-radius: 20px;
background: #fef3c7; color: #92400e; font-size: 12px; font-weight: 500;
margin-bottom: 16px;
}}
</style>
</head>
<body>
<div class="card">
<div class="badge">🧪 Mock Gateway — Development Mode</div>
<h1>Complete Your Payment</h1>
<p class="subtitle">This is a simulated payment page for testing</p>
<div class="amount">
{amount:.2f} <span class="currency">{currency}</span>
</div>
<dl class="details">
<dt>Intent ID</dt>
<dd>{intent_id[:20]}...</dd>
<dt>Description</dt>
<dd>Subscription Package Purchase</dd>
</dl>
<form action="{callback_url}" method="POST">
<input type="hidden" name="intent_id" value="{intent_id}">
<button type="submit" class="btn-pay">
✅ Pay {amount:.2f} {currency}
</button>
</form>
<p class="footer">
🔒 Connection simulated &bull; No real payment will be charged
</p>
</div>
</body>
</html>"""
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",
})