Wallet.cs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. using System.ComponentModel.DataAnnotations;
  2. using System.ComponentModel.DataAnnotations.Schema;
  3. using Domain.Entities.Common.ValueObject;
  4. using Domain.Entities.Members;
  5. using Domain.Entities.Wallets.Policy;
  6. using Domain.Entities.Wallets.ValueObject;
  7. namespace Domain.Entities.Wallets;
  8. public class Wallet
  9. {
  10. [ForeignKey(nameof(MemberID))]
  11. public virtual Member Member { get; private set; } = null!;
  12. private readonly List<WalletBalance> _balances = [];
  13. public IReadOnlyCollection<WalletBalance> Balances => _balances;
  14. private readonly List<WalletTransaction> _transactions = [];
  15. public IReadOnlyCollection<WalletTransaction> Transactions => _transactions;
  16. [Key]
  17. public int ID { get; private set; }
  18. public Guid WalletKey { get; private set; } = Guid.NewGuid();
  19. public int MemberID { get; private set; }
  20. public DateTime? UpdatedAt { get; private set; }
  21. public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
  22. private Wallet() { }
  23. private Wallet(int memberID)
  24. {
  25. if (memberID <= 0)
  26. {
  27. throw new ArgumentOutOfRangeException(nameof(memberID));
  28. }
  29. MemberID = memberID;
  30. // 지갑 생성 시 구분별 잔액 기본 생성
  31. EnsureBalance(WalletBalanceType.PgCharged);
  32. EnsureBalance(WalletBalanceType.Deposit);
  33. EnsureBalance(WalletBalanceType.Donation);
  34. EnsureBalance(WalletBalanceType.Reward);
  35. EnsureBalance(WalletBalanceType.Airdrop);
  36. EnsureBalance(WalletBalanceType.Locked);
  37. EnsureBalance(WalletBalanceType.Adjusted);
  38. }
  39. public static Wallet Create(int memberID) => new(memberID);
  40. public Money GetBalance(WalletBalanceType type) => GetBalanceEntity(type).Amount;
  41. public Money GetTotalAvailable()
  42. {
  43. var total = Money.KRW(0);
  44. foreach (var b in Balances.Where(x => x.Type != WalletBalanceType.Locked))
  45. total += b.Amount;
  46. return total;
  47. }
  48. /// <summary>
  49. /// 상점 결제로 사용 가능한 잔액 합계 (SpendPolicy.StoreOrderSpendOrder 기준).
  50. /// 비활성 잔액 유형(원장 호환용)과 Locked 는 제외.
  51. /// </summary>
  52. public Money GetStoreOrderAvailable()
  53. {
  54. var total = Money.KRW(0);
  55. foreach (var type in Policy.SpendPolicy.StoreOrderSpendOrder)
  56. {
  57. total += GetBalance(type);
  58. }
  59. return total;
  60. }
  61. // ---- Credit ----
  62. public void CreditPgCharge(Money amount, string reason = "PG_CHARGE", string? refID = null)
  63. => Credit(WalletBalanceType.PgCharged, WalletTransactionType.Charge, amount, reason, refID);
  64. public void CreditDonationIn(Money amount, string reason, string? refID = null)
  65. => Credit(WalletBalanceType.Donation, WalletTransactionType.DonationIn, amount, reason, refID);
  66. public void CreditReward(Money amount, string reason = "REWARD", string? refID = null)
  67. => Credit(WalletBalanceType.Reward, WalletTransactionType.RewardEarned, amount, reason, refID);
  68. /// <summary>
  69. /// 상점 환불 환원: 원본 결제(OrderPay)가 차감한 BalanceType 그대로 복원해 출처를 보존한다.
  70. /// 호출자는 OrderPay 트랜잭션을 BalanceType 별로 집계해 type 마다 한 번씩 호출한다.
  71. /// </summary>
  72. public void CreditStoreOrderRefund(WalletBalanceType type, Money amount, string reason = "ORDER_REFUND", string? refID = null)
  73. => Credit(type, WalletTransactionType.OrderRefund, amount, reason, refID);
  74. private void Credit(
  75. WalletBalanceType balanceType,
  76. WalletTransactionType txType,
  77. Money amount,
  78. string reason,
  79. string? refID
  80. ) {
  81. EnsureMoney(amount);
  82. var balance = EnsureBalance(balanceType);
  83. balance.Increase(amount);
  84. _transactions.Add(WalletTransaction.Create(
  85. walletKey: WalletKey,
  86. balanceType: balanceType,
  87. txType: txType,
  88. amount: amount,
  89. balanceAfter: balance.Amount,
  90. reason: reason,
  91. refID: refID
  92. ));
  93. }
  94. // ---- Debit ----
  95. public void DebitDonationOut(Money amount, string reason, string? refID = null)
  96. => DebitSingle(WalletBalanceType.Donation, WalletTransactionType.DonationOut, amount, reason, refID);
  97. /// <summary>
  98. /// 상점 결제 전용 다중 잔액 차감: SpendPolicy.StoreOrderSpendOrder 순서로 분할 차감하고
  99. /// 사용된 BalanceType 별로 OrderPay 트랜잭션을 각각 기록한다 (환불 시 출처 복원용).
  100. /// 합계 부족 시 InvalidOperationException — 호출자가 GetStoreOrderAvailable 로 사전 검증할 것.
  101. /// </summary>
  102. public IReadOnlyList<(WalletBalanceType Type, Money Amount)> DebitForStoreOrder(
  103. Money total,
  104. string reason = "ORDER_PAY",
  105. string? refID = null)
  106. {
  107. EnsureMoney(total);
  108. var remaining = total;
  109. var splits = new List<(WalletBalanceType, Money)>();
  110. foreach (var type in Policy.SpendPolicy.StoreOrderSpendOrder)
  111. {
  112. if (remaining.IsZero) break;
  113. var balance = EnsureBalance(type);
  114. if (balance.Amount.IsZero) continue;
  115. var takeValue = Math.Min(balance.Amount.Value, remaining.Value);
  116. if (takeValue <= 0) continue;
  117. var take = Money.KRW(takeValue);
  118. balance.Decrease(take);
  119. remaining = remaining - take;
  120. _transactions.Add(WalletTransaction.Create(
  121. walletKey: WalletKey,
  122. balanceType: type,
  123. txType: WalletTransactionType.OrderPay,
  124. amount: take,
  125. balanceAfter: balance.Amount,
  126. reason: reason,
  127. refID: refID
  128. ));
  129. splits.Add((type, take));
  130. }
  131. if (!remaining.IsZero)
  132. {
  133. throw new InvalidOperationException("Insufficient balance for store order.");
  134. }
  135. return splits;
  136. }
  137. /// <summary>
  138. /// PG 결제 취소 회수: 충전분(PgCharged)을 되돌린다.
  139. /// 잔액 부족 시 WalletBalance.Decrease 예외 — 호출자가 PgCharged 잔액을 사전 검증할 것.
  140. /// </summary>
  141. public void DebitChargeCancel(Money amount, string reason = "CHARGE_CANCEL", string? refID = null)
  142. => DebitSingle(WalletBalanceType.PgCharged, WalletTransactionType.ChargeCancelled, amount, reason, refID);
  143. // ---- Spend (정책 순서대로 분할 차감) ----
  144. public void Spend(Money amount, string reason = "SPEND", string? refID = null)
  145. {
  146. EnsureMoney(amount);
  147. var remaining = amount;
  148. foreach (var type in SpendPolicy.DefaultSpendOrder)
  149. {
  150. if (remaining.IsZero) break;
  151. var balance = EnsureBalance(type);
  152. if (balance.Amount.IsZero) continue;
  153. var takeValue = Math.Min(balance.Amount.Value, remaining.Value);
  154. if (takeValue <= 0) continue;
  155. var take = Money.KRW(takeValue);
  156. balance.Decrease(take);
  157. remaining = remaining - take;
  158. _transactions.Add(WalletTransaction.Create(
  159. walletKey: WalletKey,
  160. balanceType: type,
  161. txType: WalletTransactionType.Spend,
  162. amount: take,
  163. balanceAfter: balance.Amount,
  164. reason: reason,
  165. refID: refID
  166. ));
  167. }
  168. if (!remaining.IsZero)
  169. {
  170. throw new InvalidOperationException("Insufficient balance for spending.");
  171. }
  172. }
  173. // ---- Lock/Unlock ----
  174. public void LockForWithdrawal(Money amount, string reason = "WITHDRAW_REQUEST", string? refID = null)
  175. {
  176. EnsureMoney(amount);
  177. var from = EnsureBalance(WalletBalanceType.Donation);
  178. var locked = EnsureBalance(WalletBalanceType.Locked);
  179. from.Decrease(amount);
  180. _transactions.Add(WalletTransaction.Create(WalletKey, WalletBalanceType.Donation, WalletTransactionType.Lock, amount, from.Amount, reason, refID));
  181. locked.Increase(amount);
  182. _transactions.Add(WalletTransaction.Create(WalletKey, WalletBalanceType.Locked, WalletTransactionType.Lock, amount, locked.Amount, reason, refID));
  183. }
  184. public void UnlockWithdrawal(Money amount, string reason = "WITHDRAW_CANCEL", string? refID = null)
  185. {
  186. EnsureMoney(amount);
  187. var locked = EnsureBalance(WalletBalanceType.Locked);
  188. var donation = EnsureBalance(WalletBalanceType.Donation);
  189. locked.Decrease(amount);
  190. _transactions.Add(WalletTransaction.Create(WalletKey, WalletBalanceType.Locked, WalletTransactionType.Unlock, amount, locked.Amount, reason, refID));
  191. donation.Increase(amount);
  192. _transactions.Add(WalletTransaction.Create(WalletKey, WalletBalanceType.Donation, WalletTransactionType.Unlock, amount, donation.Amount, reason, refID));
  193. }
  194. // ---- Adjust ----
  195. public void AdjustIncrease(Money amount, string reason, string? refID = null, string? memo = null)
  196. => AdjustIncrease(WalletBalanceType.Adjusted, amount, reason, refID, memo);
  197. /// <summary>
  198. /// 관리자 조정 충전: 지정한 잔액 유형으로 입금. 트랜잭션 타입은 Adjusted 로 기록해 수동 조작 추적을 보존한다.
  199. /// Locked 는 출금 프로세스 전용이라 직접 조정 불가.
  200. /// </summary>
  201. public void AdjustIncrease(WalletBalanceType type, Money amount, string reason, string? refID = null, string? memo = null)
  202. {
  203. EnsureMoney(amount);
  204. EnsureAdjustableType(type);
  205. if (string.IsNullOrWhiteSpace(reason))
  206. {
  207. throw new ArgumentException("Adjustment reason is required.", nameof(reason));
  208. }
  209. var balance = EnsureBalance(type);
  210. balance.Increase(amount);
  211. _transactions.Add(WalletTransaction.Create(
  212. walletKey: WalletKey,
  213. balanceType: type,
  214. txType: WalletTransactionType.Adjusted,
  215. amount: amount,
  216. balanceAfter: balance.Amount,
  217. reason: $"ADJUST_IN:{reason}",
  218. refID: refID,
  219. memo: memo
  220. ));
  221. }
  222. public void AdjustDecrease(Money amount, string reason, string? refID = null, string? memo = null)
  223. => AdjustDecrease(WalletBalanceType.Adjusted, amount, reason, refID, memo);
  224. /// <summary>
  225. /// 관리자 조정 차감: 지정한 잔액 유형에서만 차감. 잔액 부족 시 WalletBalance.Decrease 가 예외 —
  226. /// 호출자가 해당 잔액을 사전 검증할 것.
  227. /// </summary>
  228. public void AdjustDecrease(WalletBalanceType type, Money amount, string reason, string? refID = null, string? memo = null)
  229. {
  230. EnsureMoney(amount);
  231. EnsureAdjustableType(type);
  232. if (string.IsNullOrWhiteSpace(reason))
  233. {
  234. throw new ArgumentException("Adjustment reason is required.", nameof(reason));
  235. }
  236. var balance = EnsureBalance(type);
  237. balance.Decrease(amount);
  238. _transactions.Add(WalletTransaction.Create(
  239. walletKey: WalletKey,
  240. balanceType: type,
  241. txType: WalletTransactionType.Adjusted,
  242. amount: amount,
  243. balanceAfter: balance.Amount,
  244. reason: $"ADJUST_OUT:{reason}",
  245. refID: refID,
  246. memo: memo
  247. ));
  248. }
  249. private static void EnsureAdjustableType(WalletBalanceType type)
  250. {
  251. if (type == WalletBalanceType.Locked)
  252. {
  253. throw new ArgumentException("Locked balance cannot be adjusted directly.", nameof(type));
  254. }
  255. }
  256. /// <summary>
  257. /// 관리자 차감 전용 다중 잔액 분할 차감: SpendPolicy.DefaultSpendOrder 순서로 분할 차감하고
  258. /// 사용된 BalanceType 별로 Adjusted 트랜잭션을 각각 기록한다.
  259. /// 합계 부족 시 InvalidOperationException — 호출자가 GetTotalAvailable 로 사전 검증할 것.
  260. /// </summary>
  261. public void AdjustDecreaseAcrossBalances(Money amount, string reason, string? refID = null, string? memo = null)
  262. {
  263. EnsureMoney(amount);
  264. if (string.IsNullOrWhiteSpace(reason))
  265. {
  266. throw new ArgumentException("Adjustment reason is required.", nameof(reason));
  267. }
  268. var remaining = amount;
  269. foreach (var type in SpendPolicy.DefaultSpendOrder)
  270. {
  271. if (remaining.IsZero) break;
  272. var balance = EnsureBalance(type);
  273. if (balance.Amount.IsZero) continue;
  274. var takeValue = Math.Min(balance.Amount.Value, remaining.Value);
  275. if (takeValue <= 0) continue;
  276. var take = Money.KRW(takeValue);
  277. balance.Decrease(take);
  278. remaining = remaining - take;
  279. _transactions.Add(WalletTransaction.Create(
  280. walletKey: WalletKey,
  281. balanceType: type,
  282. txType: WalletTransactionType.Adjusted,
  283. amount: take,
  284. balanceAfter: balance.Amount,
  285. reason: $"ADJUST_OUT:{reason}",
  286. refID: refID,
  287. memo: memo
  288. ));
  289. }
  290. if (!remaining.IsZero)
  291. {
  292. throw new InvalidOperationException("Insufficient balance for adjustment.");
  293. }
  294. }
  295. // ---- Internal ----
  296. private void DebitSingle(WalletBalanceType balanceType, WalletTransactionType txType, Money amount, string reason, string? refID)
  297. {
  298. EnsureMoney(amount);
  299. var balance = EnsureBalance(balanceType);
  300. balance.Decrease(amount);
  301. _transactions.Add(WalletTransaction.Create(
  302. walletKey: WalletKey,
  303. balanceType: balanceType,
  304. txType: txType,
  305. amount: amount,
  306. balanceAfter: balance.Amount,
  307. reason: reason,
  308. refID: refID
  309. ));
  310. }
  311. private WalletBalance GetBalanceEntity(WalletBalanceType type)
  312. {
  313. var found = _balances.SingleOrDefault(x => x.Type == type);
  314. if (found is null)
  315. {
  316. throw new InvalidOperationException($"Balance type '{type}' not initialized.");
  317. }
  318. return found;
  319. }
  320. private WalletBalance EnsureBalance(WalletBalanceType type)
  321. {
  322. var found = _balances.SingleOrDefault(x => x.Type == type);
  323. if (found != null)
  324. {
  325. return found;
  326. }
  327. var created = WalletBalance.Create(WalletKey, type);
  328. _balances.Add(created);
  329. return created;
  330. }
  331. private static void EnsureMoney(Money amount)
  332. {
  333. if (amount.IsZero || amount.Value <= 0)
  334. {
  335. throw new ArgumentException("Amount must be positive.", nameof(amount));
  336. }
  337. }
  338. }