- Frontend: Next.js 14 with TypeScript - Backend: FastAPI with SQLAlchemy - Agent: Carmodoo sync agent - Deployment: Docker Compose based staging/production setup - Scripts: Automated deployment with rollback support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
887 lines
30 KiB
Python
887 lines
30 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, Request, Header
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import desc
|
|
from typing import List, Optional
|
|
from pydantic import BaseModel
|
|
from datetime import datetime
|
|
import stripe
|
|
import logging
|
|
|
|
from ..database import get_db
|
|
from ..models import User, Car, CarView, PerformanceCheckView, ChargeHistory, CarPerformanceCheck, CCPackage, DEFAULT_CC_PACKAGES
|
|
from ..models.settings import SystemSettings
|
|
from ..models.user import PaymentSettings
|
|
from ..schemas import UserResponse, CarViewResponse, PurchaseViewRequest
|
|
from .auth import get_current_user, get_current_admin_user, get_current_user_optional
|
|
from .referral import create_referral_reward
|
|
from .carmodoo import CarmodooClient, capture_performance_check_pdf
|
|
from .notification import notify_system
|
|
from ..config import get_settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
settings = get_settings()
|
|
|
|
# Configure Stripe
|
|
stripe.api_key = settings.STRIPE_SECRET_KEY
|
|
|
|
router = APIRouter(prefix="/cc", tags=["cc"])
|
|
|
|
|
|
class ChargeRequest(BaseModel):
|
|
amount: int
|
|
currency: str = "USD"
|
|
payment_method: str = "card"
|
|
transaction_id: Optional[str] = None # For crypto payments
|
|
wallet_address: Optional[str] = None # User's wallet for refunds
|
|
|
|
|
|
class USDCChargeRequest(BaseModel):
|
|
amount_usdc: int
|
|
transaction_hash: str
|
|
wallet_address: str
|
|
network: str = "Polygon"
|
|
|
|
|
|
class ChargeHistoryResponse(BaseModel):
|
|
id: int
|
|
amount_usd: int
|
|
cc_amount: int
|
|
payment_method: str
|
|
status: str
|
|
created_at: str
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
@router.get("/balance")
|
|
def get_cc_balance(current_user: User = Depends(get_current_user)):
|
|
"""Get current user's CC balance"""
|
|
return {"cc_balance": current_user.cc_balance or 0}
|
|
|
|
|
|
@router.get("/views", response_model=List[CarViewResponse])
|
|
def get_purchased_views(
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get list of cars the user has paid to view"""
|
|
views = db.query(CarView).filter(CarView.user_id == current_user.id).all()
|
|
return views
|
|
|
|
|
|
@router.get("/views/car-ids")
|
|
def get_purchased_car_ids(
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get list of car IDs the user has paid to view (for quick lookup)"""
|
|
views = db.query(CarView.car_id).filter(CarView.user_id == current_user.id).all()
|
|
return {"car_ids": [v[0] for v in views]}
|
|
|
|
|
|
@router.post("/purchase-view")
|
|
def purchase_car_view(
|
|
request: PurchaseViewRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Purchase access to view full car details (costs 1 CC)"""
|
|
car_id = request.car_id
|
|
|
|
# Check if car exists
|
|
car = db.query(Car).filter(Car.id == car_id).first()
|
|
if not car:
|
|
raise HTTPException(status_code=404, detail="Car not found")
|
|
|
|
# Check if already purchased
|
|
existing_view = db.query(CarView).filter(
|
|
CarView.user_id == current_user.id,
|
|
CarView.car_id == car_id
|
|
).first()
|
|
|
|
if existing_view:
|
|
return {"message": "Already purchased", "cc_balance": current_user.cc_balance}
|
|
|
|
# Check if user has enough CC
|
|
if (current_user.cc_balance or 0) < 1:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Insufficient CC balance. You need 1 CC to view full car details."
|
|
)
|
|
|
|
# Deduct CC and create view record
|
|
current_user.cc_balance = (current_user.cc_balance or 0) - 1
|
|
|
|
car_view = CarView(
|
|
user_id=current_user.id,
|
|
car_id=car_id,
|
|
cc_paid=1
|
|
)
|
|
db.add(car_view)
|
|
db.commit()
|
|
|
|
return {
|
|
"message": "Purchase successful",
|
|
"cc_balance": current_user.cc_balance,
|
|
"car_id": car_id
|
|
}
|
|
|
|
|
|
@router.get("/check-view/{car_id}")
|
|
def check_car_view(
|
|
car_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Check if user has purchased view access for a specific car"""
|
|
existing_view = db.query(CarView).filter(
|
|
CarView.user_id == current_user.id,
|
|
CarView.car_id == car_id
|
|
).first()
|
|
|
|
return {
|
|
"has_access": existing_view is not None,
|
|
"cc_balance": current_user.cc_balance or 0
|
|
}
|
|
|
|
|
|
PERFORMANCE_CHECK_COST = 0.1 # 0.1 CC for performance check view
|
|
|
|
|
|
@router.post("/purchase-performance-check")
|
|
async def purchase_performance_check_view(
|
|
request: PurchaseViewRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Purchase access to view performance check (costs 0.1 CC)"""
|
|
car_id = request.car_id
|
|
|
|
# Check if car exists
|
|
car = db.query(Car).filter(Car.id == car_id).first()
|
|
if not car:
|
|
raise HTTPException(status_code=404, detail="Car not found")
|
|
|
|
# Check if performance check record exists
|
|
perf_check = db.query(CarPerformanceCheck).filter(
|
|
CarPerformanceCheck.car_id == car_id
|
|
).first()
|
|
|
|
# If no performance check record, try to fetch from Carmodoo
|
|
if not perf_check:
|
|
try:
|
|
carmodoo_client = CarmodooClient()
|
|
check_num = car.check_num or ""
|
|
|
|
# Try to get check_num if not available
|
|
if not check_num:
|
|
check_num = await carmodoo_client.get_car_check_num(car.source_id, car.source_key or "")
|
|
|
|
if check_num:
|
|
# Fetch performance check data
|
|
perf_result = await carmodoo_client.get_performance_check(car.source_id, car.source_key or "", check_num)
|
|
|
|
if perf_result.get("found") and perf_result.get("data"):
|
|
perf_data = perf_result["data"]
|
|
# Create CarPerformanceCheck record
|
|
perf_check = CarPerformanceCheck(
|
|
car_id=car.id,
|
|
check_number=perf_data.get("check_number") or check_num,
|
|
check_date=perf_data.get("check_date"),
|
|
valid_until=perf_data.get("valid_until"),
|
|
first_registration=perf_data.get("first_registration"),
|
|
mileage=perf_data.get("mileage"),
|
|
mileage_status=perf_data.get("mileage_status"),
|
|
seize_count=perf_data.get("seize_count", 0),
|
|
collateral_count=perf_data.get("collateral_count", 0),
|
|
is_flood_damaged=perf_data.get("is_flood_damaged", False),
|
|
is_fire_damaged=perf_data.get("is_fire_damaged", False),
|
|
is_total_loss=perf_data.get("is_total_loss", False),
|
|
engine_status=perf_data.get("engine_status"),
|
|
transmission_status=perf_data.get("transmission_status"),
|
|
power_delivery_status=perf_data.get("power_delivery_status"),
|
|
raw_data=perf_data,
|
|
raw_html=perf_result.get("raw_html", "")[:50000],
|
|
)
|
|
db.add(perf_check)
|
|
db.flush()
|
|
|
|
# Capture PDF
|
|
try:
|
|
pdf_path = await capture_performance_check_pdf(perf_check.check_number, car.id)
|
|
if pdf_path:
|
|
perf_check.pdf_path = pdf_path
|
|
except Exception as pdf_error:
|
|
logger.warning(f"PDF capture failed: {pdf_error}")
|
|
|
|
db.commit()
|
|
db.refresh(perf_check)
|
|
except Exception as e:
|
|
logger.error(f"Failed to fetch performance check: {e}")
|
|
|
|
if not perf_check:
|
|
raise HTTPException(status_code=404, detail="Performance check not available for this car")
|
|
|
|
# Check if already purchased
|
|
existing_view = db.query(PerformanceCheckView).filter(
|
|
PerformanceCheckView.user_id == current_user.id,
|
|
PerformanceCheckView.car_id == car_id
|
|
).first()
|
|
|
|
if existing_view:
|
|
return {"message": "Already purchased", "cc_balance": current_user.cc_balance}
|
|
|
|
# Check if user has enough CC
|
|
if (current_user.cc_balance or 0) < PERFORMANCE_CHECK_COST:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Insufficient CC balance. You need {PERFORMANCE_CHECK_COST} CC to view performance check."
|
|
)
|
|
|
|
# Deduct CC and create view record
|
|
current_user.cc_balance = (current_user.cc_balance or 0) - PERFORMANCE_CHECK_COST
|
|
|
|
perf_view = PerformanceCheckView(
|
|
user_id=current_user.id,
|
|
car_id=car_id,
|
|
cc_paid=PERFORMANCE_CHECK_COST
|
|
)
|
|
db.add(perf_view)
|
|
db.commit()
|
|
|
|
return {
|
|
"message": "Purchase successful",
|
|
"cc_balance": current_user.cc_balance,
|
|
"car_id": car_id
|
|
}
|
|
|
|
|
|
@router.get("/check-performance-check/{car_id}")
|
|
def check_performance_check_view(
|
|
car_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Check if user has purchased performance check view for a specific car"""
|
|
# Check if performance check exists for this car
|
|
perf_check = db.query(CarPerformanceCheck).filter(
|
|
CarPerformanceCheck.car_id == car_id
|
|
).first()
|
|
|
|
# Check 1: Purchased performance check (0.1 CC)
|
|
existing_perf_view = db.query(PerformanceCheckView).filter(
|
|
PerformanceCheckView.user_id == current_user.id,
|
|
PerformanceCheckView.car_id == car_id
|
|
).first()
|
|
|
|
# Check 2: Purchased full car view (1 CC) -> performance check included free
|
|
existing_car_view = db.query(CarView).filter(
|
|
CarView.user_id == current_user.id,
|
|
CarView.car_id == car_id
|
|
).first()
|
|
|
|
has_access = (existing_perf_view is not None) or (existing_car_view is not None)
|
|
|
|
return {
|
|
"has_access": has_access,
|
|
"has_performance_check": perf_check is not None,
|
|
"cc_balance": current_user.cc_balance or 0,
|
|
"cost": PERFORMANCE_CHECK_COST,
|
|
"included_in_car_view": existing_car_view is not None # True if already purchased car view
|
|
}
|
|
|
|
|
|
@router.get("/payment-info")
|
|
def get_payment_info():
|
|
"""Get payment information including USDC wallet address"""
|
|
return {
|
|
"usdc_wallet_address": PaymentSettings.USDC_WALLET_ADDRESS,
|
|
"usdc_network": PaymentSettings.USDC_NETWORK,
|
|
"min_charge_usd": PaymentSettings.MIN_CHARGE_USD,
|
|
"max_charge_usd": PaymentSettings.MAX_CHARGE_USD,
|
|
"supported_currencies": PaymentSettings.SUPPORTED_CURRENCIES,
|
|
"supported_methods": PaymentSettings.SUPPORTED_METHODS,
|
|
"rate": "1 USD = 1 CC",
|
|
}
|
|
|
|
|
|
@router.post("/charge")
|
|
def charge_cc(
|
|
request: ChargeRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Create a charge request (for card or bank transfer - requires admin verification)"""
|
|
# Validate amount
|
|
if request.amount < PaymentSettings.MIN_CHARGE_USD:
|
|
raise HTTPException(status_code=400, detail=f"Minimum charge amount is ${PaymentSettings.MIN_CHARGE_USD}")
|
|
|
|
if request.amount > PaymentSettings.MAX_CHARGE_USD:
|
|
raise HTTPException(status_code=400, detail=f"Maximum charge amount is ${PaymentSettings.MAX_CHARGE_USD}")
|
|
|
|
# Calculate CC amount (1 USD = 1 CC)
|
|
cc_amount = request.amount
|
|
|
|
# Determine status based on payment method
|
|
# Card payments would go through a payment gateway (not implemented yet)
|
|
# USDC and bank transfers require manual verification
|
|
status = "pending" if request.payment_method in ["usdc", "bank_transfer"] else "pending"
|
|
|
|
# Create charge history record
|
|
charge_record = ChargeHistory(
|
|
user_id=current_user.id,
|
|
amount=request.amount,
|
|
amount_usd=request.amount, # Backwards compatibility
|
|
cc_amount=cc_amount,
|
|
currency=request.currency,
|
|
payment_method=request.payment_method,
|
|
transaction_id=request.transaction_id,
|
|
wallet_address=request.wallet_address,
|
|
status=status
|
|
)
|
|
db.add(charge_record)
|
|
db.commit()
|
|
db.refresh(charge_record)
|
|
|
|
return {
|
|
"message": "Charge request created" if status == "pending" else "Charge successful",
|
|
"charge_id": charge_record.id,
|
|
"amount": request.amount,
|
|
"currency": request.currency,
|
|
"cc_amount": cc_amount,
|
|
"status": status,
|
|
"payment_info": {
|
|
"usdc_wallet": PaymentSettings.USDC_WALLET_ADDRESS if request.payment_method == "usdc" else None,
|
|
"network": PaymentSettings.USDC_NETWORK if request.payment_method == "usdc" else None,
|
|
}
|
|
}
|
|
|
|
|
|
@router.post("/charge/usdc")
|
|
def charge_cc_usdc(
|
|
request: USDCChargeRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Create USDC charge request with transaction hash"""
|
|
# Validate amount
|
|
if request.amount_usdc < PaymentSettings.MIN_CHARGE_USD:
|
|
raise HTTPException(status_code=400, detail=f"Minimum charge amount is {PaymentSettings.MIN_CHARGE_USD} USDC")
|
|
|
|
if request.amount_usdc > PaymentSettings.MAX_CHARGE_USD:
|
|
raise HTTPException(status_code=400, detail=f"Maximum charge amount is {PaymentSettings.MAX_CHARGE_USD} USDC")
|
|
|
|
# Check for duplicate transaction
|
|
existing = db.query(ChargeHistory).filter(
|
|
ChargeHistory.transaction_id == request.transaction_hash
|
|
).first()
|
|
if existing:
|
|
raise HTTPException(status_code=400, detail="This transaction has already been submitted")
|
|
|
|
# Create pending charge record
|
|
charge_record = ChargeHistory(
|
|
user_id=current_user.id,
|
|
amount=request.amount_usdc,
|
|
amount_usd=request.amount_usdc,
|
|
cc_amount=request.amount_usdc,
|
|
currency="USDC",
|
|
payment_method="usdc",
|
|
transaction_id=request.transaction_hash,
|
|
wallet_address=request.wallet_address,
|
|
status="pending"
|
|
)
|
|
db.add(charge_record)
|
|
db.commit()
|
|
db.refresh(charge_record)
|
|
|
|
return {
|
|
"message": "USDC payment submitted for verification",
|
|
"charge_id": charge_record.id,
|
|
"amount_usdc": request.amount_usdc,
|
|
"cc_amount": request.amount_usdc,
|
|
"status": "pending",
|
|
"transaction_hash": request.transaction_hash
|
|
}
|
|
|
|
|
|
@router.get("/charge-history")
|
|
def get_charge_history(
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get user's charge history"""
|
|
history = db.query(ChargeHistory).filter(
|
|
ChargeHistory.user_id == current_user.id
|
|
).order_by(desc(ChargeHistory.created_at)).limit(50).all()
|
|
|
|
return [
|
|
{
|
|
"id": h.id,
|
|
"amount": h.amount or h.amount_usd,
|
|
"amount_usd": h.amount_usd,
|
|
"currency": h.currency or "USD",
|
|
"cc_amount": h.cc_amount,
|
|
"payment_method": h.payment_method,
|
|
"transaction_id": h.transaction_id,
|
|
"status": h.status,
|
|
"created_at": h.created_at.isoformat() if h.created_at else None
|
|
}
|
|
for h in history
|
|
]
|
|
|
|
|
|
# Admin endpoints for payment verification
|
|
@router.get("/admin/pending")
|
|
def admin_get_pending_payments(
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_admin_user)
|
|
):
|
|
"""Get all pending payment requests (Admin only)"""
|
|
pending = db.query(ChargeHistory).filter(
|
|
ChargeHistory.status == "pending"
|
|
).order_by(desc(ChargeHistory.created_at)).all()
|
|
|
|
return [
|
|
{
|
|
"id": h.id,
|
|
"user_id": h.user_id,
|
|
"user_email": h.user.email if h.user else None,
|
|
"user_name": h.user.name if h.user else None,
|
|
"amount": h.amount or h.amount_usd,
|
|
"currency": h.currency or "USD",
|
|
"cc_amount": h.cc_amount,
|
|
"payment_method": h.payment_method,
|
|
"transaction_id": h.transaction_id,
|
|
"wallet_address": h.wallet_address,
|
|
"status": h.status,
|
|
"created_at": h.created_at.isoformat() if h.created_at else None
|
|
}
|
|
for h in pending
|
|
]
|
|
|
|
|
|
@router.get("/admin/all")
|
|
def admin_get_all_payments(
|
|
status: str = None,
|
|
page: int = 1,
|
|
page_size: int = 20,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_admin_user)
|
|
):
|
|
"""Get all payment records with optional status filter (Admin only)"""
|
|
query = db.query(ChargeHistory)
|
|
|
|
if status:
|
|
query = query.filter(ChargeHistory.status == status)
|
|
|
|
total = query.count()
|
|
payments = query.order_by(desc(ChargeHistory.created_at)).offset((page - 1) * page_size).limit(page_size).all()
|
|
|
|
return {
|
|
"payments": [
|
|
{
|
|
"id": h.id,
|
|
"user_id": h.user_id,
|
|
"user_email": h.user.email if h.user else None,
|
|
"user_name": h.user.name if h.user else None,
|
|
"amount": h.amount or h.amount_usd,
|
|
"currency": h.currency or "USD",
|
|
"cc_amount": h.cc_amount,
|
|
"payment_method": h.payment_method,
|
|
"transaction_id": h.transaction_id,
|
|
"wallet_address": h.wallet_address,
|
|
"admin_note": h.admin_note,
|
|
"status": h.status,
|
|
"verified_at": h.verified_at.isoformat() if h.verified_at else None,
|
|
"created_at": h.created_at.isoformat() if h.created_at else None
|
|
}
|
|
for h in payments
|
|
],
|
|
"total": total,
|
|
"page": page,
|
|
"page_size": page_size
|
|
}
|
|
|
|
|
|
@router.put("/admin/{charge_id}/verify")
|
|
def admin_verify_payment(
|
|
charge_id: int,
|
|
approved: bool,
|
|
admin_note: str = None,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_admin_user)
|
|
):
|
|
"""Verify and approve/reject a pending payment (Admin only)"""
|
|
charge = db.query(ChargeHistory).filter(ChargeHistory.id == charge_id).first()
|
|
if not charge:
|
|
raise HTTPException(status_code=404, detail="Charge record not found")
|
|
|
|
if charge.status != "pending":
|
|
raise HTTPException(status_code=400, detail=f"Charge is already {charge.status}")
|
|
|
|
if approved:
|
|
charge.status = "completed"
|
|
charge.verified_at = datetime.utcnow()
|
|
charge.verified_by = current_user.id
|
|
charge.admin_note = admin_note
|
|
|
|
# Credit CC to user
|
|
user = db.query(User).filter(User.id == charge.user_id).first()
|
|
if user:
|
|
user.cc_balance = (user.cc_balance or 0) + charge.cc_amount
|
|
|
|
# Trigger referral reward if applicable
|
|
if user.referred_by:
|
|
referrer = db.query(User).filter(
|
|
User.referral_code == user.referred_by
|
|
).first()
|
|
if referrer:
|
|
create_referral_reward(
|
|
referrer_id=referrer.id,
|
|
referred_user_id=user.id,
|
|
payment_amount=charge.amount_usd or charge.amount,
|
|
db=db
|
|
)
|
|
|
|
# Send notification to user
|
|
notify_system(
|
|
db,
|
|
user.id,
|
|
"Payment Confirmed",
|
|
f"Your payment of {charge.amount} {charge.currency or 'USD'} has been confirmed. {charge.cc_amount} CC has been added to your balance.",
|
|
"/profile"
|
|
)
|
|
else:
|
|
charge.status = "rejected"
|
|
charge.verified_at = datetime.utcnow()
|
|
charge.verified_by = current_user.id
|
|
charge.admin_note = admin_note
|
|
|
|
# Send notification to user
|
|
user = db.query(User).filter(User.id == charge.user_id).first()
|
|
if user:
|
|
notify_system(
|
|
db,
|
|
user.id,
|
|
"Payment Rejected",
|
|
f"Your payment request for {charge.amount} {charge.currency or 'USD'} was rejected. Reason: {admin_note or 'No reason provided'}",
|
|
"/profile"
|
|
)
|
|
|
|
db.commit()
|
|
|
|
return {
|
|
"message": f"Payment {'approved' if approved else 'rejected'}",
|
|
"charge_id": charge_id,
|
|
"new_status": charge.status
|
|
}
|
|
|
|
|
|
# ============================================
|
|
# Stripe Payment Endpoints
|
|
# ============================================
|
|
|
|
class CreateCheckoutRequest(BaseModel):
|
|
package_id: int
|
|
|
|
|
|
@router.get("/stripe-key")
|
|
def get_stripe_publishable_key():
|
|
"""Get Stripe publishable key for frontend"""
|
|
return {"publishable_key": settings.STRIPE_PUBLISHABLE_KEY}
|
|
|
|
|
|
@router.get("/packages")
|
|
def get_cc_packages(db: Session = Depends(get_db)):
|
|
"""Get available CC packages"""
|
|
# Get system settings for cars_per_cc
|
|
system_settings = db.query(SystemSettings).first()
|
|
cars_per_cc = system_settings.cars_per_cc if system_settings and system_settings.cars_per_cc else 3
|
|
|
|
# First try to get from database
|
|
packages = db.query(CCPackage).filter(CCPackage.is_active == True).order_by(CCPackage.sort_order).all()
|
|
|
|
# If no packages in DB, initialize with defaults
|
|
if not packages:
|
|
for pkg_data in DEFAULT_CC_PACKAGES:
|
|
pkg = CCPackage(**pkg_data)
|
|
db.add(pkg)
|
|
db.commit()
|
|
packages = db.query(CCPackage).filter(CCPackage.is_active == True).order_by(CCPackage.sort_order).all()
|
|
|
|
return [
|
|
{
|
|
"id": pkg.id,
|
|
"name": pkg.name,
|
|
"price_usd": pkg.price_usd,
|
|
"cc_amount": pkg.cc_amount,
|
|
"bonus_cc": pkg.bonus_cc,
|
|
"total_cc": pkg.cc_amount + pkg.bonus_cc,
|
|
"discount_percent": pkg.discount_percent,
|
|
"recommendations": (pkg.cc_amount + pkg.bonus_cc) * cars_per_cc,
|
|
"cars_per_cc": cars_per_cc, # 프론트엔드에서 표시용
|
|
}
|
|
for pkg in packages
|
|
]
|
|
|
|
|
|
@router.post("/create-checkout-session")
|
|
def create_checkout_session(
|
|
request: CreateCheckoutRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Create Stripe checkout session for CC purchase"""
|
|
if not settings.STRIPE_SECRET_KEY:
|
|
raise HTTPException(status_code=500, detail="Stripe is not configured")
|
|
|
|
# Get package
|
|
package = db.query(CCPackage).filter(CCPackage.id == request.package_id).first()
|
|
if not package:
|
|
raise HTTPException(status_code=404, detail="Package not found")
|
|
|
|
if not package.is_active:
|
|
raise HTTPException(status_code=400, detail="This package is no longer available")
|
|
|
|
try:
|
|
# Create Stripe Checkout Session
|
|
checkout_session = stripe.checkout.Session.create(
|
|
payment_method_types=["card"],
|
|
line_items=[
|
|
{
|
|
"price_data": {
|
|
"currency": "usd",
|
|
"unit_amount": package.price_usd * 100, # Stripe uses cents
|
|
"product_data": {
|
|
"name": f"AutonetSellCar CC - {package.name}",
|
|
"description": f"{package.cc_amount + package.bonus_cc} CC ({package.cc_amount} + {package.bonus_cc} bonus)",
|
|
},
|
|
},
|
|
"quantity": 1,
|
|
}
|
|
],
|
|
mode="payment",
|
|
success_url=f"{settings.STRIPE_SUCCESS_URL}?session_id={{CHECKOUT_SESSION_ID}}",
|
|
cancel_url=settings.STRIPE_CANCEL_URL,
|
|
client_reference_id=str(current_user.id),
|
|
metadata={
|
|
"user_id": str(current_user.id),
|
|
"package_id": str(package.id),
|
|
"cc_amount": str(package.cc_amount),
|
|
"bonus_cc": str(package.bonus_cc),
|
|
},
|
|
customer_email=current_user.email,
|
|
)
|
|
|
|
# Create pending charge record
|
|
charge_record = ChargeHistory(
|
|
user_id=current_user.id,
|
|
package_id=package.id,
|
|
amount=package.price_usd,
|
|
amount_usd=package.price_usd,
|
|
cc_amount=package.cc_amount,
|
|
bonus_cc=package.bonus_cc,
|
|
currency="USD",
|
|
payment_method="stripe",
|
|
stripe_session_id=checkout_session.id,
|
|
status="pending"
|
|
)
|
|
db.add(charge_record)
|
|
db.commit()
|
|
|
|
return {
|
|
"checkout_url": checkout_session.url,
|
|
"session_id": checkout_session.id
|
|
}
|
|
|
|
except stripe.error.StripeError as e:
|
|
logger.error(f"Stripe error: {e}")
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
@router.post("/webhook")
|
|
async def stripe_webhook(
|
|
request: Request,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Handle Stripe webhook events"""
|
|
payload = await request.body()
|
|
sig_header = request.headers.get("stripe-signature")
|
|
|
|
if not settings.STRIPE_WEBHOOK_SECRET:
|
|
logger.warning("Stripe webhook secret not configured")
|
|
raise HTTPException(status_code=500, detail="Webhook not configured")
|
|
|
|
try:
|
|
event = stripe.Webhook.construct_event(
|
|
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
|
|
)
|
|
except ValueError as e:
|
|
logger.error(f"Invalid payload: {e}")
|
|
raise HTTPException(status_code=400, detail="Invalid payload")
|
|
except stripe.error.SignatureVerificationError as e:
|
|
logger.error(f"Invalid signature: {e}")
|
|
raise HTTPException(status_code=400, detail="Invalid signature")
|
|
|
|
# Handle the checkout.session.completed event
|
|
if event["type"] == "checkout.session.completed":
|
|
session = event["data"]["object"]
|
|
|
|
# Get charge record by session ID
|
|
charge = db.query(ChargeHistory).filter(
|
|
ChargeHistory.stripe_session_id == session["id"]
|
|
).first()
|
|
|
|
if charge and charge.status == "pending":
|
|
# Update charge record
|
|
charge.status = "completed"
|
|
charge.stripe_payment_intent_id = session.get("payment_intent")
|
|
charge.verified_at = datetime.utcnow()
|
|
|
|
# Credit CC to user
|
|
user = db.query(User).filter(User.id == charge.user_id).first()
|
|
if user:
|
|
total_cc = charge.cc_amount + (charge.bonus_cc or 0)
|
|
user.cc_balance = (user.cc_balance or 0) + total_cc
|
|
|
|
# Trigger referral reward if applicable
|
|
if user.referred_by:
|
|
referrer = db.query(User).filter(
|
|
User.referral_code == user.referred_by
|
|
).first()
|
|
if referrer:
|
|
create_referral_reward(
|
|
referrer_id=referrer.id,
|
|
referred_user_id=user.id,
|
|
payment_amount=charge.amount_usd or charge.amount,
|
|
db=db
|
|
)
|
|
|
|
# Send notification
|
|
notify_system(
|
|
db,
|
|
user.id,
|
|
"CC Purchase Successful",
|
|
f"Your purchase of {total_cc} CC has been completed. Your new balance is {user.cc_balance} CC.",
|
|
"/cc"
|
|
)
|
|
|
|
logger.info(f"CC credited: user={user.id}, amount={total_cc}")
|
|
|
|
db.commit()
|
|
|
|
elif event["type"] == "checkout.session.expired":
|
|
session = event["data"]["object"]
|
|
|
|
# Update charge record to cancelled
|
|
charge = db.query(ChargeHistory).filter(
|
|
ChargeHistory.stripe_session_id == session["id"]
|
|
).first()
|
|
|
|
if charge and charge.status == "pending":
|
|
charge.status = "cancelled"
|
|
db.commit()
|
|
|
|
return {"status": "success"}
|
|
|
|
|
|
@router.get("/checkout-success")
|
|
def checkout_success(
|
|
session_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Verify checkout session and return result"""
|
|
# Find charge record
|
|
charge = db.query(ChargeHistory).filter(
|
|
ChargeHistory.stripe_session_id == session_id,
|
|
ChargeHistory.user_id == current_user.id
|
|
).first()
|
|
|
|
if not charge:
|
|
raise HTTPException(status_code=404, detail="Payment record not found")
|
|
|
|
# If still pending, try to verify with Stripe
|
|
if charge.status == "pending":
|
|
try:
|
|
session = stripe.checkout.Session.retrieve(session_id)
|
|
if session.payment_status == "paid":
|
|
charge.status = "completed"
|
|
charge.stripe_payment_intent_id = session.payment_intent
|
|
charge.verified_at = datetime.utcnow()
|
|
|
|
# Credit CC
|
|
total_cc = charge.cc_amount + (charge.bonus_cc or 0)
|
|
current_user.cc_balance = (current_user.cc_balance or 0) + total_cc
|
|
|
|
db.commit()
|
|
except stripe.error.StripeError as e:
|
|
logger.error(f"Error verifying session: {e}")
|
|
|
|
return {
|
|
"status": charge.status,
|
|
"cc_amount": charge.cc_amount,
|
|
"bonus_cc": charge.bonus_cc or 0,
|
|
"total_cc": charge.cc_amount + (charge.bonus_cc or 0),
|
|
"cc_balance": current_user.cc_balance or 0
|
|
}
|
|
|
|
|
|
# Manual CC charge request (for Russian users via Mongolian partner)
|
|
class ManualChargeRequest(BaseModel):
|
|
package_id: int
|
|
payment_note: Optional[str] = None # e.g., "Paid via Mongolian partner bank"
|
|
|
|
|
|
@router.post("/manual-request")
|
|
def create_manual_charge_request(
|
|
request: ManualChargeRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Create manual CC charge request (for Russian users)"""
|
|
# Get package
|
|
package = db.query(CCPackage).filter(CCPackage.id == request.package_id).first()
|
|
if not package:
|
|
raise HTTPException(status_code=404, detail="Package not found")
|
|
|
|
# Create pending charge record
|
|
charge_record = ChargeHistory(
|
|
user_id=current_user.id,
|
|
package_id=package.id,
|
|
amount=package.price_usd,
|
|
amount_usd=package.price_usd,
|
|
cc_amount=package.cc_amount,
|
|
bonus_cc=package.bonus_cc,
|
|
currency="USD",
|
|
payment_method="manual",
|
|
admin_note=request.payment_note,
|
|
status="pending"
|
|
)
|
|
db.add(charge_record)
|
|
db.commit()
|
|
db.refresh(charge_record)
|
|
|
|
# Notify admins
|
|
admins = db.query(User).filter(User.is_admin == True).all()
|
|
for admin in admins:
|
|
notify_system(
|
|
db,
|
|
admin.id,
|
|
"New Manual CC Request",
|
|
f"User {current_user.email} requested {package.cc_amount} CC (${package.price_usd}). Payment method: manual.",
|
|
"/admin/cc"
|
|
)
|
|
|
|
return {
|
|
"message": "Manual charge request created. An admin will verify your payment.",
|
|
"charge_id": charge_record.id,
|
|
"package": {
|
|
"name": package.name,
|
|
"price_usd": package.price_usd,
|
|
"cc_amount": package.cc_amount + package.bonus_cc
|
|
},
|
|
"status": "pending"
|
|
}
|