Wallet.cs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  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. EnsureBalance(WalletBalanceType.StoreRevenue);
  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. /// <summary>
  50. /// 상점 결제로 사용 가능한 잔액 합계 (SpendPolicy.StoreOrderSpendOrder 기준).
  51. /// Donation/Locked/StoreRevenue 는 제외.
  52. /// </summary>
  53. public Money GetStoreOrderAvailable()
  54. {
  55. var total = Money.KRW(0);
  56. foreach (var type in Policy.SpendPolicy.StoreOrderSpendOrder)
  57. {
  58. total += GetBalance(type);
  59. }
  60. return total;
  61. }
  62. // ---- Credit ----
  63. public void CreditPgCharge(Money amount, string reason = "PG_CHARGE", string? refID = null)
  64. => Credit(WalletBalanceType.PgCharged, WalletTransactionType.Charge, amount, reason, refID);
  65. public void CreditDonationIn(Money amount, string reason, string? refID = null)
  66. => Credit(WalletBalanceType.Donation, WalletTransactionType.DonationIn, amount, reason, refID);
  67. public void CreditReward(Money amount, string reason = "REWARD", string? refID = null)
  68. => Credit(WalletBalanceType.Reward, WalletTransactionType.RewardEarned, amount, reason, refID);
  69. public void CreditStoreRevenue(Money amount, string reason = "STORE_CHANNEL_REWARD", string? refID = null)
  70. => Credit(WalletBalanceType.StoreRevenue, WalletTransactionType.OrderChannelReward, amount, reason, refID);
  71. /// <summary>
  72. /// 게임사 API 결제 보고 수수료 적립 (보류 확정 시): 채널주 StoreRevenue 입금.
  73. /// </summary>
  74. public void CreditApiCommission(Money amount, string reason = "API_COMMISSION", string? refID = null)
  75. => Credit(WalletBalanceType.StoreRevenue, WalletTransactionType.ApiCommissionEarned, amount, reason, refID);
  76. /// <summary>
  77. /// 상점 환불 환원: 원본 결제(OrderPay)가 차감한 BalanceType 그대로 복원해 출처를 보존한다.
  78. /// 호출자는 OrderPay 트랜잭션을 BalanceType 별로 집계해 type 마다 한 번씩 호출한다.
  79. /// </summary>
  80. public void CreditStoreOrderRefund(WalletBalanceType type, Money amount, string reason = "ORDER_REFUND", string? refID = null)
  81. => Credit(type, WalletTransactionType.OrderRefund, amount, reason, refID);
  82. private void Credit(
  83. WalletBalanceType balanceType,
  84. WalletTransactionType txType,
  85. Money amount,
  86. string reason,
  87. string? refID
  88. ) {
  89. EnsureMoney(amount);
  90. var balance = EnsureBalance(balanceType);
  91. balance.Increase(amount);
  92. _transactions.Add(WalletTransaction.Create(
  93. walletKey: WalletKey,
  94. balanceType: balanceType,
  95. txType: txType,
  96. amount: amount,
  97. balanceAfter: balance.Amount,
  98. reason: reason,
  99. refID: refID
  100. ));
  101. }
  102. // ---- Debit ----
  103. public void DebitDonationOut(Money amount, string reason, string? refID = null)
  104. => DebitSingle(WalletBalanceType.Donation, WalletTransactionType.DonationOut, amount, reason, refID);
  105. /// <summary>
  106. /// 상점 결제 전용 다중 잔액 차감: SpendPolicy.StoreOrderSpendOrder 순서로 분할 차감하고
  107. /// 사용된 BalanceType 별로 OrderPay 트랜잭션을 각각 기록한다 (환불 시 출처 복원용).
  108. /// 합계 부족 시 InvalidOperationException — 호출자가 GetStoreOrderAvailable 로 사전 검증할 것.
  109. /// </summary>
  110. public IReadOnlyList<(WalletBalanceType Type, Money Amount)> DebitForStoreOrder(
  111. Money total,
  112. string reason = "ORDER_PAY",
  113. string? refID = null)
  114. {
  115. EnsureMoney(total);
  116. var remaining = total;
  117. var splits = new List<(WalletBalanceType, Money)>();
  118. foreach (var type in Policy.SpendPolicy.StoreOrderSpendOrder)
  119. {
  120. if (remaining.IsZero) break;
  121. var balance = EnsureBalance(type);
  122. if (balance.Amount.IsZero) continue;
  123. var takeValue = Math.Min(balance.Amount.Value, remaining.Value);
  124. if (takeValue <= 0) continue;
  125. var take = Money.KRW(takeValue);
  126. balance.Decrease(take);
  127. remaining = remaining - take;
  128. _transactions.Add(WalletTransaction.Create(
  129. walletKey: WalletKey,
  130. balanceType: type,
  131. txType: WalletTransactionType.OrderPay,
  132. amount: take,
  133. balanceAfter: balance.Amount,
  134. reason: reason,
  135. refID: refID
  136. ));
  137. splits.Add((type, take));
  138. }
  139. if (!remaining.IsZero)
  140. {
  141. throw new InvalidOperationException("Insufficient balance for store order.");
  142. }
  143. return splits;
  144. }
  145. /// <summary>
  146. /// 환불 시 채널 보상 역분개: 채널주 StoreRevenue 잔액 차감.
  147. /// 잔액 부족 시 InvalidOperationException — 호출자가 별도 정책 처리 필요 (예: 음수 허용 모드).
  148. /// </summary>
  149. public void DebitStoreRevenueRefund(Money amount, string reason = "ORDER_REFUND_CHANNEL", string? refID = null)
  150. => DebitSingle(WalletBalanceType.StoreRevenue, WalletTransactionType.OrderRefundChannelReward, amount, reason, refID);
  151. /// <summary>
  152. /// API 결제 취소 회수 (확정 후 취소): 채널주 StoreRevenue 차감.
  153. /// 잔액 부족 시 WalletBalance.Decrease 예외 — 호출자가 가용분을 사전 계산해 부족분(shortfall)을 별도 기록할 것.
  154. /// </summary>
  155. public void DebitApiCommissionRevoke(Money amount, string reason = "API_COMMISSION_REVOKE", string? refID = null)
  156. => DebitSingle(WalletBalanceType.StoreRevenue, WalletTransactionType.ApiCommissionRevoked, amount, reason, refID);
  157. /// <summary>
  158. /// PG 결제 취소 회수: 충전분(PgCharged)을 되돌린다.
  159. /// 잔액 부족 시 WalletBalance.Decrease 예외 — 호출자가 PgCharged 잔액을 사전 검증할 것.
  160. /// </summary>
  161. public void DebitChargeCancel(Money amount, string reason = "CHARGE_CANCEL", string? refID = null)
  162. => DebitSingle(WalletBalanceType.PgCharged, WalletTransactionType.ChargeCancelled, amount, reason, refID);
  163. // ---- Spend (정책 순서대로 분할 차감) ----
  164. public void Spend(Money amount, string reason = "SPEND", string? refID = null)
  165. {
  166. EnsureMoney(amount);
  167. var remaining = amount;
  168. foreach (var type in SpendPolicy.DefaultSpendOrder)
  169. {
  170. if (remaining.IsZero) break;
  171. var balance = EnsureBalance(type);
  172. if (balance.Amount.IsZero) continue;
  173. var takeValue = Math.Min(balance.Amount.Value, remaining.Value);
  174. if (takeValue <= 0) continue;
  175. var take = Money.KRW(takeValue);
  176. balance.Decrease(take);
  177. remaining = remaining - take;
  178. _transactions.Add(WalletTransaction.Create(
  179. walletKey: WalletKey,
  180. balanceType: type,
  181. txType: WalletTransactionType.Spend,
  182. amount: take,
  183. balanceAfter: balance.Amount,
  184. reason: reason,
  185. refID: refID
  186. ));
  187. }
  188. if (!remaining.IsZero)
  189. {
  190. throw new InvalidOperationException("Insufficient balance for spending.");
  191. }
  192. }
  193. // ---- Lock/Unlock ----
  194. public void LockForWithdrawal(Money amount, string reason = "WITHDRAW_REQUEST", string? refID = null)
  195. {
  196. EnsureMoney(amount);
  197. var from = EnsureBalance(WalletBalanceType.Donation);
  198. var locked = EnsureBalance(WalletBalanceType.Locked);
  199. from.Decrease(amount);
  200. _transactions.Add(WalletTransaction.Create(WalletKey, WalletBalanceType.Donation, WalletTransactionType.Lock, amount, from.Amount, reason, refID));
  201. locked.Increase(amount);
  202. _transactions.Add(WalletTransaction.Create(WalletKey, WalletBalanceType.Locked, WalletTransactionType.Lock, amount, locked.Amount, reason, refID));
  203. }
  204. public void UnlockWithdrawal(Money amount, string reason = "WITHDRAW_CANCEL", string? refID = null)
  205. {
  206. EnsureMoney(amount);
  207. var locked = EnsureBalance(WalletBalanceType.Locked);
  208. var donation = EnsureBalance(WalletBalanceType.Donation);
  209. locked.Decrease(amount);
  210. _transactions.Add(WalletTransaction.Create(WalletKey, WalletBalanceType.Locked, WalletTransactionType.Unlock, amount, locked.Amount, reason, refID));
  211. donation.Increase(amount);
  212. _transactions.Add(WalletTransaction.Create(WalletKey, WalletBalanceType.Donation, WalletTransactionType.Unlock, amount, donation.Amount, reason, refID));
  213. }
  214. /// <summary>
  215. /// 상점 판매 수익 출금 잠금: StoreRevenue → Locked.
  216. /// Donation/StoreRevenue 두 잔액에서 동시 출금할 때 LockForWithdrawal + 이 메서드를 따로 호출.
  217. /// 정산서 명세는 호출자(WithdrawalRequest)에 분개 컬럼으로 분리 표시.
  218. /// </summary>
  219. public void LockStoreRevenueForWithdrawal(Money amount, string reason = "WITHDRAW_REQUEST_STORE", string? refID = null)
  220. {
  221. EnsureMoney(amount);
  222. var from = EnsureBalance(WalletBalanceType.StoreRevenue);
  223. var locked = EnsureBalance(WalletBalanceType.Locked);
  224. from.Decrease(amount);
  225. _transactions.Add(WalletTransaction.Create(WalletKey, WalletBalanceType.StoreRevenue, WalletTransactionType.WithdrawalStoreRevenue, amount, from.Amount, reason, refID));
  226. locked.Increase(amount);
  227. _transactions.Add(WalletTransaction.Create(WalletKey, WalletBalanceType.Locked, WalletTransactionType.WithdrawalStoreRevenue, amount, locked.Amount, reason, refID));
  228. }
  229. /// <summary>
  230. /// 상점 판매 수익 출금 잠금 해제: Locked → StoreRevenue.
  231. /// </summary>
  232. public void UnlockStoreRevenueWithdrawal(Money amount, string reason = "WITHDRAW_CANCEL_STORE", string? refID = null)
  233. {
  234. EnsureMoney(amount);
  235. var locked = EnsureBalance(WalletBalanceType.Locked);
  236. var storeRevenue = EnsureBalance(WalletBalanceType.StoreRevenue);
  237. locked.Decrease(amount);
  238. _transactions.Add(WalletTransaction.Create(WalletKey, WalletBalanceType.Locked, WalletTransactionType.Unlock, amount, locked.Amount, reason, refID));
  239. storeRevenue.Increase(amount);
  240. _transactions.Add(WalletTransaction.Create(WalletKey, WalletBalanceType.StoreRevenue, WalletTransactionType.Unlock, amount, storeRevenue.Amount, reason, refID));
  241. }
  242. // ---- Adjust ----
  243. public void AdjustIncrease(Money amount, string reason, string? refID = null, string? memo = null)
  244. => AdjustIncrease(WalletBalanceType.Adjusted, amount, reason, refID, memo);
  245. /// <summary>
  246. /// 관리자 조정 충전: 지정한 잔액 유형으로 입금. 트랜잭션 타입은 Adjusted 로 기록해 수동 조작 추적을 보존한다.
  247. /// Locked 는 출금 프로세스 전용이라 직접 조정 불가.
  248. /// </summary>
  249. public void AdjustIncrease(WalletBalanceType type, Money amount, string reason, string? refID = null, string? memo = null)
  250. {
  251. EnsureMoney(amount);
  252. EnsureAdjustableType(type);
  253. if (string.IsNullOrWhiteSpace(reason))
  254. {
  255. throw new ArgumentException("Adjustment reason is required.", nameof(reason));
  256. }
  257. var balance = EnsureBalance(type);
  258. balance.Increase(amount);
  259. _transactions.Add(WalletTransaction.Create(
  260. walletKey: WalletKey,
  261. balanceType: type,
  262. txType: WalletTransactionType.Adjusted,
  263. amount: amount,
  264. balanceAfter: balance.Amount,
  265. reason: $"ADJUST_IN:{reason}",
  266. refID: refID,
  267. memo: memo
  268. ));
  269. }
  270. public void AdjustDecrease(Money amount, string reason, string? refID = null, string? memo = null)
  271. => AdjustDecrease(WalletBalanceType.Adjusted, amount, reason, refID, memo);
  272. /// <summary>
  273. /// 관리자 조정 차감: 지정한 잔액 유형에서만 차감. 잔액 부족 시 WalletBalance.Decrease 가 예외 —
  274. /// 호출자가 해당 잔액을 사전 검증할 것.
  275. /// </summary>
  276. public void AdjustDecrease(WalletBalanceType type, Money amount, string reason, string? refID = null, string? memo = null)
  277. {
  278. EnsureMoney(amount);
  279. EnsureAdjustableType(type);
  280. if (string.IsNullOrWhiteSpace(reason))
  281. {
  282. throw new ArgumentException("Adjustment reason is required.", nameof(reason));
  283. }
  284. var balance = EnsureBalance(type);
  285. balance.Decrease(amount);
  286. _transactions.Add(WalletTransaction.Create(
  287. walletKey: WalletKey,
  288. balanceType: type,
  289. txType: WalletTransactionType.Adjusted,
  290. amount: amount,
  291. balanceAfter: balance.Amount,
  292. reason: $"ADJUST_OUT:{reason}",
  293. refID: refID,
  294. memo: memo
  295. ));
  296. }
  297. private static void EnsureAdjustableType(WalletBalanceType type)
  298. {
  299. if (type == WalletBalanceType.Locked)
  300. {
  301. throw new ArgumentException("Locked balance cannot be adjusted directly.", nameof(type));
  302. }
  303. }
  304. /// <summary>
  305. /// 관리자 차감 전용 다중 잔액 분할 차감: SpendPolicy.DefaultSpendOrder 순서로 분할 차감하고
  306. /// 사용된 BalanceType 별로 Adjusted 트랜잭션을 각각 기록한다.
  307. /// 합계 부족 시 InvalidOperationException — 호출자가 GetTotalAvailable 로 사전 검증할 것.
  308. /// </summary>
  309. public void AdjustDecreaseAcrossBalances(Money amount, string reason, string? refID = null, string? memo = null)
  310. {
  311. EnsureMoney(amount);
  312. if (string.IsNullOrWhiteSpace(reason))
  313. {
  314. throw new ArgumentException("Adjustment reason is required.", nameof(reason));
  315. }
  316. var remaining = amount;
  317. foreach (var type in SpendPolicy.DefaultSpendOrder)
  318. {
  319. if (remaining.IsZero) break;
  320. var balance = EnsureBalance(type);
  321. if (balance.Amount.IsZero) continue;
  322. var takeValue = Math.Min(balance.Amount.Value, remaining.Value);
  323. if (takeValue <= 0) continue;
  324. var take = Money.KRW(takeValue);
  325. balance.Decrease(take);
  326. remaining = remaining - take;
  327. _transactions.Add(WalletTransaction.Create(
  328. walletKey: WalletKey,
  329. balanceType: type,
  330. txType: WalletTransactionType.Adjusted,
  331. amount: take,
  332. balanceAfter: balance.Amount,
  333. reason: $"ADJUST_OUT:{reason}",
  334. refID: refID,
  335. memo: memo
  336. ));
  337. }
  338. if (!remaining.IsZero)
  339. {
  340. throw new InvalidOperationException("Insufficient balance for adjustment.");
  341. }
  342. }
  343. // ---- Internal ----
  344. private void DebitSingle(WalletBalanceType balanceType, WalletTransactionType txType, Money amount, string reason, string? refID)
  345. {
  346. EnsureMoney(amount);
  347. var balance = EnsureBalance(balanceType);
  348. balance.Decrease(amount);
  349. _transactions.Add(WalletTransaction.Create(
  350. walletKey: WalletKey,
  351. balanceType: balanceType,
  352. txType: txType,
  353. amount: amount,
  354. balanceAfter: balance.Amount,
  355. reason: reason,
  356. refID: refID
  357. ));
  358. }
  359. private WalletBalance GetBalanceEntity(WalletBalanceType type)
  360. {
  361. var found = _balances.SingleOrDefault(x => x.Type == type);
  362. if (found is null)
  363. {
  364. throw new InvalidOperationException($"Balance type '{type}' not initialized.");
  365. }
  366. return found;
  367. }
  368. private WalletBalance EnsureBalance(WalletBalanceType type)
  369. {
  370. var found = _balances.SingleOrDefault(x => x.Type == type);
  371. if (found != null)
  372. {
  373. return found;
  374. }
  375. var created = WalletBalance.Create(WalletKey, type);
  376. _balances.Add(created);
  377. return created;
  378. }
  379. private static void EnsureMoney(Money amount)
  380. {
  381. if (amount.IsZero || amount.Value <= 0)
  382. {
  383. throw new ArgumentException("Amount must be positive.", nameof(amount));
  384. }
  385. }
  386. }