Wallet.cs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  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. {
  9. public class Wallet
  10. {
  11. [ForeignKey(nameof(MemberID))]
  12. public virtual Member Member { get; private set; } = null!;
  13. private readonly List<WalletBalance> _balances = [];
  14. public IReadOnlyCollection<WalletBalance> Balances => _balances;
  15. private readonly List<WalletTransaction> _transactions = [];
  16. public IReadOnlyCollection<WalletTransaction> Transactions => _transactions;
  17. [Key]
  18. public int ID { get; private set; }
  19. public Guid WalletKey { get; private set; } = Guid.NewGuid();
  20. public int MemberID { get; private set; }
  21. public DateTime? UpdatedAt { get; private set; }
  22. public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
  23. private Wallet() { }
  24. private Wallet(int memberID)
  25. {
  26. if (memberID <= 0)
  27. {
  28. throw new ArgumentOutOfRangeException(nameof(memberID));
  29. }
  30. MemberID = memberID;
  31. // 지갑 생성 시 구분별 잔액 기본 생성
  32. EnsureBalance(WalletBalanceType.PgCharged);
  33. EnsureBalance(WalletBalanceType.Deposit);
  34. EnsureBalance(WalletBalanceType.Donation);
  35. EnsureBalance(WalletBalanceType.Reward);
  36. EnsureBalance(WalletBalanceType.Airdrop);
  37. EnsureBalance(WalletBalanceType.Locked);
  38. EnsureBalance(WalletBalanceType.Adjusted);
  39. }
  40. public static Wallet Create(int memberID) => new(memberID);
  41. public Money GetBalance(WalletBalanceType type) => GetBalanceEntity(type).Amount;
  42. public Money GetTotalAvailable()
  43. {
  44. var total = Money.KRW(0);
  45. foreach (var b in Balances.Where(x => x.Type != WalletBalanceType.Locked))
  46. total += b.Amount;
  47. return total;
  48. }
  49. // ---- Credit ----
  50. public void CreditPgCharge(Money amount, string reason = "PG_CHARGE", string? refID = null)
  51. => Credit(WalletBalanceType.PgCharged, WalletTransactionType.Charge, amount, reason, refID);
  52. public void CreditDonationIn(Money amount, string reason, string? refID = null)
  53. => Credit(WalletBalanceType.Donation, WalletTransactionType.DonationIn, amount, reason, refID);
  54. public void CreditReward(Money amount, string reason = "REWARD", string? refID = null)
  55. => Credit(WalletBalanceType.Reward, WalletTransactionType.RewardEarned, amount, reason, refID);
  56. /// <summary>
  57. /// 가입 축하 보상 적립: 지정 잔액 유형(코인=Airdrop, 캐시=Adjusted)으로 입금하되
  58. /// 트랜잭션 타입은 RewardEarned 로 기록해 멱등 판정(RefID "signup:{memberID}")을 일원화한다.
  59. /// Locked 는 출금 프로세스 전용이라 직접 적립 불가.
  60. /// </summary>
  61. public void CreditSignupReward(WalletBalanceType type, Money amount, string reason, string? refID = null)
  62. {
  63. if (type == WalletBalanceType.Locked)
  64. {
  65. throw new ArgumentException("Locked balance cannot be credited directly.", nameof(type));
  66. }
  67. Credit(type, WalletTransactionType.RewardEarned, amount, reason, refID);
  68. }
  69. private void Credit(
  70. WalletBalanceType balanceType,
  71. WalletTransactionType txType,
  72. Money amount,
  73. string reason,
  74. string? refID
  75. ) {
  76. EnsureMoney(amount);
  77. var balance = EnsureBalance(balanceType);
  78. balance.Increase(amount);
  79. _transactions.Add(WalletTransaction.Create(
  80. walletKey: WalletKey,
  81. balanceType: balanceType,
  82. txType: txType,
  83. amount: amount,
  84. balanceAfter: balance.Amount,
  85. reason: reason,
  86. refID: refID
  87. ));
  88. }
  89. // ---- Debit ----
  90. public void DebitDonationOut(Money amount, string reason, string? refID = null)
  91. => DebitSingle(WalletBalanceType.Donation, WalletTransactionType.DonationOut, amount, reason, refID);
  92. // ---- Spend (정책 순서대로 분할 차감) ----
  93. public void Spend(Money amount, string reason = "SPEND", string? refID = null)
  94. {
  95. EnsureMoney(amount);
  96. var remaining = amount;
  97. foreach (var type in SpendPolicy.DefaultSpendOrder)
  98. {
  99. if (remaining.IsZero) break;
  100. var balance = EnsureBalance(type);
  101. if (balance.Amount.IsZero) continue;
  102. var takeValue = Math.Min(balance.Amount.Value, remaining.Value);
  103. if (takeValue <= 0) continue;
  104. var take = Money.KRW(takeValue);
  105. balance.Decrease(take);
  106. remaining = remaining - take;
  107. _transactions.Add(WalletTransaction.Create(
  108. walletKey: WalletKey,
  109. balanceType: type,
  110. txType: WalletTransactionType.Spend,
  111. amount: take,
  112. balanceAfter: balance.Amount,
  113. reason: reason,
  114. refID: refID
  115. ));
  116. }
  117. if (!remaining.IsZero)
  118. {
  119. throw new InvalidOperationException("Insufficient balance for spending.");
  120. }
  121. }
  122. // ---- Lock/Unlock ----
  123. public void LockForWithdrawal(Money amount, string reason = "WITHDRAW_REQUEST", string? refID = null)
  124. {
  125. EnsureMoney(amount);
  126. var from = EnsureBalance(WalletBalanceType.Donation);
  127. var locked = EnsureBalance(WalletBalanceType.Locked);
  128. from.Decrease(amount);
  129. _transactions.Add(WalletTransaction.Create(WalletKey, WalletBalanceType.Donation, WalletTransactionType.Lock, amount, from.Amount, reason, refID));
  130. locked.Increase(amount);
  131. _transactions.Add(WalletTransaction.Create(WalletKey, WalletBalanceType.Locked, WalletTransactionType.Lock, amount, locked.Amount, reason, refID));
  132. }
  133. public void UnlockWithdrawal(Money amount, string reason = "WITHDRAW_CANCEL", string? refID = null)
  134. {
  135. EnsureMoney(amount);
  136. var locked = EnsureBalance(WalletBalanceType.Locked);
  137. var donation = EnsureBalance(WalletBalanceType.Donation);
  138. locked.Decrease(amount);
  139. _transactions.Add(WalletTransaction.Create(WalletKey, WalletBalanceType.Locked, WalletTransactionType.Unlock, amount, locked.Amount, reason, refID));
  140. donation.Increase(amount);
  141. _transactions.Add(WalletTransaction.Create(WalletKey, WalletBalanceType.Donation, WalletTransactionType.Unlock, amount, donation.Amount, reason, refID));
  142. }
  143. // ---- Adjust ----
  144. public void AdjustIncrease(Money amount, string reason, string? refID = null, string? memo = null)
  145. {
  146. EnsureMoney(amount);
  147. if (string.IsNullOrWhiteSpace(reason))
  148. {
  149. throw new ArgumentException("Adjustment reason is required.", nameof(reason));
  150. }
  151. var balance = EnsureBalance(WalletBalanceType.Adjusted);
  152. balance.Increase(amount);
  153. _transactions.Add(WalletTransaction.Create(
  154. walletKey: WalletKey,
  155. balanceType: WalletBalanceType.Adjusted,
  156. txType: WalletTransactionType.Adjusted,
  157. amount: amount,
  158. balanceAfter: balance.Amount,
  159. reason: $"ADJUST_IN:{reason}",
  160. refID: refID,
  161. memo: memo
  162. ));
  163. }
  164. public void AdjustDecrease(Money amount, string reason, string? refID = null, string? memo = null)
  165. {
  166. EnsureMoney(amount);
  167. if (string.IsNullOrWhiteSpace(reason))
  168. {
  169. throw new ArgumentException("Adjustment reason is required.", nameof(reason));
  170. }
  171. var balance = EnsureBalance(WalletBalanceType.Adjusted);
  172. balance.Decrease(amount);
  173. _transactions.Add(WalletTransaction.Create(
  174. walletKey: WalletKey,
  175. balanceType: WalletBalanceType.Adjusted,
  176. txType: WalletTransactionType.Adjusted,
  177. amount: amount,
  178. balanceAfter: balance.Amount,
  179. reason: $"ADJUST_OUT:{reason}",
  180. refID: refID,
  181. memo: memo
  182. ));
  183. }
  184. // ---- Internal ----
  185. private void DebitSingle(WalletBalanceType balanceType, WalletTransactionType txType, Money amount, string reason, string? refID)
  186. {
  187. EnsureMoney(amount);
  188. var balance = EnsureBalance(balanceType);
  189. balance.Decrease(amount);
  190. _transactions.Add(WalletTransaction.Create(
  191. walletKey: WalletKey,
  192. balanceType: balanceType,
  193. txType: txType,
  194. amount: amount,
  195. balanceAfter: balance.Amount,
  196. reason: reason,
  197. refID: refID
  198. ));
  199. }
  200. private WalletBalance GetBalanceEntity(WalletBalanceType type)
  201. {
  202. var found = _balances.SingleOrDefault(x => x.Type == type);
  203. if (found is null)
  204. {
  205. throw new InvalidOperationException($"Balance type '{type}' not initialized.");
  206. }
  207. return found;
  208. }
  209. private WalletBalance EnsureBalance(WalletBalanceType type)
  210. {
  211. var found = _balances.SingleOrDefault(x => x.Type == type);
  212. if (found != null)
  213. {
  214. return found;
  215. }
  216. var created = WalletBalance.Create(WalletKey, type);
  217. _balances.Add(created);
  218. return created;
  219. }
  220. private static void EnsureMoney(Money amount)
  221. {
  222. if (amount.IsZero || amount.Value <= 0)
  223. {
  224. throw new ArgumentException("Amount must be positive.", nameof(amount));
  225. }
  226. }
  227. }
  228. }