pénzügyi modul továbbfejlesztése (csomagkezelés)
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request, Header, Query
|
||||
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
|
||||
@@ -13,6 +15,8 @@ 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__)
|
||||
@@ -407,3 +411,302 @@ async def get_wallet_transactions(
|
||||
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 • 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",
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user