180 lines
5.5 KiB
Python
180 lines
5.5 KiB
Python
"""결제 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
|