ソースを参照

Wave 1: replace Danal PG with Toss Payments + signup rewards + coin terminology

- Remove Danal PG entirely (domain/service/handlers/admin pages/tests, 48 files)
- Add Toss Payments V2 integration: TossConfirm/TossCancel/TossLog,
  TossPaymentService (confirm/cancel/query, Basic auth w/ colon, Idempotency-Key),
  webhook (re-query by paymentKey, no signature trust), auto-reconcile HostedService
- Server-side amount verification on confirm (tamper block); per-member customerKey UUID
- Inherit Phase 1.5 reliability: NeedsReconciliation on comm failure + reconcile log,
  concurrency single-retry (no double credit), PrepareRetry for unique conflict
- Admin: /Payments/Toss/* pages, Config Toss ClientKey/SecretKey (AES-GCM encrypted)
- Port ProcessRefund (store order) from Danal to Toss cancel
- Signup rewards: SignupRewardConfig (coin/cash/XP) + idempotent grant on all
  signup paths (local/Google/social) + Admin /Config/SignupReward
- Terminology: point -> coin in user-facing strings (code symbols/cash-domain preserved)
- Migrations: ReplaceDanalWithTossPayments, AddSignupRewardConfig (localdb applied)
- Build 0 errors, tests 65/65

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KIM-JINO5 3 週間 前
コミット
36fe8c1757
100 ファイル変更2952 行追加2809 行削除
  1. 5 5
      Admin/Pages/Config/Attendance/Index.cshtml
  2. 2 66
      Admin/Pages/Config/External.cshtml
  3. 40 144
      Admin/Pages/Config/Payment/Index.cshtml
  4. 14 4
      Admin/Pages/Config/Payment/Index.cshtml.cs
  5. 71 0
      Admin/Pages/Config/SignupReward/Index.cshtml
  6. 55 0
      Admin/Pages/Config/SignupReward/Index.cshtml.cs
  7. 0 166
      Admin/Pages/Payments/Danal/Cancel/View.cshtml
  8. 0 347
      Admin/Pages/Payments/Danal/Confirm/View.cshtml
  9. 38 44
      Admin/Pages/Payments/Toss/Cancel/Index.cshtml
  10. 10 10
      Admin/Pages/Payments/Toss/Cancel/Index.cshtml.cs
  11. 125 0
      Admin/Pages/Payments/Toss/Cancel/View.cshtml
  12. 5 5
      Admin/Pages/Payments/Toss/Cancel/View.cshtml.cs
  13. 32 44
      Admin/Pages/Payments/Toss/Confirm/Index.cshtml
  14. 10 10
      Admin/Pages/Payments/Toss/Confirm/Index.cshtml.cs
  15. 279 0
      Admin/Pages/Payments/Toss/Confirm/View.cshtml
  16. 9 9
      Admin/Pages/Payments/Toss/Confirm/View.cshtml.cs
  17. 41 70
      Admin/Pages/Payments/Toss/Error/Index.cshtml
  18. 8 8
      Admin/Pages/Payments/Toss/Error/Index.cshtml.cs
  19. 14 14
      Admin/using.cs
  20. 47 0
      Application/Abstractions/Crypto/EncryptedConfigValue.cs
  21. 4 4
      Application/Abstractions/Data/IAppDbContext.cs
  22. 0 55
      Application/Abstractions/Payment/DanalPayModels.cs
  23. 0 19
      Application/Abstractions/Payment/IDanalPayService.cs
  24. 24 0
      Application/Abstractions/Payment/ITossPaymentService.cs
  25. 38 0
      Application/Abstractions/Payment/TossPaymentModels.cs
  26. 0 5
      Application/Features/Admin/Payment/Danal/Cancel/Delete/Command.cs
  27. 0 43
      Application/Features/Admin/Payment/Danal/Cancel/Get/Handler.cs
  28. 0 5
      Application/Features/Admin/Payment/Danal/Cancel/Get/Query.cs
  29. 0 31
      Application/Features/Admin/Payment/Danal/Cancel/Get/Response.cs
  30. 0 101
      Application/Features/Admin/Payment/Danal/Cancel/Search/Handler.cs
  31. 0 38
      Application/Features/Admin/Payment/Danal/Cancel/Search/Response.cs
  32. 0 5
      Application/Features/Admin/Payment/Danal/Confirm/Delete/Command.cs
  33. 0 78
      Application/Features/Admin/Payment/Danal/Confirm/Get/Handler.cs
  34. 0 5
      Application/Features/Admin/Payment/Danal/Confirm/Get/Query.cs
  35. 0 63
      Application/Features/Admin/Payment/Danal/Confirm/Get/Response.cs
  36. 0 122
      Application/Features/Admin/Payment/Danal/Confirm/Search/Handler.cs
  37. 0 5
      Application/Features/Admin/Payment/Danal/Log/Delete/Command.cs
  38. 0 93
      Application/Features/Admin/Payment/Danal/Log/Search/Handler.cs
  39. 5 0
      Application/Features/Admin/Payment/Toss/Cancel/Delete/Command.cs
  40. 3 3
      Application/Features/Admin/Payment/Toss/Cancel/Delete/Handler.cs
  41. 33 0
      Application/Features/Admin/Payment/Toss/Cancel/Get/Handler.cs
  42. 5 0
      Application/Features/Admin/Payment/Toss/Cancel/Get/Query.cs
  43. 21 0
      Application/Features/Admin/Payment/Toss/Cancel/Get/Response.cs
  44. 105 0
      Application/Features/Admin/Payment/Toss/Cancel/Search/Handler.cs
  45. 4 4
      Application/Features/Admin/Payment/Toss/Cancel/Search/Query.cs
  46. 34 0
      Application/Features/Admin/Payment/Toss/Cancel/Search/Response.cs
  47. 2 2
      Application/Features/Admin/Payment/Toss/Confirm/Cancel/Command.cs
  48. 55 75
      Application/Features/Admin/Payment/Toss/Confirm/Cancel/Handler.cs
  49. 5 0
      Application/Features/Admin/Payment/Toss/Confirm/Delete/Command.cs
  50. 3 3
      Application/Features/Admin/Payment/Toss/Confirm/Delete/Handler.cs
  51. 62 0
      Application/Features/Admin/Payment/Toss/Confirm/Get/Handler.cs
  52. 5 0
      Application/Features/Admin/Payment/Toss/Confirm/Get/Query.cs
  53. 50 0
      Application/Features/Admin/Payment/Toss/Confirm/Get/Response.cs
  54. 123 0
      Application/Features/Admin/Payment/Toss/Confirm/Search/Handler.cs
  55. 3 4
      Application/Features/Admin/Payment/Toss/Confirm/Search/Query.cs
  56. 10 14
      Application/Features/Admin/Payment/Toss/Confirm/Search/Response.cs
  57. 5 0
      Application/Features/Admin/Payment/Toss/Log/Delete/Command.cs
  58. 3 3
      Application/Features/Admin/Payment/Toss/Log/Delete/Handler.cs
  59. 94 0
      Application/Features/Admin/Payment/Toss/Log/Search/Handler.cs
  60. 4 4
      Application/Features/Admin/Payment/Toss/Log/Search/Query.cs
  61. 7 6
      Application/Features/Admin/Payment/Toss/Log/Search/Response.cs
  62. 42 54
      Application/Features/Admin/Store/Order/ProcessRefund/Handler.cs
  63. 4 0
      Application/Features/Api/Auth/GoogleLogin/Handler.cs
  64. 4 0
      Application/Features/Api/Auth/Register/Handler.cs
  65. 4 0
      Application/Features/Api/Auth/SocialLogin/Handler.cs
  66. 0 9
      Application/Features/Api/Payment/Cancel/Command.cs
  67. 0 10
      Application/Features/Api/Payment/Confirm/Command.cs
  68. 0 213
      Application/Features/Api/Payment/Confirm/Handler.cs
  69. 0 8
      Application/Features/Api/Payment/Confirm/Response.cs
  70. 0 14
      Application/Features/Api/Payment/CreateOrder/Command.cs
  71. 0 44
      Application/Features/Api/Payment/CreateOrder/Handler.cs
  72. 11 0
      Application/Features/Api/Payment/Toss/Cancel/Command.cs
  73. 45 51
      Application/Features/Api/Payment/Toss/Cancel/Handler.cs
  74. 14 0
      Application/Features/Api/Payment/Toss/Confirm/Command.cs
  75. 236 0
      Application/Features/Api/Payment/Toss/Confirm/Handler.cs
  76. 13 0
      Application/Features/Api/Payment/Toss/Confirm/Response.cs
  77. 14 0
      Application/Features/Api/Payment/Toss/CreateOrder/Command.cs
  78. 61 0
      Application/Features/Api/Payment/Toss/CreateOrder/Handler.cs
  79. 4 5
      Application/Features/Api/Payment/Toss/CreateOrder/Response.cs
  80. 13 0
      Application/Features/Api/Payment/Toss/Reconcile/Command.cs
  81. 122 0
      Application/Features/Api/Payment/Toss/Reconcile/Handler.cs
  82. 16 0
      Application/Features/Api/Payment/Toss/Webhook/Command.cs
  83. 261 0
      Application/Features/Api/Payment/Toss/Webhook/Handler.cs
  84. 15 27
      Application/Features/Config/Get/Handler.cs
  85. 33 65
      Application/Features/Config/Get/Response.cs
  86. 2 1
      Application/Features/Config/Update/Command.cs
  87. 43 34
      Application/Features/Config/Update/Handler.cs
  88. 50 96
      Application/Features/Config/Update/Request.cs
  89. 108 0
      Application/Helpers/SignupRewardGranter.cs
  90. 10 9
      CLAUDE.md
  91. 31 59
      Domain/Entities/Common/Config.cs
  92. 18 0
      Domain/Entities/Members/Member.cs
  93. 0 134
      Domain/Entities/Payments/Danal/DanalCancel.cs
  94. 0 208
      Domain/Entities/Payments/Danal/DanalConfirm.cs
  95. 0 7
      Domain/Entities/Payments/Danal/ValueObject/DanalCancelType.cs
  96. 0 8
      Domain/Entities/Payments/Danal/ValueObject/DanalLogType.cs
  97. 113 0
      Domain/Entities/Payments/Toss/TossCancel.cs
  98. 126 0
      Domain/Entities/Payments/Toss/TossConfirm.cs
  99. 18 17
      Domain/Entities/Payments/Toss/TossLog.cs
  100. 9 0
      Domain/Entities/Payments/Toss/ValueObject/TossLogType.cs

+ 5 - 5
Admin/Pages/Config/Attendance/Index.cshtml

@@ -46,10 +46,10 @@
             </div>
 
             <div class="row mb-2">
-                <label asp-for="Input.Attendance.BasePoint" class="col-sm-2 col-form-label">기본 포인트</label>
+                <label asp-for="Input.Attendance.BasePoint" class="col-sm-2 col-form-label">기본 코인</label>
                 <div class="col-sm-3">
                     <input asp-for="Input.Attendance.BasePoint" type="number" class="form-control" min="0" max="100000" />
-                    <div class="form-text text-muted">출석 시 기본으로 지급되는 포인트</div>
+                    <div class="form-text text-muted">출석 시 기본으로 지급되는 코인</div>
                     <span asp-validation-for="Input.Attendance.BasePoint" class="text-danger"></span>
                 </div>
             </div>
@@ -83,10 +83,10 @@
             </div>
 
             <div class="row mb-2">
-                <label asp-for="Input.Attendance.StreakBonusPointPerDay" class="col-sm-2 col-form-label">1일당 추가 포인트</label>
+                <label asp-for="Input.Attendance.StreakBonusPointPerDay" class="col-sm-2 col-form-label">1일당 추가 코인</label>
                 <div class="col-sm-3">
                     <input asp-for="Input.Attendance.StreakBonusPointPerDay" type="number" class="form-control" min="0" max="10000" />
-                    <div class="form-text text-muted">연속 출석 1일당 추가 지급 포인트</div>
+                    <div class="form-text text-muted">연속 출석 1일당 추가 지급 코인</div>
                     <span asp-validation-for="Input.Attendance.StreakBonusPointPerDay" class="text-danger"></span>
                 </div>
             </div>
@@ -127,7 +127,7 @@
                             <tr>
                                 <th class="text-center" style="width: 80px;">순위</th>
                                 <th class="text-center">추가 경험치</th>
-                                <th class="text-center">추가 포인트</th>
+                                <th class="text-center">추가 코인</th>
                             </tr>
                         </thead>
                         <tbody>

+ 2 - 66
Admin/Pages/Config/External.cshtml

@@ -74,72 +74,8 @@
 
         <hr />
 
-        <!-- 다날 PG -->
-        <details open>
-            <summary class="fs-5 mb-3">다날 PG (DanalPay)</summary>
-
-            <div class="row mb-2">
-                <label asp-for="Input.External.DanalPayMode" class="col-sm-2 col-form-label">결제 환경</label>
-                <div class="col-sm-10">
-                    <select asp-for="Input.External.DanalPayMode" class="form-select">
-                        <option value="">-- 선택 --</option>
-                        <option value="test">테스트 (Test)</option>
-                        <option value="live">운영 (Live)</option>
-                    </select>
-                    <span asp-validation-for="Input.External.DanalPayMode" class="text-danger"></span>
-                </div>
-            </div>
-
-            <div class="row mb-2">
-                <label asp-for="Input.External.DanalTestCpid" class="col-sm-2 col-form-label">Test CPID</label>
-                <div class="col-sm-10">
-                    <input asp-for="Input.External.DanalTestCpid" type="text" class="form-control" placeholder="예: T0000" />
-                    <span asp-validation-for="Input.External.DanalTestCpid" class="text-danger"></span>
-                </div>
-            </div>
-
-            <div class="row mb-2">
-                <label asp-for="Input.External.DanalTestClientKeyEnc" class="col-sm-2 col-form-label">Test Client Key</label>
-                <div class="col-sm-10">
-                    <input asp-for="Input.External.DanalTestClientKeyEnc" type="text" class="form-control" autocomplete="off" />
-                    <span asp-validation-for="Input.External.DanalTestClientKeyEnc" class="text-danger"></span>
-                </div>
-            </div>
-
-            <div class="row mb-2">
-                <label asp-for="Input.External.DanalTestSecretKeyEnc" class="col-sm-2 col-form-label">Test Secret Key</label>
-                <div class="col-sm-10">
-                    <input asp-for="Input.External.DanalTestSecretKeyEnc" type="text" class="form-control" autocomplete="off" />
-                    <span asp-validation-for="Input.External.DanalTestSecretKeyEnc" class="text-danger"></span>
-                </div>
-            </div>
-
-            <div class="row mb-2">
-                <label asp-for="Input.External.DanalLiveCpid" class="col-sm-2 col-form-label">Live CPID</label>
-                <div class="col-sm-10">
-                    <input asp-for="Input.External.DanalLiveCpid" type="text" class="form-control" />
-                    <span asp-validation-for="Input.External.DanalLiveCpid" class="text-danger"></span>
-                </div>
-            </div>
-
-            <div class="row mb-2">
-                <label asp-for="Input.External.DanalLiveClientKeyEnc" class="col-sm-2 col-form-label">Live Client Key</label>
-                <div class="col-sm-10">
-                    <input asp-for="Input.External.DanalLiveClientKeyEnc" type="text" class="form-control" autocomplete="off" />
-                    <span asp-validation-for="Input.External.DanalLiveClientKeyEnc" class="text-danger"></span>
-                </div>
-            </div>
-
-            <div class="row mb-2">
-                <label asp-for="Input.External.DanalLiveSecretKeyEnc" class="col-sm-2 col-form-label">Live Secret Key</label>
-                <div class="col-sm-10">
-                    <input asp-for="Input.External.DanalLiveSecretKeyEnc" type="text" class="form-control" autocomplete="off" />
-                    <span asp-validation-for="Input.External.DanalLiveSecretKeyEnc" class="text-danger"></span>
-                </div>
-            </div>
-        </details>
-
-        <hr />
+        @* 다날 PG 설정 섹션은 토스 전환으로 제거됨 — 토스 키는 결제 설정(/Config/Payment)에서 관리.
+           저장된 다날 값은 서버(Update Handler)가 보존한다. *@
 
         <div class="row">
             <div class="col text-center p-3">

+ 40 - 144
Admin/Pages/Config/Payment/Index.cshtml

@@ -3,6 +3,8 @@
 
 @{
     ViewData["Title"] = "결제 설정";
+
+    string KeyPlaceholder(bool isSet) => isSet ? "설정됨 — 변경할 때만 입력하세요" : "미설정 — 키를 입력하세요";
 }
 
 <div class="container">
@@ -13,171 +15,65 @@
     <partial name="_StatusMessage" />
 
     <form name="f_admin_write" id="fAdminWrite" class="mt-2" method="post" autocomplete="off" accept-charset="UTF-8">
-        <!-- Danal Pay 수수료 -->
+        <!-- 토스페이먼츠 -->
         <details class="adm-section" open>
-            <summary class="fs-5">Danal Pay 수수료</summary>
+            <summary class="fs-5">토스페이먼츠 (Toss Payments)</summary>
 
             <div class="adm-section__body">
                 <div class="form-text text-muted mb-3">
-                    수수료는 <strong>%</strong> 가 우선 적용된 후, <strong>원</strong> 이 가산됩니다. 모든 기본값은 0 입니다.
-                </div>
-
-                <div class="row mb-2 align-items-center">
-                    <label asp-for="Input.Payment.DanalCardFeeRate" class="col-sm-2 col-form-label">신용카드</label>
-                    <div class="col-sm-4">
-                        <div class="input-group">
-                            <input asp-for="Input.Payment.DanalCardFeeRate" type="number" step="0.01" min="0" max="100" class="form-control text-end" />
-                            <span class="input-group-text">%</span>
-                        </div>
-                        <span asp-validation-for="Input.Payment.DanalCardFeeRate" class="text-danger"></span>
-                    </div>
-                    <div class="col-sm-4">
-                        <div class="input-group">
-                            <input asp-for="Input.Payment.DanalCardFeeAmount" type="number" step="1" min="0" max="100000000" class="form-control text-end" />
-                            <span class="input-group-text">원</span>
-                        </div>
-                        <span asp-validation-for="Input.Payment.DanalCardFeeAmount" class="text-danger"></span>
-                    </div>
+                    키는 저장 시 암호화됩니다. 이미 설정된 키는 화면에 표시되지 않으며, <strong>빈 칸으로 저장하면 기존 키가 유지</strong>됩니다.
                 </div>
 
-                <div class="row mb-2 align-items-center">
-                    <label asp-for="Input.Payment.DanalKakaoPayFeeRate" class="col-sm-2 col-form-label">카카오페이</label>
-                    <div class="col-sm-4">
-                        <div class="input-group">
-                            <input asp-for="Input.Payment.DanalKakaoPayFeeRate" type="number" step="0.01" min="0" max="100" class="form-control text-end" />
-                            <span class="input-group-text">%</span>
-                        </div>
-                        <span asp-validation-for="Input.Payment.DanalKakaoPayFeeRate" class="text-danger"></span>
-                    </div>
-                    <div class="col-sm-4">
-                        <div class="input-group">
-                            <input asp-for="Input.Payment.DanalKakaoPayFeeAmount" type="number" step="1" min="0" max="100000000" class="form-control text-end" />
-                            <span class="input-group-text">원</span>
-                        </div>
-                        <span asp-validation-for="Input.Payment.DanalKakaoPayFeeAmount" class="text-danger"></span>
+                <div class="row mb-2">
+                    <label asp-for="Input.Toss.TossPayMode" class="col-sm-2 col-form-label">결제 환경</label>
+                    <div class="col-sm-10">
+                        <select asp-for="Input.Toss.TossPayMode" class="form-select">
+                            <option value="">-- 선택 --</option>
+                            <option value="test">테스트 (Test)</option>
+                            <option value="live">운영 (Live)</option>
+                        </select>
+                        <span asp-validation-for="Input.Toss.TossPayMode" class="text-danger"></span>
                     </div>
                 </div>
 
-                <div class="row mb-2 align-items-center">
-                    <label asp-for="Input.Payment.DanalNaverPayFeeRate" class="col-sm-2 col-form-label">네이버페이</label>
-                    <div class="col-sm-4">
-                        <div class="input-group">
-                            <input asp-for="Input.Payment.DanalNaverPayFeeRate" type="number" step="0.01" min="0" max="100" class="form-control text-end" />
-                            <span class="input-group-text">%</span>
-                        </div>
-                        <span asp-validation-for="Input.Payment.DanalNaverPayFeeRate" class="text-danger"></span>
-                    </div>
-                    <div class="col-sm-4">
-                        <div class="input-group">
-                            <input asp-for="Input.Payment.DanalNaverPayFeeAmount" type="number" step="1" min="0" max="100000000" class="form-control text-end" />
-                            <span class="input-group-text">원</span>
-                        </div>
-                        <span asp-validation-for="Input.Payment.DanalNaverPayFeeAmount" class="text-danger"></span>
+                <div class="row mb-2">
+                    <label asp-for="Input.Toss.TestClientKey" class="col-sm-2 col-form-label">Test Client Key</label>
+                    <div class="col-sm-10">
+                        <input asp-for="Input.Toss.TestClientKey" type="text" class="form-control" autocomplete="off" placeholder="@KeyPlaceholder(Model.KeyState.HasTestClientKey)" />
+                        <span asp-validation-for="Input.Toss.TestClientKey" class="text-danger"></span>
                     </div>
                 </div>
 
-                <div class="row mb-2 align-items-center">
-                    <label asp-for="Input.Payment.DanalTransferFeeRate" class="col-sm-2 col-form-label">계좌이체</label>
-                    <div class="col-sm-4">
-                        <div class="input-group">
-                            <input asp-for="Input.Payment.DanalTransferFeeRate" type="number" step="0.01" min="0" max="100" class="form-control text-end" />
-                            <span class="input-group-text">%</span>
-                        </div>
-                        <span asp-validation-for="Input.Payment.DanalTransferFeeRate" class="text-danger"></span>
-                    </div>
-                    <div class="col-sm-4">
-                        <div class="input-group">
-                            <input asp-for="Input.Payment.DanalTransferFeeAmount" type="number" step="1" min="0" max="100000000" class="form-control text-end" />
-                            <span class="input-group-text">원</span>
-                        </div>
-                        <span asp-validation-for="Input.Payment.DanalTransferFeeAmount" class="text-danger"></span>
+                <div class="row mb-2">
+                    <label asp-for="Input.Toss.TestSecretKey" class="col-sm-2 col-form-label">Test Secret Key</label>
+                    <div class="col-sm-10">
+                        <input asp-for="Input.Toss.TestSecretKey" type="password" class="form-control" autocomplete="new-password" placeholder="@KeyPlaceholder(Model.KeyState.HasTestSecretKey)" />
+                        <span asp-validation-for="Input.Toss.TestSecretKey" class="text-danger"></span>
                     </div>
                 </div>
 
-                <div class="row mb-2 align-items-center">
-                    <label asp-for="Input.Payment.DanalMobileFeeRate" class="col-sm-2 col-form-label">휴대폰</label>
-                    <div class="col-sm-4">
-                        <div class="input-group">
-                            <input asp-for="Input.Payment.DanalMobileFeeRate" type="number" step="0.01" min="0" max="100" class="form-control text-end" />
-                            <span class="input-group-text">%</span>
-                        </div>
-                        <span asp-validation-for="Input.Payment.DanalMobileFeeRate" class="text-danger"></span>
-                    </div>
-                    <div class="col-sm-4">
-                        <div class="input-group">
-                            <input asp-for="Input.Payment.DanalMobileFeeAmount" type="number" step="1" min="0" max="100000000" class="form-control text-end" />
-                            <span class="input-group-text">원</span>
-                        </div>
-                        <span asp-validation-for="Input.Payment.DanalMobileFeeAmount" class="text-danger"></span>
+                <div class="row mb-2">
+                    <label asp-for="Input.Toss.LiveClientKey" class="col-sm-2 col-form-label">Live Client Key</label>
+                    <div class="col-sm-10">
+                        <input asp-for="Input.Toss.LiveClientKey" type="text" class="form-control" autocomplete="off" placeholder="@KeyPlaceholder(Model.KeyState.HasLiveClientKey)" />
+                        <span asp-validation-for="Input.Toss.LiveClientKey" class="text-danger"></span>
                     </div>
                 </div>
 
-                <div class="row mb-2 align-items-center">
-                    <label asp-for="Input.Payment.DanalVbankFeeRate" class="col-sm-2 col-form-label">가상계좌</label>
-                    <div class="col-sm-4">
-                        <div class="input-group">
-                            <input asp-for="Input.Payment.DanalVbankFeeRate" type="number" step="0.01" min="0" max="100" class="form-control text-end" />
-                            <span class="input-group-text">%</span>
-                        </div>
-                        <span asp-validation-for="Input.Payment.DanalVbankFeeRate" class="text-danger"></span>
+                <div class="row mb-2">
+                    <label asp-for="Input.Toss.LiveSecretKey" class="col-sm-2 col-form-label">Live Secret Key</label>
+                    <div class="col-sm-10">
+                        <input asp-for="Input.Toss.LiveSecretKey" type="password" class="form-control" autocomplete="new-password" placeholder="@KeyPlaceholder(Model.KeyState.HasLiveSecretKey)" />
+                        <span asp-validation-for="Input.Toss.LiveSecretKey" class="text-danger"></span>
                     </div>
-                    <div class="col-sm-4">
-                        <div class="input-group">
-                            <input asp-for="Input.Payment.DanalVbankFeeAmount" type="number" step="1" min="0" max="100000000" class="form-control text-end" />
-                            <span class="input-group-text">원</span>
-                        </div>
-                        <span asp-validation-for="Input.Payment.DanalVbankFeeAmount" class="text-danger"></span>
-                    </div>
-                </div>
-            </div>
-        </details>
-
-        <!-- 결제 수단 사용 여부 -->
-        <details class="adm-section" open>
-            <summary class="fs-5">Danal Pay 결제 수단</summary>
-
-            <div class="adm-section__body">
-                <div class="form-text text-muted mb-3">
-                    체크 해제된 결제 수단은 사용자 포인트 충전 페이지에서 비활성(어둡게) 표시되어 선택할 수 없습니다.
                 </div>
 
-                <div class="row">
-                    <div class="col-md-4 mb-2">
-                        <div class="form-check form-check-inline">
-                            <input asp-for="Input.Payment.IsCardEnabled" class="form-check-input" />
-                            <label class="form-check-label" asp-for="Input.Payment.IsCardEnabled">신용카드</label>
-                        </div>
-                    </div>
-                    <div class="col-md-4 mb-2">
-                        <div class="form-check form-check-inline">
-                            <input asp-for="Input.Payment.IsKakaoPayEnabled" class="form-check-input" />
-                            <label class="form-check-label" asp-for="Input.Payment.IsKakaoPayEnabled">카카오페이</label>
-                        </div>
-                    </div>
-                    <div class="col-md-4 mb-2">
-                        <div class="form-check form-check-inline">
-                            <input asp-for="Input.Payment.IsNaverPayEnabled" class="form-check-input" />
-                            <label class="form-check-label" asp-for="Input.Payment.IsNaverPayEnabled">네이버페이</label>
-                        </div>
-                    </div>
-                    <div class="col-md-4 mb-2">
-                        <div class="form-check form-check-inline">
-                            <input asp-for="Input.Payment.IsTransferEnabled" class="form-check-input" />
-                            <label class="form-check-label" asp-for="Input.Payment.IsTransferEnabled">계좌이체</label>
-                        </div>
-                    </div>
-                    <div class="col-md-4 mb-2">
-                        <div class="form-check form-check-inline">
-                            <input asp-for="Input.Payment.IsMobileEnabled" class="form-check-input" />
-                            <label class="form-check-label" asp-for="Input.Payment.IsMobileEnabled">휴대폰</label>
-                        </div>
-                    </div>
-                    <div class="col-md-4 mb-2">
-                        <div class="form-check form-check-inline">
-                            <input asp-for="Input.Payment.IsVirtualAccountEnabled" class="form-check-input" />
-                            <label class="form-check-label" asp-for="Input.Payment.IsVirtualAccountEnabled">가상계좌</label>
-                        </div>
-                    </div>
+                <div class="form-text text-muted">
+                    <ul class="mb-0">
+                        <li>Client Key 는 결제위젯 호출용으로 프론트엔드에 전달됩니다.</li>
+                        <li>Secret Key 는 승인/취소/조회 API 인증에 사용됩니다. 외부에 노출되지 않도록 주의하세요.</li>
+                        <li>키 발급: 토스페이먼츠 개발자센터 → 내 개발정보 → API 키</li>
+                    </ul>
                 </div>
             </div>
         </details>

+ 14 - 4
Admin/Pages/Config/Payment/Index.cshtml.cs

@@ -9,12 +9,16 @@ public class IndexModel(IMediator mediator) : PageModel
     [BindProperty]
     public InputModel Input { get; set; } = new();
 
+    /// <summary>저장된 키 존재 여부 — 화면에는 키 원문/암호문을 노출하지 않고 placeholder 로만 표시</summary>
+    public GetConfig.Response.TossConfigDto KeyState { get; set; } = new();
+
     public async Task OnGetAsync(CancellationToken ct)
     {
         var config = await mediator.Send(new GetConfig.Query(), ct);
         if (config is not null)
         {
             Input = InputModel.From(config);
+            KeyState = config.Toss;
         }
     }
 
@@ -22,6 +26,12 @@ public class IndexModel(IMediator mediator) : PageModel
     {
         if (!ModelState.IsValid)
         {
+            var config = await mediator.Send(new GetConfig.Query(), ct);
+            if (config is not null)
+            {
+                KeyState = config.Toss;
+            }
+
             return Page();
         }
 
@@ -33,7 +43,7 @@ public class IndexModel(IMediator mediator) : PageModel
 
     public sealed class InputModel
     {
-        public UpdateConfig.Request.PaymentConfigDto Payment { get; set; } = new();
+        public UpdateConfig.Request.TossConfigDto Toss { get; set; } = new();
 
         public static InputModel From(GetConfig.Response config)
         {
@@ -41,15 +51,15 @@ public class IndexModel(IMediator mediator) : PageModel
 
             return new()
             {
-                Payment = req.Payment ?? new()
+                Toss = req.Toss ?? new()
             };
         }
 
         public UpdateConfig.Command ToCommand()
         {
             return new(
-                Payment: Payment
+                Toss: Toss
             );
         }
     }
-}
+}

+ 71 - 0
Admin/Pages/Config/SignupReward/Index.cshtml

@@ -0,0 +1,71 @@
+@page
+@model Admin.Pages.Config.SignupReward.IndexModel
+
+@{
+    ViewData["Title"] = "가입 축하 보상";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
+    <partial name="_StatusMessage" />
+
+    <form name="f_admin_write" id="fAdminWrite" class="mt-2" method="post" autocomplete="off" accept-charset="UTF-8">
+
+        <!-- 가입 축하 보상 -->
+        <details open>
+            <summary class="fs-5 mb-3">가입 축하 보상</summary>
+
+            <div class="row mb-2">
+                <label asp-for="Input.SignupReward.Enabled" class="col-sm-2 col-form-label">보상 활성화</label>
+                <div class="col-sm-10 align-content-center">
+                    <div class="form-check-inline">
+                        <input asp-for="Input.SignupReward.Enabled" class="form-check-input" />
+                        <label class="form-check-label" for="Input_SignupReward_Enabled">사용</label>
+                    </div>
+                    <div class="form-text text-muted">신규 회원 최초 가입 시 1회 지급합니다. (중복 지급 방지)</div>
+                    <span asp-validation-for="Input.SignupReward.Enabled" class="text-danger"></span>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.SignupReward.CoinAmount" class="col-sm-2 col-form-label">지급 코인</label>
+                <div class="col-sm-3">
+                    <input asp-for="Input.SignupReward.CoinAmount" type="number" class="form-control" min="0" max="100000000" />
+                    <div class="form-text text-muted">무상 재화(코인). 0이면 지급하지 않습니다.</div>
+                    <span asp-validation-for="Input.SignupReward.CoinAmount" class="text-danger"></span>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.SignupReward.CashAmount" class="col-sm-2 col-form-label">지급 캐시</label>
+                <div class="col-sm-3">
+                    <input asp-for="Input.SignupReward.CashAmount" type="number" class="form-control" min="0" max="100000000" />
+                    <div class="form-text text-muted">유상 취급 재화(캐시). 0이면 지급하지 않습니다.</div>
+                    <span asp-validation-for="Input.SignupReward.CashAmount" class="text-danger"></span>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.SignupReward.ExpAmount" class="col-sm-2 col-form-label">지급 경험치</label>
+                <div class="col-sm-3">
+                    <input asp-for="Input.SignupReward.ExpAmount" type="number" class="form-control" min="0" max="100000" />
+                    <div class="form-text text-muted">가입 시 지급되는 경험치(XP). 0이면 지급하지 않습니다.</div>
+                    <span asp-validation-for="Input.SignupReward.ExpAmount" class="text-danger"></span>
+                </div>
+            </div>
+        </details>
+
+        <hr />
+
+        <div class="row">
+            <div class="col text-center p-3">
+                <button type="submit" class="btn btn-success">저장하기</button>
+            </div>
+        </div>
+
+        <br />
+    </form>
+</div>

+ 55 - 0
Admin/Pages/Config/SignupReward/Index.cshtml.cs

@@ -0,0 +1,55 @@
+using Application.Abstractions.Messaging;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Pages.Config.SignupReward;
+
+public class IndexModel(IMediator mediator) : PageModel
+{
+    [BindProperty]
+    public InputModel Input { get; set; } = new();
+
+    public async Task OnGetAsync(CancellationToken ct)
+    {
+        var config = await mediator.Send(new GetConfig.Query(), ct);
+        if (config is not null)
+        {
+            Input = InputModel.From(config);
+        }
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+    {
+        if (!ModelState.IsValid)
+        {
+            return Page();
+        }
+
+        await mediator.Send(Input.ToCommand(), ct);
+
+        TempData["SuccessMessage"] = "저장되었습니다.";
+        return RedirectToPage();
+    }
+
+    public sealed class InputModel
+    {
+        public UpdateConfig.Request.SignupRewardConfigDto SignupReward { get; set; } = new();
+
+        public static InputModel From(GetConfig.Response config)
+        {
+            var req = UpdateConfig.Request.From(config);
+
+            return new()
+            {
+                SignupReward = req.SignupReward ?? new()
+            };
+        }
+
+        public UpdateConfig.Command ToCommand()
+        {
+            return new(
+                SignupReward: SignupReward
+            );
+        }
+    }
+}

+ 0 - 166
Admin/Pages/Payments/Danal/Cancel/View.cshtml

@@ -1,166 +0,0 @@
-@page "{id:int}"
-@model Admin.Pages.Payments.Danal.Cancel.ViewModel
-@using Domain.Entities.Payments.ValueObject
-@using SharedKernel.Extensions
-@{
-    ViewData["Title"] = "다날 취소 상세";
-
-    string MethodName(PaymentMethod m) => m switch
-    {
-        PaymentMethod.Card => "신용카드",
-        PaymentMethod.VirtualAccount => "가상계좌",
-        PaymentMethod.Mobile => "휴대폰",
-        PaymentMethod.Transfer => "계좌이체",
-        PaymentMethod.NaverPay => "네이버페이",
-        PaymentMethod.KakaoPay => "카카오페이",
-        PaymentMethod.Payco => "페이코",
-        PaymentMethod.Integrated => "통합",
-        _ => "-"
-    };
-}
-
-<div class="container">
-    <h3>@ViewData["Title"]</h3>
-    <hr />
-
-    <partial name="_StatusMessage" />
-
-    @if (Model.Data is null)
-    {
-        <div class="alert alert-danger">취소 내역을 불러올 수 없습니다.</div>
-    }
-    else
-    {
-        var cancel = Model.Data;
-        var isSuccess = cancel.ResponseCode == "0000";
-
-        <h5 class="mb-3">다날 취소 정보</h5>
-        <div class="row mb-2">
-            <label class="col-sm-2 col-form-label">PK</label>
-            <div class="col-sm-10"><div class="form-control-plaintext">@cancel.ID</div></div>
-        </div>
-        <div class="row mb-2">
-            <label class="col-sm-2 col-form-label">회원</label>
-            <div class="col-sm-10"><div class="form-control-plaintext">[@cancel.MemberID] @(cancel.MemberName ?? "-") / @cancel.MemberEmail</div></div>
-        </div>
-        <div class="row mb-2">
-            <label class="col-sm-2 col-form-label">결제 수단</label>
-            <div class="col-sm-10"><div class="form-control-plaintext">@MethodName(cancel.Method)</div></div>
-        </div>
-        <div class="row mb-2">
-            <label class="col-sm-2 col-form-label">취소 유형</label>
-            <div class="col-sm-10">
-                <div class="form-control-plaintext">
-                    @if (cancel.CancelType == Domain.Entities.Payments.Danal.ValueObject.DanalCancelType.Full)
-                    {
-                        <span class="badge bg-danger">전액 환불</span>
-                    }
-                    else
-                    {
-                        <span class="badge bg-warning text-dark">부분 환불</span>
-                    }
-                </div>
-            </div>
-        </div>
-        <div class="row mb-2">
-            <label class="col-sm-2 col-form-label">거래번호</label>
-            <div class="col-sm-10"><div class="form-control-plaintext">@cancel.TransactionID</div></div>
-        </div>
-        <div class="row mb-2">
-            <label class="col-sm-2 col-form-label">주문번호</label>
-            <div class="col-sm-10"><div class="form-control-plaintext">@cancel.OrderID</div></div>
-        </div>
-        <div class="row mb-2">
-            <label class="col-sm-2 col-form-label">취소 요청 금액</label>
-            <div class="col-sm-10"><div class="form-control-plaintext">@cancel.Amount.ToString("N0") 원</div></div>
-        </div>
-        <div class="row mb-2">
-            <label class="col-sm-2 col-form-label">CPID</label>
-            <div class="col-sm-10"><div class="form-control-plaintext">@cancel.MerchantID</div></div>
-        </div>
-        <div class="row mb-2">
-            <label class="col-sm-2 col-form-label">취소 요청자</label>
-            <div class="col-sm-10"><div class="form-control-plaintext">@(cancel.CancelRequester ?? "-")</div></div>
-        </div>
-        <div class="row mb-2">
-            <label class="col-sm-2 col-form-label">취소 사유</label>
-            <div class="col-sm-10"><div class="form-control-plaintext">@(cancel.CancelReason ?? "-")</div></div>
-        </div>
-
-        <hr />
-        <h5 class="mb-3">다날 취소 응답</h5>
-
-        @if (string.IsNullOrEmpty(cancel.ResponseCode))
-        {
-            <div class="alert alert-warning">취소 응답이 기록되지 않았습니다.</div>
-        }
-        else
-        {
-            <div class="row mb-2">
-                <label class="col-sm-2 col-form-label">응답</label>
-                <div class="col-sm-10">
-                    <div class="form-control-plaintext">
-                        @if (isSuccess)
-                        {
-                            <span class="badge bg-success">성공</span>
-                        }
-                        else
-                        {
-                            <span class="badge bg-danger">실패</span>
-                        }
-                        [@cancel.ResponseCode] @cancel.ResponseMessage
-                    </div>
-                </div>
-            </div>
-            <div class="row mb-2">
-                <label class="col-sm-2 col-form-label">원거래 ID</label>
-                <div class="col-sm-10"><div class="form-control-plaintext">@(cancel.OriginalTransactionID ?? "-")</div></div>
-            </div>
-            <div class="row mb-2">
-                <label class="col-sm-2 col-form-label">취소된 금액</label>
-                <div class="col-sm-10">
-                    <div class="form-control-plaintext">@(cancel.CancelledAmount.HasValue ? cancel.CancelledAmount.Value.ToString("N0") + " 원" : "-")</div>
-                </div>
-            </div>
-            <div class="row mb-2">
-                <label class="col-sm-2 col-form-label">승인 일시</label>
-                <div class="col-sm-10">
-                    <div class="form-control-plaintext">@(string.IsNullOrEmpty(cancel.TransDate) && string.IsNullOrEmpty(cancel.TransTime) ? "-" : $"{cancel.TransDate} {cancel.TransTime}")</div>
-                </div>
-            </div>
-            <div class="row mb-2">
-                <label class="col-sm-2 col-form-label">남은 금액 (Balance)</label>
-                <div class="col-sm-10"><div class="form-control-plaintext">@(cancel.Balance.HasValue ? cancel.Balance.Value.ToString("N0") + " 원" : "-")</div></div>
-            </div>
-            <div class="row mb-2">
-                <label class="col-sm-2 col-form-label">잔여 결제 (RemainedAmount)</label>
-                <div class="col-sm-10"><div class="form-control-plaintext">@(cancel.RemainedAmount.HasValue ? cancel.RemainedAmount.Value.ToString("N0") + " 원" : "-")</div></div>
-            </div>
-            <div class="row mb-2">
-                <label class="col-sm-2 col-form-label">승인 발생 일시</label>
-                <div class="col-sm-10"><div class="form-control-plaintext">@(cancel.ApprovalDateTime ?? "-")</div></div>
-            </div>
-        }
-
-        @if (cancel.UpdatedAt is not null)
-        {
-            <div class="row mb-2">
-                <label class="col-sm-2 col-form-label">수정 일시</label>
-                <div class="col-sm-10"><div class="form-control-plaintext">@cancel.UpdatedAt.GetDateAt()</div></div>
-            </div>
-        }
-        <div class="row mb-2">
-            <label class="col-sm-2 col-form-label">등록 일시</label>
-            <div class="col-sm-10"><div class="form-control-plaintext">@cancel.CreatedAt.GetDateAt()</div></div>
-        </div>
-
-        <hr />
-        <div class="d-grid gap-2 text-center d-md-block">
-            <a href="/Payments/Danal/Cancel@(Request.QueryString)" class="btn btn-sm btn-secondary">뒤로가기</a>
-        </div>
-
-        <br />
-        <br />
-        <br />
-    }
-</div>

+ 0 - 347
Admin/Pages/Payments/Danal/Confirm/View.cshtml

@@ -1,347 +0,0 @@
-@page "{id:int}"
-@model Admin.Pages.Payments.Danal.Confirm.ViewModel
-@using Domain.Entities.Payments.ValueObject
-@using SharedKernel.Extensions
-@{
-    ViewData["Title"] = "다날 결제 상세";
-
-    string MethodName(PaymentMethod m) => m switch
-    {
-        PaymentMethod.Card => "신용카드",
-        PaymentMethod.VirtualAccount => "가상계좌",
-        PaymentMethod.Mobile => "휴대폰",
-        PaymentMethod.Transfer => "계좌이체",
-        PaymentMethod.NaverPay => "네이버페이",
-        PaymentMethod.KakaoPay => "카카오페이",
-        PaymentMethod.Payco => "페이코",
-        PaymentMethod.Integrated => "통합",
-        _ => "-"
-    };
-}
-
-<div class="container">
-    <h3>@ViewData["Title"]</h3>
-    <hr />
-
-    <partial name="_StatusMessage" />
-
-    @if (Model.Data is null)
-    {
-        <div class="alert alert-danger">결제 내역을 불러올 수 없습니다.</div>
-    }
-    else
-    {
-        var confirm = Model.Data.Confirm;
-        var cancel = Model.Data.Cancel;
-        var isCardLike = confirm.Method is PaymentMethod.Card or PaymentMethod.NaverPay or PaymentMethod.KakaoPay or PaymentMethod.Payco;
-        var isMobile = confirm.Method == PaymentMethod.Mobile;
-        var isTransfer = confirm.Method == PaymentMethod.Transfer;
-        var isVAccount = confirm.Method == PaymentMethod.VirtualAccount;
-
-        <h5 class="mb-3">다날 결제 요청</h5>
-        <div class="row mb-2">
-            <label class="col-sm-2 col-form-label">PK</label>
-            <div class="col-sm-10">
-                <div class="form-control-plaintext">@confirm.ID</div>
-            </div>
-        </div>
-        <div class="row mb-2">
-            <label class="col-sm-2 col-form-label">회원</label>
-            <div class="col-sm-10">
-                <div class="form-control-plaintext">[@confirm.MemberID] @(confirm.MemberName ?? "-") / @confirm.MemberEmail</div>
-            </div>
-        </div>
-        <div class="row mb-2">
-            <label class="col-sm-2 col-form-label">결제 수단</label>
-            <div class="col-sm-10">
-                <div class="form-control-plaintext">@MethodName(confirm.Method)</div>
-            </div>
-        </div>
-        <div class="row mb-2">
-            <label class="col-sm-2 col-form-label">거래번호</label>
-            <div class="col-sm-10">
-                <div class="form-control-plaintext">@confirm.TransactionID</div>
-            </div>
-        </div>
-        <div class="row mb-2">
-            <label class="col-sm-2 col-form-label">주문번호</label>
-            <div class="col-sm-10">
-                <div class="form-control-plaintext">@confirm.OrderID</div>
-            </div>
-        </div>
-        <div class="row mb-2">
-            <label class="col-sm-2 col-form-label">요청 금액</label>
-            <div class="col-sm-10">
-                <div class="form-control-plaintext">@confirm.Amount.ToString("N0") 원</div>
-            </div>
-        </div>
-        <div class="row mb-2">
-            <label class="col-sm-2 col-form-label">CPID</label>
-            <div class="col-sm-10">
-                <div class="form-control-plaintext">@confirm.MerchantID</div>
-            </div>
-        </div>
-        <div class="row mb-2">
-            <label class="col-sm-2 col-form-label">상품명</label>
-            <div class="col-sm-10">
-                <div class="form-control-plaintext">@(string.IsNullOrEmpty(confirm.OrderName) ? "-" : confirm.OrderName)</div>
-            </div>
-        </div>
-
-        <hr />
-        <h5 class="mb-3">다날 결제 응답</h5>
-
-        @if (string.IsNullOrEmpty(confirm.ResponseCode))
-        {
-            <div class="alert alert-warning">다날 결제 요청 후 최종 승인 응답이 기록되지 않았습니다.</div>
-        }
-        else
-        {
-            <div class="row mb-2">
-                <label class="col-sm-2 col-form-label">응답 코드</label>
-                <div class="col-sm-10">
-                    <div class="form-control-plaintext">[@confirm.ResponseCode] @confirm.ResponseMessage</div>
-                </div>
-            </div>
-            <div class="row mb-2">
-                <label class="col-sm-2 col-form-label">최종 승인 금액</label>
-                <div class="col-sm-10">
-                    <div class="form-control-plaintext">@(confirm.TotalAmount.HasValue ? confirm.TotalAmount.Value.ToString("N0") + " 원" : "-")</div>
-                </div>
-            </div>
-            <div class="row mb-2">
-                <label class="col-sm-2 col-form-label">할인 금액</label>
-                <div class="col-sm-10">
-                    <div class="form-control-plaintext">@(confirm.DiscountAmount.HasValue ? confirm.DiscountAmount.Value.ToString("N0") + " 원" : "-")</div>
-                </div>
-            </div>
-            <div class="row mb-2">
-                <label class="col-sm-2 col-form-label">사용자 이름</label>
-                <div class="col-sm-10">
-                    <div class="form-control-plaintext">@(confirm.UserName ?? "-")</div>
-                </div>
-            </div>
-            <div class="row mb-2">
-                <label class="col-sm-2 col-form-label">승인 일시</label>
-                <div class="col-sm-10">
-                    <div class="form-control-plaintext">
-                        @(string.IsNullOrEmpty(confirm.TransDate) && string.IsNullOrEmpty(confirm.TransTime) ? "-" : $"{confirm.TransDate} {confirm.TransTime}")
-                    </div>
-                </div>
-            </div>
-
-            @if (isCardLike)
-            {
-                <div class="row mb-2">
-                    <label class="col-sm-2 col-form-label">카드사</label>
-                    <div class="col-sm-10">
-                        <div class="form-control-plaintext">@(string.IsNullOrEmpty(confirm.CardName) ? "-" : $"[{confirm.CardCode}] {confirm.CardName}")</div>
-                    </div>
-                </div>
-                <div class="row mb-2">
-                    <label class="col-sm-2 col-form-label">카드번호</label>
-                    <div class="col-sm-10">
-                        <div class="form-control-plaintext">@(confirm.CardNo ?? "-")</div>
-                    </div>
-                </div>
-                <div class="row mb-2">
-                    <label class="col-sm-2 col-form-label">할부 개월</label>
-                    <div class="col-sm-10">
-                        <div class="form-control-plaintext">@(confirm.InstallmentMonths.HasValue ? $"{confirm.InstallmentMonths} 개월" : "-")</div>
-                    </div>
-                </div>
-                <div class="row mb-2">
-                    <label class="col-sm-2 col-form-label">승인번호</label>
-                    <div class="col-sm-10">
-                        <div class="form-control-plaintext">@(confirm.ApproveNo ?? "-")</div>
-                    </div>
-                </div>
-            }
-
-            @if (isMobile)
-            {
-                <div class="row mb-2">
-                    <label class="col-sm-2 col-form-label">결제 승인 일시</label>
-                    <div class="col-sm-10">
-                        <div class="form-control-plaintext">@(confirm.ApprovalDateTime ?? "-")</div>
-                    </div>
-                </div>
-                <div class="row mb-2">
-                    <label class="col-sm-2 col-form-label">정기결제 키</label>
-                    <div class="col-sm-10">
-                        <div class="form-control-plaintext">@(confirm.AuthKey ?? "-")</div>
-                    </div>
-                </div>
-            }
-
-            @if (isTransfer)
-            {
-                <div class="row mb-2">
-                    <label class="col-sm-2 col-form-label">출금계좌</label>
-                    <div class="col-sm-10">
-                        <div class="form-control-plaintext">@(confirm.AccountNumber ?? "-")</div>
-                    </div>
-                </div>
-                <div class="row mb-2">
-                    <label class="col-sm-2 col-form-label">은행 코드</label>
-                    <div class="col-sm-10">
-                        <div class="form-control-plaintext">@(confirm.BankCode ?? "-")</div>
-                    </div>
-                </div>
-                <div class="row mb-2">
-                    <label class="col-sm-2 col-form-label">사용자 ID</label>
-                    <div class="col-sm-10">
-                        <div class="form-control-plaintext">@(confirm.UserId ?? "-")</div>
-                    </div>
-                </div>
-                <div class="row mb-2">
-                    <label class="col-sm-2 col-form-label">사용자 이메일</label>
-                    <div class="col-sm-10">
-                        <div class="form-control-plaintext">@(confirm.UserEmail ?? "-")</div>
-                    </div>
-                </div>
-            }
-
-            @if (isVAccount)
-            {
-                <div class="row mb-2">
-                    <label class="col-sm-2 col-form-label">입금은행</label>
-                    <div class="col-sm-10">
-                        <div class="form-control-plaintext">@(confirm.BankName ?? "-")</div>
-                    </div>
-                </div>
-                <div class="row mb-2">
-                    <label class="col-sm-2 col-form-label">입금 마감</label>
-                    <div class="col-sm-10">
-                        <div class="form-control-plaintext">
-                            @(string.IsNullOrEmpty(confirm.ExpireDate) && string.IsNullOrEmpty(confirm.ExpireTime) ? "-" : $"{confirm.ExpireDate} {confirm.ExpireTime}")
-                        </div>
-                    </div>
-                </div>
-                <div class="row mb-2">
-                    <label class="col-sm-2 col-form-label">가상계좌 번호</label>
-                    <div class="col-sm-10">
-                        <div class="form-control-plaintext">@(confirm.VirtualAccountNumber ?? "-")</div>
-                    </div>
-                </div>
-                <div class="row mb-2">
-                    <label class="col-sm-2 col-form-label">현금영수증</label>
-                    <div class="col-sm-10">
-                        <div class="form-control-plaintext">@(confirm.UseCashReceipt == "Y" ? "사용" : confirm.UseCashReceipt == "N" ? "미사용" : "-")</div>
-                    </div>
-                </div>
-            }
-
-            @if (confirm.UpdatedAt is not null)
-            {
-                <div class="row mb-2">
-                    <label class="col-sm-2 col-form-label">수정 일시</label>
-                    <div class="col-sm-10">
-                        <div class="form-control-plaintext">@confirm.UpdatedAt.GetDateAt()</div>
-                    </div>
-                </div>
-            }
-            <div class="row mb-2">
-                <label class="col-sm-2 col-form-label">등록 일시</label>
-                <div class="col-sm-10">
-                    <div class="form-control-plaintext">@confirm.CreatedAt.GetDateAt()</div>
-                </div>
-            </div>
-
-            <hr />
-            <h5 class="mb-3">다날 결제 취소</h5>
-
-            @if (cancel is not null)
-            {
-                <div class="alert alert-success" role="alert">
-                    이미 다날 결제 취소가 완료되었습니다. (@(cancel.CancelType == Domain.Entities.Payments.Danal.ValueObject.DanalCancelType.Full ? "전액" : "부분") 환불 — @cancel.Amount.ToString("N0") 원)
-                </div>
-            }
-            else if (confirm.OrderStatus == PaymentStatus.Paid)
-            {
-                var isForcedCancel = confirm.ResponseCode != "0000";
-                if (isForcedCancel)
-                {
-                    <div class="alert alert-danger" role="alert">
-                        <strong>주의 — 다날 응답 코드가 [@(string.IsNullOrEmpty(confirm.ResponseCode) ? "응답 미수신" : confirm.ResponseCode)] 인 강제 환불입니다.</strong><br />
-                        PaymentOrder.Status 는 <code>Paid</code> 이지만 DanalConfirm 응답이 비정상입니다. 다날 PG 콘솔에서 실제 거래 상태를 사전 확인 후 진행하세요.
-                    </div>
-                }
-                <div class="alert alert-warning" role="alert">
-                    다날 결제 승인 후 전액 취소가 가능합니다. (취소 금액: <u>@confirm.Amount.ToString("N0") 원</u>)<br />
-                    취소 요청자는 주체를 구분하기 위해 사용합니다. 취소 사유는 추후 거래 상태 식별 및 대응을 위해 활용됩니다.
-                </div>
-
-                <form id="fAdminWrite" method="post" asp-page-handler="Cancel" accept-charset="utf-8" autocomplete="off" data-forced="@(isForcedCancel ? "1" : "0")">
-                    @Html.AntiForgeryToken()
-
-                    <div class="row mb-2">
-                        <label asp-for="Input.CancelRequester" class="col-sm-2 col-form-label">취소 요청자</label>
-                        <div class="col-sm-10">
-                            <input asp-for="Input.CancelRequester" class="form-control" maxlength="20" placeholder="20자 이내" required />
-                            <span asp-validation-for="Input.CancelRequester" class="text-danger"></span>
-                        </div>
-                    </div>
-                    <div class="row mb-2">
-                        <label asp-for="Input.CancelReason" class="col-sm-2 col-form-label">취소 사유</label>
-                        <div class="col-sm-10">
-                            <textarea asp-for="Input.CancelReason" class="form-control" placeholder="최대 255자" rows="3" maxlength="255" required></textarea>
-                            <span asp-validation-for="Input.CancelReason" class="text-danger"></span>
-                        </div>
-                    </div>
-                    <div class="d-grid gap-2 text-center d-md-block">
-                        <button type="submit" class="btn btn-danger">결제 취소 (전액 @confirm.Amount.ToString("N0") 원)</button>
-                    </div>
-                </form>
-            }
-            else if (confirm.OrderStatus == PaymentStatus.Cancelled)
-            {
-                <div class="alert alert-info" role="alert">
-                    이미 취소된 주문입니다. (PaymentOrder.Status = Cancelled)
-                </div>
-            }
-            else if (confirm.OrderStatus == PaymentStatus.Failed)
-            {
-                <div class="alert alert-secondary" role="alert">
-                    결제가 실패한 주문입니다. 환불할 금액이 없습니다. (PaymentOrder.Status = Failed)
-                </div>
-            }
-            else if (confirm.OrderStatus is PaymentStatus.Pending or PaymentStatus.WaitingDeposit)
-            {
-                <div class="alert alert-warning" role="alert">
-                    결제가 아직 완료되지 않았습니다. (PaymentOrder.Status = @confirm.OrderStatus) — 입금 대기 / 처리 중인 거래는 취소할 수 없습니다.
-                </div>
-            }
-            else
-            {
-                <div class="alert alert-warning" role="alert">
-                    승인되지 않은 결제는 취소할 수 없습니다.
-                </div>
-            }
-        }
-
-        <hr />
-        <div class="d-grid gap-2 text-center d-md-block">
-            <a href="/Payments/Danal/Confirm@(Request.QueryString)" class="btn btn-sm btn-secondary">뒤로가기</a>
-        </div>
-        <br />
-        <br />
-        <br />
-    }
-</div>
-
-@section Scripts {
-    <script>
-        $(document).on("submit", "#fAdminWrite", function (e) {
-            e.preventDefault();
-            var forced = $(this).data("forced") === 1 || $(this).data("forced") === "1";
-            var message = forced
-                ? "[강제 환불] 다날 응답 코드가 비정상이지만 PaymentOrder.Status 는 Paid 입니다.\n다날 PG 콘솔에서 실제 거래 상태를 확인하셨습니까?\n전액 결제 취소를 진행합니다 — 다날 API 를 실제로 호출합니다."
-                : "전액 결제 취소를 진행하시겠습니까?\n다날 API를 실제로 호출합니다.";
-            if (!confirm(message)) {
-                return false;
-            }
-            this.submit();
-        });
-    </script>
-}

+ 38 - 44
Admin/Pages/Payments/Danal/Cancel/Index.cshtml → Admin/Pages/Payments/Toss/Cancel/Index.cshtml

@@ -1,24 +1,10 @@
 @page
-@model Admin.Pages.Payments.Danal.Cancel.IndexModel
-@using Domain.Entities.Payments.ValueObject
+@model Admin.Pages.Payments.Toss.Cancel.IndexModel
 @using SharedKernel.Extensions
 @{
-    ViewData["Title"] = "다날 취소 내역";
+    ViewData["Title"] = "토스 취소 내역";
 
-    string MethodName(PaymentMethod m) => m switch
-    {
-        PaymentMethod.Card => "신용카드",
-        PaymentMethod.VirtualAccount => "가상계좌",
-        PaymentMethod.Mobile => "휴대폰",
-        PaymentMethod.Transfer => "계좌이체",
-        PaymentMethod.NaverPay => "네이버페이",
-        PaymentMethod.KakaoPay => "카카오페이",
-        PaymentMethod.Payco => "페이코",
-        PaymentMethod.Integrated => "통합",
-        _ => "-"
-    };
-
-    string BuildHref(PaymentMethod? method)
+    string BuildHref(int? state)
     {
         var qs = new List<string>();
         if (Model.Query.Search.HasValue) qs.Add($"search={Model.Query.Search}");
@@ -26,8 +12,8 @@
         if (!string.IsNullOrEmpty(Model.Query.StartAt)) qs.Add($"startAt={Model.Query.StartAt}");
         if (!string.IsNullOrEmpty(Model.Query.EndAt)) qs.Add($"endAt={Model.Query.EndAt}");
         qs.Add($"perPage={Model.Query.PerPage}");
-        if (method.HasValue) qs.Add($"payMethod={(int)method.Value}");
-        return "/Payments/Danal/Cancel" + (qs.Count > 0 ? "?" + string.Join("&", qs) : "");
+        if (state.HasValue) qs.Add($"state={state.Value}");
+        return "/Payments/Toss/Cancel" + (qs.Count > 0 ? "?" + string.Join("&", qs) : "");
     }
 }
 
@@ -43,7 +29,7 @@
                 <div class="col-auto col-md-auto">
                     <select id="search" class="form-select">
                         <option value="">검색 조건</option>
-                        <option value="1" selected="@(Model.Query.Search == 1)">거래번호</option>
+                        <option value="1" selected="@(Model.Query.Search == 1)">결제 키</option>
                         <option value="2" selected="@(Model.Query.Search == 2)">주문번호</option>
                         <option value="3" selected="@(Model.Query.Search == 3)">회원 PK</option>
                         <option value="4" selected="@(Model.Query.Search == 4)">회원 이름</option>
@@ -96,19 +82,16 @@
 
     <ul class="nav nav-tabs">
         <li class="nav-item">
-            <a class="nav-link @(Model.Query.PayMethod == null ? "active" : null)" href="@BuildHref(null)">전체(@Model.Total)</a>
-        </li>
-        <li class="nav-item">
-            <a class="nav-link @(Model.Query.PayMethod == PaymentMethod.Card ? "active" : null)" href="@BuildHref(PaymentMethod.Card)">신용카드(@Model.Counts.Card)</a>
+            <a class="nav-link @(Model.Query.State == null ? "active" : null)" href="@BuildHref(null)">전체(@Model.Total)</a>
         </li>
         <li class="nav-item">
-            <a class="nav-link @(Model.Query.PayMethod == PaymentMethod.VirtualAccount ? "active" : null)" href="@BuildHref(PaymentMethod.VirtualAccount)">가상계좌(@Model.Counts.VirtualAccount)</a>
+            <a class="nav-link @(Model.Query.State == 1 ? "active" : null)" href="@BuildHref(1)">성공(@Model.Counts.Success)</a>
         </li>
         <li class="nav-item">
-            <a class="nav-link @(Model.Query.PayMethod == PaymentMethod.Mobile ? "active" : null)" href="@BuildHref(PaymentMethod.Mobile)">휴대폰(@Model.Counts.Mobile)</a>
+            <a class="nav-link @(Model.Query.State == 2 ? "active" : null)" href="@BuildHref(2)">실패(@Model.Counts.Failed)</a>
         </li>
         <li class="nav-item">
-            <a class="nav-link @(Model.Query.PayMethod == PaymentMethod.Transfer ? "active" : null)" href="@BuildHref(PaymentMethod.Transfer)">계좌이체(@Model.Counts.Transfer)</a>
+            <a class="nav-link @(Model.Query.State == 3 ? "active" : null)" href="@BuildHref(3)">응답 대기(@Model.Counts.Pending)</a>
         </li>
     </ul>
 
@@ -122,17 +105,16 @@
                 <tr>
                     <th style="width: 5%">No.</th>
                     <th>
-                        <div class="form-check form-check-inline">
+                        <div class="form-check">
                             <input type="checkbox" id="checkedAll" class="form-check-input" value="1" form="fAdminList" />
-                            <label for="checkedAll" class="form-check-label">거래번호</label>
+                            <label for="checkedAll" class="form-check-label">결제 키</label>
                         </div>
                     </th>
                     <th style="width: 6%">상태</th>
                     <th style="width: 14%">메시지</th>
                     <th>주문번호</th>
-                    <th style="width: 8%">결제 수단</th>
+                    <th style="width: 8%">요청자</th>
                     <th>취소 금액</th>
-                    <th>남은 금액</th>
                     <th>취소 일시</th>
                     <th style="width: 10%">비고</th>
                 </tr>
@@ -141,7 +123,7 @@
                 @if (Model.List == null || Model.List.Count <= 0)
                 {
                     <tr>
-                        <td colspan="10">No Data.</td>
+                        <td colspan="9">No Data.</td>
                     </tr>
                 }
                 else
@@ -151,21 +133,33 @@
                         <tr>
                             <td>@row.Num</td>
                             <td>
-                                <div class="form-check">
+                                <div class="form-check form-check-inline">
                                     <input type="checkbox" name="ids" id="chk_@row.ID" class="form-check-input list-check-box" value="@row.ID" form="fAdminList" />
-                                    <label for="chk_@row.ID" class="form-check-label">@row.TransactionID</label>
+                                    <label for="chk_@row.ID" class="form-check-label">@row.PaymentKey</label>
                                 </div>
                             </td>
-                            <td>@row.ResponseCode</td>
-                            <td>@row.ResponseMessage</td>
+                            <td>
+                                @if (row.ResponseCode == "SUCCESS")
+                                {
+                                    <span class="text-success">성공</span>
+                                }
+                                else if (row.ResponseCode is null)
+                                {
+                                    <span class="text-muted">대기</span>
+                                }
+                                else
+                                {
+                                    <span class="text-danger">실패</span>
+                                }
+                            </td>
+                            <td>@(row.ResponseCode == "SUCCESS" ? row.Status : row.ResponseMessage)</td>
                             <td>@row.OrderID</td>
-                            <td>@MethodName(row.Method)</td>
-                            <td>@row.Amount.ToString("N0")</td>
-                            <td>@(row.Balance.HasValue ? row.Balance.Value.ToString("N0") : "-")</td>
+                            <td>@(row.CancelRequester ?? "-")</td>
+                            <td>@row.CancelAmount.ToString("N0")</td>
                             <td>@row.CreatedAt.GetDateAt()</td>
                             <td>
                                 <div class="d-xl-flex gap-2 justify-content-center d-grid">
-                                    <a class="btn btn-sm btn-outline-info" href="/Payments/Danal/Cancel/View/@(row.ID)@(Request.QueryString)">상세</a>
+                                    <a class="btn btn-sm btn-outline-info" href="/Payments/Toss/Cancel/View/@(row.ID)@(Request.QueryString)">상세</a>
                                     <environment include="Local,Development">
                                         <button type="button" class="btn btn-sm btn-outline-danger btn-row-delete" data-id="@row.ID">삭제</button>
                                     </environment>
@@ -182,7 +176,7 @@
 
     <div>
         <ul class="form-text text-muted">
-            <li>취소 내역 삭제 시 회원의 지갑 장부에 영향은 없습니다.</li>
+            <li>취소 내역 삭제 시 회원의 지갑에 영향은 없습니다.</li>
             <li><u>삭제 전 취소 정보를 자세히 확인 후 삭제하실 것을 권장합니다.</u></li>
         </ul>
     </div>
@@ -203,9 +197,9 @@
             if (startAt) queryParams.set("startAt", startAt);
             if (endAt) queryParams.set("endAt", endAt);
             queryParams.set("perPage", document.getElementById("perPage").value);
-            @if (Model.Query.PayMethod.HasValue)
+            @if (Model.Query.State.HasValue)
             {
-                <text>queryParams.set("payMethod", "@((int)Model.Query.PayMethod.Value)");</text>
+                <text>queryParams.set("state", "@Model.Query.State.Value");</text>
             }
 
             window.location.href = window.location.pathname + "?" + queryParams.toString();
@@ -242,4 +236,4 @@
             document.getElementById("fAdminList").submit();
         });
     </script>
-}
+}

+ 10 - 10
Admin/Pages/Payments/Danal/Confirm/Index.cshtml.cs → Admin/Pages/Payments/Toss/Cancel/Index.cshtml.cs

@@ -1,4 +1,3 @@
-using Domain.Entities.Payments.ValueObject;
 using SharedKernel.Helpers;
 using Application.Abstractions.Messaging;
 using Microsoft.AspNetCore.Mvc;
@@ -6,7 +5,7 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
 using System.ComponentModel;
 using System.ComponentModel.DataAnnotations;
 
-namespace Admin.Pages.Payments.Danal.Confirm;
+namespace Admin.Pages.Payments.Toss.Cancel;
 
 public class IndexModel(IMediator mediator) : PageModel
 {
@@ -37,14 +36,15 @@ public class IndexModel(IMediator mediator) : PageModel
         [DisplayName("종료일")]
         public string? EndAt { get; set; }
 
-        [DisplayName("결제 수단")]
-        public PaymentMethod? PayMethod { get; set; }
+        [DisplayName("처리 상태")]
+        [Range(1, 3, ErrorMessage = "{0}이(가) 올바르지 않습니다.")]
+        public int? State { get; set; }
     }
 
     public int Total { get; set; } = 0;
-    public SearchDanalConfirms.Response.MethodCounts Counts { get; set; } = new();
+    public SearchTossCancels.Response.StateCounts Counts { get; set; } = new();
 
-    public List<SearchDanalConfirms.Response.Row> List { get; set; } = [];
+    public List<SearchTossCancels.Response.Row> List { get; set; } = [];
 
     public Pagination? Pagination { get; set; }
 
@@ -55,14 +55,14 @@ public class IndexModel(IMediator mediator) : PageModel
             return;
         }
 
-        var result = await mediator.Send(new SearchDanalConfirms.Query(
+        var result = await mediator.Send(new SearchTossCancels.Query(
             Query.PageNum,
             Query.PerPage,
             Query.Search,
             Query.Keyword,
             Query.StartAt,
             Query.EndAt,
-            Query.PayMethod
+            Query.State
         ), ct);
 
         Total = result.Total;
@@ -80,7 +80,7 @@ public class IndexModel(IMediator mediator) : PageModel
                 throw new Exception("삭제할 항목을 선택해주세요.");
             }
 
-            await mediator.Send(new DeleteDanalConfirms.Command(ids), ct);
+            await mediator.Send(new DeleteTossCancels.Command(ids), ct);
             TempData["SuccessMessage"] = $"{ids.Length}건이 삭제되었습니다.";
         }
         catch (Exception e)
@@ -90,4 +90,4 @@ public class IndexModel(IMediator mediator) : PageModel
 
         return Redirect($"{Request.Path}{Request.QueryString}");
     }
-}
+}

+ 125 - 0
Admin/Pages/Payments/Toss/Cancel/View.cshtml

@@ -0,0 +1,125 @@
+@page "{id:int}"
+@model Admin.Pages.Payments.Toss.Cancel.ViewModel
+@using SharedKernel.Extensions
+@{
+    ViewData["Title"] = "토스 취소 상세";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    @if (Model.Data is null)
+    {
+        <div class="alert alert-danger">취소 내역을 불러올 수 없습니다.</div>
+    }
+    else
+    {
+        var cancel = Model.Data;
+
+        <h5 class="mb-3">토스 취소 요청</h5>
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">PK</label>
+            <div class="col-sm-10">
+                <div class="form-control-plaintext">@cancel.ID</div>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">회원</label>
+            <div class="col-sm-10">
+                <div class="form-control-plaintext">[@cancel.MemberID] @(cancel.MemberName ?? "-") / @cancel.MemberEmail</div>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">결제 키</label>
+            <div class="col-sm-10">
+                <div class="form-control-plaintext">@cancel.PaymentKey</div>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">주문번호</label>
+            <div class="col-sm-10">
+                <div class="form-control-plaintext">@cancel.OrderID</div>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">취소 금액</label>
+            <div class="col-sm-10">
+                <div class="form-control-plaintext">@cancel.CancelAmount.ToString("N0") 원</div>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">취소 요청자</label>
+            <div class="col-sm-10">
+                <div class="form-control-plaintext">@(cancel.CancelRequester ?? "-")</div>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">취소 사유</label>
+            <div class="col-sm-10">
+                <div class="form-control-plaintext">@(cancel.CancelReason ?? "-")</div>
+            </div>
+        </div>
+
+        <hr />
+        <h5 class="mb-3">토스 취소 응답</h5>
+
+        @if (string.IsNullOrEmpty(cancel.ResponseCode))
+        {
+            <div class="alert alert-warning">토스 취소 요청 후 최종 응답이 기록되지 않았습니다.</div>
+        }
+        else
+        {
+            <div class="row mb-2">
+                <label class="col-sm-2 col-form-label">응답 코드</label>
+                <div class="col-sm-10">
+                    <div class="form-control-plaintext">[@cancel.ResponseCode] @(cancel.ResponseMessage ?? "-")</div>
+                </div>
+            </div>
+            <div class="row mb-2">
+                <label class="col-sm-2 col-form-label">결제 상태</label>
+                <div class="col-sm-10">
+                    <div class="form-control-plaintext">@(cancel.Status ?? "-")</div>
+                </div>
+            </div>
+            <div class="row mb-2">
+                <label class="col-sm-2 col-form-label">취소 거래 키</label>
+                <div class="col-sm-10">
+                    <div class="form-control-plaintext">@(cancel.TransactionKey ?? "-")</div>
+                </div>
+            </div>
+            <div class="row mb-2">
+                <label class="col-sm-2 col-form-label">취소 일시</label>
+                <div class="col-sm-10">
+                    <div class="form-control-plaintext">@(cancel.CanceledAt is not null ? cancel.CanceledAt.GetDateAt() : "-")</div>
+                </div>
+            </div>
+        }
+
+        @if (cancel.UpdatedAt is not null)
+        {
+            <div class="row mb-2">
+                <label class="col-sm-2 col-form-label">수정 일시</label>
+                <div class="col-sm-10">
+                    <div class="form-control-plaintext">@cancel.UpdatedAt.GetDateAt()</div>
+                </div>
+            </div>
+        }
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">등록 일시</label>
+            <div class="col-sm-10">
+                <div class="form-control-plaintext">@cancel.CreatedAt.GetDateAt()</div>
+            </div>
+        </div>
+
+        <hr />
+        <div class="d-grid gap-2 text-center d-md-block">
+            <a href="/Payments/Toss/Cancel@(Request.QueryString)" class="btn btn-sm btn-secondary">뒤로가기</a>
+        </div>
+        <br />
+        <br />
+        <br />
+    }
+</div>

+ 5 - 5
Admin/Pages/Payments/Danal/Cancel/View.cshtml.cs → Admin/Pages/Payments/Toss/Cancel/View.cshtml.cs

@@ -2,26 +2,26 @@ using Application.Abstractions.Messaging;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.Mvc.RazorPages;
 
-namespace Admin.Pages.Payments.Danal.Cancel;
+namespace Admin.Pages.Payments.Toss.Cancel;
 
 public class ViewModel(IMediator mediator) : PageModel
 {
     [BindProperty(SupportsGet = true)]
     public int ID { get; set; }
 
-    public GetDanalCancel.Response? Data { get; set; }
+    public GetTossCancel.Response? Data { get; set; }
 
     public async Task<IActionResult> OnGetAsync(CancellationToken ct)
     {
         try
         {
-            Data = await mediator.Send(new GetDanalCancel.Query(ID), ct);
+            Data = await mediator.Send(new GetTossCancel.Query(ID), ct);
             return Page();
         }
         catch (Exception e)
         {
             TempData["ErrorMessages"] = e.Message;
-            return RedirectToPage("/Payments/Danal/Cancel/Index");
+            return RedirectToPage("/Payments/Toss/Cancel/Index");
         }
     }
-}
+}

+ 32 - 44
Admin/Pages/Payments/Danal/Confirm/Index.cshtml → Admin/Pages/Payments/Toss/Confirm/Index.cshtml

@@ -1,24 +1,22 @@
 @page
-@model Admin.Pages.Payments.Danal.Confirm.IndexModel
-@using Domain.Entities.Payments.ValueObject
+@model Admin.Pages.Payments.Toss.Confirm.IndexModel
 @using SharedKernel.Extensions
 @{
-    ViewData["Title"] = "다날 결제 내역";
+    ViewData["Title"] = "토스 결제 내역";
 
-    string MethodName(PaymentMethod m) => m switch
+    string StatusName(string? s) => s switch
     {
-        PaymentMethod.Card => "신용카드",
-        PaymentMethod.VirtualAccount => "가상계좌",
-        PaymentMethod.Mobile => "휴대폰",
-        PaymentMethod.Transfer => "계좌이체",
-        PaymentMethod.NaverPay => "네이버페이",
-        PaymentMethod.KakaoPay => "카카오페이",
-        PaymentMethod.Payco => "페이코",
-        PaymentMethod.Integrated => "통합",
-        _ => "-"
+        "DONE" => "결제 완료",
+        "WAITING_FOR_DEPOSIT" => "입금 대기",
+        "CANCELED" => "취소",
+        "PARTIAL_CANCELED" => "부분 취소",
+        "ABORTED" => "승인 실패",
+        "EXPIRED" => "만료",
+        null => "-",
+        _ => s
     };
 
-    string BuildHref(PaymentMethod? method)
+    string BuildHref(string? status)
     {
         var qs = new List<string>();
         if (Model.Query.Search.HasValue) qs.Add($"search={Model.Query.Search}");
@@ -26,8 +24,8 @@
         if (!string.IsNullOrEmpty(Model.Query.StartAt)) qs.Add($"startAt={Model.Query.StartAt}");
         if (!string.IsNullOrEmpty(Model.Query.EndAt)) qs.Add($"endAt={Model.Query.EndAt}");
         qs.Add($"perPage={Model.Query.PerPage}");
-        if (method.HasValue) qs.Add($"payMethod={(int)method.Value}");
-        return "/Payments/Danal/Confirm" + (qs.Count > 0 ? "?" + string.Join("&", qs) : "");
+        if (!string.IsNullOrEmpty(status)) qs.Add($"status={status}");
+        return "/Payments/Toss/Confirm" + (qs.Count > 0 ? "?" + string.Join("&", qs) : "");
     }
 }
 
@@ -43,7 +41,7 @@
                 <div class="col-auto col-md-auto">
                     <select id="search" class="form-select">
                         <option value="">검색 조건</option>
-                        <option value="1" selected="@(Model.Query.Search == 1)">거래번호</option>
+                        <option value="1" selected="@(Model.Query.Search == 1)">결제 키</option>
                         <option value="2" selected="@(Model.Query.Search == 2)">주문번호</option>
                         <option value="3" selected="@(Model.Query.Search == 3)">회원 PK</option>
                         <option value="4" selected="@(Model.Query.Search == 4)">회원 이름</option>
@@ -96,19 +94,16 @@
 
     <ul class="nav nav-tabs">
         <li class="nav-item">
-            <a class="nav-link @(Model.Query.PayMethod == null ? "active" : null)" href="@BuildHref(null)">전체(@Model.Total)</a>
+            <a class="nav-link @(string.IsNullOrEmpty(Model.Query.Status) ? "active" : null)" href="@BuildHref(null)">전체(@Model.Total)</a>
         </li>
         <li class="nav-item">
-            <a class="nav-link @(Model.Query.PayMethod == PaymentMethod.Card ? "active" : null)" href="@BuildHref(PaymentMethod.Card)">신용카드(@Model.Counts.Card)</a>
+            <a class="nav-link @(Model.Query.Status == "DONE" ? "active" : null)" href="@BuildHref("DONE")">결제 완료(@Model.Counts.Done)</a>
         </li>
         <li class="nav-item">
-            <a class="nav-link @(Model.Query.PayMethod == PaymentMethod.VirtualAccount ? "active" : null)" href="@BuildHref(PaymentMethod.VirtualAccount)">가상계좌(@Model.Counts.VirtualAccount)</a>
+            <a class="nav-link @(Model.Query.Status == "WAITING_FOR_DEPOSIT" ? "active" : null)" href="@BuildHref("WAITING_FOR_DEPOSIT")">입금 대기(@Model.Counts.WaitingDeposit)</a>
         </li>
         <li class="nav-item">
-            <a class="nav-link @(Model.Query.PayMethod == PaymentMethod.Mobile ? "active" : null)" href="@BuildHref(PaymentMethod.Mobile)">휴대폰(@Model.Counts.Mobile)</a>
-        </li>
-        <li class="nav-item">
-            <a class="nav-link @(Model.Query.PayMethod == PaymentMethod.Transfer ? "active" : null)" href="@BuildHref(PaymentMethod.Transfer)">계좌이체(@Model.Counts.Transfer)</a>
+            <a class="nav-link @(Model.Query.Status == "CANCELED" ? "active" : null)" href="@BuildHref("CANCELED")">취소(@Model.Counts.Canceled)</a>
         </li>
     </ul>
 
@@ -124,10 +119,10 @@
                     <th>
                         <div class="form-check">
                             <input type="checkbox" id="checkedAll" class="form-check-input" value="1" form="fAdminList" />
-                            <label for="checkedAll" class="form-check-label">거래번호</label>
+                            <label for="checkedAll" class="form-check-label">결제 키</label>
                         </div>
                     </th>
-                    <th style="width: 6%">상태</th>
+                    <th style="width: 8%">상태</th>
                     <th style="width: 14%">메시지</th>
                     <th>주문번호</th>
                     <th style="width: 8%">결제 수단</th>
@@ -152,31 +147,24 @@
                             <td>
                                 <div class="form-check form-check-inline">
                                     <input type="checkbox" name="ids" id="chk_@row.ID" class="form-check-input list-check-box" value="@row.ID" form="fAdminList" />
-                                    <label for="chk_@row.ID" class="form-check-label">@row.TransactionID</label>
+                                    <label for="chk_@row.ID" class="form-check-label">@row.PaymentKey</label>
                                 </div>
-                                @if (row.CancelType.HasValue)
+                                @if (row.CancelledAmount.HasValue)
                                 {
                                     <div class="mt-1">
-                                        @if (row.CancelType == Domain.Entities.Payments.Danal.ValueObject.DanalCancelType.Partial)
-                                        {
-                                            <span class="text-warning">[부분환불]</span>
-                                        }
-                                        else
-                                        {
-                                            <span class="text-danger">[전액환불]</span>
-                                        }
+                                        <span class="text-danger">[환불 @row.CancelledAmount.Value.ToString("N0") 원]</span>
                                     </div>
                                 }
                             </td>
-                            <td>@row.ResponseCode</td>
-                            <td>@row.ResponseMessage</td>
+                            <td>@StatusName(row.Status)</td>
+                            <td>@(string.IsNullOrEmpty(row.ResponseMessage) ? row.ResponseCode : row.ResponseMessage)</td>
                             <td>@row.OrderID</td>
-                            <td>@MethodName(row.Method)</td>
+                            <td>@(row.Method ?? "-")</td>
                             <td>@((row.TotalAmount ?? row.Amount).ToString("N0"))</td>
                             <td>@row.CreatedAt.GetDateAt()</td>
                             <td>
                                 <div class="d-xl-flex gap-2 justify-content-center d-grid">
-                                    <a class="btn btn-sm btn-outline-info" href="/Payments/Danal/Confirm/View/@(row.ID)@(Request.QueryString)">상세</a>
+                                    <a class="btn btn-sm btn-outline-info" href="/Payments/Toss/Confirm/View/@(row.ID)@(Request.QueryString)">상세</a>
                                     <environment include="Local,Development">
                                         <button type="button" class="btn btn-sm btn-outline-danger btn-row-delete" data-id="@row.ID">삭제</button>
                                     </environment>
@@ -193,7 +181,7 @@
 
     <div>
         <ul class="form-text text-muted">
-            <li>결제 내역 삭제 시 회원의 지갑에 영향은 없습니다. (다날 결제 취소는 상세 페이지에서 별도로 진행)</li>
+            <li>결제 내역 삭제 시 회원의 지갑에 영향은 없습니다. (토스 결제 취소는 상세 페이지에서 별도로 진행)</li>
             <li>전액 취소만 지원합니다.</li>
             <li><u>삭제 전 결제 정보를 자세히 확인 후 삭제하실 것을 권장합니다.</u></li>
         </ul>
@@ -215,9 +203,9 @@
             if (startAt) queryParams.set("startAt", startAt);
             if (endAt) queryParams.set("endAt", endAt);
             queryParams.set("perPage", document.getElementById("perPage").value);
-            @if (Model.Query.PayMethod.HasValue)
+            @if (!string.IsNullOrEmpty(Model.Query.Status))
             {
-                <text>queryParams.set("payMethod", "@((int)Model.Query.PayMethod.Value)");</text>
+                <text>queryParams.set("status", "@Model.Query.Status");</text>
             }
 
             window.location.href = window.location.pathname + "?" + queryParams.toString();
@@ -254,4 +242,4 @@
             document.getElementById("fAdminList").submit();
         });
     </script>
-}
+}

+ 10 - 10
Admin/Pages/Payments/Danal/Cancel/Index.cshtml.cs → Admin/Pages/Payments/Toss/Confirm/Index.cshtml.cs

@@ -1,4 +1,3 @@
-using Domain.Entities.Payments.ValueObject;
 using SharedKernel.Helpers;
 using Application.Abstractions.Messaging;
 using Microsoft.AspNetCore.Mvc;
@@ -6,7 +5,7 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
 using System.ComponentModel;
 using System.ComponentModel.DataAnnotations;
 
-namespace Admin.Pages.Payments.Danal.Cancel;
+namespace Admin.Pages.Payments.Toss.Confirm;
 
 public class IndexModel(IMediator mediator) : PageModel
 {
@@ -37,14 +36,15 @@ public class IndexModel(IMediator mediator) : PageModel
         [DisplayName("종료일")]
         public string? EndAt { get; set; }
 
-        [DisplayName("결제 수단")]
-        public PaymentMethod? PayMethod { get; set; }
+        [DisplayName("결제 상태")]
+        [MaxLength(30)]
+        public string? Status { get; set; }
     }
 
     public int Total { get; set; } = 0;
-    public SearchDanalCancels.Response.MethodCounts Counts { get; set; } = new();
+    public SearchTossConfirms.Response.StatusCounts Counts { get; set; } = new();
 
-    public List<SearchDanalCancels.Response.Row> List { get; set; } = [];
+    public List<SearchTossConfirms.Response.Row> List { get; set; } = [];
 
     public Pagination? Pagination { get; set; }
 
@@ -55,14 +55,14 @@ public class IndexModel(IMediator mediator) : PageModel
             return;
         }
 
-        var result = await mediator.Send(new SearchDanalCancels.Query(
+        var result = await mediator.Send(new SearchTossConfirms.Query(
             Query.PageNum,
             Query.PerPage,
             Query.Search,
             Query.Keyword,
             Query.StartAt,
             Query.EndAt,
-            Query.PayMethod
+            Query.Status
         ), ct);
 
         Total = result.Total;
@@ -80,7 +80,7 @@ public class IndexModel(IMediator mediator) : PageModel
                 throw new Exception("삭제할 항목을 선택해주세요.");
             }
 
-            await mediator.Send(new DeleteDanalCancels.Command(ids), ct);
+            await mediator.Send(new DeleteTossConfirms.Command(ids), ct);
             TempData["SuccessMessage"] = $"{ids.Length}건이 삭제되었습니다.";
         }
         catch (Exception e)
@@ -90,4 +90,4 @@ public class IndexModel(IMediator mediator) : PageModel
 
         return Redirect($"{Request.Path}{Request.QueryString}");
     }
-}
+}

+ 279 - 0
Admin/Pages/Payments/Toss/Confirm/View.cshtml

@@ -0,0 +1,279 @@
+@page "{id:int}"
+@model Admin.Pages.Payments.Toss.Confirm.ViewModel
+@using Domain.Entities.Payments.ValueObject
+@using SharedKernel.Extensions
+@{
+    ViewData["Title"] = "토스 결제 상세";
+
+    string StatusName(string? s) => s switch
+    {
+        "DONE" => "결제 완료",
+        "WAITING_FOR_DEPOSIT" => "입금 대기",
+        "CANCELED" => "취소",
+        "PARTIAL_CANCELED" => "부분 취소",
+        "ABORTED" => "승인 실패",
+        "EXPIRED" => "만료",
+        null => "-",
+        _ => s
+    };
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    @if (Model.Data is null)
+    {
+        <div class="alert alert-danger">결제 내역을 불러올 수 없습니다.</div>
+    }
+    else
+    {
+        var confirm = Model.Data.Confirm;
+        var cancel = Model.Data.Cancel;
+        var isVAccount = confirm.Status == "WAITING_FOR_DEPOSIT" || !string.IsNullOrEmpty(confirm.VirtualAccountNumber);
+
+        <h5 class="mb-3">토스 결제 요청</h5>
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">PK</label>
+            <div class="col-sm-10">
+                <div class="form-control-plaintext">@confirm.ID</div>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">회원</label>
+            <div class="col-sm-10">
+                <div class="form-control-plaintext">[@confirm.MemberID] @(confirm.MemberName ?? "-") / @confirm.MemberEmail</div>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">결제 키</label>
+            <div class="col-sm-10">
+                <div class="form-control-plaintext">@confirm.PaymentKey</div>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">주문번호</label>
+            <div class="col-sm-10">
+                <div class="form-control-plaintext">@confirm.OrderID</div>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">요청 금액</label>
+            <div class="col-sm-10">
+                <div class="form-control-plaintext">@confirm.Amount.ToString("N0") 원</div>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">가맹점 구분</label>
+            <div class="col-sm-10">
+                <div class="form-control-plaintext">@confirm.MerchantID</div>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label class="col-sm-2 col-form-label">상품명</label>
+            <div class="col-sm-10">
+                <div class="form-control-plaintext">@(string.IsNullOrEmpty(confirm.OrderName) ? "-" : confirm.OrderName)</div>
+            </div>
+        </div>
+
+        <hr />
+        <h5 class="mb-3">토스 결제 응답</h5>
+
+        @if (string.IsNullOrEmpty(confirm.ResponseCode))
+        {
+            <div class="alert alert-warning">토스 승인 요청 후 최종 응답이 기록되지 않았습니다. (통신 불명 — 자동 대사 대상)</div>
+        }
+        else
+        {
+            @if (confirm.ResponseCode != "SUCCESS")
+            {
+                <div class="row mb-2">
+                    <label class="col-sm-2 col-form-label">에러 코드</label>
+                    <div class="col-sm-10">
+                        <div class="form-control-plaintext text-danger">[@confirm.ResponseCode] @confirm.ResponseMessage</div>
+                    </div>
+                </div>
+            }
+            <div class="row mb-2">
+                <label class="col-sm-2 col-form-label">결제 상태</label>
+                <div class="col-sm-10">
+                    <div class="form-control-plaintext">@StatusName(confirm.Status) (@(confirm.Status ?? "-"))</div>
+                </div>
+            </div>
+            <div class="row mb-2">
+                <label class="col-sm-2 col-form-label">결제 수단</label>
+                <div class="col-sm-10">
+                    <div class="form-control-plaintext">@(confirm.Method ?? "-")</div>
+                </div>
+            </div>
+            <div class="row mb-2">
+                <label class="col-sm-2 col-form-label">최종 승인 금액</label>
+                <div class="col-sm-10">
+                    <div class="form-control-plaintext">@(confirm.TotalAmount.HasValue ? confirm.TotalAmount.Value.ToString("N0") + " 원" : "-")</div>
+                </div>
+            </div>
+            <div class="row mb-2">
+                <label class="col-sm-2 col-form-label">승인 일시</label>
+                <div class="col-sm-10">
+                    <div class="form-control-plaintext">@(confirm.ApprovedAt is not null ? confirm.ApprovedAt.GetDateAt() : "-")</div>
+                </div>
+            </div>
+            <div class="row mb-2">
+                <label class="col-sm-2 col-form-label">영수증</label>
+                <div class="col-sm-10">
+                    <div class="form-control-plaintext">
+                        @if (string.IsNullOrEmpty(confirm.ReceiptUrl))
+                        {
+                            <text>-</text>
+                        }
+                        else
+                        {
+                            <a href="@confirm.ReceiptUrl" target="_blank" rel="noopener">영수증 보기</a>
+                        }
+                    </div>
+                </div>
+            </div>
+
+            @if (isVAccount)
+            {
+                <div class="row mb-2">
+                    <label class="col-sm-2 col-form-label">가상계좌 번호</label>
+                    <div class="col-sm-10">
+                        <div class="form-control-plaintext">@(confirm.VirtualAccountNumber ?? "-")</div>
+                    </div>
+                </div>
+                <div class="row mb-2">
+                    <label class="col-sm-2 col-form-label">입금 은행</label>
+                    <div class="col-sm-10">
+                        <div class="form-control-plaintext">@(confirm.VirtualAccountBank ?? "-")</div>
+                    </div>
+                </div>
+                <div class="row mb-2">
+                    <label class="col-sm-2 col-form-label">예금주</label>
+                    <div class="col-sm-10">
+                        <div class="form-control-plaintext">@(confirm.VirtualAccountHolder ?? "-")</div>
+                    </div>
+                </div>
+                <div class="row mb-2">
+                    <label class="col-sm-2 col-form-label">입금 마감</label>
+                    <div class="col-sm-10">
+                        <div class="form-control-plaintext">@(confirm.VirtualAccountExpireAt is not null ? confirm.VirtualAccountExpireAt.GetDateAt() : "-")</div>
+                    </div>
+                </div>
+            }
+
+            @if (confirm.UpdatedAt is not null)
+            {
+                <div class="row mb-2">
+                    <label class="col-sm-2 col-form-label">수정 일시</label>
+                    <div class="col-sm-10">
+                        <div class="form-control-plaintext">@confirm.UpdatedAt.GetDateAt()</div>
+                    </div>
+                </div>
+            }
+            <div class="row mb-2">
+                <label class="col-sm-2 col-form-label">등록 일시</label>
+                <div class="col-sm-10">
+                    <div class="form-control-plaintext">@confirm.CreatedAt.GetDateAt()</div>
+                </div>
+            </div>
+        }
+
+        <hr />
+        <h5 class="mb-3">토스 결제 취소</h5>
+
+        @if (cancel is not null)
+        {
+            <div class="alert alert-success" role="alert">
+                이미 토스 결제 취소가 완료되었습니다. (@cancel.CancelAmount.ToString("N0") 원 — 요청자: @(cancel.CancelRequester ?? "-"), 사유: @(cancel.CancelReason ?? "-"))
+            </div>
+        }
+        else if (confirm.OrderStatus == PaymentStatus.Paid)
+        {
+            var isForcedCancel = confirm.ResponseCode != "SUCCESS";
+            if (isForcedCancel)
+            {
+                <div class="alert alert-danger" role="alert">
+                    <strong>주의 — 토스 응답 코드가 [@(string.IsNullOrEmpty(confirm.ResponseCode) ? "응답 미수신" : confirm.ResponseCode)] 인 강제 환불입니다.</strong><br />
+                    PaymentOrder.Status 는 <code>Paid</code> 이지만 TossConfirm 응답이 비정상입니다. 토스 상점관리자에서 실제 거래 상태를 사전 확인 후 진행하세요.
+                </div>
+            }
+            <div class="alert alert-warning" role="alert">
+                토스 결제 승인 후 전액 취소가 가능합니다. (취소 금액: <u>@confirm.Amount.ToString("N0") 원</u>)<br />
+                취소 요청자는 주체를 구분하기 위해 사용합니다. 취소 사유는 토스 취소 API 의 cancelReason 으로 전송됩니다.
+            </div>
+
+            <form id="fAdminWrite" method="post" asp-page-handler="Cancel" accept-charset="utf-8" autocomplete="off" data-forced="@(isForcedCancel ? "1" : "0")">
+                @Html.AntiForgeryToken()
+
+                <div class="row mb-2">
+                    <label asp-for="Input.CancelRequester" class="col-sm-2 col-form-label">취소 요청자</label>
+                    <div class="col-sm-10">
+                        <input asp-for="Input.CancelRequester" class="form-control" maxlength="20" placeholder="20자 이내" required />
+                        <span asp-validation-for="Input.CancelRequester" class="text-danger"></span>
+                    </div>
+                </div>
+                <div class="row mb-2">
+                    <label asp-for="Input.CancelReason" class="col-sm-2 col-form-label">취소 사유</label>
+                    <div class="col-sm-10">
+                        <textarea asp-for="Input.CancelReason" class="form-control" placeholder="최대 200자" rows="3" maxlength="200" required></textarea>
+                        <span asp-validation-for="Input.CancelReason" class="text-danger"></span>
+                    </div>
+                </div>
+                <div class="d-grid gap-2 text-center d-md-block">
+                    <button type="submit" class="btn btn-danger">결제 취소 (전액 @confirm.Amount.ToString("N0") 원)</button>
+                </div>
+            </form>
+        }
+        else if (confirm.OrderStatus == PaymentStatus.Cancelled)
+        {
+            <div class="alert alert-info" role="alert">
+                이미 취소된 주문입니다. (PaymentOrder.Status = Cancelled)
+            </div>
+        }
+        else if (confirm.OrderStatus == PaymentStatus.Failed)
+        {
+            <div class="alert alert-secondary" role="alert">
+                결제가 실패한 주문입니다. 환불할 금액이 없습니다. (PaymentOrder.Status = Failed)
+            </div>
+        }
+        else if (confirm.OrderStatus is PaymentStatus.Pending or PaymentStatus.WaitingDeposit or PaymentStatus.NeedsReconciliation)
+        {
+            <div class="alert alert-warning" role="alert">
+                결제가 아직 완료되지 않았습니다. (PaymentOrder.Status = @confirm.OrderStatus) — 입금 대기 / 대사 중인 거래는 취소할 수 없습니다.
+            </div>
+        }
+        else
+        {
+            <div class="alert alert-warning" role="alert">
+                승인되지 않은 결제는 취소할 수 없습니다.
+            </div>
+        }
+
+        <hr />
+        <div class="d-grid gap-2 text-center d-md-block">
+            <a href="/Payments/Toss/Confirm@(Request.QueryString)" class="btn btn-sm btn-secondary">뒤로가기</a>
+        </div>
+        <br />
+        <br />
+        <br />
+    }
+</div>
+
+@section Scripts {
+    <script>
+        $(document).on("submit", "#fAdminWrite", function (e) {
+            e.preventDefault();
+            var forced = $(this).data("forced") === 1 || $(this).data("forced") === "1";
+            var message = forced
+                ? "[강제 환불] 토스 응답 코드가 비정상이지만 PaymentOrder.Status 는 Paid 입니다.\n토스 상점관리자에서 실제 거래 상태를 확인하셨습니까?\n전액 결제 취소를 진행합니다 — 토스 API 를 실제로 호출합니다."
+                : "전액 결제 취소를 진행하시겠습니까?\n토스 API를 실제로 호출합니다.";
+            if (!confirm(message)) {
+                return false;
+            }
+            this.submit();
+        });
+    </script>
+}

+ 9 - 9
Admin/Pages/Payments/Danal/Confirm/View.cshtml.cs → Admin/Pages/Payments/Toss/Confirm/View.cshtml.cs

@@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.Mvc.RazorPages;
 using System.ComponentModel.DataAnnotations;
 
-namespace Admin.Pages.Payments.Danal.Confirm;
+namespace Admin.Pages.Payments.Toss.Confirm;
 
 public class ViewModel(IMediator mediator) : PageModel
 {
@@ -20,29 +20,29 @@ public class ViewModel(IMediator mediator) : PageModel
         public string? CancelRequester { get; set; }
 
         [Display(Name = "취소 사유")]
-        [MaxLength(255)]
+        [MaxLength(200)]
         public string? CancelReason { get; set; }
     }
 
-    public GetDanalConfirm.Response? Data { get; set; }
+    public GetTossConfirm.Response? Data { get; set; }
 
     public async Task<IActionResult> OnGetAsync(CancellationToken ct)
     {
         try
         {
-            Data = await mediator.Send(new GetDanalConfirm.Query(ID), ct);
+            Data = await mediator.Send(new GetTossConfirm.Query(ID), ct);
             return Page();
         }
         catch (Exception e)
         {
             TempData["ErrorMessages"] = e.Message;
-            return RedirectToPage("/Payments/Danal/Confirm/Index");
+            return RedirectToPage("/Payments/Toss/Confirm/Index");
         }
     }
 
     public async Task<IActionResult> OnPostCancelAsync(CancellationToken ct)
     {
-        var result = await mediator.Send(new CancelDanalConfirm.Command(
+        var result = await mediator.Send(new CancelTossConfirm.Command(
             ID,
             Input.CancelRequester,
             Input.CancelReason
@@ -50,13 +50,13 @@ public class ViewModel(IMediator mediator) : PageModel
 
         if (result.IsSuccess)
         {
-            TempData["SuccessMessage"] = "다날 결제 취소가 완료되었습니다.";
+            TempData["SuccessMessage"] = "토스 결제 취소가 완료되었습니다.";
         }
         else
         {
             TempData["ErrorMessages"] = result.Error?.Description ?? "취소에 실패했습니다.";
         }
 
-        return Redirect($"/Payments/Danal/Confirm/View/{ID}{Request.QueryString}");
+        return Redirect($"/Payments/Toss/Confirm/View/{ID}{Request.QueryString}");
     }
-}
+}

+ 41 - 70
Admin/Pages/Payments/Danal/Error/Index.cshtml → Admin/Pages/Payments/Toss/Error/Index.cshtml

@@ -1,51 +1,29 @@
 @page
-@model Admin.Pages.Payments.Danal.Error.IndexModel
-@using Domain.Entities.Payments.Danal.ValueObject
+@model Admin.Pages.Payments.Toss.Error.IndexModel
+@using Domain.Entities.Payments.Toss.ValueObject
 @using SharedKernel.Extensions
 @{
-    ViewData["Title"] = "다날 오류 내역";
+    ViewData["Title"] = "토스 오류 내역";
 
-    string TypeName(DanalLogType t) => t switch
+    string LogTypeName(TossLogType t) => t switch
     {
-        DanalLogType.Failed => "실패",
-        DanalLogType.Error => "Error",
-        DanalLogType.Webhook => "Webhook",
+        TossLogType.Failed => "실패",
+        TossLogType.Error => "오류",
+        TossLogType.Webhook => "웹훅",
+        TossLogType.Reconcile => "대사",
         _ => "-"
     };
 
-    string TypeBadge(DanalLogType t) => t switch
-    {
-        DanalLogType.Failed => "bg-warning text-dark",
-        DanalLogType.Error => "bg-danger",
-        DanalLogType.Webhook => "bg-info text-dark",
-        _ => "bg-secondary"
-    };
-
-    string BuildHref(DanalLogType? type)
+    string BuildHref(TossLogType? logType)
     {
         var qs = new List<string>();
-        if (Model.Query.Search.HasValue) {
-            qs.Add($"search={Model.Query.Search}");
-        }
-        if (!string.IsNullOrEmpty(Model.Query.Keyword)) {
-            qs.Add($"keyword={Model.Query.Keyword}");
-        }
-
-        if (!string.IsNullOrEmpty(Model.Query.StartAt)) {
-            qs.Add($"startAt={Model.Query.StartAt}");
-        }
-
-        if (!string.IsNullOrEmpty(Model.Query.EndAt)) {
-            qs.Add($"endAt={Model.Query.EndAt}");
-        }
-
+        if (Model.Query.Search.HasValue) qs.Add($"search={Model.Query.Search}");
+        if (!string.IsNullOrEmpty(Model.Query.Keyword)) qs.Add($"keyword={Model.Query.Keyword}");
+        if (!string.IsNullOrEmpty(Model.Query.StartAt)) qs.Add($"startAt={Model.Query.StartAt}");
+        if (!string.IsNullOrEmpty(Model.Query.EndAt)) qs.Add($"endAt={Model.Query.EndAt}");
         qs.Add($"perPage={Model.Query.PerPage}");
-
-        if (type.HasValue) {
-            qs.Add($"logType={(int)type.Value}");
-        }
-
-        return "/Payments/Danal/Error" + (qs.Count > 0 ? "?" + string.Join("&", qs) : "");
+        if (logType.HasValue) qs.Add($"logType={(int)logType.Value}");
+        return "/Payments/Toss/Error" + (qs.Count > 0 ? "?" + string.Join("&", qs) : "");
     }
 }
 
@@ -61,7 +39,7 @@
                 <div class="col-auto col-md-auto">
                     <select id="search" class="form-select">
                         <option value="">검색 조건</option>
-                        <option value="1" selected="@(Model.Query.Search == 1)">거래번호</option>
+                        <option value="1" selected="@(Model.Query.Search == 1)">결제 키</option>
                         <option value="2" selected="@(Model.Query.Search == 2)">주문번호</option>
                         <option value="3" selected="@(Model.Query.Search == 3)">회원 PK</option>
                         <option value="4" selected="@(Model.Query.Search == 4)">회원 이름</option>
@@ -117,13 +95,16 @@
             <a class="nav-link @(Model.Query.LogType == null ? "active" : null)" href="@BuildHref(null)">전체(@Model.Total)</a>
         </li>
         <li class="nav-item">
-            <a class="nav-link @(Model.Query.LogType == DanalLogType.Failed ? "active" : null)" href="@BuildHref(DanalLogType.Failed)">실패(@Model.Counts.Failed)</a>
+            <a class="nav-link @(Model.Query.LogType == TossLogType.Failed ? "active" : null)" href="@BuildHref(TossLogType.Failed)">실패(@Model.Counts.Failed)</a>
         </li>
         <li class="nav-item">
-            <a class="nav-link @(Model.Query.LogType == DanalLogType.Error ? "active" : null)" href="@BuildHref(DanalLogType.Error)">Error(@Model.Counts.Error)</a>
+            <a class="nav-link @(Model.Query.LogType == TossLogType.Error ? "active" : null)" href="@BuildHref(TossLogType.Error)">오류(@Model.Counts.Error)</a>
         </li>
         <li class="nav-item">
-            <a class="nav-link @(Model.Query.LogType == DanalLogType.Webhook ? "active" : null)" href="@BuildHref(DanalLogType.Webhook)">Webhook(@Model.Counts.Webhook)</a>
+            <a class="nav-link @(Model.Query.LogType == TossLogType.Webhook ? "active" : null)" href="@BuildHref(TossLogType.Webhook)">웹훅(@Model.Counts.Webhook)</a>
+        </li>
+        <li class="nav-item">
+            <a class="nav-link @(Model.Query.LogType == TossLogType.Reconcile ? "active" : null)" href="@BuildHref(TossLogType.Reconcile)">대사(@Model.Counts.Reconcile)</a>
         </li>
     </ul>
 
@@ -135,21 +116,19 @@
         <table class="table table-bordered table-hover mt-3">
             <thead>
                 <tr>
-                    <th style="width: 5%">
+                    <th style="width: 5%">No.</th>
+                    <th>
                         <div class="form-check">
                             <input type="checkbox" id="checkedAll" class="form-check-input" value="1" form="fAdminList" />
-                            <label for="checkedAll" class="form-check-label">No.</label>
+                            <label for="checkedAll" class="form-check-label">코드</label>
                         </div>
                     </th>
-                    <th>거래번호</th>
-                    <th style="width: 8%">타입</th>
-                    <th style="width: 10%">상태</th>
+                    <th style="width: 6%">타입</th>
                     <th>메시지</th>
+                    <th>결제 키</th>
                     <th>주문번호</th>
+                    <th style="width: 8%">회원 PK</th>
                     <th>등록 일시</th>
-                    <environment include="Local,Development">
-                        <th style="width: 10%">비고</th>
-                    </environment>
                 </tr>
             </thead>
             <tbody>
@@ -164,25 +143,19 @@
                     @foreach (var row in Model.List)
                     {
                         <tr>
+                            <td>@row.Num</td>
                             <td>
                                 <div class="form-check form-check-inline">
                                     <input type="checkbox" name="ids" id="chk_@row.ID" class="form-check-input list-check-box" value="@row.ID" form="fAdminList" />
-                                    <label for="chk_@row.ID" class="form-check-label">@row.Num</label>
+                                    <label for="chk_@row.ID" class="form-check-label">@row.Code</label>
                                 </div>
                             </td>
-                            <td>@(row.TransactionID ?? "-")</td>
-                            <td><span class="badge @TypeBadge(row.LogType)">@TypeName(row.LogType)</span></td>
-                            <td>@row.Code</td>
+                            <td>@LogTypeName(row.LogType)</td>
                             <td>@row.Message</td>
+                            <td>@(row.PaymentKey ?? "-")</td>
                             <td>@(row.OrderID ?? "-")</td>
+                            <td>@(row.MemberID?.ToString() ?? "-")</td>
                             <td>@row.CreatedAt.GetDateAt()</td>
-                            <environment include="Local,Development">
-                                <td>
-                                    <div class="d-xl-flex gap-2 justify-content-center d-grid">
-                                        <button type="button" class="btn btn-sm btn-outline-danger btn-row-delete" data-id="@row.ID">삭제</button>
-                                    </div>
-                                </td>
-                            </environment>
                         </tr>
                     }
                 }
@@ -191,6 +164,13 @@
 
         <partial name="_Pagination" model="Model.Pagination" />
     </div>
+
+    <div>
+        <ul class="form-text text-muted">
+            <li>토스 결제 과정에서 발생한 실패/오류/웹훅/대사 로그입니다.</li>
+            <li>웹훅 로그의 회원 PK 는 회원을 특정하지 못한 경우 비어 있을 수 있습니다.</li>
+        </ul>
+    </div>
 </div>
 
 @section Scripts {
@@ -237,14 +217,5 @@
             const anyChecked = $(".list-check-box:checked").length > 0;
             $("#btnListDelete").prop("disabled", !anyChecked);
         });
-
-        $(document).on("click", ".btn-row-delete", function (e) {
-            e.preventDefault();
-            if (!confirm("이 오류 내역을 삭제하시겠습니까?")) return;
-            const id = this.dataset.id;
-            $(".list-check-box").prop("checked", false);
-            $(`#chk_${id}`).prop("checked", true);
-            document.getElementById("fAdminList").submit();
-        });
     </script>
-}
+}

+ 8 - 8
Admin/Pages/Payments/Danal/Error/Index.cshtml.cs → Admin/Pages/Payments/Toss/Error/Index.cshtml.cs

@@ -1,4 +1,4 @@
-using Domain.Entities.Payments.Danal.ValueObject;
+using Domain.Entities.Payments.Toss.ValueObject;
 using SharedKernel.Helpers;
 using Application.Abstractions.Messaging;
 using Microsoft.AspNetCore.Mvc;
@@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
 using System.ComponentModel;
 using System.ComponentModel.DataAnnotations;
 
-namespace Admin.Pages.Payments.Danal.Error;
+namespace Admin.Pages.Payments.Toss.Error;
 
 public class IndexModel(IMediator mediator) : PageModel
 {
@@ -38,13 +38,13 @@ public class IndexModel(IMediator mediator) : PageModel
         public string? EndAt { get; set; }
 
         [DisplayName("로그 타입")]
-        public DanalLogType? LogType { get; set; }
+        public TossLogType? LogType { get; set; }
     }
 
     public int Total { get; set; } = 0;
-    public SearchDanalLogs.Response.TypeCounts Counts { get; set; } = new();
+    public SearchTossLogs.Response.TypeCounts Counts { get; set; } = new();
 
-    public List<SearchDanalLogs.Response.Row> List { get; set; } = [];
+    public List<SearchTossLogs.Response.Row> List { get; set; } = [];
 
     public Pagination? Pagination { get; set; }
 
@@ -55,7 +55,7 @@ public class IndexModel(IMediator mediator) : PageModel
             return;
         }
 
-        var result = await mediator.Send(new SearchDanalLogs.Query(
+        var result = await mediator.Send(new SearchTossLogs.Query(
             Query.PageNum,
             Query.PerPage,
             Query.Search,
@@ -80,7 +80,7 @@ public class IndexModel(IMediator mediator) : PageModel
                 throw new Exception("삭제할 항목을 선택해주세요.");
             }
 
-            await mediator.Send(new DeleteDanalLogs.Command(ids), ct);
+            await mediator.Send(new DeleteTossLogs.Command(ids), ct);
             TempData["SuccessMessage"] = $"{ids.Length}건이 삭제되었습니다.";
         }
         catch (Exception e)
@@ -90,4 +90,4 @@ public class IndexModel(IMediator mediator) : PageModel
 
         return Redirect($"{Request.Path}{Request.QueryString}");
     }
-}
+}

+ 14 - 14
Admin/using.cs

@@ -212,20 +212,20 @@ global using TestYouTubeApiKey = Application.Features.Admin.Channel.YouTubePubSu
 global using ForceYouTubeLive = Application.Features.Admin.Channel.YouTubePubSub.ForceLive;
 global using ForceYouTubeOffline = Application.Features.Admin.Channel.YouTubePubSub.ForceOffline;
 
-// 다날 결제 내역
-global using SearchDanalConfirms = Application.Features.Admin.Payment.Danal.Confirm.Search;
-global using GetDanalConfirm = Application.Features.Admin.Payment.Danal.Confirm.Get;
-global using CancelDanalConfirm = Application.Features.Admin.Payment.Danal.Confirm.Cancel;
-global using DeleteDanalConfirms = Application.Features.Admin.Payment.Danal.Confirm.Delete;
-
-// 다날 취소 내역
-global using SearchDanalCancels = Application.Features.Admin.Payment.Danal.Cancel.Search;
-global using GetDanalCancel = Application.Features.Admin.Payment.Danal.Cancel.Get;
-global using DeleteDanalCancels = Application.Features.Admin.Payment.Danal.Cancel.Delete;
-
-// 다날 오류 내역
-global using SearchDanalLogs = Application.Features.Admin.Payment.Danal.Log.Search;
-global using DeleteDanalLogs = Application.Features.Admin.Payment.Danal.Log.Delete;
+// 토스 결제 내역
+global using SearchTossConfirms = Application.Features.Admin.Payment.Toss.Confirm.Search;
+global using GetTossConfirm = Application.Features.Admin.Payment.Toss.Confirm.Get;
+global using CancelTossConfirm = Application.Features.Admin.Payment.Toss.Confirm.Cancel;
+global using DeleteTossConfirms = Application.Features.Admin.Payment.Toss.Confirm.Delete;
+
+// 토스 취소 내역
+global using SearchTossCancels = Application.Features.Admin.Payment.Toss.Cancel.Search;
+global using GetTossCancel = Application.Features.Admin.Payment.Toss.Cancel.Get;
+global using DeleteTossCancels = Application.Features.Admin.Payment.Toss.Cancel.Delete;
+
+// 토스 오류 내역
+global using SearchTossLogs = Application.Features.Admin.Payment.Toss.Log.Search;
+global using DeleteTossLogs = Application.Features.Admin.Payment.Toss.Log.Delete;
 
 // 메일 관리
 global using SearchEmailLogs = Application.Features.Admin.Mail.Search;

+ 47 - 0
Application/Abstractions/Crypto/EncryptedConfigValue.cs

@@ -0,0 +1,47 @@
+namespace Application.Abstractions.Crypto;
+
+/// <summary>
+/// Config 문자열 컬럼에 키 버전을 함께 담아 암호화 저장하는 헬퍼.
+/// 별도 KeyVersion 컬럼이 없는 Config 특성상 "enc:v{version}:{Base64}" 자체 포맷을 사용한다.
+/// 접두사가 없는 값은 레거시 평문으로 간주해 그대로 반환한다 (하위호환).
+/// </summary>
+public static class EncryptedConfigValue
+{
+    private const string Prefix = "enc:v";
+
+    /// <summary>평문을 현재 키 버전으로 암호화해 버전 접두사 포맷으로 반환</summary>
+    public static string Protect(IFieldEncryptor encryptor, string plaintext)
+    {
+        ArgumentNullException.ThrowIfNull(encryptor);
+
+        return $"{Prefix}{encryptor.CurrentKeyVersion}:{encryptor.Encrypt(plaintext)}";
+    }
+
+    /// <summary>버전 접두사 포맷 여부</summary>
+    public static bool IsProtected(string? value)
+    {
+        return value is not null && value.StartsWith(Prefix, StringComparison.Ordinal);
+    }
+
+    /// <summary>버전 접두사 포맷이면 복호화, 아니면(레거시 평문) 그대로 반환</summary>
+    public static string Unprotect(IFieldEncryptor encryptor, string value)
+    {
+        ArgumentNullException.ThrowIfNull(encryptor);
+        ArgumentNullException.ThrowIfNull(value);
+
+        if (!IsProtected(value))
+        {
+            return value;
+        }
+
+        var rest = value.AsSpan(Prefix.Length);
+        var separator = rest.IndexOf(':');
+
+        if (separator <= 0 || !int.TryParse(rest[..separator], out var version))
+        {
+            throw new InvalidOperationException("암호화된 설정 값의 키 버전 형식이 올바르지 않습니다.");
+        }
+
+        return encryptor.Decrypt(rest[(separator + 1)..].ToString(), version);
+    }
+}

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

@@ -14,7 +14,7 @@ using Domain.Entities.EmailVerification;
 using Domain.Entities.Forum.Logs;
 using Domain.Entities.Page.Popup;
 using Domain.Entities.Notes;
-using Domain.Entities.Payments.Danal;
+using Domain.Entities.Payments.Toss;
 using Domain.Entities.Feed;
 using Domain.Entities.Store;
 using Domain.Entities.Stocks;
@@ -127,9 +127,9 @@ public interface IAppDbContext : IAsyncDisposable
 
     // 결제
     DbSet<Domain.Entities.Payments.PaymentOrder> PaymentOrder { get; set; }
-    DbSet<DanalConfirm> DanalConfirm { get; set; }
-    DbSet<DanalCancel> DanalCancel { get; set; }
-    DbSet<DanalLog> DanalLog { get; set; }
+    DbSet<TossConfirm> TossConfirm { get; set; }
+    DbSet<TossCancel> TossCancel { get; set; }
+    DbSet<TossLog> TossLog { get; set; }
     DbSet<Domain.Entities.Payments.PaymentReconcileLog> PaymentReconcileLog { get; set; }
 
     // 상점 (Store)

+ 0 - 55
Application/Abstractions/Payment/DanalPayModels.cs

@@ -1,55 +0,0 @@
-namespace Application.Abstractions.Payment;
-
-/// <summary>프론트엔드에 전달할 클라이언트 설정</summary>
-public sealed record DanalClientConfig(
-    string ClientKey,
-    string MerchantID
-);
-
-/// <summary>결제 승인 결과 (다날 API 응답 전체)</summary>
-public sealed record DanalConfirmResult(
-    bool Success,
-    string? Code,
-    string? Message,
-    string? TransactionID,
-    string? OrderName,
-    int? TotalAmount,
-    int? DiscountAmount,
-    string? UserName,
-    // 카드
-    string? TransDate,
-    string? TransTime,
-    string? CardCode,
-    string? CardName,
-    string? CardNo,
-    byte? InstallmentMonths,
-    string? ApproveNo,
-    // 휴대폰
-    string? ApprovalDateTime,
-    string? AuthKey,
-    // 계좌이체
-    string? AccountNumber,
-    string? BankCode,
-    string? UserId,
-    string? UserEmail,
-    // 가상계좌
-    string? BankName,
-    string? ExpireDate,
-    string? ExpireTime,
-    string? VirtualAccountNumber,
-    string? UseCashReceipt
-);
-
-/// <summary>결제 취소 결과 (다날 API 응답 전체)</summary>
-public sealed record DanalCancelResult(
-    bool Success,
-    string? Code,
-    string? Message,
-    string? OriginalTransactionID,
-    int? CancelledAmount,
-    string? TransDate,
-    string? TransTime,
-    string? Balance,
-    string? RemainedAmount,
-    string? ApprovalDateTime
-);

+ 0 - 19
Application/Abstractions/Payment/IDanalPayService.cs

@@ -1,19 +0,0 @@
-namespace Application.Abstractions.Payment;
-
-/// <summary>
-/// 다날 PG 결제 서비스
-/// - Config DB에서 테스트/라이브 자동 분기
-/// - Basic Auth 헤더 생성
-/// - 승인/취소 API 호출
-/// </summary>
-public interface IDanalPayService
-{
-    /// <summary>프론트엔드에 전달할 ClientKey, MerchantID</summary>
-    Task<DanalClientConfig> GetClientConfigAsync(CancellationToken ct);
-
-    /// <summary>결제 승인 (successUrl 콜백 후 서버에서 호출)</summary>
-    Task<DanalConfirmResult> ConfirmAsync(string method, string transactionID, string orderID, int amount, CancellationToken ct);
-
-    /// <summary>결제 취소</summary>
-    Task<DanalCancelResult> CancelAsync(string method, string transactionID, int amount, string cancelType, CancellationToken ct);
-}

+ 24 - 0
Application/Abstractions/Payment/ITossPaymentService.cs

@@ -0,0 +1,24 @@
+namespace Application.Abstractions.Payment;
+
+/// <summary>
+/// 토스페이먼츠 PG 결제 서비스 (V2 — api.tosspayments.com)
+/// - Config DB(External.TossPayMode)에서 테스트/라이브 자동 분기
+/// - Basic Auth 헤더 생성 (secretKey + ":" → Base64, BOM 없이)
+/// - 승인/취소/조회 API 호출. POST 에는 Idempotency-Key(UUID v4) 부여.
+/// - 통신 불명 예외(HttpRequestException/TaskCanceledException)는 삼키지 않고 전파 —
+///   호출측이 NeedsReconciliation 분기(승인 통신 실패 = 결제 실패 단정 금지). (Phase 1.5)
+/// </summary>
+public interface ITossPaymentService
+{
+    /// <summary>프론트엔드에 전달할 ClientKey</summary>
+    Task<TossClientConfig> GetClientConfigAsync(CancellationToken ct);
+
+    /// <summary>결제 승인 (successUrl 콜백 후 서버에서 호출) — POST /v1/payments/confirm</summary>
+    Task<TossPaymentResult> ConfirmAsync(string paymentKey, string orderID, int amount, CancellationToken ct);
+
+    /// <summary>결제 취소 — POST /v1/payments/{paymentKey}/cancel (cancelAmount null = 전액 취소)</summary>
+    Task<TossPaymentResult> CancelAsync(string paymentKey, string cancelReason, int? cancelAmount, CancellationToken ct);
+
+    /// <summary>결제 조회 — GET /v1/payments/{paymentKey} (웹훅/대사 재확인용)</summary>
+    Task<TossPaymentResult> GetPaymentAsync(string paymentKey, CancellationToken ct);
+}

+ 38 - 0
Application/Abstractions/Payment/TossPaymentModels.cs

@@ -0,0 +1,38 @@
+namespace Application.Abstractions.Payment;
+
+/// <summary>프론트엔드에 전달할 토스 클라이언트 설정</summary>
+public sealed record TossClientConfig(
+    string ClientKey
+);
+
+/// <summary>
+/// Toss Payment 객체 결과 (승인/취소/조회 공용 — 필요 필드만).
+/// Success=false 면 Code/Message 에 Toss 에러 응답 {code, message} 가 담긴다.
+/// </summary>
+public sealed record TossPaymentResult(
+    bool Success,
+    string? Code,
+    string? Message,
+    string? PaymentKey,
+    string? OrderID,
+    string? Status,
+    int? TotalAmount,
+    string? Method,
+    DateTime? ApprovedAt,
+    string? ReceiptUrl,
+    // 가상계좌 (status = WAITING_FOR_DEPOSIT)
+    string? VirtualAccountNumber,
+    string? VirtualAccountBankCode,
+    string? VirtualAccountHolder,
+    DateTime? VirtualAccountDueDate,
+    IReadOnlyList<TossCancelDetail> Cancels,
+    string? RawJson
+);
+
+/// <summary>Toss Payment.cancels[] 항목</summary>
+public sealed record TossCancelDetail(
+    string? TransactionKey,
+    int CancelAmount,
+    string? CancelReason,
+    DateTime? CanceledAt
+);

+ 0 - 5
Application/Features/Admin/Payment/Danal/Cancel/Delete/Command.cs

@@ -1,5 +0,0 @@
-using Application.Abstractions.Messaging;
-
-namespace Application.Features.Admin.Payment.Danal.Cancel.Delete;
-
-public sealed record Command(int[] IDs) : ICommand;

+ 0 - 43
Application/Features/Admin/Payment/Danal/Cancel/Get/Handler.cs

@@ -1,43 +0,0 @@
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Microsoft.EntityFrameworkCore;
-
-namespace Application.Features.Admin.Payment.Danal.Cancel.Get;
-
-public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
-{
-    public async Task<Response> Handle(Query request, CancellationToken ct)
-    {
-        var cancel = await db.DanalCancel.AsNoTracking()
-            .Include(x => x.Member)
-            .FirstOrDefaultAsync(x => x.ID == request.ID, ct)
-            ?? throw new InvalidOperationException("다날 취소 내역을 찾을 수 없습니다.");
-
-        return new Response
-        {
-            ID = cancel.ID,
-            MemberID = cancel.MemberID,
-            MemberEmail = cancel.Member?.Email ?? string.Empty,
-            MemberName = cancel.Member?.Name,
-            Method = cancel.Method,
-            CancelType = cancel.CancelType,
-            TransactionID = cancel.TransactionID,
-            OrderID = cancel.OrderID,
-            Amount = cancel.Amount,
-            MerchantID = cancel.MerchantID,
-            CancelRequester = cancel.CancelRequester,
-            CancelReason = cancel.CancelReason,
-            ResponseCode = cancel.ResponseCode,
-            ResponseMessage = cancel.ResponseMessage,
-            OriginalTransactionID = cancel.OriginalTransactionID,
-            CancelledAmount = cancel.CancelledAmount,
-            TransDate = cancel.TransDate,
-            TransTime = cancel.TransTime,
-            Balance = cancel.Balance,
-            RemainedAmount = cancel.RemainedAmount,
-            ApprovalDateTime = cancel.ApprovalDateTime,
-            CreatedAt = cancel.CreatedAt,
-            UpdatedAt = cancel.UpdatedAt
-        };
-    }
-}

+ 0 - 5
Application/Features/Admin/Payment/Danal/Cancel/Get/Query.cs

@@ -1,5 +0,0 @@
-using Application.Abstractions.Messaging;
-
-namespace Application.Features.Admin.Payment.Danal.Cancel.Get;
-
-public sealed record Query(int ID) : IQuery<Response>;

+ 0 - 31
Application/Features/Admin/Payment/Danal/Cancel/Get/Response.cs

@@ -1,31 +0,0 @@
-using Domain.Entities.Payments.Danal.ValueObject;
-using Domain.Entities.Payments.ValueObject;
-
-namespace Application.Features.Admin.Payment.Danal.Cancel.Get;
-
-public sealed class Response
-{
-    public int ID { get; init; }
-    public int MemberID { get; init; }
-    public required string MemberEmail { get; init; }
-    public string? MemberName { get; init; }
-    public PaymentMethod Method { get; init; }
-    public DanalCancelType CancelType { get; init; }
-    public required string TransactionID { get; init; }
-    public required string OrderID { get; init; }
-    public int Amount { get; init; }
-    public required string MerchantID { get; init; }
-    public string? CancelRequester { get; init; }
-    public string? CancelReason { get; init; }
-    public string? ResponseCode { get; init; }
-    public string? ResponseMessage { get; init; }
-    public string? OriginalTransactionID { get; init; }
-    public int? CancelledAmount { get; init; }
-    public string? TransDate { get; init; }
-    public string? TransTime { get; init; }
-    public int? Balance { get; init; }
-    public int? RemainedAmount { get; init; }
-    public string? ApprovalDateTime { get; init; }
-    public required DateTime CreatedAt { get; init; }
-    public DateTime? UpdatedAt { get; init; }
-}

+ 0 - 101
Application/Features/Admin/Payment/Danal/Cancel/Search/Handler.cs

@@ -1,101 +0,0 @@
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Domain.Entities.Payments.ValueObject;
-using Microsoft.EntityFrameworkCore;
-
-namespace Application.Features.Admin.Payment.Danal.Cancel.Search;
-
-public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
-{
-    public async Task<Response> Handle(Query request, CancellationToken ct)
-    {
-        var query = db.DanalCancel.AsNoTracking().Include(x => x.Member).AsQueryable();
-
-        if (!string.IsNullOrWhiteSpace(request.Keyword))
-        {
-            query = request.Search switch
-            {
-                1 => query.Where(x => x.TransactionID.Contains(request.Keyword)),
-                2 => query.Where(x => x.OrderID.Contains(request.Keyword)),
-                3 => query.Where(x => x.Member != null && x.Member.ID.ToString().Contains(request.Keyword)),
-                4 => query.Where(x => x.Member != null && x.Member.Name != null && x.Member.Name.Contains(request.Keyword)),
-                5 => query.Where(x => x.Member != null && x.Member.SID.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 counts = await query.GroupBy(c => 1).Select(c => new Response.MethodCounts
-        {
-            Card = c.Count(x => x.Method == PaymentMethod.Card),
-            VirtualAccount = c.Count(x => x.Method == PaymentMethod.VirtualAccount),
-            Mobile = c.Count(x => x.Method == PaymentMethod.Mobile),
-            Transfer = c.Count(x => x.Method == PaymentMethod.Transfer)
-        }).FirstOrDefaultAsync(ct) ?? new Response.MethodCounts();
-
-        if (request.PayMethod.HasValue)
-        {
-            query = query.Where(x => x.Method == request.PayMethod.Value);
-        }
-
-        var total = await query.CountAsync(ct);
-        var skip = (request.Page - 1) * request.PerPage;
-
-        var list = await query
-            .OrderByDescending(x => x.ID)
-            .Skip(skip)
-            .Take(request.PerPage)
-            .Select(x => new Response.Row
-            {
-                Num = 0,
-                ID = x.ID,
-                MemberID = x.MemberID,
-                MemberEmail = x.Member != null ? x.Member.Email : string.Empty,
-                MemberName = x.Member != null ? x.Member.Name : null,
-                Method = x.Method,
-                CancelType = x.CancelType,
-                TransactionID = x.TransactionID,
-                OrderID = x.OrderID,
-                Amount = x.Amount,
-                Balance = x.Balance,
-                ResponseCode = x.ResponseCode,
-                ResponseMessage = x.ResponseMessage,
-                CreatedAt = 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,
-            Method = x.Method,
-            CancelType = x.CancelType,
-            TransactionID = x.TransactionID,
-            OrderID = x.OrderID,
-            Amount = x.Amount,
-            Balance = x.Balance,
-            ResponseCode = x.ResponseCode,
-            ResponseMessage = x.ResponseMessage,
-            CreatedAt = x.CreatedAt
-        }).ToList();
-
-        return new Response
-        {
-            Total = total,
-            Counts = counts,
-            List = rows
-        };
-    }
-}

+ 0 - 38
Application/Features/Admin/Payment/Danal/Cancel/Search/Response.cs

@@ -1,38 +0,0 @@
-using Domain.Entities.Payments.Danal.ValueObject;
-using Domain.Entities.Payments.ValueObject;
-
-namespace Application.Features.Admin.Payment.Danal.Cancel.Search;
-
-public sealed class Response
-{
-    public int Total { get; init; }
-    public MethodCounts Counts { get; init; } = new();
-
-    public sealed class MethodCounts
-    {
-        public int Card { get; init; }
-        public int VirtualAccount { get; init; }
-        public int Mobile { get; init; }
-        public int Transfer { 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 PaymentMethod Method { get; init; }
-        public DanalCancelType CancelType { get; init; }
-        public required string TransactionID { get; init; }
-        public required string OrderID { get; init; }
-        public int Amount { get; init; }
-        public int? Balance { get; init; }
-        public string? ResponseCode { get; init; }
-        public string? ResponseMessage { get; init; }
-        public required DateTime CreatedAt { get; init; }
-    }
-}

+ 0 - 5
Application/Features/Admin/Payment/Danal/Confirm/Delete/Command.cs

@@ -1,5 +0,0 @@
-using Application.Abstractions.Messaging;
-
-namespace Application.Features.Admin.Payment.Danal.Confirm.Delete;
-
-public sealed record Command(int[] IDs) : ICommand;

+ 0 - 78
Application/Features/Admin/Payment/Danal/Confirm/Get/Handler.cs

@@ -1,78 +0,0 @@
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Microsoft.EntityFrameworkCore;
-
-namespace Application.Features.Admin.Payment.Danal.Confirm.Get;
-
-public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
-{
-    public async Task<Response> Handle(Query request, CancellationToken ct)
-    {
-        var confirm = await db.DanalConfirm.AsNoTracking()
-            .Include(x => x.Member)
-            .Include(x => x.PaymentOrder)
-            .FirstOrDefaultAsync(x => x.ID == request.ID, ct)
-            ?? throw new InvalidOperationException("다날 결제 내역을 찾을 수 없습니다.");
-
-        var cancel = await db.DanalCancel.AsNoTracking()
-            .Where(c => c.PaymentOrderID == confirm.PaymentOrderID && c.ResponseCode == "0000")
-            .OrderByDescending(c => c.ID)
-            .Select(c => new Response.CancelData
-            {
-                ID = c.ID,
-                CancelType = c.CancelType,
-                Amount = c.Amount,
-                ResponseCode = c.ResponseCode,
-                ResponseMessage = c.ResponseMessage,
-                CancelledAmount = c.CancelledAmount,
-                CreatedAt = c.CreatedAt
-            })
-            .FirstOrDefaultAsync(ct);
-
-        return new Response
-        {
-            Confirm = new Response.ConfirmData
-            {
-                ID = confirm.ID,
-                MemberID = confirm.MemberID,
-                MemberEmail = confirm.Member?.Email ?? string.Empty,
-                MemberName = confirm.Member?.Name,
-                Method = confirm.Method,
-                TransactionID = confirm.TransactionID,
-                OrderID = confirm.OrderID,
-                Amount = confirm.Amount,
-                MerchantID = confirm.MerchantID,
-                OrderStatus = confirm.PaymentOrder != null ? confirm.PaymentOrder.Status : default,
-                OrderPaidAt = confirm.PaymentOrder?.PaidAt,
-                OrderCancelledAt = confirm.PaymentOrder?.CancelledAt,
-                OrderName = confirm.OrderName,
-                ResponseCode = confirm.ResponseCode,
-                ResponseMessage = confirm.ResponseMessage,
-                TotalAmount = confirm.TotalAmount,
-                DiscountAmount = confirm.DiscountAmount,
-                UserName = confirm.UserName,
-                TransDate = confirm.TransDate,
-                TransTime = confirm.TransTime,
-                CardCode = confirm.CardCode,
-                CardName = confirm.CardName,
-                CardNo = confirm.CardNo,
-                InstallmentMonths = confirm.InstallmentMonths,
-                ApproveNo = confirm.ApproveNo,
-                ApprovalDateTime = confirm.ApprovalDateTime,
-                AuthKey = confirm.AuthKey,
-                AccountNumber = confirm.AccountNumber,
-                BankCode = confirm.BankCode,
-                UserId = confirm.UserId,
-                UserEmail = confirm.UserEmail,
-                BankName = confirm.BankName,
-                ExpireDate = confirm.ExpireDate,
-                ExpireTime = confirm.ExpireTime,
-                VirtualAccountNumber = confirm.VirtualAccountNumber,
-                UseCashReceipt = confirm.UseCashReceipt,
-                CreatedAt = confirm.CreatedAt,
-                UpdatedAt = confirm.UpdatedAt
-            },
-            Cancel = cancel
-        };
-    }
-}

+ 0 - 5
Application/Features/Admin/Payment/Danal/Confirm/Get/Query.cs

@@ -1,5 +0,0 @@
-using Application.Abstractions.Messaging;
-
-namespace Application.Features.Admin.Payment.Danal.Confirm.Get;
-
-public sealed record Query(int ID) : IQuery<Response>;

+ 0 - 63
Application/Features/Admin/Payment/Danal/Confirm/Get/Response.cs

@@ -1,63 +0,0 @@
-using Domain.Entities.Payments.Danal.ValueObject;
-using Domain.Entities.Payments.ValueObject;
-
-namespace Application.Features.Admin.Payment.Danal.Confirm.Get;
-
-public sealed class Response
-{
-    public required ConfirmData Confirm { get; init; }
-    public CancelData? Cancel { get; init; }
-
-    public sealed class ConfirmData
-    {
-        public int ID { get; init; }
-        public int MemberID { get; init; }
-        public required string MemberEmail { get; init; }
-        public string? MemberName { get; init; }
-        public PaymentMethod Method { get; init; }
-        public required string TransactionID { get; init; }
-        public required string OrderID { get; init; }
-        public int Amount { get; init; }
-        public required string MerchantID { get; init; }
-        public PaymentStatus OrderStatus { get; init; }
-        public DateTime? OrderPaidAt { get; init; }
-        public DateTime? OrderCancelledAt { get; init; }
-        public string? OrderName { get; init; }
-        public string? ResponseCode { get; init; }
-        public string? ResponseMessage { get; init; }
-        public int? TotalAmount { get; init; }
-        public int? DiscountAmount { get; init; }
-        public string? UserName { get; init; }
-        public string? TransDate { get; init; }
-        public string? TransTime { get; init; }
-        public string? CardCode { get; init; }
-        public string? CardName { get; init; }
-        public string? CardNo { get; init; }
-        public byte? InstallmentMonths { get; init; }
-        public string? ApproveNo { get; init; }
-        public string? ApprovalDateTime { get; init; }
-        public string? AuthKey { get; init; }
-        public string? AccountNumber { get; init; }
-        public string? BankCode { get; init; }
-        public string? UserId { get; init; }
-        public string? UserEmail { get; init; }
-        public string? BankName { get; init; }
-        public string? ExpireDate { get; init; }
-        public string? ExpireTime { get; init; }
-        public string? VirtualAccountNumber { get; init; }
-        public string? UseCashReceipt { get; init; }
-        public required DateTime CreatedAt { get; init; }
-        public DateTime? UpdatedAt { get; init; }
-    }
-
-    public sealed class CancelData
-    {
-        public int ID { get; init; }
-        public DanalCancelType CancelType { get; init; }
-        public int Amount { get; init; }
-        public string? ResponseCode { get; init; }
-        public string? ResponseMessage { get; init; }
-        public int? CancelledAmount { get; init; }
-        public required DateTime CreatedAt { get; init; }
-    }
-}

+ 0 - 122
Application/Features/Admin/Payment/Danal/Confirm/Search/Handler.cs

@@ -1,122 +0,0 @@
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Domain.Entities.Payments.ValueObject;
-using Microsoft.EntityFrameworkCore;
-
-namespace Application.Features.Admin.Payment.Danal.Confirm.Search;
-
-public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
-{
-    public async Task<Response> Handle(Query request, CancellationToken ct)
-    {
-        var query = db.DanalConfirm.AsNoTracking().Include(x => x.Member).AsQueryable();
-
-        // 키워드 검색
-        if (!string.IsNullOrWhiteSpace(request.Keyword))
-        {
-            query = request.Search switch
-            {
-                1 => query.Where(x => x.TransactionID.Contains(request.Keyword)),
-                2 => query.Where(x => x.OrderID.Contains(request.Keyword)),
-                3 => query.Where(x => x.Member != null && x.Member.ID.ToString().Contains(request.Keyword)),
-                4 => query.Where(x => x.Member != null && x.Member.Name != null && x.Member.Name.Contains(request.Keyword)),
-                5 => query.Where(x => x.Member != null && x.Member.SID.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);
-        }
-
-        // 탭별 건수 (PayMethod 필터 전)
-        var counts = await query.GroupBy(c => 1).Select(c => new Response.MethodCounts
-        {
-            Card = c.Count(x => x.Method == PaymentMethod.Card),
-            VirtualAccount = c.Count(x => x.Method == PaymentMethod.VirtualAccount),
-            Mobile = c.Count(x => x.Method == PaymentMethod.Mobile),
-            Transfer = c.Count(x => x.Method == PaymentMethod.Transfer)
-        }).FirstOrDefaultAsync(ct) ?? new Response.MethodCounts();
-
-        if (request.PayMethod.HasValue)
-        {
-            query = query.Where(x => x.Method == request.PayMethod.Value);
-        }
-
-        var total = await query.CountAsync(ct);
-        var skip = (request.Page - 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 != null ? x.Member.Email : string.Empty,
-                MemberName = x.Member != null ? x.Member.Name : null,
-                x.Method,
-                x.TransactionID,
-                x.OrderID,
-                x.Amount,
-                x.TotalAmount,
-                x.ResponseCode,
-                x.ResponseMessage,
-                x.PaymentOrderID,
-                x.CreatedAt
-            })
-            .ToListAsync(ct);
-
-        // 같은 PaymentOrderID로 DanalCancel 있는지 조회 (성공한 취소만)
-        var orderIDs = list.Select(x => x.PaymentOrderID).Distinct().ToArray();
-
-        var cancels = await db.DanalCancel.AsNoTracking()
-            .Where(c => orderIDs.Contains(c.PaymentOrderID) && c.ResponseCode == "0000")
-            .Select(c => new { c.PaymentOrderID, c.CancelType, c.CancelledAmount })
-            .ToListAsync(ct);
-
-        var cancelByOrder = cancels.GroupBy(c => c.PaymentOrderID).ToDictionary(
-            g => g.Key,
-            g => g.First()
-        );
-
-        var rows = list.Select((x, idx) =>
-        {
-            cancelByOrder.TryGetValue(x.PaymentOrderID, out var cancel);
-
-            return new Response.Row
-            {
-                Num = total - skip - idx,
-                ID = x.ID,
-                MemberID = x.MemberID,
-                MemberEmail = x.MemberEmail,
-                MemberName = x.MemberName,
-                Method = x.Method,
-                TransactionID = x.TransactionID,
-                OrderID = x.OrderID,
-                Amount = x.Amount,
-                TotalAmount = x.TotalAmount,
-                ResponseCode = x.ResponseCode,
-                ResponseMessage = x.ResponseMessage,
-                CancelType = cancel?.CancelType,
-                CancelledAmount = cancel?.CancelledAmount,
-                CreatedAt = x.CreatedAt
-            };
-        }).ToList();
-
-        return new Response
-        {
-            Total = total,
-            Counts = counts,
-            List = rows
-        };
-    }
-}

+ 0 - 5
Application/Features/Admin/Payment/Danal/Log/Delete/Command.cs

@@ -1,5 +0,0 @@
-using Application.Abstractions.Messaging;
-
-namespace Application.Features.Admin.Payment.Danal.Log.Delete;
-
-public sealed record Command(int[] IDs) : ICommand;

+ 0 - 93
Application/Features/Admin/Payment/Danal/Log/Search/Handler.cs

@@ -1,93 +0,0 @@
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Domain.Entities.Payments.Danal.ValueObject;
-using Microsoft.EntityFrameworkCore;
-
-namespace Application.Features.Admin.Payment.Danal.Log.Search;
-
-public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
-{
-    public async Task<Response> Handle(Query request, CancellationToken ct)
-    {
-        var query = db.DanalLog.AsNoTracking().Include(x => x.Member).AsQueryable();
-
-        if (!string.IsNullOrWhiteSpace(request.Keyword))
-        {
-            query = request.Search switch
-            {
-                1 => query.Where(x => x.TransactionID != null && x.TransactionID.Contains(request.Keyword)),
-                2 => query.Where(x => x.OrderID != null && x.OrderID.Contains(request.Keyword)),
-                3 => query.Where(x => x.Member != null && x.Member.ID.ToString().Contains(request.Keyword)),
-                4 => query.Where(x => x.Member != null && x.Member.Name != null && x.Member.Name.Contains(request.Keyword)),
-                5 => query.Where(x => x.Member != null && x.Member.SID.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 counts = await query.GroupBy(c => 1).Select(c => new Response.TypeCounts
-        {
-            Failed = c.Count(x => x.LogType == DanalLogType.Failed),
-            Error = c.Count(x => x.LogType == DanalLogType.Error),
-            Webhook = c.Count(x => x.LogType == DanalLogType.Webhook)
-        }).FirstOrDefaultAsync(ct) ?? new Response.TypeCounts();
-
-        if (request.LogType.HasValue)
-        {
-            query = query.Where(x => x.LogType == request.LogType.Value);
-        }
-
-        var total = await query.CountAsync(ct);
-        var skip = (request.Page - 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 != null ? x.Member.Email : null,
-                MemberName = x.Member != null ? x.Member.Name : null,
-                x.LogType,
-                x.Code,
-                x.Message,
-                x.TransactionID,
-                x.OrderID,
-                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,
-            LogType = x.LogType,
-            Code = x.Code,
-            Message = x.Message,
-            TransactionID = x.TransactionID,
-            OrderID = x.OrderID,
-            CreatedAt = x.CreatedAt
-        }).ToList();
-
-        return new Response
-        {
-            Total = total,
-            Counts = counts,
-            List = rows
-        };
-    }
-}

+ 5 - 0
Application/Features/Admin/Payment/Toss/Cancel/Delete/Command.cs

@@ -0,0 +1,5 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Admin.Payment.Toss.Cancel.Delete;
+
+public sealed record Command(int[] IDs) : ICommand;

+ 3 - 3
Application/Features/Admin/Payment/Danal/Confirm/Delete/Handler.cs → Application/Features/Admin/Payment/Toss/Cancel/Delete/Handler.cs

@@ -2,7 +2,7 @@ using Application.Abstractions.Data;
 using Application.Abstractions.Messaging;
 using Microsoft.EntityFrameworkCore;
 
-namespace Application.Features.Admin.Payment.Danal.Confirm.Delete;
+namespace Application.Features.Admin.Payment.Toss.Cancel.Delete;
 
 public sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
 {
@@ -13,6 +13,6 @@ public sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
             return;
         }
 
-        await db.DanalConfirm.Where(x => request.IDs.Contains(x.ID)).ExecuteDeleteAsync(ct);
+        await db.TossCancel.Where(c => request.IDs.Contains(c.ID)).ExecuteDeleteAsync(ct);
     }
-}
+}

+ 33 - 0
Application/Features/Admin/Payment/Toss/Cancel/Get/Handler.cs

@@ -0,0 +1,33 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Admin.Payment.Toss.Cancel.Get;
+
+public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var cancel = await db.TossCancel.AsNoTracking().Include(c => c.Member).FirstOrDefaultAsync(c => c.ID == request.ID, ct) ?? throw new InvalidOperationException("토스 취소 내역을 찾을 수 없습니다.");
+
+        return new Response
+        {
+            ID = cancel.ID,
+            MemberID = cancel.MemberID,
+            MemberEmail = cancel.Member?.Email ?? string.Empty,
+            MemberName = cancel.Member?.Name,
+            PaymentKey = cancel.PaymentKey,
+            OrderID = cancel.OrderID,
+            CancelAmount = cancel.CancelAmount,
+            CancelReason = cancel.CancelReason,
+            CancelRequester = cancel.CancelRequester,
+            ResponseCode = cancel.ResponseCode,
+            ResponseMessage = cancel.ResponseMessage,
+            Status = cancel.Status,
+            TransactionKey = cancel.TransactionKey,
+            CanceledAt = cancel.CanceledAt,
+            CreatedAt = cancel.CreatedAt,
+            UpdatedAt = cancel.UpdatedAt
+        };
+    }
+}

+ 5 - 0
Application/Features/Admin/Payment/Toss/Cancel/Get/Query.cs

@@ -0,0 +1,5 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Admin.Payment.Toss.Cancel.Get;
+
+public sealed record Query(int ID) : IQuery<Response>;

+ 21 - 0
Application/Features/Admin/Payment/Toss/Cancel/Get/Response.cs

@@ -0,0 +1,21 @@
+namespace Application.Features.Admin.Payment.Toss.Cancel.Get;
+
+public sealed class Response
+{
+    public int ID { get; init; }
+    public int MemberID { get; init; }
+    public required string MemberEmail { get; init; }
+    public string? MemberName { get; init; }
+    public required string PaymentKey { get; init; }
+    public required string OrderID { get; init; }
+    public int CancelAmount { get; init; }
+    public string? CancelReason { get; init; }
+    public string? CancelRequester { get; init; }
+    public string? ResponseCode { get; init; }
+    public string? ResponseMessage { get; init; }
+    public string? Status { get; init; }
+    public string? TransactionKey { get; init; }
+    public DateTime? CanceledAt { get; init; }
+    public required DateTime CreatedAt { get; init; }
+    public DateTime? UpdatedAt { get; init; }
+}

+ 105 - 0
Application/Features/Admin/Payment/Toss/Cancel/Search/Handler.cs

@@ -0,0 +1,105 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Admin.Payment.Toss.Cancel.Search;
+
+public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var query = db.TossCancel.AsNoTracking().Include(c => c.Member).AsQueryable();
+
+        if (!string.IsNullOrWhiteSpace(request.Keyword))
+        {
+            query = request.Search switch
+            {
+                1 => query.Where(c => c.PaymentKey.Contains(request.Keyword)),
+                2 => query.Where(c => c.OrderID.Contains(request.Keyword)),
+                3 => query.Where(c => c.Member != null && c.Member.ID.ToString().Contains(request.Keyword)),
+                4 => query.Where(c => c.Member != null && c.Member.Name != null && c.Member.Name.Contains(request.Keyword)),
+                5 => query.Where(c => c.Member != null && c.Member.SID.Contains(request.Keyword)),
+                _ => query
+            };
+        }
+
+        if (!string.IsNullOrWhiteSpace(request.StartAt) && DateTime.TryParse(request.StartAt, out var startAt))
+        {
+            query = query.Where(c => c.CreatedAt >= startAt);
+        }
+
+        if (!string.IsNullOrWhiteSpace(request.EndAt) && DateTime.TryParse(request.EndAt, out var endAt))
+        {
+            query = query.Where(c => c.CreatedAt <= endAt);
+        }
+
+        // 탭별 건수 (State 필터 전)
+        var counts = await query.GroupBy(c => 1).Select(c => new Response.StateCounts
+        {
+            Success = c.Count(x => x.ResponseCode == "SUCCESS"),
+            Failed = c.Count(x => x.ResponseCode != null && x.ResponseCode != "SUCCESS"),
+            Pending = c.Count(x => x.ResponseCode == null)
+        }).FirstOrDefaultAsync(ct) ?? new Response.StateCounts();
+
+        if (request.State.HasValue)
+        {
+            query = request.State.Value switch
+            {
+                1 => query.Where(c => c.ResponseCode == "SUCCESS"),
+                2 => query.Where(c => c.ResponseCode != null && c.ResponseCode != "SUCCESS"),
+                3 => query.Where(c => c.ResponseCode == null),
+                _ => query
+            };
+        }
+
+        var total = await query.CountAsync(ct);
+        var skip = (request.Page - 1) * request.PerPage;
+
+        var list = await query
+            .OrderByDescending(c => c.ID)
+            .Skip(skip)
+            .Take(request.PerPage)
+            .Select(c => new
+            {
+                c.ID,
+                c.MemberID,
+                MemberEmail = c.Member != null ? c.Member.Email : string.Empty,
+                MemberName = c.Member != null ? c.Member.Name : null,
+                c.PaymentKey,
+                c.OrderID,
+                c.CancelAmount,
+                c.CancelReason,
+                c.CancelRequester,
+                c.Status,
+                c.ResponseCode,
+                c.ResponseMessage,
+                c.CreatedAt
+            })
+            .ToListAsync(ct);
+
+        var rows = list.Select((c, idx) => new Response.Row
+        {
+            Num = total - skip - idx,
+            ID = c.ID,
+            MemberID = c.MemberID,
+            MemberEmail = c.MemberEmail,
+            MemberName = c.MemberName,
+            PaymentKey = c.PaymentKey,
+            OrderID = c.OrderID,
+            CancelAmount = c.CancelAmount,
+            CancelReason = c.CancelReason,
+            CancelRequester = c.CancelRequester,
+            Status = c.Status,
+            ResponseCode = c.ResponseCode,
+            ResponseMessage = c.ResponseMessage,
+            CreatedAt = c.CreatedAt
+        }).ToList();
+
+        return new Response
+        {
+            Total = total,
+            Counts = counts,
+            List = rows
+        };
+    }
+}

+ 4 - 4
Application/Features/Admin/Payment/Danal/Confirm/Search/Query.cs → Application/Features/Admin/Payment/Toss/Cancel/Search/Query.cs

@@ -1,8 +1,8 @@
 using Application.Abstractions.Messaging;
-using Domain.Entities.Payments.ValueObject;
 
-namespace Application.Features.Admin.Payment.Danal.Confirm.Search;
+namespace Application.Features.Admin.Payment.Toss.Cancel.Search;
 
+/// <summary>State: 1=성공, 2=실패, 3=응답 대기</summary>
 public sealed record Query(
     int Page,
     ushort PerPage,
@@ -10,5 +10,5 @@ public sealed record Query(
     string? Keyword = null,
     string? StartAt = null,
     string? EndAt = null,
-    PaymentMethod? PayMethod = null
-) : IQuery<Response>;
+    int? State = null
+) : IQuery<Response>;

+ 34 - 0
Application/Features/Admin/Payment/Toss/Cancel/Search/Response.cs

@@ -0,0 +1,34 @@
+namespace Application.Features.Admin.Payment.Toss.Cancel.Search;
+
+public sealed class Response
+{
+    public int Total { get; init; }
+    public StateCounts Counts { get; init; } = new();
+
+    public sealed class StateCounts
+    {
+        public int Success { get; init; }
+        public int Failed { get; init; }
+        public int Pending { 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 required string PaymentKey { get; init; }
+        public required string OrderID { get; init; }
+        public int CancelAmount { get; init; }
+        public string? CancelReason { get; init; }
+        public string? CancelRequester { get; init; }
+        public string? Status { get; init; }
+        public string? ResponseCode { get; init; }
+        public string? ResponseMessage { get; init; }
+        public required DateTime CreatedAt { get; init; }
+    }
+}

+ 2 - 2
Application/Features/Admin/Payment/Danal/Confirm/Cancel/Command.cs → Application/Features/Admin/Payment/Toss/Confirm/Cancel/Command.cs

@@ -1,10 +1,10 @@
 using Application.Abstractions.Messaging;
 using SharedKernel.Results;
 
-namespace Application.Features.Admin.Payment.Danal.Confirm.Cancel;
+namespace Application.Features.Admin.Payment.Toss.Confirm.Cancel;
 
 public sealed record Command(
     int ID,
     string? CancelRequester,
     string? CancelReason
-) : ICommand<Result>;
+) : ICommand<Result>;

+ 55 - 75
Application/Features/Admin/Payment/Danal/Confirm/Cancel/Handler.cs → Application/Features/Admin/Payment/Toss/Confirm/Cancel/Handler.cs

@@ -2,49 +2,54 @@ using Application.Abstractions.Data;
 using Application.Abstractions.Messaging;
 using Application.Abstractions.Payment;
 using Domain.Entities.Common.ValueObject;
-using Domain.Entities.Payments.Danal;
-using Domain.Entities.Payments.Danal.ValueObject;
+using Domain.Entities.Payments;
+using Domain.Entities.Payments.Toss;
+using Domain.Entities.Payments.Toss.ValueObject;
 using Domain.Entities.Payments.ValueObject;
+using Domain.Entities.Wallets.ValueObject;
 using Microsoft.EntityFrameworkCore;
 using SharedKernel.Results;
 
-namespace Application.Features.Admin.Payment.Danal.Confirm.Cancel;
+namespace Application.Features.Admin.Payment.Toss.Confirm.Cancel;
 
+/// <summary>운영자 강제취소 — 토스 취소 API 로 전체 취소 (Phase 1.5 계약 승계)</summary>
 public sealed class Handler(
     IAppDbContext db,
-    IDanalPayService danalPay,
+    ITossPaymentService tossPay,
     IAppDbContextFactory dbFactory
 ) : ICommandHandler<Command, Result>
 {
     public async Task<Result> Handle(Command request, CancellationToken ct)
     {
-        var confirm = await db.DanalConfirm.FirstOrDefaultAsync(x => x.ID == request.ID, ct);
+        var confirm = await db.TossConfirm.FirstOrDefaultAsync(c => c.ID == request.ID, ct);
         if (confirm is null)
         {
-            return Result.Failure(Error.NotFound("Danal.ConfirmNotFound", "결제 내역을 찾을 수 없습니다."));
+            return Result.Failure(Error.NotFound("Toss.ConfirmNotFound", "결제 내역을 찾을 수 없습니다."));
         }
 
-        var order = await db.PaymentOrder.FirstOrDefaultAsync(o => o.ID == confirm.PaymentOrderID, ct);
+        var order = await db.PaymentOrder.FirstOrDefaultAsync(c => c.ID == confirm.PaymentOrderID, ct);
         if (order is null)
         {
             return Result.Failure(Error.NotFound("Payment.OrderNotFound", "주문을 찾을 수 없습니다."));
         }
 
-        // 진실의 근원은 PaymentOrder.Status — DanalConfirm.ResponseCode 가 비어있거나 "0000" 이 아니어도
-        // PG 측에 실제 결제가 잡혀있다면(=Paid) 운영자 강제 환불이 필요. confirm.IsSuccess 단독 가드는 너무 엄격.
+        // 진실의 근원은 PaymentOrder.Status — TossConfirm 응답이 비정상이어도
+        // PG 측에 실제 결제가 잡혀있다면(=Paid) 운영자 강제 환불이 필요.
         if (order.Status != PaymentStatus.Paid)
         {
             return Result.Failure(Error.Problem("Payment.InvalidStatusForCancel", "취소할 수 없는 주문 상태입니다."));
         }
 
         // 이미 성공 취소된 경우 방지
-        var hasSuccessfulCancel = await db.DanalCancel.AnyAsync(
-            c => c.PaymentOrderID == order.ID && c.ResponseCode == "0000", ct);
+        var hasSuccessfulCancel = await db.TossCancel.AnyAsync(c => c.PaymentOrderID == order.ID && c.ResponseCode == "SUCCESS", ct);
         if (hasSuccessfulCancel)
         {
-            return Result.Failure(Error.Problem("Danal.AlreadyCancelled", "이미 결제가 취소되었습니다."));
+            return Result.Failure(Error.Problem("Toss.AlreadyCancelled", "이미 결제가 취소되었습니다."));
         }
 
+        // paymentKey 확보 — MarkPaid 시 TransactionID 에 저장됨. 없으면 TossConfirm 에서 보강.
+        var paymentKey = string.IsNullOrEmpty(order.TransactionID) ? confirm.PaymentKey : order.TransactionID;
+
         // 환불 가능성 선검증 — PG 취소 성공 후 지갑 회수가 실패하면 PG-지갑 desync 가 되므로
         // 지갑 존재와 충전 잔액(PgCharged)을 PG 호출 전에 확정한다. (API Cancel 핸들러와 동일 패턴)
         var wallet = await db.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct);
@@ -53,58 +58,44 @@ public sealed class Handler(
             return Result.Failure(Error.NotFound("Payment.WalletNotFound", "지갑 정보를 찾을 수 없습니다."));
         }
 
-        var pgCharged = wallet.Balances.Where(b => b.Type == Domain.Entities.Wallets.ValueObject.WalletBalanceType.PgCharged).Sum(b => b.Amount.Value);
+        var pgCharged = wallet.Balances.Where(b => b.Type == WalletBalanceType.PgCharged).Sum(b => b.Amount.Value);
         if (pgCharged < order.PointAmount)
         {
-            return Result.Failure(Error.Problem("Payment.PointAlreadySpent", "회원이 이미 사용한 포인트가 있어 결제를 취소할 수 없습니다. 수동 조정이 필요합니다."));
+            return Result.Failure(Error.Problem("Payment.PointAlreadySpent", "회원이 이미 사용한 캐시가 있어 결제를 취소할 수 없습니다. 수동 조정이 필요합니다."));
         }
 
-        // 다날 결제수단 코드 매핑
-        var methodCode = order.PaymentMethod switch
-        {
-            PaymentMethod.Integrated => "INTEGRATED",
-            PaymentMethod.Card => "CARD",
-            PaymentMethod.VirtualAccount => "VACCOUNT",
-            PaymentMethod.Mobile => "MOBILE",
-            PaymentMethod.Transfer => "TRANSFER",
-            PaymentMethod.NaverPay => "NAVERPAY",
-            PaymentMethod.KakaoPay => "KAKAOPAY",
-            PaymentMethod.Payco => "PAYCO",
-            _ => "CARD"
-        };
+        var cancelReason = string.IsNullOrWhiteSpace(request.CancelReason) ? "운영자 강제취소" : request.CancelReason;
 
         // 취소 요청 기록 저장 (전체 취소)
-        var cancel = DanalCancel.CreateRequest(
-            memberID: order.MemberID,
-            paymentOrderID: order.ID,
-            method: order.PaymentMethod,
-            transactionID: order.TransactionID!,
-            orderID: order.OrderID,
-            amount: order.Amount,
-            merchantID: order.MerchantID,
-            cancelType: DanalCancelType.Full,
-            cancelRequester: request.CancelRequester,
-            cancelReason: request.CancelReason
-        );
-
-        await db.DanalCancel.AddAsync(cancel, ct);
+        var cancel = TossCancel.CreateRequest(order.MemberID, order.ID, paymentKey, order.OrderID, order.Amount, cancelReason, request.CancelRequester ?? "admin");
+
+        await db.TossCancel.AddAsync(cancel, ct);
         await db.SaveChangesAsync(ct);
 
-        // 다날 API 호출
-        var result = await danalPay.CancelAsync(methodCode, order.TransactionID!, order.Amount, "C", ct);
+        // 토스 취소 API 호출 (cancelAmount 미전달 = 전액 취소) — 통신 예외도 기록을 남긴다
+        TossPaymentResult result;
+        try
+        {
+            result = await tossPay.CancelAsync(paymentKey, cancelReason, null, ct);
+        }
+        catch (Exception ex)
+        {
+            cancel.SetError("EXCEPTION", ex.Message);
+
+            await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Error, "EXCEPTION", ex.Message, paymentKey, order.OrderID), ct);
+            await db.SaveChangesAsync(ct);
+
+            return Result.Failure(Error.Problem("Toss.CancelFailed", "결제 취소 처리 중 오류가 발생했습니다."));
+        }
+
         if (!result.Success)
         {
-            cancel.SetResponse(result.Code ?? "", result.Message ?? "취소 실패");
-            await db.DanalLog.AddAsync(DanalLog.Create(
-                order.MemberID,
-                DanalLogType.Failed,
-                result.Code ?? "FAIL",
-                result.Message ?? "취소 실패",
-                order.TransactionID,
-                order.OrderID), ct);
+            cancel.SetError(result.Code ?? "FAIL", result.Message ?? "취소 실패");
+
+            await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Failed, result.Code ?? "FAIL", result.Message ?? "취소 실패", paymentKey, order.OrderID), ct);
             await db.SaveChangesAsync(ct);
 
-            return Result.Failure(Error.Problem("Danal.CancelFailed", result.Message ?? "결제 취소에 실패했습니다."));
+            return Result.Failure(Error.Problem("Toss.CancelFailed", result.Message ?? "결제 취소에 실패했습니다."));
         }
 
         // 취소 응답 저장
@@ -113,7 +104,6 @@ public sealed class Handler(
         order.MarkCancelled();
 
         // 충전분(PgCharged) 회수 — ChargeCancelled 원장 기록으로 출처를 보존한다.
-        // (기존: Adjusted 버킷 차감 → 항상 잔액 0 → 예외 흡수로 환불해도 포인트 미회수 결함)
         // 잔액은 위에서 선검증했으므로 여기서 예외를 흡수하지 않는다.
         // 주문 취소 + 회수는 단일 SaveChanges 원자 커밋 (실패 시 부분 반영 없음)
         wallet.DebitChargeCancel(Money.KRW(order.PointAmount), "결제 취소 환불(운영자)", order.OrderID);
@@ -132,7 +122,7 @@ public sealed class Handler(
             }
             catch (Exception retryEx)
             {
-                await TryWriteReconcileLogAsync(order.ID, order.TransactionID, order.PointAmount, $"ADMIN_CANCEL_PERSIST 재시도 실패: {retryEx.Message} (동시성 충돌: {ex.Message})");
+                await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"ADMIN_CANCEL_PERSIST 재시도 실패: {retryEx.Message} (동시성 충돌: {ex.Message})");
                 throw;
             }
         }
@@ -140,7 +130,7 @@ public sealed class Handler(
         {
             // Phase 1.5: PG 취소는 성공했는데 저장 실패 — 별도 컨텍스트로 대사 로그를 독립 커밋한 뒤
             // 기존 실패 흐름(예외 전파)을 유지한다.
-            await TryWriteReconcileLogAsync(order.ID, order.TransactionID, order.PointAmount, $"ADMIN_CANCEL_PERSIST 실패: {ex.Message}");
+            await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"ADMIN_CANCEL_PERSIST 실패: {ex.Message}");
             throw;
         }
 
@@ -148,25 +138,25 @@ public sealed class Handler(
     }
 
     /// <summary>동시성 충돌 1회 재시도 — 새 컨텍스트로 리로드 후 취소 반영을 재적용한다. (Phase 1.5)</summary>
-    private async Task<Result> RetryCancelPersistAsync(int paymentOrderID, int cancelID, DanalCancelResult result, CancellationToken ct)
+    private async Task<Result> RetryCancelPersistAsync(int paymentOrderID, int cancelID, TossPaymentResult result, CancellationToken ct)
     {
         await using var retryDb = dbFactory.CreateDbContext();
 
-        var order = await retryDb.PaymentOrder.FirstAsync(o => o.ID == paymentOrderID, ct);
+        var order = await retryDb.PaymentOrder.FirstAsync(c => c.ID == paymentOrderID, ct);
         if (order.Status == PaymentStatus.Cancelled)
         {
             // 동시 요청이 이미 취소 반영을 끝낸 경우 — 이중회수 금지, idempotent 성공 응답
             return Result.Success();
         }
 
-        var cancel = await retryDb.DanalCancel.FirstAsync(c => c.ID == cancelID, ct);
+        var cancel = await retryDb.TossCancel.FirstAsync(c => c.ID == cancelID, ct);
         ApplyCancelResponse(cancel, result);
 
         var wallet = await retryDb.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstAsync(w => w.MemberID == order.MemberID, ct);
 
         order.MarkCancelled();
 
-        // 리로드된 실제 잔액 기준 회수 — 충돌 사이에 포인트가 사용됐다면 Decrease 예외 → 호출부가 대사 로그를 남긴다
+        // 리로드된 실제 잔액 기준 회수 — 충돌 사이에 캐시가 사용됐다면 Decrease 예외 → 호출부가 대사 로그를 남긴다
         // (PG 환불은 이미 완료 — 자동 회수 불가, 운영자 수동 대사 대상)
         wallet.DebitChargeCancel(Money.KRW(order.PointAmount), "결제 취소 환불(운영자)", order.OrderID);
 
@@ -176,13 +166,13 @@ public sealed class Handler(
     }
 
     /// <summary>대사 로그 독립 커밋 — best-effort. 로그 실패가 원래 실패 흐름을 가리면 안 된다. (Phase 1.5)</summary>
-    private async Task TryWriteReconcileLogAsync(int paymentOrderID, string? transactionID, int amount, string reason)
+    private async Task TryWriteReconcileLogAsync(int paymentOrderID, string? paymentKey, int amount, string reason)
     {
         try
         {
             // 호출측 취소와 무관하게 반드시 남아야 하므로 CancellationToken.None
             await using var logDb = dbFactory.CreateDbContext();
-            await logDb.PaymentReconcileLog.AddAsync(Domain.Entities.Payments.PaymentReconcileLog.Create(ReconcileKind.AdminCancel, paymentOrderID, transactionID, amount, reason), CancellationToken.None);
+            await logDb.PaymentReconcileLog.AddAsync(PaymentReconcileLog.Create(ReconcileKind.AdminCancel, paymentOrderID, paymentKey, amount, reason), CancellationToken.None);
             await logDb.SaveChangesAsync(CancellationToken.None);
         }
         catch
@@ -191,19 +181,9 @@ public sealed class Handler(
         }
     }
 
-    private static void ApplyCancelResponse(DanalCancel cancel, DanalCancelResult result)
+    private static void ApplyCancelResponse(TossCancel cancel, TossPaymentResult result)
     {
-        // Balance, RemainedAmount는 string? → int? 변환
-        cancel.SetResponse(
-            result.Code ?? "",
-            result.Message ?? "",
-            result.OriginalTransactionID,
-            result.CancelledAmount,
-            result.TransDate,
-            result.TransTime,
-            int.TryParse(result.Balance, out var balanceVal) ? balanceVal : null,
-            int.TryParse(result.RemainedAmount, out var remainedVal) ? remainedVal : null,
-            result.ApprovalDateTime
-        );
+        var last = result.Cancels.Count > 0 ? result.Cancels[^1] : null;
+        cancel.SetResponse(result.Status ?? "", last?.TransactionKey, last?.CanceledAt, result.RawJson);
     }
-}
+}

+ 5 - 0
Application/Features/Admin/Payment/Toss/Confirm/Delete/Command.cs

@@ -0,0 +1,5 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Admin.Payment.Toss.Confirm.Delete;
+
+public sealed record Command(int[] IDs) : ICommand;

+ 3 - 3
Application/Features/Admin/Payment/Danal/Cancel/Delete/Handler.cs → Application/Features/Admin/Payment/Toss/Confirm/Delete/Handler.cs

@@ -2,7 +2,7 @@ using Application.Abstractions.Data;
 using Application.Abstractions.Messaging;
 using Microsoft.EntityFrameworkCore;
 
-namespace Application.Features.Admin.Payment.Danal.Cancel.Delete;
+namespace Application.Features.Admin.Payment.Toss.Confirm.Delete;
 
 public sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
 {
@@ -13,6 +13,6 @@ public sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
             return;
         }
 
-        await db.DanalCancel.Where(x => request.IDs.Contains(x.ID)).ExecuteDeleteAsync(ct);
+        await db.TossConfirm.Where(c => request.IDs.Contains(c.ID)).ExecuteDeleteAsync(ct);
     }
-}
+}

+ 62 - 0
Application/Features/Admin/Payment/Toss/Confirm/Get/Handler.cs

@@ -0,0 +1,62 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Admin.Payment.Toss.Confirm.Get;
+
+public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var confirm = await db.TossConfirm.AsNoTracking().Include(c => c.Member).Include(c => c.PaymentOrder).FirstOrDefaultAsync(c => c.ID == request.ID, ct) ?? throw new InvalidOperationException("토스 결제 내역을 찾을 수 없습니다.");
+
+        var cancel = await db.TossCancel.AsNoTracking()
+            .Where(c => c.PaymentOrderID == confirm.PaymentOrderID && c.ResponseCode == "SUCCESS")
+            .OrderByDescending(c => c.ID)
+            .Select(c => new Response.CancelData
+            {
+                ID = c.ID,
+                CancelAmount = c.CancelAmount,
+                CancelReason = c.CancelReason,
+                CancelRequester = c.CancelRequester,
+                Status = c.Status,
+                TransactionKey = c.TransactionKey,
+                CanceledAt = c.CanceledAt,
+                CreatedAt = c.CreatedAt
+            })
+            .FirstOrDefaultAsync(ct);
+
+        return new Response
+        {
+            Confirm = new Response.ConfirmData
+            {
+                ID = confirm.ID,
+                MemberID = confirm.MemberID,
+                MemberEmail = confirm.Member?.Email ?? string.Empty,
+                MemberName = confirm.Member?.Name,
+                PaymentKey = confirm.PaymentKey,
+                OrderID = confirm.OrderID,
+                Amount = confirm.Amount,
+                MerchantID = confirm.PaymentOrder?.MerchantID ?? "TOSS",
+                OrderStatus = confirm.PaymentOrder != null ? confirm.PaymentOrder.Status : default,
+                OrderPaidAt = confirm.PaymentOrder?.PaidAt,
+                OrderCancelledAt = confirm.PaymentOrder?.CancelledAt,
+                OrderName = confirm.PaymentOrder?.OrderName,
+                ResponseCode = confirm.ResponseCode,
+                ResponseMessage = confirm.ResponseMessage,
+                Status = confirm.Status,
+                Method = confirm.Method,
+                TotalAmount = confirm.TotalAmount,
+                ApprovedAt = confirm.ApprovedAt,
+                ReceiptUrl = confirm.ReceiptUrl,
+                VirtualAccountNumber = confirm.PaymentOrder?.VirtualAccountNumber,
+                VirtualAccountBank = confirm.PaymentOrder?.VirtualAccountBank,
+                VirtualAccountHolder = confirm.PaymentOrder?.VirtualAccountHolder,
+                VirtualAccountExpireAt = confirm.PaymentOrder?.VirtualAccountExpireAt,
+                CreatedAt = confirm.CreatedAt,
+                UpdatedAt = confirm.UpdatedAt
+            },
+            Cancel = cancel
+        };
+    }
+}

+ 5 - 0
Application/Features/Admin/Payment/Toss/Confirm/Get/Query.cs

@@ -0,0 +1,5 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Admin.Payment.Toss.Confirm.Get;
+
+public sealed record Query(int ID) : IQuery<Response>;

+ 50 - 0
Application/Features/Admin/Payment/Toss/Confirm/Get/Response.cs

@@ -0,0 +1,50 @@
+using Domain.Entities.Payments.ValueObject;
+
+namespace Application.Features.Admin.Payment.Toss.Confirm.Get;
+
+public sealed class Response
+{
+    public required ConfirmData Confirm { get; init; }
+    public CancelData? Cancel { get; init; }
+
+    public sealed class ConfirmData
+    {
+        public int ID { get; init; }
+        public int MemberID { get; init; }
+        public required string MemberEmail { get; init; }
+        public string? MemberName { get; init; }
+        public required string PaymentKey { get; init; }
+        public required string OrderID { get; init; }
+        public int Amount { get; init; }
+        public required string MerchantID { get; init; }
+        public PaymentStatus OrderStatus { get; init; }
+        public DateTime? OrderPaidAt { get; init; }
+        public DateTime? OrderCancelledAt { get; init; }
+        public string? OrderName { get; init; }
+        public string? ResponseCode { get; init; }
+        public string? ResponseMessage { get; init; }
+        public string? Status { get; init; }
+        public string? Method { get; init; }
+        public int? TotalAmount { get; init; }
+        public DateTime? ApprovedAt { get; init; }
+        public string? ReceiptUrl { get; init; }
+        public string? VirtualAccountNumber { get; init; }
+        public string? VirtualAccountBank { get; init; }
+        public string? VirtualAccountHolder { get; init; }
+        public DateTime? VirtualAccountExpireAt { get; init; }
+        public required DateTime CreatedAt { get; init; }
+        public DateTime? UpdatedAt { get; init; }
+    }
+
+    public sealed class CancelData
+    {
+        public int ID { get; init; }
+        public int CancelAmount { get; init; }
+        public string? CancelReason { get; init; }
+        public string? CancelRequester { get; init; }
+        public string? Status { get; init; }
+        public string? TransactionKey { get; init; }
+        public DateTime? CanceledAt { get; init; }
+        public required DateTime CreatedAt { get; init; }
+    }
+}

+ 123 - 0
Application/Features/Admin/Payment/Toss/Confirm/Search/Handler.cs

@@ -0,0 +1,123 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Admin.Payment.Toss.Confirm.Search;
+
+public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var query = db.TossConfirm.AsNoTracking().Include(c => c.Member).AsQueryable();
+
+        // 키워드 검색
+        if (!string.IsNullOrWhiteSpace(request.Keyword))
+        {
+            query = request.Search switch
+            {
+                1 => query.Where(c => c.PaymentKey.Contains(request.Keyword)),
+                2 => query.Where(c => c.OrderID.Contains(request.Keyword)),
+                3 => query.Where(c => c.Member != null && c.Member.ID.ToString().Contains(request.Keyword)),
+                4 => query.Where(c => c.Member != null && c.Member.Name != null && c.Member.Name.Contains(request.Keyword)),
+                5 => query.Where(c => c.Member != null && c.Member.SID.Contains(request.Keyword)),
+                _ => query
+            };
+        }
+
+        // 기간 필터
+        if (!string.IsNullOrWhiteSpace(request.StartAt) && DateTime.TryParse(request.StartAt, out var startAt))
+        {
+            query = query.Where(c => c.CreatedAt >= startAt);
+        }
+
+        if (!string.IsNullOrWhiteSpace(request.EndAt) && DateTime.TryParse(request.EndAt, out var endAt))
+        {
+            query = query.Where(c => c.CreatedAt <= endAt);
+        }
+
+        // 탭별 건수 (Status 필터 전)
+        var counts = await query.GroupBy(c => 1).Select(c => new Response.StatusCounts
+        {
+            Done = c.Count(x => x.Status == "DONE"),
+            WaitingDeposit = c.Count(x => x.Status == "WAITING_FOR_DEPOSIT"),
+            Canceled = c.Count(x => x.Status == "CANCELED" || x.Status == "PARTIAL_CANCELED")
+        }).FirstOrDefaultAsync(ct) ?? new Response.StatusCounts();
+
+        if (!string.IsNullOrWhiteSpace(request.Status))
+        {
+            query = request.Status == "CANCELED"
+                ? query.Where(c => c.Status == "CANCELED" || c.Status == "PARTIAL_CANCELED")
+                : query.Where(c => c.Status == request.Status);
+        }
+
+        var total = await query.CountAsync(ct);
+        var skip = (request.Page - 1) * request.PerPage;
+
+        var list = await query
+            .OrderByDescending(c => c.ID)
+            .Skip(skip)
+            .Take(request.PerPage)
+            .Select(c => new
+            {
+                c.ID,
+                c.MemberID,
+                MemberEmail = c.Member != null ? c.Member.Email : string.Empty,
+                MemberName = c.Member != null ? c.Member.Name : null,
+                c.PaymentKey,
+                c.OrderID,
+                c.Amount,
+                c.TotalAmount,
+                c.Status,
+                c.Method,
+                c.ResponseCode,
+                c.ResponseMessage,
+                c.PaymentOrderID,
+                c.CreatedAt
+            })
+            .ToListAsync(ct);
+
+        // 같은 PaymentOrderID로 성공한 TossCancel 있는지 조회
+        var orderIDs = list.Select(c => c.PaymentOrderID).Distinct().ToArray();
+
+        var cancels = await db.TossCancel.AsNoTracking()
+            .Where(c => orderIDs.Contains(c.PaymentOrderID) && c.ResponseCode == "SUCCESS")
+            .Select(c => new { c.PaymentOrderID, c.CancelAmount })
+            .ToListAsync(ct);
+
+        var cancelByOrder = cancels.GroupBy(c => c.PaymentOrderID).ToDictionary(
+            g => g.Key,
+            g => g.First()
+        );
+
+        var rows = list.Select((c, idx) =>
+        {
+            cancelByOrder.TryGetValue(c.PaymentOrderID, out var cancel);
+
+            return new Response.Row
+            {
+                Num = total - skip - idx,
+                ID = c.ID,
+                MemberID = c.MemberID,
+                MemberEmail = c.MemberEmail,
+                MemberName = c.MemberName,
+                PaymentKey = c.PaymentKey,
+                OrderID = c.OrderID,
+                Amount = c.Amount,
+                TotalAmount = c.TotalAmount,
+                Status = c.Status,
+                Method = c.Method,
+                ResponseCode = c.ResponseCode,
+                ResponseMessage = c.ResponseMessage,
+                CancelledAmount = cancel?.CancelAmount,
+                CreatedAt = c.CreatedAt
+            };
+        }).ToList();
+
+        return new Response
+        {
+            Total = total,
+            Counts = counts,
+            List = rows
+        };
+    }
+}

+ 3 - 4
Application/Features/Admin/Payment/Danal/Cancel/Search/Query.cs → Application/Features/Admin/Payment/Toss/Confirm/Search/Query.cs

@@ -1,7 +1,6 @@
 using Application.Abstractions.Messaging;
-using Domain.Entities.Payments.ValueObject;
 
-namespace Application.Features.Admin.Payment.Danal.Cancel.Search;
+namespace Application.Features.Admin.Payment.Toss.Confirm.Search;
 
 public sealed record Query(
     int Page,
@@ -10,5 +9,5 @@ public sealed record Query(
     string? Keyword = null,
     string? StartAt = null,
     string? EndAt = null,
-    PaymentMethod? PayMethod = null
-) : IQuery<Response>;
+    string? Status = null
+) : IQuery<Response>;

+ 10 - 14
Application/Features/Admin/Payment/Danal/Confirm/Search/Response.cs → Application/Features/Admin/Payment/Toss/Confirm/Search/Response.cs

@@ -1,19 +1,15 @@
-using Domain.Entities.Payments.Danal.ValueObject;
-using Domain.Entities.Payments.ValueObject;
-
-namespace Application.Features.Admin.Payment.Danal.Confirm.Search;
+namespace Application.Features.Admin.Payment.Toss.Confirm.Search;
 
 public sealed class Response
 {
     public int Total { get; init; }
-    public MethodCounts Counts { get; init; } = new();
+    public StatusCounts Counts { get; init; } = new();
 
-    public sealed class MethodCounts
+    public sealed class StatusCounts
     {
-        public int Card { get; init; }
-        public int VirtualAccount { get; init; }
-        public int Mobile { get; init; }
-        public int Transfer { get; init; }
+        public int Done { get; init; }
+        public int WaitingDeposit { get; init; }
+        public int Canceled { get; init; }
     }
 
     public required IReadOnlyList<Row> List { get; init; }
@@ -25,15 +21,15 @@ public sealed class Response
         public int MemberID { get; init; }
         public required string MemberEmail { get; init; }
         public string? MemberName { get; init; }
-        public PaymentMethod Method { get; init; }
-        public required string TransactionID { get; init; }
+        public required string PaymentKey { get; init; }
         public required string OrderID { get; init; }
         public int Amount { get; init; }
         public int? TotalAmount { get; init; }
+        public string? Status { get; init; }
+        public string? Method { get; init; }
         public string? ResponseCode { get; init; }
         public string? ResponseMessage { get; init; }
-        public DanalCancelType? CancelType { get; init; }
         public int? CancelledAmount { get; init; }
         public required DateTime CreatedAt { get; init; }
     }
-}
+}

+ 5 - 0
Application/Features/Admin/Payment/Toss/Log/Delete/Command.cs

@@ -0,0 +1,5 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Admin.Payment.Toss.Log.Delete;
+
+public sealed record Command(int[] IDs) : ICommand;

+ 3 - 3
Application/Features/Admin/Payment/Danal/Log/Delete/Handler.cs → Application/Features/Admin/Payment/Toss/Log/Delete/Handler.cs

@@ -2,7 +2,7 @@ using Application.Abstractions.Data;
 using Application.Abstractions.Messaging;
 using Microsoft.EntityFrameworkCore;
 
-namespace Application.Features.Admin.Payment.Danal.Log.Delete;
+namespace Application.Features.Admin.Payment.Toss.Log.Delete;
 
 public sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
 {
@@ -13,6 +13,6 @@ public sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
             return;
         }
 
-        await db.DanalLog.Where(x => request.IDs.Contains(x.ID)).ExecuteDeleteAsync(ct);
+        await db.TossLog.Where(c => request.IDs.Contains(c.ID)).ExecuteDeleteAsync(ct);
     }
-}
+}

+ 94 - 0
Application/Features/Admin/Payment/Toss/Log/Search/Handler.cs

@@ -0,0 +1,94 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Domain.Entities.Payments.Toss.ValueObject;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Admin.Payment.Toss.Log.Search;
+
+public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var query = db.TossLog.AsNoTracking().Include(c => c.Member).AsQueryable();
+
+        if (!string.IsNullOrWhiteSpace(request.Keyword))
+        {
+            query = request.Search switch
+            {
+                1 => query.Where(c => c.PaymentKey != null && c.PaymentKey.Contains(request.Keyword)),
+                2 => query.Where(c => c.OrderID != null && c.OrderID.Contains(request.Keyword)),
+                3 => query.Where(c => c.Member != null && c.Member.ID.ToString().Contains(request.Keyword)),
+                4 => query.Where(c => c.Member != null && c.Member.Name != null && c.Member.Name.Contains(request.Keyword)),
+                5 => query.Where(c => c.Member != null && c.Member.SID.Contains(request.Keyword)),
+                _ => query
+            };
+        }
+
+        if (!string.IsNullOrWhiteSpace(request.StartAt) && DateTime.TryParse(request.StartAt, out var startAt))
+        {
+            query = query.Where(c => c.CreatedAt >= startAt);
+        }
+
+        if (!string.IsNullOrWhiteSpace(request.EndAt) && DateTime.TryParse(request.EndAt, out var endAt))
+        {
+            query = query.Where(c => c.CreatedAt <= endAt);
+        }
+
+        var counts = await query.GroupBy(c => 1).Select(c => new Response.TypeCounts
+        {
+            Failed = c.Count(x => x.LogType == TossLogType.Failed),
+            Error = c.Count(x => x.LogType == TossLogType.Error),
+            Webhook = c.Count(x => x.LogType == TossLogType.Webhook),
+            Reconcile = c.Count(x => x.LogType == TossLogType.Reconcile)
+        }).FirstOrDefaultAsync(ct) ?? new Response.TypeCounts();
+
+        if (request.LogType.HasValue)
+        {
+            query = query.Where(c => c.LogType == request.LogType.Value);
+        }
+
+        var total = await query.CountAsync(ct);
+        var skip = (request.Page - 1) * request.PerPage;
+
+        var list = await query
+            .OrderByDescending(c => c.ID)
+            .Skip(skip)
+            .Take(request.PerPage)
+            .Select(c => new
+            {
+                c.ID,
+                c.MemberID,
+                MemberEmail = c.Member != null ? c.Member.Email : null,
+                MemberName = c.Member != null ? c.Member.Name : null,
+                c.LogType,
+                c.Code,
+                c.Message,
+                c.PaymentKey,
+                c.OrderID,
+                c.CreatedAt
+            })
+            .ToListAsync(ct);
+
+        var rows = list.Select((c, idx) => new Response.Row
+        {
+            Num = total - skip - idx,
+            ID = c.ID,
+            MemberID = c.MemberID,
+            MemberEmail = c.MemberEmail,
+            MemberName = c.MemberName,
+            LogType = c.LogType,
+            Code = c.Code,
+            Message = c.Message,
+            PaymentKey = c.PaymentKey,
+            OrderID = c.OrderID,
+            CreatedAt = c.CreatedAt
+        }).ToList();
+
+        return new Response
+        {
+            Total = total,
+            Counts = counts,
+            List = rows
+        };
+    }
+}

+ 4 - 4
Application/Features/Admin/Payment/Danal/Log/Search/Query.cs → Application/Features/Admin/Payment/Toss/Log/Search/Query.cs

@@ -1,7 +1,7 @@
 using Application.Abstractions.Messaging;
-using Domain.Entities.Payments.Danal.ValueObject;
+using Domain.Entities.Payments.Toss.ValueObject;
 
-namespace Application.Features.Admin.Payment.Danal.Log.Search;
+namespace Application.Features.Admin.Payment.Toss.Log.Search;
 
 public sealed record Query(
     int Page,
@@ -10,5 +10,5 @@ public sealed record Query(
     string? Keyword = null,
     string? StartAt = null,
     string? EndAt = null,
-    DanalLogType? LogType = null
-) : IQuery<Response>;
+    TossLogType? LogType = null
+) : IQuery<Response>;

+ 7 - 6
Application/Features/Admin/Payment/Danal/Log/Search/Response.cs → Application/Features/Admin/Payment/Toss/Log/Search/Response.cs

@@ -1,6 +1,6 @@
-using Domain.Entities.Payments.Danal.ValueObject;
+using Domain.Entities.Payments.Toss.ValueObject;
 
-namespace Application.Features.Admin.Payment.Danal.Log.Search;
+namespace Application.Features.Admin.Payment.Toss.Log.Search;
 
 public sealed class Response
 {
@@ -12,6 +12,7 @@ public sealed class Response
         public int Failed { get; init; }
         public int Error { get; init; }
         public int Webhook { get; init; }
+        public int Reconcile { get; init; }
     }
 
     public required IReadOnlyList<Row> List { get; init; }
@@ -20,14 +21,14 @@ public sealed class Response
     {
         public int Num { get; init; }
         public int ID { get; init; }
-        public int MemberID { get; init; }
+        public int? MemberID { get; init; }
         public string? MemberEmail { get; init; }
         public string? MemberName { get; init; }
-        public DanalLogType LogType { get; init; }
+        public TossLogType LogType { get; init; }
         public required string Code { get; init; }
         public required string Message { get; init; }
-        public string? TransactionID { get; init; }
+        public string? PaymentKey { get; init; }
         public string? OrderID { get; init; }
         public required DateTime CreatedAt { get; init; }
     }
-}
+}

+ 42 - 54
Application/Features/Admin/Store/Order/ProcessRefund/Handler.cs

@@ -2,8 +2,7 @@ using Application.Abstractions.Data;
 using Application.Abstractions.Messaging;
 using Application.Abstractions.Payment;
 using Domain.Entities.Common.ValueObject;
-using Domain.Entities.Payments.Danal;
-using Domain.Entities.Payments.Danal.ValueObject;
+using Domain.Entities.Payments.Toss;
 using Domain.Entities.Store.ValueObject;
 using Domain.Entities.Wallets.ValueObject;
 using Microsoft.EntityFrameworkCore;
@@ -14,8 +13,8 @@ namespace Application.Features.Admin.Store.Order.ProcessRefund;
 /// <summary>
 /// 환불 실행 흐름 (사용자 정책):
 ///   1. 사전 검증 (Digital 미사용 쿠폰 가치 합)
-///   2. 다날 부분 취소 호출 — 실패 시 OrderRefund.Reject + 종료
-///   3. DanalCancel 행 기록
+///   2. 토스 부분 취소 호출 — 실패 시 OrderRefund.Reject + 종료
+///   3. TossCancel 행 기록
 ///   4. OrderRefund.Approve
 ///   5. 구매자 환원: 원본 OrderPay 트랜잭션의 BalanceType 별로 비례 분배해 출처 복원
 ///   7. GameSettlement 비례 차감
@@ -26,7 +25,7 @@ namespace Application.Features.Admin.Store.Order.ProcessRefund;
 ///   9. Physical 상품: 재고 복구
 ///  10. Order.Status 갱신 (Cancelled/Refunded/Exchanged), refund.Complete
 /// </summary>
-public sealed class Handler(IAppDbContext db, IDanalPayService danal) : ICommandHandler<Command, Result>
+public sealed class Handler(IAppDbContext db, ITossPaymentService tossPay) : ICommandHandler<Command, Result>
 {
     public async Task<Result> Handle(Command request, CancellationToken ct)
     {
@@ -71,70 +70,59 @@ public sealed class Handler(IAppDbContext db, IDanalPayService danal) : ICommand
             return Result.Failure(Error.Problem("Refund.InsufficientUnusedCoupons", $"환불 금액({refund.Amount:N0}원)에 대응하는 미사용 쿠폰 코드 가치({digitalUnusedValue:N0}원)가 부족합니다. 일부 쿠폰이 이미 사용되었습니다."));
         }
 
-        // 2. 다날 부분 취소 — PaymentOrder + DanalConfirm 조회 후 호출
+        // 2. 토스 부분/전체 취소 — PaymentOrder 조회 후 호출 (paymentKey = TransactionID)
         var paymentOrder = await db.PaymentOrder
             .Where(p => p.OrderID == order.OrderNumber && p.TransactionID != null)
             .OrderByDescending(p => p.ID)
             .FirstOrDefaultAsync(ct);
 
-        DanalCancel? danalCancel = null;
-
         if (paymentOrder is not null && !string.IsNullOrEmpty(paymentOrder.TransactionID))
         {
-            // 다날 잔액 사전 확인은 생략 (다날 응답이 RemainedAmount 반환)
-            var methodCode = ((int)paymentOrder.PaymentMethod).ToString();   // 다날 API method 파라미터 (실제 매핑은 DanalPayService 내부)
-            var methodEnum = paymentOrder.PaymentMethod switch
-            {
-                Domain.Entities.Payments.ValueObject.PaymentMethod.Card => "card",
-                Domain.Entities.Payments.ValueObject.PaymentMethod.VirtualAccount => "vbank",
-                Domain.Entities.Payments.ValueObject.PaymentMethod.Mobile => "mobile",
-                Domain.Entities.Payments.ValueObject.PaymentMethod.Transfer => "transfer",
-                Domain.Entities.Payments.ValueObject.PaymentMethod.NaverPay => "naverpay",
-                Domain.Entities.Payments.ValueObject.PaymentMethod.KakaoPay => "kakaopay",
-                Domain.Entities.Payments.ValueObject.PaymentMethod.Payco => "payco",
-                _ => "card"
-            };
-
-            var cancelType = refund.Amount >= paymentOrder.Amount ? "FULL" : "PARTIAL";
-            var cancelResult = await danal.CancelAsync(methodEnum, paymentOrder.TransactionID, refund.Amount, cancelType, ct);
-
-            if (!cancelResult.Success)
-            {
-                // 다날 실패 → 환불 요청 거부 처리. 운영자 메모에 다날 응답 기록.
-                refund.Reject(request.AdminUserId, request.AdminEmail, $"[다날 부분취소 실패] code={cancelResult.Code} message={cancelResult.Message}");
-                await db.SaveChangesAsync(ct);
-                return Result.Failure(Error.Problem("Refund.DanalCancelFailed", $"다날 부분 취소 실패: {cancelResult.Message ?? cancelResult.Code ?? "ERROR"}"));
-            }
+            var paymentKey = paymentOrder.TransactionID;
+            var isFullCancel = refund.Amount >= paymentOrder.Amount;
+            var cancelReason = string.IsNullOrWhiteSpace(refund.Reason) ? "주문 환불" : refund.Reason;
 
-            // 3. DanalCancel 행 기록
-            danalCancel = DanalCancel.CreateRequest(
+            // 취소 요청 기록 저장 (부분/전체) — 응답 전에 요청 흔적을 남긴다
+            var tossCancel = TossCancel.CreateRequest(
                 memberID: order.MemberID,
                 paymentOrderID: paymentOrder.ID,
-                method: paymentOrder.PaymentMethod,
-                transactionID: paymentOrder.TransactionID,
+                paymentKey: paymentKey,
                 orderID: order.OrderNumber,
-                amount: refund.Amount,
-                merchantID: paymentOrder.MerchantID,
-                cancelType: cancelType == "FULL" ? DanalCancelType.Full : DanalCancelType.Partial,
-                cancelRequester: request.AdminEmail ?? request.AdminUserId,
-                cancelReason: refund.Reason
+                cancelAmount: refund.Amount,
+                cancelReason: cancelReason,
+                cancelRequester: request.AdminEmail ?? request.AdminUserId
             );
 
-            danalCancel.SetResponse(
-                code: cancelResult.Code ?? "SUCCESS",
-                message: cancelResult.Message ?? "OK",
-                originalTransactionID: cancelResult.OriginalTransactionID,
-                cancelledAmount: cancelResult.CancelledAmount,
-                transDate: cancelResult.TransDate,
-                transTime: cancelResult.TransTime,
-                balance: int.TryParse(cancelResult.Balance, out var bal) ? bal : null,
-                remainedAmount: int.TryParse(cancelResult.RemainedAmount, out var rem) ? rem : null,
-                approvalDateTime: cancelResult.ApprovalDateTime
-            );
+            db.TossCancel.Add(tossCancel);
+
+            // 토스 취소 API 호출 (cancelAmount null = 전액 취소) — 통신 예외도 기록을 남긴다
+            TossPaymentResult cancelResult;
+            try
+            {
+                cancelResult = await tossPay.CancelAsync(paymentKey, cancelReason, isFullCancel ? null : refund.Amount, ct);
+            }
+            catch (Exception ex)
+            {
+                tossCancel.SetError("EXCEPTION", ex.Message);
+                refund.Reject(request.AdminUserId, request.AdminEmail, $"[토스 취소 통신 오류] {ex.Message}");
+                await db.SaveChangesAsync(ct);
+                return Result.Failure(Error.Problem("Refund.TossCancelFailed", "결제 취소 처리 중 오류가 발생했습니다."));
+            }
+
+            if (!cancelResult.Success)
+            {
+                // 토스 실패 → 환불 요청 거부 처리. 운영자 메모에 토스 응답 기록.
+                tossCancel.SetError(cancelResult.Code ?? "FAIL", cancelResult.Message ?? "취소 실패");
+                refund.Reject(request.AdminUserId, request.AdminEmail, $"[토스 취소 실패] code={cancelResult.Code} message={cancelResult.Message}");
+                await db.SaveChangesAsync(ct);
+                return Result.Failure(Error.Problem("Refund.TossCancelFailed", $"토스 취소 실패: {cancelResult.Message ?? cancelResult.Code ?? "ERROR"}"));
+            }
 
-            db.DanalCancel.Add(danalCancel);
+            // 3. TossCancel 응답 기록
+            var last = cancelResult.Cancels.Count > 0 ? cancelResult.Cancels[^1] : null;
+            tossCancel.SetResponse(cancelResult.Status ?? "", last?.TransactionKey, last?.CanceledAt, cancelResult.RawJson);
         }
-        // PaymentOrder 가 없으면 (포인트만 결제 / 옛 주문) 다날 호출 skip
+        // PaymentOrder 가 없으면 (캐시만 결제 / 옛 주문) 토스 호출 skip
 
         // 4. OrderRefund 승인
         refund.Approve(request.AdminUserId, request.AdminEmail, request.AdminMemo);

+ 4 - 0
Application/Features/Api/Auth/GoogleLogin/Handler.cs

@@ -86,6 +86,10 @@ internal sealed class Handler(
 
             await db.SaveChangesAsync(ct);
 
+            // 가입 축하 보상 (멱등 — RefID "signup:{memberID}")
+            await SignupRewardGranter.GrantAsync(db, cache, member.ID, ct);
+            await db.SaveChangesAsync(ct);
+
             logger.LogInformation("Google 자동 회원가입: {Email}", email);
         }
 

+ 4 - 0
Application/Features/Api/Auth/Register/Handler.cs

@@ -116,6 +116,10 @@ internal sealed class Handler(
             );
 
             await db.SaveChangesAsync(ct);
+
+            // 가입 축하 보상 (멱등 — RefID "signup:{memberID}")
+            await SignupRewardGranter.GrantAsync(db, cache, member.ID, ct);
+            await db.SaveChangesAsync(ct);
         }
         else
         {

+ 4 - 0
Application/Features/Api/Auth/SocialLogin/Handler.cs

@@ -141,6 +141,10 @@ internal sealed class Handler(
 
             await db.SaveChangesAsync(ct);
 
+            // 가입 축하 보상 (멱등 — RefID "signup:{memberID}")
+            await SignupRewardGranter.GrantAsync(db, cache, member.ID, ct);
+            await db.SaveChangesAsync(ct);
+
             logger.LogInformation("{Provider} 자동 회원가입: {Email}", providerName, email);
         }
         else

+ 0 - 9
Application/Features/Api/Payment/Cancel/Command.cs

@@ -1,9 +0,0 @@
-using Application.Abstractions.Messaging;
-using SharedKernel.Results;
-
-namespace Application.Features.Api.Payment.Cancel;
-
-public sealed record Command(
-    string OrderID,
-    int MemberID = 0
-) : ICommand<Result>;

+ 0 - 10
Application/Features/Api/Payment/Confirm/Command.cs

@@ -1,10 +0,0 @@
-using Application.Abstractions.Messaging;
-
-namespace Application.Features.Api.Payment.Confirm;
-
-public sealed record Command(
-    string OrderID,
-    string TransactionID,
-    string Method,
-    int MemberID
-) : ICommand<Response>;

+ 0 - 213
Application/Features/Api/Payment/Confirm/Handler.cs

@@ -1,213 +0,0 @@
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Application.Abstractions.Payment;
-using Domain.Entities.Common.ValueObject;
-using Domain.Entities.Payments;
-using Domain.Entities.Payments.Danal;
-using Domain.Entities.Payments.Danal.ValueObject;
-using Domain.Entities.Payments.ValueObject;
-using Domain.Entities.Wallets;
-using Microsoft.EntityFrameworkCore;
-
-namespace Application.Features.Api.Payment.Confirm;
-
-internal sealed class Handler(
-    IAppDbContext db,
-    IDanalPayService danalPay,
-    IAppDbContextFactory dbFactory
-) : ICommandHandler<Command, Response>
-{
-    public async Task<Response> Handle(Command r, CancellationToken ct)
-    {
-        // 주문 조회
-        var order = await db.PaymentOrder.FirstOrDefaultAsync(o => o.OrderID == r.OrderID, ct);
-        if (order is null)
-        {
-            return new Response(false, 0, "주문을 찾을 수 없습니다.", null);
-        }
-
-        // 이미 결제 완료된 주문인 경우 중복 승인 방지 (회원 검증 이후로 미룰 수 없음 — idempotent 응답)
-        if (order.Status == PaymentStatus.Paid && order.MemberID == r.MemberID)
-        {
-            return new Response(true, order.PointAmount, "이미 충전이 완료된 주문입니다.", order.PaidAt);
-        }
-
-        // 회원 검증 — 불일치 시 주문 상태를 건드리지 않는다.
-        // (MarkFailed 로 남의 Pending 주문을 오염시켜 이후 정상 승인을 MarkPaid 크래시로 유도하는 공격 차단)
-        if (order.MemberID != r.MemberID)
-        {
-            return new Response(false, 0, "주문 정보가 일치하지 않습니다.", null);
-        }
-
-        // 승인 가능 상태 가드 — Pending/WaitingDeposit 승인 진행. Failed/Cancelled 재진입은 PG 재호출 후
-        // MarkPaid 예외(승인금 유실)를 유발하므로 여기서 차단 (실패 주문은 새 주문으로 재시도).
-        // NeedsReconciliation(승인 통신 불명)은 재확인(재승인) 경로로 허용한다. (Phase 1.5)
-        if (order.Status is not PaymentStatus.Pending and not PaymentStatus.WaitingDeposit and not PaymentStatus.NeedsReconciliation)
-        {
-            return new Response(false, 0, "승인할 수 없는 주문 상태입니다.", null);
-        }
-
-        // 승인 요청 기록 저장 — DanalConfirm 은 TransactionID/OrderID unique 라 주문당 1행.
-        // 재확인(NeedsReconciliation, 또는 저장 실패 후 재시도)이면 기존 행을 재사용해 인덱스 충돌을 피한다. (Phase 1.5)
-        var confirm = await db.DanalConfirm.FirstOrDefaultAsync(c => c.PaymentOrderID == order.ID, ct);
-        if (confirm is null)
-        {
-            confirm = DanalConfirm.CreateRequest(r.MemberID, order.ID, order.PaymentMethod, r.TransactionID, r.OrderID, order.Amount, order.MerchantID);
-            await db.DanalConfirm.AddAsync(confirm, ct);
-        }
-        else
-        {
-            confirm.PrepareRetry(r.TransactionID);
-        }
-
-        await db.SaveChangesAsync(ct);
-
-        // 다날 승인 API 호출
-        DanalConfirmResult result;
-        try
-        {
-            result = await danalPay.ConfirmAsync(r.Method, r.TransactionID, r.OrderID, order.Amount, ct);
-        }
-        catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
-        {
-            // 통신 불명(타임아웃 등) — 다날이 실제 승인했을 수 있어 Failed 로 단정하지 않고 대사 대상으로 표시. (Phase 1.5)
-            // 호출측 취소와 무관하게 상태가 반드시 남아야 하므로 CancellationToken.None 으로 저장한다.
-            confirm.SetResponse("EXCEPTION", ex.Message);
-            order.MarkNeedsReconciliation("승인 통신 불명(타임아웃)");
-
-            await db.DanalLog.AddAsync(DanalLog.Create(r.MemberID, DanalLogType.Error, "EXCEPTION", ex.Message, r.TransactionID, r.OrderID), CancellationToken.None);
-            await db.SaveChangesAsync(CancellationToken.None);
-
-            return new Response(false, 0, "결제 승인 확인이 지연되고 있습니다. 잠시 후 다시 시도해주세요.", null);
-        }
-        catch (Exception ex)
-        {
-            // 통신 이외의 예외(설정 오류 등)는 기존대로 Failed — Pending 방치를 막는다
-            confirm.SetResponse("EXCEPTION", ex.Message);
-            order.MarkFailed("승인 통신 오류");
-
-            await db.DanalLog.AddAsync(DanalLog.Create(r.MemberID, DanalLogType.Error, "EXCEPTION", ex.Message, r.TransactionID, r.OrderID), ct);
-            await db.SaveChangesAsync(ct);
-
-            return new Response(false, 0, "결제 처리 중 오류가 발생했습니다.", null);
-        }
-
-        if (!result.Success)
-        {
-            // PG 의 명시적 거절 — 불명이 아니므로 기존대로 Failed
-            confirm.SetResponse(result.Code ?? "", result.Message ?? "승인 실패");
-            order.MarkFailed(result.Message ?? "승인 실패");
-
-            await db.DanalLog.AddAsync(DanalLog.Create(r.MemberID, DanalLogType.Failed, result.Code ?? "FAIL", result.Message ?? "승인 실패", r.TransactionID, r.OrderID), ct);
-            await db.SaveChangesAsync(ct);
-
-            return new Response(false, 0, result.Message ?? "결제 승인에 실패했습니다.", null);
-        }
-
-        // 응답 저장
-        ApplyConfirmResponse(confirm, result);
-
-        // 지갑에 포인트 충전 — 지갑이 없으면 생성해 승인 결과 유실을 막는다 (PG 는 이미 승인됨)
-        var wallet = await db.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct);
-        if (wallet is null)
-        {
-            wallet = Wallet.Create(order.MemberID);
-            await db.Wallet.AddAsync(wallet, ct);
-        }
-
-        wallet.CreditPgCharge(Money.KRW(order.PointAmount), $"포인트 충전 ({order.PaymentMethod})", order.OrderID);
-
-        // 주문 상태 갱신 — 지갑 적립(CreditPgCharge)과 단일 SaveChanges 로 원자 커밋 (실패 시 부분 반영 없음)
-        order.MarkPaid(r.TransactionID);
-
-        try
-        {
-            await db.SaveChangesAsync(ct);
-        }
-        catch (DbUpdateConcurrencyException ex)
-        {
-            // Phase 1.5: WalletBalance rowversion 동시성 충돌 — 오염된 컨텍스트는 버리고(커밋된 것 없음)
-            // 새 컨텍스트로 엔티티를 리로드해 도메인 연산을 1회 재적용한다.
-            // 첫 시도의 원장 기록은 미영속이므로 재적용해도 이중적립이 없다.
-            try
-            {
-                return await RetryConfirmPersistAsync(order.ID, confirm.ID, r.TransactionID, result, ct);
-            }
-            catch (Exception retryEx)
-            {
-                await TryWriteReconcileLogAsync(order.ID, r.TransactionID, order.PointAmount, $"CONFIRM_PERSIST 재시도 실패: {retryEx.Message} (동시성 충돌: {ex.Message})");
-                throw;
-            }
-        }
-        catch (Exception ex)
-        {
-            // Phase 1.5: PG 승인은 성공했는데 저장 실패 — 별도 컨텍스트로 대사 로그를 독립 커밋한 뒤
-            // 기존 실패 흐름(예외 전파)을 유지한다.
-            await TryWriteReconcileLogAsync(order.ID, r.TransactionID, order.PointAmount, $"CONFIRM_PERSIST 실패: {ex.Message}");
-            throw;
-        }
-
-        return new Response(true, order.PointAmount, "충전이 완료되었습니다.", order.PaidAt);
-    }
-
-    /// <summary>동시성 충돌 1회 재시도 — 새 컨텍스트로 리로드 후 승인 반영을 재적용한다. (Phase 1.5)</summary>
-    private async Task<Response> RetryConfirmPersistAsync(int paymentOrderID, int confirmID, string transactionID, DanalConfirmResult result, CancellationToken ct)
-    {
-        await using var retryDb = dbFactory.CreateDbContext();
-
-        var order = await retryDb.PaymentOrder.FirstAsync(o => o.ID == paymentOrderID, ct);
-        if (order.Status == PaymentStatus.Paid)
-        {
-            // 동시 요청이 이미 승인 반영을 끝낸 경우 — 이중적립 금지, idempotent 성공 응답
-            return new Response(true, order.PointAmount, "이미 충전이 완료된 주문입니다.", order.PaidAt);
-        }
-
-        var confirm = await retryDb.DanalConfirm.FirstAsync(c => c.ID == confirmID, ct);
-        ApplyConfirmResponse(confirm, result);
-
-        var wallet = await retryDb.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct);
-        if (wallet is null)
-        {
-            wallet = Wallet.Create(order.MemberID);
-            await retryDb.Wallet.AddAsync(wallet, ct);
-        }
-
-        wallet.CreditPgCharge(Money.KRW(order.PointAmount), $"포인트 충전 ({order.PaymentMethod})", order.OrderID);
-        order.MarkPaid(transactionID);
-
-        await retryDb.SaveChangesAsync(ct);
-
-        return new Response(true, order.PointAmount, "충전이 완료되었습니다.", order.PaidAt);
-    }
-
-    /// <summary>대사 로그 독립 커밋 — best-effort. 로그 실패가 원래 실패 흐름을 가리면 안 된다. (Phase 1.5)</summary>
-    private async Task TryWriteReconcileLogAsync(int paymentOrderID, string? transactionID, int amount, string reason)
-    {
-        try
-        {
-            // 호출측 취소와 무관하게 반드시 남아야 하므로 CancellationToken.None
-            await using var logDb = dbFactory.CreateDbContext();
-            await logDb.PaymentReconcileLog.AddAsync(PaymentReconcileLog.Create(ReconcileKind.Confirm, paymentOrderID, transactionID, amount, reason), CancellationToken.None);
-            await logDb.SaveChangesAsync(CancellationToken.None);
-        }
-        catch
-        {
-            // best-effort — 원래 예외를 유지한다
-        }
-    }
-
-    private static void ApplyConfirmResponse(DanalConfirm confirm, DanalConfirmResult result)
-    {
-        confirm.SetResponse(
-            result.Code ?? "", result.Message ?? "",
-            result.OrderName, result.TotalAmount, result.DiscountAmount, result.UserName,
-            result.TransDate, result.TransTime,
-            result.CardCode, result.CardName, result.CardNo,
-            result.InstallmentMonths, result.ApproveNo,
-            result.ApprovalDateTime, result.AuthKey,
-            result.AccountNumber, result.BankCode, result.UserId, result.UserEmail,
-            result.BankName, result.ExpireDate, result.ExpireTime,
-            result.VirtualAccountNumber, result.UseCashReceipt
-        );
-    }
-}

+ 0 - 8
Application/Features/Api/Payment/Confirm/Response.cs

@@ -1,8 +0,0 @@
-namespace Application.Features.Api.Payment.Confirm;
-
-public sealed record Response(
-    bool Success,
-    int PointAmount,
-    string? Message,
-    DateTime? PaidAt
-);

+ 0 - 14
Application/Features/Api/Payment/CreateOrder/Command.cs

@@ -1,14 +0,0 @@
-using Application.Abstractions.Messaging;
-using Domain.Entities.Payments.ValueObject;
-
-namespace Application.Features.Api.Payment.CreateOrder;
-
-/// <summary>
-/// 포인트 충전 주문 생성. <see cref="Amount"/>는 사용자가 충전할 포인트(VAT 제외).
-/// PG 결제 총액은 Handler 에서 Amount + VAT(10%)로 계산됨.
-/// </summary>
-public sealed record Command(
-    int Amount,
-    PaymentMethod PaymentMethod,
-    int MemberID = 0
-) : IQuery<Response>;

+ 0 - 44
Application/Features/Api/Payment/CreateOrder/Handler.cs

@@ -1,44 +0,0 @@
-using Application.Abstractions.Data;
-using Application.Abstractions.Messaging;
-using Application.Abstractions.Payment;
-using Domain.Entities.Payments;
-using Microsoft.Extensions.Options;
-using SharedKernel;
-
-namespace Application.Features.Api.Payment.CreateOrder;
-
-internal sealed class Handler(
-    IAppDbContext db,
-    IDanalPayService danalPay,
-    IOptions<AppSettings> options
-) : IQueryHandler<Command, Response>
-{
-    public async Task<Response> Handle(Command r, CancellationToken ct)
-    {
-        var settings = options.Value;
-        var baseUrl = settings.App.FrontURL?.TrimEnd('/') ?? settings.App.BaseURL?.TrimEnd('/') ?? "https://localhost:3000";
-
-        // 주문번호 생성
-        var orderID = $"ANTOOZA-{DateTime.UtcNow:yyyyMMddHHmmss}-{r.MemberID}-{Random.Shared.Next(1000, 9999)}";
-
-        // 다날 클라이언트 설정 (CPID 획득)
-        var config = await danalPay.GetClientConfigAsync(ct);
-
-        // 엔티티 생성
-        var order = PaymentOrder.Create(r.MemberID, orderID, config.MerchantID, r.Amount, r.PaymentMethod);
-
-        await db.PaymentOrder.AddAsync(order);
-        await db.SaveChangesAsync(ct);
-
-        return new Response(
-            orderID,
-            config.ClientKey,
-            config.MerchantID,
-            order.Amount,
-            order.PointAmount,
-            order.VatAmount,
-            $"{baseUrl}/api/payment/success?orderId={orderID}",
-            $"{baseUrl}/api/payment/fail?orderId={orderID}"
-        );
-    }
-}

+ 11 - 0
Application/Features/Api/Payment/Toss/Cancel/Command.cs

@@ -0,0 +1,11 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.Payment.Toss.Cancel;
+
+/// <summary>토스 결제 전액 취소 — Toss 취소 API + 지갑 충전분(PgCharged) 회수</summary>
+public sealed record Command(
+    string OrderID,
+    string? CancelReason = null,
+    int MemberID = 0
+) : ICommand<Result>;

+ 45 - 51
Application/Features/Api/Payment/Cancel/Handler.cs → Application/Features/Api/Payment/Toss/Cancel/Handler.cs

@@ -1,25 +1,26 @@
 using Application.Abstractions.Data;
 using Application.Abstractions.Messaging;
 using Application.Abstractions.Payment;
-using SharedKernel.Results;
 using Domain.Entities.Common.ValueObject;
-using Domain.Entities.Payments.Danal;
-using Domain.Entities.Payments.Danal.ValueObject;
+using Domain.Entities.Payments;
+using Domain.Entities.Payments.Toss;
+using Domain.Entities.Payments.Toss.ValueObject;
 using Domain.Entities.Payments.ValueObject;
 using Domain.Entities.Wallets.ValueObject;
 using Microsoft.EntityFrameworkCore;
+using SharedKernel.Results;
 
-namespace Application.Features.Api.Payment.Cancel;
+namespace Application.Features.Api.Payment.Toss.Cancel;
 
 internal sealed class Handler(
     IAppDbContext db,
-    IDanalPayService danalPay,
+    ITossPaymentService tossPay,
     IAppDbContextFactory dbFactory
 ) : ICommandHandler<Command, Result>
 {
     public async Task<Result> Handle(Command r, CancellationToken ct)
     {
-        var order = await db.PaymentOrder.FirstOrDefaultAsync(o => o.OrderID == r.OrderID && o.MemberID == r.MemberID, ct);
+        var order = await db.PaymentOrder.FirstOrDefaultAsync(c => c.OrderID == r.OrderID && c.MemberID == r.MemberID, ct);
         if (order is null)
         {
             return Result.Failure(Error.NotFound("Payment.OrderNotFound", "주문을 찾을 수 없습니다."));
@@ -30,6 +31,19 @@ internal sealed class Handler(
             return Result.Failure(Error.Problem("Payment.InvalidStatusForCancel", "취소할 수 없는 주문 상태입니다."));
         }
 
+        // paymentKey 확보 — MarkPaid 시 TransactionID 에 저장됨. 없으면 TossConfirm 에서 보강.
+        var paymentKey = order.TransactionID;
+        if (string.IsNullOrEmpty(paymentKey))
+        {
+            var confirm = await db.TossConfirm.FirstOrDefaultAsync(c => c.PaymentOrderID == order.ID, ct);
+            paymentKey = confirm?.PaymentKey;
+        }
+
+        if (string.IsNullOrEmpty(paymentKey))
+        {
+            return Result.Failure(Error.Problem("Payment.PaymentKeyNotFound", "결제 키를 찾을 수 없어 취소할 수 없습니다."));
+        }
+
         // 환불 가능성 선검증 — PG 취소가 성공한 뒤 지갑 회수가 실패하면 PG-지갑 desync 가 되므로
         // 지갑 존재와 충전 잔액(PgCharged)을 PG 호출 전에 확정한다.
         var wallet = await db.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct);
@@ -41,40 +55,28 @@ internal sealed class Handler(
         var pgCharged = wallet.Balances.Where(b => b.Type == WalletBalanceType.PgCharged).Sum(b => b.Amount.Value);
         if (pgCharged < order.PointAmount)
         {
-            return Result.Failure(Error.Problem("Payment.PointAlreadySpent", "이미 사용한 포인트가 있어 결제를 취소할 수 없습니다."));
+            return Result.Failure(Error.Problem("Payment.PointAlreadySpent", "이미 사용한 캐시가 있어 결제를 취소할 수 없습니다."));
         }
 
-        // 다날 결제수단 코드 매핑
-        var method = order.PaymentMethod switch
-        {
-            PaymentMethod.Integrated => "INTEGRATED",
-            PaymentMethod.Card => "CARD",
-            PaymentMethod.VirtualAccount => "VACCOUNT",
-            PaymentMethod.Mobile => "MOBILE",
-            PaymentMethod.Transfer => "TRANSFER",
-            PaymentMethod.NaverPay => "NAVERPAY",
-            PaymentMethod.KakaoPay => "KAKAOPAY",
-            PaymentMethod.Payco => "PAYCO",
-            _ => "CARD"
-        };
+        var cancelReason = string.IsNullOrWhiteSpace(r.CancelReason) ? "사용자 요청 취소" : r.CancelReason;
 
         // 취소 요청 기록 저장
-        var cancel = DanalCancel.CreateRequest(r.MemberID, order.ID, order.PaymentMethod, order.TransactionID!, order.OrderID, order.Amount, order.MerchantID, DanalCancelType.Full);
+        var cancel = TossCancel.CreateRequest(r.MemberID, order.ID, paymentKey, order.OrderID, order.Amount, cancelReason, "member");
 
-        await db.DanalCancel.AddAsync(cancel, ct);
+        await db.TossCancel.AddAsync(cancel, ct);
         await db.SaveChangesAsync(ct);
 
-        // 다날 취소 API 호출 — 통신 예외도 기록을 남긴다
-        DanalCancelResult result;
+        // 토스 취소 API 호출 (cancelAmount 미전달 = 전액 취소) — 통신 예외도 기록을 남긴다
+        TossPaymentResult result;
         try
         {
-            result = await danalPay.CancelAsync(method, order.TransactionID!, order.Amount, "C", ct);
+            result = await tossPay.CancelAsync(paymentKey, cancelReason, null, ct);
         }
         catch (Exception ex)
         {
-            cancel.SetResponse("EXCEPTION", ex.Message);
+            cancel.SetError("EXCEPTION", ex.Message);
 
-            await db.DanalLog.AddAsync(DanalLog.Create(r.MemberID, DanalLogType.Error, "EXCEPTION", ex.Message, order.TransactionID, order.OrderID), ct);
+            await db.TossLog.AddAsync(TossLog.Create(r.MemberID, TossLogType.Error, "EXCEPTION", ex.Message, paymentKey, order.OrderID), ct);
             await db.SaveChangesAsync(ct);
 
             return Result.Failure(Error.Problem("Payment.CancelFailed", "결제 취소 처리 중 오류가 발생했습니다."));
@@ -82,9 +84,9 @@ internal sealed class Handler(
 
         if (!result.Success)
         {
-            cancel.SetResponse(result.Code ?? "", result.Message ?? "취소 실패");
+            cancel.SetError(result.Code ?? "FAIL", result.Message ?? "취소 실패");
 
-            await db.DanalLog.AddAsync(DanalLog.Create(r.MemberID, DanalLogType.Failed, result.Code ?? "FAIL", result.Message ?? "취소 실패", order.TransactionID, order.OrderID), ct);
+            await db.TossLog.AddAsync(TossLog.Create(r.MemberID, TossLogType.Failed, result.Code ?? "FAIL", result.Message ?? "취소 실패", paymentKey, order.OrderID), ct);
             await db.SaveChangesAsync(ct);
 
             return Result.Failure(Error.Problem("Payment.CancelFailed", result.Message ?? "결제 취소에 실패했습니다."));
@@ -97,7 +99,7 @@ internal sealed class Handler(
 
         // 충전분(PgCharged) 회수 — ChargeCancelled 원장 기록으로 출처를 보존한다.
         // 주문 취소 + 회수는 단일 SaveChanges 원자 커밋 (실패 시 부분 반영 없음)
-        wallet.DebitChargeCancel(Money.KRW(order.PointAmount), "결제 취소 환불", order.OrderID);
+        wallet.DebitChargeCancel(Money.KRW(order.PointAmount), "결제 취소 환불 (Toss)", order.OrderID);
 
         try
         {
@@ -113,7 +115,7 @@ internal sealed class Handler(
             }
             catch (Exception retryEx)
             {
-                await TryWriteReconcileLogAsync(order.ID, order.TransactionID, order.PointAmount, $"CANCEL_PERSIST 재시도 실패: {retryEx.Message} (동시성 충돌: {ex.Message})");
+                await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"CANCEL_PERSIST 재시도 실패: {retryEx.Message} (동시성 충돌: {ex.Message})");
                 throw;
             }
         }
@@ -121,7 +123,7 @@ internal sealed class Handler(
         {
             // Phase 1.5: PG 취소는 성공했는데 저장 실패 — 별도 컨텍스트로 대사 로그를 독립 커밋한 뒤
             // 기존 실패 흐름(예외 전파)을 유지한다.
-            await TryWriteReconcileLogAsync(order.ID, order.TransactionID, order.PointAmount, $"CANCEL_PERSIST 실패: {ex.Message}");
+            await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"CANCEL_PERSIST 실패: {ex.Message}");
             throw;
         }
 
@@ -129,27 +131,27 @@ internal sealed class Handler(
     }
 
     /// <summary>동시성 충돌 1회 재시도 — 새 컨텍스트로 리로드 후 취소 반영을 재적용한다. (Phase 1.5)</summary>
-    private async Task<Result> RetryCancelPersistAsync(int paymentOrderID, int cancelID, DanalCancelResult result, CancellationToken ct)
+    private async Task<Result> RetryCancelPersistAsync(int paymentOrderID, int cancelID, TossPaymentResult result, CancellationToken ct)
     {
         await using var retryDb = dbFactory.CreateDbContext();
 
-        var order = await retryDb.PaymentOrder.FirstAsync(o => o.ID == paymentOrderID, ct);
+        var order = await retryDb.PaymentOrder.FirstAsync(c => c.ID == paymentOrderID, ct);
         if (order.Status == PaymentStatus.Cancelled)
         {
             // 동시 요청이 이미 취소 반영을 끝낸 경우 — 이중회수 금지, idempotent 성공 응답
             return Result.Success();
         }
 
-        var cancel = await retryDb.DanalCancel.FirstAsync(c => c.ID == cancelID, ct);
+        var cancel = await retryDb.TossCancel.FirstAsync(c => c.ID == cancelID, ct);
         ApplyCancelResponse(cancel, result);
 
         var wallet = await retryDb.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstAsync(w => w.MemberID == order.MemberID, ct);
 
         order.MarkCancelled();
 
-        // 리로드된 실제 잔액 기준 회수 — 충돌 사이에 포인트가 사용됐다면 Decrease 예외 → 호출부가 대사 로그를 남긴다
+        // 리로드된 실제 잔액 기준 회수 — 충돌 사이에 캐시가 사용됐다면 Decrease 예외 → 호출부가 대사 로그를 남긴다
         // (PG 환불은 이미 완료 — 자동 회수 불가, 운영자 수동 대사 대상)
-        wallet.DebitChargeCancel(Money.KRW(order.PointAmount), "결제 취소 환불", order.OrderID);
+        wallet.DebitChargeCancel(Money.KRW(order.PointAmount), "결제 취소 환불 (Toss)", order.OrderID);
 
         await retryDb.SaveChangesAsync(ct);
 
@@ -157,13 +159,13 @@ internal sealed class Handler(
     }
 
     /// <summary>대사 로그 독립 커밋 — best-effort. 로그 실패가 원래 실패 흐름을 가리면 안 된다. (Phase 1.5)</summary>
-    private async Task TryWriteReconcileLogAsync(int paymentOrderID, string? transactionID, int amount, string reason)
+    private async Task TryWriteReconcileLogAsync(int paymentOrderID, string? paymentKey, int amount, string reason)
     {
         try
         {
             // 호출측 취소와 무관하게 반드시 남아야 하므로 CancellationToken.None
             await using var logDb = dbFactory.CreateDbContext();
-            await logDb.PaymentReconcileLog.AddAsync(Domain.Entities.Payments.PaymentReconcileLog.Create(ReconcileKind.Cancel, paymentOrderID, transactionID, amount, reason), CancellationToken.None);
+            await logDb.PaymentReconcileLog.AddAsync(PaymentReconcileLog.Create(ReconcileKind.Cancel, paymentOrderID, paymentKey, amount, reason), CancellationToken.None);
             await logDb.SaveChangesAsync(CancellationToken.None);
         }
         catch
@@ -172,17 +174,9 @@ internal sealed class Handler(
         }
     }
 
-    private static void ApplyCancelResponse(DanalCancel cancel, DanalCancelResult result)
+    private static void ApplyCancelResponse(TossCancel cancel, TossPaymentResult result)
     {
-        // Balance, RemainedAmount는 string? → int? 변환
-        cancel.SetResponse(
-            result.Code ?? "", result.Message ?? "",
-            result.OriginalTransactionID,
-            result.CancelledAmount,
-            result.TransDate, result.TransTime,
-            int.TryParse(result.Balance, out var balanceVal) ? balanceVal : null,
-            int.TryParse(result.RemainedAmount, out var remainedVal) ? remainedVal : null,
-            result.ApprovalDateTime
-        );
+        var last = result.Cancels.Count > 0 ? result.Cancels[^1] : null;
+        cancel.SetResponse(result.Status ?? "", last?.TransactionKey, last?.CanceledAt, result.RawJson);
     }
-}
+}

+ 14 - 0
Application/Features/Api/Payment/Toss/Confirm/Command.cs

@@ -0,0 +1,14 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.Payment.Toss.Confirm;
+
+/// <summary>
+/// 토스 결제 승인. successUrl 콜백의 {paymentKey, orderId, amount} 를 받아
+/// 서버 저장 주문 금액과 대조(변조 차단) 후 서버 저장 금액으로 승인 API 를 호출한다.
+/// </summary>
+public sealed record Command(
+    string PaymentKey,
+    string OrderID,
+    int Amount,
+    int MemberID = 0
+) : ICommand<Response>;

+ 236 - 0
Application/Features/Api/Payment/Toss/Confirm/Handler.cs

@@ -0,0 +1,236 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Application.Abstractions.Payment;
+using Domain.Entities.Common.ValueObject;
+using Domain.Entities.Payments;
+using Domain.Entities.Payments.Toss;
+using Domain.Entities.Payments.Toss.ValueObject;
+using Domain.Entities.Payments.ValueObject;
+using Domain.Entities.Wallets;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.Payment.Toss.Confirm;
+
+internal sealed class Handler(
+    IAppDbContext db,
+    ITossPaymentService tossPay,
+    IAppDbContextFactory dbFactory
+) : ICommandHandler<Command, Response>
+{
+    public async Task<Response> Handle(Command r, CancellationToken ct)
+    {
+        // 주문 조회
+        var order = await db.PaymentOrder.FirstOrDefaultAsync(c => c.OrderID == r.OrderID, ct);
+        if (order is null)
+        {
+            return new Response(false, "", 0, "주문을 찾을 수 없습니다.", null);
+        }
+
+        // 이미 결제 완료된 주문인 경우 중복 승인 방지 (idempotent 응답)
+        if (order.Status == PaymentStatus.Paid && order.MemberID == r.MemberID)
+        {
+            return new Response(true, order.Status.ToString(), order.PointAmount, "이미 충전이 완료된 주문입니다.", order.PaidAt);
+        }
+
+        // 회원 검증 — 불일치 시 주문 상태를 건드리지 않는다 (남의 주문 오염 공격 차단)
+        if (order.MemberID != r.MemberID)
+        {
+            return new Response(false, order.Status.ToString(), 0, "주문 정보가 일치하지 않습니다.", null);
+        }
+
+        // 금액 검증 — 클라이언트 전달 amount 와 서버 저장 주문 금액 불일치 시 즉시 실패 (변조 차단, PG 호출 전)
+        if (r.Amount != order.Amount)
+        {
+            await db.TossLog.AddAsync(TossLog.Create(r.MemberID, TossLogType.Failed, "AMOUNT_MISMATCH", $"금액 불일치: 요청={r.Amount}, 주문={order.Amount}", r.PaymentKey, r.OrderID), ct);
+            await db.SaveChangesAsync(ct);
+
+            return new Response(false, order.Status.ToString(), 0, "결제 금액이 주문 금액과 일치하지 않습니다.", null);
+        }
+
+        // 승인 가능 상태 가드 — Failed/Cancelled 재진입 차단, NeedsReconciliation 은 재확인(재승인) 경로 허용 (Phase 1.5)
+        if (order.Status is not PaymentStatus.Pending and not PaymentStatus.WaitingDeposit and not PaymentStatus.NeedsReconciliation)
+        {
+            return new Response(false, order.Status.ToString(), 0, "승인할 수 없는 주문 상태입니다.", null);
+        }
+
+        // 승인 요청 기록 저장 — TossConfirm 은 PaymentKey/OrderID unique 라 주문당 1행.
+        // 재확인(NeedsReconciliation, 또는 저장 실패 후 재시도)이면 기존 행을 재사용해 인덱스 충돌을 피한다. (Phase 1.5)
+        var confirm = await db.TossConfirm.FirstOrDefaultAsync(c => c.PaymentOrderID == order.ID, ct);
+        if (confirm is null)
+        {
+            confirm = TossConfirm.CreateRequest(r.MemberID, order.ID, r.PaymentKey, r.OrderID, order.Amount);
+            await db.TossConfirm.AddAsync(confirm, ct);
+        }
+        else
+        {
+            confirm.PrepareRetry(r.PaymentKey);
+        }
+
+        await db.SaveChangesAsync(ct);
+
+        // 토스 승인 API 호출 — 서버 저장 금액 사용 (클라이언트 amount 는 검증용으로만)
+        TossPaymentResult result;
+        try
+        {
+            result = await tossPay.ConfirmAsync(r.PaymentKey, r.OrderID, order.Amount, ct);
+        }
+        catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
+        {
+            // 통신 불명(타임아웃 등) — 토스가 실제 승인했을 수 있어 Failed 로 단정하지 않고 대사 대상으로 표시. (Phase 1.5)
+            // 호출측 취소와 무관하게 상태가 반드시 남아야 하므로 CancellationToken.None 으로 저장한다.
+            confirm.SetError("EXCEPTION", ex.Message);
+            order.MarkNeedsReconciliation("승인 통신 불명(타임아웃)");
+
+            await db.TossLog.AddAsync(TossLog.Create(r.MemberID, TossLogType.Error, "EXCEPTION", ex.Message, r.PaymentKey, r.OrderID), CancellationToken.None);
+            await db.SaveChangesAsync(CancellationToken.None);
+
+            // 자동 대사 배치가 참조할 수 있게 대사 로그 독립 커밋
+            await TryWriteReconcileLogAsync(order.ID, r.PaymentKey, order.PointAmount, $"CONFIRM 통신 불명: {ex.Message}");
+
+            return new Response(false, order.Status.ToString(), 0, "결제 승인 확인이 지연되고 있습니다. 잠시 후 다시 시도해주세요.", null);
+        }
+        catch (Exception ex)
+        {
+            // 통신 이외의 예외(설정 오류 등)는 Failed — Pending 방치를 막는다
+            confirm.SetError("EXCEPTION", ex.Message);
+            order.MarkFailed("승인 처리 오류");
+
+            await db.TossLog.AddAsync(TossLog.Create(r.MemberID, TossLogType.Error, "EXCEPTION", ex.Message, r.PaymentKey, r.OrderID), ct);
+            await db.SaveChangesAsync(ct);
+
+            return new Response(false, order.Status.ToString(), 0, "결제 처리 중 오류가 발생했습니다.", null);
+        }
+
+        if (!result.Success)
+        {
+            // 토스의 명시적 거절 응답 {code, message} — 불명이 아니므로 Failed
+            confirm.SetError(result.Code ?? "FAIL", result.Message ?? "승인 실패");
+            order.MarkFailed(result.Message ?? "승인 실패");
+
+            await db.TossLog.AddAsync(TossLog.Create(r.MemberID, TossLogType.Failed, result.Code ?? "FAIL", result.Message ?? "승인 실패", r.PaymentKey, r.OrderID), ct);
+            await db.SaveChangesAsync(ct);
+
+            return new Response(false, order.Status.ToString(), 0, result.Message ?? "결제 승인에 실패했습니다.", null);
+        }
+
+        // 가상계좌 — 승인 응답 status=WAITING_FOR_DEPOSIT 는 입금 대기. 이 시점에 지갑 적립 금지,
+        // DEPOSIT_CALLBACK 웹훅 후 조회 API 로 DONE 확인 후 적립한다.
+        if (result.Status == "WAITING_FOR_DEPOSIT")
+        {
+            ApplyConfirmResponse(confirm, result);
+            order.MarkWaitingDeposit(result.VirtualAccountNumber ?? "", result.VirtualAccountBankCode ?? "", result.VirtualAccountHolder ?? "", result.VirtualAccountDueDate ?? DateTime.UtcNow.AddDays(7));
+
+            await db.SaveChangesAsync(ct);
+
+            return new Response(true, order.Status.ToString(), 0, "가상계좌가 발급되었습니다. 입금 확인 후 충전됩니다.", null);
+        }
+
+        if (result.Status != "DONE")
+        {
+            // HTTP 성공인데 DONE/WAITING_FOR_DEPOSIT 이 아닌 상태 — 단정하지 않고 대사 대상 (보수적)
+            ApplyConfirmResponse(confirm, result);
+            order.MarkNeedsReconciliation($"승인 응답 상태 불명: {result.Status}");
+
+            await db.SaveChangesAsync(ct);
+            await TryWriteReconcileLogAsync(order.ID, r.PaymentKey, order.PointAmount, $"CONFIRM 응답 상태 불명: {result.Status}");
+
+            return new Response(false, order.Status.ToString(), 0, "결제 상태 확인이 필요합니다. 잠시 후 다시 확인해주세요.", null);
+        }
+
+        // 응답 저장
+        ApplyConfirmResponse(confirm, result);
+
+        // 지갑에 캐시 충전 — 지갑이 없으면 생성해 승인 결과 유실을 막는다 (PG 는 이미 승인됨)
+        var wallet = await db.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct);
+        if (wallet is null)
+        {
+            wallet = Wallet.Create(order.MemberID);
+            await db.Wallet.AddAsync(wallet, ct);
+        }
+
+        wallet.CreditPgCharge(Money.KRW(order.PointAmount), "캐시 충전 (Toss)", order.OrderID);
+
+        // 주문 상태 갱신 — 지갑 적립(CreditPgCharge)과 단일 SaveChanges 로 원자 커밋 (실패 시 부분 반영 없음)
+        order.MarkPaid(r.PaymentKey);
+
+        try
+        {
+            await db.SaveChangesAsync(ct);
+        }
+        catch (DbUpdateConcurrencyException ex)
+        {
+            // Phase 1.5: WalletBalance rowversion 동시성 충돌 — 오염된 컨텍스트는 버리고(커밋된 것 없음)
+            // 새 컨텍스트로 엔티티를 리로드해 도메인 연산을 1회 재적용한다.
+            // 첫 시도의 원장 기록은 미영속이므로 재적용해도 이중적립이 없다.
+            try
+            {
+                return await RetryConfirmPersistAsync(order.ID, confirm.ID, r.PaymentKey, result, ct);
+            }
+            catch (Exception retryEx)
+            {
+                await TryWriteReconcileLogAsync(order.ID, r.PaymentKey, order.PointAmount, $"CONFIRM_PERSIST 재시도 실패: {retryEx.Message} (동시성 충돌: {ex.Message})");
+                throw;
+            }
+        }
+        catch (Exception ex)
+        {
+            // Phase 1.5: PG 승인은 성공했는데 저장 실패 — 별도 컨텍스트로 대사 로그를 독립 커밋한 뒤
+            // 기존 실패 흐름(예외 전파)을 유지한다.
+            await TryWriteReconcileLogAsync(order.ID, r.PaymentKey, order.PointAmount, $"CONFIRM_PERSIST 실패: {ex.Message}");
+            throw;
+        }
+
+        return new Response(true, order.Status.ToString(), order.PointAmount, "충전이 완료되었습니다.", order.PaidAt);
+    }
+
+    /// <summary>동시성 충돌 1회 재시도 — 새 컨텍스트로 리로드 후 승인 반영을 재적용한다. (Phase 1.5)</summary>
+    private async Task<Response> RetryConfirmPersistAsync(int paymentOrderID, int confirmID, string paymentKey, TossPaymentResult result, CancellationToken ct)
+    {
+        await using var retryDb = dbFactory.CreateDbContext();
+
+        var order = await retryDb.PaymentOrder.FirstAsync(c => c.ID == paymentOrderID, ct);
+        if (order.Status == PaymentStatus.Paid)
+        {
+            // 동시 요청이 이미 승인 반영을 끝낸 경우 — 이중적립 금지, idempotent 성공 응답
+            return new Response(true, order.Status.ToString(), order.PointAmount, "이미 충전이 완료된 주문입니다.", order.PaidAt);
+        }
+
+        var confirm = await retryDb.TossConfirm.FirstAsync(c => c.ID == confirmID, ct);
+        ApplyConfirmResponse(confirm, result);
+
+        var wallet = await retryDb.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct);
+        if (wallet is null)
+        {
+            wallet = Wallet.Create(order.MemberID);
+            await retryDb.Wallet.AddAsync(wallet, ct);
+        }
+
+        wallet.CreditPgCharge(Money.KRW(order.PointAmount), "캐시 충전 (Toss)", order.OrderID);
+        order.MarkPaid(paymentKey);
+
+        await retryDb.SaveChangesAsync(ct);
+
+        return new Response(true, order.Status.ToString(), order.PointAmount, "충전이 완료되었습니다.", order.PaidAt);
+    }
+
+    /// <summary>대사 로그 독립 커밋 — best-effort. 로그 실패가 원래 실패 흐름을 가리면 안 된다. (Phase 1.5)</summary>
+    private async Task TryWriteReconcileLogAsync(int paymentOrderID, string? paymentKey, int amount, string reason)
+    {
+        try
+        {
+            // 호출측 취소와 무관하게 반드시 남아야 하므로 CancellationToken.None
+            await using var logDb = dbFactory.CreateDbContext();
+            await logDb.PaymentReconcileLog.AddAsync(PaymentReconcileLog.Create(ReconcileKind.Confirm, paymentOrderID, paymentKey, amount, reason), CancellationToken.None);
+            await logDb.SaveChangesAsync(CancellationToken.None);
+        }
+        catch
+        {
+            // best-effort — 원래 예외를 유지한다
+        }
+    }
+
+    private static void ApplyConfirmResponse(TossConfirm confirm, TossPaymentResult result)
+    {
+        confirm.SetResponse(result.Status ?? "", result.Method, result.TotalAmount, result.ApprovedAt, result.ReceiptUrl, result.RawJson);
+    }
+}

+ 13 - 0
Application/Features/Api/Payment/Toss/Confirm/Response.cs

@@ -0,0 +1,13 @@
+namespace Application.Features.Api.Payment.Toss.Confirm;
+
+/// <summary>
+/// Status: PaymentOrder.Status 문자열 (Paid / WaitingDeposit / Failed / NeedsReconciliation ...)
+/// 가상계좌(WaitingDeposit)는 Success=true 이지만 적립 전 — 입금 완료 웹훅 후 적립된다.
+/// </summary>
+public sealed record Response(
+    bool Success,
+    string Status,
+    int PointAmount,
+    string? Message,
+    DateTime? PaidAt
+);

+ 14 - 0
Application/Features/Api/Payment/Toss/CreateOrder/Command.cs

@@ -0,0 +1,14 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.Payment.Toss.CreateOrder;
+
+/// <summary>
+/// 토스 캐시 충전 주문 생성. <see cref="Amount"/>는 사용자가 충전할 캐시(VAT 제외).
+/// PG 결제 총액은 Handler 에서 Amount + VAT(10%)로 계산됨.
+/// 서버가 orderID·금액·orderName 을 확정 저장하고, 회원 customerKey(UUID)를 없으면 발급한다.
+/// </summary>
+public sealed record Command(
+    int Amount,
+    int MemberID = 0
+) : ICommand<Result<Response>>;

+ 61 - 0
Application/Features/Api/Payment/Toss/CreateOrder/Handler.cs

@@ -0,0 +1,61 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Application.Abstractions.Payment;
+using Domain.Entities.Payments;
+using Domain.Entities.Payments.ValueObject;
+using Microsoft.EntityFrameworkCore;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.Payment.Toss.CreateOrder;
+
+internal sealed class Handler(
+    IAppDbContext db,
+    ITossPaymentService tossPay
+) : ICommandHandler<Command, Result<Response>>
+{
+    /// <summary>토스에서 사용할 MerchantID 표기 (PaymentOrder.MerchantID 필수 컬럼)</summary>
+    private const string TossMerchantMark = "TOSS";
+
+    public async Task<Result<Response>> Handle(Command r, CancellationToken ct)
+    {
+        if (r.Amount <= 0)
+        {
+            return Result.Failure<Response>(new Error("Payment.InvalidAmount", "충전 금액이 올바르지 않습니다.", ErrorType.Validation));
+        }
+
+        var member = await db.Member.FirstOrDefaultAsync(c => c.ID == r.MemberID, ct);
+        if (member is null)
+        {
+            return Result.Failure<Response>(Error.NotFound("Payment.MemberNotFound", "회원 정보를 찾을 수 없습니다."));
+        }
+
+        // customerKey — 예측 가능 값(SID/이메일/PK) 금지, 회원별 UUID 발급·저장 (Guid 문자열은 '-' 포함 = 특수문자 조건 충족)
+        var customerKey = member.EnsurePaymentCustomerKey();
+
+        // 주문번호 생성 — 6~64자, 영숫자와 -_ 만 (기존 prefix 방식 미러링)
+        var orderID = $"ANTOOZA-{DateTime.UtcNow:yyyyMMddHHmmss}-{r.MemberID}-{Random.Shared.Next(1000, 9999)}";
+        if (orderID.Length is < 6 or > 64 || orderID.Any(c => !char.IsAsciiLetterOrDigit(c) && c != '-' && c != '_'))
+        {
+            return Result.Failure<Response>(new Error("Payment.InvalidOrderID", "주문번호 생성에 실패했습니다.", ErrorType.Validation));
+        }
+
+        // 토스 클라이언트 설정 (ClientKey 획득 — 설정 미비 시 예외로 조기 실패)
+        var config = await tossPay.GetClientConfigAsync(ct);
+
+        // 서버가 금액·orderName 확정 저장 — Confirm 시 클라이언트 금액과 대조해 변조 차단
+        var order = PaymentOrder.Create(r.MemberID, orderID, TossMerchantMark, r.Amount, PaymentMethod.Integrated, "개미투자 캐시 충전");
+
+        await db.PaymentOrder.AddAsync(order, ct);
+        await db.SaveChangesAsync(ct);
+
+        return new Response(
+            orderID,
+            order.OrderName,
+            order.Amount,
+            order.PointAmount,
+            order.VatAmount,
+            config.ClientKey,
+            customerKey.ToString()
+        );
+    }
+}

+ 4 - 5
Application/Features/Api/Payment/CreateOrder/Response.cs → Application/Features/Api/Payment/Toss/CreateOrder/Response.cs

@@ -1,12 +1,11 @@
-namespace Application.Features.Api.Payment.CreateOrder;
+namespace Application.Features.Api.Payment.Toss.CreateOrder;
 
 public sealed record Response(
     string OrderID,
-    string ClientKey,
-    string MerchantID,
+    string OrderName,
     int Amount,
     int PointAmount,
     int VatAmount,
-    string SuccessUrl,
-    string FailUrl
+    string ClientKey,
+    string CustomerKey
 );

+ 13 - 0
Application/Features/Api/Payment/Toss/Reconcile/Command.cs

@@ -0,0 +1,13 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.Payment.Toss.Reconcile;
+
+/// <summary>
+/// NeedsReconciliation 상태 토스 주문 1건 자동 대사.
+/// 조회 API 로 실제 상태 확인 → DONE 이면 멱등 적립, CANCELED/ABORTED/EXPIRED 면 Failed,
+/// 그 외/판별 불가면 아무것도 하지 않고 PaymentReconcileLog 기록. (PaymentReconcileService 가 호출)
+/// </summary>
+public sealed record Command(
+    int PaymentOrderID
+) : ICommand<Result>;

+ 122 - 0
Application/Features/Api/Payment/Toss/Reconcile/Handler.cs

@@ -0,0 +1,122 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Application.Abstractions.Payment;
+using Domain.Entities.Common.ValueObject;
+using Domain.Entities.Payments;
+using Domain.Entities.Payments.Toss;
+using Domain.Entities.Payments.Toss.ValueObject;
+using Domain.Entities.Payments.ValueObject;
+using Domain.Entities.Wallets;
+using Microsoft.EntityFrameworkCore;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.Payment.Toss.Reconcile;
+
+internal sealed class Handler(
+    IAppDbContext db,
+    ITossPaymentService tossPay
+) : ICommandHandler<Command, Result>
+{
+    public async Task<Result> Handle(Command r, CancellationToken ct)
+    {
+        var order = await db.PaymentOrder.FirstOrDefaultAsync(c => c.ID == r.PaymentOrderID, ct);
+        if (order is null)
+        {
+            return Result.Failure(Error.NotFound("Payment.OrderNotFound", "주문을 찾을 수 없습니다."));
+        }
+
+        // 대사 대상 상태만 처리 — 그 사이 사용자 재승인 등으로 상태가 바뀌었으면 멱등 skip
+        if (order.Status != PaymentStatus.NeedsReconciliation)
+        {
+            return Result.Success();
+        }
+
+        // 토스 주문만 처리 (타 PG 주문 오처리 방지 — TossConfirm 행 존재 = Toss 결제)
+        var confirm = await db.TossConfirm.FirstOrDefaultAsync(c => c.PaymentOrderID == order.ID, ct);
+        if (confirm is null)
+        {
+            return Result.Success();
+        }
+
+        // 조회 API 로 실제 상태 확인 — 통신 실패는 아무것도 반영하지 않고 로그만 (다음 주기 재시도)
+        TossPaymentResult payment;
+        try
+        {
+            payment = await tossPay.GetPaymentAsync(confirm.PaymentKey, ct);
+        }
+        catch (Exception ex)
+        {
+            await WriteReconcileLogAsync(order.ID, confirm.PaymentKey, order.PointAmount, $"자동 대사 조회 실패: {ex.Message}", ct);
+
+            return Result.Success();
+        }
+
+        // 미결제 주문(승인 자체가 안 된 paymentKey)은 404/NOT_FOUND 계열 — Failed 단정 대신 로그만 (보수적)
+        if (!payment.Success)
+        {
+            await WriteReconcileLogAsync(order.ID, confirm.PaymentKey, order.PointAmount, $"자동 대사 조회 거절: {payment.Code} {payment.Message}", ct);
+
+            return Result.Success();
+        }
+
+        if (payment.Status == "DONE")
+        {
+            return await SettleDoneAsync(order, confirm, payment, ct);
+        }
+
+        if (payment.Status is "CANCELED" or "ABORTED" or "EXPIRED")
+        {
+            // 적립 전 주문의 종결 상태 — Failed 처리 (지갑 반영 없음)
+            confirm.SetResponse(payment.Status ?? "", payment.Method, payment.TotalAmount, payment.ApprovedAt, payment.ReceiptUrl, payment.RawJson);
+            order.MarkFailed($"자동 대사 결과 {payment.Status}");
+
+            await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Reconcile, payment.Status ?? "", "자동 대사 — 주문 Failed 종결", confirm.PaymentKey, order.OrderID), ct);
+            await db.SaveChangesAsync(ct);
+
+            return Result.Success();
+        }
+
+        // 그 외(WAITING_FOR_DEPOSIT/IN_PROGRESS 등)/판별 불가 — 아무것도 하지 않고 대사 로그 기록
+        await WriteReconcileLogAsync(order.ID, confirm.PaymentKey, order.PointAmount, $"자동 대사 판별 불가: status={payment.Status}", ct);
+
+        return Result.Success();
+    }
+
+    /// <summary>조회 결과 DONE — Confirm 완료 로직 재사용, 멱등 적립</summary>
+    private async Task<Result> SettleDoneAsync(PaymentOrder order, TossConfirm confirm, TossPaymentResult payment, CancellationToken ct)
+    {
+        // 금액 대조 — 불일치 시 적립하지 않고 수동 대사 대상
+        if (payment.TotalAmount != order.Amount)
+        {
+            await WriteReconcileLogAsync(order.ID, confirm.PaymentKey, order.PointAmount, $"자동 대사 DONE 금액 불일치: 조회={payment.TotalAmount}, 주문={order.Amount}", ct);
+
+            return Result.Success();
+        }
+
+        confirm.SetResponse(payment.Status ?? "", payment.Method, payment.TotalAmount, payment.ApprovedAt, payment.ReceiptUrl, payment.RawJson);
+
+        var wallet = await db.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct);
+        if (wallet is null)
+        {
+            wallet = Wallet.Create(order.MemberID);
+            await db.Wallet.AddAsync(wallet, ct);
+        }
+
+        wallet.CreditPgCharge(Money.KRW(order.PointAmount), "캐시 충전 (Toss 자동 대사)", order.OrderID);
+        order.MarkPaid(confirm.PaymentKey);
+
+        await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Reconcile, "SUCCESS", "자동 대사 — 승인 확인, 멱등 적립 완료", confirm.PaymentKey, order.OrderID), ct);
+
+        // 저장 실패 시 커밋된 것 없음(원자) — 주문은 NeedsReconciliation 로 남아 다음 주기에 재시도된다.
+        await db.SaveChangesAsync(ct);
+
+        return Result.Success();
+    }
+
+    /// <summary>대사 로그 기록 — 배치 컨텍스트에는 미커밋 변경이 없어 현재 컨텍스트로 바로 커밋</summary>
+    private async Task WriteReconcileLogAsync(int paymentOrderID, string? paymentKey, int amount, string reason, CancellationToken ct)
+    {
+        await db.PaymentReconcileLog.AddAsync(PaymentReconcileLog.Create(ReconcileKind.AutoReconcile, paymentOrderID, paymentKey, amount, reason), ct);
+        await db.SaveChangesAsync(ct);
+    }
+}

+ 16 - 0
Application/Features/Api/Payment/Toss/Webhook/Command.cs

@@ -0,0 +1,16 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.Payment.Toss.Webhook;
+
+/// <summary>
+/// 토스 일반결제 웹훅 처리 (PAYMENT_STATUS_CHANGED / DEPOSIT_CALLBACK).
+/// 일반결제 웹훅은 서명 헤더가 없으므로 본문을 신뢰하지 않는다 —
+/// paymentKey 로 조회 API 를 재호출해 그 결과만 반영한다.
+/// PaymentKey 가 없으면(DEPOSIT_CALLBACK) OrderID 로 TossConfirm 에서 보강한다.
+/// </summary>
+public sealed record Command(
+    string? EventType,
+    string? PaymentKey,
+    string? OrderID
+) : ICommand<Result>;

+ 261 - 0
Application/Features/Api/Payment/Toss/Webhook/Handler.cs

@@ -0,0 +1,261 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Application.Abstractions.Payment;
+using Domain.Entities.Common.ValueObject;
+using Domain.Entities.Payments;
+using Domain.Entities.Payments.Toss;
+using Domain.Entities.Payments.Toss.ValueObject;
+using Domain.Entities.Payments.ValueObject;
+using Domain.Entities.Wallets;
+using Domain.Entities.Wallets.ValueObject;
+using Microsoft.EntityFrameworkCore;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.Payment.Toss.Webhook;
+
+internal sealed class Handler(
+    IAppDbContext db,
+    ITossPaymentService tossPay,
+    IAppDbContextFactory dbFactory
+) : ICommandHandler<Command, Result>
+{
+    public async Task<Result> Handle(Command r, CancellationToken ct)
+    {
+        // paymentKey 확보 — DEPOSIT_CALLBACK 은 본문에 paymentKey 가 없어 orderId 로 TossConfirm 에서 보강
+        var paymentKey = r.PaymentKey;
+        if (string.IsNullOrEmpty(paymentKey) && !string.IsNullOrEmpty(r.OrderID))
+        {
+            var confirmRow = await db.TossConfirm.FirstOrDefaultAsync(c => c.OrderID == r.OrderID, ct);
+            paymentKey = confirmRow?.PaymentKey;
+        }
+
+        if (string.IsNullOrEmpty(paymentKey))
+        {
+            // 판별 불가 — 로그만 남기고 정상 응답 (재시도 무의미)
+            await db.TossLog.AddAsync(TossLog.Create(null, TossLogType.Webhook, "IGNORED", $"paymentKey 판별 불가 (eventType={r.EventType})", null, r.OrderID), ct);
+            await db.SaveChangesAsync(ct);
+
+            return Result.Success();
+        }
+
+        // 웹훅 본문 신뢰 금지 — 조회 API 재조회 결과만 반영
+        TossPaymentResult payment;
+        try
+        {
+            payment = await tossPay.GetPaymentAsync(paymentKey, ct);
+        }
+        catch (Exception ex)
+        {
+            // 조회 실패 — 실패 응답으로 토스 재시도 유도
+            await db.TossLog.AddAsync(TossLog.Create(null, TossLogType.Error, "EXCEPTION", $"웹훅 재조회 실패: {ex.Message}", paymentKey, r.OrderID), CancellationToken.None);
+            await db.SaveChangesAsync(CancellationToken.None);
+
+            return Result.Failure(Error.Problem("Payment.WebhookRequeryFailed", "결제 재조회에 실패했습니다."));
+        }
+
+        if (!payment.Success || string.IsNullOrEmpty(payment.OrderID))
+        {
+            await db.TossLog.AddAsync(TossLog.Create(null, TossLogType.Webhook, payment.Code ?? "IGNORED", $"재조회 결과 처리 불가 (eventType={r.EventType}): {payment.Message}", paymentKey, r.OrderID), ct);
+            await db.SaveChangesAsync(ct);
+
+            return Result.Success();
+        }
+
+        // 조회 결과의 orderId 기준으로 주문 확정 (본문 orderId 미신뢰)
+        var order = await db.PaymentOrder.FirstOrDefaultAsync(c => c.OrderID == payment.OrderID, ct);
+        if (order is null)
+        {
+            await db.TossLog.AddAsync(TossLog.Create(null, TossLogType.Webhook, "ORDER_NOT_FOUND", $"주문 없음 (eventType={r.EventType})", paymentKey, payment.OrderID), ct);
+            await db.SaveChangesAsync(ct);
+
+            return Result.Success();
+        }
+
+        return payment.Status switch
+        {
+            "DONE" => await HandleDoneAsync(order, paymentKey, payment, r.EventType, ct),
+            "CANCELED" or "PARTIAL_CANCELED" => await HandleCanceledAsync(order, paymentKey, payment, r.EventType, ct),
+            "ABORTED" or "EXPIRED" => await HandleAbortedAsync(order, paymentKey, payment, ct),
+            _ => await HandleUnknownAsync(order, paymentKey, payment, r.EventType, ct)
+        };
+    }
+
+    /// <summary>DONE — 승인/입금 완료. 미적립이면 멱등 적립 (가상계좌 DEPOSIT_CALLBACK 포함)</summary>
+    private async Task<Result> HandleDoneAsync(PaymentOrder order, string paymentKey, TossPaymentResult payment, string? eventType, CancellationToken ct)
+    {
+        if (order.Status == PaymentStatus.Paid)
+        {
+            // 이미 적립 완료 — 멱등, 중복 적립 금지
+            await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "ALREADY_PAID", $"이미 처리된 주문 (eventType={eventType})", paymentKey, order.OrderID), ct);
+            await db.SaveChangesAsync(ct);
+
+            return Result.Success();
+        }
+
+        if (order.Status is not PaymentStatus.Pending and not PaymentStatus.WaitingDeposit and not PaymentStatus.NeedsReconciliation)
+        {
+            // Failed/Cancelled 주문에 DONE 웹훅 — 자동 반영하지 않고 대사 대상 기록 (보수적)
+            await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "STATE_MISMATCH", $"주문 상태 {order.Status} 에 DONE 웹훅", paymentKey, order.OrderID), ct);
+            await db.SaveChangesAsync(ct);
+            await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"웹훅 DONE — 주문 상태 {order.Status} 불일치, 수동 확인 필요");
+
+            return Result.Success();
+        }
+
+        // 금액 대조 — 조회 결과 totalAmount 와 서버 저장 주문 금액 불일치 시 적립하지 않는다
+        if (payment.TotalAmount != order.Amount)
+        {
+            await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "AMOUNT_MISMATCH", $"금액 불일치: 조회={payment.TotalAmount}, 주문={order.Amount}", paymentKey, order.OrderID), ct);
+            await db.SaveChangesAsync(ct);
+            await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"웹훅 DONE 금액 불일치: 조회={payment.TotalAmount}, 주문={order.Amount}");
+
+            return Result.Success();
+        }
+
+        // 승인 기록 갱신 — TossConfirm 행이 없으면 생성 (unique 충돌 방지 위해 기존 행 재사용)
+        var confirm = await db.TossConfirm.FirstOrDefaultAsync(c => c.PaymentOrderID == order.ID, ct);
+        if (confirm is null)
+        {
+            confirm = TossConfirm.CreateRequest(order.MemberID, order.ID, paymentKey, order.OrderID, order.Amount);
+            await db.TossConfirm.AddAsync(confirm, ct);
+        }
+
+        confirm.SetResponse(payment.Status ?? "", payment.Method, payment.TotalAmount, payment.ApprovedAt, payment.ReceiptUrl, payment.RawJson);
+
+        // 지갑 적립 (멱등 — 위 Paid 가드로 중복 차단) + 주문 완료 단일 커밋
+        var wallet = await db.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct);
+        if (wallet is null)
+        {
+            wallet = Wallet.Create(order.MemberID);
+            await db.Wallet.AddAsync(wallet, ct);
+        }
+
+        wallet.CreditPgCharge(Money.KRW(order.PointAmount), "캐시 충전 (Toss)", order.OrderID);
+        order.MarkPaid(paymentKey);
+
+        await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "SUCCESS", $"입금/승인 완료 반영 (eventType={eventType})", paymentKey, order.OrderID), ct);
+
+        try
+        {
+            await db.SaveChangesAsync(ct);
+        }
+        catch (Exception ex)
+        {
+            // PG 는 이미 DONE — 저장 실패는 대사 로그 독립 커밋 후 실패 응답 (토스 재시도로 재처리)
+            await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"WEBHOOK_PERSIST 실패: {ex.Message}");
+
+            return Result.Failure(Error.Problem("Payment.WebhookPersistFailed", "웹훅 반영 저장에 실패했습니다."));
+        }
+
+        return Result.Success();
+    }
+
+    /// <summary>CANCELED — 토스 측 취소 (관리자콘솔 취소 등). Paid 주문이면 지갑 회수 반영</summary>
+    private async Task<Result> HandleCanceledAsync(PaymentOrder order, string paymentKey, TossPaymentResult payment, string? eventType, CancellationToken ct)
+    {
+        if (order.Status == PaymentStatus.Cancelled)
+        {
+            // 이미 취소 반영 완료 — 멱등
+            await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "ALREADY_CANCELLED", $"이미 취소된 주문 (eventType={eventType})", paymentKey, order.OrderID), ct);
+            await db.SaveChangesAsync(ct);
+
+            return Result.Success();
+        }
+
+        // 미적립 주문(Pending/WaitingDeposit/NeedsReconciliation)의 취소 — 적립된 것이 없으므로 Failed 처리로 종결
+        if (order.Status != PaymentStatus.Paid)
+        {
+            order.MarkFailed($"토스 취소 웹훅 (status={payment.Status})");
+
+            await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "CANCELED_BEFORE_PAID", $"미적립 주문 취소 종결 (eventType={eventType})", paymentKey, order.OrderID), ct);
+            await db.SaveChangesAsync(ct);
+
+            return Result.Success();
+        }
+
+        // 부분취소는 자동 반영하지 않는다 — 수동 대사 대상 (보수적)
+        if (payment.Status == "PARTIAL_CANCELED")
+        {
+            await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "PARTIAL_CANCELED", "부분취소 웹훅 — 자동 반영 안 함", paymentKey, order.OrderID), ct);
+            await db.SaveChangesAsync(ct);
+            await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, "웹훅 PARTIAL_CANCELED — 수동 확인 필요");
+
+            return Result.Success();
+        }
+
+        // 회수 가능성 검증 — 이미 사용한 캐시가 있으면 자동 회수 불가, 수동 대사 대상
+        var wallet = await db.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct);
+        var pgCharged = wallet?.Balances.Where(b => b.Type == WalletBalanceType.PgCharged).Sum(b => b.Amount.Value) ?? 0;
+        if (wallet is null || pgCharged < order.PointAmount)
+        {
+            await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "REFUND_BLOCKED", $"잔액 부족으로 자동 회수 불가 (PgCharged={pgCharged}, 필요={order.PointAmount})", paymentKey, order.OrderID), ct);
+            await db.SaveChangesAsync(ct);
+            await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"웹훅 CANCELED — PG 취소 완료됐으나 잔액 부족으로 자동 회수 불가 (PgCharged={pgCharged})");
+
+            return Result.Success();
+        }
+
+        // 취소 기록 (webhook 발) + 주문 취소 + 지갑 회수 단일 커밋
+        var last = payment.Cancels.Count > 0 ? payment.Cancels[^1] : null;
+        var cancel = TossCancel.CreateRequest(order.MemberID, order.ID, paymentKey, order.OrderID, order.Amount, last?.CancelReason ?? "토스 측 취소(웹훅)", "webhook");
+        cancel.SetResponse(payment.Status ?? "", last?.TransactionKey, last?.CanceledAt, payment.RawJson);
+
+        await db.TossCancel.AddAsync(cancel, ct);
+
+        order.MarkCancelled();
+        wallet.DebitChargeCancel(Money.KRW(order.PointAmount), "결제 취소 환불 (Toss 웹훅)", order.OrderID);
+
+        await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "SUCCESS", $"취소 반영 (eventType={eventType})", paymentKey, order.OrderID), ct);
+
+        try
+        {
+            await db.SaveChangesAsync(ct);
+        }
+        catch (Exception ex)
+        {
+            await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"WEBHOOK_CANCEL_PERSIST 실패: {ex.Message}");
+
+            return Result.Failure(Error.Problem("Payment.WebhookPersistFailed", "웹훅 취소 반영 저장에 실패했습니다."));
+        }
+
+        return Result.Success();
+    }
+
+    /// <summary>ABORTED/EXPIRED — 승인 실패/가상계좌 만료. 미적립 주문만 Failed 로 종결</summary>
+    private async Task<Result> HandleAbortedAsync(PaymentOrder order, string paymentKey, TossPaymentResult payment, CancellationToken ct)
+    {
+        if (order.Status is PaymentStatus.Pending or PaymentStatus.WaitingDeposit or PaymentStatus.NeedsReconciliation)
+        {
+            order.MarkFailed($"토스 웹훅 (status={payment.Status})");
+        }
+
+        await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, payment.Status ?? "ABORTED", $"주문 종결 처리 (order.Status={order.Status})", paymentKey, order.OrderID), ct);
+        await db.SaveChangesAsync(ct);
+
+        return Result.Success();
+    }
+
+    /// <summary>판별 불가 상태 — 아무것도 반영하지 않고 로그만</summary>
+    private async Task<Result> HandleUnknownAsync(PaymentOrder order, string paymentKey, TossPaymentResult payment, string? eventType, CancellationToken ct)
+    {
+        await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "IGNORED", $"미처리 상태 (status={payment.Status}, eventType={eventType})", paymentKey, order.OrderID), ct);
+        await db.SaveChangesAsync(ct);
+
+        return Result.Success();
+    }
+
+    /// <summary>대사 로그 독립 커밋 — best-effort. (Phase 1.5)</summary>
+    private async Task TryWriteReconcileLogAsync(int paymentOrderID, string? paymentKey, int amount, string reason)
+    {
+        try
+        {
+            await using var logDb = dbFactory.CreateDbContext();
+            await logDb.PaymentReconcileLog.AddAsync(PaymentReconcileLog.Create(ReconcileKind.Confirm, paymentOrderID, paymentKey, amount, reason), CancellationToken.None);
+            await logDb.SaveChangesAsync(CancellationToken.None);
+        }
+        catch
+        {
+            // best-effort
+        }
+    }
+}

+ 15 - 27
Application/Features/Config/Get/Handler.cs

@@ -135,36 +135,16 @@ public sealed class Handler(IAppDbContext db, ICacheService cache) : IQueryHandl
                 YouTubeApiName = config.External.YouTubeApiName,
                 GoogleClientId = config.External.GoogleClientId,
                 GoogleClientSecretEnc = config.External.GoogleClientSecretEnc,
-                GoogleAppId = config.External.GoogleAppId,
-                DanalPayMode = config.External.DanalPayMode,
-                DanalTestCpid = config.External.DanalTestCpid,
-                DanalTestClientKeyEnc = config.External.DanalTestClientKeyEnc,
-                DanalTestSecretKeyEnc = config.External.DanalTestSecretKeyEnc,
-                DanalLiveCpid = config.External.DanalLiveCpid,
-                DanalLiveClientKeyEnc = config.External.DanalLiveClientKeyEnc,
-                DanalLiveSecretKeyEnc = config.External.DanalLiveSecretKeyEnc
+                GoogleAppId = config.External.GoogleAppId
             },
 
-            Payment = new Response.PaymentConfigDto
+            Toss = new Response.TossConfigDto
             {
-                DanalCardFeeRate = config.Payment.DanalCardFeeRate,
-                DanalCardFeeAmount = config.Payment.DanalCardFeeAmount,
-                DanalKakaoPayFeeRate = config.Payment.DanalKakaoPayFeeRate,
-                DanalKakaoPayFeeAmount = config.Payment.DanalKakaoPayFeeAmount,
-                DanalNaverPayFeeRate = config.Payment.DanalNaverPayFeeRate,
-                DanalNaverPayFeeAmount = config.Payment.DanalNaverPayFeeAmount,
-                DanalTransferFeeRate = config.Payment.DanalTransferFeeRate,
-                DanalTransferFeeAmount = config.Payment.DanalTransferFeeAmount,
-                DanalMobileFeeRate = config.Payment.DanalMobileFeeRate,
-                DanalMobileFeeAmount = config.Payment.DanalMobileFeeAmount,
-                DanalVbankFeeRate = config.Payment.DanalVbankFeeRate,
-                DanalVbankFeeAmount = config.Payment.DanalVbankFeeAmount,
-                IsCardEnabled = config.Payment.IsCardEnabled,
-                IsKakaoPayEnabled = config.Payment.IsKakaoPayEnabled,
-                IsNaverPayEnabled = config.Payment.IsNaverPayEnabled,
-                IsTransferEnabled = config.Payment.IsTransferEnabled,
-                IsMobileEnabled = config.Payment.IsMobileEnabled,
-                IsVirtualAccountEnabled = config.Payment.IsVirtualAccountEnabled
+                TossPayMode = config.External.TossPayMode,
+                HasTestClientKey = !string.IsNullOrEmpty(config.External.TossTestClientKeyEnc),
+                HasTestSecretKey = !string.IsNullOrEmpty(config.External.TossTestSecretKeyEnc),
+                HasLiveClientKey = !string.IsNullOrEmpty(config.External.TossLiveClientKeyEnc),
+                HasLiveSecretKey = !string.IsNullOrEmpty(config.External.TossLiveSecretKeyEnc)
             },
 
             Attendance = new Response.AttendanceConfigDto
@@ -178,6 +158,14 @@ public sealed class Handler(IAppDbContext db, ICacheService cache) : IQueryHandl
                 StreakBonusMaxDays = config.Attendance.StreakBonusMaxDays,
                 UseRankBonus = config.Attendance.UseRankBonus,
                 RankBonusConfig = config.Attendance.RankBonusConfig
+            },
+
+            SignupReward = new Response.SignupRewardConfigDto
+            {
+                Enabled = config.SignupReward.Enabled,
+                CoinAmount = config.SignupReward.CoinAmount,
+                CashAmount = config.SignupReward.CashAmount,
+                ExpAmount = config.SignupReward.ExpAmount
             }
         };
 

+ 33 - 65
Application/Features/Config/Get/Response.cs

@@ -14,8 +14,9 @@ public sealed class Response
     public AccountConfigDto Account { get; init; } = new();
     public EmailTemplateConfigDto EmailTemplate { get; init; } = new();
     public ExternalApiConfigDto External { get; init; } = new();
-    public PaymentConfigDto Payment { get; init; } = new();
+    public TossConfigDto Toss { get; init; } = new();
     public AttendanceConfigDto Attendance { get; init; } = new();
+    public SignupRewardConfigDto SignupReward { get; init; } = new();
 
     public sealed class BasicConfigDto
     {
@@ -320,73 +321,25 @@ public sealed class Response
 
         [DisplayName("Google - App ID")]
         public string? GoogleAppId { get; init; }
+    }
 
-        [DisplayName("다날 - 결제 환경")]
-        public string? DanalPayMode { get; init; }
-
-        [DisplayName("다날 - Test CPID")]
-        public string? DanalTestCpid { get; init; }
-
-        [DisplayName("다날 - Test Client Key")]
-        public string? DanalTestClientKeyEnc { get; init; }
-
-        [DisplayName("다날 - Test Secret Key")]
-        public string? DanalTestSecretKeyEnc { get; init; }
+    // 토스페이먼츠 결제 설정 — 키 원문/암호문은 응답에 싣지 않는다 (설정 여부만 노출)
+    public sealed class TossConfigDto
+    {
+        [DisplayName("토스 - 결제 환경")]
+        public string? TossPayMode { get; init; }
 
-        [DisplayName("다날 - Live CPID")]
-        public string? DanalLiveCpid { get; init; }
+        [DisplayName("토스 - Test Client Key 설정 여부")]
+        public bool HasTestClientKey { get; init; }
 
-        [DisplayName("다날 - Live Client Key")]
-        public string? DanalLiveClientKeyEnc { get; init; }
+        [DisplayName("토스 - Test Secret Key 설정 여부")]
+        public bool HasTestSecretKey { get; init; }
 
-        [DisplayName("다날 - Live Secret Key")]
-        public string? DanalLiveSecretKeyEnc { get; init; }
-    }
+        [DisplayName("토스 - Live Client Key 설정 여부")]
+        public bool HasLiveClientKey { get; init; }
 
-    public sealed class PaymentConfigDto
-    {
-        [DisplayName("신용카드 수수료(%)")]
-        public decimal? DanalCardFeeRate { get; init; }
-        [DisplayName("신용카드 수수료(원)")]
-        public decimal? DanalCardFeeAmount { get; init; }
-
-        [DisplayName("카카오페이 수수료(%)")]
-        public decimal? DanalKakaoPayFeeRate { get; init; }
-        [DisplayName("카카오페이 수수료(원)")]
-        public decimal? DanalKakaoPayFeeAmount { get; init; }
-
-        [DisplayName("네이버페이 수수료(%)")]
-        public decimal? DanalNaverPayFeeRate { get; init; }
-        [DisplayName("네이버페이 수수료(원)")]
-        public decimal? DanalNaverPayFeeAmount { get; init; }
-
-        [DisplayName("계좌이체 수수료(%)")]
-        public decimal? DanalTransferFeeRate { get; init; }
-        [DisplayName("계좌이체 수수료(원)")]
-        public decimal? DanalTransferFeeAmount { get; init; }
-
-        [DisplayName("휴대폰 수수료(%)")]
-        public decimal? DanalMobileFeeRate { get; init; }
-        [DisplayName("휴대폰 수수료(원)")]
-        public decimal? DanalMobileFeeAmount { get; init; }
-
-        [DisplayName("가상계좌 수수료(%)")]
-        public decimal? DanalVbankFeeRate { get; init; }
-        [DisplayName("가상계좌 수수료(원)")]
-        public decimal? DanalVbankFeeAmount { get; init; }
-
-        [DisplayName("신용카드 사용 여부")]
-        public bool IsCardEnabled { get; init; } = false;
-        [DisplayName("카카오페이 사용 여부")]
-        public bool IsKakaoPayEnabled { get; init; } = false;
-        [DisplayName("네이버페이 사용 여부")]
-        public bool IsNaverPayEnabled { get; init; } = false;
-        [DisplayName("계좌이체 사용 여부")]
-        public bool IsTransferEnabled { get; init; } = false;
-        [DisplayName("휴대폰 사용 여부")]
-        public bool IsMobileEnabled { get; init; } = false;
-        [DisplayName("가상계좌 사용 여부")]
-        public bool IsVirtualAccountEnabled { get; init; } = false;
+        [DisplayName("토스 - Live Secret Key 설정 여부")]
+        public bool HasLiveSecretKey { get; init; }
     }
 
     public sealed class AttendanceConfigDto
@@ -397,7 +350,7 @@ public sealed class Response
         [DisplayName("기본 경험치")]
         public int BaseExp { get; init; } = 0;
 
-        [DisplayName("기본 포인트")]
+        [DisplayName("기본 코인")]
         public int BasePoint { get; init; } = 0;
 
         [DisplayName("연속 출석 가중치 사용")]
@@ -406,7 +359,7 @@ public sealed class Response
         [DisplayName("1일당 추가 경험치")]
         public int StreakBonusPerDay { get; init; } = 0;
 
-        [DisplayName("1일당 추가 포인트")]
+        [DisplayName("1일당 추가 코인")]
         public int StreakBonusPointPerDay { get; init; } = 0;
 
         [DisplayName("가중치 최대 적용 일수")]
@@ -418,4 +371,19 @@ public sealed class Response
         [DisplayName("순위별 보상 설정 (JSON)")]
         public string? RankBonusConfig { get; init; }
     }
+
+    public sealed class SignupRewardConfigDto
+    {
+        [DisplayName("가입 축하 보상 활성화")]
+        public bool Enabled { get; init; } = false;
+
+        [DisplayName("지급 코인")]
+        public int CoinAmount { get; init; } = 0;
+
+        [DisplayName("지급 캐시")]
+        public int CashAmount { get; init; } = 0;
+
+        [DisplayName("지급 경험치")]
+        public int ExpAmount { get; init; } = 0;
+    }
 }

+ 2 - 1
Application/Features/Config/Update/Command.cs

@@ -10,8 +10,9 @@ public sealed record Command(
     Request.AccountConfigDto? Account = null,
     Request.EmailTemplateConfigDto? EmailTemplate = null,
     Request.ExternalApiConfigDto? External = null,
-    Request.PaymentConfigDto? Payment = null,
+    Request.TossConfigDto? Toss = null,
     Request.AttendanceConfigDto? Attendance = null,
+    Request.SignupRewardConfigDto? SignupReward = null,
     Command.ImagesDeleteFlags? ImagesDelete = null
 ) : ICommand {
     public sealed record ImagesDeleteFlags(

+ 43 - 34
Application/Features/Config/Update/Handler.cs

@@ -1,4 +1,5 @@
 using Application.Abstractions.Messaging;
+using Application.Abstractions.Crypto;
 using Application.Abstractions.Data;
 using Application.Abstractions.Cache;
 using Domain.Entities.Common;
@@ -8,7 +9,7 @@ using Microsoft.EntityFrameworkCore;
 
 namespace Application.Features.Config.Update;
 
-public sealed class Handler(IAppDbContext db, IFileStorage storage, IEditorImageService editorImage, ICacheService cache) : ICommandHandler<Command>
+public sealed class Handler(IAppDbContext db, IFileStorage storage, IEditorImageService editorImage, ICacheService cache, IFieldEncryptor encryptor) : ICommandHandler<Command>
 {
     public async Task Handle(Command request, CancellationToken ct)
     {
@@ -26,7 +27,6 @@ public sealed class Handler(IAppDbContext db, IFileStorage storage, IEditorImage
         var account = new AccountConfig { };
         var emailTemplate = new EmailTemplateConfig { };
         var external = new ExternalApiConfig { };
-        var payment = new PaymentConfig { };
 
         if (request.Basic != null)
         {
@@ -156,41 +156,25 @@ public sealed class Handler(IAppDbContext db, IFileStorage storage, IEditorImage
             external.GoogleClientId = request.External.GoogleClientId;
             external.GoogleClientSecretEnc = request.External.GoogleClientSecretEnc;
             external.GoogleAppId = request.External.GoogleAppId;
-            external.DanalPayMode = request.External.DanalPayMode;
-            external.DanalTestCpid = request.External.DanalTestCpid;
-            external.DanalTestClientKeyEnc = request.External.DanalTestClientKeyEnc;
-            external.DanalTestSecretKeyEnc = request.External.DanalTestSecretKeyEnc;
-            external.DanalLiveCpid = request.External.DanalLiveCpid;
-            external.DanalLiveClientKeyEnc = request.External.DanalLiveClientKeyEnc;
-            external.DanalLiveSecretKeyEnc = request.External.DanalLiveSecretKeyEnc;
+
+            // 토스 필드는 결제 설정 화면(request.Toss) 소관 — External 저장 시 보존
+            external.TossPayMode = config.External.TossPayMode;
+            external.TossTestClientKeyEnc = config.External.TossTestClientKeyEnc;
+            external.TossTestSecretKeyEnc = config.External.TossTestSecretKeyEnc;
+            external.TossLiveClientKeyEnc = config.External.TossLiveClientKeyEnc;
+            external.TossLiveSecretKeyEnc = config.External.TossLiveSecretKeyEnc;
         } else {
             external = config.External;
         }
 
-        if (request.Payment != null)
-        {
-            payment.DanalCardFeeRate = request.Payment.DanalCardFeeRate;
-            payment.DanalCardFeeAmount = request.Payment.DanalCardFeeAmount;
-            payment.DanalKakaoPayFeeRate = request.Payment.DanalKakaoPayFeeRate;
-            payment.DanalKakaoPayFeeAmount = request.Payment.DanalKakaoPayFeeAmount;
-            payment.DanalNaverPayFeeRate = request.Payment.DanalNaverPayFeeRate;
-            payment.DanalNaverPayFeeAmount = request.Payment.DanalNaverPayFeeAmount;
-            payment.DanalTransferFeeRate = request.Payment.DanalTransferFeeRate;
-            payment.DanalTransferFeeAmount = request.Payment.DanalTransferFeeAmount;
-            payment.DanalMobileFeeRate = request.Payment.DanalMobileFeeRate;
-            payment.DanalMobileFeeAmount = request.Payment.DanalMobileFeeAmount;
-            payment.DanalVbankFeeRate = request.Payment.DanalVbankFeeRate;
-            payment.DanalVbankFeeAmount = request.Payment.DanalVbankFeeAmount;
-            payment.IsCardEnabled = request.Payment.IsCardEnabled;
-            payment.IsKakaoPayEnabled = request.Payment.IsKakaoPayEnabled;
-            payment.IsNaverPayEnabled = request.Payment.IsNaverPayEnabled;
-            payment.IsTransferEnabled = request.Payment.IsTransferEnabled;
-            payment.IsMobileEnabled = request.Payment.IsMobileEnabled;
-            payment.IsVirtualAccountEnabled = request.Payment.IsVirtualAccountEnabled;
-        }
-        else
+        if (request.Toss != null)
         {
-            payment = config.Payment;
+            // 키는 빈 값 = 기존 유지, 입력 시 암호화 저장 (이미 암호문 포맷이면 이중 암호화 방지 위해 그대로 저장)
+            external.TossPayMode = request.Toss.TossPayMode;
+            external.TossTestClientKeyEnc = ProtectOrKeep(request.Toss.TestClientKey, external.TossTestClientKeyEnc);
+            external.TossTestSecretKeyEnc = ProtectOrKeep(request.Toss.TestSecretKey, external.TossTestSecretKeyEnc);
+            external.TossLiveClientKeyEnc = ProtectOrKeep(request.Toss.LiveClientKey, external.TossLiveClientKeyEnc);
+            external.TossLiveSecretKeyEnc = ProtectOrKeep(request.Toss.LiveSecretKey, external.TossLiveSecretKeyEnc);
         }
 
         var attendance = new AttendanceConfig { };
@@ -210,6 +194,18 @@ public sealed class Handler(IAppDbContext db, IFileStorage storage, IEditorImage
             attendance = config.Attendance;
         }
 
+        var signupReward = new SignupRewardConfig { };
+
+        if (request.SignupReward != null)
+        {
+            signupReward.Enabled = request.SignupReward.Enabled;
+            signupReward.CoinAmount = request.SignupReward.CoinAmount;
+            signupReward.CashAmount = request.SignupReward.CashAmount;
+            signupReward.ExpAmount = request.SignupReward.ExpAmount;
+        } else {
+            signupReward = config.SignupReward;
+        }
+
         config.Update(
             basic,
             images,
@@ -218,15 +214,28 @@ public sealed class Handler(IAppDbContext db, IFileStorage storage, IEditorImage
             account,
             emailTemplate,
             external,
-            payment,
             config.Crypto,
-            attendance
+            attendance,
+            signupReward
         );
 
         await db.SaveChangesAsync(ct);
         await cache.RemoveAsync(CacheKeys.Config, ct);
     }
 
+    /// <summary>키 입력값이 비어있으면 기존 값 유지, 입력됐으면 암호화 저장. 이미 암호문 포맷이면 재암호화하지 않는다.</summary>
+    private string? ProtectOrKeep(string? input, string? current)
+    {
+        if (string.IsNullOrWhiteSpace(input))
+        {
+            return current;
+        }
+
+        var trimmed = input.Trim();
+
+        return EncryptedConfigValue.IsProtected(trimmed) ? trimmed : EncryptedConfigValue.Protect(encryptor, trimmed);
+    }
+
     private async Task<string?> ApplyImageAsync(bool deleteRequested, string? currentUrl, IFormFile? newFile, string[] allowedExtensions, CancellationToken ct)
     {
         if (deleteRequested)

+ 50 - 96
Application/Features/Config/Update/Request.cs

@@ -14,8 +14,9 @@ public sealed class Request
     public AccountConfigDto Account { get; init; } = new();
     public EmailTemplateConfigDto EmailTemplate { get; init; } = new();
     public ExternalApiConfigDto External { get; init; } = new();
-    public PaymentConfigDto Payment { get; init; } = new();
+    public TossConfigDto Toss { get; init; } = new();
     public AttendanceConfigDto? Attendance { get; init; }
+    public SignupRewardConfigDto? SignupReward { get; init; }
 
     // 기본 설정
     public sealed class BasicConfigDto
@@ -427,81 +428,30 @@ public sealed class Request
         [MaxLength(500)]
         [DisplayName("Google - App ID")]
         public string? GoogleAppId { get; init; }
+    }
 
-        [DisplayName("다날 - 결제 환경")]
-        public string? DanalPayMode { get; init; }
-
-        [MaxLength(100)]
-        [DisplayName("다날 - Test CPID")]
-        public string? DanalTestCpid { get; init; }
+    // 토스페이먼츠 결제 설정 (결제 설정 화면 전용)
+    // 키 입력값은 저장 시 암호화되며, 빈 값으로 제출하면 기존 저장 키를 유지한다.
+    public sealed class TossConfigDto
+    {
+        [DisplayName("토스 - 결제 환경")]
+        public string? TossPayMode { get; init; }
 
         [MaxLength(500)]
-        [DisplayName("다날 - Test Client Key")]
-        public string? DanalTestClientKeyEnc { get; init; }
+        [DisplayName("토스 - Test Client Key")]
+        public string? TestClientKey { get; init; }
 
         [MaxLength(500)]
-        [DisplayName("다날 - Test Secret Key")]
-        public string? DanalTestSecretKeyEnc { get; init; }
-
-        [MaxLength(100)]
-        [DisplayName("다날 - Live CPID")]
-        public string? DanalLiveCpid { get; init; }
+        [DisplayName("토스 - Test Secret Key")]
+        public string? TestSecretKey { get; init; }
 
         [MaxLength(500)]
-        [DisplayName("다날 - Live Client Key")]
-        public string? DanalLiveClientKeyEnc { get; init; }
+        [DisplayName("토스 - Live Client Key")]
+        public string? LiveClientKey { get; init; }
 
         [MaxLength(500)]
-        [DisplayName("다날 - Live Secret Key")]
-        public string? DanalLiveSecretKeyEnc { get; init; }
-    }
-
-    public sealed class PaymentConfigDto
-    {
-        // 수수료 — %가 우선, 원이 그 다음
-        [Range(0, 100), DisplayName("신용카드 수수료(%)")]
-        public decimal? DanalCardFeeRate { get; init; }
-        [Range(0, 100000000), DisplayName("신용카드 수수료(원)")]
-        public decimal? DanalCardFeeAmount { get; init; }
-
-        [Range(0, 100), DisplayName("카카오페이 수수료(%)")]
-        public decimal? DanalKakaoPayFeeRate { get; init; }
-        [Range(0, 100000000), DisplayName("카카오페이 수수료(원)")]
-        public decimal? DanalKakaoPayFeeAmount { get; init; }
-
-        [Range(0, 100), DisplayName("네이버페이 수수료(%)")]
-        public decimal? DanalNaverPayFeeRate { get; init; }
-        [Range(0, 100000000), DisplayName("네이버페이 수수료(원)")]
-        public decimal? DanalNaverPayFeeAmount { get; init; }
-
-        [Range(0, 100), DisplayName("계좌이체 수수료(%)")]
-        public decimal? DanalTransferFeeRate { get; init; }
-        [Range(0, 100000000), DisplayName("계좌이체 수수료(원)")]
-        public decimal? DanalTransferFeeAmount { get; init; }
-
-        [Range(0, 100), DisplayName("휴대폰 수수료(%)")]
-        public decimal? DanalMobileFeeRate { get; init; }
-        [Range(0, 100000000), DisplayName("휴대폰 수수료(원)")]
-        public decimal? DanalMobileFeeAmount { get; init; }
-
-        [Range(0, 100), DisplayName("가상계좌 수수료(%)")]
-        public decimal? DanalVbankFeeRate { get; init; }
-        [Range(0, 100000000), DisplayName("가상계좌 수수료(원)")]
-        public decimal? DanalVbankFeeAmount { get; init; }
-
-        // 결제 수단 사용 여부
-        [DisplayName("신용카드 사용 여부")]
-        public bool IsCardEnabled { get; init; } = false;
-        [DisplayName("카카오페이 사용 여부")]
-        public bool IsKakaoPayEnabled { get; init; } = false;
-        [DisplayName("네이버페이 사용 여부")]
-        public bool IsNaverPayEnabled { get; init; } = false;
-        [DisplayName("계좌이체 사용 여부")]
-        public bool IsTransferEnabled { get; init; } = false;
-        [DisplayName("휴대폰 사용 여부")]
-        public bool IsMobileEnabled { get; init; } = false;
-        [DisplayName("가상계좌 사용 여부")]
-        public bool IsVirtualAccountEnabled { get; init; } = false;
+        [DisplayName("토스 - Live Secret Key")]
+        public string? LiveSecretKey { get; init; }
     }
 
     // 출석 설정
@@ -515,7 +465,7 @@ public sealed class Request
         public int BaseExp { get; init; } = 0;
 
         [Range(0, 100000)]
-        [DisplayName("기본 포인트")]
+        [DisplayName("기본 코인")]
         public int BasePoint { get; init; } = 0;
 
         [DisplayName("연속 출석 가중치 사용")]
@@ -526,7 +476,7 @@ public sealed class Request
         public int StreakBonusPerDay { get; init; } = 0;
 
         [Range(0, 10000)]
-        [DisplayName("1일당 추가 포인트")]
+        [DisplayName("1일당 추가 코인")]
         public int StreakBonusPointPerDay { get; init; } = 0;
 
         [Range(0, 365)]
@@ -541,6 +491,25 @@ public sealed class Request
         public string? RankBonusConfig { get; init; }
     }
 
+    // 가입 축하 보상 설정
+    public sealed class SignupRewardConfigDto
+    {
+        [DisplayName("가입 축하 보상 활성화")]
+        public bool Enabled { get; init; } = false;
+
+        [Range(0, 100000000)]
+        [DisplayName("지급 코인")]
+        public int CoinAmount { get; init; } = 0;
+
+        [Range(0, 100000000)]
+        [DisplayName("지급 캐시")]
+        public int CashAmount { get; init; } = 0;
+
+        [Range(0, 100000)]
+        [DisplayName("지급 경험치")]
+        public int ExpAmount { get; init; } = 0;
+    }
+
     public static Request From(Get.Response src) => new()
     {
         ID = src.ID,
@@ -656,36 +625,13 @@ public sealed class Request
             YouTubeApiName = src.External.YouTubeApiName,
             GoogleClientId = src.External.GoogleClientId,
             GoogleClientSecretEnc = src.External.GoogleClientSecretEnc,
-            GoogleAppId = src.External.GoogleAppId,
-            DanalPayMode = src.External.DanalPayMode,
-            DanalTestCpid = src.External.DanalTestCpid,
-            DanalTestClientKeyEnc = src.External.DanalTestClientKeyEnc,
-            DanalTestSecretKeyEnc = src.External.DanalTestSecretKeyEnc,
-            DanalLiveCpid = src.External.DanalLiveCpid,
-            DanalLiveClientKeyEnc = src.External.DanalLiveClientKeyEnc,
-            DanalLiveSecretKeyEnc = src.External.DanalLiveSecretKeyEnc
+            GoogleAppId = src.External.GoogleAppId
         },
 
-        Payment = new PaymentConfigDto
+        // 토스 키는 화면에 되돌리지 않는다 (암호문 재제출로 인한 이중 암호화 방지 — 빈 값 = 기존 유지)
+        Toss = new TossConfigDto
         {
-            DanalCardFeeRate = src.Payment.DanalCardFeeRate,
-            DanalCardFeeAmount = src.Payment.DanalCardFeeAmount,
-            DanalKakaoPayFeeRate = src.Payment.DanalKakaoPayFeeRate,
-            DanalKakaoPayFeeAmount = src.Payment.DanalKakaoPayFeeAmount,
-            DanalNaverPayFeeRate = src.Payment.DanalNaverPayFeeRate,
-            DanalNaverPayFeeAmount = src.Payment.DanalNaverPayFeeAmount,
-            DanalTransferFeeRate = src.Payment.DanalTransferFeeRate,
-            DanalTransferFeeAmount = src.Payment.DanalTransferFeeAmount,
-            DanalMobileFeeRate = src.Payment.DanalMobileFeeRate,
-            DanalMobileFeeAmount = src.Payment.DanalMobileFeeAmount,
-            DanalVbankFeeRate = src.Payment.DanalVbankFeeRate,
-            DanalVbankFeeAmount = src.Payment.DanalVbankFeeAmount,
-            IsCardEnabled = src.Payment.IsCardEnabled,
-            IsKakaoPayEnabled = src.Payment.IsKakaoPayEnabled,
-            IsNaverPayEnabled = src.Payment.IsNaverPayEnabled,
-            IsTransferEnabled = src.Payment.IsTransferEnabled,
-            IsMobileEnabled = src.Payment.IsMobileEnabled,
-            IsVirtualAccountEnabled = src.Payment.IsVirtualAccountEnabled
+            TossPayMode = src.Toss.TossPayMode
         },
 
         Attendance = new AttendanceConfigDto
@@ -699,6 +645,14 @@ public sealed class Request
             StreakBonusMaxDays = src.Attendance.StreakBonusMaxDays,
             UseRankBonus = src.Attendance.UseRankBonus,
             RankBonusConfig = src.Attendance.RankBonusConfig
+        },
+
+        SignupReward = new SignupRewardConfigDto
+        {
+            Enabled = src.SignupReward.Enabled,
+            CoinAmount = src.SignupReward.CoinAmount,
+            CashAmount = src.SignupReward.CashAmount,
+            ExpAmount = src.SignupReward.ExpAmount
         }
     };
 }

+ 108 - 0
Application/Helpers/SignupRewardGranter.cs

@@ -0,0 +1,108 @@
+using Application.Abstractions.Cache;
+using Application.Abstractions.Data;
+using Application.Features.Config.Get;
+using Domain.Entities.Common.ValueObject;
+using Domain.Entities.Members.Logs;
+using Domain.Entities.Wallets.ValueObject;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Helpers;
+
+/// <summary>
+/// 가입 축하 보상 지급 (코인=Airdrop / 캐시=Adjusted / XP=MemberStats+MemberExpLog).
+/// 멱등: WalletTransaction RefID "signup:{memberID}" 가 이미 존재하면 재지급하지 않는다.
+/// 신규 회원 생성 지점(로컬 가입/소셜 자동가입)에서 호출하며, SaveChangesAsync 는 호출자가 수행한다.
+/// </summary>
+public static class SignupRewardGranter
+{
+    public static string RefIDFor(int memberID) => $"signup:{memberID}";
+
+    /// <summary>
+    /// 가입 보상을 대상 회원 지갑/스탯에 반영한다. 설정 비활성/전액 0 이거나 이미 지급된 경우 아무것도 하지 않는다.
+    /// 지갑은 호출자 컨텍스트에서 추적 상태로 로드해 변경을 SaveChangesAsync 에 위임한다.
+    /// </summary>
+    public static async Task GrantAsync(IAppDbContext db, ICacheService cache, int memberID, CancellationToken ct)
+    {
+        if (memberID <= 0)
+        {
+            return;
+        }
+
+        var config = await LoadConfigAsync(db, cache, ct);
+        if (config is null || !config.Enabled)
+        {
+            return;
+        }
+
+        var coin = config.CoinAmount;
+        var cash = config.CashAmount;
+        var exp = config.ExpAmount;
+
+        if (coin <= 0 && cash <= 0 && exp <= 0)
+        {
+            return;
+        }
+
+        var refID = RefIDFor(memberID);
+
+        // 멱등: 이미 가입 보상 트랜잭션이 있으면 재지급 금지
+        var alreadyGranted = await db.WalletTransaction.AsNoTracking()
+            .AnyAsync(c => c.RefID == refID && c.TxType == WalletTransactionType.RewardEarned, ct);
+
+        if (alreadyGranted)
+        {
+            return;
+        }
+
+        // 코인/캐시 지급 (지갑 필요)
+        if (coin > 0 || cash > 0)
+        {
+            var wallet = await db.Wallet
+                .Include(c => c.Balances)
+                .FirstOrDefaultAsync(c => c.MemberID == memberID, ct);
+
+            if (wallet is not null)
+            {
+                if (coin > 0)
+                {
+                    wallet.CreditSignupReward(WalletBalanceType.Airdrop, Money.KRW(coin), "SIGNUP_REWARD", refID);
+                }
+
+                if (cash > 0)
+                {
+                    wallet.CreditSignupReward(WalletBalanceType.Adjusted, Money.KRW(cash), "SIGNUP_REWARD", refID);
+                }
+            }
+        }
+
+        // XP 지급 (MemberStats + MemberExpLog)
+        if (exp > 0)
+        {
+            var stats = await db.MemberStats.FirstOrDefaultAsync(c => c.MemberID == memberID, ct);
+            if (stats is not null)
+            {
+                stats.Exp += exp;
+                db.MemberExpLog.Add(MemberExpLog.Create(memberID, "가입 축하 보상", exp, stats.Exp));
+            }
+        }
+    }
+
+    private static async Task<Domain.Entities.Common.SignupRewardConfig?> LoadConfigAsync(IAppDbContext db, ICacheService cache, CancellationToken ct)
+    {
+        var cached = await cache.GetAsync<Response>(CacheKeys.Config, ct);
+        if (cached is not null)
+        {
+            return new Domain.Entities.Common.SignupRewardConfig
+            {
+                Enabled = cached.SignupReward.Enabled,
+                CoinAmount = cached.SignupReward.CoinAmount,
+                CashAmount = cached.SignupReward.CashAmount,
+                ExpAmount = cached.SignupReward.ExpAmount
+            };
+        }
+
+        var entity = await db.Config.AsNoTracking().OrderByDescending(c => c.ID).FirstOrDefaultAsync(ct);
+
+        return entity?.SignupReward;
+    }
+}

+ 10 - 9
CLAUDE.md

@@ -29,7 +29,7 @@ Domain/          → Entity, ValueObject
       ├── Store/          → Product, Order, CouponCode, GameSettlement 등
       ├── Notes/          → Note (쪽지)
       ├── Notifications/  → Notification (알림)
-      ├── Payments/       → PaymentOrder, DanalConfirm/Cancel, PaymentLog, PaymentReconcileLog
+      ├── Payments/       → PaymentOrder, Toss(Confirm/Cancel/Log), PaymentReconcileLog
       └── Forum/          → Board, Post, Comment
 Infrastructure/  → DB, Auth, Storage, Email
   ├── Authentication/ → JWT, Identity, GoogleOAuth, NaverAuthProvider, KakaoAuthProvider
@@ -39,7 +39,7 @@ Infrastructure/  → DB, Auth, Storage, Email
   ├── Notification/   → NotificationService (쪽지/알림 발송)
   ├── StockData/      → 금융위(data.go.kr) 종목마스터·일별시세 수집 배치 (M1 신규)
   ├── YouTube/        → [비활성 — Features:Channel 플래그] YouTubeApiService, PubSub, LiveChat
-  ├── Payment/        → DanalPayService (PG 결제)
+  ├── Payment/        → TossPaymentService (PG 결제)
   ├── Forum/          → 게시판 비즈니스 로직
   ├── Messaging/      → Email (SMTP, MimeKit)
   ├── Persistence/    → AppDbContext, IdentityDbContext, Migrations
@@ -99,7 +99,7 @@ Auth(social-login/disconnect 포함), Banner, Channel(플래그 게이트), Chat
   - AppHub (`/hubs/app`) — 전역 (접속자 추적, 알림, 쪽지)
   - ChatHub (`/hubs/chat`) — 룸 기반 채팅: `JoinRoom(roomKey)` = `main` | `stock:{code}` (게스트 읽기 허용, 전송은 인증)
 - YouTube: [비활성 — Features:Channel] YouTubeApiService, PubSubHubbub, LiveChatService
-- Payment: 다날(DanalPay) PG 결제 연동 (Confirm/Cancel + PaymentReconcileLog 대사, NeedsReconciliation 상태)
+- Payment: 토스페이먼츠(TossPayments) PG 결제 연동 (Confirm/Cancel + PaymentReconcileLog 대사, NeedsReconciliation 상태)
 - Social Login: Google + Naver/Kakao (`/api/auth/social-login`, 자동 계정연결 금지, disconnect 웹훅)
 - StockData: 금융위 T+1 시세·종목마스터 배치 (`StockData:Enabled` + ServiceKey 필요)
 - DI: 각 레이어별 DependencyInjection.cs
@@ -149,16 +149,17 @@ dotnet ef database update --project Infrastructure --startup-project Admin --con
 ## 용어 규칙 (개미투자 — d0-overview §2.2 확정)
 
 - **캐시**: 충전 재화 = WalletBalanceType `PgCharged + Deposit` (다날 PG 충전)
-- **포인트**: 활동 적립 재화 = `Reward + Airdrop` (출석/글/댓글 보상 — D3 M3 예정)
+- **코인**: 활동 적립 재화 = `Reward + Airdrop` (출석/글/댓글 보상 — D3 M3 예정)
 - 후원/머니 개념은 Phase B 에서 제거됨. `Donation`/`StoreRevenue` enum 값은 원장 호환용으로만 보존(`[비활성]` 주석)
 
-## Payment Gateway (다날 PG)
+## Payment Gateway (토스페이먼츠 PG)
 
-- SDK: `@danalpay/javascript-sdk` (Frontend)
-- Server: `Infrastructure/Payment/DanalPayService.cs`
-- 테이블: PaymentOrder → PaymentConfirm → PaymentCancel, PaymentLog
+- SDK: Toss V2 JS SDK (`@tosspayments/tosspayments-sdk`, Frontend)
+- Server: `Infrastructure/Payment/TossPaymentService.cs` (V2 REST — api.tosspayments.com)
+- 테이블: PaymentOrder → TossConfirm → TossCancel, TossLog (+ PaymentReconcileLog 대사)
 - 결제수단: Card, VirtualAccount, Mobile, Transfer, NaverPay, KakaoPay, Payco
-- Config: DB Config.Payment에 CPID, ClientKey, SecretKey 저장 (암호화)
+- Config: DB Config.External 에 TossPayMode(test/live) + Test/Live ClientKey·SecretKey 저장 (AES-GCM 암호화, `enc:v{n}:...` 포맷)
+- 라우트(Web.Api): POST `/api/payments/orders` · `/confirm` · `/cancel` (인증) · `/webhook` (Anonymous)
 
 ## Database Schema
 **AI 지시**: DB 테이블/컬럼/관계 관련 작업 시 먼저 아래 스키마 파일을 Read로 확인할 것.

+ 31 - 59
Domain/Entities/Common/Config.cs

@@ -12,9 +12,9 @@ public sealed class Config
     public AccountConfig Account { get; private set; } = new();
     public EmailTemplateConfig EmailTemplate { get; private set; } = new();
     public ExternalApiConfig External { get; private set; } = new();
-    public PaymentConfig Payment { get; private set; } = new();
     public CryptoConfig Crypto { get; private set; } = new();
     public AttendanceConfig Attendance { get; private set; } = new();
+    public SignupRewardConfig SignupReward { get; private set; } = new();
     public ChatExpConfig ChatExp { get; private set; } = new();
 
     private Config() { }
@@ -32,9 +32,9 @@ public sealed class Config
         AccountConfig account,
         EmailTemplateConfig emailTemplate,
         ExternalApiConfig external,
-        PaymentConfig payment,
         CryptoConfig crypto,
-        AttendanceConfig attendance
+        AttendanceConfig attendance,
+        SignupRewardConfig signupReward
     ) {
         Basic = basic ?? throw new ArgumentNullException(nameof(basic));
         Images = images ?? throw new ArgumentNullException(nameof(images));
@@ -43,9 +43,9 @@ public sealed class Config
         Account = account ?? throw new ArgumentNullException(nameof(account));
         EmailTemplate = emailTemplate ?? throw new ArgumentNullException(nameof(emailTemplate));
         External = external ?? throw new ArgumentNullException(nameof(external));
-        Payment = payment ?? throw new ArgumentNullException(nameof(payment));
         Crypto = crypto ?? throw new ArgumentNullException(nameof(crypto));
         Attendance = attendance ?? throw new ArgumentNullException(nameof(attendance));
+        SignupReward = signupReward ?? throw new ArgumentNullException(nameof(signupReward));
         LastUpdatedAt = DateTime.UtcNow;
     }
 
@@ -202,61 +202,17 @@ public sealed class ExternalApiConfig
     public string? GoogleClientSecretEnc { get; set; }
     public string? GoogleAppId { get; set; }
 
-    /** 다날 페이 **/
-    /// <summary>Danal 결제 환경 (test / live)</summary>
-    public string? DanalPayMode { get; set; }
-    /// <summary>Danal Test CPID</summary>
-    public string? DanalTestCpid { get; set; }
-    /// <summary>Danal Test Client Key (암호화 저장 권장)</summary>
-    public string? DanalTestClientKeyEnc { get; set; }
-    /// <summary>Danal Test Secret Key (암호화 저장 권장)</summary>
-    public string? DanalTestSecretKeyEnc { get; set; }
-    /// <summary>Danal Live CPID</summary>
-    public string? DanalLiveCpid { get; set; }
-    /// <summary>Danal Live Client Key (암호화 저장 권장)</summary>
-    public string? DanalLiveClientKeyEnc { get; set; }
-    /// <summary>Danal Live Secret Key (암호화 저장 권장)</summary>
-    public string? DanalLiveSecretKeyEnc { get; set; }
-}
-
-// ==================================================
-// 결제 설정 (Payment)
-// ==================================================
-public sealed class PaymentConfig
-{
-    /** 다날 페이 수수료 (% 우선 적용 후 원 가산) **/
-    /// <summary>신용카드 수수료(%)</summary>
-    public decimal? DanalCardFeeRate { get; set; }
-    /// <summary>신용카드 수수료(원)</summary>
-    public decimal? DanalCardFeeAmount { get; set; }
-    /// <summary>카카오페이 수수료(%)</summary>
-    public decimal? DanalKakaoPayFeeRate { get; set; }
-    /// <summary>카카오페이 수수료(원)</summary>
-    public decimal? DanalKakaoPayFeeAmount { get; set; }
-    /// <summary>네이버페이 수수료(%)</summary>
-    public decimal? DanalNaverPayFeeRate { get; set; }
-    /// <summary>네이버페이 수수료(원)</summary>
-    public decimal? DanalNaverPayFeeAmount { get; set; }
-    /// <summary>계좌이체 수수료(%)</summary>
-    public decimal? DanalTransferFeeRate { get; set; }
-    /// <summary>계좌이체 수수료(원)</summary>
-    public decimal? DanalTransferFeeAmount { get; set; }
-    /// <summary>휴대폰 수수료(%)</summary>
-    public decimal? DanalMobileFeeRate { get; set; }
-    /// <summary>휴대폰 수수료(원)</summary>
-    public decimal? DanalMobileFeeAmount { get; set; }
-    /// <summary>가상계좌 수수료(%)</summary>
-    public decimal? DanalVbankFeeRate { get; set; }
-    /// <summary>가상계좌 수수료(원)</summary>
-    public decimal? DanalVbankFeeAmount { get; set; }
-
-    /** 결제수단 사용 여부 (기본 false — 관리자가 명시적으로 활성화) **/
-    public bool IsCardEnabled { get; set; } = false;
-    public bool IsKakaoPayEnabled { get; set; } = false;
-    public bool IsNaverPayEnabled { get; set; } = false;
-    public bool IsTransferEnabled { get; set; } = false;
-    public bool IsMobileEnabled { get; set; } = false;
-    public bool IsVirtualAccountEnabled { get; set; } = false;
+    /** 토스페이먼츠 **/
+    /// <summary>Toss 결제 환경 (test / live)</summary>
+    public string? TossPayMode { get; set; }
+    /// <summary>Toss Test Client Key (암호화 저장 권장)</summary>
+    public string? TossTestClientKeyEnc { get; set; }
+    /// <summary>Toss Test Secret Key (암호화 저장 권장)</summary>
+    public string? TossTestSecretKeyEnc { get; set; }
+    /// <summary>Toss Live Client Key (암호화 저장 권장)</summary>
+    public string? TossLiveClientKeyEnc { get; set; }
+    /// <summary>Toss Live Secret Key (암호화 저장 권장)</summary>
+    public string? TossLiveSecretKeyEnc { get; set; }
 }
 
 // ==================================================
@@ -329,4 +285,20 @@ public sealed class AttendanceConfig
     public string? RankBonusConfig { get; set; }
 }
 
+// ==================================================
+// 가입 축하 보상 설정 (SignupReward)
+// 신규 회원 최초 가입 시 1회 지급. 멱등: WalletTransaction RefID "signup:{memberID}"
+// ==================================================
+public sealed class SignupRewardConfig
+{
+    /// <summary>가입 축하 보상 활성화</summary>
+    public bool Enabled { get; set; } = false;
+    /// <summary>지급 코인(무상, Airdrop 파티션)</summary>
+    public int CoinAmount { get; set; } = 0;
+    /// <summary>지급 캐시(유상 취급, Adjusted 파티션). 기본 0</summary>
+    public int CashAmount { get; set; } = 0;
+    /// <summary>지급 경험치(XP)</summary>
+    public int ExpAmount { get; set; } = 0;
+}
+
 #endregion

+ 18 - 0
Domain/Entities/Members/Member.cs

@@ -65,6 +65,12 @@ public class Member
 
     public string? Icon { get; private set; }
 
+    /// <summary>
+    /// PG customerKey (토스페이먼츠 — 예측 가능 값 금지, 회원별 UUID).
+    /// 결제 주문 생성 시 없으면 발급·저장한다. 마이그레이션은 후속 처리.
+    /// </summary>
+    public Guid? PaymentCustomerKey { get; private set; }
+
     public bool IsEmailVerified { get; private set; } = false;
 
     public bool IsAuthCertified { get; private set; } = false;
@@ -204,6 +210,18 @@ public class Member
         UpdatedAt = DateTime.UtcNow;
     }
 
+    /// <summary>PG customerKey 확보 — 없으면 이 시점에 UUID 발급 (호출측이 SaveChanges 로 영속화)</summary>
+    public Guid EnsurePaymentCustomerKey()
+    {
+        if (PaymentCustomerKey is null)
+        {
+            PaymentCustomerKey = Guid.NewGuid();
+            UpdatedAt = DateTime.UtcNow;
+        }
+
+        return PaymentCustomerKey.Value;
+    }
+
     public void MarkEmailVerified()
     {
         IsEmailVerified = true;

+ 0 - 134
Domain/Entities/Payments/Danal/DanalCancel.cs

@@ -1,134 +0,0 @@
-using System.ComponentModel.DataAnnotations;
-using System.ComponentModel.DataAnnotations.Schema;
-using Domain.Entities.Members;
-using Domain.Entities.Payments.Danal.ValueObject;
-using Domain.Entities.Payments.ValueObject;
-
-namespace Domain.Entities.Payments.Danal;
-
-/// <summary>
-/// 다날 결제 취소 요청 + 응답 통합
-/// 전체 취소 / 부분 취소 모두 지원
-/// </summary>
-public class DanalCancel
-{
-    [ForeignKey(nameof(MemberID))]
-    public virtual Member? Member { get; private set; }
-
-    [ForeignKey(nameof(PaymentOrderID))]
-    public virtual PaymentOrder? PaymentOrder { get; private set; }
-
-    [Key]
-    public int ID { get; private set; }
-
-    public int MemberID { get; private set; }
-
-    public int PaymentOrderID { get; private set; }
-
-    public PaymentMethod Method { get; private set; }
-
-    /// <summary>원거래 번호</summary>
-    public string TransactionID { get; private set; } = default!;
-
-    /// <summary>가맹점 주문번호</summary>
-    public string OrderID { get; private set; } = default!;
-
-    /// <summary>취소 금액</summary>
-    public int Amount { get; private set; }
-
-    /// <summary>CPID</summary>
-    public string MerchantID { get; private set; } = default!;
-
-    /// <summary>전체/부분 취소</summary>
-    public DanalCancelType CancelType { get; private set; }
-
-    /// <summary>취소 요청자 (admin / system / member)</summary>
-    public string? CancelRequester { get; private set; }
-
-    /// <summary>취소 사유</summary>
-    public string? CancelReason { get; private set; }
-
-    // ── 응답 필드 ────────────────────────────────────────────────────
-
-    /// <summary>응답 코드 (0000 = 성공)</summary>
-    public string? ResponseCode { get; private set; }
-
-    /// <summary>응답 메시지</summary>
-    public string? ResponseMessage { get; private set; }
-
-    /// <summary>원거래 ID (다날 응답)</summary>
-    public string? OriginalTransactionID { get; private set; }
-
-    /// <summary>취소된 금액 (다날 응답)</summary>
-    public int? CancelledAmount { get; private set; }
-
-    /// <summary>원거래 승인일자</summary>
-    public string? TransDate { get; private set; }
-
-    /// <summary>원거래 승인시간</summary>
-    public string? TransTime { get; private set; }
-
-    /// <summary>부분취소 후 남은 금액 (Balance)</summary>
-    public int? Balance { get; private set; }
-
-    /// <summary>부분취소 후 남은 결제 잔액 (RemainedAmount)</summary>
-    public int? RemainedAmount { get; private set; }
-
-    /// <summary>승인 발생 일시</summary>
-    public string? ApprovalDateTime { get; private set; }
-
-    public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
-
-    public DateTime? UpdatedAt { get; private set; }
-
-    private DanalCancel() { }
-
-    public static DanalCancel CreateRequest(
-        int memberID,
-        int paymentOrderID,
-        PaymentMethod method,
-        string transactionID,
-        string orderID,
-        int amount,
-        string merchantID,
-        DanalCancelType cancelType,
-        string? cancelRequester = null,
-        string? cancelReason = null
-    ) {
-        return new DanalCancel
-        {
-            MemberID = memberID,
-            PaymentOrderID = paymentOrderID,
-            Method = method,
-            TransactionID = transactionID,
-            OrderID = orderID,
-            Amount = amount,
-            MerchantID = merchantID,
-            CancelType = cancelType,
-            CancelRequester = cancelRequester,
-            CancelReason = cancelReason
-        };
-    }
-
-    public void SetResponse(
-        string code, string message,
-        string? originalTransactionID = null,
-        int? cancelledAmount = null,
-        string? transDate = null, string? transTime = null,
-        int? balance = null, int? remainedAmount = null,
-        string? approvalDateTime = null
-    ) {
-        ResponseCode = code;
-        ResponseMessage = message;
-        OriginalTransactionID = originalTransactionID;
-        CancelledAmount = cancelledAmount;
-        TransDate = transDate;
-        TransTime = transTime;
-        Balance = balance;
-        RemainedAmount = remainedAmount;
-        ApprovalDateTime = approvalDateTime;
-        UpdatedAt = DateTime.UtcNow;
-    }
-
-    public bool IsSuccess => ResponseCode == "0000";
-}

+ 0 - 208
Domain/Entities/Payments/Danal/DanalConfirm.cs

@@ -1,208 +0,0 @@
-using System.ComponentModel.DataAnnotations;
-using System.ComponentModel.DataAnnotations.Schema;
-using Domain.Entities.Members;
-using Domain.Entities.Payments.ValueObject;
-
-namespace Domain.Entities.Payments.Danal;
-
-/// <summary>
-/// 다날 결제 승인 요청 + 응답 통합
-/// confirm API 호출 시 요청 데이터를 먼저 저장하고, 응답 수신 시 업데이트
-/// </summary>
-public class DanalConfirm
-{
-    [ForeignKey(nameof(MemberID))]
-    public virtual Member? Member { get; private set; }
-
-    [ForeignKey(nameof(PaymentOrderID))]
-    public virtual PaymentOrder? PaymentOrder { get; private set; }
-
-    [Key]
-    public int ID { get; private set; }
-
-    public int MemberID { get; private set; }
-
-    public int PaymentOrderID { get; private set; }
-
-    public PaymentMethod Method { get; private set; }
-
-    /// <summary>다날 거래번호</summary>
-    public string TransactionID { get; private set; } = default!;
-
-    /// <summary>가맹점 주문번호</summary>
-    public string OrderID { get; private set; } = default!;
-
-    /// <summary>요청 금액</summary>
-    public int Amount { get; private set; }
-
-    /// <summary>CPID</summary>
-    public string MerchantID { get; private set; } = default!;
-
-    /// <summary>도서문화상품권: 승인 요청 토큰</summary>
-    public string? CertificateToken { get; private set; }
-
-    // ── 응답 필드 (승인 완료 시 업데이트) ────────────────────────────
-
-    /// <summary>응답 코드 (0000 = 성공)</summary>
-    public string? ResponseCode { get; private set; }
-
-    /// <summary>응답 메시지</summary>
-    public string? ResponseMessage { get; private set; }
-
-    /// <summary>승인된 상품명</summary>
-    public string? OrderName { get; private set; }
-
-    /// <summary>최종 승인 금액</summary>
-    public int? TotalAmount { get; private set; }
-
-    /// <summary>할인 금액</summary>
-    public int? DiscountAmount { get; private set; }
-
-    /// <summary>사용자 이름</summary>
-    public string? UserName { get; private set; }
-
-    // ── 카드 응답 ────────────────────────────────────────────────────
-
-    /// <summary>승인일자 (YYYYMMDD)</summary>
-    public string? TransDate { get; private set; }
-
-    /// <summary>승인시간</summary>
-    public string? TransTime { get; private set; }
-
-    /// <summary>카드사 코드</summary>
-    public string? CardCode { get; private set; }
-
-    /// <summary>카드사 이름</summary>
-    public string? CardName { get; private set; }
-
-    /// <summary>카드번호 (마스킹)</summary>
-    public string? CardNo { get; private set; }
-
-    /// <summary>할부 개월 수 (0=일시불)</summary>
-    public byte? InstallmentMonths { get; private set; }
-
-    /// <summary>승인번호</summary>
-    public string? ApproveNo { get; private set; }
-
-    // ── 휴대폰 응답 ─────────────────────────────────────────────────
-
-    /// <summary>결제 승인 일시</summary>
-    public string? ApprovalDateTime { get; private set; }
-
-    /// <summary>정기 결제 시 사용하는 키</summary>
-    public string? AuthKey { get; private set; }
-
-    // ── 계좌이체 응답 ────────────────────────────────────────────────
-
-    /// <summary>출금계좌 뒷자리</summary>
-    public string? AccountNumber { get; private set; }
-
-    /// <summary>은행 코드</summary>
-    public string? BankCode { get; private set; }
-
-    /// <summary>구매자 ID</summary>
-    public string? UserId { get; private set; }
-
-    /// <summary>구매자 이메일</summary>
-    public string? UserEmail { get; private set; }
-
-    // ── 가상계좌 응답 ────────────────────────────────────────────────
-
-    /// <summary>입금은행 이름</summary>
-    public string? BankName { get; private set; }
-
-    /// <summary>입금 마감 날짜 (YYYYMMDD)</summary>
-    public string? ExpireDate { get; private set; }
-
-    /// <summary>입금 마감 시간</summary>
-    public string? ExpireTime { get; private set; }
-
-    /// <summary>가상계좌 번호</summary>
-    public string? VirtualAccountNumber { get; private set; }
-
-    /// <summary>현금영수증 설정 유무</summary>
-    public string? UseCashReceipt { get; private set; }
-
-    public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
-
-    public DateTime? UpdatedAt { get; private set; }
-
-    private DanalConfirm() { }
-
-    /// <summary>승인 요청 시 생성 (응답 전)</summary>
-    public static DanalConfirm CreateRequest(
-        int memberID,
-        int paymentOrderID,
-        PaymentMethod method,
-        string transactionID,
-        string orderID,
-        int amount,
-        string merchantID,
-        string? certificateToken = null
-    ) {
-        return new DanalConfirm
-        {
-            MemberID = memberID,
-            PaymentOrderID = paymentOrderID,
-            Method = method,
-            TransactionID = transactionID,
-            OrderID = orderID,
-            Amount = amount,
-            MerchantID = merchantID,
-            CertificateToken = certificateToken
-        };
-    }
-
-    /// <summary>승인 응답 수신 시 업데이트</summary>
-    public void SetResponse(
-        string code, string message, string? orderName = null,
-        int? totalAmount = null, int? discountAmount = null, string? userName = null,
-        string? transDate = null, string? transTime = null,
-        string? cardCode = null, string? cardName = null, string? cardNo = null,
-        byte? installmentMonths = null, string? approveNo = null,
-        string? approvalDateTime = null, string? authKey = null,
-        string? accountNumber = null, string? bankCode = null, string? userId = null, string? userEmail = null,
-        string? bankName = null, string? expireDate = null, string? expireTime = null,
-        string? virtualAccountNumber = null, string? useCashReceipt = null
-    ) {
-        ResponseCode = code;
-        ResponseMessage = message;
-        OrderName = orderName;
-        TotalAmount = totalAmount;
-        DiscountAmount = discountAmount;
-        UserName = userName;
-        TransDate = transDate;
-        TransTime = transTime;
-        CardCode = cardCode;
-        CardName = cardName;
-        CardNo = cardNo;
-        InstallmentMonths = installmentMonths;
-        ApproveNo = approveNo;
-        ApprovalDateTime = approvalDateTime;
-        AuthKey = authKey;
-        AccountNumber = accountNumber;
-        BankCode = bankCode;
-        UserId = userId;
-        UserEmail = userEmail;
-        BankName = bankName;
-        ExpireDate = expireDate;
-        ExpireTime = expireTime;
-        VirtualAccountNumber = virtualAccountNumber;
-        UseCashReceipt = useCashReceipt;
-        UpdatedAt = DateTime.UtcNow;
-    }
-
-    /// <summary>
-    /// 재승인(대사 재확인) 시 기존 요청 행을 재사용한다. TransactionID/OrderID unique 인덱스 충돌을 피하려
-    /// 새 행을 만들지 않고, 거래번호를 갱신하고 이전 응답을 초기화해 "요청 후 응답 미수신" 상태로 되돌린다. (Phase 1.5)
-    /// </summary>
-    public void PrepareRetry(string transactionID)
-    {
-        TransactionID = transactionID;
-        ResponseCode = null;
-        ResponseMessage = null;
-        UpdatedAt = DateTime.UtcNow;
-    }
-
-    public bool IsSuccess => ResponseCode == "0000";
-}

+ 0 - 7
Domain/Entities/Payments/Danal/ValueObject/DanalCancelType.cs

@@ -1,7 +0,0 @@
-namespace Domain.Entities.Payments.Danal.ValueObject;
-
-public enum DanalCancelType
-{
-    Full = 1,
-    Partial = 2
-}

+ 0 - 8
Domain/Entities/Payments/Danal/ValueObject/DanalLogType.cs

@@ -1,8 +0,0 @@
-namespace Domain.Entities.Payments.Danal.ValueObject;
-
-public enum DanalLogType
-{
-    Failed = 1,
-    Error = 2,
-    Webhook = 3
-}

+ 113 - 0
Domain/Entities/Payments/Toss/TossCancel.cs

@@ -0,0 +1,113 @@
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using Domain.Entities.Members;
+
+namespace Domain.Entities.Payments.Toss;
+
+/// <summary>
+/// 토스페이먼츠 결제 취소 요청 + 응답 통합
+/// 전체 취소 / 부분 취소 모두 지원
+/// </summary>
+public class TossCancel
+{
+    [ForeignKey(nameof(MemberID))]
+    public virtual Member? Member { get; private set; }
+
+    [ForeignKey(nameof(PaymentOrderID))]
+    public virtual PaymentOrder? PaymentOrder { get; private set; }
+
+    [Key]
+    public int ID { get; private set; }
+
+    public int MemberID { get; private set; }
+
+    public int PaymentOrderID { get; private set; }
+
+    /// <summary>원거래 결제 키 (취소 대상 paymentKey)</summary>
+    public string PaymentKey { get; private set; } = default!;
+
+    /// <summary>가맹점 주문번호</summary>
+    public string OrderID { get; private set; } = default!;
+
+    /// <summary>취소 요청 금액</summary>
+    public int CancelAmount { get; private set; }
+
+    /// <summary>취소 사유 (Toss cancelReason — 최대 200자)</summary>
+    public string CancelReason { get; private set; } = default!;
+
+    /// <summary>취소 요청자 (admin / system / member / webhook)</summary>
+    public string? CancelRequester { get; private set; }
+
+    // ── 응답 필드 ────────────────────────────────────────────────────
+
+    /// <summary>응답 코드 (SUCCESS = 성공, 그 외 = Toss 에러 코드/EXCEPTION)</summary>
+    public string? ResponseCode { get; private set; }
+
+    /// <summary>응답 메시지 (실패 사유)</summary>
+    public string? ResponseMessage { get; private set; }
+
+    /// <summary>취소 거래 키 (Toss cancels[].transactionKey)</summary>
+    public string? TransactionKey { get; private set; }
+
+    /// <summary>취소 후 Payment.status (CANCELED / PARTIAL_CANCELED)</summary>
+    public string? Status { get; private set; }
+
+    /// <summary>취소 일시 (UTC)</summary>
+    public DateTime? CanceledAt { get; private set; }
+
+    /// <summary>Toss Payment 객체 원문 JSON — 대사/CS 용</summary>
+    public string? RawResponse { get; private set; }
+
+    public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
+
+    public DateTime? UpdatedAt { get; private set; }
+
+    private TossCancel() { }
+
+    public static TossCancel CreateRequest(
+        int memberID,
+        int paymentOrderID,
+        string paymentKey,
+        string orderID,
+        int cancelAmount,
+        string cancelReason,
+        string? cancelRequester = null
+    ) {
+        return new TossCancel
+        {
+            MemberID = memberID,
+            PaymentOrderID = paymentOrderID,
+            PaymentKey = paymentKey,
+            OrderID = orderID,
+            CancelAmount = cancelAmount,
+            CancelReason = cancelReason.Length > 200 ? cancelReason[..200] : cancelReason,
+            CancelRequester = cancelRequester
+        };
+    }
+
+    /// <summary>취소 성공 응답 수신 시 업데이트</summary>
+    public void SetResponse(
+        string status,
+        string? transactionKey = null,
+        DateTime? canceledAt = null,
+        string? rawResponse = null
+    ) {
+        ResponseCode = "SUCCESS";
+        ResponseMessage = null;
+        Status = status;
+        TransactionKey = transactionKey;
+        CanceledAt = canceledAt;
+        RawResponse = rawResponse;
+        UpdatedAt = DateTime.UtcNow;
+    }
+
+    /// <summary>취소 실패/예외 시 업데이트</summary>
+    public void SetError(string code, string message)
+    {
+        ResponseCode = code;
+        ResponseMessage = message.Length > 500 ? message[..500] : message;
+        UpdatedAt = DateTime.UtcNow;
+    }
+
+    public bool IsSuccess => ResponseCode == "SUCCESS";
+}

+ 126 - 0
Domain/Entities/Payments/Toss/TossConfirm.cs

@@ -0,0 +1,126 @@
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using Domain.Entities.Members;
+
+namespace Domain.Entities.Payments.Toss;
+
+/// <summary>
+/// 토스페이먼츠 결제 승인 요청 + 응답 통합
+/// confirm API 호출 시 요청 데이터를 먼저 저장하고, 응답 수신 시 업데이트
+/// </summary>
+public class TossConfirm
+{
+    [ForeignKey(nameof(MemberID))]
+    public virtual Member? Member { get; private set; }
+
+    [ForeignKey(nameof(PaymentOrderID))]
+    public virtual PaymentOrder? PaymentOrder { get; private set; }
+
+    [Key]
+    public int ID { get; private set; }
+
+    public int MemberID { get; private set; }
+
+    public int PaymentOrderID { get; private set; }
+
+    /// <summary>토스 결제 키 (unique — 취소·조회 API 호출에 필요)</summary>
+    public string PaymentKey { get; private set; } = default!;
+
+    /// <summary>가맹점 주문번호</summary>
+    public string OrderID { get; private set; } = default!;
+
+    /// <summary>승인 요청 금액 (서버 저장 주문 금액)</summary>
+    public int Amount { get; private set; }
+
+    // ── 응답 필드 (승인 완료/실패 시 업데이트) ──────────────────────
+
+    /// <summary>응답 코드 (SUCCESS = 성공, 그 외 = Toss 에러 코드/EXCEPTION)</summary>
+    public string? ResponseCode { get; private set; }
+
+    /// <summary>응답 메시지 (실패 사유)</summary>
+    public string? ResponseMessage { get; private set; }
+
+    /// <summary>Toss Payment.status (DONE / WAITING_FOR_DEPOSIT / CANCELED / ABORTED / EXPIRED ...)</summary>
+    public string? Status { get; private set; }
+
+    /// <summary>결제수단 (Toss method 문자열 — 카드/가상계좌/간편결제 등)</summary>
+    public string? Method { get; private set; }
+
+    /// <summary>최종 승인 금액 (Toss totalAmount)</summary>
+    public int? TotalAmount { get; private set; }
+
+    /// <summary>승인 일시 (UTC)</summary>
+    public DateTime? ApprovedAt { get; private set; }
+
+    /// <summary>영수증 URL (Toss receipt.url)</summary>
+    public string? ReceiptUrl { get; private set; }
+
+    /// <summary>Toss Payment 객체 원문 JSON — 대사/CS 용</summary>
+    public string? RawResponse { get; private set; }
+
+    public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
+
+    public DateTime? UpdatedAt { get; private set; }
+
+    private TossConfirm() { }
+
+    /// <summary>승인 요청 시 생성 (응답 전)</summary>
+    public static TossConfirm CreateRequest(
+        int memberID,
+        int paymentOrderID,
+        string paymentKey,
+        string orderID,
+        int amount
+    ) {
+        return new TossConfirm
+        {
+            MemberID = memberID,
+            PaymentOrderID = paymentOrderID,
+            PaymentKey = paymentKey,
+            OrderID = orderID,
+            Amount = amount
+        };
+    }
+
+    /// <summary>승인 성공 응답 수신 시 업데이트</summary>
+    public void SetResponse(
+        string status,
+        string? method = null,
+        int? totalAmount = null,
+        DateTime? approvedAt = null,
+        string? receiptUrl = null,
+        string? rawResponse = null
+    ) {
+        ResponseCode = "SUCCESS";
+        ResponseMessage = null;
+        Status = status;
+        Method = method;
+        TotalAmount = totalAmount;
+        ApprovedAt = approvedAt;
+        ReceiptUrl = receiptUrl;
+        RawResponse = rawResponse;
+        UpdatedAt = DateTime.UtcNow;
+    }
+
+    /// <summary>승인 실패/예외 시 업데이트</summary>
+    public void SetError(string code, string message)
+    {
+        ResponseCode = code;
+        ResponseMessage = message.Length > 500 ? message[..500] : message;
+        UpdatedAt = DateTime.UtcNow;
+    }
+
+    /// <summary>
+    /// 재승인(대사 재확인) 시 기존 요청 행을 재사용한다. PaymentKey/OrderID unique 인덱스 충돌을 피하려
+    /// 새 행을 만들지 않고, 결제 키를 갱신하고 이전 응답을 초기화해 "요청 후 응답 미수신" 상태로 되돌린다. (Phase 1.5 승계)
+    /// </summary>
+    public void PrepareRetry(string paymentKey)
+    {
+        PaymentKey = paymentKey;
+        ResponseCode = null;
+        ResponseMessage = null;
+        UpdatedAt = DateTime.UtcNow;
+    }
+
+    public bool IsSuccess => ResponseCode == "SUCCESS";
+}

+ 18 - 17
Domain/Entities/Payments/Danal/DanalLog.cs → Domain/Entities/Payments/Toss/TossLog.cs

@@ -1,14 +1,15 @@
 using System.ComponentModel.DataAnnotations;
 using System.ComponentModel.DataAnnotations.Schema;
 using Domain.Entities.Members;
-using Domain.Entities.Payments.Danal.ValueObject;
+using Domain.Entities.Payments.Toss.ValueObject;
 
-namespace Domain.Entities.Payments.Danal;
+namespace Domain.Entities.Payments.Toss;
 
 /// <summary>
-/// 다날 결제 에러 / 실패 / 가상계좌 웹훅 통합 로그
+/// 토스페이먼츠 결제 에러 / 실패 / 웹훅 / 대사 통합 로그
+/// 웹훅은 회원을 특정하지 못할 수 있어 MemberID 는 nullable.
 /// </summary>
-public class DanalLog
+public class TossLog
 {
     [ForeignKey(nameof(MemberID))]
     public virtual Member? Member { get; private set; }
@@ -16,10 +17,10 @@ public class DanalLog
     [Key]
     public int ID { get; private set; }
 
-    public int MemberID { get; private set; }
+    public int? MemberID { get; private set; }
 
-    /// <summary>로그 유형 (Failed, Error, Webhook)</summary>
-    public DanalLogType LogType { get; private set; }
+    /// <summary>로그 유형 (Failed, Error, Webhook, Reconcile)</summary>
+    public TossLogType LogType { get; private set; }
 
     /// <summary>결과/에러 코드</summary>
     public string Code { get; private set; } = default!;
@@ -27,8 +28,8 @@ public class DanalLog
     /// <summary>결과/에러 메시지</summary>
     public string Message { get; private set; } = default!;
 
-    /// <summary>다날 거래번호 (있는 경우)</summary>
-    public string? TransactionID { get; private set; }
+    /// <summary>토스 결제 키 (있는 경우)</summary>
+    public string? PaymentKey { get; private set; }
 
     /// <summary>가맹점 주문번호 (있는 경우)</summary>
     public string? OrderID { get; private set; }
@@ -38,24 +39,24 @@ public class DanalLog
 
     public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
 
-    private DanalLog() { }
+    private TossLog() { }
 
-    public static DanalLog Create(
-        int memberID,
-        DanalLogType logType,
+    public static TossLog Create(
+        int? memberID,
+        TossLogType logType,
         string code,
         string message,
-        string? transactionID = null,
+        string? paymentKey = null,
         string? orderID = null,
         string? extraData = null
     ) {
-        return new DanalLog
+        return new TossLog
         {
             MemberID = memberID,
             LogType = logType,
             Code = code,
-            Message = message,
-            TransactionID = transactionID,
+            Message = message.Length > 500 ? message[..500] : message,
+            PaymentKey = paymentKey,
             OrderID = orderID,
             ExtraData = extraData
         };

+ 9 - 0
Domain/Entities/Payments/Toss/ValueObject/TossLogType.cs

@@ -0,0 +1,9 @@
+namespace Domain.Entities.Payments.Toss.ValueObject;
+
+public enum TossLogType
+{
+    Failed = 1,
+    Error = 2,
+    Webhook = 3,
+    Reconcile = 4
+}

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません