diff --git a/payments/mixins.py b/payments/mixins.py index bb7fd3a..c0cfbe8 100644 --- a/payments/mixins.py +++ b/payments/mixins.py @@ -206,8 +206,11 @@ def refund_payment(self, order, payment): if not payment or payment.payment_status != "completed": raise ValidationError("해당 주문에 대한 완료된 결제를 찾을 수 없습니다.") + if payment.paid_at is None: + raise ValidationError("결제 완료 시간이 기록되지 않았습니다.") + if timezone.now() - payment.paid_at > timezone.timedelta(days=7): - raise ValidationError("결제 후 7일이 지나 취소할 수 없습니다.") + raise ValidationError("결제 후 7일이 지난 주문은 환불할 수 없습니다.") try: self.kakao_pay_service.refund_payment(payment) diff --git a/payments/tests/test_payments_views.py b/payments/tests/test_payments_views.py index bba6c5b..d56b3cb 100644 --- a/payments/tests/test_payments_views.py +++ b/payments/tests/test_payments_views.py @@ -1,8 +1,8 @@ import pytest +from django.utils import timezone from django.urls import reverse from rest_framework import status from unittest.mock import patch -from payments.models import CartItem @pytest.mark.django_db @@ -41,12 +41,35 @@ def test_주문_생성_실패_빈_장바구니(self, api_client, user): @pytest.mark.django_db class Test결제뷰: + @patch("payments.mixins.KakaoPayService.request_payment") + def test_결제_요청(self, mock_request_payment, api_client, user, order): + mock_request_payment.return_value = { + "next_redirect_pc_url": "http://test-redirect-url.com", + "next_redirect_mobile_url": "http://test-redirect-url.com", + "next_redirect_app_url": "http://test-redirect-url.com", + "tid": "test_transaction_id", + } + api_client.force_authenticate(user=user) + url = reverse("payments:payment") + response = api_client.post(url) + print(f"\n결제 요청 테스트") + print(f"URL: {url}") + print(f"응답 상태 코드: {response.status_code}") + print(f"응답 데이터: {response.data}") + assert response.status_code == status.HTTP_201_CREATED + assert "payment" in response.data + assert "next_redirect_pc_url" in response.data + @patch("payments.mixins.KakaoPayService.approve_payment") def test_결제_승인_성공(self, mock_approve_payment, api_client, user, payment): - api_client.force_authenticate(user=user) mock_approve_payment.return_value = {"amount": {"total": 10000}} + api_client.force_authenticate(user=user) url = reverse("payments:payment") response = api_client.get(url, {"result": "success", "pg_token": "test_token"}) + print(f"\n결제 승인 테스트") + print(f"URL: {url}") + print(f"응답 상태 코드: {response.status_code}") + print(f"응답 데이터: {response.data}") assert response.status_code == status.HTTP_200_OK assert response.data["detail"] == "결제가 성공적으로 완료되었습니다." @@ -54,9 +77,28 @@ def test_결제_취소(self, api_client, user, payment): api_client.force_authenticate(user=user) url = reverse("payments:payment") response = api_client.get(url, {"result": "cancel"}) + print(f"\n결제 취소 테스트") + print(f"URL: {url}") + print(f"응답 상태 코드: {response.status_code}") + print(f"응답 데이터: {response.data}") assert response.status_code == status.HTTP_200_OK assert "결제 과정이 취소되었습니다" in response.data["detail"] + @patch("payments.mixins.KakaoPayService.refund_payment") + def test_결제_환불(self, mock_refund_payment, api_client, user, completed_payment): + mock_refund_payment.return_value = {"status": "CANCEL_PAYMENT"} + completed_payment.paid_at = timezone.now() + completed_payment.save() + api_client.force_authenticate(user=user) + url = reverse("payments:payment-cancel", args=[completed_payment.order.id]) + response = api_client.delete(url) + print(f"\n결제 환불 테스트") + print(f"URL: {url}") + print(f"응답 상태 코드: {response.status_code}") + print(f"응답 데이터: {response.data}") + assert response.status_code == status.HTTP_200_OK + assert "결제가 성공적으로 환불되었습니다" in response.data["detail"] + @pytest.mark.django_db class Test영수증뷰: diff --git a/payments/urls.py b/payments/urls.py index 9c59162..0a1bd00 100644 --- a/payments/urls.py +++ b/payments/urls.py @@ -32,6 +32,11 @@ PaymentView.as_view(), name="payment", ), + path( + "payments//cancel/", + PaymentView.as_view(), + name="payment-cancel", + ), # 영수증 관련 URLs path("receipts/", ReceiptView.as_view(), name="receipt-list"), path( diff --git a/payments/views.py b/payments/views.py index fba85d7..5db952a 100644 --- a/payments/views.py +++ b/payments/views.py @@ -6,7 +6,7 @@ from rest_framework.permissions import IsAuthenticated from drf_spectacular.utils import extend_schema, extend_schema_view -from .models import CartItem, Order, UserBillingAddress +from .models import CartItem, Order, UserBillingAddress, Payment from .serializers import ( CartItemSerializer, CartSerializer, @@ -254,7 +254,7 @@ class PaymentView(PaymentMixin, OrderMixin, generics.GenericAPIView): [POST /payments/]: 현재 진행 중인 주문에 대한 결제를 생성하고 카카오페이 결제를 요청합니다. [GET /payments/]: 카카오페이 결제 결과를 처리합니다. - [DELETE /payments/]: 결제를 취소하고 환불을 처리합니다. + [DELETE /payments//cancel/]: 결제를 취소하고 환불을 처리합니다. """ serializer_class = PaymentSerializer @@ -265,7 +265,7 @@ def get_queryset(self): @transaction.atomic def post(self, request): - order = self.get_queryset().select_for_update().first() + order = self.get_queryset().filter(order_status="pending").select_for_update().first() if not order: return Response( {"detail": "진행 중인 주문이 없습니다."}, @@ -290,14 +290,35 @@ def post(self, request): @transaction.atomic def get(self, request): - order = self.get_queryset().select_for_update().first() + order = ( + self.get_queryset() + .filter(order_status="pending") + .select_for_update() + .first() + ) if not order: return Response( {"detail": "진행 중인 주문이 없습니다."}, status=status.HTTP_404_NOT_FOUND, ) - payment = self.get_payment(request.user, order=order) + # 가장 최근의 pending payment를 가져오고 나머지는 취소 처리 + payments = list( + Payment.objects.filter(order=order, payment_status="pending").order_by( + "-created_at" + ) + ) + if not payments: + return Response( + {"detail": "해당 주문에 대한 대기 중인 결제를 찾을 수 없습니다."}, + status=status.HTTP_404_NOT_FOUND, + ) + payment = payments[0] + # 가장 최근의 pending payment를 제외한 나머지 payment를 취소 처리 + if len(payments) > 1: + Payment.objects.filter(id__in=[p.id for p in payments[1:]]).update( + payment_status="cancelled" + ) result = request.GET.get("result") pg_token = request.GET.get("pg_token") @@ -339,11 +360,14 @@ def get(self, request): ) @transaction.atomic - def delete(self, request): - order = self.get_queryset().select_for_update().first() - if not order: + def delete(self, request, order_id): + try: + order = ( + self.get_queryset().filter(order_status="completed").get(id=order_id) + ) + except Order.DoesNotExist: return Response( - {"detail": "진행 중인 주문이 없습니다."}, + {"detail": "결제된 주문을 찾을 수 없습니다."}, status=status.HTTP_404_NOT_FOUND, )