diff --git a/app/admin_api/serializers/shop/orders.py b/app/admin_api/serializers/shop/orders.py index 32d022e..7b9635c 100644 --- a/app/admin_api/serializers/shop/orders.py +++ b/app/admin_api/serializers/shop/orders.py @@ -85,7 +85,7 @@ class Meta: current_paid_price = serializers.IntegerField(read_only=True) current_status = serializers.CharField(read_only=True) first_paid_at = serializers.DateTimeField(read_only=True) - latest_imp_id = serializers.CharField(read_only=True) + latest_imp_id = serializers.ReadOnlyField() class Meta: model = Order diff --git a/app/admin_api/test/shop/order_notifications_api_test.py b/app/admin_api/test/shop/order_notifications_api_test.py index 70c6869..ef26a7c 100644 --- a/app/admin_api/test/shop/order_notifications_api_test.py +++ b/app/admin_api/test/shop/order_notifications_api_test.py @@ -50,6 +50,36 @@ def test_notification_preview_returns_recipients_for_completed_order(api_client, } +@pytest.mark.django_db +def test_notification_preview_includes_free_completed_order(api_client, order_email_template, order_factory): + order = order_factory(status="completed", product_price=0, imp_id=None) + scancode_url = urljoin(settings.BACKEND_DOMAIN, order.scancode_path) + + response = OrderNotificationsAdminApi(http_client=api_client).preview( + {"channel": "email", "template_id": str(order_email_template.id)} + ) + + assert response.status_code == HTTP_200_OK + assert response.json() == { + "template_variables": ["customer_email", "customer_name", "first_paid_price", "order_name"], + "recipients": [ + { + "recipient": order.customer_info.email, + "context": { + "scancode_url": scancode_url, + "order_name": order.name, + "first_paid_at": order.first_paid_at.isoformat(), + "first_paid_price": 0, + "customer_name": order.customer_info.name, + "customer_phone": order.customer_info.phone, + "customer_email": order.customer_info.email, + }, + "missing_variables": [], + } + ], + } + + @pytest.mark.django_db def test_notification_preview_rejects_unknown_template_id(api_client, order_factory): order_factory(status="completed") diff --git a/app/admin_api/test/shop/orders_api_test.py b/app/admin_api/test/shop/orders_api_test.py index 9d140ac..abce19d 100644 --- a/app/admin_api/test/shop/orders_api_test.py +++ b/app/admin_api/test/shop/orders_api_test.py @@ -3,9 +3,11 @@ import pandas import pytest +import yaml from admin_api.serializers.shop.orders import OrderAdminSerializer from admin_api.test.helpers import OrdersAdminApi from admin_api.views.shop.orders import OrderAdminViewSet +from core.const.shop_error_messages import NotRefundableErrorMessages from freezegun import freeze_time from model_bakery import baker from rest_framework.fields import DateTimeField @@ -17,6 +19,7 @@ HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, ) +from rest_framework.test import APIClient from shop.order.models import CustomerInfo, Order, OrderProductRelation from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus @@ -42,6 +45,22 @@ def test_admin_list_returns_only_orders_with_payment_history_and_products(api_cl } +@pytest.mark.django_db +def test_admin_list_includes_free_completed_order(api_client, order_factory): + order = order_factory(status="completed", product_price=0, imp_id=None) + + response = OrdersAdminApi(http_client=api_client).list() + + assert response.status_code == HTTP_200_OK + row = response.json()["results"][0] + assert row["id"] == str(order.id) + assert row["current_status"] == PaymentHistoryStatus.completed + assert row["current_paid_price"] == 0 + assert row["latest_imp_id"] is None + assert row["payment_histories"][0]["price"] == 0 + assert row["payment_histories"][0]["imp_id"] is None + + @pytest.mark.django_db def test_admin_list_orders_by_first_paid_at_desc(api_client, order_factory): # 먼저 생성된 주문(= created_at 이 더 과거)이 더 최근에 결제되도록 구성. @@ -136,6 +155,19 @@ def test_admin_refund_action_refunds_order(api_client, mock_portone_req_cancel_p assert completed_order.payment_histories.filter(status=PaymentHistoryStatus.refunded).exists() +@pytest.mark.django_db +def test_admin_refund_action_rejects_free_completed_order_without_portone_cancel( + api_client, mock_portone_req_cancel_payment, order_factory +): + order = order_factory(status="completed", product_price=0, imp_id=None) + + response = OrdersAdminApi(http_client=api_client).refund(order.id) + + assert response.status_code == HTTP_400_BAD_REQUEST + assert NotRefundableErrorMessages.ORDER_IMP_ID_NOT_EXIST in str(response.json()) + mock_portone_req_cancel_payment.assert_not_called() + + @pytest.mark.django_db def test_admin_refund_product_action_does_partial_refund( api_client, ticket_product, mock_portone_req_cancel_payment, order_factory @@ -165,6 +197,39 @@ def test_admin_refund_product_action_returns_404_for_unknown_rel(api_client, ord assert response.status_code == HTTP_404_NOT_FOUND +@pytest.mark.django_db +def test_admin_refund_product_action_rejects_free_completed_opr_without_portone_cancel( + api_client, mock_portone_req_cancel_payment, order_factory +): + order = order_factory(status="completed", product_price=0, imp_id=None) + opr = order.products.get() + + response = OrdersAdminApi(http_client=api_client).refund_product(order.id, opr.id) + + assert response.status_code == HTTP_400_BAD_REQUEST + assert NotRefundableErrorMessages.ORDER_NOT_REFUNDABLE in str(response.json()) + mock_portone_req_cancel_payment.assert_not_called() + + +@pytest.mark.django_db +def test_admin_refund_actions_document_validation_error_responses(): + response = APIClient().get("/api/schema/v1/") + assert response.status_code == HTTP_200_OK + + schema = yaml.safe_load(response.content) + total_refund_path = next(path for path in schema["paths"] if path.endswith("/admin-api/shop/order/{id}/refund/")) + product_refund_path = next( + path for path in schema["paths"] if path.endswith("/admin-api/shop/order/{id}/products/{rel_id}/refund/") + ) + + for path in (total_refund_path, product_refund_path): + responses = schema["paths"][path]["post"]["responses"] + assert "204" in responses + assert responses["400"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/ValidationErrorResponse", + } + + @pytest.mark.django_db def test_admin_refund_allows_expired_window(api_client, mock_portone_req_cancel_payment, order_factory): completed_order = order_factory(status="completed") diff --git a/app/admin_api/test/shop/products_api_test.py b/app/admin_api/test/shop/products_api_test.py index ac5e95a..96632ac 100644 --- a/app/admin_api/test/shop/products_api_test.py +++ b/app/admin_api/test/shop/products_api_test.py @@ -149,6 +149,27 @@ def test_admin_product_create_returns_201(api_client, ticket_product): assert Product.objects.filter(name_ko="신규 상품").exists() +@pytest.mark.django_db +def test_admin_product_create_allows_zero_price(api_client, ticket_product): + response = ProductsAdminApi(http_client=api_client).create( + { + "name_ko": "무료 튜토리얼", + "name_en": "Free Tutorial", + "price": 0, + "stock": 10, + "visible_starts_at": FAR_PAST.isoformat(), + "visible_ends_at": FAR_FUTURE.isoformat(), + "orderable_starts_at": FAR_PAST.isoformat(), + "orderable_ends_at": FAR_FUTURE.isoformat(), + "refundable_ends_at": FAR_FUTURE.isoformat(), + "category": str(ticket_product.category.id), + } + ) + assert response.status_code == HTTP_201_CREATED + assert response.json()["price"] == 0 + assert Product.objects.filter(name_ko="무료 튜토리얼", price=0).exists() + + @pytest.mark.django_db def test_admin_product_partial_update_can_set_refundable_ends_at_null(api_client, ticket_product): # null = 환불 불가 상품. 운영자가 어드민에서 직접 지정하는 경로. diff --git a/app/admin_api/views/shop/orders.py b/app/admin_api/views/shop/orders.py index 95f9f1a..bd9011c 100644 --- a/app/admin_api/views/shop/orders.py +++ b/app/admin_api/views/shop/orders.py @@ -16,6 +16,7 @@ from django.db import models, transaction from django.http.response import StreamingHttpResponse from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema, extend_schema_view +from drf_standardized_errors.openapi_serializers import ValidationErrorResponseSerializer from rest_framework import exceptions, mixins, parsers, request, response, status, viewsets from rest_framework.decorators import action from shop.order import exports, imports @@ -92,7 +93,10 @@ class OrderAdminViewSet( @extend_schema( summary="주문 전체 환불", tags=[OpenAPITag.ADMIN_SHOP_ORDER_REFUND], - responses={status.HTTP_204_NO_CONTENT: None}, + responses={ + status.HTTP_204_NO_CONTENT: None, + status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer, + }, ) @action(detail=True, methods=["post"], url_path="refund") @transaction.atomic @@ -109,7 +113,10 @@ def refund(self, request: request.Request, pk: typing.Any = None) -> response.Re @extend_schema( summary="주문 부분 환불", tags=[OpenAPITag.ADMIN_SHOP_ORDER_REFUND], - responses={status.HTTP_204_NO_CONTENT: None}, + responses={ + status.HTTP_204_NO_CONTENT: None, + status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer, + }, ) @action(detail=True, methods=["post"], url_path=r"products/(?P[^/.]+)/refund") @transaction.atomic diff --git a/app/core/const/shop_error_messages.py b/app/core/const/shop_error_messages.py index f80d187..033bf6e 100644 --- a/app/core/const/shop_error_messages.py +++ b/app/core/const/shop_error_messages.py @@ -22,7 +22,7 @@ class ProductNotOrderableErrorMessages: SOLDOUT = "{} 상품은 매진되었습니다." ALREADY_ORDERED_TOO_MUCH = "{} 상품의 인당 최대 구매 수량 초과로 구매하실 수 없습니다." TOO_MUCH_CART_PRODUCT = "{} 상품의 재고 수량을 초과하여 구매하실 수 없습니다. 장바구니에 담은 수량을 확인해주세요." - PRICE_TOO_LOW = "결제 금액이 너무 낮습니다, 최소한 1원 이상으로 구매해주세요." + PRICE_TOO_LOW = "결제 금액이 너무 낮습니다, 최소한 0원 이상으로 구매해주세요." PRICE_TOO_HIGH = "결제 금액이 너무 높습니다, 후원 금액 등을 줄여 100만원 미만으로 구매해주세요." DONATION_NOT_ALLOWED = "{} 상품은 후원이 불가능한 상품입니다." DONATION_PRICE_OUT_OF_RANGE = "{} 상품의 후원 금액이 범위를 벗어났습니다. {}원 이상 {}원 이하로 입력해주세요." @@ -57,7 +57,7 @@ class CartNotOrderableErrorMessages: ALREADY_ORDERED = "이미 결제한 장바구니입니다." CONTAINS_PAID_PRODUCT = "결제한 상품이 포함되어 있습니다. PyCon 한국 준비 위원회에 문의해주세요." EMPTY = "장바구니가 비어있습니다, 먼저 상품을 담아주세요." - CART_PRICE_TOO_LOW = "장바구니의 금액이 너무 낮습니다. 최소한 1원 이상으로 구매해주세요." + CART_PRICE_TOO_LOW = "장바구니의 금액이 너무 낮습니다. 최소한 0원 이상으로 구매해주세요." CART_PRICE_TOO_HIGH = "장바구니의 금액이 너무 높습니다. 일부 상품을 제거하여 100만원 미만으로 구매해주세요." TICKET_INFO_REQUIRED = "참가자 정보가 입력되지 않은 티켓이 있습니다. 모든 티켓의 참가자 정보를 입력해주세요." @@ -99,6 +99,7 @@ class PortOneWebhookFailureCode(models.TextChoices): UNEXPECTED_RETRIEVED_ORDER_STATUS = "UNEXPECTED_RETRIEVED_ORDER_STATUS", "예상한 결제 상태가 아닙니다." UNEXPECTED_RETRIEVED_ORDER_ID = "UNEXPECTED_RETRIEVED_ORDER_ID", "결제 ID가 일치하지 않습니다." UNEXPECTED_PAID_PRICE = "UNEXPECTED_PAID_PRICE", "결제 금액이 일치하지 않습니다." + ORDER_NOT_ORDERABLE = "ORDER_NOT_ORDERABLE", "주문 가능한 상태가 아니거나 재고가 부족합니다." UNSUPPORTED_CURRENCY = "UNSUPPORTED_CURRENCY", "지원하지 않는 통화입니다." ILLEGAL_STATUS_TRANSITION = "ILLEGAL_STATUS_TRANSITION", "이미 처리된 결제이거나 허용되지 않는 상태 전환입니다." CANCELLED_NOT_SUPPORTED = ( diff --git a/app/shop/conftest.py b/app/shop/conftest.py index 436bef8..7cf548b 100644 --- a/app/shop/conftest.py +++ b/app/shop/conftest.py @@ -243,7 +243,14 @@ def order_factory(request, customer_user): ``donation>0`` 이면 종류와 무관하게 ``donation_product`` 사용. """ - def make(*, status: OrderStatus = "cart", donation: int = 0, is_ticket: bool = True) -> Order: + def make( + *, + status: OrderStatus = "cart", + donation: int = 0, + is_ticket: bool = True, + product_price: int | None = None, + imp_id: str | None = _COMPLETED_ORDER_IMP_ID, + ) -> Order: if status == "empty": return Order.objects.create(user=customer_user, name="cart") @@ -256,7 +263,10 @@ def make(*, status: OrderStatus = "cart", donation: int = 0, is_ticket: bool = T name_en=used_product.name_en, ) OrderProductRelation.objects.create( - order=order, product=used_product, price=used_product.price, donation_price=donation + order=order, + product=used_product, + price=used_product.price if product_price is None else product_price, + donation_price=donation, ) CustomerInfo.objects.create(order=order, name="홍길동", phone="01012345678", email="customer@example.com") @@ -270,7 +280,7 @@ def make(*, status: OrderStatus = "cart", donation: int = 0, is_ticket: bool = T order.products.update(status=OrderProductRelation.OrderProductStatus.paid) PaymentHistory.objects.create( order=order, - imp_id=_COMPLETED_ORDER_IMP_ID, + imp_id=imp_id, status=PaymentHistoryStatus.completed, price=order.first_paid_price, ) @@ -287,14 +297,14 @@ def make(*, status: OrderStatus = "cart", donation: int = 0, is_ticket: bool = T if status == "refunded": order.products.update(status=OrderProductRelation.OrderProductStatus.refunded) second_ph = PaymentHistory.objects.create( - order=order, imp_id=_COMPLETED_ORDER_IMP_ID, status=PaymentHistoryStatus.refunded, price=0 + order=order, imp_id=imp_id, status=PaymentHistoryStatus.refunded, price=0 ) PaymentHistory.objects.filter(id=second_ph.id).update(created_at=later_at) return order if status == "partial_refunded": second_ph = PaymentHistory.objects.create( order=order, - imp_id=_COMPLETED_ORDER_IMP_ID, + imp_id=imp_id, status=PaymentHistoryStatus.partial_refunded, price=order.first_paid_price // 2, ) diff --git a/app/shop/order/serializers/dto.py b/app/shop/order/serializers/dto.py index 56b69a0..87255b5 100644 --- a/app/shop/order/serializers/dto.py +++ b/app/shop/order/serializers/dto.py @@ -156,3 +156,21 @@ class Meta: "merchant_uid", ) model = SingleProductCart + + +class CreateSingleProductOrderResponseDto(serializers.Serializer): + id = serializers.UUIDField() + name = serializers.CharField() + payment_histories = PaymentHistoryDto(many=True) + products = OrderProductRelationDto(many=True) + first_paid_price = serializers.IntegerField() + current_paid_price = serializers.IntegerField() + current_status = serializers.ChoiceField(choices=PaymentHistoryStatus.choices) + created_at = serializers.DateTimeField() + customer_info = CustomerInfoDto(allow_null=True) + merchant_uid = serializers.CharField(allow_null=True) + + # 0원 즉시 완료 응답(OrderDto)에만 존재하는 필드. 유료 SingleProductCart 응답에는 없음. + scancode_url = serializers.URLField(required=False, allow_null=True) + first_paid_at = serializers.DateTimeField(required=False, allow_null=True) + not_fully_refundable_reason = serializers.CharField(required=False, allow_null=True) diff --git a/app/shop/order/test/cart_api_test.py b/app/shop/order/test/cart_api_test.py index 8d7e8a3..90c6384 100644 --- a/app/shop/order/test/cart_api_test.py +++ b/app/shop/order/test/cart_api_test.py @@ -1,5 +1,13 @@ +import threading +import time +from types import SimpleNamespace + import pytest +from core.const.shop_error_messages import ProductNotOrderableErrorMessages from core.util.testutil import to_json +from django import db +from django.db import transaction +from rest_framework.exceptions import ValidationError from rest_framework.status import ( HTTP_200_OK, HTTP_201_CREATED, @@ -7,9 +15,13 @@ HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND, ) +from rest_framework.test import APIClient from shop.conftest import VALID_TICKET_INFO from shop.order.models import Order, OrderProductRelation from shop.order.serializers.dto import OrderDto +from shop.order.views.carts import CartProductViewSet +from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus +from shop.serializers.cart_validation import OrderableCheckSerializerMode, ProductOrderableCheckSerializer from shop.test.helpers import CartApi, CartProductsApi @@ -92,6 +104,147 @@ def test_cart_add_product_creates_new_cart_when_none_exists(customer_client, cus assert list(opr.history.values_list("history_type", flat=True)) == ["+"] +@pytest.mark.django_db +def test_cart_product_queryset_create_action_uses_unlocked_queryset(customer_user): + view = CartProductViewSet() + view.request = SimpleNamespace(user=customer_user) + view.action = "create" + + assert list(view.get_queryset()) == [] + + +@pytest.mark.django_db(transaction=True) +def test_cart_add_product_after_concurrent_free_checkout_creates_new_cart(customer_user, ticket_product): + completed_cart = Order.objects.create(user=customer_user, name="cart") + created_order_ids = [] + errors = [] + + def add_product(): + db.close_old_connections() + try: + user = customer_user.__class__.objects.get(id=customer_user.id) + serializer = ProductOrderableCheckSerializer( + data={ + "product": str(ticket_product.id), + "options": [], + "ticket_info": VALID_TICKET_INFO, + }, + context={ + "request": SimpleNamespace(user=user), + "mode": OrderableCheckSerializerMode.ADD_SINGLE_PRODUCT_TO_CART, + }, + ) + serializer.is_valid(raise_exception=True) + created_order_ids.append(serializer.save().order_id) + except Exception as exc: # pragma: no cover - surfaced by assertion below + errors.append(exc) + finally: + db.close_old_connections() + + with transaction.atomic(): + Order.objects.select_for_update().get(id=completed_cart.id) + thread = threading.Thread(target=add_product) + thread.start() + time.sleep(0.2) + PaymentHistory.objects.create( + order=completed_cart, + imp_id=None, + status=PaymentHistoryStatus.completed, + price=0, + ) + + thread.join(timeout=3) + + assert not thread.is_alive() + assert errors == [] + assert created_order_ids != [completed_cart.id] + assert not completed_cart.products.filter_active().exists() + assert Order.objects.filter(user=customer_user).exclude(id=completed_cart.id).count() == 1 + + +@pytest.mark.django_db(transaction=True) +def test_concurrent_cart_add_without_existing_cart_reuses_one_cart(monkeypatch, customer_user, ticket_product): + original_get_cart = ProductOrderableCheckSerializer._get_unpaid_cart_for_update + no_cart_barrier = threading.Barrier(2) + created_order_ids = [] + errors = [] + + def delayed_get_cart(serializer): + cart = original_get_cart(serializer) + if cart is None: + try: + no_cart_barrier.wait(timeout=0.5) + except threading.BrokenBarrierError: + pass + return cart + + monkeypatch.setattr(ProductOrderableCheckSerializer, "_get_unpaid_cart_for_update", delayed_get_cart) + + def add_product(): + db.close_old_connections() + try: + user = customer_user.__class__.objects.get(id=customer_user.id) + serializer = ProductOrderableCheckSerializer( + data={ + "product": str(ticket_product.id), + "options": [], + "ticket_info": VALID_TICKET_INFO, + }, + context={ + "request": SimpleNamespace(user=user), + "mode": OrderableCheckSerializerMode.ADD_SINGLE_PRODUCT_TO_CART, + }, + ) + serializer.is_valid(raise_exception=True) + created_order_ids.append(serializer.save().order_id) + except Exception as exc: # pragma: no cover - surfaced by assertion below + errors.append(exc) + finally: + db.close_old_connections() + + threads = [threading.Thread(target=add_product) for _ in range(2)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=5) + + assert all(not thread.is_alive() for thread in threads) + assert errors == [] + assert len(created_order_ids) == 2 + assert len(set(created_order_ids)) == 1 + cart = Order.objects.get(user=customer_user) + assert cart.products.filter_active().count() == 2 + + +@pytest.mark.django_db +def test_cart_add_revalidates_after_user_lock_before_saving(customer_user, ticket_product): + ticket_product.max_quantity_per_user = 1 + ticket_product.save(update_fields=["max_quantity_per_user"]) + payload = { + "product": str(ticket_product.id), + "options": [], + "ticket_info": VALID_TICKET_INFO, + } + context = { + "request": SimpleNamespace(user=customer_user), + "mode": OrderableCheckSerializerMode.ADD_SINGLE_PRODUCT_TO_CART, + } + first_serializer = ProductOrderableCheckSerializer(data=payload, context=context) + second_serializer = ProductOrderableCheckSerializer(data=payload, context=context) + assert first_serializer.is_valid(raise_exception=True) + assert second_serializer.is_valid(raise_exception=True) + + first_serializer.save() + + with pytest.raises(ValidationError) as exc_info: + second_serializer.save() + + assert ProductNotOrderableErrorMessages.ALREADY_ORDERED_TOO_MUCH.format(ticket_product.name) in str( + exc_info.value.detail + ) + assert OrderProductRelation.objects.filter(product=ticket_product, order__user=customer_user).count() == 1 + + @pytest.mark.django_db def test_cart_add_product_rejects_invalid_product_id(customer_client): response = CartProductsApi(http_client=customer_client).create( @@ -126,6 +279,47 @@ def test_cart_remove_product_invalidates_prepared_payment(customer_client, order assert pending_order.prepared_cart_hash is None +@pytest.mark.django_db(transaction=True) +def test_cart_remove_product_after_concurrent_free_checkout_returns_404(customer_user, order_factory): + pending_order = order_factory() + opr = pending_order.products.first() + responses = [] + + def remove_product(): + db.close_old_connections() + try: + user = customer_user.__class__.objects.get(id=customer_user.id) + client = APIClient() + client.force_authenticate(user=user) + responses.append(CartProductsApi(http_client=client).delete(opr.id)) + finally: + db.close_old_connections() + + with transaction.atomic(): + Order.objects.select_for_update().get(id=pending_order.id) + thread = threading.Thread(target=remove_product) + thread.start() + time.sleep(0.2) + locked_opr = OrderProductRelation.objects.select_for_update().get(id=opr.id) + locked_opr.status = OrderProductRelation.OrderProductStatus.paid + locked_opr.save(update_fields=["status"]) + PaymentHistory.objects.create( + order=pending_order, + imp_id=None, + status=PaymentHistoryStatus.completed, + price=0, + ) + + thread.join(timeout=3) + + assert not thread.is_alive() + assert len(responses) == 1 + assert responses[0].status_code == HTTP_404_NOT_FOUND + opr.refresh_from_db() + assert opr.deleted_at is None + assert opr.status == OrderProductRelation.OrderProductStatus.paid + + @pytest.mark.django_db def test_cart_remove_product_rejects_other_users_opr(other_client, order_factory): pending_order = order_factory() diff --git a/app/shop/order/test/opr_model_test.py b/app/shop/order/test/opr_model_test.py index 7344fbb..44dcc5e 100644 --- a/app/shop/order/test/opr_model_test.py +++ b/app/shop/order/test/opr_model_test.py @@ -63,6 +63,15 @@ def test_opr_not_refundable_reason_when_price_is_zero(customer_user, ticket_prod assert opr.not_refundable_reason == NotRefundableErrorMessages.PRODUCT_PRICE_IS_ZERO +@pytest.mark.django_db +def test_free_completed_opr_without_imp_id_not_refundable(order_factory): + order = order_factory(status="completed", product_price=0, imp_id=None) + opr = order.products.get() + + assert opr.status == OrderProductRelation.OrderProductStatus.paid + assert opr.not_refundable_reason == NotRefundableErrorMessages.ORDER_NOT_REFUNDABLE + + @pytest.mark.django_db def test_opr_not_refundable_reason_returns_none_for_refundable_opr(order_factory): completed_order = order_factory(status="completed") diff --git a/app/shop/order/test/order_model_test.py b/app/shop/order/test/order_model_test.py index 7d533a6..5e7eb4b 100644 --- a/app/shop/order/test/order_model_test.py +++ b/app/shop/order/test/order_model_test.py @@ -132,6 +132,16 @@ def test_not_fully_refundable_reason_when_price_zero(customer_user, ticket_produ assert refreshed.not_fully_refundable_reason == NotRefundableErrorMessages.ORDER_REFUNDABLE_PRICE_NOT_FOUND +@pytest.mark.django_db +def test_free_completed_order_without_imp_id_not_refundable_as_no_imp_id(order_factory): + order = order_factory(status="completed", product_price=0, imp_id=None) + + assert order.current_status == PaymentHistoryStatus.completed + assert order.current_paid_price == 0 + assert order.latest_imp_id is None + assert order.not_fully_refundable_reason == NotRefundableErrorMessages.ORDER_IMP_ID_NOT_EXIST + + @pytest.mark.django_db def test_not_fully_refundable_reason_when_expected_price_mismatch(ticket_product, order_factory): completed_order = order_factory(status="completed") diff --git a/app/shop/order/test/orders_api_test.py b/app/shop/order/test/orders_api_test.py index 16b38b4..dae98e1 100644 --- a/app/shop/order/test/orders_api_test.py +++ b/app/shop/order/test/orders_api_test.py @@ -17,8 +17,8 @@ from shop.conftest import VALID_TICKET_INFO from shop.order.models import CustomerInfo, Order, OrderProductRelation, SingleProductCart, TicketInfo from shop.order.serializers.dto import OrderDto, SingleProductCartDto -from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus -from shop.test.helpers import OrdersApi +from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus, PaymentWebhookEvent +from shop.test.helpers import CartProductsApi, OrdersApi @pytest.mark.django_db @@ -105,6 +105,52 @@ def test_create_single_product_order_creates_cart_and_calls_portone( assert list(cart.order_product_relation.history.values_list("history_type", flat=True)) == ["+"] +@pytest.mark.django_db +def test_create_single_product_order_free_completes_without_portone_or_webhook( + customer_client, customer_user, ticket_product, mock_portone_register +): + ticket_product.price = 0 + ticket_product.save(update_fields=["price"]) + + with ( + patch("shop.payment_history.services.transaction.on_commit") as mock_on_commit, + patch("shop.payment_history.services.send_payment_completed_notifications.delay") as mock_delay, + ): + response = OrdersApi(http_client=customer_client).create_single( + { + "product": str(ticket_product.id), + "options": [], + "customer_info": { + "name": "홍길동", + "phone": "010-1234-5678", + "email": "customer@example.com", + "organization": "", + }, + "ticket_info": VALID_TICKET_INFO, + } + ) + mock_on_commit.assert_called_once() + mock_on_commit.call_args.args[0]() + + assert response.status_code == HTTP_201_CREATED + completed = Order.objects.for_dto_response().get(user=customer_user) + assert response.json() == to_json(OrderDto(instance=completed).data) + assert completed.current_status == PaymentHistoryStatus.completed + assert completed.current_paid_price == 0 + assert completed.latest_imp_id is None + assert not SingleProductCart.objects.filter(id=completed.id).exists() + assert completed.products.get().status == OrderProductRelation.OrderProductStatus.paid + assert PaymentHistory.objects.filter( + order=completed, + status=PaymentHistoryStatus.completed, + price=0, + imp_id=None, + ).exists() + mock_portone_register.assert_not_called() + assert not PaymentWebhookEvent.objects.exists() + mock_delay.assert_called_once_with(str(completed.id)) + + @pytest.mark.django_db def test_create_single_product_order_rejects_invalid_product(customer_client, mock_portone_register): response = OrdersApi(http_client=customer_client).create_single( @@ -157,6 +203,53 @@ def test_create_order_persists_customer_info_and_schedules_portone_call( mocked_on_commit.assert_called_once() +@pytest.mark.django_db +def test_create_order_free_cart_completes_without_portone_or_webhook( + customer_client, customer_user, ticket_product, mock_portone_register +): + ticket_product.price = 0 + ticket_product.save(update_fields=["price"]) + add_response = CartProductsApi(http_client=customer_client).create( + { + "product": str(ticket_product.id), + "options": [], + "ticket_info": VALID_TICKET_INFO, + } + ) + assert add_response.status_code == HTTP_201_CREATED + cart = Order.objects.get(user=customer_user) + + with ( + patch("shop.payment_history.services.transaction.on_commit") as mock_on_commit, + patch("shop.payment_history.services.send_payment_completed_notifications.delay") as mock_delay, + ): + response = OrdersApi(http_client=customer_client).create( + {"name": "홍길동", "phone": "010-1234-5678", "email": "customer@example.com", "organization": ""} + ) + mock_on_commit.assert_called_once() + mock_on_commit.call_args.args[0]() + + assert response.status_code == HTTP_201_CREATED + completed = Order.objects.for_dto_response().get(id=cart.id) + assert response.json() == to_json(OrderDto(instance=completed).data) + assert completed.current_status == PaymentHistoryStatus.completed + assert completed.current_paid_price == 0 + assert completed.latest_imp_id is None + assert list(completed.products.values_list("status", flat=True)) == [OrderProductRelation.OrderProductStatus.paid] + assert ( + PaymentHistory.objects.filter( + order=completed, + status=PaymentHistoryStatus.completed, + price=0, + imp_id=None, + ).count() + == 1 + ) + mock_portone_register.assert_not_called() + assert not PaymentWebhookEvent.objects.exists() + mock_delay.assert_called_once_with(str(completed.id)) + + @pytest.mark.django_db def test_destroy_order_refunds_when_owned_by_request_user( customer_client, mock_portone_req_cancel_payment, order_factory @@ -181,6 +274,23 @@ def test_destroy_order_rejects_other_users_order(other_client, mock_portone_req_ mock_portone_req_cancel_payment.assert_not_called() +@pytest.mark.django_db +def test_destroy_order_rejects_free_completed_order_without_portone_cancel( + customer_client, mock_portone_req_cancel_payment, order_factory +): + completed_order = order_factory(status="completed", product_price=0, imp_id=None) + + response = OrdersApi(http_client=customer_client).delete(completed_order.id) + + assert response.status_code == HTTP_400_BAD_REQUEST + completed_order.refresh_from_db() + assert list(completed_order.products.values_list("status", flat=True)) == [ + OrderProductRelation.OrderProductStatus.paid + ] + assert completed_order.payment_histories.count() == 1 + mock_portone_req_cancel_payment.assert_not_called() + + @pytest.mark.django_db def test_retrieve_receipt_returns_kcp_redirect_html_for_owner(customer_client, mock_portone_kcp_receipt, order_factory): completed_order = order_factory(status="completed") @@ -199,11 +309,13 @@ def test_retrieve_receipt_returns_404_when_order_has_no_imp_id(customer_client, @pytest.mark.django_db -def test_retrieve_receipt_returns_404_when_payment_history_has_no_imp_id(customer_client, order_factory): - completed_order = order_factory(status="completed") - completed_order.payment_histories.update(imp_id=None) +def test_retrieve_receipt_returns_404_when_payment_history_has_no_imp_id( + customer_client, mock_portone_kcp_receipt, order_factory +): + completed_order = order_factory(status="completed", product_price=0, imp_id=None) response = OrdersApi(http_client=customer_client).retrieve_receipt(completed_order.id) assert response.status_code == HTTP_404_NOT_FOUND + mock_portone_kcp_receipt.assert_not_called() @pytest.mark.django_db diff --git a/app/shop/order/test/orders_schema_test.py b/app/shop/order/test/orders_schema_test.py new file mode 100644 index 0000000..d796318 --- /dev/null +++ b/app/shop/order/test/orders_schema_test.py @@ -0,0 +1,16 @@ +import pytest +import yaml +from rest_framework.test import APIClient + + +@pytest.mark.django_db +def test_create_single_product_order_response_schema_is_not_ambiguous_oneof(): + response = APIClient().get("/api/schema/v1/") + assert response.status_code == 200 + + schema = yaml.safe_load(response.content) + path = next(path for path in schema["paths"] if path.endswith("/shop/orders/single/")) + response_schema = schema["paths"][path]["post"]["responses"]["201"]["content"]["application/json"]["schema"] + + assert response_schema == {"$ref": "#/components/schemas/CreateSingleProductOrderResponseDto"} + assert "oneOf" not in schema["components"]["schemas"]["CreateSingleProductOrderResponseDto"] diff --git a/app/shop/order/test/soft_delete_filtering_test.py b/app/shop/order/test/soft_delete_filtering_test.py index 0130be5..29a86f3 100644 --- a/app/shop/order/test/soft_delete_filtering_test.py +++ b/app/shop/order/test/soft_delete_filtering_test.py @@ -10,7 +10,7 @@ from django.urls import reverse from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_404_NOT_FOUND from rest_framework.test import APIClient -from shop.conftest import WEBHOOK_WHITELISTED_IP +from shop.conftest import VALID_TICKET_INFO, WEBHOOK_WHITELISTED_IP from shop.order.exports import OrderProductExportSerializer from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation, TicketInfo from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus @@ -160,6 +160,7 @@ def test_webhook_paid_transition_skips_soft_deleted_opr(mock_portone_find_paymen """webhook PAID 처리 시 soft-deleted OPR 는 paid 로 전환되지 않고 deleted 상태가 유지된다.""" order = order_factory(status="cart") active_opr = order.products.get() + TicketInfo.objects.create(order_product_relation=active_opr, **VALID_TICKET_INFO) stale_opr = OrderProductRelation.objects.create(order=order, product=ticket_product, price=ticket_product.price) stale_opr.delete() order.prepare_payment() diff --git a/app/shop/order/views/carts.py b/app/shop/order/views/carts.py index 43df149..bfa1d19 100644 --- a/app/shop/order/views/carts.py +++ b/app/shop/order/views/carts.py @@ -1,11 +1,12 @@ import typing from core.const.tag import OpenAPITag +from django.db import transaction from django.db.models import Exists, OuterRef, QuerySet from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes from drf_spectacular.utils import extend_schema, extend_schema_view from drf_standardized_errors.openapi_serializers import ErrorResponse403Serializer, ValidationErrorResponseSerializer -from rest_framework import mixins, request, response, status, viewsets +from rest_framework import exceptions, mixins, request, response, status, viewsets from shop.order.models import Order, OrderProductRelation from shop.order.serializers.dto import OrderDto from shop.payment_history.models import PaymentHistory @@ -77,7 +78,13 @@ class CartProductViewSet(mixins.CreateModelMixin, mixins.DestroyModelMixin, view lookup_url_kwarg = "order_product_rel_id" serializer_class = ProductOrderableCheckSerializer - def get_queryset(self) -> QuerySet[Order]: + def get_queryset(self) -> QuerySet[OrderProductRelation]: + queryset = self._base_queryset() + if getattr(self, "action", None) == "destroy": + return queryset.select_for_update(of=("self",)) + return queryset + + def _base_queryset(self) -> QuerySet[OrderProductRelation]: if not isinstance(self.request.user, UserExt): return OrderProductRelation.objects.none() @@ -88,3 +95,19 @@ def get_queryset(self) -> QuerySet[Order]: single_product_cart__isnull=True, status=OrderProductRelation.OrderProductStatus.pending, ) + + def _lock_parent_order_for_destroy(self, order_product_rel_id: typing.Any) -> None: + order_id = self._base_queryset().filter(id=order_product_rel_id).values_list("order_id", flat=True).first() + if not order_id: + raise exceptions.NotFound() + + order = Order.objects.select_for_update().filter_active().filter(id=order_id).first() + if not order or PaymentHistory.objects.filter_active().filter(order_id=order_id).exists(): + raise exceptions.NotFound() + + @transaction.atomic + def destroy( + self, request: request.Request, *args: tuple[typing.Any], **kwargs: dict[str, typing.Any] + ) -> response.Response: + self._lock_parent_order_for_destroy(kwargs.get(self.lookup_url_kwarg)) + return super().destroy(request, *args, **kwargs) diff --git a/app/shop/order/views/orders.py b/app/shop/order/views/orders.py index b0e154c..abb9530 100644 --- a/app/shop/order/views/orders.py +++ b/app/shop/order/views/orders.py @@ -16,12 +16,18 @@ from rest_framework import mixins, renderers, request, response, serializers, status, viewsets from rest_framework.decorators import action from shop.order.models import CustomerInfo, Order, OrderProductRelation, OrderQuerySet -from shop.order.serializers.dto import CertificateIssueResponseDto, OrderDto, SingleProductCartDto +from shop.order.serializers.dto import ( + CertificateIssueResponseDto, + CreateSingleProductOrderResponseDto, + OrderDto, + SingleProductCartDto, +) from shop.order.serializers.validator import ( OptionProductOptionCustomResponseModifyRequestSerializer, OrderProductUpdateSerializer, ) from shop.payment_history.models import PaymentHistory +from shop.payment_history.services import complete_free_checkout from shop.serializers.cart_validation import ( CartOrderableCheckSerializer, CustomerInfoCheckSerializer, @@ -93,7 +99,7 @@ def get_queryset(self) -> models.QuerySet[Order]: tags=[OpenAPITag.SHOP_ORDER], request=SingleProductCartOrderableCheckSerializer, responses={ - status.HTTP_201_CREATED: SingleProductCartDto, + status.HTTP_201_CREATED: CreateSingleProductOrderResponseDto, status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer, status.HTTP_403_FORBIDDEN: ErrorResponse403Serializer, }, @@ -112,6 +118,11 @@ def create_single_product_order( cart = order_product_rel.single_product_cart cart.prepare_payment() + if cart.prepared_price == 0: + completed_order = complete_free_checkout(cart) + dto_order = Order.objects.for_dto_response().get(id=completed_order.id) + return response.Response(data=OrderDto(instance=dto_order).data, status=status.HTTP_201_CREATED) + portone_client.register_or_update_prepared_payment(merchant_id=cart.merchant_uid, price=cart.prepared_price) return response.Response(data=SingleProductCartDto(instance=cart).data, status=status.HTTP_201_CREATED) @@ -145,6 +156,7 @@ def create( data=[ { "product": product_rel.product_id, + "donation_price": product_rel.donation_price, "options": [ { "product_option_group": product_option_rel.product_option_group_id, @@ -171,6 +183,11 @@ def create( cart.save() cart.prepare_payment() + if cart.prepared_price == 0: + completed_order = complete_free_checkout(cart) + dto_cart = Order.objects.for_dto_response().get(id=completed_order.id) + return response.Response(data=OrderDto(instance=dto_cart).data, status=status.HTTP_201_CREATED) + # idempotent + forward-only — DB commit 후 비동기 호출. 실패 시 클라이언트 retry 가 자연 보상. transaction.on_commit( lambda: portone_client.register_or_update_prepared_payment( diff --git a/app/shop/patron.py b/app/shop/patron.py index f78f6ea..248bfbb 100644 --- a/app/shop/patron.py +++ b/app/shop/patron.py @@ -70,6 +70,7 @@ class PatronViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): OrderProductRelation.objects.filter_active().filter( order_id=OuterRef("id"), product__donation_allowed=True, + donation_price__gt=0, status__in=OrderProductRelation.PURCHASED_STOCK_STATUS, ) ), diff --git a/app/shop/payment_history/serializers.py b/app/shop/payment_history/serializers.py index 67b68bc..e697596 100644 --- a/app/shop/payment_history/serializers.py +++ b/app/shop/payment_history/serializers.py @@ -14,7 +14,9 @@ PaymentWebhookEvent, is_legal_payment_status_transition, ) +from shop.payment_history.services import lock_and_revalidate_checkout_order from shop.payment_history.tasks import send_payment_completed_notifications +from shop.serializers.cart_validation import OrderableCheckSerializerMode class PortOneV1PaymentStatus(models.TextChoices): @@ -124,6 +126,9 @@ def validate(self, data: dict) -> dict: if retrieved_order_data["currency"] != "KRW": self._reject_and_cancel_paid_payment(PortOneWebhookFailureCode.UNSUPPORTED_CURRENCY) + if retrieved_order_data["amount"] <= 0: + self._reject_and_cancel_paid_payment(PortOneWebhookFailureCode.UNEXPECTED_PAID_PRICE) + if not order.matches_payment_preparation(data["merchant_uid"], retrieved_order_data["amount"]): self._reject_and_cancel_paid_payment(PortOneWebhookFailureCode.UNEXPECTED_PAID_PRICE) @@ -132,6 +137,11 @@ def validate(self, data: dict) -> dict: @transaction.atomic def create(self, validated_data: dict) -> PaymentHistory: # CANCELLED webhook (관리자 콘솔 취소) 자동 처리는 미구현 — validate_status 에서 거부됨. + validation_mode = ( + OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT + if isinstance(self.cart_or_order, SingleProductCart) + else OrderableCheckSerializerMode.CHECKOUT_CART + ) order = self._lock_or_promote_order(validated_data["merchant_uid"]) # cart→Order 승격 시 cached cart_or_order 가 stale (cart 는 hard_delete 됨) — 새 Order 로 교체해 # 이후 _queue_event 가 derive 하는 order/single_product_cart 가 deleted row 를 가리키지 않게 한다. @@ -144,7 +154,12 @@ def create(self, validated_data: dict) -> PaymentHistory: if not is_legal_payment_status_transition(order.current_status, next_status): self._reject(PortOneWebhookFailureCode.ILLEGAL_STATUS_TRANSITION) - for product_rel in order.products.filter_active(): + try: + product_rels = lock_and_revalidate_checkout_order(order, validation_mode) + except serializers.ValidationError: + self._reject_and_cancel_paid_payment(PortOneWebhookFailureCode.ORDER_NOT_ORDERABLE) + + for product_rel in product_rels: product_rel.status = OrderProductRelation.OrderProductStatus.paid product_rel.save() diff --git a/app/shop/payment_history/services.py b/app/shop/payment_history/services.py new file mode 100644 index 0000000..357ff79 --- /dev/null +++ b/app/shop/payment_history/services.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from django.db import transaction +from rest_framework import serializers +from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation, SingleProductCart, TicketInfo +from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus, is_legal_payment_status_transition +from shop.payment_history.tasks import send_payment_completed_notifications +from shop.product.models import Option, OptionGroup, Product, ProductTagRelation, Tag +from shop.serializers.cart_validation import ( + CartOrderableCheckSerializer, + OrderableCheckSerializerMode, + ProductOrderableCheckSerializer, +) + +FREE_CHECKOUT_PRICE_ERROR = "무료 주문은 결제 준비 금액이 0원이어야 합니다." +FREE_CHECKOUT_TARGET_ERROR = "무료 주문 대상을 찾을 수 없습니다." +FREE_CHECKOUT_TRANSITION_ERROR = "이미 처리된 주문이거나 무료 완료로 전환할 수 없습니다." +_LOCKED_OPTIONS_ATTR = "_locked_checkout_options" +_LOCKED_TICKET_INFO_ATTR = "_locked_checkout_ticket_info" +_MISSING = object() + + +def complete_free_checkout(cart_or_order: Order | SingleProductCart) -> Order: + validation_mode = ( + OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT + if isinstance(cart_or_order, SingleProductCart) + else OrderableCheckSerializerMode.CHECKOUT_CART + ) + + with transaction.atomic(): + order = _lock_or_promote_order(cart_or_order) + + if order.prepared_price != 0: + raise serializers.ValidationError(FREE_CHECKOUT_PRICE_ERROR) + if not is_legal_payment_status_transition(order.current_status, PaymentHistoryStatus.completed): + raise serializers.ValidationError(FREE_CHECKOUT_TRANSITION_ERROR) + + product_rels = lock_and_revalidate_checkout_order(order, validation_mode) + + for product_rel in product_rels: + product_rel.status = OrderProductRelation.OrderProductStatus.paid + product_rel.save(update_fields=["status"]) + + PaymentHistory.objects.create( + order=order, + imp_id=None, + status=PaymentHistoryStatus.completed, + price=0, + ) + transaction.on_commit(lambda: send_payment_completed_notifications.delay(str(order.id))) + _clear_order_payment_cache(order) + return order + + +def _lock_or_promote_order(cart_or_order: Order | SingleProductCart) -> Order: + if isinstance(cart_or_order, SingleProductCart): + if cart := SingleProductCart.objects.select_for_update().filter_active().filter(id=cart_or_order.id).first(): + return cart.to_order() + if order := Order.objects.select_for_update().filter_active().filter(id=cart_or_order.id).first(): + return order + raise serializers.ValidationError(FREE_CHECKOUT_TARGET_ERROR) + + return Order.objects.select_for_update().filter_active().get(id=cart_or_order.id) + + +def lock_and_revalidate_checkout_order( + order: Order, validation_mode: OrderableCheckSerializerMode +) -> list[OrderProductRelation]: + product_rels = _lock_order_allocation_rows(order) + context = {"request": SimpleNamespace(user=order.user), "mode": validation_mode} + + ProductOrderableCheckSerializer( + data=[_build_product_validation_payload(product_rel, validation_mode) for product_rel in product_rels], + context=context, + many=True, + ).is_valid(raise_exception=True) + + if validation_mode == OrderableCheckSerializerMode.CHECKOUT_CART: + CartOrderableCheckSerializer(data={"cart": order.id}, context=context).is_valid(raise_exception=True) + + return product_rels + + +def _lock_order_allocation_rows(order: Order) -> list[OrderProductRelation]: + product_rels = list( + OrderProductRelation.objects.select_for_update() + .filter_active() + .filter(order=order) + .select_related("product", "product__category") + .order_by("id") + ) + product_rel_ids = [product_rel.id for product_rel in product_rels] + product_ids = [product_rel.product_id for product_rel in product_rels] + + if not product_ids: + return product_rels + + list(Product.objects.select_for_update().filter_active().filter(id__in=product_ids).order_by("id")) + active_tag_ids = ( + ProductTagRelation.objects.filter_active().filter(product_id__in=product_ids).order_by().values("tag_id") + ) + list(Tag.objects.select_for_update().filter_active().filter(id__in=active_tag_ids).order_by("id")) + + option_groups = list( + OptionGroup.objects.select_for_update().filter_active().filter(product_id__in=product_ids).order_by("id") + ) + option_group_ids = [option_group.id for option_group in option_groups] + + if option_group_ids: + list(Option.objects.select_for_update().filter_active().filter(group_id__in=option_group_ids).order_by("id")) + + option_rels = list( + OrderProductOptionRelation.objects.select_for_update() + .filter_active() + .filter(order_product_relation_id__in=product_rel_ids) + .order_by("id") + ) + ticket_infos = list( + TicketInfo.objects.select_for_update().filter_active().filter(order_product_relation_id__in=product_rel_ids) + ) + + option_rels_by_product_rel_id: dict[int, list[OrderProductOptionRelation]] = {} + for option_rel in option_rels: + option_rels_by_product_rel_id.setdefault(option_rel.order_product_relation_id, []).append(option_rel) + + ticket_infos_by_product_rel_id = { + ticket_info.order_product_relation_id: ticket_info for ticket_info in ticket_infos + } + for product_rel in product_rels: + setattr(product_rel, _LOCKED_OPTIONS_ATTR, option_rels_by_product_rel_id.get(product_rel.id, [])) + setattr(product_rel, _LOCKED_TICKET_INFO_ATTR, ticket_infos_by_product_rel_id.get(product_rel.id)) + + return product_rels + + +def _build_product_validation_payload( + product_rel: OrderProductRelation, validation_mode: OrderableCheckSerializerMode +) -> dict: + option_rels = getattr(product_rel, _LOCKED_OPTIONS_ATTR, _MISSING) + if option_rels is _MISSING: + option_rels = product_rel.options.filter_active().order_by("id") + + payload = { + "product": product_rel.product_id, + "donation_price": product_rel.donation_price, + "options": [ + { + "product_option_group": option_rel.product_option_group_id, + "product_option": option_rel.product_option_id, + "custom_response": option_rel.custom_response, + } + for option_rel in option_rels + ], + } + + if ( + validation_mode == OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT + and product_rel.product.category.is_ticket + ): + ticket_info = getattr(product_rel, _LOCKED_TICKET_INFO_ATTR, _MISSING) + if ticket_info is _MISSING: + ticket_info = getattr(product_rel, "ticket_info", None) + if ticket_info: + payload["ticket_info"] = { + "name": ticket_info.name, + "phone": ticket_info.phone, + "email": ticket_info.email, + "organization": ticket_info.organization or "", + "contribution_message": ticket_info.contribution_message, + } + + return payload + + +def _clear_order_payment_cache(order: Order) -> None: + for attr in ( + "active_products", + "active_payment_histories", + "first_paid_price", + "first_payment_history", + "first_paid_at", + "current_payment_history", + "current_paid_price", + "current_status", + "latest_imp_id", + ): + order.__dict__.pop(attr, None) diff --git a/app/shop/payment_history/test/concurrent_webhook_test.py b/app/shop/payment_history/test/concurrent_webhook_test.py index e56c88d..830cc44 100644 --- a/app/shop/payment_history/test/concurrent_webhook_test.py +++ b/app/shop/payment_history/test/concurrent_webhook_test.py @@ -11,7 +11,8 @@ import pytest from django.db import connections -from shop.order.models import Order, SingleProductCart +from shop.conftest import VALID_TICKET_INFO +from shop.order.models import Order, SingleProductCart, TicketInfo from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus from shop.payment_history.serializers import PortOneV1WebhookRequestSerializer from shop.test.helpers import make_portone_payment_info, make_webhook_payload @@ -23,6 +24,7 @@ def test_concurrent_webhooks_for_same_cart_create_exactly_one_payment_history( ): cart_id = single_product_cart.id expected_price = single_product_cart.first_paid_price + TicketInfo.objects.create(order_product_relation=single_product_cart.order_product_relation, **VALID_TICKET_INFO) single_product_cart.prepare_payment() merchant_uid = single_product_cart.merchant_uid mock_portone_find_payment_info.return_value = make_portone_payment_info(order=single_product_cart) diff --git a/app/shop/payment_history/test/free_checkout_service_test.py b/app/shop/payment_history/test/free_checkout_service_test.py new file mode 100644 index 0000000..874d7d0 --- /dev/null +++ b/app/shop/payment_history/test/free_checkout_service_test.py @@ -0,0 +1,292 @@ +import uuid +from unittest.mock import patch + +import pytest +from core.const.shop_error_messages import CartNotOrderableErrorMessages, ProductNotOrderableErrorMessages +from core.util.testutil import errors_payload +from django.db import transaction +from rest_framework.exceptions import ValidationError +from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation, SingleProductCart, TicketInfo +from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus +from shop.payment_history.services import ( + _build_product_validation_payload, + _lock_order_allocation_rows, + complete_free_checkout, +) +from shop.serializers.cart_validation import OrderableCheckSerializerMode + + +@pytest.mark.django_db +def test_complete_free_checkout_marks_order_paid_and_records_zero_payment(customer_user, non_ticket_product): + order = Order.objects.create(user=customer_user, name="free") + OrderProductRelation.objects.create(order=order, product=non_ticket_product, price=0) + order.prepare_payment() + + with ( + patch("shop.payment_history.services.transaction.on_commit") as mock_on_commit, + patch("shop.payment_history.services.send_payment_completed_notifications.delay") as mock_delay, + ): + completed = complete_free_checkout(order) + mock_on_commit.assert_called_once() + mock_on_commit.call_args.args[0]() + + assert completed.id == order.id + assert list(completed.products.values_list("status", flat=True)) == [OrderProductRelation.OrderProductStatus.paid] + assert completed.current_status == PaymentHistoryStatus.completed + assert completed.current_paid_price == 0 + payment_history = PaymentHistory.objects.get(order=completed) + assert payment_history.status == PaymentHistoryStatus.completed + assert payment_history.price == 0 + assert payment_history.imp_id is None + mock_delay.assert_called_once_with(str(order.id)) + + +@pytest.mark.django_db +def test_complete_free_checkout_promotes_single_product_cart(single_product_cart, option): + product = single_product_cart.order_product_relation.product + product.price = 0 + product.donation_allowed = True + product.save(update_fields=["price", "donation_allowed"]) + single_product_cart.order_product_relation.price = 0 + single_product_cart.order_product_relation.donation_price = 0 + single_product_cart.order_product_relation.save(update_fields=["price", "donation_price"]) + OrderProductOptionRelation.objects.create( + order_product_relation=single_product_cart.order_product_relation, + product_option_group=option.group, + product_option=option, + custom_response=None, + ) + TicketInfo.objects.create( + order_product_relation=single_product_cart.order_product_relation, + name="김참가", + phone="010-9999-8888", + email="attendee@example.com", + organization="PSK", + contribution_message="응원합니다", + ) + single_product_cart.prepare_payment() + cart_id = single_product_cart.id + + with ( + patch("shop.payment_history.services.transaction.on_commit") as mock_on_commit, + patch("shop.payment_history.services.send_payment_completed_notifications.delay") as mock_delay, + ): + completed = complete_free_checkout(single_product_cart) + mock_on_commit.assert_called_once() + mock_on_commit.call_args.args[0]() + + assert completed.id == cart_id + assert Order.objects.filter(id=cart_id).exists() + assert not SingleProductCart.objects.filter(id=cart_id).exists() + completed_opr = completed.products.select_related("ticket_info").prefetch_related("options").get() + assert completed_opr.status == OrderProductRelation.OrderProductStatus.paid + assert completed_opr.price == 0 + assert completed_opr.donation_price == 0 + ticket_info = completed_opr.ticket_info + assert ticket_info.name == "김참가" + assert ticket_info.phone == "010-9999-8888" + assert ticket_info.email == "attendee@example.com" + assert ticket_info.organization == "PSK" + assert ticket_info.contribution_message == "응원합니다" + option_rel = completed_opr.options.get() + assert option_rel.product_option_group_id == option.group_id + assert option_rel.product_option_id == option.id + assert option_rel.custom_response is None + assert PaymentHistory.objects.filter( + order=completed, + status=PaymentHistoryStatus.completed, + price=0, + imp_id=None, + ).exists() + mock_delay.assert_called_once_with(str(cart_id)) + + +@pytest.mark.django_db +def test_lock_order_allocation_rows_attaches_payload_relations_without_extra_queries( + customer_user, ticket_product, option, django_assert_num_queries +): + order = Order.objects.create(user=customer_user, name="free") + product_rel = OrderProductRelation.objects.create(order=order, product=ticket_product, price=0) + option_rel = OrderProductOptionRelation.objects.create( + order_product_relation=product_rel, + product_option_group=option.group, + product_option=option, + custom_response=None, + ) + TicketInfo.objects.create( + order_product_relation=product_rel, + name="김참가", + phone="010-9999-8888", + email="attendee@example.com", + organization="PSK", + contribution_message="응원합니다", + ) + + with transaction.atomic(): + locked_product_rel = _lock_order_allocation_rows(order)[0] + + with django_assert_num_queries(0): + payload = _build_product_validation_payload( + locked_product_rel, OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT + ) + + assert payload == { + "product": ticket_product.id, + "donation_price": 0, + "options": [ + { + "product_option_group": option.group_id, + "product_option": option.id, + "custom_response": option_rel.custom_response, + } + ], + "ticket_info": { + "name": "김참가", + "phone": "010-9999-8888", + "email": "attendee@example.com", + "organization": "PSK", + "contribution_message": "응원합니다", + }, + } + + +@pytest.mark.django_db +def test_build_product_validation_payload_falls_back_to_related_rows(customer_user, ticket_product, option): + order = Order.objects.create(user=customer_user, name="free") + product_rel = OrderProductRelation.objects.create(order=order, product=ticket_product, price=0) + OrderProductOptionRelation.objects.create( + order_product_relation=product_rel, + product_option_group=option.group, + product_option=option, + custom_response="L", + ) + TicketInfo.objects.create( + order_product_relation=product_rel, + name="김참가", + phone="010-9999-8888", + email="attendee@example.com", + organization="PSK", + ) + + payload = _build_product_validation_payload(product_rel, OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT) + + assert payload["options"] == [ + { + "product_option_group": option.group_id, + "product_option": option.id, + "custom_response": "L", + } + ] + assert payload["ticket_info"]["email"] == "attendee@example.com" + + +@pytest.mark.django_db +def test_complete_free_checkout_rejects_empty_zero_price_order(customer_user): + order = Order.objects.create(user=customer_user, name="empty") + order.prepare_payment() + + with ( + patch("shop.payment_history.services.transaction.on_commit") as mock_on_commit, + pytest.raises(ValidationError) as exc_info, + ): + complete_free_checkout(order) + + assert errors_payload(exc_info.value.detail) == { + "non_field_errors": [{"detail": CartNotOrderableErrorMessages.EMPTY, "code": "invalid"}] + } + assert PaymentHistory.objects.filter(order=order).count() == 0 + mock_on_commit.assert_not_called() + + +@pytest.mark.django_db +def test_complete_free_checkout_rechecks_stock_after_single_cart_preparation(single_product_cart, other_user): + product = single_product_cart.order_product_relation.product + product.price = 0 + product.stock = 1 + product.save(update_fields=["price", "stock"]) + single_product_cart.order_product_relation.price = 0 + single_product_cart.order_product_relation.save(update_fields=["price"]) + TicketInfo.objects.create( + order_product_relation=single_product_cart.order_product_relation, + name="김참가", + phone="010-9999-8888", + email="attendee@example.com", + organization="PSK", + ) + single_product_cart.prepare_payment() + completed_order = Order.objects.create(user=other_user, name="already paid") + OrderProductRelation.objects.create( + order=completed_order, + product=product, + price=0, + status=OrderProductRelation.OrderProductStatus.paid, + ) + PaymentHistory.objects.create( + order=completed_order, + imp_id=None, + status=PaymentHistoryStatus.completed, + price=0, + ) + + with ( + patch("shop.payment_history.services.transaction.on_commit") as mock_on_commit, + pytest.raises(ValidationError) as exc_info, + ): + complete_free_checkout(single_product_cart) + + assert ProductNotOrderableErrorMessages.SOLDOUT.format(product.name) in str(exc_info.value.detail) + assert PaymentHistory.objects.filter(order_id=single_product_cart.id).count() == 0 + assert SingleProductCart.objects.filter(id=single_product_cart.id).exists() + mock_on_commit.assert_not_called() + + +@pytest.mark.django_db +def test_complete_free_checkout_rejects_duplicate_single_cart_completion(single_product_cart): + single_product_cart.order_product_relation.price = 0 + single_product_cart.order_product_relation.save(update_fields=["price"]) + TicketInfo.objects.create( + order_product_relation=single_product_cart.order_product_relation, + name="김참가", + phone="010-9999-8888", + email="attendee@example.com", + organization="PSK", + ) + single_product_cart.prepare_payment() + cart_id = single_product_cart.id + complete_free_checkout(single_product_cart) + + with ( + patch("shop.payment_history.services.transaction.on_commit") as mock_on_commit, + pytest.raises(ValidationError) as exc_info, + ): + complete_free_checkout(single_product_cart) + + assert errors_payload(exc_info.value.detail) == [ + {"detail": "이미 처리된 주문이거나 무료 완료로 전환할 수 없습니다.", "code": "invalid"} + ] + assert PaymentHistory.objects.filter(order_id=cart_id).count() == 1 + mock_on_commit.assert_not_called() + + +@pytest.mark.django_db +def test_complete_free_checkout_rejects_positive_amount(order_factory): + order = order_factory(status="prepared", is_ticket=False) + + with pytest.raises(ValidationError) as exc_info: + complete_free_checkout(order) + + assert errors_payload(exc_info.value.detail) == [ + {"detail": "무료 주문은 결제 준비 금액이 0원이어야 합니다.", "code": "invalid"} + ] + + +@pytest.mark.django_db +def test_complete_free_checkout_rejects_missing_single_cart_target(): + ghost_cart = SingleProductCart(id=uuid.uuid4()) + + with pytest.raises(ValidationError) as exc_info: + complete_free_checkout(ghost_cart) + + assert errors_payload(exc_info.value.detail) == [ + {"detail": "무료 주문 대상을 찾을 수 없습니다.", "code": "invalid"} + ] diff --git a/app/shop/payment_history/test/tasks_test.py b/app/shop/payment_history/test/tasks_test.py index b6fdb99..8280040 100644 --- a/app/shop/payment_history/test/tasks_test.py +++ b/app/shop/payment_history/test/tasks_test.py @@ -116,6 +116,21 @@ def test_creates_email_history_when_template_exists(order_with_customer, email_t assert sent_to.status == NotificationStatus.CREATED +@pytest.mark.django_db +def test_send_payment_completed_notifications_uses_zero_price_context( + order_factory, email_template, override_email_setting +): + order = order_factory(status="completed", product_price=0, imp_id=None) + + send_payment_completed_notifications(str(order.id)) + + history = EmailNotificationHistory.objects.filter_active().get() + sent_to = history.sent_to_list.get() + assert sent_to.context["first_paid_price"] == 0 + assert sent_to.context["first_paid_at"] == order.first_paid_at.isoformat() + assert sent_to.context["customer_email"] == order.customer_info.email + + # --------------------------------------------------------------------------- # Happy path — 알림톡 # --------------------------------------------------------------------------- diff --git a/app/shop/payment_history/test/webhook_serializer_test.py b/app/shop/payment_history/test/webhook_serializer_test.py index 1dc94e1..b9b0ada 100644 --- a/app/shop/payment_history/test/webhook_serializer_test.py +++ b/app/shop/payment_history/test/webhook_serializer_test.py @@ -6,7 +6,8 @@ from core.external_apis.portone.client import PortOneException from core.util.testutil import errors_payload from rest_framework import serializers as drf_serializers -from shop.order.models import Order, OrderProductRelation, SingleProductCart +from shop.conftest import VALID_TICKET_INFO +from shop.order.models import Order, OrderProductRelation, SingleProductCart, TicketInfo from shop.payment_history.models import PaymentHistoryStatus, PaymentWebhookEvent from shop.payment_history.serializers import PortOneV1WebhookRequestSerializer, PortOneV1WebhookRequestStatus from shop.test.helpers import make_portone_payment_info, make_webhook_payload @@ -167,6 +168,42 @@ def test_validate_rejects_when_paid_amount_does_not_match_order_price( ) +@pytest.mark.django_db +def test_validate_rejects_zero_amount_webhook_without_cancel( + mock_portone_find_payment_info, mock_portone_req_cancel_payment, order_factory +): + pending_order = order_factory(status="prepared") + mock_portone_find_payment_info.return_value = make_portone_payment_info(order=pending_order, amount=0) + serializer = PortOneV1WebhookRequestSerializer(data=make_webhook_payload(merchant_uid=pending_order.merchant_uid)) + + assert serializer.is_valid() is False + assert errors_payload(serializer.errors) == { + "non_field_errors": [ + {"detail": PortOneWebhookFailureCode.UNEXPECTED_PAID_PRICE.label, "code": "UNEXPECTED_PAID_PRICE"} + ], + } + mock_portone_req_cancel_payment.assert_not_called() + + +@pytest.mark.django_db +def test_validate_rejects_zero_prepared_order_webhook_without_free_checkout_bypass( + mock_portone_find_payment_info, mock_portone_req_cancel_payment, order_factory +): + pending_order = order_factory(status="prepared", product_price=0) + assert pending_order.prepared_price == 0 + mock_portone_find_payment_info.return_value = make_portone_payment_info(order=pending_order, amount=0) + serializer = PortOneV1WebhookRequestSerializer(data=make_webhook_payload(merchant_uid=pending_order.merchant_uid)) + + assert serializer.is_valid() is False + assert errors_payload(serializer.errors) == { + "non_field_errors": [ + {"detail": PortOneWebhookFailureCode.UNEXPECTED_PAID_PRICE.label, "code": "UNEXPECTED_PAID_PRICE"} + ], + } + assert pending_order.payment_histories.count() == 0 + mock_portone_req_cancel_payment.assert_not_called() + + @pytest.mark.django_db def test_validate_rejects_fractional_paid_amount( mock_portone_find_payment_info, mock_portone_req_cancel_payment, order_factory @@ -277,7 +314,7 @@ def test_validate_passes_happy_path_for_single_product_cart(single_product_cart, @pytest.mark.django_db def test_create_completes_pending_order(mock_portone_find_payment_info, order_factory): - pending_order = order_factory(status="prepared") + pending_order = order_factory(status="prepared", is_ticket=False) mock_portone_find_payment_info.return_value = make_portone_payment_info(order=pending_order, imp_uid="imp_done") serializer = PortOneV1WebhookRequestSerializer( data=make_webhook_payload(merchant_uid=pending_order.merchant_uid, imp_uid="imp_done") @@ -293,8 +330,41 @@ def test_create_completes_pending_order(mock_portone_find_payment_info, order_fa assert statuses == [OrderProductRelation.OrderProductStatus.paid] +@pytest.mark.django_db +def test_create_revalidates_allocation_after_validate_before_marking_paid( + mock_portone_find_payment_info, + mock_portone_req_cancel_payment, + order_factory, + non_ticket_product, +): + non_ticket_product.stock = 1 + non_ticket_product.save(update_fields=["stock"]) + pending_order = order_factory(status="prepared", is_ticket=False) + mock_portone_find_payment_info.return_value = make_portone_payment_info(order=pending_order) + serializer = PortOneV1WebhookRequestSerializer(data=make_webhook_payload(merchant_uid=pending_order.merchant_uid)) + assert serializer.is_valid() + + order_factory(status="completed", is_ticket=False) + + with pytest.raises(drf_serializers.ValidationError): + serializer.save() + + pending_order.refresh_from_db() + assert pending_order.payment_histories.count() == 0 + assert list(pending_order.products.values_list("status", flat=True)) == [ + OrderProductRelation.OrderProductStatus.pending, + ] + mock_portone_req_cancel_payment.assert_called_once_with( + imp_id="imp_test", + refund_request_price=pending_order.first_paid_price, + current_leftover_price=pending_order.first_paid_price, + reason=PortOneWebhookFailureCode.ORDER_NOT_ORDERABLE.label, + ) + + @pytest.mark.django_db def test_create_promotes_single_product_cart_to_order(single_product_cart, mock_portone_find_payment_info): + TicketInfo.objects.create(order_product_relation=single_product_cart.order_product_relation, **VALID_TICKET_INFO) single_product_cart.prepare_payment() mock_portone_find_payment_info.return_value = make_portone_payment_info(order=single_product_cart) serializer = PortOneV1WebhookRequestSerializer( @@ -330,7 +400,7 @@ def test_create_rejects_webhook_when_state_machine_blocks( @pytest.mark.django_db def test_create_registers_notification_task_on_commit(mock_portone_find_payment_info, mocked_on_commit, order_factory): - pending_order = order_factory(status="prepared") + pending_order = order_factory(status="prepared", is_ticket=False) mock_portone_find_payment_info.return_value = make_portone_payment_info(order=pending_order) serializer = PortOneV1WebhookRequestSerializer(data=make_webhook_payload(merchant_uid=pending_order.merchant_uid)) assert serializer.is_valid() diff --git a/app/shop/payment_history/test/webhook_view_test.py b/app/shop/payment_history/test/webhook_view_test.py index c06ea0c..336c633 100644 --- a/app/shop/payment_history/test/webhook_view_test.py +++ b/app/shop/payment_history/test/webhook_view_test.py @@ -59,7 +59,7 @@ def _post_webhook( def test_ip_allowlist_respects_debug_bypass( debug, expected_status, expected_body, mock_portone_find_payment_info, order_factory ): - pending_order = order_factory(status="prepared") + pending_order = order_factory(status="prepared", is_ticket=False) mock_portone_find_payment_info.return_value = make_portone_payment_info(order=pending_order) with override_settings(DEBUG=debug): response = _post_webhook(merchant_uid=pending_order.merchant_uid, ip=_NON_WHITELISTED_IP) @@ -96,7 +96,7 @@ def test_ip_allowlist_respects_debug_bypass( ) @pytest.mark.django_db def test_accepts_request_from_whitelisted_ip(post_kwargs, mock_portone_find_payment_info, order_factory): - pending_order = order_factory(status="prepared") + pending_order = order_factory(status="prepared", is_ticket=False) mock_portone_find_payment_info.return_value = make_portone_payment_info(order=pending_order) response = _post_webhook(merchant_uid=pending_order.merchant_uid, **post_kwargs) assert response.status_code == HTTP_200_OK diff --git a/app/shop/serializers/cart_validation/cart.py b/app/shop/serializers/cart_validation/cart.py index 00d2bb7..fb9e606 100644 --- a/app/shop/serializers/cart_validation/cart.py +++ b/app/shop/serializers/cart_validation/cart.py @@ -34,24 +34,28 @@ def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: def validate(self, data: dict) -> dict: cart: Order = data["cart"] + active_products = cart.products.filter_active() # 이미 결제한 장바구니인 경우 주문 불가능 # `current_status` 는 active PH 기준이라 != pending 이면 곧 active PH 가 존재 — 별도 exists() 불필요. if cart.current_status != PaymentHistoryStatus.pending: raise serializers.ValidationError(CartNotOrderableErrorMessages.ALREADY_ORDERED) + if not active_products.exists(): + raise serializers.ValidationError(CartNotOrderableErrorMessages.EMPTY) + # 장바구니 내에 이미 결제한 상품이 있는 경우 주문 불가능 - if cart.products.filter_active().exclude(status=OrderProductRelation.OrderProductStatus.pending).exists(): + if active_products.exclude(status=OrderProductRelation.OrderProductStatus.pending).exists(): raise serializers.ValidationError(CartNotOrderableErrorMessages.CONTAINS_PAID_PRODUCT) - # 장바구니 내의 상품들 주문 금액 합계가 0원 이하거나 100만원 이상인 경우 주문 불가능 - if cart.first_paid_price <= 0: + # 장바구니 내의 상품들 주문 금액 합계가 음수이거나 100만원 이상인 경우 주문 불가능 + if cart.first_paid_price < 0: raise serializers.ValidationError(CartNotOrderableErrorMessages.CART_PRICE_TOO_LOW) if cart.first_paid_price >= 1_000_000: raise serializers.ValidationError(CartNotOrderableErrorMessages.CART_PRICE_TOO_HIGH) # 티켓 상품은 결제 전 참가자 정보(TicketInfo)가 반드시 있어야 함 - if cart.products.filter_active().filter(product__category__is_ticket=True, ticket_info__isnull=True).exists(): + if active_products.filter(product__category__is_ticket=True, ticket_info__isnull=True).exists(): raise serializers.ValidationError(CartNotOrderableErrorMessages.TICKET_INFO_REQUIRED) return data diff --git a/app/shop/serializers/cart_validation/product.py b/app/shop/serializers/cart_validation/product.py index 58a871d..c3c97e7 100644 --- a/app/shop/serializers/cart_validation/product.py +++ b/app/shop/serializers/cart_validation/product.py @@ -52,7 +52,7 @@ class ProductOrderableCheckSerializer(serializers.ModelSerializer): - 재고 충분 (mode 별 cart 누적 / +1 / 무시 분기) - 인당 최대 구매 수량 안 (mode 별) - 후원 금액 정책: donation_allowed + [min, max] 범위 - - 총 금액 (product + donation + option additional) 이 [1원, 100만원) + - 총 금액 (product + donation + option additional) 이 [0원, 100만원) ==================== 상품군 (Tag) ==================== - TagOrderableCheckSerializer 위임 (재고 / 인당 한도) @@ -259,8 +259,8 @@ def validate( + donation_price + sum(o["product_option"].additional_price for o in options if o["product_option"]) ) - # 후원 금액 포함 단일 상품 금액이 0원 이하인 경우 주문 불가능 — PortOne 결제는 0원 금액에서 실패하므로 사전 차단. - if total_price <= 0: + # 후원 금액 포함 단일 상품 금액이 음수인 경우 주문 불가능. + if total_price < 0: raise serializers.ValidationError(ProductNotOrderableErrorMessages.PRICE_TOO_LOW) # 후원 금액 포함 단일 상품 금액이 100만원 이상인 경우 주문 불가능 if total_price >= 1_000_000: @@ -322,7 +322,9 @@ def create(self, validated_data: ProductOrderableCheckAfterValidationDataType) - } if self.validation_mode == OrderableCheckSerializerMode.ADD_SINGLE_PRODUCT_TO_CART: - if not (cart := Order.objects.filter(user=self.user).filter_has_no_payment_histories().first()): + self._lock_user_for_cart_update() + self._revalidate_add_to_cart_after_user_lock(validated_data) + if not (cart := self._get_unpaid_cart_for_update()): cart = Order.objects.create(user=self.user) order_product_rel_create_kwargs["order"] = cart elif self.validation_mode == OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT: @@ -351,3 +353,37 @@ def create(self, validated_data: ProductOrderableCheckAfterValidationDataType) - TicketInfo.objects.create(**ticket_info_data, order_product_relation=order_product_rel) return order_product_rel + + def _lock_user_for_cart_update(self) -> None: + UserExt.objects.select_for_update().only("id").get(id=self.user.id) + + def _revalidate_add_to_cart_after_user_lock( + self, validated_data: ProductOrderableCheckAfterValidationDataType + ) -> None: + payload: dict[str, object] = { + "product": validated_data["product"].id, + "donation_price": validated_data.get("donation_price", 0), + "options": [ + { + "product_option_group": option_data["product_option_group"].id, + "product_option": option_data["product_option"].id if option_data["product_option"] else None, + "custom_response": option_data["custom_response"], + } + for option_data in validated_data["options"] + ], + } + if ticket_info := validated_data.get("ticket_info"): + payload["ticket_info"] = ticket_info + + ProductOrderableCheckSerializer(data=payload, context=self.context).is_valid(raise_exception=True) + + def _get_unpaid_cart_for_update(self) -> Order | None: + cart = Order.objects.select_for_update().filter(user=self.user).filter_has_no_payment_histories().first() + if not cart: + return None + + # A concurrent free checkout can create PaymentHistory while this SELECT waits on the cart row. + # Recheck in a fresh statement after the lock so stale writers do not mutate a completed order. + if Order.objects.filter(id=cart.id).filter_has_no_payment_histories().exists(): + return cart + return None diff --git a/app/shop/test/cart_orderable_test.py b/app/shop/test/cart_orderable_test.py index 34703a1..1cddf6e 100644 --- a/app/shop/test/cart_orderable_test.py +++ b/app/shop/test/cart_orderable_test.py @@ -42,18 +42,42 @@ def test_cart_rejects_when_contains_paid_product(customer_user, order_factory): @pytest.mark.django_db -def test_cart_rejects_when_price_is_zero_or_negative(customer_user): - # OPR 없이 빈 cart — first_paid_price=0 → CART_PRICE_TOO_LOW. +def test_cart_rejects_when_empty(customer_user): empty_cart = Order.objects.create(user=customer_user, name="empty") serializer = CartOrderableCheckSerializer( data={"cart": str(empty_cart.id)}, context=make_serializer_context(customer_user) ) assert serializer.is_valid() is False assert errors_payload(serializer.errors) == { - "non_field_errors": [{"detail": CartNotOrderableErrorMessages.CART_PRICE_TOO_LOW, "code": "invalid"}], + "non_field_errors": [{"detail": CartNotOrderableErrorMessages.EMPTY, "code": "invalid"}], } +@pytest.mark.django_db +def test_cart_allows_zero_price_active_product(customer_user, non_ticket_product): + cart = Order.objects.create(user=customer_user, name="free") + OrderProductRelation.objects.create(order=cart, product=non_ticket_product, price=0) + + serializer = CartOrderableCheckSerializer( + data={"cart": str(cart.id)}, context=make_serializer_context(customer_user) + ) + + assert serializer.is_valid() is True + + +@pytest.mark.django_db +def test_cart_rejects_when_price_is_negative(customer_user, non_ticket_product): + cart = Order.objects.create(user=customer_user, name="negative") + OrderProductRelation.objects.create(order=cart, product=non_ticket_product, price=0) + cart.__dict__["first_paid_price"] = -1 + serializer = CartOrderableCheckSerializer(context=make_serializer_context(customer_user)) + + with pytest.raises(ValidationError) as exc_info: + serializer.validate({"cart": cart}) + + assert exc_info.value.detail[0] == CartNotOrderableErrorMessages.CART_PRICE_TOO_LOW + + @pytest.mark.django_db def test_cart_rejects_when_price_too_high(customer_user, non_ticket_product): # OPR 가격 1_000_000 이상 → CART_PRICE_TOO_HIGH. diff --git a/app/shop/test/patron_api_test.py b/app/shop/test/patron_api_test.py index 915241d..e8978eb 100644 --- a/app/shop/test/patron_api_test.py +++ b/app/shop/test/patron_api_test.py @@ -15,6 +15,19 @@ def test_patron_list_includes_only_orders_with_donation_product(anon_client, cus assert response.json() == [{"name": "홍길동", "contribution_message": ""}] +@pytest.mark.django_db +def test_patron_list_excludes_zero_won_donation_capable_order(anon_client, order_factory): + order = order_factory(status="completed", donation=0, is_ticket=False, product_price=0) + product = order.products.get().product + product.donation_allowed = True + product.save(update_fields=["donation_allowed"]) + + response = PatronApi(http_client=anon_client).list() + + assert response.status_code == HTTP_200_OK + assert response.json() == [] + + @pytest.mark.django_db def test_patron_list_excludes_refunded_orders(anon_client, order_factory): order_factory(status="refunded", donation=5000) diff --git a/app/shop/test/product_orderable_test.py b/app/shop/test/product_orderable_test.py index 95c0a71..b512c0f 100644 --- a/app/shop/test/product_orderable_test.py +++ b/app/shop/test/product_orderable_test.py @@ -198,12 +198,6 @@ def test_product_rejects_when_option_does_not_belong_to_product( lambda _product: ProductNotOrderableErrorMessages.PRICE_TOO_HIGH, id="total_price_too_high", ), - pytest.param( - {"price": 0}, - 0, - lambda _product: ProductNotOrderableErrorMessages.PRICE_TOO_LOW, - id="total_price_is_zero", - ), ], ) @pytest.mark.django_db @@ -232,6 +226,30 @@ def test_product_passes_happy_path(customer_user, non_ticket_product): assert serializer.is_valid() +@pytest.mark.django_db +def test_product_allows_zero_price_without_donation(customer_user, non_ticket_product): + non_ticket_product.price = 0 + non_ticket_product.save(update_fields=["price"]) + + serializer = ProductOrderableCheckSerializer( + data={"product": str(non_ticket_product.id), "options": []}, + context=make_serializer_context(customer_user), + ) + + assert serializer.is_valid() is True + + +@pytest.mark.django_db +def test_product_rejects_when_total_price_is_negative(customer_user, non_ticket_product): + non_ticket_product.price = -1 + serializer = ProductOrderableCheckSerializer(context=make_serializer_context(customer_user)) + + with pytest.raises(ValidationError) as exc_info: + serializer.validate({"product": non_ticket_product, "options": [], "donation_price": 0}) + + assert exc_info.value.detail[0] == ProductNotOrderableErrorMessages.PRICE_TOO_LOW + + @pytest.mark.django_db def test_product_create_in_add_mode_appends_to_existing_unpaid_cart(customer_user, non_ticket_product): # 기존 unpaid Order(cart) 있음 → 동일 cart 에 OPR append. diff --git a/app/shop/test/refund_serializer_test.py b/app/shop/test/refund_serializer_test.py index ac7b886..16895ed 100644 --- a/app/shop/test/refund_serializer_test.py +++ b/app/shop/test/refund_serializer_test.py @@ -56,6 +56,24 @@ def test_total_refund_rejects_when_order_has_no_imp_id(mock_portone_req_cancel_p mock_portone_req_cancel_payment.assert_not_called() +@pytest.mark.django_db +def test_total_refund_rejects_free_completed_order_without_portone_cancel( + mock_portone_req_cancel_payment, order_factory +): + order = order_factory(status="completed", product_price=0, imp_id=None) + serializer = OrderTotalRefundSerializer( + instance=order, + data={"id": str(order.id)}, + context={"check_totp": False}, + ) + + assert serializer.is_valid() is False + assert errors_payload(serializer.errors) == { + "non_field_errors": [{"detail": NotRefundableErrorMessages.ORDER_IMP_ID_NOT_EXIST, "code": "invalid"}], + } + mock_portone_req_cancel_payment.assert_not_called() + + @pytest.mark.django_db def test_total_refund_rejects_when_order_already_refunded(mock_portone_req_cancel_payment, order_factory): refunded_order = order_factory(status="refunded") @@ -332,6 +350,25 @@ def test_partial_refund_rejects_when_refund_window_expired(mock_portone_req_canc mock_portone_req_cancel_payment.assert_not_called() +@pytest.mark.django_db +def test_partial_refund_rejects_free_completed_order_without_portone_cancel( + mock_portone_req_cancel_payment, order_factory +): + order = order_factory(status="completed", product_price=0, imp_id=None) + opr = order.products.get() + serializer = OrderProductRefundSerializer( + instance=opr, + data={"id": str(opr.id)}, + context={"check_totp": False}, + ) + + assert serializer.is_valid() is False + assert errors_payload(serializer.errors) == { + "non_field_errors": [{"detail": NotRefundableErrorMessages.ORDER_NOT_REFUNDABLE, "code": "invalid"}], + } + mock_portone_req_cancel_payment.assert_not_called() + + @pytest.mark.django_db def test_total_refund_expected_price_is_zero_when_no_paid_oprs(order_factory): refunded_order = order_factory(status="refunded")