Wallet.cs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  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. /// 가입 축하 보상 적립: 지정 잔액 유형(코인=Airdrop, 캐시=Adjusted)으로 입금하되
  70. /// 트랜잭션 타입은 RewardEarned 로 기록해 멱등 판정(RefID "signup:{memberID}")을 일원화한다.
  71. /// Locked 는 출금 프로세스 전용이라 직접 적립 불가.
  72. /// </summary>
  73. public void CreditSignupReward(WalletBalanceType type, Money amount, string reason, string? refID = null)
  74. {
  75. if (type == WalletBalanceType.Locked)
  76. {
  77. throw new ArgumentException("Locked balance cannot be credited directly.", nameof(type));
  78. }
  79. Credit(type, WalletTransactionType.RewardEarned, amount, reason, refID);
  80. }
  81. /// <summary>
  82. /// 상점 환불 환원: 원본 결제(OrderPay)가 차감한 BalanceType 그대로 복원해 출처를 보존한다.
  83. /// 호출자는 OrderPay 트랜잭션을 BalanceType 별로 집계해 type 마다 한 번씩 호출한다.
  84. /// </summary>
  85. public void CreditStoreOrderRefund(WalletBalanceType type, Money amount, string reason = "ORDER_REFUND", string? refID = null)
  86. => Credit(type, WalletTransactionType.OrderRefund, amount, reason, refID);
  87. private void Credit(
  88. WalletBalanceType balanceType,
  89. WalletTransactionType txType,
  90. Money amount,
  91. string reason,
  92. string? refID
  93. ) {
  94. EnsureMoney(amount);
  95. var balance = EnsureBalance(balanceType);
  96. balance.Increase(amount);
  97. _transactions.Add(WalletTransaction.Create(
  98. walletKey: WalletKey,
  99. balanceType: balanceType,
  100. txType: txType,
  101. amount: amount,
  102. balanceAfter: balance.Amount,
  103. reason: reason,
  104. refID: refID
  105. ));
  106. }
  107. /// <summary>
  108. /// 모의투자 출금 환급: 계좌 → 지갑 토큰(Reward 파티션). 트랜잭션 타입 PaperWithdraw.
  109. /// </summary>
  110. public void CreditPaperWithdraw(Money amount, string reason = "PAPER_WITHDRAW", string? refID = null)
  111. => Credit(WalletBalanceType.Reward, WalletTransactionType.PaperWithdraw, amount, reason, refID);
  112. /// <summary>
  113. /// 응원(Cheer) 수신 적립 (D3 M4). 수령 순액(net)을 Reward 파티션으로 입금하고 CheerReceived 로 기록한다.
  114. /// Reward = 토큰 파티션 → 구조적으로 출금 불가·상점/모의투자 소비 전용(§⑧ 규제 가드라인:
  115. /// 응원 적립분은 "투자조언의 대가"가 성립하지 않도록 인출 경로가 없다).
  116. /// </summary>
  117. public void CreditCheerReceived(Money net, string reason = "CHEER_RECEIVED", string? refID = null)
  118. => Credit(WalletBalanceType.Reward, WalletTransactionType.CheerReceived, net, reason, refID);
  119. /// <summary>
  120. /// 모의투자 입금: 토큰 파티션(Reward → Airdrop) 순서로 분할 차감하고 사용된 BalanceType 별로
  121. /// PaperDeposit 트랜잭션을 각각 기록한다. 합계 부족 시 InvalidOperationException —
  122. /// 호출자가 GetPaperTokenAvailable 로 사전 검증할 것.
  123. /// </summary>
  124. public void DebitForPaperDeposit(Money total, string reason = "PAPER_DEPOSIT", string? refID = null)
  125. {
  126. EnsureMoney(total);
  127. var remaining = total;
  128. foreach (var type in PaperTokenSpendOrder)
  129. {
  130. if (remaining.IsZero) break;
  131. var balance = EnsureBalance(type);
  132. if (balance.Amount.IsZero) continue;
  133. var takeValue = Math.Min(balance.Amount.Value, remaining.Value);
  134. if (takeValue <= 0) continue;
  135. var take = Money.KRW(takeValue);
  136. balance.Decrease(take);
  137. remaining = remaining - take;
  138. _transactions.Add(WalletTransaction.Create(
  139. walletKey: WalletKey,
  140. balanceType: type,
  141. txType: WalletTransactionType.PaperDeposit,
  142. amount: take,
  143. balanceAfter: balance.Amount,
  144. reason: reason,
  145. refID: refID
  146. ));
  147. }
  148. if (!remaining.IsZero)
  149. {
  150. throw new InvalidOperationException("Insufficient token balance for paper deposit.");
  151. }
  152. }
  153. /// <summary>모의투자 입금 가용 토큰 합계 (Reward + Airdrop).</summary>
  154. public Money GetPaperTokenAvailable()
  155. {
  156. var total = Money.KRW(0);
  157. foreach (var type in PaperTokenSpendOrder)
  158. {
  159. total += GetBalance(type);
  160. }
  161. return total;
  162. }
  163. // 모의투자 토큰 파티션 (활동 보상 재화 = 코인/토큰). 입금 시 Reward 먼저, 이후 Airdrop.
  164. private static readonly WalletBalanceType[] PaperTokenSpendOrder =
  165. {
  166. WalletBalanceType.Reward,
  167. WalletBalanceType.Airdrop
  168. };
  169. // ---- Debit ----
  170. public void DebitDonationOut(Money amount, string reason, string? refID = null)
  171. => DebitSingle(WalletBalanceType.Donation, WalletTransactionType.DonationOut, amount, reason, refID);
  172. /// <summary>
  173. /// 상점 결제 전용 다중 잔액 차감: SpendPolicy.StoreOrderSpendOrder 순서로 분할 차감하고
  174. /// 사용된 BalanceType 별로 OrderPay 트랜잭션을 각각 기록한다 (환불 시 출처 복원용).
  175. /// 합계 부족 시 InvalidOperationException — 호출자가 GetStoreOrderAvailable 로 사전 검증할 것.
  176. /// </summary>
  177. public IReadOnlyList<(WalletBalanceType Type, Money Amount)> DebitForStoreOrder(
  178. Money total,
  179. string reason = "ORDER_PAY",
  180. string? refID = null)
  181. {
  182. EnsureMoney(total);
  183. var remaining = total;
  184. var splits = new List<(WalletBalanceType, Money)>();
  185. foreach (var type in Policy.SpendPolicy.StoreOrderSpendOrder)
  186. {
  187. if (remaining.IsZero) break;
  188. var balance = EnsureBalance(type);
  189. if (balance.Amount.IsZero) continue;
  190. var takeValue = Math.Min(balance.Amount.Value, remaining.Value);
  191. if (takeValue <= 0) continue;
  192. var take = Money.KRW(takeValue);
  193. balance.Decrease(take);
  194. remaining = remaining - take;
  195. _transactions.Add(WalletTransaction.Create(
  196. walletKey: WalletKey,
  197. balanceType: type,
  198. txType: WalletTransactionType.OrderPay,
  199. amount: take,
  200. balanceAfter: balance.Amount,
  201. reason: reason,
  202. refID: refID
  203. ));
  204. splits.Add((type, take));
  205. }
  206. if (!remaining.IsZero)
  207. {
  208. throw new InvalidOperationException("Insufficient balance for store order.");
  209. }
  210. return splits;
  211. }
  212. /// <summary>
  213. /// PG 결제 취소 회수: 충전분(PgCharged)을 되돌린다.
  214. /// 잔액 부족 시 WalletBalance.Decrease 예외 — 호출자가 PgCharged 잔액을 사전 검증할 것.
  215. /// </summary>
  216. public void DebitChargeCancel(Money amount, string reason = "CHARGE_CANCEL", string? refID = null)
  217. => DebitSingle(WalletBalanceType.PgCharged, WalletTransactionType.ChargeCancelled, amount, reason, refID);
  218. /// <summary>
  219. /// 응원(Cheer) 발신 다중 잔액 차감 (D3 M4): SpendPolicy.CheerSpendOrder(캐시→토큰) 순서로 분할 차감하고
  220. /// 사용된 BalanceType 별로 CheerSent 트랜잭션을 각각 기록한다. 합계 부족 시 InvalidOperationException —
  221. /// 호출자가 GetCheerAvailable 로 사전 검증할 것.
  222. /// </summary>
  223. public IReadOnlyList<(WalletBalanceType Type, Money Amount)> DebitForCheer(
  224. Money total,
  225. string reason = "CHEER_SENT",
  226. string? refID = null)
  227. {
  228. EnsureMoney(total);
  229. var remaining = total;
  230. var splits = new List<(WalletBalanceType, Money)>();
  231. foreach (var type in SpendPolicy.CheerSpendOrder)
  232. {
  233. if (remaining.IsZero) break;
  234. var balance = EnsureBalance(type);
  235. if (balance.Amount.IsZero) continue;
  236. var takeValue = Math.Min(balance.Amount.Value, remaining.Value);
  237. if (takeValue <= 0) continue;
  238. var take = Money.KRW(takeValue);
  239. balance.Decrease(take);
  240. remaining = remaining - take;
  241. _transactions.Add(WalletTransaction.Create(
  242. walletKey: WalletKey,
  243. balanceType: type,
  244. txType: WalletTransactionType.CheerSent,
  245. amount: take,
  246. balanceAfter: balance.Amount,
  247. reason: reason,
  248. refID: refID
  249. ));
  250. splits.Add((type, take));
  251. }
  252. if (!remaining.IsZero)
  253. {
  254. throw new InvalidOperationException("Insufficient balance for cheer.");
  255. }
  256. return splits;
  257. }
  258. /// <summary>응원 발신 가용 잔액 합계 (SpendPolicy.CheerSpendOrder = 캐시 + 토큰).</summary>
  259. public Money GetCheerAvailable()
  260. {
  261. var total = Money.KRW(0);
  262. foreach (var type in SpendPolicy.CheerSpendOrder)
  263. {
  264. total += GetBalance(type);
  265. }
  266. return total;
  267. }
  268. // ---- Spend (정책 순서대로 분할 차감) ----
  269. public void Spend(Money amount, string reason = "SPEND", string? refID = null)
  270. {
  271. EnsureMoney(amount);
  272. var remaining = amount;
  273. foreach (var type in SpendPolicy.DefaultSpendOrder)
  274. {
  275. if (remaining.IsZero) break;
  276. var balance = EnsureBalance(type);
  277. if (balance.Amount.IsZero) continue;
  278. var takeValue = Math.Min(balance.Amount.Value, remaining.Value);
  279. if (takeValue <= 0) continue;
  280. var take = Money.KRW(takeValue);
  281. balance.Decrease(take);
  282. remaining = remaining - take;
  283. _transactions.Add(WalletTransaction.Create(
  284. walletKey: WalletKey,
  285. balanceType: type,
  286. txType: WalletTransactionType.Spend,
  287. amount: take,
  288. balanceAfter: balance.Amount,
  289. reason: reason,
  290. refID: refID
  291. ));
  292. }
  293. if (!remaining.IsZero)
  294. {
  295. throw new InvalidOperationException("Insufficient balance for spending.");
  296. }
  297. }
  298. // ---- Lock/Unlock ----
  299. public void LockForWithdrawal(Money amount, string reason = "WITHDRAW_REQUEST", string? refID = null)
  300. {
  301. EnsureMoney(amount);
  302. var from = EnsureBalance(WalletBalanceType.Donation);
  303. var locked = EnsureBalance(WalletBalanceType.Locked);
  304. from.Decrease(amount);
  305. _transactions.Add(WalletTransaction.Create(WalletKey, WalletBalanceType.Donation, WalletTransactionType.Lock, amount, from.Amount, reason, refID));
  306. locked.Increase(amount);
  307. _transactions.Add(WalletTransaction.Create(WalletKey, WalletBalanceType.Locked, WalletTransactionType.Lock, amount, locked.Amount, reason, refID));
  308. }
  309. public void UnlockWithdrawal(Money amount, string reason = "WITHDRAW_CANCEL", string? refID = null)
  310. {
  311. EnsureMoney(amount);
  312. var locked = EnsureBalance(WalletBalanceType.Locked);
  313. var donation = EnsureBalance(WalletBalanceType.Donation);
  314. locked.Decrease(amount);
  315. _transactions.Add(WalletTransaction.Create(WalletKey, WalletBalanceType.Locked, WalletTransactionType.Unlock, amount, locked.Amount, reason, refID));
  316. donation.Increase(amount);
  317. _transactions.Add(WalletTransaction.Create(WalletKey, WalletBalanceType.Donation, WalletTransactionType.Unlock, amount, donation.Amount, reason, refID));
  318. }
  319. // ---- Adjust ----
  320. public void AdjustIncrease(Money amount, string reason, string? refID = null, string? memo = null)
  321. => AdjustIncrease(WalletBalanceType.Adjusted, amount, reason, refID, memo);
  322. /// <summary>
  323. /// 관리자 조정 충전: 지정한 잔액 유형으로 입금. 트랜잭션 타입은 Adjusted 로 기록해 수동 조작 추적을 보존한다.
  324. /// Locked 는 출금 프로세스 전용이라 직접 조정 불가.
  325. /// </summary>
  326. public void AdjustIncrease(WalletBalanceType type, Money amount, string reason, string? refID = null, string? memo = null)
  327. {
  328. EnsureMoney(amount);
  329. EnsureAdjustableType(type);
  330. if (string.IsNullOrWhiteSpace(reason))
  331. {
  332. throw new ArgumentException("Adjustment reason is required.", nameof(reason));
  333. }
  334. var balance = EnsureBalance(type);
  335. balance.Increase(amount);
  336. _transactions.Add(WalletTransaction.Create(
  337. walletKey: WalletKey,
  338. balanceType: type,
  339. txType: WalletTransactionType.Adjusted,
  340. amount: amount,
  341. balanceAfter: balance.Amount,
  342. reason: $"ADJUST_IN:{reason}",
  343. refID: refID,
  344. memo: memo
  345. ));
  346. }
  347. public void AdjustDecrease(Money amount, string reason, string? refID = null, string? memo = null)
  348. => AdjustDecrease(WalletBalanceType.Adjusted, amount, reason, refID, memo);
  349. /// <summary>
  350. /// 관리자 조정 차감: 지정한 잔액 유형에서만 차감. 잔액 부족 시 WalletBalance.Decrease 가 예외 —
  351. /// 호출자가 해당 잔액을 사전 검증할 것.
  352. /// </summary>
  353. public void AdjustDecrease(WalletBalanceType type, Money amount, string reason, string? refID = null, string? memo = null)
  354. {
  355. EnsureMoney(amount);
  356. EnsureAdjustableType(type);
  357. if (string.IsNullOrWhiteSpace(reason))
  358. {
  359. throw new ArgumentException("Adjustment reason is required.", nameof(reason));
  360. }
  361. var balance = EnsureBalance(type);
  362. balance.Decrease(amount);
  363. _transactions.Add(WalletTransaction.Create(
  364. walletKey: WalletKey,
  365. balanceType: type,
  366. txType: WalletTransactionType.Adjusted,
  367. amount: amount,
  368. balanceAfter: balance.Amount,
  369. reason: $"ADJUST_OUT:{reason}",
  370. refID: refID,
  371. memo: memo
  372. ));
  373. }
  374. private static void EnsureAdjustableType(WalletBalanceType type)
  375. {
  376. if (type == WalletBalanceType.Locked)
  377. {
  378. throw new ArgumentException("Locked balance cannot be adjusted directly.", nameof(type));
  379. }
  380. }
  381. /// <summary>
  382. /// 관리자 차감 전용 다중 잔액 분할 차감: SpendPolicy.DefaultSpendOrder 순서로 분할 차감하고
  383. /// 사용된 BalanceType 별로 Adjusted 트랜잭션을 각각 기록한다.
  384. /// 합계 부족 시 InvalidOperationException — 호출자가 GetTotalAvailable 로 사전 검증할 것.
  385. /// </summary>
  386. public void AdjustDecreaseAcrossBalances(Money amount, string reason, string? refID = null, string? memo = null)
  387. {
  388. EnsureMoney(amount);
  389. if (string.IsNullOrWhiteSpace(reason))
  390. {
  391. throw new ArgumentException("Adjustment reason is required.", nameof(reason));
  392. }
  393. var remaining = amount;
  394. foreach (var type in SpendPolicy.DefaultSpendOrder)
  395. {
  396. if (remaining.IsZero) break;
  397. var balance = EnsureBalance(type);
  398. if (balance.Amount.IsZero) continue;
  399. var takeValue = Math.Min(balance.Amount.Value, remaining.Value);
  400. if (takeValue <= 0) continue;
  401. var take = Money.KRW(takeValue);
  402. balance.Decrease(take);
  403. remaining = remaining - take;
  404. _transactions.Add(WalletTransaction.Create(
  405. walletKey: WalletKey,
  406. balanceType: type,
  407. txType: WalletTransactionType.Adjusted,
  408. amount: take,
  409. balanceAfter: balance.Amount,
  410. reason: $"ADJUST_OUT:{reason}",
  411. refID: refID,
  412. memo: memo
  413. ));
  414. }
  415. if (!remaining.IsZero)
  416. {
  417. throw new InvalidOperationException("Insufficient balance for adjustment.");
  418. }
  419. }
  420. // ---- Internal ----
  421. private void DebitSingle(WalletBalanceType balanceType, WalletTransactionType txType, Money amount, string reason, string? refID)
  422. {
  423. EnsureMoney(amount);
  424. var balance = EnsureBalance(balanceType);
  425. balance.Decrease(amount);
  426. _transactions.Add(WalletTransaction.Create(
  427. walletKey: WalletKey,
  428. balanceType: balanceType,
  429. txType: txType,
  430. amount: amount,
  431. balanceAfter: balance.Amount,
  432. reason: reason,
  433. refID: refID
  434. ));
  435. }
  436. private WalletBalance GetBalanceEntity(WalletBalanceType type)
  437. {
  438. var found = _balances.SingleOrDefault(x => x.Type == type);
  439. if (found is null)
  440. {
  441. throw new InvalidOperationException($"Balance type '{type}' not initialized.");
  442. }
  443. return found;
  444. }
  445. private WalletBalance EnsureBalance(WalletBalanceType type)
  446. {
  447. var found = _balances.SingleOrDefault(x => x.Type == type);
  448. if (found != null)
  449. {
  450. return found;
  451. }
  452. var created = WalletBalance.Create(WalletKey, type);
  453. _balances.Add(created);
  454. return created;
  455. }
  456. private static void EnsureMoney(Money amount)
  457. {
  458. if (amount.IsZero || amount.Value <= 0)
  459. {
  460. throw new ArgumentException("Amount must be positive.", nameof(amount));
  461. }
  462. }
  463. }