| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575 |
- 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<WalletBalance> _balances = [];
- public IReadOnlyCollection<WalletBalance> Balances => _balances;
- private readonly List<WalletTransaction> _transactions = [];
- public IReadOnlyCollection<WalletTransaction> 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;
- }
- /// <summary>
- /// 상점 결제로 사용 가능한 잔액 합계 (SpendPolicy.StoreOrderSpendOrder 기준).
- /// 비활성 잔액 유형(원장 호환용)과 Locked 는 제외.
- /// </summary>
- 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);
- /// <summary>
- /// 가입 축하 보상 적립: 지정 잔액 유형(코인=Airdrop, 캐시=Adjusted)으로 입금하되
- /// 트랜잭션 타입은 RewardEarned 로 기록해 멱등 판정(RefID "signup:{memberID}")을 일원화한다.
- /// Locked 는 출금 프로세스 전용이라 직접 적립 불가.
- /// </summary>
- 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);
- }
- /// <summary>
- /// 상점 환불 환원: 원본 결제(OrderPay)가 차감한 BalanceType 그대로 복원해 출처를 보존한다.
- /// 호출자는 OrderPay 트랜잭션을 BalanceType 별로 집계해 type 마다 한 번씩 호출한다.
- /// </summary>
- 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
- ));
- }
- /// <summary>
- /// 모의투자 출금 환급: 계좌 → 지갑 토큰(Reward 파티션). 트랜잭션 타입 PaperWithdraw.
- /// </summary>
- public void CreditPaperWithdraw(Money amount, string reason = "PAPER_WITHDRAW", string? refID = null)
- => Credit(WalletBalanceType.Reward, WalletTransactionType.PaperWithdraw, amount, reason, refID);
- /// <summary>
- /// 응원(Cheer) 수신 적립 (D3 M4). 수령 순액(net)을 Reward 파티션으로 입금하고 CheerReceived 로 기록한다.
- /// Reward = 토큰 파티션 → 구조적으로 출금 불가·상점/모의투자 소비 전용(§⑧ 규제 가드라인:
- /// 응원 적립분은 "투자조언의 대가"가 성립하지 않도록 인출 경로가 없다).
- /// </summary>
- public void CreditCheerReceived(Money net, string reason = "CHEER_RECEIVED", string? refID = null)
- => Credit(WalletBalanceType.Reward, WalletTransactionType.CheerReceived, net, reason, refID);
- /// <summary>
- /// 모의투자 입금: 토큰 파티션(Reward → Airdrop) 순서로 분할 차감하고 사용된 BalanceType 별로
- /// PaperDeposit 트랜잭션을 각각 기록한다. 합계 부족 시 InvalidOperationException —
- /// 호출자가 GetPaperTokenAvailable 로 사전 검증할 것.
- /// </summary>
- 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.");
- }
- }
- /// <summary>모의투자 입금 가용 토큰 합계 (Reward + Airdrop).</summary>
- 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);
- /// <summary>
- /// 상점 결제 전용 다중 잔액 차감: SpendPolicy.StoreOrderSpendOrder 순서로 분할 차감하고
- /// 사용된 BalanceType 별로 OrderPay 트랜잭션을 각각 기록한다 (환불 시 출처 복원용).
- /// 합계 부족 시 InvalidOperationException — 호출자가 GetStoreOrderAvailable 로 사전 검증할 것.
- /// </summary>
- 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;
- }
- /// <summary>
- /// PG 결제 취소 회수: 충전분(PgCharged)을 되돌린다.
- /// 잔액 부족 시 WalletBalance.Decrease 예외 — 호출자가 PgCharged 잔액을 사전 검증할 것.
- /// </summary>
- public void DebitChargeCancel(Money amount, string reason = "CHARGE_CANCEL", string? refID = null)
- => DebitSingle(WalletBalanceType.PgCharged, WalletTransactionType.ChargeCancelled, amount, reason, refID);
- /// <summary>
- /// 응원(Cheer) 발신 다중 잔액 차감 (D3 M4): SpendPolicy.CheerSpendOrder(캐시→토큰) 순서로 분할 차감하고
- /// 사용된 BalanceType 별로 CheerSent 트랜잭션을 각각 기록한다. 합계 부족 시 InvalidOperationException —
- /// 호출자가 GetCheerAvailable 로 사전 검증할 것.
- /// </summary>
- public IReadOnlyList<(WalletBalanceType Type, Money Amount)> DebitForCheer(
- Money total,
- string reason = "CHEER_SENT",
- string? refID = null)
- {
- EnsureMoney(total);
- var remaining = total;
- var splits = new List<(WalletBalanceType, Money)>();
- foreach (var type in SpendPolicy.CheerSpendOrder)
- {
- 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.CheerSent,
- amount: take,
- balanceAfter: balance.Amount,
- reason: reason,
- refID: refID
- ));
- splits.Add((type, take));
- }
- if (!remaining.IsZero)
- {
- throw new InvalidOperationException("Insufficient balance for cheer.");
- }
- return splits;
- }
- /// <summary>응원 발신 가용 잔액 합계 (SpendPolicy.CheerSpendOrder = 캐시 + 토큰).</summary>
- public Money GetCheerAvailable()
- {
- var total = Money.KRW(0);
- foreach (var type in SpendPolicy.CheerSpendOrder)
- {
- total += GetBalance(type);
- }
- return total;
- }
- // ---- 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);
- /// <summary>
- /// 관리자 조정 충전: 지정한 잔액 유형으로 입금. 트랜잭션 타입은 Adjusted 로 기록해 수동 조작 추적을 보존한다.
- /// Locked 는 출금 프로세스 전용이라 직접 조정 불가.
- /// </summary>
- 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);
- /// <summary>
- /// 관리자 조정 차감: 지정한 잔액 유형에서만 차감. 잔액 부족 시 WalletBalance.Decrease 가 예외 —
- /// 호출자가 해당 잔액을 사전 검증할 것.
- /// </summary>
- 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));
- }
- }
- /// <summary>
- /// 관리자 차감 전용 다중 잔액 분할 차감: SpendPolicy.DefaultSpendOrder 순서로 분할 차감하고
- /// 사용된 BalanceType 별로 Adjusted 트랜잭션을 각각 기록한다.
- /// 합계 부족 시 InvalidOperationException — 호출자가 GetTotalAvailable 로 사전 검증할 것.
- /// </summary>
- 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));
- }
- }
- }
|