"""결제 API — 토스페이먼츠 연동 결제 흐름: 1. POST /payments/prepare → orderId 발급 + Redis에 임시 저장 2. 프론트에서 토스 결제 UI 호출 → 사용자 카드 입력 3. POST /payments/confirm → 백엔드에서 토스 confirm API 호출 4. 승인 성공 → 세션 상태 AUTHORIZED → 충전 시작 가능 """ import uuid import json import logging from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models import ChargingSession, Payment, SessionStatus, PaymentStatus from app.schemas import PaymentRequest, PaymentConfirm, PaymentOut from app.services.payment import toss_service from app.config import get_settings logger = logging.getLogger(__name__) settings = get_settings() router = APIRouter(prefix="/payments", tags=["결제"]) @router.post("/prepare") async def prepare_payment( data: PaymentRequest, db: AsyncSession = Depends(get_db), ): """결제 준비 — orderId 발급 프론트에서 토스 결제 UI를 호출하기 전에 호출. orderId와 클라이언트 키를 반환. """ # 세션 확인 result = await db.execute( select(ChargingSession).where( ChargingSession.session_uid == data.session_uid ) ) session = result.scalar_one_or_none() if not session: raise HTTPException(404, "세션 없음") if session.status != SessionStatus.PENDING: raise HTTPException(400, f"결제 불가 상태: {session.status}") # 주문 ID 생성 order_id = f"EV-{uuid.uuid4().hex[:16].upper()}" # Payment 레코드 생성 payment = Payment( session_id=session.id, order_id=order_id, amount=data.amount, status=PaymentStatus.PENDING, ) db.add(payment) await db.flush() return { "order_id": order_id, "amount": data.amount, "session_uid": data.session_uid, "client_key": settings.TOSS_CLIENT_KEY, "customer_name": "EV 충전", "order_name": f"전기차 충전 ({data.amount:,}원)", } @router.post("/confirm", response_model=PaymentOut) async def confirm_payment( data: PaymentConfirm, db: AsyncSession = Depends(get_db), ): """결제 최종 승인 프론트에서 토스 결제 완료 후 paymentKey, orderId, amount 전달. 백엔드에서 토스 confirm API 호출 → 실제 청구 확정. 성공 시 세션 상태를 AUTHORIZED로 변경. """ # Payment 조회 result = await db.execute( select(Payment).where(Payment.order_id == data.order_id) ) payment = result.scalar_one_or_none() if not payment: raise HTTPException(404, f"주문 없음: {data.order_id}") # 금액 검증 if payment.amount != data.amount: raise HTTPException(400, "결제 금액 불일치") # 토스페이먼츠 최종 승인 toss_result = await toss_service.confirm_payment( payment_key=data.payment_key, order_id=data.order_id, amount=data.amount, ) if not toss_result["success"]: payment.status = PaymentStatus.FAILED raise HTTPException(400, toss_result.get("message", "결제 승인 실패")) # 결제 성공 처리 toss_data = toss_result["data"] payment.payment_key = data.payment_key payment.status = PaymentStatus.CONFIRMED payment.method = toss_data.get("method", "") payment.card_company = ( toss_data.get("card", {}).get("company", "") if toss_data.get("card") else "" ) payment.confirmed_at = datetime.now(timezone.utc) # 세션 상태 → AUTHORIZED (충전 시작 가능) session = await db.get(ChargingSession, payment.session_id) session.status = SessionStatus.AUTHORIZED logger.info( f"결제 승인 완료: {data.order_id} / {data.amount}원 → " f"세션 {session.session_uid} AUTHORIZED" ) await db.flush() await db.refresh(payment) return payment @router.post("/{order_id}/cancel") async def cancel_payment( order_id: str, cancel_reason: str = "사용자 취소", db: AsyncSession = Depends(get_db), ): """결제 취소""" result = await db.execute( select(Payment).where(Payment.order_id == order_id) ) payment = result.scalar_one_or_none() if not payment: raise HTTPException(404, "주문 없음") if not payment.payment_key: raise HTTPException(400, "승인된 결제가 아닙니다") cancel_result = await toss_service.cancel_payment( payment_key=payment.payment_key, cancel_reason=cancel_reason, ) if not cancel_result["success"]: raise HTTPException(400, cancel_result.get("message", "취소 실패")) payment.status = PaymentStatus.REFUNDED payment.refunded_at = datetime.now(timezone.utc) # 세션 취소 session = await db.get(ChargingSession, payment.session_id) session.status = SessionStatus.CANCELLED return {"message": "결제 취소 완료", "order_id": order_id} @router.get("/{order_id}", response_model=PaymentOut) async def get_payment( order_id: str, db: AsyncSession = Depends(get_db), ): """결제 상세 조회""" result = await db.execute( select(Payment).where(Payment.order_id == order_id) ) payment = result.scalar_one_or_none() if not payment: raise HTTPException(404, "주문 없음") return payment