22
33import datetime
44import functools
5+ import json
56import typing
7+ from base64 import urlsafe_b64encode
8+ from collections .abc import Iterable
9+ from contextlib import suppress
10+ from hashlib import sha256
11+ from hmac import new as hmac_new
612from urllib .parse import urljoin
13+ from uuid import UUID , uuid4
714
15+ import shortuuid
816from core .const .shop_error_messages import NotRefundableErrorMessages
917from core .models import BaseAbstractModel , BaseAbstractModelQuerySet
1018from core .scancode_mixin import ScanCodeMixin
1725from simple_history .models import HistoricalRecords
1826
1927UserModel = get_user_model ()
28+ PAYMENT_HASH_LENGTH = 16
2029
2130
22- class OrderQuerySet (BaseAbstractModelQuerySet ):
31+ class PaymentPreparationMixin :
32+ id : UUID
33+ prepared_cart_snapshot : dict [str , typing .Any ] | None
34+ prepared_cart_hash : str | None
35+ first_paid_price : int
36+ products : typing .Any
37+
38+ @property
39+ def merchant_uid (self ) -> str | None :
40+ return f"{ shortuuid .encode (self .id )} .{ self .prepared_cart_hash } " if self .prepared_cart_hash else None
41+
42+ @property
43+ def prepared_price (self ) -> int | None :
44+ return self .prepared_cart_snapshot ["price" ] if self .prepared_cart_snapshot else None
45+
46+ def get_current_cart_snapshot (self , * , attempt : dict [str , str ] | None = None ) -> dict [str , typing .Any ]:
47+ products = self .products .filter_active ().prefetch_related (
48+ models .Prefetch ("options" , queryset = OrderProductOptionRelation .objects .filter_active ()),
49+ )
50+ return {
51+ "attempt" : attempt or {"id" : str (uuid4 ()), "prepared_at" : now_aware ().isoformat ()},
52+ "price" : self .first_paid_price ,
53+ "products" : [
54+ {
55+ "id" : str (product_rel .id ),
56+ "product" : str (product_rel .product_id ),
57+ "price" : product_rel .price ,
58+ "donation_price" : product_rel .donation_price ,
59+ "options" : [
60+ {
61+ "id" : str (option_rel .id ),
62+ "product_option_group" : str (option_rel .product_option_group_id ),
63+ "product_option" : (
64+ str (option_rel .product_option_id ) if option_rel .product_option_id is not None else None
65+ ),
66+ "custom_response" : option_rel .custom_response ,
67+ }
68+ for option_rel in sorted (product_rel .options .all (), key = lambda option_rel : str (option_rel .id ))
69+ ],
70+ }
71+ for product_rel in sorted (products , key = lambda rel : str (rel .id ))
72+ ],
73+ }
74+
75+ @staticmethod
76+ def _compute_cart_hash (snapshot : dict [str , typing .Any ]) -> str :
77+ digest = hmac_new (
78+ settings .SECRET_KEY .encode (),
79+ json .dumps (snapshot , ensure_ascii = True , separators = ("," , ":" ), sort_keys = True ).encode (),
80+ sha256 ,
81+ ).digest ()
82+ return urlsafe_b64encode (digest [:12 ]).decode ().rstrip ("=" )
83+
84+ def get_current_cart_hash (self , * , attempt : dict [str , str ] | None = None ) -> str :
85+ return self ._compute_cart_hash (self .get_current_cart_snapshot (attempt = attempt ))
86+
87+ def prepare_payment (self ) -> None :
88+ snapshot = self .get_current_cart_snapshot ()
89+ self .prepared_cart_snapshot = snapshot
90+ self .prepared_cart_hash = self ._compute_cart_hash (snapshot )
91+ self .save (update_fields = {"prepared_cart_snapshot" , "prepared_cart_hash" })
92+
93+ def matches_payment_preparation (self , merchant_uid : str , amount : int | float ) -> bool :
94+ if not self .prepared_cart_snapshot :
95+ return False
96+ try :
97+ amount_int = int (amount )
98+ except (TypeError , ValueError ):
99+ return False
100+ attempt = self .prepared_cart_snapshot .get ("attempt" )
101+ if not isinstance (attempt , dict ):
102+ return False
103+ return (
104+ self .merchant_uid == merchant_uid
105+ and amount == self .prepared_price == amount_int
106+ and self .prepared_cart_hash == self .get_current_cart_hash (attempt = attempt )
107+ )
108+
109+
110+ class BaseCartQuerySet (BaseAbstractModelQuerySet ):
111+ def filter_by_merchant_uid (self , merchant_uid : object ) -> models .QuerySet :
112+ with suppress (AttributeError , TypeError , ValueError ):
113+ encoded_id , cart_hash = merchant_uid .split ("." , 1 )
114+ return self .filter_active ().filter (id = shortuuid .decode (encoded_id ), prepared_cart_hash = cart_hash )
115+ return self .none ()
116+
117+
118+ class OrderQuerySet (BaseCartQuerySet ):
23119 def filter_has_payment_histories (self ) -> models .QuerySet [Order ]:
24120 return self .filter_active ().filter (models .Exists (PaymentHistory .objects .filter (order = models .OuterRef ("id" ))))
25121
@@ -67,11 +163,13 @@ def filter_in_last_six_months(self) -> models.QuerySet[Order]:
67163 return self .filter (created_at__gte = datetime .date .today () - datetime .timedelta (days = 183 ))
68164
69165
70- class Order (ScanCodeMixin , BaseAbstractModel ):
166+ class Order (PaymentPreparationMixin , ScanCodeMixin , BaseAbstractModel ):
71167 scancode_prefix = "order"
72168
73169 user = models .ForeignKey (UserModel , on_delete = models .PROTECT )
74170 name = models .TextField ()
171+ prepared_cart_snapshot = models .JSONField (null = True , blank = True )
172+ prepared_cart_hash = models .CharField (max_length = PAYMENT_HASH_LENGTH , null = True , blank = True )
75173
76174 payment_histories : BaseManager [PaymentHistory ]
77175 products : BaseManager [OrderProductRelation ]
@@ -234,6 +332,37 @@ class OrderProductStatus(models.TextChoices):
234332 def __str__ (self ) -> str : # pragma: no cover
235333 return f"[{ self .order } ] { self .product } ({ self .get_status_display ()} )"
236334
335+ def save ( # type: ignore[override]
336+ self ,
337+ * ,
338+ force_insert : bool = False ,
339+ force_update : bool = False ,
340+ using : str | None = None ,
341+ update_fields : Iterable [str ] | None = None ,
342+ clear_parent_preparation : bool = True ,
343+ ) -> None :
344+ super ().save (force_insert = force_insert , force_update = force_update , using = using , update_fields = update_fields )
345+ if clear_parent_preparation :
346+ self ._clear_parent_payment_preparation ()
347+
348+ def delete (self , using : str | None = None ) -> None :
349+ self ._clear_parent_payment_preparation ()
350+ super ().delete (using = using )
351+
352+ def _clear_parent_payment_preparation (self ) -> None :
353+ if self .status != OrderProductRelation .OrderProductStatus .pending :
354+ return
355+
356+ # `filter().update()` — snapshot 이 존재하는 row 만 매칭하는 단일 UPDATE.
357+ # 대부분의 cart 편집은 snapshot 없는 상태라 fetch 없이 0 row UPDATE 로 끝남.
358+ has_snapshot = models .Q (prepared_cart_snapshot__isnull = False ) | models .Q (prepared_cart_hash__isnull = False )
359+ cleared = {"prepared_cart_snapshot" : None , "prepared_cart_hash" : None }
360+
361+ if self .order_id :
362+ Order .objects .filter (id = self .order_id ).filter (has_snapshot ).update (** cleared )
363+ return
364+ SingleProductCart .objects .filter (order_product_relation_id = self .id ).filter (has_snapshot ).update (** cleared )
365+
237366 @property
238367 def not_refundable_reason (self ) -> str | None :
239368 """
@@ -280,14 +409,43 @@ def __str__(self) -> str: # pragma: no cover
280409 name = self .product_option .name if self .product_option else self .custom_response
281410 return f"{ self .product_option_group .name } - { name } "
282411
412+ def save ( # type: ignore[override]
413+ self ,
414+ * ,
415+ force_insert : bool = False ,
416+ force_update : bool = False ,
417+ using : str | None = None ,
418+ update_fields : Iterable [str ] | None = None ,
419+ ) -> None :
420+ super ().save (force_insert = force_insert , force_update = force_update , using = using , update_fields = update_fields )
421+ self ._clear_parent_payment_preparation ()
422+
423+ def delete (self , using : str | None = None ) -> None :
424+ self ._clear_parent_payment_preparation ()
425+ super ().delete (using = using )
426+
427+ def _clear_parent_payment_preparation (self ) -> None :
428+ order_product_relation = self .order_product_relation
429+ if order_product_relation .status != OrderProductRelation .OrderProductStatus .pending :
430+ return
431+ order_product_relation ._clear_parent_payment_preparation ()
283432
284- class SingleProductCart (BaseAbstractModel ):
433+
434+ class SingleProductCartQuerySet (BaseCartQuerySet ):
435+ pass
436+
437+
438+ class SingleProductCart (PaymentPreparationMixin , BaseAbstractModel ):
285439 user = models .ForeignKey (UserModel , on_delete = models .PROTECT )
286440 order_product_relation = models .OneToOneField (
287441 OrderProductRelation ,
288442 on_delete = models .PROTECT ,
289443 related_name = "single_product_cart" ,
290444 )
445+ prepared_cart_snapshot = models .JSONField (null = True , blank = True )
446+ prepared_cart_hash = models .CharField (max_length = PAYMENT_HASH_LENGTH , null = True , blank = True )
447+
448+ objects : SingleProductCartQuerySet = SingleProductCartQuerySet .as_manager () # type: ignore[assignment, misc]
291449
292450 history = HistoricalRecords ()
293451
@@ -298,9 +456,12 @@ def to_order(self) -> Order:
298456 name = self .order_product_relation .product .name ,
299457 name_ko = self .order_product_relation .product .name_ko ,
300458 name_en = self .order_product_relation .product .name_en ,
459+ prepared_cart_snapshot = self .prepared_cart_snapshot ,
460+ prepared_cart_hash = self .prepared_cart_hash ,
301461 )
302462 self .order_product_relation .order = order
303- self .order_product_relation .save ()
463+ # cart→Order 승격은 결제 직전 단계이므로 prepared snapshot 을 유지한 채 OPR 의 parent FK 만 갱신.
464+ self .order_product_relation .save (clear_parent_preparation = False )
304465
305466 CustomerInfo .objects .filter (single_product_cart = self ).update (order = order , single_product_cart = None )
306467
@@ -335,9 +496,9 @@ def is_cart(self) -> typing.Literal[True]:
335496 def payment_histories (self ) -> list [PaymentHistory ]:
336497 return []
337498
338- @functools . cached_property
339- def products (self ) -> list [OrderProductRelation ]:
340- return [ self .order_product_relation ]
499+ @property
500+ def products (self ) -> models . QuerySet [OrderProductRelation ]:
501+ return OrderProductRelation . objects . filter ( id = self .order_product_relation_id )
341502
342503 @functools .cached_property
343504 def name (self ) -> str :
0 commit comments