Skip to content

Commit b27f833

Browse files
committed
Merge remote-tracking branch 'origin/fix/zero-won-checkout'
2 parents eb63626 + 7b4f40f commit b27f833

29 files changed

Lines changed: 1303 additions & 44 deletions

app/admin_api/serializers/shop/orders.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ class Meta:
8585
current_paid_price = serializers.IntegerField(read_only=True)
8686
current_status = serializers.CharField(read_only=True)
8787
first_paid_at = serializers.DateTimeField(read_only=True)
88-
latest_imp_id = serializers.CharField(read_only=True)
88+
latest_imp_id = serializers.ReadOnlyField()
8989

9090
class Meta:
9191
model = Order

app/admin_api/test/shop/order_notifications_api_test.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,36 @@ def test_notification_preview_returns_recipients_for_completed_order(api_client,
5050
}
5151

5252

53+
@pytest.mark.django_db
54+
def test_notification_preview_includes_free_completed_order(api_client, order_email_template, order_factory):
55+
order = order_factory(status="completed", product_price=0, imp_id=None)
56+
scancode_url = urljoin(settings.BACKEND_DOMAIN, order.scancode_path)
57+
58+
response = OrderNotificationsAdminApi(http_client=api_client).preview(
59+
{"channel": "email", "template_id": str(order_email_template.id)}
60+
)
61+
62+
assert response.status_code == HTTP_200_OK
63+
assert response.json() == {
64+
"template_variables": ["customer_email", "customer_name", "first_paid_price", "order_name"],
65+
"recipients": [
66+
{
67+
"recipient": order.customer_info.email,
68+
"context": {
69+
"scancode_url": scancode_url,
70+
"order_name": order.name,
71+
"first_paid_at": order.first_paid_at.isoformat(),
72+
"first_paid_price": 0,
73+
"customer_name": order.customer_info.name,
74+
"customer_phone": order.customer_info.phone,
75+
"customer_email": order.customer_info.email,
76+
},
77+
"missing_variables": [],
78+
}
79+
],
80+
}
81+
82+
5383
@pytest.mark.django_db
5484
def test_notification_preview_rejects_unknown_template_id(api_client, order_factory):
5585
order_factory(status="completed")

app/admin_api/test/shop/orders_api_test.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33

44
import pandas
55
import pytest
6+
import yaml
67
from admin_api.serializers.shop.orders import OrderAdminSerializer
78
from admin_api.test.helpers import OrdersAdminApi
89
from admin_api.views.shop.orders import OrderAdminViewSet
10+
from core.const.shop_error_messages import NotRefundableErrorMessages
911
from freezegun import freeze_time
1012
from model_bakery import baker
1113
from rest_framework.fields import DateTimeField
@@ -17,6 +19,7 @@
1719
HTTP_403_FORBIDDEN,
1820
HTTP_404_NOT_FOUND,
1921
)
22+
from rest_framework.test import APIClient
2023
from shop.order.models import CustomerInfo, Order, OrderProductRelation
2124
from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus
2225

@@ -42,6 +45,22 @@ def test_admin_list_returns_only_orders_with_payment_history_and_products(api_cl
4245
}
4346

4447

48+
@pytest.mark.django_db
49+
def test_admin_list_includes_free_completed_order(api_client, order_factory):
50+
order = order_factory(status="completed", product_price=0, imp_id=None)
51+
52+
response = OrdersAdminApi(http_client=api_client).list()
53+
54+
assert response.status_code == HTTP_200_OK
55+
row = response.json()["results"][0]
56+
assert row["id"] == str(order.id)
57+
assert row["current_status"] == PaymentHistoryStatus.completed
58+
assert row["current_paid_price"] == 0
59+
assert row["latest_imp_id"] is None
60+
assert row["payment_histories"][0]["price"] == 0
61+
assert row["payment_histories"][0]["imp_id"] is None
62+
63+
4564
@pytest.mark.django_db
4665
def test_admin_list_orders_by_first_paid_at_desc(api_client, order_factory):
4766
# 먼저 생성된 주문(= created_at 이 더 과거)이 더 최근에 결제되도록 구성.
@@ -136,6 +155,19 @@ def test_admin_refund_action_refunds_order(api_client, mock_portone_req_cancel_p
136155
assert completed_order.payment_histories.filter(status=PaymentHistoryStatus.refunded).exists()
137156

138157

158+
@pytest.mark.django_db
159+
def test_admin_refund_action_rejects_free_completed_order_without_portone_cancel(
160+
api_client, mock_portone_req_cancel_payment, order_factory
161+
):
162+
order = order_factory(status="completed", product_price=0, imp_id=None)
163+
164+
response = OrdersAdminApi(http_client=api_client).refund(order.id)
165+
166+
assert response.status_code == HTTP_400_BAD_REQUEST
167+
assert NotRefundableErrorMessages.ORDER_IMP_ID_NOT_EXIST in str(response.json())
168+
mock_portone_req_cancel_payment.assert_not_called()
169+
170+
139171
@pytest.mark.django_db
140172
def test_admin_refund_product_action_does_partial_refund(
141173
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
165197
assert response.status_code == HTTP_404_NOT_FOUND
166198

167199

200+
@pytest.mark.django_db
201+
def test_admin_refund_product_action_rejects_free_completed_opr_without_portone_cancel(
202+
api_client, mock_portone_req_cancel_payment, order_factory
203+
):
204+
order = order_factory(status="completed", product_price=0, imp_id=None)
205+
opr = order.products.get()
206+
207+
response = OrdersAdminApi(http_client=api_client).refund_product(order.id, opr.id)
208+
209+
assert response.status_code == HTTP_400_BAD_REQUEST
210+
assert NotRefundableErrorMessages.ORDER_NOT_REFUNDABLE in str(response.json())
211+
mock_portone_req_cancel_payment.assert_not_called()
212+
213+
214+
@pytest.mark.django_db
215+
def test_admin_refund_actions_document_validation_error_responses():
216+
response = APIClient().get("/api/schema/v1/")
217+
assert response.status_code == HTTP_200_OK
218+
219+
schema = yaml.safe_load(response.content)
220+
total_refund_path = next(path for path in schema["paths"] if path.endswith("/admin-api/shop/order/{id}/refund/"))
221+
product_refund_path = next(
222+
path for path in schema["paths"] if path.endswith("/admin-api/shop/order/{id}/products/{rel_id}/refund/")
223+
)
224+
225+
for path in (total_refund_path, product_refund_path):
226+
responses = schema["paths"][path]["post"]["responses"]
227+
assert "204" in responses
228+
assert responses["400"]["content"]["application/json"]["schema"] == {
229+
"$ref": "#/components/schemas/ValidationErrorResponse",
230+
}
231+
232+
168233
@pytest.mark.django_db
169234
def test_admin_refund_allows_expired_window(api_client, mock_portone_req_cancel_payment, order_factory):
170235
completed_order = order_factory(status="completed")

app/admin_api/test/shop/products_api_test.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,27 @@ def test_admin_product_create_returns_201(api_client, ticket_product):
149149
assert Product.objects.filter(name_ko="신규 상품").exists()
150150

151151

152+
@pytest.mark.django_db
153+
def test_admin_product_create_allows_zero_price(api_client, ticket_product):
154+
response = ProductsAdminApi(http_client=api_client).create(
155+
{
156+
"name_ko": "무료 튜토리얼",
157+
"name_en": "Free Tutorial",
158+
"price": 0,
159+
"stock": 10,
160+
"visible_starts_at": FAR_PAST.isoformat(),
161+
"visible_ends_at": FAR_FUTURE.isoformat(),
162+
"orderable_starts_at": FAR_PAST.isoformat(),
163+
"orderable_ends_at": FAR_FUTURE.isoformat(),
164+
"refundable_ends_at": FAR_FUTURE.isoformat(),
165+
"category": str(ticket_product.category.id),
166+
}
167+
)
168+
assert response.status_code == HTTP_201_CREATED
169+
assert response.json()["price"] == 0
170+
assert Product.objects.filter(name_ko="무료 튜토리얼", price=0).exists()
171+
172+
152173
@pytest.mark.django_db
153174
def test_admin_product_partial_update_can_set_refundable_ends_at_null(api_client, ticket_product):
154175
# null = 환불 불가 상품. 운영자가 어드민에서 직접 지정하는 경로.

app/admin_api/views/shop/orders.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from django.db import models, transaction
1717
from django.http.response import StreamingHttpResponse
1818
from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema, extend_schema_view
19+
from drf_standardized_errors.openapi_serializers import ValidationErrorResponseSerializer
1920
from rest_framework import exceptions, mixins, parsers, request, response, status, viewsets
2021
from rest_framework.decorators import action
2122
from shop.order import exports, imports
@@ -92,7 +93,10 @@ class OrderAdminViewSet(
9293
@extend_schema(
9394
summary="주문 전체 환불",
9495
tags=[OpenAPITag.ADMIN_SHOP_ORDER_REFUND],
95-
responses={status.HTTP_204_NO_CONTENT: None},
96+
responses={
97+
status.HTTP_204_NO_CONTENT: None,
98+
status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer,
99+
},
96100
)
97101
@action(detail=True, methods=["post"], url_path="refund")
98102
@transaction.atomic
@@ -109,7 +113,10 @@ def refund(self, request: request.Request, pk: typing.Any = None) -> response.Re
109113
@extend_schema(
110114
summary="주문 부분 환불",
111115
tags=[OpenAPITag.ADMIN_SHOP_ORDER_REFUND],
112-
responses={status.HTTP_204_NO_CONTENT: None},
116+
responses={
117+
status.HTTP_204_NO_CONTENT: None,
118+
status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer,
119+
},
113120
)
114121
@action(detail=True, methods=["post"], url_path=r"products/(?P<rel_id>[^/.]+)/refund")
115122
@transaction.atomic

app/core/const/shop_error_messages.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class ProductNotOrderableErrorMessages:
2222
SOLDOUT = "{} 상품은 매진되었습니다."
2323
ALREADY_ORDERED_TOO_MUCH = "{} 상품의 인당 최대 구매 수량 초과로 구매하실 수 없습니다."
2424
TOO_MUCH_CART_PRODUCT = "{} 상품의 재고 수량을 초과하여 구매하실 수 없습니다. 장바구니에 담은 수량을 확인해주세요."
25-
PRICE_TOO_LOW = "결제 금액이 너무 낮습니다, 최소한 1원 이상으로 구매해주세요."
25+
PRICE_TOO_LOW = "결제 금액이 너무 낮습니다, 최소한 0원 이상으로 구매해주세요."
2626
PRICE_TOO_HIGH = "결제 금액이 너무 높습니다, 후원 금액 등을 줄여 100만원 미만으로 구매해주세요."
2727
DONATION_NOT_ALLOWED = "{} 상품은 후원이 불가능한 상품입니다."
2828
DONATION_PRICE_OUT_OF_RANGE = "{} 상품의 후원 금액이 범위를 벗어났습니다. {}원 이상 {}원 이하로 입력해주세요."
@@ -57,11 +57,17 @@ class CartNotOrderableErrorMessages:
5757
ALREADY_ORDERED = "이미 결제한 장바구니입니다."
5858
CONTAINS_PAID_PRODUCT = "결제한 상품이 포함되어 있습니다. PyCon 한국 준비 위원회에 문의해주세요."
5959
EMPTY = "장바구니가 비어있습니다, 먼저 상품을 담아주세요."
60-
CART_PRICE_TOO_LOW = "장바구니의 금액이 너무 낮습니다. 최소한 1원 이상으로 구매해주세요."
60+
CART_PRICE_TOO_LOW = "장바구니의 금액이 너무 낮습니다. 최소한 0원 이상으로 구매해주세요."
6161
CART_PRICE_TOO_HIGH = "장바구니의 금액이 너무 높습니다. 일부 상품을 제거하여 100만원 미만으로 구매해주세요."
6262
TICKET_INFO_REQUIRED = "참가자 정보가 입력되지 않은 티켓이 있습니다. 모든 티켓의 참가자 정보를 입력해주세요."
6363

6464

65+
class FreeCheckoutErrorMessages:
66+
PRICE_NOT_ZERO = "무료 주문은 결제 준비 금액이 0원이어야 합니다."
67+
TARGET_NOT_FOUND = "무료 주문 대상을 찾을 수 없습니다."
68+
ILLEGAL_STATUS_TRANSITION = "이미 처리된 주문이거나 무료 완료로 전환할 수 없습니다."
69+
70+
6571
class NotRefundableErrorMessages:
6672
ONE_OF_PRODUCT_IS_USED = "주문 중 이미 사용한 상품이 존재합니다. 개별 환불을 진행해주세요."
6773
ONE_OF_PRODUCT_IS_USED_TRY_AFTER_CHANGING_STATUS = (
@@ -99,6 +105,7 @@ class PortOneWebhookFailureCode(models.TextChoices):
99105
UNEXPECTED_RETRIEVED_ORDER_STATUS = "UNEXPECTED_RETRIEVED_ORDER_STATUS", "예상한 결제 상태가 아닙니다."
100106
UNEXPECTED_RETRIEVED_ORDER_ID = "UNEXPECTED_RETRIEVED_ORDER_ID", "결제 ID가 일치하지 않습니다."
101107
UNEXPECTED_PAID_PRICE = "UNEXPECTED_PAID_PRICE", "결제 금액이 일치하지 않습니다."
108+
ORDER_NOT_ORDERABLE = "ORDER_NOT_ORDERABLE", "주문 가능한 상태가 아니거나 재고가 부족합니다."
102109
UNSUPPORTED_CURRENCY = "UNSUPPORTED_CURRENCY", "지원하지 않는 통화입니다."
103110
ILLEGAL_STATUS_TRANSITION = "ILLEGAL_STATUS_TRANSITION", "이미 처리된 결제이거나 허용되지 않는 상태 전환입니다."
104111
CANCELLED_NOT_SUPPORTED = (

app/shop/conftest.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,14 @@ def order_factory(request, customer_user):
243243
``donation>0`` 이면 종류와 무관하게 ``donation_product`` 사용.
244244
"""
245245

246-
def make(*, status: OrderStatus = "cart", donation: int = 0, is_ticket: bool = True) -> Order:
246+
def make(
247+
*,
248+
status: OrderStatus = "cart",
249+
donation: int = 0,
250+
is_ticket: bool = True,
251+
product_price: int | None = None,
252+
imp_id: str | None = _COMPLETED_ORDER_IMP_ID,
253+
) -> Order:
247254
if status == "empty":
248255
return Order.objects.create(user=customer_user, name="cart")
249256

@@ -256,7 +263,10 @@ def make(*, status: OrderStatus = "cart", donation: int = 0, is_ticket: bool = T
256263
name_en=used_product.name_en,
257264
)
258265
OrderProductRelation.objects.create(
259-
order=order, product=used_product, price=used_product.price, donation_price=donation
266+
order=order,
267+
product=used_product,
268+
price=used_product.price if product_price is None else product_price,
269+
donation_price=donation,
260270
)
261271
CustomerInfo.objects.create(order=order, name="홍길동", phone="01012345678", email="customer@example.com")
262272

@@ -270,7 +280,7 @@ def make(*, status: OrderStatus = "cart", donation: int = 0, is_ticket: bool = T
270280
order.products.update(status=OrderProductRelation.OrderProductStatus.paid)
271281
PaymentHistory.objects.create(
272282
order=order,
273-
imp_id=_COMPLETED_ORDER_IMP_ID,
283+
imp_id=imp_id,
274284
status=PaymentHistoryStatus.completed,
275285
price=order.first_paid_price,
276286
)
@@ -287,14 +297,14 @@ def make(*, status: OrderStatus = "cart", donation: int = 0, is_ticket: bool = T
287297
if status == "refunded":
288298
order.products.update(status=OrderProductRelation.OrderProductStatus.refunded)
289299
second_ph = PaymentHistory.objects.create(
290-
order=order, imp_id=_COMPLETED_ORDER_IMP_ID, status=PaymentHistoryStatus.refunded, price=0
300+
order=order, imp_id=imp_id, status=PaymentHistoryStatus.refunded, price=0
291301
)
292302
PaymentHistory.objects.filter(id=second_ph.id).update(created_at=later_at)
293303
return order
294304
if status == "partial_refunded":
295305
second_ph = PaymentHistory.objects.create(
296306
order=order,
297-
imp_id=_COMPLETED_ORDER_IMP_ID,
307+
imp_id=imp_id,
298308
status=PaymentHistoryStatus.partial_refunded,
299309
price=order.first_paid_price // 2,
300310
)

app/shop/order/serializers/dto.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,21 @@ class Meta:
156156
"merchant_uid",
157157
)
158158
model = SingleProductCart
159+
160+
161+
class CreateSingleProductOrderResponseDto(serializers.Serializer):
162+
id = serializers.UUIDField()
163+
name = serializers.CharField()
164+
payment_histories = PaymentHistoryDto(many=True)
165+
products = OrderProductRelationDto(many=True)
166+
first_paid_price = serializers.IntegerField()
167+
current_paid_price = serializers.IntegerField()
168+
current_status = serializers.ChoiceField(choices=PaymentHistoryStatus.choices)
169+
created_at = serializers.DateTimeField()
170+
customer_info = CustomerInfoDto(allow_null=True)
171+
merchant_uid = serializers.CharField(allow_null=True)
172+
173+
# 0원 즉시 완료 응답(OrderDto)에만 존재하는 필드. 유료 SingleProductCart 응답에는 없음.
174+
scancode_url = serializers.URLField(required=False, allow_null=True)
175+
first_paid_at = serializers.DateTimeField(required=False, allow_null=True)
176+
not_fully_refundable_reason = serializers.CharField(required=False, allow_null=True)

0 commit comments

Comments
 (0)