Skip to content

Commit

Permalink
Merge pull request #173 from weaverse-techtide/yeonwoo/payments
Browse files Browse the repository at this point in the history
refactor: 장바구니 중복 추가 수정
  • Loading branch information
AlbertImKr authored Oct 13, 2024
2 parents 9b0894e + 0659915 commit 3964013
Show file tree
Hide file tree
Showing 5 changed files with 148 additions and 20 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Generated by Django 5.1.1 on 2024-10-13 06:00

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('courses', '0007_alter_course_category'),
('materials', '0002_image_video_delete_blacklistedtoken'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.AddField(
model_name='image',
name='image',
field=models.ImageField(blank=True, null=True, upload_to='images/'),
),
migrations.AddField(
model_name='image',
name='user',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='image', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='image',
name='course',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='image', to='courses.course'),
),
migrations.AlterField(
model_name='video',
name='topic',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='video', to='courses.topic'),
),
migrations.CreateModel(
name='VideoEventData',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('event_type', models.CharField(choices=[('pause', 'Paused'), ('ended', 'Ended'), ('leave', 'Left Page')], max_length=20, verbose_name='이벤트 유형')),
('duration', models.FloatField(verbose_name='비디오 전체 길이')),
('current_time', models.FloatField(verbose_name='현재 재생 위치')),
('timestamp', models.DateTimeField(auto_now_add=True, verbose_name='이벤트 발생 시간')),
('video', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='video_event_datas', to='materials.video', verbose_name='해당 비디오')),
],
),
]
29 changes: 20 additions & 9 deletions payments/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,15 @@ def add_to_cart(self, cart, serializer):
{"detail": "이 상품은 이미 장바구니에 있습니다."},
status=status.HTTP_400_BAD_REQUEST,
)

serializer.save(cart=cart)
return Response(
{"detail": "상품이 장바구니에 추가되었습니다.", "data": serializer.data},
status=status.HTTP_201_CREATED,
)
else:
serializer.save(cart=cart)
return Response(
{
"detail": "상품이 장바구니에 추가되었습니다.",
"data": serializer.data,
},
status=status.HTTP_201_CREATED,
)

def remove_from_cart(self, cart_item):
cart_item.delete()
Expand Down Expand Up @@ -138,12 +141,17 @@ def validate_order(self, order):
raise ValidationError("결제 가능한 상태의 주문이 아닙니다.")
if order.get_total_price() > 50000:
raise ValidationError("결제 금액이 50,000원을 초과할 수 없습니다.")
if order.payments.filter(payment_status__in=["completed", "pending"]).exists():
raise ValidationError("이미 결제가 완료되었거나 진행 중인 주문입니다.")

def create_payment(self, order, user):
self.validate_order(order)

existing_payments = Payment.objects.filter(
order=order, payment_status="pending"
)
if existing_payments.exists():
# 모든 기존 pending payment를 취소 처리
existing_payments.update(payment_status="cancelled")

try:
kakao_response = self.kakao_pay_service.request_payment(order)
except Exception as e:
Expand Down Expand Up @@ -203,8 +211,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)
Expand Down
46 changes: 44 additions & 2 deletions payments/tests/test_payments_views.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -41,22 +41,64 @@ 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"] == "결제가 성공적으로 완료되었습니다."

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영수증뷰:
Expand Down
5 changes: 5 additions & 0 deletions payments/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
PaymentView.as_view(),
name="payment",
),
path(
"payments/<int:order_id>/cancel/",
PaymentView.as_view(),
name="payment-cancel",
),
# 영수증 관련 URLs
path("receipts/", ReceiptView.as_view(), name="receipt-list"),
path(
Expand Down
40 changes: 31 additions & 9 deletions payments/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -254,7 +254,7 @@ class PaymentView(PaymentMixin, OrderMixin, generics.GenericAPIView):
[POST /payments/]: 현재 진행 중인 주문에 대한 결제를 생성하고 카카오페이 결제를 요청합니다.
[GET /payments/]: 카카오페이 결제 결과를 처리합니다.
[DELETE /payments/]: 결제를 취소하고 환불을 처리합니다.
[DELETE /payments/<order_id>/cancel/]: 결제를 취소하고 환불을 처리합니다.
"""

serializer_class = PaymentSerializer
Expand All @@ -265,7 +265,12 @@ 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": "진행 중인 주문이 없습니다."},
Expand All @@ -290,14 +295,28 @@ 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)
payment = (
Payment.objects.filter(order=order, payment_status="pending")
.order_by("-created_at")
.first()
)
if not payment:
return Response(
{"detail": "해당 주문에 대한 대기 중인 결제를 찾을 수 없습니다."},
status=status.HTTP_404_NOT_FOUND,
)

result = request.GET.get("result")
pg_token = request.GET.get("pg_token")
Expand Down Expand Up @@ -339,11 +358,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,
)

Expand Down

0 comments on commit 3964013

Please sign in to comment.