Browse Source

feat: 지갑 관리 및 거래 장부 기능 추가

- Application CQRS Features: Wallet List (Search, Get, Charge), Transactions (Search, Get, Delete)
- Domain: Wallet.AdjustIncrease/AdjustDecrease에 memo 파라미터 추가
- Presentation: PageModel 4개 (Index, View, Transactions Index/View)
- cshtml: 도메인 WalletTransactionType 기반으로 거래 장부 페이지 재작성
- IAppDbContext/AppDbContext에 Wallet, WalletTransaction DbSet 추가
- Transactions Search에서 N+1 COUNT 쿼리를 GroupBy 1회로 최적화
- 충전 페이지에 관리자 비밀번호 검증 추가
- Get Handler들의 잔액 계산을 엔티티 메서드(GetTotalAvailable, GetBalance)로 통일

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
KIM-JINO5 4 months ago
parent
commit
623eaa8d22
30 changed files with 1475 additions and 18 deletions
  1. 1 12
      Admin/Admin.csproj
  2. 162 0
      Admin/Pages/Member/Wallet/List/Index.cshtml
  3. 93 0
      Admin/Pages/Member/Wallet/List/Index.cshtml.cs
  4. 99 0
      Admin/Pages/Member/Wallet/List/View.cshtml
  5. 105 0
      Admin/Pages/Member/Wallet/List/View.cshtml.cs
  6. 222 0
      Admin/Pages/Member/Wallet/Transactions/Index.cshtml
  7. 169 0
      Admin/Pages/Member/Wallet/Transactions/Index.cshtml.cs
  8. 100 0
      Admin/Pages/Member/Wallet/Transactions/View.cshtml
  9. 95 0
      Admin/Pages/Member/Wallet/Transactions/View.cshtml.cs
  10. 11 1
      Admin/using.cs
  11. 4 0
      Application/Abstractions/Data/IAppDbContext.cs
  12. 1 1
      Application/Application.csproj
  13. 9 0
      Application/Features/Member/Wallet/List/Charge/Command.cs
  14. 34 0
      Application/Features/Member/Wallet/List/Charge/Handler.cs
  15. 24 0
      Application/Features/Member/Wallet/List/Get/Handler.cs
  16. 5 0
      Application/Features/Member/Wallet/List/Get/Query.cs
  17. 10 0
      Application/Features/Member/Wallet/List/Get/Response.cs
  18. 62 0
      Application/Features/Member/Wallet/List/Search/Handler.cs
  19. 12 0
      Application/Features/Member/Wallet/List/Search/Query.cs
  20. 21 0
      Application/Features/Member/Wallet/List/Search/Response.cs
  21. 5 0
      Application/Features/Member/Wallet/Transactions/Delete/Command.cs
  22. 13 0
      Application/Features/Member/Wallet/Transactions/Delete/Handler.cs
  23. 39 0
      Application/Features/Member/Wallet/Transactions/Get/Handler.cs
  24. 5 0
      Application/Features/Member/Wallet/Transactions/Get/Query.cs
  25. 24 0
      Application/Features/Member/Wallet/Transactions/Get/Response.cs
  26. 88 0
      Application/Features/Member/Wallet/Transactions/Search/Handler.cs
  27. 14 0
      Application/Features/Member/Wallet/Transactions/Search/Query.cs
  28. 37 0
      Application/Features/Member/Wallet/Transactions/Search/Response.cs
  29. 6 4
      Domain/Entities/Wallets/Wallet.cs
  30. 5 0
      Infrastructure/Persistence/AppDbContext.cs

+ 1 - 12
Admin/Admin.csproj

@@ -1,4 +1,4 @@
-<Project Sdk="Microsoft.NET.Sdk.Web">
+<Project Sdk="Microsoft.NET.Sdk.Web">
 
 	<PropertyGroup>
 		<TargetFramework>net10.0</TargetFramework>
@@ -6,19 +6,8 @@
 		<ImplicitUsings>enable</ImplicitUsings>
 	</PropertyGroup>
 
-	<ItemGroup>
-	  <Content Remove="Pages\Member\Wallet\List\Index.cshtml" />
-	  <Content Remove="Pages\Member\Wallet\List\View.cshtml" />
-	  <Content Remove="Pages\Member\Wallet\Transactions\List.cshtml" />
-	  <Content Remove="Pages\Member\Wallet\Transactions\View.cshtml" />
-	</ItemGroup>
-
 	<ItemGroup>
 		<None Include=".github\copilot-instructions.md" />
-		<None Include="Pages\Member\Wallet\List\Index.cshtml" />
-		<None Include="Pages\Member\Wallet\List\View.cshtml" />
-		<None Include="Pages\Member\Wallet\Transactions\List.cshtml" />
-		<None Include="Pages\Member\Wallet\Transactions\View.cshtml" />
 	</ItemGroup>
 
 	<ItemGroup>

+ 162 - 0
Admin/Pages/Member/Wallet/List/Index.cshtml

@@ -0,0 +1,162 @@
+@page
+@model Admin.Pages.Member.Wallet.List.IndexModel
+@{
+    ViewData["Title"] = "지갑 관리";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="row g-2 mb-2">
+        <div class="col-12 col-lg-auto">
+            <div class="row g-2">
+                <div class="col-auto col-md-auto">
+                    <select id="search" class="form-select">
+                        <option value="1" selected="@(Model.Parameter.Search == 1)">회원 ID</option>
+                        <option value="2" selected="@(Model.Parameter.Search == 2)">회원 별명</option>
+                        <option value="3" selected="@(Model.Parameter.Search == 3)">회원 이메일</option>
+                        <option value="4" selected="@(Model.Parameter.Search == 4)">지갑 ID</option>
+                    </select>
+                </div>
+                <div class="col col-md-auto">
+                    <input type="search" id="keyword" class="form-control" maxlength="100" value="@Model.Parameter.Keyword" />
+                </div>
+            </div>
+        </div>
+        <div class="col-12 col-sm">
+            <div class="row g-2">
+                <div class="col-12 col-md-auto">
+                    <div class="row row-cols-2 g-2">
+                        <div class="col">
+                            <input type="datetime-local" name="startAt" id="startAt" class="form-control" value="@Model.Parameter.StartAt" />
+                        </div>
+                        <div class="col d-none">
+                            ~
+                        </div>
+                        <div class="col">
+                            <input type="datetime-local" name="endAt" id="endAt" class="form-control" value="@Model.Parameter.EndAt" />
+                        </div>
+                    </div>
+                </div>
+                <div class="col col-md-auto text-center">
+                    <button type="submit" id="btnSearch" class="btn btn-primary w-100">검색</button>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <hr />
+
+    <div class="row g-2 align-items-end">
+        <div class="col">
+            Total : @Model.Total
+        </div>
+        <div class="col text-end">
+            <select name="per_page" id="perPage" class="form-select w-auto d-inline-block" form="fAdminList">
+                <option value="10" selected="@(Model.Parameter.PerPage == 10)">10</option>
+                <option value="20" selected="@(Model.Parameter.PerPage == 20)">20</option>
+                <option value="50" selected="@(Model.Parameter.PerPage == 50)">50</option>
+                <option value="100" selected="@(Model.Parameter.PerPage == 100)">100</option>
+            </select>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <table class="table table-bordered table-hover mt-3">
+            <colgroup>
+                <col style="width: 5%;" />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+            </colgroup>
+            <thead>
+                <tr>
+                    <th>No</th>
+                    <th>지갑 ID</th>
+                    <th>회원</th>
+                    <th>보유 P</th>
+                    <th>후원 받은 P</th>
+                    <th>변동 일시</th>
+                    <th>등록 일시</th>
+                    <th>비고</th>
+                </tr>
+            </thead>
+            @if (Model.List == null || Model.List.Count <= 0)
+            {
+                <tbody>
+                    <tr>
+                        <td colspan="8">No Data.</td>
+                    </tr>
+                </tbody>
+            }
+            else
+            {
+                @foreach (var row in Model.List)
+                {
+                    <tbody>
+                        <tr>
+                            <td>@row.Num</td>
+                            <td>@row.ID</td>
+                            <td>[@row.MemberID] @row.MemberEmail, @row.MemberName</td>
+                            <td>@row.Balance</td>
+                            <td>@row.DonationBalance</td>
+                            <td>@row.UpdatedAt</td>
+                            <td>@row.CreatedAt</td>
+                            <td>
+                                <a class="btn btn-sm btn-outline-success" href="@row.ChargeURL">충전</a>
+                            </td>
+                        </tr>
+                    </tbody>
+                }
+            }
+        </table>
+
+        <partial name="_Pagination" model="Model.Pagination" />
+    </div>
+
+    <div>
+        <ul class="form-text text-muted">
+            <li>회원 ID는 회원 번호(PK)로 조회가 가능합니다.</li>
+            <li>지갑 ID는 지갑 번호(PK)로 조회가 가능합니다.</li>
+        </ul>
+    </div>
+</div>
+
+@section Scripts {
+    <script>
+        function updateQueryString() {
+            let queryParams = new URLSearchParams();
+
+            queryParams.set("search", document.getElementById("search").value);
+            queryParams.set("keyword", document.getElementById("keyword").value);
+            queryParams.set("startAt", document.getElementById("startAt").value);
+            queryParams.set("endAt", document.getElementById("endAt").value);
+            queryParams.set("perPage", document.getElementById("perPage").value);
+
+            window.location.href = window.location.pathname + "?" + queryParams.toString();
+        }
+
+        $(document).on("change", "#perPage", function () {
+            updateQueryString();
+        });
+
+        $(document).on("click", "#btnSearch", function(e) {
+            e.preventDefault();
+            updateQueryString();
+        });
+
+        $(document).on("keyup", "#keyword, #startAt, #endAt", function(e) {
+            if (e.which === 13 || e.key === "Enter") {
+                e.preventDefault();
+                updateQueryString();
+            }
+        });
+    </script>
+}

+ 93 - 0
Admin/Pages/Member/Wallet/List/Index.cshtml.cs

@@ -0,0 +1,93 @@
+using SharedKernel.Helpers;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Member.Wallet.List;
+
+public class IndexModel(IMediator mediator) : PageModel
+{
+    [BindProperty(SupportsGet = true)]
+    public QueryParams Parameter { get; set; } = new();
+
+    public sealed class QueryParams
+    {
+        [Range(1, int.MaxValue)]
+        [DisplayName("페이지 번호")]
+        public int PageNum { get; set; } = 1;
+
+        [Range(1, 100)]
+        [DisplayName("페이지 목록 수")]
+        public ushort PerPage { get; set; } = 10;
+
+        [DisplayName("검색 조건")]
+        [Range(1, 4, ErrorMessage = "{0}이(가) 올바르지 않습니다.")]
+        public int? Search { get; set; }
+
+        [DisplayName("검색어")]
+        [MaxLength(255, ErrorMessage = "{0}은(는) {1}자 이하로 입력하세요.")]
+        public string? Keyword { get; set; }
+
+        [DisplayName("시작일")]
+        public string? StartAt { get; set; }
+
+        [DisplayName("종료일")]
+        public string? EndAt { get; set; }
+    }
+
+    public int Total { get; set; }
+
+    public List<(
+        int Num,
+        int ID,
+        int MemberID,
+        string MemberEmail,
+        string MemberName,
+        string Balance,
+        string DonationBalance,
+        string UpdatedAt,
+        string CreatedAt,
+        string ChargeURL
+    )> List { get; set; } = [];
+
+    public Pagination? Pagination { get; set; }
+
+    public async Task OnGetAsync(CancellationToken ct)
+    {
+        if (!ModelState.IsValid)
+        {
+            return;
+        }
+
+        var result = await mediator.Send(new SearchWallets.Query(
+            Parameter.PageNum,
+            Parameter.PerPage,
+            Parameter.Search,
+            Parameter.Keyword,
+            Parameter.StartAt,
+            Parameter.EndAt
+        ), ct);
+
+        Total = result.Total;
+
+        var qs = Request.QueryString.ToString();
+
+        List = [..result.List.Select(c => (
+            c.Num,
+            c.ID,
+            c.MemberID,
+            c.MemberEmail,
+            MemberName: c.MemberName ?? "-",
+            Balance: c.Balance.ToString("N0"),
+            DonationBalance: c.DonationBalance.ToString("N0"),
+            UpdatedAt: c.UpdatedAt.GetDateAt() ?? "-",
+            CreatedAt: c.CreatedAt.GetDateAt(),
+            ChargeURL: $"/Member/Wallet/List/View/{c.ID}{qs}"
+        ))];
+
+        Pagination = new Pagination(result.Total, Parameter.PageNum, Parameter.PerPage);
+    }
+}

+ 99 - 0
Admin/Pages/Member/Wallet/List/View.cshtml

@@ -0,0 +1,99 @@
+@page "{id:int}"
+@model Admin.Pages.Member.Wallet.List.ViewModel
+@{
+    ViewData["Title"] = "(C) 충전 하기";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="alert alert-warning" role="alert">
+        관리자 비밀번호 확인 후 충전 또는 차감 처리됩니다.
+    </div>
+
+    <form name="f_admin_write" id="fAdminWrite" method="post" asp-page-handler="Charge" accept-charset="utf-8" autocomplete="off">
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">회원 ID</label>
+            <div class="col-sm-10">
+                <input type="text" class="form-control-plaintext" value="@Model.Member.ID" readonly disabled />
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">회원 이메일</label>
+            <div class="col-sm-10">
+                <input type="text" class="form-control-plaintext" value="@Model.Member.Email" readonly disabled />
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label class="col-sm-2 mb-2">회원 등급</label>
+            <div class="col-sm-10">
+                @(Model.Member.GradeName ?? "-")
+            </div>
+        </div>
+        <div class="row mb-3">
+            <label class="col-sm-2">보유 잔액(P)</label>
+            <div class="col-sm-10">
+                @Model.Wallet.Balance
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Amount" class="col-sm-2 col-form-label"><span>*</span> 충전 금액</label>
+            <div class="col-sm-10">
+                <div class="input-group">
+                    <input type="number" asp-for="Amount" class="form-control w-auto d-flex flex-grow-0" required min="-99999999" max="99999999" step="100" autofocus />
+                    <span class="input-group-text">P</span>
+                </div>
+                <span asp-validation-for="Amount" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Password" class="col-sm-2 col-form-label"><span>*</span> 비밀번호</label>
+            <div class="col-sm-10">
+                <input type="password" asp-for="Password" class="form-control" required maxlength="100"/>
+                <span asp-validation-for="Password" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Memo" class="col-sm-2 col-form-label">메모</label>
+            <div class="col-sm-10">
+                <textarea asp-for="Memo" class="form-control" rows="5" maxlength="1000"></textarea>
+                <span asp-validation-for="Memo" class="text-danger"></span>
+            </div>
+        </div>
+        <hr />
+        <div class="d-grid gap-2 text-center d-md-block">
+            <button type="submit" class="btn btn-sm btn-success">저장</button>
+            <a href="/Member/Wallet/List" class="btn btn-sm btn-secondary">취소</a>
+        </div>
+        <br />
+    </form>
+</div>
+
+@section Scripts {
+    <script>
+        $("#fAdminWrite").validate({
+            rules: {
+                "Amount": {
+                    required: true
+                },
+                "Password": {
+                    required: true
+                }
+            },
+            messages: {
+                "Amount": {
+                    required: "충전 금액을 입력해주세요."
+                },
+                "Password": {
+                    required: "비밀번호를 입력해주세요."
+                }
+            },
+            submitHandler: function(form) {
+                form.submit();
+            }
+        });
+    </script>
+}

+ 105 - 0
Admin/Pages/Member/Wallet/List/View.cshtml.cs

@@ -0,0 +1,105 @@
+using Infrastructure.Persistence.Identity;
+using MediatR;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Member.Wallet.List;
+
+public class ViewModel(IMediator mediator, UserManager<ApplicationUser> userManager) : PageModel
+{
+    public WalletInfo Wallet { get; set; } = new();
+    public MemberInfo Member { get; set; } = new();
+
+    [BindProperty]
+    [Required(ErrorMessage = "충전 금액을 입력해주세요.")]
+    [Range(-99999999, 99999999, ErrorMessage = "금액은 -99,999,999 ~ 99,999,999 범위입니다.")]
+    [DisplayName("충전 금액")]
+    public long Amount { get; set; }
+
+    [BindProperty]
+    [Required(ErrorMessage = "비밀번호를 입력해주세요.")]
+    [DisplayName("비밀번호")]
+    public string Password { get; set; } = default!;
+
+    [BindProperty]
+    [MaxLength(1000, ErrorMessage = "메모는 {1}자 이하로 입력하세요.")]
+    [DisplayName("메모")]
+    public string? Memo { get; set; }
+
+    public sealed class WalletInfo
+    {
+        public int ID { get; set; }
+        public string Balance { get; set; } = "0";
+    }
+
+    public sealed class MemberInfo
+    {
+        public int ID { get; set; }
+        public string Email { get; set; } = default!;
+        public string? GradeName { get; set; }
+    }
+
+    public async Task<IActionResult> OnGetAsync(int id, CancellationToken ct)
+    {
+        try
+        {
+            var result = await mediator.Send(new GetWallet.Query(id), ct);
+
+            Wallet = new WalletInfo
+            {
+                ID = result.WalletID,
+                Balance = result.Balance.ToString("N0")
+            };
+
+            Member = new MemberInfo
+            {
+                ID = result.MemberID,
+                Email = result.MemberEmail,
+                GradeName = result.GradeName
+            };
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+            return RedirectToPage("Index");
+        }
+
+        return Page();
+    }
+
+    public async Task<IActionResult> OnPostChargeAsync(int id, CancellationToken ct)
+    {
+        try
+        {
+            if (Amount == 0)
+            {
+                throw new ArgumentException("충전 금액을 입력해주세요.");
+            }
+
+            var admin = await userManager.GetUserAsync(User);
+            if (admin is null || !await userManager.CheckPasswordAsync(admin, Password))
+            {
+                throw new UnauthorizedAccessException("비밀번호가 올바르지 않습니다.");
+            }
+
+            await mediator.Send(new ChargeWallet.Command(
+                id,
+                Amount,
+                Memo
+            ), ct);
+
+            TempData["SuccessMessage"] = Amount > 0
+                ? $"{Amount:N0}P 충전되었습니다."
+                : $"{Math.Abs(Amount):N0}P 차감되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return RedirectToPage("View", new { id });
+    }
+}

+ 222 - 0
Admin/Pages/Member/Wallet/Transactions/Index.cshtml

@@ -0,0 +1,222 @@
+@page
+@model Admin.Pages.Member.Wallet.Transactions.IndexModel
+@using Domain.Entities.Wallets.ValueObject
+@{
+    ViewData["Title"] = "회원 거래 장부";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="row g-2 mb-2">
+        <div class="col-12 col-lg-auto">
+            <div class="row g-2">
+                <div class="col-auto col-md-auto">
+                    <select id="search" class="form-select">
+                        <option value="1" selected="@(Model.Query.Search == 1)">회원 이메일</option>
+                        <option value="2" selected="@(Model.Query.Search == 2)">거래 ID</option>
+                        <option value="3" selected="@(Model.Query.Search == 3)">지갑 ID</option>
+                        <option value="4" selected="@(Model.Query.Search == 4)">회원 ID</option>
+                        <option value="5" selected="@(Model.Query.Search == 5)">회원 별명</option>
+                    </select>
+                </div>
+                <div class="col col-md-auto">
+                    <input type="search" id="keyword" class="form-control" maxlength="100" value="@Model.Query.Keyword" />
+                </div>
+            </div>
+        </div>
+        <div class="col-12 col-sm">
+            <div class="row g-2">
+                <div class="col-12 col-md-auto">
+                    <div class="row row-cols-2 g-2">
+                        <div class="col">
+                            <input type="datetime-local" name="startAt" id="startAt" class="form-control" value="@Model.Query.StartAt" />
+                        </div>
+                        <div class="col d-none">
+                            ~
+                        </div>
+                        <div class="col">
+                            <input type="datetime-local" name="endAt" id="endAt" class="form-control" value="@Model.Query.EndAt" />
+                        </div>
+                    </div>
+                </div>
+                <div class="col col-md-auto text-center">
+                    <button type="submit" id="btnSearch" class="btn btn-primary w-100">검색</button>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <hr />
+
+    <div class="row g-2 align-items-end mb-3">
+        <div class="col">
+            Total : @Model.Total
+        </div>
+        <div class="col text-end">
+            <select name="per_page" id="perPage" class="form-select w-auto d-inline-block form-select-sm">
+                <option value="10" selected="@(Model.Query.PerPage == 10)">10</option>
+                <option value="20" selected="@(Model.Query.PerPage == 20)">20</option>
+                <option value="50" selected="@(Model.Query.PerPage == 50)">50</option>
+                <option value="100" selected="@(Model.Query.PerPage == 100)">100</option>
+            </select>
+            <environment include="Local,Development">
+                <button type="button" id="btnListDelete" class="btn btn-sm btn-danger" data-action="/Member/Wallet/Transactions?handler=Delete" disabled>삭제</button>
+            </environment>
+        </div>
+    </div>
+
+    <ul class="nav nav-tabs">
+        <li class="nav-item">
+            <a class="nav-link @(Model.Query.Type == null ? "active" : null)" href="/Member/Wallet/Transactions">전체(@Model.Total)</a>
+        </li>
+        <li class="nav-item">
+            <a class="nav-link @(Model.Query.Type == WalletTransactionType.Charge ? "active" : null)" href="/Member/Wallet/Transactions?Type=1">충전(@Model.TotalCharge)</a>
+        </li>
+        <li class="nav-item">
+            <a class="nav-link @(Model.Query.Type == WalletTransactionType.DonationIn ? "active" : null)" href="/Member/Wallet/Transactions?Type=2">후원 받음(@Model.TotalDonationIn)</a>
+        </li>
+        <li class="nav-item">
+            <a class="nav-link @(Model.Query.Type == WalletTransactionType.DonationOut ? "active" : null)" href="/Member/Wallet/Transactions?Type=3">후원 보냄(@Model.TotalDonationOut)</a>
+        </li>
+        <li class="nav-item">
+            <a class="nav-link @(Model.Query.Type == WalletTransactionType.RewardEarned ? "active" : null)" href="/Member/Wallet/Transactions?Type=4">보상 적립(@Model.TotalRewardEarned)</a>
+        </li>
+        <li class="nav-item">
+            <a class="nav-link @(Model.Query.Type == WalletTransactionType.Spend ? "active" : null)" href="/Member/Wallet/Transactions?Type=5">사용(@Model.TotalSpend)</a>
+        </li>
+        <li class="nav-item">
+            <a class="nav-link @(Model.Query.Type == WalletTransactionType.Refund ? "active" : null)" href="/Member/Wallet/Transactions?Type=6">환불(@Model.TotalRefund)</a>
+        </li>
+        <li class="nav-item">
+            <a class="nav-link @(Model.Query.Type == WalletTransactionType.Lock ? "active" : null)" href="/Member/Wallet/Transactions?Type=7">잠금(@Model.TotalLock)</a>
+        </li>
+        <li class="nav-item">
+            <a class="nav-link @(Model.Query.Type == WalletTransactionType.Unlock ? "active" : null)" href="/Member/Wallet/Transactions?Type=8">잠금 해제(@Model.TotalUnlock)</a>
+        </li>
+        <li class="nav-item">
+            <a class="nav-link @(Model.Query.Type == WalletTransactionType.Adjusted ? "active" : null)" href="/Member/Wallet/Transactions?Type=9">조정(@Model.TotalAdjusted)</a>
+        </li>
+    </ul>
+
+    <form name="f_admin_list" id="fAdminList" method="post" accept-charset="utf-8" autocomplete="off">
+        <input type="hidden" name="type" id="type" value="@((int?)Model.Query.Type)" />
+    </form>
+
+    <div class="table-responsive">
+        <table class="table table-bordered table-hover mt-3">
+            <colgroup>
+                <col style="width: 5%;" />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+            </colgroup>
+            <thead>
+                <tr>
+                    <th>No</th>
+                    <th>
+                        <div class="form-check-inline">
+                            <input type="checkbox" id="checkedAll" class="form-check-input" value="1" form="fAdminList" />
+                            <label for="checkedAll">거래 ID</label>
+                        </div>
+                    </th>
+                    <th>회원</th>
+                    <th>거래 유형</th>
+                    <th>잔액 구분</th>
+                    <th>거래 금액</th>
+                    <th>거래 후 잔액</th>
+                    <th>거래 일시</th>
+                    <th>비고</th>
+                </tr>
+            </thead>
+            @if (Model.List == null || Model.List.Count <= 0)
+            {
+                <tbody>
+                    <tr>
+                        <td colspan="9">No Data.</td>
+                    </tr>
+                </tbody>
+            }
+            else
+            {
+                @foreach (var row in Model.List)
+                {
+                    <tbody>
+                        <tr>
+                            <td>@row.Num</td>
+                            <td>
+                                <div>
+                                    <input type="checkbox" name="checkList[]" id="chk_@row.ID" class="form-check-input list-check-box" value="@row.ID" form="fAdminList" />
+                                    <label for="chk_@row.ID" class="form-check-inline">@row.ID</label>
+                                </div>
+                            </td>
+                            <td>[@row.MemberID] @row.MemberEmail, @row.MemberName</td>
+                            <td>@row.TxType</td>
+                            <td>@row.BalanceType</td>
+                            <td>@row.Amount</td>
+                            <td>@row.BalanceAfter</td>
+                            <td>@row.CreatedAt</td>
+                            <td>
+                                <div class="d-xl-flex gap-2 justify-content-center d-grid">
+                                    <a class="btn btn-sm btn-outline-info" href="@row.ViewURL">상세</a>
+                                </div>
+                            </td>
+                        </tr>
+                    </tbody>
+                }
+            }
+        </table>
+
+        <partial name="_Pagination" model="Model.Pagination" />
+    </div>
+
+    <div>
+        <ul class="form-text text-muted">
+            <li>거래 장부 삭제 시 거래 유형에 맞는 증감 또는 차감 처리가 이루어 집니다.</li>
+            <li>거래 후 잔액은 거래 시점의 해당 잔액 구분 잔액입니다.</li>
+            <li><u>삭제 전 지갑 정보를 자세히 확인 후 삭제하실 것을 권장합니다.</u></li>
+        </ul>
+    </div>
+</div>
+
+@section Scripts {
+    <script>
+        function updateQueryString() {
+            let queryParams = new URLSearchParams();
+            let type = document.getElementById("type").value;
+
+            queryParams.set("search", document.getElementById("search").value);
+            queryParams.set("keyword", document.getElementById("keyword").value);
+            queryParams.set("startAt", document.getElementById("startAt").value);
+            queryParams.set("endAt", document.getElementById("endAt").value);
+            queryParams.set("perPage", document.getElementById("perPage").value);
+            if (type) queryParams.set("type", type);
+
+            window.location.href = window.location.pathname + "?" + queryParams.toString();
+        }
+
+        $(document).on("change", "#perPage", function () {
+            updateQueryString();
+        });
+
+        $(document).on("click", "#btnSearch", function(e) {
+            e.preventDefault();
+            updateQueryString();
+        });
+
+        $(document).on("keyup", "#keyword, #startAt, #endAt", function(e) {
+            if (e.which === 13 || e.key === "Enter") {
+                e.preventDefault();
+                updateQueryString();
+            }
+        });
+    </script>
+}

+ 169 - 0
Admin/Pages/Member/Wallet/Transactions/Index.cshtml.cs

@@ -0,0 +1,169 @@
+using SharedKernel.Helpers;
+using SharedKernel.Extensions;
+using Domain.Entities.Wallets.ValueObject;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Member.Wallet.Transactions;
+
+public class IndexModel(IMediator mediator) : PageModel
+{
+    [BindProperty(SupportsGet = true)]
+    public QueryParams Query { get; set; } = new();
+
+    public sealed class QueryParams
+    {
+        [Range(1, int.MaxValue)]
+        [DisplayName("페이지 번호")]
+        public int PageNum { get; set; } = 1;
+
+        [Range(1, 100)]
+        [DisplayName("페이지 목록 수")]
+        public ushort PerPage { get; set; } = 10;
+
+        [DisplayName("검색 조건")]
+        [Range(1, 5, ErrorMessage = "{0}이(가) 올바르지 않습니다.")]
+        public int? Search { get; set; }
+
+        [DisplayName("검색어")]
+        [MaxLength(255, ErrorMessage = "{0}은(는) {1}자 이하로 입력하세요.")]
+        public string? Keyword { get; set; }
+
+        [DisplayName("시작일")]
+        public string? StartAt { get; set; }
+
+        [DisplayName("종료일")]
+        public string? EndAt { get; set; }
+
+        [DisplayName("거래 유형")]
+        public WalletTransactionType? Type { get; set; }
+    }
+
+    public int Total { get; set; }
+    public int TotalCharge { get; set; }
+    public int TotalDonationIn { get; set; }
+    public int TotalDonationOut { get; set; }
+    public int TotalRewardEarned { get; set; }
+    public int TotalSpend { get; set; }
+    public int TotalRefund { get; set; }
+    public int TotalLock { get; set; }
+    public int TotalUnlock { get; set; }
+    public int TotalAdjusted { get; set; }
+
+    public List<(
+        int Num,
+        int ID,
+        Guid WalletKey,
+        int MemberID,
+        string MemberEmail,
+        string MemberName,
+        string TxType,
+        string BalanceType,
+        string Amount,
+        string BalanceAfter,
+        string CreatedAt,
+        string ViewURL,
+        string DeleteURL
+    )> List { get; set; } = [];
+
+    public Pagination? Pagination { get; set; }
+
+    public async Task OnGetAsync(CancellationToken ct)
+    {
+        if (!ModelState.IsValid)
+        {
+            return;
+        }
+
+        var result = await mediator.Send(new SearchWalletTransactions.Query(
+            Query.PageNum,
+            Query.PerPage,
+            Query.Search,
+            Query.Keyword,
+            Query.StartAt,
+            Query.EndAt,
+            Query.Type
+        ), ct);
+
+        Total = result.Total;
+        TotalCharge = result.TotalCharge;
+        TotalDonationIn = result.TotalDonationIn;
+        TotalDonationOut = result.TotalDonationOut;
+        TotalRewardEarned = result.TotalRewardEarned;
+        TotalSpend = result.TotalSpend;
+        TotalRefund = result.TotalRefund;
+        TotalLock = result.TotalLock;
+        TotalUnlock = result.TotalUnlock;
+        TotalAdjusted = result.TotalAdjusted;
+
+        var qs = Request.QueryString.ToString();
+
+        List = [..result.List.Select(c => (
+            c.Num,
+            c.ID,
+            c.WalletKey,
+            c.MemberID,
+            c.MemberEmail,
+            MemberName: c.MemberName ?? "-",
+            TxType: GetTxTypeName(c.TxType),
+            BalanceType: GetBalanceTypeName(c.BalanceType),
+            Amount: c.Amount.ToString("N0"),
+            BalanceAfter: c.BalanceAfter.ToString("N0"),
+            CreatedAt: c.CreatedAt.GetDateAt(),
+            ViewURL: $"/Member/Wallet/Transactions/View/{c.ID}{qs}",
+            DeleteURL: $"/Member/Wallet/Transactions?handler=Delete&id={c.ID}"
+        ))];
+
+        Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage);
+    }
+
+    public async Task<IActionResult> OnPostDeleteAsync(int[] ids, CancellationToken ct)
+    {
+        try
+        {
+            if (ids.Length == 0)
+            {
+                throw new Exception("삭제할 항목을 선택해주세요.");
+            }
+
+            await mediator.Send(new DeleteWalletTransaction.Command(ids), ct);
+
+            TempData["SuccessMessage"] = $"{ids.Length}건이 삭제되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return RedirectToPage(Query);
+    }
+
+    private static string GetTxTypeName(WalletTransactionType type) => type switch
+    {
+        WalletTransactionType.Charge => "충전",
+        WalletTransactionType.DonationIn => "후원 받음",
+        WalletTransactionType.DonationOut => "후원 보냄",
+        WalletTransactionType.RewardEarned => "보상 적립",
+        WalletTransactionType.Spend => "사용",
+        WalletTransactionType.Refund => "환불",
+        WalletTransactionType.Lock => "잠금",
+        WalletTransactionType.Unlock => "잠금 해제",
+        WalletTransactionType.Adjusted => "조정",
+        _ => type.ToString()
+    };
+
+    private static string GetBalanceTypeName(WalletBalanceType type) => type switch
+    {
+        WalletBalanceType.PgCharged => "PG 충전",
+        WalletBalanceType.Deposit => "직접 입금",
+        WalletBalanceType.Donation => "후원",
+        WalletBalanceType.Reward => "보상",
+        WalletBalanceType.Airdrop => "에어드랍",
+        WalletBalanceType.Locked => "잠금",
+        WalletBalanceType.Adjusted => "조정",
+        _ => type.ToString()
+    };
+}

+ 100 - 0
Admin/Pages/Member/Wallet/Transactions/View.cshtml

@@ -0,0 +1,100 @@
+@page "{id:int}"
+@model Admin.Pages.Member.Wallet.Transactions.ViewModel
+@{
+    ViewData["Title"] = "거래 장부 상세";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="row mb-3">
+        <label class="col-sm-2">거래 ID</label>
+        <div class="col-sm-10">
+            @Model.Detail.ID
+        </div>
+    </div>
+    <div class="row mb-3">
+        <label class="col-sm-2">회원</label>
+        <div class="col-sm-10">
+            [@Model.Detail.MemberID] @Model.Detail.MemberEmail, @(Model.Detail.MemberName ?? "-")
+        </div>
+    </div>
+    <div class="row mb-3">
+        <label class="col-sm-2">지갑 ID</label>
+        <div class="col-sm-10">
+            @Model.Detail.WalletID
+        </div>
+    </div>
+    <div class="row mb-3">
+        <label class="col-sm-2">지갑 정보</label>
+        <div class="col-sm-10">
+            보유 P : @Model.Detail.WalletBalance / 후원 받은 P : @Model.Detail.WalletDonationBalance
+        </div>
+    </div>
+    <div class="row mb-3">
+        <label class="col-sm-2">거래 유형</label>
+        <div class="col-sm-10">
+            @Model.Detail.TxType
+        </div>
+    </div>
+    <div class="row mb-3">
+        <label class="col-sm-2">잔액 구분</label>
+        <div class="col-sm-10">
+            @Model.Detail.BalanceType
+        </div>
+    </div>
+    <div class="row mb-3">
+        <label class="col-sm-2">거래 금액</label>
+        <div class="col-sm-10">
+            @Model.Detail.Amount P
+        </div>
+    </div>
+    <div class="row mb-3">
+        <label class="col-sm-2">거래 후 잔액</label>
+        <div class="col-sm-10">
+            @Model.Detail.BalanceAfter P
+            <div class="form-text text-muted">
+                거래 시점의 해당 잔액 구분 잔액입니다.
+            </div>
+        </div>
+    </div>
+    <div class="row mb-3">
+        <label class="col-sm-2">사유</label>
+        <div class="col-sm-10">
+            @(Model.Detail.Reason ?? "-")
+        </div>
+    </div>
+    <div class="row mb-3">
+        <label class="col-sm-2">참조 ID</label>
+        <div class="col-sm-10">
+            @(Model.Detail.RefID ?? "-")
+        </div>
+    </div>
+    <div class="row mb-3">
+        <label class="col-sm-2">사용자 ID</label>
+        <div class="col-sm-10">
+            @(Model.Detail.UserID ?? "-")
+        </div>
+    </div>
+    <div class="row mb-3">
+        <label class="col-sm-2">메모</label>
+        <div class="col-sm-10">
+            @(Model.Detail.Memo ?? "-")
+        </div>
+    </div>
+    <div class="row mb-3">
+        <label class="col-sm-2">거래 일시</label>
+        <div class="col-sm-10">
+            @Model.Detail.CreatedAt
+        </div>
+    </div>
+    <hr/>
+
+    <div class="d-grid gap-2 text-center d-md-block">
+        <a href="/Member/Wallet/Transactions" class="btn btn-sm btn-secondary">뒤로가기</a>
+    </div>
+    <br/>
+</div>

+ 95 - 0
Admin/Pages/Member/Wallet/Transactions/View.cshtml.cs

@@ -0,0 +1,95 @@
+using SharedKernel.Extensions;
+using Domain.Entities.Wallets.ValueObject;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Pages.Member.Wallet.Transactions;
+
+public class ViewModel(IMediator mediator) : PageModel
+{
+    public TransactionDetail Detail { get; set; } = new();
+
+    public sealed class TransactionDetail
+    {
+        public int ID { get; set; }
+        public Guid WalletKey { get; set; }
+        public int WalletID { get; set; }
+        public int MemberID { get; set; }
+        public string MemberEmail { get; set; } = default!;
+        public string? MemberName { get; set; }
+        public string WalletBalance { get; set; } = "0";
+        public string WalletDonationBalance { get; set; } = "0";
+        public string TxType { get; set; } = default!;
+        public string BalanceType { get; set; } = default!;
+        public string Amount { get; set; } = "0";
+        public string BalanceAfter { get; set; } = "0";
+        public string? Reason { get; set; }
+        public string? RefID { get; set; }
+        public string? UserID { get; set; }
+        public string? Memo { get; set; }
+        public string CreatedAt { get; set; } = default!;
+    }
+
+    public async Task<IActionResult> OnGetAsync(int id, CancellationToken ct)
+    {
+        try
+        {
+            var result = await mediator.Send(new GetWalletTransaction.Query(id), ct);
+
+            Detail = new TransactionDetail
+            {
+                ID = result.ID,
+                WalletKey = result.WalletKey,
+                WalletID = result.WalletID,
+                MemberID = result.MemberID,
+                MemberEmail = result.MemberEmail,
+                MemberName = result.MemberName,
+                WalletBalance = result.WalletBalance.ToString("N0"),
+                WalletDonationBalance = result.WalletDonationBalance.ToString("N0"),
+                TxType = GetTxTypeName(result.TxType),
+                BalanceType = GetBalanceTypeName(result.BalanceType),
+                Amount = result.Amount.ToString("N0"),
+                BalanceAfter = result.BalanceAfter.ToString("N0"),
+                Reason = result.Reason,
+                RefID = result.RefID,
+                UserID = result.UserID,
+                Memo = result.Memo,
+                CreatedAt = result.CreatedAt.GetDateAt()
+            };
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+            return RedirectToPage("List");
+        }
+
+        return Page();
+    }
+
+    private static string GetTxTypeName(WalletTransactionType type) => type switch
+    {
+        WalletTransactionType.Charge => "충전",
+        WalletTransactionType.DonationIn => "후원 받음",
+        WalletTransactionType.DonationOut => "후원 보냄",
+        WalletTransactionType.RewardEarned => "보상 적립",
+        WalletTransactionType.Spend => "사용",
+        WalletTransactionType.Refund => "환불",
+        WalletTransactionType.Lock => "잠금",
+        WalletTransactionType.Unlock => "잠금 해제",
+        WalletTransactionType.Adjusted => "조정",
+        _ => type.ToString()
+    };
+
+    private static string GetBalanceTypeName(WalletBalanceType type) => type switch
+    {
+        WalletBalanceType.PgCharged => "PG 충전",
+        WalletBalanceType.Deposit => "직접 입금",
+        WalletBalanceType.Donation => "후원",
+        WalletBalanceType.Reward => "보상",
+        WalletBalanceType.Airdrop => "에어드랍",
+        WalletBalanceType.Locked => "잠금",
+        WalletBalanceType.Adjusted => "조정",
+        _ => type.ToString()
+    };
+}

+ 11 - 1
Admin/using.cs

@@ -81,4 +81,14 @@ global using DeleteNameChangeLog = Application.Features.Member.NameChangeLog.Del
 
 // 한마디 변경 내역
 global using SearchSummaryChangeLogs = Application.Features.Member.SummaryChangeLog.Search;
-global using DeleteSummaryChangeLog = Application.Features.Member.SummaryChangeLog.Delete;
+global using DeleteSummaryChangeLog = Application.Features.Member.SummaryChangeLog.Delete;
+
+// 지갑 관리
+global using SearchWallets = Application.Features.Member.Wallet.List.Search;
+global using GetWallet = Application.Features.Member.Wallet.List.Get;
+global using ChargeWallet = Application.Features.Member.Wallet.List.Charge;
+
+// 거래 장부
+global using SearchWalletTransactions = Application.Features.Member.Wallet.Transactions.Search;
+global using GetWalletTransaction = Application.Features.Member.Wallet.Transactions.Get;
+global using DeleteWalletTransaction = Application.Features.Member.Wallet.Transactions.Delete;

+ 4 - 0
Application/Abstractions/Data/IAppDbContext.cs

@@ -5,6 +5,7 @@ using Domain.Entities.Page;
 using Domain.Entities.Page.Faq;
 using Domain.Entities.Page.Banner;
 using Domain.Entities.Members.Logs;
+using Domain.Entities.Wallets;
 
 namespace Application.Abstractions.Data
 {
@@ -28,6 +29,9 @@ namespace Application.Abstractions.Data
         DbSet<MemberIntroChangeLog> MemberIntroChangeLog { get; set; }
         DbSet<Channel> Channel { get; set; }
 
+        DbSet<Wallet> Wallet { get; set; }
+        DbSet<WalletTransaction> WalletTransaction { get; set; }
+
         Task<int> SaveChangesAsync(CancellationToken ct = default);
     }
 }

+ 1 - 1
Application/Application.csproj

@@ -9,7 +9,7 @@
   <ItemGroup>
     <Folder Include="Authentication\" />
     <Folder Include="Behaviors\" />
-    <Folder Include="Features\Member\Logs\" />
+    <Folder Include="Features\Member\Log\" />
     <Folder Include="Features\ReferenceData\Dtos\" />
   </ItemGroup>
 

+ 9 - 0
Application/Features/Member/Wallet/List/Charge/Command.cs

@@ -0,0 +1,9 @@
+using MediatR;
+
+namespace Application.Features.Member.Wallet.List.Charge;
+
+public sealed record Command(
+    int WalletID,
+    long Amount,
+    string? Memo
+) : IRequest;

+ 34 - 0
Application/Features/Member/Wallet/List/Charge/Handler.cs

@@ -0,0 +1,34 @@
+using Application.Abstractions.Data;
+using Domain.Entities.Common.ValueObject;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Member.Wallet.List.Charge;
+
+public sealed class Handler(IAppDbContext db) : IRequestHandler<Command>
+{
+    public async Task Handle(Command request, CancellationToken ct)
+    {
+        var wallet = await db.Wallet.Include(x => x.Balances).Include(x => x.Transactions).FirstOrDefaultAsync(x => x.ID == request.WalletID, ct);
+
+        if (wallet is null) throw new KeyNotFoundException("지갑을 찾을 수 없습니다.");
+
+        var amount = Money.KRW(Math.Abs(request.Amount));
+        var reason = request.Amount > 0 ? "관리자 충전" : "관리자 차감";
+
+        if (request.Amount > 0)
+        {
+            wallet.AdjustIncrease(amount, reason, memo: request.Memo);
+        }
+        else if (request.Amount < 0)
+        {
+            wallet.AdjustDecrease(amount, reason, memo: request.Memo);
+        }
+        else
+        {
+            throw new ArgumentException("충전 금액을 입력해주세요.");
+        }
+
+        await db.SaveChangesAsync(ct);
+    }
+}

+ 24 - 0
Application/Features/Member/Wallet/List/Get/Handler.cs

@@ -0,0 +1,24 @@
+using Application.Abstractions.Data;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Member.Wallet.List.Get;
+
+public sealed class Handler(IAppDbContext db) : IRequestHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var wallet = await db.Wallet.AsNoTracking().Include(x => x.Member).ThenInclude(m => m.MemberGrade).Include(x => x.Balances).FirstOrDefaultAsync(x => x.ID == request.Id, ct);
+
+        if (wallet is null) throw new KeyNotFoundException("지갑을 찾을 수 없습니다.");
+
+        return new Response
+        {
+            WalletID = wallet.ID,
+            Balance = (long)wallet.GetTotalAvailable().Value,
+            MemberID = wallet.MemberID,
+            MemberEmail = wallet.Member.Email,
+            GradeName = wallet.Member.MemberGrade?.KorName
+        };
+    }
+}

+ 5 - 0
Application/Features/Member/Wallet/List/Get/Query.cs

@@ -0,0 +1,5 @@
+using MediatR;
+
+namespace Application.Features.Member.Wallet.List.Get;
+
+public sealed record Query(int Id) : IRequest<Response>;

+ 10 - 0
Application/Features/Member/Wallet/List/Get/Response.cs

@@ -0,0 +1,10 @@
+namespace Application.Features.Member.Wallet.List.Get;
+
+public sealed class Response
+{
+    public int WalletID { get; init; }
+    public long Balance { get; init; }
+    public int MemberID { get; init; }
+    public required string MemberEmail { get; init; }
+    public string? GradeName { get; init; }
+}

+ 62 - 0
Application/Features/Member/Wallet/List/Search/Handler.cs

@@ -0,0 +1,62 @@
+using Application.Abstractions.Data;
+using Domain.Entities.Wallets.ValueObject;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Member.Wallet.List.Search;
+
+public sealed class Handler(IAppDbContext db) : IRequestHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var query = db.Wallet.AsNoTracking().Include(x => x.Member).Include(x => x.Balances).AsQueryable();
+
+        // 키워드 검색
+        if (!string.IsNullOrWhiteSpace(request.Keyword))
+        {
+            query = request.Search switch
+            {
+                1 => query.Where(x => x.MemberID.ToString().Contains(request.Keyword)),
+                2 => query.Where(x => x.Member.Name != null && x.Member.Name.Contains(request.Keyword)),
+                3 => query.Where(x => x.Member.Email.Contains(request.Keyword)),
+                4 => query.Where(x => x.ID.ToString().Contains(request.Keyword)),
+                _ => query
+            };
+        }
+
+        // 날짜 필터
+        if (!string.IsNullOrWhiteSpace(request.StartAt) && DateTime.TryParse(request.StartAt, out var startAt))
+        {
+            query = query.Where(x => x.CreatedAt >= startAt);
+        }
+
+        if (!string.IsNullOrWhiteSpace(request.EndAt) && DateTime.TryParse(request.EndAt, out var endAt))
+        {
+            query = query.Where(x => x.CreatedAt <= endAt);
+        }
+
+        var total = await query.CountAsync(ct);
+        var skip = (request.PageNum - 1) * request.PerPage;
+
+        var list = await query.OrderByDescending(x => x.ID).Skip(skip).Take(request.PerPage).Select(x => new { x.ID, x.MemberID, MemberEmail = x.Member.Email, MemberName = x.Member.Name, Balances = x.Balances, x.UpdatedAt, x.CreatedAt }).ToListAsync(ct);
+
+        var rows = list.Select((x, idx) => new Response.Row
+        {
+            Num = total - skip - idx,
+            ID = x.ID,
+            MemberID = x.MemberID,
+            MemberEmail = x.MemberEmail,
+            MemberName = x.MemberName,
+            Balance = (long)x.Balances.Where(b => b.Type != WalletBalanceType.Locked).Sum(b => b.Amount.Value),
+            DonationBalance = (long)(x.Balances.FirstOrDefault(b => b.Type == WalletBalanceType.Donation)?.Amount.Value ?? 0),
+            UpdatedAt = x.UpdatedAt,
+            CreatedAt = x.CreatedAt
+        }).ToList();
+
+        return new Response
+        {
+            Total = total,
+            List = rows
+        };
+    }
+}

+ 12 - 0
Application/Features/Member/Wallet/List/Search/Query.cs

@@ -0,0 +1,12 @@
+using MediatR;
+
+namespace Application.Features.Member.Wallet.List.Search;
+
+public sealed record Query(
+    int PageNum,
+    ushort PerPage,
+    int? Search = null,
+    string? Keyword = null,
+    string? StartAt = null,
+    string? EndAt = null
+) : IRequest<Response>;

+ 21 - 0
Application/Features/Member/Wallet/List/Search/Response.cs

@@ -0,0 +1,21 @@
+namespace Application.Features.Member.Wallet.List.Search;
+
+public sealed class Response
+{
+    public int Total { get; init; }
+
+    public required IReadOnlyList<Row> List { get; init; }
+
+    public sealed class Row
+    {
+        public int Num { get; init; }
+        public int ID { get; init; }
+        public int MemberID { get; init; }
+        public required string MemberEmail { get; init; }
+        public string? MemberName { get; init; }
+        public long Balance { get; init; }
+        public long DonationBalance { get; init; }
+        public DateTime? UpdatedAt { get; init; }
+        public DateTime CreatedAt { get; init; }
+    }
+}

+ 5 - 0
Application/Features/Member/Wallet/Transactions/Delete/Command.cs

@@ -0,0 +1,5 @@
+using MediatR;
+
+namespace Application.Features.Member.Wallet.Transactions.Delete;
+
+public sealed record Command(int[] IDs) : IRequest;

+ 13 - 0
Application/Features/Member/Wallet/Transactions/Delete/Handler.cs

@@ -0,0 +1,13 @@
+using Application.Abstractions.Data;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Member.Wallet.Transactions.Delete;
+
+public sealed class Handler(IAppDbContext db) : IRequestHandler<Command>
+{
+    public async Task Handle(Command request, CancellationToken ct)
+    {
+        await db.WalletTransaction.Where(x => request.IDs.Contains(x.ID)).ExecuteDeleteAsync(ct);
+    }
+}

+ 39 - 0
Application/Features/Member/Wallet/Transactions/Get/Handler.cs

@@ -0,0 +1,39 @@
+using Application.Abstractions.Data;
+using Domain.Entities.Wallets.ValueObject;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Member.Wallet.Transactions.Get;
+
+public sealed class Handler(IAppDbContext db) : IRequestHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var tx = await db.WalletTransaction.AsNoTracking().Include(x => x.Wallet).ThenInclude(w => w.Member).Include(x => x.Wallet).ThenInclude(w => w.Balances).FirstOrDefaultAsync(x => x.ID == request.Id, ct);
+
+        if (tx is null) throw new KeyNotFoundException("거래 내역을 찾을 수 없습니다.");
+
+        var wallet = tx.Wallet;
+
+        return new Response
+        {
+            ID = tx.ID,
+            WalletKey = tx.WalletKey,
+            WalletID = wallet.ID,
+            MemberID = wallet.MemberID,
+            MemberEmail = wallet.Member.Email,
+            MemberName = wallet.Member.Name,
+            WalletBalance = (long)wallet.GetTotalAvailable().Value,
+            WalletDonationBalance = (long)wallet.GetBalance(WalletBalanceType.Donation).Value,
+            TxType = tx.TxType,
+            BalanceType = tx.BalanceType,
+            Amount = (long)tx.Amount.Value,
+            BalanceAfter = (long)tx.BalanceAfter.Value,
+            Reason = tx.Reason,
+            RefID = tx.RefID,
+            UserID = tx.UserID,
+            Memo = tx.Memo,
+            CreatedAt = tx.CreatedAt
+        };
+    }
+}

+ 5 - 0
Application/Features/Member/Wallet/Transactions/Get/Query.cs

@@ -0,0 +1,5 @@
+using MediatR;
+
+namespace Application.Features.Member.Wallet.Transactions.Get;
+
+public sealed record Query(int Id) : IRequest<Response>;

+ 24 - 0
Application/Features/Member/Wallet/Transactions/Get/Response.cs

@@ -0,0 +1,24 @@
+using Domain.Entities.Wallets.ValueObject;
+
+namespace Application.Features.Member.Wallet.Transactions.Get;
+
+public sealed class Response
+{
+    public int ID { get; init; }
+    public Guid WalletKey { get; init; }
+    public int WalletID { get; init; }
+    public int MemberID { get; init; }
+    public required string MemberEmail { get; init; }
+    public string? MemberName { get; init; }
+    public long WalletBalance { get; init; }
+    public long WalletDonationBalance { get; init; }
+    public WalletTransactionType TxType { get; init; }
+    public WalletBalanceType BalanceType { get; init; }
+    public long Amount { get; init; }
+    public long BalanceAfter { get; init; }
+    public required string Reason { get; init; }
+    public string? RefID { get; init; }
+    public string? UserID { get; init; }
+    public string? Memo { get; init; }
+    public DateTime CreatedAt { get; init; }
+}

+ 88 - 0
Application/Features/Member/Wallet/Transactions/Search/Handler.cs

@@ -0,0 +1,88 @@
+using Application.Abstractions.Data;
+using Domain.Entities.Wallets.ValueObject;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Member.Wallet.Transactions.Search;
+
+public sealed class Handler(IAppDbContext db) : IRequestHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var query = db.WalletTransaction.AsNoTracking().Include(x => x.Wallet).ThenInclude(w => w.Member).AsQueryable();
+
+        // 거래 유형 필터
+        if (request.Type.HasValue)
+        {
+            query = query.Where(x => x.TxType == request.Type.Value);
+        }
+
+        // 키워드 검색
+        if (!string.IsNullOrWhiteSpace(request.Keyword))
+        {
+            query = request.Search switch
+            {
+                1 => query.Where(x => x.Wallet.Member.Email.Contains(request.Keyword)),
+                2 => query.Where(x => x.ID.ToString().Contains(request.Keyword)),
+                3 => query.Where(x => x.Wallet.ID.ToString().Contains(request.Keyword)),
+                4 => query.Where(x => x.Wallet.MemberID.ToString().Contains(request.Keyword)),
+                5 => query.Where(x => x.Wallet.Member.Name != null && x.Wallet.Member.Name.Contains(request.Keyword)),
+                _ => query
+            };
+        }
+
+        // 날짜 필터
+        if (!string.IsNullOrWhiteSpace(request.StartAt) && DateTime.TryParse(request.StartAt, out var startAt))
+        {
+            query = query.Where(x => x.CreatedAt >= startAt);
+        }
+
+        if (!string.IsNullOrWhiteSpace(request.EndAt) && DateTime.TryParse(request.EndAt, out var endAt))
+        {
+            query = query.Where(x => x.CreatedAt <= endAt);
+        }
+
+        var total = await query.CountAsync(ct);
+
+        // 유형별 카운트 (GroupBy 1회 쿼리)
+        var typeCounts = await db.WalletTransaction.AsNoTracking().GroupBy(x => x.TxType).Select(g => new { Type = g.Key, Count = g.Count() }).ToListAsync(ct);
+        int CountOf(WalletTransactionType t) => typeCounts.FirstOrDefault(x => x.Type == t)?.Count ?? 0;
+
+        var skip = (request.PageNum - 1) * request.PerPage;
+
+        var list = await query.OrderByDescending(x => x.ID).Skip(skip).Take(request.PerPage).Select(x => new { x.ID, x.WalletKey, MemberID = x.Wallet.MemberID, MemberEmail = x.Wallet.Member.Email, MemberName = x.Wallet.Member.Name, x.TxType, x.BalanceType, Amount = x.Amount.Value, BalanceAfter = x.BalanceAfter.Value, x.Reason, x.RefID, x.Memo, x.CreatedAt }).ToListAsync(ct);
+
+        var rows = list.Select((x, idx) => new Response.Row
+        {
+            Num = total - skip - idx,
+            ID = x.ID,
+            WalletKey = x.WalletKey,
+            MemberID = x.MemberID,
+            MemberEmail = x.MemberEmail,
+            MemberName = x.MemberName,
+            TxType = x.TxType,
+            BalanceType = x.BalanceType,
+            Amount = (long)x.Amount,
+            BalanceAfter = (long)x.BalanceAfter,
+            Reason = x.Reason,
+            RefID = x.RefID,
+            Memo = x.Memo,
+            CreatedAt = x.CreatedAt
+        }).ToList();
+
+        return new Response
+        {
+            Total = total,
+            TotalCharge = CountOf(WalletTransactionType.Charge),
+            TotalDonationIn = CountOf(WalletTransactionType.DonationIn),
+            TotalDonationOut = CountOf(WalletTransactionType.DonationOut),
+            TotalRewardEarned = CountOf(WalletTransactionType.RewardEarned),
+            TotalSpend = CountOf(WalletTransactionType.Spend),
+            TotalRefund = CountOf(WalletTransactionType.Refund),
+            TotalLock = CountOf(WalletTransactionType.Lock),
+            TotalUnlock = CountOf(WalletTransactionType.Unlock),
+            TotalAdjusted = CountOf(WalletTransactionType.Adjusted),
+            List = rows
+        };
+    }
+}

+ 14 - 0
Application/Features/Member/Wallet/Transactions/Search/Query.cs

@@ -0,0 +1,14 @@
+using Domain.Entities.Wallets.ValueObject;
+using MediatR;
+
+namespace Application.Features.Member.Wallet.Transactions.Search;
+
+public sealed record Query(
+    int PageNum,
+    ushort PerPage,
+    int? Search = null,
+    string? Keyword = null,
+    string? StartAt = null,
+    string? EndAt = null,
+    WalletTransactionType? Type = null
+) : IRequest<Response>;

+ 37 - 0
Application/Features/Member/Wallet/Transactions/Search/Response.cs

@@ -0,0 +1,37 @@
+using Domain.Entities.Wallets.ValueObject;
+
+namespace Application.Features.Member.Wallet.Transactions.Search;
+
+public sealed class Response
+{
+    public int Total { get; init; }
+    public int TotalCharge { get; init; }
+    public int TotalDonationIn { get; init; }
+    public int TotalDonationOut { get; init; }
+    public int TotalRewardEarned { get; init; }
+    public int TotalSpend { get; init; }
+    public int TotalRefund { get; init; }
+    public int TotalLock { get; init; }
+    public int TotalUnlock { get; init; }
+    public int TotalAdjusted { get; init; }
+
+    public required IReadOnlyList<Row> List { get; init; }
+
+    public sealed class Row
+    {
+        public int Num { get; init; }
+        public int ID { get; init; }
+        public Guid WalletKey { get; init; }
+        public int MemberID { get; init; }
+        public required string MemberEmail { get; init; }
+        public string? MemberName { get; init; }
+        public WalletTransactionType TxType { get; init; }
+        public WalletBalanceType BalanceType { get; init; }
+        public long Amount { get; init; }
+        public long BalanceAfter { get; init; }
+        public required string Reason { get; init; }
+        public string? RefID { get; init; }
+        public string? Memo { get; init; }
+        public DateTime CreatedAt { get; init; }
+    }
+}

+ 6 - 4
Domain/Entities/Wallets/Wallet.cs

@@ -165,7 +165,7 @@ namespace Domain.Entities.Wallets
         }
 
         // ---- Adjust ----
-        public void AdjustIncrease(Money amount, string reason, string? refID = null)
+        public void AdjustIncrease(Money amount, string reason, string? refID = null, string? memo = null)
         {
             EnsureMoney(amount);
 
@@ -184,11 +184,12 @@ namespace Domain.Entities.Wallets
                 amount: amount,
                 balanceAfter: balance.Amount,
                 reason: $"ADJUST_IN:{reason}",
-                refID: refID
+                refID: refID,
+                memo: memo
             ));
         }
 
-        public void AdjustDecrease(Money amount, string reason, string? refID = null)
+        public void AdjustDecrease(Money amount, string reason, string? refID = null, string? memo = null)
         {
             EnsureMoney(amount);
 
@@ -207,7 +208,8 @@ namespace Domain.Entities.Wallets
                 amount: amount,
                 balanceAfter: balance.Amount,
                 reason: $"ADJUST_OUT:{reason}",
-                refID: refID
+                refID: refID,
+                memo: memo
             ));
         }
 

+ 5 - 0
Infrastructure/Persistence/AppDbContext.cs

@@ -5,6 +5,7 @@ using Domain.Entities.Members.Logs;
 using Domain.Entities.Page;
 using Domain.Entities.Page.Banner;
 using Domain.Entities.Page.Faq;
+using Domain.Entities.Wallets;
 using Microsoft.EntityFrameworkCore;
 
 namespace Infrastructure.Persistence
@@ -32,6 +33,10 @@ namespace Infrastructure.Persistence
         public DbSet<MemberSummaryChangeLog> MemberSummaryChangeLog { get; set; }
         public DbSet<MemberIntroChangeLog> MemberIntroChangeLog { get; set; }
 
+        // Wallet
+        public DbSet<Wallet> Wallet { get; set; }
+        public DbSet<WalletTransaction> WalletTransaction { get; set; }
+
         protected override void OnModelCreating(ModelBuilder modelBuilder)
         {
             // Apply all configurations from the current assembly