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; } // ---- 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); 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 )); } // ---- Debit ---- public void DebitDonationOut(Money amount, string reason, string? refID = null) => DebitSingle(WalletBalanceType.Donation, WalletTransactionType.DonationOut, 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) { EnsureMoney(amount); if (string.IsNullOrWhiteSpace(reason)) { throw new ArgumentException("Adjustment reason is required.", nameof(reason)); } var balance = EnsureBalance(WalletBalanceType.Adjusted); balance.Increase(amount); _transactions.Add(WalletTransaction.Create( walletKey: WalletKey, balanceType: WalletBalanceType.Adjusted, 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) { EnsureMoney(amount); if (string.IsNullOrWhiteSpace(reason)) { throw new ArgumentException("Adjustment reason is required.", nameof(reason)); } var balance = EnsureBalance(WalletBalanceType.Adjusted); balance.Decrease(amount); _transactions.Add(WalletTransaction.Create( walletKey: WalletKey, balanceType: WalletBalanceType.Adjusted, txType: WalletTransactionType.Adjusted, amount: amount, balanceAfter: balance.Amount, reason: $"ADJUST_OUT:{reason}", refID: refID, memo: memo )); } // ---- 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)); } } } }