Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/admin_api/serializers/shop/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions app/admin_api/test/shop/order_notifications_api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
65 changes: 65 additions & 0 deletions app/admin_api/test/shop/orders_api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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 이 더 과거)이 더 최근에 결제되도록 구성.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
21 changes: 21 additions & 0 deletions app/admin_api/test/shop/products_api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 환불 불가 상품. 운영자가 어드민에서 직접 지정하는 경로.
Expand Down
11 changes: 9 additions & 2 deletions app/admin_api/views/shop/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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<rel_id>[^/.]+)/refund")
@transaction.atomic
Expand Down
5 changes: 3 additions & 2 deletions app/core/const/shop_error_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "{} 상품의 후원 금액이 범위를 벗어났습니다. {}원 이상 {}원 이하로 입력해주세요."
Expand Down Expand Up @@ -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 = "참가자 정보가 입력되지 않은 티켓이 있습니다. 모든 티켓의 참가자 정보를 입력해주세요."

Expand Down Expand Up @@ -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 = (
Expand Down
20 changes: 15 additions & 5 deletions app/shop/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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")

Expand All @@ -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,
)
Expand All @@ -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,
)
Expand Down
18 changes: 18 additions & 0 deletions app/shop/order/serializers/dto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading
Loading