using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Domain.Entities.Common.ValueObject; using Domain.Entities.Members; using Domain.Entities.Wallets.Policy; using Domain.Entities.Wallets.ValueObject; namespace Domain.Entities.Wallets; public class Wallet { [ForeignKey(nameof(MemberID))] public virtual Member Member { get; private set; } = null!; private readonly List _balances = []; public IReadOnlyCollection Balances => _balances; private readonly List _transactions = []; public IReadOnlyCollection Transactions => _transactions; [Key] public int ID { get; private set; } public Guid WalletKey { get; private set; } = Guid.NewGuid(); public int MemberID { get; private set; } public DateTime? UpdatedAt { get; private set; } public DateTime CreatedAt { get; private set; } = DateTime.UtcNow; private Wallet() { } private Wallet(int memberID) { if (memberID <= 0) { throw new ArgumentOutOfRangeException(nameof(memberID)); } MemberID = memberID; // 지갑 생성 시 구분별 잔액 기본 생성 EnsureBalance(WalletBalanceType.PgCharged); EnsureBalance(WalletBalanceType.Deposit); EnsureBalance(WalletBalanceType.Donation); EnsureBalance(WalletBalanceType.Reward); EnsureBalance(WalletBalanceType.Airdrop); EnsureBalance(WalletBalanceType.Locked); EnsureBalance(WalletBalanceType.Adjusted); } public static Wallet Create(int memberID) => new(memberID); public Money GetBalance(WalletBalanceType type) => GetBalanceEntity(type).Amount; public Money GetTotalAvailable() { var total = Money.KRW(0); foreach (var b in Balances.Where(x => x.Type != WalletBalanceType.Locked)) total += b.Amount; return total; } /// /// 상점 결제로 사용 가능한 잔액 합계 (SpendPolicy.StoreOrderSpendOrder 기준). /// 비활성 잔액 유형(원장 호환용)과 Locked 는 제외. /// public Money GetStoreOrderAvailable() { var total = Money.KRW(0); foreach (var type in Policy.SpendPolicy.StoreOrderSpendOrder) { total += GetBalance(type); } return total; } // ---- Credit ---- public void CreditPgCharge(Money amount, string reason = "PG_CHARGE", string? refID = null) => Credit(WalletBalanceType.PgCharged, WalletTransactionType.Charge, amount, reason, refID); public void CreditDonationIn(Money amount, string reason, string? refID = null) => Credit(WalletBalanceType.Donation, WalletTransactionType.DonationIn, amount, reason, refID); public void CreditReward(Money amount, string reason = "REWARD", string? refID = null) => Credit(WalletBalanceType.Reward, WalletTransactionType.RewardEarned, amount, reason, refID); /// /// 가입 축하 보상 적립: 지정 잔액 유형(코인=Airdrop, 캐시=Adjusted)으로 입금하되 /// 트랜잭션 타입은 RewardEarned 로 기록해 멱등 판정(RefID "signup:{memberID}")을 일원화한다. /// Locked 는 출금 프로세스 전용이라 직접 적립 불가. /// public void CreditSignupReward(WalletBalanceType type, Money amount, string reason, string? refID = null) { if (type == WalletBalanceType.Locked) { throw new ArgumentException("Locked balance cannot be credited directly.", nameof(type)); } Credit(type, WalletTransactionType.RewardEarned, amount, reason, refID); } /// /// 상점 환불 환원: 원본 결제(OrderPay)가 차감한 BalanceType 그대로 복원해 출처를 보존한다. /// 호출자는 OrderPay 트랜잭션을 BalanceType 별로 집계해 type 마다 한 번씩 호출한다. /// public void CreditStoreOrderRefund(WalletBalanceType type, Money amount, string reason = "ORDER_REFUND", string? refID = null) => Credit(type, WalletTransactionType.OrderRefund, amount, reason, refID); private void Credit( WalletBalanceType balanceType, WalletTransactionType txType, Money amount, string reason, string? refID ) { EnsureMoney(amount); var balance = EnsureBalance(balanceType); balance.Increase(amount); _transactions.Add(WalletTransaction.Create( walletKey: WalletKey, balanceType: balanceType, txType: txType, amount: amount, balanceAfter: balance.Amount, reason: reason, refID: refID )); } /// /// 모의투자 출금 환급: 계좌 → 지갑 토큰(Reward 파티션). 트랜잭션 타입 PaperWithdraw. /// public void CreditPaperWithdraw(Money amount, string reason = "PAPER_WITHDRAW", string? refID = null) => Credit(WalletBalanceType.Reward, WalletTransactionType.PaperWithdraw, amount, reason, refID); /// /// 모의투자 입금: 토큰 파티션(Reward → Airdrop) 순서로 분할 차감하고 사용된 BalanceType 별로 /// PaperDeposit 트랜잭션을 각각 기록한다. 합계 부족 시 InvalidOperationException — /// 호출자가 GetPaperTokenAvailable 로 사전 검증할 것. /// public void DebitForPaperDeposit(Money total, string reason = "PAPER_DEPOSIT", string? refID = null) { EnsureMoney(total); var remaining = total; foreach (var type in PaperTokenSpendOrder) { if (remaining.IsZero) break; var balance = EnsureBalance(type); if (balance.Amount.IsZero) continue; var takeValue = Math.Min(balance.Amount.Value, remaining.Value); if (takeValue <= 0) continue; var take = Money.KRW(takeValue); balance.Decrease(take); remaining = remaining - take; _transactions.Add(WalletTransaction.Create( walletKey: WalletKey, balanceType: type, txType: WalletTransactionType.PaperDeposit, amount: take, balanceAfter: balance.Amount, reason: reason, refID: refID )); } if (!remaining.IsZero) { throw new InvalidOperationException("Insufficient token balance for paper deposit."); } } /// 모의투자 입금 가용 토큰 합계 (Reward + Airdrop). public Money GetPaperTokenAvailable() { var total = Money.KRW(0); foreach (var type in PaperTokenSpendOrder) { total += GetBalance(type); } return total; } // 모의투자 토큰 파티션 (활동 보상 재화 = 코인/토큰). 입금 시 Reward 먼저, 이후 Airdrop. private static readonly WalletBalanceType[] PaperTokenSpendOrder = { WalletBalanceType.Reward, WalletBalanceType.Airdrop }; // ---- Debit ---- public void DebitDonationOut(Money amount, string reason, string? refID = null) => DebitSingle(WalletBalanceType.Donation, WalletTransactionType.DonationOut, amount, reason, refID); /// /// 상점 결제 전용 다중 잔액 차감: SpendPolicy.StoreOrderSpendOrder 순서로 분할 차감하고 /// 사용된 BalanceType 별로 OrderPay 트랜잭션을 각각 기록한다 (환불 시 출처 복원용). /// 합계 부족 시 InvalidOperationException — 호출자가 GetStoreOrderAvailable 로 사전 검증할 것. /// public IReadOnlyList<(WalletBalanceType Type, Money Amount)> DebitForStoreOrder( Money total, string reason = "ORDER_PAY", string? refID = null) { EnsureMoney(total); var remaining = total; var splits = new List<(WalletBalanceType, Money)>(); foreach (var type in Policy.SpendPolicy.StoreOrderSpendOrder) { if (remaining.IsZero) break; var balance = EnsureBalance(type); if (balance.Amount.IsZero) continue; var takeValue = Math.Min(balance.Amount.Value, remaining.Value); if (takeValue <= 0) continue; var take = Money.KRW(takeValue); balance.Decrease(take); remaining = remaining - take; _transactions.Add(WalletTransaction.Create( walletKey: WalletKey, balanceType: type, txType: WalletTransactionType.OrderPay, amount: take, balanceAfter: balance.Amount, reason: reason, refID: refID )); splits.Add((type, take)); } if (!remaining.IsZero) { throw new InvalidOperationException("Insufficient balance for store order."); } return splits; } /// /// PG 결제 취소 회수: 충전분(PgCharged)을 되돌린다. /// 잔액 부족 시 WalletBalance.Decrease 예외 — 호출자가 PgCharged 잔액을 사전 검증할 것. /// public void DebitChargeCancel(Money amount, string reason = "CHARGE_CANCEL", string? refID = null) => DebitSingle(WalletBalanceType.PgCharged, WalletTransactionType.ChargeCancelled, amount, reason, refID); // ---- Spend (정책 순서대로 분할 차감) ---- public void Spend(Money amount, string reason = "SPEND", string? refID = null) { EnsureMoney(amount); var remaining = amount; foreach (var type in SpendPolicy.DefaultSpendOrder) { if (remaining.IsZero) break; var balance = EnsureBalance(type); if (balance.Amount.IsZero) continue; var takeValue = Math.Min(balance.Amount.Value, remaining.Value); if (takeValue <= 0) continue; var take = Money.KRW(takeValue); balance.Decrease(take); remaining = remaining - take; _transactions.Add(WalletTransaction.Create( walletKey: WalletKey, balanceType: type, txType: WalletTransactionType.Spend, amount: take, balanceAfter: balance.Amount, reason: reason, refID: refID )); } if (!remaining.IsZero) { throw new InvalidOperationException("Insufficient balance for spending."); } } // ---- Lock/Unlock ---- public void LockForWithdrawal(Money amount, string reason = "WITHDRAW_REQUEST", string? refID = null) { EnsureMoney(amount); var from = EnsureBalance(WalletBalanceType.Donation); var locked = EnsureBalance(WalletBalanceType.Locked); from.Decrease(amount); _transactions.Add(WalletTransaction.Create(WalletKey, WalletBalanceType.Donation, WalletTransactionType.Lock, amount, from.Amount, reason, refID)); locked.Increase(amount); _transactions.Add(WalletTransaction.Create(WalletKey, WalletBalanceType.Locked, WalletTransactionType.Lock, amount, locked.Amount, reason, refID)); } public void UnlockWithdrawal(Money amount, string reason = "WITHDRAW_CANCEL", string? refID = null) { EnsureMoney(amount); var locked = EnsureBalance(WalletBalanceType.Locked); var donation = EnsureBalance(WalletBalanceType.Donation); locked.Decrease(amount); _transactions.Add(WalletTransaction.Create(WalletKey, WalletBalanceType.Locked, WalletTransactionType.Unlock, amount, locked.Amount, reason, refID)); donation.Increase(amount); _transactions.Add(WalletTransaction.Create(WalletKey, WalletBalanceType.Donation, WalletTransactionType.Unlock, amount, donation.Amount, reason, refID)); } // ---- Adjust ---- public void AdjustIncrease(Money amount, string reason, string? refID = null, string? memo = null) => AdjustIncrease(WalletBalanceType.Adjusted, amount, reason, refID, memo); /// /// 관리자 조정 충전: 지정한 잔액 유형으로 입금. 트랜잭션 타입은 Adjusted 로 기록해 수동 조작 추적을 보존한다. /// Locked 는 출금 프로세스 전용이라 직접 조정 불가. /// public void AdjustIncrease(WalletBalanceType type, Money amount, string reason, string? refID = null, string? memo = null) { EnsureMoney(amount); EnsureAdjustableType(type); if (string.IsNullOrWhiteSpace(reason)) { throw new ArgumentException("Adjustment reason is required.", nameof(reason)); } var balance = EnsureBalance(type); balance.Increase(amount); _transactions.Add(WalletTransaction.Create( walletKey: WalletKey, balanceType: type, txType: WalletTransactionType.Adjusted, amount: amount, balanceAfter: balance.Amount, reason: $"ADJUST_IN:{reason}", refID: refID, memo: memo )); } public void AdjustDecrease(Money amount, string reason, string? refID = null, string? memo = null) => AdjustDecrease(WalletBalanceType.Adjusted, amount, reason, refID, memo); /// /// 관리자 조정 차감: 지정한 잔액 유형에서만 차감. 잔액 부족 시 WalletBalance.Decrease 가 예외 — /// 호출자가 해당 잔액을 사전 검증할 것. /// public void AdjustDecrease(WalletBalanceType type, Money amount, string reason, string? refID = null, string? memo = null) { EnsureMoney(amount); EnsureAdjustableType(type); if (string.IsNullOrWhiteSpace(reason)) { throw new ArgumentException("Adjustment reason is required.", nameof(reason)); } var balance = EnsureBalance(type); balance.Decrease(amount); _transactions.Add(WalletTransaction.Create( walletKey: WalletKey, balanceType: type, txType: WalletTransactionType.Adjusted, amount: amount, balanceAfter: balance.Amount, reason: $"ADJUST_OUT:{reason}", refID: refID, memo: memo )); } private static void EnsureAdjustableType(WalletBalanceType type) { if (type == WalletBalanceType.Locked) { throw new ArgumentException("Locked balance cannot be adjusted directly.", nameof(type)); } } /// /// 관리자 차감 전용 다중 잔액 분할 차감: SpendPolicy.DefaultSpendOrder 순서로 분할 차감하고 /// 사용된 BalanceType 별로 Adjusted 트랜잭션을 각각 기록한다. /// 합계 부족 시 InvalidOperationException — 호출자가 GetTotalAvailable 로 사전 검증할 것. /// public void AdjustDecreaseAcrossBalances(Money amount, string reason, string? refID = null, string? memo = null) { EnsureMoney(amount); if (string.IsNullOrWhiteSpace(reason)) { throw new ArgumentException("Adjustment reason is required.", nameof(reason)); } var remaining = amount; foreach (var type in SpendPolicy.DefaultSpendOrder) { if (remaining.IsZero) break; var balance = EnsureBalance(type); if (balance.Amount.IsZero) continue; var takeValue = Math.Min(balance.Amount.Value, remaining.Value); if (takeValue <= 0) continue; var take = Money.KRW(takeValue); balance.Decrease(take); remaining = remaining - take; _transactions.Add(WalletTransaction.Create( walletKey: WalletKey, balanceType: type, txType: WalletTransactionType.Adjusted, amount: take, balanceAfter: balance.Amount, reason: $"ADJUST_OUT:{reason}", refID: refID, memo: memo )); } if (!remaining.IsZero) { throw new InvalidOperationException("Insufficient balance for adjustment."); } } // ---- Internal ---- private void DebitSingle(WalletBalanceType balanceType, WalletTransactionType txType, Money amount, string reason, string? refID) { EnsureMoney(amount); var balance = EnsureBalance(balanceType); balance.Decrease(amount); _transactions.Add(WalletTransaction.Create( walletKey: WalletKey, balanceType: balanceType, txType: txType, amount: amount, balanceAfter: balance.Amount, reason: reason, refID: refID )); } private WalletBalance GetBalanceEntity(WalletBalanceType type) { var found = _balances.SingleOrDefault(x => x.Type == type); if (found is null) { throw new InvalidOperationException($"Balance type '{type}' not initialized."); } return found; } private WalletBalance EnsureBalance(WalletBalanceType type) { var found = _balances.SingleOrDefault(x => x.Type == type); if (found != null) { return found; } var created = WalletBalance.Create(WalletKey, type); _balances.Add(created); return created; } private static void EnsureMoney(Money amount) { if (amount.IsZero || amount.Value <= 0) { throw new ArgumentException("Amount must be positive.", nameof(amount)); } } }