22
33from core .const .shop_error_messages import PortOneWebhookFailureMessages
44from core .external_apis .portone .client import PortOneException , PortOneExceptionGroup , portone_client
5- from django .db import models
5+ from django .db import models , transaction
66from rest_framework import serializers
77from shop .order .models import Order , OrderProductRelation , SingleProductCart
8- from shop .payment_history .models import PaymentHistory , PaymentHistoryStatus
8+ from shop .payment_history .models import PaymentHistory , PaymentHistoryStatus , is_legal_payment_status_transition
99
1010
1111class PortOneV1PaymentStatus (models .TextChoices ):
@@ -83,6 +83,11 @@ def validate_status(self, value: str) -> str:
8383 )
8484 elif value == PortOneV1WebhookRequestStatus .FAILED :
8585 raise serializers .ValidationError (detail = PortOneWebhookFailureMessages .PURCHASE_FAILED , code = "forgery" )
86+ elif value == PortOneV1WebhookRequestStatus .CANCELLED :
87+ # TODO: 관리자 콘솔 취소 자동 처리는 미구현 — 우선 거부하고 운영자가 수동으로 환불 처리.
88+ raise serializers .ValidationError (
89+ detail = PortOneWebhookFailureMessages .CANCELLED_NOT_SUPPORTED , code = "unsupported"
90+ )
8691 return value
8792
8893 def validate (self , data : dict ) -> dict :
@@ -98,7 +103,7 @@ def validate(self, data: dict) -> dict:
98103 except (PortOneException , PortOneExceptionGroup ) as e :
99104 raise serializers .ValidationError (detail = str (e ), code = "portone_error" ) from e
100105
101- if retrieved_order_data ["status" ] not in ( PortOneV1PaymentStatus .PAID , PortOneV1PaymentStatus . CANCELLED ) :
106+ if retrieved_order_data ["status" ] != PortOneV1PaymentStatus .PAID :
102107 raise serializers .ValidationError (
103108 detail = PortOneWebhookFailureMessages .UNEXPECTED_RETRIEVED_ORDER_STATUS , code = "forgery"
104109 )
@@ -121,13 +126,18 @@ def validate(self, data: dict) -> dict:
121126
122127 return data
123128
129+ @transaction .atomic
124130 def create (self , validated_data : dict ) -> PaymentHistory :
125- # TODO: 결제 취소 (payment_serializer.validated_data["status"] == PortOneV1PaymentStatus.CANCELLED)인 경우,
126- # cancellation_id를 확인하고 환불 내역을 저장하는 로직을 추가해야 합니다.
131+ # CANCELLED webhook (관리자 콘솔 취소) 자동 처리는 미구현 — validate_status 에서 거부됨.
127132 payment_info = PortOneV1PaymentDetailSerializer (instance = self .portone_payment_info ).data
133+ order = self ._lock_or_promote_order (validated_data ["merchant_uid" ])
128134
129- assert (order_or_cart := self .cart_or_order ) # nosec: B101
130- order = order_or_cart .to_order () if isinstance (order_or_cart , SingleProductCart ) else order_or_cart
135+ # State machine — webhook retry 등 중복/불법 전이는 거부.
136+ next_status = PaymentHistoryStatus .completed
137+ if not is_legal_payment_status_transition (order .current_status , next_status ):
138+ raise serializers .ValidationError (
139+ detail = PortOneWebhookFailureMessages .ILLEGAL_STATUS_TRANSITION , code = "illegal_transition"
140+ )
131141
132142 for product_rel in order .products .all ():
133143 product_rel .status = OrderProductRelation .OrderProductStatus .paid
@@ -136,10 +146,26 @@ def create(self, validated_data: dict) -> PaymentHistory:
136146 return PaymentHistory .objects .create (
137147 order = order ,
138148 imp_id = validated_data ["imp_uid" ],
139- status = PaymentHistoryStatus . completed ,
149+ status = next_status ,
140150 price = payment_info ["amount" ],
141151 )
142152
153+ @staticmethod
154+ def _lock_or_promote_order (obj_id : str ) -> Order :
155+ """Order 가 있으면 lock 하여 반환. SingleProductCart 만 있으면 lock + to_order() 로 승격.
156+
157+ 동시 webhook race 시: 첫 호출이 cart lock + to_order() commit 후, 두 번째 호출은
158+ cart 가 hard_delete 된 상태로 lock 해제됨 → Order 재조회에서 승격된 Order 발견.
159+ """
160+ if order := Order .objects .select_for_update ().filter_active ().filter (id = obj_id ).first ():
161+ return order
162+ if cart := SingleProductCart .objects .select_for_update ().filter_active ().filter (id = obj_id ).first ():
163+ return cart .to_order ()
164+ # 첫 lock 시 cart 가 다른 webhook 에 의해 promote 된 경우 — Order 재조회.
165+ if order := Order .objects .select_for_update ().filter_active ().filter (id = obj_id ).first ():
166+ return order
167+ raise serializers .ValidationError (detail = PortOneWebhookFailureMessages .ORDER_NOT_FOUND , code = "forgery" )
168+
143169
144170class PortOneV1WebhookResponseSerializer (serializers .Serializer ):
145171 status = serializers .CharField (default = "success" , read_only = True )
0 commit comments